@peterxiaoyang/superspec 0.1.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/README.md +47 -0
- package/adapters/codex/agents/architect.toml +157 -0
- package/adapters/codex/agents/code-reviewer.toml +175 -0
- package/adapters/codex/agents/critic.toml +114 -0
- package/adapters/codex/agents/test-engineer.toml +163 -0
- package/adapters/codex/agents/verifier.toml +119 -0
- package/adapters/codex/install-map.json +81 -0
- package/bin/launch.js +37 -0
- package/bin/superspec-guard.js +4 -0
- package/bin/superspec-init.js +4 -0
- package/bin/superspec.js +4 -0
- package/dist/src/archive.d.ts +23 -0
- package/dist/src/archive.js +428 -0
- package/dist/src/cli.d.ts +1 -0
- package/dist/src/cli.js +20 -0
- package/dist/src/cli_args.d.ts +12 -0
- package/dist/src/cli_args.js +146 -0
- package/dist/src/core.d.ts +19 -0
- package/dist/src/core.js +357 -0
- package/dist/src/disclosure.d.ts +35 -0
- package/dist/src/disclosure.js +671 -0
- package/dist/src/evidence.d.ts +28 -0
- package/dist/src/evidence.js +849 -0
- package/dist/src/gates.d.ts +16 -0
- package/dist/src/gates.js +1470 -0
- package/dist/src/git.d.ts +8 -0
- package/dist/src/git.js +112 -0
- package/dist/src/init_cli.d.ts +2 -0
- package/dist/src/init_cli.js +145 -0
- package/dist/src/install_engine.d.ts +54 -0
- package/dist/src/install_engine.js +351 -0
- package/dist/src/invariants.d.ts +16 -0
- package/dist/src/invariants.js +363 -0
- package/dist/src/openspec.d.ts +18 -0
- package/dist/src/openspec.js +157 -0
- package/dist/src/paths.d.ts +22 -0
- package/dist/src/paths.js +203 -0
- package/dist/src/project_init.d.ts +4 -0
- package/dist/src/project_init.js +161 -0
- package/dist/src/state.d.ts +37 -0
- package/dist/src/state.js +464 -0
- package/dist/src/tasks.d.ts +23 -0
- package/dist/src/tasks.js +225 -0
- package/dist/src/util.d.ts +120 -0
- package/dist/src/util.js +442 -0
- package/dist/superspec.d.ts +4 -0
- package/dist/superspec.js +57 -0
- package/dist/superspec_guard.d.ts +4 -0
- package/dist/superspec_guard.js +19 -0
- package/dist/superspec_init.d.ts +2 -0
- package/dist/superspec_init.js +17 -0
- package/package.json +63 -0
- package/schemas/install-manifest.schema.json +80 -0
- package/templates/sidecar/archive-preservation.json +11 -0
- package/templates/sidecar/business-invariants.md +38 -0
- package/templates/sidecar/config.yaml +13 -0
- package/templates/sidecar/discovery.md +24 -0
- package/templates/sidecar/test-contract.md +26 -0
- package/templates/workflow/prompts/architect.md +113 -0
- package/templates/workflow/prompts/code-reviewer.md +141 -0
- package/templates/workflow/prompts/critic.md +80 -0
- package/templates/workflow/prompts/test-engineer.md +130 -0
- package/templates/workflow/prompts/verifier.md +85 -0
- package/templates/workflow/skills/superspec-apply/SKILL.md +72 -0
- package/templates/workflow/skills/superspec-archive/SKILL.md +41 -0
- package/templates/workflow/skills/superspec-explore/SKILL.md +70 -0
- package/templates/workflow/skills/superspec-propose/SKILL.md +79 -0
- package/templates/workflow/skills/superspec-review/SKILL.md +237 -0
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { CLAIM_ADJUDICATION_DECISIONS, EVIDENCE_KINDS, EVIDENCE_STATUSES, FINAL_VERIFICATION_ROLES, HUMAN_CONFIRMATION_GATES, FINAL_TEST_REQUIRED_FIELDS, FINDING_ADJUDICATION_DECISIONS, FORBIDDEN_FIELDS, MAIN_ADJUDICATION_DECISIONS, MAIN_ADJUDICATION_REQUIRED_FIELDS, REQUEST_CHANGES_ROUTES, ROLE_EVIDENCE_FIELDS, REVIEW_GUIDANCE_ROLES, SELF_REVIEW_MARKERS, SOURCE_GUIDANCE_REQUIRED_FIELDS, TASK_REOPEN_INVALIDITY_CLASSES, TASK_REOPEN_REQUIRED_FIELDS, TASK_REOPEN_RESOLVED_REQUIRED_FIELDS, VERIFY_EVIDENCE_REQUIRED_FIELDS, fingerprint_obj, isObject, reason, renderList, repr, pinned_ref_key, runtime, safe_within, sha256_file, toPosix, walkFiles, } from "./util.js";
|
|
4
|
+
import { normalize_gate } from "./openspec.js";
|
|
5
|
+
import { superspec_dir } from "./paths.js";
|
|
6
|
+
import { findings_schema_reasons, review_digest_schema_reasons, standing_authorization_schema_reasons, user_decision_schema_reasons, } from "./disclosure.js";
|
|
7
|
+
function file_ref_reasons(baseRoot, ev, field, code) {
|
|
8
|
+
const problems = [];
|
|
9
|
+
const raw = ev[field];
|
|
10
|
+
if (raw === undefined)
|
|
11
|
+
return problems;
|
|
12
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
13
|
+
problems.push(reason(code, `${ev._path}: ${field} must be a non-empty list`));
|
|
14
|
+
return problems;
|
|
15
|
+
}
|
|
16
|
+
for (const refItem of raw) {
|
|
17
|
+
if (typeof refItem !== "string" || !refItem) {
|
|
18
|
+
problems.push(reason(code, `${ev._path}: ${field} entries must be non-empty strings`));
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const target = safe_within(baseRoot, refItem);
|
|
22
|
+
if (target === null) {
|
|
23
|
+
problems.push(reason("evidence_unsafe_ref", `${ev._path}: ${field} escapes allowed root: ${refItem}`));
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (!existsSync(target) || !statSync(target).isFile()) {
|
|
27
|
+
problems.push(reason(code, `${ev._path}: ${field} is not readable: ${refItem}`));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return problems;
|
|
31
|
+
}
|
|
32
|
+
function pinned_ref_list_reasons(ev, field, baseRoot, staleCode, opts = {}) {
|
|
33
|
+
const problems = [];
|
|
34
|
+
const raw = ev[field];
|
|
35
|
+
if (!Array.isArray(raw)) {
|
|
36
|
+
problems.push(reason("evidence_missing_field", `${ev._path}: ${field} must be a list`));
|
|
37
|
+
return problems;
|
|
38
|
+
}
|
|
39
|
+
if (!opts.allowEmpty && raw.length === 0) {
|
|
40
|
+
problems.push(reason("evidence_missing_field", `${ev._path}: ${field} must be a non-empty list`));
|
|
41
|
+
return problems;
|
|
42
|
+
}
|
|
43
|
+
for (const refItem of raw) {
|
|
44
|
+
if (!isObject(refItem)) {
|
|
45
|
+
problems.push(reason(staleCode, `${ev._path}: ${field} item must be object`));
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const rel = refItem.path;
|
|
49
|
+
const expected = refItem.blob_sha;
|
|
50
|
+
if (typeof rel !== "string" || !rel || typeof expected !== "string" || !expected) {
|
|
51
|
+
problems.push(reason(staleCode, `${ev._path}: ${field} requires path and blob_sha`));
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const target = safe_within(baseRoot, rel);
|
|
55
|
+
if (target === null) {
|
|
56
|
+
problems.push(reason("evidence_unsafe_ref", `${ev._path}: ${field} escapes allowed root: ${rel}`));
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!existsSync(target) || !statSync(target).isFile()) {
|
|
60
|
+
problems.push(reason(staleCode, `${ev._path}: ${field} is not readable: ${rel}`));
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const actual = runtime.file_blob_sha(target);
|
|
64
|
+
if (actual !== expected)
|
|
65
|
+
problems.push(reason(staleCode, `${ev._path}: ${field} stale for ${rel}`));
|
|
66
|
+
}
|
|
67
|
+
return problems;
|
|
68
|
+
}
|
|
69
|
+
function pinned_ref_reasons(ev, field, baseRoot, staleCode) {
|
|
70
|
+
const problems = [];
|
|
71
|
+
for (const refItem of ev[field] ?? []) {
|
|
72
|
+
if (!isObject(refItem)) {
|
|
73
|
+
problems.push(reason(staleCode, `${ev._path}: ${field} item must be object`));
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const rel = refItem.path;
|
|
77
|
+
const expected = refItem.blob_sha;
|
|
78
|
+
if (typeof rel !== "string" || typeof expected !== "string") {
|
|
79
|
+
problems.push(reason(staleCode, `${ev._path}: ${field} requires path and blob_sha`));
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const target = safe_within(baseRoot, rel);
|
|
83
|
+
if (target === null) {
|
|
84
|
+
problems.push(reason("evidence_unsafe_ref", `${ev._path}: ${field} escapes allowed root: ${rel}`));
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!existsSync(target) || !statSync(target).isFile()) {
|
|
88
|
+
problems.push(reason(staleCode, `${ev._path}: ${field} is not readable: ${rel}`));
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const actual = runtime.file_blob_sha(target);
|
|
92
|
+
if (actual !== expected)
|
|
93
|
+
problems.push(reason(staleCode, `${ev._path}: ${field} stale for ${rel}`));
|
|
94
|
+
}
|
|
95
|
+
return problems;
|
|
96
|
+
}
|
|
97
|
+
export function role_target_ref_reasons(ev, targetRoot) {
|
|
98
|
+
return pinned_ref_reasons(ev, "target_refs", targetRoot, "stale_review");
|
|
99
|
+
}
|
|
100
|
+
export function loaded_ref_reasons(ev, repoRoot) {
|
|
101
|
+
return pinned_ref_reasons(ev, "loaded_refs", repoRoot, "stale_loaded_ref");
|
|
102
|
+
}
|
|
103
|
+
export function verify_reference_reasons(changeRoot, ev, evidences) {
|
|
104
|
+
const problems = [];
|
|
105
|
+
for (const field of ["openspec_validate_ref", "task_matrix_ref", "invariant_matrix_ref", "scope_drift_ref"]) {
|
|
106
|
+
const value = ev[field];
|
|
107
|
+
if (typeof value !== "string" || !value)
|
|
108
|
+
continue;
|
|
109
|
+
const filePath = safe_within(changeRoot, value);
|
|
110
|
+
if (filePath === null)
|
|
111
|
+
problems.push(reason("verification_ref_invalid", `${ev._path}: ${field} escapes change root: ${value}`));
|
|
112
|
+
else if (!existsSync(filePath) || !statSync(filePath).isFile())
|
|
113
|
+
problems.push(reason("verification_ref_missing", `${ev._path}: ${field} is not readable: ${value}`));
|
|
114
|
+
}
|
|
115
|
+
const refs = ev.test_evidence_refs;
|
|
116
|
+
if (refs === undefined)
|
|
117
|
+
return problems;
|
|
118
|
+
if (!Array.isArray(refs) || refs.length === 0) {
|
|
119
|
+
problems.push(reason("verification_evidence_incomplete", `${ev._path}: test_evidence_refs must be a non-empty list`));
|
|
120
|
+
return problems;
|
|
121
|
+
}
|
|
122
|
+
if (evidences.length === 0)
|
|
123
|
+
return problems;
|
|
124
|
+
const liveIds = new Set(live_pass(evidences).map((item) => item.evidence_id));
|
|
125
|
+
const missing = refs.map((item) => String(item)).filter((item) => !liveIds.has(item)).sort();
|
|
126
|
+
if (missing.length > 0)
|
|
127
|
+
problems.push(reason("test_evidence_ref_missing", `${ev._path}: test_evidence_refs not live/pass: ${renderList(missing)}`, missing));
|
|
128
|
+
return problems;
|
|
129
|
+
}
|
|
130
|
+
function is_final_verification_evidence(ev) {
|
|
131
|
+
if (ev.kind === "final_test" || ev.kind === "verification_review")
|
|
132
|
+
return true;
|
|
133
|
+
if (ev.agent_role === "verifier")
|
|
134
|
+
return true;
|
|
135
|
+
return VERIFY_EVIDENCE_REQUIRED_FIELDS.some((field) => field in ev);
|
|
136
|
+
}
|
|
137
|
+
export function is_final_verification_only_evidence(ev) {
|
|
138
|
+
return ev.kind === "final_test" || ev.kind === "verification_review" || ev.agent_role === "verifier";
|
|
139
|
+
}
|
|
140
|
+
export function final_verification_evidences(evidences) {
|
|
141
|
+
return live_pass(evidences, { gate: "review_complete" }).filter(is_final_verification_evidence);
|
|
142
|
+
}
|
|
143
|
+
export function index_evidence(changeRoot) {
|
|
144
|
+
const evRoot = join(superspec_dir(changeRoot), "evidence");
|
|
145
|
+
const out = [];
|
|
146
|
+
if (!existsSync(evRoot) || !statSync(evRoot).isDirectory())
|
|
147
|
+
return out;
|
|
148
|
+
for (const filePath of walkFiles(evRoot).sort()) {
|
|
149
|
+
if (!filePath.endsWith(".json"))
|
|
150
|
+
continue;
|
|
151
|
+
try {
|
|
152
|
+
const data = JSON.parse(readFileSync(filePath, "utf8"));
|
|
153
|
+
data._path = toPosix(relative(changeRoot, filePath));
|
|
154
|
+
out.push(data);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
out.push({ _invalid: true, _path: toPosix(relative(changeRoot, filePath)) });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
export function evidence_fingerprint(changeRoot) {
|
|
163
|
+
const evRoot = join(superspec_dir(changeRoot), "evidence");
|
|
164
|
+
const items = [];
|
|
165
|
+
if (existsSync(evRoot) && statSync(evRoot).isDirectory()) {
|
|
166
|
+
for (const filePath of walkFiles(evRoot).sort()) {
|
|
167
|
+
if (filePath.endsWith(".json"))
|
|
168
|
+
items.push({ path: toPosix(relative(changeRoot, filePath)), sha: sha256_file(filePath) });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return fingerprint_obj(items);
|
|
172
|
+
}
|
|
173
|
+
function output_ref_reasons(ev, changeRoot, field = "output_ref", refCodes = { missing: "evidence_output_missing", empty: "evidence_output_empty" }) {
|
|
174
|
+
const problems = [];
|
|
175
|
+
const outputRef = ev[field];
|
|
176
|
+
if (typeof outputRef !== "string" || !outputRef) {
|
|
177
|
+
problems.push(reason("evidence_missing_field", `${ev._path}: ${field} must be a string`));
|
|
178
|
+
return problems;
|
|
179
|
+
}
|
|
180
|
+
const outputPath = safe_within(changeRoot, outputRef);
|
|
181
|
+
if (outputPath === null)
|
|
182
|
+
problems.push(reason("evidence_unsafe_ref", `${ev._path}: ${field} escapes change root: ${outputRef}`));
|
|
183
|
+
else if (!existsSync(outputPath) || !statSync(outputPath).isFile())
|
|
184
|
+
problems.push(reason(refCodes.missing, `${ev._path}: ${field} is not readable: ${outputRef}`));
|
|
185
|
+
else {
|
|
186
|
+
const text = readFileSync(outputPath, "utf8");
|
|
187
|
+
if (text.trim().length === 0)
|
|
188
|
+
problems.push(reason(refCodes.empty, `${ev._path}: ${field} must point to non-empty review/test output: ${outputRef}`));
|
|
189
|
+
}
|
|
190
|
+
return problems;
|
|
191
|
+
}
|
|
192
|
+
function file_identity(path) {
|
|
193
|
+
const st = statSync(path);
|
|
194
|
+
return `${st.dev}:${st.ino}`;
|
|
195
|
+
}
|
|
196
|
+
function output_ref_target_overlap_reasons(ev, changeRoot, targetRoot) {
|
|
197
|
+
const problems = [];
|
|
198
|
+
const outputRef = ev.output_ref;
|
|
199
|
+
if (typeof outputRef !== "string" || !outputRef || !Array.isArray(ev.target_refs))
|
|
200
|
+
return problems;
|
|
201
|
+
const outputPath = safe_within(changeRoot, outputRef);
|
|
202
|
+
if (outputPath === null || !existsSync(outputPath) || !statSync(outputPath).isFile())
|
|
203
|
+
return problems;
|
|
204
|
+
const outputIdentity = file_identity(outputPath);
|
|
205
|
+
for (const refItem of ev.target_refs) {
|
|
206
|
+
if (!isObject(refItem) || typeof refItem.path !== "string" || !refItem.path)
|
|
207
|
+
continue;
|
|
208
|
+
const targetPath = safe_within(targetRoot, refItem.path);
|
|
209
|
+
if (targetPath === null || !existsSync(targetPath) || !statSync(targetPath).isFile())
|
|
210
|
+
continue;
|
|
211
|
+
if (file_identity(targetPath) === outputIdentity) {
|
|
212
|
+
problems.push(reason("evidence_output_ref_invalid", `${ev._path}: output_ref must not point at a reviewed target file: ${outputRef}`));
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return problems;
|
|
217
|
+
}
|
|
218
|
+
function string_list_reasons(ev, field, opts = {}) {
|
|
219
|
+
const problems = [];
|
|
220
|
+
const raw = ev[field];
|
|
221
|
+
if (!Array.isArray(raw)) {
|
|
222
|
+
problems.push(reason("evidence_missing_field", `${ev._path}: ${field} must be a list`));
|
|
223
|
+
return problems;
|
|
224
|
+
}
|
|
225
|
+
if (!opts.allowEmpty && raw.length === 0) {
|
|
226
|
+
problems.push(reason("evidence_missing_field", `${ev._path}: ${field} must be a non-empty list`));
|
|
227
|
+
return problems;
|
|
228
|
+
}
|
|
229
|
+
for (const item of raw) {
|
|
230
|
+
if (typeof item !== "string" || !item)
|
|
231
|
+
problems.push(reason("evidence_missing_field", `${ev._path}: ${field} entries must be non-empty strings`));
|
|
232
|
+
}
|
|
233
|
+
return problems;
|
|
234
|
+
}
|
|
235
|
+
function object_list_reasons(ev, field, opts = {}) {
|
|
236
|
+
const problems = [];
|
|
237
|
+
const raw = ev[field];
|
|
238
|
+
if (!Array.isArray(raw)) {
|
|
239
|
+
problems.push(reason("evidence_missing_field", `${ev._path}: ${field} must be a list`));
|
|
240
|
+
return problems;
|
|
241
|
+
}
|
|
242
|
+
if (!opts.allowEmpty && raw.length === 0) {
|
|
243
|
+
problems.push(reason("evidence_missing_field", `${ev._path}: ${field} must be a non-empty list`));
|
|
244
|
+
return problems;
|
|
245
|
+
}
|
|
246
|
+
for (const item of raw) {
|
|
247
|
+
if (!isObject(item))
|
|
248
|
+
problems.push(reason("evidence_missing_field", `${ev._path}: ${field} entries must be objects`));
|
|
249
|
+
}
|
|
250
|
+
return problems;
|
|
251
|
+
}
|
|
252
|
+
function finding_string_list_reasons(ev, finding, field, opts = {}) {
|
|
253
|
+
const problems = [];
|
|
254
|
+
const raw = finding[field];
|
|
255
|
+
if (!Array.isArray(raw)) {
|
|
256
|
+
problems.push(reason("blocking_findings_invalid", `${ev._path}: blocking finding ${repr(finding.finding_id)} requires ${field} to be a list`));
|
|
257
|
+
return problems;
|
|
258
|
+
}
|
|
259
|
+
const values = raw.map((item) => String(item)).filter(Boolean);
|
|
260
|
+
if (!opts.allowEmpty && values.length === 0) {
|
|
261
|
+
problems.push(reason("blocking_findings_invalid", `${ev._path}: blocking finding ${repr(finding.finding_id)} requires non-empty ${field}`));
|
|
262
|
+
return problems;
|
|
263
|
+
}
|
|
264
|
+
if (values.length !== raw.length) {
|
|
265
|
+
problems.push(reason("blocking_findings_invalid", `${ev._path}: blocking finding ${repr(finding.finding_id)} requires non-empty string entries in ${field}`));
|
|
266
|
+
}
|
|
267
|
+
return problems;
|
|
268
|
+
}
|
|
269
|
+
// FIX-7 (audit C-3): human_confirmation minimal schema. The confirmation must say what was
|
|
270
|
+
// confirmed (confirmation_text), reference the confirmed content (confirmed_refs, or
|
|
271
|
+
// confirmed_paths for branch_handling), and target a gate where the guard actually consumes it.
|
|
272
|
+
function human_confirmation_reasons(ev) {
|
|
273
|
+
const problems = [];
|
|
274
|
+
const gate = normalize_gate(String(ev.gate ?? ""));
|
|
275
|
+
if (!HUMAN_CONFIRMATION_GATES.has(gate)) {
|
|
276
|
+
problems.push(reason("human_confirmation_invalid", `${ev._path}: human_confirmation gate=${repr(ev.gate)} is not consumed by any guard gate; expected one of ${renderList([...HUMAN_CONFIRMATION_GATES].sort())}`));
|
|
277
|
+
}
|
|
278
|
+
if (typeof ev.confirmation_text !== "string" || !ev.confirmation_text.trim()) {
|
|
279
|
+
problems.push(reason("human_confirmation_invalid", `${ev._path}: human_confirmation requires non-empty confirmation_text`));
|
|
280
|
+
}
|
|
281
|
+
const refsField = gate === "branch_handling" ? "confirmed_paths" : "confirmed_refs";
|
|
282
|
+
const refs = ev[refsField];
|
|
283
|
+
if (!Array.isArray(refs) || refs.length === 0 || !refs.every((item) => typeof item === "string" && item.trim())) {
|
|
284
|
+
problems.push(reason("human_confirmation_invalid", `${ev._path}: human_confirmation requires non-empty string list ${refsField}`));
|
|
285
|
+
}
|
|
286
|
+
// FIX-8 (audit A-5): apply-scope confirmations must pin the approved tasks.md structure so the
|
|
287
|
+
// guard can detect scope expansion (structural tasks.md edits) after the user's approval.
|
|
288
|
+
if ((gate === "apply_isolation" || gate === "scope_expansion")
|
|
289
|
+
&& (typeof ev.tasks_structure_hash !== "string" || !ev.tasks_structure_hash.trim())) {
|
|
290
|
+
problems.push(reason("human_confirmation_invalid", `${ev._path}: ${gate} human_confirmation requires tasks_structure_hash pinning the approved tasks.md structure`));
|
|
291
|
+
}
|
|
292
|
+
return problems;
|
|
293
|
+
}
|
|
294
|
+
// FIX-10 (audit C-5/H-4): test_run evidence is archived per run, not as a bare self-report.
|
|
295
|
+
// Every run must reference its raw log(s) (readable, non-empty, change-root relative), carry a
|
|
296
|
+
// result_summary, and every claimed test id (test_id / test_ids[]) must actually appear in at
|
|
297
|
+
// least one referenced log — a grep-level reality check; exit-code truth stays a v2 hook concern.
|
|
298
|
+
// Per-test fan-out stays legal, but a consolidated per-run record with test_ids[] is preferred.
|
|
299
|
+
function test_run_reasons(ev, changeRoot) {
|
|
300
|
+
const problems = [];
|
|
301
|
+
const rawRefs = ev.raw_log_refs;
|
|
302
|
+
const logTexts = [];
|
|
303
|
+
if (!Array.isArray(rawRefs) || rawRefs.length === 0 || !rawRefs.every((item) => typeof item === "string" && item.trim())) {
|
|
304
|
+
problems.push(reason("test_run_log_missing", `${ev._path}: test_run requires non-empty string list raw_log_refs`));
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
for (const refItem of rawRefs) {
|
|
308
|
+
const target = safe_within(changeRoot, refItem);
|
|
309
|
+
if (target === null) {
|
|
310
|
+
problems.push(reason("evidence_unsafe_ref", `${ev._path}: raw_log_refs escapes change root: ${refItem}`));
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (!existsSync(target) || !statSync(target).isFile()) {
|
|
314
|
+
problems.push(reason("test_run_log_missing", `${ev._path}: raw_log_refs is not readable: ${refItem}`));
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const text = readFileSync(target, "utf8");
|
|
318
|
+
if (!text.trim()) {
|
|
319
|
+
problems.push(reason("test_run_log_missing", `${ev._path}: raw_log_refs must point to non-empty test output: ${refItem}`));
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
logTexts.push(text);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (typeof ev.result_summary !== "string" || !ev.result_summary.trim()) {
|
|
326
|
+
problems.push(reason("test_run_summary_missing", `${ev._path}: test_run requires non-empty result_summary`));
|
|
327
|
+
}
|
|
328
|
+
if (logTexts.length > 0) {
|
|
329
|
+
const claimed = [
|
|
330
|
+
...(typeof ev.test_id === "string" && ev.test_id.trim() ? [ev.test_id.trim()] : []),
|
|
331
|
+
...(Array.isArray(ev.test_ids) ? ev.test_ids.filter((item) => typeof item === "string" && item.trim()).map((item) => String(item).trim()) : []),
|
|
332
|
+
];
|
|
333
|
+
for (const testId of claimed) {
|
|
334
|
+
if (!logTexts.some((text) => text.includes(testId))) {
|
|
335
|
+
problems.push(reason("test_id_not_in_log", `${ev._path}: claimed test_id ${repr(testId)} does not appear in any referenced raw log`, [testId]));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return problems;
|
|
340
|
+
}
|
|
341
|
+
export function validate_evidence_schema(ev, change, changeRoot, repoRoot) {
|
|
342
|
+
const problems = [];
|
|
343
|
+
if (ev._invalid)
|
|
344
|
+
return [reason("evidence_unparsable", `evidence not valid json: ${ev._path}`)];
|
|
345
|
+
for (const field of ["schema_version", "evidence_id", "change_id", "gate", "kind", "created_at", "created_by", "status"]) {
|
|
346
|
+
if (!(field in ev))
|
|
347
|
+
problems.push(reason("evidence_missing_field", `${ev._path}: missing ${field}`));
|
|
348
|
+
}
|
|
349
|
+
if (!EVIDENCE_STATUSES.has(String(ev.status)))
|
|
350
|
+
problems.push(reason("evidence_bad_status", `${ev._path}: status=${repr(ev.status)}`));
|
|
351
|
+
// FIX-7 (audit C-3): unknown kinds fail loudly instead of silently becoming "missing evidence".
|
|
352
|
+
if (ev.kind !== undefined && !EVIDENCE_KINDS.has(String(ev.kind))) {
|
|
353
|
+
problems.push(reason("evidence_unknown_kind", `${ev._path}: unknown kind ${repr(ev.kind)}; known kinds: ${renderList([...EVIDENCE_KINDS].sort())}`));
|
|
354
|
+
}
|
|
355
|
+
if (ev.kind === "human_confirmation")
|
|
356
|
+
problems.push(...human_confirmation_reasons(ev));
|
|
357
|
+
if (ev.kind === "test_run")
|
|
358
|
+
problems.push(...test_run_reasons(ev, changeRoot));
|
|
359
|
+
// DISC Phase 1: disclosure evidence kinds and reviewer findings[] are schema-checked fail-closed.
|
|
360
|
+
if (ev.kind === "main_review_digest")
|
|
361
|
+
problems.push(...review_digest_schema_reasons(ev));
|
|
362
|
+
if (ev.kind === "user_review_decision")
|
|
363
|
+
problems.push(...user_decision_schema_reasons(ev));
|
|
364
|
+
if (ev.kind === "review_standing_authorization")
|
|
365
|
+
problems.push(...standing_authorization_schema_reasons(ev));
|
|
366
|
+
if (ev.findings !== undefined)
|
|
367
|
+
problems.push(...findings_schema_reasons(ev));
|
|
368
|
+
if (ev.change_id !== undefined && ev.change_id !== change)
|
|
369
|
+
problems.push(reason("evidence_change_mismatch", `${ev._path}: change_id=${repr(ev.change_id)} != ${change}`));
|
|
370
|
+
for (const forbidden of Object.keys(ev).filter((key) => FORBIDDEN_FIELDS.has(key))) {
|
|
371
|
+
problems.push(reason("evidence_forbidden_field", `${ev._path}: forbidden ${forbidden}`));
|
|
372
|
+
}
|
|
373
|
+
const base = superspec_dir(changeRoot);
|
|
374
|
+
for (const refItem of ev.refs ?? []) {
|
|
375
|
+
if (safe_within(base, String(refItem)) === null)
|
|
376
|
+
problems.push(reason("evidence_unsafe_ref", `${ev._path}: ref escapes .superspec: ${refItem}`));
|
|
377
|
+
}
|
|
378
|
+
if (ev.agent_role) {
|
|
379
|
+
const mode = ev.execution_mode;
|
|
380
|
+
if (mode === "direct")
|
|
381
|
+
problems.push(reason("self_review_not_allowed", `${ev._path}: role evidence cannot use execution_mode=direct`));
|
|
382
|
+
else if (mode !== "native_subagent")
|
|
383
|
+
problems.push(reason("missing_native_subagent_evidence", `${ev._path}: role evidence requires execution_mode=native_subagent`));
|
|
384
|
+
const markerValues = new Set([String(ev.created_by ?? "").toLowerCase(), String(ev.agent_id ?? "").toLowerCase()]);
|
|
385
|
+
if ([...markerValues].some((item) => SELF_REVIEW_MARKERS.has(item)))
|
|
386
|
+
problems.push(reason("self_review_not_allowed", `${ev._path}: main-thread/self-review marker is not allowed`));
|
|
387
|
+
for (const field of ROLE_EVIDENCE_FIELDS) {
|
|
388
|
+
if (!(field in ev))
|
|
389
|
+
problems.push(reason("evidence_missing_field", `${ev._path}: role evidence missing ${field}`));
|
|
390
|
+
}
|
|
391
|
+
const outputRef = ev.output_ref;
|
|
392
|
+
if (outputRef !== undefined)
|
|
393
|
+
problems.push(...output_ref_reasons(ev, changeRoot));
|
|
394
|
+
// FIX-9 (audit C-4): prompt_ref gets the same readable/non-empty treatment as output_ref;
|
|
395
|
+
// a role evidence whose prompt never existed is a forged delegation claim by construction.
|
|
396
|
+
if (ev.prompt_ref !== undefined) {
|
|
397
|
+
problems.push(...output_ref_reasons(ev, changeRoot, "prompt_ref", { missing: "evidence_prompt_missing", empty: "evidence_prompt_empty" }));
|
|
398
|
+
}
|
|
399
|
+
if (!Array.isArray(ev.target_refs) || ev.target_refs.length === 0)
|
|
400
|
+
problems.push(reason("missing_target_refs", `${ev._path}: role evidence requires non-empty target_refs`));
|
|
401
|
+
const targetRoot = ev.kind === "source_guidance" ? repoRoot : changeRoot;
|
|
402
|
+
problems.push(...role_target_ref_reasons(ev, targetRoot));
|
|
403
|
+
problems.push(...output_ref_target_overlap_reasons(ev, changeRoot, targetRoot));
|
|
404
|
+
}
|
|
405
|
+
if (ev.kind === "source_guidance") {
|
|
406
|
+
if (!REVIEW_GUIDANCE_ROLES.includes(String(ev.agent_role))) {
|
|
407
|
+
problems.push(reason("review_guidance_role_invalid", `${ev._path}: source_guidance requires agent_role in ${renderList([...REVIEW_GUIDANCE_ROLES])}`));
|
|
408
|
+
}
|
|
409
|
+
for (const field of SOURCE_GUIDANCE_REQUIRED_FIELDS) {
|
|
410
|
+
if (!(field in ev))
|
|
411
|
+
problems.push(reason("evidence_missing_field", `${ev._path}: source_guidance missing ${field}`));
|
|
412
|
+
}
|
|
413
|
+
problems.push(...pinned_ref_list_reasons(ev, "source_refs", repoRoot, "source_ref_invalid"));
|
|
414
|
+
problems.push(...pinned_ref_list_reasons(ev, "required_load_refs", repoRoot, "required_load_invalid", { allowEmpty: true }));
|
|
415
|
+
problems.push(...string_list_reasons(ev, "required_claim_ids", { allowEmpty: true }));
|
|
416
|
+
problems.push(...object_list_reasons(ev, "blocking_findings", { allowEmpty: true }));
|
|
417
|
+
problems.push(...object_list_reasons(ev, "non_blocking_findings", { allowEmpty: true }));
|
|
418
|
+
problems.push(...object_list_reasons(ev, "finding_dispositions", { allowEmpty: true }));
|
|
419
|
+
if (Array.isArray(ev.required_load_refs) && Array.isArray(ev.source_refs)) {
|
|
420
|
+
const allowed = new Set(ev.source_refs.filter(isObject).map((item) => pinned_ref_key(item)));
|
|
421
|
+
const missing = ev.required_load_refs
|
|
422
|
+
.filter(isObject)
|
|
423
|
+
.map((item) => ({ item, key: pinned_ref_key(item) }))
|
|
424
|
+
.filter(({ key }) => !allowed.has(key))
|
|
425
|
+
.map(({ item }) => String(item.path));
|
|
426
|
+
if (missing.length > 0)
|
|
427
|
+
problems.push(reason("required_load_invalid", `${ev._path}: required_load_refs must be drawn from source_refs: ${renderList(missing)}`, missing));
|
|
428
|
+
}
|
|
429
|
+
const blockingIds = new Set();
|
|
430
|
+
for (const finding of Array.isArray(ev.blocking_findings) ? ev.blocking_findings : []) {
|
|
431
|
+
if (!isObject(finding) || typeof finding.finding_id !== "string" || !finding.finding_id) {
|
|
432
|
+
problems.push(reason("blocking_findings_invalid", `${ev._path}: blocking_findings entries require non-empty finding_id`));
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
blockingIds.add(finding.finding_id);
|
|
436
|
+
if ("affected_task_ids" in finding)
|
|
437
|
+
problems.push(...finding_string_list_reasons(ev, finding, "affected_task_ids"));
|
|
438
|
+
if ("violated_test_ids" in finding)
|
|
439
|
+
problems.push(...finding_string_list_reasons(ev, finding, "violated_test_ids", { allowEmpty: true }));
|
|
440
|
+
if ("violated_requirement_refs" in finding)
|
|
441
|
+
problems.push(...finding_string_list_reasons(ev, finding, "violated_requirement_refs", { allowEmpty: true }));
|
|
442
|
+
if ("why_completion_invalid" in finding && (typeof finding.why_completion_invalid !== "string" || !finding.why_completion_invalid)) {
|
|
443
|
+
problems.push(reason("blocking_findings_invalid", `${ev._path}: blocking finding ${repr(finding.finding_id)} requires why_completion_invalid`));
|
|
444
|
+
}
|
|
445
|
+
if ("required_fix" in finding && (typeof finding.required_fix !== "string" || !finding.required_fix)) {
|
|
446
|
+
problems.push(reason("blocking_findings_invalid", `${ev._path}: blocking finding ${repr(finding.finding_id)} requires required_fix`));
|
|
447
|
+
}
|
|
448
|
+
if ("completion_invalidity_class" in finding
|
|
449
|
+
&& !TASK_REOPEN_INVALIDITY_CLASSES.includes(String(finding.completion_invalidity_class))) {
|
|
450
|
+
problems.push(reason("blocking_findings_invalid", `${ev._path}: blocking finding ${repr(finding.finding_id)} has unsupported completion_invalidity_class=${repr(finding.completion_invalidity_class)}`));
|
|
451
|
+
}
|
|
452
|
+
if ("scope_expansion" in finding && typeof finding.scope_expansion !== "boolean") {
|
|
453
|
+
problems.push(reason("blocking_findings_invalid", `${ev._path}: blocking finding ${repr(finding.finding_id)} requires boolean scope_expansion`));
|
|
454
|
+
}
|
|
455
|
+
if ("reopen_recommendation" in finding && typeof finding.reopen_recommendation !== "boolean") {
|
|
456
|
+
problems.push(reason("blocking_findings_invalid", `${ev._path}: blocking finding ${repr(finding.finding_id)} requires boolean reopen_recommendation`));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const dispositionCounts = new Map();
|
|
460
|
+
for (const finding of Array.isArray(ev.finding_dispositions) ? ev.finding_dispositions : []) {
|
|
461
|
+
if (!isObject(finding) || typeof finding.finding_id !== "string" || !finding.finding_id || typeof finding.recommendation !== "string" || !finding.recommendation || typeof finding.rationale !== "string" || !finding.rationale) {
|
|
462
|
+
problems.push(reason("finding_disposition_invalid", `${ev._path}: finding_dispositions entries require finding_id, recommendation, and rationale`));
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
dispositionCounts.set(finding.finding_id, (dispositionCounts.get(finding.finding_id) ?? 0) + 1);
|
|
466
|
+
}
|
|
467
|
+
const missingDispositions = [...blockingIds].filter((id) => !dispositionCounts.has(id)).sort();
|
|
468
|
+
if (missingDispositions.length > 0) {
|
|
469
|
+
problems.push(reason("finding_disposition_invalid", `${ev._path}: finding_dispositions must cover each blocking finding exactly once: ${renderList(missingDispositions)}`, missingDispositions));
|
|
470
|
+
}
|
|
471
|
+
const duplicatedDispositions = [...dispositionCounts.entries()].filter(([, count]) => count > 1).map(([id]) => id).sort();
|
|
472
|
+
if (duplicatedDispositions.length > 0) {
|
|
473
|
+
problems.push(reason("finding_disposition_invalid", `${ev._path}: finding_dispositions duplicate finding_id: ${renderList(duplicatedDispositions)}`, duplicatedDispositions));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (ev.kind === "main_adjudication") {
|
|
477
|
+
if (ev.execution_mode !== "direct")
|
|
478
|
+
problems.push(reason("main_adjudication_invalid", `${ev._path}: main_adjudication must set execution_mode="direct"`));
|
|
479
|
+
if (ev.created_by !== "main-thread")
|
|
480
|
+
problems.push(reason("main_adjudication_invalid", `${ev._path}: main_adjudication must set created_by="main-thread"`));
|
|
481
|
+
for (const forbiddenField of ["agent_role", "agent_id", "prompt_ref"]) {
|
|
482
|
+
if (ev[forbiddenField] !== undefined)
|
|
483
|
+
problems.push(reason("main_adjudication_invalid", `${ev._path}: main_adjudication must not set ${forbiddenField}`));
|
|
484
|
+
}
|
|
485
|
+
for (const field of MAIN_ADJUDICATION_REQUIRED_FIELDS) {
|
|
486
|
+
if (!(field in ev))
|
|
487
|
+
problems.push(reason("evidence_missing_field", `${ev._path}: main_adjudication missing ${field}`));
|
|
488
|
+
}
|
|
489
|
+
problems.push(...output_ref_reasons(ev, changeRoot));
|
|
490
|
+
problems.push(...string_list_reasons(ev, "source_evidence_refs"));
|
|
491
|
+
problems.push(...string_list_reasons(ev, "verification_evidence_refs", { allowEmpty: true }));
|
|
492
|
+
if ("adjudicated_claim_ids" in ev)
|
|
493
|
+
problems.push(...string_list_reasons(ev, "adjudicated_claim_ids", { allowEmpty: true }));
|
|
494
|
+
if ("blocking_source_evidence_refs" in ev)
|
|
495
|
+
problems.push(...string_list_reasons(ev, "blocking_source_evidence_refs", { allowEmpty: true }));
|
|
496
|
+
if ("reopen_task_ids" in ev)
|
|
497
|
+
problems.push(...string_list_reasons(ev, "reopen_task_ids", { allowEmpty: true }));
|
|
498
|
+
if (!Array.isArray(ev.loaded_refs))
|
|
499
|
+
problems.push(reason("evidence_missing_field", `${ev._path}: loaded_refs must be a list`));
|
|
500
|
+
else
|
|
501
|
+
problems.push(...loaded_ref_reasons(ev, repoRoot));
|
|
502
|
+
problems.push(...object_list_reasons(ev, "claim_adjudications", { allowEmpty: true }));
|
|
503
|
+
problems.push(...object_list_reasons(ev, "finding_adjudications", { allowEmpty: true }));
|
|
504
|
+
if (!("review_decision" in ev))
|
|
505
|
+
problems.push(reason("evidence_missing_field", `${ev._path}: main_adjudication missing review_decision`));
|
|
506
|
+
for (const claim of Array.isArray(ev.claim_adjudications) ? ev.claim_adjudications : []) {
|
|
507
|
+
if (!isObject(claim) || typeof claim.claim_id !== "string" || !claim.claim_id || typeof claim.rationale !== "string" || !claim.rationale) {
|
|
508
|
+
problems.push(reason("claim_adjudication_invalid", `${ev._path}: claim_adjudications entries require claim_id and rationale`));
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if (!CLAIM_ADJUDICATION_DECISIONS.includes(String(claim.decision))) {
|
|
512
|
+
problems.push(reason("claim_adjudication_invalid", `${ev._path}: claim_adjudications decision must be one of ${renderList([...CLAIM_ADJUDICATION_DECISIONS])}`));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
for (const finding of Array.isArray(ev.finding_adjudications) ? ev.finding_adjudications : []) {
|
|
516
|
+
if (!isObject(finding) || typeof finding.finding_id !== "string" || !finding.finding_id || typeof finding.rationale !== "string" || !finding.rationale) {
|
|
517
|
+
problems.push(reason("finding_adjudication_invalid", `${ev._path}: finding_adjudications entries require finding_id and rationale`));
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (!FINDING_ADJUDICATION_DECISIONS.includes(String(finding.decision))) {
|
|
521
|
+
problems.push(reason("finding_adjudication_invalid", `${ev._path}: finding_adjudications decision must be one of ${renderList([...FINDING_ADJUDICATION_DECISIONS])}`));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (ev.review_decision !== undefined && !MAIN_ADJUDICATION_DECISIONS.includes(String(ev.review_decision))) {
|
|
525
|
+
problems.push(reason("main_adjudication_invalid", `${ev._path}: review_decision must be one of ${renderList([...MAIN_ADJUDICATION_DECISIONS])}`));
|
|
526
|
+
}
|
|
527
|
+
if (ev.request_changes_route !== undefined && !REQUEST_CHANGES_ROUTES.includes(String(ev.request_changes_route))) {
|
|
528
|
+
problems.push(reason("main_adjudication_invalid", `${ev._path}: request_changes_route must be one of ${renderList([...REQUEST_CHANGES_ROUTES])}`));
|
|
529
|
+
}
|
|
530
|
+
const verificationRefs = Array.isArray(ev.verification_evidence_refs) ? ev.verification_evidence_refs.map((item) => String(item)) : [];
|
|
531
|
+
const blockingSourceRefs = Array.isArray(ev.blocking_source_evidence_refs) ? ev.blocking_source_evidence_refs.map((item) => String(item)) : [];
|
|
532
|
+
const reopenTaskIds = Array.isArray(ev.reopen_task_ids) ? ev.reopen_task_ids.map((item) => String(item)) : [];
|
|
533
|
+
if (ev.review_decision === "allow") {
|
|
534
|
+
if (!Array.isArray(ev.verification_evidence_refs) || verificationRefs.length === 0) {
|
|
535
|
+
problems.push(reason("main_adjudication_invalid", `${ev._path}: allow review_decision requires non-empty verification_evidence_refs`));
|
|
536
|
+
}
|
|
537
|
+
if (ev.request_changes_route !== undefined) {
|
|
538
|
+
problems.push(reason("main_adjudication_invalid", `${ev._path}: request_changes_route is only allowed when review_decision='request_changes'`));
|
|
539
|
+
}
|
|
540
|
+
if (reopenTaskIds.length > 0) {
|
|
541
|
+
problems.push(reason("main_adjudication_invalid", `${ev._path}: reopen_task_ids are only allowed when review_decision='request_changes'`));
|
|
542
|
+
}
|
|
543
|
+
if (blockingSourceRefs.length > 0) {
|
|
544
|
+
problems.push(reason("main_adjudication_invalid", `${ev._path}: blocking_source_evidence_refs are only allowed when review_decision='request_changes'`));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
else if (ev.review_decision === "request_changes") {
|
|
548
|
+
const route = String(ev.request_changes_route ?? "");
|
|
549
|
+
if (verificationRefs.length > 0) {
|
|
550
|
+
problems.push(reason("main_adjudication_invalid", `${ev._path}: request_changes review_decision must keep verification_evidence_refs empty`));
|
|
551
|
+
}
|
|
552
|
+
if (blockingSourceRefs.length === 0) {
|
|
553
|
+
problems.push(reason("main_adjudication_invalid", `${ev._path}: request_changes review_decision requires non-empty blocking_source_evidence_refs`));
|
|
554
|
+
}
|
|
555
|
+
if (!route) {
|
|
556
|
+
problems.push(reason("main_adjudication_invalid", `${ev._path}: request_changes review_decision requires request_changes_route`));
|
|
557
|
+
}
|
|
558
|
+
else if (route === "reopen_tasks") {
|
|
559
|
+
if (reopenTaskIds.length === 0) {
|
|
560
|
+
problems.push(reason("main_adjudication_invalid", `${ev._path}: request_changes_route='reopen_tasks' requires non-empty reopen_task_ids`));
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
else if (route === "change_update") {
|
|
564
|
+
if (reopenTaskIds.length > 0) {
|
|
565
|
+
problems.push(reason("main_adjudication_invalid", `${ev._path}: request_changes_route='change_update' must not set reopen_task_ids`));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
if (ev.request_changes_route !== undefined) {
|
|
571
|
+
problems.push(reason("main_adjudication_invalid", `${ev._path}: request_changes_route is only allowed when review_decision='request_changes'`));
|
|
572
|
+
}
|
|
573
|
+
if (reopenTaskIds.length > 0) {
|
|
574
|
+
problems.push(reason("main_adjudication_invalid", `${ev._path}: reopen_task_ids are only allowed when review_decision='request_changes'`));
|
|
575
|
+
}
|
|
576
|
+
if (blockingSourceRefs.length > 0) {
|
|
577
|
+
problems.push(reason("main_adjudication_invalid", `${ev._path}: blocking_source_evidence_refs are only allowed when review_decision='request_changes'`));
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (Array.isArray(ev.blocking_source_evidence_refs) && Array.isArray(ev.source_evidence_refs)) {
|
|
581
|
+
const allowed = new Set(ev.source_evidence_refs.map((item) => String(item)));
|
|
582
|
+
const missing = ev.blocking_source_evidence_refs.map((item) => String(item)).filter((item) => !allowed.has(item));
|
|
583
|
+
if (missing.length > 0) {
|
|
584
|
+
problems.push(reason("main_adjudication_invalid", `${ev._path}: blocking_source_evidence_refs must be drawn from source_evidence_refs: ${renderList(missing)}`, missing));
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (ev.kind === "task_reopen") {
|
|
589
|
+
if (normalize_gate(String(ev.gate ?? "")) !== "task_reopen") {
|
|
590
|
+
problems.push(reason("task_reopen_invalid", `${ev._path}: task_reopen evidence must use gate="task_reopen"`));
|
|
591
|
+
}
|
|
592
|
+
for (const field of TASK_REOPEN_REQUIRED_FIELDS) {
|
|
593
|
+
if (!(field in ev))
|
|
594
|
+
problems.push(reason("evidence_missing_field", `${ev._path}: task_reopen missing ${field}`));
|
|
595
|
+
}
|
|
596
|
+
problems.push(...string_list_reasons(ev, "violated_test_ids", { allowEmpty: true }));
|
|
597
|
+
problems.push(...string_list_reasons(ev, "violated_requirement_refs", { allowEmpty: true }));
|
|
598
|
+
problems.push(...string_list_reasons(ev, "invalidated_completion_evidence_ids"));
|
|
599
|
+
problems.push(...string_list_reasons(ev, "required_supersede_evidence_ids"));
|
|
600
|
+
if (!TASK_REOPEN_INVALIDITY_CLASSES.includes(String(ev.completion_invalidity_class))) {
|
|
601
|
+
problems.push(reason("task_reopen_invalid", `${ev._path}: unsupported completion_invalidity_class=${repr(ev.completion_invalidity_class)}`));
|
|
602
|
+
}
|
|
603
|
+
if (ev.scope_expansion !== false) {
|
|
604
|
+
problems.push(reason("task_reopen_invalid", `${ev._path}: task_reopen requires scope_expansion=false`));
|
|
605
|
+
}
|
|
606
|
+
if (typeof ev.reopen_id !== "string" || !ev.reopen_id)
|
|
607
|
+
problems.push(reason("task_reopen_invalid", `${ev._path}: reopen_id must be a non-empty string`));
|
|
608
|
+
for (const field of ["source_adjudication_evidence_id", "source_guidance_evidence_id", "before_tasks_sha256", "after_tasks_sha256", "why_completion_invalid", "required_fix"]) {
|
|
609
|
+
if (typeof ev[field] !== "string" || !ev[field])
|
|
610
|
+
problems.push(reason("task_reopen_invalid", `${ev._path}: ${field} must be a non-empty string`));
|
|
611
|
+
}
|
|
612
|
+
if (Array.isArray(ev.invalidated_completion_evidence_ids) && Array.isArray(ev.required_supersede_evidence_ids)) {
|
|
613
|
+
const required = new Set(ev.required_supersede_evidence_ids.map((item) => String(item)));
|
|
614
|
+
const missing = ev.invalidated_completion_evidence_ids.map((item) => String(item)).filter((item) => !required.has(item));
|
|
615
|
+
if (missing.length > 0) {
|
|
616
|
+
problems.push(reason("task_reopen_invalid", `${ev._path}: required_supersede_evidence_ids must include invalidated_completion_evidence_ids: ${renderList(missing)}`, missing));
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (Array.isArray(ev.required_supersede_evidence_ids)) {
|
|
620
|
+
const sourceIds = new Set([String(ev.source_adjudication_evidence_id ?? ""), String(ev.source_guidance_evidence_id ?? "")]);
|
|
621
|
+
const conflicts = ev.required_supersede_evidence_ids.map((item) => String(item)).filter((item) => sourceIds.has(item));
|
|
622
|
+
if (conflicts.length > 0) {
|
|
623
|
+
problems.push(reason("task_reopen_invalid", `${ev._path}: source evidence ids must not appear in required_supersede_evidence_ids: ${renderList(conflicts)}`, conflicts));
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (ev.kind === "task_reopen_resolved") {
|
|
628
|
+
if (normalize_gate(String(ev.gate ?? "")) !== "task_reopen") {
|
|
629
|
+
problems.push(reason("task_reopen_resolved_invalid", `${ev._path}: task_reopen_resolved evidence must use gate="task_reopen"`));
|
|
630
|
+
}
|
|
631
|
+
for (const field of TASK_REOPEN_RESOLVED_REQUIRED_FIELDS) {
|
|
632
|
+
if (!(field in ev))
|
|
633
|
+
problems.push(reason("evidence_missing_field", `${ev._path}: task_reopen_resolved missing ${field}`));
|
|
634
|
+
}
|
|
635
|
+
if (typeof ev.reopen_evidence_id !== "string" || !ev.reopen_evidence_id)
|
|
636
|
+
problems.push(reason("task_reopen_resolved_invalid", `${ev._path}: reopen_evidence_id must be a non-empty string`));
|
|
637
|
+
if (typeof ev.reopen_id !== "string" || !ev.reopen_id)
|
|
638
|
+
problems.push(reason("task_reopen_resolved_invalid", `${ev._path}: reopen_id must be a non-empty string`));
|
|
639
|
+
if (typeof ev.after_tasks_sha256 !== "string" || !ev.after_tasks_sha256)
|
|
640
|
+
problems.push(reason("task_reopen_resolved_invalid", `${ev._path}: after_tasks_sha256 must be a non-empty string`));
|
|
641
|
+
problems.push(...string_list_reasons(ev, "successor_completion_evidence_ids"));
|
|
642
|
+
}
|
|
643
|
+
if (ev.workflow) {
|
|
644
|
+
if (ev.workflow !== "code-review")
|
|
645
|
+
problems.push(reason("workflow_evidence_invalid", `${ev._path}: unsupported workflow ${repr(ev.workflow)}`));
|
|
646
|
+
if (ev.execution_mode !== "workflow")
|
|
647
|
+
problems.push(reason("workflow_evidence_invalid", `${ev._path}: workflow evidence requires execution_mode=workflow`));
|
|
648
|
+
problems.push(...output_ref_reasons(ev, changeRoot));
|
|
649
|
+
const laneEvidenceRefs = ev.lane_evidence_refs;
|
|
650
|
+
if (!laneEvidenceRefs || typeof laneEvidenceRefs !== "object" || Array.isArray(laneEvidenceRefs)) {
|
|
651
|
+
problems.push(reason("code_review_workflow_incomplete", `${ev._path}: code-review workflow evidence requires lane_evidence_refs`));
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
for (const lane of ["code-reviewer", "architect"]) {
|
|
655
|
+
const refItem = laneEvidenceRefs[lane];
|
|
656
|
+
if (typeof refItem !== "string" || !refItem)
|
|
657
|
+
problems.push(reason("code_review_workflow_incomplete", `${ev._path}: code-review workflow missing ${lane} lane evidence_id`));
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
if (ev.kind === "verification_review") {
|
|
662
|
+
if (!FINAL_VERIFICATION_ROLES.includes(String(ev.agent_role))) {
|
|
663
|
+
problems.push(reason("verification_role_invalid", `${ev._path}: verification_review requires agent_role in ${renderList([...FINAL_VERIFICATION_ROLES])}`));
|
|
664
|
+
}
|
|
665
|
+
for (const field of VERIFY_EVIDENCE_REQUIRED_FIELDS) {
|
|
666
|
+
if (!(field in ev))
|
|
667
|
+
problems.push(reason("verification_evidence_incomplete", `${ev._path}: missing ${field}`));
|
|
668
|
+
}
|
|
669
|
+
problems.push(...verify_reference_reasons(changeRoot, ev, []));
|
|
670
|
+
}
|
|
671
|
+
if (ev.kind === "final_test") {
|
|
672
|
+
for (const field of FINAL_TEST_REQUIRED_FIELDS) {
|
|
673
|
+
if (!(field in ev))
|
|
674
|
+
problems.push(reason("verification_evidence_incomplete", `${ev._path}: final_test missing ${field}`));
|
|
675
|
+
}
|
|
676
|
+
problems.push(...output_ref_reasons(ev, changeRoot));
|
|
677
|
+
}
|
|
678
|
+
if (ev.requires_raw_artifact_refs === true) {
|
|
679
|
+
if (!("raw_artifact_refs" in ev))
|
|
680
|
+
problems.push(reason("raw_artifact_refs_required", `${ev._path}: raw_artifact_refs required when requires_raw_artifact_refs=true`));
|
|
681
|
+
else
|
|
682
|
+
problems.push(...file_ref_reasons(repoRoot, ev, "raw_artifact_refs", "raw_artifact_refs_required"));
|
|
683
|
+
}
|
|
684
|
+
else if (ev.raw_artifact_refs !== undefined) {
|
|
685
|
+
problems.push(...file_ref_reasons(repoRoot, ev, "raw_artifact_refs", "raw_artifact_ref_invalid"));
|
|
686
|
+
}
|
|
687
|
+
// FIX-10: test_run owns raw_log_refs with change-root semantics (audit H-4 flagged the
|
|
688
|
+
// repo-root prefix as C-7 convention mixing); the legacy repo-root check stays for other kinds.
|
|
689
|
+
if (ev.kind !== "test_run" && ev.raw_log_refs !== undefined) {
|
|
690
|
+
problems.push(...file_ref_reasons(repoRoot, ev, "raw_log_refs", "raw_log_ref_invalid"));
|
|
691
|
+
}
|
|
692
|
+
return problems;
|
|
693
|
+
}
|
|
694
|
+
export function code_review_lane_evidence_reasons(ev, evidences) {
|
|
695
|
+
const problems = [];
|
|
696
|
+
const refs = ev.lane_evidence_refs;
|
|
697
|
+
if (!refs || typeof refs !== "object" || Array.isArray(refs)) {
|
|
698
|
+
return [reason("code_review_workflow_incomplete", `${ev._path}: code-review workflow evidence requires lane_evidence_refs`)];
|
|
699
|
+
}
|
|
700
|
+
const liveById = new Map(live_pass(evidences, { gate: "review_complete" }).map((item) => [String(item.evidence_id), item]));
|
|
701
|
+
for (const lane of ["code-reviewer", "architect"]) {
|
|
702
|
+
const evidenceId = refs[lane];
|
|
703
|
+
if (typeof evidenceId !== "string" || !evidenceId) {
|
|
704
|
+
problems.push(reason("code_review_workflow_incomplete", `${ev._path}: code-review workflow missing ${lane} lane evidence_id`));
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
const laneEvidence = liveById.get(evidenceId);
|
|
708
|
+
if (!laneEvidence) {
|
|
709
|
+
problems.push(reason("code_review_lane_evidence_missing", `${ev._path}: ${lane} lane_evidence_refs entry is not live/pass: ${evidenceId}`, [evidenceId]));
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
if (laneEvidence.agent_role !== lane) {
|
|
713
|
+
problems.push(reason("code_review_lane_role_mismatch", `${ev._path}: ${lane} lane_evidence_refs points to agent_role=${repr(laneEvidence.agent_role)}`, [evidenceId]));
|
|
714
|
+
}
|
|
715
|
+
if (laneEvidence.execution_mode !== "native_subagent") {
|
|
716
|
+
problems.push(reason("missing_native_subagent_evidence", `${ev._path}: ${lane} lane evidence requires execution_mode=native_subagent`, [evidenceId]));
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return problems;
|
|
720
|
+
}
|
|
721
|
+
// FIX-6 (audit C-2) deliberate supersede exemption: task_reopen records are historical
|
|
722
|
+
// blockers, so they intentionally use find_pass instead of live_pass. If supersede could
|
|
723
|
+
// remove a task_reopen from the live set, one superseded evidence would erase the reopen
|
|
724
|
+
// obligation without a matching task_reopen_resolved record, contradicting the disclosure
|
|
725
|
+
// fixed-point rule that historical blockers must stay visible until explicitly resolved.
|
|
726
|
+
export function pass_task_reopens(evidences, taskId = null) {
|
|
727
|
+
return find_pass(evidences, { gate: "task_reopen", kind: "task_reopen", task_id: taskId });
|
|
728
|
+
}
|
|
729
|
+
export function live_task_reopens(evidences, taskId = null) {
|
|
730
|
+
return live_pass(evidences, { gate: "task_reopen", kind: "task_reopen", task_id: taskId });
|
|
731
|
+
}
|
|
732
|
+
export function live_task_reopen_resolutions(evidences, taskId = null) {
|
|
733
|
+
return live_pass(evidences, { gate: "task_reopen", kind: "task_reopen_resolved", task_id: taskId });
|
|
734
|
+
}
|
|
735
|
+
function task_reopen_resolution_matches(resolution, reopen) {
|
|
736
|
+
return (String(resolution.reopen_evidence_id ?? "") === String(reopen.evidence_id ?? "")
|
|
737
|
+
&& String(resolution.reopen_id ?? "") === String(reopen.reopen_id ?? "")
|
|
738
|
+
&& String(resolution.task_id ?? "") === String(reopen.task_id ?? ""));
|
|
739
|
+
}
|
|
740
|
+
export function unresolved_live_task_reopens(evidences, taskId = null) {
|
|
741
|
+
const resolutions = live_task_reopen_resolutions(evidences);
|
|
742
|
+
return pass_task_reopens(evidences, taskId)
|
|
743
|
+
.filter((reopen) => !resolutions.some((resolution) => task_reopen_resolution_matches(resolution, reopen)));
|
|
744
|
+
}
|
|
745
|
+
export function find_pass(evidences, filters = {}) {
|
|
746
|
+
const expectedGate = filters.gate !== undefined && filters.gate !== null ? normalize_gate(filters.gate) : null;
|
|
747
|
+
return evidences.filter((ev) => {
|
|
748
|
+
if (ev._invalid || ev.status !== "pass")
|
|
749
|
+
return false;
|
|
750
|
+
if (expectedGate !== null && normalize_gate(String(ev.gate ?? "")) !== expectedGate)
|
|
751
|
+
return false;
|
|
752
|
+
if (filters.kind !== undefined && filters.kind !== null && ev.kind !== filters.kind)
|
|
753
|
+
return false;
|
|
754
|
+
if (filters.task_id !== undefined && filters.task_id !== null && ev.task_id !== filters.task_id)
|
|
755
|
+
return false;
|
|
756
|
+
return true;
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
export function superseded_ids(evidences) {
|
|
760
|
+
return new Set(evidences.filter((ev) => ev.status === "superseded" && ev.supersedes).map((ev) => ev.supersedes));
|
|
761
|
+
}
|
|
762
|
+
// FIX-6 (audit C-2): supersede is a rollback mechanism, so it needs an authorization model.
|
|
763
|
+
// A supersede evidence must point at an existing evidence_id, and a cross-gate supersede
|
|
764
|
+
// must carry a non-empty supersede_reason explaining why it reaches outside its own gate.
|
|
765
|
+
// FIX-9 (audit C-4): duplicate evidence ids make refs/supersede/Map-keyed lookups silently
|
|
766
|
+
// resolve to one arbitrary winner (last writer). Fail loudly instead of guessing.
|
|
767
|
+
export function duplicate_evidence_id_reasons(evidences) {
|
|
768
|
+
const byId = new Map();
|
|
769
|
+
for (const ev of evidences) {
|
|
770
|
+
if (ev._invalid)
|
|
771
|
+
continue;
|
|
772
|
+
if (typeof ev.evidence_id !== "string" || !ev.evidence_id)
|
|
773
|
+
continue;
|
|
774
|
+
const bucket = byId.get(ev.evidence_id) ?? [];
|
|
775
|
+
bucket.push(String(ev._path ?? "unknown"));
|
|
776
|
+
byId.set(ev.evidence_id, bucket);
|
|
777
|
+
}
|
|
778
|
+
return [...byId.entries()]
|
|
779
|
+
.filter(([, paths]) => paths.length > 1)
|
|
780
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
781
|
+
.map(([id, paths]) => reason("evidence_id_duplicate", `evidence_id ${repr(id)} is used by ${paths.length} evidence files: ${renderList([...paths].sort())}`, [...paths].sort()));
|
|
782
|
+
}
|
|
783
|
+
// FIX-12 (audit D-3): cross-evidence reference integrity is a guard-wide capability, not a
|
|
784
|
+
// review_complete special case. Any `*_evidence_refs` field (string list, or lane map like
|
|
785
|
+
// lane_evidence_refs) naming an evidence_id that exists nowhere in the evidence set blocks
|
|
786
|
+
// dangling_evidence_ref — deleting an evidence file now trips a hard block instead of one
|
|
787
|
+
// transient state_fingerprint_stale. `supersedes` is covered by supersede_target_missing (FIX-6).
|
|
788
|
+
export function dangling_evidence_ref_reasons(evidences) {
|
|
789
|
+
const known = new Set();
|
|
790
|
+
for (const ev of evidences) {
|
|
791
|
+
if (typeof ev.evidence_id === "string" && ev.evidence_id)
|
|
792
|
+
known.add(ev.evidence_id);
|
|
793
|
+
}
|
|
794
|
+
const problems = [];
|
|
795
|
+
for (const ev of evidences) {
|
|
796
|
+
if (ev._invalid)
|
|
797
|
+
continue;
|
|
798
|
+
for (const [field, value] of Object.entries(ev).sort(([a], [b]) => a.localeCompare(b))) {
|
|
799
|
+
if (!field.endsWith("_evidence_refs"))
|
|
800
|
+
continue;
|
|
801
|
+
let ids;
|
|
802
|
+
if (Array.isArray(value)) {
|
|
803
|
+
ids = value.filter((item) => typeof item === "string" && item.length > 0);
|
|
804
|
+
}
|
|
805
|
+
else if (isObject(value)) {
|
|
806
|
+
ids = Object.values(value).filter((item) => typeof item === "string" && item.length > 0);
|
|
807
|
+
}
|
|
808
|
+
else {
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
const missing = [...new Set(ids)].filter((id) => !known.has(id)).sort();
|
|
812
|
+
if (missing.length > 0) {
|
|
813
|
+
problems.push(reason("dangling_evidence_ref", `${ev._path}: ${field} references unknown evidence_id: ${renderList(missing)}`, missing));
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return problems;
|
|
818
|
+
}
|
|
819
|
+
export function supersede_reasons(evidences) {
|
|
820
|
+
const problems = [];
|
|
821
|
+
const byId = new Map();
|
|
822
|
+
for (const ev of evidences) {
|
|
823
|
+
if (ev._invalid)
|
|
824
|
+
continue;
|
|
825
|
+
const id = typeof ev.evidence_id === "string" ? ev.evidence_id : "";
|
|
826
|
+
if (id && !byId.has(id))
|
|
827
|
+
byId.set(id, ev);
|
|
828
|
+
}
|
|
829
|
+
for (const ev of evidences) {
|
|
830
|
+
if (ev._invalid || ev.status !== "superseded" || ev.supersedes === undefined)
|
|
831
|
+
continue;
|
|
832
|
+
const targetId = typeof ev.supersedes === "string" ? ev.supersedes : "";
|
|
833
|
+
const target = targetId ? byId.get(targetId) : undefined;
|
|
834
|
+
if (!target) {
|
|
835
|
+
problems.push(reason("supersede_target_missing", `${ev._path}: supersedes points at unknown evidence_id: ${repr(ev.supersedes)}`, targetId ? [targetId] : []));
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
const sameGate = normalize_gate(String(ev.gate ?? "")) === normalize_gate(String(target.gate ?? ""));
|
|
839
|
+
const supersedeReason = typeof ev.supersede_reason === "string" ? ev.supersede_reason.trim() : "";
|
|
840
|
+
if (!sameGate && !supersedeReason) {
|
|
841
|
+
problems.push(reason("supersede_unauthorized", `${ev._path}: cross-gate supersede of ${targetId} (gate=${repr(target.gate)}) requires a non-empty supersede_reason`, [targetId]));
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return problems;
|
|
845
|
+
}
|
|
846
|
+
export function live_pass(evidences, filters = {}) {
|
|
847
|
+
const dead = superseded_ids(evidences);
|
|
848
|
+
return find_pass(evidences, filters).filter((ev) => !dead.has(ev.evidence_id));
|
|
849
|
+
}
|