Skip to content

ci: typecheck the documented TypeScript blocks, with the shadowing check repaired - #1324

Open
borisno2 wants to merge 9 commits into
prisma-8from
claude/doc-block-typecheck
Open

ci: typecheck the documented TypeScript blocks, with the shadowing check repaired#1324
borisno2 wants to merge 9 commits into
prisma-8from
claude/doc-block-typecheck

Conversation

@borisno2

@borisno2 borisno2 commented Sep 7, 2026

Copy link
Copy Markdown
Member

Split out of #1318 at review request. That PR's documentation fixes were individually sound; this script is repo tooling with no relationship to @opensaas/stack-rag's published surface, it needs its own CI wiring, and its central check needed reviewing as a program. #1318 is now documentation and examples only.

References #1301, which asked for exactly this tool. It does not close it — see "What this does not cover" below.

The script itself ships to nobody; the one edit under packages/* (packages/rag/CLAUDE.md's hybrid-search sample) carries a minor changeset for @opensaas/stack-rag.

What it does

pnpm build && pnpm check:doc-ts-blocks extracts every fenced typescript/ts/tsx block from the six paths named in scripts/doc-blocks/files.txt (five markdown documents plus a changeset) and runs three checks per block.

  1. Compile. The block is written into a scratch project under packages/rag (so vitest and @types/node resolve as they do anywhere in the repo), alongside the two preludes, and type-checked under strict. @opensaas/* resolves through a scratch node_modules/@opensaas/* of symlinks to packages/*, so each package's own exports map decides what a subpath means: one it exports resolves to its built .d.ts, one it does not export fails TS2307 for the checker exactly as it fails for a reader (the Docs samples import from subpaths the packages do not export, and nothing checks them #1301 class).
  2. Imports. Prelude bindings are typeof import(...) of the real export, so a nested excerpt still gets the shipped signature. Each block is then compiled a second time without them, and a block that carries imports of its own yet still needs a prelude name is failed for not importing it.
  3. Shadowing. A block that exports — or declares at module scope, under any spelling the checker sees as an export — a type whose name a package also exports is compared against the package's own. The comparison is a member-by-member diff computed from the checker (below).

The prelude's context is parameterised. scripts/doc-blocks/prelude.d.ts declares a db for the three lists the listed prose invents — Article, Document, DocumentChunk — with rows carrying every field a listed block reads or writes, over a query surface mirroring the generated ListOps (composable reads and their terminals, select narrowing the row, nearest accepting only the row's vector columns, the three writes). A wrong-cased list, an unknown list, a misspelt vector column, a misspelt row field and an unchecked null are all compile errors now. Every helper type lives inside a declare namespace, so no bare name a block might write resolves to the prelude by accident — only context and getContext are reachable.

A block that cannot stand alone carries an entry in scripts/doc-blocks/fragments.json, and the entry names what it excuses: { "excuses": ["provider", "TS2307 'cohere-ai'"], "reason": … } covers exactly those diagnostics — an unresolved bare name the prose supplies, or a TSnnnn 'token' code-and-token pair — and every other diagnostic in the block still fails it. A block that is not a statement list at all (an object-literal body, a ... elision) instead carries the distinct, greppable { "whole": "reason" }, which excuses every compile diagnostic; the summary counts those separately. The entry is a claim about the block, so it is checked as one: an excuse matching no diagnostic is STALE, so is a whole entry on a block that now compiles or one this check never compiles at all, an entry matching no block is ORPHAN, and no entry ever excuses a shadowing failure or a missing import.

Current result on the merged tree:

97 blocks: 71 compile, 0 not compiled; 26 carry a fragment entry (10 whole-block, 0 stale). Redeclared exported names: 3 compared against the package, 0 not compared.

The comparison

Neither built-in relation answers the question on its own. Assignability in both directions cannot see an optional member appear or disappear — ChunkingConfig has no required members, so any rearrangement of its surface passed. The identity relation sees that, but compares type flags before members, so an intersection is never identical to its flattened spelling — and the core field types ship as intersections, so the ordinary way of documenting one would have failed.

The decision is therefore a diff: member presence, optionality, readonly — declared, or introduced by a mapped type such as Readonly<>, read off the symbol's check flags — any against something narrower, index signatures, call-signature arity, and each member's type, recursing into object-typed members whether required or optional (T | undefined and T | null are entered after their nullability is compared on its own) and into non-generic signatures, and falling back to assignability in both directions at the leaves, for unions of more than one object type, and for generic signatures. Identity is kept only as a fast path — when the checker holds the two to be the same type there is nothing to diff. The failure names the difference:

FAIL   scripts/doc-blocks/self-test/fixture.md:317 (redeclares ChunkingOptions)
         shadows ChunkingOptions from @opensaas/stack-rag/runtime — not the type the package
         declares — the block declares `minTokens`, the package does not

A name two specifiers export as different types (ChunkingStrategy from @opensaas/stack-rag and from @opensaas/stack-rag/runtime) is held as two candidates. A block is compared against the candidates it imports from, or against every candidate when it imports from none, and fails only when it matches none.

Type parameters are compared rather than worked around. A parameter the block declares past the package's arity is a difference, and so is a default on one side and none on the other — checked by a second instantiation at the shared required arity. An unused parameter is filled with never whatever its position and whatever its constraint, so no no-op edit can retire the comparison; a used one takes a fresh opaque type, and a used one carrying a real constraint is the one case that bails.

A redeclaration the parser cannot fully read — a ... elision inside its body — is compared one way, over the members the parser recovered: an invented or mistyped member still fails, a member the block elided does not. A whole fragment entry therefore cannot buy an invented sibling a free pass.

The self-test

scripts/doc-blocks/self-test/fixture.md holds one known-bad and one known-good block for each shape the reviews found. Each marker says exactly what the checker must report:

  • <!-- expect: fail --> — reported FAIL.
  • <!-- expect: pass --> — clean, and redeclares no shipped name.
  • <!-- expect: pass compared --> — clean, and every shipped name it redeclares was compared against the package; a bail is a mismatch, not a pass.
  • <!-- expect: pass not-compared --> — clean, with at least one comparison reported NOT COMPARED.
  • <!-- expect: excused --> — every compile diagnostic is excused by the entry on the marker, and every redeclared name was compared.

A marker carries its fragment entry as excuses="a, b" or whole="reason". node scripts/check-doc-typescript-blocks.mjs --self-test runs the checker over the fixture and exits non-zero on any mismatch, so every known-good block is shown to have been compared rather than assumed. CI runs it before the real documents.

self-test: 55 blocks — expected 29 fail, 9 pass, 12 pass compared,
1 pass not-compared, 4 excused; Redeclared exported names: 32 compared
against the package, 1 not compared. 0 mismatch(es).

The shapes: a non-compiling block; an unexported subpath (the good twin imports @opensaas/stack-auth/server, a real subpath the old hand list could not resolve); a wrong-cased context.db key; an unknown list; an unchecked null dereference; a misspelt vector column; a self-contained block leaning on the prelude for an import; and the four the last review reproduced against the previous claims —

  • (a) a phantom type parameter carrying a constraint (<X extends string = string>) — previously tripped the constraint bail and passed; a phantom parameter is now filled with never whatever its constraint, so the invented member is reported;
  • (b) declare module '@opensaas/stack-rag/runtime' { interface ChunkingOptions { minTokens?: number } } — previously only NOT COMPARED; any declare module '@opensaas/…' in a block is a FAIL;
  • (c) import type { SearchResult as X } from '@opensaas/stack-rag'; export type { X as ChunkingConfig } — previously no output at all; exports are enumerated through checker.getExportsOfModule + getAliasedSymbol, so every spelling is seen;
  • (d) a fragment entry plus strategy?: NotReal inside the redeclaration — previously the whole comparison bailed; an unresolved member is skipped and reported by name and its siblings are still compared, so the invented minTokens? beside it is a FAIL. The comparison bails only when the type itself does not resolve (a heritage clause, an intersection operand);

— plus export { type Wrong as SearchResult }, an unexported module-scope redeclaration, a readonly member and a Readonly<> mapped type, an any member, an invented member hidden inside an intersection (with its faithful intersection twin passing), and the two-specifier ChunkingStrategy case in four spellings.

The second review added: an invented member under an optional object member, in two spellings — a flat 40-member EmbeddingField copy with bogus?: number under its optional db, and TextChunk with metadata?: Record<string, unknown> & { bogus?: number } — each beside the exact copy that must still pass; a constrained parameter the block does not use within the package's arity; a type-parameter default that differs from the package's, and one dropped entirely; a ... elision inside a redeclared shipped name, with and without an invented sibling; and a fragment entry proving it excuses only the diagnostics it names.

Hygiene

  • The scratch directory is mkdtempSync(packages/rag/.doc-blocks-check-XXXXXX), so two overlapping runs no longer crash each other. The pattern is in .gitignore and in eslint.config.js ignores (ESLint 9 flat config does not read .gitignore).
  • --json exits non-zero on any failure and its payload carries ok, the summary, every block's verdict, orphans and missingFiles.
  • A fence info string may carry attributes (```ts title="x").
  • A file-less diagnostic — a TS2688/TS6046-class option error — is a tooling failure (exit 2), not something dropped with the diagnostics of other files.
  • The guard checks every exports[*].types of every packages/*/package.json exists, so the shadow targets and the resolvable modules are the same list, derived rather than hand-kept.

CI

The two sibling checks (check:adr-duplicates, check:prisma-error-codes) already run in test.yml. This one runs in the same job after a full package build (turbo-cached; the test steps depend on the same task), as two steps: --self-test, then the real run.

It is skipped for PRs into main. The fixtures describe this branch's documents: files.txt names them and fragments.json keys entries by file:line. main's RAG guidance is a different text at different lines, so every entry would report as orphaned. The gate and the one instruction for flipping it live together in test.yml; the script's Known limits and the warning annotation point there.

What this does not cover

Stated plainly, because the point of the tool is to stop certifying more than it measures. The full list is the Known limits block in the script; the load-bearing ones:

  • Coverage is the file list, not the tree. Six paths. A new doc, or context-api.md with the four bad imports Docs samples import from subpaths the packages do not export, and nothing checks them #1301 actually reports, is never compiled, and nothing here detects that a file was added. That is why this references Docs samples import from subpaths the packages do not export, and nothing checks them #1301 rather than closing it.
  • Fragment keys are file:line. The orphan check catches a key that has drifted off every block; a key that drifts onto a different block's first line still excuses that block instead.
  • context.db is hand-written, not generated. Three lists, with rows carrying the fields the listed prose uses. where and orderBy take the package's untyped vocabulary rather than the list's own columns, so a misspelt key in either is not a compile error; include, distinct, distinctOn and cursor are not modelled, nor are select/include on a write. create takes a fully partial data, because CreateInput requires a member exactly where the contract shows a non-nullable column with no default and no listed page declares one — validation: { isRequired: true } is an application-layer check and leaves the column nullable. A documented create omitting a field a reader's own stricter list requires is therefore not a compile error here. The generated SecuredList cannot be used here because it is instantiated from the emitted Prisma contract, which nothing but the generator can write.
  • The comparison sees types, not meaning. It does not see a @default that no longer matches the code, a member whose name is right and whose meaning has changed, or an option documented as accepting a range the package narrows only at runtime. A generic member signature is compared by arity and the outer assignability only. Two declarations can compare equal and still document the package wrongly.
  • A generic is sometimes out of reach. A comparison bails when it would have to supply a genuinely constrained parameter the block uses, or when the package requires a type argument the block does not declare. Every bail is NOT COMPARED on stderr, excluded from the compared tally, and never a pass — a shape that cannot be compared soundly is reported, never failed.
  • A type that does not resolve cannot be compared. A member declared in terms of an unresolved name is skipped by name and its siblings compared; a type whose heritage or operand does not resolve is NOT COMPARED.
  • The package's side is trusted to resolve. Every program here sets skipLibCheck, so a packages/*/dist that is present but internally broken degrades an export to an error type with no diagnostic, and a wrong block then compares clean. The guard checks only that the entry declaration files exist. Tracked as The doc-block check silently weakens against a partial build instead of failing #1350.
  • Blocks are compiled, not run. Nothing here says a documented call does what the prose claims.
  • tsx fences are not compiled — no jsx option, no React in the scratch project. Reported UNCHECKED, never a failure.
  • Extraction is textual. The closing fence must match the opening indent exactly; four-backtick fences and fences with trailing whitespace are handled wrongly.
  • A run killed outright leaves a scratch directory. SIGINT and SIGTERM remove it and re-raise; SIGKILL cannot, which is what the .gitignore entry is for.

Follow-ups

Raised in review and deliberately not in this PR:

  • Extend files.txt to the rest of the docs tree — six paths is the coverage limit, and closing Docs samples import from subpaths the packages do not export, and nothing checks them #1301 needs the tree, not a list. Out of scope here: the fragment keys and the fixture describe these documents, so widening the list is a re-keying exercise with its own review surface.
  • Compile tsx fences — needs a jsx option and React resolvable from the scratch project, i.e. a second scratch layout. Reported UNCHECKED today, never a false pass.
  • Reuse the TypeScript program across blocks — the run takes ~33 s because every block builds a fresh program. Caching changes the isolation each block currently gets, so it wants its own change.
  • Key fragment entries by content rather than file:line — a key that drifts onto a different block's first line still excuses that block. A content hash would close it, at the cost of an entry that must be regenerated on any edit.
  • skipLibCheck hides a broken packages/*/dist — tracked separately as The doc-block check silently weakens against a partial build instead of failing #1350.

Checks

pnpm lint 0 errors (2 pre-existing warnings elsewhere in the tree) · pnpm format clean · pnpm build (all packages) green · --self-test exit 0, 0 mismatches · pnpm check:doc-ts-blocks exit 0 on the tree merged with current prisma-8 (post-#1370), and from a foreign cwd · kill -INT mid-run exits 130 leaving nothing under packages/rag/.

🤖 Generated with Claude Code

… they redeclare

References #1301, which asked for this tool. It does not close it: the check
runs over the nine files named in scripts/doc-blocks/files.txt, not all of
docs/content/, and the four `@opensaas/stack-core/context` imports that issue
reports sit in context-api.md, which is not on the list.

scripts/check-doc-typescript-blocks.mjs extracts every fenced TypeScript block
from the listed files and compiles each one under `strict` against this branch's
own built declarations, with @opensaas/* mapped to packages/*/dist. A block that
cannot stand alone carries a reason in scripts/doc-blocks/fragments.json, and
the reason is treated as a claim: an entry whose block compiles is STALE, an
entry matching no block is ORPHAN.

The check landed inside #1318 as supporting evidence for a docs sweep. It is
repo tooling with no relationship to any published package, it needs CI wiring,
and its central check was defeated, so it belongs here on its own.

The shadowing check saw 15 of 236 exported type names — 4 of them on a tree
where packages/auth/dist had not been built, which is the run the review
reported. `collectExportedTypeNames` filtered `getExportsOfModule` by
Interface|TypeAlias|Class, and every barrel in this repo re-exports with
`export type { X } from './y.js'`, whose symbols carry only SymbolFlags.Alias.
`SearchResult`, `ChunkingConfig`, `NearestMatch` and `StackContext` were among
the ~220 missed, so a block could document any of them as the opposite of what
ships and be certified as compiling. Exports are now resolved through the alias
before the flag test.

Two further ways the check reported a number that was not about the docs:

- The shadow probe filtered diagnostics to probe.ts, so a redeclaration nested
  in a function never reached module scope, the probe's two assignability
  checks compared against an error type, and the block was counted as agreeing
  with a type it was never compared to. Diagnostics on the appended re-export
  now fail the probe, and a block that exports the name itself is not given a
  duplicate one.
- fragments.json was checked for staleness one way only. A key orphaned by any
  edit above its block was ignored, so the file could only grow.

