/** * cli:derive-referential-codes — sources.ts * * The IO half of the citation inventory: it FINDS the four sources on disk and * hands them to the pure core. * * It lives apart from `index.ts` for one reason: `index.ts` calls `main()` at * import time, so anything defined there is untestable. And the source that * MUST be tested is the third one — `prd*.md` + `pagespecs/`. On a real module * the retirement and suspension reasons were cited by no rule and no constant, * yet the API designated them by `ReasonCode`; an inventory blind to the PRD * under-declares, and the backfill then drops a code the contract depends on. * « The backfill takes more than N entities » is exactly the symptom of this * source being unplugged, so it is asserted end to end. */ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { join } from 'node:path' import { loadModuleRules } from '../../../../lib/ba-rules-rows.js' import { parseUseCases } from '../../../../lib/ba-use-cases.js' import { acceptanceCitationSources, prdCitationSources, ruleCitationSources, type CitationSource, } from '../../../../lib/ba-referential-codes.js' import type { SourceCoverage } from './types.js' /** Section/resource folders of a module — the same skip list as the corpus. */ export function childFolders(dir: string): string[] { try { return readdirSync(dir, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => e.name) .filter( (n) => !n.startsWith('_') && !n.startsWith('.') && n !== 'pagespecs' && n !== 'node_modules', ) .sort() } catch { return [] } } function readOrNull(p: string): string | null { try { return existsSync(p) && statSync(p).isFile() ? readFileSync(p, 'utf8') : null } catch { return null } } /** Source 1 — the module business rules (module + section docs). */ export function ruleSources( baRoot: string, app: string, module: string, ): { sources: CitationSource[]; docs: number; warnings: string[] } { const loaded = loadModuleRules(baRoot, app, module) return { sources: ruleCitationSources(loaded.rules), docs: loaded.docs.length, warnings: loaded.warnings, } } /** Source 2 — the acceptance criteria of every use case of the module. */ export function acceptanceSources(moduleDir: string): { sources: CitationSource[]; docs: number } { const sources: CitationSource[] = [] let docs = 0 const visit = (dir: string, depth: number): void => { if (depth > 2) return // module → section → resource const md = readOrNull(join(dir, 'use-case.md')) if (md !== null) { docs += 1 const { ucs } = parseUseCases(md, join(dir, 'use-case.md')) sources.push(...acceptanceCitationSources(ucs)) } for (const child of childFolders(dir)) visit(join(dir, child), depth + 1) } visit(moduleDir, 0) return { sources, docs } } /** Source 3 — the PRD slices and the pagespecs. Delegated to lib so DM-022 * and this CLI read the exact same files: a rule and its remediation that * disagree on the sources would block and unblock the same entity. */ export const prdSources = prdCitationSources /** * The three on-disk sources, plus the coverage that keeps an empty citation * list honest. The FOURTH source (sibling `**Valeurs initiales**`) needs no IO * — the pure core derives it from the entities it already parsed. */ export function loadCitationSources( baRoot: string, app: string, module: string, ): { sources: CitationSource[]; coverage: SourceCoverage; warnings: string[] } { const moduleDir = join(baRoot, app, module) const rules = ruleSources(baRoot, app, module) const ac = acceptanceSources(moduleDir) const prd = prdSources(moduleDir) return { sources: [...rules.sources, ...ac.sources, ...prd.sources], coverage: { rulesDocs: rules.docs, useCaseDocs: ac.docs, prdFiles: prd.files, prdPresent: prd.files > 0, siblingTables: 0, }, warnings: rules.warnings, } }