@momentiq/dark-factory-cli 0.1.0-alpha.4
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/LICENSE +201 -0
- package/README.md +150 -0
- package/dist/adapters/_shared.d.ts +45 -0
- package/dist/adapters/_shared.d.ts.map +1 -0
- package/dist/adapters/_shared.js +200 -0
- package/dist/adapters/_shared.js.map +1 -0
- package/dist/adapters/codex-sdk.d.ts +279 -0
- package/dist/adapters/codex-sdk.d.ts.map +1 -0
- package/dist/adapters/codex-sdk.js +930 -0
- package/dist/adapters/codex-sdk.js.map +1 -0
- package/dist/adapters/critic-result-schema.d.ts +215 -0
- package/dist/adapters/critic-result-schema.d.ts.map +1 -0
- package/dist/adapters/critic-result-schema.js +153 -0
- package/dist/adapters/critic-result-schema.js.map +1 -0
- package/dist/adapters/critic.d.ts +62 -0
- package/dist/adapters/critic.d.ts.map +1 -0
- package/dist/adapters/critic.js +79 -0
- package/dist/adapters/critic.js.map +1 -0
- package/dist/adapters/cursor-sdk.d.ts +153 -0
- package/dist/adapters/cursor-sdk.d.ts.map +1 -0
- package/dist/adapters/cursor-sdk.js +818 -0
- package/dist/adapters/cursor-sdk.js.map +1 -0
- package/dist/adapters/gemini-sdk.d.ts +113 -0
- package/dist/adapters/gemini-sdk.d.ts.map +1 -0
- package/dist/adapters/gemini-sdk.js +532 -0
- package/dist/adapters/gemini-sdk.js.map +1 -0
- package/dist/adapters/grok-direct-sdk.d.ts +148 -0
- package/dist/adapters/grok-direct-sdk.d.ts.map +1 -0
- package/dist/adapters/grok-direct-sdk.js +694 -0
- package/dist/adapters/grok-direct-sdk.js.map +1 -0
- package/dist/branch-protection/audit_branch_protection.py +759 -0
- package/dist/branch-protection/index.d.ts +25 -0
- package/dist/branch-protection/index.d.ts.map +1 -0
- package/dist/branch-protection/index.js +70 -0
- package/dist/branch-protection/index.js.map +1 -0
- package/dist/branch-protection/spec-default.yaml +314 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +581 -0
- package/dist/cli.js.map +1 -0
- package/dist/cycle-doc-validator/index.d.ts +43 -0
- package/dist/cycle-doc-validator/index.d.ts.map +1 -0
- package/dist/cycle-doc-validator/index.js +69 -0
- package/dist/cycle-doc-validator/index.js.map +1 -0
- package/dist/cycle-doc-validator/validate_cycle_doc.py +1260 -0
- package/dist/cycle-tracker-sync/attribute_pr_cycle_ref.py +384 -0
- package/dist/cycle-tracker-sync/index.d.ts +20 -0
- package/dist/cycle-tracker-sync/index.d.ts.map +1 -0
- package/dist/cycle-tracker-sync/index.js +71 -0
- package/dist/cycle-tracker-sync/index.js.map +1 -0
- package/dist/cycle-tracker-sync/sync_cycle_trackers.py +1093 -0
- package/dist/evidence/audit-trail.d.ts +59 -0
- package/dist/evidence/audit-trail.d.ts.map +1 -0
- package/dist/evidence/audit-trail.js +283 -0
- package/dist/evidence/audit-trail.js.map +1 -0
- package/dist/evidence/index.d.ts +4 -0
- package/dist/evidence/index.d.ts.map +1 -0
- package/dist/evidence/index.js +29 -0
- package/dist/evidence/index.js.map +1 -0
- package/dist/evidence/per-sha.d.ts +13 -0
- package/dist/evidence/per-sha.d.ts.map +1 -0
- package/dist/evidence/per-sha.js +97 -0
- package/dist/evidence/per-sha.js.map +1 -0
- package/dist/evidence/quality-gates.d.ts +19 -0
- package/dist/evidence/quality-gates.d.ts.map +1 -0
- package/dist/evidence/quality-gates.js +212 -0
- package/dist/evidence/quality-gates.js.map +1 -0
- package/dist/git.d.ts +40 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +414 -0
- package/dist/git.js.map +1 -0
- package/dist/glob.d.ts +4 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +99 -0
- package/dist/glob.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +12 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +42 -0
- package/dist/paths.js.map +1 -0
- package/dist/policy/baseline.d.ts +15 -0
- package/dist/policy/baseline.d.ts.map +1 -0
- package/dist/policy/baseline.js +115 -0
- package/dist/policy/baseline.js.map +1 -0
- package/dist/policy/config.d.ts +64 -0
- package/dist/policy/config.d.ts.map +1 -0
- package/dist/policy/config.js +363 -0
- package/dist/policy/config.js.map +1 -0
- package/dist/policy/gate.d.ts +80 -0
- package/dist/policy/gate.d.ts.map +1 -0
- package/dist/policy/gate.js +1019 -0
- package/dist/policy/gate.js.map +1 -0
- package/dist/policy/index.d.ts +7 -0
- package/dist/policy/index.d.ts.map +1 -0
- package/dist/policy/index.js +14 -0
- package/dist/policy/index.js.map +1 -0
- package/dist/policy/merge-queue.d.ts +183 -0
- package/dist/policy/merge-queue.d.ts.map +1 -0
- package/dist/policy/merge-queue.js +310 -0
- package/dist/policy/merge-queue.js.map +1 -0
- package/dist/policy/profile.d.ts +98 -0
- package/dist/policy/profile.d.ts.map +1 -0
- package/dist/policy/profile.js +156 -0
- package/dist/policy/profile.js.map +1 -0
- package/dist/policy/tdd-classifier.d.ts +18 -0
- package/dist/policy/tdd-classifier.d.ts.map +1 -0
- package/dist/policy/tdd-classifier.js +79 -0
- package/dist/policy/tdd-classifier.js.map +1 -0
- package/dist/prompt.d.ts +13 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +175 -0
- package/dist/prompt.js.map +1 -0
- package/dist/report.d.ts +88 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +376 -0
- package/dist/report.js.map +1 -0
- package/dist/runner.d.ts +30 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +456 -0
- package/dist/runner.js.map +1 -0
- package/dist/security.d.ts +2 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +19 -0
- package/dist/security.js.map +1 -0
- package/dist/trusted-surface/rebind.d.ts +10 -0
- package/dist/trusted-surface/rebind.d.ts.map +1 -0
- package/dist/trusted-surface/rebind.js +154 -0
- package/dist/trusted-surface/rebind.js.map +1 -0
- package/package.json +78 -0
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, appendFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { collectChangedPaths, perShaQualityGatePath, } from "../evidence/index.js";
|
|
4
|
+
import { matchAnyGlob } from "../glob.js";
|
|
5
|
+
import { changedFiles, commitDiff, commitMetadata, commitParent, diffHash, resolveCommit, } from "../git.js";
|
|
6
|
+
import { parseCommitTrailers } from "../evidence/index.js";
|
|
7
|
+
import { resolveArtifactDir, resolveArtifactRoot, telemetryPath } from "../paths.js";
|
|
8
|
+
import { isCriticCompleted, criticVetoesGate, quorumAggregateVerdict, readArtifact, } from "../report.js";
|
|
9
|
+
import { classifyTdd as classifyTddImpl, } from "./tdd-classifier.js";
|
|
10
|
+
import { parseQualityGateEvidence, } from "@momentiq/dark-factory-schemas";
|
|
11
|
+
const BYPASS_ENV = "AGENT_REVIEW_BYPASS";
|
|
12
|
+
// Re-export classifyTdd from gate.ts so the cycle 318.2 exit-criteria
|
|
13
|
+
// statement ("gate.ts exports classifyTdd()") is satisfied without
|
|
14
|
+
// mixing the pure classifier logic into this file.
|
|
15
|
+
export { classifyTddImpl as classifyTdd };
|
|
16
|
+
export async function evaluateCommitGate(options) {
|
|
17
|
+
const { loaded, commit } = options;
|
|
18
|
+
const cwd = options.cwd ?? loaded.repoRoot;
|
|
19
|
+
const allowBypass = options.allowBypass ?? loaded.config.policy.allowEmergencyBypass;
|
|
20
|
+
const bypassReasonRaw = options.bypassReason ?? process.env[BYPASS_ENV] ?? "";
|
|
21
|
+
const bypassReason = bypassReasonRaw.trim();
|
|
22
|
+
if (allowBypass && bypassReason) {
|
|
23
|
+
return {
|
|
24
|
+
blocked: false,
|
|
25
|
+
blocks: [],
|
|
26
|
+
warnings: [
|
|
27
|
+
{
|
|
28
|
+
reason: `bypass invoked: ${bypassReason}`,
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
bypass: { reason: bypassReason, at: new Date().toISOString() },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const blocks = [];
|
|
35
|
+
const warnings = [];
|
|
36
|
+
const sha = await resolveCommit(commit, cwd);
|
|
37
|
+
const artifact = await readArtifact(loaded, sha);
|
|
38
|
+
if (!artifact) {
|
|
39
|
+
if (loaded.config.policy.blockOnMissingReview) {
|
|
40
|
+
blocks.push({
|
|
41
|
+
reason: "missing_review",
|
|
42
|
+
detail: `no review artifact for ${sha}; run \`make agent-review-commit COMMIT=${sha}\``,
|
|
43
|
+
});
|
|
44
|
+
return { blocked: true, blocks, warnings };
|
|
45
|
+
}
|
|
46
|
+
warnings.push({
|
|
47
|
+
reason: "missing_review",
|
|
48
|
+
detail: `no review artifact for ${sha} (blockOnMissingReview disabled)`,
|
|
49
|
+
});
|
|
50
|
+
return { blocked: false, blocks, warnings };
|
|
51
|
+
}
|
|
52
|
+
if (artifact.status === "pending" || artifact.status === "running") {
|
|
53
|
+
blocks.push({
|
|
54
|
+
reason: "review_in_progress",
|
|
55
|
+
detail: `artifact status is ${artifact.status}; wait for completion`,
|
|
56
|
+
});
|
|
57
|
+
return { blocked: true, blocks, warnings };
|
|
58
|
+
}
|
|
59
|
+
if (artifact.status === "error") {
|
|
60
|
+
if (loaded.config.policy.blockOnReviewError) {
|
|
61
|
+
blocks.push({
|
|
62
|
+
reason: "review_error",
|
|
63
|
+
detail: "artifact status is error",
|
|
64
|
+
});
|
|
65
|
+
return { blocked: true, blocks, warnings };
|
|
66
|
+
}
|
|
67
|
+
warnings.push({ reason: "review_error" });
|
|
68
|
+
}
|
|
69
|
+
// Cycle 332 — precondition check for shape compatibility. Feeding a
|
|
70
|
+
// push-range artifact to the commit gate would silently produce
|
|
71
|
+
// stale_diff_hash (commit gate recomputes diffHash over
|
|
72
|
+
// parent..sha; push artifact's diffHash is over base..head). A
|
|
73
|
+
// distinct error keeps the operator's failure-mode dispatch
|
|
74
|
+
// legible. Legacy artifacts (rangeKind undefined) are treated as
|
|
75
|
+
// commit-shape for back-compat.
|
|
76
|
+
if (artifact.rangeKind === "push") {
|
|
77
|
+
blocks.push({
|
|
78
|
+
reason: "artifact_shape_mismatch",
|
|
79
|
+
detail: "evaluateCommitGate received a push-shape artifact (rangeKind=push); use evaluatePushGate for review-push runs.",
|
|
80
|
+
});
|
|
81
|
+
return { blocked: true, blocks, warnings };
|
|
82
|
+
}
|
|
83
|
+
// Verify diff hash freshness
|
|
84
|
+
try {
|
|
85
|
+
const parent = await safeParent(sha, cwd);
|
|
86
|
+
const diff = await commitDiff(parent, sha, cwd);
|
|
87
|
+
const expected = diffHash(diff);
|
|
88
|
+
if (expected !== artifact.diffHash) {
|
|
89
|
+
blocks.push({
|
|
90
|
+
reason: "stale_diff_hash",
|
|
91
|
+
detail: `expected ${expected}, artifact has ${artifact.diffHash}`,
|
|
92
|
+
});
|
|
93
|
+
return { blocked: true, blocks, warnings };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
blocks.push({
|
|
98
|
+
reason: "diff_hash_check_failed",
|
|
99
|
+
detail: err.message,
|
|
100
|
+
});
|
|
101
|
+
return { blocked: true, blocks, warnings };
|
|
102
|
+
}
|
|
103
|
+
await enforceRequiredQualityGates(loaded, sha, blocks);
|
|
104
|
+
// Cycle 318.2 Component 5: when a rubric context is supplied (v2-aware
|
|
105
|
+
// orchestrator path), strip naked blocker/high findings on each critic
|
|
106
|
+
// result BEFORE the artifact-level evaluation. The stripped findings
|
|
107
|
+
// never reach `evaluateCriticResults`, so a naked "missing tests"
|
|
108
|
+
// finding with no evidence does not block the push. Stripping is
|
|
109
|
+
// logged to NDJSON for audit by `enforceFindingRubric` itself.
|
|
110
|
+
const rubricArtifact = options.rubricContext
|
|
111
|
+
? applyRubricToArtifact(artifact, options.rubricContext)
|
|
112
|
+
: artifact;
|
|
113
|
+
// Cycle 322.7 — when a profile is active at gate-push time, narrow the
|
|
114
|
+
// artifact's criticResults to the profile's allowlist BEFORE policy
|
|
115
|
+
// dispatch. Without this, a commit reviewed under `cloud` (3 critics)
|
|
116
|
+
// and pushed under `--profile local` (2 critics) would still see
|
|
117
|
+
// out-of-profile critics' verdicts contribute to the gate decision —
|
|
118
|
+
// a divergence Codex P2 caught on PR #1468. Out-of-profile critics
|
|
119
|
+
// are added as informational warnings so operators still see they
|
|
120
|
+
// existed.
|
|
121
|
+
//
|
|
122
|
+
// After filtering, ALSO recompute the artifact's gateVerdict from the
|
|
123
|
+
// scoped result set. Without this, the block-if-any cross-check at
|
|
124
|
+
// `evaluateCriticResults:503-511` (which checks `artifact.gateVerdict
|
|
125
|
+
// === "CHANGES_REQUESTED" && blocks.length === 0`) would falsely fire:
|
|
126
|
+
// the verdict was set by buildAggregate against the FULL critic list,
|
|
127
|
+
// but after profile filtering the scoped blocks could legitimately be
|
|
128
|
+
// empty. (Cursor critic high finding on PR #1468 commit c7537f34.)
|
|
129
|
+
let scopedArtifact = rubricArtifact;
|
|
130
|
+
if (options.profileCriticIds && options.profileCriticIds.length > 0) {
|
|
131
|
+
const allowed = new Set(options.profileCriticIds);
|
|
132
|
+
const scopedResults = rubricArtifact.criticResults.filter((r) => allowed.has(r.criticId));
|
|
133
|
+
const outOfProfile = rubricArtifact.criticResults.filter((r) => !allowed.has(r.criticId));
|
|
134
|
+
for (const r of outOfProfile) {
|
|
135
|
+
warnings.push({
|
|
136
|
+
reason: "out_of_profile_critic",
|
|
137
|
+
criticId: r.criticId,
|
|
138
|
+
detail: `critic "${r.criticId}" ran but is not in the active profile's criticIds; verdict ignored at gate`,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
// Recompute gateVerdict from the scoped result set so the cross-check
|
|
142
|
+
// in evaluateCriticResults sees a like-for-like aggregate. Only
|
|
143
|
+
// recompute when the artifact is complete (status === "complete"):
|
|
144
|
+
// pending/error artifacts have no gateVerdict to overwrite, and
|
|
145
|
+
// the policy-specific evaluator handles those cases directly.
|
|
146
|
+
let scopedVerdict = rubricArtifact.gateVerdict;
|
|
147
|
+
if (rubricArtifact.status === "complete") {
|
|
148
|
+
scopedVerdict = recomputeArtifactVerdict(scopedResults, loaded, options.quorumOverride);
|
|
149
|
+
}
|
|
150
|
+
scopedArtifact = {
|
|
151
|
+
...rubricArtifact,
|
|
152
|
+
criticResults: scopedResults,
|
|
153
|
+
...(scopedVerdict !== undefined ? { gateVerdict: scopedVerdict } : {}),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
// Cycle 322.3 — dispatch on aggregation policy.
|
|
157
|
+
// - `min-complete-quorum`: quorum-aware evaluator (defined below).
|
|
158
|
+
// The `required` flag is semantically irrelevant under the
|
|
159
|
+
// quorum policy (all critics contribute to the quorum count
|
|
160
|
+
// and any one can veto via §11), so the dispatcher does not
|
|
161
|
+
// thread `requiredCriticIds`.
|
|
162
|
+
// - `block-if-any` (default): 322.2-shape `required`-threaded
|
|
163
|
+
// evaluator (preserves shadow-mode semantics).
|
|
164
|
+
if (loaded.config.aggregation.policy === "min-complete-quorum") {
|
|
165
|
+
// Cycle 322.7 — profile.quorum (when supplied) overrides root
|
|
166
|
+
// aggregation.quorum. Fall back to root value otherwise; legacy
|
|
167
|
+
// fallback to 2 preserves cycle 322.3 default for hand-built
|
|
168
|
+
// test configs.
|
|
169
|
+
const effectiveQuorum = options.quorumOverride ?? loaded.config.aggregation.quorum ?? 2;
|
|
170
|
+
evaluateQuorumCriticResults(scopedArtifact, loaded.config.aggregation.blockingSeverities, effectiveQuorum, blocks, warnings);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
// Cycle 322.2 Component 4 — `required` flag threading.
|
|
174
|
+
// Build the set of required critic ids so the per-critic evaluator
|
|
175
|
+
// demotes findings from optional (`required: false`) critics from
|
|
176
|
+
// `blocks` to `warnings`.
|
|
177
|
+
const requiredCriticIds = new Set(loaded.config.critics.filter((c) => c.required).map((c) => c.id));
|
|
178
|
+
evaluateCriticResults(scopedArtifact, loaded.config.aggregation.blockingSeverities, blocks, warnings, loaded.config.policy.blockOnReviewError, requiredCriticIds);
|
|
179
|
+
}
|
|
180
|
+
return { blocked: blocks.length > 0, blocks, warnings };
|
|
181
|
+
}
|
|
182
|
+
export async function evaluatePushGate(options) {
|
|
183
|
+
const { loaded, artifact } = options;
|
|
184
|
+
const cwd = options.cwd ?? loaded.repoRoot;
|
|
185
|
+
const allowBypass = options.allowBypass ?? loaded.config.policy.allowEmergencyBypass;
|
|
186
|
+
const bypassReasonRaw = options.bypassReason ?? process.env[BYPASS_ENV] ?? "";
|
|
187
|
+
const bypassReason = bypassReasonRaw.trim();
|
|
188
|
+
if (allowBypass && bypassReason) {
|
|
189
|
+
return {
|
|
190
|
+
blocked: false,
|
|
191
|
+
blocks: [],
|
|
192
|
+
warnings: [{ reason: `bypass invoked: ${bypassReason}` }],
|
|
193
|
+
bypass: { reason: bypassReason, at: new Date().toISOString() },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const blocks = [];
|
|
197
|
+
const warnings = [];
|
|
198
|
+
// Shape precondition: reject anything that isn't a push-shape
|
|
199
|
+
// artifact. Legacy (undefined) and explicit "commit" are both
|
|
200
|
+
// rejected — caller should be dispatching to evaluateCommitGate
|
|
201
|
+
// for those.
|
|
202
|
+
if (artifact.rangeKind !== "push") {
|
|
203
|
+
blocks.push({
|
|
204
|
+
reason: "artifact_shape_mismatch",
|
|
205
|
+
detail: `evaluatePushGate received an artifact with rangeKind=${artifact.rangeKind ?? "undefined"} (expected "push"); use evaluateCommitGate for commit-shape artifacts.`,
|
|
206
|
+
});
|
|
207
|
+
return { blocked: true, blocks, warnings };
|
|
208
|
+
}
|
|
209
|
+
// SHA precondition: the artifact's commit/parent MUST identify the
|
|
210
|
+
// same head/base the gate is being evaluated against. Without this
|
|
211
|
+
// assertion, a fresh artifact for a different push-range could fail
|
|
212
|
+
// loudly only via the downstream diffHash recompute — and only when
|
|
213
|
+
// the two ranges happen to produce different diffs. An explicit
|
|
214
|
+
// SHA check makes mismatched artifacts unambiguous (cycle 332 bot
|
|
215
|
+
// review #5).
|
|
216
|
+
if (artifact.commit !== options.headSha || artifact.parent !== options.baseSha) {
|
|
217
|
+
blocks.push({
|
|
218
|
+
reason: "artifact_sha_mismatch",
|
|
219
|
+
detail: `evaluatePushGate received artifact for commit=${artifact.commit} parent=${artifact.parent} but gate is evaluating headSha=${options.headSha} baseSha=${options.baseSha}`,
|
|
220
|
+
});
|
|
221
|
+
return { blocked: true, blocks, warnings };
|
|
222
|
+
}
|
|
223
|
+
if (artifact.status === "pending" || artifact.status === "running") {
|
|
224
|
+
blocks.push({
|
|
225
|
+
reason: "review_in_progress",
|
|
226
|
+
detail: `artifact status is ${artifact.status}; wait for completion`,
|
|
227
|
+
});
|
|
228
|
+
return { blocked: true, blocks, warnings };
|
|
229
|
+
}
|
|
230
|
+
if (artifact.status === "error") {
|
|
231
|
+
if (loaded.config.policy.blockOnReviewError) {
|
|
232
|
+
blocks.push({
|
|
233
|
+
reason: "review_error",
|
|
234
|
+
detail: "artifact status is error",
|
|
235
|
+
});
|
|
236
|
+
return { blocked: true, blocks, warnings };
|
|
237
|
+
}
|
|
238
|
+
warnings.push({ reason: "review_error" });
|
|
239
|
+
}
|
|
240
|
+
// Verify diff hash freshness over <baseSha>..<headSha>.
|
|
241
|
+
try {
|
|
242
|
+
// The push-shape artifact's diffHash is computed against the
|
|
243
|
+
// base..head range. We recompute the same range here. The
|
|
244
|
+
// commitDiff helper handles non-empty parent, which for
|
|
245
|
+
// base..head is just the base sha.
|
|
246
|
+
const diff = await commitDiff(options.baseSha, options.headSha, cwd);
|
|
247
|
+
const expected = diffHash(diff);
|
|
248
|
+
if (expected !== artifact.diffHash) {
|
|
249
|
+
blocks.push({
|
|
250
|
+
reason: "stale_diff_hash",
|
|
251
|
+
detail: `expected ${expected}, artifact has ${artifact.diffHash}`,
|
|
252
|
+
});
|
|
253
|
+
return { blocked: true, blocks, warnings };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
blocks.push({
|
|
258
|
+
reason: "diff_hash_check_failed",
|
|
259
|
+
detail: err.message,
|
|
260
|
+
});
|
|
261
|
+
return { blocked: true, blocks, warnings };
|
|
262
|
+
}
|
|
263
|
+
// Quality-gate evidence enforced at headSha — same contract as
|
|
264
|
+
// the commit gate, scoped to the head of the push range.
|
|
265
|
+
await enforceRequiredQualityGates(loaded, options.headSha, blocks);
|
|
266
|
+
const rubricArtifact = options.rubricContext
|
|
267
|
+
? applyRubricToArtifact(artifact, options.rubricContext)
|
|
268
|
+
: artifact;
|
|
269
|
+
let scopedArtifact = rubricArtifact;
|
|
270
|
+
if (options.profileCriticIds && options.profileCriticIds.length > 0) {
|
|
271
|
+
const allowed = new Set(options.profileCriticIds);
|
|
272
|
+
const scopedResults = rubricArtifact.criticResults.filter((r) => allowed.has(r.criticId));
|
|
273
|
+
const outOfProfile = rubricArtifact.criticResults.filter((r) => !allowed.has(r.criticId));
|
|
274
|
+
for (const r of outOfProfile) {
|
|
275
|
+
warnings.push({
|
|
276
|
+
reason: "out_of_profile_critic",
|
|
277
|
+
criticId: r.criticId,
|
|
278
|
+
detail: `critic "${r.criticId}" ran but is not in the active profile's criticIds; verdict ignored at gate`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
let scopedVerdict = rubricArtifact.gateVerdict;
|
|
282
|
+
if (rubricArtifact.status === "complete") {
|
|
283
|
+
scopedVerdict = recomputeArtifactVerdict(scopedResults, loaded, options.quorumOverride);
|
|
284
|
+
}
|
|
285
|
+
scopedArtifact = {
|
|
286
|
+
...rubricArtifact,
|
|
287
|
+
criticResults: scopedResults,
|
|
288
|
+
...(scopedVerdict !== undefined ? { gateVerdict: scopedVerdict } : {}),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
if (loaded.config.aggregation.policy === "min-complete-quorum") {
|
|
292
|
+
const effectiveQuorum = options.quorumOverride ?? loaded.config.aggregation.quorum ?? 2;
|
|
293
|
+
evaluateQuorumCriticResults(scopedArtifact, loaded.config.aggregation.blockingSeverities, effectiveQuorum, blocks, warnings);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
const requiredCriticIds = new Set(loaded.config.critics.filter((c) => c.required).map((c) => c.id));
|
|
297
|
+
evaluateCriticResults(scopedArtifact, loaded.config.aggregation.blockingSeverities, blocks, warnings, loaded.config.policy.blockOnReviewError, requiredCriticIds);
|
|
298
|
+
}
|
|
299
|
+
return { blocked: blocks.length > 0, blocks, warnings };
|
|
300
|
+
}
|
|
301
|
+
function applyRubricToArtifact(artifact, context) {
|
|
302
|
+
// Deep-replace each critic's findings with the rubric-kept subset AND
|
|
303
|
+
// recompute the critic's verdict when stripping removes the last
|
|
304
|
+
// blocking finding (codex P2 follow-up on PR #1349). Without the
|
|
305
|
+
// verdict recompute, an artifact whose CHANGES_REQUESTED verdict was
|
|
306
|
+
// driven by now-stripped blockers still triggers the existing
|
|
307
|
+
// `verdict === "CHANGES_REQUESTED"` block path in `evaluateCriticResults`,
|
|
308
|
+
// re-blocking the push that the rubric just cleared.
|
|
309
|
+
//
|
|
310
|
+
// The artifact itself is returned shallow-copied so callers don't
|
|
311
|
+
// observe mutation of the on-disk artifact.
|
|
312
|
+
const blockingSeverities = context.loaded.config.aggregation.blockingSeverities;
|
|
313
|
+
const newCriticResults = artifact.criticResults.map((cr) => {
|
|
314
|
+
const { kept, stripped } = enforceFindingRubric(cr.findings, context);
|
|
315
|
+
const updated = { ...cr, findings: kept };
|
|
316
|
+
// Only recompute verdict for completed critics — pending/running/error
|
|
317
|
+
// already have specialized handling in `evaluateCriticResults`.
|
|
318
|
+
//
|
|
319
|
+
// Critical: we only flip CHANGES_REQUESTED → APPROVED when the rubric
|
|
320
|
+
// ACTUALLY stripped a blocking finding. If the verdict was already
|
|
321
|
+
// CHANGES_REQUESTED for a different reason (e.g., the critic
|
|
322
|
+
// intentionally returned CHANGES_REQUESTED with only medium findings,
|
|
323
|
+
// or the verdict was set by `aggregateVerdict` for a non-finding
|
|
324
|
+
// reason), the rubric step is silent and the verdict stays.
|
|
325
|
+
// (Codex P2 follow-up on d46b8be7 — predicate was too broad.)
|
|
326
|
+
if (updated.status === "complete" && updated.verdict === "CHANGES_REQUESTED") {
|
|
327
|
+
const strippedBlockingCount = stripped.filter((s) => blockingSeverities.includes(s.finding.severity)).length;
|
|
328
|
+
const stillHasBlocking = kept.some((f) => blockingSeverities.includes(f.severity));
|
|
329
|
+
if (strippedBlockingCount > 0 &&
|
|
330
|
+
!stillHasBlocking &&
|
|
331
|
+
!updated.requiresHumanJudgment) {
|
|
332
|
+
updated.verdict = "APPROVED";
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return updated;
|
|
336
|
+
});
|
|
337
|
+
// The artifact-level `gateVerdict` is also derived from critic verdicts
|
|
338
|
+
// by `buildAggregate()`. Recompute it from the rewritten critic results
|
|
339
|
+
// so the gate-level check stays consistent: a per-critic verdict flip
|
|
340
|
+
// to APPROVED has to surface at the aggregate too, or the existing
|
|
341
|
+
// "aggregate_changes_requested" cross-check in `evaluateCriticResults`
|
|
342
|
+
// re-blocks the same push.
|
|
343
|
+
const recomputedArtifact = {
|
|
344
|
+
...artifact,
|
|
345
|
+
criticResults: newCriticResults,
|
|
346
|
+
};
|
|
347
|
+
if (artifact.status === "complete") {
|
|
348
|
+
// Cycle 322.2 — recompute is required-aware so an optional critic's
|
|
349
|
+
// surviving blockers don't re-flip the aggregate after rubric strip.
|
|
350
|
+
const requiredCriticIds = new Set(context.loaded.config.critics.filter((c) => c.required).map((c) => c.id));
|
|
351
|
+
recomputedArtifact.gateVerdict = recomputeGateVerdict(newCriticResults, blockingSeverities, requiredCriticIds);
|
|
352
|
+
}
|
|
353
|
+
return recomputedArtifact;
|
|
354
|
+
}
|
|
355
|
+
function recomputeGateVerdict(results, blockingSeverities,
|
|
356
|
+
// Cycle 322.2 — optional critics' verdicts don't drive recomputation;
|
|
357
|
+
// only required critics determine the aggregate gate verdict here.
|
|
358
|
+
requiredCriticIds) {
|
|
359
|
+
for (const r of results) {
|
|
360
|
+
if (!requiredCriticIds.has(r.criticId))
|
|
361
|
+
continue;
|
|
362
|
+
if (r.status !== "complete")
|
|
363
|
+
return "CHANGES_REQUESTED";
|
|
364
|
+
if (r.verdict === "CHANGES_REQUESTED")
|
|
365
|
+
return "CHANGES_REQUESTED";
|
|
366
|
+
if (r.requiresHumanJudgment)
|
|
367
|
+
return "CHANGES_REQUESTED";
|
|
368
|
+
if (r.findings.some((f) => blockingSeverities.includes(f.severity))) {
|
|
369
|
+
return "CHANGES_REQUESTED";
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return "APPROVED";
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Cycle 322.7 — recompute the artifact's gateVerdict from a scoped
|
|
376
|
+
* (profile-filtered) result set, dispatching on the same policy the
|
|
377
|
+
* runner used. Always returns a verdict — the caller is expected to
|
|
378
|
+
* only invoke this for completed artifacts (the only artifact status
|
|
379
|
+
* that has a gateVerdict to overwrite).
|
|
380
|
+
*
|
|
381
|
+
* This is the symmetric helper to the recomputation already performed
|
|
382
|
+
* by `applyRubricToArtifact` when the rubric strip removes the
|
|
383
|
+
* blocker that drove the verdict. Without this, profile-filtered
|
|
384
|
+
* gate evaluation could falsely trip the `aggregate_changes_requested`
|
|
385
|
+
* cross-check at `evaluateCriticResults:503-511` (Cursor critic high
|
|
386
|
+
* finding on PR #1468 commit c7537f34).
|
|
387
|
+
*/
|
|
388
|
+
function recomputeArtifactVerdict(scopedResults, loaded, quorumOverride) {
|
|
389
|
+
if (loaded.config.aggregation.policy === "min-complete-quorum") {
|
|
390
|
+
const quorum = quorumOverride ?? loaded.config.aggregation.quorum ?? 2;
|
|
391
|
+
return quorumAggregateVerdict(
|
|
392
|
+
// Cast: `quorumAggregateVerdict` reads CriticResult fields by
|
|
393
|
+
// name; scoped results are a subset of the original artifact's
|
|
394
|
+
// results array, so the shape is identical.
|
|
395
|
+
scopedResults, loaded.config.aggregation.blockingSeverities, quorum).verdict;
|
|
396
|
+
}
|
|
397
|
+
// block-if-any: only required critics drive the aggregate.
|
|
398
|
+
const requiredCriticIds = new Set(loaded.config.critics.filter((c) => c.required).map((c) => c.id));
|
|
399
|
+
return recomputeGateVerdict(scopedResults, loaded.config.aggregation.blockingSeverities, requiredCriticIds);
|
|
400
|
+
}
|
|
401
|
+
// Deterministic gate-evidence enforcement, independent of critic verdict.
|
|
402
|
+
// Without this, a critic that returns APPROVED while `qualityGatesMissing`
|
|
403
|
+
// is non-empty (or any required gate has non-zero exit) would let the push
|
|
404
|
+
// through. The gate must enforce the configured required gates directly
|
|
405
|
+
// rather than trusting the model. Adapter populates each critic result's
|
|
406
|
+
// `validation` view from the same packet, so reading any one is sufficient.
|
|
407
|
+
async function enforceRequiredQualityGates(loaded, sha, blocks) {
|
|
408
|
+
const required = loaded.config.validation.requiredQualityGates;
|
|
409
|
+
if (required.length === 0)
|
|
410
|
+
return;
|
|
411
|
+
// Closes #1549. Read the per-SHA quality-gate evidence file DIRECTLY
|
|
412
|
+
// rather than `artifact.criticResults[0]?.validation`. The old path was
|
|
413
|
+
// fragile in two ways the dogfood case made obvious:
|
|
414
|
+
// (a) `criticResults[0]` is whichever critic appears first in
|
|
415
|
+
// `.agent-review/config.json`. When that critic errored (e.g.,
|
|
416
|
+
// cursor sandbox failure in CI runs since the post-#1546/1547
|
|
417
|
+
// cleanup), the adapter builds a `status: "error"` result with
|
|
418
|
+
// `validation: { qualityGateResults: [], qualityGatesMissing: [] }`
|
|
419
|
+
// — so every required gate trips `required_gate_missing` even
|
|
420
|
+
// though gate-prepare wrote real evidence to disk.
|
|
421
|
+
// (b) Even on the success path, the adapter's `validation` block is
|
|
422
|
+
// a SECONDHAND copy of `packet.validation.evidence` (set by the
|
|
423
|
+
// cursor/codex/gemini/grok adapters from the same packet). The
|
|
424
|
+
// gate's authoritative source of truth is the on-disk per-SHA
|
|
425
|
+
// file written by `gate-prepare`; routing through critic state
|
|
426
|
+
// only added a layer that could go wrong.
|
|
427
|
+
// `readPerShaEvidence` returns null when the file is missing OR when
|
|
428
|
+
// its `commit` field doesn't match `sha` (the integrity check in
|
|
429
|
+
// `readPerShaEvidence`). Both states collapse to `required_gate_missing`
|
|
430
|
+
// with the same detail; that's intentional — the operator action is
|
|
431
|
+
// the same either way (re-run `gate-prepare --commit <sha>`).
|
|
432
|
+
const evidence = await readPerShaEvidence(loaded, sha);
|
|
433
|
+
if (!evidence) {
|
|
434
|
+
for (const gate of required) {
|
|
435
|
+
blocks.push({
|
|
436
|
+
reason: "required_gate_missing",
|
|
437
|
+
detail: `no per-SHA evidence file for ${sha}; run \`gate-prepare --commit ${sha}\``,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
for (const gate of required) {
|
|
443
|
+
const result = evidence.results.find((r) => r.command === gate);
|
|
444
|
+
if (!result) {
|
|
445
|
+
blocks.push({
|
|
446
|
+
reason: "required_gate_missing",
|
|
447
|
+
detail: `no evidence for required gate: ${gate}`,
|
|
448
|
+
});
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
if (result.exitCode !== 0) {
|
|
452
|
+
blocks.push({
|
|
453
|
+
reason: "required_gate_failed",
|
|
454
|
+
detail: `${gate} exit=${result.exitCode}`,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
function evaluateCriticResults(artifact, blockingSeverities, blocks, warnings, blockOnReviewError,
|
|
460
|
+
// Cycle 322.2 Component 4 — `required` flag threading. Findings,
|
|
461
|
+
// verdict flips, and human-judgment markers from optional
|
|
462
|
+
// (`required: false`) critics are demoted to warnings instead of
|
|
463
|
+
// contributing to blocks. Errors from optional critics are similarly
|
|
464
|
+
// demoted (the `blockOnReviewError` flag still applies, but only for
|
|
465
|
+
// required critics — an optional critic's transient SDK error must
|
|
466
|
+
// never block the gate by definition of being optional).
|
|
467
|
+
requiredCriticIds) {
|
|
468
|
+
// Track per-critic errors that we downgraded to warnings. The aggregate
|
|
469
|
+
// cross-check below must not silently re-block them via the "verdict was
|
|
470
|
+
// CHANGES_REQUESTED but no block captured" path. Without this counter,
|
|
471
|
+
// `buildAggregate()` flips the verdict to CHANGES_REQUESTED for any
|
|
472
|
+
// required errored critic (in `report.ts:aggregateVerdict`), which then
|
|
473
|
+
// re-blocks even though the operator set `blockOnReviewError=false`.
|
|
474
|
+
// (Cycle 3 #13 follow-up — flagged by critic on cerebe df2169a.)
|
|
475
|
+
let errorsDowngraded = 0;
|
|
476
|
+
for (const result of artifact.criticResults) {
|
|
477
|
+
const isRequired = requiredCriticIds.has(result.criticId);
|
|
478
|
+
if (result.status === "error") {
|
|
479
|
+
// Per-critic errors (startup/JSON/schema failures) are persisted
|
|
480
|
+
// inside a `status: "complete"` artifact, so the artifact-level
|
|
481
|
+
// `blockOnReviewError` check above wouldn't apply here. Honor the
|
|
482
|
+
// same flag at the per-critic level — without this, setting
|
|
483
|
+
// `blockOnReviewError=false` had no effect for the common failure
|
|
484
|
+
// path. (Cycle 3 #13)
|
|
485
|
+
// Cycle 322.2 — optional critic errors are unconditionally demoted
|
|
486
|
+
// to warnings; only required critic errors honor blockOnReviewError.
|
|
487
|
+
const detail = result.error?.message ?? "critic returned error";
|
|
488
|
+
if (isRequired && blockOnReviewError) {
|
|
489
|
+
blocks.push({ reason: "critic_error", criticId: result.criticId, detail });
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
warnings.push({ reason: "critic_error", criticId: result.criticId, detail });
|
|
493
|
+
if (isRequired)
|
|
494
|
+
errorsDowngraded++;
|
|
495
|
+
}
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (result.status === "pending" || result.status === "running") {
|
|
499
|
+
// Cycle 322.2 — pending/running on optional critics is a warning
|
|
500
|
+
// (informational); only required critics block on in-progress state.
|
|
501
|
+
if (isRequired) {
|
|
502
|
+
blocks.push({
|
|
503
|
+
reason: "critic_in_progress",
|
|
504
|
+
criticId: result.criticId,
|
|
505
|
+
detail: `critic status is ${result.status}`,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
warnings.push({
|
|
510
|
+
reason: "critic_in_progress",
|
|
511
|
+
criticId: result.criticId,
|
|
512
|
+
detail: `critic status is ${result.status} (optional)`,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
if (result.requiresHumanJudgment) {
|
|
518
|
+
if (isRequired) {
|
|
519
|
+
blocks.push({
|
|
520
|
+
reason: "requires_human_judgment",
|
|
521
|
+
criticId: result.criticId,
|
|
522
|
+
detail: result.summary,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
warnings.push({
|
|
527
|
+
reason: "requires_human_judgment",
|
|
528
|
+
criticId: result.criticId,
|
|
529
|
+
detail: `${result.summary} (optional)`,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (result.verdict === "CHANGES_REQUESTED") {
|
|
534
|
+
if (isRequired) {
|
|
535
|
+
blocks.push({
|
|
536
|
+
reason: "changes_requested",
|
|
537
|
+
criticId: result.criticId,
|
|
538
|
+
detail: result.summary,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
warnings.push({
|
|
543
|
+
reason: "changes_requested",
|
|
544
|
+
criticId: result.criticId,
|
|
545
|
+
detail: `${result.summary} (optional)`,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const blocking = result.findings.filter((f) => blockingSeverities.includes(f.severity));
|
|
550
|
+
for (const f of blocking) {
|
|
551
|
+
const detail = `${f.category}@${f.file ?? "?"}: ${f.evidence}`;
|
|
552
|
+
if (isRequired) {
|
|
553
|
+
blocks.push({ reason: `${f.severity}_finding`, criticId: result.criticId, detail });
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
warnings.push({
|
|
557
|
+
reason: `${f.severity}_finding`,
|
|
558
|
+
criticId: result.criticId,
|
|
559
|
+
detail: `${detail} (optional)`,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const nonBlocking = result.findings.filter((f) => !blockingSeverities.includes(f.severity));
|
|
564
|
+
for (const f of nonBlocking) {
|
|
565
|
+
// Below-threshold findings are always warnings regardless of the
|
|
566
|
+
// critic's required flag.
|
|
567
|
+
warnings.push({
|
|
568
|
+
reason: `${f.severity}_finding`,
|
|
569
|
+
criticId: result.criticId,
|
|
570
|
+
detail: `${f.category}@${f.file ?? "?"}: ${f.evidence}`,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
// Aggregate gate verdict cross-check. Skip when the only reason the
|
|
575
|
+
// aggregate is CHANGES_REQUESTED is downgraded errors — otherwise the
|
|
576
|
+
// operator's `blockOnReviewError=false` setting would be silently
|
|
577
|
+
// overridden by the cross-check for required critics, since
|
|
578
|
+
// `aggregateVerdict()` flips on required+error before this gate runs.
|
|
579
|
+
if (artifact.gateVerdict === "CHANGES_REQUESTED" &&
|
|
580
|
+
blocks.length === 0 &&
|
|
581
|
+
errorsDowngraded === 0) {
|
|
582
|
+
blocks.push({
|
|
583
|
+
reason: "aggregate_changes_requested",
|
|
584
|
+
detail: "gateVerdict is CHANGES_REQUESTED but no per-critic blocker captured",
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
// ---------------------------------------------------------------------------
|
|
589
|
+
// Cycle 322.3 — `min-complete-quorum` gate evaluator.
|
|
590
|
+
//
|
|
591
|
+
// Companion to `report.ts:quorumAggregateVerdict` — both functions
|
|
592
|
+
// share the same "completed", "vetoes" predicates via the helper
|
|
593
|
+
// exports so the gate-block enforcement and the aggregate-verdict
|
|
594
|
+
// computation cannot drift.
|
|
595
|
+
//
|
|
596
|
+
// Verdict→block mapping:
|
|
597
|
+
// - veto by any completed critic → `blocker_finding` /
|
|
598
|
+
// `high_finding` / `requires_human_judgment` /
|
|
599
|
+
// `changes_requested` blocks (one per veto-source) PLUS the
|
|
600
|
+
// non-blocking findings as warnings. Identical block reason
|
|
601
|
+
// strings to the block-if-any evaluator so downstream parsers
|
|
602
|
+
// don't need a separate quorum-mode branch.
|
|
603
|
+
// - completed_count < quorum (no veto) → `quorum_unmet` block.
|
|
604
|
+
// Distinct block reason so operators can route alerts
|
|
605
|
+
// differently from content blocks (e.g., page on a sustained
|
|
606
|
+
// `quorum_unmet` spike correlated with `critic_run_error`).
|
|
607
|
+
// - completed_count >= quorum, no veto → no blocks.
|
|
608
|
+
//
|
|
609
|
+
// Per-critic errors are recorded as warnings unconditionally under
|
|
610
|
+
// quorum policy — `quorum_unmet` is the canonical fail-closed
|
|
611
|
+
// signal when too many critics error. This is a deliberate
|
|
612
|
+
// semantic divergence from `block-if-any` (where
|
|
613
|
+
// `policy.blockOnReviewError=true` blocks on any required-critic
|
|
614
|
+
// error): under quorum, the operator-facing "fail-closed" knob is
|
|
615
|
+
// the `quorum` value itself. Setting `quorum = critics.length`
|
|
616
|
+
// forces ALL critics to complete for the gate to pass (any single
|
|
617
|
+
// error → `quorum_unmet` → block); lower quorum values express the
|
|
618
|
+
// operator's tolerance for vendor outages. `blockOnReviewError`
|
|
619
|
+
// would be redundant with `quorum` under this scheme and is
|
|
620
|
+
// therefore NOT threaded into the quorum evaluator. See cycle
|
|
621
|
+
// 322.3 §"Failure modes" + cycle 322.3 Cursor critic feedback
|
|
622
|
+
// (367476d3 finding #3) for the design rationale.
|
|
623
|
+
//
|
|
624
|
+
// The cycle 322.3.1 promotion PR will document the operator
|
|
625
|
+
// runbook for choosing `quorum`; until then, the policy stays at
|
|
626
|
+
// `block-if-any` and `blockOnReviewError` continues to drive the
|
|
627
|
+
// fail-closed behavior in the legacy evaluator below.
|
|
628
|
+
export function evaluateQuorumCriticResults(artifact, blockingSeverities, quorum, blocks, warnings) {
|
|
629
|
+
// Surface every critic's terminal state as a warning so the
|
|
630
|
+
// artifact reader sees all per-critic diagnostics even when no
|
|
631
|
+
// veto fires.
|
|
632
|
+
for (const result of artifact.criticResults) {
|
|
633
|
+
if (result.status === "error") {
|
|
634
|
+
const detail = result.error?.message ?? "critic returned error";
|
|
635
|
+
warnings.push({ reason: "critic_error", criticId: result.criticId, detail });
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
if (result.status === "pending" || result.status === "running") {
|
|
639
|
+
warnings.push({
|
|
640
|
+
reason: "critic_in_progress",
|
|
641
|
+
criticId: result.criticId,
|
|
642
|
+
detail: `critic status is ${result.status}`,
|
|
643
|
+
});
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
// Completed critic. Vetoes contribute blocks; non-veto findings
|
|
647
|
+
// contribute warnings.
|
|
648
|
+
const vetoes = criticVetoesGate(result, blockingSeverities);
|
|
649
|
+
if (vetoes) {
|
|
650
|
+
if (result.requiresHumanJudgment) {
|
|
651
|
+
blocks.push({
|
|
652
|
+
reason: "requires_human_judgment",
|
|
653
|
+
criticId: result.criticId,
|
|
654
|
+
detail: result.summary,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
if (result.verdict === "CHANGES_REQUESTED") {
|
|
658
|
+
blocks.push({
|
|
659
|
+
reason: "changes_requested",
|
|
660
|
+
criticId: result.criticId,
|
|
661
|
+
detail: result.summary,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
const blocking = result.findings.filter((f) => blockingSeverities.includes(f.severity));
|
|
665
|
+
for (const f of blocking) {
|
|
666
|
+
const detail = `${f.category}@${f.file ?? "?"}: ${f.evidence}`;
|
|
667
|
+
blocks.push({
|
|
668
|
+
reason: `${f.severity}_finding`,
|
|
669
|
+
criticId: result.criticId,
|
|
670
|
+
detail,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
// Non-blocking findings are always warnings regardless of vetoes.
|
|
675
|
+
const nonBlocking = result.findings.filter((f) => !blockingSeverities.includes(f.severity));
|
|
676
|
+
for (const f of nonBlocking) {
|
|
677
|
+
warnings.push({
|
|
678
|
+
reason: `${f.severity}_finding`,
|
|
679
|
+
criticId: result.criticId,
|
|
680
|
+
detail: `${f.category}@${f.file ?? "?"}: ${f.evidence}`,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
// Quorum check. If `quorum_unmet` AND no veto blocks already
|
|
685
|
+
// captured, surface a single `quorum_unmet` block. The
|
|
686
|
+
// veto-preserves-quorum semantics live in `criticVetoesGate`
|
|
687
|
+
// above — when a veto already blocks, `quorum_unmet` is silent
|
|
688
|
+
// (the §11 invariant). When the verdict already shows a veto
|
|
689
|
+
// contributed to blocks, do NOT also add `quorum_unmet` — the
|
|
690
|
+
// veto is the actionable signal.
|
|
691
|
+
const outcome = quorumAggregateVerdict(artifact.criticResults, blockingSeverities, quorum);
|
|
692
|
+
if (outcome.reason === "quorum_unmet" && blocks.length === 0) {
|
|
693
|
+
const completed = artifact.criticResults.filter(isCriticCompleted).length;
|
|
694
|
+
const total = artifact.criticResults.length;
|
|
695
|
+
const erroredAdapters = artifact.criticResults
|
|
696
|
+
.filter((r) => r.status === "error")
|
|
697
|
+
.map((r) => `${r.criticId}=${r.error?.code ?? "(no code)"}`)
|
|
698
|
+
.join(", ");
|
|
699
|
+
blocks.push({
|
|
700
|
+
reason: "quorum_unmet",
|
|
701
|
+
detail: `only ${completed}/${total} critics completed; quorum is ${quorum}` +
|
|
702
|
+
(erroredAdapters
|
|
703
|
+
? ` (errored: ${erroredAdapters})`
|
|
704
|
+
: ""),
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
async function safeParent(sha, cwd) {
|
|
709
|
+
try {
|
|
710
|
+
return await commitParent(sha, cwd);
|
|
711
|
+
}
|
|
712
|
+
catch {
|
|
713
|
+
return "";
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
export function summarizeGate(result) {
|
|
717
|
+
const lines = [];
|
|
718
|
+
lines.push(result.blocked ? "GATE: BLOCKED" : "GATE: PASSED");
|
|
719
|
+
if (result.bypass) {
|
|
720
|
+
lines.push(`bypass: ${result.bypass.reason}`);
|
|
721
|
+
}
|
|
722
|
+
for (const b of result.blocks) {
|
|
723
|
+
lines.push(` block: [${b.reason}]${b.criticId ? ` (${b.criticId})` : ""}${b.detail ? ` ${b.detail}` : ""}`);
|
|
724
|
+
}
|
|
725
|
+
for (const w of result.warnings) {
|
|
726
|
+
lines.push(` warn: [${w.reason}]${w.criticId ? ` (${w.criticId})` : ""}${w.detail ? ` ${w.detail}` : ""}`);
|
|
727
|
+
}
|
|
728
|
+
return lines.join("\n");
|
|
729
|
+
}
|
|
730
|
+
export async function enforceVerificationRoutes(options) {
|
|
731
|
+
const { loaded, sha, changedPaths } = options;
|
|
732
|
+
const routes = loaded.config.validation.verificationRoutes ?? [];
|
|
733
|
+
if (routes.length === 0) {
|
|
734
|
+
return { triggered: [], active: [], perRoute: [] };
|
|
735
|
+
}
|
|
736
|
+
// Step 1 — determine triggered routes.
|
|
737
|
+
const triggered = [];
|
|
738
|
+
for (const route of routes) {
|
|
739
|
+
if (changedPaths.some((p) => matchAnyGlob(p, route.trigger))) {
|
|
740
|
+
triggered.push(route);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
// Step 2 — exclusive-route suppression. The cycle-318.2 doc rule: an
|
|
744
|
+
// exclusive route fires ONLY when every changed path matches its
|
|
745
|
+
// trigger. When it fires, drop all non-exclusive routes. Mixed PRs
|
|
746
|
+
// (e.g., docs + production paths) keep the production routes active.
|
|
747
|
+
let suppressedBy;
|
|
748
|
+
for (const route of triggered) {
|
|
749
|
+
if (!route.exclusive)
|
|
750
|
+
continue;
|
|
751
|
+
const allMatch = changedPaths.every((p) => matchAnyGlob(p, route.trigger));
|
|
752
|
+
if (allMatch) {
|
|
753
|
+
suppressedBy = route;
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
let active;
|
|
758
|
+
if (suppressedBy) {
|
|
759
|
+
active = triggered.filter((r) => r === suppressedBy);
|
|
760
|
+
}
|
|
761
|
+
else {
|
|
762
|
+
active = triggered.filter((r) => !r.exclusive);
|
|
763
|
+
}
|
|
764
|
+
// Step 3 — read per-SHA evidence and evaluate each active route.
|
|
765
|
+
const evidence = await readPerShaEvidence(loaded, sha);
|
|
766
|
+
const perRoute = [];
|
|
767
|
+
for (const route of active) {
|
|
768
|
+
if (route.command === null) {
|
|
769
|
+
// Suppression-only routes have no command; if they're "active"
|
|
770
|
+
// (i.e., suppressedBy === route), there is nothing to enforce —
|
|
771
|
+
// the suppression itself is the gate decision.
|
|
772
|
+
perRoute.push({
|
|
773
|
+
routeId: route.id,
|
|
774
|
+
status: "skipped",
|
|
775
|
+
detail: "suppression-only route (no command to enforce)",
|
|
776
|
+
});
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
const gateResult = evidence?.gateResults?.[route.id];
|
|
780
|
+
if (!gateResult) {
|
|
781
|
+
perRoute.push({
|
|
782
|
+
routeId: route.id,
|
|
783
|
+
status: "missing",
|
|
784
|
+
detail: `no evidence at agent-reviews/quality-gates/${sha}.json for route "${route.id}"; run \`agent-review gates --route ${route.id}\` or \`${route.command}\` followed by \`agent-review gates --commit ${sha.slice(0, 12)}\``,
|
|
785
|
+
});
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
if (gateResult.exitCode !== 0) {
|
|
789
|
+
perRoute.push({
|
|
790
|
+
routeId: route.id,
|
|
791
|
+
status: "failed",
|
|
792
|
+
exitCode: gateResult.exitCode,
|
|
793
|
+
detail: `route "${route.id}" command exited ${gateResult.exitCode}`,
|
|
794
|
+
});
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
perRoute.push({
|
|
798
|
+
routeId: route.id,
|
|
799
|
+
status: "ok",
|
|
800
|
+
exitCode: 0,
|
|
801
|
+
detail: `route "${route.id}" passed`,
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
return {
|
|
805
|
+
triggered,
|
|
806
|
+
active,
|
|
807
|
+
...(suppressedBy !== undefined ? { suppressedBy } : {}),
|
|
808
|
+
perRoute,
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
async function readPerShaEvidence(loaded, sha) {
|
|
812
|
+
const root = await resolveArtifactRoot(loaded);
|
|
813
|
+
const path = perShaQualityGatePath(root, loaded.config.git.artifactDir, sha);
|
|
814
|
+
if (!existsSync(path))
|
|
815
|
+
return null;
|
|
816
|
+
let evidence;
|
|
817
|
+
try {
|
|
818
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
819
|
+
evidence = parseQualityGateEvidence(raw);
|
|
820
|
+
}
|
|
821
|
+
catch {
|
|
822
|
+
return null;
|
|
823
|
+
}
|
|
824
|
+
// Bind the parsed `commit` field to the SHA being gated. Matches the
|
|
825
|
+
// tighter read semantics in `quality-gates.ts:readEvidenceAtPath` and
|
|
826
|
+
// closes the integrity gap the critic flagged: a hand-edited file at
|
|
827
|
+
// `<sha>.json` that names a different commit would otherwise satisfy
|
|
828
|
+
// verificationRoutes without representing the gated commit.
|
|
829
|
+
if (evidence.commit !== sha)
|
|
830
|
+
return null;
|
|
831
|
+
return evidence;
|
|
832
|
+
}
|
|
833
|
+
export async function runTddClassifier(options) {
|
|
834
|
+
const { loaded, sha } = options;
|
|
835
|
+
const cwd = options.cwd ?? loaded.repoRoot;
|
|
836
|
+
if (!loaded.config.tdd)
|
|
837
|
+
return null;
|
|
838
|
+
const parent = await safeParent(sha, cwd);
|
|
839
|
+
const files = await changedFiles(parent, sha, cwd, { readContent: false });
|
|
840
|
+
const meta = await commitMetadata(sha, cwd);
|
|
841
|
+
const fullMessage = meta.body
|
|
842
|
+
? `${meta.subject}\n\n${meta.body}`
|
|
843
|
+
: meta.subject;
|
|
844
|
+
const trailers = parseCommitTrailers(fullMessage);
|
|
845
|
+
const classifierConfig = {
|
|
846
|
+
productionGlobs: loaded.config.tdd.classifier.productionGlobs,
|
|
847
|
+
testGlobs: loaded.config.tdd.classifier.testGlobs,
|
|
848
|
+
exclusionGlobs: loaded.config.tdd.classifier.exclusionGlobs,
|
|
849
|
+
justificationTrailer: loaded.config.tdd.classifier.justificationTrailer,
|
|
850
|
+
};
|
|
851
|
+
// Include both `path` (new) and `oldPath` (rename/copy source) so a
|
|
852
|
+
// rename out of a production glob still routes through the classifier.
|
|
853
|
+
// Centralized in `collectChangedPaths` (see evidence.ts).
|
|
854
|
+
return classifyTddImpl(collectChangedPaths(files), trailers, classifierConfig);
|
|
855
|
+
}
|
|
856
|
+
export function enforceFindingRubric(findings, context) {
|
|
857
|
+
const blockingSeverities = context.loaded.config.aggregation.blockingSeverities;
|
|
858
|
+
const v2 = context.loaded.config.version === 2;
|
|
859
|
+
const kept = [];
|
|
860
|
+
const stripped = [];
|
|
861
|
+
for (const f of findings) {
|
|
862
|
+
// Below-threshold severities are never stripped by the rubric.
|
|
863
|
+
if (!blockingSeverities.includes(f.severity)) {
|
|
864
|
+
kept.push(f);
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
// v1 configs keep the old behavior (no rubric enforcement) so
|
|
868
|
+
// downstream repos can still use the schema. Only v2 enforces.
|
|
869
|
+
if (!v2) {
|
|
870
|
+
kept.push(f);
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
const verdict = evaluateFindingRubric(f, context);
|
|
874
|
+
if (verdict.kept) {
|
|
875
|
+
kept.push(f);
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
stripped.push({ finding: f, reason: verdict.reason });
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
if (stripped.length > 0) {
|
|
882
|
+
void logRubricStrips(context, stripped);
|
|
883
|
+
}
|
|
884
|
+
return { kept, stripped };
|
|
885
|
+
}
|
|
886
|
+
function evaluateFindingRubric(finding, context) {
|
|
887
|
+
// (1) Gate-failure evidence: all three of the following must hold:
|
|
888
|
+
// (a) `evidencePath` points at the canonical per-SHA artifact for
|
|
889
|
+
// the gated SHA (full or short suffix)
|
|
890
|
+
// (b) `routeId` names a specific route
|
|
891
|
+
// (c) `evidence.gateResults[routeId].exitCode !== 0`
|
|
892
|
+
// Without (b)+(c), the finding could ride on any failing gate in the
|
|
893
|
+
// file — closing the loopholes the 729a646c and 244464aa critics flagged.
|
|
894
|
+
if (finding.evidencePath &&
|
|
895
|
+
finding.routeId &&
|
|
896
|
+
context.evidence?.gateResults) {
|
|
897
|
+
if (evidencePathMatchesSha(finding.evidencePath, context.sha) ||
|
|
898
|
+
evidencePathMatchesSha(finding.evidencePath, context.sha.slice(0, 12))) {
|
|
899
|
+
const namedRoute = context.evidence.gateResults[finding.routeId];
|
|
900
|
+
if (namedRoute && namedRoute.exitCode !== 0) {
|
|
901
|
+
return {
|
|
902
|
+
kept: true,
|
|
903
|
+
reason: `evidencePath+routeId bound to ${context.sha.slice(0, 12)}; gateResults["${finding.routeId}"].exitCode=${namedRoute.exitCode}`,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// (2) File + line + evidence — the spec-defined shape of an ordinary
|
|
909
|
+
// code-review finding (cycle 318.2 §Component 5 + §Exit Criteria). All
|
|
910
|
+
// three are required: file (already required by the schema parser for
|
|
911
|
+
// blocker severities), a concrete line number, and an evidence string.
|
|
912
|
+
// Findings that name a location without pointing at a specific line
|
|
913
|
+
// fall back to evidence_path or justification — this is what keeps the
|
|
914
|
+
// prompt rubric and harness rubric in lock-step (closes the spec drift
|
|
915
|
+
// the local critic flagged on 71c5b3c7).
|
|
916
|
+
if (finding.file &&
|
|
917
|
+
finding.file.trim().length > 0 &&
|
|
918
|
+
finding.line !== undefined &&
|
|
919
|
+
finding.evidence.trim().length > 0) {
|
|
920
|
+
return { kept: true, reason: "file+line+evidence (code-review finding shape)" };
|
|
921
|
+
}
|
|
922
|
+
// (3) Per-finding `justification` — the critic must populate this
|
|
923
|
+
// with the VALUE of a recognized commit trailer (Tdd-Justification,
|
|
924
|
+
// Evidence, Migration-Justification, or the config's
|
|
925
|
+
// `tdd.classifier.justificationTrailer`). The rubric verifies the
|
|
926
|
+
// value actually appears in `context.trailers`, so the critic can't
|
|
927
|
+
// invent a free-form justification string — there has to be a real
|
|
928
|
+
// human-supplied trailer behind it. Closes the loophole the 67b9ecd7
|
|
929
|
+
// critic flagged.
|
|
930
|
+
if (finding.justification && finding.justification.trim().length > 0) {
|
|
931
|
+
const recognizedTrailers = new Set([
|
|
932
|
+
"tdd-justification",
|
|
933
|
+
"evidence",
|
|
934
|
+
"migration-justification",
|
|
935
|
+
]);
|
|
936
|
+
const customTrailer = context.loaded.config.tdd?.classifier.justificationTrailer;
|
|
937
|
+
if (customTrailer)
|
|
938
|
+
recognizedTrailers.add(customTrailer.toLowerCase());
|
|
939
|
+
const justification = finding.justification.trim();
|
|
940
|
+
for (const key of recognizedTrailers) {
|
|
941
|
+
const value = context.trailers.trailers[key];
|
|
942
|
+
if (value && value.trim() === justification) {
|
|
943
|
+
return {
|
|
944
|
+
kept: true,
|
|
945
|
+
reason: `per-finding justification matches commit trailer ${key}`,
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
// Justification text present but does NOT match any recognized
|
|
950
|
+
// trailer value — fall through to the strip path. The critic
|
|
951
|
+
// attached prose but no auditable human override is on the commit.
|
|
952
|
+
}
|
|
953
|
+
return {
|
|
954
|
+
kept: false,
|
|
955
|
+
reason: "no evidencePath+routeId matching gate failure, no file+line+evidence, no per-finding justification backed by a recognized commit trailer",
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
// Does the finding's `evidencePath` string anchor to the gated SHA?
|
|
959
|
+
// We accept three shapes:
|
|
960
|
+
// - exact suffix `<sha>.json` (full sha)
|
|
961
|
+
// - exact suffix `<short-sha>.json` (when caller passes a 12-char prefix)
|
|
962
|
+
// - the path is a substring containing the sha — guards against a critic
|
|
963
|
+
// that writes a fully-qualified absolute path
|
|
964
|
+
// The check is intentionally generous on path separators (Windows-style
|
|
965
|
+
// or slash-mixed) but strict on the SHA itself.
|
|
966
|
+
function evidencePathMatchesSha(evidencePath, sha) {
|
|
967
|
+
if (!sha)
|
|
968
|
+
return false;
|
|
969
|
+
const needle = `${sha}.json`;
|
|
970
|
+
// Allow both forward and back slashes in the path; the path may be
|
|
971
|
+
// relative ("agent-reviews/quality-gates/<sha>.json") or absolute.
|
|
972
|
+
return evidencePath.includes(needle);
|
|
973
|
+
}
|
|
974
|
+
async function logRubricStrips(context, stripped) {
|
|
975
|
+
try {
|
|
976
|
+
const dir = await resolveArtifactDir(context.loaded);
|
|
977
|
+
mkdirSync(dir, { recursive: true });
|
|
978
|
+
const path = telemetryPath(dir);
|
|
979
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
980
|
+
for (const s of stripped) {
|
|
981
|
+
const event = {
|
|
982
|
+
ts: new Date().toISOString(),
|
|
983
|
+
event: "rubric_strip",
|
|
984
|
+
commit: context.sha,
|
|
985
|
+
severity: s.finding.severity,
|
|
986
|
+
category: s.finding.category,
|
|
987
|
+
file: s.finding.file ?? null,
|
|
988
|
+
line: s.finding.line ?? null,
|
|
989
|
+
reason: s.reason,
|
|
990
|
+
manifesto_section: s.finding.manifestoSection ?? null,
|
|
991
|
+
};
|
|
992
|
+
appendFileSync(path, `${JSON.stringify(event)}\n`, "utf8");
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
catch {
|
|
996
|
+
// Telemetry writes are best-effort; do not fail the gate because
|
|
997
|
+
// we couldn't log a strip event.
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
export async function buildRubricContext(options) {
|
|
1001
|
+
const { loaded, sha } = options;
|
|
1002
|
+
const cwd = options.cwd ?? loaded.repoRoot;
|
|
1003
|
+
const meta = await commitMetadata(sha, cwd);
|
|
1004
|
+
const fullMessage = meta.body
|
|
1005
|
+
? `${meta.subject}\n\n${meta.body}`
|
|
1006
|
+
: meta.subject;
|
|
1007
|
+
const trailers = parseCommitTrailers(fullMessage);
|
|
1008
|
+
const evidence = await readPerShaEvidence(loaded, sha);
|
|
1009
|
+
return { loaded, sha, trailers, evidence };
|
|
1010
|
+
}
|
|
1011
|
+
// Re-export the per-SHA path helper from `evidence.ts` so callers can
|
|
1012
|
+
// import a single boundary — this matches the cycle-318.2 doc's mental
|
|
1013
|
+
// model of `gate.ts` as the canonical surface for gate-time decisions.
|
|
1014
|
+
export { perShaQualityGatePath, parseCommitTrailers };
|
|
1015
|
+
// Keep `resolve` referenced so bundlers don't strip it from the runtime
|
|
1016
|
+
// import — some downstream tooling depends on this module re-exporting
|
|
1017
|
+
// the same node:path helpers as the previous version.
|
|
1018
|
+
void resolve;
|
|
1019
|
+
//# sourceMappingURL=gate.js.map
|