/** * lib/ba-sources.ts — THE shared contract of the client-sources registry. * * A BA spec that leans on client-provided material must SAY SO: every * artefact line `- **Sources** : SRC-001 §2` binds a written decision to a * registered document (or a retained web finding). Until this module the * provenance existed only as prose: `_workflow/proposal-method.md` MANDATED * cited web research while no BA skill declared a web tool, no format * existed, nothing was stored and no audit read anything — the exact * dead-data failure the removed UC back-references (`Règles liées`) proved. * * The registry is a SIBLING root of the BA tree — `.smartstack/sources/`, * never inside `ba/` (ba-files.md §Boundary: `ba/` is the spec; the `_dev/` * mistake is documented there). It is COMMITTED: a spec that cites SRC-002 §3 * must stay verifiable after the client's original file is gone. * * READ-ONLY by construction — it never writes. Two consumers share it, and * that is why it lives in lib/ rather than beside a CLI: audit-ba may only * import cross-skill from an ENUMERATED allowlist (`src/lib/installer.ts`, * `(create-rbac|create-screen)`), so SRC-* rules importing create-sources * would ship pointing at a directory that does not exist. * * - `create-sources/cli/{ingest,search,status}` — the writer + the readers; * - `audit-ba` rules/sources.ts — SRC-001..007 (the verdict). * * Fail-closed doctrine (inherited from ba-referential-codes / DM-022): an * empty result only means something when something was SEARCHED — every * search result carries its counts, and a registry that cannot be read is * `suspect`, never silently green. */ import { existsSync, readFileSync, readdirSync } from 'node:fs' import { createHash } from 'node:crypto' import { join } from 'node:path' import { fold, foldPreservingLength } from './ba-referential-codes.js' // --------------------------------------------------------------------------- // Location — the ONE definition // --------------------------------------------------------------------------- /** Folder name of the registry, sibling of the BA tree. */ export const SOURCES_DIR = 'sources' export const SOURCES_INDEX_FILE = 'index.json' /** File name of the normalized, citable document inside each `SRC-NNN/`. */ export const SOURCE_DOC_FILE = 'source.md' /** `.smartstack/ba` → `.smartstack/sources`. The ONLY place this is derived. */ export function sourcesRootFor(baRoot: string): string { return join(baRoot, '..', SOURCES_DIR) } // --------------------------------------------------------------------------- // Index contract — `.smartstack/sources/index.json` // --------------------------------------------------------------------------- export type SourceKind = 'file' | 'web' /** * `blocked/*` is a TYPED admission, not a failure: the SRC code is reserved * and the document is on record as UNREAD — « ingested, 0 extracts » must * never exist (the support-report `classifyFailure` doctrine: no verdict on * an absence of proof). */ export type SourceStatus = 'ingested' | 'blocked/needs-export' | 'blocked/unreadable' | 'superseded' export type SourceFormat = | 'pdf' | 'md' | 'txt' | 'csv' | 'json' | 'eml' | 'image' | 'docx' | 'xlsx' | 'msg' | 'web' | 'other' export type SourceOrigin = { path: string } | { url: string; fetchedAt: string } export interface SourceEntry { /** `SRC-NNN` — stable, greppable, NEVER renumbered nor reused. */ code: string kind: SourceKind title: string /** sha256 of the normalized content, truncated to 12 hex (support-report * idiom): 1 fingerprint = 1 code, re-ingesting the same content is a hit * on the existing code, never a second folder. */ fingerprint: string origin: SourceOrigin format: SourceFormat ingestedAt: string updatedAt: string status: SourceStatus /** When status=superseded: the SRC code that replaces this one. */ supersededBy?: string /** Judgment tags (model-authored, user-validated) — the phases' filter. */ tags: string[] /** Menu scopes this source is believed to inform: `CRM` or `CRM/PIPELINE`. */ scopes: string[] /** One line — the long summary lives in source.md. */ summary: string sections: number extracts: number /** Relative path of the committed raw copy (kind=file only). */ rawFile?: string /** true when the raw copy was skipped (size cap) — origin.path remains. */ rawOmitted?: boolean } export interface SourcesIndex { version: 1 /** Next SRC sequence number — strictly greater than every allocated one. */ nextSeq: number sources: Record } export const SOURCE_CODE_RE = /^SRC-\d{3}$/ export function sourceCodeOf(seq: number): string { return `SRC-${String(seq).padStart(3, '0')}` } /** Parse index.json tolerantly — a corrupt index is DATA (`index: null` + * errors), never a crash; SRC-001 turns it into the verdict. */ export function parseSourcesIndex(raw: string): { index: SourcesIndex | null; errors: string[] } { let parsed: unknown try { parsed = JSON.parse(raw) } catch (e) { return { index: null, errors: [`index.json is not valid JSON: ${e instanceof Error ? e.message : String(e)}`] } } if (typeof parsed !== 'object' || parsed === null) { return { index: null, errors: ['index.json is not an object'] } } const obj = parsed as Record const errors: string[] = [] if (obj.version !== 1) errors.push(`index.json version is ${String(obj.version)} — expected 1`) if (typeof obj.nextSeq !== 'number' || !Number.isInteger(obj.nextSeq) || obj.nextSeq < 1) { errors.push('index.json nextSeq is not a positive integer') } if (typeof obj.sources !== 'object' || obj.sources === null) errors.push('index.json has no sources map') if (errors.length > 0) return { index: null, errors } return { index: obj as unknown as SourcesIndex, errors } } // --------------------------------------------------------------------------- // source.md — the normalized, citable document // --------------------------------------------------------------------------- // // // # SRC-001 — Cahier des charges v2 // // ## Métadonnées // - **Origine** : `docs/cdc-v2.pdf` (copie : `raw/cdc-v2.pdf`) // - **Format** : pdf · **Ingéré le** : 2026-09-02 // - **Tags** : facturation, processus-vente // - **Portée pressentie** : CRM, CRM/PIPELINE // // ## Résumé // {5-15 lignes} // // ## Points saillants // ### §1 — Processus de vente [processus-vente] // {2-6 lignes} // ### §2 — Plafond de remise [regles] (p. 12) — extrait verbatim // > « Toute remise supérieure à 20 % requiert la validation du manager. » // // ## Ce que cette source ne couvre PAS // - {scopeNotes} export const SOURCE_ANCHOR_RE = /^/ export interface ParsedSourceSection { /** The §n anchor cited by the specs. */ anchor: number title: string tags: string[] /** Optional locator inside the original (page, sheet, chapter). */ where?: string /** true ⇔ the body carries a `>` blockquote — a VERBATIM extract. The * heading's « — extrait verbatim » is decoration; the blockquote decides. */ verbatim: boolean body: string } export interface ParsedSourceDoc { code: string kind: SourceKind | null fingerprint: string | null status: SourceStatus | null title: string tags: string[] scopes: string[] resume: string sections: ParsedSourceSection[] scopeNotes: string[] warnings: string[] } function parseAnchorAttrs(attrText: string): Record { const out: Record = {} for (const m of attrText.matchAll(/([a-zA-Z][\w/-]*)=([^\s]+)/g)) { out[m[1]!] = m[2]! } return out } const STATUS_VALUES: readonly SourceStatus[] = ['ingested', 'blocked/needs-export', 'blocked/unreadable', 'superseded'] function csvList(s: string): string[] { return s .split(',') .map((v) => v.trim()) .filter((v) => v !== '' && v !== '—') } /** Body text of one `## Heading` (until the next `## `), or ''. */ function sectionBody(lines: readonly string[], headingRe: RegExp): string { const start = lines.findIndex((l) => headingRe.test(l)) if (start === -1) return '' const out: string[] = [] for (let i = start + 1; i < lines.length; i++) { if (/^##\s/.test(lines[i]!)) break out.push(lines[i]!) } return out.join('\n').trim() } /** * Parse one source.md. Returns null when the `ba:source` anchor is missing or * carries no code — the caller counts raw anchors separately (control-counts * idiom) so a mute parser can never look green. */ export function parseSourceDoc(raw: string, relPath: string): ParsedSourceDoc | null { const warnings: string[] = [] const anchorMatch = SOURCE_ANCHOR_RE.exec(raw.trimStart()) if (!anchorMatch) return null const attrs = parseAnchorAttrs(anchorMatch[1]!) const code = attrs.code ?? '' if (!SOURCE_CODE_RE.test(code)) return null const kind = attrs.kind === 'file' || attrs.kind === 'web' ? attrs.kind : null if (kind === null) warnings.push(`${relPath}: anchor kind is ${attrs.kind ?? '(absent)'} — expected file|web`) const status = STATUS_VALUES.includes(attrs.status as SourceStatus) ? (attrs.status as SourceStatus) : null if (status === null) { warnings.push(`${relPath}: anchor status is ${attrs.status ?? '(absent)'} — expected ${STATUS_VALUES.join('|')}`) } const fingerprint = /^[0-9a-f]{12}$/.test(attrs.fingerprint ?? '') ? attrs.fingerprint! : null if (fingerprint === null) warnings.push(`${relPath}: anchor fingerprint is not 12 hex chars`) const lines = raw.split(/\r?\n/) const titleLine = lines.find((l) => /^#\s/.test(l)) ?? '' const titleMatch = /^#\s+SRC-\d{3}\s+—\s+(.*)$/.exec(titleLine) const title = (titleMatch?.[1] ?? titleLine.replace(/^#\s+/, '')).trim() const metaBody = sectionBody(lines, /^##\s+M[ée]tadonn[ée]es\b/i) const tagsMatch = /\*\*Tags\*\*\s*:\s*(.+)$/im.exec(metaBody) const tags = tagsMatch ? csvList(tagsMatch[1]!) : [] const scopesMatch = /\*\*Port[ée]e pressentie\*\*\s*:\s*(.+)$/im.exec(metaBody) const scopes = scopesMatch ? csvList(scopesMatch[1]!) : [] const resume = sectionBody(lines, /^##\s+R[ée]sum[ée]?\b/i) // --- Points saillants: ### §n — Titre [tags] (où) — extrait verbatim ----- const sections: ParsedSourceSection[] = [] const saillantsStart = lines.findIndex((l) => /^##\s+Points saillants\b/i.test(l)) if (saillantsStart !== -1) { let current: ParsedSourceSection | null = null const bodyLines: string[] = [] const flush = (): void => { if (current) { current.body = bodyLines.join('\n').trim() current.verbatim = bodyLines.some((l) => /^\s*>/.test(l)) sections.push(current) } bodyLines.length = 0 } for (let i = saillantsStart + 1; i < lines.length; i++) { const line = lines[i]! if (/^##\s/.test(line)) break const h = /^###\s*§(\d+)\s*—\s*(.*)$/.exec(line) if (h) { flush() let rest = h[2]!.trim().replace(/\s*—\s*extraits?\s+verbatims?\s*$/i, '') let where: string | undefined const whereM = /\(([^()]*)\)\s*$/.exec(rest) if (whereM) { where = whereM[1]!.trim() rest = rest.slice(0, whereM.index).trim() } let sectionTags: string[] = [] const tagsM = /\[([^\][]*)\]\s*$/.exec(rest) if (tagsM) { sectionTags = csvList(tagsM[1]!) rest = rest.slice(0, tagsM.index).trim() } current = { anchor: Number(h[1]), title: rest, tags: sectionTags, where, verbatim: false, body: '' } } else if (current) { bodyLines.push(line) } } flush() } const seenAnchors = new Set() for (const s of sections) { if (seenAnchors.has(s.anchor)) warnings.push(`${relPath}: duplicate section anchor §${s.anchor}`) seenAnchors.add(s.anchor) } const notesBody = sectionBody(lines, /^##\s+Ce que cette source ne couvre PAS\b/i) const scopeNotes = notesBody .split(/\r?\n/) .map((l) => l.replace(/^\s*-\s*/, '').trim()) .filter((l) => l !== '') return { code, kind, fingerprint, status, title, tags, scopes, resume, sections, scopeNotes, warnings } } // --------------------------------------------------------------------------- // Registry — index ↔ disk, reconciled fail-closed // --------------------------------------------------------------------------- export interface SourceDocFile { /** Folder name (`SRC-001`). */ code: string relPath: string raw: string parsed: ParsedSourceDoc | null } export interface SourcesRegistry { /** false ⇔ the sibling root does not exist — a legitimate state (« aucun * registre — sans objet »), NEVER an error by itself. */ exists: boolean root: string index: SourcesIndex | null docs: SourceDocFile[] /** Independent control (control-counts idiom): raw `ba:source` anchors on * disk vs docs that actually parsed — a mute parser cannot look green. */ controls: { anchors: number; parsed: number } /** Index ↔ disk reconciliation. `suspect` feeds SRC-001, never a crash. */ reconciliation: { status: 'ok' | 'suspect'; issues: string[] } warnings: string[] } export function loadSourcesRegistry(sourcesRoot: string): SourcesRegistry { const empty: SourcesRegistry = { exists: false, root: sourcesRoot, index: null, docs: [], controls: { anchors: 0, parsed: 0 }, reconciliation: { status: 'ok', issues: [] }, warnings: [], } if (!existsSync(sourcesRoot)) return empty const issues: string[] = [] const warnings: string[] = [] let index: SourcesIndex | null = null const indexPath = join(sourcesRoot, SOURCES_INDEX_FILE) if (!existsSync(indexPath)) { issues.push(`${SOURCES_INDEX_FILE} is missing while ${sourcesRoot} exists`) } else { try { const parsed = parseSourcesIndex(readFileSync(indexPath, 'utf8')) index = parsed.index issues.push(...parsed.errors) } catch (e) { issues.push(`${SOURCES_INDEX_FILE}: unreadable — ${e instanceof Error ? e.message : String(e)}`) } } const docs: SourceDocFile[] = [] let anchors = 0 let dirNames: string[] = [] try { dirNames = readdirSync(sourcesRoot, { withFileTypes: true }) .filter((e) => e.isDirectory() && SOURCE_CODE_RE.test(e.name)) .map((e) => e.name) .sort() } catch (e) { issues.push(`${sourcesRoot}: unreadable — ${e instanceof Error ? e.message : String(e)}`) } for (const dir of dirNames) { const relPath = `${dir}/${SOURCE_DOC_FILE}` const docPath = join(sourcesRoot, dir, SOURCE_DOC_FILE) if (!existsSync(docPath)) { issues.push(`${dir}/ has no ${SOURCE_DOC_FILE}`) continue } let raw = '' try { raw = readFileSync(docPath, 'utf8') } catch (e) { issues.push(`${relPath}: unreadable — ${e instanceof Error ? e.message : String(e)}`) continue } if (/