@kylecheng3146/agent-ops 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +9 -4
  2. package/dist/packages/cli/src/args.js +15 -0
  3. package/dist/packages/cli/src/bin.js +36 -22
  4. package/dist/packages/cli/src/cli.js +1 -0
  5. package/dist/packages/cli/src/commands/review.js +286 -29
  6. package/dist/packages/cli/src/commands/task.js +4 -1
  7. package/dist/packages/cli/src/commands/verify.js +13 -1
  8. package/dist/packages/cli/src/wizard.js +3 -3
  9. package/dist/runtime/src/contracts.js +1 -1
  10. package/dist/runtime/src/review/execute.js +143 -83
  11. package/dist/runtime/src/review/extract.js +20 -22
  12. package/dist/runtime/src/review/invocation.js +66 -2
  13. package/dist/runtime/src/review/packet.js +42 -5
  14. package/dist/runtime/src/review/probe.js +50 -26
  15. package/dist/runtime/src/review/render.js +62 -0
  16. package/dist/runtime/src/review/report.js +183 -0
  17. package/dist/runtime/src/review/runner.js +85 -33
  18. package/dist/runtime/src/review/scope.js +123 -0
  19. package/dist/runtime/src/schema/validate.js +18 -0
  20. package/dist/runtime/src/task/service.js +6 -1
  21. package/dist/runtime/src/task/store.js +16 -4
  22. package/dist/runtime/src/verify/change-surface.js +38 -2
  23. package/dist/runtime/src/verify/command-executor.js +4 -1
  24. package/dist/runtime/src/verify/evidence.js +36 -0
  25. package/dist/runtime/src/verify/scope.js +1 -2
  26. package/dist/runtime/src/verify/service.js +66 -9
  27. package/dist/runtime/src/verify/source-fingerprint.js +49 -0
  28. package/dist/runtime/src/verify/spawn.js +9 -3
  29. package/docs/en/guides/configuration.md +17 -9
  30. package/docs/zh-TW/guides/configuration.md +15 -8
  31. package/package.json +1 -1
  32. package/schemas/evidence.schema.json +16 -1
  33. package/schemas/review-report.schema.json +48 -0
@@ -3,6 +3,7 @@ import { EVIDENCE_SCHEMA_VERSION } from "../contracts.js";
3
3
  import { sha256 } from "../fs/hash.js";
4
4
  import { AgentOpsError } from "../fs/paths.js";
5
5
  import { calculateConfigHash } from "../config/hash.js";
6
+ import { evaluateTestCount } from "./test-count.js";
6
7
  export { calculateConfigHash } from "../config/hash.js";
7
8
  import { validateEvidence } from "../schema/validate.js";
8
9
  import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
@@ -34,10 +35,24 @@ export function buildVerificationEvidence(input) {
34
35
  finishedAt: input.finishedAt,
35
36
  exitCode: input.exitCode,
36
37
  testCount: input.testCount,
38
+ status: input.status,
39
+ failureClass: redactSecrets(input.failureClass),
40
+ sourceFingerprint: input.sourceFingerprint,
37
41
  toolVersions: redactRecord(input.toolVersions),
38
42
  configHash: calculateConfigHash(input.config)
39
43
  });
40
44
  }
45
+ /** Confirms that a persisted PASS still matches the current command contract. */
46
+ export function isPassingVerificationEvidence(command, evidence) {
47
+ if (evidence.status !== "PASS" ||
48
+ evidence.exitCode !== 0 ||
49
+ evidence.failureClass !== "none" ||
50
+ command.evidence.kind === "file") {
51
+ return false;
52
+ }
53
+ return command.evidence.kind !== "test-count" ||
54
+ evaluateTestCount(evidence.testCount, command.evidence.minimum).status === "PASS";
55
+ }
41
56
  export class FileEvidenceStore {
42
57
  #root;
43
58
  #anchorDirectory;
@@ -66,4 +81,25 @@ export class FileEvidenceStore {
66
81
  }
67
82
  return relativePath;
68
83
  }
