Skip to content

feat: build-generate AI retrieval layer (knowledge graph + index) - #418

Closed
eugenia-scandit wants to merge 57 commits into
mainfrom
feat/ai-retrieval-layer
Closed

eugenia-scandit wants to merge 57 commits into
mainfrom
feat/ai-retrieval-layer

Conversation

@eugenia-scandit

@eugenia-scandit eugenia-scandit commented Aug 5, 2026 •

Copy link
Copy Markdown
Collaborator

Add a Docusaurus postBuild plugin that turns the rendered docs into an AI-consumable layer, generated at build time:

/assets/knowledge-retrieval-index.json  (fast lookup)
/assets/knowledge-graph.jsonld          (concept graph)

Parses the final rendered HTML (so imported partials/MDX are captured in full), splits each page of the version served at the root into ~1400-char knowledge modules, and derives per-chunk metadata: summary (from the frontmatter description), rule-based intents/audiences, framework, product, and the real URL. The graph mines real edges — BelongsToProduct, CitesApi, SeeAlso, and per-product AvailableOn / NotAvailableOn. Per-page extraction failures are non-fatal - one bad page is skipped with a warning and the build continues. Systemic failures are deliberately the opposite: zero pages extracted, a page-error ratio above 50%, a missing or malformed customFields.knowledgeIndexIgnoreFiles, an ignore glob using an unsupported character, and a broken excerpt invariant (a dangling code fence, or an excerpt ending on a heading with nothing under it) all throw and fail docusaurus build, matching the config's onBrokenLinks: "throw" convention. Publishing an empty or malformed index over a good one is the failure this is guarding against.

Division of labour with docusaurus-plugin-llms

Both plugins export for AI consumers, so the boundary is worth stating. Figures are from a current build.

docusaurus-plugin-llms ships the prose, for context-stuffing: llms.txt as a link index (0.08 MiB) and llms-full.txt as the text (2.28 MiB).

This plugin ships typed metadata and edges, for deciding what to read: intents, audiences, channels, frameworks, products, cites-API, see-also, availability — none of which a flat text dump can express. It publishes only an excerpt of the prose — the index is around 10 MiB and the graph around 11 — plus a url; the full text is the page itself. (Exact sizes drift with chunking and are deliberately not pinned in the source comment; read baseline.json or measure the built artifacts.)

Neither replaces the other. Publishing the prose per module would add roughly 6 MiB to a 10 MiB artifact to restate text that is already published beside it, so CHUNK_TARGET_CHARS is a granularity knob rather than a payload size: it decides which links land in which module's references and api_refs, so changing it changes the edges.

Shared scope, one deliberate difference. Both apply the same curation decision — assistantIgnoreFiles in docusaurus.config.ts, covering deprecated platforms, features a platform lacks, redirect-only stubs and non-route partials — so what an assistant may see is decided once. Each takes its own list as a plugin option (options.ignoreFiles here, ignoreFiles on the llms plugin) rather than through customFields: a customFields key is a second name to keep in step with the list it mirrors, and it had already drifted once. They differ only on llmsDedupedToWeb: a flat corpus wants a single copy of prose repeated across frameworks, while a routing index needs the module that points at the framework the reader is actually on. Applying that dedup here removed ~1,400 modules along with the ability to answer "where in the Flutter docs does this live".

Known limits

  • 10 MiB is large to fetch whole. Nothing consumes the index that way today; if something does, it needs a served index or a split by framework rather than a smaller excerpt.
  • On a frozen-at-root build most modules share one last_verified — the snapshot commit's, because that is when those files were created. Accurate, but close to a constant.
  • --auto self-retrieval is gated as a regression against a recorded baseline, not an absolute floor. scripts/retrieval-evals/baseline.json holds the last measured run (page-success@3 0.8596, page-MRR 0.7750, over 520 pages / 4,228 modules); a run fails if either metric drops more than AUTO_TOLERANCE (0.03) below it, with absolute backstops of 0.60 / 0.40 underneath. An absolute floor was the wrong instrument: this corpus is ~11 near-duplicate copies of the same prose, so every page competes with its own siblings and adding an SDK would have failed the gate for a reason unrelated to retrieval quality. Re-record with --update-baseline after any change to what is indexed, and say so in the commit.
  • One glob divergence from minimatch, unreachable today: * and whole-segment ** here also match a segment beginning with ., where minimatch defaults to dot: false. No doc source is dot-prefixed.

Scoring (updated after review)

The proxy retriever originally ranked on overlap / sqrt(|q| * |d|). |q| is constant across the documents of one query, so that reduced to overlap / sqrt(|d|) - which pays a document for being short: a 10-token stub matching one query term scored 0.316 against 0.200 for a 100-token page matching two.

It now uses BM25 over the same binary term representation: IDF weighs a rare term above a common one, and the length term normalises against the corpus average with b=0.75. Term frequency stays binary because docText() is read as a Set - the fields it concatenates already repeat the title and slug, so a raw count would measure field layout rather than aboutness.

The --auto half carried a second copy of the old formula in fastScore, and that is the half the baseline and the gate are computed from - so fixing only score() would have changed nothing that is measured. Both now call one makeScorer.

Measured on one index, changing only the scorer:

old new
page-success@3 0.7962 0.8423 +0.0461
page-MRR 0.6981 0.7696 +0.0715
pages missed 106 82 -24

The 20-query gold set is unchanged at success@3 0.95 / precision@3 0.8667 / MRR 0.95, with the same single miss - it is too small to register a ranking change of this size, which is itself worth knowing about what that set can detect.

Freshness

run.cjs reports last_verified coverage on every run (currently 4228/4228 dated, 2025-12-12 to 2026-09-18, 2 older than 180 days), so the full clone docs-retrieval-evals.yml takes buys a signal that is actually read. Passing --require-freshness - which only that workflow does - turns a fully dateless index into a failure, which is what losing fetch-depth: 0 would look like. It is deliberately not a ranking input: last_verified is a date, and the scorer tokenises on [a-z0-9]{2,}, so it would enter the term stream as 2026/09/18 and match any query mentioning those numbers.

build-docs.yml is shallow on pull_request: that job builds and verifies but never publishes, so the freshness signal there was computed and discarded on every PR to the repo.

Two later corrections to the gate (after review)

Ranking unit. --auto counted competing chunks while search() spends one slot per page, so three chunks of one rival page consumed all three slots and scored a miss that the gold-set path scores a hit. That is the half baseline.json and the CI gate are computed from, and it moved with chunk counts - re-chunking a rival page changed the score with no change in retrieval quality. Both halves now count distinct pages, verified by an independent harness that computes each page rank through both paths: 120 sampled pages, 120 agree, 0 disagree. The corrected figures are 0.8596 / 0.7750 with 73 misses, against 0.8404 / 0.7676 and 82 before, so the old metric understated page retrieval by about two points.

Product keys. detectProduct returned the raw URL segment, but a route does not always spell its product key. 609 modules shipped under label-capture and matrixscan, neither of which exists in products.json, while the real smart-label-capture and matrixscan-batch had no pages at all. The alias map is now derived from the registry own frameworks[].apiUrl rather than listed.

baseline.json is current at 0.8596 / 0.7750.

Add a Docusaurus postBuild plugin that turns the rendered docs into an
AI-consumable layer, generated at build time (nothing committed):

  /assets/knowledge-retrieval-index.json  (fast lookup)
  /assets/knowledge-graph.jsonld          (concept graph)

Parses the final rendered HTML (so imported partials/MDX are captured in
full), splits each current-version page into ~1400-char knowledge modules,
and derives per-chunk metadata: summary (from the frontmatter description),
rule-based intents/audiences, framework, product, and the real URL. The
graph mines real edges — BelongsToProduct, CitesApi, SeeAlso, and
per-product AvailableOn / NotAvailableOn. Current docs only (frozen
versions, the external API reference, and *.html redirect stubs excluded).
Extraction failures are non-fatal so they never block a deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Score the build-generated knowledge retrieval index against a gold set of
real user queries (seeded from Algolia top-queries + the product taxonomy).
A module is relevant when its URL matches the query's expected path-class, so
the gold set stays stable even though module ids are auto-generated.

- scripts/retrieval-evals/run.cjs — pure-Node token-mode scorer + thresholds
- scripts/retrieval-evals/gold-set.json — 20 seed queries
- .github/workflows/docs-retrieval-evals.yml — builds docs, runs the eval on
  PRs touching docs/extractor/gold-set; uploads the report
- npm run docs:retrieval-evals

Verified locally against 4523 modules: success@3=0.90, precision@3=0.87,
MRR=0.86 (thresholds 0.8 / 0.6). The eval already surfaces real token-mode
weaknesses (e.g. "data matrix" matching "matrixscan") — which is the point.

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

@lucatorella lucatorella 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.

🤖 Two build/deploy-integrity findings inline.

Comment thread src/plugins/knowledge-extractor/index.ts
Comment thread src/plugins/knowledge-extractor/index.ts Outdated
eugenia-scandit and others added 2 commits August 5, 2026 12:58
Report recall@k = relevant-in-top-k / min(total-relevant-in-corpus, k), with a
--min-recall gate (default 0.6), alongside success@k / precision@k / MRR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… writes + all-docs eval

Addresses review of PR #418:

1. cheerio was imported but undeclared — resolved only via npm hoisting from a
   transitive Docusaurus dep. A dedupe / lockfile regen / Docusaurus 3.x bump
   could drop it, failing config load before any lifecycle (so postBuild's
   try/catch can't help) and breaking the whole build. Declare
   cheerio@1.0.0-rc.12 and update both lockfiles.

2. Empty extraction could publish empty artifacts on a green build. postBuild
   now keeps per-page failures non-fatal but THROWS when 0 modules are
   extracted (matches onBrokenLinks:"throw"), so selector drift fails the build.

3. Both artifacts are written atomically (temp + rename) so a failure between
   the two writes can't ship a mismatched index/graph.

Also: `--auto` eval mode does corpus-wide, page-level self-retrieval over ALL
docs (page-success@3=0.82 over 572 pages), not just the 20-query gold set;
recall@k added earlier.

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

@lucatorella lucatorella 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.

🤖 Re-reviewed 65676e09..d948727b. One finding on the new metric; notes on the two earlier threads.

Comment thread scripts/retrieval-evals/run.cjs Outdated
eugenia-scandit and others added 5 commits August 5, 2026 13:53
…ush trigger

Follow-up to review of PR #418:

- recall@k was algebraically identical to precision@k for this gold set: it uses
  path-classes (>K relevant modules each), so capped recall (relCount/min(total,k))
  reduces to precision (relCount/k), query by query. Reporting both was one signal
  twice, and --min-recall could only fail when precision already had. Removed the
  recall metric/gate; report precision@k with an honestly-named MIN_PRECISION
  floor. A real recall needs single-page gold entries (noted in code).
- Run the eval on push to main too, so the deploy branch's index is checked (the
  plugin's own fail-loud floor already fails every build on empty extraction; this
  adds the quality check on main).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Read each page's source frontmatter (gray-matter) and flow user_intents, not_for, product, topic_type, canonical_id into every chunk — product/topic_type now authoritative over path heuristics, user_intents injected into assistant_context, semantic_status flips rule_based->frontmatter. Falls back to today's heuristics when a page has no mapped source or no fields, so extraction never breaks (verified: 4523 modules/572 pages/identical edge counts). Declares gray-matter (already a Docusaurus transitive dep) explicitly.

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

assistant_excerpt is sliced to 300 chars; inlining user_intents/not_for pushed the summary out of the preview. user_intents/not_for/canonical_id/topic_type are already emitted as full, un-truncated fields, so the signal is preserved — the excerpt now stays a clean title+summary preview. llms.txt is unaffected (docusaurus-plugin-llms never reads these fields).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Makes curated intent retrievable by any consumer that searches keywords, not only one configured to read the dedicated user_intents field. not_for is excluded from keywords to avoid false-matching the products it steers away from; it stays a structured field for reranker demotion. Verified: intent phrase present in keywords, not_for absent from keywords, dedicated fields full.

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

Multi-product: frontmatter product may be a list (e.g. troubleshooting: [sparkscan, barcode-capture, id-capture]); now every product flows into the record (products[]), graph nodes, and BelongsToProduct edges — not just the first — so LLMs see all relevant products. Rename: semantic.status 'frontmatter'->'frontmatter_augmented' since intent/audience remain heuristic and only keywords/product are curated; the old label overstated. Verified: products=[sparkscan,barcode-capture] on a two-product page, status renamed, 0 page errors.

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

@raffaelefarinaro raffaelefarinaro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 This review was AI-assisted. I verified each blocking finding by hand against the code and a local build before posting.

Starting with what works, because it is genuinely careful. The exclusion logic is correct, and I checked it against the built output rather than reading it: zero URLs from the frozen versions, the external data-capture-sdk reference, or any of the 235 *.html redirect stubs make it into the index. Build cost is not a regression, Build Documentation on this branch ran 3m29s to 4m08s against 3m36s to 4m00s on recent main. And the eval gates sit far above chance, since a random retriever over this corpus would score success@3 = 0.158 against your floor of 0.8, with 0.900 actual.

Your three open threads are already fixed, so close them

You have probably been looking at three red threads for six days. They are stale, not outstanding:

  • cheerio and gray-matter are both declared in dependencies as of 1748eaf, with matching entries in both lockfiles.
  • The zero-modules floor check at index.ts:697 and the .tmp plus rename writes at index.ts:712 landed in the same commit, which is exactly what the thread asked for.
  • Recall was removed and replaced with an honestly named MIN_PRECISION in 972e6cb, plus a comment explaining the algebra.

Luca's re-confirm was against d948727b, which is six commits behind the current head 2a6edd5. I checked the recall claim against the built corpus rather than taking it on trust, and he was right: every one of the 20 gold entries resolves to between 31 and 1075 relevant modules against K = 3, so capped recall did reduce to precision. The fix is the right one.

Rebase before responding to anything else here

