Merge pull request #1488 from open-gsd/feat/1463-capability-outdated

feat(#1463): add capability outdated (per-source update check); drop phantom slash-command docs
This commit is contained in:
Tom Boucher
2026-06-20 11:02:47 -04:00
committed by GitHub
11 changed files with 1244 additions and 20 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 1488
---
**Added `gsd capability outdated`** — a new subcommand that light-peeks each installed overlay capability's recorded source for the latest version that re-resolving that source would install and reports which have an update available (ADR-1244 D6 per-source matrix: git `ls-remote --tags`, npm `view … version`, local re-read; tarball → `manual`, registry → `unknown`). A capability is reported `outdated` only if re-resolving its recorded source would fetch a newer version: an npm range (`@^1`) resolves to the highest version **matching the range** (read from each `npm view` line's canonical version field, so a version-like substring in the package name never poisons the result), and a source pinned to an immutable ref (git `#sha:`/`#tag:`) or an exact npm version is reported `pinned` — never `outdated`, since `update` will not move it. A bare git ref (`#<ref>`) is classified at the remote with a bounded `git ls-remote`: a ref that resolves to a tag is `pinned`, while a **mutable branch** ref is never `pinned` (it degrades to `unknown`, since the installed commit is not recorded to compare against). Each capability is classified `outdated` / `current` / `pinned` / `manual` / `unknown`; subprocesses are bounded (git ≤30s, npm ≤60s) and a failing or unsupported peek degrades that row to `unknown` instead of crashing the command. `--json` emits the records array; the default prints a table. (#1463)

View File

@@ -1117,7 +1117,7 @@ Toggle which skills are surfaced — apply a profile, list, or disable a cluster
### `gsd capability`
Manage GSD capabilities — first-party (shipped) and third-party overlays. CLI form `gsd capability <subcommand>` (slash form `gsd:capability` on slash-command runtimes). See the [`gsd capability` command reference](reference/gsd-capability-command.md) for the full contract, source-spec forms, and install layout.
Manage GSD capabilities — first-party (shipped) and third-party overlays. CLI form `gsd capability <subcommand>`. See the [`gsd capability` command reference](reference/gsd-capability-command.md) for the full contract, source-spec forms, and install layout.
| Subcommand | Description |
|------------|-------------|
@@ -1125,6 +1125,7 @@ Manage GSD capabilities — first-party (shipped) and third-party overlays. CLI
| `update [<id> \| --all] [--scope …] [--yes]` | Re-resolve a capability's recorded source and upgrade it (atomic stage-then-swap) |
| `remove <id> [--purge-data] [--scope …]` | Remove an installed overlay capability's files + marker-isolated shared edits (first-party cannot be removed here) |
| `list [--json]` | List first-party + installed overlay capabilities as a JSON array |
| `outdated [--json] [--scope …]` | Light-peek each installed overlay's recorded source and report which have a newer version available (per-source matrix; npm ranges resolve the highest matching version; `pinned` for immutable/explicit git refs or exact npm versions; `manual`/`unknown` for sources that can't be auto-checked) |
| `disable <id>` / `enable <id>` | Toggle a capability's activation state (same as `capability set <id> --off`/`--on`) |
| `state` / `set <id> …` | Inspect resolved capability state / set activation + per-hook gates |
@@ -1133,6 +1134,7 @@ gsd capability list --json # All capabilities as JSON
gsd capability install ./my-cap --scope project # Install a local capability into the project
gsd capability install npm:@org/gsd-cap-x@^1 --yes # Install from npm, granting executable-surface consent
gsd capability update my-cap # Upgrade from its recorded source
gsd capability outdated --json # Which installed overlays have a newer version?
gsd capability disable my-cap # Turn it off without removing it
gsd capability remove my-cap # Remove the overlay capability
```

View File

@@ -3189,15 +3189,16 @@ The load-bearing wire is the `plan-phase` lift into `must_haves.prohibitions`, s
### 147. Capability Management Command
**Command:** `gsd capability install | update | remove | list | disable | enable`
**Command:** `gsd capability install | update | remove | list | outdated | disable | enable`
**Purpose:** The user-facing CLI for the ADR-1244 capability ecosystem — install, upgrade, remove, list, and toggle GSD capabilities (first-party and third-party overlays) from a registry / git / npm / tarball / local source. Wires the Phase-3/4 lifecycle library (source resolver, install ledger, trust gate) to a command users actually run.
**Purpose:** The user-facing CLI for the ADR-1244 capability ecosystem — install, upgrade, remove, list, check for updates, and toggle GSD capabilities (first-party and third-party overlays) from a registry / git / npm / tarball / local source. Wires the Phase-3/4 lifecycle library (source resolver, install ledger, trust gate) to a command users actually run.
**Behavior:**
- `install <spec> [--integrity sha512-…] [--scope global|project] [--yes] [--shared-file <rel>]…` — resolve (copy-only) → verify integrity / SHA pin → `engines.gsd` gate → disclose executable surfaces → consent (`--yes` grants; without it an executable install aborts after printing the disclosure and writes nothing) → validate → extract → record the ledger.
- `update [<id> | --all] [--scope] [--yes]` — re-resolve the capability's recorded source and upgrade via atomic stage-then-swap; re-consent when the executable set changed; `--all` reports a per-capability outcome and exits non-zero on any partial failure.
- `remove <id> [--purge-data] [--scope]` — strip the ledger-recorded files + marker-isolated shared edits; first-party capabilities are rejected (use the product uninstaller).
- `list [--json]` — first-party + installed overlay capabilities (both scopes) as a JSON array.
- `outdated [--json] [--scope]` — light remote peek of each installed overlay's recorded source (ADR-1244 D6 per-source matrix: git `ls-remote --tags`, npm `view … version` resolving the highest version matching the recorded range, local re-read; tarball → `manual`, registry → `unknown`) reporting `outdated` / `current` / `pinned` / `manual` / `unknown` per capability. A source pinned to an immutable ref (git `#sha:` or `#tag:`, or an exact npm version) is reported `pinned`. A bare git `#<ref>` is classified at the remote: if it resolves exclusively under `refs/tags/` it is an immutable tag → `pinned`; if it resolves to a mutable branch (or is ambiguous) it is `unknown`. Bounded subprocesses (git ≤30s, npm ≤60s) and a failing peek degrades that row to `unknown` without crashing the command. `--json` for machine output, default for a table.
- `disable | enable <id>` — toggle activation state (equivalent to `gsd capability set <id> --off` / `--on`).
**Trust boundary:** install never executes capability code (copy-only staging); executable surfaces require explicit consent; sources are gated by the **project-scoped** `capabilities.strict_known_registries` policy (fail-closed on a malformed/unparseable value); every shared-config write/delete is realpath-confined to the scope root, and a name collision with a user's `mcpServers` entry is never clobbered.

View File