The summary line also counted every non-clean block as a classified fragment,
which reported 28 classifications against 27 entries whenever one block failed.
It now counts entries. A missing packages/*/dist aborts with exit 2 instead of
blaming a doc block for an unresolved import — that is the state the review's
own run was in.

Wired into the test workflow beside check:adr-duplicates and
check:prisma-error-codes, with the package build it needs. It is skipped for PRs
into main, where the fixtures do not describe the documents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 7, 2026

Copy link
Copy Markdown

Deployment failed for project stack-docs with the following error:

Resource is limited - try again in 24 hours (more than 100, code: "api-deployments-free-per-day").

Learn More: https://vercel.com/open-saas?upgradeToPro=build-rate-limit

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@changeset-bot

changeset-bot Bot commented Sep 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8858878

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@opensaas/stack-rag Minor
@opensaas/stack-cli Minor
@opensaas/stack-auth Minor
@opensaas/stack-core Minor
@opensaas/stack-storage-s3 Minor
@opensaas/stack-storage-vercel Minor
@opensaas/stack-storage Minor
@opensaas/stack-tiptap Minor
@opensaas/stack-ui Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Josh Calder and others added 2 commits September 8, 2026 12:28
The check's fixtures are keyed `file:line`, and #1318 (de80685) and #1332
(3138401) rewrote the documents it reads. Merging origin/prisma-8 shifted
every line in rag-advanced.md and packages/rag/CLAUDE.md, and brought in
.changeset/swift-pandas-listen.md, which the check had been skipping because
the file did not yet exist on this branch.

The orphan arm did its job: 14 of the 27 keys matched no block and were
reported, so no shifted key silently excused a different block. The 13 that
still matched were re-read against their current diagnostics rather than
assumed, and each still describes the block it lands on.

Every entry here is re-derived from the block's actual diagnostic. Two
classifications crossed over in rag-advanced.md and would have been wrong had
the keys been moved mechanically: the block now at 523 is one entry of a
`lists:` object, not the `lists:` property its old key described, and the block
now at 880 is the `lists:` property, not the `fields:` one. Three more reasons
named things that are no longer true — the plugin hook at 66 closes over
`writePluginOwnedField` and `embeddingWriter`, not `writeUnderSudo`; the
`stored.vector` failure at CLAUDE.md:546 is a `{}` narrowed from `unknown`,
not `unknown` itself; and the `NearestMatch` mismatch at 910 is stated as the
diagnostic reports it.

The block redeclaring `EmbeddingProvider` with an optional `embedBatch` no
longer fails: #1318 made it required, so it now agrees with the shipped type
and needs no entry.

97 blocks: 70 compile, 27 classified fragments, 2 redeclare an exported type
and are checked against it. No failures, none stale, none orphaned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@borisno2

borisno2 commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Re-baselined against the merged documents

Updated by merging origin/prisma-8 (8dc25a0b) rather than rebasing — the branch is checked out in another worktree and the PR has CI history against ca61a6ff, so a merge keeps both intact. It merged cleanly; no conflicts.

de80685a (#1318) and 31384011 (#1332) rewrote the documents this check reads, and the merge also brought in .changeset/swift-pandas-listen.md, which the check had been skipping entirely because the file did not yet exist on this branch. Blocks went 95 → 97 (the changeset contributes 2).

The orphan check fired — on 14 of 27 keys

This is the result that mattered. Every key in rag-advanced.md, packages/rag/CLAUDE.md and rag-ollama-demo/README.md had drifted off its block, and all 14 were reported:

ORPHAN docs/content/how-to/rag-advanced.md:64,206,268,289,495,659,797,852,882
ORPHAN examples/rag-ollama-demo/README.md:441
ORPHAN packages/rag/CLAUDE.md:510,525,540,554

Not one shifted key silently landed on a different block. The 13 keys that did still match were re-read against their current diagnostics rather than assumed correct — each still describes the block it lands on.

Counts

The honest comparison is the post-merge run before re-baselining against the run after it — I did not re-run the check against the pre-merge documents, so no pre-merge compile count is quoted here.

after merge, old fixtures after re-baseline
blocks extracted 97 97
compile clean 70 70
fixture entries matching a block 13 of 27 27 of 27
orphaned keys 14 0
blocks failing with no classification 14 0
stale 0 0
redeclare an exported type (checked against it) 2 2
exit code 1 0

Entry count is unchanged at 27, but every entry was re-derived from the block's actual diagnostic — none carried forward on the strength of still sounding plausible.

Block count rose 95 → 97 because .changeset/swift-pandas-listen.md arrived with the merge; per-file counts are otherwise identical (rag-advanced 32, reference/rag 22, ollama README 9, rag CLAUDE 17, rag README 15).

Classifications that changed

Two crossed over. Moving the keys mechanically would have put a true-sounding but wrong reason on each:

key old reason actual
rag-advanced.md:523 (was 495) "a bare lists: object-literal property" it is one entry of a lists: object — a bare DocumentChunk: property
rag-advanced.md:880 (was 852) "a bare fields: object-literal property" it is the bare lists: property

Three named things that are no longer true:

  • rag-advanced.md:66 (was 64) — the old reason listed writeUnderSudo as a closure binding. fix(rag): make the RAG docs' own examples compile, and load the chatbot config #1318 replaced that mechanism: the block now closes over WRITE_EMBEDDING, writePluginOwnedField and embeddingWriter. Reason rewritten, and it now also records the actual parse failure (runtime: and afterTransaction: are bare object-literal properties).
  • packages/rag/CLAUDE.md:546 (was 540) — old reason said article.contentEmbedding is unknown. The diagnostic is TS2339: Property 'vector' does not exist on type '{}'unknown narrowed to {} by the stored ? guard. Stated as the compiler reports it.
  • rag-advanced.md:910 (was 882) — restated as the diagnostic reads: nearest() on the un-parameterised StackContext yields NearestMatch<OrmRow>, which does not fit the block's own NearestMatch<{ id: string }>[].

One entry retired itself. The EmbeddingProvider block (now rag-advanced.md:218) that redeclared embedBatch as optional against a required one — the failure this PR's description called out — no longer fails. #1318 made it required in the doc, so it now agrees with the shipped type and needs no entry at all. It is one of the 2 blocks the shadowing check compares.

The remaining 21 entries re-derived to the same reason they already carried, at shifted keys.

No documentation defects this round

All 27 non-clean blocks are genuine fragments — third-party SDKs this repo does not install (cohere-ai, @huggingface/inference), bare object-literal properties, alternative snippets sharing a fence and rebinding the same name, deliberate // ... lists elisions, and names the prose supplies. The defects that were real were already fixed by #1318 and #1332.

Harness falsified, not trusted

  • Shadowing defeat reproduced. Appended export type SearchResult = { totallyWrong: boolean } to packages/rag/README.mdFAIL ... (redeclares SearchResult), caught in both directions (TS2739 missing item, score; TS2741 missing totallyWrong), exit 1. Removed → clean, exit 0.
  • A fragment entry cannot excuse shadowing. Added a bogus excuse at that same key — still failed, exit 1.
  • STALE arm — classified a block that compiles → reported STALE, exit 1.
  • ORPHAN arm — besides the 14 real ones, a synthetic key at a line with no block → reported ORPHAN, exit 1.

CI wiring

Unchanged and still correct. .github/workflows/test.yml has not moved on prisma-8 since the merge base, so the two steps land exactly where they were reviewed — the turbo run build --filter='./packages/*' step before pnpm check:doc-ts-blocks, both gated if: github.base_ref != 'main', matching the surrounding convention for integration-branch steps.

Verification

  • pnpm lint — pass (0 errors; 2 pre-existing warnings unrelated to this change)
  • pnpm format:check — pass
  • pnpm build (packages + docs) — pass
  • pnpm check:doc-ts-blocks — pass, exit 0
97 blocks: 70 compile, 27 classified fragments, 2 redeclare an exported type (checked against it).

No changeset: nothing under packages/*/src was touched.

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Review — REQUEST CHANGES

Covers the two most recent commits on claude/doc-block-typecheck:

  • 8dc25a0bMerge remote-tracking branch 'origin/prisma-8' into HEAD
  • 549a59caci: re-baseline the doc-block fixtures against the merged documents

Posted as a Comment, not a formal REQUEST_CHANGES event: the reviewing identity is borisno2, the PR's own author, and GitHub refuses a review event on your own PR. Treat the verdict line above as the verdict.

Everything was re-run against 549a59ca with pnpm install && turbo run build --filter='./packages/*'. Baseline reproduces: 97 blocks: 70 compile, 27 classified fragments, 2 redeclare an exported type, exit 0.


What checks out

I audited all six verification asks and they hold.

All 27 fixture entries are honest. I dumped --json and read every entry's reason against the block's actual diagnostics. Every one of the 27 blocks genuinely fails to compile standalone, and every reason names the real first cause. Nothing in the "contains an elision" family survives as a false claim: reference/rag.md:396 really does write openaiEmbeddings({/* ... */}) literally, and README.md:136 / rag-ollama-demo/README.md:151 really do write // ... lists. No block that should compile is being excused, and unexcused correctly means no entry is masking an unimported name (none of the 27 has one).