84
+ async load(reference) {
85
+ const segments = reference.split("/");
86
+ if (!reference.startsWith(".agent-ops/tasks/evidence/") ||
87
+ segments.some((segment) => segment.length === 0 ||
88
+ segment === "." ||
89
+ segment === ".." ||
90
+ !/^[A-Za-z0-9._-]+$/u.test(segment))) {
91
+ return null;
92
+ }
93
+ const path = join(this.#root, ...segments);
94
+ const source = await readPrivateFile(path, this.#anchorDirectory);
95
+ if (source === null) {
96
+ return null;
97
+ }
98
+ try {
99
+ return JSON.parse(source);
100
+ }
101
+ catch {
102
+ throw new AgentOpsError("EVIDENCE_INVALID", "Stored evidence is not valid JSON.");
103
+ }
104
+ }
69
105
  }
@@ -1,4 +1,3 @@
1
- import { AgentOpsError } from "../fs/paths.js";
2
1
  function sortedUnique(values) {
3
2
  return [...new Set(values)].sort();
4
3
  }
@@ -18,7 +17,7 @@ function sameVerifierIds(left, right) {
18
17
  }
19
18
  function fallbackSelection(reason, evidence) {
20
19
  if (evidence.requiredVerifierIds.length === 0) {
21
- throw new AgentOpsError("VERIFICATION_SCOPE_EMPTY", `Verification scope fallback (${reason}) has no required commands.`);
20
+ return { verifierIds: [], fallback: true, reason, evidence };
22
21
  }
23
22
  return {
24
23
  verifierIds: evidence.requiredVerifierIds,
@@ -1,6 +1,8 @@
1
1
  import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
2
2
  import { validateTaskAgainstConfig } from "../schema/validate.js";
3
3
  import { collectChangeSurface } from "./change-surface.js";
4
+ import { resolveReviewScope, reviewScopeSignature } from "../review/scope.js";
5
+ import { calculateSourceFingerprint } from "./source-fingerprint.js";
4
6
  import { buildVerificationEvidence } from "./evidence.js";
5
7
  import { createFailureFingerprint } from "./fingerprint.js";
6
8
  import { aggregateVerificationStatus, executeConfiguredCommand } from "./command-executor.js";
@@ -41,7 +43,7 @@ export class VerificationService {
41
43
  }
42
44
  return await resolveContainedPath(this.#options.root, command.cwd);
43
45
  }
44
- async #persistEvidence(task, command, startedAt, finishedAt, result, exitCode) {
46
+ async #persistEvidence(task, command, startedAt, finishedAt, result, sourceFingerprint) {
45
47
  const references = [];
46
48
  for (const criterion of relevantCriteria(task, command.id)) {
47
49
  const evidence = buildVerificationEvidence({
@@ -51,8 +53,11 @@ export class VerificationService {
51
53
  scope: this.#options.scope,
52
54
  startedAt,
53
55
  finishedAt,
54
- exitCode,
56
+ exitCode: result.exitCode,
55
57
  testCount: result.testCount,
58
+ status: result.status,
59
+ failureClass: result.failureClass,
60
+ sourceFingerprint,
56
61
  toolVersions: this.#options.toolVersions ?? {},
57
62
  config: this.#options.config
58
63
  });
@@ -84,7 +89,6 @@ export class VerificationService {
84
89
  diagnostics: result.diagnostic
85
90
  });
86
91
  const finishedAt = (this.#options.now ?? (() => new Date().toISOString()))();
87
- const evidenceReferences = await this.#persistEvidence(task, command, startedAt, finishedAt, result, result.exitCode);
88
92
  return {
89
93
  commandId: command.id,
90
94
  required: command.required,
@@ -94,7 +98,9 @@ export class VerificationService {
94
98
  timedOut: result.timedOut,
95
99
  testCount: result.testCount,
96
100
  diagnostic: fingerprint?.diagnostics ?? "",
97
- evidenceReferences
101
+ evidenceReferences: [],
102
+ startedAt,
103
+ finishedAt
98
104
  };
99
105
  }
100
106
  async verify(taskId) {
@@ -107,23 +113,72 @@ export class VerificationService {
107
113
  throw verificationError("VERIFICATION_INPUT_INVALID", validation.errors[0]?.message ??
108
114
  "Task and verification configuration are incompatible.");
109
115
  }
110
- const surface = await collectChangeSurface(this.#options.gitRunner);
116
+ const reviewScope = await resolveReviewScope({
117
+ root: this.#options.root,
118
+ runner: this.#options.gitRunner,
119
+ ...(this.#options.base === undefined ? {} : { base: this.#options.base })
120
+ });
121
+ const sourceFingerprint = await calculateSourceFingerprint(this.#options.root, reviewScope, this.#options.gitRunner);
122
+ const worktreeSurface = reviewScope.mode === "worktree"
123
+ ? await collectChangeSurface(this.#options.gitRunner)
124
+ : { staged: [], unstaged: [], untracked: [], paths: reviewScope.changedFiles };
125
+ const surface = worktreeSurface;
111
126
  const selection = selectVerificationScope(surface.paths, this.#options.config);
112
127
  const results = [];
113
128
  for (const commandId of selection.verifierIds) {
114
129
  results.push(await this.#runCommand(validation.value, commandById(this.#options.config, commandId)));
115
130
  }
116
- const status = aggregateVerificationStatus(results);
131
+ let status = aggregateVerificationStatus(results);
132
+ let sourceChanged = false;
133
+ const postflightScope = await resolveReviewScope({
134
+ root: this.#options.root,
135
+ runner: this.#options.gitRunner,
136
+ ...(this.#options.base === undefined ? {} : { base: this.#options.base })
137
+ });
138
+ const postflightFingerprint = await calculateSourceFingerprint(this.#options.root, postflightScope, this.#options.gitRunner);
139
+ if (reviewScopeSignature(reviewScope) !== reviewScopeSignature(postflightScope) ||
140
+ sourceFingerprint !== postflightFingerprint) {
141
+ status = "UNKNOWN";
142
+ sourceChanged = true;
143
+ }
117
144
  let signal = null;
118
145
  if (status === "PASS") {
146
+ const taskEvidence = {};
147
+ for (const [index, result] of results.entries()) {
148
+ const command = commandById(this.#options.config, result.commandId);
149
+ const references = await this.#persistEvidence(validation.value, command, result.startedAt, result.finishedAt, result, sourceFingerprint);
150
+ results[index] = { ...result, evidenceReferences: references };
151
+ for (const [criterionIndex, criterion] of relevantCriteria(validation.value, result.commandId).entries()) {
152
+ const reference = references[criterionIndex];
153
+ if (reference !== undefined) {
154
+ taskEvidence[criterion.id] = [
155
+ ...(taskEvidence[criterion.id] ?? []),
156
+ reference
157
+ ];
158
+ }
159
+ }
160
+ }
161
+ if (Object.keys(taskEvidence).length > 0) {
162
+ await this.#options.taskService.recordEvidence(taskId, taskEvidence);
163
+ }
119
164
  await this.#options.taskService.clearFailure(taskId);
120
165
  }
121
166
  else {
122
167
  const required = results.filter((result) => result.required);
123
- const gating = required.length > 0 ? required : results;
168
+ const gating = required;
124
169
  const failed = gating.find((result) => result.status !== "PASS");
125
170
  if (failed === undefined) {
126
- throw verificationError("VERIFICATION_RESULT_INVALID", "Verification failed without a gating result.");
171
+ if (!sourceChanged) {
172
+ throw verificationError("VERIFICATION_RESULT_INVALID", "Verification failed without a required result.");
173
+ }
174
+ const advanced = await this.#options.taskService.recordFailure(taskId, createFailureFingerprint({
175
+ commandId: "source-snapshot",
176
+ failureClass: "source-changed-during-verification",
177
+ exitCategory: "no-exit",
178
+ diagnostics: "source changed during verification"
179
+ }));
180
+ signal = advanced.signal;
181
+ return { taskId, status, surface, selection, results, signal, reviewScope, sourceFingerprint };
127
182
  }
128
183
  const advanced = await this.#options.taskService.recordFailure(taskId, createFailureFingerprint({
129
184
  commandId: failed.commandId,
@@ -139,7 +194,9 @@ export class VerificationService {
139
194
  surface,
140
195
  selection,
141
196
  results,
142
- signal
197
+ signal,
198
+ reviewScope,
199
+ sourceFingerprint
143
200
  };
144
201
  }
145
202
  }
@@ -0,0 +1,49 @@
1
+ import { readFile, lstat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { sha256 } from "../fs/hash.js";
4
+ import { AgentOpsError } from "../fs/paths.js";
5
+ import { resolveGitCommit } from "./change-surface.js";
6
+ export async function calculateSourceFingerprint(root, scope, runner) {
7
+ const head = await resolveGitCommit(runner, "HEAD");
8
+ if (scope.mode === "base") {
9
+ return sha256(JSON.stringify({
10
+ domain: "agent-ops-source-v1",
11
+ mode: "base",
12
+ head,
13
+ base: scope.resolvedBase,
14
+ paths: [...scope.changedFiles]
15
+ }));
16
+ }
17
+ const paths = [];
18
+ for (const path of scope.changedFiles) {
19
+ const absolute = join(root, ...path.split("/"));
20
+ let entry;
21
+ try {
22
+ entry = await lstat(absolute);
23
+ }
24
+ catch (error) {
25
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
26
+ paths.push({ path, kind: "deleted" });
27
+ continue;
28
+ }
29
+ throw error;
30
+ }
31
+ if (!entry.isFile()) {
32
+ throw new AgentOpsError("SOURCE_SNAPSHOT_UNAVAILABLE", "Review source contains an unsupported entry.");
33
+ }
34
+ const bytes = await readFile(absolute);
35
+ paths.push({
36
+ path,
37
+ kind: "file",
38
+ executable: (entry.mode & 0o111) !== 0,
39
+ hash: sha256(bytes)
40
+ });
41
+ }
42
+ return sha256(JSON.stringify({
43
+ domain: "agent-ops-source-v1",
44
+ mode: "worktree",
45
+ head,
46
+ base: null,
47
+ paths
48
+ }));
49
+ }
@@ -139,13 +139,17 @@ export class NodeVerificationProcessRunner {
139
139
  cwd: request.cwd,
140
140
  detached: this.#platform !== "win32",
141
141
  env: {
142
- ...process.env,
142
+ ...(request.replaceEnv === true ? {} : process.env),
143
143
  ...(request.env ?? {})
144
144
  },
145
145
  shell: request.shell,
146
- stdio: ["ignore", "pipe", "pipe"],
146
+ stdio: [request.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
147
147
  windowsHide: true
148
148
  });
149
+ if (request.stdin !== undefined && child.stdin !== null) {
150
+ child.stdin.on("error", () => { });
151
+ child.stdin.end(request.stdin);
152
+ }
149
153
  let settled = false;
150
154
  const completion = new Promise((resolve) => {
151
155
  child.once("error", (error) => {
@@ -263,7 +267,9 @@ export async function runVerificationCommand(command, options) {
263
267
  args: [...command.args],
264
268
  cwd: options.cwd,
265
269
  shell: command.shell === true,
266
- ...(options.env === undefined ? {} : { env: options.env })
270
+ ...(options.env === undefined ? {} : { env: options.env }),
271
+ ...(options.replaceEnv === true ? { replaceEnv: true } : {}),
272
+ ...(options.stdin === undefined ? {} : { stdin: options.stdin })
267
273
  });
268
274
  }
269
275
  catch {
@@ -41,19 +41,25 @@ Enable it during `agent-ops init`, or by hand:
41
41
  ```json
42
42
  {
43
43
  "reviewRoles": [
44
- { "role": "independent-review", "targets": ["codex", "agy"] }
44
+ { "role": "independent-review", "targets": ["claude"] }
45
45
  ]
46
46
  }
47
47
  ```
48
48
 
49
- `targets` is an **ordered fallback chain**. Supported targets and the read-only
50
- flags they are launched with:
49
+ `targets` is an **ordered fallback chain**. Every review locks to the
50
+ staged/unstaged/untracked surface (or a clean `--base <ref>...HEAD` range),
51
+ requires fresh PASS evidence for required checks, and prints a complete native
52
+ schema report without persisting it.
53
+
54
+ Every attempt starts from a fresh temporary cwd with a narrow environment. The
55
+ following target identities may be configured; only Claude currently meets the
56
+ complete context-isolation contract and can execute automatically:
51
57
 
52
58
  | Target | Invocation | Read-only |
53
59
  | --- | --- | --- |
54
- | `codex` | `codex exec` | `-s read-only` |
55
- | `agy` (Antigravity) | `agy -p` | `--sandbox --mode plan` |
56
- | `claude` | `claude -p` | `--permission-mode plan` |
60
+ | `codex` | `codex exec` | retained; `capability-unavailable` |
61
+ | `agy` (Antigravity) | `agy -p` | retained; `capability-unavailable` |
62
+ | `claude` | `claude -p` | `--permission-mode plan --safe-mode` |
57
63
 
58
64
  `opencode` is **not** a review target even though it is a supported harness.
59
65
  Its `--agent plan` is rejected as a subagent and silently falls back to a
@@ -72,9 +78,11 @@ of the chain. It still runs when it is the only configured target, with a
72
78
  `reviewer == host` warning.
73
79
 
74
80
  Criterion descriptions come from the task bound to the current session, so a
75
- review needs an attached task; `--criterion` filters those ids. Results are
76
- appended to the task's evidence with a `review:<target>:` prefix, and only
77
- while the task is active a completed task is printed, never rewritten.
81
+ review needs an attached task created under the current policy configuration;
82
+ `--criterion` filters those ids. Run `agent-ops verify` first: review rejects
83
+ stale, failed, or source-mismatched required evidence before any model call.
84
+ Compact PASS evidence is appended with a `review:<target>:` prefix; the full
85
+ human-readable report is transient. Completed tasks are never rewritten.
78
86
 
79
87
  `--yes` is still required for every review run: init selection decides which
80
88
  targets are permitted, `--yes` decides whether to spend money now.
@@ -36,18 +36,23 @@ Claude 與 Codex lifecycle support 為 `supported`,OpenCode 從 app initializa
36
36
  ```json
37
37
  {
38
38
  "reviewRoles": [
39
- { "role": "independent-review", "targets": ["codex", "agy"] }
39
+ { "role": "independent-review", "targets": ["claude"] }
40
40
  ]
41
41
  }
42
42
  ```
43
43
 
44
- `targets` 是**有序的後備鏈**。支援的目標與其唯讀旗標:
44
+ `targets` 是**有序的後備鏈**。每次 review 都鎖定 staged/unstaged/untracked
45
+ 變更(或乾淨的 `--base <ref>...HEAD`),並要求必要驗證的最新 PASS evidence;
46
+ 完整原生 schema report 會顯示給人看,但不會持久化。
47
+
48
+ 每次嘗試都從新的暫存 cwd 與最小環境啟動。目前只有 Claude 具備完整的
49
+ context-isolation 合約,能自動執行:
45
50
 
46
51
  | 目標 | 呼叫方式 | 唯讀 |
47
52
  | --- | --- | --- |
48
- | `codex` | `codex exec` | `-s read-only` |
49
- | `agy`(Antigravity)| `agy -p` | `--sandbox --mode plan` |
50
- | `claude` | `claude -p` | `--permission-mode plan` |
53
+ | `codex` | `codex exec` | 保留設定;`capability-unavailable` |
54
+ | `agy`(Antigravity)| `agy -p` | 保留設定;`capability-unavailable` |
55
+ | `claude` | `claude -p` | `--permission-mode plan --safe-mode` |
51
56
 
52
57
  `opencode` **不是** review 目標,即使它是支援的 harness。它的 `--agent plan`
53
58
  會被判定為 subagent 而遭拒,並靜默退回可寫入的 agent,因此無法滿足唯讀前置
@@ -61,9 +66,11 @@ Claude 與 Codex lifecycle support 為 `supported`,OpenCode 從 app initializa
61
66
  若 host 是 Claude Code(`CLAUDECODE` 已設定),`claude` 會被移到鏈尾。
62
67
  當它是唯一設定的目標時仍會執行,並附上 `reviewer == host` 警告。
63
68
 
64
- criterion 描述來自當前 session 綁定的 task,所以 review 需要已附加的 task;
65
- `--criterion` 用來篩選這些 id。結果會以 `review:<target>:` 前綴附加到 task 的
66
- evidence,且僅在 task active 時寫入 —— 已完成的 task 只印出,絕不改寫。
69
+ criterion 描述來自當前 session 綁定的 task,所以 review 需要已附加、且建立時的
70
+ policy 設定仍相同的 task;`--criterion` 用來篩選 id。請先執行
71
+ `agent-ops verify`:必要 evidence 若失敗、過期或來源不符,review 會在 model 呼叫前停止。
72
+ 僅 compact PASS evidence 會以 `review:<target>:` 附加;完整人類可讀 report 是暫存的。
73
+ 已完成的 task 絕不改寫。
67
74
 
68
75
  每次執行 review 仍需 `--yes`:init 的勾選決定「允許哪些目標」,
69
76
  `--yes` 決定「現在是否要花錢」。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylecheng3146/agent-ops",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Evidence-driven development loops for Codex, Claude Code, and opencode",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,13 +15,16 @@
15
15
  "startedAt",
16
16
  "finishedAt",
17
17
  "exitCode",
18
+ "status",
19
+ "failureClass",
20
+ "sourceFingerprint",
18
21
  "testCount",
19
22
  "toolVersions",
20
23
  "configHash"
21
24
  ],
22
25
  "properties": {
23
26
  "schemaVersion": {
24
- "const": 1
27
+ "const": 2
25
28
  },
26
29
  "taskId": {
27
30
  "$ref": "#/$defs/id"
@@ -62,6 +65,18 @@
62
65
  "minimum": 0,
63
66
  "maximum": 9007199254740991
64
67
  },
68
+ "status": {
69
+ "enum": ["PASS", "FAIL", "UNKNOWN"]
70
+ },
71
+ "failureClass": {
72
+ "type": "string",
73
+ "minLength": 1,
74
+ "maxLength": 256,
75
+ "pattern": "^[^\\u0000]*$"
76
+ },
77
+ "sourceFingerprint": {
78
+ "$ref": "#/$defs/hash"
79
+ },
65
80
  "toolVersions": {
66
81
  "type": "object",
67
82
  "additionalProperties": {
@@ -0,0 +1,48 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/kylecheng3146/agent-ops/schemas/review-report.schema.json",
4
+ "title": "Agent Ops detailed independent review report",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["summary", "results", "findings", "residualRisks", "changedFilesInspected", "supportingFilesInspected"],
8
+ "properties": {
9
+ "summary": { "$ref": "#/$defs/text" },
10
+ "results": { "type": "array", "items": { "$ref": "#/$defs/result" } },
11
+ "findings": { "type": "array", "items": { "$ref": "#/$defs/finding" } },
12
+ "residualRisks": { "type": "array", "items": { "$ref": "#/$defs/text" } },
13
+ "changedFilesInspected": { "type": "array", "items": { "$ref": "#/$defs/path" }, "uniqueItems": true },
14
+ "supportingFilesInspected": { "type": "array", "items": { "$ref": "#/$defs/path" }, "uniqueItems": true }
15
+ },
16
+ "$defs": {
17
+ "text": { "type": "string", "minLength": 1, "maxLength": 16384, "pattern": "[^\\s\\u0000]" },
18
+ "path": { "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^(?!/)(?!.*\\\\)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/-]+$" },
19
+ "result": {
20
+ "type": "object", "additionalProperties": false,
21
+ "required": ["criterionId", "status", "summary", "evidence"],
22
+ "properties": {
23
+ "criterionId": { "$ref": "#/$defs/text" },
24
+ "status": { "enum": ["PASS", "FAIL"] },
25
+ "summary": { "$ref": "#/$defs/text" },
26
+ "evidence": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/text" } }
27
+ }
28
+ },
29
+ "location": {
30
+ "type": "object", "additionalProperties": false, "required": ["path"],
31
+ "properties": { "path": { "$ref": "#/$defs/path" }, "line": { "type": "integer", "minimum": 1 } }
32
+ },
33
+ "finding": {
34
+ "type": "object", "additionalProperties": false,
35
+ "required": ["severity", "blocking", "title", "details", "locations", "evidence", "recommendation", "criterionIds"],
36
+ "properties": {
37
+ "severity": { "enum": ["critical", "important", "minor"] },
38
+ "blocking": { "type": "boolean" },
39
+ "title": { "$ref": "#/$defs/text" },
40
+ "details": { "$ref": "#/$defs/text" },
41
+ "locations": { "type": "array", "items": { "$ref": "#/$defs/location" } },
42
+ "evidence": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/text" } },
43
+ "recommendation": { "$ref": "#/$defs/text" },
44
+ "criterionIds": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/text" } }
45
+ }
46
+ }
47
+ }
48
+ }