#!/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 5 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 { parseArgs } from 'node:util' import { guardedRm } from '../../../../lib/guarded-rm.js' import { findFiles } 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) { console.error('Error: --spec is required') process.exit(2) } let raw: unknown try { raw = JSON.parse(args.values.spec) } catch { console.error('Error: --spec must be valid JSON') process.exit(2) } const validation = validate(raw) if (!validation.valid) { emit(args.values.json, { success: false, error: validation.errors.join('; '), ucsScanned: 0, acsParsed: 0, factsEmitted: 0, filesCreated: [], warnings: 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.findFiles (minimatch-based) for Node 18+ compat. const absoluteMatches = await findFiles('**/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, ...(lost.length > 0 ? { error: `${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(args.values.json, result) process.exit(lost.length === 0 ? 0 : 1) } function emit(json: boolean | undefined, payload: ScaffoldTestsFromAcResult): void { if (json) { console.log(JSON.stringify(payload, null, 2)) return } if (!payload.success) { console.error(`✗ ${payload.error ?? 'failed'}`) console.error(` (files were still written for the heal loop: ${payload.filesCreated.length})`) for (const p of payload.filesCreated) console.error(` + ${p}`) return } console.log(`✓ scaffold-tests-from-ac:`) console.log(` UCs scanned : ${payload.ucsScanned}`) console.log(` ACs parsed : ${payload.acsParsed}`) console.log(` [Fact]s emitted: ${payload.factsEmitted}`) console.log(` Files created : ${payload.filesCreated.length}`) for (const p of payload.filesCreated) console.log(` + ${p}`) if (payload.warnings.length > 0) { console.log(` Warnings:`) for (const w of payload.warnings) console.log(` ! ${w}`) } } main().catch(err => { console.error('Fatal:', (err as Error).message) process.exit(2) }) // Ensure existsSync is used (placeholder for future fs.access) void existsSync