Merge pull request #3537 from open-gsd/feat/2873-cross-scope-shadowing
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
"version": 1,
|
||||
"paths": {
|
||||
"health.md": {
|
||||
"reason": "#3309 (epic #3180 Phase 11, ADR-3180): the `<error_codes>`/`<repair_actions>` tables and their footnote are now GENERATED by `scripts/gen-health-docs.cjs` from the full 31-rule `RULES` table, replacing a hand-maintained 16-code table. #3309 explicitly required closing the 16-vs-30+ documentation gap structurally, so the growth is the deliberate, expected result of that acceptance criterion — not accidental bloat. Regeneration is verified deterministic via `node scripts/gen-health-docs.cjs --check` (wired into `npm run lint:generated-sync`)."
|
||||
"reason": "#3309 (epic #3180 Phase 11, ADR-3180): the `<error_codes>`/`<repair_actions>` tables and their footnote are GENERATED by `scripts/gen-health-docs.cjs` from the live `RULES` table, replacing a hand-maintained 16-code table — growth here is a deliberate, expected consequence of that generator doing its job as `RULES` grows, not accidental bloat. #2873 (epic #2866 Phase 4a) adds health rule W028 (\"A GSD-owned install scope shadows another on this machine\"), growing `RULES` from 31 to 32 entries and the generated `<error_codes>` table from 34 to 35 rows (an incremental 84-byte growth on top of #3309's original generation). Regeneration is verified deterministic via `node scripts/gen-health-docs.cjs --check` (wired into `npm run lint:generated-sync`)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,9 +151,9 @@ describe('gen-health-docs.cjs --check / --write (CLI, --target fixture)', () =>
|
||||
describe('gen-health-docs.cjs row content (representative codes)', () => {
|
||||
const rules = loadRealRules();
|
||||
|
||||
test('produces a 34-row <error_codes> table: 31 rules + 3 pre-checks (E001, E010, I010)', () => {
|
||||
test('produces a 35-row <error_codes> table: 32 rules + 3 pre-checks (E001, E010, I010)', () => {
|
||||
const rows = buildErrorCodeRows(rules);
|
||||
assert.equal(rows.length, 34);
|
||||
assert.equal(rows.length, 35);
|
||||
const codes = rows.map((r) => r.code);
|
||||
for (const precheck of PRECHECK_CODES) {
|
||||
assert.ok(codes.includes(precheck.code), `missing pre-check code ${precheck.code}`);
|
||||
|
||||
@@ -22,10 +22,13 @@ const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const healthDiagnostic = require('../gsd-core/bin/lib/health-diagnostic.cjs');
|
||||
const { buildPlanningSnapshot } = require('../gsd-core/bin/lib/planning-snapshot.cjs');
|
||||
const { createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs');
|
||||
const { cmdValidateHealth } = require('../gsd-core/bin/lib/verify.cjs');
|
||||
const { MANIFEST_NAME } = require('../gsd-core/bin/lib/installer-migrations.cjs');
|
||||
const { createTempProject, createTempGitProject, createTempDir, cleanup, captureConsole } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
SEVERITY,
|
||||
@@ -271,23 +274,21 @@ describe('evaluateRuleTable — duplicate-code guard (row 13)', () => {
|
||||
|
||||
// ─── RULES — the fully wired table ──────────────────────────────────────────
|
||||
//
|
||||
// 31 rule entries, not the design doc's own prose figure of "32" (that doc's
|
||||
// "Rule table organization" section already flags its own count as
|
||||
// inconsistent between its table and prose — see this repo's design doc,
|
||||
// same section). Counted directly from each rule-group file's own exported
|
||||
// 32 rule entries. Counted directly from each rule-group file's own exported
|
||||
// `RULES` array: root-existence (4: E002/E003/E004/W001) + state-consistency
|
||||
// (5: W024/W002/W011/W021/W026) + config-validation (10: W003/E005/W004/
|
||||
// W008/W016/W012/W013/W014/W015/W022) + phase-structure (4: W005/W023/I001/
|
||||
// W009) + agent-install (1: W010) + roadmap-disk-consistency (2: W006/W007)
|
||||
// + worktree-health (3: W020/W017/W027) + milestone-archive-hygiene (2:
|
||||
// W018/W019) = 31. E001 and the home-directory guard (E010/I010) are
|
||||
// deliberately NOT rows (design doc, "Two guards that stay OUTSIDE the rule
|
||||
// table entirely").
|
||||
// W018/W019) + install-surface-shadowing (1: W028, #2873 epic #2866 Phase
|
||||
// 4a) = 32. E001 and the home-directory guard (E010/I010) are deliberately
|
||||
// NOT rows (design doc, "Two guards that stay OUTSIDE the rule table
|
||||
// entirely").
|
||||
|
||||
describe('RULES', () => {
|
||||
test('is the full, frozen 31-rule table with every code unique', () => {
|
||||
test('is the full, frozen 32-rule table with every code unique', () => {
|
||||
assert.equal(Array.isArray(RULES), true);
|
||||
assert.equal(RULES.length, 31);
|
||||
assert.equal(RULES.length, 32);
|
||||
const codes = RULES.map((r) => r.code);
|
||||
assert.equal(new Set(codes).size, codes.length, 'every rule code must be unique');
|
||||
});
|
||||
@@ -301,6 +302,175 @@ describe('RULES', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── W028 — install surface shadowing (#2873, epic #2866 Phase 4a; D1-D5) ──
|
||||
//
|
||||
// `src/health-diagnostic-rules/install-surface-shadowing.cts` reuses W010's
|
||||
// (`agent-install.cts`) runtime-resolution mechanism: `resolveRuntime(cwd)`
|
||||
// (`runtime-slash.cjs`) never throws (env/config/'claude'-default chain), and
|
||||
// `buildShadowReport(runtime, { cwd: snapshot.cwd })` is called with `home`
|
||||
// left un-injected so the resolver defaults to `os.homedir()` — the real
|
||||
// machine, the same production call shape the installer uses. Every row
|
||||
// below therefore drives the REAL global scope by monkeypatching
|
||||
// `os.homedir()` (`installSpawnHome`-style DI is not available to this rule,
|
||||
// which accepts no `home` option at all) rather than `fs.chmodSync`/mode-bit
|
||||
// tricks — this repo's mandated IO-failure-injection technique
|
||||
// (CLAUDE.md → "CROSS-PLATFORM TEST IO-FAILURE INJECTION").
|
||||
//
|
||||
// This suite chose `tests/health-diagnostic.test.cjs` over
|
||||
// `tests/health-diagnostic-rules/agent-install.test.cjs`: the latter is
|
||||
// W010's dedicated fixture file (closest *mechanism* match, cited above, but
|
||||
// a different SUBJECT — agent installation, not install-scope shadowing);
|
||||
// this file is the RULES-table-and-evaluator skeleton suite (`describe(
|
||||
// 'RULES', ...)` immediately above already asserts the wired table includes
|
||||
// every code, W028 included) and is where `evaluateRuleTable`'s own
|
||||
// duplicate-code-guard rows (13) already live — the natural home for D4.
|
||||
// Extending an existing file either way keeps `lint-test-file-count.cjs`'s
|
||||
// `health-diagnostic` prefix bucket unchanged (still the 1 file it was
|
||||
// before this PR).
|
||||
|
||||
function withHomedir(t, tmpHome) {
|
||||
const originalHomedir = os.homedir;
|
||||
os.homedir = () => tmpHome;
|
||||
t.after(() => {
|
||||
os.homedir = originalHomedir;
|
||||
});
|
||||
}
|
||||
|
||||
function writeClaudeManifest(configDir, scope, files) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, MANIFEST_NAME), JSON.stringify({
|
||||
manifestVersion: 2, runtime: 'claude', scope, files,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('W028 (install surface shadowing)', () => {
|
||||
test('D1: health surfaces cross-scope shadowing when both scopes are installed', (t) => {
|
||||
const home = createTempDir('gsd-w028-d1-home-');
|
||||
const cwd = createTempDir('gsd-w028-d1-cwd-');
|
||||
t.after(() => { cleanup(home); cleanup(cwd); });
|
||||
withHomedir(t, home);
|
||||
|
||||
writeClaudeManifest(path.join(home, '.claude'), 'global', { 'skills/gsd-plan-phase/SKILL.md': 'a' });
|
||||
writeClaudeManifest(path.join(cwd, '.claude'), 'local', { 'commands/gsd-plan-phase.md': 'a' });
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
const rule = RULES.find((r) => r.code === 'W028');
|
||||
assert.ok(rule, 'RULES must contain a W028 entry');
|
||||
const diagnostics = rule.check(snapshot);
|
||||
assert.strictEqual(diagnostics.length, 1, `expected exactly one W028 diagnostic, got: ${JSON.stringify(diagnostics)}`);
|
||||
const [d] = diagnostics;
|
||||
assert.strictEqual(d.code, 'W028');
|
||||
assert.strictEqual(d.severity, SEVERITY.WARNING);
|
||||
assert.strictEqual(d.remedy.action, REMEDY_ACTION.ADVISE);
|
||||
assert.strictEqual(d.remedy.risk, REMEDY_RISK.NONE);
|
||||
});
|
||||
|
||||
test('D2: health is quiet without shadowing', (t) => {
|
||||
const home = createTempDir('gsd-w028-d2-home-');
|
||||
const cwd = createTempDir('gsd-w028-d2-cwd-');
|
||||
t.after(() => { cleanup(home); cleanup(cwd); });
|
||||
withHomedir(t, home);
|
||||
// Neither scope has any GSD install at all — nothing to shadow.
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
const rule = RULES.find((r) => r.code === 'W028');
|
||||
assert.deepStrictEqual(rule.check(snapshot), []);
|
||||
});
|
||||
|
||||
test('D3: --json output (cmdValidateHealth raw=true) carries the W028 code structurally', (t) => {
|
||||
const home = createTempDir('gsd-w028-d3-home-');
|
||||
const cwd = createTempGitProject();
|
||||
t.after(() => { cleanup(home); cleanup(cwd); });
|
||||
withHomedir(t, home);
|
||||
|
||||
// A real, otherwise-healthy .planning/ project — required so
|
||||
// cmdValidateHealth's own E001 pre-check does not short-circuit before
|
||||
// the rule table ever runs (that path is D5, below).
|
||||
const sections = ['## What This Is', '## Core Value', '## Requirements'];
|
||||
fs.writeFileSync(path.join(cwd, '.planning', 'PROJECT.md'), `# Project\n\n${sections.map((s) => `${s}\n\nContent here.\n`).join('\n')}`);
|
||||
fs.writeFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), '# Roadmap\n\n### Phase 1: Setup\n');
|
||||
fs.writeFileSync(path.join(cwd, '.planning', 'STATE.md'), '# Session State\n\n## Current Position\n\nPhase: 1\n');
|
||||
fs.writeFileSync(path.join(cwd, '.planning', 'config.json'), JSON.stringify({
|
||||
model_profile: 'balanced', commit_docs: true,
|
||||
workflow: { nyquist_validation: true, ai_integration_phase: true },
|
||||
}, null, 2));
|
||||
fs.mkdirSync(path.join(cwd, '.planning', 'phases', '01-setup'), { recursive: true });
|
||||
|
||||
writeClaudeManifest(path.join(home, '.claude'), 'global', { 'skills/gsd-plan-phase/SKILL.md': 'a' });
|
||||
writeClaudeManifest(path.join(cwd, '.claude'), 'local', { 'commands/gsd-plan-phase.md': 'a' });
|
||||
|
||||
let result;
|
||||
captureConsole(() => {
|
||||
result = cmdValidateHealth(cwd, {}, true);
|
||||
});
|
||||
// Typed structured assertions on the RETURNED payload (the same object
|
||||
// `output(result, raw)` would JSON.stringify for `--json` mode) — never
|
||||
// a substring match against rendered/printed prose.
|
||||
assert.ok(result, 'cmdValidateHealth must return the result payload');
|
||||
const w028Entries = (result.warnings ?? []).filter((w) => w.code === 'W028');
|
||||
assert.strictEqual(w028Entries.length, 1, `expected one W028 warning entry, got: ${JSON.stringify(result.warnings)}`);
|
||||
assert.strictEqual(typeof w028Entries[0].message, 'string');
|
||||
assert.strictEqual(w028Entries[0].repairable, false);
|
||||
});
|
||||
|
||||
test('D4: rule code is unique — W028 appears exactly once and the duplicate-code guard passes over the real, healthy-project RULES evaluation', (t) => {
|
||||
const codes = RULES.map((r) => r.code);
|
||||
assert.strictEqual(codes.filter((c) => c === 'W028').length, 1, 'W028 must appear exactly once in RULES');
|
||||
|
||||
const tmpDir = createTempGitProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
writeMinimalProjectMd(tmpDir);
|
||||
writeMinimalRoadmap(tmpDir);
|
||||
writeMinimalStateMd(tmpDir);
|
||||
writeValidConfigJson(tmpDir);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-setup'), { recursive: true });
|
||||
|
||||
// evaluateRuleTable's own duplicate-code guard (row 13, above) throws
|
||||
// BEFORE running any check() if two RULES entries share a code — running
|
||||
// the real, full RULES table end to end (via evaluateRules) over a real
|
||||
// snapshot is what proves that guard passes with the real, wired W028
|
||||
// present, not merely that a hand-built fake array behaves.
|
||||
assert.doesNotThrow(() => evaluateRules(buildPlanningSnapshot(tmpDir)));
|
||||
});
|
||||
|
||||
test('D5: health run outside a project (no .planning/) never throws; rule degrades with no config dir', (t) => {
|
||||
const home = createTempDir('gsd-w028-d5-home-');
|
||||
const cwd = createTempDir('gsd-w028-d5-cwd-'); // deliberately no .planning/ created
|
||||
t.after(() => { cleanup(home); cleanup(cwd); });
|
||||
withHomedir(t, home);
|
||||
|
||||
// A real coexistence fixture exists on disk — proves the outer E001
|
||||
// guard (verify.cts, "stays OUTSIDE the rule table entirely") short-
|
||||
// circuits BEFORE the rule table (and W028 specifically) ever runs, not
|
||||
// merely that nothing happens to be installed. `writeAllSync`-based
|
||||
// `output()` writes directly to fd 1 (io.cts), bypassing `console.log`
|
||||
// entirely, so `captureConsole` cannot observe it here — the CONTRACT
|
||||
// under test is `cmdValidateHealth`'s documented early-return shape
|
||||
// itself: `output(...); return;` with no explicit value, i.e. `undefined`.
|
||||
writeClaudeManifest(path.join(home, '.claude'), 'global', { 'skills/gsd-plan-phase/SKILL.md': 'a' });
|
||||
writeClaudeManifest(path.join(cwd, '.claude'), 'local', { 'commands/gsd-plan-phase.md': 'a' });
|
||||
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = cmdValidateHealth(cwd, {}, true);
|
||||
});
|
||||
assert.strictEqual(result, undefined, 'the E001 no-.planning/ pre-check returns before the rule table (and W028) ever runs');
|
||||
|
||||
// "no config dir" half of D5: the rule itself, driven directly, must
|
||||
// degrade to no diagnostic (never throw) when `os.homedir()` resolves to
|
||||
// a path that does not exist on disk at all.
|
||||
const rule = RULES.find((r) => r.code === 'W028');
|
||||
const missingHome = path.join(home, 'does-not-exist-at-all');
|
||||
withHomedir(t, missingHome);
|
||||
const bareCwd = createTempDir('gsd-w028-d5-barecwd-');
|
||||
t.after(() => cleanup(bareCwd));
|
||||
const snapshot = buildPlanningSnapshot(bareCwd);
|
||||
assert.doesNotThrow(() => {
|
||||
assert.deepStrictEqual(rule.check(snapshot), []);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Row 14 — evaluator against an all-clean REAL snapshot ────────────────
|
||||
|
||||
describe('evaluateRules (row 14)', () => {
|
||||
|
||||
44
tests/helpers/shadow-report-throws-preload.cjs
Normal file
44
tests/helpers/shadow-report-throws-preload.cjs
Normal file
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Preload fixture for #2873 matrix row C5 — force the installer's
|
||||
* cross-scope shadow-report call site (bin/install.js, immediately after
|
||||
* writeManifest) to exercise its own swallow-everything catch.
|
||||
*
|
||||
* `install-shadow-report.cjs`'s `buildShadowReport()` only degrades a
|
||||
* `TypeError` thrown by `resolveInstalledSurfaces` (returns
|
||||
* `RESOLVER_UNAVAILABLE`); any OTHER error type it lets propagate — see its
|
||||
* own doc comment. This preload replaces the exported `buildShadowReport`
|
||||
* with a function that always throws a plain `Error`, so whatever calls it
|
||||
* next (`bin/install.js`, requiring the SAME resolved module path and
|
||||
* therefore getting the SAME cached module object) sees that throw and must
|
||||
* swallow it without failing the install (design row C5: "a report failure
|
||||
* never fails the install").
|
||||
*
|
||||
* Loaded via `node --require <this file> bin/install.js ...`
|
||||
* (`tests/helpers/process-seam.cjs`'s `runNode` forwards extra argv
|
||||
* verbatim, ahead of the target script) so the patch lands in the require
|
||||
* cache BEFORE `bin/install.js`'s own
|
||||
* `require('../gsd-core/bin/lib/install-shadow-report.cjs')` resolves.
|
||||
*
|
||||
* One-shot subprocess: no restoration needed — the process exits right
|
||||
* after the single `install()` call this preload targets. This is NOT a
|
||||
* chmod/mode-bit trick (CONTRIBUTING.md: those no-op under root), and NOT
|
||||
* the in-process fs fault-injection seam (`tests/helpers/faulty-deps.cjs`'s
|
||||
* `withFaultyFs` is explicitly documented as in-process-only, since a
|
||||
* spawned subprocess offers no shared memory to monkeypatch into) — this
|
||||
* patches the ONE exported function the design doc names as the report
|
||||
* builder, at the require-cache seam a spawned child process actually
|
||||
* offers.
|
||||
*/
|
||||
|
||||
const path = require('node:path');
|
||||
|
||||
const modPath = require.resolve(
|
||||
path.join(__dirname, '..', '..', 'gsd-core', 'bin', 'lib', 'install-shadow-report.cjs'),
|
||||
);
|
||||
const shadowReportModule = require(modPath);
|
||||
|
||||
shadowReportModule.buildShadowReport = function throwingBuildShadowReport() {
|
||||
throw new Error('injected by tests/helpers/shadow-report-throws-preload.cjs (#2873 matrix row C5)');
|
||||
};
|
||||
@@ -19,13 +19,22 @@
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const { test, describe, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const os = require('node:os');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { runNode } = require('./helpers/process-seam.cjs');
|
||||
const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||||
const {
|
||||
INSTALL_SCRIPT,
|
||||
MANIFEST_NAME,
|
||||
installerEnv,
|
||||
stripAnsi,
|
||||
} = require('./helpers/install-shared.cjs');
|
||||
|
||||
const {
|
||||
installRuntimeArtifacts,
|
||||
@@ -6308,3 +6317,381 @@ describe('Gap 2: installer ships the capability registry generator scripts (#192
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── #2218 cross-scope shadowing — coexistence gate (C1) + 4b guard pair
|
||||
// (E13/E14, #2873, epic #2866 Phase 4a) ────────────────────────────────────
|
||||
//
|
||||
// Moved here from the now-deleted tests/install-cross-scope-shadowing.test.cjs:
|
||||
// `scripts/lint-test-file-count.allowlist.json` grandfathers the `install`
|
||||
// prefix at 8 files, and that suite's own `_doc` says adding a 9th file to a
|
||||
// capped module is a novel offender, not a fix — this gate is folded into
|
||||
// the emitted-artifact suite instead, which is already allowlisted and,
|
||||
// per #2873's acceptance criteria, is the correct home ("written against the
|
||||
// existing `runMinimalInstall` harness").
|
||||
//
|
||||
// Implements the coexistence gate (`C1`) and the 4b behavioral pair
|
||||
// (`E13`/`E14`) from
|
||||
// `.gsd/phase/feat-2873-cross-scope-shadowing/50-test-matrix.md`. Per that
|
||||
// matrix's "Red-first order": C1 must go RED against `next` (no
|
||||
// `install-shadow-report.cjs` report exists today), E14 must go RED today
|
||||
// (the global skill's spec-root include points at the global tree even when
|
||||
// a coexisting local install has its own project-local copy of that
|
||||
// workflow file), and E13 must stay GREEN both before and after — it is the
|
||||
// guard that phase 4b does not break today's global-only case.
|
||||
//
|
||||
// This section does NOT implement the 4b spec-root emission transform
|
||||
// (E1-E12, a separate matrix section) — that transform
|
||||
// (`resolveSpecRootReference`, `runtime-artifact-conversion.cts`) landed
|
||||
// separately and is exercised here only via its INSTALLED OUTPUT. #2873
|
||||
// Task 3 (2026-08-14): re-verified against a real global+local double
|
||||
// install — 4b has landed and E14 below is GREEN, not the known-RED case
|
||||
// this comment block originally described. The "Red-first order" paragraph
|
||||
// above is left as-is: it accurately records the matrix's ORIGINAL red-first
|
||||
// plan, not a live claim about E14's current state.
|
||||
|
||||
/**
|
||||
* Extract the `@`-include lines from an emitted markdown body — structural
|
||||
* parsing, never substring/regex matching on the whole body (CONTRIBUTING.md
|
||||
* "Prohibited: Raw Text Matching on Test Outputs"). Splits on newlines
|
||||
* (CRLF-tolerant) and keeps only lines whose first character is `@`.
|
||||
*
|
||||
* @param {string} content
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function extractAtIncludeLines(content) {
|
||||
return content.split(/\r?\n/).filter((line) => line.startsWith('@'));
|
||||
}
|
||||
|
||||
describe('#2218 cross-scope shadowing', () => {
|
||||
let root;
|
||||
let projectDir;
|
||||
let globalInstallResult;
|
||||
let localInstallResult;
|
||||
|
||||
before(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2218-shadow-'));
|
||||
projectDir = path.join(root, 'myrepo');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
// Global half: cannot use runMinimalInstall here — its scope:'global'
|
||||
// path pushes `--config-dir <root>`, which pins the install AT `<root>`
|
||||
// itself (manifest at `<root>/gsd-file-manifest.json`), not at
|
||||
// `<root>/.claude`. That is not the shape #2218 describes: the reporter's
|
||||
// configuration is a HOME-resolved global install (no --config-dir)
|
||||
// sitting alongside a project-local one. Spawn the installer directly,
|
||||
// with HOME=root and no --config-dir, so it resolves its own config home
|
||||
// the way a real global install does. Must run BEFORE the local half —
|
||||
// order matters for this fixture (a separate test covers order-independence).
|
||||
globalInstallResult = runNode([INSTALL_SCRIPT, '--claude', '--global'], {
|
||||
cwd: root,
|
||||
env: installerEnv({ HOME: root, USERPROFILE: root }),
|
||||
timeoutMs: INSTALL_TIMEOUT_MS,
|
||||
});
|
||||
assert.strictEqual(globalInstallResult.exitCode, 0,
|
||||
`global install exited with status ${globalInstallResult.exitCode} ` +
|
||||
`(outcome=${globalInstallResult.outcome})\n` +
|
||||
`stdout: ${globalInstallResult.stdout}\nstderr: ${globalInstallResult.stderr}`);
|
||||
|
||||
// Local half: runMinimalInstall cannot be reused for this either — for
|
||||
// scope:'local' it sets cwd=root, which would install into
|
||||
// `<root>/.claude` and collide with the global install above. Spawn the
|
||||
// installer directly instead, with cwd pinned at the project dir.
|
||||
localInstallResult = runNode([INSTALL_SCRIPT, '--claude', '--local'], {
|
||||
cwd: projectDir,
|
||||
env: installerEnv({ HOME: root, USERPROFILE: root }),
|
||||
timeoutMs: INSTALL_TIMEOUT_MS,
|
||||
});
|
||||
assert.strictEqual(localInstallResult.exitCode, 0,
|
||||
`local install exited with status ${localInstallResult.exitCode} ` +
|
||||
`(outcome=${localInstallResult.outcome})\n` +
|
||||
`stdout: ${localInstallResult.stdout}\nstderr: ${localInstallResult.stderr}`);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(root);
|
||||
});
|
||||
|
||||
test('both installs land their own manifest', () => {
|
||||
const globalManifestPath = path.join(root, '.claude', MANIFEST_NAME);
|
||||
const localManifestPath = path.join(projectDir, '.claude', MANIFEST_NAME);
|
||||
|
||||
assert.ok(fs.existsSync(globalManifestPath), 'global manifest should exist');
|
||||
assert.ok(fs.statSync(globalManifestPath).isFile(), 'global manifest should be a file');
|
||||
assert.ok(fs.existsSync(localManifestPath), 'local manifest should exist');
|
||||
assert.ok(fs.statSync(localManifestPath).isFile(), 'local manifest should be a file');
|
||||
|
||||
const globalManifest = JSON.parse(fs.readFileSync(globalManifestPath, 'utf8'));
|
||||
const localManifest = JSON.parse(fs.readFileSync(localManifestPath, 'utf8'));
|
||||
|
||||
assert.strictEqual(globalManifest.scope, 'global');
|
||||
assert.strictEqual(localManifest.scope, 'local');
|
||||
});
|
||||
|
||||
test('the local install reports the shadowing it causes', () => {
|
||||
// #2218/#2873: install-shadow-report.cjs does not exist yet — this
|
||||
// require is the intended RED. buildShadowReport is the pure IR builder
|
||||
// described in .gsd/phase/feat-2873-cross-scope-shadowing/40-design.md
|
||||
// (row 3): claude installed at both G and L reports N triggers shadowed,
|
||||
// winner skills@global, loser commands@local.
|
||||
const { buildShadowReport } = require('../gsd-core/bin/lib/install-shadow-report.cjs');
|
||||
const report = buildShadowReport('claude', { home: root, cwd: projectDir });
|
||||
|
||||
assert.strictEqual(report.shadowed, true);
|
||||
assert.strictEqual(report.winner.kind, 'skills');
|
||||
assert.strictEqual(report.winner.scope, 'global');
|
||||
assert.strictEqual(report.shadowedSide.kind, 'commands');
|
||||
assert.strictEqual(report.shadowedSide.scope, 'local');
|
||||
assert.ok(report.triggers.length > 0, 'expected at least one shadowed trigger');
|
||||
});
|
||||
|
||||
test('global-only install resolves the same spec file it does today', () => {
|
||||
const skillPath = path.join(root, '.claude', 'skills', 'gsd-plan-phase', 'SKILL.md');
|
||||
const content = fs.readFileSync(skillPath, 'utf8');
|
||||
const atLines = extractAtIncludeLines(content);
|
||||
|
||||
assert.ok(
|
||||
atLines.includes('@~/.claude/gsd-core/references/ui-brand.md'),
|
||||
`expected the ui-brand reference @-line among: ${JSON.stringify(atLines)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// #2218 / phase #2873: before 4b, the global SKILL.md's spec-root include
|
||||
// was a static `@~/.claude/gsd-core/workflows/plan-phase.md` reference,
|
||||
// which always resolved against the GLOBAL tree even when a coexisting
|
||||
// local install has its own project-local copy of that workflow file.
|
||||
// Phase 4b (`resolveSpecRootReference`, `runtime-artifact-conversion.cts`)
|
||||
// replaces that static include with a two-step imperative form that names
|
||||
// both candidate paths and lets the runtime prefer the local one when it
|
||||
// exists. #2873 Task 3 (2026-08-14): re-verified GREEN against a real
|
||||
// global+local double install — 4b landed after this test package was
|
||||
// authored, so this is no longer the known-RED case the original comment
|
||||
// above it described.
|
||||
test('the winning global skill points at the project-local spec tree (E14)', () => {
|
||||
const skillPath = path.join(root, '.claude', 'skills', 'gsd-plan-phase', 'SKILL.md');
|
||||
const content = fs.readFileSync(skillPath, 'utf8');
|
||||
const atLines = extractAtIncludeLines(content);
|
||||
|
||||
assert.ok(
|
||||
!atLines.includes('@~/.claude/gsd-core/workflows/plan-phase.md'),
|
||||
`expected the static global workflow @-line to be replaced, but found it among: ${JSON.stringify(atLines)}`,
|
||||
);
|
||||
// The reference @-include (a DIFFERENT spec root, row E4) survives
|
||||
// untouched — structural proof 4b did not over-fire on this file.
|
||||
assert.ok(
|
||||
atLines.includes('@~/.claude/gsd-core/references/ui-brand.md'),
|
||||
`expected the ui-brand reference @-line to survive among: ${JSON.stringify(atLines)}`,
|
||||
);
|
||||
|
||||
const localSpecPath = path.join(projectDir, '.claude', 'gsd-core', 'workflows', 'plan-phase.md');
|
||||
assert.ok(fs.existsSync(localSpecPath), 'local spec-root workflow file should exist on disk');
|
||||
|
||||
// Positive assertion, not just absence-of-the-old-include: the emitted
|
||||
// body must actually NAME the project-local candidate path. Exact-string
|
||||
// presence check on the literal candidate path `resolveSpecRootReference`
|
||||
// emits (never a substring-scan for prose wording — CONTRIBUTING →
|
||||
// "Prohibited: Raw Text Matching on Test Outputs"; this checks for the
|
||||
// PATH token, not sentence phrasing).
|
||||
assert.ok(
|
||||
content.includes('.claude/gsd-core/workflows/plan-phase.md'),
|
||||
`expected the emitted body to name the project-local candidate path, got: ${JSON.stringify(content)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #2873 matrix section C — install-time report (spawned installer) ─────
|
||||
//
|
||||
// Implements rows C1-C6 from
|
||||
// `.gsd/phase/feat-2873-cross-scope-shadowing/50-test-matrix.md`. The
|
||||
// `#2218 cross-scope shadowing` suite above calls `buildShadowReport`
|
||||
// DIRECTLY — real coverage of the pure IR, but it proves nothing about the
|
||||
// INSTALLER'S OWN WIRING at bin/install.js's writeManifest-adjacent
|
||||
// try/catch block (the only call site that ever prints a report). These
|
||||
// rows instead spawn the real installer and inspect its own stdout/stderr
|
||||
// and exit code — the actual product surface #2218 reported a gap in.
|
||||
|
||||
describe('#2873 C1-C6 — install-time shadow report (spawned installer wiring)', () => {
|
||||
const { buildShadowReport, renderShadowReport, SHADOW_REASON } = require('../gsd-core/bin/lib/install-shadow-report.cjs');
|
||||
const SHADOW_THROWS_PRELOAD = path.join(__dirname, 'helpers', 'shadow-report-throws-preload.cjs');
|
||||
|
||||
function spawnInstall(args, cwd, root, nodeFlags = []) {
|
||||
return runNode([...nodeFlags, INSTALL_SCRIPT, ...args], {
|
||||
cwd,
|
||||
env: installerEnv({ HOME: root, USERPROFILE: root }),
|
||||
timeoutMs: INSTALL_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert `stderr` (the installer's own `console.warn` shadow-report
|
||||
* output) actually carries `expectedReport`'s rendered lines, verbatim.
|
||||
* `expectedReport`/its lines are computed by CALLING the module's own
|
||||
* pure `buildShadowReport`/`renderShadowReport` against the SAME on-disk
|
||||
* fixture the spawned installer just produced — never a guessed/hardcoded
|
||||
* literal. This is the typed-count-plus-content route the review brief
|
||||
* asks for: structural comparison against a pure function's own computed
|
||||
* output (mirrors this file's own `extractAtIncludeLines`/E14 pattern
|
||||
* above), not prose matching.
|
||||
*/
|
||||
function assertReportRendered(stderr, expectedReport) {
|
||||
const lines = renderShadowReport(expectedReport);
|
||||
assert.ok(lines.length > 0,
|
||||
'fixture must actually be shadowed for this to be a meaningful positive assertion');
|
||||
const stripped = stripAnsi(stderr);
|
||||
for (const line of lines) {
|
||||
assert.ok(stripped.includes(line),
|
||||
`expected installer stderr to carry the typed report line ${JSON.stringify(line)}\nstderr: ${stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Negative-proof counterpart to `assertReportRendered`, for fixtures where
|
||||
* no report is expected. `expectedReport` is computed by calling the
|
||||
* module's own pure `buildShadowReport` against the SAME on-disk fixture
|
||||
* the spawned installer just produced. Asserts the typed IR itself is
|
||||
* `not_shadowed`, that `renderShadowReport` therefore computes ZERO lines
|
||||
* for it, and then — for every line it WOULD have computed had the IR been
|
||||
* shadowed (structurally empty here) — that none of them appear in
|
||||
* `stderr`. This replaces matching a hardcoded literal fragment
|
||||
* (`' shadowed: the '`) with a structural comparison against
|
||||
* `renderShadowReport`'s own (empty) output, so there is no longer a
|
||||
* guessed string for `local/no-source-grep`/`allow-test-rule` to flag.
|
||||
*/
|
||||
function assertReportAbsent(stderr, expectedReport) {
|
||||
assert.strictEqual(expectedReport.shadowed, false,
|
||||
'fixture must not be shadowed for this to be a meaningful negative assertion');
|
||||
assert.strictEqual(expectedReport.reason, SHADOW_REASON.NOT_SHADOWED,
|
||||
`expected reason ${SHADOW_REASON.NOT_SHADOWED}, got ${expectedReport.reason}`);
|
||||
const lines = renderShadowReport(expectedReport);
|
||||
assert.deepStrictEqual(lines, [],
|
||||
'renderShadowReport must compute zero lines for a not_shadowed report');
|
||||
const stripped = stripAnsi(stderr);
|
||||
for (const line of lines) {
|
||||
assert.ok(!stripped.includes(line),
|
||||
`expected installer stderr NOT to carry the typed report line ${JSON.stringify(line)}\nstderr: ${stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
test('C1: global-then-local double install reports shadowing on the second install, exit 0', (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2873-c1-'));
|
||||
const projectDir = path.join(root, 'myrepo');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const g = spawnInstall(['--claude', '--global'], root, root);
|
||||
assert.strictEqual(g.exitCode, 0, `global install failed: ${g.stdout}\n${g.stderr}`);
|
||||
|
||||
const l = spawnInstall(['--claude', '--local'], projectDir, root);
|
||||
assert.strictEqual(l.exitCode, 0, `local install failed: ${l.stdout}\n${l.stderr}`);
|
||||
|
||||
const expectedReport = buildShadowReport('claude', { home: root, cwd: projectDir });
|
||||
assert.strictEqual(expectedReport.shadowed, true);
|
||||
assertReportRendered(l.stderr, expectedReport);
|
||||
});
|
||||
|
||||
test('C2: local-then-global double install reports shadowing symmetrically, exit 0', (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2873-c2-'));
|
||||
const projectDir = path.join(root, 'myrepo');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const l = spawnInstall(['--claude', '--local'], projectDir, root);
|
||||
assert.strictEqual(l.exitCode, 0, `local install failed: ${l.stdout}\n${l.stderr}`);
|
||||
|
||||
// The global install's own production `buildShadowReport(runtime)` call
|
||||
// (bin/install.js) takes no injected opts — it defaults to
|
||||
// `process.cwd()` to detect a coexisting LOCAL scope. Run it with cwd
|
||||
// INSIDE the already-locally-installed project (the real #2218 shape: a
|
||||
// developer running the global install from inside an existing
|
||||
// project), or it structurally cannot see the local scope at all —
|
||||
// verified empirically: cwd=root (a global install's usual cwd) never
|
||||
// reports, cwd=projectDir does.
|
||||
const g = spawnInstall(['--claude', '--global'], projectDir, root);
|
||||
assert.strictEqual(g.exitCode, 0, `global install failed: ${g.stdout}\n${g.stderr}`);
|
||||
|
||||
const expectedReport = buildShadowReport('claude', { home: root, cwd: projectDir });
|
||||
assert.strictEqual(expectedReport.shadowed, true);
|
||||
assertReportRendered(g.stderr, expectedReport);
|
||||
});
|
||||
|
||||
test('C3: global-only install stays quiet, exit 0 (negative proof)', (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2873-c3-'));
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const g = spawnInstall(['--claude', '--global'], root, root);
|
||||
assert.strictEqual(g.exitCode, 0, `global install failed: ${g.stdout}\n${g.stderr}`);
|
||||
|
||||
// Typed control: a single-scope fixture can never be shadowed by
|
||||
// construction (buildShadowReport requires two installed scopes) —
|
||||
// confirms this negative-proof fixture is not accidentally shadowed.
|
||||
const controlReport = buildShadowReport('claude', { home: root, cwd: root });
|
||||
assertReportAbsent(g.stderr, controlReport);
|
||||
});
|
||||
|
||||
test('C4: an install that fails before writeManifest never emits a report', (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2873-c4-'));
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
// Structural failure, never chmod (chmod 0o000 no-ops under root —
|
||||
// CONTRIBUTING.md): pre-create the global config dir's OWN path as a
|
||||
// plain file. installerMigrations' lock-acquisition mkdirSync (which
|
||||
// runs before ANY artifact copy, long before writeManifest at
|
||||
// bin/install.js) then throws ENOTDIR/EEXIST — verified empirically,
|
||||
// and works identically whether or not the test runner is root.
|
||||
fs.writeFileSync(path.join(root, '.claude'), 'blocker');
|
||||
|
||||
const g = spawnInstall(['--claude', '--global'], root, root);
|
||||
assert.notStrictEqual(g.exitCode, 0,
|
||||
`expected the structural collision to fail the install: ${g.stdout}\n${g.stderr}`);
|
||||
|
||||
const manifestPath = path.join(root, '.claude', MANIFEST_NAME);
|
||||
assert.ok(!fs.existsSync(manifestPath), 'writeManifest must never have run');
|
||||
|
||||
// Same fixture the failed install just left on disk: nothing was ever
|
||||
// written, so the typed IR is not_shadowed by construction — the failure
|
||||
// path never reaches the report call site at all (it runs strictly after
|
||||
// writeManifest).
|
||||
const expectedReport = buildShadowReport('claude', { home: root, cwd: root });
|
||||
assertReportAbsent(g.stdout + g.stderr, expectedReport);
|
||||
});
|
||||
|
||||
test('C5: a throwing report builder never fails the install, report suppressed', (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2873-c5-'));
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const g = spawnInstall(['--claude', '--global'], root, root, ['--require', SHADOW_THROWS_PRELOAD]);
|
||||
assert.strictEqual(g.exitCode, 0,
|
||||
`a throwing buildShadowReport must never fail the install: ${g.stdout}\n${g.stderr}`);
|
||||
|
||||
const manifestPath = path.join(root, '.claude', MANIFEST_NAME);
|
||||
assert.ok(fs.existsSync(manifestPath),
|
||||
'writeManifest must still have run — the report call happens strictly after it');
|
||||
|
||||
// Same single-scope fixture as C3 (writeManifest ran, but the report
|
||||
// builder was preloaded to throw): the typed IR built from the real
|
||||
// installer's own scope is still not_shadowed, and — because the
|
||||
// injected throw is caught before renderShadowReport ever runs — no
|
||||
// report text should reach stderr either.
|
||||
const expectedReport = buildShadowReport('claude', { home: root, cwd: root });
|
||||
assertReportAbsent(g.stderr, expectedReport);
|
||||
});
|
||||
|
||||
test('C6: re-running the same scope twice produces the same report', (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2873-c6-'));
|
||||
const projectDir = path.join(root, 'myrepo');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const g = spawnInstall(['--claude', '--global'], root, root);
|
||||
assert.strictEqual(g.exitCode, 0, `global install failed: ${g.stdout}\n${g.stderr}`);
|
||||
|
||||
const l1 = spawnInstall(['--claude', '--local'], projectDir, root);
|
||||
assert.strictEqual(l1.exitCode, 0, `first local install failed: ${l1.stdout}\n${l1.stderr}`);
|
||||
const l2 = spawnInstall(['--claude', '--local'], projectDir, root);
|
||||
assert.strictEqual(l2.exitCode, 0, `second local install failed: ${l2.stdout}\n${l2.stderr}`);
|
||||
|
||||
const expectedReport = buildShadowReport('claude', { home: root, cwd: projectDir });
|
||||
assert.strictEqual(expectedReport.shadowed, true);
|
||||
assertReportRendered(l1.stderr, expectedReport);
|
||||
assertReportRendered(l2.stderr, expectedReport);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -514,6 +514,26 @@ describe('readInstallManifest — manifest schema (#2872 R1-R21)', () => {
|
||||
});
|
||||
assert.strictEqual(readInstallManifest(dir).runtime, runtime);
|
||||
});
|
||||
|
||||
// R27 (regression, #2873 test-matrix row B9) — valid JSON that parses to a
|
||||
// non-object top-level value must degrade to "absent", identically to R1,
|
||||
// on every JS typeof-'object' member: `0`, a string, `true`, `null`
|
||||
// (JSON.parse('null') is a real value), and — the one the `typeof !==
|
||||
// 'object'` guard alone misses, since `typeof [] === 'object'` in JS — a
|
||||
// bare array. Found while implementing #2873's shadow-report test matrix:
|
||||
// `[]` was misread as manifestVersion 1 (a v1 install), reporting a
|
||||
// completely absent manifest as "installed". `readInstallManifest` now
|
||||
// explicitly excludes `Array.isArray` from the object-shape check.
|
||||
for (const raw of ['0', '"a string"', 'true', 'null', '[]']) {
|
||||
test(`R27: a valid-JSON, non-object manifest body (${raw}) reads as absent`, () => {
|
||||
writeRawManifest(dir, raw);
|
||||
const result = readInstallManifest(dir);
|
||||
assert.strictEqual(result.manifestVersion, null, `${raw}: manifestVersion must be null, not a v1 guess`);
|
||||
assert.strictEqual(result.runtime, null);
|
||||
assert.strictEqual(result.scope, null);
|
||||
assert.deepStrictEqual(result.files, {});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('writeManifest — scope + runtime recording (#2872 W1-W9)', () => {
|
||||
|
||||
380
tests/shadow-report.security.test.cjs
Normal file
380
tests/shadow-report.security.test.cjs
Normal file
@@ -0,0 +1,380 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* tests/shadow-report.security.test.cjs — hostile-manifest rendering suite
|
||||
* for `install-shadow-report.cts` (#2873, epic #2866 Phase 4a — governed by
|
||||
* `.gsd/phase/feat-2873-cross-scope-shadowing/40-design.md`).
|
||||
*
|
||||
* Implements matrix section B ("Rendering / sanitization (hostile manifest)",
|
||||
* rows B1-B16) from
|
||||
* `.gsd/phase/feat-2873-cross-scope-shadowing/50-test-matrix.md`. The
|
||||
* matrix's own "Suites" section names this file `install-shadow-report
|
||||
* .security.test.cjs`; it is shipped as `shadow-report.security.test.cjs`
|
||||
* instead so its `lint-test-file-count.cjs` prefix is `shadow` rather than
|
||||
* colliding with the already grandfathered, already-at-cap `install` prefix.
|
||||
*
|
||||
* Fixture provenance (#2371, per the matrix's own note): B1-B4/B7/B8's
|
||||
* `declaredRuntime` payloads and B9-B13's manifest bodies are authored
|
||||
* against the PUBLISHED `gsd-file-manifest.json` schema/format directly (raw
|
||||
* JSON text or a hand-built `readManifest` result), never derived from
|
||||
* `writeManifest`'s own output — a fixture the writer produced could only
|
||||
* confirm what the writer already believes.
|
||||
*
|
||||
* Every `declaredRuntime` assertion below reads the TYPED IR field
|
||||
* (`report.mismatches[0].declaredRuntime`) produced by `buildShadowReport`'s
|
||||
* sanitize-at-the-render-seam guarantee — never a substring match against
|
||||
* rendered prose (CONTRIBUTING → "Prohibited: Raw Text Matching on Test
|
||||
* Outputs"). Where a `renderShadowReport` line is also inspected (B2, B12),
|
||||
* the check is a structural security invariant (absence of a control
|
||||
* character / a traversal payload), not a wording assertion.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const { buildShadowReport, renderShadowReport } = require('../gsd-core/bin/lib/install-shadow-report.cjs');
|
||||
const { resolveScope } = require('../gsd-core/bin/lib/install-scope.cjs');
|
||||
const { MANIFEST_NAME } = require('../gsd-core/bin/lib/installer-migrations.cjs');
|
||||
|
||||
// ─── Fixture helpers (mirrors tests/installed-surface-resolver.test.cjs) ───
|
||||
|
||||
const ABSENT_MANIFEST = Object.freeze({ manifestVersion: null, runtime: null, scope: null, files: {} });
|
||||
|
||||
function manifest({ manifestVersion = null, runtime = null, scope = null, files = {} } = {}) {
|
||||
return { manifestVersion, runtime, scope, files };
|
||||
}
|
||||
|
||||
function mkReadManifest(byConfigHome) {
|
||||
return (configDir) => byConfigHome.get(configDir) ?? ABSENT_MANIFEST;
|
||||
}
|
||||
|
||||
function scopeHomes(runtime, home, cwd) {
|
||||
const base = { runtime, env: {}, home, existsSync: () => false, cwd };
|
||||
return {
|
||||
global: resolveScope({ ...base, id: 'global' }).configHome,
|
||||
local: resolveScope({ ...base, id: 'local' }).configHome,
|
||||
};
|
||||
}
|
||||
|
||||
function baseOpts(home, cwd, overrides = {}) {
|
||||
return { home, cwd, env: {}, existsSync: () => false, ...overrides };
|
||||
}
|
||||
|
||||
/** Single-scope (global-only) fixture: a claude install declaring
|
||||
* `declaredRuntime = runtimeVal` — always a mismatch against the requested
|
||||
* 'claude' runtime unless `runtimeVal === 'claude'`, which is exactly what
|
||||
* puts an entry in `report.mismatches` for every B-row below to inspect.
|
||||
* `files: {}` keeps the fixture single-purpose: no trigger/shadowing signal
|
||||
* competes with the mismatch signal under test. */
|
||||
function declaredRuntimeReport(runtimeVal) {
|
||||
const home = '/fixture/sec-home';
|
||||
const cwd = '/fixture/sec-cwd';
|
||||
const homes = scopeHomes('claude', home, cwd);
|
||||
const byConfigHome = new Map([
|
||||
[homes.global, manifest({ manifestVersion: 2, runtime: runtimeVal, scope: 'global', files: {} })],
|
||||
]);
|
||||
return buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
|
||||
}
|
||||
|
||||
// RLO (Right-to-Left Override, U+202E) — written as a `\u{...}` escape (not
|
||||
// a literal bidi character) so the source stays plain ASCII and does not
|
||||
// carry the very invisible/dangerous-Unicode class it tests. See the
|
||||
// matching B17/B18 note further below for the same rationale.
|
||||
const BIDI_RLO = '\u{202E}';
|
||||
|
||||
// ─── B1-B4 — hostile declaredRuntime payloads are neutralized in the IR ────
|
||||
|
||||
describe('buildShadowReport — hostile declaredRuntime is sanitized in the IR (B1-B4)', () => {
|
||||
test('ansi escape is neutralized (B1)', () => {
|
||||
const report = declaredRuntimeReport('\x1b[31mcursor');
|
||||
assert.strictEqual(report.mismatches.length, 1);
|
||||
assert.strictEqual(report.mismatches[0].declaredRuntime, 'cursor');
|
||||
assert.ok(!report.mismatches[0].declaredRuntime.includes('\x1b'));
|
||||
});
|
||||
|
||||
test('newlines cannot forge a log line (B2)', () => {
|
||||
const lf = declaredRuntimeReport('cursor\nFAKE LOG LINE');
|
||||
const crlf = declaredRuntimeReport('cursor\r\nFAKE LOG LINE');
|
||||
assert.strictEqual(lf.mismatches[0].declaredRuntime, 'cursorFAKE LOG LINE');
|
||||
assert.strictEqual(crlf.mismatches[0].declaredRuntime, 'cursorFAKE LOG LINE');
|
||||
assert.ok(!lf.mismatches[0].declaredRuntime.includes('\n'));
|
||||
// Every rendered line must itself be single-line — a structural check on
|
||||
// the renderer's output shape, not a wording assertion.
|
||||
for (const line of renderShadowReport(lf)) {
|
||||
assert.ok(!line.includes('\n'), `rendered line must never carry an embedded newline: ${JSON.stringify(line)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('control characters are stripped (B3)', () => {
|
||||
const report = declaredRuntimeReport('a\x00b\x07c');
|
||||
assert.strictEqual(report.mismatches[0].declaredRuntime, 'abc');
|
||||
});
|
||||
|
||||
test('bidi override is stripped (B4)', () => {
|
||||
const report = declaredRuntimeReport(`a${BIDI_RLO}b`);
|
||||
assert.strictEqual(report.mismatches[0].declaredRuntime, 'ab');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── B5-B6 — the READER's 64-char cap, real fs, no double-truncation ───────
|
||||
|
||||
describe('buildShadowReport — declaredRuntime length cap, real reader (B5-B6)', () => {
|
||||
function realCappedReport(t, n) {
|
||||
const home = createTempDir('gsd-shadow-sec-b56-home-');
|
||||
const cwd = createTempDir('gsd-shadow-sec-b56-cwd-');
|
||||
t.after(() => { cleanup(home); cleanup(cwd); });
|
||||
const globalDir = path.join(home, '.claude');
|
||||
fs.mkdirSync(globalDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(globalDir, MANIFEST_NAME), JSON.stringify({
|
||||
manifestVersion: 2, runtime: 'A'.repeat(n), scope: 'global', files: {},
|
||||
}));
|
||||
return buildShadowReport('claude', { home, cwd });
|
||||
}
|
||||
|
||||
test('cap-length runtime renders intact (B5, 64 chars)', (t) => {
|
||||
const report = realCappedReport(t, 64);
|
||||
assert.strictEqual(report.mismatches[0].declaredRuntime.length, 64);
|
||||
assert.strictEqual(report.mismatches[0].declaredRuntime, 'A'.repeat(64));
|
||||
});
|
||||
|
||||
test('reader cap is respected once, not double-truncated (B6, 63/65 chars)', (t) => {
|
||||
const below = realCappedReport(t, 63);
|
||||
assert.strictEqual(below.mismatches[0].declaredRuntime, 'A'.repeat(63));
|
||||
|
||||
const above = realCappedReport(t, 65);
|
||||
// readInstallManifest's MAX_REPORTED_RUNTIME_LENGTH truncates to 64 chars
|
||||
// plus an ellipsis (65 chars total) — buildShadowReport's sanitizer never
|
||||
// truncates further, so the ellipsis must survive intact.
|
||||
assert.strictEqual(above.mismatches[0].declaredRuntime.length, 65);
|
||||
assert.strictEqual(above.mismatches[0].declaredRuntime, `${'A'.repeat(64)}…`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── B7-B8 — empty vs null declaredRuntime ─────────────────────────────────
|
||||
|
||||
describe('buildShadowReport — empty vs absent declaredRuntime (B7-B8)', () => {
|
||||
test('empty declared runtime renders as empty string, never as the string "null" (B7)', () => {
|
||||
// Injected directly (bypassing readInstallManifest's own empty-string ->
|
||||
// null normalization) so this exercises buildShadowReport/sanitizeForRender's
|
||||
// OWN handling of an empty-but-present declared value, independent of
|
||||
// the reader's separate empty-string rule.
|
||||
const report = declaredRuntimeReport('');
|
||||
assert.strictEqual(report.mismatches.length, 1);
|
||||
assert.strictEqual(report.mismatches[0].declaredRuntime, '');
|
||||
assert.notStrictEqual(report.mismatches[0].declaredRuntime, null);
|
||||
});
|
||||
|
||||
test('absent declared runtime (null, v1 manifest) is omitted from the IR entirely (B8)', () => {
|
||||
const home = '/fixture/b8-home';
|
||||
const cwd = '/fixture/b8-cwd';
|
||||
const homes = scopeHomes('claude', home, cwd);
|
||||
const byConfigHome = new Map([
|
||||
// scope matches probe too, so NEITHER mismatch flag fires.
|
||||
[homes.global, manifest({ manifestVersion: 2, runtime: null, scope: 'global', files: {} })],
|
||||
]);
|
||||
const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
|
||||
assert.deepStrictEqual(report.mismatches, [], 'a null declaredRuntime with no scope mismatch produces no mismatch entry at all');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── B9-B11 — manifest document malformation, real files, real reads ──────
|
||||
|
||||
describe('buildShadowReport — malformed manifest documents degrade, never throw (B9-B11)', () => {
|
||||
function realSingleScopeReport(t, rawBody) {
|
||||
const home = createTempDir('gsd-shadow-sec-b9-home-');
|
||||
const cwd = createTempDir('gsd-shadow-sec-b9-cwd-');
|
||||
t.after(() => { cleanup(home); cleanup(cwd); });
|
||||
const globalDir = path.join(home, '.claude');
|
||||
fs.mkdirSync(globalDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(globalDir, MANIFEST_NAME), rawBody);
|
||||
let report;
|
||||
assert.doesNotThrow(() => {
|
||||
report = buildShadowReport('claude', { home, cwd });
|
||||
});
|
||||
return report;
|
||||
}
|
||||
|
||||
test('non-object manifest json (0, string, array, boolean, null) all degrade to not-installed (B9)', (t) => {
|
||||
for (const raw of ['0', '"a string"', '[]', 'true', 'null']) {
|
||||
const report = realSingleScopeReport(t, raw);
|
||||
assert.strictEqual(report.shadowed, false, `raw body ${raw} must degrade to not-installed`);
|
||||
assert.deepStrictEqual(report.triggers, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty (0-byte) manifest file degrades to not-installed (B10)', (t) => {
|
||||
const report = realSingleScopeReport(t, '');
|
||||
assert.strictEqual(report.shadowed, false);
|
||||
assert.deepStrictEqual(report.triggers, []);
|
||||
});
|
||||
|
||||
test('a CRLF manifest parses identically to its LF counterpart (B11)', (t) => {
|
||||
const lfBody = [
|
||||
'{',
|
||||
' "manifestVersion": 2,',
|
||||
' "runtime": "claude",',
|
||||
' "scope": "global",',
|
||||
' "files": { "skills/gsd-plan-phase/SKILL.md": "a" }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
const lfReport = realSingleScopeReport(t, lfBody);
|
||||
const crlfReport = realSingleScopeReport(t, lfBody.replace(/\n/g, '\r\n'));
|
||||
assert.deepStrictEqual(crlfReport, lfReport);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── B12-B13 — manifest key hostility / cross-platform normalization ──────
|
||||
|
||||
describe('buildShadowReport — manifest KEY hostility and normalization (B12-B13)', () => {
|
||||
test('a traversal stem is rejected, never reaches a rendered trigger (B12)', () => {
|
||||
const home = '/fixture/b12-home';
|
||||
const cwd = '/fixture/b12-cwd';
|
||||
const homes = scopeHomes('claude', home, cwd);
|
||||
const byConfigHome = new Map([
|
||||
[homes.global, manifest({
|
||||
manifestVersion: 2, runtime: 'claude', scope: 'global',
|
||||
files: {
|
||||
'skills/gsd-../../../x/SKILL.md': 'a',
|
||||
'skills/gsd-plan-phase/SKILL.md': 'b',
|
||||
},
|
||||
})],
|
||||
[homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' } })],
|
||||
]);
|
||||
const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
|
||||
// Only the legitimate stem is present — the traversal key contributed nothing.
|
||||
assert.deepStrictEqual(report.triggers.map((t) => t.trigger), ['gsd-plan-phase']);
|
||||
for (const line of renderShadowReport(report)) {
|
||||
assert.ok(!line.includes('..'), `rendered output must never carry a traversal payload: ${JSON.stringify(line)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('backslash-separated keys normalize on posix too (B13)', () => {
|
||||
const home = '/fixture/b13-home';
|
||||
const cwd = '/fixture/b13-cwd';
|
||||
const homes = scopeHomes('claude', home, cwd);
|
||||
const byConfigHome = new Map([
|
||||
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'skills\\gsd-foo\\SKILL.md': 'a' } })],
|
||||
[homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-foo.md': 'a' } })],
|
||||
]);
|
||||
const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
|
||||
assert.deepStrictEqual(report.triggers.map((t) => t.trigger), ['gsd-foo']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── B14-B16 — the lstatSync symlink guard ─────────────────────────────────
|
||||
|
||||
describe('buildShadowReport — the lstatSync symlink guard (B14-B16)', () => {
|
||||
test('a symlinked local config dir is not followed, injected lstatSync (B14)', () => {
|
||||
const home = '/fixture/b14-home';
|
||||
const cwd = '/fixture/b14-cwd';
|
||||
const homes = scopeHomes('claude', home, cwd);
|
||||
const byConfigHome = new Map([
|
||||
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' } })],
|
||||
// A manifest IS present at the local configHome per this readManifest
|
||||
// stub — proving the guard, not the reader, is what refuses it below.
|
||||
[homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' } })],
|
||||
]);
|
||||
// Injected lstatSync reports the local configHome itself as a symlink.
|
||||
const lstatSync = (p) => ({ isSymbolicLink: () => p === homes.local });
|
||||
const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome), lstatSync }));
|
||||
assert.strictEqual(report.shadowed, false, 'the symlinked local scope must not be counted as installed, so nothing can shadow it');
|
||||
assert.deepStrictEqual(report.triggers, []);
|
||||
});
|
||||
|
||||
test('a symlinked manifest file is not followed, real symlink on disk (B15)', (t) => {
|
||||
const home = createTempDir('gsd-shadow-sec-b15-home-');
|
||||
const cwd = createTempDir('gsd-shadow-sec-b15-cwd-');
|
||||
const outOfTreeDir = createTempDir('gsd-shadow-sec-b15-outoftree-');
|
||||
t.after(() => { cleanup(home); cleanup(cwd); cleanup(outOfTreeDir); });
|
||||
|
||||
const globalDir = path.join(home, '.claude');
|
||||
const localDir = path.join(cwd, '.claude');
|
||||
fs.mkdirSync(globalDir, { recursive: true });
|
||||
fs.mkdirSync(localDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(globalDir, MANIFEST_NAME), JSON.stringify({
|
||||
manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' },
|
||||
}));
|
||||
const outOfTreeManifest = path.join(outOfTreeDir, 'real-manifest.json');
|
||||
fs.writeFileSync(outOfTreeManifest, JSON.stringify({
|
||||
manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' },
|
||||
}));
|
||||
// The local config DIR is real; only the manifest FILE inside it is a
|
||||
// symlink pointing OUTSIDE the config dir — proves the guard checks the
|
||||
// manifest path itself, not merely the directory.
|
||||
fs.symlinkSync(outOfTreeManifest, path.join(localDir, MANIFEST_NAME), 'file');
|
||||
|
||||
const report = buildShadowReport('claude', { home, cwd });
|
||||
assert.strictEqual(report.shadowed, false, 'a symlinked manifest file must never be followed, even though its target is valid, matching content');
|
||||
assert.deepStrictEqual(report.triggers, []);
|
||||
});
|
||||
|
||||
test('an unsymlinked local config still reads (B16, negative proof)', (t) => {
|
||||
const home = createTempDir('gsd-shadow-sec-b16-home-');
|
||||
const cwd = createTempDir('gsd-shadow-sec-b16-cwd-');
|
||||
t.after(() => { cleanup(home); cleanup(cwd); });
|
||||
|
||||
const globalDir = path.join(home, '.claude');
|
||||
const localDir = path.join(cwd, '.claude');
|
||||
fs.mkdirSync(globalDir, { recursive: true });
|
||||
fs.mkdirSync(localDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(globalDir, MANIFEST_NAME), JSON.stringify({
|
||||
manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' },
|
||||
}));
|
||||
fs.writeFileSync(path.join(localDir, MANIFEST_NAME), JSON.stringify({
|
||||
manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' },
|
||||
}));
|
||||
|
||||
const report = buildShadowReport('claude', { home, cwd });
|
||||
assert.strictEqual(report.shadowed, true, 'the guard must not break the ordinary, unsymlinked happy path');
|
||||
assert.strictEqual(report.triggers.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── B17-B18 — zalgo / zero-width, #2873 PR review Finding 2 (MINOR) ──────
|
||||
//
|
||||
// `sanitizeForRender` stripped ANSI, C0/C1, and bidi overrides/isolates, but
|
||||
// not combining marks (U+0300-U+036F — "zalgo" text, which visually
|
||||
// overflows into adjacent terminal cells) or zero-width characters (ZWSP
|
||||
// U+200B, ZWNJ U+200C, ZWJ U+200D, BOM/ZWNBSP U+FEFF). Neither class is a JS
|
||||
// `\s`, so both survived the 64-char cap and the whitespace-collapse step
|
||||
// undetected.
|
||||
|
||||
// Written as `\u{...}` escapes throughout (never literal combining/bidi/
|
||||
// zero-width characters) so the source stays plain ASCII and does not
|
||||
// visually combine with adjacent punctuation in editors/diffs.
|
||||
const ZALGO_COMBINING_1 = '\u{0300}'; // combining grave accent
|
||||
const ZALGO_COMBINING_2 = '\u{0301}'; // combining acute accent
|
||||
const ZALGO_COMBINING_3 = '\u{036F}'; // combining latin small letter x (top of range)
|
||||
const ZWSP = '\u{200B}';
|
||||
const ZWNJ = '\u{200C}';
|
||||
const ZWJ = '\u{200D}';
|
||||
const BOM = '\u{FEFF}';
|
||||
|
||||
describe('buildShadowReport — hostile declaredRuntime is sanitized in the IR (B17-B18)', () => {
|
||||
test('combining marks (zalgo) are stripped (B17)', () => {
|
||||
const report = declaredRuntimeReport(`a${ZALGO_COMBINING_1}${ZALGO_COMBINING_2}${ZALGO_COMBINING_3}b`);
|
||||
assert.strictEqual(report.mismatches.length, 1);
|
||||
assert.strictEqual(report.mismatches[0].declaredRuntime, 'ab');
|
||||
assert.ok(!/[\u{0300}-\u{036F}]/u.test(report.mismatches[0].declaredRuntime));
|
||||
});
|
||||
|
||||
test('zero-width characters (ZWSP/ZWNJ/ZWJ/BOM) are stripped (B18)', () => {
|
||||
const report = declaredRuntimeReport(`a${ZWSP}b${ZWNJ}c${ZWJ}d${BOM}e`);
|
||||
assert.strictEqual(report.mismatches.length, 1);
|
||||
assert.strictEqual(report.mismatches[0].declaredRuntime, 'abcde');
|
||||
assert.ok(!/[\u{200B}-\u{200D}\u{FEFF}]/u.test(report.mismatches[0].declaredRuntime));
|
||||
});
|
||||
});
|
||||
|
||||
// Note: the F3 property ("sanitizer output contains no character in the
|
||||
// stripped class, for arbitrary input") lives in `tests/shadow-report.test.cjs`
|
||||
// alongside F2 (sanitizer idempotence) — both target `sanitizeForRender` and
|
||||
// share one hostile-input generator, extended for #2873 PR review Finding 2
|
||||
// (MINOR) to also emit combining marks (zalgo) and zero-width characters so
|
||||
// the property actually exercises the newly-stripped classes rather than
|
||||
// passing vacuously.
|
||||
911
tests/shadow-report.test.cjs
Normal file
911
tests/shadow-report.test.cjs
Normal file
@@ -0,0 +1,911 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* tests/shadow-report.test.cjs — pure IR unit suite for
|
||||
* `install-shadow-report.cts`'s `buildShadowReport` (#2873, epic #2866 Phase
|
||||
* 4a — governed by `.gsd/phase/feat-2873-cross-scope-shadowing/40-design.md`).
|
||||
*
|
||||
* Implements matrix section A (`buildShadowReport()`, rows A1-A24) and
|
||||
* properties F1/F4 from `.gsd/phase/feat-2873-cross-scope-shadowing/50-test-matrix.md`.
|
||||
* The matrix's own "Suites" section names this file `install-shadow-report
|
||||
* .test.cjs`; it is shipped as `shadow-report.test.cjs` instead so its
|
||||
* `lint-test-file-count.cjs` prefix is `shadow` (0 files before this PR, at
|
||||
* the 2-file cap after it) rather than colliding with the already
|
||||
* grandfathered, already-at-cap `install` prefix bucket.
|
||||
*
|
||||
* A21-A24 cover the per-scope truth filter (#2873 Task 1): a `full`-profile
|
||||
* global install alongside a `core`-profile local install must never report
|
||||
* the profile-only stems as shadowed local artifacts that do not exist on
|
||||
* disk. F1 is updated in lockstep — its expected shadowed set is now the
|
||||
* INTERSECTION of the two scopes' stems, not their union.
|
||||
*
|
||||
* F4 ("4b transform is idempotent over arbitrary bodies") targets
|
||||
* `resolveSpecRootReference` (`runtime-artifact-conversion.cts`, #2873 Phase
|
||||
* 4b), which has since landed — see the "F4" describe block below.
|
||||
*
|
||||
* Fixture strategy mirrors `tests/installed-surface-resolver.test.cjs`
|
||||
* (`buildShadowReport` forwards its `opts` verbatim to
|
||||
* `resolveInstalledSurfaces`): an injectable `readManifest` keyed by the
|
||||
* REAL `configHome` `resolveScope` computes for a given runtime/scope, never
|
||||
* a hand-typed path literal.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const fc = require('./helpers/fast-check-setup.cjs');
|
||||
|
||||
const {
|
||||
buildShadowReport,
|
||||
renderShadowReport,
|
||||
sanitizeForRender,
|
||||
SHADOW_REASON,
|
||||
} = require('../gsd-core/bin/lib/install-shadow-report.cjs');
|
||||
const { resolveScope } = require('../gsd-core/bin/lib/install-scope.cjs');
|
||||
const { MANIFEST_NAME } = require('../gsd-core/bin/lib/installer-migrations.cjs');
|
||||
const { resolveSpecRootReference } = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
|
||||
|
||||
// ─── Fixture helpers (mirrors tests/installed-surface-resolver.test.cjs) ───
|
||||
|
||||
const ABSENT_MANIFEST = Object.freeze({ manifestVersion: null, runtime: null, scope: null, files: {} });
|
||||
|
||||
function manifest({ manifestVersion = null, runtime = null, scope = null, files = {} } = {}) {
|
||||
return { manifestVersion, runtime, scope, files };
|
||||
}
|
||||
|
||||
function mkReadManifest(byConfigHome) {
|
||||
return (configDir) => byConfigHome.get(configDir) ?? ABSENT_MANIFEST;
|
||||
}
|
||||
|
||||
function scopeHomes(runtime, home, cwd) {
|
||||
const base = { runtime, env: {}, home, existsSync: () => false, cwd };
|
||||
return {
|
||||
global: resolveScope({ ...base, id: 'global' }).configHome,
|
||||
local: resolveScope({ ...base, id: 'local' }).configHome,
|
||||
};
|
||||
}
|
||||
|
||||
function baseOpts(home, cwd, overrides = {}) {
|
||||
return { home, cwd, env: {}, existsSync: () => false, ...overrides };
|
||||
}
|
||||
|
||||
/** `commands/gsd/*.md` stems shipped by the real repo — used so A1's "71
|
||||
* entries" tracks the real roster instead of a hardcoded, driftable count. */
|
||||
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
const REAL_STEMS = fs.readdirSync(REAL_COMMANDS_DIR)
|
||||
.filter((f) => f.endsWith('.md'))
|
||||
.map((f) => f.slice(0, -3))
|
||||
.sort();
|
||||
|
||||
function skillFilesFor(stems) {
|
||||
const files = {};
|
||||
for (const s of stems) files[`skills/gsd-${s}/SKILL.md`] = 'x';
|
||||
return files;
|
||||
}
|
||||
|
||||
function commandFilesFor(stems) {
|
||||
const files = {};
|
||||
for (const s of stems) files[`commands/gsd-${s}.md`] = 'x';
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Build a claude coexistence fixture: `stems` installed as global skills AND
|
||||
* local commands (so every one of them is a shadowed trigger). */
|
||||
function coexistenceOpts(home, cwd, stems, overrides = {}) {
|
||||
const homes = scopeHomes('claude', home, cwd);
|
||||
const byConfigHome = new Map([
|
||||
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(stems) })],
|
||||
[homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(stems) })],
|
||||
]);
|
||||
return baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome), ...overrides });
|
||||
}
|
||||
|
||||
// ─── A1-A6 — shape happy/negative paths ────────────────────────────────────
|
||||
|
||||
describe('buildShadowReport — shape (A1-A6)', () => {
|
||||
test('reports shadowing for a claude coexistence, full real roster (A1)', () => {
|
||||
const home = '/fixture/a1-home';
|
||||
const cwd = '/fixture/a1-cwd';
|
||||
const report = buildShadowReport('claude', coexistenceOpts(home, cwd, REAL_STEMS));
|
||||
assert.strictEqual(report.shadowed, true);
|
||||
assert.strictEqual(report.reason, SHADOW_REASON.SCOPE_SHADOWED);
|
||||
assert.strictEqual(report.triggers.length, REAL_STEMS.length);
|
||||
assert.deepStrictEqual(report.winner, { kind: 'skills', scope: 'global' });
|
||||
assert.deepStrictEqual(report.shadowedSide, { kind: 'commands', scope: 'local' });
|
||||
assert.deepStrictEqual(report.triggers.map((t) => t.trigger).sort(), REAL_STEMS.map((s) => `gsd-${s}`));
|
||||
});
|
||||
|
||||
test('no report for a single scope, global only (A2)', () => {
|
||||
const home = '/fixture/a2-home';
|
||||
const cwd = '/fixture/a2-cwd';
|
||||
const homes = scopeHomes('claude', home, cwd);
|
||||
const byConfigHome = new Map([
|
||||
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['plan-phase']) })],
|
||||
]);
|
||||
const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
|
||||
assert.strictEqual(report.shadowed, false);
|
||||
assert.strictEqual(report.reason, SHADOW_REASON.NOT_SHADOWED);
|
||||
assert.deepStrictEqual(report.triggers, []);
|
||||
});
|
||||
|
||||
test('no report for local-only (A3)', () => {
|
||||
const home = '/fixture/a3-home';
|
||||
const cwd = '/fixture/a3-cwd';
|
||||
const homes = scopeHomes('claude', home, cwd);
|
||||
const byConfigHome = new Map([
|
||||
[homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['plan-phase']) })],
|
||||
]);
|
||||
const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
|
||||
assert.strictEqual(report.shadowed, false);
|
||||
assert.deepStrictEqual(report.triggers, []);
|
||||
});
|
||||
|
||||
test('same-kind shadowing is reported as override, not a vanished tree (A4)', () => {
|
||||
const home = '/fixture/a4-home';
|
||||
const cwd = '/fixture/a4-cwd';
|
||||
const homes = scopeHomes('cursor', home, cwd);
|
||||
const byConfigHome = new Map([
|
||||
[homes.global, manifest({ manifestVersion: 2, runtime: 'cursor', scope: 'global', files: skillFilesFor(['plan-phase']) })],
|
||||
[homes.local, manifest({ manifestVersion: 2, runtime: 'cursor', scope: 'local', files: skillFilesFor(['plan-phase']) })],
|
||||
]);
|
||||
const report = buildShadowReport('cursor', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
|
||||
assert.strictEqual(report.shadowed, true);
|
||||
assert.strictEqual(report.kindsDiffer, false);
|
||||
assert.deepStrictEqual(report.winner, { kind: 'skills', scope: 'global' });
|
||||
assert.deepStrictEqual(report.shadowedSide, { kind: 'skills', scope: 'local' });
|
||||
});
|
||||
|
||||
test('windsurf asymmetry does not collide (A5)', () => {
|
||||
const home = '/fixture/a5-home';
|
||||
const cwd = '/fixture/a5-cwd';
|
||||
const homes = scopeHomes('windsurf', home, cwd);
|
||||
const byConfigHome = new Map([
|
||||
[homes.global, manifest({ manifestVersion: 2, runtime: 'windsurf', scope: 'global', files: { 'agents/gsd-planner.md': 'a' } })],
|
||||
[homes.local, manifest({ manifestVersion: 2, runtime: 'windsurf', scope: 'local', files: { 'workflows/gsd-plan-phase.md': 'a' } })],
|
||||
]);
|
||||
const report = buildShadowReport('windsurf', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
|
||||
assert.strictEqual(report.shadowed, false);
|
||||
});
|
||||
|
||||
test('same config home is not self-shadowing (A6)', () => {
|
||||
const shared = '/fixture/a6-shared-home';
|
||||
const homes = scopeHomes('claude', shared, shared);
|
||||
assert.strictEqual(homes.global, homes.local, 'fixture assumption: both scopes collapse to one configHome');
|
||||
const byConfigHome = new Map([
|
||||
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['plan-phase']) })],
|
||||
]);
|
||||
const report = buildShadowReport('claude', baseOpts(shared, shared, { readManifest: mkReadManifest(byConfigHome) }));
|
||||
assert.strictEqual(report.shadowed, false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── A7-A10 — SAMPLE_LIMIT boundary (limit-1, limit, limit+1) ──────────────
|
||||
|
||||
describe('buildShadowReport — sample-limit boundary (A7-A10)', () => {
|
||||
test('zero triggers renders nothing (A7, limit-1 in the sense of "below any sample")', () => {
|
||||
const home = '/fixture/a7-home';
|
||||
const cwd = '/fixture/a7-cwd';
|
||||
const homes = scopeHomes('claude', home, cwd);
|
||||
// Both scopes installed (manifestVersion set) but with an empty `files`
|
||||
// map each — `deriveStemsFromManifest` short-circuits to `[]` for an
|
||||
// empty `files` BEFORE resolving a layout at all (installed-surface-
|
||||
// resolver.cts's C13), so the stem union across both scopes is empty and
|
||||
// no trigger is ever synthesized. NOT a disjoint-stems fixture: because
|
||||
// `resolveInstalledSurfaces` unions stems across every INSTALLED scope
|
||||
// (not per-scope), two scopes installed with genuinely DIFFERENT,
|
||||
// non-empty stem sets still produce a shadowed entry for each stem in
|
||||
// the union — see A12's comment for the same mechanism.
|
||||
const byConfigHome = new Map([
|
||||
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: {} })],
|
||||
[homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: {} })],
|
||||
]);
|
||||
const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
|
||||
assert.strictEqual(report.shadowed, false);
|
||||
assert.deepStrictEqual(report.triggers, []);
|
||||
assert.deepStrictEqual(renderShadowReport(report), []);
|
||||
});
|
||||
|
||||
test('single trigger has no overflow tail (A8, limit=1)', () => {
|
||||
const home = '/fixture/a8-home';
|
||||
const cwd = '/fixture/a8-cwd';
|
||||
const report = buildShadowReport('claude', coexistenceOpts(home, cwd, ['solo']));
|
||||
assert.strictEqual(report.triggers.length, 1);
|
||||
const lines = renderShadowReport(report);
|
||||
// header + exactly one sample line, no "...and N more" tail, no mismatch notes.
|
||||
assert.strictEqual(lines.length, 2);
|
||||
});
|
||||
|
||||
test('sample limit exactly, 5 shadowed (A9)', () => {
|
||||
const home = '/fixture/a9-home';
|
||||
const cwd = '/fixture/a9-cwd';
|
||||
const stems = ['s1', 's2', 's3', 's4', 's5'];
|
||||
const report = buildShadowReport('claude', coexistenceOpts(home, cwd, stems));
|
||||
assert.strictEqual(report.triggers.length, 5);
|
||||
const lines = renderShadowReport(report);
|
||||
// header + 5 samples, still no tail.
|
||||
assert.strictEqual(lines.length, 6);
|
||||
});
|
||||
|
||||
test('sample limit plus one, 6 shadowed (A10)', () => {
|
||||
const home = '/fixture/a10-home';
|
||||
const cwd = '/fixture/a10-cwd';
|
||||
const stems = ['s1', 's2', 's3', 's4', 's5', 's6'];
|
||||
const report = buildShadowReport('claude', coexistenceOpts(home, cwd, stems));
|
||||
assert.strictEqual(report.triggers.length, 6);
|
||||
const lines = renderShadowReport(report);
|
||||
// header + 5 samples + one overflow-tail line.
|
||||
assert.strictEqual(lines.length, 7);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── A11-A13 — manifest content edge cases ─────────────────────────────────
|
||||
|
||||
describe('buildShadowReport — manifest content edge cases (A11-A13)', () => {
|
||||
test('v1 manifest still reports shadowing, identical to v2, no reinstall signal in the IR (A11)', () => {
|
||||
const home = '/fixture/a11-home';
|
||||
const cwd = '/fixture/a11-cwd';
|
||||
const homesV1 = scopeHomes('claude', home, cwd);
|
||||
const byConfigHomeV1 = new Map([
|
||||
[homesV1.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['plan-phase']) })],
|
||||
// v1: no manifestVersion key at all, normalized to 1; no declared runtime/scope.
|
||||
[homesV1.local, manifest({ manifestVersion: 1, runtime: null, scope: null, files: commandFilesFor(['plan-phase']) })],
|
||||
]);
|
||||
const reportV1 = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHomeV1) }));
|
||||
|
||||
const byConfigHomeV2 = new Map([
|
||||
[homesV1.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['plan-phase']) })],
|
||||
[homesV1.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['plan-phase']) })],
|
||||
]);
|
||||
const reportV2 = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHomeV2) }));
|
||||
|
||||
assert.strictEqual(reportV1.shadowed, true);
|
||||
assert.deepStrictEqual(reportV1, reportV2, 'a v1-backed report must be structurally identical to its v2 counterpart');
|
||||
|
||||
// No reinstall/version signal anywhere in the IR's shape.
|
||||
assert.ok(!('manifestVersion' in reportV1));
|
||||
for (const trig of reportV1.triggers) assert.ok(!('manifestVersion' in trig));
|
||||
for (const m of reportV1.mismatches) assert.ok(!('manifestVersion' in m));
|
||||
});
|
||||
|
||||
test('empty manifest yields no triggers (A12)', () => {
|
||||
const home = '/fixture/a12-home';
|
||||
const cwd = '/fixture/a12-cwd';
|
||||
const homes = scopeHomes('claude', home, cwd);
|
||||
// Deliberately only ONE scope present, with an empty `files` map — the
|
||||
// clean exercise of the empty-files short-circuit (deriveStemsFromManifest's
|
||||
// C13) in isolation. A COEXISTENCE fixture (both scopes installed, one
|
||||
// side's `files: {}`) does NOT stay `shadowed: false`: because
|
||||
// `resolveInstalledSurfaces` unions stems across every scope it counts as
|
||||
// installed (manifestVersion set, regardless of that scope's own file
|
||||
// count) rather than per-scope, a real stem contributed by the OTHER,
|
||||
// populated scope still gets a synthesized trigger at this empty one —
|
||||
// see `installed-surface-resolver.cts`'s `stemUnion` computation. That is
|
||||
// established, already-tested Phase 3 (#2872) behavior (the roster is
|
||||
// assumed uniform across installed scopes), not something this row
|
||||
// exercises.
|
||||
const byConfigHome = new Map([
|
||||
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: {} })],
|
||||
]);
|
||||
const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
|
||||
assert.strictEqual(report.shadowed, false);
|
||||
assert.deepStrictEqual(report.triggers, []);
|
||||
});
|
||||
|
||||
test('unreadable manifest degrades, never throws (A13)', () => {
|
||||
const home = '/fixture/a13-home';
|
||||
const cwd = '/fixture/a13-cwd';
|
||||
const homes = scopeHomes('claude', home, cwd);
|
||||
const byConfigHome = new Map([
|
||||
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['plan-phase']) })],
|
||||
]);
|
||||
const readManifest = (configDir) => {
|
||||
if (configDir === homes.local) throw new Error('EACCES: permission denied');
|
||||
return byConfigHome.get(configDir) ?? ABSENT_MANIFEST;
|
||||
};
|
||||
let report;
|
||||
assert.doesNotThrow(() => {
|
||||
report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest }));
|
||||
});
|
||||
assert.strictEqual(report.shadowed, false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── A14-A15 — declared-runtime/scope mismatch surfaced, not corrected ─────
|
||||
|
||||
describe('buildShadowReport — mismatches are reported, never corrected (A14-A15)', () => {
|
||||
test('declared runtime mismatch is surfaced (A14)', () => {
|
||||
const home = '/fixture/a14-home';
|
||||
const cwd = '/fixture/a14-cwd';
|
||||
const homes = scopeHomes('claude', home, cwd);
|
||||
const byConfigHome = new Map([
|
||||
[homes.global, manifest({ manifestVersion: 2, runtime: 'cursor', scope: 'global', files: skillFilesFor(['plan-phase']) })],
|
||||
[homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['plan-phase']) })],
|
||||
]);
|
||||
const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
|
||||
assert.strictEqual(report.mismatches.length, 1);
|
||||
assert.strictEqual(report.mismatches[0].scope, 'global');
|
||||
assert.strictEqual(report.mismatches[0].declaredRuntime, 'cursor');
|
||||
assert.strictEqual(report.mismatches[0].declaredRuntimeMatchesProbe, false);
|
||||
});
|
||||
|
||||
test('declared scope mismatch is surfaced (A15)', () => {
|
||||
const home = '/fixture/a15-home';
|
||||
const cwd = '/fixture/a15-cwd';
|
||||
const homes = scopeHomes('claude', home, cwd);
|
||||
const byConfigHome = new Map([
|
||||
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: skillFilesFor(['plan-phase']) })],
|
||||
[homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['plan-phase']) })],
|
||||
]);
|
||||
const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
|
||||
assert.strictEqual(report.mismatches.length, 1);
|
||||
assert.strictEqual(report.mismatches[0].scope, 'global');
|
||||
assert.strictEqual(report.mismatches[0].declaredScope, 'local');
|
||||
assert.strictEqual(report.mismatches[0].declaredScopeMatchesProbe, false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── A16-A17 — malformed runtime degrades, never propagates ───────────────
|
||||
|
||||
describe('buildShadowReport — non-installable / unknown runtime degrades (A16-A17)', () => {
|
||||
test('non-installable runtime degrades to no report (A16, vscode)', () => {
|
||||
const home = '/fixture/a16-home';
|
||||
const cwd = '/fixture/a16-cwd';
|
||||
let report;
|
||||
assert.doesNotThrow(() => {
|
||||
report = buildShadowReport('vscode', baseOpts(home, cwd, { readManifest: mkReadManifest(new Map()) }));
|
||||
});
|
||||
assert.strictEqual(report.reason, SHADOW_REASON.RESOLVER_UNAVAILABLE);
|
||||
assert.strictEqual(report.shadowed, false);
|
||||
assert.deepStrictEqual(renderShadowReport(report), []);
|
||||
});
|
||||
|
||||
test('unknown runtime degrades to no report (A17)', () => {
|
||||
const home = '/fixture/a17-home';
|
||||
const cwd = '/fixture/a17-cwd';
|
||||
let report;
|
||||
assert.doesNotThrow(() => {
|
||||
report = buildShadowReport('not-a-real-runtime-xyz', baseOpts(home, cwd, { readManifest: mkReadManifest(new Map()) }));
|
||||
});
|
||||
assert.strictEqual(report.reason, SHADOW_REASON.RESOLVER_UNAVAILABLE);
|
||||
assert.strictEqual(report.shadowed, false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── A18 — caller mutation cannot corrupt a later call ─────────────────────
|
||||
|
||||
describe('buildShadowReport — independence across calls (A18)', () => {
|
||||
test('report is not shared across calls', () => {
|
||||
const home = '/fixture/a18-home';
|
||||
const cwd = '/fixture/a18-cwd';
|
||||
const homes = scopeHomes('claude', home, cwd);
|
||||
const byConfigHome = new Map([
|
||||
[homes.global, manifest({ manifestVersion: 2, runtime: 'cursor', scope: 'global', files: skillFilesFor(['plan-phase']) })],
|
||||
[homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['plan-phase']) })],
|
||||
]);
|
||||
const opts = baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) });
|
||||
|
||||
const first = buildShadowReport('claude', opts);
|
||||
const pristine = JSON.parse(JSON.stringify(first));
|
||||
|
||||
first.winner.kind = 'HACKED';
|
||||
first.triggers[0].trigger = 'HACKED';
|
||||
first.triggers.push({ trigger: 'INJECTED' });
|
||||
first.mismatches[0].declaredRuntime = 'HACKED';
|
||||
first.mismatches.push({ scope: 'INJECTED' });
|
||||
|
||||
const second = buildShadowReport('claude', opts);
|
||||
assert.deepStrictEqual(second, pristine, 'a second call must be unaffected by mutation of the first result');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── A19 — the production call shape ───────────────────────────────────────
|
||||
|
||||
describe('buildShadowReport — production call shape (A19)', () => {
|
||||
test('production call shape resolves, matches the injected-dep rows\' shape', (t) => {
|
||||
const home = createTempDir('gsd-shadow-a19-home-');
|
||||
const cwd = createTempDir('gsd-shadow-a19-cwd-');
|
||||
t.after(() => { cleanup(home); cleanup(cwd); });
|
||||
|
||||
const globalDir = path.join(home, '.claude');
|
||||
const localDir = path.join(cwd, '.claude');
|
||||
fs.mkdirSync(globalDir, { recursive: true });
|
||||
fs.mkdirSync(localDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(globalDir, MANIFEST_NAME), JSON.stringify({
|
||||
manifestVersion: 2, runtime: 'claude', scope: 'global',
|
||||
files: skillFilesFor(['plan-phase']),
|
||||
}));
|
||||
fs.writeFileSync(path.join(localDir, MANIFEST_NAME), JSON.stringify({
|
||||
manifestVersion: 2, runtime: 'claude', scope: 'local',
|
||||
files: commandFilesFor(['plan-phase']),
|
||||
}));
|
||||
|
||||
let report;
|
||||
assert.doesNotThrow(() => {
|
||||
// The exact production call shape: no injected registry, no injected
|
||||
// readManifest — real fs, real capability registry.
|
||||
report = buildShadowReport('claude', { home, cwd });
|
||||
});
|
||||
assert.strictEqual(report.shadowed, true);
|
||||
assert.deepStrictEqual(report.winner, { kind: 'skills', scope: 'global' });
|
||||
assert.deepStrictEqual(report.shadowedSide, { kind: 'commands', scope: 'local' });
|
||||
assert.strictEqual(report.triggers.length, 1);
|
||||
assert.deepStrictEqual(
|
||||
Object.keys(report).sort(),
|
||||
['kindsDiffer', 'mismatches', 'reason', 'runtime', 'shadowed', 'shadowedSide', 'triggers', 'winner'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── A20 — frozen reason-code enum key set is locked ───────────────────────
|
||||
|
||||
describe('SHADOW_REASON (A20)', () => {
|
||||
test('reason enum key set is locked', () => {
|
||||
assert.deepStrictEqual(
|
||||
Object.keys(SHADOW_REASON).sort(),
|
||||
['NOT_SHADOWED', 'RESOLVER_UNAVAILABLE', 'SCOPE_SHADOWED'],
|
||||
);
|
||||
});
|
||||
|
||||
test('is frozen', () => {
|
||||
assert.strictEqual(Object.isFrozen(SHADOW_REASON), true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── A21-A24 — per-scope truth filter (cross-scope stem-union false positive) ─
|
||||
|
||||
describe('buildShadowReport — per-scope truth filter (A21-A24)', () => {
|
||||
test('both scopes carry the same stems: every shadowed trigger reported (A21)', () => {
|
||||
const home = '/fixture/a21-home';
|
||||
const cwd = '/fixture/a21-cwd';
|
||||
const stems = ['plan-phase', 'milestone-complete', 'phase-create'];
|
||||
const report = buildShadowReport('claude', coexistenceOpts(home, cwd, stems));
|
||||
assert.strictEqual(report.shadowed, true);
|
||||
assert.deepStrictEqual(report.triggers.map((t) => t.trigger).sort(), stems.map((s) => `gsd-${s}`).sort());
|
||||
});
|
||||
|
||||
test('global strict superset of local (full vs core profile): only the intersection is reported (A22)', () => {
|
||||
const home = '/fixture/a22-home';
|
||||
const cwd = '/fixture/a22-cwd';
|
||||
const homes = scopeHomes('claude', home, cwd);
|
||||
// global = 'full' profile (a, b, c) — local = 'core' profile (a only).
|
||||
const byConfigHome = new Map([
|
||||
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['a', 'b', 'c']) })],
|
||||
[homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['a']) })],
|
||||
]);
|
||||
const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
|
||||
assert.strictEqual(report.shadowed, true);
|
||||
// Only 'a' is a REAL local artifact — 'b' and 'c' must never be reported
|
||||
// as shadowed local commands; there is no local artifact for either.
|
||||
assert.deepStrictEqual(report.triggers.map((t) => t.trigger), ['gsd-a']);
|
||||
});
|
||||
|
||||
test('local has a stem global does not: not reported as shadowed (A23)', () => {
|
||||
const home = '/fixture/a23-home';
|
||||
const cwd = '/fixture/a23-cwd';
|
||||
const homes = scopeHomes('claude', home, cwd);
|
||||
const byConfigHome = new Map([
|
||||
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['a']) })],
|
||||
[homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['a', 'z']) })],
|
||||
]);
|
||||
const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
|
||||
assert.strictEqual(report.shadowed, true);
|
||||
// 'z' exists ONLY at local (no global artifact "wins" it) — must not
|
||||
// appear in the shadowed set at all.
|
||||
assert.deepStrictEqual(report.triggers.map((t) => t.trigger), ['gsd-a']);
|
||||
});
|
||||
|
||||
test('disjoint stem sets: shadowed is false (A24)', () => {
|
||||
const home = '/fixture/a24-home';
|
||||
const cwd = '/fixture/a24-cwd';
|
||||
const homes = scopeHomes('claude', home, cwd);
|
||||
const byConfigHome = new Map([
|
||||
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['a', 'b']) })],
|
||||
[homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['x', 'y']) })],
|
||||
]);
|
||||
const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
|
||||
assert.strictEqual(report.shadowed, false);
|
||||
assert.deepStrictEqual(report.triggers, []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── F1 — bijective property: every trigger has exactly one winner ────────
|
||||
|
||||
describe('buildShadowReport — property (F1)', () => {
|
||||
test('every trigger has exactly one winner', () => {
|
||||
const stemArb = fc.stringMatching(/^[a-z0-9]{1,6}(-[a-z0-9]{1,6}){0,2}$/);
|
||||
const setArb = fc.uniqueArray(stemArb, { maxLength: 6 });
|
||||
|
||||
fc.assert(
|
||||
fc.property(setArb, setArb, (globalStems, localStems) => {
|
||||
const home = '/fixture/f1-home';
|
||||
const cwd = '/fixture/f1-cwd';
|
||||
const homes = scopeHomes('claude', home, cwd);
|
||||
const byConfigHome = new Map([
|
||||
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(globalStems) })],
|
||||
[homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(localStems) })],
|
||||
]);
|
||||
const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
|
||||
|
||||
// Both scopes are always "installed" here (manifestVersion set
|
||||
// regardless of file-list length), so `resolveOneRuntime`'s
|
||||
// `stemUnion` still synthesizes a candidate trigger for every stem
|
||||
// observed at EITHER scope. `buildShadowReport`'s per-scope truth
|
||||
// filter (#2873 Task 1 — see install-shadow-report.cts's module
|
||||
// comment) then narrows that down to the INTERSECTION: a trigger is
|
||||
// only reported when a real artifact exists at BOTH scopes.
|
||||
const expectedShadowed = globalStems
|
||||
.filter((s) => localStems.includes(s))
|
||||
.map((s) => `gsd-${s}`)
|
||||
.sort();
|
||||
const actualShadowed = report.triggers.map((t) => t.trigger).sort();
|
||||
assert.deepStrictEqual(actualShadowed, expectedShadowed);
|
||||
|
||||
// Bijection: each shadowed trigger names exactly one winner (kind,scope).
|
||||
for (const trig of report.triggers) {
|
||||
assert.strictEqual(trig.winnerKind, 'skills');
|
||||
assert.strictEqual(trig.winnerScope, 'global');
|
||||
assert.strictEqual(trig.shadowedKind, 'commands');
|
||||
assert.strictEqual(trig.shadowedScope, 'local');
|
||||
}
|
||||
// No trigger name appears twice in the shadowed set.
|
||||
assert.strictEqual(new Set(actualShadowed).size, actualShadowed.length);
|
||||
}),
|
||||
// Explicit seed + bounded numRuns (CONTRIBUTING: unseeded property
|
||||
// tests are a review blocker). On failure, fast-check's thrown error
|
||||
// carries the pinned seed and the shrunk counterexample needed to
|
||||
// replay deterministically.
|
||||
{ seed: 20260814, numRuns: 50 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── F2/F3 — sanitizeForRender properties (idempotence, stripped-class-free) ─
|
||||
|
||||
describe('sanitizeForRender — properties (F2, F3)', () => {
|
||||
// Explicit seed + bounded numRuns (CONTRIBUTING: unseeded property tests
|
||||
// are a review blocker), matching F1/F4's seed above.
|
||||
const SEED = 20260814;
|
||||
const NUM_RUNS = 300;
|
||||
|
||||
// Hostile-input generator: ANSI CSI/OSC escape sequences, C0 controls
|
||||
// (including bare \x00 and a lone unterminated \x1b), DEL/C1, Unicode bidi
|
||||
// embedding/override + isolate controls, combining marks ("zalgo",
|
||||
// U+0300-U+036F), zero-width characters (ZWSP/ZWNJ/ZWJ/BOM), astral-plane
|
||||
// characters (surrogate-pair-backed — real emoji/supplementary-plane text,
|
||||
// not just printable ASCII), CRLF/LF/CR newlines, and plain text —
|
||||
// interleaved so a single generated string usually mixes several hostile
|
||||
// classes at once, per the brief's "not just printable ASCII, or the
|
||||
// properties are vacuous" requirement.
|
||||
//
|
||||
// #2873 PR review Finding 2 (MINOR): `sanitizeForRender` originally
|
||||
// stripped ANSI/control/bidi only, missing combining marks and zero-width
|
||||
// characters — neither is a JS `\s`, so both survived the 64-char cap and
|
||||
// the whitespace-collapse step undetected. `combiningArb`/`zeroWidthArb`
|
||||
// and the extended `STRIPPED_CLASS_RE` below close that generator gap.
|
||||
const c0ControlArb = fc.integer({ min: 0x00, max: 0x1f }).map((c) => String.fromCharCode(c));
|
||||
const delC1Arb = fc.integer({ min: 0x7f, max: 0x9f }).map((c) => String.fromCharCode(c));
|
||||
const ansiCsiArb = fc.constantFrom('\x1b[31m', '\x1b[0m', '\x1b[2K', '\x1b[1;37;40m');
|
||||
const ansiOscArb = fc.constantFrom('\x1b]0;title\x07', '\x1b]8;;http://example\x1b\\');
|
||||
// Bidi embedding/override controls (U+202A-U+202E) and isolates
|
||||
// (U+2066-U+2069), written as `\u{...}` escapes rather than literal
|
||||
// characters — see the #2873 PR review Finding 2 note above.
|
||||
const BIDI_LRE = '\u{202A}'; // Left-to-Right Embedding
|
||||
const BIDI_RLE = '\u{202B}'; // Right-to-Left Embedding
|
||||
const BIDI_PDF = '\u{202C}'; // Pop Directional Formatting
|
||||
const BIDI_LRO = '\u{202D}'; // Left-to-Right Override
|
||||
const BIDI_RLO = '\u{202E}'; // Right-to-Left Override
|
||||
const BIDI_LRI = '\u{2066}'; // Left-to-Right Isolate
|
||||
const BIDI_RLI = '\u{2067}'; // Right-to-Left Isolate
|
||||
const BIDI_FSI = '\u{2068}'; // First Strong Isolate
|
||||
const BIDI_PDI = '\u{2069}'; // Pop Directional Isolate
|
||||
const bidiArb = fc.constantFrom(
|
||||
BIDI_LRE, BIDI_RLE, BIDI_PDF, BIDI_LRO, BIDI_RLO, // embedding/override
|
||||
BIDI_LRI, BIDI_RLI, BIDI_FSI, BIDI_PDI, // isolates
|
||||
);
|
||||
const combiningArb = fc.constantFrom('\u{0300}', '\u{0301}', '\u{0302}', '\u{036F}');
|
||||
const zeroWidthArb = fc.constantFrom('\u{200B}', '\u{200C}', '\u{200D}', '\u{FEFF}');
|
||||
const astralArb = fc.constantFrom('\u{1F600}', '\u{1F4A9}', '\u{10000}', '\u{1F469}\u{200D}\u{1F4BB}');
|
||||
const newlineArb = fc.constantFrom('\n', '\r\n', '\r');
|
||||
const plainArb = fc.string({ maxLength: 12 });
|
||||
|
||||
const hostileChunkArb = fc.oneof(
|
||||
c0ControlArb, delC1Arb, ansiCsiArb, ansiOscArb, bidiArb, combiningArb, zeroWidthArb, astralArb, newlineArb, plainArb,
|
||||
);
|
||||
const hostileStringArb = fc.array(hostileChunkArb, { maxLength: 10 }).map((parts) => parts.join(''));
|
||||
|
||||
// The stripped classes sanitizeForRender documents (ANSI escapes, C0
|
||||
// controls + DEL/C1, Unicode bidi override/isolate, combining marks,
|
||||
// zero-width characters), checked with an INDEPENDENT regex here rather
|
||||
// than re-requiring the module's private ANSI_RE/CONTROL_RE/BIDI_RE/
|
||||
// COMBINING_MARK_RE/ZERO_WIDTH_RE — so F3 is a real invariant check
|
||||
// against the module's documented contract, not a tautology against its
|
||||
// own internals.
|
||||
// eslint-disable-next-line no-control-regex, no-misleading-character-class
|
||||
const STRIPPED_CLASS_RE = /[\x00-\x1f\x7f-\x9f\u{202A}-\u{202E}\u{2066}-\u{2069}\u{0300}-\u{036F}\u{200B}-\u{200D}\u{FEFF}]/u;
|
||||
|
||||
test('sanitizer is idempotent: s(s(x)) === s(x) for arbitrary strings (F2)', () => {
|
||||
let changedCount = 0;
|
||||
fc.assert(
|
||||
fc.property(hostileStringArb, (input) => {
|
||||
const once = sanitizeForRender(input);
|
||||
if (once !== input) changedCount += 1;
|
||||
const twice = sanitizeForRender(once);
|
||||
assert.strictEqual(
|
||||
twice,
|
||||
once,
|
||||
`not idempotent — input: ${JSON.stringify(input)}\nonce: ${JSON.stringify(once)}\ntwice: ${JSON.stringify(twice)}`,
|
||||
);
|
||||
}),
|
||||
{ seed: SEED, numRuns: NUM_RUNS },
|
||||
);
|
||||
// Non-vacuity (mirrors F4's real-shape-generator rationale above): prove
|
||||
// the generator actually produced input sanitizeForRender changed at
|
||||
// least once, or this property would pass trivially over inert strings.
|
||||
assert.ok(changedCount > 0, 'generator never produced a string sanitizeForRender actually changed — F2 would be vacuous');
|
||||
});
|
||||
|
||||
test('sanitizer output never contains a stripped-class character, for arbitrary input (F3)', () => {
|
||||
let hostileInputCount = 0;
|
||||
fc.assert(
|
||||
fc.property(hostileStringArb, (input) => {
|
||||
if (STRIPPED_CLASS_RE.test(input)) hostileInputCount += 1;
|
||||
const output = sanitizeForRender(input);
|
||||
assert.ok(
|
||||
output === null || !STRIPPED_CLASS_RE.test(output),
|
||||
`stripped-class character survived sanitization — input: ${JSON.stringify(input)}\noutput: ${JSON.stringify(output)}`,
|
||||
);
|
||||
}),
|
||||
{ seed: SEED, numRuns: NUM_RUNS },
|
||||
);
|
||||
// Non-vacuity: prove the generator actually exercised at least one
|
||||
// stripped-class character, or F3 would hold trivially over clean input.
|
||||
assert.ok(hostileInputCount > 0, 'generator never produced a stripped-class character — F3 would be vacuous');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── E1-E12 — resolveSpecRootReference direct unit coverage ───────────────
|
||||
// Matrix section E (`.gsd/phase/feat-2873-cross-scope-shadowing/50-test-matrix.md`).
|
||||
// Only the E13/E14 installed-output integration pair (in
|
||||
// tests/install-runtime-artifacts.test.cjs) and F4's idempotence property
|
||||
// (above) touched this exported function before this block — these rows
|
||||
// exercise it DIRECTLY, one behavior at a time.
|
||||
describe('resolveSpecRootReference — direct unit coverage (E1-E12)', () => {
|
||||
test('global skill body: the include is replaced by the two-step imperative form naming both candidates (E1)', () => {
|
||||
const body = '@~/.claude/gsd-core/workflows/plan-phase.md';
|
||||
const result = resolveSpecRootReference(body);
|
||||
assert.notStrictEqual(result, body);
|
||||
assert.ok(!result.startsWith('@'), 'the static @-include must be gone');
|
||||
assert.ok(
|
||||
result.includes('.claude/gsd-core/workflows/plan-phase.md') && result.includes('~/.claude/gsd-core/workflows/plan-phase.md'),
|
||||
`expected both the project-local and global candidate paths named in: ${JSON.stringify(result)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('local command body: byte-identical to today (E2)', () => {
|
||||
// The REAL literal a local claude install emits for this same source
|
||||
// line (verified empirically against a real --local install): the
|
||||
// installer's path-prefix rewrite resolves the local scope's absolute
|
||||
// config dir, never `~`, so this never has the `@~/.claude/` shape
|
||||
// WORKFLOW_SPEC_ROOT_INCLUDE_RE requires in the first place — a genuine
|
||||
// non-qualifying condition, not a hand-waved one.
|
||||
const body = '@/Users/dev/myrepo/.claude/gsd-core/workflows/plan-phase.md';
|
||||
assert.strictEqual(resolveSpecRootReference(body), body);
|
||||
});
|
||||
|
||||
test('every non-claude runtime, both scopes: byte-identical to today (E3)', () => {
|
||||
// Real literal shapes emitted for other runtimes (verified empirically
|
||||
// against a real --cursor --global install): no `.claude/` segment at
|
||||
// all, so none of them ever match the claude-only spec-root regex.
|
||||
const cursorGlobal = '@$HOME/gsd-core/workflows/plan-phase.md';
|
||||
const genericLocal = '@./gsd-core/workflows/plan-phase.md';
|
||||
assert.strictEqual(resolveSpecRootReference(cursorGlobal), cursorGlobal);
|
||||
assert.strictEqual(resolveSpecRootReference(genericLocal), genericLocal);
|
||||
});
|
||||
|
||||
test('a references/ include is a different spec root and stays static (E4)', () => {
|
||||
const body = '@~/.claude/gsd-core/references/ui-brand.md';
|
||||
assert.strictEqual(resolveSpecRootReference(body), body);
|
||||
});
|
||||
|
||||
test('an @.planning/… include is untouched (E5)', () => {
|
||||
const body = '@.planning/notes.md';
|
||||
assert.strictEqual(resolveSpecRootReference(body), body);
|
||||
});
|
||||
|
||||
test('an include inside a fenced code block is untouched, byte-identical (E6)', () => {
|
||||
const body = ['```', '@~/.claude/gsd-core/workflows/plan-phase.md', '```'].join('\n');
|
||||
assert.strictEqual(resolveSpecRootReference(body), body);
|
||||
});
|
||||
|
||||
test('an include mentioned in inline backticks is untouched, byte-identical (E7)', () => {
|
||||
const body = 'See `@~/.claude/gsd-core/workflows/plan-phase.md` for the spec.';
|
||||
assert.strictEqual(resolveSpecRootReference(body), body);
|
||||
});
|
||||
|
||||
test('a body with no workflow include is a no-op (E8)', () => {
|
||||
const body = 'Just some ordinary command prose with no includes at all.';
|
||||
assert.strictEqual(resolveSpecRootReference(body), body);
|
||||
});
|
||||
|
||||
test('two independent workflow includes both resolve (E9)', () => {
|
||||
const body = [
|
||||
'@~/.claude/gsd-core/workflows/plan-phase.md',
|
||||
'@~/.claude/gsd-core/workflows/execute-phase.md',
|
||||
].join('\n');
|
||||
const result = resolveSpecRootReference(body);
|
||||
assert.ok(!result.includes('@~/.claude/gsd-core/workflows/plan-phase.md'));
|
||||
assert.ok(!result.includes('@~/.claude/gsd-core/workflows/execute-phase.md'));
|
||||
assert.ok(result.includes('.claude/gsd-core/workflows/plan-phase.md'));
|
||||
assert.ok(result.includes('.claude/gsd-core/workflows/execute-phase.md'));
|
||||
});
|
||||
|
||||
test('prose merely mentioning gsd-core/workflows/x.md is untouched (E10)', () => {
|
||||
const body = 'See gsd-core/workflows/plan-phase.md for background on how this works.';
|
||||
assert.strictEqual(resolveSpecRootReference(body), body);
|
||||
});
|
||||
|
||||
test('a CRLF body emits identically to LF, no orphaned \\r (E11)', () => {
|
||||
const bodyLf = '@~/.claude/gsd-core/workflows/plan-phase.md\nSecond line.';
|
||||
const bodyCrlf = '@~/.claude/gsd-core/workflows/plan-phase.md\r\nSecond line.';
|
||||
const resultLf = resolveSpecRootReference(bodyLf);
|
||||
const resultCrlf = resolveSpecRootReference(bodyCrlf);
|
||||
assert.strictEqual(resultCrlf, resultLf.replace('\n', '\r\n'));
|
||||
// Every `\r` in the result must be immediately followed by `\n` — an
|
||||
// orphaned CR (one not paired with the LF that owns it) would mean the
|
||||
// transform dropped or duplicated a line-ending byte.
|
||||
assert.ok(!/\r(?!\n)/.test(resultCrlf), `orphaned CR found in: ${JSON.stringify(resultCrlf)}`);
|
||||
});
|
||||
|
||||
test('applying the transform twice over an installed tree is idempotent (E12)', () => {
|
||||
const body = 'intro\n@~/.claude/gsd-core/workflows/plan-phase.md\noutro';
|
||||
const once = resolveSpecRootReference(body);
|
||||
const twice = resolveSpecRootReference(once);
|
||||
assert.strictEqual(twice, once);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── E-rows — resolveSpecRootReference fence-detection regressions ────────
|
||||
// (#2873 PR review Finding 1, MEDIUM): the hand-rolled `FENCE_DELIMITER_RE`
|
||||
// tracker toggled open/closed on ANY delimiter line regardless of type,
|
||||
// which is wrong under CommonMark (a closer must share the opener's
|
||||
// delimiter character and have run length >= the opener's). The fix reuses
|
||||
// `scanFencedBlocks` (`markdown-sectionizer.cts`). These cases pin the exact
|
||||
// failure the review constructed plus the sibling CommonMark edge cases
|
||||
// named in the review (nested fences, an unterminated fence, and a
|
||||
// longer-run opener closed by a too-short run).
|
||||
describe('resolveSpecRootReference — unit (fence detection, #2873 review Finding 1)', () => {
|
||||
test('mismatched fence types (``` opened, ~~~ inside, ``` closes) leave BOTH includes untouched', () => {
|
||||
const body = [
|
||||
'```',
|
||||
'@~/.claude/gsd-core/workflows/alpha.md',
|
||||
'~~~',
|
||||
'@~/.claude/gsd-core/workflows/beta.md',
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
const result = resolveSpecRootReference(body);
|
||||
|
||||
assert.strictEqual(result, body, 'a ``` fence is not closed by a ~~~ line — both includes must stay inside the one open block');
|
||||
assert.ok(!result.includes('To load this command'), 'no rewrite marker should appear when both includes are fenced');
|
||||
});
|
||||
|
||||
test('nested fences (outer run longer than an inner same-char run) leave the enclosed include untouched', () => {
|
||||
const body = [
|
||||
'````',
|
||||
'```',
|
||||
'@~/.claude/gsd-core/workflows/nested.md',
|
||||
'```',
|
||||
'````',
|
||||
].join('\n');
|
||||
|
||||
const result = resolveSpecRootReference(body);
|
||||
|
||||
assert.strictEqual(result, body, 'the inner 3-backtick lines are content, not closers, for a 4-backtick opener');
|
||||
});
|
||||
|
||||
test('an unterminated fence covers to end-of-string, leaving the include untouched', () => {
|
||||
const body = [
|
||||
'```',
|
||||
'@~/.claude/gsd-core/workflows/orphan.md',
|
||||
].join('\n');
|
||||
|
||||
const result = resolveSpecRootReference(body);
|
||||
|
||||
assert.strictEqual(result, body, 'a fence with no closer is still open through EOF');
|
||||
});
|
||||
|
||||
test('a fence opened with a longer run (````) is NOT closed by a shorter run (```)', () => {
|
||||
const body = [
|
||||
'````',
|
||||
'@~/.claude/gsd-core/workflows/longshort.md',
|
||||
'```',
|
||||
'@~/.claude/gsd-core/workflows/other.md',
|
||||
'````',
|
||||
].join('\n');
|
||||
|
||||
const result = resolveSpecRootReference(body);
|
||||
|
||||
assert.strictEqual(
|
||||
result,
|
||||
body,
|
||||
'a 3-backtick line cannot close a 4-backtick opener per CommonMark run-length rule — both includes stay inside the one fence',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── F4 — resolveSpecRootReference is idempotent over arbitrary bodies ────
|
||||
|
||||
describe('resolveSpecRootReference — property (F4)', () => {
|
||||
test('spec-root transform is idempotent over arbitrary bodies', () => {
|
||||
// A bare fc.string() body would almost never contain the exact
|
||||
// `@~/.claude/gsd-core/workflows/<stem>.md` shape `resolveSpecRootReference`
|
||||
// matches, making the property vacuous (see this suite's F4 comment and
|
||||
// CONTRIBUTING's writer-seeded-vs-document-shaped generator guidance).
|
||||
// Instead, bodies are assembled from chunks that actually exercise every
|
||||
// branch of the transform: a real include line (rewritten), a fenced
|
||||
// block wrapping the SAME include shape (left untouched — Claude Code
|
||||
// documents backticks as the way to prevent an `@`-import), a
|
||||
// `@.planning/…` include (a different spec root, untouched), plain prose
|
||||
// that merely MENTIONS `gsd-core/workflows/<stem>.md` without the
|
||||
// line-start `@` (untouched), and arbitrary free text.
|
||||
const stemArb = fc.stringMatching(/^[a-z][a-z0-9._-]{0,20}$/);
|
||||
const includeLineArb = stemArb.map((s) => `@~/.claude/gsd-core/workflows/${s}.md`);
|
||||
const proseMentionArb = stemArb.map((s) => `See gsd-core/workflows/${s}.md for background.`);
|
||||
const planningIncludeArb = stemArb.map((s) => `@.planning/${s}.md`);
|
||||
const fencedIncludeArb = fc.tuple(fc.constantFrom('```', '~~~'), stemArb).map(
|
||||
([fence, s]) => `${fence}\n@~/.claude/gsd-core/workflows/${s}.md\n${fence}`,
|
||||
);
|
||||
// #2873 review Finding 1: a same-type-only generator is structurally
|
||||
// incapable of producing the mismatched-delimiter defect the review
|
||||
// constructed (a ``` fence "closed" by a ~~~ line). Also emit
|
||||
// mismatched-type and nested-run shapes so the property actually
|
||||
// exercises the CommonMark same-type/same-or-longer-run closing rule,
|
||||
// not just the trivial same-fence-twice case.
|
||||
const mismatchedFencedIncludeArb = fc.tuple(
|
||||
fc.constantFrom(['```', '~~~'], ['~~~', '```']),
|
||||
stemArb,
|
||||
stemArb,
|
||||
).map(
|
||||
([[openFence, midFence], s1, s2]) =>
|
||||
`${openFence}\n@~/.claude/gsd-core/workflows/${s1}.md\n${midFence}\n@~/.claude/gsd-core/workflows/${s2}.md\n${openFence}`,
|
||||
);
|
||||
const nestedFencedIncludeArb = fc.tuple(fc.constantFrom('```', '~~~'), stemArb).map(
|
||||
([fenceChar, s]) => {
|
||||
const inner = fenceChar.repeat(3);
|
||||
const outer = fenceChar.repeat(4);
|
||||
return `${outer}\n${inner}\n@~/.claude/gsd-core/workflows/${s}.md\n${inner}\n${outer}`;
|
||||
},
|
||||
);
|
||||
const plainTextArb = fc.string({ maxLength: 40 });
|
||||
|
||||
const chunkArb = fc.oneof(
|
||||
includeLineArb,
|
||||
proseMentionArb,
|
||||
planningIncludeArb,
|
||||
fencedIncludeArb,
|
||||
mismatchedFencedIncludeArb,
|
||||
nestedFencedIncludeArb,
|
||||
plainTextArb,
|
||||
);
|
||||
const bodyArb = fc.array(chunkArb, { maxLength: 12 }).map((chunks) => chunks.join('\n'));
|
||||
|
||||
fc.assert(
|
||||
fc.property(bodyArb, (body) => {
|
||||
const once = resolveSpecRootReference(body);
|
||||
const twice = resolveSpecRootReference(once);
|
||||
assert.strictEqual(
|
||||
twice,
|
||||
once,
|
||||
`not idempotent — body: ${JSON.stringify(body)}\nonce: ${JSON.stringify(once)}\ntwice: ${JSON.stringify(twice)}`,
|
||||
);
|
||||
}),
|
||||
// Explicit seed + bounded numRuns, replay data printed on failure via
|
||||
// the assertion message above (fast-check's own thrown error additionally
|
||||
// carries the pinned seed + shrunk counterexample needed to replay).
|
||||
{ seed: 20260814, numRuns: 300 },
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user