Skip to content

ci: gate the API reference against duplicate-content across versions - #431

Merged
moritzhartmeier merged 13 commits into
mainfrom
fix/api-reference-seo-check
Sep 18, 2026
Merged

moritzhartmeier merged 13 commits into
mainfrom
fix/api-reference-seo-check

Conversation

@eugenia-scandit

Copy link
Copy Markdown
Collaborator

The API reference is published once per major.minor line, so the same symbol page exists at /6.28/data-capture-sdk/X, /7.6/data-capture-sdk/X and the unversioned /data-capture-sdk/X. None of them declares which is canonical, so Google treats them as independent pages and picks whichever it likes - usually the oldest, because it carries the most history and inbound links. That is how /6.28/.../aamva-barcode-result.html came to outrank current docs.

The rule this checks, per versioned page:

unversioned counterpart returns 200 -> canonical pointing at it
unversioned counterpart returns 404 -> robots noindex

The second branch is the one easy to get wrong. An API removed since that line has no current equivalent, so a canonical would point at a 404 and Google would ignore it; that page needs de-indexing, not redirecting. Both cases occur in practice - the first sampled run found 4 of one and 3 of the other.

Nothing here is version-specific, which is the point: the canonical target is always the unversioned URL, which is by definition the current line. A release never changes what this expects and there is no version constant to maintain.

URLs come from the versioned API links the built site already contains (2,049 distinct across /6.28/ and /7.6/), sampled deterministically so CI checks the same pages each run rather than drifting.

Warns rather than fails. The API-reference HTML is generated outside this repository, so this gate can only observe it - it is merged now so the finding is visible and tracked, and takes --strict once the generator emits the tags.

The API reference is published once per major.minor line, so the same symbol
page exists at /6.28/data-capture-sdk/X, /7.6/data-capture-sdk/X and the
unversioned /data-capture-sdk/X. None of them declares which is canonical, so
Google treats them as independent pages and picks whichever it likes - usually
the oldest, because it carries the most history and inbound links. That is how
/6.28/.../aamva-barcode-result.html came to outrank current docs.

The rule this checks, per versioned page:

  unversioned counterpart returns 200  ->  canonical pointing at it
  unversioned counterpart returns 404  ->  robots noindex

The second branch is the one easy to get wrong. An API removed since that line
has no current equivalent, so a canonical would point at a 404 and Google would
ignore it; that page needs de-indexing, not redirecting. Both cases occur in
practice - the first sampled run found 4 of one and 3 of the other.

Nothing here is version-specific, which is the point: the canonical target is
always the unversioned URL, which is by definition the current line. A release
never changes what this expects and there is no version constant to maintain.

URLs come from the versioned API links the built site already contains (2,049
distinct across /6.28/ and /7.6/), sampled deterministically so CI checks the
same pages each run rather than drifting.

Warns rather than fails. The API-reference HTML is generated outside this
repository, so this gate can only observe it - it is merged now so the finding
is visible and tracked, and takes --strict once the generator emits the tags.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-18 14:34 UTC

eugenia-scandit and others added 4 commits September 4, 2026 09:51
…-check

# Conflicts:
#	.github/workflows/build-docs.yml
#	package.json
Review pass over the new gate. Three of the findings were the gate reporting OK
on a state it was written to catch, and one was the rule itself not fixing the
bug in the header comment.

- Checking NOTHING was a pass. fetchHtml returns null on any non-ok response or
  thrown fetch, and the loop `continue`d before incrementing `checked`, so a DNS
  blip, an outage or a 429 burst left violations empty and printed "OK: every
  sampled page declares a canonical form" with exit 0 - under --strict too.
  Verified by forcing a 1 ms request timeout: now WARN + exit 0, FAIL + exit 1
  under --strict, and it says how many picks it could not judge.

- A mistyped flag did the same. `--sample` with no value gave Number(undefined)
  = NaN, and Array.from({length: NaN}) is [], so the run checked zero pages and
  went green. `--sample`, `--sample abc`, `--sample 0`, `--sample --strict` and
  `--sample=abc` all now exit 1 with a usage error; `--sample=N` is parsed rather
  than silently ignored.

- Every non-200 on the unversioned URL was read as "API retired". A transport 0,
  403, 429 or 5xx therefore told the generator team to de-index a live, healthy
  current page. Only an explicit 404 means retired now; anything else is
  reported as undetermined and judges nothing.

