Skip to content

Commit ec0593c

Browse files
ci: stage exhaustive model bundle hardening validation
1 parent 5d46ac3 commit ec0593c

16 files changed

Lines changed: 886 additions & 1 deletion
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
import { compileContext } from './context.mjs';
5+
import { budgetReport, runProvider } from './provider.mjs';
6+
import { databaseStats, indexRepository, ingestModels } from './indexer.mjs';
7+
import { auditRepository } from './quality.mjs';
8+
import { ensureDir, fileSha256, kitVersion, loadConfig, now, projectPaths, readJson, rel, sha256, slug, sourceSnapshot, stableHash, updateStage, writeJson } from './core.mjs';
9+
import { evidenceFromAliases, normalizeSemanticDocument, normalizeSourceModelRefs, semanticMetadata } from './semantic.mjs';
10+
import { extractModelObjects, mergeModelObjects, safeModelPlaceholder } from './model-bundle.mjs';
11+
12+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
13+
14+
function prompt(root, name, vars = {}) {
15+
const override = path.join(root, '.docgen', 'prompts', name); const fallback = path.resolve(moduleDir, '..', 'prompts', name);
16+
let text = fs.readFileSync(fs.existsSync(override) ? override : fallback, 'utf8');
17+
for (const [key, value] of Object.entries(vars)) text = text.replaceAll(`{{${key}}}`, String(value));
18+
return text;
19+
}
20+
function state(root) { return readJson(projectPaths(root).state, { schemaVersion: '2.0', kitVersion, stages: {}, pages: {} }); }
21+
function writeState(root, next) { writeJson(projectPaths(root).state, { ...next, schemaVersion: '2.0', kitVersion, updatedAt: now() }); }
22+
function stageCurrent(root, stage, inputHash, outputs = []) { const current = state(root).stages?.[stage]; return current?.status === 'completed' && current.inputHash === inputHash && outputs.every((file) => fs.existsSync(file)); }
23+
function completeStage(root, stage, inputHash, details = {}) { const next = state(root); next.stages ??= {}; next.stages[stage] = { status: 'completed', completedAt: now(), inputHash, ...details }; writeState(root, next); }
24+
function failStage(root, stage, error, details = {}) { const next = state(root); next.stages ??= {}; next.stages[stage] = { ...(next.stages[stage] ?? {}), status: 'failed', failedAt: now(), error: error.message, ...details }; writeState(root, next); }
25+
function pageState(root, id) { return state(root).pages?.[id] ?? {}; }
26+
function updatePage(root, id, patch) { const next = state(root); next.pages ??= {}; next.pages[id] = { ...(next.pages[id] ?? {}), ...patch, updatedAt: now() }; writeState(root, next); }
27+
function modelPath(root, name) { return path.join(projectPaths(root).model, `${name}.json`); }
28+
function tracePath(root, page) { return path.join(projectPaths(root).traceability, 'pages', `${page.id}.json`); }
29+
function validateJson(file) { const value = readJson(file); if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Invalid JSON object: ${file}`); return value; }
30+
function artifactStamp(file) {
31+
if (!fs.existsSync(file)) return null;
32+
const stat = fs.statSync(file, { bigint: true });
33+
return { hash: fileSha256(file), mtimeNs: String(stat.mtimeNs) };
34+
}
35+
function artifactChanged(file, before) {
36+
const after = artifactStamp(file); if (!after) return false; if (!before) return true;
37+
return after.hash !== before.hash || after.mtimeNs !== before.mtimeNs;
38+
}
39+
function phase(name, position, total) { console.log(`[docgen] phase ${position}/${total} ${name.toUpperCase()}`); }
40+
41+
export function index(root, options = {}) {
42+
updateStage(root, 'index', 'running');
43+
try { const result = indexRepository(root, options); completeStage(root, 'index', result.inventoryFingerprint, result); return result; }
44+
catch (error) { failStage(root, 'index', error); throw error; }
45+
}
46+
47+
function validateBundleJson(file) {
48+
const value = readJson(file);
49+
if (!value || typeof value !== 'object') throw new Error(`Invalid model bundle JSON: ${file}`);
50+
return value;
51+
}
52+
53+
function commitModelObjects(root, stage, names, objects) {
54+
const modelDir = projectPaths(root).model;
55+
const transaction = `${stage}-${process.pid}-${Date.now()}`;
56+
const stagingDir = path.join(modelDir, `.staging-${transaction}`);
57+
const backupDir = path.join(modelDir, `.backup-${transaction}`);
58+
ensureDir(stagingDir); ensureDir(backupDir);
59+
const existed = new Set();
60+
try {
61+
for (const name of names) {
62+
if (!Object.hasOwn(objects, name)) throw new Error(`${stage}: unresolved model object before commit: ${name}`);
63+
const value = structuredClone(objects[name]); normalizeSemanticDocument(value);
64+
const staged = path.join(stagingDir, `${name}.json`);
65+
writeJson(staged, { schemaVersion: '2.0', generatedAt: now(), ...value }); validateJson(staged);
66+
}
67+
for (const name of names) {
68+
const final = modelPath(root, name); const backup = path.join(backupDir, `${name}.json`);
69+
if (fs.existsSync(final)) { fs.copyFileSync(final, backup); existed.add(name); }
70+
}
71+
for (const name of names) fs.copyFileSync(path.join(stagingDir, `${name}.json`), modelPath(root, name));
72+
} catch (error) {
73+
for (const name of names) {
74+
const final = modelPath(root, name); const backup = path.join(backupDir, `${name}.json`);
75+
if (existed.has(name) && fs.existsSync(backup)) fs.copyFileSync(backup, final);
76+
else fs.rmSync(final, { force: true });
77+
}
78+
throw error;
79+
} finally {
80+
fs.rmSync(stagingDir, { recursive: true, force: true }); fs.rmSync(backupDir, { recursive: true, force: true });
81+
}
82+
}
83+
84+
async function synthesizeBundle(root, stage, names, query) {
85+
const paths = projectPaths(root); const config = loadConfig(root);
86+
const context = compileContext(root, { stage, query, target: stage, metadata: { expectedModels: names } });
87+
const output = path.join(paths.model, `${stage}-bundle.json`); const inputHash = context.payload.inputHash; const outputs = names.map((name) => modelPath(root, name));
88+
if (stageCurrent(root, stage, inputHash, outputs)) return { skipped: true, inputHash };
89+
ensureDir(paths.model); updateStage(root, stage, 'running', { inputHash, contextId: context.payload.id });
90+
const temporaryArtifacts = new Set([output]);
91+
const renderPrompt = (expected, target, repair = false) => {
92+
const base = prompt(root, stage === 'modelCore' ? 'model-core.md' : 'model-enterprise.md', { CONTEXT_PATH: rel(root, context.file), OUTPUT_PATH: rel(root, target), MODEL_NAMES: JSON.stringify(expected) });
93+
if (!repair) return base;
94+
return `${base}\n\nRecovery request: write only the requested model object(s). For a single requested name, a direct object or a named wrapper is accepted. Do not repeat unrelated models.`;
95+
};
96+
async function requestBundle(expected, target, repair = false) {
97+
temporaryArtifacts.add(target); const before = artifactStamp(target); let extraction = null;
98+
const inspect = () => {
99+
if (!artifactChanged(target, before)) return null;
100+
extraction = extractModelObjects(validateBundleJson(target), expected);
101+
return extraction;
102+
};
103+
const accept = () => Boolean(Object.keys(inspect()?.objects ?? {}).length);
104+
const provider = await runProvider(
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
root, { stage, target: repair ? `${stage}:repair:${expected.join(',')}` : stage, prompt: renderPrompt(expected, target, repair), acceptArtifacts: accept });
2+
if (!extraction) {
3+
if (!artifactChanged(target, before)) throw new Error(`${stage}: provider completed without writing a fresh bundle artifact`);
4+
extraction = extractModelObjects(validateBundleJson(target), expected);
5+
}
6+
return { provider, ...extraction };
7+
}
8+
9+
const resolved = {}; const diagnostics = []; const recoveryErrors = []; let recovered = false; let providerCalls = 0;
10+
const merge = (result) => {
11+
providerCalls++; recovered ||= result.provider?.recovered === true; mergeModelObjects(resolved, result); diagnostics.push(...(result.diagnostics ?? []));
12+
};
13+
try {
14+
const first = await requestBundle(names, output); merge(first);
15+
if (!Object.keys(resolved).length) throw new Error(`${stage}: provider bundle contained no recognizable requested model objects`);
16+
17+
let missing = names.filter((name) => !Object.hasOwn(resolved, name));
18+
if (missing.length) {
19+
console.warn(`[docgen] ${stage} REPAIR | bundle omitted ${missing.join(', ')}; requesting only unresolved model object(s).`);
20+
const repairOutput = path.join(paths.model, `${stage}-repair-${sha256(missing.join('|')).slice(0, 10)}-bundle.json`);
21+
try { merge(await requestBundle(missing, repairOutput, true)); }
22+
catch (error) { recoveryErrors.push(`batch repair: ${error.message}`); console.warn(`[docgen] ${stage} REPAIR WARNING | ${error.message}`); }
23+
}
24+
25+
missing = names.filter((name) => !Object.hasOwn(resolved, name));
26+
for (const name of missing) {
27+
const singleOutput = path.join(paths.model, `${stage}-repair-${sha256(name).slice(0, 10)}-${name}.json`);
28+
console.warn(`[docgen] ${stage} OBJECT REPAIR | requesting ${name} independently.`);
29+
try { merge(await requestBundle([name], singleOutput, true)); }
30+
catch (error) { recoveryErrors.push(`${name}: ${error.message}`); console.warn(`[docgen] ${stage} OBJECT REPAIR WARNING | ${name}: ${error.message}`); }
31+
}
32+
33+
missing = names.filter((name) => !Object.hasOwn(resolved, name)); const degradedModels = [];
34+
const missingPolicy = String(config.execution?.missingModelPolicy ?? 'placeholder').trim().toLowerCase();
35+
if (missing.length && missingPolicy === 'fail') throw new Error(`Model recovery exhausted for: ${missing.join(', ')}`);
36+
for (const name of missing) {
37+
resolved[name] = safeModelPlaceholder(name, `${stage} provider output omitted ${name} after a batch repair and an independent object repair.`);
38+
degradedModels.push(name);
39+
console.warn(`[docgen] ${stage} DEGRADED | ${name} was replaced with an explicit UNKNOWN placeholder; no repository fact was invented.`);
40+
}
41+
42+
commitModelObjects(root, stage, names, resolved);
43+
completeStage(root, stage, inputHash, { models: names, degradedModels, recoveryErrors, providerCalls, contextId: context.payload.id, contextTokens: context.payload.estimatedTokens, recovered });
44+
return { skipped: false, recovered, degradedModels, providerCalls, inputHash };
45+
} catch (error) {
46+
failStage(root, stage, error, { inputHash, contextId: context.payload.id, recoveryErrors }); throw error;
47+
} finally {
48+
for (const file of temporaryArtifacts) fs.rmSync(file, { force: true });
49+
}
50+
}
51+
52+
export async function model(root, { skipIndex = false } = {}) {
53+
if (!skipIndex) index(root);
54+
await synthesizeBundle(root, 'modelCore', ['system', 'business', 'flows', 'catalogs'], 'repository structure architecture components modules symbols interfaces contracts dependencies behavior domain rules states flows data and automation; detect technologies from evidence and do not assume a language or framework');
55+
ingestModels(root);
56+
await synthesizeBundle(root, 'modelEnterprise', ['security', 'operations', 'testing', 'data-governance', 'decisions', 'configuration', 'change-impact', 'ownership'], 'security operations testing governance configuration ownership decisions change impact reliability consistency and compatibility across any language framework runtime or deployment model');
57+
return ingestModels(root);
58+
}
59+
60+
function canonicalPagePath(page, category, id) {
61+
let value = String(page.path ?? '').replaceAll('\\', '/').replace(/^\/+/, '');
62+
if (!value) value = `docs/${category}/${slug(page.slug ?? page.title ?? id)}.md`;
63+
if (!value.startsWith('docs/')) value = `docs/${value}`;
64+
if (!value.endsWith('.md')) value += '.md';
65+
return value;
66+
}
67+
function normalizePage(page, indexValue) {
68+
const id = slug(page.id ?? page.title ?? `page-${indexValue + 1}`); const category = slug(page.category ?? page.type ?? 'overview');
69+
return { id, title: String(page.title ?? id.replaceAll('-', ' ')), summary: String(page.summary ?? page.purpose ?? ''), category, path: canonicalPagePath(page, category, id), mode: String(page.mode ?? 'explanation'), type: String(page.type ?? 'explanation'), order: Number(page.order ?? indexValue + 1), audience: Array.isArray(page.audience) ? page.audience : ['engineer'], coverageTags: Array.isArray(page.coverageTags) ? page.coverageTags.map(String) : [], query: String(page.query ?? [page.title, page.summary, ...(page.coverageTags ?? [])].join(' ')), requiredSections: Array.isArray(page.requiredSections) ? page.requiredSections.map(String) : [], risk: String(page.risk ?? 'normal'), relatedPages: Array.isArray(page.relatedPages) ? page.relatedPages.map(String) : [] };
70+
}
71+
function validateManifest(root) {
72+
const paths = projectPaths(root); const manifest = readJson(paths.plan); if (!Array.isArray(manifest.pages) || !manifest.pages.length) throw new Error('Manifest must contain non-empty pages[].');
73+
manifest.pages = manifest.pages.map(normalizePage); const ids = new Set(); const files = new Set(); const max = Number(loadConfig(root).execution?.maxPlannedPages ?? 30);
74+
if (max > 0 && manifest.pages.length > max) throw new Error(`Manifest contains ${manifest.pages.length} pages, above execution.maxPlannedPages=${max}. Consolidate duplicate user intents or raise the limit explicitly.`);
75+
for (const page of manifest.pages) {
76+
if (ids.has(page.id)) throw new Error(`Duplicate page id: ${page.id}`); if (files.has(page.path)) throw new Error(`Duplicate page path: ${page.path}`);
77+
if (!page.title.trim() || !page.summary.trim()) throw new Error(`Page ${page.id} requires non-empty title and summary.`);
78+
ids.add(page.id); files.add(page.path);
79+
}
80+
manifest.schemaVersion = '2.0'; manifest.generatedAt ??= now(); writeJson(paths.plan, manifest); return manifest;
81+
}
82+
83+
export async function plan(root) {
84+
const paths = projectPaths(root); ingestModels(root);
85+
const context = compileContext(root, { stage: 'plan', query: 'documentation information architecture onboarding reference explanation tutorial operations decisions behavior interfaces dependencies configuration and change; select only concerns evidenced by this repository regardless of technology stack', targe

0 commit comments

Comments
 (0)