mason-context 0.9.0 → 0.10.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.
@@ -1,117 +1,559 @@
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
+ var text = (max) => z2.string().trim().min(1).max(max);
380
+ var decisionSourceSchema = z2.object({
381
+ kind: z2.enum(["pull_request", "issue", "incident", "discussion", "document", "other"]),
382
+ reference: text(1e3),
383
+ note: text(500).optional()
384
+ }).strict();
385
+ var attributionSchema = z2.object({
386
+ owner: text(200).nullable().optional(),
387
+ sources: z2.array(decisionSourceSchema).max(20).optional(),
388
+ actor: text(200).optional()
389
+ });
390
+ var contentSchema = z2.object({
391
+ title: z2.string().min(1),
392
+ body: z2.string().min(1),
393
+ category: z2.enum(["decision", "gotcha", "deprecation", "convention"]),
394
+ files: z2.array(z2.string().refine((f) => normalizeRepoPath(f) !== null)),
395
+ owner: text(200).optional(),
396
+ sources: z2.array(decisionSourceSchema).max(20)
397
+ });
398
+ var approvalSchema = z2.enum(["unreviewed", "proposed", "accepted"]);
399
+ var statusSchema = z2.enum(["active", "superseded", "retired"]);
400
+ var reviewEvidenceSchema = z2.object({
401
+ baseHash: z2.string(),
402
+ headHash: z2.string(),
403
+ historyAvailable: z2.boolean(),
404
+ changedFiles: z2.array(z2.string()),
405
+ localChanges: z2.array(z2.string())
406
+ });
407
+ var eventSchema = z2.object({
408
+ kind: z2.enum(["imported", "created", "revised", "accepted", "reaffirmed", "retired", "superseded"]),
409
+ at: z2.string().datetime(),
410
+ actor: text(200).optional(),
411
+ note: text(1500).optional(),
412
+ revision: z2.number().int().positive(),
413
+ content: contentSchema,
414
+ approval: approvalSchema,
415
+ status: statusSchema,
416
+ refreshedHash: z2.string(),
417
+ evidence: reviewEvidenceSchema.optional()
418
+ });
419
+ var legacySchema = z2.object({
420
+ version: z2.literal(1),
421
+ id: z2.string().regex(/^[a-zA-Z0-9_-]+$/),
422
+ title: z2.string().min(1),
423
+ body: z2.string().min(1),
424
+ category: contentSchema.shape.category,
425
+ files: contentSchema.shape.files,
426
+ createdAt: z2.string(),
427
+ updatedAt: z2.string(),
428
+ refreshedHash: z2.string(),
429
+ status: z2.enum(["active", "superseded"]),
430
+ supersededBy: z2.string().optional()
431
+ }).passthrough();
432
+ var currentSchema = legacySchema.extend({
433
+ version: z2.literal(2),
434
+ status: statusSchema,
435
+ approval: approvalSchema,
436
+ revision: z2.number().int().positive(),
437
+ owner: text(200).optional(),
438
+ sources: z2.array(decisionSourceSchema).max(20),
439
+ history: z2.array(eventSchema).min(1)
440
+ }).superRefine((record, ctx) => {
441
+ const invalid = (message2) => ctx.addIssue({ code: "custom", message: message2 });
442
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
443
+ let previous;
444
+ for (const event of record.history) {
445
+ if (!previous) {
446
+ if (!["created", "imported"].includes(event.kind) || event.revision !== 1) invalid("History must begin with creation or legacy import at revision 1");
447
+ if (event.approval !== (event.kind === "created" ? "proposed" : "unreviewed")) invalid("Initial records cannot claim acceptance");
448
+ } else {
449
+ if (["created", "imported"].includes(event.kind)) invalid("History cannot restart");
450
+ if (previous.status !== "active") invalid("Archived decisions cannot be changed");
451
+ if (event.revision !== previous.revision + (event.kind === "revised" ? 1 : 0)) invalid("Invalid revision sequence");
452
+ if (event.kind !== "revised" && !same(event.content, previous.content)) invalid("A review cannot silently revise decision content");
453
+ if (event.kind === "reaffirmed" && previous.approval !== "accepted") invalid("Only accepted decisions can be reaffirmed");
454
+ if (event.kind === "accepted" && previous.approval === "accepted") invalid("Use reaffirmation for an accepted decision");
455
+ const approval = event.kind === "revised" ? "proposed" : ["accepted", "reaffirmed"].includes(event.kind) ? "accepted" : previous.approval;
456
+ if (event.approval !== approval) invalid("Approval disagrees with review history");
457
+ if (!["accepted", "reaffirmed"].includes(event.kind) && event.refreshedHash !== previous.refreshedHash) invalid("Only a review can refresh the evidence baseline");
458
+ }
459
+ if (event.kind !== "imported" && event.status !== (event.kind === "retired" ? "retired" : event.kind === "superseded" ? "superseded" : "active")) invalid("Lifecycle disagrees with history");
460
+ if (["accepted", "reaffirmed", "retired"].includes(event.kind) && (!event.actor || !event.note || !event.evidence)) invalid("Reviews require a named reviewer, reason, and code evidence");
461
+ if (["accepted", "reaffirmed"].includes(event.kind)) {
462
+ if (!event.content.owner || !event.content.sources.length) invalid("Accepted decisions require an owner and source");
463
+ 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");
464
+ }
465
+ previous = event;
466
+ }
467
+ 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");
468
+ });
469
+ var decisionSchema = z2.union([legacySchema, currentSchema]);
470
+ function decisionContent(record) {
471
+ return {
472
+ title: record.title,
473
+ body: record.body,
474
+ category: record.category,
475
+ files: record.files,
476
+ ...typeof record.owner === "string" ? { owner: record.owner } : {},
477
+ sources: Array.isArray(record.sources) ? record.sources : []
478
+ };
479
+ }
480
+ function decisionApproval(record) {
481
+ return record.version === 1 ? "unreviewed" : record.approval;
482
+ }
483
+ function decisionProvenance(record, freshness = "unknown") {
484
+ const approval = decisionApproval(record);
485
+ const review = record.version === 2 ? [...record.history].reverse().find((e) => ["accepted", "reaffirmed"].includes(e.kind) && e.revision === record.revision) : void 0;
486
+ return {
487
+ approval,
488
+ revision: record.version === 2 ? record.revision : 0,
489
+ owner: record.version === 2 ? record.owner ?? null : null,
490
+ sources: record.version === 2 ? record.sources : [],
491
+ guidance: record.status !== "active" ? "historical" : approval === "accepted" ? "constraint" : approval === "proposed" ? "proposal" : "unreviewed",
492
+ reviewRequired: record.status === "active" && (approval !== "accepted" || freshness !== "current"),
493
+ lastReview: review ? { reviewer: review.actor, at: review.at, note: review.note, gitHash: review.refreshedHash } : null
494
+ };
495
+ }
77
496
 
