diff --git a/.changeset/feat-1463-capability-outdated.md b/.changeset/feat-1463-capability-outdated.md new file mode 100644 index 000000000..6b4249349 --- /dev/null +++ b/.changeset/feat-1463-capability-outdated.md @@ -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 (`#`) 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) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 25837fe6c..6ede06a21 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -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 ` (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 `. 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 [ \| --all] [--scope …] [--yes]` | Re-resolve a capability's recorded source and upgrade it (atomic stage-then-swap) | | `remove [--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 ` / `enable ` | Toggle a capability's activation state (same as `capability set --off`/`--on`) | | `state` / `set …` | 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 ``` diff --git a/docs/FEATURES.md b/docs/FEATURES.md index d5285d91a..015bec892 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -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 [--integrity sha512-…] [--scope global|project] [--yes] [--shared-file ]…` — 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 [ | --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 [--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 `#` 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 ` — toggle activation state (equivalent to `gsd capability set --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. diff --git a/docs/how-to/import-a-capability-from-a-url.md b/docs/how-to/import-a-capability-from-a-url.md index 60c52dd21..b866c7017 100644 --- a/docs/how-to/import-a-capability-from-a-url.md +++ b/docs/how-to/import-a-capability-from-a-url.md @@ -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 ` — without the leading `/` in the command palette). - --- ## Read the pre-install summary diff --git a/docs/reference/gsd-capability-command.md b/docs/reference/gsd-capability-command.md index 1db9518da..2654a267c 100644 --- a/docs/reference/gsd-capability-command.md +++ b/docs/reference/gsd-capability-command.md @@ -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:` or `#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#`) is **ambiguous** — it may name an immutable tag or a **mutable branch**. `outdated` resolves it at the remote with a bounded `git ls-remote ` (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#`) | `git ls-remote ` 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 @ 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 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 (`@`) | 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 ` 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 `#` 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 [--project ] --- -## 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. diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index b8de91268..fe460ac67 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -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 [--project ] @@ -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, ); } diff --git a/src/capability-lifecycle.cts b/src/capability-lifecycle.cts index ed070d3b3..a590f1f12 100644 --- a/src/capability-lifecycle.cts +++ b/src/capability-lifecycle.cts @@ -30,6 +30,12 @@ const sourceMod = require('./capability-source.cjs') as { opts?: Record, ) => 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 }, + ) => { 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 (ab). +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; +}): 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. diff --git a/src/capability-source.cts b/src/capability-source.cts index e8e17bd0d..32c267585 100644 --- a/src/capability-source.cts +++ b/src/capability-source.cts @@ -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:`/`#`) + * or an EXACT npm version (`npm:@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 ` 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 `\t` (e.g. `\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; + // `\t` — 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/^{}` — 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@`) that is the LAST `@`, NOT the leading scope `@`; + * for an unscoped package (`name@`) 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 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 `@ ''` (range/multi-match) or a single bare `` 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 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:`) → IMMUTABLE pin (a commit never moves). + * - 'tag' (`#tag:`) → IMMUTABLE pin (a tag is opted-into; `update` re-checks-out the SAME tag). + * - 'bare' (`#`) → AMBIGUOUS: it is either a tag (immutable) or a branch (MUTABLE). The + * caller must resolve it remotely (git ls-remote ) before + * deciding pinned-vs-not — a bare branch ref is NEVER pinned. + * - 'none' → no `#`: 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 ` (the SAME safe seam as the tag peek: argv + `--`, never a shell string). + * ls-remote prints `\t` 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 ` → highest stable semver tag (pickHighestSemverTag). status 'ok'. + * - npm `npm view 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 `#` 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 `#`, 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 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; + 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, }; diff --git a/tests/capability-cli.test.cjs b/tests/capability-cli.test.cjs index de93f812c..909ef4f77 100644 --- a/tests/capability-cli.test.cjs +++ b/tests/capability-cli.test.cjs @@ -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); }); }); diff --git a/tests/capability-lifecycle.test.cjs b/tests/capability-lifecycle.test.cjs index b894dcb96..960b64caf 100644 --- a/tests/capability-lifecycle.test.cjs +++ b/tests/capability-lifecycle.test.cjs @@ -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','--',,'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:`-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'); +}); diff --git a/tests/capability-source.test.cjs b/tests/capability-source.test.cjs index 23f120d44..a5080de7d 100644 --- a/tests/capability-source.test.cjs +++ b/tests/capability-source.test.cjs @@ -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:` 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 (#) resolving to a TAG (refs/tags/…) → status pinned (immutable tag)', () => { + // #1463 Fix 2 (R Medium): a bare `#` is ambiguous (tag OR branch). We classify it with a + // bounded `git ls-remote `. 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 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/ AND refs/heads/ (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 @ 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','--',,'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 `@ ''`. 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 }, + ); + }); +});