Skip to content

fix(vendor-graph): rank releases by version, not by ASCII - #42

Open
mdheller wants to merge 1 commit into
mainfrom
fix/vendor-graph-semver-ordering
Open

fix(vendor-graph): rank releases by version, not by ASCII#42
mdheller wants to merge 1 commit into
mainfrom
fix/vendor-graph-semver-ordering

Conversation

@mdheller

Copy link
Copy Markdown
Member

Retrospective Copilot review on #38, raised in both rounds. Four defects in ts/src/vendor-graph.ts; one of them is live on main today.

The live one — newestReleasedVersion

.sort() on version strings is lexicographic:

ASCII:    ["0.4.10","0.4.46","0.4.5","0.4.9"] -> picks 0.4.9
releases: ["0.4.5","0.4.9","0.4.10","0.4.46"] -> should pick 0.4.46

Not latent. The engine is at 0.4.46/0.4.47, so every repository past a two-digit patch resolved to the wrong "newest", and that value is handed to blastRadiusOf as proposedVersion — so every sealed blast-radius report named a release nobody would cut.

While writing the test I found the same defect reaching further than the ticket described. A pin may name a version the release history does not list; the ingest builds that artifact with no supersession chain, so it is a head and joins the candidate set. Under ASCII, letters outrank digits — so a vendored_commit sha, or the literal unknown, beat every real release. The report proposed cutting unknown. Covered by its own test.

The comparator, and its documented tie-break

Replaced with compareVersions, exported so the rule is assertable rather than implicit — an undocumented tie-break is how this class of bug comes back. Full rules are on the docstring; in order:

  1. Shapev? digits(.digits)* -pre? +build?. Anything else (commit shas, dates, unknown, "") is not a release and sorts below every release; non-releases compare by ASCII between themselves.
  2. Release components numerically, missing ⇒ 0. Compared as digit strings, not via Number, so precision never collapses two versions into a tie.
  3. Prerelease precedes its release (semver §11): 1.0.0-rc.1 < 1.0.0.
  4. Between prereleases, semver §11 identifier precedence — numeric identifiers numerically and below alphanumeric, prefix below longer list. Deliberately not ASCII on the whole suffix, which would rank rc.10 below rc.2 and reintroduce the same defect one level down.
  5. Build metadata ignored for precedence (semver §10).
  6. Total order: returns 0 only for identical strings. Same-release-different-text (0.4 vs 0.4.0, 1.0.0+a vs 1.0.0+b) still orders, by raw text. An unresolved tie makes a sort depend on input order, and this sort's result is sealed into a receipt.

Hand-scanned rather than regex-matched, deliberately: slug() in this same file already earned a CodeQL js/polynomial-redos finding on a register-supplied value, and a nested-quantifier version pattern is the textbook way to earn a second. There is a linearity test.

Three smaller defects, same review

  • supersessionChain docstring (~L517) — claimed "longest path"; the code takes the lowest next node id and the comment two lines below already said so. The code is correct — lowest-id is replayable, and a sealed receipt must be re-derivable from the graph alone — so the docstring changed, not the code.
  • producedBy edge (~L477)vfpId.repository(field(src,'repo') ?? '') built an edge to vfp:repo:, a dangling edge to a node the source loop never creates (it skips sources with no repo). Guarded on non-empty and routed through ensureRepo, so the target is guaranteed to exist rather than assumed to.
  • stats.releases++ (~L418) — the one counter not gated by a seen* set. It counted manifest lines while artifacts/contracts/repositories/consumerApps counted nodes created, so a register declaring one release twice made them disagree with no way for a caller to tell which question it had asked.

Tests — red before green

vendor-graph.test.ts had no coverage for either semver ordering or forked supersededBy histories. Both added. Against the unmodified source, the new tests fail:

not ok - the newest release is the newest by VERSION, not by ASCII — 0.4.46 outranks 0.4.9
        '0.4.9' !== '0.4.46'
not ok - a commit sha and the literal unknown are not "newer" than every release
not ok - ingest stats count NODES, not manifest lines — a repeated release is counted once
        5 !== 4
not ok - a source with no repo produces no edge to an empty repository id
# tests 26 / pass 22 / fail 4

The ordering test uses the four-version case, so a single-digit-patch fixture cannot pass in its place — and it asserts through analyzeVendorFreshness().blastRadius, the real surface that was wrong, not just the private helper.

The fork test pins the documented lowest-id behaviour: its branches are built so the two rules give different answers (low-id branch is a dead end at distance 1, high-id branch runs to distance 2), so a future "improvement" to longest-path changes releaseDistance/latestVersion and breaks a test instead of silently rewriting receipts.

After the fix: 417 pass / 0 fail (was 410 — seven new tests). typecheck clean, build clean, check:dist exit 0 and reproducible. ts/dist committed.

Coordination with #40

