feat(#2873): detect cross-scope shadowing and reach the local spec tree

4a - the detection floor. A shadowed install now reports which triggers are
shadowed and which scope wins, at install time and through a new W028
/gsd-health diagnostic. Exit codes are untouched: a shadowed install is a
warning, not a failure. Only triggers whose stem exists at BOTH scopes are
reported, so a global full profile beside a local core profile no longer
names local artifacts the user does not have.

4b - spec-root reachability, claude runtime and global scope only. The
winning global skill stops carrying a static workflow @-include and instead
resolves its spec at runtime: prefer the project-local copy, fall back to
the global one, stop if neither exists. Every other @-include stays static,
and the local emission is byte-identical. It runs after the staged-skills
rewrite pass, whose claude branch would otherwise mangle the literal tilde
path into an undocumented $HOME form.

Also fixed inline: readInstallManifest classified a top-level JSON array as
an installed v1 manifest, because typeof [] is object.

Refs #2873
This commit is contained in:
sim
2026-08-14 22:47:17 -04:00
parent 52f4ea17cc
commit 2641e6cb67
14 changed files with 1765 additions and 176 deletions

1
.gitignore vendored
View File

@@ -213,6 +213,7 @@ build/
/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs
/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs
/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs
/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs
/gsd-core/bin/lib/command-roster.cjs
/gsd-core/bin/lib/runtime-artifact-conversion.cjs
/gsd-core/bin/lib/runtime-artifact-layout.cjs

View File

@@ -60,6 +60,10 @@ const { composeWorkflow } = require('../gsd-core/bin/lib/workflow-fragments.cjs'
const { shouldCompose } = require('../gsd-core/bin/lib/mcp-catalog.cjs');
const runtimeArtifactConversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
const { escapeRegex: escapeRegExp } = require('../gsd-core/bin/lib/pattern.cjs');
// #2873: cross-scope shadow detection — reports (never fails) when a
// GSD-owned scope shadows another on this machine (design doc:
// .gsd/phase/feat-2873-cross-scope-shadowing/40-design.md).
const { buildShadowReport, renderShadowReport } = require('../gsd-core/bin/lib/install-shadow-report.cjs');
// #2544: the CommonJS marker's single source of truth. classifyMarker() backs
// BOTH ensureCommonJsMarker() (install) and removeCommonJsMarker() (uninstall),
// so the write side can no longer clobber a package.json the remove side would
@@ -11664,6 +11668,29 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// Report any backed-up local patches
reportLocalPatches(targetDir, runtime);
// #2873: cross-scope shadow report. Fires ONCE per install (this is the
// only writeManifest call site that gets it — the other four sites are
// sub-writes within a single install, not separate installs). A shadowed
// install is a warning, never a failure (ADR-2866 Consequences), so this
// never touches `failures` or `process.exit`, and the whole block is
// wrapped in a try/catch that swallows everything: a report failure must
// never fail an otherwise-successful install (design row C5). No options
// are injected into buildShadowReport — this is the production call shape,
// resolving the real machine via os.homedir()/process.cwd() defaults
// inside the resolver.
try {
const shadowReport = buildShadowReport(runtime);
const shadowLines = renderShadowReport(shadowReport);
if (shadowLines.length > 0) {
console.warn(`\n ${yellow}⚠${reset} ${shadowLines[0]}`);
for (const line of shadowLines.slice(1)) {
console.warn(` ${dim}${line}${reset}`);
}
}
} catch (_shadowReportErr) {
// Never fail an install over a reporting concern — see comment above.
}
// Verify no leaked .claude paths in non-Claude runtimes (manifest-scoped)
if (!_hostBehaviors(runtime).ownsClaudePaths) {
const leakedPaths = [];

View File

@@ -0,0 +1,110 @@
/**
* Install Surface Shadowing rule (#2873, epic #2866 Phase 4a — governed by
* `.gsd/phase/feat-2873-cross-scope-shadowing/40-design.md`).
*
* One code, W028, surfacing `install-shadow-report.cts`'s `ShadowReport` as
* a `/gsd-health` diagnostic — the design doc's row #4 ("one diagnostic,
* severity WARNING, same projection" as the install-time report). WARNING
* severity, ADVISE remedy with `risk: NONE`: shadowing is never auto-fixable
* (there is no single correct scope to remove) so this is advisory only,
* mirroring `agent-install.cts`'s W010 shape.
*
* Reaches OUTSIDE the planning snapshot the same way `agent-install.cts`
* does for W010: `resolveRuntime(cwd)` (`runtime-slash.cjs`) resolves the
* runtime id from `process.env.GSD_RUNTIME` / `config.runtime` / the
* `'claude'` default (never throws), using `snapshot.cwd`
* (`planning-snapshot.cts`'s `buildPlanningSnapshot` — `path.resolve(cwd)`)
* as the project directory. `buildShadowReport` is then called with that
* `cwd` so the local scope resolves against the project actually being
* health-checked, while `home` is left un-injected so the resolver defaults
* to `os.homedir()` — the real machine, same production call shape the
* installer uses.
*
* `check(snapshot)` degrades to `[]` (never throws) whenever there is
* nothing installable to report: `buildShadowReport` itself already
* degrades an unresolvable runtime (`configHome.kind === 'none'`, e.g.
* vscode — design row #7) to `reason: RESOLVER_UNAVAILABLE`, which
* `renderShadowReport` renders as `[]`; the try/catch around both calls
* below additionally absorbs any other unexpected throw (design row D5),
* since this rule is advisory and must never make `/gsd-health` itself
* fail.
*
* Design: .gsd/phase/feat-2873-cross-scope-shadowing/40-design.md
*
* ADR-457 build-at-publish: source in
* src/health-diagnostic-rules/install-surface-shadowing.cts, compiled to
* gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs
* (gitignored).
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports -- type-only; erased at compile time, no runtime require emitted
import type planningSnapshotMod = require('../planning-snapshot.cjs');
type PlanningSnapshot = ReturnType<typeof planningSnapshotMod.buildPlanningSnapshot>;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import healthDiagnosticMod = require('../health-diagnostic-types.cjs');
const { SEVERITY, adviseRemedy } = healthDiagnosticMod;
type Rule = healthDiagnosticMod.Rule;
type Diagnostic = healthDiagnosticMod.Diagnostic;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import installShadowReportMod = require('../install-shadow-report.cjs');
const { buildShadowReport, renderShadowReport } = installShadowReportMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import runtimeSlashMod = require('../runtime-slash.cjs');
const { resolveRuntime } = runtimeSlashMod;
import { PACKAGE_NAME } from '../package-identity.cjs';
/**
* `check(snapshot)` for W028 — see module header for the degrade-to-`[]`
* cases and the runtime-resolution mechanism reused from `agent-install.cts`.
*/
function checkInstallSurfaceShadowing(snapshot: PlanningSnapshot): Diagnostic[] {
let lines: string[];
let runtime: string;
try {
runtime = resolveRuntime(snapshot.cwd);
const report = buildShadowReport(runtime, { cwd: snapshot.cwd });
// `renderShadowReport` returns `[]` for every reason other than
// SCOPE_SHADOWED (design rows #1, #2, #6, #7, #8, #11), and sanitizes
// every `declaredRuntime` it interpolates via `sanitizeForRender`
// internally (`install-shadow-report.cts`) — this rule's `message`
// reuses that render path rather than re-sanitizing, so the
// sanitize-at-the-render-seam guarantee (design row #13) holds here too.
// Trigger names are already SAFE_STEM-gated upstream
// (`installed-surface-resolver.cts`'s `deriveStemsForKindEntry`) before
// they ever reach a rendered line — no re-gating needed here either.
lines = renderShadowReport(report);
} catch {
// Advisory rule: an unresolvable runtime, a `configHome.kind === 'none'`
// runtime, or any other unexpected failure degrades to "no finding",
// never a thrown exception that would break `/gsd-health` itself
// (design row D5).
return [];
}
if (lines.length === 0) return [];
return [
{
code: 'W028',
severity: SEVERITY.WARNING,
message: lines.join(' '),
remedy: adviseRemedy(`Review install scopes for ${runtime}: npx ${PACKAGE_NAME}@latest`),
},
];
}
const RULES: Rule[] = [
{
code: 'W028',
severity: SEVERITY.WARNING,
description: 'A GSD-owned install scope shadows another on this machine',
repairable: false,
check: checkInstallSurfaceShadowing,
},
];
export = { RULES };

View File

@@ -83,6 +83,8 @@ import worktreeHealthMod = require('./health-diagnostic-rules/worktree-health.cj
import milestoneArchiveHygieneMod = require('./health-diagnostic-rules/milestone-archive-hygiene.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import consistencyMod = require('./health-diagnostic-rules/consistency.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import installSurfaceShadowingMod = require('./health-diagnostic-rules/install-surface-shadowing.cjs');
const RULES: Rule[] = [
...rootExistenceMod.RULES,
@@ -93,6 +95,7 @@ const RULES: Rule[] = [
...roadmapDiskConsistencyMod.RULES,
...worktreeHealthMod.RULES,
...milestoneArchiveHygieneMod.RULES,
...installSurfaceShadowingMod.RULES,
];
/**

View File

@@ -43,6 +43,55 @@
* (`installed-surface-resolver.cts`'s `deriveStemsForKindEntry`) before they
* ever reach a `TriggerSurface` — this module does not re-gate them.
*
* ── Per-scope truth filter (why this lives HERE, not in the resolver) ──────
* `resolveOneRuntime` (`installed-surface-resolver.cts`) builds ONE union of
* every installed scope's `stems` and hands that single list to
* `resolveTriggerSurface`, which then synthesizes a candidate trigger for
* EVERY stem at EVERY installed scope's trigger-bearing kind entry —
* regardless of whether that specific scope's own manifest actually shipped
* that stem. Concretely: a global `full`-profile install (stems a, b, c)
* alongside a local `core`-profile install (stem a only) unions to
* `{a, b, c}`, and `resolveTriggerSurface` then reports `commands@local`
* candidates for b and c too — trigger names for artifacts that do not exist
* on disk at that scope. Left unfiltered, this module would tell the user
* `/gsd-b` and `/gsd-c` are shadowed local commands when there is no local
* artifact for either at all — over-reporting that is not cosmetic, since
* the whole point of this report is to make a real failure legible.
*
* `resolveTriggerSurface`'s API takes ONE stem list shared by every scope it
* is asked about, so per-scope truth cannot be expressed through it without
* either widening a shipped Phase-2 contract other callers may depend on, or
* calling it once per scope and re-implementing its winner computation
* (`isHigherPriority`) here as a second, driftable copy. `resolveOneRuntime`
* / `resolveInstalledSurfaces` (Phase 3, #2872) is likewise a shipped module
* this task deliberately leaves untouched. This module already receives the
* full `InstalledRuntimeSurface`, including each scope's own REAL `stems`
* list (`installed-surface-resolver.cts`'s `deriveStemsFromManifest`) — so
* the correction belongs here, as a filter over `resolveTriggerSurface`'s
* already-computed `shadowedBy` groups: a trigger is reported as shadowed
* only when its underlying stem is present in BOTH the winner's scope's own
* `stems` AND the shadowed side's scope's own `stems` — i.e. an artifact
* genuinely exists at both scopes, not merely "some stem exists somewhere in
* the union".
*
* `TriggerSurface` does not carry the originating stem OR the composing
* prefix on its output — only the already-composed `trigger` string
* (`${prefix}${stem}`) — so the stem cannot be read off it directly. Rather
* than hand-roll a fixed-offset `trigger.slice(4)` (which would silently
* assume every runtime's prefix is exactly `gsd-` — true today, but not a
* contract this module owns), the prefix is recovered the honest way: by
* re-resolving that scope's `ArtifactKind` layout (`resolveRuntimeArtifactLayout`
* / `resolveRuntimeArtifactLayoutFromRegistry`, the SAME layout descriptor
* `resolveTriggerSurface` itself reads its `entry.prefix` from) for the
* winner's and shadowed side's own `(scope, kind)`, and reading `.prefix`
* off the matching kind entry. This is metadata-only (constructing an
* `ArtifactKind` never touches the filesystem — see
* `runtime-artifact-layout.cts`'s kind-builder functions), so it costs
* nothing beyond a small per-`(scope,kind)` memo. If a prefix cannot be
* resolved at all (a `TypeError` from an unexpected registry shape), the
* trigger is conservatively DROPPED rather than kept — the same
* report-nothing-you-cannot-prove posture as the rest of this filter.
*
* ── Pure with respect to caller-visible state ───────────────────────────────
* `buildShadowReport` builds a fresh `ShadowReport` (fresh arrays, fresh
* objects) on every call, exactly as the resolver documents for itself
@@ -55,8 +104,24 @@ import {
resolveInstalledSurfaces,
type ResolveInstalledSurfacesOptions,
type InstalledRuntimeSurface,
type InstalledScopeRecord,
} from './installed-surface-resolver.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import runtimeArtifactLayoutMod = require('./runtime-artifact-layout.cjs');
const { resolveRuntimeArtifactLayout, resolveRuntimeArtifactLayoutFromRegistry } = runtimeArtifactLayoutMod;
/** The registry shape `resolveRuntimeArtifactLayoutFromRegistry` accepts as
* its first argument — reused (not re-typed) so `opts.registry` can be
* forwarded to it, mirroring `installed-surface-resolver.cts`'s own
* `LayoutRegistryLike`. */
type LayoutRegistryLike = Parameters<typeof resolveRuntimeArtifactLayoutFromRegistry>[0];
/** `installed-surface-resolver.cts` does not export `TriggerSurface` by
* name (only via `InstalledRuntimeSurface.triggers`'s element type) —
* derived here rather than re-declared as a second, driftable shape. */
type TriggerSurface = InstalledRuntimeSurface['triggers'][number];
// ── Reason enum ─────────────────────────────────────────────────────────
export const SHADOW_REASON = Object.freeze({
@@ -133,6 +198,76 @@ export function sanitizeForRender(value: string | null): string | null {
return stripped.replace(/\s+/g, ' ').trim();
}
// ── Per-scope truth filter helpers ─────────────────────────────────────
/**
* Build a `(scope, kind) -> prefix | null` lookup for one runtime, memoized
* per call to `buildShadowReport` (never shared across calls — matches this
* module's "fresh objects on every call" contract). `null` means "could not
* be resolved" (unknown scope record, or a `TypeError` from the layout
* resolver) — the caller treats that as "cannot honestly attribute this
* trigger to a real stem here", not as "assume it is fine".
*/
function buildPrefixLookup(
runtime: string,
scopeRecords: Map<InstallScope, InstalledScopeRecord>,
opts: ResolveInstalledSurfacesOptions,
): (scope: InstallScope, kind: string) => string | null {
const cache = new Map<string, string | null>();
return (scope: InstallScope, kind: string): string | null => {
const key = `${scope}:${kind}`;
if (cache.has(key)) return cache.get(key) as string | null;
const record = scopeRecords.get(scope);
let prefix: string | null = null;
if (record) {
try {
const layout = opts.registry !== undefined
? resolveRuntimeArtifactLayoutFromRegistry(opts.registry as LayoutRegistryLike, runtime, record.configHome, record.scope)
: resolveRuntimeArtifactLayout(runtime, record.configHome, record.scope);
const kindEntry = (layout.kinds as Array<{ kind: string; prefix: string }>).find((k) => k.kind === kind);
prefix = kindEntry ? kindEntry.prefix : null;
} catch {
// Unknown runtime / malformed registry — degrade to "cannot resolve",
// never throw out of a report builder (matches this module's own
// RESOLVER_UNAVAILABLE degrade-not-propagate posture above).
prefix = null;
}
}
cache.set(key, prefix);
return prefix;
};
}
/** `trigger` minus `prefix`, or `null` when `prefix` is unknown, does not
* actually prefix `trigger`, or the remainder would be empty (a `prefix`
* covering the whole trigger string is not a real stem). */
function stemFromTrigger(trigger: string, prefix: string | null): string | null {
if (prefix === null || !trigger.startsWith(prefix)) return null;
const stem = trigger.slice(prefix.length);
return stem === '' ? null : stem;
}
/**
* True when `t` (a `resolveTriggerSurface`-reported shadowed trigger) is a
* REAL cross-scope shadow: its stem is present in the winner's OWN scope
* `stems` and, independently, in the shadowed side's OWN scope `stems`. See
* the module-level "Per-scope truth filter" comment for why this check
* exists and why it lives here rather than in the resolver.
*/
function isGenuinelyShadowed(
t: TriggerSurface,
scopeRecords: Map<InstallScope, InstalledScopeRecord>,
prefixFor: (scope: InstallScope, kind: string) => string | null,
): boolean {
if (t.shadowedBy === null) return false;
const winnerRecord = scopeRecords.get(t.shadowedBy.scope);
const shadowedRecord = scopeRecords.get(t.scope);
const winnerStem = stemFromTrigger(t.trigger, prefixFor(t.shadowedBy.scope, t.shadowedBy.kind));
const shadowedStem = stemFromTrigger(t.trigger, prefixFor(t.scope, t.kind));
if (winnerStem === null || shadowedStem === null) return false;
return (winnerRecord?.stems ?? []).includes(winnerStem) && (shadowedRecord?.stems ?? []).includes(shadowedStem);
}
// ── Report builder ──────────────────────────────────────────────────────
/**
@@ -171,7 +306,15 @@ export function buildShadowReport(runtime: string, opts: ResolveInstalledSurface
// always returns exactly one element (see its own doc comment).
const surface = surfaces[0];
const shadowedSurfaces = surface.triggers.filter((t) => t.shadowedBy !== null);
// Per-scope truth filter (see module comment): `surface.triggers` may
// contain candidates synthesized from the CROSS-SCOPE stem union
// (`installed-surface-resolver.cts`'s `stemUnion`) that do not correspond
// to a real artifact at one or both scopes. Only a trigger whose stem is
// provably present in BOTH the winner's own `stems` and the shadowed
// side's own `stems` is reported.
const scopeRecords = new Map<InstallScope, InstalledScopeRecord>(surface.scopes.map((r) => [r.scope, r] as const));
const prefixFor = buildPrefixLookup(runtime, scopeRecords, opts);
const shadowedSurfaces = surface.triggers.filter((t) => isGenuinelyShadowed(t, scopeRecords, prefixFor));
const triggers: ShadowedTrigger[] = shadowedSurfaces
.map((t) => ({
trigger: t.trigger,

View File

@@ -246,7 +246,14 @@ function normalizeManifestVersion(raw: unknown): number {
function readInstallManifest(configDir: string): InstallManifest {
const manifest = readJsonIfPresent(path.join(configDir, MANIFEST_NAME), null);
if (!manifest || typeof manifest !== 'object') {
// `typeof [] === 'object'` in JS, so a bare `typeof !== 'object'` guard lets
// a top-level JSON array (valid JSON, but not the manifest's documented
// object shape) fall through to the field reads below — `m.manifestVersion`
// reads `undefined` off an array, which `normalizeManifestVersion` then
// reports as `1` (a v1 manifest), misclassifying "not an object" as
// "installed". `Array.isArray` closes that gap explicitly rather than
// relying on the object-shape checks below to catch it incidentally.
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
return {
version: null,
timestamp: null,

View File

@@ -504,6 +504,88 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
return `${fm}\n${normalizedBody}`;
}
// #2873 (4b) — spec-root reachability. Matches ONLY a line that is a real
// `@~/.claude/gsd-core/workflows/<stem>.md` include: line-start `@`, exact
// spec-root shape, nothing else on the line. This is deliberately narrower
// than "any line mentioning gsd-core/workflows" so prose mentions and
// `references/`/`templates/`/`@.planning/...` includes are never touched
// (rows 24/25). CRLF-safe: an optional trailing `\r` is captured and
// preserved rather than dropped.
const WORKFLOW_SPEC_ROOT_INCLUDE_RE = /^@~\/\.claude\/gsd-core\/workflows\/([A-Za-z0-9._-]+)\.md[ \t]*(\r?)$/gm;
// Matches a fenced code-block delimiter line (``` or ~~~, any info string)
// so occurrences of the include shape used as *documentation* inside a fence
// are left untouched — Claude Code documents backticks as the way to
// *prevent* an `@`-import, so rewriting a fenced example would corrupt
// documentation-of-the-syntax.
const FENCE_DELIMITER_RE = /^(```|~~~)[^\r\n]*$/gm;
/**
* Rewrite a static global-scope Claude skill `@`-include of the command's own
* workflow spec into an imperative two-step resolution the agent performs at
* runtime: prefer the project-local spec (cwd-relative), fall back to the
* global spec, and treat "neither exists" as a visible failure rather than a
* silent no-spec proceed.
*
* WHY this can't stay a static `@`-include (even a relative one): Claude Code
* documents relative `@`-paths as resolving against the file *containing* the
* import, which for a global skill is `~/.claude/skills/gsd-<stem>/` — not
* the project's working directory. `@./.claude/...` would therefore always
* resolve inside the skill's own install directory, never the project, so
* there is no static include syntax that can express "prefer local, fall
* back to global". This function exists precisely so that resolution can be
* performed by the agent, not the host's pre-expansion.
*
* Scope-free by design: this function does not know or care whether it is
* being applied to a global or local artifact, or which runtime — that
* judgment belongs to the caller (`skillsKind` in
* `runtime-artifact-layout.cts`, the one site that knows install scope).
* Applying it to a body with no workflow include is a no-op (row 26); a body
* with two independent workflow includes has each rewritten independently
* (row 27); an include inside a fenced code block or wrapped in inline
* backticks is left untouched (the backtick case is already excluded by the
* line-start anchor, since a backtick-wrapped line does not begin with `@`).
* Idempotent: the replacement text never begins with `@` and never matches
* `WORKFLOW_SPEC_ROOT_INCLUDE_RE`, so re-applying this function to its own
* output is a no-op.
*/
function resolveSpecRootReference(body) {
if (typeof body !== 'string' || body.length === 0) return body;
if (!body.includes('@~/.claude/gsd-core/workflows/')) return body;
// Collect [start, end) offset ranges covered by fenced code blocks so
// matches inside them are skipped. An unterminated trailing fence covers
// to the end of the string (still "inside a fence").
const fenceRanges = [];
{
let m;
let openStart = null;
FENCE_DELIMITER_RE.lastIndex = 0;
while ((m = FENCE_DELIMITER_RE.exec(body)) !== null) {
if (openStart === null) {
openStart = m.index;
} else {
fenceRanges.push([openStart, m.index + m[0].length]);
openStart = null;
}
}
if (openStart !== null) fenceRanges.push([openStart, body.length]);
}
const isInsideFence = (offset) => fenceRanges.some(([start, end]) => offset >= start && offset < end);
return body.replace(WORKFLOW_SPEC_ROOT_INCLUDE_RE, (match, stem, cr, offset) => {
if (isInsideFence(offset)) return match;
return (
`To load this command's workflow spec: check for ` +
`\`.claude/gsd-core/workflows/${stem}.md\` relative to the current working ` +
`directory first (project-local); if it is not there, fall back to ` +
`\`~/.claude/gsd-core/workflows/${stem}.md\` (the global install). If ` +
`neither file exists, stop — a workflow spec is required and none was found.` +
cr
);
});
}
function normalizeKimiSkillName(skillName) {
let text = String(skillName || '').trim().toLowerCase();
if (text.startsWith('/')) text = text.slice(1);
@@ -2995,6 +3077,38 @@ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathP
return tempDir;
}
/**
* #2873 (4b) — second pass over a staged skills directory, run strictly AFTER
* `applyRuntimeContentRewritesInPlace`. That pass's `case 'claude':` branch
* unconditionally rewrites any bare (non-`@`-prefixed) `~/.claude/` substring
* in the body to the computed pathPrefix (`$HOME/.claude/` for a global
* install) and restores ONLY the `@`-prefixed form back to `~`
* (`@$HOME/.claude/` → `@~/.claude/`). `resolveSpecRootReference`'s
* replacement text is deliberately imperative prose containing a literal,
* non-`@`-prefixed `~/.claude/gsd-core/workflows/<stem>.md` — running it
* BEFORE the pass above would let that literal tilde text get silently
* mangled into the undocumented `$HOME/` form the design explicitly rejects.
* Running it here, after, means it only ever sees the FINAL
* `@~/.claude/gsd-core/workflows/<stem>.md` include line (which survives the
* pass above intact via its own `@`-guarded restore).
*/
function applySpecRootReferenceToStagedSkills(stagedDir) {
if (!fs.existsSync(stagedDir)) return;
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else if (entry.name === 'SKILL.md') {
const content = fs.readFileSync(fullPath, 'utf8');
const rewritten = resolveSpecRootReference(content);
if (rewritten !== content) fs.writeFileSync(fullPath, rewritten);
}
}
};
walk(stagedDir);
}
/**
* HIGH-LEVEL: In-place fs walk: rewrite all .md files under stagedDir for the given runtime.
*
@@ -3032,6 +3146,17 @@ function rewriteStagedSkillBodies(stagedDir, opts) {
const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined;
applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix, isGlobal, attribution);
// #2873 (4b): claude, global scope only — see
// applySpecRootReferenceToStagedSkills's doc comment for why this MUST run
// after the rewrite pass above, not before. `rewriteStagedSkillBodies` is
// the skills-kind seam (`kind.kind === 'skills'`), so this never touches a
// 'commands' or 'agents' kind body (rows 24/25 unaffected), and claude has
// no skills-kind entry at local scope, so this is already structurally
// scoped to global (row 23) — the explicit isGlobal check is defense-in-depth
// against that descriptor wiring ever changing.
if (runtime === 'claude' && isGlobal) {
applySpecRootReferenceToStagedSkills(stagedDir);
}
}
/**
@@ -3176,6 +3301,11 @@ export = {
convertClaudeToAntigravityContent,
convertClaudeCommandToAntigravitySkill,
convertClaudeCommandToClaudeSkill,
// #2873 (4b): pure, scope-free transform — applied by the one call site
// that knows install scope (skillsKind's stage() in
// runtime-artifact-layout.cts), never inside convertClaudeCommandToClaudeSkill
// itself.
resolveSpecRootReference,
convertClaudeCommandToKimiSkill,
convertClaudeCommandToKimiCodeSkill,
buildKimiAgentArtifacts,

View File

@@ -395,6 +395,16 @@ function skillsKind(
// undefined here); `isGlobalScope` projects it to the boolean
// `realConverter`'s positional `isGlobal` arg requires.
const isGlobal = isGlobalScope(scope);
// #2873 (4b): spec-root reachability is applied LATER in the pipeline —
// see `rewriteStagedSkillBodies` in runtime-artifact-conversion.cts, not
// here. This stage() closure runs BEFORE the staged directory's generic
// path-prefix rewrite pass (`applyRuntimeContentRewritesInPlace`'s
// `case 'claude'`), which unconditionally rewrites any bare (non-`@`)
// `~/.claude/` substring to the undocumented `$HOME/.claude/` form and
// only restores the `@`-prefixed form. Emitting the imperative
// tilde-path prose here would get silently mangled by that later pass;
// it must run AFTER it instead, once the `@`-include is in its final
// rewritten shape.
const wrappedConverter = (content: string, skillName: string): string =>
realConverter(content, skillName, runtime, cmdNames, isGlobal);
return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, wrappedConverter, prefix, nested, capabilityRegistry);

View File

@@ -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)', () => {

View File

@@ -1,163 +0,0 @@
'use strict';
/**
* install-cross-scope-shadowing.test.cjs — failing-first regression suite for
* issue #2218 ("cross-scope shadowing"), phase issue #2873 (epic #2866,
* ADR-2866).
*
* 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 local install exists), 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 suite does NOT implement any production code. It is deliberately
* failing against the current tree.
*/
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 os = require('node:os');
const { 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,
} = require('./helpers/install-shared.cjs');
/**
* 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: today the global SKILL.md's spec-root include is a
// static `@~/.claude/gsd-core/workflows/plan-phase.md` reference, which
// always resolves against the GLOBAL tree even when a coexisting local
// install has its own project-local copy of that workflow file. Phase 4b
// 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 — this test pins today's (broken) behavior as the RED case that
// 4b must flip.
test('the winning global skill points at the project-local spec tree', () => {
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)}`,
);
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');
});
});

View File

@@ -19,13 +19,21 @@
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,
} = require('./helpers/install-shared.cjs');
const {
installRuntimeArtifacts,
@@ -6253,3 +6261,183 @@ 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)}`,
);
});
});

View File

@@ -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)', () => {

View File

@@ -0,0 +1,330 @@
'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) }));
}
// ─── 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‮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);
});
});

View File

@@ -0,0 +1,613 @@
'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,
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 },
);
});
});
// ─── 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}`,
);
const plainTextArb = fc.string({ maxLength: 40 });
const chunkArb = fc.oneof(
includeLineArb,
proseMentionArb,
planningIncludeArb,
fencedIncludeArb,
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 },
);
});
});