- The rule would not have fixed the motivating bug. A canonical is a DUPLICATE
  signal and Google drops it between pages whose content materially differs.
  Measured on ios/core/api/camera.html: unversioned 47,186 bytes, /7.6/ 50,846,
  /6.28/ 45,806. So a canonical from 6.28 to the current URL is cross-content and
  likely ignored, and /6.28/.../aamva-barcode-result.html would keep outranking
  current docs. The gate now accepts noindex OR a correct canonical, and reports
  the canonical as weak when the two pages differ by more than 5%. It does not
  decide for the docs team that old lines must leave Google - noindex also stops
  a 7.6 reader finding their own version's page - it surfaces the trade-off.

- Coverage was silently narrowed three ways: a 6,000-file walk budget that
  returned without a word once exhausted (and was already below the build's
  6,540 .html files while three doc versions existed, which also broke sample()'s
  promise of checking the same pages every run); a URL character class that
  swallowed `)` and `,`, turning `[AI](...parser/AI)` into `parser/AI)` and
  producing 7 bogus picks in /6.28/ and 5 in /7.6/ that 404 and were skipped
  unannounced; and an unguarded readFileSync. The walk is now complete (3,209
  files, well under a second), artefacts are filtered, and unreadable files are
  counted and reported.

- Lines the build no longer links were invisible. The 8.6 release deleted the
  version-8.5.3 snapshot, so nothing links to /8.5/ - while the generator still
  publishes it. Discovering lines only from links hid the NEWEST frozen line,
  the one whose content is closest to current and so most likely to outrank it.
  The gate now probes the minors below the current version and reports what is
  live but uncovered: /8.5/, /8.4/ and /8.3/ today.

- An unconditional exit(1) in the catch contradicted the workflow step, which
  omits --strict on purpose because the generator cannot pass this yet. Transport
  now exits 0 unless --strict; a defect in the script always fails.