78
497
  // src/decisions/decisions.ts
79
498
  import fs4 from "fs/promises";
80
- import path6 from "path";
499
+ import path8 from "path";
81
500
  import { createHash } from "crypto";
82
501
 
83
502
  // src/context/lexical.ts
84
- import path5 from "path";
503
+ import path7 from "path";
85
504
 
86
505
  // src/decisions/decisions.ts
87
- function decisionsDir(rootDir) {
88
- return path6.join(rootDir, ".mason", "decisions");
89
- }
90
- async function loadDecisions(rootDir) {
506
+ async function loadDecisionStore(rootDir) {
507
+ const records = [];
508
+ const diagnostics = [];
91
509
  let entries;
92
510
  try {
93
- entries = await fs4.readdir(decisionsDir(rootDir));
94
- } catch {
95
- return [];
511
+ entries = await fs4.readdir(await storePath(rootDir, ".mason/decisions"));
512
+ } catch (error) {
513
+ if (error.code !== "ENOENT") diagnostics.push({ path: ".mason/decisions", message: String(error) });
514
+ return { records, diagnostics };
96
515
  }
97
- const records = [];
98
- for (const entry of entries) {
516
+ for (const entry of entries.sort()) {
99
517
  if (!entry.endsWith(".json")) continue;
518
+ const relative = `.mason/decisions/${entry}`;
100
519
  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 {
520
+ const record = decisionSchema.parse(await readStoreJson(rootDir, relative));
521
+ if (entry !== `${record.id}.json`) throw new Error("Record id does not match its filename");
522
+ records.push(record);
523
+ } catch (error) {
524
+ diagnostics.push({ path: relative, message: error instanceof Error ? error.message : String(error) });
525
+ }
526
+ }
527
+ return { records, diagnostics };
528
+ }
529
+
530
+ // src/decisions/drift.ts
531
+ import path9 from "path";
532
+ async function computeDecisionDrift(rootDir, decisions) {
533
+ const resolvedRoot = path9.resolve(rootDir);
534
+ const store = decisions ? { records: decisions, diagnostics: [] } : await loadDecisionStore(resolvedRoot);
535
+ const report = { historyAvailable: true, totalDecisions: store.records.length, staleDecisions: {}, freshness: {}, diagnostics: store.diagnostics };
536
+ const [head, workingTree] = await Promise.all([getCurrentGitHash(resolvedRoot), getWorkingTree(resolvedRoot)]);
537
+ const changesByHash = /* @__PURE__ */ new Map();
538
+ for (const record of store.records) {
539
+ if (record.status !== "active") continue;
540
+ if (record.files.length === 0) {
541
+ report.freshness[record.id] = "unknown";
111
542
  continue;
112
543
  }
544
+ let touched = changesByHash.get(record.refreshedHash);
545
+ if (touched === void 0) {
546
+ const changes = record.refreshedHash === head && head !== "unknown" ? [] : await getChangesWithStatus(resolvedRoot, record.refreshedHash);
547
+ touched = changes === null ? null : touchedPaths(changes);
548
+ changesByHash.set(record.refreshedHash, touched);
549
+ }
550
+ if (touched === null) report.historyAvailable = false;
551
+ const hits = touched ? matchingPaths(record.files, touched) : [];
552
+ if (hits.length) report.staleDecisions[record.id] = hits;
553
+ const localHits = matchingPaths(record.files, workingTree.changedFiles);
554
+ report.freshness[record.id] = touched === null || !workingTree.available ? "unknown" : hits.length || localHits.length ? "changed" : "current";
113
555
  }
114
- return records.sort((a, b) => a.id.localeCompare(b.id));
556
+ return report;
115
557
  }
116
558
 
117
559
  // src/review/cochange.ts
@@ -125,6 +567,8 @@ var MIN_COCHANGE_RATE = 0.6;
125
567
  var MAX_COMMIT_FILES = 30;
126
568
  async function buildMatrix(resolvedRoot) {
127
569
  try {
570
+ const { stdout: shallow } = await exec4("git", ["rev-parse", "--is-shallow-repository"], { cwd: resolvedRoot });
571
+ if (shallow.trim() === "true") return null;
128
572
  const { stdout } = await exec4(
129
573
  "git",
130
574
  ["log", `-n${HISTORY_COMMITS}`, "--format=%x01", "--name-only", "-M"],
@@ -132,7 +576,7 @@ async function buildMatrix(resolvedRoot) {
132
576
  );
133
577
  const commitsByFile = /* @__PURE__ */ new Map();
134
578
  const blocks = stdout.split("");
135
- let index = 0;
579
+ let index2 = 0;
136
580
  for (const block of blocks) {
137
581
  const files = block.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith(".mason/"));
138
582
  if (files.length === 0 || files.length > MAX_COMMIT_FILES) continue;
@@ -142,19 +586,19 @@ async function buildMatrix(resolvedRoot) {
142
586
  set = /* @__PURE__ */ new Set();
143
587
  commitsByFile.set(file, set);
144
588
  }
145
- set.add(index);
589
+ set.add(index2);
146
590
  }
147
- index++;
591
+ index2++;
148
592
  }
149
- return { commitsByFile, totalCommits: index };
593
+ return { commitsByFile, totalCommits: index2 };
150
594
  } catch {
151
595
  return null;
152
596
  }
153
597
  }
154
- async function findMissingPartners(resolvedRoot, changedFiles, existsOnDisk) {
598
+ async function findMissingPartners(resolvedRoot, changedFiles, existsOnDisk, allChangedFiles = changedFiles) {
155
599
  const matrix = await buildMatrix(resolvedRoot);
156
600
  if (!matrix) return null;
157
- const changedSet = new Set(changedFiles);
601
+ const changedSet = new Set(allChangedFiles);
158
602
  const findings = [];
159
603
  for (const changedFile of changedFiles) {
160
604
  const fileCommits = matrix.commitsByFile.get(changedFile);
@@ -182,12 +626,397 @@ async function findMissingPartners(resolvedRoot, changedFiles, existsOnDisk) {
182
626
  return findings;
183
627
  }
184
628
 
629
+ // src/review/evidence.ts
630
+ import path10 from "path";
631
+ import { z as z5 } from "zod";
632
+
633
+ // src/review/evidence/vitest.ts
634
+ import { z as z3 } from "zod";
635
+
636
+ // src/review/evidence/paths.ts
637
+ function evidencePath(value, sourceRoot, uri = false) {
638
+ if (value.length > 4e3) return null;
639
+ let file = value;
640
+ try {
641
+ if (uri) {
642
+ if (file.startsWith("file:")) {
643
+ const url = new URL(file);
644
+ if (url.hostname && url.hostname !== "localhost") return null;
645
+ file = decodeURIComponent(url.pathname).replace(/^\/([A-Za-z]:\/)/, "$1");
646
+ } else {
647
+ if (/^[a-z][a-z0-9+.-]*:/i.test(file)) return null;
648
+ file = decodeURIComponent(file);
649
+ }
650
+ }
651
+ } catch {
652
+ return null;
653
+ }
654
+ file = file.replace(/\\/g, "/");
655
+ const root = sourceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
656
+ if (file.startsWith("/") || /^[A-Za-z]:/.test(file)) {
657
+ if (!file.startsWith(root + "/")) return null;
658
+ file = file.slice(root.length + 1);
659
+ }
660
+ return normalizeRepoPath(file);
661
+ }
662
+
663
+ // src/review/evidence/types.ts
664
+ var MAX_FINDINGS = 200;
665
+ function messagePreview(message2) {
666
+ return { message: message2.slice(0, 4e3), ...message2.length > 4e3 ? { truncated: true } : {} };
667
+ }
668
+
669
+ // src/review/evidence/vitest.ts
670
+ var count = z3.number().int().nonnegative();
671
+ var schema = z3.object({
672
+ success: z3.boolean(),
673
+ numTotalTests: count,
674
+ numPassedTests: count,
675
+ numFailedTests: count,
676
+ numPendingTests: count,
677
+ numTodoTests: count,
678
+ numFailedTestSuites: count,
679
+ testResults: z3.array(z3.object({
680
+ name: z3.string().min(1),
681
+ status: z3.enum(["passed", "failed"]),
682
+ message: z3.string().optional(),
683
+ assertionResults: z3.array(z3.object({
684
+ fullName: z3.string(),
685
+ status: z3.enum(["passed", "failed", "pending", "skipped", "todo", "disabled"]),
686
+ failureMessages: z3.array(z3.string()).nullable().optional(),
687
+ location: z3.object({ line: count, column: count }).nullable().optional()
688
+ }))
689
+ }))
690
+ });
691
+ function parseVitest(raw, sourceRoot) {
692
+ const report = schema.parse(raw);
693
+ const findings = [], diagnostics = [];
694
+ let passed = 0, failed = 0, skipped = 0;
695
+ for (const [index2, suite] of report.testResults.entries()) {
696
+ const file = evidencePath(suite.name, sourceRoot);
697
+ if (!file) diagnostics.push(`Test path is outside the declared checkout or invalid: ${suite.name}`);
698
+ let suiteFailures = 0;
699
+ for (const [testIndex, test] of suite.assertionResults.entries()) {
700
+ if (test.status === "passed") passed++;
701
+ else if (test.status === "failed") {
702
+ failed++;
703
+ suiteFailures++;
704
+ findings.push({
705
+ id: `${index2}:${testIndex}`,
706
+ ...messagePreview([test.fullName, ...test.failureMessages ?? []].join("\n")),
707
+ severity: "error",
708
+ state: "active",
709
+ locations: file ? [{ file, ...test.location ? { line: test.location.line, column: test.location.column } : {} }] : []
710
+ });
711
+ } else skipped++;
712
+ }
713
+ if (suite.status === "failed" && !suiteFailures) {
714
+ findings.push({ id: `${index2}:suite`, ...messagePreview(suite.message || `Test suite failed: ${suite.name}`), severity: "error", state: "active", locations: file ? [{ file }] : [] });
715
+ }
716
+ }
717
+ if (passed !== report.numPassedTests || failed !== report.numFailedTests || skipped !== report.numPendingTests + report.numTodoTests || passed + failed + skipped !== report.numTotalTests) {
718
+ throw new Error("Vitest summary counts disagree with its assertion results");
719
+ }
720
+ const hasFailures = failed > 0 || report.numFailedTestSuites > 0 || report.testResults.some((s) => s.status === "failed");
721
+ if (report.success && hasFailures) throw new Error("Vitest success conflicts with failed tests or suites");
722
+ const outcome = hasFailures || !report.success ? "failed" : !report.numTotalTests ? "unavailable" : !passed ? "skipped" : "passed";
723
+ if (!report.numTotalTests) diagnostics.push("No tests executed; an empty report is not passing test evidence.");
724
+ if (skipped) diagnostics.push(`${skipped} tests were skipped, pending, disabled, or todo.`);
725
+ return {
726
+ outcome,
727
+ findings,
728
+ counts: { total: report.numTotalTests, passed, failed, skipped, failedSuites: report.numFailedTestSuites },
729
+ incomplete: skipped > 0 || diagnostics.length > 0,
730
+ diagnostics
731
+ };
732
+ }
733
+
734
+ // src/review/evidence/sarif.ts
735
+ import { z as z4 } from "zod";
736
+ var index = z4.number().int().nonnegative();
737
+ var artifact = z4.object({ uri: z4.string().optional(), uriBaseId: z4.string().optional(), index: index.optional() });
738
+ var message = z4.object({ text: z4.string().optional(), markdown: z4.string().optional(), id: z4.string().optional(), arguments: z4.array(z4.string()).optional() });
739
+ var level = z4.enum(["error", "warning", "note", "none"]);
740
+ var location = z4.object({ physicalLocation: z4.object({ artifactLocation: artifact.optional(), region: z4.object({ startLine: index.optional(), startColumn: index.optional() }).optional() }).optional() });
741
+ var schema2 = z4.object({
742
+ version: z4.literal("2.1.0"),
743
+ runs: z4.array(z4.object({
744
+ 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() }) }),
745
+ invocations: z4.array(z4.object({ executionSuccessful: z4.boolean(), commandLine: z4.string().optional(), toolExecutionNotifications: z4.array(z4.object({ level: level.optional(), message })).optional() })).optional(),
746
+ versionControlProvenance: z4.array(z4.object({ revisionId: z4.string().optional() })).optional(),
747
+ originalUriBaseIds: z4.record(artifact).optional(),
748
+ artifacts: z4.array(z4.object({ location: artifact.optional() })).optional(),
749
+ results: z4.array(z4.object({
750
+ ruleId: z4.string().optional(),
751
+ ruleIndex: index.optional(),
752
+ message,
753
+ level: level.optional(),
754
+ kind: z4.enum(["fail", "pass", "open", "informational", "notApplicable", "review"]).optional(),
755
+ baselineState: z4.enum(["new", "unchanged", "updated", "absent"]).optional(),
756
+ suppressions: z4.array(z4.object({ kind: z4.enum(["inSource", "external"]), status: z4.enum(["accepted", "underReview", "rejected"]).optional() })).nullable().optional(),
757
+ locations: z4.array(location).optional(),
758
+ relatedLocations: z4.array(location).optional()
759
+ })).optional()
760
+ }))
761
+ });
762
+ function parseSarif(raw, sourceRoot) {
763
+ const report = schema2.parse(raw), findings = [], diagnostics = [];
764
+ let active = 0, suppressed = 0, absent = 0, unresolved = 0;
765
+ let executed = report.runs.length > 0, executionFailed = false;
766
+ const reportedCommits = [], reportedCommands = [], reportedTools = [];
767
+ for (const [runIndex, run] of report.runs.entries()) {
768
+ reportedTools.push(run.tool.driver.name);
769
+ reportedCommits.push(...(run.versionControlProvenance ?? []).flatMap((v) => v.revisionId ? [v.revisionId] : []));
770
+ if (!run.invocations?.length) executed = false;
771
+ for (const invocation of run.invocations ?? []) {
772
+ if (invocation.commandLine) reportedCommands.push(invocation.commandLine);
773
+ if (!invocation.executionSuccessful) executionFailed = true;
774
+ for (const notification of invocation.toolExecutionNotifications ?? []) {
775
+ diagnostics.push(notification.message.text ?? notification.message.markdown ?? "SARIF tool execution notification");
776
+ if (notification.level === "error") executionFailed = true;
777
+ }
778
+ }
779
+ if (!run.results) {
780
+ diagnostics.push(`Run ${runIndex} omits results; analysis output is unavailable.`);
781
+ executed = false;
782
+ }
783
+ const resolve = (ref, seen = /* @__PURE__ */ new Set()) => {
784
+ if (!ref.uri && ref.index !== void 0) {
785
+ const key = `artifact:${ref.index}`;
786
+ if (seen.has(key)) return null;
787
+ const entry = run.artifacts?.[ref.index]?.location;
788
+ return entry ? resolve(entry, /* @__PURE__ */ new Set([...seen, key])) : null;
789
+ }
790
+ if (!ref.uri) return null;
791
+ if (!ref.uriBaseId) return ref.uri;
792
+ if (seen.has(ref.uriBaseId)) return null;
793
+ const base = run.originalUriBaseIds?.[ref.uriBaseId];
794
+ if (!base) return null;
795
+ const prefix = resolve(base, /* @__PURE__ */ new Set([...seen, ref.uriBaseId]));
796
+ if (!prefix) return null;
797
+ return /^[a-z][a-z0-9+.-]*:/i.test(ref.uri) ? ref.uri : prefix + ref.uri;
798
+ };
799
+ for (const [resultIndex, result] of (run.results ?? []).entries()) {
800
+ const kind = result.kind ?? "fail";
801
+ const isSuppressed = result.suppressions?.some((s) => s.status === "accepted");
802
+ if (result.suppressions?.some((s) => !s.status || s.status === "underReview")) {
803
+ diagnostics.push(`Suppression state unresolved for result ${runIndex}:${resultIndex}.`);
804
+ unresolved++;
805
+ }
806
+ const state = result.baselineState === "absent" ? "absent" : isSuppressed ? "suppressed" : kind === "fail" ? "active" : "informational";
807
+ if (state === "active") active++;
808
+ if (state === "absent") absent++;
809
+ if (state === "suppressed") suppressed++;
810
+ if (["open", "review"].includes(kind) && state === "informational") {
811
+ unresolved++;
812
+ diagnostics.push(`Result ${runIndex}:${resultIndex} needs further analysis or review.`);
813
+ }
814
+ if (kind === "pass" || kind === "notApplicable") continue;
815
+ const rule = result.ruleIndex !== void 0 ? run.tool.driver.rules?.[result.ruleIndex] : run.tool.driver.rules?.find((rule2) => rule2.id === result.ruleId);
816
+ const template = result.message.id ? rule?.messageStrings?.[result.message.id] ?? run.tool.driver.globalMessageStrings?.[result.message.id] : void 0;
817
+ const rawMessage = result.message.text ?? result.message.markdown ?? template?.text ?? template?.markdown;
818
+ if (!rawMessage) diagnostics.push(`Message cannot be resolved for result ${runIndex}:${resultIndex}.`);
819
+ const rendered = (rawMessage ?? `Unresolved SARIF message ${result.message.id ?? ""}`).replace(/\{(\d+)\}/g, (match, n) => result.message.arguments?.[Number(n)] ?? match);
820
+ const locations = [];
821
+ for (const entry of [...result.locations ?? [], ...result.relatedLocations ?? []]) {
822
+ const ref = entry.physicalLocation?.artifactLocation;
823
+ const uri = ref ? resolve(ref) : null;
824
+ const file = uri ? evidencePath(uri, sourceRoot, true) : null;
825
+ if (file) locations.push({ file, line: entry.physicalLocation?.region?.startLine, column: entry.physicalLocation?.region?.startColumn });
826
+ else diagnostics.push(`Unresolved or out-of-checkout location for result ${runIndex}:${resultIndex}.`);
827
+ }
828
+ const severity = result.level ?? rule?.defaultConfiguration?.level ?? (kind === "fail" ? "warning" : "none");
829
+ findings.push({ id: `${runIndex}:${resultIndex}`, ruleId: result.ruleId ?? rule?.id, ...messagePreview(rendered), severity: severity === "none" ? "note" : severity, state, locations });
830
+ }
831
+ }
832
+ if (!report.runs.length) diagnostics.push("SARIF contains no analysis runs.");
833
+ if (executionFailed) diagnostics.push("The SARIF tool reported an unsuccessful analysis invocation.");
834
+ return {
835
+ outcome: executionFailed || !report.runs.length || report.runs.some((r) => !r.results) ? "unavailable" : active ? "failed" : "passed",
836
+ findings,
837
+ counts: { active, suppressed, absent, unresolved },
838
+ incomplete: diagnostics.length > 0,
839
+ diagnostics,
840
+ reportedCommits,
841
+ reportedCommands,
842
+ reportedTools,
843
+ executed: executed && !executionFailed
844
+ };
845
+ }
846
+
847
+ // src/review/evidence.ts
848
+ var checkSchema = z5.object({
849
+ id: z5.string().min(1).max(100),
850
+ kind: z5.enum(["tests", "static-analysis", "security", "complexity", "duplication"]),
851
+ tool: z5.string().min(1).max(200),
852
+ command: z5.string().min(1).max(2e3),
853
+ commit: z5.string().regex(/^(?:[a-fA-F0-9]{40}|[a-fA-F0-9]{64})$/).nullable().optional(),
854
+ workingTreeClean: z5.boolean().optional(),
855
+ source: z5.string().max(2e3).optional(),
856
+ sourceRoot: z5.string().min(1).max(4e3).optional(),
857
+ status: z5.enum(["completed", "skipped", "unavailable"]).default("completed"),
858
+ reason: z5.string().min(1).max(2e3).optional(),
859
+ exitCode: z5.number().int().nullable().optional(),
860
+ report: z5.object({ format: z5.enum(["vitest-json", "sarif"]), path: z5.string().min(1).max(4e3) }).optional()
861
+ });
862
+ function relativeArtifact(root, file) {
863
+ const relative = path10.isAbsolute(file) ? path10.relative(root, file) : file;
864
+ const normalized = normalizeRepoPath(relative);
865
+ if (!normalized) throw new Error(`Evidence artifact must be inside the repository: ${file}`);
866
+ return normalized;
867
+ }
868
+ function linkFinding(finding, changed, pairs, decisions, freshness) {
869
+ const located = [...new Set(finding.locations.map((l) => l.file))];
870
+ const paired = pairs.filter((pair) => located.includes(pair.test));
871
+ const relatedChangedFiles = located.filter((file) => changed.has(file)).map((file) => ({ file, relationship: "direct" }));
872
+ for (const pair of paired) {
873
+ if (changed.has(pair.source) && !relatedChangedFiles.some((f) => f.file === pair.source)) relatedChangedFiles.push({ file: pair.source, relationship: "paired-test", confidence: pair.confidence });
874
+ }
875
+ const allFiles = [.../* @__PURE__ */ new Set([...located, ...paired.map((pair) => pair.source)])];
876
+ const acceptedDecisions = decisions.filter((d) => d.status === "active" && decisionApproval(d) === "accepted").map((d) => ({ d, viaFiles: matchingPaths(d.files, allFiles) })).filter((match) => match.viaFiles.length).map(({ d, viaFiles }) => {
877
+ const state = freshness[d.id] ?? "unknown", provenance = decisionProvenance(d, state);
878
+ return { id: d.id, title: d.title, owner: provenance.owner, freshness: state, reviewRequired: provenance.reviewRequired, viaFiles };
879
+ });
880
+ return { ...finding, relatedChangedFiles, acceptedDecisions: acceptedDecisions.slice(0, 5), totalAcceptedDecisions: acceptedDecisions.length };
881
+ }
882
+ async function collectReviewEvidence(root, manifests, changedFiles, decisions, decisionFreshness = {}, reviewedHead) {
883
+ const [headHash, workingTree] = await Promise.all([reviewedHead ?? getCurrentGitHash(root), getWorkingTree(root)]);
884
+ const output = {
885
+ version: 1,
886
+ scope: "committed",
887
+ headHash,
888
+ status: "unavailable",
889
+ checks: [],
890
+ diagnostics: [],
891
+ workingTree,
892
+ summary: { passed: 0, failed: 0, skipped: 0, unavailable: 0, stale: 0, unknown: 0 },
893
+ 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."
894
+ };
895
+ const seen = /* @__PURE__ */ new Set(), changed = new Set(changedFiles);
896
+ let pairs;
897
+ if (manifests.length > 10) output.diagnostics.push("Only the first 10 evidence manifests were imported.");
898
+ for (const supplied of manifests.slice(0, 10)) {
899
+ let manifest, raw;
900
+ try {
901
+ manifest = relativeArtifact(root, supplied);
902
+ raw = z5.object({ version: z5.literal(1), checks: z5.array(z5.unknown()) }).parse(await readStoreJson(root, manifest));
903
+ } catch (error) {
904
+ output.diagnostics.push(`${supplied}: ${String(error)}`);
905
+ continue;
906
+ }
907
+ if (!raw.checks.length) output.diagnostics.push(`${manifest}: no checks declared.`);
908
+ for (const [index2, item] of raw.checks.entries()) {
909
+ if (output.checks.length >= 50) {
910
+ output.diagnostics.push("Only the first 50 checks were imported.");
911
+ break;
912
+ }
913
+ const input = checkSchema.safeParse(item);
914
+ if (!input.success) {
915
+ output.diagnostics.push(`${manifest} check ${index2}: ${input.error.message}`);
916
+ continue;
917
+ }
918
+ const check = input.data;
919
+ if (seen.has(check.id)) {
920
+ output.diagnostics.push(`Duplicate check id ${check.id}; give different runs distinct ids.`);
921
+ continue;
922
+ }
923
+ seen.add(check.id);
924
+ const result = {
925
+ id: check.id,
926
+ kind: check.kind,
927
+ tool: check.tool,
928
+ command: check.command,
929
+ commit: check.commit?.toLowerCase() ?? null,
930
+ workingTreeClean: check.workingTreeClean ?? null,
931
+ source: check.source ?? null,
932
+ manifest,
933
+ report: check.report ?? null,
934
+ outcome: "unavailable",
935
+ freshness: "unknown",
936
+ findings: [],
937
+ totalFindings: 0,
938
+ counts: {},
939
+ incomplete: false,
940
+ diagnostics: [],
941
+ truncated: false
942
+ };
943
+ output.checks.push(result);
944
+ 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.");
945
+ else if (result.commit && headHash !== "unknown") result.freshness = result.commit === headHash.toLowerCase() ? "current" : "stale";
946
+ else result.diagnostics.push("The tested commit or reviewed HEAD is unknown.");
947
+ if (check.status !== "completed") {
948
+ result.outcome = check.status;
949
+ result.diagnostics.push(check.reason ?? "No reason was recorded for this skipped or unavailable check.");
950
+ continue;
951
+ }
952
+ try {
953
+ if (!check.report) throw new Error("Completed check has no report artifact.");
954
+ if (check.kind === "tests" !== (check.report.format === "vitest-json")) throw new Error("Test checks require vitest-json; analysis checks require sarif.");
955
+ const file = relativeArtifact(root, check.report.path);
956
+ const report = await readStoreJson(root, file);
957
+ if (report === null) throw new Error(`Report artifact is missing: ${file}`);
958
+ const sourceRoot = check.sourceRoot ?? root;
959
+ if (!path10.posix.isAbsolute(sourceRoot) && !/^[A-Za-z]:[\\/]/.test(sourceRoot)) throw new Error("sourceRoot must identify the absolute checkout root on the check runner.");
960
+ const parsed = check.report.format === "vitest-json" ? parseVitest(report, sourceRoot) : parseSarif(report, sourceRoot);
961
+ result.outcome = parsed.outcome;
962
+ result.counts = parsed.counts;
963
+ result.incomplete = parsed.incomplete;
964
+ result.diagnostics.push(...parsed.diagnostics);
965
+ result.reportedCommands = parsed.reportedCommands;
966
+ result.reportedTools = parsed.reportedTools;
967
+ if (parsed.reportedCommits?.some((commit) => !result.commit || commit.toLowerCase() !== result.commit)) {
968
+ result.freshness = "unknown";
969
+ result.incomplete = true;
970
+ result.diagnostics.push("Report revision metadata conflicts with, or cannot be tied to, the manifest's tested commit.");
971
+ }
972
+ if (check.exitCode == null && !parsed.executed) {
973
+ result.incomplete = true;
974
+ result.diagnostics.push("Neither an exit code nor successful analysis invocation was recorded; completion cannot be confirmed.");
975
+ }
976
+ if (check.exitCode != null && check.exitCode !== 0 && result.outcome === "passed") {
977
+ result.outcome = "failed";
978
+ result.diagnostics.push(`Check command exited ${check.exitCode} despite a report with no active failures.`);
979
+ }
980
+ if (check.report.format === "vitest-json" && pairs === void 0) {
981
+ try {
982
+ pairs = (await buildTestMap(root)).paired;
983
+ } catch (error) {
984
+ pairs = [];
985
+ output.diagnostics.push(`Test pairing unavailable: ${String(error)}`);
986
+ }
987
+ }
988
+ result.totalFindings = parsed.findings.length;
989
+ const testPairs = check.kind === "tests" ? pairs ?? [] : [];
990
+ const relevant = /* @__PURE__ */ new Set([...changed, ...testPairs.filter((p) => changed.has(p.source)).map((p) => p.test)]);
991
+ const touchesChange = (f) => f.locations.some((l) => relevant.has(l.file));
992
+ parsed.findings.sort((a, b) => Number(b.state === "active") - Number(a.state === "active") || Number(touchesChange(b)) - Number(touchesChange(a)));
993
+ result.findings = parsed.findings.slice(0, MAX_FINDINGS).map((f) => linkFinding(f, changed, testPairs, decisions, decisionFreshness));
994
+ result.truncated = parsed.findings.length > MAX_FINDINGS || result.findings.some((f) => f.truncated || f.totalAcceptedDecisions > f.acceptedDecisions.length);
995
+ } catch (error) {
996
+ result.outcome = "unavailable";
997
+ result.incomplete = true;
998
+ result.diagnostics.push(String(error));
999
+ }
1000
+ }
1001
+ }
1002
+ if (!output.checks.length) output.diagnostics.push("No readable checks were imported.");
1003
+ for (const check of output.checks) {
1004
+ output.summary[check.outcome]++;
1005
+ if (check.freshness !== "current") output.summary[check.freshness]++;
1006
+ }
1007
+ if (output.checks.some((c) => c.outcome === "failed" && c.freshness === "current")) output.status = "failed";
1008
+ else if (!output.checks.length || output.checks.every((c) => c.outcome === "unavailable")) output.status = "unavailable";
1009
+ else if (output.diagnostics.length || output.checks.some((c) => c.outcome !== "passed" || c.freshness !== "current" || c.incomplete)) output.status = "incomplete";
1010
+ else output.status = "passed";
1011
+ return output;
1012
+ }
1013
+
185
1014
  // src/review/review.ts
