feat(#1463): add capability outdated (per-source update check); drop phantom slash-command docs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-20 09:38:34 -04:00
parent 67a3842776
commit 7c93d9e222
11 changed files with 1244 additions and 20 deletions

View File

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