Report the index shortfall the reindex audit was structurally unable to see - #162
Merged
Conversation
…le to the audit Forty .mdx pages, eight of them pure-JSX stubs whose prose lives in an excluded snippet. The real chunker strips them to nothing and returns [], the real pipeline writes nothing, and the index ends up holding 32 of 40 files. runReindexAudit() reports [].
Check 3 only ever fired on db_has_more. The db_has_fewer direction existed in the finding type and was never implemented, so an index holding fewer files than disk produced no finding at all — the blindness that let a wrong crawl scope, a wedged source, and 130 zero-chunk stubs run undetected. Measured on the set difference (disk minus db), not the raw count delta: the delta is a lossy proxy that misses an equal-count stale/missing swap, and the set difference yields the actual paths so the finding names the files. Gated on a per-source tolerance (5% of walked files by default, with a 3-file floor for small sources, overridable via the new unindexed_tolerance source key) so a healthy source stays silent instead of emitting a finding on every reindex. The dedup cache now keys on direction too; both directions can fire for one source in one run, and the old key let one overwrite the other.
chunkMarkdown returned [] from three places and pipeline.indexItem wrote nothing, all without a word. A walked, matched, read file left the pipeline and the only trace was a file count that quietly did not add up. Each chunker exit now names the file and a distinct reason — empty file, MDX stripping left no prose, every split trimmed to empty — because the fixes differ. The pipeline logs that the index actually lost the item, prefixed by source and id.
check-test-shapes flagged the `as never` on the pipeline's embedding provider; it is an EmbeddingProvider, so say so.
jpr5
added a commit
that referenced
this pull request
Sep 11, 2026
## Why `reindex-audit` compares what is on disk against what is in the index — but "what is on disk" is enumerated by walking the path the **config** points at, the same path the indexer used. When that walk root is wrong or too narrow, both halves are blind identically and agree perfectly. That is how the CopilotKit API reference — 184 `.mdx` files under `showcase/shell-docs/src/content/reference/` — stayed invisible for months. The docs source's `path` was the sibling directory `content/docs/`. The indexer walked `docs/` and found 682 files; the audit walked `docs/` and found 682; the index held 682. A perfect match, with 183 live pages missing from search. #159 fixed that specific config and #162 added shortfall reporting, but neither can see the *next* one, because the comparison is anchored to the config rather than to the repository. ## What this adds Check 4, `unclaimed_content` (`src/indexing/unclaimed-audit.ts`): walk the whole **repository** and report indexable-looking files that no source's walk root and patterns would ever reach. Noise control is the entire problem, and it is harder than #162's — most of a repo is legitimately unclaimed, and a check that reports every unclaimed file gets muted, which is exactly how the original blindness arose. So a cluster is reported only when it has the shape the reference tree had: 1. **Maximal unclaimed subtree.** The highest directory holding no claimed file whose parent does hold one. `content/reference/` surfaces as one 184-file finding instead of fragmenting into `reference/hooks/`, `reference/components/` and a dozen more — each too small to notice and far too many to read. 2. **At least 10 files of one extension** (`MIN_UNCLAIMED_CLUSTER_FILES`). The "large cohesive directory" half of the shape. A scattered handful is not a signal. 3. **An extension the repository already publishes through that same parent.** Derived from what the configured sources actually index, not a hardcoded list — a repo full of `.mdx` with one unclaimed `.mdx` tree beside a claimed one is the signal. Stray `.ts` scripts next to an indexed docs tree are not. 4. **`exclude_patterns` count as accounted for.** A file a source's own exclusion vetoes was reviewed and rejected on purpose. The exclusion only counts when one of *that source's include patterns* would otherwise have taken the file — otherwise a code source excluding all of `showcase/` would silently vouch for every `.mdx` under `showcase/` too, which is the exact tree the reference pages were hiding in. 5. **Generated directories are never walked** — dot-directories wholesale, plus `node_modules`, `dist`, `build`, `out`, `coverage`, `target`, `vendor`, `venv`, `__pycache__`, `site-packages`, `storybook-static`. Claims are computed from **every** configured source on the repo, not just the ones that reindexed. Findings are attributed to the first audited source on that repo so #162's dedup cache can retire them; the dedup key includes the directory so two trees on one source stay two findings. ### Opting out New per-source `unclaimed_exempt_paths` key, following #162's per-source `unindexed_tolerance` precedent. Repo-root-relative prefixes or globs, **pooled across every source reading the same repository** — any one of their operators can vouch for a subtree. An operator reviews a directory once, records it, and never sees it again. Documented in `docs/config/index.html`. ### Surfacing The existing `[reindex-audit]` log stream and the existing Slack alert, both with the directory, file count and extension, and the remedy inline: ``` [reindex-audit] docs — unclaimed_content: 96 .mdx files under showcase/shell-docs/src/content/ag-ui/ that no configured source claims (widen a source's path/file_patterns, or record it in unclaimed_exempt_paths if it is correctly unclaimed): ... ``` Not added to `/health` or admin `index-stats`: both project `index_state` rows, and audit findings are not persisted. Giving them a durable home is a real change with its own schema and lifecycle questions, and bolting an in-memory last-run cache onto an operator endpoint would be worse than the log line. ### What a healthy repo looks like after this Silent. `pathfinder` and (after the opt-outs below) `aimock` produce no findings at all. A healthy repo emits a finding only when someone adds a new content tree and forgets to point a source at it — which is the day you want to hear about it, not six months later. ## What it fires on today, against the real configs Run over fresh checkouts (CopilotKit `ce10479`, ag-ui `e606ce3`, aimock `42eaaa2`, pathfinder `8c4c289`). Nine clusters before tuning; seven are content nobody meant to index and are now recorded as exempt in `deploy/`: | Repo | Tree | Verdict | |---|---|---| | CopilotKit | `content/snippets/` (98 `.mdx`) | MDX partials inlined at render time — exempt | | CopilotKit | `examples/` (26 `.mdx`) | example-app READMEs — exempt | | CopilotKit | `showcase/integrations/` (80 `.mdx`) | per-integration setup notes — exempt | | ag-ui | `apps/` (371 `.ts`, 25 `.mdx`) | the dojo demo app — exempt | | ag-ui | `sdks/dotnet/` (30 `.ts`) | cross-language Vitest harness — exempt | | aimock | `scripts/` (13 `.ts`) | repo maintenance tooling — exempt | **The two left unexempted are real, and the first one is the same bug again:** - **`showcase/shell-docs/src/content/ag-ui/` — 96 `.mdx`.** Served live at `docs.copilotkit.ai/ag-ui/<slug>` by `src/app/ag-ui/[[...slug]]`, and **all 96 are in the production sitemap** (`curl -s https://docs.copilotkit.ai/sitemap.xml | grep -c /ag-ui/` → `96`; two spot-checked URLs return 200). No source indexes one of them. This is `content/reference/` again, sitting in the same directory, still open — found by the check on its first real run. Deliberately not exempted: whether to index them here or leave them to `docs.ag-ui.com` (which indexes a different repo) is a call for the docs owner, and until it is made the audit should keep saying so. - **ag-ui `middlewares/` — 34 `.ts`.** Six published `@ag-ui/*` packages (`a2a-middleware`, `mcp-middleware`, …) whose source `search-ag-ui-code` cannot reach. No re-tuning was needed: nothing in the first run was build output, a test fixture storm, or `node_modules`. Scan cost is 1.3s for CopilotKit, under 300ms for the rest, once per reindex, off the request path. ## Red–green **RED** — the test builds a real temp-dir repo with a claimed `content/docs/` tree and an unclaimed sibling `content/reference/` of the same file type, and runs the real `walkSourceFiles` over it. Against unmodified code it fails by finding **nothing**, not by erroring: ``` FAIL src/__tests__/reindex-audit-unclaimed.test.ts > reports the reference tree no source's walk root can reach AssertionError: expected [] to have a length of 1 but got +0 FAIL src/__tests__/reindex-audit-unclaimed.test.ts > reports a deliberately-excluded directory until it is opted out AssertionError: expected [] to deeply equal [ …(2) ] - [ "showcase/shell-docs/src/content/reference", "showcase/shell-docs/src/content/snippets" ] + [] Test Files 1 failed (1) Tests 2 failed | 3 passed (5) ``` **GREEN** — same test unchanged: the finding names `showcase/shell-docs/src/content/reference`, extension `.mdx`, count 35, with sample paths. `Tests 5 passed (5)`. **Negatives, each mutation-proven** — every noise control was deleted in turn and the suite went red: | Mutation | Result | |---|---| | `unclaimed_exempt_paths` ignored | 4 failed — the opt-out is load-bearing | | `exclude_patterns` no longer account | 4 failed — `packages/core/tests` (40 `.ts`) fires | | `MIN_UNCLAIMED_CLUSTER_FILES` 10 → 1 | 5 failed — `content/misc` (3 `.mdx`) fires | | dot-directory skip removed | 4 failed — `.venv` (60 `.py`) fires | | `build` removed from generated dirs | 4 failed — `build/lib` (60 `.py`) fires | | same-extension-under-parent rule removed | 1 failed — `sdks/` fires | Two of the negatives were *vacuous on the first attempt* and the mutation run is what caught it: the generated-output fixture was sitting under a claim glob (so it was classified `claimed`, not skipped), and a later revision routed through `site-packages`, which a different rule was silencing. Both fixtures were rebuilt until the intended rule was the only thing keeping them quiet. ## Gate `npm run build` ✅ · `npx tsc --noEmit` ✅ · `npm test` → **3717 passed, 1 skipped, 0 failed** ✅ · `node scripts/check-test-shapes.mjs` ✅ · `npx prettier --check` on every touched file ✅. No pre-existing failures to control for. `reindex-audit.test.ts` and `reindex-audit-shortfall.test.ts` now spread `importOriginal()` into their `../indexing/utils.js` mock: the new check matches patterns through that module, and a module-shaped hole there would have failed silently.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The audit could not report an index that had shrunk
reindex-audit.tsCheck 3 only ever fired ondb_has_more. Thedb_has_fewerdirection was in the finding type and was never implemented:
The stated reason is real — the indexer legitimately drops files with no
extractable prose — but "some shortfall is normal" got implemented as "all
shortfall is invisible."
Motivation: three bugs the check watched and never mentioned
.mdx) lived in asibling directory outside the configured walk root; 183 of 184 pages were
live on the site and absent from the index.
codesource sat in a falseerrorfrom 2026-09-01to 2026-09-11: one file produced a 36 KB chunk and a non-retryable 400, and
the orchestrator refused to advance past it.
.mdxfiles are pureJSX stubs whose text lives in the excluded
src/content/snippets/**. Theyare walked, matched, read, stripped to empty, and chunk to zero.
markdown.tsreturns[],pipeline.tswrites nothing — both silently. A19% shortfall that stood for months.
Honest scope note: this change catches #2 and #3. It does not catch #1 —
when the walk root itself is wrong, the disk side of the comparison is wrong
too, so disk and index agree. #1 needs a config-level scope check, not this one.
What changed
Check 3 reports both directions. The shortfall is measured on the set
difference (disk minus index), not on
diskCount - dbCount. The raw delta isa lossy proxy: a source holding one stale row and missing one real file has
matching counts and reports nothing — the same blindness in miniature. The set
difference fires in every case the count comparison would, plus that one, and
it yields the actual paths, so the finding names the files instead of
saying "the count is off by 130."
Keeping the signal from becoming noise. The finding fires only above a
per-source baseline:
unindexed_tolerance, a new optional file-source key,defaulting to 5% of walked files, with an absolute floor of 3 files so
a small source does not alert on one or two legitimately-empty ones. The floor
is bypassed when a source explicitly sets
0.What a healthy source looks like after this change: it emits nothing.
A source dropping a handful of empty or content-free files per reindex stays
silent at the default. A source that has been cleaned up sets
unindexed_tolerance: 0and then hears about the very first regression — thatis the intended end state, and it is why the lever is per-source rather than a
global mute. The failure mode this design is guarding against is an alarm that
fires on every source on every reindex and gets muted within a week, which is
how the direction came to be suppressed outright in the first place.
unindexed_toleranceis deliberately not in the source fingerprint(
source-fingerprint.tsuses an explicit allowlist): it changes reporting, notwhat gets indexed, so changing it must not force a reindex.
Dedup key now includes the direction. A source can produce both directions
in one run;
source:checkalone let one overwrite the other and suppress itsSlack alert.
The two silent drop sites are now audible. Every
return []inchunkMarkdownwarns with the file and a distinct reason — empty file / MDXstripping left no prose / every split trimmed to empty — because those have
completely different fixes. (There was a third silent path at the bottom of the
function; it got the same treatment.)
pipeline.indexItemlogs that the indexactually lost the item, prefixed by source and id, greppable next to
next_acquire_reasonandquarantined_items:Should a zero-chunk file be its own audit finding?
No — and it does not need to be, because the shortfall finding already names
it individually. A zero-chunk file is written as
replaceChunksForFile(src, id, []), which deletes the row, so it is by construction a disk file absentfrom the index and it lands in the shortfall finding's
samplesby path. Aseparate persisted zero-chunk finding would need the indexer to write a second
record of the same fact into a second store, which can go stale against the
chunks table and would report a file as dropped after someone fixed it. The
audit's two sources of truth are disk and the index; keeping it that way means
the finding cannot lie. The per-file reason — which the audit genuinely
cannot know — is what the new chunker warnings supply, at the one place that
does know.
Red-green
RED — 40
.mdxpages, 8 of them pure-JSX stubs, run through the realchunker and the real pipeline (only the DB and the disk walk are faked), so
the test exercises the actual drop path:
Preconditions in the same run passed: disk 40 files, index 32. The shrink was
real and the audit said nothing.
GREEN — same test, unchanged:
Negative assertion — a source skipping a normal share (1 of 40, 2.5%)
produces
findings === [], and the same shortfall does report once thesource sets
unindexed_tolerance: 0:Mutation-tested so the negative assertion is not vacuous — replacing
unindexed.length > budgetwith> 0fails it:The chunker and pipeline warnings were red-greened the same way (2 chunker
tests and 1 pipeline test failed before the warnings existed), each paired with
a "does not warn on a normal file" assertion.
Local gate
npm run build✅ ·npx tsc --noEmit✅ ·npx tsc --noEmit -p tsconfig.scripts.json✅ ·node scripts/check-test-shapes.mjs✅ ·npx prettier --check✅ ·npm test✅ 189 passed | 1 skipped (190 files), 3712 passed | 1 skipped. No pre-existing failures.What an operator does when this fires
Open the named files. Three outcomes, in order of likelihood:
legitimate. Either fix the extraction (inline the snippet, widen the source)
or, if the drop is genuinely correct, raise that source's
unindexed_tolerancepast the measured baseline and leave a note saying why.[chunker] no chunks for <path>andproduced zero chunks; the reason isthere.
Check
/healthfor a wedged source and a held state token (Make a reindex actually pick up a change to a source's crawl configuration #160, Unwedge the code source: a URL in a template literal, an 8192-token chunk, and an unbounded retry #161).