diff --git a/.changeset/zesty-moles-tumble.md b/.changeset/zesty-moles-tumble.md new file mode 100644 index 000000000..ab30ec7ec --- /dev/null +++ b/.changeset/zesty-moles-tumble.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3537 +--- +**Installing GSD for Claude at both global and local scope no longer silently hides your project's specs.** Claude Code always resolves the personal skill over the project command, so a project with a local install previously ran the global workflow specs with no warning. The install now prints which scope wins and `/gsd-health` surfaces the same as diagnostic W028; at global scope, the winning skill's workflow reference now resolves your project's own specs first when present. (#2218) diff --git a/.gitignore b/.gitignore index c35e537e0..3fff33e45 100644 --- a/.gitignore +++ b/.gitignore @@ -213,11 +213,13 @@ 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 /gsd-core/bin/lib/install-scope.cjs /gsd-core/bin/lib/installed-surface-resolver.cjs +/gsd-core/bin/lib/install-shadow-report.cjs /gsd-core/bin/lib/runtime-config-adapter-registry.cjs /gsd-core/bin/lib/runtime-hooks-surface.cjs /gsd-core/bin/lib/command-routing-hub.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 5641adb4b..be863c8ac 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -264,7 +264,10 @@ Module owning install-time staging and content-rewrite selection for a pre-resol Owns the two-value install-scope axis (`'global' | 'local'`) as a typed value, replacing the bare `isGlobal ? 'global' : 'local'` string re-derived at 12 sites in `bin/install.js` plus several downstream re-derivations (#2870, ADR-2866). Interface: `resolveScope({ id, runtime, explicitDir?, env?, home?, existsSync? }) -> { id, configHome, settingsFile, consentRequired, hostPrecedenceRank }` — pure (no writes, never mutates `input`) and the returned value is frozen so a caller cannot corrupt a subsequent resolution. Owns the `InstallScope` type name: previously a private, non-exported `TypeAlias` inside Runtime Artifact Install Plan Module; that module now `import type`s it from here instead of re-declaring it, so the codebase does not grow a fifth spelling of the axis alongside the layout module's `'local' | 'global'`, `capability-lifecycle.cts`'s `'global' | 'project'`, and `capability-consent.cts`'s single `'project'` literal. `configHome` for `global` composes `resolveConfigHomeFromDescriptor` (Runtime Homes Module) unmodified rather than adding a `scope` parameter to it — that function is CRITICAL blast radius (60 dependents across 13 files); for `local` it joins the capability registry's per-runtime `localConfigDir` onto the real process cwd (the project you are standing in — not injectable via `home`, by design). `explicitDir` short-circuits both scopes identically to `getGlobalConfigDir`'s existing override, and every returned `configHome` is normalized to forward slashes UNCONDITIONALLY (`.replace(/\\/g,'/')`, never gated on `path.sep`). `settingsFile` reads the registry's `hostBehaviors.settingsFileByScope[id]` and is `null` for the 18 of 19 registered runtimes that declare none — absence is a value, not an invented Claude-shaped default; the one caller that legitimately wants a Claude fallback (`bin/install.js:550`) still applies it itself. `consentRequired` is `false` for `global` (nothing is recorded — matches Capability Registry Overlay's rule that a GLOBAL-scope capability is trusted without a consent record) and `true` for `local`; it reports the requirement only; it does not perform or waive consent. `hostPrecedenceRank` (`global` outranks `local`) is carried as data only this phase — unread until Phase 2 (#2871) defines precedence semantics. Throws `TypeError` for an invalid `id` (wrong case, empty, missing, or any non-string value — never coerced), an unknown `runtime`, or a runtime whose `configHome.kind === 'none'` (vscode — non-installable, #2103) — all three share one `instanceof TypeError` catch shape with Runtime Artifact Layout Module's existing unknown-runtime contract. **The `local`/`project` boundary is documented, not unified:** this module's `'local'` spelling — chosen because it is the CLI's own vocabulary (`--local`) and what the layout module and manifest already use — is deliberately NOT reconciled with Capability Consent Store's `ConsentRecord.scope: 'project'` or Capability Lifecycle's `'global' | 'project'` operations. `ConsentRecord.scope` is a value persisted on disk in user-owned consent records outside any repository; renaming that literal to match would silently invalidate every existing project-scoped consent record on a user's machine the next time it is read back — a far worse defect than the vocabulary split. The mapping instead lives here as a fact: install scope `'local'` ⇄ consent scope `'project'`; install scope `'global'` ⇄ no consent record at all. Source: `gsd-core/bin/lib/install-scope.cjs` (generated from `src/install-scope.cts`). See Runtime Homes Module, Runtime Artifact Layout Module, Runtime Artifact Install Plan Module, Capability Consent Store, Capability Lifecycle. ### Installed Surface Resolver Module -Read-only Module answering *"which GSD surfaces are installed, at which scopes, for which runtimes — and is one shadowing another?"* (#2872, ADR-2866 Phase 3). Interface: `resolveInstalledSurfaces(runtime?, { home?, cwd?, env?, existsSync?, registry?, readManifest? }) -> InstalledRuntimeSurface[]`, each carrying both scope records (`{ scope, configHome, installed, manifestVersion, declaredRuntime, declaredScope, declaredScopeMatchesProbe, declaredRuntimeMatchesProbe, stems }`) plus a `triggers: TriggerSurface[]` resolved in ONE `resolveTriggerSurface` call over **only the scopes actually installed** — which is what makes `shadowedBy` describe this machine rather than a hypothetical. **The first code path in the repo that reads both install scopes at once.** Mutates nothing; builds fresh objects per call; caches nothing. It composes rather than re-derives: `resolveScope` (Install Scope Module) supplies each scope's `configHome`, `readInstallManifest` (Installer Migration Module) supplies presence, and `resolveTriggerSurface` + the exported `isNamespacedByDir`/`composeCommandFilename` (Runtime Artifact Layout Module) supply the trigger surface — the stem derivation is the *inverse* of that filename composition and consumes those exports rather than becoming a fourth copy of the rule (`fast-check` round-trip property guards the bijection). **Installed-ness is decided by manifest PRESENCE, never by the schema-2 `runtime`/`scope` fields**, which is what keeps a pre-#2872 (schema-1) install fully functional with no reinstall; the recorded fields are corroboration, surfaced as `declaredScopeMatchesProbe` / `declaredRuntimeMatchesProbe` so a manifest that disagrees with the directory it was found in (an `--config-dir` install, a copied config tree) is **reported, never silently corrected**. A runtime whose `resolveScope` throws (unknown, or `configHome.kind === 'none'` — vscode) propagates the `TypeError` when asked for explicitly but is *skipped* in the all-runtimes sweep, so one non-installable runtime cannot kill the sweep. Two scopes resolving to one `configHome` are deduplicated before the trigger call, so a single physical install is never reported as shadowing itself. Ships unread this phase — Phase 4 (#2873) is its first consumer, and is where detection becomes user-visible output. Source: `gsd-core/bin/lib/installed-surface-resolver.cjs` (generated from `src/installed-surface-resolver.cts`). See Install Scope Module, Runtime Artifact Layout Module, Installer Migration Module, Agent Install Check Module. +Read-only Module answering *"which GSD surfaces are installed, at which scopes, for which runtimes — and is one shadowing another?"* (#2872, ADR-2866 Phase 3). Interface: `resolveInstalledSurfaces(runtime?, { home?, cwd?, env?, existsSync?, registry?, readManifest? }) -> InstalledRuntimeSurface[]`, each carrying both scope records (`{ scope, configHome, installed, manifestVersion, declaredRuntime, declaredScope, declaredScopeMatchesProbe, declaredRuntimeMatchesProbe, stems }`) plus a `triggers: TriggerSurface[]` resolved in ONE `resolveTriggerSurface` call over **only the scopes actually installed** — which is what makes `shadowedBy` describe this machine rather than a hypothetical. **The first code path in the repo that reads both install scopes at once.** Mutates nothing; builds fresh objects per call; caches nothing. It composes rather than re-derives: `resolveScope` (Install Scope Module) supplies each scope's `configHome`, `readInstallManifest` (Installer Migration Module) supplies presence, and `resolveTriggerSurface` + the exported `isNamespacedByDir`/`composeCommandFilename` (Runtime Artifact Layout Module) supply the trigger surface — the stem derivation is the *inverse* of that filename composition and consumes those exports rather than becoming a fourth copy of the rule (`fast-check` round-trip property guards the bijection). **Installed-ness is decided by manifest PRESENCE, never by the schema-2 `runtime`/`scope` fields**, which is what keeps a pre-#2872 (schema-1) install fully functional with no reinstall; the recorded fields are corroboration, surfaced as `declaredScopeMatchesProbe` / `declaredRuntimeMatchesProbe` so a manifest that disagrees with the directory it was found in (an `--config-dir` install, a copied config tree) is **reported, never silently corrected**. A runtime whose `resolveScope` throws (unknown, or `configHome.kind === 'none'` — vscode) propagates the `TypeError` when asked for explicitly but is *skipped* in the all-runtimes sweep, so one non-installable runtime cannot kill the sweep. Two scopes resolving to one `configHome` are deduplicated before the trigger call, so a single physical install is never reported as shadowing itself. **The `local`-scope manifest read is `lstat`-guarded (#2873)**: it refuses to follow a symlinked config dir or a symlinked manifest file, degrading that scope to `installed: false` — the same degraded verdict the pre-existing EACCES path already produces — rather than resolving a manifest outside the project the caller asked about; the `global` scope carries no such guard (it resolves against the real machine home, never an arbitrary cloned repository). Phase 4 (#2873, Install Shadow Report Module) is its first consumer, projecting `shadowedBy` into the install-time report and the `/gsd-health` W028 diagnostic. Source: `gsd-core/bin/lib/installed-surface-resolver.cjs` (generated from `src/installed-surface-resolver.cts`). See Install Scope Module, Runtime Artifact Layout Module, Installer Migration Module, Agent Install Check Module, Install Shadow Report Module. + +### Install Shadow Report Module +Read-only projection Module answering *"is a cross-scope trigger collision hiding a spec tree from the user right now, and if so, which scope wins?"* (#2873, epic #2866 Phase 4, resolving #2218). Composes Installed Surface Resolver Module's `resolveInstalledSurfaces` rather than re-deriving install state — the design doc's "Rejected" list explicitly refuses to widen that module with a second export, keeping rendering/sanitization a separate concern with a separate consumer set (installer + `/gsd-health`). Interface: `buildShadowReport(runtime, { home?, cwd?, env?, existsSync?, registry?, readManifest? }) -> ShadowReport` (a typed IR, never rendered text — tests assert the IR, per CONTRIBUTING's ban on raw-text-matching test outputs) plus `renderShadowReport(report) -> string[]`, the sole renderer consumed identically by the installer and the W028 health rule so install-time output and `/gsd-health` output can never drift. **Per-scope truth filter**: `resolveTriggerSurface` (Runtime Artifact Layout Module) synthesizes a candidate trigger for every stem at every installed scope's trigger-bearing kind, regardless of whether that specific scope's own manifest shipped that stem — left unfiltered this would over-report triggers as shadowed when no artifact exists at one side. This Module filters `shadowedBy` groups down to triggers whose underlying stem is present in BOTH scopes' own real `stems` list before reporting. **`kindsDiffer` distinguishes vanish from override**: for claude (`skills`@global vs `commands`@local) the kinds differ, so the losing side's entire spec tree becomes unreachable through the trigger (#2218's exact failure); for the 12 both-scopes-`skills` runtimes the kinds are the same on both sides, so the loser is merely overridden, not vanished — `renderShadowReport`'s wording is gated on this flag so it never claims a same-kind local tree "disappears". **Report, don't correct**: a declared runtime/scope that disagrees with the probed one is surfaced via `mismatches`, never silently absorbed or substituted (Postel's Law, liberal-but-visible). **Sanitized at the render seam**: `declaredRuntime` is attacker-influenceable (sourced from a manifest that may live inside a merely-cloned repository) and is length-bounded (64) but deliberately not charset-gated at the reader (`declaredRuntimeMatchesProbe` needs the raw value); `sanitizeForRender` strips ANSI escapes, C0/C1 controls, and Unicode bidi overrides/isolates before render, and never truncates a second time. **Degrades to `reason: RESOLVER_UNAVAILABLE`** (renders `[]`) for a runtime whose `configHome.kind === 'none'` (vscode) rather than propagating the resolver's `TypeError` — a shadow report must never crash an install or `/gsd-health`. Consumers: the installer (prints the report after `writeManifest`, exit code unchanged — a shadowed install is a warning per ADR-2866 Consequences, never a failure) and `health-diagnostic-rules/install-surface-shadowing.cts` (W028, WARNING severity, ADVISE remedy with `risk: NONE` — never auto-fixable, there is no single correct scope to remove). Source of truth: `gsd-core/bin/lib/install-shadow-report.cjs` (generated from `src/install-shadow-report.cts`). See Installed Surface Resolver Module, Runtime Artifact Layout Module, Runtime Artifact Conversion Module (spec-root reachability, the sibling deliverable in the same phase). ### Command Roster Module Tiny read-only helper Module owning discovery of canonical `commands/gsd/*.md` command stems for artifact conversion and runtime projection. It is a sibling dependency of Runtime Artifact Conversion Module, not part of conversion itself: conversion consumes a roster to safely rewrite `gsd:` / `/gsd-` references, while roster discovery owns filesystem/catalog knowledge. First slice: extract existing `readGsdCommandNames` behavior behind this Module instead of moving it into Runtime Artifact Conversion Module or keeping it as installer-owned state. diff --git a/bin/install.js b/bin/install.js index c6dff753c..7d743e38d 100755 --- a/bin/install.js +++ b/bin/install.js @@ -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 @@ -11673,6 +11677,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 = []; diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 98be6f94f..9eac932d4 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -1014,6 +1014,8 @@ rather than that its contents are correct. The advisory never changes health's pass/fail status, and stays silent when the stamp is absent or the project isn't a git repo — "unknown" is reported as unknown, not as fresh. +**Cross-scope install shadowing (`W028`).** When a runtime is installed at both `global` and `local` scope and the host's trigger-resolution rules make one scope's `/gsd-*` surface unreachable — the Claude Code case: personal skill always beats project command — health adds a WARNING-severity advisory naming the shadowed triggers, the winning scope, and the losing scope. It never changes health's pass/fail status and is never auto-fixable (there is no single correct scope to remove), so `--repair` never touches it. Identical to the same advisory GSD Core prints at install time. See [Interpret install-shadow warnings](how-to/interpret-install-shadow-warnings.md). + **`--repair` does not apply destructive fixes.** Resetting config.json (`resetConfig`) and regenerating STATE.md (`regenerateState`) are destructive — the former loses custom settings, the latter loses session history — so diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 953f8ef5a..ec8ffc0da 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -381,6 +381,7 @@ "health-diagnostic-rules/agent-install.cjs", "health-diagnostic-rules/config-validation.cjs", "health-diagnostic-rules/consistency.cjs", + "health-diagnostic-rules/install-surface-shadowing.cjs", "health-diagnostic-rules/milestone-archive-hygiene.cjs", "health-diagnostic-rules/phase-structure.cjs", "health-diagnostic-rules/roadmap-disk-consistency.cjs", @@ -401,6 +402,7 @@ "install-engine.cjs", "install-profiles.cjs", "install-scope.cjs", + "install-shadow-report.cjs", "installed-surface-resolver.cjs", "installer-migration-authoring.cjs", "installer-migration-report.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 1252b18da..16c9e25f7 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -519,7 +519,9 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `install-engine.cjs` | Runtime-artifact install engine — `installRuntimeArtifacts`/`uninstallRuntimeArtifacts`/`installOpencodeFamilySkills` + their helpers, extracted from `bin/install.js` (ADR-1239 Phase B, #1679); install.js imports them back and injects `getCommitAttribution` | | `install-profiles.cjs` | Install profile allowlist + skill staging for `--minimal` install (#2762); single source of truth for which `gsd-*` skills/agents land in runtime config dirs | | `install-scope.cjs` | Install Scope Module — `resolveScope({id,runtime,...})` resolves the `'global'\|'local'` install-scope axis into `{id, configHome, settingsFile, consentRequired, hostPrecedenceRank}`, composing `resolveConfigHomeFromDescriptor` (`runtime-homes.cjs`) rather than modifying it (#2870, ADR-2866) | -| `installed-surface-resolver.cjs` | Installed Surface Resolver — `resolveInstalledSurfaces(runtime?, opts?)` probes both install scopes for a runtime (or every registered runtime, sorted, when omitted), reads each scope's manifest, and resolves the trigger surface across only the scopes actually installed on this machine, composing `resolveScope` and `resolveTriggerSurface` rather than re-deriving either (#2872, ADR-2866 Phase 3) | +| `install-shadow-report.cjs` | Cross-Scope Shadow Report Module (#2873, epic #2866 Phase 4a) — read-only projection over `installed-surface-resolver.cjs`'s `resolveInstalledSurfaces`; `buildShadowReport(runtime, opts)` filters `resolveTriggerSurface`'s `shadowedBy` groups down to triggers whose underlying stem genuinely exists in BOTH scopes' own manifests (not merely the union), and `renderShadowReport` projects the typed IR into bounded, sanitized (`sanitizeForRender` strips ANSI/C0-C1/bidi overrides) operator-console lines; consumed by both the installer and the W028 health rule so install-time and `/gsd-health` report identically | +| `health-diagnostic-rules/install-surface-shadowing.cjs` | Health-diagnostic rule: cross-scope trigger shadowing check (W028) — projects `install-shadow-report.cjs`'s `ShadowReport` as a WARNING-severity, ADVISE/`risk:NONE` diagnostic (never auto-fixable), reusing `agent-install.cjs`'s W010 runtime-resolution shape; degrades to `[]` (never throws) when nothing is installable to report (#2873, epic #2866 Phase 4a) | +| `installed-surface-resolver.cjs` | Installed Surface Resolver — `resolveInstalledSurfaces(runtime?, opts?)` probes both install scopes for a runtime (or every registered runtime, sorted, when omitted), reads each scope's manifest, and resolves the trigger surface across only the scopes actually installed on this machine, composing `resolveScope` and `resolveTriggerSurface` rather than re-deriving either (#2872, ADR-2866 Phase 3); local-scope manifest read is `lstat`-guarded and refuses to follow a symlinked config dir or manifest, degrading that scope to `installed: false` rather than following (#2873) | | `installer-migration-authoring.cjs` | Installer migration authoring guardrails for record metadata, explicit scopes, ownership evidence, and runtime contract citations | | `installer-migration-report.cjs` | Installer migration report projection and blocked-action guard for install/update integration | | `installer-migrations.cjs` | Installer migration planning, artifact classification, install-state persistence, journaled apply, and rollback helpers | diff --git a/docs/README.md b/docs/README.md index 8344bcffa..58a188bbd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,6 +37,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md) - [Isolate work with workspaces](how-to/isolate-work-with-workspaces.md) — use workspaces to sandbox experimental or risky changes - [Debug a failed execution](how-to/debug-a-failed-execution.md) — diagnose and recover from broken or incomplete phase execution - [Interpret scope-conformance warnings](how-to/interpret-scope-conformance-warnings.md) — read the advisory the worktree-wave merge emits when a plan branch commits outside its declared scope +- [Interpret install-shadow warnings](how-to/interpret-install-shadow-warnings.md) — read the advisory GSD Core emits when a `/gsd-*` trigger is installed at both scopes and one silently wins, and tell "nothing to report" apart from "could not look" - [Interpret `state validate` results](how-to/interpret-state-validate-results.md) — read the `scope` reason codes and tell "nothing to report" apart from "could not look" - [Spike and sketch](how-to/spike-and-sketch.md) — use `/gsd-spike` and `/gsd-sketch` for exploratory work before committing to a plan - [Design a UI phase](how-to/design-a-ui-phase.md) — use the UI phase loop for frontend and visual work diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 537865819..1d6a42b11 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -36,6 +36,8 @@ npx @opengsd/gsd-core@latest --claude --global Skills land in `~/.claude/`. Commands appear as `/gsd-*` slash commands in your next Claude Code session. Restart Claude Code to pick them up. +**Installing at both `--global` and `--local`.** This is a supported configuration (different projects sometimes need different customizations), but Claude Code's own trigger-resolution rules — personal scope overrides project scope, and a skill overrides a same-named command — both point the same direction: the global skill always wins the `/gsd-` trigger over the local command. GSD Core detects this and prints which scope is winning right after install completes (and surfaces the identical fact from `/gsd-health` as diagnostic `W028`); it is an advisory, not a failure — the install itself still succeeds. At **global** scope, the winning skill's workflow-spec reference resolves at runtime against your working directory first, so a project with its own `.claude/gsd-core/` still gets its own specs even though the global skill is what Claude Code invokes — see [Interpret install-shadow warnings](interpret-install-shadow-warnings.md) for what the warning means, how to read which scope wins, and the limits of that resolution (it does not extend to the skill's `references/`/`templates/` includes). + **Override the install directory:** ```bash diff --git a/docs/how-to/interpret-install-shadow-warnings.md b/docs/how-to/interpret-install-shadow-warnings.md new file mode 100644 index 000000000..54cf51304 --- /dev/null +++ b/docs/how-to/interpret-install-shadow-warnings.md @@ -0,0 +1,93 @@ +# How to interpret install-shadow warnings + +**Goal:** Understand the advisory GSD Core prints when a `/gsd-*` trigger is installed at more than one scope and one scope silently wins, tell which scope actually wins, know what to do about it, and tell "nothing to report" apart from "could not look". + +**Prerequisites:** GSD Core installed on a Claude Code (or other skills-capable) runtime at more than one scope on the same machine — most commonly `--claude --global` (writes personal skills to `~/.claude/`) followed or preceded by `--claude --local` in a project (writes project commands to `/.claude/`). + +--- + +## What the warning means + +Claude Code resolves a `/gsd-` trigger through two rules, both documented by the host: **personal scope overrides project scope**, and **a skill overrides a command of the same name**. GSD's own Claude artifact layout installs global (personal) as **skills** and local (project) as **commands** — so when both scopes are installed, both rules point the same way, and the global skill wins every time. The project's own `.claude/gsd-core/` spec tree — the workflow and reference files the local command correctly points at — becomes unreachable through the trigger, with no error and no missing file. Nothing in the local install is broken; it is simply never invoked (issue #2218). + +The warning is GSD Core's fix for the "with no error" half of that sentence — it does not change which scope wins (see [Known limits](#known-limits)). You will see it in two places, projecting the identical fact: + +- **At install time**, printed once after the manifest write completes, whichever scope you install second (design: shadowing can only exist once both scopes exist, and the report is always downstream of a successful `writeManifest`, never upstream of a failed one). +- **From `/gsd-health`**, as diagnostic code **`W028`**, severity `WARNING` — see [Health diagnostic codes](../COMMANDS.md#gsd-health) for where this fits alongside the rest of the `Wnnn`/`Ennn`/`Innn` space. `--json` health output carries the same structured fact. + +A realistic install-time warning: + +``` +2 triggers shadowed: the local commands surface is unreachable through those triggers — global skills wins instead. + - gsd-execute-phase: local/commands shadowed by global/skills + - gsd-plan-phase: local/commands shadowed by global/skills +``` + +For a runtime whose global scope also installs **skills** (12 of the 19 supported runtimes install `skills` at both scopes), the wording is deliberately different, because nothing disappears — the loser is merely overridden, not orphaned: + +``` +1 trigger shadowed: the local skills entry is overridden by global skills. +``` + +A report with more than 5 shadowed triggers shows the first 5 and a `...and N more` line rather than every one — the same bounded-sample shape as the installer's existing leaked-path warning. + +--- + +## How to tell which scope is winning + +Read the first line of the report. It always names the losing side first (`the surface/entry`) and the winning side last (` wins instead` / `overridden by `). Each subsequent bullet repeats the same fact per trigger: `: / shadowed by /`. + +On Claude Code specifically, the winner is always `global/skills` when both scopes are installed — that is the mechanism described above, not a per-machine coin flip. If you need to confirm this for yourself independent of the warning: run `/gsd-` and check which "Base directory for this skill" (or equivalent skill-injection marker) the host reports. + +An optional `Note:` line after the trigger list means one scope's own manifest disagrees with the directory it was actually found in (for example, a manifest copied between machines, or an `--config-dir` install). This is surfaced, never silently absorbed — see the trailing `declared runtime "…" does not match this runtime` / `declared scope "…" does not match the probed scope` text. + +--- + +## What to do about it + +The warning never changes the install's exit code — a shadowed install is a warning, not a failure, and the install itself is not broken. Your options, in order of how much they cost: + +1. **Do nothing, if the global spec tree is what you want everywhere.** This is a legitimate configuration. +2. **On Claude Code, rely on the built-in resolution described in [Known limits](#known-limits)** — at global scope, the workflow spec `@`-include the winning skill carries is resolved by an explicit, imperative two-step lookup that prefers your project's own `.claude/gsd-core/workflows/.md` over the global one, so the project you are standing in gets its own specs even though the global skill is what's invoked. This is automatic; there is nothing to configure. It applies only to the workflow-spec include — see the limits below for what it does *not* cover. +3. **Uninstall one scope.** If you never intend to use per-project GSD customizations, uninstalling the local scope removes the ambiguity entirely. If you always want project-local behavior, uninstalling the global scope does the same from the other direction. +4. **On a non-Claude, both-scopes-`skills` runtime**, the loser is overridden rather than orphaned, so reordering which install ran last (reinstalling the scope you want to win) resolves it directly — there is no separate spec-tree-reachability problem to reason about on those runtimes. + +--- + +## When nothing is reported: "nothing to report" vs "could not look" + +Absence of a shadow warning is not always proof that nothing is shadowed. GSD Core distinguishes two silent outcomes: + +**Nothing to report (verified clean)** — the check ran and found no genuine cross-scope collision: + +- Only one scope is installed for that runtime. +- Both scopes resolve to the *same* config directory (for example, `--config-dir` pointed both installs at the same place) — this is one physical install, not two. +- The runtime's global scope installs no trigger-bearing artifact kind at all (windsurf's global install is `agents`, which is never invoked by a `/gsd-` trigger) — there is nothing for the local scope to collide with. +- The other scope's manifest exists but declares zero files (an empty or partial install at that scope). + +**Could not look (degraded, not verified)** — the check itself could not run, and its silence carries no claim either way: + +- The runtime has no installable config directory to probe (`configHome.kind === 'none'`, currently only VS Code) — the resolver this check is built on cannot even ask the question, so it degrades to no report rather than crashing your install or your `/gsd-health` run. +- The other scope's manifest exists on disk but could not be read (a permissions error) — that scope is treated as not-installed for this check, exactly as if it were absent. +- The other scope's manifest is reached through a symlinked config directory or is itself a symlink — the check refuses to follow it and treats that scope as not-installed, the same degraded outcome as an unreadable manifest. + +If you are not sure which case applies, re-run `/gsd-health` and read the diagnostic list directly rather than inferring from silence — `/gsd-health --json`'s absence of a `W028` entry combined with a runtime you know is VS Code, or a manifest you know you cannot read, tells you it is the second case, not the first. + +--- + +## Known limits + +- **Detection is manifest-based.** If you deleted `gsd-file-manifest.json` at a scope but kept the installed artifacts, that scope is reported as not-installed and cannot shadow (or be shadowed by) anything. +- **The workflow-spec resolution (item 2 above) is instruction-following, not guaranteed inclusion.** A static `@`-include is pre-expanded by the host before the agent ever runs; this resolved reference instead costs the agent one file read, following an explicit instruction. That is a weaker guarantee than a real `@`-include — it is deliberately confined to one reference, in the Claude runtime, at global scope only, which is what keeps that weaker guarantee acceptable rather than something the whole install quietly depends on. +- **It covers only the workflow-spec include.** The winning global skill's `references/` and `templates/` includes still point at the global tree. If you customize only a *reference* file locally (not a workflow), the global copy of that reference is still what loads — you reach your local references only by way of entering the local workflow spec, whose own internal includes are already baked to local-absolute paths. +- **Non-Claude runtimes get detection only, never the spec-root resolution above.** The 12 both-scopes-`skills` runtimes have the same shadowing mechanic but identical artifact kinds on both sides, so no spec tree becomes unreachable the way it does for Claude Code. + +--- + +## Related + +- [Install on your runtime](install-on-your-runtime.md) — per-runtime install commands, including the Claude Code coexistence note +- [Host Integration Capability Matrix](../reference/host-integration-capability-matrix.md) — trigger precedence and per-runtime axis reference +- [Interpret scope-conformance warnings](interpret-scope-conformance-warnings.md) — the sibling advisory-warning guide for worktree-wave merges +- [`/gsd-health`](../COMMANDS.md#gsd-health) — command reference, including the diagnostic-code table this warning's `W028` belongs to +- [docs index](../README.md) diff --git a/docs/ja-JP/COMMANDS.md b/docs/ja-JP/COMMANDS.md index ff7abfc61..97d60ae6d 100644 --- a/docs/ja-JP/COMMANDS.md +++ b/docs/ja-JP/COMMANDS.md @@ -814,6 +814,8 @@ GSD の保証付きでアドホックタスクを実行します。 /gsd-health --context # コンテキスト使用率のトリアージ ``` +**スコープ間インストールのシャドーイング(`W028`)。** あるランタイムが `global` と `local` の両方のスコープにインストールされ、ホストのトリガー解決ルールによって一方のスコープの `/gsd-*` サーフェスが到達不能になっている場合——Claude Code のケース:個人スキルは常にプロジェクトコマンドより優先される——ヘルスチェックは、シャドーイングされたトリガー、勝者スコープ、敗者スコープを示す WARNING 重大度のアドバイザリを追加します。これはヘルスチェックの合否ステータスを変更することはなく、自動修正の対象にもなりません(削除すべき単一の正解スコープが存在しないため)。そのため `--repair` はこれに一切手を加えません。インストール時に GSD Core が表示するのと同一のアドバイザリです。 + ### `/gsd-cleanup` 完了したマイルストーンからの累積フェーズディレクトリをアーカイブし、アップストリームが削除されたローカルブランチを削除します。 diff --git a/docs/ja-JP/how-to/install-on-your-runtime.md b/docs/ja-JP/how-to/install-on-your-runtime.md index 5df24860f..f81d87181 100644 --- a/docs/ja-JP/how-to/install-on-your-runtime.md +++ b/docs/ja-JP/how-to/install-on-your-runtime.md @@ -36,6 +36,8 @@ npx @opengsd/gsd-core@latest --claude --global スキルは `~/.claude/` に配置されます。次回の Claude Code セッションからコマンドが `/gsd-*` スラッシュコマンドとして表示されます。反映するには Claude Code を再起動してください。 +**`--global` と `--local` の両方のスコープへのインストール。** これはサポートされている構成です(プロジェクトによって異なるカスタマイズが必要になることがあるため)が、Claude Code 自身のトリガー解決ルール——個人スコープがプロジェクトスコープより優先され、スキルは同名のコマンドより優先される——はどちらも同じ方向を指します。つまり、グローバルスキルは常にローカルコマンドより `/gsd-` トリガーで勝ちます。GSD Core はこれを検出し、インストール完了直後にどちらのスコープが勝っているかを表示します(同じ事実は `/gsd-health` の診断コード `W028` としても表示されます)。これは警告であり失敗ではありません——インストール自体は成功します。**グローバル**スコープでは、勝者となったスキルのワークフロー仕様への参照は実行時にまず作業ディレクトリを基準に解決されるため、Claude Code が実際に呼び出すのはグローバルスキルであっても、独自の `.claude/gsd-core/` を持つプロジェクトは自分自身の仕様を取得できます。 + **インストールディレクトリの上書き:** ```bash diff --git a/docs/ko-KR/COMMANDS.md b/docs/ko-KR/COMMANDS.md index c6eb4fd47..4b1352caf 100644 --- a/docs/ko-KR/COMMANDS.md +++ b/docs/ko-KR/COMMANDS.md @@ -820,6 +820,8 @@ GSD 보장을 통해 애드혹 작업을 실행합니다. /gsd-health --context # 컨텍스트 활용 트리아지 ``` +**범위 간 설치 섀도잉(`W028`).** 런타임이 `global`과 `local` 두 범위 모두에 설치되어 있고, 호스트의 트리거 해석 규칙으로 인해 한 범위의 `/gsd-*` 표면에 도달할 수 없게 되는 경우 — Claude Code의 사례: 개인 스킬이 항상 프로젝트 명령을 이깁니다 — 상태 점검은 섀도잉된 트리거, 승리한 범위, 패배한 범위를 명시하는 WARNING 심각도 권고를 추가합니다. 이는 상태 점검의 통과/실패 상태를 절대 변경하지 않으며, 자동으로 수정되지도 않습니다(제거해야 할 단 하나의 올바른 범위가 존재하지 않기 때문입니다). 따라서 `--repair`는 이를 절대 건드리지 않습니다. 설치 시점에 GSD Core가 출력하는 것과 동일한 권고입니다. + ### `/gsd-cleanup` 완료된 마일스톤에서 누적된 단계 디렉토리를 아카이브하고 업스트림이 삭제된 로컬 브랜치를 정리합니다. diff --git a/docs/ko-KR/how-to/install-on-your-runtime.md b/docs/ko-KR/how-to/install-on-your-runtime.md index f7503a9c6..1a59c5a93 100644 --- a/docs/ko-KR/how-to/install-on-your-runtime.md +++ b/docs/ko-KR/how-to/install-on-your-runtime.md @@ -36,6 +36,8 @@ npx @opengsd/gsd-core@latest --claude --global 스킬은 `~/.claude/`에 저장됩니다. 다음 Claude Code 세션에서 `/gsd-*` 슬래시 명령으로 명령이 나타납니다. Claude Code를 재시작하여 적용하세요. +**`--global`과 `--local` 두 범위 모두에 설치하는 경우.** 이는 지원되는 구성입니다(프로젝트마다 서로 다른 커스터마이징이 필요한 경우가 있기 때문입니다). 하지만 Claude Code 자체의 트리거 해석 규칙 — 개인 범위가 프로젝트 범위보다 우선하고, 스킬이 동일한 이름의 명령보다 우선함 — 은 모두 같은 방향을 가리킵니다: 전역 스킬이 항상 `/gsd-` 트리거에서 로컬 명령을 이깁니다. GSD Core는 이를 감지하여 설치 완료 직후 어떤 범위가 우선하는지 출력합니다(동일한 사실이 `/gsd-health`의 진단 코드 `W028`로도 표시됩니다). 이는 경고일 뿐 실패가 아닙니다 — 설치 자체는 계속 성공합니다. **전역** 범위에서는, 승리한 스킬의 워크플로 스펙 참조가 실행 시점에 작업 디렉터리를 기준으로 우선 해석되므로, Claude Code가 실제로 호출하는 것이 전역 스킬이더라도 자체 `.claude/gsd-core/`를 가진 프로젝트는 여전히 자신의 스펙을 얻습니다. + **설치 디렉터리 재정의:** ```bash diff --git a/docs/pt-BR/COMMANDS.md b/docs/pt-BR/COMMANDS.md index 8e21a52b6..8a61c2533 100644 --- a/docs/pt-BR/COMMANDS.md +++ b/docs/pt-BR/COMMANDS.md @@ -817,6 +817,8 @@ v1.40.0, [#2792](https://github.com/open-gsd/gsd-core/issues/2792)). /gsd-health --context # Triagem de utilização de contexto ``` +**Sombreamento de instalação entre escopos (`W028`).** Quando um runtime é instalado em ambos os escopos `global` e `local` e as regras de resolução de gatilhos do host tornam a superfície `/gsd-*` de um dos escopos inalcançável — o caso do Claude Code: a skill pessoal sempre vence o comando de projeto — a checagem de integridade adiciona um aviso de severidade WARNING nomeando os gatilhos sombreados, o escopo vencedor e o escopo perdedor. Isso nunca altera o status de aprovação/reprovação e nunca é corrigido automaticamente (não existe um único escopo correto a remover), então `--repair` nunca o toca. É idêntico ao mesmo aviso que o GSD Core imprime no momento da instalação. + ### `/gsd-cleanup` Arquiva diretórios de fases acumulados de milestones concluídos e poda branches locais cujo upstream foi excluído. diff --git a/docs/pt-BR/how-to/install-on-your-runtime.md b/docs/pt-BR/how-to/install-on-your-runtime.md index cdb1d3853..d83f82348 100644 --- a/docs/pt-BR/how-to/install-on-your-runtime.md +++ b/docs/pt-BR/how-to/install-on-your-runtime.md @@ -36,6 +36,8 @@ npx @opengsd/gsd-core@latest --claude --global As habilidades são instaladas em `~/.claude/`. Os comandos aparecem como slash commands `/gsd-*` na sua próxima sessão do Claude Code. Reinicie o Claude Code para carregá-los. +**Instalação em ambos os escopos `--global` e `--local`.** Essa é uma configuração suportada, mas as próprias regras de resolução de gatilhos do Claude Code — escopo pessoal sobrepõe escopo de projeto, e uma skill sobrepõe um comando de mesmo nome — apontam para a mesma direção: a skill global sempre vence o gatilho `/gsd-` sobre o comando local. O GSD Core detecta isso e imprime qual escopo está vencendo logo após a instalação (e reporta o mesmo fato pelo `/gsd-health` como o diagnóstico `W028`); é um aviso, não uma falha — a instalação em si continua bem-sucedida. No escopo **global**, a referência da spec de workflow da skill vencedora é resolvida em tempo de execução em relação ao seu diretório de trabalho primeiro, então um projeto com seu próprio `.claude/gsd-core/` ainda obtém suas próprias specs mesmo que a skill global seja o que o Claude Code invoca. + **Substituir o diretório de instalação:** ```bash diff --git a/docs/reference/host-integration-capability-matrix.md b/docs/reference/host-integration-capability-matrix.md index ea1de48cd..0312094e4 100644 --- a/docs/reference/host-integration-capability-matrix.md +++ b/docs/reference/host-integration-capability-matrix.md @@ -99,6 +99,8 @@ Sources consulted: - Context7 /websites/code_claude - Context7 /llmstxt/code_claude_llms_txt +**Cross-scope trigger shadowing and spec-root reachability (#2873, epic #2866 Phase 4, resolving #2218).** GSD's claude `artifactLayout` installs `global=[skills]` and `local=[commands, agents]` (see "Trigger precedence" above). Claude Code's own documented precedence — personal overrides project, and a same-named skill overrides a same-named command — means both rules point the same direction when a user installs both scopes: the global skill always wins the `/gsd-` trigger, and the project-local `.claude/gsd-core/` spec tree the local command correctly points at becomes unreachable through that trigger, silently. GSD Core now (a) detects this at install time and from `/gsd-health` (diagnostic `W028`) and prints which scope wins — an advisory only, exit code unchanged; and (b) for claude at **global** scope only, resolves the winning skill's own workflow-spec `@`-include at runtime rather than pre-expanding it: the skill body carries an explicit imperative instruction that resolves `.claude/gsd-core/workflows/.md` relative to the working directory first, falling back to `~/.claude/gsd-core/workflows/.md` when no local tree exists. This is instruction-following, not the guaranteed inclusion a real `@`-include provides — it costs the agent one file read — and is deliberately confined to this one reference, in this one runtime, at this one scope: the skill's `references/`/`templates/` includes and the local scope's own emission are unchanged. See [Interpret install-shadow warnings](../how-to/interpret-install-shadow-warnings.md) and [Install on your runtime — Claude Code](../how-to/install-on-your-runtime.md#claude-code). + --- ## codex diff --git a/docs/zh-CN/COMMANDS.md b/docs/zh-CN/COMMANDS.md index 5afcac706..003b99c3d 100644 --- a/docs/zh-CN/COMMANDS.md +++ b/docs/zh-CN/COMMANDS.md @@ -814,6 +814,8 @@ ROADMAP.md 中阶段的 CRUD 操作 — 通过单一合并命令添加、插入 /gsd-health --context # 上下文使用率分类 ``` +**跨作用域安装遮蔽(`W028`)。** 当某个运行时同时安装在 `global` 和 `local` 两个作用域,且宿主的触发器解析规则使其中一个作用域的 `/gsd-*` 界面变得不可达——Claude Code 的情况:个人技能总是胜过项目命令——健康检查会添加一条 WARNING 级别的提示,指出被遮蔽的触发器、胜出的作用域以及落败的作用域。它从不改变健康检查的通过/失败状态,也从不会被自动修复(不存在唯一正确应移除的作用域),因此 `--repair` 永远不会处理它。这与 GSD Core 在安装时打印的提示完全相同。 + ### `/gsd-cleanup` 归档已完成里程碑中积累的阶段目录,并删除上游已删除的本地分支。 diff --git a/docs/zh-CN/how-to/install-on-your-runtime.md b/docs/zh-CN/how-to/install-on-your-runtime.md index e8e34d194..c58e0a0d4 100644 --- a/docs/zh-CN/how-to/install-on-your-runtime.md +++ b/docs/zh-CN/how-to/install-on-your-runtime.md @@ -36,6 +36,8 @@ npx @opengsd/gsd-core@latest --claude --global 技能文件存放于 `~/.claude/`。下次 Claude Code 会话中,命令将以 `/gsd-*` 斜杠命令的形式出现。重启 Claude Code 以加载它们。 +**同时在 `--global` 和 `--local` 两个作用域安装。** 这是受支持的配置(不同项目有时需要不同的自定义配置),但 Claude Code 自身的触发器解析规则——个人作用域覆盖项目作用域,且技能(skill)覆盖同名命令——都指向同一个方向:全局技能总是在 `/gsd-` 触发器上胜过本地命令。GSD Core 会检测到这一点,并在安装完成后立即打印出哪个作用域胜出(并通过 `/gsd-health` 以诊断代码 `W028` 呈现相同的事实);这只是一条提示,不是失败——安装本身仍然会成功。在**全局**作用域下,胜出技能的工作流规范引用会在运行时优先相对于你的工作目录解析,因此即使 Claude Code 实际调用的是全局技能,拥有自己 `.claude/gsd-core/` 的项目仍然能获取到自己的规范。 + **覆盖安装目录:** ```bash diff --git a/eslint.config.mjs b/eslint.config.mjs index bf346c853..87119c4b9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -159,6 +159,9 @@ export default tseslint.config( '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', + // #2873 (epic #2866 Phase 4): tsc-generated runtime artifact — lint the + // src/health-diagnostic-rules/install-surface-shadowing.cts source. + 'gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs', 'gsd-core/bin/lib/shell-command-projection.cjs', 'gsd-core/bin/lib/security.cjs', 'gsd-core/bin/lib/command-aliases.cjs', @@ -200,6 +203,9 @@ export default tseslint.config( 'gsd-core/bin/lib/runtime-artifact-layout.cjs', 'gsd-core/bin/lib/install-scope.cjs', 'gsd-core/bin/lib/installed-surface-resolver.cjs', + // #2873 (epic #2866 Phase 4): tsc-generated runtime artifact — lint the + // src/install-shadow-report.cts source. + 'gsd-core/bin/lib/install-shadow-report.cjs', 'gsd-core/bin/lib/runtime-config-adapter-registry.cjs', 'gsd-core/bin/lib/runtime-hooks-surface.cjs', 'gsd-core/bin/lib/command-routing-hub.cjs', diff --git a/gsd-core/workflows/health.md b/gsd-core/workflows/health.md index 84e183518..da57196ff 100644 --- a/gsd-core/workflows/health.md +++ b/gsd-core/workflows/health.md @@ -250,6 +250,7 @@ Report final status. | W024 | warning | STATE.md was written many commits ago — treat its contents as approximate | No | | W026 | warning | STATE says milestone complete but ROADMAP lists an unstarted phase for that milestone | No | | W027 | warning | Stale git worktree (not modified in a long time) | No | +| W028 | warning | A GSD-owned install scope shadows another on this machine | No | | I001 | info | Plan without SUMMARY (may be in progress) | No | | I010 | info | Resolved CWD reported alongside the E010 home-directory guard | No | diff --git a/src/health-diagnostic-rules/install-surface-shadowing.cts b/src/health-diagnostic-rules/install-surface-shadowing.cts new file mode 100644 index 000000000..2c05e3581 --- /dev/null +++ b/src/health-diagnostic-rules/install-surface-shadowing.cts @@ -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; + +// 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 }; diff --git a/src/health-diagnostic.cts b/src/health-diagnostic.cts index af10ec1fe..18917faeb 100644 --- a/src/health-diagnostic.cts +++ b/src/health-diagnostic.cts @@ -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, ]; /** diff --git a/src/install-shadow-report.cts b/src/install-shadow-report.cts new file mode 100644 index 000000000..718121e35 --- /dev/null +++ b/src/install-shadow-report.cts @@ -0,0 +1,457 @@ +/** + * install-shadow-report.cts — Cross-Scope Shadow Report Module (#2873, epic + * #2866 Phase 4a — governed by + * `.gsd/phase/feat-2873-cross-scope-shadowing/40-design.md`). + * + * A read-only PROJECTION over `resolveInstalledSurfaces` + * (`installed-surface-resolver.cts`, #2872 Phase 3). That module answers + * "what is installed"; it is documented there as read-only, and rendering + * plus sanitization are a different concern with a different consumer set + * (installer + `/gsd-health`) — the design doc's "Rejected" #5 is why this is + * a separate leaf module rather than a second export bolted onto the + * resolver. + * + * ── What "shadowed" means here ────────────────────────────────────────────── + * A trigger is shadowed when `resolveTriggerSurface` (via the resolver) + * recorded a non-null `shadowedBy` for it: two scopes both installed a + * trigger-bearing artifact under the SAME trigger name, and only one wins. + * For claude (`skills`@global vs `commands`@local) the KINDS differ, so the + * loser's entire spec tree becomes unreachable through the trigger — the bug + * #2218 diagnosed. For the 12 both-scopes-`skills` runtimes the kinds are the + * SAME on both sides, so the loser is merely overridden, not vanished + * (design row #5 / "Not-corruption"). `kindsDiffer` on `ShadowReport` is what + * lets `renderShadowReport` word the two cases correctly. + * + * ── Report, don't correct (mirrors the resolver's own law) ───────────────── + * `mismatches` surfaces a declared runtime/scope that disagrees with the + * probed one (Postel's Law, design doc: liberal in what is accepted, but the + * mismatch is never silently absorbed). This module never substitutes a + * declared value for a probed one; it only reports the disagreement the + * resolver already computed. + * + * ── Sanitize at the render seam ───────────────────────────────────────────── + * `declaredRuntime` is attacker-influenceable (it comes from a manifest that + * may live inside a merely-cloned repository) and length-bounded but + * deliberately NOT charset-gated by the reader (`declaredRuntimeMatchesProbe` + * needs the raw value there). THIS module is what renders it to an operator, + * so this module owns the guard — `sanitizeForRender` strips ANSI escapes, + * C0/C1 controls, and Unicode bidi overrides/isolates, then collapses + * whitespace. It never truncates: `readInstallManifest` already caps at 64 + * chars, and a second truncation here would double-truncate. + * + * Trigger names, by contrast, are already `SAFE_STEM`-gated upstream + * (`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 + * (`installed-surface-resolver.cts`'s "Pure with respect to caller-visible + * state" paragraph) — no shared or cached state between calls. + */ + +import { type InstallScope } from './install-scope.cjs'; +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[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({ + NOT_SHADOWED: 'not_shadowed', + SCOPE_SHADOWED: 'scope_shadowed', + RESOLVER_UNAVAILABLE: 'resolver_unavailable', +}); + +// ── Public types ──────────────────────────────────────────────────────── + +export interface ShadowedTrigger { + trigger: string; + winnerKind: string; + winnerScope: InstallScope; + shadowedKind: string; + shadowedScope: InstallScope; +} + +export interface DeclarationMismatch { + scope: InstallScope; + declaredRuntime: string | null; + declaredRuntimeMatchesProbe: boolean | null; + declaredScope: InstallScope | null; + declaredScopeMatchesProbe: boolean | null; +} + +export interface ShadowReport { + runtime: string; + reason: string; + shadowed: boolean; + winner: { kind: string; scope: InstallScope } | null; + shadowedSide: { kind: string; scope: InstallScope } | null; + kindsDiffer: boolean; + triggers: ShadowedTrigger[]; + mismatches: DeclarationMismatch[]; +} + +// ── Sanitization ──────────────────────────────────────────────────────── + +/** CSI (`\x1b[...final`) and OSC (`\x1b]...BEL-or-ST`) sequences. An + * unterminated/malformed sequence is left for the C0-control strip below to + * remove the bare `\x1b` byte — liberal, never a throw. */ +const ANSI_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g; + +/** C0 controls (`\x00`-`\x1f`, including any `\x1b` the ANSI strip above did + * not consume) and DEL/C1 (`\x7f`-`\x9f`). */ +const CONTROL_RE = /[\x00-\x1f\x7f-\x9f]/g; + +/** Unicode bidi embedding/override controls (U+202A-U+202E) and bidi + * isolates (U+2066-U+2069) — the RTL-spoofing class the design doc's row + * #13 names. */ +const BIDI_RE = /[\u{202A}-\u{202E}\u{2066}-\u{2069}]/gu; + +/** Combining marks (U+0300-U+036F) — "zalgo" text. Stacked onto the + * preceding base character, an unbounded run visually overflows into + * adjacent terminal cells/rows even though the string stays within the + * 64-char cap `readInstallManifest` enforces. Written as `\u{...}` escapes + * (not literal combining characters) so the source itself stays plain + * ASCII and does not visually combine in editors/diffs. */ +const COMBINING_MARK_RE = /[\u{0300}-\u{036F}]/gu; + +/** Zero-width characters: ZWSP (U+200B), ZWNJ (U+200C), ZWJ (U+200D), and + * BOM/ZWNBSP (U+FEFF). None of these are JS `\s`, so they survive both the + * char-count cap and the whitespace-collapse step below undetected. */ +const ZERO_WIDTH_RE = /[\u{200B}-\u{200D}\u{FEFF}]/gu; + +/** + * Sanitize a `declaredRuntime` (or any similarly attacker-influenceable + * string) for terminal/console rendering. `null` passes through as `null`; + * `''` passes through as `''`. Strips ANSI escapes, C0/C1 controls, Unicode + * bidi overrides/isolates, combining marks (zalgo), and zero-width + * characters (replacing each stripped run with nothing — never a space), + * then collapses any remaining whitespace run (including adjacent spaces + * left behind by a removed newline) to a single space and trims. + * + * Idempotent by construction: once ANSI/control/bidi/combining/zero-width + * bytes are gone and whitespace is collapsed to single internal spaces with + * no leading/trailing space, a second pass finds nothing left to strip or + * collapse. Pure character-class filter — never truncates; `readInstallManifest` + * already caps at 64 chars. + */ +export function sanitizeForRender(value: string | null): string | null { + if (value === null) return null; + const stripped = value + .replace(ANSI_RE, '') + .replace(CONTROL_RE, '') + .replace(BIDI_RE, '') + .replace(COMBINING_MARK_RE, '') + .replace(ZERO_WIDTH_RE, ''); + 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, + opts: ResolveInstalledSurfacesOptions, +): (scope: InstallScope, kind: string) => string | null { + const cache = new Map(); + 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, + 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 ────────────────────────────────────────────────────── + +/** + * Build a shadow report for one runtime. `opts` is forwarded VERBATIM to + * `resolveInstalledSurfaces` — this function adds no option of its own. + * Production call shape: `buildShadowReport('claude', { home, cwd })`. + * + * A `resolveInstalledSurfaces` `TypeError` (unknown runtime, or + * `configHome.kind === 'none'`, e.g. vscode — design row #7) degrades to + * `reason: RESOLVER_UNAVAILABLE` rather than propagating: an install-time or + * `/gsd-health` caller must never crash because a runtime has no installable + * config directory. Any other error type is rethrown — mirrors the + * resolver's own `TypeError` narrowing (`resolveInstalledSurfaces`'s sweep + * catch, and `buildScopeRecord`'s stem-derivation catch) so the two cannot + * drift apart. + */ +export function buildShadowReport(runtime: string, opts: ResolveInstalledSurfacesOptions = {}): ShadowReport { + let surfaces: InstalledRuntimeSurface[]; + try { + surfaces = resolveInstalledSurfaces(runtime, opts); + } catch (error) { + if (!(error instanceof TypeError)) throw error; + return { + runtime, + reason: SHADOW_REASON.RESOLVER_UNAVAILABLE, + shadowed: false, + winner: null, + shadowedSide: null, + kindsDiffer: false, + triggers: [], + mismatches: [], + }; + } + + // resolveInstalledSurfaces(runtime, opts) with an explicit string `runtime` + // always returns exactly one element (see its own doc comment). + const surface = surfaces[0]; + + // 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(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, + // `shadowedBy` is non-null by construction of the filter above. + winnerKind: t.shadowedBy!.kind, + winnerScope: t.shadowedBy!.scope, + shadowedKind: t.kind, + shadowedScope: t.scope, + })) + .sort((a, b) => (a.trigger < b.trigger ? -1 : a.trigger > b.trigger ? 1 : 0)); + + const mismatches: DeclarationMismatch[] = []; + for (const record of surface.scopes) { + if (record.declaredRuntimeMatchesProbe === false || record.declaredScopeMatchesProbe === false) { + mismatches.push({ + scope: record.scope, + // Postel's Law (design doc): sanitized here because this is the + // render seam — never silently absorbed, always surfaced. + declaredRuntime: sanitizeForRender(record.declaredRuntime), + declaredRuntimeMatchesProbe: record.declaredRuntimeMatchesProbe, + declaredScope: record.declaredScope, + declaredScopeMatchesProbe: record.declaredScopeMatchesProbe, + }); + } + } + + if (triggers.length === 0) { + return { + runtime, + reason: SHADOW_REASON.NOT_SHADOWED, + shadowed: false, + winner: null, + shadowedSide: null, + kindsDiffer: false, + triggers: [], + mismatches, + }; + } + + // Winner/shadowedSide are the (kind,scope) pair of the FIRST shadowed + // trigger (post-sort, for the same determinism reason the array itself is + // sorted). They are asserted-by-construction uniform across the whole set + // for every runtime this module has seen (every trigger shadowed by the + // SAME scope, with the SAME two kinds, on one machine) — but if a future + // registry shape ever produced a non-uniform set, this still returns the + // first pair rather than throwing; every distinct (kind,scope) pair is + // already visible per-entry in `triggers` itself, so nothing is lost. + const first = triggers[0]; + const winner = { kind: first.winnerKind, scope: first.winnerScope }; + const shadowedSide = { kind: first.shadowedKind, scope: first.shadowedScope }; + + return { + runtime, + reason: SHADOW_REASON.SCOPE_SHADOWED, + shadowed: true, + winner, + shadowedSide, + kindsDiffer: winner.kind !== shadowedSide.kind, + triggers, + mismatches, + }; +} + +// ── Renderer ──────────────────────────────────────────────────────────── + +/** + * Render a `ShadowReport` to plain lines — no ANSI, no color, no leading + * indent. The caller (installer console output, `/gsd-health` text mode) + * owns terminal formatting; this keeps the module free of terminal concerns + * and testable without a spawned process. Structured (`--json`) health + * output (design row #17) consumes the typed `ShadowReport` directly and + * never calls this function. + * + * `reason !== SCOPE_SHADOWED` renders nothing — there is nothing to report + * (design rows #1, #2, #6, #7, #8, #11). + */ +export function renderShadowReport(report: ShadowReport, opts: { sampleLimit?: number } = {}): string[] { + if (report.reason !== SHADOW_REASON.SCOPE_SHADOWED || report.winner === null || report.shadowedSide === null) { + return []; + } + + const sampleLimit = opts.sampleLimit ?? 5; + const count = report.triggers.length; + const plural = count === 1 ? '' : 's'; + const { winner, shadowedSide, kindsDiffer } = report; + + const lines: string[] = []; + lines.push( + kindsDiffer + ? `${count} trigger${plural} shadowed: the ${shadowedSide.scope} ${shadowedSide.kind} surface is unreachable through ${count === 1 ? 'that trigger' : 'those triggers'} — ${winner.scope} ${winner.kind} wins instead.` + : `${count} trigger${plural} shadowed: the ${shadowedSide.scope} ${shadowedSide.kind} ${count === 1 ? 'entry is' : 'entries are'} overridden by ${winner.scope} ${winner.kind}.`, + ); + + // Trigger names in `report.triggers` are already SAFE_STEM-gated upstream + // (installed-surface-resolver.cts's deriveStemsForKindEntry) — no re-gating + // needed here. + const sample = report.triggers.slice(0, sampleLimit); + for (const t of sample) { + lines.push(` - ${t.trigger}: ${t.shadowedScope}/${t.shadowedKind} shadowed by ${t.winnerScope}/${t.winnerKind}`); + } + const remaining = count - sample.length; + if (remaining > 0) { + lines.push(` ...and ${remaining} more`); + } + + for (const m of report.mismatches) { + // Re-sanitized defensively: `buildShadowReport` already sanitizes + // `declaredRuntime` before it reaches a `ShadowReport`, and + // `sanitizeForRender` is idempotent, so this is a no-op in the normal + // path and a real guard against a hand-built `ShadowReport` (e.g. a + // renderer-only test) that skipped it. + const declaredRuntime = sanitizeForRender(m.declaredRuntime); + const parts: string[] = []; + if (m.declaredRuntimeMatchesProbe === false) { + parts.push(`declared runtime "${declaredRuntime}" does not match this runtime`); + } + if (m.declaredScopeMatchesProbe === false) { + parts.push(`declared scope "${m.declaredScope}" does not match the probed ${m.scope} scope`); + } + lines.push(`Note: ${m.scope} scope manifest mismatch — ${parts.join('; ')}.`); + } + + return lines; +} diff --git a/src/installed-surface-resolver.cts b/src/installed-surface-resolver.cts index a1b2c4c10..b804a72c7 100644 --- a/src/installed-surface-resolver.cts +++ b/src/installed-surface-resolver.cts @@ -53,6 +53,8 @@ * than re-deriving either rule as a fourth independent copy. */ +import fs from 'node:fs'; +import path from 'node:path'; import { resolveScope, SCOPE_ORDER, type InstallScope } from './install-scope.cjs'; import { posixNormalize } from './shell-command-projection.cjs'; @@ -68,7 +70,7 @@ const { // eslint-disable-next-line @typescript-eslint/no-require-imports import installerMigrationsMod = require('./installer-migrations.cjs'); -const { readInstallManifest } = installerMigrationsMod; +const { readInstallManifest, MANIFEST_NAME } = installerMigrationsMod; // In .cts (CommonJS output) files, `require` is available as a global. const _require: NodeRequire = require; @@ -130,6 +132,12 @@ export interface ResolveInstalledSurfacesOptions { cwd?: string; env?: Record; existsSync?: (p: string) => boolean; + /** Injected for tests, matching `existsSync` above; defaults to + * `node:fs`'s `lstatSync`. Used to refuse a manifest read when the + * scope's config dir or manifest file is a symlink — see the + * `buildScopeRecord` comment for why this is a deliberate hardening, + * not an oversight. */ + lstatSync?: (p: string) => { isSymbolicLink(): boolean }; registry?: unknown; /** Injected for tests; defaults to installer-migrations' readInstallManifest. */ readManifest?: (configDir: string) => { @@ -252,6 +260,45 @@ function deriveStemsFromManifest( return [...stems].sort(); } +/** + * True when `p` is a symlink. An `lstatSync` throw (ENOENT — nothing at this + * path) is NOT evidence of a symlink; it is treated as "not a symlink" here + * and left for `readManifest` to classify (it already owns the absent-file + * case, per C14 above). + */ +function isSymlinkPath(p: string, lstatSync: (p: string) => { isSymbolicLink(): boolean }): boolean { + try { + return lstatSync(p).isSymbolicLink(); + } catch { + return false; + } +} + +/** + * The shared "not installed" degraded shape (C14's EACCES path, and this + * phase's new symlink-guard path). A FACTORY, not a module-level constant + * object: a `const` object spread at each return site would still share the + * same `stems` ARRAY reference across every call (`...` shallow-copies the + * object but not the array a property points at), which would violate this + * module's own "builds fresh arrays/objects on every call" contract (C15) — + * a caller mutating one degraded record's `stems` must never be visible on + * another's. + */ +function notInstalledScopeRecordFields(): Pick< + InstalledScopeRecord, + 'installed' | 'manifestVersion' | 'declaredRuntime' | 'declaredScope' | 'declaredScopeMatchesProbe' | 'declaredRuntimeMatchesProbe' | 'stems' +> { + return { + installed: false, + manifestVersion: null, + declaredRuntime: null, + declaredScope: null, + declaredScopeMatchesProbe: null, + declaredRuntimeMatchesProbe: null, + stems: [], + }; +} + /** * Build one scope's record. `resolvedConfigHome` has already been probed * successfully by the time this is called (a `resolveScope` `TypeError` is @@ -278,6 +325,26 @@ function buildScopeRecord( resolvedConfigHome: string, opts: ResolveInstalledSurfacesOptions, ): InstalledScopeRecord { + // Hardening requirement 2 (#2873 design doc, "Hardening requirements + // claimed from #2873's comment"): `readInstallManifest` -> `readJsonIfPresent` + // uses `existsSync` + `readFileSync` and therefore FOLLOWS symlinks, and + // this module resolves `local` against `process.cwd()` — a directory that, + // as of this phase, becomes reachable from an arbitrary cloned repository + // (this is the same phase that makes the local scope's manifest a first + // read target, not merely a write target). The in-tree precedent is + // `getAgentsDir` (`agent-install-check.cts`), which probes with + // `fs.lstatSync(...).isDirectory()`/`.isFile()` and deliberately does not + // follow. #2872 left this resolver's read un-guarded only because the path + // had zero callers at the time; refusing to follow a symlinked config dir + // or manifest here closes that asymmetry rather than carrying it forward. + // Degrading to `installed: false` reuses the SAME shape the EACCES catch + // below already returns — no new failure shape is introduced. + const lstatSync = opts.lstatSync ?? fs.lstatSync; + const manifestPath = path.join(resolvedConfigHome, MANIFEST_NAME); + if (isSymlinkPath(resolvedConfigHome, lstatSync) || isSymlinkPath(manifestPath, lstatSync)) { + return { scope: scopeId, configHome: resolvedConfigHome, ...notInstalledScopeRecordFields() }; + } + let manifest: { manifestVersion: number | null; runtime: string | null; @@ -293,17 +360,7 @@ function buildScopeRecord( // read failure at all (EACCES, ENOENT-after-race, a corrupt filesystem) // legitimately means "cannot tell whether installed" (design row C14), so // there is no error TYPE here that should instead propagate. - return { - scope: scopeId, - configHome: resolvedConfigHome, - installed: false, - manifestVersion: null, - declaredRuntime: null, - declaredScope: null, - declaredScopeMatchesProbe: null, - declaredRuntimeMatchesProbe: null, - stems: [], - }; + return { scope: scopeId, configHome: resolvedConfigHome, ...notInstalledScopeRecordFields() }; } const installed = manifest.manifestVersion !== null; // C9: presence, never the new fields diff --git a/src/installer-migrations.cts b/src/installer-migrations.cts index a1ab769a3..6139adbea 100644 --- a/src/installer-migrations.cts +++ b/src/installer-migrations.cts @@ -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, diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index c8e10d45e..24a93afb2 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -27,6 +27,7 @@ import capabilityRegistry = require('./capability-registry.cjs'); import hostIntegration = require('./host-integration.cjs'); import { posixNormalize } from './shell-command-projection.cjs'; import { escapeRegex as escapeRegExp } from './pattern.cjs'; +import { scanFencedBlocks } from './markdown-sectionizer.cjs'; // #2870: install-scope.cts is a leaf-tier sibling (imports only // runtime-homes.cjs + node builtins, never this module) — no cycle. See the // isGlobal sites below for why the boolean projection is centralized here too. @@ -504,6 +505,90 @@ 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/.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; + +/** + * 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-/` — 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. + * + * Fence detection reuses `scanFencedBlocks` (markdown-sectionizer.cts) — the + * same CommonMark-correct state machine `stripFencedCode`/`extractFencedBlock` + * are built on — instead of a hand-rolled "any delimiter line toggles + * open/closed" tracker. A naive toggle is wrong under CommonMark: a fence + * opened with ``` is NOT closed by a ~~~ line (closer must share the + * opener's delimiter character and have run length >= the opener's), so a + * mismatched delimiter is fence CONTENT, not a boundary. #2873 review. + */ +function resolveSpecRootReference(body) { + if (typeof body !== 'string' || body.length === 0) return body; + if (!body.includes('@~/.claude/gsd-core/workflows/')) return body; + + // Collect [start, end) character-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 lines = body.split('\n'); + const lineStartOffsets = []; + { + let offset = 0; + for (const line of lines) { + lineStartOffsets.push(offset); + offset += line.length + 1; // +1 for the '\n' separator + } + } + const fenceRanges = scanFencedBlocks(lines).map(({ openLineIdx, closeLineIdx }) => { + const start = lineStartOffsets[openLineIdx]; + const end = closeLineIdx === -1 + ? body.length + : lineStartOffsets[closeLineIdx] + lines[closeLineIdx].length; + return [start, end]; + }); + 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 +3080,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/.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/.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 +3149,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 +3304,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, diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index 9456f593a..216aa3f6d 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -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); diff --git a/tests/emitted-drift-acks/3309-health-docs-generated.json b/tests/emitted-drift-acks/3309-health-docs-generated.json index 6531e406f..c72a3f51e 100644 --- a/tests/emitted-drift-acks/3309-health-docs-generated.json +++ b/tests/emitted-drift-acks/3309-health-docs-generated.json @@ -2,7 +2,7 @@ "version": 1, "paths": { "health.md": { - "reason": "#3309 (epic #3180 Phase 11, ADR-3180): the ``/`` tables and their footnote are now GENERATED by `scripts/gen-health-docs.cjs` from the full 31-rule `RULES` table, replacing a hand-maintained 16-code table. #3309 explicitly required closing the 16-vs-30+ documentation gap structurally, so the growth is the deliberate, expected result of that acceptance criterion — not accidental bloat. Regeneration is verified deterministic via `node scripts/gen-health-docs.cjs --check` (wired into `npm run lint:generated-sync`)." + "reason": "#3309 (epic #3180 Phase 11, ADR-3180): the ``/`` tables and their footnote are GENERATED by `scripts/gen-health-docs.cjs` from the live `RULES` table, replacing a hand-maintained 16-code table — growth here is a deliberate, expected consequence of that generator doing its job as `RULES` grows, not accidental bloat. #2873 (epic #2866 Phase 4a) adds health rule W028 (\"A GSD-owned install scope shadows another on this machine\"), growing `RULES` from 31 to 32 entries and the generated `` table from 34 to 35 rows (an incremental 84-byte growth on top of #3309's original generation). Regeneration is verified deterministic via `node scripts/gen-health-docs.cjs --check` (wired into `npm run lint:generated-sync`)." } } } diff --git a/tests/gen-health-docs.test.cjs b/tests/gen-health-docs.test.cjs index 9653e7d07..8370d24a2 100644 --- a/tests/gen-health-docs.test.cjs +++ b/tests/gen-health-docs.test.cjs @@ -151,9 +151,9 @@ describe('gen-health-docs.cjs --check / --write (CLI, --target fixture)', () => describe('gen-health-docs.cjs row content (representative codes)', () => { const rules = loadRealRules(); - test('produces a 34-row table: 31 rules + 3 pre-checks (E001, E010, I010)', () => { + test('produces a 35-row table: 32 rules + 3 pre-checks (E001, E010, I010)', () => { const rows = buildErrorCodeRows(rules); - assert.equal(rows.length, 34); + assert.equal(rows.length, 35); const codes = rows.map((r) => r.code); for (const precheck of PRECHECK_CODES) { assert.ok(codes.includes(precheck.code), `missing pre-check code ${precheck.code}`); diff --git a/tests/health-diagnostic.test.cjs b/tests/health-diagnostic.test.cjs index 88bc83ff2..a5d0d6802 100644 --- a/tests/health-diagnostic.test.cjs +++ b/tests/health-diagnostic.test.cjs @@ -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)', () => { diff --git a/tests/helpers/shadow-report-throws-preload.cjs b/tests/helpers/shadow-report-throws-preload.cjs new file mode 100644 index 000000000..8ab48c269 --- /dev/null +++ b/tests/helpers/shadow-report-throws-preload.cjs @@ -0,0 +1,44 @@ +'use strict'; + +/** + * Preload fixture for #2873 matrix row C5 — force the installer's + * cross-scope shadow-report call site (bin/install.js, immediately after + * writeManifest) to exercise its own swallow-everything catch. + * + * `install-shadow-report.cjs`'s `buildShadowReport()` only degrades a + * `TypeError` thrown by `resolveInstalledSurfaces` (returns + * `RESOLVER_UNAVAILABLE`); any OTHER error type it lets propagate — see its + * own doc comment. This preload replaces the exported `buildShadowReport` + * with a function that always throws a plain `Error`, so whatever calls it + * next (`bin/install.js`, requiring the SAME resolved module path and + * therefore getting the SAME cached module object) sees that throw and must + * swallow it without failing the install (design row C5: "a report failure + * never fails the install"). + * + * Loaded via `node --require bin/install.js ...` + * (`tests/helpers/process-seam.cjs`'s `runNode` forwards extra argv + * verbatim, ahead of the target script) so the patch lands in the require + * cache BEFORE `bin/install.js`'s own + * `require('../gsd-core/bin/lib/install-shadow-report.cjs')` resolves. + * + * One-shot subprocess: no restoration needed — the process exits right + * after the single `install()` call this preload targets. This is NOT a + * chmod/mode-bit trick (CONTRIBUTING.md: those no-op under root), and NOT + * the in-process fs fault-injection seam (`tests/helpers/faulty-deps.cjs`'s + * `withFaultyFs` is explicitly documented as in-process-only, since a + * spawned subprocess offers no shared memory to monkeypatch into) — this + * patches the ONE exported function the design doc names as the report + * builder, at the require-cache seam a spawned child process actually + * offers. + */ + +const path = require('node:path'); + +const modPath = require.resolve( + path.join(__dirname, '..', '..', 'gsd-core', 'bin', 'lib', 'install-shadow-report.cjs'), +); +const shadowReportModule = require(modPath); + +shadowReportModule.buildShadowReport = function throwingBuildShadowReport() { + throw new Error('injected by tests/helpers/shadow-report-throws-preload.cjs (#2873 matrix row C5)'); +}; diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index 8da221010..fad341db5 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -19,13 +19,22 @@ process.env.GSD_TEST_MODE = '1'; -const { test, describe } = require('node:test'); +const { test, describe, before, after } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); +const os = require('node:os'); const { createTempDir, cleanup } = require('./helpers.cjs'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); +const { + INSTALL_SCRIPT, + MANIFEST_NAME, + installerEnv, + stripAnsi, +} = require('./helpers/install-shared.cjs'); const { installRuntimeArtifacts, @@ -6308,3 +6317,381 @@ describe('Gap 2: installer ships the capability registry generator scripts (#192 }); }); } + +// ─── #2218 cross-scope shadowing — coexistence gate (C1) + 4b guard pair +// (E13/E14, #2873, epic #2866 Phase 4a) ──────────────────────────────────── +// +// Moved here from the now-deleted tests/install-cross-scope-shadowing.test.cjs: +// `scripts/lint-test-file-count.allowlist.json` grandfathers the `install` +// prefix at 8 files, and that suite's own `_doc` says adding a 9th file to a +// capped module is a novel offender, not a fix — this gate is folded into +// the emitted-artifact suite instead, which is already allowlisted and, +// per #2873's acceptance criteria, is the correct home ("written against the +// existing `runMinimalInstall` harness"). +// +// Implements the coexistence gate (`C1`) and the 4b behavioral pair +// (`E13`/`E14`) from +// `.gsd/phase/feat-2873-cross-scope-shadowing/50-test-matrix.md`. Per that +// matrix's "Red-first order": C1 must go RED against `next` (no +// `install-shadow-report.cjs` report exists today), E14 must go RED today +// (the global skill's spec-root include points at the global tree even when +// a coexisting local install has its own project-local copy of that +// workflow file), and E13 must stay GREEN both before and after — it is the +// guard that phase 4b does not break today's global-only case. +// +// This section does NOT implement the 4b spec-root emission transform +// (E1-E12, a separate matrix section) — that transform +// (`resolveSpecRootReference`, `runtime-artifact-conversion.cts`) landed +// separately and is exercised here only via its INSTALLED OUTPUT. #2873 +// Task 3 (2026-08-14): re-verified against a real global+local double +// install — 4b has landed and E14 below is GREEN, not the known-RED case +// this comment block originally described. The "Red-first order" paragraph +// above is left as-is: it accurately records the matrix's ORIGINAL red-first +// plan, not a live claim about E14's current state. + +/** + * Extract the `@`-include lines from an emitted markdown body — structural + * parsing, never substring/regex matching on the whole body (CONTRIBUTING.md + * "Prohibited: Raw Text Matching on Test Outputs"). Splits on newlines + * (CRLF-tolerant) and keeps only lines whose first character is `@`. + * + * @param {string} content + * @returns {string[]} + */ +function extractAtIncludeLines(content) { + return content.split(/\r?\n/).filter((line) => line.startsWith('@')); +} + +describe('#2218 cross-scope shadowing', () => { + let root; + let projectDir; + let globalInstallResult; + let localInstallResult; + + before(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2218-shadow-')); + projectDir = path.join(root, 'myrepo'); + fs.mkdirSync(projectDir, { recursive: true }); + + // Global half: cannot use runMinimalInstall here — its scope:'global' + // path pushes `--config-dir `, which pins the install AT `` + // itself (manifest at `/gsd-file-manifest.json`), not at + // `/.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 + // `/.claude` and collide with the global install above. Spawn the + // installer directly instead, with cwd pinned at the project dir. + localInstallResult = runNode([INSTALL_SCRIPT, '--claude', '--local'], { + cwd: projectDir, + env: installerEnv({ HOME: root, USERPROFILE: root }), + timeoutMs: INSTALL_TIMEOUT_MS, + }); + assert.strictEqual(localInstallResult.exitCode, 0, + `local install exited with status ${localInstallResult.exitCode} ` + + `(outcome=${localInstallResult.outcome})\n` + + `stdout: ${localInstallResult.stdout}\nstderr: ${localInstallResult.stderr}`); + }); + + after(() => { + cleanup(root); + }); + + test('both installs land their own manifest', () => { + const globalManifestPath = path.join(root, '.claude', MANIFEST_NAME); + const localManifestPath = path.join(projectDir, '.claude', MANIFEST_NAME); + + assert.ok(fs.existsSync(globalManifestPath), 'global manifest should exist'); + assert.ok(fs.statSync(globalManifestPath).isFile(), 'global manifest should be a file'); + assert.ok(fs.existsSync(localManifestPath), 'local manifest should exist'); + assert.ok(fs.statSync(localManifestPath).isFile(), 'local manifest should be a file'); + + const globalManifest = JSON.parse(fs.readFileSync(globalManifestPath, 'utf8')); + const localManifest = JSON.parse(fs.readFileSync(localManifestPath, 'utf8')); + + assert.strictEqual(globalManifest.scope, 'global'); + assert.strictEqual(localManifest.scope, 'local'); + }); + + test('the local install reports the shadowing it causes', () => { + // #2218/#2873: install-shadow-report.cjs does not exist yet — this + // require is the intended RED. buildShadowReport is the pure IR builder + // described in .gsd/phase/feat-2873-cross-scope-shadowing/40-design.md + // (row 3): claude installed at both G and L reports N triggers shadowed, + // winner skills@global, loser commands@local. + const { buildShadowReport } = require('../gsd-core/bin/lib/install-shadow-report.cjs'); + const report = buildShadowReport('claude', { home: root, cwd: projectDir }); + + assert.strictEqual(report.shadowed, true); + assert.strictEqual(report.winner.kind, 'skills'); + assert.strictEqual(report.winner.scope, 'global'); + assert.strictEqual(report.shadowedSide.kind, 'commands'); + assert.strictEqual(report.shadowedSide.scope, 'local'); + assert.ok(report.triggers.length > 0, 'expected at least one shadowed trigger'); + }); + + test('global-only install resolves the same spec file it does today', () => { + const skillPath = path.join(root, '.claude', 'skills', 'gsd-plan-phase', 'SKILL.md'); + const content = fs.readFileSync(skillPath, 'utf8'); + const atLines = extractAtIncludeLines(content); + + assert.ok( + atLines.includes('@~/.claude/gsd-core/references/ui-brand.md'), + `expected the ui-brand reference @-line among: ${JSON.stringify(atLines)}`, + ); + }); + + // #2218 / phase #2873: before 4b, the global SKILL.md's spec-root include + // was a static `@~/.claude/gsd-core/workflows/plan-phase.md` reference, + // which always resolved against the GLOBAL tree even when a coexisting + // local install has its own project-local copy of that workflow file. + // Phase 4b (`resolveSpecRootReference`, `runtime-artifact-conversion.cts`) + // replaces that static include with a two-step imperative form that names + // both candidate paths and lets the runtime prefer the local one when it + // exists. #2873 Task 3 (2026-08-14): re-verified GREEN against a real + // global+local double install — 4b landed after this test package was + // authored, so this is no longer the known-RED case the original comment + // above it described. + test('the winning global skill points at the project-local spec tree (E14)', () => { + const skillPath = path.join(root, '.claude', 'skills', 'gsd-plan-phase', 'SKILL.md'); + const content = fs.readFileSync(skillPath, 'utf8'); + const atLines = extractAtIncludeLines(content); + + assert.ok( + !atLines.includes('@~/.claude/gsd-core/workflows/plan-phase.md'), + `expected the static global workflow @-line to be replaced, but found it among: ${JSON.stringify(atLines)}`, + ); + // The reference @-include (a DIFFERENT spec root, row E4) survives + // untouched — structural proof 4b did not over-fire on this file. + assert.ok( + atLines.includes('@~/.claude/gsd-core/references/ui-brand.md'), + `expected the ui-brand reference @-line to survive among: ${JSON.stringify(atLines)}`, + ); + + const localSpecPath = path.join(projectDir, '.claude', 'gsd-core', 'workflows', 'plan-phase.md'); + assert.ok(fs.existsSync(localSpecPath), 'local spec-root workflow file should exist on disk'); + + // Positive assertion, not just absence-of-the-old-include: the emitted + // body must actually NAME the project-local candidate path. Exact-string + // presence check on the literal candidate path `resolveSpecRootReference` + // emits (never a substring-scan for prose wording — CONTRIBUTING → + // "Prohibited: Raw Text Matching on Test Outputs"; this checks for the + // PATH token, not sentence phrasing). + assert.ok( + content.includes('.claude/gsd-core/workflows/plan-phase.md'), + `expected the emitted body to name the project-local candidate path, got: ${JSON.stringify(content)}`, + ); + }); +}); + +// ─── #2873 matrix section C — install-time report (spawned installer) ───── +// +// Implements rows C1-C6 from +// `.gsd/phase/feat-2873-cross-scope-shadowing/50-test-matrix.md`. The +// `#2218 cross-scope shadowing` suite above calls `buildShadowReport` +// DIRECTLY — real coverage of the pure IR, but it proves nothing about the +// INSTALLER'S OWN WIRING at bin/install.js's writeManifest-adjacent +// try/catch block (the only call site that ever prints a report). These +// rows instead spawn the real installer and inspect its own stdout/stderr +// and exit code — the actual product surface #2218 reported a gap in. + +describe('#2873 C1-C6 — install-time shadow report (spawned installer wiring)', () => { + const { buildShadowReport, renderShadowReport, SHADOW_REASON } = require('../gsd-core/bin/lib/install-shadow-report.cjs'); + const SHADOW_THROWS_PRELOAD = path.join(__dirname, 'helpers', 'shadow-report-throws-preload.cjs'); + + function spawnInstall(args, cwd, root, nodeFlags = []) { + return runNode([...nodeFlags, INSTALL_SCRIPT, ...args], { + cwd, + env: installerEnv({ HOME: root, USERPROFILE: root }), + timeoutMs: INSTALL_TIMEOUT_MS, + }); + } + + /** + * Assert `stderr` (the installer's own `console.warn` shadow-report + * output) actually carries `expectedReport`'s rendered lines, verbatim. + * `expectedReport`/its lines are computed by CALLING the module's own + * pure `buildShadowReport`/`renderShadowReport` against the SAME on-disk + * fixture the spawned installer just produced — never a guessed/hardcoded + * literal. This is the typed-count-plus-content route the review brief + * asks for: structural comparison against a pure function's own computed + * output (mirrors this file's own `extractAtIncludeLines`/E14 pattern + * above), not prose matching. + */ + function assertReportRendered(stderr, expectedReport) { + const lines = renderShadowReport(expectedReport); + assert.ok(lines.length > 0, + 'fixture must actually be shadowed for this to be a meaningful positive assertion'); + const stripped = stripAnsi(stderr); + for (const line of lines) { + assert.ok(stripped.includes(line), + `expected installer stderr to carry the typed report line ${JSON.stringify(line)}\nstderr: ${stderr}`); + } + } + + /** + * Negative-proof counterpart to `assertReportRendered`, for fixtures where + * no report is expected. `expectedReport` is computed by calling the + * module's own pure `buildShadowReport` against the SAME on-disk fixture + * the spawned installer just produced. Asserts the typed IR itself is + * `not_shadowed`, that `renderShadowReport` therefore computes ZERO lines + * for it, and then — for every line it WOULD have computed had the IR been + * shadowed (structurally empty here) — that none of them appear in + * `stderr`. This replaces matching a hardcoded literal fragment + * (`' shadowed: the '`) with a structural comparison against + * `renderShadowReport`'s own (empty) output, so there is no longer a + * guessed string for `local/no-source-grep`/`allow-test-rule` to flag. + */ + function assertReportAbsent(stderr, expectedReport) { + assert.strictEqual(expectedReport.shadowed, false, + 'fixture must not be shadowed for this to be a meaningful negative assertion'); + assert.strictEqual(expectedReport.reason, SHADOW_REASON.NOT_SHADOWED, + `expected reason ${SHADOW_REASON.NOT_SHADOWED}, got ${expectedReport.reason}`); + const lines = renderShadowReport(expectedReport); + assert.deepStrictEqual(lines, [], + 'renderShadowReport must compute zero lines for a not_shadowed report'); + const stripped = stripAnsi(stderr); + for (const line of lines) { + assert.ok(!stripped.includes(line), + `expected installer stderr NOT to carry the typed report line ${JSON.stringify(line)}\nstderr: ${stderr}`); + } + } + + test('C1: global-then-local double install reports shadowing on the second install, exit 0', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2873-c1-')); + const projectDir = path.join(root, 'myrepo'); + fs.mkdirSync(projectDir, { recursive: true }); + t.after(() => cleanup(root)); + + const g = spawnInstall(['--claude', '--global'], root, root); + assert.strictEqual(g.exitCode, 0, `global install failed: ${g.stdout}\n${g.stderr}`); + + const l = spawnInstall(['--claude', '--local'], projectDir, root); + assert.strictEqual(l.exitCode, 0, `local install failed: ${l.stdout}\n${l.stderr}`); + + const expectedReport = buildShadowReport('claude', { home: root, cwd: projectDir }); + assert.strictEqual(expectedReport.shadowed, true); + assertReportRendered(l.stderr, expectedReport); + }); + + test('C2: local-then-global double install reports shadowing symmetrically, exit 0', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2873-c2-')); + const projectDir = path.join(root, 'myrepo'); + fs.mkdirSync(projectDir, { recursive: true }); + t.after(() => cleanup(root)); + + const l = spawnInstall(['--claude', '--local'], projectDir, root); + assert.strictEqual(l.exitCode, 0, `local install failed: ${l.stdout}\n${l.stderr}`); + + // The global install's own production `buildShadowReport(runtime)` call + // (bin/install.js) takes no injected opts — it defaults to + // `process.cwd()` to detect a coexisting LOCAL scope. Run it with cwd + // INSIDE the already-locally-installed project (the real #2218 shape: a + // developer running the global install from inside an existing + // project), or it structurally cannot see the local scope at all — + // verified empirically: cwd=root (a global install's usual cwd) never + // reports, cwd=projectDir does. + const g = spawnInstall(['--claude', '--global'], projectDir, root); + assert.strictEqual(g.exitCode, 0, `global install failed: ${g.stdout}\n${g.stderr}`); + + const expectedReport = buildShadowReport('claude', { home: root, cwd: projectDir }); + assert.strictEqual(expectedReport.shadowed, true); + assertReportRendered(g.stderr, expectedReport); + }); + + test('C3: global-only install stays quiet, exit 0 (negative proof)', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2873-c3-')); + t.after(() => cleanup(root)); + + const g = spawnInstall(['--claude', '--global'], root, root); + assert.strictEqual(g.exitCode, 0, `global install failed: ${g.stdout}\n${g.stderr}`); + + // Typed control: a single-scope fixture can never be shadowed by + // construction (buildShadowReport requires two installed scopes) — + // confirms this negative-proof fixture is not accidentally shadowed. + const controlReport = buildShadowReport('claude', { home: root, cwd: root }); + assertReportAbsent(g.stderr, controlReport); + }); + + test('C4: an install that fails before writeManifest never emits a report', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2873-c4-')); + t.after(() => cleanup(root)); + + // Structural failure, never chmod (chmod 0o000 no-ops under root — + // CONTRIBUTING.md): pre-create the global config dir's OWN path as a + // plain file. installerMigrations' lock-acquisition mkdirSync (which + // runs before ANY artifact copy, long before writeManifest at + // bin/install.js) then throws ENOTDIR/EEXIST — verified empirically, + // and works identically whether or not the test runner is root. + fs.writeFileSync(path.join(root, '.claude'), 'blocker'); + + const g = spawnInstall(['--claude', '--global'], root, root); + assert.notStrictEqual(g.exitCode, 0, + `expected the structural collision to fail the install: ${g.stdout}\n${g.stderr}`); + + const manifestPath = path.join(root, '.claude', MANIFEST_NAME); + assert.ok(!fs.existsSync(manifestPath), 'writeManifest must never have run'); + + // Same fixture the failed install just left on disk: nothing was ever + // written, so the typed IR is not_shadowed by construction — the failure + // path never reaches the report call site at all (it runs strictly after + // writeManifest). + const expectedReport = buildShadowReport('claude', { home: root, cwd: root }); + assertReportAbsent(g.stdout + g.stderr, expectedReport); + }); + + test('C5: a throwing report builder never fails the install, report suppressed', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2873-c5-')); + t.after(() => cleanup(root)); + + const g = spawnInstall(['--claude', '--global'], root, root, ['--require', SHADOW_THROWS_PRELOAD]); + assert.strictEqual(g.exitCode, 0, + `a throwing buildShadowReport must never fail the install: ${g.stdout}\n${g.stderr}`); + + const manifestPath = path.join(root, '.claude', MANIFEST_NAME); + assert.ok(fs.existsSync(manifestPath), + 'writeManifest must still have run — the report call happens strictly after it'); + + // Same single-scope fixture as C3 (writeManifest ran, but the report + // builder was preloaded to throw): the typed IR built from the real + // installer's own scope is still not_shadowed, and — because the + // injected throw is caught before renderShadowReport ever runs — no + // report text should reach stderr either. + const expectedReport = buildShadowReport('claude', { home: root, cwd: root }); + assertReportAbsent(g.stderr, expectedReport); + }); + + test('C6: re-running the same scope twice produces the same report', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2873-c6-')); + const projectDir = path.join(root, 'myrepo'); + fs.mkdirSync(projectDir, { recursive: true }); + t.after(() => cleanup(root)); + + const g = spawnInstall(['--claude', '--global'], root, root); + assert.strictEqual(g.exitCode, 0, `global install failed: ${g.stdout}\n${g.stderr}`); + + const l1 = spawnInstall(['--claude', '--local'], projectDir, root); + assert.strictEqual(l1.exitCode, 0, `first local install failed: ${l1.stdout}\n${l1.stderr}`); + const l2 = spawnInstall(['--claude', '--local'], projectDir, root); + assert.strictEqual(l2.exitCode, 0, `second local install failed: ${l2.stdout}\n${l2.stderr}`); + + const expectedReport = buildShadowReport('claude', { home: root, cwd: projectDir }); + assert.strictEqual(expectedReport.shadowed, true); + assertReportRendered(l1.stderr, expectedReport); + assertReportRendered(l2.stderr, expectedReport); + }); +}); diff --git a/tests/installer-migrations-manifest-schema.test.cjs b/tests/installer-migrations-manifest-schema.test.cjs index 761c31c09..7cdd82ea9 100644 --- a/tests/installer-migrations-manifest-schema.test.cjs +++ b/tests/installer-migrations-manifest-schema.test.cjs @@ -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)', () => { diff --git a/tests/shadow-report.security.test.cjs b/tests/shadow-report.security.test.cjs new file mode 100644 index 000000000..a16b72d2c --- /dev/null +++ b/tests/shadow-report.security.test.cjs @@ -0,0 +1,380 @@ +'use strict'; + +/** + * tests/shadow-report.security.test.cjs — hostile-manifest rendering suite + * for `install-shadow-report.cts` (#2873, epic #2866 Phase 4a — governed by + * `.gsd/phase/feat-2873-cross-scope-shadowing/40-design.md`). + * + * Implements matrix section B ("Rendering / sanitization (hostile manifest)", + * rows B1-B16) from + * `.gsd/phase/feat-2873-cross-scope-shadowing/50-test-matrix.md`. The + * matrix's own "Suites" section names this file `install-shadow-report + * .security.test.cjs`; it is shipped as `shadow-report.security.test.cjs` + * instead so its `lint-test-file-count.cjs` prefix is `shadow` rather than + * colliding with the already grandfathered, already-at-cap `install` prefix. + * + * Fixture provenance (#2371, per the matrix's own note): B1-B4/B7/B8's + * `declaredRuntime` payloads and B9-B13's manifest bodies are authored + * against the PUBLISHED `gsd-file-manifest.json` schema/format directly (raw + * JSON text or a hand-built `readManifest` result), never derived from + * `writeManifest`'s own output — a fixture the writer produced could only + * confirm what the writer already believes. + * + * Every `declaredRuntime` assertion below reads the TYPED IR field + * (`report.mismatches[0].declaredRuntime`) produced by `buildShadowReport`'s + * sanitize-at-the-render-seam guarantee — never a substring match against + * rendered prose (CONTRIBUTING → "Prohibited: Raw Text Matching on Test + * Outputs"). Where a `renderShadowReport` line is also inspected (B2, B12), + * the check is a structural security invariant (absence of a control + * character / a traversal payload), not a wording assertion. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const { buildShadowReport, renderShadowReport } = require('../gsd-core/bin/lib/install-shadow-report.cjs'); +const { resolveScope } = require('../gsd-core/bin/lib/install-scope.cjs'); +const { MANIFEST_NAME } = require('../gsd-core/bin/lib/installer-migrations.cjs'); + +// ─── Fixture helpers (mirrors tests/installed-surface-resolver.test.cjs) ─── + +const ABSENT_MANIFEST = Object.freeze({ manifestVersion: null, runtime: null, scope: null, files: {} }); + +function manifest({ manifestVersion = null, runtime = null, scope = null, files = {} } = {}) { + return { manifestVersion, runtime, scope, files }; +} + +function mkReadManifest(byConfigHome) { + return (configDir) => byConfigHome.get(configDir) ?? ABSENT_MANIFEST; +} + +function scopeHomes(runtime, home, cwd) { + const base = { runtime, env: {}, home, existsSync: () => false, cwd }; + return { + global: resolveScope({ ...base, id: 'global' }).configHome, + local: resolveScope({ ...base, id: 'local' }).configHome, + }; +} + +function baseOpts(home, cwd, overrides = {}) { + return { home, cwd, env: {}, existsSync: () => false, ...overrides }; +} + +/** Single-scope (global-only) fixture: a claude install declaring + * `declaredRuntime = runtimeVal` — always a mismatch against the requested + * 'claude' runtime unless `runtimeVal === 'claude'`, which is exactly what + * puts an entry in `report.mismatches` for every B-row below to inspect. + * `files: {}` keeps the fixture single-purpose: no trigger/shadowing signal + * competes with the mismatch signal under test. */ +function declaredRuntimeReport(runtimeVal) { + const home = '/fixture/sec-home'; + const cwd = '/fixture/sec-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: runtimeVal, scope: 'global', files: {} })], + ]); + return buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); +} + +// RLO (Right-to-Left Override, U+202E) — written as a `\u{...}` escape (not +// a literal bidi character) so the source stays plain ASCII and does not +// carry the very invisible/dangerous-Unicode class it tests. See the +// matching B17/B18 note further below for the same rationale. +const BIDI_RLO = '\u{202E}'; + +// ─── B1-B4 — hostile declaredRuntime payloads are neutralized in the IR ──── + +describe('buildShadowReport — hostile declaredRuntime is sanitized in the IR (B1-B4)', () => { + test('ansi escape is neutralized (B1)', () => { + const report = declaredRuntimeReport('\x1b[31mcursor'); + assert.strictEqual(report.mismatches.length, 1); + assert.strictEqual(report.mismatches[0].declaredRuntime, 'cursor'); + assert.ok(!report.mismatches[0].declaredRuntime.includes('\x1b')); + }); + + test('newlines cannot forge a log line (B2)', () => { + const lf = declaredRuntimeReport('cursor\nFAKE LOG LINE'); + const crlf = declaredRuntimeReport('cursor\r\nFAKE LOG LINE'); + assert.strictEqual(lf.mismatches[0].declaredRuntime, 'cursorFAKE LOG LINE'); + assert.strictEqual(crlf.mismatches[0].declaredRuntime, 'cursorFAKE LOG LINE'); + assert.ok(!lf.mismatches[0].declaredRuntime.includes('\n')); + // Every rendered line must itself be single-line — a structural check on + // the renderer's output shape, not a wording assertion. + for (const line of renderShadowReport(lf)) { + assert.ok(!line.includes('\n'), `rendered line must never carry an embedded newline: ${JSON.stringify(line)}`); + } + }); + + test('control characters are stripped (B3)', () => { + const report = declaredRuntimeReport('a\x00b\x07c'); + assert.strictEqual(report.mismatches[0].declaredRuntime, 'abc'); + }); + + test('bidi override is stripped (B4)', () => { + const report = declaredRuntimeReport(`a${BIDI_RLO}b`); + assert.strictEqual(report.mismatches[0].declaredRuntime, 'ab'); + }); +}); + +// ─── B5-B6 — the READER's 64-char cap, real fs, no double-truncation ─────── + +describe('buildShadowReport — declaredRuntime length cap, real reader (B5-B6)', () => { + function realCappedReport(t, n) { + const home = createTempDir('gsd-shadow-sec-b56-home-'); + const cwd = createTempDir('gsd-shadow-sec-b56-cwd-'); + t.after(() => { cleanup(home); cleanup(cwd); }); + const globalDir = path.join(home, '.claude'); + fs.mkdirSync(globalDir, { recursive: true }); + fs.writeFileSync(path.join(globalDir, MANIFEST_NAME), JSON.stringify({ + manifestVersion: 2, runtime: 'A'.repeat(n), scope: 'global', files: {}, + })); + return buildShadowReport('claude', { home, cwd }); + } + + test('cap-length runtime renders intact (B5, 64 chars)', (t) => { + const report = realCappedReport(t, 64); + assert.strictEqual(report.mismatches[0].declaredRuntime.length, 64); + assert.strictEqual(report.mismatches[0].declaredRuntime, 'A'.repeat(64)); + }); + + test('reader cap is respected once, not double-truncated (B6, 63/65 chars)', (t) => { + const below = realCappedReport(t, 63); + assert.strictEqual(below.mismatches[0].declaredRuntime, 'A'.repeat(63)); + + const above = realCappedReport(t, 65); + // readInstallManifest's MAX_REPORTED_RUNTIME_LENGTH truncates to 64 chars + // plus an ellipsis (65 chars total) — buildShadowReport's sanitizer never + // truncates further, so the ellipsis must survive intact. + assert.strictEqual(above.mismatches[0].declaredRuntime.length, 65); + assert.strictEqual(above.mismatches[0].declaredRuntime, `${'A'.repeat(64)}…`); + }); +}); + +// ─── B7-B8 — empty vs null declaredRuntime ───────────────────────────────── + +describe('buildShadowReport — empty vs absent declaredRuntime (B7-B8)', () => { + test('empty declared runtime renders as empty string, never as the string "null" (B7)', () => { + // Injected directly (bypassing readInstallManifest's own empty-string -> + // null normalization) so this exercises buildShadowReport/sanitizeForRender's + // OWN handling of an empty-but-present declared value, independent of + // the reader's separate empty-string rule. + const report = declaredRuntimeReport(''); + assert.strictEqual(report.mismatches.length, 1); + assert.strictEqual(report.mismatches[0].declaredRuntime, ''); + assert.notStrictEqual(report.mismatches[0].declaredRuntime, null); + }); + + test('absent declared runtime (null, v1 manifest) is omitted from the IR entirely (B8)', () => { + const home = '/fixture/b8-home'; + const cwd = '/fixture/b8-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + // scope matches probe too, so NEITHER mismatch flag fires. + [homes.global, manifest({ manifestVersion: 2, runtime: null, scope: 'global', files: {} })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(report.mismatches, [], 'a null declaredRuntime with no scope mismatch produces no mismatch entry at all'); + }); +}); + +// ─── B9-B11 — manifest document malformation, real files, real reads ────── + +describe('buildShadowReport — malformed manifest documents degrade, never throw (B9-B11)', () => { + function realSingleScopeReport(t, rawBody) { + const home = createTempDir('gsd-shadow-sec-b9-home-'); + const cwd = createTempDir('gsd-shadow-sec-b9-cwd-'); + t.after(() => { cleanup(home); cleanup(cwd); }); + const globalDir = path.join(home, '.claude'); + fs.mkdirSync(globalDir, { recursive: true }); + fs.writeFileSync(path.join(globalDir, MANIFEST_NAME), rawBody); + let report; + assert.doesNotThrow(() => { + report = buildShadowReport('claude', { home, cwd }); + }); + return report; + } + + test('non-object manifest json (0, string, array, boolean, null) all degrade to not-installed (B9)', (t) => { + for (const raw of ['0', '"a string"', '[]', 'true', 'null']) { + const report = realSingleScopeReport(t, raw); + assert.strictEqual(report.shadowed, false, `raw body ${raw} must degrade to not-installed`); + assert.deepStrictEqual(report.triggers, []); + } + }); + + test('an empty (0-byte) manifest file degrades to not-installed (B10)', (t) => { + const report = realSingleScopeReport(t, ''); + assert.strictEqual(report.shadowed, false); + assert.deepStrictEqual(report.triggers, []); + }); + + test('a CRLF manifest parses identically to its LF counterpart (B11)', (t) => { + const lfBody = [ + '{', + ' "manifestVersion": 2,', + ' "runtime": "claude",', + ' "scope": "global",', + ' "files": { "skills/gsd-plan-phase/SKILL.md": "a" }', + '}', + '', + ].join('\n'); + const lfReport = realSingleScopeReport(t, lfBody); + const crlfReport = realSingleScopeReport(t, lfBody.replace(/\n/g, '\r\n')); + assert.deepStrictEqual(crlfReport, lfReport); + }); +}); + +// ─── B12-B13 — manifest key hostility / cross-platform normalization ────── + +describe('buildShadowReport — manifest KEY hostility and normalization (B12-B13)', () => { + test('a traversal stem is rejected, never reaches a rendered trigger (B12)', () => { + const home = '/fixture/b12-home'; + const cwd = '/fixture/b12-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ + manifestVersion: 2, runtime: 'claude', scope: 'global', + files: { + 'skills/gsd-../../../x/SKILL.md': 'a', + 'skills/gsd-plan-phase/SKILL.md': 'b', + }, + })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' } })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + // Only the legitimate stem is present — the traversal key contributed nothing. + assert.deepStrictEqual(report.triggers.map((t) => t.trigger), ['gsd-plan-phase']); + for (const line of renderShadowReport(report)) { + assert.ok(!line.includes('..'), `rendered output must never carry a traversal payload: ${JSON.stringify(line)}`); + } + }); + + test('backslash-separated keys normalize on posix too (B13)', () => { + const home = '/fixture/b13-home'; + const cwd = '/fixture/b13-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'skills\\gsd-foo\\SKILL.md': 'a' } })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-foo.md': 'a' } })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(report.triggers.map((t) => t.trigger), ['gsd-foo']); + }); +}); + +// ─── B14-B16 — the lstatSync symlink guard ───────────────────────────────── + +describe('buildShadowReport — the lstatSync symlink guard (B14-B16)', () => { + test('a symlinked local config dir is not followed, injected lstatSync (B14)', () => { + const home = '/fixture/b14-home'; + const cwd = '/fixture/b14-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' } })], + // A manifest IS present at the local configHome per this readManifest + // stub — proving the guard, not the reader, is what refuses it below. + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' } })], + ]); + // Injected lstatSync reports the local configHome itself as a symlink. + const lstatSync = (p) => ({ isSymbolicLink: () => p === homes.local }); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome), lstatSync })); + assert.strictEqual(report.shadowed, false, 'the symlinked local scope must not be counted as installed, so nothing can shadow it'); + assert.deepStrictEqual(report.triggers, []); + }); + + test('a symlinked manifest file is not followed, real symlink on disk (B15)', (t) => { + const home = createTempDir('gsd-shadow-sec-b15-home-'); + const cwd = createTempDir('gsd-shadow-sec-b15-cwd-'); + const outOfTreeDir = createTempDir('gsd-shadow-sec-b15-outoftree-'); + t.after(() => { cleanup(home); cleanup(cwd); cleanup(outOfTreeDir); }); + + const globalDir = path.join(home, '.claude'); + const localDir = path.join(cwd, '.claude'); + fs.mkdirSync(globalDir, { recursive: true }); + fs.mkdirSync(localDir, { recursive: true }); + fs.writeFileSync(path.join(globalDir, MANIFEST_NAME), JSON.stringify({ + manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' }, + })); + const outOfTreeManifest = path.join(outOfTreeDir, 'real-manifest.json'); + fs.writeFileSync(outOfTreeManifest, JSON.stringify({ + manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' }, + })); + // The local config DIR is real; only the manifest FILE inside it is a + // symlink pointing OUTSIDE the config dir — proves the guard checks the + // manifest path itself, not merely the directory. + fs.symlinkSync(outOfTreeManifest, path.join(localDir, MANIFEST_NAME), 'file'); + + const report = buildShadowReport('claude', { home, cwd }); + assert.strictEqual(report.shadowed, false, 'a symlinked manifest file must never be followed, even though its target is valid, matching content'); + assert.deepStrictEqual(report.triggers, []); + }); + + test('an unsymlinked local config still reads (B16, negative proof)', (t) => { + const home = createTempDir('gsd-shadow-sec-b16-home-'); + const cwd = createTempDir('gsd-shadow-sec-b16-cwd-'); + t.after(() => { cleanup(home); cleanup(cwd); }); + + const globalDir = path.join(home, '.claude'); + const localDir = path.join(cwd, '.claude'); + fs.mkdirSync(globalDir, { recursive: true }); + fs.mkdirSync(localDir, { recursive: true }); + fs.writeFileSync(path.join(globalDir, MANIFEST_NAME), JSON.stringify({ + manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' }, + })); + fs.writeFileSync(path.join(localDir, MANIFEST_NAME), JSON.stringify({ + manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' }, + })); + + const report = buildShadowReport('claude', { home, cwd }); + assert.strictEqual(report.shadowed, true, 'the guard must not break the ordinary, unsymlinked happy path'); + assert.strictEqual(report.triggers.length, 1); + }); +}); + +// ─── B17-B18 — zalgo / zero-width, #2873 PR review Finding 2 (MINOR) ────── +// +// `sanitizeForRender` stripped ANSI, C0/C1, and bidi overrides/isolates, but +// not combining marks (U+0300-U+036F — "zalgo" text, which visually +// overflows into adjacent terminal cells) or zero-width characters (ZWSP +// U+200B, ZWNJ U+200C, ZWJ U+200D, BOM/ZWNBSP U+FEFF). Neither class is a JS +// `\s`, so both survived the 64-char cap and the whitespace-collapse step +// undetected. + +// Written as `\u{...}` escapes throughout (never literal combining/bidi/ +// zero-width characters) so the source stays plain ASCII and does not +// visually combine with adjacent punctuation in editors/diffs. +const ZALGO_COMBINING_1 = '\u{0300}'; // combining grave accent +const ZALGO_COMBINING_2 = '\u{0301}'; // combining acute accent +const ZALGO_COMBINING_3 = '\u{036F}'; // combining latin small letter x (top of range) +const ZWSP = '\u{200B}'; +const ZWNJ = '\u{200C}'; +const ZWJ = '\u{200D}'; +const BOM = '\u{FEFF}'; + +describe('buildShadowReport — hostile declaredRuntime is sanitized in the IR (B17-B18)', () => { + test('combining marks (zalgo) are stripped (B17)', () => { + const report = declaredRuntimeReport(`a${ZALGO_COMBINING_1}${ZALGO_COMBINING_2}${ZALGO_COMBINING_3}b`); + assert.strictEqual(report.mismatches.length, 1); + assert.strictEqual(report.mismatches[0].declaredRuntime, 'ab'); + assert.ok(!/[\u{0300}-\u{036F}]/u.test(report.mismatches[0].declaredRuntime)); + }); + + test('zero-width characters (ZWSP/ZWNJ/ZWJ/BOM) are stripped (B18)', () => { + const report = declaredRuntimeReport(`a${ZWSP}b${ZWNJ}c${ZWJ}d${BOM}e`); + assert.strictEqual(report.mismatches.length, 1); + assert.strictEqual(report.mismatches[0].declaredRuntime, 'abcde'); + assert.ok(!/[\u{200B}-\u{200D}\u{FEFF}]/u.test(report.mismatches[0].declaredRuntime)); + }); +}); + +// Note: the F3 property ("sanitizer output contains no character in the +// stripped class, for arbitrary input") lives in `tests/shadow-report.test.cjs` +// alongside F2 (sanitizer idempotence) — both target `sanitizeForRender` and +// share one hostile-input generator, extended for #2873 PR review Finding 2 +// (MINOR) to also emit combining marks (zalgo) and zero-width characters so +// the property actually exercises the newly-stripped classes rather than +// passing vacuously. diff --git a/tests/shadow-report.test.cjs b/tests/shadow-report.test.cjs new file mode 100644 index 000000000..fd400e7b5 --- /dev/null +++ b/tests/shadow-report.test.cjs @@ -0,0 +1,911 @@ +'use strict'; + +/** + * tests/shadow-report.test.cjs — pure IR unit suite for + * `install-shadow-report.cts`'s `buildShadowReport` (#2873, epic #2866 Phase + * 4a — governed by `.gsd/phase/feat-2873-cross-scope-shadowing/40-design.md`). + * + * Implements matrix section A (`buildShadowReport()`, rows A1-A24) and + * properties F1/F4 from `.gsd/phase/feat-2873-cross-scope-shadowing/50-test-matrix.md`. + * The matrix's own "Suites" section names this file `install-shadow-report + * .test.cjs`; it is shipped as `shadow-report.test.cjs` instead so its + * `lint-test-file-count.cjs` prefix is `shadow` (0 files before this PR, at + * the 2-file cap after it) rather than colliding with the already + * grandfathered, already-at-cap `install` prefix bucket. + * + * A21-A24 cover the per-scope truth filter (#2873 Task 1): a `full`-profile + * global install alongside a `core`-profile local install must never report + * the profile-only stems as shadowed local artifacts that do not exist on + * disk. F1 is updated in lockstep — its expected shadowed set is now the + * INTERSECTION of the two scopes' stems, not their union. + * + * F4 ("4b transform is idempotent over arbitrary bodies") targets + * `resolveSpecRootReference` (`runtime-artifact-conversion.cts`, #2873 Phase + * 4b), which has since landed — see the "F4" describe block below. + * + * Fixture strategy mirrors `tests/installed-surface-resolver.test.cjs` + * (`buildShadowReport` forwards its `opts` verbatim to + * `resolveInstalledSurfaces`): an injectable `readManifest` keyed by the + * REAL `configHome` `resolveScope` computes for a given runtime/scope, never + * a hand-typed path literal. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); +const fc = require('./helpers/fast-check-setup.cjs'); + +const { + buildShadowReport, + renderShadowReport, + sanitizeForRender, + SHADOW_REASON, +} = require('../gsd-core/bin/lib/install-shadow-report.cjs'); +const { resolveScope } = require('../gsd-core/bin/lib/install-scope.cjs'); +const { MANIFEST_NAME } = require('../gsd-core/bin/lib/installer-migrations.cjs'); +const { resolveSpecRootReference } = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); + +// ─── Fixture helpers (mirrors tests/installed-surface-resolver.test.cjs) ─── + +const ABSENT_MANIFEST = Object.freeze({ manifestVersion: null, runtime: null, scope: null, files: {} }); + +function manifest({ manifestVersion = null, runtime = null, scope = null, files = {} } = {}) { + return { manifestVersion, runtime, scope, files }; +} + +function mkReadManifest(byConfigHome) { + return (configDir) => byConfigHome.get(configDir) ?? ABSENT_MANIFEST; +} + +function scopeHomes(runtime, home, cwd) { + const base = { runtime, env: {}, home, existsSync: () => false, cwd }; + return { + global: resolveScope({ ...base, id: 'global' }).configHome, + local: resolveScope({ ...base, id: 'local' }).configHome, + }; +} + +function baseOpts(home, cwd, overrides = {}) { + return { home, cwd, env: {}, existsSync: () => false, ...overrides }; +} + +/** `commands/gsd/*.md` stems shipped by the real repo — used so A1's "71 + * entries" tracks the real roster instead of a hardcoded, driftable count. */ +const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); +const REAL_STEMS = fs.readdirSync(REAL_COMMANDS_DIR) + .filter((f) => f.endsWith('.md')) + .map((f) => f.slice(0, -3)) + .sort(); + +function skillFilesFor(stems) { + const files = {}; + for (const s of stems) files[`skills/gsd-${s}/SKILL.md`] = 'x'; + return files; +} + +function commandFilesFor(stems) { + const files = {}; + for (const s of stems) files[`commands/gsd-${s}.md`] = 'x'; + return files; +} + +/** Build a claude coexistence fixture: `stems` installed as global skills AND + * local commands (so every one of them is a shadowed trigger). */ +function coexistenceOpts(home, cwd, stems, overrides = {}) { + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(stems) })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(stems) })], + ]); + return baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome), ...overrides }); +} + +// ─── A1-A6 — shape happy/negative paths ──────────────────────────────────── + +describe('buildShadowReport — shape (A1-A6)', () => { + test('reports shadowing for a claude coexistence, full real roster (A1)', () => { + const home = '/fixture/a1-home'; + const cwd = '/fixture/a1-cwd'; + const report = buildShadowReport('claude', coexistenceOpts(home, cwd, REAL_STEMS)); + assert.strictEqual(report.shadowed, true); + assert.strictEqual(report.reason, SHADOW_REASON.SCOPE_SHADOWED); + assert.strictEqual(report.triggers.length, REAL_STEMS.length); + assert.deepStrictEqual(report.winner, { kind: 'skills', scope: 'global' }); + assert.deepStrictEqual(report.shadowedSide, { kind: 'commands', scope: 'local' }); + assert.deepStrictEqual(report.triggers.map((t) => t.trigger).sort(), REAL_STEMS.map((s) => `gsd-${s}`)); + }); + + test('no report for a single scope, global only (A2)', () => { + const home = '/fixture/a2-home'; + const cwd = '/fixture/a2-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['plan-phase']) })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.shadowed, false); + assert.strictEqual(report.reason, SHADOW_REASON.NOT_SHADOWED); + assert.deepStrictEqual(report.triggers, []); + }); + + test('no report for local-only (A3)', () => { + const home = '/fixture/a3-home'; + const cwd = '/fixture/a3-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['plan-phase']) })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.shadowed, false); + assert.deepStrictEqual(report.triggers, []); + }); + + test('same-kind shadowing is reported as override, not a vanished tree (A4)', () => { + const home = '/fixture/a4-home'; + const cwd = '/fixture/a4-cwd'; + const homes = scopeHomes('cursor', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'cursor', scope: 'global', files: skillFilesFor(['plan-phase']) })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'cursor', scope: 'local', files: skillFilesFor(['plan-phase']) })], + ]); + const report = buildShadowReport('cursor', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.shadowed, true); + assert.strictEqual(report.kindsDiffer, false); + assert.deepStrictEqual(report.winner, { kind: 'skills', scope: 'global' }); + assert.deepStrictEqual(report.shadowedSide, { kind: 'skills', scope: 'local' }); + }); + + test('windsurf asymmetry does not collide (A5)', () => { + const home = '/fixture/a5-home'; + const cwd = '/fixture/a5-cwd'; + const homes = scopeHomes('windsurf', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'windsurf', scope: 'global', files: { 'agents/gsd-planner.md': 'a' } })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'windsurf', scope: 'local', files: { 'workflows/gsd-plan-phase.md': 'a' } })], + ]); + const report = buildShadowReport('windsurf', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.shadowed, false); + }); + + test('same config home is not self-shadowing (A6)', () => { + const shared = '/fixture/a6-shared-home'; + const homes = scopeHomes('claude', shared, shared); + assert.strictEqual(homes.global, homes.local, 'fixture assumption: both scopes collapse to one configHome'); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['plan-phase']) })], + ]); + const report = buildShadowReport('claude', baseOpts(shared, shared, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.shadowed, false); + }); +}); + +// ─── A7-A10 — SAMPLE_LIMIT boundary (limit-1, limit, limit+1) ────────────── + +describe('buildShadowReport — sample-limit boundary (A7-A10)', () => { + test('zero triggers renders nothing (A7, limit-1 in the sense of "below any sample")', () => { + const home = '/fixture/a7-home'; + const cwd = '/fixture/a7-cwd'; + const homes = scopeHomes('claude', home, cwd); + // Both scopes installed (manifestVersion set) but with an empty `files` + // map each — `deriveStemsFromManifest` short-circuits to `[]` for an + // empty `files` BEFORE resolving a layout at all (installed-surface- + // resolver.cts's C13), so the stem union across both scopes is empty and + // no trigger is ever synthesized. NOT a disjoint-stems fixture: because + // `resolveInstalledSurfaces` unions stems across every INSTALLED scope + // (not per-scope), two scopes installed with genuinely DIFFERENT, + // non-empty stem sets still produce a shadowed entry for each stem in + // the union — see A12's comment for the same mechanism. + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: {} })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: {} })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.shadowed, false); + assert.deepStrictEqual(report.triggers, []); + assert.deepStrictEqual(renderShadowReport(report), []); + }); + + test('single trigger has no overflow tail (A8, limit=1)', () => { + const home = '/fixture/a8-home'; + const cwd = '/fixture/a8-cwd'; + const report = buildShadowReport('claude', coexistenceOpts(home, cwd, ['solo'])); + assert.strictEqual(report.triggers.length, 1); + const lines = renderShadowReport(report); + // header + exactly one sample line, no "...and N more" tail, no mismatch notes. + assert.strictEqual(lines.length, 2); + }); + + test('sample limit exactly, 5 shadowed (A9)', () => { + const home = '/fixture/a9-home'; + const cwd = '/fixture/a9-cwd'; + const stems = ['s1', 's2', 's3', 's4', 's5']; + const report = buildShadowReport('claude', coexistenceOpts(home, cwd, stems)); + assert.strictEqual(report.triggers.length, 5); + const lines = renderShadowReport(report); + // header + 5 samples, still no tail. + assert.strictEqual(lines.length, 6); + }); + + test('sample limit plus one, 6 shadowed (A10)', () => { + const home = '/fixture/a10-home'; + const cwd = '/fixture/a10-cwd'; + const stems = ['s1', 's2', 's3', 's4', 's5', 's6']; + const report = buildShadowReport('claude', coexistenceOpts(home, cwd, stems)); + assert.strictEqual(report.triggers.length, 6); + const lines = renderShadowReport(report); + // header + 5 samples + one overflow-tail line. + assert.strictEqual(lines.length, 7); + }); +}); + +// ─── A11-A13 — manifest content edge cases ───────────────────────────────── + +describe('buildShadowReport — manifest content edge cases (A11-A13)', () => { + test('v1 manifest still reports shadowing, identical to v2, no reinstall signal in the IR (A11)', () => { + const home = '/fixture/a11-home'; + const cwd = '/fixture/a11-cwd'; + const homesV1 = scopeHomes('claude', home, cwd); + const byConfigHomeV1 = new Map([ + [homesV1.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['plan-phase']) })], + // v1: no manifestVersion key at all, normalized to 1; no declared runtime/scope. + [homesV1.local, manifest({ manifestVersion: 1, runtime: null, scope: null, files: commandFilesFor(['plan-phase']) })], + ]); + const reportV1 = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHomeV1) })); + + const byConfigHomeV2 = new Map([ + [homesV1.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['plan-phase']) })], + [homesV1.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['plan-phase']) })], + ]); + const reportV2 = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHomeV2) })); + + assert.strictEqual(reportV1.shadowed, true); + assert.deepStrictEqual(reportV1, reportV2, 'a v1-backed report must be structurally identical to its v2 counterpart'); + + // No reinstall/version signal anywhere in the IR's shape. + assert.ok(!('manifestVersion' in reportV1)); + for (const trig of reportV1.triggers) assert.ok(!('manifestVersion' in trig)); + for (const m of reportV1.mismatches) assert.ok(!('manifestVersion' in m)); + }); + + test('empty manifest yields no triggers (A12)', () => { + const home = '/fixture/a12-home'; + const cwd = '/fixture/a12-cwd'; + const homes = scopeHomes('claude', home, cwd); + // Deliberately only ONE scope present, with an empty `files` map — the + // clean exercise of the empty-files short-circuit (deriveStemsFromManifest's + // C13) in isolation. A COEXISTENCE fixture (both scopes installed, one + // side's `files: {}`) does NOT stay `shadowed: false`: because + // `resolveInstalledSurfaces` unions stems across every scope it counts as + // installed (manifestVersion set, regardless of that scope's own file + // count) rather than per-scope, a real stem contributed by the OTHER, + // populated scope still gets a synthesized trigger at this empty one — + // see `installed-surface-resolver.cts`'s `stemUnion` computation. That is + // established, already-tested Phase 3 (#2872) behavior (the roster is + // assumed uniform across installed scopes), not something this row + // exercises. + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: {} })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.shadowed, false); + assert.deepStrictEqual(report.triggers, []); + }); + + test('unreadable manifest degrades, never throws (A13)', () => { + const home = '/fixture/a13-home'; + const cwd = '/fixture/a13-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['plan-phase']) })], + ]); + const readManifest = (configDir) => { + if (configDir === homes.local) throw new Error('EACCES: permission denied'); + return byConfigHome.get(configDir) ?? ABSENT_MANIFEST; + }; + let report; + assert.doesNotThrow(() => { + report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest })); + }); + assert.strictEqual(report.shadowed, false); + }); +}); + +// ─── A14-A15 — declared-runtime/scope mismatch surfaced, not corrected ───── + +describe('buildShadowReport — mismatches are reported, never corrected (A14-A15)', () => { + test('declared runtime mismatch is surfaced (A14)', () => { + const home = '/fixture/a14-home'; + const cwd = '/fixture/a14-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'cursor', scope: 'global', files: skillFilesFor(['plan-phase']) })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['plan-phase']) })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.mismatches.length, 1); + assert.strictEqual(report.mismatches[0].scope, 'global'); + assert.strictEqual(report.mismatches[0].declaredRuntime, 'cursor'); + assert.strictEqual(report.mismatches[0].declaredRuntimeMatchesProbe, false); + }); + + test('declared scope mismatch is surfaced (A15)', () => { + const home = '/fixture/a15-home'; + const cwd = '/fixture/a15-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: skillFilesFor(['plan-phase']) })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['plan-phase']) })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.mismatches.length, 1); + assert.strictEqual(report.mismatches[0].scope, 'global'); + assert.strictEqual(report.mismatches[0].declaredScope, 'local'); + assert.strictEqual(report.mismatches[0].declaredScopeMatchesProbe, false); + }); +}); + +// ─── A16-A17 — malformed runtime degrades, never propagates ─────────────── + +describe('buildShadowReport — non-installable / unknown runtime degrades (A16-A17)', () => { + test('non-installable runtime degrades to no report (A16, vscode)', () => { + const home = '/fixture/a16-home'; + const cwd = '/fixture/a16-cwd'; + let report; + assert.doesNotThrow(() => { + report = buildShadowReport('vscode', baseOpts(home, cwd, { readManifest: mkReadManifest(new Map()) })); + }); + assert.strictEqual(report.reason, SHADOW_REASON.RESOLVER_UNAVAILABLE); + assert.strictEqual(report.shadowed, false); + assert.deepStrictEqual(renderShadowReport(report), []); + }); + + test('unknown runtime degrades to no report (A17)', () => { + const home = '/fixture/a17-home'; + const cwd = '/fixture/a17-cwd'; + let report; + assert.doesNotThrow(() => { + report = buildShadowReport('not-a-real-runtime-xyz', baseOpts(home, cwd, { readManifest: mkReadManifest(new Map()) })); + }); + assert.strictEqual(report.reason, SHADOW_REASON.RESOLVER_UNAVAILABLE); + assert.strictEqual(report.shadowed, false); + }); +}); + +// ─── A18 — caller mutation cannot corrupt a later call ───────────────────── + +describe('buildShadowReport — independence across calls (A18)', () => { + test('report is not shared across calls', () => { + const home = '/fixture/a18-home'; + const cwd = '/fixture/a18-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'cursor', scope: 'global', files: skillFilesFor(['plan-phase']) })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['plan-phase']) })], + ]); + const opts = baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }); + + const first = buildShadowReport('claude', opts); + const pristine = JSON.parse(JSON.stringify(first)); + + first.winner.kind = 'HACKED'; + first.triggers[0].trigger = 'HACKED'; + first.triggers.push({ trigger: 'INJECTED' }); + first.mismatches[0].declaredRuntime = 'HACKED'; + first.mismatches.push({ scope: 'INJECTED' }); + + const second = buildShadowReport('claude', opts); + assert.deepStrictEqual(second, pristine, 'a second call must be unaffected by mutation of the first result'); + }); +}); + +// ─── A19 — the production call shape ─────────────────────────────────────── + +describe('buildShadowReport — production call shape (A19)', () => { + test('production call shape resolves, matches the injected-dep rows\' shape', (t) => { + const home = createTempDir('gsd-shadow-a19-home-'); + const cwd = createTempDir('gsd-shadow-a19-cwd-'); + t.after(() => { cleanup(home); cleanup(cwd); }); + + const globalDir = path.join(home, '.claude'); + const localDir = path.join(cwd, '.claude'); + fs.mkdirSync(globalDir, { recursive: true }); + fs.mkdirSync(localDir, { recursive: true }); + fs.writeFileSync(path.join(globalDir, MANIFEST_NAME), JSON.stringify({ + manifestVersion: 2, runtime: 'claude', scope: 'global', + files: skillFilesFor(['plan-phase']), + })); + fs.writeFileSync(path.join(localDir, MANIFEST_NAME), JSON.stringify({ + manifestVersion: 2, runtime: 'claude', scope: 'local', + files: commandFilesFor(['plan-phase']), + })); + + let report; + assert.doesNotThrow(() => { + // The exact production call shape: no injected registry, no injected + // readManifest — real fs, real capability registry. + report = buildShadowReport('claude', { home, cwd }); + }); + assert.strictEqual(report.shadowed, true); + assert.deepStrictEqual(report.winner, { kind: 'skills', scope: 'global' }); + assert.deepStrictEqual(report.shadowedSide, { kind: 'commands', scope: 'local' }); + assert.strictEqual(report.triggers.length, 1); + assert.deepStrictEqual( + Object.keys(report).sort(), + ['kindsDiffer', 'mismatches', 'reason', 'runtime', 'shadowed', 'shadowedSide', 'triggers', 'winner'], + ); + }); +}); + +// ─── A20 — frozen reason-code enum key set is locked ─────────────────────── + +describe('SHADOW_REASON (A20)', () => { + test('reason enum key set is locked', () => { + assert.deepStrictEqual( + Object.keys(SHADOW_REASON).sort(), + ['NOT_SHADOWED', 'RESOLVER_UNAVAILABLE', 'SCOPE_SHADOWED'], + ); + }); + + test('is frozen', () => { + assert.strictEqual(Object.isFrozen(SHADOW_REASON), true); + }); +}); + +// ─── A21-A24 — per-scope truth filter (cross-scope stem-union false positive) ─ + +describe('buildShadowReport — per-scope truth filter (A21-A24)', () => { + test('both scopes carry the same stems: every shadowed trigger reported (A21)', () => { + const home = '/fixture/a21-home'; + const cwd = '/fixture/a21-cwd'; + const stems = ['plan-phase', 'milestone-complete', 'phase-create']; + const report = buildShadowReport('claude', coexistenceOpts(home, cwd, stems)); + assert.strictEqual(report.shadowed, true); + assert.deepStrictEqual(report.triggers.map((t) => t.trigger).sort(), stems.map((s) => `gsd-${s}`).sort()); + }); + + test('global strict superset of local (full vs core profile): only the intersection is reported (A22)', () => { + const home = '/fixture/a22-home'; + const cwd = '/fixture/a22-cwd'; + const homes = scopeHomes('claude', home, cwd); + // global = 'full' profile (a, b, c) — local = 'core' profile (a only). + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['a', 'b', 'c']) })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['a']) })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.shadowed, true); + // Only 'a' is a REAL local artifact — 'b' and 'c' must never be reported + // as shadowed local commands; there is no local artifact for either. + assert.deepStrictEqual(report.triggers.map((t) => t.trigger), ['gsd-a']); + }); + + test('local has a stem global does not: not reported as shadowed (A23)', () => { + const home = '/fixture/a23-home'; + const cwd = '/fixture/a23-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['a']) })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['a', 'z']) })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.shadowed, true); + // 'z' exists ONLY at local (no global artifact "wins" it) — must not + // appear in the shadowed set at all. + assert.deepStrictEqual(report.triggers.map((t) => t.trigger), ['gsd-a']); + }); + + test('disjoint stem sets: shadowed is false (A24)', () => { + const home = '/fixture/a24-home'; + const cwd = '/fixture/a24-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['a', 'b']) })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['x', 'y']) })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.shadowed, false); + assert.deepStrictEqual(report.triggers, []); + }); +}); + +// ─── F1 — bijective property: every trigger has exactly one winner ──────── + +describe('buildShadowReport — property (F1)', () => { + test('every trigger has exactly one winner', () => { + const stemArb = fc.stringMatching(/^[a-z0-9]{1,6}(-[a-z0-9]{1,6}){0,2}$/); + const setArb = fc.uniqueArray(stemArb, { maxLength: 6 }); + + fc.assert( + fc.property(setArb, setArb, (globalStems, localStems) => { + const home = '/fixture/f1-home'; + const cwd = '/fixture/f1-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(globalStems) })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(localStems) })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + + // Both scopes are always "installed" here (manifestVersion set + // regardless of file-list length), so `resolveOneRuntime`'s + // `stemUnion` still synthesizes a candidate trigger for every stem + // observed at EITHER scope. `buildShadowReport`'s per-scope truth + // filter (#2873 Task 1 — see install-shadow-report.cts's module + // comment) then narrows that down to the INTERSECTION: a trigger is + // only reported when a real artifact exists at BOTH scopes. + const expectedShadowed = globalStems + .filter((s) => localStems.includes(s)) + .map((s) => `gsd-${s}`) + .sort(); + const actualShadowed = report.triggers.map((t) => t.trigger).sort(); + assert.deepStrictEqual(actualShadowed, expectedShadowed); + + // Bijection: each shadowed trigger names exactly one winner (kind,scope). + for (const trig of report.triggers) { + assert.strictEqual(trig.winnerKind, 'skills'); + assert.strictEqual(trig.winnerScope, 'global'); + assert.strictEqual(trig.shadowedKind, 'commands'); + assert.strictEqual(trig.shadowedScope, 'local'); + } + // No trigger name appears twice in the shadowed set. + assert.strictEqual(new Set(actualShadowed).size, actualShadowed.length); + }), + // Explicit seed + bounded numRuns (CONTRIBUTING: unseeded property + // tests are a review blocker). On failure, fast-check's thrown error + // carries the pinned seed and the shrunk counterexample needed to + // replay deterministically. + { seed: 20260814, numRuns: 50 }, + ); + }); +}); + +// ─── F2/F3 — sanitizeForRender properties (idempotence, stripped-class-free) ─ + +describe('sanitizeForRender — properties (F2, F3)', () => { + // Explicit seed + bounded numRuns (CONTRIBUTING: unseeded property tests + // are a review blocker), matching F1/F4's seed above. + const SEED = 20260814; + const NUM_RUNS = 300; + + // Hostile-input generator: ANSI CSI/OSC escape sequences, C0 controls + // (including bare \x00 and a lone unterminated \x1b), DEL/C1, Unicode bidi + // embedding/override + isolate controls, combining marks ("zalgo", + // U+0300-U+036F), zero-width characters (ZWSP/ZWNJ/ZWJ/BOM), astral-plane + // characters (surrogate-pair-backed — real emoji/supplementary-plane text, + // not just printable ASCII), CRLF/LF/CR newlines, and plain text — + // interleaved so a single generated string usually mixes several hostile + // classes at once, per the brief's "not just printable ASCII, or the + // properties are vacuous" requirement. + // + // #2873 PR review Finding 2 (MINOR): `sanitizeForRender` originally + // stripped ANSI/control/bidi only, missing combining marks and zero-width + // characters — neither is a JS `\s`, so both survived the 64-char cap and + // the whitespace-collapse step undetected. `combiningArb`/`zeroWidthArb` + // and the extended `STRIPPED_CLASS_RE` below close that generator gap. + const c0ControlArb = fc.integer({ min: 0x00, max: 0x1f }).map((c) => String.fromCharCode(c)); + const delC1Arb = fc.integer({ min: 0x7f, max: 0x9f }).map((c) => String.fromCharCode(c)); + const ansiCsiArb = fc.constantFrom('\x1b[31m', '\x1b[0m', '\x1b[2K', '\x1b[1;37;40m'); + const ansiOscArb = fc.constantFrom('\x1b]0;title\x07', '\x1b]8;;http://example\x1b\\'); + // Bidi embedding/override controls (U+202A-U+202E) and isolates + // (U+2066-U+2069), written as `\u{...}` escapes rather than literal + // characters — see the #2873 PR review Finding 2 note above. + const BIDI_LRE = '\u{202A}'; // Left-to-Right Embedding + const BIDI_RLE = '\u{202B}'; // Right-to-Left Embedding + const BIDI_PDF = '\u{202C}'; // Pop Directional Formatting + const BIDI_LRO = '\u{202D}'; // Left-to-Right Override + const BIDI_RLO = '\u{202E}'; // Right-to-Left Override + const BIDI_LRI = '\u{2066}'; // Left-to-Right Isolate + const BIDI_RLI = '\u{2067}'; // Right-to-Left Isolate + const BIDI_FSI = '\u{2068}'; // First Strong Isolate + const BIDI_PDI = '\u{2069}'; // Pop Directional Isolate + const bidiArb = fc.constantFrom( + BIDI_LRE, BIDI_RLE, BIDI_PDF, BIDI_LRO, BIDI_RLO, // embedding/override + BIDI_LRI, BIDI_RLI, BIDI_FSI, BIDI_PDI, // isolates + ); + const combiningArb = fc.constantFrom('\u{0300}', '\u{0301}', '\u{0302}', '\u{036F}'); + const zeroWidthArb = fc.constantFrom('\u{200B}', '\u{200C}', '\u{200D}', '\u{FEFF}'); + const astralArb = fc.constantFrom('\u{1F600}', '\u{1F4A9}', '\u{10000}', '\u{1F469}\u{200D}\u{1F4BB}'); + const newlineArb = fc.constantFrom('\n', '\r\n', '\r'); + const plainArb = fc.string({ maxLength: 12 }); + + const hostileChunkArb = fc.oneof( + c0ControlArb, delC1Arb, ansiCsiArb, ansiOscArb, bidiArb, combiningArb, zeroWidthArb, astralArb, newlineArb, plainArb, + ); + const hostileStringArb = fc.array(hostileChunkArb, { maxLength: 10 }).map((parts) => parts.join('')); + + // The stripped classes sanitizeForRender documents (ANSI escapes, C0 + // controls + DEL/C1, Unicode bidi override/isolate, combining marks, + // zero-width characters), checked with an INDEPENDENT regex here rather + // than re-requiring the module's private ANSI_RE/CONTROL_RE/BIDI_RE/ + // COMBINING_MARK_RE/ZERO_WIDTH_RE — so F3 is a real invariant check + // against the module's documented contract, not a tautology against its + // own internals. + // eslint-disable-next-line no-control-regex, no-misleading-character-class + const STRIPPED_CLASS_RE = /[\x00-\x1f\x7f-\x9f\u{202A}-\u{202E}\u{2066}-\u{2069}\u{0300}-\u{036F}\u{200B}-\u{200D}\u{FEFF}]/u; + + test('sanitizer is idempotent: s(s(x)) === s(x) for arbitrary strings (F2)', () => { + let changedCount = 0; + fc.assert( + fc.property(hostileStringArb, (input) => { + const once = sanitizeForRender(input); + if (once !== input) changedCount += 1; + const twice = sanitizeForRender(once); + assert.strictEqual( + twice, + once, + `not idempotent — input: ${JSON.stringify(input)}\nonce: ${JSON.stringify(once)}\ntwice: ${JSON.stringify(twice)}`, + ); + }), + { seed: SEED, numRuns: NUM_RUNS }, + ); + // Non-vacuity (mirrors F4's real-shape-generator rationale above): prove + // the generator actually produced input sanitizeForRender changed at + // least once, or this property would pass trivially over inert strings. + assert.ok(changedCount > 0, 'generator never produced a string sanitizeForRender actually changed — F2 would be vacuous'); + }); + + test('sanitizer output never contains a stripped-class character, for arbitrary input (F3)', () => { + let hostileInputCount = 0; + fc.assert( + fc.property(hostileStringArb, (input) => { + if (STRIPPED_CLASS_RE.test(input)) hostileInputCount += 1; + const output = sanitizeForRender(input); + assert.ok( + output === null || !STRIPPED_CLASS_RE.test(output), + `stripped-class character survived sanitization — input: ${JSON.stringify(input)}\noutput: ${JSON.stringify(output)}`, + ); + }), + { seed: SEED, numRuns: NUM_RUNS }, + ); + // Non-vacuity: prove the generator actually exercised at least one + // stripped-class character, or F3 would hold trivially over clean input. + assert.ok(hostileInputCount > 0, 'generator never produced a stripped-class character — F3 would be vacuous'); + }); +}); + +// ─── E1-E12 — resolveSpecRootReference direct unit coverage ─────────────── +// Matrix section E (`.gsd/phase/feat-2873-cross-scope-shadowing/50-test-matrix.md`). +// Only the E13/E14 installed-output integration pair (in +// tests/install-runtime-artifacts.test.cjs) and F4's idempotence property +// (above) touched this exported function before this block — these rows +// exercise it DIRECTLY, one behavior at a time. +describe('resolveSpecRootReference — direct unit coverage (E1-E12)', () => { + test('global skill body: the include is replaced by the two-step imperative form naming both candidates (E1)', () => { + const body = '@~/.claude/gsd-core/workflows/plan-phase.md'; + const result = resolveSpecRootReference(body); + assert.notStrictEqual(result, body); + assert.ok(!result.startsWith('@'), 'the static @-include must be gone'); + assert.ok( + result.includes('.claude/gsd-core/workflows/plan-phase.md') && result.includes('~/.claude/gsd-core/workflows/plan-phase.md'), + `expected both the project-local and global candidate paths named in: ${JSON.stringify(result)}`, + ); + }); + + test('local command body: byte-identical to today (E2)', () => { + // The REAL literal a local claude install emits for this same source + // line (verified empirically against a real --local install): the + // installer's path-prefix rewrite resolves the local scope's absolute + // config dir, never `~`, so this never has the `@~/.claude/` shape + // WORKFLOW_SPEC_ROOT_INCLUDE_RE requires in the first place — a genuine + // non-qualifying condition, not a hand-waved one. + const body = '@/Users/dev/myrepo/.claude/gsd-core/workflows/plan-phase.md'; + assert.strictEqual(resolveSpecRootReference(body), body); + }); + + test('every non-claude runtime, both scopes: byte-identical to today (E3)', () => { + // Real literal shapes emitted for other runtimes (verified empirically + // against a real --cursor --global install): no `.claude/` segment at + // all, so none of them ever match the claude-only spec-root regex. + const cursorGlobal = '@$HOME/gsd-core/workflows/plan-phase.md'; + const genericLocal = '@./gsd-core/workflows/plan-phase.md'; + assert.strictEqual(resolveSpecRootReference(cursorGlobal), cursorGlobal); + assert.strictEqual(resolveSpecRootReference(genericLocal), genericLocal); + }); + + test('a references/ include is a different spec root and stays static (E4)', () => { + const body = '@~/.claude/gsd-core/references/ui-brand.md'; + assert.strictEqual(resolveSpecRootReference(body), body); + }); + + test('an @.planning/… include is untouched (E5)', () => { + const body = '@.planning/notes.md'; + assert.strictEqual(resolveSpecRootReference(body), body); + }); + + test('an include inside a fenced code block is untouched, byte-identical (E6)', () => { + const body = ['```', '@~/.claude/gsd-core/workflows/plan-phase.md', '```'].join('\n'); + assert.strictEqual(resolveSpecRootReference(body), body); + }); + + test('an include mentioned in inline backticks is untouched, byte-identical (E7)', () => { + const body = 'See `@~/.claude/gsd-core/workflows/plan-phase.md` for the spec.'; + assert.strictEqual(resolveSpecRootReference(body), body); + }); + + test('a body with no workflow include is a no-op (E8)', () => { + const body = 'Just some ordinary command prose with no includes at all.'; + assert.strictEqual(resolveSpecRootReference(body), body); + }); + + test('two independent workflow includes both resolve (E9)', () => { + const body = [ + '@~/.claude/gsd-core/workflows/plan-phase.md', + '@~/.claude/gsd-core/workflows/execute-phase.md', + ].join('\n'); + const result = resolveSpecRootReference(body); + assert.ok(!result.includes('@~/.claude/gsd-core/workflows/plan-phase.md')); + assert.ok(!result.includes('@~/.claude/gsd-core/workflows/execute-phase.md')); + assert.ok(result.includes('.claude/gsd-core/workflows/plan-phase.md')); + assert.ok(result.includes('.claude/gsd-core/workflows/execute-phase.md')); + }); + + test('prose merely mentioning gsd-core/workflows/x.md is untouched (E10)', () => { + const body = 'See gsd-core/workflows/plan-phase.md for background on how this works.'; + assert.strictEqual(resolveSpecRootReference(body), body); + }); + + test('a CRLF body emits identically to LF, no orphaned \\r (E11)', () => { + const bodyLf = '@~/.claude/gsd-core/workflows/plan-phase.md\nSecond line.'; + const bodyCrlf = '@~/.claude/gsd-core/workflows/plan-phase.md\r\nSecond line.'; + const resultLf = resolveSpecRootReference(bodyLf); + const resultCrlf = resolveSpecRootReference(bodyCrlf); + assert.strictEqual(resultCrlf, resultLf.replace('\n', '\r\n')); + // Every `\r` in the result must be immediately followed by `\n` — an + // orphaned CR (one not paired with the LF that owns it) would mean the + // transform dropped or duplicated a line-ending byte. + assert.ok(!/\r(?!\n)/.test(resultCrlf), `orphaned CR found in: ${JSON.stringify(resultCrlf)}`); + }); + + test('applying the transform twice over an installed tree is idempotent (E12)', () => { + const body = 'intro\n@~/.claude/gsd-core/workflows/plan-phase.md\noutro'; + const once = resolveSpecRootReference(body); + const twice = resolveSpecRootReference(once); + assert.strictEqual(twice, once); + }); +}); + +// ─── E-rows — resolveSpecRootReference fence-detection regressions ──────── +// (#2873 PR review Finding 1, MEDIUM): the hand-rolled `FENCE_DELIMITER_RE` +// tracker toggled open/closed on ANY delimiter line regardless of type, +// which is wrong under CommonMark (a closer must share the opener's +// delimiter character and have run length >= the opener's). The fix reuses +// `scanFencedBlocks` (`markdown-sectionizer.cts`). These cases pin the exact +// failure the review constructed plus the sibling CommonMark edge cases +// named in the review (nested fences, an unterminated fence, and a +// longer-run opener closed by a too-short run). +describe('resolveSpecRootReference — unit (fence detection, #2873 review Finding 1)', () => { + test('mismatched fence types (``` opened, ~~~ inside, ``` closes) leave BOTH includes untouched', () => { + const body = [ + '```', + '@~/.claude/gsd-core/workflows/alpha.md', + '~~~', + '@~/.claude/gsd-core/workflows/beta.md', + '```', + ].join('\n'); + + const result = resolveSpecRootReference(body); + + assert.strictEqual(result, body, 'a ``` fence is not closed by a ~~~ line — both includes must stay inside the one open block'); + assert.ok(!result.includes('To load this command'), 'no rewrite marker should appear when both includes are fenced'); + }); + + test('nested fences (outer run longer than an inner same-char run) leave the enclosed include untouched', () => { + const body = [ + '````', + '```', + '@~/.claude/gsd-core/workflows/nested.md', + '```', + '````', + ].join('\n'); + + const result = resolveSpecRootReference(body); + + assert.strictEqual(result, body, 'the inner 3-backtick lines are content, not closers, for a 4-backtick opener'); + }); + + test('an unterminated fence covers to end-of-string, leaving the include untouched', () => { + const body = [ + '```', + '@~/.claude/gsd-core/workflows/orphan.md', + ].join('\n'); + + const result = resolveSpecRootReference(body); + + assert.strictEqual(result, body, 'a fence with no closer is still open through EOF'); + }); + + test('a fence opened with a longer run (````) is NOT closed by a shorter run (```)', () => { + const body = [ + '````', + '@~/.claude/gsd-core/workflows/longshort.md', + '```', + '@~/.claude/gsd-core/workflows/other.md', + '````', + ].join('\n'); + + const result = resolveSpecRootReference(body); + + assert.strictEqual( + result, + body, + 'a 3-backtick line cannot close a 4-backtick opener per CommonMark run-length rule — both includes stay inside the one fence', + ); + }); +}); + +// ─── F4 — resolveSpecRootReference is idempotent over arbitrary bodies ──── + +describe('resolveSpecRootReference — property (F4)', () => { + test('spec-root transform is idempotent over arbitrary bodies', () => { + // A bare fc.string() body would almost never contain the exact + // `@~/.claude/gsd-core/workflows/.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/.md` without the + // line-start `@` (untouched), and arbitrary free text. + const stemArb = fc.stringMatching(/^[a-z][a-z0-9._-]{0,20}$/); + const includeLineArb = stemArb.map((s) => `@~/.claude/gsd-core/workflows/${s}.md`); + const proseMentionArb = stemArb.map((s) => `See gsd-core/workflows/${s}.md for background.`); + const planningIncludeArb = stemArb.map((s) => `@.planning/${s}.md`); + const fencedIncludeArb = fc.tuple(fc.constantFrom('```', '~~~'), stemArb).map( + ([fence, s]) => `${fence}\n@~/.claude/gsd-core/workflows/${s}.md\n${fence}`, + ); + // #2873 review Finding 1: a same-type-only generator is structurally + // incapable of producing the mismatched-delimiter defect the review + // constructed (a ``` fence "closed" by a ~~~ line). Also emit + // mismatched-type and nested-run shapes so the property actually + // exercises the CommonMark same-type/same-or-longer-run closing rule, + // not just the trivial same-fence-twice case. + const mismatchedFencedIncludeArb = fc.tuple( + fc.constantFrom(['```', '~~~'], ['~~~', '```']), + stemArb, + stemArb, + ).map( + ([[openFence, midFence], s1, s2]) => + `${openFence}\n@~/.claude/gsd-core/workflows/${s1}.md\n${midFence}\n@~/.claude/gsd-core/workflows/${s2}.md\n${openFence}`, + ); + const nestedFencedIncludeArb = fc.tuple(fc.constantFrom('```', '~~~'), stemArb).map( + ([fenceChar, s]) => { + const inner = fenceChar.repeat(3); + const outer = fenceChar.repeat(4); + return `${outer}\n${inner}\n@~/.claude/gsd-core/workflows/${s}.md\n${inner}\n${outer}`; + }, + ); + const plainTextArb = fc.string({ maxLength: 40 }); + + const chunkArb = fc.oneof( + includeLineArb, + proseMentionArb, + planningIncludeArb, + fencedIncludeArb, + mismatchedFencedIncludeArb, + nestedFencedIncludeArb, + plainTextArb, + ); + const bodyArb = fc.array(chunkArb, { maxLength: 12 }).map((chunks) => chunks.join('\n')); + + fc.assert( + fc.property(bodyArb, (body) => { + const once = resolveSpecRootReference(body); + const twice = resolveSpecRootReference(once); + assert.strictEqual( + twice, + once, + `not idempotent — body: ${JSON.stringify(body)}\nonce: ${JSON.stringify(once)}\ntwice: ${JSON.stringify(twice)}`, + ); + }), + // Explicit seed + bounded numRuns, replay data printed on failure via + // the assertion message above (fast-check's own thrown error additionally + // carries the pinned seed + shrunk counterexample needed to replay). + { seed: 20260814, numRuns: 300 }, + ); + }); +});