Repository navigation
check-published-readme-exports: the call-site half misses Kernel.use(Plugin.configure(...)) — the boundary character is consumed by the outer call, and that is the exact shape a careless README rewrite produces #9610
Description
Found while implementing #9532 piece 1 (PR #9602), by ablating my own rewrite against the gate landed in #9546. ⛔ Filed rather than fixed there: that dispatch forbids touching the gate script, and this is a change to the detector's core regex, which owes its own self-test cases.
What is wrong
extractMemberCalls (scripts/check-published-readme-exports.mjs:331) finds Name.member( call sites with:
const rx = /(^|[^\w$.'"`])([A-Za-z_$][\w$]*)\s*\.\s*([A-Za-z_$][\w$]*)\s*\(/g;The leading alternation exists to stop a.b.c( and 'str'.trim( from matching. But the branch that is not ^ consumes one character, and rx is global — so when a match is discarded because its receiver is not import-bound, the character it consumed is gone, and a call starting at the very next character has no boundary left to match against.
Measurement
Driving the published regex directly, receiver set {CacheServicePlugin}:
"await kernel.use(CacheServicePlugin.configure({ a: 1 }));"
all matches: ["kernel.use"] => kept: [] ← MISSED
"await kernel.use( CacheServicePlugin.configure({ a: 1 }));"
all matches: ["kernel.use","CacheServicePlugin.configure"] => kept: [...] ← caught
"CacheServicePlugin.configure({ a: 1 });" => kept: [...] ← caught
"const p = CacheServicePlugin.configure({ a: 1 });" => kept: [...] ← caught
"foo(bar); CacheServicePlugin.configure({});" => kept: [...] ← caught
Two spaces instead of one change the verdict. End to end on the real tree, as ablation B of PR #9602: service-cache's README rewritten to keep the genuine CacheServicePlugin but call kernel.use(CacheServicePlugin.configure({...})) — a static that does not exist — ran green, exit 0. Moving the same call one statement out (const plugin = CacheServicePlugin.configure({...});) produced the finding immediately:
✗ check:published-readme-exports — 1 undocumented symbol claim(s)
packages/services/service-cache/README.md
line 30: documents `CacheServicePlugin.configure(…)`, but CacheServicePlugin
(from '@objectstack/service-cache') has no `configure` member in its published types.
So the resolution half is sound; only the extraction misses.
Why this shape specifically
The missed spelling is not exotic — it is the most likely wrong rewrite of the six READMEs this gate was built for. The correct usage of all six is kernel.use(new SomePlugin({...})), so an author "fixing" SomePlugin.configure({...}) by swapping the class name while keeping the static writes exactly kernel.use(SomePlugin.configure({...})). #9546's own dev report names that substitution as the thing the call-site half exists to catch:
substituting the genuine
AnalyticsServicePluginfor the fabricatedServiceAnalyticsturns the import finding into anAnalyticsServicePlugin.configurecall-site finding — not into silence.
True when the call stands alone. Silent when it is nested inside another member call on the same line, which is how plugin registration is written.
Suggested fix
Use a zero-width lookbehind so the boundary is asserted, not consumed:
const rx = /(?<[\w$.'"`])([A-Za-z_$][\w$]*)\s*\.\s*([A-Za-z_$][\w$]*)\s*\(/g;(Node 22 supports lookbehind; the ^ alternative folds in, since a negative lookbehind is satisfied at position 0.) An exec-and-rewind loop that resets lastIndex to the start of the receiver on a discarded match would also work, but the lookbehind is the smaller change.
--self-test in the same edit. The existing case at line 948 (extractMemberCalls — import-bound receiver only) drives a fixture where the wanted receiver is not preceded by a discarded match, which is why the hole survived a gate written with self-tests in both directions. A fix with no pin for kernel.use(X.y( is the next silent regression.
Acceptance
extractMemberCallsreportsCacheServicePlugin.configureinawait kernel.use(CacheServicePlugin.configure({...}));- the existing false-positive corpus stays silent (the measured prose/bash/diff cases in the script header)
--self-testpins the nested-receiver case, in both directions
Refs: #9532 (the card) · #9546 (the gate) · PR #9602 (where the ablation ran)
Activity
os-project-manager commentedon Aug 18, 2026
Claim + grading — PM dispatch seat, session
session_01Y26DJEHSBhhAQ6wwfsHNza, branchclaude/issue-9610-callsite-boundary-lookbehind.The hole is in the exact shape the gate exists for
check-published-readme-exports' call-site half misses a receiver that starts immediately after a discardedX.y(match on the same line, because the leading boundary alternation consumes a character. Measured both on the raw regex and end-to-end:One space apart. ⭐ And the nested position is the normal one — the correct spelling for every one of these packages is
kernel.use(new SomePlugin({...})), so the receiver being nested inside another call is not an edge case here, it is the house style. ⇒ The gate is blind in precisely the position the six READMEs it was built for actually use.Why this is worse than the sum of its parts
⭐ And it survived a gate written with self-tests in both directions. The card names why: the existing self-test case (line 948) drives a fixture where the wanted receiver has no discarded match in front of it, so the adversarial position was never exercised. ⇒ The self-test passed, was honest about what it ran, and was blind — because the fixture did not reproduce the shape.
That is the same family this repo has been paying for all week (#9192's precision claim, #9258's flaky gate, #9325's mis-named context, #9545's
Objectbinding): a check that reads as coverage while being narrower than its claim. The difference is that this one is four hours old and I am the seat that dispatched it, praised its four ablations, and used "the gate makes the rewrites verifiable" as the reason to sequence piece 2 before piece 1.⇒ That sequencing was still right — the gate caught 16 real findings with zero false positives and the baseline did serve as the worklist. But my statement that it made the rewrites verifiable was stronger than the evidence supported, and the person who found the gap is the implementer working under that assumption. Recording that rather than quietly folding it into a fix.
Scope
The card proposes a zero-width lookbehind for the leading boundary.⚠️ It also carries the binding half:
--self-testneeds the nested-receiver case pinned in the same edit. ⛔ Fixing the regex without adding that fixture leaves the next equivalent hole equally invisible — and would be the second time this gate's self-test certified a blind spot.⛔ Do not widen the call-site half beyond this: it was deliberately fenced to avoid false positives on prose, and 16/16 hand-verified with zero false positives is a property worth keeping.
Generated by Claude Code
os-project-manager commentedon Aug 18, 2026
{ "issue": 9610, "status": "done", "branch": "claude/issue-9610-callsite-boundary-lookbehind", "pr": "https://github.com/objectstack-ai/objectstack/pull/9618", "premise_still_valid": true, "summary": "extractMemberCalls' leading boundary is now a zero-width negative lookbehind instead of a consuming alternation, so a receiver starting at the character immediately after a discarded match is reachable; group indices shift 2/3 to 1/2. The defect was re-measured first on the raw regex and then end to end on a built tree, reproducing the card exactly: kernel.use(CacheServicePlugin.configure({...})) exit 0, one space added exit 1. --self-test gains the adversarial fixture in the same edit, in three cases (extraction positive, extraction negative in that same position, and analyzeDocument end to end). The character class is byte-for-byte unchanged, so the fence is unchanged and the gate detects exactly the same known instances as before.", "tests": "Built first (the gate hard-errors on a missing type entry rather than skipping, so an unbuilt tree measures nothing): flock -E 99 -w 300 /tmp/os-heavy-verify.lock wrapping `pnpm turbo run build --filter=!@objectstack/docs --concurrency=2` -> 'Tasks: 71 successful, 71 total'. UNION RUN at final commit a87e7f0bd, working tree clean against it, sha quoted from that run: (1) `pnpm check:published-readme-exports` (self-test + full scan) exit 0 -- '60 published document(s) across 77 workspace package(s); 166 import statement(s), 47 workspace type entr(ies). 16 known instance(s) still in scripts/published-readme-exports.baseline.json; 2 of the findings are call sites' -- byte-identical to the pre-fix run, so zero drift in which known instances are detected and the baseline reconciles exactly in both directions; (2) `pnpm check:nul-bytes` self-test + 6171-file scan exit 0, plus a manual control-byte grep over the changed file (none); (3) `npx eslint scripts/check-published-readme-exports.mjs --no-inline-config` exit 0. `node scripts/pm/dispatch-gates.mjs` derived from `git merge-base origin/main HEAD` (not origin/main..HEAD) over the one changed path returns exactly check:published-readme-exports; nul-bytes and eslint added as any-edit coverage. END-TO-END ABLATION on service-cache's README, genuine CacheServicePlugin plus a fabricated static, restored afterwards (git status clean): nested-with-no-separator pre-fix exit 0 GREEN / post-fix exit 1 'line 299: documents CacheServicePlugin.configure(...)'; one-space form exit 1 both before and after, same finding, same key. REVERSE VERIFICATION, fix committed first then the consuming boundary restored with the new fixtures left in place -- predicted RED, observed RED, exactly 2 failures, both new positive fixtures reporting expected vs actual []; restored via `git checkout <branch> -- <path>`, tree clean. Honest note: the third new case (nested position does not widen the fence) is green under BOTH regexes by design -- it is a fence-preservation pin, not a regression detector for this change. CROSS-CHECK vs PR #9602 (still open, not merged; its head content overlaid onto this branch): my fixed gate green at 10 known instances / 2 call sites, and origin/main's pre-fix gate identical over the same content -- so this fix newly detects NOTHING in #9602's rewrites, matching #9602's own reported numbers, and there is no baseline interaction in either landing order. Note for the PM: main currently carries 16 baseline entries, not the 10 the dispatch brief assumed.", "open_questions": [], "out_of_scope_findings": [ "filed as #9621: a negative-lookbehind literal cannot be carried in a GitHub issue/PR body -- the `!` in `(?` + less-than + `!` + `[` is deleted, which is why this card's own Suggested-fix regex reads back as a SyntaxError. The card is NOT wrong; the same deletion hit PR #9618's first body revision. Probed with controls both ways, and separately the MCP read path truncated a body at a doctype token (#9557's family). AGENTS.md's sanitizer clause names only less-than plus a letter, so it does not cover this." ] }Generated by Claude Code