Skip to content

Commit b17dbcf

Browse files
fix(scripts): the ObjectQL double-limit lift reads identifiers from code, never from string or template text (#30521)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgMPwCdpvbf2imf5vac9Zoo4)_ domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF Fixes #30473 Clause-②: no — an internal instrument; no product surface moves Metadata-protocol: none ## 维护者速读 - **改了什么:** ObjectQL 测试替身 `limit` 检查在"把替身的 `find` 抽出来单独运行"时,改为只顺着代码里出现的名字去找同文件依赖;字符串、模板文字正文和注释里的单词不再算数,`${…}` 里的表达式照收。与刚合入的 #30472(where-matcher 检查)同一处修法。 - **为什么改:** 原来是对源码正文跑单词正则。替身报错文案里只要提到一个同文件的导出函数名,检查就会把它拉进来,抽取直接语法报错,一个合规替身被判"无法判定",门禁结论取决于文案用词。 - **风险与回滚:** 只动一个仓库内部检查脚本,不发布任何包。全树 985 个候选改前改后判定逐条一致(含原因列),耗时持平(约 27 秒)。回滚即撤 PR。 - **先看:** `scripts/check-objectql-double-limit.mjs` 的 `identifiersIn`(改为遍历语法树)、`discoverInSource` 里的 `sourceIdentifiers`(方法只取参数和函数体)、`buildCallable` 读集合的两行。 - **两分钟验证:** `pnpm check:objectql-double-limit`,预期 self-test 打印 OK,全树一行 `41 unjudged`,与 main 相同(合 main 后共 987 个候选)。 - **需要你做的:** 无,席位落地。 ## What changes `identifiersIn` was a word regex over source TEXT, the same function PR #30472 replaced in `check-where-matcher-conformance.mjs`. It now walks the parsed tree and collects `Identifier` nodes only: string literals, a template literal's text, regular expressions and comments contribute nothing, and an identifier inside a `${…}` hole is collected. Discovery computes the sets off the nodes it already holds, so nothing is re-parsed: each visible declaration carries `identifiers` beside its `text`, and the candidate carries `sourceIdentifiers` for exactly the text its `source` lifts (a method lifts as `function (params) body`, so its parameters and body are walked and its own name is not; an expression is walked whole). `buildCallable` follows those sets. **Not imported from the where-matcher gate.** The seat asked for one implementation, shared if it fits. It does not fit here: this file's header records why its scope walk is hand-copied rather than imported ("a gate that imports its own substrate from another gate's file couples two tripwires that must be able to fail independently"). The walker is the same tree walk in both files (this one takes several nodes, for the method case), and the doc comment names its twin. **Where the defect bites in this gate.** Measured, not assumed: this lift already survives a text-pulled sibling that throws `X is not defined` (it stubs the name and retries) and stubs non-function initializers at stub class 2, so the where-matcher reproducer ("sparse driver" beside `const driver = { name }`) seats here either way. What it cannot survive is a text-pulled FUNCTION-like sibling whose source cannot sit in the lift (function-like declarations are never stubbed). The self-test case uses an `export function`: `export` inside the lift body is a syntax error at every stub class, so the old lift answers `UNJUDGED could not lift: SyntaxError: Unexpected token 'export'` for a double that never calls it. ## Tests Two fixtures and seven assertions in the existing battery (floor 56 → 63): - `FIXTURE_TEXT_NAMES_SIBLING`: a bounding, refusing double whose messages say "openDriver" (one template literal with a `${k}` hole, one plain string), beside `export function openDriver`. Pinned: discovered; `openDriver` and `implemented` not collected; judged CONFORMING. - `FIXTURE_HOLE_NAMES_HELPER`: a same-file helper `key` named only inside a `${…}` hole on the answering path. Pinned: discovered; `key` collected; judged CONFORMING. <details><summary>Mutation replay (one-off, restored by blob hash == HEAD)</summary> - A, old semantics (word regex over each node's text): self-test exit 1 — `template-literal TEXT names no identifier: "openDriver" in the message is not collected`, `nor does a plain string literal: its "implemented" is not collected either`, `a double beside text naming a sibling binding lifts and is CONFORMING, not UNJUDGED`. - B, holes skipped (`if (ts.isTemplateExpression(n)) return;`): self-test exit 1 — `the helper named only inside the hole is collected`, `so the hole-reading double lifts with its helper and is CONFORMING`. - The text fixture through `main`'s module directly: `UNJUDGED could not lift: SyntaxError: Unexpected token 'export'`; through this branch: `CONFORMING`. </details> ## Acceptance notes - Whole-tree verdicts, before (48231b0) and after (c6af339): 985 structural candidates, 488 CONFORMING, 158 BLIND, 32 WRONG, 41 UNJUDGED, 266 OUT_OF_SCOPE, both runs; a per-candidate diff (file, line, verdict, wrap order, reason) is empty. No verdict changed, so nothing newly lifts and no count moves. The 41 UNJUDGED are not this defect. After merging `origin/main` (head 07592b3): `987 structural candidate(s): 680 graded, 41 unjudged`, gate exit 0. - No real producer in the tree today (the card said read, not measured); the reach is the self-test case. - Observation, not touched: a function-like same-file declaration spelled `export function` cannot be lifted even when the double's CODE calls it. That limit is unchanged by this PR and no corpus double hits it today. ## Verification Gate list derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (31 commands), each run with its exit code recorded before any pipe and reconciled with `--ran`; `pnpm lint` run. Readings are in the os-dev-report on #30473. No changeset: the diff is one repo-root script that no released package ships (`skip-changeset`). --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CYZ6tmbWZDWzLW4Yzg)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent ae5bd5b commit b17dbcf

1 file changed

Lines changed: 96 additions & 8 deletions

File tree

‎scripts/check-objectql-double-limit.mjs‎

Lines changed: 96 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -384,24 +384,46 @@ function visibleDeclarations(node, sf) {
384384
ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer);
385385
decls.set(d.name.text, {
386386
text: `let ${d.name.text} = ${d.initializer.getText(sf)};`,
387+
identifiers: identifiersIn(d.initializer),
387388
literal,
388389
fnLike,
389390
});
390391
}
391392
}
392393
} else if (ts.isFunctionDeclaration(s) && s.name && !decls.has(s.name.text)) {
393-
decls.set(s.name.text, { text: s.getText(sf), literal: false, fnLike: true });
394+
decls.set(s.name.text, {
395+
text: s.getText(sf), identifiers: identifiersIn(s), literal: false, fnLike: true,
396+
});
394397
}
395398
}
396399
}
397400
return decls;
398401
}
399402

