diff --git a/.changeset/bold-orcas-sing.md b/.changeset/bold-orcas-sing.md new file mode 100644 index 000000000..202c34868 --- /dev/null +++ b/.changeset/bold-orcas-sing.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 3323 +--- +**The install manifest now records which runtime and scope wrote it** — a global and a project-local install used to write two `gsd-file-manifest.json` files that neither named their own runtime nor their own scope, so nothing could answer "which GSD surfaces are installed, where". The manifest gains `manifestVersion`, `runtime` and `scope`, and a new read-only Installed Surface Resolver reads both scopes at once. Manifests written by earlier versions are read without error and need no reinstall. (#2872) diff --git a/.gitignore b/.gitignore index 3b7788372..80d1e85a9 100644 --- a/.gitignore +++ b/.gitignore @@ -199,6 +199,7 @@ build/ /gsd-core/bin/lib/runtime-artifact-conversion.cjs /gsd-core/bin/lib/runtime-artifact-layout.cjs /gsd-core/bin/lib/install-scope.cjs +/gsd-core/bin/lib/installed-surface-resolver.cjs /gsd-core/bin/lib/runtime-config-adapter-registry.cjs /gsd-core/bin/lib/runtime-hooks-surface.cjs /gsd-core/bin/lib/command-routing-hub.cjs diff --git a/CONTEXT.md b/CONTEXT.md index eb68d7d64..6c2d35e42 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -242,6 +242,9 @@ Module owning install-time staging and content-rewrite selection for a pre-resol ### Install Scope Module Owns the two-value install-scope axis (`'global' | 'local'`) as a typed value, replacing the bare `isGlobal ? 'global' : 'local'` string re-derived at 12 sites in `bin/install.js` plus several downstream re-derivations (#2870, ADR-2866). Interface: `resolveScope({ id, runtime, explicitDir?, env?, home?, existsSync? }) -> { id, configHome, settingsFile, consentRequired, hostPrecedenceRank }` — pure (no writes, never mutates `input`) and the returned value is frozen so a caller cannot corrupt a subsequent resolution. Owns the `InstallScope` type name: previously a private, non-exported `TypeAlias` inside Runtime Artifact Install Plan Module; that module now `import type`s it from here instead of re-declaring it, so the codebase does not grow a fifth spelling of the axis alongside the layout module's `'local' | 'global'`, `capability-lifecycle.cts`'s `'global' | 'project'`, and `capability-consent.cts`'s single `'project'` literal. `configHome` for `global` composes `resolveConfigHomeFromDescriptor` (Runtime Homes Module) unmodified rather than adding a `scope` parameter to it — that function is CRITICAL blast radius (60 dependents across 13 files); for `local` it joins the capability registry's per-runtime `localConfigDir` onto the real process cwd (the project you are standing in — not injectable via `home`, by design). `explicitDir` short-circuits both scopes identically to `getGlobalConfigDir`'s existing override, and every returned `configHome` is normalized to forward slashes UNCONDITIONALLY (`.replace(/\\/g,'/')`, never gated on `path.sep`). `settingsFile` reads the registry's `hostBehaviors.settingsFileByScope[id]` and is `null` for the 18 of 19 registered runtimes that declare none — absence is a value, not an invented Claude-shaped default; the one caller that legitimately wants a Claude fallback (`bin/install.js:550`) still applies it itself. `consentRequired` is `false` for `global` (nothing is recorded — matches Capability Registry Overlay's rule that a GLOBAL-scope capability is trusted without a consent record) and `true` for `local`; it reports the requirement only; it does not perform or waive consent. `hostPrecedenceRank` (`global` outranks `local`) is carried as data only this phase — unread until Phase 2 (#2871) defines precedence semantics. Throws `TypeError` for an invalid `id` (wrong case, empty, missing, or any non-string value — never coerced), an unknown `runtime`, or a runtime whose `configHome.kind === 'none'` (vscode — non-installable, #2103) — all three share one `instanceof TypeError` catch shape with Runtime Artifact Layout Module's existing unknown-runtime contract. **The `local`/`project` boundary is documented, not unified:** this module's `'local'` spelling — chosen because it is the CLI's own vocabulary (`--local`) and what the layout module and manifest already use — is deliberately NOT reconciled with Capability Consent Store's `ConsentRecord.scope: 'project'` or Capability Lifecycle's `'global' | 'project'` operations. `ConsentRecord.scope` is a value persisted on disk in user-owned consent records outside any repository; renaming that literal to match would silently invalidate every existing project-scoped consent record on a user's machine the next time it is read back — a far worse defect than the vocabulary split. The mapping instead lives here as a fact: install scope `'local'` ⇄ consent scope `'project'`; install scope `'global'` ⇄ no consent record at all. Source: `gsd-core/bin/lib/install-scope.cjs` (generated from `src/install-scope.cts`). See Runtime Homes Module, Runtime Artifact Layout Module, Runtime Artifact Install Plan Module, Capability Consent Store, Capability Lifecycle. +### Installed Surface Resolver Module +Read-only Module answering *"which GSD surfaces are installed, at which scopes, for which runtimes — and is one shadowing another?"* (#2872, ADR-2866 Phase 3). Interface: `resolveInstalledSurfaces(runtime?, { home?, cwd?, env?, existsSync?, registry?, readManifest? }) -> InstalledRuntimeSurface[]`, each carrying both scope records (`{ scope, configHome, installed, manifestVersion, declaredRuntime, declaredScope, declaredScopeMatchesProbe, declaredRuntimeMatchesProbe, stems }`) plus a `triggers: TriggerSurface[]` resolved in ONE `resolveTriggerSurface` call over **only the scopes actually installed** — which is what makes `shadowedBy` describe this machine rather than a hypothetical. **The first code path in the repo that reads both install scopes at once.** Mutates nothing; builds fresh objects per call; caches nothing. It composes rather than re-derives: `resolveScope` (Install Scope Module) supplies each scope's `configHome`, `readInstallManifest` (Installer Migration Module) supplies presence, and `resolveTriggerSurface` + the exported `isNamespacedByDir`/`composeCommandFilename` (Runtime Artifact Layout Module) supply the trigger surface — the stem derivation is the *inverse* of that filename composition and consumes those exports rather than becoming a fourth copy of the rule (`fast-check` round-trip property guards the bijection). **Installed-ness is decided by manifest PRESENCE, never by the schema-2 `runtime`/`scope` fields**, which is what keeps a pre-#2872 (schema-1) install fully functional with no reinstall; the recorded fields are corroboration, surfaced as `declaredScopeMatchesProbe` / `declaredRuntimeMatchesProbe` so a manifest that disagrees with the directory it was found in (an `--config-dir` install, a copied config tree) is **reported, never silently corrected**. A runtime whose `resolveScope` throws (unknown, or `configHome.kind === 'none'` — vscode) propagates the `TypeError` when asked for explicitly but is *skipped* in the all-runtimes sweep, so one non-installable runtime cannot kill the sweep. Two scopes resolving to one `configHome` are deduplicated before the trigger call, so a single physical install is never reported as shadowing itself. Ships unread this phase — Phase 4 (#2873) is its first consumer, and is where detection becomes user-visible output. Source: `gsd-core/bin/lib/installed-surface-resolver.cjs` (generated from `src/installed-surface-resolver.cts`). See Install Scope Module, Runtime Artifact Layout Module, Installer Migration Module, Agent Install Check Module. + ### Command Roster Module Tiny read-only helper Module owning discovery of canonical `commands/gsd/*.md` command stems for artifact conversion and runtime projection. It is a sibling dependency of Runtime Artifact Conversion Module, not part of conversion itself: conversion consumes a roster to safely rewrite `gsd:` / `/gsd-` references, while roster discovery owns filesystem/catalog knowledge. First slice: extract existing `readGsdCommandNames` behavior behind this Module instead of moving it into Runtime Artifact Conversion Module or keeping it as installer-owned state. diff --git a/bin/install.js b/bin/install.js index 5e0d752a8..dd3e583e9 100755 --- a/bin/install.js +++ b/bin/install.js @@ -672,6 +672,7 @@ function _runtimeAdapter(runtime) { const { applyInstallerMigrationPlan, discoverInstallerMigrations, + MANIFEST_SCHEMA_VERSION, runInstallerMigrations, } = require(path.join(_gsdLibDir, 'installer-migrations.cjs')); const { @@ -9636,13 +9637,31 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) { // so the manifest records what's actually on disk. _resolveSkillsRootDir already // resolves destSubpath (which includes hermes's 'skills/gsd' nesting) — do not // re-append 'gsd' or the hermes dir gets double-nested to skills/gsd/gsd. - const codexSkillsDir = _resolveSkillsRootDir(runtime, configDir, options.scope === 'local' ? 'local' : 'global'); + // #2872 (ADR-2866 Phase 3): the scope used to pick the skills root and the + // scope RECORDED in the manifest are one value, resolved once. Two reads of + // `options.scope` could drift; one cannot. + const resolvedScope = options.scope === 'local' ? 'local' : 'global'; + const codexSkillsDir = _resolveSkillsRootDir(runtime, configDir, resolvedScope); const codexSkillsManifestPrefix = _hostBehaviors(runtime).skillsManifestPrefix || 'skills/'; const agentsDir = path.join(configDir, 'agents'); const manifest = { + // Schema version of this DOCUMENT (#2872) — distinct from `version` + // below, which is the GSD package version. Absent ⇒ a pre-#2872 (v1) + // manifest, which readInstallManifest still reads without error and + // without requiring a reinstall. Read from the Installer Migration + // Module rather than repeated as a second literal: the writer here and + // the reader's normalizeManifestVersion are two surfaces over one + // constant, and this repo's "generative fix divergence" class is exactly + // two such literals drifting apart. + manifestVersion: MANIFEST_SCHEMA_VERSION, version: pkg.version, timestamp: new Date().toISOString(), mode: options.mode === 'minimal' ? 'minimal' : 'full', + // Recorded so an Installed Surface Resolver can answer "which surfaces + // are installed, at which scopes, for which runtimes" without re-deriving + // it from the directory it happened to be found in (#2872). + runtime, + scope: resolvedScope, files: {}, }; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 65c49907e..6ef036ad3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -779,7 +779,7 @@ The installer (`bin/install.js`, ~10,700 lines) handles: 5. **Path normalization** — Replaces `~/.claude/` paths with runtime-specific paths 6. **Settings integration** — Registers hooks in runtime's `settings.json` 7. **Patch backup** — Since v1.17, backs up locally modified files to `gsd-local-patches/` for `/gsd-update --reapply` -8. **Manifest tracking** — Writes `gsd-file-manifest.json` for clean uninstall +8. **Manifest tracking** — Writes `gsd-file-manifest.json` for clean uninstall. The manifest also records which `runtime` and which `scope` (`global`/`local`) wrote it, under a `manifestVersion` schema field, so a reader can answer "which surfaces are installed, at which scopes" without inferring it from the directory the file sits in ([ADR 2866](adr/2866-install-surface-resolution.md), #2872). Manifests written before that carry no such fields and are read without error — no reinstall is required. See [Installer Migrations → File Manifest](installer-migrations.md#file-manifest) 9. **Uninstall mode** — `--uninstall` removes all GSD files, hooks, and settings Install-time file moves, stale-artifact cleanup, config rewrites, and user-data diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 7ab4af02a..99d74c6b0 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -387,6 +387,7 @@ "install-engine.cjs", "install-profiles.cjs", "install-scope.cjs", + "installed-surface-resolver.cjs", "installer-migration-authoring.cjs", "installer-migration-report.cjs", "installer-migrations.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 7b09477d8..8c725f9ff 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -500,6 +500,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `install-engine.cjs` | Runtime-artifact install engine — `installRuntimeArtifacts`/`uninstallRuntimeArtifacts`/`installOpencodeFamilySkills` + their helpers, extracted from `bin/install.js` (ADR-1239 Phase B, #1679); install.js imports them back and injects `getCommitAttribution` | | `install-profiles.cjs` | Install profile allowlist + skill staging for `--minimal` install (#2762); single source of truth for which `gsd-*` skills/agents land in runtime config dirs | | `install-scope.cjs` | Install Scope Module — `resolveScope({id,runtime,...})` resolves the `'global'\|'local'` install-scope axis into `{id, configHome, settingsFile, consentRequired, hostPrecedenceRank}`, composing `resolveConfigHomeFromDescriptor` (`runtime-homes.cjs`) rather than modifying it (#2870, ADR-2866) | +| `installed-surface-resolver.cjs` | Installed Surface Resolver — `resolveInstalledSurfaces(runtime?, opts?)` probes both install scopes for a runtime (or every registered runtime, sorted, when omitted), reads each scope's manifest, and resolves the trigger surface across only the scopes actually installed on this machine, composing `resolveScope` and `resolveTriggerSurface` rather than re-deriving either (#2872, ADR-2866 Phase 3) | | `installer-migration-authoring.cjs` | Installer migration authoring guardrails for record metadata, explicit scopes, ownership evidence, and runtime contract citations | | `installer-migration-report.cjs` | Installer migration report projection and blocked-action guard for install/update integration | | `installer-migrations.cjs` | Installer migration planning, artifact classification, install-state persistence, journaled apply, and rollback helpers | diff --git a/docs/installer-migrations.md b/docs/installer-migrations.md index 5793730f9..884178cf4 100644 --- a/docs/installer-migrations.md +++ b/docs/installer-migrations.md @@ -88,8 +88,47 @@ record. ### File Manifest -The existing manifest remains the ownership baseline. It records the installed -GSD version, install mode, and hashes for distribution-owned files. +`gsd-file-manifest.json` remains the ownership baseline. It records the +installed GSD version, install mode, the runtime and scope that wrote it, and +hashes for distribution-owned files. + +```json +{ + "manifestVersion": 2, + "version": "1.9.2", + "timestamp": "2026-08-10T00:00:00.000Z", + "mode": "full", + "runtime": "claude", + "scope": "local", + "files": { + "gsd-core/VERSION": "sha256-hex…", + "commands/gsd-plan-phase.md": "sha256-hex…" + } +} +``` + +| Field | Meaning | +|---|---| +| `manifestVersion` | Schema version of **this document**. Absent ⇒ version 1, a manifest written before #2872. | +| `version` | The GSD **package** version that wrote the manifest — *not* the schema version. The two are separate fields on purpose. | +| `timestamp` | ISO-8601 write time. | +| `mode` | `full` or `minimal`. | +| `runtime` | The runtime this install targeted (`claude`, `codex`, …). Added in schema 2. | +| `scope` | `global` or `local`. Added in schema 2. | +| `files` | Relative path → SHA-256, for distribution-owned files only. | + +`runtime` and `scope` (#2872, [ADR-2866](adr/2866-install-surface-resolution.md)) +exist so a reader can answer *"which surfaces are installed, at which scopes, +for which runtimes"* without inferring it from the directory the file happened +to be found in. Before schema 2, a global and a local install wrote two +manifests to two directories that were never merged and never cross-read. + +**A schema-1 manifest is read without error and never requires a reinstall.** +`readInstallManifest` reports `manifestVersion: 1` with `runtime: null` and +`scope: null`, and every consumer treats the absence of those fields as +"not declared", never as "not installed". A `manifestVersion` written by a +*newer* GSD is reported verbatim rather than rejected — two GSD versions on one +machine is a supported state — so consumers branch on `>= 2`, never `=== 2`. The invariant is strict: @@ -99,28 +138,34 @@ The invariant is strict: ### Install State -The installer writes an install-state file next to the manifest. - -Required fields: +The installer writes `gsd-install-state.json` next to the manifest. It carries +migration bookkeeping only — the runtime, scope, version and mode of the +install live in the file manifest above, not here. ```json { - "schema": 1, - "runtime": "codex", - "scope": "global", - "installed_version": "1.50.0", - "install_mode": "full", - "applied_migrations": [ + "schemaVersion": 1, + "appliedMigrations": [ { "id": "2026-05-11-codex-hooks-layout", - "package_version": "1.50.0", + "packageVersion": "1.50.0", "checksum": "sha256:...", - "applied_at": "2026-05-11T00:00:00.000Z" + "appliedAt": "2026-05-11T00:00:00.000Z" } ] } ``` +> **Corrected 2026-08-10 (#2872).** This section previously documented five +> fields — `schema`, `runtime`, `scope`, `installed_version`, `install_mode` — +> in snake_case. None of them were ever written: `InstallState` +> (`src/installer-migrations.cts`) has only ever been +> `{ schemaVersion, appliedMigrations }`, in camelCase. The stale schema was +> load-bearing in the wrong direction — a reader trusting it would have +> concluded that install scope and runtime were already recorded on disk, which +> is the exact premise [ADR-2866](adr/2866-install-surface-resolution.md) was +> written to fix. + The checksum is calculated from the migration definition. An already-applied migration is never re-run, so a drifted checksum is diff --git a/eslint.config.mjs b/eslint.config.mjs index 42045f1c9..4a9f34498 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -174,6 +174,7 @@ export default tseslint.config( 'gsd-core/bin/lib/runtime-artifact-install-plan.cjs', 'gsd-core/bin/lib/runtime-artifact-layout.cjs', 'gsd-core/bin/lib/install-scope.cjs', + 'gsd-core/bin/lib/installed-surface-resolver.cjs', 'gsd-core/bin/lib/runtime-config-adapter-registry.cjs', 'gsd-core/bin/lib/runtime-hooks-surface.cjs', 'gsd-core/bin/lib/command-routing-hub.cjs', diff --git a/src/agent-install-check.cts b/src/agent-install-check.cts index 1201362af..6c3687601 100644 --- a/src/agent-install-check.cts +++ b/src/agent-install-check.cts @@ -183,23 +183,15 @@ function checkAgentsInstalled(runtime?: string, projectRoot?: string): AgentsIns // when the plain presence check above passed (e.g. .md present, .toml absent). // If no manifest is found the check is a no-op (graceful for claude/bundled). const incomplete: string[] = []; - const manifestPath = path.join(path.dirname(agentsDir), 'gsd-file-manifest.json'); - let manifestFiles: Record = {}; - try { - const raw = fs.readFileSync(manifestPath, 'utf8'); - const parsed: unknown = JSON.parse(raw); - if ( - parsed !== null && - typeof parsed === 'object' && - 'files' in parsed && - typeof (parsed as Record)['files'] === 'object' && - (parsed as Record)['files'] !== null - ) { - manifestFiles = (parsed as Record>)['files']; - } - } catch { - // No manifest or unreadable — completeness check is skipped - } + // #2872: the manifest read is the Installer Migration Module's, not a + // fourth private copy of it. Lazily required — matching this file's own + // capability-registry idiom — so a pure read/verify surface on the + // init/verify hot path takes no new static dependency. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { readInstallManifest } = require('./installer-migrations.cjs') as { + readInstallManifest: (configDir: string) => { files: Record }; + }; + const manifestFiles: Record = readInstallManifest(path.dirname(agentsDir)).files; if (Object.keys(manifestFiles).length > 0) { for (const agent of expectedAgents) { diff --git a/src/install-scope.cts b/src/install-scope.cts index 032debf7b..872df3474 100644 --- a/src/install-scope.cts +++ b/src/install-scope.cts @@ -132,6 +132,16 @@ export function validateScopeId(id: unknown, caller: string): InstallScope { return id as InstallScope; } +/** + * Non-throwing sibling of {@link validateScopeId}, for readers that must + * report an unrecognized scope as a value rather than fail (#2872). Reads the + * same `VALID_SCOPE_IDS` set, so the two can never disagree about what a + * scope is. + */ +export function isInstallScopeId(value: unknown): value is InstallScope { + return typeof value === 'string' && VALID_SCOPE_IDS.has(value); +} + // Higher wins. Not exported as a public constant — only the resulting // `hostPrecedenceRank` field on `ResolvedScope` is public API, so a future // re-basing of the literal values (Phase 2, #2871) never requires touching @@ -338,3 +348,14 @@ export function isGlobalScope(scope: InstallScope): boolean { export function scopeRank(id: InstallScope): number { return HOST_PRECEDENCE_RANK[validateScopeId(id, 'scopeRank')]; } + +/** + * Both scope ids, highest host precedence first. The ONE ordering of the + * install-scope axis: `runtime-artifact-layout.cts`'s trigger resolution and + * `installed-surface-resolver.cts`'s scope-record construction both consume + * this rather than each re-declaring `['global','local']` (#2872 review + * finding — this repo's recorded "generative fix divergence" class). Frozen so + * a caller cannot reorder it for everyone else. Ordering is not arbitrary: it + * is `scopeRank` descending, and a test locks that so the two cannot drift. + */ +export const SCOPE_ORDER: readonly InstallScope[] = Object.freeze(['global', 'local']); diff --git a/src/installed-surface-resolver.cts b/src/installed-surface-resolver.cts new file mode 100644 index 000000000..a1b2c4c10 --- /dev/null +++ b/src/installed-surface-resolver.cts @@ -0,0 +1,433 @@ +/** + * installed-surface-resolver.cts — Installed Surface Resolver Module (#2872, + * ADR-2866, Phase 3 — governed by + * `.gsd/phase/feat-2872-manifest-scope-runtime/40-design.md`). + * + * `resolveInstalledSurfaces(runtime?, opts?)` answers a question no existing + * module answers: "which surfaces actually exist on THIS machine, across + * BOTH install scopes, right now?" `capability-state.cts` answers "which + * capabilities are enabled in THIS config dir" — a different question at a + * different altitude. This module is a new leaf: it reads two scopes at + * once, composing three already-shipped, already-tested pieces + * (`resolveScope`, Phase 1; `resolveTriggerSurface`, Phase 2; and + * `readInstallManifest`, widened in this same phase) rather than + * re-implementing any of their rules. + * + * ── Probe-not-declared keying ─────────────────────────────────────────────── + * A scope RECORD is keyed by the PROBED scope (`resolveScope`'s own `id`), + * never by what the manifest itself claims. `manifestVersion`/`runtime`/ + * `scope` recorded inside a v2 manifest are corroboration, not identity: a + * manifest copied between config dirs, or a `--config-dir` install, must + * still be reported at the scope this machine actually resolves to. + * + * ── Report, don't correct ─────────────────────────────────────────────────── + * When a declared `runtime`/`scope` disagrees with the probe, this module + * reports the mismatch (`declaredScopeMatchesProbe: false` / + * `declaredRuntimeMatchesProbe: false`) and otherwise proceeds exactly as it + * would for an undeclared (v1) manifest. It never silently substitutes the + * declared value for the probed one, and never throws on a mismatch — see + * B-row "Postel's Law" discussion in the design doc. + * + * ── One trigger call over installed scopes only ───────────────────────────── + * `resolveTriggerSurface` is called AT MOST once per runtime, with the + * scopes that are actually installed on this machine (per manifest + * presence — never per the new corroboration fields, which is what keeps a + * v1-only install fully functional with no reinstall required). A + * hypothetical "what if both scopes were installed" answer is deliberately + * not offered; #2218 is a fact about THIS machine, not a simulation. + * + * ── Installed-ness is manifest PRESENCE, never the new fields ─────────────── + * `installed` is `manifestVersion !== null`. A v1 manifest (no + * `manifestVersion` key at all, `manifestVersion: 1` after normalization) is + * a correct manifest written by an older GSD — never a broken one, never + * grounds for `installed: false`, a warning, or a reinstall prompt. + * + * ── Stems come from the manifest, not the source tree ─────────────────────── + * `resolveTriggerSurface` needs `stems`. They are derived from the + * INSTALLED manifest's own `files` keys (see the private stem-derivation + * helpers below), never from a roster read of the source tree — a global + * `claude` install ships no `commands/gsd` source, so a roster read would + * return `[]` in exactly the configuration #2218 is about. The derivation + * consumes `isNamespacedByDir` and `composeCommandFilename` + * (`runtime-artifact-layout.cjs`, #2871 Phase 2's exported helpers) rather + * than re-deriving either rule as a fourth independent copy. + */ + +import { resolveScope, SCOPE_ORDER, type InstallScope } from './install-scope.cjs'; +import { posixNormalize } from './shell-command-projection.cjs'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import runtimeArtifactLayoutMod = require('./runtime-artifact-layout.cjs'); +const { + resolveRuntimeArtifactLayout, + resolveRuntimeArtifactLayoutFromRegistry, + resolveTriggerSurface, + isNamespacedByDir, + composeCommandFilename, +} = runtimeArtifactLayoutMod; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import installerMigrationsMod = require('./installer-migrations.cjs'); +const { readInstallManifest } = installerMigrationsMod; + +// In .cts (CommonJS output) files, `require` is available as a global. +const _require: NodeRequire = require; + +// ── Types derived from the two composed modules ──────────────────────────── +// +// Neither `TriggerSurface` nor the two modules' private registry shapes are +// exported by name from `runtime-artifact-layout.cts` (only the functions +// are, via its `export =`). Deriving the types with `ReturnType`/ +// `Parameters` off the imported function values — rather than duplicating +// the shapes here as a second, driftable copy — is the same pattern already +// used elsewhere in this codebase (e.g. `phase.cts`, `config.cts`). + +/** One resolved `/gsd-`-style trigger, as `resolveTriggerSurface` + * (`runtime-artifact-layout.cts`, #2871 Phase 2) produces it. */ +type TriggerSurface = ReturnType[number]; + +/** The registry shape `resolveRuntimeArtifactLayoutFromRegistry` accepts as + * its first argument — reused so `opts.registry` can be forwarded to it + * without a second, independently-typed registry shape. */ +type LayoutRegistryLike = Parameters[0]; + +/** The registry shape `resolveTriggerSurface`'s `opts.registry` accepts. */ +type TriggerRegistryLike = NonNullable[2]['registry']>; + +/** Minimal shape this module needs to enumerate registered runtime ids + * (C7) — deliberately narrower than `LayoutRegistryLike`/`TriggerRegistryLike` + * above (`Object.keys` needs nothing more than the `runtimes` map itself). */ +interface RuntimeEnumerableRegistry { + runtimes: Record; +} + +// ── Public types ──────────────────────────────────────────────────────── + +export interface InstalledScopeRecord { + scope: InstallScope; + configHome: string; + installed: boolean; + manifestVersion: number | null; + declaredRuntime: string | null; + declaredScope: InstallScope | null; + /** `null` when nothing was declared (v1 manifest or not installed). */ + declaredScopeMatchesProbe: boolean | null; + declaredRuntimeMatchesProbe: boolean | null; + /** Trigger stems derived from this scope's manifest keys, sorted, deduped. */ + stems: string[]; +} + +export interface InstalledRuntimeSurface { + runtime: string; + scopes: InstalledScopeRecord[]; + /** Resolved across ONLY the installed scopes, in one resolveTriggerSurface + * call, so `shadowedBy` describes THIS machine rather than a hypothetical. */ + triggers: TriggerSurface[]; +} + +export interface ResolveInstalledSurfacesOptions { + home?: string; + cwd?: string; + env?: Record; + existsSync?: (p: string) => boolean; + registry?: unknown; + /** Injected for tests; defaults to installer-migrations' readInstallManifest. */ + readManifest?: (configDir: string) => { + manifestVersion: number | null; + runtime: string | null; + scope: InstallScope | null; + files: Record; + }; +} + +// ── Internals ─────────────────────────────────────────────────────────── + +/** + * A trigger stem is a kebab-case token and nothing else. Verified against the + * real roster: all 71 `commands/gsd/*.md` stems match this, so it has no false + * negatives today. + * + * This is a SECURITY boundary, not cosmetics. A stem is derived from a manifest + * key — attacker-influenceable text, since a project-local + * `gsd-file-manifest.json` lives inside a repository a user may merely have + * cloned — and Phase 4 (#2873) renders it back to the user as `/gsd-`. + * Without this, `skills/gsd-../../../x` yields the stem `..`, and control + * characters, newlines, ANSI escapes and RTL-override codepoints all survive + * into that rendered trigger. The `commands` branch happened to be protected by + * its `composeCommandFilename` round-trip; the `skills` branch had no + * equivalent, so the rule is stated once here and applied to both. + */ +const SAFE_STEM = /^[a-z0-9][a-z0-9-]*$/; + +function getDefaultRuntimeRegistry(): RuntimeEnumerableRegistry { + return _require('./capability-registry.cjs') as RuntimeEnumerableRegistry; +} + +/** C7: every registered runtime id, sorted for determinism. Reads + * `opts.registry` when provided (so a sweep is assertable without the real + * capability registry), else the real one. */ +function listRegisteredRuntimeIds(opts: ResolveInstalledSurfacesOptions): string[] { + const registry = (opts.registry as RuntimeEnumerableRegistry | undefined) ?? getDefaultRuntimeRegistry(); + return Object.keys(registry.runtimes).sort(); +} + +/** + * D — the stem inverse for one `commands`/`skills` layout kind entry. + * Consumes `isNamespacedByDir` (to decide the commands filename shape) and + * `composeCommandFilename` (to VERIFY a candidate stem round-trips to the + * exact filename seen) rather than re-deriving either rule locally (design + * "Rejected" #6). Manifest keys are normalized with an unconditional + * `.replace(/\\/g, '/')` (D7) — never gated on `path.sep` — matching this + * repo's recorded path-separator-normalization defect class. + */ +function deriveStemsForKindEntry( + kind: 'commands' | 'skills', + destSubpath: string, + prefix: string, + fileKeys: readonly string[], +): string[] { + const destSubpathNorm = posixNormalize(destSubpath); + const boundary = `${destSubpathNorm}/`; + const namespacedByDir = isNamespacedByDir(kind, destSubpath, prefix); + const stems = new Set(); + + for (const rawKey of fileKeys) { + const key = rawKey.replace(/\\/g, '/'); + if (!key.startsWith(boundary)) continue; // D4: key not under this declared subpath + const rest = key.slice(boundary.length); + if (rest === '') continue; + const segments = rest.split('/'); + + if (kind === 'skills') { + const dirSegment = segments[0]; + if (!dirSegment.startsWith(prefix)) continue; // D5: subpath matches, prefix does not + const stem = dirSegment.slice(prefix.length); + if (stem === '') continue; // D7/B7: empty stem never emitted + if (!SAFE_STEM.test(stem)) continue; // security: reject anything but a kebab-case token + stems.add(stem); // D6: several files under one dir -> one stem + continue; + } + + // commands: exactly one path segment — the composed filename itself. + if (segments.length !== 1) continue; + const filename = segments[0]; + if (!filename.endsWith('.md')) continue; + const base = filename.slice(0, -3); + const candidateStem = namespacedByDir + ? base + : (base.startsWith(prefix) ? base.slice(prefix.length) : ''); + if (candidateStem === '') continue; + if (composeCommandFilename(namespacedByDir, prefix, candidateStem) !== filename) continue; + if (!SAFE_STEM.test(candidateStem)) continue; // security: reject anything but a kebab-case token + stems.add(candidateStem); + } + + return [...stems]; +} + +/** D — full stem set for one scope: every `commands`/`skills` layout kind + * entry, unioned. Empty `files` (C13) short-circuits to `[]` without + * resolving a layout at all. */ +function deriveStemsFromManifest( + runtime: string, + scopeId: InstallScope, + configHome: string, + files: Record, + opts: ResolveInstalledSurfacesOptions, +): string[] { + const fileKeys = Object.keys(files); + if (fileKeys.length === 0) return []; + + const layout = opts.registry !== undefined + ? resolveRuntimeArtifactLayoutFromRegistry(opts.registry as LayoutRegistryLike, runtime, configHome, scopeId) + : resolveRuntimeArtifactLayout(runtime, configHome, scopeId); + + const stems = new Set(); + for (const kindEntry of layout.kinds) { + if (kindEntry.kind !== 'commands' && kindEntry.kind !== 'skills') continue; // excludes agents/kimi-agents (D4) + for (const stem of deriveStemsForKindEntry(kindEntry.kind, kindEntry.destSubpath, kindEntry.prefix, fileKeys)) { + stems.add(stem); + } + } + return [...stems].sort(); +} + +/** + * Build one scope's record. `resolvedConfigHome` has already been probed + * successfully by the time this is called (a `resolveScope` `TypeError` is + * handled by the caller, per C8 — it is never this function's concern). + * + * Two distinct failure modes are handled with two distinct, narrowly-scoped + * try/catches — NOT one catch-all around the whole function: + * + * - The manifest READ (`readManifest`) is the only thing that can justify + * `installed: false` (C14, e.g. EACCES). A failure here means "we could + * not even tell whether this scope is installed". + * - The stem DERIVATION (`deriveStemsFromManifest`, which resolves a + * runtime artifact layout) is a separate concern. A `TypeError` thrown + * while deriving stems is a layout-lookup failure, not evidence the + * manifest is absent — the manifest was already read successfully, so + * `installed` and every declared/*MatchesProbe field stay exactly as the + * manifest reported. Conflating the two would report a genuinely + * installed runtime as `installed: false`, hiding a real install from + * #2218 shadow detection precisely when this module exists to surface it. + */ +function buildScopeRecord( + runtime: string, + scopeId: InstallScope, + resolvedConfigHome: string, + opts: ResolveInstalledSurfacesOptions, +): InstalledScopeRecord { + let manifest: { + manifestVersion: number | null; + runtime: string | null; + scope: InstallScope | null; + files: Record; + }; + try { + const readManifest = opts.readManifest ?? readInstallManifest; + manifest = readManifest(resolvedConfigHome); + } catch { + // C14: manifest read/probe failure degrades to not-installed, never throws. + // Deliberately a BARE catch, unlike the stem-derivation catch below: any + // read failure at all (EACCES, ENOENT-after-race, a corrupt filesystem) + // legitimately means "cannot tell whether installed" (design row C14), so + // there is no error TYPE here that should instead propagate. + return { + scope: scopeId, + configHome: resolvedConfigHome, + installed: false, + manifestVersion: null, + declaredRuntime: null, + declaredScope: null, + declaredScopeMatchesProbe: null, + declaredRuntimeMatchesProbe: null, + stems: [], + }; + } + + const installed = manifest.manifestVersion !== null; // C9: presence, never the new fields + const declaredRuntime = manifest.runtime; + const declaredScope = manifest.scope; + const declaredRuntimeMatchesProbe = declaredRuntime === null ? null : declaredRuntime === runtime; + const declaredScopeMatchesProbe = declaredScope === null ? null : declaredScope === scopeId; + + let stems: string[] = []; + if (installed) { + try { + stems = deriveStemsFromManifest(runtime, scopeId, resolvedConfigHome, manifest.files, opts); + } catch (error) { + if (!(error instanceof TypeError)) throw error; + // A layout-lookup failure means "we could not enumerate this scope's + // triggers", NOT "this scope is not installed" — the manifest read + // already succeeded above, so `installed` and the declared fields + // stay as reported. Conflating the two would hide a real install. + // Anything that is not the expected `TypeError` (a genuine bug in + // `deriveStemsFromManifest`/`resolveRuntimeArtifactLayout`) is + // rethrown rather than silently degrading to `stems: []` — this + // matches `resolveInstalledSurfaces`'s own `TypeError` narrowing + // below, so the two catches cannot drift apart. + stems = []; + } + } + + return { + scope: scopeId, + configHome: resolvedConfigHome, + installed, + manifestVersion: manifest.manifestVersion, + declaredRuntime, + declaredScope, + declaredScopeMatchesProbe, + declaredRuntimeMatchesProbe, + stems, + }; +} + +/** + * Resolve one runtime's full installed surface. A `resolveScope` `TypeError` + * (unknown runtime, or `configHome.kind === 'none'`, e.g. vscode) propagates + * from here uncaught — `resolveInstalledSurfaces` decides whether that + * means "skip" (C7 sweep) or "propagate" (explicit single-runtime ask, C8). + */ +function resolveOneRuntime(runtime: string, opts: ResolveInstalledSurfacesOptions): InstalledRuntimeSurface { + const scopes: InstalledScopeRecord[] = SCOPE_ORDER.map((scopeId) => { + const resolved = resolveScope({ + id: scopeId, + runtime, + env: opts.env, + home: opts.home, + existsSync: opts.existsSync, + cwd: opts.cwd, + }); + return buildScopeRecord(runtime, scopeId, resolved.configHome, opts); + }); + + // C12: dedupe by resolved configHome BEFORE the trigger call, keeping the + // higher-ranked (global, first in SCOPE_ORDER) scope. Both scope RECORDS + // above are unaffected — only the scope list handed to resolveTriggerSurface + // is deduped. + const seenConfigHomes = new Set(); + const triggerScopeIds: InstallScope[] = []; + for (const record of scopes) { + if (!record.installed) continue; + if (seenConfigHomes.has(record.configHome)) continue; + seenConfigHomes.add(record.configHome); + triggerScopeIds.push(record.scope); + } + + const stemUnion = new Set(); + for (const scopeId of triggerScopeIds) { + const record = scopes.find((s) => s.scope === scopeId); + for (const stem of record?.stems ?? []) stemUnion.add(stem); + } + + const triggers: TriggerSurface[] = triggerScopeIds.length > 0 + ? resolveTriggerSurface(runtime, triggerScopeIds, { + stems: [...stemUnion].sort(), + registry: opts.registry as TriggerRegistryLike | undefined, + }) + : []; + + return { runtime, scopes, triggers }; +} + +/** + * Read-only. Probes both install scopes for a runtime (or every registered + * runtime, sorted by id, when `runtime` is omitted), reads each scope's + * manifest, and resolves the trigger surface across the scopes that are + * actually installed on this machine. See the module-level comment for the + * non-obvious choices (probe-not-declared keying, report-don't-correct, one + * trigger call over installed scopes only). + * + * Pure with respect to caller-visible state: builds fresh arrays/objects on + * every call (C15) and performs no writes. Filesystem reads happen only via + * `resolveScope`'s injected `existsSync`/`env`/`home`/`cwd` and via + * `readManifest` (default: `readInstallManifest`). + * + * @throws {TypeError} when `runtime` is given explicitly and it is unknown, + * or has no installable config directory (`configHome.kind === 'none'`, + * e.g. vscode) — same contract `resolveScope` throws. In the all-runtimes + * sweep (`runtime` omitted), a runtime that would throw this same + * `TypeError` is skipped instead, so one non-installable runtime cannot + * kill the sweep (C7/C8). Any other error type is never swallowed here. + */ +export function resolveInstalledSurfaces( + runtime?: string, + opts: ResolveInstalledSurfacesOptions = {}, +): InstalledRuntimeSurface[] { + if (typeof runtime === 'string') { + return [resolveOneRuntime(runtime, opts)]; + } + + const results: InstalledRuntimeSurface[] = []; + for (const runtimeId of listRegisteredRuntimeIds(opts)) { + try { + results.push(resolveOneRuntime(runtimeId, opts)); + } catch (error) { + if (error instanceof TypeError) continue; // C8: sweep skips, never dies + throw error; + } + } + return results; +} diff --git a/src/installer-migrations.cts b/src/installer-migrations.cts index 399fda2b1..a1ab769a3 100644 --- a/src/installer-migrations.cts +++ b/src/installer-migrations.cts @@ -18,6 +18,7 @@ import { } from './installer-migration-authoring.cjs'; import { platformWriteSync, retryRenameSync, posixNormalize } from './shell-command-projection.cjs'; import { realClock, type Clock } from './clock.cjs'; +import { isInstallScopeId, type InstallScope } from './install-scope.cjs'; const MANIFEST_NAME = 'gsd-file-manifest.json'; const INSTALL_STATE_NAME = 'gsd-install-state.json'; @@ -164,19 +165,108 @@ interface InstallManifest { timestamp: string | null; mode: string | null; files: Record; + /** + * Schema version of the manifest DOCUMENT (#2872, ADR-2866 Phase 3) — NOT + * the GSD package version, which `version` above already carries. The two + * are deliberately separate fields: `version` holds `pkg.version` and is + * read by the golden-parity fixtures, so overloading it with a schema + * number would be the textbook Hyrum break (same key, new meaning). + * + * `null` — no manifest at this configDir (or an unparseable one; see + * `readJsonIfPresent`'s long-standing fallback). + * `1` — a manifest written before #2872: no `manifestVersion` key, and + * therefore no recorded `runtime`/`scope`. **This is a correct + * manifest, not a broken one** — read without error and without + * requiring a reinstall. + * `>= 2` — records `runtime` and `scope`. + * + * A value written by a NEWER GSD is reported verbatim rather than clamped + * or rejected: two GSD versions on one machine is a supported state, and an + * older reader must not crash on a newer writer. Consumers branch on + * `>= 2`, never `=== 2`. + */ + manifestVersion: number | null; + /** Runtime that wrote this manifest, or `null` for a v1 manifest. Reported + * verbatim up to `MAX_REPORTED_RUNTIME_LENGTH` chars, then truncated with + * `…` — an unregistered runtime string is a fact about the file, and this + * reader reports facts; callers decide what to do with one. Charset is + * deliberately NOT gated (see `normalizeReportedRuntime`). */ + runtime: string | null; + /** Install scope that wrote this manifest, or `null` for a v1 manifest (or + * an unrecognized value). Validated through Install Scope Module's shared + * membership predicate, never a second copy of the rule — so `'project'` + * (the consent/lifecycle vocabulary) reads as `null` rather than being + * silently mistaken for `'local'`. */ + scope: InstallScope | null; +} + +/** Lowest manifest schema version that records `runtime`/`scope` (#2872). */ +const MANIFEST_SCHEMA_VERSION = 2; + +/** + * Longest `runtime` string this reader will report. Real runtime ids are + * registry keys (`claude`, `antigravity`, `kimi-code` — 11 chars at the + * longest), so this loses nothing legitimate; it exists because the manifest + * is attacker-influenceable (a project-local one lives inside a repository a + * user may merely have cloned) and the value reaches a consumer that renders + * it. Same 64-char convention as `truncatePostureValue` + * (`agent-install-check.cts`), deliberately, so the subsystem caps reported + * values one way. + */ +const MAX_REPORTED_RUNTIME_LENGTH = 64; + +/** + * A manifest's `runtime` is reported as a FACT about the file — it is + * deliberately NOT validated against the capability registry, because an + * unregistered id is exactly the kind of mismatch the Installed Surface + * Resolver exists to surface (#2872 design row B8). It is, however, LENGTH + * bounded: "report the fact" never required "report unbounded bytes". + */ +function normalizeReportedRuntime(raw: unknown): string | null { + if (typeof raw !== 'string') return null; + if (raw.trim() === '') return null; + return raw.length > MAX_REPORTED_RUNTIME_LENGTH + ? `${raw.slice(0, MAX_REPORTED_RUNTIME_LENGTH)}…` + : raw; +} + +/** + * Normalize a raw `manifestVersion`. Only a finite integer >= 1 is a version + * claim; everything else (absent, `"2"`, `0`, `-1`, `2.5`, `NaN`, `Infinity`) + * reads as `1` — a pre-#2872 manifest. Liberal in what it accepts, but the + * normalization is a stated value rather than a silent guess: a caller can + * always tell v1 (`1`) from "no manifest at all" (`null`). + */ +function normalizeManifestVersion(raw: unknown): number { + if (typeof raw !== 'number') return 1; + if (!Number.isInteger(raw)) return 1; + if (raw < 1) return 1; + return raw; } function readInstallManifest(configDir: string): InstallManifest { const manifest = readJsonIfPresent(path.join(configDir, MANIFEST_NAME), null); if (!manifest || typeof manifest !== 'object') { - return { version: null, timestamp: null, mode: null, files: {} }; + return { + version: null, + timestamp: null, + mode: null, + files: {}, + manifestVersion: null, + runtime: null, + scope: null, + }; } const m = manifest as Record; + const rawRuntime = m.runtime; return { version: typeof m.version === 'string' ? m.version : null, timestamp: typeof m.timestamp === 'string' ? m.timestamp : null, mode: typeof m.mode === 'string' ? m.mode : null, files: m.files && typeof m.files === 'object' ? m.files as Record : {}, + manifestVersion: normalizeManifestVersion(m.manifestVersion), + runtime: normalizeReportedRuntime(rawRuntime), + scope: isInstallScopeId(m.scope) ? m.scope : null, }; } @@ -1092,6 +1182,7 @@ export = { classifyArtifact, discoverInstallerMigrations, evaluateRemoveEmptyDir, + MANIFEST_SCHEMA_VERSION, migrationChecksum, planInstallerMigrations, readInstallManifest, diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index 8e27ac66f..9456f593a 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -34,7 +34,7 @@ import { posixNormalize } from './shell-command-projection.cjs'; // projection both kind-builder closures below need at the converters' // positional `isGlobal` boundary (see its doc comment in install-scope.cts // for why the projection is centralized rather than eliminated). -import { isGlobalScope, scopeRank, validateScopeId, type InstallScope } from './install-scope.cjs'; +import { isGlobalScope, scopeRank, validateScopeId, SCOPE_ORDER, type InstallScope } from './install-scope.cjs'; // In .cts (CommonJS output) files, `require` is available as a global. const _require: NodeRequire = require; @@ -728,8 +728,6 @@ function getDefaultTriggerPrecedence(): string[] { return capValidator.DEFAULT_TRIGGER_PRECEDENCE; } -const SCOPE_ORDER: readonly InstallScope[] = ['global', 'local']; - /** * True when a `commands` kind entry is namespaced by its destination * directory rather than by a filename prefix — i.e. `destSubpath`'s basename diff --git a/tests/agent-install-check.test.cjs b/tests/agent-install-check.test.cjs index 9be7e7af2..b07cd1db4 100644 --- a/tests/agent-install-check.test.cjs +++ b/tests/agent-install-check.test.cjs @@ -1166,3 +1166,158 @@ describe('cmdValidateAgents surfaces the Codex posture result (#3242 row 20)', ( ); }); }); + +// ─── #2872 (ADR-2866 Phase 3): checkAgentsInstalled unchanged by the ────── +// manifest-reader de-duplication (50-test-matrix.md section 5, rows A1-A5) +// +// installer-migrations.cts's readInstallManifest now records manifestVersion +// /runtime/scope, but checkAgentsInstalled only ever consumed `.files` +// before AND after the substitution (agent-install-check.cts:191-194 +// swapped an inlined try/readFileSync/JSON.parse for the shared reader, +// zero new branches) — see 40-design.md's "Rejected" #4 for why this +// function is not rewired onto the two-scope resolver instead. These rows +// prove the substitution changed nothing observable here. +describe('checkAgentsInstalled — unchanged by the manifest-reader substitution (#2872 A1-A5)', () => { + describe('A1-A4: v1/v2 manifest parity', () => { + let tmpDir; + let agentsDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-agent-check-schema-'); + agentsDir = path.join(tmpDir, 'agents'); + process.env['GSD_AGENTS_DIR'] = agentsDir; + fs.mkdirSync(agentsDir, { recursive: true }); + for (const agent of EXPECTED_AGENTS) { + fs.writeFileSync(path.join(agentsDir, `${agent}.md`), `# ${agent}\n`); + } + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // A1 — a v1 manifest (no manifestVersion/runtime/scope key at all) + // beside the agents dir: result identical to pre-#2872 behavior. + test('A1: checkAgentsInstalled is unchanged for a v1 manifest', () => { + const manifestFiles = {}; + for (const agent of EXPECTED_AGENTS) manifestFiles[`agents/${agent}.md`] = 'somehash'; + fs.writeFileSync( + path.join(tmpDir, 'gsd-file-manifest.json'), + JSON.stringify({ + version: '1.49.0', + timestamp: '2026-05-10T00:00:00.000Z', + mode: 'full', + files: manifestFiles, + }), + ); + + const result = agentInstallCheck.checkAgentsInstalled(); + assert.strictEqual(result.agents_installed, true); + assert.deepStrictEqual(result.missing_agents, []); + assert.deepStrictEqual(result.incomplete_agents, []); + }); + + // A2 — a v2 manifest (carrying manifestVersion/runtime/scope) produces + // the exact same result: the new fields change nothing here. + test('A2: checkAgentsInstalled is unchanged for a v2 manifest', () => { + const manifestFiles = {}; + for (const agent of EXPECTED_AGENTS) manifestFiles[`agents/${agent}.md`] = 'somehash'; + fs.writeFileSync( + path.join(tmpDir, 'gsd-file-manifest.json'), + JSON.stringify({ + manifestVersion: 2, + version: '1.60.0', + timestamp: '2026-08-01T00:00:00.000Z', + mode: 'full', + runtime: 'claude', + scope: 'global', + files: manifestFiles, + }), + ); + + const result = agentInstallCheck.checkAgentsInstalled(); + assert.strictEqual(result.agents_installed, true); + assert.deepStrictEqual(result.missing_agents, []); + assert.deepStrictEqual(result.incomplete_agents, []); + }); + + // A3 — no manifest at all (readInstallManifest's absent-file branch): + // the completeness check is skipped, no throw — today's behavior. + // Presence (the .md files above) still makes the install look complete. + test('A3: an unreadable/absent manifest still skips the completeness check', () => { + let result; + assert.doesNotThrow(() => { + result = agentInstallCheck.checkAgentsInstalled(); + }); + assert.deepStrictEqual(result.incomplete_agents, []); + assert.strictEqual(result.agents_installed, true); + }); + + // A4 — manifest present, one agent's .toml tracked but absent on disk: + // still reported as incomplete, same as before the substitution. + test('A4: still reports an incomplete agent', () => { + const targetAgent = EXPECTED_AGENTS[0]; + const manifestFiles = {}; + for (const agent of EXPECTED_AGENTS) manifestFiles[`agents/${agent}.md`] = 'somehash'; + manifestFiles[`agents/${targetAgent}.toml`] = 'somehash'; + fs.writeFileSync( + path.join(tmpDir, 'gsd-file-manifest.json'), + JSON.stringify({ + manifestVersion: 2, + version: '1.60.0', + timestamp: '2026-08-01T00:00:00.000Z', + mode: 'full', + runtime: 'claude', + scope: 'global', + files: manifestFiles, + }), + ); + + const result = agentInstallCheck.checkAgentsInstalled(); + assert.ok( + result.incomplete_agents.includes(targetAgent), + `expected ${targetAgent} in incomplete_agents, got: ${JSON.stringify(result.incomplete_agents)}`, + ); + assert.strictEqual(result.agents_installed, false); + }); + }); + + // A5 — getAgentsDir's local-install probe is deliberately NOT rewired to + // readInstallManifest: it stays an lstat-based (symlink-unaware) presence + // check (agent-install-check.cts:123, "Not touched" in 40-design.md's + // blast-radius table), so a manifest path that is itself a SYMLINK is + // still ignored, exactly as before the substitution. + test('A5: getAgentsDir still ignores a symlinked manifest', (t) => { + const projectRoot = createTempDir('gsd-local-codex-schema-'); + const globalHome = createTempDir('gsd-global-codex-schema-'); + const localConfigDir = path.join(projectRoot, '.codex'); + const localAgentsDir = path.join(localConfigDir, 'agents'); + const realManifestTarget = path.join(projectRoot, 'real-manifest.json'); + t.after(() => cleanup(projectRoot)); + t.after(() => cleanup(globalHome)); + fs.mkdirSync(localAgentsDir, { recursive: true }); + for (const agent of EXPECTED_AGENTS) { + fs.writeFileSync(path.join(localAgentsDir, `${agent}.toml`), `name = "${agent}"\n`); + } + fs.writeFileSync(realManifestTarget, JSON.stringify({ files: {} })); + const manifestPath = path.join(localConfigDir, 'gsd-file-manifest.json'); + createCompleteAgents(path.join(globalHome, 'agents')); + process.env['CODEX_HOME'] = globalHome; + try { + fs.symlinkSync(realManifestTarget, manifestPath, 'file'); + } catch (error) { + if (error && ['EPERM', 'EACCES', 'ENOTSUP'].includes(error.code)) { + t.skip('symlink creation is not available on this platform'); + return; + } + throw error; + } + + // getAgentsDir's own probe (`fs.lstatSync(manifestPath).isFile()`) sees + // the manifest path as a symlink, not a regular file — isFile() is false + // for the link itself even though its target is a regular file — so the + // local install is NOT selected; the global fallback wins instead. + assert.strictEqual(agentInstallCheck.getAgentsDir('codex', projectRoot), path.join(globalHome, 'agents')); + assert.notStrictEqual(agentInstallCheck.getAgentsDir('codex', projectRoot), localAgentsDir); + }); +}); diff --git a/tests/fixtures/index.cjs b/tests/fixtures/index.cjs index 019afec89..0875c7c7c 100644 --- a/tests/fixtures/index.cjs +++ b/tests/fixtures/index.cjs @@ -1,7 +1,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -const { gitOrThrow } = require('../helpers/git-fixture.cjs'); +const { gitOrThrow, GIT_FIXTURE_TIMEOUT_MS } = require('../helpers/git-fixture.cjs'); /** * Create a temp test fixture directory with canonical planning layout. @@ -36,16 +36,20 @@ function createFixture(options = {}) { } if (git) { - gitOrThrow(['init'], { cwd: tmpDir }); - gitOrThrow(['config', 'user.email', 'test@test.com'], { cwd: tmpDir }); - gitOrThrow(['config', 'user.name', 'Test'], { cwd: tmpDir }); - gitOrThrow(['config', 'commit.gpgsign', 'false'], { cwd: tmpDir }); - gitOrThrow(['add', '-A'], { cwd: tmpDir }); + // Construction calls (init/config/add/commit), a heavier class than a + // plumbing read — each of `init`/`commit` writes dozens of files, and on + // Windows every spawn is Defender-scanned (#3323). + const gitOpts = { cwd: tmpDir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS }; + gitOrThrow(['init'], gitOpts); + gitOrThrow(['config', 'user.email', 'test@test.com'], gitOpts); + gitOrThrow(['config', 'user.name', 'Test'], gitOpts); + gitOrThrow(['config', 'commit.gpgsign', 'false'], gitOpts); + gitOrThrow(['add', '-A'], gitOpts); // `--allow-empty`: a fixture with `git: true, planning: false, // projectDoc: false` (e.g. a "greenfield" starting world) stages nothing, // so a plain `git commit` would fail with "nothing to commit" and the // caller would never get a usable repo (there'd be no HEAD at all). - gitOrThrow(['commit', '--allow-empty', '-m', 'initial commit'], { cwd: tmpDir }); + gitOrThrow(['commit', '--allow-empty', '-m', 'initial commit'], gitOpts); } return tmpDir; diff --git a/tests/golden-parity-single-source.test.cjs b/tests/golden-parity-single-source.test.cjs index 46bf4a4a7..2dfaa3581 100644 --- a/tests/golden-parity-single-source.test.cjs +++ b/tests/golden-parity-single-source.test.cjs @@ -48,6 +48,13 @@ test('install-shared.cjs exports the canonical buildParityManifest + exclusion c installShared.VOLATILE_FILES instanceof Set, 'VOLATILE_FILES must be a Set' ); + assert.ok( + installShared.VOLATILE_FILES.has('gsd-file-manifest.json'), + "VOLATILE_FILES must keep 'gsd-file-manifest.json' excluded (#2872 acceptance " + + 'criterion: the manifest gained manifestVersion/runtime/scope, but its `timestamp` ' + + 'field is unchanged and still varies per install — if this ever stops being true, ' + + 'the golden fixtures start hashing a per-install timestamp)' + ); assert.ok( installShared.HOOK_CONFIG_FILES instanceof Set, 'HOOK_CONFIG_FILES must be a Set' diff --git a/tests/helpers/git-fixture.cjs b/tests/helpers/git-fixture.cjs index 081b51a81..014f869f5 100644 --- a/tests/helpers/git-fixture.cjs +++ b/tests/helpers/git-fixture.cjs @@ -50,6 +50,31 @@ const { runGit, OUTCOME } = require('./process-seam.cjs'); */ const DEFAULT_GIT_TIMEOUT_MS = 15000; +/** + * Timeout for git calls that CONSTRUCT a fixture repository, in milliseconds. + * + * A distinct class from `DEFAULT_GIT_TIMEOUT_MS` above, which is sized for + * plumbing READS (rev-parse, branch, log) against an existing repo. + * `createFixture` (`tests/fixtures/index.cjs`) issues SIX sequential spawns to + * build one repo — `init`, three `config` writes, `add -A`, `commit` — and + * `init`/`commit` each write dozens of files. On Windows every one of those + * spawns is Defender-scanned, so the construction sequence is materially + * heavier than any single read. + * + * CI (PR #3323, `full test (windows-latest, 22, shard 2/3)`) recorded + * `gitOrThrow: git init failed — outcome=timed_out exitCode=null` and the same + * for `git commit --allow-empty`, with sibling tests in the same block taking + * 15.6-22.0s, while every other lane — including windows-latest node 24, all + * three shards — passed the same commit. That is a bound sized for the wrong + * class, not a slow machine: the identical conclusion, in the identical job, + * that `HOOK_FANOUT_TIMEOUT_MS` records for PR #3285. + * + * 60000ms is 4x the bound that failed and half `INSTALL_TIMEOUT_MS` — the same + * ratio `HOOK_FANOUT_TIMEOUT_MS` uses, and the right order for a call that is + * far heavier than a plumbing read but much lighter than a full installer run. + */ +const GIT_FIXTURE_TIMEOUT_MS = 60000; + /** * Throw on anything other than a clean (exit 0) process-seam result, * preserving the legacy `execSync`/`execFileSync` throw-on-failure idiom @@ -147,4 +172,10 @@ function toLegacyResult(result) { return { status: result.exitCode, stdout: result.stdout, stderr: result.stderr }; } -module.exports = { gitOrThrow, throwIfFailed, toLegacyResult, DEFAULT_GIT_TIMEOUT_MS }; +module.exports = { + gitOrThrow, + throwIfFailed, + toLegacyResult, + DEFAULT_GIT_TIMEOUT_MS, + GIT_FIXTURE_TIMEOUT_MS, +}; diff --git a/tests/helpers/install-shared.cjs b/tests/helpers/install-shared.cjs index 153a449cb..75cfbc211 100644 --- a/tests/helpers/install-shared.cjs +++ b/tests/helpers/install-shared.cjs @@ -154,6 +154,11 @@ const PKG_VERSION = require('../../package.json').version; // that cause hash drift between local (PKG_VERSION=1.x.x) and CI (PKG_VERSION=1.x.x-rc.N): // the PKG_VERSION normalization below replaces only the *current* version, but // CHANGELOG.md references prior-release versions, so the normalized hash diverges. +// gsd-file-manifest.json's exclusion was revisited deliberately for #2872: the +// manifest gained `manifestVersion`/`runtime`/`scope` fields, all of which are +// deterministic and would not by themselves force an exclusion, but `timestamp` +// — the original reason this file is volatile — is unchanged by #2872, so the +// exclusion still holds for exactly the same reason it always has. const VOLATILE_FILES = new Set([ 'gsd-file-manifest.json', 'gsd-install-state.json', diff --git a/tests/helpers/timeouts.cjs b/tests/helpers/timeouts.cjs index c5d2b3246..6d6abbb7e 100644 --- a/tests/helpers/timeouts.cjs +++ b/tests/helpers/timeouts.cjs @@ -21,7 +21,7 @@ * sites onto a shared value that doesn't describe them. */ -const { DEFAULT_GIT_TIMEOUT_MS } = require('./git-fixture.cjs'); +const { DEFAULT_GIT_TIMEOUT_MS, GIT_FIXTURE_TIMEOUT_MS } = require('./git-fixture.cjs'); /** * A single short CLI query or `node -e` probe against a temp fixture — @@ -62,6 +62,13 @@ const HOOK_FANOUT_TIMEOUT_MS = 60000; */ const GIT_TIMEOUT_MS = DEFAULT_GIT_TIMEOUT_MS; +/** + * Git fixture CONSTRUCTION calls (init/config/add/commit) — a heavier class + * than `GIT_TIMEOUT_MS`. See `tests/helpers/git-fixture.cjs`'s + * `GIT_FIXTURE_TIMEOUT_MS` for the full rationale (PR #3323); re-exported + * here rather than restated so the two can never disagree. + */ + /** * Hooks bundling via `scripts/build-hooks.js` (not a full project build — * see per-site comments for sites that run a heavier build and therefore @@ -83,6 +90,7 @@ module.exports = { PROBE_TIMEOUT_MS, HOOK_FANOUT_TIMEOUT_MS, GIT_TIMEOUT_MS, + GIT_FIXTURE_TIMEOUT_MS, BUILD_TIMEOUT_MS, INSTALL_TIMEOUT_MS, }; diff --git a/tests/install-scope.test.cjs b/tests/install-scope.test.cjs index de33431e4..bcc906cc8 100644 --- a/tests/install-scope.test.cjs +++ b/tests/install-scope.test.cjs @@ -23,7 +23,7 @@ const assert = require('node:assert/strict'); const path = require('node:path'); const { toPosixPath } = require('./helpers.cjs'); -const { resolveScope, isGlobalScope } = require('../gsd-core/bin/lib/install-scope.cjs'); +const { resolveScope, isGlobalScope, SCOPE_ORDER, scopeRank } = require('../gsd-core/bin/lib/install-scope.cjs'); const registry = require('../gsd-core/bin/lib/capability-registry.cjs'); const { createRuntimeArtifactInstallPlan } = require('../gsd-core/bin/lib/runtime-artifact-install-plan.cjs'); @@ -278,3 +278,29 @@ describe('isGlobalScope', () => { ); }); }); + +describe('SCOPE_ORDER (#2872 review finding — single source of the scope axis ordering)', () => { + // `runtime-artifact-layout.cts` and `installed-surface-resolver.cts` both + // consume this constant instead of each re-declaring their own + // `['global', 'local']` literal (the "generative fix divergence" class + // recorded in this repo's CLAUDE.md). Locking the exact value here is what + // makes a future re-declaration in either consumer fail somewhere, rather + // than silently drifting. + test('is exactly [\'global\', \'local\']', () => { + assert.deepStrictEqual(SCOPE_ORDER, ['global', 'local']); + }); + + test('is frozen', () => { + assert.strictEqual(Object.isFrozen(SCOPE_ORDER), true); + }); + + // Derives the expected order from scopeRank rather than hardcoding + // ['global', 'local'] a second time: this fails if someone reorders + // SCOPE_ORDER without changing the ranks, and equally fails if someone + // changes the ranks without reordering SCOPE_ORDER — the two can never + // silently drift apart. + test('is sorted by scopeRank descending', () => { + const expected = [...SCOPE_ORDER].sort((a, b) => scopeRank(b) - scopeRank(a)); + assert.deepStrictEqual(SCOPE_ORDER, expected); + }); +}); diff --git a/tests/installed-surface-resolver.test.cjs b/tests/installed-surface-resolver.test.cjs new file mode 100644 index 000000000..4e9f7ab93 --- /dev/null +++ b/tests/installed-surface-resolver.test.cjs @@ -0,0 +1,783 @@ +'use strict'; + +/** + * Failing-first suite for `resolveInstalledSurfaces` (#2872 Phase 3). + * + * Implements sections 3 ("resolveInstalledSurfaces") and 4 ("stem derivation + * bijection") of `.gsd/phase/feat-2872-manifest-scope-runtime/50-test-matrix.md` + * (rows S1-S21, B1-B16). Sections 1, 2 and 5 are owned by sibling suites — + * `tests/install-manifest-scope-runtime.test.cjs`, + * `tests/installer-migrations-manifest-schema.test.cjs`, + * `tests/agent-install-check.test.cjs`. + * + * The module under test exposes only ONE runtime value — + * `resolveInstalledSurfaces` itself (the private stem-derivation helpers are + * not exported) — so every row, including the bijection rows in section 4, + * is driven end to end through that single entry point and asserted against + * the `InstalledScopeRecord.stems` field it returns. + * + * ── Why `resolveScope` is never overridden by `opts.registry` ────────────── + * Per the design doc's "Correction" note (`40-design.md`, bottom): an + * injected `opts.registry` reaches only the layout lookup + * (`resolveRuntimeArtifactLayoutFromRegistry`) and `resolveTriggerSurface` — + * never `resolveScope`, which always consults the REAL + * `capability-registry.cjs`. So every test below uses a REAL registered + * runtime id (`claude`, `cursor`, `cline`, `windsurf`) for scope resolution, + * and reaches for `opts.registry` only when a row needs a layout shape the + * real registry does not currently ship (namespaced-by-dir commands, B2/B8). + * C7/C8 (the all-runtimes sweep) are asserted against the real registry only + * — a fully synthetic registry of invented ids would make `resolveScope` + * throw for every one of them and the sweep would vacuously return `[]`. + */ + +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 { resolveInstalledSurfaces } = require('../gsd-core/bin/lib/installed-surface-resolver.cjs'); +const { resolveScope } = require('../gsd-core/bin/lib/install-scope.cjs'); +const capabilityRegistry = require('../gsd-core/bin/lib/capability-registry.cjs'); +const { isNamespacedByDir, composeCommandFilename } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + +// ─── Fixture helpers ───────────────────────────────────────────────────── + +/** The `readInstallManifest` result shape for "nothing here". */ +const ABSENT_MANIFEST = Object.freeze({ manifestVersion: null, runtime: null, scope: null, files: {} }); + +/** Build a normalized manifest-read result (the shape `opts.readManifest` + * must already return — no re-normalization happens inside the resolver). */ +function manifest({ manifestVersion = null, runtime = null, scope = null, files = {} } = {}) { + return { manifestVersion, runtime, scope, files }; +} + +/** + * Injectable `readManifest` backed by a plain Map keyed on the EXACT + * `configHome` string `resolveScope` will produce for a given runtime/scope + * under the same `home`/`cwd`/`env`/`existsSync` passed to + * `resolveInstalledSurfaces`. Never a hardcoded path literal — keys are + * always computed via the real `resolveScope` (see `scopeHomes` below), so a + * platform-specific separator can never leak into a fixture. + */ +function mkReadManifest(byConfigHome) { + return (configDir) => byConfigHome.get(configDir) ?? ABSENT_MANIFEST; +} + +/** The real global/local `configHome` for `runtime` under `home`/`cwd` — + * computed via the actual `resolveScope`, never re-derived by hand, so a + * fixture can never silently drift from what the module under test will + * itself resolve to. */ +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 }; +} + +function scopeOf(result, scopeId) { + return result[0].scopes.find((s) => s.scope === scopeId); +} + +describe('resolveInstalledSurfaces — scope presence (S1-S3)', () => { + test('reports both scopes uninstalled when nothing is present', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(new Map()) })); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].scopes.length, 2); + for (const record of result[0].scopes) { + assert.strictEqual(record.installed, false); + assert.strictEqual(record.manifestVersion, null); + assert.deepStrictEqual(record.stems, []); + } + assert.deepStrictEqual(result[0].triggers, []); + }); + + test('a global-only install shadows nothing', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + 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' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + const global = scopeOf(result, 'global'); + const local = scopeOf(result, 'local'); + assert.strictEqual(global.installed, true); + assert.deepStrictEqual(global.stems, ['plan-phase']); + assert.strictEqual(local.installed, false); + assert.ok(result[0].triggers.length > 0, 'expected at least one trigger'); + for (const t of result[0].triggers) assert.strictEqual(t.shadowedBy, null); + }); + + test('a local-only install shadows nothing', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + const global = scopeOf(result, 'global'); + const local = scopeOf(result, 'local'); + assert.strictEqual(local.installed, true); + assert.deepStrictEqual(local.stems, ['plan-phase']); + assert.strictEqual(global.installed, false); + assert.ok(result[0].triggers.length > 0, 'expected at least one trigger'); + for (const t of result[0].triggers) assert.strictEqual(t.shadowedBy, null); + }); +}); + +describe('resolveInstalledSurfaces — shadowing (S4-S7, acceptance criterion S4)', () => { + test('a claude install at both scopes reports the local command surface as shadowed', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + 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' } })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + const triggers = result[0].triggers; + const localCommands = triggers.filter((t) => t.kind === 'commands' && t.scope === 'local'); + const globalSkills = triggers.filter((t) => t.kind === 'skills' && t.scope === 'global'); + assert.ok(localCommands.length > 0, 'expected at least one local commands trigger'); + assert.ok(globalSkills.length > 0, 'expected at least one global skills trigger'); + for (const t of localCommands) { + assert.deepStrictEqual(t.shadowedBy, { kind: 'skills', scope: 'global' }); + } + for (const t of globalSkills) { + assert.strictEqual(t.shadowedBy, null); + } + }); + + test('a skills-at-both-scopes runtime reports a same-kind shadow', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('cursor', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'cursor', scope: 'global', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' } })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'cursor', scope: 'local', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' } })], + ]); + const result = resolveInstalledSurfaces('cursor', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + const group = result[0].triggers.filter((t) => t.trigger === 'gsd-plan-phase' && t.kind === 'skills'); + const winner = group.find((t) => t.scope === 'global'); + const loser = group.find((t) => t.scope === 'local'); + assert.ok(winner); + assert.ok(loser); + assert.strictEqual(winner.shadowedBy, null); + assert.deepStrictEqual(loser.shadowedBy, { kind: 'skills', scope: 'global' }); + }); + + test('windsurf does not report a shadow it does not have', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + 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 result = resolveInstalledSurfaces('windsurf', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(scopeOf(result, 'global').installed, true); + assert.strictEqual(scopeOf(result, 'local').installed, true); + // Global emits agents only — not trigger-bearing — so its stems are empty. + assert.deepStrictEqual(scopeOf(result, 'global').stems, []); + assert.ok(result[0].triggers.length > 0, 'expected at least the local trigger'); + assert.ok(result[0].triggers.every((t) => t.shadowedBy === null), 'windsurf must report no shadow at all'); + }); + + test('a runtime with no local emission reports no shadow', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('cline', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'cline', scope: 'global', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' } })], + // cline's local layout declares no kinds at all — a manifest present + // there still counts as "installed", but contributes no stems. + [homes.local, manifest({ manifestVersion: 2, runtime: 'cline', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' } })], + ]); + const result = resolveInstalledSurfaces('cline', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(scopeOf(result, 'local').stems, []); + assert.ok(result[0].triggers.length > 0); + assert.ok(result[0].triggers.every((t) => t.shadowedBy === null)); + assert.ok(result[0].triggers.every((t) => t.scope === 'global')); + }); +}); + +describe('resolveInstalledSurfaces — the all-runtimes sweep (S8-S11)', () => { + test('sweeps every installable runtime in a stable order, excluding vscode (S8/S9)', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const result = resolveInstalledSurfaces(undefined, baseOpts(home, cwd, { readManifest: mkReadManifest(new Map()) })); + const registeredSorted = Object.keys(capabilityRegistry.runtimes).sort(); + assert.ok(registeredSorted.includes('vscode'), 'fixture assumption: vscode is registered'); + const expected = registeredSorted.filter((id) => id !== 'vscode'); + const actual = result.map((r) => r.runtime); + assert.ok(expected.length > 0); + assert.deepStrictEqual(actual, expected, 'the sweep must be sorted and exclude non-installable runtimes'); + }); + + test('throws for an explicitly requested non-installable runtime (S10)', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + assert.throws( + () => resolveInstalledSurfaces('vscode', baseOpts(home, cwd, { readManifest: mkReadManifest(new Map()) })), + (err) => err instanceof TypeError, + ); + }); + + test('throws for an unknown runtime (S11)', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + assert.throws( + () => resolveInstalledSurfaces('not-a-real-runtime-xyz', baseOpts(home, cwd, { readManifest: mkReadManifest(new Map()) })), + (err) => err instanceof TypeError, + ); + }); +}); + +describe('resolveInstalledSurfaces — v1/v2 manifest reporting (S12-S14, acceptance criterion S12)', () => { + test('a v1 manifest is fully functional without reinstall', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 1, runtime: null, scope: null, files: { 'skills/gsd-plan-phase/SKILL.md': 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + const global = scopeOf(result, 'global'); + assert.strictEqual(global.installed, true); + assert.strictEqual(global.manifestVersion, 1); + assert.strictEqual(global.declaredRuntime, null); + assert.strictEqual(global.declaredScope, null); + assert.strictEqual(global.declaredScopeMatchesProbe, null); + assert.strictEqual(global.declaredRuntimeMatchesProbe, null); + assert.deepStrictEqual(global.stems, ['plan-phase']); + assert.ok(result[0].triggers.length > 0, 'triggers must still be resolved for a v1 install'); + }); + + test('reports a declared-scope mismatch instead of correcting it', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + // Declares 'local' while probed at 'global' — e.g. a manifest copied + // between config dirs. + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: {} })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + const global = scopeOf(result, 'global'); + assert.strictEqual(global.scope, 'global', 'the record stays keyed by the PROBED scope'); + assert.strictEqual(global.declaredScope, 'local'); + assert.strictEqual(global.declaredScopeMatchesProbe, false); + }); + + test('reports a declared-runtime mismatch', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'cursor', scope: 'global', files: {} })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + const global = scopeOf(result, 'global'); + assert.strictEqual(global.declaredRuntime, 'cursor'); + assert.strictEqual(global.declaredRuntimeMatchesProbe, false); + }); +}); + +describe('resolveInstalledSurfaces — negative space (S15, S16, S20, S21)', () => { + test('one physical install is never reported as shadowing itself (S15)', () => { + // claude: global name '.claude', local localConfigDir '.claude' — setting + // cwd === home makes both scopes resolve to the SAME configHome (the + // "project at $HOME" case the design calls out). + const shared = '/fixture/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: { 'skills/gsd-plan-phase/SKILL.md': 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(shared, shared, { readManifest: mkReadManifest(byConfigHome) })); + assert.strictEqual(scopeOf(result, 'global').installed, true); + assert.strictEqual(scopeOf(result, 'local').installed, true); + assert.ok(result[0].triggers.length > 0); + assert.ok(result[0].triggers.every((t) => t.shadowedBy === null), 'a single physical install must never shadow itself'); + }); + + test('an empty manifest is installed with no triggers (S16)', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: {} })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + const global = scopeOf(result, 'global'); + assert.strictEqual(global.installed, true); + assert.deepStrictEqual(global.stems, []); + assert.deepStrictEqual(result[0].triggers, []); + }); + + test('non-trigger-bearing manifest keys yield no stems (S20)', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ + manifestVersion: 2, runtime: 'claude', scope: 'global', + files: { 'hooks/foo.json': 'a', 'gsd-core/VERSION': 'b', 'settings.json': 'c' }, + })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(scopeOf(result, 'global').stems, []); + }); + + test('a gsd-prefixed key outside a declared subpath is not a trigger (S21)', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'gsd-something.md': 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(scopeOf(result, 'global').stems, []); + }); +}); + +describe('resolveInstalledSurfaces — filesystem failure and the STEP 1 fix (S17)', () => { + test('an unreadable config home degrades instead of throwing', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { + readManifest: () => { throw new Error('EACCES: permission denied'); }, + })); + assert.strictEqual(result.length, 1); + for (const record of result[0].scopes) { + assert.strictEqual(record.installed, false); + assert.strictEqual(record.manifestVersion, null); + assert.deepStrictEqual(record.stems, []); + } + assert.deepStrictEqual(result[0].triggers, []); + }); + + test('a layout failure yields no stems but never reports the scope uninstalled', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + 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 synthetic registry that reproduces the exact TypeError + // `resolveRuntimeArtifactLayoutFromRegistry` throws for a skills entry + // with `converter: null` (`dispatchKindEntry`'s `case 'skills'` guard). + // `resolveTriggerSurface` does not call `dispatchKindEntry` at all, so it + // is unaffected by this — which is exactly what isolates "stems failed" + // from "the trigger call failed" in this fixture. + const realClaude = JSON.parse(JSON.stringify(capabilityRegistry.runtimes.claude)); + realClaude.runtime.artifactLayout.global[0].converter = null; + const registry = { runtimes: { claude: realClaude } }; + + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { + readManifest: mkReadManifest(byConfigHome), + registry, + })); + const global = scopeOf(result, 'global'); + assert.strictEqual(global.installed, true, 'a layout failure must not report the scope uninstalled'); + assert.strictEqual(global.manifestVersion, 2); + assert.deepStrictEqual(global.stems, [], 'stems degrade to empty on a layout-lookup failure'); + }); +}); + +describe('resolveInstalledSurfaces — purity (S18, S19)', () => { + test('a mutated result cannot corrupt a later call (S18)', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + 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' } })], + [homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' } })], + ]); + const opts = baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }); + + const first = resolveInstalledSurfaces('claude', opts); + const pristine = JSON.parse(JSON.stringify(first)); + + first[0].scopes[0].installed = false; + first[0].scopes[0].stems.push('HACKED'); + first[0].triggers.push({ trigger: 'INJECTED' }); + first.push({ runtime: 'INJECTED' }); + + const second = resolveInstalledSurfaces('claude', opts); + assert.deepStrictEqual(second, pristine, 'a second call must be unaffected by mutation of the first result'); + }); + + test('resolveInstalledSurfaces mutates nothing on disk (S19, acceptance criterion)', (t) => { + const root = createTempDir('gsd-installed-surface-resolver-'); + t.after(() => cleanup(root)); + + const home = path.join(root, 'home'); + const cwd = path.join(root, 'project'); + const globalDir = path.join(home, '.claude'); + const localDir = path.join(cwd, '.claude'); + fs.mkdirSync(globalDir, { recursive: true }); + fs.mkdirSync(localDir, { recursive: true }); + + const nowIso = new Date().toISOString(); + const globalManifest = { + version: '1.10.0', + timestamp: nowIso, + mode: 'full', + files: { 'skills/gsd-plan-phase/SKILL.md': 'sha-global' }, + manifestVersion: 2, + runtime: 'claude', + scope: 'global', + }; + const localManifest = { + version: '1.10.0', + timestamp: nowIso, + mode: 'full', + files: { 'commands/gsd-plan-phase.md': 'sha-local' }, + manifestVersion: 2, + runtime: 'claude', + scope: 'local', + }; + fs.writeFileSync(path.join(globalDir, 'gsd-file-manifest.json'), JSON.stringify(globalManifest, null, 2)); + fs.writeFileSync(path.join(localDir, 'gsd-file-manifest.json'), JSON.stringify(localManifest, null, 2)); + // A stray, unrelated file — proves the resolver doesn't touch anything it + // doesn't need either. + fs.writeFileSync(path.join(globalDir, 'settings.json'), '{}'); + + function snapshot(dir) { + const out = []; + const walk = (d) => { + for (const entry of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const full = path.join(d, entry.name); + if (entry.isDirectory()) { + out.push([full, { dir: true }]); + walk(full); + } else if (entry.isFile()) { + const st = fs.statSync(full); + out.push([full, { dir: false, size: st.size, mtimeMs: st.mtimeMs }]); + } + } + }; + walk(dir); + return out; + } + + const before = snapshot(root); + const result = resolveInstalledSurfaces('claude', { home, cwd, env: {}, existsSync: fs.existsSync }); + const after = snapshot(root); + + assert.strictEqual(scopeOf(result, 'global').installed, true); + assert.strictEqual(scopeOf(result, 'local').installed, true); + assert.deepStrictEqual(after, before, 'the resolver must perform no writes and create no new files/dirs'); + assert.deepStrictEqual(after.map((e) => e[0]), before.map((e) => e[0]), 'no new paths must appear'); + }); +}); + +// ─── Section 4 — stem derivation bijection + hostile-stem rejection (B1-B16) ─ +// +// The private derivation helpers (`deriveStemsForKindEntry`, +// `deriveStemsFromManifest`) are not exported — every row here is driven +// through `resolveInstalledSurfaces` and asserted against the returned +// `InstalledScopeRecord.stems` field, exactly as the module's own public +// contract exposes it. + +describe('resolveInstalledSurfaces — stem derivation (B1-B15)', () => { + test('derives a stem from a skills manifest key (B1)', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, files: { 'skills/gsd-plan-phase/SKILL.md': 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(scopeOf(result, 'global').stems, ['plan-phase']); + }); + + test('derives a stem from a namespaced-by-dir command key (B2)', () => { + // No shipped runtime declares a namespaced-by-dir commands layout today + // (destSubpath's basename === prefix minus its trailing '-') — same gap + // the sibling resolveTriggerSurface suite documents for its own row 12. + // `opts.registry` overrides ONLY the layout lookup (never `resolveScope`, + // per the design's Correction note), so `claude` still resolves via the + // REAL registry while its layout is read from this synthetic one. + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + const registry = { + runtimes: { + claude: { + runtime: { + artifactLayout: { + global: [], + local: [ + { kind: 'commands', destSubpath: 'commands/gsd', prefix: 'gsd-', nesting: 'flat', recursive: false, converter: null }, + ], + }, + }, + }, + }, + }; + const byConfigHome = new Map([ + [homes.local, manifest({ manifestVersion: 2, files: { 'commands/gsd/plan-phase.md': 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome), registry })); + assert.deepStrictEqual(scopeOf(result, 'local').stems, ['plan-phase']); + }); + + test('derives a stem from a prefixed command key (B3)', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.local, manifest({ manifestVersion: 2, files: { 'commands/gsd-plan-phase.md': 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(scopeOf(result, 'local').stems, ['plan-phase']); + }); + + test('multiple files under one skill dir yield one stem (B4)', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ + manifestVersion: 2, + files: { + 'skills/gsd-plan-phase/SKILL.md': 'a', + 'skills/gsd-plan-phase/reference.md': 'b', + }, + })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(scopeOf(result, 'global').stems, ['plan-phase']); + }); + + test('normalizes backslash keys unconditionally (B5)', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, files: { 'skills\\gsd-plan-phase\\SKILL.md': 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(scopeOf(result, 'global').stems, ['plan-phase']); + }); + + test('a non-markdown command key yields no stem (B6)', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.local, manifest({ manifestVersion: 2, files: { 'commands/gsd-plan-phase': 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(scopeOf(result, 'local').stems, []); + }); + + test('an empty stem is not emitted (B7)', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.local, manifest({ manifestVersion: 2, files: { 'commands/gsd-.md': 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(scopeOf(result, 'local').stems, []); + }); + + test('a traversal segment in a skills key is rejected (B9)', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, files: { 'skills/gsd-../SKILL.md': 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(scopeOf(result, 'global').stems, []); + }); + + test("the reviewer's exact hostile payload yields no stem (B10)", () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, files: { 'skills/gsd-../../../x/SKILL.md': 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(scopeOf(result, 'global').stems, []); + }); + + test('a stem containing a control character / newline is rejected (B11)', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, files: { 'skills/gsd-x\ny/SKILL.md': 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(scopeOf(result, 'global').stems, []); + }); + + test('a stem containing an ANSI escape sequence is rejected (B12)', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, files: { 'skills/gsd-xy/SKILL.md': 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(scopeOf(result, 'global').stems, []); + }); + + test('a stem containing an RTL-override codepoint is rejected (B13)', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, files: { 'skills/gsd-x‮y/SKILL.md': 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(scopeOf(result, 'global').stems, []); + }); + + test('an uppercase stem is rejected (B14)', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, files: { 'skills/gsd-PlanPhase/SKILL.md': 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(scopeOf(result, 'global').stems, []); + }); + + test('a stem starting with a hyphen is rejected (B15)', () => { + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, files: { 'skills/gsd--x/SKILL.md': 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(scopeOf(result, 'global').stems, []); + }); +}); + +describe('resolveInstalledSurfaces — stem derivation is the exact inverse of Phase 2 filename composition (B8, property)', () => { + // Real stem alphabet: lowercase alnum segments joined by single hyphens + // (e.g. 'plan-phase', 'x', 'a1-b2-c3'), bounded so generated manifest keys + // stay realistic in length. + const stemArb = fc.stringMatching(/^[a-z0-9]{1,8}(-[a-z0-9]{1,8}){0,2}$/); + + const PREFIX = 'gsd-'; + const home = '/fixture/home'; + const cwd = '/fixture/project'; + const homes = scopeHomes('claude', home, cwd); + + // The namespaced-by-dir shape needs a synthetic layout override (no shipped + // runtime declares one today — see B2 above); built once, outside the + // property body, since it never varies across runs. + const namespacedRegistry = { + runtimes: { + claude: { + runtime: { + artifactLayout: { + global: [], + local: [ + { kind: 'commands', destSubpath: 'commands/gsd', prefix: PREFIX, nesting: 'flat', recursive: false, converter: null }, + ], + }, + }, + }, + }, + }; + + test('property: prefixed commands round-trip', () => { + fc.assert( + fc.property(stemArb, (stem) => { + // isNamespacedByDir/composeCommandFilename are the SAME two exports + // the resolver's own derivation consumes — binding both halves of + // the bijection genuinely, not by re-deriving either rule here. + const namespacedByDir = isNamespacedByDir('commands', 'commands', PREFIX); + assert.strictEqual(namespacedByDir, false, 'fixture assumption: claude local commands is the prefixed shape'); + const filename = composeCommandFilename(namespacedByDir, PREFIX, stem); + const byConfigHome = new Map([ + [homes.local, manifest({ manifestVersion: 2, files: { [`commands/${filename}`]: 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(scopeOf(result, 'local').stems, [stem]); + }), + { numRuns: 50 }, + ); + // On failure, fast-check prints the pinned seed and the exact failing + // stem (its shrunk counterexample) as part of the thrown AssertionError + // — sufficient to replay the run deterministically without re-running + // the whole suite. + }); + + test('property: namespaced-by-dir commands round-trip', () => { + fc.assert( + fc.property(stemArb, (stem) => { + const namespacedByDir = isNamespacedByDir('commands', 'commands/gsd', PREFIX); + assert.strictEqual(namespacedByDir, true, 'fixture assumption: the synthetic layout is the namespaced-by-dir shape'); + const filename = composeCommandFilename(namespacedByDir, PREFIX, stem); + const byConfigHome = new Map([ + [homes.local, manifest({ manifestVersion: 2, files: { [`commands/gsd/${filename}`]: 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { + readManifest: mkReadManifest(byConfigHome), + registry: namespacedRegistry, + })); + assert.deepStrictEqual(scopeOf(result, 'local').stems, [stem]); + }), + { numRuns: 50 }, + ); + }); + + test('property: skills round-trip', () => { + fc.assert( + fc.property(stemArb, (stem) => { + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, files: { [`skills/${PREFIX}${stem}/SKILL.md`]: 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(scopeOf(result, 'global').stems, [stem]); + }), + { numRuns: 50 }, + ); + }); + + test('property: any dirSegment suffix that is not a bare kebab-case token yields no stem', () => { + // Complement of `SAFE_STEM` (`^[a-z0-9][a-z0-9-]*$`) — any non-empty + // string that does not match it must never survive into `stems`, no + // matter what a manifest key throws at the derivation (security + // boundary; see FINDING 1). Bounded and seeded like the round-trip + // properties above. + const hostileArb = fc.string({ minLength: 1, maxLength: 12 }) + // Excludes '/' and '\\' — either would split the manifest key into + // extra path segments and stop `hostile` from landing whole inside + // `dirSegment`, which is what this property needs to exercise. + .filter((s) => s.length > 0 && !s.includes('/') && !s.includes('\\') && !/^[a-z0-9][a-z0-9-]*$/.test(s)); + fc.assert( + fc.property(hostileArb, (hostile) => { + const byConfigHome = new Map([ + [homes.global, manifest({ manifestVersion: 2, files: { [`skills/${PREFIX}${hostile}/SKILL.md`]: 'a' } })], + ]); + const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) })); + assert.deepStrictEqual(scopeOf(result, 'global').stems, []); + }), + { numRuns: 100, seed: 42 }, + ); + }); +}); diff --git a/tests/installer-migrations-manifest-schema.test.cjs b/tests/installer-migrations-manifest-schema.test.cjs new file mode 100644 index 000000000..761c31c09 --- /dev/null +++ b/tests/installer-migrations-manifest-schema.test.cjs @@ -0,0 +1,668 @@ +'use strict'; + +/** + * Installer Migration Module — manifest DOCUMENT schema contract, both + * directions (#2872, ADR-2866 Phase 3, suite `unit` + suite `install`). + * + * Seams: + * - gsd-core/bin/lib/installer-migrations.cjs -> readInstallManifest (read path) + * - bin/install.js -> writeManifest(configDir, runtime, { mode, scope }) (write path) + * + * Covers rows R1-R21 (read path, section 2) and W1-W9 (write path, section 1) + * of `.gsd/phase/feat-2872-manifest-scope-runtime/50-test-matrix.md`. The + * resolver (S1-S21) and the stem bijection (B1-B8) belong to a different test + * file entirely and are deliberately NOT covered here. + * + * Both the writer and the reader are asserted in this ONE file, side by side, + * because they share a single on-disk format (`gsd-file-manifest.json`): + * `writeManifest` produces it, `readInstallManifest` consumes it, and there + * is no independent schema authority arbitrating between them other than + * this test file. Splitting the two across files would let a writer/reader + * divergence — the writer emitting a shape the reader silently misreads, or + * vice versa — pass both suites individually while breaking the real + * contract; keeping them together makes that class of defect fail in one + * place instead of two. + * + * The manifest is a JSON config document WRITTEN by the code under test — + * reading it back with JSON.parse and asserting field equality is the + * correct, expected shape here (never `.includes()`/`.match()` on raw text; + * that would trip `local/no-source-grep`, and this isn't a source file + * anyway). + * + * `writeManifest` is driven DIRECTLY (in-process, via `require('../bin/ + * install.js')`) rather than through a full spawned install for every row. + * `bin/install.js` guards its CLI entrypoint behind `require.main === module` + * (bin/install.js:13806), so requiring it as a module — the same thing + * tests/install-runtime-artifacts.test.cjs already does — never runs the + * installer's CLI path; it only exposes the exported functions, including + * `writeManifest` itself (see its export block). + * + * W1/W2 pass the exact three-argument shape production always uses + * (`writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: + * _installScopeId })`, all 5 call sites in bin/install.js) so the suite + * proves the property real callers exercise, not a degenerate one. W5-W7 + * deliberately use degenerate/omitted shapes — a third-party caller's + * defense-in-depth case per 40-design.md row A4. + */ + +const { describe, test, beforeEach, afterEach } = 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 { createTempDir, cleanup } = require('./helpers.cjs'); + +const { MANIFEST_SCHEMA_VERSION, readInstallManifest } = require('../gsd-core/bin/lib/installer-migrations.cjs'); +const { writeManifest } = require('../bin/install.js'); + +const MANIFEST_NAME = 'gsd-file-manifest.json'; + +function writeRawManifest(dir, content) { + fs.writeFileSync(path.join(dir, MANIFEST_NAME), content, 'utf8'); +} + +function writeJsonManifest(dir, value) { + writeRawManifest(dir, JSON.stringify(value)); +} + +function readManifest(dir) { + return JSON.parse(fs.readFileSync(path.join(dir, MANIFEST_NAME), 'utf8')); +} + +function sha256(content) { + return crypto.createHash('sha256').update(content).digest('hex'); +} + +describe('readInstallManifest — manifest schema (#2872 R1-R21)', () => { + let dir; + + beforeEach(() => { + dir = createTempDir('gsd-manifest-schema-'); + }); + + afterEach(() => { + cleanup(dir); + }); + + // R1 — no manifest file at all. manifestVersion must be null, distinct from + // the `1` a v1 manifest reports (locked together with R2/R6 elsewhere), and + // scope/runtime null too. + test('R1: absent manifest reports manifestVersion null, distinct from 1', () => { + const result = readInstallManifest(dir); + assert.strictEqual(result.manifestVersion, null); + assert.notStrictEqual(result.manifestVersion, 1); + assert.strictEqual(result.runtime, null); + assert.strictEqual(result.scope, null); + assert.deepStrictEqual(result.files, {}); + assert.strictEqual(result.version, null); + assert.strictEqual(result.timestamp, null); + assert.strictEqual(result.mode, null); + }); + + // R2 — the must-have acceptance criterion (AC2): a REAL v1 manifest, with + // exactly the four pre-#2872 fields and nothing else, reads without error + // and without requiring a reinstall. + test('R2 (must-have AC2): reads a v1 manifest without error and without reinstall', () => { + const v1 = { + version: '1.49.0', + timestamp: '2026-05-10T00:00:00.000Z', + mode: 'full', + files: { 'gsd-core/hooks/dist/gsd-check-update.js': 'deadbeef' }, + }; + writeJsonManifest(dir, v1); + + let result; + assert.doesNotThrow(() => { + result = readInstallManifest(dir); + }); + assert.strictEqual(result.manifestVersion, 1); + assert.strictEqual(result.runtime, null); + assert.strictEqual(result.scope, null); + // Byte-identical to what today's (pre-#2872) reader produced for the same input. + assert.strictEqual(result.version, v1.version); + assert.strictEqual(result.timestamp, v1.timestamp); + assert.strictEqual(result.mode, v1.mode); + assert.deepStrictEqual(result.files, v1.files); + }); + + // R3/R5 — a v2 manifest surfaces all three new fields. + test('R3: reads scope and runtime from a v2 manifest', () => { + writeJsonManifest(dir, { + manifestVersion: 2, + version: '1.60.0', + timestamp: '2026-08-01T00:00:00.000Z', + mode: 'full', + runtime: 'claude', + scope: 'local', + files: { 'agents/gsd-planner.md': 'abc123' }, + }); + + const result = readInstallManifest(dir); + assert.strictEqual(result.manifestVersion, 2); + assert.strictEqual(result.runtime, 'claude'); + assert.strictEqual(result.scope, 'local'); + }); + + // R4 — a FUTURE writer's manifestVersion is reported verbatim, never + // clamped and never thrown on. + test('R4: reports a future manifestVersion verbatim', () => { + writeJsonManifest(dir, { + manifestVersion: 3, + version: '1.99.0', + timestamp: '2026-09-01T00:00:00.000Z', + mode: 'full', + runtime: 'codex', + scope: 'global', + files: {}, + }); + + let result; + assert.doesNotThrow(() => { + result = readInstallManifest(dir); + }); + assert.strictEqual(result.manifestVersion, 3); + }); + + // R6/R7/R8/R9 — the manifestVersion normalization boundary set, in one + // table-driven test so the limit-1/limit/limit+1 + malformed classes sit + // together. + const manifestVersionCases = [ + { label: 'R6: explicit 1 matches an implicit v1', raw: 1, expected: 1 }, + { label: 'R7: 0 is out-of-range, rejected to 1', raw: 0, expected: 1 }, + { label: 'R7: -1 is out-of-range, rejected to 1', raw: -1, expected: 1 }, + { label: 'R8: 2.5 is non-integer, rejected to 1', raw: 2.5, expected: 1 }, + { label: 'R8: NaN is non-integer, rejected to 1', raw: NaN, expected: 1 }, + { label: 'R8: Infinity is non-integer, rejected to 1', raw: Infinity, expected: 1 }, + { label: 'R9: stringified "2" is not a version claim, rejected to 1', raw: '2', expected: 1 }, + ]; + for (const { label, raw, expected } of manifestVersionCases) { + test(label, () => { + writeJsonManifest(dir, { + manifestVersion: raw, + version: '1.50.0', + timestamp: '2026-05-11T00:00:00.000Z', + mode: 'full', + files: {}, + }); + const result = readInstallManifest(dir); + assert.strictEqual(result.manifestVersion, expected); + }); + } + + // R6 (paired assertion) — an explicit manifestVersion:1 is indistinguishable + // from one with the key entirely absent. + test('R6: an explicit manifestVersion 1 matches an implicit one', (t) => { + const explicitDir = createTempDir('gsd-manifest-schema-explicit-'); + const implicitDir = createTempDir('gsd-manifest-schema-implicit-'); + t.after(() => cleanup(explicitDir)); + t.after(() => cleanup(implicitDir)); + const base = { version: '1.50.0', timestamp: '2026-05-11T00:00:00.000Z', mode: 'full', files: {} }; + writeJsonManifest(explicitDir, { manifestVersion: 1, ...base }); + writeJsonManifest(implicitDir, base); + + assert.strictEqual(readInstallManifest(explicitDir).manifestVersion, 1); + assert.strictEqual( + readInstallManifest(explicitDir).manifestVersion, + readInstallManifest(implicitDir).manifestVersion, + ); + }); + + // R10 — the negative-space row: consent/lifecycle vocabulary ('project') + // must never be read as an install scope, and must NEVER collapse to + // 'local'. + test('R10 (negative space): does not read the consent vocabulary as an install scope', () => { + writeJsonManifest(dir, { + manifestVersion: 2, + version: '1.60.0', + timestamp: '2026-08-01T00:00:00.000Z', + mode: 'full', + runtime: 'claude', + scope: 'project', + files: {}, + }); + + const result = readInstallManifest(dir); + assert.strictEqual(result.scope, null); + assert.notStrictEqual(result.scope, 'local'); + }); + + // R11 — hostile scope values all reject to null. + for (const scope of ['GLOBAL', '', 7, null]) { + test(`R11: rejects an invalid scope (${JSON.stringify(scope)}) to null`, () => { + writeJsonManifest(dir, { + manifestVersion: 2, + version: '1.60.0', + timestamp: '2026-08-01T00:00:00.000Z', + mode: 'full', + runtime: 'claude', + scope, + files: {}, + }); + assert.strictEqual(readInstallManifest(dir).scope, null); + }); + } + + // R12 — malformed runtime values (empty, whitespace-only, non-string) + // reject to null. + for (const runtime of ['', ' ', 42]) { + test(`R12: rejects an empty or non-string runtime (${JSON.stringify(runtime)}) to null`, () => { + writeJsonManifest(dir, { + manifestVersion: 2, + version: '1.60.0', + timestamp: '2026-08-01T00:00:00.000Z', + mode: 'full', + runtime, + scope: 'global', + files: {}, + }); + assert.strictEqual(readInstallManifest(dir).runtime, null); + }); + } + + // R13 — an unregistered runtime string is a fact about the file, reported + // verbatim; the reader does not validate against the runtime registry. + test('R13: reports an unregistered runtime string verbatim', () => { + writeJsonManifest(dir, { + manifestVersion: 2, + version: '1.60.0', + timestamp: '2026-08-01T00:00:00.000Z', + mode: 'full', + runtime: 'not-a-registered-runtime', + scope: 'global', + files: {}, + }); + assert.strictEqual(readInstallManifest(dir).runtime, 'not-a-registered-runtime'); + }); + + // R14 — unparseable JSON reads as absent, no throw (Known limit, B9). + test('R14: an unparseable manifest reads as absent', () => { + writeRawManifest(dir, '{not valid json'); + + let result; + assert.doesNotThrow(() => { + result = readInstallManifest(dir); + }); + assert.strictEqual(result.manifestVersion, null); + assert.strictEqual(result.runtime, null); + assert.strictEqual(result.scope, null); + assert.deepStrictEqual(result.files, {}); + }); + + // R15 — valid JSON that is NOT an object (0, "str", true, null) all read + // as absent, no throw. + for (const value of [0, 'str', true, null]) { + test(`R15 (negative space): valid non-object JSON (${JSON.stringify(value)}) reads as absent`, () => { + writeJsonManifest(dir, value); + + let result; + assert.doesNotThrow(() => { + result = readInstallManifest(dir); + }); + assert.strictEqual(result.manifestVersion, null); + assert.strictEqual(result.runtime, null); + assert.strictEqual(result.scope, null); + assert.deepStrictEqual(result.files, {}); + }); + } + + // R16 — valid JSON `[]` documents the current branch: typeof [] === 'object' + // passes the guard, but `files` is absent on an array so it collapses to {}. + test('R16 (negative space): an array manifest yields an empty file map', () => { + writeJsonManifest(dir, []); + + let result; + assert.doesNotThrow(() => { + result = readInstallManifest(dir); + }); + assert.deepStrictEqual(result.files, {}); + }); + + // R17 — a zero-byte manifest file reads as absent, no throw. + test('R17 (negative space): an empty manifest file reads as absent', () => { + writeRawManifest(dir, ''); + + let result; + assert.doesNotThrow(() => { + result = readInstallManifest(dir); + }); + assert.strictEqual(result.manifestVersion, null); + assert.strictEqual(result.runtime, null); + assert.strictEqual(result.scope, null); + assert.deepStrictEqual(result.files, {}); + }); + + // R18 — a manifest with zero files still reports its version: "present but + // empty" is a distinct state from "absent". + test('R18: a manifest with no files still reports its version', () => { + writeJsonManifest(dir, { + manifestVersion: 2, + version: '1.60.0', + timestamp: '2026-08-01T00:00:00.000Z', + mode: 'full', + runtime: 'claude', + scope: 'global', + files: {}, + }); + + const result = readInstallManifest(dir); + assert.strictEqual(result.manifestVersion, 2); + assert.deepStrictEqual(result.files, {}); + }); + + // R19 — CRLF line endings inside the JSON text must not change the parse. + test('R19: CRLF line endings do not change the parse', (t) => { + const lfDir = createTempDir('gsd-manifest-schema-lf-'); + const crlfDir = createTempDir('gsd-manifest-schema-crlf-'); + t.after(() => cleanup(lfDir)); + t.after(() => cleanup(crlfDir)); + const lfJson = [ + '{', + ' "manifestVersion": 2,', + ' "version": "1.60.0",', + ' "timestamp": "2026-08-01T00:00:00.000Z",', + ' "mode": "full",', + ' "runtime": "claude",', + ' "scope": "local",', + ' "files": { "agents/gsd-planner.md": "abc123" }', + '}', + '', + ].join('\n'); + writeRawManifest(lfDir, lfJson); + writeRawManifest(crlfDir, lfJson.replace(/\n/g, '\r\n')); + + assert.deepStrictEqual(readInstallManifest(crlfDir), readInstallManifest(lfDir)); + }); + + // R20 — a `files` key literally named `__proto__` must never pollute + // Object.prototype. + test('R20 (hostile): a __proto__ manifest key does not pollute the prototype', () => { + // Built as raw JSON TEXT (never a JS object literal) so the on-disk bytes + // contain a literal `"__proto__"` key. An object-literal spelling + // (`{ '__proto__': ... }`) is special-cased by the ObjectLiteral grammar + // and would set the JS object's prototype at construction time instead + // of producing an own enumerable key — which would not reproduce what + // `JSON.parse` actually hands the reader when a hostile manifest is read + // from disk (JSON.parse's InternalizeJSONProperty creates a plain own + // property named "__proto__", not a prototype rewire). + const raw = [ + '{', + ' "manifestVersion": 2,', + ' "version": "1.60.0",', + ' "timestamp": "2026-08-01T00:00:00.000Z",', + ' "mode": "full",', + ' "runtime": "claude",', + ' "scope": "global",', + ' "files": { "__proto__": { "polluted": "yes" }, "constructor": { "polluted": "also-yes" } }', + '}', + ].join('\n'); + writeRawManifest(dir, raw); + + assert.doesNotThrow(() => readInstallManifest(dir)); + + assert.strictEqual(({}).polluted, undefined); + assert.strictEqual(Object.getOwnPropertyNames(Object.prototype).includes('polluted'), false); + }); + + // R21 — independence: the four v1 callers read only `files`. A manifest + // carrying just the four v1 fields must yield the exact same `files` map + // the pre-#2872 reader produced for that input. + test('R21 (independence): existing callers see an unchanged files map', () => { + const files = { + 'gsd-core/hooks/dist/gsd-check-update.js': 'aaa111', + 'agents/gsd-planner.md': 'bbb222', + 'skills/gsd-plan-phase/SKILL.md': 'ccc333', + }; + writeJsonManifest(dir, { + version: '1.49.0', + timestamp: '2026-05-10T00:00:00.000Z', + mode: 'full', + files, + }); + + const result = readInstallManifest(dir); + assert.deepStrictEqual(result.files, files); + }); + + // R22 — boundary (limit): a runtime string of exactly 64 chars is reported + // unchanged, no truncation marker. + test('R22: reports a 64-char runtime unchanged', () => { + const runtime = 'a'.repeat(64); + writeJsonManifest(dir, { + manifestVersion: 2, + version: '1.60.0', + timestamp: '2026-08-01T00:00:00.000Z', + mode: 'full', + runtime, + scope: 'global', + files: {}, + }); + assert.strictEqual(readInstallManifest(dir).runtime, runtime); + }); + + // R23 — boundary (limit+1): one char past the cap is truncated to 64 chars + // plus the ellipsis marker (same convention as `truncatePostureValue`, + // agent-install-check.cts:75-77). + test('R23: truncates a 65-char runtime to 64 chars plus an ellipsis', () => { + const runtime = 'a'.repeat(65); + writeJsonManifest(dir, { + manifestVersion: 2, + version: '1.60.0', + timestamp: '2026-08-01T00:00:00.000Z', + mode: 'full', + runtime, + scope: 'global', + files: {}, + }); + const result = readInstallManifest(dir); + assert.strictEqual(result.runtime, `${'a'.repeat(64)}…`); + assert.strictEqual(result.runtime.length, 65); + }); + + // R24 — boundary (limit-1): one char under the cap is reported unchanged. + test('R24: reports a 63-char runtime unchanged', () => { + const runtime = 'a'.repeat(63); + writeJsonManifest(dir, { + manifestVersion: 2, + version: '1.60.0', + timestamp: '2026-08-01T00:00:00.000Z', + mode: 'full', + runtime, + scope: 'global', + files: {}, + }); + assert.strictEqual(readInstallManifest(dir).runtime, runtime); + }); + + // R25 — hostile: the manifest is attacker-influenceable (a project-local + // one lives inside a repository a user may merely have cloned), so a very + // long `runtime` string must never reach a consumer unbounded, and must + // never throw. + test('R25: bounds a very long runtime without throwing', () => { + const runtime = 'x'.repeat(100_000); + writeJsonManifest(dir, { + manifestVersion: 2, + version: '1.60.0', + timestamp: '2026-08-01T00:00:00.000Z', + mode: 'full', + runtime, + scope: 'global', + files: {}, + }); + let result; + assert.doesNotThrow(() => { + result = readInstallManifest(dir); + }); + assert.strictEqual(result.runtime.length, 65); + }); + + // R26 — negative space, documented deliberately: the CHARSET of `runtime` + // is NOT gated, only its length. `declaredRuntimeMatchesProbe` needs to see + // the actual value to detect a mismatch, so a charset gate here would + // destroy that signal. A future reader must not "fix" this by adding one — + // Phase 4 (#2873) owns sanitizing `declaredRuntime` before it is rendered. + test('R26: still reports a runtime containing a control character', () => { + const runtime = 'claude'; + writeJsonManifest(dir, { + manifestVersion: 2, + version: '1.60.0', + timestamp: '2026-08-01T00:00:00.000Z', + mode: 'full', + runtime, + scope: 'global', + files: {}, + }); + assert.strictEqual(readInstallManifest(dir).runtime, runtime); + }); +}); + +describe('writeManifest — scope + runtime recording (#2872 W1-W9)', () => { + let dir; + + beforeEach(() => { + dir = createTempDir('gsd-write-manifest-'); + }); + + afterEach(() => { + cleanup(dir); + }); + + // W1 — global install, claude: manifestVersion 2, runtime + scope recorded. + test('records manifestVersion, runtime and scope for a global install', () => { + writeManifest(dir, 'claude', { mode: 'full', scope: 'global' }); + + const manifest = readManifest(dir); + assert.strictEqual(manifest.manifestVersion, 2); + assert.strictEqual(manifest.runtime, 'claude'); + assert.strictEqual(manifest.scope, 'global'); + }); + + // W2 — local install, claude: same runtime, scope local. + test('records scope local for a --local install', () => { + writeManifest(dir, 'claude', { mode: 'full', scope: 'local' }); + + const manifest = readManifest(dir); + assert.strictEqual(manifest.manifestVersion, 2); + assert.strictEqual(manifest.runtime, 'claude'); + assert.strictEqual(manifest.scope, 'local'); + }); + + // W3 — a non-claude runtime is recorded as itself, not a hardcoded default. + test('records the installing runtime, not a default', () => { + writeManifest(dir, 'codex', { mode: 'full', scope: 'global' }); + + const manifest = readManifest(dir); + assert.strictEqual(manifest.runtime, 'codex'); + }); + + // W4 — an install over a pre-existing v1 manifest rebuilds it whole, as v2, + // with `files` still reflecting what's actually on disk (not wiped). + test('an install over a v1 manifest rewrites it as v2', () => { + const gsdCoreDir = path.join(dir, 'gsd-core'); + fs.mkdirSync(gsdCoreDir, { recursive: true }); + const trackedContent = 'console.log("hook");\n'; + fs.writeFileSync(path.join(gsdCoreDir, 'gsd-check-update.js'), trackedContent); + fs.writeFileSync( + path.join(dir, MANIFEST_NAME), + JSON.stringify({ + version: '1.49.0', + timestamp: '2026-05-10T00:00:00.000Z', + mode: 'full', + files: { 'some/stale/path.md': 'stalehash' }, + }), + ); + + writeManifest(dir, 'claude', { mode: 'full', scope: 'global' }); + + const manifest = readManifest(dir); + assert.strictEqual(manifest.manifestVersion, 2); + assert.strictEqual(manifest.runtime, 'claude'); + assert.strictEqual(manifest.scope, 'global'); + // `files` reflects what's actually on disk now, not the stale v1 entry. + assert.strictEqual( + manifest.files['gsd-core/gsd-check-update.js'], + sha256(trackedContent), + ); + assert.strictEqual(manifest.files['some/stale/path.md'], undefined); + }); + + // W5 — options omitted entirely: scope defaults to 'global', never + // null/absent (A3: the SAME fallback the skills-root line already + // applies, read from one place). + test('defaults scope to global when options are omitted', () => { + writeManifest(dir, 'claude'); + + const manifest = readManifest(dir); + assert.strictEqual(manifest.scope, 'global'); + assert.notStrictEqual(manifest.scope, null); + }); + + // W6 — a junk options.scope coerces to 'global' without throwing (A4: + // defense-in-depth for a third-party caller; junk cannot reach here from + // bin/install.js itself). + for (const junkScope of ['GLOBAL', '', 0, null]) { + test(`coerces an unrecognized scope (${JSON.stringify(junkScope)}) to global without throwing`, () => { + let manifest; + assert.doesNotThrow(() => { + writeManifest(dir, 'claude', { mode: 'full', scope: junkScope }); + manifest = readManifest(dir); + }); + assert.strictEqual(manifest.scope, 'global'); + }); + } + + // W7 — runtime omitted: records DEFAULT_RUNTIME, never null. + test('records the default runtime when none is passed', () => { + writeManifest(dir); + + const manifest = readManifest(dir); + assert.strictEqual(typeof manifest.runtime, 'string'); + assert.ok(manifest.runtime.length > 0); + assert.notStrictEqual(manifest.runtime, null); + }); + + // W8 — independence: the four pre-existing manifest fields keep their + // names and types. + test('does not change any pre-existing manifest field', () => { + writeManifest(dir, 'claude', { mode: 'full', scope: 'global' }); + + const manifest = readManifest(dir); + assert.strictEqual(typeof manifest.version, 'string'); + assert.strictEqual(typeof manifest.timestamp, 'string'); + assert.strictEqual(typeof manifest.mode, 'string'); + assert.strictEqual(typeof manifest.files, 'object'); + assert.ok(manifest.files !== null); + assert.ok(!Array.isArray(manifest.files)); + }); + + // W9 — idempotent apart from timestamp: writing twice over the same dir + // produces two manifests that differ ONLY in their timestamp. Never + // asserts on elapsed wall-clock time — only on structural equality once + // `timestamp` is removed from both sides. + test('is idempotent apart from timestamp', () => { + writeManifest(dir, 'claude', { mode: 'full', scope: 'global' }); + const first = readManifest(dir); + writeManifest(dir, 'claude', { mode: 'full', scope: 'global' }); + const second = readManifest(dir); + + assert.strictEqual(typeof first.timestamp, 'string'); + assert.strictEqual(typeof second.timestamp, 'string'); + delete first.timestamp; + delete second.timestamp; + assert.deepStrictEqual(first, second); + }); + + // W10 (parity) — the divergence guard this repo requires when two surfaces + // share a constant: `writeManifest` (bin/install.js) and + // `readInstallManifest` (gsd-core/bin/lib/installer-migrations.cjs) must + // agree on the manifest schema version via the SAME owned constant, never + // two independent literals that can drift apart (the "generative fix + // divergence" class). This fails if either side is changed alone. + test('W10 (parity): the version writeManifest emits is the same constant readInstallManifest owns', () => { + writeManifest(dir, 'claude', { mode: 'full', scope: 'global' }); + + const manifest = readManifest(dir); + assert.strictEqual(manifest.manifestVersion, MANIFEST_SCHEMA_VERSION); + assert.strictEqual(readInstallManifest(dir).manifestVersion, MANIFEST_SCHEMA_VERSION); + }); +});