/** * lib/remediation.ts — THE shared audit → correction contract. * * The audits of this repo say WHAT is broken and, in prose, HOW to repair it. * Nothing made the bridge: the remediation knowledge was scattered across * three disjoint places — * - a `**fixSkill**:` / `**fixPhaseKey**:` line in every `audit-dev-*` * SKILL.md rule block (and the matching fields on the emitted findings), * - eleven `derive-*` CLIs that ALREADY rewrite `.smartstack/ba/**` in * write mode (splice of an anchored machine block — idempotent, human * content preserved), invoked by nobody after an audit, * - a French sentence inside each `audit-ba` finding `message`. * * This module is the machine-readable form of that knowledge: one lane per * KIND of remedy, one registry row per rule whose remedy is executable, and a * fail-safe default that ROUTES rather than guesses. * * Doctrine it encodes (all three learned the hard way in this repo): * 1. The house remedy is « re-run the generator », not « patch the output ». * A fix that patches where a re-scaffold is the real remedy MASKS the * drift — that is why DEV-UI-011 was pulled out of audit-dev-frontend's * IN_PLACE table (`audit-dev-frontend/cli/audit-dev-frontend/apply.ts`). * 2. Business CONTENT is never mechanically written. A rule that needs an * authoring decision routes to the `create-*` skill that OWNS the * document's grammar; a corrector that re-implements that grammar * diverges from it. * 3. Nothing is silent. A finding with no known remedy comes out as * `manual`, named — never dropped. * * Consumed by `audit-ba` (BA findings carry their resolved remedy) and by the * `/audit-fix` router (which also adapts the `audit-dev-*` findings). Pinned * by `lib/__tests__/remediation.test.ts` (`remediation:v1`). */ // --------------------------------------------------------------------------- // Lanes // --------------------------------------------------------------------------- /** * How a finding gets closed. * * - `derive` — a `derive-*` CLI in WRITE mode: recompute the machine block * and splice it back into the BA doc. Idempotent by design. * - `scaffold` — re-run the generator named by the rule's `fixSkill`. The * generated file is REGENERATED, not patched. * - `rewrite` — mechanical in-place source rewrite (ui-polish / * lib/frontend-fixers). The narrowest lane: it only covers * what a regex/span rewrite can do safely. * - `authoring` — a content decision. NEVER executed by the router: it is * handed off to the `create-*` skill that owns the document. * - `manual` — no known remedy. Reported, never hidden. */ export type RemedyLane = 'derive' | 'scaffold' | 'rewrite' | 'authoring' | 'manual' /** * The lanes the router may execute, IN THE ORDER IT MUST EXECUTE THEM. * * The order is a constraint, not a preference: * `derive` first — it rewrites the specs the scaffolders read next; * `scaffold` then — it REGENERATES whole files; * `rewrite` last — a mechanical rewrite applied before a re-scaffold * would simply be overwritten by the regeneration. */ export const EXECUTION_ORDER = ['derive', 'scaffold', 'rewrite'] as const export type ExecutableLane = (typeof EXECUTION_ORDER)[number] export function isExecutableLane(lane: RemedyLane): lane is ExecutableLane { return (EXECUTION_ORDER as readonly string[]).includes(lane) } // --------------------------------------------------------------------------- // Findings // --------------------------------------------------------------------------- /** * A structured business anchor — what the free-form `evidence[]` strings * encode today with a grammar that varies from rule to rule * ("Contact: OwnerId", "Contact.total : formule > 1000 caractères", * "UC-CRM-PIPELINE-004 → err-devis-expire"…). A router cannot re-parse French * prose; it reads this instead. */ export interface FindingAnchor { kind: | 'entity' | 'attribute' | 'use-case' | 'rule' | 'screen' | 'actor' | 'menu-node' | 'pagespec' | 'permission' | 'file' | 'source' /** The anchor's own id — entity name, UC code, BR code, screen code… */ id: string /** Set when the anchor is an attribute/field of `id`. */ attribute?: string } export interface FindingScope { app?: string module?: string section?: string } export type FindingSeverity = 'ok' | 'warn' | 'err' /** * The normalized shape every audit family is adapted INTO. It is deliberately * a superset of the two existing finding types, so neither audit family has to * be rewritten: * - `audit-ba` findings gain `file`/`anchor`/`remedy` (Inc 2); * - `audit-dev-*` findings already carry `file`/`line`/`autoFixable`/ * `fixSkill` — the router maps them (see `remedyFromDevFinding`). */ export interface RemediableFinding { ruleId: string severity: FindingSeverity scope: FindingScope message: string /** Repo-relative path of the doc/file the finding lives in. */ file?: string line?: number /** * The structured twin of `evidence[]`, one entry per offending item. * * PLURAL by necessity: an audit-ba rule emits ONE finding per module with N * offending items (`["Contact.OwnerId", "Devis.ClientId"]`). A single anchor * would have to pick one of them arbitrarily. */ anchors?: FindingAnchor[] evidence?: string[] remedy?: RemedyRef } // --------------------------------------------------------------------------- // Remedies // --------------------------------------------------------------------------- /** * A context slot the runner must bind into the target's `--spec` before * invoking it. Named bindings, never free-form code: the registry stays data. */ export type RemedyBinding = | 'baRoot' | 'app' | 'module' | 'moduleRoot' | 'pagespecDir' | 'projectPath' | 'modulePath' | 'file' | 'entity' export interface RemedyRef { lane: RemedyLane /** CLI or skill id — `derive-lookup-grants`, `scaffold-business`, `ba-create-use-case`. */ target: string /** * Invocation path of the target CLI, relative to the skills root. * * MUST be a single CONTIGUOUS string literal. The installer re-points * `skills/business-analyse//…` at the flattened `skills/ba-/…` with a * plain `String.replace` (`src/lib/installer.ts` — `rewriteDeployedSkillPaths`, * applied to non-BA code files too). A path assembled with `join()` or split * across template fragments ESCAPES that rewrite and, once deployed, points * at a directory that does not exist. `remediation.test.ts` enforces the * literal form. */ cliPath?: string /** * The target's own write-mode token. The `derive-*` family never settled on * one vocabulary (`derive` | `backfill` | `apply`), so the registry carries * it per row instead of renaming eleven CLIs and every one of their callers. */ writeMode?: string bind?: RemedyBinding[] /** Imperative one-liner. Mandatory — it is what the report shows a human. */ solution: string } // --------------------------------------------------------------------------- // Authoring fallback — NOT derivable from the dimension name // --------------------------------------------------------------------------- /** * Dimension → the `create-*` skill that owns that document's grammar. * * This table CANNOT be replaced by a `ba-create-${dimension}` template: the * slugs do not follow the dimension names (`use-cases` → `ba-create-use-case` * SINGULAR, `rules` → `ba-create-business-rules`, `sections` → the menu skill), * and two dimensions have no single owning skill at all. */ export const DIMENSION_AUTHORING_SKILL: Record = { menu: 'ba-create-menu', sections: 'ba-create-menu', actors: 'ba-create-actors', 'use-cases': 'ba-create-use-case', rules: 'ba-create-business-rules', rbac: 'ba-create-rbac', 'data-model': 'ba-create-data-model', screens: 'ba-create-screen', // A cross-ref-code finding is always a data-model statement (entité.md vs // the existing C# domain), so it authors there. 'cross-ref-code': 'ba-create-data-model', // A cross-dimension finding spans several documents by definition: there is // no single owning skill, so it stays `manual` and names the incoherence. 'cross-dimension': null, // A sources finding is either registry hygiene (fix through the ingest CLI) // or a missing/broken citation in a BA doc — both are authored via the // sources skill's discipline, and a broken citation is an authoring decision // (never a mechanical rewrite). sources: 'ba-create-sources', } // --------------------------------------------------------------------------- // Registry — executable remedies ONLY // --------------------------------------------------------------------------- const DERIVE_PRD = 'skills/business-analyse/create-prd/cli' const DERIVE_RBAC = 'skills/business-analyse/create-rbac/cli' const DERIVE_DEV = 'skills/ba-develop/cli' /** * The rules whose remedy is EXECUTABLE. Everything absent from this table * falls back to `DIMENSION_AUTHORING_SKILL` — which is the honest default: * most audit rules describe a missing business decision, not a stale derived * block. * * Every row here is a `mode:check` engine that ALREADY backs the rule (see the * "CLI half" column of `templates/skills/CLAUDE.md`): running the same CLI in * write mode is by construction what closes the finding. */ export const REMEDY_REGISTRY: Record = { 'RBAC-008': { lane: 'derive', target: 'derive-lookup-grants', cliPath: `${DERIVE_RBAC}/derive-lookup-grants/index.ts`, writeMode: 'derive', bind: ['baRoot', 'app', 'module'], solution: 'Re-derive the ba:rbac-derived-lookups machine block from the FK graph.', }, 'RBAC-009': { lane: 'derive', target: 'derive-permission-floor', cliPath: `${DERIVE_RBAC}/derive-permission-floor/index.ts`, writeMode: 'derive', bind: ['baRoot', 'app', 'module'], solution: 'Re-derive the ba:rbac-floor mirror of the seeded permission floor.', }, 'PRD-111': { lane: 'derive', target: 'derive-form-sections', cliPath: `${DERIVE_PRD}/derive-form-sections/index.ts`, writeMode: 'derive', bind: ['pagespecDir'], solution: 'Promote the uiDesign overlay per-field sections into a first-order sections[].', }, 'PRD-120': { lane: 'derive', target: 'derive-lifecycle', cliPath: `${DERIVE_PRD}/derive-lifecycle/index.ts`, writeMode: 'derive', bind: ['moduleRoot', 'pagespecDir'], solution: 'Re-derive the status-anchored lifecycle block of the form pagespecs.', }, 'PRD-129': { lane: 'derive', target: 'derive-rule-links', cliPath: `${DERIVE_PRD}/derive-rule-links/index.ts`, writeMode: 'backfill', bind: ['baRoot', 'app', 'module'], solution: 'Backfill linkedBusinessRules[] on the pagespecs from règles-métier.md.', }, 'PRD-130': { lane: 'derive', target: 'derive-rule-links', cliPath: `${DERIVE_PRD}/derive-rule-links/index.ts`, writeMode: 'backfill', bind: ['baRoot', 'app', 'module'], solution: 'Backfill linkedBusinessRules[] so every cited rule code resolves.', }, 'PRD-134': { lane: 'derive', target: 'derive-detail-summary', cliPath: `${DERIVE_PRD}/derive-detail-summary/index.ts`, writeMode: 'derive', bind: ['moduleRoot', 'pagespecDir'], solution: 'Derive the detail summary band from the entité.md **Affichage** anchor.', }, 'PRD-135': { lane: 'derive', target: 'derive-kanban-spec', cliPath: `${DERIVE_PRD}/derive-kanban-spec/index.ts`, writeMode: 'derive', bind: ['moduleRoot', 'pagespecDir'], solution: 'Re-fold the kanban block onto the list pagespec, columns anchored on the enum.', }, 'PRD-132': { lane: 'derive', target: 'derive-code-specs', cliPath: `${DERIVE_DEV}/derive-code-specs/index.ts`, writeMode: 'derive', bind: ['moduleRoot'], solution: 'Reconcile the pagespec codedEntity flags with the entité.md **Code pattern**.', }, 'PRD-113': { lane: 'derive', target: 'derive-filter-fks', cliPath: `${DERIVE_DEV}/derive-filter-fks/index.ts`, writeMode: 'derive', bind: ['moduleRoot'], solution: 'Backfill fkTo + the FK property name on every reference filter.', }, // --- Deterministic content, but written by a SKILL, not a CLI ------------ // /ba-translate-prd rewrites the pagespec `i18nKeys`, filling `[xx]` // placeholders with real en/it/de. Deterministic in shape, but the values // are translations — no CLI can derive them, so it routes. 'PRD-089': { lane: 'authoring', target: 'ba-translate-prd', solution: 'Run /ba-translate-prd — it fills the [xx] i18n placeholders with real translations.', }, // --- Deliberate project conventions — NOT a defect to repair ------------- // CONV-001/002 report that the corpus settled on its own convention (UC // section segment in lowercase; flat-kebab error codes). The documented // decision is « ne PAS renommer »: those error-code strings are asserted // VERBATIM by the acceptance criteria — they are a public API contract. // Without these two rows the dimension fallback would route them at an // authoring skill, i.e. propose exactly the rename the doctrine forbids. 'CONV-001': { lane: 'manual', target: '', solution: 'Deliberate project convention — do not rename. Parsing is case-insensitive.', }, 'CONV-002': { lane: 'manual', target: '', solution: 'Deliberate project convention — do not rename: the error codes are asserted verbatim by the acceptance criteria.', }, // --- Deterministic, but NEVER unattended --------------------------------- // DM-022 has a fully deterministic corrector — derive-referential-codes // --mode backfill. It is deliberately NOT a `derive` row, and that omission // is the whole guard: the router would then run it unattended and strip // `code` columns across a corpus in the name of a decision only the USER may // take. A reference value not carrying a code is the DEFAULT; keeping one is // a business decision, and the inventory of citations exists so a human can // take it table by table. So this routes, and names the read-only inventory. 'DM-022': { lane: 'authoring', target: 'ba-create-data-model', solution: 'Inventorier d’abord (lecture seule) : skills/business-analyse/create-data-model/cli/derive-referential-codes/index.ts --spec {"mode":"check"} — il rend, table par table, les citations verbatim de ses codes. PUIS trancher : garder le code => écrire la puce datée **Code décidé** ; le retirer => --mode backfill. Jamais l’inverse, jamais sans lire les citations.', }, // --- The business test dataset (jeu-de-test.md) — authored, never derived --- // DM-029..032 read `lib/ba-test-data-check` (the same engine as // create-test-data/cli/derive-test-data --mode check). Their remedy is an // AUTHORING act on `jeu-de-test.md` — fictitious rows the client validates — // owned by /ba-create-test-data, NOT by the data-model dimension's default // skill (which would route the finding at entité.md's author). 'DM-029': { lane: 'authoring', target: 'ba-create-test-data', solution: 'Run /ba-create-test-data on the module — 5-8 fictitious rows per business entity (jeu-de-test.md), validated by the client.', }, 'DM-030': { lane: 'authoring', target: 'ba-create-test-data', solution: 'Inventory first (read-only): skills/business-analyse/create-test-data/cli/derive-test-data/index.ts --spec {"mode":"check"} — it names every incoherence and, for an unresolved citation, the OWNER module that must provide the row. Then fix jeu-de-test.md (or the cited module’s) via /ba-create-test-data.', }, 'DM-031': { lane: 'authoring', target: 'ba-create-test-data', solution: 'Remove the JT- block: a reference table’s rows ARE its **Valeurs initiales** (setup tier). Cite them from other blocks instead.', }, 'DM-032': { lane: 'authoring', target: 'ba-create-test-data', solution: 'Add a row carrying each status a Flow rule reaches (jeu-de-test.md), so the transition can be shown and tested.', }, // reconcile-menu DELETES heading blocks and scrubs references. It validates // through AskUserQuestion by design, and destructive cleanup is forbidden to // the router. So it routes; it never runs unattended. 'SEC-007': { lane: 'authoring', target: 'ba-reconcile-menu', solution: 'Run /ba-reconcile-menu — it owns rename/deletion cleanup (interactive by design).', }, 'UC-019': { lane: 'authoring', target: 'ba-reconcile-menu', solution: 'Run /ba-reconcile-menu — it owns rename/deletion cleanup (interactive by design).', }, } // --------------------------------------------------------------------------- // Resolution // --------------------------------------------------------------------------- /** * The remedy for a rule. Never returns undefined: an unknown rule in an * unknown dimension still comes back as `manual`, so no finding can be * silently un-actionable. */ export function resolveRemedy(ruleId: string, dimension?: string): RemedyRef { const hit = REMEDY_REGISTRY[ruleId] if (hit !== undefined) return hit const skill = dimension !== undefined ? DIMENSION_AUTHORING_SKILL[dimension] : undefined if (skill !== undefined && skill !== null) { return { lane: 'authoring', target: skill, solution: `Re-run /${skill} in ENRICH mode on this scope, fed with the finding and its evidence.`, } } return { lane: 'manual', target: '', solution: 'No mechanical remedy: read the finding and decide.', } } /** * The `audit-dev-*` CLIs whose own `--mode apply` really rewrites source. * * This list is what makes the `rewrite` lane reachable, and it is keyed by the * AUDIT that produced the finding — not by its `fixSkill`. That distinction is * the whole point: `autoFixable: true` means "the CLI that found this can also * repair it in apply mode", while `fixSkill` names the GENERATOR to re-run * (`frontend-component`, `frontend-routes`, `testing`…). Keying on `fixSkill` * made every autoFixable finding fall through to "no in-place executor" and the * lane could never fire. */ export const REWRITE_CAPABLE_AUDITS = new Set(['audit-dev-frontend']) /** * Adapt an `audit-dev-*` finding to a remedy. * * Read these fields off `report.findings[]`, NEVER off `auditReport.findings[]`: * the latter is a `Pick<…>` that deliberately DROPS `file`, `line` and * `autoFixable` before shipping to the Studio UI. A router wired to that view * believes nothing is locatable and quietly repairs nothing. * * `source` is the audit CLI the finding came from — required for `autoFixable` * to mean anything (see `REWRITE_CAPABLE_AUDITS`). */ export function remedyFromDevFinding(f: { code: string source?: string solution?: string fixSkill?: string autoFixable?: boolean }): RemedyRef { const registered = REMEDY_REGISTRY[f.code] if (registered !== undefined) return registered const solution = f.solution ?? 'See the rule in its audit-dev SKILL.md.' // The producing audit can rewrite this in place → run IT, in apply mode. if (f.autoFixable === true && f.source !== undefined && REWRITE_CAPABLE_AUDITS.has(f.source)) { return { lane: 'rewrite', target: f.source, solution } } // Otherwise the remedy is to re-run the generator the rule names. if (f.fixSkill !== undefined && f.fixSkill !== '') { return { lane: 'scaffold', target: f.fixSkill, solution } } return { lane: 'manual', target: '', solution } }