400-
const identifiersIn = (src) => {
403+
/**
404+
* Every identifier the CODE under `nodes` names — and nothing its TEXT says
405+
* (#30473, the twin of #29860 in `check-where-matcher-conformance`, hand-copied
406+
* for the independence reason above). Walked off the parsed tree, so a string
407+
* literal, a template literal's text between its `${…}` holes, a regular
408+
* expression and a comment contribute nothing, while an identifier inside a
409+
* `${…}` hole is code and is collected. Over-inclusive in one direction only: a
410+
* property name (`row.name`) is still an identifier node, and a name that
411+
* resolves to no same-file declaration is skipped by the lift.
412+
*
413+
* It used to be a word regex over the source text, so a double's refusal
414+
* message naming a sibling binding pulled that binding into the lift — and a
415+
* binding that closes over a factory parameter makes the lift throw, filing a
416+
* drivable double under whatever the retry loop can salvage.
417+
*/
418+
function identifiersIn(...nodes) {
401419
const out = new Set();
402-
for (const m of src.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*)\b/g)) out.add(m[1]);
420+
const visit = (n) => {
421+
if (ts.isIdentifier(n)) out.add(n.text);
422+
ts.forEachChild(n, visit);
423+
};
424+
for (const n of nodes) visit(n);
403425
return out;
404-
};
426+
}
405427

