mason-context 0.20.1 → 0.21.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 +12 -1
- package/README.md +14 -0
- package/dist/mason-audit.js +1690 -649
- package/dist/mason-audit.js.map +1 -1
- package/dist/mason-drift.js +630 -175
- package/dist/mason-drift.js.map +1 -1
- package/dist/mason-hook.js +828 -194
- package/dist/mason-hook.js.map +1 -1
- package/dist/mason-mcp.js +6420 -3828
- package/dist/mason-mcp.js.map +1 -1
- package/dist/mason-review.js +990 -312
- package/dist/mason-review.js.map +1 -1
- package/dist/mason-runtime.js +296 -148
- package/dist/mason-runtime.js.map +1 -1
- package/dist/mason.js +4526 -2210
- package/dist/mason.js.map +1 -1
- package/package.json +11 -1
package/dist/mason-review.js
CHANGED
|
@@ -31,12 +31,12 @@ var init_types = __esm({
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
// src/review/cli.ts
|
|
34
|
-
import
|
|
34
|
+
import path13 from "path";
|
|
35
35
|
import { stripVTControlCharacters } from "util";
|
|
36
36
|
|
|
37
37
|
// src/review/review.ts
|
|
38
|
-
import
|
|
39
|
-
import
|
|
38
|
+
import fs7 from "fs/promises";
|
|
39
|
+
import path12 from "path";
|
|
40
40
|
import { execFile as execFile3 } from "child_process";
|
|
41
41
|
import { promisify as promisify3 } from "util";
|
|
42
42
|
|
|
@@ -62,7 +62,19 @@ async function profilePhase(label, run) {
|
|
|
62
62
|
|
|
63
63
|
// src/utils/git-read.ts
|
|
64
64
|
var execute = promisify(execFile);
|
|
65
|
-
var commands = /* @__PURE__ */ new Set([
|
|
65
|
+
var commands = /* @__PURE__ */ new Set([
|
|
66
|
+
"rev-parse",
|
|
67
|
+
"symbolic-ref",
|
|
68
|
+
"for-each-ref",
|
|
69
|
+
"ls-files",
|
|
70
|
+
"check-ignore",
|
|
71
|
+
"status",
|
|
72
|
+
"log",
|
|
73
|
+
"diff",
|
|
74
|
+
"ls-tree",
|
|
75
|
+
"show",
|
|
76
|
+
"merge-base"
|
|
77
|
+
]);
|
|
66
78
|
function execGit(args, options) {
|
|
67
79
|
const command = args.find((arg) => !arg.startsWith("-")) ?? "other";
|
|
68
80
|
return profilePhase("git." + (commands.has(command) ? command : "other"), async () => {
|
|
@@ -84,6 +96,14 @@ import path6 from "path";
|
|
|
84
96
|
// src/snapshot/snapshot.ts
|
|
85
97
|
import path5 from "path";
|
|
86
98
|
|
|
99
|
+
// src/utils/validation.ts
|
|
100
|
+
function isRecord(value) {
|
|
101
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
102
|
+
}
|
|
103
|
+
function isStringArray(value) {
|
|
104
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
105
|
+
}
|
|
106
|
+
|
|
87
107
|
// src/utils/files.ts
|
|
88
108
|
import fs from "fs/promises";
|
|
89
109
|
import { constants } from "fs";
|
|
@@ -94,7 +114,8 @@ import fg from "fast-glob";
|
|
|
94
114
|
import path from "path";
|
|
95
115
|
function normalizeRepoPath(value) {
|
|
96
116
|
const slash = value.replace(/\\/g, "/");
|
|
97
|
-
if (!slash || slash.includes("\0") || path.posix.isAbsolute(slash) || /^[A-Za-z]:/.test(slash))
|
|
117
|
+
if (!slash || slash.includes("\0") || path.posix.isAbsolute(slash) || /^[A-Za-z]:/.test(slash))
|
|
118
|
+
return null;
|
|
98
119
|
if (slash.split("/").includes("..")) return null;
|
|
99
120
|
const normalized = path.posix.normalize(slash).replace(/\/$/, "");
|
|
100
121
|
return normalized === "." ? null : normalized;
|
|
@@ -109,11 +130,69 @@ function anchorMatches(anchor, file) {
|
|
|
109
130
|
return a !== null && f !== null && (a === f || f.startsWith(`${a}/`));
|
|
110
131
|
}
|
|
111
132
|
function matchingPaths(anchors, files) {
|
|
112
|
-
return [...new Set(files)].filter(
|
|
133
|
+
return [...new Set(files)].filter(
|
|
134
|
+
(file) => anchors.some((anchor) => anchorMatches(anchor, file))
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
function indexMatchingPaths(scopes, files) {
|
|
138
|
+
const node = () => ({ children: /* @__PURE__ */ new Map(), scopes: /* @__PURE__ */ new Set() });
|
|
139
|
+
const root = node();
|
|
140
|
+
const matches = scopes.map(() => []);
|
|
141
|
+
scopes.forEach((anchors, index2) => {
|
|
142
|
+
for (const anchor of anchors) {
|
|
143
|
+
const normalized = normalizeRepoPath(anchor);
|
|
144
|
+
if (!normalized) continue;
|
|
145
|
+
let current = root;
|
|
146
|
+
for (const part of normalized.split("/")) {
|
|
147
|
+
if (!current.children.has(part)) current.children.set(part, node());
|
|
148
|
+
current = current.children.get(part);
|
|
149
|
+
}
|
|
150
|
+
current.scopes.add(index2);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
for (const file of new Set(files)) {
|
|
154
|
+
const normalized = normalizeRepoPath(file);
|
|
155
|
+
if (!normalized) continue;
|
|
156
|
+
const selected = /* @__PURE__ */ new Set();
|
|
157
|
+
let current = root;
|
|
158
|
+
for (const part of normalized.split("/")) {
|
|
159
|
+
current = current.children.get(part);
|
|
160
|
+
if (!current) break;
|
|
161
|
+
for (const index2 of current.scopes) selected.add(index2);
|
|
162
|
+
}
|
|
163
|
+
for (const index2 of selected) matches[index2].push(file);
|
|
164
|
+
}
|
|
165
|
+
return matches;
|
|
113
166
|
}
|
|
114
167
|
|
|
115
168
|
// src/utils/files.ts
|
|
116
|
-
var SOURCE_EXTENSIONS = [
|
|
169
|
+
var SOURCE_EXTENSIONS = [
|
|
170
|
+
"ts",
|
|
171
|
+
"tsx",
|
|
172
|
+
"js",
|
|
173
|
+
"jsx",
|
|
174
|
+
"mts",
|
|
175
|
+
"cts",
|
|
176
|
+
"mjs",
|
|
177
|
+
"cjs",
|
|
178
|
+
"vue",
|
|
179
|
+
"svelte",
|
|
180
|
+
"kt",
|
|
181
|
+
"kts",
|
|
182
|
+
"java",
|
|
183
|
+
"py",
|
|
184
|
+
"go",
|
|
185
|
+
"rs",
|
|
186
|
+
"swift",
|
|
187
|
+
"rb",
|
|
188
|
+
"cs",
|
|
189
|
+
"cpp",
|
|
190
|
+
"c",
|
|
191
|
+
"h",
|
|
192
|
+
"hpp",
|
|
193
|
+
"dart",
|
|
194
|
+
"php"
|
|
195
|
+
];
|
|
117
196
|
var SOURCE_GLOB = `**/*.{${SOURCE_EXTENSIONS.join(",")}}`;
|
|
118
197
|
var SOURCE_IGNORE = [
|
|
119
198
|
"**/node_modules/**",
|
|
@@ -141,11 +220,16 @@ var SOURCE_IGNORE = [
|
|
|
141
220
|
var MAX_SOURCE_BYTES = 1024 * 1024;
|
|
142
221
|
function isSensitiveFile(file) {
|
|
143
222
|
return file.split(/[\\/]/).some(
|
|
144
|
-
(part) => /^(?:\.env(?:\..*)?|id_rsa.*|id_ed25519.*)$|\.(?:pem|key|p12|pfx|jks|keystore)$|credentials\.|secret|^local\.properties$/i.test(
|
|
223
|
+
(part) => /^(?:\.env(?:\..*)?|id_rsa.*|id_ed25519.*)$|\.(?:pem|key|p12|pfx|jks|keystore)$|credentials\.|secret|^local\.properties$/i.test(
|
|
224
|
+
part
|
|
225
|
+
)
|
|
145
226
|
);
|
|
146
227
|
}
|
|
147
228
|
async function readBoundedFile(file, maxBytes) {
|
|
148
|
-
const handle = await fs.open(
|
|
229
|
+
const handle = await fs.open(
|
|
230
|
+
file,
|
|
231
|
+
constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW
|
|
232
|
+
);
|
|
149
233
|
try {
|
|
150
234
|
const stat = await handle.stat();
|
|
151
235
|
if (!stat.isFile() || stat.size > maxBytes) return null;
|
|
@@ -165,36 +249,44 @@ async function loadProjectConfig(root) {
|
|
|
165
249
|
try {
|
|
166
250
|
const canonicalRoot = await fs.realpath(root);
|
|
167
251
|
const configPath = await fs.realpath(path2.join(root, ".mason/config.json"));
|
|
168
|
-
if (!isWithinRoot(canonicalRoot, configPath))
|
|
252
|
+
if (!isWithinRoot(canonicalRoot, configPath))
|
|
253
|
+
throw new Error("Project configuration resolves outside the repository");
|
|
169
254
|
const raw = await readBoundedFile(configPath, 64 * 1024);
|
|
170
|
-
if (raw === null)
|
|
255
|
+
if (raw === null)
|
|
256
|
+
throw new Error("Project configuration is not a regular file or exceeds 64 KiB");
|
|
171
257
|
const value = JSON.parse(raw);
|
|
172
|
-
if (!
|
|
258
|
+
if (!isRecord(value)) throw new Error("Expected a configuration object");
|
|
173
259
|
const config = {};
|
|
174
260
|
for (const key of ["patterns", "alwaysInclude", "ignore"]) {
|
|
175
261
|
if (value[key] === void 0) continue;
|
|
176
|
-
if (!
|
|
262
|
+
if (!isStringArray(value[key])) {
|
|
177
263
|
throw new Error(`Configuration ${key} must be an array of strings`);
|
|
178
264
|
}
|
|
179
265
|
config[key] = value[key];
|
|
180
266
|
}
|
|
181
267
|
if (value.audit !== void 0) {
|
|
182
268
|
const audit = value.audit;
|
|
183
|
-
if (!
|
|
269
|
+
if (!isRecord(audit)) throw new Error("Configuration audit must be an object");
|
|
184
270
|
const patterns = (raw2) => {
|
|
185
|
-
if (!
|
|
186
|
-
|
|
271
|
+
if (!isStringArray(raw2) || raw2.length > 100 || !raw2.every(
|
|
272
|
+
(s) => typeof s === "string" && normalizeRepoPath(s) === s && !s.startsWith("!") && !/[\x00-\x1f]/.test(s)
|
|
273
|
+
)) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
"Audit patterns must be at most 100 repository-relative globs without traversal or negation"
|
|
276
|
+
);
|
|
187
277
|
}
|
|
188
278
|
return raw2;
|
|
189
279
|
};
|
|
190
280
|
config.audit = {};
|
|
191
281
|
if (audit.include !== void 0) config.audit.include = patterns(audit.include);
|
|
192
282
|
if (audit.exclude !== void 0) {
|
|
193
|
-
if (!
|
|
283
|
+
if (!isRecord(audit.exclude))
|
|
284
|
+
throw new Error("audit.exclude must map check names to patterns");
|
|
194
285
|
const { ALL_CHECKS: ALL_CHECKS2 } = await Promise.resolve().then(() => (init_types(), types_exports));
|
|
195
286
|
config.audit.exclude = {};
|
|
196
287
|
for (const [check, globs] of Object.entries(audit.exclude)) {
|
|
197
|
-
if (!ALL_CHECKS2.includes(check))
|
|
288
|
+
if (!ALL_CHECKS2.includes(check))
|
|
289
|
+
throw new Error("Unknown audit check: " + check);
|
|
198
290
|
config.audit.exclude[check] = patterns(globs);
|
|
199
291
|
}
|
|
200
292
|
}
|
|
@@ -202,7 +294,10 @@ async function loadProjectConfig(root) {
|
|
|
202
294
|
return config;
|
|
203
295
|
} catch (error) {
|
|
204
296
|
if (error.code === "ENOENT") return {};
|
|
205
|
-
throw new Error(
|
|
297
|
+
throw new Error(
|
|
298
|
+
`Cannot apply project file policy: ${error instanceof Error ? error.message : String(error)}`,
|
|
299
|
+
{ cause: error }
|
|
300
|
+
);
|
|
206
301
|
}
|
|
207
302
|
}
|
|
208
303
|
async function createFileAccess(rootDir) {
|
|
@@ -212,7 +307,10 @@ async function createFileAccess(rootDir) {
|
|
|
212
307
|
const ignore = [...SOURCE_IGNORE, ...config.ignore ?? []];
|
|
213
308
|
let gitFiles = null;
|
|
214
309
|
try {
|
|
215
|
-
const { stdout } = await execGit(
|
|
310
|
+
const { stdout } = await execGit(
|
|
311
|
+
["ls-files", "-z", "--cached", "--others", "--exclude-standard"],
|
|
312
|
+
{ cwd: root, maxBuffer: 50 * 1024 * 1024 }
|
|
313
|
+
);
|
|
216
314
|
gitFiles = new Set(stdout.split("\0").filter(Boolean));
|
|
217
315
|
} catch {
|
|
218
316
|
let inGit = false;
|
|
@@ -225,14 +323,17 @@ async function createFileAccess(rootDir) {
|
|
|
225
323
|
}
|
|
226
324
|
async function resolve(file) {
|
|
227
325
|
const relative = normalizeRepoPath(file);
|
|
228
|
-
if (!relative || isSensitiveFile(relative) || gitFiles && !gitFiles.has(relative))
|
|
326
|
+
if (!relative || isSensitiveFile(relative) || gitFiles && !gitFiles.has(relative))
|
|
327
|
+
return null;
|
|
229
328
|
const candidate = path2.join(root, relative);
|
|
230
329
|
try {
|
|
231
330
|
const real = await fs.realpath(candidate);
|
|
232
|
-
if (!isWithinRoot(canonicalRoot, real) || isSensitiveFile(path2.relative(canonicalRoot, real)))
|
|
331
|
+
if (!isWithinRoot(canonicalRoot, real) || isSensitiveFile(path2.relative(canonicalRoot, real)))
|
|
332
|
+
return null;
|
|
233
333
|
const stat = await fs.stat(real);
|
|
234
334
|
if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES) return null;
|
|
235
|
-
if (gitFiles && !gitFiles.has(path2.relative(canonicalRoot, real).split(path2.sep).join("/")))
|
|
335
|
+
if (gitFiles && !gitFiles.has(path2.relative(canonicalRoot, real).split(path2.sep).join("/")))
|
|
336
|
+
return null;
|
|
236
337
|
return real;
|
|
237
338
|
} catch {
|
|
238
339
|
return null;
|
|
@@ -248,7 +349,10 @@ async function createFileAccess(rootDir) {
|
|
|
248
349
|
if (!relative) return null;
|
|
249
350
|
const real = await resolve(relative);
|
|
250
351
|
if (!real) return null;
|
|
251
|
-
for (const rel of /* @__PURE__ */ new Set([
|
|
352
|
+
for (const rel of /* @__PURE__ */ new Set([
|
|
353
|
+
relative,
|
|
354
|
+
path2.relative(canonicalRoot, real).split(path2.sep).join("/")
|
|
355
|
+
])) {
|
|
252
356
|
if (!(await fg(fg.escapePath(rel), { cwd: root, ignore, dot: true })).length) return null;
|
|
253
357
|
}
|
|
254
358
|
try {
|
|
@@ -258,7 +362,7 @@ async function createFileAccess(rootDir) {
|
|
|
258
362
|
return null;
|
|
259
363
|
}
|
|
260
364
|
}
|
|
261
|
-
return { root, config, list, read };
|
|
365
|
+
return { root, config, list, read, inventory: () => gitFiles ? [...gitFiles].sort() : null };
|
|
262
366
|
}
|
|
263
367
|
|
|
264
368
|
// src/utils/storage.ts
|
|
@@ -300,16 +404,21 @@ async function readStoreJson(root, relative) {
|
|
|
300
404
|
return parsed;
|
|
301
405
|
} catch (error) {
|
|
302
406
|
if (error.code === "ENOENT") return null;
|
|
303
|
-
throw new Error(
|
|
407
|
+
throw new Error(
|
|
408
|
+
`Invalid Mason store ${relative}: ${error instanceof Error ? error.message : String(error)}`,
|
|
409
|
+
{ cause: error }
|
|
410
|
+
);
|
|
304
411
|
}
|
|
305
412
|
}
|
|
306
413
|
|
|
307
414
|
// src/snapshot/snapshot.ts
|
|
308
|
-
import { z } from "zod";
|
|
415
|
+
import { z as z2 } from "zod";
|
|
309
416
|
|
|
310
417
|
// src/utils/store-lock.ts
|
|
418
|
+
import { z } from "zod";
|
|
311
419
|
import fs3 from "fs/promises";
|
|
312
420
|
import os from "os";
|
|
421
|
+
var lockOwnerSchema = z.object({ host: z.string(), pid: z.number().int().positive() });
|
|
313
422
|
|
|
314
423
|
// src/test-map.ts
|
|
315
424
|
import path4 from "path";
|
|
@@ -382,30 +491,30 @@ function commonSegments(pathA, pathB) {
|
|
|
382
491
|
}
|
|
383
492
|
|
|
384
493
|
// src/snapshot/snapshot.ts
|
|
385
|
-
var repoPath =
|
|
494
|
+
var repoPath = z2.string().refine((value) => normalizeRepoPath(value) !== null, "Expected a relative repository path");
|
|
386
495
|
var verificationFields = {
|
|
387
|
-
refreshedHash:
|
|
388
|
-
verifiedAt:
|
|
389
|
-
verifiedHash:
|
|
390
|
-
verificationToken:
|
|
391
|
-
verificationFailed:
|
|
392
|
-
verificationNote:
|
|
496
|
+
refreshedHash: z2.string().optional(),
|
|
497
|
+
verifiedAt: z2.string().optional(),
|
|
498
|
+
verifiedHash: z2.string().optional(),
|
|
499
|
+
verificationToken: z2.string().optional(),
|
|
500
|
+
verificationFailed: z2.boolean().optional(),
|
|
501
|
+
verificationNote: z2.string().optional()
|
|
393
502
|
};
|
|
394
|
-
var featureSchema =
|
|
395
|
-
description:
|
|
396
|
-
files:
|
|
397
|
-
tests:
|
|
398
|
-
type:
|
|
503
|
+
var featureSchema = z2.object({
|
|
504
|
+
description: z2.string(),
|
|
505
|
+
files: z2.array(repoPath),
|
|
506
|
+
tests: z2.array(repoPath).optional(),
|
|
507
|
+
type: z2.enum(["capability", "infrastructure"]).optional(),
|
|
399
508
|
...verificationFields
|
|
400
509
|
}).passthrough();
|
|
401
|
-
var flowSchema =
|
|
402
|
-
var snapshotSchema =
|
|
403
|
-
version:
|
|
404
|
-
createdAt:
|
|
405
|
-
updatedAt:
|
|
406
|
-
gitHash:
|
|
407
|
-
features:
|
|
408
|
-
flows:
|
|
510
|
+
var flowSchema = z2.object({ description: z2.string(), chain: z2.array(repoPath), ...verificationFields }).passthrough();
|
|
511
|
+
var snapshotSchema = z2.object({
|
|
512
|
+
version: z2.literal(2),
|
|
513
|
+
createdAt: z2.string(),
|
|
514
|
+
updatedAt: z2.string(),
|
|
515
|
+
gitHash: z2.string(),
|
|
516
|
+
features: z2.record(featureSchema),
|
|
517
|
+
flows: z2.record(flowSchema)
|
|
409
518
|
}).passthrough();
|
|
410
519
|
async function getCurrentGitHash(rootDir) {
|
|
411
520
|
try {
|
|
@@ -428,18 +537,25 @@ function parseChanges(output) {
|
|
|
428
537
|
if (!first) break;
|
|
429
538
|
const second = /^[RC]/.test(code) ? fields[i++] : void 0;
|
|
430
539
|
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 };
|
|
431
|
-
if (change.path.startsWith(".mason/") && (!change.previousPath || change.previousPath.startsWith(".mason/")))
|
|
540
|
+
if (change.path.startsWith(".mason/") && (!change.previousPath || change.previousPath.startsWith(".mason/")))
|
|
541
|
+
continue;
|
|
432
542
|
changes.push(change);
|
|
433
543
|
}
|
|
434
544
|
return changes;
|
|
435
545
|
}
|
|
436
546
|
function touchedPaths(changes) {
|
|
437
|
-
return [
|
|
547
|
+
return [
|
|
548
|
+
...new Set(changes.flatMap((c) => c.previousPath ? [c.previousPath, c.path] : [c.path]))
|
|
549
|
+
].sort();
|
|
438
550
|
}
|
|
439
551
|
async function getChangesWithStatus(resolvedRoot, fromHash, toHash = "HEAD") {
|
|
440
|
-
if (!fromHash || fromHash === "unknown" || fromHash.startsWith("-") || !toHash || toHash === "unknown" || toHash.startsWith("-"))
|
|
552
|
+
if (!fromHash || fromHash === "unknown" || fromHash.startsWith("-") || !toHash || toHash === "unknown" || toHash.startsWith("-"))
|
|
553
|
+
return null;
|
|
441
554
|
try {
|
|
442
|
-
const { stdout } = await execGit(
|
|
555
|
+
const { stdout } = await execGit(
|
|
556
|
+
["diff", "--name-status", "-z", "-M", fromHash, toHash, "--"],
|
|
557
|
+
{ cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }
|
|
558
|
+
);
|
|
443
559
|
return parseChanges(stdout);
|
|
444
560
|
} catch {
|
|
445
561
|
return null;
|
|
@@ -448,122 +564,340 @@ async function getChangesWithStatus(resolvedRoot, fromHash, toHash = "HEAD") {
|
|
|
448
564
|
async function getWorkingTree(resolvedRoot) {
|
|
449
565
|
try {
|
|
450
566
|
const [diff, untracked] = await Promise.all([
|
|
451
|
-
execGit(["diff", "--name-status", "-z", "-M", "HEAD", "--"], {
|
|
452
|
-
|
|
567
|
+
execGit(["diff", "--name-status", "-z", "-M", "HEAD", "--"], {
|
|
568
|
+
cwd: resolvedRoot,
|
|
569
|
+
maxBuffer: 10 * 1024 * 1024
|
|
570
|
+
}),
|
|
571
|
+
execGit(["ls-files", "-z", "--others", "--exclude-standard"], {
|
|
572
|
+
cwd: resolvedRoot,
|
|
573
|
+
maxBuffer: 10 * 1024 * 1024
|
|
574
|
+
})
|
|
453
575
|
]);
|
|
454
576
|
const untrackedFiles = untracked.stdout.split("\0").filter((f) => f && !f.startsWith(".mason/"));
|
|
455
|
-
return {
|
|
577
|
+
return {
|
|
578
|
+
available: true,
|
|
579
|
+
changedFiles: [
|
|
580
|
+
.../* @__PURE__ */ new Set([...touchedPaths(parseChanges(diff.stdout)), ...untrackedFiles])
|
|
581
|
+
].sort(),
|
|
582
|
+
untrackedFiles
|
|
583
|
+
};
|
|
456
584
|
} catch {
|
|
457
585
|
return { available: false, changedFiles: [], untrackedFiles: [] };
|
|
458
586
|
}
|
|
459
587
|
}
|
|
460
588
|
|
|
461
589
|
// src/decisions/provenance.ts
|
|
462
|
-
import { z as
|
|
590
|
+
import { z as z4 } from "zod";
|
|
591
|
+
import { createHash as createHash2 } from "crypto";
|
|
592
|
+
|
|
593
|
+
// src/decisions/anchors.ts
|
|
594
|
+
import { createHash } from "crypto";
|
|
595
|
+
import fs5 from "fs/promises";
|
|
596
|
+
import { constants as constants2 } from "fs";
|
|
597
|
+
import { z as z3 } from "zod";
|
|
598
|
+
import fg3 from "fast-glob";
|
|
599
|
+
|
|
600
|
+
// src/audit/inspection.ts
|
|
601
|
+
import fg2 from "fast-glob";
|
|
602
|
+
import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
|
|
603
|
+
import path7 from "path";
|
|
604
|
+
var inspections = new AsyncLocalStorage2();
|
|
605
|
+
function inspectionRead(root, key, read) {
|
|
606
|
+
const inspection = inspections.getStore();
|
|
607
|
+
if (!inspection || inspection.root !== path7.resolve(root)) return read();
|
|
608
|
+
if (!inspection.reads.has(key)) {
|
|
609
|
+
const pending = read();
|
|
610
|
+
inspection.reads.set(key, pending);
|
|
611
|
+
void pending.catch(() => {
|
|
612
|
+
if (inspection.reads.get(key) === pending) inspection.reads.delete(key);
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
return inspection.reads.get(key);
|
|
616
|
+
}
|
|
617
|
+
function inspectionGit(args, options) {
|
|
618
|
+
return inspectionRead(
|
|
619
|
+
options.cwd,
|
|
620
|
+
"git:" + JSON.stringify([args, options]),
|
|
621
|
+
() => execGit(args, options)
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
var sourceSpecs = SOURCE_EXTENSIONS.map((extension) => `:(glob)**/*.${extension}`);
|
|
625
|
+
var sourceSuffixes = new Set(SOURCE_EXTENSIONS.map((extension) => "." + extension));
|
|
626
|
+
|
|
627
|
+
// src/decisions/anchors.ts
|
|
628
|
+
var repoPath2 = z3.string().refine((p) => normalizeRepoPath(p) === p);
|
|
629
|
+
var anchorCaptureSchema = z3.object({
|
|
630
|
+
anchors: z3.array(repoPath2),
|
|
631
|
+
files: z3.record(repoPath2, z3.string().regex(/^[a-f0-9]{64}$/)),
|
|
632
|
+
complete: z3.boolean()
|
|
633
|
+
}).refine(
|
|
634
|
+
(capture) => JSON.stringify(capture.anchors) === JSON.stringify([...new Set(capture.anchors)].sort()) && matchingPaths(capture.anchors, Object.keys(capture.files)).length === Object.keys(capture.files).length,
|
|
635
|
+
"Capture paths must belong to its normalized, sorted anchor scope"
|
|
636
|
+
);
|
|
637
|
+
async function captureAnchorScopes(root, scopes) {
|
|
638
|
+
const union = [...new Set(scopes.flat())].sort();
|
|
639
|
+
let context;
|
|
640
|
+
const prepareContext = async () => {
|
|
641
|
+
const access = await inspectionRead(
|
|
642
|
+
root,
|
|
643
|
+
"decision-anchor-access",
|
|
644
|
+
() => createFileAccess(root)
|
|
645
|
+
);
|
|
646
|
+
const inventory = access.inventory();
|
|
647
|
+
if (!inventory) throw new Error("Git anchor inventory unavailable");
|
|
648
|
+
const ignored = await inspectionGit(
|
|
649
|
+
[
|
|
650
|
+
"ls-files",
|
|
651
|
+
"-z",
|
|
652
|
+
"--others",
|
|
653
|
+
"--ignored",
|
|
654
|
+
"--exclude-standard",
|
|
655
|
+
"--",
|
|
656
|
+
...union.map((anchor) => `:(literal)${anchor}`)
|
|
657
|
+
],
|
|
658
|
+
{ cwd: root, maxBuffer: 8 * 1024 * 1024 }
|
|
659
|
+
);
|
|
660
|
+
const files = [
|
|
661
|
+
.../* @__PURE__ */ new Set([...inventory, ...ignored.stdout.split("\0").filter(Boolean)])
|
|
662
|
+
].sort();
|
|
663
|
+
const patterns = await Promise.all(
|
|
664
|
+
union.map(async (anchor) => {
|
|
665
|
+
const literal = fg3.escapePath(anchor);
|
|
666
|
+
try {
|
|
667
|
+
return (await fs5.lstat(await storePath(root, anchor))).isDirectory() ? `${literal}/**/*` : literal;
|
|
668
|
+
} catch {
|
|
669
|
+
return literal;
|
|
670
|
+
}
|
|
671
|
+
})
|
|
672
|
+
);
|
|
673
|
+
const eligible = new Set(await access.list(patterns, { dot: true }));
|
|
674
|
+
const indexed = indexMatchingPaths(scopes, files);
|
|
675
|
+
const filesByScope = new Map(
|
|
676
|
+
scopes.map((scope, index2) => [JSON.stringify([...new Set(scope)].sort()), indexed[index2]])
|
|
677
|
+
);
|
|
678
|
+
return { filesByScope, eligible };
|
|
679
|
+
};
|
|
680
|
+
return Promise.all(
|
|
681
|
+
scopes.map((scope) => {
|
|
682
|
+
const anchors = [...new Set(scope)].sort();
|
|
683
|
+
return inspectionRead(
|
|
684
|
+
root,
|
|
685
|
+
"decision-anchor-capture:" + JSON.stringify(anchors),
|
|
686
|
+
async () => {
|
|
687
|
+
const capture = { anchors, files: {}, complete: true };
|
|
688
|
+
if (!anchors.length) return capture;
|
|
689
|
+
try {
|
|
690
|
+
const { eligible, filesByScope } = await (context ??= prepareContext());
|
|
691
|
+
const files = filesByScope.get(JSON.stringify(anchors));
|
|
692
|
+
if (files.length > 2e3) return { ...capture, complete: false };
|
|
693
|
+
for (const file of files) {
|
|
694
|
+
const evidence = await inspectionRead(
|
|
695
|
+
root,
|
|
696
|
+
"decision-anchor-file:" + file,
|
|
697
|
+
async () => {
|
|
698
|
+
try {
|
|
699
|
+
const absolute = await storePath(root, file);
|
|
700
|
+
await fs5.lstat(absolute);
|
|
701
|
+
if (!eligible.has(file)) return { complete: false };
|
|
702
|
+
const handle = await fs5.open(
|
|
703
|
+
absolute,
|
|
704
|
+
constants2.O_RDONLY | constants2.O_NONBLOCK | constants2.O_NOFOLLOW
|
|
705
|
+
);
|
|
706
|
+
try {
|
|
707
|
+
const stat = await handle.stat();
|
|
708
|
+
if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES)
|
|
709
|
+
return { complete: false };
|
|
710
|
+
const bytes = Buffer.alloc(Math.min(stat.size + 1, MAX_SOURCE_BYTES + 1));
|
|
711
|
+
let size = 0;
|
|
712
|
+
while (size < bytes.length) {
|
|
713
|
+
const read = await handle.read(bytes, size, bytes.length - size, null);
|
|
714
|
+
if (!read.bytesRead) break;
|
|
715
|
+
size += read.bytesRead;
|
|
716
|
+
}
|
|
717
|
+
if (size === bytes.length) return { complete: false };
|
|
718
|
+
return {
|
|
719
|
+
complete: true,
|
|
720
|
+
hash: createHash("sha256").update(bytes.subarray(0, size)).digest("hex")
|
|
721
|
+
};
|
|
722
|
+
} finally {
|
|
723
|
+
await handle.close();
|
|
724
|
+
}
|
|
725
|
+
} catch (error) {
|
|
726
|
+
return { complete: error.code === "ENOENT" };
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
);
|
|
730
|
+
if (!evidence.complete) capture.complete = false;
|
|
731
|
+
if (evidence.hash) capture.files[file] = evidence.hash;
|
|
732
|
+
}
|
|
733
|
+
} catch {
|
|
734
|
+
capture.complete = false;
|
|
735
|
+
}
|
|
736
|
+
return capture;
|
|
737
|
+
}
|
|
738
|
+
);
|
|
739
|
+
})
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
function changedCaptureFiles(before, after) {
|
|
743
|
+
return [.../* @__PURE__ */ new Set([...Object.keys(before.files), ...Object.keys(after.files)])].filter((file) => before.files[file] !== after.files[file]).sort();
|
|
744
|
+
}
|
|
463
745
|
|
|
464
746
|
// src/context/trust.ts
|
|
465
747
|
function assessTrust(entry, freshness) {
|
|
466
748
|
const verification = entry.verificationFailed ? "failed" : entry.verifiedAt ? "passed" : "unverified";
|
|
467
749
|
const reasons = [];
|
|
468
|
-
if (freshness === "unknown")
|
|
469
|
-
|
|
470
|
-
|
|
750
|
+
if (freshness === "unknown")
|
|
751
|
+
reasons.push(
|
|
752
|
+
"Anchors, history, or working-tree evidence are unavailable; verify before relying on this entry."
|
|
753
|
+
);
|
|
754
|
+
if (freshness === "changed")
|
|
755
|
+
reasons.push(
|
|
756
|
+
"Anchored files changed; verify against current code before relying on this entry."
|
|
757
|
+
);
|
|
758
|
+
if (verification === "failed")
|
|
759
|
+
reasons.push(
|
|
760
|
+
`Verification failed: ${entry.verificationNote ?? "re-map this entry before relying on it"}`
|
|
761
|
+
);
|
|
471
762
|
if (verification === "unverified") reasons.push("No correctness verification has been recorded.");
|
|
472
|
-
return {
|
|
763
|
+
return {
|
|
764
|
+
freshness,
|
|
765
|
+
verification,
|
|
766
|
+
verifiedAt: entry.verifiedAt,
|
|
767
|
+
verifiedHash: entry.verifiedHash,
|
|
768
|
+
reasons
|
|
769
|
+
};
|
|
473
770
|
}
|
|
474
771
|
|
|
475
772
|
// src/decisions/provenance.ts
|
|
476
|
-
var text = (max) =>
|
|
477
|
-
var decisionSourceSchema =
|
|
478
|
-
kind:
|
|
773
|
+
var text = (max) => z4.string().trim().min(1).max(max);
|
|
774
|
+
var decisionSourceSchema = z4.object({
|
|
775
|
+
kind: z4.enum(["pull_request", "issue", "incident", "discussion", "document", "other"]),
|
|
479
776
|
reference: text(1e3),
|
|
480
777
|
note: text(500).optional()
|
|
481
778
|
}).strict();
|
|
482
|
-
var attributionSchema =
|
|
779
|
+
var attributionSchema = z4.object({
|
|
483
780
|
owner: text(200).nullable().optional(),
|
|
484
|
-
sources:
|
|
781
|
+
sources: z4.array(decisionSourceSchema).max(20).optional(),
|
|
485
782
|
actor: text(200).optional()
|
|
486
783
|
});
|
|
487
|
-
var contentSchema =
|
|
488
|
-
title:
|
|
489
|
-
body:
|
|
490
|
-
category:
|
|
491
|
-
files:
|
|
784
|
+
var contentSchema = z4.object({
|
|
785
|
+
title: z4.string().min(1),
|
|
786
|
+
body: z4.string().min(1),
|
|
787
|
+
category: z4.enum(["decision", "gotcha", "deprecation", "convention"]),
|
|
788
|
+
files: z4.array(z4.string().refine((f) => normalizeRepoPath(f) !== null)),
|
|
492
789
|
owner: text(200).optional(),
|
|
493
|
-
sources:
|
|
790
|
+
sources: z4.array(decisionSourceSchema).max(20)
|
|
494
791
|
});
|
|
495
|
-
var approvalSchema =
|
|
496
|
-
var statusSchema =
|
|
497
|
-
var reviewEvidenceSchema =
|
|
498
|
-
baseHash:
|
|
499
|
-
headHash:
|
|
500
|
-
historyAvailable:
|
|
501
|
-
changedFiles:
|
|
502
|
-
localChanges:
|
|
792
|
+
var approvalSchema = z4.enum(["unreviewed", "proposed", "accepted"]);
|
|
793
|
+
var statusSchema = z4.enum(["active", "superseded", "retired"]);
|
|
794
|
+
var reviewEvidenceSchema = z4.object({
|
|
795
|
+
baseHash: z4.string(),
|
|
796
|
+
headHash: z4.string(),
|
|
797
|
+
historyAvailable: z4.boolean(),
|
|
798
|
+
changedFiles: z4.array(z4.string()),
|
|
799
|
+
localChanges: z4.array(z4.string())
|
|
503
800
|
});
|
|
504
|
-
var eventSchema =
|
|
505
|
-
kind:
|
|
506
|
-
|
|
801
|
+
var eventSchema = z4.object({
|
|
802
|
+
kind: z4.enum([
|
|
803
|
+
"imported",
|
|
804
|
+
"created",
|
|
805
|
+
"revised",
|
|
806
|
+
"accepted",
|
|
807
|
+
"reaffirmed",
|
|
808
|
+
"retired",
|
|
809
|
+
"superseded"
|
|
810
|
+
]),
|
|
811
|
+
at: z4.string().datetime(),
|
|
507
812
|
actor: text(200).optional(),
|
|
508
813
|
note: text(1500).optional(),
|
|
509
|
-
revision:
|
|
814
|
+
revision: z4.number().int().positive(),
|
|
510
815
|
content: contentSchema,
|
|
511
816
|
approval: approvalSchema,
|
|
512
817
|
status: statusSchema,
|
|
513
|
-
refreshedHash:
|
|
514
|
-
evidence: reviewEvidenceSchema.optional()
|
|
818
|
+
refreshedHash: z4.string(),
|
|
819
|
+
evidence: reviewEvidenceSchema.optional(),
|
|
820
|
+
capture: anchorCaptureSchema.optional()
|
|
515
821
|
});
|
|
516
|
-
var legacySchema =
|
|
517
|
-
version:
|
|
518
|
-
id:
|
|
519
|
-
title:
|
|
520
|
-
body:
|
|
822
|
+
var legacySchema = z4.object({
|
|
823
|
+
version: z4.literal(1),
|
|
824
|
+
id: z4.string().regex(/^[a-zA-Z0-9_-]+$/),
|
|
825
|
+
title: z4.string().min(1),
|
|
826
|
+
body: z4.string().min(1),
|
|
521
827
|
category: contentSchema.shape.category,
|
|
522
828
|
files: contentSchema.shape.files,
|
|
523
|
-
createdAt:
|
|
524
|
-
updatedAt:
|
|
525
|
-
refreshedHash:
|
|
526
|
-
status:
|
|
527
|
-
supersededBy:
|
|
829
|
+
createdAt: z4.string(),
|
|
830
|
+
updatedAt: z4.string(),
|
|
831
|
+
refreshedHash: z4.string(),
|
|
832
|
+
status: z4.enum(["active", "superseded"]),
|
|
833
|
+
supersededBy: z4.string().optional()
|
|
528
834
|
}).passthrough();
|
|
529
835
|
var currentSchema = legacySchema.extend({
|
|
530
|
-
version:
|
|
836
|
+
version: z4.literal(2),
|
|
531
837
|
status: statusSchema,
|
|
532
838
|
approval: approvalSchema,
|
|
533
|
-
revision:
|
|
839
|
+
revision: z4.number().int().positive(),
|
|
534
840
|
owner: text(200).optional(),
|
|
535
|
-
sources:
|
|
536
|
-
history:
|
|
841
|
+
sources: z4.array(decisionSourceSchema).max(20),
|
|
842
|
+
history: z4.array(eventSchema).min(1),
|
|
843
|
+
capture: anchorCaptureSchema.optional(),
|
|
844
|
+
inspections: z4.array(
|
|
845
|
+
z4.object({
|
|
846
|
+
at: z4.string().datetime(),
|
|
847
|
+
inspector: text(200),
|
|
848
|
+
note: text(1500),
|
|
849
|
+
outcome: z4.literal("no_contradiction_found"),
|
|
850
|
+
decisionDigest: z4.string().regex(/^[a-f0-9]{64}$/),
|
|
851
|
+
headHash: z4.string(),
|
|
852
|
+
capture: anchorCaptureSchema
|
|
853
|
+
})
|
|
854
|
+
).max(200).optional()
|
|
537
855
|
}).superRefine((record, ctx) => {
|
|
538
856
|
const invalid = (message2) => ctx.addIssue({ code: "custom", message: message2 });
|
|
539
857
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
540
858
|
let previous;
|
|
541
859
|
for (const event of record.history) {
|
|
542
860
|
if (!previous) {
|
|
543
|
-
if (!["created", "imported"].includes(event.kind) || event.revision !== 1)
|
|
544
|
-
|
|
861
|
+
if (!["created", "imported"].includes(event.kind) || event.revision !== 1)
|
|
862
|
+
invalid("History must begin with creation or legacy import at revision 1");
|
|
863
|
+
if (event.approval !== (event.kind === "created" ? "proposed" : "unreviewed"))
|
|
864
|
+
invalid("Initial records cannot claim acceptance");
|
|
545
865
|
} else {
|
|
546
866
|
if (["created", "imported"].includes(event.kind)) invalid("History cannot restart");
|
|
547
867
|
if (previous.status !== "active") invalid("Archived decisions cannot be changed");
|
|
548
|
-
if (event.revision !== previous.revision + (event.kind === "revised" ? 1 : 0))
|
|
549
|
-
|
|
550
|
-
if (event.kind
|
|
551
|
-
|
|
868
|
+
if (event.revision !== previous.revision + (event.kind === "revised" ? 1 : 0))
|
|
869
|
+
invalid("Invalid revision sequence");
|
|
870
|
+
if (event.kind !== "revised" && !same(event.content, previous.content))
|
|
871
|
+
invalid("A review cannot silently revise decision content");
|
|
872
|
+
if (event.kind === "reaffirmed" && previous.approval !== "accepted")
|
|
873
|
+
invalid("Only accepted decisions can be reaffirmed");
|
|
874
|
+
if (event.kind === "accepted" && previous.approval === "accepted")
|
|
875
|
+
invalid("Use reaffirmation for an accepted decision");
|
|
552
876
|
const approval = event.kind === "revised" ? "proposed" : ["accepted", "reaffirmed"].includes(event.kind) ? "accepted" : previous.approval;
|
|
553
877
|
if (event.approval !== approval) invalid("Approval disagrees with review history");
|
|
554
|
-
if (!["accepted", "reaffirmed"].includes(event.kind) && event.refreshedHash !== previous.refreshedHash)
|
|
878
|
+
if (!["accepted", "reaffirmed"].includes(event.kind) && event.refreshedHash !== previous.refreshedHash)
|
|
879
|
+
invalid("Only a review can refresh the evidence baseline");
|
|
555
880
|
}
|
|
556
|
-
if (event.kind !== "imported" && event.status !== (event.kind === "retired" ? "retired" : event.kind === "superseded" ? "superseded" : "active"))
|
|
557
|
-
|
|
881
|
+
if (event.kind !== "imported" && event.status !== (event.kind === "retired" ? "retired" : event.kind === "superseded" ? "superseded" : "active"))
|
|
882
|
+
invalid("Lifecycle disagrees with history");
|
|
883
|
+
if (["accepted", "reaffirmed", "retired"].includes(event.kind) && (!event.actor || !event.note || !event.evidence))
|
|
884
|
+
invalid("Reviews require a named reviewer, reason, and code evidence");
|
|
558
885
|
if (["accepted", "reaffirmed"].includes(event.kind)) {
|
|
559
|
-
if (!event.content.owner || !event.content.sources.length)
|
|
560
|
-
|
|
886
|
+
if (!event.content.owner || !event.content.sources.length)
|
|
887
|
+
invalid("Accepted decisions require an owner and source");
|
|
888
|
+
if (!event.evidence || !/^[a-f0-9]{40,64}$/.test(event.evidence.headHash) || event.refreshedHash !== event.evidence.headHash || event.evidence.localChanges.length)
|
|
889
|
+
invalid("Acceptance requires a committed evidence baseline");
|
|
561
890
|
}
|
|
891
|
+
if (event.capture && !same([...event.content.files].sort(), event.capture.anchors))
|
|
892
|
+
invalid("Capture scope disagrees with decision anchors");
|
|
562
893
|
previous = event;
|
|
563
894
|
}
|
|
564
|
-
if (
|
|
895
|
+
if (previous && !same(previous.capture, record.capture))
|
|
896
|
+
invalid("Capture does not match the final history event");
|
|
897
|
+
if (!previous || !same(previous.content, decisionContent(record)) || previous.approval !== record.approval || previous.status !== record.status || previous.revision !== record.revision || previous.refreshedHash !== record.refreshedHash)
|
|
898
|
+
invalid("Decision does not match the final history event");
|
|
565
899
|
});
|
|
566
|
-
var decisionSchema =
|
|
900
|
+
var decisionSchema = z4.union([legacySchema, currentSchema]);
|
|
567
901
|
function decisionContent(record) {
|
|
568
902
|
return {
|
|
569
903
|
title: record.title,
|
|
@@ -578,7 +912,8 @@ function decisionApproval(record) {
|
|
|
578
912
|
return record.version === 1 ? "unreviewed" : record.approval;
|
|
579
913
|
}
|
|
580
914
|
function effectiveDecision(record) {
|
|
581
|
-
if (record.version !== 2 || record.status !== "active" || record.approval !== "proposed")
|
|
915
|
+
if (record.version !== 2 || record.status !== "active" || record.approval !== "proposed")
|
|
916
|
+
return record;
|
|
582
917
|
let index2 = record.history.length - 1;
|
|
583
918
|
while (index2 >= 0 && !["accepted", "reaffirmed"].includes(record.history[index2].kind)) index2--;
|
|
584
919
|
if (index2 < 0) return record;
|
|
@@ -590,13 +925,16 @@ function effectiveDecision(record) {
|
|
|
590
925
|
approval: "accepted",
|
|
591
926
|
revision: event.revision,
|
|
592
927
|
refreshedHash: event.refreshedHash,
|
|
928
|
+
capture: event.capture,
|
|
593
929
|
updatedAt: event.at,
|
|
594
930
|
history: record.history.slice(0, index2 + 1)
|
|
595
931
|
};
|
|
596
932
|
}
|
|
597
933
|
function decisionProvenance(record, freshness = "unknown") {
|
|
598
934
|
const approval = decisionApproval(record);
|
|
599
|
-
const review = record.version === 2 ? [...record.history].reverse().find(
|
|
935
|
+
const review = record.version === 2 ? [...record.history].reverse().find(
|
|
936
|
+
(e) => ["accepted", "reaffirmed"].includes(e.kind) && e.revision === record.revision
|
|
937
|
+
) : void 0;
|
|
600
938
|
return {
|
|
601
939
|
approval,
|
|
602
940
|
revision: record.version === 2 ? record.revision : 0,
|
|
@@ -604,80 +942,192 @@ function decisionProvenance(record, freshness = "unknown") {
|
|
|
604
942
|
sources: record.version === 2 ? record.sources : [],
|
|
605
943
|
guidance: record.status !== "active" ? "historical" : approval === "accepted" ? "constraint" : approval === "proposed" ? "proposal" : "unreviewed",
|
|
606
944
|
reviewRequired: record.status === "active" && (approval !== "accepted" || freshness !== "current"),
|
|
607
|
-
lastReview: review ? {
|
|
945
|
+
lastReview: review ? {
|
|
946
|
+
reviewer: review.actor,
|
|
947
|
+
at: review.at,
|
|
948
|
+
note: review.note,
|
|
949
|
+
gitHash: review.refreshedHash
|
|
950
|
+
} : null
|
|
608
951
|
};
|
|
609
952
|
}
|
|
610
953
|
function decisionTrust(record, freshness) {
|
|
611
954
|
const review = decisionProvenance(record, freshness).lastReview;
|
|
612
|
-
return assessTrust(
|
|
955
|
+
return assessTrust(
|
|
956
|
+
review ? { verifiedAt: review.at, verifiedHash: review.gitHash } : {},
|
|
957
|
+
freshness
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
function decisionInspectionDigest(record) {
|
|
961
|
+
const effective = effectiveDecision(record);
|
|
962
|
+
return createHash2("sha256").update(
|
|
963
|
+
JSON.stringify([
|
|
964
|
+
record.status,
|
|
965
|
+
decisionApproval(record),
|
|
966
|
+
record.version === 2 ? record.revision : 0,
|
|
967
|
+
decisionContent(record),
|
|
968
|
+
decisionContent(effective),
|
|
969
|
+
effective.refreshedHash
|
|
970
|
+
])
|
|
971
|
+
).digest("hex");
|
|
972
|
+
}
|
|
973
|
+
function latestDecisionInspection(record) {
|
|
974
|
+
return record.version === 2 ? record.inspections?.filter((i) => i.decisionDigest === decisionInspectionDigest(record)).at(-1) : void 0;
|
|
613
975
|
}
|
|
614
976
|
function revisionKnowledge(record, freshness) {
|
|
615
|
-
return {
|
|
977
|
+
return {
|
|
978
|
+
...decisionContent(record),
|
|
979
|
+
...decisionProvenance(record, freshness),
|
|
980
|
+
trust: decisionTrust(record, freshness)
|
|
981
|
+
};
|
|
616
982
|
}
|
|
617
983
|
function decisionKnowledge(record, freshness = "unknown", proposalFreshness = "unknown") {
|
|
618
984
|
const effective = effectiveDecision(record);
|
|
985
|
+
const inspection = latestDecisionInspection(record);
|
|
619
986
|
return {
|
|
620
987
|
...revisionKnowledge(effective, freshness),
|
|
988
|
+
...inspection ? {
|
|
989
|
+
inspection: {
|
|
990
|
+
freshness,
|
|
991
|
+
at: inspection.at,
|
|
992
|
+
inspector: inspection.inspector,
|
|
993
|
+
note: inspection.note,
|
|
994
|
+
outcome: inspection.outcome,
|
|
995
|
+
headHash: inspection.headHash
|
|
996
|
+
}
|
|
997
|
+
} : {},
|
|
621
998
|
...effective !== record ? { pendingProposal: revisionKnowledge(record, proposalFreshness) } : {}
|
|
622
999
|
};
|
|
623
1000
|
}
|
|
624
1001
|
|
|
625
1002
|
// src/decisions/decisions.ts
|
|
626
|
-
import
|
|
627
|
-
import
|
|
628
|
-
import { createHash } from "crypto";
|
|
629
|
-
import { z as
|
|
1003
|
+
import fs6 from "fs/promises";
|
|
1004
|
+
import path9 from "path";
|
|
1005
|
+
import { createHash as createHash3 } from "crypto";
|
|
1006
|
+
import { z as z5 } from "zod";
|
|
630
1007
|
|
|
631
1008
|
// src/context/lexical.ts
|
|
632
|
-
import
|
|
1009
|
+
import path8 from "path";
|
|
633
1010
|
|
|
634
1011
|
// src/decisions/decisions.ts
|
|
635
1012
|
var TITLE_MAX_CHARS = 80;
|
|
636
1013
|
var BODY_RECOMMENDED_CHARS = 1500;
|
|
637
1014
|
var BODY_MAX_CHARS = 2500;
|
|
638
|
-
var boundedText = (field, max, hint) =>
|
|
639
|
-
errorMap: (issue, ctx) => ({
|
|
1015
|
+
var boundedText = (field, max, hint) => z5.string({
|
|
1016
|
+
errorMap: (issue, ctx) => ({
|
|
1017
|
+
message: issue.code === "too_big" && typeof ctx.data === "string" ? `${field} has ${ctx.data.length} characters; maximum is ${max} (${ctx.data.length - max} over). ${hint}` : ctx.defaultError
|
|
1018
|
+
})
|
|
640
1019
|
}).trim().min(1, `${field} must be non-empty`).max(max);
|
|
641
|
-
var decisionTitleSchema = boundedText(
|
|
1020
|
+
var decisionTitleSchema = boundedText(
|
|
1021
|
+
"title",
|
|
1022
|
+
TITLE_MAX_CHARS,
|
|
1023
|
+
"Tighten it to a specific headline."
|
|
1024
|
+
);
|
|
642
1025
|
var decisionBodySchema = boundedText(
|
|
643
1026
|
"body",
|
|
644
1027
|
BODY_MAX_CHARS,
|
|
645
1028
|
`Aim for ${BODY_RECOMMENDED_CHARS} characters or fewer. Preserve the rule, reason, and exceptions; put supporting references in sources.`
|
|
646
1029
|
);
|
|
1030
|
+
async function readDecisionInputs(rootDir) {
|
|
1031
|
+
return structuredClone(
|
|
1032
|
+
await inspectionRead(rootDir, "decision-store:inputs", async () => {
|
|
1033
|
+
const inputs = [];
|
|
1034
|
+
const diagnostics = [];
|
|
1035
|
+
let entries;
|
|
1036
|
+
try {
|
|
1037
|
+
entries = await fs6.readdir(await storePath(rootDir, ".mason/decisions"));
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
if (error.code !== "ENOENT")
|
|
1040
|
+
diagnostics.push({ path: ".mason/decisions", message: String(error) });
|
|
1041
|
+
return { inputs, diagnostics };
|
|
1042
|
+
}
|
|
1043
|
+
for (const entry of entries.sort()) {
|
|
1044
|
+
if (!entry.endsWith(".json")) continue;
|
|
1045
|
+
const relative = `.mason/decisions/${entry}`;
|
|
1046
|
+
try {
|
|
1047
|
+
const raw = await readBoundedFile(await storePath(rootDir, relative), 10 * 1024 * 1024);
|
|
1048
|
+
if (raw === null) throw new Error("file is not regular or exceeds 10 MiB");
|
|
1049
|
+
inputs.push([relative, raw]);
|
|
1050
|
+
} catch (error) {
|
|
1051
|
+
if (error.code === "ENOENT") inputs.push([relative, null]);
|
|
1052
|
+
else
|
|
1053
|
+
diagnostics.push({
|
|
1054
|
+
path: relative,
|
|
1055
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
return { inputs, diagnostics };
|
|
1060
|
+
})
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
647
1063
|
async function loadDecisionStore(rootDir) {
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
1064
|
+
return structuredClone(
|
|
1065
|
+
await inspectionRead(rootDir, "decision-store:parsed", async () => {
|
|
1066
|
+
const raw = await readDecisionInputs(rootDir);
|
|
1067
|
+
const records = [];
|
|
1068
|
+
const diagnostics = [...raw.diagnostics];
|
|
1069
|
+
for (const [relative, content] of raw.inputs) {
|
|
1070
|
+
try {
|
|
1071
|
+
const record = decisionSchema.parse(content === null ? null : JSON.parse(content));
|
|
1072
|
+
if (relative !== `.mason/decisions/${record.id}.json`)
|
|
1073
|
+
throw new Error("Record id does not match its filename");
|
|
1074
|
+
records.push(record);
|
|
1075
|
+
} catch (error) {
|
|
1076
|
+
diagnostics.push({
|
|
1077
|
+
path: relative,
|
|
1078
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return { records, diagnostics };
|
|
1083
|
+
})
|
|
1084
|
+
);
|
|
669
1085
|
}
|
|
670
1086
|
|
|
671
1087
|
// src/decisions/drift.ts
|
|
672
|
-
import
|
|
1088
|
+
import path10 from "path";
|
|
673
1089
|
async function computeDecisionDrift(rootDir, decisions) {
|
|
674
|
-
const resolvedRoot =
|
|
1090
|
+
const resolvedRoot = path10.resolve(rootDir);
|
|
675
1091
|
const store = decisions ? { records: decisions, diagnostics: [] } : await loadDecisionStore(resolvedRoot);
|
|
676
|
-
const report = {
|
|
677
|
-
|
|
1092
|
+
const report = {
|
|
1093
|
+
historyAvailable: true,
|
|
1094
|
+
totalDecisions: store.records.length,
|
|
1095
|
+
staleDecisions: {},
|
|
1096
|
+
freshness: {},
|
|
1097
|
+
diagnostics: store.diagnostics
|
|
1098
|
+
};
|
|
1099
|
+
const [head, workingTree] = await Promise.all([
|
|
1100
|
+
getCurrentGitHash(resolvedRoot),
|
|
1101
|
+
getWorkingTree(resolvedRoot)
|
|
1102
|
+
]);
|
|
678
1103
|
const changesByHash = /* @__PURE__ */ new Map();
|
|
679
|
-
const
|
|
1104
|
+
const baselines = store.records.filter((record) => record.status === "active").flatMap((record) => {
|
|
1105
|
+
const inspection = latestDecisionInspection(record);
|
|
1106
|
+
if (inspection) return [inspection.capture];
|
|
1107
|
+
return [.../* @__PURE__ */ new Set([effectiveDecision(record), record])].flatMap(
|
|
1108
|
+
(candidate) => candidate.version === 2 && candidate.capture ? [candidate.capture] : []
|
|
1109
|
+
);
|
|
1110
|
+
});
|
|
1111
|
+
const captures = await captureAnchorScopes(
|
|
1112
|
+
resolvedRoot,
|
|
1113
|
+
baselines.map((baseline) => baseline.anchors)
|
|
1114
|
+
);
|
|
1115
|
+
const currentCaptures = new Map(
|
|
1116
|
+
baselines.map((baseline, index2) => [JSON.stringify(baseline.anchors), captures[index2]])
|
|
1117
|
+
);
|
|
1118
|
+
const inspect = async (record, inspectionCapture) => {
|
|
680
1119
|
if (record.files.length === 0) return { freshness: "unknown", changedFiles: [] };
|
|
1120
|
+
const baseline = inspectionCapture ?? (record.version === 2 ? record.capture : void 0);
|
|
1121
|
+
if (baseline) {
|
|
1122
|
+
const current = currentCaptures.get(JSON.stringify(baseline.anchors));
|
|
1123
|
+
const hits2 = changedCaptureFiles(baseline, current).filter(
|
|
1124
|
+
(file) => matchingPaths(record.files, [file]).length
|
|
1125
|
+
);
|
|
1126
|
+
return {
|
|
1127
|
+
freshness: !baseline.complete || !current.complete ? "unknown" : hits2.length ? "changed" : "current",
|
|
1128
|
+
changedFiles: hits2
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
681
1131
|
let touched = changesByHash.get(record.refreshedHash);
|
|
682
1132
|
if (touched === void 0) {
|
|
683
1133
|
const changes = record.refreshedHash === head && head !== "unknown" ? [] : await getChangesWithStatus(resolvedRoot, record.refreshedHash);
|
|
@@ -687,15 +1137,20 @@ async function computeDecisionDrift(rootDir, decisions) {
|
|
|
687
1137
|
if (touched === null) report.historyAvailable = false;
|
|
688
1138
|
const hits = touched ? matchingPaths(record.files, touched) : [];
|
|
689
1139
|
const localHits = matchingPaths(record.files, workingTree.changedFiles);
|
|
690
|
-
return {
|
|
1140
|
+
return {
|
|
1141
|
+
freshness: touched === null || !workingTree.available ? "unknown" : hits.length || localHits.length ? "changed" : "current",
|
|
1142
|
+
changedFiles: [.../* @__PURE__ */ new Set([...hits, ...localHits])].sort()
|
|
1143
|
+
};
|
|
691
1144
|
};
|
|
692
1145
|
for (const record of store.records) {
|
|
693
1146
|
if (record.status !== "active") continue;
|
|
694
1147
|
const effective = effectiveDecision(record);
|
|
695
|
-
const
|
|
1148
|
+
const inspection = latestDecisionInspection(record);
|
|
1149
|
+
const state = await inspect(effective, inspection?.capture);
|
|
696
1150
|
report.freshness[record.id] = state.freshness;
|
|
697
1151
|
if (state.changedFiles.length) report.staleDecisions[record.id] = state.changedFiles;
|
|
698
|
-
if (effective !== record)
|
|
1152
|
+
if (effective !== record)
|
|
1153
|
+
(report.pendingProposals ??= {})[record.id] = await inspect(record, inspection?.capture);
|
|
699
1154
|
}
|
|
700
1155
|
return report;
|
|
701
1156
|
}
|
|
@@ -711,7 +1166,9 @@ var MIN_COCHANGE_RATE = 0.6;
|
|
|
711
1166
|
var MAX_COMMIT_FILES = 30;
|
|
712
1167
|
async function buildMatrix(resolvedRoot) {
|
|
713
1168
|
try {
|
|
714
|
-
const { stdout: shallow } = await exec("git", ["rev-parse", "--is-shallow-repository"], {
|
|
1169
|
+
const { stdout: shallow } = await exec("git", ["rev-parse", "--is-shallow-repository"], {
|
|
1170
|
+
cwd: resolvedRoot
|
|
1171
|
+
});
|
|
715
1172
|
if (shallow.trim() === "true") return null;
|
|
716
1173
|
const { stdout } = await exec(
|
|
717
1174
|
"git",
|
|
@@ -771,11 +1228,11 @@ async function findMissingPartners(resolvedRoot, changedFiles, existsOnDisk, all
|
|
|
771
1228
|
}
|
|
772
1229
|
|
|
773
1230
|
// src/review/evidence.ts
|
|
774
|
-
import
|
|
775
|
-
import { z as
|
|
1231
|
+
import path11 from "path";
|
|
1232
|
+
import { z as z9 } from "zod";
|
|
776
1233
|
|
|
777
1234
|
// src/review/evidence/vitest.ts
|
|
778
|
-
import { z as
|
|
1235
|
+
import { z as z6 } from "zod";
|
|
779
1236
|
|
|
780
1237
|
// src/review/evidence/paths.ts
|
|
781
1238
|
function evidencePath(value, sourceRoot, uri = false) {
|
|
@@ -811,26 +1268,30 @@ function messagePreview(message2) {
|
|
|
811
1268
|
}
|
|
812
1269
|
|
|
813
1270
|
// src/review/evidence/vitest.ts
|
|
814
|
-
var count =
|
|
815
|
-
var schema =
|
|
816
|
-
success:
|
|
1271
|
+
var count = z6.number().int().nonnegative();
|
|
1272
|
+
var schema = z6.object({
|
|
1273
|
+
success: z6.boolean(),
|
|
817
1274
|
numTotalTests: count,
|
|
818
1275
|
numPassedTests: count,
|
|
819
1276
|
numFailedTests: count,
|
|
820
1277
|
numPendingTests: count,
|
|
821
1278
|
numTodoTests: count,
|
|
822
1279
|
numFailedTestSuites: count,
|
|
823
|
-
testResults:
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
1280
|
+
testResults: z6.array(
|
|
1281
|
+
z6.object({
|
|
1282
|
+
name: z6.string().min(1),
|
|
1283
|
+
status: z6.enum(["passed", "failed"]),
|
|
1284
|
+
message: z6.string().optional(),
|
|
1285
|
+
assertionResults: z6.array(
|
|
1286
|
+
z6.object({
|
|
1287
|
+
fullName: z6.string(),
|
|
1288
|
+
status: z6.enum(["passed", "failed", "pending", "skipped", "todo", "disabled"]),
|
|
1289
|
+
failureMessages: z6.array(z6.string()).nullable().optional(),
|
|
1290
|
+
location: z6.object({ line: count, column: count }).nullable().optional()
|
|
1291
|
+
})
|
|
1292
|
+
)
|
|
1293
|
+
})
|
|
1294
|
+
)
|
|
834
1295
|
});
|
|
835
1296
|
function parseVitest(raw, sourceRoot) {
|
|
836
1297
|
const report = schema.parse(raw);
|
|
@@ -838,7 +1299,8 @@ function parseVitest(raw, sourceRoot) {
|
|
|
838
1299
|
let passed = 0, failed = 0, skipped = 0;
|
|
839
1300
|
for (const [index2, suite] of report.testResults.entries()) {
|
|
840
1301
|
const file = evidencePath(suite.name, sourceRoot);
|
|
841
|
-
if (!file)
|
|
1302
|
+
if (!file)
|
|
1303
|
+
diagnostics.push(`Test path is outside the declared checkout or invalid: ${suite.name}`);
|
|
842
1304
|
let suiteFailures = 0;
|
|
843
1305
|
for (const [testIndex, test] of suite.assertionResults.entries()) {
|
|
844
1306
|
if (test.status === "passed") passed++;
|
|
@@ -850,58 +1312,118 @@ function parseVitest(raw, sourceRoot) {
|
|
|
850
1312
|
...messagePreview([test.fullName, ...test.failureMessages ?? []].join("\n")),
|
|
851
1313
|
severity: "error",
|
|
852
1314
|
state: "active",
|
|
853
|
-
locations: file ? [
|
|
1315
|
+
locations: file ? [
|
|
1316
|
+
{
|
|
1317
|
+
file,
|
|
1318
|
+
...test.location ? { line: test.location.line, column: test.location.column } : {}
|
|
1319
|
+
}
|
|
1320
|
+
] : []
|
|
854
1321
|
});
|
|
855
1322
|
} else skipped++;
|
|
856
1323
|
}
|
|
857
1324
|
if (suite.status === "failed" && !suiteFailures) {
|
|
858
|
-
findings.push({
|
|
1325
|
+
findings.push({
|
|
1326
|
+
id: `${index2}:suite`,
|
|
1327
|
+
...messagePreview(suite.message || `Test suite failed: ${suite.name}`),
|
|
1328
|
+
severity: "error",
|
|
1329
|
+
state: "active",
|
|
1330
|
+
locations: file ? [{ file }] : []
|
|
1331
|
+
});
|
|
859
1332
|
}
|
|
860
1333
|
}
|
|
861
1334
|
if (passed !== report.numPassedTests || failed !== report.numFailedTests || skipped !== report.numPendingTests + report.numTodoTests || passed + failed + skipped !== report.numTotalTests) {
|
|
862
1335
|
throw new Error("Vitest summary counts disagree with its assertion results");
|
|
863
1336
|
}
|
|
864
1337
|
const hasFailures = failed > 0 || report.numFailedTestSuites > 0 || report.testResults.some((s) => s.status === "failed");
|
|
865
|
-
if (report.success && hasFailures)
|
|
1338
|
+
if (report.success && hasFailures)
|
|
1339
|
+
throw new Error("Vitest success conflicts with failed tests or suites");
|
|
866
1340
|
const outcome = hasFailures || !report.success ? "failed" : !report.numTotalTests ? "unavailable" : !passed ? "skipped" : "passed";
|
|
867
|
-
if (!report.numTotalTests)
|
|
1341
|
+
if (!report.numTotalTests)
|
|
1342
|
+
diagnostics.push("No tests executed; an empty report is not passing test evidence.");
|
|
868
1343
|
if (skipped) diagnostics.push(`${skipped} tests were skipped, pending, disabled, or todo.`);
|
|
869
1344
|
return {
|
|
870
1345
|
outcome,
|
|
871
1346
|
findings,
|
|
872
|
-
counts: {
|
|
1347
|
+
counts: {
|
|
1348
|
+
total: report.numTotalTests,
|
|
1349
|
+
passed,
|
|
1350
|
+
failed,
|
|
1351
|
+
skipped,
|
|
1352
|
+
failedSuites: report.numFailedTestSuites
|
|
1353
|
+
},
|
|
873
1354
|
incomplete: skipped > 0 || diagnostics.length > 0,
|
|
874
1355
|
diagnostics
|
|
875
1356
|
};
|
|
876
1357
|
}
|
|
877
1358
|
|
|
878
1359
|
// src/review/evidence/sarif.ts
|
|
879
|
-
import { z as
|
|
880
|
-
var index =
|
|
881
|
-
var artifact =
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1360
|
+
import { z as z7 } from "zod";
|
|
1361
|
+
var index = z7.number().int().nonnegative();
|
|
1362
|
+
var artifact = z7.object({
|
|
1363
|
+
uri: z7.string().optional(),
|
|
1364
|
+
uriBaseId: z7.string().optional(),
|
|
1365
|
+
index: index.optional()
|
|
1366
|
+
});
|
|
1367
|
+
var message = z7.object({
|
|
1368
|
+
text: z7.string().optional(),
|
|
1369
|
+
markdown: z7.string().optional(),
|
|
1370
|
+
id: z7.string().optional(),
|
|
1371
|
+
arguments: z7.array(z7.string()).optional()
|
|
1372
|
+
});
|
|
1373
|
+
var level = z7.enum(["error", "warning", "note", "none"]);
|
|
1374
|
+
var location = z7.object({
|
|
1375
|
+
physicalLocation: z7.object({
|
|
1376
|
+
artifactLocation: artifact.optional(),
|
|
1377
|
+
region: z7.object({ startLine: index.optional(), startColumn: index.optional() }).optional()
|
|
1378
|
+
}).optional()
|
|
1379
|
+
});
|
|
1380
|
+
var schema2 = z7.object({
|
|
1381
|
+
version: z7.literal("2.1.0"),
|
|
1382
|
+
runs: z7.array(
|
|
1383
|
+
z7.object({
|
|
1384
|
+
tool: z7.object({
|
|
1385
|
+
driver: z7.object({
|
|
1386
|
+
name: z7.string().min(1),
|
|
1387
|
+
rules: z7.array(
|
|
1388
|
+
z7.object({
|
|
1389
|
+
id: z7.string(),
|
|
1390
|
+
defaultConfiguration: z7.object({ level: level.optional() }).optional(),
|
|
1391
|
+
messageStrings: z7.record(message).optional()
|
|
1392
|
+
})
|
|
1393
|
+
).optional(),
|
|
1394
|
+
globalMessageStrings: z7.record(message).optional()
|
|
1395
|
+
})
|
|
1396
|
+
}),
|
|
1397
|
+
invocations: z7.array(
|
|
1398
|
+
z7.object({
|
|
1399
|
+
executionSuccessful: z7.boolean(),
|
|
1400
|
+
commandLine: z7.string().optional(),
|
|
1401
|
+
toolExecutionNotifications: z7.array(z7.object({ level: level.optional(), message })).optional()
|
|
1402
|
+
})
|
|
1403
|
+
).optional(),
|
|
1404
|
+
versionControlProvenance: z7.array(z7.object({ revisionId: z7.string().optional() })).optional(),
|
|
1405
|
+
originalUriBaseIds: z7.record(artifact).optional(),
|
|
1406
|
+
artifacts: z7.array(z7.object({ location: artifact.optional() })).optional(),
|
|
1407
|
+
results: z7.array(
|
|
1408
|
+
z7.object({
|
|
1409
|
+
ruleId: z7.string().optional(),
|
|
1410
|
+
ruleIndex: index.optional(),
|
|
1411
|
+
message,
|
|
1412
|
+
level: level.optional(),
|
|
1413
|
+
kind: z7.enum(["fail", "pass", "open", "informational", "notApplicable", "review"]).optional(),
|
|
1414
|
+
baselineState: z7.enum(["new", "unchanged", "updated", "absent"]).optional(),
|
|
1415
|
+
suppressions: z7.array(
|
|
1416
|
+
z7.object({
|
|
1417
|
+
kind: z7.enum(["inSource", "external"]),
|
|
1418
|
+
status: z7.enum(["accepted", "underReview", "rejected"]).optional()
|
|
1419
|
+
})
|
|
1420
|
+
).nullable().optional(),
|
|
1421
|
+
locations: z7.array(location).optional(),
|
|
1422
|
+
relatedLocations: z7.array(location).optional()
|
|
1423
|
+
})
|
|
1424
|
+
).optional()
|
|
1425
|
+
})
|
|
1426
|
+
)
|
|
905
1427
|
});
|
|
906
1428
|
function parseSarif(raw, sourceRoot) {
|
|
907
1429
|
const report = schema2.parse(raw), findings = [], diagnostics = [];
|
|
@@ -910,13 +1432,17 @@ function parseSarif(raw, sourceRoot) {
|
|
|
910
1432
|
const reportedCommits = [], reportedCommands = [], reportedTools = [];
|
|
911
1433
|
for (const [runIndex, run] of report.runs.entries()) {
|
|
912
1434
|
reportedTools.push(run.tool.driver.name);
|
|
913
|
-
reportedCommits.push(
|
|
1435
|
+
reportedCommits.push(
|
|
1436
|
+
...(run.versionControlProvenance ?? []).flatMap((v) => v.revisionId ? [v.revisionId] : [])
|
|
1437
|
+
);
|
|
914
1438
|
if (!run.invocations?.length) executed = false;
|
|
915
1439
|
for (const invocation of run.invocations ?? []) {
|
|
916
1440
|
if (invocation.commandLine) reportedCommands.push(invocation.commandLine);
|
|
917
1441
|
if (!invocation.executionSuccessful) executionFailed = true;
|
|
918
1442
|
for (const notification of invocation.toolExecutionNotifications ?? []) {
|
|
919
|
-
diagnostics.push(
|
|
1443
|
+
diagnostics.push(
|
|
1444
|
+
notification.message.text ?? notification.message.markdown ?? "SARIF tool execution notification"
|
|
1445
|
+
);
|
|
920
1446
|
if (notification.level === "error") executionFailed = true;
|
|
921
1447
|
}
|
|
922
1448
|
}
|
|
@@ -959,22 +1485,39 @@ function parseSarif(raw, sourceRoot) {
|
|
|
959
1485
|
const rule = result.ruleIndex !== void 0 ? run.tool.driver.rules?.[result.ruleIndex] : run.tool.driver.rules?.find((rule2) => rule2.id === result.ruleId);
|
|
960
1486
|
const template = result.message.id ? rule?.messageStrings?.[result.message.id] ?? run.tool.driver.globalMessageStrings?.[result.message.id] : void 0;
|
|
961
1487
|
const rawMessage = result.message.text ?? result.message.markdown ?? template?.text ?? template?.markdown;
|
|
962
|
-
if (!rawMessage)
|
|
1488
|
+
if (!rawMessage)
|
|
1489
|
+
diagnostics.push(`Message cannot be resolved for result ${runIndex}:${resultIndex}.`);
|
|
963
1490
|
const rendered = (rawMessage ?? `Unresolved SARIF message ${result.message.id ?? ""}`).replace(/\{(\d+)\}/g, (match, n) => result.message.arguments?.[Number(n)] ?? match);
|
|
964
1491
|
const locations = [];
|
|
965
1492
|
for (const entry of [...result.locations ?? [], ...result.relatedLocations ?? []]) {
|
|
966
1493
|
const ref = entry.physicalLocation?.artifactLocation;
|
|
967
1494
|
const uri = ref ? resolve(ref) : null;
|
|
968
1495
|
const file = uri ? evidencePath(uri, sourceRoot, true) : null;
|
|
969
|
-
if (file)
|
|
970
|
-
|
|
1496
|
+
if (file)
|
|
1497
|
+
locations.push({
|
|
1498
|
+
file,
|
|
1499
|
+
line: entry.physicalLocation?.region?.startLine,
|
|
1500
|
+
column: entry.physicalLocation?.region?.startColumn
|
|
1501
|
+
});
|
|
1502
|
+
else
|
|
1503
|
+
diagnostics.push(
|
|
1504
|
+
`Unresolved or out-of-checkout location for result ${runIndex}:${resultIndex}.`
|
|
1505
|
+
);
|
|
971
1506
|
}
|
|
972
1507
|
const severity = result.level ?? rule?.defaultConfiguration?.level ?? (kind === "fail" ? "warning" : "none");
|
|
973
|
-
findings.push({
|
|
1508
|
+
findings.push({
|
|
1509
|
+
id: `${runIndex}:${resultIndex}`,
|
|
1510
|
+
ruleId: result.ruleId ?? rule?.id,
|
|
1511
|
+
...messagePreview(rendered),
|
|
1512
|
+
severity: severity === "none" ? "note" : severity,
|
|
1513
|
+
state,
|
|
1514
|
+
locations
|
|
1515
|
+
});
|
|
974
1516
|
}
|
|
975
1517
|
}
|
|
976
1518
|
if (!report.runs.length) diagnostics.push("SARIF contains no analysis runs.");
|
|
977
|
-
if (executionFailed)
|
|
1519
|
+
if (executionFailed)
|
|
1520
|
+
diagnostics.push("The SARIF tool reported an unsuccessful analysis invocation.");
|
|
978
1521
|
return {
|
|
979
1522
|
outcome: executionFailed || !report.runs.length || report.runs.some((r) => !r.results) ? "unavailable" : active ? "failed" : "passed",
|
|
980
1523
|
findings,
|
|
@@ -989,20 +1532,24 @@ function parseSarif(raw, sourceRoot) {
|
|
|
989
1532
|
}
|
|
990
1533
|
|
|
991
1534
|
// src/review/evidence/check.ts
|
|
992
|
-
import { z as
|
|
993
|
-
var schema3 =
|
|
994
|
-
version:
|
|
995
|
-
commit:
|
|
996
|
-
results:
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1535
|
+
import { z as z8 } from "zod";
|
|
1536
|
+
var schema3 = z8.object({
|
|
1537
|
+
version: z8.literal(1),
|
|
1538
|
+
commit: z8.string().regex(/^(?:[a-fA-F0-9]{40}|[a-fA-F0-9]{64})$/).optional(),
|
|
1539
|
+
results: z8.array(
|
|
1540
|
+
z8.object({
|
|
1541
|
+
name: z8.string().min(1).max(1e3),
|
|
1542
|
+
status: z8.enum(["passed", "failed", "skipped"]),
|
|
1543
|
+
message: z8.string().max(1e5).optional(),
|
|
1544
|
+
locations: z8.array(
|
|
1545
|
+
z8.object({
|
|
1546
|
+
file: z8.string().min(1).max(4e3),
|
|
1547
|
+
line: z8.number().int().positive().optional(),
|
|
1548
|
+
column: z8.number().int().positive().optional()
|
|
1549
|
+
})
|
|
1550
|
+
).max(100).default([])
|
|
1551
|
+
})
|
|
1552
|
+
).max(1e5)
|
|
1006
1553
|
});
|
|
1007
1554
|
function parseCheck(raw, sourceRoot) {
|
|
1008
1555
|
const report = schema3.parse(raw);
|
|
@@ -1013,21 +1560,26 @@ function parseCheck(raw, sourceRoot) {
|
|
|
1013
1560
|
const locations = result.locations.flatMap((location2) => {
|
|
1014
1561
|
const file = evidencePath(location2.file, sourceRoot);
|
|
1015
1562
|
if (!file) {
|
|
1016
|
-
diagnostics.push(
|
|
1563
|
+
diagnostics.push(
|
|
1564
|
+
`Check location is outside the declared checkout or invalid: ${location2.file}`
|
|
1565
|
+
);
|
|
1017
1566
|
return [];
|
|
1018
1567
|
}
|
|
1019
1568
|
return [{ ...location2, file }];
|
|
1020
1569
|
});
|
|
1021
|
-
if (result.status === "failed")
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1570
|
+
if (result.status === "failed")
|
|
1571
|
+
findings.push({
|
|
1572
|
+
id: String(index2),
|
|
1573
|
+
...messagePreview([result.name, result.message].filter(Boolean).join("\n")),
|
|
1574
|
+
severity: "error",
|
|
1575
|
+
state: "active",
|
|
1576
|
+
locations
|
|
1577
|
+
});
|
|
1028
1578
|
}
|
|
1029
|
-
if (!counts.total)
|
|
1030
|
-
|
|
1579
|
+
if (!counts.total)
|
|
1580
|
+
diagnostics.push("No check results were recorded; an empty report is not passing evidence.");
|
|
1581
|
+
if (counts.skipped)
|
|
1582
|
+
diagnostics.push(`${counts.skipped} checks were skipped; verification is incomplete.`);
|
|
1031
1583
|
return {
|
|
1032
1584
|
outcome: counts.failed ? "failed" : !counts.total ? "unavailable" : !counts.passed ? "skipped" : "passed",
|
|
1033
1585
|
counts,
|
|
@@ -1039,28 +1591,38 @@ function parseCheck(raw, sourceRoot) {
|
|
|
1039
1591
|
}
|
|
1040
1592
|
|
|
1041
1593
|
// src/review/evidence.ts
|
|
1042
|
-
var checkSchema =
|
|
1043
|
-
id:
|
|
1044
|
-
kind:
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1594
|
+
var checkSchema = z9.object({
|
|
1595
|
+
id: z9.string().min(1).max(100),
|
|
1596
|
+
kind: z9.enum([
|
|
1597
|
+
"tests",
|
|
1598
|
+
"static-analysis",
|
|
1599
|
+
"security",
|
|
1600
|
+
"complexity",
|
|
1601
|
+
"duplication",
|
|
1602
|
+
"documentation"
|
|
1603
|
+
]),
|
|
1604
|
+
tool: z9.string().min(1).max(200),
|
|
1605
|
+
command: z9.string().min(1).max(2e3),
|
|
1606
|
+
commit: z9.string().regex(/^(?:[a-fA-F0-9]{40}|[a-fA-F0-9]{64})$/).nullable().optional(),
|
|
1607
|
+
workingTreeClean: z9.boolean().optional(),
|
|
1608
|
+
source: z9.string().max(2e3).optional(),
|
|
1609
|
+
sourceRoot: z9.string().min(1).max(4e3).optional(),
|
|
1610
|
+
status: z9.enum(["completed", "skipped", "unavailable"]).default("completed"),
|
|
1611
|
+
reason: z9.string().min(1).max(2e3).optional(),
|
|
1612
|
+
exitCode: z9.number().int().nullable().optional(),
|
|
1613
|
+
environment: z9.object({
|
|
1614
|
+
toolVersion: z9.string().min(1).max(200),
|
|
1615
|
+
runtime: z9.string().min(1).max(200).optional(),
|
|
1616
|
+
platform: z9.string().min(1).max(200).optional(),
|
|
1617
|
+
configuration: z9.array(z9.string().min(1).max(4e3)).max(100).optional()
|
|
1059
1618
|
}).optional(),
|
|
1060
|
-
report:
|
|
1619
|
+
report: z9.object({
|
|
1620
|
+
format: z9.enum(["vitest-json", "sarif", "mason-check-json"]),
|
|
1621
|
+
path: z9.string().min(1).max(4e3)
|
|
1622
|
+
}).optional()
|
|
1061
1623
|
});
|
|
1062
1624
|
function relativeArtifact(root, file) {
|
|
1063
|
-
const relative =
|
|
1625
|
+
const relative = path11.isAbsolute(file) ? path11.relative(root, file) : file;
|
|
1064
1626
|
const normalized = normalizeRepoPath(relative);
|
|
1065
1627
|
if (!normalized) throw new Error(`Evidence artifact must be inside the repository: ${file}`);
|
|
1066
1628
|
return normalized;
|
|
@@ -1070,17 +1632,37 @@ function linkFinding(finding, changed, pairs, decisions, freshness) {
|
|
|
1070
1632
|
const paired = pairs.filter((pair) => located.includes(pair.test));
|
|
1071
1633
|
const relatedChangedFiles = located.filter((file) => changed.has(file)).map((file) => ({ file, relationship: "direct" }));
|
|
1072
1634
|
for (const pair of paired) {
|
|
1073
|
-
if (changed.has(pair.source) && !relatedChangedFiles.some((f) => f.file === pair.source))
|
|
1635
|
+
if (changed.has(pair.source) && !relatedChangedFiles.some((f) => f.file === pair.source))
|
|
1636
|
+
relatedChangedFiles.push({
|
|
1637
|
+
file: pair.source,
|
|
1638
|
+
relationship: "paired-test",
|
|
1639
|
+
confidence: pair.confidence
|
|
1640
|
+
});
|
|
1074
1641
|
}
|
|
1075
1642
|
const allFiles = [.../* @__PURE__ */ new Set([...located, ...paired.map((pair) => pair.source)])];
|
|
1076
1643
|
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 }) => {
|
|
1077
1644
|
const state = freshness[d.id] ?? "unknown", provenance = decisionProvenance(d, state);
|
|
1078
|
-
return {
|
|
1645
|
+
return {
|
|
1646
|
+
id: d.id,
|
|
1647
|
+
title: d.title,
|
|
1648
|
+
owner: provenance.owner,
|
|
1649
|
+
freshness: state,
|
|
1650
|
+
reviewRequired: provenance.reviewRequired,
|
|
1651
|
+
viaFiles
|
|
1652
|
+
};
|
|
1079
1653
|
});
|
|
1080
|
-
return {
|
|
1654
|
+
return {
|
|
1655
|
+
...finding,
|
|
1656
|
+
relatedChangedFiles,
|
|
1657
|
+
acceptedDecisions: acceptedDecisions.slice(0, 5),
|
|
1658
|
+
totalAcceptedDecisions: acceptedDecisions.length
|
|
1659
|
+
};
|
|
1081
1660
|
}
|
|
1082
1661
|
async function collectReviewEvidence(root, manifests, changedFiles, decisions, decisionFreshness = {}, reviewedHead) {
|
|
1083
|
-
const [headHash, workingTree] = await Promise.all([
|
|
1662
|
+
const [headHash, workingTree] = await Promise.all([
|
|
1663
|
+
reviewedHead ?? getCurrentGitHash(root),
|
|
1664
|
+
getWorkingTree(root)
|
|
1665
|
+
]);
|
|
1084
1666
|
const output = {
|
|
1085
1667
|
version: 1,
|
|
1086
1668
|
scope: "committed",
|
|
@@ -1094,12 +1676,13 @@ async function collectReviewEvidence(root, manifests, changedFiles, decisions, d
|
|
|
1094
1676
|
};
|
|
1095
1677
|
const seen = /* @__PURE__ */ new Set(), changed = new Set(changedFiles);
|
|
1096
1678
|
let pairs;
|
|
1097
|
-
if (manifests.length > 10)
|
|
1679
|
+
if (manifests.length > 10)
|
|
1680
|
+
output.diagnostics.push("Only the first 10 evidence manifests were imported.");
|
|
1098
1681
|
for (const supplied of manifests.slice(0, 10)) {
|
|
1099
1682
|
let manifest, raw;
|
|
1100
1683
|
try {
|
|
1101
1684
|
manifest = relativeArtifact(root, supplied);
|
|
1102
|
-
raw =
|
|
1685
|
+
raw = z9.object({ version: z9.literal(1), checks: z9.array(z9.unknown()) }).parse(await readStoreJson(root, manifest));
|
|
1103
1686
|
} catch (error) {
|
|
1104
1687
|
output.diagnostics.push(`${supplied}: ${String(error)}`);
|
|
1105
1688
|
continue;
|
|
@@ -1117,7 +1700,9 @@ async function collectReviewEvidence(root, manifests, changedFiles, decisions, d
|
|
|
1117
1700
|
}
|
|
1118
1701
|
const check = input.data;
|
|
1119
1702
|
if (seen.has(check.id)) {
|
|
1120
|
-
output.diagnostics.push(
|
|
1703
|
+
output.diagnostics.push(
|
|
1704
|
+
`Duplicate check id ${check.id}; give different runs distinct ids.`
|
|
1705
|
+
);
|
|
1121
1706
|
continue;
|
|
1122
1707
|
}
|
|
1123
1708
|
seen.add(check.id);
|
|
@@ -1142,22 +1727,34 @@ async function collectReviewEvidence(root, manifests, changedFiles, decisions, d
|
|
|
1142
1727
|
...check.environment ? { environment: check.environment } : {}
|
|
1143
1728
|
};
|
|
1144
1729
|
output.checks.push(result);
|
|
1145
|
-
if (check.workingTreeClean !== true)
|
|
1146
|
-
|
|
1730
|
+
if (check.workingTreeClean !== true)
|
|
1731
|
+
result.diagnostics.push(
|
|
1732
|
+
"The check's working tree was dirty or not recorded; its results cannot be attributed to the claimed commit alone."
|
|
1733
|
+
);
|
|
1734
|
+
else if (result.commit && headHash !== "unknown")
|
|
1735
|
+
result.freshness = result.commit === headHash.toLowerCase() ? "current" : "stale";
|
|
1147
1736
|
else result.diagnostics.push("The tested commit or reviewed HEAD is unknown.");
|
|
1148
1737
|
if (check.status !== "completed") {
|
|
1149
1738
|
result.outcome = check.status;
|
|
1150
|
-
result.diagnostics.push(
|
|
1739
|
+
result.diagnostics.push(
|
|
1740
|
+
check.reason ?? "No reason was recorded for this skipped or unavailable check."
|
|
1741
|
+
);
|
|
1151
1742
|
continue;
|
|
1152
1743
|
}
|
|
1153
1744
|
try {
|
|
1154
1745
|
if (!check.report) throw new Error("Completed check has no report artifact.");
|
|
1155
|
-
if (check.report.format !== "mason-check-json" && check.kind === "tests" !== (check.report.format === "vitest-json"))
|
|
1746
|
+
if (check.report.format !== "mason-check-json" && check.kind === "tests" !== (check.report.format === "vitest-json"))
|
|
1747
|
+
throw new Error(
|
|
1748
|
+
"Test checks require vitest-json or mason-check-json; analysis checks require sarif or mason-check-json."
|
|
1749
|
+
);
|
|
1156
1750
|
const file = relativeArtifact(root, check.report.path);
|
|
1157
1751
|
const report = await readStoreJson(root, file);
|
|
1158
1752
|
if (report === null) throw new Error(`Report artifact is missing: ${file}`);
|
|
1159
1753
|
const sourceRoot = check.sourceRoot ?? root;
|
|
1160
|
-
if (!
|
|
1754
|
+
if (!path11.posix.isAbsolute(sourceRoot) && !/^[A-Za-z]:[\\/]/.test(sourceRoot))
|
|
1755
|
+
throw new Error(
|
|
1756
|
+
"sourceRoot must identify the absolute checkout root on the check runner."
|
|
1757
|
+
);
|
|
1161
1758
|
const parsed = check.report.format === "vitest-json" ? parseVitest(report, sourceRoot) : check.report.format === "mason-check-json" ? parseCheck(report, sourceRoot) : parseSarif(report, sourceRoot);
|
|
1162
1759
|
result.outcome = parsed.outcome;
|
|
1163
1760
|
result.counts = parsed.counts;
|
|
@@ -1165,18 +1762,26 @@ async function collectReviewEvidence(root, manifests, changedFiles, decisions, d
|
|
|
1165
1762
|
result.diagnostics.push(...parsed.diagnostics);
|
|
1166
1763
|
result.reportedCommands = parsed.reportedCommands;
|
|
1167
1764
|
result.reportedTools = parsed.reportedTools;
|
|
1168
|
-
if (parsed.reportedCommits?.some(
|
|
1765
|
+
if (parsed.reportedCommits?.some(
|
|
1766
|
+
(commit) => !result.commit || commit.toLowerCase() !== result.commit
|
|
1767
|
+
)) {
|
|
1169
1768
|
result.freshness = "unknown";
|
|
1170
1769
|
result.incomplete = true;
|
|
1171
|
-
result.diagnostics.push(
|
|
1770
|
+
result.diagnostics.push(
|
|
1771
|
+
"Report revision metadata conflicts with, or cannot be tied to, the manifest's tested commit."
|
|
1772
|
+
);
|
|
1172
1773
|
}
|
|
1173
1774
|
if (check.exitCode == null && !parsed.executed) {
|
|
1174
1775
|
result.incomplete = true;
|
|
1175
|
-
result.diagnostics.push(
|
|
1776
|
+
result.diagnostics.push(
|
|
1777
|
+
"Neither an exit code nor successful analysis invocation was recorded; completion cannot be confirmed."
|
|
1778
|
+
);
|
|
1176
1779
|
}
|
|
1177
1780
|
if (check.exitCode != null && check.exitCode !== 0 && result.outcome === "passed") {
|
|
1178
1781
|
result.outcome = "failed";
|
|
1179
|
-
result.diagnostics.push(
|
|
1782
|
+
result.diagnostics.push(
|
|
1783
|
+
`Check command exited ${check.exitCode} despite a report with no active failures.`
|
|
1784
|
+
);
|
|
1180
1785
|
}
|
|
1181
1786
|
if (check.kind === "tests" && pairs === void 0) {
|
|
1182
1787
|
try {
|
|
@@ -1188,11 +1793,18 @@ async function collectReviewEvidence(root, manifests, changedFiles, decisions, d
|
|
|
1188
1793
|
}
|
|
1189
1794
|
result.totalFindings = parsed.findings.length;
|
|
1190
1795
|
const testPairs = check.kind === "tests" ? pairs ?? [] : [];
|
|
1191
|
-
const relevant = /* @__PURE__ */ new Set([
|
|
1796
|
+
const relevant = /* @__PURE__ */ new Set([
|
|
1797
|
+
...changed,
|
|
1798
|
+
...testPairs.filter((p) => changed.has(p.source)).map((p) => p.test)
|
|
1799
|
+
]);
|
|
1192
1800
|
const touchesChange = (f) => f.locations.some((l) => relevant.has(l.file));
|
|
1193
|
-
parsed.findings.sort(
|
|
1801
|
+
parsed.findings.sort(
|
|
1802
|
+
(a, b) => Number(b.state === "active") - Number(a.state === "active") || Number(touchesChange(b)) - Number(touchesChange(a))
|
|
1803
|
+
);
|
|
1194
1804
|
result.findings = parsed.findings.slice(0, MAX_FINDINGS).map((f) => linkFinding(f, changed, testPairs, decisions, decisionFreshness));
|
|
1195
|
-
result.truncated = parsed.findings.length > MAX_FINDINGS || result.findings.some(
|
|
1805
|
+
result.truncated = parsed.findings.length > MAX_FINDINGS || result.findings.some(
|
|
1806
|
+
(f) => f.truncated || f.totalAcceptedDecisions > f.acceptedDecisions.length
|
|
1807
|
+
);
|
|
1196
1808
|
} catch (error) {
|
|
1197
1809
|
result.outcome = "unavailable";
|
|
1198
1810
|
result.incomplete = true;
|
|
@@ -1205,9 +1817,12 @@ async function collectReviewEvidence(root, manifests, changedFiles, decisions, d
|
|
|
1205
1817
|
output.summary[check.outcome]++;
|
|
1206
1818
|
if (check.freshness !== "current") output.summary[check.freshness]++;
|
|
1207
1819
|
}
|
|
1208
|
-
if (output.checks.some((c) => c.outcome === "failed" && c.freshness === "current"))
|
|
1209
|
-
|
|
1210
|
-
else if (output.
|
|
1820
|
+
if (output.checks.some((c) => c.outcome === "failed" && c.freshness === "current"))
|
|
1821
|
+
output.status = "failed";
|
|
1822
|
+
else if (!output.checks.length || output.checks.every((c) => c.outcome === "unavailable"))
|
|
1823
|
+
output.status = "unavailable";
|
|
1824
|
+
else if (output.diagnostics.length || output.checks.some((c) => c.outcome !== "passed" || c.freshness !== "current" || c.incomplete))
|
|
1825
|
+
output.status = "incomplete";
|
|
1211
1826
|
else output.status = "passed";
|
|
1212
1827
|
return output;
|
|
1213
1828
|
}
|
|
@@ -1241,7 +1856,7 @@ function anchorsTouched(record, changedFiles) {
|
|
|
1241
1856
|
return matchingPaths(record.files, changedFiles);
|
|
1242
1857
|
}
|
|
1243
1858
|
async function computeReview(rootDir, base, options = {}) {
|
|
1244
|
-
const resolvedRoot =
|
|
1859
|
+
const resolvedRoot = path12.resolve(rootDir);
|
|
1245
1860
|
const head = await getCurrentGitHash(resolvedRoot);
|
|
1246
1861
|
if (head === "unknown") return null;
|
|
1247
1862
|
const mergeBase = await resolveMergeBase(resolvedRoot, base, head);
|
|
@@ -1264,15 +1879,26 @@ async function computeReview(rootDir, base, options = {}) {
|
|
|
1264
1879
|
report.diagnostics = store.diagnostics;
|
|
1265
1880
|
const decisionDrift = await computeDecisionDrift(resolvedRoot, store.records);
|
|
1266
1881
|
if (options.evidence !== void 0) {
|
|
1267
|
-
report.evidence = await collectReviewEvidence(
|
|
1882
|
+
report.evidence = await collectReviewEvidence(
|
|
1883
|
+
resolvedRoot,
|
|
1884
|
+
options.evidence,
|
|
1885
|
+
changedFiles,
|
|
1886
|
+
store.records,
|
|
1887
|
+
decisionDrift.freshness,
|
|
1888
|
+
head
|
|
1889
|
+
);
|
|
1268
1890
|
if (store.diagnostics.length) {
|
|
1269
|
-
report.evidence.diagnostics.push(
|
|
1891
|
+
report.evidence.diagnostics.push(
|
|
1892
|
+
"Invalid decision records make knowledge associations incomplete; consult review diagnostics."
|
|
1893
|
+
);
|
|
1270
1894
|
if (report.evidence.status === "passed") report.evidence.status = "incomplete";
|
|
1271
1895
|
}
|
|
1272
1896
|
}
|
|
1273
1897
|
const finalize = async () => {
|
|
1274
1898
|
if (report.evidence && await getCurrentGitHash(resolvedRoot) !== head) {
|
|
1275
|
-
report.evidence.diagnostics.push(
|
|
1899
|
+
report.evidence.diagnostics.push(
|
|
1900
|
+
"HEAD changed during the review; rerun to obtain consistent change and knowledge associations."
|
|
1901
|
+
);
|
|
1276
1902
|
for (const check of report.evidence.checks) check.freshness = "unknown";
|
|
1277
1903
|
report.evidence.summary.stale = 0;
|
|
1278
1904
|
report.evidence.summary.unknown = report.evidence.checks.length;
|
|
@@ -1291,7 +1917,7 @@ async function computeReview(rootDir, base, options = {}) {
|
|
|
1291
1917
|
analyzed,
|
|
1292
1918
|
async (relPath) => {
|
|
1293
1919
|
try {
|
|
1294
|
-
await
|
|
1920
|
+
await fs7.access(path12.join(resolvedRoot, relPath));
|
|
1295
1921
|
return true;
|
|
1296
1922
|
} catch {
|
|
1297
1923
|
return false;
|
|
@@ -1311,7 +1937,11 @@ async function computeReview(rootDir, base, options = {}) {
|
|
|
1311
1937
|
const touched = anchorsTouched(effective, changedFiles);
|
|
1312
1938
|
const proposalTouched = effective !== record ? anchorsTouched(record, changedFiles) : [];
|
|
1313
1939
|
if (touched.length > 0 || proposalTouched.length > 0) {
|
|
1314
|
-
const { pendingProposal, ...knowledge } = decisionKnowledge(
|
|
1940
|
+
const { pendingProposal, ...knowledge } = decisionKnowledge(
|
|
1941
|
+
record,
|
|
1942
|
+
decisionDrift.freshness?.[record.id] ?? "unknown",
|
|
1943
|
+
decisionDrift.pendingProposals?.[record.id]?.freshness ?? "unknown"
|
|
1944
|
+
);
|
|
1315
1945
|
report.touchedDecisions.push({
|
|
1316
1946
|
...knowledge,
|
|
1317
1947
|
...pendingProposal ? { pendingProposal: { ...pendingProposal, touchedFiles: proposalTouched } } : {},
|
|
@@ -1388,36 +2018,71 @@ function parseArgs(argv) {
|
|
|
1388
2018
|
throw new Error(`Unknown argument: ${arg}`);
|
|
1389
2019
|
}
|
|
1390
2020
|
}
|
|
1391
|
-
if (parsed.requireEvidence && !parsed.evidence.length)
|
|
2021
|
+
if (parsed.requireEvidence && !parsed.evidence.length)
|
|
2022
|
+
throw new Error("--require-evidence requires at least one --evidence manifest");
|
|
1392
2023
|
return parsed;
|
|
1393
2024
|
}
|
|
1394
2025
|
var display = (value, limit = 500) => stripVTControlCharacters(value).replace(/[\r\n\t]/g, " ").slice(0, limit);
|
|
1395
2026
|
function evidenceSummary(report) {
|
|
1396
2027
|
const evidence = report.evidence;
|
|
1397
2028
|
if (!evidence) return [];
|
|
1398
|
-
const lines = [
|
|
1399
|
-
|
|
2029
|
+
const lines = [
|
|
2030
|
+
`Imported CI evidence: ${evidence.status} (commit ${evidence.headHash.slice(0, 12)}; committed scope).`
|
|
2031
|
+
];
|
|
2032
|
+
for (const diagnostic of evidence.diagnostics)
|
|
2033
|
+
lines.push(` Evidence unavailable: ${display(diagnostic)}`);
|
|
1400
2034
|
for (const check of evidence.checks) {
|
|
1401
|
-
lines.push(
|
|
1402
|
-
|
|
2035
|
+
lines.push(
|
|
2036
|
+
` [${check.kind}] ${display(check.id)}: ${check.outcome}, ${check.freshness} (${display(check.tool)})`
|
|
2037
|
+
);
|
|
2038
|
+
lines.push(
|
|
2039
|
+
` command: ${display(check.command)}; tested commit: ${check.commit ?? "unknown"}`
|
|
2040
|
+
);
|
|
1403
2041
|
if (check.environment) {
|
|
1404
|
-
lines.push(
|
|
1405
|
-
|
|
2042
|
+
lines.push(
|
|
2043
|
+
` environment: ${display(check.tool)} ${display(check.environment.toolVersion)}` + (check.environment.runtime ? `; ${display(check.environment.runtime)}` : "") + (check.environment.platform ? `; ${display(check.environment.platform)}` : "")
|
|
2044
|
+
);
|
|
2045
|
+
if (check.environment.configuration?.length)
|
|
2046
|
+
lines.push(
|
|
2047
|
+
" configuration: " + check.environment.configuration.map((value) => display(value)).join(", ")
|
|
2048
|
+
);
|
|
1406
2049
|
}
|
|
1407
|
-
lines.push(
|
|
2050
|
+
lines.push(
|
|
2051
|
+
` source: ${display(check.source ?? check.manifest)}; report: ${display(check.report?.path ?? "none")}`
|
|
2052
|
+
);
|
|
1408
2053
|
if (Object.keys(check.counts).length) lines.push(` counts: ${JSON.stringify(check.counts)}`);
|
|
1409
|
-
for (const diagnostic of check.diagnostics.slice(0, 5))
|
|
2054
|
+
for (const diagnostic of check.diagnostics.slice(0, 5))
|
|
2055
|
+
lines.push(` note: ${display(diagnostic)}`);
|
|
1410
2056
|
for (const finding of check.findings.slice(0, 10)) {
|
|
1411
2057
|
const locations = finding.locations.map((l) => `${l.file}${l.line ? `:${l.line}` : ""}`).join(", ") || "no resolved file location";
|
|
1412
|
-
lines.push(
|
|
1413
|
-
|
|
1414
|
-
|
|
2058
|
+
lines.push(
|
|
2059
|
+
` [${finding.state}/${finding.severity}] ${display(finding.ruleId ?? finding.id)} ${display(locations)}: ${display(finding.message)}`
|
|
2060
|
+
);
|
|
2061
|
+
if (finding.relatedChangedFiles.length)
|
|
2062
|
+
lines.push(
|
|
2063
|
+
` changed files: ${finding.relatedChangedFiles.map((f) => `${display(f.file)} (${f.relationship}${f.confidence ? `, ${f.confidence}` : ""})`).join(", ")}`
|
|
2064
|
+
);
|
|
2065
|
+
for (const decision of finding.acceptedDecisions)
|
|
2066
|
+
lines.push(
|
|
2067
|
+
` related accepted decision: ${display(decision.id)}; owner ${display(decision.owner ?? "unknown")}; freshness ${decision.freshness}${decision.reviewRequired ? "; needs review" : ""}`
|
|
2068
|
+
);
|
|
1415
2069
|
}
|
|
1416
|
-
if (check.totalFindings > 10 || check.truncated || check.diagnostics.length > 5)
|
|
2070
|
+
if (check.totalFindings > 10 || check.truncated || check.diagnostics.length > 5)
|
|
2071
|
+
lines.push(
|
|
2072
|
+
` Output abbreviated (${check.totalFindings} findings); inspect --json and the original artifact for full evidence.`
|
|
2073
|
+
);
|
|
1417
2074
|
}
|
|
1418
|
-
if (!evidence.workingTree.available)
|
|
1419
|
-
|
|
1420
|
-
|
|
2075
|
+
if (!evidence.workingTree.available)
|
|
2076
|
+
lines.push(
|
|
2077
|
+
" Working-tree state unavailable; imported evidence only describes the recorded commit."
|
|
2078
|
+
);
|
|
2079
|
+
else if (evidence.workingTree.changedFiles.length)
|
|
2080
|
+
lines.push(
|
|
2081
|
+
` ${evidence.workingTree.changedFiles.length} uncommitted paths are outside this evidence's scope.`
|
|
2082
|
+
);
|
|
2083
|
+
lines.push(
|
|
2084
|
+
" Related decisions are associations, not proven violations. Passing imported checks do not establish complete coverage."
|
|
2085
|
+
);
|
|
1421
2086
|
return lines;
|
|
1422
2087
|
}
|
|
1423
2088
|
function formatReviewSummary(report) {
|
|
@@ -1425,16 +2090,13 @@ function formatReviewSummary(report) {
|
|
|
1425
2090
|
lines.push(
|
|
1426
2091
|
`Diff vs ${report.base} (merge-base ${report.mergeBase.slice(0, 7)}): ${report.changedFiles.length} changed file${report.changedFiles.length === 1 ? "" : "s"}.`
|
|
1427
2092
|
);
|
|
1428
|
-
for (const diagnostic of report.diagnostics ?? [])
|
|
2093
|
+
for (const diagnostic of report.diagnostics ?? [])
|
|
2094
|
+
lines.push(`Invalid decision record ${diagnostic.path}: ${diagnostic.message}`);
|
|
1429
2095
|
if (report.truncated) {
|
|
1430
|
-
lines.push(
|
|
1431
|
-
`Large diff \u2013 co-change analysis limited to the first ${50} files.`
|
|
1432
|
-
);
|
|
2096
|
+
lines.push(`Large diff \u2013 co-change analysis limited to the first ${50} files.`);
|
|
1433
2097
|
}
|
|
1434
2098
|
if (!report.historyAvailable) {
|
|
1435
|
-
lines.push(
|
|
1436
|
-
"Git history unavailable (shallow clone?) \u2013 co-change analysis skipped."
|
|
1437
|
-
);
|
|
2099
|
+
lines.push("Git history unavailable (shallow clone?) \u2013 co-change analysis skipped.");
|
|
1438
2100
|
}
|
|
1439
2101
|
if (report.missingPartners.length > 0) {
|
|
1440
2102
|
lines.push("Possible forgotten co-change partners:");
|
|
@@ -1445,16 +2107,24 @@ function formatReviewSummary(report) {
|
|
|
1445
2107
|
}
|
|
1446
2108
|
}
|
|
1447
2109
|
if (report.touchedDecisions.length > 0) {
|
|
1448
|
-
lines.push(
|
|
2110
|
+
lines.push(
|
|
2111
|
+
"Recorded decisions touched by this diff (check approval and freshness before relying on them):"
|
|
2112
|
+
);
|
|
1449
2113
|
for (const d of report.touchedDecisions) {
|
|
1450
2114
|
lines.push(
|
|
1451
2115
|
` [decision] ${d.title} (${d.category}; ${d.approval ?? "unreviewed"}; owner ${d.owner ?? "unknown"}; freshness ${d.freshness ?? "unknown"}; via ${d.touchedFiles.join(", ") || "pending proposal"})`
|
|
1452
2116
|
);
|
|
1453
|
-
if (d.sources?.length)
|
|
1454
|
-
|
|
2117
|
+
if (d.sources?.length)
|
|
2118
|
+
lines.push(` sources: ${d.sources.map((s) => s.reference).join(", ")}`);
|
|
2119
|
+
if (d.lastReview)
|
|
2120
|
+
lines.push(
|
|
2121
|
+
` last review: ${d.lastReview.reviewer} at ${d.lastReview.gitHash.slice(0, 7)}`
|
|
2122
|
+
);
|
|
1455
2123
|
if (d.pendingProposal) {
|
|
1456
2124
|
const p = d.pendingProposal;
|
|
1457
|
-
lines.push(
|
|
2125
|
+
lines.push(
|
|
2126
|
+
` [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`
|
|
2127
|
+
);
|
|
1458
2128
|
}
|
|
1459
2129
|
}
|
|
1460
2130
|
}
|
|
@@ -1482,7 +2152,7 @@ async function runReviewCli(argv, io = {
|
|
|
1482
2152
|
io.out(USAGE);
|
|
1483
2153
|
return 0;
|
|
1484
2154
|
}
|
|
1485
|
-
const rootDir =
|
|
2155
|
+
const rootDir = path13.resolve(args.dir);
|
|
1486
2156
|
const base = args.base ?? await defaultBase(rootDir);
|
|
1487
2157
|
if (!base) {
|
|
1488
2158
|
io.err(
|
|
@@ -1490,7 +2160,11 @@ async function runReviewCli(argv, io = {
|
|
|
1490
2160
|
);
|
|
1491
2161
|
return 2;
|
|
1492
2162
|
}
|
|
1493
|
-
const report = await computeReview(
|
|
2163
|
+
const report = await computeReview(
|
|
2164
|
+
rootDir,
|
|
2165
|
+
base,
|
|
2166
|
+
args.evidence.length ? { evidence: args.evidence } : {}
|
|
2167
|
+
);
|
|
1494
2168
|
if (!report) {
|
|
1495
2169
|
io.err(
|
|
1496
2170
|
`Could not resolve a merge base between ${base} and HEAD in ${rootDir} \u2013 not a git repository, unknown ref, or unrelated histories.`
|
|
@@ -1501,9 +2175,13 @@ async function runReviewCli(argv, io = {
|
|
|
1501
2175
|
io.out(JSON.stringify(report, null, 2));
|
|
1502
2176
|
} else {
|
|
1503
2177
|
io.out(formatReviewSummary(report));
|
|
1504
|
-
if (args.evidence.length && !args.requireEvidence)
|
|
2178
|
+
if (args.evidence.length && !args.requireEvidence)
|
|
2179
|
+
io.out(
|
|
2180
|
+
"Imported evidence is advisory for exit codes; use --require-evidence to require current passing checks."
|
|
2181
|
+
);
|
|
1505
2182
|
}
|
|
1506
|
-
if (args.requireEvidence && report.evidence?.status !== "passed")
|
|
2183
|
+
if (args.requireEvidence && report.evidence?.status !== "passed")
|
|
2184
|
+
return report.evidence?.status === "failed" ? 1 : 2;
|
|
1507
2185
|
return report.missingPartners.length > 0 ? 1 : 0;
|
|
1508
2186
|
}
|
|
1509
2187
|
|