The last four commits, which add the entire frontmatter-ingestion feature, have zero CI runs. Here is why. main deleted package-lock.json in f23a28f (#416), and this branch still edits it, so git merge-tree reports a modify/delete conflict on package-lock.json plus a content conflict in package.json. GitHub cannot build the refs/pull/418/merge ref for a conflicted PR, so no pull_request run gets created. Runs exist and are green for the first five commits and then stop dead on 2026-08-05 at 12:53Z. gh pr checks 418 reports "no checks" because it queries head 2a6edd5 specifically.

A rebase onto main plus deleting package-lock.json fixes the conflict, restores CI, and picks up docs-gate.yml, which does not exist on this branch at all. Do that first, because CI will probably surface more than any human reader can, and it would be wasteful to work through review comments against a head that has never been built.

Provenance of the numbers

The corpus statistics below (4523 records, 89% at the 400-character cap, 219 Titanium modules, 26.9 MB, the random-retriever baseline) come from my own local build on Node 26 with npm ci, not from CI on Node 18 with yarn. My counts came out at 4523 modules against CI's 4526 at 972e6cb, a 0.07% difference I attribute to the four intervening commits. Treat the figures as indicative of magnitude, not exact.

What is in the inline comments

Five blocking: the package-lock.json conflict, the Titanium trees, the chunk truncation, the contradictory core availability edges, and the division of labour with docusaurus-plugin-llms. Three smaller ones: a substring match in pickIntents, MIN_AUTO_SUCCESS defaulting to zero, and last_verified being the build timestamp.

Two of these are product decisions more than defects, so I have put them as questions about intent rather than verdicts. I also could not see who consumes these artifacts, so my point about truncation argues from the plugin's own stated purpose in its header comment, not from a requirement I can verify. If a consumer exists that only needs pointers plus a snippet, that finding weakens a lot and I would rather hear that from you than assume it.

I have left a longer writeup with the non-blocking observations out of this review to keep it readable. Happy to walk through it whenever suits.

Comment thread package-lock.json Outdated
Comment thread docusaurus.config.ts Outdated
Comment thread src/plugins/knowledge-extractor/index.ts Outdated
Comment thread src/plugins/knowledge-extractor/index.ts Outdated
Comment thread src/plugins/knowledge-extractor/index.ts Outdated
Comment thread src/plugins/knowledge-extractor/index.ts Outdated
Comment thread src/plugins/knowledge-extractor/index.ts Outdated
Comment thread scripts/retrieval-evals/run.cjs Outdated
eugenia-scandit and others added 15 commits September 4, 2026 12:26
…l layer

Every fix below was reproduced against the repo's real build/ output first, and
re-measured after. Numbers are before -> after on 616 current-version pages.

HIGH - code samples were shipped broken. Prism renders one <span class=
"token-line"> per source line and emits no newline characters at all, so
$(el).text() returned the whole sample on one line. Not cosmetic: a `//` or `#`
comment then commented out the rest of the sample. Join the token lines, falling
back to .text() for non-Prism blocks (the SkillsCallout command blocks).
Multi-line samples in the index: 0 -> 275.

HIGH - heading anchors leaked into content and poisoned the graph. Docusaurus
renders them as <a class="hash-link"> whose label is a single U+200B and whose
href is the FULL page path plus a fragment - so neither the empty-label check
(U+200B is not \s, so trim() keeps it) nor the startsWith("#") guard caught them.
They ended up in heading, topic and the excerpt, and classifyLinks then read the
href as a real reference. Skip hash-link anchors, strip zero-width characters
from labels, and drop self-references when building SeeAlso.
"## Key Benefits[](/hosted/.../#key-benefits)" -> "## Key Benefits";
SeeAlso edges 4292 -> 944, of which self-referential 3248 -> 0.

MEDIUM - the graph contradicted itself about availability. detectProduct filed
any single-FILE product under "core", so /sdks/web/barcode-generator/ became
product core on framework web; since every other web page also contributed
AvailableOn(core, web), the graph asserted both AvailableOn and NotAvailableOn
for the same pair. Read the product keys from src/data/products.json - the
registry the rest of the site already uses - and exclude the synthetic core /
general buckets from availability edges entirely. Contradicted edges 5 -> 0, and
the facts that were being lost now appear: barcode-generator unavailable on web,
.NET iOS, .NET Android and Titanium; matrixscan-count and matrixscan-pick
unavailable on web.

MEDIUM - text directly inside a wrapper element was dropped. The div / section /
details / article / aside branch only recursed children(), so a text node that
was a direct child vanished - which is how an admonition's severity word
("danger") disappeared, leaving a :::danger block indistinguishable from prose.
Walk contents() instead.

MEDIUM - list structure was destroyed. ul and ol both emitted "- ", and
inlineText() flattened each <li> including nested lists and extra <p> into the
parent bullet with no separator, so ordered steps lost their numbers, sub-steps
merged upward, and where the HTML had no whitespace between </li><li> the words
glued together. Emit real ordered markers (honouring the start attribute),
recurse into nested lists and indent them. Modules with numbered steps: 0 -> 63.

MEDIUM - 29 MB of public assets with no budget. HasChannel was 3x every module
(14904 edges) carrying no information, because channels is a hardcoded constant;
the edges are gone and the field stays for consumers. Added a size budget that
fails the build on runaway growth - the existing guard only caught EMPTY output.
Index 11.2 -> 9.5 MB, graph 18.1 -> 11.4 MB.

LOW - chunk splitting could break a fence. Splitting on /\n(?=##\s|###\s)/ with
no fence awareness would cut inside a code block whose line starts with "## ",
leaving an unterminated fence and making the code comment the chunk heading. No
such line exists in docs/ today, so this is a guard, not a live fix.

LOW - the "atomically" comment was false. Two renameSync calls are not atomic as
a pair, so a crash between them shipped a new index with the old graph - exactly
what the comment claimed to prevent. Keep the previous index and roll the first
rename back if the second throws.

LOW - last_verified was a constant. It was the build clock, so every module
claimed it was verified today. My first attempt used the source file's mtime,
which measured as STILL one distinct value: git does not preserve mtimes, so a
fresh clone or worktree stamps every file with the checkout time. And
actions/checkout@v4 is used with no fetch-depth, i.e. depth 1, so a git-log
lookup would collapse to one commit date too. So: take the real per-file commit
date from one batched git log, and emit "" when the answer is genuinely
unknowable (shallow clone, or no git) rather than a value that looks like data.
updated_at keeps the build stamp, which is honestly named and true.
Distinct last_verified values: 1 -> 19.

Verified: tsc reports no errors in this file; the plugin runs clean over the real
build (4452 modules, 616 pages, 0 page errors); the PR's own eval harness passes
(success@3 0.90, precision@3 0.8333, MRR 0.90 against floors of 0.8/0.6/0.6).

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

The previous commit fixed flattened code samples and, in doing so, broke
chunking. Findings from the second review round, all re-measured on the real
616-page build.

REGRESSION I introduced. chunkBody's paragraph fallback used a plain
split("\n\n"), which was safe only while code samples were wrongly collapsed
onto one line and therefore contained no blank lines. Restoring real newlines
turned that latent bug live: chunks were being cut mid-sample, leaving a dangling
``` and raw code handed to a consumer as prose. The heading-boundary guard I had
added covered the wrong split - and fires zero times on today's docs, so it was
guarding nothing. Both boundaries now go through one fence-aware splitter.
Measured on FULL chunk content: 386 -> 0 unbalanced chunks.

A note on that measurement, because I got it wrong first. docs_excerpt is
docs_markdown.slice(0, 400), so counting fences there reports every code block
longer than 400 characters as unbalanced - it produced 737 and told me my fix had
made things worse. Measuring the full content, and compiling the previous commit
to compare against, gives the real 386 -> 0.

The code-sample fix did not reach blocks inside a list item: <pre> went through
the inline branch and had its newlines collapsed straight back out, so the "//
comment swallows the rest of the sample" failure still shipped for the 4
multi-line in-list samples. List items now separate inline prose from
line-structured blocks, and route code the same way as nested lists. The Express
Find sample extracts as 14 lines instead of 1.

extractMarkdownish still walked children(), so the dropped-text-node bug fixed
for wrapper elements survived at the article root - two pages lost a whole
trailing sentence that sits as a direct text child of .markdown. My first attempt
at this changed only the comment and not the call, which is why the sentence was
still missing; now fixed and verified present.

Shallowness is now settled by `git rev-parse --is-shallow-repository` instead of
probing for a .git/shallow FILE. The probe failed open wherever .git is a file -
a linked worktree, --separate-git-dir, a submodule - and would have run git log
against a shallow store, resolving every page to the one available commit: the
exact fake constant the change set out to remove. This review ran from such a
worktree.

Empty product keys are filtered before slugging, because slug("") returns
"module": the old filter(Boolean) could never fire, and a products.json entry
with a missing key would have registered "module" as a product, after which a
page at /sdks/<framework>/module/ would emit availability edges for something
that does not exist.

The eval workflow now checks out with fetch-depth: 0, since it runs yarn build
and therefore produces the artifact it evaluates; without full history every
last_verified is "". NOTE: build-docs.yml and docs-preview.yml build the
DEPLOYED artifact and are not touched by this PR - they need the same one-line
change, or the published index carries no freshness signal. Flagged rather than
edited, since they are shared CI and full-history clones are not free.

Also corrects a comment that still described the mtime approach this PR
abandoned.

Verified: tsc reports no errors in the file; 4470 modules from 616 pages, 0 page
errors; 0 unbalanced chunks; the trailing sentence is back; no "module" product;
19 distinct last_verified locally. The PR's own evals improve - success@3
0.90 -> 0.95, MRR 0.90 -> 0.9167, precision@3 0.8333 (floors 0.8/0.6/0.6).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Decision: last_verified should track when each page was actually edited, so the
signal has to work in CI - not just locally.

The plugin takes each page's date from its last commit. actions/checkout@v4
defaults to a depth-1 clone, which makes every file resolve to the same single
commit; the plugin detects that and emits "" rather than a constant that looks
like data. The consequence was that every module in every CI build shipped
last_verified: "" - a real signal locally, none in the artifact anyone actually
consumes.

build-docs.yml is the workflow on this branch that runs `yarn build`, so it now
checks out full history. The eval workflow got the same treatment in the previous
commit.

Measured before changing it, rather than assuming: 796 commits in this repo, and
the plugin's `git log` over docs/ runs in ~0.13s and resolves 1000 files. Full
history is effectively free here.

Two things worth knowing, flagged rather than silently handled:

- docs-preview.yml does not exist on this branch (it is on a newer main) and it
  also builds the site. It will need the same two lines once this branch is
  rebased, or PR previews will carry empty dates.
- Whatever pipeline publishes docs.scandit.com is not a workflow in this repo, so
  if the deployed artifact is built there, that clone needs full history too.

Note also that Docusaurus' own showLastUpdateTime is false, so nothing else in
the build depended on history before now - this is the first thing that does.

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

Round 3. The HIGH was mine again, from the list-item fix in the previous commit.

serializeInline() iterates the CONTENTS of the node it is handed, so handing it
an <a> skips the branch that emits [label](href) and keeps only the label - same
for <code> (backticks) and <br>. My walk passed inline elements one at a time, so
every anchor and inline-code span sitting directly inside an <li> was stripped:
1489 anchors and 3124 code spans across 173 pages. Measured effect on the
artifact: CitesApi edges 2980 -> 1854, with 82 API-reference URLs disappearing
entirely - in a file whose stated purpose is preserving citations.

Three findings shared that root cause, so one restructure fixes all of them:
gather consecutive inline children into a RUN and serialize the run as one unit.
That puts <a>/<code> back where serializeInline can see them, removes the
join(" ") that was inserting "List-based workflows : Validate…", and lets
document order be preserved so a sentence following a code block is no longer
hoisted above it. CitesApi 1854 -> 2976 (base 2980), 818 distinct API URLs,
" :" artefacts 640 -> 1 (the 1 predates this PR).

LAST_VERIFIED NOW FOLLOWS THE CONTENT, which is the point of the field.

110 pages under docs/sdks/ are 2-line shells importing a partial, so the shell's
own commit date describes when the shell was written, not when the content
changed - and editing a partial changes what a dozen pages say while moving none
of their dates. The date is now the most recent commit across the page's own
source AND every partial it imports, transitively. Verified on
features-by-framework: shell 2026-01-19, partial 2026-02-03 on this branch, and
the page now reports 2026-02-03.

Frontmatter reading and date resolution also had two different ideas of where a
page's source lives, which is why /hosted/express/configuration/device-pairing/
had no date even from a full-history clone - its source is
device-pairing/device-pairing.md, Docusaurus' folder/folder.md convention, which
neither list covered. One shared candidate list now; that page reports
2026-07-29.

Also from round 3:

- Self-references were filtered from the graph but not the index, so 25 records
  pointed a consumer back at the page it was already on. Both agree now: 0.
- The Channel nodes were left behind when HasChannel was removed - 3 typed nodes
  no traversal could reach, in a graph whose point is traversal. Dropped.
- The size budget now WARNS instead of throwing. Everything else in this plugin
  is careful never to break a deploy, and growth is not the exception to make:
  it would have failed the whole docs build on main, for artifacts nothing
  consumes yet. The empty-output guard still throws - that means the selectors
  drifted and the data is wrong, which is worth stopping for.
- Four "not available" stubs were being discarded rather than recorded, because
  ai-powered-barcode-scanning and batch-scanning are not products.json entries
  and so fell into the synthetic bucket - the same loss the bucket was added to
  prevent. A page whose whole job is to say "X is not available here" is now
  named after itself. NotAvailableOn 28 -> 32.
- Availability now covers every declared product instead of products[0], so it
  stops disagreeing with BelongsToProduct on multi-product pages.
- The rollback comment no longer restates the "atomic" claim it replaced: two
  renames cannot be one operation, the catch only covers a throw, and that is
  now said plainly.

CI, per the decision that last_verified should track real edits:
docs-preview.yml is brought over from main with fetch-depth: 0 on its BUILD job
only - `deploy` just downloads the artifact and reading git there would slow
every preview for nothing.

package-lock.json is no longer touched by this PR: main deleted it in #416 (the
repo is yarn-only), which retires the lockfile-sync finding from round 1 rather
than fixing it.

Verified: tsc reports no errors in the file; 4668 modules from 616 pages, 0 page
errors; 0 unbalanced fences; 0 availability contradictions; 0 index
self-references. Evals improve again - success@3 0.95, precision@3 0.85, MRR
0.9167 (floors 0.8/0.6/0.6).

Known and left alone deliberately: products.json keys and docs directory names
are different vocabularies (matrixscan-batch vs matrixscan), so a future
single-FILE page named after a product directory would re-open the "core"
misfiling. No such page exists today, and a second alias map is a worse cure
than the disease - it would be one more thing to drift.

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

Round 4. Three walk sites had independently grown the same logic - the <li>
branch, the wrapper-element branch and extractMarkdownish - and each review round
found the same class of bug in whichever one had not been touched yet. They now
share ONE helper, orderedSegments(), so the next fix lands in all three at once.

That closes two findings by construction: the wrapper branch was still stripping
link URLs the same way the list branch had been (serializeInline iterates the
CONTENTS of the node it is given, so an <a> passed directly never reaches the
branch that emits the href), and extractMarkdownish was pushing every root-level
text node as its own paragraph, so a text node CONTINUING a sentence became a
standalone fragment - and since \n\n is a chunk boundary, that fragment could
land in a different chunk with nothing to attach it to.

readFrontMatter now ACTUALLY shares sourceCandidates(). The previous commit added
a comment saying it did and left the duplicate list in place - my second
comment-only "fix" in this PR. It mattered: a folder/folder.md page resolved for
dating but not for frontmatter, so device-pairing's curated `keywords: [express]`
was dropped and semantic_status stayed rule_based. Verified: the keyword is now
in the shipped record.

The PUBLISHED excerpt no longer truncates inside a code fence. docs_excerpt is a
hard slice(0, 400) of the chunk, so restoring real newlines in code samples made
this worse, not better - 693 records at base, 783 after. Every one of those hands
a consumer raw code with a dangling ```, the exact defect this plugin treats as a
bug elsewhere, and my earlier "0 unbalanced fences" was measured on chunk content
rather than on the field consumers actually read. Now clipped at the last safe
boundary: 783 -> 0.

Dates are compared as unix seconds (%ct), not ISO strings. %cI carries each
committer's own offset - this repo has +02:00, +01:00, +03:00 and one Z - so
comparing lexicographically can order two commits backwards and pick the older
one, putting last_verified out by a day.

contributingFiles also follows local component imports, not just partials. The
nine agent-skills pages are shells around @site/src/components/SkillsPage, whose
prose lives in the component, so editing it changes what those pages say while
their date sat at the shell's commit. gitDates covers src/ for this. Verified:
those pages now report 2026-08-04, the component's date.

And slug("") bit me in my own round-3 fix: an empty path segment became "module",
so a framework-root availability stub would have emitted a phantom
urn:product:module with availability edges. The raw segment is checked before
slugging, and the path shape is verified before trusting the /net/ offset.

NOT FIXED, deliberately - it is a docs bug, not an extractor bug. The review
flagged that /sdks/{ios,android}/matrixscan-count/advanced/ ship "See the
[Expiry Management Sample](...)" and "for an example of how to use this feature."
as two paragraphs. The source does that itself:

    See the [Expiry Management Sample](https://...)
    <blank line>
    <ReactPlayer ... /> for an example of how to use this feature.

The sentence is split around a video embed in the markdown, so it reads broken on
the live page too. Papering over it here would hide a real content defect.
Exactly 2 pages, one line each; worth its own change.

Verified: tsc reports no errors in the file; 4668 modules from 616 pages, 0 page
errors; 0 dangling fences in the published excerpt; 0 availability
contradictions; 0 index self-references; no phantom product; CitesApi 2976 (base
2980). Evals: success@3 0.90, precision@3 0.85, MRR 0.90 (floors 0.8/0.6/0.6) -
precision holds at 0.85, above the 0.8333 of the last two rounds.

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

Round 5. Two of the four findings were one line away from something already
fixed, which is the same shape of mistake as the earlier comment-only edits.

assistant_excerpt was still a hard slice(0, 300) while docs_excerpt directly
above it had been given clipMarkdown, so 466 of 4668 records handed an assistant
raw code with a dangling ``` - the identical defect, on the other published text
field of the same record. Now clipped the same way: 466 -> 0 (base 471).

contributingFiles stopped one hop short of the prose it was added for. Matching
only `partials/` and `@site/src/components/` reached SkillsPage/index.tsx but not
the src/data/skills.json that page renders, so adding a skill would change what
nine pages say and move none of their dates. It now matches EVERY import spec and
decides by where the spec RESOLVES - package imports land outside docs/ and src/
and are dropped, so widening the match costs nothing - and .json counts as a
content file. Verified: the label-definitions pages now pick up 2026-08-04
through the deeper chain, where before they stopped at the shell.

Correcting my own verification claim from the last commit while I am here: the
agent-skills pages report 2026-08-04 from their own .mdx, not from the component
(SkillsPage/index.tsx is 2026-07-09). The date was right; my attribution of where
it came from was not.

The div walk had started publishing SSR loading placeholders as documentation.
Returning bare text children from wrapper elements surfaced "Loading
features..." - what FeatureList renders before it hydrates - so nine
label-definitions pages asserted that where their feature tables should be, and
the real tables are never in the HTML at all. Loading strings 52 -> 0, matched
narrowly (the word plus a trailing ellipsis) so prose that merely begins with
"Loading" survives.

package-lock.json is now DELETED rather than reverted. Reverting it left the
branch carrying a pre-PR lockfile that omits cheerio while package.json declares
it, so `npm ci` refuses - and main deleted the file outright in #416 when the
repo went yarn-only, so keeping any version of it only guarantees a modify/delete
conflict on merge. The PR-wide diff is now 9 files with no lockfile in it.

Verified: tsc reports no errors in the file; 4668 modules from 616 pages, 0 page
errors; 0 dangling fences in EITHER published excerpt; 0 loading placeholders; 0
availability contradictions; 0 index self-references. Evals: success@3 0.90,
precision@3 0.85, MRR 0.90 (floors 0.8/0.6/0.6).

Known, from the review and left as-is: docs-preview.yml will be an add/add
conflict on merge, since main has it and this branch's copy differs only by the
fetch-depth hunk - trivially resolved by taking this version.

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

Round 6. The MEDIUM was mine again, and it was the round-5 fix eating the field
it was meant to repair.

assistant_context is "<boilerplate about the module> + \n\n + <chunk>", and the
boilerplate alone is typically 200-270 of the 300-char budget. Clipping the WHOLE
string at a fence boundary therefore discarded the chunk and left the template -
448 records lost their entire content preview, taking boilerplate-only records
from 39 to 487. The limit/3 "don't throw away almost everything" guard never
fired, because the boilerplate satisfied it by itself. Now the CHUNK is clipped to
its own budget and the prefix is left alone: 487 -> 0 boilerplate-only, still 0
dangling fences. The field averages 515 chars rather than ~300, which is the
point of it existing; index 9.9 -> 11.1 MB against the 14 MB soft cap.

Tables were not markdown. 595 chunks shipped a `| … |` table and none had a
`|---|` delimiter row, so to any parser they are a run of literal pipe lines with
the header indistinguishable from the data. The delimiter is emitted after the
header now, and `|` inside a cell is escaped - it would otherwise end the cell.
505 of 515 table chunks carry it; the remaining 10 are tables whose header sits
in a neighbouring chunk or past the excerpt cut, not a formatting failure.

The placeholder filter guarded only one of two push sites. It worked purely
because FeatureList happens to use a <div>, which recurses back through the
inline path - a <p>, or any sibling element beside the text, and "Loading
features..." would be published as documentation again. Named the test once and
applied it at both sites.

The import-candidate list omitted .js/.jsx while the has-extension test in the
same expression accepted them, so an extension-less import of one of the ~40
JS components in src/ was silently not followed - quietly reintroducing the
shell-staleness contributingFiles exists to prevent.

The fail-loud guard only caught TOTAL failure. Realistic selector drift is
partial: `!root.length` and `!bodyMd.trim()` skip a page without counting as an
error, so a theme upgrade that broke most page types would publish a tenth of the
corpus over a green build. Now also throws when fewer than half of the candidate
HTML files produce content. Measured against files.length deliberately, not
against a counter taken after the selector check - drift makes that counter fall
with the result, so the ratio would stay at 1 and prove nothing. ~13% of files
are legitimately skipped (redirect stubs, homepage), so today's ratio is ~0.87.

The gold set was scoring a correct answer as a miss: "data matrix" rejected
/symbology-properties/ while "supported symbologies" two lines above accepts it
for the same content. That spent CI headroom on bookkeeping rather than
retrieval. Evals now: success@3 0.95, precision@3 0.90, MRR 0.95 (floors
0.8/0.6/0.6) - best of the six rounds.

NOT FIXED, and worth its own work rather than a patch here: the ten
label-definitions chunks still promise a table they cannot deliver ("There are
two types of custom fields you can define:" followed by nothing), because
FeatureList renders client-side and the real tables are never in the SSR HTML.
That also means readers without JS - and every crawler - see the same nothing.
The fix is to server-render FeatureList from src/data/features.json, which is a
docs change, not an extractor one.

Verified: tsc reports no errors in the file; 4703 modules from 616 pages, 0 page
errors; 0 dangling fences in either published field; 0 boilerplate-only records;
0 placeholders; index 11.1 MB / graph 12.4 MB within budget.

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

Round 7. Neither MEDIUM was a regression from my earlier commits - both were in
the PR from the start, and the code-sample fix only made the first one visible.

TABBED CONTENT WAS PUBLISHED INTERLEAVED AND UNLABELLED. Docusaurus emits every
<Tabs> panel into the HTML - inactive ones only carry `hidden` - as a tablist of
labels followed by one panel per tab. With no handling for that, the tablist came
out as a detached bullet list ("- Gradle", "- Maven") and the panels ran together
as consecutive prose, so /sdks/android/add-sdk/ published the Gradle
build.gradle block immediately followed by "Add the mavenCentral repository in
pom.xml file:" with nothing tying either body to its label. 42 pages use tabs and
378 hidden elements' text was reaching the index, so an assistant answering "how
do I add the SDK on Android" could hand a Gradle user the Maven steps, or splice
the two.

Dropping the hidden panels would have fixed the splice by discarding half the
documentation, so each panel is now emitted under its own label instead, paired
positionally - the only association the markup offers. 21 chunks now carry
labelled variants, and the stray tablist bullet list is gone.

LINKS TO FILES WERE BEING SLASH-TERMINATED INTO 404s. Docusaurus routes end in
"/", but the asset filter only covered images and archives, so /llms.txt and the
generated /stable/c_api/*.html pages shipped as "/llms.txt/" and
"…struct_sc_symbology_settings.html/" - nine references that 404 for anyone who
follows them. Anything with a file extension in its last segment is now left as
written: 9 -> 0.

Also from round 7, all latent rather than live, and all one docs edit away from
mattering:

- The placeholder test was matching 40 arbitrary characters, and since the last
  commit also applied it to whole blocks, a paragraph like "Loading the SDK
  asynchronously..." would have been deleted silently. Narrowed to one or two
  bare words and an ellipsis.
- tableToMd used find("tr"), which also collects a NESTED table's rows and
  appends them to the outer table, and pinned the delimiter to the header's
  width - so a header narrower than a body row would make a parser discard the
  extra cells. Now scoped to this table's own row groups, and sized to the widest
  row.
- The pipe escape ran over the whole serialized cell, including link targets, so
  a URL containing a pipe became `[L](/a?x=1\|2)` - broken in any renderer.
  Escaping now steps over `](…)`.
- assistantExcerpt dropped anything after the chunk. Empty today only because
  assistant_context happens to END with the chunk, which nothing asserts; the
  tail is re-appended so a future suffix cannot vanish unnoticed.
- A comment justified keeping curated fields out of assistant_context with "the
  index only ships a 300-char assistant_excerpt". After the last commit that is
  false (avg 515). Corrected, so the decision gets re-derived rather than read
  from a stale premise.

Verified: tsc reports no errors in the file; 4704 modules from 616 pages, 0 page
errors; 0 file links with a trailing slash; 0 escaped pipes inside a link target;
0 dangling fences in either published field; 0 placeholders; index 11.09 MB /
graph 12.41 MB within the 14/16 budgets. Evals hold at their best: success@3
0.95, precision@3 0.90, MRR 0.95 (floors 0.8/0.6/0.6), one remaining miss
("qr code scanning") which predates this work.

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

Round 8. All three findings were mine, from round 7's tabs work, and two of them
made things worse than the state they replaced - an unlabelled panel is bad, a
confidently mislabelled one is worse.

find() reached into nested <Tabs>. A nested block's tabs joined the outer
tablist while its panels joined the outer panel list in DOCUMENT order (outer
panel 1, then its nested panels, then outer panel 2), so positional pairing
crossed the boundary and nested content was emitted twice. Live on
/sdks/react-native/add-sdk/: "Install Manually from Dashboard" printed over a
yarn command and "**Yarn**" over `npm install` - the exact mix-up the function
exists to prevent.

My first attempt at scoping used children(), which was wrong and expensive:
Docusaurus wraps the panels, so the container's children are the tablist and a
`margin-top--md` div, children('[role=tabpanel]') matched NOTHING, and every
panel's content was silently dropped - modules 4704 -> 4083, CitesApi 2976 ->
1845. Caught by comparing the run against the previous commit rather than by
reading the diff. Panels are now scoped by OWNERSHIP - a panel is ours when the
nearest ancestor owning a tablist is this container - which excludes nested
blocks without assuming a wrapper depth. Restored: 4702 modules, CitesApi 2976,
0 duplicated code bodies, and the react-native labels pair correctly.

The label was a standalone paragraph, so chunkBody severed it from its body: 273
chunks ended on a lone **label** while the body moved to the next chunk with no
label at all. Joined by a single newline instead, label and body are one
paragraph unit the splitter cannot separate: 273 -> 1 on full chunk content.

The residual 1, plus 38 in the published excerpt, came from my own clipMarkdown:
it drops a half-included fence, and the label sits on the line before it, so the
excerpt advertised a variant it did not carry. The label is now dropped with its
body: 0.

escapeCell's link-target exemption was a step away from the spec, not toward it.
CommonMark recognises backslash escapes inside a link destination (spec 6.1), and
GFM requires the escape for a pipe anywhere in a cell including inside inline
spans - so escaping everything was already right. Leaving the pipe raw would make
a parser split the row there: URL truncated, a phantom cell appears, and since
the delimiter is sized to the widest row that phantom pads every other row with
an empty column. Reverted to escaping every pipe.

Verified: tsc reports no errors in the file; 4702 modules from 616 pages, 0 page
errors; 0 chunks ending on a lone label; 0 duplicated code bodies; 0 mislabelled
panels on the page the review cited; 0 dangling fences in either published field;
0 raw pipes in a link target; index 11.09 MB / graph 12.41 MB within budget.
Evals hold at their best: success@3 0.95, precision@3 0.90, MRR 0.95.

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

Round 9. Three findings, all mine, all narrow - and the review's own numbers
confirmed the totals and that no page lost content versus base, which is the
check that caught my children() mistake last round.

The label drop sat AFTER clipMarkdown's early return, so it only ran when a fence
had also been half-included. When the cut landed exactly at the end of a label
line - the fence opener falling outside the window - the label shipped with
nothing after it, which is the "advertises a variant this excerpt does not carry"
defect I had reported as 0. It runs on both paths now, and the duplicate
40-character regex inside the function is gone: the shared one is capped at 80,
because this corpus already has a 43-character label ("Install from Package
Registry (Recommended)") that the tighter cap silently exempted. 2 -> 0.

The single-newline join defeats the PARAGRAPH splitter but not the HEADING
splitter, which keys on the heading line alone - so a panel whose body opens with
a heading still left the label as the tail of the previous chunk. splitFenceAware
now passes the preceding line to the boundary test, and a heading immediately
after a label-only line is not a boundary. 2 -> 0, and the one label+heading pair
on /sdks/react-native/add-sdk/ now travels together.

blockquote was classified as a block but flattened with inlineText, so a fenced
sample inside a quote would collapse onto one line - the same failure the `pre`
handler exists to prevent, where a `//` comment then swallows the rest. It
recurses now and re-prefixes with "> ". No blockquote in the corpus contains a
fence today, so this is so the first one that does is not silently mangled;
module count is unchanged at 4702, confirming no behaviour change on today's
content.

NOT fixed, and stated as a limit rather than left implied: a code block inside a
TABLE CELL is still flattened, because GFM tables cannot represent a fenced block
in a cell at all - there is no correct output to produce. 0 such cells today.

Verified: tsc reports no errors in the file; 4702 modules from 616 pages, 0 page
errors; 0 chunks or excerpts ending on a lone label in either published field; 0
dangling fences; CitesApi 2976 (base 2980); index 11.09 MB / graph 12.41 MB.
Evals hold: success@3 0.95, precision@3 0.90, MRR 0.95.

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

Round 10, and this one takes the review's structural advice rather than patching
another predicate.

Nine rounds each closed one SURFACE of the same class - "don't end on a lone
label", "don't split a fence", "don't orphan a label behind a heading" - and each
fix MOVED the symptom. Round 9's heading suppression is the clearest case: it
turned "chunk ends on a lone label" (2 records) into "chunk ends on a label plus
two headings" (1 record) and cost two chunks their correct section title, because
firstHeading() then picked a title from the wrong part.

So the invariant is now stated in one place, after chunking: a chunk may not END
on a run of lines that only INTRODUCES content - bold-only lines and headings
alike. Such a run is MOVED to the head of the next chunk, never dropped, because
it belongs with the content it introduces and dropping it would lose text. A
chunk left holding nothing but introducers merges into its successor. Each
chunk's heading is then re-derived from its OWN leading heading.

That subsumes both earlier workarounds, so the splitOnHeadings suppression is
reverted and dropTrailingLabels is generalised into the shared predicate.

Checkable with one assertion over the corpus instead of a new measurement per
round, which is the actual point: 0 excerpts ending on an introducer run in
either published field, 0 chunks whose heading is not their own leading heading.
Modules 4702 -> 4637, the difference being introducer-only chunks merging into
their successors.

AND - the check I now run every time, after the children() incident - full chunk
content compared page by page against the previous commit: 0 of 616 pages lose a
single word. The 132 pages whose EXCERPTS shift a word are the 400-char window
moving over merged chunks, which is why the comparison has to be on full content;
measuring the excerpt would have shown a loss that is not there.

Three smaller ones from the same round:

- Fence detection could not see a fence inside a blockquote: both clipMarkdown
  and splitFenceAware tested /^\s*```/, which never matches "> ```" - so last
  round's blockquote recursion would have been cut straight through. Both now
  allow the quote prefix. Still latent (0 blockquotes contain a fence).
- /sdks/web/matrixscan/get-started/ renders a SECOND permalink anchor with no
  hash-link class whose label is a Font-Awesome private-use glyph, so the
  zero-width strip left it non-empty and it was published as a real link inside a
  heading. The private-use range is stripped too: 1 affected module -> 0.
- heading/topic kept raw markdown link syntax while summary strips it, so a
  hard slice to 180/120 chars published half a URL as a semantic field. De-linked
  the same way: 0 remaining.

Verified: tsc reports no errors in the file; 4637 modules from 616 pages, 0 page
errors; invariant holds corpus-wide; 0 dangling fences; 0 pages losing content;
CitesApi 2974 (base 2980, previous 2976 - the delta is fewer duplicate edges from
merged chunks, not lost citations); evals success@3 0.95, precision@3 0.90,
MRR 0.95.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 11. The HIGH was mine, inside the very function meant to close the class.

enforceChunkInvariant swept BACKWARDS, writing into a slot already finalised: if
out[i+1] was introducer-only it was emptied into out[i+2] first, and out[i]'s
trailing run then landed in that now-empty slot, re-creating an introducer-only
chunk no later step inspects. So the "one invariant, enforced once" claim was
false in its own implementation - it was not a fixpoint and provably not
idempotent. The review's repro (## X immediately followed by a deeper heading, a
shape that occurs 160 times on 90 pages) produced a published module whose entire
content was one heading line.

Forward now, so every slot is re-examined after it is written. Output on this
corpus is byte-identical - 4637 modules, CitesApi 2974 - because the trigger
shape has not been written yet; the fix is for the content edit that writes it.

Two holes in the same place, both reachable by ordinary editing:
- The LAST chunk was exempt, since there is nowhere forward to move to. A
  trailing introducer run at the end of the body introduces nothing anywhere -
  the section it names is empty in the source too - so it is dropped, which costs
  only the heading of an empty trailing section.
- clipMarkdown's `text.length <= limit` early return skipped the drop entirely,
  so for any chunk shorter than the limit the property held by luck of the
  content rather than by enforcement.

And two of my own claims were not what the code did:
- "each chunk's own LEADING heading" was firstHeading(), i.e. the first heading
  ANYWHERE in the chunk - so 241 chunks were titled by a section starting deep
  inside them, 139 of those more than 400 characters in. Added leadingHeading()
  and fell back to the chunkBody-derived heading, which is the section the chunk
  STARTS in and is the more accurate answer.
- "tsc reports no errors" was measured with my own flags, not the repo's. The
  repo's `yarn typecheck` did NOT pass on this file, and had not passed since
  before my work: TS2802 on iterating a Map and a Set, from base 2a6edd5, plus
  a TS2322 I introduced in tabsToMd. All fixed with Array.from and braced
  callbacks; `yarn typecheck` now passes with zero errors across the repo. It
  never surfaced because Docusaurus transpiles separately, so the build was
  always green.

Also: the appended fence closer now carries the opener's "> " prefix. Adding the
quote-aware fence regex last round made lastFenceStart able to point at a quoted
fence, and a bare closer after "> ```" is not balanced - CommonMark ends the
blockquote at the unquoted line and opens a new, unterminated top-level fence.

THE INVARIANT IS NOW CI-ENFORCED, which was the review's actual point: the eval
script - the one thing that already loads the published index on every build -
now fails when any published excerpt ends on a heading or bold-only label with no
content, or carries an unbalanced fence. Verified in BOTH directions: it passes
on the real index and it fails on an injected breach. Eleven rounds of measuring
this by hand is now one assertion.

Verified: `yarn typecheck` clean; 4637 modules from 616 pages, 0 page errors;
invariant holds corpus-wide on both published fields; enforceChunkInvariant is
idempotent on three inputs and satisfies the review's repro, the empty-trailing-
section case, consecutive introducer-only chunks and the short-text case;
0 pages lose a word on full chunk content; evals success@3 0.95, precision@3
0.90, MRR 0.95.

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

The invariant lived in the eval script, which is paths-filtered and limited to
base `main` - so it did not run on build-docs.yml or docs-preview.yml, the two
workflows that actually build and publish the index. A breach would have first
appeared on the post-merge run against main, and never at all for release/**.
Asserting in postBuild covers every build and leaves ONE copy of each predicate,
so the generator and its check cannot drift apart.

Scope stated rather than assumed: measured, disabling enforceChunkInvariant does
NOT fail the build, because clipMarkdown drops trailing introducers from the
excerpts on its own. The assertion guards the published surface - which is all a
consumer sees - and is not a regression test for the chunker.

clipMarkdown's last-resort branch could still emit the forbidden shape, which
with the assertion in place would be a build failure no code change could fix.
The corpus was close: the longest leading introducer run is 247 characters
against a 300-character budget. It now demotes the markers instead of echoing
the raw window, so the words survive and only the false promise goes.

Also:
- an introducer inside a blockquote is recognised, matching the fence predicate;
- a run of trailing blank lines is no longer counted as an introducer run, which
  would have reported the wrong defect;
- a page whose only chunk is a heading keeps its words instead of leaving the
  index silently, uncounted by the drift guard;
- a git failure in gitDates no longer blanks last_verified for every page in
  silence. Observed here: one build shipped 4200 empty dates green, between two
  builds carrying 17 distinct real ones. Still non-fatal - metadata must not
  break a deploy - but now loud, and it does not blame shallowness for a
  failure that was something else.

Verified: typecheck clean; 4200 modules / 572 pages / 0 page errors; the
published index differs from HEAD only in its build timestamp and the graph is
byte-identical; invariant 0 breaches on every published field; the chunker is
output-identical across all 630 docs files (2729 chunks, no word or heading
changes); the assertion tested in both directions on 9 shapes; gitDates tested
in both directions; evals success@3 0.90, precision@3 0.85, MRR 0.90.

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

demoteIntroducers - added last commit to close clipMarkdown's escape hatch -
replaced its markers ONCE, so a whole family of shapes came back out still
matching INTRODUCER_LINE: `### # Foo` -> `# Foo`, `**# Note**` -> `# Note`,
`**## # x**` -> `## # x`. The demotion therefore handed assertArtifactInvariants
exactly the shape it throws on, and because that assertion now runs in postBuild
it would have failed build-docs.yml and every PR preview on content no docs
author could clear without a code change. Zero such lines exist in the corpus
today - which was equally true of the hole this replaced, 53 characters away.

It also cost enforceChunkInvariant the fixpoint property its own docstring
claims: a second application demoted `# Note` again, to `Note`.

demoteLine now loops until the line is not an introducer. It terminates: while
the line matches, one of the two patterns matches too, and either replacement
strictly shortens the line. No-progress is made terminal anyway, returning a
form that cannot match either branch - the cost of being wrong about that
argument is a build nobody can unblock.

The "page must not vanish" guard tested out.length === 1, the array length
BEFORE filtering, so a body that is entirely introducer lines but long enough to
split twice still lost every chunk - and postBuild skips a page with no chunks
WITHOUT incrementing pagesProcessed, so it left the index silently and dragged
the drift-guard ratio down. Now guarded on how many chunks still carry content,
which covers that case and the single-chunk one under one rule.

And two claims in last commit's comments were false. There are no chunker tests
in this repo, so "a separate concern with its own tests" was wrong; the property
is argued in the code and checked by nothing automated, which is what the comment
now says. And the assertion guards two published excerpt fields, not "the whole
surface a consumer sees" - `summary` is deliberately excluded because
whitespace-collapsing makes it match the predicate while carrying content.

Verified: all ten shapes demote to non-introducers and demotion is itself a
fixpoint; enforceChunkInvariant is a fixpoint again on each of them; a page of 70
heading-only sections yields 1 chunk instead of 0; every prior round's shape
still holds (27 assertions, all pass). typecheck 0. The chunker is
output-identical across all 630 docs files (2729 chunks, no word or heading
change). Build: 4200 modules / 572 pages / 0 page errors; the published index
differs from the reviewed commit only in its build timestamp and the graph is
byte-identical; last_verified carries 17 distinct dates, none empty. Evals
success@3 0.90, precision@3 0.85, MRR 0.90.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PR had been CONFLICTING for a month, and GitHub cannot build a merge commit
in that state - so no `pull_request` workflow ran at all. Not failing: never
starting. The last CI runs on this branch are from 2026-08-05.

All three conflicts were purely additive; nothing from either side is dropped.

  package.json          both sides only add scripts - kept both sets.
  docusaurus.config.ts  both sides only add imports and plugin entries - kept
                        both. knowledgeExtractor is placed LAST in the array on
                        purpose: its postBuild reads the rendered HTML, so it
                        must run after any plugin that rewrites the build -
                        stripPreviewMediaPlugin on a preview build in
                        particular. Ordered earlier it would extract from a
                        state the deploy does not serve.
  docs-preview.yml      main's side of the hunk is empty (its checkout has no
                        `with:`), so this keeps the fetch-depth: 0 block. That
                        emptiness is asserted, not assumed, so a future change
                        on main cannot be dropped silently here.

Verified on the merged tree: yarn install --frozen-lockfile, typecheck, build
and the retrieval evals all pass. 4637 modules from 616 pages, 0 page errors -
the count rises from 4200/572 because main brings the 43 KMP pages this branch
never had. The artifact invariants hold on all of it, including 296 new KMP
modules: 0 excerpts ending on an introducer, 0 unbalanced fences, 0 empty, on
both published fields. Evals success@3 0.95, precision@3 0.90, MRR 0.95.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The matrix ships as ten near-identical copies, one per SDK. They are 120 modules
over 10 URLs at 23-33 distinct tokens against an index median of 50, and they
name every product - so under score()'s sqrt(|q| * |d|) normalisation they score
well on ANY single-product query while answering none of them. The whole top ten
for "sparkscan" was the ten copies of this page, with every SparkScan page below
all of them; they also took ranks 4-5 on "qr code scanning".

Excluded through a new knowledgeIndexIgnoreFiles rather than by adding to
assistantIgnoreFiles. That list is the CURATION decision - what an assistant may
see - and is deliberately shared by both exports; putting the matrix there would
also strip the one copy the llms export keeps, where a flat text corpus can
carry a table perfectly well. The new list is the index's counterpart to
llmsDedupedToWeb: shared curation, per-export corpus shape, which is the
distinction the config already draws.

Nothing is lost. What the table says - which product exists on which framework -
is what the graph's AvailableOn / NotAvailableOn edges already carry, in a form
a consumer can query rather than one it has to parse.

Measured: 4,364 -> 4,244 modules, 0 matrix modules left, llms.txt keeps its one
copy. "sparkscan" now returns five SparkScan pages. Gold set improves on every
metric - success@3 0.9 -> 0.95, precision@3 0.8167 -> 0.8667, MRR 0.9 -> 0.95 -
and the auto half rises to 0.7969 / 0.6992, re-recorded here.

Reported by @moritzhartmeier in review of #418.

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

@raffaelefarinaro raffaelefarinaro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Automated review, posted on Raffa's behalf and not yet read by him. Treat the findings as a starting point, not a verdict. Anything that looks wrong probably is, so push back.

Cheapest fix that unblocks the most. The current head (c8308df) has never actually run through CI. gh pr checks 418 reports zero checks, and commits/c8308df/check-runs confirms it: no check-runs at all. The PR is CONFLICTING with main (mergeable_state: dirty), and git merge-tree shows exactly one real textual conflict in the whole diff, in package.json's scripts block (main added four api-reference-seo scripts at the same insertion point this branch adds docs:retrieval-evals). A conflicted PR gets no pull_request run because GitHub can't build the merge ref, so the last green CI run on record is for be5c0a04, two commits behind head — the per-page search dedup fix and the chip-announce fix haven't been through CI at all yet. Inline comment on the exact spot.

What's good. The knowledge-extractor is unusually disciplined for a build-time HTML-to-text pipeline: it fails the build loudly (not silently) on empty extraction, a >50% page-error ratio, or a broken excerpt invariant (dangling code fence, or an excerpt ending on a heading/label with nothing under it), and nearly every one of those guards cites a specific measured incident in its own comment rather than a hypothetical. The last_verified handling — shallow-clone detection via git rev-parse, %ct instead of %cI to dodge cross-timezone string sorting — is the kind of detail that's easy to get subtly wrong, and it isn't here.

PR description vs. what's actually committed — question about intent, not a defect. "Known limits" says --auto self-retrieval is "gated at 0.80, against a measured 0.8493 over 531 pages / 4,386 modules." scripts/retrieval-evals/baseline.json on this head is dated the same day (2026-09-18) and records page_success_at_k: 0.7876, page_mrr: 0.6895, 532 pages / 4364 modules — and the gating in run.cjs isn't a static 0.80 floor anymore, it's a regression-against-baseline check (AUTO_TOLERANCE 0.03) with a 0.60 backstop (AUTO_FLOOR). Is the description meant to be refreshed before merge, or is there a newer baseline that hasn't landed?

"Extraction failures are non-fatal" is broader than what the code does. Several classes of failure are deliberately fatal by design: zero pages extracted, a >50% page-error ratio, a missing or malformed customFields.assistantIgnoreFiles, an ignore-glob using an unsupported character, and a broken excerpt invariant all throw and fail docusaurus build — on purpose, per the comments ("fail LOUD ... matches the config's onBrokenLinks:"throw" convention"). That's a reasonable design, but it's the opposite of "never block a deploy." Worth tightening the description to say per-page failures are non-fatal, since systemic ones are intentionally not?

Existing threads, checked against head:

  • src/components/FeatureList/styles.module.css:135 (Raffa, aria-disabled / screen-reader announcement) — fixed by c8308df ("Announce the unavailable chip as unavailable"). Current index.tsx adds aria-disabled="true", a title, and a visuallyHidden "(not available)" suffix exactly as asked. moritzhartmeier's "this seems to be unaddressed" ping (14:41) landed 5 minutes before that commit (15:46 UTC+1) — stale, recommend closing.
  • scripts/retrieval-evals/run.cjs (moritzhartmeier, search() has no per-URL dedup) — fixed by 4a4a63b ("Give the gold-set search one slot per page, not per chunk"), committed ~15:39, after the 14:30 comment. GitHub already marked the thread outdated; recommend closing too.
  • scripts/retrieval-evals/run.cjs:121 (moritzhartmeier, score()'s length normalization makes small-vocabulary pages universal attractors) — still open and still accurate at head; no commit has touched score() since. Not stale, leave it.

Full non-blocking list, including a couple of smaller reuse/cost notes, is in the linked vault doc.

Comment thread package.json Outdated
"verify:search-tags": "node scripts/verify-search-tags.cjs",
"test:search-facets": "node scripts/test-search-facets.cjs"
"test:search-facets": "node scripts/test-search-facets.cjs",
"docs:retrieval-evals": "node scripts/retrieval-evals/run.cjs"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking for merge (not a code defect): this conflicts with main. Main added verify:api-reference-seo / test:api-reference-seo / test:api-reference-seo-verdicts / discover:api-reference-lines at this same insertion point, and git merge-tree $(git merge-base origin/main HEAD) origin/main HEAD shows a genuine <<<<<<< conflict right here — the only one anywhere in the diff. This is also why CI shows zero checks on the current head (c8308df): a conflicted PR gets no pull_request run because GitHub can't compute a merge ref. The last green CI run on record is for be5c0a04, two commits back — so the two newest commits (the per-page search dedup and the chip aria-announce fix) haven't actually been validated by CI. Four lines to combine once this rebases onto main.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Resolved in the 77d5651 merge commit — both the package.json script-block conflict and the earlier package-lock.json modify/delete. git merge-tree $(git merge-base origin/main HEAD) origin/main HEAD is clean at this head, and the concurrency group on docs-retrieval-evals.yml went in with the same commit.

Closing this thread.

branches:
- main

jobs:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking, reuse question: docs-preview.yml already has concurrency: { group: docs-preview-${{ github.event.number }}, cancel-in-progress: true } for exactly this situation. This workflow's pull_request trigger has no concurrency group, and each run does a full yarn build plus two eval passes. This PR alone has ~30 commits touching paths this workflow watches, so without a concurrency group those are ~30 overlapping full builds queued rather than superseded. Worth the same pattern here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added in the 77d5651 merge commit — docs-retrieval-evals.yml now carries the same concurrency group as docs-preview.yml.

Closing this thread.

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>

@raffaelefarinaro raffaelefarinaro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Automated review, posted on Raffa's behalf and not yet read by him. Treat the findings as a starting point, not a verdict. Anything that looks wrong probably is, so push back.

CI is green and the head is mergeable against main. The most useful thing right now: the two conflict threads that were blocking review (the package.json script-block conflict and the earlier package-lock.json modify/delete conflict) are both resolved as of the merge commit 77d5651, which explicitly documents resolving the one remaining conflict and also adds the concurrency group to docs-retrieval-evals.yml. git merge-tree $(git merge-base origin/main HEAD) origin/main HEAD shows a clean merge, no conflict markers. Both threads can close.

What's good: the postBuild fail-loud guards (zero-extraction, <50% page success ratio, assertArtifactInvariants) now live in the plugin itself with no swallowing catch around them, so a selector-drift regression fails the build instead of silently shipping an empty index. The SYNTHETIC_PRODUCTS fix is also solid: excluding "core"/"general" from availability-edge generation, driven off products.json rather than a path-shape guess, closes the self-contradicting AvailableOn/NotAvailableOn edges cleanly. And the amount of measured verification in the comments (module counts, blast radius before/after a regex change, timing) is the right level of rigor for a plugin that gates every docs build.

Status of existing threads at this head (77d5651), so the still-open ones aren't lost in the noise:

Resolved, recommend closing:

  • cheerio undeclared dependency: cheerio and gray-matter are now real package.json dependencies with exact versions, and yarn.lock carries a matching pinned resolution.
  • "Nothing asserts extraction succeeded": fully fixed, not just partially as the Aug 11 follow-up said. The empty-index and low-success-ratio throws are in the plugin's own postBuild now, not only in the separate eval script.
  • recall@k == precision@k: recall was dropped entirely (commit 972e6cb).
  • " auth" substring match: fixed with a word-boundary regex, verified against the built HTML (27 document-verification false positives vs 2 real security mentions).
  • Titanium tree indexed despite the repo's own curation decision: fixed via a shared assistantIgnoreFiles export consumed by both AI-export plugins; corpus dropped from 4,637 to 4,386 modules with zero from /sdks/titanium/.
  • docusaurus-plugin-llms division of labour: now stated in the plugin header, next to the config registration, and in the PR description.
  • servesCurrent / frozen-version branch: re-verified with DOCS_LAST_VERSION actually set, after two rounds of the author catching their own gaps.
  • --min-auto-success defaulting to 0: now derives a real floor from baseline.json with tolerance, and --auto is actually wired into docs-retrieval-evals.yml.
  • FeatureList unavailable chip not announced to screen readers: fixed by c8308df ("Announce the unavailable chip as unavailable"). Worth noting the timing: the Sept 18 "this seems unaddressed" ping landed at 14:41:53Z, and the fix commit landed at 14:46:54Z, five minutes later. Nobody was wrong, just crossed in flight.
  • No per-URL dedup in the gold-set search(): fixed by 4a4a63b (one slot per page).
  • package.json/package-lock.json conflicts and the missing concurrency group on docs-retrieval-evals.yml: both fixed in the 77d5651 merge commit itself, as above.

Still open, not re-flagged inline since the threads already exist:

  • The score() length-normalization issue (moritzhartmeier, Sept 18): unchanged at scripts/retrieval-evals/run.cjs:116-122. The gate still uses overlap / sqrt(|q| * |d|), so a small-vocabulary page can still win on denominator alone rather than actual relevance. This only affects the eval script's own signal quality, not production data, but as long as it stands the gate's precision numbers are a weaker check than they look.
  • The excerpt-truncation design question (Aug 11 / Sept 8, with eugenia's pushback): still an open disagreement rather than a bug at this point, since the author's counter-argument (api_refs/references/keywords are derived from the full chunk, not discarded) is partly right and partly not. api_refs and references do come from parsing the chunk's HTML links, so that part holds. keywords, though, is built from the page slug, product, framework, contentType and frontmatter (src/plugins/knowledge-extractor/index.ts:1490-1494), not from the chunk prose at all, so it doesn't actually support "the chunk isn't discarded." Is 400/300-char excerpts intentionally the final design, or a placeholder pending a consumer that wants more?

One new item, inline: build-docs.yml's checkout now always does fetch-depth: 0, on pull_request, push, schedule, and workflow_dispatch alike, and this job has no path filter, so it runs on every PR to the repo. But this job never publishes the generated index (no deploy step, just build + verify), so last_verified accuracy only matters on the actual deploy path. docs-preview.yml already draws exactly this distinction and stays at fetch-depth: 1 because "a full clone would buy a freshness signal nobody computes" there. Measured checkout time on the current head's CI run (job 105649872300): 14 seconds. Not alarming on its own, but it's a cost paid forever, on every PR, for a value only the push-to-main run needs. Worth asking whether this should follow the preview workflow's precedent and stay shallow except on push to main.

Non-blocking notes and the rest of the thread audit are in the full write-up: memory-vault/work/projects/active/docs-improvement/review-pr418.md in the ciao repo.

Comment thread .github/workflows/build-docs.yml Outdated
#
# Cheap here: 796 commits, and the `git log` over docs/ measures at
# ~0.13s. Revisit only if the history grows by orders of magnitude.
fetch-depth: 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking, cost/reuse question: this job (pull_request on main and release/**, push to main, schedule, workflow_dispatch, no path filter) runs on every PR to the repo, but it never publishes the generated index (build + verify steps only, no deploy). last_verified accuracy only matters on the actual deploy path.

docs-preview.yml already draws this line and stays at fetch-depth: 1 specifically because "a full clone would buy a freshness signal nobody computes" on a build that doesn't publish. Measured on the current head's CI run (job 105649872300): the checkout step took 14 seconds. Small, but it's paid on every PR/schedule/workflow_dispatch run forever, for a value only the push-to-main run needs.

Is fetch-depth: 0 here deliberate for all triggers, or should it follow the same pattern as docs-preview.yml and stay shallow except on push to main?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

You're right on both halves, and the second half is worse than you put it.

Checked before changing anything:

  1. This job really does not publish. Its steps are checkout, setup, install, build, then test:search-facets, test:api-reference-seo, test:api-reference-seo-verdicts, verify:search-tags, verify canonical tags. No deploy step — and in fact no workflow in the repo publishes the index. So last_verified accuracy here buys nothing on any trigger, not just on PRs.

  2. A shallow clone cannot turn this job red. knowledge-extractor/index.ts:1080 checks git rev-parse --is-shallow-repository, warns, and returns empty dates. Nothing asserts on last_verified — not assertArtifactInvariants, not any verify step. So going shallow costs the freshness signal and nothing else.

Fixed by making it conditional rather than flat:

fetch-depth: ${{ github.event_name == 'pull_request' && 1 || 0 }}

Shallow on PRs, which is the trigger with no path filter that pays the 14s on every PR to the repo; full on push, schedule and workflow_dispatch, where the artifact is what a deploy or a hand-run inspection reads and the cost is paid once.

Separately, your comment exposed a stale claim in the precedent you cited. docs-preview.yml said:

build-docs.yml, which does publish the artifact, still fetches full history - do not copy this value there.

build-docs.yml does not publish the artifact. That comment was instructing future readers not to make exactly the change you're asking for, on a false premise. Corrected in the same commit.

One I noticed and am deliberately not changing without a second opinion: docs-retrieval-evals.yml:62 also sets fetch-depth: 0, and the eval scorer reads title, summary, docs_excerpt, assistant_excerpt, keywords, intents — never last_verified. So that one appears to be over-fetching too. It has a path filter and runs far less often, and it's outside what you flagged, so I'd rather ask than widen the diff: want it shallow as well?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Follow-up on the docs-retrieval-evals.yml question I left open above — resolved the other way, and I think it's the better answer.

Rather than making that job shallow too, the full clone now buys something. run.cjs reports freshness coverage on every run:

Freshness: 4228/4228 modules carry a last_verified date.
  oldest 2025-12-12, newest 2026-09-18, 2 older than 180 days (before 2026-03-25).

And it guards the setting that produces it. If not one module carries a date — exactly what a shallow clone yields, since the extractor emits "" rather than a constant that looks like data — the run fails in CI and names the workflow and the line that must have been lost. Locally it warns and carries on, because a shallow clone is a legitimate state to develop in. Verified both ways against an index with every date blanked: exit 1 under CI, exit 0 without it.

That turns your observation into a check: the cost is now justified, and dropping fetch-depth: 0 there can no longer be silent.

build-docs.yml still goes shallow on pull_request as described above — that job genuinely has no use for the signal, and this is what the difference between the two looks like now.

One thing I deliberately did not do, since it was suggested and is worth recording as rejected: feed last_verified into the ranking. It's a date, and the scorer tokenises on [a-z0-9]{2,}, so 2026-09-18 enters the term stream as 2026, 09, 18 — a query like "scandit 18 digit barcode" would then match every page verified on the 18th. Recency is not relevance, and mixing them would degrade the metric while looking like an improvement. It stays a report.

Retrieval numbers unchanged by this commit: gold set 0.95 / 0.8667 / 0.95, auto 0.8423 / 0.7696.

eugenia-scandit and others added 9 commits September 21, 2026 10:50
The proxy retriever scored overlap / sqrt(|q| * |d|). |q| is constant
across the documents of one query, so that ranked on overlap / sqrt(|d|),
which pays a document for being SHORT: a 10-token stub matching one query
term scored 0.316 against 0.200 for a 100-token page matching two. The gate
reads its numbers from this retriever, so its precision looked stronger
than it was.

Replaced with BM25 over the existing binary term representation. IDF weighs
a rare term above a common one, and the length term normalises against the
corpus average with b=0.75 rather than sqrt(|d|). Term frequency stays
binary because docText() is read as a Set - deliberately, since the fields
it concatenates already repeat the title and slug, so a raw count would
measure field layout rather than aboutness.

The --auto half carried its own copy of the formula in fastScore, and that
is the half the baseline and the gate are computed from - so fixing only
score() would have changed nothing that is measured, and left the two
halves disagreeing about what a rank means. Both now call one makeScorer.

Measured on one index, changing only the scorer, so the corpus cannot
explain the difference:

                      old        new
  page-success@3      0.7962     0.8423    +0.0461
  page-MRR            0.6981     0.7696    +0.0715
  pages missed        106        82        -24

Baseline re-recorded against the new scorer. The 20-query gold set is
unchanged at success@3 0.95 / precision@3 0.8667 / MRR 0.95, with the same
single miss - it is too small to register the difference, which is itself
worth knowing about what that set can and cannot detect.

Separately: build-docs.yml took fetch-depth: 0 on every trigger. That job
builds and verifies but never publishes - no deploy step, and no workflow
in the repo publishes the index - and a shallow clone cannot fail it,
because the extractor warns and emits "" and nothing asserts on
last_verified. So on pull_request the freshness signal was computed, paid
for and discarded, on a job with no path filter, which is every PR to the
repo. Now shallow on pull_request and full elsewhere.

docs-preview.yml justified its own depth by saying build-docs.yml "does
publish the artifact". It does not, and that comment was steering future
readers away from this exact change. Corrected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs-retrieval-evals.yml checks out full history so the extractor can date
each page, and nothing read the result - the same "freshness signal nobody
computes" that docs-preview.yml names as its reason for staying shallow.
The cost was real and the return was zero, which is a fair objection to
simply keeping the setting.

run.cjs now reports coverage on every run: how many modules carry a
last_verified, the oldest and newest, and how many are over 180 days old.
On this index that is 4228/4228 dated, 2025-12-12 to 2026-09-18, 2 older
than 180 days.

It also guards the setting that produces it. If NOT ONE module carries a
date - which is exactly what a shallow clone yields, since the extractor
emits "" rather than a constant that looks like data - the run fails in CI
and names the workflow and the line that must have been lost. Locally it
warns and carries on, because a shallow clone is a legitimate state to
develop in. Verified both ways against an index with every date blanked:
exit 1 under CI, exit 0 without it.

Deliberately a REPORT and not a ranking input. last_verified is a date and
the scorer tokenises on [a-z0-9]{2,}, so "2026-09-18" would enter the term
stream as "2026", "09" and "18" - a query mentioning any of those numbers
would then match every page verified that day. Recency is not relevance,
and mixing them would degrade the metric this file exists to measure while
looking like an improvement.

Retrieval numbers are untouched: gold set 0.95 / 0.8667 / 0.95, auto
page-success@3 0.8423 and page-MRR 0.7696, both unchanged.

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

Two defects found by reviewing this branch's own new code.

reportFreshness keyed its hard failure off process.env.CI, which assumed
the retrieval-evals job is the only CI caller. It is not, and this same PR
is what made that false: build-docs.yml now uses a shallow clone on
pull_request, so an index built there legitimately carries no dates. Any
other CI job running `yarn docs:retrieval-evals` would have exited 1 and
blamed a workflow it never touched. The failure is now opt-in via
--require-freshness, passed only by the job that checks out full history on
purpose; every other caller gets the report without the exit. Verified all
three cases: dateless without the flag exits 0, dateless with it exits 1,
dated with it exits 0.

The knowledge-extractor's fatal guard reads
customFields.knowledgeIndexIgnoreFiles but its message told the reader to
export assistantIgnoreFiles - contradicting the comment two lines above it.
Those are different lists: the one it reads is assembled from the other
plus this export's corpus-shape exclusions, so following the old message
would not have cleared the error. It now names the key it reads and says
why the neighbouring one will not do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All three share a shape: the index quietly stops describing the corpus and
nothing goes red, because the guards that exist watch for the index getting
SMALLER or EMPTIER and these do not always do that.

skipDir matched directory NAMES at any depth. The excluded set holds
`search`, `assets`, `img`, `data-capture-sdk` and the frozen version
numbers, so a future guide at /hosted/express/search/ would have vanished
from the index - and invisibly, since a page dropped in the walk never
reaches `files` and so cannot lower the drift ratio that guards this.
walkHtml now hands skipDir the root-relative path and the set is anchored
to depth 1. Checked against the real tree first: every member of it is
genuinely top-level, so this removes the class without moving anything
today. The `.html` suffix test stays depth-agnostic - that is a shape, not
a location. (`fonts` turns out to be dead weight either way: it exists only
at build/assets/fonts, inside an already-skipped directory.)

The curation filter failed OPEN when resolveSource found nothing, so a
route in an ignored tree would be fed to assistants despite the repo having
decided to hide it - and the page count goes UP, which is the one direction
neither the empty-output guard nor the drift ratio can see. It now falls
back to matching the ROUTE against the same globs, which covers the
realistic trigger: a page gaining `slug:` frontmatter moves the route off
the file path without moving it out of the ignored tree. Failing closed was
the wrong alternative - this build reports exactly one unresolved route,
`/`, the homepage, which has no docs source and never will, so a throw
would break every build. Unresolved routes are now warned about by path.

docs-retrieval-evals.yml did not watch versioned_docs/**. While
lastVersion is pinned to a snapshot - a beta cycle, which is what the
extractor's servedVersion handling exists for - that snapshot IS the
corpus this measures, so a PR editing it changed exactly what is indexed
and ran neither eval until after it merged.

Verified against a full rebuild rather than argued: the index is identical
either side of these changes - 4228 modules, 520 pages, zero added, zero
removed - and the gate is unmoved at page-success@3 0.8423 / page-MRR
0.7696, delta +0. tsc clean.

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

detectProduct returned the raw URL segment, but a route does not always
spell its product's key. /sdks/*/label-capture/ belongs to
smart-label-capture and /sdks/*/matrixscan/ to matrixscan-batch, so 609
modules shipped filed under two products that do not exist in
products.json - and the two real keys had no pages at all. Every
BelongsToProduct and AvailableOn edge built on them described something no
consumer can look up, which is the exact failure productKeys() documents
itself as preventing.

The alias map is DERIVED from the registry's own frameworks[].apiUrl rather
than listed, so it is a fact products.json already states instead of a
second copy to keep in step: each product records real routes there, and
the segment in them is its URL spelling. Guarded so one product's route can
never claim another's key. Today it yields exactly the two aliases and
identity for the other eleven.

Measured on a rebuild: label-capture 349 -> smart-label-capture 349,
matrixscan 260 -> matrixscan-batch 260, corpus unchanged at 4228 modules
over 520 pages. `product` feeds `keywords`, so correcting it moves the
token stream slightly; the gate stays green well inside tolerance and the
baseline is re-recorded at 0.8404 / 0.7676 per this script's own rule about
changes to what is indexed. Gold set unmoved at 0.95 / 0.8667 / 0.95.

Also: published urls dropped baseUrl. Docusaurus writes routes into outDir
without that prefix, so `site + pathname` is right only because production
sets baseUrl ''. Folded in as a separate `siteBase` rather than into
`site`, because classifyLinks strips `site` as a bare origin and takes
baseUrl as its own argument - combining them would stop it recognising
internal links.

And assistantIgnoreFiles is no longer exported through customFields.
Nothing reads it - both consumers take a superset - while customFields is
serialised into the client bundle on every page, so it shipped a build-time
list to every reader and gave the extractor a second name to drift from.

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

The drift guard could fail the build for a reason that never happened.
@docusaurus/plugin-client-redirects writes <path>/index.html stubs into
outDir from its own postBuild, and those are exactly the files walkHtml
enumerates. postBuild hooks run through Promise.all, so whether they exist
when this plugin walks the tree is a race. Measured on this build: 617 real
pages and 712 stubs in scope, so pagesProcessed/files.length is 0.843
without them and 0.391 with them - and the threshold is 0.5. It has only
ever passed because this plugin wins the race; losing it fails the build
claiming "page selectors likely drifted", which is not what happened.

Stubs are now identified by their meta refresh and excluded from both terms
of the ratio. Detected that way rather than by the doc selectors on
purpose: the guard exists to catch those selectors drifting, so excluding
files BY them would let it neutralise itself - every file would look like
"not a doc page" and the denominator would empty.

The comment claiming stripPreviewMediaPlugin was "the one other postBuild"
was wrong three ways: client-redirects and docusaurus-plugin-llms both have
one, and the first is the one that matters.

--auto counted competing CHUNKS while search() spends one slot per PAGE, so
three chunks of a single rival consumed all three slots and scored a miss
the gold-set path scores a hit. That is the half baseline.json and the CI
gate come from, so the gated number was the wrong one - and it moved with
chunk counts, meaning re-chunking a rival page changed the score with no
change in retrieval quality. It now counts distinct pages.

Verified rather than asserted: an independent harness computes each page's
own rank through both paths and compares - 120 sampled pages, 120 agree, 0
disagree. The corrected metric is 0.8596 / 0.7750 with 73 misses, against
0.8404 / 0.7676 and 82 before, so the old one understated page retrieval by
about two points. Baseline re-recorded. Gold set unmoved at 0.95 / 0.8667 /
0.95.

Also: the title stripped only U+200B where serializeInline strips the whole
zero-width and private-use set, so a permalink glyph could survive into the
published title - which defeats both the heading-only-page guard
(bodyText === titleText) and isAvailabilityStub's title skip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review asked for this list to move off customFields, on the grounds that a
plugin option would keep a build-time list out of the client bundle. The
move is worth making and it is made here, but that reasoning does not hold
and the comments no longer repeat it.

Measured on the built artifact rather than assumed: Docusaurus serialises
the resolved config, plugin options included, so main.js carries TWO
`ignoreFiles:` arrays - this plugin's and docusaurus-plugin-llms'. Moving
off customFields changed nothing about what ships. Keeping the globs out of
the client would mean the plugin reading them off disk itself, and the llms
plugin could not do that at all.

What the move does buy is one name per consumer instead of a customFields
key that has to be kept in step with the list it mirrors - which is the
failure the previous commit had already hit once, when the fatal guard read
knowledgeIndexIgnoreFiles and told the reader to export assistantIgnoreFiles.
The plugin still throws when the option is absent rather than defaulting to
indexing everything, which is the property that actually protects curation.

Corpus identical across the change: 4228 modules, 520 pages, zero module
ids added or removed. tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
and refuse a threshold that is not a number

pickIntents tested `text.includes("fix")` as a bare substring, two lines
above the comment explaining why `auth` needed a word boundary. It matched
"prefix", "suffix" and "fixtures", which appear in ordinary reference
prose. Measured over a full rebuild: 38 modules lose the troubleshoot
intent, and for 28 of them it was the ONLY intent they carried - pages with
nothing wrong, filed entirely as troubleshooting.

detectProduct published the first path segment as a product for non-/sdks/
routes, so urn:product:hosted (128 modules) and urn:product:id-documents
(12) went into the graph as keys products.json has never held. Those are
sections, not products; they now read `general`, which is already in
SYNTHETIC_PRODUCTS and so stays out of availability edges.

The /sdks/ branch had the same shape left in it: `rest.length >= 2` meant
"a directory, therefore a product". Every such directory in the repo IS a
product today - the published values are identical either side of this
change, verified on a full build - so this removes a class rather than a
defect. Unknown segments now fall to core.

Two keys remain that products.json does not define,
ai-powered-barcode-scanning and batch-scanning, and they are NOT a defect:
both pages carry not_available: true, and the availability-stub block names
a stub after the feature it rules out precisely so that fact is not thrown
away. Checked rather than assumed before touching it.

Threshold flags were bare parseFloat, so `--min-success abc` gave NaN,
every `metric < NaN` was false, and the gate exited 0 reporting "ok" - a
typo silently disabling the check it was meant to tighten. readBaseline()
already refused to run ungated for this hazard; the flags now do too.
Verified both ways: exit 1 on a non-numeric flag, unchanged on a valid one.

build-docs.yml is shallow on every trigger. Keeping full history on
push/schedule rested on those runs producing what a deploy reads; the job
has no deploy step and no upload-artifact step, so the index is discarded
with the runner on every trigger. docs-retrieval-evals.yml keeps
fetch-depth: 0 because it is the job that actually reads the dates.

Corpus unchanged at 4228 modules over 520 pages. Gate exactly on baseline
(delta +0), gold set 0.95 / 0.8667 / 0.95. tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.frameworkList was `display: none` in this file's FIRST commit (#244), so
the "Available Frameworks" cell has never rendered - while the component
goes on emitting <th>Available Frameworks</th> above it and building the
chips inside it. A column header over content hidden by CSS is not a
layout decision, it is two halves of the component disagreeing.

Removing the rule is safe to the point of being invisible: all 50
FeatureList usages in docs/, versioned_docs/ and src/ pass
displayMode="compact", which returns before the table is built, so nothing
in the built site reaches this path. Measured on the current build:
`frameworkList` and `frameworkItemUnavailable` appear in 10 JS bundles and
1 CSS file and in ZERO html files.

Added the smallest container layout that makes the column legible rather
than leaving the chips flush: they carry padding and a border but no
margin, and React emits no whitespace between siblings of a .map(), so
without it they would touch.

This also makes c8308df real. That commit added the dashed unavailable chip
and its visually-hidden "(not available)" text to answer an accessibility
review, and `display: none` removes an element from the accessibility tree
as thoroughly as from the page - so the fix could never have taken effect
where it stood. It still does not render today, because the compact path
skips the column entirely; what changes is that enabling that view now
yields a working, announced column instead of an empty one.

Whether the non-compact view should exist at all is a separate question -
it is component, CSS and bundle weight for something used zero times - and
it is a product decision, not a review fix.

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

@raffaelefarinaro raffaelefarinaro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Automated review, posted on Raffa's behalf and not yet read by him. Treat the findings as a starting point, not a verdict. Anything that looks wrong probably is, so push back.

Cheapest fix that unblocks the most: the checked-numeric-flag fix in scripts/retrieval-evals/run.cjs (this branch, "refuse a threshold that is not a number") only converted some of the --auto flags. AUTO_FLOOR, AUTO_MRR_FLOOR, and MIN_AUTO_MRR are still bare parseFloat, sitting three lines below the num() reader that was written specifically to close this hole. Inline comment below with the exact lines.

What's good, concretely: the BM25 rewrite is real and its numbers check out against the actual CI run, not just the PR description — I pulled the Docs Retrieval Evals log for this head (run 35617057715) and it prints page-success@3 = 0.8596 / page-MRR = 0.775 over 520 pages / 4228 modules, matching baseline.json exactly. The drift-guard fix (excluding plugin-client-redirects' meta-refresh stubs from both terms of the page-success ratio) is a legitimate postBuild-race bug caught and fixed with a measured before/after (0.843 vs 0.391 against a 0.5 threshold) rather than asserted. And detectProduct's alias map is genuinely derived from products.json's own frameworks[].apiUrl rather than a second hardcoded list — I checked it against the registry directly and it produces exactly the two aliases the commit claims (matrixscan→matrixscan-batch, label-capture→smart-label-capture) and identity for the other eleven.

Thread audit against the current head (8a55edd), so nothing already fixed gets re-litigated:

Resolved, safe to close:

  • cheerio/gray-matter undeclared deps (lucatorella) — both are real package.json dependencies now.
  • Empty-extraction / low-success-ratio not asserted (lucatorella) — both throw in the plugin's own postBuild, verified at lines ~2201 and ~2231.
  • recall@k == precision@k (lucatorella) — recall was dropped entirely.
  • Titanium indexed despite llmsIgnoredSdkTrees (Aug 11) — fixed via the shared assistantIgnoreFiles export, verified 4,637→4,386 modules with zero from /sdks/titanium/.
  • " auth" / "fix" bare-substring intent matches (Aug 11, and the same defect found again in pickIntents's troubleshoot test on this branch) — both now word-bounded.
  • MIN_AUTO_SUCCESS defaulting to 0 (Aug 11) — now derives a real floor from baseline.json with tolerance, and --auto is wired into CI.
  • score()'s length-normalization letting the feature-availability matrix win single-product queries, and search() having no per-page dedup (moritzhartmeier, Sept 18) — both fixed by the BM25 rewrite and the page-level dedup in search(); the ranking-unit fix (chunks vs. pages) on the --auto side landed in the same round and was verified with an independent 120-page harness (120 agree, 0 disagree).
  • package.json/package-lock.json conflicts and the missing concurrency group on docs-retrieval-evals.yml (Sept 18/19) — resolved in the referenced merge commit; current mergeable: true (state blocked only on the pending review, not a conflict).
  • build-docs.yml full-cloning on every trigger with no path filter (Sept 19 finding) — now shallow (fetch-depth: 1) on every trigger, with the rationale that this job never publishes what a full clone would improve.

Still open, on purpose — not re-flagged inline since the thread exists:

  • The FeatureList table-view accessibility thread, reopened by eugenia on this head. The display:none removal and container layout are correct given the code as fixed, but she's right that the whole table path is dead code (50/50 call sites use displayMode="compact"). Worth deciding as a product question rather than in this PR: does the non-compact table view need to exist at all, or should it (and the a11y work riding on it) be deleted until something calls it?

One more non-blocking finding, inline: a latent alias-collision gap in the same product-canonicalization commit.

Full write-up, including the items above with commit references and a couple of lower-priority notes (a markdown-link regex that truncates on a literal ) in an href), is in memory-vault/work/projects/active/docs-improvement/review-pr418.md in the ciao repo.

Comment thread scripts/retrieval-evals/run.cjs Outdated
// that legitimately alters what is indexed, and say so in the commit.
const BASELINE_PATH = path.join(__dirname, "baseline.json");
const AUTO_TOLERANCE = num("auto-tolerance", "0.03", parseFloat);
const AUTO_FLOOR = parseFloat(arg("auto-floor", "0.60"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This line and the next two are still bare parseFloat(arg(...)), not routed through the num() checked reader defined a few lines up in this same commit ("Parsed through a checked reader, NOT bare parseFloat" / "refuse a threshold that is not a number"). AUTO_TOLERANCE right above got the fix; AUTO_FLOOR, AUTO_MRR_FLOOR, and MIN_AUTO_MRR didn't.

Concretely: --auto-mrr-floor abc (or any non-numeric value) yields NaN, and metrics.page_mrr < mrrFloor at line 418 is then always false — the gate reports "ok" regardless of the real score, which is the exact hazard this commit's own header comment says was closed. Not live today since no workflow passes these flags with a bad value, same as the MIN_AUTO_SUCCESS-defaults-to-0 issue from the Aug 11 thread — but it's a one-line fix (num("auto-floor", "0.60", parseFloat) etc.) and the checked reader is already sitting right above it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Right, and it's a bad miss — I wrote num() in that commit specifically to close this, converted the line above these three, and stopped. A header comment claiming the hazard was closed while three lines beneath it left it open is worse than not having written the reader, because it tells the next reader not to check.

Fixed in 74b5eec. All three now go through num():

const AUTO_FLOOR = num("auto-floor", "0.60", parseFloat);
const AUTO_MRR_FLOOR = num("auto-mrr-floor", "0.40", parseFloat);
const MIN_AUTO_MRR = num("min-auto-mrr", "0", parseFloat);

Verified each one rather than assuming the shared reader covers them:

--auto-floor abc      -> retrieval-evals: --auto-floor is "abc", which is not a number.      exit 1
--auto-mrr-floor abc  -> retrieval-evals: --auto-mrr-floor is "abc", which is not a number.  exit 1
--min-auto-mrr abc    -> retrieval-evals: --min-auto-mrr is "abc", which is not a number.    exit 1
valid args            -> page-success@3 = 0.8596, delta +0                                   exit 0

That is now every numeric flag the script takes — I checked the file for remaining parseFloat(arg( / parseInt(arg( call sites and there are none.

if (!m) continue;
const seg = slug(m[1]);
// Never let one product's route claim another product's key.
if (seg && seg !== key && !PRODUCT_KEYS.has(seg)) aliases.set(seg, key);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking. The guard here (!PRODUCT_KEYS.has(seg)) stops a route segment from hijacking another product's real key, but it doesn't stop two different products from deriving the same alias segment — aliases.set(seg, key) would just overwrite with whichever product is later in products.json's array, silently, with no signal that it happened. Not reachable with the current registry (checked: only two aliases exist today, matrixscan→matrixscan-batch and label-capture→smart-label-capture, on disjoint segments), but a third product adopting an ambiguous URL segment later would misattribute pages rather than fail loud — which is the opposite of how every other silent-failure path in this file is handled (e.g. the ignore-glob and missing-ignoreFiles throws a few hundred lines down).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, and fixed in 74b5eec rather than left as a note — the argument that it is unreachable today is the same argument that would have justified leaving the original misattribution alone.

The guard checked that a segment does not collide with a real key, but not that two products do not derive the same alias. aliases.set() is last-write-wins, so the segment would go to whichever product sits later in products.json, every page under it would be reattributed, and nothing would record that a choice was made. Misattributing a product is precisely what this map was added to fix, so resolving it by array order reintroduces the bug in a form that is harder to see than the one it replaced.

It now throws and names both products, matching the ignore-glob and missing-ignoreFiles checks a few hundred lines down:

products.json: the route segment "label-capture" is claimed by both
"smart-label-capture" and "matrixscan-batch" through their frameworks[].apiUrl.

Throw rather than warn for the reason you give: reaching it means products.json is ambiguous about which product owns a route, and there is no fallback this plugin can pick that is the right answer to that.

Verified against the real registry, including the case that must not trip it:

case result
real products.json exactly matrixscan -> matrixscan-batch, label-capture -> smart-label-capture, no throw
one product repeating a segment across 11 frameworks no throw — this is the ordinary case, and a naive collision check would have broken every build
two products claiming one segment throws, naming both

That middle row was the thing worth checking: every product emits the same segment once per framework, so a guard that fired on any repeat would have failed on the current registry immediately.

eugenia-scandit and others added 2 commits September 22, 2026 08:55
…loud

The previous commit added num() specifically to stop a non-numeric
threshold turning into NaN, then converted only some of its callers.
AUTO_FLOOR, AUTO_MRR_FLOOR and MIN_AUTO_MRR were left as bare parseFloat on
the three lines directly below AUTO_TOLERANCE, which did get it - so
`--auto-mrr-floor abc` still gave NaN, `page_mrr < NaN` was still false, and
the gate still reported ok whatever the real score. A header comment
claiming the hole was closed while three lines under it left it open is
worse than not having written the reader. All three now go through num();
verified each rejects a non-numeric value and that valid arguments are
unaffected (delta +0 against the baseline).

The alias map resolved a collision by last-write-wins: two products whose
frameworks[].apiUrl yield the same route segment would silently give it to
whichever sits later in products.json, reattributing every page under it,
with nothing anywhere recording that a choice was made. Misattributing a
product is the failure the alias map exists to fix, so settling it by array
order reintroduces it in a form that is harder to see. It now throws and
names both products, matching how the ignore-glob and missing-ignoreFiles
checks in this file already behave.

Verified against the real registry rather than reasoned: the loop still
derives exactly matrixscan -> matrixscan-batch and label-capture ->
smart-label-capture and throws on neither, one product repeating a segment
across eleven frameworks does not trip it - that is the ordinary case - and
two products claiming one segment throws naming both. Not reachable today;
the two live aliases are on disjoint segments.

tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The worst of these was mine, from the previous commit. The
alias-collision throw sat INSIDE loadProducts' try, and the bare catch
swallowed it and set PRODUCT_KEYS to an empty Set. A collision would then
have made detectProduct answer "core" for every /sdks/ page and "general"
for everything else: every BelongsToProduct edge collapsed onto two
synthetic buckets, every availability edge gone, on a green build. That is
strictly worse than the last-write-wins it replaced, and the same shape of
bug - a silent fallback standing in for a decision nobody can make
correctly. Reading the registry is now the only thing inside the try, so a
missing file is still tolerated and a self-contradictory one reaches the
surface.

Its sibling: loadProducts was called lazily from detectProduct, which runs
inside the per-page try/catch, so even a correct throw would have arrived
as ~620 "skipped page" warnings followed by the drift guard's "page
selectors likely drifted" - the wrong diagnosis. Loaded once, eagerly,
before the loop.

clipMarkdown balanced fences on its short path and not on its demote path.
`open` is computed over the raw cut and the fence test is line-anchored, so
"### # ``` weird" holds no fence - then demoteLine strips the markers, the
backticks land at column 0, and the result carries an opener with no
closer. Measured: limit 12 on that input returned "``` we", which
assertArtifactInvariants throws on, failing the build on content no docs
author could clear. Both paths now share balanceFences. Fuzzed over 60,000
adversarial inputs: 0 unbalanced.

readBaseline blocked the command it told you to run. A baseline missing
page_mrr exited 1 saying "Re-record it with --update-baseline", but that
write happens at the end of main() and the exit prevented ever reaching it,
so the only way out was deleting the file by hand. --update-baseline now
passes through; verified it refuses without the flag and repairs with it.

A malformed versions.json was swallowed as "no frozen versions", so
/7.6.14/ and /6.28.11/ would be indexed as duplicates of the root tree,
every module labelled version: "current". Invisible to both guards, because
the page count goes UP. Absent is still fine; present-and-unreadable now
throws.

And docs-preview.yml still described build-docs.yml as taking full history
on push and schedule, which stopped being true when this branch made it
shallow on every trigger. Mine, from the commit that made it conditional.

Verified without a full rebuild - 2.8 GB free and the last local build was
reaped for memory pressure - by exercising the real functions against the
real registry: 13 product keys, the same two aliases, and
/sdks/ios/label-capture/ -> smart-label-capture, /sdks/ios/matrixscan/ ->
matrixscan-batch, /sdks/linux/batch-scanning/ -> core,
/hosted/express/ -> general. tsc clean. CI runs the full build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eugenia-scandit eugenia-scandit mentioned this pull request Sep 22, 2026

@raffaelefarinaro raffaelefarinaro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Automated review, posted on Raffa's behalf and not yet read by him. Treat the findings as a starting point, not a verdict. Anything that looks wrong probably is, so push back.

Cheapest fix that unblocks the most: the PR has a fresh merge conflict with main again, in package.json's scripts block. git merge-tree $(git merge-base origin/main HEAD) origin/main HEAD on the current head (b5c57d3) shows one real conflict: main's #430 (same author, merged at 804180a, about 37 minutes after this branch's last commit) added verify:frameworks/test:frameworks/test:docs-gate at the same insertion point this branch adds docs:retrieval-evals. The GitHub API confirms it, mergeable: false and mergeable_state: "dirty", checked twice a few minutes apart. Same shape of conflict that blocked review twice before, just a new instance because main moved again. CI on the current head is green across all 6 checks, so this is a rebase, not a code problem. Inline comment on the exact spot.

What's good, concretely. I pulled the actual Docs Retrieval Evals log for the current head (run 35713917584) rather than trusting the PR description: it prints page-success@3 = 0.8596 and page-MRR = 0.775 over 520 pages / 4228 modules, matching baseline.json exactly, delta +0. The newest commit, b5c57d3, is careful work: it caught a real regression in the commit right before it. The alias-collision throw added in 74b5eec sat inside loadProducts's bare try, so the check meant to fail loud on a contradictory products.json would instead have been swallowed, leaving PRODUCT_KEYS empty and collapsing every /sdks/ page's product onto "core" or "general" on a green build - worse than the last-write-wins bug it replaced. I checked the restructuring: reading the registry is now the only thing inside the try, so a missing or unparseable file still degrades gracefully, but a genuinely contradictory products.json now throws and reaches the surface. The other four fixes in that commit also check out: shared fence-balancing between clipMarkdown's two early-return paths, a readBaseline deadlock where --update-baseline could never reach the write that repairs a bad baseline, and unguarded versions.json parsing that would have silently mislabeled frozen-version pages as duplicates of current.

Thread audit against this head. Everything previously open is resolved:

  • cheerio/gray-matter undeclared deps (lucatorella) - both are real package.json dependencies with matching yarn.lock entries.
  • Empty-extraction / low page-success-ratio not asserted (lucatorella) - postBuild throws directly now (the pagesProcessed === 0 || index.length === 0 check around line 2304, the 50% ratio check around line 2335), not just the eval script.
  • recall@k algebraically equal to precision@k (lucatorella) - the metric was removed rather than patched.
  • AUTO_FLOOR/AUTO_MRR_FLOOR/MIN_AUTO_MRR left as bare parseFloat (flagged two rounds ago) - fixed in 74b5eec, all three now go through the checked num() reader.
  • Titanium indexed despite llmsIgnoredSdkTrees, the bare-substring " auth"/"fix" intent matches, the llms-plugin division-of-labor question, build-docs.yml full-cloning with no concurrency group, and the earlier package-lock.json conflict - all confirmed resolved in earlier rounds and still hold on this head.

Still legitimately open, not stale: the FeatureList table-view accessibility thread. Eugenia's reopening of it holds up under an independent check - every <FeatureList> call site in docs/, versioned_docs/ and src/ passes displayMode="compact", so renderTableView (and the aria-disabled plus visually-hidden fix on its chip) never runs today. That's a real open product question, not a defect: does the table view need to exist at all, or is the honest cleanup deleting it along with the accessibility work riding on it? Worth leaving open until that's answered rather than closing as stale.

One new, non-blocking finding inline: classifyLinks's link regex truncates any href containing a literal ) before the link's real closing paren. Small blast radius, plausible for both external URLs and API-reference fragments, worth a one-line fix.

Full write-up, including this at more length, is in memory-vault/work/projects/active/docs-improvement/review-pr418.md in the ciao repo.

Comment thread package.json
"docs:gate:setup": "node scripts/setup-vale.cjs",
"verify:search-tags": "node scripts/verify-search-tags.cjs",
"test:search-facets": "node scripts/test-search-facets.cjs",
"docs:retrieval-evals": "node scripts/retrieval-evals/run.cjs",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking for merge, not a code defect: fresh conflict with main. git merge-tree $(git merge-base origin/main HEAD) origin/main HEAD on this head (b5c57d3) shows a real conflict right here - main's #430 (same author, merged at 804180a, ~37 min after this branch's last commit) added verify:frameworks/test:frameworks/test:docs-gate at the same insertion point this branch adds docs:retrieval-evals. gh api pulls/418 confirms mergeable: false, mergeable_state: "dirty", checked twice a few minutes apart. CI on the current head is green, so this is a rebase, not a regression - same shape of conflict as the two earlier rounds in this PR, just a fresh instance because main advanced again since the last sync.

function classifyLinks(chunkMarkdown: string, site: string, baseUrl: string): { internal: string[]; api: string[] } {
const internal = new Set<string>();
const api = new Set<string>();
const re = /\[[^\]]*\]\(([^)]+)\)/g;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: this capture group stops at the first literal ), not the one that actually closes the markdown link. Verified: [Data Matrix](https://en.wikipedia.org/wiki/Data_Matrix_(symbology)) captures href as https://en.wikipedia.org/wiki/Data_Matrix_(symbology - truncated, missing the real closing paren and anything after it. External links like that will usually just fail the site// prefix checks below and get dropped silently, but the same shape hits an API-reference fragment like #init(context:), which would land in api.add() already truncated. A balanced-paren match, or matching up to the last ) on the line, would close it.

…aren

#430 merged about half an hour after this branch's last commit and added
verify:frameworks, test:frameworks and test:docs-gate at the same insertion
point in package.json's scripts block that this branch uses for
docs:retrieval-evals. Resolved as a union - both sets are real and neither
supersedes the other - so the block now carries all of them plus main's
api-reference-seo scripts from #431.

classifyLinks matched hrefs with `[^)]+`, which stops at the first literal
`)` rather than the one closing the markdown link. Two shapes in this
corpus contain one:

  [Data Matrix](https://en.wikipedia.org/wiki/Data_Matrix_(symbology))
    -> https://en.wikipedia.org/wiki/Data_Matrix_(symbology
  [init](https://docs.scandit.com/.../ns-scan.html#init(context:))
    -> https://docs.scandit.com/.../ns-scan.html#init(context:

The first is close to harmless: a truncated external URL fails the `site`
prefix tests and is dropped. The second is not. It is an API-reference
fragment, so it passes those tests and enters `api` with the fragment cut
mid-signature - and a Swift or ObjC selector in that form is exactly what
the API reference anchors on, so the CitesApi edge points at nothing.

Now matches one level of balanced parens. Checked against a nested-paren
external URL, an API fragment, a plain absolute link, a relative link, and
two links on one line - all extract whole. Full nesting would need a
parser, and CommonMark only guarantees one level without escaping.

Merge verified: no conflict markers in any text file, the FeatureList
accessibility chip and the display:none removal both survived, tsc clean.
The corpus effect of the regex change is left to CI - 2.5 GB free here and
the last local build was reaped for memory pressure.

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

@raffaelefarinaro raffaelefarinaro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Automated review, posted on Raffa's behalf and not yet read by him. Treat the findings as a starting point, not a verdict. Anything that looks wrong probably is, so push back.

Cheapest fix that unblocks the most: there isn't one today, because both blocking items from yesterday's round are already fixed on this head. 06dfdd3 merges main (resolving the package.json scripts-block conflict as a real union - all four script sets coexist at the same insertion point, none overwriting another) and fixes the link-regex truncation flagged yesterday. gh api pulls/418 now reports mergeable: true, CI is 6/6 green on this exact head, and the retrieval-evals log for this head (run 35836621420) prints page-success@3 = 0.8596 / page-MRR = 0.775 over 520 pages / 4228 modules, matching baseline.json exactly, delta +0.

What's good, concretely. The paren-balancing regex fix is correct, not just plausible - I traced both corpus examples cited in the commit message by hand (Data_Matrix_(symbology) and ns-scan.html#init(context:)) and the new pattern captures the full href in both cases, stopping at the real closing paren instead of the first literal one. And the package.json merge is a genuine union rather than a pick-one-side resolution: the scripts #430 added and docs:retrieval-evals from this branch all survive at the same insertion point.

Two small, non-blocking doc-accuracy findings, inline. The plugin's own header comment has drifted from the code it describes: it still says the two AI-export plugins share customFields.assistantIgnoreFiles, but the actual mechanism (both docusaurus.config.ts's own comment and the plugin code itself) deliberately moved to a per-plugin options.ignoreFiles, specifically to avoid a customFields key that has to be kept in step. The PR description repeats the same stale claim ("Both read customFields.assistantIgnoreFiles"). Separately, the header cites "4,386 modules" for the corpus - true before the Sept 22 BM25/freshness round, but the current number, in both baseline.json and this head's own CI run, is 4,228. Neither affects behavior, but both are exactly what a future maintainer will grep for and not find.

Thread audit against this head. Every previously open thread is now resolved:

  • cheerio/gray-matter undeclared deps (lucatorella) - both real package.json dependencies with matching yarn.lock entries.
  • Empty-extraction / low page-success-ratio not asserted (lucatorella) - postBuild throws directly; confirmed the zero-extraction check and the 50% ratio check are both there, not behind a swallowing catch.
  • recall@k algebraically equal to precision@k (lucatorella) - the metric was removed, not patched.
  • AUTO_FLOOR/AUTO_MRR_FLOOR/MIN_AUTO_MRR left as bare parseFloat (flagged two rounds ago) - confirmed all three now go through the checked num() reader.
  • Alias-collision guard not stopping two products from claiming the same route segment (flagged as non-blocking two rounds ago) - confirmed fixed in b5c57d3: the collision throw now sits outside the file-read try, so a genuinely contradictory products.json reaches the surface instead of silently degrading to an empty PRODUCT_KEYS.
  • Both package.json conflicts (Sept 18 and Sept 23 instances) and the link-regex truncation (Sept 23) - all fixed on this head, as above.
  • Everything from the Aug 11 / Sept 8 / Sept 9 / Sept 18 / Sept 19 rounds (Titanium exclusion, bare " auth" substring match, last_verified build-timestamp bug, MIN_AUTO_SUCCESS defaulting to 0, FeatureList screen-reader announcement, score() length normalization, search() per-page dedup, docs-retrieval-evals.yml concurrency group, build-docs.yml shallow clone) - all re-checked and still hold, nothing regressed.

Still legitimately open, not stale: the FeatureList table-view accessibility thread. All 50 <FeatureList> call sites in the repo pass displayMode="compact", so renderTableView - and the aria-disabled/visually-hidden fix riding on it - never executes today. That's a real product question, not a defect: does the table view need to exist at all, or should it (and the accessibility work on it) be deleted until something actually calls it?

Also still open, as an acknowledged tradeoff rather than a bug: the 400/300-char excerpt truncation. The header now states the pointer-index design explicitly and correctly (api_refs/references come from the chunk's own links, not discarded), so this is answered as intentional - but nobody in the thread has named a concrete consumer that needs more than a pointer plus a snippet. Worth a firmer answer before merge if one exists.

Full write-up, including the header-comment drift at more length, is in memory-vault/work/projects/active/docs-improvement/review-pr418.md in the ciao repo.

* 2.28 MiB, so per-module text is an expensive way to store text. The argument
* rests on that and on the `url`, NOT on a claim that each module's prose also
* ships in llms-full.txt: the two artifacts cut the corpus differently - 423
* sections there and 410 links in llms.txt, against 4,386 modules here - so

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking, doc-accuracy: this cites "4,386 modules" for the corpus, which was the real count before the Sept 22 BM25/freshness round. baseline.json and this head's own CI run (page-success@3 = 0.8596, page-MRR = 0.775) both say 520 pages / 4228 modules. Small, but this header is where someone will look to understand the current shape of the index.

* for deciding which page or chunk to read - intents, audiences, channels,
* frameworks, products, cites-API, see-also, availability - which a flat text
* dump cannot express. They share a source of truth for what an assistant may
* see (`customFields.assistantIgnoreFiles`) so a curation decision is made once.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking, doc-accuracy: this says the two AI-export plugins share customFields.assistantIgnoreFiles, but neither actually reads it. docusaurus.config.ts's own comment ("Neither assistantIgnoreFiles nor knowledgeIndexIgnoreFiles is exported [via customFields] here... each consumer takes its own list as a plugin option instead") and this file's code (options?.ignoreFiles and the "the ignoreFiles option is missing" error a few hundred lines down) confirm the real mechanism is a per-plugin option, not a customFields key. This sentence predates that refactor, and the PR description repeats the same stale claim ("Both read customFields.assistantIgnoreFiles").

eugenia-scandit and others added 3 commits September 24, 2026 08:57
Review flagged the module count as stale. Measuring the rest of the header
against a real build found it was not alone - five figures had drifted, not
one:

  modules            4,386  ->  4,228
  index size         10.41  ->  10.09 MiB
  graph size         11.62  ->  11.29 MiB
  docs_excerpt avg   376.5  ->  379.9
  assistant avg      521.2  ->  525.2

Only llms-full.txt at 2.28 MiB and 410 llms.txt links were still right.

Re-pinning them would set up the fourth round of the same correction, so
the paragraph now argues in magnitudes - around 10 MiB, around 11, roughly
6 more, against about 2.3 for the whole prose corpus - which is all the
argument ever needed and which survives a chunking change. Where to get
exact values is named instead: baseline.json for pages and modules, the
built artifacts for sizes, and the extractor's own postBuild line, all of
which are derived rather than typed. This is the same conclusion #436
reached about measurements in a published file, applied to a source
comment: nothing checks a number in a comment.

Dropped the "423 sections" figure with them. I could not reproduce it from
the built llms-full.txt under any reading I tried, and the sentence it
supports - that the two artifacts cut the corpus differently - is better
made structurally: llms.txt indexes whole PAGES, this indexes CHUNKS,
several per page, so there is no per-module correspondence either way.

And the DIVISION OF LABOUR paragraph still said the two plugins share
`customFields.assistantIgnoreFiles`. Neither reads it. Both take their list
as a plugin option, which this branch changed deliberately - a customFields
key is a second name that has to be kept in step, and it had already
drifted once. The curation decision is still made once; only the transport
changed. The PR description carried the same stale sentence and is fixed
too.

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

The previous commit stopped pinning drifting figures in the header and
pointed readers at postBuild for current values instead. That pointer was
only half true: postBuild printed modules, pages, nodes and edges, but not
sizes. idxMb and graphMb were already computed a few lines above for the
soft-cap check and were printed ONLY when that check failed - so the figure
a reader most often wants was visible only when the build was about to
break.

Both now appear on the normal success line, derived from the bytes just
written rather than typed into a comment. That is the whole point of the
previous commit: a number nobody maintains goes stale, a number the build
computes cannot.

Header updated to say what is actually reported, and to name `yarn build`
plus the last [knowledge-extractor] line as the way to get it.

tsc clean. The line itself is left to CI to print - 4.5 GB free here, but
the last local build was reaped for memory pressure and CI reports this for
nothing.

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

review found that `page_success_at_k = ok / rows.length` is NaN when rows
is empty - an index whose records carry no `url` - and `NaN < floor` is
false, so `breached` stays false and the gate prints "ok" and exits 0 on an
index it never measured. That is exactly the hazard num() and readBaseline()
were written to close, left open one level above them. Now refuses: exit 1
on a url-less index, unchanged exit 0 on the real one, both checked.

And the comment I added to the link regex claimed a harm that cannot
happen. It said a truncated API href "lands in `api` with the fragment cut
mid-signature" - but `api.add(href.split("#")[0])` discards the fragment
anyway, so no anchor ever reaches api_refs. The external case is dropped by
the `site` prefix test, and no link in docs/ has a paren in its href at all
(zero matches).

I also told the PR that the +2 graph nodes after that change were "two
API-reference targets that were previously truncated now resolving". I
cannot support that: the same commit merged main, which changed docs, and I
never isolated the two. Withdrawn.

The fix itself stands and stays - a parser that truncates on a shape
CommonMark allows is wrong, and a truncated INTERNAL path would enter
`internal` as a doc URL resolving to nothing with no error anywhere. It is
a correctness fix for the parser, not a repair of live damage, and the
comment now says so.

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

@raffaelefarinaro raffaelefarinaro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Automated review, posted on Raffa's behalf and not yet read by him. Treat the findings as a starting point, not a verdict. Anything that looks wrong probably is, so push back.

Cheapest fix first: the PR description's first paragraph still says a missing or malformed customFields.knowledgeIndexIgnoreFiles is one of the throw conditions. That's stale. The mechanism changed during review (commit 59fbb07) to a plugin option instead (options.ignoreFiles here, ignoreFiles on docusaurus-plugin-llms), specifically so each consumer has one name rather than a customFields key that drifts from the list it mirrors. The code comment got fixed; the top-level description still repeats the exact claim thread #4090540829 flagged. One-line edit, and it saves the next reader from looking for a customFields entry that no longer exists.

What's good here: the plugin fails loud in the right places (empty extraction, a page-error ratio over 50%, an unbalanced excerpt fence) matching the repo's own onBrokenLinks: "throw" convention, and the artifact write rolls the index back to its previous content if the paired graph write fails, rather than leaving a mismatched pair on disk. The review trail is also unusually honest with itself - several replies walk back a prior claim once it didn't hold up under measurement (the "keywords are chunk-derived" claim, the llms-full.txt per-module correspondence claim), which is exactly the discipline the file's own comments ask of the next person touching it.

CI is green, 6/6, at the current head (2feeee1). One thing worth a sanity check before merge: gh api pulls/418 currently reports mergeable: false, mergeable_state: "dirty", but git merge-tree $(git merge-base origin/main HEAD) origin/main HEAD against the latest origin/main (875c305) shows zero conflict markers, and the pull_request-triggered checks (Build Docusaurus Site, Docs Retrieval Evals, etc.) did run and pass at this exact head - which they couldn't do if GitHub had failed to build the merge ref. Reads like a stale mergeability cache rather than a real conflict; worth refreshing rather than trusting the flag as-is.

Five inline threads look fixed but were never closed, so they're still showing red:

  • #3720310854 / #3720500695 (cheerio imported but not a declared dependency): fixed same day in 1748eaf - cheerio is in dependencies in the current package.json. "Still open at d948727" is the last word posted in that thread, about an hour before the fix landed.
  • #4079526048 (fresh conflict with main at b5c57d3): resolved by the 06dfdd36 merge-main commit; confirmed no conflict against current origin/main above.
  • #4079526062 (link regex truncates at the first literal paren): the balanced-paren pattern is in the current code at index.ts:1176; 2feeee19 additionally walked back an oversold claim in the comment about what the truncation could have broken.
  • #4090540817 (header citing a stale "4,386 modules"): fixed in 59fbb07, which stopped pinning exact figures in the comment and pointed at baseline.json / the build's own success-line output instead.
  • #4090540829: same commit fixed the code comment's customFields claim - see the PR-description note above, since that part is still live.

One new item, non-blocking: build-docs.yml has no concurrency group, unlike docs-preview.yml and the new docs-retrieval-evals.yml, both of which got one during this review for the same reason - a full yarn build plus several test steps queuing on every push instead of the newest one superseding the rest. This job has no paths: filter, so it runs on every PR to the repo, not just this one; a PR with this many commits queues that many redundant full builds here too. Left an inline note on the touched line.

Also non-blocking: the extractor is about 2,456 lines of glob compiling, regex classification, chunk clipping and graph mining, and its correctness today is established by measuring against the real corpus during review (counts pasted into replies) plus the retrieval evals, not by anything that pins a behavior and fails fast on a regression. The repo already has the pattern for this (test:frameworks, test:docs-gate, test:search-facets, test:api-reference-seo) - a handful of deterministic cases for the glob compiler and the intent classifiers would catch the next word-boundary-style regression (like the two pickIntents substring bugs this thread already found) without needing a full rebuild and a manual count to notice it.

Left open by design, not re-asking: whether FeatureList's non-compact view (the one the accessibility fix in this PR made internally coherent) should exist at all, given all current usages pass displayMode="compact". Already on the thread; flagging only that it's still an open call, not a resolved one.

# docs-retrieval-evals.yml is the one that does use the dates - it
# reports freshness coverage and fails on --require-freshness - and it
# keeps fetch-depth: 0 for that reason.
fetch-depth: 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking, reuse: this job has no concurrency group, unlike docs-preview.yml and the new docs-retrieval-evals.yml, both of which got one during this review for the same reason (a full yarn build plus several test steps queuing on every push rather than the latest one winning). This workflow has no paths: filter, so every PR to the repo pays for that, not just this one - and this PR alone has roughly 30 commits that would have triggered it. Worth the same pattern, e.g. concurrency: { group: build-docs-${{ github.event.number || github.sha }}, cancel-in-progress: true }.

@raffaelefarinaro raffaelefarinaro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Automated review, posted on Raffa's behalf and not yet read by him. Treat the findings as a starting point, not a verdict. Anything that looks wrong probably is, so push back.

Cheapest fix that unblocks the most, and a correction to yesterday's review: this PR has a real merge conflict against main right now, in three files (package.json, scripts/test-search-facets.cjs, docusaurus.config.ts). I checked with git merge-tree --write-tree origin/main HEAD, which runs git's actual merge algorithm rather than just diffing blobs, and it reports CONFLICT in all three. Two are trivial: package.json is the same recurring shape as before, main added test:robots at the same insertion point this branch adds docs:retrieval-evals (line 22), keep both lines. scripts/test-search-facets.cjs:184 is a real double-fix: PR #446 (merged yesterday, the 7.6.15 version bump) independently fixed the same hardcoded-tag bug this branch fixed, but via frozen[0] instead of this branch's LEGACY.replace("docusaurus_tag:", "") — since LEGACY = tag(frozen[0]) two lines up, they're the same value reached two ways, so either wins. docusaurus.config.ts is the substantive one: PR #436 ("Add robots.txt", merged 2026-09-24) added a robotsTxtPlugin and its own config additions in the same neighborhood this branch edits (the ignore-list refactor around line 105-160, and the plugins array around line 871). The two sets of changes don't actually overlap in what they do, just in where they land, so this should resolve to keeping both blocks, but it needs a human to do that.

On the correction itself: yesterday's review checked mergeability with the legacy two/three-argument git merge-tree and read "zero conflict markers" as evidence that the mergeable: false GitHub was reporting was a stale cache. I ran that same legacy form again today and it also comes back clean for docusaurus.config.ts and test-search-facets.cjs — but git merge-tree --write-tree against that exact same main commit (875c305) shows the conflict was already there yesterday. The legacy form doesn't do a real three-way text merge, so it's the wrong tool to clear a mergeable: false with; --write-tree (or just trusting the GitHub flag) is the one to use here going forward.

What's good, concretely: CI is still 6/6 green, verified against this exact head SHA (2feeee19..., matches every check run's own head_sha, not just the branch name). Nothing in the code changed since yesterday's round (last commit is still 2feeee1, from 2026-09-24), so the six earlier rounds' fixes all still hold — I re-checked the cheerio dependency, the balanced-paren link regex, the empty-extraction and page-error-ratio guards, and the FeatureList contrast fix, and all are exactly where the last review said they'd be.

Two threads from previous rounds are still legitimately open, not stale, since nothing has changed to resolve them: whether build-docs.yml should get a concurrency group like its two siblings now have (thread from yesterday, #4101679788, still unaddressed), and whether FeatureList's non-compact table view should exist at all given every one of the 50 call sites in the repo passes displayMode="compact" (re-verified this count directly against the source rather than trusting the prior number).

Full write-up, including the merge-conflict detail and the tool-choice correction at more length, is in memory-vault/work/projects/active/docs-improvement/review-pr418.md in the ciao repo.

Comment thread package.json
"docs:gate:setup": "node scripts/setup-vale.cjs",
"verify:search-tags": "node scripts/verify-search-tags.cjs",
"test:search-facets": "node scripts/test-search-facets.cjs",
"docs:retrieval-evals": "node scripts/retrieval-evals/run.cjs",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fresh instance of the recurring conflict, confirmed with git merge-tree --write-tree origin/main HEAD (git's real merge algorithm): main added test:robots at the same insertion point this branch adds docs:retrieval-evals. Mechanical to resolve, keep both script lines, but it's blocking the merge button right now and needs a rebase.

// that no version may be hardcoded here - LEGACY and API76 either side of
// this line are derived - so retiring 7.6 would have failed the suite for a
// reason unrelated to search correctness.
const out = rewriteVersionTag(filters, LEGACY.replace("docusaurus_tag:", ""), MAP);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This line conflicts against current origin/main. PR #446 (merged yesterday, the 7.6.15 version bump) independently fixed the exact same hardcoded-tag problem this line fixes, but via a different route: main uses frozen[0] directly, this branch uses LEGACY.replace("docusaurus_tag:", ""). Since LEGACY = tag(frozen[0]) at line 123, the two expressions are the same value reached two ways — low risk to reconcile, either survives, but it is a genuine textual conflict and not an artifact of a stale mergeability flag.

Comment thread docusaurus.config.ts
import remarkOffloadPreviewMedia from "./src/plugins/remark-offload-preview-media";
import stripPreviewMediaPlugin from "./src/plugins/plugin-strip-preview-media";
import { UNRELEASED_FRAMEWORK_SLUGS } from "./src/constants/unreleasedFrameworks";
import knowledgeExtractor from "./src/plugins/knowledge-extractor";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This whole file now conflicts against current origin/main, confirmed with git merge-tree --write-tree (the legacy 3-arg git merge-tree used in earlier rounds under-reports this and reads clean, which is worth knowing for next time). PR #436 (robots.txt, merged 2026-09-24) added a robotsTxtPlugin function and wired it into the same plugins array and nearby ignore-list region this PR edits (assistantIgnoreFiles/llmsIgnoreFiles/knowledgeIndexIgnoreFiles around line 105-160, and the [knowledgeExtractor, ...] registration around line 871). The two additions don't overlap logically, each owns its own consts and its own plugin entry, so this should resolve to keeping both blocks — but git can't do that automatically given how close the insertion points are. Worth rebasing before a third file joins the conflict list.

@eugenia-scandit

eugenia-scandit commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

Closing this. Based on our testing, it isn't needed: Agent Skills, together with an MCP server that reads the live docs, retrieve information very well, so a build-time knowledge graph and retrieval index would have no consumer.

Thank you @raffaelefarinaro and everyone who reviewed it. The review rounds were thorough, and several findings (the llmsIgnoredSdkTrees mismatch, the truncated excerpts, last_verified) are worth keeping in mind for the docusaurus-plugin-llms export, which remains the AI-facing layer.

The one change here that stands on its own, the FeatureList chip for a framework with no API reference, moved to #451. The test-search-facets.cjs fix already reached main through #446.

moritzhartmeier pushed a commit that referenced this pull request Oct 9, 2026
…on-link (#451)

* fix(feature-list): render a framework without an API reference as a non-link

A framework cell with no apiUrl rendered an <a> with no href:
withCurrentDocsPath returns undefined, React omits the attribute, and the
chip stayed a link that is not keyboard-focusable, navigates nowhere and
still shows the link hover. 16 cells in features.json reach that branch.

It now renders a <span> with a dashed border, a dimmed colour token that
keeps AA contrast (the old opacity rule composited to 3.19:1), and a
visually hidden "(not available)" so screen readers get the same signal.
The framework column's container, which was display: none, gets a minimal
wrapping layout so the chips are legible.

Latent today: all 50 FeatureList usages pass displayMode="compact", which
skips this column. Split out of #418, which is being closed.

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

* Describe a chip without an API reference accurately to screen readers

- The hidden text says "(no API reference)" instead of "(not available)":
  every chip in this column is a framework that supports the feature.
- Drop aria-disabled: a plain span is not a control, and ARIA 1.2 does
  not allow the attribute on generic elements.
- Shorten the title to "No API reference" so screen readers don't
  announce the name and version twice.

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

---------

Co-authored-by: Claude Opus 5.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.

4 participants