Skip to content

fix(vendor-graph): track-minor was track-latest — follow the pinned x.y line - #44

Open
mdheller wants to merge 1 commit into
fix/vendor-graph-semver-orderingfrom
fix/vendor-graph-track-minor
Open

fix(vendor-graph): track-minor was track-latest — follow the pinned x.y line#44
mdheller wants to merge 1 commit into
fix/vendor-graph-semver-orderingfrom
fix/vendor-graph-track-minor

Conversation

@mdheller

Copy link
Copy Markdown
Member

Stacked on #42 (fix/vendor-graph-semver-ordering) — merge #42 first. It stacks because this fix needs #42's parseVersion, not because the two touch the same lines.

The defect

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 honours another.

It is 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. releaseDistance > 0 was therefore always stale — which is track-latest. Three declared policies, two distinct behaviours, and the collapsed one is the default (defaultFreshnessPolicy ?? 'track-minor') that every pin in the live vendor register declares.

Measured before the change — identical graph, both policies:

case track-minor track-latest differ?
0.4.46 → 0.4.47 patch, inside minor stale stale no
0.4.46 → 0.5.0 minor, leaves minor stale stale no
0.4.46 → 1.0.0 major, leaves major stale stale no
0.4.46, nothing newer current current no

0 of 5 probe cases distinguished the two policies. majorOf only ever changed the rationale string, never a verdict.

The semantics established

A pin on x.y.* follows its own minor line. It accepts patch bumps within that line; it does not accept bumps that leave it.

derived state proposal target
0.4.46 → 0.4.47 in-lane stale — accepted 0.4.47
0.4.46 → 0.5.0 off-lane current — refused
0.4.46 → 1.0.0 off-lane current — refused

current here never means "nothing happened upstream". releaseDistance and latestVersion still report the real gap, and the rationale names every off-line release:

track-minor: nothing newer on the pinned 0.4 line (1 newer release(s) have left the
0.4 line — 0.5.0 — not followed under track-minor)

Leaving the line stays a deliberate act — an owner editing the pin, or declaring track-latest — not something the freshness plane proposes on the owner's behalf.

The same defect one layer down

proposeRevendor took verdict.latestVersion. So a correctly stale track-minor pin at 0.4.46 with both 0.4.47 and 0.5.0 upstream would still have proposed → 0.5.0: the exact bump the policy refuses, sealed into an EffectRequest, under a rationale saying it was withheld. Fixing stalenessOf alone would have moved the bug rather than closed it.

StalenessVerdict now carries policyTargetVersion — the newest release the policy will take (track-latest ⇒ newest; pin-exact ⇒ the pin; track-minor ⇒ newest in-lane) — and the proposal uses it. track-latest is unchanged and still proposes the newest release; the split is per-policy, not global.

Boundary rules, stated so they are not rediscovered as bugs

  • A pin that is not release-shaped (a commit sha, unknown) names no x.y line, so its state is unknown — the same answer, for the same reason, that an unobserved pin already gets. Deriving current would be exactly the guess the unobserved branch refuses to make. A register declaring current over it becomes a dispositionViolation, which is correct: you cannot follow a minor line from a sha.
  • 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.
  • The line is derived through fix(vendor-graph): rank releases by version, not by ASCII #42's parseVersion, not split('.'), so v0.4.46, 0.4.46+build.7 and 0.4 all resolve to the 0.4 line, and 1 / 1.0 / 1.0.0 are one lane.

Proof

The 9 new tests were run against both implementations:

against the majorOf implementation:  ℹ tests 37  ℹ pass 28  ℹ fail 9
against this one:                    ℹ tests 37  ℹ pass 37  ℹ fail 0

Including 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 the plane was built to report. (It fails pre-fix only on the new policyTargetVersion assertion; the state was and remains stale.)

Full suite 426/426, npm run typecheck clean, ts/dist rebuilt and committed.

Lane note

ts/src/vendor-graph.ts is also touched by #40 and #42. git merge-tree against both:

CONFLICT (content): ts/dist/index.{js,mjs,d.ts,d.mts}   ← binary, all four
Auto-merging ts/src/vendor-graph.ts                     ← clean
Auto-merging ts/src/vendor-graph.test.ts                ← clean

