|
| 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