import { describe, expect, it } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { RENAMED_SKILL_DIRS } from '../../src/cli/setup.js'; import { STANDARD_SKILL_CATALOG, type StandardSkillName } from '../../src/cli/standard-skills.js'; // The engineering skill family is authored once under .claude/skills/ and // shipped as byte-identical copies through the npm package's skills/ directory // (installed to editor targets by `gitnexus setup`) and the Claude Code plugin // (which adds only a per-skill mcp.json). gitnexus-review is also mirrored by // the standalone Cursor integration. // This test is the drift guard — edit the .claude/skills/ copy and re-copy; // never edit a shipped copy directly. Same discipline as run.cjs ↔ // resolve-invocation.ts. const REPO_ROOT = path.resolve(__dirname, '..', '..', '..'); const FAMILY = ['gitnexus-plan', 'gitnexus-work', 'gitnexus-review', 'gitnexus-lfg']; const STANDARD_SKILL_NAMES = STANDARD_SKILL_CATALOG.map((skill) => skill.name); const SPECIALIZED_NESTED_SKILLS = ['gitnexus-pdg-query', 'gitnexus-taint-analysis'] as const; function listFilesRecursive(dir: string, base: string = dir): string[] { // readdirSync follows a symlinked directory, so a mirror dir aliased to the // canonical tree would pass the byte-compare. Reject a symlinked root. if (fs.lstatSync(dir).isSymbolicLink()) { throw new Error(`shipped skill path must not be a symlink: ${dir}`); } const out: string[] = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); // A symlinked file is typed as a non-directory and readFileSync would follow // it to the canonical bytes — a silent pass. Only real, byte-identical files // (and real directories) may make up a shipped mirror. if (entry.isSymbolicLink()) { throw new Error(`shipped skill entry must be a regular file, not a symlink: ${full}`); } if (entry.isDirectory()) { out.push(...listFilesRecursive(full, base)); } else { out.push(path.relative(base, full).replace(/\\/g, '/')); } } return out.sort(); } function snapshotDir(dir: string): Record { const snapshot: Record = {}; for (const rel of listFilesRecursive(dir)) { snapshot[rel] = fs.readFileSync(path.join(dir, rel), 'utf-8'); } return snapshot; } function standardSkillCopies(name: StandardSkillName): string[] { const entry = STANDARD_SKILL_CATALOG.find((skill) => skill.name === name); if (!entry) throw new Error(`Unknown standard skill: ${name}`); const copies: string[] = []; if (entry.distributions.project) { copies.push(path.join(REPO_ROOT, '.claude', 'skills', name, 'SKILL.md')); } if (entry.distributions.npm) { copies.push(path.join(REPO_ROOT, 'gitnexus', 'skills', `${name}.md`)); } if (entry.distributions.claudePlugin) { copies.push(path.join(REPO_ROOT, 'gitnexus-claude-plugin', 'skills', name, 'SKILL.md')); } if (entry.distributions.cursor) { copies.push(path.join(REPO_ROOT, 'gitnexus-cursor-integration', 'skills', name, 'SKILL.md')); } return copies; } function discoverStandardSkillNames(): string[] { const bundledSkillsDir = path.join(REPO_ROOT, 'gitnexus', 'skills'); return fs .readdirSync(bundledSkillsDir, { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.endsWith('.md')) .map((entry) => entry.name.slice(0, -'.md'.length)) .filter( (name) => fs.existsSync(path.join(REPO_ROOT, '.claude', 'skills', name, 'SKILL.md')) && fs.existsSync(path.join(REPO_ROOT, 'gitnexus-claude-plugin', 'skills', name, 'SKILL.md')), ) .sort(); } describe('standard skill catalog coverage', () => { const discovered = discoverStandardSkillNames(); it('exactly matches the independently discovered standard skills', () => { expect([...STANDARD_SKILL_NAMES].sort()).toEqual(discovered); }); it('exactly matches the independently discovered Cursor subset', () => { const discoveredCursor = discovered.filter((name) => fs.existsSync( path.join(REPO_ROOT, 'gitnexus-cursor-integration', 'skills', name, 'SKILL.md'), ), ); const catalogCursor = STANDARD_SKILL_CATALOG.filter((skill) => skill.distributions.cursor) .map((skill) => skill.name) .sort(); expect(catalogCursor).toEqual(discoveredCursor); }); }); describe.each(STANDARD_SKILL_NAMES)('standard skill distribution for %s', (name) => { it('contains every applicable canonical and shipped copy', () => { expect(standardSkillCopies(name).map((file) => fs.existsSync(file))).toEqual( standardSkillCopies(name).map(() => true), ); }); }); describe('intended standard-skill improvements stay in every applicable copy', () => { it('ships byte-identical refactoring guidance in every distribution', () => { const canonical = fs.readFileSync( path.join(REPO_ROOT, '.claude', 'skills', 'gitnexus-refactoring', 'SKILL.md'), ); const copies = standardSkillCopies('gitnexus-refactoring'); expect(copies).toHaveLength(4); for (const file of copies) { expect(fs.readFileSync(file).equals(canonical), file).toBe(true); } }); it('documents the PDG analyze flag in every CLI copy', () => { for (const file of standardSkillCopies('gitnexus-cli')) { expect(fs.readFileSync(file, 'utf-8')).toContain('`--pdg`'); } }); it('documents the AsyncAPI analyze flag in every CLI copy', () => { for (const file of standardSkillCopies('gitnexus-cli')) { expect(fs.readFileSync(file, 'utf-8')).toContain('`--asyncapi-spec `'); } }); // These CLI copies are not byte-compared, so a runner added to // resolve-analyze-cmd.cjs can silently miss them. The // audience that most needs bunx documented — a bun-only machine with no npm, // npx or pnpm — is exactly the one an npx/pnpm-only bootstrap line strands. it('documents the bunx runner and bootstrap in every CLI copy', () => { for (const file of standardSkillCopies('gitnexus-cli')) { const content = fs.readFileSync(file, 'utf-8'); expect(content).toContain('else `bunx`'); expect(content).toContain('bunx gitnexus@latest analyze'); } }); it('documents CLI fallbacks in every impact-analysis copy', () => { const required = [ 'node .gitnexus/run.cjs impact --direction upstream --repo .', 'node .gitnexus/run.cjs detect-changes --scope all --repo .', 'replace `node .gitnexus/run.cjs` with `npx gitnexus`', 'detect_changes({scope: "all"})', ]; for (const file of standardSkillCopies('gitnexus-impact-analysis')) { const content = fs.readFileSync(file, 'utf-8'); for (const fragment of required) expect(content).toContain(fragment); } }); // The risk scale's own escape hatch. `UNKNOWN` means the walk could not // answer, and an agent that reads it as a low rung proceeds on a zero — the // one reading the verdict exists to prevent. // // This assertion exists because its absence let real drift ship: the canonical // `.claude/` copy lost the UNKNOWN block while the plugin mirror kept it, and // this suite passed 54/54 with the two copies contradicting each other. The // byte-identical checks do not cover impact-analysis, so a fragment // that is not listed here is a fragment nothing protects. it('keeps the UNKNOWN-risk guidance in every impact-analysis copy', () => { const required = [ '| **Zero callers found** | **UNKNOWN** |', '`UNKNOWN` is not a low rung on this scale', 'Confirm with a text search before', ]; const copies = standardSkillCopies('gitnexus-impact-analysis'); // Guard the guard: an empty copy list would make every loop below vacuous. expect(copies.length).toBeGreaterThan(1); for (const file of copies) { const content = fs.readFileSync(file, 'utf-8'); for (const fragment of required) expect(content).toContain(fragment); } }); it('keeps the cross-surface risk-scale guidance in every impact-analysis copy', () => { const required = [ '`riskSharedAxes`', 'MCP File walks', 'web Graph-RAG expands File targets', 'Within single-repo mode', 'Within group mode', 'overlays resolved', ]; for (const file of standardSkillCopies('gitnexus-impact-analysis')) { const content = fs.readFileSync(file, 'utf-8'); for (const fragment of required) expect(content).toContain(fragment); } }); // Same shape as the UNKNOWN guard above, for the other half of the verdict: // `detect_changes` can come back SHORT — `partial` when a batched graph query // failed, `truncated` when the changed-symbol listing hit its cap — and both // read as a clean gate if the agent only looks at the count (#2915). The // wording differs per copy (the Cursor mirror compresses it to one blockquote // line), so the fragments here are the parts every copy shares. it('keeps the partial/truncated degradation guidance in every impact-analysis copy', () => { const required = [ '`partial: true` (a graph query failed) or `truncated: true` (the changed-symbol', 'listing was capped)', 'a zero there means unseen, not unaffected.', 'tick the pre-commit check.', ]; const copies = standardSkillCopies('gitnexus-impact-analysis'); // Guard the guard: an empty copy list would make the loop below vacuous. expect(copies.length).toBeGreaterThan(1); for (const file of copies) { const content = fs.readFileSync(file, 'utf-8'); for (const fragment of required) expect(content).toContain(fragment); } }); // The refactoring copies carry the same warning for the verification step a // refactor ends on: there, a short list reads as "only the expected files // changed" rather than as a low risk score. it('keeps the partial/truncated degradation guidance in every refactoring copy', () => { const required = [ '`partial: true` (a graph query failed) or `truncated: true` (the changed-symbol', 'listing was capped)', 'is not proof that only the expected files changed.', 'treat the refactor as verified.', ]; const copies = standardSkillCopies('gitnexus-refactoring'); expect(copies.length).toBeGreaterThan(1); for (const file of copies) { const content = fs.readFileSync(file, 'utf-8'); for (const fragment of required) expect(content).toContain(fragment); } }); it('documents the current tools, schema, and cross-repo trace in every guide copy', () => { const required = [ '`route_map`', '`shape_check`', '`api_impact`', '`tool_map`', '`group_list`', '`group_sync`', '`TAINT_PATH`', 'Cross-repo (experimental)', 'Read `gitnexus://repo/{name}/schema` before writing Cypher', ]; for (const file of standardSkillCopies('gitnexus-guide')) { const content = fs.readFileSync(file, 'utf-8'); for (const fragment of required) expect(content).toContain(fragment); } }); // #2899 / #3291: every gitnexus-guide distribution documents the with-ref // hot-tool field. A copy that drops the section (or stays on the pre-#3291 // "absent when current" contract) ships a silent disagreement about identity. it('keeps the inline-staleness-signal section in every gitnexus-guide copy', () => { for (const file of standardSkillCopies('gitnexus-guide')) { const content = fs.readFileSync(file, 'utf-8'); expect(content).toContain('### Inline staleness signal'); expect(content).toContain('commitsBehind'); // #3256: the field gained `status`, and the `diverged` arm carries no // count — the reason an agent has to read `status` before the number. // #3291: it also gained the indexed ref, and is now emitted for every // status rather than suppressed when the index is current — without the // ref, `current` cannot distinguish an index of the default branch from // one of a feature branch. expect(content).toContain( '{ status, branch?, lastCommit, indexedAt, measuredAgainst, commitsBehind?, hint? }', ); expect(content).toContain('"status": "diverged"'); expect(content).toContain('"measuredAgainst": "HEAD"'); } }); // Same reasoning as the UNKNOWN guard above: most of these skills are not // byte-compared, so an edit to one copy alone can silently ship four // distributions that disagree about whether identity is required. The // fragments are matched against whitespace-normalized text because the // copies wrap the same sentences at different columns. const IDENTITY_CONTRACT_SKILLS = [ 'gitnexus-impact-analysis', 'gitnexus-refactoring', 'gitnexus-debugging', 'gitnexus-exploring', ] as const; const normalize = (text: string): string => text.replace(/\s+/g, ' '); it.each(IDENTITY_CONTRACT_SKILLS)( 'keeps the repository-identity contract in every %s copy', (name) => { const required = [ 'list_repos {}', '`offset: pagination.nextOffset`', '`hasMore` is false', 'an omitted `repo` normally errors', 'stop and ask', 'repo: "my-app"', 'bind repo; explicit repo when >1 indexed, ask if ambiguous', 'Re-analyze only for `behind` or `diverged`', ]; const copies = standardSkillCopies(name); expect(copies.length).toBeGreaterThan(1); for (const file of copies) { const content = normalize(fs.readFileSync(file, 'utf-8')); for (const fragment of required) expect(content).toContain(normalize(fragment)); } }, ); // Every `code` a failed rename can carry, as thrown in src/mcp/local/rename/* // and gitnexus-shared/src/rename/*, plus the statuses an agent must recover from. const RENAME_RECOVERY_KEYS = [ 'stale_graph', 'source_changed', 'unsupported_*', 'ambiguous_reference', 'incomplete_semantics', 'name_collision', 'invalid_name', 'invalid_argument', 'no_locations', 'overlapping_edits', 'invalid_edit', 'duplicate_file', 'missing_snapshot', 'outside_repository', 'source-unavailable', 'rename_failed', 'write_failed', 'application_status: "partial"', ]; it('documents a recovery step for every rename failure code in every refactoring copy', () => { for (const file of standardSkillCopies('gitnexus-refactoring')) { const content = fs.readFileSync(file, 'utf-8'); const table = content.slice(content.indexOf('| Result | Next step |')); for (const key of RENAME_RECOVERY_KEYS) expect(table, `${file}: ${key}`).toContain(key); } }); it('teaches one rename tool with dry_run, not a separate rename_preview, in every copy', () => { for (const name of ['gitnexus-refactoring', 'gitnexus-guide'] as const) { for (const file of standardSkillCopies(name)) { const content = fs.readFileSync(file, 'utf-8'); expect(content, file).not.toContain('rename_preview'); expect(content, file).toContain('dry_run'); expect(content, file).toContain('plan_token'); } } for (const file of standardSkillCopies('gitnexus-refactoring')) { const content = fs.readFileSync(file, 'utf-8'); expect(content).toContain( 'rename({symbol_name: "oldName", new_name: "newName", dry_run: true})', ); expect(content).toContain( 'rename({..., dry_run: false, plan_token: ""})', ); } }); it('teaches graph-backed semantic preview in every refactoring copy', () => { for (const file of standardSkillCopies('gitnexus-refactoring')) { const content = fs.readFileSync(file, 'utf-8'); expect(content).toContain('gitnexus-semantic'); expect(content).not.toContain('tsconfig_path'); expect(content).toContain('confidence: "semantic"'); expect(content).not.toContain('graph (safe)'); expect(content).not.toContain('ast_search'); } }); }); /** * The body of the root AGENTS.md / CLAUDE.md machine-managed block * (`` … ``), which * generateGitNexusContent (src/cli/ai-context.ts) regenerates on every * `gitnexus analyze`. Shared by the policy guards below. */ function extractManagedBlock(file: string): string { const content = fs.readFileSync(path.join(REPO_ROOT, file), 'utf-8'); // Markers must occupy their own line — CLAUDE.md's "GitNexus rules" // section links to AGENTS.md with an inline prose mention of both // marker strings ("See the ` ... `" etc.) that a // bare indexOf would mistake for the real block (mirrors // findSectionMarkerIndex in ai-context.ts, #1041). const match = /(?:^|\n)\r?\n([\s\S]*?)\n(?:\r?\n|$)/.exec( content, ); expect(match, `${file} must contain an own-line gitnexus:start/end block`).not.toBeNull(); return match![1]; } function alwaysDoSection(block: string): string { return block.slice(block.indexOf('## Always Do'), block.indexOf('## Never Do')); } // The `risk: UNKNOWN` Always-Do bullet and its Never-Do clause were hand-added // INSIDE the machine-managed region instead of living in the template, so a // real analyze run silently deleted them on regeneration — twice (#2856's // 8f8261021, then #2899's 9e602aef0, which piggybacked an unrelated // fetch-parsing fix and also regressed the index stats 248612/565510/918 -> // 42853/135955/758, itself evidence the block had been rebuilt from a stale // local index). ai-context.ts now generates both lines directly regardless of // `hasPdg` (see ai-context.test.ts's hasPdg-independent UNKNOWN test), so a // real analyze cannot drop them again. This guard is the second line of // defense: it reads the committed docs themselves, so a hand-revert or a stale // generator binary landing the same regression fails here even if the template // is fine. describe('root AGENTS.md / CLAUDE.md managed block keeps the risk: UNKNOWN policy (#2899)', () => { const REQUIRED_FRAGMENTS = [ 'MUST treat `risk: UNKNOWN` as unresolved, not as low.', 'never read `UNKNOWN` as an all-clear', 'never use `riskSharedAxes` to waive a HIGH/CRITICAL `risk` warning', 'Compare File/symbol', 'MCP File omits axes', 'Graph-RAG expands File', ]; it.each(['AGENTS.md', 'CLAUDE.md'])('%s managed block documents the policy', (file) => { const block = extractManagedBlock(file); for (const fragment of REQUIRED_FRAGMENTS) expect(block).toContain(fragment); }); it.each(['AGENTS.md', 'CLAUDE.md'])( '%s Always-Do pins the read-path MUST as its own bullet (#3076)', (file) => { const alwaysDo = alwaysDoSection(extractManagedBlock(file)); expect(alwaysDo).toMatch(/^- \*\*MUST use `query\(\{search_query: "concept"\}\)`/m); expect(alwaysDo).toContain('Graph first'); expect(alwaysDo).toContain('text search only for empty/'); expect(alwaysDo).not.toMatch(/Explore\s+with/); expect(alwaysDo).not.toMatch(/Use\s+`context\(\{name:/); expect(alwaysDo).not.toMatch(/^- [^\n]*Explore/m); }, ); it.each(['AGENTS.md', 'CLAUDE.md'])( "%s managed block's Always Do / Never Do bullet counts do not drop below the known floor", (file) => { const block = extractManagedBlock(file); const alwaysDo = alwaysDoSection(block); const neverDoSection = block.slice(block.indexOf('## Never Do')); const ungated = (alwaysDo.match(/^- .+/gm) ?? []).filter( (line) => !line.includes('pdg_query'), ); // Six Always-Do bullets are not hasPdg-gated after #3076. pdg_query is // extra when the committed block was generated with --pdg. Counting // ungated bullets (not total >= 6) fails if the read-path MUST leaves // Always-Do while pdg_query keeps the old slack. expect(ungated).toHaveLength(6); // Never Do never varies with hasPdg — exactly 4 today, so 4 is the floor. expect((neverDoSection.match(/^- NEVER /gm) || []).length).toBeGreaterThanOrEqual(4); }, ); }); // The same second-line-of-defense reading for the OTHER thing the block now // says about the pre-commit gate: a `detect_changes` that came back `partial` // (a batched graph query failed) or `truncated` (the changed-symbol listing hit // its cap) has not cleared anything (#2915). It lives inside the machine-managed // region, so it survives only as long as ai-context.ts keeps generating it — // exactly the shape that was silently deleted twice above. Reading the // committed docs catches a stale generator binary or a hand-revert too. describe('root AGENTS.md / CLAUDE.md managed block keeps the degraded-detect_changes policy (#2915)', () => { const REQUIRED_FRAGMENTS = [ // Deliberately short. The block is under a hard size cap (#856), so this // sentence gets re-trimmed whenever anything else in the block grows — it // already lost both parentheticals to pay for restoring the `detect-changes` // subcommand in the regression example. Pin the two claims that carry the // policy, not the prose around them. '`partial: true` or `truncated: true` is not a clean check', 'a zero means unseen, not unaffected; re-run it', ]; it.each(['AGENTS.md', 'CLAUDE.md'])('%s managed block documents the policy', (file) => { const block = extractManagedBlock(file); for (const fragment of REQUIRED_FRAGMENTS) expect(block).toContain(fragment); }); }); // The checked-in blocks must agree with what src/cli/ai-context.ts generates for the // one public rename tool: preview with dry_run:true first, then apply with dry_run:false. describe('root AGENTS.md / CLAUDE.md managed block teaches the single rename tool', () => { it.each(['AGENTS.md', 'CLAUDE.md'])('%s managed block previews before applying', (file) => { const block = extractManagedBlock(file); expect(block).toContain( 'use `rename` with `dry_run: true` first, review the graph-backed semantic spans and coverage, then apply with `dry_run: false`', ); expect(block).not.toContain('rename_preview'); }); }); describe.each(FAMILY)('shipped copies of %s stay in sync', (name) => { const canonical = snapshotDir(path.join(REPO_ROOT, '.claude', 'skills', name)); it('npm package copy (gitnexus/skills/) is byte-identical', () => { const shipped = snapshotDir(path.join(REPO_ROOT, 'gitnexus', 'skills', name)); expect(shipped).toEqual(canonical); }); it('plugin copy (gitnexus-claude-plugin/skills/) is canonical + mcp.json only', () => { const plugin = snapshotDir(path.join(REPO_ROOT, 'gitnexus-claude-plugin', 'skills', name)); const guideMcp = fs.readFileSync( path.join(REPO_ROOT, 'gitnexus-claude-plugin', 'skills', 'gitnexus-guide', 'mcp.json'), 'utf-8', ); expect(plugin).toEqual({ ...canonical, 'mcp.json': guideMcp }); }); }); describe('standalone Cursor review skill stays in sync', () => { it('is byte-identical to the canonical gitnexus-review skill', () => { const canonical = snapshotDir(path.join(REPO_ROOT, '.claude', 'skills', 'gitnexus-review')); const cursor = snapshotDir( path.join(REPO_ROOT, 'gitnexus-cursor-integration', 'skills', 'gitnexus-review'), ); expect(cursor).toEqual(canonical); }); }); describe('gitnexus-review target contract', () => { const skill = fs.readFileSync( path.join(REPO_ROOT, '.claude', 'skills', 'gitnexus-review', 'SKILL.md'), 'utf-8', ); it.each(['PR URL', 'base...head', 'Branch, tag, or commit', 'Local changes'])( 'documents the %s target mode', (targetMode) => { expect(skill).toContain(targetMode); }, ); it('uses the generalized public skill name', () => { expect(skill).toContain('name: gitnexus-review'); expect(skill).not.toContain('name: gitnexus-pr-review'); }); }); // ── Resurrection guard ── // A skill's OLD directory name must never reappear in a shipped tree: setup // would install it again alongside the new name, and the rename warning in // setup.ts would point at a dir we ourselves shipped. Empty directories are // treated as absent (checkout residue can leave empty dirs on disk locally), // so the assertion is "no files inside", not fs.existsSync of the dir. const filesUnder = (dir: string): string[] => (fs.existsSync(dir) ? listFilesRecursive(dir) : []); describe.each(STANDARD_SKILL_NAMES)('duplicate nested standard skill %s stays deleted', (name) => { it('has no files under .claude/skills/gitnexus/', () => { expect(filesUnder(path.join(REPO_ROOT, '.claude', 'skills', 'gitnexus', name))).toEqual([]); }); }); describe.each(SPECIALIZED_NESTED_SKILLS)( 'specialized nested skill %s remains available', (name) => { it('retains its SKILL.md', () => { expect( fs.existsSync(path.join(REPO_ROOT, '.claude', 'skills', 'gitnexus', name, 'SKILL.md')), ).toBe(true); }); }, ); describe.each(Object.values(RENAMED_SKILL_DIRS).flat())( 'legacy skill name %s stays out of the shipped trees', (legacyName) => { it.each([ path.join(REPO_ROOT, '.claude', 'skills'), path.join(REPO_ROOT, 'gitnexus', 'skills'), path.join(REPO_ROOT, 'gitnexus-claude-plugin', 'skills'), path.join(REPO_ROOT, 'gitnexus-cursor-integration', 'skills'), ])('has no files under %s', (skillsRoot) => { expect(filesUnder(path.join(skillsRoot, legacyName))).toEqual([]); }); it('has no flat copy in the npm package skills root', () => { expect(fs.existsSync(path.join(REPO_ROOT, 'gitnexus', 'skills', `${legacyName}.md`))).toBe( false, ); }); }, ); describe('skill-sync workflow contract', () => { const workflow = fs.readFileSync( path.join(REPO_ROOT, '.github', 'workflows', 'skill-sync.yml'), 'utf-8', ); const guardedPaths = [ '.claude/skills/gitnexus-*/**', '.claude/skills/gitnexus/**', 'gitnexus/skills/**', 'gitnexus-claude-plugin/skills/**', 'gitnexus-cursor-integration/skills/**', 'gitnexus/test/unit/shipped-skills-sync.test.ts', 'gitnexus/test/unit/skills-steering.test.ts', 'gitnexus/test/unit/engineering-skills-contract.test.ts', 'gitnexus/test/unit/evidence-provenance-helper.test.ts', '.github/workflows/skill-sync.yml', ]; it.each(guardedPaths)('triggers on %s for both pull requests and main pushes', (guardedPath) => { expect(workflow.split(`- '${guardedPath}'`).length - 1).toBe(2); }); it('runs parity, steering, engineering, and provenance contracts in one blocking job', () => { expect(workflow).toContain('npx vitest run'); expect(workflow).toContain('test/unit/shipped-skills-sync.test.ts'); expect(workflow).toContain('test/unit/skills-steering.test.ts'); expect(workflow).toContain('test/unit/engineering-skills-contract.test.ts'); expect(workflow).toContain('test/unit/evidence-provenance-helper.test.ts'); }); }); describe.skipIf(process.platform === 'win32')( 'drift guard rejects symlinked shipped entries', () => { it('rejects a mirror file symlinked to the canonical copy instead of passing byte-compare', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-drift-file-')); try { const canonical = path.join(tmp, 'canonical-SKILL.md'); fs.writeFileSync(canonical, 'canonical content'); const mirror = path.join(tmp, 'mirror'); fs.mkdirSync(mirror); fs.symlinkSync(canonical, path.join(mirror, 'SKILL.md')); expect(() => snapshotDir(mirror)).toThrow(/symlink/); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); it('rejects a mirror subdirectory that is a symlink', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-drift-dir-')); try { const realDir = path.join(tmp, 'real'); fs.mkdirSync(realDir); fs.writeFileSync(path.join(realDir, 'SKILL.md'), 'x'); const mirror = path.join(tmp, 'mirror'); fs.mkdirSync(mirror); fs.symlinkSync(realDir, path.join(mirror, 'scripts')); expect(() => listFilesRecursive(mirror)).toThrow(/symlink/); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); it('still accepts a mirror made only of real byte-identical files', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-drift-real-')); try { const mirror = path.join(tmp, 'mirror'); fs.mkdirSync(path.join(mirror, 'scripts'), { recursive: true }); fs.writeFileSync(path.join(mirror, 'SKILL.md'), 'real'); fs.writeFileSync(path.join(mirror, 'scripts', 'helper.mjs'), 'real'); expect(snapshotDir(mirror)).toEqual({ 'SKILL.md': 'real', 'scripts/helper.mjs': 'real', }); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); }, );