#!/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()