diff --git a/scripts/resolvers/tasks-section.ts b/scripts/resolvers/tasks-section.ts index 2f86f588b..f9c6bbac6 100644 --- a/scripts/resolvers/tasks-section.ts +++ b/scripts/resolvers/tasks-section.ts @@ -118,8 +118,12 @@ if command -v jq >/dev/null 2>&1; then # Filter to current branch + recent commits, then keep records for the # latest run_id only. (Single phase may have multiple files if the user # re-ran the review; aggregator takes the newest.) + # .commit must be bound BEFORE piping to the split commit array: a + # pipe rebinds jq's context, so a bare .commit after it indexes the + # ARRAY with a string, every line errors into 2>/dev/null, and the + # aggregate is empty forever — the #2018 zero-tasks bug. jq -c --arg branch "$BRANCH" --arg commits "$COMMITS_RECENT" \\ - 'select(.branch == $branch and ($commits | split("|") | index(.commit) != null))' \\ + '.commit as $c | select(.branch == $branch and ($commits | split("|") | index($c) != null))' \\ "$f" 2>/dev/null >> "$ALL_JSONL" || true done < <(find "$TASKS_DIR" -maxdepth 1 -name "tasks-$phase-*.jsonl" 2>/dev/null | sort) # Reduce to latest run_id per phase diff --git a/test/tasks-section-jq.test.ts b/test/tasks-section-jq.test.ts new file mode 100644 index 000000000..78f8de9cf --- /dev/null +++ b/test/tasks-section-jq.test.ts @@ -0,0 +1,106 @@ +/** + * Regression pin for #2018: /autoplan Phase 4's task aggregator emitted zero + * tasks on every run, forever, for everyone. + * + * Root cause: the branch+commit filter in scripts/resolvers/tasks-section.ts + * piped to the split commit array and THEN referenced `.commit` — + * + * select(.branch == $branch and ($commits | split("|") | index(.commit) != null)) + * + * In jq, the pipe rebinds the context, so `.commit` was evaluated against the + * split ARRAY ("Cannot index array with string \"commit\""), every input line + * errored, stderr went to /dev/null, `|| true` swallowed the exit code, and + * the aggregate was empty. A dead feature indistinguishable from "no tasks". + * + * These tests run the ACTUAL jq program extracted from the resolver source + * against fixture JSONL, so they were RED against the broken filter and stay + * red if anyone reintroduces a context-rebinding shape. + */ + +import { describe, it, expect } from "bun:test"; +import { execFileSync } from "child_process"; +import { readFileSync } from "fs"; +import { join } from "path"; + +const SOURCE_PATH = join(import.meta.dir, "..", "scripts", "resolvers", "tasks-section.ts"); + +/** + * Extract the emitted jq filter program from the resolver source. The + * resolver builds bash inside a TS template literal, so `\\` in source is a + * bash line-continuation `\` — strip it when unescaping. We match the + * single-quoted jq program on the line that filters by $branch + $commits. + */ +function extractBranchCommitFilter(): string { + const src = readFileSync(SOURCE_PATH, "utf-8"); + const m = src.match(/'([^']*select\(\.branch == \$branch[^']*)'/); + if (!m) throw new Error("branch+commit jq filter not found in tasks-section.ts"); + return m[1].replace(/\\\\/g, "\\"); +} + +function runJq(program: string, inputLines: string[], branch: string, commits: string): string[] { + const out = execFileSync( + "jq", + ["-c", "--arg", "branch", branch, "--arg", "commits", commits, program], + { input: inputLines.join("\n"), encoding: "utf-8" }, + ); + return out.split("\n").filter(Boolean); +} + +const RECORD = (branch: string, commit: string) => + JSON.stringify({ + phase: "ceo-review", + run_id: "20260814T000000Z-1", + branch, + commit, + id: "T1", + priority: "P1", + component: "demo", + files: ["a.ts"], + effort_human: "~1h", + effort_cc: "~5min", + title: "demo task", + source_finding: "demo finding", + }); + +describe("tasks-section jq filter (#2018)", () => { + it("matches a record whose branch and commit are in the window", () => { + const program = extractBranchCommitFilter(); + const matched = runJq( + program, + [RECORD("feature/x", "abc123")], + "feature/x", + "abc123|def456", + ); + // The broken filter returned [] here (every line errored) — the exact + // #2018 symptom: reviews produced tasks, the aggregate table showed none. + expect(matched).toHaveLength(1); + expect(JSON.parse(matched[0]).id).toBe("T1"); + }); + + it("filters out other branches and out-of-window commits", () => { + const program = extractBranchCommitFilter(); + const matched = runJq( + program, + [ + RECORD("feature/x", "abc123"), + RECORD("other-branch", "abc123"), + RECORD("feature/x", "zzz999"), + ], + "feature/x", + "abc123|def456", + ); + expect(matched).toHaveLength(1); + }); + + it("errors on no input line at all rather than fabricating output", () => { + const program = extractBranchCommitFilter(); + expect(runJq(program, [], "feature/x", "abc123")).toHaveLength(0); + }); + + it("source does not reference .commit after a context-rebinding pipe", () => { + const src = readFileSync(SOURCE_PATH, "utf-8"); + // The bug shape: split("|") piped, then a bare `.commit` in the new array + // context. Binding first (`.commit as $c`) is the required form. + expect(src).not.toMatch(/split\("\|"\)\s*\|\s*index\(\.commit\)/); + }); +});