diff --git a/ts/dist/index.d.mts b/ts/dist/index.d.mts index 7478013..9b750cc 100644 Binary files a/ts/dist/index.d.mts and b/ts/dist/index.d.mts differ diff --git a/ts/dist/index.d.ts b/ts/dist/index.d.ts index 7478013..9b750cc 100644 Binary files a/ts/dist/index.d.ts and b/ts/dist/index.d.ts differ diff --git a/ts/dist/index.js b/ts/dist/index.js index 3783524..bde3e69 100644 Binary files a/ts/dist/index.js and b/ts/dist/index.js differ diff --git a/ts/dist/index.mjs b/ts/dist/index.mjs index 7dc66bf..be5e3a0 100644 Binary files a/ts/dist/index.mjs and b/ts/dist/index.mjs differ diff --git a/ts/src/vendor-graph.test.ts b/ts/src/vendor-graph.test.ts index 4bf0c9e..079168a 100644 --- a/ts/src/vendor-graph.test.ts +++ b/ts/src/vendor-graph.test.ts @@ -848,3 +848,169 @@ test('a source with no repo produces no edge to an empty repository id', () => { assert.equal(store.getNode(emptyRepo), undefined, 'no node was created for the empty repository id') assert.deepEqual(store.in(emptyRepo, VFP_EDGE.producedBy), [], 'and nothing points at it') }) + +// ─── 9 ─ `track-minor` means the MINOR line ──────────────────────────────────── + +/** + * `track-minor` was implemented with `majorOf`: + * + * const sameMajorNewer = intervening.some((a) => majorOf(a.version) === majorOf(pinnedVersion)) + * const majorMoved = releaseDistance > 0 && majorOf(latestVersion) !== majorOf(pinnedVersion) + * freshnessState = sameMajorNewer || majorMoved ? 'stale' : 'current' + * + * A policy whose NAME states one contract and whose CODE honoured another. Worse than + * "track-minor is really track-major": the two arms are exhaustive — if nothing newer shares the + * pinned major then the newest release cannot either, so `majorMoved` fires — which made + * `releaseDistance > 0` always stale. `track-minor` was `track-latest`, wearing a major-shaped + * rationale string. Three declared policies, two distinct behaviours, and the collapsed one is + * the DEFAULT that every pin in the live register declares. + * + * These tests fix the contract the name states: a pin on `x.y.*` follows its own minor line. + */ +const minorLineFixture = (releases: string[], pinned: string, policy = 'track-minor'): HellGraphStore => { + const store = new HellGraphStore(new AtomSpace(`vendor-graph-line-${pinned}-${releases.join('_')}-${policy}`, false)) + ingestVendorFreshness(store, { + manifest_id: 'vendor-freshness-minor-line', + sources: [{ + source_id: 'engine', + repo: 'SocioProphet/hellgraph', + artifact_kind: 'npm-tarball', + version_scheme: 'semver', + releases: releases.map((version) => ({ version })), + }], + artifacts: [{ + artifact_id: 'engine@probe', + source_id: 'engine', + consumer_repo: 'SocioProphet/prophet-platform', + consumer_app: 'apps/probe', + vendored_version: pinned, + freshness_policy: policy, + disposition: 'current', + }], + }) + return store +} +const LINE_PIN = vfpId.pin('engine@probe') + +test('track-minor ACCEPTS a patch bump inside the pinned minor: 0.4.46 → 0.4.47', () => { + const v = stalenessOf(minorLineFixture(['0.4.46', '0.4.47'], '0.4.46'), LINE_PIN) + assert.equal(v.freshnessState, 'stale', '0.4.47 is on the 0.4 line the pin follows — take it') + assert.equal(v.policyTargetVersion, '0.4.47') + assert.match(v.rationale, /on the pinned 0\.4 line, newest 0\.4\.47/) +}) + +test('track-minor REFUSES a minor bump inside the pinned major: 0.4.46 → 0.5.0', () => { + const v = stalenessOf(minorLineFixture(['0.4.46', '0.5.0'], '0.4.46'), LINE_PIN) + assert.equal(v.freshnessState, 'current', '0.5.0 left the 0.4 line — not this pin\'s to take') + assert.equal(v.releaseDistance, 1, 'and the distance is still REPORTED: current ≠ nothing happened') + assert.equal(v.policyTargetVersion, '0.4.46', 'so no cross-minor target is proposed') + assert.match(v.rationale, /0\.5\.0.*not followed under track-minor/) +}) + +test('track-minor is no longer track-latest — the two now disagree', () => { + // The differential the old implementation could not produce: identical graph, both policies. + const cases: [string[], string, string, string][] = [ + [['0.4.46', '0.4.47'], '0.4.46', 'stale', 'stale'], + [['0.4.46', '0.5.0'], '0.4.46', 'current', 'stale'], + [['0.4.46', '1.0.0'], '0.4.46', 'current', 'stale'], + [['0.4.46', '0.4.47', '0.5.0'], '0.4.46', 'stale', 'stale'], + ] + for (const [releases, pinned, wantMinor, wantLatest] of cases) { + assert.equal(stalenessOf(minorLineFixture(releases, pinned), LINE_PIN).freshnessState, wantMinor, + `track-minor over ${releases.join('→')}`) + assert.equal(stalenessOf(minorLineFixture(releases, pinned, 'track-latest'), LINE_PIN).freshnessState, wantLatest, + `track-latest over ${releases.join('→')}`) + } +}) + +test('a stale track-minor pin proposes the newest IN-LANE release, not the newest release', () => { + // 0.4.47 and 0.5.0 both exist. The pin IS stale — 0.4.47 is on its line — and the proposal it + // earns is `→ 0.4.47`. Proposing `→ 0.5.0` would hand a human the cross-minor bump the policy + // exists to withhold, under a rationale that says it was withheld. + const store = minorLineFixture(['0.4.46', '0.4.47', '0.5.0'], '0.4.46') + const v = stalenessOf(store, LINE_PIN) + assert.equal(v.freshnessState, 'stale') + assert.equal(v.latestVersion, '0.5.0', 'the newest release is still reported as such') + assert.equal(v.policyTargetVersion, '0.4.47', 'but the policy will only take the 0.4 line') + + const p = proposeRevendor(store, LINE_PIN, { requestedAt: '2026-07-30T00:00:00Z' }) + assert.equal(p.effectRequest.parameters['toVersion'], '0.4.47') + assert.equal(p.effectRequest.idempotencyKey, 'engine@probe@0.4.46->0.4.47') + assert.deepEqual(p.contractViolations, []) +}) + +test('the in-lane target is the newest by PRECEDENCE, not the last release in the declared chain', () => { + // The filter above ranks with `compareVersions` precisely because "a register may declare its + // releases out of order" — the supersession chain is declared order, not precedence order. The + // proposal target has to honour the same rule. Picking `onLine[last]` (the chain tail) here would + // reintroduce the exact chain-vs-precedence defect this stack removes, one level down: an + // out-of-order 0.4 line `[0.4.46, 0.4.9]` would propose `→ 0.4.9`, seal a re-vendor receipt for + // it, and print a rationale calling 0.4.9 the "newest". 0.4.46 is newer and also on the line. + const store = minorLineFixture(['0.4.5', '0.4.46', '0.4.9'], '0.4.5') + const v = stalenessOf(store, LINE_PIN) + assert.equal(v.freshnessState, 'stale') + assert.equal(v.policyTargetVersion, '0.4.46', + 'newest IN-LANE by version, not 0.4.9 (the last release in the declared chain)') + assert.ok(v.rationale.includes('newest 0.4.46'), `rationale must name the real newest: ${v.rationale}`) + assert.ok(!v.rationale.includes('newest 0.4.9'), `rationale must not call 0.4.9 newest: ${v.rationale}`) + + const p = proposeRevendor(store, LINE_PIN, { requestedAt: '2026-07-30T00:00:00Z' }) + assert.equal(p.effectRequest.parameters['toVersion'], '0.4.46', + 'the sealed proposal targets the real newest in-lane release') +}) + +test('track-latest still proposes the newest release — the split is per-policy, not global', () => { + const store = minorLineFixture(['0.4.46', '0.4.47', '0.5.0'], '0.4.46', 'track-latest') + const v = stalenessOf(store, LINE_PIN) + assert.equal(v.policyTargetVersion, '0.5.0') + assert.equal(proposeRevendor(store, LINE_PIN, { requestedAt: '2026-07-30T00:00:00Z' }) + .effectRequest.parameters['toVersion'], '0.5.0') +}) + +test('the minor line is parsed, not string-split: v-prefix, build metadata, missing patch', () => { + // `split('.')[0]` on `v0.4.46` yields `v0`; on `0.4.46+build.7` the LINE is still 0.4. These all + // name a point on the 0.4 line, and `parseVersion` is what knows that. + for (const [pinned, newer] of [['v0.4.46', '0.4.47'], ['0.4.46+build.7', 'v0.4.47+build.8'], ['0.4', '0.4.1']]) { + const v = stalenessOf(minorLineFixture([pinned!, newer!], pinned!), LINE_PIN) + assert.equal(v.freshnessState, 'stale', `${pinned} → ${newer} is an in-lane bump`) + } + // …and 1 / 1.0 / 1.0.0 are one lane, so 1.1.0 leaves it. + assert.equal(stalenessOf(minorLineFixture(['1', '1.1.0'], '1'), LINE_PIN).freshnessState, 'current') + assert.equal(stalenessOf(minorLineFixture(['1', '1.0.1'], '1'), LINE_PIN).freshnessState, 'stale') +}) + +test('a prerelease does not make a stable track-minor pin stale — but does for a prerelease pin', () => { + assert.equal(stalenessOf(minorLineFixture(['0.4.46', '0.4.47-rc.1'], '0.4.46'), LINE_PIN).freshnessState, + 'current', 'prereleases are opt-in, as in every semver range') + assert.equal(stalenessOf(minorLineFixture(['0.4.47-rc.1', '0.4.47-rc.2'], '0.4.47-rc.1'), LINE_PIN).freshnessState, + 'stale', 'a pin that is itself a prerelease follows them') + assert.equal(stalenessOf(minorLineFixture(['0.4.47-rc.1', '0.4.47'], '0.4.47-rc.1'), LINE_PIN).freshnessState, + 'stale', 'and the release the prerelease led to is in-lane') +}) + +test('a track-minor pin that is not release-shaped is UNKNOWN, not a guess', () => { + // A sha names no x.y line. Deriving `current` would be the same silent guess the unobserved + // branch already refuses to make. + const store = new HellGraphStore(new AtomSpace('vendor-graph-shapeless', false)) + ingestVendorFreshness(store, { + manifest_id: 'vendor-freshness-shapeless', + sources: [{ source_id: 'engine', repo: 'SocioProphet/hellgraph', releases: [{ version: '7d74db8' }, { version: '0.4.47' }] }], + artifacts: [{ + artifact_id: 'engine@probe', source_id: 'engine', + consumer_repo: 'SocioProphet/prophet-platform', consumer_app: 'apps/probe', + vendored_version: '7d74db8', freshness_policy: 'track-minor', disposition: 'current', + }], + }) + const v = stalenessOf(store, LINE_PIN) + assert.equal(v.freshnessState, 'unknown') + assert.equal(v.dispositionAgrees, false, 'declaring `current` over an undecidable state is a violation') + assert.match(v.rationale, /not release-shaped/) +}) + +test('the live register is unaffected: 0.4.40 → 0.4.45 is all one 0.4 line', () => { + // The fix must not quietly un-stale the real finding it was built to keep reporting. + const v = stalenessOf(fixture(), SERVICE_PIN) + assert.equal(v.freshnessState, 'stale') + assert.equal(v.policyTargetVersion, '0.4.45', 'every intervening release is on the 0.4 line') + assert.equal(v.latestVersion, '0.4.45') +}) diff --git a/ts/src/vendor-graph.ts b/ts/src/vendor-graph.ts index b3087d4..08613aa 100644 --- a/ts/src/vendor-graph.ts +++ b/ts/src/vendor-graph.ts @@ -734,6 +734,24 @@ export interface StalenessVerdict { /** The artifacts crossed, in release order. `releaseDistance === intervening.length`. */ intervening: InterveningArtifact[] freshnessPolicy: FreshnessPolicy + /** + * The newest release the POLICY is willing to take — which is not always `latestVersion`. + * + * `track-latest` takes the newest release; `pin-exact` takes nothing, so it is the pinned + * version; `track-minor` takes the newest release **on the pinned `x.y` line**, which is + * `0.4.47` even when `0.5.0` exists. This is the version `proposeRevendor` proposes: without + * it a policy that correctly REFUSES a cross-minor bump would still emit a proposal to make + * it, and the refusal would be a verdict nobody acts on. + * + * Required, not optional. Like every other field on this verdict it is DERIVED and always + * produced — `stalenessOf` seeds it with `pinnedVersion` and only ever narrows it — and + * `StalenessVerdict` is an OUTPUT type: produced here, read by consumers, not constructed by + * them. Adding a field a reader will simply receive is not a break; making it optional would + * instead push an `undefined` that never occurs onto every read site (`proposeRevendor` reads it + * unconditionally). If a downstream ever does construct this verdict, that call site wanting the + * new field set is the correct signal, not a reason to weaken the type. + */ + policyTargetVersion: string /** DERIVED, per the policy. */ freshnessState: FreshnessState /** DECLARED in the register. */ @@ -750,7 +768,23 @@ export interface StalenessVerdict { guardInvokedByCi?: boolean } -const majorOf = (v: string): string => v.split('.')[0] ?? v +/** + * The `x.y` line a version belongs to — the lane a `track-minor` pin follows — or `undefined` + * if the version is not release-shaped and therefore has no lane. + * + * Derived through `parseVersion`, not `split('.')`: `v0.4.46`, `0.4.46+build.7` and `0.4` all + * name a point on the `0.4` line, and a commit sha or the literal `unknown` names no line at + * all. A missing minor is `0` (`1` and `1.0` are the same lane), matching `compareVersions`, + * which already treats a missing component as `0`. + */ +function minorLineOf(v: string): string | undefined { + const p = parseVersion(v) + if (!p) return undefined + return `${p.release[0] ?? '0'}.${p.release[1] ?? '0'}` +} + +/** Is `v` a prerelease (`1.0.0-rc.1`)? Non-release-shaped strings are not. */ +const isPrerelease = (v: string): boolean => parseVersion(v)?.prerelease !== undefined /** * Derive the staleness of one pin from the graph. @@ -758,13 +792,41 @@ const majorOf = (v: string): string => v.split('.')[0] ?? v * PURE — reads only. Returns the release distance and the intervening artifacts, because the * actionable question is never "is it stale" but "how far, across what". * - * Policy semantics are the register's, implemented verbatim: + * Policy semantics are the register's: * • `pin-exact` — never stale, but ONLY if upstream has actually been OBSERVED. A pin to * something nobody has looked at is `unknown`, not `current` — it is an * unknown wearing a pin's clothes. - * • `track-minor` — stale if a newer artifact shares the pinned major, or if the newest - * artifact's major differs at all. + * • `track-minor` — the pin follows ONE MINOR LINE, `x.y.*`. Stale when a newer release lands + * ON that line; NOT stale when the only newer releases have left it. * • `track-latest` — stale if the distance is greater than zero. + * + * ── `track-minor` used to mean `track-latest` ─────────────────────────────────────────── + * The previous implementation was + * + * const sameMajorNewer = intervening.some((a) => majorOf(a.version) === majorOf(pinnedVersion)) + * const majorMoved = releaseDistance > 0 && majorOf(latestVersion) !== majorOf(pinnedVersion) + * freshnessState = sameMajorNewer || majorMoved ? 'stale' : 'current' + * + * — MAJOR, not minor, and the two arms between them are exhaustive: if nothing newer shares the + * pinned major then the newest cannot either, so `majorMoved` fires. `releaseDistance > 0` was + * therefore ALWAYS stale, and `track-minor` was `track-latest` with a major-shaped rationale + * string attached. Three declared policies, two distinct behaviours. A register author writing + * `freshness_policy: track-minor` — the DEFAULT, and what every pin in the live vendor register + * declares — was silently opted in to cross-minor bumps: a pin at `0.4.46` was reported stale + * against `0.5.0` and a re-vendor proposal to `0.5.0` was emitted and sealed for it. + * + * Now: a `track-minor` pin at `0.4.46` is stale against `0.4.47` and CURRENT against `0.5.0`. + * Leaving the line is a deliberate act — an owner editing the pin, or declaring `track-latest` — + * not something the freshness plane proposes on the owner's behalf. The rationale names the + * off-line releases anyway, so `current` here never means "nothing happened upstream". + * + * Two boundary rules, both stated so they are not rediscovered as bugs: + * · A pin whose version is NOT release-shaped (a sha, `unknown`) has no minor line to follow, + * so its state is `unknown`, not a guess in either direction — the same answer this function + * already gives an unobserved pin, for the same reason. + * · A PRERELEASE does not make a stable pin stale (`0.4.47-rc.1` leaves `0.4.46` current), + * matching how semver ranges treat prereleases: opt-in, never inherited. A pin that is + * itself a prerelease does follow them. */ export function stalenessOf(store: HellGraphStore, pinId: string): StalenessVerdict { const pin = store.getNode(pinId) @@ -789,6 +851,8 @@ export function stalenessOf(store: HellGraphStore, pinId: string): StalenessVerd const observed = releaseDistance > 0 || store.in(pinnedArtifact.id, VFP_EDGE.supersededBy).length > 0 let freshnessState: FreshnessState let rationale: string + // Defaults to the pinned version: a policy that takes nothing proposes nothing. + let policyTargetVersion = pinnedVersion if (!observed) { freshnessState = 'unknown' rationale = `no upstream release history for ${pinnedVersion} in the graph — unobserved, not current` @@ -797,18 +861,48 @@ export function stalenessOf(store: HellGraphStore, pinId: string): StalenessVerd rationale = `pin-exact against an observed upstream: ${releaseDistance} newer release(s) exist and are deliberately not taken` } else if (freshnessPolicy === 'track-latest') { freshnessState = releaseDistance > 0 ? 'stale' : 'current' + policyTargetVersion = latestVersion rationale = releaseDistance > 0 ? `track-latest: ${releaseDistance} release(s) behind ${latestVersion}` : `track-latest: pinned at the newest release ${pinnedVersion}` } else { - const sameMajorNewer = intervening.some((a) => majorOf(a.version) === majorOf(pinnedVersion)) - const majorMoved = releaseDistance > 0 && majorOf(latestVersion) !== majorOf(pinnedVersion) - freshnessState = sameMajorNewer || majorMoved ? 'stale' : 'current' - rationale = freshnessState === 'stale' - ? (sameMajorNewer - ? `track-minor: ${releaseDistance} newer release(s) share major ${majorOf(pinnedVersion)}, newest ${latestVersion}` - : `track-minor: newest release ${latestVersion} changes major from ${majorOf(pinnedVersion)}`) - : `track-minor: nothing newer within major ${majorOf(pinnedVersion)}` + const line = minorLineOf(pinnedVersion) + if (line === undefined) { + // No lane can be derived, so neither `stale` nor `current` is a fact. Saying either would + // be inventing one; `unknown` is the state this function already has for exactly that. + freshnessState = 'unknown' + rationale = `track-minor: pinned version '${pinnedVersion}' is not release-shaped, so it names no ` + + `x.y line to follow — undecidable, not current (declare pin-exact or track-latest, or pin a release)` + } else { + // In-lane: same x.y line, strictly newer by PRECEDENCE (not by chain position — a register + // may declare its releases out of order), and not a prerelease unless the pin is one. + const followsPre = isPrerelease(pinnedVersion) + const onLine = intervening.filter((a) => + minorLineOf(a.version) === line && + compareVersions(a.version, pinnedVersion) > 0 && + (followsPre || !isPrerelease(a.version))) + const offLine = intervening.filter((a) => minorLineOf(a.version) !== line) + freshnessState = onLine.length > 0 ? 'stale' : 'current' + // The newest in-lane release by PRECEDENCE, not by chain position. `onLine` preserves the + // supersession-chain order, which is the register's declared order and — as the filter above + // says — may be out of order; `onLine[last]` would therefore reintroduce the exact chain-vs- + // precedence defect this stack removes, one level down (an out-of-order `[0.4.46, 0.4.9]` + // would propose `0.4.9` and seal a receipt whose rationale calls it "newest"). Reduce by + // `compareVersions` — the same ranking `newestReleasedVersion` uses for the same reason. + const newestOnLine = onLine.length > 0 + ? onLine.reduce((m, a) => (compareVersions(a.version, m.version) > 0 ? a : m)).version + : undefined + // The proposal target is the newest IN-LANE release, never `latestVersion`. `0.5.0` being + // newer is not a reason to propose it to a pin that follows `0.4.*`. + if (newestOnLine !== undefined) policyTargetVersion = newestOnLine + const offLineNote = offLine.length > 0 + ? ` (${offLine.length} newer release(s) have left the ${line} line — ${offLine.map((a) => a.version).join(', ')} — ` + + `not followed under track-minor)` + : '' + rationale = freshnessState === 'stale' + ? `track-minor: ${onLine.length} newer release(s) on the pinned ${line} line, newest ${newestOnLine}${offLineNote}` + : `track-minor: nothing newer on the pinned ${line} line${offLineNote || ` (newest overall ${latestVersion})`}` + } } // Agreement, not enforcement of freshness. A `current` declaration over a `stale` derivation is @@ -829,6 +923,7 @@ export function stalenessOf(store: HellGraphStore, pinId: string): StalenessVerd releaseDistance, intervening, freshnessPolicy, + policyTargetVersion, freshnessState, disposition, dispositionAgrees, @@ -1075,7 +1170,11 @@ export function proposeRevendor(store: HellGraphStore, pinId: string, opts: Prop const risk = contractCrossingRiskOf(store, pinId) const pin = store.getNode(pinId)! - const toVersion = str(pin.properties['targetVersion']) ?? verdict.latestVersion + // `policyTargetVersion`, not `latestVersion`. They differ exactly when the policy declines the + // newest release — a `track-minor` pin at 0.4.46 with 0.4.47 and 0.5.0 upstream is stale, and + // the proposal it earns is `→ 0.4.47`. Proposing `→ 0.5.0` there would hand a human the very + // cross-minor bump the policy exists to withhold, over a rationale that says it was withheld. + const toVersion = str(pin.properties['targetVersion']) ?? verdict.policyTargetVersion const location = verdict.artifactPath !== undefined ? `${verdict.consumerRepo}/${verdict.artifactPath}` : undefined