Skip to content

Support object spread in path-scoped scope blocks#86

Merged
aarne merged 5 commits intomainfrom
copilot/support-object-spread-scope
Mar 5, 2026
Merged

Support object spread in path-scoped scope blocks#86
aarne merged 5 commits intomainfrom
copilot/support-object-spread-scope

Conversation

Copy link
Contributor

Copilot AI commented Mar 4, 2026

  • Add Spread token (...) to lexer, placed before Dot
  • Add scopeSpreadLine rule to parser
  • Support ...sourceExpr in bridgeWire scope blocks (audit { ...i })
  • Support ...sourceExpr in nested pathScopeLine scope blocks (.foo { ...i })
  • Support ...sourceExpr in elementLine array mapper scope blocks
  • Update processScopeLines visitor to handle spread lines (including spread-only nested scopes)
  • Update processElementScopeLines visitor to handle spread lines
  • Fix compiler buildObjectLiteral to support spread + field override combination
  • Fix compiler ToolDef-backed tool path to handle spread wires
  • Add syntax highlighting for ...sourceExpr spread lines
  • Add 8 new tests for spread parsing and execution in scope blocks
  • Fix: propagate safe: true on spread wires in element scope blocks when source uses ?. safe navigation (processElementLines and processElementScopeLines)
  • All 929 tests pass, lint/build clean, CodeQL clean
Original prompt

This section details on the original issue you should resolve

