* enhance(#2872): record scope and runtime in the install manifest gsd-file-manifest.json gains manifestVersion, runtime and scope, and a new read-only Installed Surface Resolver Module reads both install scopes for a runtime in one call -- the first code path in the repo that does. Phase 3 of epic #2866 (ADR-2866). Blocks Phase 4 (#2873), which resolves #2218: the resolver's shadowedBy field is that defect expressed as a value for the first time. It ships computed-and-unread here. Installed-ness is decided by manifest PRESENCE, never by the new fields, so a manifest written by an older GSD stays fully functional and no user needs to reinstall. Recorded runtime/scope are corroboration: a disagreement with the probed config dir is reported as declaredScopeMatchesProbe: false, never silently corrected. readInstallManifest is widened additively -- version/timestamp/mode/files keep their exact names, types and meanings for all four existing callers. manifestVersion is a new field rather than a reinterpretation of version, which holds the package version and is read by the golden-parity fixtures. Stems are derived from the installed manifest's own file keys, the inverse of Phase 2's filename composition, guarded by a fast-check round-trip property plus a kebab-case charset check so a crafted manifest key cannot put a traversal segment, control character or ANSI escape into a trigger that Phase 4 renders back to the user. Also fixes two defects found while working: - bin/install.js hardcoded manifestVersion: 2 while the reader owned MANIFEST_SCHEMA_VERSION = 2. Now single-sourced, with a parity test. - docs/installer-migrations.md documented an install-state schema of five snake_case fields that have never been written; InstallState has only ever been { schemaVersion, appliedMigrations }. Corrected with a dated note. Verification runs on the remote runner. * fix(#2872): fold review findings from three independent engines Standards axis: - convert the manifest-schema suite from a hybrid setup(t) closure to beforeEach/afterEach (CONTRIBUTING.md:319-354 Pattern 1). The hybrid was neither approved pattern and a new test forgetting the call got no warning. - SCOPE_ORDER was declared twice with no parity test -- this repo's recorded generative-fix-divergence class. Give the ordering one owner: install-scope exports it frozen, the layout module and the resolver both import it, and a test locks it against scopeRank so the constant and the ranks cannot drift. - drop the defaultReadManifest passthrough (Middle Man). Spec axis: - add the VOLATILE_FILES exclusion test and source comment the acceptance table promised and did not deliver. gsd-file-manifest.json stays excluded: the new fields are deterministic, but timestamp -- the original reason -- is unchanged. Security axis: - bound the reported manifest runtime at 64 chars, matching the truncatePostureValue convention already used in this subsystem. It reached declaredRuntime unbounded while the adjacent stems were gated by SAFE_STEM; an inconsistent posture on the same attacker-influenceable document. The charset stays ungated on purpose -- declaredRuntimeMatchesProbe needs to see the real value -- so Phase 4 must sanitize before rendering, recorded in the design's Known limits. Both new parity tests were verified to FAIL when the two sides are made to disagree, then pass again on revert. Verification runs on the remote runner. * chore(#2872): backfill changeset pr number to 3323 * fix(#2872): give git fixture construction its own timeout class PR #3323's full test (windows-latest, 22, shard 2/3) failed with gitOrThrow: 'git init' failed -- outcome=timed_out exitCode=null gitOrThrow: 'git commit --allow-empty' failed -- outcome=timed_out from drift-detection.test.cjs's beforeEach, a file this branch never touched. Every other lane passed the same commit, including windows-latest node 24 on all three shards, and next is green. Root cause is a bound sized for the wrong class. DEFAULT_GIT_TIMEOUT_MS is 15000 and its own comment scopes it to plumbing READS -- rev-parse, branch, log -- against an existing repo. createFixture uses it for six sequential repo-CONSTRUCTION spawns: init, three config writes, add -A, commit. init and commit each write dozens of files, and on Windows every spawn is Defender-scanned. Sibling tests in the failing block took 15.6-22.0s against a 15000ms bound. This repo already diagnosed this exact shape once: timeouts.cjs's HOOK_FANOUT_TIMEOUT_MS records PR #3285 failing in the SAME job with the SAME outcome=timed_out exitCode=null signature at the SAME bound while every other lane passed, and concludes 'a bound sized for the wrong class, not a slow machine'. It was fixed by splitting out a heavier class-norm at 60000. Same remedy here: GIT_FIXTURE_TIMEOUT_MS = 60000, 4x the bound that failed and half INSTALL_TIMEOUT_MS. DEFAULT_GIT_TIMEOUT_MS deliberately stays at 15000 -- a blanket raise would stop a genuinely hung plumbing read from surfacing fast. Verified the value reaches the spawn rather than being an ignored option: spawnSync was monkeypatched before requiring the fixture module, and all six git construction calls were captured carrying timeout: 60000. This branch's two new test files shift shard composition, which is how a pre-existing fragility landed in the heaviest shard on the slowest lane. Fixed here rather than deferred, per the no-defer rule. Verification runs on the remote runner. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/bold-orcas-sing.md
Normal file
5
.changeset/bold-orcas-sing.md
Normal file
@@ -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)
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, unknown> = {};
|
||||
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<string, unknown>)['files'] === 'object' &&
|
||||
(parsed as Record<string, unknown>)['files'] !== null
|
||||
) {
|
||||
manifestFiles = (parsed as Record<string, Record<string, unknown>>)['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<string, string> };
|
||||
};
|
||||
const manifestFiles: Record<string, unknown> = readInstallManifest(path.dirname(agentsDir)).files;
|
||||
|
||||
if (Object.keys(manifestFiles).length > 0) {
|
||||
for (const agent of expectedAgents) {
|
||||
|
||||
@@ -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']);
|
||||
|
||||
433
src/installed-surface-resolver.cts
Normal file
433
src/installed-surface-resolver.cts
Normal file
@@ -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-<name>`-style trigger, as `resolveTriggerSurface`
|
||||
* (`runtime-artifact-layout.cts`, #2871 Phase 2) produces it. */
|
||||
type TriggerSurface = ReturnType<typeof resolveTriggerSurface>[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<typeof resolveRuntimeArtifactLayoutFromRegistry>[0];
|
||||
|
||||
/** The registry shape `resolveTriggerSurface`'s `opts.registry` accepts. */
|
||||
type TriggerRegistryLike = NonNullable<Parameters<typeof resolveTriggerSurface>[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<string, unknown>;
|
||||
}
|
||||
|
||||
// ── 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<string, string | undefined>;
|
||||
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<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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-<stem>`.
|
||||
* 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<string>();
|
||||
|
||||
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<string, string>,
|
||||
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<string>();
|
||||
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<string, string>;
|
||||
};
|
||||
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<string>();
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
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<string, string> : {},
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
18
tests/fixtures/index.cjs
vendored
18
tests/fixtures/index.cjs
vendored
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
783
tests/installed-surface-resolver.test.cjs
Normal file
783
tests/installed-surface-resolver.test.cjs
Normal file
@@ -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-x[31my/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-xy/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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
668
tests/installer-migrations-manifest-schema.test.cjs
Normal file
668
tests/installer-migrations-manifest-schema.test.cjs
Normal file
@@ -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[31m';
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user