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:
5
.changeset/feat-1463-capability-outdated.md
Normal file
5
.changeset/feat-1463-capability-outdated.md
Normal 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)
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user