dev-loops 0.7.1 → 0.8.0
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/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +7 -7
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/refiner.md +4 -4
- package/.claude/agents/review.md +10 -13
- package/.claude/commands/loop-continue.md +2 -1
- package/.claude/commands/loop-enqueue.md +9 -2
- package/.claude/commands/loop-info.md +1 -1
- package/.claude/commands/loop-start-spike.md +1 -1
- package/.claude/hooks/_bash-command-classify.mjs +7 -6
- package/.claude/hooks/_hook-decisions.mjs +5 -4
- package/.claude/skills/copilot-pr-followup/SKILL.md +42 -28
- package/.claude/skills/dev-loop/SKILL.md +8 -8
- package/.claude/skills/docs/acceptance-criteria-verification.md +13 -4
- package/.claude/skills/docs/anti-patterns.md +6 -5
- package/.claude/skills/docs/artifact-authority-contract.md +17 -14
- package/.claude/skills/docs/confirmation-rules.md +2 -1
- package/.claude/skills/docs/contract-style-guide.md +37 -0
- package/.claude/skills/docs/copilot-loop-operations.md +21 -15
- package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
- package/.claude/skills/docs/debt-remediation-contract.md +1 -1
- package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/.claude/skills/docs/issue-intake-procedure.md +37 -22
- package/.claude/skills/docs/merge-preconditions.md +17 -13
- package/.claude/skills/docs/pr-lifecycle-contract.md +45 -36
- package/.claude/skills/docs/public-dev-loop-contract.md +61 -44
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +25 -9
- package/.claude/skills/docs/spike-mode-contract.md +14 -7
- package/.claude/skills/docs/stop-conditions.md +30 -15
- package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
- package/.claude/skills/docs/validation-policy.md +4 -5
- package/.claude/skills/local-implementation/SKILL.md +49 -143
- package/.claude/skills/loop-grill/SKILL.md +34 -14
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +36 -0
- package/README.md +95 -189
- package/agents/dev-loop.agent.md +8 -8
- package/agents/fixer.agent.md +1 -0
- package/agents/refiner.agent.md +4 -4
- package/agents/review.agent.md +10 -13
- package/extension/README.md +5 -4
- package/package.json +7 -5
- package/scripts/_core-helpers.mjs +2 -0
- package/scripts/claude/headless-dev-loop.mjs +1 -1
- package/scripts/docs/validate-rule-ownership.mjs +442 -0
- package/scripts/docs/validate-state-machine-conformance.mjs +1169 -0
- package/scripts/github/_gate-names.mjs +5 -0
- package/scripts/github/_review-thread-mutations.mjs +5 -2
- package/scripts/github/capture-review-threads.mjs +2 -2
- package/scripts/github/create-pr.mjs +119 -10
- package/scripts/github/detect-checkpoint-evidence.mjs +109 -24
- package/scripts/github/edit-issue.mjs +259 -0
- package/scripts/github/list-review-threads.mjs +277 -0
- package/scripts/github/post-gate-findings.mjs +6 -3
- package/scripts/github/probe-ci-status.mjs +18 -0
- package/scripts/github/probe-copilot-review.mjs +24 -3
- package/scripts/github/reconcile-draft-gate.mjs +13 -13
- package/scripts/github/request-copilot-review.mjs +109 -42
- package/scripts/github/upsert-checkpoint-verdict.mjs +98 -27
- package/scripts/github/verify-briefing-prefixes.mjs +382 -0
- package/scripts/github/verify-fresh-review-context.mjs +78 -3
- package/scripts/github/wait-pr-checks.mjs +171 -0
- package/scripts/github/write-gate-context.mjs +220 -10
- package/scripts/github/write-gate-findings-log.mjs +54 -2
- package/scripts/loop/_post-convergence-change.mjs +2 -2
- package/scripts/loop/_pr-runner-coordination.mjs +112 -13
- package/scripts/loop/check-retro-tooling.mjs +14 -9
- package/scripts/loop/copilot-pr-handoff.mjs +47 -16
- package/scripts/loop/detect-change-scope.mjs +2 -2
- package/scripts/loop/detect-copilot-loop-state.mjs +34 -14
- package/scripts/loop/detect-internal-only-pr.mjs +6 -6
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +152 -18
- package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
- package/scripts/loop/run-watch-cycle.mjs +42 -7
- package/scripts/loop/sanctioned-commands.mjs +3 -0
- package/scripts/loop/validate-pr-body-spec.mjs +21 -5
- package/scripts/pages/build-state-atlas.mjs +65 -48
- package/scripts/projects/_resolve-project.mjs +1 -148
- package/scripts/projects/add-queue-item.mjs +87 -4
- package/scripts/projects/list-queue-items.mjs +3 -377
- package/scripts/projects/move-queue-item.mjs +3 -410
- package/scripts/projects/reorder-queue-item.mjs +3 -22
- package/skills/copilot-pr-followup/SKILL.md +42 -28
- package/skills/dev-loop/SKILL.md +3 -3
- package/skills/docs/acceptance-criteria-verification.md +13 -4
- package/skills/docs/anti-patterns.md +6 -5
- package/skills/docs/artifact-authority-contract.md +17 -14
- package/skills/docs/confirmation-rules.md +2 -1
- package/skills/docs/contract-style-guide.md +37 -0
- package/skills/docs/copilot-loop-operations.md +21 -15
- package/skills/docs/cross-harness-regression-contract.md +2 -2
- package/skills/docs/debt-remediation-contract.md +1 -1
- package/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/skills/docs/issue-intake-procedure.md +37 -22
- package/skills/docs/merge-preconditions.md +17 -13
- package/skills/docs/pr-lifecycle-contract.md +45 -36
- package/skills/docs/public-dev-loop-contract.md +61 -44
- package/skills/docs/required-rules.json +165 -0
- package/skills/docs/retrospective-checkpoint-contract.md +25 -9
- package/skills/docs/spike-mode-contract.md +14 -7
- package/skills/docs/stop-conditions.md +30 -15
- package/skills/docs/tracker-first-loop-state.md +13 -8
- package/skills/docs/validation-policy.md +4 -5
- package/skills/local-implementation/SKILL.md +49 -143
- package/skills/loop-grill/SKILL.md +38 -17
- package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
-
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
4
|
-
import { loadDevLoopConfig, resolveGateConfig, resolveRefinementConfig } from "@dev-loops/core/config";
|
|
3
|
+
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText, sanitizeCopilotSummonTokens } from "../_core-helpers.mjs";
|
|
4
|
+
import { loadDevLoopConfig, resolveEffectiveCopilotRoundCap, resolveGateAngleContract, resolveGateConfig, resolveRefinementConfig, resolveRejectForeignAngles } from "@dev-loops/core/config";
|
|
5
|
+
import { checkFanoutAngleCoverage } from "@dev-loops/core/loop/gate-fanin";
|
|
5
6
|
import { parseArgs } from "node:util";
|
|
6
7
|
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
7
|
-
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
8
|
+
import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
|
|
8
9
|
import { truncateText } from "@dev-loops/core/bash-exit-one";
|
|
9
10
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
10
11
|
import { loadPrGateCoordinationContext } from "../loop/detect-pr-gate-coordination-state.mjs";
|
|
@@ -72,6 +73,11 @@ Optional:
|
|
|
72
73
|
--gate <draft_gate|pre_approval_gate> Auto-resolved from coordination state
|
|
73
74
|
when omitted. Explicit gate is validated
|
|
74
75
|
against allowed coordination actions.
|
|
76
|
+
--lightweight This PR is light-dispatched (#1210):
|
|
77
|
+
resolve the Copilot round cap as
|
|
78
|
+
min(lightMode.maxCopilotRounds ?? 1,
|
|
79
|
+
refinement.maxCopilotRounds) instead of
|
|
80
|
+
refinement.maxCopilotRounds alone.
|
|
75
81
|
--findings-severity-counts <json> JSON object mapping severity to count
|
|
76
82
|
(e.g. '{"must-fix":0,"worth-fixing-now":0}').
|
|
77
83
|
Required for --verdict clean when
|
|
@@ -261,6 +267,7 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
|
|
|
261
267
|
"findings-severity-counts": { type: "string" },
|
|
262
268
|
"execution-mode": { type: "string" },
|
|
263
269
|
"inline-reason": { type: "string" },
|
|
270
|
+
lightweight: { type: "boolean" },
|
|
264
271
|
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
265
272
|
},
|
|
266
273
|
allowPositionals: true,
|
|
@@ -281,6 +288,7 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
|
|
|
281
288
|
findingsSeverityCounts: undefined,
|
|
282
289
|
executionMode: undefined,
|
|
283
290
|
inlineReason: undefined,
|
|
291
|
+
lightweight: false,
|
|
284
292
|
jq: undefined,
|
|
285
293
|
silent: false,
|
|
286
294
|
};
|
|
@@ -306,6 +314,10 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
|
|
|
306
314
|
options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
|
|
307
315
|
continue;
|
|
308
316
|
}
|
|
317
|
+
if (token.name === "lightweight") {
|
|
318
|
+
options.lightweight = true;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
309
321
|
if (token.name === "gate") {
|
|
310
322
|
const gate = normalizeGateName(requireTokenValue(token, parseError));
|
|
311
323
|
if (!gate) {
|
|
@@ -742,7 +754,11 @@ export function renderGateReviewCommentBody({ gate, headSha, verdict, findingsSu
|
|
|
742
754
|
"",
|
|
743
755
|
`**Next action:** ${nextAction}`,
|
|
744
756
|
);
|
|
745
|
-
|
|
757
|
+
// Neutralize any bare @copilot/`/copilot`* tokens in the rendered body (gate
|
|
758
|
+
// evidence legitimately quotes the anti-summon rule, e.g. from a findings
|
|
759
|
+
// excerpt) so posting this comment can never arm request-copilot-review.mjs's
|
|
760
|
+
// anti-summon guard on a later request.
|
|
761
|
+
return sanitizeCopilotSummonTokens(lines.join("\n"));
|
|
746
762
|
}
|
|
747
763
|
function resolveRequestedHeadSha(requestedHeadSha, currentHeadSha) {
|
|
748
764
|
if (requestedHeadSha === currentHeadSha) {
|
|
@@ -823,7 +839,7 @@ function detectStaleGateCommentWarning({ strict, headSha, gate }) {
|
|
|
823
839
|
}
|
|
824
840
|
return `A gate comment for \`${gate}\` already exists on a different head SHA \`${strict.headSha}\` (comment ${strict.commentId}). The old comment is stale for the current head.`;
|
|
825
841
|
}
|
|
826
|
-
async function runGhJson(args, { env, ghCommand }) {
|
|
842
|
+
async function runGhJson(args, { env, ghCommand, runChild = defaultRunChild }) {
|
|
827
843
|
const result = await runChild(ghCommand, args, env);
|
|
828
844
|
if (result.code !== 0) {
|
|
829
845
|
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
@@ -841,18 +857,18 @@ function parseCommentMutationResponse(payload) {
|
|
|
841
857
|
}
|
|
842
858
|
return { commentId, commentUrl };
|
|
843
859
|
}
|
|
844
|
-
async function createComment({ repo, pr, body }, { env, ghCommand }) {
|
|
845
|
-
const payload = await runGhJson(["api", "repos/" + repo + "/issues/" + pr + "/comments", "-f", `body=${body}`], { env, ghCommand });
|
|
860
|
+
async function createComment({ repo, pr, body }, { env, ghCommand, runChild = defaultRunChild }) {
|
|
861
|
+
const payload = await runGhJson(["api", "repos/" + repo + "/issues/" + pr + "/comments", "-f", `body=${body}`], { env, ghCommand, runChild });
|
|
846
862
|
return parseCommentMutationResponse(payload);
|
|
847
863
|
}
|
|
848
|
-
async function updateComment({ repo, commentId, body }, { env, ghCommand }) {
|
|
849
|
-
const payload = await runGhJson(["api", "-X", "PATCH", `repos/${repo}/issues/comments/${commentId}`, "-f", `body=${body}`], { env, ghCommand });
|
|
864
|
+
async function updateComment({ repo, commentId, body }, { env, ghCommand, runChild = defaultRunChild }) {
|
|
865
|
+
const payload = await runGhJson(["api", "-X", "PATCH", `repos/${repo}/issues/comments/${commentId}`, "-f", `body=${body}`], { env, ghCommand, runChild });
|
|
850
866
|
return parseCommentMutationResponse(payload);
|
|
851
867
|
}
|
|
852
868
|
|
|
853
|
-
async function verifyComment({ repo, commentId }, { env, ghCommand }) {
|
|
869
|
+
async function verifyComment({ repo, commentId }, { env, ghCommand, runChild = defaultRunChild }) {
|
|
854
870
|
try {
|
|
855
|
-
const payload = await runGhJson(["api", `repos/${repo}/issues/comments/${commentId}`], { env, ghCommand });
|
|
871
|
+
const payload = await runGhJson(["api", `repos/${repo}/issues/comments/${commentId}`], { env, ghCommand, runChild });
|
|
856
872
|
return payload?.id != null;
|
|
857
873
|
} catch {
|
|
858
874
|
return false;
|
|
@@ -879,13 +895,13 @@ async function verifyComment({ repo, commentId }, { env, ghCommand }) {
|
|
|
879
895
|
// markPrReady mutations are individually idempotent, so concurrent cooperating
|
|
880
896
|
// runners cause at most a transient draft flicker (not a stuck draft) — only a hard
|
|
881
897
|
// crash mid-transition can leave the PR drafted until a subsequent run.
|
|
882
|
-
async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot }) {
|
|
898
|
+
async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot, runChild = defaultRunChild }) {
|
|
883
899
|
const { convertPrToDraft, markPrReady } = await import("./reconcile-draft-gate.mjs");
|
|
884
900
|
process.stderr.write(
|
|
885
901
|
`[draft_gate] ${options.repo}#${options.pr} is ready but needs clean draft_gate evidence; ` +
|
|
886
902
|
`temporarily converting to draft to post the verdict, then restoring ready.\n`,
|
|
887
903
|
);
|
|
888
|
-
const conversion = await convertPrToDraft({ repo: options.repo, pr: options.pr }, { env, ghCommand });
|
|
904
|
+
const conversion = await convertPrToDraft({ repo: options.repo, pr: options.pr }, { env, ghCommand, runChild });
|
|
889
905
|
let result;
|
|
890
906
|
try {
|
|
891
907
|
// The PR is now a draft, so RUN_DRAFT_GATE is the legal action. Re-enter with
|
|
@@ -896,12 +912,12 @@ async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRo
|
|
|
896
912
|
// with a clear error instead of recursing indefinitely (exit 13). (#1020)
|
|
897
913
|
result = await upsertCheckpointVerdict(
|
|
898
914
|
{ ...options, _draftTransitionInProgress: true },
|
|
899
|
-
{ env, ghCommand, repoRoot },
|
|
915
|
+
{ env, ghCommand, repoRoot, runChild },
|
|
900
916
|
);
|
|
901
917
|
} catch (error) {
|
|
902
918
|
if (conversion.alreadyDraft !== true) {
|
|
903
919
|
try {
|
|
904
|
-
await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand });
|
|
920
|
+
await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand, runChild });
|
|
905
921
|
process.stderr.write(`[draft_gate] restored ${options.repo}#${options.pr} to ready after a failed verdict post.\n`);
|
|
906
922
|
} catch (restoreError) {
|
|
907
923
|
// Best-effort restore; surface the original error but log the restore failure
|
|
@@ -916,7 +932,7 @@ async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRo
|
|
|
916
932
|
}
|
|
917
933
|
if (conversion.alreadyDraft !== true) {
|
|
918
934
|
try {
|
|
919
|
-
await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand });
|
|
935
|
+
await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand, runChild });
|
|
920
936
|
} catch (restoreError) {
|
|
921
937
|
// The verdict WAS posted successfully; only the ready-restore failed. Make that
|
|
922
938
|
// explicit so the caller does not re-post the gate (the comment already exists)
|
|
@@ -933,7 +949,8 @@ async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRo
|
|
|
933
949
|
return { ...result, draftTransition: true };
|
|
934
950
|
}
|
|
935
951
|
|
|
936
|
-
export async function upsertCheckpointVerdict(options, { env = process.env, ghCommand = "gh", repoRoot = process.cwd() } = {}) {
|
|
952
|
+
export async function upsertCheckpointVerdict(options, { env = process.env, ghCommand = "gh", repoRoot = process.cwd(), runChild = defaultRunChild } = {}) {
|
|
953
|
+
const gh = { env, ghCommand, repoRoot, runChild };
|
|
937
954
|
// Root cause 1: allow resurrected sessions to claim ownership when the previous
|
|
938
955
|
// run's coordination record is stale. Without this, a new run ID is rejected even
|
|
939
956
|
// though the old run is dead, forcing manual file deletion.
|
|
@@ -949,19 +966,24 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
949
966
|
// loadPrGateCoordinationContext call will surface the real error.
|
|
950
967
|
}
|
|
951
968
|
}
|
|
952
|
-
|
|
969
|
+
// Thread the light-dispatch signal (#1210) so the context interpreter and the
|
|
970
|
+
// maxCopilotRounds resolution below both use the composed lightweight cap —
|
|
971
|
+
// the two must never disagree at the cap boundary (#1126).
|
|
972
|
+
const coordinationContext = await loadPrGateCoordinationContext({ repo: options.repo, pr: options.pr, lightweight: options.lightweight === true }, gh);
|
|
953
973
|
const evidence = coordinationContext.gateEvidence;
|
|
954
974
|
const canonicalHeadSha = resolveRequestedHeadSha(options.headSha, evidence.currentHeadSha);
|
|
955
975
|
const { config } = await loadDevLoopConfig({ repoRoot });
|
|
956
976
|
const draftGateConfig = resolveGateConfig(config, "draft");
|
|
957
977
|
const preApprovalGateConfig = resolveGateConfig(config, "preApproval");
|
|
958
|
-
const maxCopilotRounds =
|
|
978
|
+
const maxCopilotRounds = options.lightweight === true
|
|
979
|
+
? resolveEffectiveCopilotRoundCap(config, { lightweight: true })
|
|
980
|
+
: resolveRefinementConfig(config, "maxCopilotRounds");
|
|
959
981
|
// Root cause 2: detect internal-only PRs so the Copilot convergence requirement
|
|
960
982
|
// is suppressed. Docs-only / tooling-only PRs should go straight to pre_approval_gate
|
|
961
983
|
// without requiring an external Copilot review cycle.
|
|
962
984
|
let reviewMode = null;
|
|
963
985
|
try {
|
|
964
|
-
const internalResult = await detectInternalOnly({ repo: options.repo, pr: options.pr },
|
|
986
|
+
const internalResult = await detectInternalOnly({ repo: options.repo, pr: options.pr }, gh);
|
|
965
987
|
if (internalResult?.ok && internalResult.internalOnly) {
|
|
966
988
|
reviewMode = "internal_only";
|
|
967
989
|
}
|
|
@@ -982,9 +1004,14 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
982
1004
|
copilotReviewRoundCount: coordinationContext.snapshot?.copilotReviewRoundCount ?? 0,
|
|
983
1005
|
maxCopilotRounds,
|
|
984
1006
|
sameHeadCleanConverged: coordinationContext.interpretation.sameHeadCleanConverged,
|
|
1007
|
+
// Independent gate-ENTRY re-check (#1190): fed alongside (not derived from)
|
|
1008
|
+
// sameHeadCleanConverged, so an outstanding request on the current head refuses
|
|
1009
|
+
// RUN_PRE_APPROVAL_GATE even if sameHeadCleanConverged were somehow stale/wrong.
|
|
1010
|
+
copilotReviewRequestStatus: coordinationContext.snapshot?.copilotReviewRequestStatus ?? "none",
|
|
985
1011
|
draftGateRequireCi: draftGateConfig.requireCi,
|
|
986
1012
|
draftGate: coordinationContext.gateEvidence.draftGate,
|
|
987
1013
|
draftGateMarker: coordinationContext.gateEvidence.draftGateMarker,
|
|
1014
|
+
refinementArtifact: coordinationContext.refinementArtifact,
|
|
988
1015
|
preApprovalGate: coordinationContext.gateEvidence.preApprovalGate,
|
|
989
1016
|
preApprovalGateMarker: coordinationContext.gateEvidence.preApprovalGateMarker,
|
|
990
1017
|
...(reviewMode ? { reviewMode } : {}),
|
|
@@ -1064,7 +1091,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1064
1091
|
&& !coordination.draftGateAlreadySatisfied
|
|
1065
1092
|
&& coordination.allowedNextActions.includes(PR_CHECKPOINT_ACTION.RECONCILE_DRAFT_GATE)
|
|
1066
1093
|
) {
|
|
1067
|
-
return await postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot });
|
|
1094
|
+
return await postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot, runChild });
|
|
1068
1095
|
}
|
|
1069
1096
|
// Fail closed on a lagged draft-state read: we are re-entering FROM
|
|
1070
1097
|
// postDraftGateViaDraftTransition (which just converted the PR to draft) yet the
|
|
@@ -1122,6 +1149,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1122
1149
|
// multi-line per-angle breakdown and the `**Findings summary:**` line carries a
|
|
1123
1150
|
// single-line digest (so the marker/parse contract still round-trips).
|
|
1124
1151
|
let structuredFindings = null;
|
|
1152
|
+
let rawFindingsInput = null;
|
|
1125
1153
|
if (options.findingsJson) {
|
|
1126
1154
|
let raw;
|
|
1127
1155
|
try {
|
|
@@ -1140,6 +1168,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1140
1168
|
const candidate = Array.isArray(parsed)
|
|
1141
1169
|
? parsed
|
|
1142
1170
|
: (Array.isArray(parsed?.angles) ? parsed.angles : (Array.isArray(parsed?.findings) ? parsed.findings : null));
|
|
1171
|
+
rawFindingsInput = candidate;
|
|
1143
1172
|
try {
|
|
1144
1173
|
structuredFindings = normalizeStructuredFindings(candidate);
|
|
1145
1174
|
} catch (err) {
|
|
@@ -1149,6 +1178,48 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1149
1178
|
throw new Error(`--findings-json "${options.findingsJson}" did not contain any renderable findings (expected a non-empty per-angle array of { angle, findings } entries, or a flat per-finding array of { severity, summary, angle? } entries)`);
|
|
1150
1179
|
}
|
|
1151
1180
|
}
|
|
1181
|
+
// Fan-out angle-coverage enforcement (fail closed): a fanout_fanin verdict's
|
|
1182
|
+
// structured per-angle results must cover every configured mandatory angle,
|
|
1183
|
+
// and (default) must not name an angle outside the gate's configured pool.
|
|
1184
|
+
// Only applies when structured per-angle results were actually supplied —
|
|
1185
|
+
// a free-text --findings-summary fanout_fanin verdict carries no per-angle
|
|
1186
|
+
// data to validate.
|
|
1187
|
+
if (structuredFindings && (options.executionMode ?? DEFAULT_EXECUTION_MODE) === "fanout_fanin") {
|
|
1188
|
+
// Angle-less entries would be bucketed under the synthetic `general` label
|
|
1189
|
+
// by normalization and then surface as a CONFUSING foreign-angle error.
|
|
1190
|
+
// Fail first with a dedicated message naming the real problem instead.
|
|
1191
|
+
const angleless = (rawFindingsInput ?? []).filter(
|
|
1192
|
+
(e) => !e || typeof e !== "object" || typeof e.angle !== "string" || e.angle.trim().length === 0,
|
|
1193
|
+
).length;
|
|
1194
|
+
if (angleless > 0) {
|
|
1195
|
+
throw new Error(
|
|
1196
|
+
`--findings-json for ${options.gate}: ${angleless} entr${angleless === 1 ? "y" : "ies"} lack a non-empty .angle — a fanout_fanin verdict must attribute every per-angle entry/finding to its review angle (use the nested [{ angle, verdict, findings }] shape, or add .angle to each flat finding)`,
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
const gateKey = options.gate === "draft_gate" ? "draft" : "preApproval";
|
|
1200
|
+
const { mandatoryAngles, pool } = resolveGateAngleContract(config, gateKey);
|
|
1201
|
+
const { missingMandatory, foreignAngles } = checkFanoutAngleCoverage(structuredFindings, {
|
|
1202
|
+
mandatoryAngles,
|
|
1203
|
+
pool,
|
|
1204
|
+
});
|
|
1205
|
+
if (missingMandatory.length > 0) {
|
|
1206
|
+
throw new Error(
|
|
1207
|
+
`--findings-json for ${options.gate} is missing mandatory angle(s): ${missingMandatory.join(", ")} (configured in gates.${gateKey}.mandatoryAngles; add a per-angle entry for each before posting a fanout_fanin verdict)`,
|
|
1208
|
+
);
|
|
1209
|
+
}
|
|
1210
|
+
if (foreignAngles.length > 0) {
|
|
1211
|
+
const message = `--findings-json for ${options.gate} names angle(s) outside the configured pool: ${foreignAngles.join(", ")}`;
|
|
1212
|
+
if (resolveRejectForeignAngles(config)) {
|
|
1213
|
+
throw new Error(
|
|
1214
|
+
`${message} (add them to gates.${gateKey}.angles, or set gates.rejectForeignAngles: false to warn instead of fail)`,
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
// rejectForeignAngles: false is WARNING mode, not silence — one line per call.
|
|
1218
|
+
if (!options.silent) {
|
|
1219
|
+
process.stderr.write(`WARNING: ${message} (gates.rejectForeignAngles is false; recorded as a warning)\n`);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1152
1223
|
// --findings-json takes precedence; when structured findings are present, do not
|
|
1153
1224
|
// read --findings-file at all (avoids a spurious hard failure if a caller passes
|
|
1154
1225
|
// both and the file is missing/invalid even though it would be ignored anyway).
|
|
@@ -1227,15 +1298,15 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1227
1298
|
};
|
|
1228
1299
|
}
|
|
1229
1300
|
if (existing) {
|
|
1230
|
-
const updated = await updateComment({ repo: options.repo, commentId: existing.commentId, body: desiredBody },
|
|
1301
|
+
const updated = await updateComment({ repo: options.repo, commentId: existing.commentId, body: desiredBody }, gh);
|
|
1231
1302
|
// Post-update verification: verify the updated comment is visible via direct API fetch by comment ID.
|
|
1232
1303
|
// A run id is set (production context) — DEVLOOPS_RUN_ID.
|
|
1233
1304
|
let updateVerificationWarning = null;
|
|
1234
1305
|
if (envRunId) {
|
|
1235
|
-
let verified = await verifyComment({ repo: options.repo, commentId: updated.commentId },
|
|
1306
|
+
let verified = await verifyComment({ repo: options.repo, commentId: updated.commentId }, gh);
|
|
1236
1307
|
if (!verified) {
|
|
1237
1308
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
1238
|
-
verified = await verifyComment({ repo: options.repo, commentId: updated.commentId },
|
|
1309
|
+
verified = await verifyComment({ repo: options.repo, commentId: updated.commentId }, gh);
|
|
1239
1310
|
}
|
|
1240
1311
|
updateVerificationWarning = !verified
|
|
1241
1312
|
? `Post-update verification failed: comment ${updated.commentId} not retrievable after retry.`
|
|
@@ -1258,7 +1329,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1258
1329
|
...(updateVerificationWarning ? { verificationWarning: updateVerificationWarning } : {}),
|
|
1259
1330
|
};
|
|
1260
1331
|
}
|
|
1261
|
-
const created = await createComment({ repo: options.repo, pr: options.pr, body: desiredBody },
|
|
1332
|
+
const created = await createComment({ repo: options.repo, pr: options.pr, body: desiredBody }, gh);
|
|
1262
1333
|
// Post-creation verification: verify the comment is retrievable before returning.
|
|
1263
1334
|
// GitHub API can have brief eventual-consistency windows where a just-posted
|
|
1264
1335
|
// comment is not yet returned by paginated list endpoints. A direct fetch
|
|
@@ -1268,11 +1339,11 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1268
1339
|
let verified = true;
|
|
1269
1340
|
let verificationWarning = null;
|
|
1270
1341
|
if (envRunId) {
|
|
1271
|
-
verified = await verifyComment({ repo: options.repo, commentId: created.commentId },
|
|
1342
|
+
verified = await verifyComment({ repo: options.repo, commentId: created.commentId }, gh);
|
|
1272
1343
|
if (!verified) {
|
|
1273
1344
|
// Brief wait then retry — eventual consistency should resolve within ~2s.
|
|
1274
1345
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
1275
|
-
verified = await verifyComment({ repo: options.repo, commentId: created.commentId },
|
|
1346
|
+
verified = await verifyComment({ repo: options.repo, commentId: created.commentId }, gh);
|
|
1276
1347
|
}
|
|
1277
1348
|
verificationWarning = !verified
|
|
1278
1349
|
? `Post-creation verification failed: comment ${created.commentId} not retrievable after retry. The comment was created (API confirmed) but may not appear in list endpoints immediately.`
|