Files
msd-core/tests/plugin-manifest.test.cjs
Tom Boucher 30468f16fe test(#4515): migrate installer-runs batch to named timeout constants (#4575)
* test(#4515): migrate installer-runs batch to named timeout constants

Batch 4 of the ad hoc timeout literal migration (epic #4445). Replaces
every bare numeric timeout/timeoutMs object-literal property across
tests/install.test.cjs, tests/install-minimal-hooks.test.cjs,
tests/fragment-single-edit-propagation.install.test.cjs,
tests/install-regressions.test.cjs, tests/install-runtime-artifacts.test.cjs,
tests/npm-integrity-gate.test.cjs, tests/faulty-deps.test.cjs,
tests/release-tarball-smoke.install.test.cjs,
tests/install-write-confinement.test.cjs, tests/plugin-manifest.test.cjs,
and tests/packaging-shipped-scripts-require-only-shipped.test.cjs with a
named constant, per eslint-rules/no-adhoc-timeout-literal.cjs. Removes
these 11 files from the rule's allowlist.

Adds one new shared constant to tests/helpers/timeouts.cjs for a
fixture-JSON value (in seconds, not ms) mimicking a Claude Code
settings.json hook-entry's own timeout field, shared across two files in
this batch. Every other new constant is file-local, each carrying a
comment explaining why its call site is a distinct operational class from
the existing shared norms even where its digits numerically coincide with
one. No src/bin file touched, no numeric timeout value changed anywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: combine gsd-worktree-path-guard's git rev-parse calls to cut subprocess count under CI load

Discovered while verifying PR #4575 (a full test (windows-latest) failure in an unrelated test, tests/kilo-upgrades.test.cjs's worktree-path-guard rejection test). Root cause: this hook's up-to-4 sequential git spawns (git-dir, branch, worktree-toplevel, file-toplevel), invoked inside a native-plugin's own 8000ms-capped subprocess wrapper, left zero margin for node/git startup overhead — the guard is deliberately fail-open on any timeout, so CI-load-induced latency in this chain silently downgrades a security block into an allow. Combines the first three git rev-parse calls (git-dir, branch via --abbrev-ref, worktree-toplevel) into ONE spawn instead of three, using git's documented multi-flag rev-parse output (one line per flag, in order) — verified empirically including the detached-HEAD edge case. No timeout value changed; this reduces subprocess COUNT, not any tolerance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs: add changeset fragment for the worktree-path-guard fix

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:45:44 -04:00

1286 lines
63 KiB
JavaScript

'use strict';
/**
* Regression tests for issue #766: additive Claude Code plugin manifest.
*
* Asserts structural and semantic correctness of:
* .claude-plugin/plugin.json — plugin manifest
* hooks/hooks.json — plugin hook wiring
*
* Section C1 validates plugin.json against the snapshotted schema fixture
* (tests/fixtures/plugin-manifest-schema.json) using explicit structural
* assertions instead of an Ajv dependency, so this gate runs unconditionally
* without requiring ajv in devDependencies.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const identity = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'package-identity.cjs'));
const pkg = require(path.join(ROOT, 'package.json'));
const { MANAGED_HOOKS } = require(path.join(ROOT, 'hooks', 'managed-hooks-registry.cjs'));
const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const PLUGIN_JSON_PATH = path.join(ROOT, '.claude-plugin', 'plugin.json');
const HOOKS_JSON_PATH = path.join(ROOT, 'hooks', 'hooks.json');
// Bounds a `claude --version` PATH probe; kept separate from the shared
// PROBE_TIMEOUT_MS (15000ms) since this site's pre-existing value differs
// and this migration never raises a bound without a fresh bench citation.
const CLAUDE_VERSION_PROBE_TIMEOUT_MS = 5000;
// ─── Section A: plugin.json ───────────────────────────────────────────────────
describe('A: .claude-plugin/plugin.json', () => {
let manifest;
test('exists and is valid JSON', () => {
assert.ok(fs.existsSync(PLUGIN_JSON_PATH), '.claude-plugin/plugin.json must exist');
const raw = fs.readFileSync(PLUGIN_JSON_PATH, 'utf-8');
manifest = JSON.parse(raw); // throws on invalid JSON
assert.ok(typeof manifest === 'object' && manifest !== null, 'manifest must be a JSON object');
});
test('name equals identity.binName ("gsd-core")', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.equal(manifest.name, identity.binName, `name should be "${identity.binName}"`);
});
test('name is kebab-case, no colons, spaces, or uppercase', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.match(
manifest.name,
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
'name must be kebab-case (no colon, space, or uppercase) to be namespace-safe'
);
});
test('version matches package.json version', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.equal(manifest.version, pkg.version, `.claude-plugin/plugin.json version (${manifest.version}) must match package.json version (${pkg.version}). When bumping the package version, update .claude-plugin/plugin.json \`version\` to match — Claude Code plugin --strict validation requires a version field and the plugin manifest must track the package version. (#766)`);
});
test('repository equals identity.repoUrl', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.equal(manifest.repository, identity.repoUrl, 'repository must equal identity.repoUrl');
});
test('homepage equals identity.repoUrl', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.equal(manifest.homepage, identity.repoUrl, 'homepage must equal identity.repoUrl');
});
test('license matches package.json license', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.equal(manifest.license, pkg.license, 'license must match package.json');
});
test('author.name is a non-empty string', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.ok(
manifest.author && typeof manifest.author.name === 'string' && manifest.author.name.trim().length > 0,
'author.name must be a non-empty string'
);
});
test('commands field is "./commands/gsd/" and that dir exists with at least one .md file', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.equal(manifest.commands, './commands/gsd/', 'commands must be "./commands/gsd/"');
const resolvedDir = path.resolve(path.dirname(PLUGIN_JSON_PATH), '..', manifest.commands);
assert.ok(fs.existsSync(resolvedDir), `resolved commands dir must exist: ${resolvedDir}`);
const mdFiles = fs.readdirSync(resolvedDir).filter(f => f.endsWith('.md'));
assert.ok(mdFiles.length > 0, `commands dir must contain at least one .md file`);
});
test('#3029: hooks field is ABSENT — Claude Code auto-loads hooks/hooks.json (explicit declaration caused duplicate-rejection)', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.ok(!manifest.hooks, 'plugin.json must NOT declare hooks — Claude Code auto-loads hooks/hooks.json; an explicit declaration causes a duplicate-rejection that silently disables all hooks (#3029)');
// The auto-loaded hooks file must still exist on disk.
const resolvedHooks = path.resolve(path.dirname(PLUGIN_JSON_PATH), '..', 'hooks', 'hooks.json');
assert.ok(fs.existsSync(resolvedHooks), `hooks/hooks.json must exist for auto-loading: ${resolvedHooks}`);
});
test('no "$schema" key (intentionally omitted)', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.ok(!Object.prototype.hasOwnProperty.call(manifest, '$schema'), 'plugin.json must NOT contain a $schema key');
});
});
// ─── Section B: hooks/hooks.json ─────────────────────────────────────────────
describe('B: hooks/hooks.json', () => {
let hooksConfig;
test('exists and is valid JSON with top-level "hooks" object', () => {
assert.ok(fs.existsSync(HOOKS_JSON_PATH), 'hooks/hooks.json must exist');
const raw = fs.readFileSync(HOOKS_JSON_PATH, 'utf-8');
hooksConfig = JSON.parse(raw);
assert.ok(
typeof hooksConfig === 'object' && hooksConfig !== null &&
typeof hooksConfig.hooks === 'object' && hooksConfig.hooks !== null,
'hooks.json must have a top-level "hooks" object'
);
});
test('every event name is a known Claude Code lifecycle event', (t) => {
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
// Complete set of Claude Code hook events as of #770 (SubagentStop, Stop,
// PreCompact, FileChanged added in #770; prior set was SessionStart,
// PreToolUse, PostToolUse from #766).
const validEvents = new Set([
'SessionStart', 'PreToolUse', 'PostToolUse',
'SubagentStop', 'Stop', 'PreCompact', 'FileChanged',
]);
for (const eventName of Object.keys(hooksConfig.hooks)) {
assert.ok(validEvents.has(eventName), `Unknown hook event: "${eventName}"`);
}
});
test('every hook entry has type "command" and command contains ${CLAUDE_PLUGIN_ROOT}', (t) => {
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
for (const [eventName, eventEntries] of Object.entries(hooksConfig.hooks)) {
assert.ok(Array.isArray(eventEntries), `Event "${eventName}" must be an array`);
for (const entry of eventEntries) {
assert.ok(Array.isArray(entry.hooks), `Entry in "${eventName}" must have a hooks array`);
for (const hook of entry.hooks) {
assert.equal(hook.type, 'command', `All hook entries must have type "command" (got "${hook.type}")`);
assert.ok(
typeof hook.command === 'string' && hook.command.includes('${CLAUDE_PLUGIN_ROOT}'),
`Hook command must contain "\${CLAUDE_PLUGIN_ROOT}": ${hook.command}`
);
}
}
}
});
test('every referenced script file exists on disk and its basename is in MANAGED_HOOKS', (t) => {
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
// Extract script path: substring after ${CLAUDE_PLUGIN_ROOT}/ up to next "
const scriptPathRe = /\$\{CLAUDE_PLUGIN_ROOT\}\/([^"]+)/g;
const allScripts = [];
for (const eventEntries of Object.values(hooksConfig.hooks)) {
for (const entry of eventEntries) {
for (const hook of entry.hooks) {
const matches = [...hook.command.matchAll(scriptPathRe)];
for (const m of matches) {
allScripts.push(m[1]);
}
}
}
}
assert.ok(allScripts.length > 0, 'Should have found at least one script path in hooks.json');
for (const scriptPath of allScripts) {
const fullPath = path.join(ROOT, scriptPath);
assert.ok(fs.existsSync(fullPath), `Script referenced in hooks.json does not exist on disk: ${fullPath}`);
const basename = path.basename(scriptPath);
assert.ok(
MANAGED_HOOKS.includes(basename),
`Script basename "${basename}" is not listed in hooks/managed-hooks-registry.cjs MANAGED_HOOKS`
);
}
});
test('all eight always-on hooks are wired', (t) => {
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
const REQUIRED_HOOKS = [
'gsd-check-update.js',
'gsd-prompt-guard.js',
'gsd-read-guard.js',
'gsd-worktree-path-guard.js',
'gsd-write-guard.js',
'gsd-secret-read-guard.js',
'gsd-context-monitor.js',
'gsd-read-injection-scanner.js',
];
// Collect all basenames wired in hooks.json
const wiredBasenames = new Set();
const scriptPathRe = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/([^"]+)/g;
for (const eventEntries of Object.values(hooksConfig.hooks)) {
for (const entry of eventEntries) {
for (const hook of entry.hooks) {
const matches = [...hook.command.matchAll(scriptPathRe)];
for (const m of matches) {
wiredBasenames.add(m[1]);
}
}
}
}
for (const required of REQUIRED_HOOKS) {
assert.ok(wiredBasenames.has(required), `Required hook "${required}" is not wired in hooks/hooks.json`);
}
});
test('gsd-context-monitor.js entry has timeout === 10', (t) => {
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
let found = false;
for (const eventEntries of Object.values(hooksConfig.hooks)) {
for (const entry of eventEntries) {
for (const hook of entry.hooks) {
if (hook.command && hook.command.includes('gsd-context-monitor.js')) {
found = true;
assert.equal(hook.timeout, 10, 'gsd-context-monitor.js must have timeout === 10');
}
}
}
}
assert.ok(found, 'gsd-context-monitor.js entry was not found in hooks.json');
});
});
// ─── Section C: Unconditional JSON schema gate + opportunistic CLI integration ──
//
// The `claude plugin validate --strict` binary is absent on CI, so Section C was
// previously SKIPPED there — the only full-schema gate never ran. This section
// replaces the skip-on-absent pattern with three tiers:
//
// C1 (UNCONDITIONAL) — Validate plugin.json against a snapshotted JSON schema
// fixture that captures the fields `--strict` requires. Runs on every
// platform, every CI job, every local run. A bug that removes `version`
// or changes `name` to an invalid form goes red immediately.
//
// C2 (OPPORTUNISTIC — LOCAL-ONLY IN PRACTICE) — When the `claude` binary IS on
// PATH, also run `claude plugin validate <temp-plugin-root> --strict` as an
// end-to-end smoke test, catching schema changes Claude Code may introduce
// that the C1 fixture hasn't yet captured.
//
// #3613: no job under .github/workflows/ installs the `claude` CLI, so
// `claudeAvailable` is false on today's CI images and this tier runs ONLY on a
// developer machine that happens to have the binary. Read its coverage
// that way — a best-effort local check, not a gate any PR must clear.
// Provisioning the CLI in a CI job would turn it into a real gate; that
// is a maintainer call and is deliberately left open here.
//
// C3 (UNCONDITIONAL) — Enforces a symlink-free fixture throughout, deliberately
// stricter than what C2 itself requires. It is a
// tripwire against a symlink regression, not a substitute for C2's end-to-end
// check — but it is the only part of that pair CI can run.
//
describe('C: plugin.json schema validation', () => {
const SCHEMA_FIXTURE_PATH = path.join(__dirname, 'fixtures', 'plugin-manifest-schema.json');
// ── C1: Unconditional structural gate ────────────────────────────────────────
//
// Validates plugin.json against the required fields from the snapshotted
// schema fixture (tests/fixtures/plugin-manifest-schema.json) using explicit
// structural assertions. This avoids a runtime dependency on `ajv` (which is
// only a transitive dep) while providing identical coverage for the fields that
// `claude plugin validate --strict` requires.
//
// Required fields and constraints are derived directly from SCHEMA_FIXTURE_PATH.
// If the fixture changes (new required field, new pattern), update this test too.
test('C1: plugin.json satisfies the snapshotted Claude Code plugin schema (unconditional)', () => {
assert.ok(
fs.existsSync(SCHEMA_FIXTURE_PATH),
`Schema fixture must exist: ${SCHEMA_FIXTURE_PATH}`
);
assert.ok(
fs.existsSync(PLUGIN_JSON_PATH),
`.claude-plugin/plugin.json must exist: ${PLUGIN_JSON_PATH}`
);
const manifest = JSON.parse(fs.readFileSync(PLUGIN_JSON_PATH, 'utf-8'));
const schema = JSON.parse(fs.readFileSync(SCHEMA_FIXTURE_PATH, 'utf-8'));
const errors = [];
const schemaRequired = Array.isArray(schema.required) ? schema.required : [];
const schemaProps = (schema.properties && typeof schema.properties === 'object') ? schema.properties : {};
// Helper: assert a required field exists with the expected type.
function requireField(key, type) {
if (!(key in manifest)) {
errors.push(`"${key}" is required but missing`);
} else if (typeof manifest[key] !== type) {
errors.push(`"${key}" must be a ${type}, got ${typeof manifest[key]}`);
}
}
// Derive required fields and their types directly from the schema fixture.
// Each required field whose "properties" entry has a primitive "type" is
// checked via requireField; "object"-typed fields are handled below.
for (const key of schemaRequired) {
const propDef = schemaProps[key];
const fieldType = propDef && propDef.type;
if (fieldType === 'object') {
// Object fields are validated with deeper checks below.
continue;
}
requireField(key, fieldType || 'string');
}
// Validate "object"-typed required fields from the schema.
// For each such field, check existence, type, and any nested "required" sub-fields.
for (const key of schemaRequired) {
const propDef = schemaProps[key];
if (!propDef || propDef.type !== 'object') continue;
if (!(key in manifest)) {
errors.push(`"${key}" is required but missing`);
} else if (typeof manifest[key] !== 'object' || manifest[key] === null) {
errors.push(`"${key}" must be an object`);
} else {
// Validate nested required sub-fields declared in the schema.
const nestedRequired = Array.isArray(propDef.required) ? propDef.required : [];
const nestedProps = (propDef.properties && typeof propDef.properties === 'object') ? propDef.properties : {};
for (const subKey of nestedRequired) {
const subDef = nestedProps[subKey];
const subType = subDef && subDef.type;
if (!(subKey in manifest[key])) {
errors.push(`"${key}.${subKey}" is required but missing`);
} else if (subType && typeof manifest[key][subKey] !== subType) {
errors.push(`"${key}.${subKey}" must be a ${subType}, got ${typeof manifest[key][subKey]}`);
}
// minLength check for nested string sub-fields
if (subType === 'string' && subDef.minLength !== undefined) {
if (typeof manifest[key][subKey] === 'string' && manifest[key][subKey].length < subDef.minLength) {
errors.push(`"${key}.${subKey}" must have minLength ${subDef.minLength}`);
}
}
}
}
}
// Derive pattern and minLength constraints from the schema fixture properties.
for (const key of schemaRequired) {
const propDef = schemaProps[key];
if (!propDef || propDef.type === 'object') continue;
const value = manifest[key];
if (propDef.pattern && typeof value === 'string') {
// Pattern extracted verbatim from the schema fixture — the shipped
// JSON-schema pattern IS the product under test (#3951).
const re = new RegExp(propDef.pattern); // allow-adhoc-regex-escape: runtime-contract-is-the-product
if (!re.test(value)) {
errors.push(`"${key}" must match ${propDef.pattern}, got "${value}"`);
}
}
if (propDef.minLength !== undefined && typeof value === 'string') {
if (value.length < propDef.minLength) {
errors.push(`"${key}" must have minLength ${propDef.minLength}, got length ${value.length}`);
}
}
}
if (errors.length > 0) {
assert.fail(
`plugin.json fails structural validation against ${path.relative(ROOT, SCHEMA_FIXTURE_PATH)}:\n` +
errors.map(e => ` - ${e}`).join('\n') +
`\n\nFull manifest:\n${JSON.stringify(manifest, null, 2)}`
);
}
});
// ── C2: Opportunistic CLI integration (skipped when claude not on PATH) ──────
// #2665: the `claude` CLI is a THIRD-PARTY binary that bootstraps its own
// config (.claude.json plus a backups/ dir) into whatever CLAUDE_CONFIG_DIR
// names. Two things follow, and only the first is obvious:
//
// 1. Inheriting the developer's ambient CLAUDE_CONFIG_DIR makes even a bare
// `--version` probe write into their live config dir. That alone kept
// `CLAUDE_CONFIG_DIR=<dir> npm run test:unit; find <dir> -type f` from
// returning empty after every GSD-side leak was closed.
// 2. BLANKING it (TEST_ENV_BASE's '' convention) is not enough here. GSD's
// own resolvers treat '' as falsy and fall back to the home dir, but this
// binary is not ours and gives no such guarantee -- blanking it produced a
// stray backups/ directory in the REPO ROOT.
//
// So point it at a real throwaway dir rather than at nothing.
const claudeCliHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2665-claude-cli-'));
const claudeCliEnv = () => ({
...process.env,
...TEST_ENV_BASE,
HOME: claudeCliHome,
USERPROFILE: claudeCliHome,
CLAUDE_CONFIG_DIR: path.join(claudeCliHome, '.claude'),
});
const claudeAvailable = (() => {
try {
const result = spawnSync('claude', ['--version'], {
encoding: 'utf-8',
timeout: CLAUDE_VERSION_PROBE_TIMEOUT_MS,
env: claudeCliEnv(),
});
return result.status === 0;
} catch (_) {
return false;
}
})();
// #3613: the component directories are COPIED, never symlinked. `claude plugin
// validate` reads them WITHOUT following symlinks and warns when it finds one,
// and `--strict` promotes that warning to a non-zero exit — so a symlinked
// fixture failed the gate on its own construction rather than on the manifest
// under test. Copying still gives the CLI a tree containing only plugin.json
// and the three component directories — which is the isolation the temp root
// exists for, since nothing else from the repo root is placed where the
// validator can read it — while letting it actually read those components.
// Exactly the three directories #3613 names — the ones the pre-fix code
// symlinked. An earlier revision also copied agents/, on the (correct)
// observation that the CLI auto-validates it and a frontmatter-less
// agents/*.md exits 1. Dropped in review: it is a NEW gate the issue does not
// ask for, on the largest of the trees. RESOLVED by #3751 (2026-09-02,
// options 1+3): CI provisions the claude CLI, agents/ is in the fixture, and
// the asymmetry #3613 existed to remove is gone — C2 runs in CI.
// #3751 (decision 1+3, 2026-09-02): agents/ is included — the CLI validates
// it by convention (measured on 2.1.239), and CI now provisions the claude
// CLI in a dedicated test.yml job, so C2 is a real gate over this tree.
const COMPONENT_DIRS = ['commands', 'hooks', 'skills', 'agents'];
/**
* One entry that must survive the copy into each component tree, so C3 catches
* a partial copy rather than only a wholly empty one. `commands/gsd` and
* `skills/` are what `.claude-plugin/plugin.json` declares; `hooks/hooks.json`
* is the manifest the runtime loads and the one entry hooks/ cannot be useful
* without.
*/
const EXPECTED_ENTRY = {
commands: 'gsd',
hooks: 'hooks.json',
skills: 'gsd-add-tests',
// #3751: agents/ is undeclared in plugin.json (CLI-convention pickup), so
// the expected entry is a shipped agent file, not a manifest-declared path.
agents: 'gsd-executor.md',
};
/**
* Should this top-level `hooks/` entry be copied into the validation fixture?
*
* By NAME, before anything stats it. `scripts/build-hooks.js` writes
* atomically through a per-PID `hooks/.dist-staging-<pid>` and removes it when
* done, and nine test files invoke that script from their before() hooks — so
* a walk can enumerate a staging directory and then lstat it after the owning
* process deleted it (ENOENT). A filter applied AFTER the stat does not close
* that. `dist` is excluded for an independent reason: a real marketplace
* install contains neither dist nor a transient staging dir.
*
* Deliberately local rather than reusing cold-runtime-lib-fixture.cjs's
* identical predicate. That one is documented as scoped to the cold-tree
* fixture and had exactly one caller; making it two, across fixtures with
* different requirements and nothing asserting they stay compatible, is how a
* later cold-tree change silently alters what this fixture validates.
* Raised in review of #3627.
*/
function shouldCopyHookEntry(name) {
return name !== 'dist' && !name.startsWith('.dist-staging');
}
function buildValidationPluginRoot() {
const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-plugin-validate-'));
// Construction is self-cleaning: the callers' try/finally only begins once
// this returns, so a throw partway through would otherwise strand a
// half-built root on disk. Before this helper existed the same steps ran
// inside C2's own try, and that teardown guarantee is preserved here.
try {
fs.mkdirSync(path.join(pluginRoot, '.claude-plugin'), { recursive: true });
fs.copyFileSync(PLUGIN_JSON_PATH, path.join(pluginRoot, '.claude-plugin', 'plugin.json'));
for (const dir of COMPONENT_DIRS) {
// Copy entry-by-entry and skip by NAME BEFORE anything stats it. A bare
// recursive copy of hooks/ races the builders: scripts/build-hooks.js
// writes atomically through a per-PID `hooks/.dist-staging-<pid>` and
// removes it when done, and nine test files invoke that script from
// their before() hooks — so a recursive walk can enumerate a staging
// directory and then lstat it after the owning process deleted it
// (ENOENT). #3656 fixed this same shape in the cold-tree fixture; the
// rule is the same one, stated locally above rather than imported.
const src = path.join(ROOT, dir);
fs.mkdirSync(path.join(pluginRoot, dir), { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
// The predicate is documented as a hooks/ filter, so apply it only
// there. Applying it to the other trees happens to be harmless today
// but silently encodes a hooks-shaped exclusion into them — a future
// commands/dist would vanish from the validated tree with no signal.
if (dir === 'hooks' && !shouldCopyHookEntry(entry.name)) continue;
fs.cpSync(path.join(src, entry.name), path.join(pluginRoot, dir, entry.name), { recursive: true });
}
}
} catch (err) {
// Best-effort: cleanup() can itself throw (it tolerates Windows EBUSY by
// giving up after retries), and a throw here would replace the real
// construction error with a teardown one.
try {
cleanup(pluginRoot);
} catch {
/* keep the original error */
}
throw err;
}
return pluginRoot;
}
test(
'C2: claude plugin validate --strict exits 0 (opportunistic — skip when claude not on PATH)',
{
skip: !claudeAvailable
? 'claude binary not on PATH (local-only tier — no CI job provisions it, see #3613)'
: false,
},
() => {
const pluginRoot = buildValidationPluginRoot();
try {
const result = spawnSync('claude', ['plugin', 'validate', pluginRoot, '--strict'], {
cwd: ROOT,
encoding: 'utf-8',
timeout: PROBE_TIMEOUT_MS,
env: claudeCliEnv(),
});
assert.equal(
result.status,
0,
`claude plugin validate ${pluginRoot} --strict exited with ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
);
} finally {
cleanup(pluginRoot);
}
}
);
// ── #3751: agents/ coverage (maintainer decision 2026-09-02: options 1+3) ────
//
// `claude plugin validate` auto-validates agents/ by CLI CONVENTION (the
// manifest does not declare it), measured live on CLI 2.1.239 in the issue.
// Decision: CI provisions the claude CLI (a dedicated test.yml job), so C2 is
// a real gate, and the fixture + C3 cover the tree everywhere else.
test('C3+#3751: the validation fixture covers agents/, the tree the CLI validates by convention', () => {
const pluginRoot = buildValidationPluginRoot();
try {
const agentsDir = path.join(pluginRoot, 'agents');
const stat = fs.lstatSync(agentsDir);
assert.ok(stat.isDirectory(), 'agents/ must be a real directory in the C2 validation fixture');
assert.equal(stat.isSymbolicLink(), false, 'agents/ must be copied, not symlinked');
const entries = fs.readdirSync(agentsDir);
assert.ok(entries.length > 0, 'agents/ is EMPTY in the C2 validation fixture');
assert.ok(
entries.some((e) => /^gsd-.*\.md$/.test(e)),
'agents/ must carry the shipped gsd-*.md files, not a stub'
);
} finally {
cleanup(pluginRoot);
}
});
test('#3751: CI provisions the claude CLI so C2 is a real gate, not a local-only tier', () => {
const workflow = fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'test.yml'), 'utf8');
assert.ok(
/@anthropic-ai\/claude-code/.test(workflow),
'test.yml must install the claude CLI (npm i -g @anthropic-ai/claude-code) in a job'
);
assert.ok(
/plugin-manifest\.test\.cjs/.test(workflow),
'the provisioning job must run tests/plugin-manifest.test.cjs (the C2 gate)'
);
});
// ── C3: Unconditional fixture-construction guard ─────────────────────────────
//
// #3613 regression. C2 above is the test that actually shells out to the CLI,
// but no CI job provisions that binary today, so it does not execute there. A
// revert to symlinked component directories would therefore sail through every
// CI lane and surface only as a red suite on contributor machines — which is
// exactly how #3613 went unnoticed. This enforces a symlink-free fixture with no
// dependency on the CLI, so it runs on every platform and every job — a stricter
// invariant than C2 requires, chosen so it does not encode the CLI's exact and
// undocumented boundary.
//
// Scope, stated honestly: this is a tripwire, not product coverage. It cannot be
// demonstrated red against the base commit, because the helper it calls arrives in
// the same change; the genuine failing-first artifact for #3613 is C2. What it buys
// is that a future edit reverting the helper to symlinkSync goes red somewhere CI
// can see, which C2 cannot do.
//
// It deliberately builds the fixture for real rather than stubbing it — that is the
// only way it exercises the code path it guards, and it is why the ~1.06 MB copy (commands 340K, hooks 380K excluding dist, skills 336K — 176 files) now
// runs on every job (and twice when `claude` is present). Do not "optimize" that
// into a stub; it would void the guard — and the emptiness assertions in C3 are
// what make that statement enforceable rather than advisory.
test('C3: validation fixture exposes real component directories, not symlinks (#3613)', () => {
const pluginRoot = buildValidationPluginRoot();
try {
for (const dir of COMPONENT_DIRS) {
const target = path.join(pluginRoot, dir);
const stat = fs.lstatSync(target);
assert.equal(
stat.isSymbolicLink(),
false,
`${dir}/ is a symlink in the C2 validation fixture. \`claude plugin validate\` reads component directories without following symlinks and warns on each one, and --strict turns that warning into a failing exit (#3613). Copy the directory with fs.cpSync instead of symlinking it.`
);
assert.ok(stat.isDirectory(), `${dir}/ must be a real directory in the C2 validation fixture`);
// Raised in review: without this, C3 goes green on a fixture that
// validates nothing. buildValidationPluginRoot() mkdir's every component
// dir BEFORE the entry loop, so if the copy ever stops happening — a
// broadened filter, a mis-scoped `if (dir === ...)`, a wrong src, an
// early continue — the result is real, empty directories, and all three
// structural assertions above still pass (an empty tree contains no
// symlinks). C2 would catch it, but C2 does not run in CI; C3 is the only
// CI-visible guard on this fixture, so it has to see it.
const contents = fs.readdirSync(target);
assert.ok(
contents.length > 0,
`${dir}/ is EMPTY in the C2 validation fixture. The directory was created but nothing was copied into it — C3's symlink assertions pass vacuously on an empty tree, and \`claude plugin validate\` reports "Path not found" for the manifest's declared components.`
);
// A known entry per tree, so a PARTIAL copy is caught too, not just a
// wholly empty one. These are the paths plugin.json declares (commands,
// skills) and the hooks manifest the runtime loads.
assert.ok(
fs.existsSync(path.join(target, EXPECTED_ENTRY[dir])),
`${dir}/${EXPECTED_ENTRY[dir]} is missing from the C2 validation fixture — the copy is partial, so the validated tree is not the shipped one.`
);
}
// Depth-N, not just depth-1. fs.cpSync defaults to dereference:false, so any
// symlink inside a component directory is copied AS a symlink.
//
// An earlier revision of this comment carried a table indexed by DEPTH and
// claimed a symlink two levels inside skills/ exits 0. That row was wrong,
// and the mistake was measuring an inert file. Re-measured on CLI 2.1.239
// (exit code of `plugin validate --strict`), reproducing the review's
// 2.1.237 result:
//
// component dir is itself a symlink ................... 1
// symlink one level inside skills/ (file or dir) ...... 1
// symlink two levels in, an inert file (a stray *.md) . 0
// symlink two levels in that IS the component file
// (skills/<name>/SKILL.md) .......................... 1
// symlink inside commands/ (a dir, or a component *.md) . 0
// stray symlinked dir or *.js inside hooks/ ........... 0
// symlinked hooks/hooks.json .......................... 1
//
// So the boundary is not depth at all — it is whether the symlink is a file
// the CLI actually reads as a component ("1 component here was not read —
// the path is not a regular file"). That is an external, undocumented
// boundary that has already moved once between CLI versions, so C3 does not
// encode it: it enforces the stricter invariant "no symlinks anywhere in the
// fixture", a superset that stays correct as the CLI tightens, for one walk
// over ~176 files. The trees are symlink-free today, so this is latent, not
// a live break.
//
// Walk with an explicit stack, NOT readdirSync's `recursive: true` — that
// option follows directory symlinks (verified on Node 24: a symlinked dir
// inside the tree is descended into), so a link pointing outward would walk
// an unrelated tree, or a cycle, before this assertion ever ran. Record links
// and descend only into real directories.
const nested = [];
const stack = COMPONENT_DIRS.map((dir) => path.join(pluginRoot, dir));
while (stack.length > 0) {
const current = stack.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const full = path.join(current, entry.name);
if (entry.isSymbolicLink()) {
nested.push(path.relative(pluginRoot, full));
} else if (entry.isDirectory()) {
stack.push(full);
}
}
}
nested.sort();
assert.deepStrictEqual(
nested,
[],
`The C2 validation fixture contains symlink(s): ${nested.join(', ')}. The fixture must be symlink-free throughout: \`claude plugin validate\` reads component directories without following symlinks and warns on ones it finds, and --strict turns that warning into a failing exit (#3613). Measured on CLI 2.1.239, a component dir itself, an entry directly under skills/, a symlinked SKILL.md at any depth, or a symlinked hooks/hooks.json is enough to fail; this assertion is deliberately stricter than that boundary so it stays correct if the CLI tightens. Copy with fs.cpSync instead of symlinking.`
);
} finally {
cleanup(pluginRoot);
}
});
});
// ─── Section D: Always-on hook contract (drift guard) ────────────────────────
describe('D: always-on hook contract drift guard', () => {
/**
* Parses hooks.json and builds a map:
* event -> matcher (or '' for no-matcher) -> [{script, timeout}]
*
* script: basename of the .js/.sh file referenced in the command string
* timeout: numeric value from hook.timeout, or undefined if absent
*/
function buildHookMap() {
const raw = fs.readFileSync(HOOKS_JSON_PATH, 'utf-8');
const hooksConfig = JSON.parse(raw);
const scriptRe = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/([^\s"]+)/;
const map = {};
for (const [eventName, eventEntries] of Object.entries(hooksConfig.hooks)) {
map[eventName] = map[eventName] || {};
for (const entry of eventEntries) {
const matcher = entry.matcher || '';
map[eventName][matcher] = map[eventName][matcher] || [];
for (const hook of entry.hooks) {
const m = hook.command.match(scriptRe);
if (m) {
map[eventName][matcher].push({
script: m[1],
timeout: hook.timeout,
});
}
}
}
}
return map;
}
test('SessionStart: one no-matcher group with gsd-ensure-canonical-path.js then gsd-check-update.js', () => {
// #997: gsd-ensure-canonical-path.js is wired alongside gsd-check-update.js
// in the single SessionStart no-matcher group. It must run FIRST so the
// canonical ~/.claude/gsd-core path (and its @-include targets) exist before
// any other SessionStart logic that may read the bundled tree.
const map = buildHookMap();
const groups = map['SessionStart'];
assert.ok(groups, 'SessionStart must be present in hooks.json');
// There must be exactly one entry group (key '' = no matcher)
const noMatcherHooks = groups[''];
assert.ok(
Array.isArray(noMatcherHooks) && noMatcherHooks.length === 2,
`SessionStart no-matcher group must contain exactly two hooks; got: ${JSON.stringify(noMatcherHooks)}`
);
assert.equal(
noMatcherHooks[0].script, 'gsd-ensure-canonical-path.js',
'gsd-ensure-canonical-path.js must be the FIRST SessionStart hook (#997)'
);
assert.equal(
noMatcherHooks[0].timeout, 5,
'gsd-ensure-canonical-path.js must have a small timeout (5s) — symlink setup is fast'
);
assert.equal(
noMatcherHooks[1].script, 'gsd-check-update.js',
'gsd-check-update.js must remain a SessionStart hook'
);
assert.equal(noMatcherHooks[1].timeout, undefined, 'gsd-check-update.js must NOT have a timeout field');
});
test('PreToolUse Write|Edit group: gsd-prompt-guard.js (timeout 5) + gsd-read-guard.js (timeout 5)', () => {
const map = buildHookMap();
const groups = map['PreToolUse'];
assert.ok(groups, 'PreToolUse must be present in hooks.json');
const hooks = groups['Write|Edit'];
assert.ok(
Array.isArray(hooks) && hooks.length === 2,
`PreToolUse Write|Edit must have exactly 2 hooks; got: ${JSON.stringify(hooks)}`
);
assert.equal(hooks[0].script, 'gsd-prompt-guard.js', 'first hook must be gsd-prompt-guard.js');
assert.equal(hooks[0].timeout, 5, 'gsd-prompt-guard.js must have timeout 5');
assert.equal(hooks[1].script, 'gsd-read-guard.js', 'second hook must be gsd-read-guard.js');
assert.equal(hooks[1].timeout, 5, 'gsd-read-guard.js must have timeout 5');
});
test('PreToolUse Write|Edit|MultiEdit group: gsd-worktree-path-guard.js (timeout 5)', () => {
const map = buildHookMap();
const groups = map['PreToolUse'];
assert.ok(groups, 'PreToolUse must be present in hooks.json');
const hooks = groups['Write|Edit|MultiEdit'];
assert.ok(
Array.isArray(hooks) && hooks.length === 1,
`PreToolUse Write|Edit|MultiEdit must have exactly 1 hook; got: ${JSON.stringify(hooks)}`
);
assert.equal(hooks[0].script, 'gsd-worktree-path-guard.js', 'hook must be gsd-worktree-path-guard.js');
assert.equal(hooks[0].timeout, 5, 'gsd-worktree-path-guard.js must have timeout 5');
});
test('PreToolUse Write group: gsd-write-guard.js (timeout 5)', () => {
const map = buildHookMap();
const groups = map['PreToolUse'];
assert.ok(groups, 'PreToolUse must be present in hooks.json');
// #2255: catastrophic-shrink guard for curated .planning/ writes — its own
// matcher group because it guards Write payloads only (Edit/MultiEdit are
// scoped by construction and out of scope by design).
const hooks = groups['Write'];
assert.ok(
Array.isArray(hooks) && hooks.length === 1,
`PreToolUse Write must have exactly 1 hook; got: ${JSON.stringify(hooks)}`
);
assert.equal(hooks[0].script, 'gsd-write-guard.js', 'hook must be gsd-write-guard.js');
assert.equal(hooks[0].timeout, 5, 'gsd-write-guard.js must have timeout 5');
});
test('PreToolUse Read|Grep|Bash group: gsd-secret-read-guard.js (timeout 5)', () => {
const map = buildHookMap();
const groups = map['PreToolUse'];
assert.ok(groups, 'PreToolUse must be present in hooks.json');
// #4221: secret-file read guard — its own matcher group because it is the
// only guard that fires on Read/Grep/Bash (the reading tools).
const hooks = groups['Read|Grep|Bash'];
assert.ok(
Array.isArray(hooks) && hooks.length === 1,
`PreToolUse Read|Grep|Bash must have exactly 1 hook; got: ${JSON.stringify(hooks)}`
);
assert.equal(hooks[0].script, 'gsd-secret-read-guard.js', 'hook must be gsd-secret-read-guard.js');
assert.equal(hooks[0].timeout, 5, 'gsd-secret-read-guard.js must have timeout 5');
});
test('PostToolUse Bash|Edit|Write|MultiEdit|Agent|Task group: gsd-context-monitor.js (timeout 10)', () => {
const map = buildHookMap();
const groups = map['PostToolUse'];
assert.ok(groups, 'PostToolUse must be present in hooks.json');
const hooks = groups['Bash|Edit|Write|MultiEdit|Agent|Task'];
assert.ok(
Array.isArray(hooks) && hooks.length === 1,
`PostToolUse Bash|Edit|Write|MultiEdit|Agent|Task must have exactly 1 hook; got: ${JSON.stringify(hooks)}`
);
assert.equal(hooks[0].script, 'gsd-context-monitor.js', 'hook must be gsd-context-monitor.js');
assert.equal(hooks[0].timeout, 10, 'gsd-context-monitor.js must have timeout 10');
});
test('PostToolUse Read|WebFetch|WebSearch group: gsd-read-injection-scanner.js (timeout 5)', () => {
const map = buildHookMap();
const groups = map['PostToolUse'];
assert.ok(groups, 'PostToolUse must be present in hooks.json');
// #1577: the injection scanner now also covers WebFetch/WebSearch ingress,
// so the matcher is the combined "Read|WebFetch|WebSearch" group.
const hooks = groups['Read|WebFetch|WebSearch'];
assert.ok(
Array.isArray(hooks) && hooks.length === 1,
`PostToolUse Read|WebFetch|WebSearch must have exactly 1 hook; got: ${JSON.stringify(hooks)}`
);
assert.equal(hooks[0].script, 'gsd-read-injection-scanner.js', 'hook must be gsd-read-injection-scanner.js');
assert.equal(hooks[0].timeout, 5, 'gsd-read-injection-scanner.js must have timeout 5');
});
});
// ─── Section E: Config-gated hooks must be absent from hooks.json ─────────────
describe('E: config-gated (opt-in) hooks must not appear in hooks.json', () => {
const CONFIG_GATED_HOOKS = [
'gsd-workflow-guard.js',
'gsd-validate-commit.sh',
'gsd-graphify-update.sh',
'gsd-session-state.sh',
'gsd-phase-boundary.sh',
'gsd-update-banner.js',
'gsd-statusline.js',
'gsd-check-update-worker.js',
];
test('none of the config-gated hook basenames appear in hooks.json command strings', () => {
const raw = fs.readFileSync(HOOKS_JSON_PATH, 'utf-8');
// Check raw text — simple and resistant to structure changes
for (const hookBasename of CONFIG_GATED_HOOKS) {
assert.ok(
!raw.includes(hookBasename),
`Config-gated hook "${hookBasename}" must NOT appear in hooks/hooks.json ` +
`(it is opt-in and must not run unconditionally on the plugin path)`
);
}
});
});
// ─── Section F: #997 canonical-path hook registration ────────────────────────
//
// gsd-ensure-canonical-path.js must be shipped + wired so plugin installs get a
// real ~/.claude/gsd-core directory (with the immutable bundled subdirs
// symlinked) — otherwise every `@~/.claude/gsd-core/...` include in agents /
// commands / templates resolves to nothing and agents fail (#997).
describe('F: #997 gsd-ensure-canonical-path.js is shipped and wired', () => {
const HOOK_BASENAME = 'gsd-ensure-canonical-path.js';
test('hook source file exists in hooks/', () => {
assert.ok(
fs.existsSync(path.join(ROOT, 'hooks', HOOK_BASENAME)),
`hooks/${HOOK_BASENAME} must exist on disk`
);
});
test('hook is listed in HOOKS_TO_COPY (build-hooks.js) so it ships to dist', () => {
const { HOOKS_TO_COPY } = require(path.join(ROOT, 'scripts', 'build-hooks.js'));
assert.ok(
HOOKS_TO_COPY.includes(HOOK_BASENAME),
`${HOOK_BASENAME} must be in HOOKS_TO_COPY or it never ships to hooks/dist`
);
});
test('hook is listed in MANAGED_HOOKS (staleness detection)', () => {
assert.ok(
MANAGED_HOOKS.includes(HOOK_BASENAME),
`${HOOK_BASENAME} must be in MANAGED_HOOKS so it is checked for staleness after update`
);
});
test('hook is wired in hooks.json SessionStart with ${CLAUDE_PLUGIN_ROOT}', () => {
const hooksConfig = JSON.parse(fs.readFileSync(HOOKS_JSON_PATH, 'utf-8'));
const sessionStart = hooksConfig.hooks.SessionStart || [];
let wired = false;
for (const entry of sessionStart) {
for (const hook of entry.hooks || []) {
if (hook.command && hook.command.includes(HOOK_BASENAME)) {
wired = true;
assert.ok(
hook.command.includes('${CLAUDE_PLUGIN_ROOT}'),
'canonical-path hook command must use ${CLAUDE_PLUGIN_ROOT}'
);
}
}
}
assert.ok(wired, `${HOOK_BASENAME} must be wired under SessionStart in hooks.json`);
});
});
// ─── Section G: #997 ensureCanonicalPath() behavioral regression ─────────────
//
// Drives the hook's exported pure core with fake home / fake plugin-root layouts
// to prove the actual canonical-path bootstrap behaviour: creates symlinks for a
// plugin layout, no-ops for classic installs, preserves user files, prunes stale
// links (self-heal after `claude plugin update`), and handles boundary cases
// (missing bundled dir, pre-existing real dir, pre-existing user file at a link
// target). Behavioral — calls the exported function and asserts the resulting
// filesystem state, not source text.
describe('G: #997 ensureCanonicalPath() behavioural regression', () => {
const { ensureCanonicalPath, dirLinkType, MANAGED_SUBDIRS } =
require(path.join(ROOT, 'hooks', 'gsd-ensure-canonical-path.js'));
test('win32 uses a junction; other platforms use a dir symlink', () => {
// Junction correctness is an explicit requirement but real junctions can
// only be created on Windows. Assert the platform→fs.symlinkSync type
// mapping directly so the win32 branch is covered on any host.
assert.equal(dirLinkType('win32'), 'junction', 'win32 must use a junction');
assert.equal(dirLinkType('linux'), 'dir', 'POSIX must use a dir symlink');
assert.equal(dirLinkType('darwin'), 'dir', 'POSIX must use a dir symlink');
});
let tmp;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-997-'));
});
afterEach(() => {
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- per-test temp cleanup, swallows ENOENT
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
});
// Build a fake plugin layout: <tmp>/plugin/gsd-core/<subdir>/marker.md and a
// separate fake home <tmp>/home with an (initially absent) .claude dir.
function makePluginLayout(subdirs = MANAGED_SUBDIRS) {
const pluginRoot = path.join(tmp, 'plugin');
const bundled = path.join(pluginRoot, 'gsd-core');
for (const sub of subdirs) {
fs.mkdirSync(path.join(bundled, sub), { recursive: true });
fs.writeFileSync(path.join(bundled, sub, 'marker.md'), `bundled ${sub}`);
}
const homeDir = path.join(tmp, 'home');
fs.mkdirSync(path.join(homeDir, '.claude'), { recursive: true });
return { pluginRoot, homeDir, bundled };
}
test('plugin layout: creates ~/.claude/gsd-core with all subdirs symlinked to the bundle', () => {
const { pluginRoot, homeDir, bundled } = makePluginLayout();
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'ensured', `expected ensured; got ${JSON.stringify(result)}`);
const canonical = path.join(homeDir, '.claude', 'gsd-core');
assert.ok(fs.existsSync(canonical), 'canonical dir must exist');
for (const sub of MANAGED_SUBDIRS) {
const linkPath = path.join(canonical, sub);
const st = fs.lstatSync(linkPath);
assert.ok(st.isSymbolicLink(), `${sub} must be a symlink`);
assert.equal(
fs.realpathSync(linkPath),
fs.realpathSync(path.join(bundled, sub)),
`${sub} link must resolve to the bundled subdir`
);
// The @-include target now resolves to real bundled content.
assert.equal(
fs.readFileSync(path.join(linkPath, 'marker.md'), 'utf-8'),
`bundled ${sub}`,
`@-include into ${sub} must resolve to bundled content (this is the #997 fix)`
);
}
assert.deepEqual(result.linked.sort(), [...MANAGED_SUBDIRS].sort());
});
test('idempotent: a second run with the same layout re-affirms links and changes nothing', () => {
const { pluginRoot, homeDir } = makePluginLayout();
ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
const second = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(second.status, 'ensured');
assert.deepEqual(second.linked.sort(), [...MANAGED_SUBDIRS].sort());
assert.deepEqual(second.prunedStale, []);
assert.deepEqual(second.preserved, []);
});
test('classic install: real bundled subdirs at canonical path → no-op (never touched)', () => {
const { pluginRoot, homeDir } = makePluginLayout();
// Simulate a classic bin/install.js layout: canonical dir is a REAL dir with
// REAL subdirs (not symlinks).
const canonical = path.join(homeDir, '.claude', 'gsd-core');
for (const sub of MANAGED_SUBDIRS) {
fs.mkdirSync(path.join(canonical, sub), { recursive: true });
fs.writeFileSync(path.join(canonical, sub, 'real.md'), `classic ${sub}`);
}
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'noop');
assert.equal(result.reason, 'classic-install');
for (const sub of MANAGED_SUBDIRS) {
const st = fs.lstatSync(path.join(canonical, sub));
assert.ok(st.isDirectory() && !st.isSymbolicLink(), `${sub} must stay a real dir`);
}
});
test('no plugin context: CLAUDE_PLUGIN_ROOT unset → no-op (classic/npm install path)', () => {
const { homeDir } = makePluginLayout();
const result = ensureCanonicalPath({ pluginRoot: undefined, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'noop');
assert.equal(result.reason, 'no-plugin-bundle');
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'gsd-core')), 'must not create canonical dir');
});
test('boundary: bundled gsd-core dir missing under plugin root → no-op', () => {
const pluginRoot = path.join(tmp, 'plugin-empty');
fs.mkdirSync(pluginRoot, { recursive: true }); // no gsd-core/ inside
const homeDir = path.join(tmp, 'home');
fs.mkdirSync(path.join(homeDir, '.claude'), { recursive: true });
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'noop');
assert.equal(result.reason, 'no-plugin-bundle');
});
test('preserve: a real user file at a managed link target is never clobbered', () => {
const { pluginRoot, homeDir } = makePluginLayout();
const canonical = path.join(homeDir, '.claude', 'gsd-core');
fs.mkdirSync(canonical, { recursive: true });
// User (or partial state) put a REAL directory at 'references' with content.
fs.mkdirSync(path.join(canonical, 'references'), { recursive: true });
fs.writeFileSync(path.join(canonical, 'references', 'USER-NOTES.md'), 'precious');
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
// 'references' is a real dir → classic detection kicks in and the whole op
// is a no-op, preserving everything. Either way, the user file survives.
assert.ok(
fs.existsSync(path.join(canonical, 'references', 'USER-NOTES.md')),
'user file under a managed target must survive'
);
assert.equal(
fs.readFileSync(path.join(canonical, 'references', 'USER-NOTES.md'), 'utf-8'),
'precious'
);
void result;
});
test('preserve user-generated top-level file (USER-PROFILE.md) while linking subdirs', () => {
const { pluginRoot, homeDir } = makePluginLayout();
const canonical = path.join(homeDir, '.claude', 'gsd-core');
fs.mkdirSync(canonical, { recursive: true });
// A user-generated file at the TOP of the canonical dir (not a managed
// subdir) — must never be removed. No managed subdir is real yet, so the
// hook proceeds to link them.
fs.writeFileSync(path.join(canonical, 'USER-PROFILE.md'), 'my profile');
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'ensured');
assert.ok(
fs.existsSync(path.join(canonical, 'USER-PROFILE.md')),
'USER-PROFILE.md must survive canonical-path setup'
);
assert.equal(fs.readFileSync(path.join(canonical, 'USER-PROFILE.md'), 'utf-8'), 'my profile');
// And subdirs are still linked.
for (const sub of MANAGED_SUBDIRS) {
assert.ok(fs.lstatSync(path.join(canonical, sub)).isSymbolicLink(), `${sub} linked`);
}
});
test('self-heal: a stale symlink (pointing at a removed prior plugin version) is pruned and recreated', () => {
const { pluginRoot, homeDir } = makePluginLayout();
const canonical = path.join(homeDir, '.claude', 'gsd-core');
fs.mkdirSync(canonical, { recursive: true });
// Simulate a stale link left by a previous plugin version that has since
// been removed (claude plugin update rotated the version dir).
const stalePrior = path.join(tmp, 'plugin-OLD', 'gsd-core', 'references');
fs.mkdirSync(stalePrior, { recursive: true });
const linkPath = path.join(canonical, 'references');
fs.symlinkSync(stalePrior, linkPath, 'dir');
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- simulate removed prior version
fs.rmSync(path.join(tmp, 'plugin-OLD'), { recursive: true, force: true });
assert.ok(!fs.existsSync(linkPath), 'precondition: link now dangles (target removed)');
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'ensured');
assert.ok(result.prunedStale.includes('references'), 'stale references link must be pruned');
// Now resolves to the CURRENT bundle.
assert.equal(
fs.realpathSync(linkPath),
fs.realpathSync(path.join(pluginRoot, 'gsd-core', 'references')),
'references must now point at the current bundled tree'
);
});
test('self-heal: a managed symlink pointing at the wrong (but existing) target is repointed', () => {
const { pluginRoot, homeDir } = makePluginLayout();
const canonical = path.join(homeDir, '.claude', 'gsd-core');
fs.mkdirSync(canonical, { recursive: true });
// A link to some OTHER real directory (e.g. a different plugin version still
// on disk). It is a valid link but points at the wrong place.
const otherDir = path.join(tmp, 'plugin-OTHER', 'gsd-core', 'workflows');
fs.mkdirSync(otherDir, { recursive: true });
fs.symlinkSync(otherDir, path.join(canonical, 'workflows'), 'dir');
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'ensured');
assert.equal(
fs.realpathSync(path.join(canonical, 'workflows')),
fs.realpathSync(path.join(pluginRoot, 'gsd-core', 'workflows')),
'workflows must be repointed to the current bundle'
);
});
test('security: bundled gsd-core that symlinks OUTSIDE the plugin root is rejected', () => {
const pluginRoot = path.join(tmp, 'plugin-evil');
fs.mkdirSync(pluginRoot, { recursive: true });
// Attacker places a symlink at <pluginRoot>/gsd-core pointing outside root.
const outside = path.join(tmp, 'OUTSIDE');
fs.mkdirSync(path.join(outside, 'references'), { recursive: true });
fs.symlinkSync(outside, path.join(pluginRoot, 'gsd-core'), 'dir');
const homeDir = path.join(tmp, 'home');
fs.mkdirSync(path.join(homeDir, '.claude'), { recursive: true });
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'noop', 'a bundled tree resolving outside the plugin root must be rejected');
assert.equal(result.reason, 'no-plugin-bundle');
assert.ok(
!fs.existsSync(path.join(homeDir, '.claude', 'gsd-core', 'references')),
'must NOT link the canonical path at content outside the plugin root'
);
});
test('CLAUDE_CONFIG_DIR honoured: canonical path is created under the custom config dir', () => {
const { pluginRoot, homeDir } = makePluginLayout();
const customCfg = path.join(tmp, 'custom-cfg');
fs.mkdirSync(customCfg, { recursive: true });
const result = ensureCanonicalPath({
pluginRoot, homeDir, platform: 'linux',
env: { CLAUDE_CONFIG_DIR: customCfg },
});
assert.equal(result.status, 'ensured');
assert.equal(result.canonicalDir, path.join(customCfg, 'gsd-core'));
assert.ok(fs.lstatSync(path.join(customCfg, 'gsd-core', 'references')).isSymbolicLink());
});
test('canonical path is itself a symlink → no-op (never writes links through a user-pointed symlink)', () => {
// A user pointed ~/.claude/gsd-core at some other directory via a symlink.
// The hook must NOT create managed links through it into a dir it does not
// own — it bails as a no-op.
const { pluginRoot, homeDir } = makePluginLayout();
const userTarget = path.join(tmp, 'user-gsd');
fs.mkdirSync(userTarget, { recursive: true });
const canonical = path.join(homeDir, '.claude', 'gsd-core');
fs.symlinkSync(userTarget, canonical, 'dir');
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'noop');
assert.equal(result.reason, 'canonical-is-symlink');
// No managed links were written into the user's target directory.
for (const sub of MANAGED_SUBDIRS) {
assert.ok(
!fs.existsSync(path.join(userTarget, sub)),
`must not write ${sub} link through the user symlink`
);
}
});
test('uniform result contract: every status carries the four action arrays', () => {
const { pluginRoot, homeDir } = makePluginLayout();
const noop = ensureCanonicalPath({ pluginRoot: undefined, homeDir, platform: 'linux', env: {} });
for (const k of ['linked', 'prunedStale', 'preserved', 'skipped']) {
assert.ok(Array.isArray(noop[k]), `noop result.${k} must be an array, not undefined`);
}
const ensured = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
for (const k of ['linked', 'prunedStale', 'preserved', 'skipped']) {
assert.ok(Array.isArray(ensured[k]), `ensured result.${k} must be an array`);
}
});
test('security: a bundled subdir that symlinks OUTSIDE the bundle is skipped, not linked', () => {
// Defence-in-depth: even within a (validated) plugin root, a tampered
// bundle that ships <bundle>/references as a symlink escaping the bundle
// must NOT be exposed at the canonical path.
const { pluginRoot, homeDir, bundled } = makePluginLayout(['workflows']);
// Plant an escaping symlink at <bundle>/references → outside the bundle.
const outside = path.join(tmp, 'OUTSIDE-references');
fs.mkdirSync(outside, { recursive: true });
fs.writeFileSync(path.join(outside, 'evil.md'), 'evil');
fs.symlinkSync(outside, path.join(bundled, 'references'), 'dir');
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'ensured');
assert.ok(result.linked.includes('workflows'), 'legit subdir still linked');
assert.ok(result.skipped.includes('references'), 'escaping subdir must be skipped');
assert.ok(
!fs.existsSync(path.join(homeDir, '.claude', 'gsd-core', 'references')),
'canonical path must NOT expose the escaping subdir'
);
});
test('partial bundle: only ships some subdirs → links those, skips absent ones', () => {
const { pluginRoot, homeDir } = makePluginLayout(['references', 'workflows']);
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'ensured');
assert.deepEqual(result.linked.sort(), ['references', 'workflows']);
assert.deepEqual(
result.skipped.sort(),
['bin', 'contexts', 'templates'].sort(),
'subdirs not present in the bundle must be skipped, not errored'
);
});
});
// ─── Section H: skills surface projection (#1596 — Phase B-provide) ──────────
//
// ADR-766 originally projected commands + hooks but NOT skills. Phase B-provide
// (#1596) adds a build-generated `skills/` dir + a `skills` manifest field so
// plugin-installed GSD exposes `gsd-core:<skill>` the native Claude Code way.
// The skills are generated from `commands/gsd/*.md` by
// `scripts/gen-plugin-skills.cjs` using `convertClaudeCommandToClaudeSkill`.
describe('H: skills surface projection (#1596)', () => {
const SKILLS_DIR = path.resolve(ROOT, 'skills');
test('plugin.json declares skills: "./skills/"', () => {
const manifest = JSON.parse(fs.readFileSync(PLUGIN_JSON_PATH, 'utf-8'));
assert.equal(
manifest.skills, './skills/',
'plugin.json must declare "skills": "./skills/" so Claude Code discovers plugin skills (#1596)'
);
});
test('skills/ dir exists with at least one gsd-*/SKILL.md', () => {
assert.ok(fs.existsSync(SKILLS_DIR), `skills/ dir must exist (run: npm run gen:plugin-skills -- --write): ${SKILLS_DIR}`);
const entries = fs.readdirSync(SKILLS_DIR, { withFileTypes: true });
const skillDirs = entries.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.ok(skillDirs.length > 0, 'skills/ must contain at least one gsd-*/ directory');
// Each must have a SKILL.md
for (const dir of skillDirs) {
const skillMd = path.join(SKILLS_DIR, dir.name, 'SKILL.md');
assert.ok(fs.existsSync(skillMd), `${dir.name}/SKILL.md must exist`);
}
});
test('every generated SKILL.md has name: and description: frontmatter', () => {
assert.ok(fs.existsSync(SKILLS_DIR), 'skills/ must exist');
const skillDirs = fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.ok(skillDirs.length > 0, 'must have at least one skill dir');
for (const dir of skillDirs) {
const raw = fs.readFileSync(path.join(SKILLS_DIR, dir.name, 'SKILL.md'), 'utf-8');
// eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own generated SKILL.md frontmatter, fixed-size author-controlled content
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
assert.ok(fmMatch, `${dir.name}/SKILL.md must have frontmatter`);
const fm = fmMatch[1];
assert.ok(/^\s*name:\s*\S/m.test(fm), `${dir.name}/SKILL.md frontmatter must have a name: field`);
assert.ok(/^\s*description:\s*\S/m.test(fm), `${dir.name}/SKILL.md frontmatter must have a description: field`);
}
});
test('parity: one skill dir per command file (DEFECT.GENERATIVE-FIX)', () => {
const commandsDir = path.resolve(ROOT, 'commands', 'gsd');
const commandFiles = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'));
const skillDirs = fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.equal(
skillDirs.length, commandFiles.length,
`skills/gsd-*/ count (${skillDirs.length}) must equal commands/gsd/*.md count (${commandFiles.length}). ` +
`Run: npm run gen:plugin-skills -- --write`
);
});
});