Skip to content

[rig-eslint] Add no-heterogeneous-parallel ESLint rule - #431

Merged
pelikhan merged 1 commit into
mainfrom
rig-eslint/no-heterogeneous-parallel-2026-08-16-7c9d21965185fb0a
Aug 16, 2026
Merged

[rig-eslint] Add no-heterogeneous-parallel ESLint rule#431
pelikhan merged 1 commit into
mainfrom
rig-eslint/no-heterogeneous-parallel-2026-08-16-7c9d21965185fb0a

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Recurring mistake

parallel([thunkA, thunkB]) is repeatedly used with thunks that call different agents with distinct output schemas. Because parallel<Result> requires a single homogeneous type parameter, TypeScript cannot unify differing return types and reports a type error. The correct fix is Promise.all([thunkA, thunkB]), which infers a tuple type and handles heterogeneous results correctly.

Evidence — at least 5 distinct runs

Run PR Excerpt
31872348817 #427 412 (workflow): `parallel()` helper doesn't support heterogeneous output types; switched to `Promise.all`.
31781805528 #421 Task 4 (parallel-branch-analysis): parallel()` requires homogeneous result type across thunks — solved by giving both subagents the same unified output schema.
31679912784 #415 Task 10: parallel() in workflow body was replaced with Promise.all() due to a TypeScript inference limitation when the two subagent output shapes differ.
31470542846 #404 Task 10: the initial workflow({ agents: {...} }) pattern ... Fixed by ... sequential call() invocations ... (heterogeneous parallel)
31368276546 #395 Task 9: parallel([() => call(agentA, ...), () => call(agentB, ...)]) with heterogeneous agent output types ... Fixed by using sequential call() statements.
(run for PR #382) #382 390 (parallel workflow): switched from parallel() to Promise.all to avoid heterogeneous type unification error

Invalid → fixed examples

// ❌ parallel() with heterogeneous agents — TypeScript type error
const [branchHealth, commitFreq] = await parallel([
  () => call(branchHealthAgent, "analyze"),
  () => call(commitFrequencyAgent, "analyze"),
]);
// ✅ Promise.all() — TypeScript infers a tuple, handles differing output types
const [branchHealth, commitFreq] = await Promise.all([
  () => call(branchHealthAgent, "analyze"),
  () => call(commitFrequencyAgent, "analyze"),
]);

The autofix replaces only the callee identifier parallelPromise.all. It preserves the array argument, all thunks, and all surrounding comments unchanged. The fix is idempotent because Promise.all is not flagged.

Four changed integration points

  1. New rule: skills/rig/eslint/rules/no-heterogeneous-parallel.js
  2. Index export: skills/rig/eslint/index.js — exports no-heterogeneous-parallel
  3. Dependency-free CLI: skills/rig/eslint/lint.js — adds scanNoHeterogeneousParallel to tokenRules
  4. Tests: src/eslint-rules.test.js — adds describe("no-heterogeneous-parallel", ...) with invalid, fixed, idempotent, and ESLint-rule-alignment coverage

Detection logic

The token-based scanner looks for parallel ( [ (not preceded by .), finds two or more top-level thunks inside the array, and checks whether their call(agent, ...) first-argument identifiers are all distinct. If ≥2 distinct agent names are found, it flags and offers the parallelPromise.all callee replacement. Thunks without an identifiable call(identifier, ...) pattern are not flagged (conservative, no false positives).

Validation results

npm test       — 117/117 ESLint rule tests pass; 2 pre-existing failures in launcher-default-engine.test.ts (unrelated)
npm run lint   — exit 0
npm run typecheck — exit 0

Generated by Rig ESLint Rule Miner · sonnet46 165.5 AIC · ⌖ 9.86 AIC · ⊞ 5.3K ·

Detect and autofix parallel() calls with thunks that call different
agents (heterogeneous output types). Replace parallel() with Promise.all()
which TypeScript can handle correctly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@pelikhan
pelikhan marked this pull request as ready for review August 16, 2026 13:27
@pelikhan
pelikhan merged commit e48710a into main Aug 16, 2026
1 check passed
@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions github-actions Bot left a comment

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.

Skills-Based Review 🧠

Applied /tdd — requesting changes on one correctness bug: the autofix produces semantically broken code.

📋 Key Themes & Highlights

Blocking Issue

  • Broken autofix: parallel calls thunks internally (thunks.map(t => t())), but the generated fix replaces it with Promise.all([...thunks]) which does not call the thunks. At runtime the result would be [Function, Function] rather than the awaited values. The PR description's own "fixed" example has the same mistake. The fix should either unwrap the arrow functions or be removed in favour of a suggestion-only diagnostic.

Positive Highlights

  • ✅ Well-motivated by evidence from 6 prior PRs — the detection logic is conservative and avoids false positives
  • ✅ Dual-path implementation (token scanner + AST visitor) is consistent with the existing rule pattern
  • ✅ Strong test coverage for accept/reject/idempotence cases and ESLint-rule alignment
  • ✅ Clean integration into index.js and lint.js

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 47.1 AIC · ⌖ 4.35 AIC · ⊞ 6.3K
Comment /matt to run again

message:
"parallel() requires homogeneous output types. Use Promise.all([...]) when thunks call agents with different output schemas.",
kind: "no-heterogeneous-parallel",
edits: [{ start: parallel.start, end: parallel.end, text: "Promise.all" }],

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.

[/tdd] The autofix is semantically broken: parallel calls each thunk internally via thunks.map(t => t()), but Promise.all does not — so after the fix the call silently returns [Function, Function] instead of awaited results.

💡 Why and how to fix

parallel's implementation is:

export async function parallel<Result>(thunks) {
  return Promise.all(thunks.map(async (thunk) => thunk()));
}

So parallel([() => call(agentA, x), () => call(agentB, x)]) works correctly, but the autofix produces Promise.all([() => call(agentA, x), () => call(agentB, x)]) which resolves to [Function, Function].

Two safe options:

  1. Remove the autofix (set fixable: undefined, drop the fix callback and edits field) and let the error message guide the developer to unwrap thunks manually.
  2. Generate a correct fix that unwraps arrow function bodies: replace () => call(agentA, x)call(agentA, x) while renaming parallelPromise.all. This is significantly harder in a token-based scanner.

The test for idempotence passes because Promise.all([thunks]) is not re-flagged, but it never validates that the output actually works at runtime.

Comment thread src/eslint-rules.test.js
"Promise.all([\n () => call(agentX, msg),\n () => call(agentY, msg),\n])",
],
])("fixes %s", (source, expected) => {
const problems = lintSource(source).filter((p) => p.kind === "no-heterogeneous-parallel");

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.

[/tdd] The test for fixes only checks that the source string transforms correctly — it never asserts that the fixed code actually awaits the results. A runtime smoke test (or at minimum a comment) would catch the thunk-unwrapping bug.

💡 Add a runtime assertion

A minimal runtime check that would have caught the broken fix:

it('fixed output actually resolves values, not functions', async () => {
  const source = "parallel([() => call(agentA, 'x'), () => call(agentB, 'y')])";
  const fixed = fixSource(source);
  // If thunks are not unwrapped, eval(fixed) would resolve to [Function, Function]
  // This forces the author to think about semantics, not just string output.
  expect(fixed).toMatch(/Promise\.all\(\[call\(/);
});

Even a pure string assertion that the thunk wrappers are removed would surface the issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant