From 8378fc0c74bd1e5641a7b83d669c98ff8db5416b Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Wed, 28 Oct 2026 00:14:36 +0100 Subject: [PATCH 01/23] fix(gate): tick AC/DoD rows only after verdict refusals; refuse silent untick in edit-pr Refs #2898 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetS2hFz48x4dfrpt8j7 --- changes/2898-tick-integrity.md | 3 ++ scripts/github/edit-pr.mjs | 20 +++++++++++++ scripts/github/upsert-checkpoint-verdict.mjs | 27 ++++++++++++++--- test/github/edit-pr.test.mjs | 17 +++++++++++ .../github/upsert-checkpoint-verdict.test.mjs | 30 +++++++++++++++---- 5 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 changes/2898-tick-integrity.md diff --git a/changes/2898-tick-integrity.md b/changes/2898-tick-integrity.md new file mode 100644 index 0000000000..a8e1d02601 --- /dev/null +++ b/changes/2898-tick-integrity.md @@ -0,0 +1,3 @@ +### Fixed + +- `upsert-checkpoint-verdict` now ticks reviewer-verified AC/DoD rows only after every refusal check and skips rows an open judge act item names; `edit-pr` refuses a body that unticks checked rows unless `--allow-untick` is passed (#2898) diff --git a/scripts/github/edit-pr.mjs b/scripts/github/edit-pr.mjs index 6d33500698..7555e6eb04 100644 --- a/scripts/github/edit-pr.mjs +++ b/scripts/github/edit-pr.mjs @@ -37,6 +37,8 @@ At least one edit: --enforce-grill Opt-in GRILL-SUBLOOP-NO-EMBED-SYNTHESIS (#1628): refuse a body that embeds grill transcript/synthesis/Q&A headings. + --allow-untick Allow a body that turns a ticked \`[x]\` checkbox row + into \`[ ]\` (refused by default, naming the rows). --allow-cross-issue Waive the branch-derived closing-reference mismatch guard for a deliberate cross-issue reference. By default a new body whose closing @@ -78,6 +80,7 @@ export function parseEditPrCliArgs(argv) { base: { type: "string" }, "enforce-grill": { type: "boolean" }, "allow-cross-issue": { type: "boolean" }, + "allow-untick": { type: "boolean" }, ...JQ_OUTPUT_PARSE_OPTIONS, }, allowPositionals: true, @@ -97,6 +100,7 @@ export function parseEditPrCliArgs(argv) { base: undefined, enforceGrill: false, allowCrossIssue: false, + allowUntick: false, jq: undefined, silent: false, }; @@ -185,6 +189,10 @@ export function parseEditPrCliArgs(argv) { options.enforceGrill = true; continue; } + if (token.name === "allow-untick") { + options.allowUntick = token.value === undefined || token.value === true || /^(?:true|1)$/iu.test(String(token.value)); + continue; + } if (token.name === "allow-cross-issue") { // Boolean flag: bare enables; parseArgs also accepts `=value` for a // boolean, so honor an explicit `=false`/`=0` (disable) rather than @@ -297,6 +305,14 @@ async function buildEditArgs(options) { return { args, edited }; } +// Labels of `[x]` rows in currentBody that nextBody no longer carries as `[x]`. +const CHECKBOX_ROW = /^\s*[-*]\s+\[([ xX])\]\s+(.+?)\s*$/u; +function untickedRows(currentBody, nextBody) { + const rows = (body) => body.split(/\r?\n/u).map((l) => CHECKBOX_ROW.exec(l)).filter(Boolean); + const stillChecked = new Set(rows(nextBody).filter((m) => m[1] !== " ").map((m) => m[2])); + return rows(currentBody).filter((m) => m[1] !== " " && !stillChecked.has(m[2])).map((m) => m[2]); +} + export async function editPr( options, { env = process.env, ghCommand = "gh", run = runChild, fetchPrContext = defaultFetchPrContext, waiverWriter = false, currentBody } = {}, @@ -330,6 +346,10 @@ export async function editPr( } const waiverRefusal = waiverLineChangeRefusal({ currentBody: baseline, nextBody: guardBody, action: "write" }); if (waiverRefusal) throw new Error(waiverRefusal); + const unticked = untickedRows(baseline, guardBody); + if (unticked.length > 0 && !options.allowUntick) { + throw new Error(`Refusing to untick ${unticked.length} checked row(s) on PR #${options.pr}: ${unticked.map((l) => JSON.stringify(l)).join("; ")}. Edit from the current body, or pass --allow-untick for a deliberate untick.`); + } } if (!options.allowCrossIssue && detectClosingKeyword(guardBody)) { const ctx = await readPrContext(); diff --git a/scripts/github/upsert-checkpoint-verdict.mjs b/scripts/github/upsert-checkpoint-verdict.mjs index 82c0ecbf46..4becca5aae 100755 --- a/scripts/github/upsert-checkpoint-verdict.mjs +++ b/scripts/github/upsert-checkpoint-verdict.mjs @@ -2337,7 +2337,15 @@ function anglesPassCoverage(entries, config, gate) { // labels no reviewer verified stay unchecked in it and still block. Only lines // inside the AC/DoD sections (the AC section for an issue body) are flipped, so // the same label under another heading stays unchecked. -async function tickReviewerVerifiedItems({ repo, pr, verifiedItems, coordinationContext }, { env, ghCommand, runChild }) { +// dryRun computes the ticked artifact without editing and queues each edit in +// pendingWrites; skipLabels (labels an open act item names) are never ticked. +async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, coordinationContext, dryRun = false, pendingWrites = [], skipLabels = new Set() }, { env, ghCommand, runChild }) { + const verifiedItems = allVerified.filter((label) => !skipLabels.has(String(label).trim())); + const tick = async (body, labels, write, scope) => { + const result = await applyTick(body, labels, dryRun, write, scope); + if (dryRun && result.flipped.length > 0) pendingWrites.push(() => applyTick(body, labels, false, write, scope)); + return result; + }; const prData = coordinationContext.prData; if (typeof prData?.body !== "string") { throw new Error(`Cannot tick reviewer-verified items on PR ${pr}: the PR body is not a string.`); @@ -2351,7 +2359,7 @@ async function tickReviewerVerifiedItems({ repo, pr, verifiedItems, coordination const indices = checklistSectionLineIndices(body, options); return (index) => indices.has(index); }; - const prTick = await applyTick(prData.body, inList([...(artifact?.prBodyUncheckedAcItems ?? []), ...(artifact?.prBodyUncheckedDodItems ?? [])]), false, (bodyFile) => + const prTick = await tick(prData.body, inList([...(artifact?.prBodyUncheckedAcItems ?? []), ...(artifact?.prBodyUncheckedDodItems ?? [])]), (bodyFile) => editPr({ repo, pr, bodyFile, addAssignees: [], removeAssignees: [] }, { env, ghCommand, run: runChild, currentBody: prData.body }), inSections(prData.body)); const issueLabels = inList(artifact?.uncheckedAcItems); @@ -2359,7 +2367,7 @@ async function tickReviewerVerifiedItems({ repo, pr, verifiedItems, coordination if (Array.isArray(artifact?.uncheckedAcItems) && artifact.uncheckedAcItems.length > 0) { for (const issue of artifact.uncheckedAcIssues ?? []) { const issueBody = await fetchIssueBody({ repo, issue }, { env, ghCommand, runChild }); - const issueTick = await applyTick(issueBody, issueLabels, false, (bodyFile) => + const issueTick = await tick(issueBody, issueLabels,(bodyFile) => editIssue({ repo, issue, bodyFile, addAssignees: [], removeAssignees: [] }, { env, ghCommand, run: runChild }), inSections(issueBody, { acOnly: true })); tickedIssueBodies.push(issueTick.body); @@ -2376,7 +2384,8 @@ async function tickReviewerVerifiedItems({ repo, pr, verifiedItems, coordination // drop them and let an unverified issue AC item pass the clean guard. // Union with the reload's list so the result is never smaller than either source. const local = tickedIssueBodies.flatMap((body) => detectIssueRefinementArtifact({ body }).uncheckedAcItems); - const uncheckedAcItems = [...new Set([...(reloaded.uncheckedAcItems ?? []), ...local])]; + // A dry run edited nothing, so the reload's re-fetch is stale: use the local list alone. + const uncheckedAcItems = dryRun ? local : [...new Set([...(reloaded.uncheckedAcItems ?? []), ...local])]; return { ...reloaded, uncheckedAcItems }; } @@ -3031,15 +3040,23 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo // must also pass the angle-coverage checks, and so must --findings-json when // present: its throwing coverage check runs later, so an input it refuses // must tick nothing. + const pendingTickWrites = []; const tickCoverageOk = (options.executionMode ?? DEFAULT_EXECUTION_MODE) !== "fanout_fanin" || (ledgerPassesAngleCoverage(preloadedFindingsLedger, config, options.gate) && (!structuredFindings || (anglesPassCoverage(rawFindingsInput, config, options.gate) && anglesPassCoverage(structuredFindings, config, options.gate)))); if (options.gate === "pre_approval_gate" && preloadedFindingsLedger?.verifiedItems?.length > 0 && ledgerVerifiesChecklist(preloadedFindingsLedger) && tickCoverageOk) { + // Dry run: the composition and clean guards read the would-be ticked artifact; the edits run after the last refusal (pendingTickWrites). + const namedByOpenAct = new Set(listOpenActItems([...(preloadedFindingsLedger.findings ?? []), ...enrichedActItems]) + .flatMap((f) => [f.judgeCriterion, ...(Array.isArray(f.checkedCriteria) ? f.checkedCriteria : [])]) + .filter((c) => typeof c === "string").map((c) => c.trim())); coordinationContext.refinementArtifact = await tickReviewerVerifiedItems({ repo: options.repo, pr: options.pr, verifiedItems: preloadedFindingsLedger.verifiedItems, coordinationContext, + dryRun: true, + pendingWrites: pendingTickWrites, + skipLabels: namedByOpenAct, }, gh); } // Set only when the posted checkpoint verdict differs from the ledger's review @@ -3441,6 +3458,8 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo const findingsLedgerWarning = ((options.executionMode ?? DEFAULT_EXECUTION_MODE) === "fanout_fanin" && options.findingsJson && !options.findingsLedger) ? "WARNING: --findings-json without --findings-ledger files zero inline comments — the --findings-ledger (consolidate-fanin --ledger-out → write-gate-findings-log) is the only inline-comment source. Pass --findings-ledger to file inline findings." : null; + // Every refusal has passed: apply the queued reviewer-verified ticks now, so a refused post leaves no ticked box. + for (const write of pendingTickWrites) await write(); // PENDING-REVIEW RESOLUTION: GitHub allows only ONE pending review // per user per PR (PR-scoped, NOT head-scoped), so a --gate review --submit // re-run must RESOLVE the caller's own pending review rather than POST a diff --git a/test/github/edit-pr.test.mjs b/test/github/edit-pr.test.mjs index ff6fe386ff..c4defb1c82 100644 --- a/test/github/edit-pr.test.mjs +++ b/test/github/edit-pr.test.mjs @@ -454,3 +454,20 @@ test("editPr: the closing-reference refusal still fails closed when currentBody /CLOSING-REF-BRANCH-MISMATCH.*fail closed/s, ); }); + +test("editPr (#2898): refuses a body that unticks checked rows, naming them; --allow-untick lets it through", async () => { + const rows = Array.from({ length: 15 }, (_, i) => `row ${i + 1}`); + const live = rows.map((r) => `- [x] ${r}`).join("\n"); + const next = rows.map((r) => `- [ ] ${r}`).join("\n"); + const fetchPrContext = async () => ({ headRefName: "x", closingIssuesReferences: [], body: live }); + const refused = stubGh(); + await assert.rejects( + () => editPr({ repo: "o/n", pr: 5, body: next, addAssignees: [], removeAssignees: [] }, { run: refused.run, fetchPrContext }), + /untick 15 checked row.*"row 1".*"row 15"/s, + ); + assert.equal(refused.calls.length, 0); + const allowed = stubGh(); + await editPr({ repo: "o/n", pr: 5, body: next, addAssignees: [], removeAssignees: [], allowUntick: true }, { run: allowed.run, fetchPrContext }); + assert.equal(allowed.calls.length, 1); + assert.equal(parseEditPrCliArgs(["--repo", "o/n", "--pr", "5", "--body", "x", "--allow-untick"]).allowUntick, true); +}); diff --git a/test/github/upsert-checkpoint-verdict.test.mjs b/test/github/upsert-checkpoint-verdict.test.mjs index e1bc580ae8..c88708af17 100644 --- a/test/github/upsert-checkpoint-verdict.test.mjs +++ b/test/github/upsert-checkpoint-verdict.test.mjs @@ -10727,10 +10727,10 @@ function makeCompositionRunChild(prBody, extraComments = [], { issueBody = TICKE // fresh acceptance-criteria (or pr-checklist) review. const TICK_PROVENANCE = { distinctReviewers: 1, perAngle: [{ angle: "acceptance-criteria", reviewer: "reviewer-ac" }] }; -async function withCompositionRound({ overallVerdict, prBody, gate = "pre_approval_gate", requireFanoutEvidence = false, extraComments = [], verifiedItems, provenance = verifiedItems === undefined ? undefined : TICK_PROVENANCE, issueBody, failBodyEdit, failIssueViewAfter, closingIssues }, fn) { +async function withCompositionRound({ overallVerdict, prBody, gate = "pre_approval_gate", requireFanoutEvidence = false, extraComments = [], verifiedItems, provenance = verifiedItems === undefined ? undefined : TICK_PROVENANCE, issueBody, failBodyEdit, failIssueViewAfter, closingIssues, findings }, fn) { const repoRoot = await stageConfigRepoRoot(requireFanoutEvidence); try { - const ledgerPath = await writeVerdictLedger(repoRoot, { overallVerdict, gate, verifiedItems, provenance }); + const ledgerPath = await writeVerdictLedger(repoRoot, { overallVerdict, gate, verifiedItems, provenance, findings }); const ledgerBefore = await readFile(ledgerPath, "utf8"); const { runChild, calls, editedBodies, control } = gate === "draft_gate" ? makeAcRunChild({ isDraft: true, prBody }) @@ -11116,10 +11116,10 @@ test("tick: a failed post-tick issue re-fetch cannot drop the unverified issue A ); assert.equal(reviewPosted(calls), false); }); - await withCompositionRound(round, async ({ post, postedBody }) => { - const result = await post({ findingsSeverityCounts: CLEAN_COUNTS }); - assert.match(postedBody(), /\*\*Verdict:\*\* blocked/); - assert.deepEqual(result.gateBlockers, [{ kind: "unticked spec-of-record Acceptance criteria", item: "unverified issue criterion" }]); + // The queued issue edit re-reads the issue after the refusals; that read fails too, so nothing is posted (fail closed). + await withCompositionRound(round, async ({ post, calls }) => { + await assert.rejects(() => post({ findingsSeverityCounts: CLEAN_COUNTS }), /ADR-TRIPWIRE-STANDING-WAIVER/); + assert.equal(reviewPosted(calls), false); }); }); @@ -12352,3 +12352,21 @@ test("buildCoordinationEvaluatorInput threads draftCapRoundFixed so pre_approval assert.ok(!evaluate(false).allowedNextActions.includes(PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE), "no proof: entry refused"); assert.ok(!evaluate(undefined).allowedNextActions.includes(PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE), "an unresolved proof (resolver threw) fails closed"); }); + +test("tick (#2898): a refused verdict post changes no PR body", async () => { + await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS }, async ({ post, calls, editedBodies }) => { + await assert.rejects(() => post({ verdict: "clean", findingsSeverityCounts: CLEAN_COUNTS }), /contradicts the consolidated ledger/); + assert.equal(editedBodies.length, 0); + assert.equal(calls.some((c) => c.args[1] === "edit" && (c.args.includes("--body") || c.args.includes("--body-file"))), false); + }); +}); + +test("tick (#2898): a row an open act item names stays unticked; the other verified rows are ticked", async () => { + const act = { angle: "acceptance-criteria", severity: "medium", summary: "no evidence for that row", judgeDisposition: "act", judgeCriterion: TICK_ITEMS[12] }; + await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [act] }, async ({ post, editedBodies }) => { + await post(); + assert.equal(editedBodies.length, 1); + assert.match(editedBodies[0].body, new RegExp(`^- \\[ \\] ${TICK_ITEMS[12]}$`, "m")); + assert.match(editedBodies[0].body, new RegExp(`^- \\[x\\] ${TICK_ITEMS[0]}$`, "m")); + }); +}); From baf76000efa99827960e1196176d8d40109a9b31 Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Wed, 28 Oct 2026 00:19:21 +0100 Subject: [PATCH 02/23] fix(gate): skip spec-id-named rows in the tick and restore the re-fetch test Refs #2898 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetS2hFz48x4dfrpt8j7 --- scripts/github/edit-issue.mjs | 6 +++--- scripts/github/upsert-checkpoint-verdict.mjs | 16 ++++++++++++---- test/github/upsert-checkpoint-verdict.test.mjs | 17 +++++++++++++---- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/scripts/github/edit-issue.mjs b/scripts/github/edit-issue.mjs index 9c8b71614c..ffb440f23c 100644 --- a/scripts/github/edit-issue.mjs +++ b/scripts/github/edit-issue.mjs @@ -216,7 +216,7 @@ export function parseEditIssueCliArgs(argv) { return options; } -export async function editIssue(options, { env = process.env, ghCommand = "gh", run = runChild } = {}) { +export async function editIssue(options, { env = process.env, ghCommand = "gh", run = runChild, currentBody } = {}) { // ADR-TRIPWIRE-STANDING-WAIVER: a PR is also an issue, so `gh issue edit` can // rewrite a PR body. A body write may not add, change or remove an // `adr-tripwire:allow` line; fail closed when the current body is unreadable. @@ -229,8 +229,8 @@ export async function editIssue(options, { env = process.env, ghCommand = "gh", throw new Error(`--body-file ${options.bodyFile} is empty`); } } - let baseline; - try { + let baseline = currentBody; + if (typeof baseline !== "string") try { const view = await run(ghCommand, ["issue", "view", String(options.issue), "--repo", options.repo, "--json", "body"], env); if (view && view.code === 0) baseline = JSON.parse(view.stdout).body; } catch { diff --git a/scripts/github/upsert-checkpoint-verdict.mjs b/scripts/github/upsert-checkpoint-verdict.mjs index 4becca5aae..ce7d187c8e 100755 --- a/scripts/github/upsert-checkpoint-verdict.mjs +++ b/scripts/github/upsert-checkpoint-verdict.mjs @@ -55,7 +55,7 @@ import { } from "./_gate-finding-surface.mjs"; import { fetchAllReviewThreads } from "./list-review-threads.mjs"; import { mergeSameDefectFindings, normalizeFindingSummary } from "./_gate-finding-text.mjs"; -import { stampSpecAuthorityIdentity } from "@dev-loops/core/loop/spec-authority"; +import { extractSpecFromBody, stampSpecAuthorityIdentity } from "@dev-loops/core/loop/spec-authority"; import { readSpecAuthorityIdentity } from "../lib/spec-authority-stamp.mjs"; import { normalizeGate as normalizeGateShared, normalizeVerdict as normalizeVerdictShared } from "./_gate-names.mjs"; import { evaluatePrSizeBudget as realEvaluatePrSizeBudget } from "../loop/check-size-budget.mjs"; @@ -2368,7 +2368,7 @@ async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, for (const issue of artifact.uncheckedAcIssues ?? []) { const issueBody = await fetchIssueBody({ repo, issue }, { env, ghCommand, runChild }); const issueTick = await tick(issueBody, issueLabels,(bodyFile) => - editIssue({ repo, issue, bodyFile, addAssignees: [], removeAssignees: [] }, { env, ghCommand, run: runChild }), + editIssue({ repo, issue, bodyFile, addAssignees: [], removeAssignees: [] }, { env, ghCommand, run: runChild, currentBody: issueBody }), inSections(issueBody, { acOnly: true })); tickedIssueBodies.push(issueTick.body); } @@ -3046,9 +3046,16 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo && (!structuredFindings || (anglesPassCoverage(rawFindingsInput, config, options.gate) && anglesPassCoverage(structuredFindings, config, options.gate)))); if (options.gate === "pre_approval_gate" && preloadedFindingsLedger?.verifiedItems?.length > 0 && ledgerVerifiesChecklist(preloadedFindingsLedger) && tickCoverageOk) { // Dry run: the composition and clean guards read the would-be ticked artifact; the edits run after the last refusal (pendingTickWrites). + // An open act item names a row by label or by spec id (`ac:` / `dod:`, zero-based like specCriterionIds); ids resolve against the PR body's rows. + const prSpec = typeof coordinationContext.prData?.body === "string" ? extractSpecFromBody(coordinationContext.prData.body) : {}; + const rowForId = (c) => { + const m = /^(ac|dod):(\d+)$/u.exec(c); + return m ? (m[1] === "ac" ? prSpec.acceptanceCriteria : prSpec.definitionOfDone)?.[Number(m[2])] : undefined; + }; const namedByOpenAct = new Set(listOpenActItems([...(preloadedFindingsLedger.findings ?? []), ...enrichedActItems]) - .flatMap((f) => [f.judgeCriterion, ...(Array.isArray(f.checkedCriteria) ? f.checkedCriteria : [])]) - .filter((c) => typeof c === "string").map((c) => c.trim())); + .flatMap((f) => [f.defectKey, f.judgeCriterion, ...(Array.isArray(f.checkedCriteria) ? f.checkedCriteria : [])]) + .filter((c) => typeof c === "string").map((c) => c.trim()) + .flatMap((c) => [c, rowForId(c)]).filter((c) => typeof c === "string")); coordinationContext.refinementArtifact = await tickReviewerVerifiedItems({ repo: options.repo, pr: options.pr, @@ -3459,6 +3466,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo ? "WARNING: --findings-json without --findings-ledger files zero inline comments — the --findings-ledger (consolidate-fanin --ledger-out → write-gate-findings-log) is the only inline-comment source. Pass --findings-ledger to file inline findings." : null; // Every refusal has passed: apply the queued reviewer-verified ticks now, so a refused post leaves no ticked box. + // A failed write throws before any post; a rerun re-ticks only the rows still unticked. for (const write of pendingTickWrites) await write(); // PENDING-REVIEW RESOLUTION: GitHub allows only ONE pending review // per user per PR (PR-scoped, NOT head-scoped), so a --gate review --submit diff --git a/test/github/upsert-checkpoint-verdict.test.mjs b/test/github/upsert-checkpoint-verdict.test.mjs index c88708af17..b621964576 100644 --- a/test/github/upsert-checkpoint-verdict.test.mjs +++ b/test/github/upsert-checkpoint-verdict.test.mjs @@ -11116,10 +11116,10 @@ test("tick: a failed post-tick issue re-fetch cannot drop the unverified issue A ); assert.equal(reviewPosted(calls), false); }); - // The queued issue edit re-reads the issue after the refusals; that read fails too, so nothing is posted (fail closed). - await withCompositionRound(round, async ({ post, calls }) => { - await assert.rejects(() => post({ findingsSeverityCounts: CLEAN_COUNTS }), /ADR-TRIPWIRE-STANDING-WAIVER/); - assert.equal(reviewPosted(calls), false); + await withCompositionRound(round, async ({ post, postedBody }) => { + const result = await post({ findingsSeverityCounts: CLEAN_COUNTS }); + assert.match(postedBody(), /\*\*Verdict:\*\* blocked/); + assert.deepEqual(result.gateBlockers, [{ kind: "unticked spec-of-record Acceptance criteria", item: "unverified issue criterion" }]); }); }); @@ -12370,3 +12370,12 @@ test("tick (#2898): a row an open act item names stays unticked; the other verif assert.match(editedBodies[0].body, new RegExp(`^- \\[x\\] ${TICK_ITEMS[0]}$`, "m")); }); }); + +test("tick (#2898): a row an open act item names by spec id (dod:) stays unticked", async () => { + const act = { angle: "acceptance-criteria", severity: "medium", summary: "no evidence for that row", judgeDisposition: "act", judgeCriterion: "dod:1" }; + await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [act] }, async ({ post, editedBodies }) => { + await post(); + assert.match(editedBodies[0].body, new RegExp(`^- \\[ \\] ${TICK_ITEMS[13]}$`, "m")); + assert.match(editedBodies[0].body, new RegExp(`^- \\[x\\] ${TICK_ITEMS[12]}$`, "m")); + }); +}); From 85660bb9519ff897fcc706849d45bd924d166358 Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Wed, 28 Oct 2026 00:23:10 +0100 Subject: [PATCH 03/23] docs(changes): shorten 2898 changelog entry to 200 characters Refs #2898 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetS2hFz48x4dfrpt8j7 --- changes/2898-tick-integrity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/2898-tick-integrity.md b/changes/2898-tick-integrity.md index a8e1d02601..660e68ddb2 100644 --- a/changes/2898-tick-integrity.md +++ b/changes/2898-tick-integrity.md @@ -1,3 +1,3 @@ ### Fixed -- `upsert-checkpoint-verdict` now ticks reviewer-verified AC/DoD rows only after every refusal check and skips rows an open judge act item names; `edit-pr` refuses a body that unticks checked rows unless `--allow-untick` is passed (#2898) +- `upsert-checkpoint-verdict` ticks verified AC/DoD rows only after all refusal checks; `edit-pr` refuses a body that unticks checked rows without `--allow-untick` (#2898) From 837a0e2388f9ab494ffcb080dd738a35c967eb82 Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Wed, 28 Oct 2026 00:44:48 +0100 Subject: [PATCH 04/23] fix(gate): resolve open-act criteria fail-closed, defer tick writes on the live body, count duplicate unticks Refs #2898 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetS2hFz48x4dfrpt8j7 --- changes/2898-tick-integrity.md | 2 +- scripts/github/edit-issue.mjs | 6 +- scripts/github/edit-pr.mjs | 23 +++-- scripts/github/upsert-checkpoint-verdict.mjs | 75 +++++++++------ test/github/edit-pr.test.mjs | 14 ++- .../github/upsert-checkpoint-verdict.test.mjs | 93 ++++++++++++++----- 6 files changed, 151 insertions(+), 62 deletions(-) diff --git a/changes/2898-tick-integrity.md b/changes/2898-tick-integrity.md index 660e68ddb2..37695f13cb 100644 --- a/changes/2898-tick-integrity.md +++ b/changes/2898-tick-integrity.md @@ -1,3 +1,3 @@ ### Fixed -- `upsert-checkpoint-verdict` ticks verified AC/DoD rows only after all refusal checks; `edit-pr` refuses a body that unticks checked rows without `--allow-untick` (#2898) +- `upsert-checkpoint-verdict` ticks verified AC/DoD rows only after all refusal checks; `edit-pr` refuses a body that unticks or removes checked rows (flipped, deleted or reworded) without `--allow-untick` (#2898) diff --git a/scripts/github/edit-issue.mjs b/scripts/github/edit-issue.mjs index ffb440f23c..9c8b71614c 100644 --- a/scripts/github/edit-issue.mjs +++ b/scripts/github/edit-issue.mjs @@ -216,7 +216,7 @@ export function parseEditIssueCliArgs(argv) { return options; } -export async function editIssue(options, { env = process.env, ghCommand = "gh", run = runChild, currentBody } = {}) { +export async function editIssue(options, { env = process.env, ghCommand = "gh", run = runChild } = {}) { // ADR-TRIPWIRE-STANDING-WAIVER: a PR is also an issue, so `gh issue edit` can // rewrite a PR body. A body write may not add, change or remove an // `adr-tripwire:allow` line; fail closed when the current body is unreadable. @@ -229,8 +229,8 @@ export async function editIssue(options, { env = process.env, ghCommand = "gh", throw new Error(`--body-file ${options.bodyFile} is empty`); } } - let baseline = currentBody; - if (typeof baseline !== "string") try { + let baseline; + try { const view = await run(ghCommand, ["issue", "view", String(options.issue), "--repo", options.repo, "--json", "body"], env); if (view && view.code === 0) baseline = JSON.parse(view.stdout).body; } catch { diff --git a/scripts/github/edit-pr.mjs b/scripts/github/edit-pr.mjs index 7555e6eb04..7dc4f0e4af 100644 --- a/scripts/github/edit-pr.mjs +++ b/scripts/github/edit-pr.mjs @@ -37,8 +37,10 @@ At least one edit: --enforce-grill Opt-in GRILL-SUBLOOP-NO-EMBED-SYNTHESIS (#1628): refuse a body that embeds grill transcript/synthesis/Q&A headings. - --allow-untick Allow a body that turns a ticked \`[x]\` checkbox row - into \`[ ]\` (refused by default, naming the rows). + --allow-untick Allow a body that unticks or removes a checked + \`[x]\` checkbox row (a flip to \`[ ]\`, a delete + or a reworded label; refused by default, naming + the rows). --allow-cross-issue Waive the branch-derived closing-reference mismatch guard for a deliberate cross-issue reference. By default a new body whose closing @@ -305,12 +307,19 @@ async function buildEditArgs(options) { return { args, edited }; } -// Labels of `[x]` rows in currentBody that nextBody no longer carries as `[x]`. +// Labels of `[x]` rows in currentBody that nextBody no longer carries as `[x]`: +// unticked, deleted or reworded rows, across every checkbox in the body. Counts +// per label, so one lost occurrence of a duplicated label is reported. const CHECKBOX_ROW = /^\s*[-*]\s+\[([ xX])\]\s+(.+?)\s*$/u; function untickedRows(currentBody, nextBody) { - const rows = (body) => body.split(/\r?\n/u).map((l) => CHECKBOX_ROW.exec(l)).filter(Boolean); - const stillChecked = new Set(rows(nextBody).filter((m) => m[1] !== " ").map((m) => m[2])); - return rows(currentBody).filter((m) => m[1] !== " " && !stillChecked.has(m[2])).map((m) => m[2]); + const checked = (body) => body.split(/\r?\n/u).map((l) => CHECKBOX_ROW.exec(l)).filter((m) => m && m[1] !== " ").map((m) => m[2]); + const remaining = new Map(); + for (const label of checked(nextBody)) remaining.set(label, (remaining.get(label) ?? 0) + 1); + return checked(currentBody).filter((label) => { + const left = remaining.get(label) ?? 0; + if (left > 0) remaining.set(label, left - 1); + return left === 0; + }); } export async function editPr( @@ -348,7 +357,7 @@ export async function editPr( if (waiverRefusal) throw new Error(waiverRefusal); const unticked = untickedRows(baseline, guardBody); if (unticked.length > 0 && !options.allowUntick) { - throw new Error(`Refusing to untick ${unticked.length} checked row(s) on PR #${options.pr}: ${unticked.map((l) => JSON.stringify(l)).join("; ")}. Edit from the current body, or pass --allow-untick for a deliberate untick.`); + throw new Error(`Refusing to untick or remove ${unticked.length} checked row(s) on PR #${options.pr} (unticked, deleted or reworded): ${unticked.map((l) => JSON.stringify(l)).join("; ")}. Edit from the current body, or pass --allow-untick for a deliberate change.`); } } if (!options.allowCrossIssue && detectClosingKeyword(guardBody)) { diff --git a/scripts/github/upsert-checkpoint-verdict.mjs b/scripts/github/upsert-checkpoint-verdict.mjs index ce7d187c8e..97a499e1f6 100755 --- a/scripts/github/upsert-checkpoint-verdict.mjs +++ b/scripts/github/upsert-checkpoint-verdict.mjs @@ -55,7 +55,7 @@ import { } from "./_gate-finding-surface.mjs"; import { fetchAllReviewThreads } from "./list-review-threads.mjs"; import { mergeSameDefectFindings, normalizeFindingSummary } from "./_gate-finding-text.mjs"; -import { extractSpecFromBody, stampSpecAuthorityIdentity } from "@dev-loops/core/loop/spec-authority"; +import { stampSpecAuthorityIdentity } from "@dev-loops/core/loop/spec-authority"; import { readSpecAuthorityIdentity } from "../lib/spec-authority-stamp.mjs"; import { normalizeGate as normalizeGateShared, normalizeVerdict as normalizeVerdictShared } from "./_gate-names.mjs"; import { evaluatePrSizeBudget as realEvaluatePrSizeBudget } from "../loop/check-size-budget.mjs"; @@ -2338,12 +2338,29 @@ function anglesPassCoverage(entries, config, gate) { // inside the AC/DoD sections (the AC section for an issue body) are flipped, so // the same label under another heading stays unchecked. // dryRun computes the ticked artifact without editing and queues each edit in -// pendingWrites; skipLabels (labels an open act item names) are never ticked. -async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, coordinationContext, dryRun = false, pendingWrites = [], skipLabels = new Set() }, { env, ghCommand, runChild }) { - const verifiedItems = allVerified.filter((label) => !skipLabels.has(String(label).trim())); - const tick = async (body, labels, write, scope) => { - const result = await applyTick(body, labels, dryRun, write, scope); - if (dryRun && result.flipped.length > 0) pendingWrites.push(() => applyTick(body, labels, false, write, scope)); +// pendingWrites; each queued write re-fetches the live body and re-applies the +// tick to it, so a concurrent body edit is kept and the edit guards compare +// against the live body. namedCriteria (open act item criterion texts) are never +// ticked: a verified label that a criterion contains is skipped, and an `ac:` / +// `dod:` id skips every PR-body row of that category (the ids number the pinned +// spec, which this tick cannot map to a PR row). +async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, coordinationContext, dryRun = false, pendingWrites = [], namedCriteria = [] }, { env, ghCommand, runChild }) { + const verifiedItems = allVerified.filter((label) => { + const text = String(label).trim(); + return !text || !namedCriteria.some((c) => c.includes(text)); + }); + const inSections = (body, options) => { + const indices = checklistSectionLineIndices(body, options); + return (index) => indices.has(index); + }; + const tick = async (body, labels, write, liveIssue, options) => { + const result = await applyTick(body, labels, dryRun, write, inSections(body, options)); + if (dryRun && result.flipped.length > 0) { + pendingWrites.push(async () => { + const live = await fetchIssueBody({ repo, issue: liveIssue }, { env, ghCommand, runChild }); + return applyTick(live, labels, false, write, inSections(live, options)); + }); + } return result; }; const prData = coordinationContext.prData; @@ -2355,21 +2372,26 @@ async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, const allowed = new Set(Array.isArray(list) ? list : []); return verifiedItems.filter((label) => allowed.has(String(label).trim())); }; - const inSections = (body, options) => { - const indices = checklistSectionLineIndices(body, options); - return (index) => indices.has(index); - }; - const prTick = await tick(prData.body, inList([...(artifact?.prBodyUncheckedAcItems ?? []), ...(artifact?.prBodyUncheckedDodItems ?? [])]), (bodyFile) => - editPr({ repo, pr, bodyFile, addAssignees: [], removeAssignees: [] }, { env, ghCommand, run: runChild, currentBody: prData.body }), - inSections(prData.body)); + const namedCategories = new Set(namedCriteria.flatMap((c) => [...c.matchAll(/(?:^|\s)(ac|dod):\d+\b/giu)].map((m) => m[1].toLowerCase()))); + const prSkipped = new Set([ + ...(namedCategories.has("ac") ? artifact?.prBodyUncheckedAcItems ?? [] : []), + ...(namedCategories.has("dod") ? artifact?.prBodyUncheckedDodItems ?? [] : []), + ].map((label) => String(label).trim())); + const prLabels = inList([...(artifact?.prBodyUncheckedAcItems ?? []), ...(artifact?.prBodyUncheckedDodItems ?? [])]) + .filter((label) => !prSkipped.has(String(label).trim())); + const prTick = await tick(prData.body, prLabels, (bodyFile) => + editPr({ repo, pr, bodyFile, addAssignees: [], removeAssignees: [] }, { env, ghCommand, run: runChild }), + pr); const issueLabels = inList(artifact?.uncheckedAcItems); const tickedIssueBodies = []; + const flippedIssueLabels = new Set(); if (Array.isArray(artifact?.uncheckedAcItems) && artifact.uncheckedAcItems.length > 0) { for (const issue of artifact.uncheckedAcIssues ?? []) { const issueBody = await fetchIssueBody({ repo, issue }, { env, ghCommand, runChild }); - const issueTick = await tick(issueBody, issueLabels,(bodyFile) => - editIssue({ repo, issue, bodyFile, addAssignees: [], removeAssignees: [] }, { env, ghCommand, run: runChild, currentBody: issueBody }), - inSections(issueBody, { acOnly: true })); + const issueTick = await tick(issueBody, issueLabels, (bodyFile) => + editIssue({ repo, issue, bodyFile, addAssignees: [], removeAssignees: [] }, { env, ghCommand, run: runChild }), + issue, { acOnly: true }); + issueTick.flipped.forEach((label) => flippedIssueLabels.add(String(label).trim())); tickedIssueBodies.push(issueTick.body); } } @@ -2384,8 +2406,9 @@ async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, // drop them and let an unverified issue AC item pass the clean guard. // Union with the reload's list so the result is never smaller than either source. const local = tickedIssueBodies.flatMap((body) => detectIssueRefinementArtifact({ body }).uncheckedAcItems); - // A dry run edited nothing, so the reload's re-fetch is stale: use the local list alone. - const uncheckedAcItems = dryRun ? local : [...new Set([...(reloaded.uncheckedAcItems ?? []), ...local])]; + // A dry run edited nothing, so the reload's issue rows still carry the labels this tick would flip: drop those, keep the rest. + const reloadedItems = (reloaded.uncheckedAcItems ?? []).filter((label) => !(dryRun && flippedIssueLabels.has(String(label).trim()))); + const uncheckedAcItems = [...new Set([...reloadedItems, ...local])]; return { ...reloaded, uncheckedAcItems }; } @@ -3046,16 +3069,10 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo && (!structuredFindings || (anglesPassCoverage(rawFindingsInput, config, options.gate) && anglesPassCoverage(structuredFindings, config, options.gate)))); if (options.gate === "pre_approval_gate" && preloadedFindingsLedger?.verifiedItems?.length > 0 && ledgerVerifiesChecklist(preloadedFindingsLedger) && tickCoverageOk) { // Dry run: the composition and clean guards read the would-be ticked artifact; the edits run after the last refusal (pendingTickWrites). - // An open act item names a row by label or by spec id (`ac:` / `dod:`, zero-based like specCriterionIds); ids resolve against the PR body's rows. - const prSpec = typeof coordinationContext.prData?.body === "string" ? extractSpecFromBody(coordinationContext.prData.body) : {}; - const rowForId = (c) => { - const m = /^(ac|dod):(\d+)$/u.exec(c); - return m ? (m[1] === "ac" ? prSpec.acceptanceCriteria : prSpec.definitionOfDone)?.[Number(m[2])] : undefined; - }; - const namedByOpenAct = new Set(listOpenActItems([...(preloadedFindingsLedger.findings ?? []), ...enrichedActItems]) + // An open act item names a row by its label text or by a spec id (`ac:` / `dod:`); see tickReviewerVerifiedItems. + const namedCriteria = listOpenActItems([...(preloadedFindingsLedger.findings ?? []), ...enrichedActItems]) .flatMap((f) => [f.defectKey, f.judgeCriterion, ...(Array.isArray(f.checkedCriteria) ? f.checkedCriteria : [])]) - .filter((c) => typeof c === "string").map((c) => c.trim()) - .flatMap((c) => [c, rowForId(c)]).filter((c) => typeof c === "string")); + .filter((c) => typeof c === "string").map((c) => c.trim()).filter(Boolean); coordinationContext.refinementArtifact = await tickReviewerVerifiedItems({ repo: options.repo, pr: options.pr, @@ -3063,7 +3080,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo coordinationContext, dryRun: true, pendingWrites: pendingTickWrites, - skipLabels: namedByOpenAct, + namedCriteria, }, gh); } // Set only when the posted checkpoint verdict differs from the ledger's review diff --git a/test/github/edit-pr.test.mjs b/test/github/edit-pr.test.mjs index c4defb1c82..f8235a2591 100644 --- a/test/github/edit-pr.test.mjs +++ b/test/github/edit-pr.test.mjs @@ -463,7 +463,7 @@ test("editPr (#2898): refuses a body that unticks checked rows, naming them; --a const refused = stubGh(); await assert.rejects( () => editPr({ repo: "o/n", pr: 5, body: next, addAssignees: [], removeAssignees: [] }, { run: refused.run, fetchPrContext }), - /untick 15 checked row.*"row 1".*"row 15"/s, + /untick or remove 15 checked row.*"row 1".*"row 15"/s, ); assert.equal(refused.calls.length, 0); const allowed = stubGh(); @@ -471,3 +471,15 @@ test("editPr (#2898): refuses a body that unticks checked rows, naming them; --a assert.equal(allowed.calls.length, 1); assert.equal(parseEditPrCliArgs(["--repo", "o/n", "--pr", "5", "--body", "x", "--allow-untick"]).allowUntick, true); }); + +test("editPr (#2898): the untick guard counts duplicate labels and reports a removed or reworded checked row", async () => { + const live = ["## AC", "- [x] Tests pass", "## DoD", "- [x] Tests pass", "- [x] Docs updated"].join("\n"); + const fetchPrContext = async () => ({ headRefName: "x", closingIssuesReferences: [], body: live }); + const attempt = (body) => editPr({ repo: "o/n", pr: 5, body, addAssignees: [], removeAssignees: [] }, { run: stubGh().run, fetchPrContext }); + // both duplicates kept checked: accepted + await attempt(live); + // one of two identical checked labels unticked: refused + await assert.rejects(() => attempt(live.replace("## DoD\n- [x] Tests pass", "## DoD\n- [ ] Tests pass")), /untick or remove 1 checked row.*"Tests pass"/s); + // a checked label removed: refused + await assert.rejects(() => attempt(live.replace("\n- [x] Docs updated", "")), /untick or remove 1 checked row.*"Docs updated"/s); +}); diff --git a/test/github/upsert-checkpoint-verdict.test.mjs b/test/github/upsert-checkpoint-verdict.test.mjs index b621964576..7c378b76f0 100644 --- a/test/github/upsert-checkpoint-verdict.test.mjs +++ b/test/github/upsert-checkpoint-verdict.test.mjs @@ -9820,6 +9820,7 @@ function makeAcRunChild({ failBodyEdit = false, // Fail every `gh issue view` after this many successful ones (null: never). failIssueViewAfter = null, + failIssueViewCount = Infinity, } = {}) { const calls = []; const control = { failBodyEdit }; @@ -9849,7 +9850,12 @@ function makeAcRunChild({ // gh issue view --json body — the linked-issue spec-of-record fetch. if (args[0] === "issue" && args[1] === "view") { const issue = args[2]; - if (failIssueViewAfter !== null && issueViews++ >= failIssueViewAfter) return { code: 1, stdout: "", stderr: "HTTP 502: issue view failed\n" }; + // A PR is also an issue: the live PR body, after any concurrent edit. + if (issue === "17") { + if (control.concurrentPrBody !== undefined) currentPrBody = control.concurrentPrBody; + return { code: 0, stdout: JSON.stringify({ body: currentPrBody }) + "\n", stderr: "" }; + } + if (failIssueViewAfter !== null && issueViews++ >= failIssueViewAfter && issueViews <= failIssueViewAfter + failIssueViewCount) return { code: 1, stdout: "", stderr: "HTTP 502: issue view failed\n" }; const body = issueBodies[issue]; if (body === undefined) return { code: 1, stdout: "", stderr: "not found\n" }; return { code: 0, stdout: JSON.stringify({ body }) + "\n", stderr: "" }; @@ -10697,15 +10703,16 @@ const COMPOSITION_UNCHECKED_AC_PR_BODY = [ "- none", "", ].join("\n"); -function makeCompositionRunChild(prBody, extraComments = [], { issueBody = TICKED_AC_ISSUE_BODY, failBodyEdit = false, failIssueViewAfter = null, closingIssues = [900] } = {}) { +function makeCompositionRunChild(prBody, extraComments = [], { issueBody = TICKED_AC_ISSUE_BODY, failBodyEdit = false, failIssueViewAfter = null, failIssueViewCount, closingIssues = [900], extraIssueBodies = {} } = {}) { return makeAcRunChild({ isDraft: false, // Only issue 900 has a body; any other linked issue fails every fetch. closingIssues: closingIssues.map((number) => ({ number })), - issueBodyByNumber: { 900: issueBody }, + issueBodyByNumber: { 900: issueBody, ...extraIssueBodies }, prBody, failBodyEdit, failIssueViewAfter, + failIssueViewCount, reviews: [1, 2, 3, 4, 5].map((i) => ({ author: { login: "copilot-pull-request-reviewer[bot]" }, state: "COMMENTED", submittedAt: `2026-06-01T20:0${i}:00Z`, commit: { oid: `${i}`.repeat(40) }, @@ -10727,14 +10734,14 @@ function makeCompositionRunChild(prBody, extraComments = [], { issueBody = TICKE // fresh acceptance-criteria (or pr-checklist) review. const TICK_PROVENANCE = { distinctReviewers: 1, perAngle: [{ angle: "acceptance-criteria", reviewer: "reviewer-ac" }] }; -async function withCompositionRound({ overallVerdict, prBody, gate = "pre_approval_gate", requireFanoutEvidence = false, extraComments = [], verifiedItems, provenance = verifiedItems === undefined ? undefined : TICK_PROVENANCE, issueBody, failBodyEdit, failIssueViewAfter, closingIssues, findings }, fn) { +async function withCompositionRound({ overallVerdict, prBody, gate = "pre_approval_gate", requireFanoutEvidence = false, extraComments = [], verifiedItems, provenance = verifiedItems === undefined ? undefined : TICK_PROVENANCE, issueBody, failBodyEdit, failIssueViewAfter, failIssueViewCount, closingIssues, extraIssueBodies, findings }, fn) { const repoRoot = await stageConfigRepoRoot(requireFanoutEvidence); try { const ledgerPath = await writeVerdictLedger(repoRoot, { overallVerdict, gate, verifiedItems, provenance, findings }); const ledgerBefore = await readFile(ledgerPath, "utf8"); const { runChild, calls, editedBodies, control } = gate === "draft_gate" ? makeAcRunChild({ isDraft: true, prBody }) - : makeCompositionRunChild(prBody, extraComments, { issueBody, failBodyEdit, failIssueViewAfter, closingIssues }); + : makeCompositionRunChild(prBody, extraComments, { issueBody, failBodyEdit, failIssueViewAfter, failIssueViewCount, closingIssues, extraIssueBodies }); const post = (overrides = {}) => upsertCheckpointVerdict({ repo: "owner/repo", pr: 17, gate, headSha: GATE_FULL_HEAD, findingsSummary: "review round summary", @@ -11107,8 +11114,8 @@ const TICK_ISSUE_BODY_WITH_UNVERIFIED = [ const reviewPosted = (calls) => calls.some((c) => c.args.some((x) => x.includes("pulls/17/reviews")) && c.args.includes("POST")); test("tick: a failed post-tick issue re-fetch cannot drop the unverified issue AC item, so no clean is posted", async () => { - // Views 1-3 (coordination load, tick fetch, edit-issue guard body read) succeed; the reload re-fetch fails. - const round = { overallVerdict: "clean", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, issueBody: TICK_ISSUE_BODY_WITH_UNVERIFIED, failIssueViewAfter: 3 }; + // Views 1-2 (coordination load, tick fetch) succeed; the dry-run reload re-fetch fails. + const round = { overallVerdict: "clean", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, issueBody: TICK_ISSUE_BODY_WITH_UNVERIFIED, failIssueViewAfter: 2, failIssueViewCount: 1 }; await withCompositionRound(round, async ({ post, calls }) => { await assert.rejects( () => post({ verdict: "clean", findingsSeverityCounts: CLEAN_COUNTS }), @@ -12352,30 +12359,74 @@ test("buildCoordinationEvaluatorInput threads draftCapRoundFixed so pre_approval assert.ok(!evaluate(false).allowedNextActions.includes(PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE), "no proof: entry refused"); assert.ok(!evaluate(undefined).allowedNextActions.includes(PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE), "an unresolved proof (resolver threw) fails closed"); }); +const bodyEdits = (calls) => calls.filter((c) => c.args[1] === "edit" && (c.args.includes("--body") || c.args.includes("--body-file"))); +const openAct = (extra) => ({ angle: "acceptance-criteria", severity: "medium", summary: "no evidence for that row", judgeDisposition: "act", ...extra }); +const UNTICKED_TICK_ISSUE_BODY = ["## Acceptance criteria", "", "- [ ] criterion number 1", "- [ ] criterion number 2", ""].join("\n"); -test("tick (#2898): a refused verdict post changes no PR body", async () => { - await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS }, async ({ post, calls, editedBodies }) => { +test("tick (#2898): a refused verdict post changes no PR body and no issue body", async () => { + await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, issueBody: UNTICKED_TICK_ISSUE_BODY, verifiedItems: TICK_ITEMS }, async ({ post, calls, editedBodies }) => { await assert.rejects(() => post({ verdict: "clean", findingsSeverityCounts: CLEAN_COUNTS }), /contradicts the consolidated ledger/); assert.equal(editedBodies.length, 0); - assert.equal(calls.some((c) => c.args[1] === "edit" && (c.args.includes("--body") || c.args.includes("--body-file"))), false); + assert.equal(bodyEdits(calls).length, 0); }); }); -test("tick (#2898): a row an open act item names stays unticked; the other verified rows are ticked", async () => { - const act = { angle: "acceptance-criteria", severity: "medium", summary: "no evidence for that row", judgeDisposition: "act", judgeCriterion: TICK_ITEMS[12] }; - await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [act] }, async ({ post, editedBodies }) => { +test("tick (#2898): a row an open act item names by label stays unticked; a criterion with a prefix or a surrounding clause names it too", async () => { + for (const judgeCriterion of [TICK_ITEMS[12], `DoD: ${TICK_ITEMS[12]}`, `the row ${TICK_ITEMS[12]} has no evidence`]) { + await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ judgeCriterion })] }, async ({ post, editedBodies }) => { + await post(); + assert.equal(editedBodies.length, 1); + assert.match(editedBodies[0].body, new RegExp(`^- \\[ \\] ${TICK_ITEMS[12]}$`, "m")); + assert.match(editedBodies[0].body, new RegExp(`^- \\[x\\] ${TICK_ITEMS[1]}$`, "m")); + }); + } + // A criterion that does not contain the label names nothing: every verified row is ticked. + await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ judgeCriterion: "an unrelated clause" })] }, async ({ post, editedBodies }) => { await post(); - assert.equal(editedBodies.length, 1); - assert.match(editedBodies[0].body, new RegExp(`^- \\[ \\] ${TICK_ITEMS[12]}$`, "m")); - assert.match(editedBodies[0].body, new RegExp(`^- \\[x\\] ${TICK_ITEMS[0]}$`, "m")); + assert.doesNotMatch(editedBodies[0].body, /- \[ \]/); + }); +}); + +test("tick (#2898): a spec id (dod: / ac:) skips every PR row of its category, whatever order the PR body lists them", async () => { + const dod = ["dod:1", "dod:0 The tick never flips a row that an open act item names."]; + for (const judgeCriterion of dod) { + await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ judgeCriterion })] }, async ({ post, editedBodies }) => { + await post(); + for (const item of TICK_ITEMS.slice(12)) assert.match(editedBodies[0].body, new RegExp(`^- \\[ \\] ${item}$`, "m")); + for (const item of TICK_ITEMS.slice(0, 12)) assert.match(editedBodies[0].body, new RegExp(`^- \\[x\\] ${item}$`, "m")); + }); + } + await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ judgeCriterion: "ac:0" })] }, async ({ post, editedBodies }) => { + await post(); + for (const item of TICK_ITEMS.slice(0, 12)) assert.match(editedBodies[0].body, new RegExp(`^- \\[ \\] ${item}$`, "m")); + for (const item of TICK_ITEMS.slice(12)) assert.match(editedBodies[0].body, new RegExp(`^- \\[x\\] ${item}$`, "m")); }); }); -test("tick (#2898): a row an open act item names by spec id (dod:) stays unticked", async () => { - const act = { angle: "acceptance-criteria", severity: "medium", summary: "no evidence for that row", judgeDisposition: "act", judgeCriterion: "dod:1" }; - await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [act] }, async ({ post, editedBodies }) => { +test("tick (#2898): an open act item carrying checkedCriteria and no judgeCriterion keeps the named category unticked", async () => { + await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ checkedCriteria: ["dod:1"] })] }, async ({ post, editedBodies }) => { await post(); - assert.match(editedBodies[0].body, new RegExp(`^- \\[ \\] ${TICK_ITEMS[13]}$`, "m")); - assert.match(editedBodies[0].body, new RegExp(`^- \\[x\\] ${TICK_ITEMS[12]}$`, "m")); + for (const item of TICK_ITEMS.slice(12)) assert.match(editedBodies[0].body, new RegExp(`^- \\[ \\] ${item}$`, "m")); + assert.match(editedBodies[0].body, new RegExp(`^- \\[x\\] ${TICK_ITEMS[0]}$`, "m")); + }); +}); + +test("tick (#2898): the deferred write re-applies the tick to the live body and keeps a concurrent edit", async () => { + await withCompositionRound({ overallVerdict: "clean", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS }, async ({ post, editedBodies, control }) => { + control.concurrentPrBody = `${TICK_PR_BODY}\nConcurrent note added during the round.\n`; + await post({ findingsSeverityCounts: CLEAN_COUNTS }); + assert.equal(editedBodies.length, 1); + assert.match(editedBodies[0].body, /^Concurrent note added during the round\.$/m); + for (const item of TICK_ITEMS) assert.match(editedBodies[0].body, new RegExp(`^- \\[x\\] ${item}$`, "m")); + }); +}); + +test("tick (#2898): the dry-run artifact keeps an issue's unticked AC row that only the reload reads", async () => { + const extraIssueBodies = { 901: ["## Acceptance criteria", "", "- [ ] only the reload sees this row", ""].join("\n") }; + // View 1 (coordination load of issue 901) fails once; the tick fetch and the reload then read issue 900 and 901. + const round = { overallVerdict: "clean", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, issueBody: UNTICKED_TICK_ISSUE_BODY, closingIssues: [900, 901], extraIssueBodies, failIssueViewAfter: 1, failIssueViewCount: 1 }; + await withCompositionRound(round, async ({ post }) => { + const result = await post({ findingsSeverityCounts: CLEAN_COUNTS }); + assert.deepEqual(result.gateBlockers, [{ kind: "unticked spec-of-record Acceptance criteria", item: "only the reload sees this row" }]); }); }); From a43006e0fd60a038915fc3024505bf288800bc80 Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Wed, 28 Oct 2026 00:49:07 +0100 Subject: [PATCH 05/23] docs(changes): shorten 2898 tick-integrity entry to 200 chars Refs #2898 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetS2hFz48x4dfrpt8j7 --- changes/2898-tick-integrity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/2898-tick-integrity.md b/changes/2898-tick-integrity.md index 37695f13cb..8c03656e39 100644 --- a/changes/2898-tick-integrity.md +++ b/changes/2898-tick-integrity.md @@ -1,3 +1,3 @@ ### Fixed -- `upsert-checkpoint-verdict` ticks verified AC/DoD rows only after all refusal checks; `edit-pr` refuses a body that unticks or removes checked rows (flipped, deleted or reworded) without `--allow-untick` (#2898) +- `upsert-checkpoint-verdict` ticks verified rows after all refusal checks; `edit-pr` refuses a body that unticks or removes checked rows without `--allow-untick` (#2898) From 2870f7dd223388a1c505d74bd099f0dbf66b37b6 Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Wed, 28 Oct 2026 00:54:44 +0100 Subject: [PATCH 06/23] fix(gate): match a spec id after any word boundary in an open act criterion Refs #2898 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetS2hFz48x4dfrpt8j7 --- scripts/github/upsert-checkpoint-verdict.mjs | 2 +- test/github/upsert-checkpoint-verdict.test.mjs | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/github/upsert-checkpoint-verdict.mjs b/scripts/github/upsert-checkpoint-verdict.mjs index 97a499e1f6..d4094c1ae3 100755 --- a/scripts/github/upsert-checkpoint-verdict.mjs +++ b/scripts/github/upsert-checkpoint-verdict.mjs @@ -2372,7 +2372,7 @@ async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, const allowed = new Set(Array.isArray(list) ? list : []); return verifiedItems.filter((label) => allowed.has(String(label).trim())); }; - const namedCategories = new Set(namedCriteria.flatMap((c) => [...c.matchAll(/(?:^|\s)(ac|dod):\d+\b/giu)].map((m) => m[1].toLowerCase()))); + const namedCategories = new Set(namedCriteria.flatMap((c) => [...c.matchAll(/\b(ac|dod):\d+\b/giu)].map((m) => m[1].toLowerCase()))); const prSkipped = new Set([ ...(namedCategories.has("ac") ? artifact?.prBodyUncheckedAcItems ?? [] : []), ...(namedCategories.has("dod") ? artifact?.prBodyUncheckedDodItems ?? [] : []), diff --git a/test/github/upsert-checkpoint-verdict.test.mjs b/test/github/upsert-checkpoint-verdict.test.mjs index 7c378b76f0..d70fe228cf 100644 --- a/test/github/upsert-checkpoint-verdict.test.mjs +++ b/test/github/upsert-checkpoint-verdict.test.mjs @@ -12388,7 +12388,7 @@ test("tick (#2898): a row an open act item names by label stays unticked; a crit }); test("tick (#2898): a spec id (dod: / ac:) skips every PR row of its category, whatever order the PR body lists them", async () => { - const dod = ["dod:1", "dod:0 The tick never flips a row that an open act item names."]; + const dod = ["dod:1", "(dod:1)", "dod:0 The tick never flips a row that an open act item names."]; for (const judgeCriterion of dod) { await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ judgeCriterion })] }, async ({ post, editedBodies }) => { await post(); @@ -12401,6 +12401,13 @@ test("tick (#2898): a spec id (dod: / ac:) skips every PR row of its categ for (const item of TICK_ITEMS.slice(0, 12)) assert.match(editedBodies[0].body, new RegExp(`^- \\[ \\] ${item}$`, "m")); for (const item of TICK_ITEMS.slice(12)) assert.match(editedBodies[0].body, new RegExp(`^- \\[x\\] ${item}$`, "m")); }); + // ponytail: a second id after a slash or comma names its category too; with both named nothing is ticked. + for (const judgeCriterion of ["ac:0/dod:1", "ac:0,dod:1", "(ac:0) (dod:1)"]) { + await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ judgeCriterion })] }, async ({ post, editedBodies }) => { + await post(); + for (const item of TICK_ITEMS) assert.doesNotMatch(editedBodies[0]?.body ?? "", new RegExp(`^- \\[x\\] ${item}$`, "m")); + }); + } }); test("tick (#2898): an open act item carrying checkedCriteria and no judgeCriterion keeps the named category unticked", async () => { From 7ede9a7134cc0a8ac0b1a7a5a2f4fa14411223f5 Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Wed, 28 Oct 2026 01:04:20 +0100 Subject: [PATCH 07/23] fix(gate): keep a label a reload-only linked issue carries unticked in the dry-run artifact Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetS2hFz48x4dfrpt8j7 --- scripts/github/upsert-checkpoint-verdict.mjs | 14 +++++++++++++- test/github/upsert-checkpoint-verdict.test.mjs | 10 ++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/scripts/github/upsert-checkpoint-verdict.mjs b/scripts/github/upsert-checkpoint-verdict.mjs index d4094c1ae3..e61803803c 100755 --- a/scripts/github/upsert-checkpoint-verdict.mjs +++ b/scripts/github/upsert-checkpoint-verdict.mjs @@ -2407,7 +2407,19 @@ async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, // Union with the reload's list so the result is never smaller than either source. const local = tickedIssueBodies.flatMap((body) => detectIssueRefinementArtifact({ body }).uncheckedAcItems); // A dry run edited nothing, so the reload's issue rows still carry the labels this tick would flip: drop those, keep the rest. - const reloadedItems = (reloaded.uncheckedAcItems ?? []).filter((label) => !(dryRun && flippedIssueLabels.has(String(label).trim()))); + // A linked issue only the reload read keeps its own unticked rows, even when they share a label this tick flips elsewhere. + const reloadOnlyLabels = new Set(); + if (dryRun) { + const fetchedIssues = new Set(artifact.uncheckedAcIssues ?? []); + for (const issue of (reloaded.uncheckedAcIssues ?? []).filter((n) => !fetchedIssues.has(n))) { + const body = await fetchIssueBody({ repo, issue }, { env, ghCommand, runChild }); + detectIssueRefinementArtifact({ body }).uncheckedAcItems.forEach((label) => reloadOnlyLabels.add(String(label).trim())); + } + } + const reloadedItems = (reloaded.uncheckedAcItems ?? []).filter((label) => { + const text = String(label).trim(); + return !(dryRun && flippedIssueLabels.has(text) && !reloadOnlyLabels.has(text)); + }); const uncheckedAcItems = [...new Set([...reloadedItems, ...local])]; return { ...reloaded, uncheckedAcItems }; } diff --git a/test/github/upsert-checkpoint-verdict.test.mjs b/test/github/upsert-checkpoint-verdict.test.mjs index d70fe228cf..2acabeb96e 100644 --- a/test/github/upsert-checkpoint-verdict.test.mjs +++ b/test/github/upsert-checkpoint-verdict.test.mjs @@ -12359,6 +12359,16 @@ test("buildCoordinationEvaluatorInput threads draftCapRoundFixed so pre_approval assert.ok(!evaluate(false).allowedNextActions.includes(PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE), "no proof: entry refused"); assert.ok(!evaluate(undefined).allowedNextActions.includes(PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE), "an unresolved proof (resolver threw) fails closed"); }); + +test("tick (#2898): the dry-run artifact keeps a label a reload-only issue carries unticked even when another issue's row is flipped", async () => { + const extraIssueBodies = { 901: ["## Acceptance criteria", "", "- [ ] criterion number 1", ""].join("\n") }; + const round = { overallVerdict: "clean", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, issueBody: UNTICKED_TICK_ISSUE_BODY, closingIssues: [900, 901], extraIssueBodies, failIssueViewAfter: 1, failIssueViewCount: 1 }; + await withCompositionRound(round, async ({ post }) => { + const result = await post({ findingsSeverityCounts: CLEAN_COUNTS }); + assert.deepEqual(result.gateBlockers, [{ kind: "unticked spec-of-record Acceptance criteria", item: "criterion number 1" }]); + }); +}); + const bodyEdits = (calls) => calls.filter((c) => c.args[1] === "edit" && (c.args.includes("--body") || c.args.includes("--body-file"))); const openAct = (extra) => ({ angle: "acceptance-criteria", severity: "medium", summary: "no evidence for that row", judgeDisposition: "act", ...extra }); const UNTICKED_TICK_ISSUE_BODY = ["## Acceptance criteria", "", "- [ ] criterion number 1", "- [ ] criterion number 2", ""].join("\n"); From f139c23a8fa02dc933da1804cbdc335eec54fd80 Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Wed, 28 Oct 2026 01:27:53 +0100 Subject: [PATCH 08/23] fix(gate): tick after the last refusal, resolve spec ids to one row, match every checkbox form in edit-pr Refs #2898 - upsert-checkpoint-verdict: run the deferred tick writes after the desiredBody id guard - ac:/dod: skip only the PR row the pinned spec resolves to; unresolved ids skip the category - edit-pr untick guard reads the same checkbox grammar as the tick tool Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetS2hFz48x4dfrpt8j7 --- scripts/github/edit-pr.mjs | 4 +- scripts/github/upsert-checkpoint-verdict.mjs | 33 ++++++++----- test/github/edit-pr.test.mjs | 14 ++++++ .../github/upsert-checkpoint-verdict.test.mjs | 47 +++++++++++++++---- 4 files changed, 76 insertions(+), 22 deletions(-) diff --git a/scripts/github/edit-pr.mjs b/scripts/github/edit-pr.mjs index 7dc4f0e4af..7c5070bd25 100644 --- a/scripts/github/edit-pr.mjs +++ b/scripts/github/edit-pr.mjs @@ -310,7 +310,9 @@ async function buildEditArgs(options) { // Labels of `[x]` rows in currentBody that nextBody no longer carries as `[x]`: // unticked, deleted or reworded rows, across every checkbox in the body. Counts // per label, so one lost occurrence of a duplicated label is reported. -const CHECKBOX_ROW = /^\s*[-*]\s+\[([ xX])\]\s+(.+?)\s*$/u; +// The row grammar matches CHECKBOX_RE in tick-verified-checkboxes.mjs and the gate's checklist parser: +// an optional blockquote prefix, then a [-*+] or N. or N) marker. +const CHECKBOX_ROW = /^\s*(?:>\s*)*(?:[-*+]|\d+[.)])\s+\[([ xX])\]\s+(.+?)\s*$/u; function untickedRows(currentBody, nextBody) { const checked = (body) => body.split(/\r?\n/u).map((l) => CHECKBOX_ROW.exec(l)).filter((m) => m && m[1] !== " ").map((m) => m[2]); const remaining = new Map(); diff --git a/scripts/github/upsert-checkpoint-verdict.mjs b/scripts/github/upsert-checkpoint-verdict.mjs index e61803803c..c99288608f 100755 --- a/scripts/github/upsert-checkpoint-verdict.mjs +++ b/scripts/github/upsert-checkpoint-verdict.mjs @@ -55,7 +55,7 @@ import { } from "./_gate-finding-surface.mjs"; import { fetchAllReviewThreads } from "./list-review-threads.mjs"; import { mergeSameDefectFindings, normalizeFindingSummary } from "./_gate-finding-text.mjs"; -import { stampSpecAuthorityIdentity } from "@dev-loops/core/loop/spec-authority"; +import { extractSpecFromBody, normalizeCriterionText, stampSpecAuthorityIdentity } from "@dev-loops/core/loop/spec-authority"; import { readSpecAuthorityIdentity } from "../lib/spec-authority-stamp.mjs"; import { normalizeGate as normalizeGateShared, normalizeVerdict as normalizeVerdictShared } from "./_gate-names.mjs"; import { evaluatePrSizeBudget as realEvaluatePrSizeBudget } from "../loop/check-size-budget.mjs"; @@ -2342,8 +2342,9 @@ function anglesPassCoverage(entries, config, gate) { // tick to it, so a concurrent body edit is kept and the edit guards compare // against the live body. namedCriteria (open act item criterion texts) are never // ticked: a verified label that a criterion contains is skipped, and an `ac:` / -// `dod:` id skips every PR-body row of that category (the ids number the pinned -// spec, which this tick cannot map to a PR row). +// `dod:` id skips the PR-body row whose label equals that criterion in the pinned +// spec (the PR body's spec). Only an id the spec cannot resolve, or whose text matches +// no unchecked PR row, skips every PR-body row of its category. async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, coordinationContext, dryRun = false, pendingWrites = [], namedCriteria = [] }, { env, ghCommand, runChild }) { const verifiedItems = allVerified.filter((label) => { const text = String(label).trim(); @@ -2372,11 +2373,21 @@ async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, const allowed = new Set(Array.isArray(list) ? list : []); return verifiedItems.filter((label) => allowed.has(String(label).trim())); }; - const namedCategories = new Set(namedCriteria.flatMap((c) => [...c.matchAll(/\b(ac|dod):\d+\b/giu)].map((m) => m[1].toLowerCase()))); - const prSkipped = new Set([ - ...(namedCategories.has("ac") ? artifact?.prBodyUncheckedAcItems ?? [] : []), - ...(namedCategories.has("dod") ? artifact?.prBodyUncheckedDodItems ?? [] : []), - ].map((label) => String(label).trim())); + // ponytail: the pinned spec is the PR body's own spec; a spec that lives only in a linked issue resolves no id and falls back to the category skip. + const pinnedSpec = extractSpecFromBody(prData.body); + const categories = { + ac: { spec: pinnedSpec.acceptanceCriteria, unchecked: artifact?.prBodyUncheckedAcItems ?? [] }, + dod: { spec: pinnedSpec.definitionOfDone, unchecked: artifact?.prBodyUncheckedDodItems ?? [] }, + }; + const prSkipped = new Set(); + for (const c of namedCriteria) { + for (const [, kind, index] of c.matchAll(/\b(ac|dod):(\d+)\b/giu)) { + const { spec, unchecked } = categories[kind.toLowerCase()]; + const text = spec[Number(index)] === undefined ? "" : normalizeCriterionText(spec[Number(index)]); + const row = unchecked.find((label) => normalizeCriterionText(label) === text); + for (const label of text && row !== undefined ? [row] : unchecked) prSkipped.add(String(label).trim()); + } + } const prLabels = inList([...(artifact?.prBodyUncheckedAcItems ?? []), ...(artifact?.prBodyUncheckedDodItems ?? [])]) .filter((label) => !prSkipped.has(String(label).trim())); const prTick = await tick(prData.body, prLabels, (bodyFile) => @@ -3494,9 +3505,6 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo const findingsLedgerWarning = ((options.executionMode ?? DEFAULT_EXECUTION_MODE) === "fanout_fanin" && options.findingsJson && !options.findingsLedger) ? "WARNING: --findings-json without --findings-ledger files zero inline comments — the --findings-ledger (consolidate-fanin --ledger-out → write-gate-findings-log) is the only inline-comment source. Pass --findings-ledger to file inline findings." : null; - // Every refusal has passed: apply the queued reviewer-verified ticks now, so a refused post leaves no ticked box. - // A failed write throws before any post; a rerun re-ticks only the rows still unticked. - for (const write of pendingTickWrites) await write(); // PENDING-REVIEW RESOLUTION: GitHub allows only ONE pending review // per user per PR (PR-scoped, NOT head-scoped), so a --gate review --submit // re-run must RESOLVE the caller's own pending review rather than POST a @@ -3599,6 +3607,9 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo // gate review) and the legacy issue-comment surface (updateComment below) are // covered, in addition to the low-level guards in the write helpers. guardCommentBodyNoIssuePrIds(desiredBody, { ref: "gate verdict comment body", allowedRefs: options.allowedRefs }); + // The last refusal has passed: apply the queued reviewer-verified ticks now, before the first review or comment write, so a refused post leaves no ticked box. + // A failed write throws before any post; a rerun re-ticks only the rows still unticked. + for (const write of pendingTickWrites) await write(); const findingSurfaceFields = findingSurface ? { round: findingSurface.round, diff --git a/test/github/edit-pr.test.mjs b/test/github/edit-pr.test.mjs index f8235a2591..3b4aecf1f6 100644 --- a/test/github/edit-pr.test.mjs +++ b/test/github/edit-pr.test.mjs @@ -483,3 +483,17 @@ test("editPr (#2898): the untick guard counts duplicate labels and reports a rem // a checked label removed: refused await assert.rejects(() => attempt(live.replace("\n- [x] Docs updated", "")), /untick or remove 1 checked row.*"Docs updated"/s); }); + +test("editPr (#2898): the untick guard sees every checkbox row form the gate parser reads", async () => { + const attempt = (live, body) => editPr( + { repo: "o/n", pr: 5, body, addAssignees: [], removeAssignees: [] }, + { run: stubGh().run, fetchPrContext: async () => ({ headRefName: "x", closingIssuesReferences: [], body: live }) }, + ); + for (const marker of ["-", "*", "+", "1.", "1)", "> -", "> 1."]) { + await assert.rejects(() => attempt(`${marker} [x] Docs updated`, `${marker} [ ] Docs updated`), /untick or remove 1 checked row.*"Docs updated"/s, marker); + } + // Not checkbox rows: unticking them is no refusal. + for (const line of ["plain text line without a checkbox", "[x] Docs updated"]) { + await attempt(line, line.replace("[x]", "[ ]")); + } +}); diff --git a/test/github/upsert-checkpoint-verdict.test.mjs b/test/github/upsert-checkpoint-verdict.test.mjs index 2acabeb96e..35205084d5 100644 --- a/test/github/upsert-checkpoint-verdict.test.mjs +++ b/test/github/upsert-checkpoint-verdict.test.mjs @@ -12381,6 +12381,14 @@ test("tick (#2898): a refused verdict post changes no PR body and no issue body" }); }); +test("tick (#2898): a refusal raised after the deferred tick point (the desiredBody issue/PR id guard) changes no PR body and no issue body", async () => { + await withCompositionRound({ overallVerdict: "clean", prBody: TICK_PR_BODY, issueBody: UNTICKED_TICK_ISSUE_BODY, verifiedItems: TICK_ITEMS }, async ({ post, calls, editedBodies }) => { + await assert.rejects(() => post({ findingsSeverityCounts: CLEAN_COUNTS, findingsSummary: "tracked as 1234 and #1234" }), /comment-id-guard/); + assert.equal(editedBodies.length, 0); + assert.equal(bodyEdits(calls).length, 0); + }); +}); + test("tick (#2898): a row an open act item names by label stays unticked; a criterion with a prefix or a surrounding clause names it too", async () => { for (const judgeCriterion of [TICK_ITEMS[12], `DoD: ${TICK_ITEMS[12]}`, `the row ${TICK_ITEMS[12]} has no evidence`]) { await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ judgeCriterion })] }, async ({ post, editedBodies }) => { @@ -12397,34 +12405,53 @@ test("tick (#2898): a row an open act item names by label stays unticked; a crit }); }); -test("tick (#2898): a spec id (dod: / ac:) skips every PR row of its category, whatever order the PR body lists them", async () => { - const dod = ["dod:1", "(dod:1)", "dod:0 The tick never flips a row that an open act item names."]; - for (const judgeCriterion of dod) { +test("tick (#2898): a spec id (dod: / ac:) resolves against the pinned spec and skips only its row, whatever order the PR body lists them", async () => { + const unticked = (body, item) => assert.match(body, new RegExp(`^- \\[ \\] ${item}$`, "m")); + const ticked = (body, item) => assert.match(body, new RegExp(`^- \\[x\\] ${item}$`, "m")); + // DoD row 1 is "criterion number 14": only that row stays unticked. + for (const judgeCriterion of ["dod:1", "(dod:1)", "dod:1 The tick never flips a row that an open act item names."]) { await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ judgeCriterion })] }, async ({ post, editedBodies }) => { await post(); - for (const item of TICK_ITEMS.slice(12)) assert.match(editedBodies[0].body, new RegExp(`^- \\[ \\] ${item}$`, "m")); - for (const item of TICK_ITEMS.slice(0, 12)) assert.match(editedBodies[0].body, new RegExp(`^- \\[x\\] ${item}$`, "m")); + unticked(editedBodies[0].body, TICK_ITEMS[13]); + for (const item of TICK_ITEMS.filter((_, i) => i !== 13)) ticked(editedBodies[0].body, item); }); } + // AC row 0 is "criterion number 1". await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ judgeCriterion: "ac:0" })] }, async ({ post, editedBodies }) => { await post(); - for (const item of TICK_ITEMS.slice(0, 12)) assert.match(editedBodies[0].body, new RegExp(`^- \\[ \\] ${item}$`, "m")); - for (const item of TICK_ITEMS.slice(12)) assert.match(editedBodies[0].body, new RegExp(`^- \\[x\\] ${item}$`, "m")); + unticked(editedBodies[0].body, TICK_ITEMS[0]); + for (const item of TICK_ITEMS.slice(1)) ticked(editedBodies[0].body, item); }); - // ponytail: a second id after a slash or comma names its category too; with both named nothing is ticked. + // Two ids across categories skip their two rows. for (const judgeCriterion of ["ac:0/dod:1", "ac:0,dod:1", "(ac:0) (dod:1)"]) { await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ judgeCriterion })] }, async ({ post, editedBodies }) => { await post(); - for (const item of TICK_ITEMS) assert.doesNotMatch(editedBodies[0]?.body ?? "", new RegExp(`^- \\[x\\] ${item}$`, "m")); + unticked(editedBodies[0].body, TICK_ITEMS[0]); + unticked(editedBodies[0].body, TICK_ITEMS[13]); + for (const item of TICK_ITEMS.filter((_, i) => i !== 0 && i !== 13)) ticked(editedBodies[0].body, item); }); } + // An id the pinned spec cannot resolve (no such row) skips every PR row of its category. + for (const [judgeCriterion, skipped] of [["dod:99", TICK_ITEMS.slice(12)], ["ac:99", TICK_ITEMS.slice(0, 12)]]) { + await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ judgeCriterion })] }, async ({ post, editedBodies }) => { + await post(); + for (const item of TICK_ITEMS) (skipped.includes(item) ? unticked : ticked)(editedBodies[0].body, item); + }); + } + // The pinned spec resolves the id, but no unchecked PR row carries that text (the row is already checked): the whole category is skipped. + const preChecked = TICK_PR_BODY.replace(`- [ ] ${TICK_ITEMS[12]}`, `- [x] ${TICK_ITEMS[12]}`); + await withCompositionRound({ overallVerdict: "findings_present", prBody: preChecked, verifiedItems: TICK_ITEMS.slice(13), findings: [openAct({ judgeCriterion: "dod:0" })] }, async ({ post, editedBodies }) => { + await post(); + for (const item of TICK_ITEMS.slice(13)) unticked(editedBodies[0]?.body ?? `- [ ] ${item}`, item); + }); }); test("tick (#2898): an open act item carrying checkedCriteria and no judgeCriterion keeps the named category unticked", async () => { await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ checkedCriteria: ["dod:1"] })] }, async ({ post, editedBodies }) => { await post(); - for (const item of TICK_ITEMS.slice(12)) assert.match(editedBodies[0].body, new RegExp(`^- \\[ \\] ${item}$`, "m")); + assert.match(editedBodies[0].body, new RegExp(`^- \\[ \\] ${TICK_ITEMS[13]}$`, "m")); assert.match(editedBodies[0].body, new RegExp(`^- \\[x\\] ${TICK_ITEMS[0]}$`, "m")); + assert.match(editedBodies[0].body, new RegExp(`^- \\[x\\] ${TICK_ITEMS[12]}$`, "m")); }); }); From 6f28141406ab25c4677553f3b7471038cf6aba80 Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Wed, 28 Oct 2026 01:32:30 +0100 Subject: [PATCH 09/23] fix(gate): resolve a spec id to a PR row only when the PR body is the pinned spec Refs #2898 - tickReviewerVerifiedItems compares the PR body spec digest with the pinned specDigest - no pin or another pinned spec falls back to the whole-category skip Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetS2hFz48x4dfrpt8j7 --- scripts/github/upsert-checkpoint-verdict.mjs | 17 +++++++---- .../github/upsert-checkpoint-verdict.test.mjs | 29 +++++++++++++++---- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/scripts/github/upsert-checkpoint-verdict.mjs b/scripts/github/upsert-checkpoint-verdict.mjs index c99288608f..bd605380c9 100755 --- a/scripts/github/upsert-checkpoint-verdict.mjs +++ b/scripts/github/upsert-checkpoint-verdict.mjs @@ -55,7 +55,7 @@ import { } from "./_gate-finding-surface.mjs"; import { fetchAllReviewThreads } from "./list-review-threads.mjs"; import { mergeSameDefectFindings, normalizeFindingSummary } from "./_gate-finding-text.mjs"; -import { extractSpecFromBody, normalizeCriterionText, stampSpecAuthorityIdentity } from "@dev-loops/core/loop/spec-authority"; +import { computeSpecDigest, extractSpecFromBody, normalizeCriterionText, stampSpecAuthorityIdentity } from "@dev-loops/core/loop/spec-authority"; import { readSpecAuthorityIdentity } from "../lib/spec-authority-stamp.mjs"; import { normalizeGate as normalizeGateShared, normalizeVerdict as normalizeVerdictShared } from "./_gate-names.mjs"; import { evaluatePrSizeBudget as realEvaluatePrSizeBudget } from "../loop/check-size-budget.mjs"; @@ -2343,9 +2343,10 @@ function anglesPassCoverage(entries, config, gate) { // against the live body. namedCriteria (open act item criterion texts) are never // ticked: a verified label that a criterion contains is skipped, and an `ac:` / // `dod:` id skips the PR-body row whose label equals that criterion in the pinned -// spec (the PR body's spec). Only an id the spec cannot resolve, or whose text matches -// no unchecked PR row, skips every PR-body row of its category. -async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, coordinationContext, dryRun = false, pendingWrites = [], namedCriteria = [] }, { env, ghCommand, runChild }) { +// spec, resolved only when the PR body's spec digest equals the pinned specDigest. +// Only an id the pinned spec cannot resolve (no pin, a different pinned spec, no such +// row), or whose text matches no unchecked PR row, skips every PR-body row of its category. +async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, coordinationContext, dryRun = false, pendingWrites = [], namedCriteria = [], pinnedSpecDigest }, { env, ghCommand, runChild }) { const verifiedItems = allVerified.filter((label) => { const text = String(label).trim(); return !text || !namedCriteria.some((c) => c.includes(text)); @@ -2373,8 +2374,11 @@ async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, const allowed = new Set(Array.isArray(list) ? list : []); return verifiedItems.filter((label) => allowed.has(String(label).trim())); }; - // ponytail: the pinned spec is the PR body's own spec; a spec that lives only in a linked issue resolves no id and falls back to the category skip. - const pinnedSpec = extractSpecFromBody(prData.body); + // The PR body's spec stands in for the pinned spec only when its digest equals the pinned specDigest; otherwise (no pin, or the pin is another body such as the linked issue) no id resolves and the category skip applies. + const bodySpec = extractSpecFromBody(prData.body); + const pinnedSpec = pinnedSpecDigest !== undefined && computeSpecDigest(bodySpec) === pinnedSpecDigest + ? bodySpec + : { acceptanceCriteria: [], definitionOfDone: [] }; const categories = { ac: { spec: pinnedSpec.acceptanceCriteria, unchecked: artifact?.prBodyUncheckedAcItems ?? [] }, dod: { spec: pinnedSpec.definitionOfDone, unchecked: artifact?.prBodyUncheckedDodItems ?? [] }, @@ -3104,6 +3108,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo dryRun: true, pendingWrites: pendingTickWrites, namedCriteria, + pinnedSpecDigest: specAuthorityIdentity?.specDigest, }, gh); } // Set only when the posted checkpoint verdict differs from the ledger's review diff --git a/test/github/upsert-checkpoint-verdict.test.mjs b/test/github/upsert-checkpoint-verdict.test.mjs index 35205084d5..7671000499 100644 --- a/test/github/upsert-checkpoint-verdict.test.mjs +++ b/test/github/upsert-checkpoint-verdict.test.mjs @@ -31,6 +31,7 @@ import { buildValidationResultsPath } from "../../scripts/github/write-gate-cont import { loadDevLoopConfig } from "@dev-loops/core/config"; import { evaluatePrGateCoordination, PR_CHECKPOINT_ACTION } from "@dev-loops/core/loop/pr-gate-coordination"; import { renderFallbackGateReviewCommentBody } from "../../skills/dev-loop/scripts/post-gate-verdict-fallback.mjs"; +import { computeSpecDigest, extractSpecFromBody } from "@dev-loops/core/loop/spec-authority"; // The legacy input shapes in this file predate the round cap. A fixture repo root that does not set the cap gets // maxReviewRounds: 0 (the cap has its own tests in upsert-checkpoint-verdict-round-cap.test.mjs). @@ -12405,27 +12406,43 @@ test("tick (#2898): a row an open act item names by label stays unticked; a crit }); }); +// A --spec-authority file pinning `specDigest`, in a scratch dir the OS cleans up. +async function writeTickPin(name, specDigest) { + const file = path.join(await mkdtemp(path.join(os.tmpdir(), "dev-loops-tick-pin-")), name); + await writeFile(file, JSON.stringify({ specDigest, headSha: "b".repeat(40), contentDigest: `sha256:${"c".repeat(64)}`, checkedCriteria: [] })); + return file; +} + test("tick (#2898): a spec id (dod: / ac:) resolves against the pinned spec and skips only its row, whatever order the PR body lists them", async () => { const unticked = (body, item) => assert.match(body, new RegExp(`^- \\[ \\] ${item}$`, "m")); const ticked = (body, item) => assert.match(body, new RegExp(`^- \\[x\\] ${item}$`, "m")); + const pin = await writeTickPin("pinned.json", computeSpecDigest(extractSpecFromBody(TICK_PR_BODY))); + // The pinned spec is not the PR body's spec (the linked issue): the id resolves nothing, so every DoD row stays unticked. + // With no pin at all the same category skip applies. + for (const specAuthority of [await writeTickPin("other.json", `sha256:${"d".repeat(64)}`), undefined]) { + await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ judgeCriterion: "dod:1" })] }, async ({ post, editedBodies }) => { + await post({ specAuthority }); + for (const item of TICK_ITEMS) (TICK_ITEMS.slice(12).includes(item) ? unticked : ticked)(editedBodies[0].body, item); + }); + } // DoD row 1 is "criterion number 14": only that row stays unticked. for (const judgeCriterion of ["dod:1", "(dod:1)", "dod:1 The tick never flips a row that an open act item names."]) { await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ judgeCriterion })] }, async ({ post, editedBodies }) => { - await post(); + await post({ specAuthority: pin }); unticked(editedBodies[0].body, TICK_ITEMS[13]); for (const item of TICK_ITEMS.filter((_, i) => i !== 13)) ticked(editedBodies[0].body, item); }); } // AC row 0 is "criterion number 1". await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ judgeCriterion: "ac:0" })] }, async ({ post, editedBodies }) => { - await post(); + await post({ specAuthority: pin }); unticked(editedBodies[0].body, TICK_ITEMS[0]); for (const item of TICK_ITEMS.slice(1)) ticked(editedBodies[0].body, item); }); // Two ids across categories skip their two rows. for (const judgeCriterion of ["ac:0/dod:1", "ac:0,dod:1", "(ac:0) (dod:1)"]) { await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ judgeCriterion })] }, async ({ post, editedBodies }) => { - await post(); + await post({ specAuthority: pin }); unticked(editedBodies[0].body, TICK_ITEMS[0]); unticked(editedBodies[0].body, TICK_ITEMS[13]); for (const item of TICK_ITEMS.filter((_, i) => i !== 0 && i !== 13)) ticked(editedBodies[0].body, item); @@ -12434,21 +12451,21 @@ test("tick (#2898): a spec id (dod: / ac:) resolves against the pinned spe // An id the pinned spec cannot resolve (no such row) skips every PR row of its category. for (const [judgeCriterion, skipped] of [["dod:99", TICK_ITEMS.slice(12)], ["ac:99", TICK_ITEMS.slice(0, 12)]]) { await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ judgeCriterion })] }, async ({ post, editedBodies }) => { - await post(); + await post({ specAuthority: pin }); for (const item of TICK_ITEMS) (skipped.includes(item) ? unticked : ticked)(editedBodies[0].body, item); }); } // The pinned spec resolves the id, but no unchecked PR row carries that text (the row is already checked): the whole category is skipped. const preChecked = TICK_PR_BODY.replace(`- [ ] ${TICK_ITEMS[12]}`, `- [x] ${TICK_ITEMS[12]}`); await withCompositionRound({ overallVerdict: "findings_present", prBody: preChecked, verifiedItems: TICK_ITEMS.slice(13), findings: [openAct({ judgeCriterion: "dod:0" })] }, async ({ post, editedBodies }) => { - await post(); + await post({ specAuthority: pin }); for (const item of TICK_ITEMS.slice(13)) unticked(editedBodies[0]?.body ?? `- [ ] ${item}`, item); }); }); test("tick (#2898): an open act item carrying checkedCriteria and no judgeCriterion keeps the named category unticked", async () => { await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ checkedCriteria: ["dod:1"] })] }, async ({ post, editedBodies }) => { - await post(); + await post({ specAuthority: await writeTickPin("pinned.json", computeSpecDigest(extractSpecFromBody(TICK_PR_BODY))) }); assert.match(editedBodies[0].body, new RegExp(`^- \\[ \\] ${TICK_ITEMS[13]}$`, "m")); assert.match(editedBodies[0].body, new RegExp(`^- \\[x\\] ${TICK_ITEMS[0]}$`, "m")); assert.match(editedBodies[0].body, new RegExp(`^- \\[x\\] ${TICK_ITEMS[12]}$`, "m")); From b0ebc65b39074ae559cd1be1257a34565a8a969c Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Wed, 28 Oct 2026 01:49:37 +0100 Subject: [PATCH 10/23] fix(gate): bounded normalized label skip, late-refusal before tick writes, AC-less pinned body Refs #2898 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetS2hFz48x4dfrpt8j7 --- scripts/github/upsert-checkpoint-verdict.mjs | 65 +++++++++++-------- .../github/upsert-checkpoint-verdict.test.mjs | 55 ++++++++++++++++ 2 files changed, 92 insertions(+), 28 deletions(-) diff --git a/scripts/github/upsert-checkpoint-verdict.mjs b/scripts/github/upsert-checkpoint-verdict.mjs index bd605380c9..474a054278 100755 --- a/scripts/github/upsert-checkpoint-verdict.mjs +++ b/scripts/github/upsert-checkpoint-verdict.mjs @@ -2347,9 +2347,14 @@ function anglesPassCoverage(entries, config, gate) { // Only an id the pinned spec cannot resolve (no pin, a different pinned spec, no such // row), or whose text matches no unchecked PR row, skips every PR-body row of its category. async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, coordinationContext, dryRun = false, pendingWrites = [], namedCriteria = [], pinnedSpecDigest }, { env, ghCommand, runChild }) { + // A label is skipped when a normalized criterion equals it or contains it bounded by non-word characters or the string edge. + const flat = (t) => normalizeCriterionText(String(t).replaceAll("`", "")).toLowerCase().replace(/\.+$/u, ""); + const normalizedCriteria = namedCriteria.map(flat); const verifiedItems = allVerified.filter((label) => { - const text = String(label).trim(); - return !text || !namedCriteria.some((c) => c.includes(text)); + const text = flat(label); + if (!text) return true; + const bounded = new RegExp(`(? bounded.test(c)); }); const inSections = (body, options) => { const indices = checklistSectionLineIndices(body, options); @@ -2376,7 +2381,9 @@ async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, }; // The PR body's spec stands in for the pinned spec only when its digest equals the pinned specDigest; otherwise (no pin, or the pin is another body such as the linked issue) no id resolves and the category skip applies. const bodySpec = extractSpecFromBody(prData.body); - const pinnedSpec = pinnedSpecDigest !== undefined && computeSpecDigest(bodySpec) === pinnedSpecDigest + // computeSpecDigest throws on a body with no acceptance criteria; that body cannot match the pin. + const bodyDigest = pinnedSpecDigest === undefined ? undefined : (() => { try { return computeSpecDigest(bodySpec); } catch { return undefined; } })(); + const pinnedSpec = pinnedSpecDigest !== undefined && bodyDigest === pinnedSpecDigest ? bodySpec : { acceptanceCriteria: [], definitionOfDone: [] }; const categories = { @@ -3612,6 +3619,31 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo // gate review) and the legacy issue-comment surface (updateComment below) are // covered, in addition to the low-level guards in the write helpers. guardCommentBodyNoIssuePrIds(desiredBody, { ref: "gate verdict comment body", allowedRefs: options.allowedRefs }); + // SUBMITTED-REVIEW RESOLUTION (review gate): a same-head own SUBMITTED review + // is this round's existing surface. Without this, a same-head rerun POSTs a + // duplicate submitted review (findOwnPendingReview above only sees the + // author-only PENDING draft, never a submitted one). --new-round is the + // deliberate escape hatch that bypasses this dedup to force a fresh review. + // Runs AFTER resolveFindingSurface so findingSurface.locatable already tells + // us whether a correction needs a new inline comment GitHub cannot add to an + // already-submitted review. The lookup and its refusal run before the queued ticks. + const ownSubmitted = isReviewGate && !options.newRound + ? await findOwnSubmittedReview( + { repo: options.repo, pr: options.pr, headSha: canonicalHeadSha }, + gh, + ) + : null; + // A still-unposted locatable finding would need an inline comment, and + // GitHub exposes no endpoint to add one to an already-submitted review. + // Fail closed rather than silently drop it to body text; --new-round + // posts a fresh review that CAN carry the inline comment. + if (ownSubmitted && findingSurface && findingSurface.locatable.length > 0) { + throw new Error( + `Cannot correct the already-submitted review ${ownSubmitted.id} on ${options.repo}#${options.pr}: ` + + `this round adds ${findingSurface.locatable.length} inline finding(s), and GitHub has no endpoint to add inline ` + + `comments to a submitted review. Re-run with --new-round to post a fresh review round that carries the new inline comment(s).`, + ); + } // The last refusal has passed: apply the queued reviewer-verified ticks now, before the first review or comment write, so a refused post leaves no ticked box. // A failed write throws before any post; a rerun re-ticks only the rows still unticked. for (const write of pendingTickWrites) await write(); @@ -3624,20 +3656,8 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo suppressed: findingSurface.suppressedCount, } : {}; - // SUBMITTED-REVIEW RESOLUTION (review gate): a same-head own SUBMITTED review - // is this round's existing surface. Without this, a same-head rerun POSTs a - // duplicate submitted review (findOwnPendingReview above only sees the - // author-only PENDING draft, never a submitted one). --new-round is the - // deliberate escape hatch that bypasses this dedup to force a fresh review. - // Runs AFTER resolveFindingSurface so findingSurface.locatable already tells - // us whether a correction needs a new inline comment GitHub cannot add to an - // already-submitted review. - if (isReviewGate && !options.newRound) { - const ownSubmitted = await findOwnSubmittedReview( - { repo: options.repo, pr: options.pr, headSha: canonicalHeadSha }, - gh, - ); - if (ownSubmitted) { + if (ownSubmitted) { + { const reviewBaseResult = { ok: true, repo: options.repo, @@ -3653,17 +3673,6 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo ...(findingsLedgerWarning ? { findingsLedgerWarning } : {}), ...(specAuthority ? { specAuthority } : {}), }; - // A still-unposted locatable finding would need an inline comment, and - // GitHub exposes no endpoint to add one to an already-submitted review. - // Fail closed rather than silently drop it to body text; --new-round - // posts a fresh review that CAN carry the inline comment. - if (findingSurface && findingSurface.locatable.length > 0) { - throw new Error( - `Cannot correct the already-submitted review ${ownSubmitted.id} on ${options.repo}#${options.pr}: ` - + `this round adds ${findingSurface.locatable.length} inline finding(s), and GitHub has no endpoint to add inline ` - + `comments to a submitted review. Re-run with --new-round to post a fresh review round that carries the new inline comment(s).`, - ); - } // Byte-identical body: nothing to correct, suppress the duplicate repost. if (desiredBody === ownSubmitted.body) { return { diff --git a/test/github/upsert-checkpoint-verdict.test.mjs b/test/github/upsert-checkpoint-verdict.test.mjs index 7671000499..5709c2a887 100644 --- a/test/github/upsert-checkpoint-verdict.test.mjs +++ b/test/github/upsert-checkpoint-verdict.test.mjs @@ -12397,6 +12397,25 @@ test("tick (#2898): a row an open act item names by label stays unticked; a crit assert.equal(editedBodies.length, 1); assert.match(editedBodies[0].body, new RegExp(`^- \\[ \\] ${TICK_ITEMS[12]}$`, "m")); assert.match(editedBodies[0].body, new RegExp(`^- \\[x\\] ${TICK_ITEMS[1]}$`, "m")); + // "criterion number 1" is a prefix of the named "criterion number 13" but names a different row. + assert.match(editedBodies[0].body, new RegExp(`^- \\[x\\] ${TICK_ITEMS[0]}$`, "m")); + }); + } + // Accepted forms: case, internal whitespace and dropped backticks still name the row; a quoted label followed by a period too. + const tickedRow = "The `edit-pr.mjs` USAGE text documents `--allow-untick`."; + const rowBody = ["## Acceptance criteria", "", `- [ ] ${tickedRow}`, "- [ ] Tests pass", "", "## Definition of done", "", "- [x] done", ""].join("\n"); + for (const judgeCriterion of ["the EDIT-PR.MJS usage text documents --allow-untick", "The edit-pr.mjs USAGE text documents --allow-untick", `"${tickedRow}"`]) { + await withCompositionRound({ overallVerdict: "findings_present", prBody: rowBody, verifiedItems: [tickedRow, "Tests pass"], findings: [openAct({ judgeCriterion })] }, async ({ post, editedBodies }) => { + await post(); + assert.match(editedBodies[0].body, /^- \[ \] The `edit-pr\.mjs` USAGE/m); + assert.match(editedBodies[0].body, /^- \[x\] Tests pass$/m); + }); + } + // Rejected forms: a label only inside a longer word, and a criterion naming a different row, name nothing. + for (const judgeCriterion of ["Tests passed", "an unrelated row"]) { + await withCompositionRound({ overallVerdict: "findings_present", prBody: rowBody, verifiedItems: ["Tests pass"], findings: [openAct({ judgeCriterion })] }, async ({ post, editedBodies }) => { + await post(); + assert.match(editedBodies[0].body, /^- \[x\] Tests pass$/m); }); } // A criterion that does not contain the label names nothing: every verified row is ticked. @@ -12463,6 +12482,42 @@ test("tick (#2898): a spec id (dod: / ac:) resolves against the pinned spe }); }); +test("tick (#2898): a pinned spec and a PR body without an AC checklist still tick the verified issue rows", async () => { + const issueBody = ["## Acceptance criteria", "", "- [ ] criterion number 1", "- [ ] criterion number 2", ""].join("\n"); + const pin = await writeTickPin("pinned.json", `sha256:${"d".repeat(64)}`); + await withCompositionRound({ overallVerdict: "clean", prBody: "## Scope\n\nNo checklist here.\n", verifiedItems: TICK_ITEMS, issueBody }, async ({ post, editedBodies }) => { + await post({ specAuthority: pin, findingsSeverityCounts: CLEAN_COUNTS }); + assert.equal(editedBodies.length, 1); + assert.match(editedBodies[0].body, /^- \[x\] criterion number 1$/m); + assert.match(editedBodies[0].body, /^- \[x\] criterion number 2$/m); + }); +}); + +test("tick (#2898): a review-gate rerun refused for the already-submitted review changes no PR body and no issue body", async () => { + await withTempDir(async (tempDir) => { + const { body } = await createReviewGateAndCaptureBody(tempDir, { nextAction: "none — informational review, no re-gate required" }); + const submitted = seededSubmittedReview({ id: 982, body }); + const ledgerPath = await writeReviewGateLedger(tempDir, [LOCATABLE_FINDING], { overallVerdict: "findings_present", verifiedItems: TICK_ITEMS }); + const { runChild, calls } = makeGhMock( + reviewGateFindingSurfaceEntries({ + files: [{ filename: "src/db.mjs", patch: SINGLE_SURFACE_PATCH }], + pendingReviews: [submitted], + reviews: [submitted], + submittedReviews: [submitted], + }), + ); + await assert.rejects( + upsertCheckpointVerdict({ + repo: "owner/repo", pr: 17, gate: "review", headSha: SINGLE_SURFACE_HEAD, verdict: "findings_present", + findingsSummary: "one locatable finding", nextAction: "stay draft and fix", + findingsLedger: ledgerPath, judgeSpecSource: "none", executionMode: "fanout_fanin", submit: "comment", + }, { env: runIdFreeEnv({ DEVLOOPS_RUN_ID: "" }), ghCommand: "gh", runChild, repoRoot: tempDir }), + /already-submitted review 982/, + ); + assert.equal(bodyEdits(calls).length, 0); + }, { prefix: "dev-loops-upsert-review-refused-no-tick-" }); +}); + test("tick (#2898): an open act item carrying checkedCriteria and no judgeCriterion keeps the named category unticked", async () => { await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ checkedCriteria: ["dod:1"] })] }, async ({ post, editedBodies }) => { await post({ specAuthority: await writeTickPin("pinned.json", computeSpecDigest(extractSpecFromBody(TICK_PR_BODY))) }); From 7d838969cc73d75835fca454557923484675aef8 Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Wed, 28 Oct 2026 02:04:39 +0100 Subject: [PATCH 11/23] docs(gate): align tick contract with act-item skip and post-refusal writes Refs #2898 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetS2hFz48x4dfrpt8j7 --- .claude/skills/docs/acceptance-criteria-verification.md | 2 +- changes/2898-tick-integrity.md | 2 +- skills/docs/acceptance-criteria-verification.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/skills/docs/acceptance-criteria-verification.md b/.claude/skills/docs/acceptance-criteria-verification.md index 9f93e5e268..998bd9f6b0 100644 --- a/.claude/skills/docs/acceptance-criteria-verification.md +++ b/.claude/skills/docs/acceptance-criteria-verification.md @@ -25,7 +25,7 @@ Canonical owner for the acceptance-criteria verification procedure run during th 4. **Verify each AC item** against the proposed changes on the current PR head. -5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` ticks exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit. The poster then reloads the refinement artifact, so the verdict composition and the `clean` guards read the ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface. +5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` computes the ticked bodies as a dry run for exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A verified label that an open judge act item names (by label text or by an `ac:` / `dod:` id) stays unticked. An id that does not resolve in the pinned spec, or whose text matches no unchecked PR row, skips every PR-body row of its category. Do not hand-tick a row left unticked for an open act item with `tick-verified-checkboxes`. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit, written only after every refusal check and directly before the verdict is posted. The verdict composition and the `clean` guards read the dry-run ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface. **Manual tick paths:** the automatic tick needs `verifiedItems`, and only the fan-out path writes them (per-angle artifacts through `consolidate-fanin`). It also reads the unchecked PR-body lists, which the coordination state fills only for a tracker-backed PR with a linked issue (step 7). The tick reads `verifiedItems` only from a trusted ledger: consistent provenance that records a fresh `acceptance-criteria` or `pr-checklist` review, plus fan-out angle coverage in `fanout_fanin` mode. Two paths therefore tick nothing automatically. The first is a post that carries no trusted `verifiedItems`: a post without `--findings-ledger`, a light-mode `inline_single_agent` round (with or without a ledger), or a ledger that lacks a fresh `acceptance-criteria` or `pr-checklist` review or fails the `fanout_fanin` angle coverage. The second is a lightweight or issue-less PR. On those paths the operator ticks each verified item before posting with `node scripts/github/tick-verified-checkboxes.mjs --repo --issue --pr --verified ...`. Without a tracker-backed issue, the operator passes `--pr` alone. The CLI applies the same exact-label tick. On a fan-out post for a tracker-backed PR with a linked issue, the CLI is a manual correction tool only. diff --git a/changes/2898-tick-integrity.md b/changes/2898-tick-integrity.md index 8c03656e39..9be0d095d2 100644 --- a/changes/2898-tick-integrity.md +++ b/changes/2898-tick-integrity.md @@ -1,3 +1,3 @@ ### Fixed -- `upsert-checkpoint-verdict` ticks verified rows after all refusal checks; `edit-pr` refuses a body that unticks or removes checked rows without `--allow-untick` (#2898) +- `upsert-checkpoint-verdict` ticks verified rows after all refusal checks and leaves unticked any row an open act item names (by label or `ac:` / `dod:` id); `edit-pr` refuses a body that unticks or removes checked rows without `--allow-untick` (#2898) diff --git a/skills/docs/acceptance-criteria-verification.md b/skills/docs/acceptance-criteria-verification.md index 9f93e5e268..998bd9f6b0 100644 --- a/skills/docs/acceptance-criteria-verification.md +++ b/skills/docs/acceptance-criteria-verification.md @@ -25,7 +25,7 @@ Canonical owner for the acceptance-criteria verification procedure run during th 4. **Verify each AC item** against the proposed changes on the current PR head. -5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` ticks exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit. The poster then reloads the refinement artifact, so the verdict composition and the `clean` guards read the ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface. +5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` computes the ticked bodies as a dry run for exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A verified label that an open judge act item names (by label text or by an `ac:` / `dod:` id) stays unticked. An id that does not resolve in the pinned spec, or whose text matches no unchecked PR row, skips every PR-body row of its category. Do not hand-tick a row left unticked for an open act item with `tick-verified-checkboxes`. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit, written only after every refusal check and directly before the verdict is posted. The verdict composition and the `clean` guards read the dry-run ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface. **Manual tick paths:** the automatic tick needs `verifiedItems`, and only the fan-out path writes them (per-angle artifacts through `consolidate-fanin`). It also reads the unchecked PR-body lists, which the coordination state fills only for a tracker-backed PR with a linked issue (step 7). The tick reads `verifiedItems` only from a trusted ledger: consistent provenance that records a fresh `acceptance-criteria` or `pr-checklist` review, plus fan-out angle coverage in `fanout_fanin` mode. Two paths therefore tick nothing automatically. The first is a post that carries no trusted `verifiedItems`: a post without `--findings-ledger`, a light-mode `inline_single_agent` round (with or without a ledger), or a ledger that lacks a fresh `acceptance-criteria` or `pr-checklist` review or fails the `fanout_fanin` angle coverage. The second is a lightweight or issue-less PR. On those paths the operator ticks each verified item before posting with `node scripts/github/tick-verified-checkboxes.mjs --repo --issue --pr --verified ...`. Without a tracker-backed issue, the operator passes `--pr` alone. The CLI applies the same exact-label tick. On a fan-out post for a tracker-backed PR with a linked issue, the CLI is a manual correction tool only. From e20c449ac1cf20299269f5457424ae43f5193006 Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Wed, 28 Oct 2026 02:08:29 +0100 Subject: [PATCH 12/23] docs(gate): shorten 2898 changelog, scope AC step 5 id skip to PR-body rows Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetS2hFz48x4dfrpt8j7 --- .claude/skills/docs/acceptance-criteria-verification.md | 2 +- changes/2898-tick-integrity.md | 4 +--- skills/docs/acceptance-criteria-verification.md | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.claude/skills/docs/acceptance-criteria-verification.md b/.claude/skills/docs/acceptance-criteria-verification.md index 998bd9f6b0..3301c3d9a5 100644 --- a/.claude/skills/docs/acceptance-criteria-verification.md +++ b/.claude/skills/docs/acceptance-criteria-verification.md @@ -25,7 +25,7 @@ Canonical owner for the acceptance-criteria verification procedure run during th 4. **Verify each AC item** against the proposed changes on the current PR head. -5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` computes the ticked bodies as a dry run for exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A verified label that an open judge act item names (by label text or by an `ac:` / `dod:` id) stays unticked. An id that does not resolve in the pinned spec, or whose text matches no unchecked PR row, skips every PR-body row of its category. Do not hand-tick a row left unticked for an open act item with `tick-verified-checkboxes`. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit, written only after every refusal check and directly before the verdict is posted. The verdict composition and the `clean` guards read the dry-run ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface. +5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` computes the ticked bodies as a dry run for exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A verified label that an open judge act item names (by label text or by an `ac:` / `dod:` id) stays unticked. An id that does not resolve in the pinned spec, or whose text matches no unchecked PR row, skips every PR-body row of its category; the skip applies to PR-body rows only, not to linked issue bodies. Do not hand-tick a row left unticked for an open act item with `tick-verified-checkboxes`. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit, written only after every refusal check and directly before the verdict is posted. The verdict composition and the `clean` guards read the dry-run ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface. **Manual tick paths:** the automatic tick needs `verifiedItems`, and only the fan-out path writes them (per-angle artifacts through `consolidate-fanin`). It also reads the unchecked PR-body lists, which the coordination state fills only for a tracker-backed PR with a linked issue (step 7). The tick reads `verifiedItems` only from a trusted ledger: consistent provenance that records a fresh `acceptance-criteria` or `pr-checklist` review, plus fan-out angle coverage in `fanout_fanin` mode. Two paths therefore tick nothing automatically. The first is a post that carries no trusted `verifiedItems`: a post without `--findings-ledger`, a light-mode `inline_single_agent` round (with or without a ledger), or a ledger that lacks a fresh `acceptance-criteria` or `pr-checklist` review or fails the `fanout_fanin` angle coverage. The second is a lightweight or issue-less PR. On those paths the operator ticks each verified item before posting with `node scripts/github/tick-verified-checkboxes.mjs --repo --issue --pr --verified ...`. Without a tracker-backed issue, the operator passes `--pr` alone. The CLI applies the same exact-label tick. On a fan-out post for a tracker-backed PR with a linked issue, the CLI is a manual correction tool only. diff --git a/changes/2898-tick-integrity.md b/changes/2898-tick-integrity.md index 9be0d095d2..64a2275931 100644 --- a/changes/2898-tick-integrity.md +++ b/changes/2898-tick-integrity.md @@ -1,3 +1 @@ -### Fixed - -- `upsert-checkpoint-verdict` ticks verified rows after all refusal checks and leaves unticked any row an open act item names (by label or `ac:` / `dod:` id); `edit-pr` refuses a body that unticks or removes checked rows without `--allow-untick` (#2898) +- `upsert-checkpoint-verdict` ticks verified rows after all refusal checks, leaves act-named rows unticked; `edit-pr` refuses a silent untick without `--allow-untick` (#2898) diff --git a/skills/docs/acceptance-criteria-verification.md b/skills/docs/acceptance-criteria-verification.md index 998bd9f6b0..3301c3d9a5 100644 --- a/skills/docs/acceptance-criteria-verification.md +++ b/skills/docs/acceptance-criteria-verification.md @@ -25,7 +25,7 @@ Canonical owner for the acceptance-criteria verification procedure run during th 4. **Verify each AC item** against the proposed changes on the current PR head. -5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` computes the ticked bodies as a dry run for exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A verified label that an open judge act item names (by label text or by an `ac:` / `dod:` id) stays unticked. An id that does not resolve in the pinned spec, or whose text matches no unchecked PR row, skips every PR-body row of its category. Do not hand-tick a row left unticked for an open act item with `tick-verified-checkboxes`. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit, written only after every refusal check and directly before the verdict is posted. The verdict composition and the `clean` guards read the dry-run ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface. +5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` computes the ticked bodies as a dry run for exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A verified label that an open judge act item names (by label text or by an `ac:` / `dod:` id) stays unticked. An id that does not resolve in the pinned spec, or whose text matches no unchecked PR row, skips every PR-body row of its category; the skip applies to PR-body rows only, not to linked issue bodies. Do not hand-tick a row left unticked for an open act item with `tick-verified-checkboxes`. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit, written only after every refusal check and directly before the verdict is posted. The verdict composition and the `clean` guards read the dry-run ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface. **Manual tick paths:** the automatic tick needs `verifiedItems`, and only the fan-out path writes them (per-angle artifacts through `consolidate-fanin`). It also reads the unchecked PR-body lists, which the coordination state fills only for a tracker-backed PR with a linked issue (step 7). The tick reads `verifiedItems` only from a trusted ledger: consistent provenance that records a fresh `acceptance-criteria` or `pr-checklist` review, plus fan-out angle coverage in `fanout_fanin` mode. Two paths therefore tick nothing automatically. The first is a post that carries no trusted `verifiedItems`: a post without `--findings-ledger`, a light-mode `inline_single_agent` round (with or without a ledger), or a ledger that lacks a fresh `acceptance-criteria` or `pr-checklist` review or fails the `fanout_fanin` angle coverage. The second is a lightweight or issue-less PR. On those paths the operator ticks each verified item before posting with `node scripts/github/tick-verified-checkboxes.mjs --repo --issue --pr --verified ...`. Without a tracker-backed issue, the operator passes `--pr` alone. The CLI applies the same exact-label tick. On a fan-out post for a tracker-backed PR with a linked issue, the CLI is a manual correction tool only. From dd03b14748e8c934d6edc10fcfb241592fb16220 Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Sun, 1 Nov 2026 15:54:47 +0100 Subject: [PATCH 13/23] fix(gate): guard inline finding ids before deferred AC ticks Refs #2898 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetT95WedZayUQMysoHv --- .../docs/acceptance-criteria-verification.md | 2 +- scripts/github/upsert-checkpoint-verdict.mjs | 13 +++++++---- .../docs/acceptance-criteria-verification.md | 2 +- .../github/upsert-checkpoint-verdict.test.mjs | 23 +++++++++++++++---- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/.claude/skills/docs/acceptance-criteria-verification.md b/.claude/skills/docs/acceptance-criteria-verification.md index 3301c3d9a5..a48336e36f 100644 --- a/.claude/skills/docs/acceptance-criteria-verification.md +++ b/.claude/skills/docs/acceptance-criteria-verification.md @@ -25,7 +25,7 @@ Canonical owner for the acceptance-criteria verification procedure run during th 4. **Verify each AC item** against the proposed changes on the current PR head. -5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` computes the ticked bodies as a dry run for exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A verified label that an open judge act item names (by label text or by an `ac:` / `dod:` id) stays unticked. An id that does not resolve in the pinned spec, or whose text matches no unchecked PR row, skips every PR-body row of its category; the skip applies to PR-body rows only, not to linked issue bodies. Do not hand-tick a row left unticked for an open act item with `tick-verified-checkboxes`. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit, written only after every refusal check and directly before the verdict is posted. The verdict composition and the `clean` guards read the dry-run ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface. +5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` computes the ticked bodies as a dry run for exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A verified label that an open judge act item names (by label text or by an `ac:` / `dod:` id) stays unticked. An id that does not resolve in the pinned spec, or whose text matches no unchecked PR row, skips every PR-body row of its category; the skip applies to PR-body rows only, not to linked issue bodies. Do not hand-tick a row left unticked for an open act item with `tick-verified-checkboxes`. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit, written only after every refusal check and directly before the verdict is posted. The verdict composition and the `clean` guards read the dry-run ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. A refusal changes no body. A failed post (HTTP error) after the write keeps the ticks, and a rerun is idempotent. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface. **Manual tick paths:** the automatic tick needs `verifiedItems`, and only the fan-out path writes them (per-angle artifacts through `consolidate-fanin`). It also reads the unchecked PR-body lists, which the coordination state fills only for a tracker-backed PR with a linked issue (step 7). The tick reads `verifiedItems` only from a trusted ledger: consistent provenance that records a fresh `acceptance-criteria` or `pr-checklist` review, plus fan-out angle coverage in `fanout_fanin` mode. Two paths therefore tick nothing automatically. The first is a post that carries no trusted `verifiedItems`: a post without `--findings-ledger`, a light-mode `inline_single_agent` round (with or without a ledger), or a ledger that lacks a fresh `acceptance-criteria` or `pr-checklist` review or fails the `fanout_fanin` angle coverage. The second is a lightweight or issue-less PR. On those paths the operator ticks each verified item before posting with `node scripts/github/tick-verified-checkboxes.mjs --repo --issue --pr --verified ...`. Without a tracker-backed issue, the operator passes `--pr` alone. The CLI applies the same exact-label tick. On a fan-out post for a tracker-backed PR with a linked issue, the CLI is a manual correction tool only. diff --git a/scripts/github/upsert-checkpoint-verdict.mjs b/scripts/github/upsert-checkpoint-verdict.mjs index 474a054278..10cd77a833 100755 --- a/scripts/github/upsert-checkpoint-verdict.mjs +++ b/scripts/github/upsert-checkpoint-verdict.mjs @@ -2327,7 +2327,8 @@ function anglesPassCoverage(entries, config, gate) { // ACCEPT-CRITERIA-VERIFY-AND-REFLECT automatic tick (pre_approval_gate with a // trusted --findings-ledger only): tick the ledger's reviewer-verified labels -// in the PR body's AC/DoD boxes and, for each linked issue whose unticked AC +// in the PR body's AC/DoD boxes (dry run first; writes are deferred until after +// every refusal) and, for each linked issue whose unticked AC // items the coordination load read, in that issue body. A label outside those // unchecked lists is dropped; an issue whose body the coordination load could // not fetch is skipped (it stays spec-of-record unavailable). Exact-label @@ -3090,7 +3091,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo throw new Error(`--verdict "clean" for ${options.gate} @ ${canonicalHeadSha} contradicts ${actItems.length} open judge act item(s) in --findings-json "${options.findingsJson}" (GATE-COMMENT-VERDICT-VALUES, skills/docs/gate-review-comment-contract.md; ADR 0089): ${actItems.map((f) => `[${f.severity}] ${f.summary}`).join("; ")}. Post "findings_present" or fix the act items first.`); } } - // Tick the reviewer-verified AC/DoD labels BEFORE composing, then read the + // Dry-run the reviewer-verified AC/DoD ticks before composing (writes are queued and applied after the last refusal), then read the // reloaded artifact everywhere below (blocker collection and the clean guards). // Only a ledger whose provenance records a fresh acceptance-criteria or // pr-checklist review is trusted to tick. In fanout_fanin mode the ledger @@ -3644,8 +3645,12 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo + `comments to a submitted review. Re-run with --new-round to post a fresh review round that carries the new inline comment(s).`, ); } + // createGateReview refuses an inline finding comment that carries a raw issue/PR id; run that guard here so the refusal precedes the ticks. + for (const finding of findingSurface ? mergeSameDefectFindings(findingSurface.locatable) : []) { + guardCommentBodyNoIssuePrIds(renderInlineCommentBody(finding, { round: findingSurface.round }), { ref: "gate review inline finding comment", allowedRefs: options.allowedRefs }); + } // The last refusal has passed: apply the queued reviewer-verified ticks now, before the first review or comment write, so a refused post leaves no ticked box. - // A failed write throws before any post; a rerun re-ticks only the rows still unticked. + // A failed write throws before any post. A failed post (HTTP error) after the write keeps the ticks; a rerun is idempotent and re-ticks only the rows still unticked. for (const write of pendingTickWrites) await write(); const findingSurfaceFields = findingSurface ? { @@ -3657,7 +3662,6 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo } : {}; if (ownSubmitted) { - { const reviewBaseResult = { ok: true, repo: options.repo, @@ -3710,7 +3714,6 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo ...((updatedReview.reviewUrl ?? ownSubmitted.commentUrl) ? { commentUrl: updatedReview.reviewUrl ?? ownSubmitted.commentUrl } : {}), ...(verificationWarning ? { verificationWarning } : {}), }; - } } const desiredExecutionMode = options.executionMode ?? DEFAULT_EXECUTION_MODE; const compositionResultFields = checkpointComposition diff --git a/skills/docs/acceptance-criteria-verification.md b/skills/docs/acceptance-criteria-verification.md index 3301c3d9a5..a48336e36f 100644 --- a/skills/docs/acceptance-criteria-verification.md +++ b/skills/docs/acceptance-criteria-verification.md @@ -25,7 +25,7 @@ Canonical owner for the acceptance-criteria verification procedure run during th 4. **Verify each AC item** against the proposed changes on the current PR head. -5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` computes the ticked bodies as a dry run for exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A verified label that an open judge act item names (by label text or by an `ac:` / `dod:` id) stays unticked. An id that does not resolve in the pinned spec, or whose text matches no unchecked PR row, skips every PR-body row of its category; the skip applies to PR-body rows only, not to linked issue bodies. Do not hand-tick a row left unticked for an open act item with `tick-verified-checkboxes`. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit, written only after every refusal check and directly before the verdict is posted. The verdict composition and the `clean` guards read the dry-run ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface. +5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` computes the ticked bodies as a dry run for exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A verified label that an open judge act item names (by label text or by an `ac:` / `dod:` id) stays unticked. An id that does not resolve in the pinned spec, or whose text matches no unchecked PR row, skips every PR-body row of its category; the skip applies to PR-body rows only, not to linked issue bodies. Do not hand-tick a row left unticked for an open act item with `tick-verified-checkboxes`. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit, written only after every refusal check and directly before the verdict is posted. The verdict composition and the `clean` guards read the dry-run ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. A refusal changes no body. A failed post (HTTP error) after the write keeps the ticks, and a rerun is idempotent. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface. **Manual tick paths:** the automatic tick needs `verifiedItems`, and only the fan-out path writes them (per-angle artifacts through `consolidate-fanin`). It also reads the unchecked PR-body lists, which the coordination state fills only for a tracker-backed PR with a linked issue (step 7). The tick reads `verifiedItems` only from a trusted ledger: consistent provenance that records a fresh `acceptance-criteria` or `pr-checklist` review, plus fan-out angle coverage in `fanout_fanin` mode. Two paths therefore tick nothing automatically. The first is a post that carries no trusted `verifiedItems`: a post without `--findings-ledger`, a light-mode `inline_single_agent` round (with or without a ledger), or a ledger that lacks a fresh `acceptance-criteria` or `pr-checklist` review or fails the `fanout_fanin` angle coverage. The second is a lightweight or issue-less PR. On those paths the operator ticks each verified item before posting with `node scripts/github/tick-verified-checkboxes.mjs --repo --issue --pr --verified ...`. Without a tracker-backed issue, the operator passes `--pr` alone. The CLI applies the same exact-label tick. On a fan-out post for a tracker-backed PR with a linked issue, the CLI is a manual correction tool only. diff --git a/test/github/upsert-checkpoint-verdict.test.mjs b/test/github/upsert-checkpoint-verdict.test.mjs index 5709c2a887..bbf31e5f75 100644 --- a/test/github/upsert-checkpoint-verdict.test.mjs +++ b/test/github/upsert-checkpoint-verdict.test.mjs @@ -9613,7 +9613,7 @@ function permissiveVerdictRunChild(headSha) { if (a.includes("graphql")) return { code: 0, stdout: '{"data":{"repository":{"pullRequest":{"reviewThreads":{"nodes":[]}}}}}\n', stderr: "" }; if (a.includes("issues/17/comments")) return { code: 0, stdout: "[]\n", stderr: "" }; if (a.includes("pulls/17/reviews")) return { code: 0, stdout: "[]\n", stderr: "" }; - if (a.includes("pulls/17/files")) return { code: 0, stdout: "[]\n", stderr: "" }; + if (a.includes("pulls/17/files")) return { code: 0, stdout: "[]" + "\n", stderr: "" }; if (a.includes("--json") && a.includes("statusCheckRollup")) return { code: 0, stdout: prJson, stderr: "" }; if (a.includes("--json") && a.includes("headRefOid") && !a.includes("state")) return { code: 0, stdout: JSON.stringify({ headRefOid: headSha }) + "\n", stderr: "" }; if (a.includes("--json") && a.includes("files")) return { code: 0, stdout: "src/db.mjs\n", stderr: "" }; @@ -9816,6 +9816,7 @@ function makeAcRunChild({ issueComments = [], ciSuccess = true, prBody = DEFAULT_TEST_PR_BODY, + prFiles = [], // true fails every body edit; "pr" or "issue" fails only that kind. Mutable // through the returned `control` so a second run can succeed. failBodyEdit = false, @@ -9874,7 +9875,7 @@ function makeAcRunChild({ if (a.includes("graphql")) return { code: 0, stdout: '{"data":{"repository":{"pullRequest":{"reviewThreads":{"nodes":[]}}}}}\n', stderr: "" }; if (a.includes("issues/17/comments")) return { code: 0, stdout: JSON.stringify(issueComments) + "\n", stderr: "" }; if (a.includes("pulls/17/reviews")) return { code: 0, stdout: JSON.stringify(reviews) + "\n", stderr: "" }; - if (a.includes("pulls/17/files")) return { code: 0, stdout: "[]\n", stderr: "" }; + if (a.includes("pulls/17/files")) return { code: 0, stdout: JSON.stringify(prFiles) + "\n", stderr: "" }; // Dispatch pr view --json on the exact --json field list, so the // full pr view (which lists `files` among many fields) is not mistaken for // the dedicated `--json files` files-list call. @@ -10704,13 +10705,14 @@ const COMPOSITION_UNCHECKED_AC_PR_BODY = [ "- none", "", ].join("\n"); -function makeCompositionRunChild(prBody, extraComments = [], { issueBody = TICKED_AC_ISSUE_BODY, failBodyEdit = false, failIssueViewAfter = null, failIssueViewCount, closingIssues = [900], extraIssueBodies = {} } = {}) { +function makeCompositionRunChild(prBody, extraComments = [], { prFiles, issueBody = TICKED_AC_ISSUE_BODY, failBodyEdit = false, failIssueViewAfter = null, failIssueViewCount, closingIssues = [900], extraIssueBodies = {} } = {}) { return makeAcRunChild({ isDraft: false, // Only issue 900 has a body; any other linked issue fails every fetch. closingIssues: closingIssues.map((number) => ({ number })), issueBodyByNumber: { 900: issueBody, ...extraIssueBodies }, prBody, + prFiles, failBodyEdit, failIssueViewAfter, failIssueViewCount, @@ -10735,14 +10737,14 @@ function makeCompositionRunChild(prBody, extraComments = [], { issueBody = TICKE // fresh acceptance-criteria (or pr-checklist) review. const TICK_PROVENANCE = { distinctReviewers: 1, perAngle: [{ angle: "acceptance-criteria", reviewer: "reviewer-ac" }] }; -async function withCompositionRound({ overallVerdict, prBody, gate = "pre_approval_gate", requireFanoutEvidence = false, extraComments = [], verifiedItems, provenance = verifiedItems === undefined ? undefined : TICK_PROVENANCE, issueBody, failBodyEdit, failIssueViewAfter, failIssueViewCount, closingIssues, extraIssueBodies, findings }, fn) { +async function withCompositionRound({ overallVerdict, prBody, gate = "pre_approval_gate", requireFanoutEvidence = false, extraComments = [], verifiedItems, provenance = verifiedItems === undefined ? undefined : TICK_PROVENANCE, issueBody, failBodyEdit, failIssueViewAfter, failIssueViewCount, closingIssues, extraIssueBodies, findings, prFiles }, fn) { const repoRoot = await stageConfigRepoRoot(requireFanoutEvidence); try { const ledgerPath = await writeVerdictLedger(repoRoot, { overallVerdict, gate, verifiedItems, provenance, findings }); const ledgerBefore = await readFile(ledgerPath, "utf8"); const { runChild, calls, editedBodies, control } = gate === "draft_gate" ? makeAcRunChild({ isDraft: true, prBody }) - : makeCompositionRunChild(prBody, extraComments, { issueBody, failBodyEdit, failIssueViewAfter, failIssueViewCount, closingIssues, extraIssueBodies }); + : makeCompositionRunChild(prBody, extraComments, { prFiles, issueBody, failBodyEdit, failIssueViewAfter, failIssueViewCount, closingIssues, extraIssueBodies }); const post = (overrides = {}) => upsertCheckpointVerdict({ repo: "owner/repo", pr: 17, gate, headSha: GATE_FULL_HEAD, findingsSummary: "review round summary", @@ -12518,6 +12520,17 @@ test("tick (#2898): a review-gate rerun refused for the already-submitted review }, { prefix: "dev-loops-upsert-review-refused-no-tick-" }); }); +test("tick (#2898): an issue id in a locatable finding summary is refused before any PR or issue body edit", async () => { + const finding = { angle: "correctness", severity: "medium", summary: "see 1234 and #1234", files: ["src/db.mjs"], line: 2, recommendation: "fix it" }; + await withCompositionRound({ + overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [finding], + prFiles: [{ filename: "src/db.mjs", patch: SINGLE_SURFACE_PATCH }], + }, async ({ post, calls }) => { + await assert.rejects(post({ verdict: "findings_present" }), /issue\/PR|#1234/i); + assert.equal(bodyEdits(calls).length, 0); + }); +}); + test("tick (#2898): an open act item carrying checkedCriteria and no judgeCriterion keeps the named category unticked", async () => { await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ checkedCriteria: ["dod:1"] })] }, async ({ post, editedBodies }) => { await post({ specAuthority: await writeTickPin("pinned.json", computeSpecDigest(extractSpecFromBody(TICK_PR_BODY))) }); From d07ad39a74fce0ebd5699c1e64331055fdcc6fc4 Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Sun, 1 Nov 2026 16:13:19 +0100 Subject: [PATCH 14/23] fix(test): address round-1 review nits on tick integrity Refs #2898 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetT95WedZayUQMysoHv --- changes/2898-tick-integrity.md | 2 ++ test/github/upsert-checkpoint-verdict.test.mjs | 9 +++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/changes/2898-tick-integrity.md b/changes/2898-tick-integrity.md index 64a2275931..2d3be92d98 100644 --- a/changes/2898-tick-integrity.md +++ b/changes/2898-tick-integrity.md @@ -1 +1,3 @@ +### Fixed + - `upsert-checkpoint-verdict` ticks verified rows after all refusal checks, leaves act-named rows unticked; `edit-pr` refuses a silent untick without `--allow-untick` (#2898) diff --git a/test/github/upsert-checkpoint-verdict.test.mjs b/test/github/upsert-checkpoint-verdict.test.mjs index bbf31e5f75..f33753ac4b 100644 --- a/test/github/upsert-checkpoint-verdict.test.mjs +++ b/test/github/upsert-checkpoint-verdict.test.mjs @@ -9613,7 +9613,7 @@ function permissiveVerdictRunChild(headSha) { if (a.includes("graphql")) return { code: 0, stdout: '{"data":{"repository":{"pullRequest":{"reviewThreads":{"nodes":[]}}}}}\n', stderr: "" }; if (a.includes("issues/17/comments")) return { code: 0, stdout: "[]\n", stderr: "" }; if (a.includes("pulls/17/reviews")) return { code: 0, stdout: "[]\n", stderr: "" }; - if (a.includes("pulls/17/files")) return { code: 0, stdout: "[]" + "\n", stderr: "" }; + if (a.includes("pulls/17/files")) return { code: 0, stdout: "[]\n", stderr: "" }; if (a.includes("--json") && a.includes("statusCheckRollup")) return { code: 0, stdout: prJson, stderr: "" }; if (a.includes("--json") && a.includes("headRefOid") && !a.includes("state")) return { code: 0, stdout: JSON.stringify({ headRefOid: headSha }) + "\n", stderr: "" }; if (a.includes("--json") && a.includes("files")) return { code: 0, stdout: "src/db.mjs\n", stderr: "" }; @@ -12478,9 +12478,10 @@ test("tick (#2898): a spec id (dod: / ac:) resolves against the pinned spe } // The pinned spec resolves the id, but no unchecked PR row carries that text (the row is already checked): the whole category is skipped. const preChecked = TICK_PR_BODY.replace(`- [ ] ${TICK_ITEMS[12]}`, `- [x] ${TICK_ITEMS[12]}`); - await withCompositionRound({ overallVerdict: "findings_present", prBody: preChecked, verifiedItems: TICK_ITEMS.slice(13), findings: [openAct({ judgeCriterion: "dod:0" })] }, async ({ post, editedBodies }) => { + await withCompositionRound({ overallVerdict: "findings_present", prBody: preChecked, verifiedItems: TICK_ITEMS.slice(13), findings: [openAct({ judgeCriterion: "dod:0" })] }, async ({ post, editedBodies, calls }) => { await post({ specAuthority: pin }); - for (const item of TICK_ITEMS.slice(13)) unticked(editedBodies[0]?.body ?? `- [ ] ${item}`, item); + assert.equal(editedBodies.length, 0); + assert.equal(bodyEdits(calls).length, 0); }); }); @@ -12495,7 +12496,7 @@ test("tick (#2898): a pinned spec and a PR body without an AC checklist still ti }); }); -test("tick (#2898): a review-gate rerun refused for the already-submitted review changes no PR body and no issue body", async () => { +test("tick (#2898): a review-gate rerun is refused with the already-submitted refusal message", async () => { await withTempDir(async (tempDir) => { const { body } = await createReviewGateAndCaptureBody(tempDir, { nextAction: "none — informational review, no re-gate required" }); const submitted = seededSubmittedReview({ id: 982, body }); From d03184e79c85e0b7dce8f8fcb7fbd58c8bf48def Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Sun, 1 Nov 2026 16:33:39 +0100 Subject: [PATCH 15/23] fix(gate): fence- and section-aware untick guard, category-scoped ticks, live checklist preview Refs #2898 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetT95WedZayUQMysoHv --- scripts/github/edit-pr.mjs | 30 +++++++++++--- scripts/github/tick-verified-checkboxes.mjs | 4 +- scripts/github/upsert-checkpoint-verdict.mjs | 38 ++++++++++++++---- test/github/edit-pr.test.mjs | 14 +++++++ .../github/upsert-checkpoint-verdict.test.mjs | 39 +++++++++++++++++++ 5 files changed, 109 insertions(+), 16 deletions(-) diff --git a/scripts/github/edit-pr.mjs b/scripts/github/edit-pr.mjs index 7c5070bd25..2d7f2f9ab8 100644 --- a/scripts/github/edit-pr.mjs +++ b/scripts/github/edit-pr.mjs @@ -314,14 +314,32 @@ async function buildEditArgs(options) { // an optional blockquote prefix, then a [-*+] or N. or N) marker. const CHECKBOX_ROW = /^\s*(?:>\s*)*(?:[-*+]|\d+[.)])\s+\[([ xX])\]\s+(.+?)\s*$/u; function untickedRows(currentBody, nextBody) { - const checked = (body) => body.split(/\r?\n/u).map((l) => CHECKBOX_ROW.exec(l)).filter((m) => m && m[1] !== " ").map((m) => m[2]); + // Fence-aware and section-scoped: a checked row counts under its nearest heading; rows inside fenced code do not count. + const checked = (body) => { + let fence = null; + let section = ""; + const rows = []; + for (const line of body.split(/\r?\n/u)) { + const f = /^\s*(`{3,}|~{3,})/u.exec(line); + if (f) { + if (fence === null) fence = f[1][0]; + else if (f[1][0] === fence) fence = null; + continue; + } + if (fence !== null) continue; + if (/^\s{0,3}#{1,6}\s/u.test(line)) section = line.trim(); + const m = CHECKBOX_ROW.exec(line); + if (m && m[1] !== " ") rows.push(`${section}\u0000${m[2]}`); + } + return rows; + }; const remaining = new Map(); - for (const label of checked(nextBody)) remaining.set(label, (remaining.get(label) ?? 0) + 1); - return checked(currentBody).filter((label) => { - const left = remaining.get(label) ?? 0; - if (left > 0) remaining.set(label, left - 1); + for (const key of checked(nextBody)) remaining.set(key, (remaining.get(key) ?? 0) + 1); + return checked(currentBody).filter((key) => { + const left = remaining.get(key) ?? 0; + if (left > 0) remaining.set(key, left - 1); return left === 0; - }); + }).map((key) => key.slice(key.indexOf("\u0000") + 1)); } export async function editPr( diff --git a/scripts/github/tick-verified-checkboxes.mjs b/scripts/github/tick-verified-checkboxes.mjs index edb1269c1c..99dc1175dc 100644 --- a/scripts/github/tick-verified-checkboxes.mjs +++ b/scripts/github/tick-verified-checkboxes.mjs @@ -62,7 +62,7 @@ const CHECKBOX_RE = /^(\s*)((?:>\s*)*)([-*+]|\d+[.)])\s+\[([ xX])\]\s+(.*?)(\r?) // an already-`[x]` verified label is neither flipped nor reported as unmatched. // Returns { body, flipped, unmatched } where flipped = labels changed this call // and unmatched = verified labels absent from the body's checklist. An optional -// `lineInScope(index)` limits the flip to the lines it accepts; the CLI omits it. +// `lineInScope(index, label)` limits the flip to the lines it accepts; the CLI omits it. export function tickVerifiedCheckboxes(body, verifiedLabels, lineInScope = () => true) { const verified = new Set( (verifiedLabels ?? []).map((l) => String(l).trim()).filter((l) => l.length > 0), @@ -71,11 +71,11 @@ export function tickVerifiedCheckboxes(body, verifiedLabels, lineInScope = () => const found = new Set(); const lines = String(body).split("\n"); const nextLines = lines.map((line, index) => { - if (!lineInScope(index)) return line; const m = line.match(CHECKBOX_RE); if (!m) return line; const [, indent, quote, marker, mark, rest, cr] = m; const label = rest.trim(); + if (!lineInScope(index, label)) return line; if (!verified.has(label)) return line; found.add(label); if (mark !== " ") return line; // already checked — leave it, idempotent diff --git a/scripts/github/upsert-checkpoint-verdict.mjs b/scripts/github/upsert-checkpoint-verdict.mjs index 10cd77a833..b3e6bae9e0 100755 --- a/scripts/github/upsert-checkpoint-verdict.mjs +++ b/scripts/github/upsert-checkpoint-verdict.mjs @@ -2359,13 +2359,26 @@ async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, }); const inSections = (body, options) => { const indices = checklistSectionLineIndices(body, options); - return (index) => indices.has(index); + if (!options?.byCategory) return (index) => indices.has(index); + // Per-category labels: an AC row ticks only for an AC label, a DoD row only for a DoD label, so a duplicate label in the other category is unaffected. + const acIndices = checklistSectionLineIndices(body, { acOnly: true }); + return (index, label) => indices.has(index) && (acIndices.has(index) ? options.byCategory.ac : options.byCategory.dod).has(label); }; const tick = async (body, labels, write, liveIssue, options) => { const result = await applyTick(body, labels, dryRun, write, inSections(body, options)); if (dryRun && result.flipped.length > 0) { - pendingWrites.push(async () => { + // preview=true re-reads the live body and refuses when its ticked checkbox rows differ from the dry-run's (a concurrent row change the clean guards never saw); it writes nothing. + const checkboxRows = (b) => b.split(/\r?\n/u).filter((l) => /^\s*(?:>\s*)*(?:[-*+]|\d+[.)])\s+\[[ xX]\]\s/u.test(l)).map((l) => l.trim()); + const dryRows = checkboxRows(result.body); + pendingWrites.push(async (preview = false) => { const live = await fetchIssueBody({ repo, issue: liveIssue }, { env, ghCommand, runChild }); + if (preview) { + const liveRows = checkboxRows((await applyTick(live, labels, true, write, inSections(live, options))).body); + if (liveRows.length !== dryRows.length || liveRows.some((row, i) => row !== dryRows[i])) { + throw new Error(`The checklist of ${liveIssue === pr ? `PR ${pr}` : `issue ${liveIssue}`} changed after the gate read it; no verdict is posted and nothing was ticked. Re-run the gate against the current body.`); + } + return undefined; + } return applyTick(live, labels, false, write, inSections(live, options)); }); } @@ -2391,20 +2404,23 @@ async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, ac: { spec: pinnedSpec.acceptanceCriteria, unchecked: artifact?.prBodyUncheckedAcItems ?? [] }, dod: { spec: pinnedSpec.definitionOfDone, unchecked: artifact?.prBodyUncheckedDodItems ?? [] }, }; - const prSkipped = new Set(); + // Skips are scoped to the criterion's category: an AC id never suppresses a same-text DoD row. + const prSkipped = { ac: new Set(), dod: new Set() }; for (const c of namedCriteria) { for (const [, kind, index] of c.matchAll(/\b(ac|dod):(\d+)\b/giu)) { const { spec, unchecked } = categories[kind.toLowerCase()]; const text = spec[Number(index)] === undefined ? "" : normalizeCriterionText(spec[Number(index)]); const row = unchecked.find((label) => normalizeCriterionText(label) === text); - for (const label of text && row !== undefined ? [row] : unchecked) prSkipped.add(String(label).trim()); + for (const label of text && row !== undefined ? [row] : unchecked) prSkipped[kind.toLowerCase()].add(String(label).trim()); } } - const prLabels = inList([...(artifact?.prBodyUncheckedAcItems ?? []), ...(artifact?.prBodyUncheckedDodItems ?? [])]) - .filter((label) => !prSkipped.has(String(label).trim())); - const prTick = await tick(prData.body, prLabels, (bodyFile) => + const byCategory = { + ac: new Set(inList(artifact?.prBodyUncheckedAcItems).map((l) => String(l).trim()).filter((l) => !prSkipped.ac.has(l))), + dod: new Set(inList(artifact?.prBodyUncheckedDodItems).map((l) => String(l).trim()).filter((l) => !prSkipped.dod.has(l))), + }; + const prTick = await tick(prData.body, [...byCategory.ac, ...byCategory.dod], (bodyFile) => editPr({ repo, pr, bodyFile, addAssignees: [], removeAssignees: [] }, { env, ghCommand, run: runChild }), - pr); + pr, { byCategory }); const issueLabels = inList(artifact?.uncheckedAcItems); const tickedIssueBodies = []; const flippedIssueLabels = new Set(); @@ -3056,6 +3072,10 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo summary: (typeof f?.summary === "string" && f.summary.trim()) || "(unparseable)", judgeDisposition: typeof f?.judgeDisposition === "string" ? f.judgeDisposition.trim() : f?.judgeDisposition, ...(f?.remedyKind ? { remedyKind: f.remedyKind } : {}), + // Criterion references survive so the tick exclusion (namedCriteria) sees a reference supplied only in --findings-json. + ...(typeof f?.defectKey === "string" ? { defectKey: f.defectKey } : {}), + ...(typeof f?.judgeCriterion === "string" ? { judgeCriterion: f.judgeCriterion } : {}), + ...(Array.isArray(f?.checkedCriteria) ? { checkedCriteria: f.checkedCriteria } : {}), // Cluster and location survive the projection so the next-action count and fingerprints match the ledger's. ...(Number.isInteger(f?.clusterId) ? { clusterId: f.clusterId } : {}), ...(resolveFindingFile(f) ? { files: [resolveFindingFile(f)] } : {}), @@ -3651,6 +3671,8 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo } // The last refusal has passed: apply the queued reviewer-verified ticks now, before the first review or comment write, so a refused post leaves no ticked box. // A failed write throws before any post. A failed post (HTTP error) after the write keeps the ticks; a rerun is idempotent and re-ticks only the rows still unticked. + // Validate every live checklist against its dry-run first, so a concurrent row change refuses before any body write. + for (const write of pendingTickWrites) await write(true); for (const write of pendingTickWrites) await write(); const findingSurfaceFields = findingSurface ? { diff --git a/test/github/edit-pr.test.mjs b/test/github/edit-pr.test.mjs index 3b4aecf1f6..b5f33c3709 100644 --- a/test/github/edit-pr.test.mjs +++ b/test/github/edit-pr.test.mjs @@ -484,6 +484,20 @@ test("editPr (#2898): the untick guard counts duplicate labels and reports a rem await assert.rejects(() => attempt(live.replace("\n- [x] Docs updated", "")), /untick or remove 1 checked row.*"Docs updated"/s); }); +test("editPr (#2898): the untick guard ignores fenced rows and scopes labels to their section", async () => { + const attempt = (live, body) => editPr( + { repo: "o/n", pr: 5, body, addAssignees: [], removeAssignees: [] }, + { run: stubGh().run, fetchPrContext: async () => ({ headRefName: "x", closingIssuesReferences: [], body: live }) }, + ); + const fenced = "```\n- [x] Tests pass\n```"; + // unticking a real row while adding a fenced checked example: refused + await assert.rejects(() => attempt("## AC\n- [x] Tests pass", `## AC\n- [ ] Tests pass\n${fenced}`), /untick or remove 1 checked row.*"Tests pass"/s); + // editing a fenced checked example: accepted + await attempt(`## AC\n- [x] Tests pass\n${fenced}`, `## AC\n- [x] Tests pass\n${fenced.replace("[x]", "[ ]")}`); + // swapping the tick between sections with the same label: refused + await assert.rejects(() => attempt("## AC\n- [x] Tests pass\n## Validation\n- [ ] Tests pass", "## AC\n- [ ] Tests pass\n## Validation\n- [x] Tests pass"), /untick or remove 1 checked row.*"Tests pass"/s); +}); + test("editPr (#2898): the untick guard sees every checkbox row form the gate parser reads", async () => { const attempt = (live, body) => editPr( { repo: "o/n", pr: 5, body, addAssignees: [], removeAssignees: [] }, diff --git a/test/github/upsert-checkpoint-verdict.test.mjs b/test/github/upsert-checkpoint-verdict.test.mjs index f33753ac4b..37d1dd1ca0 100644 --- a/test/github/upsert-checkpoint-verdict.test.mjs +++ b/test/github/upsert-checkpoint-verdict.test.mjs @@ -12551,6 +12551,45 @@ test("tick (#2898): the deferred write re-applies the tick to the live body and }); }); +test("tick (#2898): a concurrent unchecked row added to the live checklist refuses before any body write", async () => { + await withCompositionRound({ overallVerdict: "clean", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS }, async ({ post, editedBodies, control }) => { + control.concurrentPrBody = TICK_PR_BODY.replace("## Definition of done\n", "## Definition of done\n\n- [ ] a row added concurrently\n"); + await assert.rejects(() => post({ findingsSeverityCounts: CLEAN_COUNTS }), /checklist of PR 17 changed after the gate read it/); + assert.equal(editedBodies.length, 0); + }); +}); + +test("tick (#2898): an ac id skips only the AC row of a label the DoD repeats; the DoD row still ticks", async () => { + const body = ["## Acceptance criteria", "", "- [ ] Shared label", "", "## Definition of done", "", "- [ ] Shared label", ""].join("\n"); + const pin = await writeTickPin("pinned.json", computeSpecDigest(extractSpecFromBody(body))); + for (const [judgeCriterion, acTicked, dodTicked] of [["ac:0", false, true], ["dod:0", true, false]]) { + await withCompositionRound({ overallVerdict: "findings_present", prBody: body, verifiedItems: ["Shared label"], findings: [openAct({ judgeCriterion })] }, async ({ post, editedBodies }) => { + await post({ specAuthority: pin }); + const [acPart, dodPart] = editedBodies[0].body.split("## Definition of done"); + assert.equal(/- \[x\] Shared label/.test(acPart), acTicked, `${judgeCriterion} AC`); + assert.equal(/- \[x\] Shared label/.test(dodPart), dodTicked, `${judgeCriterion} DoD`); + }); + } + // An unresolved id (no pin) skips only its own category too. + await withCompositionRound({ overallVerdict: "findings_present", prBody: body, verifiedItems: ["Shared label"], findings: [openAct({ judgeCriterion: "ac:0" })] }, async ({ post, editedBodies }) => { + await post(); + const [acPart, dodPart] = editedBodies[0].body.split("## Definition of done"); + assert.doesNotMatch(acPart, /- \[x\]/); + assert.match(dodPart, /- \[x\] Shared label/); + }); +}); + +test("tick (#2898): a criterion supplied only through --findings-json keeps its row unticked next to a trusted ledger", async () => { + await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS }, async ({ post, editedBodies }) => { + const dir = await mkdtemp(path.join(os.tmpdir(), "dev-loops-tick-findings-")); + const findingsJson = path.join(dir, "findings.json"); + await writeFile(findingsJson, JSON.stringify([openAct({ judgeCriterion: TICK_ITEMS[12] })])); + await post({ findingsJson }); + assert.match(editedBodies[0].body, new RegExp(`^- \\[ \\] ${TICK_ITEMS[12]}$`, "m")); + assert.match(editedBodies[0].body, new RegExp(`^- \\[x\\] ${TICK_ITEMS[1]}$`, "m")); + }); +}); + test("tick (#2898): the dry-run artifact keeps an issue's unticked AC row that only the reload reads", async () => { const extraIssueBodies = { 901: ["## Acceptance criteria", "", "- [ ] only the reload sees this row", ""].join("\n") }; // View 1 (coordination load of issue 901) fails once; the tick fetch and the reload then read issue 900 and 901. From b361113cfaa28e1f9c419ae8514237f84de36d54 Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Sun, 1 Nov 2026 16:36:29 +0100 Subject: [PATCH 16/23] fix(github): untick guard tolerates heading renames and section moves Refs #2898 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetT95WedZayUQMysoHv --- scripts/github/edit-pr.mjs | 27 +++++++++++++++++++++------ test/github/edit-pr.test.mjs | 15 +++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/scripts/github/edit-pr.mjs b/scripts/github/edit-pr.mjs index 2d7f2f9ab8..26d36fa6ea 100644 --- a/scripts/github/edit-pr.mjs +++ b/scripts/github/edit-pr.mjs @@ -329,17 +329,32 @@ function untickedRows(currentBody, nextBody) { if (fence !== null) continue; if (/^\s{0,3}#{1,6}\s/u.test(line)) section = line.trim(); const m = CHECKBOX_ROW.exec(line); - if (m && m[1] !== " ") rows.push(`${section}\u0000${m[2]}`); + if (m) rows.push({ key: `${section}\u0000${m[2]}`, label: m[2], on: m[1] !== " " }); } return rows; }; + const labelOf = (key) => key.slice(key.indexOf("\u0000") + 1); + const nextRows = checked(nextBody); + const currentRows = checked(currentBody); const remaining = new Map(); - for (const key of checked(nextBody)) remaining.set(key, (remaining.get(key) ?? 0) + 1); - return checked(currentBody).filter((key) => { - const left = remaining.get(key) ?? 0; - if (left > 0) remaining.set(key, left - 1); + for (const r of nextRows.filter((x) => x.on)) remaining.set(r.key, (remaining.get(r.key) ?? 0) + 1); + const lost = currentRows.filter((r) => r.on).filter((r) => { + const left = remaining.get(r.key) ?? 0; + if (left > 0) remaining.set(r.key, left - 1); return left === 0; - }).map((key) => key.slice(key.indexOf("\u0000") + 1)); + }); + // A heading rename or a move to another section changes the key. A lost row is a move when the next body carries + // the label checked under a (section, label) key the current body lacks; a tick swapped onto an existing row is not. + const existing = new Set(currentRows.map((r) => r.key)); + const moved = new Map(); + for (const r of nextRows) { + if (r.on && !existing.has(r.key)) moved.set(r.label, (moved.get(r.label) ?? 0) + 1); + } + return lost.filter((r) => { + const left = moved.get(r.label) ?? 0; + if (left > 0) moved.set(r.label, left - 1); + return left === 0; + }).map((r) => labelOf(r.key)); } export async function editPr( diff --git a/test/github/edit-pr.test.mjs b/test/github/edit-pr.test.mjs index b5f33c3709..2ded633812 100644 --- a/test/github/edit-pr.test.mjs +++ b/test/github/edit-pr.test.mjs @@ -498,6 +498,21 @@ test("editPr (#2898): the untick guard ignores fenced rows and scopes labels to await assert.rejects(() => attempt("## AC\n- [x] Tests pass\n## Validation\n- [ ] Tests pass", "## AC\n- [ ] Tests pass\n## Validation\n- [x] Tests pass"), /untick or remove 1 checked row.*"Tests pass"/s); }); +test("editPr (#2898): the untick guard tolerates a heading rename and a section move, still refuses a real untick", async () => { + const attempt = (live, body) => editPr( + { repo: "o/n", pr: 5, body, addAssignees: [], removeAssignees: [] }, + { run: stubGh().run, fetchPrContext: async () => ({ headRefName: "x", closingIssuesReferences: [], body: live }) }, + ); + // heading renamed: accepted + await attempt("## AC\n- [x] Tests pass", "## Acceptance criteria\n- [x] Tests pass"); + // checked row moved to another section: accepted + await attempt("## AC\n- [x] Tests pass\n## DoD\n- [ ] Docs", "## AC\n## DoD\n- [ ] Docs\n- [x] Tests pass"); + // rename plus a real untick of a second label: refused + await assert.rejects(() => attempt("## AC\n- [x] Tests pass\n- [x] Docs", "## Criteria\n- [x] Tests pass\n- [ ] Docs"), /untick or remove 1 checked row.*"Docs"/s); + // moved under a new heading but unticked there: refused + await assert.rejects(() => attempt("## AC\n- [x] Tests pass", "## Criteria\n- [ ] Tests pass"), /untick or remove 1 checked row.*"Tests pass"/s); +}); + test("editPr (#2898): the untick guard sees every checkbox row form the gate parser reads", async () => { const attempt = (live, body) => editPr( { repo: "o/n", pr: 5, body, addAssignees: [], removeAssignees: [] }, From 95cc7890ebad9de87f5a27fad94ee0d81e1d34be Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Sun, 1 Nov 2026 16:38:42 +0100 Subject: [PATCH 17/23] fix(github): untick guard grants move credit only when the row's key left the body Refs #2898 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetT95WedZayUQMysoHv --- scripts/github/edit-pr.mjs | 3 +++ test/github/edit-pr.test.mjs | 2 ++ 2 files changed, 5 insertions(+) diff --git a/scripts/github/edit-pr.mjs b/scripts/github/edit-pr.mjs index 26d36fa6ea..d0050048dd 100644 --- a/scripts/github/edit-pr.mjs +++ b/scripts/github/edit-pr.mjs @@ -350,7 +350,10 @@ function untickedRows(currentBody, nextBody) { for (const r of nextRows) { if (r.on && !existing.has(r.key)) moved.set(r.label, (moved.get(r.label) ?? 0) + 1); } + // Only a row whose own key vanished from the next body can have moved; an unticked row that stays put is a plain untick. + const nextKeys = new Set(nextRows.map((r) => r.key)); return lost.filter((r) => { + if (nextKeys.has(r.key)) return true; const left = moved.get(r.label) ?? 0; if (left > 0) moved.set(r.label, left - 1); return left === 0; diff --git a/test/github/edit-pr.test.mjs b/test/github/edit-pr.test.mjs index 2ded633812..9a6b47733a 100644 --- a/test/github/edit-pr.test.mjs +++ b/test/github/edit-pr.test.mjs @@ -509,6 +509,8 @@ test("editPr (#2898): the untick guard tolerates a heading rename and a section await attempt("## AC\n- [x] Tests pass\n## DoD\n- [ ] Docs", "## AC\n## DoD\n- [ ] Docs\n- [x] Tests pass"); // rename plus a real untick of a second label: refused await assert.rejects(() => attempt("## AC\n- [x] Tests pass\n- [x] Docs", "## Criteria\n- [x] Tests pass\n- [ ] Docs"), /untick or remove 1 checked row.*"Docs"/s); + // row unticked in place while a checked row with the same label is added under a new heading: refused + await assert.rejects(() => attempt("## AC\n- [x] Tests pass", "## AC\n- [ ] Tests pass\n## Extra\n- [x] Tests pass"), /untick or remove 1 checked row.*"Tests pass"/s); // moved under a new heading but unticked there: refused await assert.rejects(() => attempt("## AC\n- [x] Tests pass", "## Criteria\n- [ ] Tests pass"), /untick or remove 1 checked row.*"Tests pass"/s); }); From cee773cfeb0064b8d558900de5a69fffc3660a5f Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Sun, 1 Nov 2026 16:57:32 +0100 Subject: [PATCH 18/23] fix(github): one fence-aware AC/DoD scope model for tick, preview and untick guard (Refs #2898) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetT95WedZayUQMysoHv --- .../docs/acceptance-criteria-verification.md | 2 + .../src/loop/issue-refinement-artifact.mjs | 36 ++++++++++++ scripts/github/edit-pr.mjs | 51 ++++------------- scripts/github/upsert-checkpoint-verdict.mjs | 13 +++-- .../docs/acceptance-criteria-verification.md | 2 + test/github/edit-pr.test.mjs | 56 ++++++++++++++----- 6 files changed, 102 insertions(+), 58 deletions(-) diff --git a/.claude/skills/docs/acceptance-criteria-verification.md b/.claude/skills/docs/acceptance-criteria-verification.md index a48336e36f..bce685d4ee 100644 --- a/.claude/skills/docs/acceptance-criteria-verification.md +++ b/.claude/skills/docs/acceptance-criteria-verification.md @@ -27,6 +27,8 @@ Canonical owner for the acceptance-criteria verification procedure run during th 5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` computes the ticked bodies as a dry run for exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A verified label that an open judge act item names (by label text or by an `ac:` / `dod:` id) stays unticked. An id that does not resolve in the pinned spec, or whose text matches no unchecked PR row, skips every PR-body row of its category; the skip applies to PR-body rows only, not to linked issue bodies. Do not hand-tick a row left unticked for an open act item with `tick-verified-checkboxes`. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit, written only after every refusal check and directly before the verdict is posted. The verdict composition and the `clean` guards read the dry-run ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. A refusal changes no body. A failed post (HTTP error) after the write keeps the ticks, and a rerun is idempotent. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface. + **CHECKLIST-SCOPE-ONE-MODEL:** every tick-integrity check reads checklist scope from `checklistLineScopes` in `packages/core/src/loop/issue-refinement-artifact.mjs`: the fence-aware scope of a line is `ac`, `dod`, or none, set by its nearest Acceptance criteria or Definition of done heading, so a Definition of done section nested under Acceptance criteria is `dod`. The category-scoped tick, the live checklist preview and the `edit-pr` untick guard key a checkbox row by that scope plus its label. A heading rename inside one scope keeps a checked row. A heading edit that moves a checked row into or out of a scope counts as losing it and needs `--allow-untick`. No second fence scanner or section classifier may decide that scope. + **Manual tick paths:** the automatic tick needs `verifiedItems`, and only the fan-out path writes them (per-angle artifacts through `consolidate-fanin`). It also reads the unchecked PR-body lists, which the coordination state fills only for a tracker-backed PR with a linked issue (step 7). The tick reads `verifiedItems` only from a trusted ledger: consistent provenance that records a fresh `acceptance-criteria` or `pr-checklist` review, plus fan-out angle coverage in `fanout_fanin` mode. Two paths therefore tick nothing automatically. The first is a post that carries no trusted `verifiedItems`: a post without `--findings-ledger`, a light-mode `inline_single_agent` round (with or without a ledger), or a ledger that lacks a fresh `acceptance-criteria` or `pr-checklist` review or fails the `fanout_fanin` angle coverage. The second is a lightweight or issue-less PR. On those paths the operator ticks each verified item before posting with `node scripts/github/tick-verified-checkboxes.mjs --repo --issue --pr --verified ...`. Without a tracker-backed issue, the operator passes `--pr` alone. The CLI applies the same exact-label tick. On a fan-out post for a tracker-backed PR with a linked issue, the CLI is a manual correction tool only. 6. **PR body tick:** the step 5 tick covers the PR body, so the merged PR shows checked AC/DoD. The tick is automatic on a fan-out post for a tracker-backed PR with a linked issue. On the manual tick paths the operator runs the CLI. An item that no reviewer verified stays `- [ ]`, and so does a deferred item. diff --git a/packages/core/src/loop/issue-refinement-artifact.mjs b/packages/core/src/loop/issue-refinement-artifact.mjs index f908a3684f..de801f7447 100644 --- a/packages/core/src/loop/issue-refinement-artifact.mjs +++ b/packages/core/src/loop/issue-refinement-artifact.mjs @@ -1436,6 +1436,42 @@ export function checklistSectionLineIndices(body, { acOnly = false } = {}) { return indices; } +/** + * Per-line checklist scope of a body, indexed like `body.split(/\r?\n/)`: + * "ac" or "dod" for a line under an Acceptance criteria / Definition of done + * section (nearest matching ancestor wins, so a `### Definition of done` + * nested under `## Acceptance criteria` is "dod"), null for any other line, + * undefined for a line inside fenced code. One scope model for every + * tick-integrity consumer (CHECKLIST-SCOPE-ONE-MODEL). + * + * @param {string} body + * @returns {Array<"ac"|"dod"|null|undefined>} + */ +export function checklistLineScopes(body) { + const lines = typeof body === "string" ? body.split(/\r?\n/u) : []; + const scopes = new Array(lines.length).fill(undefined); + const sections = parseMarkdownSections(body); + const mark = (from, texts, scope) => { + let fence = null; + texts.forEach((text, k) => { + const step = stepFence(fence, text); + fence = step.fence; + if (!step.insideFence) scopes[from + k] = scope; + }); + }; + mark(0, lines.slice(0, sections[0]?.line ?? lines.length), null); + const stack = []; + for (const section of sections) { + while (stack.length > 0 && stack.at(-1).level >= section.level) stack.pop(); + const own = ACCEPTANCE_SECTION_PATTERNS.some((p) => p.test(section.name)) + ? "ac" + : DOD_SECTION_PATTERNS.some((p) => p.test(section.name)) ? "dod" : (stack.at(-1)?.scope ?? null); + stack.push({ level: section.level, scope: own }); + mark(section.line + 1, section.bodyLines, own); + } + return scopes; +} + /** * Decide what an enqueue caller should do with a refinement-artifact result, * so an un-refined item never lands in the Next Up pickup column in the first diff --git a/scripts/github/edit-pr.mjs b/scripts/github/edit-pr.mjs index d0050048dd..37305e9402 100644 --- a/scripts/github/edit-pr.mjs +++ b/scripts/github/edit-pr.mjs @@ -2,7 +2,7 @@ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs"; import { parsePrNumber, requireTokenValue, resolveBodyOrFile, runChild } from "../_cli-primitives.mjs"; import { parseRepoSlug } from "@dev-loops/core/github/repo-slug"; -import { detectGrillEmbedHeading } from "@dev-loops/core/loop/issue-refinement-artifact"; +import { checklistLineScopes, detectGrillEmbedHeading } from "@dev-loops/core/loop/issue-refinement-artifact"; import { detectClosingKeyword, resolveExpectedIssueFromPrContext, @@ -314,50 +314,23 @@ async function buildEditArgs(options) { // an optional blockquote prefix, then a [-*+] or N. or N) marker. const CHECKBOX_ROW = /^\s*(?:>\s*)*(?:[-*+]|\d+[.)])\s+\[([ xX])\]\s+(.+?)\s*$/u; function untickedRows(currentBody, nextBody) { - // Fence-aware and section-scoped: a checked row counts under its nearest heading; rows inside fenced code do not count. + // Fence-aware and scope-keyed (CHECKLIST-SCOPE-ONE-MODEL): the key is the row's AC/DoD/other scope plus its label, from the + // same checklistLineScopes the tick path uses. A rename inside one scope keeps the row; a heading edit that moves a + // checked row into or out of a scope loses it. Rows inside fenced code have no scope and do not count. const checked = (body) => { - let fence = null; - let section = ""; - const rows = []; - for (const line of body.split(/\r?\n/u)) { - const f = /^\s*(`{3,}|~{3,})/u.exec(line); - if (f) { - if (fence === null) fence = f[1][0]; - else if (f[1][0] === fence) fence = null; - continue; - } - if (fence !== null) continue; - if (/^\s{0,3}#{1,6}\s/u.test(line)) section = line.trim(); - const m = CHECKBOX_ROW.exec(line); - if (m) rows.push({ key: `${section}\u0000${m[2]}`, label: m[2], on: m[1] !== " " }); - } - return rows; + const scopes = checklistLineScopes(body); + return body.split(/\r?\n/u).flatMap((line, i) => { + const m = scopes[i] === undefined ? null : CHECKBOX_ROW.exec(line); + return m && m[1] !== " " ? [{ key: `${scopes[i]}\u0000${m[2]}`, label: m[2] }] : []; + }); }; - const labelOf = (key) => key.slice(key.indexOf("\u0000") + 1); - const nextRows = checked(nextBody); - const currentRows = checked(currentBody); const remaining = new Map(); - for (const r of nextRows.filter((x) => x.on)) remaining.set(r.key, (remaining.get(r.key) ?? 0) + 1); - const lost = currentRows.filter((r) => r.on).filter((r) => { + for (const r of checked(nextBody)) remaining.set(r.key, (remaining.get(r.key) ?? 0) + 1); + return checked(currentBody).filter((r) => { const left = remaining.get(r.key) ?? 0; if (left > 0) remaining.set(r.key, left - 1); return left === 0; - }); - // A heading rename or a move to another section changes the key. A lost row is a move when the next body carries - // the label checked under a (section, label) key the current body lacks; a tick swapped onto an existing row is not. - const existing = new Set(currentRows.map((r) => r.key)); - const moved = new Map(); - for (const r of nextRows) { - if (r.on && !existing.has(r.key)) moved.set(r.label, (moved.get(r.label) ?? 0) + 1); - } - // Only a row whose own key vanished from the next body can have moved; an unticked row that stays put is a plain untick. - const nextKeys = new Set(nextRows.map((r) => r.key)); - return lost.filter((r) => { - if (nextKeys.has(r.key)) return true; - const left = moved.get(r.label) ?? 0; - if (left > 0) moved.set(r.label, left - 1); - return left === 0; - }).map((r) => labelOf(r.key)); + }).map((r) => r.label); } export async function editPr( diff --git a/scripts/github/upsert-checkpoint-verdict.mjs b/scripts/github/upsert-checkpoint-verdict.mjs index b3e6bae9e0..234026c26a 100755 --- a/scripts/github/upsert-checkpoint-verdict.mjs +++ b/scripts/github/upsert-checkpoint-verdict.mjs @@ -15,7 +15,7 @@ import { ghGraphql as runGhGraphql, ghJson as runGhJson } from "@dev-loops/core/ import { minimizeSupersededGateReviews } from "./_minimize-superseded-verdicts.mjs"; import { loadPrGateCoordinationContext, loadRefinementArtifact } from "../loop/detect-pr-gate-coordination-state.mjs"; import { fetchIssueBody } from "../loop/detect-issue-refinement-artifact.mjs"; -import { checklistSectionLineIndices, detectIssueRefinementArtifact } from "@dev-loops/core/loop/issue-refinement-artifact"; +import { checklistLineScopes, checklistSectionLineIndices, detectIssueRefinementArtifact } from "@dev-loops/core/loop/issue-refinement-artifact"; import { applyTick } from "./tick-verified-checkboxes.mjs"; import { editPr } from "./edit-pr.mjs"; import { editIssue } from "./edit-issue.mjs"; @@ -2361,14 +2361,19 @@ async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, const indices = checklistSectionLineIndices(body, options); if (!options?.byCategory) return (index) => indices.has(index); // Per-category labels: an AC row ticks only for an AC label, a DoD row only for a DoD label, so a duplicate label in the other category is unaffected. - const acIndices = checklistSectionLineIndices(body, { acOnly: true }); - return (index, label) => indices.has(index) && (acIndices.has(index) ? options.byCategory.ac : options.byCategory.dod).has(label); + // The nearest-heading scope puts a DoD section nested under Acceptance criteria in the DoD category. + const scopes = checklistLineScopes(body); + return (index, label) => indices.has(index) && options.byCategory[scopes[index]]?.has(label) === true; }; const tick = async (body, labels, write, liveIssue, options) => { const result = await applyTick(body, labels, dryRun, write, inSections(body, options)); if (dryRun && result.flipped.length > 0) { // preview=true re-reads the live body and refuses when its ticked checkbox rows differ from the dry-run's (a concurrent row change the clean guards never saw); it writes nothing. - const checkboxRows = (b) => b.split(/\r?\n/u).filter((l) => /^\s*(?:>\s*)*(?:[-*+]|\d+[.)])\s+\[[ xX]\]\s/u.test(l)).map((l) => l.trim()); + // Each row carries its checklist scope, so a concurrent heading edit that moves rows into or out of AC/DoD also refuses. + const checkboxRows = (b) => { + const scopes = checklistLineScopes(b); + return b.split(/\r?\n/u).flatMap((l, i) => /^\s*(?:>\s*)*(?:[-*+]|\d+[.)])\s+\[[ xX]\]\s/u.test(l) ? [`${scopes[i]}\u0000${l.trim()}`] : []); + }; const dryRows = checkboxRows(result.body); pendingWrites.push(async (preview = false) => { const live = await fetchIssueBody({ repo, issue: liveIssue }, { env, ghCommand, runChild }); diff --git a/skills/docs/acceptance-criteria-verification.md b/skills/docs/acceptance-criteria-verification.md index a48336e36f..bce685d4ee 100644 --- a/skills/docs/acceptance-criteria-verification.md +++ b/skills/docs/acceptance-criteria-verification.md @@ -27,6 +27,8 @@ Canonical owner for the acceptance-criteria verification procedure run during th 5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` computes the ticked bodies as a dry run for exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A verified label that an open judge act item names (by label text or by an `ac:` / `dod:` id) stays unticked. An id that does not resolve in the pinned spec, or whose text matches no unchecked PR row, skips every PR-body row of its category; the skip applies to PR-body rows only, not to linked issue bodies. Do not hand-tick a row left unticked for an open act item with `tick-verified-checkboxes`. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit, written only after every refusal check and directly before the verdict is posted. The verdict composition and the `clean` guards read the dry-run ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. A refusal changes no body. A failed post (HTTP error) after the write keeps the ticks, and a rerun is idempotent. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface. + **CHECKLIST-SCOPE-ONE-MODEL:** every tick-integrity check reads checklist scope from `checklistLineScopes` in `packages/core/src/loop/issue-refinement-artifact.mjs`: the fence-aware scope of a line is `ac`, `dod`, or none, set by its nearest Acceptance criteria or Definition of done heading, so a Definition of done section nested under Acceptance criteria is `dod`. The category-scoped tick, the live checklist preview and the `edit-pr` untick guard key a checkbox row by that scope plus its label. A heading rename inside one scope keeps a checked row. A heading edit that moves a checked row into or out of a scope counts as losing it and needs `--allow-untick`. No second fence scanner or section classifier may decide that scope. + **Manual tick paths:** the automatic tick needs `verifiedItems`, and only the fan-out path writes them (per-angle artifacts through `consolidate-fanin`). It also reads the unchecked PR-body lists, which the coordination state fills only for a tracker-backed PR with a linked issue (step 7). The tick reads `verifiedItems` only from a trusted ledger: consistent provenance that records a fresh `acceptance-criteria` or `pr-checklist` review, plus fan-out angle coverage in `fanout_fanin` mode. Two paths therefore tick nothing automatically. The first is a post that carries no trusted `verifiedItems`: a post without `--findings-ledger`, a light-mode `inline_single_agent` round (with or without a ledger), or a ledger that lacks a fresh `acceptance-criteria` or `pr-checklist` review or fails the `fanout_fanin` angle coverage. The second is a lightweight or issue-less PR. On those paths the operator ticks each verified item before posting with `node scripts/github/tick-verified-checkboxes.mjs --repo --issue --pr --verified ...`. Without a tracker-backed issue, the operator passes `--pr` alone. The CLI applies the same exact-label tick. On a fan-out post for a tracker-backed PR with a linked issue, the CLI is a manual correction tool only. 6. **PR body tick:** the step 5 tick covers the PR body, so the merged PR shows checked AC/DoD. The tick is automatic on a fan-out post for a tracker-backed PR with a linked issue. On the manual tick paths the operator runs the CLI. An item that no reviewer verified stays `- [ ]`, and so does a deferred item. diff --git a/test/github/edit-pr.test.mjs b/test/github/edit-pr.test.mjs index 9a6b47733a..cbc561182c 100644 --- a/test/github/edit-pr.test.mjs +++ b/test/github/edit-pr.test.mjs @@ -7,6 +7,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseEditPrCliArgs, editPr as editPrRaw, runCli } from "../../scripts/github/edit-pr.mjs"; +import { checklistLineScopes } from "@dev-loops/core/loop/issue-refinement-artifact"; import { captureStream, makeGhStub } from "../_helpers.mjs"; // A body edit reads the PR's current body (waiver-line baseline) through @@ -498,21 +499,46 @@ test("editPr (#2898): the untick guard ignores fenced rows and scopes labels to await assert.rejects(() => attempt("## AC\n- [x] Tests pass\n## Validation\n- [ ] Tests pass", "## AC\n- [ ] Tests pass\n## Validation\n- [x] Tests pass"), /untick or remove 1 checked row.*"Tests pass"/s); }); -test("editPr (#2898): the untick guard tolerates a heading rename and a section move, still refuses a real untick", async () => { - const attempt = (live, body) => editPr( - { repo: "o/n", pr: 5, body, addAssignees: [], removeAssignees: [] }, - { run: stubGh().run, fetchPrContext: async () => ({ headRefName: "x", closingIssuesReferences: [], body: live }) }, - ); - // heading renamed: accepted - await attempt("## AC\n- [x] Tests pass", "## Acceptance criteria\n- [x] Tests pass"); - // checked row moved to another section: accepted - await attempt("## AC\n- [x] Tests pass\n## DoD\n- [ ] Docs", "## AC\n## DoD\n- [ ] Docs\n- [x] Tests pass"); - // rename plus a real untick of a second label: refused - await assert.rejects(() => attempt("## AC\n- [x] Tests pass\n- [x] Docs", "## Criteria\n- [x] Tests pass\n- [ ] Docs"), /untick or remove 1 checked row.*"Docs"/s); - // row unticked in place while a checked row with the same label is added under a new heading: refused - await assert.rejects(() => attempt("## AC\n- [x] Tests pass", "## AC\n- [ ] Tests pass\n## Extra\n- [x] Tests pass"), /untick or remove 1 checked row.*"Tests pass"/s); - // moved under a new heading but unticked there: refused - await assert.rejects(() => attempt("## AC\n- [x] Tests pass", "## Criteria\n- [ ] Tests pass"), /untick or remove 1 checked row.*"Tests pass"/s); +const attemptEdit = (live, body) => editPr( + { repo: "o/n", pr: 5, body, addAssignees: [], removeAssignees: [] }, + { run: stubGh().run, fetchPrContext: async () => ({ headRefName: "x", closingIssuesReferences: [], body: live }) }, +); + +test("editPr (#2898): a heading rename inside one scope is accepted; a heading-only edit that leaves the scope is refused", async () => { + // rename inside the AC scope: accepted + await attemptEdit("## AC\n- [x] Tests pass", "## Acceptance criteria\n- [x] Tests pass"); + // heading-only edit: the AC heading becomes a plain heading, the checked row leaves the AC scope + await assert.rejects(() => attemptEdit("## AC\n- [x] Tests pass", "## Criteria\n- [x] Tests pass"), /untick or remove 1 checked row.*"Tests pass"/s); + // a plain heading nested into the AC scope is no loss of the other rows + await attemptEdit("## AC\n- [x] A\n## Notes\n- [x] B", "## AC\n- [x] A\n## Notes\n- [x] B"); +}); + +test("editPr (#2898): a four-backtick fence holding a three-backtick line hides its checked rows", async () => { + const fenced = "````\n```\n- [x] Hidden\n````"; + await attemptEdit(`## AC\n- [x] Tests pass\n${fenced}`, `## AC\n- [x] Tests pass\n${fenced.replace("[x]", "[ ]")}`); + await assert.rejects(() => attemptEdit(`## AC\n- [x] Tests pass\n${fenced}`, `## AC\n- [ ] Tests pass\n${fenced}`), /untick or remove 1 checked row.*"Tests pass"/s); + // the row after the closed four-backtick fence is a real row + await assert.rejects(() => attemptEdit(`${fenced}\n- [x] Real`, `${fenced}\n- [ ] Real`), /"Real"/); +}); + +test("editPr (#2898): a Definition of done nested under Acceptance criteria is its own scope", async () => { + const body = "## Acceptance criteria\n- [x] A\n### Definition of done\n- [x] A\n### Notes\n- [x] A"; + const scopes = checklistLineScopes(body); + assert.deepEqual([scopes[1], scopes[3], scopes[5]], ["ac", "dod", "ac"]); + // unticking the nested DoD copy is a loss of a dod row, not an ac row + await assert.rejects(() => attemptEdit(body, body.replace("### Definition of done\n- [x] A", "### Definition of done\n- [ ] A")), /untick or remove 1 checked row.*"A"/s); +}); + +test("editPr (#2898): a heading rename plus a real untick is refused naming the unticked row", async () => { + await assert.rejects(() => attemptEdit("## AC\n- [x] Tests pass\n- [x] Docs", "## Acceptance criteria\n- [x] Tests pass\n- [ ] Docs"), /untick or remove 1 checked row.*"Docs"/s); +}); + +test("editPr (#2898): renaming both AC and DoD headings with a swap moves every checked row out of its scope", async () => { + const live = "## AC\n- [x] A\n## DoD\n- [x] D"; + // the headings swap names, so each row changes scope + await assert.rejects(() => attemptEdit(live, "## DoD\n- [x] A\n## AC\n- [x] D"), /untick or remove 2 checked row/s); + // both renamed within their own scope: accepted + await attemptEdit(live, "## Acceptance criteria\n- [x] A\n## Definition of done\n- [x] D"); }); test("editPr (#2898): the untick guard sees every checkbox row form the gate parser reads", async () => { From 092c2681c2e06a5f8f94f99b6f887364e86fd007 Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Sun, 1 Nov 2026 17:02:10 +0100 Subject: [PATCH 19/23] test(github): cover nested-DoD tick pick and scope-only preview change; narrow CHECKLIST-SCOPE-ONE-MODEL to the PR body (Refs #2898) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetT95WedZayUQMysoHv --- .../docs/acceptance-criteria-verification.md | 2 +- .../docs/acceptance-criteria-verification.md | 2 +- test/github/edit-pr.test.mjs | 6 ++++-- .../github/upsert-checkpoint-verdict.test.mjs | 20 +++++++++++++++++++ 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/.claude/skills/docs/acceptance-criteria-verification.md b/.claude/skills/docs/acceptance-criteria-verification.md index bce685d4ee..70f97d35cc 100644 --- a/.claude/skills/docs/acceptance-criteria-verification.md +++ b/.claude/skills/docs/acceptance-criteria-verification.md @@ -27,7 +27,7 @@ Canonical owner for the acceptance-criteria verification procedure run during th 5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` computes the ticked bodies as a dry run for exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A verified label that an open judge act item names (by label text or by an `ac:` / `dod:` id) stays unticked. An id that does not resolve in the pinned spec, or whose text matches no unchecked PR row, skips every PR-body row of its category; the skip applies to PR-body rows only, not to linked issue bodies. Do not hand-tick a row left unticked for an open act item with `tick-verified-checkboxes`. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit, written only after every refusal check and directly before the verdict is posted. The verdict composition and the `clean` guards read the dry-run ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. A refusal changes no body. A failed post (HTTP error) after the write keeps the ticks, and a rerun is idempotent. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface. - **CHECKLIST-SCOPE-ONE-MODEL:** every tick-integrity check reads checklist scope from `checklistLineScopes` in `packages/core/src/loop/issue-refinement-artifact.mjs`: the fence-aware scope of a line is `ac`, `dod`, or none, set by its nearest Acceptance criteria or Definition of done heading, so a Definition of done section nested under Acceptance criteria is `dod`. The category-scoped tick, the live checklist preview and the `edit-pr` untick guard key a checkbox row by that scope plus its label. A heading rename inside one scope keeps a checked row. A heading edit that moves a checked row into or out of a scope counts as losing it and needs `--allow-untick`. No second fence scanner or section classifier may decide that scope. + **CHECKLIST-SCOPE-ONE-MODEL:** every PR-body tick-integrity check reads checklist scope from `checklistLineScopes` in `packages/core/src/loop/issue-refinement-artifact.mjs`: the fence-aware scope of a line is `ac`, `dod`, or none, set by its nearest Acceptance criteria or Definition of done heading, so a Definition of done section nested under Acceptance criteria is `dod`. The category-scoped tick, the live checklist preview and the `edit-pr` untick guard key a checkbox row by that scope plus its label. A heading rename inside one scope keeps a checked row. A heading edit that moves a checked row into or out of a scope counts as losing it and needs `--allow-untick`. No second fence scanner or section classifier may decide that scope for the PR body. The linked-issue tick keeps its own `acOnly` section filter. **Manual tick paths:** the automatic tick needs `verifiedItems`, and only the fan-out path writes them (per-angle artifacts through `consolidate-fanin`). It also reads the unchecked PR-body lists, which the coordination state fills only for a tracker-backed PR with a linked issue (step 7). The tick reads `verifiedItems` only from a trusted ledger: consistent provenance that records a fresh `acceptance-criteria` or `pr-checklist` review, plus fan-out angle coverage in `fanout_fanin` mode. Two paths therefore tick nothing automatically. The first is a post that carries no trusted `verifiedItems`: a post without `--findings-ledger`, a light-mode `inline_single_agent` round (with or without a ledger), or a ledger that lacks a fresh `acceptance-criteria` or `pr-checklist` review or fails the `fanout_fanin` angle coverage. The second is a lightweight or issue-less PR. On those paths the operator ticks each verified item before posting with `node scripts/github/tick-verified-checkboxes.mjs --repo --issue --pr --verified ...`. Without a tracker-backed issue, the operator passes `--pr` alone. The CLI applies the same exact-label tick. On a fan-out post for a tracker-backed PR with a linked issue, the CLI is a manual correction tool only. diff --git a/skills/docs/acceptance-criteria-verification.md b/skills/docs/acceptance-criteria-verification.md index bce685d4ee..70f97d35cc 100644 --- a/skills/docs/acceptance-criteria-verification.md +++ b/skills/docs/acceptance-criteria-verification.md @@ -27,7 +27,7 @@ Canonical owner for the acceptance-criteria verification procedure run during th 5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` computes the ticked bodies as a dry run for exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A verified label that an open judge act item names (by label text or by an `ac:` / `dod:` id) stays unticked. An id that does not resolve in the pinned spec, or whose text matches no unchecked PR row, skips every PR-body row of its category; the skip applies to PR-body rows only, not to linked issue bodies. Do not hand-tick a row left unticked for an open act item with `tick-verified-checkboxes`. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit, written only after every refusal check and directly before the verdict is posted. The verdict composition and the `clean` guards read the dry-run ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. A refusal changes no body. A failed post (HTTP error) after the write keeps the ticks, and a rerun is idempotent. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface. - **CHECKLIST-SCOPE-ONE-MODEL:** every tick-integrity check reads checklist scope from `checklistLineScopes` in `packages/core/src/loop/issue-refinement-artifact.mjs`: the fence-aware scope of a line is `ac`, `dod`, or none, set by its nearest Acceptance criteria or Definition of done heading, so a Definition of done section nested under Acceptance criteria is `dod`. The category-scoped tick, the live checklist preview and the `edit-pr` untick guard key a checkbox row by that scope plus its label. A heading rename inside one scope keeps a checked row. A heading edit that moves a checked row into or out of a scope counts as losing it and needs `--allow-untick`. No second fence scanner or section classifier may decide that scope. + **CHECKLIST-SCOPE-ONE-MODEL:** every PR-body tick-integrity check reads checklist scope from `checklistLineScopes` in `packages/core/src/loop/issue-refinement-artifact.mjs`: the fence-aware scope of a line is `ac`, `dod`, or none, set by its nearest Acceptance criteria or Definition of done heading, so a Definition of done section nested under Acceptance criteria is `dod`. The category-scoped tick, the live checklist preview and the `edit-pr` untick guard key a checkbox row by that scope plus its label. A heading rename inside one scope keeps a checked row. A heading edit that moves a checked row into or out of a scope counts as losing it and needs `--allow-untick`. No second fence scanner or section classifier may decide that scope for the PR body. The linked-issue tick keeps its own `acOnly` section filter. **Manual tick paths:** the automatic tick needs `verifiedItems`, and only the fan-out path writes them (per-angle artifacts through `consolidate-fanin`). It also reads the unchecked PR-body lists, which the coordination state fills only for a tracker-backed PR with a linked issue (step 7). The tick reads `verifiedItems` only from a trusted ledger: consistent provenance that records a fresh `acceptance-criteria` or `pr-checklist` review, plus fan-out angle coverage in `fanout_fanin` mode. Two paths therefore tick nothing automatically. The first is a post that carries no trusted `verifiedItems`: a post without `--findings-ledger`, a light-mode `inline_single_agent` round (with or without a ledger), or a ledger that lacks a fresh `acceptance-criteria` or `pr-checklist` review or fails the `fanout_fanin` angle coverage. The second is a lightweight or issue-less PR. On those paths the operator ticks each verified item before posting with `node scripts/github/tick-verified-checkboxes.mjs --repo --issue --pr --verified ...`. Without a tracker-backed issue, the operator passes `--pr` alone. The CLI applies the same exact-label tick. On a fan-out post for a tracker-backed PR with a linked issue, the CLI is a manual correction tool only. diff --git a/test/github/edit-pr.test.mjs b/test/github/edit-pr.test.mjs index cbc561182c..00ec15a86f 100644 --- a/test/github/edit-pr.test.mjs +++ b/test/github/edit-pr.test.mjs @@ -509,8 +509,8 @@ test("editPr (#2898): a heading rename inside one scope is accepted; a heading-o await attemptEdit("## AC\n- [x] Tests pass", "## Acceptance criteria\n- [x] Tests pass"); // heading-only edit: the AC heading becomes a plain heading, the checked row leaves the AC scope await assert.rejects(() => attemptEdit("## AC\n- [x] Tests pass", "## Criteria\n- [x] Tests pass"), /untick or remove 1 checked row.*"Tests pass"/s); - // a plain heading nested into the AC scope is no loss of the other rows - await attemptEdit("## AC\n- [x] A\n## Notes\n- [x] B", "## AC\n- [x] A\n## Notes\n- [x] B"); + // a plain heading nested into the AC scope moves its checked row into AC: its scope key changes, so it is refused + await assert.rejects(() => attemptEdit("## AC\n- [x] A\n## Notes\n- [x] B", "## AC\n- [x] A\n### Notes\n- [x] B"), /untick or remove 1 checked row.*"B"/s); }); test("editPr (#2898): a four-backtick fence holding a three-backtick line hides its checked rows", async () => { @@ -531,6 +531,8 @@ test("editPr (#2898): a Definition of done nested under Acceptance criteria is i test("editPr (#2898): a heading rename plus a real untick is refused naming the unticked row", async () => { await assert.rejects(() => attemptEdit("## AC\n- [x] Tests pass\n- [x] Docs", "## Acceptance criteria\n- [x] Tests pass\n- [ ] Docs"), /untick or remove 1 checked row.*"Docs"/s); + // a same-label checked row outside AC must not absorb the renamed row's credit + await assert.rejects(() => attemptEdit("## AC\n- [x] Tests pass\n- [x] Docs\n## Notes\n- [x] Tests pass", "## Acceptance criteria\n- [x] Tests pass\n- [ ] Docs\n## Notes\n- [x] Tests pass"), /untick or remove 1 checked row.*"Docs"/s); }); test("editPr (#2898): renaming both AC and DoD headings with a swap moves every checked row out of its scope", async () => { diff --git a/test/github/upsert-checkpoint-verdict.test.mjs b/test/github/upsert-checkpoint-verdict.test.mjs index 37d1dd1ca0..0ecf30b65f 100644 --- a/test/github/upsert-checkpoint-verdict.test.mjs +++ b/test/github/upsert-checkpoint-verdict.test.mjs @@ -12579,6 +12579,26 @@ test("tick (#2898): an ac id skips only the AC row of a label the DoD repeats; t }); }); +test("tick (#2898): an open dod id keeps a Definition of done nested under Acceptance criteria unticked", async () => { + const body = ["## Acceptance criteria", "", "- [ ] Shared label", "", "### Definition of done", "", "- [ ] Shared label", ""].join("\n"); + const pin = await writeTickPin("pinned.json", computeSpecDigest(extractSpecFromBody(body))); + await withCompositionRound({ overallVerdict: "findings_present", prBody: body, verifiedItems: ["Shared label"], findings: [openAct({ judgeCriterion: "dod:0" })] }, async ({ post, editedBodies }) => { + await post({ specAuthority: pin }); + const [acPart, dodPart] = editedBodies[0].body.split("### Definition of done"); + assert.match(acPart, /- \[x\] Shared label/); + assert.doesNotMatch(dodPart, /- \[x\]/); + }); +}); + +test("tick (#2898): a concurrent heading edit that moves a row into the AC scope refuses before any body write", async () => { + const withValidation = (heading) => TICK_PR_BODY.replace("## Definition of done\n", `${heading}\n\n- [ ] an unverified row\n\n## Definition of done\n`); + await withCompositionRound({ overallVerdict: "clean", prBody: withValidation("## Validation"), verifiedItems: TICK_ITEMS }, async ({ post, editedBodies, control }) => { + control.concurrentPrBody = withValidation("### Validation"); + await assert.rejects(() => post({ findingsSeverityCounts: CLEAN_COUNTS }), /checklist of PR 17 changed after the gate read it/); + assert.equal(editedBodies.length, 0); + }); +}); + test("tick (#2898): a criterion supplied only through --findings-json keeps its row unticked next to a trusted ledger", async () => { await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS }, async ({ post, editedBodies }) => { const dir = await mkdtemp(path.join(os.tmpdir(), "dev-loops-tick-findings-")); From d5ddd319af43a9e99d1e077ac95b1e28b0828b06 Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Sun, 1 Nov 2026 17:04:42 +0100 Subject: [PATCH 20/23] test(github): drop the same-label Notes case that passes on the pre-fix source (Refs #2898) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetT95WedZayUQMysoHv --- test/github/edit-pr.test.mjs | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/github/edit-pr.test.mjs b/test/github/edit-pr.test.mjs index 00ec15a86f..8b24a9e26c 100644 --- a/test/github/edit-pr.test.mjs +++ b/test/github/edit-pr.test.mjs @@ -531,8 +531,6 @@ test("editPr (#2898): a Definition of done nested under Acceptance criteria is i test("editPr (#2898): a heading rename plus a real untick is refused naming the unticked row", async () => { await assert.rejects(() => attemptEdit("## AC\n- [x] Tests pass\n- [x] Docs", "## Acceptance criteria\n- [x] Tests pass\n- [ ] Docs"), /untick or remove 1 checked row.*"Docs"/s); - // a same-label checked row outside AC must not absorb the renamed row's credit - await assert.rejects(() => attemptEdit("## AC\n- [x] Tests pass\n- [x] Docs\n## Notes\n- [x] Tests pass", "## Acceptance criteria\n- [x] Tests pass\n- [ ] Docs\n## Notes\n- [x] Tests pass"), /untick or remove 1 checked row.*"Docs"/s); }); test("editPr (#2898): renaming both AC and DoD headings with a swap moves every checked row out of its scope", async () => { From 57ffb66a541cd4e97cd364d2698f663d28605694 Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Sun, 1 Nov 2026 17:28:00 +0100 Subject: [PATCH 21/23] fix(gate): evidence_only act items hold their row; register CHECKLIST-SCOPE-ONE-MODEL Refs #2898 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetT95WedZayUQMysoHv --- .claude/skills/docs/acceptance-criteria-verification.md | 5 +++-- scripts/github/upsert-checkpoint-verdict.mjs | 4 +++- skills/docs/acceptance-criteria-verification.md | 5 +++-- skills/docs/required-rules.json | 4 ++++ test/github/upsert-checkpoint-verdict.test.mjs | 6 ++++++ 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/.claude/skills/docs/acceptance-criteria-verification.md b/.claude/skills/docs/acceptance-criteria-verification.md index 70f97d35cc..3ff3f9aa32 100644 --- a/.claude/skills/docs/acceptance-criteria-verification.md +++ b/.claude/skills/docs/acceptance-criteria-verification.md @@ -25,9 +25,10 @@ Canonical owner for the acceptance-criteria verification procedure run during th 4. **Verify each AC item** against the proposed changes on the current PR head. -5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` computes the ticked bodies as a dry run for exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A verified label that an open judge act item names (by label text or by an `ac:` / `dod:` id) stays unticked. An id that does not resolve in the pinned spec, or whose text matches no unchecked PR row, skips every PR-body row of its category; the skip applies to PR-body rows only, not to linked issue bodies. Do not hand-tick a row left unticked for an open act item with `tick-verified-checkboxes`. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit, written only after every refusal check and directly before the verdict is posted. The verdict composition and the `clean` guards read the dry-run ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. A refusal changes no body. A failed post (HTTP error) after the write keeps the ticks, and a rerun is idempotent. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface. +5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` computes the ticked bodies as a dry run for exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A verified label that an open judge act item (including one with an evidence-only remedy) names (by label text or by an `ac:` / `dod:` id) stays unticked. An id that does not resolve in the pinned spec, or whose text matches no unchecked PR row, skips every PR-body row of its category; the skip applies to PR-body rows only, not to linked issue bodies. Do not hand-tick a row left unticked for an open act item with `tick-verified-checkboxes`. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit, written only after every refusal check and directly before the verdict is posted. The verdict composition and the `clean` guards read the dry-run ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. A refusal changes no body. A failed post (HTTP error) after the write keeps the ticks, and a rerun is idempotent. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface. - **CHECKLIST-SCOPE-ONE-MODEL:** every PR-body tick-integrity check reads checklist scope from `checklistLineScopes` in `packages/core/src/loop/issue-refinement-artifact.mjs`: the fence-aware scope of a line is `ac`, `dod`, or none, set by its nearest Acceptance criteria or Definition of done heading, so a Definition of done section nested under Acceptance criteria is `dod`. The category-scoped tick, the live checklist preview and the `edit-pr` untick guard key a checkbox row by that scope plus its label. A heading rename inside one scope keeps a checked row. A heading edit that moves a checked row into or out of a scope counts as losing it and needs `--allow-untick`. No second fence scanner or section classifier may decide that scope for the PR body. The linked-issue tick keeps its own `acOnly` section filter. + + **CHECKLIST-SCOPE-ONE-MODEL:** every PR-body tick-integrity check reads checklist scope from `checklistLineScopes` in `packages/core/src/loop/issue-refinement-artifact.mjs`: the fence-aware scope of a line is `ac`, `dod`, or none, set by its nearest Acceptance criteria or Definition of done heading, so a Definition of done section nested under Acceptance criteria is `dod`. The category-scoped tick, the live checklist preview and the `edit-pr` untick guard key a checkbox row by that scope plus its label. A heading rename inside one scope keeps a checked row. A heading edit that moves a checked row into or out of a scope counts as losing it and needs `--allow-untick`. A second fence scanner or section classifier MUST NOT decide that scope for the PR body. The linked-issue tick keeps its own `acOnly` section filter. **Manual tick paths:** the automatic tick needs `verifiedItems`, and only the fan-out path writes them (per-angle artifacts through `consolidate-fanin`). It also reads the unchecked PR-body lists, which the coordination state fills only for a tracker-backed PR with a linked issue (step 7). The tick reads `verifiedItems` only from a trusted ledger: consistent provenance that records a fresh `acceptance-criteria` or `pr-checklist` review, plus fan-out angle coverage in `fanout_fanin` mode. Two paths therefore tick nothing automatically. The first is a post that carries no trusted `verifiedItems`: a post without `--findings-ledger`, a light-mode `inline_single_agent` round (with or without a ledger), or a ledger that lacks a fresh `acceptance-criteria` or `pr-checklist` review or fails the `fanout_fanin` angle coverage. The second is a lightweight or issue-less PR. On those paths the operator ticks each verified item before posting with `node scripts/github/tick-verified-checkboxes.mjs --repo --issue --pr --verified ...`. Without a tracker-backed issue, the operator passes `--pr` alone. The CLI applies the same exact-label tick. On a fan-out post for a tracker-backed PR with a linked issue, the CLI is a manual correction tool only. diff --git a/scripts/github/upsert-checkpoint-verdict.mjs b/scripts/github/upsert-checkpoint-verdict.mjs index 234026c26a..0ae1d9a540 100755 --- a/scripts/github/upsert-checkpoint-verdict.mjs +++ b/scripts/github/upsert-checkpoint-verdict.mjs @@ -3130,7 +3130,9 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo if (options.gate === "pre_approval_gate" && preloadedFindingsLedger?.verifiedItems?.length > 0 && ledgerVerifiesChecklist(preloadedFindingsLedger) && tickCoverageOk) { // Dry run: the composition and clean guards read the would-be ticked artifact; the edits run after the last refusal (pendingTickWrites). // An open act item names a row by its label text or by a spec id (`ac:` / `dod:`); see tickReviewerVerifiedItems. - const namedCriteria = listOpenActItems([...(preloadedFindingsLedger.findings ?? []), ...enrichedActItems]) + // An evidence_only act item also holds its row: its remedy is evidence, but the judge still says the row is unproven. + const namedCriteria = [...(preloadedFindingsLedger.findings ?? []), ...enrichedActItems, ...jsonUnactedFindings] + .filter((f) => f?.judgeDisposition === "act") .flatMap((f) => [f.defectKey, f.judgeCriterion, ...(Array.isArray(f.checkedCriteria) ? f.checkedCriteria : [])]) .filter((c) => typeof c === "string").map((c) => c.trim()).filter(Boolean); coordinationContext.refinementArtifact = await tickReviewerVerifiedItems({ diff --git a/skills/docs/acceptance-criteria-verification.md b/skills/docs/acceptance-criteria-verification.md index 70f97d35cc..3ff3f9aa32 100644 --- a/skills/docs/acceptance-criteria-verification.md +++ b/skills/docs/acceptance-criteria-verification.md @@ -25,9 +25,10 @@ Canonical owner for the acceptance-criteria verification procedure run during th 4. **Verify each AC item** against the proposed changes on the current PR head. -5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` computes the ticked bodies as a dry run for exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A verified label that an open judge act item names (by label text or by an `ac:` / `dod:` id) stays unticked. An id that does not resolve in the pinned spec, or whose text matches no unchecked PR row, skips every PR-body row of its category; the skip applies to PR-body rows only, not to linked issue bodies. Do not hand-tick a row left unticked for an open act item with `tick-verified-checkboxes`. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit, written only after every refusal check and directly before the verdict is posted. The verdict composition and the `clean` guards read the dry-run ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. A refusal changes no body. A failed post (HTTP error) after the write keeps the ticks, and a rerun is idempotent. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface. +5. **Automatic tick before composing:** the reviewers of the `acceptance-criteria` and `pr-checklist` angles list the exact label of each checklist item they verified at this head in their findings artifact's `verifiedItems` field (defined in the review agent's structured findings artifact). Fan-in carries the deduplicated union into the durable findings ledger. For a `pre_approval_gate` post with `--findings-ledger`, `upsert-checkpoint-verdict.mjs` computes the ticked bodies as a dry run for exactly those labels before it composes the verdict. In `fanout_fanin` mode the ledger must first pass the fan-out angle-coverage checks. In every mode its provenance must be consistent and record a fresh (not carried) `acceptance-criteria` or `pr-checklist` review. Otherwise nothing is ticked. The poster flips each matching `- [ ]` to `- [x]` in the PR body's unchecked Acceptance criteria and Definition of done items. When the spec-of-record still has unticked AC items (`uncheckedAcItems`), it also ticks each linked issue body that the coordination load read and that carries unticked AC items. A label outside those unchecked lists is not ticked. A verified label that an open judge act item (including one with an evidence-only remedy) names (by label text or by an `ac:` / `dod:` id) stays unticked. An id that does not resolve in the pinned spec, or whose text matches no unchecked PR row, skips every PR-body row of its category; the skip applies to PR-body rows only, not to linked issue bodies. Do not hand-tick a row left unticked for an open act item with `tick-verified-checkboxes`. A linked issue whose body the coordination load could not fetch is skipped and stays spec-of-record unavailable. The match is exact-label, and the tick never blanket-checks and never unchecks. Each changed body gets one edit, written only after every refusal check and directly before the verdict is posted. The verdict composition and the `clean` guards read the dry-run ticked bodies. A failed fetch or edit of a body being ticked fails closed, and no verdict is posted. A refusal changes no body. A failed post (HTTP error) after the write keeps the ticks, and a rerun is idempotent. The tick does not run for `draft_gate`. On a matrix-representation issue the issue-side tick is a no-op, and the PR body's derived checklist is the ticked surface. - **CHECKLIST-SCOPE-ONE-MODEL:** every PR-body tick-integrity check reads checklist scope from `checklistLineScopes` in `packages/core/src/loop/issue-refinement-artifact.mjs`: the fence-aware scope of a line is `ac`, `dod`, or none, set by its nearest Acceptance criteria or Definition of done heading, so a Definition of done section nested under Acceptance criteria is `dod`. The category-scoped tick, the live checklist preview and the `edit-pr` untick guard key a checkbox row by that scope plus its label. A heading rename inside one scope keeps a checked row. A heading edit that moves a checked row into or out of a scope counts as losing it and needs `--allow-untick`. No second fence scanner or section classifier may decide that scope for the PR body. The linked-issue tick keeps its own `acOnly` section filter. + + **CHECKLIST-SCOPE-ONE-MODEL:** every PR-body tick-integrity check reads checklist scope from `checklistLineScopes` in `packages/core/src/loop/issue-refinement-artifact.mjs`: the fence-aware scope of a line is `ac`, `dod`, or none, set by its nearest Acceptance criteria or Definition of done heading, so a Definition of done section nested under Acceptance criteria is `dod`. The category-scoped tick, the live checklist preview and the `edit-pr` untick guard key a checkbox row by that scope plus its label. A heading rename inside one scope keeps a checked row. A heading edit that moves a checked row into or out of a scope counts as losing it and needs `--allow-untick`. A second fence scanner or section classifier MUST NOT decide that scope for the PR body. The linked-issue tick keeps its own `acOnly` section filter. **Manual tick paths:** the automatic tick needs `verifiedItems`, and only the fan-out path writes them (per-angle artifacts through `consolidate-fanin`). It also reads the unchecked PR-body lists, which the coordination state fills only for a tracker-backed PR with a linked issue (step 7). The tick reads `verifiedItems` only from a trusted ledger: consistent provenance that records a fresh `acceptance-criteria` or `pr-checklist` review, plus fan-out angle coverage in `fanout_fanin` mode. Two paths therefore tick nothing automatically. The first is a post that carries no trusted `verifiedItems`: a post without `--findings-ledger`, a light-mode `inline_single_agent` round (with or without a ledger), or a ledger that lacks a fresh `acceptance-criteria` or `pr-checklist` review or fails the `fanout_fanin` angle coverage. The second is a lightweight or issue-less PR. On those paths the operator ticks each verified item before posting with `node scripts/github/tick-verified-checkboxes.mjs --repo --issue --pr --verified ...`. Without a tracker-backed issue, the operator passes `--pr` alone. The CLI applies the same exact-label tick. On a fan-out post for a tracker-backed PR with a linked issue, the CLI is a manual correction tool only. diff --git a/skills/docs/required-rules.json b/skills/docs/required-rules.json index 699bb0fe13..2a285a6954 100644 --- a/skills/docs/required-rules.json +++ b/skills/docs/required-rules.json @@ -80,6 +80,10 @@ "enforcement": "agent", "enforcementNote": "Path assumptions are agent behavior." }, + { + "id": "CHECKLIST-SCOPE-ONE-MODEL", + "enforcement": "runtime" + }, { "id": "CLOSING-REF-BRANCH-MISMATCH", "enforcement": "runtime" diff --git a/test/github/upsert-checkpoint-verdict.test.mjs b/test/github/upsert-checkpoint-verdict.test.mjs index 0ecf30b65f..eb79d23564 100644 --- a/test/github/upsert-checkpoint-verdict.test.mjs +++ b/test/github/upsert-checkpoint-verdict.test.mjs @@ -12454,6 +12454,12 @@ test("tick (#2898): a spec id (dod: / ac:) resolves against the pinned spe for (const item of TICK_ITEMS.filter((_, i) => i !== 13)) ticked(editedBodies[0].body, item); }); } + // An evidence_only act item holds its row too. + await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ judgeCriterion: "dod:1", remedyKind: "evidence_only" })] }, async ({ post, editedBodies }) => { + await post({ specAuthority: pin }); + unticked(editedBodies[0].body, TICK_ITEMS[13]); + for (const item of TICK_ITEMS.filter((_, i) => i !== 13)) ticked(editedBodies[0].body, item); + }); // AC row 0 is "criterion number 1". await withCompositionRound({ overallVerdict: "findings_present", prBody: TICK_PR_BODY, verifiedItems: TICK_ITEMS, findings: [openAct({ judgeCriterion: "ac:0" })] }, async ({ post, editedBodies }) => { await post({ specAuthority: pin }); From 058b4bb56589f12793a65d7c0917818fe4de66e3 Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Sun, 1 Nov 2026 17:33:57 +0100 Subject: [PATCH 22/23] fix(docs): cite CHECKLIST-SCOPE-ONE-MODEL in the edit-pr untick refusal Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetT95WedZayUQMysoHv --- scripts/github/edit-pr.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/github/edit-pr.mjs b/scripts/github/edit-pr.mjs index 37305e9402..a13c79d788 100644 --- a/scripts/github/edit-pr.mjs +++ b/scripts/github/edit-pr.mjs @@ -368,7 +368,7 @@ export async function editPr( if (waiverRefusal) throw new Error(waiverRefusal); const unticked = untickedRows(baseline, guardBody); if (unticked.length > 0 && !options.allowUntick) { - throw new Error(`Refusing to untick or remove ${unticked.length} checked row(s) on PR #${options.pr} (unticked, deleted or reworded): ${unticked.map((l) => JSON.stringify(l)).join("; ")}. Edit from the current body, or pass --allow-untick for a deliberate change.`); + throw new Error(`CHECKLIST-SCOPE-ONE-MODEL: refusing to untick or remove ${unticked.length} checked row(s) on PR #${options.pr} (unticked, deleted or reworded): ${unticked.map((l) => JSON.stringify(l)).join("; ")}. Edit from the current body, or pass --allow-untick for a deliberate change.`); } } if (!options.allowCrossIssue && detectClosingKeyword(guardBody)) { From c4d852dcc3fc21bf07b8a67efbb777e3d6d12e9b Mon Sep 17 00:00:00 2001 From: Manuel Fittko Date: Sun, 1 Nov 2026 17:51:20 +0100 Subject: [PATCH 23/23] refactor(gate): tickReviewerVerifiedItems always queues writes Refs #2898 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R7yetT95WedZayUQMysoHv --- scripts/github/upsert-checkpoint-verdict.mjs | 21 +++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/scripts/github/upsert-checkpoint-verdict.mjs b/scripts/github/upsert-checkpoint-verdict.mjs index 0ae1d9a540..fd2cdd1b4b 100755 --- a/scripts/github/upsert-checkpoint-verdict.mjs +++ b/scripts/github/upsert-checkpoint-verdict.mjs @@ -2338,7 +2338,7 @@ function anglesPassCoverage(entries, config, gate) { // labels no reviewer verified stay unchecked in it and still block. Only lines // inside the AC/DoD sections (the AC section for an issue body) are flipped, so // the same label under another heading stays unchecked. -// dryRun computes the ticked artifact without editing and queues each edit in +// The tick computes the ticked artifact without editing and queues each edit in // pendingWrites; each queued write re-fetches the live body and re-applies the // tick to it, so a concurrent body edit is kept and the edit guards compare // against the live body. namedCriteria (open act item criterion texts) are never @@ -2347,7 +2347,7 @@ function anglesPassCoverage(entries, config, gate) { // spec, resolved only when the PR body's spec digest equals the pinned specDigest. // Only an id the pinned spec cannot resolve (no pin, a different pinned spec, no such // row), or whose text matches no unchecked PR row, skips every PR-body row of its category. -async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, coordinationContext, dryRun = false, pendingWrites = [], namedCriteria = [], pinnedSpecDigest }, { env, ghCommand, runChild }) { +async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, coordinationContext, pendingWrites, namedCriteria = [], pinnedSpecDigest }, { env, ghCommand, runChild }) { // A label is skipped when a normalized criterion equals it or contains it bounded by non-word characters or the string edge. const flat = (t) => normalizeCriterionText(String(t).replaceAll("`", "")).toLowerCase().replace(/\.+$/u, ""); const normalizedCriteria = namedCriteria.map(flat); @@ -2366,8 +2366,8 @@ async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, return (index, label) => indices.has(index) && options.byCategory[scopes[index]]?.has(label) === true; }; const tick = async (body, labels, write, liveIssue, options) => { - const result = await applyTick(body, labels, dryRun, write, inSections(body, options)); - if (dryRun && result.flipped.length > 0) { + const result = await applyTick(body, labels, true, write, inSections(body, options)); + if (result.flipped.length > 0) { // preview=true re-reads the live body and refuses when its ticked checkbox rows differ from the dry-run's (a concurrent row change the clean guards never saw); it writes nothing. // Each row carries its checklist scope, so a concurrent heading edit that moves rows into or out of AC/DoD also refuses. const checkboxRows = (b) => { @@ -2453,16 +2453,14 @@ async function tickReviewerVerifiedItems({ repo, pr, verifiedItems: allVerified, // A dry run edited nothing, so the reload's issue rows still carry the labels this tick would flip: drop those, keep the rest. // A linked issue only the reload read keeps its own unticked rows, even when they share a label this tick flips elsewhere. const reloadOnlyLabels = new Set(); - if (dryRun) { - const fetchedIssues = new Set(artifact.uncheckedAcIssues ?? []); - for (const issue of (reloaded.uncheckedAcIssues ?? []).filter((n) => !fetchedIssues.has(n))) { - const body = await fetchIssueBody({ repo, issue }, { env, ghCommand, runChild }); - detectIssueRefinementArtifact({ body }).uncheckedAcItems.forEach((label) => reloadOnlyLabels.add(String(label).trim())); - } + const fetchedIssues = new Set(artifact.uncheckedAcIssues ?? []); + for (const issue of (reloaded.uncheckedAcIssues ?? []).filter((n) => !fetchedIssues.has(n))) { + const body = await fetchIssueBody({ repo, issue }, { env, ghCommand, runChild }); + detectIssueRefinementArtifact({ body }).uncheckedAcItems.forEach((label) => reloadOnlyLabels.add(String(label).trim())); } const reloadedItems = (reloaded.uncheckedAcItems ?? []).filter((label) => { const text = String(label).trim(); - return !(dryRun && flippedIssueLabels.has(text) && !reloadOnlyLabels.has(text)); + return !(flippedIssueLabels.has(text) && !reloadOnlyLabels.has(text)); }); const uncheckedAcItems = [...new Set([...reloadedItems, ...local])]; return { ...reloaded, uncheckedAcItems }; @@ -3140,7 +3138,6 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo pr: options.pr, verifiedItems: preloadedFindingsLedger.verifiedItems, coordinationContext, - dryRun: true, pendingWrites: pendingTickWrites, namedCriteria, pinnedSpecDigest: specAuthorityIdentity?.specDigest,