#!/usr/bin/env node /** * cli:derive-uc-coverage — entry point. * * THE UC → surface counter: reports, for every use case of the module, * which screens / pagespec actions / scheduled runtime serve it. Read-only; * drift is DATA (exit 0) — the verdicts belong to the audits: * /ba-audit-screens SCR-024 (BA leg), /ba-audit-prd PRD-131 (PRD leg), * /ba-audit-use-cases UC-022 (exception ↔ AC parity). * * Invocation: * npx --prefer-offline tsx skills/business-analyse/create-screen/cli/derive-uc-coverage/index.ts \ * --spec '{"baRoot":".smartstack/ba","app":"CRM","module":"PIPELINE"}' \ * [--workdir ] */ import { parseArgs } from 'node:util' import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs' import { join, relative } from 'node:path' import { executeEnvelope, failExecute, printEnvelope, } from '../../../../lib/output.js' import { parseAcFromUseCaseMd } from '../../../../development/testing/cli/scaffold-tests-from-ac/parse-ac.js' import type { UcWithAc } from '../../../../development/testing/cli/scaffold-tests-from-ac/types.js' import { validateSpec } from './validate.js' import { deriveUcCoverage, type PagespecSource, type ScreenSource } from './execute.js' import type { DeriveUcCoverageReport } from './types.js' const COMMAND = 'derive-uc-coverage' /** Collect every under the module tree (sections + resources; `_audit`, * `_plan`, `pagespecs` and other non-node folders are skipped). */ function collectDocs(moduleDir: string, fileName: string): { relPath: string; md: string }[] { const out: { relPath: string; md: string }[] = [] const walk = (dir: string, depth: number): void => { if (depth > 3) return const direct = join(dir, fileName) try { if (existsSync(direct) && statSync(direct).isFile()) { out.push({ relPath: relative(moduleDir, direct).replace(/\\/g, '/'), md: readFileSync(direct, 'utf8') }) } for (const e of readdirSync(dir, { withFileTypes: true })) { // Visit every node folder — the old `/^[a-z]/` filter silently skipped // uppercase/digit/accented folder names, leaving their UCs unparsed // and SCR-024/PRD-131 green on nothing (fail-open). if ( e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.') && e.name !== 'pagespecs' && e.name !== 'node_modules' ) walk(join(dir, e.name), depth + 1) } } catch { /* unreadable folder — the report simply carries fewer sources */ } } walk(moduleDir, 0) return out.sort((a, b) => a.relPath.localeCompare(b.relPath)) } function main(): void { const { values } = parseArgs({ options: { spec: { type: 'string' }, workdir: { type: 'string' }, }, strict: true, }) if (!values.spec) { printEnvelope(failExecute(COMMAND, ['--spec is required'])) process.exit(1) } let raw: unknown try { raw = JSON.parse(values.spec) } catch { printEnvelope(failExecute(COMMAND, ['Invalid JSON in --spec'])) process.exit(1) } const validation = validateSpec(raw, values.workdir) if (!validation.valid || !validation.spec || !validation.resolvedBaRoot) { printEnvelope(failExecute(COMMAND, validation.errors)) process.exit(1) } const spec = validation.spec const moduleDir = join(validation.resolvedBaRoot, spec.app, spec.module) const ucs: UcWithAc[] = [] const ucWarnings: string[] = [] for (const doc of collectDocs(moduleDir, 'use-case.md')) { const parsed = parseAcFromUseCaseMd(doc.md, doc.relPath) ucs.push(...parsed.ucs) ucWarnings.push(...parsed.warnings) } const screens: ScreenSource[] = collectDocs(moduleDir, 'screen.md') const pagespecsDir = join(moduleDir, 'pagespecs') let pagespecs: PagespecSource[] | null = null if (existsSync(pagespecsDir)) { pagespecs = [] for (const name of readdirSync(pagespecsDir).filter((n) => n.endsWith('.md')).sort()) { const full = join(pagespecsDir, name) try { if (!statSync(full).isFile()) continue pagespecs.push({ name, md: readFileSync(full, 'utf8') }) } catch { ucWarnings.push(`${name}: unreadable — skipped.`) } } } const report = deriveUcCoverage({ app: spec.app, module: spec.module, ucs, ucWarnings, screens, pagespecs, }) // `upToDate` is HONEST about judgeability: without a pagespecs/ dir the PRD // leg has uncoveredPrd === 0 by construction — claiming "up to date" while // simultaneously warning "PRD leg not judgeable" was a self-contradicting // envelope (conformity-audit finding). No-pagespecs → never upToDate. const clean = report.totals.uncoveredBa === 0 && report.totals.uncoveredPrd === 0 && report.totals.exceptionFindings === 0 && report.pagespecsPresent const nextSteps: string[] = [] if (report.totals.uncoveredBa > 0) { nextSteps.push( `${report.totals.uncoveredBa} user-goal UC(s) with NO screen surface — link them (screen.md ` + '`- **Cas d\'usage liés** :` or an action `UC: UC-…`), mark the UC `scheduled`, or declare its ' + 'level (`subfunction`/`summary`) — /ba-audit-screens SCR-024 errs on these.', ) } if (report.totals.uncoveredPrd > 0) { nextSteps.push( `${report.totals.uncoveredPrd} user-goal UC(s) absent from every pagespec (linkedUseCases[] / ` + 'actions[].ucReference) — re-run /ba-create-prd or author the link; /ba-audit-prd PRD-131 errs on these.', ) } if (report.totals.exceptionFindings > 0) { nextSteps.push( `${report.totals.exceptionFindings} UC(s) with exception flows short of ACs — write one AC per ` + 'EXC-N (cite the id or assert its 4xx/error outcome); /ba-audit-use-cases UC-022 warns on these.', ) } if (!report.pagespecsPresent) { nextSteps.push( report.totals.uncoveredBa === 0 && report.totals.exceptionFindings === 0 ? 'BA leg clean — but no pagespecs/ dir: the PRD leg is not judgeable yet (run /ba-create-prd, then re-run).' : 'No pagespecs/ dir — the PRD leg is not judgeable yet (run /ba-create-prd, then re-run).', ) } if (clean) { nextSteps.push('Every user-goal UC is served by a surface — nothing to do.') } nextSteps.push( 'Drift is DATA (exit 0) — verdicts belong to /ba-audit-screens (SCR-024), /ba-audit-prd (PRD-131) and /ba-audit-use-cases (UC-022).', ) printEnvelope( executeEnvelope(COMMAND, { success: true, data: { ...report.totals, pagespecsPresent: report.pagespecsPresent, upToDate: clean }, report, warnings: report.warnings, nextSteps, }), ) process.exit(0) } main()