/** * impact.test.ts — the ordered checklists: shape invariants + the corpus-driven * decisions (candidates, skipped prd steps, developed hand-off). */ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { entityViewsFromPagespecFilenames } from '../../../../../lib/detail-tab-strip.js' import type { ParsedScreen } from '../../../../../lib/ba-screens.js' import { loadScopeCorpus } from '../corpus.js' import { executeChangeImpact } from '../execute.js' import { buildImpact, expectedPagespecName, pagespecFor } from '../impact.js' import { ChangeImpactInputSchema, IMPLEMENTED_KINDS, type ChangeImpactInput, type ImpactStep } from '../types.js' import { SCR_DETAIL, SCR_LIST, UC_PREFIX, writeChangeCorpus } from './fixture.js' let tmp: string let root: string beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'ba-change-impact-')) root = join(tmp, 'ba') }) afterEach(() => rmSync(tmp, { recursive: true, force: true })) const specOf = (over: Partial & { kind: ChangeImpactInput['kind'] }): ChangeImpactInput => ChangeImpactInputSchema.parse({ baRoot: root, app: 'CRM', module: 'PIPELINE', ...over }) const run = (over: Partial & { kind: ChangeImpactInput['kind'] }, section?: string) => { const spec = specOf(over) const corpus = loadScopeCorpus({ baRoot: root, app: 'CRM', module: 'PIPELINE', ...(section ? { section } : {}) }) return executeChangeImpact(spec, corpus) } const CLI_PATH_RE = /^skills\/(business-analyse\/[a-z-]+|ba-develop)\/cli\/[a-z0-9-]+\/index\.ts$/ const GATE_RE = /^[A-Z]+(-[A-Z]+)*-\d{3}$/ function assertShape(steps: ImpactStep[]): void { expect(steps.length).toBeGreaterThan(0) steps.forEach((s, i) => { expect(s.step).toBe(i + 1) if (s.cli) expect(s.cli.cliPath).toMatch(CLI_PATH_RE) for (const g of s.gateRules) expect(g).toMatch(GATE_RE) if (s.status === 'skipped') expect(s.skipReason).toBeTruthy() if (s.status === 'conditional') expect(s.condition).toBeTruthy() }) expect(steps[steps.length - 2]!.phase).toBe('audit') expect(steps[steps.length - 1]!.phase).toBe('handoff') } describe('shape invariants — every implemented kind × op', () => { it.each(IMPLEMENTED_KINDS.flatMap((k) => (['add', 'modify'] as const).map((op) => [k, op] as const)))( '%s / %s', (kind, op) => { writeChangeCorpus(root, { withSnapshot: true }) const section = kind === 'use-case' || kind === 'screen' ? 'opportunites' : undefined const addTarget: Record> = { attribute: { entity: 'Opportunity', attribute: 'Discount' }, // « Discount » is in BR-001's Expression → trace found entity: { entity: 'Prospect' }, // « le prospect existe » in the UC text → trace found screen: { entity: 'Opportunity', screenType: 'SmartForm', mode: 'create', title: 'Créer une opportunité (formulaire)' }, } const modifyTarget: Record> = { 'use-case': { code: `${UC_PREFIX}001` }, 'business-rule': { code: 'BR-001' }, actor: { code: 'BA-001-AC-001' }, permission: { actor: 'BA-001-AC-001', permissionPath: 'pipeline.opportunites.read' }, attribute: { entity: 'Opportunity', attribute: 'Amount' }, entity: { code: 'ENT-001' }, screen: { code: SCR_LIST }, } const target = op === 'modify' ? modifyTarget[kind] ?? {} : addTarget[kind] ?? {} const report = run({ kind, op, target }, section) expect(report.blocked).toEqual([]) assertShape(report.impact) }, ) }) describe('use-case', () => { it('candidates = screens of the section, with their pagespec joined on screenCode', () => { writeChangeCorpus(root) const report = run({ kind: 'use-case', target: { title: 'Relancer un prospect', actor: 'BA-001-AC-001' } }, 'opportunites') const surface = report.impact.find((s) => s.gateRules.includes('SCR-024'))! const candidates = surface.data!['candidates'] as Array<{ code: string; pagespec: string | null }> expect(candidates.map((c) => c.code)).toEqual([SCR_LIST, SCR_DETAIL]) expect(candidates[0]!.pagespec).toBe('Opportunity.list.md') expect(candidates[1]!.pagespec).toBe('Opportunity.detail.md') }) it('a known actor skips the precondition; a named-but-unknown actor is blocked upstream', () => { writeChangeCorpus(root) const ok = run({ kind: 'use-case', target: { actor: 'Commercial' } }, 'opportunites') expect(ok.impact[0]!.status).toBe('skipped') const ko = run({ kind: 'use-case', target: { actor: 'Assistant' } }, 'opportunites') expect(ko.blocked.map((b) => b.code)).toEqual(['actor-not-found']) expect(ko.blocked[0]!.routeTo).toEqual({ skill: 'ba-change', kind: 'actor' }) expect(ko.impact).toEqual([]) }) it('no pagespecs → every prd step skipped with a reason, derive-rule-links skipped', () => { writeChangeCorpus(root, { withPagespecs: false }) const report = run({ kind: 'use-case' }, 'opportunites') const prd = report.impact.filter((s) => s.phase === 'prd') expect(prd.length).toBeGreaterThan(0) for (const s of prd) { expect(s.status).toBe('skipped') expect(s.skipReason).toMatch(/no pagespecs/) } expect(report.impact.find((s) => s.cli?.cliPath.includes('derive-rule-links'))!.status).toBe('skipped') }) it('not developed → hand-off skipped; developed → required with re-entries', () => { writeChangeCorpus(root) const cold = run({ kind: 'use-case' }, 'opportunites') expect(cold.impact.at(-1)!.status).toBe('skipped') rmSync(tmp, { recursive: true, force: true }) writeChangeCorpus(root, { withSnapshot: true }) const hot = run({ kind: 'use-case' }, 'opportunites') const handoff = hot.impact.at(-1)! expect(handoff.status).toBe('required') expect((handoff.data!['reentries'] as string[]).length).toBeGreaterThan(0) expect(handoff.data!['force']).toBe(false) }) it('op=modify adds the AC-parity gate (DEV-TEST-003) and allocates nothing', () => { writeChangeCorpus(root) const report = run({ kind: 'use-case', op: 'modify', target: { code: `${UC_PREFIX}002` } }, 'opportunites') expect(report.allocation).toBeNull() expect(report.impact.find((s) => s.phase === 'author')!.gateRules).toContain('DEV-TEST-003') }) it('the mandatory derive-lookup-grants post-step is a writing CLI in derive mode', () => { writeChangeCorpus(root) const report = run({ kind: 'use-case' }, 'opportunites') const step = report.impact.find((s) => s.cli?.cliPath.includes('derive-lookup-grants'))! expect(step.cli!.writes).toBe(true) expect(step.cli!.spec['mode']).toBe('derive') }) }) describe('business-rule', () => { it('an unknown linked UC blocks with a route to kind=use-case', () => { writeChangeCorpus(root) const report = run({ kind: 'business-rule', target: { useCase: `${UC_PREFIX}001, ${UC_PREFIX}077` } }) expect(report.blocked.map((b) => b.code)).toEqual(['use-case-not-found']) expect(report.blocked[0]!.routeTo).toEqual({ skill: 'ba-change', kind: 'use-case' }) }) it('an access rule skips the PRD link (RBAC channel); a numbering rule checks the Code pattern', () => { writeChangeCorpus(root) const access = run({ kind: 'business-rule', target: { ruleType: 'access' } }) expect(access.impact.find((s) => s.phase === 'prd')!.status).toBe('skipped') const numbering = run({ kind: 'business-rule', target: { ruleType: 'numbering' } }) const typed = numbering.impact.find((s) => s.gateRules.includes('PRD-132'))! expect(typed.cli!.cliPath).toBe('skills/ba-develop/cli/derive-code-specs/index.ts') }) it('a section-scoped rule owns the section doc; allocation stays module-wide', () => { writeChangeCorpus(root) const report = run({ kind: 'business-rule' }, 'opportunites') expect(report.owner.file).toBe('CRM/PIPELINE/opportunites/règles-métier.md') expect(report.allocation!.next).toBe('BR-004') }) }) describe('expectedPagespecName', () => { const screen = (over: Partial): ParsedScreen => ({ code: 'SCR-X-Y-Z-001', title: 't', screenType: 'SmartListView', file: 'X/Y/z/screen.md', app: 'X', module: 'Y', section: 'z', declaredTabs: [], fieldTabLabels: [], ...over, }) it('maps the SmartComponent (+mode) to the create-prd view and folds Kanban/Card', () => { expect(expectedPagespecName(screen({ entity: 'Client' }))).toBe('Client.list.md') expect(expectedPagespecName(screen({ entity: 'Client', screenType: 'SmartForm' }))).toBe('Client.form.md') expect(expectedPagespecName(screen({ entity: 'Client', screenType: 'SmartForm', mode: 'detail' }))).toBe('Client.detail.md') expect(expectedPagespecName(screen({ entity: 'Client', screenType: 'SmartDashboard' }))).toBe('Client.dashboard.md') expect(expectedPagespecName(screen({ entity: 'Client', screenType: 'SmartKanban' }))).toBeNull() expect(expectedPagespecName(screen({ entity: 'Client', screenType: 'SmartCard' }))).toBeNull() expect(expectedPagespecName(screen({ screenType: 'SmartListView' }))).toBeNull() }) it('agrees with lib/detail-tab-strip entityViewsFromPagespecFilenames', () => { const names = ['Client.list.md', 'Client.form.md', 'Client.detail.md', 'Client.dashboard.md'] const views = entityViewsFromPagespecFilenames(names).get('Client')! for (const n of names) expect(views.has(n.split('.')[1]!)).toBe(true) }) it('pagespecFor joins on screenCode first, falls back to the expected name', () => { writeChangeCorpus(root) const corpus = loadScopeCorpus({ baRoot: root, app: 'CRM', module: 'PIPELINE' }) const list = corpus.screens.find((s) => s.code === SCR_LIST)! expect(pagespecFor(corpus, list)).toEqual({ name: 'Opportunity.list.md', exists: true, joinedOnScreenCode: true }) const client = corpus.screens.find((s) => s.code === 'SCR-CRM-PIPELINE-CLIENTS-001')! expect(pagespecFor(corpus, client)).toEqual({ name: 'Client.list.md', exists: false, joinedOnScreenCode: false }) }) it('buildImpact builds a checklist for EVERY kind, even on a bare context (placeholders, never a crash)', () => { writeChangeCorpus(root) const corpus = loadScopeCorpus({ baRoot: root, app: 'CRM', module: 'PIPELINE' }) for (const kind of IMPLEMENTED_KINDS) { const steps = buildImpact({ corpus, kind, op: 'add', target: {}, allocation: null, ownerFile: 'CRM/PIPELINE/x.md' }) expect(steps.length, kind).toBeGreaterThan(0) expect(steps.at(-1)!.phase).toBe('handoff') } }) })