GitNexus/gitnexus/test/unit/shipped-skills-sync.test.ts
Gergő Magyar 30676685e0
fix(rename): apply the exact reviewed edit plan (#3617)
* fix(rename): bind apply to reviewed plans (U7)

Retain exact semantic edits behind single-use preview tokens and reject
source, target, inventory and graph drift before application. Preserve
explicit partial-write accounting and keep large unedited sources covered
with hashes instead of retained text.

Refs #2973

* fix(rename): cover unindexed sources (U7)

Address PR #3617 review by binding every supported source to streamed byte
hashes and canonical targets, including tracked and untracked files absent
from the graph. Keep the exact reviewed edits and bound evidence memory.

Use conventional even-sample medians and extend the existing CI benchmark
to capture and validate unindexed sources without relaxing timing gates.

Note: pre-existing packed Tree-sitter GLIBCXX_3.4.31 host failure in
parse-cache-identity-package.test.ts is not addressed by this PR.

* fix(rename): preserve lazy startup (U7)

Keep language providers off the MCP startup path; load their existing
classification only when rename inventory is requested. The existing
12 startup closure checks now pass.

Address PR #3617 review by including token validation in reviewed apply
timing and growing indexed evidence before preview, preserving the
20MiB controls under a 256MiB native pool. Migrate dispatch and retention
tests to the preview token contract, keeping write-failure oracles.

Note: pre-existing auto-sync /tmp fixture security and installed ARM64
grammar-header failures remain outside this PR; the packed Tree-sitter
GLIBCXX_3.4.31 host failure also reproduces on unchanged main.

* test(rename): always remove apply fixture (U7)

Own the temporary directory from creation in an outer finally so setup
and preview assertions cannot bypass cleanup. Keep spy restoration in
its inner finally and preserve all write-failure assertions.

Address PR #3617 cleanup feedback.

* fix(rename): retain targets and config evidence

* test(rename): verify approved physical output

* test(rename): cover native filesystem matrix

* test(rename): align preview contract fixtures

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-10-17 19:38:01 +01:00

679 lines
28 KiB
TypeScript

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<string, string> {
const snapshot: Record<string, string> = {};
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 <path>`');
}
});
// 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 <symbol> --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: "<token from preview>"})',
);
}
});
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
* (`<!-- gitnexus:start -->` … `<!-- gitnexus:end -->`), 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 `<!-- gitnexus:start --> ... `" etc.) that a
// bare indexOf would mistake for the real block (mirrors
// findSectionMarkerIndex in ai-context.ts, #1041).
const match =
/(?:^|\n)<!-- gitnexus:start -->\r?\n([\s\S]*?)\n<!-- gitnexus:end -->(?:\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 });
}
});
},
);