@dev-loops/core 1.0.3 → 1.0.4-pre.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -198,6 +198,7 @@ export function buildSnapshotFromPrFacts({
198
198
  failureDetails = [],
199
199
  excludedFailureDetails,
200
200
  copilotBodyFeedbackUnresolved = false,
201
+ copilotPriorHeadBodyFeedbackUnresolved = false,
201
202
  }) {
202
203
  const prState = typeof prData?.state === "string" ? prData.state.toUpperCase() : "OPEN";
203
204
  const prMerged = prState === "MERGED";
@@ -228,6 +229,7 @@ export function buildSnapshotFromPrFacts({
228
229
  failureDetails,
229
230
  excludedFailureDetails: excludedFailureDetails ?? rollupDerivation.excludedFailureDetails,
230
231
  copilotBodyFeedbackUnresolved,
232
+ copilotPriorHeadBodyFeedbackUnresolved,
231
233
  });
232
234
  }
233
235
 
@@ -298,6 +300,10 @@ export function normalizeSnapshot(raw) {
298
300
  failureDetails: Array.isArray(raw.failureDetails) ? raw.failureDetails : [],
299
301
  excludedFailureDetails: Array.isArray(raw.excludedFailureDetails) ? raw.excludedFailureDetails : [],
300
302
  copilotBodyFeedbackUnresolved: Boolean(raw.copilotBodyFeedbackUnresolved),
303
+ // A body-only changes-recommended/unrecognized latest Copilot review on an
304
+ // earlier head with no trusted disposition record. Consumed only at the
305
+ // round cap, where no fresh Copilot review can supersede it.
306
+ copilotPriorHeadBodyFeedbackUnresolved: Boolean(raw.copilotPriorHeadBodyFeedbackUnresolved),
301
307
  };
302
308
  }
303
309
 
@@ -357,7 +363,8 @@ export function applyConfirmedReviewRequest(snapshot, reviewRequestStatus) {
357
363
  * nextAction: string,
358
364
  * autoRerequestEligible: boolean,
359
365
  * sameHeadCleanConverged: boolean,
360
- * roundCapCleanEligible: boolean
366
+ * roundCapCleanEligible: boolean,
367
+ * roundCapReopenEligible: boolean
361
368
  * }}
362
369
  */
