import { existsSync } from 'node:fs' import { basename, join, relative, resolve } from 'node:path' import { resolveMissingRules } from '@/gov/stacks' import type { InstalledFile, RetiredSurface, SyncAdapter } from '@/sync/engine' import { readStamp, stampedChain } from '@/sync/stamp' const RETIRED_GOV_FILE = join('.claude', 'GOV.md') export function rulesSourceDir(root: string): string { return join(root, 'governance', 'rules') } /** * Maps a rule name to its source file. The bash used * `find governance/rules -name ".md" | head -n 1` once per installed * rule, which walked the whole tree per file and returned whichever match * the filesystem happened to yield first. Indexing once makes the walk single * pass and the winner deterministic when a name appears in two subdirectories. */ export function indexSourceRules(root: string): Map { const dir = rulesSourceDir(root) const index = new Map() if (!existsSync(dir)) return index const paths = [ ...new Bun.Glob('**/*.md').scanSync({ cwd: dir, onlyFiles: true, dot: true, }), ].sort() for (const path of paths) { const name = basename(path, '.md') if (!index.has(name)) index.set(name, resolve(dir, path)) } return index } /** * Matches installed rules to sources by rule name rather than by relative * path, so a rule that moved between subdirectories in the toolkit still * syncs into the subdirectory the target already uses. */ export function createGovAdapter(root: string): SyncAdapter { const index = indexSourceRules(root) return { banner: 'aitk gov sync', label: 'rules', missingMessage: "No governance surfaces found in target. Run 'aitk gov install' first.", unit: 'changes', installedRoot: (target: string) => join(target, '.claude', 'rules'), locateSource: (file: InstalledFile) => index.get(basename(file.path, '.md')), collectRetired: (target: string) => collectRetiredGov(target), collectMissing: (target: string) => collectMissingGov(root, target), projectSubdir: 'project', stamp: { domain: 'governance', toolkitRoot: root }, } } /** * Rules the target's recorded chain entitles it to and its tree does not * hold. Reports as `notice` text through the same shape `collectRetired` * already returns, since both are surfaces the file walk cannot see: one an * absence to remove, this one an absence to add. */ function collectMissingGov(root: string, target: string): RetiredSurface[] { const chain = stampedChain(readStamp(target), 'governance') return resolveMissingRules(root, target, chain).map((source) => { const dest = join( target, '.claude', 'rules', source.subdir, `${source.rule}.md`, ) const rel = relative(target, dest) return { path: dest, rel, notice: `${rel} (listed by ${chain[0]}, not installed. Run aitk gov install ${chain[0]} to add it.)`, } }) } function collectRetiredGov(target: string): RetiredSurface[] { const path = join(target, RETIRED_GOV_FILE) if (!existsSync(path)) return [] const rel = relative(target, path) return [ { path, rel, notice: `${rel} (retired surface, scheduled for removal)`, }, ] }