/** * cli:sources-ingest — the FIRST ingestion, end to end (real spawn). * * `index.json` is only ever written by a successful ingestion, and hand-writing * it is forbidden (SKILL.md: « the CLI is the ONLY writer »). The registry * loader nonetheless read ANY existing `.smartstack/sources/` folder as a * registry whose index had gone missing, and the writer refused — so a project * whose folder was created before its first ingestion could never ingest * anything. That is every project the Studio creates: it drops the client's * brief under `_inbox/` at project creation (support report b367ead9b76b). * * The fail-closed half is pinned too: a folder that DOES hold sources without * an index, or an unreadable index, is still refused — and the refusal now says * which of the two it is. */ import { afterEach, describe, expect, it } from 'vitest' import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { runCli } from '../../../../../lib/__tests__/helpers/run-cli.js' const here = dirname(fileURLToPath(import.meta.url)) const INGEST = resolve(here, '../index.ts') const STATUS = resolve(here, '../../status/index.ts') const roots: string[] = [] afterEach(() => { for (const r of roots.splice(0)) rmSync(r, { recursive: true, force: true }) }) const BRIEF = [ '# Besoin — Gestion des rappels', '', '> Écrit à la création du projet.', '', '## En une phrase', 'Noter ce qu’il reste à faire et le retrouver au bon moment.', '', '## Dans le périmètre', '- Rappels personnels', '- Carnet de correspondants', '', ].join('\n') /** A project as the Studio leaves it: the BA tree, and the brief in the inbox. */ function project(): string { const root = mkdtempSync(join(tmpdir(), 'ss-first-ingest-')) roots.push(root) mkdirSync(join(root, '.smartstack', 'ba', 'GEST'), { recursive: true }) writeFileSync(join(root, '.smartstack', 'ba', 'GEST', 'index.md'), '# GEST\n', 'utf8') put(root, '.smartstack/sources/_inbox/besoin.md', BRIEF) return root } function put(root: string, rel: string, content: string): void { const abs = join(root, ...rel.split('/')) mkdirSync(dirname(abs), { recursive: true }) writeFileSync(abs, content, 'utf8') } const sources = (root: string, ...rel: string[]): string => join(root, '.smartstack', 'sources', ...rel) function spec(originPath: string, over: Record = {}): string { return JSON.stringify({ baRoot: '.smartstack/ba', mode: 'write', kind: 'file', origin: { path: originPath }, analysis: { title: 'Besoin — Gestion des rappels', summary: 'Note de cadrage écrite à la création du projet. Elle nomme le domaine, les rappels personnels, et un carnet de correspondants.', tags: ['processus', 'vocabulaire'], scopes: ['GEST'], sections: [{ title: 'Objet du projet', tags: ['processus'], body: '« Noter ce qu’il reste à faire et le retrouver au bon moment. »', verbatim: true, where: 'rubrique « En une phrase »' }], scopeNotes: ['Aucun acteur ni rôle décrit.'], }, ...over, }) } const ingest = (root: string, s: string) => runCli(INGEST, ['--spec', s, '--workdir', root], { cwd: root }) const status = (root: string) => runCli(STATUS, ['--spec', JSON.stringify({ baRoot: '.smartstack/ba' }), '--workdir', root], { cwd: root }) describe('first ingestion — a folder that holds no registry yet', () => { it('the Studio state (`_inbox/besoin.md`, no index.json) ingests: SRC-001 allocated, index.json CREATED', () => { const root = project() const r = ingest(root, spec('.smartstack/sources/_inbox/besoin.md')) expect(r.envelope?.errors ?? []).toEqual([]) expect(r.envelope?.success).toBe(true) expect(r.status).toBe(0) expect(r.envelope.data.code).toBe('SRC-001') expect(existsSync(sources(root, 'SRC-001', 'source.md'))).toBe(true) const index = JSON.parse(readFileSync(sources(root, 'index.json'), 'utf8')) expect(Object.keys(index.sources)).toEqual(['SRC-001']) expect(index.nextSeq).toBe(2) const created = (r.envelope.filesCreated as string[]).map((p) => p.replace(/\\/g, '/')) expect(created.some((p) => p.endsWith('.smartstack/sources/index.json'))).toBe(true) expect(created.some((p) => p.endsWith('SRC-001/source.md'))).toBe(true) // The deposit is a source of the ingestion, never its output. expect(readFileSync(sources(root, '_inbox', 'besoin.md'), 'utf8')).toBe(BRIEF) }) it('the registry it created is healthy, and the next ingestion extends it', () => { const root = project() expect(ingest(root, spec('.smartstack/sources/_inbox/besoin.md')).envelope?.success).toBe(true) put(root, 'docs/regles.md', '# Règles\n\nToute remise supérieure à 20 % requiert une validation.\n') const second = ingest(root, spec('docs/regles.md')) expect(second.envelope?.errors ?? []).toEqual([]) expect(second.envelope.data.code).toBe('SRC-002') const modified = (second.envelope.filesModified as string[]).map((p) => p.replace(/\\/g, '/')) expect(modified.some((p) => p.endsWith('.smartstack/sources/index.json'))).toBe(true) const st = status(root) expect(st.envelope.report.registryExists).toBe(true) expect(st.envelope.report.reconciliation).toEqual({ status: 'ok', issues: [] }) }) it('an EMPTY folder ingests too', () => { const root = mkdtempSync(join(tmpdir(), 'ss-first-ingest-')) roots.push(root) mkdirSync(join(root, '.smartstack', 'ba'), { recursive: true }) mkdirSync(join(root, '.smartstack', 'sources'), { recursive: true }) put(root, 'docs/besoin.md', BRIEF) const r = ingest(root, spec('docs/besoin.md')) expect(r.envelope?.success).toBe(true) expect(r.envelope.data.code).toBe('SRC-001') }) it('mode=plan on that folder previews without a registry warning, and writes nothing', () => { const root = project() const r = ingest(root, spec('.smartstack/sources/_inbox/besoin.md', { mode: 'plan', analysis: undefined })) expect(r.envelope?.success).toBe(true) expect(r.envelope.report.registryExists).toBe(false) expect(r.envelope.warnings).toEqual([]) expect(readdirSync(sources(root)).sort()).toEqual(['_inbox']) }) it('status says what the state IS — not « aucun registre », not « suspect »', () => { const root = project() const st = status(root) expect(st.envelope.report.registryExists).toBe(false) expect(st.envelope.report.reconciliation.status).toBe('ok') expect(st.envelope.nextSteps.join(' ')).toContain('registre non initialisé') expect(st.envelope.nextSteps.join(' ')).toContain('première ingestion') }) }) describe('fail-closed — a registry that exists and cannot be trusted is still refused', () => { it('source folders WITHOUT an index: refused, the message says MANQUE and names the way out', () => { const root = project() put(root, '.smartstack/sources/SRC-001/source.md', '\n# Une source\n') const r = ingest(root, spec('.smartstack/sources/_inbox/besoin.md')) expect(r.envelope?.success).toBe(false) expect(r.status).toBe(1) const msg = (r.envelope.errors as string[]).join('\n') expect(msg).toContain('index.json MANQUE') expect(msg).toContain('SRC-001') expect(msg).toContain('historique git') expect(msg).not.toContain('ILLISIBLE') expect(existsSync(sources(root, 'index.json'))).toBe(false) expect(existsSync(sources(root, 'SRC-002'))).toBe(false) }) it('an unreadable index: refused, the message says ILLISIBLE — and the file is left as found', () => { const root = project() put(root, '.smartstack/sources/index.json', '{ not json') const r = ingest(root, spec('.smartstack/sources/_inbox/besoin.md')) expect(r.envelope?.success).toBe(false) const msg = (r.envelope.errors as string[]).join('\n') expect(msg).toContain('index.json est ILLISIBLE') expect(msg).not.toContain('MANQUE') expect(readFileSync(sources(root, 'index.json'), 'utf8')).toBe('{ not json') expect(existsSync(sources(root, 'SRC-001'))).toBe(false) }) it('status calls the first one suspect', () => { const root = project() put(root, '.smartstack/sources/SRC-001/source.md', '\n# Une source\n') const st = status(root) expect(st.envelope.report.registryExists).toBe(true) expect(st.envelope.report.reconciliation.status).toBe('suspect') expect(st.envelope.report.reconciliation.issues.join(' ')).toContain('1 source folder(s) exist (SRC-001)') }) })