<issue_title>Support object spread in path scoped scopes</issue_title>
<issue_description>```bridge
bridge xxx {
with input as i
with std.audit

audit {
...i # As a developer i expect this to also work
.what = "now"
}
audit.in.addition.to.this <- i

force audit
}


## Comments on the Issue (you are @copilot in this section)

<comments>
</comments>


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

@changeset-bot
Copy link

changeset-bot bot commented Mar 4, 2026

🦋 Changeset detected

Latest commit: a10741c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@stackables/bridge-compiler Minor
@stackables/bridge-parser Minor
@stackables/bridge Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Co-authored-by: aarne <82001+aarne@users.noreply.github.com>
@cloudflare-workers-and-pages
Copy link

cloudflare-workers-and-pages bot commented Mar 4, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
bridge a10741c Commit Preview URL

Branch Preview URL
Mar 05 2026, 06:50 AM

Copilot AI changed the title [WIP] Add support for object spread in path scoped scopes Support object spread in path-scoped scope blocks Mar 4, 2026
Copilot finished work on behalf of aarne March 4, 2026 21:49
@aarne aarne marked this pull request as ready for review March 5, 2026 06:16
@github-actions
Copy link
Contributor

github-actions bot commented Mar 5, 2026

🐰 Bencher Report

Branchcopilot/support-object-spread-scope
Testbedubuntu-latest
Click to view all benchmark results
BenchmarkLatencyBenchmark Result
nanoseconds (ns)
(Result Δ%)
Upper Boundary
nanoseconds (ns)
(Limit %)
compiled: absolute baseline (passthrough, no tools)📈 view plot
🚷 view threshold
0.00 ns
(+2.56%)Baseline: 0.00 ns
0.00 ns
(98.64%)
compiled: array + tool-per-element 10📈 view plot
🚷 view threshold
0.01 ns
(+1.06%)Baseline: 0.01 ns
0.01 ns
(97.59%)
compiled: array + tool-per-element 100📈 view plot
🚷 view threshold
0.03 ns
(+3.55%)Baseline: 0.03 ns
0.04 ns
(96.72%)
compiled: chained 3-tool fan-out📈 view plot
🚷 view threshold
0.00 ns
(+2.25%)Baseline: 0.00 ns
0.00 ns
(96.97%)
compiled: flat array 10 items📈 view plot
🚷 view threshold
0.01 ns
(+1.23%)Baseline: 0.01 ns
0.01 ns
(96.91%)
compiled: flat array 100 items📈 view plot
🚷 view threshold
0.01 ns
(-0.08%)Baseline: 0.01 ns
0.01 ns
(94.58%)
compiled: flat array 1000 items📈 view plot
🚷 view threshold
0.07 ns
(-0.46%)Baseline: 0.07 ns
0.07 ns
(93.66%)
compiled: nested array 10x10📈 view plot
🚷 view threshold
0.02 ns
(+1.32%)Baseline: 0.02 ns
0.02 ns
(97.84%)
compiled: nested array 20x10📈 view plot
🚷 view threshold
0.03 ns
(+0.69%)Baseline: 0.03 ns
0.03 ns
(97.64%)
compiled: nested array 5x5📈 view plot
🚷 view threshold
0.01 ns
(+0.74%)Baseline: 0.01 ns
0.01 ns
(97.99%)
compiled: short-circuit (overdefinition bypass)📈 view plot
🚷 view threshold
0.00 ns
(+1.93%)Baseline: 0.00 ns
0.00 ns
(97.64%)
compiled: simple chain (1 tool)📈 view plot
🚷 view threshold
0.00 ns
(+2.74%)Baseline: 0.00 ns
0.00 ns
(97.38%)
exec: absolute baseline (passthrough, no tools)📈 view plot
🚷 view threshold
0.00 ns
(+2.08%)Baseline: 0.00 ns
0.00 ns
(82.67%)
exec: array + tool-per-element 10📈 view plot
🚷 view threshold
0.08 ns
(+5.08%)Baseline: 0.07 ns
0.10 ns
(76.49%)
exec: array + tool-per-element 100📈 view plot
🚷 view threshold
0.69 ns
(+2.47%)Baseline: 0.67 ns
0.95 ns
(72.31%)
exec: chained 3-tool fan-out📈 view plot
🚷 view threshold
0.02 ns
(+14.24%)Baseline: 0.01 ns
0.02 ns
(83.26%)
exec: flat array 10 items📈 view plot
🚷 view threshold
0.01 ns
(-10.60%)Baseline: 0.02 ns
0.04 ns
(37.43%)
exec: flat array 100 items📈 view plot
🚷 view threshold
0.07 ns
(-27.32%)Baseline: 0.10 ns
0.35 ns
(21.43%)
exec: flat array 1000 items📈 view plot
🚷 view threshold
0.68 ns
(-35.71%)Baseline: 1.06 ns
4.26 ns
(15.93%)
exec: nested array 10x10📈 view plot
🚷 view threshold
0.13 ns
(-17.58%)Baseline: 0.15 ns
0.42 ns
(29.73%)
exec: nested array 20x10📈 view plot
🚷 view threshold
0.24 ns
(-18.66%)Baseline: 0.30 ns
0.86 ns
(28.32%)
exec: nested array 5x5📈 view plot
🚷 view threshold
0.05 ns
(-9.12%)Baseline: 0.05 ns
0.12 ns
(41.86%)
exec: short-circuit (overdefinition bypass)📈 view plot
🚷 view threshold
0.00 ns
(+7.05%)Baseline: 0.00 ns
0.00 ns
(98.97%)
exec: simple chain (1 tool)📈 view plot
🚷 view threshold
0.01 ns
(+15.82%)Baseline: 0.01 ns
0.01 ns
(88.81%)
parse: large bridge (20 handles x 5 wires)📈 view plot
🚷 view threshold
1.21 ns
(+25.50%)Baseline: 0.97 ns
1.32 ns
(92.04%)
parse: simple bridge📈 view plot
🚷 view threshold
0.04 ns
(+28.60%)Baseline: 0.03 ns
0.04 ns
(91.30%)
🐰 View full continuous benchmarking report in Bencher

@aarne
Copy link
Contributor

aarne commented Mar 5, 2026

@copilot

The code examples in the review are just illustration and need to be evaluated one by one. verify if the mentioned issues really are issues before making any changes. Test before fix!

Architectural Review

You have done an excellent job integrating the spread operator into the existing CST-to-AST translation pipeline.

  1. Lexer Integration (Spread token): The parser correctly consumes the Spread token (...).
  2. Grammar Updates (scopeSpreadLine): You created a new dedicated rule scopeSpreadLine to handle ...sourceExpr within scoping blocks. This was the correct choice; it prevents the spread operator from leaking into regular pull wires (<-) where it doesn't belong.
  3. Path Scoping Block Integration: You updated bridgeWire and elementLine to accept scopeSpreadLine inside the LCurly ... RCurly scope blocks alongside pathScopeLine and bridgeNodeAlias.
  4. AST Emission (The Magic Trick): Your implementation in processScopeLines and processElementScopeLines is brilliant.
  • The Problem: The Bridge Core AST (Instruction[]) has no concept of a "spread wire."
  • The Solution: You are desugaring the spread operator during the parsing phase. When the parser sees ...source inside a block targeting output.user, it emits a standard pull wire: output.user <- source.

Because output.user is an object, mapping source directly to it accomplishes the exact same thing as an object spread. This means you didn't have to change the interpreter, the compiler, or the core AST types at all.

Code Review & Minor Fixes

While the architecture is sound, there are a few edge cases in the AST generation logic that need to be addressed to ensure robustness, especially when dealing with complex path scoping.

1. Spread Operator inside bridgeWire (Root Scope Blocks)

In processScopeLines, you process the spread lines correctly:

      // Process spread lines inside this nested scope block: ...sourceExpr
      const nestedToRef = resolveAddress(targetRoot, fullSegs, scopeLineNum);
      for (const spreadLine of nestedSpreadLines) {
        const spreadLineNum = line(findFirstToken(spreadLine));
        const sourceNode = sub(spreadLine, "spreadSource")!;
        const { ref: fromRef, safe: spreadSafe } = buildSourceExprSafe(
          sourceNode,
          spreadLineNum,
        );
        wires.push({
          from: fromRef,
          to: nestedToRef,
          ...(spreadSafe ? { safe: true as const } : {}),
        });
      }

However, look at how you process spread lines at the top level of a standard bridge wire scope block (around line 1251 in buildBridgeBody):

    // ── Path scoping block: target { .field ... } ──
    if (wc.scopeBlock) {
      // ... [alias logic omitted] ...
      
      const scopeLines = subs(wireNode, "pathScopeLine");
      // Process spread lines inside the scope block: ...sourceExpr
      const spreadLines = subs(wireNode, "scopeSpreadLine");
      for (const spreadLine of spreadLines) {
        const spreadLineNum = line(findFirstToken(spreadLine));
        const sourceNode = sub(spreadLine, "spreadSource")!;
        const { ref: fromRef, safe: spreadSafe } = buildSourceExprSafe(
          sourceNode,
          spreadLineNum,
        );
        wires.push({
          from: fromRef,
          to: toRef, // <--- Correct, maps to the root target
          ...(spreadSafe ? { safe: true as const } : {}),
        });
      }
      processScopeLines(scopeLines, targetRoot, targetSegs);
      continue;
    }

This is structurally correct. If a user writes o.user { ...api.user }, it generates o.user <- api.user.

The Bug: What happens if the api.user object contains a field id, but the user explicitly overrides it in the same scope block?

o.user {
  ...api.user
  .id = "custom_id"
}

Because of how the interpreter and compiler handle object merging (overdefinition protection), the order of wires matters. If the ...api.user wire is emitted after the .id = "custom_id" wire, the spread might accidentally overwrite the specific explicit mapping.

In your code, you are emitting the spread lines before calling processScopeLines. This is actually the correct behavior, as it allows subsequent pathScopeLine entries to overwrite the spread data. However, if a user writes the spread at the bottom of the block, it will still act as the "base" object.

To ensure the spread behaves intuitively regardless of where it is placed in the block, you should ensure the execution engine's object merge logic respects standard JS spread semantics (last write wins). Because you map it to o.user <- api.user, and explicit wires map to o.user.id = "custom_id", the execution engine's deep-merge will naturally allow the explicit deeper path (user.id) to win over the broader object assignment (user), so this is safe!

2. Spread Operator inside elementLine (Nested Array Scope Blocks)

In processElementLines, you handle scope blocks inside array iterations:

    } else if (elemC.elemScopeBlock) {
      // ── Path scope block inside array mapping: .field { lines: .sub <- ..., ...source } ──
      const scopeLines = subs(elemLine, "elemScopeLine");
      // Process spread lines at the top level of this scope block
      const spreadLines = subs(elemLine, "elemSpreadLine");
      for (const spreadLine of spreadLines) {
        const spreadLineNum = line(findFirstToken(spreadLine));
        const sourceNode = sub(spreadLine, "spreadSource")!;
        const fromRef = buildSourceExpr(sourceNode, spreadLineNum, iterName);
        wires.push({
          from: fromRef,
          to: {
            module: SELF_MODULE,
            type: bridgeType,
            field: bridgeField,
            element: true,
            path: elemToPath,
          },
        });
      }

The Missing Feature: You forgot to extract the safe property from buildSourceExprSafe here, unlike in the root scope blocks. If a user writes ...it.optionalData?.profile, the safe: true attribute is lost, and the compiler might throw an error if optionalData is null.

The Fix:

        const { ref: fromRef, safe: spreadSafe } = buildSourceExprSafe(sourceNode, spreadLineNum, iterName);
        wires.push({
          from: fromRef,
          to: {
            module: SELF_MODULE,
            type: bridgeType,
            field: bridgeField,
            element: true,
            path: elemToPath,
          },
          ...(spreadSafe ? { safe: true as const } : {}), // Add safe flag propagation
        });

3. Spread Operator inside processElementScopeLines

There is a recursive block handler for elements as well. Around line 700:

      // Process spread lines inside this nested scope block: ...sourceExpr
      const spreadToPath = [...arrayToPath, ...fullSegs];
      for (const spreadLine of nestedSpreadLines) {
        const spreadLineNum = line(findFirstToken(spreadLine));
        const sourceNode = sub(spreadLine, "spreadSource")!;
        const fromRef = buildSourceExpr(sourceNode, spreadLineNum, iterName);
        wires.push({
          from: fromRef,
          to: {
            module: SELF_MODULE,
            type: bridgeType,
            field: bridgeField,
            element: true,
            path: spreadToPath,
          },
        });
      }

The Fix: Apply the exact same buildSourceExprSafe fix here to ensure ?. safe navigation is respected on spreads inside deeply nested array scopes.

Co-authored-by: aarne <82001+aarne@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 5, 2026

@copilot

The code examples in the review are just illustration and need to be evaluated one by one. verify if the mentioned issues really are issues before making any changes. Te...

Verified all three points:

  1. Root scope spread ordering — the comment is correct that the current behavior (spread emitted before field wires) is already right. The runtime's deep-merge semantics naturally let explicit o.user.id = "custom_id" wires override the broader o.user <- api.user spread, no change needed.

  2. safe flag in processElementLines (elemScopeBlock) — confirmed real issue. ...item?.profile was missing safe: true on the wire because buildSourceExpr drops the flag. Fixed in 72a8e1d by extracting the safe flag directly from the source CST node via extractAddressPath.

  3. safe flag in processElementScopeLines (nested element scope) — same issue, same fix applied.

Both standalone functions only receive buildSourceExpr as a parameter (which drops safe). Since extractAddressPath is a module-level function, I compute safe from the actual source head node directly, mirroring buildSourceExprSafe's logic without changing the function signatures.

Copilot finished work on behalf of aarne March 5, 2026 06:31
@aarne aarne merged commit fc3d8ed into main Mar 5, 2026
8 checks passed
@aarne aarne deleted the copilot/support-object-spread-scope branch March 5, 2026 07:04
@github-actions github-actions bot mentioned this pull request Mar 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support object spread in path scoped scopes

2 participants