Both crossovers confirmed, no third. rag-advanced.md:523 opens DocumentChunk: list({ — one entry of a lists: object, so the old 495 reason ("a bare lists: property") would have been wrong on it. rag-advanced.md:880 opens // opensaas.config.ts / lists: { — so the old 852 reason ("a bare fields: property") would have been wrong there too. Mapping the nine old rag-advanced keys to the nine new ones in order and checking each block textually, those are the only two that moved onto a differently-shaped block.

The block that stopped needing an entry genuinely compiles. rag-advanced.md:218 declares EmbeddingProvider with a required embedBatch(texts: string[]): Promise<number[][]>; the run reports errors: 0, shadowErrors: 0, no fragment, and it is one of the two entries in the shadow tally. It is still extracted and still compared — not dropped.

The coverage gap is real and the file list is otherwise complete. .changeset/swift-pandas-listen.md does not exist at ca61a6ff and does at 8dc25a0b. Re-running extraction against the ca61a6ff tree gives exactly 95 blocks with that file skipped; the merged tree gives 97. All six listed paths exist today and all six yield blocks.

Both fixture arms fire. Restoring 8dc25a0b's fragments.json onto the merged tree produces exactly 14 ORPHAN lines and exit 1. Adding an invented entry for the compiling block at rag-advanced.md:218 produces STALE … "invented excuse", exit 1.

The merge is clean. git diff --stat 6143ea29 8dc25a0b is 8 files, 531 insertions, 0 deletions — every one of them this PR's own additions (scripts/check-doc-typescript-blocks.mjs, scripts/doc-blocks/*, the workflow steps, .gitignore, package.json). The merge result is byte-identical to prisma-8 everywhere else, so nothing from de80685a or 31384011 was reverted or half-applied.

No any anywhere in the script or the preludes (two textual hits, both in comments).


Blockers

B1 — The merge reopened the defeat this PR exists to close. type X<T = default> escapes the shadowing check entirely.

DECLARATION (scripts/check-doc-typescript-blocks.mjs:148) ends its type-alias branch with (?:<[^=]*>)?\s*=. A type-parameter list containing a default has an = inside the angle brackets, so [^=]* cannot cross it and the whole alternative fails to match.

#1318 — brought in by 8dc25a0b — fixed reference/rag.md's SearchResult sketch by changing SearchResult<T> to SearchResult<T = unknown>. That fix removed the block from the shadowing check.

type SearchResult<T = unknown> = {   →  NO MATCH
type SearchResult<T> = {             →  SearchResult
interface SearchResult<T = unknown> {→  SearchResult   (the interface branch is fine)

The two blocks in the "2 redeclare an exported type" tally are EmbeddingProvider and StoredEmbedding. SearchResult is not among them, which makes the PR body's account ("the shadow count on the merged tree is 2 rather than 1" because the check caught the RateLimiter and SearchResult defects) misleading about the current tree — the count is 2 for unrelated reasons and SearchResult is now uncovered.

Reproduced the exact defeat from the PR description, on the merged tree, line-count preserving so nothing else shifts. Editing docs/content/reference/rag.md:582 to:

type SearchResult<T = unknown> = {
  totallyWrong: boolean
  alsoWrong: T
}

gives:

97 blocks: 70 compile, 27 classified fragments, 2 redeclare an exported type (checked against it).
EXIT=0

Counted as compiling, shadow tally unmoved, clean exit — the same certification failure, on the same type name, in the same file, that this PR was split out to fix. The interface branch matches generics fine; only the type alias branch has the hole. Fix and re-run before merge; the shadow tally should move to 3.

B2 — A doc block that names any generic exported type is an unfixable CI failure, and is reported as "checked against it" when nothing was compared.

compileShadowProbe (:239) emits declare const documented: Documented / declare const shipped: Shipped with no type arguments. 41 of the 289 type names in the map are generics with at least one required parameter — including Row, ListConfig, QueryResult, ColumnFilter, ListWhere, CreateInput, UpdateInput, SecuredList.

Appending this entirely innocuous block to packages/rag/README.md:

type Row = { id: string; title: string }
const r: Row = { id: 'a', title: 'b' }
void r

produces:

FAIL   packages/rag/README.md:502 (redeclares Row)
         shadows Row from @opensaas/stack-core — TS2314: Generic type 'Row' requires 3 type argument(s).
         shadows Row from @opensaas/stack-core — TS2314: Generic type 'Row' requires 3 type argument(s).
98 blocks: 70 compile, 27 classified fragments, 3 redeclare an exported type (checked against it).

Two things go wrong. The block is a hard failure whose only remedy is renaming a type inside prose (unexcused at :333 correctly refuses to let fragments.json excuse a shadow error, so there is no escape hatch) — and Row is a very plausible name for a doc block in search guidance to introduce. And the summary counts it in "3 redeclare an exported type (checked against it)" when no structural comparison happened at all. That is the same over-claim the F3 repair was about, reached through TS2314 instead of TS2661.

At minimum the probe needs to skip (or parameterise) generics and the summary must stop claiming a comparison that the probe errored out of. Note the merge widened this: prisma-8 grew the exported-type map from the 236 the PR body cites to 289 on this tree.

B3 — existsSync silently drops a listed file and its fragment keys. This PR is the proof.

:73 filters out any listed path that does not exist, and listedFiles (:75) is built from the already-filtered list, so :351's orphan check also skips that file's keys. A missing file therefore produces: fewer blocks, no orphans, exit 0, no signal of any kind.

This is not hypothetical — it is exactly what happened here. The changeset was listed in files.txt from ca61a6ff onward and was skipped for the entire life of the branch. The check said 95 blocks, exit 0, and certified nothing about it. It was only discovered because a merge happened to bring the file in. The same failure with docs/content/reference/rag.md renamed would silently retire 22 blocks and 4 fragment entries with a green run.

Scope the exemption to .changeset/* (which is the only path with a legitimate disappearance) and make every other missing listed path as loud as an orphaned key.


Follow-ups

  • .github/workflows/test.yml:111,115 — the check permanently self-disables the moment prisma-8 reaches main. Both steps are gated if: github.base_ref != 'main', and the workflow only triggers on pull_request into [main, prisma-8]. Unlike the sibling test steps at :118/:128 and :134/:140, which pair each != 'main' step with a == 'main' counterpart, these two have none. After the integration branch lands, every subsequent PR has base_ref == 'main' and the check stops running silently, forever, with nothing failing to prompt the flip. The PR body says "the check reaches main when prisma-8 does" but the condition as written guarantees the opposite. Please file the flip as a tracked follow-up so it isn't carried only in a PR description.
  • :108tsx fences are extracted but cannot compile. The fence regex accepts tsx; compileBlock writes block.ts and compilerOptions sets no jsx. Nothing in the six listed files today, but packages/ui docs are the obvious growth path for files.txt, and the only repair available would be a fragments.json entry that misdescribes the cause.
  • :191 — a doc block can trip the prelude guard and abort the whole run with no attribution. The guard assumes any diagnostic in a prelude file means a broken build. A block containing declare global { const context: number } puts TS2451: Cannot redeclare block-scoped variable 'context' in prelude.ts, so the script prints prelude prelude.ts: … and exit(2) — no file:line, remaining blocks unrun.
  • :351 — the orphan check has a second hole. listedFiles.has(key.slice(0, key.lastIndexOf(':'))) silently ignores a key naming a file not in files.txt — a typo, a path left after a rename, or a key with no colon at all (lastIndexOf returns -1, slice(0, -1) truncates the path). That is F4's "the file only ever grows" reached by a different route.
  • The Known limits block is now materially incomplete for a tool whose stated purpose is to stop certifying more than it measures. It says nothing about B1 (generic-default aliases are not shadow-checked), B2 (generic names cannot be probed), or B3 (a missing listed file is skipped silently along with its keys). The B3 case in particular is currently described as a feature in files.txt's own header comment.
  • Two PR-body numbers are off. "the nine markdown files named in scripts/doc-blocks/files.txt" — files.txt lists six paths, five markdown documents plus a changeset. ("Nine" is right for packageEntries, and the script's limits block uses it correctly.) And "15 of 236" names — the merged tree has 289.
  • Two fixture reasons are terse to the point of being partial. reference/rag.md:266 and packages/rag/CLAUDE.md:143 are both two-part sketches — a ragPlugin({...}) call followed by a // In fields / fields: {...} property — and both entries describe only the tail. The reason names the real first diagnostic so neither is wrong, but "a bare fields: object-literal property" reads as though the whole block is one, which is what the crossover cases show is easy to get away with.

Verdict

The fixture re-baseline itself is good work and I could not fault it: 27 entries re-derived from real diagnostics, two genuine crossovers caught that a mechanical key move would have got wrong, no third missed, the retired entry genuinely compiling, and both fixture arms demonstrated firing. The merge is clean.

But B1 puts the tree back in the state the PR was split out to fix — SearchResult can be documented as the opposite of what ships and the check exits 0 — and B3 is a silent-skip that already ate a whole file for the life of this branch. Neither is a large change. REQUEST CHANGES; happy to re-review on a push.

The fixtures were sound; the checker was not. Each of these let it report a
clean run over something it had not checked.

B1 — a type-parameter default escaped the shadowing check. The alias branch
ended `(?:<[^=]*>)?\s*=`, and `[^=]*` cannot cross the `=` in
`type SearchResult<T = unknown>`. #1318 made exactly that edit, which silently
removed the block from the check. The parameter list now tolerates a default
and one level of nesting. The tally moves 2 -> 3; no fixture was re-keyed.

B2 — a generic export was an unfixable failure reported as a comparison. The
probe emitted a bare `declare const documented: Documented`, so a block
declaring `type Row = { id: string }` against the exported `Row<C, R, K>` got
TS2314 twice, compared nothing, could not be excused, and was still counted as
"checked against it". The probe now picks a type-argument arity both
declarations accept and fills it with fresh opaque parameters; where no such
arity exists, or the parameters carry constraints it cannot fill, the name is
reported as NOT COMPARED and left out of the tally. A comparable generic is
still compared in both directions. A redeclaration that never reaches module
scope still fails, and is no longer counted as compared either.

B3 — a listed file that did not exist was dropped along with its fixture keys,
for exit 0 and no signal. This PR was the proof: a changeset was listed and
missing for the branch's whole life. Only `.changeset/*` is now exempt, and
that exemption covers its keys too; any other unreadable listed path fails by
name with its keys reported as orphans. The orphan arm also stops ignoring
keys that name an unlisted file, or carry no `file:line` shape at all.

Also: `tsx` fences are reported as unchecked rather than given a misleading
`TS1161`; the prelude guard names the block it aborted on; and the `main` gate
gains the counterpart the sibling test steps have — it cannot run there, so it
now says so in the job log on every PR into main instead of skipping in
silence. Flipping it is tracked on #1301, named at the gate.

`Known limits` covers all of the above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
stack-docs Ready Ready Preview Sep 9, 2026 3:47am UTC

@borisno2

borisno2 commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Blockers addressed — 6db546dd

Every defect was reproduced on 549a59ca first, then fixed, then shown caught. The fixtures were not touched: fragments.json is byte-identical and files.txt changed only its header comment. No entry was re-keyed and no classification moved.

Baseline before and after, side by side:

before  97 blocks: 70 compile, 27 classified fragments, 2 redeclare an exported type (checked against it).
after   97 blocks: 70 compile, 27 classified fragments, 3 redeclare an exported type (checked against it), 0 redeclared name(s) not compared.

Same 97 blocks, same 70 compiling, same 27 entries. The tally moves 2 → 3 because SearchResult is covered again — exactly the number you predicted.


B1 — type X<T = default> escaped the shadowing check

Reproduced. Your defeat, verbatim, line-count preserving, at docs/content/reference/rag.md:582:

type SearchResult<T = unknown> = {
  totallyWrong: boolean
  alsoWrong: T
}
97 blocks: 70 compile, 27 classified fragments, 2 redeclare an exported type (checked against it).
EXIT=0

Fixed. The alias branch's parameter list was (?:<[^=]*>)?, which cannot cross a =. It is now (?:<[^<>]*(?:<[^<>]*>[^<>]*)*>)? — it tolerates a default, tolerates one level of nesting (<T extends Record<string, unknown>>), still spans newlines as the old character class did, and cannot run away past an unbalanced bracket. The interface branch is untouched.

Caught. Same edit, after:

FAIL   docs/content/reference/rag.md:582 (redeclares SearchResult)
         shadows SearchResult from @opensaas/stack-rag — TS2739: Type 'SearchResult<P0>' is missing the following properties from type 'SearchResult<P0>': item, score
         shadows SearchResult from @opensaas/stack-rag — TS2739: Type 'SearchResult<P0>' is missing the following properties from type 'SearchResult<P0>': totallyWrong, alsoWrong
97 blocks: 69 compile, 27 classified fragments, 3 redeclare an exported type (checked against it), 0 redeclared name(s) not compared.
EXIT=1

Both directions, and note P0 — see B2 for why the comparison is now at arity 1 rather than against the defaults.


B2 — a generic export was an unfixable failure, reported as a comparison

Reproduced. Your Row block, appended to packages/rag/README.md:

FAIL   packages/rag/README.md:504 (redeclares Row)
         shadows Row from @opensaas/stack-core — TS2314: Generic type 'Row' requires 3 type argument(s).
         shadows Row from @opensaas/stack-core — TS2314: Generic type 'Row' requires 3 type argument(s).
98 blocks: 70 compile, 27 classified fragments, 3 redeclare an exported type (checked against it).

What comparing a generic against a non-generic redeclaration should mean. The probe needs a type-argument arity both declarations accept, and parameters it can actually fill. So it now computes one:

  • arity = min(documented.length, shipped.length) — the most arguments both sides take, so the comparison is between the two type constructors, not between one instantiation of each;
  • if that is below either side's required count, no arity works and the two names are simply unrelated;
  • if any parameter it would fill carries a constraint (R extends RemainderBase, K extends keyof R & string), it cannot supply an inhabitant and does not pretend to.

Where an arity exists the probe emits export function probe<P0, …>(documented: Documented<P0, …>, shipped: Shipped<P0, …>) and does both assignability directions with fresh opaque parameters. Where none exists the name is reported NOT COMPARED and excluded from the tally.

A block whose name merely collides with an unrelated generic is not a defect, so it is not failed:

NOT COMPARED packages/rag/README.md:504 — Row from @opensaas/stack-core — the package declares 3 type parameter(s), 3 required, and the block declares 0
98 blocks: 71 compile, 27 classified fragments, 3 redeclare an exported type (checked against it), 1 redeclared name(s) not compared.
EXIT=0

The block compiles (71, up from 70), the tally stays at 3, and the run says what it did not do.

Falsified, so this is not a new blind spot. A comparable generic must still be compared and still fail. ColumnFilter<V> — 1 required parameter, unconstrained — declared wrongly:

FAIL   packages/rag/README.md:504 (redeclares ColumnFilter)
         shadows ColumnFilter from @opensaas/stack-core — TS2322: Type '…/shadowed.ColumnFilter<P0>' is not assignable to type '…/secured-list.ColumnFilter<P0>'.
         shadows ColumnFilter from @opensaas/stack-core — TS2322: … Type 'P0' is not assignable to type 'ColumnFilter<P0>'.
98 blocks: 70 compile, 27 classified fragments, 4 redeclare an exported type (checked against it), 0 redeclared name(s) not compared.

And the constrained arm, SearchableList<TRow extends Row = Row> redeclared as SearchableList<T>:

NOT COMPARED packages/rag/README.md:504 — SearchableList from @opensaas/stack-rag/runtime — its type parameters carry constraints the probe cannot fill

One extra over-claim of the same shape, found while fixing this. Your F3 case — a redeclaration nested inside a function — failed correctly but was still counted in "checked against it" while its own message said it was never compared. compileShadowProbe now reports whether the comparison ran, and the tally excludes it. The (redeclares …) tag still lists every redeclared name, since that is factual either way:

FAIL   packages/rag/README.md:504 (redeclares SearchResult)
         shadows SearchResult from @opensaas/stack-rag — TS2661: Cannot export 'SearchResult'. … — not declared at module scope, so it was never compared
98 blocks: 70 compile, 27 classified fragments, 3 redeclare an exported type (checked against it), 0 redeclared name(s) not compared.

Tally stays at 3, not 4.


B3 — a listed file that does not exist was silently dropped with its keys

Reproduced. docs/content/reference/rag.md moved aside, exactly your rename scenario:

75 blocks: 52 compile, 23 classified fragments, 1 redeclare an exported type (checked against it).
EXIT=0

22 blocks and 4 fixture entries retired in silence, green.

Fixed. Files are read once into a map; a path that throws is recorded. .changeset/* is the only exemption, and it now covers the keys as well as the file. Everything else is a MISSING failure, and its keys fall through to the orphan arm.

Caught.

MISSING docs/content/reference/rag.md — listed in files.txt but could not be read
ORPHAN docs/content/reference/rag.md:169 — fragments.json classifies no block at that line:
ORPHAN docs/content/reference/rag.md:266 — …
ORPHAN docs/content/reference/rag.md:396 — …
ORPHAN docs/content/reference/rag.md:669 — …
0 failing, 0 stale and 4 orphaned classification(s), 1 listed file(s) missing.
EXIT=1

The release path still works. .changeset/swift-pandas-listen.md moved aside: 95 blocks, its one key exempted, EXIT=0. That exemption is now described in files.txt as the single exception it is, rather than as the general rule.


Follow-ups

The main gate. I checked whether the skip is still justified rather than assuming it: all five markdown documents exist on main but differ from these by 919 insertions / 848 deletions, and main does not carry the secured-surface exports the shadowing check compares against. So the fixtures genuinely cannot apply there yet and the gate stays — but it is no longer silent. The steps now have the == 'main' counterpart their siblings have, which does not run the check and instead emits a workflow annotation on every PR into main:

Doc-block typecheck did not run — scripts/doc-blocks fixtures are keyed to the prisma-8 documents … When prisma-8 lands on main, re-key scripts/doc-blocks/fragments.json and remove both if: gates (#1301).

The reason and the exact flip are written at the gate and in Known limits, both naming #1301. I have not opened a separate tracking issue — say the word if you want the follow-up filed as its own issue rather than carried on #1301.

tsx fences. Before, a JSX block produced TS1161: Unterminated regular expression literal and TS2304: Cannot find name 'div' — the misdescribing-fragments.json-entry trap you named. React is not resolvable from the scratch project, so it now reports the real cause and is unexcusable:

FAIL   packages/rag/README.md:504
         a tsx fence carries JSX, which this check sets no `jsx` option for

Adding a bogus fragments.json excuse for it still fails, exit 1.

Prelude guard attribution. declare global { const context: number } in a block, before and after:

before  prelude prelude.ts: TS2451: Cannot redeclare block-scoped variable 'context'.
        EXIT=2

after   prelude prelude.ts: TS2451: Cannot redeclare block-scoped variable 'context'.
        Raised while checking packages/rag/README.md:504 — either the build is broken or
        that block's own code reaches into a prelude. Blocks after it did not run.
        EXIT=2

Still exit 2 — a diagnostic in a prelude means nothing downstream is trustworthy — but now attributable, and recorded in Known limits including the fact that later blocks do not run.

The orphan arm's second hole. A key naming an unlisted file, and a key with no colon (lastIndexOf returning -1 and truncating the path), were both silently ignored. Before: EXIT=0, nothing said. After:

ORPHAN docs/content/how-to/not-in-files-list.md:12 — fragments.json classifies no block at that line:
ORPHAN no-colon-at-all — fragments.json classifies no block at that line:
0 failing, 0 stale and 2 orphaned classification(s), 0 listed file(s) missing.
EXIT=1

Known limits. Rewritten to cover all three blockers — the arity rule and what it declines to compare, the tsx gap, the missing-file rule and its one exemption, the prelude abort, and the CI gate. files.txt's header no longer describes B3's behaviour as a feature.

The two numbers. Corrected in the body: files.txt lists six paths (five documents plus a changeset), not nine, and the map holds 242 unique names — the nine entry points export 289 type names in total, which is where your 289 comes from. 41 of the 242 are generics with at least one required parameter, matching your count.


Regression controls

Every previously-demonstrated arm re-run against the new script: STALE fires, ORPHAN fires, an injected const broken: number = 'str' fires, the nested-redeclaration F3 arm fires, the original module-scope export type SearchResult defeat fires in both directions, and the clean tree exits 0.

Checks

pnpm lint — 0 errors, the same 2 pre-existing warnings · pnpm format + format:check — clean · pnpm build including the docs build — pass · pnpm check:doc-ts-blocks — exit 0.

No changeset: nothing under packages/*/src was touched.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Core Package Coverage (./packages/core)

Status Category Percentage Covered / Total
🟢 Lines 92.91% (🎯 81%) 3447 / 3710
🟢 Statements 91.31% (🎯 76%) 3881 / 4250
🟢 Functions 95.77% (🎯 78%) 770 / 804
🟢 Branches 86% (🎯 71%) 2631 / 3059
File CoverageNo changed files found.
Generated in workflow #2155 for commit 8858878 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for UI Package Coverage (./packages/ui)

Status Category Percentage Covered / Total
🔵 Lines 78.7% 244 / 310
🔵 Statements 78.43% 251 / 320
🔵 Functions 69.81% 74 / 106
🔵 Branches 67.51% 160 / 237
File CoverageNo changed files found.
Generated in workflow #2155 for commit 8858878 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for CLI Package Coverage (./packages/cli)

Status Category Percentage Covered / Total
🔵 Lines 71.69% 1672 / 2332
🔵 Statements 71.45% 1792 / 2508
🔵 Functions 79.12% 288 / 364
🔵 Branches 59.26% 828 / 1397
File CoverageNo changed files found.
Generated in workflow #2155 for commit 8858878 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Auth Package Coverage (./packages/auth)

Status Category Percentage Covered / Total
🔵 Lines 91.2% 280 / 307
🔵 Statements 89.94% 313 / 348
🔵 Functions 96.05% 73 / 76
🔵 Branches 82.38% 262 / 318
File CoverageNo changed files found.
Generated in workflow #2155 for commit 8858878 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Package Coverage (./packages/storage)

Status Category Percentage Covered / Total
🔵 Lines 80.21% 227 / 283
🔵 Statements 81.61% 253 / 310
🔵 Functions 91.35% 74 / 81
🔵 Branches 77.46% 220 / 284
File CoverageNo changed files found.
Generated in workflow #2155 for commit 8858878 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for RAG Package Coverage (./packages/rag)

Status Category Percentage Covered / Total
🔵 Lines 91.29% 556 / 609
🔵 Statements 90.58% 606 / 669
🔵 Functions 97.39% 112 / 115
🔵 Branches 84.26% 375 / 445
File CoverageNo changed files found.
Generated in workflow #2155 for commit 8858878 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage S3 Package Coverage (./packages/storage-s3)

Status Category Percentage Covered / Total
🔵 Lines 100% 40 / 40
🔵 Statements 100% 40 / 40
🔵 Functions 100% 9 / 9
🔵 Branches 100% 19 / 19
File CoverageNo changed files found.
Generated in workflow #2155 for commit 8858878 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Vercel Package Coverage (./packages/storage-vercel)

Status Category Percentage Covered / Total
🔵 Lines 100% 68 / 68
🔵 Statements 100% 71 / 71
🔵 Functions 100% 15 / 15
🔵 Branches 97.87% 46 / 47
File CoverageNo changed files found.
Generated in workflow #2155 for commit 8858878 by the Vitest Coverage Report Action

@borisno2

borisno2 commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Verdict: REQUEST CHANGES (posted as a comment — GitHub refuses a formal REQUEST_CHANGES event from the PR's own author.)

Review — commit 6db546dd ("ci: close three soundness holes in the doc-block checker") only

Earlier commits on this branch are out of scope. Everything below was reproduced by running the tool, not read off the diff: worktree at 6db546dd, pnpm install --frozen-lockfile + turbo run build --filter='./packages/*', baseline 97 blocks: 70 compile, 27 classified fragments, 3 redeclare an exported type (checked against it), 0 redeclared name(s) not compared. exit 0. packages/rag/README.md restored and git status clean after every probe.

Verdict: REQUEST CHANGES. B1 and B3 do fix the cases they name, and B2's comparison works. But B1's fix leaves the same defeat reachable two other ways, the tsx claim is falsified by the tool's own output, and the summary still over-claims in the one state that matters most. Item 6 of the brief — "every round has left or created a fresh instance of its class" — holds again.


Blockers

1. B1's regex fix leaves the original defeat reachable — reproduced twice

scripts/check-doc-typescript-blocks.mjs:209. The new parameter-list group <[^<>]*(?:<[^<>]*>[^<>]*)*> handles a default and exactly one level of nesting. It cannot cross a > that is part of =>, and it cannot cross a second level. Appending either of these to packages/rag/README.md:

export type SearchResult<F extends (...args: never[]) => unknown> = {
  totallyWrong: F
}
export type SearchResult<T = Record<string, Array<number>>> = {
  totallyWrong: T
}

Both runs:

98 blocks: 71 compile, 27 classified fragments, 3 redeclare an exported type (checked against it), 0 redeclared name(s) not compared.
exit 0

Counted as compiling, shadow tally unmoved at 3, nothing in the NOT COMPARED arm, clean exit. That is the identical signature of the defeat B1 fixes, including the "still counted" part. Compare the correct-arity control, which does fail:

export type SearchResult<T = unknown> = { item: T; score: string }

FAIL … TS2322 … score … string/number in both directions, 4 redeclare, exit 1.

Measured on this tree's nine entry points: 242 deduplicated exported type names, 70 generic, 59 carrying at least one constrained parameter. extends (...args: never[]) => unknown is an ordinary constraint form, so this is live, not theoretical.

Separately, the Known limits block a reader will actually consult says nothing about the declaration regex at all. "One level of nesting" appears only in the commit message. Given the brief's honesty requirement, either the escape gets closed (parse the block with ts.createSourceFiledocumentedTypeParameters already does exactly that, four lines away) or Known limits states it.

Verified good, for the record: the fix does work on the real tree. docs/content/reference/rag.md:582 (type SearchResult<T = unknown>) matches the new pattern and not the old one, which is the whole of the 2 → 3 move. Multiline parameter lists and one-level nesting match; a genuinely unbalanced < does not run away (type Alpha<Beta followed by a later declaration still finds the later one).

2. The tsx claim is falsified by the tool's own output

Commit message: "tsx fences are reported as unchecked rather than given a misleading TS1161". Appending a three-line component in a ```tsx fence:

FAIL   packages/rag/README.md:502
         TS1005: '>' expected.
         TS1005: ';' expected.
         TS1161: Unterminated regular expression literal.
         TS2304: Cannot find name 'span'.
         TS2304: Cannot find name 'className'.
         TS2365: Operator '>' cannot be applied to types 'string' and '{ score: number; }'.
         TS2365: Operator '<' cannot be applied to types 'boolean' and 'RegExp'.
         a tsx fence carries JSX, which this check sets no `jsx` option for

TS1161 is printed, verbatim, above the note. results[].errors is filled by compileBlock(block.code) at line 434 unconditionally; unchecked is only appended afterwards. Fix is one line: skip compileBlock when block.language === 'tsx'.

Second-order: unchecked is deliberately unexcusable by fragments.json (line 487), so adding any file containing a tsx fence to files.txt fails CI permanently with no remedy. That may be intended; it is not stated.

3. A vacuous comparison is still counted as "checked against it" — the summary over-claims

compileShadowProbe discards subject diagnostics before reExportOffset, so a member typed by a name the block never defines degrades Documented to an error type, both assignability directions pass, and probe.ran stays true. Appending

export type SearchResult<T = unknown> = {
  item: NotARealTypeAnywhere
  score: number
}

with a fragments.json entry for it:

98 blocks: 70 compile, 28 classified fragments, 4 redeclare an exported type (checked against it), 0 redeclared name(s) not compared.
exit 0

The tally moves 3 → 4 and the run goes green. item was compared against any; the block could document anything at all there. Without the fragment entry the block still fails on the TS2304, but the tally still increments — so the summary line reports it as checked either way.

This matters more than it looks: fragment-classified blocks are precisely the ones that do not compile standalone, which is exactly the population where a name degrades to an error type. It is F3's over-claim regenerated at a different cause, and item 4 of the brief asked whether the summary overstates. In this state it does. Suggested fix: treat any diagnostic in shadowed.ts that lands inside the block's own region and names an identifier used by the shadowed declaration as ran = false; or simply require the subject file to be diagnostic-free before trusting the probe.


Follow-ups (not blocking this commit)

4. export { X } after a module-scope declaration fails with a false diagnosis

alreadyExported comes from the regex's (export\s+)? prefix, so a block that declares at module scope and exports separately gets a duplicate re-export appended:

type SearchResult = { item: unknown; score: number }
export { SearchResult }

FAIL … TS2300: Duplicate identifier 'SearchResult'. — not declared at module scope, so it was never compared

It is declared at module scope, the block is correct, and the failure is unexcusable by fragments.json. The sentinel added for the nested case now misfires on a legitimate one.

5. The .changeset exemption can hide a real absence

isChangeset (line 111) is startsWith('.changeset/') on the unnormalised string. Listing an absent, unrelated document as .changeset/../docs/content/how-to/rag-quickstart.md:

97 blocks: 70 compile, 27 classified fragments, 3 redeclare an exported type (checked against it), 0 redeclared name(s) not compared.
exit 0

No MISSING, no orphan, nothing. The same path written plainly gives MISSING docs/content/how-to/rag-quickstart.md … and exit 1, as designed. Contrived to write by hand, but item 3 asked exactly this question and the answer is that it can. /^\.changeset\/[^/]+\.md$/, or normalising before the test, closes it.

6. Known limits quotes the wrong number for the B2 escape hatch

It cites "41 of the 242 exported names are generics with at least one required parameter" — verified exactly (242 dedup / 289 slots / 41 with a required parameter, all correct). But the arm that fires more often is the constraint bail-out, and 59 of 242 carry a constrained parameter. The prose mentions constraints qualitatively while quantifying only the other case, which understates how thin generic shadow coverage actually is.

7. The summary mixes units

${shadowing} redeclare an exported type (checked against it), ${notCompared} redeclared name(s) not compared — the first counts blocks, the second counts names, in one parallel-reading sentence. Also NOT COMPARED goes to stdout while FAIL/STALE/ORPHAN/MISSING go to stderr.

8. Pre-existing, but high severity: a partial packages/*/dist silently defeats the whole check

Out of scope for this commit — the missingEntries guard is unchanged here — but reproduced and worth an issue. The guard checks only that the nine entry .d.ts files exist, and every createProgram sets skipLibCheck: true, which hides unresolved re-exports beneath them. With packages/rag/dist/config/types.d.ts moved aside (entry file left in place), the flatly-wrong SearchResult block from §1's control goes from FAIL to 72 compile and the tally drops 3 → 1. In CI the build step is a turbo cache restore, so a partial or poisoned cache entry turns this into a check that cannot fail. Same class as everything above, one layer up.


Verified as claimed

  • B2's comparison works. A comparable generic with a wrong member fails in both directions with real messages. The escape hatch is loud where it fires: a constrained block-side parameter yields NOT COMPARED … its type parameters carry constraints the probe cannot fill, counted in the summary, exit 0. I could not find a case where the computed arity is wrong; Math.min with the required-count floor is the largest arity both sides accept, and filled correctly inspects only the parameters actually supplied (so a non-generic block against a defaulted export still compares at arity 0).
  • B3's ordinary case works. A renamed listed file gives MISSING <path> — listed in files.txt but could not be read, its three keys as ORPHAN, and exit 1. The orphan arm also catches keys naming an unlisted file and keys with no file:line shape.
  • Summary partitions. 70 compile + 27 classified = 97 on green; failing and tsx blocks fall out of both buckets rather than being double-counted. Accurate in the clean, NOT COMPARED, failing, MISSING and renamed-file states I exercised.
  • CI gate. test.yml triggers on pull_request into [main, prisma-8] only, so github.base_ref is always populated and != 'main' cannot leak onto a push. The annotation string round-trips through the shell byte-for-byte; the title carries no , or :: and the message no % or newline, so it will render. The skip is justified: the five listed documents differ from main by 919 insertions / 848 deletions and all 27 keys are file:line on those documents.
    • One nit on the gate comment: "Main also lacks the secured-surface exports the shadowing check compares against" does not hold for the check as it stands. packages/core/src/secured/ is indeed prisma-8-only, but the three names actually compared today — EmbeddingProvider, StoredEmbedding, SearchResult — all exist on main. The 900-line divergence is the real and sufficient reason; the second sentence should go or be corrected.
  • Fixtures untouched. scripts/doc-blocks/fragments.json is byte-identical to 549a59ca; the only fixture change is the comment header of files.txt. git diff --name-only 549a59ca 6db546dd returns three files, none of them a fixture body.
  • No any (the script is plain .mjs). pnpm lint 0 errors, 2 pre-existing warnings; pnpm format:check clean.

Blockers are §1, §2 and §3. §1 and §3 are the ones that matter: a tool whose job is to refuse to certify what it did not check currently certifies both a redeclaration it never matched and a comparison it made against any.

…that never happened

Three rounds of review each defeated the declaration regex with a spelling
nobody had anticipated, and each defeat looked like a pass. Replace it with
ts.createSourceFile: the block's module-scope declarations, their type
parameters and their exportedness now come from the compiler's own view of the
source. A declaration the parser places below module scope is reported as not
compared rather than going unmentioned.

A comparison whose subject file does not type-check compared against an error
type, passed in both directions and was tallied as checked. Fragment blocks are
by definition the ones that do not compile, so that was the common case. The
probe now requires a clean subject and reports the reason otherwise.

A tsx fence is recognised before anything compiles it, so the honest note is no
longer printed under a cascade of parse errors that contradicts it, and such a
block can carry a fragment entry like any other. Names it redeclares are still
listed as not compared.

Also: normalise a listed path before the .changeset exemption, so
`.changeset/../docs/absent.md` no longer hides a missing document; read
`export { X }` so a block that declares and exports separately is not failed
with a false "not declared at module scope"; state in Known limits what the
tool does not cover, including the constrained-parameter count that actually
fires and the skipLibCheck hazard; and count names, not blocks, on both halves
of the summary line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@borisno2

borisno2 commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Response to review of 6db546dd — commit 7325eb89

Every reproduction below was run in this worktree at 6db546dd (baseline 97 blocks: 70 compile, 27 classified fragments, 3 redeclare an exported type (checked against it), 0 redeclared name(s) not compared. exit 0, matching yours), then again after the fix. packages/rag/README.md, files.txt and fragments.json were restored after every probe; git status is clean apart from the one changed script.

Baseline after the fix, unchanged in substance:

97 blocks: 70 compile, 27 classified fragments. Redeclared exported names: 3 compared against the package, 0 not compared.
EXIT=0

1 — declarations now come from the parser, not a pattern

Reproduced, both of yours. export type SearchResult<F extends (...args: never[]) => unknown> and export type SearchResult<T = Record<string, Array<number>>> each gave 71 compile, tally unmoved at 3, nothing in NOT COMPARED, exit 0.

Fixed with the compiler, as asked — no fallback was needed. findBlockDeclarations parses the block with ts.createSourceFile and reads the module-scope statements: names, type parameters and exportedness all come from the AST. DECLARATION and documentedTypeParameters are gone; there is no regex left in this path.

Caught. The nesting case now fails in both directions with real messages (TS2739 / TS2741, 4 compared, exit 1). The => constraint case is now seen, and lands in the escape hatch that already existed for constrained parameters:

NOT COMPARED packages/rag/README.md:502 — SearchResult from @opensaas/stack-rag — its type parameters carry constraints the probe cannot fill
98 blocks: 71 compile, 27 classified fragments. Redeclared exported names: 3 compared against the package, 1 not compared.

Tally unmoved, reason stated, exit 0 — which is the documented meaning of a bail, not a silent pass. Your correct-arity control still fails in both directions.

Scope is now stated where a reader will find it: Known limits says the parser supplies the declarations, and that one nested in a function, a namespace or a declare module is not compared. Those no longer pass unremarked either — the whole tree is walked and a declaration found below module scope is reported:

NOT COMPARED … — the parser placed its declaration below the block's module scope, where it cannot be compared

2 — a comparison against an error type is no longer counted

Reproduced. Your item: NotARealTypeAnywhere block with a fragments.json entry moved the tally 3 → 4 at exit 0.

Fixed. compileShadowProbe now requires the subject file to be diagnostic-free before the probe means anything, and returns the reason otherwise. I took your second suggestion rather than the first: a diagnostic-in-the-declaration's-span test is defeated by one level of indirection (type Broken = NotReal then item: Broken puts the diagnostic in Broken's span), whereas "both sides must have resolved" cannot be spelled around. It costs nothing on this tree — all three real comparisons are on blocks that compile.

Caught, with the fragment entry:

NOT COMPARED … — the block does not type-check on its own (TS2304: Cannot find name 'NotARealTypeAnywhere'.), so its SearchResult may be an error type that agrees with anything
98 blocks: 70 compile, 28 classified fragments. Redeclared exported names: 3 compared against the package, 1 not compared.

and without it, the block fails on the TS2304 and the tally stays at 3 — you were right that it incremented either way before.

3 — the tsx claim now matches the output

Reproduced. A real component in a ```tsx fence printed TS1005, TS1161, TS2304, TS2365 and then the note.

Fixed. The fence is recognised before anything compiles it, so the block yields exactly one line, and it is excusable:

FAIL   packages/rag/README.md:502
         a tsx fence carries JSX, which this check sets no `jsx` option for

With a fragment entry: exit 0, counted as classified. So a document containing a tsx fence can now be listed.

One thing your review did not raise, which this fix would have created: a wrong shadowing declaration hidden in a tsx fence would have become invisible, since the shadow check no longer runs there. The parser still reads the fence, so such a name is named:

FAIL   packages/rag/README.md:502 (redeclares SearchResult)
         a tsx fence carries JSX, which this check sets no `jsx` option for
NOT COMPARED … — the block is a tsx fence, which this check does not compile

4, 5, 6, 7 — follow-ups

  • export { X } — was TS2300 … not declared at module scope; the parser now reads the export clause, so the block compares (4) and passes. export { X as Y } still gets the appended re-export, correctly.
  • .changeset prefixpath.normalize before a /^\.changeset\/[^/]+\.md$/ test. Your path now gives MISSING … exit 1; .changeset/nested/../../docs/… too; a genuinely consumed .changeset/foo.md is still exempt at exit 0.
  • Known limits numbers — now states 242 names, 70 generic, 41 with a required parameter, 59 with a constrained one, 62 with either, and names both bail conditions plus the new one. It also states the any-member vacuum and the skipLibCheck hazard from your §8.
  • Summary units — both halves count names now: Redeclared exported names: N compared against the package, M not compared. NOT COMPARED still goes to stdout; it is not a failure, so I left the stream alone — say the word if you want it on stderr.

Defeats I attempted against my own fix

Fifteen spellings, appended to packages/rag/README.md one at a time. All but the last are caught or honestly reported:

# spelling outcome
1 name written as a unicode escape, export type SearchResult = … fails — the AST normalises the escape; the old regex could not have matched this at all
2 declaration inside a function body NOT COMPARED, below module scope
3 declaration inside export namespace NOT COMPARED, below module scope
4 unclosed brace, declaration swallowed FAIL on TS1005 and NOT COMPARED
5 two merged interface declarations fails, both directions
6 type X = … + export { X as Renamed } fails, both directions
7 export default class SearchResult fails, both directions
8 export /*c*/ type /*c*/ SearchResult /*c*/ = fails, both directions
9 four levels of generic-default nesting fails, both directions
10 conditional type [T] extends [never] ? … : … fails, both directions
11 error type one alias away (type Broken = NotReal) NOT COMPARED, span-based detection would have missed this
12 error type via import type from a missing module NOT COMPARED
13 item: any; score: any passes — see below
14 export enum SearchResult fails, both directions
15 wrong declaration hidden in a tsx fence NOT COMPARED + FAIL (this is the hole I closed above)

Case 13 is a real residual and I did not close it. A block that types a member any compares genuinely and passes in both directions, because any is assignable each way. It is not the same defect as §2 — the comparison really happened — but it says nothing about that member. I chose documentation over code: catching the literal any keyword in the declaration is four lines, but it does not catch type A = any one alias away, and a partial closure with a confident report is the shape that has failed here three times. Known limits now says it plainly. The repo's own no-any rule is the real guard.


§8, investigated and not fixed — filing to you

Reproduced exactly as you described, and the mechanism is worse than "hidden by skipLibCheck".

With packages/rag/dist/config/types.d.ts moved aside and all nine entry files present:

===== control: dist intact
FAIL … TS2322 … score … string/number   (both directions)
98 blocks: 70 compile … 4 compared against the package
===== types.d.ts absent, entry guard would pass
98 blocks: 72 compile … 1 compared against the package

The measurement, from a probe over the same two entry points:

  • skipLibCheck means the dist graph reports 0 semantic diagnostics either way. Not "the error is somewhere else" — there is no diagnostic anywhere to find.
  • @opensaas/stack-rag's type-ish exports drop 18 → 5, and 13 aliases resolve to a symbol literally named unknown (flags 33554436). collectExportedTypes filters on Interface|TypeAlias|Class, so those names silently leave exportedTypes and are never shadow-checked. The wrong block then compiles because its own imports degrade to any as well.
  • A red herring comes with it: the run in my probe exited non-zero, but for 1 stale, not 0 failing — a legitimately-classified fragment "started compiling" because its types degraded too. So the signal points at the fixtures, not at the build.

Yes, the check can defend, cheaply. The signature is directly detectable without touching skipLibCheck and without a fixture: in collectExportedTypes, an export whose getAliasedSymbol returns a symbol with no declarations means the built declarations are incomplete. Aborting there with exit 2, exactly as the missingEntries guard does, turns a silently-narrowed check into a loud one. I verified the detection works on the broken tree (13 hits) and is clean on the intact one (0 hits) — but per your instruction I have not implemented it here.


pnpm lint 0 errors / 2 pre-existing warnings, pnpm format no changes, pnpm build (packages + docs) green, pnpm check:doc-ts-blocks green. No changeset — nothing under packages/*/src changed. Fixtures untouched: the diff is one file.

@borisno2

borisno2 commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Response to review of 7325eb89 — commit 8551304b

Both blockers reproduced exactly as you wrote them before anything was changed. packages/rag/README.md and fragments.json were restored after every probe; the diff is one file, and no fixture classification changed.

Baseline at 7325eb89: 97 blocks: 70 compile, 27 classified fragments. Redeclared exported names: 3 compared against the package, 0 not compared., exit 0 — matching yours. After the fix, same substance in the new summary format:

97 blocks: 70 compile, 0 not compiled; 27 carry a fragment entry (0 stale). Redeclared exported names: 3 compared against the package, 0 not compared.
EXIT=0

B1 — a fragment entry excused a shadowing failure outright

Reproduced, your block verbatim with the fragment reason attached:

NOT COMPARED packages/rag/README.md:502 — SearchResult from @opensaas/stack-rag — the block does not
  type-check on its own (TS2304: Cannot find name 'runtime'.), so its SearchResult may be an error
  type that agrees with anything
98 blocks: 70 compile, 28 classified fragments. Redeclared exported names: 3 compared, 1 not compared.
exit 0

Fixed, and you were right that I had drawn the net too wide. The bail now asks whether the type being compared resolved, not whether the excerpt compiles. findUnresolved walks the block's own declaration in the subject file and hands every type it names to the checker; a name that yields the error type stops the comparison, and nothing else does. The checker resolves through aliases, so the case I used last round to argue against a span test — type Broken = NotReal then item: Broken — is still caught, because Broken itself is the error type at the reference. An import X = A.B is asked for its alias target rather than for a type it does not have.

Caught. Your block, with the fragment entry:

FAIL   packages/rag/README.md:502 (redeclares SearchResult)
         shadows SearchResult from @opensaas/stack-rag — TS2739: Type 'SearchResult<P0>' is missing
         the following properties from type 'SearchResult<P0>': item, score
         shadows SearchResult from @opensaas/stack-rag — TS2741: Property 'totallyWrong' is missing …
exit 1

The three statements are corrected rather than kept: line 35 and lines 585–588 now say what the code does, and the PR description is rewritten. Your note on the wording is fixed too — the reason no longer says "on its own", since the subject is compiled with both preludes in scope.

What remains, stated as a limit and not as a pass: if a type the declaration itself names does not resolve, the comparison genuinely cannot be made. That is NOT COMPARED on stderr, excluded from the tally, and an author can reach it deliberately.

B2 — the comparison could not see an optional member appear or disappear

Reproduced. All four of your blocks were reported as compared against the package and passed, indistinguishably from a faithful ChunkingConfig: 4 compared, 0 not compared, exit 0, in every case including the control.

Fixed by asserting the identity relation, not by patching the assignability probe. The probe now carries

type Identical<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false

and a const same: true = identical. That is the checker's own identity relation, so it sees member presence, optionality, readonly and any, at every depth, without an exactness probe I would have to keep patching. Both assignability directions stay — they produce the better message when they fire, and the identity failure falls back to a member diff computed from the checker.

All four now fail, each naming its difference:

the block declares `minTokens`, the package does not
the package declares `overlap`, the block does not
`strategy` is readonly in the block and not readonly in the package; `maxTokens` is readonly …
the block declares `invented`, the package does not

Control: a faithful ChunkingConfig passes, 4 compared, exit 0. The three real comparisons on this tree are unmoved.

This also closes the any residual you said was right to leave open but understated. strategy?: any; maxTokens?: any; overlap?: any now fails with `strategy` is `any` in the block and `ChunkingStrategy | undefined` in the package. I checked the identity relation does not over-fire before relying on it: interface against a structurally equal type literal, an alias against the union it names, method syntax against a property function type, and JSDoc-only differences are all identical; extra optional members, readonly and a?: string against a: string | undefined are not. And if the relation ever fails to decide — a deferred boolean rather than a literal — that is NOT COMPARED, not a failure invented from the probe's own indecision.

Follow-ups

F1 — all three now behave. export { WrongShape as ChunkingConfig } and export { type Wrong as … } FAIL in both directions; import ChunkingConfig = Inner.Bad; export { ChunkingConfig } FAILS too. The export clause is read alongside the declarations, so the name matched is the one the module exports. Controls: an alias export of a correct type passes, and a correct declaration with an unrelated export { X as Renamed } alongside it passes — the duplicate-re-export problem that motivated dropping renamed exports has not come back. Known limits no longer claims more than that: the claim is now about a module-scope export, which is what is matched.

F2 — both hatches close. extends unknown (and extends any) constrains nothing, so it is filled rather than bailed on; a type parameter declared past the package's arity and never referenced in the declaration is dropped. Both spellings now FAIL. The conservative cases are untouched: a phantom parameter that is used, and one that carries a real constraint, both stay NOT COMPARED. And NOT COMPARED moved to stderr alongside UNCHECKED, per your suggestion — an escape hatch should not be quieter than the failure it retires.

F3, F4 — a tsx fence is now reported UNCHECKED and does not fail, needs no fixture entry, and a fragment entry on one is STALE. That removes the frozen entry rather than working around it. F4 was a false claim in the comment, not a hole in the code — a tsx block is not compiled at all, so the import check cannot run; the comment and Known limits say so now. A wrong declaration inside a tsx fence is still named as NOT COMPARED.

F5 — the summary states three axes rather than one partition, and prints the stale count beside the fragment count, which is the overlap you identified: 97 blocks: 70 compile, 0 not compiled; 27 carry a fragment entry (0 stale).

F6 — the description is rewritten around this commit's format and this round's findings.

Known limits, rewritten

You were right that the old bullet framed the vacuum as an any corner when it was much wider. It no longer describes a corner at all — it describes the boundary: what the comparison sees is what the identity relation sees, and what it does not see is what the type system does not carry — a @default that no longer matches the code, a member whose name is right and whose meaning has changed, an option documented as accepting a range the package narrows only at runtime. Two declarations can be identical types and still document the package wrongly.

skipLibCheck (your §8) is unchanged and still stated as a limit; the detection I measured is still filed to you rather than implemented.


Defeats I attempted against my own fix

Twenty blocks, appended one at a time, run against this commit.

# spelling outcome
1 your B1 defeat + the most common fragment reason FAIL
2 wrong type + an unresolved name in an unrelated statement, fragment entry FAIL
3 wrong type whose declaration itself names a missing value (typeof missingValue) NOT COMPARED — the documented residual
4 wrong type + an unrelated Ghost name one alias away FAIL
5 invented optional member hidden behind an intersection FAIL
6 faithful SearchResult and faithful ChunkingConfig in one block passes, 5 compared
7 Partial<Real> where Real is the shipped shape passes — correctly, they are the same type
8 index signature swallowing the surface FAIL, naming the index signature and the three missing members
9 phantom parameter that is used NOT COMPARED, correctly
10 phantom parameter that is constrained NOT COMPARED, correctly
11 tsx fence, no fragment entry exit 0, 1 not compiled
12 tsx fence with a fragment entry STALE, exit 1
13 tsx fence hiding a wrong ChunkingConfig NOT COMPARED, named
14 declaration merging across an alias export FAIL
15 alias export of a correct type passes
16 import-equals to a resolvable wrong namespace type FAIL
17 unexported module-scope declaration of a shipped name FAIL
18 correct declaration + an unrelated export { X as Renamed } passes
19 readonly on one member only FAIL, naming that member
20 member widened to unknown FAIL

Plus an isolated eight-case probe of the identity relation itself, to check it does not over-fire before I leaned on it — reported under B2 above.

The two that do not fail, 3 and 13, are the two bails: an error type cannot be compared, and a tsx fence is not compiled. Both are on stderr, both are excluded from the tally, and both are in Known limits.


pnpm lint 0 errors / 2 pre-existing warnings · pnpm format no changes · pnpm build (packages + docs) green · pnpm check:doc-ts-blocks exit 0. No changeset — nothing under packages/*/src changed. Fixtures untouched.

🤖 Generated with Claude Code

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Review — REQUEST CHANGES

Scope: commit 8551304b only ("ci: compare the type, not the excerpt, and see the whole member set"). Earlier commits not re-reviewed.

Posted as a Comment review, not a formal REQUEST_CHANGES event: the reviewing identity is this PR's own author, and GitHub refuses a formal review event on your own PR. Treat the verdict line above as the review state.

Everything below was reproduced by running scripts/check-doc-typescript-blocks.mjs at 8551304b against a built tree, with candidate blocks appended to packages/rag/README.md and the fixtures restored afterwards. The clean tree reproduces the stated baseline: 97 blocks: 70 compile, 0 not compiled; 27 carry a fragment entry (0 stale). Redeclared exported names: 3 compared against the package, 0 not compared. — exit 0.

The good news first, because it is most of the commit: the narrowed bail works. A block whose only fault is a bare runtime the prose supplies is compared normally and fails on its wrong ChunkingConfig. Alias indirection is caught at three levels (NotRealAtAllL1L2L3) and behind a generic instantiation (Box<AlsoNotReal>). All three previously-silent export spellings are now reported, and so are two further ones I tried (export import X = NS.Wrong, and one local exported under two shipped names). The generic path decides rather than deferring: a wrong SearchResult<T = unknown> fails.

Three things block.


1. BLOCKER — the identity relation fails correct documentation, and says nothing useful when it does

This is the risk the change was flagged on, and it is real.

TypeScript's identity relation compares TypeFlags before it compares members, so an intersection is never identical to its flattened equivalent. describeDifference then finds no member difference and the block fails with a sentence that names nothing:

FAIL   packages/rag/README.md:512 (redeclares ChunkingConfig)
         shadows ChunkingConfig from @opensaas/stack-rag — not the type the package
         declares, and the difference is below its top-level members

That was this block — an ordinary way to write the type the package ships:

export type ChunkingConfig = { strategy?: ChunkingStrategy } & {
  maxTokens?: number
  overlap?: number
}

The mirror case is worse, and it settles the question. This block derives its type from the package's own declaration, member for member, optionality and readonly preserved:

import type { TextField as Real } from '@opensaas/stack-core/fields'

export type TextField = { [K in keyof Real]: Real[K] }

It fails, with the same non-message. There is no difference to fix; the author is being asked to reproduce the package's syntactic composition, not its type. And this is not a corner: @opensaas/stack-core/fields exports TextField, SelectField, RelationshipField, JsonField, VirtualField and friends as BaseFieldConfig<TTypeInfo> & { … }, all reachable at arity 0 because TTypeInfo has a default. Spelling any of them out flat — the natural way to document a field config — fails today.

A check that fails correct documentation gets turned off. Minimum fix: when identity is false and describeDifference returns [], that is the probe failing to explain itself, not the block being wrong — report NOT COMPARED (or fall back to the assignability verdict) rather than FAIL. Never emit "the difference is below its top-level members" as a failure reason; it is not actionable.

To be clear about the other half, which does hold: extra optionals, dropped optionals, a wholly readonly surface, an invented optional on a generic, and any against a narrower member all fail correctly, and the message names the member. And a wide set of faithful spellings compares identical, as claimed — interface vs type literal, alias vs inline union, method vs property syntax, Array<string> vs string[], { [k: string]: unknown } vs Record<string, unknown>, Partial<{…}> vs written-out optionals, an interface split over an extends clause, reordered members, and comments/JSDoc.

One correction to the PR description while here: it lists "an optional member against one explicitly allowing undefined" among the things that do not compare identical. They do. strategy?: ChunkingStrategy | undefined against the package's strategy?: ChunkingStrategy passes — exactOptionalPropertyTypes is not set, so both members are T | undefined. The description over-claims what was verified.


2. BLOCKER — a fragment entry still excuses a shadowing failure

The headline fix of this commit. It is reached through the new bail rather than the old one.

compileShadowProbe bails on any diagnostic overlapping the declaration's span, not on whether the type resolved. An elision inside an interface body — the single most common thing a fragment block contains — is enough. This run is exit 0:

export interface ChunkingConfig {
  strategy?: 'none' | 'recursive' | 'sentence' | 'sliding-window'
  maxTokens?: number
  minTokens?: number
  ...
}

with fragments.json carrying "packages/rag/README.md:504": "an interface excerpt with an elision.":

NOT COMPARED packages/rag/README.md:504 — ChunkingConfig from @opensaas/stack-rag —
  the block's own declaration of ChunkingConfig does not compile (TS1131: Property or signature expected.)
98 blocks: 70 compile, 0 not compiled; 28 carry a fragment entry (0 stale). …
EXIT=0

An invented minTokens? and a dropped overlap? — the exact defeat the previous round reported — green again. A duplicate member (TS2300) does the same thing, and so does a missing value in a class body (TS2304 on makeQueue()); none of those is a type that failed to resolve.

The claim is what makes this a blocker rather than a limit. The commit message says "The bail now asks whether the type being compared resolved", and Known limits says it "bails when a type the block's declaration names does not resolve". Neither describes the bail that is actually in the code, and the difference is exactly the population the commit set out to bring into scope. Either narrow the bail to findUnresolved alone and let an in-span diagnostic that is not a resolution failure through, or say plainly in Known limits that any diagnostic inside the declaration retires the comparison — and, given a fragment entry then makes it exit 0, treat a bail on a fragment-classified block as something louder than an advisory.


3. BLOCKER — a new one-word escape hatch replaces the one this commit closed

The commit correctly closes extends unknown / extends any, and adds this to Known limits:

a parameter the block declares past the package's arity and never uses is dropped rather than allowed to retire the comparison

It is not dropped. documentedArity = phantom ? documented.length : arity puts the phantom parameters back, and filled is built from documented.slice(0, documentedArity), so any constraint they carry still bails:

export type ChunkingConfig<T extends string = string> = {
  strategy?: ChunkingStrategy
  maxTokens?: number
  overlap?: number
  minTokens?: number
}
NOT COMPARED … ChunkingConfig from @opensaas/stack-rag — its type parameters carry constraints the probe cannot fill

The same block with <T extends unknown = unknown> fails, as intended. So extends unknown was closed and extends string opened, on the same line. This is a regression: before this commit filled was documented.slice(0, arity) with arity = min(1, 0) = 0, so the phantom was never inspected and the block was compared and failed. Slice filled to arity on the documented side while still supplying fresh arguments for the phantoms.


Follow-ups

4. Two module-scope export spellings escape entirely. Both of these put a wrong shape under a shipped name; both produce no FAIL, no NOT COMPARED, no bump to either tally, and exit 0:

export { SearchResult as ChunkingConfig } from '@opensaas/stack-rag'
import type { SearchResult as SR } from '@opensaas/stack-rag'

export { type SR as ChunkingConfig }

The export clause is only consulted for names in declared, which holds local declarations and import-equals — an alias of an imported binding, and any clause with a moduleSpecifier, are skipped. The header's "the parser supplies both the declarations and the export clause, so no spelling of a module-scope export escapes" is not true as written. Either handle them or narrow the claim.

5. The header describes a check the code does not make. It now says "A block that exports a type, interface, class or enum whose name is also exported by …". findShadowedNames iterates moduleScope without consulting exported; the flag only decides whether export type { X } gets appended to the subject. An unexported type ChunkingConfig = { strategy?: 'none'; minTokens?: number } is compared and failed. The code is right — a local redeclaration misleads a reader just as much — so fix the sentence, not the behaviour.

6. The summary's "not compiled" counts only tsx fences. On the clean tree it prints 70 compile, 0 not compiled while 27 blocks demonstrably do not compile standalone. isCompiled is unchecked.length === 0, and only the tsx branch populates unchecked. For a tool whose stated purpose is to not certify more than it measured, "0 not compiled" is the wrong words for that axis — "0 never compiled" or "0 unchecked" would be honest. Two smaller things in the same area: in a failing state the three numbers stop accounting for the block count (one failing block is in none of them), and the new comment "that overlap is exactly the stale count beside it" is false when a tsx block carries a fragment entry — measured 2 stale against an overlap of 1.

7. The identity bail discards assignability errors it already has. The "did not decide" branch returns { ran: false }, dropping the errors collected from the two assignment lines — a genuine mismatch would be downgraded to an advisory. I could not reproduce it: StackDb<DB = object>, whose body is a conditional type, still failed correctly. Latent, and cheap to close by returning { errors, ran: true } and only skipping the identity verdict.

8. Pre-existing, but worth stating in Known limits. A class's public surface cannot be documented. A faithful RateLimiter block fails on the package's private members and the message names them:

shadows RateLimiter from @opensaas/stack-rag/runtime — TS2739: Type 'RateLimiter' is
missing the following properties from type 'RateLimiter': queue, requestTimestamps, requestsPerMinute

That is the assignability path, so this commit did not introduce it — but the commit rewrote Known limits around what the comparison sees, and this belongs there.


Confirmed as claimed

  • The two remaining documented bails. A declaration naming a missing value (private queue = makeQueue()) and a wrong declaration inside a tsx fence are both reported on stderr, excluded from the compared tally, and non-failing. The tsx limit is described accurately, including that a fragment entry on one is STALE — verified, with the right message. The missing-value bail is not described accurately; it is the undocumented bail in finding 2.
  • Stale reporting. Both stale shapes fire with distinct, correct messages: an entry on a block that compiles, and an entry on a block never compiled.
  • describeDifference does not crash on a difference below the top level, and names index-signature, presence, optionality, readonly and any differences when they exist.
  • No any, prettier --check clean, eslint clean, and git diff --stat 7325eb89 8551304b is one file — fixtures untouched, as claimed.

I could not defeat the declaration detection, and did not try to: it was out of scope.

@borisno2

borisno2 commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

State of this PR, and a recommendation

Six rounds in. Recording where this stands so the next person does not start from scratch.

Solved, and worth keeping

Declaration detection. Three rounds tried to find a block's declared names with a regular expression, and a reviewer defeated each within minutes. Replacing it with the TypeScript compiler's own parse ended that: the next review threw 36 injected blocks at it — declaration merging, ambient declarations, triple-slash directives, satisfies, const type parameters, template-literal bodies, three-deep conditionals, alias indirection — and could not defeat it. That part is sound and should not be touched.

The infrastructure around it is also sound and repeatedly verified: the orphan arm, the staleness arm, missing-file detection, the CI wiring and its annotation.

Not converging: the comparison

The comparison has now oscillated twice between the two available relations, and each is wrong in one direction:

  • Assignability, both directions — too loose. It cannot see an optional member appear or disappear. One compared type has no required members at all, so any rearrangement of its surface passes.
  • The identity relation — too strict. TypeScript compares type flags before members, so an intersection is never identical to its flattened equivalent. The core field types are exported as intersections, so spelling any field config out flat — the ordinary way anyone documents one — fails today.

That second one is the serious finding, and it is worse than a false negative. A check that rejects correct documentation gets switched off, and a switched-off check still looks like coverage.

Neither relation is the right tool. The same mistake as the regex, one level up: reaching for a built-in relation to answer a question it does not answer. The comparison this needs is a member-by-member diff computed from the checker — names, optionality, readonly, and each member's type compared individually — not a whole-type relation in either direction.

Two smaller things are open alongside it. The resolution bail still fires on any diagnostic overlapping the declaration span rather than on whether the compared type resolved, so a ... elision inside an interface body — the commonest fragment idiom — still retires the comparison and lets a fragment entry hide a real defect. And an unused type parameter with both a constraint and a default now bails where it previously compared and failed.

Recommendation

Do not merge as it stands. The detection is genuinely good and the comparison would reject correct documentation.

Two honest options:

  1. Land the detection and infrastructure without the shadowing comparison. The compile and import checks alone catch most of what this was built for — every documentation defect found during spec 8 was a block that did not compile or an import that did not resolve, not a wrong redeclaration. Shadowing could follow separately.
  2. Rewrite the comparison as a member-by-member diff before merging anything.

The second is better if someone has the appetite; the first gets real value in front of users sooner and carries no risk of false positives.

Related: #1350, a partial build silently weakening this check rather than failing it — measured, with a verified cheap defence, deliberately not implemented here.


🤖 Generated with Claude Code

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

REQUEST CHANGES

Reviewed as a program from a fresh checkout of 8551304b (built, then probed with known-bad blocks), plus a run on a PR+prisma-8 tree rebuilt after #1370. The checker itself is sound — every known-bad probe I wrote was reported except one class, below — but the PR cannot land in its current state and one measured/unmeasured gap is undocumented.

1. The PR's green CI is stale; on current prisma-8 the check exits 1 (blocking)

The last CI run (07:07Z on 2026-09-08) predates #1370 (merged 12:54Z), which removed findMany from AccessControlledDelegate. On a merge with today's prisma-8, rebuilt:

FAIL   docs/content/how-to/rag-advanced.md:1089
         TS2339: Property 'findMany' does not exist on type 'AccessControlledDelegate'.
1 failing …  exit 1

That is the tool doing its job — the block really is wrong now — but merging as-is makes every subsequent PR into prisma-8 red. Rebase, and either fix that block (a .where()…many() rewrite, which belongs in a docs PR you stack this under, as you did with #1318) or classify it. Please re-run CI against the current base before merge.

2. context.db.<anything> type-checks — undocumented (should be fixed or stated)

scripts/doc-blocks/prelude.d.ts declares context as the un-parameterised StackContext, whose db defaults to AccessControlledDB = { [listKey: string]: AccessControlledDelegate }. So these compile clean:

const rows = await context.db.post.where({ id: documentId }).first()      // camelCase — the surface is PascalCase
const rows = await context.db.NoSuchListAnywhere.findMany()               // list nothing declares

The check therefore certifies list names, casing and row shapes it does not measure — the exact failure mode the Known limits block exists to name, and it is not named there (the prelude header's "No any" is technically true but the string index is the same hole). Minimum: add it to Known limits. Better: give the prelude a hand-written db type for the lists the RAG prose invents (Article, DocumentChunk, Post) and parameterise context with it, so casing and null-narrowing are checked against a real row. Fragments :910 and :546 already exist only because of this un-parameterised default.

3. Fixed scratch dir — concurrent runs crash each other (scripts/check-doc-typescript-blocks.mjs:123)

scratchDir is the constant packages/rag/.doc-blocks-check; the run rmSyncs it on entry. Two runs 3s apart in the same tree: the second dies with an unhandled ENOENT … block.ts Node stack trace (exit 1, no summary). CI's concurrency group hides this; a local turbo/pre-push overlap does not. mkdtempSync(path.join(repoRoot, 'packages/rag/.doc-blocks-check-')) fixes it (gitignore pattern becomes packages/rag/.doc-blocks-check*/).

4. packageEntries doubles as the module resolver — legitimate subpaths fail as "not found" (:154–165, :409)

paths is built from the nine hand-listed entries, so any real @opensaas/* export outside them fails identically to a nonexistent one:

FAIL  probe.md:12   TS2307: Cannot find module '@opensaas/stack-rag/does-not-exist'   ← correct
FAIL  probe.md:19   TS2307: Cannot find module '@opensaas/stack-auth/server'          ← real export, false failure

Harmless for the six RAG files today (none import outside the nine), but it is the first thing that blocks pointing this at docs/content (below). Resolve @opensaas/* through the packages' own exports maps instead — a scratch node_modules/@opensaas/stack-* of symlinks to packages/* under moduleResolution: bundler does it, keeping packageEntries only as the list of shadow-comparison targets.

5. Whole-tree readiness (asked for explicitly)

Pointed at all 34 docs/content/**/*.md: 698 blocks, 245s, 507 FAIL, 27 UNCHECKED (tsx), exit 1. What blocks it, in order:

  • ~100 TS2307 on unmapped @opensaas/* subpaths (stack-ui, stack-storage*, stack-tiptap, stack-auth/{server,ui,client,plugins}, stack-core/context) — item 4.
  • App-local imports (@/lib/auth ×17, @/lib/auth-client ×9, ../opensaas.config ×8, @/opensaas.config ×7) and third-party deps not resolvable from packages/rag (next/*, react, better-auth/plugins, decimal.js, @prisma/adapter-*): needs a richer declare module prelude or a scratch project that depends on them.
  • 27 tsx fences are UNCHECKED by design — that is the entire UI/auth-ui doc surface.
  • 867 TS2304 bare prose names and ~700 syntax diagnostics from object-literal fragments — hundreds of file:line fragment entries, which item 6 makes expensive.
  • Positive signal: the shadowing check flagged redeclarations of AccessContext, Session, PluginContext, Plugin, ListIndex/ListIndexFieldRef, BaseFieldConfig, FieldAccess/AccessControl across access-control.md, context-api.md, config-api.md, fields-api.md, write-a-plugin.md — worth a follow-up issue on its own.

6. Smaller

  • :251 first-wins dedup: ChunkingStrategy is exported as two different types by @opensaas/stack-rag ('none'|'recursive'|'sentence'|'sliding-window') and @opensaas/stack-rag/runtime ('recursive'|'sentence'|'sliding-window'|'token-aware'); a block redeclaring the runtime one is compared against the config one. Key the map by specifier, or compare against every candidate. (The package inconsistency itself deserves an issue.)
  • :803–806 --json exits 0 regardless of failures/stale/orphans/missing files, and the payload omits missing files and orphans.
  • :184 the fence regex requires a bare info string: a ```ts title="x" fence is silently skipped (probed: not extracted, not reported). Zero such fences exist today; add to Known limits or accept \s+.*$.
  • :694 describeDifference only runs when assignability passed; when it fails the raw TS2322/2559 text with absolute scratch paths is printed. Cosmetic.
  • Fragment keys by file:line mean an edit above a block orphans its entry and fails the block. Content-hash or first-line-text keys would survive edits; the whole-tree numbers show what line-keyed maintenance would cost.

Verified

  • Probes: (a) non-compiling block → TS2322 FAIL; (b) unexported subpath → TS2307 FAIL; (c) context.db.post / context.db.NoSuchListpasses clean (item 2); (d) await …first() then post.idTS18047 FAIL, null-checked control passes.
  • Shadowing: export type SearchResult = { totallyWrong: boolean } → FAIL both directions (the reviewer's defeat from the description, reproduced); exact ChunkingConfig + minTokens? → FAIL naming minTokens (identity axis); readonly strategy? → FAIL naming readonly; strategy?: any → FAIL naming any; non-exported type SearchResult<T = unknown> = { item: T } → FAIL; export { type Wrong as ChunkingConfig } → FAIL; nested declaration → NOT COMPARED, exit unaffected; a fragments.json entry on a shadow-failing block does not excuse it; exact import('@opensaas/stack-rag').ChunkingConfig alias → compared, passes.
  • CI: test.yml triggers on pull_request into main and prisma-8 only (pre-existing — a PR into any other base gets no run at all). The step runs on the merge commit (job log 2026-09-08T07:09: 97 blocks: 70 compile …), has no continue-on-error, so a nonzero exit is a red check; skipped on main with a ::warning annotation.
  • Hygiene: script is plain .mjs (no any/casts by construction), preludes carry none; no package.json dependency changes, typescript is already a root devDependency; eslint clean on the script; baseline on the PR head: exit 0, 41.9s locally / ~2 min in CI.

const repoRoot = path.resolve(fileURLToPath(import.meta.url), '../..')
const blocksDir = path.join(repoRoot, 'scripts', 'doc-blocks')
const scratchDir = path.join(repoRoot, 'packages', 'rag', '.doc-blocks-check')

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

A constant scratch dir plus the rmSync at entry means two overlapping runs in one tree crash each other — reproduced: second run started 3s after the first dies with an unhandled ENOENT … block.ts stack trace and no summary. mkdtempSync(path.join(repoRoot, 'packages/rag/.doc-blocks-check-')) (and packages/rag/.doc-blocks-check*/ in .gitignore) closes it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 6fe74c4. The scratch directory is now mkdtempSync(path.join(repoRoot, "packages/rag/.doc-blocks-check-")), removed in a finally. Reproduced your overlap (second run started 3s after the first): both now exit 0 and no scratch directory is left behind. The pattern packages/rag/.doc-blocks-check-*/ is in .gitignore and in eslint.config.js ignores, since ESLint 9 flat config does not read .gitignore and an interrupted run must not make pnpm lint red.

Comment thread scripts/check-doc-typescript-blocks.mjs Outdated

const fragments = JSON.parse(readFileSync(path.join(blocksDir, 'fragments.json'), 'utf8'))

const packageEntries = {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This list is also the module resolver (via paths at :409), so a real export outside these nine fails as TS2307 exactly like a nonexistent one — probed: import { createAuth } from '@opensaas/stack-auth/server' fails. Fine for the six RAG files today; first blocker for pointing the check at docs/content (~100 of the 230 TS2307s there are real @opensaas/* subpaths). Resolving through the packages' own exports — a scratch node_modules/@opensaas/* of symlinks under moduleResolution: bundler — would keep this list purely as the shadow-comparison targets.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in 6fe74c4, the way you sketched. The scratch project carries a node_modules/@opensaas/* of symlinks to packages/*, and paths/baseUrl are gone, so under moduleResolution: bundler each package's own exports map decides what a subpath means: @opensaas/stack-auth/server now resolves to its built .d.ts and @opensaas/stack-rag/does-not-exist still fails TS2307 — both are in the self-test fixture as a PASS/FAIL pair. packageEntries is no longer a hand list: it is derived from every exports[*].types of every packages/*/package.json (37 entries across the nine @opensaas/* packages), and that derived list is both the shadow-comparison target set and the build guard.

Comment thread scripts/check-doc-typescript-blocks.mjs Outdated
target.getFlags() &
(ts.SymbolFlags.Interface | ts.SymbolFlags.TypeAlias | ts.SymbolFlags.Class)
if (!isType || names.has(symbol.getName())) continue
names.set(symbol.getName(), {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

First-wins dedup hides a real collision: ChunkingStrategy is exported as two different types — @opensaas/stack-rag has 'none'|'recursive'|'sentence'|'sliding-window', @opensaas/stack-rag/runtime has 'recursive'|'sentence'|'sliding-window'|'token-aware'. A block redeclaring the runtime one is compared against the config one and fails wrongly. Key by specifier (or compare against every candidate). The package inconsistency itself deserves its own issue.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 6fe74c4. The exported-name map now holds one candidate per distinct declaration, each with every specifier that exports it, so ChunkingStrategy is two candidates. A block is compared against the candidates whose specifier it imports from (import/export declarations, import() types, import =), or against every candidate when it imports from none, and fails only when it matches none. The fixture pins four spellings: "recursive" | "bogus" with no import (FAIL against both), the config union with a runtime import (FAIL), the runtime union with a runtime import (PASS), the config union with no import (PASS). Agreed the package inconsistency deserves its own issue; not touched here.

const blocks = []
for (let i = 0; i < lines.length; i++) {
const fence = lines[i].match(/^(\s*)```(typescript|ts|tsx)\s*$/)
if (!fence) continue

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Requires a bare info string, so a ``````ts title="opensaas.config.ts"```` fence is silently skipped — probed: not extracted, not reported. None exist in the tree today, but it is not in Known limits beside the four-backtick and trailing-whitespace cases; either accept `\s+.*$` or list it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Accepted in 6fe74c4: the fence pattern is now ^(\s*)```(typescript|ts|tsx)(?:\s+\S.*)?\s*$, so ```ts title="opensaas.config.ts" is extracted like a bare fence. Whitespace is required before the attributes so tsx-foo is not read as tsx.

Comment thread scripts/check-doc-typescript-blocks.mjs Outdated

if (process.argv.includes('--json')) {
console.log(JSON.stringify(results, null, 2))
process.exit(0)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

--json exits 0 even when the run has failures, stale/orphaned entries or missing listed files, and the payload carries neither missingFiles nor the orphan list — a consumer of the JSON cannot reconstruct the verdict.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 6fe74c4. --json now goes through the same verdict as text mode and exits non-zero on any failure, stale entry, orphan or missing file. The payload is { ok, summary, results, orphans, missingFiles }, where summary carries the counts (blocks, compiling, notCompiled, classified, stale, failing, compared, notCompared, orphans, missingFiles) and every result carries its verdict (clean | excused | fail | stale | unchecked), so a consumer can reconstruct the verdict from the payload alone.

Comment thread scripts/doc-blocks/prelude.d.ts Outdated
// The request-scoped context the prose established earlier on the page. A block
// declaring its own `const context` shadows this rather than colliding, because
// the check compiles each block as a module.
declare const context: import('@opensaas/stack-core').StackContext

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Un-parameterised StackContextdb: AccessControlledDB = { [listKey: string]: AccessControlledDelegate }, so context.db.post.where(…).first() (wrong casing) and context.db.NoSuchListAnywhere.findMany() both compile clean. The check certifies list names and row shapes it does not measure, and Known limits does not say so. At minimum document it; better, declare a db type here for the lists the RAG prose invents (Article, DocumentChunk, Post) and parameterise context — fragments :910 and :546 exist only because of this default.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Took the better option in 6fe74c4. prelude.d.ts declares a db for the three lists the six files use — Article, Document, DocumentChunk (enumerated by grepping every .db.<List> in them) — with rows carrying every field a listed block reads or writes, over a query surface mirroring the generated ListOps: composable reads and terminals, select narrowing the row to the chosen columns plus the system fields, nearest accepting only the row's vector columns, and the three writes. context is StackContext<DB, Session, Record<string, unknown>, TxDB>, and the @/.opensaas/context module returns the same. Your two probes (context.db.post, context.db.NoSuchListAnywhere) are now TS2551/TS2339 FAILs and are in the self-test fixture, alongside a misspelt vector column, a misspelt row field and an unchecked null. Fragments :910 and :546 are removed (:546 is re-keyed to :544 after the findMany rewrite above it and is down to the one bare name id the prose really supplies). Every helper type sits inside declare namespace DocBlocksPrelude so no bare name a block writes resolves to the prelude by accident.

What it still cannot type precisely, stated in Known limits: where takes the package's untyped Where vocabulary rather than the list's columns (a misspelt where key is not a compile error), and include/distinct/cursor are not modelled. The generated SecuredList cannot be used because it is instantiated from the emitted Prisma family-contract, which nothing but the generator can write.

borisno2 and others added 2 commits September 9, 2026 09:38
…lude, and pin the shadowing claims with a self-test

Merges origin/prisma-8 (#1370 removed the Prisma 7 read delegates from the
generated read surface) and rewrites the two blocks that still called
`findMany` to the query-value surface.

The prelude's `context` is parameterised: a hand-written `db` for the three
lists the listed prose invents, mirroring the generated `ListOps`, so a
wrong-cased or unknown list, a misspelt vector column, a misspelt row field
and an unchecked null are compile errors. The two fragment entries that
existed only because of the un-parameterised default are gone.

`@opensaas/*` resolves through a scratch node_modules of symlinks to
packages/*, so each package's own `exports` map decides what a subpath
means. The shadow-comparison targets are derived from every
`exports[*].types` in packages/*/package.json; a name two specifiers export
as different types is held as two candidates and compared against the ones
the block imports from.

The comparison is a member-by-member diff computed from the checker, with
identity kept as a fast path, so an intersection compares equal to its
flattened spelling. A phantom type parameter is filled with `never` whatever
its constraint; a `declare module '@opensaas/…'` fails; exports are
enumerated through the checker so an aliased re-export is seen; an
unresolved member is skipped by name and its siblings still compared.

`scripts/doc-blocks/self-test/fixture.md` holds one known-bad and one
known-good block per shape; `--self-test` exits non-zero unless every bad
block is FAIL and every good block passes, and CI runs it before the real
documents.

Scratch dir is mkdtemp'd (ignored in .gitignore and eslint.config.js);
`--json` exits non-zero on failure and carries missingFiles and orphans;
fence info strings may carry attributes; file-less diagnostics are tooling
failures. The gate-flip instruction is stated once, in test.yml.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@borisno2

borisno2 commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

Response to the review of 8551304b — commit 6fe74c40

Merged with current prisma-8 (88111532), then rebuilt. Every item below was run on that tree. Baseline on the merge before any change: FAIL docs/content/how-to/rag-advanced.md:1089 — TS2339: Property 'findMany' does not exist on type 'AccessControlledDelegate', exit 1 — your item 1, reproduced.

1. Base merged; the check is green on the merge commit

rag-advanced.md:1089 now reads context.db.Article.select('id', 'title', 'contentEmbedding').all() — the query-value surface, no fragments.json entry.

Sweep (every .db.<List>.<method> in the six files, after the merge): the removed-delegate class had one more instance beyond line 1089 — packages/rag/CLAUDE.md:532, context.db.Article.findMany({ where: … }) inside the block keyed :531. It was invisible to the check because that block carries a fragment entry (query is a bare name), and an entry excuses compile errors. Rewritten to .where({ OR: […] }).all(); the entry stays, for the bare name only. Nothing else in the six files calls findMany, findUnique, findFirst, count, aggregate({…}), updateMany, deleteMany or upsert on the read surface. The two keys below that block are re-keyed (−2 lines).

Result on the merged tree: 97 blocks: 71 compile, 0 not compiled; 26 carry a fragment entry (0 stale). Redeclared exported names: 3 compared against the package, 0 not compared. exit 0.

2. The prelude's db is typed

prelude.d.ts declares db for the three lists the six files use — Article, Document, DocumentChunk, enumerated by grepping every .db.<List> — with rows carrying every field a listed block reads or writes, over a query surface mirroring the generated ListOps. context is StackContext<DB, Session, Record<string, unknown>, TxDB>. Wrong case, unknown list, misspelt vector column, misspelt row field and unchecked null are all FAILs now (all in the fixture). Fragments :910 and :546 are gone as such. All helper types sit inside a declare namespace, so no bare name in a block resolves to the prelude by accident. What it cannot type precisely — where keys, include/distinct/cursor — is in Known limits with the reason (the generated SecuredList is instantiated from the emitted Prisma contract).

3. Resolution through exports

A scratch node_modules/@opensaas/* of symlinks to packages/* under moduleResolution: bundler; paths gone. @opensaas/stack-auth/server resolves, @opensaas/stack-rag/does-not-exist fails TS2307. The shadow targets are every exports[*].types of every packages/*/package.json (37 entries, derived). The name map holds one candidate per distinct declaration with all its specifiers; ChunkingStrategy is two candidates, compared against the ones the block imports from, or all when it imports from none, failing only when it matches none.

4. Concurrency and hygiene

mkdtempSync scratch (two runs 3s apart: both exit 0, nothing left behind); pattern in .gitignore and eslint.config.js. --json exits non-zero on any failure and carries ok, summary, per-block verdict, orphans, missingFiles. Info strings with attributes are extracted. File-less diagnostics (getOptionsDiagnostics, getGlobalDiagnostics, and any semantic diagnostic without a file) are a tooling failure, exit 2.

5. The shadowing claims, made true and pinned

The comparison is now a member-by-member diff computed from the checker — presence, optionality, readonly, any, index signatures, signature arity, each member's type, recursing into object members and non-generic signatures, assignability both ways at the leaves. Identity is a fast path only. This is what your "state of this PR" comment asked for, and it makes an intersection compare equal to its flattened spelling (fixture: ChunkingOptions as an intersection of two literals passes; the same with an invented member inside one operand fails).

Each of (a)–(d), reproduced first on 8551304b as you described, then closed:

  • (a) phantom parameter with a constraint — filled with never whatever its constraint; the invented sibling is reported.
  • (b) declare module '<shipped specifier>' — any declare module '@opensaas/…' in a block is a FAIL, unexcusable.
  • (c) import type { SearchResult as X } …; export type { X as ChunkingConfig } — the block's exports are enumerated through checker.getExportsOfModule + getAliasedSymbol and merged with the parser's module-scope declarations, so it is compared and fails on five member differences.
  • (d) fragment + strategy?: NotReal — the unresolved member is skipped and reported by name (ChunkingConfig.strategy is declared in terms of NotReal); the invented minTokens? beside it is a FAIL. The whole-type bail remains only for a heritage clause / intersection operand / mapped-type body that does not resolve.

Self-testscripts/doc-blocks/self-test/fixture.md, run by --self-test, wired into CI before the real run:

self-test: 36 blocks — 18 known-bad, 18 known-good, 0 mismatch(es).

One FAIL/PASS pair per shape: non-compiling, unexported subpath, wrong-cased db key, unknown list, unchecked null, misspelt vector column, missing import, (a), (b), (c), (d), export { type Wrong as SearchResult }, unexported module-scope redeclaration, readonly member, any member, invented member behind an intersection, and the two-specifier ChunkingStrategy in four spellings.

6. Comments

Header is one sentence plus usage plus Known limits; the review-history note, the 242 / 70 / 41 / 59 / 62 census and the duplicated gate-flip instruction are gone. The flip is stated once, in test.yml beside the gate; the script and the warning annotation point there.

Checks

pnpm lint 0 errors (2 pre-existing warnings) · pnpm format clean · pnpm build green · --self-test exit 0 · pnpm check:doc-ts-blocks exit 0 on the merged tree, in text and --json modes. No changeset. PR body rewritten to describe this behaviour, with a "What this does not cover" list that is true.

🤖 Generated with Claude Code

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

REQUEST CHANGES

Re-reviewed as a program from a fresh checkout of 6fe74c40 (built; 88111532..origin/prisma-8 is empty, so this head is the merge tree CI would run). Every claim in the response comment and the six inline threads was re-run rather than read. Most hold. One does not, and it is the load-bearing one: the member-by-member diff stops at every optional member, so the class of wrong document the diff was built to catch still passes whenever the invented member sits under an optional object.

1. The diff never recurses into an optional object member (blocking) — scripts/check-doc-typescript-blocks.mjs:714

Under strict, an optional member's type is T | undefined — a union — so objectLike is false, compareMembers is skipped, and the leaf falls to assignability in both directions, which is exactly the relation the PR body says "cannot see an optional member appear or disappear". Reproduced on this head against EmbeddingField spelled flat (a 40-member copy that passes clean when exact — the intersection claim holds) and against TextChunk:

// flat EmbeddingField, exact, plus one line under the optional `db`
db?: { map?: string; isNullable?: boolean; nativeType?: string; bogus?: number }
// → clean

export interface TextChunk { …exact…; metadata?: Record<string, unknown> & { bogus?: number } }
// → clean

A changed nested type is still caught (db.map?: number → FAIL), but as a whole-db blob rather than the member path, and the same happens to ui.showVector made required. Config types are mostly optional object members (db?, ui?, chunking?: ChunkingConfig, index?: EmbeddingIndexConfig), so this is the common case, not a corner. Fix is small: when both sides are unions carrying undefined, strip it (checker.getNonNullableType) and recurse if both remainders are object-like; pin it in the fixture with an invented member under an optional object. If it is not closed, the Known limits bullet and the PR body's "recursing into object-typed members" need to say "required object-typed members".

2. The fixture's shape-(a) "known-good" twin is a wrong document — scripts/doc-blocks/self-test/fixture.md:152, check-doc-typescript-blocks.mjs:479

export type ChunkingConfig<X extends string = string> = … documents a type parameter the package does not have (ChunkingConfig<string> is an error against the package). It is PASS only because planComparison fills the phantom with never and never reports the arity difference. Filling it to see the sibling is right; not naming the phantom itself is not. Report "the block declares 1 type parameter(s), the package 0" and make the twin the exact ChunkingConfig.

3. The never fill covers only parameters past the shipped arity — :481

An unused constrained parameter within arity still retires the comparison:

export type SearchResult<T extends string = string> = { item: unknown; score: number; bogus?: number }
// → clean, NOT COMPARED: its type parameters carry constraints the probe cannot fill

That is the "no-op edit that turns a shadowing failure into an advisory note" the comment at :223 says the code prevents. Fill any unused parameter with never, whatever its position.

4. "A bail fails the self-test" is false — :1166, :1186

Appended a PASS-marked block whose comparison bails (the one in item 3) to the fixture: 37 blocks … 0 mismatch(es), exit 0. verdictOf does not look at uncompared, so the self-test cannot tell a compared PASS twin from a bailed one, and none of the 18 known-good blocks is proven to have been compared. Make uncompared.length > 0 a mismatch on a PASS block, or correct the comment.

5. SIGINT leaves the scratch directory behind — :508, :1222

finally does not run on a signal. Sent SIGINT 10 s into a run: exit 130, packages/rag/.doc-blocks-check-HZFyYZ/ left with block.ts, both preludes and node_modules/@opensaas/* (one of them stack-rag -> packages/rag, a cycle inside the package). The :100 reply's "nothing left behind" holds for the overlap case (re-run: two runs 3 s apart, both exit 0, nothing left) and not for an interrupt. process.once('SIGINT', …) that removes the dir and re-raises closes it; the gitignore comment already half-admits it.

6. Smaller

  • Type-parameter defaults are not comparedexport type SearchResult<T = string> = { item: T; score: number } passes (package default is unknown). Compare at arity 0 too when every parameter is optional on both sides, or list it.
  • readonly through a mapped type is invisible (:680) — export type TextChunk = Readonly<{…exact…}> passes; isReadonly reads declaration modifiers only, while Partial<> optionality is caught. List it or read the checker's view of the symbol.
  • ... inside a shadowed declaration under a fragment entry (:878) — export interface TextChunk { text: string; …; bogus?: number } with an entry is excused + NOT COMPARED; the invented sibling is never seen. A parse failure inside a redeclared shipped name is the one bail a fragment entry should not be able to buy.
  • A fragment entry excuses every diagnostic in the block, not the one its reason names — the author's own find (CLAUDE.md:532's findMany behind a query entry) is the demonstration, and 26 of 97 blocks are in that state. Not in Known limits.
  • prelude.d.ts:101Writes.create takes Partial<Omit<TRow, SystemFieldKey>> where the package's CreateInput requires required fields, so a documented create missing title passes; orderBy is as untyped as where. Add to the context.db bullet.
  • Unresolved-member notes repeat once per reference (EmbeddingField.access … TypeInfo ×3). Dedupe.
  • Comments: within the rule except the false :1166 and the rationale at :223, :474, :1094, :1105. The test.yml gate comment's "around 900 lines" census is the kind of number that goes stale beside the instruction it decorates; keep the instruction.

Verified on this head

  • Baseline (merge tree): --self-test exit 0 (36 blocks, 0 mismatches, 15.6 s); real run exit 0 (97 blocks: 71 compile … 3 compared, 0 not compared, 32.6 s); --json exit 0, ok: true, payload carries summary/per-block verdict/orphans/missingFiles, and exits 1 with failures.
  • Threads: mkdtemp + overlap ✓; @opensaas/stack-auth/server and @opensaas/stack-ui/primitives resolve, @opensaas/stack-core/not-a-thing is TS2307 ✓; ChunkingStrategy two-candidate spellings ✓ (fixture matches the shipped unions); ```ts title="x" extracted and checked, tsx-foo not read as tsx ✓; --json non-zero ✓; typed db: context.db.article TS2551, NoSuchList TS2339, unchecked .first() TS18047, null-checked read passes ✓. The prelude's Query matches shipped ComposedRead for select/nearest/aggregate/first/all.
  • Shadowing probes: caught — invented optional member on an exact ChunkingOptions; dropped required index; index?; declared readonly; Partial<>; widened strategy?: string; narrowed 'recursive'; invented index signature; extends shipped + member; alias re-export (c); declare module (b); phantom past arity + sibling (a); fragment + NotReal + sibling (d); dropped member in a 40-member flat copy with five unresolved siblings. Passed correctly — exact flat EmbeddingField (intersection vs flat ✓), mapped-flat, import() alias, export type { X } from. Passed wrongly — items 1, 2, 3 and the three in item 6.
  • CI: pull_request only, so base_ref is always set and the != 'main' gate is sound; no continue-on-error, so exit 1 is a red check. No packages/*/src change; only the root check:doc-ts-blocks script; no dependency added (typescript already a root devDependency).
  • Conventions: eslint 0 errors on the script; prettier clean on every PR file; no any/casts in the preludes (the fixture's any is the deliberate bad block).

🤖 Generated with Claude Code

return
}
if (visited(own, theirs)) return
if (objectLike(own) && objectLike(theirs) && depth < MAX_DEPTH) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Blocking. Under strict every optional member's type is T | undefined, a union, so objectLike is false here and the member falls through to mutual assignability — the relation the PR body says cannot see an optional member appear. Reproduced on this head: flat EmbeddingField (exact, passes) plus bogus?: number under db?clean; TextChunk with metadata?: Record<string, unknown> & { bogus?: number }clean. A changed nested type is caught but reported as the whole-db blob, not the member path. Strip undefined from both sides (checker.getNonNullableType) and recurse when both remainders are object-like; pin it in the fixture. Otherwise the Known limits bullet and the PR body must say required object-typed members.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 8858878. compare now strips undefined/null from both sides with checker.getNonNullableType, compares the nullability on its own, and recurses when both remainders are object-like. Both reproductions are pinned in the fixture as expect: fail — the flat EmbeddingField copy with bogus?: number under db?, and TextChunk with metadata?: Record<string, unknown> & { bogus?: number } — and the exact flat copy beside them is expect: pass compared. The Known limits bullet now reads "recursing into object-typed members whether required or optional".

Comment thread scripts/check-doc-typescript-blocks.mjs Outdated
// satisfies any constraint — rather than allowed to retire the comparison.
function planComparison(documented, shipped) {
const phantom =
documented.length > shipped.length && documented.slice(shipped.length).every((p) => !p.used)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Two things here. (1) The never fill applies only to parameters past the shipped arity: export type SearchResult<T extends string = string> = { item: unknown; score: number; bogus?: number }T unused, within arity — bails NOT COMPARED and the block is clean; that is the no-op edit the comment at :223 says the code prevents. Fill any unused parameter with never regardless of position. (2) A phantom parameter is itself a difference (ChunkingConfig<string> is an error against the package) and is never reported — see the fixture's shape-(a) PASS twin.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Both fixed in 8858878. (1) argumentsFor fills any unused parameter with never whatever its position, so SearchResult<T extends string = string> with an invented member is now compared and FAILs — pinned as its own fixture section. (2) A parameter the block declares past the package's arity is now reported as a difference ("the block declares 1 type parameter(s), the package 0") rather than filled silently, and the shape-(a) twin was replaced (see the reply on that thread).

```ts
import type { ChunkingStrategy } from '@opensaas/stack-rag'

export type ChunkingConfig<X extends string = string> = {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This "known-good" twin is not correct documentation: ChunkingConfig has no type parameter, so ChunkingConfig<X extends string = string> documents a generic the package does not ship. It is PASS only because planComparison fills the phantom with never and never names the arity difference. Make this twin the exact ChunkingConfig, and have the checker report "the block declares 1 type parameter(s), the package 0".

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 8858878. The shape-(a) known-good twin is now the exact ChunkingConfig with no type parameter, marked expect: pass compared, and the phantom-parameter block above it is expect: fail reporting the arity difference by name.

Comment thread scripts/check-doc-typescript-blocks.mjs Outdated
// The fixture holds one block per known-bad shape and one correct block per
// shape, each preceded by `<!-- expect: FAIL -->` or `<!-- expect: PASS -->`
// (with `fragment="…"` where the shape needs an entry). A bad block must be
// reported FAIL; a good one must be clean or excused. Anything else — a bail,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

False on this head: a bail does not fail the self-test. Appended a PASS-marked block whose comparison bails (SearchResult<T extends string = string> with an invented member) — 37 blocks … 0 mismatch(es), exit 0. verdictOf never looks at uncompared, so none of the 18 known-good blocks is proven to have been compared rather than bailed. Make uncompared.length > 0 a mismatch on a PASS block, or correct the comment.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 8858878. Fixture markers now say what they expect — fail, pass, pass compared, pass not-compared, excused — and pass compared / excused require compared.length === shadowed.length with uncompared.length === 0, so a bail on a known-good block is a mismatch. The self-test summary carries the counts: 55 blocks, 32 compared / 1 not compared, 0 mismatches. The one pass not-compared block is the deliberate used-and-constrained parameter the probe cannot fill.

Comment thread scripts/check-doc-typescript-blocks.mjs Outdated
exitCode = 2
} finally {
checking = null
rmSync(scratchDir, { recursive: true, force: true })

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

finally does not run on a signal. SIGINT 10 s into a run: exit 130 and packages/rag/.doc-blocks-check-HZFyYZ/ left behind with block.ts, both preludes and the node_modules/@opensaas/* symlinks (including stack-rag -> packages/rag, a cycle inside the package). The overlap claim on the :100 thread re-runs clean (two runs 3 s apart, both exit 0, nothing left); the interrupt case does not. A process.once('SIGINT', …) that removes the dir and re-raises closes it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 8858878. process.once handlers for SIGINT and SIGTERM remove the scratch directory and re-raise, scratch setup moved inside the try so a throw during setup is cleaned up too, and runBlocks yields to the event loop between blocks so a pending signal gets its turn. Reproduced: kill -INT 8 s into a run (scratch dir confirmed present at that moment) exits 130 and leaves nothing under packages/rag/.

Comment thread scripts/check-doc-typescript-blocks.mjs Outdated
const isOptional = (symbol) => Boolean(symbol.getFlags() & ts.SymbolFlags.Optional)
const isReadonly = (symbol) =>
(symbol.declarations ?? []).some(
(d) => ts.getCombinedModifierFlags(d) & ts.ModifierFlags.Readonly,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Declaration modifiers only, so readonly introduced through a mapped type is invisible: export type TextChunk = Readonly<{ …exact… }> passes clean on this head, while the declared-readonly fixture block fails and Partial<> optionality is caught (it lives on the symbol flags). Either read the checker's readonly view of the symbol or add it to Known limits.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Compared rather than listed, in 8858878. readonlyView reads the declaration modifiers first and falls back to the symbol's check flags, so export type TextChunk = Readonly<{ …exact… }> now FAILs and the shipped ListReduction, whose members really are readonly, still passes. A TypeScript build that exposes no check flags reports the member as unread (a PARTIAL note) rather than assuming readonly is absent — the comparison never fails a correct document on a missing internal API.

Comment thread scripts/check-doc-typescript-blocks.mjs Outdated
if (inDeclaration.length > 0) {
return {
ran: false,
reason: `the block's own declaration of ${name} does not parse (${format(inDeclaration[0])})`,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

With a fragment entry this bail is an escape: export interface TextChunk { text: string; ...; bogus?: number } plus an entry is excused + NOT COMPARED, so the invented bogus is never seen. A parse failure inside a redeclared shipped name is the one bail a fragment entry should not be able to buy — fail it under an entry, or compare the members the parser did recover. At minimum, list it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 8858878 by comparing what the parser recovered rather than by failing the block. A declaration the parser cannot fully read is diffed one way — only the members the block spells are held against the package's, never the reverse — so export interface TextChunk { text: string; ...; bogus?: number } under a whole-block entry now FAILs on bogus, while the same block with index: number in its place is excused and reported as compared. Both are pinned in the fixture.

// added.
// - Fragment entries are keyed `file:line`. The orphan check catches a key
// that has drifted off every block, but a key that drifts onto a different
// block's first line still excuses that block instead.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Two limits the probes found that are not listed: (1) a fragment entry excuses every diagnostic in the block, not the one its reason names — the CLAUDE.md:532 findMany hiding behind a query entry in your own sweep is the demonstration, and 26 of 97 blocks are in that state; (2) type-parameter defaults are not compared — export type SearchResult<T = string> = { item: T; score: number } passes against the package's T = unknown. Comparing at arity 0 when every parameter is optional on both sides would close (2).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Both closed in 8858878. (1) A fragment entry now names the diagnostics it excuses — a bare unresolved name, or a TSnnnn 'token' pair — and every other diagnostic in the block fails it; an excuse that matches nothing is stale. fragments.json was re-keyed accordingly, and the packages/rag/CLAUDE.md hybrid-search block's entry now excuses query alone, so its rewritten .where({ OR }).all() is really checked. A block that is genuinely not a statement list keeps a whole-block excuse in the distinct, greppable form { "whole": "reason" }; the summary prints that count separately (10 of 97). (2) Type-parameter defaults are compared by a second instantiation at the shared required arity, so SearchResult<T = string> against the package's T = unknown now FAILs, as does SearchResult<T> with no default at all.

}

interface Writes<TRow> {
create(args: { data: Partial<Omit<TRow, SystemFieldKey>> }): Promise<TRow | null>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Partial<Omit<TRow, SystemFieldKey>> where the package's CreateInput requires the list's required fields, so a documented create that omits title passes here and fails in a reader's project. orderBy is as untyped as where. Both belong in the context.db Known-limits bullet alongside where.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 8858878, with one correction. select was wrong: shipped ListQuery.select returns ListQuery<C, R, K, Included, F, Tx>, which both narrows the row (ComposedRowSelectedRow) and preserves Tx, so the prelude now narrows to Pick<TRow, F | SystemFieldKey> and keeps forUpdate() on the transaction-bound face — tx.db.Article.select('id').forUpdate().first() passes and reading a column it did not select fails, both pinned in the fixture.

On create: CreateInput requires a member exactly where the contract shows a non-nullable column with no default, and validation: { isRequired: true } does not make the column non-null (that needs db: { isNullable: false }, which no listed page sets). So for these three rows nothing is required and the fully partial data is faithful; what was missing was the explanation, which is now in the prelude and in the context.db Known-limits bullet alongside orderBy and where.

Members compared against packages/core/src/types/secured-list.ts and packages/core/src/secured/read.ts on this head: where, orderBy, select, limit, offset, all, first, nearest, aggregate, forUpdate, create, update, delete. Divergences kept and now all named in the bullet — where/orderBy take the untyped vocabulary rather than ListPredicate/ListSort; include, distinct, distinctOn and cursor are not modelled; select/include on a write are not modelled; update/delete take { id: string } rather than ListIdentityWhere.

Second review round on the doc-block type checker.

- The member diff now enters an optional object member: `getNonNullableType`
  strips `undefined`/`null` from both sides, the nullability is compared on
  its own, and the remainders recurse. An invented member under an optional
  `db?` or inside an intersection behind `metadata?` now fails.
- Type parameters are compared rather than worked around. A parameter the
  block declares past the package's arity is a difference; so is a default on
  one side and none on the other, checked by a second instantiation at the
  shared required arity. An unused parameter is filled with `never` whatever
  its position, so an unused constrained one no longer retires the comparison.
- `readonly` introduced by a mapped type is read off the symbol's check flags,
  so `Readonly<{…}>` is compared rather than passing clean; a build that
  exposes no check flags reports the member as unread instead of assuming.
- A `...` elision inside a redeclared shipped name is compared one way over
  the members the parser recovered, so a whole-block entry can no longer buy
  the comparison an invented sibling would fail.
- A fragment entry names the diagnostics it excuses — a bare name or a
  `TSnnnn 'token'` pair — and every other diagnostic in the block fails it. A
  block that is genuinely not a statement list carries `{ "whole": … }`, and
  the summary counts those separately (10 of 97). An excuse that matches
  nothing is stale.
- Fixture markers say what they expect (`fail`, `pass`, `pass compared`,
  `pass not-compared`, `excused`), so a known-good block that bails is a
  self-test mismatch rather than a silent pass.
- SIGINT/SIGTERM remove the scratch directory and re-raise; setup moved inside
  the try, and a per-block yield gives the handler its turn.
- Prelude: `select()` narrows the row and keeps the transaction's
  `forUpdate()`, matching `ListQuery`. `create`'s fully partial `data` is now
  explained rather than assumed — `CreateInput` requires a member only where
  the contract shows a non-nullable column with no default.
- `typeRoots` set explicitly so the script runs from any cwd; unresolved-member
  notes deduped; the `test.yml` gate comment keeps the instruction and drops
  the line census.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@borisno2

borisno2 commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

Second review round — all ten items addressed in 8858878

Re-verified on the merged tree (origin/prisma-8 is already an ancestor of the PR head, so this head is the merge tree).

The rule this round was held to: the shadowing comparison must never FAIL a correct document. Where a shape cannot be compared soundly it reports NOT COMPARED or PARTIAL with a reason and is counted, never failed.

Blocking — optional object members

compare strips undefined/null from both sides with checker.getNonNullableType, compares the nullability on its own, then recurses when both remainders are object-like. Both of your reproductions are pinned in the fixture as expect: fail — the flat EmbeddingField copy with bogus?: number under db?, and TextChunk with metadata?: Record<string, unknown> & { bogus?: number } — with the exact flat copy beside them as expect: pass compared.

The six "passed wrongly" probes

Probe Was Now
Invented member under an optional db? (flat EmbeddingField) clean FAIL
TextChunk.metadata?: … & { bogus?: number } clean FAIL
Within-arity constrained phantom SearchResult<T extends string = string> + sibling clean, NOT COMPARED FAIL (compared)
SearchResult<T = string> against the package's T = unknown clean FAIL
export type TextChunk = Readonly<{ …exact… }> clean FAIL
Fixture shape-(a) known-good twin a wrong document that passed replaced with the exact ChunkingConfig, pass compared

The rest

  • Self-test proves comparison. Markers now say what they expect — fail, pass, pass compared, pass not-compared, excused — and pass compared/excused require every redeclared shipped name to have been compared. A bail on a known-good block is a mismatch.
  • Fragment entries name what they excuse. fragments.json values are { "excuses": [...], "reason": … } (a bare unresolved name, or a TSnnnn 'token' pair) or, for a block that is genuinely not a statement list, { "whole": "reason" }. Every unnamed diagnostic fails the block; an excuse matching nothing is stale. Re-keyed; 10 of 97 blocks carry a whole-block excuse, printed in the summary. packages/rag/CLAUDE.md's hybrid-search block now excuses query alone, so its rewritten .where({ OR }).all() is really checked.
  • ... inside a redeclared shipped name is compared one way over the members the parser recovered, so a whole-block entry can no longer buy an invented sibling a free pass.
  • SIGINT/SIGTERM handlers remove the scratch dir and re-raise; setup moved inside the try; runBlocks yields between blocks so the handler gets its turn. Reproduced: kill -INT mid-run → exit 130, nothing left under packages/rag/.
  • Prelude. select narrows the row and keeps forUpdate(), matching ListQuery on this head. create's fully partial data is now explained rather than assumed: CreateInput requires a member only where the contract shows a non-nullable column with no default, and validation: { isRequired: true } does not make the column non-null. Compared against secured-list.ts/read.ts: where, orderBy, select, limit, offset, all, first, nearest, aggregate, forUpdate, create, update, delete — every kept divergence is now named in the context.db Known-limits bullet.
  • Changeset added (minor, @opensaas/stack-rag) for the packages/rag/CLAUDE.md edit.
  • test.yml gate comment keeps the instruction and the Docs samples import from subpaths the packages do not export, and nothing checks them #1301 pointer; the line census and the if:-count are gone.
  • Dedupe on unresolved-member notes; typeRoots set explicitly so the script runs from any cwd (verified from /tmp).

Verification

self-test: 55 blocks — expected 29 fail, 9 pass, 12 pass compared,
1 pass not-compared, 4 excused; Redeclared exported names: 32 compared
against the package, 1 not compared. 0 mismatch(es).            exit 0

97 blocks: 71 compile, 0 not compiled; 26 carry a fragment entry
(10 whole-block, 0 stale). Redeclared exported names: 3 compared
against the package, 0 not compared.                            exit 0

pnpm build ✓ · pnpm lint 0 errors (2 pre-existing warnings elsewhere in the tree) ✓ · pnpm format clean ✓ · SIGINT cleanup ✓ · run from a foreign cwd ✓.

Follow-ups that are real but out of scope for this PR are now listed in the description.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant