feat(lint): check:doc-formula-expressions reads spec TSDoc @example strings (#6763) - #7180
Merged
Merged
Conversation
… strings (#6763) A TSDoc `@example` is not reachable from any import, so no ordinary test can go red on it and — until now — no gate read one. #6641 was the proof: the RLS `check` slot documented its enumerated-values idiom as `"status IN ('draft', 'pending')"`, which does not compile, so an author copying the schema's own example got a policy that denies every row. It was found by hand, months late. Landed as the ruled scan-surface extension: the judgment stays imported from `@objectstack/formula`, only the surface is new. Three measurements shaped it, two of them against the obvious design: - the extraction half is genuinely new — #6641's defect was an INLINE `@example "<expr>" - caption` on a property, not a fenced block, so pointing ROOTS at packages/spec would have read nothing; - the existing admission rules admit ZERO across all 424 spec `@example` tags, so a gate carrying only them would arrive green and meaningless; - scope is not inferable from the slot's schema type — 25 slots are typed ExpressionInputSchema and they do not share a scope, so judging them all as record-scoped would false-red a correct page predicate. So admission is a declared slot registry (2 entries, both measured), which cannot rot quietly: an entry matching no site is an error, and an expression-typed slot carrying an @example that no entry claims is an error naming the slot rather than a guess at its scope. Exemptions are named, reasoned and self-invalidating in both directions — stale (matches no site) and unnecessary (the site now judges clean) are each an error. Stock pass, per the ruling's condition 2: 9 sites admitted — rls-predicate=8 (all clean), hook-record-condition=1 (RED, exempted and filed as #7175 because packages/spec/src is another seat's territory), record-formula=0. The zero is proven honest by a positive control that re-injects #6641's string into an in-memory copy of the real rls.zod.ts and watches the extractor catch it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJATVrh6V2ysutYUJigh3B
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckNo hand-written docs reference the 0 changed package(s). ✅ |
This was referenced Aug 10, 2026
os-help
marked this pull request as ready for review
August 10, 2026 02:23
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6763
Landed under the maintainer ruling of 2026-08-09 on that thread — approved as a scan-surface extension, not new machinery, with two conditions ruled in. Maintainer directive verbatim: 「同意你的建议」.
Premise re-verified on
origin/main@5087ac6Holds in full. The baseline gate is green at
22 record-scoped formula example(s) across 387 files / 1397 TS blocks, and it never opens apackages/spec/srcfile. 424 TSDoc@exampletags live underpackages/spec/src/**and nothing reads any of them.Three measurements, two of which contradict the obvious design
The PM's dispatch flagged one hypothesis as most worth falsifying — that the judging half is reusable but the extraction half is not. Confirmed, and then some.
1. Extraction is genuinely new. #6641's defect was not in a fenced block at all. It was an inline
@example "EXPR" - captionstring hanging off a property:Adding
packages/spectoROOTSwould have read exactly nothing: the walker collects.md/.mdxonly, and the extractor reads markdown fences.2. The existing admission rules admit ZERO on this surface. All 424
@exampletags were run through the currentextractFormulaExpressions, both fenced-only and whole-body: 0 admitted, 0 tripwires. There is noField.formula(…)ortype: 'formula'example in the spec sources today.This is the load-bearing measurement. The ruling's literal instruction — reuse the existing admission logic — produces a gate that admits nothing, i.e. a no-op that arrives green and meaningless, which is the one thing condition 2 forbids ("so the gate arrives green and meaningful"). Pass A below still applies that rule to the new surface, because it is the ruling's letter and it costs nothing; pass B is what makes the gate mean something.
3. Scope cannot be inferred from the slot's schema type. 25 slots are typed
ExpressionInputSchema, and they do not share a scope:hook.conditionbinds the record,page.visibleWhenalso binds page variables,flow.conditionis flow-scoped. Judging them all as record-scoped reports this measured verdict on a correct page predicate:validateExpression(…, { scope: 'record' })page.selectedProjectId != ''bare reference `page` … Write `record.page`.That is precisely the false-red the script's existing discriminator note exists to prevent, one surface over.
What changed — one file,
packages/lint/scripts/check-doc-formula-expressions.mjsPass A — the ruling's letter. The existing record-scoped formula discriminator, pointed at fenced TS blocks inside spec TSDoc
@examplebodies. Same admission, same verdict, new surface. Admits 0 today; kept because the rule is live on surface 1 and the day a spec docblock grows aField.formula({ expression: 'qty * price' })example it is judged rather than shipped.Pass B — a declared slot registry,
SPEC_EXAMPLE_SLOTS: a(declaration, property)pair, its dialect, and the imported verdict for that dialect.@objectstack/formularls-predicateRowLevelSecurityPolicySchema.using/.checkisSupportedRlsExpressionhook-record-conditionHookSchema.conditionvalidateExpression(…, { scope: 'record' })No verdict is reimplemented — the RLS red message computes its reason via
isPushdownableCel(sqlPredicateToCel(…))only afterisSupportedRlsExpressionhas already said no, so it is decoration on a decided verdict rather than a second opinion (Prime Directive #12).The registry cannot rot quietly in either direction:
assertRootsResolvable;@examplebut is not registered is an error naming the slot. That is the tripwire for the next expression slot someone documents: its scope is not knowable from its type, so the gate refuses to guess and asks for a registry entry instead.Pass A is a text walk and pass B is an AST walk, and they disagree on purpose:
ts.getJSDocTagsonly reaches comments the parser attached to a node, which is 110 of the 424 tags — the fencedtypescriptexamples live on module docblocks and on the schema declaration itself, which pass A must see. Pass B needs the node to know the slot, so it is AST-bound by nature.Condition 1 — the day-one exemption idiom
EXEMPT_EXAMPLES, same discipline as the parity-gate lists andcheck-error-code-casing.mjs'sEXEMPT_FILES: an entry carries the site and its reason, never a blanket path ignore. Keyed on the example's exact text rather than a line number, so it survives reflow but dies with the example it excuses.Self-invalidating in both directions, which is what separates "we thought about this one" from a hole:
All three directions plus an exact-match near-miss are executed in the self-test, not described.
Condition 2 — the stock pass, and its count
9 sites admitted on the new surface, of which 1 is red:
rls-predicateusing, 3check— post-#6729)hook-record-conditionrecord-formula(pass A)The gate arrives green.
The red one: a second live instance of the class
packages/spec/src/data/hook.zod.ts:236documents a record-scoped CEL slot with a SQL predicate:The
@exampleand the.describe()are one line apart and disagree about the dialect. Measured:invalid CEL value: Unexpected character: =. It is wrong twice over — even after the operators are fixed, barestatus/amountwould still resolve to nothing and evaluate silently to null (the #5116 shape).This is a second live instance of the class, found by measurement rather than by hand, which is what the findings-round grading on #6763 named as this class's promotion condition.
Not fixed here, deliberately.
packages/spec/src/**belongs to thedomain:spec/domain:spec-surfaceseats and this card isdomain:spec-tooling, so per the dispatching seat's boundary constraint it is exempted with a reason naming it as a real defect pending cross-seat transfer, and filed as #7175. The ruling's "arrives green" condition holds; the difference is that a real defect leaves a labelled trail instead of being silently repaired by the wrong seat. Deleting that exemption is part of #7175's fix, and the "unnecessary" direction above means it cannot be forgotten silently.The positive control — why
record-formula=0is not a blind scannerA scanner that silently sees nothing reads identically to a clean corpus, so the zero is proven rather than asserted. #6641's string was re-injected into an in-memory copy of the real
rls.zod.ts(the file on disk is never touched) and the production extractor re-run over it:The extractor reaches the real file, reads all 8 examples out of it, and catches the historical defect. The zero on
record-formulais therefore a fact about the corpus, not about the scanner. The substitution itself carries an anti-vacuity throw: if the #6729 fix line is not found verbatim in the real source, the control fails rather than reporting a happy zero.Reverse verification — direction predicted before running, both RED
1. Delete the exemption — the hook defect surfaces, proving the exemption is load-bearing and the red is real:
2. Rename a registry entry's declaration — the anti-vacuity guard fires instead of the scan silently shrinking:
Both were taken out with a file copy, never
git stash.Self-test — 11 cases to 24
The 11 existing cases are untouched and still pass. 13 added, the RED ones verbatim from the real defects so this fails if the gate ever stops catching them:
CI cost
Measured on the same container, same corpus: 1.23s before, 2.71s after — about +1.5s for 698 additional files parsed. Reported rather than worked around; the scan root was not narrowed to make it look cheaper.
Verification
pnpm --filter @objectstack/lint run check:doc-formula-expressionspnpm --filter @objectstack/lint testTest Files 68 passed (68) / Tests 1771 passed | 4 skipped (1775)pnpm --filter @objectstack/lint typechecknpx eslint packages/lint/scripts/check-doc-formula-expressions.mjsnode scripts/check-nul-bytes.mjsOK (scanned 6593 text file(s) … no raw ASCII control bytes)grep -naPover the control-byte class on the changed filepnpm --filter @objectstack/spec check:generated --reconcile-only20 check: + 14 gen: scripts, all classified(no script entry added or renamed)Scope notes
packages/spec/src/**is byte-identical — confirmed bygit status. No.zod.tsexample was edited in this PR.skip-changeset:packages/lint's publishedfilesare["dist", "README.md", "CHANGELOG.md"], soscripts/ships in no package. This is a CI gate only and releases nothing.doc-formula-expressionsa little narrow, but renaming the entry would touchlint.ymland thecheck:generatedledger — wider than "a scan-surface extension" and a separate call. The header says so plainly rather than leaving the name to mislead.content/docs/releases/untouched.Generated by Claude Code