/** * `remediation:v1` — the drift lock of the audit → correction contract. * * Three classes of invariant, each closing a defect found while designing the * contract: * * 1. EXECUTION ORDER. `derive → scaffold → rewrite` is a constraint, not a * preference: a mechanical rewrite applied before a re-scaffold is simply * overwritten by the regeneration. * * 2. INSTALLER-SAFE PATHS. The installer re-points `skills/business-analyse/…` * at the flattened `skills/ba-…` with a plain `String.replace`. A cliPath * assembled from fragments escapes that rewrite and, once deployed, points * at a directory that does not exist — invisible until launch. * * 3. NO SILENT DEAD END. Every dimension resolves to something; an unknown * rule still comes back `manual`, never undefined. */ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { DIMENSION_AUTHORING_SKILL, EXECUTION_ORDER, REMEDY_REGISTRY, isExecutableLane, REWRITE_CAPABLE_AUDITS, remedyFromDevFinding, resolveRemedy, type RemedyRef, } from '../remediation.js' const skillsRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..') /** The 11 dimensions of the audit-ba engine (`audit-ba/types.ts`). */ const AUDIT_BA_DIMENSIONS = [ 'menu', 'sections', 'actors', 'use-cases', 'rules', 'rbac', 'data-model', 'screens', 'cross-dimension', 'cross-ref-code', 'sources', ] /** * Resolve a registry cliPath against the skills root, tolerating BOTH trees: * the source (`business-analyse//…`) and the deployed/mirror one, where the * installer has already flattened it to `ba-/…`. */ function resolveCliPath(cliPath: string): string | null { const rel = cliPath.replace(/^skills\//, '') const source = join(skillsRoot, rel) if (existsSync(source)) return source const flattened = join(skillsRoot, rel.replace(/^business-analyse\//, 'ba-')) return existsSync(flattened) ? flattened : null } const rows = Object.entries(REMEDY_REGISTRY) describe('remediation:v1 — execution order', () => { it('runs derive before scaffold before rewrite', () => { expect([...EXECUTION_ORDER]).toEqual(['derive', 'scaffold', 'rewrite']) }) it('never treats an authoring or manual lane as executable', () => { expect(isExecutableLane('authoring')).toBe(false) expect(isExecutableLane('manual')).toBe(false) for (const lane of EXECUTION_ORDER) expect(isExecutableLane(lane)).toBe(true) }) }) describe('remediation:v1 — registry rows', () => { it('has at least one row (a registry that empties itself is a silent regression)', () => { expect(rows.length).toBeGreaterThan(0) }) it.each(rows)('%s carries an imperative solution', (_id, remedy: RemedyRef) => { expect(remedy.solution.trim().length).toBeGreaterThan(0) }) it.each(rows.filter(([, r]) => r.lane === 'derive'))( '%s (derive) names an EXISTING CLI, a write mode and its bindings', (_id, remedy: RemedyRef) => { expect(remedy.cliPath).toBeDefined() expect(remedy.cliPath).toMatch(/^skills\/[^\s]+\/cli\/[a-z0-9-]+\/index\.ts$/) expect(resolveCliPath(remedy.cliPath as string)).not.toBeNull() expect(remedy.writeMode).toBeDefined() expect(remedy.bind?.length ?? 0).toBeGreaterThan(0) }, ) it.each(rows.filter(([, r]) => !isExecutableLane(r.lane)))( '%s (non-executable) carries no CLI to run', (_id, remedy: RemedyRef) => { expect(remedy.cliPath).toBeUndefined() expect(remedy.writeMode).toBeUndefined() }, ) it('keys look like audit rule codes', () => { for (const [id] of rows) expect(id).toMatch(/^[A-Z][A-Z-]*-\d{3}$/) }) }) describe('remediation:v1 — installer-safe cliPaths', () => { // The source module must keep `skills/business-analyse/` CONTIGUOUS so the // installer's String.replace can re-point it at `skills/ba-*`. A path built // with join()/fragments would pass every other test here and still 404 once // deployed. it('keeps the BA prefix contiguous in the source module', () => { const src = readFileSync(join(skillsRoot, 'lib', 'remediation.ts'), 'utf8') const baRows = rows.filter(([, r]) => r.cliPath?.startsWith('skills/business-analyse/')) if (baRows.length === 0) return // already deployed/flattened tree expect(src).toContain('skills/business-analyse/') }) }) describe('remediation:v1 — authoring fallback', () => { it('covers every audit-ba dimension explicitly', () => { for (const dim of AUDIT_BA_DIMENSIONS) { expect(Object.prototype.hasOwnProperty.call(DIMENSION_AUTHORING_SKILL, dim)).toBe(true) } }) it('names the create-* skills by their real slugs, not by the dimension name', () => { // The whole reason this table exists: `ba-create-${dimension}` is WRONG. expect(DIMENSION_AUTHORING_SKILL['use-cases']).toBe('ba-create-use-case') expect(DIMENSION_AUTHORING_SKILL['rules']).toBe('ba-create-business-rules') expect(DIMENSION_AUTHORING_SKILL['sections']).toBe('ba-create-menu') expect(DIMENSION_AUTHORING_SKILL['sources']).toBe('ba-create-sources') expect(DIMENSION_AUTHORING_SKILL['screens']).toBe('ba-create-screen') }) it('points every named skill at a deployed skill folder', () => { for (const skill of Object.values(DIMENSION_AUTHORING_SKILL)) { if (skill === null) continue const authored = join(skillsRoot, 'business-analyse', skill.replace(/^ba-/, '')) const deployed = join(skillsRoot, skill) expect(existsSync(authored) || existsSync(deployed)).toBe(true) } }) it('leaves cross-dimension without an owning skill', () => { expect(DIMENSION_AUTHORING_SKILL['cross-dimension']).toBeNull() }) }) describe('remediation:v1 — no orphan row', () => { // The plan's "filet qui empêche la pourriture": a registry row whose rule no // longer exists anywhere is a remedy pointing at nothing. Checked against the // audit-ba rule sources AND the audit SKILL.md rule specs (PRD-* live only in // the latter — /ba-audit-prd has no CLI). const RULE_SOURCES = [ 'business-analyse/audit-run/cli/audit-ba/rules', 'business-analyse/audit-run/cli/audit-ba/render/report-json.ts', 'business-analyse/audit-prd/SKILL.md', 'business-analyse/audit-sections/SKILL.md', 'business-analyse/audit-use-cases/SKILL.md', 'business-analyse/audit-rbac/SKILL.md', ] function corpus(): string { let out = '' for (const rel of RULE_SOURCES) { const abs = join(skillsRoot, rel) if (!existsSync(abs)) continue if (statSync(abs).isDirectory()) { for (const f of readdirSync(abs)) out += readFileSync(join(abs, f), 'utf8') } else { out += readFileSync(abs, 'utf8') } } return out } it.each(rows)('%s names a rule that still exists', (id) => { expect(corpus()).toContain(id) }) }) describe('remediation:v1 — rewrite-capable audits', () => { it('names only audits whose own --mode apply really rewrites', () => { expect([...REWRITE_CAPABLE_AUDITS]).toEqual(['audit-dev-frontend']) }) it('points at an audit CLI that exists', () => { for (const a of REWRITE_CAPABLE_AUDITS) { expect(existsSync(join(skillsRoot, 'development', a, 'cli', a, 'index.ts'))).toBe(true) } }) }) describe('remediation:v1 — resolveRemedy', () => { it('prefers the registry over the dimension fallback', () => { expect(resolveRemedy('RBAC-008', 'rbac').lane).toBe('derive') expect(resolveRemedy('RBAC-008', 'rbac').target).toBe('derive-lookup-grants') }) it('falls back to the dimension owner for an unregistered rule', () => { const r = resolveRemedy('UC-007', 'use-cases') expect(r.lane).toBe('authoring') expect(r.target).toBe('ba-create-use-case') }) it('returns manual — never undefined — for an unknown rule and dimension', () => { const r = resolveRemedy('ZZZ-999', 'nope') expect(r.lane).toBe('manual') expect(r.solution.length).toBeGreaterThan(0) }) it('returns manual for a dimension with no owning skill', () => { expect(resolveRemedy('XD-002', 'cross-dimension').lane).toBe('manual') }) }) describe('remediation:v1 — dev finding adapter', () => { it('maps autoFixable to the rewrite lane, keyed by the PRODUCING AUDIT', () => { // The bug this locks: keying on `fixSkill` made the lane unreachable. // audit-dev-frontend emits fixSkill values like `frontend-component` — // never its own name — so every autoFixable finding fell through to // "no in-place executor" and nothing was ever rewritten. const r = remedyFromDevFinding({ code: 'DEV-UI-014', source: 'audit-dev-frontend', solution: 'Use the token.', fixSkill: 'frontend-component', autoFixable: true, }) expect(r.lane).toBe('rewrite') expect(r.target).toBe('audit-dev-frontend') }) it('falls back to scaffold when the producing audit cannot rewrite', () => { const r = remedyFromDevFinding({ code: 'DEV-API-030', source: 'audit-dev-api', fixSkill: 'backend-controller', autoFixable: true, }) expect(r.lane).toBe('scaffold') expect(r.target).toBe('backend-controller') }) it('never claims rewrite without knowing which audit found it', () => { const r = remedyFromDevFinding({ code: 'DEV-UI-014', fixSkill: 'frontend-component', autoFixable: true }) expect(r.lane).toBe('scaffold') }) it('maps a fixSkill without autoFixable to the scaffold lane', () => { const r = remedyFromDevFinding({ code: 'DEV-API-026', solution: 'Re-run scaffold-business.', fixSkill: 'backend-business-layer', }) expect(r.lane).toBe('scaffold') expect(r.target).toBe('backend-business-layer') }) it('maps a finding with neither to manual, keeping its solution', () => { const r = remedyFromDevFinding({ code: 'DEV-TEST-004', solution: 'Write a real assertion.' }) expect(r.lane).toBe('manual') expect(r.solution).toBe('Write a real assertion.') }) it('lets the registry override the dev finding fields', () => { const r = remedyFromDevFinding({ code: 'PRD-113', fixSkill: 'whatever', autoFixable: true }) expect(r.lane).toBe('derive') expect(r.target).toBe('derive-filter-fks') }) })