/** * cli:audit-ba — rules/sources.ts (SRC-001..007 — the client-sources verdict). * Mirrors business-analyse/audit-sources/SKILL.md. All mechanical. * * Fail-closed both ways: a registry that cannot be read is `suspect` (SRC-001 * err), and « no citation » is only acceptable when NO in-scope source exists * (SRC-005 — N=0 never concludes). The registry itself lives OUTSIDE baRoot * (`.smartstack/sources/`, sibling root) and reaches the rules through * `model.sources` — the engine never re-reads the disk. */ import { citationsInText, resolveCitation, type SourceCitation, type SourcesRegistry, } from '../../../../../lib/ba-sources.js' import type { CorpusModel } from '../corpus/model.js' import { finding, fold, type RuleDef } from './registry.js' import type { Finding } from '../types.js' const DIM = 'sources' as const /** Every document that may CARRY a citation — the 6 authored docs plus the * menu index.md files (their `## Contexte` closes with the Sources line). */ interface CitationCarrier { relPath: string app?: string module?: string raw: string } function appCarriers(model: CorpusModel, app: string): CitationCarrier[] { const out: CitationCarrier[] = [] const a = model.apps.find((x) => x.app === app) if (a?.indexRaw) out.push({ relPath: `${app}/index.md`, app, raw: a.indexRaw }) for (const d of model.docs) { if (d.app === app && !d.relPath.includes('_audit')) { out.push({ relPath: d.relPath, app: d.app, module: d.module, raw: d.raw }) } } for (const m of model.modules.filter((x) => x.app === app)) { if (m.indexRaw) out.push({ relPath: `${m.app}/${m.module}/index.md`, app: m.app, module: m.module, raw: m.indexRaw }) for (const s of m.sectionNodes) { if (s.indexRaw) { out.push({ relPath: `${m.app}/${m.module}/${s.folder}/index.md`, app: m.app, module: m.module, raw: s.indexRaw }) } } } return out } function allCarriers(model: CorpusModel): CitationCarrier[] { const out: CitationCarrier[] = [] if (model.rootIndexRaw) out.push({ relPath: 'index.md', raw: model.rootIndexRaw }) for (const a of model.apps) out.push(...appCarriers(model, a.app)) return out } function citationsOf(carriers: CitationCarrier[]): SourceCitation[] { return carriers.flatMap((c) => citationsInText(c.raw, c.relPath)) } /** `scopes` entries folded for comparison (`CRM/PIPELINE`). */ function scopesOf(reg: SourcesRegistry, code: string): string[] { return (reg.index?.sources[code]?.scopes ?? []).map((s) => fold(s.replace(/\s+/g, ''))) } export const SOURCES_RULES: RuleDef[] = [ { id: 'SRC-001', dimension: DIM, scope: 'project', kind: 'mechanical', evaluate({ model }) { const reg = model.sources if (!reg.exists) { return { findings: [finding('SRC-001', DIM, 'ok', {}, 'Aucun registre de sources (`.smartstack/sources/`) — vérification sans objet.')], } } const issues = [...reg.reconciliation.issues] // Root-level citations resolve here (no app/module owns index.md racine). if (model.rootIndexRaw) { for (const c of citationsInText(model.rootIndexRaw, 'index.md')) { const r = resolveCitation(reg, c) if (r !== 'ok') issues.push(`index.md (racine) cite ${c.code}${c.anchor !== null ? ` §${c.anchor}` : ''} — ${r}.`) } } return { findings: [ issues.length > 0 ? finding('SRC-001', DIM, 'err', {}, 'Registre de sources INCOHÉRENT — tant que l’index et le disque divergent, aucune citation n’est fiable.', issues) : finding('SRC-001', DIM, 'ok', {}, `Registre cohérent — ${Object.keys(reg.index?.sources ?? {}).length} source(s), index ↔ disque réconciliés.`), ], } }, }, { id: 'SRC-002', dimension: DIM, scope: 'project', kind: 'mechanical', evaluate({ model }) { const reg = model.sources if (!reg.exists || !reg.index) { return { findings: [finding('SRC-002', DIM, 'ok', {}, 'Aucun registre lisible — sans objet (SRC-001 porte l’incohérence éventuelle).')] } } const errs: string[] = [] const warns: string[] = [] for (const e of Object.values(reg.index.sources)) { if (e.status === 'ingested') { const doc = reg.docs.find((d) => d.code === e.code)?.parsed const missing: string[] = [] if (!doc || doc.resume === '') missing.push('résumé') if ((doc?.tags ?? e.tags).length === 0) missing.push('tags') if ((doc?.sections.length ?? e.sections) === 0) missing.push('sections §') if (missing.length > 0) errs.push(`${e.code} (${e.title}) : ingérée sans ${missing.join(' ni ')} — « ingéré, 0 extrait » n'existe pas.`) } else if (e.status.startsWith('blocked/')) { warns.push(`${e.code} (${e.title}) : ${e.status} — document client NON exploité ; exporte puis rattache via ingest \`"as":"${e.code}"\`.`) } } // Scope resolution — the /ba-reconcile-menu blind spot: a menu rename // rewrites the downstream BA codes but NOT the registry's `scopes`, and // an orphan scope would make SRC-005 vacuously ok (fail-open). Only // meaningful once a menu tree exists — scopes are « pressenties » and // may legitimately precede it. if (model.apps.length > 0) { const appKeys = new Set(model.apps.map((a) => fold(a.app))) const moduleKeys = new Set(model.modules.map((m) => fold(`${m.app}/${m.module}`.replace(/\s+/g, '')))) for (const e of Object.values(reg.index.sources)) { if (e.status !== 'ingested') continue for (const s of e.scopes ?? []) { const key = fold(s.replace(/\s+/g, '')) const resolves = key.includes('/') ? moduleKeys.has(key) : appKeys.has(key) if (!resolves) { warns.push( `${e.code} (${e.title}) : portée pressentie « ${s} » ne résout vers aucun nœud du menu — menu renommé ? /ba-reconcile-menu ne réécrit PAS le registre ; corrige les scopes via /ba-create-sources, sinon SRC-005 ne surveille plus rien pour cette source.`, ) } } } } const findings: Finding[] = [] if (errs.length > 0) findings.push(finding('SRC-002', DIM, 'err', {}, 'Sources ingérées incomplètes — un résumé vide ne peut nourrir aucune phase.', errs)) if (warns.length > 0) findings.push(finding('SRC-002', DIM, 'warn', {}, 'Sources bloquées en attente — leur contenu n’alimente encore rien.', warns)) if (findings.length === 0) findings.push(finding('SRC-002', DIM, 'ok', {}, 'Chaque source ingérée porte résumé, tags et sections.')) return { findings } }, }, { id: 'SRC-003', dimension: DIM, scope: 'project', kind: 'mechanical', evaluate({ model }) { const reg = model.sources if (!reg.exists || !reg.index) { return { findings: [finding('SRC-003', DIM, 'ok', {}, 'Aucun registre lisible — sans objet.')] } } const errs: string[] = [] for (const e of Object.values(reg.index.sources)) { if (e.kind !== 'web' || e.status !== 'ingested') continue const doc = reg.docs.find((d) => d.code === e.code)?.parsed const verbatims = doc ? doc.sections.filter((s) => s.verbatim).length : e.extracts if (verbatims === 0) { errs.push(`${e.code} (${e.title}) : source web sans AUCUN extrait verbatim — une recherche qui n'a rien retenu n'entre pas au registre.`) } } return { findings: [ errs.length > 0 ? finding('SRC-003', DIM, 'err', {}, 'Source(s) web sans extrait retenu — le CLI le refuse à l’écriture, ceci est le filet des éditions manuelles.', errs) : finding('SRC-003', DIM, 'ok', {}, 'Toute source web ingérée porte au moins un extrait verbatim.'), ], } }, }, { id: 'SRC-004', dimension: DIM, scope: 'app', kind: 'mechanical', evaluate({ model, app }) { const a = app! const reg = model.sources const carriers = appCarriers(model, a.app) const cites = citationsOf(carriers) if (cites.length === 0) { return { findings: [finding('SRC-004', DIM, 'ok', { app: a.app }, `0 citation SRC dans les documents de ${a.app} — rien à résoudre.`)] } } const broken: string[] = [] for (const c of cites) { const r = resolveCitation(reg, c) if (r !== 'ok') { broken.push(`${c.where} cite ${c.code}${c.anchor !== null ? ` §${c.anchor}` : ''} — ${r === 'no-registry' ? 'AUCUN registre de sources n’existe' : r === 'unknown-code' ? 'ce code n’existe pas dans le registre' : 'cette ancre §n n’existe pas dans la source'}.`) } } return { findings: [ broken.length > 0 ? finding('SRC-004', DIM, 'err', { app: a.app }, `Citations cassées — ${cites.length} citation(s) vérifiée(s), ${broken.length} irrésolvable(s) : une citation qui ne résout pas est une provenance inventée.`, broken) : finding('SRC-004', DIM, 'ok', { app: a.app }, `${cites.length} citation(s) vérifiée(s), toutes résolvent (code ET ancre).`), ], } }, }, { id: 'SRC-005', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const reg = model.sources if (!reg.exists || !reg.index) { return { findings: [finding('SRC-005', DIM, 'ok', { app: m.app, module: m.module }, 'Aucun registre de sources — spec sans citation acceptable (rien n’était à consommer).')] } } const moduleKey = fold(`${m.app}/${m.module}`.replace(/\s+/g, '')) const appKey = fold(m.app) const ingested = Object.values(reg.index.sources).filter((e) => e.status === 'ingested') const moduleScoped = ingested.filter((e) => scopesOf(reg, e.code).includes(moduleKey)) const appScoped = ingested.filter((e) => { const scopes = scopesOf(reg, e.code) return scopes.includes(appKey) && !scopes.includes(moduleKey) }) if (moduleScoped.length === 0 && appScoped.length === 0) { return { findings: [ finding('SRC-005', DIM, 'ok', { app: m.app, module: m.module }, `Aucune source déclarée en portée ${m.app}/${m.module} — spec sans citation acceptable (0 source cherchée ne conclut jamais « rien à citer »).`), ], } } const carriers: CitationCarrier[] = [] if (m.indexRaw) carriers.push({ relPath: `${m.app}/${m.module}/index.md`, raw: m.indexRaw }) for (const s of m.sectionNodes) if (s.indexRaw) carriers.push({ relPath: `${m.app}/${m.module}/${s.folder}/index.md`, raw: s.indexRaw }) for (const d of model.docs) if (d.app === m.app && d.module === m.module && !d.relPath.includes('_audit')) carriers.push({ relPath: d.relPath, raw: d.raw }) const citedCodes = new Set(citationsOf(carriers).map((c) => c.code)) const uncitedModule = moduleScoped.filter((e) => !citedCodes.has(e.code)) const uncitedApp = appScoped.filter((e) => !citedCodes.has(e.code)) const findings: Finding[] = [] if (uncitedModule.length > 0) { findings.push( finding('SRC-005', DIM, 'err', { app: m.app, module: m.module }, `${moduleScoped.length} source(s) en portée ${m.app}/${m.module}, ${uncitedModule.length} jamais citée(s) dans le module — la spec ignore du matériel client déclaré pertinent.`, uncitedModule.map((e) => `${e.code} — ${e.title}`)), ) } if (uncitedApp.length > 0) { findings.push( finding('SRC-005', DIM, 'warn', { app: m.app, module: m.module }, `${appScoped.length} source(s) en portée ${m.app} (app entière), ${uncitedApp.length} non citée(s) ici — vérifie si ce module est concerné.`, uncitedApp.map((e) => `${e.code} — ${e.title}`)), ) } if (findings.length === 0) { findings.push(finding('SRC-005', DIM, 'ok', { app: m.app, module: m.module }, `${moduleScoped.length + appScoped.length} source(s) en portée, toutes citées dans le module.`)) } return { findings } }, }, { id: 'SRC-006', dimension: DIM, scope: 'project', kind: 'mechanical', evaluate({ model }) { const reg = model.sources if (!reg.exists || !reg.index) { return { findings: [finding('SRC-006', DIM, 'ok', {}, 'Aucun registre lisible — sans objet.')] } } const citedCodes = new Set(citationsOf(allCarriers(model)).map((c) => c.code)) const unconsumed = Object.values(reg.index.sources).filter((e) => e.status === 'ingested' && !citedCodes.has(e.code)) return { findings: [ unconsumed.length > 0 ? finding('SRC-006', DIM, 'warn', {}, `${unconsumed.length} source(s) ingérée(s) jamais citée(s) dans l’arbre BA — cite-les où elles portent, ou marque-les \`superseded\` (l’anti-dead-data vaut aussi pour le registre).`, unconsumed.map((e) => `${e.code} — ${e.title}`)) : finding('SRC-006', DIM, 'ok', {}, 'Chaque source ingérée est consommée par au moins une spec.'), ], } }, }, { id: 'SRC-007', dimension: DIM, scope: 'project', kind: 'mechanical', evaluate({ model }) { const reg = model.sources if (!reg.exists || !reg.index) { return { findings: [finding('SRC-007', DIM, 'ok', {}, 'Aucun registre lisible — sans objet.')] } } const superseded = new Map( Object.values(reg.index.sources) .filter((e) => e.status === 'superseded') .map((e) => [e.code, e]), ) if (superseded.size === 0) { return { findings: [finding('SRC-007', DIM, 'ok', {}, 'Aucune source remplacée — rien à re-router.')] } } const stale: string[] = [] for (const c of citationsOf(allCarriers(model))) { const e = superseded.get(c.code) if (e) stale.push(`${c.where} cite ${c.code} (remplacée${e.supersededBy ? ` par ${e.supersededBy}` : ''}) — re-fonde l’item sur la source à jour.`) } return { findings: [ stale.length > 0 ? finding('SRC-007', DIM, 'warn', {}, 'Des specs citent encore des sources REMPLACÉES — la provenance pointe sur du matériel périmé.', stale) : finding('SRC-007', DIM, 'ok', {}, `${superseded.size} source(s) remplacée(s), plus aucune citée.`), ], } }, }, ] export type { Finding }