Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c3fcabe
feat: stabilize runtime and add stack-neutral quality gates
github-actions[bot] Jul 16, 2026
8cd7359
ci: replace bootstrap workflow with validated cross-platform checks
fajarnugraha37 Jul 16, 2026
5d46ac3
fix: normalize provider artifacts and repair incomplete model bundles
fajarnugraha37 Jul 16, 2026
d764e7f
fix: eliminate repeated missing model object failures
fajarnugraha37 Jul 17, 2026
44c6496
fix: sanitize audit inputs and stop before costly risk audit
fajarnugraha37 Jul 17, 2026
9aa3a00
fix: fail deterministic audit before invoking provider
fajarnugraha37 Jul 17, 2026
bd4c5d9
test: prove deterministic failures spend zero audit-provider calls
fajarnugraha37 Jul 17, 2026
ad15e18
test: include audit guard in syntax gate
fajarnugraha37 Jul 17, 2026
f89cc7d
fix: make LLM risk findings advisory unless explicitly blocking
fajarnugraha37 Jul 17, 2026
33027f3
refactor: centralize advisory LLM audit policy
fajarnugraha37 Jul 17, 2026
0683cee
test: route LLM findings through explicit advisory policy
fajarnugraha37 Jul 17, 2026
7898c9b
config: default LLM risk findings to advisory
fajarnugraha37 Jul 17, 2026
8c74e55
test: lock advisory and blocking LLM audit behavior
fajarnugraha37 Jul 17, 2026
d705ece
test: include audit policy in syntax gate
fajarnugraha37 Jul 17, 2026
1a0ecce
fix: make LLM audit explicitly opt-in by mode
fajarnugraha37 Jul 17, 2026
b7695b5
fix: skip audit provider unless LLM audit mode is explicit
fajarnugraha37 Jul 17, 2026
f709ee4
config: make audit provider opt-in
fajarnugraha37 Jul 17, 2026
755cfb4
fix: auto-migrate legacy audit configuration to token-safe defaults
fajarnugraha37 Jul 17, 2026
75aeb4d
test: verify audit provider is opt-in and modes are explicit
fajarnugraha37 Jul 17, 2026
d7a94f6
test: prove legacy audit config makes zero provider calls
fajarnugraha37 Jul 17, 2026
aa39266
ci: trigger full PR validation after audit hardening
fajarnugraha37 Jul 17, 2026
ca0c505
ci: attach exact git bundle for failed-run reproduction
fajarnugraha37 Jul 17, 2026
20c53d3
ci: apply validated regression fix
fajarnugraha37 Jul 17, 2026
e9c5d8a
ci: run validated regression fix in PR
fajarnugraha37 Jul 17, 2026
43c7c91
fix: preserve domain classification catalogs and align audit regressions
Jul 17, 2026
2e9cfd5
ci: restore full cross-platform validation
fajarnugraha37 Jul 17, 2026
190488a
ci: isolate Windows test failure output
fajarnugraha37 Jul 17, 2026
d6bf548
ci: upload concise Windows failure context
fajarnugraha37 Jul 17, 2026
ea963f7
test: launch model bundle fixture through Windows shim
fajarnugraha37 Jul 17, 2026
41268f9
ci: restore final cross-platform validation
fajarnugraha37 Jul 17, 2026
b11f6af
merge: synchronize Phase 2 branch with main
fajarnugraha37 Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions global-template/docgen/CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<stage>.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:
Expand Down
316 changes: 316 additions & 0 deletions global-template/docgen/lib/audit-guard.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
}
42 changes: 42 additions & 0 deletions global-template/docgen/lib/audit-policy.mjs
Original file line number Diff line number Diff line change
@@ -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
};
}
Loading
Loading