/** * Intra-monorepo skill-drift guard. * * The same skill is physically duplicated across many monorepo packages * (`packages/@dzhechkov/*​//` + `.claude/skills//`). A fix applied to ONE copy * silently leaves the others broken — this is exactly how a CRITICAL `goap-research-ed25519` * self-signed-forgery exploit shipped in 10 of 12 copies, and how a `brutal-honesty-review` * `set -e` crash reached the PUBLISHED `@dzhechkov/skills-qe`. Both were found only by accident. * * `dz sync-upstream` only checks against EXTERNAL repos and is structurally blind to this class of * drift. This module is the intra-monorepo complement: * * • `sweepSkillDrift(root)` — detector: which shared skills byte-differ between copies. * • `syncCanonicalSkill(root, s)` — healer: overwrite every copy from `skills-meta/`. * * Both are PURE functions that return plain data — no printing, no `process.exit`, no throwing on * the "canonical missing" / "drift found" business cases. The CLI layer owns exit codes and I/O. * Dependency-free: `node:fs` / `node:path` / `node:crypto` only. * * @packageDocumentation */ import { readdirSync, statSync, readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs'; import { join, relative, resolve, dirname, basename } from 'node:path'; import { createHash } from 'node:crypto'; /** One shared skill whose copies byte-differ (`driftFiles > 0`). */ export interface DriftedSkill { /** Skill dir basename (e.g. `goap-research-ed25519`). */ readonly name: string; /** How many locations hold this skill. */ readonly copies: number; /** # relative paths where the copies disagree (≥1 ⇒ drift). */ readonly driftFiles: number; /** Size of the union of relative file paths across all copies. */ readonly totalFiles: number; /** # (copy × relative-path) pairs where the file is absent from a copy. */ readonly missingFiles: number; /** Absolute paths of every copy — lets a human / `--json` consumer jump to the drifted dirs. */ readonly locations: readonly string[]; } /** Options for {@link sweepSkillDrift}. */ export interface SweepOptions { /** * Which copies to compare. * - `'packages'` (the CI-gate default): PUBLISHED package copies only (`packages/`). The dogfood * `.claude/skills/` dev copies are excluded — the repo's own `dz sync` test treats them as * "legitimately lagging" the published version, so counting them makes the gate red-on-arrival. * The dangerous drift (goap, brutal-honesty) was always between PUBLISHED packages. * - `'all'`: packages + `.claude/skills` (the raw sweep the audit script does). */ readonly scope?: 'packages' | 'all'; /** Skill basenames whose drift is ACCEPTED (documented intentional forks) — reported separately, never counted as gate drift. */ readonly allowlist?: readonly string[]; } /** Result of a read-only intra-monorepo drift sweep. */ export interface SweepResult { /** # skills present in ≥2 locations (within scope). */ readonly duplicated: number; /** Skills that byte-differ AND are not allowlisted, sorted by `driftFiles` desc — the gate keys on this. */ readonly drifted: readonly DriftedSkill[]; /** Skills that byte-differ but are allowlisted (intentional) — surfaced for transparency, not gated. */ readonly allowlisted: readonly DriftedSkill[]; } /** * How the canonical was resolved for a {@link syncCanonicalSkill} run. * - `from` — an explicit `--from ` was supplied. * - `skills-meta` — no `--from`; `skills-meta/` exists and was used. * - `auto` — no explicit canonical; the WRITE path auto-detected the most-complete copy * (opt-in `--auto` ONLY — never a bare default). * - `none` — no canonical could be resolved. `--check` still runs a canonical-free peer * comparison; a bare write refuses (safe-by-default). */ export type CanonicalSource = 'from' | 'skills-meta' | 'auto' | 'none'; /** Options for {@link syncCanonicalSkill}. */ export interface SyncCanonicalOptions { /** Report drift only, write NOTHING (CI mode). */ readonly check?: boolean; /** Override the canonical source dir (default: `skills-meta/`). */ readonly from?: string; /** * Opt-in for the WRITE path ONLY: when no `--from`/`skills-meta` canonical exists, auto-detect the * most-complete copy as canonical instead of refusing. A HEURISTIC — the CLI prints a loud warning * naming the pick + the exact overwrite list. Never affects the read-only `check` path. */ readonly auto?: boolean; } /** Result of a canonical-wins sync (or a `check:true` dry-run / canonical-free peer check). */ export interface SyncResult { /** Resolved canonical dir (abs). Empty `''` in canonical-free peer / refuse modes. */ readonly canonical: string; /** Whether a canonical source dir was resolved. `false` ⇒ `resolvedFrom === 'none'`. */ readonly canonicalExists: boolean; /** How the canonical resolved: `from` | `skills-meta` | `auto` | `none`. */ readonly resolvedFrom: CanonicalSource; /** # copies compared — non-canonical copies in resolved modes; ALL peers in canonical-free mode. */ readonly copies: number; /** # copies overwritten (0 when `check:true`, in peer mode, or when a bare write refuses). */ readonly synced: number; /** # copies/files that differ (from canonical in resolved modes; between peers in canonical-free mode). */ readonly drifted: number; /** Abs paths of copies written (empty when `check:true` / peer / refuse — proves no writes). */ readonly wrote: readonly string[]; } const SKILL_MANIFEST = 'SKILL.md'; const IGNORED_ENTRIES = new Set(['node_modules', '__pycache__', '.DS_Store', 'run-history.json']); /** md5 of a file's bytes (identical to both prototype scripts ⇒ identical drift verdicts). */ function md5(path: string): string { return createHash('md5').update(readFileSync(path)).digest('hex'); } /** Recursive file list under `dir`; skips `node_modules` / `__pycache__` / `.DS_Store`. */ function walk(dir: string): string[] { const out: string[] = []; for (const entry of readdirSync(dir)) { if (IGNORED_ENTRIES.has(entry)) continue; const p = join(dir, entry); let st; try { st = statSync(p); } catch { continue; } if (st.isDirectory()) out.push(...walk(p)); else out.push(p); } return out; } /** * Byte-compare a set of skill copies against EACH OTHER (canonical-free). Builds the union of * relative file paths across every copy, hashes each `(copy × rel)`, and counts how many relative * paths disagree (`driftFiles`) and how many `(copy × rel)` pairs are absent from a copy * (`missingFiles`). `driftFiles > 0` ⇔ the copies are NOT byte-identical to one another. * * This is the exact per-skill comparison {@link sweepSkillDrift} performs; it is extracted verbatim * so BOTH the CI sweep AND the canonical-free `sync-canonical --check` share one implementation and * yield the same verdict. Behavior-preserving refactor — no numbers change. */ function comparePeers(copies: readonly string[]): { driftFiles: number; missingFiles: number; totalFiles: number } { const relFiles = new Set(); for (const c of copies) for (const f of walk(c)) relFiles.add(relative(c, f)); let driftFiles = 0; let missingFiles = 0; for (const rel of relFiles) { const hashes = new Set(); for (const c of copies) { const p = join(c, rel); if (existsSync(p)) hashes.add(md5(p)); else { missingFiles++; hashes.add('__MISSING__'); } } if (hashes.size > 1) driftFiles++; } return { driftFiles, missingFiles, totalFiles: relFiles.size }; } /** * Deterministic auto-pick of a canonical from a set of copies: the copy with the MOST files wins, * tie-broken lexicographically on sorted path (so the same drift always auto-picks the same * canonical, across runs and machines). "Most complete" is a HEURISTIC, not a correctness oracle — * hence it is only ever reached behind an explicit `--auto` opt-in plus a loud warning. */ function pickMostComplete(copies: readonly string[]): string { return [...copies].sort().reduce((best, c) => (walk(c).length > walk(best).length ? c : best)); } /** * Resolve WHICH dir is canonical for a sync/check run, and HOW it resolved. Precedence is identical * for read and write paths: * 1. `opts.from` → `'from'` * 2. `skills-meta/` → `'skills-meta'` * 3. `opts.auto` (write) → `'auto'` (most-complete copy) * 4. otherwise → `'none'` (no canonical — `--check` compares copies to each other; * a bare write refuses) */ function resolveCanonical( root: string, skill: string, copies: readonly string[], opts: SyncCanonicalOptions, ): { canonical: string | null; resolvedFrom: CanonicalSource } { if (opts.from !== undefined) return { canonical: resolve(opts.from), resolvedFrom: 'from' }; const meta = join(root, 'packages/@dzhechkov/skills-meta', skill); if (existsSync(meta)) return { canonical: meta, resolvedFrom: 'skills-meta' }; if (opts.auto === true && copies.length >= 1) return { canonical: pickMostComplete(copies), resolvedFrom: 'auto' }; return { canonical: null, resolvedFrom: 'none' }; } /** * Every skill dir (a dir containing `SKILL.md`) under `packages/` + `.claude/skills`, excluding * `node_modules` / `__pycache__`. Ported verbatim from `scripts/drift-sweep-skills.mjs`. */ function findSkillDirs(root: string, scope: 'packages' | 'all' = 'all'): string[] { const dirs: string[] = []; const roots = scope === 'packages' ? [join(root, 'packages')] : [join(root, 'packages'), join(root, '.claude', 'skills')]; const stack = roots.filter((p) => existsSync(p)); while (stack.length) { const d = stack.pop() as string; let entries: string[]; try { entries = readdirSync(d); } catch { continue; } // Drift-guard scope = SKILL.md-bearing dirs ONLY. `templates/docs//` mirrors carry // skill-shaped NAMES but are rendered/derived documentation (no SKILL.md) and are therefore // intentionally excluded — they are not skill definitions, so there is nothing to keep in sync. // (Verified: no `packages/**/templates/docs/**` dir carries a SKILL.md. See ADR-001, D1.) if (entries.includes(SKILL_MANIFEST)) dirs.push(d); for (const e of entries) { if (IGNORED_ENTRIES.has(e)) continue; const p = join(d, e); try { if (statSync(p).isDirectory()) stack.push(p); } catch { /* skip unreadable entries */ } } } return dirs; } /** * Detect intra-monorepo skill drift: find every skill duplicated across ≥2 locations and report * which copies byte-differ. Pure port of `scripts/drift-sweep-skills.mjs`. * * `result.drifted.length === 0` is the exact condition the CI gate keys on. */ export function sweepSkillDrift(root: string, opts: SweepOptions = {}): SweepResult { const scope = opts.scope ?? 'all'; const allow = new Set(opts.allowlist ?? []); // Group skill dirs by basename → Map. const byName = new Map(); for (const d of findSkillDirs(root, scope)) { const name = basename(d); const list = byName.get(name); if (list) list.push(d); else byName.set(name, [d]); } let duplicated = 0; const drifted: DriftedSkill[] = []; const allowlisted: DriftedSkill[] = []; for (const [name, unsorted] of [...byName.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) { if (unsorted.length < 2) continue; duplicated++; const copies = [...unsorted].sort(); // Per-skill byte-comparison of every copy against each other (extracted to `comparePeers` // so the canonical-free `sync-canonical --check` reuses the EXACT same logic). const { driftFiles, missingFiles, totalFiles } = comparePeers(copies); if (driftFiles > 0) { const entry: DriftedSkill = { name, copies: copies.length, driftFiles, totalFiles, missingFiles, locations: copies, }; (allow.has(name) ? allowlisted : drifted).push(entry); } } const byDrift = (a: DriftedSkill, b: DriftedSkill): number => b.driftFiles - a.driftFiles; drifted.sort(byDrift); allowlisted.sort(byDrift); return { duplicated, drifted, allowlisted }; } /** * Heal one skill: treat the resolved canonical (`--from` → `skills-meta/` → `--auto` * most-complete copy) as authoritative and overwrite every other copy in the monorepo, proving * byte-identity. Pure port of `scripts/sync-canonical-skill.mjs`, extended with a canonical-free path. * * `check:true` writes NOTHING (`wrote` stays empty) and only reports the drift count. * Default overwrites drifting copies; a subsequent {@link sweepSkillDrift} then reports 0 drift. * * When NO canonical resolves (`resolvedFrom === 'none'` — no `--from`, no `skills-meta`, no `--auto`): * • `check:true` → CANONICAL-FREE peer check: `drifted` = # files that differ ACROSS the copies * (byte-identical copies ⇒ `drifted === 0`). Reuses {@link comparePeers} — the exact sweep logic. * • write (bare) → REFUSES: returns `wrote:[]`, `synced:0`, mutates NOTHING. Safe-by-default: the * tool never guesses a canonical for a write, because a wrong pick destroys the good copy. The * operator must pass `--from`, run `--check`, or opt in to `--auto` (which the CLI announces). * * This function never throws / never `process.exit`s — the CLI owns exit codes, printing, and the * loud `--auto` warning. */ export function syncCanonicalSkill(root: string, skill: string, opts: SyncCanonicalOptions = {}): SyncResult { const check = opts.check === true; // The healer/checker operates on EXACTLY what the detector sees (same roots via findSkillDirs) — // otherwise a copy could be silently healed but never gated, or vice-versa. const allCopies = findSkillDirs(root, 'all') .filter((d) => basename(d) === skill) .sort(); const { canonical, resolvedFrom } = resolveCanonical(root, skill, allCopies, opts); // No canonical resolved → asymmetric read/write handling (ADR-001 D3/D4). if (canonical === null) { if (check) { // CANONICAL-FREE peer check: are the copies byte-identical to EACH OTHER? (<2 ⇒ vacuously so.) const drifted = allCopies.length < 2 ? 0 : comparePeers(allCopies).driftFiles; return { canonical: '', canonicalExists: false, resolvedFrom, copies: allCopies.length, synced: 0, drifted, wrote: [] }; } // Bare WRITE with no resolvable canonical → REFUSE. Mutates nothing (`wrote:[]` proves it). return { canonical: '', canonicalExists: false, resolvedFrom, copies: allCopies.length, synced: 0, drifted: 0, wrote: [] }; } const canonFiles = walk(canonical) .map((p) => relative(canonical, p)) .sort(); // Every / dir except the canonical itself. const copies = allCopies.filter((d) => relative(canonical, d) !== ''); let drifted = 0; let synced = 0; const wrote: string[] = []; for (const copy of copies) { const copyFiles = new Set(walk(copy).map((p) => relative(copy, p))); let differs = false; // Extra files in the copy not present in canonical ⇒ drift. for (const f of copyFiles) if (!canonFiles.includes(f)) differs = true; for (const f of canonFiles) { const src = join(canonical, f); const dst = join(copy, f); if (!existsSync(dst) || md5(src) !== md5(dst)) differs = true; } if (!differs) continue; drifted++; if (check) continue; // report only — write NOTHING // Overwrite: remove extra files, then copy every canonical file byte-for-byte. for (const f of copyFiles) if (!canonFiles.includes(f)) rmSync(join(copy, f)); for (const f of canonFiles) { const src = join(canonical, f); const dst = join(copy, f); mkdirSync(dirname(dst), { recursive: true }); writeFileSync(dst, readFileSync(src)); } synced++; wrote.push(copy); } return { canonical, canonicalExists: true, resolvedFrom, copies: copies.length, synced, drifted, wrote }; }