@enwithai/product-check 0.2.0 → 0.3.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/ATTRIBUTION.md +4 -4
- package/CHANGELOG.md +28 -0
- package/README.md +29 -16
- package/SECURITY.md +5 -1
- package/dashboard/assurance.css +71 -0
- package/dashboard/assurance.js +21 -0
- package/dist/assurance/adapters.d.ts +32 -0
- package/dist/assurance/adapters.js +76 -0
- package/dist/assurance/approvals.d.ts +34 -0
- package/dist/assurance/approvals.js +133 -0
- package/dist/assurance/cli.d.ts +13 -0
- package/dist/assurance/cli.js +863 -0
- package/dist/assurance/dashboard.d.ts +18 -0
- package/dist/assurance/dashboard.js +267 -0
- package/dist/assurance/diff.d.ts +20 -0
- package/dist/assurance/diff.js +99 -0
- package/dist/assurance/evidence-log.d.ts +20 -0
- package/dist/assurance/evidence-log.js +100 -0
- package/dist/assurance/fix-packets.d.ts +12 -0
- package/dist/assurance/fix-packets.js +178 -0
- package/dist/assurance/gate.d.ts +37 -0
- package/dist/assurance/gate.js +324 -0
- package/dist/assurance/lanes.d.ts +61 -0
- package/dist/assurance/lanes.js +380 -0
- package/dist/assurance/policy.d.ts +10 -0
- package/dist/assurance/policy.js +85 -0
- package/dist/assurance/proof.d.ts +35 -0
- package/dist/assurance/proof.js +146 -0
- package/dist/assurance/register.d.ts +41 -0
- package/dist/assurance/register.js +480 -0
- package/dist/assurance/render-md.d.ts +17 -0
- package/dist/assurance/render-md.js +212 -0
- package/dist/assurance/repo-state.d.ts +15 -0
- package/dist/assurance/repo-state.js +79 -0
- package/dist/assurance/review.d.ts +52 -0
- package/dist/assurance/review.js +408 -0
- package/dist/assurance/risk.d.ts +21 -0
- package/dist/assurance/risk.js +184 -0
- package/dist/assurance/types.d.ts +573 -0
- package/dist/assurance/types.js +6 -0
- package/dist/assurance/util.d.ts +24 -0
- package/dist/assurance/util.js +89 -0
- package/dist/assurance/verification.d.ts +30 -0
- package/dist/assurance/verification.js +212 -0
- package/dist/assurance/verify.d.ts +26 -0
- package/dist/assurance/verify.js +304 -0
- package/dist/cli/run.js +8 -1
- package/dist/exec/process.d.ts +27 -0
- package/dist/exec/process.js +175 -0
- package/dist/reporting/markdown.d.ts +1 -0
- package/dist/reporting/markdown.js +1 -1
- package/dist/schema/registry.js +9 -0
- package/package.json +1 -1
- package/schemas/approval.schema.json +98 -0
- package/schemas/assurance-common.schema.json +291 -0
- package/schemas/assurance-policy.schema.json +185 -0
- package/schemas/change-review.schema.json +711 -0
- package/schemas/closure-register.schema.json +499 -0
- package/schemas/evidence-record.schema.json +94 -0
- package/schemas/fix-packet.schema.json +272 -0
- package/schemas/release-gate.schema.json +308 -0
- package/schemas/reviewer-findings.schema.json +49 -0
- package/schemas/verification-run.schema.json +199 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 EnWithAI Technologies
|
|
3
|
+
/**
|
|
4
|
+
* Change review (VC-P2 layered review + VC-P3 risk-routed gate) for PR_DIFF and PRE_PR_LOCAL modes.
|
|
5
|
+
*
|
|
6
|
+
* diff → risk classification → six independent lanes → independent verification of every hit and external
|
|
7
|
+
* claim → de-duplication (rule, component, root-cause signature, impact scope) → repeat detection against the
|
|
8
|
+
* previous cycle → approval routing → change gate (PASS / CONDITIONAL / BLOCK / NOT_EVALUATED).
|
|
9
|
+
*
|
|
10
|
+
* A change PASS never implies whole-product readiness: that is the baseline scan's and the release gate's job.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { join, resolve, sep } from "node:path";
|
|
14
|
+
import { sha256, stableId } from "../util/hash.js";
|
|
15
|
+
import { redact } from "../util/redact.js";
|
|
16
|
+
import { TOOL_VERSION } from "../version.js";
|
|
17
|
+
import { tallyApprovals } from "./approvals.js";
|
|
18
|
+
import { changeHits, fileHits, lineHits } from "./lanes.js";
|
|
19
|
+
import { approvalsRequired, classifyRisk } from "./risk.js";
|
|
20
|
+
import { ASSURANCE_SCHEMA_VERSION, METHODOLOGY_VERSION, } from "./types.js";
|
|
21
|
+
import { isBlocking, recheckHit, verifyClaim, verifyHit } from "./verification.js";
|
|
22
|
+
const LANES = [
|
|
23
|
+
{ lane: "spec-outcome", reviewer: "spec-outcome-lane" },
|
|
24
|
+
{ lane: "correctness", reviewer: "correctness-lane" },
|
|
25
|
+
{ lane: "impact", reviewer: "impact-lane" },
|
|
26
|
+
{ lane: "trust-security", reviewer: "trust-security-lane" },
|
|
27
|
+
{ lane: "experience", reviewer: "experience-lane" },
|
|
28
|
+
{ lane: "operations", reviewer: "operations-lane" },
|
|
29
|
+
];
|
|
30
|
+
const component = (path) => path.split("/").slice(0, 2).join("/");
|
|
31
|
+
/** Dedupe key from rule, component, root-cause signature and impact scope (the signature itself is not stored). */
|
|
32
|
+
export const dedupeKey = (h) => sha256([h.rule_id, component(h.path), h.signature, h.lane].join("\u001f")).slice(0, 20);
|
|
33
|
+
/** Runs a change review. */
|
|
34
|
+
export function reviewChange(input) {
|
|
35
|
+
const { policy, files } = input;
|
|
36
|
+
const limitations = [];
|
|
37
|
+
const author = {
|
|
38
|
+
...input.author,
|
|
39
|
+
trusted: input.author.id !== null && policy.risk.trusted_authors.includes(input.author.id),
|
|
40
|
+
};
|
|
41
|
+
const { changed, risk, owners } = classifyRisk(files, policy, input.impact);
|
|
42
|
+
const required = approvalsRequired(risk, owners, policy, author);
|
|
43
|
+
// Lanes -------------------------------------------------------------------------------------------------------
|
|
44
|
+
const heads = new Map();
|
|
45
|
+
const head = (p) => {
|
|
46
|
+
if (!heads.has(p))
|
|
47
|
+
heads.set(p, input.headText(p));
|
|
48
|
+
return heads.get(p) ?? null;
|
|
49
|
+
};
|
|
50
|
+
const hits = [];
|
|
51
|
+
for (const f of files) {
|
|
52
|
+
if (f.status === "binary" || f.status === "deleted") {
|
|
53
|
+
if (f.status === "deleted")
|
|
54
|
+
hits.push(...fileHits(f, null));
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
hits.push(...lineHits(f.path, f.lines.filter((l) => l.kind === "add")));
|
|
58
|
+
hits.push(...fileHits(f, head(f.path)));
|
|
59
|
+
}
|
|
60
|
+
hits.push(...changeHits(files));
|
|
61
|
+
// Verification and de-duplication -----------------------------------------------------------------------------
|
|
62
|
+
const verifiedClaims = input.claims.map((c) => ({ claim: c, v: verifyClaim(c, head(c.path), hits) }));
|
|
63
|
+
const signals = [
|
|
64
|
+
...hits.map((h) => ({ path: h.path, line: h.line, source: `${h.rule_id}` })),
|
|
65
|
+
...verifiedClaims
|
|
66
|
+
.filter((c) => c.v.state !== "DISPROVEN")
|
|
67
|
+
.map((c) => ({
|
|
68
|
+
path: c.claim.path,
|
|
69
|
+
line: c.claim.line,
|
|
70
|
+
source: `${c.claim.reviewer} (${c.claim.type})`,
|
|
71
|
+
})),
|
|
72
|
+
];
|
|
73
|
+
const byKey = new Map();
|
|
74
|
+
const disproven = [];
|
|
75
|
+
const prevKeys = new Set(input.previous?.findings.map((f) => f.dedupe_key) ?? []);
|
|
76
|
+
const failClosed = policy.review.fail_closed_unverified_p0;
|
|
77
|
+
for (const h of hits) {
|
|
78
|
+
const key = dedupeKey(h);
|
|
79
|
+
const existing = byKey.get(key);
|
|
80
|
+
if (existing) {
|
|
81
|
+
existing.occurrences++;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const file = files.find((f) => f.path === h.path);
|
|
85
|
+
const recheck = recheckHit(h, head(h.path), file);
|
|
86
|
+
const others = signals.filter((s) => s.source !== h.rule_id);
|
|
87
|
+
const v = verifyHit(h, recheck, others);
|
|
88
|
+
const evidenceId = input.log.append({
|
|
89
|
+
type: "diff-hunk",
|
|
90
|
+
subject: key,
|
|
91
|
+
commit_sha: input.headSha,
|
|
92
|
+
environment_id: null,
|
|
93
|
+
summary: `${h.rule_id} at ${h.path}${h.line !== null ? `:${h.line}` : ""}`,
|
|
94
|
+
locator: h.path,
|
|
95
|
+
line: h.line,
|
|
96
|
+
payload: { rule_id: h.rule_id, excerpt: redact(h.excerpt), checks: v.checks },
|
|
97
|
+
});
|
|
98
|
+
const finding = {
|
|
99
|
+
id: stableId("RF", [key]),
|
|
100
|
+
rule_id: h.rule_id,
|
|
101
|
+
lane: h.lane,
|
|
102
|
+
reviewer: { id: `${h.lane}-lane`, type: "deterministic", version: TOOL_VERSION },
|
|
103
|
+
title: h.title,
|
|
104
|
+
severity: h.severity,
|
|
105
|
+
pillars: h.pillars,
|
|
106
|
+
location: { path: h.path, line: h.line, runtime: null },
|
|
107
|
+
expected: h.expected,
|
|
108
|
+
actual: redact(h.actual),
|
|
109
|
+
steps_or_command: [
|
|
110
|
+
input.baseSha && input.headSha
|
|
111
|
+
? `git diff ${input.baseSha.slice(0, 12)}...${input.headSha.slice(0, 12)} -- ${h.path}`
|
|
112
|
+
: `inspect ${h.path}${h.line !== null ? `:${h.line}` : ""}`,
|
|
113
|
+
],
|
|
114
|
+
evidence_ids: [evidenceId],
|
|
115
|
+
confidence: h.confidence,
|
|
116
|
+
epistemic: h.epistemic,
|
|
117
|
+
impact: h.impact,
|
|
118
|
+
affected_scope: `${component(h.path)} (${h.pillars.join(", ")})`,
|
|
119
|
+
baseline_sha: input.baseSha,
|
|
120
|
+
head_sha: input.headSha,
|
|
121
|
+
uncertainty: h.epistemic === "hypothesis"
|
|
122
|
+
? "Pattern heuristic; needs a second signal or human triage before it can block."
|
|
123
|
+
: null,
|
|
124
|
+
verification: v,
|
|
125
|
+
dedupe_key: key,
|
|
126
|
+
occurrences: 1,
|
|
127
|
+
repeat_of_review: prevKeys.has(key) ? (input.previous?.review_id ?? null) : null,
|
|
128
|
+
blocking: isBlocking(h.severity, v.state, failClosed),
|
|
129
|
+
};
|
|
130
|
+
if (v.state === "DISPROVEN")
|
|
131
|
+
disproven.push({ ...finding, blocking: false });
|
|
132
|
+
else
|
|
133
|
+
byKey.set(key, finding);
|
|
134
|
+
}
|
|
135
|
+
for (const { claim, v } of verifiedClaims) {
|
|
136
|
+
const key = sha256(["claim", claim.reviewer, claim.path, String(claim.line), claim.title].join("\u001f")).slice(0, 20);
|
|
137
|
+
const evidenceId = input.log.append({
|
|
138
|
+
type: "reviewer-claim",
|
|
139
|
+
subject: key,
|
|
140
|
+
commit_sha: input.headSha,
|
|
141
|
+
environment_id: null,
|
|
142
|
+
summary: `${claim.reviewer} (${claim.type}): ${claim.title}`,
|
|
143
|
+
locator: claim.path,
|
|
144
|
+
line: claim.line,
|
|
145
|
+
payload: {
|
|
146
|
+
title: claim.title,
|
|
147
|
+
body: claim.body.slice(0, 500),
|
|
148
|
+
severity: claim.severity,
|
|
149
|
+
checks: v.checks,
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
const f = {
|
|
153
|
+
id: stableId("RF", [key]),
|
|
154
|
+
rule_id: "VC-EXT-CLAIM",
|
|
155
|
+
lane: "correctness",
|
|
156
|
+
reviewer: { id: claim.reviewer, type: claim.type, version: "external" },
|
|
157
|
+
title: claim.title,
|
|
158
|
+
severity: claim.severity,
|
|
159
|
+
pillars: ["technology"],
|
|
160
|
+
location: { path: claim.path, line: claim.line, runtime: null },
|
|
161
|
+
expected: "(as stated by the reviewer)",
|
|
162
|
+
actual: redact(claim.body).slice(0, 500),
|
|
163
|
+
steps_or_command: [`inspect ${claim.path}${claim.line !== null ? `:${claim.line}` : ""}`],
|
|
164
|
+
evidence_ids: [evidenceId],
|
|
165
|
+
confidence: "LOW",
|
|
166
|
+
epistemic: "hypothesis",
|
|
167
|
+
impact: "As claimed; not machine-checked.",
|
|
168
|
+
affected_scope: component(claim.path),
|
|
169
|
+
baseline_sha: input.baseSha,
|
|
170
|
+
head_sha: input.headSha,
|
|
171
|
+
uncertainty: "External reviewer claim; treated as a proposal, never as a human approval or proven defect.",
|
|
172
|
+
verification: v,
|
|
173
|
+
dedupe_key: key,
|
|
174
|
+
occurrences: 1,
|
|
175
|
+
repeat_of_review: prevKeys.has(key) ? (input.previous?.review_id ?? null) : null,
|
|
176
|
+
blocking: isBlocking(claim.severity, v.state, false),
|
|
177
|
+
};
|
|
178
|
+
if (v.state === "DISPROVEN")
|
|
179
|
+
disproven.push(f);
|
|
180
|
+
else if (!byKey.has(key))
|
|
181
|
+
byKey.set(key, f);
|
|
182
|
+
}
|
|
183
|
+
// Spec Check gaps that touch changed files (imported facts from a separate tool; advisory).
|
|
184
|
+
if (input.spec) {
|
|
185
|
+
for (const g of input.spec.gaps.filter((x) => x.path !== null && changed.some((c) => c.path === x.path))) {
|
|
186
|
+
const key = sha256(["spec", g.id].join("\u001f")).slice(0, 20);
|
|
187
|
+
byKey.set(key, {
|
|
188
|
+
id: stableId("RF", [key]),
|
|
189
|
+
rule_id: "VC-SP-SPEC-GAP",
|
|
190
|
+
lane: "spec-outcome",
|
|
191
|
+
reviewer: { id: "spec-check", type: "imported", version: "1.0" },
|
|
192
|
+
title: `Changed spec has an unresolved gap: ${g.summary}`,
|
|
193
|
+
severity: "P2",
|
|
194
|
+
pillars: ["customer"],
|
|
195
|
+
location: { path: g.path, line: null, runtime: null },
|
|
196
|
+
expected: "Changed requirements are clear and verifiable before the change ships.",
|
|
197
|
+
actual: `Spec Check ${g.severity}: ${g.summary}`,
|
|
198
|
+
steps_or_command: ["spec-check check --format json"],
|
|
199
|
+
evidence_ids: [],
|
|
200
|
+
confidence: "MEDIUM",
|
|
201
|
+
epistemic: "fact",
|
|
202
|
+
impact: "Acceptance cannot be verified against an unclear requirement.",
|
|
203
|
+
affected_scope: g.path,
|
|
204
|
+
baseline_sha: input.baseSha,
|
|
205
|
+
head_sha: input.headSha,
|
|
206
|
+
uncertainty: null,
|
|
207
|
+
verification: {
|
|
208
|
+
state: "EVIDENCE_COLLECTED",
|
|
209
|
+
checks: [{ check: "evidence-present", result: "pass", detail: `Spec Check finding ${g.id}.` }],
|
|
210
|
+
},
|
|
211
|
+
dedupe_key: key,
|
|
212
|
+
occurrences: 1,
|
|
213
|
+
repeat_of_review: prevKeys.has(key) ? (input.previous?.review_id ?? null) : null,
|
|
214
|
+
blocking: false,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const findings = [...byKey.values()].sort((a, b) => a.severity === b.severity ? (a.id < b.id ? -1 : 1) : a.severity < b.severity ? -1 : 1);
|
|
219
|
+
// Lane summaries -----------------------------------------------------------------------------------------------
|
|
220
|
+
const prodFiles = changed.filter((c) => !c.surfaces.includes("docs"));
|
|
221
|
+
const ui = changed.filter((c) => /\.(jsx|tsx|html|vue|svelte)$/.test(c.path));
|
|
222
|
+
const lanes = LANES.map(({ lane, reviewer }) => {
|
|
223
|
+
const mine = findings.filter((f) => f.lane === lane);
|
|
224
|
+
const notes = [];
|
|
225
|
+
let applicable = prodFiles.length > 0;
|
|
226
|
+
if (lane === "experience")
|
|
227
|
+
applicable = ui.length > 0;
|
|
228
|
+
if (lane === "spec-outcome") {
|
|
229
|
+
if (!input.spec && !input.outcome)
|
|
230
|
+
return {
|
|
231
|
+
lane,
|
|
232
|
+
reviewer,
|
|
233
|
+
status: "NOT_TESTED",
|
|
234
|
+
findings: 0,
|
|
235
|
+
notes: [
|
|
236
|
+
"No Spec Check or Outcome Check report supplied (--spec-check, --outcome-check); intent vs outcome not reviewed.",
|
|
237
|
+
],
|
|
238
|
+
};
|
|
239
|
+
if (input.spec)
|
|
240
|
+
notes.push(`Spec Check: ${input.spec.requirements.length} requirement(s), ${input.spec.gaps.length} open gap(s).`);
|
|
241
|
+
if (input.outcome)
|
|
242
|
+
notes.push(`Outcome Check: CI first-pass rate ${input.outcome.ci_first_pass_rate ?? "n/a"}, revert rate ${input.outcome.revert_rate ?? "n/a"} (delivery signal, not a product score).`);
|
|
243
|
+
applicable = true;
|
|
244
|
+
}
|
|
245
|
+
if (lane === "impact") {
|
|
246
|
+
notes.push(input.impact
|
|
247
|
+
? `Impact Check 2.0: ${input.impact.affected_modules} affected module(s), ${input.impact.high} high-severity impact(s).`
|
|
248
|
+
: `Blast radius from paths only (no Impact Check report): ${changed.length} file(s), surfaces ${risk.surfaces.join(", ")}.`);
|
|
249
|
+
return {
|
|
250
|
+
lane,
|
|
251
|
+
reviewer,
|
|
252
|
+
status: changed.length === 0
|
|
253
|
+
? "NOT_TESTED"
|
|
254
|
+
: input.impact
|
|
255
|
+
? input.impact.high > 0
|
|
256
|
+
? "PARTIAL"
|
|
257
|
+
: "PASS"
|
|
258
|
+
: "PARTIAL",
|
|
259
|
+
findings: 0,
|
|
260
|
+
notes,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
if (!applicable)
|
|
264
|
+
return {
|
|
265
|
+
lane,
|
|
266
|
+
reviewer,
|
|
267
|
+
status: "NOT_APPLICABLE",
|
|
268
|
+
findings: 0,
|
|
269
|
+
notes: ["No files in this lane's scope changed."],
|
|
270
|
+
};
|
|
271
|
+
notes.push(`Ran over ${lane === "experience" ? ui.length : prodFiles.length} changed file(s).`);
|
|
272
|
+
const status = mine.some((f) => f.blocking) ? "FAIL" : mine.length > 0 ? "PARTIAL" : "PASS";
|
|
273
|
+
return { lane, reviewer, status, findings: mine.length, notes };
|
|
274
|
+
});
|
|
275
|
+
// Checks, approvals, cycles, gate ---------------------------------------------------------------------------------
|
|
276
|
+
let checks;
|
|
277
|
+
const proof = input.proofRun;
|
|
278
|
+
if (policy.required_checks.length === 0)
|
|
279
|
+
checks = {
|
|
280
|
+
proof_run_id: null,
|
|
281
|
+
status: "NOT_TESTED",
|
|
282
|
+
detail: "The policy declares no required checks; none can be claimed as green.",
|
|
283
|
+
};
|
|
284
|
+
else if (!proof)
|
|
285
|
+
checks = {
|
|
286
|
+
proof_run_id: null,
|
|
287
|
+
status: "NOT_TESTED",
|
|
288
|
+
detail: "No author-side proof run (product-check proof) for this change.",
|
|
289
|
+
};
|
|
290
|
+
else if (proof.exact_sha !== input.headSha || input.headSha === null)
|
|
291
|
+
checks = {
|
|
292
|
+
proof_run_id: proof.verification_id,
|
|
293
|
+
status: "NOT_TESTED",
|
|
294
|
+
detail: `The proof run is for ${proof.exact_sha?.slice(0, 12) ?? "an unknown commit"}, not the head ${input.headSha?.slice(0, 12) ?? "(worktree)"}.`,
|
|
295
|
+
};
|
|
296
|
+
else
|
|
297
|
+
checks = {
|
|
298
|
+
proof_run_id: proof.verification_id,
|
|
299
|
+
status: proof.result === "PASS" ? "PASS" : proof.result === "FAIL" ? "FAIL" : "NOT_TESTED",
|
|
300
|
+
detail: `Proof run ${proof.verification_id}: ${proof.result} (${proof.executions.filter((e) => e.status === "PASS").length}/${proof.executions.length} commands passed).`,
|
|
301
|
+
};
|
|
302
|
+
const tally = tallyApprovals(input.approvals, policy, {
|
|
303
|
+
scopeType: "change",
|
|
304
|
+
ref: input.changeRef,
|
|
305
|
+
sha: input.headSha,
|
|
306
|
+
excluded: input.author.id ? [input.author.id] : [],
|
|
307
|
+
}, required.humans, owners);
|
|
308
|
+
const cycleNo = (input.previous?.cycle.number ?? 0) + 1;
|
|
309
|
+
const repeated = findings.filter((f) => f.repeat_of_review !== null).map((f) => f.id);
|
|
310
|
+
const escalated = cycleNo > policy.review.max_cycles && findings.some((f) => f.blocking || f.repeat_of_review !== null);
|
|
311
|
+
const blockingIds = findings.filter((f) => f.blocking).map((f) => f.id);
|
|
312
|
+
const reasons = [];
|
|
313
|
+
let status;
|
|
314
|
+
if (blockingIds.length > 0)
|
|
315
|
+
reasons.push(`${blockingIds.length} verified (or fail-closed P0) blocking finding(s).`);
|
|
316
|
+
if (checks.status === "FAIL")
|
|
317
|
+
reasons.push("Required checks failed.");
|
|
318
|
+
if (escalated)
|
|
319
|
+
reasons.push(`Review cycle ${cycleNo} exceeds the limit of ${policy.review.max_cycles}; stopped and escalated for a human design/root-cause decision.`);
|
|
320
|
+
if (tally.evaluations.some((e) => e.status === "REJECTED"))
|
|
321
|
+
reasons.push("A required reviewer rejected the change.");
|
|
322
|
+
if (reasons.length > 0)
|
|
323
|
+
status = "BLOCK";
|
|
324
|
+
else if (checks.status !== "PASS") {
|
|
325
|
+
status = "NOT_EVALUATED";
|
|
326
|
+
reasons.push(`Mandatory checks not evidenced: ${checks.detail}`);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
if (!tally.satisfied)
|
|
330
|
+
reasons.push(`Approvals: ${tally.validApprovers.length}/${required.humans} valid human approval(s)${tally.stale ? `, ${tally.stale} stale` : ""}${tally.missingOwners.length ? `; domain owner(s) missing: ${tally.missingOwners.join(", ")}` : ""}.`);
|
|
331
|
+
const triage = findings.filter((f) => f.verification.state === "NEEDS_HUMAN_TRIAGE");
|
|
332
|
+
if (triage.length > 0)
|
|
333
|
+
reasons.push(`${triage.length} reviewer finding(s) need human triage (not proven defects).`);
|
|
334
|
+
status = reasons.length > 0 ? "CONDITIONAL" : "PASS";
|
|
335
|
+
if (status === "PASS")
|
|
336
|
+
reasons.push("Checks green, no verified blocking finding, approvals valid for the head commit.");
|
|
337
|
+
}
|
|
338
|
+
if (input.diffSource === "file")
|
|
339
|
+
limitations.push("Diff supplied as a file: head content could not be re-read, so line rules stay EVIDENCE_COLLECTED and approvals cannot be bound to a verified commit.");
|
|
340
|
+
if (files.length >= 2000)
|
|
341
|
+
limitations.push("Diff truncated at 2000 files.");
|
|
342
|
+
limitations.push("Deterministic lanes match patterns in changed code; they do not execute the application. Executable verification comes from proof and verify runs.");
|
|
343
|
+
const diffDigest = sha256(input.diffText);
|
|
344
|
+
const review = {
|
|
345
|
+
schema_version: ASSURANCE_SCHEMA_VERSION,
|
|
346
|
+
kind: "change-review",
|
|
347
|
+
methodology_version: METHODOLOGY_VERSION,
|
|
348
|
+
review_id: stableId("CR", [input.baseSha, input.headSha, diffDigest, cycleNo]),
|
|
349
|
+
mode: input.mode,
|
|
350
|
+
change_ref: input.changeRef,
|
|
351
|
+
created_at: input.now.toISOString(),
|
|
352
|
+
target: { name: input.targetName, repository: input.repositoryLabel },
|
|
353
|
+
base_sha: input.baseSha,
|
|
354
|
+
head_sha: input.headSha,
|
|
355
|
+
author,
|
|
356
|
+
diff: { source: input.diffSource, digest: diffDigest, files: changed, truncated: files.length >= 2000 },
|
|
357
|
+
risk,
|
|
358
|
+
approvals_required: required,
|
|
359
|
+
approvals: tally.evaluations,
|
|
360
|
+
lanes,
|
|
361
|
+
findings,
|
|
362
|
+
disproven,
|
|
363
|
+
checks,
|
|
364
|
+
baseline: input.baseline
|
|
365
|
+
? {
|
|
366
|
+
run_id: input.baseline.results.run.run_id,
|
|
367
|
+
verdict: input.baseline.results.verdict.verdict,
|
|
368
|
+
results_sha256: input.baseline.sha256,
|
|
369
|
+
}
|
|
370
|
+
: null,
|
|
371
|
+
cycle: {
|
|
372
|
+
number: cycleNo,
|
|
373
|
+
max: policy.review.max_cycles,
|
|
374
|
+
previous_review_id: input.previous?.review_id ?? null,
|
|
375
|
+
repeated_unchanged: repeated,
|
|
376
|
+
new_findings: findings.filter((f) => f.repeat_of_review === null).map((f) => f.id),
|
|
377
|
+
escalated,
|
|
378
|
+
},
|
|
379
|
+
gate: { status, reasons, blocking_finding_ids: blockingIds },
|
|
380
|
+
product_vs_change: input.baseline
|
|
381
|
+
? `The whole product was last assessed as ${input.baseline.results.verdict.verdict} (run ${input.baseline.results.run.run_id}). This change gate is a separate view; a change PASS never implies product readiness.`
|
|
382
|
+
: "No baseline whole-product scan was supplied. This change gate says nothing about product readiness.",
|
|
383
|
+
limitations,
|
|
384
|
+
evidence_ids: findings.flatMap((f) => f.evidence_ids),
|
|
385
|
+
provenance: {
|
|
386
|
+
tool: "product-check",
|
|
387
|
+
tool_version: TOOL_VERSION,
|
|
388
|
+
policy_id: input.policyDigest ? policy.policy_id : null,
|
|
389
|
+
policy_digest: input.policyDigest,
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
return review;
|
|
393
|
+
}
|
|
394
|
+
/** Reads a file from the working tree (PRE_PR_LOCAL); null when missing. */
|
|
395
|
+
export function worktreeText(repo) {
|
|
396
|
+
const root = resolve(repo);
|
|
397
|
+
return (p) => {
|
|
398
|
+
const abs = resolve(join(root, p));
|
|
399
|
+
if (!abs.startsWith(root + sep))
|
|
400
|
+
return null;
|
|
401
|
+
try {
|
|
402
|
+
return existsSync(abs) ? readFileSync(abs, "utf8") : null;
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Change-risk classification and human-approval routing. Risk follows the impacted surface (paths and changed
|
|
3
|
+
* content), never who or what wrote the change. UNDETERMINED is treated as HIGH. A trusted author can only waive
|
|
4
|
+
* the human approval of an eligible LOW change when the policy allows it and no protected path is touched; that
|
|
5
|
+
* never bypasses a security block.
|
|
6
|
+
*/
|
|
7
|
+
import type { AssurancePolicy, ChangedFile, ChangeReview } from "./types.js";
|
|
8
|
+
import type { DiffFile } from "./diff.js";
|
|
9
|
+
/** Surfaces a changed file touches. */
|
|
10
|
+
export declare function surfacesOf(file: DiffFile): string[];
|
|
11
|
+
/** Classifies a change. */
|
|
12
|
+
export declare function classifyRisk(files: DiffFile[], policy: AssurancePolicy, impact: ChangeReview["risk"]["impact_check"]): {
|
|
13
|
+
changed: ChangedFile[];
|
|
14
|
+
risk: ChangeReview["risk"];
|
|
15
|
+
owners: string[];
|
|
16
|
+
};
|
|
17
|
+
/** Human approvals a change needs, including the LOW-risk waiver decision. */
|
|
18
|
+
export declare function approvalsRequired(risk: ChangeReview["risk"], owners: string[], policy: AssurancePolicy, author: {
|
|
19
|
+
id: string | null;
|
|
20
|
+
trusted: boolean;
|
|
21
|
+
}): ChangeReview["approvals_required"];
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { globToRegExp, matchesAny } from "./util.js";
|
|
2
|
+
/** High-risk surfaces and how they are recognized (path first, then changed content). */
|
|
3
|
+
const SURFACES = [
|
|
4
|
+
{
|
|
5
|
+
id: "auth",
|
|
6
|
+
path: /(^|\/)(auth|login|logout|session|sessions|oauth|sso|jwt|passwords?|middleware|permissions?|rbac|acl|guards?)([./_-]|$)/i,
|
|
7
|
+
content: /\b(requireAuth|isAuthenticated|getServerSession|verifyToken|jwt\.verify|authorize|checkPermission|login_required|@PreAuthorize|currentUser|req\.user)\b/,
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
id: "billing",
|
|
11
|
+
path: /(^|\/)(billing|payments?|stripe|checkout|invoices?|subscriptions?|pricing)([./_-]|$)/i,
|
|
12
|
+
content: /\b(stripe\.|paymentIntents|charges\.create|checkout\.sessions)\b/,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: "personal-data",
|
|
16
|
+
path: /(^|\/)(users?|profiles?|accounts?|customers?|pii|gdpr|privacy)([./_-]|$)/i,
|
|
17
|
+
content: /\b(email|phone|date_of_birth|ssn|address)\b.*[:=]/i,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: "tenancy",
|
|
21
|
+
path: /(^|\/)(tenants?|orgs?|organizations?|workspaces?|teams?)([./_-]|$)/i,
|
|
22
|
+
content: /\b(tenant_?id|org_?id|organization_?id|workspace_?id)\b/i,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "data-migration",
|
|
26
|
+
path: /(^|\/)(migrations?|migrate|schema\.prisma|alembic|flyway|liquibase)([./_-]|$)|\.sql$/i,
|
|
27
|
+
content: /\b(ALTER TABLE|DROP TABLE|DROP COLUMN|CREATE TABLE)\b/i,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "ci-deployment",
|
|
31
|
+
path: /^(\.github\/workflows\/|\.gitlab-ci|\.circleci\/|Jenkinsfile|azure-pipelines|Dockerfile|docker-compose|vercel\.json|netlify\.toml|fly\.toml|render\.yaml|Procfile|terraform\/|infra\/|k8s\/|helm\/|deploy)/i,
|
|
32
|
+
content: null,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "ai-action",
|
|
36
|
+
path: /(^|\/)(agents?|tools?|llm|prompts?|ai)([./_-]|$)/i,
|
|
37
|
+
content: /\b(tool_calls|function_call|tools\s*:|anthropic|openai|generateText|streamText)\b/,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "security-boundary",
|
|
41
|
+
path: /(^|\/)(security|crypto|secrets?|cors|csp|headers|rate-?limit)([./_-]|$)|(^|\/)\.env/i,
|
|
42
|
+
content: /\b(cors\(|helmet\(|Content-Security-Policy|rejectUnauthorized|createCipher|crypto\.)/,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "release-rollback",
|
|
46
|
+
path: /(^|\/)(release|rollback|feature-?flags?|canary)([./_-]|$)/i,
|
|
47
|
+
content: null,
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
const DOCS = /(^|\/)(docs?\/.*|README[^/]*|CHANGELOG[^/]*|LICENSE[^/]*|NOTICE[^/]*|CONTRIBUTING[^/]*|CODE_OF_CONDUCT[^/]*|[^/]+\.(md|mdx|txt|rst|adoc))$/i;
|
|
51
|
+
const TESTS = /(^|\/)(__tests__|tests?|spec|e2e|cypress|playwright)\/|\.(test|spec)\.[cm]?[jt]sx?$|_test\.(py|go)$/i;
|
|
52
|
+
/** Surfaces a changed file touches. */
|
|
53
|
+
export function surfacesOf(file) {
|
|
54
|
+
const added = file.lines
|
|
55
|
+
.filter((l) => l.kind === "add" || l.kind === "del")
|
|
56
|
+
.map((l) => l.text)
|
|
57
|
+
.join("\n");
|
|
58
|
+
const found = new Set();
|
|
59
|
+
for (const s of SURFACES) {
|
|
60
|
+
if (s.path.test(file.path) || (file.previous_path !== null && s.path.test(file.previous_path)))
|
|
61
|
+
found.add(s.id);
|
|
62
|
+
else if (s.content && !DOCS.test(file.path) && s.content.test(added))
|
|
63
|
+
found.add(s.id);
|
|
64
|
+
}
|
|
65
|
+
if (DOCS.test(file.path))
|
|
66
|
+
found.add("docs");
|
|
67
|
+
else if (TESTS.test(file.path))
|
|
68
|
+
found.add("tests");
|
|
69
|
+
else if (found.size === 0)
|
|
70
|
+
found.add("application");
|
|
71
|
+
return [...found].sort();
|
|
72
|
+
}
|
|
73
|
+
const HIGH_SURFACES = new Set([
|
|
74
|
+
"auth",
|
|
75
|
+
"billing",
|
|
76
|
+
"personal-data",
|
|
77
|
+
"tenancy",
|
|
78
|
+
"data-migration",
|
|
79
|
+
"ci-deployment",
|
|
80
|
+
"ai-action",
|
|
81
|
+
"security-boundary",
|
|
82
|
+
"release-rollback",
|
|
83
|
+
]);
|
|
84
|
+
/** Classifies a change. */
|
|
85
|
+
export function classifyRisk(files, policy, impact) {
|
|
86
|
+
const reasons = [];
|
|
87
|
+
const owners = new Set();
|
|
88
|
+
const protectedHits = [];
|
|
89
|
+
const changed = files.map((f) => {
|
|
90
|
+
const surfaces = surfacesOf(f);
|
|
91
|
+
const prot = policy.risk.protected_paths.filter((p) => globToRegExp(p.pattern).test(f.path) ||
|
|
92
|
+
(f.previous_path !== null && globToRegExp(p.pattern).test(f.previous_path)));
|
|
93
|
+
for (const p of prot)
|
|
94
|
+
p.owners.forEach((o) => owners.add(o));
|
|
95
|
+
if (prot.length > 0)
|
|
96
|
+
protectedHits.push(f.path);
|
|
97
|
+
return {
|
|
98
|
+
path: f.path,
|
|
99
|
+
previous_path: f.previous_path,
|
|
100
|
+
status: f.status,
|
|
101
|
+
additions: f.additions,
|
|
102
|
+
deletions: f.deletions,
|
|
103
|
+
surfaces,
|
|
104
|
+
protected: prot.length > 0,
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
const surfaces = [...new Set(changed.flatMap((c) => c.surfaces))].sort();
|
|
108
|
+
let level;
|
|
109
|
+
if (changed.length === 0) {
|
|
110
|
+
level = "UNDETERMINED";
|
|
111
|
+
reasons.push("The diff is empty or could not be read; risk cannot be determined.");
|
|
112
|
+
}
|
|
113
|
+
else if (changed.some((c) => c.status === "binary")) {
|
|
114
|
+
level = "UNDETERMINED";
|
|
115
|
+
reasons.push(`Binary change(s) cannot be inspected: ${changed
|
|
116
|
+
.filter((c) => c.status === "binary")
|
|
117
|
+
.map((c) => c.path)
|
|
118
|
+
.join(", ")}.`);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
const high = changed.filter((c) => c.surfaces.some((s) => HIGH_SURFACES.has(s)) ||
|
|
122
|
+
c.protected ||
|
|
123
|
+
matchesAny(c.path, policy.risk.high_risk_paths));
|
|
124
|
+
const nonLow = changed.filter((c) => !(c.surfaces.length === 1 && (c.surfaces[0] === "docs" || c.surfaces[0] === "tests")) &&
|
|
125
|
+
!matchesAny(c.path, policy.risk.low_risk_paths));
|
|
126
|
+
if (high.length > 0) {
|
|
127
|
+
level = "HIGH";
|
|
128
|
+
reasons.push(`High-risk surface(s) touched: ${[...new Set(high.flatMap((c) => c.surfaces.filter((s) => HIGH_SURFACES.has(s))))].join(", ") || "policy high-risk path"} (${high
|
|
129
|
+
.map((c) => c.path)
|
|
130
|
+
.slice(0, 8)
|
|
131
|
+
.join(", ")}).`);
|
|
132
|
+
}
|
|
133
|
+
else if (nonLow.length > 0) {
|
|
134
|
+
level = "MEDIUM";
|
|
135
|
+
reasons.push(`Application change(s) without a high-risk surface: ${nonLow
|
|
136
|
+
.map((c) => c.path)
|
|
137
|
+
.slice(0, 8)
|
|
138
|
+
.join(", ")}.`);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
level = "LOW";
|
|
142
|
+
reasons.push("Only documentation, tests or policy-declared low-risk paths changed.");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (protectedHits.length > 0)
|
|
146
|
+
reasons.push(`Protected path(s): ${protectedHits.join(", ")}; domain-owner approval required.`);
|
|
147
|
+
if (impact && impact.high > 0 && level !== "HIGH") {
|
|
148
|
+
reasons.push(`Impact Check reports ${impact.high} high-severity impact finding(s); raised to HIGH.`);
|
|
149
|
+
level = "HIGH";
|
|
150
|
+
}
|
|
151
|
+
const effective = level === "UNDETERMINED" ? "HIGH" : level;
|
|
152
|
+
if (level === "UNDETERMINED")
|
|
153
|
+
reasons.push("UNDETERMINED is treated as HIGH until evidence supports reclassification.");
|
|
154
|
+
return {
|
|
155
|
+
changed,
|
|
156
|
+
risk: {
|
|
157
|
+
level,
|
|
158
|
+
effective_level: effective,
|
|
159
|
+
surfaces,
|
|
160
|
+
reasons,
|
|
161
|
+
protected_paths: protectedHits,
|
|
162
|
+
impact_check: impact,
|
|
163
|
+
},
|
|
164
|
+
owners: [...owners].sort(),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** Human approvals a change needs, including the LOW-risk waiver decision. */
|
|
168
|
+
export function approvalsRequired(risk, owners, policy, author) {
|
|
169
|
+
const humans = policy.risk.approvals[risk.effective_level];
|
|
170
|
+
const eligible = risk.level === "LOW" && risk.protected_paths.length === 0 && policy.risk.allow_low_risk_waiver;
|
|
171
|
+
let reason;
|
|
172
|
+
if (risk.level !== "LOW")
|
|
173
|
+
reason = `Not eligible: risk is ${risk.level}.`;
|
|
174
|
+
else if (risk.protected_paths.length > 0)
|
|
175
|
+
reason = "Not eligible: a protected path is touched.";
|
|
176
|
+
else if (!policy.risk.allow_low_risk_waiver)
|
|
177
|
+
reason = "Not eligible: the policy does not allow LOW-risk waivers.";
|
|
178
|
+
else if (!author.trusted)
|
|
179
|
+
reason = `Eligible, not applied: author ${author.id ?? "(unknown)"} is not a trusted author in the policy.`;
|
|
180
|
+
else
|
|
181
|
+
reason = `Applied: LOW risk, no protected path, trusted author ${author.id ?? ""}, policy allows the waiver.`;
|
|
182
|
+
const applied = eligible && author.trusted;
|
|
183
|
+
return { humans: applied ? 0 : humans, domain_owners: owners, waiver: { eligible, applied, reason } };
|
|
184
|
+
}
|