/** * cli:derive-change-impact — impact.ts * * The ORDERED downstream checklist of each kind — the heart of `/ba-change`, * as DATA. Each step names the file and the owning skill sub-workflow of an * authoring decision, or the derive-* CLI to run (contiguous literal path + * spec + whether it writes), the reason, and the audit rule(s) that sanction * the omission. Conditions the corpus cannot decide stay `conditional` with a * human `condition` — never a guessed verdict. * * Every `skills/business-analyse/…` path below is ONE contiguous literal: the * installer re-points it at the flattened `skills/ba-*` with a plain * String.replace (lib/remediation.ts doctrine; deploy-paths.test.ts locks it). * `skills/ba-develop/…` deploys as-is. */ import type { ParsedScreen } from '../../../../lib/ba-screens.js' import { effectiveMode } from '../../../../lib/ba-screens.js' import { FLOOR_BY_GRAIN, PERMISSION_ACTIONS, parsePermissionPath } from '../../../../lib/permission-actions.js' import type { ScopeCorpus } from './corpus.js' import { canonicalPortee } from './existing.js' import type { Allocation, ChangeKind, ChangeOp, ChangeTarget, ExistingMatch, ExistingReport, ImpactStep, TraceReport } from './types.js' export const CLI_PATHS = { deriveLookupGrants: 'skills/business-analyse/create-rbac/cli/derive-lookup-grants/index.ts', derivePermissionFloor: 'skills/business-analyse/create-rbac/cli/derive-permission-floor/index.ts', deriveRbacGrants: 'skills/business-analyse/create-rbac/cli/derive-rbac-grants/index.ts', deriveRuleLinks: 'skills/business-analyse/create-prd/cli/derive-rule-links/index.ts', deriveLifecycle: 'skills/business-analyse/create-prd/cli/derive-lifecycle/index.ts', deriveKanbanSpec: 'skills/business-analyse/create-prd/cli/derive-kanban-spec/index.ts', deriveUcCoverage: 'skills/business-analyse/create-screen/cli/derive-uc-coverage/index.ts', deriveRelatedTabs: 'skills/business-analyse/create-screen/cli/derive-related-tabs/index.ts', auditBa: 'skills/business-analyse/audit-run/cli/audit-ba/index.ts', sourcesSearch: 'skills/business-analyse/create-sources/cli/search/index.ts', deriveFormSections: 'skills/business-analyse/create-prd/cli/derive-form-sections/index.ts', deriveDetailSummary: 'skills/business-analyse/create-prd/cli/derive-detail-summary/index.ts', deriveCodeSpecs: 'skills/ba-develop/cli/derive-code-specs/index.ts', deriveFilterFks: 'skills/ba-develop/cli/derive-filter-fks/index.ts', deriveNavResources: 'skills/ba-develop/cli/derive-nav-resources/index.ts', } as const /** Mirror of `lib/detail-tab-strip.ts` PAGESPEC_FILENAME_RE (private there). */ export const PAGESPEC_FILENAME_RE = /^([A-Z][A-Za-z0-9]*)\.([a-z][a-z-]*)\.md$/ /** * The pagespec view a screen folds into (create-prd/SKILL.md § pagespecs): * list / form / detail / dashboard / app-home / module-home / section-home; * SmartKanban and SmartCard fold into the LIST pagespec (no file of their own). */ export function pagespecViewOf(screen: ParsedScreen): string | null { return pagespecViewFor(screen.screenType, effectiveMode(screen)) } /** Expected `..md` of a screen that does NOT exist yet (kind=screen). */ export function expectedPagespecNameFor(screenType: string, entity: string | undefined, mode: string | undefined): string | null { const view = pagespecViewFor(screenType, mode ?? 'edit') if (!view || !entity) return null const name = `${entity}.${view}.md` return PAGESPEC_FILENAME_RE.test(name) ? name : null } export function pagespecViewFor(screenType: string, mode: string): string | null { switch (screenType) { case 'SmartListView': return 'list' case 'SmartForm': return mode === 'detail' ? 'detail' : 'form' case 'SmartDashboard': return 'dashboard' case 'SmartAppHome': return 'app-home' case 'SmartModuleHome': return 'module-home' case 'SmartSectionHome': return 'section-home' default: return null } } /** Expected `..md` name of a screen's pagespec — null when the type folds or has no entity. */ export function expectedPagespecName(screen: ParsedScreen): string | null { const view = pagespecViewOf(screen) if (!view || !screen.entity) return null const name = `${screen.entity}.${view}.md` return PAGESPEC_FILENAME_RE.test(name) ? name : null } /** The pagespec of an existing screen: joined on `screenCode` verbatim (authoritative), else the expected name. */ export function pagespecFor(corpus: ScopeCorpus, screen: ParsedScreen): { name: string | null; exists: boolean; joinedOnScreenCode: boolean } { const byCode = (corpus.pagespecs ?? []).find((p) => p.block && p.block['screenCode'] === screen.code) if (byCode) return { name: byCode.name, exists: true, joinedOnScreenCode: true } const expected = expectedPagespecName(screen) const exists = expected !== null && (corpus.pagespecs ?? []).some((p) => p.name === expected) return { name: expected, exists, joinedOnScreenCode: false } } export interface ImpactContext { corpus: ScopeCorpus kind: ChangeKind op: ChangeOp target: ChangeTarget sectionFolder?: string allocation: Allocation | null resolved?: ExistingMatch /** The duplicate report — actor / permission / screen preconditions read it. */ existing?: ExistingReport /** The upstream-trace report — attribute / entity preconditions read it. */ trace?: TraceReport /** Owner doc, relative to baRoot. */ ownerFile: string } /** The SmartComponent types a screen heading may carry. */ export const SCREEN_TYPES = ['SmartListView', 'SmartForm', 'SmartDashboard', 'SmartKanban', 'SmartCard', 'SmartAppHome', 'SmartModuleHome', 'SmartSectionHome'] as const /** The create-screen level file of a screen type. */ export function levelFileOf(screenType: string): string { switch (screenType) { case 'SmartListView': case 'SmartCard': return 'levels/list-screens.md' case 'SmartForm': return 'levels/form-screens.md' case 'SmartDashboard': return 'levels/dashboard-screens.md' case 'SmartKanban': return 'levels/kanban-screens.md' default: return 'levels/home-screens.md' } } const auditSpec = (c: ScopeCorpus, dimensions: string[]): Record => ({ baRoot: '', projectRoot: '.', scope: { app: c.scope.app, module: c.scope.module }, dimensions, }) const codeOf = (ctx: ImpactContext): string => ctx.allocation?.next ?? ctx.resolved?.code ?? ctx.target.code ?? '' function auditStep(step: number, ctx: ImpactContext, dimensions: string[]): ImpactStep { const { corpus } = ctx return { step, phase: 'audit', title: `Audit the ${dimensions.join(', ')} dimension(s) of ${corpus.scope.app}/${corpus.scope.module}${corpus.state.pagespecsPresent ? ' + /ba-audit-prd' : ''}`, cli: { cliPath: CLI_PATHS.auditBa, spec: auditSpec(corpus, dimensions), writes: true }, why: 'The audits are the verdict — a step above that was skipped shows up here as an err with its remedy.', gateRules: [], status: 'required', data: { writesOnly: '_audit/*.md verdicts', ...(corpus.state.pagespecsPresent ? { alsoRun: '/ba-audit-prd (conversational — no CLI) on the module, dev-ready score ≥ 80' } : { note: 'No pagespecs/ — /ba-audit-prd has nothing to read yet.' }), }, } } function handoffStep(step: number, ctx: ImpactContext, reentries: string[]): ImpactStep { const { corpus } = ctx const developed = corpus.state.developed return { step, phase: 'handoff', title: `Hand off to /ba-develop ${corpus.scope.app}/${corpus.scope.module} (no --force)`, skill: '/ba-develop', why: 'Phases 0-2 re-enter on a pre-entry coverage ERR and add only what is missing; Phase 3 regenerates the pagespec delta (compute-page-diff); Phase 4 always re-runs.', gateRules: [], status: developed ? 'required' : 'skipped', ...(developed ? {} : { skipReason: 'not developed yet (no .run-snapshot.json / core-seed state) — nothing to re-enter; run /ba-develop when the module is ready.' }), data: { reentries, force: false }, } } // --------------------------------------------------------------------------- // use-case // --------------------------------------------------------------------------- function useCaseImpact(ctx: ImpactContext): ImpactStep[] { const { corpus, op, target } = ctx const code = codeOf(ctx) const section = ctx.sectionFolder ?? '
' const sectionFile = `${corpus.scope.app}/${corpus.scope.module}/${section}` const rulesFile = `${corpus.scope.app}/${corpus.scope.module}/règles-métier.md` const steps: ImpactStep[] = [] let n = 1 const appActors = corpus.actors.find((a) => a.app.toLowerCase() === corpus.scope.app.toLowerCase()) const actorKnown = target.actor ? (appActors?.actors ?? []).some( (a) => a.code.toLowerCase() === target.actor!.toLowerCase() || a.label.toLowerCase() === target.actor!.toLowerCase(), ) : undefined steps.push({ step: n++, phase: 'precondition', title: 'The primary actor exists in acteur.md', file: `${corpus.scope.app}/acteur.md`, why: 'A UC cannot cite an actor that does not exist — never SCREAMING_SNAKE a label into a fake code (create-use-case Actors gate).', gateRules: ['UC-008'], status: actorKnown === true ? 'skipped' : actorKnown === false ? 'required' : 'conditional', ...(actorKnown === true ? { skipReason: `actor "${target.actor}" resolved in ${corpus.scope.app}/acteur.md` } : actorKnown === false ? { condition: `actor "${target.actor}" is NOT in acteur.md — run /ba-change kind=actor first` } : { condition: 'name the primary actor; a new role → /ba-change kind=actor first' }), data: { actors: (appActors?.actors ?? []).map((a) => `${a.code} (${a.label})`) }, }) steps.push({ step: n++, phase: 'author', title: op === 'add' ? `Write the UC block ${code}` : `Rewrite the UC block ${code} — never renumber an AC`, file: `${sectionFile}/use-case.md`, skill: 'ba-create-use-case', subWorkflow: 'Phase 2 (detail) — levels/detail.md, ONE UC', why: op === 'add' ? 'Level in the heading parenthetical, ≥ 1 AC, one AC per EXC-N — the AC contract feeds the Phase 4 [Fact]s.' : 'An AC renumbered or removed leaves a stale [Trait("AC",…)] behind — a green test on a deleted assertion (DEV-TEST-003). Add new ACs at the END; keep every existing id.', gateRules: op === 'add' ? ['UC-012', 'UC-022'] : ['UC-012', 'UC-022', 'DEV-TEST-003'], status: 'required', ...(corpus.state.sourcesPresent ? { data: { sources: 'registry present — cite `- **Sources** : SRC-NNN §n` (search via ' + CLI_PATHS.sourcesSearch + ')' } } : {}), }) steps.push({ step: n++, phase: 'author', title: `Business rules for ${code} — link existing rules or author new ones`, file: rulesFile, skill: 'ba-create-business-rules', subWorkflow: 'Single-UC entry point', why: 'Every use case must be covered by ≥ 1 rule — a UC without rules cannot be vibecoded (no invariants, no validation, no failure paths).', gateRules: ['BR-007', 'BR-006'], status: 'required', data: { rulesInModule: corpus.rules.length }, }) const rbacRows = corpus.rbac.rows const actorRows = target.actor ? rbacRows.filter((r) => r.actorCode.toLowerCase() === target.actor!.toLowerCase() || (r.actorLabel ?? '').toLowerCase() === target.actor!.toLowerCase()) : [] steps.push({ step: n++, phase: 'check', title: 'The actor holds ≥ 1 permission row in rbac.md; a new business action needs its own row', file: `${corpus.scope.app}/${corpus.scope.module}/rbac.md`, skill: 'ba-create-rbac', why: 'An actor that performs use cases but holds no permission cannot be authorised to do anything.', gateRules: ['XD-004', 'RBAC-004', 'RBAC-005'], status: !target.actor ? 'conditional' : actorRows.length > 0 ? 'conditional' : 'required', condition: !target.actor ? 'name the actor first — then: ≥ 1 row in rbac.md, plus a row for any new action the UC introduces → /ba-change kind=permission' : actorRows.length > 0 ? `actor holds ${actorRows.length} row(s) — add a row only if the UC introduces a new action (approve, export, …) → /ba-change kind=permission` : 'no row found for the actor — author the access+read floor grants and the action the UC needs → /ba-change kind=permission', data: { actorRows: actorRows.map((r) => `${r.path} (${r.portee})`) }, }) const sectionScreens = corpus.screens.filter( (s) => s.section.toLowerCase() === (ctx.sectionFolder ?? '').toLowerCase() && (!target.entity || s.entity === target.entity), ) steps.push({ step: n++, phase: 'author', title: `A surface serves ${code}: an existing screen gains it in \`- **Cas d'usage liés**\` or an action line \`UC: ${code}\`, else a new screen`, file: `${sectionFile}/screen.md`, skill: 'ba-create-screen', subWorkflow: '§ Custom actions (create-screen/SKILL.md) or /ba-change kind=screen', why: 'A user-goal UC served by no screen, no custom action and no scheduled runtime is a phantom: green everywhere, failing [Fact]s later with nothing to point at.', gateRules: ['SCR-024', 'SCR-005'], status: 'required', data: { candidates: sectionScreens.map((s) => ({ code: s.code, type: s.screenType, entity: s.entity ?? null, pagespec: pagespecFor(corpus, s).name })), exemptWhen: 'level subfunction/summary, or the UC is `scheduled` (no actor)', }, }) steps.push({ step: n++, phase: 'check', title: 'Data-model trace: every entity / attribute the UC names exists in entité.md', file: `${corpus.scope.app}/${corpus.scope.module}/entité.md`, skill: 'ba-create-data-model', subWorkflow: 'Step 0 — trace audit (the UC IS the upstream trace)', why: 'A flow that names a field the model does not carry is dead prose; a new FK changes the lookup grants.', gateRules: ['DM-011', 'DM-025', 'RBAC-008'], status: 'conditional', condition: 'if the UC names an attribute or entity absent from entité.md → enrich the model, then run the MANDATORY post-step below', cli: { cliPath: CLI_PATHS.deriveLookupGrants, spec: { baRoot: '', app: corpus.scope.app, module: corpus.scope.module, mode: 'derive' }, writes: true, }, data: { entities: corpus.entities?.entities.map((e) => `${e.code} ${e.name}`) ?? [], runCliOnlyIf: 'entité.md was written' }, }) const pagespecTargets = sectionScreens.map((s) => pagespecFor(corpus, s)).filter((p) => p.name !== null) steps.push({ step: n++, phase: 'prd', title: `Pagespec delta: \`linkedUseCases[]\` += ${code} (and an \`actions[]\` entry with \`ucReference\` for a custom action) — inside the fenced json block ONLY`, file: `${corpus.scope.app}/${corpus.scope.module}/pagespecs/..md`, skill: 'ba-create-prd', subWorkflow: '§ Single-pagespec entry point (edit the machine block; never touch uiDesign / lifecycle / other keys)', why: 'PRD leg of UC coverage. NEVER re-run /ba-create-prd on a module that has pagespecs: it rewrites every pagespec and drops the uiDesign overlay + lifecycle block /ui-design wrote.', gateRules: ['PRD-131', 'PRD-097'], status: corpus.state.pagespecsPresent ? 'required' : 'skipped', ...(corpus.state.pagespecsPresent ? {} : { skipReason: 'no pagespecs/ — the module has no PRD yet; /ba-create-prd is legitimate here (nothing to lose).' }), data: { pagespecs: pagespecTargets, prdApi: 'prd.api.md gains the handler bullet of a `kind: api` action' }, }) steps.push({ step: n++, phase: 'derive', title: 'Backfill the pagespec `linkedBusinessRules[]` from the rules authored above', cli: { cliPath: CLI_PATHS.deriveRuleLinks, spec: { baRoot: '', app: corpus.scope.app, module: corpus.scope.module, mode: 'backfill' }, writes: true, }, why: 'THE field DEV-API-008 reads — a rule linked nowhere ships unenforced with the gate green. `needs-judgment` = author the link by hand, never an invented one.', gateRules: ['PRD-129', 'PRD-130'], status: corpus.state.pagespecsPresent ? 'required' : 'skipped', ...(corpus.state.pagespecsPresent ? {} : { skipReason: 'no pagespecs/ yet.' }), }) steps.push({ step: n++, phase: 'check', title: `Prove the coverage: ${code} is \`covered\` on the BA leg (and the PRD leg when pagespecs exist)`, cli: { cliPath: CLI_PATHS.deriveUcCoverage, spec: { baRoot: '', app: corpus.scope.app, module: corpus.scope.module }, writes: false }, why: 'Proof, not prose — the engine of SCR-024 / PRD-131 / UC-022.', gateRules: ['SCR-024', 'PRD-131', 'UC-022'], status: 'required', }) steps.push({ step: n++, phase: 'check', title: 'If a rule authored above is workflow / state-transition: the form lifecycle still parses', cli: { cliPath: CLI_PATHS.deriveLifecycle, spec: { moduleRoot: `/${corpus.scope.app}/${corpus.scope.module}`, pagespecDir: `/${corpus.scope.app}/${corpus.scope.module}/pagespecs`, mode: 'check' }, writes: false, }, why: 'A new transition changes which fields a phase owns.', gateRules: ['PRD-120', 'PRD-121', 'XD-007'], status: corpus.state.pagespecsPresent ? 'conditional' : 'skipped', ...(corpus.state.pagespecsPresent ? { condition: 'a rule of type workflow or state-transition was authored' } : { skipReason: 'no pagespecs/ yet.' }), }) steps.push(auditStep(n++, ctx, ['use-cases', 'rules', 'rbac', 'screens', 'cross-dimension'])) steps.push( handoffStep(n++, ctx, [ 'Phase 2a — DEV-API-009 (UC actions) / DEV-API-010 (custom action coverage) when an action was added', 'Phase 2b — DEV-API-012 when the action lives on a screen controller', 'Phase 3 — the modified pagespec lands in compute-page-diff.toRegenerate', 'Phase 4 — always: one [Fact] per new AC (DEV-TEST-001/008)', ]), ) return steps } // --------------------------------------------------------------------------- // business-rule // --------------------------------------------------------------------------- function ruleImpact(ctx: ImpactContext): ImpactStep[] { const { corpus, op, target } = ctx const code = codeOf(ctx) const ruleType = (target.ruleType ?? '').toLowerCase() const steps: ImpactStep[] = [] let n = 1 const wantedUcs = (target.useCase ?? '') .split(/[,\s]+/) .map((s) => s.trim()) .filter(Boolean) const knownUcs = new Set(corpus.useCaseDocs.flatMap((d) => d.parsed.ucs.map((u) => u.ucCode.toLowerCase()))) const missingUcs = wantedUcs.filter((u) => !knownUcs.has(u.toLowerCase())) steps.push({ step: n++, phase: 'precondition', title: 'Every linked use case (`Cas d\'usage liés`) exists in the module', why: 'Never fabricate a reference (create-business-rules prohibition 3). No BA audit rule checks that a cited UC resolves (BR-005 is retired, BR-006 covers only rule → ≥ 1 UC) — this CLI carries the check.', gateRules: ['BR-006'], status: missingUcs.length > 0 ? 'required' : wantedUcs.length > 0 ? 'skipped' : 'conditional', ...(missingUcs.length > 0 ? { condition: `unknown UC code(s): ${missingUcs.join(', ')} — run /ba-change kind=use-case first` } : wantedUcs.length > 0 ? { skipReason: `${wantedUcs.length} linked UC(s) resolved` } : { condition: 'name the UC(s) the rule governs (a rule with no UC is BABOK-legal but unenforceable — see BR-006)' }), data: { linkedUcs: wantedUcs, knownUcs: knownUcs.size }, }) steps.push({ step: n++, phase: 'author', title: op === 'add' ? `Write the rule ${code}` : `Rewrite the rule ${code}`, file: ctx.ownerFile, skill: 'ba-create-business-rules', subWorkflow: 'Single-UC entry point (Level 2 fields: Type, Sévérité, Portée, Condition, Expression, Code d\'erreur, ≥ 1 valid AND ≥ 1 invalid example, Cas d\'usage liés)', why: 'Rules feed FluentValidation + form errors + the BR-tagged tests; a rule without both examples stays a Level 1 draft.', gateRules: ['BR-002', 'BR-003', 'BR-004', 'BR-008', 'BR-010', 'BR-011'], status: 'required', ...(corpus.state.sourcesPresent ? { data: { sources: 'registry present — cite `- **Sources** : SRC-NNN §n` (search via ' + CLI_PATHS.sourcesSearch + ')' } } : {}), }) const isFlow = ruleType === 'workflow' || ruleType === 'state-transition' const isAccess = ruleType === 'access' || ruleType === 'ownership' const isNumbering = ruleType === 'numbering' steps.push({ step: n++, phase: 'check', title: 'Type-specific consequences: Flow lines ⇢ status enum; access ⇢ RBAC matrix; numbering ⇢ Code pattern', file: `${corpus.scope.app}/${corpus.scope.module}/entité.md`, why: 'A workflow rule names statuses that must exist in the entity enum (else /ba-change kind=attribute); an access rule is exempt from the PRD link but the MATRIX must carry it (/ba-change kind=permission); a numbering rule is the SPEC of the entity `**Code pattern**` (never a hand-rolled allocator).', gateRules: isFlow ? ['XD-001', 'XD-002', 'XD-003', 'DM-026'] : isAccess ? ['RBAC-001'] : isNumbering ? ['DM-017', 'PRD-132'] : ['XD-001', 'DM-026', 'DM-017'], status: ruleType ? 'required' : 'conditional', condition: ruleType ? undefined : 'depends on the rule type: workflow/state-transition → Flow + enum; access → permission row; numbering → Code pattern', ...(isNumbering ? { cli: { cliPath: CLI_PATHS.deriveCodeSpecs, spec: { moduleRoot: `/${corpus.scope.app}/${corpus.scope.module}`, mode: 'check' }, writes: false } } : {}), data: { ruleType: ruleType || null, stateAttributes: (corpus.entities?.entities ?? []).flatMap((e) => e.attributes.filter((a) => /\//.test(a.constraints)).map((a) => `${e.name}.${a.name}: ${a.constraints}`), ), }, }) steps.push({ step: n++, phase: 'check', title: 'Every attribute the `Expression` names exists in entité.md', file: `${corpus.scope.app}/${corpus.scope.module}/entité.md`, skill: 'ba-create-data-model', why: 'A rule on a column that does not exist is dead code — the scaffolded validator has nothing to read.', gateRules: ['DM-025'], status: 'conditional', condition: 'compare the Expression identifiers with the entity attributes; a missing one → /ba-change kind=attribute (the rule IS its upstream trace)', data: { entities: corpus.entities?.entities.map((e) => `${e.name}: ${e.attributes.map((a) => a.name).join(', ')}`) ?? [] }, }) steps.push({ step: n++, phase: 'prd', title: `Link ${code} to its pagespec(s): derive-rule-links backfill; \`needs-judgment\` → author \`linkedBusinessRules[]\` by hand (json block only)`, file: `${corpus.scope.app}/${corpus.scope.module}/pagespecs/..md`, cli: { cliPath: CLI_PATHS.deriveRuleLinks, spec: { baRoot: '', app: corpus.scope.app, module: corpus.scope.module, mode: 'backfill' }, writes: true, }, why: 'THE field DEV-API-008 reads — the audit\'s main BR silent-loss point. Mapping ladder: rule-doc folder → linked-UC sections → needs-judgment. Optional: an action line gains `BR: ' + code + '`. NEVER /ba-create-prd on a module with pagespecs.', gateRules: ['PRD-129', 'PRD-130'], status: isAccess ? 'skipped' : corpus.state.pagespecsPresent ? 'required' : 'skipped', ...(isAccess ? { skipReason: 'access/ownership rules are exempt from the PRD link (their channel is the RBAC matrix).' } : corpus.state.pagespecsPresent ? {} : { skipReason: 'no pagespecs/ — the module has no PRD yet; /ba-create-prd is legitimate here.' }), }) steps.push(auditStep(n++, ctx, ['rules', 'use-cases', 'cross-dimension'])) steps.push( handoffStep(n++, ctx, [ 'Phase 2a pre-entry checks do NOT see a new rule (DEV-API-024/010/026 only) — the post-phase audit-dev-api DEV-API-008 (module-scoped trace) and audit-dev-tests DEV-TEST-009 catch it in the auto-heal loop: business layer re-scaffolded from linkedBusinessRules[]', 'Phase 4 — a [Trait("BR","' + code + '")] test under Tests//', 'run /audit-fix after the run for anything left', ]), ) return steps } // --------------------------------------------------------------------------- // actor // --------------------------------------------------------------------------- /** Seed-parity check — Phase 0's own engine, run here as proof before the hand-off. */ function seedParityStep(step: number, ctx: ImpactContext, why: string): ImpactStep { const { corpus } = ctx return { step, phase: 'check', title: 'Seed parity: rbac.md + acteur.md ⇄ the seeded state (the DEV-CORE-011 engine)', cli: { cliPath: CLI_PATHS.deriveRbacGrants, spec: { baRoot: '', apps: [corpus.scope.app], mode: 'check', projectPath: '' }, writes: false, }, why, gateRules: ['DEV-CORE-011'], status: corpus.state.developed ? 'required' : 'skipped', ...(corpus.state.developed ? {} : { skipReason: 'not developed yet — no seed state to compare (spec.projectPath locates .smartstack/core-seed/*.state.json).' }), } } function actorImpact(ctx: ImpactContext): ImpactStep[] { const { corpus, op, target, existing } = ctx const code = codeOf(ctx) const label = target.title ?? existing?.resolved?.title ?? '