The three PRs touch disjoint regions of the source: #42 the ingest + comparator, #40 analyzeVendorFreshness, this one stalenessOf + proposeRevendor. The only real overlap is the rebuilt ts/dist, which git cannot auto-merge — whoever merges second runs npm run build && git add ts/dist.

….y 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 honours another. And it is
worse than "track-minor is really track-major": the two arms are EXHAUSTIVE — if
nothing newer shares the pinned major, the newest release cannot either, so
`majorMoved` fires. `releaseDistance > 0` was therefore always stale, which is
`track-latest`. Three declared policies, two distinct behaviours, and the collapsed
one is the DEFAULT that every pin in the live vendor register declares.

Consequence, measured before the change: a pin at 0.4.46 was reported stale against
0.5.0, and `proposeRevendor` sealed an EffectRequest to take it. A manifest author
who wrote `track-minor` to stay on a minor line was silently opted in to leaving it.

Established semantics — a pin on `x.y.*` follows its own minor line:

  · 0.4.46 → 0.4.47  in-lane   ⇒ stale, target 0.4.47   (accepted)
  · 0.4.46 → 0.5.0   off-lane  ⇒ current                (refused)
  · 0.4.46 → 1.0.0   off-lane  ⇒ current                (refused)

`current` here never means "nothing happened upstream": the rationale names every
off-line release and `releaseDistance`/`latestVersion` still report the real gap.

Also fixes the same defect one layer down. `proposeRevendor` took
`verdict.latestVersion`, so a correctly-stale track-minor pin at 0.4.46 with 0.4.47
AND 0.5.0 upstream would have proposed → 0.5.0: the exact bump the policy refuses,
under a rationale saying it was withheld. `StalenessVerdict` now carries
`policyTargetVersion` — the newest release the POLICY will take — and the proposal
uses it. track-latest is unchanged and still proposes the newest release.

Two boundary rules, stated so they are not rediscovered as bugs:
  · a pin that is not release-shaped (a sha, `unknown`) names no line, so its state
    is `unknown` — the same answer, for the same reason, that an unobserved pin gets
  · a prerelease does not make a stable pin stale (semver ranges treat prereleases as
    opt-in); a pin that is itself a prerelease does follow them

The line is derived through this branch's `parseVersion`, not `split('.')`, so
`v0.4.46`, `0.4.46+build.7` and `0.4` all resolve to the 0.4 line — which is why this
stacks on #42 rather than duplicating a version parser.

Proof: the 9 new tests fail against the `majorOf` implementation and pass against
this one. 426/426 green, typecheck clean, ts/dist rebuilt.
Copilot AI review requested due to automatic review settings July 30, 2026 05:54

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Fixes track-minor freshness semantics so it follows the pinned x.y.* minor line (not “latest”), and ensures revendor proposals target the version allowed by the policy.

Changes:

  • Add policyTargetVersion to staleness verdicts and use it for proposeRevendor.
  • Rework track-minor logic to compute staleness based on the pinned minor line and prerelease rules.
  • Add targeted tests covering minor-line behavior, prerelease handling, non-release-shaped pins, and proposal targeting.

Reviewed changes

Copilot reviewed 2 out of 6 changed files in this pull request and generated 3 comments.

File Description
ts/src/vendor-graph.ts Implements minor-line tracking, adds policyTargetVersion, and updates proposal targeting logic.
ts/src/vendor-graph.test.ts Adds regression tests validating the corrected track-minor semantics and proposal behavior.
Comments suppressed due to low confidence (1)

ts/src/vendor-graph.ts:1

  • StalenessVerdict is exported, and adding a new required field is a breaking change for downstream TypeScript consumers that implement/construct this interface or rely on structural typing. If this package is intended to publish a patch-level fix, consider making policyTargetVersion optional with a well-defined fallback, or ensure the release/versioning strategy reflects this breaking API change.
/**

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread ts/src/vendor-graph.ts
Comment on lines +878 to +881
const newestOnLine = onLine[onLine.length - 1]?.version
// 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
Comment thread ts/src/vendor-graph.ts
Comment on lines +772 to +776
function minorLineOf(v: string): string | undefined {
const p = parseVersion(v)
if (!p) return undefined
return `${p.release[0] ?? '0'}.${p.release[1] ?? '0'}`
}
Comment thread ts/src/vendor-graph.ts
Comment on lines +871 to +876
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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants