import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, sep } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { citationsInText, fingerprintOfContent, loadSourcesRegistry, parseSourceDoc, parseSourcesIndex, resolveCitation, searchSources, sourceCodeOf, sourcesRootFor, SOURCE_CITATION_RE, type SourcesIndex, type SourcesRegistry, } from '../ba-sources.js' // --------------------------------------------------------------------------- // Fixtures — inline markdown run through the PRODUCTION parser, never // hand-built ParsedSourceDoc objects. // --------------------------------------------------------------------------- const DOC_SRC_001 = ` # 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é Le client décrit son cycle de vente complet, du prospect au devis signé. La facturation est déléguée à un ERP tiers. ## Points saillants ### §1 — Processus de vente [processus-vente] Cycle en 4 étapes : prospect, qualification, proposition, signature. ### §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 - Rien sur les permissions ni les rôles. ` function indexWith(entries: SourcesIndex['sources'], nextSeq = 2): string { return JSON.stringify({ version: 1, nextSeq, sources: entries }) } function entryFor(code: string, overrides: Record = {}): SourcesIndex['sources'][string] { return { code, kind: 'file', title: 'Cahier des charges v2', fingerprint: 'a1b2c3d4e5f6', origin: { path: 'docs/cdc-v2.pdf' }, format: 'pdf', ingestedAt: '2026-09-02', updatedAt: '2026-09-02', status: 'ingested', tags: ['facturation', 'processus-vente'], scopes: ['CRM', 'CRM/PIPELINE'], summary: 'Cycle de vente complet, facturation déléguée.', sections: 2, extracts: 1, ...overrides, } as SourcesIndex['sources'][string] } /** In-memory registry built through the production parser. */ function registryOf(docsMd: Record, index: SourcesIndex | null): SourcesRegistry { const docs = Object.entries(docsMd).map(([code, raw]) => ({ code, relPath: `${code}/source.md`, raw, parsed: parseSourceDoc(raw, `${code}/source.md`), })) return { exists: true, root: '.smartstack/sources', index, docs, controls: { anchors: docs.length, parsed: docs.filter((d) => d.parsed !== null).length }, reconciliation: { status: 'ok', issues: [] }, warnings: [], } } // --------------------------------------------------------------------------- // sourcesRootFor // --------------------------------------------------------------------------- describe('sourcesRootFor', () => { it('is the SIBLING of the ba root, never inside it', () => { const root = sourcesRootFor(join('.smartstack', 'ba')) expect(root).toBe(join('.smartstack', 'sources')) expect(root.includes(`${sep}ba${sep}`)).toBe(false) }) }) // --------------------------------------------------------------------------- // Citation grammar // --------------------------------------------------------------------------- describe('SOURCE_CITATION_RE / citationsInText', () => { it('matches bare codes and §-anchored codes', () => { const c = citationsInText('- **Sources** : SRC-001 §2, SRC-003', 'use-case.md') expect(c).toHaveLength(2) expect(c[0]).toMatchObject({ code: 'SRC-001', anchor: 2, where: 'use-case.md' }) expect(c[1]).toMatchObject({ code: 'SRC-003', anchor: null }) }) it('rejects near-miss tokens: 4 digits, prefixed, suffixed', () => { expect(citationsInText('SRC-0011 XSRC-001 SRC-001-old SRC-001x', 'x')).toHaveLength(0) }) it('tolerates spacing around § and dedupes (code, anchor) pairs', () => { const c = citationsInText('SRC-001 § 2 puis encore SRC-001 §2, et SRC-001', 'x') expect(c.map((x) => `${x.code}#${x.anchor}`)).toEqual(['SRC-001#2', 'SRC-001#null']) }) it('carries a verbatim excerpt around the hit', () => { const text = `${'a'.repeat(100)} justifié par SRC-002 §1 dans la règle ${'b'.repeat(100)}` const [c] = citationsInText(text, 'règles-métier.md') expect(c!.excerpt).toContain('SRC-002 §1') expect(c!.excerpt.startsWith('…')).toBe(true) expect(c!.excerpt.endsWith('…')).toBe(true) }) it('is a global regex safe for matchAll', () => { expect(SOURCE_CITATION_RE.flags).toContain('g') }) }) // --------------------------------------------------------------------------- // parseSourceDoc // --------------------------------------------------------------------------- describe('parseSourceDoc', () => { it('parses the full normalized document', () => { const p = parseSourceDoc(DOC_SRC_001, 'SRC-001/source.md') expect(p).not.toBeNull() expect(p!.code).toBe('SRC-001') expect(p!.kind).toBe('file') expect(p!.fingerprint).toBe('a1b2c3d4e5f6') expect(p!.status).toBe('ingested') expect(p!.title).toBe('Cahier des charges v2') expect(p!.tags).toEqual(['facturation', 'processus-vente']) expect(p!.scopes).toEqual(['CRM', 'CRM/PIPELINE']) expect(p!.resume).toContain('cycle de vente') expect(p!.scopeNotes).toEqual(['Rien sur les permissions ni les rôles.']) expect(p!.warnings).toEqual([]) }) it('parses sections: anchor, tags, where, and verbatim from the blockquote', () => { const p = parseSourceDoc(DOC_SRC_001, 'SRC-001/source.md')! expect(p.sections).toHaveLength(2) expect(p.sections[0]).toMatchObject({ anchor: 1, title: 'Processus de vente', tags: ['processus-vente'], verbatim: false }) expect(p.sections[1]).toMatchObject({ anchor: 2, title: 'Plafond de remise', tags: ['regles'], where: 'p. 12', verbatim: true }) expect(p.sections[1]!.body).toContain('validation du manager') }) it('returns null without a ba:source anchor or with a non-SRC code (fail-closed)', () => { expect(parseSourceDoc('# Un document quelconque', 'x.md')).toBeNull() expect(parseSourceDoc('\n# DOC-1', 'x.md')).toBeNull() }) it('keeps parsing on a bad status/fingerprint but SAYS so in warnings', () => { const raw = DOC_SRC_001.replace('status=ingested', 'status=weird').replace('fingerprint=a1b2c3d4e5f6', 'fingerprint=zz') const p = parseSourceDoc(raw, 'SRC-001/source.md')! expect(p.status).toBeNull() expect(p.fingerprint).toBeNull() expect(p.warnings.join(' ')).toContain('status') expect(p.warnings.join(' ')).toContain('fingerprint') }) it('warns on duplicate § anchors', () => { const raw = DOC_SRC_001.replace('### §2 —', '### §1 —') const p = parseSourceDoc(raw, 'SRC-001/source.md')! expect(p.warnings.some((w) => w.includes('duplicate section anchor §1'))).toBe(true) }) }) // --------------------------------------------------------------------------- // parseSourcesIndex // --------------------------------------------------------------------------- describe('parseSourcesIndex', () => { it('accepts the v1 contract', () => { const { index, errors } = parseSourcesIndex(indexWith({ 'SRC-001': entryFor('SRC-001') })) expect(errors).toEqual([]) expect(index!.nextSeq).toBe(2) }) it('is DATA on corruption, never a throw', () => { expect(parseSourcesIndex('{oops').index).toBeNull() expect(parseSourcesIndex('{"version":2,"nextSeq":1,"sources":{}}').index).toBeNull() expect(parseSourcesIndex('{"version":1,"nextSeq":0,"sources":{}}').errors.join(' ')).toContain('nextSeq') }) }) // --------------------------------------------------------------------------- // resolveCitation // --------------------------------------------------------------------------- describe('resolveCitation', () => { const index: SourcesIndex = { version: 1, nextSeq: 2, sources: { 'SRC-001': entryFor('SRC-001') } } const reg = registryOf({ 'SRC-001': DOC_SRC_001 }, index) const cite = (code: string, anchor: number | null) => ({ code, anchor, where: 'x', excerpt: '' }) it('resolves the four states', () => { expect(resolveCitation(reg, cite('SRC-001', null))).toBe('ok') expect(resolveCitation(reg, cite('SRC-001', 2))).toBe('ok') expect(resolveCitation(reg, cite('SRC-001', 9))).toBe('unknown-anchor') expect(resolveCitation(reg, cite('SRC-999', null))).toBe('unknown-code') const noReg: SourcesRegistry = { ...reg, exists: false, index: null } expect(resolveCitation(noReg, cite('SRC-001', null))).toBe('no-registry') }) it('an anchored citation on an UNPARSABLE doc is unknown-anchor, never assumed ok', () => { const broken = registryOf({ 'SRC-001': '# pas d anchor' }, index) expect(resolveCitation(broken, cite('SRC-001', 2))).toBe('unknown-anchor') // the code itself still exists in the index — code-only stays ok expect(resolveCitation(broken, cite('SRC-001', null))).toBe('ok') }) }) // --------------------------------------------------------------------------- // searchSources // --------------------------------------------------------------------------- describe('searchSources', () => { const index: SourcesIndex = { version: 1, nextSeq: 2, sources: { 'SRC-001': entryFor('SRC-001') } } it('folds case + accents and returns verbatim excerpts with § anchors', () => { const reg = registryOf({ 'SRC-001': DOC_SRC_001 }, index) const r = searchSources(reg, 'REMISE SUPERIEURE') expect(r.searchedDocs).toBe(1) expect(r.hits).toHaveLength(1) expect(r.hits[0]).toMatchObject({ code: 'SRC-001', anchor: 2 }) expect(r.hits[0]!.excerpt).toContain('remise supérieure à 20 %') }) it('filters by tags (doc + section tags, folded)', () => { const reg = registryOf({ 'SRC-001': DOC_SRC_001 }, index) expect(searchSources(reg, 'remise', { tags: ['REGLES'] }).hits).toHaveLength(1) expect(searchSources(reg, 'remise', { tags: ['rh'] }).searchedDocs).toBe(0) }) it('publishes its counts — 0 searchable never reads as « nothing matches »', () => { const reg = registryOf({ 'SRC-001': '# pas d anchor' }, index) const r = searchSources(reg, 'remise') expect(r.hits).toHaveLength(0) expect(r.searchedDocs).toBe(0) expect(r.unreadableDocs).toBe(1) }) it('a doc whose anchor status cannot be established is UNREADABLE, never served', () => { const badStatus = DOC_SRC_001.replace('status=ingested', 'status=weird') const reg = registryOf({ 'SRC-001': badStatus }, index) const r = searchSources(reg, 'remise') expect(r.hits).toHaveLength(0) expect(r.searchedDocs).toBe(0) expect(r.unreadableDocs).toBe(1) }) it('skips blocked sources (nothing ingested to hit)', () => { const blocked = DOC_SRC_001.replace('status=ingested', 'status=blocked/needs-export') const reg = registryOf({ 'SRC-001': blocked }, index) expect(searchSources(reg, 'remise').searchedDocs).toBe(0) }) }) // --------------------------------------------------------------------------- // fingerprintOfContent / sourceCodeOf // --------------------------------------------------------------------------- describe('fingerprintOfContent', () => { it('is EOL- and edge-stable for text, 12 hex chars', () => { const a = fingerprintOfContent('ligne 1\nligne 2\n') expect(a).toMatch(/^[0-9a-f]{12}$/) expect(fingerprintOfContent('ligne 1\r\nligne 2')).toBe(a) expect(fingerprintOfContent(' ligne 1\nligne 2 ')).toBe(a) expect(fingerprintOfContent('ligne 1\nligne 3')).not.toBe(a) }) it('hashes binary content as-is', () => { expect(fingerprintOfContent(Buffer.from([1, 2, 3]))).not.toBe(fingerprintOfContent(Buffer.from([1, 2, 4]))) }) }) describe('sourceCodeOf', () => { it('pads to 3 digits', () => { expect(sourceCodeOf(1)).toBe('SRC-001') expect(sourceCodeOf(42)).toBe('SRC-042') expect(sourceCodeOf(123)).toBe('SRC-123') }) }) // --------------------------------------------------------------------------- // loadSourcesRegistry — index ↔ disk reconciliation (real temp dir) // --------------------------------------------------------------------------- describe('loadSourcesRegistry', () => { const roots: string[] = [] const makeRoot = (): string => { const root = mkdtempSync(join(tmpdir(), 'ba-sources-')) roots.push(root) return root } afterEach(() => { for (const r of roots.splice(0)) rmSync(r, { recursive: true, force: true }) }) const writeSource = (root: string, code: string, md: string): void => { mkdirSync(join(root, code), { recursive: true }) writeFileSync(join(root, code, 'source.md'), md, 'utf8') } it('absent root → exists:false, ok — a legitimate « sans objet » state', () => { const reg = loadSourcesRegistry(join(tmpdir(), 'ba-sources-definitely-missing')) expect(reg.exists).toBe(false) expect(reg.reconciliation.status).toBe('ok') }) it('healthy registry reconciles ok with matching controls', () => { const root = makeRoot() writeFileSync(join(root, 'index.json'), indexWith({ 'SRC-001': entryFor('SRC-001') }), 'utf8') writeSource(root, 'SRC-001', DOC_SRC_001) const reg = loadSourcesRegistry(root) expect(reg.exists).toBe(true) expect(reg.reconciliation).toEqual({ status: 'ok', issues: [] }) expect(reg.controls).toEqual({ anchors: 1, parsed: 1 }) }) it('index entry without its doc on disk → suspect', () => { const root = makeRoot() writeFileSync(join(root, 'index.json'), indexWith({ 'SRC-001': entryFor('SRC-001') }), 'utf8') const reg = loadSourcesRegistry(root) expect(reg.reconciliation.status).toBe('suspect') expect(reg.reconciliation.issues.join(' ')).toContain('SRC-001') }) it('doc on disk missing from the index → suspect (orphan said out loud)', () => { const root = makeRoot() writeFileSync(join(root, 'index.json'), indexWith({}), 'utf8') writeSource(root, 'SRC-001', DOC_SRC_001) const reg = loadSourcesRegistry(root) expect(reg.reconciliation.issues.join(' ')).toContain('not in index.json') }) it('duplicate fingerprints and stale nextSeq → suspect', () => { const root = makeRoot() writeFileSync( join(root, 'index.json'), indexWith({ 'SRC-001': entryFor('SRC-001'), 'SRC-002': entryFor('SRC-002') }, 2), 'utf8', ) writeSource(root, 'SRC-001', DOC_SRC_001) writeSource(root, 'SRC-002', DOC_SRC_001.replace(/SRC-001/g, 'SRC-002')) const issues = loadSourcesRegistry(root).reconciliation.issues.join(' ') expect(issues).toContain('fingerprint a1b2c3d4e5f6 is shared') expect(issues).toContain('nextSeq 2') }) it('anchor-less doc counts in controls.anchors gap (mute parser cannot look green)', () => { const root = makeRoot() writeFileSync(join(root, 'index.json'), indexWith({ 'SRC-001': entryFor('SRC-001') }), 'utf8') writeSource(root, 'SRC-001', '# un fichier sans ancre') const reg = loadSourcesRegistry(root) expect(reg.controls).toEqual({ anchors: 0, parsed: 0 }) expect(reg.reconciliation.status).toBe('suspect') expect(reg.reconciliation.issues.join(' ')).toContain('anchor missing or unparsable') }) it('anchor status/fingerprint ≠ index entry → suspect (a lost half-write is SAID)', () => { const root = makeRoot() writeFileSync(join(root, 'index.json'), indexWith({ 'SRC-001': entryFor('SRC-001', { status: 'superseded' }) }), 'utf8') writeSource(root, 'SRC-001', DOC_SRC_001) // anchor still says ingested const issues = loadSourcesRegistry(root).reconciliation.issues.join(' ') expect(issues).toContain('anchor status ingested ≠ index status superseded') const root2 = makeRoot() writeFileSync(join(root2, 'index.json'), indexWith({ 'SRC-001': entryFor('SRC-001', { fingerprint: 'ffffffffffff' }) }), 'utf8') writeSource(root2, 'SRC-001', DOC_SRC_001) expect(loadSourcesRegistry(root2).reconciliation.issues.join(' ')).toContain('anchor fingerprint a1b2c3d4e5f6 ≠ index fingerprint ffffffffffff') }) it('hand-edited index with an out-of-contract status/kind → suspect', () => { const root = makeRoot() writeFileSync( join(root, 'index.json'), indexWith({ 'SRC-001': entryFor('SRC-001', { status: 'weird', kind: 'carrier-pigeon' }) }), 'utf8', ) writeSource(root, 'SRC-001', DOC_SRC_001) const issues = loadSourcesRegistry(root).reconciliation.issues.join(' ') expect(issues).toContain('status "weird"') expect(issues).toContain('kind "carrier-pigeon"') }) it('anchor code ≠ folder name → suspect', () => { const root = makeRoot() writeFileSync(join(root, 'index.json'), indexWith({ 'SRC-002': entryFor('SRC-002') }), 'utf8') writeSource(root, 'SRC-002', DOC_SRC_001) const issues = loadSourcesRegistry(root).reconciliation.issues.join(' ') expect(issues).toContain('SRC-001 ≠ folder SRC-002') }) })