Skip to content

feat(resolver): resolve inline-array spread call edges (fn(...[a, b, c]))#1394

Merged
carlos-alm merged 6 commits into
mainfrom
feat/spread-inline-literal-1379
Jun 8, 2026
Merged

feat(resolver): resolve inline-array spread call edges (fn(...[a, b, c]))#1394
carlos-alm merged 6 commits into
mainfrom
feat/spread-inline-literal-1379

Conversation

@carlos-alm

Copy link
Copy Markdown
Contributor

Summary

  • Extend extractParamBindingsWalk to handle spread arguments whose source is an inline array literal (fn(...[a, b, c]))
  • Each element in the inline array is expanded as a ParamBinding at the corresponding position, so the PTS solver propagates function references through to callees
  • Also handles mixed calls: fn(x, ...[a, b]) correctly assigns positions starting from the spread's slot
  • Update jelly-micro/spread fixture to include inline-literal test cases (single-element, multi-element, mixed)

Root cause

extractParamBindingsWalk only emitted ParamBinding for identifier-typed arguments. The spread_element node was skipped (incrementing argIdx by 1 but emitting nothing), so fn(...[a, b]) produced no param-flow constraints. The existing f(...arr) path (via SpreadArgBinding + ArrayElemBinding) already worked — the inline-literal form was the gap.

Test plan

  • jelly-micro/spread benchmark: recall 100% (11/11 TP, 0 FN, 0 FP) — up from 4/4 on the old fixture (inline cases were simply untested)
  • Full test suite: all tests pass (pre-existing prototype-method-resolution failures are unrelated to this change)

Closes #1379

…c]))

Extend extractParamBindingsWalk to handle spread arguments whose source
is an inline array literal. When a call site has fn(...[a, b, c]), each
element is expanded as a ParamBinding at the corresponding position so
the PTS solver can propagate function references through to callees.

Previously only fn(...arr) (spread of a named variable) was handled via
SpreadArgBinding + ArrayElemBinding. The inline-literal form fn(...[a, b])
produced no param-flow constraints, so all 4 expected spread edges scored 0%.

Also handles mixed calls: fn(x, ...[a, b]) correctly assigns positions
starting from the spread's slot (x at 0, a at 1, b at 2).

Benchmark: jelly-micro/spread recall 100% (11/11 TP, 0 FN, 0 FP).
Closes #1379
@claude

claude Bot commented Jun 8, 2026

Copy link
Copy Markdown

Claude finished @carlos-alm's task —— View job


PR Review

  • Gather context and read changed files
  • Phase 1: Root cause analysis
  • Phase 2: Configuration change detection
  • Phase 3: Test integrity
  • Phase 4: Code quality
  • Phase 5: Backlog compliance
  • Final assessment

Root Cause Analysis

Problem Being Solved: extractParamBindingsWalk only handled identifier-typed arguments. When a spread argument's source was an inline array literal (fn(...[a, b, c])), the spread_element node was silently skipped — argIdx advanced by 1 but no ParamBinding was emitted. The result: zero param-flow constraints for inline-literal spreads, so the PTS solver could never propagate function references through to callees.

Why This Approach: The fix is minimal and precisely targeted — it adds a new branch for ct === 'spread_element' in the existing argument-iteration loop. It does NOT touch the SpreadArgBinding path (which handles f(...arr) via a named variable), keeping the two cases cleanly separated. The inline-literal form is expanded positionally, reusing the same ParamBinding interface that the PTS solver already understands.

Risk Assessment: Low. The change is additive-only within extractParamBindingsWalk. The extractSpreadForOfWalk function handles f(...arr) only when the spread target is an identifier (line 2200), so there is no double-counting with the new path.


Phase 2: Configuration Change Detection

No linting rules relaxed, no test timeouts increased, no TypeScript flags changed, no build config modified. Clean.


Phase 3: Test Integrity

Test fixture additions are correct and honest:

  • spread.js adds e, h, p, q functions and three new call patterns (inline single-element, inline multi-element, mixed)
  • expected-edges.json adds 7 new edges with "mode": "pts-param" — correctly distinct from the existing "mode": "pts-spread" edges, reflecting the different resolution path
  • No existing assertions weakened, no expectations lowered

The description in expected-edges.json is updated accurately: "variable and inline array". No red flags.


Phase 4: Code Quality

src/extractors/javascript.tsextractParamBindingsWalk (lines 2046-2067):

Correct:

  • elemCount counts all non-punctuation children (not just identifiers), so non-identifier elements like expr() still consume a positional slot. This means [a, getB(), c]a at argIdx+0, c at argIdx+2 — correct.
  • The argIdx += Math.max(0, elemCount - 1) pre-advance plus the outer argIdx++ correctly accounts for total slots consumed by the spread.
  • The fallback child.childCount > 1 ? child.child(1) : null mirrors the same pattern used in extractSpreadForOfWalk (line 2199) for consistency.
  • No interaction with the SpreadArgBinding path: extractSpreadForOfWalk gates on spreadTarget?.type === 'identifier', which is false for ...[ ] spreads.

