/** * cli:derive-seed-delta — diff.ts * * Pure 2-state diff: base (previously released) vs next (release branch). * No I/O. The resurrection trap is solved BY CONSTRUCTION here: the plan only * ever inserts what is NEW relative to base, so a mapping the admin removed in * prod (still present in both states) is never re-delivered, and a mapping the * seed no longer declares is revoked exactly once. * * Rename resolution policy (deterministic, never guessed): * 1. declared aliases (`previousCodes` / `previousPaths`) carried by the NEW * state — authored upstream by /ba-reconcile-menu; * 2. explicit `resolvedRenames` passed in the spec (a human decision on a * previous run's `ambiguous[]`); * 3. anything else stays a removal+addition and the (removed, added) pairs * sharing a scope are surfaced in `ambiguous[]` for the PR reviewer. */ import type { CoreSeedState, CoreSeedStateLevel, CoreSeedStateNavEntry, } from '../scaffold-core-seed/state.js'; import type { AmbiguousRename, DeltaPlan, FieldChange, NavKeyRef, ResolvedRename, } from './types.js'; const LEVELS: CoreSeedStateLevel[] = ['application', 'module', 'section', 'resource']; export interface DiffInput { app: string; base: CoreSeedState | null; next: CoreSeedState; resolvedRenames: ResolvedRename[]; } export function diffStates(input: DiffInput): DeltaPlan { const { app, base, next } = input; const resolved = input.resolvedRenames.filter((r) => r.app === app); const plan: DeltaPlan = { app, baseHash: base?.specHash ?? null, newHash: next.specHash, baseline: base === null, navRenames: [], navUpdates: [], navAdditions: [], navDeactivations: [], removedApplications: [], roleRenames: [], roleUpdates: [], roleAdditions: [], removedRoles: [], permissionRenames: [], permissionUpdates: [], permissionAdditions: [], permissionDeletions: [], rolePermissionAdditions: [], rolePermissionRevocations: [], ambiguous: [], }; // Baseline: prod is presumed to match the current seed lineage (it was // seeded additively by the same providers). Nothing to reconcile — the gate // treats "no base state" as exempt. if (base === null) return plan; diffNavigation(base, next, resolved, plan); const roleRenameMap = diffRoles(base, next, resolved, plan); const permRenameMap = diffPermissions(base, next, resolved, plan); diffRolePermissions(base, next, roleRenameMap, permRenameMap, plan); return plan; } // ─── Navigation ────────────────────────────────────────────────────────── function navFieldChanges(from: CoreSeedStateNavEntry, to: CoreSeedStateNavEntry): FieldChange[] { const changes: FieldChange[] = []; if (from.label !== to.label) changes.push({ field: 'label', from: from.label, to: to.label }); if (from.icon !== to.icon) changes.push({ field: 'icon', from: from.icon, to: to.icon }); if (from.iconType !== to.iconType) changes.push({ field: 'iconType', from: from.iconType, to: to.iconType }); if (from.route !== to.route) changes.push({ field: 'route', from: from.route, to: to.route }); if (from.displayOrder !== to.displayOrder) changes.push({ field: 'displayOrder', from: from.displayOrder, to: to.displayOrder }); return changes; } function diffNavigation( base: CoreSeedState, next: CoreSeedState, resolved: ResolvedRename[], plan: DeltaPlan, ): void { // Base entry key → NEW code, filled level by level so children compare // against the renamed parent identity (top-down). const renamedBaseKeys = new Map(); const keyOf = (level: CoreSeedStateLevel, parent: string | undefined, code: string): string => `${level}|${parent ?? ''}|${code}`; for (const level of LEVELS) { const parentLevel = LEVELS[LEVELS.indexOf(level) - 1]; // Remap each base entry's parentCode through the parent level's renames. const baseEntries = base.navigation .filter((n) => n.level === level) .map((n) => { if (!parentLevel || !n.parentCode) return { entry: n, parent: n.parentCode }; const grandParent = base.navigation.find( (p) => p.level === parentLevel && p.code === n.parentCode, )?.parentCode; const mapped = renamedBaseKeys.get(keyOf(parentLevel, grandParent, n.parentCode)); return { entry: n, parent: mapped ?? n.parentCode }; }); const nextEntries = next.navigation.filter((n) => n.level === level); const baseByKey = new Map(baseEntries.map((b) => [`${b.parent ?? ''}|${b.entry.code}`, b])); const nextByKey = new Map(nextEntries.map((n) => [`${n.parentCode ?? ''}|${n.code}`, n])); const removed = [...baseByKey.entries()].filter(([k]) => !nextByKey.has(k)); const added = [...nextByKey.entries()].filter(([k]) => !baseByKey.has(k)); const removedByKey = new Map(removed); const addedByKey = new Map(added); // 1. Declared aliases on the NEW entries. for (const [addedKey, entry] of added) { if (addedByKey.get(addedKey) === undefined) continue; // already consumed for (const alias of entry.previousCodes ?? []) { const removedKey = `${entry.parentCode ?? ''}|${alias}`; const baseMatch = removedByKey.get(removedKey); if (!baseMatch) continue; recordNavRename(plan, renamedBaseKeys, keyOf, level, baseMatch, entry, alias); removedByKey.delete(removedKey); addedByKey.delete(addedKey); break; } } // 2. Explicit resolutions from the spec. for (const r of resolved) { if (r.kind !== 'navigation' || r.level !== level) continue; const removedKey = `${r.parentCode ?? ''}|${r.from}`; const addedKey = `${r.parentCode ?? ''}|${r.to}`; const baseMatch = removedByKey.get(removedKey); const nextMatch = addedByKey.get(addedKey); if (!baseMatch || !nextMatch) continue; recordNavRename(plan, renamedBaseKeys, keyOf, level, baseMatch, nextMatch, r.from); removedByKey.delete(removedKey); addedByKey.delete(addedKey); } // 3. Whatever remains: removals + additions (+ ambiguous candidates when // a removed and an added entry share the same parent scope). for (const [, b] of removedByKey) { if (level === 'application') { plan.removedApplications.push(b.entry.code); } else { plan.navDeactivations.push({ level, parentCode: b.parent, code: b.entry.code }); } for (const [, a] of addedByKey) { if ((a.parentCode ?? '') !== (b.parent ?? '')) continue; plan.ambiguous.push({ kind: 'navigation', level, parentCode: a.parentCode, removed: b.entry.code, added: a.code, similarity: similarity(b.entry.code, a.code, b.entry.label === a.label), }); } } for (const [, a] of addedByKey) { if (level === 'application') continue; // whole-app inserts are boot-seed only plan.navAdditions.push({ level, parentCode: a.parentCode, code: a.code }); } // 4. Property updates on entries matched by identical key — renamed // entries get their updates recorded inside recordNavRename (which holds // both sides), always keyed on the NEW identity since renames run first // in the script. for (const [key, b] of baseByKey) { const n = nextByKey.get(key); if (!n) continue; const changes = navFieldChanges(b.entry, n); if (changes.length > 0) { plan.navUpdates.push({ level, parentCode: n.parentCode, code: n.code, changes }); } } } sortNavKeyRefs(plan.navAdditions); sortNavKeyRefs(plan.navDeactivations); } function recordNavRename( plan: DeltaPlan, renamedBaseKeys: Map, keyOf: (level: CoreSeedStateLevel, parent: string | undefined, code: string) => string, level: CoreSeedStateLevel, baseMatch: { entry: CoreSeedStateNavEntry; parent: string | undefined }, nextEntry: CoreSeedStateNavEntry, fromCode: string, ): void { plan.navRenames.push({ level, parentCode: nextEntry.parentCode, from: fromCode, to: nextEntry.code, }); // Children of this base entry must compare against the NEW code. renamedBaseKeys.set(keyOf(level, baseMatch.entry.parentCode, fromCode), nextEntry.code); // Property drift across the rename — keyed on the NEW identity (renames run // first in the generated script). const changes = navFieldChanges(baseMatch.entry, nextEntry); if (changes.length > 0) { plan.navUpdates.push({ level, parentCode: nextEntry.parentCode, code: nextEntry.code, changes }); } } function sortNavKeyRefs(refs: NavKeyRef[]): void { refs.sort( (a, b) => LEVELS.indexOf(a.level) - LEVELS.indexOf(b.level) || (a.parentCode ?? '').localeCompare(b.parentCode ?? '') || a.code.localeCompare(b.code), ); } // ─── Roles ─────────────────────────────────────────────────────────────── function diffRoles( base: CoreSeedState, next: CoreSeedState, resolved: ResolvedRename[], plan: DeltaPlan, ): Map { const renameMap = new Map(); const baseByCode = new Map(base.roles.map((r) => [r.code, r])); const nextByCode = new Map(next.roles.map((r) => [r.code, r])); const removed = new Map([...baseByCode].filter(([code]) => !nextByCode.has(code))); const added = new Map([...nextByCode].filter(([code]) => !baseByCode.has(code))); for (const [code, role] of added) { for (const alias of role.previousCodes ?? []) { if (!removed.has(alias)) continue; plan.roleRenames.push({ from: alias, to: code }); renameMap.set(alias, code); removed.delete(alias); added.delete(code); break; } } for (const r of resolved) { if (r.kind !== 'role') continue; if (!removed.has(r.from) || !added.has(r.to)) continue; plan.roleRenames.push({ from: r.from, to: r.to }); renameMap.set(r.from, r.to); removed.delete(r.from); added.delete(r.to); } for (const [code, b] of removed) { plan.removedRoles.push(code); for (const [addedCode, a] of added) { plan.ambiguous.push({ kind: 'role', removed: code, added: addedCode, similarity: similarity(code, addedCode, b.name === a.name), }); } } plan.roleAdditions.push(...added.keys()); for (const [code, b] of baseByCode) { const targetCode = renameMap.get(code) ?? code; const n = nextByCode.get(targetCode); if (!n) continue; if (b.name !== n.name) { plan.roleUpdates.push({ code: targetCode, changes: [{ field: 'name', from: b.name, to: n.name }] }); } } return renameMap; } // ─── Permissions ───────────────────────────────────────────────────────── function diffPermissions( base: CoreSeedState, next: CoreSeedState, resolved: ResolvedRename[], plan: DeltaPlan, ): Map { const renameMap = new Map(); const baseByPath = new Map(base.permissions.map((p) => [p.path, p])); const nextByPath = new Map(next.permissions.map((p) => [p.path, p])); const removed = new Map([...baseByPath].filter(([path]) => !nextByPath.has(path))); const added = new Map([...nextByPath].filter(([path]) => !baseByPath.has(path))); for (const [path, perm] of added) { for (const alias of perm.previousPaths ?? []) { if (!removed.has(alias)) continue; plan.permissionRenames.push({ from: alias, to: path }); renameMap.set(alias, path); removed.delete(alias); added.delete(path); break; } } for (const r of resolved) { if (r.kind !== 'permission') continue; if (!removed.has(r.from) || !added.has(r.to)) continue; plan.permissionRenames.push({ from: r.from, to: r.to }); renameMap.set(r.from, r.to); removed.delete(r.from); added.delete(r.to); } for (const [path, b] of removed) { plan.permissionDeletions.push(path); for (const [addedPath, a] of added) { // Only worth flagging when the paths share the bearing node or the // action — a totally unrelated pair is noise for the reviewer. const sim = similarity( path, addedPath, b.action === a.action && b.level === a.level && b.nodeCode === a.nodeCode, ); if (sim >= 0.5) { plan.ambiguous.push({ kind: 'permission', removed: path, added: addedPath, similarity: sim }); } } } plan.permissionAdditions.push(...added.keys()); // A nodeCode change EXPLAINED by a nav rename at the same level is not a // rebinding: the nav row kept its GUID, so the FK is already correct — // emitting an UPDATE would be redundant and could even mis-resolve onto a // homonymous node of another module (TOP 1). Only a genuine move emits the // change. Multi-grain since the floor: a module/resource rename explains a // module/resource-grain nodeCode change the same way. const nodeRenamePairs = new Set( plan.navRenames.map((r) => `${r.level}|${r.from}|${r.to}`), ); for (const [path, b] of baseByPath) { const targetPath = renameMap.get(path) ?? path; const n = nextByPath.get(targetPath); if (!n) continue; const changes: FieldChange[] = []; if (b.action !== n.action) changes.push({ field: 'action', from: b.action, to: n.action }); if (b.level !== n.level) { changes.push({ field: 'level', from: b.level, to: n.level }); // A level change ALWAYS rebinds (different FK column) — carry the node // even when the code text is identical across grains. if (b.nodeCode === n.nodeCode) { changes.push({ field: 'nodeCode', from: b.nodeCode, to: n.nodeCode }); } } if ( b.nodeCode !== n.nodeCode && !nodeRenamePairs.has(`${n.level}|${b.nodeCode}|${n.nodeCode}`) ) { changes.push({ field: 'nodeCode', from: b.nodeCode, to: n.nodeCode }); } if (changes.length > 0) plan.permissionUpdates.push({ path: targetPath, level: n.level, changes }); } return renameMap; } // ─── Role-permission mappings ──────────────────────────────────────────── function diffRolePermissions( base: CoreSeedState, next: CoreSeedState, roleRenames: Map, permRenames: Map, plan: DeltaPlan, ): void { // Base pairs remapped through the renames: a pair whose role or permission // was renamed is STILL the same DB row (the rename UPDATE preserves the // GUIDs), so it must not read as removed+added. const basePairs = new Set( base.rolePermissions.map((rp) => { const role = roleRenames.get(rp.roleCode) ?? rp.roleCode; const path = permRenames.get(rp.permissionPath) ?? rp.permissionPath; return `${role}|${path}`; }), ); const nextPairs = new Set(next.rolePermissions.map((rp) => `${rp.roleCode}|${rp.permissionPath}`)); // Deleted permissions cascade their mappings inside the deletion statement — // don't emit a redundant revocation for those. const deletedPaths = new Set(plan.permissionDeletions); for (const pair of basePairs) { if (nextPairs.has(pair)) continue; const [roleCode, permissionPath] = pair.split('|'); if (deletedPaths.has(permissionPath)) continue; plan.rolePermissionRevocations.push({ roleCode, permissionPath }); } for (const pair of nextPairs) { if (basePairs.has(pair)) continue; const [roleCode, permissionPath] = pair.split('|'); plan.rolePermissionAdditions.push({ roleCode, permissionPath }); } plan.rolePermissionRevocations.sort( (a, b) => a.roleCode.localeCompare(b.roleCode) || a.permissionPath.localeCompare(b.permissionPath), ); plan.rolePermissionAdditions.sort( (a, b) => a.roleCode.localeCompare(b.roleCode) || a.permissionPath.localeCompare(b.permissionPath), ); } // ─── Similarity (advisory scoring for ambiguous[] only) ────────────────── function similarity(a: string, b: string, labelBoost: boolean): number { const dist = levenshtein(a, b); const maxLen = Math.max(a.length, b.length) || 1; const codeSim = 1 - dist / maxLen; return Math.min(1, Math.max(codeSim, labelBoost ? 0.9 : 0)); } function levenshtein(a: string, b: string): number { if (a === b) return 0; const m = a.length; const n = b.length; if (m === 0) return n; if (n === 0) return m; let prev = Array.from({ length: n + 1 }, (_, j) => j); for (let i = 1; i <= m; i++) { const curr = [i]; for (let j = 1; j <= n; j++) { curr[j] = Math.min( prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1), ); } prev = curr; } return prev[n]; }