Skip to content

Commit 40162f1

Browse files
fix(ci): published-README call sites assert the leading boundary instead of consuming it (#9618)
`extractMemberCalls` found `Name.member(` with a leading alternation `(^|[^\w$.'"`])`. The non-`^` arm CONSUMES a character, and the regex is global, so a receiver starting at the very next character after a previous match had no boundary left to match against. A match always ends at its own `(`, which makes the swallowed position exactly `outer(Inner.m(` — and `kernel.use(SomePlugin.configure({...}))` is the house spelling of every README this gate was built for. The gate was blind in its most important position; two spellings one space apart disagreed: kernel.use(CacheServicePlugin.configure({...})) green, exit 0 kernel.use( CacheServicePlugin.configure({...})) red, exit 1 A negative lookbehind asserts the boundary zero-width, so nothing is consumed and the `^` arm folds in. The character class is unchanged, so the fence is unchanged: `a.b.c(` and `'str'.trim(` stay out, now in the nested position too. `--self-test` gains the adversarial fixture in the same edit, in both directions plus end to end. The hole survived a gate written with self-tests both ways because every existing fixture placed the wanted receiver where no discarded match had consumed anything in front of it. Baseline reconciliation is unchanged: 16 known instances, 2 of them call sites, before and after. Co-authored-by: Claude <noreply@anthropic.com>
1 parent f01c0ee commit 40162f1

1 file changed

Lines changed: 83 additions & 4 deletions

File tree

scripts/check-published-readme-exports.mjs

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,11 @@
117117
// count(...)` on a locally-bound variable is pseudo-code and is never read.
118118
// Anything whose type is `any`, or which carries an index signature, is not
119119
// reported -- absence of a property there is not evidence.
120+
//
121+
// That fence is a CHARACTER CLASS, and #9610 measured what happens when it is
122+
// spelled as a consuming alternation instead of a zero-width assertion: the
123+
// receiver in `kernel.use(SomePlugin.configure(…))` became unreachable, because
124+
// the outer call had already eaten the `(` in front of it. See extractMemberCalls.
120125

121126
import { existsSync, readFileSync, readdirSync } from 'node:fs';
122127
import { join, posix, resolve } from 'node:path';
@@ -337,14 +342,31 @@ export function extractMemberCalls(markdown, localNames) {
337342
for (const { n, text } of fence.lines) {
338343
// Skip the import statements themselves and single-line comments.
339344
if (/^\s*(import\b|\/\/|\*|\/\*)/.test(text)) continue;
340-
const rx = /(^|[^\w$.'"`])([A-Za-z_$][\w$]*)\s*\.\s*([A-Za-z_$][\w$]*)\s*\(/g;
345+
// ⛔ The leading boundary is ASSERTED, never consumed (#9610). The obvious
346+
// spelling -- `(^|[^\w$.'"`])` -- eats the character in front of the receiver,
347+
// and `rx` is global, so a receiver beginning at the very next character after
348+
// a previous match has no boundary left to match against. A match always ends
349+
// at its own `(`, which makes the swallowed position exactly `outer(Inner.m(`
350+
// -- and `kernel.use(SomePlugin.configure({…}))` is the house spelling of every
351+
// README this gate was built for, so the blind spot was the NORMAL position,
352+
// not a corner. Measured on the published regex, one space apart:
353+
//
354+
// kernel.use(CacheServicePlugin.configure({…})) -> extracted: kernel.use
355+
// kernel.use( CacheServicePlugin.configure({…})) -> extracted: both
356+
//
357+
// A negative lookbehind is zero-width, so nothing is consumed and the `^` arm
358+
// folds in (a negative lookbehind is satisfied at position 0). The character
359+
// class is byte-for-byte the old one, so the fence is unchanged: `a.b.c(` and
360+
// `'str'.trim(` stay out -- now in the nested position too, which is the only
361+
// position this change newly reaches.
362+
const rx = /(?<![\w$.'"`])([A-Za-z_$][\w$]*)\s*\.\s*([A-Za-z_$][\w$]*)\s*\(/g;
341363
let m;
342364
while ((m = rx.exec(text)) !== null) {
343-
if (!wanted.has(m[2])) continue;
344-
const key = `${m[2]}.${m[3]}`;
365+
if (!wanted.has(m[1])) continue;
366+
const key = `${m[1]}.${m[2]}`;
345367
if (seen.has(key)) continue;
346368
seen.add(key);
347-
out.push({ line: n, object: m[2], member: m[3] });
369+
out.push({ line: n, object: m[1], member: m[2] });
348370
}
349371
}
350372
}
@@ -954,6 +976,44 @@ function selfTest() {
954976
[],
955977
);
956978

979+
// -- the adversarial position (#9610), which the fixture above cannot reach ----
980+
// Every case above puts the wanted receiver where no earlier match on the line
981+
// has consumed anything in front of it. That is why a gate written with
982+
// self-tests in BOTH directions still shipped blind to the shape below: the
983+
// receiver starts at the character immediately after a DISCARDED `X.y(` match,
984+
// with nothing separating them. It is also the house spelling of the six READMEs
985+
// this gate exists for, so it is the likeliest wrong rewrite of any of them.
986+
const nestedReceiver = [
987+
'```typescript',
988+
"import { CacheServicePlugin } from '@objectstack/service-cache';",
989+
'await kernel.use(CacheServicePlugin.configure({ adapter: "memory" }));',
990+
'```',
991+
].join('\n');
992+
eq(
993+
'extractMemberCalls — a receiver directly inside a discarded call is still read',
994+
extractMemberCalls(nestedReceiver, ['CacheServicePlugin']).map((c) => `${c.object}.${c.member}`),
995+
['CacheServicePlugin.configure'],
996+
);
997+
998+
// The other direction, in that SAME position: reaching it must not widen the
999+
// fence. Property access, all three quote styles, and the CORRECT `new X(`
1000+
// spelling stay silent when nested exactly as above.
1001+
const nestedRejected = [
1002+
'```typescript',
1003+
"import { CacheServicePlugin } from '@objectstack/service-cache';",
1004+
'await kernel.use(wrapper.CacheServicePlugin.configure({}));',
1005+
"console.log('CacheServicePlugin.configure(');",
1006+
'console.log("CacheServicePlugin.configure(");',
1007+
'console.log(`CacheServicePlugin.configure(`);',
1008+
'await kernel.use(new CacheServicePlugin({ adapter: "memory" }));',
1009+
'```',
1010+
].join('\n');
1011+
eq(
1012+
'extractMemberCalls — the nested position does not widen the fence',
1013+
extractMemberCalls(nestedRejected, ['CacheServicePlugin']),
1014+
[],
1015+
);
1016+
9571017
// -- specifier splitting ------------------------------------------------------
9581018
eq('splitSpecifier — scoped root', splitSpecifier('@objectstack/spec'), {
9591019
name: '@objectstack/spec',
@@ -1065,6 +1125,25 @@ function selfTest() {
10651125
['@objectstack/kernel|packages/kernel/README.md|member|@objectstack/kernel|Kernel.configure'],
10661126
);
10671127

1128+
// ...and the same fabricated static written the way plugin registration is
1129+
// actually written: nested inside another call, no separator (#9610). This ran
1130+
// GREEN end to end on the real tree before the boundary became zero-width.
1131+
const fabricatedStaticNested = {
1132+
pkg: '@objectstack/kernel',
1133+
file: 'packages/kernel/README.md',
1134+
text: [
1135+
'```typescript',
1136+
"import { Kernel } from '@objectstack/kernel';",
1137+
'await app.use(Kernel.configure({}));',
1138+
'```',
1139+
].join('\n'),
1140+
};
1141+
eq(
1142+
'analyzeDocument — a fabricated static nested inside another call is reported',
1143+
analyzeDocument(fabricatedStaticNested, resolveFake).map((f) => f.id),
1144+
['@objectstack/kernel|packages/kernel/README.md|member|@objectstack/kernel|Kernel.configure'],
1145+
);
1146+
10681147
// Undeclared subpath: the packaged surface says something the source does not.
10691148
const badSubpath = {
10701149
pkg: '@objectstack/service-analytics',

0 commit comments

Comments
 (0)