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.
- package/CHANGELOG.md +25 -0
- package/README.md +167 -37
- 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 +988 -97
- package/dist/mason-review.js.map +1 -1
- package/package.json +7 -2
package/dist/mason-review.js
CHANGED
|
@@ -1,117 +1,559 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/review/cli.ts
|
|
4
|
-
import
|
|
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
|
|
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
|
|
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
|
|
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/
|
|
24
|
+
// src/utils/files.ts
|
|
26
25
|
import fs from "fs/promises";
|
|
27
|
-
import
|
|
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
|
|
35
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
499
|
+
import path8 from "path";
|
|
81
500
|
import { createHash } from "crypto";
|
|
82
501
|
|
|
83
502
|
// src/context/lexical.ts
|
|
84
|
-
import
|
|
503
|
+
import path7 from "path";
|
|
85
504
|
|
|
86
505
|
// src/decisions/decisions.ts
|
|
87
|
-
function
|
|
88
|
-
|
|
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(
|
|
94
|
-
} catch {
|
|
95
|
-
|
|
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
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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
|
|
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(
|
|
589
|
+
set.add(index2);
|
|
146
590
|
}
|
|
147
|
-
|
|
591
|
+
index2++;
|
|
148
592
|
}
|
|
149
|
-
return { commitsByFile, totalCommits:
|
|
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(
|
|
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,
|
|
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
|
|
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 =
|
|
220
|
-
const
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
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 (
|
|
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 =
|
|
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
|
|