Minor observation — elemCount when inner is absent:
When inner?.type !== 'array' (e.g. ...({}) or ...someCall()), the if (inner?.type === 'array') block is skipped entirely, argIdx gets only the outer argIdx++. This means a non-array spread advances argIdx by 1, which is consistent with how the previous code handled unknown spread forms. No regression.

One edge case worth noting (not a bug, but worth awareness): ...[] (empty array) → elemCount = 0, argIdx += Math.max(0, -1) = 0, outer argIdx++ → advances by 1. This is correct — an empty spread occupies 1 argument slot syntactically, matching the pre-existing extractSpreadForOfWalk behavior.

Architecture compliance: extractParamBindingsWalk is a WASM-path function, but paramBindings are also applied in buildParamFlowPtsPostPass for the native engine path (build-edges.ts:542). Both engines benefit automatically. No native Rust changes required — this is intentional per the architecture comment at line 542.


Phase 5: Backlog Compliance

  • Zero-dep: ✓ — no new runtime dependencies
  • Foundation-aligned: ✓ — purely local AST analysis, no cloud, no external services, no agents
  • Problem-fit: 5/5 — directly reduces hallucinated edges (false negatives in call graph = agents inferring nonexistent flows). Improves accuracy of codegraph query / codegraph fn-impact for any codebase using fn(...[a, b]) call patterns — a common pattern in real JS.
  • Breaking: No — purely additive edges where none existed before. No CLI output format changes, no DB schema changes, no MCP contract changes.
  • Tier: 1 — zero-dep + foundation-aligned

Final Recommendation

Rating: ⭐⭐⭐⭐ (4/5)

Action: APPROVE

Reasoning: The fix is surgically correct. The root cause analysis in the PR description is accurate. The elemCount positional accounting correctly handles all relevant cases (identifier, non-identifier, empty, mixed). Test additions are honest and strengthen coverage. No architectural concerns, no regressions, no scope creep. The only reason this isn't a 5/5 is that a test for ...[a, expr(), b] (mixed-type element array) would give additional confidence in the positional counting, but the existing coverage for the stated cases is solid.

@greptile-apps

greptile-apps Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR extends extractParamBindingsWalk to emit ParamBinding entries for spread arguments whose source is an inline array literal (fn(...[a, b, c])), filling a gap that previously left those calls with no param-flow constraints. It also includes the fix for the empty-spread edge case so that ...[ ] consumes zero index slots and does not shift subsequent positional arguments.

  • src/extractors/javascript.ts: New spread_element branch inside the argument-walk loop iterates the inner array node, pushes one ParamBinding per identifier element at argIdx + elemCount, then advances argIdx += elemCount; continue — keeping positional arithmetic correct for both zero-element and multi-element spreads.
  • spread.js / expected-edges.json: Four new test cases (single-element, multi-element, mixed identifier + inline spread, and empty spread with a trailing positional arg) lock in the corrected behaviour with 100 % recall on the updated fixture.

Confidence Score: 5/5

Safe to merge — the change is narrowly scoped to inline-array spreads, the empty-spread index bug has been addressed, and the new test cases cover all four scenarios introduced by the PR.

The new branch only fires when a spread argument's inner node is an array literal, leaving all other argument types (plain identifiers, variable spreads) on their existing code paths. The argIdx += elemCount; continue pattern correctly handles zero-, single-, and multi-element inline arrays, and the mixed-position case is validated by q(e, ...[h, d]). No pre-existing logic is disturbed.

No files require special attention.

Important Files Changed

Filename Overview
src/extractors/javascript.ts Extends extractParamBindingsWalk to handle spread_element over inline array literals, with correct argIdx accounting including the empty-spread fix; logic is tight and well-commented.
tests/benchmarks/resolution/fixtures/jelly-micro/spread/spread.js Adds four new call-site test cases covering single-element inline spread, multi-element inline spread, mixed identifier+inline spread, and empty-spread with a trailing positional argument.
tests/benchmarks/resolution/fixtures/jelly-micro/spread/expected-edges.json Expected edges extended with 8 new pts-param entries matching the new inline-spread test cases; all positions align with the corrected argIdx logic.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[call_expression node] --> B{fn is identifier?}
    B -- No --> Z[skip]
    B -- Yes --> C[iterate args children]
    C --> D{child type?}
    D -- comma/parens --> C
    D -- identifier --> E[push ParamBinding at argIdx\nthen argIdx++]
    E --> C
    D -- spread_element --> F[get inner = child.argument]
    F --> G{inner type == array?}
    G -- No --> H[fall-through: argIdx++]
    H --> C
    G -- Yes --> I[iterate array elements]
    I --> J{elem type?}
    J -- comma/brackets --> I
    J -- identifier --> K[push ParamBinding at argIdx+elemCount\nelemCount++]
    K --> I
    J -- other --> L[elemCount++ only, no binding]
    L --> I
    I --> M[argIdx += elemCount\ncontinue — skip outer argIdx++]
    M --> C