@@ -40,8 +40,6 @@ gsd capability install https://example.com/releases/gsd-cap-example-1.0.0.tgz
gsd capability install ./path/to/capability
```
You can also use the slash command form inside a supported runtime (surfaced as `gsd:capability install <spec>` — without the leading `/` in the command palette).
---
## Read the pre-install summary

View File

@@ -1,13 +1,12 @@
# `gsd capability` Command Reference
> **Slash form:** `gsd:capability` (surfaced as a slash command on slash-command runtimes)
> **CLI form:** `gsd capability`
> **Canonical ADR:** [ADR-1244](../adr/1244-capability-ecosystem.md)
> **See also:** [Capability Manifest Reference](capability-manifest.md) · [How to develop a capability](../how-to/develop-a-capability.md) · [The capability trust model](../explanation/capability-trust-model.md)
The `capability` family manages the installation, upgrade, removal, and inspection of GSD capabilities — both first-party (shipped) and third-party overlays. A row for this command also appears in [docs/COMMANDS.md](../COMMANDS.md) (that file is not edited here).
**Implemented in 1.6.0:** `install`, `update`, `remove`, `list`, `trust`, `disable`, `enable` (plus the pre-existing `state` and `set` introspection/activation subcommands). **Planned (not yet implemented):** `outdated` — see [Planned subcommands](#planned-subcommands).
**Implemented:** `install`, `update`, `remove`, `list`, `outdated`, `trust`, `disable`, `enable` (plus the pre-existing `state` and `set` introspection/activation subcommands).
---
@@ -196,6 +195,72 @@ The `reason` field is `null` for active/incompatible rows and carries a short ex
---
### `outdated`
**Synopsis**
```
gsd capability outdated [--json] [--scope global|project]
```
**Flags**
| Flag | Description |
|---|---|
| `--json` | Emit the records array as JSON (machine output). When omitted, a human-readable table is printed instead (columns: `ID`, `Source`, `Current`, `Latest`, `Status`). |
| `--scope` | Read only the given scope's ledger (`global` or `project`). When omitted, both scopes are swept (mirroring `list`). |
**Behaviour**
For every installed overlay capability in the chosen scope(s), `outdated` performs a **light remote peek** of the capability's **recorded source** (the `source` stored in its ledger entry at install time) to learn the latest version that re-resolving that source would install, then compares it (numeric `major.minor.patch`) with the installed version. It is a metadata-only read — it never re-clones, re-packs, or re-extracts a bundle. A failing, timed-out, or unsupported peek **degrades** that row to `status: unknown`; it never crashes the command, and a single bad entry never suppresses the others.
A capability is reported `outdated` **only if** re-resolving its recorded source (exactly what `update` does) would fetch a **newer** version than the one installed. A source pinned to an **immutable** ref — a git `#sha:<commit>` or `#tag:<tag>` fragment, or an **exact** npm version (`npm:@org/pkg@1.2.3`) — is never `outdated`: `update` re-resolves to the same commit/tag/version, so the row is reported `status: pinned` instead.
A **bare** git ref fragment (`…repo.git#<ref>`) is **ambiguous** — it may name an immutable tag or a **mutable branch**. `outdated` resolves it at the remote with a bounded `git ls-remote <url> <ref>` (the same safe argv-only seam, no shell): a ref that resolves under `refs/tags/` is an immutable tag → `status: pinned`; a ref that resolves under `refs/heads/` is a **mutable branch** (`update` re-clones and checks out the branch HEAD, which can move) and is therefore **never** reported `pinned`. Because the ledger does not record the commit a git source was installed at, a moved branch HEAD cannot be compared against the installed commit, so a branch-tracked source degrades to `status: unknown`. An unresolvable / ambiguous / errored / timed-out classification also degrades to `unknown`.
The per-source "update available?" matrix (ADR-1244 D6):
| Source kind | Latest-version peek | Bound |
|---|---|---|
| git, **unpinned** (`https://…/repo.git`, tracks default branch) | `git ls-remote --tags` → highest **stable** semver tag (`v`-prefix and `^{}` peeled entries handled; prerelease/junk tags ignored) | ≤ 30s |
| git, **pinned** (`…repo.git#sha:…` / `#tag:…`) | immutable ref → `status: pinned` (no peek; `update` will not move it) | — |
| git, **bare ref** (`…repo.git#<ref>`) | `git ls-remote <url> <ref>` classifies the ref: `refs/tags/…` → `status: pinned` (immutable tag); `refs/heads/…` → **mutable branch**, never `pinned` (no installed commit recorded to compare against → `status: unknown`); unresolvable/ambiguous → `status: unknown` | ≤ 30s |
| npm **range** (`npm:@org/pkg@^1`) | `npm view <pkg>@<range> version` → **highest version matching the recorded range** (npm prints one line per match; the numeric max satisfying the range is what `update` installs) | ≤ 60s |
| npm **latest** (`npm:@org/pkg`, no version) | `npm view <pkg> version` → the single `latest` dist-tag version | ≤ 60s |
| npm **exact** (`npm:@org/pkg@1.2.3`) | pinned exact version → `status: pinned` (no peek; `update` re-installs the same version) | — |
| local (`./path` or absolute) | re-read of `capability.json` at the recorded path | — |
| tarball (`https://…/cap-x.y.z.tgz`) | **not auto-detectable** — one immutable URL → `status: manual` | — |
| registry (`<name>@<registry>`) | registry adapter not yet implemented → `status: unknown` | — |
**Output shape** (`--json`)
```json
[
{
"id": "string",
"sourceKind": "git | npm | local | tarball | registry | unknown",
"current": "semver | null",
"latest": "semver | null",
"status": "outdated | current | pinned | manual | unknown",
"scope": "global | project"
}
]
```
`status` values:
| Value | Meaning |
|---|---|
| `outdated` | Re-resolving the recorded source would fetch a newer version than the installed one (for an npm range, the latest version **matching the recorded range**). Run `gsd capability update <id>` to upgrade. |
| `current` | The installed version is the latest the recorded source would resolve to (or newer). |
| `pinned` | The recorded source is pinned to an **immutable** ref (git `#sha:`/`#tag:`, or a bare git `#<ref>` that resolves to a tag) or an exact npm version; `update` re-resolves to the same commit/tag/version, so it can never be `outdated`. A bare git ref that resolves to a **mutable branch** is never `pinned`. |
| `manual` | The source (a bare tarball URL) cannot be auto-checked; re-install from a new URL to upgrade. |
| `unknown` | The peek failed (network error / timeout / non-zero exit / unparseable output), the source kind is not auto-checkable (registry), or the source tracks a mutable git branch whose installed commit was not recorded (so a moved branch HEAD cannot be compared). |
An empty (or missing) ledger reports nothing: `--json` emits `[]`; the table notes that there are no installed overlay capabilities.
---
### `trust`
Manage the **user-owned consent store** (#1459) that gates project-scope third-party capability activation. The store lives at `${GSD_HOME||homedir()}/.gsd/consent.json` — **outside any repository** — and records, per `(realpath(projectRoot), capability id)`, the bundle integrity and disclosure signature you consented to **on this machine**. A project-scope overlay is inactive until such a record exists (so a forged or cloned in-repo project ledger activates nothing on its own); installing a project-scope capability through the lifecycle writes the record, and removing it revokes the record.
@@ -227,16 +292,6 @@ gsd capability trust revoke <id> [--project <path>]
---
## Planned subcommands
These appear in ADR-1244's command surface but are **not implemented in 1.6.0**. They are documented here so the surface is explicit; invoking them returns the unknown-subcommand error listing the available set.
| Subcommand | Intended behaviour |
|---|---|
| `outdated` | Query each installed overlay's source and report those with a newer version available (`--json` for machine output). Until it ships, `update --all` re-resolves every recorded source and reports what changed. |
---
## Source specifications
The `install` subcommand accepts the following source specification forms.

View File

@@ -1968,6 +1968,40 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
{ enabled: capSubcommand === 'enable', runtime: capFlagValue('--runtime'), scope: capFlagValue('--scope') },
raw,
);
} else if (capSubcommand === 'outdated') {
// capability outdated [--json] [--scope global|project] — ADR-1244 D6 "Update available?".
// For each installed overlay in the chosen scope(s), LIGHT-PEEK its recorded source for the
// latest available version and report whether a newer one exists. This never re-clones/re-packs;
// a failing/unsupported peek DEGRADES that row to status 'unknown' (the verb never crashes).
const lifecycle = require('./lib/capability-lifecycle.cjs');
const outdatedScopeArg = capFlagValue('--scope');
if (outdatedScopeArg && outdatedScopeArg !== 'global' && outdatedScopeArg !== 'project') {
error(`Invalid --scope "${outdatedScopeArg}": must be "global" or "project"`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
// Honor --scope (read only that scope's ledger); default sweeps both, mirroring `list`.
const outdatedScopes = outdatedScopeArg ? [outdatedScopeArg] : ['global', 'project'];
const records = [];
for (const sc of outdatedScopes) {
const { runtimeDir } = capResolveScope(sc);
// outdatedCapabilities is read-only + non-throwing (returns [] on a missing/corrupt ledger).
const scRecords = lifecycle.outdatedCapabilities({ runtimeDir });
for (const r of scRecords) records.push({ ...r, scope: sc });
}
const asJson = raw || capHasFlag('--json');
if (asJson) {
output(records, false); // machine output: the records array (JSON).
} else {
// Human-readable table: ID | Source | Current | Latest | Status.
const headers = ['ID', 'Source', 'Current', 'Latest', 'Status'];
const cell = (v) => (v === null || v === undefined ? '-' : String(v));
const tableRows = records.map((r) => [cell(r.id), cell(r.sourceKind), cell(r.current), cell(r.latest), cell(r.status)]);
const widths = headers.map((h, i) => Math.max(h.length, ...tableRows.map((row) => row[i].length), 0));
const fmt = (row) => row.map((c, i) => c.padEnd(widths[i])).join(' ').replace(/\s+$/, '');
const lines = [fmt(headers), widths.map((w) => '-'.repeat(w)).join(' ').replace(/\s+$/, '')];
for (const row of tableRows) lines.push(fmt(row));
if (tableRows.length === 0) lines.push('(no installed overlay capabilities)');
output(records, true, lines.join('\n') + '\n');
}
} else if (capSubcommand === 'trust') {
// capability trust list [--scope project] [--json]
// capability trust revoke <id> [--project <path>]
@@ -2026,7 +2060,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
}
} else {
error(
`Unknown capability subcommand: ${capSubcommand}. Available: install, update, remove, list, trust, disable, enable, state, set`,
`Unknown capability subcommand: ${capSubcommand}. Available: install, update, remove, list, outdated, trust, disable, enable, state, set`,
ERROR_REASON ? ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
);
}

View File

@@ -30,6 +30,12 @@ const sourceMod = require('./capability-source.cjs') as {
opts?: Record<string, unknown>,
) => Promise<{ id: string; version: string; stagedDir: string; integrity: string | null; source: string }>;
parseSpec: (spec: string) => { kind: string; raw: string; target: string; ref?: string };
// #1463 D6 "Update available?" per-source latest-version peek. NEVER throws — returns a status the
// `outdated` aggregation maps onto a record. The exec seam mirrors the resolver's execOverrides.
peekLatestVersion: (
source: string,
opts?: { execOverrides?: Record<string, unknown> },
) => { status: 'ok' | 'pinned' | 'manual' | 'unsupported' | 'unknown'; version: string | null; reason?: string };
};
const ledgerMod = require('./capability-ledger.cjs') as {
readLedger: (runtimeDir: string) => LedgerFile | null;
@@ -80,6 +86,11 @@ const lockMod = require('./capability-lock.cjs') as {
const { platformWriteSync } = require('./shell-command-projection.cjs') as {
platformWriteSync: (filePath: string, content: string) => void;
};
// #1463: numeric major.minor.patch comparison for the outdated check (the SAME compare the resolver
// and capability list use). -1 (a<b), 0 (equal), 1 (a>b).
const semverMod = require('./semver-compare.cjs') as {
compareSemverCore: (a: unknown, b: unknown) => -1 | 0 | 1;
};
/* eslint-enable @typescript-eslint/no-require-imports */
// ---------------------------------------------------------------------------
@@ -1626,6 +1637,101 @@ function reconcileCapabilities(opts: { runtimeDir: string; scope?: 'global' | 'p
}
}
// ---------------------------------------------------------------------------
// outdatedCapabilities (ADR-1244 D6 "Update available?"; #1463)
// ---------------------------------------------------------------------------
/** One row of the `outdated` report: the installed capability vs. its source's latest version. */
interface OutdatedRecord {
id: string;
/** Source kind discriminant (git | npm | local | tarball | registry | unknown). */
sourceKind: string;
/** Installed version (from the ledger entry). */
current: string | null;
/** Latest available version at the source, or null when not resolvable. */
latest: string | null;
/**
* outdated (latest > current) | current (latest <= current) | pinned (recorded source pinned to an
* immutable/explicit ref or exact version — update will not move it) | manual (tarball) | unknown
* (peek failed/unsupported).
*/
status: 'outdated' | 'current' | 'pinned' | 'manual' | 'unknown';
}
/**
* #1463 (ADR-1244 D6): for every installed overlay in `runtimeDir`'s ledger, peek its recorded source
* for the latest available version and classify it. This is a LIGHT remote read per entry (the source
* module's metadata-only peek); it NEVER throws on a single bad entry — that entry is reported with
* status 'unknown'. Status rules:
* - peek 'ok' → compare latest vs current (compareSemverCore): latest > current ⇒ 'outdated', else 'current'.
* - peek 'pinned' → 'pinned' (#1463: source pinned to an immutable/explicit git ref or exact npm
* version — `update` re-resolves the SAME ref/version, so it is NEVER outdated; the
* peek's optional `version` is informational only).
* - peek 'manual' → 'manual' (tarball: not auto-detectable per D6).
* - peek 'unsupported'/'unknown' → 'unknown' (registry unimplemented, or the peek failed/timed out).
*
* An empty/missing ledger yields an empty array (non-throwing — readLedger returns null on a missing or
* corrupt-present ledger; the `outdated` report is read-only and degrades to "nothing to report").
*
* @param opts.runtimeDir the scope root holding `.gsd-capabilities.json`.
* @param opts.execOverrides threaded to the source peek (test seam — mock git ls-remote / npm view).
*/
function outdatedCapabilities(opts: {
runtimeDir: string;
execOverrides?: Record<string, unknown>;
}): OutdatedRecord[] {
const { runtimeDir, execOverrides } = opts;
const records: OutdatedRecord[] = [];
const ledger = ledgerMod.readLedger(runtimeDir);
if (!ledger || !ledger.entries) return records;
for (const id of Object.keys(ledger.entries)) {
const entry = ledger.entries[id];
// Defensive: a hostile/partial ledger entry must never crash the sweep — report it 'unknown'.
const current = entry && typeof entry.version === 'string' ? entry.version : null;
const source = entry && typeof entry.source === 'string' ? entry.source : '';
let sourceKind = 'unknown';
try {
sourceKind = sourceMod.parseSpec(source).kind;
} catch { /* unparseable source — leave kind 'unknown' */ }
let peek: { status: string; version: string | null };
try {
peek = sourceMod.peekLatestVersion(source, execOverrides ? { execOverrides } : undefined);
} catch (err) {
// peekLatestVersion is contractually non-throwing, but belt-and-suspenders: a single bad entry
// must never abort the whole report.
records.push({ id, sourceKind, current, latest: null, status: 'unknown' });
void err;
continue;
}
let status: OutdatedRecord['status'];
let latest: string | null = peek.version;
if (peek.status === 'pinned') {
// #1463: the recorded source is pinned (immutable/explicit git ref or exact npm version). `update`
// re-resolves the SAME ref/version, so it can never be outdated. `latest` carries the peek's
// informational version when one is known (exact-pinned npm), else null (a pinned git ref is not
// peeked for a tag).
status = 'pinned';
} else if (peek.status === 'manual') {
status = 'manual';
} else if (peek.status === 'ok' && peek.version && current) {
status = semverMod.compareSemverCore(peek.version, current) > 0 ? 'outdated' : 'current';
} else if (peek.status === 'ok' && peek.version && !current) {
// We have a latest but no recorded current — cannot compare; treat as unknown (no false 'outdated').
status = 'unknown';
} else {
// unsupported / unknown / ok-but-empty → unknown.
status = 'unknown';
latest = peek.version ?? null;
}
records.push({ id, sourceKind, current, latest, status });
}
return records;
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
@@ -1635,6 +1741,7 @@ export = {
upgradeCapability,
removeCapability,
reconcileCapabilities,
outdatedCapabilities,
applyCapabilitySharedEdits,
stripCapabilitySharedEdits,
// #1460 CONF-2: exported so the ancestor-symlink confinement is locked in by a regression test.

View File

@@ -42,6 +42,8 @@ const capValidator = require('./capability-validator.cjs') as ValidatorModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const semverMod = require('./semver-compare.cjs') as {
semverSatisfies: (version: unknown, range: unknown) => boolean;
compareSemverCore: (a: unknown, b: unknown) => -1 | 0 | 1;
isStableTripletSemver: (v: unknown) => boolean;
};
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -1089,6 +1091,357 @@ async function resolveCapabilitySource(spec: string, opts: ResolveOptions = {}):
}
}
// ---------------------------------------------------------------------------
// Latest-version peek (ADR-1244 D6 "Update available?" per-source matrix; #1463)
// ---------------------------------------------------------------------------
/**
* #1463: timeouts for the LIGHT remote peek the `outdated` verb performs. These are deliberately the
* SAME bounds the resolve path uses for the analogous heavy operations (CONTEXT.md "every git/npm
* subprocess needs a timeout"): a hung registry/remote must DEGRADE the verb (status 'unknown'), never
* hang it. The peek is a metadata-only read (`git ls-remote --tags`, `npm view … version`), NOT a
* clone / pack / extract.
*/
const PEEK_GIT_TIMEOUT_MS = 30_000;
const PEEK_NPM_TIMEOUT_MS = 60_000;
/**
* Status of a single per-source latest-version peek.
* - ok a latest version was resolved (compare it to installed).
* - pinned the recorded source is pinned to an immutable/explicit ref (git `#sha:`/`#tag:`/`#<ref>`)
* or an EXACT npm version (`npm:<pkg>@1.2.3`). `update` re-resolves to the SAME ref/version,
* so it can never be "outdated" — version (when known) is informational only.
* - manual a bare tarball URL — one immutable artifact, no catalogue to query.
* - unsupported the source kind has no implemented peek (registry).
* - unknown the peek failed / timed out / returned unparseable output (DEGRADE, never thrown).
*/
type PeekStatus = 'ok' | 'pinned' | 'manual' | 'unsupported' | 'unknown';
/** Result of peekLatestVersion: a status discriminant + the resolved version when status==='ok'. */
interface PeekResult {
status: PeekStatus;
/** The latest version string when status==='ok'; null otherwise. */
version: string | null;
/** Optional human-readable reason for a non-ok status (DEGRADE diagnostics, never thrown). */
reason?: string;
}
/**
* #1463: parse the output of `git ls-remote --tags <url>` and return the HIGHEST stable-triplet semver
* tag, or null when no parseable semver tag exists. UNTRUSTED-DATA RULE: the remote's ref names are
* treated purely as data — each line is `<sha>\t<ref>` (e.g. `<sha>\trefs/tags/v1.2.0`); we strip
* `refs/tags/`, ignore the `^{}` peeled-annotation entries (they would otherwise double-count and the
* `^{}` suffix is not a version), strip a leading `v`, and keep only STABLE x.y.z triplets
* (isStableTripletSemver) so a `-rc`/junk tag never wins. The max is selected via compareSemverCore so
* 1.10.0 correctly beats 1.2.0 (numeric, not lexical). Pure + deterministic → property-tested.
*/
function pickHighestSemverTag(lsRemoteOutput: string): string | null {
if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') return null;
let best: string | null = null;
for (const rawLine of lsRemoteOutput.split('\n')) {
const line = rawLine.trim();
if (line === '') continue;
// `<sha>\t<ref>` — take the ref (last whitespace-delimited token); a line without a tab/ref is junk.
const tabIdx = line.search(/\s/);
const ref = tabIdx === -1 ? line : line.slice(tabIdx + 1).trim();
if (!ref.startsWith('refs/tags/')) continue;
let tag = ref.slice('refs/tags/'.length);
// Ignore the peeled-annotation entry `refs/tags/<tag>^{}` — same tag, not a distinct version.
if (tag.endsWith('^{}')) continue;
if (tag.startsWith('v')) tag = tag.slice(1);
// Keep only stable x.y.z triplets — a prerelease/junk tag is not an "available stable version".
if (!semverMod.isStableTripletSemver(tag)) continue;
if (best === null || semverMod.compareSemverCore(tag, best) > 0) best = tag;
}
return best;
}
const NPM_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
/**
* #1463: split an npm package spec (the `parsed.target` for an `npm:` source) into its package NAME and
* its trailing version/range selector. The selector is everything after the `@` that separates name from
* version — for a SCOPED package (`@scope/name@<sel>`) that is the LAST `@`, NOT the leading scope `@`;
* for an unscoped package (`name@<sel>`) it is the single non-leading `@`. A spec with no such `@`
* (`@scope/name`, `name`) has selector `''` (tracks the npm `latest` dist-tag).
*
* Pure string parse on an already-shell-safe spec (assertSafeNpmSpec ran in parseSpec). Used ONLY to
* classify the recorded source (exact-pin vs range vs latest) and to range-filter `npm view` output —
* the subprocess invocation still passes the FULL `parsed.target` unchanged.
*/
function splitNpmSpec(target: string): { name: string; selector: string } {
// Find the `@` that introduces the version selector: search from the END, but stop at index 0 (the
// leading `@` of a scope is never a version separator).
const at = target.lastIndexOf('@');
if (at <= 0) return { name: target, selector: '' };
return { name: target.slice(0, at), selector: target.slice(at + 1) };
}
/**
* #1463: pull the ONE canonical version token out of a single `npm view <spec> version` output line, or
* null when the line carries no version in a canonical position. UNTRUSTED-DATA RULE: the line is data.
*
* #1463 Fix 1 (R Medium): the version MUST come from its CANONICAL position, NOT from "any x.y.z token on
* the line" — a package NAME can itself contain a version-like substring (`@scope/cap-1.2.3@1.0.0`) and
* the old any-token scan returned the NAME's `1.2.3` instead of the resolved `1.0.0`. npm prints lines
* shaped `<name>@<version> '<version>'` (range/multi-match) or a single bare `<version>` token (latest
* dist-tag). Canonical extraction:
* 1. Prefer the QUOTED token (`'x.y.z'`) when present — that is npm's explicit version annotation.
* 2. Else take the token after the LAST `@` of the leading `name@version` segment (the first
* whitespace-delimited field), mirroring splitNpmSpec's scoped last-`@` rule so a scope `@` is not
* mistaken for the version separator.
* 3. Else (a single bare line with no `@` and no quotes) treat the whole first field as the version.
* The candidate is validated against NPM_VERSION_RE; a version-like substring embedded in the NAME is
* never consulted. Returns the canonical version (unvalidated against any range) or null. Pure.
*/
function extractNpmLineVersion(line: string): string | null {
// 1. Quoted annotation `'x.y.z'` — npm's explicit version field.
const quoted = line.match(/'([^']+)'/);
if (quoted && NPM_VERSION_RE.test(quoted[1])) return quoted[1];
// 2/3. The leading `name@version` (or bare `version`) field is the first whitespace-delimited token.
const head = line.split(/\s+/, 1)[0];
if (head === undefined || head === '') return null;
// Last `@` that is not a leading scope `@` (index 0) separates name from version; no such `@` ⇒ the
// whole head is the candidate (a bare `version` line). Never read a substring inside the NAME portion.
const at = head.lastIndexOf('@');
const candidate = at > 0 ? head.slice(at + 1) : head;
return NPM_VERSION_RE.test(candidate) ? candidate : null;
}
/**
* #1463: return the HIGHEST version across `npm view <spec> version` stdout that satisfies `range`
* (compareSemverCore for max; semverSatisfies for the range bound), or null when none parse / match.
* UNTRUSTED-DATA RULE: the output is treated purely as data. npm prints ONE annotated line per matching
* version for a multi-version range, e.g.:
* @org/pkg@1.0.0 '1.0.0'
* @org/pkg@1.10.0 '1.10.0'
* and a single bare line for a single match. Each line yields at most ONE canonical version (via
* extractNpmLineVersion — Fix 1: the version's canonical position, NOT any token, so a version-like
* substring in the package NAME never poisons the result). We keep only versions satisfying the recorded
* range and pick the numeric max so 1.10.0 beats 1.2.0. An empty selector means "no range constraint"
* (track latest) → every parseable version qualifies. Pure + deterministic.
*/
function pickHighestNpmVersion(viewOutput: string, range: string): string | null {
if (typeof viewOutput !== 'string') return null;
let best: string | null = null;
for (const rawLine of viewOutput.split('\n')) {
const line = rawLine.trim();
if (line === '') continue;
const tok = extractNpmLineVersion(line);
if (tok === null) continue;
// Empty range = no constraint (track latest); otherwise the version must satisfy the recorded range.
if (range !== '' && !semverMod.semverSatisfies(tok, range)) continue;
if (best === null || semverMod.compareSemverCore(tok, best) > 0) best = tok;
}
return best;
}
/**
* #1463 Fix 2 (R Medium): classify the git ref FRAGMENT (`parsed.ref`, the raw text after `#`) by KIND.
* parseSpec captures the WHOLE `#…` fragment as a raw string and does NOT split the kind, so we parse the
* `sha:` / `tag:` prefix here. The kind decides mutability:
* - 'sha' (`#sha:<commit>`) → IMMUTABLE pin (a commit never moves).
* - 'tag' (`#tag:<name>`) → IMMUTABLE pin (a tag is opted-into; `update` re-checks-out the SAME tag).
* - 'bare' (`#<ref>`) → AMBIGUOUS: it is either a tag (immutable) or a branch (MUTABLE). The
* caller must resolve it remotely (git ls-remote <url> <ref>) before
* deciding pinned-vs-not — a bare branch ref is NEVER pinned.
* - 'none' → no `#<ref>`: tracks the default branch (peek highest tag).
* Pure string parse on an already-shell-safe ref (parseSpec asserted it). `sha:`/`tag:` are matched
* case-insensitively with optional surrounding whitespace; the prefix's value is returned for diagnostics.
*/
function classifyGitRef(parsed: ParsedSpec): { kind: 'none' | 'sha' | 'tag' | 'bare'; value: string } {
const raw = typeof parsed.ref === 'string' ? parsed.ref.trim() : '';
if (raw === '') return { kind: 'none', value: '' };
const shaMatch = /^sha:(.+)$/i.exec(raw);
if (shaMatch) return { kind: 'sha', value: shaMatch[1].trim() };
const tagMatch = /^tag:(.+)$/i.exec(raw);
if (tagMatch) return { kind: 'tag', value: tagMatch[1].trim() };
return { kind: 'bare', value: raw };
}
/**
* #1463 Fix 2 (R Medium): resolve a bare ambiguous git ref to its KIND at the remote with a bounded
* `git ls-remote <url> <ref>` (the SAME safe seam as the tag peek: argv + `--`, never a shell string).
* ls-remote prints `<sha>\t<full-ref>` lines for every matching ref. A ref that matches under
* `refs/tags/` is an immutable TAG; one under `refs/heads/` is a MUTABLE branch. UNTRUSTED-DATA RULE:
* the remote's ref strings are data — we only test the canonical `refs/tags/` vs `refs/heads/` prefix on
* the ref column (last whitespace-delimited token of each line). Returns:
* 'tag' — at least one matching ref under refs/tags/ (and none ambiguous-conflicting branch).
* 'branch' — at least one matching ref under refs/heads/.
* 'unknown' — ls-remote error / timeout / non-zero / empty / unresolvable / conflicting output.
* NEVER throws (DEGRADE). Bounded by PEEK_GIT_TIMEOUT_MS (≤30s).
*/
function classifyBareGitRefRemote(
url: string,
ref: string,
execGit: (args: string[], o?: { timeout?: number }) => SpawnResult,
): 'tag' | 'branch' | 'unknown' {
let r: SpawnResult;
try {
// Metadata-only ref lookup; `--` terminates options so a hostile URL/ref can't be read as a flag
// (both are already transport-/shell-safe via parseSpec). The ref filters ls-remote server-side.
r = execGit(['ls-remote', '--', url, ref], { timeout: PEEK_GIT_TIMEOUT_MS });
} catch {
return 'unknown';
}
if (!r || r.exitCode !== 0 || r.signal) return 'unknown';
let sawTag = false;
let sawBranch = false;
for (const rawLine of (r.stdout || '').split('\n')) {
const line = rawLine.trim();
if (line === '') continue;
const tabIdx = line.indexOf('\t');
const refName = tabIdx === -1 ? line : line.slice(tabIdx + 1).trim();
if (refName.startsWith('refs/tags/')) sawTag = true;
else if (refName.startsWith('refs/heads/')) sawBranch = true;
}
// A clean single-kind resolution wins; anything ambiguous (both, or neither) degrades to unknown so a
// mutable branch is never silently treated as an immutable tag (and vice-versa).
if (sawTag && !sawBranch) return 'tag';
if (sawBranch && !sawTag) return 'branch';
return 'unknown';
}
/**
* #1463: resolve the LATEST available version for a recorded capability source string, per ADR-1244 D6
* ("Update available?" is a per-source matrix). This is a LIGHT remote PEEK — metadata only — never a
* re-clone / re-pack / re-extract. It NEVER throws: every error / timeout / unsupported source DEGRADES
* to a status the `outdated` verb can render. Per-kind behaviour:
*
* - git `git ls-remote --tags <url>` → highest stable semver tag (pickHighestSemverTag). status 'ok'.
* - npm `npm view <pkg> version` (latest dist-tag) → the reported version. status 'ok'.
* - local re-read capability.json at the path (bounded reader) → its `version`. status 'ok'.
* - tarball one immutable URL, not auto-detectable per D6 → status 'manual' (no version).
* - registry resolveCapabilitySource throws (unimplemented) → status 'unsupported'.
*
* BOUNDED SUBPROCESSES (CONTEXT.md): git ls-remote ≤30s, npm view ≤60s; on timeout / non-zero / error /
* empty-or-unparseable output → status 'unknown' (DEGRADE, never crash the verb).
*
* The exec seam mirrors the resolver: opts.execOverrides.{git,npm} (or the default shell seam) so a test
* can mock the remote PEEK with no network I/O.
*/
function peekLatestVersion(
source: string,
opts: {
execOverrides?: { git?: (args: string[], o?: { timeout?: number }) => SpawnResult; npm?: (args: string[], o?: { timeout?: number }) => SpawnResult };
} = {},
): PeekResult {
let parsed: ParsedSpec;
try {
parsed = parseSpec(source);
} catch (err) {
// An unparseable recorded source cannot be peeked — DEGRADE (do not throw).
return { status: 'unknown', version: null, reason: `unparseable source: ${(err as Error).message}` };
}
switch (parsed.kind) {
case 'git': {
const execGit = opts.execOverrides?.git ?? shellSeam.execGit;
// #1463 Fix 2 (R Medium): classify the recorded ref by KIND before deciding pinned-vs-not. An
// IMMUTABLE pin (`#sha:`/`#tag:`) is NEVER outdated — `update` re-resolves the SAME commit/tag, so
// a newer remote tag is irrelevant; report 'pinned' WITHOUT any peek. A BARE `#<ref>` is ambiguous
// (tag OR branch): we MUST classify it remotely so a MUTABLE branch is never falsely 'pinned'.
const refKind = classifyGitRef(parsed);
if (refKind.kind === 'sha' || refKind.kind === 'tag') {
return { status: 'pinned', version: null, reason: `git source pinned to ${refKind.kind} "${refKind.value}"; update will not move it` };
}
if (refKind.kind === 'bare') {
// Resolve the ambiguous ref at the remote (same safe execGit seam: argv + `--`).
const resolved = classifyBareGitRefRemote(parsed.target, refKind.value, execGit);
if (resolved === 'tag') {
// An immutable tag → pinned (the ref the user recorded is a tag, not a moving branch).
return { status: 'pinned', version: null, reason: `git source ref "${refKind.value}" resolves to an immutable tag; update will not move it` };
}
// A branch (MUTABLE) or an unresolvable/ambiguous result. The ledger records NO installed commit
// sha for git sources (integrity is null), so a moved branch HEAD cannot be compared against the
// installed commit → DEGRADE to 'unknown'. The HARD INVARIANT holds: a branch is NEVER 'pinned'.
const reason = resolved === 'branch'
? `git source tracks mutable branch "${refKind.value}"; no installed commit recorded to compare against`
: `git source ref "${refKind.value}" could not be classified (tag vs branch) at the remote`;
return { status: 'unknown', version: null, reason };
}
// refKind.kind === 'none' — no `#<ref>`, tracks the default branch: peek the highest remote tag.
let r: SpawnResult;
try {
// Metadata-only: ls-remote lists refs without cloning. `--` terminates options so a hostile
// URL cannot be read as a flag (the URL is already transport-allowlisted by parseSpec).
r = execGit(['ls-remote', '--tags', '--', parsed.target], { timeout: PEEK_GIT_TIMEOUT_MS });
} catch (err) {
return { status: 'unknown', version: null, reason: `git ls-remote error: ${(err as Error).message}` };
}
if (!r || r.exitCode !== 0 || r.signal) {
const reason = r && r.signal ? `git ls-remote timed out (signal ${r.signal})`
: `git ls-remote exit ${r ? r.exitCode : 'n/a'}`;
return { status: 'unknown', version: null, reason };
}
const latest = pickHighestSemverTag(r.stdout || '');
if (latest === null) return { status: 'unknown', version: null, reason: 'no semver tags at remote' };
return { status: 'ok', version: latest };
}
case 'npm': {
// #1463: classify the recorded npm spec — what would `update` resolve it to?
// exact version (`@1.2.3`) → PINNED: update re-installs the SAME version, never outdated.
// range (`@^1`, `@~1.2`, …) → peek and pick the HIGHEST version satisfying the range (multi-line).
// no version (bare name) → peek the single `latest` dist-tag version.
const { selector } = splitNpmSpec(parsed.target);
// An EXACT version selector is an immutable pin (a single x.y.z[-pre], no range operator/wildcard).
if (selector !== '' && NPM_VERSION_RE.test(selector)) {
return { status: 'pinned', version: selector, reason: `npm source pinned to exact version "${selector}"; update will not move it` };
}
const execNpm = opts.execOverrides?.npm ?? shellSeam.execNpm;
let r: SpawnResult;
try {
// Mirrors scripts/check-latest-version.cjs (checkLatestVersion): `npm view <spec> version` reports
// the matching version(s). parsed.target is the npm package spec (parseSpec asserted it is free of
// shell metacharacters) and is passed UNCHANGED — for a range npm prints every matching version,
// for a bare name the single latest. `--` terminates options.
r = execNpm(['view', '--', parsed.target, 'version'], { timeout: PEEK_NPM_TIMEOUT_MS });
} catch (err) {
return { status: 'unknown', version: null, reason: `npm view error: ${(err as Error).message}` };
}
if (!r || r.exitCode !== 0 || r.signal) {
const reason = r && r.signal ? `npm view timed out (signal ${r.signal})`
: `npm view exit ${r ? r.exitCode : 'n/a'}`;
return { status: 'unknown', version: null, reason };
}
// Treat the OUTPUT as untrusted: extract every version token (npm prints one annotated line per
// matching version for a range, a bare token for a single match) and pick the HIGHEST that
// satisfies the recorded range (empty selector = no constraint → latest). Garbage / no match →
// DEGRADE to 'unknown'.
const version = pickHighestNpmVersion(r.stdout || '', selector);
if (version === null) {
return { status: 'unknown', version: null, reason: `npm view returned no matching semver version: ${(r.stdout || '').trim() || '(empty)'}` };
}
return { status: 'ok', version };
}
case 'local': {
// Re-read the recorded local capability.json (bounded reader) for its current declared version.
let cap: Record<string, unknown>;
try {
const manifestPath = path.join(path.resolve(parsed.target), 'capability.json');
cap = readManifestBounded(manifestPath, `local capability.json not readable: ${parsed.target}`);
} catch (err) {
return { status: 'unknown', version: null, reason: `local re-read failed: ${(err as Error).message}` };
}
const version = typeof cap['version'] === 'string' ? cap['version'] : '';
if (!version) return { status: 'unknown', version: null, reason: 'local capability.json missing version' };
return { status: 'ok', version };
}
case 'tarball':
// D6: a bare tarball URL is one immutable artifact — there is no catalogue to query, so update
// availability cannot be auto-detected. Surface 'manual' (the user must point install at a new URL).
return { status: 'manual', version: null, reason: 'tarball sources cannot be auto-checked; re-install from a new URL' };
case 'registry':
// The registry adapter is unimplemented (resolveCapabilitySource throws for it).
return { status: 'unsupported', version: null, reason: 'registry source kind is not yet implemented' };
default: {
const _never: never = parsed.kind;
return { status: 'unknown', version: null, reason: `unknown source kind: ${String(_never)}` };
}
}
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
@@ -1101,8 +1454,16 @@ export = {
// #1461 finding 3 test seam: the exact bounded reader stageValidated uses on the COPIED manifest, so
// a test can exercise the staged re-read directly (not just the local pre-read that shadows it).
_readManifestBounded: readManifestBounded,
// #1463: D6 "Update available?" per-source latest-version peek + the pure parsers it composes (the
// git highest-semver-tag parser, the npm spec splitter, and the npm-view range/version picker).
peekLatestVersion,
pickHighestSemverTag,
splitNpmSpec,
pickHighestNpmVersion,
MAX_RESPONSE_BYTES,
MANIFEST_MAX_BYTES,
MAX_STAGED_BUNDLE_BYTES,
MAX_STAGED_BUNDLE_ENTRIES,
PEEK_GIT_TIMEOUT_MS,
PEEK_NPM_TIMEOUT_MS,
};

View File

@@ -324,10 +324,79 @@ describe('capability disable / enable', () => {
// ─── unknown subcommand ───────────────────────────────────────────────────────
describe('capability (unknown)', () => {
test('an unknown subcommand lists the full available set', () => {
test('an unknown subcommand lists the full available set (incl. outdated)', () => {
const r = runGsdTools(['capability', 'bogus'], makeCwd());
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /install, update, remove, list, trust, disable, enable, state, set/);
assert.match(`${r.error}\n${r.output}`, /install, update, remove, list, outdated, trust, disable, enable, state, set/);
});
});
// ─── outdated (#1463) ───────────────────────────────────────────────────────
describe('capability outdated', () => {
test('empty ledger → --json empty array; default table shows the empty marker', () => {
const home = tmpDir('cap-cli-home-');
const json = runGsdTools(['capability', 'outdated', '--scope', 'global', '--json'], makeCwd(), scopeEnv(home));
assert.equal(json.success, true, `outdated --json failed: ${json.error || json.output}`);
assert.deepEqual(parse(json.output), []);
const table = runGsdTools(['capability', 'outdated', '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(table.success, true, `outdated table failed: ${table.error || table.output}`);
assert.match(table.output, /no installed overlay capabilities/i);
});
test('local source whose path now declares a newer version → status outdated (records shape)', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('outcap', { version: '1.0.0' });
assert.equal(runGsdTools(['capability', 'install', src, '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home)).success, true);
// Bump the recorded LOCAL source to a newer version — the peek re-reads it.
const cap = JSON.parse(fs.readFileSync(path.join(src, 'capability.json'), 'utf8'));
cap.version = '2.0.0';
fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap, null, 2));
const r = runGsdTools(['capability', 'outdated', '--scope', 'global', '--json'], makeCwd(), scopeEnv(home));
assert.equal(r.success, true, `outdated failed: ${r.error || r.output}`);
const rows = parse(r.output);
const row = rows.find((x) => x.id === 'outcap');
assert.ok(row, 'installed capability reported by outdated');
// revert-fails: with the comparison inverted this would be 'current', not 'outdated'.
assert.equal(row.status, 'outdated');
assert.equal(row.current, '1.0.0');
assert.equal(row.latest, '2.0.0');
assert.equal(row.sourceKind, 'local');
assert.equal(row.scope, 'global');
});
test('local source at the same version → status current; default emits a table with the row', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('samecap', { version: '1.0.0' });
assert.equal(runGsdTools(['capability', 'install', src, '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home)).success, true);
const r = runGsdTools(['capability', 'outdated', '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, true, `outdated failed: ${r.error || r.output}`);
// Table form: header columns + the capability row with status current.
assert.match(r.output, /ID\s+Source\s+Current\s+Latest\s+Status/);
assert.match(r.output, /samecap\s+local\s+1\.0\.0\s+1\.0\.0\s+current/);
});
test('tarball source → status manual (not auto-detectable)', () => {
// Plant a project-scope ledger entry with a tarball source directly (install would need network).
const cwd = makeCwd();
const ledgerMod = require('../gsd-core/bin/lib/capability-ledger.cjs');
ledgerMod.recordInstall(cwd, {
id: 'tarcap', version: '1.0.0', source: 'https://host/path/cap-1.0.0.tgz',
integrity: '', files: ['.gsd/capabilities/tarcap'], sharedEdits: [],
});
const r = runGsdTools(['capability', 'outdated', '--scope', 'project', '--json'], cwd, { GSD_WORKSTREAM: '', GSD_PROJECT: '' });
assert.equal(r.success, true, `outdated failed: ${r.error || r.output}`);
const row = parse(r.output).find((x) => x.id === 'tarcap');
assert.ok(row, 'tarball capability reported');
assert.equal(row.status, 'manual');
assert.equal(row.latest, null);
});
test('invalid --scope is rejected', () => {
const r = runGsdTools(['capability', 'outdated', '--scope', 'bogus'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /Invalid --scope/i);
});
});

View File

@@ -3133,3 +3133,179 @@ test('IC-05/WIN-2: a consent-store write failure leaves the install status:insta
assert.match(buf, /could not write the consent record/i, 'the warning explains the write failure');
assert.match(buf, new RegExp(home.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 'the warning names the consent store path');
});
// ---------------------------------------------------------------------------
// #1463: outdatedCapabilities (ADR-1244 D6 "Update available?")
// ---------------------------------------------------------------------------
/** Plant a ledger entry with a given source string and installed version. */
function plantEntry(dir, id, version, source) {
ledgerMod.recordInstall(dir, {
id, version, source, integrity: '',
files: [`.gsd/capabilities/${id}`], sharedEdits: [],
});
}
/** A git ls-remote --tags line for a tag. */
function lsLine(tag) {
return `0000000000000000000000000000000000000000\trefs/tags/${tag}`;
}
test('#1463 outdated: empty ledger → empty records (no throw)', () => {
const dir = runtime();
assert.deepStrictEqual(lifecycle.outdatedCapabilities({ runtimeDir: dir }), []);
});
test('#1463 outdated: git source with a newer tag → status outdated; latest reported', () => {
const dir = runtime();
plantEntry(dir, 'gitcap', '1.0.0', 'https://github.com/org/repo.git');
const fakeGit = () => ({ exitCode: 0, stdout: [lsLine('v1.0.0'), lsLine('v1.2.0')].join('\n'), stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
// revert-fails: an inverted comparison would report 'current' here.
assert.strictEqual(rec.status, 'outdated');
assert.strictEqual(rec.latest, '1.2.0');
assert.strictEqual(rec.current, '1.0.0');
assert.strictEqual(rec.sourceKind, 'git');
});
test('#1463 outdated: git installed == latest → current', () => {
const dir = runtime();
plantEntry(dir, 'gitcap', '1.2.0', 'https://github.com/org/repo.git');
const fakeGit = () => ({ exitCode: 0, stdout: lsLine('v1.2.0'), stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
assert.strictEqual(rec.status, 'current');
});
test('#1463 outdated: git installed > latest → current (not outdated)', () => {
const dir = runtime();
plantEntry(dir, 'gitcap', '2.0.0', 'https://github.com/org/repo.git');
const fakeGit = () => ({ exitCode: 0, stdout: lsLine('v1.5.0'), stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
assert.strictEqual(rec.status, 'current');
});
test('#1463 outdated: npm newer → outdated; npm peek error → unknown, other caps still reported', () => {
const dir = runtime();
plantEntry(dir, 'npmgood', '1.0.0', 'npm:@org/good@^1');
plantEntry(dir, 'npmbad', '1.0.0', 'npm:@org/bad@^1');
// revert-fails: without timeout/error→unknown handling, the failing peek crashes the whole verb and
// npmgood would never be reported.
// #1463: the good peek returns npm's REAL multi-line range output (one line per matching version); the
// highest version satisfying `^1` is 1.9.0 (a 2.x would be OUT of range and must NOT be chosen).
const fakeNpm = (args) => {
const pkg = args[args.indexOf('view') + 2]; // ['view','--',<pkg>,'version']
if (pkg === '@org/good@^1') {
const out = ["@org/good@1.4.0 '1.4.0'", "@org/good@1.9.0 '1.9.0'"].join('\n') + '\n';
return { exitCode: 0, stdout: out, stderr: '', signal: null, error: null };
}
return { exitCode: 1, stdout: '', stderr: 'E404', signal: null, error: null };
};
const recs = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { npm: fakeNpm } });
const byId = Object.fromEntries(recs.map((r) => [r.id, r]));
assert.strictEqual(byId.npmgood.status, 'outdated');
assert.strictEqual(byId.npmgood.latest, '1.9.0', 'highest version satisfying the recorded ^1 range');
assert.strictEqual(byId.npmbad.status, 'unknown', 'a failing peek degrades that row only');
assert.strictEqual(recs.length, 2, 'both capabilities are still reported');
});
test('#1463 outdated: tarball → manual; registry → unknown', () => {
const dir = runtime();
plantEntry(dir, 'tarcap', '1.0.0', 'https://host/path/cap-1.0.0.tgz');
plantEntry(dir, 'regcap', '1.0.0', 'my-cap@gsd-registry');
const recs = lifecycle.outdatedCapabilities({ runtimeDir: dir });
const byId = Object.fromEntries(recs.map((r) => [r.id, r]));
assert.strictEqual(byId.tarcap.status, 'manual');
assert.strictEqual(byId.regcap.status, 'unknown');
});
test('#1463 outdated: local newer/equal → outdated/current (re-read of recorded path)', () => {
const dir = runtime();
const srcNew = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-out-localnew-'));
const srcSame = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-out-localsame-'));
cleanups.push(srcNew, srcSame);
fs.writeFileSync(path.join(srcNew, 'capability.json'), JSON.stringify(declarativeCap('locnew', '2.0.0')));
fs.writeFileSync(path.join(srcSame, 'capability.json'), JSON.stringify(declarativeCap('locsame', '1.0.0')));
plantEntry(dir, 'locnew', '1.0.0', srcNew);
plantEntry(dir, 'locsame', '1.0.0', srcSame);
const recs = lifecycle.outdatedCapabilities({ runtimeDir: dir });
const byId = Object.fromEntries(recs.map((r) => [r.id, r]));
assert.strictEqual(byId.locnew.status, 'outdated');
assert.strictEqual(byId.locnew.latest, '2.0.0');
assert.strictEqual(byId.locsame.status, 'current');
});
test('#1463 outdated: git source pinned to a commit SHA is NEVER outdated even with a newer remote tag → status pinned', () => {
const dir = runtime();
// A `#sha:<commit>`-pinned source: `update` re-resolves to the SAME commit, so a newer tag at the
// remote is irrelevant. revert-fails: without the parsed.ref pinned check, peek returns the highest
// tag 'ok' and outdatedCapabilities compares 9.9.9 > 1.0.0 ⇒ 'outdated', failing this 'pinned' assert.
plantEntry(dir, 'pinnedsha', '1.0.0', 'https://github.com/org/repo.git#sha:abcdef1234567890abcdef1234567890abcdef12');
const fakeGit = () => ({ exitCode: 0, stdout: [lsLine('v1.0.0'), lsLine('v9.9.9')].join('\n'), stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
assert.strictEqual(rec.status, 'pinned');
assert.strictEqual(rec.sourceKind, 'git');
});
test('#1463 outdated: git source pinned to an explicit tag → status pinned (not outdated)', () => {
const dir = runtime();
plantEntry(dir, 'pinnedtag', '1.0.0', 'https://github.com/org/repo.git#tag:v1.0.0');
const fakeGit = () => ({ exitCode: 0, stdout: [lsLine('v1.0.0'), lsLine('v2.0.0')].join('\n'), stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
assert.strictEqual(rec.status, 'pinned');
});
test('#1463 outdated: UNPINNED git source (no #ref) with a newer tag → still outdated', () => {
const dir = runtime();
plantEntry(dir, 'unpinned', '1.0.0', 'https://github.com/org/repo.git');
const fakeGit = () => ({ exitCode: 0, stdout: [lsLine('v1.0.0'), lsLine('v1.5.0')].join('\n'), stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
assert.strictEqual(rec.status, 'outdated');
assert.strictEqual(rec.latest, '1.5.0');
});
test('#1463 outdated: UNPINNED git source at latest → current', () => {
const dir = runtime();
plantEntry(dir, 'unpinnedcur', '1.5.0', 'https://github.com/org/repo.git');
const fakeGit = () => ({ exitCode: 0, stdout: lsLine('v1.5.0'), stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
assert.strictEqual(rec.status, 'current');
});
test('#1463 outdated: npm RANGE source picks highest matching (real multi-line output) → outdated when installed below it', () => {
const dir = runtime();
plantEntry(dir, 'npmrange', '1.0.0', 'npm:@org/cap@^1');
// npm's REAL range output: one annotated line per matching version (not a single bare token).
// revert-fails: the old single-token parse degrades this to 'unknown', so the 'outdated' assert fails.
const multiLine = ["@org/cap@1.2.0 '1.2.0'", "@org/cap@1.10.0 '1.10.0'"].join('\n') + '\n';
const fakeNpm = () => ({ exitCode: 0, stdout: multiLine, stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { npm: fakeNpm } });
assert.strictEqual(rec.status, 'outdated');
assert.strictEqual(rec.latest, '1.10.0', 'highest matching version (numeric, not lexical) is what update installs');
});
test('#1463 outdated: npm RANGE source installed == highest matching → current', () => {
const dir = runtime();
plantEntry(dir, 'npmrangecur', '1.10.0', 'npm:@org/cap@^1');
const multiLine = ["@org/cap@1.2.0 '1.2.0'", "@org/cap@1.10.0 '1.10.0'"].join('\n') + '\n';
const fakeNpm = () => ({ exitCode: 0, stdout: multiLine, stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { npm: fakeNpm } });
assert.strictEqual(rec.status, 'current');
});
test('#1463 outdated: npm NO-version source (tracks latest) → outdated/current via single latest', () => {
const dir = runtime();
plantEntry(dir, 'npmlatest', '1.0.0', 'npm:@org/cap');
const fakeNpm = () => ({ exitCode: 0, stdout: '2.0.0\n', stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { npm: fakeNpm } });
assert.strictEqual(rec.status, 'outdated');
assert.strictEqual(rec.latest, '2.0.0');
});
test('#1463 outdated: npm EXACT-pinned source (@1.2.3) → status pinned (update will not move it)', () => {
const dir = runtime();
plantEntry(dir, 'npmpinned', '1.2.3', 'npm:@org/cap@1.2.3');
// revert-fails: without the exact-pin → 'pinned' branch, peek runs npm view and the row classifies
// by comparison; a registry that advertised 9.9.9 would render it 'outdated', failing this assert.
const fakeNpm = () => ({ exitCode: 0, stdout: '9.9.9\n', stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { npm: fakeNpm } });
assert.strictEqual(rec.status, 'pinned');
});

View File

@@ -30,12 +30,26 @@ const {
parseSpec,
_setCapabilitySourceHttpGet,
_setHttpsGetImpl,
peekLatestVersion,
pickHighestSemverTag,
splitNpmSpec,
pickHighestNpmVersion,
MAX_RESPONSE_BYTES,
MANIFEST_MAX_BYTES,
MAX_STAGED_BUNDLE_BYTES,
MAX_STAGED_BUNDLE_ENTRIES,
} = capSource;
const { EventEmitter } = require('node:events');
const fc = require('fast-check');
/** Build a `git ls-remote --tags` style stdout line for a tag. */
function lsRemoteLine(tag) {
return `0000000000000000000000000000000000000000\trefs/tags/${tag}`;
}
/** A SpawnResult-shaped success. */
function spawnOk(stdout) {
return { exitCode: 0, stdout, stderr: '', signal: null, error: null };
}
// ---------------------------------------------------------------------------
// Helpers
@@ -1410,3 +1424,405 @@ describe('#1461 finding 2 — tar header-size parse removed; NAME/TYPE guards re
assert.ok(fs.existsSync(result.stagedDir), 'staged dir exists after extraction');
});
});
// ---------------------------------------------------------------------------
// #1463: pickHighestSemverTag — pure highest-stable-semver-tag parser
// ---------------------------------------------------------------------------
describe('#1463 pickHighestSemverTag (git ls-remote --tags parser)', () => {
test('BOUNDARY: picks the NUMERIC max across v1.1.0/v1.2.0/v1.10.0 + junk (1.10.0, not 1.2.0)', () => {
// revert-fails: a lexical (string) compare would pick "1.2.0" > "1.10.0"; the numeric compare
// (compareSemverCore) must pick 1.10.0. Junk + a non-semver tag must be ignored.
const out = [
lsRemoteLine('v1.1.0'),
lsRemoteLine('v1.2.0'),
lsRemoteLine('v1.10.0'),
lsRemoteLine('not-a-version'),
lsRemoteLine('release-candidate'),
].join('\n');
assert.strictEqual(pickHighestSemverTag(out), '1.10.0');
});
test('ignores ^{} peeled-annotation entries (same tag, not a distinct version)', () => {
const out = [
lsRemoteLine('v2.0.0'),
lsRemoteLine('v2.0.0^{}'),
lsRemoteLine('v1.5.0'),
].join('\n');
assert.strictEqual(pickHighestSemverTag(out), '2.0.0');
});
test('ignores prerelease/non-triplet tags; bare (no-v) triplets accepted', () => {
const out = [
lsRemoteLine('v1.0.0-rc.1'),
lsRemoteLine('1.4.2'),
lsRemoteLine('v2'),
lsRemoteLine('v1.0'),
].join('\n');
assert.strictEqual(pickHighestSemverTag(out), '1.4.2');
});
test('no parseable semver tags → null', () => {
assert.strictEqual(pickHighestSemverTag('0000\trefs/heads/main\n0000\trefs/tags/latest'), null);
assert.strictEqual(pickHighestSemverTag(''), null);
});
test('PROPERTY (fc): result is the numeric max of the injected stable triplets, ignoring junk', () => {
fc.assert(
fc.property(
fc.array(fc.tuple(fc.nat(50), fc.nat(50), fc.nat(50)), { minLength: 1, maxLength: 12 }),
(triplets) => {
const tags = triplets.map(([a, b, c]) => `v${a}.${b}.${c}`);
// Interleave non-semver junk that must be ignored.
const lines = [];
for (const t of tags) {
lines.push(lsRemoteLine(t));
lines.push(lsRemoteLine('junk-' + t)); // non-triplet → ignored
lines.push(lsRemoteLine(`${t}-rc.1`)); // prerelease → ignored
}
const got = pickHighestSemverTag(lines.join('\n'));
// Expected max computed numerically (not lexically).
const expected = triplets
.slice()
.sort((x, y) => (x[0] - y[0]) || (x[1] - y[1]) || (x[2] - y[2]))
.pop();
return got === `${expected[0]}.${expected[1]}.${expected[2]}`;
},
),
{ numRuns: 200 },
);
});
});
// ---------------------------------------------------------------------------
// #1463: peekLatestVersion — per-source latest-version peek (ADR-1244 D6)
// ---------------------------------------------------------------------------
describe('#1463 peekLatestVersion (D6 per-source matrix)', () => {
test('git: highest remote tag returned as latest (status ok)', () => {
const fakeGit = (args) => {
assert.ok(args.includes('ls-remote'), 'must use ls-remote (metadata only — no clone)');
assert.ok(!args.includes('clone'), 'must NOT clone for a peek');
return spawnOk([lsRemoteLine('v1.0.0'), lsRemoteLine('v1.3.0'), lsRemoteLine('v1.10.0')].join('\n'));
};
const r = peekLatestVersion('https://github.com/org/repo.git', { execOverrides: { git: fakeGit } });
assert.deepStrictEqual(r, { status: 'ok', version: '1.10.0' });
});
test('git: ls-remote non-zero exit → status unknown (DEGRADE, no throw)', () => {
const fakeGit = () => ({ exitCode: 128, stdout: '', stderr: 'fatal', signal: null, error: null });
const r = peekLatestVersion('https://github.com/org/repo.git', { execOverrides: { git: fakeGit } });
assert.strictEqual(r.status, 'unknown');
assert.strictEqual(r.version, null);
});
test('git: ls-remote timeout (signal set) → status unknown', () => {
// revert-fails: without the timeout→unknown branch, a killed peek (signal) would not degrade.
const fakeGit = () => ({ exitCode: null, stdout: '', stderr: '', signal: 'SIGTERM', error: null });
const r = peekLatestVersion('https://github.com/org/repo.git', { execOverrides: { git: fakeGit } });
assert.strictEqual(r.status, 'unknown');
});
test('git: bounded timeout is passed to execGit (≤30s)', () => {
let seenTimeout;
const fakeGit = (_args, o) => { seenTimeout = o && o.timeout; return spawnOk(lsRemoteLine('v1.0.0')); };
peekLatestVersion('https://github.com/org/repo.git', { execOverrides: { git: fakeGit } });
assert.ok(typeof seenTimeout === 'number' && seenTimeout <= 30_000, `git peek must be bounded ≤30s (got ${seenTimeout})`);
});
test('git: source pinned to a commit SHA (#sha:…) → status pinned, NEVER outdated (update stays on the ref)', () => {
// A `#sha:<commit>` pin is immutable: re-resolving the recorded source checks out the SAME commit,
// so a newer remote tag is irrelevant. revert-fails: without the parsed.ref pinned check the peek
// returns the highest tag with status 'ok', which outdatedCapabilities renders 'outdated' — this
// assert (status 'pinned') then fails.
const fakeGit = () => spawnOk([lsRemoteLine('v1.0.0'), lsRemoteLine('v9.9.9')].join('\n'));
const r = peekLatestVersion('https://github.com/org/repo.git#sha:abcdef1234567890abcdef1234567890abcdef12', { execOverrides: { git: fakeGit } });
assert.strictEqual(r.status, 'pinned');
});
test('git: source pinned to an explicit tag (#tag:…) → status pinned', () => {
const fakeGit = () => spawnOk([lsRemoteLine('v1.0.0'), lsRemoteLine('v2.0.0')].join('\n'));
const r = peekLatestVersion('https://github.com/org/repo.git#tag:v1.0.0', { execOverrides: { git: fakeGit } });
assert.strictEqual(r.status, 'pinned');
});
test('git: bare ref (#<ref>) resolving to a TAG (refs/tags/…) → status pinned (immutable tag)', () => {
// #1463 Fix 2 (R Medium): a bare `#<ref>` is ambiguous (tag OR branch). We classify it with a
// bounded `git ls-remote <url> <ref>`. When the remote resolves it under refs/tags/ it is an
// immutable tag → pinned. The ls-remote query is the SAME safe seam (argv + `--`).
const fakeGit = (args) => {
assert.ok(args.includes('ls-remote'), 'classification must use ls-remote (metadata only)');
assert.ok(args.includes('--'), 'argv must terminate options with `--`');
assert.ok(args.includes('release-1'), 'the bare ref is passed to ls-remote for classification');
// ls-remote <url> <ref> prints the matching ref line(s).
return spawnOk('1111111111111111111111111111111111111111\trefs/tags/release-1');
};
const r = peekLatestVersion('https://github.com/org/repo.git#release-1', { execOverrides: { git: fakeGit } });
assert.strictEqual(r.status, 'pinned');
});
test('git: bare ref (#main) resolving to a BRANCH (refs/heads/…) → NEVER pinned (mutable; no installed sha ⇒ unknown)', () => {
// #1463 Fix 2 (R Medium) — THE bug: `repo.git#main` is a MUTABLE branch (`update` re-clones and
// checks out the ref, so it can move). The OLD isGitRefPinned reported ANY non-empty parsed.ref as
// 'pinned', so this asserted 'pinned' and was WRONG. The ledger records NO installed commit sha for
// git sources (integrity is null), so a moved branch HEAD cannot be compared → DEGRADE to 'unknown'.
// revert-fails: with the old blanket isGitRefPinned this returns 'pinned' and the !== 'pinned'
// assert below fails (and the === 'unknown' assert fails too).
const fakeGit = (args) => {
assert.ok(args.includes('ls-remote'), 'classification must use ls-remote');
return spawnOk('2222222222222222222222222222222222222222\trefs/heads/main');
};
const r = peekLatestVersion('https://github.com/org/repo.git#main', { execOverrides: { git: fakeGit } });
assert.notStrictEqual(r.status, 'pinned', 'a mutable branch ref must NEVER be reported pinned');
assert.strictEqual(r.status, 'unknown', 'no installed sha recorded ⇒ cannot compare branch HEAD ⇒ unknown');
});
test('git: bare ref classification — ls-remote error/timeout → status unknown (DEGRADE, never pinned/crash)', () => {
const errGit = () => ({ exitCode: 128, stdout: '', stderr: 'fatal', signal: null, error: null });
const r1 = peekLatestVersion('https://github.com/org/repo.git#main', { execOverrides: { git: errGit } });
assert.notStrictEqual(r1.status, 'pinned');
assert.strictEqual(r1.status, 'unknown');
const killGit = () => ({ exitCode: null, stdout: '', stderr: '', signal: 'SIGTERM', error: null });
const r2 = peekLatestVersion('https://github.com/org/repo.git#main', { execOverrides: { git: killGit } });
assert.strictEqual(r2.status, 'unknown');
});
test('git: bare ref classification — unresolvable/empty ls-remote output → status unknown (never pinned)', () => {
const fakeGit = () => spawnOk('');
const r = peekLatestVersion('https://github.com/org/repo.git#mystery-ref', { execOverrides: { git: fakeGit } });
assert.notStrictEqual(r.status, 'pinned');
assert.strictEqual(r.status, 'unknown');
});
test('git: bare ref classification — ls-remote returns BOTH refs/tags/<r> AND refs/heads/<r> (true ambiguity) → status unknown (NOT pinned)', () => {
// #1463 accuracy fix: when ls-remote resolves a bare ref under BOTH refs/tags/ AND refs/heads/
// the ref is genuinely ambiguous (a tag and a branch share the same name). The classifier must
// NOT prefer the tag and report 'pinned' — the mutable branch reading means the ref could move.
// The safe fallback is 'unknown'.
// revert-fails: a classifier that scans lines and picks the FIRST refs/tags/ hit (or any tag-wins
// strategy) would return 'pinned' here, making the notStrictEqual('pinned') assert below fail.
const ambiguousRef = 'release-1';
const fakeGit = (args) => {
assert.ok(args.includes('ls-remote'), 'must use ls-remote for bare-ref classification');
assert.ok(args.includes(ambiguousRef), 'the bare ref must be passed to ls-remote');
// ls-remote output: same name exists as BOTH a tag and a branch head.
return spawnOk(
`1111111111111111111111111111111111111111\trefs/tags/${ambiguousRef}\n` +
`2222222222222222222222222222222222222222\trefs/heads/${ambiguousRef}\n`,
);
};
const r = peekLatestVersion(`https://github.com/org/repo.git#${ambiguousRef}`, { execOverrides: { git: fakeGit } });
assert.notStrictEqual(r.status, 'pinned', 'ambiguous tag+branch ref must NEVER be reported pinned');
assert.strictEqual(r.status, 'unknown', 'ambiguous ref degrades to unknown (safe fallback)');
});
test('git: bare ref classification — bounded timeout (≤30s) passed to the ls-remote classify call', () => {
let seenTimeout;
const fakeGit = (_args, o) => { seenTimeout = o && o.timeout; return spawnOk('33\trefs/heads/main'); };
peekLatestVersion('https://github.com/org/repo.git#main', { execOverrides: { git: fakeGit } });
assert.ok(typeof seenTimeout === 'number' && seenTimeout <= 30_000, `classify peek must be bounded ≤30s (got ${seenTimeout})`);
});
test('git: UNPINNED source (no #ref, tracks default branch) → highest tag with status ok (NOT pinned)', () => {
// revert-fails-guard for over-pinning: an unpinned source must STILL peek and resolve a version.
const fakeGit = () => spawnOk([lsRemoteLine('v1.0.0'), lsRemoteLine('v1.4.0')].join('\n'));
const r = peekLatestVersion('https://github.com/org/repo.git', { execOverrides: { git: fakeGit } });
assert.deepStrictEqual(r, { status: 'ok', version: '1.4.0' });
});
test('npm: RANGE spec — npm view prints EVERY matching version (real multi-line output) → highest matching is chosen', () => {
// npm's REAL behaviour for `npm view <pkg>@<range> version`: when the range matches multiple
// versions it prints one annotated line PER matching version, e.g.
// @org/gsd-cap-foo@1.0.0 '1.0.0'
// @org/gsd-cap-foo@1.3.0 '1.3.0'
// @org/gsd-cap-foo@1.10.0 '1.10.0'
// (NOT a single bare token). The peek must parse ALL tokens and pick the HIGHEST numerically.
// revert-fails: the old single-token NPM_VERSION_RE.test(stdout.trim()) parse sees multi-line
// output as non-semver and DEGRADES to status 'unknown' — this assert then fails.
const multiLine = [
"@org/gsd-cap-foo@1.0.0 '1.0.0'",
"@org/gsd-cap-foo@1.3.0 '1.3.0'",
"@org/gsd-cap-foo@1.10.0 '1.10.0'",
].join('\n') + '\n';
const fakeNpm = (args, o) => {
assert.ok(args.includes('view'), 'must use npm view');
assert.ok(typeof o.timeout === 'number' && o.timeout <= 60_000, 'npm peek must be bounded ≤60s');
// Invocation shape is unchanged: ['view','--',<target>,'version'] with the range still on target.
assert.deepStrictEqual(args, ['view', '--', '@org/gsd-cap-foo@^1', 'version']);
return spawnOk(multiLine);
};
const r = peekLatestVersion('npm:@org/gsd-cap-foo@^1', { execOverrides: { npm: fakeNpm } });
// 1.10.0 must beat 1.3.0 numerically (not lexically), and it satisfies ^1.
assert.deepStrictEqual(r, { status: 'ok', version: '1.10.0' });
});
test('npm: RANGE spec — highest MATCHING version is bounded by the range (out-of-range versions ignored)', () => {
// ^1 must NOT pick a 2.x even if npm happened to print one; the chosen version must satisfy the range.
const multiLine = [
"@org/cap@1.4.0 '1.4.0'",
"@org/cap@1.9.0 '1.9.0'",
"@org/cap@2.0.0 '2.0.0'",
].join('\n') + '\n';
const r = peekLatestVersion('npm:@org/cap@^1', { execOverrides: { npm: () => spawnOk(multiLine) } });
assert.deepStrictEqual(r, { status: 'ok', version: '1.9.0' });
});
test('npm: RANGE spec — installed < highest matching ⇒ caller sees newer; installed == highest ⇒ same', () => {
// Two ledgers: the peek itself only resolves the highest-matching version; the outdated/current
// decision lives in outdatedCapabilities. Here we lock the peek's resolution (the input to that).
const multiLine = [
"@org/cap@1.2.0 '1.2.0'",
"@org/cap@1.5.0 '1.5.0'",
].join('\n') + '\n';
const r = peekLatestVersion('npm:@org/cap@^1', { execOverrides: { npm: () => spawnOk(multiLine) } });
assert.strictEqual(r.version, '1.5.0', 'highest matching is the version update would install');
});
test('npm: NO-version spec (tracks latest) — single bare latest line → status ok', () => {
const fakeNpm = (args) => {
// No version on the spec ⇒ target is just the bare name; npm view prints a single latest token.
assert.deepStrictEqual(args, ['view', '--', '@org/gsd-cap-foo', 'version']);
return spawnOk('2.4.1\n');
};
const r = peekLatestVersion('npm:@org/gsd-cap-foo', { execOverrides: { npm: fakeNpm } });
assert.deepStrictEqual(r, { status: 'ok', version: '2.4.1' });
});
test('npm: EXACT-pinned spec (@1.2.3) → status pinned (update re-resolves to the SAME version, never outdated)', () => {
// revert-fails: without the exact-pin → 'pinned' branch, the npm peek would run npm view and
// compare, so a pinned source could be reported outdated; this assert requires status 'pinned'.
let called = false;
const fakeNpm = () => { called = true; return spawnOk('9.9.9\n'); };
const r = peekLatestVersion('npm:@org/gsd-cap-foo@1.2.3', { execOverrides: { npm: fakeNpm } });
assert.strictEqual(r.status, 'pinned');
assert.strictEqual(r.version, '1.2.3', 'pinned reports the pinned exact version');
assert.strictEqual(called, false, 'an exact-pinned npm source needs no remote peek (update will not move it)');
});
test('npm: npm view error/timeout → status unknown (no crash)', () => {
const r1 = peekLatestVersion('npm:@org/cap@^1', { execOverrides: { npm: () => ({ exitCode: 1, stdout: '', stderr: 'E404', signal: null, error: null }) } });
assert.strictEqual(r1.status, 'unknown');
const r2 = peekLatestVersion('npm:@org/cap@^1', { execOverrides: { npm: () => ({ exitCode: null, stdout: '', stderr: '', signal: 'SIGTERM', error: null }) } });
assert.strictEqual(r2.status, 'unknown');
});
test('npm: non-semver output → status unknown (untrusted output)', () => {
const r = peekLatestVersion('npm:@org/cap@^1', { execOverrides: { npm: () => spawnOk('not a version\n') } });
assert.strictEqual(r.status, 'unknown');
});
test('local: re-reads capability.json version (status ok)', () => {
const dir = makeLocalCap(featureCap('local-peek', { version: '3.1.0' }));
try {
const r = peekLatestVersion(dir);
assert.deepStrictEqual(r, { status: 'ok', version: '3.1.0' });
} finally {
cleanup(dir);
}
});
test('local: missing path → status unknown (no throw)', () => {
const r = peekLatestVersion('/no/such/path/that/exists');
assert.strictEqual(r.status, 'unknown');
});
test('tarball: not auto-detectable → status manual (no version)', () => {
const r = peekLatestVersion('https://host/path/cap-1.0.0.tgz');
assert.strictEqual(r.status, 'manual');
assert.strictEqual(r.version, null);
});
test('registry: unimplemented → status unsupported', () => {
const r = peekLatestVersion('my-cap@gsd-registry');
assert.strictEqual(r.status, 'unsupported');
assert.strictEqual(r.version, null);
});
});
// ---------------------------------------------------------------------------
// #1463: pure npm-spec / npm-view parsers (splitNpmSpec, pickHighestNpmVersion)
// ---------------------------------------------------------------------------
describe('#1463 splitNpmSpec (name vs version-selector)', () => {
test('scoped package with exact version → split at the LAST @ (not the scope @)', () => {
assert.deepStrictEqual(splitNpmSpec('@org/pkg@1.2.3'), { name: '@org/pkg', selector: '1.2.3' });
});
test('scoped package with range → selector is the range', () => {
assert.deepStrictEqual(splitNpmSpec('@org/pkg@^1'), { name: '@org/pkg', selector: '^1' });
});
test('scoped package, no version → empty selector (tracks latest)', () => {
assert.deepStrictEqual(splitNpmSpec('@org/pkg'), { name: '@org/pkg', selector: '' });
});
test('unscoped package with version → split at the single @', () => {
assert.deepStrictEqual(splitNpmSpec('pkg@2.0.0'), { name: 'pkg', selector: '2.0.0' });
});
test('unscoped package, no version → empty selector', () => {
assert.deepStrictEqual(splitNpmSpec('pkg'), { name: 'pkg', selector: '' });
});
});
describe('#1463 pickHighestNpmVersion (robust multi-line range parse)', () => {
test('multi-line annotated range output → highest matching (numeric, not lexical)', () => {
const out = ["@org/pkg@1.2.0 '1.2.0'", "@org/pkg@1.10.0 '1.10.0'", "@org/pkg@1.3.0 '1.3.0'"].join('\n');
assert.strictEqual(pickHighestNpmVersion(out, '^1'), '1.10.0');
});
test('range bound is honored — out-of-range versions ignored', () => {
const out = ["@org/pkg@1.9.0 '1.9.0'", "@org/pkg@2.0.0 '2.0.0'"].join('\n');
assert.strictEqual(pickHighestNpmVersion(out, '^1'), '1.9.0');
});
test('empty selector = no constraint → overall max', () => {
const out = ["@org/pkg@1.9.0 '1.9.0'", "@org/pkg@2.4.0 '2.4.0'"].join('\n');
assert.strictEqual(pickHighestNpmVersion(out, ''), '2.4.0');
});
test('single bare token (latest dist-tag) parses', () => {
assert.strictEqual(pickHighestNpmVersion('2.4.1\n', ''), '2.4.1');
});
test('garbage / no version tokens → null (DEGRADE)', () => {
assert.strictEqual(pickHighestNpmVersion('not a version\n', ''), null);
assert.strictEqual(pickHighestNpmVersion('', '^1'), null);
});
test('no token satisfies the range → null', () => {
const out = ["@org/pkg@2.0.0 '2.0.0'", "@org/pkg@3.0.0 '3.0.0'"].join('\n');
assert.strictEqual(pickHighestNpmVersion(out, '^1'), null);
});
test('package NAME contains a version-like substring → resolves the RESOLVED version, not the name token', () => {
// #1463 Fix 1 (R Medium): npm view (range) prints `<name>@<version> '<version>'`. When the package
// NAME itself contains an `x.y.z`-shaped substring (`@scope/cap-1.2.3`), the version must come from
// its CANONICAL position (the quoted token / the token after the LAST `@`), NOT any token on the line.
// revert-fails: the old any-token regex matches `1.2.3` from the NAME first and returns it (the
// highest token that satisfies ^1 is `1.5.0`, but `1.2.3` < `1.5.0`, so a name-poisoned parse could
// also wrongly surface `1.2.3` as a candidate). With both lines present the CORRECT answer is 1.5.0.
const out = [
"@scope/cap-1.2.3@1.0.0 '1.0.0'",
"@scope/cap-1.2.3@1.5.0 '1.5.0'",
].join('\n');
assert.strictEqual(pickHighestNpmVersion(out, '^1'), '1.5.0');
});
test('single name-poisoned line → resolves the resolved version (not the name substring)', () => {
// revert-fails: with one line `@scope/cap-1.2.3@1.0.0 '1.0.0'` and range `^1.0.0`, the any-token
// regex picks `1.2.3` (the FIRST/HIGHEST satisfying token, from the NAME); the canonical parse must
// return `1.0.0` (the resolved version). 1.2.3 !== 1.0.0 so the assert flips on revert.
const out = "@scope/cap-1.2.3@1.0.0 '1.0.0'";
assert.strictEqual(pickHighestNpmVersion(out, '^1.0.0'), '1.0.0');
});
test('property: with no range constraint, picks the numeric max of the printed versions', () => {
fc.assert(
fc.property(
fc.array(fc.tuple(fc.nat(40), fc.nat(40), fc.nat(40)), { minLength: 1, maxLength: 12 }),
(triplets) => {
const lines = triplets.map(([a, b, c]) => `@org/pkg@${a}.${b}.${c} '${a}.${b}.${c}'`);
const got = pickHighestNpmVersion(lines.join('\n'), '');
const expected = triplets
.slice()
.sort((x, y) => (x[0] - y[0]) || (x[1] - y[1]) || (x[2] - y[2]))
.pop();
return got === `${expected[0]}.${expected[1]}.${expected[2]}`;
},
),
{ numRuns: 200 },
);
});
});