/** * cli:derive-uc-coverage — execute.ts (pure core over injected sources). * * Coverage semantics (per UC): * - level ≠ user-goal → exempt on BOTH legs (a subfunction/summary UC * is reached through its callers' surfaces). * - scheduled → covered on BOTH legs by the scheduled surface * (derive-job-specs derives the runtime, * DEV-API-028 verifies it exists). * - else BA leg → ≥1 screen.md reference through a SANCTIONED * channel (never loose prose): the * `- **Cas d'usage liés** : …` field or a * `UC: UC-…` tag on an action line. * - else PRD leg → ≥1 pagespec reference: `linkedUseCases[]` or * an action's `ucReference`. When the module has * no pagespecs/ at all the leg is `no-pagespecs` * (reported loudly, never silently green). * * Matching is VERBATIM on the UC code — a typo'd reference covers nothing * (PRD-097 / SCR-005 own the dead-reference direction). * * Exception ↔ AC parity (UC-022): every EXC-N flow of a user-goal UC needs a * corresponding AC. Correspondence is deterministic, two tiers: * 1. explicit — an AC text cites the exception id verbatim (`EXC-2`, * word-bounded): that flow is covered by THAT AC; * 2. counted pool — the remaining (un-cited) EXC flows are covered when the * UC has at least as many NEGATIVE ACs (an AC asserting a 4xx/5xx status * or a refusal/error outcome) not already claimed by tier 1. * A UC with 5 exceptions and 1 happy-path AC is a finding (« un AC par chemin * EXC-N » finally counted); which specific AC tests which flow stays the * author's judgment — this check counts, it never guesses semantics. */ import type { UcWithAc } from '../../../../development/testing/cli/scaffold-tests-from-ac/types.js' import type { DeriveUcCoverageReport, ExceptionFinding, SurfaceRef, UcCoverageEntry, } from './types.js' export interface ScreenSource { /** Path relative to the module dir (e.g. `opportunites/screen.md`). */ relPath: string md: string } export interface PagespecSource { /** File name (e.g. `Opportunite.list.md`). */ name: string md: string } /** Matches the FIRST fenced ```json … ``` block (the pagespec machine block). */ export const JSON_BLOCK_RE = /```json\s*\r?\n([\s\S]*?)\r?\n```/ // Case-INSENSITIVE segments + variable count (≥3 before the trailing -NNN) — // kept in lockstep with parse-ac's UC_HEADING_RE: the corpus writes the // section segment in lowercase (19/20 modules) and multi-word kebab sections // span several segments. The old UPPERCASE-only forms matched nothing there, // so every reference was dropped and SCR-024/PRD-131 flagged « uncovered » // screens that were correctly authored. const SCR_HEADING_RE = /^###\s+(SCR-[A-Za-z0-9_-]+)\s+[—\-]/ const UC_TOKEN_RE = /\bUC-(?:[A-Za-z0-9_]+-){3,}\d{3}\b/g const ACTION_UC_TAG_RE = /\bUC:\s*(UC-[A-Za-z0-9_-]+)/g /** Diacritics-insensitive test for the `- **Cas d'usage liés** :` field * (files mix `'`/`’` and composed/decomposed accents). */ function isCasDUsageLiesLine(line: string): boolean { const folded = line .normalize('NFD') .replace(/[̀-ͯ]/g, '') .replace(/’/g, "'") .toLowerCase() return /^\s*-\s*\*\*cas d'usage lies\*\*\s*:/.test(folded) } /** Extract every sanctioned UC reference of one screen.md file. */ export function screenUcRefs(source: ScreenSource): Map { const refs = new Map() const push = (uc: string, ref: SurfaceRef): void => { refs.set(uc, [...(refs.get(uc) ?? []), ref]) } let currentScr: string | undefined const lines = source.md.split(/\r?\n/) let i = 0 while (i < lines.length) { const line = lines[i]! const h = line.match(SCR_HEADING_RE) if (h) { currentScr = h[1]! i++ continue } if (isCasDUsageLiesLine(line)) { const scanLine = (l: string): void => { for (const m of l.matchAll(UC_TOKEN_RE)) { push(m[0], { file: source.relPath, channel: 'cas-d-usage-lies', ...(currentScr !== undefined ? { anchor: currentScr } : {}), }) } } scanLine(line) // The field FOLDS over continuation lines — an indented sub-bullet // (` - UC-…`) or an indented plain line — the same continuation // grammar as lib/ba-rules-rows. Only the OPENING line used to be // scanned: every code on a continuation line fell through to the // `UC:` tag loop (which requires that literal prefix) and was lost, // yielding false « uncovered » on correctly-authored screens. let k = i + 1 while (k < lines.length) { const lk = lines[k]! if (/^\s*-\s*\*\*/.test(lk)) break // next field bullet ends the block if (!(/^\s+-\s+\S/.test(lk) || /^\s{2,}\S/.test(lk))) break scanLine(lk) k++ } i = k continue } for (const m of line.matchAll(ACTION_UC_TAG_RE)) { push(m[1]!, { file: source.relPath, channel: 'screen-action-uc', ...(currentScr !== undefined ? { anchor: currentScr } : {}), }) } i++ } return refs } /** Extract every UC reference of one pagespec (machine block only). */ export function pagespecUcRefs(source: PagespecSource, warnings: string[]): Map { const refs = new Map() const m = JSON_BLOCK_RE.exec(source.md) if (!m) { warnings.push(`${source.name}: no parseable \`\`\`json machine block — skipped for UC coverage.`) return refs } let block: Record try { block = JSON.parse(m[1]!) as Record } catch { warnings.push(`${source.name}: machine block is not valid JSON — skipped for UC coverage.`) return refs } const push = (uc: string, ref: SurfaceRef): void => { refs.set(uc, [...(refs.get(uc) ?? []), ref]) } if (Array.isArray(block.linkedUseCases)) { for (const v of block.linkedUseCases) { if (typeof v === 'string' && v.startsWith('UC-')) { push(v, { file: `pagespecs/${source.name}`, channel: 'linked-use-cases' }) } } } if (Array.isArray(block.actions)) { for (const a of block.actions) { if (a && typeof a === 'object') { const act = a as Record if (typeof act.ucReference === 'string' && act.ucReference.startsWith('UC-')) { push(act.ucReference, { file: `pagespecs/${source.name}`, channel: 'uc-reference', ...(typeof act.code === 'string' ? { anchor: act.code } : {}), }) } } } } return refs } /** An AC asserting a refusal outcome — the pool tier of UC-022. A 4xx/5xx * status, or a refusal/error keyword (fr/en). Word stems on purpose: the * check counts negative INTENT, it never parses semantics. */ const NEGATIVE_AC_RE = /\b[45]\d{2}\b|refus|rejet|erreur|échou|échec|echou|echec|error|reject|fail|denied|interdit|invalide|invalid/i /** UC-022 engine — see the header doc for the two correspondence tiers. */ export function exceptionParity(ucs: UcWithAc[]): ExceptionFinding[] { const findings: ExceptionFinding[] = [] for (const uc of ucs) { // Subfunction/summary UCs legitimately carry no AC contract (UC-012). if (uc.level !== 'user-goal') continue if (uc.exceptions.length === 0) continue const cited = new Set() const citingAcs = new Set() for (const exc of uc.exceptions) { const re = new RegExp(`\\b${exc.id}\\b`) for (const ac of uc.acs) { if (re.test(ac.text)) { cited.add(exc.id) citingAcs.add(ac.localId) } } } const uncited = uc.exceptions.filter((e) => !cited.has(e.id)).map((e) => e.id) if (uncited.length === 0) continue const negativePool = uc.acs.filter( (ac) => !citingAcs.has(ac.localId) && NEGATIVE_AC_RE.test(ac.text), ).length const shortfall = uncited.length - negativePool if (shortfall <= 0) continue findings.push({ ucCode: uc.ucCode, sourceFile: uc.sourceFile, uncovered: uncited, detail: `${uc.exceptions.length} exception flow(s), ${cited.size} cited explicitly by an AC, ` + `${negativePool} negative AC(s) in the pool — ${shortfall} flow(s) have no corresponding AC. ` + `Write one AC per EXC-N (cite the id, e.g. « … (EXC-2) », or assert its 4xx/error outcome).`, }) } return findings } export interface DeriveInput { app: string module: string ucs: UcWithAc[] ucWarnings: string[] screens: ScreenSource[] /** `null` = the module has no pagespecs/ directory at all. */ pagespecs: PagespecSource[] | null } export function deriveUcCoverage(input: DeriveInput): DeriveUcCoverageReport { const warnings = [...input.ucWarnings] // Coverage joins are case-INSENSITIVE on the code (the corpus mixes a // lowercase section segment in the heading with other casings in the // references); segments themselves stay verbatim — a typo'd reference // still covers nothing. const screenRefsByUc = new Map() for (const screen of input.screens) { for (const [uc, refs] of screenUcRefs(screen)) { const key = uc.toUpperCase() screenRefsByUc.set(key, [...(screenRefsByUc.get(key) ?? []), ...refs]) } } const pagespecsPresent = input.pagespecs !== null const pagespecRefsByUc = new Map() for (const spec of input.pagespecs ?? []) { for (const [uc, refs] of pagespecUcRefs(spec, warnings)) { const key = uc.toUpperCase() pagespecRefsByUc.set(key, [...(pagespecRefsByUc.get(key) ?? []), ...refs]) } } const ucs: UcCoverageEntry[] = input.ucs.map((uc) => { const screenRefs = screenRefsByUc.get(uc.ucCode.toUpperCase()) ?? [] const pagespecRefs = pagespecRefsByUc.get(uc.ucCode.toUpperCase()) ?? [] const base = { ucCode: uc.ucCode, title: uc.title, level: uc.level, scheduled: uc.scheduled, sourceFile: uc.sourceFile, screenRefs, pagespecRefs, } if (uc.level !== 'user-goal') { return { ...base, ba: 'exempt-level' as const, prd: 'exempt-level' as const } } if (uc.scheduled) { return { ...base, ba: 'scheduled' as const, prd: 'scheduled' as const } } return { ...base, ba: screenRefs.length > 0 ? ('covered' as const) : ('uncovered' as const), prd: !pagespecsPresent ? ('no-pagespecs' as const) : pagespecRefs.length > 0 ? ('covered' as const) : ('uncovered' as const), } }) const exceptionFindings = exceptionParity(input.ucs) const totals = { ucs: ucs.length, userGoal: ucs.filter((u) => u.level === 'user-goal').length, scheduled: ucs.filter((u) => u.ba === 'scheduled').length, exemptLevel: ucs.filter((u) => u.ba === 'exempt-level').length, uncoveredBa: ucs.filter((u) => u.ba === 'uncovered').length, uncoveredPrd: ucs.filter((u) => u.prd === 'uncovered').length, exceptionFindings: exceptionFindings.length, } return { app: input.app, module: input.module, ucs, exceptionFindings, totals, pagespecsPresent, warnings, } }