mason-context 0.9.0 → 0.10.1

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.
@@ -1,117 +1,606 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/review/cli.ts
4
- import path8 from "path";
4
+ import path12 from "path";
5
+ import { stripVTControlCharacters } from "util";
5
6
 
6
7
  // src/review/review.ts
7
8
  import fs5 from "fs/promises";
8
- import path7 from "path";
9
+ import path11 from "path";
9
10
  import { execFile as execFile5 } from "child_process";
10
11
  import { promisify as promisify5 } from "util";
11
12
 
12
13
  // src/drift/drift.ts
13
14
  import fs3 from "fs/promises";
14
- import path4 from "path";
15
+ import path6 from "path";
15
16
  import { execFile as execFile3 } from "child_process";
16
17
  import { promisify as promisify3 } from "util";
17
18
 
18
19
  // src/snapshot/snapshot.ts
19
- import fs2 from "fs/promises";
20
- import path3 from "path";
20
+ import path5 from "path";
21
21
  import { execFile as execFile2 } from "child_process";
22
22
  import { promisify as promisify2 } from "util";
23
- import fg3 from "fast-glob";
24
23
 
25
- // src/mcp/sampler.ts
24
+ // src/utils/files.ts
26
25
  import fs from "fs/promises";
27
- import path from "path";
26
+ import { constants } from "fs";
27
+ import path2 from "path";
28
28
  import { execFile } from "child_process";
29
29
  import { promisify } from "util";
30
30
  import fg from "fast-glob";
31
+
32
+ // src/utils/paths.ts
33
+ import path from "path";
34
+ function normalizeRepoPath(value) {
35
+ const slash = value.replace(/\\/g, "/");
36
+ if (!slash || slash.includes("\0") || path.posix.isAbsolute(slash) || /^[A-Za-z]:/.test(slash)) return null;
37
+ if (slash.split("/").includes("..")) return null;
38
+ const normalized = path.posix.normalize(slash).replace(/\/$/, "");
39
+ return normalized === "." ? null : normalized;
40
+ }
41
+ function isWithinRoot(root, candidate) {
42
+ const relative = path.relative(root, candidate);
43
+ return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
44
+ }
45
+ function anchorMatches(anchor, file) {
46
+ const a = normalizeRepoPath(anchor);
47
+ const f = normalizeRepoPath(file);
48
+ return a !== null && f !== null && (a === f || f.startsWith(`${a}/`));
49
+ }
50
+ function matchingPaths(anchors, files) {
51
+ return [...new Set(files)].filter((file) => anchors.some((anchor) => anchorMatches(anchor, file)));
52
+ }
53
+
54
+ // src/utils/files.ts
31
55
  var exec = promisify(execFile);