363
370
  export function interpretLoopState(snapshot, refinementConfig) {
@@ -402,12 +409,18 @@ export function interpretLoopState(snapshot, refinementConfig) {
402
409
  const maxRounds = refinementConfig?.maxCopilotRounds;
403
410
  const reviewInFlight = s.copilotReviewRequestStatus === "requested"
404
411
  || s.copilotReviewRequestStatus === "already-requested";
412
+ // Clean at the cap except for an earlier-head body-only finding. That finding
413
+ // blocks the clean fallback, but a significant post-convergence change still
414
+ // opens a new Copilot cycle, whose review supersedes it.
415
+ let roundCapBlockedOnlyByPriorHeadBody = false;
405
416
  if (isCopilotRoundCapReached({ copilotReviewRoundCount: s.copilotReviewRoundCount, maxCopilotRounds: maxRounds })
406
417
  && state !== STATE.NO_PR && state !== STATE.DONE
407
418
  && state !== STATE.PR_DRAFT && state !== STATE.REVIEW_REQUEST_UNAVAILABLE
408
419
  && state !== STATE.BLOCKED_NEEDS_USER_DECISION) {
409
420
  const ciClean = s.ciStatus === "success" || s.ciStatus === "crediblyGreen" || !preApprovalRequireCi;
410
- const cleanThreads = s.unresolvedThreadCount === 0 && !s.copilotBodyFeedbackUnresolved;
421
+ const cleanCurrentHead = s.unresolvedThreadCount === 0 && !s.copilotBodyFeedbackUnresolved;
422
+ const cleanThreads = cleanCurrentHead && !s.copilotPriorHeadBodyFeedbackUnresolved;
423
+ roundCapBlockedOnlyByPriorHeadBody = cleanCurrentHead && ciClean && !cleanThreads && !reviewInFlight;
411
424
  if (cleanThreads && ciClean) {
412
425
  state = STATE.ROUND_CAP_CLEAN_FALLBACK;
413
426
  } else if (!reviewInFlight) {
@@ -483,6 +496,9 @@ export function interpretLoopState(snapshot, refinementConfig) {
483
496
  }
484
497
 
485
498
  const roundCapCleanEligible = state === STATE.ROUND_CAP_CLEAN_FALLBACK;
499
+ // Cap states where a significant post-convergence change reopens a Copilot cycle.
500
+ const roundCapReopenEligible = roundCapCleanEligible
501
+ || (state === STATE.ROUND_CAP_REACHED && roundCapBlockedOnlyByPriorHeadBody);
486
502
 
487
503
  return {
488
504
  state,
@@ -491,6 +507,26 @@ export function interpretLoopState(snapshot, refinementConfig) {
491
507
  autoRerequestEligible,
492
508
  sameHeadCleanConverged,
493
509
  roundCapCleanEligible,
510
+ roundCapReopenEligible,
511
+ };
512
+ }
513
+
514
+ /**
515
+ * Reopen a Copilot cycle at the round cap after a significant post-convergence
516
+ * change landed on a newer head (see `roundCapReopenEligible`).
517
+ *
518
+ * @param {object} interpretation - interpretLoopState() output
519
+ * @returns {object} the interpretation routed to READY_TO_REREQUEST_REVIEW
520
+ */
521
+ export function reopenRoundCapCycle(interpretation) {
522
+ return {
523
+ ...interpretation,
524
+ state: STATE.READY_TO_REREQUEST_REVIEW,
525
+ nextAction: NEXT_ACTIONS[STATE.READY_TO_REREQUEST_REVIEW],
526
+ allowedTransitions: [...TRANSITIONS[STATE.READY_TO_REREQUEST_REVIEW]],
527
+ autoRerequestEligible: true,
528
+ roundCapCleanEligible: false,
529
+ roundCapReopenEligible: false,
494
530
  };
495
531
  }
496
532
 
@@ -10,7 +10,7 @@
10
10
  * array/objects.
11
11
  */
12
12
 
13
- import { resolveFindingFile } from "./gate-fanin.mjs";
13
+ import { normalizeSeverity, resolveFindingFile } from "./gate-fanin.mjs";
14
14
 
15
15
  // NUL separates the three key components so a value inside one component
16
16
  // (e.g. a recommendation that happens to contain a colon or digits matching
@@ -257,21 +257,40 @@ export function dedupeActListByCluster(actFindings, clusters, allFindings) {
257
257
  }
258
258
 
259
259
  /**
260
- * A "clean" verdict is invalid the moment any finding was acted on — an
261
- * acted finding is, by definition, unresolved work. Throws a clear Error on
262
- * `overallVerdict === "clean" && actCount > 0`; returns `overallVerdict`
263
- * unchanged otherwise.
260
+ * A "clean" ledger severity verdict means no finding at a BLOCKING severity
261
+ * remains open. It is invalid only when a finding at a blocking severity was
262
+ * acted on — that is unresolved blocking work, so the round cannot be clean.
263
+ * The ledger's severity verdict may stay clean with NON-BLOCKING act findings
264
+ * (a medium in the fix window, a low the fixer triages). The posted review
265
+ * verdict is composed with the act list (ADR 0089), so such a round posts
266
+ * findings_present until its act items are fixed.
267
+ *
268
+ * Throws a clear Error on `overallVerdict === "clean"` with any act finding at a
269
+ * blocking severity; returns `overallVerdict` unchanged otherwise.
264
270
  *
265
271
  * @param {unknown} overallVerdict
266
- * @param {number} actCount — non-negative integer; fails closed otherwise.
272
+ * @param {Array<{severity?: unknown}>} actFindings — the round's act-disposed findings.
273
+ * @param {string[]} [blockingSeverities] — the gate's blocking severities (default ["high"]).
267
274
  * @returns {unknown} `overallVerdict`, unchanged.
268
275
  */
269
- export function assertCleanImpliesNoAct(overallVerdict, actCount) {
270
- if (!Number.isInteger(actCount) || actCount < 0) {
271
- throw new TypeError("assertCleanImpliesNoAct requires actCount to be a non-negative integer");
276
+ export function assertCleanImpliesNoBlockingAct(overallVerdict, actFindings, blockingSeverities) {
277
+ if (!Array.isArray(actFindings)) {
278
+ throw new TypeError("assertCleanImpliesNoBlockingAct requires actFindings to be an array");
272
279
  }
273
- if (overallVerdict === "clean" && actCount > 0) {
274
- throw new Error("clean verdict is invalid with a nonzero act count: any acted finding prevents clean");
280
+ if (overallVerdict !== "clean") return overallVerdict;
281
+ const blocking = new Set(
282
+ (Array.isArray(blockingSeverities) && blockingSeverities.length > 0 ? blockingSeverities : ["high"]).map((s) =>
283
+ normalizeSeverity(s),
284
+ ),
285
+ );
286
+ const offending = actFindings.filter((f) => blocking.has(normalizeSeverity(f?.severity)));
287
+ if (offending.length > 0) {
288
+ const severities = [...new Set(offending.map((f) => normalizeSeverity(f?.severity)))].join(", ");
289
+ throw new Error(
290
+ `clean verdict is invalid with ${offending.length} acted finding(s) at a blocking severity (${severities}): ` +
291
+ `a blocking-severity finding acted on this round cannot be clean. The ledger's severity verdict may be clean with ` +
292
+ `non-blocking act findings; the posted review verdict is composed with the act list (ADR 0089).`,
293
+ );
275
294
  }
276
295
  return overallVerdict;
277
296
  }
@@ -177,10 +177,19 @@ export function angleReviewSurface(angle, { alwaysRerun } = {}) {
177
177
  * @param {AngleReviewSurface} [input.angleSurface] — the angle's declared surface;
178
178
  * derived from {@link angleReviewSurface} when omitted.
179
179
  * @param {string[]} input.changedFiles — repo-relative paths changed between head
180
- * A and head B (the delta, NOT the full PR diff against base).
180
+ * A and head B (the delta, NOT the full PR diff against base). For a base-move
181
+ * re-gate the caller passes the MAIN-RELATIVE incremental delta: files changed
182
+ * since head A whose head-B content is genuinely PR-own (differs from
183
+ * origin/main), NOT the raw two-dot A..B delta — so an integrate-only base-move
184
+ * that only replays already-merged main commits contributes an empty delta.
181
185
  * @param {string} input.prevVerdict — the angle's verdict at head A. "clean" and
182
186
  * "findings_present" are carry-forward-eligible; anything else (e.g.
183
187
  * "blocked", missing) is not.
188
+ * @param {boolean} [input.deltaComplete=false] — the caller PROVES `changedFiles`
189
+ * is the complete, successfully-computed delta (git succeeded and, for a
190
+ * base-move, the main-relative reduction ran). Only then does an EMPTY delta
191
+ * mean "nothing PR-own changed" and carry forward; without the proof an empty
192
+ * delta is indistinguishable from an unavailable one and still fails closed.
184
193
  * @returns {{ carryForward: boolean, reason: string }}
185
194
  */
186
195
 
@@ -207,7 +216,7 @@ export function isDevLoopConfigSourcePath(filePath) {
207
216
  return DEV_LOOP_CONFIG_SOURCE_RE.test(filePath.trim().replace(/\\/g, "/"));
208
217
  }
209
218
 
210
- export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, prevVerdict }) {
219
+ export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, prevVerdict, deltaComplete = false }) {
211
220
  if (!CARRY_FORWARD_ELIGIBLE_VERDICTS.has(prevVerdict)) {
212
221
  return {
213
222
  carryForward: false,
@@ -221,7 +230,16 @@ export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, pr
221
230
  if (surface.kind === "unknown") {
222
231
  return { carryForward: false, reason: "angle has no declared review surface (fail-closed)" };
223
232
  }
224
- if (!Array.isArray(changedFiles) || changedFiles.length === 0) {
233
+ if (!Array.isArray(changedFiles)) {
234
+ return { carryForward: false, reason: "delta is unavailable (fail-closed)" };
235
+ }
236
+ // A PROVEN-complete empty delta (deltaComplete) means the main-relative
237
+ // reduction found NO PR-own change since the prior reviewed head — an
238
+ // integrate-only base-move that only replays already-merged main commits.
239
+ // That carries forward: the zero-file loop below proves the surface untouched.
240
+ // Without that proof an empty delta is indistinguishable from an unavailable
241
+ // one, so it still fails closed.
242
+ if (changedFiles.length === 0 && !deltaComplete) {
225
243
  return { carryForward: false, reason: "delta is empty or unavailable (fail-closed)" };
226
244
  }
227
245
  for (const file of changedFiles) {
@@ -287,12 +305,22 @@ const COPILOT_REVIEW_SURFACE_KINDS = new Set(["code", "test", "config", "ci"]);
287
305
  * re-runs, since classifyFile is path-based). Any code/test/config/CI file, an unclassifiable
288
306
  * file, or an empty/unavailable delta -> re-run (fresh blocking round required).
289
307
  *
308
+ * `deltaComplete` mirrors {@link resolveAngleCarryForward}: when the caller PROVES
309
+ * `changedFiles` is the complete main-relative reduction, an EMPTY delta means an
310
+ * integrate-only base-move touched no Copilot surface and the convergence carries
311
+ * forward. Without the proof an empty delta still fails closed.
312
+ *
290
313
  * @param {object} input
291
314
  * @param {string[]} input.changedFiles — delta since the converged head
315
+ * @param {boolean} [input.deltaComplete=false] — proof the empty case is a real
316
+ * "nothing PR-own changed", not an unavailable delta
292
317
  * @returns {{ carryForward: boolean, reason: string }}
293
318
  */
294
- export function resolveConvergenceCarryForward({ changedFiles }) {
295
- if (!Array.isArray(changedFiles) || changedFiles.length === 0) {
319
+ export function resolveConvergenceCarryForward({ changedFiles, deltaComplete = false }) {
320
+ if (!Array.isArray(changedFiles)) {
321
+ return { carryForward: false, reason: "delta is unavailable (fail-closed)" };
322
+ }
323
+ if (changedFiles.length === 0 && !deltaComplete) {
296
324
  return { carryForward: false, reason: "delta is empty or unavailable (fail-closed)" };
297
325
  }
298
326
  for (const file of changedFiles) {
@@ -304,5 +332,10 @@ export function resolveConvergenceCarryForward({ changedFiles }) {
304
332
  return { carryForward: false, reason: `delta touches Copilot's review surface (${kind}): ${file}` };
305
333
  }
306
334
  }
307
- return { carryForward: true, reason: "delta is a pure doc/prose bump, provably outside Copilot's review surface" };
335
+ return {
336
+ carryForward: true,
337
+ reason: changedFiles.length === 0
338
+ ? "no PR-own change since the converged head (integrate-only base-move), provably outside Copilot's review surface"
339
+ : "delta is a pure doc/prose bump, provably outside Copilot's review surface",
340
+ };
308
341
  }
@@ -828,6 +828,7 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
828
828
  disposition: deriveDisposition(severity, { isBlocking, locatable: hasLocatableShape(f) }),
829
829
  };
830
830
  if (typeof f.file === "string" && f.file.trim().length > 0) entry.file = f.file.trim();
831
+ if (Array.isArray(f.files)) entry.files = f.files.filter((file) => typeof file === "string" && file.trim().length > 0).map((file) => file.trim());
831
832
  if (typeof f.line === "number" && Number.isFinite(f.line)) entry.line = f.line;
832
833
  if (typeof f.recommendation === "string" && f.recommendation.trim().length > 0) {
833
834
  entry.recommendation = f.recommendation.trim();
@@ -871,6 +872,38 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
871
872
  */
872
873
  export const JUDGE_DISPOSITIONS = Object.freeze(["act", "defer", "reject"]);
873
874
 
875
+ /**
876
+ * The `scopeDrift.verdict` vocabulary — the single shared source. The validator
877
+ * below consumes it directly, and a divergence-guard test asserts the judge
878
+ * persona surface (`agents/judge.agent.md`) documents exactly this set, so the
879
+ * producer and enforcer cannot silently drift apart.
880
+ */
881
+ export const SCOPE_DRIFT_VERDICTS = Object.freeze(["within_scope", "drift_detected"]);
882
+
883
+ /**
884
+ * Aliases a persona-following judge reaches for, normalized to the canonical
885
+ * vocabulary before validation. `none` is the intuitive no-drift spelling; it
886
+ * maps to `within_scope` so a semantically-correct verdict passes with no
887
+ * resend or hand-edit, disposition and rationale byte-intact.
888
+ */
889
+ export const SCOPE_DRIFT_VERDICT_ALIASES = Object.freeze({ none: "within_scope" });
890
+
891
+ /**
892
+ * Normalize a `scopeDrift.verdict` alias to its canonical value. Canonical and
893
+ * unknown values pass through untouched; validation rejects unknowns.
894
+ * @param {unknown} verdict
895
+ * @returns {unknown}
896
+ */
897
+ export function normalizeScopeDriftVerdict(verdict) {
898
+ // Only a primitive string spelling normalizes. `hasOwnProperty.call` coerces
899
+ // its key argument, so without this guard a boxed `new String("none")` or an
900
+ // object whose `toString()` returns `"none"` would alias to `within_scope` —
901
+ // a fail-open contradicting the validator's non-string-fails-closed contract.
902
+ return typeof verdict === "string" && Object.prototype.hasOwnProperty.call(SCOPE_DRIFT_VERDICT_ALIASES, verdict)
903
+ ? SCOPE_DRIFT_VERDICT_ALIASES[verdict]
904
+ : verdict;
905
+ }
906
+
874
907
  /**
875
908
  * Validate a judge verdict artifact shape (the `judge` agent's only write).
876
909
  * Pure; throws on a malformed verdict rather than enriching findings with
@@ -901,8 +934,9 @@ export function validateJudgeVerdict(verdict) {
901
934
  throw new Error("judge verdict.scopeDrift must be an object");
902
935
  }
903
936
  const sd = /** @type {Record<string, unknown>} */ (v.scopeDrift);
904
- if (sd.verdict !== "within_scope" && sd.verdict !== "drift_detected") {
905
- throw new Error("judge verdict.scopeDrift.verdict must be 'within_scope' or 'drift_detected'");
937
+ const normalizedVerdict = normalizeScopeDriftVerdict(sd.verdict);
938
+ if (!SCOPE_DRIFT_VERDICTS.includes(/** @type {string} */ (normalizedVerdict))) {
939
+ throw new Error(`judge verdict.scopeDrift.verdict must be one of: ${SCOPE_DRIFT_VERDICTS.join(", ")}`);
906
940
  }
907
941
  if (typeof sd.rationale !== "string" || sd.rationale.trim().length === 0) {
908
942
  throw new Error("judge verdict.scopeDrift.rationale must be a non-empty string");
@@ -949,7 +983,7 @@ export function validateJudgeVerdict(verdict) {
949
983
  }
950
984
  }
951
985
  }
952
- return { headSha: v.headSha, scopeDrift: v.scopeDrift, dispositions: v.dispositions };
986
+ return { headSha: v.headSha, scopeDrift: { ...sd, verdict: normalizedVerdict }, dispositions: v.dispositions };
953
987
  }
954
988
 
955
989
  /**
@@ -1012,6 +1046,27 @@ export function applyJudgeDispositions(findings, judgeVerdict) {
1012
1046
  return { findings: enriched, scopeDrift: validated.scopeDrift };
1013
1047
  }
1014
1048
 
1049
+ /**
1050
+ * The judge's act list: the findings this PR must still fix. Pure.
1051
+ * @param {unknown} findings
1052
+ * @returns {Array<object>}
1053
+ */
1054
+ export function listOpenActItems(findings) {
1055
+ return (Array.isArray(findings) ? findings : []).filter((f) => f && f.judgeDisposition === "act");
1056
+ }
1057
+
1058
+ /**
1059
+ * Compose a round's review verdict with the judge act list (ADR 0089): a
1060
+ * non-empty act list keeps a `clean` severity verdict from `clean`. Never
1061
+ * lowers a verdict; the blockCleanOnFindingSeverities floor already applied.
1062
+ * @param {"clean"|"findings_present"|"blocked"} overallVerdict
1063
+ * @param {unknown} findings
1064
+ * @returns {"clean"|"findings_present"|"blocked"}
1065
+ */
1066
+ export function composeReviewVerdict(overallVerdict, findings) {
1067
+ return overallVerdict === "clean" && listOpenActItems(findings).length > 0 ? "findings_present" : overallVerdict;
1068
+ }
1069
+
1015
1070
  /**
1016
1071
  * Map consolidated findings into the `--findings` JSON shape consumed by
1017
1072
  * scripts/github/write-gate-findings-log.mjs (severity, angle, summary,
@@ -949,14 +949,95 @@ function sectionHasBody(section) {
949
949
  return false;
950
950
  }
951
951
 
952
+ // A top-level list marker line (any GFM/CommonMark family: `-`/`*`/`+`,
953
+ // ordered `N.`/`N)`, optional leading blockquote `>` runs) with NO checkbox.
954
+ // `validatePrBodySpec`-local: unlike `parseChecklistItems` (whose
955
+ // `checked: null` state fires ONLY for the bare-dash form — see its
956
+ // docstring), this recognizes every marker family, so a mixed AC/DoD section
957
+ // (`- [ ] works` next to `* plain`) cannot escape detection just because the
958
+ // plain line used a non-dash marker. No leading-space match before the
959
+ // marker/blockquote keeps this top-level only: an indented continuation
960
+ // (` - detail`) is sub-content and is not matched. The negative lookahead
961
+ // excludes a real checkbox line so `- [ ] works` is never double-counted; the
962
+ // lookahead treats end-of-line as a checkbox terminator too (`(?:\s|$)`), so
963
+ // an empty checkbox placeholder (`- [ ]`/`- [x]`, no trailing text) is
964
+ // excluded rather than mis-caught as a plain bullet — `parseChecklistItems`
965
+ // already documents empty placeholders as skipped, and this scan must not
966
+ // contradict that.
967
+ const TOP_LEVEL_NON_CHECKBOX_BULLET_PATTERN = /^(?:>\s*)*(?:[-*+]|\d+[.)])\s+(?!\[[ xX]\](?:\s|$))(.+?)\s*$/u;
968
+
969
+ // A spaced Markdown thematic break (`* * *`, `- - -`) also matches
970
+ // `TOP_LEVEL_NON_CHECKBOX_BULLET_PATTERN` (marker, whitespace, more marker
971
+ // text) but is a divider, not a bullet — `parseChecklistItems` never treats
972
+ // it as an item, so counting it here would reject a legitimate AC/DoD
973
+ // section over a divider line. `***`/`---` (no spaces) already fail the
974
+ // bullet pattern's `\s+` requirement, so only the spaced form needs this
975
+ // guard. Same-marker-only via the backreference, and only `-`/`*`/`_` per
976
+ // the CommonMark thematic-break rule (3+ of the SAME char, optional spaces,
977
+ // nothing else) — `+` is NOT a valid thematic-break marker, so `+ + +`
978
+ // stays correctly counted as a real (single-item) plain bullet.
979
+ const THEMATIC_BREAK_RE = /^([-*_])(?:[ \t]*\1){2,}[ \t]*$/u;
980
+
981
+ /**
982
+ * Scan a flattened section body for top-level non-checkbox bullet lines
983
+ * (`TOP_LEVEL_NON_CHECKBOX_BULLET_PATTERN`). Fence-skipped via the shared
984
+ * `stepFence` so a fenced fake bullet cannot spoof this check. Thematic-break
985
+ * divider lines (`THEMATIC_BREAK_RE`) are excluded — see its docstring.
986
+ */
987
+ function scanTopLevelNonCheckboxBulletLines(text) {
988
+ if (typeof text !== "string" || text.length === 0) return [];
989
+ const found = [];
990
+ let fence = null;
991
+ for (const line of text.split(/\r?\n/u)) {
992
+ const step = stepFence(fence, line);
993
+ fence = step.fence;
994
+ if (step.insideFence) continue;
995
+ const match = TOP_LEVEL_NON_CHECKBOX_BULLET_PATTERN.exec(line);
996
+ if (!match || match[1].trim().length === 0) continue;
997
+ const strippedLine = line.replace(/^(?:>\s*)*/u, "").trim();
998
+ if (THEMATIC_BREAK_RE.test(strippedLine)) continue;
999
+ found.push(match[1].trim());
1000
+ }
1001
+ return found;
1002
+ }
1003
+
1004
+ /**
1005
+ * Scan every section the completeness block would read (ALL sections matching
1006
+ * `patterns`, deep-flattened past `###` sub-headings via
1007
+ * `findAllSectionsByPatterns` + `flattenSectionDeep` — the same union
1008
+ * `extractPrBodyUncheckedChecklistItems` reads) for top-level plain bullets
1009
+ * of ANY marker (no checkbox). Shares the section-set read with the
1010
+ * completeness block on purpose: a plain bullet invisible to
1011
+ * `validatePrBodySpec`'s own single-section read but visible to the
1012
+ * completeness block (a duplicate AC/DoD heading, or a bullet nested under a
1013
+ * `###` sub-heading) must still reject here, or the two surfaces diverge on
1014
+ * the same checkbox-marker fail-open class this module closes for the simple
1015
+ * single-section case. Local `scanTopLevelNonCheckboxBulletLines` scan, not
1016
+ * `parseChecklistItems`'s `checked: null` state, so a non-dash plain bullet
1017
+ * (`*`/`+`/ordered) is caught too — `parseChecklistItems` and the shared
1018
+ * deterministic pre-approval completeness-block logic it backs (see
1019
+ * acceptance-criteria-verification.md) stay untouched.
1020
+ */
1021
+ function scanPlainBullets(sections, patterns) {
1022
+ const matched = findAllSectionsByPatterns(sections, patterns);
1023
+ const items = [];
1024
+ for (let i = 0; i < sections.length; i += 1) {
1025
+ if (!matched.includes(sections[i])) continue;
1026
+ items.push(...scanTopLevelNonCheckboxBulletLines(flattenSectionDeep(sections, i)));
1027
+ }
1028
+ return items;
1029
+ }
1030
+
952
1031
  /**
953
1032
  * Validate that a PR body carries every invariant required to serve as the
954
1033
  * lightweight spec-of-record: Objective/why, in-scope, explicit non-goals,
955
- * testable Acceptance criteria (>=1 checklist item), Definition of done
956
- * (>=1 checklist item), Open questions/risks, and — unless issue-less mode is
1034
+ * testable Acceptance criteria (>=1 checkbox item), Definition of done
1035
+ * (>=1 checkbox item), Open questions/risks, and — unless issue-less mode is
957
1036
  * requested — a GitHub closing-keyword issue reference. Reuses the generic
958
1037
  * markdown logic so there is no parallel validator. Fails closed: every missing
959
- * invariant is reported under its distinct `missing_*` code. Pure; no I/O.
1038
+ * invariant is reported under its distinct `missing_*` code (plus the two
1039
+ * `*_not_checkboxes` codes for a plain-bullet AC/DoD section — see
1040
+ * `scanPlainBullets`). Pure; no I/O.
960
1041
  *
961
1042
  * Issue-less mode (`issueLess: true`): the closing-issue linkage flips from
962
1043
  * REQUIRED to FORBIDDEN (the PR is the sole artifact), failing closed under
@@ -1044,21 +1125,48 @@ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess
1044
1125
  }
1045
1126
  }
1046
1127
 
1128
+ // Checkbox markers are REQUIRED (not just any bullet): the completeness
1129
+ // block (extractPrBodyUncheckedChecklistItems) only ever sees checkbox
1130
+ // items, so a plain bullet here would fail-open the completeness check
1131
+ // even though this validator accepted it. The plain-bullet scan reads the
1132
+ // SAME section set the completeness block reads (scanPlainBullets), not
1133
+ // just the first section, so a duplicate heading or a bullet nested under a
1134
+ // `###` sub-heading cannot hide from this check either. The scan runs
1135
+ // INDEPENDENTLY of parseChecklistItems (not gated on it finding items) and
1136
+ // is checked FIRST: an AC/DoD section made entirely of non-dash plain
1137
+ // bullets (`* plain`, `1. plain`) yields zero parsed checkbox items, so
1138
+ // gating the scan on "items found" would misreport that case as
1139
+ // missing_acceptance_criteria/missing_definition_of_done instead of the
1140
+ // distinct *_not_checkboxes code.
1047
1141
  const acSection = findSectionByPatterns(sections, ACCEPTANCE_SECTION_PATTERNS);
1048
- const acItems = acSection ? extractChecklistItems(acSection.bodyLines.join("\n")) : [];
1049
- if (acItems.length === 0) {
1142
+ const acParsed = acSection ? parseChecklistItems(acSection.bodyLines.join("\n")) : [];
1143
+ const acItems = acParsed.filter((item) => item.checked !== null).map((item) => item.text);
1144
+ const acPlainBullets = scanPlainBullets(sections, ACCEPTANCE_SECTION_PATTERNS);
1145
+ if (acPlainBullets.length > 0) {
1146
+ errors.push({
1147
+ code: "acceptance_criteria_not_checkboxes",
1148
+ message: `Acceptance criteria must use checkbox markers ('- [ ]'/'- [x]'), not plain bullets (found ${acPlainBullets.length} plain bullet(s)); plain bullets fail-open the completeness block.`,
1149
+ });
1150
+ } else if (acParsed.length === 0) {
1050
1151
  errors.push({
1051
1152
  code: "missing_acceptance_criteria",
1052
- message: "Missing testable Acceptance criteria (no checklist items found).",
1153
+ message: "Missing testable Acceptance criteria (no checkbox items found).",
1053
1154
  });
1054
1155
  }
1055
1156
 
1056
1157
  const dodSection = findSectionByPatterns(sections, DOD_SECTION_PATTERNS);
1057
- const dodItems = dodSection ? extractChecklistItems(dodSection.bodyLines.join("\n")) : [];
1058
- if (dodItems.length === 0) {
1158
+ const dodParsed = dodSection ? parseChecklistItems(dodSection.bodyLines.join("\n")) : [];
1159
+ const dodItems = dodParsed.filter((item) => item.checked !== null).map((item) => item.text);
1160
+ const dodPlainBullets = scanPlainBullets(sections, DOD_SECTION_PATTERNS);
1161
+ if (dodPlainBullets.length > 0) {
1162
+ errors.push({
1163
+ code: "definition_of_done_not_checkboxes",
1164
+ message: `Definition of done must use checkbox markers ('- [ ]'/'- [x]'), not plain bullets (found ${dodPlainBullets.length} plain bullet(s)); plain bullets fail-open the completeness block.`,
1165
+ });
1166
+ } else if (dodParsed.length === 0) {
1059
1167
  errors.push({
1060
1168
  code: "missing_definition_of_done",
1061
- message: "Missing Definition of done (no checklist items found).",
1169
+ message: "Missing Definition of done (no checkbox items found).",
1062
1170
  });
1063
1171
  }
1064
1172