#!/usr/bin/env node /** * cli:scaffold-tests-from-ac — index.ts * * Reads `use-case.md` files under a BA module folder, emits one xUnit [Fact] * skeleton per Acceptance Criterion (AC). Phase 4 of /ba-develop fills the * TODO bodies; DEV-TEST-001 (Wave 3) audits coverage. * * Invocation: * npx --prefer-offline tsx scaffold-tests-from-ac/index.ts \ * --spec '{"moduleDir":"…","projectPath":"…","appCode":"crm","module":"pipeline"}' \ * [--json] */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' import { parseArgsOrFail as parseArgs } from '../../../../lib/cli-args.js' import { guardedRm } from '../../../../lib/guarded-rm.js' import { findBaDocs } from '../../../../lib/fs.js' import { generate, legacyPaths } from './generate.js' import { parseAcFromUseCaseMd } from './parse-ac.js' import type { ScaffoldTestsFromAcResult, UcWithAc } from './types.js' import { validate } from './validate.js' async function main(): Promise { const args = parseArgs({ options: { spec: { type: 'string' }, json: { type: 'boolean', default: false }, }, allowPositionals: false, }) if (!args.values.spec) { emit(fail(['--spec is required'])) process.exit(2) } let raw: unknown try { raw = JSON.parse(args.values.spec) } catch { emit(fail(['--spec must be valid JSON'])) process.exit(2) } const validation = validate(raw) if (!validation.valid) { emit(fail(validation.errors, validation.warnings)) process.exit(2) } const spec = validation.data! const moduleDir = resolve(spec.moduleDir) const projectPath = resolve(spec.projectPath) // Find every use-case.md under /
/... // Uses lib/fs.findBaDocs (minimatch-based, Node 18+ compat) — never the // audit verdict `_audit/use-case.md`, which shares the basename. const absoluteMatches = await findBaDocs('**/use-case.md', { cwd: moduleDir }) const allUcs: UcWithAc[] = [] const warnings: string[] = [...validation.warnings] const lost: string[] = [] for (const absolute of absoluteMatches) { const rel = relative(moduleDir, absolute).replace(/\\/g, '/') let content: string try { content = readFileSync(absolute, 'utf8') } catch (err) { warnings.push(`Cannot read ${rel}: ${(err as Error).message}`) continue } const result = parseAcFromUseCaseMd(content, rel) warnings.push(...result.warnings) lost.push(...result.lost) allUcs.push(...result.ucs) } const { files, ucsScanned, acsParsed, factsEmitted } = generate({ spec, ucs: allUcs }) const filesCreated: string[] = [] if (!spec.dryRun) { // Idempotent relocate: delete the old per-module Acceptance/ copies first so // re-running MOVES the section's acceptance tests into //
/. // Guarded sweep (lib/guarded-rm): @customised files preserved (warned below). const legacySweep = guardedRm(legacyPaths(spec, files), { outdir: projectPath }) warnings.push(...legacySweep.preserved.map(p => `legacy path ${p} kept: marked @customised — delete manually if truly superseded.`)) for (const f of files) { const absolute = join(projectPath, f.path) mkdirSync(dirname(absolute), { recursive: true }) writeFileSync(absolute, f.content, 'utf8') filesCreated.push(relative(projectPath, absolute).replace(/\\/g, '/')) } } else { for (const f of files) filesCreated.push(f.path) } // A LOST AC (malformed/duplicate bullet) fails the run — the files ARE // written (the survivors' facts stay useful for the heal loop), but the // envelope says false and the exit code is non-zero so the Phase 4 gate // re-enters instead of shipping a contract that silently dropped an // assertion. This used to be `success: true` unconditionally — the gate // could not see the loss (audit T4). const result: ScaffoldTestsFromAcResult = { success: lost.length === 0, errors: lost.length > 0 ? [`${lost.length} acceptance criterion/criteria LOST during parse — fix the use-case.md bullet(s) and re-run: ${lost.join(' | ')}`] : [], ucsScanned, acsParsed, factsEmitted, filesCreated, warnings, } emit(result) process.exit(lost.length === 0 ? 0 : 1) } /** * JSON on stdout, ALWAYS — the Phase 4 gate reads `success` from it. The human * summary used to be the default and no gate passed --json (audit 2026-09-24, * D-WARN-07); the flag is still accepted, without effect. */ function emit(payload: ScaffoldTestsFromAcResult): void { console.log(JSON.stringify(payload, null, 2)) } function fail(errors: string[], warnings: string[] = []): ScaffoldTestsFromAcResult { return { success: false, errors, ucsScanned: 0, acsParsed: 0, factsEmitted: 0, filesCreated: [], warnings } } main().catch(err => { emit(fail([`Unexpected error: ${(err as Error).message}`])) process.exit(2) }) // Ensure existsSync is used (placeholder for future fs.access) void existsSync