56
+ var SOURCE_EXTENSIONS = ["ts", "tsx", "js", "jsx", "mts", "cts", "mjs", "cjs", "vue", "svelte", "kt", "kts", "java", "py", "go", "rs", "swift", "rb", "cs", "cpp", "c", "h", "hpp", "dart", "php"];
57
+ var SOURCE_GLOB = `**/*.{${SOURCE_EXTENSIONS.join(",")}}`;
58
+ var SOURCE_IGNORE = [
59
+ "**/node_modules/**",
60
+ "**/dist/**",
61
+ "**/build/**",
62
+ "**/.gradle/**",
63
+ "**/target/**",
64
+ "**/.git/**",
65
+ "**/.mason/**",
66
+ "**/vendor/**",
67
+ "**/__pycache__/**",
68
+ "**/venv/**",
69
+ "**/.venv/**",
70
+ "**/*.min.*",
71
+ "**/*.map",
72
+ "**/*.lock",
73
+ "**/generated/**",
74
+ "**/*.generated.*",
75
+ "**/R.java",
76
+ "**/BuildConfig.java",
77
+ "**/package-lock.json",
78
+ "**/yarn.lock",
79
+ "**/pnpm-lock.yaml"
80
+ ];
81
+ var MAX_SOURCE_BYTES = 1024 * 1024;
82
+ function isSensitiveFile(file) {
83
+ return file.split(/[\\/]/).some(
84
+ (part) => /^(?:\.env(?:\..*)?|id_rsa.*|id_ed25519.*)$|\.(?:pem|key|p12|pfx|jks|keystore)$|credentials\.|secret|^local\.properties$/i.test(part)
85
+ );
86
+ }
87
+ async function readBoundedFile(file, maxBytes) {
88
+ const handle = await fs.open(file, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
89
+ try {
90
+ const stat = await handle.stat();
91
+ if (!stat.isFile() || stat.size > maxBytes) return null;
92
+ const buffer = Buffer.alloc(Math.min(maxBytes + 1, stat.size + 1));
93
+ let bytes = 0;
94
+ while (bytes < buffer.length) {
95
+ const result = await handle.read(buffer, bytes, buffer.length - bytes, null);
96
+ if (result.bytesRead === 0) break;
97
+ bytes += result.bytesRead;
98
+ }
99
+ return bytes === buffer.length ? null : buffer.subarray(0, bytes).toString("utf8");
100
+ } finally {
101
+ await handle.close();
102
+ }
103
+ }
104
+ async function loadProjectConfig(root) {
105
+ try {
106
+ const canonicalRoot = await fs.realpath(root);
107
+ const configPath = await fs.realpath(path2.join(root, ".mason/config.json"));
108
+ if (!isWithinRoot(canonicalRoot, configPath)) throw new Error("Project configuration resolves outside the repository");
109
+ const raw = await readBoundedFile(configPath, 64 * 1024);
110
+ if (raw === null) throw new Error("Project configuration is not a regular file or exceeds 64 KiB");
111
+ const value = JSON.parse(raw);
112
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Expected a configuration object");
113
+ const config = {};
114
+ for (const key of ["patterns", "alwaysInclude", "ignore"]) {
115
+ if (value[key] === void 0) continue;
116
+ if (!Array.isArray(value[key]) || !value[key].every((s) => typeof s === "string")) {
117
+ throw new Error(`Configuration ${key} must be an array of strings`);
118
+ }
119
+ config[key] = value[key];
120
+ }
121
+ return config;
122
+ } catch (error) {
123
+ if (error.code === "ENOENT") return {};
124
+ throw new Error(`Cannot apply project file policy: ${error instanceof Error ? error.message : String(error)}`);
125
+ }
126
+ }
127
+ async function createFileAccess(rootDir) {
128
+ const root = path2.resolve(rootDir);
129
+ const canonicalRoot = await fs.realpath(root).catch(() => root);
130
+ const config = await loadProjectConfig(root);
131
+ const ignore = [...SOURCE_IGNORE, ...config.ignore ?? []];
132
+ let gitFiles = null;
133
+ try {
134
+ const { stdout } = await exec("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], { cwd: root, maxBuffer: 50 * 1024 * 1024 });
135
+ gitFiles = new Set(stdout.split("\0").filter(Boolean));
136
+ } catch {
137
+ let inGit = false;
138
+ try {
139
+ await exec("git", ["rev-parse", "--git-dir"], { cwd: root });
140
+ inGit = true;
141
+ } catch {
142
+ }
143
+ if (inGit) throw new Error("Cannot enumerate Git files safely");
144
+ }
145
+ async function resolve(file) {
146
+ const relative = normalizeRepoPath(file);
147
+ if (!relative || isSensitiveFile(relative) || gitFiles && !gitFiles.has(relative)) return null;
148
+ const candidate = path2.join(root, relative);
149
+ try {
150
+ const real = await fs.realpath(candidate);
151
+ if (!isWithinRoot(canonicalRoot, real) || isSensitiveFile(path2.relative(canonicalRoot, real))) return null;
152
+ const stat = await fs.stat(real);
153
+ if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES) return null;
154
+ if (gitFiles && !gitFiles.has(path2.relative(canonicalRoot, real).split(path2.sep).join("/"))) return null;
155
+ return real;
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+ async function list(patterns = SOURCE_GLOB, options = {}) {
161
+ const found = await fg(patterns, { cwd: root, ignore, followSymbolicLinks: false, ...options });
162
+ const safe = await Promise.all(found.map(async (f) => await resolve(f) ? f : null));
163
+ return safe.filter((f) => f !== null).sort();
164
+ }
165
+ async function read(file) {
166
+ const relative = normalizeRepoPath(file);
167
+ if (!relative) return null;
168
+ const real = await resolve(relative);
169
+ if (!real) return null;
170
+ for (const rel of /* @__PURE__ */ new Set([relative, path2.relative(canonicalRoot, real).split(path2.sep).join("/")])) {
171
+ if (!(await fg(fg.escapePath(rel), { cwd: root, ignore, dot: true })).length) return null;
172
+ }
173
+ try {
174
+ const content = await readBoundedFile(real, MAX_SOURCE_BYTES);
175
+ return content === null ? null : { path: relative, content, totalLines: content.split("\n").length };
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+ return { root, config, list, read };
181
+ }
182
+
183
+ // src/utils/storage.ts
184
+ import fs2 from "fs/promises";
185
+ import path3 from "path";
186
+ import { randomUUID } from "crypto";
187
+ async function storePath(root, relative, createParents = false) {
188
+ const normalized = normalizeRepoPath(relative);
189
+ if (!normalized) throw new Error(`Invalid store path: ${relative}`);
190
+ let current = await fs2.realpath(root);
191
+ const parts = normalized.split("/");
192
+ for (let i = 0; i < parts.length; i++) {
193
+ current = path3.join(current, parts[i]);
194
+ let stat;
195
+ try {
196
+ stat = await fs2.lstat(current);
197
+ } catch (error) {
198
+ if (error.code !== "ENOENT") throw error;
199
+ if (createParents && i < parts.length - 1) {
200
+ try {
201
+ await fs2.mkdir(current);
202
+ } catch (mkdirError) {
203
+ if (mkdirError.code !== "EEXIST") throw mkdirError;
204
+ }
205
+ stat = await fs2.lstat(current);
206
+ }
207
+ }
208
+ if (stat?.isSymbolicLink()) throw new Error(`Symlink in store path: ${relative}`);
209
+ }
210
+ return current;
211
+ }
212
+ async function readStoreJson(root, relative) {
213
+ try {
214
+ const file = await storePath(root, relative);
215
+ const raw = await readBoundedFile(file, 10 * 1024 * 1024);
216
+ if (raw === null) throw new Error("file is not regular or exceeds 10 MiB");
217
+ const parsed = JSON.parse(raw);
218
+ if (parsed === null) throw new Error("expected a JSON object, received null");
219
+ return parsed;
220
+ } catch (error) {
221
+ if (error.code === "ENOENT") return null;
222
+ throw new Error(`Invalid Mason store ${relative}: ${error instanceof Error ? error.message : String(error)}`);
223
+ }
224
+ }
225
+
226
+ // src/snapshot/snapshot.ts
227
+ import { z } from "zod";
32
228
 
33
229
  // src/test-map.ts
34
- import path2 from "path";
35
- import fg2 from "fast-glob";
230
+ import path4 from "path";
231
+ async function buildTestMap(dir) {
232
+ const rootDir = path4.resolve(dir);
233
+ const access = await createFileAccess(rootDir);
234
+ const testPatterns = [
235
+ "**/*.test.*",
236
+ "**/*.spec.*",
237
+ "**/*Test.kt",
238
+ "**/*Test.java",
239
+ "**/*Tests.kt",
240
+ "**/*Tests.java",
241
+ "**/test_*.py",
242
+ "**/*_test.py",
243
+ "**/*_test.go",
244
+ "**/*Tests.swift",
245
+ "**/*Test.swift",
246
+ "**/*_test.rs"
247
+ ];
248
+ const testFiles = await access.list(testPatterns);
249
+ const sourceFiles = await access.list();
250
+ const sourceByBaseName = /* @__PURE__ */ new Map();
251
+ for (const file of sourceFiles) {
252
+ if (testFiles.includes(file)) continue;
253
+ const baseName = path4.basename(file).replace(/\.[^.]+$/, "");
254
+ const existing = sourceByBaseName.get(baseName) ?? [];
255
+ existing.push(file);
256
+ sourceByBaseName.set(baseName, existing);
257
+ }
258
+ const paired = [];
259
+ const unmatched = [];
260
+ for (const testFile of testFiles) {
261
+ const testBaseName = path4.basename(testFile).replace(/\.[^.]+$/, "");
262
+ const sourceName = testBaseName.replace(/Test$|Tests$|Spec$|\.test$|\.spec$/, "").replace(/^test_|_test$/, "");
263
+ if (!sourceName) {
264
+ unmatched.push(testFile);
265
+ continue;
266
+ }
267
+ const candidates = sourceByBaseName.get(sourceName);
268
+ if (candidates && candidates.length > 0) {
269
+ const testDir = path4.dirname(testFile);
270
+ const bestMatch = candidates.reduce((best, candidate) => {
271
+ const candidateDir = path4.dirname(candidate);
272
+ const bestDir = path4.dirname(best);
273
+ const candidateOverlap = commonSegments(testDir, candidateDir);
274
+ const bestOverlap = commonSegments(testDir, bestDir);
275
+ return candidateOverlap > bestOverlap ? candidate : best;
276
+ });
277
+ paired.push({
278
+ test: testFile,
279
+ source: bestMatch,
280
+ confidence: candidates.length === 1 ? "exact" : "best-guess"
281
+ });
282
+ } else {
283
+ unmatched.push(testFile);
284
+ }
285
+ }
286
+ return { totalTestFiles: testFiles.length, paired, unmatched };
287
+ }
288
+ function commonSegments(pathA, pathB) {
289
+ const segsA = pathA.split("/");
290
+ const segsB = pathB.split("/");
291
+ let count2 = 0;
292
+ for (let i = 0; i < Math.min(segsA.length, segsB.length); i++) {
293
+ if (segsA[i] === segsB[i]) count2++;
294
+ else break;
295
+ }
296
+ return count2;
297
+ }
36
298
 
37
299
  // src/snapshot/snapshot.ts
38
300
  var exec2 = promisify2(execFile2);
301
+ var repoPath = z.string().refine((value) => normalizeRepoPath(value) !== null, "Expected a relative repository path");
302
+ var verificationFields = {
303
+ refreshedHash: z.string().optional(),
304
+ verifiedAt: z.string().optional(),
305
+ verifiedHash: z.string().optional(),
306
+ verificationFailed: z.boolean().optional(),
307
+ verificationNote: z.string().optional()
308
+ };
309
+ var featureSchema = z.object({
310
+ description: z.string(),
311
+ files: z.array(repoPath),
312
+ tests: z.array(repoPath).optional(),
313
+ type: z.enum(["capability", "infrastructure"]).optional(),
314
+ ...verificationFields
315
+ }).passthrough();
316
+ var flowSchema = z.object({ description: z.string(), chain: z.array(repoPath), ...verificationFields }).passthrough();
317
+ var snapshotSchema = z.object({
318
+ version: z.literal(2),
319
+ createdAt: z.string(),
320
+ updatedAt: z.string(),
321
+ gitHash: z.string(),
322
+ features: z.record(featureSchema),
323
+ flows: z.record(flowSchema)
324
+ }).passthrough();
325
+ async function getCurrentGitHash(rootDir) {
326
+ try {
327
+ const { stdout } = await exec2("git", ["rev-parse", "HEAD"], {
328
+ cwd: rootDir
329
+ });
330
+ return stdout.trim();
331
+ } catch {
332
+ return "unknown";
333
+ }
334
+ }
39
335
 
40
336
  // src/drift/drift.ts
41
337
  var exec3 = promisify3(execFile3);
42
- async function getChangesWithStatus(resolvedRoot, fromHash) {
43
- if (!fromHash || fromHash === "unknown") return null;
338
+ function parseChanges(output) {
339
+ const fields = output.split("\0");
340
+ const changes = [];
341
+ for (let i = 0; i < fields.length && fields[i]; ) {
342
+ const code = fields[i++];
343
+ const first = fields[i++];
344
+ if (!first) break;
345
+ const second = /^[RC]/.test(code) ? fields[i++] : void 0;
346
+ const change = second ? code.startsWith("R") ? { status: "renamed", path: second, previousPath: first } : { status: "added", path: second } : { status: code === "A" ? "added" : code === "D" ? "deleted" : "modified", path: first };
347
+ if (change.path.startsWith(".mason/") && (!change.previousPath || change.previousPath.startsWith(".mason/"))) continue;
348
+ changes.push(change);
349
+ }
350
+ return changes;
351
+ }
352
+ function touchedPaths(changes) {
353
+ return [...new Set(changes.flatMap((c) => c.previousPath ? [c.previousPath, c.path] : [c.path]))].sort();
354
+ }
355
+ async function getChangesWithStatus(resolvedRoot, fromHash, toHash = "HEAD") {
356
+ if (!fromHash || fromHash === "unknown" || fromHash.startsWith("-") || !toHash || toHash === "unknown" || toHash.startsWith("-")) return null;
44
357
  try {
45
- const { stdout } = await exec3(
46
- "git",
47
- ["diff", "--name-status", "-M", fromHash, "HEAD"],
48
- { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }
49
- );
50
- const changes = [];
51
- for (const line of stdout.split("\n")) {
52
- if (!line.trim()) continue;
53
- const parts = line.split(" ");
54
- if (parts.some((p) => p.startsWith(".mason/"))) continue;
55
- const code = parts[0];
56
- if (code.startsWith("R") && parts.length >= 3) {
57
- changes.push({
58
- status: "renamed",
59
- path: parts[2],
60
- previousPath: parts[1]
61
- });
62
- } else if (code.startsWith("C") && parts.length >= 3) {
63
- changes.push({ status: "added", path: parts[2] });
64
- } else if (code === "A" && parts.length >= 2) {
65
- changes.push({ status: "added", path: parts[1] });
66
- } else if (code === "D" && parts.length >= 2) {
67
- changes.push({ status: "deleted", path: parts[1] });
68
- } else if (parts.length >= 2) {
69
- changes.push({ status: "modified", path: parts[1] });
70
- }
71
- }
72
- return changes;
358
+ const { stdout } = await exec3("git", ["diff", "--name-status", "-z", "-M", fromHash, toHash, "--"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 });
359
+ return parseChanges(stdout);
73
360
  } catch {
74
361
  return null;
75
362
  }
76
363
  }
364
+ async function getWorkingTree(resolvedRoot) {
365
+ try {
366
+ const [diff, untracked] = await Promise.all([
367
+ exec3("git", ["diff", "--name-status", "-z", "-M", "HEAD", "--"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }),
368
+ exec3("git", ["ls-files", "-z", "--others", "--exclude-standard"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 })
369
+ ]);
370
+ const untrackedFiles = untracked.stdout.split("\0").filter((f) => f && !f.startsWith(".mason/"));
371
+ return { available: true, changedFiles: [.../* @__PURE__ */ new Set([...touchedPaths(parseChanges(diff.stdout)), ...untrackedFiles])].sort(), untrackedFiles };
372
+ } catch {
373
+ return { available: false, changedFiles: [], untrackedFiles: [] };
374
+ }
375
+ }
376
+
377
+ // src/decisions/provenance.ts
378
+ import { z as z2 } from "zod";
379
+
380
+ // src/context/trust.ts
381
+ function assessTrust(entry, freshness) {
382
+ const verification = entry.verificationFailed ? "failed" : entry.verifiedAt ? "passed" : "unverified";
383
+ const reasons = [];
384
+ if (freshness === "unknown") reasons.push("Anchors, history, or working-tree evidence are unavailable; verify before relying on this entry.");
385
+ if (freshness === "changed") reasons.push("Anchored files changed; verify against current code before relying on this entry.");
386
+ if (verification === "failed") reasons.push(`Verification failed: ${entry.verificationNote ?? "re-map this entry before relying on it"}`);
387
+ if (verification === "unverified") reasons.push("No correctness verification has been recorded.");
388
+ return { freshness, verification, verifiedAt: entry.verifiedAt, verifiedHash: entry.verifiedHash, reasons };
389
+ }
390
+
391
+ // src/decisions/provenance.ts
392
+ var text = (max) => z2.string().trim().min(1).max(max);
393
+ var decisionSourceSchema = z2.object({
394
+ kind: z2.enum(["pull_request", "issue", "incident", "discussion", "document", "other"]),
395
+ reference: text(1e3),
396
+ note: text(500).optional()
397
+ }).strict();
398
+ var attributionSchema = z2.object({
399
+ owner: text(200).nullable().optional(),
400
+ sources: z2.array(decisionSourceSchema).max(20).optional(),
401
+ actor: text(200).optional()
402
+ });
403
+ var contentSchema = z2.object({
404
+ title: z2.string().min(1),
405
+ body: z2.string().min(1),
406
+ category: z2.enum(["decision", "gotcha", "deprecation", "convention"]),
407
+ files: z2.array(z2.string().refine((f) => normalizeRepoPath(f) !== null)),
408
+ owner: text(200).optional(),
409
+ sources: z2.array(decisionSourceSchema).max(20)
410
+ });
411
+ var approvalSchema = z2.enum(["unreviewed", "proposed", "accepted"]);
412
+ var statusSchema = z2.enum(["active", "superseded", "retired"]);
413
+ var reviewEvidenceSchema = z2.object({
414
+ baseHash: z2.string(),
415
+ headHash: z2.string(),
416
+ historyAvailable: z2.boolean(),
417
+ changedFiles: z2.array(z2.string()),
418
+ localChanges: z2.array(z2.string())
419
+ });
420
+ var eventSchema = z2.object({
421
+ kind: z2.enum(["imported", "created", "revised", "accepted", "reaffirmed", "retired", "superseded"]),
422
+ at: z2.string().datetime(),
423
+ actor: text(200).optional(),
424
+ note: text(1500).optional(),
425
+ revision: z2.number().int().positive(),
426
+ content: contentSchema,
427
+ approval: approvalSchema,
428
+ status: statusSchema,
429
+ refreshedHash: z2.string(),
430
+ evidence: reviewEvidenceSchema.optional()
431
+ });
432
+ var legacySchema = z2.object({
433
+ version: z2.literal(1),
434
+ id: z2.string().regex(/^[a-zA-Z0-9_-]+$/),
435
+ title: z2.string().min(1),
436
+ body: z2.string().min(1),
437
+ category: contentSchema.shape.category,
438
+ files: contentSchema.shape.files,
439
+ createdAt: z2.string(),
440
+ updatedAt: z2.string(),
441
+ refreshedHash: z2.string(),
442
+ status: z2.enum(["active", "superseded"]),
443
+ supersededBy: z2.string().optional()
444
+ }).passthrough();
445
+ var currentSchema = legacySchema.extend({
446
+ version: z2.literal(2),
447
+ status: statusSchema,
448
+ approval: approvalSchema,
449
+ revision: z2.number().int().positive(),
450
+ owner: text(200).optional(),
451
+ sources: z2.array(decisionSourceSchema).max(20),
452
+ history: z2.array(eventSchema).min(1)
453
+ }).superRefine((record, ctx) => {
454
+ const invalid = (message2) => ctx.addIssue({ code: "custom", message: message2 });
455
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
456
+ let previous;
457
+ for (const event of record.history) {
458
+ if (!previous) {
459
+ if (!["created", "imported"].includes(event.kind) || event.revision !== 1) invalid("History must begin with creation or legacy import at revision 1");
460
+ if (event.approval !== (event.kind === "created" ? "proposed" : "unreviewed")) invalid("Initial records cannot claim acceptance");
461
+ } else {
462
+ if (["created", "imported"].includes(event.kind)) invalid("History cannot restart");
463
+ if (previous.status !== "active") invalid("Archived decisions cannot be changed");
464
+ if (event.revision !== previous.revision + (event.kind === "revised" ? 1 : 0)) invalid("Invalid revision sequence");
465
+ if (event.kind !== "revised" && !same(event.content, previous.content)) invalid("A review cannot silently revise decision content");
466
+ if (event.kind === "reaffirmed" && previous.approval !== "accepted") invalid("Only accepted decisions can be reaffirmed");
467
+ if (event.kind === "accepted" && previous.approval === "accepted") invalid("Use reaffirmation for an accepted decision");
468
+ const approval = event.kind === "revised" ? "proposed" : ["accepted", "reaffirmed"].includes(event.kind) ? "accepted" : previous.approval;
469
+ if (event.approval !== approval) invalid("Approval disagrees with review history");
470
+ if (!["accepted", "reaffirmed"].includes(event.kind) && event.refreshedHash !== previous.refreshedHash) invalid("Only a review can refresh the evidence baseline");
471
+ }
472
+ if (event.kind !== "imported" && event.status !== (event.kind === "retired" ? "retired" : event.kind === "superseded" ? "superseded" : "active")) invalid("Lifecycle disagrees with history");
473
+ if (["accepted", "reaffirmed", "retired"].includes(event.kind) && (!event.actor || !event.note || !event.evidence)) invalid("Reviews require a named reviewer, reason, and code evidence");
474
+ if (["accepted", "reaffirmed"].includes(event.kind)) {
475
+ if (!event.content.owner || !event.content.sources.length) invalid("Accepted decisions require an owner and source");
476
+ if (!event.evidence || !/^[a-f0-9]{40,64}$/.test(event.evidence.headHash) || event.refreshedHash !== event.evidence.headHash || event.evidence.localChanges.length) invalid("Acceptance requires a committed evidence baseline");
477
+ }
478
+ previous = event;
479
+ }
480
+ if (!previous || !same(previous.content, decisionContent(record)) || previous.approval !== record.approval || previous.status !== record.status || previous.revision !== record.revision || previous.refreshedHash !== record.refreshedHash) invalid("Decision does not match the final history event");
481
+ });
482
+ var decisionSchema = z2.union([legacySchema, currentSchema]);
483
+ function decisionContent(record) {
484
+ return {
485
+ title: record.title,
486
+ body: record.body,
487
+ category: record.category,
488
+ files: record.files,
489
+ ...typeof record.owner === "string" ? { owner: record.owner } : {},
490
+ sources: Array.isArray(record.sources) ? record.sources : []
491
+ };
492
+ }
493
+ function decisionApproval(record) {
494
+ return record.version === 1 ? "unreviewed" : record.approval;
495
+ }
496
+ function effectiveDecision(record) {
497
+ if (record.version !== 2 || record.status !== "active" || record.approval !== "proposed") return record;
498
+ let index2 = record.history.length - 1;
499
+ while (index2 >= 0 && !["accepted", "reaffirmed"].includes(record.history[index2].kind)) index2--;
500
+ if (index2 < 0) return record;
501
+ const event = record.history[index2];
502
+ return {
503
+ ...record,
504
+ ...event.content,
505
+ owner: event.content.owner,
506
+ approval: "accepted",
507
+ revision: event.revision,
508
+ refreshedHash: event.refreshedHash,
509
+ updatedAt: event.at,
510
+ history: record.history.slice(0, index2 + 1)
511
+ };
512
+ }
513
+ function decisionProvenance(record, freshness = "unknown") {
514
+ const approval = decisionApproval(record);
515
+ const review = record.version === 2 ? [...record.history].reverse().find((e) => ["accepted", "reaffirmed"].includes(e.kind) && e.revision === record.revision) : void 0;
516
+ return {
517
+ approval,
518
+ revision: record.version === 2 ? record.revision : 0,
519
+ owner: record.version === 2 ? record.owner ?? null : null,
520
+ sources: record.version === 2 ? record.sources : [],
521
+ guidance: record.status !== "active" ? "historical" : approval === "accepted" ? "constraint" : approval === "proposed" ? "proposal" : "unreviewed",
522
+ reviewRequired: record.status === "active" && (approval !== "accepted" || freshness !== "current"),
523
+ lastReview: review ? { reviewer: review.actor, at: review.at, note: review.note, gitHash: review.refreshedHash } : null
524
+ };
525
+ }
526
+ function decisionTrust(record, freshness) {
527
+ const review = decisionProvenance(record, freshness).lastReview;
528
+ return assessTrust(review ? { verifiedAt: review.at, verifiedHash: review.gitHash } : {}, freshness);
529
+ }
530
+ function revisionKnowledge(record, freshness) {
531
+ return { ...decisionContent(record), ...decisionProvenance(record, freshness), trust: decisionTrust(record, freshness) };
532
+ }
533
+ function decisionKnowledge(record, freshness = "unknown", proposalFreshness = "unknown") {
534
+ const effective = effectiveDecision(record);
535
+ return {
536
+ ...revisionKnowledge(effective, freshness),
537
+ ...effective !== record ? { pendingProposal: revisionKnowledge(record, proposalFreshness) } : {}
538
+ };
539
+ }
77
540
 
78
541
  // src/decisions/decisions.ts
79
542
  import fs4 from "fs/promises";
80
- import path6 from "path";
543
+ import path8 from "path";
81
544
  import { createHash } from "crypto";
82
545
 
83
546
  // src/context/lexical.ts
84
- import path5 from "path";
547
+ import path7 from "path";
85
548
 
86
549
  // src/decisions/decisions.ts
87
- function decisionsDir(rootDir) {
88
- return path6.join(rootDir, ".mason", "decisions");
89
- }
90
- async function loadDecisions(rootDir) {
550
+ async function loadDecisionStore(rootDir) {
551
+ const records = [];
552
+ const diagnostics = [];
91
553
  let entries;
92
554
  try {
93
- entries = await fs4.readdir(decisionsDir(rootDir));
94
- } catch {
95
- return [];
555
+ entries = await fs4.readdir(await storePath(rootDir, ".mason/decisions"));
556
+ } catch (error) {
557
+ if (error.code !== "ENOENT") diagnostics.push({ path: ".mason/decisions", message: String(error) });
558
+ return { records, diagnostics };
96
559
  }
97
- const records = [];
98
- for (const entry of entries) {
560
+ for (const entry of entries.sort()) {
99
561
  if (!entry.endsWith(".json")) continue;
562
+ const relative = `.mason/decisions/${entry}`;
100
563
  try {
101
- const raw = await fs4.readFile(
102
- path6.join(decisionsDir(rootDir), entry),
103
- "utf-8"
104
- );
105
- const parsed = JSON.parse(raw);
106
- if (parsed.version !== 1 || !parsed.id || !parsed.title || !parsed.body) {
107
- continue;
108
- }
109
- records.push(parsed);
110
- } catch {
111
- continue;
564
+ const record = decisionSchema.parse(await readStoreJson(rootDir, relative));
565
+ if (entry !== `${record.id}.json`) throw new Error("Record id does not match its filename");
566
+ records.push(record);
567
+ } catch (error) {
568
+ diagnostics.push({ path: relative, message: error instanceof Error ? error.message : String(error) });
569
+ }
570
+ }
571
+ return { records, diagnostics };
572
+ }
573
+
574
+ // src/decisions/drift.ts
575
+ import path9 from "path";
576
+ async function computeDecisionDrift(rootDir, decisions) {
577
+ const resolvedRoot = path9.resolve(rootDir);
578
+ const store = decisions ? { records: decisions, diagnostics: [] } : await loadDecisionStore(resolvedRoot);
579
+ const report = { historyAvailable: true, totalDecisions: store.records.length, staleDecisions: {}, freshness: {}, diagnostics: store.diagnostics };
580
+ const [head, workingTree] = await Promise.all([getCurrentGitHash(resolvedRoot), getWorkingTree(resolvedRoot)]);
581
+ const changesByHash = /* @__PURE__ */ new Map();
582
+ const inspect = async (record) => {
583
+ if (record.files.length === 0) return { freshness: "unknown", changedFiles: [] };
584
+ let touched = changesByHash.get(record.refreshedHash);
585
+ if (touched === void 0) {
586
+ const changes = record.refreshedHash === head && head !== "unknown" ? [] : await getChangesWithStatus(resolvedRoot, record.refreshedHash);
587
+ touched = changes === null ? null : touchedPaths(changes);
588
+ changesByHash.set(record.refreshedHash, touched);
112
589
  }
590
+ if (touched === null) report.historyAvailable = false;
591
+ const hits = touched ? matchingPaths(record.files, touched) : [];
592
+ const localHits = matchingPaths(record.files, workingTree.changedFiles);
593
+ return { freshness: touched === null || !workingTree.available ? "unknown" : hits.length || localHits.length ? "changed" : "current", changedFiles: hits };
594
+ };
595
+ for (const record of store.records) {
596
+ if (record.status !== "active") continue;
597
+ const effective = effectiveDecision(record);
598
+ const state = await inspect(effective);
599
+ report.freshness[record.id] = state.freshness;
600
+ if (state.changedFiles.length) report.staleDecisions[record.id] = state.changedFiles;
601
+ if (effective !== record) (report.pendingProposals ??= {})[record.id] = await inspect(record);
113
602
  }
114
- return records.sort((a, b) => a.id.localeCompare(b.id));
603
+ return report;
115
604
  }
116
605
 
117
606
  // src/review/cochange.ts
@@ -125,6 +614,8 @@ var MIN_COCHANGE_RATE = 0.6;
125
614
  var MAX_COMMIT_FILES = 30;
126
615
  async function buildMatrix(resolvedRoot) {
127
616
  try {
617
+ const { stdout: shallow } = await exec4("git", ["rev-parse", "--is-shallow-repository"], { cwd: resolvedRoot });
618
+ if (shallow.trim() === "true") return null;
128
619
  const { stdout } = await exec4(
129
620
  "git",
130
621
  ["log", `-n${HISTORY_COMMITS}`, "--format=%x01", "--name-only", "-M"],
@@ -132,7 +623,7 @@ async function buildMatrix(resolvedRoot) {
132
623
  );
133
624
  const commitsByFile = /* @__PURE__ */ new Map();
134
625
  const blocks = stdout.split("");
135
- let index = 0;
626
+ let index2 = 0;
136
627
  for (const block of blocks) {
137
628
  const files = block.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith(".mason/"));
138
629
  if (files.length === 0 || files.length > MAX_COMMIT_FILES) continue;
@@ -142,19 +633,19 @@ async function buildMatrix(resolvedRoot) {
142
633
  set = /* @__PURE__ */ new Set();
143
634
  commitsByFile.set(file, set);
144
635
  }
145
- set.add(index);
636
+ set.add(index2);
146
637
  }
147
- index++;
638
+ index2++;
148
639
  }
149
- return { commitsByFile, totalCommits: index };
640
+ return { commitsByFile, totalCommits: index2 };
150
641
  } catch {
151
642
  return null;
152
643
  }
153
644
  }
154
- async function findMissingPartners(resolvedRoot, changedFiles, existsOnDisk) {
645
+ async function findMissingPartners(resolvedRoot, changedFiles, existsOnDisk, allChangedFiles = changedFiles) {
155
646
  const matrix = await buildMatrix(resolvedRoot);
156
647
  if (!matrix) return null;
157
- const changedSet = new Set(changedFiles);
648
+ const changedSet = new Set(allChangedFiles);
158
649
  const findings = [];
159
650
  for (const changedFile of changedFiles) {
160
651
  const fileCommits = matrix.commitsByFile.get(changedFile);
@@ -182,12 +673,397 @@ async function findMissingPartners(resolvedRoot, changedFiles, existsOnDisk) {
182
673
  return findings;
183
674
  }
184
675
 
676
+ // src/review/evidence.ts
677
+ import path10 from "path";
678
+ import { z as z5 } from "zod";
679
+
680
+ // src/review/evidence/vitest.ts
681
+ import { z as z3 } from "zod";
682
+
683
+ // src/review/evidence/paths.ts
684
+ function evidencePath(value, sourceRoot, uri = false) {
685
+ if (value.length > 4e3) return null;
686
+ let file = value;
687
+ try {
688
+ if (uri) {
689
+ if (file.startsWith("file:")) {
690
+ const url = new URL(file);
691
+ if (url.hostname && url.hostname !== "localhost") return null;
692
+ file = decodeURIComponent(url.pathname).replace(/^\/([A-Za-z]:\/)/, "$1");
693
+ } else {
694
+ if (/^[a-z][a-z0-9+.-]*:/i.test(file)) return null;
695
+ file = decodeURIComponent(file);
696
+ }
697
+ }
698
+ } catch {
699
+ return null;
700
+ }
701
+ file = file.replace(/\\/g, "/");
702
+ const root = sourceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
703
+ if (file.startsWith("/") || /^[A-Za-z]:/.test(file)) {
704
+ if (!file.startsWith(root + "/")) return null;
705
+ file = file.slice(root.length + 1);
706
+ }
707
+ return normalizeRepoPath(file);
708
+ }
709
+
710
+ // src/review/evidence/types.ts
711
+ var MAX_FINDINGS = 200;
712
+ function messagePreview(message2) {
713
+ return { message: message2.slice(0, 4e3), ...message2.length > 4e3 ? { truncated: true } : {} };
714
+ }
715
+
716
+ // src/review/evidence/vitest.ts
717
+ var count = z3.number().int().nonnegative();
718
+ var schema = z3.object({
719
+ success: z3.boolean(),
720
+ numTotalTests: count,
721
+ numPassedTests: count,
722
+ numFailedTests: count,
723
+ numPendingTests: count,
724
+ numTodoTests: count,
725
+ numFailedTestSuites: count,
726
+ testResults: z3.array(z3.object({
727
+ name: z3.string().min(1),
728
+ status: z3.enum(["passed", "failed"]),
729
+ message: z3.string().optional(),
730
+ assertionResults: z3.array(z3.object({
731
+ fullName: z3.string(),
732
+ status: z3.enum(["passed", "failed", "pending", "skipped", "todo", "disabled"]),
733
+ failureMessages: z3.array(z3.string()).nullable().optional(),
734
+ location: z3.object({ line: count, column: count }).nullable().optional()
735
+ }))
736
+ }))
737
+ });
738
+ function parseVitest(raw, sourceRoot) {
739
+ const report = schema.parse(raw);
740
+ const findings = [], diagnostics = [];
741
+ let passed = 0, failed = 0, skipped = 0;
742
+ for (const [index2, suite] of report.testResults.entries()) {
743
+ const file = evidencePath(suite.name, sourceRoot);
744
+ if (!file) diagnostics.push(`Test path is outside the declared checkout or invalid: ${suite.name}`);
745
+ let suiteFailures = 0;
746
+ for (const [testIndex, test] of suite.assertionResults.entries()) {
747
+ if (test.status === "passed") passed++;
748
+ else if (test.status === "failed") {
749
+ failed++;
750
+ suiteFailures++;
751
+ findings.push({
752
+ id: `${index2}:${testIndex}`,
753
+ ...messagePreview([test.fullName, ...test.failureMessages ?? []].join("\n")),
754
+ severity: "error",
755
+ state: "active",
756
+ locations: file ? [{ file, ...test.location ? { line: test.location.line, column: test.location.column } : {} }] : []
757
+ });
758
+ } else skipped++;
759
+ }
760
+ if (suite.status === "failed" && !suiteFailures) {
761
+ findings.push({ id: `${index2}:suite`, ...messagePreview(suite.message || `Test suite failed: ${suite.name}`), severity: "error", state: "active", locations: file ? [{ file }] : [] });
762
+ }
763
+ }
764
+ if (passed !== report.numPassedTests || failed !== report.numFailedTests || skipped !== report.numPendingTests + report.numTodoTests || passed + failed + skipped !== report.numTotalTests) {
765
+ throw new Error("Vitest summary counts disagree with its assertion results");
766
+ }
767
+ const hasFailures = failed > 0 || report.numFailedTestSuites > 0 || report.testResults.some((s) => s.status === "failed");
768
+ if (report.success && hasFailures) throw new Error("Vitest success conflicts with failed tests or suites");
769
+ const outcome = hasFailures || !report.success ? "failed" : !report.numTotalTests ? "unavailable" : !passed ? "skipped" : "passed";
770
+ if (!report.numTotalTests) diagnostics.push("No tests executed; an empty report is not passing test evidence.");
771
+ if (skipped) diagnostics.push(`${skipped} tests were skipped, pending, disabled, or todo.`);
772
+ return {
773
+ outcome,
774
+ findings,
775
+ counts: { total: report.numTotalTests, passed, failed, skipped, failedSuites: report.numFailedTestSuites },
776
+ incomplete: skipped > 0 || diagnostics.length > 0,
777
+ diagnostics
778
+ };
779
+ }
780
+
781
+ // src/review/evidence/sarif.ts
782
+ import { z as z4 } from "zod";
783
+ var index = z4.number().int().nonnegative();
784
+ var artifact = z4.object({ uri: z4.string().optional(), uriBaseId: z4.string().optional(), index: index.optional() });
785
+ var message = z4.object({ text: z4.string().optional(), markdown: z4.string().optional(), id: z4.string().optional(), arguments: z4.array(z4.string()).optional() });
786
+ var level = z4.enum(["error", "warning", "note", "none"]);
787
+ var location = z4.object({ physicalLocation: z4.object({ artifactLocation: artifact.optional(), region: z4.object({ startLine: index.optional(), startColumn: index.optional() }).optional() }).optional() });
788
+ var schema2 = z4.object({
789
+ version: z4.literal("2.1.0"),
790
+ runs: z4.array(z4.object({
791
+ tool: z4.object({ driver: z4.object({ name: z4.string().min(1), rules: z4.array(z4.object({ id: z4.string(), defaultConfiguration: z4.object({ level: level.optional() }).optional(), messageStrings: z4.record(message).optional() })).optional(), globalMessageStrings: z4.record(message).optional() }) }),
792
+ invocations: z4.array(z4.object({ executionSuccessful: z4.boolean(), commandLine: z4.string().optional(), toolExecutionNotifications: z4.array(z4.object({ level: level.optional(), message })).optional() })).optional(),
793
+ versionControlProvenance: z4.array(z4.object({ revisionId: z4.string().optional() })).optional(),
794
+ originalUriBaseIds: z4.record(artifact).optional(),
795
+ artifacts: z4.array(z4.object({ location: artifact.optional() })).optional(),
796
+ results: z4.array(z4.object({
797
+ ruleId: z4.string().optional(),
798
+ ruleIndex: index.optional(),
799
+ message,
800
+ level: level.optional(),
801
+ kind: z4.enum(["fail", "pass", "open", "informational", "notApplicable", "review"]).optional(),
802
+ baselineState: z4.enum(["new", "unchanged", "updated", "absent"]).optional(),
803
+ suppressions: z4.array(z4.object({ kind: z4.enum(["inSource", "external"]), status: z4.enum(["accepted", "underReview", "rejected"]).optional() })).nullable().optional(),
804
+ locations: z4.array(location).optional(),
805
+ relatedLocations: z4.array(location).optional()
806
+ })).optional()
807
+ }))
808
+ });
809
+ function parseSarif(raw, sourceRoot) {
810
+ const report = schema2.parse(raw), findings = [], diagnostics = [];
811
+ let active = 0, suppressed = 0, absent = 0, unresolved = 0;
812
+ let executed = report.runs.length > 0, executionFailed = false;
813
+ const reportedCommits = [], reportedCommands = [], reportedTools = [];
814
+ for (const [runIndex, run] of report.runs.entries()) {
815
+ reportedTools.push(run.tool.driver.name);
816
+ reportedCommits.push(...(run.versionControlProvenance ?? []).flatMap((v) => v.revisionId ? [v.revisionId] : []));
817
+ if (!run.invocations?.length) executed = false;
818
+ for (const invocation of run.invocations ?? []) {
819
+ if (invocation.commandLine) reportedCommands.push(invocation.commandLine);
820
+ if (!invocation.executionSuccessful) executionFailed = true;
821
+ for (const notification of invocation.toolExecutionNotifications ?? []) {
822
+ diagnostics.push(notification.message.text ?? notification.message.markdown ?? "SARIF tool execution notification");
823
+ if (notification.level === "error") executionFailed = true;
824
+ }
825
+ }
826
+ if (!run.results) {
827
+ diagnostics.push(`Run ${runIndex} omits results; analysis output is unavailable.`);
828
+ executed = false;
829
+ }
830
+ const resolve = (ref, seen = /* @__PURE__ */ new Set()) => {
831
+ if (!ref.uri && ref.index !== void 0) {
832
+ const key = `artifact:${ref.index}`;
833
+ if (seen.has(key)) return null;
834
+ const entry = run.artifacts?.[ref.index]?.location;
835
+ return entry ? resolve(entry, /* @__PURE__ */ new Set([...seen, key])) : null;
836
+ }
837
+ if (!ref.uri) return null;
838
+ if (!ref.uriBaseId) return ref.uri;
839
+ if (seen.has(ref.uriBaseId)) return null;
840
+ const base = run.originalUriBaseIds?.[ref.uriBaseId];
841
+ if (!base) return null;
842
+ const prefix = resolve(base, /* @__PURE__ */ new Set([...seen, ref.uriBaseId]));
843
+ if (!prefix) return null;
844
+ return /^[a-z][a-z0-9+.-]*:/i.test(ref.uri) ? ref.uri : prefix + ref.uri;
845
+ };
846
+ for (const [resultIndex, result] of (run.results ?? []).entries()) {
847
+ const kind = result.kind ?? "fail";
848
+ const isSuppressed = result.suppressions?.some((s) => s.status === "accepted");
849
+ if (result.suppressions?.some((s) => !s.status || s.status === "underReview")) {
850
+ diagnostics.push(`Suppression state unresolved for result ${runIndex}:${resultIndex}.`);
851
+ unresolved++;
852
+ }
853
+ const state = result.baselineState === "absent" ? "absent" : isSuppressed ? "suppressed" : kind === "fail" ? "active" : "informational";
854
+ if (state === "active") active++;
855
+ if (state === "absent") absent++;
856
+ if (state === "suppressed") suppressed++;
857
+ if (["open", "review"].includes(kind) && state === "informational") {
858
+ unresolved++;
859
+ diagnostics.push(`Result ${runIndex}:${resultIndex} needs further analysis or review.`);
860
+ }
861
+ if (kind === "pass" || kind === "notApplicable") continue;
862
+ const rule = result.ruleIndex !== void 0 ? run.tool.driver.rules?.[result.ruleIndex] : run.tool.driver.rules?.find((rule2) => rule2.id === result.ruleId);
863
+ const template = result.message.id ? rule?.messageStrings?.[result.message.id] ?? run.tool.driver.globalMessageStrings?.[result.message.id] : void 0;
864
+ const rawMessage = result.message.text ?? result.message.markdown ?? template?.text ?? template?.markdown;
865
+ if (!rawMessage) diagnostics.push(`Message cannot be resolved for result ${runIndex}:${resultIndex}.`);
866
+ const rendered = (rawMessage ?? `Unresolved SARIF message ${result.message.id ?? ""}`).replace(/\{(\d+)\}/g, (match, n) => result.message.arguments?.[Number(n)] ?? match);
867
+ const locations = [];
868
+ for (const entry of [...result.locations ?? [], ...result.relatedLocations ?? []]) {
869
+ const ref = entry.physicalLocation?.artifactLocation;
870
+ const uri = ref ? resolve(ref) : null;
871
+ const file = uri ? evidencePath(uri, sourceRoot, true) : null;
872
+ if (file) locations.push({ file, line: entry.physicalLocation?.region?.startLine, column: entry.physicalLocation?.region?.startColumn });
873
+ else diagnostics.push(`Unresolved or out-of-checkout location for result ${runIndex}:${resultIndex}.`);
874
+ }
875
+ const severity = result.level ?? rule?.defaultConfiguration?.level ?? (kind === "fail" ? "warning" : "none");
876
+ findings.push({ id: `${runIndex}:${resultIndex}`, ruleId: result.ruleId ?? rule?.id, ...messagePreview(rendered), severity: severity === "none" ? "note" : severity, state, locations });
877
+ }
878
+ }
879
+ if (!report.runs.length) diagnostics.push("SARIF contains no analysis runs.");
880
+ if (executionFailed) diagnostics.push("The SARIF tool reported an unsuccessful analysis invocation.");
881
+ return {
882
+ outcome: executionFailed || !report.runs.length || report.runs.some((r) => !r.results) ? "unavailable" : active ? "failed" : "passed",
883
+ findings,
884
+ counts: { active, suppressed, absent, unresolved },
885
+ incomplete: diagnostics.length > 0,
886
+ diagnostics,
887
+ reportedCommits,
888
+ reportedCommands,
889
+ reportedTools,
890
+ executed: executed && !executionFailed
891
+ };
892
+ }
893
+
894
+ // src/review/evidence.ts
895
+ var checkSchema = z5.object({
896
+ id: z5.string().min(1).max(100),
897
+ kind: z5.enum(["tests", "static-analysis", "security", "complexity", "duplication"]),
898
+ tool: z5.string().min(1).max(200),
899
+ command: z5.string().min(1).max(2e3),
900
+ commit: z5.string().regex(/^(?:[a-fA-F0-9]{40}|[a-fA-F0-9]{64})$/).nullable().optional(),
901
+ workingTreeClean: z5.boolean().optional(),
902
+ source: z5.string().max(2e3).optional(),
903
+ sourceRoot: z5.string().min(1).max(4e3).optional(),
904
+ status: z5.enum(["completed", "skipped", "unavailable"]).default("completed"),
905
+ reason: z5.string().min(1).max(2e3).optional(),
906
+ exitCode: z5.number().int().nullable().optional(),
907
+ report: z5.object({ format: z5.enum(["vitest-json", "sarif"]), path: z5.string().min(1).max(4e3) }).optional()
908
+ });
909
+ function relativeArtifact(root, file) {
910
+ const relative = path10.isAbsolute(file) ? path10.relative(root, file) : file;
911
+ const normalized = normalizeRepoPath(relative);
912
+ if (!normalized) throw new Error(`Evidence artifact must be inside the repository: ${file}`);
913
+ return normalized;
914
+ }
915
+ function linkFinding(finding, changed, pairs, decisions, freshness) {
916
+ const located = [...new Set(finding.locations.map((l) => l.file))];
917
+ const paired = pairs.filter((pair) => located.includes(pair.test));
918
+ const relatedChangedFiles = located.filter((file) => changed.has(file)).map((file) => ({ file, relationship: "direct" }));
919
+ for (const pair of paired) {
920
+ if (changed.has(pair.source) && !relatedChangedFiles.some((f) => f.file === pair.source)) relatedChangedFiles.push({ file: pair.source, relationship: "paired-test", confidence: pair.confidence });
921
+ }
922
+ const allFiles = [.../* @__PURE__ */ new Set([...located, ...paired.map((pair) => pair.source)])];
923
+ const acceptedDecisions = decisions.map(effectiveDecision).filter((d) => d.status === "active" && decisionApproval(d) === "accepted").map((d) => ({ d, viaFiles: matchingPaths(d.files, allFiles) })).filter((match) => match.viaFiles.length).map(({ d, viaFiles }) => {
924
+ const state = freshness[d.id] ?? "unknown", provenance = decisionProvenance(d, state);
925
+ return { id: d.id, title: d.title, owner: provenance.owner, freshness: state, reviewRequired: provenance.reviewRequired, viaFiles };
926
+ });
927
+ return { ...finding, relatedChangedFiles, acceptedDecisions: acceptedDecisions.slice(0, 5), totalAcceptedDecisions: acceptedDecisions.length };
928
+ }
929
+ async function collectReviewEvidence(root, manifests, changedFiles, decisions, decisionFreshness = {}, reviewedHead) {
930
+ const [headHash, workingTree] = await Promise.all([reviewedHead ?? getCurrentGitHash(root), getWorkingTree(root)]);
931
+ const output = {
932
+ version: 1,
933
+ scope: "committed",
934
+ headHash,
935
+ status: "unavailable",
936
+ checks: [],
937
+ diagnostics: [],
938
+ workingTree,
939
+ summary: { passed: 0, failed: 0, skipped: 0, unavailable: 0, stale: 0, unknown: 0 },
940
+ hint: "Evidence describes the recorded check runs for a commit, not uncommitted edits or complete test coverage. Commands and CI provenance are imported assertions, not authenticated execution. File and test-pair associations identify relevant decisions; they do not prove a decision was violated. Missing checks must be declared in the manifest to be reported."
941
+ };
942
+ const seen = /* @__PURE__ */ new Set(), changed = new Set(changedFiles);
943
+ let pairs;
944
+ if (manifests.length > 10) output.diagnostics.push("Only the first 10 evidence manifests were imported.");
945
+ for (const supplied of manifests.slice(0, 10)) {
946
+ let manifest, raw;
947
+ try {
948
+ manifest = relativeArtifact(root, supplied);
949
+ raw = z5.object({ version: z5.literal(1), checks: z5.array(z5.unknown()) }).parse(await readStoreJson(root, manifest));
950
+ } catch (error) {
951
+ output.diagnostics.push(`${supplied}: ${String(error)}`);
952
+ continue;
953
+ }
954
+ if (!raw.checks.length) output.diagnostics.push(`${manifest}: no checks declared.`);
955
+ for (const [index2, item] of raw.checks.entries()) {
956
+ if (output.checks.length >= 50) {
957
+ output.diagnostics.push("Only the first 50 checks were imported.");
958
+ break;
959
+ }
960
+ const input = checkSchema.safeParse(item);
961
+ if (!input.success) {
962
+ output.diagnostics.push(`${manifest} check ${index2}: ${input.error.message}`);
963
+ continue;
964
+ }
965
+ const check = input.data;
966
+ if (seen.has(check.id)) {
967
+ output.diagnostics.push(`Duplicate check id ${check.id}; give different runs distinct ids.`);
968
+ continue;
969
+ }
970
+ seen.add(check.id);
971
+ const result = {
972
+ id: check.id,
973
+ kind: check.kind,
974
+ tool: check.tool,
975
+ command: check.command,
976
+ commit: check.commit?.toLowerCase() ?? null,
977
+ workingTreeClean: check.workingTreeClean ?? null,
978
+ source: check.source ?? null,
979
+ manifest,
980
+ report: check.report ?? null,
981
+ outcome: "unavailable",
982
+ freshness: "unknown",
983
+ findings: [],
984
+ totalFindings: 0,
985
+ counts: {},
986
+ incomplete: false,
987
+ diagnostics: [],
988
+ truncated: false
989
+ };
990
+ output.checks.push(result);
991
+ if (check.workingTreeClean !== true) result.diagnostics.push("The check's working tree was dirty or not recorded; its results cannot be attributed to the claimed commit alone.");
992
+ else if (result.commit && headHash !== "unknown") result.freshness = result.commit === headHash.toLowerCase() ? "current" : "stale";
993
+ else result.diagnostics.push("The tested commit or reviewed HEAD is unknown.");
994
+ if (check.status !== "completed") {
995
+ result.outcome = check.status;
996
+ result.diagnostics.push(check.reason ?? "No reason was recorded for this skipped or unavailable check.");
997
+ continue;
998
+ }
999
+ try {
1000
+ if (!check.report) throw new Error("Completed check has no report artifact.");
1001
+ if (check.kind === "tests" !== (check.report.format === "vitest-json")) throw new Error("Test checks require vitest-json; analysis checks require sarif.");
1002
+ const file = relativeArtifact(root, check.report.path);
1003
+ const report = await readStoreJson(root, file);
1004
+ if (report === null) throw new Error(`Report artifact is missing: ${file}`);
1005
+ const sourceRoot = check.sourceRoot ?? root;
1006
+ if (!path10.posix.isAbsolute(sourceRoot) && !/^[A-Za-z]:[\\/]/.test(sourceRoot)) throw new Error("sourceRoot must identify the absolute checkout root on the check runner.");
1007
+ const parsed = check.report.format === "vitest-json" ? parseVitest(report, sourceRoot) : parseSarif(report, sourceRoot);
1008
+ result.outcome = parsed.outcome;
1009
+ result.counts = parsed.counts;
1010
+ result.incomplete = parsed.incomplete;
1011
+ result.diagnostics.push(...parsed.diagnostics);
1012
+ result.reportedCommands = parsed.reportedCommands;
1013
+ result.reportedTools = parsed.reportedTools;
1014
+ if (parsed.reportedCommits?.some((commit) => !result.commit || commit.toLowerCase() !== result.commit)) {
1015
+ result.freshness = "unknown";
1016
+ result.incomplete = true;
1017
+ result.diagnostics.push("Report revision metadata conflicts with, or cannot be tied to, the manifest's tested commit.");
1018
+ }
1019
+ if (check.exitCode == null && !parsed.executed) {
1020
+ result.incomplete = true;
1021
+ result.diagnostics.push("Neither an exit code nor successful analysis invocation was recorded; completion cannot be confirmed.");
1022
+ }
1023
+ if (check.exitCode != null && check.exitCode !== 0 && result.outcome === "passed") {
1024
+ result.outcome = "failed";
1025
+ result.diagnostics.push(`Check command exited ${check.exitCode} despite a report with no active failures.`);
1026
+ }
1027
+ if (check.report.format === "vitest-json" && pairs === void 0) {
1028
+ try {
1029
+ pairs = (await buildTestMap(root)).paired;
1030
+ } catch (error) {
1031
+ pairs = [];
1032
+ output.diagnostics.push(`Test pairing unavailable: ${String(error)}`);
1033
+ }
1034
+ }
1035
+ result.totalFindings = parsed.findings.length;
1036
+ const testPairs = check.kind === "tests" ? pairs ?? [] : [];
1037
+ const relevant = /* @__PURE__ */ new Set([...changed, ...testPairs.filter((p) => changed.has(p.source)).map((p) => p.test)]);
1038
+ const touchesChange = (f) => f.locations.some((l) => relevant.has(l.file));
1039
+ parsed.findings.sort((a, b) => Number(b.state === "active") - Number(a.state === "active") || Number(touchesChange(b)) - Number(touchesChange(a)));
1040
+ result.findings = parsed.findings.slice(0, MAX_FINDINGS).map((f) => linkFinding(f, changed, testPairs, decisions, decisionFreshness));
1041
+ result.truncated = parsed.findings.length > MAX_FINDINGS || result.findings.some((f) => f.truncated || f.totalAcceptedDecisions > f.acceptedDecisions.length);
1042
+ } catch (error) {
1043
+ result.outcome = "unavailable";
1044
+ result.incomplete = true;
1045
+ result.diagnostics.push(String(error));
1046
+ }
1047
+ }
1048
+ }
1049
+ if (!output.checks.length) output.diagnostics.push("No readable checks were imported.");
1050
+ for (const check of output.checks) {
1051
+ output.summary[check.outcome]++;
1052
+ if (check.freshness !== "current") output.summary[check.freshness]++;
1053
+ }
1054
+ if (output.checks.some((c) => c.outcome === "failed" && c.freshness === "current")) output.status = "failed";
1055
+ else if (!output.checks.length || output.checks.every((c) => c.outcome === "unavailable")) output.status = "unavailable";
1056
+ else if (output.diagnostics.length || output.checks.some((c) => c.outcome !== "passed" || c.freshness !== "current" || c.incomplete)) output.status = "incomplete";
1057
+ else output.status = "passed";
1058
+ return output;
1059
+ }
1060
+
185
1061
  // src/review/review.ts
186
1062
  var exec5 = promisify5(execFile5);
187
1063
  var MAX_ANALYZED_FILES = 50;
188
- async function resolveMergeBase(resolvedRoot, base) {
1064
+ async function resolveMergeBase(resolvedRoot, base, head) {
189
1065
  try {
190
- const { stdout } = await exec5("git", ["merge-base", base, "HEAD"], {
1066
+ const { stdout } = await exec5("git", ["merge-base", base, head], {
191
1067
  cwd: resolvedRoot
192
1068
  });
193
1069
  return stdout.trim() || null;
@@ -208,24 +1084,17 @@ async function defaultBase(resolvedRoot) {
208
1084
  return null;
209
1085
  }
210
1086
  function anchorsTouched(record, changedFiles) {
211
- return changedFiles.filter(
212
- (file) => record.files.some((anchor) => {
213
- const a = anchor.replace(/\/+$/, "");
214
- return a === file || file.startsWith(`${a}/`);
215
- })
216
- );
1087
+ return matchingPaths(record.files, changedFiles);
217
1088
  }
218
- async function computeReview(rootDir, base) {
219
- const resolvedRoot = path7.resolve(rootDir);
220
- const mergeBase = await resolveMergeBase(resolvedRoot, base);
1089
+ async function computeReview(rootDir, base, options = {}) {
1090
+ const resolvedRoot = path11.resolve(rootDir);
1091
+ const head = await getCurrentGitHash(resolvedRoot);
1092
+ if (head === "unknown") return null;
1093
+ const mergeBase = await resolveMergeBase(resolvedRoot, base, head);
221
1094
  if (!mergeBase) return null;
222
- const changes = await getChangesWithStatus(resolvedRoot, mergeBase);
1095
+ const changes = await getChangesWithStatus(resolvedRoot, mergeBase, head);
223
1096
  if (changes === null) return null;
224
- const changedFiles = [
225
- ...new Set(
226
- changes.filter((c) => c.status !== "deleted").map((c) => c.path)
227
- )
228
- ].sort();
1097
+ const changedFiles = touchedPaths(changes);
229
1098
  const report = {
230
1099
  version: 1,
231
1100
  root: resolvedRoot,
@@ -237,7 +1106,27 @@ async function computeReview(rootDir, base) {
237
1106
  historyAvailable: true,
238
1107
  truncated: false
239
1108
  };
240
- if (changedFiles.length === 0) return report;
1109
+ const store = await loadDecisionStore(resolvedRoot);
1110
+ report.diagnostics = store.diagnostics;
1111
+ const decisionDrift = await computeDecisionDrift(resolvedRoot, store.records);
1112
+ if (options.evidence !== void 0) {
1113
+ report.evidence = await collectReviewEvidence(resolvedRoot, options.evidence, changedFiles, store.records, decisionDrift.freshness, head);
1114
+ if (store.diagnostics.length) {
1115
+ report.evidence.diagnostics.push("Invalid decision records make knowledge associations incomplete; consult review diagnostics.");
1116
+ if (report.evidence.status === "passed") report.evidence.status = "incomplete";
1117
+ }
1118
+ }
1119
+ const finalize = async () => {
1120
+ if (report.evidence && await getCurrentGitHash(resolvedRoot) !== head) {
1121
+ report.evidence.diagnostics.push("HEAD changed during the review; rerun to obtain consistent change and knowledge associations.");
1122
+ for (const check of report.evidence.checks) check.freshness = "unknown";
1123
+ report.evidence.summary.stale = 0;
1124
+ report.evidence.summary.unknown = report.evidence.checks.length;
1125
+ if (report.evidence.status !== "unavailable") report.evidence.status = "incomplete";
1126
+ }
1127
+ return report;
1128
+ };
1129
+ if (changedFiles.length === 0) return finalize();
241
1130
  let analyzed = changedFiles;
242
1131
  if (changedFiles.length > MAX_ANALYZED_FILES) {
243
1132
  analyzed = changedFiles.slice(0, MAX_ANALYZED_FILES);
@@ -248,38 +1137,42 @@ async function computeReview(rootDir, base) {
248
1137
  analyzed,
249
1138
  async (relPath) => {
250
1139
  try {
251
- await fs5.access(path7.join(resolvedRoot, relPath));
1140
+ await fs5.access(path11.join(resolvedRoot, relPath));
252
1141
  return true;
253
1142
  } catch {
254
1143
  return false;
255
1144
  }
256
- }
1145
+ },
1146
+ changedFiles
257
1147
  );
258
1148
  if (partners === null) {
259
1149
  report.historyAvailable = false;
260
1150
  } else {
261
1151
  report.missingPartners = partners;
262
1152
  }
263
- const decisions = await loadDecisions(resolvedRoot);
1153
+ const decisions = store.records;
264
1154
  for (const record of decisions) {
265
- if (record.status !== "active" || record.files.length === 0) continue;
266
- const touched = anchorsTouched(record, changedFiles);
267
- if (touched.length > 0) {
1155
+ if (record.status !== "active") continue;
1156
+ const effective = effectiveDecision(record);
1157
+ const touched = anchorsTouched(effective, changedFiles);
1158
+ const proposalTouched = effective !== record ? anchorsTouched(record, changedFiles) : [];
1159
+ if (touched.length > 0 || proposalTouched.length > 0) {
1160
+ const { pendingProposal, ...knowledge } = decisionKnowledge(record, decisionDrift.freshness?.[record.id] ?? "unknown", decisionDrift.pendingProposals?.[record.id]?.freshness ?? "unknown");
268
1161
  report.touchedDecisions.push({
1162
+ ...knowledge,
1163
+ ...pendingProposal ? { pendingProposal: { ...pendingProposal, touchedFiles: proposalTouched } } : {},
269
1164
  id: record.id,
270
- title: record.title,
271
- body: record.body,
272
- category: record.category,
273
- anchors: record.files,
1165
+ anchors: effective.files,
1166
+ freshness: decisionDrift.freshness?.[record.id] ?? "unknown",
274
1167
  touchedFiles: touched
275
1168
  });
276
1169
  }
277
1170
  }
278
- return report;
1171
+ return finalize();
279
1172
  }
280
1173
 
281
1174
  // src/review/cli.ts
282
- var USAGE = `Usage: mason-review [--dir <path>] [--base <ref>] [--json]
1175
+ var USAGE = `Usage: mason-review [--dir <path>] [--base <ref>] [--json] [--evidence <manifest>] [--require-evidence]
283
1176
 
284
1177
  Reviews the current branch's diff against what git history and the Mason
285
1178
  decision store know:
@@ -294,18 +1187,26 @@ Options:
294
1187
  --base <ref> Base ref to diff against via merge-base (default: origin/HEAD,
295
1188
  origin/main, origin/master, or main \u2013 first that resolves)
296
1189
  --json Full report as JSON (additive-only schema)
1190
+ --evidence <manifest> Import a CI evidence manifest (repeatable; paths inside --dir)
1191
+ --require-evidence Require current, complete passing evidence; needs --evidence
297
1192
  --help Show this help
298
1193
 
299
1194
  Exit codes:
300
1195
  0 no missing co-change partners (touched decisions alone do not fail)
301
1196
  1 missing co-change partners found
302
- 2 error (base unresolvable, not a git repository, bad arguments)`;
1197
+ 2 error (base unresolvable, not a git repository, bad arguments)
1198
+
1199
+ Evidence is advisory unless --require-evidence is set. With that flag, current
1200
+ failed checks also exit 1; missing, skipped, stale, or incomplete evidence exits 2.
1201
+ No check command from an imported report is executed.`;
303
1202
  function parseArgs(argv) {
304
1203
  const parsed = {
305
1204
  dir: process.cwd(),
306
1205
  base: void 0,
307
1206
  json: false,
308
- help: false
1207
+ help: false,
1208
+ evidence: [],
1209
+ requireEvidence: false
309
1210
  };
310
1211
  for (let i = 0; i < argv.length; i++) {
311
1212
  const arg = argv[i];
@@ -321,19 +1222,52 @@ function parseArgs(argv) {
321
1222
  const value = argv[++i];
322
1223
  if (!value) throw new Error("--base requires a ref argument");
323
1224
  parsed.base = value;
1225
+ } else if (arg === "--evidence") {
1226
+ const value = argv[++i];
1227
+ if (!value || value.startsWith("--")) throw new Error("--evidence requires a manifest path");
1228
+ parsed.evidence.push(value);
1229
+ } else if (arg === "--require-evidence") {
1230
+ parsed.requireEvidence = true;
324
1231
  } else if (!arg.startsWith("-") && parsed.dir === process.cwd()) {
325
1232
  parsed.dir = arg;
326
1233
  } else {
327
1234
  throw new Error(`Unknown argument: ${arg}`);
328
1235
  }
329
1236
  }
1237
+ if (parsed.requireEvidence && !parsed.evidence.length) throw new Error("--require-evidence requires at least one --evidence manifest");
330
1238
  return parsed;
331
1239
  }
1240
+ var display = (value, limit = 500) => stripVTControlCharacters(value).replace(/[\r\n\t]/g, " ").slice(0, limit);
1241
+ function evidenceSummary(report) {
1242
+ const evidence = report.evidence;
1243
+ if (!evidence) return [];
1244
+ const lines = [`Imported CI evidence: ${evidence.status} (commit ${evidence.headHash.slice(0, 12)}; committed scope).`];
1245
+ for (const diagnostic of evidence.diagnostics) lines.push(` Evidence unavailable: ${display(diagnostic)}`);
1246
+ for (const check of evidence.checks) {
1247
+ lines.push(` [${check.kind}] ${display(check.id)}: ${check.outcome}, ${check.freshness} (${display(check.tool)})`);
1248
+ lines.push(` command: ${display(check.command)}; tested commit: ${check.commit ?? "unknown"}`);
1249
+ lines.push(` source: ${display(check.source ?? check.manifest)}; report: ${display(check.report?.path ?? "none")}`);
1250
+ if (Object.keys(check.counts).length) lines.push(` counts: ${JSON.stringify(check.counts)}`);
1251
+ for (const diagnostic of check.diagnostics.slice(0, 5)) lines.push(` note: ${display(diagnostic)}`);
1252
+ for (const finding of check.findings.slice(0, 10)) {
1253
+ const locations = finding.locations.map((l) => `${l.file}${l.line ? `:${l.line}` : ""}`).join(", ") || "no resolved file location";
1254
+ lines.push(` [${finding.state}/${finding.severity}] ${display(finding.ruleId ?? finding.id)} ${display(locations)}: ${display(finding.message)}`);
1255
+ if (finding.relatedChangedFiles.length) lines.push(` changed files: ${finding.relatedChangedFiles.map((f) => `${display(f.file)} (${f.relationship}${f.confidence ? `, ${f.confidence}` : ""})`).join(", ")}`);
1256
+ for (const decision of finding.acceptedDecisions) lines.push(` related accepted decision: ${display(decision.id)}; owner ${display(decision.owner ?? "unknown")}; freshness ${decision.freshness}${decision.reviewRequired ? "; needs review" : ""}`);
1257
+ }
1258
+ if (check.totalFindings > 10 || check.truncated || check.diagnostics.length > 5) lines.push(` Output abbreviated (${check.totalFindings} findings); inspect --json and the original artifact for full evidence.`);
1259
+ }
1260
+ if (!evidence.workingTree.available) lines.push(" Working-tree state unavailable; imported evidence only describes the recorded commit.");
1261
+ else if (evidence.workingTree.changedFiles.length) lines.push(` ${evidence.workingTree.changedFiles.length} uncommitted paths are outside this evidence's scope.`);
1262
+ lines.push(" Related decisions are associations, not proven violations. Passing imported checks do not establish complete coverage.");
1263
+ return lines;
1264
+ }
332
1265
  function formatReviewSummary(report) {
333
1266
  const lines = [];
334
1267
  lines.push(
335
1268
  `Diff vs ${report.base} (merge-base ${report.mergeBase.slice(0, 7)}): ${report.changedFiles.length} changed file${report.changedFiles.length === 1 ? "" : "s"}.`
336
1269
  );
1270
+ for (const diagnostic of report.diagnostics ?? []) lines.push(`Invalid decision record ${diagnostic.path}: ${diagnostic.message}`);
337
1271
  if (report.truncated) {
338
1272
  lines.push(
339
1273
  `Large diff \u2013 co-change analysis limited to the first ${50} files.`
@@ -353,16 +1287,23 @@ function formatReviewSummary(report) {
353
1287
  }
354
1288
  }
355
1289
  if (report.touchedDecisions.length > 0) {
356
- lines.push("Recorded decisions touched by this diff (constraints \u2013 verify the diff respects them):");
1290
+ lines.push("Recorded decisions touched by this diff (check approval and freshness before relying on them):");
357
1291
  for (const d of report.touchedDecisions) {
358
1292
  lines.push(
359
- ` [decision] ${d.title} (${d.category}; via ${d.touchedFiles.join(", ")})`
1293
+ ` [decision] ${d.title} (${d.category}; ${d.approval ?? "unreviewed"}; owner ${d.owner ?? "unknown"}; freshness ${d.freshness ?? "unknown"}; via ${d.touchedFiles.join(", ") || "pending proposal"})`
360
1294
  );
1295
+ if (d.sources?.length) lines.push(` sources: ${d.sources.map((s) => s.reference).join(", ")}`);
1296
+ if (d.lastReview) lines.push(` last review: ${d.lastReview.reviewer} at ${d.lastReview.gitHash.slice(0, 7)}`);
1297
+ if (d.pendingProposal) {
1298
+ const p = d.pendingProposal;
1299
+ lines.push(` [proposed revision ${p.revision}] ${p.title} (owner ${p.owner ?? "unknown"}; freshness ${p.trust.freshness}; via ${p.touchedFiles.join(", ") || "no draft anchors touched"}); accepted revision ${d.revision} remains operative`);
1300
+ }
361
1301
  }
362
1302
  }
363
1303
  lines.push(
364
1304
  report.missingPartners.length === 0 ? "No missing co-change partners." : `${report.missingPartners.length} possible omission${report.missingPartners.length === 1 ? "" : "s"}.`
365
1305
  );
1306
+ lines.push(...evidenceSummary(report));
366
1307
  return lines.join("\n");
367
1308
  }
368
1309
  async function runReviewCli(argv, io = {
@@ -383,7 +1324,7 @@ async function runReviewCli(argv, io = {
383
1324
  io.out(USAGE);
384
1325
  return 0;
385
1326
  }
386
- const rootDir = path8.resolve(args.dir);
1327
+ const rootDir = path12.resolve(args.dir);
387
1328
  const base = args.base ?? await defaultBase(rootDir);
388
1329
  if (!base) {
389
1330
  io.err(
@@ -391,7 +1332,7 @@ async function runReviewCli(argv, io = {
391
1332
  );
392
1333
  return 2;
393
1334
  }
394
- const report = await computeReview(rootDir, base);
1335
+ const report = await computeReview(rootDir, base, args.evidence.length ? { evidence: args.evidence } : {});
395
1336
  if (!report) {
396
1337
  io.err(
397
1338
  `Could not resolve a merge base between ${base} and HEAD in ${rootDir} \u2013 not a git repository, unknown ref, or unrelated histories.`
@@ -402,7 +1343,9 @@ async function runReviewCli(argv, io = {
402
1343
  io.out(JSON.stringify(report, null, 2));
403
1344
  } else {
404
1345
  io.out(formatReviewSummary(report));
1346
+ if (args.evidence.length && !args.requireEvidence) io.out("Imported evidence is advisory for exit codes; use --require-evidence to require current passing checks.");
405
1347
  }
1348
+ if (args.requireEvidence && report.evidence?.status !== "passed") return report.evidence?.status === "failed" ? 1 : 2;
406
1349
  return report.missingPartners.length > 0 ? 1 : 0;
407
1350
  }
408
1351