- Added AbortSignal.timeout(15s) to both fetches (undici's default is 300s), and
  the canonical comparison now resolves the href instead of string-matching it,
  so a valid relative, protocol-relative or http:// canonical is no longer
  reported as a violation to the very team meant to act on the report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gate now checks what it says it checks, and says what it did not check.
Sixteen review passes went into this; the substantive changes:

WHAT IT ASKS FOR. A canonical is a duplicate-content signal and Google drops it
between pages whose content differs. Measured on ios/core/api/camera.html:
unversioned 47,186 chars, /8.6/ 47,186, /8.5/ 45,355, /7.6/ 50,846, /6.28/
45,806. So the original rule - canonical to the unversioned URL - would have been
implemented in full and /6.28/.../aamva-barcode-result.html would have kept
outranking current docs, which is the bug the gate exists to prevent. A page is
sound when it carries robots noindex in <head> or an X-Robots-Tag header, or
redirects to its unversioned counterpart, or declares a canonical to a
counterpart close enough in size for that canonical to hold.

IT CAN NO LONGER PASS WITHOUT CHECKING. Six separate paths used to print OK and
exit 0 - under --strict as well - having judged nothing: a mistyped `--sample`
(NaN picks), every page unreachable, every page 404, a build with no versioned
links, a line judged on one page, and `--strict=true` silently disabling strict
mode. Each is now a failure or a named non-green state, verified with stubs.

WHAT IT PARSES. robots and canonical detection is scoped to <head>, tolerates
unquoted attributes and multi-token `rel`, strips HTML comments before matching,
splits robots directives on both commas and whitespace, honours `name="googlebot"`
and `X-Robots-Tag`, and tracks bot scope left to right so `bingbot: noindex, none`
is not read as de-indexed. Two forms it used to get wrong in opposite directions.

DISCOVERY, as a separate script. A line keeps being published after its doc
snapshot is deleted - the 8.6 release removed version-8.5.3, so nothing links
/8.5/ - and the newest frozen line is the one whose content is closest to current.
scripts/discover-api-reference-lines.cjs probes for those and prints them;
build-docs.yml feeds them to the gate, so coverage needs no hard-coded versions.
It probes with symbols durable across every linked line, treats a non-404 as "could
not tell" rather than absence, carries a request budget, and states the range it
probed. It lives apart because probing inside the gate did not converge over four
passes: the probe set, the coverage accounting and the cost estimate were each
wrong in turn, and one of its three discovery sources provably could not
contribute a line.

scripts/lib/linked-api-lines.cjs is shared, because the URL extraction encodes
several rounds of lessons - no file cap, `)` and `,` excluded, `..` filtered - and
two copies would drift.

KNOWN LIMITS, not silently: a major with no linked line is probed against another
major's minor count; `--lines` naming the served line is dropped when the build
links it; the 20-violation print cap is filled by the oldest lines first; the
`redirects` list is collected and not reported; the shallow-line note names
missing symbols even when the cause was transport; and the gate has no total
request budget, so a large manual `--sample` is slow. None of these can produce a
false "all clean" - they cost coverage or precision of wording.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six review rounds, each verified by running the scripts against a mock origin
rather than by reading them. The headline finding was a false pass on the one
signal this gate exists to produce:

`hasNoindexIn` split its directive list on `[,\s]+` and accepted any bare
`none`, so `index, follow, max-image-preview: none` tokenized to a `none` and
the page read as de-indexed. An indexable versioned page was therefore reported
sound - the gate saying "no duplicate content" about exactly the duplicate
content it was built to find. The space after the colon is what made it
reachable, and `VALUED_DIRECTIVES` never touched this path: it only guarded the
scope prefix in the header parser. Valued directives are now stripped WITH
their values before tokenizing, anywhere in the part, so neither
`max-image-preview: none noindex` nor `noindex, max-image-preview: none` is
misread. Verified by extracting the shipped function and running 21 meta and
header cases through it.

A MISSING FILE COULD PRODUCE A PERMANENT BLOCK. When `search-tags.json` cannot
be read, `servedLine` became `""`, which silently switched off the
served-release exclusion - so the current release's own versioned line was
judged as an old line and every one of its pages came back as duplicate
content. That is the standing `--strict` failure this gate is written to avoid,
arriving through an unreadable file rather than a real finding. The gate now
bails the way discovery already did, issuing no requests. Relatedly, the
stale-artefact version check no longer short-circuits on an unknown version:
"cannot tell" must not read as "fine", because that is precisely when a local
build/ is likeliest to hold an artefact from an older release.

A DEAD LINE COULD BE REPORTED NOWHERE. A line the build links where every url
404s escaped every floor - `requested` excludes 404s, so `blindLines` and
`thinLines` and the coverage share all skipped it - and the note that did cover
it treated a linked line like one the operator had merely asked about. Those
are different claims: a borrowed line answering "not published" is an answer,
while a linked line answering it is a build pointing at pages that are not
there. They are now separate, and the failing one is keyed on `absent ===
sampled` rather than on `checked === 0`, so a run whose picks were throttled
stays `blindLines`' case instead of being called a rename.

Two floors keep that verdict honest: a line needs enough linked urls to be a
line rather than one typo'd href, and enough sampled picks to say anything
about it - without the second, `--strict --sample 1` turned a single rotted url
on a 1,000-url line into "taken offline", identically on every retry because
the sampler is deterministic. A line that is all-absent but under either floor
is reported rather than dropped, and the footer says plainly that it is not
failed ON ITS OWN.

Smaller fixes, all reproduced first: an unreadable directory is counted and
reported instead of silently dropping its subtree, in both the gate and
discovery, since every verdict derives from that link map; a partial probe
confirmation warns on stderr, because the count was printed through the channel
`--quiet` suppresses and `--quiet` is how CI runs it; a `--lines` entry the
build already links is reported instead of vanishing, with the served line
saying plainly that naming it does not add it back; and the "nothing to check"
message now reasons from the unfiltered link map, so a build that links only
its own version no longer claims to link nothing.

Comment accuracy was treated as part of the work, because in these scripts the
comments are the deliverable. Several documented figures did not reproduce and
now do, or are gone: the sweep is ~42 requests today, not ~37, and the comment
says the figure tracks how many minors exist below the served one rather than
presenting it as a constant. `MIN_STALE_SAMPLE` was removed once measurement
showed it could never bind - requiring one judged page already forces four
sampled pages at a 0.75 share - rather than left asserting a threshold that
never applied.

KNOWN LIMITS:

  The gate is advisory in CI (no `--strict`), so nothing here can turn a docs
  PR red today. The two remaining threshold edges only bite once `--strict` is
  switched on.

  Discovery does not look for a line under a major older than those probed
  whose snapshot has been deleted: nothing links it and nothing probes it.

  The served release's own versioned copy is byte-identical to the unversioned
  tree and so IS duplicate content, but de-indexing the current release is a
  different decision and is left to `--lines`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@moritzhartmeier moritzhartmeier left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I ran both scripts against a local build and the live site. They work, and the payoff is concrete: discovery found /8.3/ /8.4/ /8.5/ — lines nothing in the build links — and the gate then confirmed all three serve duplicate content with neither canonical nor noindex. That is exactly the case the second script exists for. Cost is small too: discovery 42 requests / ~5s, gate ~4s at --sample 4.

I also hand-probed the parsers (attr, headOf, canonicalOf, isNoindex, hasNoindexHeader, samePage) with 15 tricky inputs — commented-out tags, data-name decoys, rel="alternate canonical", max-image-preview: none, bingbot: scoping, protocol-relative canonicals. 14 of 15 behaved exactly as the comments say they do.

Three things below: one real bug, and two maintainability asks. Nothing I would block on.

Comment thread .github/workflows/build-docs.yml Outdated
Comment thread scripts/discover-api-reference-lines.cjs Outdated
Comment thread scripts/verify-api-reference-seo.cjs
Comment thread scripts/verify-api-reference-seo.cjs Outdated
eugenia-scandit and others added 8 commits September 18, 2026 11:38
…plicates

Three review points on the api-reference SEO gate.

LINES -> API_LINES. zsh declares LINES as a typed integer, so
`LINES=$(...)` there arithmetic-evaluates the comma list and 8.3,8.4,8.5
becomes 8, after which the gate exits with `--lines takes major.minor
values (got "8")`. CI runs bash and was never affected, but the same
invocation is documented in the script header for people to paste into a
local shell, and on macOS that is zsh. Renamed in both places, with the
reason stated where the line is.

The parsers are exported and tested. attr, headOf, canonicalOf,
isNoindex, hasNoindexIn, hasNoindexHeader and samePage carry most of the
risk in this check, and every one of them has a comment describing a
false PASS it once produced - a commented-out canonical read as declared,
data-name="robots" read as a directive, `max-image-preview: none` read as
`none`, `bingbot: noindex` read as de-indexed. A false pass is the one
failure a gate cannot reveal by being run, and prose cannot fail when
someone reinstates the bug it describes. scripts/test-api-reference-seo.cjs
pins each of those cases by name, plus the lib helpers that had no tests,
and runs offline in milliseconds - no build/, no network. Wired in as
test:api-reference-seo and as a CI step beside test:search-facets.

Each assertion was checked against a mutated copy of the source: reverting
attr to the \b boundary, stripping inline script in headOf, dropping the
VALUED_PAIR strip, dropping the HONOURED_SCOPES guard, anchoring rel in
canonicalOf, dropping googlebot from the meta names, string-sorting
compareLines and removing the SUBSTANTIAL share all turn the suite red.
The SUBSTANTIAL case only did so after the fixture was tightened: with a
stray path unique to the stray line the intersection empties and the
biggest-line fallback restores the pool, so the guard could have been
deleted with the test still green. It now uses a path the other lines
share, as the real /8.6/ link did.

BUILD, ORIGIN, REQUEST_TIMEOUT_MS and the served-version reader - which
was currentVersion() in the gate and currentNumber() in discovery, the
same function reading the same field of the same file - move into
scripts/lib/linked-api-lines.cjs. The two scripts run as one CI step and
one hands the other an artefact, so a difference between them does not
surface as a conflict; it surfaces as a confident report about something
neither of them checked. currentVersion takes the build directory as a
parameter so a test can point it at a fixture.

Verified end to end against the live site after the refactor: discovery
still finds /8.3/ /8.4/ /8.5/ in 42 requests, and the gate still reports
all three as duplicate content with neither canonical nor noindex.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…, and running unbounded

Four findings from review, all confirmed against the running code.

The violation print cap dropped the lines the check exists for. `targets`
is sorted oldest-line-first, so violations accumulate that way, and a flat
`slice(0, 20)` spent the cap on the oldest lines. Measured against the live
site with the exact CI command: all 28 picks were violations and the 20
printed were /6.28/ x8, /7.6/ x8, /8.3/ x4 - /8.4/ and /8.5/ appeared
nowhere but inside "... and 8 more". The newest frozen line is the one
whose content is closest to current and so the likeliest to outrank it; it
is why discovery exists, and it sorts last, so it was the one guaranteed to
be cut. spreadAcrossLines now fills the cap round-robin, newest line first,
so every line appears before any line repeats. The same run now prints four
from each of the five lines. The file already carried this lesson for the
served line, at `alsoServed`; the same crowding arrived through --lines.

A redirect landing on a 404 was reported as link rot. `get` follows
redirects and reports the FINAL status, and the 404 branch ran before the
redirect branch. So a line remediated exactly as this gate asks - 301 to
the unversioned counterpart - for symbols that have since been retired was
charged to `absent`, printed under "url(s) the build links do not exist ...
link rot in the docs", and judged nothing. On such a line it also pushes
absent/sampled past STALE_LINE_SHARE and can trip `deadLines` into a
--strict failure saying the line "was taken offline" - about a line that
was just fixed. The redirect check now runs first. Verified against a stub:
before, 0 of 8 judged and a link-rot report; after, 8 of 8 judged and OK.

The gate had no request budget while discovery has one. --sample bounds
picks per line and MAX_SAMPLE bounds --sample, but the number of LINES is
whatever --lines names, and CI pipes discovery's whole list in. Discovery
can legitimately return a long one, so this was ~120 sequential requests at
a 15 s ceiling in a step that only advises. REQUEST_BUDGET = 260, with the
spend printed every run (48 today). Picks it stops are reported as "not
asked", never as absence, and the run fails its coverage floor rather than
printing OK.

`redirects` in discovery was collected and never read, so once the
generator ships the redirects this check asks for, every remediated line
would have appeared under "PUBLISHED but linked from nowhere" with nothing
to tell it from an untouched one - the opposite of what happened. It is now
named in the report and recorded in the artefact as `redirected`.

Tests. spreadAcrossLines is exported and unit-tested, including the
five-line case measured above. The two verdict fixes are ordering bugs
inside the pick loop, which no unit test of a pure function reaches, so
scripts/test-api-reference-seo-verdicts.cjs runs the real scripts against a
stub origin on localhost - still offline, no live requests. Both fixes were
mutation-checked: restoring the pre-fix branch order and removing the
not-asked branch each turn the suite red, and a sound line still passes so
the assertions cannot be satisfied by a gate that judges nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four findings from the second review round.

attr() read an attribute name out of an EARLIER attribute's value. The
boundary class `(?:^|[\s"'])` made a closing quote a boundary, so
`<link title="rel=canonical" rel="stylesheet" href="/s.css">` reported
/s.css as a declared canonical. Tightening the boundary to whitespace
closed that one and not the worse one: values contain spaces, so
`<meta content="see name=robots noindex" name="description">` still
returned "robots" and isNoindex read a page carrying NO robots directive
at all as de-indexed. Both are false passes on the only two signals this
gate verifies, and no boundary closes the class, because skipping quoted
values is a tokenizer and not a pattern. attr now walks the tag's
attributes. Third fix to this function; the first two were regexes.

A correctly remediated line was failed when the unversioned counterpart
itself redirects. The redirect verdict compared the fully followed final
url against the unversioned url, so if /data-capture-sdk/X 301s onward to
/8.6/, a frozen line doing exactly what this gate asks - 301 to the
unversioned counterpart - follows that second hop, lands on /8.6/, and
every pick was printed as "neither this line nor the current page". The
canonical path already reasons about this exact hazard at `const
counterpart = current`; the redirect path did not. It now also accepts
landing where the counterpart's own chain lands, ordered second so the
verdict still does not depend on that request succeeding.

The request budgets did not bound the time they were added to bound. 260
requests at the 15 s per-request ceiling is over an hour, and discovery's
90 sequential HEADs are ~22 minutes, so both comments named a runaway their
values permitted. Both scripts now carry a wall-clock deadline as well -
8 minutes for the gate, 4 for discovery - and print elapsed time beside the
count. Not `timeout-minutes:` on the step: that fails the job, and this step
is non-blocking on purpose. Stopping in-script keeps it advisory and, like
the request cap, reports what it did not ask rather than reporting absence.

The "could not determine" footer contradicted the run. discoveredUncertain
was printed without intersecting what was actually checked, so following
discovery's own advice - `--lines 8.5` after it filed /8.5/ as uncertain -
produced a report that judged /8.5/ and stated in the same output that it
was not among the lines checked. CI only passes the lines discovery FOUND,
so this hit the one invocation documented for a human.

All four are pinned: two unit cases for the attribute walker, two stub
cases for the counterpart chain and the footer. Each was mutation-checked -
restoring the old regex, dropping the convergence check, and dropping the
footer filter each turn a suite red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sting any 3xx

Four findings from the third review round, all low severity and all confirmed.

spreadAcrossLines keyed on a hardcoded host. The line was parsed back out of
each violation's url against `docs.scandit.com`, while every url in the run
is built from ORIGIN. Point ORIGIN anywhere else - a staging host, or the
stub the verdict suite runs against - and every entry fell to the "0.0"
fallback, leaving one queue and the flat slice this function exists to
replace. Violations now carry `line`, so nothing parses it back out. This
also means the end-to-end suite had never covered the round-robin at all.

Borrowed picks all came from one framework. A line nothing links is checked
only on paths borrowed from other lines, taken as `slice(0, 4)` off a pool
that sample() sorts lexicographically, where `android/...` dominates the low
indices: measured 3 of 4 Android picks with a discovery artefact present and
4 of 4 without one. The newest frozen lines are checked ONLY this way, so a
generator that de-indexed Android but not iOS or Web passed. borrowedPicks
now keeps discovery's confirmed probes first - the seeded-overlap guarantee
the old slice existed for - and spreads the remainder. Measured after:
android, cordova, flutter, web.

Any 3xx counted as "the line is published". A catch-all redirect for unknown
paths is ordinary static hosting, and it would have made every minor in the
sweep - about 15 today - come back as a discovered line, which CI feeds
straight into --lines, after which the gate spends its budget reporting
violations for lines that do not exist. headStatus now returns the Location
too, and a 3xx counts only when it keeps the symbol path: a redirect to the
unversioned counterpart or to another frozen line is a published line and
both reach the gate, while one that drops the path is reported and ignored.
Verified /8.2/ genuinely 404s today, so this guards a hosting change rather
than fixing a live bug.

A stale artefact outlived a run that could not replace it. Four paths return
before the write, and the gate's freshness check cannot see it, because it
compares against the served release number - which does not change between
builds of the same release. Discovery now deletes the artefact on every path
that cannot produce one; a missing artefact is already a supported state
that the gate handles by working on its own.

Pinned: borrowedPicks and servesSymbol are exported and unit-tested, the
spreader has a case using non-production urls, and the stale artefact and
catch-all redirect have stub cases. Each was mutation-checked. The lineOf
mutation initially survived - every fixture used a real docs.scandit.com url,
which is the same blind spot as the bug - so that case was added explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… servesSymbol

The high-severity finding is a regression this branch introduced two rounds
ago. The convergence check added for "the counterpart itself redirects
onward" asks whether the versioned url and the unversioned url ended in the
same place. A host-level catch-all sending unknown paths to `/` satisfies
that trivially - both land there - so every pick read as SOUND, on a line
that does not exist at all. samePage cannot catch it: it strips a trailing
slash, after which "/" compares equal to "". That is the exact false-pass
class this gate exists to close, and it shipped.

The same hosting rule with the counterpart answering 200 produced the
opposite error: every pick fell into the redirect branch, was counted as
judged, and was emitted as a duplicate-content violation - findings against
an unpublished line.

Root cause is that discovery grew servesSymbol last round and the gate did
not, so the two scripts read the same response differently. It moves into
scripts/lib/linked-api-lines.cjs and both use it. A redirect that keeps the
symbol path is a statement about the page - to the unversioned counterpart
it is the remediation asked for, to another frozen line it moves the
duplicate. One that drops the path is a hosting rule and says nothing, so
the pick is now filed as undetermined: not sound, not a violation, and it
drags the coverage floor, which is what "this run learned nothing here" is
supposed to do.

Two more in discovery. Confirming a probe on the unversioned tree rejected
any 3xx, because `redirect: "manual"` is right for probing a LINE and wrong
for asking "does this path resolve?" - one normalisation hop would have
failed every candidate, exited 1, and the workflow's `|| true` would have
turned that into an empty API_LINES, dropping the newest frozen line from CI
with a single stderr line to show for it. And a line whose probes were all
answered by a catch-all was filed as neither found nor uncertain, so it
vanished from both outputs while the report said nothing was found -
contradicting this file's own rule that a probe proving nothing must not
read as absence. Such lines are now uncertain, which the gate surfaces.

Four stub cases pin these, and each was mutation-checked: removing the
gate's guard, restoring 200-only confirmation, and filing swept lines as
absent each turn the suite red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he sweep

Three of six findings from the fifth review round. The other three are
judgement calls left for a human - see the PR discussion.

`undetermined` and `stale` were still flat-sliced. They accumulate in
targets order, oldest line first, exactly like `violations` did before
spreadAcrossLines, so with eight throttled picks on each of two linked lines
the ten printed were all /6.28/ and /7.6/. The undetermined list is the one
that says WHY coverage was lost, so this hid it for precisely the line the
run was extended to cover. Both entries now carry `line` and both prints go
through the same spreader.

The 8-minute deadline started at module load, so argument parsing and the
3,000-file link walk ate into it before the first request. It now starts
immediately before the pick loop, which is the part that can run away and
the part it was written to bound.

Coverage note: the spreader itself is unit-tested for this shape, but the
two new call sites are not pinned - reproducing them needs a throttled
origin, which the stub does not model. The violations call site is exercised
on every live run; these two are not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s, skip PRs

Three items from the fifth review round.

The lower-major sweep was incomplete. `knownCeiling(maj) + 3` probed a fixed
three minors above the newest LINKED one - 7.9, 7.8 and 7.7, which never
shipped, at about 27 requests a run - and stopped dead there, so a published
line any further up was never asked about and never reached the gate. That is
the blind spot this script exists to close.

A streak-based walk was tried first and was wrong for the same reason, which
a test caught before it shipped: de-publication removes a major's OLDEST
lines and keeps its newest, so the surviving block sits ABOVE a stretch of
absent minors. With /7.6/ linked and /7.12/ served, /7.7/-/7.11/ are gone and
any streak ends the walk before it arrives.

So the sweep is complete: the widest minor this project has shipped down to
.0, walked downward so the budget goes newest-first and a run cut short loses
only the oldest. That costs 99 live requests instead of 42, measured; the
cheapness was the bug. REQUEST_BUDGET is re-derived from that worst case
(250) with the arithmetic written down, and DEADLINE_MS remains the real
bound. The ceiling also takes a floor that does not come from the link graph:
maxMinorSeen reports 28 only because /6.28/ is still linked, so the day that
snapshot is deleted the search for major 7 would have silently shrunk.

The counterpart cache stored failures. Every --lines target borrows the same
handful of symbol paths, so one transient 429 on an unversioned url was
charged to every discovered line at once - three lines with three of four
shared paths throttled each fell to checked === 1 against a floor of 2 and
all three landed in thinLines from a single burst. Only a definitive answer
(200 or 404) is cached now; a 429, 5xx or transport failure is re-asked,
bounded by the budget and the deadline.

And the gate step no longer runs on pull requests. What it checks is the live
published site, and the API-reference HTML is generated outside this repo, so
no change on a PR branch can alter the verdict - it was charging every author
live requests to production for no PR-specific signal. Push to main, the
daily schedule and manual runs keep it, which is the monitoring an advisory
gate is for. The two offline test steps still run on PRs, because unlike this
step they test the code in the diff. timeout-minutes: 15 is a backstop above
the scripts' own 4- and 8-minute deadlines, for a hang neither can see.

Each fix mutation-checked: restoring the +3 ceiling, dropping the ceiling
floor, and re-caching counterpart failures each turn the suite red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ock at the sweep

Two findings from the sixth review round.

Borrowed picks never left three frameworks. `borrowedPicks` was fixed last
round to spread the non-seeded slots, and the test pinned that only for
`seeded: 0` - not the path CI actually takes. With a discovery artefact
present, seeded is 3 and want was 4, so the single free slot was
`sample(rest, 1)`, which is `sorted[0]`: the alphabetically first entry, i.e.
android again. Measured on the real build, every frozen line was checked on
android, capacitor and cordova and on nothing else, while `dotnet.*`,
`flutter`, `ios`, `react-native`, `titanium`, `web` and `xamarin.*` were never
sampled on any line reached through --lines - which is the ONLY way a frozen
line is checked. A generator shipping noindex for android but not ios got a
clean OK. That is the exact failure the function's own comment claims it
fixes.

sample() cannot do this job: it spreads over a lexicographic order and the
pool's low indices are all android. So the free slots are now filled by
framework - ones the seeds do not already cover first - with a deterministic
per-line spin, so the lines discovery hands over do not all spend their slots
on whichever framework sorts first.

UNLINKED_LINE_SAMPLE goes 4 -> 8 with it. The old value came with the
reasoning that borrowed picks are guesses so more of them buy little, and
that was true while they were four adjacent paths from one framework. Now
each slot is a different framework and there are 13 in the pool, so the slot
count IS how many frameworks a frozen line is looked at on. Measured: 48 live
requests to 72, and 3 frameworks per frozen line to between 5 and 7, against
a 260 budget and an 8-minute deadline on a step that no longer runs on PRs.

And discovery's deadline started at module load, which the gate had already
fixed for itself and documented. Small today - the walk is sub-second - but
it is the deadline for the sweep, and these two scripts are not allowed to
disagree about their own accounting.

Left alone deliberately: `sample(items, 1)` returning the alphabetically
first element rather than a representative one. It is real, but it only
reaches `--sample 1` on a linked line, which CI never runs and which the
coverage floor makes near-useless anyway; changing sample() would move every
existing pick and re-seat the seeded-overlap guarantee both scripts share.

Both fixes mutation-checked: reverting to sample() and removing the per-line
spin each turn the suite red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@moritzhartmeier moritzhartmeier left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

All three findings from the previous review are fixed, and the commits that followed closed two false-pass classes I had missed. Verified locally against a real build and the live site.

On my findings. API_LINES renamed in both the workflow and the script header. Two suites added — test-api-reference-seo.cjs (44 assertions, parsers + lib) and test-api-reference-seo-verdicts.cjs (15, verdict branches against a localhost stub) — 59 passing in ~1s, fully offline, both wired into CI. BUILD, ORIGIN, REQUEST_TIMEOUT_MS and currentVersion now live in the shared lib.

The PR-skip split is the right one: the live step is off pull requests, while the two offline test steps stay on them, since those are what actually test the code in the diff.

Two real bugs found after my review, both worth calling out:

  1. servesSymbol. A host-level catch-all redirect made the versioned and unversioned URLs converge on /, samePage compared "/" against "", and every pick read as sound — on a line that did not exist. A false pass on the exact signal this gate verifies. Now shared so the two scripts cannot answer it differently, with three stub tests behind it.

  2. The attr rewrite is justified. I ran the old regex against the new tokenizer, and the old one was genuinely exploitable:

    <link rel="stylesheet" title="href=/evil" href="/real">
    old attr(tag, "href") -> "/evil"
    

    " counted as a word boundary, so an attribute value could impersonate a canonical. The new tokenizer passed all 13 adversarial cases I tried: unquoted values, single quotes, spaces around =, self-closing tags, valueless attributes, uppercase, duplicate attributes, unterminated quotes, and the decoy above.

Also verified. The counterpart cache now drops non-definitive responses, so one 429 no longer poisons a shared pick across every borrowed line. dropArtefact() on all four early returns closes a stale-artefact path the version check cannot see, since the version does not change between builds of one release. spreadAcrossLines works — in my live run /8.5/ and /8.4/ lead the violation list instead of /6.28/ taking all 20 slots.

Measured live: discovery 99 requests / 19 s (matching the figure in the comment exactly), gate 72 / 2 s — 171 requests and ~22 s total, against budgets of 250 and 260 and a 15-minute step backstop. Found /8.3/ /8.4/ /8.5/ and 34 unsound pages.

Two notes, neither blocking:

  • The full lower-major sweep costs 42 → 99 requests and finds the same three lines today, so the extra requests buy nothing at the current shape. The /7.12/-surviving-above-a-gap case it protects against is real and stub-tested, and the step is off PRs now, so the trade is fine — just worth knowing.
  • "... and N more, across the same lines" holds only while the number of lines carrying violations is under the print cap of 20. Five today. Cosmetic.

My earlier point about comment volume still stands — ~4,000 lines for the feature, and the two main scripts carry a lot of first-person revision history. But with 59 tests now pinning the invariants, most of that prose is redundant rather than load-bearing, so I would treat a trimming pass as a cheap follow-up rather than a condition of merge.

Approving.

@moritzhartmeier
moritzhartmeier merged commit 94aee16 into main Sep 18, 2026
5 checks passed
@moritzhartmeier
moritzhartmeier deleted the fix/api-reference-seo-check branch September 18, 2026 14:33
eugenia-scandit added a commit that referenced this pull request Sep 18, 2026
One conflict, in package.json's scripts block: main's #431 added four
api-reference-seo scripts at the same insertion point this branch adds
docs:retrieval-evals. Both sets kept.

That conflict is why this PR has run zero CI checks - GitHub cannot compute a
merge ref for a conflicted PR, so no pull_request run fires and the last green
run on record was two commits back, before the per-page search dedup and the
chip aria-announce fix.

Also gives the evals workflow the concurrency group docs-preview.yml already
has. Every run is a full yarn build plus two eval passes, and this workflow
watches paths a docs PR touches on nearly every push, so without a group they
queue and all run rather than superseding each other. Keyed on the PR number so
runs on main are never cancelled by a PR.

Reported by @raffaelefarinaro in review of #418.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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