@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.
- package/package.json +1 -1
- package/src/analysis/change-classifier.mjs +49 -28
- package/src/analysis/diff-analyzer.mjs +127 -31
- package/src/claude/asset-generation.mjs +36 -1
- package/src/claude/hook-decisions.mjs +117 -18
- package/src/config/config.mjs +198 -86
- package/src/config/extension-defaults.yaml +66 -6
- package/src/github/copilot-helpers.mjs +85 -20
- package/src/github/gh.mjs +14 -1
- package/src/github/review-threads.mjs +6 -0
- package/src/loop/bash-command-classify.mjs +251 -14
- package/src/loop/copilot-ci-status.mjs +116 -6
- package/src/loop/copilot-loop-state.mjs +38 -2
- package/src/loop/finding-cluster.mjs +30 -11
- package/src/loop/gate-carry-forward.mjs +39 -6
- package/src/loop/gate-fanin.mjs +58 -3
- package/src/loop/issue-refinement-artifact.mjs +117 -9
- package/src/loop/main-checkout-ff.mjs +169 -17
- package/src/loop/merge-approval.mjs +186 -5
- package/src/loop/pr-gate-coordination.mjs +339 -67
- package/src/loop/run-inspection.mjs +6 -0
- package/src/loop/spec-authority.mjs +40 -6
- package/src/loop/ui-e2e-scoping.mjs +1 -0
- package/src/projects/move-queue-item.mjs +5 -79
- package/src/projects/projects-access.mjs +124 -0
|
@@ -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
|
|
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"
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
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 {
|
|
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
|
|
270
|
-
if (!
|
|
271
|
-
throw new TypeError("
|
|
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
|
|
274
|
-
|
|
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)
|
|
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)
|
|
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 {
|
|
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
|
}
|
package/src/loop/gate-fanin.mjs
CHANGED
|
@@ -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
|
-
|
|
905
|
-
|
|
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:
|
|
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
|
|
956
|
-
* (>=1
|
|
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
|
|
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
|
|
1049
|
-
|
|
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
|
|
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
|
|
1058
|
-
|
|
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
|
|
1169
|
+
message: "Missing Definition of done (no checkbox items found).",
|
|
1062
1170
|
});
|
|
1063
1171
|
}
|
|
1064
1172
|
|