Skip to content

Commit 44c6496

Browse files
fix: sanitize audit inputs and stop before costly risk audit
1 parent d764e7f commit 44c6496

1 file changed

Lines changed: 311 additions & 0 deletions

File tree

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { auditRepository } from './quality.mjs';
4+
import {
5+
ensureDir,
6+
loadConfig,
7+
now,
8+
posix,
9+
projectPaths,
10+
readJson,
11+
sha256,
12+
updateStage,
13+
writeJson
14+
} from './core.mjs';
15+
import {
16+
evidenceFromAliases,
17+
normalizeClassification,
18+
normalizeConfidence,
19+
normalizeEvidence,
20+
normalizeSourceModelRefs
21+
} from './semantic.mjs';
22+
23+
const EVIDENCE_ALIAS_KEYS = ['evidenceRefs', 'sources', 'sourceRefs', 'citations', 'references'];
24+
25+
function safeRelative(value) {
26+
const raw = posix(String(value ?? '').trim()).replace(/^\.\//, '');
27+
if (!raw || path.posix.isAbsolute(raw) || /^[a-z]:\//i.test(raw)) return null;
28+
const normalized = path.posix.normalize(raw);
29+
if (normalized === '..' || normalized.startsWith('../')) return null;
30+
return normalized;
31+
}
32+
33+
function inventoryContext(root) {
34+
const inventory = readJson(projectPaths(root).inventory, { files: [], excluded: [] });
35+
return {
36+
inventory,
37+
files: new Map((inventory.files ?? []).map((item) => [safeRelative(item.path), item]).filter(([name]) => name))
38+
};
39+
}
40+
41+
function sourceRecord(root, relative, inventory, cache) {
42+
if (cache.has(relative)) return cache.get(relative);
43+
const indexed = inventory.files.get(relative);
44+
if (!indexed) {
45+
const result = { usable: false, stale: false, reason: 'outside-inventory' };
46+
cache.set(relative, result);
47+
return result;
48+
}
49+
const file = path.join(root, relative);
50+
if (!fs.existsSync(file)) {
51+
const result = { usable: false, stale: true, reason: 'missing-indexed-source' };
52+
cache.set(relative, result);
53+
return result;
54+
}
55+
const buffer = fs.readFileSync(file);
56+
const text = buffer.toString('utf8');
57+
const hash = sha256(buffer);
58+
const result = {
59+
usable: !indexed.hash || indexed.hash === hash,
60+
stale: Boolean(indexed.hash && indexed.hash !== hash),
61+
reason: indexed.hash && indexed.hash !== hash ? 'source-changed' : null,
62+
lines: text.split(/\r?\n/).length
63+
};
64+
cache.set(relative, result);
65+
return result;
66+
}
67+
68+
function canonicalEvidence(root, raw, inventory, cache) {
69+
const normalized = normalizeEvidence(raw)[0];
70+
const relative = safeRelative(normalized?.path);
71+
if (!relative) return null;
72+
const source = sourceRecord(root, relative, inventory, cache);
73+
if (source.stale) return { ...normalized, path: relative, __stale: true };
74+
if (!source.usable) return null;
75+
const startLine = normalized.startLine;
76+
const endLine = normalized.endLine ?? startLine;
77+
if (startLine !== undefined) {
78+
if (!Number.isInteger(startLine) || startLine < 1) return null;
79+
if (!Number.isInteger(endLine) || endLine < startLine || endLine > source.lines) return null;
80+
}
81+
return { path: relative, ...(startLine ? { startLine, endLine } : {}) };
82+
}
83+
84+
function evidenceKey(value) {
85+
return `${value.path}\0${value.startLine ?? ''}\0${value.endLine ?? ''}`;
86+
}
87+
88+
function dedupeEvidence(values) {
89+
const seen = new Set();
90+
return values.filter((value) => {
91+
const key = evidenceKey(value);
92+
if (seen.has(key)) return false;
93+
seen.add(key);
94+
return true;
95+
});
96+
}
97+
98+
function matchingAllowedEvidence(requested, allowed) {
99+
const relative = safeRelative(requested?.path);
100+
if (!relative) return null;
101+
const candidates = allowed.filter((entry) => entry.path === relative && !entry.__stale);
102+
if (!candidates.length) return null;
103+
if (!requested.startLine) return candidates[0];
104+
return candidates.find((entry) => {
105+
if (!entry.startLine) return true;
106+
const end = entry.endLine ?? entry.startLine;
107+
return requested.startLine >= entry.startLine && requested.startLine <= end;
108+
}) ?? null;
109+
}
110+
111+
function removeEvidenceAliases(value) {
112+
for (const key of EVIDENCE_ALIAS_KEYS) delete value[key];
113+
}
114+
115+
function sanitizeSemanticObject(root, value, inventory, sourceCache) {
116+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
117+
let changed = false;
118+
if (value.id || value.name || value.statement) {
119+
const before = JSON.stringify({
120+
classification: value.classification,
121+
confidence: value.confidence,
122+
evidence: evidenceFromAliases(value),
123+
aliases: EVIDENCE_ALIAS_KEYS.map((key) => value[key])
124+
});
125+
const evidence = dedupeEvidence(evidenceFromAliases(value)
126+
.map((entry) => canonicalEvidence(root, entry, inventory, sourceCache))
127+
.filter(Boolean));
128+
const requested = normalizeClassification(value.classification ?? value.claimClassification ?? value.certainty);
129+
const hasLineEvidence = evidence.some((entry) => entry.startLine && !entry.__stale);
130+
value.classification = requested === 'FACT' && !hasLineEvidence ? 'INFERENCE' : requested;
131+
value.confidence = normalizeConfidence(value.confidence ?? value.confidenceScore, value.classification);
132+
if (value.classification !== 'FACT') value.confidence = Math.min(value.confidence, 0.7);
133+
value.evidence = evidence.map(({ __stale, ...entry }) => entry);
134+
removeEvidenceAliases(value);
135+
delete value.claimClassification;
136+
delete value.certainty;
137+
if (before !== JSON.stringify({
138+
classification: value.classification,
139+
confidence: value.confidence,
140+
evidence: value.evidence,
141+
aliases: EVIDENCE_ALIAS_KEYS.map((key) => value[key])
142+
})) changed = true;
143+
}
144+
for (const [key, child] of Object.entries(value)) {
145+
if (['evidence', 'sourceModelRefs', 'modelRefs', ...EVIDENCE_ALIAS_KEYS].includes(key)) continue;
146+
if (Array.isArray(child)) {
147+
for (const item of child) changed = sanitizeSemanticObject(root, item, inventory, sourceCache) || changed;
148+
} else if (child && typeof child === 'object') {
149+
changed = sanitizeSemanticObject(root, child, inventory, sourceCache) || changed;
150+
}
151+
}
152+
return changed;
153+
}
154+
155+
function sanitizeModels(root, inventory, sourceCache) {
156+
const directory = projectPaths(root).model;
157+
if (!fs.existsSync(directory)) return { files: 0, changed: 0 };
158+
let files = 0; let changed = 0;
159+
for (const name of fs.readdirSync(directory).filter((item) => item.endsWith('.json') && !item.endsWith('-bundle.json'))) {
160+
const file = path.join(directory, name);
161+
const document = readJson(file, null);
162+
if (!document || typeof document !== 'object' || Array.isArray(document)) continue;
163+
files++;
164+
if (sanitizeSemanticObject(root, document, inventory, sourceCache)) {
165+
writeJson(file, document);
166+
changed++;
167+
}
168+
}
169+
return { files, changed };
170+
}
171+
172+
function contextEvidence(root, context, inventory, sourceCache) {
173+
const values = [];
174+
for (const item of context.modelItems ?? []) values.push(...evidenceFromAliases(item));
175+
for (const fact of context.facts ?? []) values.push({
176+
path: fact.path,
177+
startLine: fact.metadata?.startLine ?? fact.line,
178+
endLine: fact.metadata?.endLine ?? fact.line
179+
});
180+
return dedupeEvidence(values
181+
.map((entry) => canonicalEvidence(root, entry, inventory, sourceCache))
182+
.filter(Boolean));
183+
}
184+
185+
function sanitizeTrace(root, page, inventory, sourceCache) {
186+
const paths = projectPaths(root);
187+
const traceFile = path.join(paths.traceability, 'pages', `${page.id}.json`);
188+
if (!fs.existsSync(traceFile)) return { changed: false, droppedEvidence: 0, droppedRefs: 0, droppedClaims: 0 };
189+
const contextFile = path.join(paths.context, 'generate', `${page.id.replace(/[^a-z0-9_.-]+/gi, '-')}.json`);
190+
if (!fs.existsSync(contextFile)) return { changed: false, droppedEvidence: 0, droppedRefs: 0, droppedClaims: 0 };
191+
const trace = readJson(traceFile, {});
192+
const context = readJson(contextFile, {});
193+
if (!Array.isArray(trace.claims)) return { changed: false, droppedEvidence: 0, droppedRefs: 0, droppedClaims: 0 };
194+
195+
const modelItems = new Map(); const aliases = new Map(); const perModel = new Map();
196+
for (const item of context.modelItems ?? []) {
197+
modelItems.set(item.id, item);
198+
const ordinal = (perModel.get(item.model) ?? 0) + 1;
199+
perModel.set(item.model, ordinal);
200+
aliases.set(`${item.model}:${ordinal}`, item.id);
201+
}
202+
const allowedEvidence = contextEvidence(root, context, inventory, sourceCache);
203+
const ids = new Map(); const claims = [];
204+
let droppedEvidence = 0; let droppedRefs = 0; let droppedClaims = 0;
205+
206+
for (const rawClaim of trace.claims) {
207+
const claim = rawClaim && typeof rawClaim === 'object' ? { ...rawClaim } : {};
208+
const baseId = String(claim.id ?? `${page.id}:claim-${claims.length + 1}`).trim() || `${page.id}:claim-${claims.length + 1}`;
209+
const occurrence = (ids.get(baseId) ?? 0) + 1; ids.set(baseId, occurrence);
210+
claim.id = occurrence === 1 ? baseId : `${baseId}-${occurrence}`;
211+
212+
const requestedRefs = normalizeSourceModelRefs(claim.sourceModelRefs ?? claim.modelRefs).map((ref) => aliases.get(ref) ?? ref);
213+
const refs = [...new Set(requestedRefs.filter((ref) => modelItems.has(ref)))];
214+
droppedRefs += requestedRefs.length - refs.length;
215+
const inherited = refs.flatMap((ref) => evidenceFromAliases(modelItems.get(ref)))
216+
.map((entry) => canonicalEvidence(root, entry, inventory, sourceCache))
217+
.filter(Boolean);
218+
const requestedEvidence = evidenceFromAliases(claim);
219+
const direct = requestedEvidence.map((entry) => matchingAllowedEvidence(entry, allowedEvidence)).filter(Boolean);
220+
droppedEvidence += requestedEvidence.length - direct.length;
221+
const evidence = dedupeEvidence([...direct, ...inherited]).filter((entry) => !entry.__stale);
222+
const fallbackStatement = refs.map((ref) => {
223+
const item = modelItems.get(ref);
224+
return String(item?.statement ?? item?.payload?.statement ?? item?.name ?? '').trim();
225+
}).find(Boolean);
226+
claim.statement = String(claim.statement ?? fallbackStatement ?? '').trim();
227+
if (!claim.statement) { droppedClaims++; continue; }
228+
229+
const requestedClassification = normalizeClassification(claim.classification ?? claim.claimClassification ?? claim.certainty);
230+
const hasLineEvidence = evidence.some((entry) => entry.startLine);
231+
claim.classification = requestedClassification === 'FACT' && !hasLineEvidence ? 'INFERENCE' : requestedClassification;
232+
claim.confidence = normalizeConfidence(claim.confidence ?? claim.confidenceScore, claim.classification);
233+
if (claim.classification !== 'FACT') claim.confidence = Math.min(claim.confidence, 0.7);
234+
claim.evidence = evidence.map(({ __stale, ...entry }) => entry);
235+
claim.sourceModelRefs = refs;
236+
delete claim.modelRefs;
237+
delete claim.claimClassification;
238+
delete claim.certainty;
239+
removeEvidenceAliases(claim);
240+
claims.push(claim);
241+
}
242+
243+
const before = JSON.stringify(trace.claims);
244+
trace.claims = claims;
245+
const changed = before !== JSON.stringify(claims);
246+
if (changed) writeJson(traceFile, trace);
247+
return { changed, droppedEvidence, droppedRefs, droppedClaims };
248+
}
249+
250+
export function sanitizeAuditInputs(root, manifest) {
251+
const inventory = inventoryContext(root);
252+
const sourceCache = new Map();
253+
const models = sanitizeModels(root, inventory, sourceCache);
254+
const traces = { files: 0, changed: 0, droppedEvidence: 0, droppedRefs: 0, droppedClaims: 0 };
255+
for (const page of manifest.pages ?? []) {
256+
const result = sanitizeTrace(root, page, inventory, sourceCache);
257+
traces.files++;
258+
if (result.changed) traces.changed++;
259+
traces.droppedEvidence += result.droppedEvidence;
260+
traces.droppedRefs += result.droppedRefs;
261+
traces.droppedClaims += result.droppedClaims;
262+
}
263+
return { models, traces };
264+
}
265+
266+
function deterministicSummary(quality, sanitation) {
267+
return {
268+
schemaVersion: '2.0',
269+
generatedAt: now(),
270+
auditInputHash: quality.auditInputHash,
271+
inventoryFingerprint: quality.inventoryFingerprint,
272+
manifestHash: quality.manifestHash,
273+
pages: quality.metrics.pages,
274+
claims: quality.metrics.claims,
275+
evidenceReferences: quality.metrics.evidenceReferences,
276+
modelItems: quality.metrics.modelItems,
277+
referencedModelItems: quality.metrics.referencedModelItems,
278+
modelReferenceCoverage: quality.metrics.modelReferenceCoverage,
279+
deterministicFailures: quality.errors.length,
280+
deterministicWarnings: quality.warnings.length,
281+
llmAuditedPages: 0,
282+
highRiskFindings: 0,
283+
llmSkippedReason: 'deterministic-fail-fast',
284+
sanitation,
285+
pass: false
286+
};
287+
}
288+
289+
export async function guardedAudit(root, baseAudit) {
290+
const paths = projectPaths(root);
291+
ensureDir(paths.audit);
292+
const manifest = readJson(paths.plan);
293+
updateStage(root, 'audit', 'running', { mode: 'deterministic-preflight' });
294+
try {
295+
const sanitation = sanitizeAuditInputs(root, manifest);
296+
const quality = auditRepository(root, manifest);
297+
writeJson(path.join(paths.audit, 'deterministic.json'), quality);
298+
if (!quality.pass) {
299+
const summary = deterministicSummary(quality, sanitation);
300+
writeJson(path.join(paths.audit, 'quality-summary.json'), summary);
301+
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.`);
302+
updateStage(root, 'audit', 'failed', { error: error.message, inputHash: quality.auditInputHash, deterministicFailures: quality.errors.length, llmAuditSkipped: true, sanitation });
303+
throw error;
304+
}
305+
return await baseAudit(root);
306+
} catch (error) {
307+
const current = readJson(paths.state, { stages: {} }).stages?.audit;
308+
if (current?.status !== 'failed') updateStage(root, 'audit', 'failed', { error: error.message });
309+
throw error;
310+
}
311+
}

0 commit comments

Comments
 (0)