Files
msd-core/tests/plugin-manifest.test.cjs
Tom Boucher 15af0f5536 enhance(#3951): B6+B7 — widen two unreachable lint rules and make the guard ledger true (#3965)
* fix(#3951): two lint rules that could not reach the code they govern

B6 names two widenings. Measuring them first turned up a defect the criterion did
not know about, and refuted the reason it gave for one of them.

1. no-adhoc-markdown-parsing self-gates on its own filename.

   Lines 107-110 short-circuit create() to {} unless the path matches
   /(?:^|\/)src\/[^/]+\.cts$/. B6 says to widen the files: glob in
   eslint.config.mjs - but doing only that ships an INERT rule, because the gate
   still returns {} for every new path. Both halves have to change, and the gate
   is the load-bearing one.

   That same regex hides a live hole: [^/]+ is FLAT-ONLY, so it requires the file
   to sit directly in src/. The registered glob is src/**/*.cts, which includes
   subdirectories. 28 .cts files - health-diagnostic-rules/ (10),
   installer-migrations/ (11), observability/ (3), host-integration-adapters/ (2),
   vendor/ (2) - are inside the registered glob and silently skipped.

   Measured with the gate neutralized: 0 violations there today. The hole is
   hiding nothing right now, and is fixed anyway, because "no violations today" is
   not a property that keeps holding.

   The fix is not invented: require-subprocess-timeout.cjs:196 already carries the
   correct form of this guard, /(?:^|\/)src\/.*\.cts$/ with .*, one directory over.
   Checked the other 21 rules for the same bug - no-adhoc-regex-escape and
   no-private-binary-resolution short-circuit only to exempt their own seam file,
   which is the right shape, and no-crlf-fragile-split has no filename gate at
   all. This bug is unique to the one rule.

2. no-adhoc-regex-escape could not see the shape that actually occurs.

   Line 396 gated the whole UNSAFE-NEW-REGEXP arm on arg.type === 'Identifier'.
   Every check below it - the _SOURCE provenance check, the
   isSoleReturnOfOwnParameter shape - lives inside that branch, so
   new RegExp(obj['key']) and new RegExp(cfg.pattern) were never examined at all.
   Runtime data arrives as a property access far more often than as a bare
   identifier, which is exactly why this rule never fired on the #3477 ReDoS.

   Widened to MemberExpression, measured by AST walk across all five registered
   blocks rather than by grep. 27 sites, zero TSAsExpression:

     18  safe new RegExp(X.source, flags)  -> exempted, keyed strictly on the
         PROPERTY being `source`, never on the object. Keying on the object would
         wave through X.anything and buy nothing. B6 estimated ~10; that was an
         undercount.
      3  _SOURCE-suffixed constants reached through a required module namespace
         (phaseId.BRACKET_PHASE_TOKEN_SOURCE) -> the same provenance-exempt class
         the rule already recognizes for bare identifiers, extended to reach them.
         Without this the widening produces 3 false flags.
      6  real findings -> marked, each a test extracting a pattern from a shipped
         file at test time, where the runtime contract IS the product.

   Deliberately the NARROW MemberExpression form. The rule's own
   isSoleReturnOfOwnParameter doc comment records that an earlier broad
   "any non-literal identifier" heuristic produced ~25 false positives and was
   rejected; a re-run of the census after this change flags exactly the 6 above
   and nothing else.

Verified by execution, not by reading: the gate now accepts src/<subdir>/x.cts,
still accepts flat src/x.cts, and still exempts paths outside src/ - each pinned
by a test proven to fail against the old regex. build:lib, lint and lint:ci all
exit 0.

Refs #3951

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

* fix(#3951): give no-adhoc-markdown-parsing its reach, and fix the 80 parses it finds

The rule self-gates on filename AND is registered on one glob, so widening either
half alone is inert. Both move here: the gate now accepts tests/**/*.cjs and
scripts/**/*.cjs alongside src/**/*.cts, and eslint.config.mjs registers it on the
same two.

A test pins that the gate and the registration AGREE, in both directions. The
original defect was a gate narrower than its registration; the failure mode of
this fix is a gate wider than its registration. Both are silent, so the test
asserts the pair rather than either half.

80 violations across 43 files, all in tests/, zero in scripts/. 70 are routed
through the existing seams - scanFencedBlocks, collectSection, stripFencedCode,
tokenizeHeadings from markdown-sectionizer; splitTableRow, parseMarkdownTable,
findTableWithColumns from markdown-table. Headerless STATE.md tables use
splitTableRow per line, because parseMarkdownTable needs a real delimiter row.

10 are suppressed, 12.5%, well under the third that would have meant the rule is
mis-scoped for tests/ rather than the tests carrying debt. Each names its reason:
three regression guards (#3873 / bug-#21) are deliberately independent of the
generator's own fence handling, and routing them through the seam would have them
test the generator against itself; one is a negative-text probe that extracts
nothing; six are a shell-pipe-to-jq detector whose regex coincidentally matches the
table fingerprint and is not markdown parsing at all.

All ten sit in tests whose subject is .md content, which is normally a reason to
prefer the seam. The marker used is allow-adhoc-markdown, distinct from
no-source-grep's allow-test-rule, and lint:ci's lint-allow-test-rule-refs reports
the same 280/280 unverified count as before - checked rather than assumed, because
those two markers are easy to conflate.

The widening earned its keep immediately: it found a test that passed for the
wrong reason.

  tests/config-field-docs.test.cjs asserted notEqual(<cell>, '600') against the
  TYPE column instead of the DEFAULT column. notEqual('number', '600') is true
  forever, so the guard against workflow.subagent_timeout regressing to the old
  seconds default could never fire. docs/CONFIGURATION.md:434 is
  `| workflow.subagent_timeout | number | 300000 | ... |`, so the default is cell
  index 2; the assertion is now row-scoped through splitTableRow and reads 300000.

That is the argument for the widening in one case: the violation was invisible to
lint, the suite was green, and the assertion was vacuous. A rule that cannot reach
a file cannot tell you the file is lying.

Not fixed here, and recorded rather than assumed: #3426/#3239 are NOT reachable by
this widening. tests/package-legitimacy-gate.test.cjs yields zero violations even
with the gate bypassed - its hand-rolled scans are real, but built from line
filters and split('|') rather than the regex-literal fingerprints this rule
detects. They need new detectors. The epic assumed a wider glob would catch them.

build:lib, lint and lint:ci all exit 0; the post-fix census across tests/** and
scripts/** is 0 violations.

Refs #3951

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

* fix(#3951): B7 — and #3356's defects were still live in the code

B7 asks that each closed child be driven fail-first with a behavioral identity
test at the CONSUMER's output. Four of eleven children had no test citing their
issue number. Auditing them by BEHAVIOR rather than by number-grep changed the
answer for three of the four.

#3364 and #2540 — traceability only. Both were implemented by #3941 and their
consumer-output tests exist and were shown failing-first; neither cited its
originating issue, so an audit that greps for the number reports them uncovered.
Tagged the specific asserting test in each file, following the citation form those
files already use.

#3372 — covered, but only at helper level, and the triage narrowed it. Of the four
commands the issue names, only estimate-cli's collectCalibrationSamples actually
enumerates phase dirs from disk; smart-entry, audit and roadmap-upgrade derive from
ROADMAP/body text and never reach the sentinel path, so they are benign by
construction and were left alone rather than "fixed" into churn. The existing #3882
rows asserted the helper's return value. Added a consumer-output test driving
`query estimate-calibrate` and asserting sample_count and the persisted document.
RED proof: reverted collectCalibrationSamples to a raw readdirSync and ran the real
CLI - sample_count 3, sentinel leaked; restored - sample_count 2.

#3356 — NOT covered, and BOTH halves of the defect were still live in source. The
issue is closed; the bug was not fixed. Fixed here rather than writing tests that
document a bug as correct.

  Defect 1, the contradicted row. quick.md:627 claimed
  `quick-tasks-append` performs "the equivalent write" to the Step 7c row. It did
  not: the `#` cell was a positional ordinal and `Directory` read `—`, because the
  route had no way to receive a quick id or task directory. Added OPTIONAL
  `--quick-id` / `--slug` / `--directory`. A caller with neither - fast.md, the
  original #2133 caller - omits them and gets the byte-identical prior row, so
  nothing existing changes. A caller that HAS a real id and directory now gets the
  canonical row quick.md:632 renders. The false-equivalence sentence itself is
  corrected rather than left to mislead the next reader.

  Defect 2, the forced re-derive. The route called readModifyWriteStateMd with no
  options, so a body-only append to the Quick Tasks table triggered a full
  re-derive of the disk-derived progress.* frontmatter. Every other body-only
  writer passes { resync: false } - src/state.cts's own docstring prescribes it -
  and this route was the lone outlier. RED proof: reverted the option, seeded a
  project with 2 real phase dirs and a curated total_phases of 25, ran
  quick-tasks-append; total_phases collapsed to 2. Restored; it stayed 25.

That second one is the shape this epic exists to close: a silent write that
replaces curated state with a re-derivation nobody asked for, exit 0 throughout.

build:lib, lint and lint:ci all exit 0.

Refs #3951

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

* docs(#3951): amend B6's ledger to what was measured, and document the new flags

The ADR gains a ledger amendment in its own correction style - the sixth wrong
premise it records, found the same way as the other five, by measuring before
building.

B6 says the net guard count must fall. It rose: 62 -> 69, +7, measured from the
epic's filing commit to origin/next. The attribution is the point, though. Five of
the seven came from PRs unrelated to this epic, one was added by a phase of it, and
the epic did retire something sub-file - #3884 removed a detector with an explicit
"net: -1 detector, 0 added" ledger. Every named casualty is load-bearing, two
already carry retractions in this same document, and a sweep of all 22 rules plus
every scripts/lint-* found no provably dead guard. There is no honest way to make
the count fall; forcing it would trade coverage for a number, which is the Goodhart
outcome Decision 6 exists to prevent.

The amendment also records that B6's own prescribed fix for one widening was inert.
no-adhoc-markdown-parsing self-gates on its filename, so widening only the files:
glob - which is what the criterion says to do - ships a rule that still returns {}
for every new path. And #3426/#3239 are not reachable by that widening at all;
their scans use line filters and split('|'), not the regex fingerprints the rule
detects. The roster row tracked them against the wrong mechanism.

Three roster rows updated from aspiration to fact: the two widenings are DONE with
their measured counts, and lint-phase-enumeration-drift is marked RETAINED rather
than "expected casualty - verify before retiring", because Phase 5 verified it and
kept it.

The rule Decision 6 should carry forward is stated plainly: a guard ledger is a
claim about COVERAGE, not about COUNT. "Net count must fall" is measurable and
wrong. "Every guard is reachable, and each retirement names what makes its defect
unrepresentable" is the property that was actually wanted.

CLI-TOOLS.md documents the optional --quick-id/--slug/--directory flags and says
plainly that omitting them keeps the pre-#3356 row byte-identical, plus that the
append no longer re-derives progress frontmatter.

New features fragment (id 3951); FEATURES.md regenerated rather than hand-edited.
Changeset is Changed, pr:0 pending backfill.

Refs #3951

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

* test(#3951): correct four rows that pinned the lint rule's old narrow reach

The remote suite came back RED with 5 failures, all in tests/eslint-rules.test.cjs.
They are stale tests, not a regression: four rows assert that
no-adhoc-markdown-parsing is inert outside src/*.cts, which is exactly the
contract this deliverable changes.

Confirmed by reading rather than inferred from the names - the row at :1981 used
filename: 'tests/some.test.cjs' and filename: 'scripts/helper.cjs', the two roots
the rule now covers on purpose.

Worth recording WHY local gates missed this. npm run lint and lint:ci were green,
and the touched test files passed standalone. Lint only reports violations in real
files; these rows assert the rule's REACH using synthetic RuleTester filenames, so
nothing but the full suite could see them. Local green on a rule change says
nothing about the rule's own tests.

Each row is rewritten with BOTH halves rather than flipped from valid to invalid:

  - the same fingerprint under tests/ or scripts/ is now flagged, with the right
    messageId
  - the negative space is preserved - the same fingerprint under a path outside
    all three roots (gsd-core/bin/lib/foo.cjs) is still NOT flagged

The second half is the one that matters. Without it the rule has no boundary and
nothing would catch an over-wide gate later, which is the mirror image of the bug
this deliverable just fixed.

Each row is renamed to state the current contract; the old names said
"non-src/*.cts ... is not flagged" and would have been actively misleading once
the bodies changed.

Proven to test the widening rather than restate it: every flagged half was run
against HEAD~2's pre-widening rule and does NOT fire there, then against the
current rule and does. 12/12 on that probe; the full file is 178/178.

Swept for the same staleness elsewhere and found none.
require-subprocess-timeout's own "inert outside src/*.cts" row is untouched -
that rule's gate was not widened here - and no-adhoc-regex-escape's test file
already carries correctly-targeted rows.

Refs #3951

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

* test(#3951): acknowledge the quick.md growth the attribution guard reported

The full suite came back RED with one failure, and it is mine:

  1 file(s) grew without an acknowledgment:
    quick.md grew 364 bytes

gsd-core/workflows/quick.md is runtime-loaded emitted content, so correcting
its false 'performs the equivalent write' claim trips emitted-attribution by
construction. This is the acknowledgment, not a workaround - there is nothing
to regenerate.

The fragment names ONE path, which is the only one the guard reported. The four
spent acknowledgments it also listed (audit-uat, plan-phase, progress, review)
belong to other fragments whose ripple the base already absorbs; they are inert,
not failures, and are deliberately NOT copied here - naming paths I did not
change would make this record false in the other direction.

Byte figure corrected before committing: the guard reported 37220 -> 37584
(+364), but origin/next has since moved and quick.md is 37232 there now, so the
measured delta is +352. The reason text says so and names the base as a moving
figure rather than pinning a number that is already stale.

Refs #3951

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

* test(#3951): move the quick.md growth ack to a trailer, delete the obsolete fragment

The acknowledgment mechanism changed under this branch. Merging next brought in
the redesign - it also deleted .github/workflows/ack-fragment-sweep.yml, which
was in the merge status and which I did not register at the time - and the guard
now says so directly:

  Add a trailer to a commit in this PR (never a new file).
    Emitted-Drift-Ack-Growth: quick.md - <why this growth is deliberate>

So tests/emitted-drift-acks/3951-quick-append-equivalence.json is obsolete on
arrival. A fragment file is no longer read by anything, and leaving it would be a
dead record that looks like an active one. It is deleted here rather than kept
"just in case".

The byte figure moved again with the merge: 37232 -> 37596, +364. The earlier
fragment said +352, measured before the merge auto-merged quick.md itself. The
trailer carries no number, which is the better design - the figure was stale
twice in two attempts.

Refs #3951

Emitted-Drift-Ack-Growth: quick.md — #3356/#3951 replaces a false claim with an accurate one. Line 627 said the `quick-tasks-append` shortcut "performs the equivalent write" to the Step 7c row rendered above it; it did not, and that was the documented half of #3356 — with no quick id or task directory the route emitted a positional ordinal in `#` and an em-dash in `Directory`, a visibly different row. The corrected sentence has to carry three facts the original elided: what the shortcut actually writes when it has neither input, that this is honest behavior for its real caller (`fast.md`, which has neither), and how a caller with both now gets the byte-identical canonical row via the new optional `--quick-id`/`--slug`/`--directory` flags. Prose is the product here — an executing agent reads this line to decide whether the shortcut is safe for its case, and a shorter correction would either drop the flags (leaving the reader unable to act on the fix) or drop the limitation (recreating the false claim in gentler words).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3951): backfill changeset pr number

Refs #3951

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 23:10:49 -04:00

1223 lines
60 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 PLUGIN_JSON_PATH = path.join(ROOT, '.claude-plugin', 'plugin.json');
const HOOKS_JSON_PATH = path.join(ROOT, 'hooks', 'hooks.json');
// ─── 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 seven 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-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: 5000,
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, and because C2 never runs in CI it
// would be red only on contributor machines with `claude` installed — the
// same worst-of-both-states #3613 exists to remove. agents/ coverage is worth
// having and is tracked as #3751, where "should CI provision the CLI" — the
// decision it actually turns on — can be answered for it.
const COMPONENT_DIRS = ['commands', 'hooks', 'skills'];
/**
* 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',
};
/**
* 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: 15000,
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);
}
}
);
// ── 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('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`
);
});
});