Loading

Reviews (6): Last reviewed commit: "Merge branch 'main' into feat/spread-inl..." | Re-trigger Greptile

Comment on lines +2050 to +2067
if (inner?.type === 'array') {
let elemCount = 0;
for (let j = 0; j < inner.childCount; j++) {
const elem = inner.child(j);
if (!elem) continue;
if (elem.type === ',' || elem.type === '[' || elem.type === ']') continue;
if (elem.type === 'identifier' && !BUILTIN_GLOBALS.has(elem.text)) {
paramBindings.push({
callee: fn.text,
argIndex: argIdx + elemCount,
argName: elem.text,
});
}
elemCount++;
}
// argIdx++ below accounts for 1 slot; pre-advance for the remaining elements.
argIdx += Math.max(0, elemCount - 1);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Empty inline-array spread incorrectly advances argIdx by 1. When ...[ ] has zero elements, elemCount is 0, so argIdx += Math.max(0, -1) = 0, then the unconditional argIdx++ below still fires. Any positional argument appearing after ...[ ] in the same call receives an index one too high, causing a missed param binding. Replacing the pre-advance + unconditional increment with a single argIdx += elemCount; continue fully controls the slot consumption inside the branch and also simplifies the arithmetic.

Suggested change
if (inner?.type === 'array') {
let elemCount = 0;
for (let j = 0; j < inner.childCount; j++) {
const elem = inner.child(j);
if (!elem) continue;
if (elem.type === ',' || elem.type === '[' || elem.type === ']') continue;
if (elem.type === 'identifier' && !BUILTIN_GLOBALS.has(elem.text)) {
paramBindings.push({
callee: fn.text,
argIndex: argIdx + elemCount,
argName: elem.text,
});
}
elemCount++;
}
// argIdx++ below accounts for 1 slot; pre-advance for the remaining elements.
argIdx += Math.max(0, elemCount - 1);
}
if (inner?.type === 'array') {
let elemCount = 0;
for (let j = 0; j < inner.childCount; j++) {
const elem = inner.child(j);
if (!elem) continue;
if (elem.type === ',' || elem.type === '[' || elem.type === ']') continue;
if (elem.type === 'identifier' && !BUILTIN_GLOBALS.has(elem.text)) {
paramBindings.push({
callee: fn.text,
argIndex: argIdx + elemCount,
argName: elem.text,
});
}
elemCount++;
}
// Advance by the exact number of slots this spread occupies and skip
// the unconditional argIdx++ below so that zero-element spreads (...[])
// do not shift subsequent argument indices.
argIdx += elemCount;
continue;
}

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The empty inline-array spread bug is real — replaced argIdx += Math.max(0, elemCount - 1) plus the unconditional argIdx++ with argIdx += elemCount; continue, so zero-element spreads consume zero slots and do not shift subsequent argument indices. Added a test case r(...[], e) to lock in the fix: e must resolve to index 0 (not 1) in r, confirming the corrected behavior.

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Codegraph Impact Analysis

7 functions changed22 callers affected across 7 files

  • extractParamBindingsWalk in src/extractors/javascript.ts:2131 (3 transitive callers)
  • walk in src/extractors/javascript.ts:2132 (26 transitive callers)
  • e in tests/benchmarks/resolution/fixtures/jelly-micro/spread/spread.js:7 (1 transitive callers)
  • h in tests/benchmarks/resolution/fixtures/jelly-micro/spread/spread.js:8 (0 transitive callers)
  • p in tests/benchmarks/resolution/fixtures/jelly-micro/spread/spread.js:18 (2 transitive callers)
  • q in tests/benchmarks/resolution/fixtures/jelly-micro/spread/spread.js:21 (4 transitive callers)
  • r in tests/benchmarks/resolution/fixtures/jelly-micro/spread/spread.js:26 (2 transitive callers)

…indices (#1394)

Empty `...[  ]` spread was advancing `argIdx` by 1 via the unconditional
post-increment, causing any positional argument after it in the same call
to receive an index one too high. Replace the pre-advance + unconditional
increment with `argIdx += elemCount; continue` so zero-element spreads
consume zero slots. Adds a test covering `r(...[], e)` to lock in the fix.
@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai

@carlos-alm carlos-alm merged commit 0fe4bfc into main Jun 8, 2026
22 checks passed
@carlos-alm carlos-alm deleted the feat/spread-inline-literal-1379 branch June 8, 2026 21:34
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 8, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(resolver): resolve spread-argument call edges (fn(...[a, b, c]))

1 participant