@enwithai/product-check 0.1.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.
Files changed (102) hide show
  1. package/ATTRIBUTION.md +6 -6
  2. package/CHANGELOG.md +66 -0
  3. package/NOTICE.md +5 -3
  4. package/README.md +34 -19
  5. package/SECURITY.md +5 -1
  6. package/dashboard/assurance.css +71 -0
  7. package/dashboard/assurance.js +21 -0
  8. package/dashboard/dashboard.css +6 -0
  9. package/dist/assurance/adapters.d.ts +32 -0
  10. package/dist/assurance/adapters.js +76 -0
  11. package/dist/assurance/approvals.d.ts +34 -0
  12. package/dist/assurance/approvals.js +133 -0
  13. package/dist/assurance/cli.d.ts +13 -0
  14. package/dist/assurance/cli.js +863 -0
  15. package/dist/assurance/dashboard.d.ts +18 -0
  16. package/dist/assurance/dashboard.js +267 -0
  17. package/dist/assurance/diff.d.ts +20 -0
  18. package/dist/assurance/diff.js +99 -0
  19. package/dist/assurance/evidence-log.d.ts +20 -0
  20. package/dist/assurance/evidence-log.js +100 -0
  21. package/dist/assurance/fix-packets.d.ts +12 -0
  22. package/dist/assurance/fix-packets.js +178 -0
  23. package/dist/assurance/gate.d.ts +37 -0
  24. package/dist/assurance/gate.js +324 -0
  25. package/dist/assurance/lanes.d.ts +61 -0
  26. package/dist/assurance/lanes.js +380 -0
  27. package/dist/assurance/policy.d.ts +10 -0
  28. package/dist/assurance/policy.js +85 -0
  29. package/dist/assurance/proof.d.ts +35 -0
  30. package/dist/assurance/proof.js +146 -0
  31. package/dist/assurance/register.d.ts +41 -0
  32. package/dist/assurance/register.js +480 -0
  33. package/dist/assurance/render-md.d.ts +17 -0
  34. package/dist/assurance/render-md.js +212 -0
  35. package/dist/assurance/repo-state.d.ts +15 -0
  36. package/dist/assurance/repo-state.js +79 -0
  37. package/dist/assurance/review.d.ts +52 -0
  38. package/dist/assurance/review.js +408 -0
  39. package/dist/assurance/risk.d.ts +21 -0
  40. package/dist/assurance/risk.js +184 -0
  41. package/dist/assurance/types.d.ts +573 -0
  42. package/dist/assurance/types.js +6 -0
  43. package/dist/assurance/util.d.ts +24 -0
  44. package/dist/assurance/util.js +89 -0
  45. package/dist/assurance/verification.d.ts +30 -0
  46. package/dist/assurance/verification.js +212 -0
  47. package/dist/assurance/verify.d.ts +26 -0
  48. package/dist/assurance/verify.js +304 -0
  49. package/dist/blueprint/build.d.ts +3 -1
  50. package/dist/blueprint/build.js +41 -5
  51. package/dist/cli/run.js +103 -5
  52. package/dist/contract/contract.d.ts +4 -0
  53. package/dist/contract/contract.js +16 -0
  54. package/dist/dashboard/render.d.ts +5 -0
  55. package/dist/dashboard/render.js +107 -3
  56. package/dist/discovery/pops.d.ts +2 -0
  57. package/dist/discovery/pops.js +7 -5
  58. package/dist/exec/process.d.ts +27 -0
  59. package/dist/exec/process.js +175 -0
  60. package/dist/importers/operational.d.ts +56 -0
  61. package/dist/importers/operational.js +217 -0
  62. package/dist/importers/reports.d.ts +24 -0
  63. package/dist/importers/reports.js +37 -1
  64. package/dist/inspectors/source/surface.d.ts +30 -0
  65. package/dist/inspectors/source/surface.js +529 -0
  66. package/dist/parse/yaml.d.ts +4 -0
  67. package/dist/parse/yaml.js +39 -0
  68. package/dist/reporting/markdown.d.ts +1 -0
  69. package/dist/reporting/markdown.js +18 -1
  70. package/dist/reporting/spec-markdown.d.ts +9 -0
  71. package/dist/reporting/spec-markdown.js +144 -0
  72. package/dist/run.d.ts +2 -0
  73. package/dist/run.js +52 -2
  74. package/dist/schema/registry.js +10 -0
  75. package/dist/scoring/score.js +1 -1
  76. package/dist/spec/build.d.ts +28 -0
  77. package/dist/spec/build.js +278 -0
  78. package/dist/types.d.ts +78 -2
  79. package/dist/types.js +6 -1
  80. package/dist/validation/catalog.d.ts +1 -1
  81. package/dist/validation/catalog.js +125 -2
  82. package/dist/validation/context.d.ts +6 -1
  83. package/dist/validation/import-evaluators.js +37 -3
  84. package/dist/validation/integrity.js +4 -0
  85. package/dist/validation/operational-evaluators.d.ts +3 -0
  86. package/dist/validation/operational-evaluators.js +153 -0
  87. package/dist/validation/source-evaluators.js +1 -1
  88. package/package.json +4 -1
  89. package/schemas/approval.schema.json +98 -0
  90. package/schemas/as-built-spec.schema.json +356 -0
  91. package/schemas/assurance-common.schema.json +291 -0
  92. package/schemas/assurance-policy.schema.json +185 -0
  93. package/schemas/blueprint.schema.json +3 -0
  94. package/schemas/change-review.schema.json +711 -0
  95. package/schemas/closure-register.schema.json +499 -0
  96. package/schemas/evidence-record.schema.json +94 -0
  97. package/schemas/fix-packet.schema.json +272 -0
  98. package/schemas/release-gate.schema.json +308 -0
  99. package/schemas/reviewer-findings.schema.json +49 -0
  100. package/schemas/scan-contract.schema.json +20 -0
  101. package/schemas/scorecard.schema.json +1 -1
  102. 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
+ }