/** * lib/ba-use-cases.ts — THE shared `use-case.md` parser. * * Promoted from `development/testing/cli/scaffold-tests-from-ac/parse-ac.ts` * (which now re-exports from here — same precedent as code-pattern-grammar): * the UC parser was the only corpus parser NOT in lib/, imported cross-tree by * derive-uc-coverage and audit-dev-tests. It parsed ONLY the AC contract * (acs + level + scheduled + ALT/EXC branches); the fields carrying audit * rules UC-003..009 (Flux principal, Préconditions, Postconditions, Acteurs) * had no deterministic reader at all. This module parses the WHOLE UC block. * * Pure parser: one file's text + its source path metadata; the caller does * the Glob. No filesystem, no I/O. * * Grammar handled (see `business-analyse/_workflow/doc-templates.md`): * * ### UC-{APP}-{MOD}-{SEC}[-{RES}]-NNN — Title (…level…) * - **Acteur principal** : BA-001-AC-001 (Commercial) * - **Acteurs secondaires** : … * - **Préconditions** : … * - **Flux principal** : * 1. Step one. * 2. Step two. * - **Flux alternatifs** : * - ALT-1 : trigger → outcome. * - **Exceptions** : * - EXC-1 : trigger → recovery. * - **Postconditions** : … * - **Acceptance Criteria** : * - [ ] AC-01 — Assertion 1. * * Tolerant of: * - missing fields (empty arrays / undefined — the AUDIT rules verdict, the * parser never invents) * - extra whitespace, any field order, 4-space and tabbed indentation * - LOWERCASE / mixed-case UC code segments (real corpora write the section * segment in lowercase) and MULTI-WORD kebab sections (variable segment * count — the code is anchored on its trailing `-NNN`) * - NFD/NFC accents and straight/typographic apostrophes in field names * - markdown thematic breaks (`---`) between UCs — block separators, never * lost assertions * * Strict on: * - `AC-NN` MUST match `^AC-\d{2}$` (UC-013 audit) * - `- [ ] AC-NN — …` separator is the em-dash ` — ` (U+2014, what * `/ba-create-use-case` writes) OR a plain ASCII ` - ` (graceful fallback) * - a heading that STARTS like a UC but does not parse is a NEAR-MISS: * it lands in the `lost` channel (loss-class), never in silence */ import { splitBaList } from './ba-list-split.js' // --------------------------------------------------------------------------- // Model // --------------------------------------------------------------------------- /** A single `- [ ] AC-NN — ` bullet under a UC's AC field. */ export interface BaAc { /** `AC-01`, `AC-02`, … — local to the parent UC, zero-padded 2 digits. */ localId: string /** Verbatim text after the ` — ` separator. */ text: string } /** One `- ALT-N : …` / `- EXC-N : …` branch line. */ export interface BaFlowBranch { id: string text: string } /** A fully-parsed UC block. Structural SUPERSET of the historical `UcWithAc` * (scaffold-tests-from-ac/types.ts) — every existing consumer keeps working * through the parse-ac shim; the new fields carry UC-003..009. */ export interface BaUseCase { ucCode: string title: string /** Section code UPPERCASED, `-`-joined for multi-word kebab sections. */ sectionCode: string /** Section folder in kebab-case (resolved against the owning folder). */ sectionFolder: string /** Source file RELATIVE to the module dir (traceability). */ sourceFile: string acs: BaAc[] level: 'user-goal' | 'subfunction' | 'summary' scheduled: boolean alternatives: BaFlowBranch[] exceptions: BaFlowBranch[] /** `- **Acteur principal** : …` verbatim value (undefined when absent). */ primaryActor?: string /** `- **Acteurs secondaires** : …` — split on `,`/`;`, continuations kept. */ secondaryActors: string[] /** `- **Préconditions** : …` — inline value + sub-bullets, one entry each. */ preconditions: string[] /** `- **Postconditions** : …` — same shape. */ postconditions: string[] /** `- **Flux principal** :` numbered/bulleted steps, one entry per step. */ mainFlow: string[] } export interface ParseUseCasesResult { ucs: BaUseCase[] warnings: string[] /** * The LOSS-class subset of `warnings`: an assertion the contract will not * carry (malformed bullet, duplicate id, near-miss UC heading). The test * scaffolder turns a non-empty `lost` into a FAILED envelope — a lost AC is * never a warning someone may read. */ lost: string[] } // --------------------------------------------------------------------------- // Regexes // --------------------------------------------------------------------------- /** Kebab-fold one UC code segment (lowercase, `_` → `-`). */ function kebabOf(segment: string): string { return segment.toLowerCase().replace(/_/g, '-') } /** NFD-fold a field label: strip accents, normalise apostrophes, lowercase. */ function foldFieldKey(label: string): string { return label .normalize('NFD') .replace(/[̀-ͯ]/g, '') .replace(/’/g, "'") .trim() .toLowerCase() } /** * Resolve the SECTION / RESOURCE placement of a UC code against the folder * that owns the file. The code is `UC-{APP}-{MOD}-{…}-NNN` where `{…}` is the * section (and optionally a resource) written as ONE OR MORE dash segments — * a multi-word kebab section (`exchange-history`) contributes several * segments, and the section segment is routinely LOWERCASE in real corpora. * Disambiguation is by the OWNING FOLDER, tried in order: * 1. the whole middle IS the section (no resource in the code); * 2. the file lives under the SECTION folder, the code carries a resource tail; * 3. the file lives under the RESOURCE folder itself (resource-level use-case.md). * No match → historical single-segment split, `consistent: false` (caller warns). */ function resolveUcPlacement( ucCode: string, pathSectionFolder: string, ): { sectionSegments: string[]; resourceFolder: string; consistent: boolean } { const middle = ucCode.split('-').slice(3, -1) if (middle.length === 0) return { sectionSegments: [], resourceFolder: '', consistent: true } if (!pathSectionFolder) return { sectionSegments: middle, resourceFolder: '', consistent: true } const wholeKebab = middle.map(kebabOf).join('-') const headKebab = middle.slice(0, -1).map(kebabOf).join('-') const lastKebab = kebabOf(middle[middle.length - 1]!) if (wholeKebab === pathSectionFolder) { return { sectionSegments: middle, resourceFolder: '', consistent: true } } if (middle.length >= 2 && headKebab === pathSectionFolder) { return { sectionSegments: middle.slice(0, -1), resourceFolder: lastKebab, consistent: true } } if (middle.length >= 2 && lastKebab === pathSectionFolder) { return { sectionSegments: middle.slice(0, -1), resourceFolder: lastKebab, consistent: true } } return middle.length >= 2 ? { sectionSegments: [middle[0]!], resourceFolder: lastKebab, consistent: false } : { sectionSegments: middle, resourceFolder: '', consistent: false } } /** * Match a UC heading and capture (code, title). Segments are case-INSENSITIVE * and the count is VARIABLE (≥ 3 before the trailing `-NNN`) — see the header * doc. The trailing `-\d{3}` is the anchor. */ export const UC_HEADING_RE = /^###\s+(UC-(?:[A-Za-z0-9_]+-){3,}\d{3})\s+[—\-]\s+(.+?)\s*$/ /** A heading that LOOKS like a UC but fails UC_HEADING_RE — loud, loss-class. */ const UC_NEAR_MISS_RE = /^###\s+uc-/i /** Markdown thematic break (`---`, `***`, `___`, spaced variants) — a BLOCK * SEPARATOR between two UCs, never a lost assertion. */ const THEMATIC_BREAK_RE = /^\s*(?:(?:-\s*){3,}|(?:\*\s*){3,}|(?:_\s*){3,})$/ /** Match the `**Acceptance Criteria**` field opener. */ const AC_FIELD_OPENER_RE = /^-\s*\*\*Acceptance\s+Criteria\*\*\s*:\s*$/i /** Match `****` bullets that end the AC field block. */ const FIELD_BULLET_RE = /^-\s*\*\*[^*]+\*\*\s*:/ /** Generic top-level field bullet: `- **Label** : value` (value may be empty). */ const GENERIC_FIELD_RE = /^-\s*\*\*([^*]+)\*\*\s*:\s*(.*)$/ /** Match a single AC bullet (indented, tabbed or column-0; em-dash or ASCII). */ const AC_BULLET_RE = /^\s*-\s*\[[ xX]?\]\s*(AC-\d{2})\s+[—\-]\s+(.+?)\s*$/ /** `- **Niveau** : user-goal|subfunction|summary` bullet (optional). */ const LEVEL_FIELD_RE = /^-\s*\*\*Niveau\*\*\s*:\s*(user-goal|subfunction|summary)\b/i /** Cockburn level from the heading's trailing parenthetical. */ function levelFromTitle(title: string): 'user-goal' | 'subfunction' | 'summary' | null { const m = title.match(/\(([^()]*)\)\s*$/) if (!m) return null const inside = m[1]!.toLowerCase() if (/\bsubfunction\b/.test(inside)) return 'subfunction' if (/\bsummary\b/.test(inside)) return 'summary' if (/\buser-goal\b/.test(inside)) return 'user-goal' return null } /** The scheduled execution signal — SAME loose detection as derive-job-specs. */ const SCHEDULED_RE = /\bscheduled\b/i /** `- ALT-N : …` / `- EXC-N : …` branch lines — self-identified by prefix. */ const FLOW_BRANCH_RE = /^\s*-\s*((?:ALT|EXC)-\d+)\s*[:—\-]\s*(.+?)\s*$/ /** Continuation of the CURRENT field: numbered step, sub-bullet, plain fold. */ const STEP_CONT_RE = /^\s+(?:\d+[.)]|[-*])\s+(.+?)\s*$/ const PLAIN_CONT_RE = /^\s{2,}(\S.*?)\s*$/ // --------------------------------------------------------------------------- // Parser // --------------------------------------------------------------------------- /** * Parse one `use-case.md` file content. * * @param text - Raw markdown content of the file. * @param relPath - Source file path RELATIVE to moduleDir (e.g. `opportunites/use-case.md`). * The section folder is taken from the owning path segment. */ export function parseUseCases(text: string, relPath: string): ParseUseCasesResult { const ucs: BaUseCase[] = [] const warnings: string[] = [] const lost: string[] = [] const lose = (message: string): void => { warnings.push(message) lost.push(message) } // Section folder is the path segment OWNING use-case.md // e.g. `opportunites/use-case.md` → `opportunites` const pathSectionFolder = relPath.split(/[/\\]/).slice(-2, -1)[0] ?? '' const lines = text.split(/\r?\n/) let i = 0 while (i < lines.length) { const line = lines[i]! const headingMatch = line.match(UC_HEADING_RE) if (!headingMatch) { // NEAR-MISS channel: a `### UC-…` heading the regex rejects means an // unknown number of ACs silently out of the contract — loss-class. if (UC_NEAR_MISS_RE.test(line)) { lose( `${relPath}: heading "${line.trim()}" looks like a UC but does not parse ` + '(expected `### UC-{APP}-{MOD}-{SEC}[-{RES}]-NNN — Title`) — its ACs are NOT in the contract until fixed.' ) } i++ continue } const ucCode = headingMatch[1]! const title = headingMatch[2]! const placement = resolveUcPlacement(ucCode, pathSectionFolder) const sectionKebab = placement.sectionSegments.map(kebabOf).join('-') const sectionCode = placement.sectionSegments.join('-').toUpperCase() if (!placement.consistent && sectionKebab && pathSectionFolder) { warnings.push( `${relPath}: UC ${ucCode} declares section "${sectionKebab}" but lives under folder "${pathSectionFolder}"` ) } // Scan forward within THIS UC's bullet block. let j = i + 1 const acs: BaAc[] = [] let foundAcField = false let level = levelFromTitle(title) let scheduled = false const alternatives: BaFlowBranch[] = [] const exceptions: BaFlowBranch[] = [] const seenBranchIds = new Set() // Extended fields (UC-003..009 carriers). let primaryActor: string | undefined const secondaryActors: string[] = [] const preconditions: string[] = [] const postconditions: string[] = [] const mainFlow: string[] = [] /** The list the CURRENT field's continuation lines append to (null = none). */ let contTarget: string[] | null = null /** True while folding a plain continuation into the LAST entry of contTarget. */ let contAppends = false const pushSplit = (target: string[], value: string): void => { // Top-level split — `BA-001-AC-002 (Manager, N+1)` is ONE secondary actor. target.push(...splitBaList(value)) } while (j < lines.length) { const lj = lines[j]! // ANY `#`/`##`/`###` heading ends the UC block — including a REJECTED // `### UC-…` near-miss, whose bullets used to be absorbed into the // PREVIOUS UC (its ACs mis-attributed with no signal). if (/^#{1,3}\s/.test(lj)) break if (SCHEDULED_RE.test(lj)) scheduled = true if (level === null) { const lm = lj.match(LEVEL_FIELD_RE) if (lm) level = lm[1]!.toLowerCase() as 'user-goal' | 'subfunction' | 'summary' } const bm = lj.match(FLOW_BRANCH_RE) if (bm) { const id = bm[1]! if (seenBranchIds.has(id)) { warnings.push(`${relPath}: UC ${ucCode} declares ${id} twice — only the first line is kept.`) } else { seenBranchIds.add(id) ;(id.startsWith('ALT-') ? alternatives : exceptions).push({ id, text: bm[2]! }) } j++ continue } if (AC_FIELD_OPENER_RE.test(lj)) { contTarget = null foundAcField = true // Consume bullets indented under this field let k = j + 1 const seenIds = new Set() while (k < lines.length) { const lk = lines[k]! // Stop on next top-level UC bullet (- **Field**:) or any heading if (/^#{1,3}\s/.test(lk)) break if (FIELD_BULLET_RE.test(lk)) break // Thematic break (`---` between two UCs): closes the AC block — // it used to match the malformed-bullet guard below and produce a // false « assertion NOT in the contract » on EVERY UC of the corpus. if (THEMATIC_BREAK_RE.test(lk)) break if (lk.trim() === '') { k++ continue } const acMatch = lk.match(AC_BULLET_RE) if (acMatch) { const localId = acMatch[1]! const acText = acMatch[2]! if (seenIds.has(localId)) { lose(`${relPath}: UC ${ucCode} duplicates ${localId} — the second bullet is DROPPED from the contract.`) } else { seenIds.add(localId) acs.push({ localId, text: acText }) if (!/^\s/.test(lk)) { warnings.push( `${relPath}: UC ${ucCode} ${localId} is written at column 0 — accepted, but indent it under the AC field (house format).` ) } } } else if (/^\s*-/.test(lk)) { lose( `${relPath}: UC ${ucCode} has a malformed AC bullet: "${lk.trim()}". Expected "- [ ] AC-NN — text" — the assertion is NOT in the contract until fixed.` ) } k++ } j = k continue } // Generic top-level field bullet (column 0) — routes the extended fields. const gf = /^\s/.test(lj) ? null : lj.match(GENERIC_FIELD_RE) if (gf) { const key = foldFieldKey(gf[1]!) const value = gf[2]!.trim() contTarget = null contAppends = false switch (key) { case 'acteur principal': primaryActor = value || undefined break case 'acteurs secondaires': if (value) pushSplit(secondaryActors, value) contTarget = secondaryActors break case 'preconditions': if (value) preconditions.push(value) contTarget = preconditions contAppends = value !== '' break case 'postconditions': if (value) postconditions.push(value) contTarget = postconditions contAppends = value !== '' break case 'flux principal': if (value) mainFlow.push(value) contTarget = mainFlow break default: break } j++ continue } // Continuations of the CURRENT extended field: a numbered step or // sub-bullet is a NEW entry; an indented plain line folds into the // previous entry (same continuation grammar as lib/ba-rules-rows). if (contTarget !== null) { const step = lj.match(STEP_CONT_RE) if (step) { contTarget.push(step[1]!) contAppends = true j++ continue } const plain = lj.match(PLAIN_CONT_RE) if (plain && contAppends && contTarget.length > 0) { contTarget[contTarget.length - 1] = `${contTarget[contTarget.length - 1]} ${plain[1]!}` j++ continue } if (lj.trim() === '' || !/^\s/.test(lj)) contTarget = null } j++ } if (!foundAcField) { warnings.push( `${relPath}: UC ${ucCode} has no **Acceptance Criteria** field (will emit no [Fact]; audit UC-012 may flag this).` ) } ucs.push({ ucCode, title, sectionCode, sectionFolder: sectionKebab || pathSectionFolder, sourceFile: relPath, acs, level: level ?? 'user-goal', scheduled, alternatives, exceptions, ...(primaryActor !== undefined ? { primaryActor } : {}), secondaryActors, preconditions, postconditions, mainFlow, }) i = j } return { ucs, warnings, lost } } /* ------------------------------------------------------------------------ * * "Is this use case DETAILED?" — the shared predicate * ------------------------------------------------------------------------ */ /** * A Cockburn field value that carries NOTHING: absent, an em dash, or * the explicit `(aucun)` marker. The discovery pass writes exactly this shape * (`create-use-case/levels/discovery.md`: "the remaining Cockburn fields … * left as `—`, to be filled in Phase 2"), and the parser KEEPS it — the field * bullet has a truthy value, so `mainFlow` ends up `['—']`, length 1. * * Testing `mainFlow.length === 0` therefore reads a discovery-level UC as * detailed. This helper is the fail-CLOSED reading, and the reason it lives * here rather than beside a rule: audit-ba `rules/uc.ts` (UC-003/004/005), * audit-ba `rules/br.ts` (BR-009 — declared `dedupOf: 'UC-003'`, so it owes * the same predicate by contract) and the BA detail cadence's worklist must * agree, or a UC is "done" for one of them and "todo" for another. */ export function isUcPlaceholderValue(v: string): boolean { return v === '' || v === '—' || foldFieldKey(v) === '(aucun)' } /** The entries of a Cockburn step field that actually say something. */ export function meaningfulSteps(steps: readonly string[]): string[] { return steps.filter(s => !isUcPlaceholderValue(s)) } /** * A UC is DETAILED once its main flow carries ≥ 1 non-placeholder step. That * is the same bar UC-003 enforces — deliberately the main flow ALONE: pre/ * postconditions have their own rules (UC-004/005), and a UC with steps but * no preconditions is an incomplete UC, not an undetailed one. */ export function isDetailedUseCase(uc: BaUseCase): boolean { return meaningfulSteps(uc.mainFlow).length > 0 } /** Codes of the UCs still awaiting the detail pass — the cadence worklist. */ export function undetailedUseCases(ucs: readonly BaUseCase[]): string[] { return ucs.filter(uc => !isDetailedUseCase(uc)).map(uc => uc.ucCode) }