186
1015
  var exec5 = promisify5(execFile5);
187
1016
  var MAX_ANALYZED_FILES = 50;
188
- async function resolveMergeBase(resolvedRoot, base) {
1017
+ async function resolveMergeBase(resolvedRoot, base, head) {
189
1018
  try {
190
- const { stdout } = await exec5("git", ["merge-base", base, "HEAD"], {
1019
+ const { stdout } = await exec5("git", ["merge-base", base, head], {
191
1020
  cwd: resolvedRoot
192
1021
  });
193
1022
  return stdout.trim() || null;
@@ -208,24 +1037,17 @@ async function defaultBase(resolvedRoot) {
208
1037
  return null;
209
1038
  }
210
1039
  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
- );
1040
+ return matchingPaths(record.files, changedFiles);
217
1041
  }
218
- async function computeReview(rootDir, base) {
219
- const resolvedRoot = path7.resolve(rootDir);
220
- const mergeBase = await resolveMergeBase(resolvedRoot, base);
1042
+ async function computeReview(rootDir, base, options = {}) {
1043
+ const resolvedRoot = path11.resolve(rootDir);
1044
+ const head = await getCurrentGitHash(resolvedRoot);
1045
+ if (head === "unknown") return null;
1046
+ const mergeBase = await resolveMergeBase(resolvedRoot, base, head);
221
1047
  if (!mergeBase) return null;
222
- const changes = await getChangesWithStatus(resolvedRoot, mergeBase);
1048
+ const changes = await getChangesWithStatus(resolvedRoot, mergeBase, head);
223
1049
  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();
1050
+ const changedFiles = touchedPaths(changes);
229
1051
  const report = {
230
1052
  version: 1,
231
1053
  root: resolvedRoot,
@@ -237,7 +1059,27 @@ async function computeReview(rootDir, base) {
237
1059
  historyAvailable: true,
238
1060
  truncated: false
239
1061
  };
240
- if (changedFiles.length === 0) return report;
1062
+ const store = await loadDecisionStore(resolvedRoot);
1063
+ report.diagnostics = store.diagnostics;
1064
+ const decisionDrift = await computeDecisionDrift(resolvedRoot, store.records);
1065
+ if (options.evidence !== void 0) {
1066
+ report.evidence = await collectReviewEvidence(resolvedRoot, options.evidence, changedFiles, store.records, decisionDrift.freshness, head);
1067
+ if (store.diagnostics.length) {
1068
+ report.evidence.diagnostics.push("Invalid decision records make knowledge associations incomplete; consult review diagnostics.");
1069
+ if (report.evidence.status === "passed") report.evidence.status = "incomplete";
1070
+ }
1071
+ }
1072
+ const finalize = async () => {
1073
+ if (report.evidence && await getCurrentGitHash(resolvedRoot) !== head) {
1074
+ report.evidence.diagnostics.push("HEAD changed during the review; rerun to obtain consistent change and knowledge associations.");
1075
+ for (const check of report.evidence.checks) check.freshness = "unknown";
1076
+ report.evidence.summary.stale = 0;
1077
+ report.evidence.summary.unknown = report.evidence.checks.length;
1078
+ if (report.evidence.status !== "unavailable") report.evidence.status = "incomplete";
1079
+ }
1080
+ return report;
1081
+ };
1082
+ if (changedFiles.length === 0) return finalize();
241
1083
  let analyzed = changedFiles;
242
1084
  if (changedFiles.length > MAX_ANALYZED_FILES) {
243
1085
  analyzed = changedFiles.slice(0, MAX_ANALYZED_FILES);
@@ -248,38 +1090,41 @@ async function computeReview(rootDir, base) {
248
1090
  analyzed,
249
1091
  async (relPath) => {
250
1092
  try {
251
- await fs5.access(path7.join(resolvedRoot, relPath));
1093
+ await fs5.access(path11.join(resolvedRoot, relPath));
252
1094
  return true;
253
1095
  } catch {
254
1096
  return false;
255
1097
  }
256
- }
1098
+ },
1099
+ changedFiles
257
1100
  );
258
1101
  if (partners === null) {
259
1102
  report.historyAvailable = false;
260
1103
  } else {
261
1104
  report.missingPartners = partners;
262
1105
  }
263
- const decisions = await loadDecisions(resolvedRoot);
1106
+ const decisions = store.records;
264
1107
  for (const record of decisions) {
265
1108
  if (record.status !== "active" || record.files.length === 0) continue;
266
1109
  const touched = anchorsTouched(record, changedFiles);
267
1110
  if (touched.length > 0) {
268
1111
  report.touchedDecisions.push({
1112
+ ...decisionProvenance(record, decisionDrift.freshness?.[record.id] ?? "unknown"),
269
1113
  id: record.id,
270
1114
  title: record.title,
271
1115
  body: record.body,
272
1116
  category: record.category,
273
1117
  anchors: record.files,
1118
+ freshness: decisionDrift.freshness?.[record.id] ?? "unknown",
274
1119
  touchedFiles: touched
275
1120
  });
276
1121
  }
277
1122
  }
278
- return report;
1123
+ return finalize();
279
1124
  }
280
1125
 
281
1126
  // src/review/cli.ts
282
- var USAGE = `Usage: mason-review [--dir <path>] [--base <ref>] [--json]
1127
+ var USAGE = `Usage: mason-review [--dir <path>] [--base <ref>] [--json] [--evidence <manifest>] [--require-evidence]
283
1128
 
284
1129
  Reviews the current branch's diff against what git history and the Mason
285
1130
  decision store know:
@@ -294,18 +1139,26 @@ Options:
294
1139
  --base <ref> Base ref to diff against via merge-base (default: origin/HEAD,
295
1140
  origin/main, origin/master, or main \u2013 first that resolves)
296
1141
  --json Full report as JSON (additive-only schema)
1142
+ --evidence <manifest> Import a CI evidence manifest (repeatable; paths inside --dir)
1143
+ --require-evidence Require current, complete passing evidence; needs --evidence
297
1144
  --help Show this help
298
1145
 
299
1146
  Exit codes:
300
1147
  0 no missing co-change partners (touched decisions alone do not fail)
301
1148
  1 missing co-change partners found
302
- 2 error (base unresolvable, not a git repository, bad arguments)`;
1149
+ 2 error (base unresolvable, not a git repository, bad arguments)
1150
+
1151
+ Evidence is advisory unless --require-evidence is set. With that flag, current
1152
+ failed checks also exit 1; missing, skipped, stale, or incomplete evidence exits 2.
1153
+ No check command from an imported report is executed.`;
303
1154
  function parseArgs(argv) {
304
1155
  const parsed = {
305
1156
  dir: process.cwd(),
306
1157
  base: void 0,
307
1158
  json: false,
308
- help: false
1159
+ help: false,
1160
+ evidence: [],
1161
+ requireEvidence: false
309
1162
  };
310
1163
  for (let i = 0; i < argv.length; i++) {
311
1164
  const arg = argv[i];
@@ -321,19 +1174,52 @@ function parseArgs(argv) {
321
1174
  const value = argv[++i];
322
1175
  if (!value) throw new Error("--base requires a ref argument");
323
1176
  parsed.base = value;
1177
+ } else if (arg === "--evidence") {
1178
+ const value = argv[++i];
1179
+ if (!value || value.startsWith("--")) throw new Error("--evidence requires a manifest path");
1180
+ parsed.evidence.push(value);
1181
+ } else if (arg === "--require-evidence") {
1182
+ parsed.requireEvidence = true;
324
1183
  } else if (!arg.startsWith("-") && parsed.dir === process.cwd()) {
325
1184
  parsed.dir = arg;
326
1185
  } else {
327
1186
  throw new Error(`Unknown argument: ${arg}`);
328
1187
  }
329
1188
  }
1189
+ if (parsed.requireEvidence && !parsed.evidence.length) throw new Error("--require-evidence requires at least one --evidence manifest");
330
1190
  return parsed;
331
1191
  }
1192
+ var display = (value, limit = 500) => stripVTControlCharacters(value).replace(/[\r\n\t]/g, " ").slice(0, limit);
1193
+ function evidenceSummary(report) {
1194
+ const evidence = report.evidence;
1195
+ if (!evidence) return [];
1196
+ const lines = [`Imported CI evidence: ${evidence.status} (commit ${evidence.headHash.slice(0, 12)}; committed scope).`];
1197
+ for (const diagnostic of evidence.diagnostics) lines.push(` Evidence unavailable: ${display(diagnostic)}`);
1198
+ for (const check of evidence.checks) {
1199
+ lines.push(` [${check.kind}] ${display(check.id)}: ${check.outcome}, ${check.freshness} (${display(check.tool)})`);
1200
+ lines.push(` command: ${display(check.command)}; tested commit: ${check.commit ?? "unknown"}`);
1201
+ lines.push(` source: ${display(check.source ?? check.manifest)}; report: ${display(check.report?.path ?? "none")}`);
1202
+ if (Object.keys(check.counts).length) lines.push(` counts: ${JSON.stringify(check.counts)}`);
1203
+ for (const diagnostic of check.diagnostics.slice(0, 5)) lines.push(` note: ${display(diagnostic)}`);
1204
+ for (const finding of check.findings.slice(0, 10)) {
1205
+ const locations = finding.locations.map((l) => `${l.file}${l.line ? `:${l.line}` : ""}`).join(", ") || "no resolved file location";
1206
+ lines.push(` [${finding.state}/${finding.severity}] ${display(finding.ruleId ?? finding.id)} ${display(locations)}: ${display(finding.message)}`);
1207
+ if (finding.relatedChangedFiles.length) lines.push(` changed files: ${finding.relatedChangedFiles.map((f) => `${display(f.file)} (${f.relationship}${f.confidence ? `, ${f.confidence}` : ""})`).join(", ")}`);
1208
+ 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" : ""}`);
1209
+ }
1210
+ 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.`);
1211
+ }
1212
+ if (!evidence.workingTree.available) lines.push(" Working-tree state unavailable; imported evidence only describes the recorded commit.");
1213
+ else if (evidence.workingTree.changedFiles.length) lines.push(` ${evidence.workingTree.changedFiles.length} uncommitted paths are outside this evidence's scope.`);
1214
+ lines.push(" Related decisions are associations, not proven violations. Passing imported checks do not establish complete coverage.");
1215
+ return lines;
1216
+ }
332
1217
  function formatReviewSummary(report) {
333
1218
  const lines = [];
334
1219
  lines.push(
335
1220
  `Diff vs ${report.base} (merge-base ${report.mergeBase.slice(0, 7)}): ${report.changedFiles.length} changed file${report.changedFiles.length === 1 ? "" : "s"}.`
336
1221
  );
1222
+ for (const diagnostic of report.diagnostics ?? []) lines.push(`Invalid decision record ${diagnostic.path}: ${diagnostic.message}`);
337
1223
  if (report.truncated) {
338
1224
  lines.push(
339
1225
  `Large diff \u2013 co-change analysis limited to the first ${50} files.`
@@ -353,16 +1239,19 @@ function formatReviewSummary(report) {
353
1239
  }
354
1240
  }
355
1241
  if (report.touchedDecisions.length > 0) {
356
- lines.push("Recorded decisions touched by this diff (constraints \u2013 verify the diff respects them):");
1242
+ lines.push("Recorded decisions touched by this diff (check approval and freshness before relying on them):");
357
1243
  for (const d of report.touchedDecisions) {
358
1244
  lines.push(
359
- ` [decision] ${d.title} (${d.category}; via ${d.touchedFiles.join(", ")})`
1245
+ ` [decision] ${d.title} (${d.category}; ${d.approval ?? "unreviewed"}; owner ${d.owner ?? "unknown"}; freshness ${d.freshness ?? "unknown"}; via ${d.touchedFiles.join(", ")})`
360
1246
  );
1247
+ if (d.sources?.length) lines.push(` sources: ${d.sources.map((s) => s.reference).join(", ")}`);
1248
+ if (d.lastReview) lines.push(` last review: ${d.lastReview.reviewer} at ${d.lastReview.gitHash.slice(0, 7)}`);
361
1249
  }
362
1250
  }
363
1251
  lines.push(
364
1252
  report.missingPartners.length === 0 ? "No missing co-change partners." : `${report.missingPartners.length} possible omission${report.missingPartners.length === 1 ? "" : "s"}.`
365
1253
  );
1254
+ lines.push(...evidenceSummary(report));
366
1255
  return lines.join("\n");
367
1256
  }
368
1257
  async function runReviewCli(argv, io = {
@@ -383,7 +1272,7 @@ async function runReviewCli(argv, io = {
383
1272
  io.out(USAGE);
384
1273
  return 0;
385
1274
  }
386
- const rootDir = path8.resolve(args.dir);
1275
+ const rootDir = path12.resolve(args.dir);
387
1276
  const base = args.base ?? await defaultBase(rootDir);
388
1277
  if (!base) {
389
1278
  io.err(
@@ -391,7 +1280,7 @@ async function runReviewCli(argv, io = {
391
1280
  );
392
1281
  return 2;
393
1282
  }
394
- const report = await computeReview(rootDir, base);
1283
+ const report = await computeReview(rootDir, base, args.evidence.length ? { evidence: args.evidence } : {});
395
1284
  if (!report) {
396
1285
  io.err(
397
1286
  `Could not resolve a merge base between ${base} and HEAD in ${rootDir} \u2013 not a git repository, unknown ref, or unrelated histories.`
@@ -402,7 +1291,9 @@ async function runReviewCli(argv, io = {
402
1291
  io.out(JSON.stringify(report, null, 2));
403
1292
  } else {
404
1293
  io.out(formatReviewSummary(report));
1294
+ if (args.evidence.length && !args.requireEvidence) io.out("Imported evidence is advisory for exit codes; use --require-evidence to require current passing checks.");
405
1295
  }
1296
+ if (args.requireEvidence && report.evidence?.status !== "passed") return report.evidence?.status === "failed" ? 1 : 2;
406
1297
  return report.missingPartners.length > 0 ? 1 : 0;
407
1298
  }
408
1299