@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.
- package/ATTRIBUTION.md +6 -6
- package/CHANGELOG.md +66 -0
- package/NOTICE.md +5 -3
- package/README.md +34 -19
- package/SECURITY.md +5 -1
- package/dashboard/assurance.css +71 -0
- package/dashboard/assurance.js +21 -0
- package/dashboard/dashboard.css +6 -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/blueprint/build.d.ts +3 -1
- package/dist/blueprint/build.js +41 -5
- package/dist/cli/run.js +103 -5
- package/dist/contract/contract.d.ts +4 -0
- package/dist/contract/contract.js +16 -0
- package/dist/dashboard/render.d.ts +5 -0
- package/dist/dashboard/render.js +107 -3
- package/dist/discovery/pops.d.ts +2 -0
- package/dist/discovery/pops.js +7 -5
- package/dist/exec/process.d.ts +27 -0
- package/dist/exec/process.js +175 -0
- package/dist/importers/operational.d.ts +56 -0
- package/dist/importers/operational.js +217 -0
- package/dist/importers/reports.d.ts +24 -0
- package/dist/importers/reports.js +37 -1
- package/dist/inspectors/source/surface.d.ts +30 -0
- package/dist/inspectors/source/surface.js +529 -0
- package/dist/parse/yaml.d.ts +4 -0
- package/dist/parse/yaml.js +39 -0
- package/dist/reporting/markdown.d.ts +1 -0
- package/dist/reporting/markdown.js +18 -1
- package/dist/reporting/spec-markdown.d.ts +9 -0
- package/dist/reporting/spec-markdown.js +144 -0
- package/dist/run.d.ts +2 -0
- package/dist/run.js +52 -2
- package/dist/schema/registry.js +10 -0
- package/dist/scoring/score.js +1 -1
- package/dist/spec/build.d.ts +28 -0
- package/dist/spec/build.js +278 -0
- package/dist/types.d.ts +78 -2
- package/dist/types.js +6 -1
- package/dist/validation/catalog.d.ts +1 -1
- package/dist/validation/catalog.js +125 -2
- package/dist/validation/context.d.ts +6 -1
- package/dist/validation/import-evaluators.js +37 -3
- package/dist/validation/integrity.js +4 -0
- package/dist/validation/operational-evaluators.d.ts +3 -0
- package/dist/validation/operational-evaluators.js +153 -0
- package/dist/validation/source-evaluators.js +1 -1
- package/package.json +4 -1
- package/schemas/approval.schema.json +98 -0
- package/schemas/as-built-spec.schema.json +356 -0
- package/schemas/assurance-common.schema.json +291 -0
- package/schemas/assurance-policy.schema.json +185 -0
- package/schemas/blueprint.schema.json +3 -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/scan-contract.schema.json +20 -0
- package/schemas/scorecard.schema.json +1 -1
- package/schemas/verification-run.schema.json +199 -0
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 EnWithAI Technologies
|
|
3
|
+
/**
|
|
4
|
+
* CLI for the vibe-code review and verified-closure workflow (CT3 v3.2). All state lives in a workspace directory
|
|
5
|
+
* (default .product-check/) next to the scan results:
|
|
6
|
+
*
|
|
7
|
+
* product-check-results.json closure-register.json review-evidence.jsonl approvals.json proof-run.json
|
|
8
|
+
* change-review.json + reviews/ fix-packets/F-*.json verification/VR-*-results.json release-gate.json
|
|
9
|
+
* PRODUCT_CHECK_REVIEW.md PRODUCT_CHECK_BLUEPRINT.md PRODUCT_CHECK_RELEASE_GATE.md dashboard/index.html
|
|
10
|
+
* human-review/F-*.md
|
|
11
|
+
*/
|
|
12
|
+
import { chmodSync, existsSync, readdirSync, readFileSync } from "node:fs";
|
|
13
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
14
|
+
import { parseArgs } from "node:util";
|
|
15
|
+
import { loadContract } from "../contract/contract.js";
|
|
16
|
+
import { ExitCode, inputError, ProductCheckError, usageError } from "../errors.js";
|
|
17
|
+
import { gitRead } from "../exec/process.js";
|
|
18
|
+
import { formatErrors, validateAgainst } from "../schema/registry.js";
|
|
19
|
+
import { sha256 } from "../util/hash.js";
|
|
20
|
+
import { checkResults } from "../validation/integrity.js";
|
|
21
|
+
import { outcomeAdapter, impactAdapter, specAdapter } from "./adapters.js";
|
|
22
|
+
import { generateApproverKeys, keyId, signApproval } from "./approvals.js";
|
|
23
|
+
import { renderAssuranceDashboard } from "./dashboard.js";
|
|
24
|
+
import { parseUnifiedDiff } from "./diff.js";
|
|
25
|
+
import { EvidenceLog, readLog, verifyLog } from "./evidence-log.js";
|
|
26
|
+
import { buildFixPackets } from "./fix-packets.js";
|
|
27
|
+
import { evaluateGate } from "./gate.js";
|
|
28
|
+
import { loadPolicy } from "./policy.js";
|
|
29
|
+
import { authorProof, detectCheckSuggestions } from "./proof.js";
|
|
30
|
+
import { acceptRisk, addApproval, applyVerification, checkRegister, closeFinding, createRegister, expireAcceptances, manualTransition, recordFix, rejectFalsePositive, syncResults, syncReview, } from "./register.js";
|
|
31
|
+
import { renderBlueprintMd, renderGateMd, renderHumanPacket, renderReviewMd, withFooter, } from "./render-md.js";
|
|
32
|
+
import { commitAuthor, environmentRecord, fileAt, resolveSha, worktreeState } from "./repo-state.js";
|
|
33
|
+
import { reviewChange, worktreeText } from "./review.js";
|
|
34
|
+
import { readJson, readJsonIfExists, writeJson, writeText } from "./util.js";
|
|
35
|
+
import { verifyFindings } from "./verify.js";
|
|
36
|
+
export const ASSURANCE_COMMANDS = [
|
|
37
|
+
"proof",
|
|
38
|
+
"review",
|
|
39
|
+
"register",
|
|
40
|
+
"transition",
|
|
41
|
+
"fix",
|
|
42
|
+
"verify",
|
|
43
|
+
"keygen",
|
|
44
|
+
"approve",
|
|
45
|
+
"close",
|
|
46
|
+
"accept-risk",
|
|
47
|
+
"reject",
|
|
48
|
+
"learn",
|
|
49
|
+
"fix-packets",
|
|
50
|
+
"gate",
|
|
51
|
+
"assure",
|
|
52
|
+
];
|
|
53
|
+
export const ASSURANCE_HELP = `Vibe-code review and verified closure (CT3 v3.2). Workspace: --workspace <dir> (default .product-check)
|
|
54
|
+
product-check run --repo . --out-dir .product-check VC-P0 baseline scan (unchanged; never executes code)
|
|
55
|
+
product-check proof --repo . --policy <p.json> [--allow-exec]
|
|
56
|
+
VC-P1 author-side proof: runs the policy's required checks at HEAD
|
|
57
|
+
product-check review --repo . --base <rev> [--head <rev>] [--change <ref>] [--policy <p.json>]
|
|
58
|
+
[--impact-check f] [--spec-check f] [--outcome-check f] [--reviewer-findings f] [--diff f]
|
|
59
|
+
VC-P2/P3 layered change review, independent verification, risk routing
|
|
60
|
+
product-check register [--results f] [--repo .] create/sync the closure register (VC-P2 verification)
|
|
61
|
+
product-check transition --finding F-001 --to ACKNOWLEDGED|IN_PROGRESS|DEFERRED|OPEN --reason "…" --actor <id>
|
|
62
|
+
product-check fix --finding F-001 --sha <commit> --actor <id> [--actor-type agent|human] [--repro-cmd "…"]
|
|
63
|
+
record a proposed fix → RETEST_REQUIRED (never fixed by itself)
|
|
64
|
+
product-check verify --finding F-001 --head <sha> [--allow-exec] [--policy p] [--contract c] [--test-results f]
|
|
65
|
+
VC-P5 targeted + regression verification at the exact fix commit
|
|
66
|
+
product-check keygen --approver <id> --out-dir <dir> Ed25519 key pair for a human approver
|
|
67
|
+
product-check approve --scope change:<ref>|closure:F-001|risk-acceptance:F-001|false-positive:F-001|continue:F-001|release:<name>
|
|
68
|
+
--sha <commit> --approver <id> --key <private.pem> [--decision approve|reject] [--comment "…"]
|
|
69
|
+
product-check close --finding F-001 --evidence VR-001 --approver <id> --policy <p.json>
|
|
70
|
+
product-check accept-risk --finding F-002 --owner <id> --risk-scope "…" --rationale "…" --compensating "…"
|
|
71
|
+
--review-date YYYY-MM-DD --expires YYYY-MM-DD --closure-condition "…" --policy <p.json>
|
|
72
|
+
product-check reject --finding F-003 --reason "…" --actor <id> [--policy p] false positive
|
|
73
|
+
product-check learn --finding F-001 --cmd "node --test test/x.test.mjs" --origin escaped-incident|regressed-finding|verified-fix
|
|
74
|
+
product-check fix-packets | gate | assure [--from <results.json>] [--policy p] [--fail-on NO_GO,UNKNOWN]
|
|
75
|
+
product-check dashboard --from <results.json> · product-check blueprint --from <results.json>
|
|
76
|
+
Commands run only with --allow-exec, only from the policy/fix/corpus, as argv without a shell.`;
|
|
77
|
+
const OPTIONS = {
|
|
78
|
+
workspace: { type: "string" },
|
|
79
|
+
repo: { type: "string" },
|
|
80
|
+
results: { type: "string" },
|
|
81
|
+
from: { type: "string" },
|
|
82
|
+
policy: { type: "string" },
|
|
83
|
+
"allow-exec": { type: "boolean" },
|
|
84
|
+
base: { type: "string" },
|
|
85
|
+
head: { type: "string" },
|
|
86
|
+
diff: { type: "string" },
|
|
87
|
+
change: { type: "string" },
|
|
88
|
+
author: { type: "string" },
|
|
89
|
+
"impact-check": { type: "string" },
|
|
90
|
+
"spec-check": { type: "string" },
|
|
91
|
+
"outcome-check": { type: "string" },
|
|
92
|
+
"reviewer-findings": { type: "string" },
|
|
93
|
+
previous: { type: "string" },
|
|
94
|
+
finding: { type: "string", multiple: true },
|
|
95
|
+
to: { type: "string" },
|
|
96
|
+
reason: { type: "string" },
|
|
97
|
+
actor: { type: "string" },
|
|
98
|
+
"actor-type": { type: "string" },
|
|
99
|
+
owner: { type: "string" },
|
|
100
|
+
revisit: { type: "string" },
|
|
101
|
+
sha: { type: "string" },
|
|
102
|
+
"repro-cmd": { type: "string", multiple: true },
|
|
103
|
+
contract: { type: "string" },
|
|
104
|
+
"test-results": { type: "string", multiple: true },
|
|
105
|
+
approver: { type: "string" },
|
|
106
|
+
"out-dir": { type: "string" },
|
|
107
|
+
scope: { type: "string" },
|
|
108
|
+
key: { type: "string" },
|
|
109
|
+
decision: { type: "string" },
|
|
110
|
+
comment: { type: "string" },
|
|
111
|
+
evidence: { type: "string" },
|
|
112
|
+
"risk-scope": { type: "string" },
|
|
113
|
+
rationale: { type: "string" },
|
|
114
|
+
compensating: { type: "string", multiple: true },
|
|
115
|
+
"review-date": { type: "string" },
|
|
116
|
+
expires: { type: "string" },
|
|
117
|
+
"closure-condition": { type: "string" },
|
|
118
|
+
cmd: { type: "string" },
|
|
119
|
+
origin: { type: "string" },
|
|
120
|
+
description: { type: "string" },
|
|
121
|
+
"fail-on": { type: "string" },
|
|
122
|
+
json: { type: "boolean" },
|
|
123
|
+
quiet: { type: "boolean" },
|
|
124
|
+
"no-branding": { type: "boolean" },
|
|
125
|
+
output: { type: "string" },
|
|
126
|
+
};
|
|
127
|
+
/** Splits a command string into argv (quotes respected; no shell expansion of any kind). */
|
|
128
|
+
export function splitCommand(text) {
|
|
129
|
+
const out = [];
|
|
130
|
+
let cur = "";
|
|
131
|
+
let quote = null;
|
|
132
|
+
let has = false;
|
|
133
|
+
for (const ch of text) {
|
|
134
|
+
if (quote) {
|
|
135
|
+
if (ch === quote)
|
|
136
|
+
quote = null;
|
|
137
|
+
else
|
|
138
|
+
cur += ch;
|
|
139
|
+
}
|
|
140
|
+
else if (ch === '"' || ch === "'") {
|
|
141
|
+
quote = ch;
|
|
142
|
+
has = true;
|
|
143
|
+
}
|
|
144
|
+
else if (/\s/.test(ch)) {
|
|
145
|
+
if (has || cur)
|
|
146
|
+
out.push(cur);
|
|
147
|
+
cur = "";
|
|
148
|
+
has = false;
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
cur += ch;
|
|
152
|
+
has = true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (quote)
|
|
156
|
+
throw usageError(`Unterminated quote in command: ${text}`);
|
|
157
|
+
if (has || cur)
|
|
158
|
+
out.push(cur);
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
class Workspace {
|
|
162
|
+
dir;
|
|
163
|
+
constructor(dir) {
|
|
164
|
+
this.dir = dir;
|
|
165
|
+
}
|
|
166
|
+
p(...parts) {
|
|
167
|
+
return join(this.dir, ...parts);
|
|
168
|
+
}
|
|
169
|
+
get registerPath() {
|
|
170
|
+
return this.p("closure-register.json");
|
|
171
|
+
}
|
|
172
|
+
loadRegister(required) {
|
|
173
|
+
const reg = readJsonIfExists(this.registerPath);
|
|
174
|
+
if (!reg) {
|
|
175
|
+
if (required)
|
|
176
|
+
throw usageError(`No closure register in ${this.dir}. Create it: product-check register --results <results.json>`);
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
const errors = [
|
|
180
|
+
...validateAgainst("closure-register.schema.json", reg).map((e) => `${e.path}: ${e.message}`),
|
|
181
|
+
...checkRegister(reg),
|
|
182
|
+
];
|
|
183
|
+
if (errors.length > 0)
|
|
184
|
+
throw new ProductCheckError(`${this.registerPath} failed its integrity check:\n ${errors.slice(0, 20).join("\n ")}`, ExitCode.Invalid);
|
|
185
|
+
return reg;
|
|
186
|
+
}
|
|
187
|
+
saveRegister(reg) {
|
|
188
|
+
const errors = checkRegister(reg);
|
|
189
|
+
if (errors.length > 0)
|
|
190
|
+
throw new Error(`internal: register invariant broken: ${errors.join("; ")}`);
|
|
191
|
+
writeJson(this.registerPath, reg);
|
|
192
|
+
}
|
|
193
|
+
approvals() {
|
|
194
|
+
return readJsonIfExists(this.p("approvals.json")) ?? [];
|
|
195
|
+
}
|
|
196
|
+
saveApprovals(a) {
|
|
197
|
+
writeJson(this.p("approvals.json"), a);
|
|
198
|
+
}
|
|
199
|
+
review() {
|
|
200
|
+
return readJsonIfExists(this.p("change-review.json"));
|
|
201
|
+
}
|
|
202
|
+
reviews() {
|
|
203
|
+
const dir = this.p("reviews");
|
|
204
|
+
if (!existsSync(dir))
|
|
205
|
+
return [];
|
|
206
|
+
return readdirSync(dir)
|
|
207
|
+
.filter((f) => f.endsWith(".json"))
|
|
208
|
+
.map((f) => readJson(join(dir, f)));
|
|
209
|
+
}
|
|
210
|
+
proof() {
|
|
211
|
+
return readJsonIfExists(this.p("proof-run.json"));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function loadResults(path) {
|
|
215
|
+
if (!existsSync(path))
|
|
216
|
+
throw usageError(`No results at ${path}. Run the baseline scan first: product-check run --repo . --out-dir .product-check`);
|
|
217
|
+
const raw = readText(path);
|
|
218
|
+
let data;
|
|
219
|
+
try {
|
|
220
|
+
data = JSON.parse(raw);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
throw inputError(`Not valid JSON: ${path}`);
|
|
224
|
+
}
|
|
225
|
+
const report = checkResults(data);
|
|
226
|
+
if (report.errors.length > 0)
|
|
227
|
+
throw new ProductCheckError(`${path} is not a valid, unmodified Product Check results file:\n ${report.errors.slice(0, 10).join("\n ")}`, ExitCode.Invalid);
|
|
228
|
+
return { results: data, sha: sha256(raw) };
|
|
229
|
+
}
|
|
230
|
+
/** Runs one assurance command. */
|
|
231
|
+
export async function runAssurance(command, argv, io) {
|
|
232
|
+
const { values, positionals } = (() => {
|
|
233
|
+
try {
|
|
234
|
+
return parseArgs({ args: argv, allowPositionals: true, strict: true, options: OPTIONS });
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
throw usageError(error.message, "Run `product-check --help` for usage.");
|
|
238
|
+
}
|
|
239
|
+
})();
|
|
240
|
+
if (positionals.length > 0)
|
|
241
|
+
throw usageError(`Unexpected argument: ${positionals[0]}`);
|
|
242
|
+
const abs = (p) => (isAbsolute(p) ? p : resolve(io.cwd, p));
|
|
243
|
+
const now = () => io.now?.() ?? new Date();
|
|
244
|
+
const fromPath = values.from ?? values.results;
|
|
245
|
+
const ws = new Workspace(abs(values.workspace ?? (fromPath ? dirname(abs(fromPath)) : ".product-check")));
|
|
246
|
+
const resultsPath = fromPath ? abs(fromPath) : ws.p("product-check-results.json");
|
|
247
|
+
const repo = abs(values.repo ?? ".");
|
|
248
|
+
const { policy, digest: policyDigest } = loadPolicy(values.policy ? abs(values.policy) : undefined);
|
|
249
|
+
const log = () => new EvidenceLog(ws.p("review-evidence.jsonl"), now);
|
|
250
|
+
const permission = values["allow-exec"]
|
|
251
|
+
? { allowed: true, reason: "Authorized for this invocation with --allow-exec." }
|
|
252
|
+
: {
|
|
253
|
+
allowed: false,
|
|
254
|
+
reason: "Execution not authorized: pass --allow-exec to run the declared commands on this machine with your permissions.",
|
|
255
|
+
};
|
|
256
|
+
const actor = (required = true) => {
|
|
257
|
+
if (!values.actor) {
|
|
258
|
+
if (required)
|
|
259
|
+
throw usageError("--actor <id> is required (who is doing this).");
|
|
260
|
+
return { id: "unknown", type: "agent" };
|
|
261
|
+
}
|
|
262
|
+
const t = values["actor-type"] ?? "agent";
|
|
263
|
+
if (t !== "agent" && t !== "human")
|
|
264
|
+
throw usageError("--actor-type must be agent or human.");
|
|
265
|
+
return { id: values.actor, type: t };
|
|
266
|
+
};
|
|
267
|
+
const say = (text) => {
|
|
268
|
+
if (!values.quiet)
|
|
269
|
+
io.stdout(text.endsWith("\n") ? text : `${text}\n`);
|
|
270
|
+
};
|
|
271
|
+
const rel = (p) => {
|
|
272
|
+
const r = relative(io.cwd, p);
|
|
273
|
+
return r && !r.startsWith("..") ? r : p;
|
|
274
|
+
};
|
|
275
|
+
const failOn = (decision) => {
|
|
276
|
+
const list = (values["fail-on"] ?? "")
|
|
277
|
+
.split(",")
|
|
278
|
+
.map((x) => x.trim())
|
|
279
|
+
.filter(Boolean);
|
|
280
|
+
return list.includes(decision) ? ExitCode.Gate : ExitCode.Ok;
|
|
281
|
+
};
|
|
282
|
+
switch (command) {
|
|
283
|
+
case "keygen": {
|
|
284
|
+
if (!values.approver || !values["out-dir"])
|
|
285
|
+
throw usageError("keygen needs --approver <id> and --out-dir <dir>.");
|
|
286
|
+
const dir = abs(values["out-dir"]);
|
|
287
|
+
const keyPath = join(dir, `${values.approver}.key.pem`);
|
|
288
|
+
if (existsSync(keyPath))
|
|
289
|
+
throw usageError(`${keyPath} exists; refusing to overwrite a private key.`);
|
|
290
|
+
const k = generateApproverKeys();
|
|
291
|
+
writeText(keyPath, k.privateKey);
|
|
292
|
+
chmodSync(keyPath, 0o600);
|
|
293
|
+
writeText(join(dir, `${values.approver}.pub.pem`), k.publicKey);
|
|
294
|
+
say(`Wrote ${rel(keyPath)} (private, keep it to yourself) and ${values.approver}.pub.pem (key id ${keyId(k.publicKey)}).\nAdd to the policy's approvers:\n${JSON.stringify({ id: values.approver, type: "human", roles: [], public_key: k.publicKey }, null, 2)}`);
|
|
295
|
+
return ExitCode.Ok;
|
|
296
|
+
}
|
|
297
|
+
case "proof": {
|
|
298
|
+
const state = worktreeState(repo, ws.dir);
|
|
299
|
+
const env = environmentRecord(repo);
|
|
300
|
+
const run = authorProof({
|
|
301
|
+
repo,
|
|
302
|
+
policy,
|
|
303
|
+
permission,
|
|
304
|
+
env,
|
|
305
|
+
sha: state.head,
|
|
306
|
+
clean: state.clean,
|
|
307
|
+
log: log(),
|
|
308
|
+
now,
|
|
309
|
+
});
|
|
310
|
+
writeJson(ws.p("proof-run.json"), run);
|
|
311
|
+
const suggestions = detectCheckSuggestions(repo).filter((s) => !policy.required_checks.some((c) => c.argv.join(" ") === s.argv.join(" ")));
|
|
312
|
+
say([
|
|
313
|
+
`Author-side proof ${run.verification_id}: ${run.result} at ${state.head?.slice(0, 12) ?? "unknown commit"}${state.clean === false ? " (dirty worktree)" : ""}`,
|
|
314
|
+
...run.executions.map((e) => ` ${e.status.padEnd(10)} ${e.argv.join(" ")}${e.exit_code !== null ? ` (exit ${e.exit_code}, ${e.duration_ms} ms)` : ""}${e.reason ? ` — ${e.reason}` : ""}`),
|
|
315
|
+
...run.reasons.map((r) => ` ${r}`),
|
|
316
|
+
...(suggestions.length
|
|
317
|
+
? [
|
|
318
|
+
`Detected but not declared (add to the policy's required_checks to run them): ${suggestions.map((s) => s.argv.join(" ")).join("; ")}`,
|
|
319
|
+
]
|
|
320
|
+
: []),
|
|
321
|
+
`Wrote ${rel(ws.p("proof-run.json"))}`,
|
|
322
|
+
].join("\n"));
|
|
323
|
+
return run.result === "FAIL"
|
|
324
|
+
? ExitCode.Gate
|
|
325
|
+
: run.result === "PASS"
|
|
326
|
+
? ExitCode.Ok
|
|
327
|
+
: failOn("INCONCLUSIVE");
|
|
328
|
+
}
|
|
329
|
+
case "review": {
|
|
330
|
+
let diffText;
|
|
331
|
+
let base;
|
|
332
|
+
let head;
|
|
333
|
+
let mode;
|
|
334
|
+
let headText;
|
|
335
|
+
if (values.diff) {
|
|
336
|
+
diffText = readText(abs(values.diff));
|
|
337
|
+
mode = "PR_DIFF";
|
|
338
|
+
headText = () => null;
|
|
339
|
+
base = values.base ? resolveSha(repo, values.base) : null;
|
|
340
|
+
head = values.head ? resolveSha(repo, values.head) : null;
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
if (!values.base)
|
|
344
|
+
throw usageError("review needs --base <rev> (or --diff <file>).", "Example: product-check review --repo . --base origin/main --head HEAD");
|
|
345
|
+
base = resolveSha(repo, values.base);
|
|
346
|
+
if (!base)
|
|
347
|
+
throw inputError(`Cannot resolve --base ${values.base} in ${repo}.`);
|
|
348
|
+
if (values.head) {
|
|
349
|
+
head = resolveSha(repo, values.head);
|
|
350
|
+
if (!head)
|
|
351
|
+
throw inputError(`Cannot resolve --head ${values.head}.`);
|
|
352
|
+
const mb = gitRead(repo, ["merge-base", base, head]);
|
|
353
|
+
const from = mb.ok && mb.stdout.trim() ? mb.stdout.trim() : base;
|
|
354
|
+
const d = gitRead(repo, ["diff", "-M", "--unified=3", from, head, "--"]);
|
|
355
|
+
if (!d.ok)
|
|
356
|
+
throw inputError(`git diff failed: ${d.stderr.trim()}`);
|
|
357
|
+
diffText = d.stdout;
|
|
358
|
+
mode = "PR_DIFF";
|
|
359
|
+
const h = head;
|
|
360
|
+
headText = (p) => fileAt(repo, h, p);
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
const d = gitRead(repo, ["diff", "-M", "--unified=3", base, "--"]);
|
|
364
|
+
if (!d.ok)
|
|
365
|
+
throw inputError(`git diff failed: ${d.stderr.trim()}`);
|
|
366
|
+
diffText = d.stdout;
|
|
367
|
+
mode = "PRE_PR_LOCAL";
|
|
368
|
+
head = null;
|
|
369
|
+
headText = worktreeText(repo);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const files = parseUnifiedDiff(diffText).slice(0, 2000);
|
|
373
|
+
const authorId = values.author ?? (head ? commitAuthor(repo, head) : null);
|
|
374
|
+
const changeRef = values.change ?? (gitRead(repo, ["rev-parse", "--abbrev-ref", "HEAD"]).stdout.trim() || "HEAD");
|
|
375
|
+
const prevPath = values.previous ? abs(values.previous) : ws.p("change-review.json");
|
|
376
|
+
const prevRaw = readJsonIfExists(prevPath);
|
|
377
|
+
const previous = prevRaw && prevRaw.change_ref === changeRef ? prevRaw : null;
|
|
378
|
+
const baseline = existsSync(resultsPath) ? loadResults(resultsPath) : null;
|
|
379
|
+
const proofRun = ws.proof();
|
|
380
|
+
const review = reviewChange({
|
|
381
|
+
repo,
|
|
382
|
+
targetName: baseline?.results.contract.target.name ?? basename(repo),
|
|
383
|
+
repositoryLabel: rel(repo) || ".",
|
|
384
|
+
mode,
|
|
385
|
+
baseSha: base,
|
|
386
|
+
headSha: head,
|
|
387
|
+
changeRef,
|
|
388
|
+
diffText,
|
|
389
|
+
diffSource: values.diff ? "file" : "git",
|
|
390
|
+
headText,
|
|
391
|
+
policy,
|
|
392
|
+
policyDigest,
|
|
393
|
+
author: { id: authorId, source: values.author ? "flag" : authorId ? "git" : "unknown" },
|
|
394
|
+
impact: values["impact-check"]
|
|
395
|
+
? impactAdapter(readJson(abs(values["impact-check"])), values["impact-check"])
|
|
396
|
+
: null,
|
|
397
|
+
spec: values["spec-check"]
|
|
398
|
+
? specAdapter(readJson(abs(values["spec-check"])), values["spec-check"])
|
|
399
|
+
: null,
|
|
400
|
+
outcome: values["outcome-check"]
|
|
401
|
+
? outcomeAdapter(readJson(abs(values["outcome-check"])), values["outcome-check"])
|
|
402
|
+
: null,
|
|
403
|
+
claims: values["reviewer-findings"] ? loadClaims(abs(values["reviewer-findings"])) : [],
|
|
404
|
+
previous,
|
|
405
|
+
baseline: baseline ? { results: baseline.results, sha256: baseline.sha } : null,
|
|
406
|
+
proofRun,
|
|
407
|
+
approvals: [
|
|
408
|
+
...new Map([...ws.approvals(), ...(ws.loadRegister(false)?.approvals ?? [])].map((a) => [a.approval_id, a])).values(),
|
|
409
|
+
],
|
|
410
|
+
log: log(),
|
|
411
|
+
now: now(),
|
|
412
|
+
files,
|
|
413
|
+
});
|
|
414
|
+
writeJson(ws.p("change-review.json"), review);
|
|
415
|
+
writeJson(ws.p("reviews", `${review.review_id}.json`), review);
|
|
416
|
+
const reg = ws.loadRegister(false);
|
|
417
|
+
let added = [];
|
|
418
|
+
if (reg) {
|
|
419
|
+
added = syncReview(reg, review, now());
|
|
420
|
+
ws.saveRegister(reg);
|
|
421
|
+
}
|
|
422
|
+
if (baseline)
|
|
423
|
+
writeText(ws.p("PRODUCT_CHECK_REVIEW.md"), withFooter(renderReviewMd(review, reg, baseline.results), !values["no-branding"]));
|
|
424
|
+
say([
|
|
425
|
+
`Change review ${review.review_id} (${review.mode}): gate ${review.gate.status}, risk ${review.risk.level}${review.risk.level === "UNDETERMINED" ? " (treated as HIGH)" : ""}, cycle ${review.cycle.number}/${review.cycle.max}`,
|
|
426
|
+
...review.gate.reasons.map((r) => ` - ${r}`),
|
|
427
|
+
` ${review.diff.files.length} file(s); ${review.findings.length} finding(s) (${review.findings.filter((f) => f.blocking).length} blocking, ${review.findings.filter((f) => f.verification.state === "NEEDS_HUMAN_TRIAGE").length} for triage), ${review.disproven.length} disproven`,
|
|
428
|
+
...review.findings
|
|
429
|
+
.slice(0, 15)
|
|
430
|
+
.map((f) => ` ${f.severity} ${f.verification.state.padEnd(18)} ${f.rule_id} ${f.location.path}${f.location.line !== null ? `:${f.location.line}` : ""} — ${f.title}`),
|
|
431
|
+
` Approvals: ${review.approvals.filter((a) => a.status === "VALID").length}/${review.approvals_required.humans} valid (scope change:${review.change_ref})`,
|
|
432
|
+
reg
|
|
433
|
+
? ` Register: ${added.length} new entr(y/ies)`
|
|
434
|
+
: " No closure register yet; run product-check register to track these findings.",
|
|
435
|
+
`Wrote ${rel(ws.p("change-review.json"))}`,
|
|
436
|
+
].join("\n"));
|
|
437
|
+
return failOn(review.gate.status);
|
|
438
|
+
}
|
|
439
|
+
case "register": {
|
|
440
|
+
const { results, sha } = loadResults(resultsPath);
|
|
441
|
+
let reg = ws.loadRegister(false);
|
|
442
|
+
let summary;
|
|
443
|
+
if (!reg) {
|
|
444
|
+
reg = createRegister(results, sha, repo, policy, now());
|
|
445
|
+
summary = `Created closure register ${reg.register_id} with ${reg.entries.length} finding(s) from scan ${results.run.run_id}.`;
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
const s = syncResults(reg, results, repo, now());
|
|
449
|
+
summary = `Synced scan ${results.run.run_id}: ${s.added.length} new, ${s.regressed.length} regressed${s.regressed.length ? ` (${s.regressed.join(", ")})` : ""}. Findings that disappeared are not marked fixed: use verify.`;
|
|
450
|
+
}
|
|
451
|
+
const review = ws.review();
|
|
452
|
+
if (review)
|
|
453
|
+
syncReview(reg, review, now());
|
|
454
|
+
const expired = expireAcceptances(reg, now());
|
|
455
|
+
ws.saveRegister(reg);
|
|
456
|
+
const l = log();
|
|
457
|
+
l.append({
|
|
458
|
+
type: "results-artifact",
|
|
459
|
+
subject: reg.register_id,
|
|
460
|
+
commit_sha: results.run.target_revision.repository?.commit ?? null,
|
|
461
|
+
environment_id: null,
|
|
462
|
+
summary: `Register synced with ${results.run.run_id}`,
|
|
463
|
+
locator: rel(resultsPath),
|
|
464
|
+
line: null,
|
|
465
|
+
payload: { results_sha256: sha, entries: reg.entries.length },
|
|
466
|
+
});
|
|
467
|
+
const by = (s) => reg.entries.filter((e) => e.verification.state === s).length;
|
|
468
|
+
say(`${summary}${expired.length ? ` Expired acceptances re-opened: ${expired.join(", ")}.` : ""}\nVerification: ${by("CONFIRMED")} confirmed, ${by("CORROBORATED")} corroborated, ${by("EVIDENCE_COLLECTED")} evidence only, ${by("NEEDS_HUMAN_TRIAGE")} need triage, ${by("SUSPECTED")} suspected.\nWrote ${rel(ws.registerPath)}`);
|
|
469
|
+
return ExitCode.Ok;
|
|
470
|
+
}
|
|
471
|
+
case "transition": {
|
|
472
|
+
const reg = ws.loadRegister(true);
|
|
473
|
+
const id = one(values.finding, "--finding");
|
|
474
|
+
if (!values.to)
|
|
475
|
+
throw usageError("--to is required.");
|
|
476
|
+
const t = manualTransition(reg, id, values.to, actor(), values.reason ?? "", now(), {
|
|
477
|
+
...(values.owner ? { owner: values.owner } : {}),
|
|
478
|
+
...(values.revisit ? { revisit: values.revisit } : {}),
|
|
479
|
+
});
|
|
480
|
+
ws.saveRegister(reg);
|
|
481
|
+
say(`${id}: ${t.from} → ${t.to} (seq ${t.seq})`);
|
|
482
|
+
return ExitCode.Ok;
|
|
483
|
+
}
|
|
484
|
+
case "fix": {
|
|
485
|
+
const reg = ws.loadRegister(true);
|
|
486
|
+
const ids = values.finding ?? [];
|
|
487
|
+
const sha = values.sha ? (resolveSha(repo, values.sha) ?? values.sha) : "";
|
|
488
|
+
const who = actor();
|
|
489
|
+
const repro = (values["repro-cmd"] ?? []).map((c, i) => ({
|
|
490
|
+
id: `repro-${i + 1}`,
|
|
491
|
+
argv: splitCommand(c),
|
|
492
|
+
purpose: "reproduction",
|
|
493
|
+
timeout_ms: 600_000,
|
|
494
|
+
cwd: ".",
|
|
495
|
+
source: "fix",
|
|
496
|
+
}));
|
|
497
|
+
const fix = recordFix(reg, ids, sha, who, repro, policy, now());
|
|
498
|
+
ws.saveRegister(reg);
|
|
499
|
+
log().append({
|
|
500
|
+
type: "transition",
|
|
501
|
+
subject: fix.fix_id,
|
|
502
|
+
commit_sha: fix.change_sha,
|
|
503
|
+
environment_id: null,
|
|
504
|
+
summary: `Fix ${fix.fix_id} proposed for ${ids.join(", ")} by ${who.id} (${who.type})`,
|
|
505
|
+
locator: null,
|
|
506
|
+
line: null,
|
|
507
|
+
payload: { fix_id: fix.fix_id, finding_ids: ids, reproduction: repro.map((r) => r.argv) },
|
|
508
|
+
});
|
|
509
|
+
say(`Fix ${fix.fix_id} recorded at ${fix.change_sha.slice(0, 12)} for ${ids.join(", ")}: FIX_PROPOSED → RETEST_REQUIRED. Not fixed until verify passes:\n product-check verify --finding ${ids.join(" --finding ")} --head ${fix.change_sha}${repro.length || policy.required_checks.length ? " --allow-exec" : ""}`);
|
|
510
|
+
return ExitCode.Ok;
|
|
511
|
+
}
|
|
512
|
+
case "verify": {
|
|
513
|
+
const reg = ws.loadRegister(true);
|
|
514
|
+
const ids = values.finding ?? [];
|
|
515
|
+
if (ids.length === 0)
|
|
516
|
+
throw usageError("verify needs --finding.");
|
|
517
|
+
if (!values.head)
|
|
518
|
+
throw usageError("verify needs --head <sha> (the exact fix commit).");
|
|
519
|
+
const state = worktreeState(repo, ws.dir);
|
|
520
|
+
const wanted = resolveSha(repo, values.head);
|
|
521
|
+
if (!wanted)
|
|
522
|
+
throw inputError(`Cannot resolve --head ${values.head}.`);
|
|
523
|
+
if (state.head !== wanted)
|
|
524
|
+
throw usageError(`The repository HEAD is ${state.head?.slice(0, 12) ?? "unknown"}, not ${wanted.slice(0, 12)}. Check out the fix commit before verifying.`);
|
|
525
|
+
const baseline = loadResults(resultsPath);
|
|
526
|
+
const l = log();
|
|
527
|
+
const run = await verifyFindings({
|
|
528
|
+
register: reg,
|
|
529
|
+
findingIds: ids,
|
|
530
|
+
repo,
|
|
531
|
+
head: wanted,
|
|
532
|
+
clean: state.clean,
|
|
533
|
+
baseline: { results: baseline.results, sha256: baseline.sha },
|
|
534
|
+
contract: values.contract ? loadContract(abs(values.contract)) : null,
|
|
535
|
+
extraTestResults: (values["test-results"] ?? []).map(abs),
|
|
536
|
+
policy,
|
|
537
|
+
permission,
|
|
538
|
+
env: environmentRecord(repo),
|
|
539
|
+
log: l,
|
|
540
|
+
now,
|
|
541
|
+
baseDir: io.cwd,
|
|
542
|
+
writeAfterResults: (id, r) => {
|
|
543
|
+
const p = ws.p("verification", `${id}-results.json`);
|
|
544
|
+
writeJson(p, r);
|
|
545
|
+
return rel(p);
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
l.append({
|
|
549
|
+
type: "verification",
|
|
550
|
+
subject: run.verification_id,
|
|
551
|
+
commit_sha: run.exact_sha,
|
|
552
|
+
environment_id: run.environment.environment_id,
|
|
553
|
+
summary: `${run.verification_id} ${run.result} for ${ids.join(", ")}`,
|
|
554
|
+
locator: null,
|
|
555
|
+
line: null,
|
|
556
|
+
payload: {
|
|
557
|
+
result: run.result,
|
|
558
|
+
checks: run.checks.map((c) => ({ id: c.check_id, status: c.status })),
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
const out = applyVerification(reg, run, now(), policyDigest ? policy.review.max_cycles : reg.settings.max_cycles);
|
|
562
|
+
ws.saveRegister(reg);
|
|
563
|
+
for (const id of out.escalated) {
|
|
564
|
+
const e = reg.entries.find((x) => x.finding_id === id);
|
|
565
|
+
if (e)
|
|
566
|
+
writeText(ws.p("human-review", `${id}.md`), withFooter(renderHumanPacket(e, reg), !values["no-branding"]));
|
|
567
|
+
}
|
|
568
|
+
say([
|
|
569
|
+
`Verification ${run.verification_id}: ${run.result} at ${wanted.slice(0, 12)}`,
|
|
570
|
+
...run.checks.map((c) => ` ${c.status.padEnd(12)} ${c.check_id} — ${c.detail}`),
|
|
571
|
+
...(run.result === "PASS" ? [] : run.reasons.map((x) => ` reason: ${x}`)),
|
|
572
|
+
...run.limitations.map((x) => ` note: ${x}`),
|
|
573
|
+
out.verified.length
|
|
574
|
+
? `VERIFIED_FIXED: ${out.verified.join(", ")} (close needs signed human approval)`
|
|
575
|
+
: "",
|
|
576
|
+
out.failed.length ? `Back to IN_PROGRESS: ${out.failed.join(", ")}` : "",
|
|
577
|
+
out.escalated.length
|
|
578
|
+
? `ESCALATED (cycle limit): ${out.escalated.join(", ")}; human review packet in ${rel(ws.p("human-review"))}`
|
|
579
|
+
: "",
|
|
580
|
+
out.unchanged.length ? `Unchanged (inconclusive): ${out.unchanged.join(", ")}` : "",
|
|
581
|
+
]
|
|
582
|
+
.filter(Boolean)
|
|
583
|
+
.join("\n"));
|
|
584
|
+
return run.result === "PASS" ? ExitCode.Ok : ExitCode.Gate;
|
|
585
|
+
}
|
|
586
|
+
case "approve": {
|
|
587
|
+
if (!values.scope || !values.sha || !values.approver)
|
|
588
|
+
throw usageError("approve needs --scope <type:ref>, --sha <commit> and --approver <id>.");
|
|
589
|
+
if (!values.key)
|
|
590
|
+
throw usageError("approve needs --key <private.pem>: a typed name is not an approval.", "Create a key with product-check keygen and register its public key in the policy.");
|
|
591
|
+
const m = /^(change|closure|risk-acceptance|false-positive|release|continue):(.+)$/.exec(values.scope);
|
|
592
|
+
if (!m)
|
|
593
|
+
throw usageError("--scope must be change:<ref>, closure:<F-id>, risk-acceptance:<F-id>, false-positive:<F-id>, continue:<F-id> or release:<name>.");
|
|
594
|
+
const decision = values.decision ?? "approve";
|
|
595
|
+
if (decision !== "approve" && decision !== "reject")
|
|
596
|
+
throw usageError("--decision must be approve or reject.");
|
|
597
|
+
const sha = resolveSha(repo, values.sha) ?? values.sha;
|
|
598
|
+
const approver = policy.approvers.find((a) => a.id === values.approver);
|
|
599
|
+
const a = signApproval({
|
|
600
|
+
approver: { id: values.approver, type: approver?.type === "bot" ? "bot" : "human" },
|
|
601
|
+
scope: { type: m[1], ref: m[2] },
|
|
602
|
+
reviewed_sha: sha,
|
|
603
|
+
decision,
|
|
604
|
+
comment: values.comment ?? "",
|
|
605
|
+
created_at: now().toISOString(),
|
|
606
|
+
}, readText(abs(values.key)));
|
|
607
|
+
const all = ws.approvals();
|
|
608
|
+
if (!all.some((x) => x.approval_id === a.approval_id))
|
|
609
|
+
all.push(a);
|
|
610
|
+
ws.saveApprovals(all);
|
|
611
|
+
const reg = ws.loadRegister(false);
|
|
612
|
+
if (reg) {
|
|
613
|
+
addApproval(reg, a);
|
|
614
|
+
ws.saveRegister(reg);
|
|
615
|
+
}
|
|
616
|
+
log().append({
|
|
617
|
+
type: "approval",
|
|
618
|
+
subject: a.approval_id,
|
|
619
|
+
commit_sha: sha,
|
|
620
|
+
environment_id: null,
|
|
621
|
+
summary: `${a.approver.id} ${decision} ${values.scope}`,
|
|
622
|
+
locator: null,
|
|
623
|
+
line: null,
|
|
624
|
+
payload: { approval_id: a.approval_id, scope: a.scope, key_id: a.signature?.key_id ?? null },
|
|
625
|
+
});
|
|
626
|
+
say(`Signed ${decision} ${a.approval_id} by ${a.approver.id} for ${values.scope} at ${sha.slice(0, 12)}${approver ? "" : " (WARNING: approver not in the policy; it will not count)"}.`);
|
|
627
|
+
return ExitCode.Ok;
|
|
628
|
+
}
|
|
629
|
+
case "close": {
|
|
630
|
+
const reg = ws.loadRegister(true);
|
|
631
|
+
const id = one(values.finding, "--finding");
|
|
632
|
+
if (!values.evidence || !values.approver)
|
|
633
|
+
throw usageError("close needs --evidence <VR-id> and --approver <id> (the accountable human closing it).");
|
|
634
|
+
if (!values.policy)
|
|
635
|
+
throw usageError("close needs --policy <p.json> to verify approver signatures.");
|
|
636
|
+
const e = closeFinding(reg, id, values.evidence, values.approver, policy, now());
|
|
637
|
+
ws.saveRegister(reg);
|
|
638
|
+
log().append({
|
|
639
|
+
type: "transition",
|
|
640
|
+
subject: id,
|
|
641
|
+
commit_sha: reg.verification_runs.find((r) => r.verification_id === values.evidence)?.exact_sha ?? null,
|
|
642
|
+
environment_id: null,
|
|
643
|
+
summary: `${id} CLOSED as ${e.closure?.closure_id}`,
|
|
644
|
+
locator: null,
|
|
645
|
+
line: null,
|
|
646
|
+
payload: { closure: e.closure },
|
|
647
|
+
});
|
|
648
|
+
say(`${id} CLOSED as ${e.closure?.closure_id} by ${values.approver} on ${values.evidence} with approvals ${e.closure?.approval_ids.join(", ")}.`);
|
|
649
|
+
return ExitCode.Ok;
|
|
650
|
+
}
|
|
651
|
+
case "accept-risk": {
|
|
652
|
+
const reg = ws.loadRegister(true);
|
|
653
|
+
const id = one(values.finding, "--finding");
|
|
654
|
+
if (!values.policy)
|
|
655
|
+
throw usageError("accept-risk needs --policy <p.json> to verify approver signatures.");
|
|
656
|
+
const head = values.sha ? resolveSha(repo, values.sha) : resolveSha(repo, "HEAD");
|
|
657
|
+
const acc = acceptRisk(reg, {
|
|
658
|
+
finding_id: id,
|
|
659
|
+
owner: values.owner ?? "",
|
|
660
|
+
scope: values["risk-scope"] ?? "",
|
|
661
|
+
rationale: values.rationale ?? "",
|
|
662
|
+
compensating_controls: values.compensating ?? [],
|
|
663
|
+
review_date: values["review-date"] ?? "",
|
|
664
|
+
expires: values.expires ?? "",
|
|
665
|
+
closure_condition: values["closure-condition"] ?? "",
|
|
666
|
+
}, policy, head, now());
|
|
667
|
+
ws.saveRegister(reg);
|
|
668
|
+
say(`${id} ACCEPTED_RISK as ${acc.acceptance_id} (owner ${acc.owner}, review ${acc.review_date}, expires ${acc.expires}). It stays open in the gate as an accepted risk; it is not fixed.`);
|
|
669
|
+
return ExitCode.Ok;
|
|
670
|
+
}
|
|
671
|
+
case "reject": {
|
|
672
|
+
const reg = ws.loadRegister(true);
|
|
673
|
+
const id = one(values.finding, "--finding");
|
|
674
|
+
rejectFalsePositive(reg, id, values.reason ?? "", actor(), policy, now());
|
|
675
|
+
ws.saveRegister(reg);
|
|
676
|
+
say(`${id} REJECTED_FALSE_POSITIVE.`);
|
|
677
|
+
return ExitCode.Ok;
|
|
678
|
+
}
|
|
679
|
+
case "learn": {
|
|
680
|
+
const reg = ws.loadRegister(true);
|
|
681
|
+
const id = one(values.finding, "--finding");
|
|
682
|
+
if (!reg.entries.some((e) => e.finding_id === id))
|
|
683
|
+
throw usageError(`Unknown finding ${id}.`);
|
|
684
|
+
if (!values.cmd)
|
|
685
|
+
throw usageError('learn needs --cmd "<test command>" (a regression or eval case that reproduces the problem).');
|
|
686
|
+
const origin = values.origin ?? "escaped-incident";
|
|
687
|
+
if (!["escaped-incident", "regressed-finding", "verified-fix"].includes(origin))
|
|
688
|
+
throw usageError("--origin must be escaped-incident, regressed-finding or verified-fix.");
|
|
689
|
+
const caseId = `RC-${String(reg.regression_corpus.length + 1).padStart(3, "0")}`;
|
|
690
|
+
reg.regression_corpus.push({
|
|
691
|
+
case_id: caseId,
|
|
692
|
+
finding_id: id,
|
|
693
|
+
origin: origin,
|
|
694
|
+
description: values.description ?? `Regression case for ${id}`,
|
|
695
|
+
command: {
|
|
696
|
+
id: caseId,
|
|
697
|
+
argv: splitCommand(values.cmd),
|
|
698
|
+
purpose: "regression",
|
|
699
|
+
timeout_ms: 600_000,
|
|
700
|
+
cwd: ".",
|
|
701
|
+
source: "corpus",
|
|
702
|
+
},
|
|
703
|
+
added_at: now().toISOString(),
|
|
704
|
+
added_by: actor(false),
|
|
705
|
+
});
|
|
706
|
+
ws.saveRegister(reg);
|
|
707
|
+
say(`Added ${caseId} to the regression corpus; every future verify runs it.`);
|
|
708
|
+
return ExitCode.Ok;
|
|
709
|
+
}
|
|
710
|
+
case "fix-packets":
|
|
711
|
+
case "gate":
|
|
712
|
+
case "assure":
|
|
713
|
+
case "dashboard":
|
|
714
|
+
case "blueprint": {
|
|
715
|
+
const { results, sha } = loadResults(resultsPath);
|
|
716
|
+
const reg = ws.loadRegister(false);
|
|
717
|
+
const repoState = existsSync(repo)
|
|
718
|
+
? worktreeState(repo, ws.dir)
|
|
719
|
+
: { head: null, clean: null, dirty: [] };
|
|
720
|
+
const latest = ws.review();
|
|
721
|
+
const review = latest && latest.head_sha !== null && latest.head_sha === repoState.head ? latest : null;
|
|
722
|
+
const relevantReviewIds = ws
|
|
723
|
+
.reviews()
|
|
724
|
+
.filter((x) => x.head_sha !== null && repoState.head !== null && isAncestor(repo, x.head_sha, repoState.head))
|
|
725
|
+
.map((x) => x.review_id);
|
|
726
|
+
const proof = ws.proof();
|
|
727
|
+
if (reg) {
|
|
728
|
+
const expired = expireAcceptances(reg, now());
|
|
729
|
+
if (expired.length)
|
|
730
|
+
ws.saveRegister(reg);
|
|
731
|
+
}
|
|
732
|
+
const packets = buildFixPackets(reg ?? emptyRegister(results, sha), results, review ? [review] : [], policy);
|
|
733
|
+
const writePackets = () => {
|
|
734
|
+
for (const p of packets)
|
|
735
|
+
writeJson(ws.p("fix-packets", `${p.finding_ids[0] ?? p.fix_id}.json`), p);
|
|
736
|
+
};
|
|
737
|
+
if (command === "fix-packets") {
|
|
738
|
+
writePackets();
|
|
739
|
+
say(`Wrote ${packets.length} fix packet(s) to ${rel(ws.p("fix-packets"))}${reg ? "" : " (no register yet: run product-check register first for verified packets)"}.`);
|
|
740
|
+
return ExitCode.Ok;
|
|
741
|
+
}
|
|
742
|
+
const gate = evaluateGate({
|
|
743
|
+
results,
|
|
744
|
+
resultsSha: sha,
|
|
745
|
+
register: reg,
|
|
746
|
+
review,
|
|
747
|
+
proof,
|
|
748
|
+
packets,
|
|
749
|
+
approvals: ws.approvals(),
|
|
750
|
+
policy,
|
|
751
|
+
headSha: repoState.head,
|
|
752
|
+
relevantReviewIds,
|
|
753
|
+
now: now(),
|
|
754
|
+
});
|
|
755
|
+
if (latest && !review)
|
|
756
|
+
say(`Note: the latest change review (${latest.review_id}) is for ${latest.head_sha?.slice(0, 12) ?? "the worktree"}, not HEAD; it is not part of this gate.`);
|
|
757
|
+
const writeGate = () => {
|
|
758
|
+
writeJson(ws.p("release-gate.json"), gate);
|
|
759
|
+
writeText(ws.p("PRODUCT_CHECK_RELEASE_GATE.md"), withFooter(renderGateMd(gate), !values["no-branding"]));
|
|
760
|
+
};
|
|
761
|
+
if (command === "gate") {
|
|
762
|
+
writeGate();
|
|
763
|
+
say([
|
|
764
|
+
`Release gate: ${gate.decision} (product ${gate.product_verdict}, change ${gate.change_gate ?? "—"})`,
|
|
765
|
+
...gate.blocking_reasons.map((r) => ` - ${r}`),
|
|
766
|
+
`Wrote ${rel(ws.p("release-gate.json"))}`,
|
|
767
|
+
].join("\n"));
|
|
768
|
+
return failOn(gate.decision);
|
|
769
|
+
}
|
|
770
|
+
writePackets();
|
|
771
|
+
writeText(ws.p("PRODUCT_CHECK_BLUEPRINT.md"), withFooter(renderBlueprintMd(results, packets, reg, gate), !values["no-branding"]));
|
|
772
|
+
if (command === "blueprint") {
|
|
773
|
+
say(`Wrote ${rel(ws.p("PRODUCT_CHECK_BLUEPRINT.md"))} and ${packets.length} fix packet(s).`);
|
|
774
|
+
return ExitCode.Ok;
|
|
775
|
+
}
|
|
776
|
+
writeGate();
|
|
777
|
+
writeText(ws.p("PRODUCT_CHECK_REVIEW.md"), withFooter(renderReviewMd(review, reg, results), !values["no-branding"]));
|
|
778
|
+
const bundle = {
|
|
779
|
+
results,
|
|
780
|
+
register: reg,
|
|
781
|
+
review,
|
|
782
|
+
proof,
|
|
783
|
+
packets,
|
|
784
|
+
gate,
|
|
785
|
+
log: readLog(ws.p("review-evidence.jsonl")),
|
|
786
|
+
now: now(),
|
|
787
|
+
};
|
|
788
|
+
const out = values.output ? abs(values.output) : ws.p("dashboard", "index.html");
|
|
789
|
+
writeText(out, renderAssuranceDashboard(bundle, { branding: !values["no-branding"] }));
|
|
790
|
+
for (const e of reg?.entries.filter((x) => x.escalated) ?? [])
|
|
791
|
+
writeText(ws.p("human-review", `${e.finding_id}.md`), withFooter(renderHumanPacket(e, reg), !values["no-branding"]));
|
|
792
|
+
const logErrors = verifyLog(bundle.log);
|
|
793
|
+
say([
|
|
794
|
+
`Release gate: ${gate.decision}`,
|
|
795
|
+
...gate.blocking_reasons.slice(0, 6).map((r) => ` - ${r}`),
|
|
796
|
+
`Phases: ${gate.phases.map((p) => `${p.phase} ${p.status}`).join(" · ")}`,
|
|
797
|
+
logErrors.length
|
|
798
|
+
? `WARNING: evidence log integrity: ${logErrors.join("; ")}`
|
|
799
|
+
: `Evidence log: ${bundle.log.length} record(s), chain intact.`,
|
|
800
|
+
`Wrote ${rel(ws.dir)}/: PRODUCT_CHECK_REVIEW.md, PRODUCT_CHECK_BLUEPRINT.md, PRODUCT_CHECK_RELEASE_GATE.md, release-gate.json, fix-packets/ (${packets.length}), ${rel(out)}`,
|
|
801
|
+
].join("\n"));
|
|
802
|
+
return failOn(gate.decision);
|
|
803
|
+
}
|
|
804
|
+
default:
|
|
805
|
+
throw usageError(`Unknown command: ${command}`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
function isAncestor(repo, ancestor, head) {
|
|
809
|
+
if (ancestor === head)
|
|
810
|
+
return true;
|
|
811
|
+
if (!/^[0-9a-f]{40,64}$/.test(ancestor) || !/^[0-9a-f]{40,64}$/.test(head))
|
|
812
|
+
return false;
|
|
813
|
+
return gitRead(repo, ["merge-base", "--is-ancestor", ancestor, head]).ok;
|
|
814
|
+
}
|
|
815
|
+
function one(list, flag) {
|
|
816
|
+
if (!list || list.length !== 1)
|
|
817
|
+
throw usageError(`Exactly one ${flag} is required.`);
|
|
818
|
+
return list[0];
|
|
819
|
+
}
|
|
820
|
+
function readText(path) {
|
|
821
|
+
try {
|
|
822
|
+
return readFileSync(path, "utf8");
|
|
823
|
+
}
|
|
824
|
+
catch {
|
|
825
|
+
throw inputError(`Cannot read ${path}`);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
function loadClaims(path) {
|
|
829
|
+
const data = readJson(path);
|
|
830
|
+
const errors = validateAgainst("reviewer-findings.schema.json", data);
|
|
831
|
+
if (errors.length > 0)
|
|
832
|
+
throw usageError(`Invalid reviewer findings ${path}:\n${formatErrors(errors)}`);
|
|
833
|
+
const d = data;
|
|
834
|
+
return d.findings.map((f) => ({
|
|
835
|
+
reviewer: d.reviewer,
|
|
836
|
+
type: d.type,
|
|
837
|
+
path: f.path,
|
|
838
|
+
line: f.line,
|
|
839
|
+
title: f.title,
|
|
840
|
+
severity: f.severity,
|
|
841
|
+
body: f.body,
|
|
842
|
+
}));
|
|
843
|
+
}
|
|
844
|
+
function emptyRegister(results, sha) {
|
|
845
|
+
return {
|
|
846
|
+
schema_version: "0.1.0",
|
|
847
|
+
kind: "closure-register",
|
|
848
|
+
methodology_version: "CT3 v3.2",
|
|
849
|
+
register_id: "none",
|
|
850
|
+
target: { name: results.contract.target.name, repository: null },
|
|
851
|
+
created_at: "",
|
|
852
|
+
updated_at: "",
|
|
853
|
+
baseline: { run_id: results.run.run_id, results_sha256: sha, baseline_sha: null },
|
|
854
|
+
settings: { max_cycles: 3 },
|
|
855
|
+
entries: [],
|
|
856
|
+
fixes: [],
|
|
857
|
+
verification_runs: [],
|
|
858
|
+
approvals: [],
|
|
859
|
+
risk_acceptances: [],
|
|
860
|
+
regression_corpus: [],
|
|
861
|
+
chain: { length: 0, head_digest: null },
|
|
862
|
+
};
|
|
863
|
+
}
|