/** * support-report/cli/create — the report writer. * * Pins the two contracts the client-site flow depends on: FAIL-CLOSED (a * usage-error / unverified / flaky / environment failure writes NOTHING), and * one-defect-one-folder (a re-invocation with the same fingerprint bumps * occurrences instead of spawning a second report). Plus the version gate: * a newer npm version stamps the report pending-retest-after-update, offline * degrades to confirmed + warning. All seams injected via deps — no network, * no real ~/.claude. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import JSZip from 'jszip' import { run, type SupportDeps, type SupportReportSpec } from '../run.js' let root: string let projectPath: string let claudeDir: string let skillsRoot: string const NOW = new Date('2026-09-01T10:00:00.000Z') beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'ss-support-')) projectPath = path.join(root, 'client-project') claudeDir = path.join(root, '.claude') skillsRoot = path.join(claudeDir, 'skills') fs.mkdirSync(projectPath, { recursive: true }) fs.mkdirSync(skillsRoot, { recursive: true }) fs.writeFileSync( path.join(claudeDir, '.smartstack-manifest.json'), JSON.stringify({ version: '5.17.0', installedAt: '2026-08-01', updatedAt: '2026-08-01', files: [] }), ) }) afterEach(() => { fs.rmSync(root, { recursive: true, force: true }) }) const deps = (over: Partial = {}): SupportDeps => ({ fetchLatestVersion: async () => '5.17.0', claudeDir, skillsRoot, now: () => NOW, ...over, }) const crashStderr = () => [ `TypeError: Cannot read properties of undefined (reading 'columns')`, ` at buildTable (${path.join(skillsRoot, 'development', 'frontend', 'scaffold-component', 'cli', 'scaffold-component', 'render', 'list.ts')}:214:18)`, ].join('\n') const spec = (over: Partial = {}): SupportReportSpec => ({ projectPath, command: 'npx --prefer-offline tsx skills/development/frontend/scaffold-component/cli/scaffold-component/index.ts --spec-file C:\\Temp\\s1.json', runs: [{ exitCode: 1, stdout: '', stderr: crashStderr() }], context: 'Phase 3 ba-develop, entity Facture', ...over, }) const supportDir = () => path.join(projectPath, '.smartstack', 'support') describe('run — fail-closed refusals (nothing written)', () => { it('refuses a controlled usage-error and relays the envelope errors', async () => { const stdout = JSON.stringify({ success: false, command: 'x', errors: ['spec.view is required'], warnings: [], nextSteps: [] }) const envelope = await run(spec({ runs: [{ exitCode: 1, stdout, stderr: '' }] }), deps()) expect(envelope.success).toBe(false) expect(envelope.report?.failureClass).toBe('usage-error') expect(envelope.errors.join('\n')).toContain('spec.view is required') expect(fs.existsSync(supportDir())).toBe(false) }) it('refuses a single run without a stack trace (unverified)', async () => { const envelope = await run(spec({ runs: [{ exitCode: 1, stdout: '', stderr: 'boom, no trace' }] }), deps()) expect(envelope.success).toBe(false) expect(envelope.report?.failureClass).toBe('unverified') expect(envelope.nextSteps.join(' ')).toMatch(/re-run/i) expect(fs.existsSync(supportDir())).toBe(false) }) it('refuses diverging runs (flaky)', async () => { const envelope = await run( spec({ runs: [ { exitCode: 1, stdout: '', stderr: 'FATAL alpha' }, { exitCode: 1, stdout: '', stderr: 'FATAL omega, entirely different' }, ], }), deps(), ) expect(envelope.success).toBe(false) expect(envelope.report?.failureClass).toBe('flaky') expect(fs.existsSync(supportDir())).toBe(false) }) it('refuses an environment failure with guidance', async () => { const envelope = await run( spec({ runs: [{ exitCode: 1, stdout: '', stderr: `'npx' is not recognized as an internal or external command` }] }), deps(), ) expect(envelope.success).toBe(false) expect(envelope.report?.failureClass).toBe('environment') expect(fs.existsSync(supportDir())).toBe(false) }) it('refuses a missing projectPath', async () => { const envelope = await run(spec({ projectPath: path.join(root, 'nope') }), deps()) expect(envelope.success).toBe(false) expect(envelope.errors[0]).toContain('projectPath') }) }) describe('run — a proven defect writes the full bundle', () => { it('cli-internal → report.md + report.json + runs/, confirmed on latest', async () => { const envelope = await run(spec(), deps()) expect(envelope.success).toBe(true) expect(envelope.report?.status).toBe('confirmed') expect(envelope.report?.failureClass).toBe('cli-internal') const dir = envelope.report!.reportDir! const md = fs.readFileSync(path.join(dir, 'report.md'), 'utf-8') expect(md).toContain('support@atlashub.ch') expect(md).toContain('| CLI SmartStack installée | 5.17.0 |') expect(md).toContain('Phase 3 ba-develop, entity Facture') expect(fs.existsSync(path.join(dir, 'runs', 'run-1.stderr.txt'))).toBe(true) const record = JSON.parse(fs.readFileSync(path.join(dir, 'report.json'), 'utf-8')) expect(record.fingerprint).toMatch(/^[0-9a-f]{12}$/) expect(record.occurrences).toBe(1) expect(record.versions.node).toBe(process.version) expect(envelope.nextSteps.join(' ')).toContain('support@atlashub.ch') }) it('same defect re-reported → occurrences bump, no second folder', async () => { const first = await run(spec(), deps()) const second = await run( // Different --spec-file temp path: must still land on the same fingerprint. spec({ command: spec().command.replace('s1.json', 's2.json') }), deps(), ) expect(second.report?.duplicate).toBe(true) expect(second.report?.occurrences).toBe(2) expect(second.report?.reportDir).toBe(first.report?.reportDir) const folders = fs.readdirSync(supportDir()).filter((f) => f !== 'index.json') expect(folders).toHaveLength(1) const index = JSON.parse(fs.readFileSync(path.join(supportDir(), 'index.json'), 'utf-8')) expect(index[first.report!.fingerprint!].occurrences).toBe(2) }) it('newer npm version → pending-retest-after-update + update nextSteps', async () => { const envelope = await run(spec(), deps({ fetchLatestVersion: async () => '5.18.0' })) expect(envelope.success).toBe(true) expect(envelope.report?.status).toBe('pending-retest-after-update') expect(envelope.nextSteps.join(' ')).toContain('npm i -g @atlashub/smartstack-cli@latest') expect(envelope.nextSteps.join(' ')).toMatch(/wait for their approval/i) const md = fs.readFileSync(path.join(envelope.report!.reportDir!, 'report.md'), 'utf-8') expect(md).toContain('RETESTER avant de transmettre') }) it('offline registry → confirmed + unreachable + warning', async () => { const envelope = await run(spec(), deps({ fetchLatestVersion: async () => null })) expect(envelope.success).toBe(true) expect(envelope.report?.status).toBe('confirmed') expect(envelope.report?.versions?.cliLatest).toBe('unreachable') expect(envelope.warnings.join(' ')).toContain('unreachable') }) it('a disputed controlled refusal files a disputed-usage-error report', async () => { const stdout = JSON.stringify({ success: false, command: 'x', errors: ['entity Facture not found in entité.md'], warnings: [], nextSteps: [] }) const envelope = await run( spec({ runs: [{ exitCode: 1, stdout, stderr: '' }], dispute: 'entité.md DOES declare Facture at line 12 with the exact heading the parser contract requires.', }), deps(), ) expect(envelope.success).toBe(true) expect(envelope.report?.failureClass).toBe('disputed-usage-error') const record = JSON.parse(fs.readFileSync(path.join(envelope.report!.reportDir!, 'report.json'), 'utf-8')) expect(record.envelopeErrors).toEqual(['entity Facture not found in entité.md']) }) it('secrets are scrubbed from every written file', async () => { const dirty = `${crashStderr()}\nconnection: Server=db;Password=Sup3rS3cret!;` const envelope = await run( spec({ runs: [{ exitCode: 1, stdout: '', stderr: dirty }], spec: { connectionString: 'Server=db;Password=Sup3rS3cret!;' }, }), deps(), ) const dir = envelope.report!.reportDir! for (const file of ['report.md', 'report.json', path.join('runs', 'run-1.stderr.txt')]) { expect(fs.readFileSync(path.join(dir, file), 'utf-8')).not.toContain('Sup3rS3cret!') } }) it('reads the installed CLI version from the manifest, socle from the csproj', async () => { fs.mkdirSync(path.join(projectPath, 'src', 'Acme.Api'), { recursive: true }) fs.writeFileSync( path.join(projectPath, 'src', 'Acme.Api', 'Acme.Api.csproj'), ``, ) const envelope = await run(spec(), deps()) expect(envelope.report?.versions?.cliInstalled).toBe('5.17.0') expect(envelope.report?.versions?.socle).toBe('3.66.0') }) it('every report ships its emailable zip + sha256 sidecar, report.md inside', async () => { const envelope = await run(spec(), deps()) const dir = envelope.report!.reportDir! const zipPath = envelope.report!.bundle!.zipPath expect(zipPath).toBe(path.join(dir, `support-${envelope.report!.fingerprint}.zip`)) const buf = fs.readFileSync(zipPath) expect(fs.readFileSync(`${zipPath}.sha256`, 'utf-8')).toContain(envelope.report!.bundle!.sha256) const zip = await JSZip.loadAsync(buf) const names = Object.keys(zip.files) expect(names).toContain(`support-${envelope.report!.fingerprint}/report.md`) expect(names).toContain(`support-${envelope.report!.fingerprint}/repro.md`) expect(names).toContain(`support-${envelope.report!.fingerprint}/runs/run-1.stderr.txt`) expect(names.some((n) => n.includes('/inputs/'))).toBe(false) expect(envelope.nextSteps.join(' ')).toContain(`support-${envelope.report!.fingerprint}.zip`) // The archive cannot describe itself: the copies INSIDE it omit the size, // the record on disk carries it — never a misleading "0 octets". const inside = JSON.parse(await zip.file(`support-${envelope.report!.fingerprint}/report.json`)!.async('string')) expect(inside.zip).toEqual({ file: `support-${envelope.report!.fingerprint}.zip` }) expect(await zip.file(`support-${envelope.report!.fingerprint}/report.md`)!.async('string')).not.toContain('0 octets') const onDisk = JSON.parse(fs.readFileSync(path.join(dir, 'report.json'), 'utf-8')) expect(onDisk.zip.bytes).toBe(buf.length) expect(fs.readFileSync(path.join(dir, 'report.md'), 'utf-8')).toContain(`(${buf.length} octets — `) }) }) // --------------------------------------------------------------------------- // rule-contradiction + verdict dispute — the channel the XD-005 incident lacked // --------------------------------------------------------------------------- const HUB_EVIDENCE = 'SCR-FLOTTE-PARC-VEHICULES-001 → — (agrégation multi-entités par les widgets)' function auditStdout(module: string, contradict = true): string { const scope = { app: 'FLOTTE', module } const findings = [ { ruleId: 'SCR-003', dimension: 'screens', severity: 'ok', scope, message: 'Toutes les références entité des écrans résolvent.' }, contradict ? { ruleId: 'XD-005', dimension: 'cross-dimension', severity: 'err', scope, message: 'Écran(s) liant une entité absente du MCD (miroir de SCR-003).', evidence: [HUB_EVIDENCE], dedupOf: 'SCR-003' } : { ruleId: 'XD-005', dimension: 'cross-dimension', severity: 'ok', scope, message: 'Toutes les liaisons écran → entité résolvent.' }, { ruleId: 'SCR-022', dimension: 'screens', severity: 'ok', scope, message: 'Chaque dashboard vit sur une entité (ou aucun dashboard).' }, ] return JSON.stringify({ success: true, command: 'audit-ba', report: { findings }, errors: [], warnings: [], nextSteps: [], data: { exitClass: contradict ? 'err' : 'ok' } }) } const AUDIT_COMMAND = `npx --prefer-offline tsx skills/ba-audit-run/cli/audit-ba/index.ts --spec '{"baRoot":".smartstack/ba","scope":{"app":"FLOTTE"},"dimensions":["screens","cross-dimension"],"dryRun":true}'` const auditSpec = (over: Partial = {}): SupportReportSpec => spec({ command: AUDIT_COMMAND, runs: [ { exitCode: 2, stdout: auditStdout('PARC'), stderr: '' }, { exitCode: 2, stdout: auditStdout('PARC'), stderr: '' }, ], context: 'audit FLOTTE, gate pre-dev', ...over, }) function writeBaCorpus(): void { const w = (rel: string, body: string): void => { const abs = path.join(projectPath, ...rel.split('/')) fs.mkdirSync(path.dirname(abs), { recursive: true }) fs.writeFileSync(abs, body) } w('.smartstack/ba/FLOTTE/index.md', '# FLOTTE\n') w('.smartstack/ba/FLOTTE/PARC/entité.md', '### ENT-001 — Vehicle (agrégat racine)\n- **Préfixe table** : `veh_`\n') w('.smartstack/ba/FLOTTE/PARC/vehicules/screen.md', '### SCR-FLOTTE-PARC-VEHICULES-001 — Accueil (SmartModuleHome)\n- **Entité** : — (agrégation multi-entités par les widgets)\n') w('.smartstack/ba/FLOTTE/PARC/_audit/screen.md', '# verdict\nconnection: Password=Sup3rS3cret!;\n') w('.smartstack/support/00000000dead/report.md', '# an older report — never re-bundled\n') w('src/Acme.Api/appsettings.Local.json', '{"ConnectionStrings":{"Default":"Password=Sup3rS3cret!"}}\n') } describe('run — rule-contradiction (mechanical, from the envelope)', () => { it('SCR-003 ok + XD-005 err (dedupOf) on the same module → reported, exit-2 verdict or not', async () => { const envelope = await run(auditSpec(), deps()) expect(envelope.success).toBe(true) expect(envelope.report?.failureClass).toBe('rule-contradiction') expect(envelope.report?.evidence).toBe('dedup-contradiction') expect(envelope.report?.contradictions).toHaveLength(1) const dir = envelope.report!.reportDir! const md = fs.readFileSync(path.join(dir, 'report.md'), 'utf-8') expect(md).toContain('## Contradiction de règles') expect(md).toContain('| `XD-005` | err | `SCR-003` (ok) | FLOTTE / PARC |') expect(md).toContain(HUB_EVIDENCE) const record = JSON.parse(fs.readFileSync(path.join(dir, 'report.json'), 'utf-8')) expect(record.classification).toBe('rule-contradiction') expect(envelope.nextSteps.join(' ')).toContain('audit.rule-contradiction') const index = JSON.parse(fs.readFileSync(path.join(supportDir(), 'index.json'), 'utf-8')) expect(Object.keys(index)).toEqual([envelope.report!.fingerprint]) }) it('the same pair on another module (narrower spec) → same folder, occurrences 2, scopes MERGED', async () => { const first = await run(auditSpec(), deps()) const second = await run( auditSpec({ command: AUDIT_COMMAND.replace('"dimensions":["screens","cross-dimension"],', ''), runs: [{ exitCode: 2, stdout: auditStdout('ENERGIE'), stderr: '' }], }), deps(), ) expect(second.report?.reportDir).toBe(first.report?.reportDir) expect(second.report?.occurrences).toBe(2) expect(second.report?.contradictions?.map((c) => c.scope.module)).toEqual(['ENERGIE', 'PARC']) expect(fs.readdirSync(supportDir()).filter((f) => f !== 'index.json')).toHaveLength(1) }) it('a coherent verdict (XD-005 ok too) stays refused as no-failure', async () => { const envelope = await run(auditSpec({ runs: [{ exitCode: 0, stdout: auditStdout('PARC', false), stderr: '' }] }), deps()) expect(envelope.success).toBe(false) expect(envelope.report?.failureClass).toBe('no-failure') expect(envelope.nextSteps.join(' ')).toContain('"disputedRuleIds"') expect(fs.existsSync(supportDir())).toBe(false) }) }) describe('run — disputed-verdict (argued, anchored on rules the envelope carries)', () => { const ARGUED = 'SCR-022 demands an entity on every SmartDashboard, while create-screen/levels/dashboard-screens.md line 29 states it is the only screen type that may omit the screen-level entity.' const coherent = (over: Partial = {}): SupportReportSpec => auditSpec({ runs: [{ exitCode: 0, stdout: auditStdout('PARC', false), stderr: '' }], ...over }) it('a dispute WITHOUT disputedRuleIds is refused with the reason, nothing written', async () => { const envelope = await run(coherent({ dispute: ARGUED }), deps()) expect(envelope.success).toBe(false) expect(envelope.errors.join('\n')).toContain('"disputedRuleIds" must name') expect(fs.existsSync(supportDir())).toBe(false) }) it('a rule absent from the envelope is refused, listing the rules seen', async () => { const envelope = await run(coherent({ dispute: ARGUED, disputedRuleIds: ['XD-999'] }), deps()) expect(envelope.success).toBe(false) expect(envelope.errors.join('\n')).toContain('XD-999 is not in the envelope findings (seen: SCR-003, SCR-022, XD-005)') expect(fs.existsSync(supportDir())).toBe(false) }) it('a short dispute is refused even with valid rules — a verdict dispute must argue', async () => { const envelope = await run(coherent({ dispute: 'SCR-022 is just wrong, trust me.', disputedRuleIds: ['SCR-022'] }), deps()) expect(envelope.success).toBe(false) expect(envelope.errors.join('\n')).toContain('≥ 80 chars') }) it('argued + present rules → disputed-verdict report with the contested rules', async () => { const envelope = await run(coherent({ dispute: ARGUED, disputedRuleIds: ['SCR-022'] }), deps()) expect(envelope.success).toBe(true) expect(envelope.report?.failureClass).toBe('disputed-verdict') expect(envelope.report?.evidence).toBe('argued-verdict-dispute') const record = JSON.parse(fs.readFileSync(path.join(envelope.report!.reportDir!, 'report.json'), 'utf-8')) expect(record.disputedRuleIds).toEqual(['SCR-022']) const md = fs.readFileSync(path.join(envelope.report!.reportDir!, 'report.md'), 'utf-8') expect(md).toContain('- **Règles contestées** : SCR-022') expect(md).toContain('- **Contestation du verdict** :') }) it('a dispute never unlocks the crash path: unverified stays refused', async () => { const envelope = await run(spec({ runs: [{ exitCode: 1, stdout: '', stderr: 'boom, no trace' }], dispute: ARGUED, disputedRuleIds: ['SCR-022'] }), deps()) expect(envelope.success).toBe(false) expect(envelope.report?.failureClass).toBe('unverified') }) }) describe('run — inputs bundle (what the CLI read, scrubbed, zipped)', () => { it('inputs → inputs/ scrubbed + manifest + repro.md, all inside the zip, hash matches the sidecar', async () => { writeBaCorpus() const envelope = await run(auditSpec({ inputs: ['.smartstack/ba'] }), deps()) expect(envelope.success).toBe(true) const dir = envelope.report!.reportDir! const fp = envelope.report!.fingerprint! expect(envelope.data?.inputsBundled).toBe(4) expect(envelope.report?.bundle).toMatchObject({ files: 4, overCap: false }) // On disk: the tree, scrubbed. const verdict = fs.readFileSync(path.join(dir, 'inputs', '.smartstack', 'ba', 'FLOTTE', 'PARC', '_audit', 'screen.md'), 'utf-8') expect(verdict).toContain('Password=') expect(verdict).not.toContain('Sup3rS3cret!') const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'inputs.manifest.json'), 'utf-8')) expect(manifest.files.map((f: { relPath: string }) => f.relPath)).toContain('.smartstack/ba/FLOTTE/PARC/entité.md') expect(fs.readFileSync(path.join(dir, 'repro.md'), 'utf-8')).toContain('inputs/') // In the zip: report + repro + runs + inputs, accented name intact, nothing from .smartstack/support. const zipPath = path.join(dir, `support-${fp}.zip`) const buf = fs.readFileSync(zipPath) expect(fs.readFileSync(`${zipPath}.sha256`, 'utf-8')).toContain(envelope.report!.bundle!.sha256) const zip = await JSZip.loadAsync(buf) const names = Object.keys(zip.files) expect(names).toContain(`support-${fp}/report.md`) expect(names).toContain(`support-${fp}/inputs/.smartstack/ba/FLOTTE/PARC/entité.md`) expect(names).toContain(`support-${fp}/inputs.manifest.json`) expect(names.some((n) => n.includes('.smartstack/support/'))).toBe(false) expect(names.some((n) => n.includes('appsettings.Local.json'))).toBe(false) expect(await zip.file(`support-${fp}/inputs/.smartstack/ba/FLOTTE/PARC/_audit/screen.md`)!.async('string')).not.toContain('Sup3rS3cret!') // report.md names the zip. expect(fs.readFileSync(path.join(dir, 'report.md'), 'utf-8')).toContain(`support-${fp}.zip`) expect(fs.readFileSync(path.join(dir, 'report.md'), 'utf-8')).toContain('## Entrées jointes (reproduction)') }) it('an input escaping the project is refused whole — nothing written', async () => { fs.writeFileSync(path.join(root, 'outside.md'), 'secret') const envelope = await run(auditSpec({ inputs: ['../outside.md'] }), deps()) expect(envelope.success).toBe(false) expect(envelope.errors.join('\n')).toContain('inputs rejected') expect(fs.existsSync(supportDir())).toBe(false) }) it('over the cap → report + zip written WITHOUT inputs, warning + nextStep say to narrow inputs[]', async () => { writeBaCorpus() const envelope = await run(auditSpec({ inputs: ['.smartstack/ba'] }), deps({ bundleTotalCapBytes: 40 })) expect(envelope.success).toBe(true) expect(envelope.report?.bundle).toMatchObject({ files: 0, overCap: true }) expect(envelope.warnings.join(' ')).toContain('NOTHING was attached') expect(envelope.nextSteps.join(' ')).toContain('narrower inputs[]') const dir = envelope.report!.reportDir! expect(fs.existsSync(path.join(dir, 'inputs'))).toBe(false) const zip = await JSZip.loadAsync(fs.readFileSync(path.join(dir, `support-${envelope.report!.fingerprint}.zip`))) expect(Object.keys(zip.files).some((n) => n.includes('/inputs/'))).toBe(false) expect(fs.readFileSync(path.join(dir, 'report.md'), 'utf-8')).toContain('Plafond dépassé') }) it('a re-invocation refreshes the inputs snapshot — a file gone from the project is gone from inputs/', async () => { writeBaCorpus() const first = await run(auditSpec({ inputs: ['.smartstack/ba'] }), deps()) fs.rmSync(path.join(projectPath, '.smartstack', 'ba', 'FLOTTE', 'PARC', '_audit'), { recursive: true }) const second = await run(auditSpec({ inputs: ['.smartstack/ba'] }), deps()) expect(second.report?.reportDir).toBe(first.report?.reportDir) expect(second.report?.occurrences).toBe(2) expect(fs.existsSync(path.join(second.report!.reportDir!, 'inputs', '.smartstack', 'ba', 'FLOTTE', 'PARC', '_audit'))).toBe(false) expect(second.data?.inputsBundled).toBe(3) }) })