406428
/** String literals in the body -- the object names a double may branch on. */
407429
function stringLiteralsIn(fnNode, sf) {
@@ -468,6 +490,12 @@ export function discoverInSource(text, label) {
468490
line: line + 1,
469491
queryShaped: looksQueryShaped(fn.body.getText(sf)),
470492
source: expressionSourceOf(fn, sf),
493+
// What the lift follows: the code of exactly the text `source` carries.
494+
// A method lifts as `function (params) body`, so its own name is not in
495+
// it; an expression lifts whole.
496+
sourceIdentifiers: ts.isMethodDeclaration(fn)
497+
? identifiersIn(...fn.parameters, fn.body)
498+
: identifiersIn(fn),
471499
declarations: visibleDeclarations(fn, sf),
472500
objectNames: [SYNTHETIC_OBJECT, ...stringLiteralsIn(fn, sf)],
473501
});
@@ -506,7 +534,7 @@ function buildCallable(candidate, stubbed, stubClass) {
506534
const self = '__os_find';
507535
const skip = new Set(stubbed);
508536
const included = new Map();
509-
let frontier = identifiersIn(candidate.source);
537+
let frontier = candidate.sourceIdentifiers;
510538
for (let depth = 0; depth < 6; depth++) {
511539
const next = new Set();
512540
for (const id of frontier) {
@@ -516,7 +544,7 @@ function buildCallable(candidate, stubbed, stubClass) {
516544
if (stubClass >= 1 && decl.literal) { skip.add(id); continue; }
517545
if (stubClass >= 2 && !decl.fnLike) { skip.add(id); continue; }
518546
included.set(id, decl.text);
519-
for (const x of identifiersIn(decl.text)) next.add(x);
547+
for (const x of decl.identifiers) next.add(x);
520548
}
521549
if (next.size === 0) break;
522550
frontier = next;
@@ -1113,6 +1141,49 @@ const ql = {
11131141
},
11141142
};`;
11151143

1144+
/**
1145+
* #30473: the lift reads identifiers from code, never from text. The refusal
1146+
* message names the sibling `openDriver`, an EXPORTED function: its source
1147+
* cannot sit inside the lift's function body (`export` is a syntax error there)
1148+
* and a function-like declaration is never stubbed, so a lift that follows the
1149+
* words of a template literal or a string cannot be built at any stub class and
1150+
* a conforming double reads UNJUDGED. The double never calls it.
1151+
*/
1152+
const FIXTURE_TEXT_NAMES_SIBLING = `
1153+
export function openDriver(name: string) { return { name, version: '0.0.0' }; }
1154+
function makeQl(rows: any[]) {
1155+
return {
1156+
async find(object: string, opts: any) {
1157+
const where = opts?.where ?? {};
1158+
for (const k of Object.keys(where)) {
1159+
if (k.startsWith('$')) throw new Error(\`no openDriver here: operator \${k} is not implemented\`);
1160+
if (k === '') throw new Error('openDriver: an empty field name is not implemented');
1161+
}
1162+
const matched = rows.filter((r: any) => ${MATCHER});
1163+
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
1164+
},
1165+
};
1166+
}`;
1167+
1168+
/**
1169+
* The other direction: a `${…}` hole IS code. The same-file helper `key` is
1170+
* named only inside a hole on the answering path, so a lift that stopped
1171+
* reading holes would leave it to the row stub and the control probe could not
1172+
* seat the double.
1173+
*/
1174+
const FIXTURE_HOLE_NAMES_HELPER = `
1175+
const key = (k: string) => k;
1176+
function makeQl(rows: any[]) {
1177+
return {
1178+
async find(object: string, opts: any) {
1179+
const where = opts?.where ?? {};
1180+
const matched = rows.filter((r: any) =>
1181+
Object.entries(where).every(([k, v]) => (r as any)[\`\${key(k)}\`] === v));
1182+
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
1183+
},
1184+
};
1185+
}`;
1186+
11161187
async function judgeFixture(src) {
11171188
const found = discoverInSource(src, 'fixture.test.ts');
11181189
const results = [];
@@ -1147,7 +1218,7 @@ const SELF_TEST_VERDICT = 'check-objectql-double-limit self-test reached its ver
11471218
// not red. A battery BELOW its floor means cases stopped running; the remedy is
11481219
// to find what stopped registering.
11491220
const SELF_TEST_BATTERIES = Object.freeze({
1150-
'check-objectql-double-limit self-test': 56,
1221+
'check-objectql-double-limit self-test': 63,
11511222
});
11521223

11531224
// DELETING an entry silences that battery's floor exactly as effectively as
@@ -1267,6 +1338,22 @@ async function selfTest() {
12671338
expect('a module-scope-fixture double is judged, not dropped',
12681339
moduleFixture.r?.verdict === 'BLIND');
12691340

1341+
// #30473: the lift reads identifiers from code, never from text.
1342+
const textNames = await one(FIXTURE_TEXT_NAMES_SIBLING);
1343+
expect('a double beside text naming a sibling binding is discovered', textNames.n === 1);
1344+
expect('template-literal TEXT names no identifier: "openDriver" in the message is not collected',
1345+
textNames.found[0]?.sourceIdentifiers?.has('openDriver') === false);
1346+
expect('nor does a plain string literal: its "implemented" is not collected either',
1347+
textNames.found[0]?.sourceIdentifiers?.has('implemented') === false);
1348+
expect('a double beside text naming a sibling binding lifts and is CONFORMING, not UNJUDGED',
1349+
textNames.r?.verdict === 'CONFORMING');
1350+
const holeNames = await one(FIXTURE_HOLE_NAMES_HELPER);
1351+
expect('a double calling a helper inside a `${…}` hole is discovered', holeNames.n === 1);
1352+
expect('the helper named only inside the hole is collected',
1353+
holeNames.found[0]?.sourceIdentifiers?.has('key') === true);
1354+
expect('so the hole-reading double lifts with its helper and is CONFORMING',
1355+
holeNames.r?.verdict === 'CONFORMING');
1356+
12701357
// Reconciliation, ONE-SIDED (#26444): an increase is an error, a decrease is a report.
12711358
const fake = new Map([['a.test.ts', { blind: 1, wrong: 0, unjudged: 0, details: [{ line: 1, result: { verdict: 'BLIND' } }] }]]);
12721359
expect('an unbaselined blind double is an error', reconcile(fake, {}).length === 1);
@@ -1394,7 +1481,8 @@ async function selfTest() {
13941481
' prove; the control probe drops a non-query `find`, one that never filters, and\n' +
13951482
' one answering with rows of its own that it could read no evidence from;\n' +
13961483
' a SHARED helper and a per-file copy grade identically; rows reached through a\n' +
1397-
' table map and through a module-scope fixture are both driven; the ledger\n' +
1484+
' table map and through a module-scope fixture are both driven; the lift follows\n' +
1485+
' identifiers its code names, never the words of its text; the ledger\n' +
13981486
' reconciles one-sided (a decrease passes and is reported).',
13991487
);
14001488

0 commit comments

Comments
 (0)