feat: build-generate AI retrieval layer (knowledge graph + index) - #418
feat: build-generate AI retrieval layer (knowledge graph + index)#418eugenia-scandit wants to merge 31 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
🤖 Two build/deploy-integrity findings inline.
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
left a comment
There was a problem hiding this comment.
🤖 Re-reviewed 65676e09..d948727b. One finding on the new metric; notes on the two earlier threads.
…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
left a comment
There was a problem hiding this comment.
🤖 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:
cheerioandgray-matterare both declared independenciesas of1748eaf, with matching entries in both lockfiles.- The zero-modules floor check at
index.ts:697and the.tmpplus rename writes atindex.ts:712landed in the same commit, which is exactly what the thread asked for. - Recall was removed and replaced with an honestly named
MIN_PRECISIONin972e6cb, 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.
| ], | ||
| // Build-generate AI layer: emits /assets/knowledge-retrieval-index.json and | ||
| // /assets/knowledge-graph.jsonld from the rendered HTML (see src/plugins/knowledge-extractor). | ||
| knowledgeExtractor, |
There was a problem hiding this comment.
Blocking, as a question rather than a defect: what is the division of labour between this and docusaurus-plugin-llms?
Seven lines above this, the same config registers docusaurus-plugin-llms, which already emits an AI-consumable layer from the same docs on the same build: llms.txt at 0.07 MB and llms-full.txt at 2.03 MB in my build. That plugin's setup here is deliberately curated, with shared-partial dedup, non-Web-SDK root trimming, and measured before/after numbers in the header comment.
This plugin adds a second layer with different curation rules and 26.9 MB of output. I am not assuming one should go, but two parallel AI exports with no stated boundary is the thing most likely to rot, and the next person to touch either one will not know which is authoritative. Could you add a line to the PR description saying what each is for, and whether the intent is for one to eventually replace the other?
| } catch { | ||
| /* no versions.json */ | ||
| } | ||
| const excluded = new Set<string>([...frozenVersions, "data-capture-sdk", "assets", "img", "fonts", "search"]); |
There was a problem hiding this comment.
Blocking: this exclusion set does not honour a curation decision made elsewhere in the same config.
docusaurus.config.ts:49 declares llmsIgnoredSdkTrees = ["docs/sdks/titanium/**"] with the comment "Entire platform omitted from llms export (deprecated / not needed for assistant context)". build/llms.txt has zero /titanium/ references. This index has 219 modules under /sdks/titanium/.
Nothing secret leaks, so this is not a security problem, but the repo has already decided which trees it wants an assistant reading, and this artifact overrides that decision silently and publishes the result to the public site.
Was that deliberate? If the new layer should have wider coverage than the llms export, that is a defensible answer and worth writing down. If not, reusing llmsIgnoredSdkTrees and the shared-partial dedup list would keep the two layers consistent for free.
| channels: m.channels, | ||
| dependencies: m.dependencies, | ||
| tags: m.tags, | ||
| docs_excerpt: m.content.docs_markdown.slice(0, 400), |
There was a problem hiding this comment.
Blocking: the index does not ship the content it chunks.
CHUNK_TARGET_CHARS is 1400 at line 30, but these two lines truncate to 400 and 300. In my build, 4031 of 4523 records (89%) sit exactly at the 400-character cap and 4475 (99%) sit exactly at the 300-character cap. So roughly 70% of every chunk gets parsed, chunked, held in memory, and then discarded, and each published record is a fragment cut mid-sentence.
The header comment describes this as the artifact "an assistant / in-docs search consume". Against that goal, a consumer gets a snippet and still has to fetch the URL, which llms-full.txt already covers.
I want to be careful here, because I could not see who actually consumes these files. If the design intent is a pointer index where a 400-character preview is enough, this is fine as built and I would just note it in the header. If the intent is self-contained retrieval modules, then either raise the caps or drop the chunking to page level, because the 1400-character chunking currently buys nothing that survives into the output. Which is it?
| if (contentType === "tutorial" || contentType === "how-to" || text.includes("configure")) intents.push("configure"); | ||
| if (contentType === "troubleshooting" || text.includes("error") || text.includes("fix")) intents.push("troubleshoot"); | ||
| if (contentType === "reference" || contentType === "concept" || text.includes("integrat")) intents.push("integrate"); | ||
| if (text.includes("secure") || text.includes(" auth")) intents.push("secure"); |
There was a problem hiding this comment.
text.includes(" auth") fires on any word starting with "auth", not just authentication. Every one of the 31 modules tagged with the secure intent in my build matched on authorized, authenticity or authority.
A word-boundary regex fixes it, something like /\bauth(entication|orization|n)?\b/. Small blast radius, easy fix.
…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>
|
…rent"
Two of the four review findings were real; the other two did not reproduce and
are noted below so nobody re-chases them.
VERSION LABEL. `const version = "current"` and "skip the dirs named in
versions.json" are both correct only while `lastVersion` is "current". On a beta
cycle - update-version.py minor_beta snapshots the release and sets
DOCS_LAST_VERSION="8.6.0" - Docusaurus serves the FROZEN 8.6.0 at the root and
moves the in-development tree to /next/. The root pages, which are 8.6.0, would
have been labelled "current", and /next/ would have been indexed a SECOND time,
also as "current", with no frontmatter match because its source path does not
exist under docs/. Neither the empty-output guard nor the 0.5 ratio guard fires
on that, because the page count goes UP.
The served version now comes from the config (`customFields.lastVersion`, added
alongside the other search-tag derivations) and `next` is excluded whenever a
frozen version is at the root. Verified on this build: 4,637 modules from 616
pages, 0 errors, every module `version: "current"`, 0 modules from /next/.
HARDCODED VERSION IN THE TEST. `rewriteVersionTag(filters, "docs-default-7.6.14",
…)` sat in the file whose stated premise is that no version may be hardcoded,
between a LEGACY and an API76 that are both derived from the manifest. Retiring
7.6 would have failed `yarn test:search-facets` for a reason unrelated to search.
It now derives the tag the same way. The remaining `docs-default-9.9.9` is a
deliberately synthetic unknown tag, not a repo version.
DID NOT REPRODUCE, checked directly:
The KMP API reference is NOT missing. /data-capture-sdk/kmp/search.html is
HTTP 200 with a real searchindex, and the tree has content -
kmp/core/api/data-capture-context.html is 46,553 bytes,
kmp/barcode-capture/api.html is 35,128. The premise (zero data-capture-sdk
references under docs/sdks/kmp) is true but means the KMP docs do not LINK
their API tree, not that it 404s. Worth fixing separately: nothing in the docs
points a KMP reader at it.
The release-notes prose was not touched by this PR. `git diff origin/main..HEAD
-- docs/sdks/{android,ios}/release-notes.md` is empty, and both "Invalid
Format." and "California–Driver's License" are byte-identical on main. Whatever
their merits, they predate this branch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
raffaelefarinaro
left a comment
There was a problem hiding this comment.
🤖 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.
Seven of the eight existing review threads are stale. Closing them saves the most time here, so start there.
Fixed, safe to close:
cheerioundeclared dependency (two lucatorella threads) —cheerioandgray-matterhave been independenciessince1748eaf, matching lockfile entries confirmed.- Empty extraction not asserted (two lucatorella threads) — the plugin now throws when
pagesProcessed === 0 || index.length === 0, plus a 0.5 partial-drift ratio guard, plus best-effort atomic tmp+rename with rollback on partial failure.docs-retrieval-evals.ymlalso gained apush: maintrigger, closing the gap Luca flagged about nothing checkingmain. recall@kalgebraically equal toprecision@k— replaced with an honestly-namedMIN_PRECISIONthreshold in972e6cb.package-lock.jsonconflict blocking everypull_requestrun — resolved by the merge frommainat5d54997, which picked upmain's deletion of the file (#416).git statuson this worktree confirms it, and CI is green: 6/6 checks passing,mergeable: MERGEABLE.- Contradictory
coreavailability edges (AvailableOn+NotAvailableOnfor the same product/framework pair) — fixed with theSYNTHETIC_PRODUCTSset (core,general) excluded from availability mining, landed in0110d02. last_verifiedbeing the build timestamp for every module — fixed by reading each file's real last-commit date via one batchedgit log(0110d02), withfetch-depth: 0added tobuild-docs.yml,docs-preview.yml, anddocs-retrieval-evals.yml(284aaca) so the shallow-clone case degrades to""instead of a fake constant.
What's genuinely good here beyond the fixes: the empty-extraction guard is layered correctly (total-failure throw, then a partial-drift ratio check that can't be gamed by measuring against a post-filter count), and the rollback logic on a failed graph rename actually restores the previous index rather than leaving a mismatched pair. That is real build-integrity engineering, not just a try/catch.
Still open, not stale: the substring auth-intent match, the chunk-truncation question, the docusaurus-plugin-llms division-of-labour question, and the Titanium exclusion mismatch are all inline below, unchanged since the last review. One new item on the latest commit (7ed5493) is also inline.
Non-blocking, left out of inline comments to stay under the cap: MIN_AUTO_SUCCESS in scripts/retrieval-evals/run.cjs:44 still defaults to 0, so --auto can never fail its own gate. Still latent rather than live, since no workflow passes --auto today — but worth a real default whenever that mode gets wired into CI.
| if (contentType === "tutorial" || contentType === "how-to" || text.includes("configure")) intents.push("configure"); | ||
| if (contentType === "troubleshooting" || text.includes("error") || text.includes("fix")) intents.push("troubleshoot"); | ||
| if (contentType === "reference" || contentType === "concept" || text.includes("integrat")) intents.push("integrate"); | ||
| if (text.includes("secure") || text.includes(" auth")) intents.push("secure"); |
There was a problem hiding this comment.
Still text.includes(" auth"), unchanged since the Aug 11 thread on pickIntents. Any word starting with "auth" (authorized, authenticity, authority...) still gets classified into the secure intent — small blast radius, but a real defect. A word-boundary regex like /\bauth(entication|orization|n)?\b/ fixes it in one line.
There was a problem hiding this comment.
Fixed — now /\bauth(?:n|entication|orization)?\b/.
I measured the blast radius across the built HTML before touching it, and it is
the other way round from "small": the substring matched authenticity 12
times, authentic 10 and authority 5 — every one of them the ID-document
sense, about whether a passport is genuine — against authorization twice.
So the old test was filing roughly 27 document-verification mentions under the
security intent and 2 real ones.
Verb forms are deliberately left out of the alternation: authenticate does not
occur in this corpus at all, while authorized does — in "authorized dealer" —
so adding them would re-admit the false positives to catch nothing. The counts
are in the comment so the next person can re-measure instead of reasoning.
| // ``` - the very defect this plugin treats as a bug elsewhere. Restoring | ||
| // real newlines in code samples made it more likely, not less (measured 693 | ||
| // -> 783 records). Cut at the last safe point instead. | ||
| docs_excerpt: clipMarkdown(m.content.docs_markdown, 400), |
There was a problem hiding this comment.
The truncation finding from Aug 11 is only partially addressed. assistantExcerpt's clip logic did improve (clips the chunk itself rather than the whole templated string, so it averages ~515 chars now instead of ~300), and KModule.content.docs_markdown internally carries the full chunk up to the 1400-char CHUNK_TARGET_CHARS target. But toIndexRecord never surfaces m.content — only this docs_excerpt (400 chars) and assistant_excerpt reach the published index.json. So most of every chunk is still parsed, chunked, held in memory, and discarded before publish. Same open question as before: is this meant to be a pointer index (fine as built, worth a header note) or a self-contained retrieval module (then ship content.docs_markdown, or shrink CHUNK_TARGET_CHARS to match what's actually published)?
There was a problem hiding this comment.
Answered as a pointer index, documented in the plugin header rather than
left implicit — and one part of the framing I want to push back on.
The push-back. "Most of every chunk is still parsed, chunked, held in
memory, and discarded before publish" — the chunk text is not discarded, it is
the input the published fields are derived FROM. On the current build the chunk
prose yields 2,823 api_refs, 1,053 internal references, 18,846
keywords, plus a summary and intents for all 4,386 modules. What is not
published is the prose itself.
That is also why CHUNK_TARGET_CHARS is a granularity knob and not a
payload size: it decides which links land in which module's references and
api_refs, so shrinking it to match the excerpt would change what a module IS —
more, smaller modules with different edges — which is a retrieval-quality call
to make against the evals, not a size cleanup.
The decision, and its cost. The index is already 10.41 MiB with an
11.62 MiB graph; publishing content.docs_markdown would add roughly
6 MiB more. For scale, llms-full.txt holds the site's prose in 2.28
MiB, so per-module text is an expensive way to store text.
One correction to my own earlier draft of this answer: I had written that each
module's prose "ships next to it" in llms-full.txt. I could not establish
that and have dropped it — the two artifacts cut the corpus differently, 452
page sections there against 4,386 modules here, so there is no per-module
correspondence to appeal to. The argument rests on the url (the page itself,
always reachable) and on the ~6 MiB.
Recorded as a known limit, prompted by your comment: 10 MiB is large for a
browser to fetch whole. Nothing consumes it that way today; if something does it
needs a served index or a split by framework, not a smaller excerpt.
docs_excerpt is also now documented honestly as averaging 376.5 with a max of
404, not "<=400" — clipMarkdown appends the fence closer after slicing, so
400 is the slice budget and not an enforced bound.
| // must run after any plugin that rewrites the build - stripPreviewMediaPlugin | ||
| // on a preview build in particular. Ordered before, it would extract from a | ||
| // state the deploy does not serve. | ||
| knowledgeExtractor, |
There was a problem hiding this comment.
The Aug 11 question here — what's the division of labour between this and docusaurus-plugin-llms, registered a few lines up — doesn't seem to have been answered anywhere in the PR description, commits, or thread replies. The plugin has roughly tripled in size and hardening since that question was asked (500 to 1750 lines), so the two-parallel-AI-exports-with-no-stated-boundary concern is more relevant now, not less. Worth a line in the description either way.
There was a problem hiding this comment.
Answered in the plugin header and in a comment beside the registration here,
so it is next to whichever one you are reading — and it will go in the PR
description too.
The boundary. docusaurus-plugin-llms ships the prose for
context-stuffing (llms.txt as a link index, 0.08 MiB; 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 — which a flat text dump cannot express, with only an
excerpt of the prose (10.41 MiB index, 11.62 MiB graph). Neither replaces the
other.
You were right that the concern grew rather than shrank, and that it had already
bitten — the Titanium thread. But my first attempt at fixing it was wrong in
both directions, and the honest answer is narrower than "they cannot disagree":
- Exporting only
llmsIgnoredSdkTreesleft them disagreeing about 39
modules (26 Linux stubs, 13 redirect-only root docs). - Exporting the whole llms ignore list over-corrected: it also carries a
corpus-shape decision — the prose repeated under every non-Web SDK root,
deduped to Web — and applying that here removed ~1,400 modules along with
the ability to answer "where in the Flutter docs does this live", which is
exactly what a routing index is for.
So the list is split at the seam that was already implicit in it.
assistantIgnoreFiles is the curation half — what no AI consumer should be given
— and is shared. llmsDedupedToWeb is the corpus half and is llms-only.
llmsIgnoreFiles is their concatenation, so every path is still declared once
and the one deliberate difference has a name and a reason beside it.
| } catch { | ||
| /* no versions.json */ | ||
| } | ||
| const excluded = new Set<string>([ |
There was a problem hiding this comment.
This exclusion set still doesn't skip docs/sdks/titanium/**, which docusaurus.config.ts's llmsIgnoredSdkTrees (line 61) explicitly excludes from the sibling docusaurus-plugin-llms export as "deprecated / not needed for assistant context." The Aug 11 thread on this is still open — a local build still indexes Titanium modules that the repo has already decided an assistant shouldn't see. Is broader coverage here deliberate, or should this reuse llmsIgnoredSdkTrees?
There was a problem hiding this comment.
Fixed — and fixed by applying your decision rather than paraphrasing it.
Measured: 4,637 modules to 4,386, zero from /sdks/titanium/, and the 39
modules you identified elsewhere (26 Linux stubs, 13 redirect-only root docs)
gone with them. Broader coverage was not deliberate; it was an oversight.
Two things went wrong in my first attempt, both worth recording:
- I reused
llmsIgnoredSdkTrees, which is one of six entries in what the llms
plugin is actually given — hence the 39. The plugin now reads a new
assistantIgnoreFiles, the curation half of that list. (Not the whole list:
see the division-of-labour thread.) - I derived route prefixes by string surgery — strip
docs/, strip a trailing
*. That understood exactly one glob shape and failed open on every
other: a brace list, a leading**/, a bare file path, a mid-path*all
matched nothing, silently. Adding a tree in any other form would have given a
green build that kept feeding an assistant what the repo had just decided to
hide.
It is now a small glob compiler that handles *, ** and literals and
throws on a shape it cannot express. Deliberately not a matcher dependency:
none is declared in this repo, and reaching for a transitive one is the mistake
this PR was already pulled up on with cheerio. Ten cases checked, including
the rejection of {a,b}.
A missing customFields.assistantIgnoreFiles now throws as well. Absent meant
"index everything", which is the wrong default for a curation list and invisible
to both output guards, because the page count goes up.
| // match because its source path does not exist under docs/. Neither the | ||
| // empty-output guard nor the ratio guard fires on that, because the page | ||
| // count goes UP. | ||
| const servedVersion = String(siteConfig?.customFields?.lastVersion || "current"); |
There was a problem hiding this comment.
New in 7ed5493. This fix targets the beta-cycle case (a frozen version served at root, next holding the unreleased tree), but the commit's own verification numbers ("4,637 modules from 616 pages, 0 errors, every module version: 'current', 0 modules from /next/") are only reachable when servesCurrent is true — which is the branch that was already correct before this change, since both the version label and the next exclusion are no-ops when servesCurrent. Was the servesCurrent === false path (the actual bug being fixed) verified against a real frozen-at-root build, or only reasoned through?
There was a problem hiding this comment.
You were right, and it is worth saying that you were right twice.
First, on this comment: the numbers I quoted came from a
servesCurrent === true build, which is exactly the branch that was already
correct. So I verified the other one — with DOCS_LAST_VERSION temporarily set
to 7.6.14, the extractor produced 2,908 modules from 463 pages, every one
labelled version: "7.6.14", zero from /next/.
Second, that test was still not enough, and the next review round caught it:
it exercised the version LABEL only. That build's root pages were the docs/
tree, so the source lookup was never asked the hard question. On a real beta
build the root is served from versioned_docs/version-<served>/ while docs/
becomes the unreleased tree, and sourceCandidates hard-coded docs/ — so
every root module would have taken its frontmatter, dates, topic_type,
product, keywords and last_verified from the next version's file.
Correct on the day of the snapshot, wrong from the first edit after it:
comparing versioned_docs/version-7.6.14 against today's docs/ gives 388 of
449 shared pages with differing frontmatter.
Fixed in a5d6cf3: sourceCandidates takes the served version and prefixes
accordingly, threaded through resolveSource, contributingFiles, sourceDate
and readFrontMatter.
One honest footnote on the 7.6.14 test: that build then failed in
plugin-client-redirects, because 7.6.14 has no /sdks/ios/agent-skills/ or
/migrate-7-to-8/ to redirect to. That is a property of picking an old version
for the test — a real beta cycle freezes the newest release, which has those
pages — not of the change. The config is restored and a normal build succeeds.
…g Titanium Second review round on this PR. Four code items and two questions. `pickIntents` matched the SUBSTRING " auth", so it filed anything beginning with those four letters under the security intent. Measured across the built HTML before changing it: "authenticity" 12 times, "authentic" 10, "authority" 5 - every one of them the ID-document sense, about whether a passport is genuine - against "authorization" twice. The old test therefore filed about 27 document-verification mentions as security findings and 2 real ones. Now a word-boundary match. Verb forms are deliberately out: "authenticate" does not occur in this corpus at all, while "authorized" does, in "authorized dealer", so adding them would re-admit the false positives to catch nothing. TITANIUM was being indexed although the repo had already decided an assistant should not see it: `llmsIgnoredSdkTrees` excludes it from the sibling docusaurus-plugin-llms export as "deprecated / not needed for assistant context". Rather than restate that decision here, the globs are exported through `customFields` and this plugin derives its route prefixes from them, so the two AI exports cannot drift into disagreeing about scope. Filtered on the file list rather than inside the loop, so an ignored page is never read or parsed. Measured: 4,637 modules to 4,425, zero from /sdks/titanium/. `--auto` could not fail its own gate: MIN_AUTO_SUCCESS defaulted to 0 and every possible rate is >= 0, so the run printed "(min 0)" and gated nothing. Measured page-success@3 = 0.8653 over all 616 pages / 4,437 modules and set the default to 0.80, which absorbs ordinary drift and still fails a regression. THE TWO OPEN QUESTIONS have one measured answer, recorded where each was asked. This is a POINTER index, not a text store: on this build it is already 11.0 MB with a 12.3 MB graph, while llms-full.txt carries the full prose of every page in 2.3 MB. Publishing content.docs_markdown would add ~6.5 MB to restate, five times less efficiently, text that ships beside it. So the division of labour is that llms ships prose for context-stuffing and this ships typed metadata and edges for deciding what to read, and 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, not just the size. The 11 MB fetch is recorded as a known limit. The chunk text is not discarded, which is worth stating because the review read it that way: it is the input the published fields are derived FROM. Measured on this build, chunk prose yields 2,823 api_refs, 1,053 internal references, 18,976 keywords, and a summary and intents for all 4,425 modules. AND THE VERIFICATION I OWED. The previous commit's numbers only exercised `servesCurrent === true`, which is the branch that was already correct - as the review pointed out, both the version label and the /next/ exclusion are no-ops there. Verified the other branch properly: with DOCS_LAST_VERSION temporarily set to 7.6.14, the extractor produced 2,908 modules from 463 pages, every one labelled version "7.6.14" rather than "current", zero from /next/, zero from titanium. That build then failed in plugin-client-redirects because 7.6.14 has no /sdks/ios/agent-skills/ or /migrate-7-to-8/ to redirect to - a property of picking an old version for the test, not of this change, since a real beta cycle freezes the newest release. Config restored; a normal build succeeds with 4,425 modules from 570 pages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…f it Third round. The previous commit reused `llmsIgnoredSdkTrees` and claimed the two AI exports "cannot disagree about scope". Both halves were wrong, and the review was right on each. WRONG SOURCE. That constant is one of six entries in what docusaurus-plugin-llms is actually given, so the exports still disagreed about 39 modules - 26 Linux stub pages and 13 redirect-only root docs. But exporting the WHOLE llms list over-corrects: it also carries a CORPUS-SHAPE decision, deduping prose repeated under every non-Web SDK root to the Web copy. Applying that here removed ~1,400 modules and with them the ability to answer "where in the Flutter docs does this live", which is what a routing index is for. So the list is split at the seam that was already implicit in it. `assistantIgnoreFiles` is the curation half - deprecated platforms, features a platform lacks, redirect-only stubs, non-route partials - shared by both exports. `llmsDedupedToWeb` is the corpus half, used only by llms. `llmsIgnoreFiles` is their concatenation, so every path is still declared once and the one deliberate difference has a name and a reason next to it. Measured: 4,637 modules -> 4,386, with exactly the 212 Titanium and 39 stub modules gone and the 1,400 deduped ones kept. WRONG MATCHER. The prefix derivation - strip `docs/`, strip trailing `*` - understood exactly one glob shape and failed OPEN on every other: a brace list, a leading `**/`, a bare file path, a mid-path `*` all matched nothing, with no warning. Adding a tree in any other form would have produced a green build that kept feeding an assistant what the repo had just decided to hide. Replaced with a small glob compiler that handles `*`, `**` and literals and THROWS on a shape it cannot express, rather than a matcher dependency: none is declared here, and reaching for a transitive one is the mistake this plugin was already corrected on with cheerio. Ten cases checked, including the rejection. A missing `customFields.assistantIgnoreFiles` now throws too. Absent meant "index everything", which is the wrong default for a curation list and invisible to both output guards, because the page count goes UP. AND THE HALF-VERIFIED FIX. The review was right again: the 7.6.14 build exercised the version LABEL only, because that build's root pages were the `docs/` tree, so the source lookup was never wrong. On a real beta build the root is served from `versioned_docs/version-<served>/` while `docs/` is the unreleased tree, and every root module took its frontmatter, dates, `topic_type`, `product`, `keywords` and `last_verified` from the NEXT version's file - right on the day of the snapshot, wrong from the first edit after it. Comparing version-7.6.14 against today's `docs/` shows 388 of 449 shared pages with differing frontmatter. `sourceCandidates` now takes the served version and prefixes accordingly, threaded through resolveSource, contributingFiles, sourceDate and readFrontMatter. Smaller corrections, all from measurement rather than reasoning: The header still described "each CURRENT-version page", which is what the previous commit changed. `docs_excerpt` is documented as averaging 376.5 with a max of 404, not "<=400": clipMarkdown appends the fence closer AFTER slicing, so 400 is the slice budget and not an enforced bound. Sizes are quoted in MiB, matching the plugin's own budget check, not SI MB. The pointer-index argument no longer claims each module's prose ships in llms-full.txt. I could not establish that: the two artifacts cut the corpus differently - 452 page sections against 4,386 modules - so the argument rests on the `url` and on the ~6 MiB the alternative would add. MIN_AUTO_SUCCESS's justification was measured against a corpus this same PR no longer produces. Re-measured on what ships: page-success@3 = 0.8493 over 531 pages / 4,386 modules, so the 0.80 default keeps 4.9 points of headroom. And that gate was never run: no workflow passed `--auto`. The evals workflow now has a second step for it with its own report, so corpus-wide coverage - the only check that covers pages nobody wrote a gold query for - is actually enforced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…minimatch
Fourth round. Two of these are the same fix being finished for the third time,
which is worth naming rather than glossing.
THE FROZEN-AT-ROOT PATH, THIRD ATTEMPT. Round two labelled the version. Round
three read frontmatter from the right tree. Both left the DATES wrong, and
silently: `gitDates` batches one `git log -- docs src`, so no `versioned_docs/`
path was ever in the map, every lookup missed, and all modules shipped
`last_verified: ""`. None of the guards fire on that - the repo is not shallow,
the map is not empty, and the drift ratio does not move. The pathspec now
includes `versioned_docs`; measured, the log carries 32,284 such paths and the
probe file resolves.
Second half of the same thing: `contributingFiles` dropped any resolved import
that did not start with `docs/` or `src/`, which is every relative partial
import once the root file is under `versioned_docs/`. Measured: the 7.6.14
snapshot has 350 relative imports and zero `@site/`-absolute ones, so a page
whose prose lives in a partial would have been dated from its own two-line
shell - the exact staleness this function exists to prevent.
THE GLOB COMPILER NOW AGREES INSTEAD OF MERELY REFUSING. It threw on shapes it
could not express, but silently mis-compiled two it accepted:
A slash-less pattern compiled to an unmatchable regex, because every candidate
path starts with `docs/`. docusaurus-plugin-llms passes `matchBase: true`, so
`system-requirements.mdx` hides that file anywhere - here it hid nothing. Now
a pattern with no `/` matches the basename, as it does there.
`**` adjacent to a non-slash emitted `.*` and crossed separators, where
minimatch degrades it to one segment. `docs/sdks/linux/matrixscan**` therefore
swallowed the whole `matrixscan/` subtree here while llms kept it - two
artifacts disagreeing about scope, which is the one thing this design claims
cannot happen. `**` now spans segments only as a whole segment.
Checked against the authority rather than by reasoning: a differential test over
154 glob/path pairs against `minimatch(..., {matchBase: true})` - the matcher
the llms plugin actually calls - reports 0 divergences, including every shape
the review found.
The evals no longer skip the inputs that decide what is indexed:
`docusaurus.config.ts` (which now holds `assistantIgnoreFiles` and
`lastVersion`) and `versions.json` are in the path filter, and the `--auto` step
carries `if: always()` so a gold-set breach cannot hide the corpus-wide number.
Two more figures corrected in the header, same class as the ones before them:
the shared-scope key is `customFields.assistantIgnoreFiles`, not
`llmsIgnoredSdkTrees` which is a private input to it; and `llms-full.txt` has
423 sections with 410 links in `llms.txt`, not the 452 I wrote.
Unchanged on the normal path: 4,386 modules from 531 pages, `last_verified` on
100% of them, zero from titanium/partials/connector-guides, page-success@3
0.8493 against the 0.80 floor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fifth round, and the review's verdict was that nothing blocks - these are its
six findings, taken because they are cheap and because two of them are the same
fail-open class as the last three rounds.
THE MATCHER NOW AGREES BY CONSTRUCTION, not by enumerating shapes. The review
found a bare directory entry - `docs/connector-guides` with no `/**` - hiding
the whole subtree in llms while matching nothing here, because
`readMarkdownFiles` calls `shouldIgnoreFile` on each DIRECTORY before recursing.
My first fix was a special case for extension-less patterns; testing it against
that walker's real semantics rather than against minimatch-on-a-file-path
showed two more shapes still disagreeing (`matrixscan**`, a bare `titanium`).
So the pattern is now applied the way the walker applies it - to the file and to
every ancestor directory - and the special case is gone. A differential test
over 208 pairs against those semantics reports 0 divergences.
The filter also matches the RESOLVED source now, not every candidate spelling.
`docsRelCandidates` emits `docs/<rel>/index.md` for every route whether or not
that file exists, so `docs/**/index.md` would have matched a candidate of every
route and emptied the index, where llms drops only pages really sourced from an
index.md. Normalised back to `docs/` first, because a frozen-at-root build
resolves into `versioned_docs/version-X/` and the globs are written against
`docs/` - unnormalised, the whole curation list would fail open there.
`\` is now refused. `docs\sdks\titanium\**` was accepted and matched nothing in
either artifact - minimatch reads it as an escape - so a plausible authoring
slip hid the tree from neither. That is precisely what the throw exists for.
One divergence is documented rather than fixed: `*` and whole-segment `**` here
also match a segment beginning with `.`, where minimatch defaults to
`dot: false`. No doc source is dot-prefixed, so nothing in the corpus reaches
it.
Outside the plugin, two smaller ones:
`FeatureList` rendered `href={withCurrentDocsPath(frameworkInfo.apiUrl)}`
unguarded. The 16 Kotlin Multiplatform rows this branch adds carry no
`apiUrl`, and `href={undefined}` renders link-styled, is not
keyboard-focusable and navigates nowhere. Latent only because all 50 current
usages are compact mode, which skips that column; the sibling cell nine lines
up already guarded it.
`if: always()` on the self-retrieval step also fires when the build failed,
adding a second red step whose message blames a missing index, and on
cancellation. Now `!cancelled() && steps.build.outcome == 'success'`.
Worth writing down rather than fixing, from the review's own measurement: on a
frozen-at-root run 479 of 498 pages carry the same `last_verified` - the
snapshot commit's - because that is when those files were created. It is
accurate, and it is also a near-constant that looks like data, which is the
doctrine `gitDates` invokes when it blanks on shallow clones.
Normal path unchanged: 4,386 modules from 531 pages, dates on all of them, zero
from titanium/partials/connector-guides/linux stubs, page-success@3 0.8493
against the 0.80 floor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ver sees Sixth round. The review's verdict was Go again; these are its two low findings, and one of them is a claim of mine that the diff did not deliver. `hiddenFromAssistants` walked ancestors down to `i > 0`, so it also tested the bare `docs` segment. The llms walker starts INSIDE siteDir/docs and never hands that segment to `shouldIgnoreFile`, so a curation entry matching it - `docs`, `doc*`, `**/docs` - hid all 676 files here while llms kept every one. Measured as the only over-hide class across 512 generated pattern shapes. It would have failed the build rather than shipped a wrong artifact, since an empty file list trips the pagesProcessed guard, but loud and wrong is still wrong. `i > 1`. THE FEATURELIST GUARD DID NOTHING, and the previous commit message said it did. `withCurrentDocsPath` returns `undefined` for `undefined` and React omits an undefined attribute, so the rendered DOM was byte-identical: the chip stayed an `<a>` with no href - not keyboard-focusable, navigating nowhere, still carrying the link hover. Nor was it a type fix; the repo's tsconfig does not set `strict`. Now the ELEMENT changes, the way the feature-name cell nine lines up already did by swapping `<a>` for `<h4>`: no apiUrl renders a `<span>`. 17 cells in features.json have no apiUrl - 16 Kotlin Multiplatform, 1 Web - and it is latent only while every usage is compact mode, which skips that column. That put the unused `.frameworkItemUnavailable` class to work, and it needed correcting first: `opacity: 0.5` composites the label to 3.27:1 in light mode, below AA, while looking merely faint. Dimmed through the border and --ifm-color-emphasis-700 instead - measured 5.06:1 light, 9.34:1 dark. Normal path unchanged: 4,386 modules from 531 pages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The review's final round returned Go, and caught one more of my numbers: I computed those contrast ratios against assumed token values rather than the built stylesheet. Measured properly they are 3.19:1 for the old opacity form (not 3.27), and 5.29:1 light / 12.26:1 dark for the replacement (not 5.06 and 9.34). The conclusions are unchanged in both directions - the old form failed AA and the new one passes - but a figure nobody can reproduce is worth no more here than it was in the six rounds before this one. Also made precise: 17 cells in features.json carry no apiUrl, but only 16 reach the branch. The 17th, Web / 7-Segment Display, is filtered out earlier by version === "n/a". Not fixed, and worth flagging rather than folding in: `.frameworkItem:hover` six lines above sets `color: var(--ifm-color-primary-contrast)`, a token this site never defines. The invalid var() falls back to `inherit`, so hovering an AVAILABLE chip paints content colour on the primary background - 3.95:1 light, 3.29:1 dark. It predates this branch, is unreachable while every usage is compact mode, and correcting the token name alone would not fix it: white on #3578e5 is 4.23:1, so it needs a darker hover background. That is a design choice, not a rename, and it belongs in its own change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add a Docusaurus postBuild plugin that turns the rendered docs into an AI-consumable layer, generated at build time:
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. Extraction failures are non-fatal so they never block a deploy.
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-llmsships the prose, for context-stuffing:llms.txtas a link index (0.08 MiB) andllms-full.txtas 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 (10.41 MiB index, 11.62 MiB graph) and a
url; the full text is the page itself.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_CHARSis a granularity knob rather than a payload size: it decides which links land in which module'sreferencesandapi_refs, so changing it changes the edges.Shared scope, one deliberate difference. Both read
customFields.assistantIgnoreFiles— the curation half of the llms ignore list, covering deprecated platforms, features a platform lacks, redirect-only stubs and non-route partials — so what an assistant may see is decided once. They differ only onllmsDedupedToWeb: 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
last_verified— the snapshot commit's, because that is when those files were created. Accurate, but close to a constant.--autoself-retrieval is gated at 0.80, against a measured 0.8493 over 531 pages / 4,386 modules. Re-measure after anything that changes what is indexed.*and whole-segment**here also match a segment beginning with., where minimatch defaults todot: false. No doc source is dot-prefixed.