diff --git a/.gitignore b/.gitignore index c70d11541..3fff33e45 100644 --- a/.gitignore +++ b/.gitignore @@ -213,6 +213,7 @@ build/ /gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs /gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs /gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs /gsd-core/bin/lib/command-roster.cjs /gsd-core/bin/lib/runtime-artifact-conversion.cjs /gsd-core/bin/lib/runtime-artifact-layout.cjs diff --git a/bin/install.js b/bin/install.js index b36956134..1665b5354 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 @@ -11664,6 +11668,29 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // Report any backed-up local patches reportLocalPatches(targetDir, runtime); + // #2873: cross-scope shadow report. Fires ONCE per install (this is the + // only writeManifest call site that gets it — the other four sites are + // sub-writes within a single install, not separate installs). A shadowed + // install is a warning, never a failure (ADR-2866 Consequences), so this + // never touches `failures` or `process.exit`, and the whole block is + // wrapped in a try/catch that swallows everything: a report failure must + // never fail an otherwise-successful install (design row C5). No options + // are injected into buildShadowReport — this is the production call shape, + // resolving the real machine via os.homedir()/process.cwd() defaults + // inside the resolver. + try { + const shadowReport = buildShadowReport(runtime); + const shadowLines = renderShadowReport(shadowReport); + if (shadowLines.length > 0) { + console.warn(`\n ${yellow}⚠${reset} ${shadowLines[0]}`); + for (const line of shadowLines.slice(1)) { + console.warn(` ${dim}${line}${reset}`); + } + } + } catch (_shadowReportErr) { + // Never fail an install over a reporting concern — see comment above. + } + // Verify no leaked .claude paths in non-Claude runtimes (manifest-scoped) if (!_hostBehaviors(runtime).ownsClaudePaths) { const leakedPaths = []; 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 index 9782f7557..478c3b354 100644 --- a/src/install-shadow-report.cts +++ b/src/install-shadow-report.cts @@ -43,6 +43,55 @@ * (`installed-surface-resolver.cts`'s `deriveStemsForKindEntry`) before they * ever reach a `TriggerSurface` — this module does not re-gate them. * + * ── Per-scope truth filter (why this lives HERE, not in the resolver) ────── + * `resolveOneRuntime` (`installed-surface-resolver.cts`) builds ONE union of + * every installed scope's `stems` and hands that single list to + * `resolveTriggerSurface`, which then synthesizes a candidate trigger for + * EVERY stem at EVERY installed scope's trigger-bearing kind entry — + * regardless of whether that specific scope's own manifest actually shipped + * that stem. Concretely: a global `full`-profile install (stems a, b, c) + * alongside a local `core`-profile install (stem a only) unions to + * `{a, b, c}`, and `resolveTriggerSurface` then reports `commands@local` + * candidates for b and c too — trigger names for artifacts that do not exist + * on disk at that scope. Left unfiltered, this module would tell the user + * `/gsd-b` and `/gsd-c` are shadowed local commands when there is no local + * artifact for either at all — over-reporting that is not cosmetic, since + * the whole point of this report is to make a real failure legible. + * + * `resolveTriggerSurface`'s API takes ONE stem list shared by every scope it + * is asked about, so per-scope truth cannot be expressed through it without + * either widening a shipped Phase-2 contract other callers may depend on, or + * calling it once per scope and re-implementing its winner computation + * (`isHigherPriority`) here as a second, driftable copy. `resolveOneRuntime` + * / `resolveInstalledSurfaces` (Phase 3, #2872) is likewise a shipped module + * this task deliberately leaves untouched. This module already receives the + * full `InstalledRuntimeSurface`, including each scope's own REAL `stems` + * list (`installed-surface-resolver.cts`'s `deriveStemsFromManifest`) — so + * the correction belongs here, as a filter over `resolveTriggerSurface`'s + * already-computed `shadowedBy` groups: a trigger is reported as shadowed + * only when its underlying stem is present in BOTH the winner's scope's own + * `stems` AND the shadowed side's scope's own `stems` — i.e. an artifact + * genuinely exists at both scopes, not merely "some stem exists somewhere in + * the union". + * + * `TriggerSurface` does not carry the originating stem OR the composing + * prefix on its output — only the already-composed `trigger` string + * (`${prefix}${stem}`) — so the stem cannot be read off it directly. Rather + * than hand-roll a fixed-offset `trigger.slice(4)` (which would silently + * assume every runtime's prefix is exactly `gsd-` — true today, but not a + * contract this module owns), the prefix is recovered the honest way: by + * re-resolving that scope's `ArtifactKind` layout (`resolveRuntimeArtifactLayout` + * / `resolveRuntimeArtifactLayoutFromRegistry`, the SAME layout descriptor + * `resolveTriggerSurface` itself reads its `entry.prefix` from) for the + * winner's and shadowed side's own `(scope, kind)`, and reading `.prefix` + * off the matching kind entry. This is metadata-only (constructing an + * `ArtifactKind` never touches the filesystem — see + * `runtime-artifact-layout.cts`'s kind-builder functions), so it costs + * nothing beyond a small per-`(scope,kind)` memo. If a prefix cannot be + * resolved at all (a `TypeError` from an unexpected registry shape), the + * trigger is conservatively DROPPED rather than kept — the same + * report-nothing-you-cannot-prove posture as the rest of this filter. + * * ── Pure with respect to caller-visible state ─────────────────────────────── * `buildShadowReport` builds a fresh `ShadowReport` (fresh arrays, fresh * objects) on every call, exactly as the resolver documents for itself @@ -55,8 +104,24 @@ import { resolveInstalledSurfaces, type ResolveInstalledSurfacesOptions, type InstalledRuntimeSurface, + type InstalledScopeRecord, } from './installed-surface-resolver.cjs'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import runtimeArtifactLayoutMod = require('./runtime-artifact-layout.cjs'); +const { resolveRuntimeArtifactLayout, resolveRuntimeArtifactLayoutFromRegistry } = runtimeArtifactLayoutMod; + +/** The registry shape `resolveRuntimeArtifactLayoutFromRegistry` accepts as + * its first argument — reused (not re-typed) so `opts.registry` can be + * forwarded to it, mirroring `installed-surface-resolver.cts`'s own + * `LayoutRegistryLike`. */ +type LayoutRegistryLike = Parameters[0]; + +/** `installed-surface-resolver.cts` does not export `TriggerSurface` by + * name (only via `InstalledRuntimeSurface.triggers`'s element type) — + * derived here rather than re-declared as a second, driftable shape. */ +type TriggerSurface = InstalledRuntimeSurface['triggers'][number]; + // ── Reason enum ───────────────────────────────────────────────────────── export const SHADOW_REASON = Object.freeze({ @@ -133,6 +198,76 @@ export function sanitizeForRender(value: string | null): string | null { return stripped.replace(/\s+/g, ' ').trim(); } +// ── Per-scope truth filter helpers ───────────────────────────────────── + +/** + * Build a `(scope, kind) -> prefix | null` lookup for one runtime, memoized + * per call to `buildShadowReport` (never shared across calls — matches this + * module's "fresh objects on every call" contract). `null` means "could not + * be resolved" (unknown scope record, or a `TypeError` from the layout + * resolver) — the caller treats that as "cannot honestly attribute this + * trigger to a real stem here", not as "assume it is fine". + */ +function buildPrefixLookup( + runtime: string, + scopeRecords: Map, + 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 ────────────────────────────────────────────────────── /** @@ -171,7 +306,15 @@ export function buildShadowReport(runtime: string, opts: ResolveInstalledSurface // always returns exactly one element (see its own doc comment). const surface = surfaces[0]; - const shadowedSurfaces = surface.triggers.filter((t) => t.shadowedBy !== null); + // Per-scope truth filter (see module comment): `surface.triggers` may + // contain candidates synthesized from the CROSS-SCOPE stem union + // (`installed-surface-resolver.cts`'s `stemUnion`) that do not correspond + // to a real artifact at one or both scopes. Only a trigger whose stem is + // provably present in BOTH the winner's own `stems` and the shadowed + // side's own `stems` is reported. + const scopeRecords = new Map(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, 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..0c74a607f 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -504,6 +504,88 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c return `${fm}\n${normalizedBody}`; } +// #2873 (4b) — spec-root reachability. Matches ONLY a line that is a real +// `@~/.claude/gsd-core/workflows/.md` include: line-start `@`, exact +// spec-root shape, nothing else on the line. This is deliberately narrower +// than "any line mentioning gsd-core/workflows" so prose mentions and +// `references/`/`templates/`/`@.planning/...` includes are never touched +// (rows 24/25). CRLF-safe: an optional trailing `\r` is captured and +// preserved rather than dropped. +const WORKFLOW_SPEC_ROOT_INCLUDE_RE = /^@~\/\.claude\/gsd-core\/workflows\/([A-Za-z0-9._-]+)\.md[ \t]*(\r?)$/gm; + +// Matches a fenced code-block delimiter line (``` or ~~~, any info string) +// so occurrences of the include shape used as *documentation* inside a fence +// are left untouched — Claude Code documents backticks as the way to +// *prevent* an `@`-import, so rewriting a fenced example would corrupt +// documentation-of-the-syntax. +const FENCE_DELIMITER_RE = /^(```|~~~)[^\r\n]*$/gm; + +/** + * Rewrite a static global-scope Claude skill `@`-include of the command's own + * workflow spec into an imperative two-step resolution the agent performs at + * runtime: prefer the project-local spec (cwd-relative), fall back to the + * global spec, and treat "neither exists" as a visible failure rather than a + * silent no-spec proceed. + * + * WHY this can't stay a static `@`-include (even a relative one): Claude Code + * documents relative `@`-paths as resolving against the file *containing* the + * import, which for a global skill is `~/.claude/skills/gsd-/` — not + * the project's working directory. `@./.claude/...` would therefore always + * resolve inside the skill's own install directory, never the project, so + * there is no static include syntax that can express "prefer local, fall + * back to global". This function exists precisely so that resolution can be + * performed by the agent, not the host's pre-expansion. + * + * Scope-free by design: this function does not know or care whether it is + * being applied to a global or local artifact, or which runtime — that + * judgment belongs to the caller (`skillsKind` in + * `runtime-artifact-layout.cts`, the one site that knows install scope). + * Applying it to a body with no workflow include is a no-op (row 26); a body + * with two independent workflow includes has each rewritten independently + * (row 27); an include inside a fenced code block or wrapped in inline + * backticks is left untouched (the backtick case is already excluded by the + * line-start anchor, since a backtick-wrapped line does not begin with `@`). + * Idempotent: the replacement text never begins with `@` and never matches + * `WORKFLOW_SPEC_ROOT_INCLUDE_RE`, so re-applying this function to its own + * output is a no-op. + */ +function resolveSpecRootReference(body) { + if (typeof body !== 'string' || body.length === 0) return body; + if (!body.includes('@~/.claude/gsd-core/workflows/')) return body; + + // Collect [start, end) offset ranges covered by fenced code blocks so + // matches inside them are skipped. An unterminated trailing fence covers + // to the end of the string (still "inside a fence"). + const fenceRanges = []; + { + let m; + let openStart = null; + FENCE_DELIMITER_RE.lastIndex = 0; + while ((m = FENCE_DELIMITER_RE.exec(body)) !== null) { + if (openStart === null) { + openStart = m.index; + } else { + fenceRanges.push([openStart, m.index + m[0].length]); + openStart = null; + } + } + if (openStart !== null) fenceRanges.push([openStart, body.length]); + } + const isInsideFence = (offset) => fenceRanges.some(([start, end]) => offset >= start && offset < end); + + return body.replace(WORKFLOW_SPEC_ROOT_INCLUDE_RE, (match, stem, cr, offset) => { + if (isInsideFence(offset)) return match; + return ( + `To load this command's workflow spec: check for ` + + `\`.claude/gsd-core/workflows/${stem}.md\` relative to the current working ` + + `directory first (project-local); if it is not there, fall back to ` + + `\`~/.claude/gsd-core/workflows/${stem}.md\` (the global install). If ` + + `neither file exists, stop — a workflow spec is required and none was found.` + + cr + ); + }); +} + function normalizeKimiSkillName(skillName) { let text = String(skillName || '').trim().toLowerCase(); if (text.startsWith('/')) text = text.slice(1); @@ -2995,6 +3077,38 @@ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathP return tempDir; } +/** + * #2873 (4b) — second pass over a staged skills directory, run strictly AFTER + * `applyRuntimeContentRewritesInPlace`. That pass's `case 'claude':` branch + * unconditionally rewrites any bare (non-`@`-prefixed) `~/.claude/` substring + * in the body to the computed pathPrefix (`$HOME/.claude/` for a global + * install) and restores ONLY the `@`-prefixed form back to `~` + * (`@$HOME/.claude/` → `@~/.claude/`). `resolveSpecRootReference`'s + * replacement text is deliberately imperative prose containing a literal, + * non-`@`-prefixed `~/.claude/gsd-core/workflows/.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 +3146,17 @@ function rewriteStagedSkillBodies(stagedDir, opts) { const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined; applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix, isGlobal, attribution); + // #2873 (4b): claude, global scope only — see + // applySpecRootReferenceToStagedSkills's doc comment for why this MUST run + // after the rewrite pass above, not before. `rewriteStagedSkillBodies` is + // the skills-kind seam (`kind.kind === 'skills'`), so this never touches a + // 'commands' or 'agents' kind body (rows 24/25 unaffected), and claude has + // no skills-kind entry at local scope, so this is already structurally + // scoped to global (row 23) — the explicit isGlobal check is defense-in-depth + // against that descriptor wiring ever changing. + if (runtime === 'claude' && isGlobal) { + applySpecRootReferenceToStagedSkills(stagedDir); + } } /** @@ -3176,6 +3301,11 @@ export = { convertClaudeToAntigravityContent, convertClaudeCommandToAntigravitySkill, convertClaudeCommandToClaudeSkill, + // #2873 (4b): pure, scope-free transform — applied by the one call site + // that knows install scope (skillsKind's stage() in + // runtime-artifact-layout.cts), never inside convertClaudeCommandToClaudeSkill + // itself. + resolveSpecRootReference, convertClaudeCommandToKimiSkill, convertClaudeCommandToKimiCodeSkill, buildKimiAgentArtifacts, 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/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/install-cross-scope-shadowing.test.cjs b/tests/install-cross-scope-shadowing.test.cjs deleted file mode 100644 index f80d310eb..000000000 --- a/tests/install-cross-scope-shadowing.test.cjs +++ /dev/null @@ -1,163 +0,0 @@ -'use strict'; - -/** - * install-cross-scope-shadowing.test.cjs — failing-first regression suite for - * issue #2218 ("cross-scope shadowing"), phase issue #2873 (epic #2866, - * ADR-2866). - * - * Implements the coexistence gate (`C1`) and the 4b behavioral pair - * (`E13`/`E14`) from - * `.gsd/phase/feat-2873-cross-scope-shadowing/50-test-matrix.md`. Per that - * matrix's "Red-first order": C1 must go RED against `next` (no - * `install-shadow-report.cjs` report exists today), E14 must go RED today - * (the global skill's spec-root include points at the global tree even when - * a local install exists), and E13 must stay GREEN both before and after — - * it is the guard that phase 4b does not break today's global-only case. - * - * This suite does NOT implement any production code. It is deliberately - * failing against the current tree. - */ - -const { test, describe, before, after } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); - -const { cleanup } = require('./helpers.cjs'); -const { runNode } = require('./helpers/process-seam.cjs'); -const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); -const { - INSTALL_SCRIPT, - MANIFEST_NAME, - installerEnv, -} = require('./helpers/install-shared.cjs'); - -/** - * Extract the `@`-include lines from an emitted markdown body — structural - * parsing, never substring/regex matching on the whole body (CONTRIBUTING.md - * "Prohibited: Raw Text Matching on Test Outputs"). Splits on newlines - * (CRLF-tolerant) and keeps only lines whose first character is `@`. - * - * @param {string} content - * @returns {string[]} - */ -function extractAtIncludeLines(content) { - return content.split(/\r?\n/).filter((line) => line.startsWith('@')); -} - -describe('#2218 cross-scope shadowing', () => { - let root; - let projectDir; - let globalInstallResult; - let localInstallResult; - - before(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2218-shadow-')); - projectDir = path.join(root, 'myrepo'); - fs.mkdirSync(projectDir, { recursive: true }); - - // Global half: cannot use runMinimalInstall here — its scope:'global' - // path pushes `--config-dir `, 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: today the global SKILL.md's spec-root include is a - // static `@~/.claude/gsd-core/workflows/plan-phase.md` reference, which - // always resolves against the GLOBAL tree even when a coexisting local - // install has its own project-local copy of that workflow file. Phase 4b - // replaces that static include with a two-step imperative form that names - // both candidate paths and lets the runtime prefer the local one when it - // exists — this test pins today's (broken) behavior as the RED case that - // 4b must flip. - test('the winning global skill points at the project-local spec tree', () => { - const skillPath = path.join(root, '.claude', 'skills', 'gsd-plan-phase', 'SKILL.md'); - const content = fs.readFileSync(skillPath, 'utf8'); - const atLines = extractAtIncludeLines(content); - - assert.ok( - !atLines.includes('@~/.claude/gsd-core/workflows/plan-phase.md'), - `expected the static global workflow @-line to be replaced, but found it among: ${JSON.stringify(atLines)}`, - ); - - const localSpecPath = path.join(projectDir, '.claude', 'gsd-core', 'workflows', 'plan-phase.md'); - assert.ok(fs.existsSync(localSpecPath), 'local spec-root workflow file should exist on disk'); - }); -}); diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index eece01b34..8a7d0edce 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -19,13 +19,21 @@ process.env.GSD_TEST_MODE = '1'; -const { test, describe } = require('node:test'); +const { test, describe, before, after } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); +const os = require('node:os'); const { createTempDir, cleanup } = require('./helpers.cjs'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); +const { + INSTALL_SCRIPT, + MANIFEST_NAME, + installerEnv, +} = require('./helpers/install-shared.cjs'); const { installRuntimeArtifacts, @@ -6253,3 +6261,183 @@ describe('Gap 2: installer ships the capability registry generator scripts (#192 }); }); } + +// ─── #2218 cross-scope shadowing — coexistence gate (C1) + 4b guard pair +// (E13/E14, #2873, epic #2866 Phase 4a) ──────────────────────────────────── +// +// Moved here from the now-deleted tests/install-cross-scope-shadowing.test.cjs: +// `scripts/lint-test-file-count.allowlist.json` grandfathers the `install` +// prefix at 8 files, and that suite's own `_doc` says adding a 9th file to a +// capped module is a novel offender, not a fix — this gate is folded into +// the emitted-artifact suite instead, which is already allowlisted and, +// per #2873's acceptance criteria, is the correct home ("written against the +// existing `runMinimalInstall` harness"). +// +// Implements the coexistence gate (`C1`) and the 4b behavioral pair +// (`E13`/`E14`) from +// `.gsd/phase/feat-2873-cross-scope-shadowing/50-test-matrix.md`. Per that +// matrix's "Red-first order": C1 must go RED against `next` (no +// `install-shadow-report.cjs` report exists today), E14 must go RED today +// (the global skill's spec-root include points at the global tree even when +// a coexisting local install has its own project-local copy of that +// workflow file), and E13 must stay GREEN both before and after — it is the +// guard that phase 4b does not break today's global-only case. +// +// This section does NOT implement the 4b spec-root emission transform +// (E1-E12, a separate matrix section) — that transform +// (`resolveSpecRootReference`, `runtime-artifact-conversion.cts`) landed +// separately and is exercised here only via its INSTALLED OUTPUT. #2873 +// Task 3 (2026-08-14): re-verified against a real global+local double +// install — 4b has landed and E14 below is GREEN, not the known-RED case +// this comment block originally described. The "Red-first order" paragraph +// above is left as-is: it accurately records the matrix's ORIGINAL red-first +// plan, not a live claim about E14's current state. + +/** + * Extract the `@`-include lines from an emitted markdown body — structural + * parsing, never substring/regex matching on the whole body (CONTRIBUTING.md + * "Prohibited: Raw Text Matching on Test Outputs"). Splits on newlines + * (CRLF-tolerant) and keeps only lines whose first character is `@`. + * + * @param {string} content + * @returns {string[]} + */ +function extractAtIncludeLines(content) { + return content.split(/\r?\n/).filter((line) => line.startsWith('@')); +} + +describe('#2218 cross-scope shadowing', () => { + let root; + let projectDir; + let globalInstallResult; + let localInstallResult; + + before(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2218-shadow-')); + projectDir = path.join(root, 'myrepo'); + fs.mkdirSync(projectDir, { recursive: true }); + + // Global half: cannot use runMinimalInstall here — its scope:'global' + // path pushes `--config-dir `, 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)}`, + ); + }); +}); 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..b0d40f010 --- /dev/null +++ b/tests/shadow-report.security.test.cjs @@ -0,0 +1,330 @@ +'use strict'; + +/** + * tests/shadow-report.security.test.cjs — hostile-manifest rendering suite + * for `install-shadow-report.cts` (#2873, epic #2866 Phase 4a — governed by + * `.gsd/phase/feat-2873-cross-scope-shadowing/40-design.md`). + * + * Implements matrix section B ("Rendering / sanitization (hostile manifest)", + * rows B1-B16) from + * `.gsd/phase/feat-2873-cross-scope-shadowing/50-test-matrix.md`. The + * matrix's own "Suites" section names this file `install-shadow-report + * .security.test.cjs`; it is shipped as `shadow-report.security.test.cjs` + * instead so its `lint-test-file-count.cjs` prefix is `shadow` rather than + * colliding with the already grandfathered, already-at-cap `install` prefix. + * + * Fixture provenance (#2371, per the matrix's own note): B1-B4/B7/B8's + * `declaredRuntime` payloads and B9-B13's manifest bodies are authored + * against the PUBLISHED `gsd-file-manifest.json` schema/format directly (raw + * JSON text or a hand-built `readManifest` result), never derived from + * `writeManifest`'s own output — a fixture the writer produced could only + * confirm what the writer already believes. + * + * Every `declaredRuntime` assertion below reads the TYPED IR field + * (`report.mismatches[0].declaredRuntime`) produced by `buildShadowReport`'s + * sanitize-at-the-render-seam guarantee — never a substring match against + * rendered prose (CONTRIBUTING → "Prohibited: Raw Text Matching on Test + * Outputs"). Where a `renderShadowReport` line is also inspected (B2, B12), + * the check is a structural security invariant (absence of a control + * character / a traversal payload), not a wording assertion. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const { buildShadowReport, renderShadowReport } = require('../gsd-core/bin/lib/install-shadow-report.cjs'); +const { resolveScope } = require('../gsd-core/bin/lib/install-scope.cjs'); +const { MANIFEST_NAME } = require('../gsd-core/bin/lib/installer-migrations.cjs'); + +// ─── Fixture helpers (mirrors tests/installed-surface-resolver.test.cjs) ─── + +const ABSENT_MANIFEST = Object.freeze({ manifestVersion: null, runtime: null, scope: null, files: {} }); + +function manifest({ manifestVersion = null, runtime = null, scope = null, files = {} } = {}) { + return { manifestVersion, runtime, scope, files }; +} + +function mkReadManifest(byConfigHome) { + return (configDir) => byConfigHome.get(configDir) ?? ABSENT_MANIFEST; +} + +function scopeHomes(runtime, home, cwd) { + const base = { runtime, env: {}, home, existsSync: () => false, cwd }; + return { + global: resolveScope({ ...base, id: 'global' }).configHome, + local: resolveScope({ ...base, id: 'local' }).configHome, + }; +} + +function baseOpts(home, cwd, overrides = {}) { + return { home, cwd, env: {}, existsSync: () => false, ...overrides }; +} + +/** Single-scope (global-only) fixture: a claude install declaring + * `declaredRuntime = runtimeVal` — always a mismatch against the requested + * 'claude' runtime unless `runtimeVal === 'claude'`, which is exactly what + * puts an entry in `report.mismatches` for every B-row below to inspect. + * `files: {}` keeps the fixture single-purpose: no trigger/shadowing signal + * competes with the mismatch signal under test. */ +function declaredRuntimeReport(runtimeVal) { + const home = '/fixture/sec-home'; + const cwd = '/fixture/sec-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: runtimeVal, scope: 'global', files: {} })], + ]); + return buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); +} + +// ─── B1-B4 — hostile declaredRuntime payloads are neutralized in the IR ──── + +describe('buildShadowReport — hostile declaredRuntime is sanitized in the IR (B1-B4)', () => { + test('ansi escape is neutralized (B1)', () => { + const report = declaredRuntimeReport('\x1b[31mcursor'); + assert.strictEqual(report.mismatches.length, 1); + assert.strictEqual(report.mismatches[0].declaredRuntime, 'cursor'); + assert.ok(!report.mismatches[0].declaredRuntime.includes('\x1b')); + }); + + test('newlines cannot forge a log line (B2)', () => { + const lf = declaredRuntimeReport('cursor\nFAKE LOG LINE'); + const crlf = declaredRuntimeReport('cursor\r\nFAKE LOG LINE'); + assert.strictEqual(lf.mismatches[0].declaredRuntime, 'cursorFAKE LOG LINE'); + assert.strictEqual(crlf.mismatches[0].declaredRuntime, 'cursorFAKE LOG LINE'); + assert.ok(!lf.mismatches[0].declaredRuntime.includes('\n')); + // Every rendered line must itself be single-line — a structural check on + // the renderer's output shape, not a wording assertion. + for (const line of renderShadowReport(lf)) { + assert.ok(!line.includes('\n'), `rendered line must never carry an embedded newline: ${JSON.stringify(line)}`); + } + }); + + test('control characters are stripped (B3)', () => { + const report = declaredRuntimeReport('a\x00b\x07c'); + assert.strictEqual(report.mismatches[0].declaredRuntime, 'abc'); + }); + + test('bidi override is stripped (B4)', () => { + const report = declaredRuntimeReport('a‮b'); + assert.strictEqual(report.mismatches[0].declaredRuntime, 'ab'); + }); +}); + +// ─── B5-B6 — the READER's 64-char cap, real fs, no double-truncation ─────── + +describe('buildShadowReport — declaredRuntime length cap, real reader (B5-B6)', () => { + function realCappedReport(t, n) { + const home = createTempDir('gsd-shadow-sec-b56-home-'); + const cwd = createTempDir('gsd-shadow-sec-b56-cwd-'); + t.after(() => { cleanup(home); cleanup(cwd); }); + const globalDir = path.join(home, '.claude'); + fs.mkdirSync(globalDir, { recursive: true }); + fs.writeFileSync(path.join(globalDir, MANIFEST_NAME), JSON.stringify({ + manifestVersion: 2, runtime: 'A'.repeat(n), scope: 'global', files: {}, + })); + return buildShadowReport('claude', { home, cwd }); + } + + test('cap-length runtime renders intact (B5, 64 chars)', (t) => { + const report = realCappedReport(t, 64); + assert.strictEqual(report.mismatches[0].declaredRuntime.length, 64); + assert.strictEqual(report.mismatches[0].declaredRuntime, 'A'.repeat(64)); + }); + + test('reader cap is respected once, not double-truncated (B6, 63/65 chars)', (t) => { + const below = realCappedReport(t, 63); + assert.strictEqual(below.mismatches[0].declaredRuntime, 'A'.repeat(63)); + + const above = realCappedReport(t, 65); + // readInstallManifest's MAX_REPORTED_RUNTIME_LENGTH truncates to 64 chars + // plus an ellipsis (65 chars total) — buildShadowReport's sanitizer never + // truncates further, so the ellipsis must survive intact. + assert.strictEqual(above.mismatches[0].declaredRuntime.length, 65); + assert.strictEqual(above.mismatches[0].declaredRuntime, `${'A'.repeat(64)}…`); + }); +}); + +// ─── B7-B8 — empty vs null declaredRuntime ───────────────────────────────── + +describe('buildShadowReport — empty vs absent declaredRuntime (B7-B8)', () => { + test('empty declared runtime renders as empty string, never as the string "null" (B7)', () => { + // Injected directly (bypassing readInstallManifest's own empty-string -> + // null normalization) so this exercises buildShadowReport/sanitizeForRender's + // OWN handling of an empty-but-present declared value, independent of + // the reader's separate empty-string rule. + const report = declaredRuntimeReport(''); + assert.strictEqual(report.mismatches.length, 1); + assert.strictEqual(report.mismatches[0].declaredRuntime, ''); + assert.notStrictEqual(report.mismatches[0].declaredRuntime, null); + }); + + test('absent declared runtime (null, v1 manifest) is omitted from the IR entirely (B8)', () => { + const home = '/fixture/b8-home'; + const cwd = '/fixture/b8-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + // scope matches probe too, so NEITHER mismatch flag fires. + [homes.global, manifest({ manifestVersion: 2, runtime: null, scope: 'global', files: {} })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(report.mismatches, [], 'a null declaredRuntime with no scope mismatch produces no mismatch entry at all'); + }); +}); + +// ─── B9-B11 — manifest document malformation, real files, real reads ────── + +describe('buildShadowReport — malformed manifest documents degrade, never throw (B9-B11)', () => { + function realSingleScopeReport(t, rawBody) { + const home = createTempDir('gsd-shadow-sec-b9-home-'); + const cwd = createTempDir('gsd-shadow-sec-b9-cwd-'); + t.after(() => { cleanup(home); cleanup(cwd); }); + const globalDir = path.join(home, '.claude'); + fs.mkdirSync(globalDir, { recursive: true }); + fs.writeFileSync(path.join(globalDir, MANIFEST_NAME), rawBody); + let report; + assert.doesNotThrow(() => { + report = buildShadowReport('claude', { home, cwd }); + }); + return report; + } + + test('non-object manifest json (0, string, array, boolean, null) all degrade to not-installed (B9)', (t) => { + for (const raw of ['0', '"a string"', '[]', 'true', 'null']) { + const report = realSingleScopeReport(t, raw); + assert.strictEqual(report.shadowed, false, `raw body ${raw} must degrade to not-installed`); + assert.deepStrictEqual(report.triggers, []); + } + }); + + test('an empty (0-byte) manifest file degrades to not-installed (B10)', (t) => { + const report = realSingleScopeReport(t, ''); + assert.strictEqual(report.shadowed, false); + assert.deepStrictEqual(report.triggers, []); + }); + + test('a CRLF manifest parses identically to its LF counterpart (B11)', (t) => { + const lfBody = [ + '{', + ' "manifestVersion": 2,', + ' "runtime": "claude",', + ' "scope": "global",', + ' "files": { "skills/gsd-plan-phase/SKILL.md": "a" }', + '}', + '', + ].join('\n'); + const lfReport = realSingleScopeReport(t, lfBody); + const crlfReport = realSingleScopeReport(t, lfBody.replace(/\n/g, '\r\n')); + assert.deepStrictEqual(crlfReport, lfReport); + }); +}); + +// ─── B12-B13 — manifest key hostility / cross-platform normalization ────── + +describe('buildShadowReport — manifest KEY hostility and normalization (B12-B13)', () => { + test('a traversal stem is rejected, never reaches a rendered trigger (B12)', () => { + const home = '/fixture/b12-home'; + const cwd = '/fixture/b12-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ + manifestVersion: 2, runtime: 'claude', scope: 'global', + files: { + 'skills/gsd-../../../x/SKILL.md': 'a', + 'skills/gsd-plan-phase/SKILL.md': 'b', + }, + })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' } })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + // Only the legitimate stem is present — the traversal key contributed nothing. + assert.deepStrictEqual(report.triggers.map((t) => t.trigger), ['gsd-plan-phase']); + for (const line of renderShadowReport(report)) { + assert.ok(!line.includes('..'), `rendered output must never carry a traversal payload: ${JSON.stringify(line)}`); + } + }); + + test('backslash-separated keys normalize on posix too (B13)', () => { + const home = '/fixture/b13-home'; + const cwd = '/fixture/b13-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'skills\\gsd-foo\\SKILL.md': 'a' } })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-foo.md': 'a' } })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(report.triggers.map((t) => t.trigger), ['gsd-foo']); + }); +}); + +// ─── B14-B16 — the lstatSync symlink guard ───────────────────────────────── + +describe('buildShadowReport — the lstatSync symlink guard (B14-B16)', () => { + test('a symlinked local config dir is not followed, injected lstatSync (B14)', () => { + const home = '/fixture/b14-home'; + const cwd = '/fixture/b14-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' } })], + // A manifest IS present at the local configHome per this readManifest + // stub — proving the guard, not the reader, is what refuses it below. + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' } })], + ]); + // Injected lstatSync reports the local configHome itself as a symlink. + const lstatSync = (p) => ({ isSymbolicLink: () => p === homes.local }); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome), lstatSync })); + assert.strictEqual(report.shadowed, false, 'the symlinked local scope must not be counted as installed, so nothing can shadow it'); + assert.deepStrictEqual(report.triggers, []); + }); + + test('a symlinked manifest file is not followed, real symlink on disk (B15)', (t) => { + const home = createTempDir('gsd-shadow-sec-b15-home-'); + const cwd = createTempDir('gsd-shadow-sec-b15-cwd-'); + const outOfTreeDir = createTempDir('gsd-shadow-sec-b15-outoftree-'); + t.after(() => { cleanup(home); cleanup(cwd); cleanup(outOfTreeDir); }); + + const globalDir = path.join(home, '.claude'); + const localDir = path.join(cwd, '.claude'); + fs.mkdirSync(globalDir, { recursive: true }); + fs.mkdirSync(localDir, { recursive: true }); + fs.writeFileSync(path.join(globalDir, MANIFEST_NAME), JSON.stringify({ + manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' }, + })); + const outOfTreeManifest = path.join(outOfTreeDir, 'real-manifest.json'); + fs.writeFileSync(outOfTreeManifest, JSON.stringify({ + manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' }, + })); + // The local config DIR is real; only the manifest FILE inside it is a + // symlink pointing OUTSIDE the config dir — proves the guard checks the + // manifest path itself, not merely the directory. + fs.symlinkSync(outOfTreeManifest, path.join(localDir, MANIFEST_NAME), 'file'); + + const report = buildShadowReport('claude', { home, cwd }); + assert.strictEqual(report.shadowed, false, 'a symlinked manifest file must never be followed, even though its target is valid, matching content'); + assert.deepStrictEqual(report.triggers, []); + }); + + test('an unsymlinked local config still reads (B16, negative proof)', (t) => { + const home = createTempDir('gsd-shadow-sec-b16-home-'); + const cwd = createTempDir('gsd-shadow-sec-b16-cwd-'); + t.after(() => { cleanup(home); cleanup(cwd); }); + + const globalDir = path.join(home, '.claude'); + const localDir = path.join(cwd, '.claude'); + fs.mkdirSync(globalDir, { recursive: true }); + fs.mkdirSync(localDir, { recursive: true }); + fs.writeFileSync(path.join(globalDir, MANIFEST_NAME), JSON.stringify({ + manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' }, + })); + fs.writeFileSync(path.join(localDir, MANIFEST_NAME), JSON.stringify({ + manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' }, + })); + + const report = buildShadowReport('claude', { home, cwd }); + assert.strictEqual(report.shadowed, true, 'the guard must not break the ordinary, unsymlinked happy path'); + assert.strictEqual(report.triggers.length, 1); + }); +}); diff --git a/tests/shadow-report.test.cjs b/tests/shadow-report.test.cjs new file mode 100644 index 000000000..09d43b476 --- /dev/null +++ b/tests/shadow-report.test.cjs @@ -0,0 +1,613 @@ +'use strict'; + +/** + * tests/shadow-report.test.cjs — pure IR unit suite for + * `install-shadow-report.cts`'s `buildShadowReport` (#2873, epic #2866 Phase + * 4a — governed by `.gsd/phase/feat-2873-cross-scope-shadowing/40-design.md`). + * + * Implements matrix section A (`buildShadowReport()`, rows A1-A24) and + * properties F1/F4 from `.gsd/phase/feat-2873-cross-scope-shadowing/50-test-matrix.md`. + * The matrix's own "Suites" section names this file `install-shadow-report + * .test.cjs`; it is shipped as `shadow-report.test.cjs` instead so its + * `lint-test-file-count.cjs` prefix is `shadow` (0 files before this PR, at + * the 2-file cap after it) rather than colliding with the already + * grandfathered, already-at-cap `install` prefix bucket. + * + * A21-A24 cover the per-scope truth filter (#2873 Task 1): a `full`-profile + * global install alongside a `core`-profile local install must never report + * the profile-only stems as shadowed local artifacts that do not exist on + * disk. F1 is updated in lockstep — its expected shadowed set is now the + * INTERSECTION of the two scopes' stems, not their union. + * + * F4 ("4b transform is idempotent over arbitrary bodies") targets + * `resolveSpecRootReference` (`runtime-artifact-conversion.cts`, #2873 Phase + * 4b), which has since landed — see the "F4" describe block below. + * + * Fixture strategy mirrors `tests/installed-surface-resolver.test.cjs` + * (`buildShadowReport` forwards its `opts` verbatim to + * `resolveInstalledSurfaces`): an injectable `readManifest` keyed by the + * REAL `configHome` `resolveScope` computes for a given runtime/scope, never + * a hand-typed path literal. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); +const fc = require('./helpers/fast-check-setup.cjs'); + +const { + buildShadowReport, + renderShadowReport, + SHADOW_REASON, +} = require('../gsd-core/bin/lib/install-shadow-report.cjs'); +const { resolveScope } = require('../gsd-core/bin/lib/install-scope.cjs'); +const { MANIFEST_NAME } = require('../gsd-core/bin/lib/installer-migrations.cjs'); +const { resolveSpecRootReference } = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); + +// ─── Fixture helpers (mirrors tests/installed-surface-resolver.test.cjs) ─── + +const ABSENT_MANIFEST = Object.freeze({ manifestVersion: null, runtime: null, scope: null, files: {} }); + +function manifest({ manifestVersion = null, runtime = null, scope = null, files = {} } = {}) { + return { manifestVersion, runtime, scope, files }; +} + +function mkReadManifest(byConfigHome) { + return (configDir) => byConfigHome.get(configDir) ?? ABSENT_MANIFEST; +} + +function scopeHomes(runtime, home, cwd) { + const base = { runtime, env: {}, home, existsSync: () => false, cwd }; + return { + global: resolveScope({ ...base, id: 'global' }).configHome, + local: resolveScope({ ...base, id: 'local' }).configHome, + }; +} + +function baseOpts(home, cwd, overrides = {}) { + return { home, cwd, env: {}, existsSync: () => false, ...overrides }; +} + +/** `commands/gsd/*.md` stems shipped by the real repo — used so A1's "71 + * entries" tracks the real roster instead of a hardcoded, driftable count. */ +const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); +const REAL_STEMS = fs.readdirSync(REAL_COMMANDS_DIR) + .filter((f) => f.endsWith('.md')) + .map((f) => f.slice(0, -3)) + .sort(); + +function skillFilesFor(stems) { + const files = {}; + for (const s of stems) files[`skills/gsd-${s}/SKILL.md`] = 'x'; + return files; +} + +function commandFilesFor(stems) { + const files = {}; + for (const s of stems) files[`commands/gsd-${s}.md`] = 'x'; + return files; +} + +/** Build a claude coexistence fixture: `stems` installed as global skills AND + * local commands (so every one of them is a shadowed trigger). */ +function coexistenceOpts(home, cwd, stems, overrides = {}) { + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(stems) })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(stems) })], + ]); + return baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome), ...overrides }); +} + +// ─── A1-A6 — shape happy/negative paths ──────────────────────────────────── + +describe('buildShadowReport — shape (A1-A6)', () => { + test('reports shadowing for a claude coexistence, full real roster (A1)', () => { + const home = '/fixture/a1-home'; + const cwd = '/fixture/a1-cwd'; + const report = buildShadowReport('claude', coexistenceOpts(home, cwd, REAL_STEMS)); + assert.strictEqual(report.shadowed, true); + assert.strictEqual(report.reason, SHADOW_REASON.SCOPE_SHADOWED); + assert.strictEqual(report.triggers.length, REAL_STEMS.length); + assert.deepStrictEqual(report.winner, { kind: 'skills', scope: 'global' }); + assert.deepStrictEqual(report.shadowedSide, { kind: 'commands', scope: 'local' }); + assert.deepStrictEqual(report.triggers.map((t) => t.trigger).sort(), REAL_STEMS.map((s) => `gsd-${s}`)); + }); + + test('no report for a single scope, global only (A2)', () => { + const home = '/fixture/a2-home'; + const cwd = '/fixture/a2-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['plan-phase']) })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.shadowed, false); + assert.strictEqual(report.reason, SHADOW_REASON.NOT_SHADOWED); + assert.deepStrictEqual(report.triggers, []); + }); + + test('no report for local-only (A3)', () => { + const home = '/fixture/a3-home'; + const cwd = '/fixture/a3-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['plan-phase']) })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.shadowed, false); + assert.deepStrictEqual(report.triggers, []); + }); + + test('same-kind shadowing is reported as override, not a vanished tree (A4)', () => { + const home = '/fixture/a4-home'; + const cwd = '/fixture/a4-cwd'; + const homes = scopeHomes('cursor', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'cursor', scope: 'global', files: skillFilesFor(['plan-phase']) })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'cursor', scope: 'local', files: skillFilesFor(['plan-phase']) })], + ]); + const report = buildShadowReport('cursor', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.shadowed, true); + assert.strictEqual(report.kindsDiffer, false); + assert.deepStrictEqual(report.winner, { kind: 'skills', scope: 'global' }); + assert.deepStrictEqual(report.shadowedSide, { kind: 'skills', scope: 'local' }); + }); + + test('windsurf asymmetry does not collide (A5)', () => { + const home = '/fixture/a5-home'; + const cwd = '/fixture/a5-cwd'; + const homes = scopeHomes('windsurf', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'windsurf', scope: 'global', files: { 'agents/gsd-planner.md': 'a' } })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'windsurf', scope: 'local', files: { 'workflows/gsd-plan-phase.md': 'a' } })], + ]); + const report = buildShadowReport('windsurf', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.shadowed, false); + }); + + test('same config home is not self-shadowing (A6)', () => { + const shared = '/fixture/a6-shared-home'; + const homes = scopeHomes('claude', shared, shared); + assert.strictEqual(homes.global, homes.local, 'fixture assumption: both scopes collapse to one configHome'); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['plan-phase']) })], + ]); + const report = buildShadowReport('claude', baseOpts(shared, shared, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.shadowed, false); + }); +}); + +// ─── A7-A10 — SAMPLE_LIMIT boundary (limit-1, limit, limit+1) ────────────── + +describe('buildShadowReport — sample-limit boundary (A7-A10)', () => { + test('zero triggers renders nothing (A7, limit-1 in the sense of "below any sample")', () => { + const home = '/fixture/a7-home'; + const cwd = '/fixture/a7-cwd'; + const homes = scopeHomes('claude', home, cwd); + // Both scopes installed (manifestVersion set) but with an empty `files` + // map each — `deriveStemsFromManifest` short-circuits to `[]` for an + // empty `files` BEFORE resolving a layout at all (installed-surface- + // resolver.cts's C13), so the stem union across both scopes is empty and + // no trigger is ever synthesized. NOT a disjoint-stems fixture: because + // `resolveInstalledSurfaces` unions stems across every INSTALLED scope + // (not per-scope), two scopes installed with genuinely DIFFERENT, + // non-empty stem sets still produce a shadowed entry for each stem in + // the union — see A12's comment for the same mechanism. + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: {} })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: {} })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.shadowed, false); + assert.deepStrictEqual(report.triggers, []); + assert.deepStrictEqual(renderShadowReport(report), []); + }); + + test('single trigger has no overflow tail (A8, limit=1)', () => { + const home = '/fixture/a8-home'; + const cwd = '/fixture/a8-cwd'; + const report = buildShadowReport('claude', coexistenceOpts(home, cwd, ['solo'])); + assert.strictEqual(report.triggers.length, 1); + const lines = renderShadowReport(report); + // header + exactly one sample line, no "...and N more" tail, no mismatch notes. + assert.strictEqual(lines.length, 2); + }); + + test('sample limit exactly, 5 shadowed (A9)', () => { + const home = '/fixture/a9-home'; + const cwd = '/fixture/a9-cwd'; + const stems = ['s1', 's2', 's3', 's4', 's5']; + const report = buildShadowReport('claude', coexistenceOpts(home, cwd, stems)); + assert.strictEqual(report.triggers.length, 5); + const lines = renderShadowReport(report); + // header + 5 samples, still no tail. + assert.strictEqual(lines.length, 6); + }); + + test('sample limit plus one, 6 shadowed (A10)', () => { + const home = '/fixture/a10-home'; + const cwd = '/fixture/a10-cwd'; + const stems = ['s1', 's2', 's3', 's4', 's5', 's6']; + const report = buildShadowReport('claude', coexistenceOpts(home, cwd, stems)); + assert.strictEqual(report.triggers.length, 6); + const lines = renderShadowReport(report); + // header + 5 samples + one overflow-tail line. + assert.strictEqual(lines.length, 7); + }); +}); + +// ─── A11-A13 — manifest content edge cases ───────────────────────────────── + +describe('buildShadowReport — manifest content edge cases (A11-A13)', () => { + test('v1 manifest still reports shadowing, identical to v2, no reinstall signal in the IR (A11)', () => { + const home = '/fixture/a11-home'; + const cwd = '/fixture/a11-cwd'; + const homesV1 = scopeHomes('claude', home, cwd); + const byConfigHomeV1 = new Map([ + [homesV1.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['plan-phase']) })], + // v1: no manifestVersion key at all, normalized to 1; no declared runtime/scope. + [homesV1.local, manifest({ manifestVersion: 1, runtime: null, scope: null, files: commandFilesFor(['plan-phase']) })], + ]); + const reportV1 = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHomeV1) })); + + const byConfigHomeV2 = new Map([ + [homesV1.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['plan-phase']) })], + [homesV1.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['plan-phase']) })], + ]); + const reportV2 = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHomeV2) })); + + assert.strictEqual(reportV1.shadowed, true); + assert.deepStrictEqual(reportV1, reportV2, 'a v1-backed report must be structurally identical to its v2 counterpart'); + + // No reinstall/version signal anywhere in the IR's shape. + assert.ok(!('manifestVersion' in reportV1)); + for (const trig of reportV1.triggers) assert.ok(!('manifestVersion' in trig)); + for (const m of reportV1.mismatches) assert.ok(!('manifestVersion' in m)); + }); + + test('empty manifest yields no triggers (A12)', () => { + const home = '/fixture/a12-home'; + const cwd = '/fixture/a12-cwd'; + const homes = scopeHomes('claude', home, cwd); + // Deliberately only ONE scope present, with an empty `files` map — the + // clean exercise of the empty-files short-circuit (deriveStemsFromManifest's + // C13) in isolation. A COEXISTENCE fixture (both scopes installed, one + // side's `files: {}`) does NOT stay `shadowed: false`: because + // `resolveInstalledSurfaces` unions stems across every scope it counts as + // installed (manifestVersion set, regardless of that scope's own file + // count) rather than per-scope, a real stem contributed by the OTHER, + // populated scope still gets a synthesized trigger at this empty one — + // see `installed-surface-resolver.cts`'s `stemUnion` computation. That is + // established, already-tested Phase 3 (#2872) behavior (the roster is + // assumed uniform across installed scopes), not something this row + // exercises. + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: {} })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.shadowed, false); + assert.deepStrictEqual(report.triggers, []); + }); + + test('unreadable manifest degrades, never throws (A13)', () => { + const home = '/fixture/a13-home'; + const cwd = '/fixture/a13-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['plan-phase']) })], + ]); + const readManifest = (configDir) => { + if (configDir === homes.local) throw new Error('EACCES: permission denied'); + return byConfigHome.get(configDir) ?? ABSENT_MANIFEST; + }; + let report; + assert.doesNotThrow(() => { + report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest })); + }); + assert.strictEqual(report.shadowed, false); + }); +}); + +// ─── A14-A15 — declared-runtime/scope mismatch surfaced, not corrected ───── + +describe('buildShadowReport — mismatches are reported, never corrected (A14-A15)', () => { + test('declared runtime mismatch is surfaced (A14)', () => { + const home = '/fixture/a14-home'; + const cwd = '/fixture/a14-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'cursor', scope: 'global', files: skillFilesFor(['plan-phase']) })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['plan-phase']) })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.mismatches.length, 1); + assert.strictEqual(report.mismatches[0].scope, 'global'); + assert.strictEqual(report.mismatches[0].declaredRuntime, 'cursor'); + assert.strictEqual(report.mismatches[0].declaredRuntimeMatchesProbe, false); + }); + + test('declared scope mismatch is surfaced (A15)', () => { + const home = '/fixture/a15-home'; + const cwd = '/fixture/a15-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: skillFilesFor(['plan-phase']) })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['plan-phase']) })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.mismatches.length, 1); + assert.strictEqual(report.mismatches[0].scope, 'global'); + assert.strictEqual(report.mismatches[0].declaredScope, 'local'); + assert.strictEqual(report.mismatches[0].declaredScopeMatchesProbe, false); + }); +}); + +// ─── A16-A17 — malformed runtime degrades, never propagates ─────────────── + +describe('buildShadowReport — non-installable / unknown runtime degrades (A16-A17)', () => { + test('non-installable runtime degrades to no report (A16, vscode)', () => { + const home = '/fixture/a16-home'; + const cwd = '/fixture/a16-cwd'; + let report; + assert.doesNotThrow(() => { + report = buildShadowReport('vscode', baseOpts(home, cwd, { readManifest: mkReadManifest(new Map()) })); + }); + assert.strictEqual(report.reason, SHADOW_REASON.RESOLVER_UNAVAILABLE); + assert.strictEqual(report.shadowed, false); + assert.deepStrictEqual(renderShadowReport(report), []); + }); + + test('unknown runtime degrades to no report (A17)', () => { + const home = '/fixture/a17-home'; + const cwd = '/fixture/a17-cwd'; + let report; + assert.doesNotThrow(() => { + report = buildShadowReport('not-a-real-runtime-xyz', baseOpts(home, cwd, { readManifest: mkReadManifest(new Map()) })); + }); + assert.strictEqual(report.reason, SHADOW_REASON.RESOLVER_UNAVAILABLE); + assert.strictEqual(report.shadowed, false); + }); +}); + +// ─── A18 — caller mutation cannot corrupt a later call ───────────────────── + +describe('buildShadowReport — independence across calls (A18)', () => { + test('report is not shared across calls', () => { + const home = '/fixture/a18-home'; + const cwd = '/fixture/a18-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'cursor', scope: 'global', files: skillFilesFor(['plan-phase']) })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['plan-phase']) })], + ]); + const opts = baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }); + + const first = buildShadowReport('claude', opts); + const pristine = JSON.parse(JSON.stringify(first)); + + first.winner.kind = 'HACKED'; + first.triggers[0].trigger = 'HACKED'; + first.triggers.push({ trigger: 'INJECTED' }); + first.mismatches[0].declaredRuntime = 'HACKED'; + first.mismatches.push({ scope: 'INJECTED' }); + + const second = buildShadowReport('claude', opts); + assert.deepStrictEqual(second, pristine, 'a second call must be unaffected by mutation of the first result'); + }); +}); + +// ─── A19 — the production call shape ─────────────────────────────────────── + +describe('buildShadowReport — production call shape (A19)', () => { + test('production call shape resolves, matches the injected-dep rows\' shape', (t) => { + const home = createTempDir('gsd-shadow-a19-home-'); + const cwd = createTempDir('gsd-shadow-a19-cwd-'); + t.after(() => { cleanup(home); cleanup(cwd); }); + + const globalDir = path.join(home, '.claude'); + const localDir = path.join(cwd, '.claude'); + fs.mkdirSync(globalDir, { recursive: true }); + fs.mkdirSync(localDir, { recursive: true }); + fs.writeFileSync(path.join(globalDir, MANIFEST_NAME), JSON.stringify({ + manifestVersion: 2, runtime: 'claude', scope: 'global', + files: skillFilesFor(['plan-phase']), + })); + fs.writeFileSync(path.join(localDir, MANIFEST_NAME), JSON.stringify({ + manifestVersion: 2, runtime: 'claude', scope: 'local', + files: commandFilesFor(['plan-phase']), + })); + + let report; + assert.doesNotThrow(() => { + // The exact production call shape: no injected registry, no injected + // readManifest — real fs, real capability registry. + report = buildShadowReport('claude', { home, cwd }); + }); + assert.strictEqual(report.shadowed, true); + assert.deepStrictEqual(report.winner, { kind: 'skills', scope: 'global' }); + assert.deepStrictEqual(report.shadowedSide, { kind: 'commands', scope: 'local' }); + assert.strictEqual(report.triggers.length, 1); + assert.deepStrictEqual( + Object.keys(report).sort(), + ['kindsDiffer', 'mismatches', 'reason', 'runtime', 'shadowed', 'shadowedSide', 'triggers', 'winner'], + ); + }); +}); + +// ─── A20 — frozen reason-code enum key set is locked ─────────────────────── + +describe('SHADOW_REASON (A20)', () => { + test('reason enum key set is locked', () => { + assert.deepStrictEqual( + Object.keys(SHADOW_REASON).sort(), + ['NOT_SHADOWED', 'RESOLVER_UNAVAILABLE', 'SCOPE_SHADOWED'], + ); + }); + + test('is frozen', () => { + assert.strictEqual(Object.isFrozen(SHADOW_REASON), true); + }); +}); + +// ─── A21-A24 — per-scope truth filter (cross-scope stem-union false positive) ─ + +describe('buildShadowReport — per-scope truth filter (A21-A24)', () => { + test('both scopes carry the same stems: every shadowed trigger reported (A21)', () => { + const home = '/fixture/a21-home'; + const cwd = '/fixture/a21-cwd'; + const stems = ['plan-phase', 'milestone-complete', 'phase-create']; + const report = buildShadowReport('claude', coexistenceOpts(home, cwd, stems)); + assert.strictEqual(report.shadowed, true); + assert.deepStrictEqual(report.triggers.map((t) => t.trigger).sort(), stems.map((s) => `gsd-${s}`).sort()); + }); + + test('global strict superset of local (full vs core profile): only the intersection is reported (A22)', () => { + const home = '/fixture/a22-home'; + const cwd = '/fixture/a22-cwd'; + const homes = scopeHomes('claude', home, cwd); + // global = 'full' profile (a, b, c) — local = 'core' profile (a only). + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['a', 'b', 'c']) })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['a']) })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.shadowed, true); + // Only 'a' is a REAL local artifact — 'b' and 'c' must never be reported + // as shadowed local commands; there is no local artifact for either. + assert.deepStrictEqual(report.triggers.map((t) => t.trigger), ['gsd-a']); + }); + + test('local has a stem global does not: not reported as shadowed (A23)', () => { + const home = '/fixture/a23-home'; + const cwd = '/fixture/a23-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['a']) })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['a', 'z']) })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.shadowed, true); + // 'z' exists ONLY at local (no global artifact "wins" it) — must not + // appear in the shadowed set at all. + assert.deepStrictEqual(report.triggers.map((t) => t.trigger), ['gsd-a']); + }); + + test('disjoint stem sets: shadowed is false (A24)', () => { + const home = '/fixture/a24-home'; + const cwd = '/fixture/a24-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(['a', 'b']) })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(['x', 'y']) })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(report.shadowed, false); + assert.deepStrictEqual(report.triggers, []); + }); +}); + +// ─── F1 — bijective property: every trigger has exactly one winner ──────── + +describe('buildShadowReport — property (F1)', () => { + test('every trigger has exactly one winner', () => { + const stemArb = fc.stringMatching(/^[a-z0-9]{1,6}(-[a-z0-9]{1,6}){0,2}$/); + const setArb = fc.uniqueArray(stemArb, { maxLength: 6 }); + + fc.assert( + fc.property(setArb, setArb, (globalStems, localStems) => { + const home = '/fixture/f1-home'; + const cwd = '/fixture/f1-cwd'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: skillFilesFor(globalStems) })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: commandFilesFor(localStems) })], + ]); + const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + + // Both scopes are always "installed" here (manifestVersion set + // regardless of file-list length), so `resolveOneRuntime`'s + // `stemUnion` still synthesizes a candidate trigger for every stem + // observed at EITHER scope. `buildShadowReport`'s per-scope truth + // filter (#2873 Task 1 — see install-shadow-report.cts's module + // comment) then narrows that down to the INTERSECTION: a trigger is + // only reported when a real artifact exists at BOTH scopes. + const expectedShadowed = globalStems + .filter((s) => localStems.includes(s)) + .map((s) => `gsd-${s}`) + .sort(); + const actualShadowed = report.triggers.map((t) => t.trigger).sort(); + assert.deepStrictEqual(actualShadowed, expectedShadowed); + + // Bijection: each shadowed trigger names exactly one winner (kind,scope). + for (const trig of report.triggers) { + assert.strictEqual(trig.winnerKind, 'skills'); + assert.strictEqual(trig.winnerScope, 'global'); + assert.strictEqual(trig.shadowedKind, 'commands'); + assert.strictEqual(trig.shadowedScope, 'local'); + } + // No trigger name appears twice in the shadowed set. + assert.strictEqual(new Set(actualShadowed).size, actualShadowed.length); + }), + // Explicit seed + bounded numRuns (CONTRIBUTING: unseeded property + // tests are a review blocker). On failure, fast-check's thrown error + // carries the pinned seed and the shrunk counterexample needed to + // replay deterministically. + { seed: 20260814, numRuns: 50 }, + ); + }); +}); + +// ─── F4 — resolveSpecRootReference is idempotent over arbitrary bodies ──── + +describe('resolveSpecRootReference — property (F4)', () => { + test('spec-root transform is idempotent over arbitrary bodies', () => { + // A bare fc.string() body would almost never contain the exact + // `@~/.claude/gsd-core/workflows/.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}`, + ); + const plainTextArb = fc.string({ maxLength: 40 }); + + const chunkArb = fc.oneof( + includeLineArb, + proseMentionArb, + planningIncludeArb, + fencedIncludeArb, + plainTextArb, + ); + const bodyArb = fc.array(chunkArb, { maxLength: 12 }).map((chunks) => chunks.join('\n')); + + fc.assert( + fc.property(bodyArb, (body) => { + const once = resolveSpecRootReference(body); + const twice = resolveSpecRootReference(once); + assert.strictEqual( + twice, + once, + `not idempotent — body: ${JSON.stringify(body)}\nonce: ${JSON.stringify(once)}\ntwice: ${JSON.stringify(twice)}`, + ); + }), + // Explicit seed + bounded numRuns, replay data printed on failure via + // the assertion message above (fast-check's own thrown error additionally + // carries the pinned seed + shrunk counterexample needed to replay). + { seed: 20260814, numRuns: 300 }, + ); + }); +});