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:
constrx=/(^|[^\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 AnalyticsServicePlugin for the fabricated ServiceAnalytics turns the import finding into an AnalyticsServicePlugin.configure call-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:
constrx=/(?<[\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.
⚠️Add the nested case to --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
extractMemberCalls reports CacheServicePlugin.configure in await kernel.use(CacheServicePlugin.configure({...}));- the existing false-positive corpus stays silent (the measured prose/bash/diff cases in the script header)
--self-test pins the nested-receiver case, in both directions
Refs: #9532 (the card) · #9546 (the gate) · PR #9602 (where the ablation ran)
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) findsName.member(call sites with:The leading alternation exists to stop
a.b.c(and'str'.trim(from matching. But the branch that is not^consumes one character, andrxis 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}: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 genuineCacheServicePluginbut callkernel.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: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 exactlykernel.use(SomePlugin.configure({...})). #9546's own dev report names that substitution as the thing the call-site half exists to catch: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:
(Node 22 supports lookbehind; the
^alternative folds in, since a negative lookbehind is satisfied at position 0.) Anexec-and-rewind loop that resetslastIndexto the start of the receiver on a discarded match would also work, but the lookbehind is the smaller change.--self-testin 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 forkernel.use(X.y(is the next silent regression.Acceptance
extractMemberCallsreportsCacheServicePlugin.configureinawait kernel.use(CacheServicePlugin.configure({...}));--self-testpins the nested-receiver case, in both directionsRefs: #9532 (the card) · #9546 (the gate) · PR #9602 (where the ablation ran)