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