#40 (consume contractViolations) touches this same file. Checked before starting and verified empirically rather than by inspection — git merge-tree of this branch against #40's head:

  • ts/src/vendor-graph.ts and ts/src/vendor-graph.test.tsauto-merge clean, zero conflict markers. The lanes are disjoint: this diff contains 0 lines mentioning contractViolations.
  • Merged source of both lanes runs 30/30 green, so they compose semantically, not just textually.
  • ts/dist/* conflicts — unavoidably. Git treats those files as binary (a NUL byte at ~offset 5985), so they cannot be auto-merged. Both branches rebuild them.

Whoever merges second must rebuild dist, not hand-resolve it: npm run build && git add ts/dist. It is a pure derivative of source, so this is mechanical, but it will not resolve itself.

Do not merge — for review.

`newestReleasedVersion` picked the newest release with a bare `.sort()`, which
is lexicographic. ASCII orders 0.4.10, 0.4.46, 0.4.5, 0.4.9 and so answers
"0.4.9". The engine is at 0.4.46 today, so this is not latent: every repository
past a two-digit patch resolved to the wrong newest version, and that value is
handed to `blastRadiusOf` as `proposedVersion` — every sealed blast-radius
report named a release nobody would cut.

Worse, and reachable from the register as written: a pin may name a version the
release history does not list, and the ingest builds that artifact with no
supersession chain — so it is a HEAD and joins the candidate set. Under ASCII a
commit sha, or the literal `unknown`, outranks every real release because
letters sort above digits. The report then proposed cutting `unknown`.

Replaced with `compareVersions`, exported so the rule is assertable, and with
the tie-break DOCUMENTED rather than implicit — an undocumented tie-break is how
this class of bug returns. Numeric release components compared as digit strings
(exact, no float); non-release strings sort below every release; a prerelease
precedes its release; prerelease identifiers follow semver §11, so `rc.2`
precedes `rc.10` instead of reintroducing the same defect one level down; build
metadata is ignored for precedence; and the order is TOTAL, returning 0 only for
identical strings, because an unresolved tie makes a sort depend on input order
and this sort is sealed into a receipt. Hand-scanned, not regex-matched:
`slug()` in this file already earned a CodeQL polynomial-redos finding on a
register-supplied value.

Three smaller defects found in the same review:

- `supersessionChain`'s docstring claimed "longest path" while the code took the
  lowest next node id and the comment below said so. The code is right —
  lowest-id is replayable and a sealed receipt must be re-derivable — so the
  docstring is what changed.
- The pinned-artifact branch built `producedBy` to `vfpId.repository(... ?? '')`,
  i.e. a dangling edge to `vfp:repo/` whenever the matched source declared no
  repo. Guarded on non-empty and routed through `ensureRepo` so the target node
  is guaranteed to exist.
- `stats.releases++` was the one counter not gated by a `seen*` set: it counted
  manifest LINES while every other counter counted NODES CREATED, so a register
  declaring one release twice made them disagree.

Tests: the ordering test is red against the old `.sort()` (asserts 0.4.46, gets
0.4.9) and uses the four-version case, so a single-digit-patch fixture cannot
pass in its place. The forked-history test pins the documented lowest-id
behaviour — its branches are built so longest-path gives a different
`releaseDistance`, so a future "improvement" breaks a test instead of silently
rewriting receipts.

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 vendor freshness analysis to rank versions by semantic precedence (not lexicographic ASCII), and addresses related ingest correctness issues that affected sealed blast-radius receipts.

Changes:

  • Replace lexicographic version sorting with an exported, documented compareVersions() semver-like comparator.
  • Fix ingest consistency bugs: gate stats.releases like other node counters and prevent dangling producedBy edges to an empty repository id.
  • Add targeted tests covering version precedence, forked supersession determinism, ingest stats correctness, and regression cases (unknown/commit SHA).

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 Adds compareVersions() + parser, uses it for newest release selection, fixes ingest stats gating and producedBy edge creation.
ts/src/vendor-graph.test.ts Adds regression tests for version precedence, forked supersession behavior, stats gating, and empty-repo edge prevention.

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

Comment thread ts/src/vendor-graph.ts
Comment on lines +644 to +652
function parseVersion(raw: string): ParsedVersion | undefined {
let s = raw
if (s.charCodeAt(0) === 118 /* v */) s = s.slice(1)
const plus = s.indexOf('+')
if (plus !== -1) s = s.slice(0, plus) // build metadata is not part of precedence
const dash = s.indexOf('-')
const core = dash === -1 ? s : s.slice(0, dash)
const pre = dash === -1 ? undefined : s.slice(dash + 1)
if (core.length === 0) return undefined
Comment thread ts/src/vendor-graph.ts
Comment on lines +567 to +568
export function compareVersions(a: string, b: string): number {
const ascii = (x: string, y: string): number => (x < y ? -1 : x > y ? 1 : 0)
Comment on lines +659 to +668
test('compareVersions is linear — the version parser is not a ReDoS', () => {
const pathological = '9'.repeat(200_000) + 'x'
const t0 = process.hrtime.bigint()
for (let i = 0; i < 50; i++) compareVersions(pathological, '0.4.46')
const ms = Number(process.hrtime.bigint() - t0) / 1e6

assert.ok(ms < 5_000, `version parsing went superlinear: ${ms.toFixed(0)} ms`)
// ...and it still gets the answer right: a 200k-digit non-release is not newer than 0.4.46.
assert.ok(compareVersions(pathological, '0.4.46') < 0)
})
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