mason-context 0.8.1 → 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.
- package/CHANGELOG.md +25 -0
- package/README.md +178 -35
- package/dist/mason-audit.js +368 -149
- package/dist/mason-audit.js.map +1 -1
- package/dist/mason-drift.js +479 -209
- package/dist/mason-drift.js.map +1 -1
- package/dist/mason-hook.js +337 -123
- package/dist/mason-hook.js.map +1 -1
- package/dist/mason-mcp.js +3459 -1451
- package/dist/mason-mcp.js.map +1 -1
- package/dist/mason-review.js +1309 -0
- package/dist/mason-review.js.map +1 -0
- package/package.json +9 -3
|
@@ -0,0 +1,1309 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/review/cli.ts
|
|
4
|
+
import path12 from "path";
|
|
5
|
+
import { stripVTControlCharacters } from "util";
|
|
6
|
+
|
|
7
|
+
// src/review/review.ts
|
|
8
|
+
import fs5 from "fs/promises";
|
|
9
|
+
import path11 from "path";
|
|
10
|
+
import { execFile as execFile5 } from "child_process";
|
|
11
|
+
import { promisify as promisify5 } from "util";
|
|
12
|
+
|
|
13
|
+
// src/drift/drift.ts
|
|
14
|
+
import fs3 from "fs/promises";
|
|
15
|
+
import path6 from "path";
|
|
16
|
+
import { execFile as execFile3 } from "child_process";
|
|
17
|
+
import { promisify as promisify3 } from "util";
|
|
18
|
+
|
|
19
|
+
// src/snapshot/snapshot.ts
|
|
20
|
+
import path5 from "path";
|
|
21
|
+
import { execFile as execFile2 } from "child_process";
|
|
22
|
+
import { promisify as promisify2 } from "util";
|
|
23
|
+
|
|
24
|
+
// src/utils/files.ts
|
|
25
|
+
import fs from "fs/promises";
|
|
26
|
+
import { constants } from "fs";
|
|
27
|
+
import path2 from "path";
|
|
28
|
+
import { execFile } from "child_process";
|
|
29
|
+
import { promisify } from "util";
|
|
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
|
|
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";
|
|
228
|
+
|
|
229
|
+
// src/test-map.ts
|
|
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
|
+
}
|
|
298
|
+
|
|
299
|
+
// src/snapshot/snapshot.ts
|
|
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
|
+
}
|
|
335
|
+
|
|
336
|
+
// src/drift/drift.ts
|
|
337
|
+
var exec3 = promisify3(execFile3);
|
|
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;
|
|
357
|
+
try {
|
|
358
|
+
const { stdout } = await exec3("git", ["diff", "--name-status", "-z", "-M", fromHash, toHash, "--"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 });
|
|
359
|
+
return parseChanges(stdout);
|
|
360
|
+
} catch {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
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
|
+
}
|
|
496
|
+
|
|
497
|
+
// src/decisions/decisions.ts
|
|
498
|
+
import fs4 from "fs/promises";
|
|
499
|
+
import path8 from "path";
|
|
500
|
+
import { createHash } from "crypto";
|
|
501
|
+
|
|
502
|
+
// src/context/lexical.ts
|
|
503
|
+
import path7 from "path";
|
|
504
|
+
|
|
505
|
+
// src/decisions/decisions.ts
|
|
506
|
+
async function loadDecisionStore(rootDir) {
|
|
507
|
+
const records = [];
|
|
508
|
+
const diagnostics = [];
|
|
509
|
+
let entries;
|
|
510
|
+
try {
|
|
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 };
|
|
515
|
+
}
|
|
516
|
+
for (const entry of entries.sort()) {
|
|
517
|
+
if (!entry.endsWith(".json")) continue;
|
|
518
|
+
const relative = `.mason/decisions/${entry}`;
|
|
519
|
+
try {
|
|
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";
|
|
542
|
+
continue;
|
|
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";
|
|
555
|
+
}
|
|
556
|
+
return report;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// src/review/cochange.ts
|
|
560
|
+
import { execFile as execFile4 } from "child_process";
|
|
561
|
+
import { promisify as promisify4 } from "util";
|
|
562
|
+
var exec4 = promisify4(execFile4);
|
|
563
|
+
var HISTORY_COMMITS = 1500;
|
|
564
|
+
var MIN_FILE_COMMITS = 5;
|
|
565
|
+
var MIN_SHARED_COMMITS = 4;
|
|
566
|
+
var MIN_COCHANGE_RATE = 0.6;
|
|
567
|
+
var MAX_COMMIT_FILES = 30;
|
|
568
|
+
async function buildMatrix(resolvedRoot) {
|
|
569
|
+
try {
|
|
570
|
+
const { stdout: shallow } = await exec4("git", ["rev-parse", "--is-shallow-repository"], { cwd: resolvedRoot });
|
|
571
|
+
if (shallow.trim() === "true") return null;
|
|
572
|
+
const { stdout } = await exec4(
|
|
573
|
+
"git",
|
|
574
|
+
["log", `-n${HISTORY_COMMITS}`, "--format=%x01", "--name-only", "-M"],
|
|
575
|
+
{ cwd: resolvedRoot, maxBuffer: 100 * 1024 * 1024 }
|
|
576
|
+
);
|
|
577
|
+
const commitsByFile = /* @__PURE__ */ new Map();
|
|
578
|
+
const blocks = stdout.split("");
|
|
579
|
+
let index2 = 0;
|
|
580
|
+
for (const block of blocks) {
|
|
581
|
+
const files = block.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith(".mason/"));
|
|
582
|
+
if (files.length === 0 || files.length > MAX_COMMIT_FILES) continue;
|
|
583
|
+
for (const file of files) {
|
|
584
|
+
let set = commitsByFile.get(file);
|
|
585
|
+
if (!set) {
|
|
586
|
+
set = /* @__PURE__ */ new Set();
|
|
587
|
+
commitsByFile.set(file, set);
|
|
588
|
+
}
|
|
589
|
+
set.add(index2);
|
|
590
|
+
}
|
|
591
|
+
index2++;
|
|
592
|
+
}
|
|
593
|
+
return { commitsByFile, totalCommits: index2 };
|
|
594
|
+
} catch {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
async function findMissingPartners(resolvedRoot, changedFiles, existsOnDisk, allChangedFiles = changedFiles) {
|
|
599
|
+
const matrix = await buildMatrix(resolvedRoot);
|
|
600
|
+
if (!matrix) return null;
|
|
601
|
+
const changedSet = new Set(allChangedFiles);
|
|
602
|
+
const findings = [];
|
|
603
|
+
for (const changedFile of changedFiles) {
|
|
604
|
+
const fileCommits = matrix.commitsByFile.get(changedFile);
|
|
605
|
+
if (!fileCommits || fileCommits.size < MIN_FILE_COMMITS) continue;
|
|
606
|
+
for (const [partner, partnerCommits] of matrix.commitsByFile) {
|
|
607
|
+
if (partner === changedFile || changedSet.has(partner)) continue;
|
|
608
|
+
let shared = 0;
|
|
609
|
+
for (const c of fileCommits) {
|
|
610
|
+
if (partnerCommits.has(c)) shared++;
|
|
611
|
+
}
|
|
612
|
+
if (shared < MIN_SHARED_COMMITS) continue;
|
|
613
|
+
const rate = shared / fileCommits.size;
|
|
614
|
+
if (rate < MIN_COCHANGE_RATE) continue;
|
|
615
|
+
if (!await existsOnDisk(partner)) continue;
|
|
616
|
+
findings.push({
|
|
617
|
+
changedFile,
|
|
618
|
+
missingPartner: partner,
|
|
619
|
+
sharedCommits: shared,
|
|
620
|
+
fileCommits: fileCommits.size,
|
|
621
|
+
rate: Math.round(rate * 100) / 100
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
findings.sort((a, b) => b.rate - a.rate || b.sharedCommits - a.sharedCommits);
|
|
626
|
+
return findings;
|
|
627
|
+
}
|
|
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
|
+
|
|
1014
|
+
// src/review/review.ts
|
|
1015
|
+
var exec5 = promisify5(execFile5);
|
|
1016
|
+
var MAX_ANALYZED_FILES = 50;
|
|
1017
|
+
async function resolveMergeBase(resolvedRoot, base, head) {
|
|
1018
|
+
try {
|
|
1019
|
+
const { stdout } = await exec5("git", ["merge-base", base, head], {
|
|
1020
|
+
cwd: resolvedRoot
|
|
1021
|
+
});
|
|
1022
|
+
return stdout.trim() || null;
|
|
1023
|
+
} catch {
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
async function defaultBase(resolvedRoot) {
|
|
1028
|
+
for (const ref of ["origin/HEAD", "origin/main", "origin/master", "main"]) {
|
|
1029
|
+
try {
|
|
1030
|
+
await exec5("git", ["rev-parse", "--verify", "--quiet", ref], {
|
|
1031
|
+
cwd: resolvedRoot
|
|
1032
|
+
});
|
|
1033
|
+
return ref;
|
|
1034
|
+
} catch {
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
return null;
|
|
1038
|
+
}
|
|
1039
|
+
function anchorsTouched(record, changedFiles) {
|
|
1040
|
+
return matchingPaths(record.files, changedFiles);
|
|
1041
|
+
}
|
|
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);
|
|
1047
|
+
if (!mergeBase) return null;
|
|
1048
|
+
const changes = await getChangesWithStatus(resolvedRoot, mergeBase, head);
|
|
1049
|
+
if (changes === null) return null;
|
|
1050
|
+
const changedFiles = touchedPaths(changes);
|
|
1051
|
+
const report = {
|
|
1052
|
+
version: 1,
|
|
1053
|
+
root: resolvedRoot,
|
|
1054
|
+
base,
|
|
1055
|
+
mergeBase,
|
|
1056
|
+
changedFiles,
|
|
1057
|
+
missingPartners: [],
|
|
1058
|
+
touchedDecisions: [],
|
|
1059
|
+
historyAvailable: true,
|
|
1060
|
+
truncated: false
|
|
1061
|
+
};
|
|
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();
|
|
1083
|
+
let analyzed = changedFiles;
|
|
1084
|
+
if (changedFiles.length > MAX_ANALYZED_FILES) {
|
|
1085
|
+
analyzed = changedFiles.slice(0, MAX_ANALYZED_FILES);
|
|
1086
|
+
report.truncated = true;
|
|
1087
|
+
}
|
|
1088
|
+
const partners = await findMissingPartners(
|
|
1089
|
+
resolvedRoot,
|
|
1090
|
+
analyzed,
|
|
1091
|
+
async (relPath) => {
|
|
1092
|
+
try {
|
|
1093
|
+
await fs5.access(path11.join(resolvedRoot, relPath));
|
|
1094
|
+
return true;
|
|
1095
|
+
} catch {
|
|
1096
|
+
return false;
|
|
1097
|
+
}
|
|
1098
|
+
},
|
|
1099
|
+
changedFiles
|
|
1100
|
+
);
|
|
1101
|
+
if (partners === null) {
|
|
1102
|
+
report.historyAvailable = false;
|
|
1103
|
+
} else {
|
|
1104
|
+
report.missingPartners = partners;
|
|
1105
|
+
}
|
|
1106
|
+
const decisions = store.records;
|
|
1107
|
+
for (const record of decisions) {
|
|
1108
|
+
if (record.status !== "active" || record.files.length === 0) continue;
|
|
1109
|
+
const touched = anchorsTouched(record, changedFiles);
|
|
1110
|
+
if (touched.length > 0) {
|
|
1111
|
+
report.touchedDecisions.push({
|
|
1112
|
+
...decisionProvenance(record, decisionDrift.freshness?.[record.id] ?? "unknown"),
|
|
1113
|
+
id: record.id,
|
|
1114
|
+
title: record.title,
|
|
1115
|
+
body: record.body,
|
|
1116
|
+
category: record.category,
|
|
1117
|
+
anchors: record.files,
|
|
1118
|
+
freshness: decisionDrift.freshness?.[record.id] ?? "unknown",
|
|
1119
|
+
touchedFiles: touched
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
return finalize();
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// src/review/cli.ts
|
|
1127
|
+
var USAGE = `Usage: mason-review [--dir <path>] [--base <ref>] [--json] [--evidence <manifest>] [--require-evidence]
|
|
1128
|
+
|
|
1129
|
+
Reviews the current branch's diff against what git history and the Mason
|
|
1130
|
+
decision store know:
|
|
1131
|
+
- co-change partners the diff forgot: files that historically change
|
|
1132
|
+
together with a changed file (>=60% of its commits, >=4 shared) but are
|
|
1133
|
+
absent from this diff
|
|
1134
|
+
- recorded decisions whose anchor files the diff touches (informational)
|
|
1135
|
+
Deterministic: no LLM call, no network \u2013 safe for CI.
|
|
1136
|
+
|
|
1137
|
+
Options:
|
|
1138
|
+
--dir <path> Project root (default: current directory)
|
|
1139
|
+
--base <ref> Base ref to diff against via merge-base (default: origin/HEAD,
|
|
1140
|
+
origin/main, origin/master, or main \u2013 first that resolves)
|
|
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
|
|
1144
|
+
--help Show this help
|
|
1145
|
+
|
|
1146
|
+
Exit codes:
|
|
1147
|
+
0 no missing co-change partners (touched decisions alone do not fail)
|
|
1148
|
+
1 missing co-change partners found
|
|
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.`;
|
|
1154
|
+
function parseArgs(argv) {
|
|
1155
|
+
const parsed = {
|
|
1156
|
+
dir: process.cwd(),
|
|
1157
|
+
base: void 0,
|
|
1158
|
+
json: false,
|
|
1159
|
+
help: false,
|
|
1160
|
+
evidence: [],
|
|
1161
|
+
requireEvidence: false
|
|
1162
|
+
};
|
|
1163
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1164
|
+
const arg = argv[i];
|
|
1165
|
+
if (arg === "--json") {
|
|
1166
|
+
parsed.json = true;
|
|
1167
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
1168
|
+
parsed.help = true;
|
|
1169
|
+
} else if (arg === "--dir") {
|
|
1170
|
+
const value = argv[++i];
|
|
1171
|
+
if (!value) throw new Error("--dir requires a path argument");
|
|
1172
|
+
parsed.dir = value;
|
|
1173
|
+
} else if (arg === "--base") {
|
|
1174
|
+
const value = argv[++i];
|
|
1175
|
+
if (!value) throw new Error("--base requires a ref argument");
|
|
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;
|
|
1183
|
+
} else if (!arg.startsWith("-") && parsed.dir === process.cwd()) {
|
|
1184
|
+
parsed.dir = arg;
|
|
1185
|
+
} else {
|
|
1186
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
if (parsed.requireEvidence && !parsed.evidence.length) throw new Error("--require-evidence requires at least one --evidence manifest");
|
|
1190
|
+
return parsed;
|
|
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
|
+
}
|
|
1217
|
+
function formatReviewSummary(report) {
|
|
1218
|
+
const lines = [];
|
|
1219
|
+
lines.push(
|
|
1220
|
+
`Diff vs ${report.base} (merge-base ${report.mergeBase.slice(0, 7)}): ${report.changedFiles.length} changed file${report.changedFiles.length === 1 ? "" : "s"}.`
|
|
1221
|
+
);
|
|
1222
|
+
for (const diagnostic of report.diagnostics ?? []) lines.push(`Invalid decision record ${diagnostic.path}: ${diagnostic.message}`);
|
|
1223
|
+
if (report.truncated) {
|
|
1224
|
+
lines.push(
|
|
1225
|
+
`Large diff \u2013 co-change analysis limited to the first ${50} files.`
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
if (!report.historyAvailable) {
|
|
1229
|
+
lines.push(
|
|
1230
|
+
"Git history unavailable (shallow clone?) \u2013 co-change analysis skipped."
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
if (report.missingPartners.length > 0) {
|
|
1234
|
+
lines.push("Possible forgotten co-change partners:");
|
|
1235
|
+
for (const f of report.missingPartners) {
|
|
1236
|
+
lines.push(
|
|
1237
|
+
` [missing-partner] ${f.changedFile} changed without ${f.missingPartner} \u2013 they changed together in ${Math.round(f.rate * 100)}% of ${f.changedFile}'s last ${f.fileCommits} commits (${f.sharedCommits} shared)`
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
if (report.touchedDecisions.length > 0) {
|
|
1242
|
+
lines.push("Recorded decisions touched by this diff (check approval and freshness before relying on them):");
|
|
1243
|
+
for (const d of report.touchedDecisions) {
|
|
1244
|
+
lines.push(
|
|
1245
|
+
` [decision] ${d.title} (${d.category}; ${d.approval ?? "unreviewed"}; owner ${d.owner ?? "unknown"}; freshness ${d.freshness ?? "unknown"}; via ${d.touchedFiles.join(", ")})`
|
|
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)}`);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
lines.push(
|
|
1252
|
+
report.missingPartners.length === 0 ? "No missing co-change partners." : `${report.missingPartners.length} possible omission${report.missingPartners.length === 1 ? "" : "s"}.`
|
|
1253
|
+
);
|
|
1254
|
+
lines.push(...evidenceSummary(report));
|
|
1255
|
+
return lines.join("\n");
|
|
1256
|
+
}
|
|
1257
|
+
async function runReviewCli(argv, io = {
|
|
1258
|
+
out: (line) => process.stdout.write(`${line}
|
|
1259
|
+
`),
|
|
1260
|
+
err: (line) => process.stderr.write(`${line}
|
|
1261
|
+
`)
|
|
1262
|
+
}) {
|
|
1263
|
+
let args;
|
|
1264
|
+
try {
|
|
1265
|
+
args = parseArgs(argv);
|
|
1266
|
+
} catch (error) {
|
|
1267
|
+
io.err(error instanceof Error ? error.message : String(error));
|
|
1268
|
+
io.err(USAGE);
|
|
1269
|
+
return 2;
|
|
1270
|
+
}
|
|
1271
|
+
if (args.help) {
|
|
1272
|
+
io.out(USAGE);
|
|
1273
|
+
return 0;
|
|
1274
|
+
}
|
|
1275
|
+
const rootDir = path12.resolve(args.dir);
|
|
1276
|
+
const base = args.base ?? await defaultBase(rootDir);
|
|
1277
|
+
if (!base) {
|
|
1278
|
+
io.err(
|
|
1279
|
+
`Could not find a base ref in ${rootDir} (tried origin/HEAD, origin/main, origin/master, main). Pass one with --base.`
|
|
1280
|
+
);
|
|
1281
|
+
return 2;
|
|
1282
|
+
}
|
|
1283
|
+
const report = await computeReview(rootDir, base, args.evidence.length ? { evidence: args.evidence } : {});
|
|
1284
|
+
if (!report) {
|
|
1285
|
+
io.err(
|
|
1286
|
+
`Could not resolve a merge base between ${base} and HEAD in ${rootDir} \u2013 not a git repository, unknown ref, or unrelated histories.`
|
|
1287
|
+
);
|
|
1288
|
+
return 2;
|
|
1289
|
+
}
|
|
1290
|
+
if (args.json) {
|
|
1291
|
+
io.out(JSON.stringify(report, null, 2));
|
|
1292
|
+
} else {
|
|
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.");
|
|
1295
|
+
}
|
|
1296
|
+
if (args.requireEvidence && report.evidence?.status !== "passed") return report.evidence?.status === "failed" ? 1 : 2;
|
|
1297
|
+
return report.missingPartners.length > 0 ? 1 : 0;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// bin/mason-review.ts
|
|
1301
|
+
runReviewCli(process.argv.slice(2)).then(
|
|
1302
|
+
(code) => process.exit(code),
|
|
1303
|
+
(err) => {
|
|
1304
|
+
process.stderr.write(`mason-review error: ${err}
|
|
1305
|
+
`);
|
|
1306
|
+
process.exit(2);
|
|
1307
|
+
}
|
|
1308
|
+
);
|
|
1309
|
+
//# sourceMappingURL=mason-review.js.map
|