fix(#2873): close review findings across fences, sanitizer and docs
Isolated security review found resolveSpecRootReference's fence tracker toggled on any delimiter, so a backtick fence could be closed by a tilde one and an include in the gap was rewritten inside a code block. Fixed by reusing scanFencedBlocks - the canonical engine already behind stripFencedCode and extractFencedBlock - rather than carrying a fourth copy of fence detection, which also closes the duplication the standards review flagged. sanitizeForRender now strips combining marks and zero-width characters alongside the ANSI, control and bidi classes it already handled. Adds the C, E and F matrix rows the spec review found missing, including installer-level coverage that spawns the real install rather than calling the report builder. Ships the how-to, the reference and command docs in five locales, the changeset, the inventory and glossary entries, and regenerates health.md for the new W028 rule. Refs #2873
This commit is contained in:
5
.changeset/zesty-moles-tumble.md
Normal file
5
.changeset/zesty-moles-tumble.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 0
|
||||
---
|
||||
**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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-<name>` 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
|
||||
|
||||
93
docs/how-to/interpret-install-shadow-warnings.md
Normal file
93
docs/how-to/interpret-install-shadow-warnings.md
Normal file
@@ -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 `<repo>/.claude/`).
|
||||
|
||||
---
|
||||
|
||||
## What the warning means
|
||||
|
||||
Claude Code resolves a `/gsd-<name>` 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 <scope> <kind> surface/entry`) and the winning side last (`<scope> <kind> wins instead` / `overridden by <scope> <kind>`). Each subsequent bullet repeats the same fact per trigger: `<trigger>: <shadowed scope>/<shadowed kind> shadowed by <winner scope>/<winner kind>`.
|
||||
|
||||
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-<name>` 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> 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/<name>.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-<name>` 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)
|
||||
@@ -814,6 +814,8 @@ GSD の保証付きでアドホックタスクを実行します。
|
||||
/gsd-health --context # コンテキスト使用率のトリアージ
|
||||
```
|
||||
|
||||
**スコープ間インストールのシャドーイング(`W028`)。** あるランタイムが `global` と `local` の両方のスコープにインストールされ、ホストのトリガー解決ルールによって一方のスコープの `/gsd-*` サーフェスが到達不能になっている場合——Claude Code のケース:個人スキルは常にプロジェクトコマンドより優先される——ヘルスチェックは、シャドーイングされたトリガー、勝者スコープ、敗者スコープを示す WARNING 重大度のアドバイザリを追加します。これはヘルスチェックの合否ステータスを変更することはなく、自動修正の対象にもなりません(削除すべき単一の正解スコープが存在しないため)。そのため `--repair` はこれに一切手を加えません。インストール時に GSD Core が表示するのと同一のアドバイザリです。
|
||||
|
||||
### `/gsd-cleanup`
|
||||
|
||||
完了したマイルストーンからの累積フェーズディレクトリをアーカイブし、アップストリームが削除されたローカルブランチを削除します。
|
||||
|
||||
@@ -36,6 +36,8 @@ npx @opengsd/gsd-core@latest --claude --global
|
||||
|
||||
スキルは `~/.claude/` に配置されます。次回の Claude Code セッションからコマンドが `/gsd-*` スラッシュコマンドとして表示されます。反映するには Claude Code を再起動してください。
|
||||
|
||||
**`--global` と `--local` の両方のスコープへのインストール。** これはサポートされている構成です(プロジェクトによって異なるカスタマイズが必要になることがあるため)が、Claude Code 自身のトリガー解決ルール——個人スコープがプロジェクトスコープより優先され、スキルは同名のコマンドより優先される——はどちらも同じ方向を指します。つまり、グローバルスキルは常にローカルコマンドより `/gsd-<name>` トリガーで勝ちます。GSD Core はこれを検出し、インストール完了直後にどちらのスコープが勝っているかを表示します(同じ事実は `/gsd-health` の診断コード `W028` としても表示されます)。これは警告であり失敗ではありません——インストール自体は成功します。**グローバル**スコープでは、勝者となったスキルのワークフロー仕様への参照は実行時にまず作業ディレクトリを基準に解決されるため、Claude Code が実際に呼び出すのはグローバルスキルであっても、独自の `.claude/gsd-core/` を持つプロジェクトは自分自身の仕様を取得できます。
|
||||
|
||||
**インストールディレクトリの上書き:**
|
||||
|
||||
```bash
|
||||
|
||||
@@ -820,6 +820,8 @@ GSD 보장을 통해 애드혹 작업을 실행합니다.
|
||||
/gsd-health --context # 컨텍스트 활용 트리아지
|
||||
```
|
||||
|
||||
**범위 간 설치 섀도잉(`W028`).** 런타임이 `global`과 `local` 두 범위 모두에 설치되어 있고, 호스트의 트리거 해석 규칙으로 인해 한 범위의 `/gsd-*` 표면에 도달할 수 없게 되는 경우 — Claude Code의 사례: 개인 스킬이 항상 프로젝트 명령을 이깁니다 — 상태 점검은 섀도잉된 트리거, 승리한 범위, 패배한 범위를 명시하는 WARNING 심각도 권고를 추가합니다. 이는 상태 점검의 통과/실패 상태를 절대 변경하지 않으며, 자동으로 수정되지도 않습니다(제거해야 할 단 하나의 올바른 범위가 존재하지 않기 때문입니다). 따라서 `--repair`는 이를 절대 건드리지 않습니다. 설치 시점에 GSD Core가 출력하는 것과 동일한 권고입니다.
|
||||
|
||||
### `/gsd-cleanup`
|
||||
|
||||
완료된 마일스톤에서 누적된 단계 디렉토리를 아카이브하고 업스트림이 삭제된 로컬 브랜치를 정리합니다.
|
||||
|
||||
@@ -36,6 +36,8 @@ npx @opengsd/gsd-core@latest --claude --global
|
||||
|
||||
스킬은 `~/.claude/`에 저장됩니다. 다음 Claude Code 세션에서 `/gsd-*` 슬래시 명령으로 명령이 나타납니다. Claude Code를 재시작하여 적용하세요.
|
||||
|
||||
**`--global`과 `--local` 두 범위 모두에 설치하는 경우.** 이는 지원되는 구성입니다(프로젝트마다 서로 다른 커스터마이징이 필요한 경우가 있기 때문입니다). 하지만 Claude Code 자체의 트리거 해석 규칙 — 개인 범위가 프로젝트 범위보다 우선하고, 스킬이 동일한 이름의 명령보다 우선함 — 은 모두 같은 방향을 가리킵니다: 전역 스킬이 항상 `/gsd-<name>` 트리거에서 로컬 명령을 이깁니다. GSD Core는 이를 감지하여 설치 완료 직후 어떤 범위가 우선하는지 출력합니다(동일한 사실이 `/gsd-health`의 진단 코드 `W028`로도 표시됩니다). 이는 경고일 뿐 실패가 아닙니다 — 설치 자체는 계속 성공합니다. **전역** 범위에서는, 승리한 스킬의 워크플로 스펙 참조가 실행 시점에 작업 디렉터리를 기준으로 우선 해석되므로, Claude Code가 실제로 호출하는 것이 전역 스킬이더라도 자체 `.claude/gsd-core/`를 가진 프로젝트는 여전히 자신의 스펙을 얻습니다.
|
||||
|
||||
**설치 디렉터리 재정의:**
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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-<nome>` 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
|
||||
|
||||
@@ -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-<name>` 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/<name>.md` relative to the working directory first, falling back to `~/.claude/gsd-core/workflows/<name>.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
|
||||
|
||||
@@ -814,6 +814,8 @@ ROADMAP.md 中阶段的 CRUD 操作 — 通过单一合并命令添加、插入
|
||||
/gsd-health --context # 上下文使用率分类
|
||||
```
|
||||
|
||||
**跨作用域安装遮蔽(`W028`)。** 当某个运行时同时安装在 `global` 和 `local` 两个作用域,且宿主的触发器解析规则使其中一个作用域的 `/gsd-*` 界面变得不可达——Claude Code 的情况:个人技能总是胜过项目命令——健康检查会添加一条 WARNING 级别的提示,指出被遮蔽的触发器、胜出的作用域以及落败的作用域。它从不改变健康检查的通过/失败状态,也从不会被自动修复(不存在唯一正确应移除的作用域),因此 `--repair` 永远不会处理它。这与 GSD Core 在安装时打印的提示完全相同。
|
||||
|
||||
### `/gsd-cleanup`
|
||||
|
||||
归档已完成里程碑中积累的阶段目录,并删除上游已删除的本地分支。
|
||||
|
||||
@@ -36,6 +36,8 @@ npx @opengsd/gsd-core@latest --claude --global
|
||||
|
||||
技能文件存放于 `~/.claude/`。下次 Claude Code 会话中,命令将以 `/gsd-*` 斜杠命令的形式出现。重启 Claude Code 以加载它们。
|
||||
|
||||
**同时在 `--global` 和 `--local` 两个作用域安装。** 这是受支持的配置(不同项目有时需要不同的自定义配置),但 Claude Code 自身的触发器解析规则——个人作用域覆盖项目作用域,且技能(skill)覆盖同名命令——都指向同一个方向:全局技能总是在 `/gsd-<name>` 触发器上胜过本地命令。GSD Core 会检测到这一点,并在安装完成后立即打印出哪个作用域胜出(并通过 `/gsd-health` 以诊断代码 `W028` 呈现相同的事实);这只是一条提示,不是失败——安装本身仍然会成功。在**全局**作用域下,胜出技能的工作流规范引用会在运行时优先相对于你的工作目录解析,因此即使 Claude Code 实际调用的是全局技能,拥有自己 `.claude/gsd-core/` 的项目仍然能获取到自己的规范。
|
||||
|
||||
**覆盖安装目录:**
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -175,26 +175,42 @@ const CONTROL_RE = /[\x00-\x1f\x7f-\x9f]/g;
|
||||
* #13 names. */
|
||||
const BIDI_RE = /[--]/g;
|
||||
|
||||
/** 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, and
|
||||
* Unicode bidi overrides/isolates (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.
|
||||
* `''` 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 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.
|
||||
* Never truncates — `readInstallManifest` already caps at 64 chars.
|
||||
* 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(BIDI_RE, '')
|
||||
.replace(COMBINING_MARK_RE, '')
|
||||
.replace(ZERO_WIDTH_RE, '');
|
||||
return stripped.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -513,13 +514,6 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
|
||||
// preserved rather than dropped.
|
||||
const WORKFLOW_SPEC_ROOT_INCLUDE_RE = /^@~\/\.claude\/gsd-core\/workflows\/([A-Za-z0-9._-]+)\.md[ \t]*(\r?)$/gm;
|
||||
|
||||
// Matches a fenced code-block delimiter line (``` or ~~~, any info string)
|
||||
// so occurrences of the include shape used as *documentation* inside a fence
|
||||
// are left untouched — Claude Code documents backticks as the way to
|
||||
// *prevent* an `@`-import, so rewriting a fenced example would corrupt
|
||||
// documentation-of-the-syntax.
|
||||
const FENCE_DELIMITER_RE = /^(```|~~~)[^\r\n]*$/gm;
|
||||
|
||||
/**
|
||||
* Rewrite a static global-scope Claude skill `@`-include of the command's own
|
||||
* workflow spec into an imperative two-step resolution the agent performs at
|
||||
@@ -548,29 +542,38 @@ const FENCE_DELIMITER_RE = /^(```|~~~)[^\r\n]*$/gm;
|
||||
* 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) offset ranges covered by fenced code blocks so
|
||||
// matches inside them are skipped. An unterminated trailing fence covers
|
||||
// to the end of the string (still "inside a fence").
|
||||
const fenceRanges = [];
|
||||
// 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 m;
|
||||
let openStart = null;
|
||||
FENCE_DELIMITER_RE.lastIndex = 0;
|
||||
while ((m = FENCE_DELIMITER_RE.exec(body)) !== null) {
|
||||
if (openStart === null) {
|
||||
openStart = m.index;
|
||||
} else {
|
||||
fenceRanges.push([openStart, m.index + m[0].length]);
|
||||
openStart = null;
|
||||
}
|
||||
let offset = 0;
|
||||
for (const line of lines) {
|
||||
lineStartOffsets.push(offset);
|
||||
offset += line.length + 1; // +1 for the '\n' separator
|
||||
}
|
||||
if (openStart !== null) fenceRanges.push([openStart, body.length]);
|
||||
}
|
||||
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) => {
|
||||
|
||||
44
tests/helpers/shadow-report-throws-preload.cjs
Normal file
44
tests/helpers/shadow-report-throws-preload.cjs
Normal file
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Preload fixture for #2873 matrix row C5 — force the installer's
|
||||
* cross-scope shadow-report call site (bin/install.js, immediately after
|
||||
* writeManifest) to exercise its own swallow-everything catch.
|
||||
*
|
||||
* `install-shadow-report.cjs`'s `buildShadowReport()` only degrades a
|
||||
* `TypeError` thrown by `resolveInstalledSurfaces` (returns
|
||||
* `RESOLVER_UNAVAILABLE`); any OTHER error type it lets propagate — see its
|
||||
* own doc comment. This preload replaces the exported `buildShadowReport`
|
||||
* with a function that always throws a plain `Error`, so whatever calls it
|
||||
* next (`bin/install.js`, requiring the SAME resolved module path and
|
||||
* therefore getting the SAME cached module object) sees that throw and must
|
||||
* swallow it without failing the install (design row C5: "a report failure
|
||||
* never fails the install").
|
||||
*
|
||||
* Loaded via `node --require <this file> bin/install.js ...`
|
||||
* (`tests/helpers/process-seam.cjs`'s `runNode` forwards extra argv
|
||||
* verbatim, ahead of the target script) so the patch lands in the require
|
||||
* cache BEFORE `bin/install.js`'s own
|
||||
* `require('../gsd-core/bin/lib/install-shadow-report.cjs')` resolves.
|
||||
*
|
||||
* One-shot subprocess: no restoration needed — the process exits right
|
||||
* after the single `install()` call this preload targets. This is NOT a
|
||||
* chmod/mode-bit trick (CONTRIBUTING.md: those no-op under root), and NOT
|
||||
* the in-process fs fault-injection seam (`tests/helpers/faulty-deps.cjs`'s
|
||||
* `withFaultyFs` is explicitly documented as in-process-only, since a
|
||||
* spawned subprocess offers no shared memory to monkeypatch into) — this
|
||||
* patches the ONE exported function the design doc names as the report
|
||||
* builder, at the require-cache seam a spawned child process actually
|
||||
* offers.
|
||||
*/
|
||||
|
||||
const path = require('node:path');
|
||||
|
||||
const modPath = require.resolve(
|
||||
path.join(__dirname, '..', '..', 'gsd-core', 'bin', 'lib', 'install-shadow-report.cjs'),
|
||||
);
|
||||
const shadowReportModule = require(modPath);
|
||||
|
||||
shadowReportModule.buildShadowReport = function throwingBuildShadowReport() {
|
||||
throw new Error('injected by tests/helpers/shadow-report-throws-preload.cjs (#2873 matrix row C5)');
|
||||
};
|
||||
@@ -33,6 +33,7 @@ const {
|
||||
INSTALL_SCRIPT,
|
||||
MANIFEST_NAME,
|
||||
installerEnv,
|
||||
stripAnsi,
|
||||
} = require('./helpers/install-shared.cjs');
|
||||
|
||||
const {
|
||||
@@ -6441,3 +6442,187 @@ describe('#2218 cross-scope shadowing', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #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 } = 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
assert.strictEqual(controlReport.shadowed, false);
|
||||
|
||||
assert.ok(
|
||||
// allow-test-rule: negative proof over a spawned process's real stdio
|
||||
// has no typed positive to structurally compare against
|
||||
// (renderShadowReport returns [] for an unshadowed report, so there
|
||||
// is nothing computed to search for). This is renderShadowReport's
|
||||
// own FIXED template fragment — present in BOTH its kindsDiffer
|
||||
// branches, verbatim in the module source — not a guessed literal.
|
||||
// [#2873]
|
||||
!stripAnsi(g.stderr).includes(' shadowed: the '),
|
||||
`expected no shadow report in a single-scope install's stderr: ${g.stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
assert.ok(
|
||||
// allow-test-rule: same fixed-template anchor as C3 — the failure
|
||||
// path never reaches the report call site at all (it runs strictly
|
||||
// after writeManifest), so this asserts the absence side of the same
|
||||
// typed renderShadowReport contract. [#2873]
|
||||
!stripAnsi(g.stdout + g.stderr).includes(' shadowed: the '),
|
||||
`expected no shadow report emitted before a structural failure: ${g.stdout}\n${g.stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
assert.ok(
|
||||
// allow-test-rule: same fixed-template anchor as C3/C4 — the injected
|
||||
// throw is caught before renderShadowReport ever runs, so no report
|
||||
// text should reach stderr; there is no typed positive to compare
|
||||
// against for a suppressed report. [#2873]
|
||||
!stripAnsi(g.stderr).includes(' shadowed: the '),
|
||||
`expected the injected report failure to be swallowed silently: ${g.stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -328,3 +328,47 @@ describe('buildShadowReport — the lstatSync symlink guard (B14-B16)', () => {
|
||||
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.
|
||||
|
||||
@@ -41,6 +41,7 @@ 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');
|
||||
@@ -561,6 +562,268 @@ describe('buildShadowReport — property (F1)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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\\');
|
||||
const bidiArb = fc.constantFrom(
|
||||
'', '', '', '', '', // embedding/override
|
||||
'', '', '', '', // 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{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)', () => {
|
||||
@@ -583,6 +846,27 @@ describe('resolveSpecRootReference — property (F4)', () => {
|
||||
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(
|
||||
@@ -590,6 +874,8 @@ describe('resolveSpecRootReference — property (F4)', () => {
|
||||
proseMentionArb,
|
||||
planningIncludeArb,
|
||||
fencedIncludeArb,
|
||||
mismatchedFencedIncludeArb,
|
||||
nestedFencedIncludeArb,
|
||||
plainTextArb,
|
||||
);
|
||||
const bodyArb = fc.array(chunkArb, { maxLength: 12 }).map((chunks) => chunks.join('\n'));
|
||||
|
||||
Reference in New Issue
Block a user