mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
* fix(impact): fail closed on id-less targets and follow ??/||/?: callable values (#3354) #3354 reports `impact` returning a byte-identical 1037/CRITICAL/`exact` result for three unrelated targets, with only `target.name` differing. The reporter's `target` had no `id` and no `filePath`, which the traversal path always emits, and a synthetic reproduction of their monorepo (pnpm, Cloudflare worker-configuration.d.ts in five packages, Hono, a Durable Object) resolves every target correctly on main. So the identical result was not reproduced. The repro did surface two real gaps and one hardening point: - `_runImpactBFS` now throws when the target has no node id. Every caller already catches, so impact reports `impactedCount: null, risk: UNKNOWN` instead of a normal-looking blast radius that cannot be about this symbol. - Callable-value flow followed only a single designator on the RHS, so `const sweep = env.__sweep ?? runSweep; await sweep(env)` produced no flow and `scheduled` was missing as a caller of `runSweep`, while the result still claimed `epistemic: exact`. Each branch of `??`, `||`, `or`, and `?:` now flows into the binding (language-neutral: operator and field names, no language checks). Parse cache bumped 104 -> 112 (105-111 are claimed by open PR #3326). - A whitespace-only `target_uid` (strict adapters materialize omitted optional strings) is treated as omitted and falls back to the name. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(mcp): treat a non-string uid as omitted instead of throwing (#3354) Review follow-up on #3373. `query.uid?.trim()` called `.trim()` on a client-supplied value, and the MCP envelope is not type-validated, so `context({uid: 42})` threw a TypeError that `context()` does not catch. Before #3373 the same input ended as a structured not_found. A non-string uid now counts as omitted, which matches how normalizeToolParams already treats a non-string `target_uid`. The impact and trace not-found messages print a trimmed uid only when it is a non-blank string, so a strict adapter sending " " sees the name it searched for instead of `' '`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(ingestion): expand ??/?:/ternary branches through a provider hook (#3354) Review follow-up on #3373. The shared value-alternatives rule keys on tree-sitter field names, and several grammars spell the same construct differently, so the expansion never fired for them: - Kotlin `elvis_expression` has no fields. - Swift uses `value`/`if_nil` and `if_true`/`if_false`. - Dart uses `first`/`second`, and its conditional has no `condition` field. - Python `a if c else b` has no fields. The `'?:'` operator entry was dead, since no bundled grammar emits it. Add an optional `valueAlternatives` hook to CallableFlowCaptureOptions, consulted before the shared rule, and implement it in the Kotlin, Swift, Dart, Python and Ruby providers. Shared code still names no language. Ruby's statement-bodied `if`/`unless`/`elsif` also carries `condition`/`consequence`/`alternative`, so the shared ternary rule dug an identifier out of an arbitrary statement (`g = h; 0` flowed `h`) and produced a wrong CALLS edge. The Ruby hook now keeps a multi-statement branch as one opaque source, as before #3373. Tests: new provider fixtures for Python, Kotlin, Swift, Dart and Ruby (including a Ruby negative), and TS chain, parenthesized, callable-left and `&&` negative cases. Captures goldens gain one entry each for the new fixtures. SCHEMA_BUMP stays 112 (same unreleased PR). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(ingestion): keep long ||/??/?: chains linear in callable-flow capture (#3354) Expanding value-selecting sources into per-branch flows made long chains super-linear and, past a few thousand operands, a stack overflow. A generated `w === "k0" || w === "k1" || ...` keyword table took 6.7 s at 1000 operands and 48 s at 2000, and threw RangeError at 8000. Two causes, both fixed without changing any emitted capture: - valueAlternatives recursed once per operator level and spread the partial results at every level. It is now an explicit stack that writes to one output array. The left-to-right order, the paren unwrapping, and the provider-hook contract (`[node]` means opaque) are unchanged. - Every alternative ran its visibility walks from its own leaf, which can be as deep as the chain is long, all the way to the root. Also, tree-sitter's `parent` re-descends from the root, so each step costs the node's depth. The walks now jump between "anchor" nodes, the only nodes any check can match: region ids and formal owners. The nearest anchor is memoized per node across the file, and parents come from a map recorded by the one DFS the synthesizer already does. After the fix: 0.34 s / 0.23 s / 1.6 s at 1000 / 2000 / 8000 operands. That is within about 1.2x of main without the expansion; what remains is tree-sitter query time. Capture fingerprints are byte-identical before and after the fix for all 16 scope-capture bench languages and for the Python harness. A `typescript-deep-chain` case added to the scope-capture bench guards the scaling: 1.11-1.22 now, 7.5-8.0 before. A unit test pins a 10000-operand chain: it must still yield the seed for a callable operand, the copy for a formal at the deepest leaf, and the invoke. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(kotlin): see through braced if-branches in callable alternatives (#3354) tree-sitter-kotlin wraps a braced branch as `control_structure_body > statements > <expr>`, so for any non-empty block kotlinValueAlternatives saw exactly one named child (`statements`) and pushed the wrapper itself as the branch value. operandSyntax emits nothing for a `statements` node, so `val run = if (c) { ::f } else { ::g }; run()` produced no flow edges, and the "multi-statement block stays opaque" guard could never fire. Descend one level through `statements` and require exactly one non-comment expression there. Empty blocks (`{}` has no named children), multi-statement blocks, and `if` without `else` still return the whole `if` as one opaque source. The doc comment now describes that. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(ruby): skip only the multi-statement branch in callable alternatives (#3354) rubyValueAlternatives returned `[node]` (opaque) as soon as one branch held more than one statement. Two things went wrong because of that: - At the top level, `run = if c then g = h; 0 else method(:f) end` dropped the single-statement `else`, so `f` got no flow edge. - In an elsif chain, the outer `if` pushed the `elsif` node as a branch. The shared loop called the hook on it again, got `[elsif]` back, and used the whole elsif subtree as one source. So in `if a then method(:run_a) elsif b then g = h; 0 else method(:run_b) end` the `run_b` edge was lost. The hook now walks the elsif chain itself and skips each multi-statement branch, while every single-statement branch still becomes an alternative. This cannot add a wrong edge: each emitted alternative is a value the conditional really evaluates to, and nothing is taken from the skipped branch. Before, that branch did not contribute a resolvable value either. A whole conditional used as a source becomes a qualified-name seed, which resolves to nothing when it has more than one identifier leaf. With a single identifier leaf, it could even seed the condition variable. When no branch is a single statement, the conditional still stays one opaque source, as before. Ruby captures golden: ruby-callable-alternatives/app.rb goes from 33 to 58 capture groups. 23 of them come from the new fixture functions (checked against the old source). The other 2 are the new branch alternatives, `statement_if -> run_sweep` and `elsif_chain -> run_b`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(ingestion): flow the right operand of && / and into callable bindings (#3354) `a && b` / `a and b` yields `a` when it is falsy and `b` otherwise. A falsy value is never a callable, so the right operand is the only one that can be invoked later. The capture left `&&` unexpanded, and the compound source became a qualified seed that resolves to nothing: `const run = x && f; run()` gained no edge to `f` while impact claimed `exact`. Python `x and f or g` reached only `g`. The shared expansion now maps `&&` / `and` to the right branch only. It recurses, so `x and f or g` reaches both `f` and `g`. Where `&&` yields a boolean (Java, C#, Go, Rust, C, C++, PHP, Zig), the destination cannot be invoked, so the flow never meets a call. A before/after CALLS diff over all 83 lang-resolution fixtures that contain `&&` or `and` shows exactly one new edge, logicalAnd -> runAndRight. PHP and Ruby bind low-precedence `and` looser than `=`, so `$g = $x and $y` never reaches this rule. The Python golden digest changes only for the extended python-callable-alternatives fixture. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(ingestion): keep operator branches of a value-selecting source opaque (#3354) The fan-out sent every branch of `??` / `||` / `or` / `?:` to emitAssignmentFact on its own, including branches that compute a value. `Handlers.fallback === run || fb` then emitted the comparison as a seed whose qualified text sliced to receiver `Handlers`, member `run`, and resolveBoundMemberCandidates minted a CALLS edge to `Handlers.run`. The same happened for `this.state !== run ?? this.fallback`, `this.state + run || this.fallback`, and Python `self.state != run or self.fallback`. Before the fan-out, the whole compound source was one opaque seed. A branch that is a binary operator expression now contributes nothing. The check uses the field vocabulary valueBranches already reads (a `left`/`right` pair, or an `operator`/`operators`/`op` token after the expression start), not grammar type names. Member accesses that field their `.`/`->` as `operator` (Ruby `call`, C/C++ `field_expression`) also field a member name through the list memberParts uses, now shared as memberNameNode, so they stay designators. Unary `&f`/`*fp` lead with their operator and stay designators. Call results were already dropped by emitAssignmentFact, and lambdas and callable references are unchanged. CALLS-edge diff over 87 lang-resolution fixtures (505 -> 501 edges): only the four false edges above were removed, and none were added. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(ingestion): pin the LEFT operand of ?? / or / elvis per provider (#3354) The Python, Kotlin, Swift and Dart "override ?? fn" cases put an unresolvable parameter on the left, so a fan-out that kept only the last operand still passed them. Each fixture now adds a callableLeft case with a real function on the left and a parameter on the right (`run_left or fallback`, `::runLeft ?: fallback`, `runLeft ?? fallback`), and each language asserts `callableLeft → runLeft`. Mutation check: dropping the left branch from the shared `??`/`||`/`or` rule and making the four provider hooks return only their last operand fails all four new tests, while the existing right-operand tests still pass. Capture goldens were regenerated with UPDATE_GOLDEN=1: - python app.py: 35 -> 85 groups. 35 -> 75 is stale drift from8ad0d8db7, which added the comparison cases without regenerating. 75 -> 85 is the new case: 2 declarations, 2 scopes, the variable, the `run()` reference, and 4 callable-flow captures (seed -> run_left, copy <- fallback, formal, invoke). - swift App.swift: 25 -> 36 groups for the same new case, plus Swift's type-binding for the fallback parameter. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(bench): re-baseline scope-capture fingerprints for #3354 callable alternatives This PR makes a callable chosen by a value-selecting source flow every branch it can yield (??, ||, or, && right operand, ternary, statement if, elvis). It also keeps an operator branch opaque. Both change @callable-flow captures, and the PR adds *-callable-alternatives regression fixtures that these benches glob. To verify, the BASE (merge-base233ca2849) and HEAD emitters were run over the same HEAD fixture corpus plus the synthetic source. Every added or removed match is an @callable-flow.* match on one of those sources. The pre-existing corpus is byte-identical for all 16 scope-capture languages and for Python. Emitter delta, then corpus growth: - ruby: +5/-0 app.rb (+1 file, +58 groups) - swift: +6/-3 App.swift (+1 file, +36 groups) - dart: +6/-3 app.dart (+1 file, +33 groups) - kotlin: +8/-2 App.kt (+1 file, +71 groups) - typescript: +18/-14 4 files (+288 groups) - python: +11/-7 app.py (+1 file, +85 groups) The removed matches are whole-expression seeds and copies that carried a qualified name of the compound source. They are replaced by one seed or copy per operand. Synthetic scaling counts are unchanged and every scaling ratio stays under budget. Per-language notes are in baselines.json under _rebaselined_3354_callable_alternatives. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor(mcp): share one blank-uid check across impact, trace and symbol lookup (#3354) The "trimmed uid, or omitted when blank/non-string" rule was inlined four times, three of them trimming twice. nonBlankUid() owns it now; behaviour is unchanged. The whitespace and empty target_uid tests collapse into one it.each. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(cfg): correct the Kotlin and Python grammar-field notes (#3354) The Kotlin CFG visitor claimed no control-flow node has fields; a parse of the vendored grammar shows if_expression fields condition/consequence/ alternative (when/for/while/do/try/elvis are fieldless). The Python harvest note listed conditional_expression's children like field names; the node is fieldless and they are positional. Both notes were misleading reviewers of the #3354 value-alternatives hooks. Comment-only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(ingestion): skip optional-grammar suites in callable-alternatives providers test (#3354) Kotlin, Swift and Dart grammars are optional installs. Guard their describe blocks with isLanguageAvailable, as swift.test.ts and dart.test.ts do, so an install without one of them skips instead of failing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(ingestion): probe the Dart parser before enabling its providers suite (#3354) isLanguageAvailable only proves the module loaded; tree-sitter-dart can still fail on setLanguage. Probe loadParser/loadLanguage and skip on failure, the same guard dart.test.ts uses. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6bb99767ff
commit
e06c2dd13e
30 changed files with 1174 additions and 44 deletions
|
|
@ -1 +1 @@
|
|||
7412edd9db56b4626c77fc09363e0d49a2f95763453756dceb82db6606280c28
|
||||
8075fe28c703c9d27d1b1b90ae04992548b677daf61be5116511b6ca3424dde1
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@
|
|||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28 -> b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c."
|
||||
},
|
||||
"ruby": {
|
||||
"fingerprint": "1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57",
|
||||
"fingerprint": "45e65d9fa8a9e5e905ddb596b179b77c86ed13ab5139c6b17ccaaf7315dfe46a",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior cff273ae6cb7232c977d9241581834a2a2fa8bcf6369f7bd8f2471cd4419a6ef -> bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236; scaling 1.103 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Ruby Method/Proc callable flow facts with invocation/constructor-result suppression. Prior b5ea93bb3d0469c3821a8c70f5d5991c6f326e41097c119ad691154301dcc753 -> cff273ae6cb7232c977d9241581834a2a2fa8bcf6369f7bd8f2471cd4419a6ef; scaling 1.086 < 1.5.",
|
||||
|
|
@ -114,10 +114,11 @@
|
|||
"_note": "F62: + scope_resolution class/module declaration captures \u2014 fixture count 78\u219281, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) \u2014 pure fixture-corpus drift, scope-extractor captures unchanged; 81\u219282. #1991: + ruby-nested-mixin-tail-collision fixture (85\u219286). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb.",
|
||||
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: bare identifiers are calls, not callable references (bareNamesAreCalls). Prior bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236 -> 070e4e11502442998ddf4048c2981cf1b2b735a87362ff854c5d14d71f98f4e2; scaling ratio re-verified within budget.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior fea3edf82f521995147874b7f6c5f9e2eb88efdebf6365668f3260e913f0b558 -> fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83.",
|
||||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57."
|
||||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57.",
|
||||
"_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: ruby-callable-alternatives/app.rb (+1 file, +58 groups). Emitter delta: +5 / -0: seeds for single-statement `if` / `elsif` branches (run_then, run_else, run_sweep, run_a, run_b); the multi-statement branch contributes nothing. capture_groups_fp 1358 -> 1416, fixture_count 91 -> 92; synthetic counts unchanged; scaling 1.04 < 1.5. Prior 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57 -> 45e65d9fa8a9e5e905ddb596b179b77c86ed13ab5139c6b17ccaaf7315dfe46a."
|
||||
},
|
||||
"swift": {
|
||||
"fingerprint": "decf74c01af0c7f403e203b19c2bd92dbf95b4562f6da2b0ac5117ef872204da",
|
||||
"fingerprint": "c9fc553662f0db18027fba882e3ff730744f6153cc46eca7b00667fabd6a0df8",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725 -> 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d; scaling 1.042 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Swift function-value callable flow facts with invocation-result suppression. Prior 180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998 -> 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725; scaling 1.043 < 1.5.",
|
||||
|
|
@ -131,10 +132,11 @@
|
|||
"_rebaselined_3309_exact_return_replay": "#3309 follow-up: Swift callable declarations now emit exact return-type captures, and untyped local initializers emit call-result assignment captures through direct, try, await, and try-await forms. This is intentional capture-set growth covered by exact-callable collision and wrapper regressions. Prior 4d32535d454cd79086f0fcaec3b75de855c1314e73630321460e569f19aa484f -> 724553c91e8ebcf872d7302b14105d59476c230f9b623acdacc0b58e39912329; hosted benchmark scaling remained within the existing 1.5 budget.",
|
||||
"_rebaselined_3308_merge_main": "Merge origin/main into #3308: combine #3262 fixture-owner recovery with #3309 protocol-extension capture growth. Prior 3ac80b64776969effb75c8d32aa0bc38fc6a41c6c115a33829c502559fa404f9 + 724553c91e8ebcf872d7302b14105d59476c230f9b623acdacc0b58e39912329 -> b0c89f8de1a4ce30409182e79c1c14cbc49a509d18d853c0d7c5f22f1f66be06; capture_groups_fp 1297, fixture_count 67.",
|
||||
"_rebaselined_3308_public_extension": "#3308 review: the nested-constructor fixture now uses `public extension Outer.Container` so owner recovery is gated on modifier-prefixed headers. Fixture-only digest drift. Prior b0c89f8de1a4ce30409182e79c1c14cbc49a509d18d853c0d7c5f22f1f66be06 -> a6d61e3749c9c02b9a54611e42d03db02f8fabfded3d7ce53a1b7229d3d38d59; capture_groups_fp 1297, fixture_count 67.",
|
||||
"_rebaselined_3308_public_enclosing_types": "#3308 review: `Outer` / `Container` / `Entry` in the nested-constructor fixture are now `public` so the `public extension` is valid Swift. Fixture-only digest drift (Types.swift capture text). Prior a6d61e3749c9c02b9a54611e42d03db02f8fabfded3d7ce53a1b7229d3d38d59 -> decf74c01af0c7f403e203b19c2bd92dbf95b4562f6da2b0ac5117ef872204da; capture_groups_fp 1297."
|
||||
"_rebaselined_3308_public_enclosing_types": "#3308 review: `Outer` / `Container` / `Entry` in the nested-constructor fixture are now `public` so the `public extension` is valid Swift. Fixture-only digest drift (Types.swift capture text). Prior a6d61e3749c9c02b9a54611e42d03db02f8fabfded3d7ce53a1b7229d3d38d59 -> decf74c01af0c7f403e203b19c2bd92dbf95b4562f6da2b0ac5117ef872204da; capture_groups_fp 1297.",
|
||||
"_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: swift-callable-alternatives/App.swift (+1 file, +36 groups). Emitter delta: +6 / -3: `??` and ternary seeds and copies now name each operand; the 3 removed matches are the whole-expression seeds and copy that carried a `target-qualified-name` / `source-qualified-name` of the compound source. capture_groups_fp 1297 -> 1333, fixture_count 67 -> 68; synthetic counts unchanged; scaling 1.057 < 1.5. Prior decf74c01af0c7f403e203b19c2bd92dbf95b4562f6da2b0ac5117ef872204da -> c9fc553662f0db18027fba882e3ff730744f6153cc46eca7b00667fabd6a0df8."
|
||||
},
|
||||
"dart": {
|
||||
"fingerprint": "3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687",
|
||||
"fingerprint": "795290a9524ee0dc38af635536e9744c401ec13828ec77822d39e59e1cd6e649",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_generic_instantiation_2912": "#2912: the Dart heritage marker carries a fourth field \u2014 the type arguments the clause was written with (`implements Validator<String>`) \u2014 so interface dispatch can prune implementors of a mismatched instantiation. Additive marker text on existing heritage matches rather than a new match, so this is digest drift only; a marker from a pre-#2912 cache simply has no fourth field and reads as unknown. Prior ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73 -> 3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687; scaling 1.027 < 1.5.",
|
||||
"_rebaselined_2538": "#2538: Dart extension type headers are preprocessed into normal extension declarations before scope capture, so extension type symbols and their methods are now emitted. Intentional Dart-only capture fingerprint drift; CI measured scaling 1.042 < 1.5.",
|
||||
|
|
@ -142,7 +144,8 @@
|
|||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 29ce2bfe70b246b1c9d5e99c0ec11e850c22e9672737592207242b7f4cc824b8 -> 66a46d5ff09f3d11b2771db0f48596fe7057e95c5bc8f56241fdb911137298c3; scaling 1.054 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Dart function-tearoff and selector callable flow facts, split signature/body lexical ownership, and invocation-result suppression. Prior 94bf2c26e1ba96f4211634aa572c0a989b503e717e75dfc5df04f66c417de80f -> 29ce2bfe70b246b1c9d5e99c0ec11e850c22e9672737592207242b7f4cc824b8; scaling 0.906 < 1.5.",
|
||||
"_added": "#939: dart added to the scope-capture bench with the registry-primary migration. Heritage-bearing scale source (Entity extends Base implements Marker) gates the @reference.inherits synth + the postfix-chain reference walk at scale. emitDartScopeCaptures threads tree-sitter captured nodes (no findNodeAtRange root-walk), so it is linear (~1.0).",
|
||||
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0."
|
||||
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0.",
|
||||
"_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: dart-callable-alternatives/app.dart (+1 file, +33 groups). Emitter delta: +6 / -3: `??` and `?:` seeds and copies now name each operand; the 3 removed matches are the whole-expression seeds and copy that carried a qualified name of the compound source. capture_groups_fp 829 -> 862, fixture_count 36 -> 37; synthetic counts unchanged; scaling 1.033 < 1.5. Prior 3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687 -> 795290a9524ee0dc38af635536e9744c401ec13828ec77822d39e59e1cd6e649."
|
||||
},
|
||||
"java": {
|
||||
"fingerprint": "e22a753b9f59331898a3a27c43072b588c33639937b1f618f6c70ce04f072a9f",
|
||||
|
|
@ -180,7 +183,7 @@
|
|||
"capture_groups_fp": 680
|
||||
},
|
||||
"typescript": {
|
||||
"fingerprint": "60e75bbe846f7200005f12c4d95c32e82d9d3feae9f2fd594e484670762b82b0",
|
||||
"fingerprint": "77c9b4ea654123a64972db8348190ec250b669472b150db65ea8c7f20b355467",
|
||||
"_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_2934_import_type_only": "#2934: `import-decomposer.ts` attaches a presence-only `@import.type-only` synthetic capture to specifiers `tsc` erases, so `check --cycles` can stop counting type-only edges as initialization cycles. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE \u2014 the tag is added to import matches that already existed, never a new match, the same shape as the #2747 receiver-chain rebaseline. Every count is unchanged: capture_groups_fp 2414, fixture_count 155, capture_groups_small/large 4503/14403 (those measure the SYNTHETIC scaling source, which has no imports at all). The fingerprint moves because `canonicalizeMatch` in measure.mjs hashes every TAG on every match, synthetics included, so one extra presence-only tag on an existing match rewrites that match's canonical string. Attribution is exact, not inferred: neutralizing ONLY the `m['@import.type-only'] = \u2026` assignment in import-decomposer.ts and re-running returns the fingerprint to c2fbf8a89e5686dd\u2026 byte-for-byte, so nothing else in the TypeScript capture stream moved. All 14 other languages report ok. Scaling 0.997 < 1.5. NOTE ON THE CONTROL: javascript did not move (2026993b\u2026, 43 fixtures), but it is a WEAK control here \u2014 `import type` is TypeScript-only syntax, so a JS corpus cannot express the construct and could not have drifted either way. It evidences no collateral damage, not the correctness of the TS change; the exact-attribution check above is what does that. Prior c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8 -> f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f.",
|
||||
|
|
@ -204,7 +207,8 @@
|
|||
"_rebaselined_type_parameter_shadowing_w2_8": "W2-8: `@declaration.type-parameters` is now captured on generic FUNCTIONS, generator functions and type ALIASES, not only on class/interface declarations. NO NEW CAPTURE NAME \u2014 verified by diffing the capture-name sets against the wave-1 branch, which returns empty; the tag already existed and simply fires on more declarations. That is the whole delta: capture_groups_fp 2338 -> 2371 (+33 occurrences of an existing tag) and fixture_count 151 -> 152 (one new fixture, typescript-type-parameters). capture_groups_small/large unchanged at 4503/14403, since those measure the synthetic scaling source this does not touch. Scaling 1.06 < 1.5. JavaScript is untouched \u2014 it has no type parameters \u2014 and its fingerprint does not move, which is the check that this is the TS declaration rules and not something broader. Prior f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7 -> 62c7f1bfbe568eed927fb78f00061ed5e49d12511fd8260648b876df386f3b4c.",
|
||||
"_rebaselined_2899_review_type_parameter_scope_fixtures": "PR #2899 review follow-up: FIXTURE-CORPUS GROWTH ONLY \u2014 no query rule changed and no capture name was added or removed. `typescript/query.ts` is byte-identical to the previous baseline; the type-parameter shadowing defect was fixed on the RESOLUTION side (`walkers.ts` gains a `declarationOpenedScope` gate so a declaration's `typeParameters` bind only inside the scope that declaration opened, and the `USES` guard moved from `graph-bridge/references-to-edges.ts` to `resolve-references.ts` where the spelled `site.name` is in hand). The fingerprint moves because measure.mjs fingerprints the whole `lang-resolution/typescript-*` fixture corpus and the regression tests add three files to `typescript-type-parameters/src/` (values.ts, aliased.ts, namespaced.ts) plus two scope-less generic aliases in shapes.ts. Per-file accounting sums exactly to the delta: shapes.ts 33->35 (+2), values.ts +11, aliased.ts +10, namespaced.ts +20 = +43. capture_groups_fp 2371 -> 2414; fixture_count 152 -> 155. capture_groups_small/large unchanged at 4503/14403 (they measure the SYNTHETIC scaling source, untouched). JAVASCRIPT IS THE CONTROL AND DID NOT MOVE (fingerprint 2026993b..., 43 fixtures) \u2014 which is the check that this is corpus growth and not a capture regression; all 14 other languages report `ok`. Scaling 0.976 < 1.5. Prior 62c7f1bfbe568eed927fb78f00061ed5e49d12511fd8260648b876df386f3b4c -> c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8.",
|
||||
"_rebaselined_2953_workspace_fixture": "#2953 adds test/fixtures/lang-resolution/typescript-pnpm-workspace-imports, a pnpm monorepo of 12 .ts files, and the TypeScript capture corpus is collected from test/fixtures. CORPUS GROWTH ONLY, NOT A CAPTURE CHANGE: fixture_count 155 -> 167 and capture_groups_fp 2414 -> 2465 are the 12 new files' own matches; capture_groups_small/large are unchanged at 4503/14403 because those measure the SYNTHETIC scaling source, which the fixture corpus does not feed. Attribution is exact rather than inferred: moving that one fixture directory aside and re-running returns typescript to f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f byte-for-byte with fixture_count back at 155, and [scope-capture --check] PASSES for all 15 languages - so nothing in the TypeScript capture stream moved. #2953 changes import RESOLUTION, which runs after capture and feeds no capture tag. Prior f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f -> 05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633.",
|
||||
"_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Drift: `await svc.verify<GuestPayload>(token, ...)` (typescript-generic-calls/src/guest.ts, member call) and `initializer()(() => {...})` (typescript-hof-callbacks/src/store.ts, call-of-call) lose `direct-callee-name`. `await verifyToken<AdminPayload>(token, ...)` (admin.ts/auth.ts) KEEPS `direct-callee-name|verifyToken`: tree-sitter-typescript parses `await f<T>(x)` as call_expression(function: await_expression(f), type_arguments, ...), and wrappedExpression now unwraps `await_expression` so the direct designator survives as it does for the un-awaited spelling. capture_groups_fp 2465 (unchanged). Prior 05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633 -> fed04ed1d5db112387781e405da208ae6b3ab803889773b0455be96f01b893ff."
|
||||
"_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Drift: `await svc.verify<GuestPayload>(token, ...)` (typescript-generic-calls/src/guest.ts, member call) and `initializer()(() => {...})` (typescript-hof-callbacks/src/store.ts, call-of-call) lose `direct-callee-name`. `await verifyToken<AdminPayload>(token, ...)` (admin.ts/auth.ts) KEEPS `direct-callee-name|verifyToken`: tree-sitter-typescript parses `await f<T>(x)` as call_expression(function: await_expression(f), type_arguments, ...), and wrappedExpression now unwraps `await_expression` so the direct designator survives as it does for the un-awaited spelling. capture_groups_fp 2465 (unchanged). Prior 05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633 -> fed04ed1d5db112387781e405da208ae6b3ab803889773b0455be96f01b893ff.",
|
||||
"_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: typescript-callable-alternatives/{index,compare,worker,sweep/index}.ts (+4 files, +288 groups). Emitter delta: +18 / -14: `??` / `||` / `&&` / ternary seeds and copies now name each operand (`&&` its right operand only); the 14 removed matches are the whole-expression seeds and copies carrying a qualified name of the compound source, including the comparison branches in compare.ts that now stay opaque. capture_groups_fp 2465 -> 2753, fixture_count 167 -> 171; synthetic counts unchanged; scaling 0.963 < 1.5. Prior 60e75bbe846f7200005f12c4d95c32e82d9d3feae9f2fd594e484670762b82b0 -> 77c9b4ea654123a64972db8348190ec250b669472b150db65ea8c7f20b355467."
|
||||
},
|
||||
"javascript": {
|
||||
"fingerprint": "b916c7072b30d09b4949803810604830ea1aca1cfdda0d9d9312f7cb22f7a10a",
|
||||
|
|
@ -223,7 +227,7 @@
|
|||
"_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side \u2014 the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3."
|
||||
},
|
||||
"kotlin": {
|
||||
"fingerprint": "a9d3f0db7547ff47856159debf15d2a6f427efca97a6af27b4004174ed432132",
|
||||
"fingerprint": "f3fefe058484823761b52df5715f41d65fa4ff0223843bcc86893d7b0a6b3de2",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_interface_abstract_2885": "#2885: Kotlin interface property accessors stay in the capture set (groups still 5753/18403 and capture_groups_fp 2563) but Method isAbstract is now true for body-less interface properties, which changes accessor-plan identity in the fixture digest. Prior 82ae5e1f750580383344d4c84c400a290474528cd502be4af8cd56705819a683 -> aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5; CI scaling 0.838 < 1.5.",
|
||||
"_rebaselined_jvm_property_accessors_2885": "#2885: Kotlin val/var properties now emit JVM getter/setter scope and declaration captures, including data-class constructor properties and custom accessors. Synthetic scaling counts move 4753/15203 -> 5753/18403; fixture-corpus groups move 2367 -> 2563. Accessor declaration sidecars use the canonical @declaration.qualified_name key, preserve same-name owner identity, follow JvmAbi is-prefix naming, and suppress @JvmName-renamed accessors until their custom names are modeled. Prior f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832 -> 82ae5e1f750580383344d4c84c400a290474528cd502be4af8cd56705819a683; scaling 0.869 < 1.5.",
|
||||
|
|
@ -245,6 +249,12 @@
|
|||
"fixture_count": 141,
|
||||
"_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Only drift: `users.map { it.name }.forEach { name -> println(name) }` (kotlin-lambda-scopes/App.kt) loses `direct-callee-name|forEach` (member call). capture_groups_fp 2334 (unchanged). Prior a184f8ff0ae40d246db855b63f7ff26bda3afac03e5f4c76e4593c7e2cefce54 -> 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284.",
|
||||
"_rebaselined_1432_rebase_onto_2960": "#1432 rebase onto main @ aac7515d: the kotlin fingerprint is a COMBINATION of two independent changes, so neither side of the merge conflict was correct on its own and resolving it by picking a side would have committed a fingerprint no run can reproduce. main's #2960 added four declared-package fixture files (fixture_count 137 -> 141, capture_groups_fp 2334 -> 2367); this branch's `_rebaselined_1432_member_call_callee_name` drops `direct-callee-name|forEach` from one member call. Recomputed under both: capture_groups_fp 2367 and fixture_count 141 match main's committed counts EXACTLY (this branch's change is emission-only and moves no count), capture_groups_small/large stay 4753/15203 (the SYNTHETIC scaling source, which neither change touches), scaling 1.003 < 1.5, and the other 14 languages report ok against their committed baselines in the same run - which is the check that the rebase replayed nothing else into the capture stream. Attribution is exact rather than inferred: moving test/fixtures/lang-resolution/kotlin-import-package-evidence aside and re-running returns kotlin to 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284 byte-for-byte with fixture_count back at 137 and capture_groups_fp back at 2334 - this branch's pre-rebase value - so the whole delta is #2960's corpus growth layered on top, with nothing else moving. Prior (this branch, pre-rebase) 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284 and (main) f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832 -> 973d702510002dda76166e017c5eca90cae37a139f5a512877b7a1b04ad19dc5.",
|
||||
"_rebaselined_1432_merge_main_2885": "#1432 merge of main @ 212e007a: the kotlin fingerprint is again a COMBINATION of two independent changes \u2014 main's #2885 JVM property accessors / interface-abstract (4753/15203 -> 5753/18403, capture_groups_fp 2367 -> 2563) and this branch's `_rebaselined_1432_member_call_callee_name` (drops `direct-callee-name|forEach` from one member call). Neither side's value reproduces under the merged tree. Recomputed under both: counts 5753/18403/2563 and fixture_count 141 match main's committed counts EXACTLY (this branch's change is emission-only), scaling 1.061 < 1.5, and csharp / cpp / typescript measure byte-for-byte at this branch's committed values (main did not touch them since the merge-base) while the other 11 languages report ok \u2014 the check that the merge replayed nothing else into the capture stream. Prior (main) aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5 and (this branch) 973d702510002dda76166e017c5eca90cae37a139f5a512877b7a1b04ad19dc5 -> a9d3f0db7547ff47856159debf15d2a6f427efca97a6af27b4004174ed432132."
|
||||
"_rebaselined_1432_merge_main_2885": "#1432 merge of main @ 212e007a: the kotlin fingerprint is again a COMBINATION of two independent changes \u2014 main's #2885 JVM property accessors / interface-abstract (4753/15203 -> 5753/18403, capture_groups_fp 2367 -> 2563) and this branch's `_rebaselined_1432_member_call_callee_name` (drops `direct-callee-name|forEach` from one member call). Neither side's value reproduces under the merged tree. Recomputed under both: counts 5753/18403/2563 and fixture_count 141 match main's committed counts EXACTLY (this branch's change is emission-only), scaling 1.061 < 1.5, and csharp / cpp / typescript measure byte-for-byte at this branch's committed values (main did not touch them since the merge-base) while the other 11 languages report ok \u2014 the check that the merge replayed nothing else into the capture stream. Prior (main) aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5 and (this branch) 973d702510002dda76166e017c5eca90cae37a139f5a512877b7a1b04ad19dc5 -> a9d3f0db7547ff47856159debf15d2a6f427efca97a6af27b4004174ed432132.",
|
||||
"_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: kotlin-callable-alternatives/App.kt (+1 file, +71 groups). Emitter delta: +8 / -2: elvis and `if` expression (bare and braced) seeds and copies now name each operand; the 2 removed matches are the whole-expression seed and copy carrying a qualified name of the compound source; the multi-statement `if` stays opaque. capture_groups_fp 2563 -> 2634, fixture_count 141 -> 142; synthetic counts unchanged; scaling 1.17 < 1.5. Prior a9d3f0db7547ff47856159debf15d2a6f427efca97a6af27b4004174ed432132 -> f3fefe058484823761b52df5715f41d65fa4ff0223843bcc86893d7b0a6b3de2."
|
||||
},
|
||||
"typescript-deep-chain": {
|
||||
"fingerprint": "c4dd97ad7be50a554930237f79035993ffa67957de6dc8c1d6606518071204b2",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#3354 review: one TS `handler || w === \"k0\" || \u2026` chain, operand count = entity count (250 -> 800). Guards callable-flow value-alternative expansion (valueAlternatives + per-leaf visibility walks) against per-leaf cost that grows with chain depth: before the fix this measured 7.5-8.0 (super-linear; 8000 operands overflowed the stack), after it 1.11-1.22. Synthetic-only fingerprint (no fixture corpus); identical before and after the fix."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -309,6 +309,21 @@ const LANGS = [
|
|||
` getId(): number { return this.id; }\n` +
|
||||
` setName(v: string): void { this.name = v; }\n}\n\n`,
|
||||
},
|
||||
{
|
||||
// One `a || b || …` chain whose operand count is the entity count (#3354
|
||||
// review): each operand is one more nesting level, so this guards the
|
||||
// callable-flow value-alternative expansion against per-leaf work that
|
||||
// grows with chain depth (ratio ~8 before the fix, and 8000 operands
|
||||
// overflowed the stack). No fixture corpus — the fingerprint is the synthetic
|
||||
// source alone; `typescript` above already covers the TS fixtures.
|
||||
name: 'typescript-deep-chain',
|
||||
emit: emitTsScopeCaptures,
|
||||
exts: ['.ts'],
|
||||
file: 'bench-chain.ts',
|
||||
header: 'function handler() {}\n\nexport function isKw(w: string) {\n const f = handler',
|
||||
unit: (n) => ` || w === "k${n}"`,
|
||||
footer: ';\n return f;\n}\n',
|
||||
},
|
||||
{
|
||||
name: 'javascript',
|
||||
emit: emitJsScopeCaptures,
|
||||
|
|
@ -371,7 +386,9 @@ function measureLang(lang) {
|
|||
// Correctness fingerprint over the fixture corpus + a fixed 20-entity source.
|
||||
const perFixture = [];
|
||||
let groups = 0;
|
||||
for (const { key, absPath } of collectFixtures(lang.fixturePrefix, lang.exts)) {
|
||||
const fixtures =
|
||||
lang.fixturePrefix === undefined ? [] : collectFixtures(lang.fixturePrefix, lang.exts);
|
||||
for (const { key, absPath } of fixtures) {
|
||||
const matches = lang.emit(fs.readFileSync(absPath, 'utf8'), absPath);
|
||||
groups += matches.length;
|
||||
perFixture.push(`${key}\t${matches.length}\t${digestCaptures(matches)}`);
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@
|
|||
*
|
||||
* Kotlin's tree-sitter grammar (vendored, NOT an npm package — loaded via
|
||||
* `requireVendoredGrammar('tree-sitter-kotlin')`, exactly like tree-sitter-swift)
|
||||
* is field-less for control flow: NONE of the control-flow nodes expose
|
||||
* `childForFieldName` fields (verified by a real parse — every `fieldNameForChild`
|
||||
* came back null), so this visitor navigates purely by child TYPE and position.
|
||||
* is mostly field-less for control flow (`when` / loops / `try` / `elvis_expression`
|
||||
* expose no fields), so this visitor navigates purely by child TYPE and position.
|
||||
* `if_expression` is the exception: it DOES field `condition` / `consequence` /
|
||||
* `alternative` (#3354 — callable-flow capture reads them); position still works.
|
||||
* Every node-type literal below was grammar-validated against the vendored
|
||||
* tree-sitter-kotlin via the introspection probe before use (mandatory pre-step —
|
||||
* the grammar-literal CI gate maps `kotlin.ts → Kotlin` and fails on a wrong
|
||||
|
|
|
|||
|
|
@ -46,8 +46,8 @@
|
|||
* - reads: `attribute` (fields `object`/`attribute`), `subscript` (fields
|
||||
* `value`/`subscript`), `call` (fields `function`/`arguments`),
|
||||
* `boolean_operator` (fields `left`/`operator`/`right`),
|
||||
* `conditional_expression` (ternary: consequent / condition / alternative in
|
||||
* source order), `parenthesized_expression`.
|
||||
* `conditional_expression` (ternary, FIELDLESS: consequent, condition,
|
||||
* alternative as positional children), `parenthesized_expression`.
|
||||
*
|
||||
* TWO-PHASE, ORDER-INDEPENDENT (load-bearing — mirrors the TS / Go harvesters):
|
||||
* the CFG walk is NOT source-order, so resolving names against a scope stack
|
||||
|
|
|
|||
|
|
@ -119,6 +119,23 @@ const DART_CALLABLE_CAPTURE_OPTIONS = {
|
|||
extractCallCallee: (node: SyntaxNode) => dartCallableCallee(node) ?? undefined,
|
||||
callSiteNode: (node: SyntaxNode) => dartCallableCallee(node) ?? undefined,
|
||||
callableProtocolMethods: new Set(['call']),
|
||||
// tree-sitter-dart spells `a ?? b` as `if_null_expression(first:, second:)`
|
||||
// and `c ? a : b` as a `conditional_expression` with `consequence` /
|
||||
// `alternative` but NO `condition` field, so neither matches the shared
|
||||
// field-based branch rule and only the last operand flowed (#3354).
|
||||
valueAlternatives: (node: SyntaxNode) => {
|
||||
if (node.type === 'if_null_expression') {
|
||||
const first = node.childForFieldName('first');
|
||||
const second = node.childForFieldName('second');
|
||||
return first !== null && second !== null ? [first, second] : undefined;
|
||||
}
|
||||
if (node.type === 'conditional_expression') {
|
||||
const consequence = node.childForFieldName('consequence');
|
||||
const alternative = node.childForFieldName('alternative');
|
||||
return consequence !== null && alternative !== null ? [consequence, alternative] : undefined;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
} as const;
|
||||
|
||||
function dartLexicalFunctionOwner(input: SyntaxNode): SyntaxNode | undefined {
|
||||
|
|
|
|||
|
|
@ -89,8 +89,48 @@ const KOTLIN_CALLABLE_CAPTURE_OPTIONS = {
|
|||
return destination === undefined || source === undefined ? undefined : { destination, source };
|
||||
},
|
||||
normalizeQualifiedName: (raw: string) => raw.replaceAll('::', '.'),
|
||||
valueAlternatives: (node: SyntaxNode) => kotlinValueAlternatives(node),
|
||||
} as const;
|
||||
|
||||
const isKotlinComment = (node: SyntaxNode): boolean =>
|
||||
node.type === 'line_comment' || node.type === 'multiline_comment';
|
||||
|
||||
const namedNonComments = (node: SyntaxNode): SyntaxNode[] =>
|
||||
node.namedChildren.filter(
|
||||
(child): child is SyntaxNode => child !== null && !isKotlinComment(child),
|
||||
);
|
||||
|
||||
/**
|
||||
* Branches of a Kotlin value-selecting expression (#3354). `a ?: b` is a
|
||||
* FIELDLESS `elvis_expression` (positional operands), so the shared
|
||||
* field-based rule never saw it and only the last operand flowed. An
|
||||
* `if_expression` fields its branches as `control_structure_body` wrappers:
|
||||
* a bare branch (`if (c) ::f`) holds the expression directly, a braced one
|
||||
* (`if (c) { ::f }`) nests it one level deeper in a `statements` node. A
|
||||
* branch holding exactly one expression is that expression's value; an
|
||||
* empty or multi-statement block, or an `if` without `else`, keeps the
|
||||
* whole `if` one opaque source.
|
||||
*/
|
||||
function kotlinValueAlternatives(node: SyntaxNode): readonly SyntaxNode[] | undefined {
|
||||
if (node.type === 'elvis_expression') {
|
||||
const operands = namedNonComments(node);
|
||||
return operands.length === 2 ? operands : undefined;
|
||||
}
|
||||
if (node.type !== 'if_expression') return undefined;
|
||||
const branches: SyntaxNode[] = [];
|
||||
for (const field of ['consequence', 'alternative'] as const) {
|
||||
const body = node.childForFieldName(field);
|
||||
if (body === null) return [node];
|
||||
let values = namedNonComments(body);
|
||||
const [block] = values;
|
||||
if (values.length === 1 && block?.type === 'statements') values = namedNonComments(block);
|
||||
const [value] = values;
|
||||
if (values.length !== 1 || value === undefined) return [node];
|
||||
branches.push(value);
|
||||
}
|
||||
return branches;
|
||||
}
|
||||
|
||||
export function emitKotlinScopeCaptures(
|
||||
sourceText: string,
|
||||
filePath: string,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,20 @@ const PYTHON_CALLABLE_CAPTURE_OPTIONS = {
|
|||
assignmentNodeTypes: new Set(['assignment', 'named_expression']),
|
||||
identifierNodeTypes: new Set(['identifier']),
|
||||
functionScopedValueBindings: true,
|
||||
// `a if c else b` is a FIELDLESS `conditional_expression` (positional
|
||||
// value, condition, value), so the shared condition/consequence/alternative
|
||||
// rule never sees its branches and only the last operand flowed (#3354).
|
||||
// `a or b` is a fielded `boolean_operator` the shared rule already handles.
|
||||
valueAlternatives: (node: SyntaxNode) => {
|
||||
if (node.type !== 'conditional_expression') return undefined;
|
||||
const named = node.namedChildren.filter(
|
||||
(child): child is SyntaxNode => child !== null && child.type !== 'comment',
|
||||
);
|
||||
const [value, , alternative] = named;
|
||||
return named.length === 3 && value !== undefined && alternative !== undefined
|
||||
? [value, alternative]
|
||||
: undefined;
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function emitPythonScopeCaptures(
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ const RUBY_CALLABLE_CAPTURE_OPTIONS = {
|
|||
// Ruby (`action = process` stores process's RETURN value) — only explicit
|
||||
// reference forms (method(:x), &:x, lambda/proc) reference the callable.
|
||||
bareNamesAreCalls: true,
|
||||
valueAlternatives: (node: SyntaxNode) => rubyValueAlternatives(node),
|
||||
extractCallableReference: (node: SyntaxNode) => {
|
||||
if (node.type !== 'call') return undefined;
|
||||
const method = node.childForFieldName('method');
|
||||
|
|
@ -65,6 +66,40 @@ const RUBY_CALLABLE_CAPTURE_OPTIONS = {
|
|||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Branches of a statement-bodied `if` / `unless` / `elsif` (#3354). They field
|
||||
* `condition` / `consequence` / `alternative` like a ternary, but their
|
||||
* branches are `then` / `else` STATEMENT LISTS, so the shared ternary rule
|
||||
* would dig an identifier out of whichever statement it found (`g = h; 0`
|
||||
* flowed `h`, although the branch evaluates to `0`). A branch holding one
|
||||
* statement is that statement's value. A longer branch is skipped: it adds
|
||||
* no alternative, while the other branches still flow. Every alternative
|
||||
* emitted is a value the conditional really evaluates to, so skipping cannot
|
||||
* add an edge. It only drops the skipped branch's own value. The `elsif`
|
||||
* chain is walked here, so one multi-statement `elsif` no longer hides the
|
||||
* branches after it. When no branch is a single statement, the whole
|
||||
* conditional stays one opaque source. The `c ? a : b` ternary
|
||||
* (`conditional`) is left to the shared rule.
|
||||
*/
|
||||
function rubyValueAlternatives(node: SyntaxNode): readonly SyntaxNode[] | undefined {
|
||||
if (node.type !== 'if' && node.type !== 'unless' && node.type !== 'elsif') return undefined;
|
||||
const branches: SyntaxNode[] = [];
|
||||
for (let link: SyntaxNode | null = node; link !== null; ) {
|
||||
const consequence: SyntaxNode | null = link.childForFieldName('consequence');
|
||||
const alternative: SyntaxNode | null = link.childForFieldName('alternative');
|
||||
link = alternative?.type === 'elsif' ? alternative : null;
|
||||
for (const branch of [consequence, link === null ? alternative : null]) {
|
||||
if (branch === null) continue;
|
||||
const statements = branch.namedChildren.filter(
|
||||
(child): child is SyntaxNode => child !== null && child.type !== 'comment',
|
||||
);
|
||||
const [statement] = statements;
|
||||
if (statements.length === 1 && statement !== undefined) branches.push(statement);
|
||||
}
|
||||
}
|
||||
return branches.length > 0 ? branches : [node];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full `.`-joined qualified owner name for a heritage/attr call by
|
||||
* walking ALL enclosing class/module ancestors (not just the immediate one),
|
||||
|
|
|
|||
|
|
@ -109,6 +109,18 @@ const SWIFT_CALLABLE_CAPTURE_OPTIONS = {
|
|||
node.namedChildren.filter(
|
||||
(child): child is SyntaxNode => child !== null && child.type === 'parameter',
|
||||
),
|
||||
// tree-sitter-swift fields `a ?? b` as `value` / `if_nil` and `c ? a : b` as
|
||||
// `if_true` / `if_false`, neither of which the shared field-based branch
|
||||
// rule knows, so only the last operand flowed (#3354).
|
||||
valueAlternatives: (node: SyntaxNode) => {
|
||||
const [first, second] =
|
||||
node.type === 'nil_coalescing_expression'
|
||||
? [node.childForFieldName('value'), node.childForFieldName('if_nil')]
|
||||
: node.type === 'ternary_expression'
|
||||
? [node.childForFieldName('if_true'), node.childForFieldName('if_false')]
|
||||
: [null, null];
|
||||
return first !== null && second !== null ? [first, second] : undefined;
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** tree-sitter-swift node types that carry arity. */
|
||||
|
|
|
|||
|
|
@ -47,6 +47,12 @@
|
|||
* `extractAssignment` (Kotlin's `assignment`, Dart's
|
||||
* `initialized_identifier`). Returning `undefined` falls back to the shared
|
||||
* path, so one callback can handle the odd node and leave the rest alone.
|
||||
* - A **fieldless or oddly-fielded** `??`/`?:`/elvis/ternary node is invisible
|
||||
* to the shared branch expansion, so only one operand (if any) flows.
|
||||
* Supply `valueAlternatives` (Kotlin's `elvis_expression`, Swift's
|
||||
* `nil_coalescing_expression`, Dart's `if_null_expression`, Python's
|
||||
* `conditional_expression`); return `[node]` to keep a statement-bodied
|
||||
* `if` opaque (Ruby).
|
||||
* - A binding needs a `SymbolDefinition` for the pass to attach to. Captures
|
||||
* alone are not enough: without a `@declaration.*` for the bound name, the
|
||||
* seed has no cell to key on.
|
||||
|
|
@ -150,6 +156,20 @@ export interface CallableFlowCaptureOptions {
|
|||
readonly qualifiedName?: string;
|
||||
}
|
||||
| undefined;
|
||||
/**
|
||||
* Provider-owned branches of a value-selecting expression (#3354). The
|
||||
* shared rule only knows the field shapes `left`/`operator`/`right` (with a
|
||||
* `??`/`||`/`or` operator) and `condition`/`consequence`/`alternative`; a
|
||||
* grammar that spells the same construct differently (fieldless children,
|
||||
* `value`/`if_nil`, `first`/`second`, a ternary without a `condition`
|
||||
* field) supplies the branches here. Each returned branch is expanded again,
|
||||
* so chains work. Returning `[node]` means "recognized, but opaque": the
|
||||
* whole expression stays one source, which is how a provider keeps the
|
||||
* shared ternary rule off a statement-bodied `if` whose branches are
|
||||
* statement lists, not values. `undefined` falls back to the shared rule
|
||||
* (mirrors `extractAssignment`).
|
||||
*/
|
||||
readonly valueAlternatives?: (node: SyntaxNode) => readonly SyntaxNode[] | undefined;
|
||||
}
|
||||
|
||||
interface OperandSyntax {
|
||||
|
|
@ -195,6 +215,25 @@ interface ValueBindingIndex {
|
|||
string,
|
||||
ReadonlyMap<number, CallableCaptureSignature>
|
||||
>;
|
||||
/**
|
||||
* Every node id a visibility walk can stop at: the region ids of the three
|
||||
* maps above plus the formal owners. Any other ancestor fails every check,
|
||||
* so the walks jump from anchor to anchor instead of visiting it.
|
||||
*/
|
||||
readonly anchorIds: ReadonlySet<number>;
|
||||
/**
|
||||
* Nearest anchor at-or-above a node, memoized for the whole file. Each
|
||||
* alternative of a long `a || b || …` chain starts its walk at a leaf as
|
||||
* deep as the chain is long; without the memo every leaf re-walked the same
|
||||
* spine to the root, so the chain cost grew quadratically in its length.
|
||||
*/
|
||||
readonly nearestAnchorById: Map<number, SyntaxNode | null>;
|
||||
/**
|
||||
* Parent of every named node, recorded by the one DFS. tree-sitter's
|
||||
* `parent` is not a pointer read: it re-descends from the root, so it costs
|
||||
* the node's depth, and a leaf of a long chain is as deep as the chain.
|
||||
*/
|
||||
readonly parentById: ReadonlyMap<number, SyntaxNode>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -203,20 +242,33 @@ interface ValueBindingIndex {
|
|||
* One explicit DFS supplies all phases below. Query-backed emitters may still
|
||||
* perform their existing query walk; this helper never reparses and remains
|
||||
* linear in AST size (the scope-capture benchmark guards the scaling ratio).
|
||||
* That includes a value-selecting source: `valueAlternatives` expands a
|
||||
* chain iteratively into disjoint leaves, and each leaf's visibility walk
|
||||
* reuses the memoized anchor spine instead of re-walking to the root (the
|
||||
* `typescript-deep-chain` benchmark case guards that one).
|
||||
*/
|
||||
export function synthesizeCallableFlowCaptures(
|
||||
root: SyntaxNode,
|
||||
options: CallableFlowCaptureOptions,
|
||||
): readonly CaptureMatch[] {
|
||||
const nodes = collectNodes(root);
|
||||
const parentById = new Map<number, SyntaxNode>();
|
||||
const nodes = collectNodes(root, parentById);
|
||||
const functions = collectFunctions(nodes, options);
|
||||
const knownCallableNames = new Set(functions.map((fn) => fn.name));
|
||||
const assignments = collectAssignments(nodes, options);
|
||||
const valueBindings = buildValueBindingIndex(nodes, assignments, functions, options);
|
||||
const valueBindings = buildValueBindingIndex(nodes, parentById, assignments, functions, options);
|
||||
|
||||
const out: CaptureMatch[] = [];
|
||||
for (const assignment of assignments) {
|
||||
emitAssignmentFact(assignment, knownCallableNames, valueBindings, options, out);
|
||||
for (const source of valueAlternatives(assignment.source, options)) {
|
||||
emitAssignmentFact(
|
||||
{ ...assignment, source },
|
||||
knownCallableNames,
|
||||
valueBindings,
|
||||
options,
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const fn of functions) emitFormalFacts(fn, options, out);
|
||||
for (const node of nodes) {
|
||||
|
|
@ -232,7 +284,9 @@ export function synthesizeCallableFlowCaptures(
|
|||
return out;
|
||||
}
|
||||
|
||||
function collectNodes(root: SyntaxNode): SyntaxNode[] {
|
||||
/** Every named node in document order; also records each one's parent into
|
||||
* `parentById` (see `ValueBindingIndex.parentById`). */
|
||||
function collectNodes(root: SyntaxNode, parentById: Map<number, SyntaxNode>): SyntaxNode[] {
|
||||
const out: SyntaxNode[] = [];
|
||||
const stack: SyntaxNode[] = [root];
|
||||
while (stack.length > 0) {
|
||||
|
|
@ -241,7 +295,9 @@ function collectNodes(root: SyntaxNode): SyntaxNode[] {
|
|||
const children = node.namedChildren;
|
||||
for (let i = children.length - 1; i >= 0; i--) {
|
||||
const child = children[i];
|
||||
if (child !== null) stack.push(child);
|
||||
if (child === null) continue;
|
||||
parentById.set(child.id, node);
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
|
@ -325,6 +381,7 @@ function collectAssignments(
|
|||
|
||||
function buildValueBindingIndex(
|
||||
nodes: readonly SyntaxNode[],
|
||||
parentById: ReadonlyMap<number, SyntaxNode>,
|
||||
assignments: readonly AssignmentParts[],
|
||||
functions: readonly FunctionInfo[],
|
||||
options: CallableFlowCaptureOptions,
|
||||
|
|
@ -412,14 +469,56 @@ function buildValueBindingIndex(
|
|||
if (functionOwner !== undefined) byRegion.set(functionOwner.id, signature);
|
||||
}
|
||||
}
|
||||
const anchorIds = new Set<number>();
|
||||
for (const index of [assignmentRegionIdsByName, memberStoreRegionIdsByName]) {
|
||||
for (const regionIds of index.values()) for (const id of regionIds) anchorIds.add(id);
|
||||
}
|
||||
for (const byRegion of signatureByNameAndRegion.values()) {
|
||||
for (const id of byRegion.keys()) anchorIds.add(id);
|
||||
}
|
||||
for (const owner of formalByOwner.keys()) if (owner !== undefined) anchorIds.add(owner);
|
||||
return {
|
||||
assignmentRegionIdsByName,
|
||||
memberStoreRegionIdsByName,
|
||||
formalByOwner,
|
||||
signatureByNameAndRegion,
|
||||
anchorIds,
|
||||
parentById,
|
||||
nearestAnchorById: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
/** A node the DFS did not reach (none in practice) falls back to tree-sitter. */
|
||||
function parentOf(node: SyntaxNode, bindings: ValueBindingIndex): SyntaxNode | null {
|
||||
return bindings.parentById.get(node.id) ?? node.parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* The nearest anchor at-or-above `start` (see `ValueBindingIndex.anchorIds`),
|
||||
* or null when none is. Every node visited on the way is memoized, so across
|
||||
* one file each node's `parent` is taken at most once by these walks.
|
||||
*/
|
||||
function nearestAnchor(start: SyntaxNode | null, bindings: ValueBindingIndex): SyntaxNode | null {
|
||||
const visited: number[] = [];
|
||||
let node = start;
|
||||
let found: SyntaxNode | null = null;
|
||||
while (node !== null) {
|
||||
const cached = bindings.nearestAnchorById.get(node.id);
|
||||
if (cached !== undefined) {
|
||||
found = cached;
|
||||
break;
|
||||
}
|
||||
visited.push(node.id);
|
||||
if (bindings.anchorIds.has(node.id)) {
|
||||
found = node;
|
||||
break;
|
||||
}
|
||||
node = parentOf(node, bindings);
|
||||
}
|
||||
for (const id of visited) bindings.nearestAnchorById.set(id, found);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** True when a pointer/parenthesized declarator sits between the declaration
|
||||
* and its binding identifier — the shape of a callable-typed variable, never
|
||||
* of a plain prototype. Only C/C++ supply signature-declaration node types,
|
||||
|
|
@ -481,8 +580,11 @@ function isVisibleValueBinding(
|
|||
) {
|
||||
return true;
|
||||
}
|
||||
let node: SyntaxNode | null = input;
|
||||
while (node !== null) {
|
||||
for (
|
||||
let node = nearestAnchor(input, bindings);
|
||||
node !== null;
|
||||
node = nearestAnchor(parentOf(node, bindings), bindings)
|
||||
) {
|
||||
if (assignmentRegionIds?.has(node.id) === true) return true;
|
||||
if (
|
||||
options.functionNodeTypes.has(node.type) &&
|
||||
|
|
@ -490,7 +592,6 @@ function isVisibleValueBinding(
|
|||
) {
|
||||
return true;
|
||||
}
|
||||
node = node.parent;
|
||||
}
|
||||
if (bindings.formalByOwner.get(undefined)?.has(name) === true) return true;
|
||||
// A declared callable-typed binding (file-scope `void (*fp)(int);`) is a
|
||||
|
|
@ -520,10 +621,12 @@ function isVisibleMemberStore(
|
|||
if (regionIds !== undefined) {
|
||||
const providerOwner = options.lexicalFunctionOwner?.(input);
|
||||
if (providerOwner !== undefined && regionIds.has(providerOwner.id)) return true;
|
||||
let node: SyntaxNode | null = input;
|
||||
while (node !== null) {
|
||||
for (
|
||||
let node = nearestAnchor(input, bindings);
|
||||
node !== null;
|
||||
node = nearestAnchor(parentOf(node, bindings), bindings)
|
||||
) {
|
||||
if (regionIds.has(node.id)) return true;
|
||||
node = node.parent;
|
||||
}
|
||||
}
|
||||
return visibleCallableSignature(input, name, bindings, options) !== undefined;
|
||||
|
|
@ -542,11 +645,13 @@ function visibleCallableSignature(
|
|||
const signature = byRegion.get(providerOwner.id);
|
||||
if (signature !== undefined) return signature;
|
||||
}
|
||||
let node: SyntaxNode | null = input;
|
||||
while (node !== null) {
|
||||
for (
|
||||
let node = nearestAnchor(input, bindings);
|
||||
node !== null;
|
||||
node = nearestAnchor(parentOf(node, bindings), bindings)
|
||||
) {
|
||||
const signature = byRegion.get(node.id);
|
||||
if (signature !== undefined) return signature;
|
||||
node = node.parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -587,6 +692,115 @@ function assignmentParts(
|
|||
return [{ container: node, destination, source }];
|
||||
}
|
||||
|
||||
/** Operators whose result is one of their operands, not a computed value. */
|
||||
const VALUE_SELECTING_OPERATORS = new Set(['??', '||', 'or']);
|
||||
|
||||
/**
|
||||
* Operators that yield their left operand when it is falsy and their right
|
||||
* operand otherwise. A falsy value is never a callable, so only the RIGHT
|
||||
* operand can be the callable that is later invoked. Where `&&` / `and`
|
||||
* yields a boolean instead (Java, C#, Go, Rust, C, C++, PHP, Zig), the
|
||||
* destination is not callable, so the flow never meets an invoke.
|
||||
*/
|
||||
const RIGHT_SELECTING_OPERATORS = new Set(['&&', 'and']);
|
||||
|
||||
/**
|
||||
* The operands a value-selecting expression can evaluate to (#3354):
|
||||
* `a ?? b`, `a || b`, `a or b`, and `c ? a : b` each yield one of their
|
||||
* branches, so each branch flows into the destination. `a && b` / `a and b`
|
||||
* can only yield a callable through `b`, so `x and f or g` reaches `f` and `g`.
|
||||
* Anything else is its own single alternative, which leaves every other
|
||||
* source shape untouched. A branch that is itself an operator expression
|
||||
* (`x.kind === f || g`) yields a computed value, so it contributes nothing.
|
||||
* `options.valueAlternatives` is consulted first for grammars whose shape the
|
||||
* field-based rule below cannot see.
|
||||
*/
|
||||
function valueAlternatives(
|
||||
node: SyntaxNode,
|
||||
options: CallableFlowCaptureOptions,
|
||||
): readonly SyntaxNode[] {
|
||||
// Explicit stack, not recursion: `a || b || …` nests one level per operand,
|
||||
// so a generated keyword table thousands of operands long overflowed the
|
||||
// call stack (and re-copied every partial result at each level).
|
||||
const out: SyntaxNode[] = [];
|
||||
const pending: SyntaxNode[] = [node];
|
||||
for (let current = pending.pop(); current !== undefined; current = pending.pop()) {
|
||||
const branches = valueBranches(current, options);
|
||||
if (branches === undefined) {
|
||||
// An operator branch is opaque, as the whole compound source was before
|
||||
// the fan-out: emitted alone, `x.kind === Handlers.run` becomes a seed
|
||||
// whose qualified text slices to receiver `Handlers`, member `run`.
|
||||
if (current === node || !isBinaryOperatorExpression(unwrapParentheses(current))) {
|
||||
out.push(current);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Reverse push keeps the left-to-right branch order on output.
|
||||
for (let i = branches.length - 1; i >= 0; i--) {
|
||||
const branch = branches[i];
|
||||
if (branch !== undefined) pending.push(branch);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One level of `valueAlternatives`: the branches `node` selects between, or
|
||||
* undefined when it is opaque (its own single alternative). */
|
||||
function valueBranches(
|
||||
node: SyntaxNode,
|
||||
options: CallableFlowCaptureOptions,
|
||||
): readonly SyntaxNode[] | undefined {
|
||||
const inner = unwrapParentheses(node);
|
||||
const provided = options.valueAlternatives?.(inner);
|
||||
if (provided !== undefined) {
|
||||
return provided.length === 1 && provided[0]?.id === inner.id ? undefined : provided;
|
||||
}
|
||||
const consequence = inner.childForFieldName('consequence');
|
||||
const alternative = inner.childForFieldName('alternative');
|
||||
if (consequence !== null && alternative !== null && inner.childForFieldName('condition')) {
|
||||
return [consequence, alternative];
|
||||
}
|
||||
const left = inner.childForFieldName('left');
|
||||
const right = inner.childForFieldName('right');
|
||||
const operator = inner.childForFieldName('operator')?.type;
|
||||
if (left === null || right === null || !operator) return undefined;
|
||||
if (VALUE_SELECTING_OPERATORS.has(operator)) return [left, right];
|
||||
if (RIGHT_SELECTING_OPERATORS.has(operator)) return [right];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function unwrapParentheses(node: SyntaxNode): SyntaxNode {
|
||||
let inner = node;
|
||||
while (inner.type.includes('parenthesized') && inner.namedChildCount === 1) {
|
||||
const child = inner.namedChild(0);
|
||||
if (child === null) break;
|
||||
inner = child;
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for an expression that computes a value from two operands (`a === b`,
|
||||
* `a + b`, `a is b`), which designates neither operand. Read from the same
|
||||
* field vocabulary as `valueBranches` rather than grammar type names: a
|
||||
* `left`/`right` pair, or an operator token (`operator`, Python's
|
||||
* `operators`, Swift's `op`) that follows the expression's start. A member
|
||||
* access that fields its `.` / `->` as `operator` (Ruby `call`, C/C++
|
||||
* `field_expression`) also fields its member name, so it stays a designator;
|
||||
* a unary `&f` / `*fp` leads with its operator and stays one too.
|
||||
*/
|
||||
function isBinaryOperatorExpression(node: SyntaxNode): boolean {
|
||||
if (node.childForFieldName('left') !== null && node.childForFieldName('right') !== null) {
|
||||
return true;
|
||||
}
|
||||
if (memberNameNode(node) !== null) return false;
|
||||
const operator =
|
||||
node.childForFieldName('operator') ??
|
||||
node.childForFieldName('operators') ??
|
||||
node.childForFieldName('op');
|
||||
return operator !== null && operator.startIndex > node.startIndex;
|
||||
}
|
||||
|
||||
function emitAssignmentFact(
|
||||
assignment: AssignmentParts,
|
||||
knownCallableNames: ReadonlySet<string>,
|
||||
|
|
@ -1021,11 +1235,7 @@ function memberParts(
|
|||
// stay unaffected. Without it every `x.f(arg)` in such a grammar collapsed
|
||||
// to a DIRECT call named `f` and the flow solver fanned the argument out to
|
||||
// every same-named callable.
|
||||
const memberNode =
|
||||
node.childForFieldName('property') ??
|
||||
node.childForFieldName('field') ??
|
||||
node.childForFieldName('method') ??
|
||||
node.childForFieldName('member');
|
||||
const memberNode = memberNameNode(node);
|
||||
if (receiverNode === null || memberNode === null) return undefined;
|
||||
const receiver = operandSyntax(receiverNode, options);
|
||||
const member = operandSyntax(memberNode, options);
|
||||
|
|
@ -1038,6 +1248,17 @@ function memberParts(
|
|||
return { receiver, member, ...(operator !== undefined ? { operator: operator.text } : {}) };
|
||||
}
|
||||
|
||||
/** The member-name child of a member access, under the field names the
|
||||
* grammars use for it. */
|
||||
function memberNameNode(node: SyntaxNode): SyntaxNode | null {
|
||||
return (
|
||||
node.childForFieldName('property') ??
|
||||
node.childForFieldName('field') ??
|
||||
node.childForFieldName('method') ??
|
||||
node.childForFieldName('member')
|
||||
);
|
||||
}
|
||||
|
||||
function operandSyntax(
|
||||
input: SyntaxNode,
|
||||
options: CallableFlowCaptureOptions,
|
||||
|
|
|
|||
|
|
@ -303,6 +303,15 @@ function resolveAliasString(canonical: unknown, legacy: unknown): string | undef
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `*_uid` param as a lookup key: trimmed, or `undefined` when it is blank or
|
||||
* not a string (#3354). Strict adapters send `" "`/`""` for an omitted optional
|
||||
* string, and the MCP envelope is not type-validated.
|
||||
*/
|
||||
function nonBlankUid(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value.trim() || undefined : undefined;
|
||||
}
|
||||
|
||||
interface StringAliasDefinition {
|
||||
canonical: string;
|
||||
aliases: readonly string[];
|
||||
|
|
@ -4463,7 +4472,11 @@ export class LocalBackend {
|
|||
}
|
||||
| { kind: 'not_found' }
|
||||
> {
|
||||
const { uid, name, include_content } = query;
|
||||
const { name, include_content } = query;
|
||||
// A blank or non-string uid is omitted, not a lookup key — fall through to
|
||||
// the name instead of `not_found`, matching normalizeToolParams' impact
|
||||
// target_uid check.
|
||||
const uid = nonBlankUid(query.uid);
|
||||
const selectClause = `n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}`;
|
||||
|
||||
// Direct UID — zero-ambiguity path.
|
||||
|
|
@ -6819,7 +6832,7 @@ export class LocalBackend {
|
|||
if (fromOutcome.kind === 'not_found') {
|
||||
return {
|
||||
status: 'not_found',
|
||||
error: `Source symbol '${params.from_uid ?? params.from}' not found.`,
|
||||
error: `Source symbol '${nonBlankUid(params.from_uid) ?? params.from}' not found.`,
|
||||
suggestion: 'Check the symbol name or use --from-uid for zero-ambiguity.',
|
||||
};
|
||||
}
|
||||
|
|
@ -6846,7 +6859,7 @@ export class LocalBackend {
|
|||
if (toOutcome.kind === 'not_found') {
|
||||
return {
|
||||
status: 'not_found',
|
||||
error: `Target symbol '${params.to_uid ?? params.to}' not found.`,
|
||||
error: `Target symbol '${nonBlankUid(params.to_uid) ?? params.to}' not found.`,
|
||||
suggestion: 'Check the symbol name or use --to-uid for zero-ambiguity.',
|
||||
};
|
||||
}
|
||||
|
|
@ -7226,7 +7239,7 @@ export class LocalBackend {
|
|||
);
|
||||
|
||||
if (outcome.kind === 'not_found') {
|
||||
const missing = params.target_uid ?? target;
|
||||
const missing = nonBlankUid(params.target_uid) ?? target;
|
||||
// not_found = no resolved symbol, so the envelope keeps the partial-but-
|
||||
// typed target (typed PdgImpactTarget — there is no id/type/filePath yet).
|
||||
const notFoundTarget: PdgImpactTarget = { name: target };
|
||||
|
|
@ -8049,6 +8062,14 @@ export class LocalBackend {
|
|||
const confidenceFilter = safeMinConfidence > 0 ? ' AND r.confidence >= $minConfidence' : '';
|
||||
|
||||
const symId = sym.id || sym[0];
|
||||
// #3354: a walk with no anchor id cannot say anything about THIS symbol,
|
||||
// yet it still ships a normal-looking `exact` result with the target's
|
||||
// name echoed back. Throw so every caller's catch reports UNKNOWN instead.
|
||||
if (!symId) {
|
||||
throw new Error(
|
||||
`Impact target '${sym.name || sym[1] || '?'}' resolved without a node id; refusing to report a blast radius`,
|
||||
);
|
||||
}
|
||||
|
||||
// #1858 — kick off the epistemic boundary probe concurrently with the BFS.
|
||||
// It depends only on symId/symType/symName (all known now) and touches no
|
||||
|
|
|
|||
|
|
@ -778,7 +778,14 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
|
|||
// v104 (#3339 review): TS/JS pair-HOC queries now name object-pair
|
||||
// `mutation(withAuth(arrow))` handlers. Warm caches replay the pre-fix
|
||||
// capture set (anonymous arrows, no Function name), so both stores re-extract.
|
||||
const SCHEMA_BUMP = 104;
|
||||
// v112 (#3354): callable-value flow now follows each branch of `a ?? f`,
|
||||
// `a || f`, and `c ? f : g`. Warm caches replay the pre-fix flow facts, which
|
||||
// have no flow for those assignments, so both stores re-extract. 105-111 are
|
||||
// claimed by open PR #3326 (Elixir).
|
||||
// Same v112: the `valueAlternatives` provider hook extends it to Kotlin
|
||||
// `?:`/`if`, Swift/Dart `??`/`?:`, and Python `x if c else y`, and keeps a
|
||||
// Ruby multi-statement `if` one opaque source.
|
||||
const SCHEMA_BUMP = 112;
|
||||
const GITNEXUS_PKG_VERSION = (() => {
|
||||
try {
|
||||
// package.json sits at gitnexus/package.json — two levels up from
|
||||
|
|
|
|||
20
gitnexus/test/fixtures/lang-resolution/dart-callable-alternatives/app.dart
vendored
Normal file
20
gitnexus/test/fixtures/lang-resolution/dart-callable-alternatives/app.dart
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
void runSweep() {}
|
||||
void runThen() {}
|
||||
void runElse() {}
|
||||
|
||||
void ifNull(void Function()? override) {
|
||||
final run = override ?? runSweep;
|
||||
run();
|
||||
}
|
||||
|
||||
void runLeft() {}
|
||||
|
||||
void callableLeft(void Function() fallback) {
|
||||
final run = runLeft ?? fallback;
|
||||
run();
|
||||
}
|
||||
|
||||
void conditional(bool fast) {
|
||||
final run = fast ? runThen : runElse;
|
||||
run();
|
||||
}
|
||||
37
gitnexus/test/fixtures/lang-resolution/kotlin-callable-alternatives/App.kt
vendored
Normal file
37
gitnexus/test/fixtures/lang-resolution/kotlin-callable-alternatives/App.kt
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
fun runSweep() {}
|
||||
fun runThen() {}
|
||||
fun runElse() {}
|
||||
|
||||
fun elvis(override: (() -> Unit)?) {
|
||||
val run = override ?: ::runSweep
|
||||
run()
|
||||
}
|
||||
|
||||
fun runLeft() {}
|
||||
|
||||
fun callableLeft(fallback: () -> Unit) {
|
||||
val run = ::runLeft ?: fallback
|
||||
run()
|
||||
}
|
||||
|
||||
fun ifExpression(fast: Boolean) {
|
||||
val run = if (fast) ::runThen else ::runElse
|
||||
run()
|
||||
}
|
||||
|
||||
fun runBracedThen() {}
|
||||
fun runBracedElse() {}
|
||||
|
||||
fun braced(fast: Boolean) {
|
||||
val run = if (fast) { ::runBracedThen } else { ::runBracedElse }
|
||||
run()
|
||||
}
|
||||
|
||||
fun log() {}
|
||||
fun runBlockThen() {}
|
||||
fun runBlockElse() {}
|
||||
|
||||
fun multiStatement(fast: Boolean) {
|
||||
val run = if (fast) { log(); ::runBlockThen } else { ::runBlockElse }
|
||||
run()
|
||||
}
|
||||
71
gitnexus/test/fixtures/lang-resolution/python-callable-alternatives/app.py
vendored
Normal file
71
gitnexus/test/fixtures/lang-resolution/python-callable-alternatives/app.py
vendored
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
def run_sweep():
|
||||
pass
|
||||
|
||||
|
||||
def run_then():
|
||||
pass
|
||||
|
||||
|
||||
def run_else():
|
||||
pass
|
||||
|
||||
|
||||
def run_and():
|
||||
pass
|
||||
|
||||
|
||||
def run_or_else():
|
||||
pass
|
||||
|
||||
|
||||
def run_left():
|
||||
pass
|
||||
|
||||
|
||||
def callable_left(fallback):
|
||||
run = run_left or fallback
|
||||
run()
|
||||
|
||||
|
||||
def logical_or(override):
|
||||
run = override or run_sweep
|
||||
run()
|
||||
|
||||
|
||||
def ternary(fast):
|
||||
run = run_then if fast else run_else
|
||||
run()
|
||||
|
||||
|
||||
def and_or(ready):
|
||||
run = ready and run_and or run_or_else
|
||||
run()
|
||||
|
||||
|
||||
# A comparison branch of `or` yields a bool, never what it compares against:
|
||||
# none of these may reach `run`; the `self.fallback` branch still flows.
|
||||
class Handlers:
|
||||
@staticmethod
|
||||
def run():
|
||||
pass
|
||||
|
||||
|
||||
def comparison_branch(x, fb):
|
||||
h = x.kind == Handlers.run or fb
|
||||
h()
|
||||
|
||||
|
||||
class Machine:
|
||||
def run(self):
|
||||
pass
|
||||
|
||||
def fallback(self):
|
||||
pass
|
||||
|
||||
def self_comparison(self):
|
||||
h = self.state != self.run or self.fallback
|
||||
h()
|
||||
|
||||
def bare_comparison(self, run):
|
||||
h = self.state != run or self.fallback
|
||||
h()
|
||||
38
gitnexus/test/fixtures/lang-resolution/ruby-callable-alternatives/app.rb
vendored
Normal file
38
gitnexus/test/fixtures/lang-resolution/ruby-callable-alternatives/app.rb
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
def run_other; end
|
||||
def run_sweep; end
|
||||
def run_then; end
|
||||
def run_else; end
|
||||
def run_a; end
|
||||
def run_b; end
|
||||
def run_inner; end
|
||||
|
||||
# Each branch holds one statement, so each branch is the value.
|
||||
def single_statement_if(fast)
|
||||
run = if fast
|
||||
method(:run_then)
|
||||
else
|
||||
method(:run_else)
|
||||
end
|
||||
run.call
|
||||
end
|
||||
|
||||
# The `then` branch evaluates to 0; `h` is only read by an inner statement,
|
||||
# so it must not flow into `run`. The single-statement `else` still does.
|
||||
def statement_if(fast)
|
||||
h = method(:run_other)
|
||||
run = if fast
|
||||
g = h
|
||||
0
|
||||
else
|
||||
method(:run_sweep)
|
||||
end
|
||||
run.call
|
||||
end
|
||||
|
||||
# The multi-statement `elsif` branch is skipped; the branches on either side
|
||||
# of it still flow, and nothing inside it does.
|
||||
def elsif_chain(a, b)
|
||||
h = method(:run_inner)
|
||||
run = if a then method(:run_a) elsif b then g = h; 0 else method(:run_b) end
|
||||
run.call
|
||||
end
|
||||
20
gitnexus/test/fixtures/lang-resolution/swift-callable-alternatives/App.swift
vendored
Normal file
20
gitnexus/test/fixtures/lang-resolution/swift-callable-alternatives/App.swift
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
func runSweep() {}
|
||||
func runThen() {}
|
||||
func runElse() {}
|
||||
|
||||
func nilCoalescing(override: (() -> Void)?) {
|
||||
let run = override ?? runSweep
|
||||
run()
|
||||
}
|
||||
|
||||
func runLeft() {}
|
||||
|
||||
func callableLeft(fallback: @escaping () -> Void) {
|
||||
let run = runLeft ?? fallback
|
||||
run()
|
||||
}
|
||||
|
||||
func ternary(fast: Bool) {
|
||||
let run = fast ? runThen : runElse
|
||||
run()
|
||||
}
|
||||
42
gitnexus/test/fixtures/lang-resolution/typescript-callable-alternatives/compare.ts
vendored
Normal file
42
gitnexus/test/fixtures/lang-resolution/typescript-callable-alternatives/compare.ts
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// A branch of `||` / `??` / `?:` that is a comparison yields a boolean, never
|
||||
// the member it compares against. None of these bindings may reach `run`.
|
||||
export class Handlers {
|
||||
static run(x: unknown): void {}
|
||||
static fallback(x: unknown): void {}
|
||||
}
|
||||
|
||||
export async function comparisonBranch(x: { kind: unknown }, fb: (x: unknown) => void) {
|
||||
const h = x.kind === Handlers.run || fb;
|
||||
h(x);
|
||||
}
|
||||
|
||||
export async function staticComparison(run: unknown, fb: (x: unknown) => void) {
|
||||
const h = Handlers.fallback === run || fb;
|
||||
h(run);
|
||||
}
|
||||
|
||||
export class Machine {
|
||||
state: unknown;
|
||||
run(): void {}
|
||||
fallback(): void {}
|
||||
|
||||
thisComparison() {
|
||||
const h = this.state !== this.run || this.fallback;
|
||||
h();
|
||||
}
|
||||
|
||||
bareComparison(run: unknown) {
|
||||
const h = this.state !== run ?? this.fallback;
|
||||
h();
|
||||
}
|
||||
|
||||
arithmeticBranch(run: number) {
|
||||
const h = this.state + run || this.fallback;
|
||||
h();
|
||||
}
|
||||
|
||||
ternaryComparison(fast: boolean) {
|
||||
const h = fast ? this.state === this.run : this.fallback;
|
||||
h();
|
||||
}
|
||||
}
|
||||
56
gitnexus/test/fixtures/lang-resolution/typescript-callable-alternatives/index.ts
vendored
Normal file
56
gitnexus/test/fixtures/lang-resolution/typescript-callable-alternatives/index.ts
vendored
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import {
|
||||
runSweep,
|
||||
runAlias,
|
||||
runOr,
|
||||
runThen,
|
||||
runElse,
|
||||
runChained,
|
||||
runParen,
|
||||
runLeft,
|
||||
runAndLeft,
|
||||
runAndRight,
|
||||
} from './sweep';
|
||||
|
||||
type Handler = (env: unknown) => Promise<void>;
|
||||
|
||||
export async function aliasOnly(env: unknown) {
|
||||
const run = runAlias;
|
||||
await run(env);
|
||||
}
|
||||
|
||||
export async function nullish(env: { __sweep?: Handler }) {
|
||||
const sweep = env.__sweep ?? runSweep;
|
||||
await sweep(env);
|
||||
}
|
||||
|
||||
export async function logicalOr(env: { override?: Handler }) {
|
||||
const run = env.override || runOr;
|
||||
await run(env);
|
||||
}
|
||||
|
||||
export async function ternary(env: unknown, fast: boolean) {
|
||||
const run = fast ? runThen : runElse;
|
||||
await run(env);
|
||||
}
|
||||
|
||||
export async function chained(env: { a?: Handler; b?: Handler }) {
|
||||
const run = env.a ?? env.b ?? runChained;
|
||||
await run(env);
|
||||
}
|
||||
|
||||
export async function parenthesized(env: { a?: Handler }) {
|
||||
const run = (env.a ?? runParen);
|
||||
await run(env);
|
||||
}
|
||||
|
||||
export async function callableLeft(env: { fallback: Handler }) {
|
||||
const run = runLeft ?? env.fallback;
|
||||
await run(env);
|
||||
}
|
||||
|
||||
// `a && b` yields `a` when it is falsy and `b` otherwise. A falsy value is
|
||||
// never a callable, so only the right operand can be the one invoked.
|
||||
export async function logicalAnd(env: unknown) {
|
||||
const run = runAndLeft && runAndRight;
|
||||
await run(env);
|
||||
}
|
||||
10
gitnexus/test/fixtures/lang-resolution/typescript-callable-alternatives/sweep/index.ts
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/typescript-callable-alternatives/sweep/index.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export async function runSweep(env: unknown): Promise<void> {}
|
||||
export async function runAlias(env: unknown): Promise<void> {}
|
||||
export async function runOr(env: unknown): Promise<void> {}
|
||||
export async function runThen(env: unknown): Promise<void> {}
|
||||
export async function runElse(env: unknown): Promise<void> {}
|
||||
export async function runChained(env: unknown): Promise<void> {}
|
||||
export async function runParen(env: unknown): Promise<void> {}
|
||||
export async function runLeft(env: unknown): Promise<void> {}
|
||||
export async function runAndLeft(env: unknown): Promise<void> {}
|
||||
export async function runAndRight(env: unknown): Promise<void> {}
|
||||
10
gitnexus/test/fixtures/lang-resolution/typescript-callable-alternatives/worker.ts
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/typescript-callable-alternatives/worker.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { runSweep } from './sweep';
|
||||
|
||||
// The reporter's shape: a Cloudflare worker's default-export object whose
|
||||
// `scheduled` handler lets tests inject a replacement sweep.
|
||||
export default {
|
||||
async scheduled(_c: unknown, env: { __sweep?: typeof runSweep }) {
|
||||
const sweep = env.__sweep ?? runSweep;
|
||||
await sweep(env);
|
||||
},
|
||||
};
|
||||
|
|
@ -95,6 +95,10 @@
|
|||
"captureGroups": 9,
|
||||
"digest": "9c7f1c54a9b0ba3bfa4410e1843bf5edb570777789ee659a00d7dd6771f3670f"
|
||||
},
|
||||
"python-callable-alternatives/app.py": {
|
||||
"captureGroups": 85,
|
||||
"digest": "d5fc223832a33ff701f09b00174bdf1e127063aae9bdead0e5d242e8bd8e12ef"
|
||||
},
|
||||
"python-calls/one.py": {
|
||||
"captureGroups": 4,
|
||||
"digest": "b99c3406b4b8555cc521b27bab98eefed02a121dc2e878dd4f63133088603dc5"
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@
|
|||
"captureGroups": 18,
|
||||
"digest": "194c1ca21a7d5d8d85cf9aecfdc4edd881c5d3447de1ad574b56cff5a411bab1"
|
||||
},
|
||||
"ruby-callable-alternatives/app.rb": {
|
||||
"captureGroups": 58,
|
||||
"digest": "7a7fcac9fd4dae83dfc78eba2864d06b48918eb42713c0429081fdd986b7e8ba"
|
||||
},
|
||||
"ruby-calls/lib/one_arg.rb": {
|
||||
"captureGroups": 8,
|
||||
"digest": "61300083e7a2e6d9a116c3975ddd3b78a593a9a0e397c252bedb160d481c20b7"
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@
|
|||
"captureGroups": 15,
|
||||
"digest": "f192ae885ea3de848f3b93acf7672d91b0118800cb0c6513628a6c09c26fadbf"
|
||||
},
|
||||
"swift-callable-alternatives/App.swift": {
|
||||
"captureGroups": 36,
|
||||
"digest": "3720e42c769ba4f6fa2becfe55f10109e170e8ac274ce24e69b04b7b23a25a8a"
|
||||
},
|
||||
"swift-child-extends-parent/Sources/App.swift": {
|
||||
"captureGroups": 11,
|
||||
"digest": "4841a7a147f5cac64ed70af845534845bf34701af4bc55a6a9f02fb09ad4b716"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
/**
|
||||
* Callable chosen by a value-selecting expression, per provider (#3354).
|
||||
*
|
||||
* The shared branch expansion in `callable-flow-captures.ts` keys on tree-sitter
|
||||
* field names (`left`/`operator`/`right`, `condition`/`consequence`/
|
||||
* `alternative`). Grammars that spell `??` / `?:` / elvis / ternary without
|
||||
* those fields supply their branches through the `valueAlternatives` provider
|
||||
* hook; without it only the LAST operand flowed and `impact` under-reported
|
||||
* callers while still claiming `epistemic: "exact"`. Ruby's statement-bodied
|
||||
* `if` shares the ternary's field names but its branches are statement lists,
|
||||
* so its hook only expands single-statement branches and skips the rest.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
FIXTURES,
|
||||
getRelationships,
|
||||
edgeSet,
|
||||
runPipelineFromRepo,
|
||||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
import {
|
||||
isLanguageAvailable,
|
||||
loadParser,
|
||||
loadLanguage,
|
||||
} from '../../../src/core/tree-sitter/parser-loader.js';
|
||||
import { SupportedLanguages } from '../../../src/config/supported-languages.js';
|
||||
|
||||
// Kotlin, Swift and Dart grammars are optional installs; skip their suites
|
||||
// when the grammar did not load, as the per-language resolver suites do.
|
||||
const kotlinAvailable = isLanguageAvailable(SupportedLanguages.Kotlin);
|
||||
const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift);
|
||||
// A loaded tree-sitter-dart module can still fail on setLanguage, so probe the
|
||||
// parser too (same guard as dart.test.ts).
|
||||
let dartAvailable = isLanguageAvailable(SupportedLanguages.Dart);
|
||||
if (dartAvailable) {
|
||||
try {
|
||||
await loadParser();
|
||||
await loadLanguage(SupportedLanguages.Dart);
|
||||
} catch {
|
||||
dartAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
const runFixture = (name: string): Promise<PipelineResult> =>
|
||||
runPipelineFromRepo(path.join(FIXTURES, name), () => {});
|
||||
|
||||
// Only flow edges count: Kotlin's `::fn` reference alone already yields a
|
||||
// `local-call` edge to fn, which would make its assertions vacuous.
|
||||
const callsOf = (result: PipelineResult): string[] =>
|
||||
edgeSet(
|
||||
getRelationships(result, 'CALLS').filter((edge) => edge.rel.reason === 'callable-value-flow'),
|
||||
);
|
||||
|
||||
describe('Python callable chosen by `or` / `and` / `x if c else y`', () => {
|
||||
let result: PipelineResult;
|
||||
beforeAll(async () => {
|
||||
result = await runFixture('python-callable-alternatives');
|
||||
}, 60000);
|
||||
|
||||
it('`override or fn` reaches fn', () => {
|
||||
expect(callsOf(result)).toContain('logical_or → run_sweep');
|
||||
});
|
||||
|
||||
// The `override or fn` case above resolves only its RIGHT operand, so it
|
||||
// would still pass if the fan-out kept just the last branch.
|
||||
it('`fn or fallback` reaches fn through the LEFT operand', () => {
|
||||
expect(callsOf(result)).toContain('callable_left → run_left');
|
||||
});
|
||||
|
||||
it('`f if c else g` reaches both branches', () => {
|
||||
expect(callsOf(result)).toEqual(
|
||||
expect.arrayContaining(['ternary → run_then', 'ternary → run_else']),
|
||||
);
|
||||
});
|
||||
|
||||
it('`x and f or g` reaches f and g, never x', () => {
|
||||
expect(callsOf(result).filter((edge) => edge.startsWith('and_or → '))).toEqual([
|
||||
'and_or → run_and',
|
||||
'and_or → run_or_else',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a comparison branch of `or` flows nothing, while its designator sibling still does', () => {
|
||||
expect(
|
||||
callsOf(result).filter(
|
||||
(edge) =>
|
||||
edge.startsWith('comparison_branch → ') ||
|
||||
edge.startsWith('self_comparison → ') ||
|
||||
edge.startsWith('bare_comparison → '),
|
||||
),
|
||||
).toEqual(['bare_comparison → fallback', 'self_comparison → fallback']);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!kotlinAvailable)('Kotlin callable chosen by `?:` / `if` expression', () => {
|
||||
let result: PipelineResult;
|
||||
beforeAll(async () => {
|
||||
result = await runFixture('kotlin-callable-alternatives');
|
||||
}, 60000);
|
||||
|
||||
it('`override ?: ::fn` reaches fn', () => {
|
||||
expect(callsOf(result)).toContain('elvis → runSweep');
|
||||
});
|
||||
|
||||
it('`::fn ?: fallback` reaches fn through the LEFT operand', () => {
|
||||
expect(callsOf(result)).toContain('callableLeft → runLeft');
|
||||
});
|
||||
|
||||
it('`if (c) ::f else ::g` reaches both branches', () => {
|
||||
expect(callsOf(result)).toEqual(
|
||||
expect.arrayContaining(['ifExpression → runThen', 'ifExpression → runElse']),
|
||||
);
|
||||
});
|
||||
|
||||
// A braced branch nests its value in a `statements` node one level below
|
||||
// the `control_structure_body` wrapper.
|
||||
it('`if (c) { ::f } else { ::g }` reaches both branches', () => {
|
||||
expect(callsOf(result)).toEqual(
|
||||
expect.arrayContaining(['braced → runBracedThen', 'braced → runBracedElse']),
|
||||
);
|
||||
});
|
||||
|
||||
it('a multi-statement branch keeps the whole `if` opaque', () => {
|
||||
expect(callsOf(result).filter((edge) => edge.startsWith('multiStatement →'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!swiftAvailable)('Swift callable chosen by `??` / `?:`', () => {
|
||||
let result: PipelineResult;
|
||||
beforeAll(async () => {
|
||||
result = await runFixture('swift-callable-alternatives');
|
||||
}, 60000);
|
||||
|
||||
it('`override ?? fn` reaches fn', () => {
|
||||
expect(callsOf(result)).toContain('nilCoalescing → runSweep');
|
||||
});
|
||||
|
||||
it('`fn ?? fallback` reaches fn through the LEFT operand', () => {
|
||||
expect(callsOf(result)).toContain('callableLeft → runLeft');
|
||||
});
|
||||
|
||||
it('`c ? f : g` reaches both branches', () => {
|
||||
expect(callsOf(result)).toEqual(
|
||||
expect.arrayContaining(['ternary → runThen', 'ternary → runElse']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dartAvailable)('Dart callable chosen by `??` / `?:`', () => {
|
||||
let result: PipelineResult;
|
||||
beforeAll(async () => {
|
||||
result = await runFixture('dart-callable-alternatives');
|
||||
}, 60000);
|
||||
|
||||
it('`override ?? fn` reaches fn', () => {
|
||||
expect(callsOf(result)).toContain('ifNull → runSweep');
|
||||
});
|
||||
|
||||
it('`fn ?? fallback` reaches fn through the LEFT operand', () => {
|
||||
expect(callsOf(result)).toContain('callableLeft → runLeft');
|
||||
});
|
||||
|
||||
it('`c ? f : g` reaches both branches', () => {
|
||||
expect(callsOf(result)).toEqual(
|
||||
expect.arrayContaining(['conditional → runThen', 'conditional → runElse']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ruby statement-bodied `if` as a callable source', () => {
|
||||
let result: PipelineResult;
|
||||
beforeAll(async () => {
|
||||
result = await runFixture('ruby-callable-alternatives');
|
||||
}, 60000);
|
||||
|
||||
it('single-statement branches each reach their callable', () => {
|
||||
expect(callsOf(result)).toEqual(
|
||||
expect.arrayContaining(['single_statement_if → run_then', 'single_statement_if → run_else']),
|
||||
);
|
||||
});
|
||||
|
||||
it('an identifier read inside a multi-statement branch does not flow into the binding', () => {
|
||||
expect(callsOf(result)).not.toContain('statement_if → run_other');
|
||||
});
|
||||
|
||||
it('a multi-statement branch is skipped while its sibling branch still flows', () => {
|
||||
expect(callsOf(result)).toContain('statement_if → run_sweep');
|
||||
});
|
||||
|
||||
it('a multi-statement `elsif` does not hide the branches around it', () => {
|
||||
expect(callsOf(result)).toEqual(
|
||||
expect.arrayContaining(['elsif_chain → run_a', 'elsif_chain → run_b']),
|
||||
);
|
||||
expect(callsOf(result)).not.toContain('elsif_chain → run_inner');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* TypeScript: a callable chosen by a value-selecting expression (#3354).
|
||||
*
|
||||
* `const sweep = env.__sweep ?? runSweep; await sweep(env)` is how the
|
||||
* reporter's Cloudflare worker made its sweep injectable in tests. The
|
||||
* callable-value flow only accepted a single designator on the right-hand
|
||||
* side, so the `??` produced no flow, `scheduled` never showed up as a caller
|
||||
* of `runSweep`, and `impact` answered with one caller fewer while still
|
||||
* claiming `epistemic: "exact"`. Each branch of `??`, `||`, and `?:` can be
|
||||
* the value that is later invoked, so each branch is a flow into the binding.
|
||||
* `a && b` can only yield a callable through `b`, so only `b` flows.
|
||||
* A branch that is itself a comparison or arithmetic expression yields a
|
||||
* computed value, so it flows nothing (compare.ts).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
FIXTURES,
|
||||
getRelationships,
|
||||
edgeSet,
|
||||
runPipelineFromRepo,
|
||||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
describe('TypeScript callable chosen by ?? / || / ?: / &&', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'typescript-callable-alternatives'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
const calls = () => edgeSet(getRelationships(result, 'CALLS'));
|
||||
|
||||
it('control: a plain alias reaches its callee', () => {
|
||||
expect(calls()).toContain('aliasOnly → runAlias');
|
||||
});
|
||||
|
||||
it('`a ?? fn` reaches fn', () => {
|
||||
expect(calls()).toContain('nullish → runSweep');
|
||||
});
|
||||
|
||||
it('`a ?? fn` inside an object-literal method reaches fn (worker `scheduled`)', () => {
|
||||
expect(calls()).toContain('scheduled → runSweep');
|
||||
});
|
||||
|
||||
it('`a || fn` reaches fn', () => {
|
||||
expect(calls()).toContain('logicalOr → runOr');
|
||||
});
|
||||
|
||||
it('`c ? f : g` reaches both branches', () => {
|
||||
expect(calls()).toEqual(expect.arrayContaining(['ternary → runThen', 'ternary → runElse']));
|
||||
});
|
||||
|
||||
it('a chain `a ?? b ?? fn` reaches fn', () => {
|
||||
expect(calls()).toContain('chained → runChained');
|
||||
});
|
||||
|
||||
it('a parenthesized `(a ?? fn)` reaches fn', () => {
|
||||
expect(calls()).toContain('parenthesized → runParen');
|
||||
});
|
||||
|
||||
it('a callable LEFT operand `fn ?? fallback` reaches fn', () => {
|
||||
expect(calls()).toContain('callableLeft → runLeft');
|
||||
});
|
||||
|
||||
it('`a && fn` reaches fn and never the left operand', () => {
|
||||
const fromLogicalAnd = calls().filter((edge) => edge.startsWith('logicalAnd → '));
|
||||
expect(fromLogicalAnd).toEqual(['logicalAnd → runAndRight']);
|
||||
});
|
||||
|
||||
// `x.kind === Handlers.run` emitted as its own source becomes a seed whose
|
||||
// qualified text slices to receiver `Handlers` and member `run`.
|
||||
it('a comparison branch never reaches the static member it compares against', () => {
|
||||
const fromComparisons = calls().filter(
|
||||
(edge) => edge.startsWith('comparisonBranch → ') || edge.startsWith('staticComparison → '),
|
||||
);
|
||||
expect(fromComparisons).toEqual([]);
|
||||
});
|
||||
|
||||
it('a comparison or arithmetic branch never reaches a same-named method of `this`', () => {
|
||||
const fromOperators = calls().filter(
|
||||
(edge) =>
|
||||
edge.startsWith('bareComparison → ') ||
|
||||
edge.startsWith('arithmeticBranch → ') ||
|
||||
edge.startsWith('thisComparison → ') ||
|
||||
edge.startsWith('ternaryComparison → '),
|
||||
);
|
||||
expect(fromOperators).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -622,6 +622,111 @@ describe('LocalBackend.callTool', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('reports UNKNOWN instead of a blast radius when the target resolves without a node id (#3354)', async () => {
|
||||
// Every query returns the same id-less row: the resolver picks it as the
|
||||
// single match, and the frontier query would answer for no symbol at all.
|
||||
(executeParameterized as any).mockResolvedValue([{ name: 'runSweep', type: 'Function' }]);
|
||||
|
||||
const result = await backend.callTool('impact', { target: 'runSweep', direction: 'upstream' });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
target: { name: 'runSweep' },
|
||||
impactedCount: null,
|
||||
risk: 'UNKNOWN',
|
||||
});
|
||||
expect(result.error).toMatch(/without a node id/);
|
||||
expect(result).not.toHaveProperty('byDepthCounts');
|
||||
});
|
||||
|
||||
it.each([[' '], ['']])(
|
||||
'treats a blank target_uid %j as omitted and resolves the name (#3354)',
|
||||
async (targetUid) => {
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'validate',
|
||||
target_uid: targetUid,
|
||||
direction: 'upstream',
|
||||
});
|
||||
|
||||
// Name resolution ran (no rows → not found by NAME), not a lookup of the blank uid.
|
||||
expect(result.error).toBe("Target 'validate' not found");
|
||||
const boundParams = (executeParameterized as any).mock.calls.map((c: unknown[]) => c[2]);
|
||||
expect(boundParams).not.toContainEqual(expect.objectContaining({ uid: expect.anything() }));
|
||||
expect(boundParams).toContainEqual(expect.objectContaining({ symName: 'validate' }));
|
||||
},
|
||||
);
|
||||
|
||||
it('treats a non-string impact target_uid as omitted instead of throwing (#3354)', async () => {
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'validate',
|
||||
target_uid: 42,
|
||||
direction: 'upstream',
|
||||
});
|
||||
|
||||
expect(result.error).toBe("Target 'validate' not found");
|
||||
const boundParams = (executeParameterized as any).mock.calls.map((c: unknown[]) => c[2]);
|
||||
expect(boundParams).not.toContainEqual(expect.objectContaining({ uid: expect.anything() }));
|
||||
});
|
||||
|
||||
it.each([[42], [true], [{ id: 'Function:src/auth.ts:validate' }], [['x']]])(
|
||||
'treats a non-string context uid %j as omitted instead of throwing (#3354)',
|
||||
async (uid) => {
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
|
||||
const result = await backend.callTool('context', { name: 'validate', uid });
|
||||
|
||||
expect(result).toEqual({ error: "Symbol 'validate' not found" });
|
||||
const boundParams = (executeParameterized as any).mock.calls.map((c: unknown[]) => c[2]);
|
||||
expect(boundParams).not.toContainEqual(expect.objectContaining({ uid: expect.anything() }));
|
||||
},
|
||||
);
|
||||
|
||||
it('names the unresolved trace source, not a blank from_uid (#3354)', async () => {
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
|
||||
const result = await backend.callTool('trace', {
|
||||
from: 'missingSource',
|
||||
from_uid: ' ',
|
||||
to: 'validate',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'not_found',
|
||||
error: "Source symbol 'missingSource' not found.",
|
||||
});
|
||||
});
|
||||
|
||||
it('names the unresolved trace target, not a blank to_uid (#3354)', async () => {
|
||||
const sourceRow = {
|
||||
id: 'Function:src/a.ts:start',
|
||||
name: 'start',
|
||||
type: 'Function',
|
||||
filePath: 'src/a.ts',
|
||||
startLine: 1,
|
||||
endLine: 2,
|
||||
};
|
||||
(executeParameterized as any).mockImplementation(
|
||||
async (_path: string, _query: string, bound: Record<string, unknown> | undefined) =>
|
||||
bound?.uid === sourceRow.id ? [sourceRow] : [],
|
||||
);
|
||||
|
||||
const result = await backend.callTool('trace', {
|
||||
from_uid: sourceRow.id,
|
||||
to: 'missingTarget',
|
||||
to_uid: ' ',
|
||||
});
|
||||
(executeParameterized as any).mockReset();
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'not_found',
|
||||
error: "Target symbol 'missingTarget' not found.",
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes impact aliases before @group forwarding', async () => {
|
||||
resolveAtMemberMock.mockResolvedValue({ ok: true, repoPath: '/tmp/test-project' });
|
||||
const groupImpactSpy = vi
|
||||
|
|
|
|||
|
|
@ -288,8 +288,10 @@ describe('PARSE_CACHE_VERSION', () => {
|
|||
// Moved 103 -> 104 for #3339 review: pair-HOC queries name
|
||||
// `mutation(withAuth(arrow))` object-pair handlers. Warm caches replay
|
||||
// anonymous arrows, so both stores re-extract.
|
||||
it('pins SCHEMA_BUMP to 104 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(104);
|
||||
// Moved 104 -> 112 for #3354: callable-value flow follows `??`/`||`/`?:`
|
||||
// branches. 105-111 are claimed by open PR #3326.
|
||||
it('pins SCHEMA_BUMP to 112 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(112);
|
||||
expect(PARSE_CACHE_BUCKET_COUNT).toBe(128);
|
||||
// The PREVIOUS version must fail the reuse gate, not merely differ from the
|
||||
// current one — a hardcoded number outside the conflict hunk rebases cleanly
|
||||
|
|
@ -298,6 +300,7 @@ describe('PARSE_CACHE_VERSION', () => {
|
|||
for (const taken of [
|
||||
59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81,
|
||||
82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103,
|
||||
104, 105, 106, 107, 108, 109, 110, 111,
|
||||
]) {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { synthesizeCallableFlowCaptures } from '../../../src/core/ingestion/utils/callable-flow-captures.js';
|
||||
import { getJsParser } from '../../../src/core/ingestion/languages/javascript/query.js';
|
||||
import { getTreeSitterBufferSize } from '../../../src/core/ingestion/constants.js';
|
||||
|
||||
const OPTIONS = {
|
||||
functionNodeTypes: new Set(['function_declaration', 'arrow_function', 'function_expression']),
|
||||
|
|
@ -25,7 +26,7 @@ const OPTIONS = {
|
|||
} as const;
|
||||
|
||||
function factsFor(src: string): Array<Record<string, string>> {
|
||||
const tree = getJsParser().parse(src);
|
||||
const tree = getJsParser().parse(src, undefined, { bufferSize: getTreeSitterBufferSize(src) });
|
||||
if (tree === null) throw new Error('parse failed');
|
||||
return synthesizeCallableFlowCaptures(tree.rootNode, OPTIONS).map((match) => {
|
||||
const out: Record<string, string> = {};
|
||||
|
|
@ -114,4 +115,24 @@ describe('synthesizeCallableFlowCaptures (shared synthesizer, #2522)', () => {
|
|||
});
|
||||
expect(suppressed.filter((match) => match['@callable-flow.seed'] !== undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('expands a 10000-operand `||` chain and keeps its callable and formal operands (#3354 review)', () => {
|
||||
// Each `||` nests one level deeper, so the expansion used to recurse once
|
||||
// per operand (stack overflow near 8000) and walk each leaf's full depth.
|
||||
// The formal `w` sits at the deepest leaf: its visibility still has to be
|
||||
// found through the enclosing function, 10000 levels up.
|
||||
const operands = Array.from({ length: 10000 }, (_, i) => `w === "k${i}"`);
|
||||
operands.splice(5000, 0, 'target');
|
||||
operands.unshift('w');
|
||||
const facts = factsFor(
|
||||
`function target() {}\nfunction isKw(w) {\n const f = ${operands.join(' || ')};\n f();\n}\n`,
|
||||
);
|
||||
expect(byTag(facts, '@callable-flow.seed')).toMatchObject([
|
||||
{ '@callable-flow.destination': 'f', '@callable-flow.target-name': 'target' },
|
||||
]);
|
||||
expect(byTag(facts, '@callable-flow.copy')).toMatchObject([
|
||||
{ '@callable-flow.destination': 'f', '@callable-flow.source': 'w' },
|
||||
]);
|
||||
expect(byTag(facts, '@callable-flow.invoke')).toMatchObject([{ '@callable-flow.callee': 'f' }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue