diff --git a/global-template/docgen/CONTRACTS.md b/global-template/docgen/CONTRACTS.md index a0c992f..2554bd8 100644 --- a/global-template/docgen/CONTRACTS.md +++ b/global-template/docgen/CONTRACTS.md @@ -74,6 +74,19 @@ index -> modelCore -> modelEnterprise -> plan -> generate -> audit -> publish 5. bounded recovery retries only the unresolved subset; 6. a page becomes `completed` only after Markdown and traceability validation succeeds. +## Model bundle recovery contract + +Model synthesis treats provider output as an untrusted transport shape. The orchestrator: + +1. extracts requested models from exact keys, normalized key variants, nested wrappers, descriptor arrays, JSON-string payloads, and direct singleton repair objects; +2. salvages every recognized object from a partial bundle without writing partial model state; +3. performs at most one batch repair for unresolved names, then at most one independent request per unresolved model; +4. commits the reconciled model set only after every requested name is resolved; +5. defaults unresolved names to an explicit `UNKNOWN` placeholder with no evidence and records them in `state.stages..degradedModels`; +6. supports `execution.missingModelPolicy = "fail"` for environments that prefer a hard gate after bounded recovery. + +A completed degraded stage is reusable on `docgen resume`, preventing an unbounded provider retry loop. `docgen status` exposes degraded model names in `summary.degradedModels`. + ## Correctness gate The deterministic audit validates, at minimum: diff --git a/global-template/docgen/lib/audit-guard.mjs b/global-template/docgen/lib/audit-guard.mjs new file mode 100644 index 0000000..594c6c6 --- /dev/null +++ b/global-template/docgen/lib/audit-guard.mjs @@ -0,0 +1,316 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { auditRepository } from './quality.mjs'; +import { + ensureDir, + loadConfig, + now, + posix, + projectPaths, + readJson, + sha256, + updateStage, + writeJson +} from './core.mjs'; +import { + evidenceFromAliases, + normalizeClassification, + normalizeConfidence, + normalizeEvidence, + normalizeSourceModelRefs +} from './semantic.mjs'; + +const EVIDENCE_ALIAS_KEYS = ['evidenceRefs', 'sources', 'sourceRefs', 'citations', 'references']; + +function safeRelative(value) { + const raw = posix(String(value ?? '').trim()).replace(/^\.\//, ''); + if (!raw || path.posix.isAbsolute(raw) || /^[a-z]:\//i.test(raw)) return null; + const normalized = path.posix.normalize(raw); + if (normalized === '..' || normalized.startsWith('../')) return null; + return normalized; +} + +function inventoryContext(root) { + const inventory = readJson(projectPaths(root).inventory, { files: [], excluded: [] }); + return { + inventory, + files: new Map((inventory.files ?? []).map((item) => [safeRelative(item.path), item]).filter(([name]) => name)) + }; +} + +function sourceRecord(root, relative, inventory, cache) { + if (cache.has(relative)) return cache.get(relative); + const indexed = inventory.files.get(relative); + if (!indexed) { + const result = { usable: false, stale: false, reason: 'outside-inventory' }; + cache.set(relative, result); + return result; + } + const file = path.join(root, relative); + if (!fs.existsSync(file)) { + const result = { usable: false, stale: true, reason: 'missing-indexed-source' }; + cache.set(relative, result); + return result; + } + const buffer = fs.readFileSync(file); + const text = buffer.toString('utf8'); + const hash = sha256(buffer); + const result = { + usable: !indexed.hash || indexed.hash === hash, + stale: Boolean(indexed.hash && indexed.hash !== hash), + reason: indexed.hash && indexed.hash !== hash ? 'source-changed' : null, + lines: text.split(/\r?\n/).length + }; + cache.set(relative, result); + return result; +} + +function canonicalEvidence(root, raw, inventory, cache) { + const normalized = normalizeEvidence(raw)[0]; + const relative = safeRelative(normalized?.path); + if (!relative) return null; + const source = sourceRecord(root, relative, inventory, cache); + if (source.stale) return { ...normalized, path: relative, __stale: true }; + if (!source.usable) return null; + const startLine = normalized.startLine; + const endLine = normalized.endLine ?? startLine; + if (startLine !== undefined) { + if (!Number.isInteger(startLine) || startLine < 1) return null; + if (!Number.isInteger(endLine) || endLine < startLine || endLine > source.lines) return null; + } + return { path: relative, ...(startLine ? { startLine, endLine } : {}) }; +} + +function evidenceKey(value) { + return `${value.path}\0${value.startLine ?? ''}\0${value.endLine ?? ''}`; +} + +function dedupeEvidence(values) { + const seen = new Set(); + return values.filter((value) => { + const key = evidenceKey(value); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function matchingAllowedEvidence(requested, allowed) { + const relative = safeRelative(requested?.path); + if (!relative) return null; + const candidates = allowed.filter((entry) => entry.path === relative && !entry.__stale); + if (!candidates.length) return null; + if (!requested.startLine) return candidates[0]; + return candidates.find((entry) => { + if (!entry.startLine) return true; + const end = entry.endLine ?? entry.startLine; + return requested.startLine >= entry.startLine && requested.startLine <= end; + }) ?? null; +} + +function removeEvidenceAliases(value) { + for (const key of EVIDENCE_ALIAS_KEYS) delete value[key]; +} + +function sanitizeSemanticObject(root, value, inventory, sourceCache) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + let changed = false; + if (value.id || value.name || value.statement) { + const before = JSON.stringify({ + classification: value.classification, + confidence: value.confidence, + evidence: evidenceFromAliases(value), + aliases: EVIDENCE_ALIAS_KEYS.map((key) => value[key]) + }); + const evidence = dedupeEvidence(evidenceFromAliases(value) + .map((entry) => canonicalEvidence(root, entry, inventory, sourceCache)) + .filter(Boolean)); + const rawClassification = value.classification ?? value.claimClassification ?? value.certainty; + const scalarClassification = rawClassification === undefined || typeof rawClassification === 'string' || typeof rawClassification === 'number'; + const requested = normalizeClassification(rawClassification); + const hasLineEvidence = evidence.some((entry) => entry.startLine && !entry.__stale); + const normalizedClassification = requested === 'FACT' && !hasLineEvidence ? 'INFERENCE' : requested; + // A field named `classification` can be a domain catalog rather than semantic + // metadata. Preserve arrays/objects and only normalize scalar metadata. + if (scalarClassification) value.classification = normalizedClassification; + value.confidence = normalizeConfidence(value.confidence ?? value.confidenceScore, normalizedClassification); + if (scalarClassification && normalizedClassification !== 'FACT') value.confidence = Math.min(value.confidence, 0.7); + value.evidence = evidence.map(({ __stale, ...entry }) => entry); + removeEvidenceAliases(value); + delete value.claimClassification; + delete value.certainty; + if (before !== JSON.stringify({ + classification: value.classification, + confidence: value.confidence, + evidence: value.evidence, + aliases: EVIDENCE_ALIAS_KEYS.map((key) => value[key]) + })) changed = true; + } + for (const [key, child] of Object.entries(value)) { + if (['evidence', 'sourceModelRefs', 'modelRefs', ...EVIDENCE_ALIAS_KEYS].includes(key)) continue; + if (Array.isArray(child)) { + for (const item of child) changed = sanitizeSemanticObject(root, item, inventory, sourceCache) || changed; + } else if (child && typeof child === 'object') { + changed = sanitizeSemanticObject(root, child, inventory, sourceCache) || changed; + } + } + return changed; +} + +function sanitizeModels(root, inventory, sourceCache) { + const directory = projectPaths(root).model; + if (!fs.existsSync(directory)) return { files: 0, changed: 0 }; + let files = 0; let changed = 0; + for (const name of fs.readdirSync(directory).filter((item) => item.endsWith('.json') && !item.endsWith('-bundle.json'))) { + const file = path.join(directory, name); + const document = readJson(file, null); + if (!document || typeof document !== 'object' || Array.isArray(document)) continue; + files++; + if (sanitizeSemanticObject(root, document, inventory, sourceCache)) { + writeJson(file, document); + changed++; + } + } + return { files, changed }; +} + +function contextEvidence(root, context, inventory, sourceCache) { + const values = []; + for (const item of context.modelItems ?? []) values.push(...evidenceFromAliases(item)); + for (const fact of context.facts ?? []) values.push({ + path: fact.path, + startLine: fact.metadata?.startLine ?? fact.line, + endLine: fact.metadata?.endLine ?? fact.line + }); + return dedupeEvidence(values + .map((entry) => canonicalEvidence(root, entry, inventory, sourceCache)) + .filter(Boolean)); +} + +function sanitizeTrace(root, page, inventory, sourceCache) { + const paths = projectPaths(root); + const traceFile = path.join(paths.traceability, 'pages', `${page.id}.json`); + if (!fs.existsSync(traceFile)) return { changed: false, droppedEvidence: 0, droppedRefs: 0, droppedClaims: 0 }; + const contextFile = path.join(paths.context, 'generate', `${page.id.replace(/[^a-z0-9_.-]+/gi, '-')}.json`); + if (!fs.existsSync(contextFile)) return { changed: false, droppedEvidence: 0, droppedRefs: 0, droppedClaims: 0 }; + const trace = readJson(traceFile, {}); + const context = readJson(contextFile, {}); + if (!Array.isArray(trace.claims)) return { changed: false, droppedEvidence: 0, droppedRefs: 0, droppedClaims: 0 }; + + const modelItems = new Map(); const aliases = new Map(); const perModel = new Map(); + for (const item of context.modelItems ?? []) { + modelItems.set(item.id, item); + const ordinal = (perModel.get(item.model) ?? 0) + 1; + perModel.set(item.model, ordinal); + aliases.set(`${item.model}:${ordinal}`, item.id); + } + const allowedEvidence = contextEvidence(root, context, inventory, sourceCache); + const ids = new Map(); const claims = []; + let droppedEvidence = 0; let droppedRefs = 0; let droppedClaims = 0; + + for (const rawClaim of trace.claims) { + const claim = rawClaim && typeof rawClaim === 'object' ? { ...rawClaim } : {}; + const baseId = String(claim.id ?? `${page.id}:claim-${claims.length + 1}`).trim() || `${page.id}:claim-${claims.length + 1}`; + const occurrence = (ids.get(baseId) ?? 0) + 1; ids.set(baseId, occurrence); + claim.id = occurrence === 1 ? baseId : `${baseId}-${occurrence}`; + + const requestedRefs = normalizeSourceModelRefs(claim.sourceModelRefs ?? claim.modelRefs).map((ref) => aliases.get(ref) ?? ref); + const refs = [...new Set(requestedRefs.filter((ref) => modelItems.has(ref)))]; + droppedRefs += requestedRefs.length - refs.length; + const inherited = refs.flatMap((ref) => evidenceFromAliases(modelItems.get(ref))) + .map((entry) => canonicalEvidence(root, entry, inventory, sourceCache)) + .filter(Boolean); + const requestedEvidence = evidenceFromAliases(claim); + const direct = requestedEvidence.map((entry) => matchingAllowedEvidence(entry, allowedEvidence)).filter(Boolean); + droppedEvidence += requestedEvidence.length - direct.length; + const evidence = dedupeEvidence([...direct, ...inherited]).filter((entry) => !entry.__stale); + const fallbackStatement = refs.map((ref) => { + const item = modelItems.get(ref); + return String(item?.statement ?? item?.payload?.statement ?? item?.name ?? '').trim(); + }).find(Boolean); + claim.statement = String(claim.statement ?? fallbackStatement ?? '').trim(); + if (!claim.statement) { droppedClaims++; continue; } + + const requestedClassification = normalizeClassification(claim.classification ?? claim.claimClassification ?? claim.certainty); + const hasLineEvidence = evidence.some((entry) => entry.startLine); + claim.classification = requestedClassification === 'FACT' && !hasLineEvidence ? 'INFERENCE' : requestedClassification; + claim.confidence = normalizeConfidence(claim.confidence ?? claim.confidenceScore, claim.classification); + if (claim.classification !== 'FACT') claim.confidence = Math.min(claim.confidence, 0.7); + claim.evidence = evidence.map(({ __stale, ...entry }) => entry); + claim.sourceModelRefs = refs; + delete claim.modelRefs; + delete claim.claimClassification; + delete claim.certainty; + removeEvidenceAliases(claim); + claims.push(claim); + } + + const before = JSON.stringify(trace.claims); + trace.claims = claims; + const changed = before !== JSON.stringify(claims); + if (changed) writeJson(traceFile, trace); + return { changed, droppedEvidence, droppedRefs, droppedClaims }; +} + +export function sanitizeAuditInputs(root, manifest) { + const inventory = inventoryContext(root); + const sourceCache = new Map(); + const models = sanitizeModels(root, inventory, sourceCache); + const traces = { files: 0, changed: 0, droppedEvidence: 0, droppedRefs: 0, droppedClaims: 0 }; + for (const page of manifest.pages ?? []) { + const result = sanitizeTrace(root, page, inventory, sourceCache); + traces.files++; + if (result.changed) traces.changed++; + traces.droppedEvidence += result.droppedEvidence; + traces.droppedRefs += result.droppedRefs; + traces.droppedClaims += result.droppedClaims; + } + return { models, traces }; +} + +function deterministicSummary(quality, sanitation) { + return { + schemaVersion: '2.0', + generatedAt: now(), + auditInputHash: quality.auditInputHash, + inventoryFingerprint: quality.inventoryFingerprint, + manifestHash: quality.manifestHash, + pages: quality.metrics.pages, + claims: quality.metrics.claims, + evidenceReferences: quality.metrics.evidenceReferences, + modelItems: quality.metrics.modelItems, + referencedModelItems: quality.metrics.referencedModelItems, + modelReferenceCoverage: quality.metrics.modelReferenceCoverage, + deterministicFailures: quality.errors.length, + deterministicWarnings: quality.warnings.length, + llmAuditedPages: 0, + highRiskFindings: 0, + llmSkippedReason: 'deterministic-fail-fast', + sanitation, + pass: false + }; +} + +export async function guardedAudit(root, baseAudit) { + const paths = projectPaths(root); + ensureDir(paths.audit); + const manifest = readJson(paths.plan); + updateStage(root, 'audit', 'running', { mode: 'deterministic-preflight' }); + try { + const sanitation = sanitizeAuditInputs(root, manifest); + const quality = auditRepository(root, manifest); + writeJson(path.join(paths.audit, 'deterministic.json'), quality); + if (!quality.pass) { + const summary = deterministicSummary(quality, sanitation); + writeJson(path.join(paths.audit, 'quality-summary.json'), summary); + const error = new Error(`Quality failed before LLM audit: deterministicFailures=${quality.errors.length}, highRiskFindings=0. No audit-provider tokens were spent. See .docgen/audit/deterministic.json.`); + updateStage(root, 'audit', 'failed', { error: error.message, inputHash: quality.auditInputHash, deterministicFailures: quality.errors.length, llmAuditSkipped: true, sanitation }); + throw error; + } + return await baseAudit(root); + } catch (error) { + const current = readJson(paths.state, { stages: {} }).stages?.audit; + if (current?.status !== 'failed') updateStage(root, 'audit', 'failed', { error: error.message }); + throw error; + } +} diff --git a/global-template/docgen/lib/audit-policy.mjs b/global-template/docgen/lib/audit-policy.mjs new file mode 100644 index 0000000..1aeac83 --- /dev/null +++ b/global-template/docgen/lib/audit-policy.mjs @@ -0,0 +1,42 @@ +export function auditLlmMode(config = {}) { + const configured = String(config.audit?.llmMode ?? '').trim().toLowerCase(); + if (['off', 'advisory', 'blocking'].includes(configured)) return configured; + if (config.audit?.blockOnLlmFindings === true) return 'blocking'; + return 'off'; +} + +export function deterministicOnlyAuditSummary(quality) { + return { + schemaVersion: '2.0', + generatedAt: new Date().toISOString(), + auditInputHash: quality.auditInputHash, + inventoryFingerprint: quality.inventoryFingerprint, + manifestHash: quality.manifestHash, + pages: quality.metrics.pages, + claims: quality.metrics.claims, + evidenceReferences: quality.metrics.evidenceReferences, + modelItems: quality.metrics.modelItems, + referencedModelItems: quality.metrics.referencedModelItems, + modelReferenceCoverage: quality.metrics.modelReferenceCoverage, + deterministicFailures: quality.errors.length, + deterministicWarnings: quality.warnings.length, + llmAuditedPages: 0, + highRiskFindings: 0, + llmSkippedReason: 'llm-audit-off', + llmFindingsBlocking: false, + pass: quality.pass + }; +} + +export function advisoryLlmAuditSummary(summary, config = {}) { + if (!summary || auditLlmMode(config) !== 'advisory') return null; + const deterministicFailures = Number(summary.deterministicFailures ?? 0); + const highRiskFindings = Number(summary.highRiskFindings ?? 0); + if (deterministicFailures !== 0 || highRiskFindings <= 0) return null; + return { + ...summary, + pass: true, + llmFindingsBlocking: false, + advisoryHighRiskFindings: highRiskFindings + }; +} diff --git a/global-template/docgen/lib/indexer.mjs b/global-template/docgen/lib/indexer.mjs index 2843fda..c83a107 100644 --- a/global-template/docgen/lib/indexer.mjs +++ b/global-template/docgen/lib/indexer.mjs @@ -2,7 +2,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { buildInventory } from './inventory.mjs'; -import { ensureDir, estimateTokens, now, projectPaths, readJson, sha256, stableHash } from './core.mjs'; +import { ensureDir, estimateTokens, now, projectPaths, readJson, sha256, stableHash, writeJson } from './core.mjs'; +import { normalizeSemanticDocument, semanticMetadata } from './semantic.mjs'; function lineNumber(text, offset) { return text.slice(0, offset).split('\n').length; } function snippet(text, start, radius = 4) { @@ -138,8 +139,8 @@ function walkItems(value, model, out, parent = '') { if (Array.isArray(value)) { for (const item of value) walkItems(item, model, out, parent); return; } if (!value || typeof value !== 'object') return; if (value.id || value.name || value.statement) { - const semanticId = String(value.id ?? sha256(`${model}\0${parent}\0${JSON.stringify(value)}`).slice(0, 24)); const id = `${model}:${semanticId}`; - out.push({ id, semanticId, model, kind: String(value.kind ?? parent ?? 'item'), name: String(value.name ?? value.title ?? semanticId), statement: String(value.statement ?? value.summary ?? value.description ?? ''), classification: String(value.classification ?? 'UNKNOWN'), confidence: Number(value.confidence ?? 0), evidence: value.evidence ?? [], payload: value }); + const semanticId = String(value.id ?? sha256(`${model}\0${parent}\0${JSON.stringify(value)}`).slice(0, 24)); const id = `${model}:${semanticId}`; const metadata = semanticMetadata(value); + out.push({ id, semanticId, model, kind: String(value.kind ?? parent ?? 'item'), name: String(value.name ?? value.title ?? semanticId), statement: String(value.statement ?? value.summary ?? value.description ?? ''), classification: metadata.classification, confidence: metadata.confidence, evidence: metadata.evidence, payload: value }); } for (const [key, child] of Object.entries(value)) if (!['evidence','sourceModelRefs'].includes(key)) walkItems(child, model, out, key); } @@ -152,7 +153,7 @@ export function ingestModels(root) { try { db.exec('DELETE FROM model_items; DELETE FROM model_fts;'); for (const name of files) { - const model = path.basename(name, '.json'); const items = []; walkItems(readJson(path.join(paths.model, name), {}), model, items); + const model = path.basename(name, '.json'); const file = path.join(paths.model, name); const document = readJson(file, {}); const before = stableHash(document); normalizeSemanticDocument(document); if (stableHash(document) !== before) writeJson(file, document); const items = []; walkItems(document, model, items); for (const item of items) { insert.run(item.id, item.semanticId, item.model, item.kind, item.name, item.statement, item.classification, item.confidence, JSON.stringify(item.evidence), JSON.stringify(item.payload), stableHash(item.payload)); insertFts.run(item.id, item.model, item.kind, item.name, item.statement); count++; } } db.exec('COMMIT'); diff --git a/global-template/docgen/lib/model-bundle.mjs b/global-template/docgen/lib/model-bundle.mjs new file mode 100644 index 0000000..0d2af3b --- /dev/null +++ b/global-template/docgen/lib/model-bundle.mjs @@ -0,0 +1,197 @@ +const WRAPPER_KEYS = new Set([ + 'artifacts', 'bundle', 'data', 'documents', 'items', 'model', 'models', 'modules', + 'objects', 'output', 'outputs', 'payload', 'result', 'results', 'response' +]); +const STRONG_SINGLETON_WRAPPERS = new Set(['artifacts', 'bundle', 'documents', 'model', 'models', 'modules', 'output', 'outputs', 'payload', 'response', 'result', 'results']); +const DESCRIPTOR_KEYS = ['modelName', 'model', 'filename', 'fileName', 'path', 'name', 'key', 'type', 'kind', 'id']; +const PAYLOAD_KEYS = ['value', 'content', 'payload', 'data', 'document', 'object', 'result', 'body', 'model']; +const GENERIC_NAME_TOKENS = new Set(['artifact', 'document', 'model', 'object', 'output', 'result']); +const NON_MODEL_SINGLETON_KEYS = new Set(['code', 'error', 'errors', 'message', 'messages', 'note', 'status', 'success', 'warning', 'warnings']); +const MODEL_SIGNAL_KEYS = new Set(['id', 'name', 'title', 'kind', 'statement', 'summary', 'description', 'classification', 'confidence', 'evidence', 'unknowns', 'items']); + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function parseJsonValue(value) { + if (typeof value !== 'string') return value; + const text = value.trim(); + if (!text || !['{', '['].includes(text[0])) return value; + try { return JSON.parse(text); } catch { return value; } +} + +function words(value) { + return String(value ?? '') + .replace(/\.json$/i, '') + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); +} + +function singular(word) { + if (word.endsWith('ies') && word.length > 4) return `${word.slice(0, -3)}y`; + if (word.endsWith('s') && word.length > 3 && !word.endsWith('ss')) return word.slice(0, -1); + return word; +} + +export function canonicalModelName(value) { + const parts = words(value); + while (parts.length && GENERIC_NAME_TOKENS.has(parts[0])) parts.shift(); + while (parts.length && GENERIC_NAME_TOKENS.has(parts.at(-1))) parts.pop(); + if (parts.length) parts[parts.length - 1] = singular(parts.at(-1)); + return parts.join(''); +} + +function wrapperKey(value) { + return words(value).join(''); +} + +function matchesExpected(value, expected) { + const candidate = canonicalModelName(value); + return Boolean(candidate) && candidate === canonicalModelName(expected); +} + +function asModelObject(value) { + const parsed = parseJsonValue(value); + if (isPlainObject(parsed)) return parsed; + if (Array.isArray(parsed)) return { items: parsed }; + return null; +} + +function descriptorIdentity(value) { + for (const key of DESCRIPTOR_KEYS) { + const candidate = value?.[key]; + if (typeof candidate === 'string' || typeof candidate === 'number') return String(candidate); + } + return null; +} + +function descriptorPayload(value) { + for (const key of PAYLOAD_KEYS) { + if (!(key in value)) continue; + const candidate = asModelObject(value[key]); + if (candidate) return candidate; + } + const ignored = new Set([...DESCRIPTOR_KEYS, ...PAYLOAD_KEYS]); + const remainder = Object.fromEntries(Object.entries(value).filter(([key]) => !ignored.has(key))); + return Object.keys(remainder).length ? remainder : null; +} + +function directSingletonCandidate(value, expectedNames) { + if (expectedNames.length !== 1) return null; + const root = asModelObject(value); + if (!root) return null; + const keys = Object.keys(root); + if (!keys.length) return root; + if (keys.length === 1 && keys.some((key) => STRONG_SINGLETON_WRAPPERS.has(wrapperKey(key)))) return null; + if (keys.every((key) => NON_MODEL_SINGLETON_KEYS.has(wrapperKey(key)))) return null; + if (keys.some((key) => expectedNames.some((name) => matchesExpected(key, name)))) return null; + const structured = Object.values(root).some((entry) => entry && typeof entry === 'object'); + const semantic = keys.some((key) => MODEL_SIGNAL_KEYS.has(wrapperKey(key))); + return structured || semantic ? root : null; +} + +export function extractModelObjects(bundle, expectedNames, { maxDepth = 8, maxNodes = 10000 } = {}) { + const expected = [...new Set(expectedNames.map(String))]; + const candidates = new Map(); + const diagnostics = []; + const seen = new WeakSet(); + let nodes = 0; + + function offer(name, rawValue, score, origin) { + const value = asModelObject(rawValue); + if (!value) return; + const current = candidates.get(name); + if (!current || score > current.score) { + candidates.set(name, { value, score, origin }); + diagnostics.push({ type: 'accepted', model: name, origin, score }); + } + } + + function visit(rawValue, depth = 0, origin = '$') { + if (depth > maxDepth || nodes >= maxNodes) return; + const value = parseJsonValue(rawValue); + if (Array.isArray(value)) { + nodes++; + for (let index = 0; index < value.length; index++) visit(value[index], depth + 1, `${origin}[${index}]`); + return; + } + if (!isPlainObject(value) || seen.has(value)) return; + seen.add(value); nodes++; + + const matchedModelKeys = new Set(); + for (const [key, child] of Object.entries(value)) { + for (const name of expected) { + if (matchesExpected(key, name)) { offer(name, child, 1000 - (depth * 10), `${origin}.${key}`); matchedModelKeys.add(key); } + } + } + + const identity = descriptorIdentity(value); + if (identity) { + for (const name of expected) { + if (matchesExpected(identity, name)) offer(name, descriptorPayload(value), 800 - (depth * 10), `${origin}<${identity}>`); + } + } + + const entries = Object.entries(value).sort(([left], [right]) => { + const leftWrapper = WRAPPER_KEYS.has(wrapperKey(left)) ? 0 : 1; + const rightWrapper = WRAPPER_KEYS.has(wrapperKey(right)) ? 0 : 1; + return leftWrapper - rightWrapper; + }); + for (const [key, child] of entries) if (!matchedModelKeys.has(key)) visit(child, depth + 1, `${origin}.${key}`); + } + + visit(bundle); + if (!candidates.size) { + const singleton = directSingletonCandidate(bundle, expected); + if (singleton) offer(expected[0], singleton, 100, '$'); + } + + const objects = Object.fromEntries(expected.filter((name) => candidates.has(name)).map((name) => [name, candidates.get(name).value])); + const missing = expected.filter((name) => !Object.hasOwn(objects, name)); + if (nodes >= maxNodes) diagnostics.push({ type: 'limit', reason: 'maxNodes', maxNodes }); + return { objects, missing, diagnostics, visitedNodes: nodes }; +} + +export function mergeModelObjects(target, extraction) { + for (const [name, value] of Object.entries(extraction.objects ?? {})) if (!Object.hasOwn(target, name)) target[name] = value; + return target; +} + +export function safeModelPlaceholder(name, reason = 'Provider omitted the requested model object after bounded recovery.') { + return { + status: 'degraded', + providerOutputStatus: 'missing', + classification: 'UNKNOWN', + confidence: 0, + evidence: [], + unknowns: [{ + id: `${canonicalModelName(name) || 'model'}-provider-output-missing`, + kind: 'provider-output-gap', + name: `${name} model unavailable`, + statement: reason, + classification: 'UNKNOWN', + confidence: 0, + evidence: [] + }], + normalizationNotes: [`A deterministic UNKNOWN placeholder was created for ${name}; no repository fact was invented.`] + }; +} + +export function resolveModelObjects(expectedNames, bundles, { missingPolicy = 'placeholder', placeholderReason } = {}) { + const expected = [...new Set(expectedNames.map(String))]; + const objects = {}; + const diagnostics = []; + for (const [index, bundle] of bundles.entries()) { + const extraction = extractModelObjects(bundle, expected.filter((name) => !Object.hasOwn(objects, name))); + mergeModelObjects(objects, extraction); + diagnostics.push(...extraction.diagnostics.map((entry) => ({ ...entry, attempt: index + 1 }))); + } + const unresolved = expected.filter((name) => !Object.hasOwn(objects, name)); + const degraded = []; + if (unresolved.length && missingPolicy !== 'fail') { + for (const name of unresolved) { objects[name] = safeModelPlaceholder(name, placeholderReason); degraded.push(name); } + } + return { objects, missing: expected.filter((name) => !Object.hasOwn(objects, name)), degraded, diagnostics }; +} diff --git a/global-template/docgen/lib/model-io.mjs b/global-template/docgen/lib/model-io.mjs new file mode 100644 index 0000000..7c423e0 --- /dev/null +++ b/global-template/docgen/lib/model-io.mjs @@ -0,0 +1,55 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ensureDir, fileSha256, now, projectPaths, readJson, writeJson } from './core.mjs'; +import { normalizeSemanticDocument } from './semantic.mjs'; + +const moduleDir = path.dirname(fileURLToPath(import.meta.url)); +export const modelPath = (root, name) => path.join(projectPaths(root).model, `${name}.json`); +export const stamp = (file) => fs.existsSync(file) ? { hash: fileSha256(file), mtimeMs: fs.statSync(file).mtimeMs } : null; +export const changed = (file, before) => { const after = stamp(file); return Boolean(after && (!before || after.hash !== before.hash || after.mtimeMs !== before.mtimeMs)); }; + +export function renderModelPrompt(root, name, vars, repair = false) { + const override = path.join(root, '.docgen', 'prompts', name); + let text = fs.readFileSync(fs.existsSync(override) ? override : path.resolve(moduleDir, '..', 'prompts', name), 'utf8'); + for (const [key, value] of Object.entries(vars)) text = text.replaceAll(`{{${key}}}`, String(value)); + return repair ? `${text}\n\nRecovery request: write only the requested model object(s). A direct object is accepted for a single requested name.` : text; +} +export function stageCurrent(root, stage, inputHash, outputs) { + const current = readJson(projectPaths(root).state, {}).stages?.[stage]; + return current?.status === 'completed' && current.inputHash === inputHash && outputs.every(fs.existsSync); +} +export function readBundle(file) { + const value = readJson(file); + if (!value || typeof value !== 'object') throw new Error(`Invalid model bundle JSON: ${file}`); + return value; +} +export function commitModels(root, stage, names, objects) { + const dir = projectPaths(root).model; + const token = `${stage}-${process.pid}-${Date.now()}`; + const staging = path.join(dir, `.staging-${token}`); + const backup = path.join(dir, `.backup-${token}`); + ensureDir(staging); ensureDir(backup); + const existed = new Set(); + try { + for (const name of names) { + const value = structuredClone(objects[name]); + normalizeSemanticDocument(value); + writeJson(path.join(staging, `${name}.json`), { schemaVersion: '2.0', generatedAt: now(), ...value }); + } + for (const name of names) { + const final = modelPath(root, name); + if (fs.existsSync(final)) { fs.copyFileSync(final, path.join(backup, `${name}.json`)); existed.add(name); } + } + for (const name of names) fs.copyFileSync(path.join(staging, `${name}.json`), modelPath(root, name)); + } catch (error) { + for (const name of names) { + const final = modelPath(root, name); const saved = path.join(backup, `${name}.json`); + if (existed.has(name) && fs.existsSync(saved)) fs.copyFileSync(saved, final); else fs.rmSync(final, { force: true }); + } + throw error; + } finally { + fs.rmSync(staging, { recursive: true, force: true }); + fs.rmSync(backup, { recursive: true, force: true }); + } +} diff --git a/global-template/docgen/lib/model-synthesis.mjs b/global-template/docgen/lib/model-synthesis.mjs new file mode 100644 index 0000000..3c31720 --- /dev/null +++ b/global-template/docgen/lib/model-synthesis.mjs @@ -0,0 +1,75 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { compileContext } from './context.mjs'; +import { runProvider } from './provider.mjs'; +import { ensureDir, loadConfig, now, projectPaths, rel, sha256, updateStage } from './core.mjs'; +import { extractModelObjects, mergeModelObjects, safeModelPlaceholder } from './model-bundle.mjs'; +import { changed, commitModels, modelPath, readBundle, renderModelPrompt, stageCurrent, stamp } from './model-io.mjs'; + +export async function synthesizeModels(root, stage, names, query) { + const paths = projectPaths(root); const config = loadConfig(root); + const context = compileContext(root, { stage, query, target: stage, metadata: { expectedModels: names } }); + const inputHash = context.payload.inputHash; const outputs = names.map((name) => modelPath(root, name)); + if (stageCurrent(root, stage, inputHash, outputs)) return { skipped: true, inputHash }; + ensureDir(paths.model); updateStage(root, stage, 'running', { inputHash, contextId: context.payload.id }); + const temporary = new Set(); const resolved = {}; const recoveryErrors = []; + let providerCalls = 0; let recovered = false; + async function request(expected, target, repair = false) { + temporary.add(target); const before = stamp(target); let extraction = null; + const inspect = () => { + if (!changed(target, before)) return null; + extraction = extractModelObjects(readBundle(target), expected); + return extraction; + }; + providerCalls++; + const provider = await runProvider(root, { + stage, + target: repair ? `${stage}:repair:${expected.join(',')}` : stage, + prompt: renderModelPrompt(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) + }, repair), + acceptArtifacts: () => Boolean(Object.keys(inspect()?.objects ?? {}).length) + }); + recovered ||= provider.recovered === true; + extraction ??= inspect(); + if (!extraction || !Object.keys(extraction.objects ?? {}).length) throw new Error(`${stage}: provider completed without a fresh recognizable model artifact`); + return extraction; + } + const merge = (result) => mergeModelObjects(resolved, result); + try { + try { merge(await request(names, path.join(paths.model, `${stage}-bundle.json`))); } + catch (error) { recoveryErrors.push(`initial: ${error.message}`); } + let missing = names.filter((name) => !Object.hasOwn(resolved, name)); + if (missing.length) { + console.warn(`[docgen] ${stage} REPAIR | unresolved: ${missing.join(', ')}`); + try { merge(await request(missing, path.join(paths.model, `${stage}-repair-${sha256(missing.join('|')).slice(0, 10)}-bundle.json`), true)); } + catch (error) { recoveryErrors.push(`batch: ${error.message}`); } + } + missing = names.filter((name) => !Object.hasOwn(resolved, name)); + for (const name of missing) { + console.warn(`[docgen] ${stage} OBJECT REPAIR | ${name}`); + try { merge(await request([name], path.join(paths.model, `${stage}-repair-${sha256(name).slice(0, 10)}-${name}.json`), true)); } + catch (error) { recoveryErrors.push(`${name}: ${error.message}`); } + } + missing = names.filter((name) => !Object.hasOwn(resolved, name)); + const policy = String(config.execution?.missingModelPolicy ?? 'placeholder').toLowerCase(); + if (missing.length && policy === 'fail') throw new Error(`Model recovery exhausted for: ${missing.join(', ')}`); + const degradedModels = []; + for (const name of missing) { + resolved[name] = safeModelPlaceholder(name, `${stage} provider output omitted ${name} after bounded recovery.`); + degradedModels.push(name); + console.warn(`[docgen] ${stage} DEGRADED | ${name} -> explicit UNKNOWN placeholder`); + } + commitModels(root, stage, names, resolved); + updateStage(root, stage, 'completed', { + inputHash, completedAt: now(), models: names, degradedModels, recoveryErrors, providerCalls, + contextId: context.payload.id, contextTokens: context.payload.estimatedTokens, recovered + }); + return { skipped: false, inputHash, degradedModels, providerCalls, recovered }; + } catch (error) { + updateStage(root, stage, 'failed', { inputHash, failedAt: now(), error: error.message, recoveryErrors, contextId: context.payload.id }); + throw error; + } finally { + for (const file of temporary) fs.rmSync(file, { force: true }); + } +} diff --git a/global-template/docgen/lib/pipeline-base.mjs b/global-template/docgen/lib/pipeline-base.mjs new file mode 100644 index 0000000..aeaf554 --- /dev/null +++ b/global-template/docgen/lib/pipeline-base.mjs @@ -0,0 +1,336 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { compileContext } from './context.mjs'; +import { budgetReport, runProvider } from './provider.mjs'; +import { databaseStats, indexRepository, ingestModels } from './indexer.mjs'; +import { auditRepository } from './quality.mjs'; +import { ensureDir, fileSha256, kitVersion, loadConfig, now, projectPaths, readJson, rel, sha256, slug, sourceSnapshot, stableHash, updateStage, writeJson } from './core.mjs'; +import { evidenceFromAliases, normalizeSemanticDocument, normalizeSourceModelRefs, semanticMetadata } from './semantic.mjs'; + +const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +function prompt(root, name, vars = {}) { + const override = path.join(root, '.docgen', 'prompts', name); const fallback = path.resolve(moduleDir, '..', 'prompts', name); + let text = fs.readFileSync(fs.existsSync(override) ? override : fallback, 'utf8'); + for (const [key, value] of Object.entries(vars)) text = text.replaceAll(`{{${key}}}`, String(value)); + return text; +} +function state(root) { return readJson(projectPaths(root).state, { schemaVersion: '2.0', kitVersion, stages: {}, pages: {} }); } +function writeState(root, next) { writeJson(projectPaths(root).state, { ...next, schemaVersion: '2.0', kitVersion, updatedAt: now() }); } +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)); } +function completeStage(root, stage, inputHash, details = {}) { const next = state(root); next.stages ??= {}; next.stages[stage] = { status: 'completed', completedAt: now(), inputHash, ...details }; writeState(root, next); } +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); } +function pageState(root, id) { return state(root).pages?.[id] ?? {}; } +function updatePage(root, id, patch) { const next = state(root); next.pages ??= {}; next.pages[id] = { ...(next.pages[id] ?? {}), ...patch, updatedAt: now() }; writeState(root, next); } +function modelPath(root, name) { return path.join(projectPaths(root).model, `${name}.json`); } +function tracePath(root, page) { return path.join(projectPaths(root).traceability, 'pages', `${page.id}.json`); } +function validateJson(file) { const value = readJson(file); if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Invalid JSON object: ${file}`); return value; } +function artifactStamp(file) { + if (!fs.existsSync(file)) return null; + const stat = fs.statSync(file, { bigint: true }); + return { hash: fileSha256(file), mtimeNs: String(stat.mtimeNs) }; +} +function artifactChanged(file, before) { + const after = artifactStamp(file); if (!after) return false; if (!before) return true; + return after.hash !== before.hash || after.mtimeNs !== before.mtimeNs; +} +function phase(name, position, total) { console.log(`[docgen] phase ${position}/${total} ${name.toUpperCase()}`); } + +export function index(root, options = {}) { + updateStage(root, 'index', 'running'); + try { const result = indexRepository(root, options); completeStage(root, 'index', result.inventoryFingerprint, result); return result; } + catch (error) { failStage(root, 'index', error); throw error; } +} + +function bundleObject(bundle, name) { + const containers = [bundle, bundle?.models, bundle?.model, bundle?.modules, bundle?.result].filter((value) => value && typeof value === 'object' && !Array.isArray(value)); + const expected = name.replace(/[^a-z0-9]/gi, '').toLowerCase(); + for (const container of containers) { + for (const [key, value] of Object.entries(container)) { + if (key.replace(/[^a-z0-9]/gi, '').toLowerCase() === expected && value && typeof value === 'object' && !Array.isArray(value)) return value; + } + } + return null; +} +function splitBundle(root, bundle, names) { + ensureDir(projectPaths(root).model); const missing = []; + for (const name of names) { + const value = bundleObject(bundle, name); + if (!value) { missing.push(name); continue; } + normalizeSemanticDocument(value); writeJson(modelPath(root, name), { schemaVersion: '2.0', generatedAt: now(), ...value }); + } + return missing; +} + +async function synthesizeBundle(root, stage, names, query) { + const paths = projectPaths(root); const context = compileContext(root, { stage, query, target: stage, metadata: { expectedModels: names } }); + const output = path.join(paths.model, `${stage}-bundle.json`); const inputHash = context.payload.inputHash; const outputs = names.map((name) => modelPath(root, name)); + if (stageCurrent(root, stage, inputHash, outputs)) return { skipped: true, inputHash }; + ensureDir(paths.model); updateStage(root, stage, 'running', { inputHash, contextId: context.payload.id }); + const renderPrompt = (expected, target) => 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) }); + async function requestBundle(expected, target, repair = false) { + const before = artifactStamp(target); let accepted = false; let missing = expected; + const accept = () => { + try { if (!artifactChanged(target, before)) return false; const bundle = validateJson(target); missing = splitBundle(root, bundle, expected); accepted = missing.length === 0; return accepted; } catch { return false; } + }; + const provider = await runProvider(root, { stage, target: repair ? `${stage}:repair:${expected.join(',')}` : stage, prompt: renderPrompt(expected, target), acceptArtifacts: accept }); + if (!accepted) { + if (!artifactChanged(target, before)) throw new Error(`${stage}: provider completed without writing a fresh bundle artifact`); + missing = splitBundle(root, validateJson(target), expected); + } + return { provider, missing }; + } + try { + const first = await requestBundle(names, output); let missing = first.missing; let recovered = first.provider.recovered === true; + if (missing.length) { + console.warn(`[docgen] ${stage} REPAIR | bundle omitted ${missing.join(', ')}; requesting only missing model object(s).`); + const repairOutput = path.join(paths.model, `${stage}-repair-${sha256(missing.join('|')).slice(0, 10)}-bundle.json`); + const repaired = await requestBundle(missing, repairOutput, true); recovered ||= repaired.provider.recovered === true; missing = repaired.missing; fs.rmSync(repairOutput, { force: true }); + } + if (missing.length) throw new Error(`Model bundle is missing object(s) after targeted repair: ${missing.join(', ')}`); + fs.rmSync(output, { force: true }); completeStage(root, stage, inputHash, { models: names, contextId: context.payload.id, contextTokens: context.payload.estimatedTokens, recovered }); + return { skipped: false, recovered, inputHash }; + } catch (error) { failStage(root, stage, error, { inputHash, contextId: context.payload.id }); throw error; } +} + +export async function model(root, { skipIndex = false } = {}) { + if (!skipIndex) index(root); + 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'); + ingestModels(root); + 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'); + return ingestModels(root); +} + +function canonicalPagePath(page, category, id) { + let value = String(page.path ?? '').replaceAll('\\', '/').replace(/^\/+/, ''); + if (!value) value = `docs/${category}/${slug(page.slug ?? page.title ?? id)}.md`; + if (!value.startsWith('docs/')) value = `docs/${value}`; + if (!value.endsWith('.md')) value += '.md'; + return value; +} +function normalizePage(page, indexValue) { + const id = slug(page.id ?? page.title ?? `page-${indexValue + 1}`); const category = slug(page.category ?? page.type ?? 'overview'); + 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) : [] }; +} +function validateManifest(root) { + 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[].'); + manifest.pages = manifest.pages.map(normalizePage); const ids = new Set(); const files = new Set(); const max = Number(loadConfig(root).execution?.maxPlannedPages ?? 30); + 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.`); + for (const page of manifest.pages) { + 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}`); + if (!page.title.trim() || !page.summary.trim()) throw new Error(`Page ${page.id} requires non-empty title and summary.`); + ids.add(page.id); files.add(page.path); + } + manifest.schemaVersion = '2.0'; manifest.generatedAt ??= now(); writeJson(paths.plan, manifest); return manifest; +} + +export async function plan(root) { + const paths = projectPaths(root); ingestModels(root); + 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', target: 'manifest', maxTokens: loadConfig(root).context?.maxTokens?.plan ?? 50000 }); + const inputHash = context.payload.inputHash; if (stageCurrent(root, 'plan', inputHash, [paths.plan])) return validateManifest(root); + updateStage(root, 'plan', 'running', { inputHash, contextId: context.payload.id }); + const planBefore = artifactStamp(paths.plan); let validManifest = null; const acceptManifest = () => { try { if (!artifactChanged(paths.plan, planBefore)) return false; validManifest = validateManifest(root); return true; } catch { return false; } }; + try { + const provider = await runProvider(root, { stage: 'plan', target: 'manifest', prompt: prompt(root, 'plan-indexed.md', { CONTEXT_PATH: rel(root, context.file), OUTPUT_PATH: rel(root, paths.plan) }), acceptArtifacts: acceptManifest }); + if (!validManifest && !artifactChanged(paths.plan, planBefore)) throw new Error('plan: provider completed without writing a fresh manifest artifact'); + const manifest = validManifest ?? validateManifest(root); completeStage(root, 'plan', inputHash, { pages: manifest.pages.length, contextId: context.payload.id, recovered: provider.recovered === true }); return manifest; + } catch (error) { failStage(root, 'plan', error, { inputHash, contextId: context.payload.id }); throw error; } +} + +function frontmatter(page) { return ['---', `title: ${JSON.stringify(page.title)}`, `description: ${JSON.stringify(page.summary)}`, `pageId: ${JSON.stringify(page.id)}`, `category: ${JSON.stringify(page.category)}`, `mode: ${JSON.stringify(page.mode)}`, `type: ${JSON.stringify(page.type)}`, `order: ${page.order}`, '---', ''].join('\n'); } +function markdownTable(headers, rows) { return `| ${headers.join(' | ')} |\n| ${headers.map(() => '---').join(' | ')} |\n${rows.map((row) => `| ${row.map((value) => String(value ?? '').replaceAll('|', '\\|').replace(/\r?\n/g, ' ')).join(' | ')} |`).join('\n')}\n`; } +function deterministicKind(page) { + const tags = new Set(page.coverageTags.map((tag) => tag.toLowerCase())); + const mapping = [ + ['endpoint-catalog', 'endpoints'], ['message-handler-catalog', 'messages'], ['external-dependency-catalog', 'dependencies'], ['dependency-catalog', 'dependencies'], + ['data-store-catalog', 'data-assets'], ['data-asset-catalog', 'data-assets'], ['scheduled-job-catalog', 'automations'], ['automation-catalog', 'automations'], + ['interface-catalog', 'interfaces'], ['component-catalog', 'components'], ['configuration-matrix', 'configuration'], ['ownership-responsibilities', 'ownership'], ['change-impact', 'change-impact'] + ]; + return mapping.find(([tag]) => tags.has(tag))?.[1] ?? null; +} +function evidenceText(item) { return (item.evidence ?? []).map((e) => [e.path, e.startLine ? `L${e.startLine}${e.endLine ? `-L${e.endLine}` : ''}` : ''].filter(Boolean).join('#')).join(', '); } +function arraysFor(document, keys) { const items = []; for (const key of keys) if (Array.isArray(document[key])) items.push(...document[key]); return items; } +function referenceData(root, kind) { + const catalog = readJson(modelPath(root, 'catalogs'), {}); const system = readJson(modelPath(root, 'system'), {}); + let model = 'catalogs'; let modelFiles = [modelPath(root, 'catalogs')]; let items = []; let headers = ['Name', 'Kind', 'Statement', 'Evidence']; let row; + if (kind === 'endpoints') { items = arraysFor(catalog, ['endpoints']); headers = ['Endpoint', 'Method', 'Path', 'Statement', 'Evidence']; row = (item) => [item.name ?? item.id, item.method ?? item.httpMethod ?? '', item.path ?? item.route ?? '', item.statement ?? item.summary ?? '', evidenceText(item)]; } + else if (kind === 'messages') { items = arraysFor(catalog, ['messageHandlers']); headers = ['Handler', 'Direction', 'Channel', 'Statement', 'Evidence']; row = (item) => [item.name ?? item.id, item.direction ?? item.role ?? '', item.topic ?? item.queue ?? item.channel ?? '', item.statement ?? item.summary ?? '', evidenceText(item)]; } + else if (kind === 'interfaces') items = arraysFor(catalog, ['interfaces', 'endpoints', 'messageHandlers', 'contracts']); + else if (kind === 'dependencies') items = arraysFor(catalog, ['dependencies', 'externalDependencies']); + else if (kind === 'data-assets') items = arraysFor(catalog, ['dataAssets', 'dataStores']); + else if (kind === 'automations') items = arraysFor(catalog, ['automations', 'scheduledJobs']); + else if (kind === 'components') { model = 'system'; modelFiles = [modelPath(root, 'system')]; items = arraysFor(system, ['components', 'modules', 'services', 'packages']); } + else { + model = kind; modelFiles = [modelPath(root, model)]; const document = readJson(modelPath(root, model), {}); items = Object.values(document).filter(Array.isArray).flat(); + } + const seen = new Set(); items = items.filter((item) => { const id = String(item?.id ?? stableHash(item)); if (seen.has(id)) return false; seen.add(id); return true; }); + row ??= (item) => [item.name ?? item.title ?? item.id, item.kind ?? '', item.statement ?? item.summary ?? item.description ?? '', evidenceText(item)]; + return { model, modelFiles, items, headers, rows: items.map(row) }; +} +function writeTrace(root, page, claims, inputHash, contextId = null) { + const pageFile = path.join(root, page.path); const trace = { schemaVersion: '2.0', pageId: page.id, pagePath: page.path, pageHash: sha256(fs.readFileSync(pageFile)), inputHash, contextId, generatedAt: now(), claims }; + writeJson(tracePath(root, page), trace); return trace; +} +function renderDeterministic(root, page, kind, inputHash) { + const data = referenceData(root, kind); const text = `${frontmatter(page)}# ${page.title}\n\n${page.summary}\n\n${markdownTable(data.headers, data.rows)}\n`; const file = path.join(root, page.path); ensureDir(path.dirname(file)); fs.writeFileSync(file, text); + const claims = data.items.map((item, indexValue) => { const metadata = semanticMetadata(item); return { id: `${page.id}:${item.id ?? indexValue + 1}`, section: page.title, statement: item.statement ?? item.summary ?? item.description ?? item.name ?? item.id, classification: metadata.classification, confidence: metadata.confidence, evidence: metadata.evidence, sourceModelRefs: [`${data.model}:${item.id ?? indexValue + 1}`] }; }); + writeTrace(root, page, claims, inputHash); return { items: data.items.length, hash: sha256(text) }; +} +function pageInputHash(page, contextHash) { return stableHash({ page, contextHash }); } +function finalizeProviderTrace(root, page, inputHash, contextId) { + const file = tracePath(root, page); const trace = validateJson(file); if (!Array.isArray(trace.claims)) throw new Error(`${rel(root, file)} must contain claims[].`); + const contextFile = path.join(projectPaths(root).context, 'generate', `${page.id.replace(/[^a-z0-9_.-]+/gi, '-')}.json`); const context = fs.existsSync(contextFile) ? readJson(contextFile, {}) : {}; + const modelItems = new Map(); const aliases = new Map(); const perModel = new Map(); + for (const item of context.modelItems ?? []) { modelItems.set(item.id, item); const ordinal = (perModel.get(item.model) ?? 0) + 1; perModel.set(item.model, ordinal); aliases.set(`${item.model}:${ordinal}`, item.id); } + const dedupeEvidence = (entries) => { const seen = new Set(); return entries.filter((entry) => { const key = `${entry.path}\0${entry.startLine ?? ''}\0${entry.endLine ?? ''}`; if (seen.has(key)) return false; seen.add(key); return true; }); }; + const ids = new Set(); const normalizedClaims = []; + for (const claim of trace.claims) { + claim.id = String(claim.id ?? `${page.id}:claim-${ids.size + 1}`); if (ids.has(claim.id)) throw new Error(`${page.id}: duplicate claim id ${claim.id}`); ids.add(claim.id); + const requestedRefs = normalizeSourceModelRefs(claim.sourceModelRefs ?? claim.modelRefs).map((ref) => aliases.get(ref) ?? ref); const refs = requestedRefs.filter((ref) => modelItems.has(ref)); const referenced = refs.map((ref) => modelItems.get(ref)); const metadata = semanticMetadata(claim); + const inherited = referenced.flatMap((item) => evidenceFromAliases(item)); const evidence = dedupeEvidence([...metadata.evidence, ...inherited]); const fallbackStatement = referenced.map((item) => String(item.statement ?? item.payload?.statement ?? item.name ?? '')).find(Boolean); + claim.statement = String(claim.statement ?? fallbackStatement ?? '').trim(); claim.classification = metadata.requestedClassification === 'FACT' && evidence.length ? 'FACT' : metadata.classification; claim.confidence = claim.classification === 'FACT' ? Math.max(metadata.confidence, 0.8) : metadata.confidence; claim.evidence = evidence; claim.sourceModelRefs = refs; + if (!claim.statement && !claim.evidence.length && !claim.sourceModelRefs.length) continue; + normalizedClaims.push(claim); + } + trace.claims = normalizedClaims; + trace.schemaVersion = '2.0'; trace.pageId = page.id; trace.pagePath = page.path; trace.pageHash = sha256(fs.readFileSync(path.join(root, page.path))); trace.inputHash = inputHash; trace.contextId = contextId; trace.generatedAt = now(); writeJson(file, trace); return trace; +} +function validatePage(root, page, inputHash = null) { + const file = path.join(root, page.path); if (!fs.existsSync(file)) throw new Error(`Missing page: ${page.path}`); const text = fs.readFileSync(file, 'utf8'); + if (!/^---\s*\n[\s\S]*?\n---\s*\n/.test(text)) throw new Error(`${page.path}: missing frontmatter`); if (!/^#\s+\S/m.test(text)) throw new Error(`${page.path}: missing H1`); if (/```(?:plantuml|dot|graphviz|puml)/i.test(text)) throw new Error(`${page.path}: non-Mermaid diagram`); + const traceFile = tracePath(root, page); if (!fs.existsSync(traceFile)) throw new Error(`${page.path}: missing traceability sidecar`); const trace = validateJson(traceFile); if (!Array.isArray(trace.claims)) throw new Error(`${rel(root, traceFile)}: missing claims[]`); if (trace.pageId !== page.id || trace.pagePath !== page.path) throw new Error(`${rel(root, traceFile)}: page identity mismatch`); if (trace.pageHash && trace.pageHash !== sha256(text)) throw new Error(`${rel(root, traceFile)}: stale page hash`); if (inputHash && trace.inputHash !== inputHash) throw new Error(`${rel(root, traceFile)}: stale input hash`); + return { file, text, trace, hash: sha256(text) }; +} + +function checkpointGeneratedItems(root, batch, recovered = false, baseline = new Map()) { + const completed = []; const missing = []; + for (const item of batch) { + try { + const pageFile = path.join(root, item.page.path); const traceFile = tracePath(root, item.page); + if (!fs.existsSync(pageFile) || !fs.existsSync(traceFile)) throw new Error('expected output is missing'); + const before = baseline.get(item.page.id) ?? {}; const existingTrace = readJson(traceFile, {}); + const alreadyCurrent = existingTrace.inputHash === item.inputHash && existingTrace.contextId === item.context.payload.id; + if (!alreadyCurrent && !artifactChanged(pageFile, before.page) && !artifactChanged(traceFile, before.trace)) throw new Error('provider did not write a fresh artifact for the current input'); + finalizeProviderTrace(root, item.page, item.inputHash, item.context.payload.id); const checked = validatePage(root, item.page, item.inputHash); + updatePage(root, item.page.id, { status: 'completed', renderer: 'provider', recovered, inputHash: item.inputHash, pageHash: checked.hash, contextId: item.context.payload.id, completedAt: now(), error: null }); completed.push(item); + } catch (error) { missing.push({ ...item, checkpointError: error.message }); } + } + return { completed, missing }; +} + +async function generateBatch(root, batch, config, recoveryRound = 0) { + if (!batch.length) return { completed: 0, recovered: 0 }; + const maxRecoveryRounds = Math.max(1, Number(config.execution?.generationRecoveryAttempts ?? 3)); const batchId = sha256(batch.map((item) => `${item.page.id}:${item.inputHash}`).join('|')).slice(0, 12); + const baseline = new Map(batch.map((item) => [item.page.id, { page: artifactStamp(path.join(root, item.page.path)), trace: artifactStamp(tracePath(root, item.page)) }])); + for (const item of batch) updatePage(root, item.page.id, { status: 'running', renderer: 'provider', inputHash: item.inputHash, contextId: item.context.payload.id, batchId, recoveryRound, startedAt: now(), error: null }); + const contract = batch.map(({ page, context, inputHash }) => ({ page, contextPath: rel(root, context.file), outputPath: page.path, traceabilityPath: rel(root, tracePath(root, page)), inputHash, contextId: context.payload.id })); + let checkpoint = { completed: [], missing: batch }; let provider; + try { + provider = await runProvider(root, { + stage: 'generate', target: batch.map((item) => item.page.id).join(','), prompt: prompt(root, 'write-pages-indexed.md', { PAGE_CONTRACTS: JSON.stringify(contract, null, 2) }), + acceptArtifacts: () => { checkpoint = checkpointGeneratedItems(root, batch, true, baseline); return checkpoint.completed.length > 0; } + }); + checkpoint = checkpointGeneratedItems(root, batch, provider.recovered === true, baseline); + } catch (error) { + checkpoint = checkpointGeneratedItems(root, batch, true, baseline); + for (const item of checkpoint.missing) updatePage(root, item.page.id, { status: 'failed', failedAt: now(), error: item.checkpointError || error.message }); + if (!checkpoint.completed.length) throw error; + } + if (checkpoint.missing.length) { + if (recoveryRound + 1 >= maxRecoveryRounds) { + const error = new Error(`Generation batch ${batchId} produced ${checkpoint.completed.length}/${batch.length} valid page(s); recovery limit ${maxRecoveryRounds} reached for: ${checkpoint.missing.map((item) => item.page.id).join(', ')}`); + for (const item of checkpoint.missing) updatePage(root, item.page.id, { status: 'failed', failedAt: now(), error: error.message }); throw error; + } + console.warn(`[docgen] generate RECOVERY | checkpointed ${checkpoint.completed.length}/${batch.length}; retrying only ${checkpoint.missing.length} missing/invalid page(s).`); + const next = await generateBatch(root, checkpoint.missing, config, recoveryRound + 1); + return { completed: checkpoint.completed.length + next.completed, recovered: checkpoint.completed.length + next.recovered }; + } + return { completed: checkpoint.completed.length, recovered: provider?.recovered === true ? checkpoint.completed.length : 0 }; +} + +export async function generate(root) { + const manifest = validateManifest(root); const config = loadConfig(root); const pending = []; let deterministicPages = 0; let reusedPages = 0; + updateStage(root, 'generate', 'running', { pages: manifest.pages.length }); + try { + for (const page of manifest.pages) { + const deterministic = deterministicKind(page); + if (deterministic) { + const data = referenceData(root, deterministic); const inputHash = stableHash({ page, models: data.modelFiles.map(fileSha256) }); + try { validatePage(root, page, inputHash); updatePage(root, page.id, { status: 'completed', renderer: 'deterministic', inputHash, pageHash: fileSha256(path.join(root, page.path)), recovered: pageState(root, page.id).status !== 'completed' }); reusedPages++; } + catch { const result = renderDeterministic(root, page, deterministic, inputHash); updatePage(root, page.id, { status: 'completed', renderer: 'deterministic', inputHash, pageHash: result.hash, completedAt: now(), error: null }); } + deterministicPages++; continue; + } + const context = compileContext(root, { stage: 'generate', target: page.id, query: page.query, maxTokens: config.context?.maxTokens?.generate ?? 30000, metadata: { page } }); const inputHash = pageInputHash(page, context.payload.inputHash); + try { + if (fs.existsSync(tracePath(root, page))) finalizeProviderTrace(root, page, inputHash, context.payload.id); + const checked = validatePage(root, page, inputHash); updatePage(root, page.id, { status: 'completed', renderer: 'provider', inputHash, pageHash: checked.hash, contextId: context.payload.id, recovered: pageState(root, page.id).status !== 'completed', error: null }); reusedPages++; continue; + } catch {} + pending.push({ page, context, inputHash }); + } + const size = Math.max(1, Number(config.execution?.generationBatchSize ?? 4)); let recoveredPages = 0; + for (let i = 0; i < pending.length; i += size) { const result = await generateBatch(root, pending.slice(i, i + size), config); recoveredPages += result.recovered; } + const inputHash = stableHash(manifest.pages.map((page) => [page.id, pageState(root, page.id).inputHash])); + completeStage(root, 'generate', inputHash, { pages: manifest.pages.length, providerPages: pending.length, deterministicPages, reusedPages, recoveredPages }); + return { pages: manifest.pages.length, providerPages: pending.length, deterministicPages, reusedPages, recoveredPages }; + } catch (error) { failStage(root, 'generate', error, { pages: manifest.pages.length }); throw error; } +} + +export async function audit(root) { + const paths = projectPaths(root); const manifest = validateManifest(root); ensureDir(paths.audit); updateStage(root, 'audit', 'running'); + try { + const quality = auditRepository(root, manifest); writeJson(path.join(paths.audit, 'deterministic.json'), quality); + const config = loadConfig(root); const threshold = Number(config.audit?.llmRiskThreshold ?? 50); const risky = quality.pages.filter((report) => report.riskScore >= threshold && !report.errors.length); const llmOutput = path.join(paths.audit, 'llm-risk.json'); let highRiskFindings = 0; + if (risky.length && config.audit?.llmEnabled !== false) { + const contexts = risky.map((report) => { const page = manifest.pages.find((item) => item.id === report.pageId); const context = compileContext(root, { stage: 'audit', target: page.id, query: page.query, maxTokens: config.context?.maxTokens?.audit ?? 18000, metadata: { page, deterministicReport: report } }); return { page, contextPath: rel(root, context.file), contextId: context.payload.id, pagePath: page.path, report }; }); + const riskInputHash = stableHash(contexts.map((item) => ({ pageId: item.page.id, pageHash: item.report.pageHash, inputHash: item.report.inputHash, contextId: item.contextId }))); + if (!stageCurrent(root, 'auditRisk', riskInputHash, [llmOutput])) { + const llmBefore = artifactStamp(llmOutput); let valid = null; const accept = () => { try { if (!artifactChanged(llmOutput, llmBefore)) return false; const result = validateJson(llmOutput); if (!Array.isArray(result.pages)) return false; valid = result; return true; } catch { return false; } }; + const provider = await runProvider(root, { stage: 'audit', target: risky.map((report) => report.pageId).join(','), prompt: prompt(root, 'audit-risk-indexed.md', { AUDIT_CONTRACTS: JSON.stringify(contexts, null, 2), OUTPUT_PATH: rel(root, llmOutput) }), acceptArtifacts: accept }); + if (!valid && !artifactChanged(llmOutput, llmBefore)) throw new Error('audit: provider completed without writing a fresh risk report artifact'); + const result = valid ?? validateJson(llmOutput); if (!Array.isArray(result.pages)) throw new Error('Risk audit report must contain pages[].'); completeStage(root, 'auditRisk', riskInputHash, { pages: risky.length, recovered: provider.recovered === true }); + } + const llm = readJson(llmOutput, { pages: [] }); highRiskFindings = (llm.pages ?? []).flatMap((page) => page.findings ?? []).filter((finding) => ['critical', 'high'].includes(String(finding.severity).toLowerCase())).length; + } + const pass = quality.pass && highRiskFindings === 0; + const summary = { schemaVersion: '2.0', generatedAt: now(), auditInputHash: quality.auditInputHash, inventoryFingerprint: quality.inventoryFingerprint, manifestHash: quality.manifestHash, pages: quality.metrics.pages, claims: quality.metrics.claims, evidenceReferences: quality.metrics.evidenceReferences, modelItems: quality.metrics.modelItems, referencedModelItems: quality.metrics.referencedModelItems, modelReferenceCoverage: quality.metrics.modelReferenceCoverage, deterministicFailures: quality.errors.length, deterministicWarnings: quality.warnings.length, llmAuditedPages: risky.length, highRiskFindings, pass }; + writeJson(path.join(paths.audit, 'quality-summary.json'), summary); + if (!pass) throw new Error(`Quality failed: deterministicFailures=${quality.errors.length}, highRiskFindings=${highRiskFindings}. See .docgen/audit/deterministic.json.`); + completeStage(root, 'audit', quality.auditInputHash, summary); return summary; + } catch (error) { failStage(root, 'audit', error); throw error; } +} + +export function publish(root) { + const paths = projectPaths(root); const manifest = validateManifest(root); const summary = readJson(path.join(paths.audit, 'quality-summary.json'), null); const auditState = state(root).stages?.audit; + if (!summary?.pass || auditState?.status !== 'completed' || auditState.inputHash !== summary.auditInputHash) throw new Error('Publish requires a current passing audit. Run `docgen audit` or `docgen resume`.'); + const inventory = readJson(paths.inventory, {}); if (summary.inventoryFingerprint !== inventory.fingerprint || summary.manifestHash !== stableHash(manifest.pages)) throw new Error('Audit is stale relative to the current inventory or manifest. Run `docgen audit`.'); + const currentQuality = auditRepository(root, manifest); + if (!currentQuality.pass || currentQuality.auditInputHash !== summary.auditInputHash) throw new Error('Audit is stale relative to current source, model, page, or traceability artifacts. Run `docgen resume`.'); + ensureDir(paths.publish); const navigation = {}; const search = []; const traces = []; + for (const page of manifest.pages) { + const checked = validatePage(root, page, pageState(root, page.id).inputHash); (navigation[page.category] ??= []).push({ id: page.id, title: page.title, path: page.path, order: page.order, summary: page.summary }); + search.push({ id: page.id, title: page.title, path: page.path, category: page.category, summary: page.summary, keywords: page.coverageTags, excerpt: checked.text.replace(/```[\s\S]*?```/g, ' ').replace(/[#>*_`\[\]()]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 400) }); + traces.push({ pageId: page.id, pagePath: page.path, pageHash: checked.trace.pageHash, inputHash: checked.trace.inputHash, claims: checked.trace.claims.length }); + } + for (const pages of Object.values(navigation)) pages.sort((a, b) => a.order - b.order); + writeJson(path.join(paths.publish, 'navigation.json'), { schemaVersion: '2.0', generatedAt: now(), navigation }); writeJson(path.join(paths.publish, 'search-index.json'), { schemaVersion: '2.0', generatedAt: now(), pages: search }); writeJson(path.join(paths.traceability, 'index.json'), { schemaVersion: '2.0', generatedAt: now(), pages: traces }); + const lines = [`# ${loadConfig(root).projectName || path.basename(root)}`, '']; for (const [category, pages] of Object.entries(navigation)) { lines.push(`## ${category}`, ''); for (const page of pages) lines.push(`- [${page.title}](${page.path.replace(/^docs\//, '')}) — ${page.summary}`); lines.push(''); } + ensureDir(paths.docs); fs.writeFileSync(path.join(paths.docs, 'llms.txt'), lines.join('\n').trimEnd() + '\n'); const full = manifest.pages.map((page) => fs.readFileSync(path.join(root, page.path), 'utf8')).join('\n\n---\n\n'); fs.writeFileSync(path.join(paths.docs, 'llms-full.txt'), full); + completeStage(root, 'publish', stableHash({ audit: summary.auditInputHash, pages: traces.map((trace) => trace.pageHash) }), { pages: search.length, categories: Object.keys(navigation).length }); + return { pages: search.length, categories: Object.keys(navigation).length, traceabilityPages: traces.length }; +} + +export function status(root) { + const current = state(root); const pages = Object.values(current.pages ?? {}); const stageOrder = ['index', 'modelCore', 'modelEnterprise', 'plan', 'generate', 'audit', 'publish']; const nextStage = stageOrder.find((name) => current.stages?.[name]?.status !== 'completed') ?? null; + return { state: current, summary: { nextStage, completedPages: pages.filter((page) => page.status === 'completed').length, failedPages: pages.filter((page) => page.status === 'failed').length, runningPages: pages.filter((page) => page.status === 'running').length }, budget: budgetReport(root), index: fs.existsSync(projectPaths(root).database) ? databaseStats(root) : null }; +} + +export async function all(root) { + phase('index', 1, 6); index(root); + phase('model', 2, 6); await model(root, { skipIndex: true }); + phase('plan', 3, 6); await plan(root); + phase('generate', 4, 6); await generate(root); + phase('audit', 5, 6); await audit(root); + phase('publish', 6, 6); const publishing = publish(root); + return { publishing, budget: budgetReport(root), snapshot: sourceSnapshot(root) }; +} diff --git a/global-template/docgen/lib/pipeline.mjs b/global-template/docgen/lib/pipeline.mjs index 296fb8b..e03c6de 100644 --- a/global-template/docgen/lib/pipeline.mjs +++ b/global-template/docgen/lib/pipeline.mjs @@ -1,301 +1,91 @@ -import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { compileContext } from './context.mjs'; -import { budgetReport, runProvider } from './provider.mjs'; -import { databaseStats, indexRepository, ingestModels } from './indexer.mjs'; -import { auditRepository } from './quality.mjs'; -import { ensureDir, fileSha256, kitVersion, loadConfig, now, projectPaths, readJson, rel, sha256, slug, sourceSnapshot, stableHash, updateStage, writeJson } from './core.mjs'; - -const moduleDir = path.dirname(fileURLToPath(import.meta.url)); - -function prompt(root, name, vars = {}) { - const override = path.join(root, '.docgen', 'prompts', name); const fallback = path.resolve(moduleDir, '..', 'prompts', name); - let text = fs.readFileSync(fs.existsSync(override) ? override : fallback, 'utf8'); - for (const [key, value] of Object.entries(vars)) text = text.replaceAll(`{{${key}}}`, String(value)); - return text; -} -function state(root) { return readJson(projectPaths(root).state, { schemaVersion: '2.0', kitVersion, stages: {}, pages: {} }); } -function writeState(root, next) { writeJson(projectPaths(root).state, { ...next, schemaVersion: '2.0', kitVersion, updatedAt: now() }); } -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)); } -function completeStage(root, stage, inputHash, details = {}) { const next = state(root); next.stages ??= {}; next.stages[stage] = { status: 'completed', completedAt: now(), inputHash, ...details }; writeState(root, next); } -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); } -function pageState(root, id) { return state(root).pages?.[id] ?? {}; } -function updatePage(root, id, patch) { const next = state(root); next.pages ??= {}; next.pages[id] = { ...(next.pages[id] ?? {}), ...patch, updatedAt: now() }; writeState(root, next); } -function modelPath(root, name) { return path.join(projectPaths(root).model, `${name}.json`); } -function tracePath(root, page) { return path.join(projectPaths(root).traceability, 'pages', `${page.id}.json`); } -function validateJson(file) { const value = readJson(file); if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Invalid JSON object: ${file}`); return value; } -function artifactStamp(file) { - if (!fs.existsSync(file)) return null; - const stat = fs.statSync(file, { bigint: true }); - return { hash: fileSha256(file), mtimeNs: String(stat.mtimeNs) }; -} -function artifactChanged(file, before) { - const after = artifactStamp(file); if (!after) return false; if (!before) return true; - return after.hash !== before.hash || after.mtimeNs !== before.mtimeNs; -} -function phase(name, position, total) { console.log(`[docgen] phase ${position}/${total} ${name.toUpperCase()}`); } - -export function index(root, options = {}) { - updateStage(root, 'index', 'running'); - try { const result = indexRepository(root, options); completeStage(root, 'index', result.inventoryFingerprint, result); return result; } - catch (error) { failStage(root, 'index', error); throw error; } -} - -function splitBundle(root, bundle, names) { - ensureDir(projectPaths(root).model); - for (const name of names) { - const value = bundle[name] ?? bundle[name.replaceAll('-', '')] ?? bundle[name.replace(/-([a-z])/g, (_, c) => c.toUpperCase())]; - if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Model bundle is missing object: ${name}`); - writeJson(modelPath(root, name), { schemaVersion: '2.0', generatedAt: now(), ...value }); - } -} - -async function synthesizeBundle(root, stage, names, query) { - const paths = projectPaths(root); const context = compileContext(root, { stage, query, target: stage, metadata: { expectedModels: names } }); - const output = path.join(paths.model, `${stage}-bundle.json`); const inputHash = context.payload.inputHash; const outputs = names.map((name) => modelPath(root, name)); - if (stageCurrent(root, stage, inputHash, outputs)) return { skipped: true, inputHash }; - ensureDir(paths.model); updateStage(root, stage, 'running', { inputHash, contextId: context.payload.id }); - const body = prompt(root, stage === 'modelCore' ? 'model-core.md' : 'model-enterprise.md', { CONTEXT_PATH: rel(root, context.file), OUTPUT_PATH: rel(root, output), MODEL_NAMES: JSON.stringify(names) }); - const outputBefore = artifactStamp(output); let bundleWritten = false; const acceptBundle = () => { - try { if (!artifactChanged(output, outputBefore)) return false; const bundle = validateJson(output); splitBundle(root, bundle, names); bundleWritten = true; return outputs.every((file) => fs.existsSync(file)); } - catch { return false; } - }; - try { - const provider = await runProvider(root, { stage, target: stage, prompt: body, acceptArtifacts: acceptBundle }); - if (!bundleWritten) { if (!artifactChanged(output, outputBefore)) throw new Error(`${stage}: provider completed without writing a fresh bundle artifact`); const bundle = validateJson(output); splitBundle(root, bundle, names); } - fs.rmSync(output, { force: true }); completeStage(root, stage, inputHash, { models: names, contextId: context.payload.id, contextTokens: context.payload.estimatedTokens, recovered: provider.recovered === true }); - return { skipped: false, recovered: provider.recovered === true, inputHash }; - } catch (error) { failStage(root, stage, error, { inputHash, contextId: context.payload.id }); throw error; } -} - +import * as base from './pipeline-base.mjs'; +import { guardedAudit } from './audit-guard.mjs'; +import { advisoryLlmAuditSummary, auditLlmMode, deterministicOnlyAuditSummary } from './audit-policy.mjs'; +import { budgetReport } from './provider.mjs'; +import { ingestModels } from './indexer.mjs'; +import { loadConfig, projectPaths, readJson, sourceSnapshot, updateStage, writeJson } from './core.mjs'; +import { synthesizeModels } from './model-synthesis.mjs'; + +export const index = base.index; export async function model(root, { skipIndex = false } = {}) { if (!skipIndex) index(root); - 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'); + await synthesizeModels(root, 'modelCore', ['system', 'business', 'flows', 'catalogs'], 'repository structure architecture components modules symbols interfaces contracts dependencies behavior domain rules states flows data and automation'); ingestModels(root); - 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'); + await synthesizeModels(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'); return ingestModels(root); } - -function canonicalPagePath(page, category, id) { - let value = String(page.path ?? '').replaceAll('\\', '/').replace(/^\/+/, ''); - if (!value) value = `docs/${category}/${slug(page.slug ?? page.title ?? id)}.md`; - if (!value.startsWith('docs/')) value = `docs/${value}`; - if (!value.endsWith('.md')) value += '.md'; - return value; -} -function normalizePage(page, indexValue) { - const id = slug(page.id ?? page.title ?? `page-${indexValue + 1}`); const category = slug(page.category ?? page.type ?? 'overview'); - 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) : [] }; -} -function validateManifest(root) { - 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[].'); - manifest.pages = manifest.pages.map(normalizePage); const ids = new Set(); const files = new Set(); const max = Number(loadConfig(root).execution?.maxPlannedPages ?? 30); - 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.`); - for (const page of manifest.pages) { - 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}`); - if (!page.title.trim() || !page.summary.trim()) throw new Error(`Page ${page.id} requires non-empty title and summary.`); - ids.add(page.id); files.add(page.path); - } - manifest.schemaVersion = '2.0'; manifest.generatedAt ??= now(); writeJson(paths.plan, manifest); return manifest; -} - -export async function plan(root) { - const paths = projectPaths(root); ingestModels(root); - 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', target: 'manifest', maxTokens: loadConfig(root).context?.maxTokens?.plan ?? 50000 }); - const inputHash = context.payload.inputHash; if (stageCurrent(root, 'plan', inputHash, [paths.plan])) return validateManifest(root); - updateStage(root, 'plan', 'running', { inputHash, contextId: context.payload.id }); - const planBefore = artifactStamp(paths.plan); let validManifest = null; const acceptManifest = () => { try { if (!artifactChanged(paths.plan, planBefore)) return false; validManifest = validateManifest(root); return true; } catch { return false; } }; - try { - const provider = await runProvider(root, { stage: 'plan', target: 'manifest', prompt: prompt(root, 'plan-indexed.md', { CONTEXT_PATH: rel(root, context.file), OUTPUT_PATH: rel(root, paths.plan) }), acceptArtifacts: acceptManifest }); - if (!validManifest && !artifactChanged(paths.plan, planBefore)) throw new Error('plan: provider completed without writing a fresh manifest artifact'); - const manifest = validManifest ?? validateManifest(root); completeStage(root, 'plan', inputHash, { pages: manifest.pages.length, contextId: context.payload.id, recovered: provider.recovered === true }); return manifest; - } catch (error) { failStage(root, 'plan', error, { inputHash, contextId: context.payload.id }); throw error; } -} - -function frontmatter(page) { return ['---', `title: ${JSON.stringify(page.title)}`, `description: ${JSON.stringify(page.summary)}`, `pageId: ${JSON.stringify(page.id)}`, `category: ${JSON.stringify(page.category)}`, `mode: ${JSON.stringify(page.mode)}`, `type: ${JSON.stringify(page.type)}`, `order: ${page.order}`, '---', ''].join('\n'); } -function markdownTable(headers, rows) { return `| ${headers.join(' | ')} |\n| ${headers.map(() => '---').join(' | ')} |\n${rows.map((row) => `| ${row.map((value) => String(value ?? '').replaceAll('|', '\\|').replace(/\r?\n/g, ' ')).join(' | ')} |`).join('\n')}\n`; } -function deterministicKind(page) { - const tags = new Set(page.coverageTags.map((tag) => tag.toLowerCase())); - const mapping = [ - ['endpoint-catalog', 'endpoints'], ['message-handler-catalog', 'messages'], ['external-dependency-catalog', 'dependencies'], ['dependency-catalog', 'dependencies'], - ['data-store-catalog', 'data-assets'], ['data-asset-catalog', 'data-assets'], ['scheduled-job-catalog', 'automations'], ['automation-catalog', 'automations'], - ['interface-catalog', 'interfaces'], ['component-catalog', 'components'], ['configuration-matrix', 'configuration'], ['ownership-responsibilities', 'ownership'], ['change-impact', 'change-impact'] - ]; - return mapping.find(([tag]) => tags.has(tag))?.[1] ?? null; -} -function evidenceText(item) { return (item.evidence ?? []).map((e) => [e.path, e.startLine ? `L${e.startLine}${e.endLine ? `-L${e.endLine}` : ''}` : ''].filter(Boolean).join('#')).join(', '); } -function arraysFor(document, keys) { const items = []; for (const key of keys) if (Array.isArray(document[key])) items.push(...document[key]); return items; } -function referenceData(root, kind) { - const catalog = readJson(modelPath(root, 'catalogs'), {}); const system = readJson(modelPath(root, 'system'), {}); - let model = 'catalogs'; let modelFiles = [modelPath(root, 'catalogs')]; let items = []; let headers = ['Name', 'Kind', 'Statement', 'Evidence']; let row; - if (kind === 'endpoints') { items = arraysFor(catalog, ['endpoints']); headers = ['Endpoint', 'Method', 'Path', 'Statement', 'Evidence']; row = (item) => [item.name ?? item.id, item.method ?? item.httpMethod ?? '', item.path ?? item.route ?? '', item.statement ?? item.summary ?? '', evidenceText(item)]; } - else if (kind === 'messages') { items = arraysFor(catalog, ['messageHandlers']); headers = ['Handler', 'Direction', 'Channel', 'Statement', 'Evidence']; row = (item) => [item.name ?? item.id, item.direction ?? item.role ?? '', item.topic ?? item.queue ?? item.channel ?? '', item.statement ?? item.summary ?? '', evidenceText(item)]; } - else if (kind === 'interfaces') items = arraysFor(catalog, ['interfaces', 'endpoints', 'messageHandlers', 'contracts']); - else if (kind === 'dependencies') items = arraysFor(catalog, ['dependencies', 'externalDependencies']); - else if (kind === 'data-assets') items = arraysFor(catalog, ['dataAssets', 'dataStores']); - else if (kind === 'automations') items = arraysFor(catalog, ['automations', 'scheduledJobs']); - else if (kind === 'components') { model = 'system'; modelFiles = [modelPath(root, 'system')]; items = arraysFor(system, ['components', 'modules', 'services', 'packages']); } - else { - model = kind; modelFiles = [modelPath(root, model)]; const document = readJson(modelPath(root, model), {}); items = Object.values(document).filter(Array.isArray).flat(); - } - const seen = new Set(); items = items.filter((item) => { const id = String(item?.id ?? stableHash(item)); if (seen.has(id)) return false; seen.add(id); return true; }); - row ??= (item) => [item.name ?? item.title ?? item.id, item.kind ?? '', item.statement ?? item.summary ?? item.description ?? '', evidenceText(item)]; - return { model, modelFiles, items, headers, rows: items.map(row) }; -} -function writeTrace(root, page, claims, inputHash, contextId = null) { - const pageFile = path.join(root, page.path); const trace = { schemaVersion: '2.0', pageId: page.id, pagePath: page.path, pageHash: sha256(fs.readFileSync(pageFile)), inputHash, contextId, generatedAt: now(), claims }; - writeJson(tracePath(root, page), trace); return trace; -} -function renderDeterministic(root, page, kind, inputHash) { - const data = referenceData(root, kind); const text = `${frontmatter(page)}# ${page.title}\n\n${page.summary}\n\n${markdownTable(data.headers, data.rows)}\n`; const file = path.join(root, page.path); ensureDir(path.dirname(file)); fs.writeFileSync(file, text); - const claims = data.items.map((item, indexValue) => ({ id: `${page.id}:${item.id ?? indexValue + 1}`, section: page.title, statement: item.statement ?? item.summary ?? item.description ?? item.name ?? item.id, classification: String(item.classification ?? (item.evidence?.length ? 'FACT' : 'UNKNOWN')).toUpperCase(), confidence: Number(item.confidence ?? (item.evidence?.length ? 1 : 0)), evidence: item.evidence ?? [], sourceModelRefs: [`${data.model}:${item.id ?? indexValue + 1}`] })); - writeTrace(root, page, claims, inputHash); return { items: data.items.length, hash: sha256(text) }; -} -function pageInputHash(page, contextHash) { return stableHash({ page, contextHash }); } -function finalizeProviderTrace(root, page, inputHash, contextId) { - const file = tracePath(root, page); const trace = validateJson(file); if (!Array.isArray(trace.claims)) throw new Error(`${rel(root, file)} must contain claims[].`); - const ids = new Set(); - for (const claim of trace.claims) { - claim.id = String(claim.id ?? `${page.id}:claim-${ids.size + 1}`); if (ids.has(claim.id)) throw new Error(`${page.id}: duplicate claim id ${claim.id}`); ids.add(claim.id); - claim.statement = String(claim.statement ?? '').trim(); claim.classification = String(claim.classification ?? 'UNKNOWN').toUpperCase(); claim.confidence = Number(claim.confidence ?? (claim.classification === 'FACT' ? 1 : 0)); claim.evidence = Array.isArray(claim.evidence) ? claim.evidence : []; claim.sourceModelRefs = Array.isArray(claim.sourceModelRefs) ? claim.sourceModelRefs : []; +export const plan = base.plan; +export const generate = base.generate; + +function ensureAuditDefaults(root) { + const paths = projectPaths(root); + const config = loadConfig(root); + config.audit ??= {}; + let changed = false; + if (!['off', 'advisory', 'blocking'].includes(String(config.audit.llmMode ?? '').toLowerCase())) { + config.audit.llmMode = 'off'; + changed = true; } - trace.schemaVersion = '2.0'; trace.pageId = page.id; trace.pagePath = page.path; trace.pageHash = sha256(fs.readFileSync(path.join(root, page.path))); trace.inputHash = inputHash; trace.contextId = contextId; trace.generatedAt = now(); writeJson(file, trace); return trace; -} -function validatePage(root, page, inputHash = null) { - const file = path.join(root, page.path); if (!fs.existsSync(file)) throw new Error(`Missing page: ${page.path}`); const text = fs.readFileSync(file, 'utf8'); - if (!/^---\s*\n[\s\S]*?\n---\s*\n/.test(text)) throw new Error(`${page.path}: missing frontmatter`); if (!/^#\s+\S/m.test(text)) throw new Error(`${page.path}: missing H1`); if (/```(?:plantuml|dot|graphviz|puml)/i.test(text)) throw new Error(`${page.path}: non-Mermaid diagram`); - const traceFile = tracePath(root, page); if (!fs.existsSync(traceFile)) throw new Error(`${page.path}: missing traceability sidecar`); const trace = validateJson(traceFile); if (!Array.isArray(trace.claims)) throw new Error(`${rel(root, traceFile)}: missing claims[]`); if (trace.pageId !== page.id || trace.pagePath !== page.path) throw new Error(`${rel(root, traceFile)}: page identity mismatch`); if (trace.pageHash && trace.pageHash !== sha256(text)) throw new Error(`${rel(root, traceFile)}: stale page hash`); if (inputHash && trace.inputHash !== inputHash) throw new Error(`${rel(root, traceFile)}: stale input hash`); - return { file, text, trace, hash: sha256(text) }; -} - -function checkpointGeneratedItems(root, batch, recovered = false, baseline = new Map()) { - const completed = []; const missing = []; - for (const item of batch) { - try { - const pageFile = path.join(root, item.page.path); const traceFile = tracePath(root, item.page); - if (!fs.existsSync(pageFile) || !fs.existsSync(traceFile)) throw new Error('expected output is missing'); - const before = baseline.get(item.page.id) ?? {}; const existingTrace = readJson(traceFile, {}); - const alreadyCurrent = existingTrace.inputHash === item.inputHash && existingTrace.contextId === item.context.payload.id; - if (!alreadyCurrent && !artifactChanged(pageFile, before.page) && !artifactChanged(traceFile, before.trace)) throw new Error('provider did not write a fresh artifact for the current input'); - finalizeProviderTrace(root, item.page, item.inputHash, item.context.payload.id); const checked = validatePage(root, item.page, item.inputHash); - updatePage(root, item.page.id, { status: 'completed', renderer: 'provider', recovered, inputHash: item.inputHash, pageHash: checked.hash, contextId: item.context.payload.id, completedAt: now(), error: null }); completed.push(item); - } catch (error) { missing.push({ ...item, checkpointError: error.message }); } - } - return { completed, missing }; -} - -async function generateBatch(root, batch, config, recoveryRound = 0) { - if (!batch.length) return { completed: 0, recovered: 0 }; - const maxRecoveryRounds = Math.max(1, Number(config.execution?.generationRecoveryAttempts ?? 3)); const batchId = sha256(batch.map((item) => `${item.page.id}:${item.inputHash}`).join('|')).slice(0, 12); - const baseline = new Map(batch.map((item) => [item.page.id, { page: artifactStamp(path.join(root, item.page.path)), trace: artifactStamp(tracePath(root, item.page)) }])); - for (const item of batch) updatePage(root, item.page.id, { status: 'running', renderer: 'provider', inputHash: item.inputHash, contextId: item.context.payload.id, batchId, recoveryRound, startedAt: now(), error: null }); - const contract = batch.map(({ page, context, inputHash }) => ({ page, contextPath: rel(root, context.file), outputPath: page.path, traceabilityPath: rel(root, tracePath(root, page)), inputHash, contextId: context.payload.id })); - let checkpoint = { completed: [], missing: batch }; let provider; - try { - provider = await runProvider(root, { - stage: 'generate', target: batch.map((item) => item.page.id).join(','), prompt: prompt(root, 'write-pages-indexed.md', { PAGE_CONTRACTS: JSON.stringify(contract, null, 2) }), - acceptArtifacts: () => { checkpoint = checkpointGeneratedItems(root, batch, true, baseline); return checkpoint.completed.length > 0; } - }); - checkpoint = checkpointGeneratedItems(root, batch, provider.recovered === true, baseline); - } catch (error) { - checkpoint = checkpointGeneratedItems(root, batch, true, baseline); - for (const item of checkpoint.missing) updatePage(root, item.page.id, { status: 'failed', failedAt: now(), error: item.checkpointError || error.message }); - if (!checkpoint.completed.length) throw error; + if (config.audit.requiredSectionsAsWarnings === undefined) { + config.audit.requiredSectionsAsWarnings = true; + changed = true; } - if (checkpoint.missing.length) { - if (recoveryRound + 1 >= maxRecoveryRounds) { - const error = new Error(`Generation batch ${batchId} produced ${checkpoint.completed.length}/${batch.length} valid page(s); recovery limit ${maxRecoveryRounds} reached for: ${checkpoint.missing.map((item) => item.page.id).join(', ')}`); - for (const item of checkpoint.missing) updatePage(root, item.page.id, { status: 'failed', failedAt: now(), error: error.message }); throw error; - } - console.warn(`[docgen] generate RECOVERY | checkpointed ${checkpoint.completed.length}/${batch.length}; retrying only ${checkpoint.missing.length} missing/invalid page(s).`); - const next = await generateBatch(root, checkpoint.missing, config, recoveryRound + 1); - return { completed: checkpoint.completed.length + next.completed, recovered: checkpoint.completed.length + next.recovered }; + if (changed) { + writeJson(paths.config, config); + console.log('[docgen] audit CONFIG MIGRATION | llmMode=off | requiredSectionsAsWarnings=true'); } - return { completed: checkpoint.completed.length, recovered: provider?.recovered === true ? checkpoint.completed.length : 0 }; + return config; } -export async function generate(root) { - const manifest = validateManifest(root); const config = loadConfig(root); const pending = []; let deterministicPages = 0; let reusedPages = 0; - updateStage(root, 'generate', 'running', { pages: manifest.pages.length }); - try { - for (const page of manifest.pages) { - const deterministic = deterministicKind(page); - if (deterministic) { - const data = referenceData(root, deterministic); const inputHash = stableHash({ page, models: data.modelFiles.map(fileSha256) }); - try { validatePage(root, page, inputHash); updatePage(root, page.id, { status: 'completed', renderer: 'deterministic', inputHash, pageHash: fileSha256(path.join(root, page.path)), recovered: pageState(root, page.id).status !== 'completed' }); reusedPages++; } - catch { const result = renderDeterministic(root, page, deterministic, inputHash); updatePage(root, page.id, { status: 'completed', renderer: 'deterministic', inputHash, pageHash: result.hash, completedAt: now(), error: null }); } - deterministicPages++; continue; - } - const context = compileContext(root, { stage: 'generate', target: page.id, query: page.query, maxTokens: config.context?.maxTokens?.generate ?? 30000, metadata: { page } }); const inputHash = pageInputHash(page, context.payload.inputHash); - try { - const checked = validatePage(root, page, inputHash); updatePage(root, page.id, { status: 'completed', renderer: 'provider', inputHash, pageHash: checked.hash, contextId: context.payload.id, recovered: pageState(root, page.id).status !== 'completed', error: null }); reusedPages++; continue; - } catch {} - pending.push({ page, context, inputHash }); - } - const size = Math.max(1, Number(config.execution?.generationBatchSize ?? 4)); let recoveredPages = 0; - for (let i = 0; i < pending.length; i += size) { const result = await generateBatch(root, pending.slice(i, i + size), config); recoveredPages += result.recovered; } - const inputHash = stableHash(manifest.pages.map((page) => [page.id, pageState(root, page.id).inputHash])); - completeStage(root, 'generate', inputHash, { pages: manifest.pages.length, providerPages: pending.length, deterministicPages, reusedPages, recoveredPages }); - return { pages: manifest.pages.length, providerPages: pending.length, deterministicPages, reusedPages, recoveredPages }; - } catch (error) { failStage(root, 'generate', error, { pages: manifest.pages.length }); throw error; } +async function finishDeterministicOnlyAudit(root) { + const paths = projectPaths(root); + const quality = readJson(path.join(paths.audit, 'deterministic.json')); + const summary = deterministicOnlyAuditSummary(quality); + writeJson(path.join(paths.audit, 'quality-summary.json'), summary); + updateStage(root, 'audit', summary.pass ? 'completed' : 'failed', { + ...summary, + inputHash: summary.auditInputHash, + mode: 'deterministic-only' + }); + if (!summary.pass) throw new Error(`Quality failed before LLM audit: deterministicFailures=${summary.deterministicFailures}, highRiskFindings=0. No audit-provider tokens were spent. See .docgen/audit/deterministic.json.`); + console.log('[docgen] audit DETERMINISTIC-ONLY | pass | provider calls 0'); + return summary; } export async function audit(root) { - const paths = projectPaths(root); const manifest = validateManifest(root); ensureDir(paths.audit); updateStage(root, 'audit', 'running'); + const config = ensureAuditDefaults(root); + const llmMode = auditLlmMode(config); try { - const quality = auditRepository(root, manifest); writeJson(path.join(paths.audit, 'deterministic.json'), quality); - const config = loadConfig(root); const threshold = Number(config.audit?.llmRiskThreshold ?? 50); const risky = quality.pages.filter((report) => report.riskScore >= threshold && !report.errors.length); const llmOutput = path.join(paths.audit, 'llm-risk.json'); let highRiskFindings = 0; - if (risky.length && config.audit?.llmEnabled !== false) { - const contexts = risky.map((report) => { const page = manifest.pages.find((item) => item.id === report.pageId); const context = compileContext(root, { stage: 'audit', target: page.id, query: page.query, maxTokens: config.context?.maxTokens?.audit ?? 18000, metadata: { page, deterministicReport: report } }); return { page, contextPath: rel(root, context.file), contextId: context.payload.id, pagePath: page.path, report }; }); - const riskInputHash = stableHash(contexts.map((item) => ({ pageId: item.page.id, pageHash: item.report.pageHash, inputHash: item.report.inputHash, contextId: item.contextId }))); - if (!stageCurrent(root, 'auditRisk', riskInputHash, [llmOutput])) { - const llmBefore = artifactStamp(llmOutput); let valid = null; const accept = () => { try { if (!artifactChanged(llmOutput, llmBefore)) return false; const result = validateJson(llmOutput); if (!Array.isArray(result.pages)) return false; valid = result; return true; } catch { return false; } }; - const provider = await runProvider(root, { stage: 'audit', target: risky.map((report) => report.pageId).join(','), prompt: prompt(root, 'audit-risk-indexed.md', { AUDIT_CONTRACTS: JSON.stringify(contexts, null, 2), OUTPUT_PATH: rel(root, llmOutput) }), acceptArtifacts: accept }); - if (!valid && !artifactChanged(llmOutput, llmBefore)) throw new Error('audit: provider completed without writing a fresh risk report artifact'); - const result = valid ?? validateJson(llmOutput); if (!Array.isArray(result.pages)) throw new Error('Risk audit report must contain pages[].'); completeStage(root, 'auditRisk', riskInputHash, { pages: risky.length, recovered: provider.recovered === true }); - } - const llm = readJson(llmOutput, { pages: [] }); highRiskFindings = (llm.pages ?? []).flatMap((page) => page.findings ?? []).filter((finding) => ['critical', 'high'].includes(String(finding.severity).toLowerCase())).length; + return await guardedAudit(root, llmMode === 'off' ? finishDeterministicOnlyAudit : base.audit); + } catch (error) { + const paths = projectPaths(root); + const summaryFile = path.join(paths.audit, 'quality-summary.json'); + const summary = readJson(summaryFile, null); + const advisory = advisoryLlmAuditSummary(summary, config); + if (advisory) { + writeJson(summaryFile, advisory); + updateStage(root, 'audit', 'completed', { + ...advisory, + inputHash: advisory.auditInputHash, + advisory: true, + llmMode, + originalError: error.message + }); + console.warn(`[docgen] audit ADVISORY | ${advisory.advisoryHighRiskFindings} high/critical LLM finding(s) recorded but not blocking.`); + return advisory; } - const pass = quality.pass && highRiskFindings === 0; - const summary = { schemaVersion: '2.0', generatedAt: now(), auditInputHash: quality.auditInputHash, inventoryFingerprint: quality.inventoryFingerprint, manifestHash: quality.manifestHash, pages: quality.metrics.pages, claims: quality.metrics.claims, evidenceReferences: quality.metrics.evidenceReferences, modelItems: quality.metrics.modelItems, referencedModelItems: quality.metrics.referencedModelItems, modelReferenceCoverage: quality.metrics.modelReferenceCoverage, deterministicFailures: quality.errors.length, deterministicWarnings: quality.warnings.length, llmAuditedPages: risky.length, highRiskFindings, pass }; - writeJson(path.join(paths.audit, 'quality-summary.json'), summary); - if (!pass) throw new Error(`Quality failed: deterministicFailures=${quality.errors.length}, highRiskFindings=${highRiskFindings}. See .docgen/audit/deterministic.json.`); - completeStage(root, 'audit', quality.auditInputHash, summary); return summary; - } catch (error) { failStage(root, 'audit', error); throw error; } -} - -export function publish(root) { - const paths = projectPaths(root); const manifest = validateManifest(root); const summary = readJson(path.join(paths.audit, 'quality-summary.json'), null); const auditState = state(root).stages?.audit; - if (!summary?.pass || auditState?.status !== 'completed' || auditState.inputHash !== summary.auditInputHash) throw new Error('Publish requires a current passing audit. Run `docgen audit` or `docgen resume`.'); - const inventory = readJson(paths.inventory, {}); if (summary.inventoryFingerprint !== inventory.fingerprint || summary.manifestHash !== stableHash(manifest.pages)) throw new Error('Audit is stale relative to the current inventory or manifest. Run `docgen audit`.'); - const currentQuality = auditRepository(root, manifest); - if (!currentQuality.pass || currentQuality.auditInputHash !== summary.auditInputHash) throw new Error('Audit is stale relative to current source, model, page, or traceability artifacts. Run `docgen resume`.'); - ensureDir(paths.publish); const navigation = {}; const search = []; const traces = []; - for (const page of manifest.pages) { - const checked = validatePage(root, page, pageState(root, page.id).inputHash); (navigation[page.category] ??= []).push({ id: page.id, title: page.title, path: page.path, order: page.order, summary: page.summary }); - search.push({ id: page.id, title: page.title, path: page.path, category: page.category, summary: page.summary, keywords: page.coverageTags, excerpt: checked.text.replace(/```[\s\S]*?```/g, ' ').replace(/[#>*_`\[\]()]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 400) }); - traces.push({ pageId: page.id, pagePath: page.path, pageHash: checked.trace.pageHash, inputHash: checked.trace.inputHash, claims: checked.trace.claims.length }); + throw error; } - for (const pages of Object.values(navigation)) pages.sort((a, b) => a.order - b.order); - writeJson(path.join(paths.publish, 'navigation.json'), { schemaVersion: '2.0', generatedAt: now(), navigation }); writeJson(path.join(paths.publish, 'search-index.json'), { schemaVersion: '2.0', generatedAt: now(), pages: search }); writeJson(path.join(paths.traceability, 'index.json'), { schemaVersion: '2.0', generatedAt: now(), pages: traces }); - const lines = [`# ${loadConfig(root).projectName || path.basename(root)}`, '']; for (const [category, pages] of Object.entries(navigation)) { lines.push(`## ${category}`, ''); for (const page of pages) lines.push(`- [${page.title}](${page.path.replace(/^docs\//, '')}) — ${page.summary}`); lines.push(''); } - ensureDir(paths.docs); fs.writeFileSync(path.join(paths.docs, 'llms.txt'), lines.join('\n').trimEnd() + '\n'); const full = manifest.pages.map((page) => fs.readFileSync(path.join(root, page.path), 'utf8')).join('\n\n---\n\n'); fs.writeFileSync(path.join(paths.docs, 'llms-full.txt'), full); - completeStage(root, 'publish', stableHash({ audit: summary.auditInputHash, pages: traces.map((trace) => trace.pageHash) }), { pages: search.length, categories: Object.keys(navigation).length }); - return { pages: search.length, categories: Object.keys(navigation).length, traceabilityPages: traces.length }; } - +export const publish = base.publish; export function status(root) { - const current = state(root); const pages = Object.values(current.pages ?? {}); const stageOrder = ['index', 'modelCore', 'modelEnterprise', 'plan', 'generate', 'audit', 'publish']; const nextStage = stageOrder.find((name) => current.stages?.[name]?.status !== 'completed') ?? null; - return { state: current, summary: { nextStage, completedPages: pages.filter((page) => page.status === 'completed').length, failedPages: pages.filter((page) => page.status === 'failed').length, runningPages: pages.filter((page) => page.status === 'running').length }, budget: budgetReport(root), index: fs.existsSync(projectPaths(root).database) ? databaseStats(root) : null }; + const result = base.status(root); + result.summary.degradedModels = ['modelCore', 'modelEnterprise'].flatMap((stage) => + (result.state.stages?.[stage]?.degradedModels ?? []).map((name) => `${stage}:${name}`)); + return result; } - export async function all(root) { - phase('index', 1, 6); index(root); - phase('model', 2, 6); await model(root, { skipIndex: true }); - phase('plan', 3, 6); await plan(root); - phase('generate', 4, 6); await generate(root); - phase('audit', 5, 6); await audit(root); - phase('publish', 6, 6); const publishing = publish(root); - return { publishing, budget: budgetReport(root), snapshot: sourceSnapshot(root) }; + index(root); await model(root, { skipIndex: true }); await plan(root); await generate(root); await audit(root); + return { publishing: publish(root), budget: budgetReport(root), snapshot: sourceSnapshot(root) }; } diff --git a/global-template/docgen/lib/quality.mjs b/global-template/docgen/lib/quality.mjs index 15ef40d..b27a7c4 100644 --- a/global-template/docgen/lib/quality.mjs +++ b/global-template/docgen/lib/quality.mjs @@ -1,17 +1,32 @@ import fs from 'node:fs'; import path from 'node:path'; import { loadConfig, now, posix, projectPaths, readJson, sha256, stableHash } from './core.mjs'; +import { evidenceFromAliases, normalizeSourceModelRefs, semanticMetadata } from './semantic.mjs'; -const CLASSIFICATIONS = new Set(['FACT', 'INFERENCE', 'ASSUMPTION', 'UNKNOWN']); const GENERIC_FILLER = [ /\bthis (?:page|document|section) (?:provides|describes|covers) (?:an )?overview\b/i, /\bin (?:this|the following) (?:page|document|section),? we (?:will|shall)\b/i, /\bmore details (?:will|can) be added\b/i ]; +const SECTION_STOPWORDS = new Set(['a','an','and','as','at','by','for','from','in','into','of','on','or','the','to','with','all','used','using','explanation','overview','catalog','inventory','model','strategy','requirements']); function tracePath(root, page) { return path.join(projectPaths(root).traceability, 'pages', `${page.id}.json`); } function modelPath(root, name) { return path.join(projectPaths(root).model, `${name}.json`); } function normalizeText(value) { return String(value ?? '').toLowerCase().replace(/[`*_>#\[\](){}:;,.!?"']/g, ' ').replace(/\s+/g, ' ').trim(); } +function token(value) { return String(value).replace(/ies$/i, 'y').replace(/(?:ing|ed)$/i, '').replace(/s$/i, ''); } +function sectionTokens(value) { return normalizeText(value).split(/\s+/).map(token).filter((item) => item.length > 1 && !SECTION_STOPWORDS.has(item) && !/^\d+$/.test(item)); } +function sectionSatisfied(requirement, headings, text) { + const exact = normalizeText(requirement); if (!exact) return true; + if (normalizeText(text).includes(exact)) return true; + const required = [...new Set(sectionTokens(requirement))]; + return headings.some((heading) => { + const normalized = normalizeText(heading); if (normalized.includes(exact) || exact.includes(normalized)) return true; + const available = new Set(sectionTokens(heading)); + if (!required.length) return normalized.includes(exact); + const matched = required.filter((item) => available.has(item)).length; + return matched >= Math.min(2, required.length) && matched / required.length >= 0.5; + }); +} function bodyFingerprint(text) { return sha256(String(text).replace(/^---\s*\n[\s\S]*?\n---\s*\n/, '').replace(/^#\s+.*$/m, '').replace(/\s+/g, ' ').trim().toLowerCase()); } function safeRelative(value) { const raw = posix(String(value ?? '').trim()).replace(/^\.\//, ''); @@ -24,35 +39,20 @@ function state(root) { return readJson(projectPaths(root).state, { stages: {}, p function inventoryContext(root) { const inventory = readJson(projectPaths(root).inventory, { files: [], excluded: [], fingerprint: null }); - return { - inventory, - files: new Map((inventory.files ?? []).map((item) => [item.path, item])), - excluded: new Map((inventory.excluded ?? []).map((item) => [item.path, item])) - }; + return { inventory, files: new Map((inventory.files ?? []).map((item) => [item.path, item])), excluded: new Map((inventory.excluded ?? []).map((item) => [item.path, item])) }; } - function sourceInfo(root, rel, cache) { if (cache.has(rel)) return cache.get(rel); - const file = path.join(root, rel); - if (!fs.existsSync(file)) { cache.set(rel, null); return null; } - const buffer = fs.readFileSync(file); const text = buffer.toString('utf8'); - const value = { hash: sha256(buffer), lines: text.split(/\r?\n/), text }; - cache.set(rel, value); return value; + const file = path.join(root, rel); if (!fs.existsSync(file)) { cache.set(rel, null); return null; } + const buffer = fs.readFileSync(file); const text = buffer.toString('utf8'); const value = { hash: sha256(buffer), lines: text.split(/\r?\n/), text }; cache.set(rel, value); return value; } - function validateEvidence(root, evidence, { inventory, sourceCache, requireLine = false, prefix = 'evidence' }) { const errors = []; const warnings = []; if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) return { errors: [`${prefix}: must be an object`], warnings }; - const rel = safeRelative(evidence.path); - if (!rel) return { errors: [`${prefix}: invalid repository-relative path`], warnings }; + const rel = safeRelative(evidence.path); if (!rel) return { errors: [`${prefix}: invalid repository-relative path`], warnings }; const item = inventory.files.get(rel); - if (!item) { - const excluded = inventory.excluded.get(rel); - errors.push(`${prefix}: ${rel} is not in the indexed source inventory${excluded ? ` (${excluded.reason})` : ''}`); - return { errors, warnings }; - } - const source = sourceInfo(root, rel, sourceCache); - if (!source) { errors.push(`${prefix}: indexed source no longer exists: ${rel}`); return { errors, warnings }; } + if (!item) { const excluded = inventory.excluded.get(rel); errors.push(`${prefix}: ${rel} is not in the indexed source inventory${excluded ? ` (${excluded.reason})` : ''}`); return { errors, warnings }; } + const source = sourceInfo(root, rel, sourceCache); if (!source) { errors.push(`${prefix}: indexed source no longer exists: ${rel}`); return { errors, warnings }; } if (item.hash && source.hash !== item.hash) errors.push(`${prefix}: source changed after indexing: ${rel}; run docgen index/resume`); const hasStart = evidence.startLine !== undefined && evidence.startLine !== null; if (requireLine && !hasStart) errors.push(`${prefix}: FACT evidence requires startLine: ${rel}`); @@ -65,155 +65,124 @@ function validateEvidence(root, evidence, { inventory, sourceCache, requireLine } return { errors, warnings }; } +function dedupeEvidence(entries) { + const seen = new Set(); return entries.filter((entry) => { const key = `${entry.path}\0${entry.startLine ?? ''}\0${entry.endLine ?? ''}`; if (seen.has(key)) return false; seen.add(key); return true; }); +} function walkModelItems(value, model, out, parent = '') { if (Array.isArray(value)) { for (const item of value) walkModelItems(item, model, out, parent); return; } if (!value || typeof value !== 'object') return; - if (value.id || value.name || value.statement) { - const semanticId = String(value.id ?? sha256(`${model}\0${parent}\0${JSON.stringify(value)}`).slice(0, 24)); - out.push({ ref: `${model}:${semanticId}`, semanticId, model, kind: String(value.kind ?? parent ?? 'item'), value }); - } - for (const [key, child] of Object.entries(value)) if (!['evidence', 'sourceModelRefs'].includes(key)) walkModelItems(child, model, out, key); + if (value.id || value.name || value.statement) { const semanticId = String(value.id ?? sha256(`${model}\0${parent}\0${JSON.stringify(value)}`).slice(0, 24)); out.push({ ref: `${model}:${semanticId}`, semanticId, model, kind: String(value.kind ?? parent ?? 'item'), value }); } + for (const [key, child] of Object.entries(value)) if (!['evidence','evidenceRefs','sources','sourceRefs','citations','references','sourceModelRefs','modelRefs'].includes(key)) walkModelItems(child, model, out, key); } - function auditModels(root, inventory, settings) { - const paths = projectPaths(root); const errors = []; const warnings = []; const sourceCache = new Map(); const refs = new Map(); const byModel = {}; + const paths = projectPaths(root); const errors = []; const warnings = []; const sourceCache = new Map(); const refs = new Map(); const aliases = new Map(); const byModel = {}; const all = []; const files = fs.existsSync(paths.model) ? fs.readdirSync(paths.model).filter((name) => name.endsWith('.json') && !name.endsWith('-bundle.json')).sort() : []; for (const file of files) { const model = path.basename(file, '.json'); const items = []; let document; try { document = readJson(modelPath(root, model)); } catch (error) { errors.push(`${model}: invalid JSON: ${error.message}`); continue; } - walkModelItems(document, model, items); byModel[model] = { items: items.length, facts: 0, errors: 0, warnings: 0 }; - const local = new Set(); - for (const item of items) { - const prefix = `model ${item.ref}`; const value = item.value; - if (local.has(item.semanticId)) { errors.push(`${prefix}: duplicate id within ${model}`); byModel[model].errors++; continue; } - local.add(item.semanticId); - if (refs.has(item.ref)) { errors.push(`${prefix}: duplicate qualified model reference`); byModel[model].errors++; continue; } - refs.set(item.ref, item); - const statement = String(value.statement ?? value.summary ?? value.description ?? '').trim(); - const name = String(value.name ?? value.title ?? '').trim(); - if (!statement && !name) { warnings.push(`${prefix}: missing name and statement`); byModel[model].warnings++; } - const classification = String(value.classification ?? 'UNKNOWN').toUpperCase(); - if (!CLASSIFICATIONS.has(classification)) { errors.push(`${prefix}: invalid classification ${classification}`); byModel[model].errors++; } - const confidence = Number(value.confidence ?? (classification === 'FACT' ? 1 : 0)); - if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) { errors.push(`${prefix}: confidence must be between 0 and 1`); byModel[model].errors++; } - const evidence = Array.isArray(value.evidence) ? value.evidence : []; - if (classification === 'FACT') { - byModel[model].facts++; - if (!evidence.length) { errors.push(`${prefix}: FACT item has no evidence`); byModel[model].errors++; } - } - for (let index = 0; index < evidence.length; index++) { - const result = validateEvidence(root, evidence[index], { inventory, sourceCache, requireLine: classification === 'FACT' && settings.requireLineEvidenceForFacts, prefix: `${prefix} evidence[${index}]` }); - errors.push(...result.errors); warnings.push(...result.warnings); byModel[model].errors += result.errors.length; byModel[model].warnings += result.warnings.length; - } + walkModelItems(document, model, items); byModel[model] = { items: items.length, facts: 0, errors: 0, warnings: 0 }; const local = new Set(); + items.forEach((item, index) => { + const prefix = `model ${item.ref}`; + if (local.has(item.semanticId)) { errors.push(`${prefix}: duplicate id within ${model}`); byModel[model].errors++; return; } + local.add(item.semanticId); if (refs.has(item.ref)) { errors.push(`${prefix}: duplicate qualified model reference`); byModel[model].errors++; return; } + item.metadata = semanticMetadata(item.value); refs.set(item.ref, item); aliases.set(`${model}:${index + 1}`, item.ref); all.push(item); + }); + } + const resolveRef = (ref) => refs.get(ref) ?? refs.get(aliases.get(ref)); + const resolving = new Set(); + function effectiveEvidence(item) { + if (item.effectiveEvidence) return item.effectiveEvidence; + if (resolving.has(item.ref)) return item.metadata.evidence; + resolving.add(item.ref); const inherited = []; + for (const ref of item.metadata.sourceModelRefs) { const source = resolveRef(ref); if (source) inherited.push(...effectiveEvidence(source)); } + resolving.delete(item.ref); item.effectiveEvidence = dedupeEvidence([...item.metadata.evidence, ...inherited]); return item.effectiveEvidence; + } + for (const item of all) { + const prefix = `model ${item.ref}`; const value = item.value; const summary = byModel[item.model]; + const statement = String(value.statement ?? value.summary ?? value.description ?? '').trim(); const name = String(value.name ?? value.title ?? '').trim(); + if (!statement && !name) { warnings.push(`${prefix}: missing name and statement`); summary.warnings++; } + const evidence = effectiveEvidence(item); item.effectiveClassification = item.metadata.requestedClassification === 'FACT' && evidence.length ? 'FACT' : item.metadata.classification; + item.effectiveConfidence = item.metadata.confidence; + if (item.metadata.unsupportedFact && !evidence.length) { warnings.push(`${prefix}: provider FACT had no direct or inherited evidence; treated as INFERENCE`); summary.warnings++; } + if (item.effectiveClassification === 'FACT') summary.facts++; + for (let index = 0; index < evidence.length; index++) { + const result = validateEvidence(root, evidence[index], { inventory, sourceCache, requireLine: item.effectiveClassification === 'FACT' && settings.requireLineEvidenceForFacts, prefix: `${prefix} evidence[${index}]` }); + errors.push(...result.errors); warnings.push(...result.warnings); summary.errors += result.errors.length; summary.warnings += result.warnings.length; } } - return { files, refs, errors, warnings, byModel, items: refs.size, inputHash: stableHash(files.map((name) => [name, sha256(fs.readFileSync(path.join(paths.model, name))) ])) }; + return { files, refs, aliases, errors, warnings, byModel, items: refs.size, inputHash: stableHash(files.map((name) => [name, sha256(fs.readFileSync(path.join(paths.model, name))) ])) }; } function contextForPage(root, page, trace) { if (!trace.contextId) return null; - const file = path.join(projectPaths(root).context, 'generate', `${page.id.replace(/[^a-z0-9_.-]+/gi, '-')}.json`); - if (!fs.existsSync(file)) return { error: `missing declared generation context for ${page.id}` }; - const value = readJson(file, {}); - if (value.id !== trace.contextId) return { error: `context identity mismatch for ${page.id}` }; - const refs = new Set((value.modelItems ?? []).map((item) => item.id)); - const evidence = []; + const file = path.join(projectPaths(root).context, 'generate', `${page.id.replace(/[^a-z0-9_.-]+/gi, '-')}.json`); if (!fs.existsSync(file)) return { error: `missing declared generation context for ${page.id}` }; + const value = readJson(file, {}); if (value.id !== trace.contextId) return { error: `context identity mismatch for ${page.id}` }; + const refs = new Set(); const aliases = new Map(); const perModel = new Map(); const evidence = []; + for (const item of value.modelItems ?? []) { refs.add(item.id); const number = (perModel.get(item.model) ?? 0) + 1; perModel.set(item.model, number); aliases.set(`${item.model}:${number}`, item.id); for (const entry of evidenceFromAliases(item)) evidence.push(entry); } for (const fact of value.facts ?? []) evidence.push({ path: fact.path, startLine: fact.metadata?.startLine ?? fact.line, endLine: fact.metadata?.endLine ?? fact.line }); - for (const item of value.modelItems ?? []) for (const entry of item.evidence ?? []) evidence.push(entry); - return { value, refs, evidence }; + return { value, refs, aliases, evidence }; } - function evidenceWasSupplied(evidence, supplied) { - const rel = safeRelative(evidence?.path); if (!rel) return false; - const line = Number(evidence.startLine ?? 0); - return supplied.some((entry) => { - if (safeRelative(entry?.path) !== rel) return false; - if (!line) return true; - const start = Number(entry.startLine ?? 0); const end = Number(entry.endLine ?? start); - return !start || (line >= start && line <= end); - }); + const rel = safeRelative(evidence?.path); if (!rel) return false; const line = Number(evidence.startLine ?? 0); + return supplied.some((entry) => { if (safeRelative(entry?.path) !== rel) return false; if (!line) return true; const start = Number(entry.startLine ?? 0); const end = Number(entry.endLine ?? start); return !start || (line >= start && line <= end); }); } - -function markdownLinks(text) { - const links = []; - for (const match of text.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) links.push(match[1].trim().replace(/^<|>$/g, '')); - return links; -} - +function markdownLinks(text) { return [...text.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)].map((match) => match[1].trim().replace(/^<|>$/g, '')); } function validateLink(root, page, target) { - if (!target || /^(?:[a-z]+:|#)/i.test(target)) return null; - const clean = target.split('#')[0].split('?')[0]; if (!clean) return null; - const candidate = clean.startsWith('/') ? path.join(root, clean.replace(/^\/+/, '')) : path.resolve(path.dirname(path.join(root, page.path)), clean); - const relative = posix(path.relative(root, candidate)); - if (relative === '..' || relative.startsWith('../')) return `link escapes repository: ${target}`; - if (!fs.existsSync(candidate)) return `broken local link: ${target}`; - return null; + if (!target || /^(?:[a-z]+:|#)/i.test(target)) return null; const clean = target.split('#')[0].split('?')[0]; if (!clean) return null; + const candidate = clean.startsWith('/') ? path.join(root, clean.replace(/^\/+/, '')) : path.resolve(path.dirname(path.join(root, page.path)), clean); const relative = posix(path.relative(root, candidate)); + if (relative === '..' || relative.startsWith('../')) return `link escapes repository: ${target}`; if (!fs.existsSync(candidate)) return `broken local link: ${target}`; return null; } -function auditPage(root, page, inventory, modelRefs, manifestIds, settings) { - const errors = []; const warnings = []; const paths = projectPaths(root); const pageFile = path.join(root, page.path); const traceFile = tracePath(root, page); const pageState = state(root).pages?.[page.id] ?? {}; +function auditPage(root, page, inventory, models, manifestIds, settings) { + const errors = []; const warnings = []; const pageFile = path.join(root, page.path); const traceFile = tracePath(root, page); const pageState = state(root).pages?.[page.id] ?? {}; if (!fs.existsSync(pageFile)) return { pageId: page.id, path: page.path, errors: [`missing page: ${page.path}`], warnings, riskScore: 100, claims: 0, evidence: 0, modelRefs: 0 }; const text = fs.readFileSync(pageFile, 'utf8'); const pageHash = sha256(text); - if (!/^---\s*\n[\s\S]*?\n---\s*\n/.test(text)) errors.push('missing YAML frontmatter'); - if (!/^#\s+\S/m.test(text)) errors.push('missing H1'); - if (/```(?:plantuml|dot|graphviz|puml)/i.test(text)) errors.push('contains non-Mermaid diagram'); - if (/\b(?:TODO|TBD|FIXME)\b/i.test(text)) warnings.push('contains unresolved placeholder'); - if (GENERIC_FILLER.some((pattern) => pattern.test(text))) warnings.push('contains generic filler language'); - const headings = new Set([...text.matchAll(/^#{2,6}\s+(.+)$/gm)].map((match) => normalizeText(match[1]))); - for (const section of page.requiredSections ?? []) if (![...headings].some((heading) => heading.includes(normalizeText(section)))) errors.push(`missing required section: ${section}`); + if (!/^---\s*\n[\s\S]*?\n---\s*\n/.test(text)) errors.push('missing YAML frontmatter'); if (!/^#\s+\S/m.test(text)) errors.push('missing H1'); if (/```(?:plantuml|dot|graphviz|puml)/i.test(text)) errors.push('contains non-Mermaid diagram'); + if (/\b(?:TODO|TBD|FIXME)\b/i.test(text)) warnings.push('contains unresolved placeholder'); if (GENERIC_FILLER.some((pattern) => pattern.test(text))) warnings.push('contains generic filler language'); + const headings = [...text.matchAll(/^#{2,6}\s+(.+)$/gm)].map((match) => match[1]); + for (const section of page.requiredSections ?? []) if (!sectionSatisfied(section, headings, text)) (settings.requiredSectionsAsWarnings ? warnings : errors).push(`missing required section: ${section}`); for (const related of page.relatedPages ?? []) if (!manifestIds.has(String(related)) && !manifestIds.has(String(related).replace(/\.md$/i, ''))) warnings.push(`unknown related page: ${related}`); for (const link of markdownLinks(text)) { const problem = validateLink(root, page, link); if (problem) errors.push(problem); } if (!fs.existsSync(traceFile)) return { pageId: page.id, path: page.path, pageHash, inputHash: pageState.inputHash, errors: [...errors, 'missing traceability sidecar'], warnings, riskScore: 100, claims: 0, evidence: 0, modelRefs: 0, bodyHash: bodyFingerprint(text) }; - let trace; - try { trace = readJson(traceFile); } catch (error) { return { pageId: page.id, path: page.path, pageHash, inputHash: pageState.inputHash, errors: [...errors, `invalid traceability JSON: ${error.message}`], warnings, riskScore: 100, claims: 0, evidence: 0, modelRefs: 0, bodyHash: bodyFingerprint(text) }; } - if (trace.pageId !== page.id || trace.pagePath !== page.path) errors.push('traceability page identity mismatch'); - if (trace.pageHash !== pageHash) errors.push('stale traceability page hash'); - if (!pageState.inputHash) errors.push('missing page checkpoint inputHash'); - else if (trace.inputHash !== pageState.inputHash) errors.push('stale traceability input hash'); - const context = contextForPage(root, page, trace); if (context?.error) errors.push(context.error); - const claims = Array.isArray(trace.claims) ? trace.claims : []; if (!Array.isArray(trace.claims)) errors.push('traceability claims must be an array'); - const ids = new Set(); const sourceCache = new Map(); let evidenceCount = 0; let modelRefCount = 0; + let trace; try { trace = readJson(traceFile); } catch (error) { return { pageId: page.id, path: page.path, pageHash, inputHash: pageState.inputHash, errors: [...errors, `invalid traceability JSON: ${error.message}`], warnings, riskScore: 100, claims: 0, evidence: 0, modelRefs: 0, bodyHash: bodyFingerprint(text) }; } + if (trace.pageId !== page.id || trace.pagePath !== page.path) errors.push('traceability page identity mismatch'); if (trace.pageHash !== pageHash) errors.push('stale traceability page hash'); if (!pageState.inputHash) errors.push('missing page checkpoint inputHash'); else if (trace.inputHash !== pageState.inputHash) errors.push('stale traceability input hash'); + const context = contextForPage(root, page, trace); if (context?.error) errors.push(context.error); const claims = Array.isArray(trace.claims) ? trace.claims : []; if (!Array.isArray(trace.claims)) errors.push('traceability claims must be an array'); + const ids = new Set(); const sourceCache = new Map(); const referencedModelRefs = []; let evidenceCount = 0; let modelRefCount = 0; for (let index = 0; index < claims.length; index++) { const claim = claims[index] ?? {}; const id = String(claim.id ?? '').trim(); const prefix = `claim ${id || index + 1}`; if (!id) errors.push(`${prefix}: missing id`); else if (ids.has(id)) errors.push(`${prefix}: duplicate id`); else ids.add(id); - const statement = String(claim.statement ?? '').trim(); if (!statement) errors.push(`${prefix}: missing statement`); - else if (!normalizeText(text).includes(normalizeText(statement))) warnings.push(`${prefix}: statement is not directly present in page prose`); - const classification = String(claim.classification ?? 'UNKNOWN').toUpperCase(); if (!CLASSIFICATIONS.has(classification)) errors.push(`${prefix}: invalid classification ${classification}`); - const confidence = Number(claim.confidence ?? (classification === 'FACT' ? 1 : 0)); if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) errors.push(`${prefix}: confidence must be between 0 and 1`); - const evidence = Array.isArray(claim.evidence) ? claim.evidence : []; evidenceCount += evidence.length; - if (classification === 'FACT' && !evidence.length) errors.push(`${prefix}: FACT claim has no direct evidence`); + const rawRefs = normalizeSourceModelRefs(claim.sourceModelRefs ?? claim.modelRefs); const resolved = rawRefs.map((ref) => models.refs.has(ref) ? ref : models.aliases.get(ref) ?? ref); modelRefCount += rawRefs.length; + const modelItems = resolved.map((ref) => models.refs.get(ref)).filter(Boolean); const fallbackStatement = modelItems.map((item) => String(item.value.statement ?? item.value.summary ?? item.value.description ?? item.value.name ?? '')).find(Boolean); + const statement = String(claim.statement ?? fallbackStatement ?? '').trim(); if (!statement) errors.push(`${prefix}: missing statement`); else if (!normalizeText(text).includes(normalizeText(statement))) warnings.push(`${prefix}: statement is not directly present in page prose`); + const metadata = semanticMetadata(claim); const inheritedEvidence = modelItems.flatMap((item) => item.effectiveEvidence ?? []); const evidence = dedupeEvidence([...metadata.evidence, ...inheritedEvidence]); const classification = metadata.requestedClassification === 'FACT' && evidence.length ? 'FACT' : metadata.classification; + if (metadata.unsupportedFact && !evidence.length) warnings.push(`${prefix}: provider FACT had no direct or inherited evidence; treated as INFERENCE`); evidenceCount += evidence.length; for (let evidenceIndex = 0; evidenceIndex < evidence.length; evidenceIndex++) { const result = validateEvidence(root, evidence[evidenceIndex], { inventory, sourceCache, requireLine: classification === 'FACT' && settings.requireLineEvidenceForFacts, prefix: `${prefix} evidence[${evidenceIndex}]` }); errors.push(...result.errors); warnings.push(...result.warnings); if (settings.requireContextBoundEvidence && context && !context.error && !evidenceWasSupplied(evidence[evidenceIndex], context.evidence)) errors.push(`${prefix}: evidence was not supplied in bounded context: ${evidence[evidenceIndex]?.path ?? ''}`); } - const refs = Array.isArray(claim.sourceModelRefs) ? claim.sourceModelRefs : []; modelRefCount += refs.length; - for (const ref of refs) { - if (!modelRefs.has(ref)) errors.push(`${prefix}: unknown sourceModelRef ${ref}`); - if (settings.requireContextBoundEvidence && context && !context.error && !context.refs.has(ref)) errors.push(`${prefix}: sourceModelRef was not supplied in bounded context: ${ref}`); + for (let refIndex = 0; refIndex < rawRefs.length; refIndex++) { + const raw = rawRefs[refIndex]; const canonical = resolved[refIndex]; if (!models.refs.has(canonical)) errors.push(`${prefix}: unknown sourceModelRef ${raw}`); else referencedModelRefs.push(canonical); + if (settings.requireContextBoundEvidence && context && !context.error && !context.refs.has(canonical) && context.aliases.get(raw) !== canonical) errors.push(`${prefix}: sourceModelRef was not supplied in bounded context: ${raw}`); } } - const riskScore = (['business', 'security', 'decision-record', 'runbook', 'migration-guide'].includes(page.type) ? 30 : 0) + (page.risk === 'high' ? 40 : page.risk === 'critical' ? 70 : 0) + warnings.length * 5; - return { pageId: page.id, path: page.path, pageHash, inputHash: pageState.inputHash, bodyHash: bodyFingerprint(text), errors, warnings, riskScore, claims: claims.length, evidence: evidenceCount, modelRefs: modelRefCount, referencedModelRefs: claims.flatMap((claim) => Array.isArray(claim.sourceModelRefs) ? claim.sourceModelRefs : []) }; + const riskScore = (['business','security','decision-record','runbook','migration-guide'].includes(page.type) ? 30 : 0) + (page.risk === 'high' ? 40 : page.risk === 'critical' ? 70 : 0) + warnings.length * 5; + return { pageId: page.id, path: page.path, pageHash, inputHash: pageState.inputHash, bodyHash: bodyFingerprint(text), errors, warnings, riskScore, claims: claims.length, evidence: evidenceCount, modelRefs: modelRefCount, referencedModelRefs }; } export function auditRepository(root, manifest) { - const config = loadConfig(root); const settings = { requireLineEvidenceForFacts: config.audit?.requireLineEvidenceForFacts !== false, requireContextBoundEvidence: config.audit?.requireContextBoundEvidence !== false }; + const config = loadConfig(root); const settings = { requireLineEvidenceForFacts: config.audit?.requireLineEvidenceForFacts !== false, requireContextBoundEvidence: config.audit?.requireContextBoundEvidence !== false, requiredSectionsAsWarnings: config.audit?.requiredSectionsAsWarnings === true }; const inventory = inventoryContext(root); const models = auditModels(root, inventory, settings); const manifestIds = new Set(manifest.pages.flatMap((page) => [page.id, page.path, page.path.replace(/^docs\//, '').replace(/\.md$/i, '')])); - const pages = manifest.pages.map((page) => auditPage(root, page, inventory, models.refs, manifestIds, settings)); const errors = [...models.errors]; const warnings = [...models.warnings]; + const pages = manifest.pages.map((page) => auditPage(root, page, inventory, models, manifestIds, settings)); const errors = [...models.errors]; const warnings = [...models.warnings]; for (const page of pages) { errors.push(...page.errors.map((message) => `${page.pageId}: ${message}`)); warnings.push(...page.warnings.map((message) => `${page.pageId}: ${message}`)); } const bodyOwners = new Map(); const statementOwners = new Map(); const referenced = new Set(); for (const page of pages) { if (page.bodyHash) { const previous = bodyOwners.get(page.bodyHash); if (previous) errors.push(`duplicate page body: ${previous} and ${page.pageId}`); else bodyOwners.set(page.bodyHash, page.pageId); } - for (const ref of page.referencedModelRefs ?? []) referenced.add(ref); - const trace = fs.existsSync(tracePath(root, { id: page.pageId })) ? readJson(tracePath(root, { id: page.pageId }), {}) : {}; + for (const ref of page.referencedModelRefs ?? []) referenced.add(ref); const trace = fs.existsSync(tracePath(root, { id: page.pageId })) ? readJson(tracePath(root, { id: page.pageId }), {}) : {}; for (const claim of trace.claims ?? []) { const key = normalizeText(claim.statement); if (!key) continue; const previous = statementOwners.get(key); if (previous && previous !== page.pageId) warnings.push(`duplicate material claim across pages: ${previous} and ${page.pageId}`); else statementOwners.set(key, page.pageId); } } - const traceDir = path.join(projectPaths(root).traceability, 'pages'); - if (fs.existsSync(traceDir)) for (const file of fs.readdirSync(traceDir).filter((name) => name.endsWith('.json'))) { const id = path.basename(file, '.json'); if (!manifest.pages.some((page) => page.id === id)) warnings.push(`orphan traceability sidecar: ${file}`); } - const coverageRatio = models.items ? referenced.size / models.items : 1; const minimumCoverage = Math.max(0, Math.min(1, Number(config.audit?.minModelReferenceCoverage ?? 0))); - if (coverageRatio < minimumCoverage) errors.push(`model reference coverage ${(coverageRatio * 100).toFixed(1)}% is below configured ${(minimumCoverage * 100).toFixed(1)}%`); - if (config.audit?.failOnWarnings === true && warnings.length) errors.push(`audit.failOnWarnings=true and ${warnings.length} warning(s) were found`); + const traceDir = path.join(projectPaths(root).traceability, 'pages'); if (fs.existsSync(traceDir)) for (const file of fs.readdirSync(traceDir).filter((name) => name.endsWith('.json'))) { const id = path.basename(file, '.json'); if (!manifest.pages.some((page) => page.id === id)) warnings.push(`orphan traceability sidecar: ${file}`); } + const coverageRatio = models.items ? referenced.size / models.items : 1; const minimumCoverage = Math.max(0, Math.min(1, Number(config.audit?.minModelReferenceCoverage ?? 0))); if (coverageRatio < minimumCoverage) errors.push(`model reference coverage ${(coverageRatio * 100).toFixed(1)}% is below configured ${(minimumCoverage * 100).toFixed(1)}%`); if (config.audit?.failOnWarnings === true && warnings.length) errors.push(`audit.failOnWarnings=true and ${warnings.length} warning(s) were found`); const metrics = { pages: pages.length, claims: pages.reduce((sum, page) => sum + page.claims, 0), evidenceReferences: pages.reduce((sum, page) => sum + page.evidence, 0), modelItems: models.items, referencedModelItems: referenced.size, modelReferenceCoverage: Number(coverageRatio.toFixed(4)), errors: errors.length, warnings: warnings.length }; const inventoryFingerprint = inventory.inventory.fingerprint ?? null; const manifestHash = stableHash(manifest.pages); const auditInputHash = stableHash({ inventoryFingerprint, manifestHash, modelInputHash: models.inputHash, pages: pages.map((page) => [page.pageId, page.pageHash, page.inputHash]) }); return { schemaVersion: '2.0', generatedAt: now(), inventoryFingerprint, manifestHash, modelInputHash: models.inputHash, auditInputHash, pass: errors.length === 0, metrics, models: { files: models.files, byModel: models.byModel, errors: models.errors, warnings: models.warnings }, pages, errors, warnings }; diff --git a/global-template/docgen/lib/semantic.mjs b/global-template/docgen/lib/semantic.mjs new file mode 100644 index 0000000..e5fa54a --- /dev/null +++ b/global-template/docgen/lib/semantic.mjs @@ -0,0 +1,153 @@ +const CLASSIFICATIONS = new Set(['FACT', 'INFERENCE', 'ASSUMPTION', 'UNKNOWN']); +const CLASSIFICATION_ALIASES = new Map([ + ['OBSERVED', 'FACT'], ['EVIDENCED', 'FACT'], ['DIRECT', 'FACT'], ['VERIFIED', 'FACT'], + ['INFERRED', 'INFERENCE'], ['DERIVED', 'INFERENCE'], ['LIKELY', 'INFERENCE'], + ['ASSUMED', 'ASSUMPTION'], ['HYPOTHESIS', 'ASSUMPTION'], ['HYPOTHETICAL', 'ASSUMPTION'], + ['UNVERIFIED', 'UNKNOWN'], ['UNCERTAIN', 'UNKNOWN'], ['NOT_KNOWN', 'UNKNOWN'] +]); + +function firstScalar(value) { + if (typeof value === 'string' || typeof value === 'number') return value; + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + for (const key of ['value', 'label', 'type', 'kind', 'classification', 'status']) { + const candidate = value[key]; + if (typeof candidate === 'string' || typeof candidate === 'number') return candidate; + } + return null; +} + +export function normalizeClassification(value) { + const scalar = firstScalar(value); + if (scalar === null) return 'UNKNOWN'; + const normalized = String(scalar).trim().toUpperCase().replace(/[\s-]+/g, '_'); + if (CLASSIFICATIONS.has(normalized)) return normalized; + return CLASSIFICATION_ALIASES.get(normalized) ?? 'UNKNOWN'; +} + +export function normalizeConfidence(value, classification = 'UNKNOWN') { + const defaults = { FACT: 1, INFERENCE: 0.7, ASSUMPTION: 0.4, UNKNOWN: 0 }; + if (typeof value === 'string') { + const text = value.trim().toLowerCase(); + const labels = { certain: 1, very_high: 0.95, 'very high': 0.95, high: 0.85, medium: 0.6, moderate: 0.6, low: 0.3, very_low: 0.15, 'very low': 0.15, unknown: 0 }; + if (Object.hasOwn(labels, text)) return labels[text]; + const percentage = text.match(/^(-?\d+(?:\.\d+)?)\s*%$/); + if (percentage) return Math.max(0, Math.min(1, Number(percentage[1]) / 100)); + } + let number = Number(value); + if (Number.isFinite(number)) { + if (number > 1 && number <= 100) number /= 100; + if (number >= 0 && number <= 1) return number; + } + return defaults[normalizeClassification(classification)] ?? 0; +} + +function normalizeLine(value) { + if (value === undefined || value === null || value === '') return undefined; + const number = Number(value); + return Number.isInteger(number) && number > 0 ? number : undefined; +} + +function evidenceFromString(value) { + const text = String(value ?? '').trim(); + if (!text) return null; + const match = text.match(/^(.*?)(?:#L(\d+)(?:-L?(\d+))?)?$/i); + const path = String(match?.[1] ?? text).trim().replace(/^\.\//, ''); + if (!path) return null; + const startLine = normalizeLine(match?.[2]); + const endLine = normalizeLine(match?.[3]) ?? startLine; + return { path, ...(startLine ? { startLine, endLine } : {}) }; +} + +function evidenceFromObject(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const nested = value.source ?? value.location ?? value.file; + if (!value.path && nested && typeof nested === 'object') return evidenceFromObject({ ...nested, ...value, source: undefined, location: undefined, file: undefined }); + const rawPath = value.path ?? value.filePath ?? value.sourcePath ?? value.repositoryPath ?? (typeof value.file === 'string' ? value.file : undefined); + if (!rawPath) return null; + const path = String(rawPath).trim().replace(/^\.\//, ''); + if (!path) return null; + const startLine = normalizeLine(value.startLine ?? value.line ?? value.lineStart ?? value.fromLine ?? value.start); + const endLine = normalizeLine(value.endLine ?? value.lineEnd ?? value.toLine ?? value.end) ?? startLine; + return { + path, + ...(startLine ? { startLine, endLine } : {}), + ...(value.symbol ? { symbol: String(value.symbol) } : {}), + ...(value.note ? { note: String(value.note) } : {}) + }; +} + +export function normalizeEvidence(value) { + const input = Array.isArray(value) ? value : value === undefined || value === null ? [] : [value]; + const out = []; const seen = new Set(); + for (const entry of input.flat(Infinity)) { + const normalized = typeof entry === 'string' ? evidenceFromString(entry) : evidenceFromObject(entry); + if (!normalized) continue; + const key = `${normalized.path}\0${normalized.startLine ?? ''}\0${normalized.endLine ?? ''}`; + if (seen.has(key)) continue; + seen.add(key); out.push(normalized); + } + return out; +} + +export function evidenceFromAliases(value) { + if (!value || typeof value !== 'object') return []; + for (const key of ['evidence', 'evidenceRefs', 'sources', 'sourceRefs', 'citations', 'references']) { + const normalized = normalizeEvidence(value[key]); + if (normalized.length) return normalized; + } + return []; +} + +export function normalizeSourceModelRefs(value) { + const input = Array.isArray(value) ? value : value === undefined || value === null ? [] : [value]; + return [...new Set(input.flat(Infinity).map((entry) => { + if (typeof entry === 'string' || typeof entry === 'number') return String(entry).trim(); + if (entry && typeof entry === 'object') return String(entry.id ?? entry.ref ?? entry.modelRef ?? '').trim(); + return ''; + }).filter(Boolean))]; +} + +export function semanticMetadata(value) { + const evidence = evidenceFromAliases(value); + const rawClassification = value?.classification ?? value?.claimClassification ?? value?.certainty; + const classification = normalizeClassification(rawClassification); + const sourceModelRefs = normalizeSourceModelRefs(value?.sourceModelRefs ?? value?.modelRefs ?? value?.sourceRefs); + const confidence = normalizeConfidence(value?.confidence ?? value?.confidenceScore ?? value?.certaintyScore, classification); + const unsupportedFact = classification === 'FACT' && evidence.length === 0; + return { + classification: unsupportedFact ? 'INFERENCE' : classification, + requestedClassification: classification, + confidence: unsupportedFact ? Math.min(confidence, 0.7) : confidence, + evidence, + sourceModelRefs, + unsupportedFact + }; +} + +export function normalizeSemanticDocument(document) { + function visit(value) { + if (Array.isArray(value)) { for (const item of value) visit(item); return; } + if (!value || typeof value !== 'object') return; + const semantic = value.id || value.name || value.statement; + if (semantic) { + const metadata = semanticMetadata(value); + const rawClassification = value.classification ?? value.claimClassification ?? value.certainty; + // Arrays/objects named "classification" can be domain catalogs rather than + // semantic metadata. Do not destroy those structures. + if (rawClassification === undefined || typeof rawClassification === 'string' || typeof rawClassification === 'number') value.classification = metadata.classification; + value.confidence = metadata.confidence; + value.evidence = metadata.evidence; + value.sourceModelRefs = metadata.sourceModelRefs; + if (metadata.unsupportedFact) { + const notes = Array.isArray(value.normalizationNotes) ? value.normalizationNotes : []; + const note = 'Provider FACT without direct evidence was normalized to INFERENCE.'; + if (!notes.includes(note)) notes.push(note); + value.normalizationNotes = notes; + } + } + for (const [key, child] of Object.entries(value)) if (!['evidence', 'evidenceRefs', 'sources', 'sourceRefs', 'citations', 'references', 'sourceModelRefs', 'modelRefs', 'normalizationNotes'].includes(key)) visit(child); + } + visit(document); return document; +} + +export { CLASSIFICATIONS }; diff --git a/global-template/docgen/package.json b/global-template/docgen/package.json index d52edfc..b5153ee 100644 --- a/global-template/docgen/package.json +++ b/global-template/docgen/package.json @@ -11,6 +11,6 @@ }, "scripts": { "test": "node --test test/*.test.mjs", - "check": "node --check bin/docgen-launcher.mjs && node --check bin/docgen-v2.mjs && node --check bin/workspace.mjs && node --check lib/core.mjs && node --check lib/inventory.mjs && node --check lib/indexer.mjs && node --check lib/context.mjs && node --check lib/provider.mjs && node --check lib/quality.mjs && node --check lib/pipeline.mjs && node --check test/fixtures/fake-provider.mjs && node --check test/semantic-index.test.mjs" + "check": "node --check bin/docgen-launcher.mjs && node --check bin/docgen-v2.mjs && node --check bin/workspace.mjs && node --check lib/core.mjs && node --check lib/inventory.mjs && node --check lib/indexer.mjs && node --check lib/context.mjs && node --check lib/provider.mjs && node --check lib/semantic.mjs && node --check lib/model-bundle.mjs && node --check lib/model-io.mjs && node --check lib/model-synthesis.mjs && node --check lib/quality.mjs && node --check lib/audit-guard.mjs && node --check lib/audit-policy.mjs && node --check lib/pipeline-base.mjs && node --check lib/pipeline.mjs && node --check test/fixtures/fake-provider.mjs && node --check test/fixtures/model-bundle-provider.mjs && node --check test/audit-guard.test.mjs && node --check test/audit-policy.test.mjs && node --check test/model-bundle.test.mjs && node --check test/model-bundle-pipeline.test.mjs && node --check test/semantic-index.test.mjs" } } diff --git a/global-template/docgen/project-template/config/documentation.json b/global-template/docgen/project-template/config/documentation.json index acd0256..a5b3325 100644 --- a/global-template/docgen/project-template/config/documentation.json +++ b/global-template/docgen/project-template/config/documentation.json @@ -58,14 +58,14 @@ "audit": 20 }, "generationRecoveryAttempts": 3, + "missingModelPolicy": "placeholder", "recoverValidArtifacts": true }, "audit": { - "llmEnabled": true, + "llmMode": "off", "llmRiskThreshold": 50, - "failOnCritical": true, - "failOnHigh": true, "failOnWarnings": false, + "requiredSectionsAsWarnings": true, "requireLineEvidenceForFacts": true, "requireContextBoundEvidence": true, "minModelReferenceCoverage": 0 diff --git a/global-template/docgen/prompts/model-core.md b/global-template/docgen/prompts/model-core.md index 0fceb91..23127b8 100644 --- a/global-template/docgen/prompts/model-core.md +++ b/global-template/docgen/prompts/model-core.md @@ -27,4 +27,6 @@ Core shape: The named arrays are a broad vocabulary, not a checklist. Populate only evidenced concerns and use generic arrays such as `interfaces`, `dependencies`, `dataAssets`, and `automations` when technology-specific categories do not fit. +Every requested top-level object is mandatory even when no repository evidence exists. Represent an empty concern with explicit empty arrays or `unknowns`; never omit the requested key. + Before completion, parse the JSON you wrote and verify every requested top-level object exists. diff --git a/global-template/docgen/prompts/model-enterprise.md b/global-template/docgen/prompts/model-enterprise.md index fe37ef5..d172db6 100644 --- a/global-template/docgen/prompts/model-enterprise.md +++ b/global-template/docgen/prompts/model-enterprise.md @@ -25,4 +25,6 @@ Expected concerns, only when evidenced: - change-impact: change surfaces, direct/transitive effects, compatibility, migration risks, tests and operations affected; - ownership: team, component, data and operational ownership, RACI, approval, escalation. +Every requested top-level object is mandatory even when no repository evidence exists. Represent an empty concern with explicit empty arrays or `unknowns`; never omit the requested key. + Before completion, parse the JSON and verify every requested top-level object exists. diff --git a/global-template/docgen/test/audit-guard.test.mjs b/global-template/docgen/test/audit-guard.test.mjs new file mode 100644 index 0000000..93f8852 --- /dev/null +++ b/global-template/docgen/test/audit-guard.test.mjs @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { guardedAudit, sanitizeAuditInputs } from '../lib/audit-guard.mjs'; +import { audit as pipelineAudit } from '../lib/pipeline.mjs'; +import { projectPaths, sha256, writeJson } from '../lib/core.mjs'; + +function pageText(page) { + return `---\ntitle: ${JSON.stringify(page.title)}\ndescription: ${JSON.stringify(page.summary)}\npageId: ${JSON.stringify(page.id)}\ncategory: ${JSON.stringify(page.category)}\nmode: "explanation"\ntype: ${JSON.stringify(page.type)}\norder: ${page.order}\n---\n# ${page.title}\n\nIdentical material body.\n`; +} + +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'docgen-audit-guard-')); + const paths = projectPaths(root); + const pages = [ + { id: 'page-a', title: 'Page A', summary: 'First page', category: 'security', path: 'docs/security/page-a.md', type: 'security', order: 1, requiredSections: [], relatedPages: [] }, + { id: 'page-b', title: 'Page B', summary: 'Second page', category: 'security', path: 'docs/security/page-b.md', type: 'security', order: 2, requiredSections: [], relatedPages: [] } + ]; + fs.mkdirSync(path.join(root, 'docs', 'security'), { recursive: true }); + fs.mkdirSync(path.join(paths.traceability, 'pages'), { recursive: true }); + for (const page of pages) { + const text = pageText(page); + fs.writeFileSync(path.join(root, page.path), text); + writeJson(path.join(paths.traceability, 'pages', `${page.id}.json`), { + schemaVersion: '2.0', pageId: page.id, pagePath: page.path, + pageHash: sha256(text), inputHash: `${page.id}-input`, claims: [] + }); + } + writeJson(paths.project, { schemaVersion: '2.0', kitVersion: '2.0.0' }); + writeJson(paths.config, { + audit: { llmEnabled: true, llmRiskThreshold: 0, requireLineEvidenceForFacts: true, requireContextBoundEvidence: true }, + execution: { maxPlannedPages: 30 } + }); + writeJson(paths.inventory, { schemaVersion: '2.0', fingerprint: 'fixture', files: [], excluded: [] }); + writeJson(paths.plan, { schemaVersion: '2.0', pages }); + writeJson(paths.state, { + schemaVersion: '2.0', stages: {}, + pages: Object.fromEntries(pages.map((page) => [page.id, { status: 'completed', inputHash: `${page.id}-input` }])) + }); + return { root, paths, pages }; +} + +test('deterministic failure stops before the costly audit provider', async () => { + const { root, paths } = fixture(); + let baseAuditCalls = 0; + await assert.rejects( + () => guardedAudit(root, async () => { baseAuditCalls++; throw new Error('provider path must not run'); }), + /Quality failed before LLM audit: deterministicFailures=1, highRiskFindings=0/ + ); + assert.equal(baseAuditCalls, 0); + const summary = JSON.parse(fs.readFileSync(path.join(paths.audit, 'quality-summary.json'), 'utf8')); + assert.equal(summary.llmAuditedPages, 0); + assert.equal(summary.highRiskFindings, 0); + assert.equal(summary.llmSkippedReason, 'deterministic-fail-fast'); + assert.equal(summary.pass, false); +}); + +test('legacy llmEnabled config is migrated to deterministic-only audit with provider calls zero', async () => { + const { root, paths, pages } = fixture(); + writeJson(paths.plan, { schemaVersion: '2.0', pages: [pages[0]] }); + const result = await pipelineAudit(root); + const migrated = JSON.parse(fs.readFileSync(paths.config, 'utf8')); + assert.equal(migrated.audit.llmMode, 'off'); + assert.equal(migrated.audit.requiredSectionsAsWarnings, true); + assert.equal(result.pass, true); + assert.equal(result.llmAuditedPages, 0); + assert.equal(result.highRiskFindings, 0); + assert.equal(result.llmSkippedReason, 'llm-audit-off'); +}); + +test('sanitizer drops out-of-context evidence and refs and downgrades unsupported FACT', () => { + const { root, paths, pages } = fixture(); + const page = pages[0]; + const contextFile = path.join(paths.context, 'generate', `${page.id}.json`); + writeJson(contextFile, { id: 'ctx-a', modelItems: [], facts: [] }); + const traceFile = path.join(paths.traceability, 'pages', `${page.id}.json`); + const trace = JSON.parse(fs.readFileSync(traceFile, 'utf8')); + trace.contextId = 'ctx-a'; + trace.claims = [{ + id: 'claim-a', statement: 'Unsupported provider claim', classification: 'FACT', confidence: 1, + evidence: [{ path: '../../outside.txt', startLine: 999 }], sourceModelRefs: ['missing:model'] + }]; + writeJson(traceFile, trace); + const result = sanitizeAuditInputs(root, { pages: [page] }); + const sanitized = JSON.parse(fs.readFileSync(traceFile, 'utf8')).claims[0]; + assert.equal(result.traces.droppedEvidence, 1); + assert.equal(result.traces.droppedRefs, 1); + assert.equal(sanitized.classification, 'INFERENCE'); + assert.equal(sanitized.confidence, 0.7); + assert.deepEqual(sanitized.evidence, []); + assert.deepEqual(sanitized.sourceModelRefs, []); +}); diff --git a/global-template/docgen/test/audit-policy.test.mjs b/global-template/docgen/test/audit-policy.test.mjs new file mode 100644 index 0000000..f27e1bb --- /dev/null +++ b/global-template/docgen/test/audit-policy.test.mjs @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { advisoryLlmAuditSummary, auditLlmMode, deterministicOnlyAuditSummary } from '../lib/audit-policy.mjs'; + +test('LLM audit is off for legacy and unspecified configurations', () => { + assert.equal(auditLlmMode({ audit: {} }), 'off'); + assert.equal(auditLlmMode({ audit: { llmEnabled: true } }), 'off'); +}); + +test('advisory mode records high-risk findings without failing the audit', () => { + const result = advisoryLlmAuditSummary({ + auditInputHash: 'audit-hash', + deterministicFailures: 0, + highRiskFindings: 3, + pass: false + }, { audit: { llmMode: 'advisory' } }); + assert.equal(result.pass, true); + assert.equal(result.llmFindingsBlocking, false); + assert.equal(result.advisoryHighRiskFindings, 3); +}); + +test('blocking mode preserves the hard LLM gate', () => { + const result = advisoryLlmAuditSummary({ + deterministicFailures: 0, + highRiskFindings: 3, + pass: false + }, { audit: { llmMode: 'blocking' } }); + assert.equal(result, null); +}); + +test('LLM advisory policy never hides deterministic failures', () => { + const result = advisoryLlmAuditSummary({ + deterministicFailures: 114, + highRiskFindings: 3, + pass: false + }, { audit: { llmMode: 'advisory' } }); + assert.equal(result, null); +}); + +test('deterministic-only summary records zero provider audit work', () => { + const result = deterministicOnlyAuditSummary({ + auditInputHash: 'hash', inventoryFingerprint: 'inventory', manifestHash: 'manifest', + metrics: { pages: 2, claims: 4, evidenceReferences: 5, modelItems: 6, referencedModelItems: 3, modelReferenceCoverage: 0.5 }, + errors: [], warnings: ['warning'], pass: true + }); + assert.equal(result.pass, true); + assert.equal(result.llmAuditedPages, 0); + assert.equal(result.highRiskFindings, 0); + assert.equal(result.llmSkippedReason, 'llm-audit-off'); +}); diff --git a/global-template/docgen/test/fixtures/fake-provider.mjs b/global-template/docgen/test/fixtures/fake-provider.mjs index 4b8318a..e9f7e2b 100755 --- a/global-template/docgen/test/fixtures/fake-provider.mjs +++ b/global-template/docgen/test/fixtures/fake-provider.mjs @@ -18,6 +18,11 @@ const between = (text, start, end) => { return tail ? tail.split(end)[0] : null; }; const target = between(prompt, `Write exactly one JSON file: ${tick}`, tick); +const expectedNamesText = between(prompt, 'top-level model objects: ', '\n'); +let expectedNames = []; +try { expectedNames = expectedNamesText ? JSON.parse(expectedNamesText.replace(/\.\s*$/, '')) : []; } catch {} +const selectModels = (value) => expectedNames.length ? Object.fromEntries(expectedNames.filter((name) => Object.hasOwn(value, name)).map((name) => [name, value[name]])) : value; +const noncanonical = process.env.DOCGEN_TEST_NONCANONICAL === '1'; const write = (rel, value) => { if (!rel) throw new Error(`missing output path for ${stage}`); const file = path.join(cwd, rel); @@ -31,21 +36,26 @@ if (process.env.DOCGEN_TEST_FAIL_BEFORE_WRITE_STAGE === stage) { } if (stage === 'modelCore') { - write(target, { + write(target, selectModels({ system: { - components: [{ id: 'resource', kind: 'component', name: 'Resource', statement: 'Source component', classification: 'FACT', confidence: 1, evidence: [{ path: 'src/Resource.java', startLine: 1 }] }], + components: [{ id: 'resource', kind: 'component', name: 'Resource', statement: 'Source component', classification: noncanonical ? 'fact' : 'FACT', confidence: noncanonical ? '85%' : 1, ...(noncanonical ? { sources: ['src/Resource.java#L1-L1'] } : { evidence: [{ path: 'src/Resource.java', startLine: 1 }] }) }], relationships: [], workflows: [], unknowns: [] }, business: { actors: [], capabilities: [], concepts: [], businessRules: [], decisions: [], branchConditions: [], lifecycles: [], invariants: [], useCases: [], unknowns: [] }, flows: { businessFlows: [], controlFlows: [], requestFlows: [], trafficFlows: [], dataFlows: [], eventFlows: [] }, catalogs: { interfaces: [], contracts: [], endpoints: [], messageHandlers: [], dependencies: [], externalDependencies: [], dataAssets: [], dataStores: [], automations: [], scheduledJobs: [], buildArtifacts: [], configurationSurfaces: [] } - }); + })); } else if (stage === 'modelEnterprise') { - write(target, { - security: { unknowns: [] }, operations: { unknowns: [] }, testing: { unknowns: [] }, - 'data-governance': { unknowns: [] }, decisions: { unknowns: [] }, configuration: { unknowns: [] }, - 'change-impact': { unknowns: [] }, ownership: { unknowns: [] } - }); + const models = { + security: { unknowns: [] }, + operations: noncanonical ? { runtimes: [{ id: 'runtime', name: 'Runtime', statement: 'Runtime inferred from repository context.', classification: 'FACT', confidence: 'high' }], unknowns: [] } : { unknowns: [] }, + testing: { unknowns: [] }, + 'data-governance': noncanonical ? { classifications: [{ id: 'classification-catalog', name: 'Classification catalog', classification: [{ id: 'internal', name: 'Internal' }, { id: 'restricted', name: 'Restricted' }], confidence: 80 }], unknowns: [] } : { unknowns: [] }, + decisions: { unknowns: [] }, configuration: { unknowns: [] }, 'change-impact': { unknowns: [] }, ownership: { unknowns: [] } + }; + const omit = process.env.DOCGEN_TEST_OMIT_MODEL_ONCE; const marker = path.join(cwd, '.docgen', `test-omit-${omit}.marker`); + if (omit && expectedNames.length > 1 && Object.hasOwn(models, omit) && !fs.existsSync(marker)) { fs.mkdirSync(path.dirname(marker), { recursive: true }); fs.writeFileSync(marker, 'omitted\n'); delete models[omit]; } + write(target, selectModels(models)); } else if (stage === 'plan') { const pageCount = Math.max(1, Number(process.env.DOCGEN_TEST_PAGE_COUNT || 1)); write(target, { @@ -56,7 +66,7 @@ if (stage === 'modelCore') { summary: index === 0 ? 'Fixture overview.' : `Fixture detail ${index + 1}.`, category: 'orientation', mode: 'explanation', type: 'overview', order: index + 1, audience: ['engineer'], coverageTags: ['architecture'], query: 'resource architecture', - requiredSections: [], risk: 'low', relatedPages: [] + requiredSections: noncanonical ? ['System architecture with responsibilities'] : [], risk: 'low', relatedPages: [] })) }); } else if (stage === 'generate') { @@ -80,7 +90,7 @@ order: ${page.order} ${page.summary} -The repository contains a source component. +${noncanonical ? '## System Architecture and Responsibilities\n\n' : ''}The repository contains a source component. `; const output = path.join(cwd, contract.outputPath); fs.mkdirSync(path.dirname(output), { recursive: true }); @@ -89,8 +99,8 @@ The repository contains a source component. schemaVersion: '2.0', pageId: page.id, pagePath: contract.outputPath, claims: [{ id: `${page.id}:resource`, section: page.title, - statement: 'The repository contains a source component.', classification: 'FACT', confidence: 1, - evidence: [{ path: 'src/Resource.java', startLine: 1 }], sourceModelRefs: ['system:resource'] + statement: 'The repository contains a source component.', classification: 'FACT', confidence: noncanonical ? 'high' : 1, + evidence: noncanonical ? [] : [{ path: 'src/Resource.java', startLine: 1 }], sourceModelRefs: [noncanonical ? 'system:1' : 'system:resource'] }] }); } diff --git a/global-template/docgen/test/fixtures/model-bundle-provider.mjs b/global-template/docgen/test/fixtures/model-bundle-provider.mjs new file mode 100755 index 0000000..07b2d62 --- /dev/null +++ b/global-template/docgen/test/fixtures/model-bundle-provider.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +const prompt = fs.readFileSync(0, 'utf8').replace(/\r\n?/g, '\n'); +const stage = process.env.DOCGEN_STAGE; +const cwd = process.cwd(); +const tick = String.fromCharCode(96); +const between = (text, start, end) => { const tail = text.split(start)[1]; return tail ? tail.split(end)[0] : null; }; +const target = between(prompt, `Write exactly one JSON file: ${tick}`, tick); +const write = (rel, value) => { + if (!rel) throw new Error(`missing output path for ${stage}`); + const file = path.join(cwd, rel); fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n'); +}; + +if (stage === 'modelCore') { + write(target, { + models: { + system: { components: [{ id: 'resource', name: 'Resource', statement: 'Source component', classification: 'FACT', confidence: 1, evidence: [{ path: 'src/Resource.java', startLine: 1 }] }] }, + business: { unknowns: [] }, flows: { unknowns: [] }, catalogs: { unknowns: [] } + } + }); +} else if (stage === 'modelEnterprise') { + const isInitial = target?.endsWith('modelEnterprise-bundle.json'); + const isBatchRepair = target?.includes('modelEnterprise-repair-') && target?.endsWith('-bundle.json'); + const neverReturn = process.env.DOCGEN_TEST_NEVER_RETURN_MODEL === '1'; + if (isInitial && process.env.DOCGEN_TEST_EMPTY_INITIAL === '1') { + write(target, { message: 'no requested model objects were emitted' }); + } else if (isInitial) { + write(target, { result: { models: { + security: { unknowns: [] }, operations: { unknowns: [] }, testing: { unknowns: [] }, + dataGovernanceModel: { unknowns: [] }, configuration: { unknowns: [] }, + changeImpactDocument: { unknowns: [] }, ownership: { unknowns: [] } + } } }); + } else if (isBatchRepair || neverReturn) { + write(target, { response: { payload: { note: 'requested object still omitted' } } }); + } else { + write(target, { items: [{ id: 'decision-1', name: 'Explicit decision', statement: 'Recovered independently.', classification: 'UNKNOWN', confidence: 0, evidence: [] }] }); + } +} else { + console.error(`unexpected stage ${stage}`); process.exit(2); +} diff --git a/global-template/docgen/test/model-bundle-pipeline.test.mjs b/global-template/docgen/test/model-bundle-pipeline.test.mjs new file mode 100644 index 0000000..315177c --- /dev/null +++ b/global-template/docgen/test/model-bundle-pipeline.test.mjs @@ -0,0 +1,92 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { model, status } from '../lib/pipeline.mjs'; +import { projectPaths, readJson, writeJson } from '../lib/core.mjs'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); + +function providerExecutable(paths) { + const source = path.join(testDir, 'fixtures', 'model-bundle-provider.mjs'); + if (process.platform !== 'win32') return source; + const dir = path.join(paths.base, 'test-bin'); fs.mkdirSync(dir, { recursive: true }); + const script = path.join(dir, 'model-bundle-provider.mjs'); fs.copyFileSync(source, script); + const shim = path.join(dir, 'model-bundle-provider.cmd'); + fs.writeFileSync(shim, `@echo off\r\n"${process.execPath}" "${script}" %*\r\n`); + return shim; +} + +function fixture({ missingPolicy = 'placeholder' } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'docgen-bundle-')); const paths = projectPaths(root); + fs.mkdirSync(path.join(root, 'src'), { recursive: true }); fs.writeFileSync(path.join(root, 'src', 'Resource.java'), 'class Resource {}\n'); + fs.mkdirSync(path.dirname(paths.config), { recursive: true }); + const provider = providerExecutable(paths); + writeJson(paths.project, { schemaVersion: '2.0', kitVersion: '2.0.0' }); + writeJson(paths.config, { + schemaVersion: '2.0', projectName: 'Bundle Fixture', + ignore: { useGitignore: true, useDocgenignore: true, binary: { enabled: true, maxTextFileBytes: 1024 * 1024 } }, + context: { maxTokens: { default: 4000, modelCore: 4000, modelEnterprise: 4000 } }, + budget: { maxProviderCalls: 20, maxEstimatedInputTokens: 200000, maxEstimatedOutputTokens: 50000, maxContextTokensPerCall: 10000 }, + execution: { missingModelPolicy: missingPolicy }, retry: { maxAttempts: 1 }, + commandCode: { executable: provider, trust: false, skipOnboarding: false, yolo: false, maxTurns: { default: 30 } } + }); + writeJson(paths.state, { schemaVersion: '2.0', kitVersion: '2.0.0', stages: {}, pages: {} }); + return { root, paths }; +} + +test('pipeline recovers a repeatedly omitted model through independent direct-object repair', async () => { + const { root, paths } = fixture(); + await model(root); + const decisions = readJson(path.join(paths.model, 'decisions.json')); + assert.equal(decisions.items[0].id, 'decision-1'); + const state = readJson(paths.state).stages.modelEnterprise; + assert.deepEqual(state.degradedModels, []); + assert.equal(state.providerCalls, 3); + for (const name of ['security', 'operations', 'testing', 'data-governance', 'decisions', 'configuration', 'change-impact', 'ownership']) { + assert.equal(fs.existsSync(path.join(paths.model, `${name}.json`)), true, name); + } + assert.equal(fs.readdirSync(paths.model).some((name) => /bundle|staging|backup/.test(name)), false); +}); + +test('pipeline emits an explicit UNKNOWN placeholder instead of failing forever', async () => { + const { root, paths } = fixture(); + const previous = process.env.DOCGEN_TEST_NEVER_RETURN_MODEL; process.env.DOCGEN_TEST_NEVER_RETURN_MODEL = '1'; + try { await model(root); } finally { + if (previous === undefined) delete process.env.DOCGEN_TEST_NEVER_RETURN_MODEL; else process.env.DOCGEN_TEST_NEVER_RETURN_MODEL = previous; + } + const decisions = readJson(path.join(paths.model, 'decisions.json')); + assert.equal(decisions.providerOutputStatus, 'missing'); + assert.equal(decisions.classification, 'UNKNOWN'); + assert.deepEqual(decisions.evidence, []); + assert.deepEqual(readJson(paths.state).stages.modelEnterprise.degradedModels, ['decisions']); + assert.deepEqual(status(root).summary.degradedModels, ['modelEnterprise:decisions']); +}); + + +test('pipeline recovers when the initial bundle contains zero recognizable objects', async () => { + const { root, paths } = fixture(); + const previousEmpty = process.env.DOCGEN_TEST_EMPTY_INITIAL; + const previousNever = process.env.DOCGEN_TEST_NEVER_RETURN_MODEL; + process.env.DOCGEN_TEST_EMPTY_INITIAL = '1'; + process.env.DOCGEN_TEST_NEVER_RETURN_MODEL = '1'; + try { await model(root); } finally { + if (previousEmpty === undefined) delete process.env.DOCGEN_TEST_EMPTY_INITIAL; else process.env.DOCGEN_TEST_EMPTY_INITIAL = previousEmpty; + if (previousNever === undefined) delete process.env.DOCGEN_TEST_NEVER_RETURN_MODEL; else process.env.DOCGEN_TEST_NEVER_RETURN_MODEL = previousNever; + } + const enterprise = readJson(path.join(paths.model, 'decisions.json')); + assert.equal(enterprise.providerOutputStatus, 'missing'); + assert.equal(enterprise.classification, 'UNKNOWN'); + const core = readJson(path.join(paths.model, 'system.json')); + assert.equal(core.components[0].id, 'resource'); + assert.ok(readJson(paths.state).stages.modelEnterprise.recoveryErrors.some((entry) => entry.startsWith('initial:'))); +}); + +test('strict policy can still turn exhausted recovery into a hard failure', async () => { + const { root } = fixture({ missingPolicy: 'fail' }); + const previous = process.env.DOCGEN_TEST_NEVER_RETURN_MODEL; process.env.DOCGEN_TEST_NEVER_RETURN_MODEL = '1'; + try { await assert.rejects(() => model(root), /Model recovery exhausted for: decisions/); } + finally { if (previous === undefined) delete process.env.DOCGEN_TEST_NEVER_RETURN_MODEL; else process.env.DOCGEN_TEST_NEVER_RETURN_MODEL = previous; } +}); diff --git a/global-template/docgen/test/model-bundle.test.mjs b/global-template/docgen/test/model-bundle.test.mjs new file mode 100644 index 0000000..2371c2f --- /dev/null +++ b/global-template/docgen/test/model-bundle.test.mjs @@ -0,0 +1,134 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + canonicalModelName, + extractModelObjects, + resolveModelObjects, + safeModelPlaceholder +} from '../lib/model-bundle.mjs'; + +const expected = ['security', 'operations', 'testing', 'data-governance', 'decisions', 'configuration', 'change-impact', 'ownership']; + +test('canonical model names tolerate punctuation, extensions, camel case, and singular/plural forms', () => { + assert.equal(canonicalModelName('decisions.json'), canonicalModelName('decisionModel')); + assert.equal(canonicalModelName('dataGovernanceModel'), canonicalModelName('data-governance')); + assert.equal(canonicalModelName('change_impact_document'), canonicalModelName('change-impact')); +}); + +test('extracts exact top-level objects without changing their payload', () => { + const source = Object.fromEntries(expected.map((name) => [name, { marker: name }])); + const result = extractModelObjects(source, expected); + assert.deepEqual(result.missing, []); + for (const name of expected) assert.equal(result.objects[name].marker, name); +}); + +test('extracts recursively through common and unknown provider wrappers', () => { + const source = { + response: { + payload: { + artifacts: { + enterpriseModels: { + decisions: { items: [{ id: 'd1' }] }, + ownership: { items: [{ id: 'o1' }] } + } + } + } + } + }; + const result = extractModelObjects(source, ['decisions', 'ownership']); + assert.deepEqual(result.missing, []); + assert.equal(result.objects.decisions.items[0].id, 'd1'); +}); + +test('accepts filename keys, camel-case keys, arrays, and JSON-string payloads', () => { + const source = { + outputs: { + 'decisions.json': '[{"id":"d1"}]', + dataGovernanceModel: '{"rules":[{"id":"r1"}]}', + changeImpactDocument: { effects: [] } + } + }; + const result = extractModelObjects(source, ['decisions', 'data-governance', 'change-impact']); + assert.deepEqual(result.missing, []); + assert.deepEqual(result.objects.decisions.items, [{ id: 'd1' }]); + assert.equal(result.objects['data-governance'].rules[0].id, 'r1'); +}); + +test('accepts descriptor arrays used by artifact-oriented providers', () => { + const source = { + artifacts: [ + { id: 'artifact-1', filename: 'decisions.json', content: { decisions: [{ id: 'd1' }] } }, + { id: 'artifact-2', modelName: 'ownership', payload: '{"teams":[{"id":"team-a"}]}' } + ] + }; + const result = extractModelObjects(source, ['decisions', 'ownership']); + assert.deepEqual(result.missing, []); + assert.equal(result.objects.decisions.items[0].id, 'd1'); + assert.equal(result.objects.ownership.teams[0].id, 'team-a'); +}); + + +test('does not mistake a nested concern field for an omitted top-level model object', () => { + const source = { + security: { decisions: [{ id: 'security-decision' }], controls: [] }, + operations: { runbooks: [] } + }; + const result = extractModelObjects(source, ['security', 'operations', 'decisions']); + assert.deepEqual(result.missing, ['decisions']); + assert.equal(result.objects.security.decisions[0].id, 'security-decision'); +}); + +test('accepts a direct singleton object during per-object repair', () => { + const direct = { items: [{ id: 'd1' }], unknowns: [] }; + const result = extractModelObjects(direct, ['decisions']); + assert.deepEqual(result.missing, []); + assert.equal(result.objects.decisions.items[0].id, 'd1'); +}); + + +test('does not accept error or status metadata as a direct singleton model', () => { + assert.deepEqual(extractModelObjects({ message: 'cannot comply', status: 'error' }, ['decisions']).missing, ['decisions']); +}); + +test('prefers explicitly named objects over lower-confidence descriptor candidates', () => { + const source = { + decisions: { selected: 'top-level' }, + artifacts: [{ name: 'decisions', value: { selected: 'descriptor' } }] + }; + const result = extractModelObjects(source, ['decisions']); + assert.equal(result.objects.decisions.selected, 'top-level'); +}); + +test('bounded reconciliation fills only unresolved objects across attempts', () => { + const first = { models: { security: { id: 's' }, operations: { id: 'o' } } }; + const batchRepair = { result: { 'decisions.json': { id: 'd' } } }; + const singleRepair = { id: 'owner-direct', teams: [] }; + const result = resolveModelObjects(['security', 'operations', 'decisions', 'ownership'], [first, batchRepair, singleRepair]); + assert.deepEqual(result.missing, []); + assert.deepEqual(result.degraded, []); + assert.equal(result.objects.ownership.id, 'owner-direct'); +}); + +test('unresolved objects become explicit UNKNOWN placeholders instead of fatal missing-object errors', () => { + const result = resolveModelObjects(['security', 'decisions'], [{ security: { controls: [] } }]); + assert.deepEqual(result.missing, []); + assert.deepEqual(result.degraded, ['decisions']); + assert.equal(result.objects.decisions.providerOutputStatus, 'missing'); + assert.equal(result.objects.decisions.classification, 'UNKNOWN'); + assert.equal(result.objects.decisions.evidence.length, 0); + assert.match(result.objects.decisions.unknowns[0].statement, /omitted|recovery/i); +}); + +test('strict missing policy remains available for environments that require a hard gate', () => { + const result = resolveModelObjects(['security', 'decisions'], [{ security: {} }], { missingPolicy: 'fail' }); + assert.deepEqual(result.missing, ['decisions']); + assert.deepEqual(result.degraded, []); +}); + +test('placeholder never fabricates repository evidence', () => { + const placeholder = safeModelPlaceholder('decisions'); + assert.equal(placeholder.classification, 'UNKNOWN'); + assert.equal(placeholder.confidence, 0); + assert.deepEqual(placeholder.evidence, []); + assert.deepEqual(placeholder.unknowns[0].evidence, []); +}); diff --git a/global-template/docgen/test/semantic-index.test.mjs b/global-template/docgen/test/semantic-index.test.mjs index c428923..23124ed 100644 --- a/global-template/docgen/test/semantic-index.test.mjs +++ b/global-template/docgen/test/semantic-index.test.mjs @@ -189,14 +189,16 @@ test('recovery never accepts stale pre-existing plan artifacts when provider wri assert.equal(readJson(p.state).stages.plan.status, 'failed'); }); -test('audit rejects unknown model references and publish rejects stale source artifacts', async () => { +test('audit sanitizes unknown model references and publish rejects stale source artifacts', async () => { const root = fixture(); const p = projectPaths(root); const provider = installFakeProvider(root); fs.writeFileSync(path.join(root, 'src', 'Resource.java'), 'class Resource {}\n'); const config = readJson(p.config); config.commandCode = { executable: provider, trust: false, skipOnboarding: false, yolo: false, maxTurns: { default: 30 } }; writeJson(p.config, config); const run = spawnSync(process.execPath, [cli, 'all'], { cwd: root, encoding: 'utf8', env: { ...process.env, DOCGEN_PROGRESS: '0' } }); assert.equal(run.status, 0, run.stderr || run.stdout); const traceFile = path.join(p.traceability, 'pages', 'overview.json'); const trace = readJson(traceFile); trace.claims[0].sourceModelRefs = ['system:does-not-exist']; writeJson(traceFile, trace); - await assert.rejects(() => audit(root), /Quality failed/); const report = readJson(path.join(p.audit, 'deterministic.json')); assert(report.errors.some((error) => /unknown sourceModelRef/.test(error))); - trace.claims[0].sourceModelRefs = ['system:resource']; writeJson(traceFile, trace); await audit(root); + const summary = await audit(root); + const sanitizedTrace = readJson(traceFile); + assert.deepEqual(sanitizedTrace.claims[0].sourceModelRefs, []); + assert.equal(summary.deterministicFailures, 0); fs.appendFileSync(path.join(root, 'src', 'Resource.java'), '// stale\n'); assert.throws(() => publish(root), /stale relative to current source/); }); @@ -214,3 +216,29 @@ test('v1 migration preserves docs and ignore policy while archiving workflow sta const next = readJson(p.config); assert.equal(next.schemaVersion, '2.0'); assert.equal(next.projectName, 'Migrated'); assert.equal(next.commandCode.executable, 'custom-cmdc'); assert.equal(next.ignore.binary.maxTextFileBytes, 123456); const marker = readJson(p.project); assert.match(marker.migrationBackup, /^\.docgen\/migration-backup\//); assert(fs.existsSync(path.join(root, marker.migrationBackup, 'evidence', 'legacy.json'))); }); + +test('enterprise model bundle repairs a missing decisions object without discarding valid models', () => { + const root = fixture(); const p = projectPaths(root); const provider = installFakeProvider(root); + fs.writeFileSync(path.join(root, 'src', 'Resource.java'), 'class Resource {}\n'); + const config = readJson(p.config); config.commandCode = { executable: provider, trust: false, skipOnboarding: false, yolo: false, maxTurns: { default: 30 } }; config.budget.maxProviderCalls = 12; writeJson(p.config, config); + const run = spawnSync(process.execPath, [cli, 'all'], { cwd: root, encoding: 'utf8', env: { ...process.env, DOCGEN_PROGRESS: '0', DOCGEN_TEST_OMIT_MODEL_ONCE: 'decisions' } }); + assert.equal(run.status, 0, `STDERR:\n${run.stderr}\nSTDOUT:\n${run.stdout}`); + assert.match(run.stderr, /modelEnterprise REPAIR \| unresolved: decisions/); + assert.equal(fs.existsSync(path.join(p.model, 'decisions.json')), true); + assert.equal(readJson(p.state).stages.modelEnterprise.status, 'completed'); + const budget = readJson(p.budget); assert.equal(budget.usage.providerCalls, 5); assert.equal(budget.usage.failedCalls, 0); +}); + +test('provider boundary canonicalizes confidence, evidence aliases, classification catalogs, numeric model refs, and fuzzy required sections', () => { + const root = fixture(); const p = projectPaths(root); const provider = installFakeProvider(root); + fs.writeFileSync(path.join(root, 'src', 'Resource.java'), 'class Resource {}\n'); + const config = readJson(p.config); config.commandCode = { executable: provider, trust: false, skipOnboarding: false, yolo: false, maxTurns: { default: 30 } }; writeJson(p.config, config); + const run = spawnSync(process.execPath, [cli, 'all'], { cwd: root, encoding: 'utf8', env: { ...process.env, DOCGEN_PROGRESS: '0', DOCGEN_TEST_NONCANONICAL: '1' } }); + assert.equal(run.status, 0, `STDERR:\n${run.stderr}\nSTDOUT:\n${run.stdout}`); + const system = readJson(path.join(p.model, 'system.json')); const resource = system.components[0]; + assert.equal(resource.classification, 'FACT'); assert.equal(resource.confidence, 0.85); assert.deepEqual(resource.evidence[0], { path: 'src/Resource.java', startLine: 1, endLine: 1 }); + const operations = readJson(path.join(p.model, 'operations.json')); assert.equal(operations.runtimes[0].classification, 'INFERENCE'); assert.equal(operations.runtimes[0].confidence, 0.7); + const governance = readJson(path.join(p.model, 'data-governance.json')); assert.equal(Array.isArray(governance.classifications[0].classification), true); + const trace = readJson(path.join(p.traceability, 'pages', 'overview.json')); assert.equal(trace.claims[0].sourceModelRefs[0], 'system:resource'); assert.equal(trace.claims[0].classification, 'FACT'); assert.equal(trace.claims[0].evidence.length, 1); + const summary = readJson(path.join(p.audit, 'quality-summary.json')); assert.equal(summary.pass, true); assert.equal(summary.deterministicFailures, 0); assert.equal(summary.evidenceReferences >= 1, true); +});