mason-context 0.11.0 → 0.12.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 -0
- package/README.md +39 -1
- package/dist/mason-audit.js +13 -7
- package/dist/mason-audit.js.map +1 -1
- package/dist/mason-auto.js +2283 -0
- package/dist/mason-auto.js.map +1 -0
- package/dist/mason-mcp.js +3443 -2761
- package/dist/mason-mcp.js.map +1 -1
- package/package.json +3 -1
|
@@ -0,0 +1,2283 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/automation/cli.ts
|
|
4
|
+
import { parseArgs } from "util";
|
|
5
|
+
|
|
6
|
+
// src/automation/runtime.ts
|
|
7
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
8
|
+
|
|
9
|
+
// src/audit/repair.ts
|
|
10
|
+
import fs10 from "fs/promises";
|
|
11
|
+
import path16 from "path";
|
|
12
|
+
import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
|
|
13
|
+
import { z as z3 } from "zod";
|
|
14
|
+
|
|
15
|
+
// src/audit/audit.ts
|
|
16
|
+
import fs9 from "fs/promises";
|
|
17
|
+
import path15 from "path";
|
|
18
|
+
|
|
19
|
+
// src/drift/drift.ts
|
|
20
|
+
import fs3 from "fs/promises";
|
|
21
|
+
import path6 from "path";
|
|
22
|
+
import { execFile as execFile3 } from "child_process";
|
|
23
|
+
import { promisify as promisify3 } from "util";
|
|
24
|
+
|
|
25
|
+
// src/snapshot/snapshot.ts
|
|
26
|
+
import path5 from "path";
|
|
27
|
+
import { execFile as execFile2 } from "child_process";
|
|
28
|
+
import { promisify as promisify2 } from "util";
|
|
29
|
+
|
|
30
|
+
// src/utils/files.ts
|
|
31
|
+
import fs from "fs/promises";
|
|
32
|
+
import { constants } from "fs";
|
|
33
|
+
import path2 from "path";
|
|
34
|
+
import { execFile } from "child_process";
|
|
35
|
+
import { promisify } from "util";
|
|
36
|
+
import fg from "fast-glob";
|
|
37
|
+
|
|
38
|
+
// src/utils/paths.ts
|
|
39
|
+
import path from "path";
|
|
40
|
+
function normalizeRepoPath(value) {
|
|
41
|
+
const slash = value.replace(/\\/g, "/");
|
|
42
|
+
if (!slash || slash.includes("\0") || path.posix.isAbsolute(slash) || /^[A-Za-z]:/.test(slash)) return null;
|
|
43
|
+
if (slash.split("/").includes("..")) return null;
|
|
44
|
+
const normalized = path.posix.normalize(slash).replace(/\/$/, "");
|
|
45
|
+
return normalized === "." ? null : normalized;
|
|
46
|
+
}
|
|
47
|
+
function isWithinRoot(root, candidate) {
|
|
48
|
+
const relative = path.relative(root, candidate);
|
|
49
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
50
|
+
}
|
|
51
|
+
function anchorMatches(anchor, file) {
|
|
52
|
+
const a = normalizeRepoPath(anchor);
|
|
53
|
+
const f = normalizeRepoPath(file);
|
|
54
|
+
return a !== null && f !== null && (a === f || f.startsWith(`${a}/`));
|
|
55
|
+
}
|
|
56
|
+
function matchingPaths(anchors, files) {
|
|
57
|
+
return [...new Set(files)].filter((file) => anchors.some((anchor) => anchorMatches(anchor, file)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/utils/files.ts
|
|
61
|
+
var exec = promisify(execFile);
|
|
62
|
+
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"];
|
|
63
|
+
var SOURCE_GLOB = `**/*.{${SOURCE_EXTENSIONS.join(",")}}`;
|
|
64
|
+
var SOURCE_IGNORE = [
|
|
65
|
+
"**/node_modules/**",
|
|
66
|
+
"**/dist/**",
|
|
67
|
+
"**/build/**",
|
|
68
|
+
"**/.gradle/**",
|
|
69
|
+
"**/target/**",
|
|
70
|
+
"**/.git/**",
|
|
71
|
+
"**/.mason/**",
|
|
72
|
+
"**/vendor/**",
|
|
73
|
+
"**/__pycache__/**",
|
|
74
|
+
"**/venv/**",
|
|
75
|
+
"**/.venv/**",
|
|
76
|
+
"**/*.min.*",
|
|
77
|
+
"**/*.map",
|
|
78
|
+
"**/*.lock",
|
|
79
|
+
"**/generated/**",
|
|
80
|
+
"**/*.generated.*",
|
|
81
|
+
"**/R.java",
|
|
82
|
+
"**/BuildConfig.java",
|
|
83
|
+
"**/package-lock.json",
|
|
84
|
+
"**/yarn.lock",
|
|
85
|
+
"**/pnpm-lock.yaml"
|
|
86
|
+
];
|
|
87
|
+
var MAX_SOURCE_BYTES = 1024 * 1024;
|
|
88
|
+
async function readBoundedFile(file, maxBytes) {
|
|
89
|
+
const handle = await fs.open(file, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
|
|
90
|
+
try {
|
|
91
|
+
const stat = await handle.stat();
|
|
92
|
+
if (!stat.isFile() || stat.size > maxBytes) return null;
|
|
93
|
+
const buffer = Buffer.alloc(Math.min(maxBytes + 1, stat.size + 1));
|
|
94
|
+
let bytes = 0;
|
|
95
|
+
while (bytes < buffer.length) {
|
|
96
|
+
const result = await handle.read(buffer, bytes, buffer.length - bytes, null);
|
|
97
|
+
if (result.bytesRead === 0) break;
|
|
98
|
+
bytes += result.bytesRead;
|
|
99
|
+
}
|
|
100
|
+
return bytes === buffer.length ? null : buffer.subarray(0, bytes).toString("utf8");
|
|
101
|
+
} finally {
|
|
102
|
+
await handle.close();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/utils/storage.ts
|
|
107
|
+
import fs2 from "fs/promises";
|
|
108
|
+
import path3 from "path";
|
|
109
|
+
import { randomUUID } from "crypto";
|
|
110
|
+
async function storePath(root, relative, createParents = false) {
|
|
111
|
+
const normalized = normalizeRepoPath(relative);
|
|
112
|
+
if (!normalized) throw new Error(`Invalid store path: ${relative}`);
|
|
113
|
+
let current = await fs2.realpath(root);
|
|
114
|
+
const parts = normalized.split("/");
|
|
115
|
+
for (let i = 0; i < parts.length; i++) {
|
|
116
|
+
current = path3.join(current, parts[i]);
|
|
117
|
+
let stat;
|
|
118
|
+
try {
|
|
119
|
+
stat = await fs2.lstat(current);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error.code !== "ENOENT") throw error;
|
|
122
|
+
if (createParents && i < parts.length - 1) {
|
|
123
|
+
try {
|
|
124
|
+
await fs2.mkdir(current);
|
|
125
|
+
} catch (mkdirError) {
|
|
126
|
+
if (mkdirError.code !== "EEXIST") throw mkdirError;
|
|
127
|
+
}
|
|
128
|
+
stat = await fs2.lstat(current);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (stat?.isSymbolicLink()) throw new Error(`Symlink in store path: ${relative}`);
|
|
132
|
+
}
|
|
133
|
+
return current;
|
|
134
|
+
}
|
|
135
|
+
async function readStoreJson(root, relative) {
|
|
136
|
+
try {
|
|
137
|
+
const file = await storePath(root, relative);
|
|
138
|
+
const raw = await readBoundedFile(file, 10 * 1024 * 1024);
|
|
139
|
+
if (raw === null) throw new Error("file is not regular or exceeds 10 MiB");
|
|
140
|
+
const parsed = JSON.parse(raw);
|
|
141
|
+
if (parsed === null) throw new Error("expected a JSON object, received null");
|
|
142
|
+
return parsed;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (error.code === "ENOENT") return null;
|
|
145
|
+
throw new Error(`Invalid Mason store ${relative}: ${error instanceof Error ? error.message : String(error)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async function writeStoreJson(root, relative, value) {
|
|
149
|
+
const payload = JSON.stringify(value, null, 2) + "\n";
|
|
150
|
+
if (Buffer.byteLength(payload) > 10 * 1024 * 1024) {
|
|
151
|
+
throw new Error(`Mason store ${relative} exceeds 10 MiB`);
|
|
152
|
+
}
|
|
153
|
+
const file = await storePath(root, relative, true);
|
|
154
|
+
const temporary = path3.join(path3.dirname(file), `.${path3.basename(file)}.${randomUUID()}.tmp`);
|
|
155
|
+
try {
|
|
156
|
+
const handle = await fs2.open(temporary, "wx", 384);
|
|
157
|
+
try {
|
|
158
|
+
await handle.writeFile(payload, "utf8");
|
|
159
|
+
await handle.sync();
|
|
160
|
+
} finally {
|
|
161
|
+
await handle.close();
|
|
162
|
+
}
|
|
163
|
+
await fs2.rename(temporary, file);
|
|
164
|
+
} finally {
|
|
165
|
+
await fs2.rm(temporary, { force: true });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/snapshot/snapshot.ts
|
|
170
|
+
import { z } from "zod";
|
|
171
|
+
|
|
172
|
+
// src/test-map.ts
|
|
173
|
+
import path4 from "path";
|
|
174
|
+
|
|
175
|
+
// src/snapshot/snapshot.ts
|
|
176
|
+
var exec2 = promisify2(execFile2);
|
|
177
|
+
var repoPath = z.string().refine((value) => normalizeRepoPath(value) !== null, "Expected a relative repository path");
|
|
178
|
+
var verificationFields = {
|
|
179
|
+
refreshedHash: z.string().optional(),
|
|
180
|
+
verifiedAt: z.string().optional(),
|
|
181
|
+
verifiedHash: z.string().optional(),
|
|
182
|
+
verificationFailed: z.boolean().optional(),
|
|
183
|
+
verificationNote: z.string().optional()
|
|
184
|
+
};
|
|
185
|
+
var featureSchema = z.object({
|
|
186
|
+
description: z.string(),
|
|
187
|
+
files: z.array(repoPath),
|
|
188
|
+
tests: z.array(repoPath).optional(),
|
|
189
|
+
type: z.enum(["capability", "infrastructure"]).optional(),
|
|
190
|
+
...verificationFields
|
|
191
|
+
}).passthrough();
|
|
192
|
+
var flowSchema = z.object({ description: z.string(), chain: z.array(repoPath), ...verificationFields }).passthrough();
|
|
193
|
+
var snapshotSchema = z.object({
|
|
194
|
+
version: z.literal(2),
|
|
195
|
+
createdAt: z.string(),
|
|
196
|
+
updatedAt: z.string(),
|
|
197
|
+
gitHash: z.string(),
|
|
198
|
+
features: z.record(featureSchema),
|
|
199
|
+
flows: z.record(flowSchema)
|
|
200
|
+
}).passthrough();
|
|
201
|
+
async function getCurrentGitHash(rootDir) {
|
|
202
|
+
try {
|
|
203
|
+
const { stdout } = await exec2("git", ["rev-parse", "HEAD"], {
|
|
204
|
+
cwd: rootDir
|
|
205
|
+
});
|
|
206
|
+
return stdout.trim();
|
|
207
|
+
} catch {
|
|
208
|
+
return "unknown";
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// src/drift/drift.ts
|
|
213
|
+
var exec3 = promisify3(execFile3);
|
|
214
|
+
function parseChanges(output) {
|
|
215
|
+
const fields = output.split("\0");
|
|
216
|
+
const changes = [];
|
|
217
|
+
for (let i = 0; i < fields.length && fields[i]; ) {
|
|
218
|
+
const code = fields[i++];
|
|
219
|
+
const first = fields[i++];
|
|
220
|
+
if (!first) break;
|
|
221
|
+
const second = /^[RC]/.test(code) ? fields[i++] : void 0;
|
|
222
|
+
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 };
|
|
223
|
+
if (change.path.startsWith(".mason/") && (!change.previousPath || change.previousPath.startsWith(".mason/"))) continue;
|
|
224
|
+
changes.push(change);
|
|
225
|
+
}
|
|
226
|
+
return changes;
|
|
227
|
+
}
|
|
228
|
+
function touchedPaths(changes) {
|
|
229
|
+
return [...new Set(changes.flatMap((c) => c.previousPath ? [c.previousPath, c.path] : [c.path]))].sort();
|
|
230
|
+
}
|
|
231
|
+
async function getChangesWithStatus(resolvedRoot, fromHash, toHash = "HEAD") {
|
|
232
|
+
if (!fromHash || fromHash === "unknown" || fromHash.startsWith("-") || !toHash || toHash === "unknown" || toHash.startsWith("-")) return null;
|
|
233
|
+
try {
|
|
234
|
+
const { stdout } = await exec3("git", ["diff", "--name-status", "-z", "-M", fromHash, toHash, "--"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 });
|
|
235
|
+
return parseChanges(stdout);
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async function getWorkingTree(resolvedRoot) {
|
|
241
|
+
try {
|
|
242
|
+
const [diff, untracked] = await Promise.all([
|
|
243
|
+
exec3("git", ["diff", "--name-status", "-z", "-M", "HEAD", "--"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }),
|
|
244
|
+
exec3("git", ["ls-files", "-z", "--others", "--exclude-standard"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 })
|
|
245
|
+
]);
|
|
246
|
+
const untrackedFiles = untracked.stdout.split("\0").filter((f) => f && !f.startsWith(".mason/"));
|
|
247
|
+
return { available: true, changedFiles: [.../* @__PURE__ */ new Set([...touchedPaths(parseChanges(diff.stdout)), ...untrackedFiles])].sort(), untrackedFiles };
|
|
248
|
+
} catch {
|
|
249
|
+
return { available: false, changedFiles: [], untrackedFiles: [] };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// src/audit/docs.ts
|
|
254
|
+
import fs4 from "fs/promises";
|
|
255
|
+
import path7 from "path";
|
|
256
|
+
import { execFile as execFile5 } from "child_process";
|
|
257
|
+
import { promisify as promisify5 } from "util";
|
|
258
|
+
|
|
259
|
+
// src/audit/tree.ts
|
|
260
|
+
var MIN_GLYPH_LINES = 3;
|
|
261
|
+
var GLYPHS = ["\u251C\u2500\u2500", "\u2514\u2500\u2500"];
|
|
262
|
+
function glyphIndex(line) {
|
|
263
|
+
for (const glyph of GLYPHS) {
|
|
264
|
+
const idx = line.indexOf(glyph);
|
|
265
|
+
if (idx !== -1) return idx;
|
|
266
|
+
}
|
|
267
|
+
return -1;
|
|
268
|
+
}
|
|
269
|
+
function isSpacerLine(line) {
|
|
270
|
+
return /^[\s│|]*$/.test(line);
|
|
271
|
+
}
|
|
272
|
+
function entryName(afterGlyph) {
|
|
273
|
+
let name = afterGlyph.replace(/^\s+/, "");
|
|
274
|
+
const hash2 = name.search(/\s+#/);
|
|
275
|
+
if (hash2 !== -1) name = name.slice(0, hash2);
|
|
276
|
+
const columns = name.search(/\s{2,}/);
|
|
277
|
+
if (columns !== -1) name = name.slice(0, columns);
|
|
278
|
+
name = name.trim();
|
|
279
|
+
if (!name || /\s/.test(name)) return null;
|
|
280
|
+
return name;
|
|
281
|
+
}
|
|
282
|
+
function extractTreeClaims(blockLines, blockStartLine) {
|
|
283
|
+
const glyphLines = blockLines.filter((l) => glyphIndex(l) !== -1).length;
|
|
284
|
+
if (glyphLines < MIN_GLYPH_LINES) return [];
|
|
285
|
+
const claims = [];
|
|
286
|
+
const stack = [];
|
|
287
|
+
let rootPrefix = "";
|
|
288
|
+
let started = false;
|
|
289
|
+
for (let i = 0; i < blockLines.length; i++) {
|
|
290
|
+
const line = blockLines[i];
|
|
291
|
+
const col = glyphIndex(line);
|
|
292
|
+
if (col === -1) {
|
|
293
|
+
if (isSpacerLine(line)) continue;
|
|
294
|
+
if (!started) {
|
|
295
|
+
const candidate = line.trim();
|
|
296
|
+
if (candidate.endsWith("/") && !/\s/.test(candidate)) {
|
|
297
|
+
rootPrefix = candidate.replace(/\/+$/, "");
|
|
298
|
+
claims.push({
|
|
299
|
+
path: rootPrefix,
|
|
300
|
+
line: blockStartLine + i,
|
|
301
|
+
excerpt: candidate
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
return claims;
|
|
307
|
+
}
|
|
308
|
+
started = true;
|
|
309
|
+
const name = entryName(line.slice(col + GLYPHS[0].length));
|
|
310
|
+
if (name === null) return claims;
|
|
311
|
+
while (stack.length > 0 && stack[stack.length - 1].col >= col) {
|
|
312
|
+
stack.pop();
|
|
313
|
+
}
|
|
314
|
+
const isDir = name.endsWith("/");
|
|
315
|
+
const cleanName = name.replace(/\/+$/, "");
|
|
316
|
+
const segments = [
|
|
317
|
+
...rootPrefix ? [rootPrefix] : [],
|
|
318
|
+
...stack.map((s) => s.name),
|
|
319
|
+
cleanName
|
|
320
|
+
];
|
|
321
|
+
claims.push({
|
|
322
|
+
path: segments.join("/"),
|
|
323
|
+
line: blockStartLine + i,
|
|
324
|
+
excerpt: name
|
|
325
|
+
});
|
|
326
|
+
if (isDir) stack.push({ col, name: cleanName });
|
|
327
|
+
}
|
|
328
|
+
return claims;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// src/audit/claims.ts
|
|
332
|
+
var ROOT_FILE_NAMES = /* @__PURE__ */ new Set([
|
|
333
|
+
"package.json",
|
|
334
|
+
"package-lock.json",
|
|
335
|
+
"pnpm-workspace.yaml",
|
|
336
|
+
"tsconfig.json",
|
|
337
|
+
"tsup.config.ts",
|
|
338
|
+
"vitest.config.ts",
|
|
339
|
+
"Makefile",
|
|
340
|
+
"Dockerfile",
|
|
341
|
+
"docker-compose.yml",
|
|
342
|
+
"Cargo.toml",
|
|
343
|
+
"go.mod",
|
|
344
|
+
"go.sum",
|
|
345
|
+
"pyproject.toml",
|
|
346
|
+
"requirements.txt",
|
|
347
|
+
"Gemfile",
|
|
348
|
+
"composer.json",
|
|
349
|
+
"settings.gradle.kts",
|
|
350
|
+
"settings.gradle",
|
|
351
|
+
"build.gradle.kts",
|
|
352
|
+
"build.gradle",
|
|
353
|
+
"manifest.json",
|
|
354
|
+
"server.json",
|
|
355
|
+
"README.md",
|
|
356
|
+
"CHANGELOG.md",
|
|
357
|
+
"LICENSE",
|
|
358
|
+
"CLAUDE.md",
|
|
359
|
+
"AGENTS.md",
|
|
360
|
+
".gitignore",
|
|
361
|
+
".env.example"
|
|
362
|
+
]);
|
|
363
|
+
var SHELL_FENCE_INFOS = /* @__PURE__ */ new Set(["", "bash", "sh", "shell", "console", "zsh"]);
|
|
364
|
+
var COMMAND_RE = /\b(npm|pnpm|yarn)\s+run\s+([A-Za-z0-9:_.-]+)/g;
|
|
365
|
+
var COUNT_RE = /(\d+)\s+(modules?|packages?|workspaces?|crates?)\b/gi;
|
|
366
|
+
var COUNT_DENYLIST_RE = /^\s*(manager|registr|lock|json)/i;
|
|
367
|
+
var IGNORE_LINE = "<!-- mason:ignore -->";
|
|
368
|
+
var IGNORE_START = "<!-- mason:ignore-start -->";
|
|
369
|
+
var IGNORE_END = "<!-- mason:ignore-end -->";
|
|
370
|
+
function normalizePathToken(token) {
|
|
371
|
+
let t = token.trim();
|
|
372
|
+
if (!t) return null;
|
|
373
|
+
if (/\s/.test(t)) return null;
|
|
374
|
+
if (t.includes("://") || t.includes("\\")) return null;
|
|
375
|
+
if (/[*?[\]{}<>$`]/.test(t)) return null;
|
|
376
|
+
if (t.startsWith("/") || t.startsWith("~") || t.startsWith("./") || t.startsWith("../")) {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
t = t.replace(/:\d+(?:-\d+)?$/, "");
|
|
380
|
+
if (t.includes(":")) return null;
|
|
381
|
+
const normalized = t.replace(/\/+$/, "");
|
|
382
|
+
if (!normalized) return null;
|
|
383
|
+
if (normalized.split("/").some((seg) => /^\.+$/.test(seg))) return null;
|
|
384
|
+
if (normalized.includes("/")) return normalized;
|
|
385
|
+
return ROOT_FILE_NAMES.has(normalized) ? normalized : null;
|
|
386
|
+
}
|
|
387
|
+
function exactTokenPath(line) {
|
|
388
|
+
const trimmed = line.trim();
|
|
389
|
+
if (!trimmed || /\s/.test(trimmed) || !trimmed.includes("/")) return null;
|
|
390
|
+
return normalizePathToken(trimmed);
|
|
391
|
+
}
|
|
392
|
+
function computeIgnoredLines(lines) {
|
|
393
|
+
const ignored = new Array(lines.length).fill(false);
|
|
394
|
+
let inRegion = false;
|
|
395
|
+
let ignoreNext = false;
|
|
396
|
+
for (let i = 0; i < lines.length; i++) {
|
|
397
|
+
const line = lines[i];
|
|
398
|
+
if (line.includes(IGNORE_START)) {
|
|
399
|
+
inRegion = true;
|
|
400
|
+
ignored[i] = true;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
if (line.includes(IGNORE_END)) {
|
|
404
|
+
inRegion = false;
|
|
405
|
+
ignored[i] = true;
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (inRegion) {
|
|
409
|
+
ignored[i] = true;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (ignoreNext) {
|
|
413
|
+
if (line.trim().length === 0) continue;
|
|
414
|
+
ignored[i] = true;
|
|
415
|
+
ignoreNext = false;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (line.includes(IGNORE_LINE)) {
|
|
419
|
+
ignored[i] = true;
|
|
420
|
+
const rest = line.replace(IGNORE_LINE, "").trim();
|
|
421
|
+
if (rest.length === 0) ignoreNext = true;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return ignored;
|
|
425
|
+
}
|
|
426
|
+
function extractClaims(content2) {
|
|
427
|
+
const lines = content2.split("\n");
|
|
428
|
+
const ignored = computeIgnoredLines(lines);
|
|
429
|
+
const paths = /* @__PURE__ */ new Map();
|
|
430
|
+
const counts = [];
|
|
431
|
+
const commands = /* @__PURE__ */ new Map();
|
|
432
|
+
const addPath = (claim) => {
|
|
433
|
+
if (!paths.has(claim.path)) paths.set(claim.path, claim);
|
|
434
|
+
};
|
|
435
|
+
const addCommand = (claim) => {
|
|
436
|
+
if (!commands.has(claim.scriptName)) commands.set(claim.scriptName, claim);
|
|
437
|
+
};
|
|
438
|
+
let inFence = false;
|
|
439
|
+
let fenceInfo = "";
|
|
440
|
+
let fenceMarker = "";
|
|
441
|
+
let blockLines = [];
|
|
442
|
+
let blockStartLine = 0;
|
|
443
|
+
const processBlock = () => {
|
|
444
|
+
for (const claim of extractTreeClaims(blockLines, blockStartLine)) {
|
|
445
|
+
addPath(claim);
|
|
446
|
+
}
|
|
447
|
+
for (let i = 0; i < blockLines.length; i++) {
|
|
448
|
+
const exact = exactTokenPath(blockLines[i]);
|
|
449
|
+
if (exact) {
|
|
450
|
+
addPath({
|
|
451
|
+
path: exact,
|
|
452
|
+
line: blockStartLine + i,
|
|
453
|
+
excerpt: blockLines[i].trim()
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
for (let i = 0; i < lines.length; i++) {
|
|
459
|
+
const line = lines[i];
|
|
460
|
+
const lineNo = i + 1;
|
|
461
|
+
const fenceMatch = line.match(/^\s*(```+|~~~+)(.*)$/);
|
|
462
|
+
if (fenceMatch) {
|
|
463
|
+
if (!inFence) {
|
|
464
|
+
inFence = true;
|
|
465
|
+
fenceMarker = fenceMatch[1][0];
|
|
466
|
+
fenceInfo = fenceMatch[2].trim().toLowerCase();
|
|
467
|
+
blockLines = [];
|
|
468
|
+
blockStartLine = lineNo + 1;
|
|
469
|
+
} else if (fenceMatch[1][0] === fenceMarker) {
|
|
470
|
+
inFence = false;
|
|
471
|
+
processBlock();
|
|
472
|
+
}
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (inFence) {
|
|
476
|
+
blockLines.push(ignored[i] ? "" : line);
|
|
477
|
+
if (!ignored[i] && SHELL_FENCE_INFOS.has(fenceInfo)) {
|
|
478
|
+
for (const m of line.matchAll(COMMAND_RE)) {
|
|
479
|
+
addCommand({
|
|
480
|
+
scriptName: m[2],
|
|
481
|
+
invocation: m[0],
|
|
482
|
+
line: lineNo,
|
|
483
|
+
excerpt: m[0]
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (ignored[i]) continue;
|
|
490
|
+
for (const m of line.matchAll(/`([^`]+)`/g)) {
|
|
491
|
+
const normalized = normalizePathToken(m[1]);
|
|
492
|
+
if (normalized) {
|
|
493
|
+
addPath({ path: normalized, line: lineNo, excerpt: m[1] });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
for (const m of line.matchAll(/"([A-Za-z][\w.@-]*(?:\/[\w.@-]+)+\/?)"/g)) {
|
|
497
|
+
const normalized = normalizePathToken(m[1]);
|
|
498
|
+
if (normalized) {
|
|
499
|
+
addPath({ path: normalized, line: lineNo, excerpt: m[1] });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
for (const m of line.matchAll(COUNT_RE)) {
|
|
503
|
+
const rest = line.slice((m.index ?? 0) + m[0].length);
|
|
504
|
+
if (COUNT_DENYLIST_RE.test(rest)) continue;
|
|
505
|
+
counts.push({
|
|
506
|
+
count: Number.parseInt(m[1], 10),
|
|
507
|
+
unit: m[2].toLowerCase(),
|
|
508
|
+
line: lineNo,
|
|
509
|
+
excerpt: m[0]
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
for (const m of line.matchAll(COMMAND_RE)) {
|
|
513
|
+
addCommand({
|
|
514
|
+
scriptName: m[2],
|
|
515
|
+
invocation: m[0],
|
|
516
|
+
line: lineNo,
|
|
517
|
+
excerpt: m[0]
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (inFence) processBlock();
|
|
522
|
+
return {
|
|
523
|
+
paths: [...paths.values()],
|
|
524
|
+
counts,
|
|
525
|
+
commands: [...commands.values()]
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// src/audit/git.ts
|
|
530
|
+
import { execFile as execFile4 } from "child_process";
|
|
531
|
+
import { promisify as promisify4 } from "util";
|
|
532
|
+
var exec4 = promisify4(execFile4);
|
|
533
|
+
var COMMIT_FORMAT = "%H%x09%cI%x09%s";
|
|
534
|
+
function parseCommitLine(line) {
|
|
535
|
+
const parts = line.split(" ");
|
|
536
|
+
if (parts.length < 3 || !parts[0]) return null;
|
|
537
|
+
return { hash: parts[0], date: parts[1], subject: parts.slice(2).join(" ") };
|
|
538
|
+
}
|
|
539
|
+
async function lastCommitOf(resolvedRoot, relPath) {
|
|
540
|
+
try {
|
|
541
|
+
const { stdout } = await exec4(
|
|
542
|
+
"git",
|
|
543
|
+
["log", "-1", `--format=${COMMIT_FORMAT}`, "--", relPath],
|
|
544
|
+
{ cwd: resolvedRoot }
|
|
545
|
+
);
|
|
546
|
+
const line = stdout.trim().split("\n")[0];
|
|
547
|
+
return line ? parseCommitLine(line) : null;
|
|
548
|
+
} catch {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
async function deletingCommitOf(resolvedRoot, relPath) {
|
|
553
|
+
try {
|
|
554
|
+
const { stdout } = await exec4(
|
|
555
|
+
"git",
|
|
556
|
+
[
|
|
557
|
+
"log",
|
|
558
|
+
"-1",
|
|
559
|
+
"--diff-filter=D",
|
|
560
|
+
`--format=${COMMIT_FORMAT}`,
|
|
561
|
+
"--",
|
|
562
|
+
relPath
|
|
563
|
+
],
|
|
564
|
+
{ cwd: resolvedRoot }
|
|
565
|
+
);
|
|
566
|
+
const line = stdout.trim().split("\n")[0];
|
|
567
|
+
return line ? parseCommitLine(line) : null;
|
|
568
|
+
} catch {
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
async function firstCommitOf(resolvedRoot, relPath) {
|
|
573
|
+
try {
|
|
574
|
+
const { stdout } = await exec4(
|
|
575
|
+
"git",
|
|
576
|
+
["log", "--reverse", `--format=${COMMIT_FORMAT}`, "--", relPath],
|
|
577
|
+
{ cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }
|
|
578
|
+
);
|
|
579
|
+
const line = stdout.trim().split("\n")[0];
|
|
580
|
+
return line ? parseCommitLine(line) : null;
|
|
581
|
+
} catch {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
async function commitsTouchingSince(resolvedRoot, fromHash, pathspecs) {
|
|
586
|
+
if (!fromHash || fromHash === "unknown") return null;
|
|
587
|
+
try {
|
|
588
|
+
const { stdout } = await exec4(
|
|
589
|
+
"git",
|
|
590
|
+
[
|
|
591
|
+
"log",
|
|
592
|
+
`${fromHash}..HEAD`,
|
|
593
|
+
`--format=%x01${COMMIT_FORMAT}`,
|
|
594
|
+
"--name-only",
|
|
595
|
+
"--",
|
|
596
|
+
...pathspecs
|
|
597
|
+
],
|
|
598
|
+
{ cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }
|
|
599
|
+
);
|
|
600
|
+
const commits = [];
|
|
601
|
+
for (const block of stdout.split("")) {
|
|
602
|
+
if (!block.trim()) continue;
|
|
603
|
+
const lines = block.split("\n").filter((l) => l.trim().length > 0);
|
|
604
|
+
const ref = parseCommitLine(lines[0]);
|
|
605
|
+
if (!ref) continue;
|
|
606
|
+
commits.push({ ...ref, files: lines.slice(1).map((l) => l.trim()) });
|
|
607
|
+
}
|
|
608
|
+
return { commits, total: commits.length };
|
|
609
|
+
} catch {
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// src/audit/docs.ts
|
|
615
|
+
var exec5 = promisify5(execFile5);
|
|
616
|
+
var DOC_CANDIDATES = [
|
|
617
|
+
"AGENTS.md",
|
|
618
|
+
"CLAUDE.md",
|
|
619
|
+
".claude/CLAUDE.md"
|
|
620
|
+
];
|
|
621
|
+
async function isDirty(resolvedRoot, relPath) {
|
|
622
|
+
try {
|
|
623
|
+
const { stdout } = await exec5(
|
|
624
|
+
"git",
|
|
625
|
+
["status", "--porcelain", "--", relPath],
|
|
626
|
+
{ cwd: resolvedRoot }
|
|
627
|
+
);
|
|
628
|
+
return stdout.trim().length > 0;
|
|
629
|
+
} catch {
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
async function discoverDocs(resolvedRoot) {
|
|
634
|
+
const docs = [];
|
|
635
|
+
for (const candidate of DOC_CANDIDATES) {
|
|
636
|
+
let content2;
|
|
637
|
+
try {
|
|
638
|
+
content2 = await fs4.readFile(path7.join(resolvedRoot, candidate), "utf-8");
|
|
639
|
+
} catch {
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
docs.push({
|
|
643
|
+
path: candidate,
|
|
644
|
+
content: content2,
|
|
645
|
+
lineCount: content2.split("\n").length,
|
|
646
|
+
lastCommit: await lastCommitOf(resolvedRoot, candidate),
|
|
647
|
+
dirty: await isDirty(resolvedRoot, candidate),
|
|
648
|
+
claims: extractClaims(content2)
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
return docs;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// src/audit/types.ts
|
|
655
|
+
var ALL_CHECKS = [
|
|
656
|
+
"deleted-reference",
|
|
657
|
+
"new-module",
|
|
658
|
+
"stale-count",
|
|
659
|
+
"dead-command",
|
|
660
|
+
"deps-changed",
|
|
661
|
+
"decision-anchor-drift"
|
|
662
|
+
];
|
|
663
|
+
|
|
664
|
+
// src/audit/checks/deleted-reference.ts
|
|
665
|
+
import fs5 from "fs/promises";
|
|
666
|
+
import path8 from "path";
|
|
667
|
+
async function exists(absPath) {
|
|
668
|
+
try {
|
|
669
|
+
await fs5.access(absPath);
|
|
670
|
+
return true;
|
|
671
|
+
} catch {
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
async function checkDeletedReferences(ctx) {
|
|
676
|
+
const result = emptyResult();
|
|
677
|
+
for (const doc of ctx.docs) {
|
|
678
|
+
const changes = ctx.changesSinceDoc.get(doc.path);
|
|
679
|
+
const renames = /* @__PURE__ */ new Map();
|
|
680
|
+
for (const change of changes ?? []) {
|
|
681
|
+
if (change.status === "renamed" && change.previousPath) {
|
|
682
|
+
renames.set(change.previousPath, change.path);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
for (const claim of doc.claims.paths) {
|
|
686
|
+
if (claim.path === ".mason" || claim.path.startsWith(".mason/")) {
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
if (await exists(path8.join(ctx.root, claim.path))) continue;
|
|
690
|
+
const anchor = { doc: doc.path, line: claim.line, excerpt: claim.excerpt };
|
|
691
|
+
const renamedTo = renames.get(claim.path) ?? null;
|
|
692
|
+
if (renamedTo) {
|
|
693
|
+
result.issues.push({
|
|
694
|
+
type: "deleted-reference",
|
|
695
|
+
message: `\`${claim.path}\` was renamed to \`${renamedTo}\``,
|
|
696
|
+
anchor,
|
|
697
|
+
confidence: "certain",
|
|
698
|
+
evidence: {
|
|
699
|
+
kind: "missing-path",
|
|
700
|
+
claimed: claim.path,
|
|
701
|
+
renamedTo,
|
|
702
|
+
deletedInCommit: null,
|
|
703
|
+
everTracked: true,
|
|
704
|
+
parentDirExists: true
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
const tracked = await lastCommitOf(ctx.root, claim.path);
|
|
710
|
+
if (tracked) {
|
|
711
|
+
const deleted = await deletingCommitOf(ctx.root, claim.path);
|
|
712
|
+
const detail = deleted ? ` \u2013 deleted in ${deleted.hash.slice(0, 7)} "${deleted.subject}" (${deleted.date.slice(0, 10)})` : "";
|
|
713
|
+
result.issues.push({
|
|
714
|
+
type: "deleted-reference",
|
|
715
|
+
message: `\`${claim.path}\` no longer exists${detail}`,
|
|
716
|
+
anchor,
|
|
717
|
+
confidence: "certain",
|
|
718
|
+
evidence: {
|
|
719
|
+
kind: "missing-path",
|
|
720
|
+
claimed: claim.path,
|
|
721
|
+
renamedTo: null,
|
|
722
|
+
deletedInCommit: deleted,
|
|
723
|
+
everTracked: true,
|
|
724
|
+
parentDirExists: await exists(
|
|
725
|
+
path8.join(ctx.root, path8.dirname(claim.path))
|
|
726
|
+
)
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
const parentDirExists = await exists(
|
|
732
|
+
path8.join(ctx.root, path8.dirname(claim.path))
|
|
733
|
+
);
|
|
734
|
+
if (!parentDirExists) continue;
|
|
735
|
+
const issue = {
|
|
736
|
+
type: "deleted-reference",
|
|
737
|
+
message: `\`${claim.path}\` does not exist (never tracked in git \u2013 possible typo or invented path)`,
|
|
738
|
+
anchor,
|
|
739
|
+
confidence: "likely",
|
|
740
|
+
evidence: {
|
|
741
|
+
kind: "missing-path",
|
|
742
|
+
claimed: claim.path,
|
|
743
|
+
renamedTo: null,
|
|
744
|
+
deletedInCommit: null,
|
|
745
|
+
everTracked: false,
|
|
746
|
+
parentDirExists: true
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
result.issues.push(issue);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return result;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// src/audit/checks/new-module.ts
|
|
756
|
+
import fg2 from "fast-glob";
|
|
757
|
+
import path9 from "path";
|
|
758
|
+
var DIR_DENYLIST = /* @__PURE__ */ new Set([
|
|
759
|
+
"node_modules",
|
|
760
|
+
"dist",
|
|
761
|
+
"build",
|
|
762
|
+
"out",
|
|
763
|
+
"coverage",
|
|
764
|
+
"target",
|
|
765
|
+
"vendor",
|
|
766
|
+
"__pycache__",
|
|
767
|
+
"venv",
|
|
768
|
+
".venv",
|
|
769
|
+
".git",
|
|
770
|
+
".gradle",
|
|
771
|
+
".mason",
|
|
772
|
+
".claude",
|
|
773
|
+
".github",
|
|
774
|
+
".vscode",
|
|
775
|
+
".idea"
|
|
776
|
+
]);
|
|
777
|
+
var SECOND_LEVEL_MIN_SOURCE_FILES = 2;
|
|
778
|
+
var ENUMERATION_THRESHOLD = 2;
|
|
779
|
+
function escapeRegExp(text2) {
|
|
780
|
+
return text2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
781
|
+
}
|
|
782
|
+
function isMentioned(combinedDocs, name) {
|
|
783
|
+
const re = new RegExp(
|
|
784
|
+
`(^|[^A-Za-z0-9_-])${escapeRegExp(name)}(/|[^A-Za-z0-9_-]|$)`,
|
|
785
|
+
"im"
|
|
786
|
+
);
|
|
787
|
+
return re.test(combinedDocs);
|
|
788
|
+
}
|
|
789
|
+
async function listSubdirs(absDir) {
|
|
790
|
+
const dirs = await fg2("*", {
|
|
791
|
+
cwd: absDir,
|
|
792
|
+
onlyDirectories: true,
|
|
793
|
+
suppressErrors: true
|
|
794
|
+
});
|
|
795
|
+
return dirs.filter((d) => !DIR_DENYLIST.has(d)).sort();
|
|
796
|
+
}
|
|
797
|
+
async function countSourceFiles(absDir) {
|
|
798
|
+
const files = await fg2(SOURCE_GLOB, {
|
|
799
|
+
cwd: absDir,
|
|
800
|
+
ignore: SOURCE_IGNORE,
|
|
801
|
+
suppressErrors: true
|
|
802
|
+
});
|
|
803
|
+
return files.length;
|
|
804
|
+
}
|
|
805
|
+
async function checkNewModules(ctx) {
|
|
806
|
+
const result = emptyResult();
|
|
807
|
+
if (ctx.docs.length === 0) return result;
|
|
808
|
+
const combinedDocs = ctx.docs.map((d) => d.content).join("\n");
|
|
809
|
+
const primaryDoc = ctx.docs[0].path;
|
|
810
|
+
const checkedDocs = ctx.docs.map((d) => d.path);
|
|
811
|
+
const flag = async (dir, sourceFileCount) => {
|
|
812
|
+
result.issues.push({
|
|
813
|
+
type: "new-module",
|
|
814
|
+
message: `directory \`${dir}/\` contains ${sourceFileCount} source file${sourceFileCount === 1 ? "" : "s"} but is not mentioned in any context file`,
|
|
815
|
+
anchor: { doc: primaryDoc, line: null, excerpt: dir },
|
|
816
|
+
confidence: "likely",
|
|
817
|
+
evidence: {
|
|
818
|
+
kind: "unmentioned-dir",
|
|
819
|
+
dir,
|
|
820
|
+
sourceFileCount,
|
|
821
|
+
firstCommit: await firstCommitOf(ctx.root, dir),
|
|
822
|
+
checkedDocs
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
};
|
|
826
|
+
for (const topDir of await listSubdirs(ctx.root)) {
|
|
827
|
+
const absTop = path9.join(ctx.root, topDir);
|
|
828
|
+
const topMentioned = isMentioned(combinedDocs, topDir);
|
|
829
|
+
if (!topMentioned) {
|
|
830
|
+
const count2 = await countSourceFiles(absTop);
|
|
831
|
+
if (count2 >= 1) await flag(topDir, count2);
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
const subdirs = await listSubdirs(absTop);
|
|
835
|
+
const mentioned = subdirs.filter((s) => isMentioned(combinedDocs, s));
|
|
836
|
+
if (mentioned.length < ENUMERATION_THRESHOLD) continue;
|
|
837
|
+
for (const sub of subdirs) {
|
|
838
|
+
if (isMentioned(combinedDocs, sub)) continue;
|
|
839
|
+
const count2 = await countSourceFiles(path9.join(absTop, sub));
|
|
840
|
+
if (count2 >= SECOND_LEVEL_MIN_SOURCE_FILES) {
|
|
841
|
+
await flag(`${topDir}/${sub}`, count2);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return result;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// src/audit/checks/stale-count.ts
|
|
849
|
+
import fs6 from "fs/promises";
|
|
850
|
+
import path10 from "path";
|
|
851
|
+
import fg3 from "fast-glob";
|
|
852
|
+
var MEMBERS_CAP = 50;
|
|
853
|
+
async function readIfExists(absPath) {
|
|
854
|
+
try {
|
|
855
|
+
return await fs6.readFile(absPath, "utf-8");
|
|
856
|
+
} catch {
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
async function countGradleModules(root) {
|
|
861
|
+
for (const name of ["settings.gradle.kts", "settings.gradle"]) {
|
|
862
|
+
const content2 = await readIfExists(path10.join(root, name));
|
|
863
|
+
if (content2 === null) continue;
|
|
864
|
+
const members = [];
|
|
865
|
+
for (const call of content2.matchAll(/include\s*\(([^)]*)\)/g)) {
|
|
866
|
+
for (const proj of call[1].matchAll(/["']([^"']+)["']/g)) {
|
|
867
|
+
members.push(proj[1]);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
if (members.length === 0) return null;
|
|
871
|
+
return { actual: members.length, countedFrom: name, members };
|
|
872
|
+
}
|
|
873
|
+
return null;
|
|
874
|
+
}
|
|
875
|
+
async function countNpmWorkspaces(root) {
|
|
876
|
+
const pkgRaw = await readIfExists(path10.join(root, "package.json"));
|
|
877
|
+
if (pkgRaw !== null) {
|
|
878
|
+
try {
|
|
879
|
+
const pkg = JSON.parse(pkgRaw);
|
|
880
|
+
const globs = Array.isArray(pkg.workspaces) ? pkg.workspaces : Array.isArray(pkg.workspaces?.packages) ? pkg.workspaces.packages : [];
|
|
881
|
+
if (globs.length > 0) {
|
|
882
|
+
const matched = await fg3(
|
|
883
|
+
globs.map((g) => `${g.replace(/\/+$/, "")}/package.json`),
|
|
884
|
+
{ cwd: root, ignore: ["**/node_modules/**"] }
|
|
885
|
+
);
|
|
886
|
+
return {
|
|
887
|
+
actual: matched.length,
|
|
888
|
+
countedFrom: "package.json workspaces",
|
|
889
|
+
members: matched.map((m) => path10.dirname(m)).sort()
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
} catch {
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
const pnpmRaw = await readIfExists(path10.join(root, "pnpm-workspace.yaml"));
|
|
896
|
+
if (pnpmRaw !== null) {
|
|
897
|
+
const globs = [];
|
|
898
|
+
let inPackages = false;
|
|
899
|
+
for (const line of pnpmRaw.split("\n")) {
|
|
900
|
+
if (/^packages\s*:/.test(line)) {
|
|
901
|
+
inPackages = true;
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
if (inPackages) {
|
|
905
|
+
const entry = line.match(/^\s*-\s*["']?([^"'#\s]+)/);
|
|
906
|
+
if (entry) {
|
|
907
|
+
if (!entry[1].startsWith("!")) globs.push(entry[1]);
|
|
908
|
+
} else if (line.trim().length > 0 && !line.startsWith(" ")) {
|
|
909
|
+
inPackages = false;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
if (globs.length > 0) {
|
|
914
|
+
const matched = await fg3(
|
|
915
|
+
globs.map((g) => `${g.replace(/\/+$/, "")}/package.json`),
|
|
916
|
+
{ cwd: root, ignore: ["**/node_modules/**"] }
|
|
917
|
+
);
|
|
918
|
+
return {
|
|
919
|
+
actual: matched.length,
|
|
920
|
+
countedFrom: "pnpm-workspace.yaml",
|
|
921
|
+
members: matched.map((m) => path10.dirname(m)).sort()
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return null;
|
|
926
|
+
}
|
|
927
|
+
async function countCargoCrates(root) {
|
|
928
|
+
const content2 = await readIfExists(path10.join(root, "Cargo.toml"));
|
|
929
|
+
if (content2 === null) return null;
|
|
930
|
+
const membersBlock = content2.match(/members\s*=\s*\[([\s\S]*?)\]/);
|
|
931
|
+
if (!membersBlock) return null;
|
|
932
|
+
const entries = [...membersBlock[1].matchAll(/["']([^"']+)["']/g)].map(
|
|
933
|
+
(m) => m[1]
|
|
934
|
+
);
|
|
935
|
+
if (entries.length === 0) return null;
|
|
936
|
+
const members = /* @__PURE__ */ new Set();
|
|
937
|
+
for (const entry of entries) {
|
|
938
|
+
if (/[*?[\]{}]/.test(entry)) {
|
|
939
|
+
const matched = await fg3(`${entry.replace(/\/+$/, "")}/Cargo.toml`, {
|
|
940
|
+
cwd: root,
|
|
941
|
+
ignore: ["**/target/**"]
|
|
942
|
+
});
|
|
943
|
+
for (const m of matched) members.add(path10.dirname(m));
|
|
944
|
+
} else if (await readIfExists(path10.join(root, entry, "Cargo.toml")) !== null) {
|
|
945
|
+
members.add(entry);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
if (members.size === 0) return null;
|
|
949
|
+
return {
|
|
950
|
+
actual: members.size,
|
|
951
|
+
countedFrom: "Cargo.toml workspace members",
|
|
952
|
+
members: [...members].sort()
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
async function resolveCountSource(root, claim) {
|
|
956
|
+
const unit = claim.unit.replace(/s$/, "");
|
|
957
|
+
if (unit === "module") return countGradleModules(root);
|
|
958
|
+
if (unit === "workspace") return countNpmWorkspaces(root);
|
|
959
|
+
if (unit === "crate") return countCargoCrates(root);
|
|
960
|
+
return await countNpmWorkspaces(root) ?? await countCargoCrates(root) ?? await countGradleModules(root);
|
|
961
|
+
}
|
|
962
|
+
async function checkStaleCounts(ctx) {
|
|
963
|
+
const result = emptyResult();
|
|
964
|
+
for (const doc of ctx.docs) {
|
|
965
|
+
for (const claim of doc.claims.counts) {
|
|
966
|
+
const source = await resolveCountSource(ctx.root, claim);
|
|
967
|
+
if (source === null) {
|
|
968
|
+
result.skipped.push({
|
|
969
|
+
check: "stale-count",
|
|
970
|
+
doc: doc.path,
|
|
971
|
+
reason: `${doc.path}: cannot resolve a workspace manifest for "${claim.excerpt}"`
|
|
972
|
+
});
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
if (source.actual === claim.count) continue;
|
|
976
|
+
result.issues.push({
|
|
977
|
+
type: "stale-count",
|
|
978
|
+
message: `says "${claim.excerpt}" but ${source.countedFrom} resolves to ${source.actual}`,
|
|
979
|
+
anchor: { doc: doc.path, line: claim.line, excerpt: claim.excerpt },
|
|
980
|
+
confidence: "certain",
|
|
981
|
+
evidence: {
|
|
982
|
+
kind: "count-mismatch",
|
|
983
|
+
claimed: claim.count,
|
|
984
|
+
actual: source.actual,
|
|
985
|
+
unit: claim.unit,
|
|
986
|
+
countedFrom: source.countedFrom,
|
|
987
|
+
members: source.members.slice(0, MEMBERS_CAP)
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
return result;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// src/audit/checks/dead-command.ts
|
|
996
|
+
import fs7 from "fs/promises";
|
|
997
|
+
import path11 from "path";
|
|
998
|
+
import fg4 from "fast-glob";
|
|
999
|
+
var AVAILABLE_SCRIPTS_CAP = 30;
|
|
1000
|
+
async function scriptsOf(absManifest) {
|
|
1001
|
+
try {
|
|
1002
|
+
const pkg = JSON.parse(await fs7.readFile(absManifest, "utf-8"));
|
|
1003
|
+
return pkg && typeof pkg.scripts === "object" && pkg.scripts !== null ? Object.keys(pkg.scripts) : [];
|
|
1004
|
+
} catch {
|
|
1005
|
+
return null;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
async function checkDeadCommands(ctx) {
|
|
1009
|
+
const result = emptyResult();
|
|
1010
|
+
const commandClaims = ctx.docs.flatMap(
|
|
1011
|
+
(doc) => doc.claims.commands.map((claim) => ({ doc, claim }))
|
|
1012
|
+
);
|
|
1013
|
+
if (commandClaims.length === 0) return result;
|
|
1014
|
+
const rootScripts = await scriptsOf(path11.join(ctx.root, "package.json"));
|
|
1015
|
+
if (rootScripts === null) {
|
|
1016
|
+
result.skipped.push({
|
|
1017
|
+
check: "dead-command",
|
|
1018
|
+
reason: "no package.json at the repo root"
|
|
1019
|
+
});
|
|
1020
|
+
return result;
|
|
1021
|
+
}
|
|
1022
|
+
const rootSet = new Set(rootScripts);
|
|
1023
|
+
let workspaceScripts = null;
|
|
1024
|
+
let manifestsChecked = ["package.json"];
|
|
1025
|
+
const loadWorkspaceScripts = async () => {
|
|
1026
|
+
if (workspaceScripts !== null) return workspaceScripts;
|
|
1027
|
+
workspaceScripts = /* @__PURE__ */ new Set();
|
|
1028
|
+
const manifests = await fg4("**/package.json", {
|
|
1029
|
+
cwd: ctx.root,
|
|
1030
|
+
ignore: [
|
|
1031
|
+
"**/node_modules/**",
|
|
1032
|
+
"**/dist/**",
|
|
1033
|
+
"**/build/**",
|
|
1034
|
+
"package.json"
|
|
1035
|
+
]
|
|
1036
|
+
});
|
|
1037
|
+
manifestsChecked = ["package.json", ...manifests.sort()];
|
|
1038
|
+
for (const manifest2 of manifests) {
|
|
1039
|
+
const scripts = await scriptsOf(path11.join(ctx.root, manifest2));
|
|
1040
|
+
for (const name of scripts ?? []) workspaceScripts.add(name);
|
|
1041
|
+
}
|
|
1042
|
+
return workspaceScripts;
|
|
1043
|
+
};
|
|
1044
|
+
for (const { doc, claim } of commandClaims) {
|
|
1045
|
+
if (rootSet.has(claim.scriptName)) continue;
|
|
1046
|
+
const elsewhere = await loadWorkspaceScripts();
|
|
1047
|
+
if (elsewhere.has(claim.scriptName)) continue;
|
|
1048
|
+
result.issues.push({
|
|
1049
|
+
type: "dead-command",
|
|
1050
|
+
message: `\`${claim.invocation}\` refers to script "${claim.scriptName}", which exists in no package.json`,
|
|
1051
|
+
anchor: { doc: doc.path, line: claim.line, excerpt: claim.excerpt },
|
|
1052
|
+
confidence: "certain",
|
|
1053
|
+
evidence: {
|
|
1054
|
+
kind: "missing-script",
|
|
1055
|
+
scriptName: claim.scriptName,
|
|
1056
|
+
invocation: claim.invocation,
|
|
1057
|
+
manifestsChecked,
|
|
1058
|
+
availableScripts: rootScripts.slice(0, AVAILABLE_SCRIPTS_CAP)
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
return result;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// src/audit/checks/deps-changed.ts
|
|
1066
|
+
var MANIFEST_COMMITS_CAP = 10;
|
|
1067
|
+
var MANIFEST_PATHSPECS = [
|
|
1068
|
+
":(glob)**/package.json",
|
|
1069
|
+
":(glob)**/build.gradle.kts",
|
|
1070
|
+
":(glob)**/build.gradle",
|
|
1071
|
+
"settings.gradle.kts",
|
|
1072
|
+
"settings.gradle",
|
|
1073
|
+
"gradle/libs.versions.toml",
|
|
1074
|
+
":(glob)**/Cargo.toml",
|
|
1075
|
+
"go.mod",
|
|
1076
|
+
"pyproject.toml",
|
|
1077
|
+
"requirements.txt",
|
|
1078
|
+
"Gemfile",
|
|
1079
|
+
"composer.json"
|
|
1080
|
+
];
|
|
1081
|
+
async function checkDepsChanged(ctx) {
|
|
1082
|
+
const result = emptyResult();
|
|
1083
|
+
result.suppressedAdvisories = [];
|
|
1084
|
+
for (const doc of ctx.docs) {
|
|
1085
|
+
if (!doc.lastCommit) {
|
|
1086
|
+
result.skipped.push({
|
|
1087
|
+
check: "deps-changed",
|
|
1088
|
+
doc: doc.path,
|
|
1089
|
+
reason: `${doc.path} has no commit history`
|
|
1090
|
+
});
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
if (doc.dirty) {
|
|
1094
|
+
result.skipped.push({
|
|
1095
|
+
check: "deps-changed",
|
|
1096
|
+
doc: doc.path,
|
|
1097
|
+
reason: `${doc.path} has uncommitted edits \u2013 suppressed while in flight`
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
const range = await commitsTouchingSince(
|
|
1101
|
+
ctx.root,
|
|
1102
|
+
doc.lastCommit.hash,
|
|
1103
|
+
MANIFEST_PATHSPECS
|
|
1104
|
+
);
|
|
1105
|
+
if (range === null) {
|
|
1106
|
+
result.skipped.push({
|
|
1107
|
+
check: "deps-changed",
|
|
1108
|
+
doc: doc.path,
|
|
1109
|
+
reason: `${doc.path}: commit range unreachable (shallow clone?)`
|
|
1110
|
+
});
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
if (range.total === 0) continue;
|
|
1114
|
+
const latest = range.commits[0];
|
|
1115
|
+
(doc.dirty ? result.suppressedAdvisories : result.advisories).push({
|
|
1116
|
+
type: "deps-changed",
|
|
1117
|
+
message: `dependency manifests touched by ${range.total} commit${range.total === 1 ? "" : "s"} since ${doc.path} was last committed (latest: ${latest.hash.slice(0, 7)} "${latest.subject}")`,
|
|
1118
|
+
anchor: { doc: doc.path, line: null, excerpt: null },
|
|
1119
|
+
evidence: {
|
|
1120
|
+
kind: "doc-behind-manifests",
|
|
1121
|
+
docLastCommit: doc.lastCommit,
|
|
1122
|
+
manifestCommits: range.commits.slice(0, MANIFEST_COMMITS_CAP),
|
|
1123
|
+
totalCommits: range.total
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
return result;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// src/decisions/drift.ts
|
|
1131
|
+
import path14 from "path";
|
|
1132
|
+
|
|
1133
|
+
// src/decisions/decisions.ts
|
|
1134
|
+
import fs8 from "fs/promises";
|
|
1135
|
+
import path13 from "path";
|
|
1136
|
+
import { createHash } from "crypto";
|
|
1137
|
+
|
|
1138
|
+
// src/context/lexical.ts
|
|
1139
|
+
import path12 from "path";
|
|
1140
|
+
|
|
1141
|
+
// src/decisions/provenance.ts
|
|
1142
|
+
import { z as z2 } from "zod";
|
|
1143
|
+
var text = (max) => z2.string().trim().min(1).max(max);
|
|
1144
|
+
var decisionSourceSchema = z2.object({
|
|
1145
|
+
kind: z2.enum(["pull_request", "issue", "incident", "discussion", "document", "other"]),
|
|
1146
|
+
reference: text(1e3),
|
|
1147
|
+
note: text(500).optional()
|
|
1148
|
+
}).strict();
|
|
1149
|
+
var attributionSchema = z2.object({
|
|
1150
|
+
owner: text(200).nullable().optional(),
|
|
1151
|
+
sources: z2.array(decisionSourceSchema).max(20).optional(),
|
|
1152
|
+
actor: text(200).optional()
|
|
1153
|
+
});
|
|
1154
|
+
var contentSchema = z2.object({
|
|
1155
|
+
title: z2.string().min(1),
|
|
1156
|
+
body: z2.string().min(1),
|
|
1157
|
+
category: z2.enum(["decision", "gotcha", "deprecation", "convention"]),
|
|
1158
|
+
files: z2.array(z2.string().refine((f) => normalizeRepoPath(f) !== null)),
|
|
1159
|
+
owner: text(200).optional(),
|
|
1160
|
+
sources: z2.array(decisionSourceSchema).max(20)
|
|
1161
|
+
});
|
|
1162
|
+
var approvalSchema = z2.enum(["unreviewed", "proposed", "accepted"]);
|
|
1163
|
+
var statusSchema = z2.enum(["active", "superseded", "retired"]);
|
|
1164
|
+
var reviewEvidenceSchema = z2.object({
|
|
1165
|
+
baseHash: z2.string(),
|
|
1166
|
+
headHash: z2.string(),
|
|
1167
|
+
historyAvailable: z2.boolean(),
|
|
1168
|
+
changedFiles: z2.array(z2.string()),
|
|
1169
|
+
localChanges: z2.array(z2.string())
|
|
1170
|
+
});
|
|
1171
|
+
var eventSchema = z2.object({
|
|
1172
|
+
kind: z2.enum(["imported", "created", "revised", "accepted", "reaffirmed", "retired", "superseded"]),
|
|
1173
|
+
at: z2.string().datetime(),
|
|
1174
|
+
actor: text(200).optional(),
|
|
1175
|
+
note: text(1500).optional(),
|
|
1176
|
+
revision: z2.number().int().positive(),
|
|
1177
|
+
content: contentSchema,
|
|
1178
|
+
approval: approvalSchema,
|
|
1179
|
+
status: statusSchema,
|
|
1180
|
+
refreshedHash: z2.string(),
|
|
1181
|
+
evidence: reviewEvidenceSchema.optional()
|
|
1182
|
+
});
|
|
1183
|
+
var legacySchema = z2.object({
|
|
1184
|
+
version: z2.literal(1),
|
|
1185
|
+
id: z2.string().regex(/^[a-zA-Z0-9_-]+$/),
|
|
1186
|
+
title: z2.string().min(1),
|
|
1187
|
+
body: z2.string().min(1),
|
|
1188
|
+
category: contentSchema.shape.category,
|
|
1189
|
+
files: contentSchema.shape.files,
|
|
1190
|
+
createdAt: z2.string(),
|
|
1191
|
+
updatedAt: z2.string(),
|
|
1192
|
+
refreshedHash: z2.string(),
|
|
1193
|
+
status: z2.enum(["active", "superseded"]),
|
|
1194
|
+
supersededBy: z2.string().optional()
|
|
1195
|
+
}).passthrough();
|
|
1196
|
+
var currentSchema = legacySchema.extend({
|
|
1197
|
+
version: z2.literal(2),
|
|
1198
|
+
status: statusSchema,
|
|
1199
|
+
approval: approvalSchema,
|
|
1200
|
+
revision: z2.number().int().positive(),
|
|
1201
|
+
owner: text(200).optional(),
|
|
1202
|
+
sources: z2.array(decisionSourceSchema).max(20),
|
|
1203
|
+
history: z2.array(eventSchema).min(1)
|
|
1204
|
+
}).superRefine((record, ctx) => {
|
|
1205
|
+
const invalid = (message) => ctx.addIssue({ code: "custom", message });
|
|
1206
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
1207
|
+
let previous;
|
|
1208
|
+
for (const event of record.history) {
|
|
1209
|
+
if (!previous) {
|
|
1210
|
+
if (!["created", "imported"].includes(event.kind) || event.revision !== 1) invalid("History must begin with creation or legacy import at revision 1");
|
|
1211
|
+
if (event.approval !== (event.kind === "created" ? "proposed" : "unreviewed")) invalid("Initial records cannot claim acceptance");
|
|
1212
|
+
} else {
|
|
1213
|
+
if (["created", "imported"].includes(event.kind)) invalid("History cannot restart");
|
|
1214
|
+
if (previous.status !== "active") invalid("Archived decisions cannot be changed");
|
|
1215
|
+
if (event.revision !== previous.revision + (event.kind === "revised" ? 1 : 0)) invalid("Invalid revision sequence");
|
|
1216
|
+
if (event.kind !== "revised" && !same(event.content, previous.content)) invalid("A review cannot silently revise decision content");
|
|
1217
|
+
if (event.kind === "reaffirmed" && previous.approval !== "accepted") invalid("Only accepted decisions can be reaffirmed");
|
|
1218
|
+
if (event.kind === "accepted" && previous.approval === "accepted") invalid("Use reaffirmation for an accepted decision");
|
|
1219
|
+
const approval = event.kind === "revised" ? "proposed" : ["accepted", "reaffirmed"].includes(event.kind) ? "accepted" : previous.approval;
|
|
1220
|
+
if (event.approval !== approval) invalid("Approval disagrees with review history");
|
|
1221
|
+
if (!["accepted", "reaffirmed"].includes(event.kind) && event.refreshedHash !== previous.refreshedHash) invalid("Only a review can refresh the evidence baseline");
|
|
1222
|
+
}
|
|
1223
|
+
if (event.kind !== "imported" && event.status !== (event.kind === "retired" ? "retired" : event.kind === "superseded" ? "superseded" : "active")) invalid("Lifecycle disagrees with history");
|
|
1224
|
+
if (["accepted", "reaffirmed", "retired"].includes(event.kind) && (!event.actor || !event.note || !event.evidence)) invalid("Reviews require a named reviewer, reason, and code evidence");
|
|
1225
|
+
if (["accepted", "reaffirmed"].includes(event.kind)) {
|
|
1226
|
+
if (!event.content.owner || !event.content.sources.length) invalid("Accepted decisions require an owner and source");
|
|
1227
|
+
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");
|
|
1228
|
+
}
|
|
1229
|
+
previous = event;
|
|
1230
|
+
}
|
|
1231
|
+
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");
|
|
1232
|
+
});
|
|
1233
|
+
var decisionSchema = z2.union([legacySchema, currentSchema]);
|
|
1234
|
+
function decisionContent(record) {
|
|
1235
|
+
return {
|
|
1236
|
+
title: record.title,
|
|
1237
|
+
body: record.body,
|
|
1238
|
+
category: record.category,
|
|
1239
|
+
files: record.files,
|
|
1240
|
+
...typeof record.owner === "string" ? { owner: record.owner } : {},
|
|
1241
|
+
sources: Array.isArray(record.sources) ? record.sources : []
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
function decisionApproval(record) {
|
|
1245
|
+
return record.version === 1 ? "unreviewed" : record.approval;
|
|
1246
|
+
}
|
|
1247
|
+
function effectiveDecision(record) {
|
|
1248
|
+
if (record.version !== 2 || record.status !== "active" || record.approval !== "proposed") return record;
|
|
1249
|
+
let index = record.history.length - 1;
|
|
1250
|
+
while (index >= 0 && !["accepted", "reaffirmed"].includes(record.history[index].kind)) index--;
|
|
1251
|
+
if (index < 0) return record;
|
|
1252
|
+
const event = record.history[index];
|
|
1253
|
+
return {
|
|
1254
|
+
...record,
|
|
1255
|
+
...event.content,
|
|
1256
|
+
owner: event.content.owner,
|
|
1257
|
+
approval: "accepted",
|
|
1258
|
+
revision: event.revision,
|
|
1259
|
+
refreshedHash: event.refreshedHash,
|
|
1260
|
+
updatedAt: event.at,
|
|
1261
|
+
history: record.history.slice(0, index + 1)
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
function decisionProvenance(record, freshness = "unknown") {
|
|
1265
|
+
const approval = decisionApproval(record);
|
|
1266
|
+
const review = record.version === 2 ? [...record.history].reverse().find((e) => ["accepted", "reaffirmed"].includes(e.kind) && e.revision === record.revision) : void 0;
|
|
1267
|
+
return {
|
|
1268
|
+
approval,
|
|
1269
|
+
revision: record.version === 2 ? record.revision : 0,
|
|
1270
|
+
owner: record.version === 2 ? record.owner ?? null : null,
|
|
1271
|
+
sources: record.version === 2 ? record.sources : [],
|
|
1272
|
+
guidance: record.status !== "active" ? "historical" : approval === "accepted" ? "constraint" : approval === "proposed" ? "proposal" : "unreviewed",
|
|
1273
|
+
reviewRequired: record.status === "active" && (approval !== "accepted" || freshness !== "current"),
|
|
1274
|
+
lastReview: review ? { reviewer: review.actor, at: review.at, note: review.note, gitHash: review.refreshedHash } : null
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// src/decisions/decisions.ts
|
|
1279
|
+
async function loadDecisionStore(rootDir) {
|
|
1280
|
+
const records = [];
|
|
1281
|
+
const diagnostics = [];
|
|
1282
|
+
let entries;
|
|
1283
|
+
try {
|
|
1284
|
+
entries = await fs8.readdir(await storePath(rootDir, ".mason/decisions"));
|
|
1285
|
+
} catch (error) {
|
|
1286
|
+
if (error.code !== "ENOENT") diagnostics.push({ path: ".mason/decisions", message: String(error) });
|
|
1287
|
+
return { records, diagnostics };
|
|
1288
|
+
}
|
|
1289
|
+
for (const entry of entries.sort()) {
|
|
1290
|
+
if (!entry.endsWith(".json")) continue;
|
|
1291
|
+
const relative = `.mason/decisions/${entry}`;
|
|
1292
|
+
try {
|
|
1293
|
+
const record = decisionSchema.parse(await readStoreJson(rootDir, relative));
|
|
1294
|
+
if (entry !== `${record.id}.json`) throw new Error("Record id does not match its filename");
|
|
1295
|
+
records.push(record);
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
diagnostics.push({ path: relative, message: error instanceof Error ? error.message : String(error) });
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
return { records, diagnostics };
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// src/decisions/drift.ts
|
|
1304
|
+
async function computeDecisionDrift(rootDir, decisions) {
|
|
1305
|
+
const resolvedRoot = path14.resolve(rootDir);
|
|
1306
|
+
const store = decisions ? { records: decisions, diagnostics: [] } : await loadDecisionStore(resolvedRoot);
|
|
1307
|
+
const report = { historyAvailable: true, totalDecisions: store.records.length, staleDecisions: {}, freshness: {}, diagnostics: store.diagnostics };
|
|
1308
|
+
const [head, workingTree] = await Promise.all([getCurrentGitHash(resolvedRoot), getWorkingTree(resolvedRoot)]);
|
|
1309
|
+
const changesByHash = /* @__PURE__ */ new Map();
|
|
1310
|
+
const inspect = async (record) => {
|
|
1311
|
+
if (record.files.length === 0) return { freshness: "unknown", changedFiles: [] };
|
|
1312
|
+
let touched = changesByHash.get(record.refreshedHash);
|
|
1313
|
+
if (touched === void 0) {
|
|
1314
|
+
const changes = record.refreshedHash === head && head !== "unknown" ? [] : await getChangesWithStatus(resolvedRoot, record.refreshedHash);
|
|
1315
|
+
touched = changes === null ? null : touchedPaths(changes);
|
|
1316
|
+
changesByHash.set(record.refreshedHash, touched);
|
|
1317
|
+
}
|
|
1318
|
+
if (touched === null) report.historyAvailable = false;
|
|
1319
|
+
const hits = touched ? matchingPaths(record.files, touched) : [];
|
|
1320
|
+
const localHits = matchingPaths(record.files, workingTree.changedFiles);
|
|
1321
|
+
return { freshness: touched === null || !workingTree.available ? "unknown" : hits.length || localHits.length ? "changed" : "current", changedFiles: hits };
|
|
1322
|
+
};
|
|
1323
|
+
for (const record of store.records) {
|
|
1324
|
+
if (record.status !== "active") continue;
|
|
1325
|
+
const effective = effectiveDecision(record);
|
|
1326
|
+
const state = await inspect(effective);
|
|
1327
|
+
report.freshness[record.id] = state.freshness;
|
|
1328
|
+
if (state.changedFiles.length) report.staleDecisions[record.id] = state.changedFiles;
|
|
1329
|
+
if (effective !== record) (report.pendingProposals ??= {})[record.id] = await inspect(record);
|
|
1330
|
+
}
|
|
1331
|
+
return report;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// src/audit/checks/decision-anchor.ts
|
|
1335
|
+
async function checkDecisionAnchors(ctx) {
|
|
1336
|
+
const result = emptyResult();
|
|
1337
|
+
if (!ctx.decisionsPresent) return result;
|
|
1338
|
+
const store = await loadDecisionStore(ctx.root);
|
|
1339
|
+
const records = store.records;
|
|
1340
|
+
for (const diagnostic of store.diagnostics) result.skipped.push({ check: "decision-anchor-drift", reason: `${diagnostic.path}: ${diagnostic.message}` });
|
|
1341
|
+
const drift = await computeDecisionDrift(ctx.root, records);
|
|
1342
|
+
if (!drift.historyAvailable) {
|
|
1343
|
+
result.skipped.push({
|
|
1344
|
+
check: "decision-anchor-drift",
|
|
1345
|
+
reason: "some decision base commits are unreachable (shallow clone?)"
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
const changed = records.flatMap((record) => [
|
|
1349
|
+
{ record: effectiveDecision(record), changedFiles: drift.staleDecisions[record.id] ?? [], freshness: drift.freshness?.[record.id] ?? "unknown" },
|
|
1350
|
+
{ record, changedFiles: drift.pendingProposals?.[record.id]?.changedFiles ?? [], freshness: drift.pendingProposals?.[record.id]?.freshness ?? "unknown" }
|
|
1351
|
+
]);
|
|
1352
|
+
for (const { record, changedFiles, freshness } of changed) {
|
|
1353
|
+
if (!changedFiles.length) continue;
|
|
1354
|
+
const id = record.id;
|
|
1355
|
+
const provenance = decisionProvenance(record, freshness);
|
|
1356
|
+
result.advisories.push({
|
|
1357
|
+
type: "decision-anchor-drift",
|
|
1358
|
+
message: `decision "${record.title}" (${provenance.approval}) has anchor files that changed since its evidence baseline \u2013 needs human review`,
|
|
1359
|
+
anchor: {
|
|
1360
|
+
doc: `.mason/decisions/${id}.json`,
|
|
1361
|
+
line: null,
|
|
1362
|
+
excerpt: record.title
|
|
1363
|
+
},
|
|
1364
|
+
evidence: {
|
|
1365
|
+
kind: "decision-anchor",
|
|
1366
|
+
provenance,
|
|
1367
|
+
decisionId: id,
|
|
1368
|
+
title: record.title,
|
|
1369
|
+
changedFiles,
|
|
1370
|
+
refreshedHash: record.refreshedHash
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
return result;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// src/audit/checks/index.ts
|
|
1378
|
+
var CHECKS = {
|
|
1379
|
+
"deleted-reference": checkDeletedReferences,
|
|
1380
|
+
"new-module": checkNewModules,
|
|
1381
|
+
"stale-count": checkStaleCounts,
|
|
1382
|
+
"dead-command": checkDeadCommands,
|
|
1383
|
+
"deps-changed": checkDepsChanged,
|
|
1384
|
+
"decision-anchor-drift": checkDecisionAnchors
|
|
1385
|
+
};
|
|
1386
|
+
function emptyResult() {
|
|
1387
|
+
return { issues: [], advisories: [], skipped: [] };
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// src/audit/audit.ts
|
|
1391
|
+
async function computeAudit(rootDir, options = {}) {
|
|
1392
|
+
const resolvedRoot = path15.resolve(rootDir);
|
|
1393
|
+
const docs = await discoverDocs(resolvedRoot);
|
|
1394
|
+
if (docs.length === 0) return null;
|
|
1395
|
+
const headHash = await getCurrentGitHash(resolvedRoot);
|
|
1396
|
+
const report = {
|
|
1397
|
+
version: 1,
|
|
1398
|
+
root: resolvedRoot,
|
|
1399
|
+
gitAvailable: headHash !== "unknown",
|
|
1400
|
+
headHash,
|
|
1401
|
+
checksRun: [],
|
|
1402
|
+
docs: docs.map((d) => ({
|
|
1403
|
+
path: d.path,
|
|
1404
|
+
lastCommit: d.lastCommit,
|
|
1405
|
+
dirty: d.dirty,
|
|
1406
|
+
lineCount: d.lineCount
|
|
1407
|
+
})),
|
|
1408
|
+
decisionsChecked: false,
|
|
1409
|
+
issues: [],
|
|
1410
|
+
advisories: [],
|
|
1411
|
+
suppressedAdvisories: [],
|
|
1412
|
+
skippedChecks: [],
|
|
1413
|
+
clean: true
|
|
1414
|
+
};
|
|
1415
|
+
if (!report.gitAvailable) return report;
|
|
1416
|
+
const changesSinceDoc = /* @__PURE__ */ new Map();
|
|
1417
|
+
for (const doc of docs) {
|
|
1418
|
+
changesSinceDoc.set(
|
|
1419
|
+
doc.path,
|
|
1420
|
+
doc.lastCommit ? await getChangesWithStatus(resolvedRoot, doc.lastCommit.hash) : null
|
|
1421
|
+
);
|
|
1422
|
+
}
|
|
1423
|
+
let decisionsPresent = false;
|
|
1424
|
+
try {
|
|
1425
|
+
await fs9.access(path15.join(resolvedRoot, ".mason", "decisions"));
|
|
1426
|
+
decisionsPresent = true;
|
|
1427
|
+
} catch {
|
|
1428
|
+
}
|
|
1429
|
+
report.decisionsChecked = decisionsPresent;
|
|
1430
|
+
const ctx = {
|
|
1431
|
+
root: resolvedRoot,
|
|
1432
|
+
docs,
|
|
1433
|
+
headHash,
|
|
1434
|
+
changesSinceDoc,
|
|
1435
|
+
decisionsPresent
|
|
1436
|
+
};
|
|
1437
|
+
const selected = options.checks ?? ALL_CHECKS;
|
|
1438
|
+
for (const name of ALL_CHECKS) {
|
|
1439
|
+
if (!selected.includes(name)) continue;
|
|
1440
|
+
const { issues, advisories, suppressedAdvisories, skipped } = await (options.runCheck ? options.runCheck(name, ctx) : CHECKS[name](ctx));
|
|
1441
|
+
report.checksRun.push(name);
|
|
1442
|
+
report.issues.push(...issues);
|
|
1443
|
+
report.advisories.push(...advisories);
|
|
1444
|
+
report.suppressedAdvisories.push(...suppressedAdvisories ?? []);
|
|
1445
|
+
report.skippedChecks.push(...skipped);
|
|
1446
|
+
}
|
|
1447
|
+
report.clean = report.issues.length === 0;
|
|
1448
|
+
return report;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// src/audit/repair.ts
|
|
1452
|
+
var checkSchema = z3.enum(["deleted-reference", "new-module", "stale-count", "dead-command", "deps-changed", "decision-anchor-drift"]);
|
|
1453
|
+
var commitSchema = z3.object({ hash: z3.string().regex(/^[a-f0-9]{40,64}$/), date: z3.string(), subject: z3.string() });
|
|
1454
|
+
var anchorSchema = z3.object({ doc: z3.string(), line: z3.number().int().positive().nullable(), excerpt: z3.string().nullable() });
|
|
1455
|
+
var count = z3.number().int().nonnegative();
|
|
1456
|
+
var evidenceSchema = z3.discriminatedUnion("kind", [
|
|
1457
|
+
z3.object({
|
|
1458
|
+
kind: z3.literal("missing-path"),
|
|
1459
|
+
claimed: z3.string(),
|
|
1460
|
+
renamedTo: z3.string().nullable(),
|
|
1461
|
+
deletedInCommit: commitSchema.nullable(),
|
|
1462
|
+
everTracked: z3.boolean(),
|
|
1463
|
+
parentDirExists: z3.boolean()
|
|
1464
|
+
}),
|
|
1465
|
+
z3.object({
|
|
1466
|
+
kind: z3.literal("unmentioned-dir"),
|
|
1467
|
+
dir: z3.string(),
|
|
1468
|
+
sourceFileCount: count,
|
|
1469
|
+
firstCommit: commitSchema.nullable(),
|
|
1470
|
+
checkedDocs: z3.array(z3.string())
|
|
1471
|
+
}),
|
|
1472
|
+
z3.object({
|
|
1473
|
+
kind: z3.literal("count-mismatch"),
|
|
1474
|
+
claimed: count,
|
|
1475
|
+
actual: count,
|
|
1476
|
+
unit: z3.string(),
|
|
1477
|
+
countedFrom: z3.string(),
|
|
1478
|
+
members: z3.array(z3.string())
|
|
1479
|
+
}),
|
|
1480
|
+
z3.object({
|
|
1481
|
+
kind: z3.literal("missing-script"),
|
|
1482
|
+
scriptName: z3.string(),
|
|
1483
|
+
invocation: z3.string(),
|
|
1484
|
+
manifestsChecked: z3.array(z3.string()),
|
|
1485
|
+
availableScripts: z3.array(z3.string())
|
|
1486
|
+
}),
|
|
1487
|
+
z3.object({
|
|
1488
|
+
kind: z3.literal("doc-behind-manifests"),
|
|
1489
|
+
docLastCommit: commitSchema,
|
|
1490
|
+
manifestCommits: z3.array(commitSchema.extend({ files: z3.array(z3.string()) })),
|
|
1491
|
+
totalCommits: count
|
|
1492
|
+
}),
|
|
1493
|
+
z3.object({
|
|
1494
|
+
kind: z3.literal("decision-anchor"),
|
|
1495
|
+
decisionId: z3.string(),
|
|
1496
|
+
title: z3.string(),
|
|
1497
|
+
changedFiles: z3.array(z3.string()),
|
|
1498
|
+
refreshedHash: z3.string(),
|
|
1499
|
+
provenance: z3.object({}).passthrough().optional()
|
|
1500
|
+
})
|
|
1501
|
+
]);
|
|
1502
|
+
var findingSchema = z3.object({ message: z3.string(), anchor: anchorSchema, evidence: evidenceSchema });
|
|
1503
|
+
var issueSchema = findingSchema.extend({
|
|
1504
|
+
type: z3.enum(["deleted-reference", "new-module", "stale-count", "dead-command"]),
|
|
1505
|
+
confidence: z3.enum(["certain", "likely"])
|
|
1506
|
+
});
|
|
1507
|
+
var advisorySchema = findingSchema.extend({ type: z3.enum(["deps-changed", "decision-anchor-drift"]) });
|
|
1508
|
+
var checkResultSchema = z3.object({
|
|
1509
|
+
issues: z3.array(issueSchema),
|
|
1510
|
+
advisories: z3.array(advisorySchema),
|
|
1511
|
+
suppressedAdvisories: z3.array(advisorySchema).optional(),
|
|
1512
|
+
skipped: z3.array(z3.object({ check: z3.string(), reason: z3.string(), doc: z3.string().optional() }))
|
|
1513
|
+
});
|
|
1514
|
+
var reportSchema = z3.object({
|
|
1515
|
+
version: z3.literal(1),
|
|
1516
|
+
root: z3.string(),
|
|
1517
|
+
gitAvailable: z3.literal(true),
|
|
1518
|
+
headHash: commitSchema.shape.hash,
|
|
1519
|
+
checksRun: z3.array(checkSchema).nonempty(),
|
|
1520
|
+
docs: z3.array(z3.object({
|
|
1521
|
+
path: z3.enum(DOC_CANDIDATES),
|
|
1522
|
+
lastCommit: commitSchema.nullable(),
|
|
1523
|
+
dirty: z3.boolean(),
|
|
1524
|
+
lineCount: count
|
|
1525
|
+
})).nonempty(),
|
|
1526
|
+
decisionsChecked: z3.boolean(),
|
|
1527
|
+
clean: z3.boolean(),
|
|
1528
|
+
issues: z3.array(issueSchema),
|
|
1529
|
+
advisories: z3.array(advisorySchema),
|
|
1530
|
+
suppressedAdvisories: z3.array(advisorySchema).optional(),
|
|
1531
|
+
skippedChecks: z3.array(z3.object({ check: z3.string(), reason: z3.string(), doc: z3.string().optional() }))
|
|
1532
|
+
});
|
|
1533
|
+
var baselineSchema = z3.object({
|
|
1534
|
+
kind: z3.literal("mason-audit-repair"),
|
|
1535
|
+
version: z3.literal(1),
|
|
1536
|
+
createdAt: z3.string().datetime(),
|
|
1537
|
+
report: reportSchema,
|
|
1538
|
+
digest: z3.string().regex(/^[a-f0-9]{64}$/)
|
|
1539
|
+
});
|
|
1540
|
+
var digest = (value) => createHash2("sha256").update(JSON.stringify(value)).digest("hex");
|
|
1541
|
+
function findingId(finding) {
|
|
1542
|
+
const e = finding.evidence;
|
|
1543
|
+
let key;
|
|
1544
|
+
switch (e.kind) {
|
|
1545
|
+
case "missing-path":
|
|
1546
|
+
key = e.claimed;
|
|
1547
|
+
break;
|
|
1548
|
+
case "unmentioned-dir":
|
|
1549
|
+
key = e.dir;
|
|
1550
|
+
break;
|
|
1551
|
+
case "count-mismatch":
|
|
1552
|
+
key = [e.unit.replace(/s$/, ""), e.countedFrom];
|
|
1553
|
+
break;
|
|
1554
|
+
case "missing-script":
|
|
1555
|
+
key = e.scriptName;
|
|
1556
|
+
break;
|
|
1557
|
+
case "doc-behind-manifests":
|
|
1558
|
+
key = null;
|
|
1559
|
+
break;
|
|
1560
|
+
case "decision-anchor":
|
|
1561
|
+
key = [e.decisionId, e.provenance?.revision, e.provenance?.approval];
|
|
1562
|
+
break;
|
|
1563
|
+
}
|
|
1564
|
+
return digest([finding.type, finding.anchor.doc, key]);
|
|
1565
|
+
}
|
|
1566
|
+
function allFindings(report) {
|
|
1567
|
+
return [...report.issues, ...report.advisories, ...report.suppressedAdvisories ?? []];
|
|
1568
|
+
}
|
|
1569
|
+
async function docState(root) {
|
|
1570
|
+
const docs = [];
|
|
1571
|
+
for (const doc of DOC_CANDIDATES) {
|
|
1572
|
+
try {
|
|
1573
|
+
const content2 = await readBoundedFile(await storePath(root, doc), 10 * 1024 * 1024);
|
|
1574
|
+
if (content2 === null) throw new Error("Context file is not regular or exceeds 10 MiB: " + doc);
|
|
1575
|
+
docs.push([doc, digest(content2)]);
|
|
1576
|
+
} catch (error) {
|
|
1577
|
+
if (error.code !== "ENOENT") throw error;
|
|
1578
|
+
docs.push([doc, null]);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
return digest(docs);
|
|
1582
|
+
}
|
|
1583
|
+
async function stableAudit(root, checks, options = {}) {
|
|
1584
|
+
const head = await getCurrentGitHash(root);
|
|
1585
|
+
const before = await docState(root);
|
|
1586
|
+
const report = await computeAudit(root, { ...options, checks });
|
|
1587
|
+
if (head !== await getCurrentGitHash(root) || before !== await docState(root) || report && report.headHash !== head) {
|
|
1588
|
+
throw new Error("HEAD or context files changed during the audit; retry against a stable checkout.");
|
|
1589
|
+
}
|
|
1590
|
+
return report;
|
|
1591
|
+
}
|
|
1592
|
+
async function prepareRepair(rootDir, checks = ALL_CHECKS, options = {}) {
|
|
1593
|
+
const root = await fs10.realpath(rootDir);
|
|
1594
|
+
const selected = z3.array(checkSchema).nonempty().parse(checks);
|
|
1595
|
+
const report = await stableAudit(root, selected, options);
|
|
1596
|
+
if (!report) throw new Error("No context files found to prepare a repair.");
|
|
1597
|
+
if (!report.gitAvailable) throw new Error("Readable Git history is required to prepare a repair.");
|
|
1598
|
+
const storedReport = reportSchema.parse(report);
|
|
1599
|
+
const payload = {
|
|
1600
|
+
kind: "mason-audit-repair",
|
|
1601
|
+
version: 1,
|
|
1602
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1603
|
+
report: storedReport
|
|
1604
|
+
};
|
|
1605
|
+
const baselinePath = ".mason/reports/repairs/" + randomUUID2() + ".json";
|
|
1606
|
+
await writeStoreJson(root, baselinePath, { ...payload, digest: digest(payload) });
|
|
1607
|
+
return { version: 1, action: "prepare", baselinePath, report };
|
|
1608
|
+
}
|
|
1609
|
+
async function verifyRepair(rootDir, baselinePath, options = {}) {
|
|
1610
|
+
const root = await fs10.realpath(rootDir);
|
|
1611
|
+
const declaredRoot = path16.resolve(rootDir);
|
|
1612
|
+
const relative = path16.isAbsolute(baselinePath) ? path16.relative(isWithinRoot(declaredRoot, baselinePath) ? declaredRoot : root, baselinePath) : baselinePath;
|
|
1613
|
+
const stored = baselineSchema.parse(await readStoreJson(root, relative));
|
|
1614
|
+
const { digest: savedDigest, ...payload } = stored;
|
|
1615
|
+
if (digest(payload) !== savedDigest) throw new Error("Repair baseline was modified; use the original baseline.");
|
|
1616
|
+
if (stored.report.root !== root) throw new Error("Repair baseline belongs to a different repository.");
|
|
1617
|
+
const original = stored.report;
|
|
1618
|
+
const diagnostics = [];
|
|
1619
|
+
let current = null;
|
|
1620
|
+
try {
|
|
1621
|
+
current = await stableAudit(root, original.checksRun, options);
|
|
1622
|
+
if (!current) diagnostics.push("No context files remain available to audit.");
|
|
1623
|
+
else if (!current.gitAvailable) diagnostics.push("Git history is unavailable.");
|
|
1624
|
+
for (const doc of original.docs) {
|
|
1625
|
+
if (!original.issues.some((f) => f.anchor.doc === doc.path) || !current?.docs.some((d) => d.path === doc.path)) continue;
|
|
1626
|
+
const content2 = await readBoundedFile(await storePath(root, doc.path), 10 * 1024 * 1024);
|
|
1627
|
+
if (content2 === null || !content2.trim()) {
|
|
1628
|
+
diagnostics.push("Original context file " + doc.path + " is empty or unreadable; losing its claims does not verify a repair.");
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
if (await getChangesWithStatus(root, original.headHash) === null) {
|
|
1632
|
+
diagnostics.push("The original audit commit is unavailable; repair history cannot be verified.");
|
|
1633
|
+
}
|
|
1634
|
+
} catch (error) {
|
|
1635
|
+
diagnostics.push(error instanceof Error ? error.message : String(error));
|
|
1636
|
+
}
|
|
1637
|
+
const currentById = new Map((current ? allFindings(current) : []).map((f) => [findingId(f), f]));
|
|
1638
|
+
const originalFindings = allFindings(original);
|
|
1639
|
+
const originalIds = new Set(originalFindings.map(findingId));
|
|
1640
|
+
const missingDocs = original.docs.filter((doc) => !current?.docs.some((d) => d.path === doc.path));
|
|
1641
|
+
for (const doc of missingDocs) diagnostics.push("Original context file " + doc.path + " is unavailable; removing it does not verify a repair.");
|
|
1642
|
+
const findings = originalFindings.map((finding) => {
|
|
1643
|
+
const id = findingId(finding);
|
|
1644
|
+
const now = currentById.get(id);
|
|
1645
|
+
const base = { id, original: finding, ...now ? { current: now } : {} };
|
|
1646
|
+
if (diagnostics.length || !current) {
|
|
1647
|
+
return { ...base, status: "unverified", reason: "The original audit scope could not be verified. See diagnostics." };
|
|
1648
|
+
}
|
|
1649
|
+
if ("confidence" in finding && now) {
|
|
1650
|
+
return { ...base, status: "unresolved", reason: "The original check still reports this claim." };
|
|
1651
|
+
}
|
|
1652
|
+
const skipped = current.skippedChecks.filter((s) => s.check === finding.type && (!s.doc || s.doc === finding.anchor.doc));
|
|
1653
|
+
if (!current.checksRun?.includes(finding.type) || skipped.length) {
|
|
1654
|
+
return { ...base, status: "unverified", reason: skipped.map((s) => s.reason).join("; ") || "The original check did not run." };
|
|
1655
|
+
}
|
|
1656
|
+
if (!("confidence" in finding)) {
|
|
1657
|
+
return {
|
|
1658
|
+
...base,
|
|
1659
|
+
status: "review-required",
|
|
1660
|
+
reason: "An audit cannot establish that this advisory was reviewed. Retain its original evidence and report a separate assessment; editing or committing the doc is not approval."
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
return { ...base, status: "resolved", reason: "The original check ran and no longer reports this claim. Inspect the edit for semantic correctness." };
|
|
1664
|
+
});
|
|
1665
|
+
const newFindings = [...currentById].filter(([id]) => !originalIds.has(id)).map(([, f]) => f);
|
|
1666
|
+
const counts = { resolved: 0, unresolved: 0, "review-required": 0, unverified: 0 };
|
|
1667
|
+
for (const f of findings) counts[f.status]++;
|
|
1668
|
+
const incomplete = diagnostics.length > 0 || counts.unverified > 0 || counts["review-required"] > 0 || (current?.skippedChecks.length ?? 0) > 0 || newFindings.some((f) => !("confidence" in f));
|
|
1669
|
+
const issuesRemain = counts.unresolved > 0 || newFindings.some((f) => "confidence" in f);
|
|
1670
|
+
return {
|
|
1671
|
+
version: 1,
|
|
1672
|
+
action: "verify",
|
|
1673
|
+
baselinePath: relative,
|
|
1674
|
+
baselineHead: original.headHash,
|
|
1675
|
+
currentHead: current?.gitAvailable ? current.headHash : null,
|
|
1676
|
+
status: incomplete ? "incomplete" : issuesRemain ? "issues-remain" : "verified",
|
|
1677
|
+
findings,
|
|
1678
|
+
newFindings,
|
|
1679
|
+
diagnostics,
|
|
1680
|
+
currentAudit: current,
|
|
1681
|
+
counts,
|
|
1682
|
+
scope: "Original audit checks over current context files and repository evidence. Resolved means no longer detected by that check. Advisories require separate review; this is not a certification of documentation or application correctness."
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// src/automation/evidence.ts
|
|
1687
|
+
import fs11 from "fs/promises";
|
|
1688
|
+
import path17 from "path";
|
|
1689
|
+
import { createHash as createHash3 } from "crypto";
|
|
1690
|
+
import { execFile as execFile6 } from "child_process";
|
|
1691
|
+
import { promisify as promisify6 } from "util";
|
|
1692
|
+
import fg5 from "fast-glob";
|
|
1693
|
+
import { z as z4 } from "zod";
|
|
1694
|
+
var exec6 = promisify6(execFile6);
|
|
1695
|
+
var engineVersion = true ? "0.12.0" : "development";
|
|
1696
|
+
var hash = (value) => createHash3("sha256").update(JSON.stringify(value)).digest("hex");
|
|
1697
|
+
async function git(root, ...args) {
|
|
1698
|
+
return (await exec6("git", args, { cwd: root, maxBuffer: 16 * 1024 * 1024, timeout: 1e4 })).stdout;
|
|
1699
|
+
}
|
|
1700
|
+
async function workspace(dir) {
|
|
1701
|
+
const root = await fs11.realpath((await git(dir, "rev-parse", "--show-toplevel")).trim());
|
|
1702
|
+
const gitDir = await fs11.realpath((await git(root, "rev-parse", "--absolute-git-dir")).trim());
|
|
1703
|
+
let branch;
|
|
1704
|
+
try {
|
|
1705
|
+
branch = (await git(root, "symbolic-ref", "--quiet", "HEAD")).trim();
|
|
1706
|
+
} catch {
|
|
1707
|
+
branch = "detached";
|
|
1708
|
+
}
|
|
1709
|
+
return { root, gitDir, branch, directory: ".mason/reports/automation/" + hash([root, gitDir, branch]).slice(0, 24) };
|
|
1710
|
+
}
|
|
1711
|
+
async function content(root, file) {
|
|
1712
|
+
try {
|
|
1713
|
+
const value = await readBoundedFile(await storePath(root, file), 10 * 1024 * 1024);
|
|
1714
|
+
if (value === null) throw new Error("Unreadable or oversized audit input: " + file);
|
|
1715
|
+
return value;
|
|
1716
|
+
} catch (error) {
|
|
1717
|
+
if (error.code === "ENOENT") return null;
|
|
1718
|
+
throw error;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
var manifest = /(^|\/)(package\.json|pnpm-workspace\.yaml|Cargo\.toml|settings\.gradle(?:\.kts)?|build\.gradle(?:\.kts)?|libs\.versions\.toml|go\.mod|pyproject\.toml|requirements\.txt|Gemfile|composer\.json)$/;
|
|
1722
|
+
var internal = (file) => file === ".mason" || file === ".mason/reports" || file.startsWith(".mason/reports/");
|
|
1723
|
+
async function readInputs(root) {
|
|
1724
|
+
const [headText, status, inventory, index, shallowPath, replacements] = await Promise.all([
|
|
1725
|
+
git(root, "rev-parse", "HEAD"),
|
|
1726
|
+
git(root, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ".", ":(exclude).mason/reports"),
|
|
1727
|
+
fg5("**/*", {
|
|
1728
|
+
cwd: root,
|
|
1729
|
+
dot: true,
|
|
1730
|
+
onlyFiles: false,
|
|
1731
|
+
followSymbolicLinks: false,
|
|
1732
|
+
objectMode: true,
|
|
1733
|
+
ignore: ["**/.git/**", "**/node_modules/**", ".mason/reports/**"]
|
|
1734
|
+
}),
|
|
1735
|
+
git(root, "ls-files", "--stage", "-z", "--", ".", ":(exclude).mason/reports"),
|
|
1736
|
+
git(root, "rev-parse", "--git-path", "shallow"),
|
|
1737
|
+
git(root, "for-each-ref", "--format=%(refname) %(objectname)", "refs/replace")
|
|
1738
|
+
]);
|
|
1739
|
+
const entries = inventory.filter((f) => !internal(f.path) && f.path !== ".git").sort((a, b) => a.path.localeCompare(b.path));
|
|
1740
|
+
const files = entries.map((f) => f.path);
|
|
1741
|
+
if (files.length > 1e5) throw new Error("Automation input inventory exceeds 100,000 paths; use an explicit scoped audit.");
|
|
1742
|
+
const head = headText.trim();
|
|
1743
|
+
let shallow = null;
|
|
1744
|
+
try {
|
|
1745
|
+
shallow = await fs11.readFile(path17.resolve(root, shallowPath.trim()), "utf8");
|
|
1746
|
+
} catch (error) {
|
|
1747
|
+
if (error.code !== "ENOENT") throw error;
|
|
1748
|
+
}
|
|
1749
|
+
const docs = {};
|
|
1750
|
+
const docContents = [];
|
|
1751
|
+
const claims = [];
|
|
1752
|
+
for (const file of DOC_CANDIDATES) {
|
|
1753
|
+
const text2 = await content(root, file);
|
|
1754
|
+
docs[file] = text2 === null ? null : hash(text2);
|
|
1755
|
+
docContents.push([file, text2]);
|
|
1756
|
+
for (const claim of text2 ? extractClaims(text2).paths : []) {
|
|
1757
|
+
if (internal(claim.path) || claim.path.startsWith(".mason/")) continue;
|
|
1758
|
+
const exists2 = async (p) => fs11.access(path17.resolve(root, p)).then(() => true, () => false);
|
|
1759
|
+
claims.push([claim.path, await exists2(claim.path), await exists2(path17.dirname(claim.path))]);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
const metadata = [];
|
|
1763
|
+
for (const file of files) {
|
|
1764
|
+
if (manifest.test(file) || file.startsWith(".mason/decisions/") && file.endsWith(".json") || file === ".mason/config.json") {
|
|
1765
|
+
metadata.push([file, await content(root, file)]);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
if (entries.some((f) => f.dirent.isSymbolicLink())) throw new Error("Automation inventory contains a symbolic link; use an explicit audit to inspect its scope. No cached verification was recorded.");
|
|
1769
|
+
const common = [1, engineVersion, head, shallow, replacements, docContents];
|
|
1770
|
+
const layout = hash(entries.map((f) => [f.path, f.dirent.isDirectory() ? "directory" : "file"]));
|
|
1771
|
+
const manifests = hash(metadata.filter(([file]) => manifest.test(file)));
|
|
1772
|
+
const decisions = hash(metadata.filter(([file]) => !manifest.test(file)));
|
|
1773
|
+
const keys = {
|
|
1774
|
+
"deleted-reference": hash([common, layout, claims]),
|
|
1775
|
+
"new-module": hash([common, layout]),
|
|
1776
|
+
"stale-count": hash([common, layout, manifests]),
|
|
1777
|
+
"dead-command": hash([common, layout, manifests]),
|
|
1778
|
+
"deps-changed": hash([common, status]),
|
|
1779
|
+
"decision-anchor-drift": hash([common, layout, decisions, status, index])
|
|
1780
|
+
};
|
|
1781
|
+
return { fingerprint: hash(keys), head, docs, keys };
|
|
1782
|
+
}
|
|
1783
|
+
var cacheSchema = z4.object({ version: z4.literal(1), entries: z4.record(z4.object({ key: z4.string(), result: checkResultSchema })), digest: z4.string() });
|
|
1784
|
+
function checkCache(raw, inputs) {
|
|
1785
|
+
let entries = {};
|
|
1786
|
+
let diagnostic = null;
|
|
1787
|
+
if (raw !== null) {
|
|
1788
|
+
const parsed = cacheSchema.safeParse(raw);
|
|
1789
|
+
if (parsed.success && parsed.data.digest === hash(parsed.data.entries)) entries = parsed.data.entries;
|
|
1790
|
+
else diagnostic = "Discarded an invalid automation cache; checks are being recomputed.";
|
|
1791
|
+
}
|
|
1792
|
+
const ran = /* @__PURE__ */ new Set(), reused = /* @__PURE__ */ new Set();
|
|
1793
|
+
const options = { runCheck: async (name, ctx) => {
|
|
1794
|
+
if (entries[name]?.key === inputs.keys[name]) {
|
|
1795
|
+
reused.add(name);
|
|
1796
|
+
return structuredClone(entries[name].result);
|
|
1797
|
+
}
|
|
1798
|
+
const result = await CHECKS[name](ctx);
|
|
1799
|
+
ran.add(name);
|
|
1800
|
+
if (!result.skipped.length) entries[name] = { key: inputs.keys[name], result };
|
|
1801
|
+
else delete entries[name];
|
|
1802
|
+
return result;
|
|
1803
|
+
} };
|
|
1804
|
+
return { options, ran, reused, diagnostic, serialize: () => {
|
|
1805
|
+
const canonical = cacheSchema.shape.entries.parse(entries);
|
|
1806
|
+
return { version: 1, entries: canonical, digest: hash(canonical) };
|
|
1807
|
+
} };
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// src/automation/store.ts
|
|
1811
|
+
import fs12 from "fs/promises";
|
|
1812
|
+
import os from "os";
|
|
1813
|
+
import { z as z5 } from "zod";
|
|
1814
|
+
var hostSchema = z5.enum(["claude", "codex"]);
|
|
1815
|
+
var stateSchema = z5.object({
|
|
1816
|
+
version: z5.literal(1),
|
|
1817
|
+
root: z5.string(),
|
|
1818
|
+
gitDir: z5.string(),
|
|
1819
|
+
branch: z5.string(),
|
|
1820
|
+
baselines: z5.array(z5.object({ path: z5.string(), at: z5.string(), event: z5.string(), fingerprint: z5.string() })).max(128),
|
|
1821
|
+
sessions: z5.record(z5.object({
|
|
1822
|
+
host: hostSchema,
|
|
1823
|
+
seen: z5.string().nullable(),
|
|
1824
|
+
continued: z5.boolean(),
|
|
1825
|
+
initialIssues: z5.array(z5.string()),
|
|
1826
|
+
initialDocs: z5.record(z5.string().nullable()),
|
|
1827
|
+
lastUsed: z5.string(),
|
|
1828
|
+
mutationObserved: z5.boolean(),
|
|
1829
|
+
pending: z5.record(z5.string()),
|
|
1830
|
+
coverageGaps: z5.array(z5.string()),
|
|
1831
|
+
events: z5.record(z5.object({ at: z5.string(), count: z5.number().int().positive() }))
|
|
1832
|
+
})),
|
|
1833
|
+
updatedAt: z5.string(),
|
|
1834
|
+
fingerprint: z5.string().nullable(),
|
|
1835
|
+
latest: z5.string().nullable()
|
|
1836
|
+
});
|
|
1837
|
+
async function withLock(root, directory, run) {
|
|
1838
|
+
const file = await storePath(root, directory + "/lock", true);
|
|
1839
|
+
const deadline = Date.now() + 5e3;
|
|
1840
|
+
let handle;
|
|
1841
|
+
while (!handle) {
|
|
1842
|
+
try {
|
|
1843
|
+
handle = await fs12.open(file, "wx", 384);
|
|
1844
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, host: os.hostname() }));
|
|
1845
|
+
} catch (error) {
|
|
1846
|
+
if (error.code !== "EEXIST") throw error;
|
|
1847
|
+
try {
|
|
1848
|
+
const owner = JSON.parse(await fs12.readFile(file, "utf8"));
|
|
1849
|
+
if (owner.host === os.hostname() && Number.isInteger(owner.pid) && owner.pid > 0) {
|
|
1850
|
+
try {
|
|
1851
|
+
process.kill(owner.pid, 0);
|
|
1852
|
+
} catch (probe) {
|
|
1853
|
+
if (probe.code === "ESRCH") {
|
|
1854
|
+
const reclaim = file + ".reclaim";
|
|
1855
|
+
let guard;
|
|
1856
|
+
try {
|
|
1857
|
+
guard = await fs12.open(reclaim, "wx", 384);
|
|
1858
|
+
const current = JSON.parse(await fs12.readFile(file, "utf8"));
|
|
1859
|
+
if (current.pid === owner.pid && current.host === owner.host) await fs12.unlink(file);
|
|
1860
|
+
} finally {
|
|
1861
|
+
if (guard) {
|
|
1862
|
+
await guard.close();
|
|
1863
|
+
await fs12.rm(reclaim, { force: true });
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
} catch {
|
|
1870
|
+
}
|
|
1871
|
+
if (Date.now() >= deadline) throw new Error("Automation is busy or its lock needs inspection: " + file);
|
|
1872
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
try {
|
|
1876
|
+
return await run();
|
|
1877
|
+
} finally {
|
|
1878
|
+
await handle.close();
|
|
1879
|
+
await fs12.unlink(file);
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
// src/automation/runtime.ts
|
|
1884
|
+
var SCOPE = "Documentation audit evidence only. Hook receipts show observed events, not complete interception. Resolved claims no longer fail their checks; advisories need separate review. Repair only within the user's task authorization.";
|
|
1885
|
+
var priority = { resolved: 0, "review-required": 1, unresolved: 2, unverified: 3 };
|
|
1886
|
+
var cleanText = (text2) => text2.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 250);
|
|
1887
|
+
function summarize(report) {
|
|
1888
|
+
const open = report.findings.filter((f) => f.status !== "resolved");
|
|
1889
|
+
return [
|
|
1890
|
+
`Mason: ${report.status}; ${report.counts.unresolved} unresolved, ${report.counts["review-required"]} need review, ${report.counts.unverified} unverified.`,
|
|
1891
|
+
...open.slice(0, 4).map((f) => `[${f.status}] ${cleanText(f.original.anchor.doc)}: ${cleanText(f.original.message)}`),
|
|
1892
|
+
...open.length > 4 ? [`${open.length - 4} more findings in the report.`] : [],
|
|
1893
|
+
...report.diagnostics.slice(0, 2).map(cleanText),
|
|
1894
|
+
`Evidence: ${report.reportPath}. Resume/check with mason_automation(action: "check") or mason-auto check.`,
|
|
1895
|
+
"Keep original evidence. Address findings relevant to the authorized task; report unrelated findings and unresolved advisories without approving them."
|
|
1896
|
+
].join("\n");
|
|
1897
|
+
}
|
|
1898
|
+
async function automate(dir, event) {
|
|
1899
|
+
const ws = await workspace(dir);
|
|
1900
|
+
return withLock(ws.root, ws.directory, async () => {
|
|
1901
|
+
const inputs = await readInputs(ws.root);
|
|
1902
|
+
const statePath = ws.directory + "/state.json";
|
|
1903
|
+
const raw = await readStoreJson(ws.root, statePath);
|
|
1904
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1905
|
+
const state = raw === null ? {
|
|
1906
|
+
version: 1,
|
|
1907
|
+
root: ws.root,
|
|
1908
|
+
gitDir: ws.gitDir,
|
|
1909
|
+
branch: ws.branch,
|
|
1910
|
+
baselines: [],
|
|
1911
|
+
sessions: {},
|
|
1912
|
+
updatedAt: now,
|
|
1913
|
+
fingerprint: null,
|
|
1914
|
+
latest: null
|
|
1915
|
+
} : stateSchema.parse(raw);
|
|
1916
|
+
if (state.root !== ws.root || state.gitDir !== ws.gitDir || state.branch !== ws.branch) {
|
|
1917
|
+
throw new Error("Automation state belongs to another branch or worktree; original evidence was retained.");
|
|
1918
|
+
}
|
|
1919
|
+
if (ws.branch === "detached" && state.latest) {
|
|
1920
|
+
const previous = await readStoreJson(ws.root, state.latest);
|
|
1921
|
+
if (!previous?.head || !/^[a-f0-9]{40,64}$/.test(previous.head)) throw new Error("The previous detached checkout evidence is unavailable.");
|
|
1922
|
+
try {
|
|
1923
|
+
await git(ws.root, "merge-base", "--is-ancestor", previous.head, inputs.head);
|
|
1924
|
+
} catch {
|
|
1925
|
+
throw new Error("Detached checkout moved to a different history; original repair evidence was retained. Inspect that baseline explicitly.");
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
const key = event.host && event.sessionId ? hash([event.host, event.sessionId]) : null;
|
|
1929
|
+
const newSession = key !== null && !state.sessions[key];
|
|
1930
|
+
if (key && !state.sessions[key]) {
|
|
1931
|
+
const keys = Object.keys(state.sessions).sort((a, b) => state.sessions[a].lastUsed.localeCompare(state.sessions[b].lastUsed));
|
|
1932
|
+
for (const expired of keys.slice(0, Math.max(0, keys.length - 31))) delete state.sessions[expired];
|
|
1933
|
+
state.sessions[key] = {
|
|
1934
|
+
host: event.host,
|
|
1935
|
+
seen: null,
|
|
1936
|
+
continued: false,
|
|
1937
|
+
initialIssues: [],
|
|
1938
|
+
initialDocs: inputs.docs,
|
|
1939
|
+
lastUsed: now,
|
|
1940
|
+
mutationObserved: false,
|
|
1941
|
+
pending: {},
|
|
1942
|
+
coverageGaps: [],
|
|
1943
|
+
events: {}
|
|
1944
|
+
};
|
|
1945
|
+
}
|
|
1946
|
+
const session = key ? state.sessions[key] : null;
|
|
1947
|
+
if (session) {
|
|
1948
|
+
session.lastUsed = now;
|
|
1949
|
+
session.events[event.event] = { at: now, count: (session.events[event.event]?.count ?? 0) + 1 };
|
|
1950
|
+
if (event.event === "before_tool" && event.mutating && event.toolId) {
|
|
1951
|
+
if (Object.keys(session.pending).length >= 128) throw new Error("Too many unfinished tool calls to track pre-edit evidence.");
|
|
1952
|
+
session.pending[event.toolId] = inputs.fingerprint;
|
|
1953
|
+
}
|
|
1954
|
+
if (event.event === "after_tool" && event.mutating) {
|
|
1955
|
+
session.mutationObserved = true;
|
|
1956
|
+
if (!event.toolId || !session.pending[event.toolId]) {
|
|
1957
|
+
const gap = "A tool completed without an observed matching pre-tool capture; pre-edit coverage is unknown.";
|
|
1958
|
+
if (!session.coverageGaps.includes(gap)) session.coverageGaps.push(gap);
|
|
1959
|
+
}
|
|
1960
|
+
if (event.toolId) delete session.pending[event.toolId];
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
if (!state.baselines.length && Object.values(inputs.docs).every((value) => value === null)) {
|
|
1964
|
+
const report2 = {
|
|
1965
|
+
version: 1,
|
|
1966
|
+
status: "unavailable",
|
|
1967
|
+
root: ws.root,
|
|
1968
|
+
branch: ws.branch,
|
|
1969
|
+
head: inputs.head,
|
|
1970
|
+
baselinePaths: [],
|
|
1971
|
+
reportPath: ws.directory + "/checks/" + randomUUID3() + ".json",
|
|
1972
|
+
findings: [],
|
|
1973
|
+
diagnostics: ["No AGENTS.md, CLAUDE.md, or .claude/CLAUDE.md exists. Documentation capture is unavailable; other Mason tools remain usable."],
|
|
1974
|
+
checks: { ran: [], reused: [], skipped: [] },
|
|
1975
|
+
counts: { resolved: 0, unresolved: 0, "review-required": 0, unverified: 0 },
|
|
1976
|
+
capture: "unknown",
|
|
1977
|
+
scope: SCOPE
|
|
1978
|
+
};
|
|
1979
|
+
const notify2 = !session || session.seen !== "no-docs";
|
|
1980
|
+
if (session) session.seen = "no-docs";
|
|
1981
|
+
state.fingerprint = inputs.fingerprint;
|
|
1982
|
+
state.latest = report2.reportPath;
|
|
1983
|
+
state.updatedAt = now;
|
|
1984
|
+
await writeStoreJson(ws.root, report2.reportPath, report2);
|
|
1985
|
+
await writeStoreJson(ws.root, statePath, state);
|
|
1986
|
+
return { report: report2, message: notify2 ? summarize(report2) : null, continueOnce: false };
|
|
1987
|
+
}
|
|
1988
|
+
let cached = null;
|
|
1989
|
+
const diagnostics = [];
|
|
1990
|
+
try {
|
|
1991
|
+
cached = await readStoreJson(ws.root, ws.directory + "/cache.json");
|
|
1992
|
+
} catch {
|
|
1993
|
+
diagnostics.push("Unreadable automation cache; checks are being recomputed.");
|
|
1994
|
+
}
|
|
1995
|
+
const cache = checkCache(cached, inputs);
|
|
1996
|
+
if (cache.diagnostic) diagnostics.push(cache.diagnostic);
|
|
1997
|
+
const saveBaseline = async () => {
|
|
1998
|
+
if (state.baselines.length >= 128) throw new Error("128 retained baselines need review; automatic capture stopped without discarding original evidence.");
|
|
1999
|
+
const prepared = await prepareRepair(ws.root, ALL_CHECKS, cache.options);
|
|
2000
|
+
state.baselines.push({ path: prepared.baselinePath, at: now, event: event.event, fingerprint: inputs.fingerprint });
|
|
2001
|
+
};
|
|
2002
|
+
if (!state.baselines.length) await saveBaseline();
|
|
2003
|
+
const verifications = [];
|
|
2004
|
+
for (const baseline of state.baselines) verifications.push(await verifyRepair(ws.root, baseline.path, cache.options));
|
|
2005
|
+
const known = new Set(verifications.flatMap((v) => v.findings.map((f) => f.id)));
|
|
2006
|
+
if (verifications.some((v) => v.newFindings.some((f) => !known.has(findingId(f))))) {
|
|
2007
|
+
await saveBaseline();
|
|
2008
|
+
verifications.push(await verifyRepair(ws.root, state.baselines.at(-1).path, cache.options));
|
|
2009
|
+
}
|
|
2010
|
+
const merged = /* @__PURE__ */ new Map();
|
|
2011
|
+
for (const verification of verifications) {
|
|
2012
|
+
for (const finding of verification.findings) {
|
|
2013
|
+
const previous = merged.get(finding.id);
|
|
2014
|
+
if (!previous || priority[finding.status] > priority[previous.status]) merged.set(finding.id, finding);
|
|
2015
|
+
}
|
|
2016
|
+
diagnostics.push(...verification.diagnostics);
|
|
2017
|
+
}
|
|
2018
|
+
if (newSession && session) session.initialIssues = [...merged.values()].filter((f) => f.status === "unresolved").map((f) => f.id);
|
|
2019
|
+
const current = verifications.at(-1).currentAudit;
|
|
2020
|
+
const counts = { resolved: 0, unresolved: 0, "review-required": 0, unverified: 0 };
|
|
2021
|
+
for (const finding of merged.values()) counts[finding.status]++;
|
|
2022
|
+
if (session) diagnostics.push(...session.coverageGaps);
|
|
2023
|
+
const capture = session && !session.coverageGaps.length && (session.events.session_start || session.events.before_tool) ? "observed" : "unknown";
|
|
2024
|
+
const after = await readInputs(ws.root);
|
|
2025
|
+
const currentWs = await workspace(ws.root);
|
|
2026
|
+
if (after.fingerprint !== inputs.fingerprint || currentWs.directory !== ws.directory) {
|
|
2027
|
+
throw new Error("Repository inputs or branch changed during automation; no current verification was recorded. Retry on a stable checkout.");
|
|
2028
|
+
}
|
|
2029
|
+
const report = {
|
|
2030
|
+
version: 1,
|
|
2031
|
+
status: diagnostics.length || verifications.some((v) => v.status === "incomplete") ? "incomplete" : counts.unresolved ? "issues-remain" : "verified",
|
|
2032
|
+
root: ws.root,
|
|
2033
|
+
branch: ws.branch,
|
|
2034
|
+
head: inputs.head,
|
|
2035
|
+
baselinePaths: state.baselines.map((b) => b.path),
|
|
2036
|
+
reportPath: ws.directory + "/checks/" + randomUUID3() + ".json",
|
|
2037
|
+
findings: [...merged.values()],
|
|
2038
|
+
diagnostics: [...new Set(diagnostics)],
|
|
2039
|
+
checks: { ran: [...cache.ran], reused: [...cache.reused].filter((name) => !cache.ran.has(name)), skipped: current?.skippedChecks ?? [] },
|
|
2040
|
+
counts,
|
|
2041
|
+
capture,
|
|
2042
|
+
scope: SCOPE
|
|
2043
|
+
};
|
|
2044
|
+
const signature = hash([report.status, report.findings, report.diagnostics, report.checks.skipped]);
|
|
2045
|
+
const relevant = report.findings.some((f) => f.status === "unresolved" && session && (!session.initialIssues.includes(f.id) || session.initialDocs[f.original.anchor.doc] !== inputs.docs[f.original.anchor.doc]));
|
|
2046
|
+
const continueOnce = event.event === "task_end" && !!session?.mutationObserved && relevant && !session.continued && !event.stopHookActive;
|
|
2047
|
+
const notify = !session || newSession || signature !== session.seen || continueOnce;
|
|
2048
|
+
if (session) {
|
|
2049
|
+
session.seen = signature;
|
|
2050
|
+
if (continueOnce) session.continued = true;
|
|
2051
|
+
}
|
|
2052
|
+
const persistReport = !state.latest || state.fingerprint !== inputs.fingerprint || notify || event.event === "task_end";
|
|
2053
|
+
if (!persistReport) report.reportPath = state.latest;
|
|
2054
|
+
state.updatedAt = now;
|
|
2055
|
+
state.fingerprint = inputs.fingerprint;
|
|
2056
|
+
state.latest = report.reportPath;
|
|
2057
|
+
if (persistReport) await writeStoreJson(ws.root, report.reportPath, report);
|
|
2058
|
+
await writeStoreJson(ws.root, ws.directory + "/cache.json", cache.serialize());
|
|
2059
|
+
await writeStoreJson(ws.root, statePath, state);
|
|
2060
|
+
return { report, message: notify ? summarize(report) : null, continueOnce };
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
2063
|
+
async function automationStatus(dir) {
|
|
2064
|
+
const ws = await workspace(dir);
|
|
2065
|
+
const raw = await readStoreJson(ws.root, ws.directory + "/state.json");
|
|
2066
|
+
if (raw === null) return { version: 1, status: "not-observed", root: ws.root, branch: ws.branch, baselinePaths: [], hosts: {} };
|
|
2067
|
+
const state = stateSchema.parse(raw);
|
|
2068
|
+
if (state.root !== ws.root || state.gitDir !== ws.gitDir || state.branch !== ws.branch) throw new Error("Automation state belongs to another workspace.");
|
|
2069
|
+
const inputs = await readInputs(ws.root);
|
|
2070
|
+
const latest = state.latest ? await readStoreJson(ws.root, state.latest) : null;
|
|
2071
|
+
const hosts = {};
|
|
2072
|
+
for (const session of Object.values(state.sessions)) {
|
|
2073
|
+
const host = hosts[session.host] ??= { sessions: 0, observedEvents: [] };
|
|
2074
|
+
host.sessions++;
|
|
2075
|
+
host.observedEvents = [.../* @__PURE__ */ new Set([...host.observedEvents, ...Object.keys(session.events)])];
|
|
2076
|
+
}
|
|
2077
|
+
return {
|
|
2078
|
+
version: 1,
|
|
2079
|
+
status: inputs.fingerprint === state.fingerprint ? "current" : "changed",
|
|
2080
|
+
root: ws.root,
|
|
2081
|
+
branch: ws.branch,
|
|
2082
|
+
baselinePaths: state.baselines.map((b) => b.path),
|
|
2083
|
+
reportPath: state.latest,
|
|
2084
|
+
verificationStatus: latest?.status ?? "unavailable",
|
|
2085
|
+
hosts,
|
|
2086
|
+
note: "Observed events do not prove all tool paths are intercepted. Run check to verify the retained evidence."
|
|
2087
|
+
};
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
// src/automation/adapters.ts
|
|
2091
|
+
import { z as z6 } from "zod";
|
|
2092
|
+
var inputSchema = z6.object({
|
|
2093
|
+
cwd: z6.string().min(1),
|
|
2094
|
+
session_id: z6.string().min(1).max(500),
|
|
2095
|
+
hook_event_name: z6.enum(["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"]),
|
|
2096
|
+
tool_name: z6.string().optional(),
|
|
2097
|
+
tool_use_id: z6.string().max(500).optional(),
|
|
2098
|
+
tool_input: z6.unknown().optional(),
|
|
2099
|
+
stop_hook_active: z6.boolean().optional(),
|
|
2100
|
+
permission_mode: z6.string().optional()
|
|
2101
|
+
});
|
|
2102
|
+
var lifecycle = {
|
|
2103
|
+
SessionStart: "session_start",
|
|
2104
|
+
UserPromptSubmit: "turn_start",
|
|
2105
|
+
PreToolUse: "before_tool",
|
|
2106
|
+
PostToolUse: "after_tool",
|
|
2107
|
+
Stop: "task_end"
|
|
2108
|
+
};
|
|
2109
|
+
function normalizeHook(host, raw) {
|
|
2110
|
+
hostSchema.parse(host);
|
|
2111
|
+
const input2 = inputSchema.parse(raw);
|
|
2112
|
+
const readOnly = host === "claude" ? /^(Read|Glob|Grep|WebSearch|WebFetch)$/ : /^(read_file|list_dir|grep_files)$/;
|
|
2113
|
+
return { cwd: input2.cwd, name: input2.hook_event_name, event: {
|
|
2114
|
+
event: lifecycle[input2.hook_event_name],
|
|
2115
|
+
host,
|
|
2116
|
+
sessionId: input2.session_id,
|
|
2117
|
+
toolId: input2.tool_use_id,
|
|
2118
|
+
mutating: !!input2.tool_name && !readOnly.test(input2.tool_name),
|
|
2119
|
+
stopHookActive: input2.stop_hook_active || input2.permission_mode === "plan"
|
|
2120
|
+
} };
|
|
2121
|
+
}
|
|
2122
|
+
async function runAutomationHook(host, stdin) {
|
|
2123
|
+
let name = "";
|
|
2124
|
+
try {
|
|
2125
|
+
if (Buffer.byteLength(stdin) > 1024 * 1024) throw new Error("Hook input exceeds 1 MiB.");
|
|
2126
|
+
const input2 = normalizeHook(host, JSON.parse(stdin));
|
|
2127
|
+
name = input2.name;
|
|
2128
|
+
const result = await automate(input2.cwd, input2.event);
|
|
2129
|
+
if (!result.message) return null;
|
|
2130
|
+
if (name === "Stop") {
|
|
2131
|
+
return result.continueOnce ? { decision: "block", reason: result.message } : { systemMessage: result.message };
|
|
2132
|
+
}
|
|
2133
|
+
return { hookSpecificOutput: { hookEventName: name, additionalContext: result.message } };
|
|
2134
|
+
} catch (error) {
|
|
2135
|
+
const message = "Mason automation unavailable; evidence capture/verification was not established. " + (error instanceof Error ? error.message : String(error)).replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 700);
|
|
2136
|
+
return { systemMessage: message, ...["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse"].includes(name) ? { hookSpecificOutput: { hookEventName: name, additionalContext: message } } : {} };
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
var HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"];
|
|
2140
|
+
function hookConfig(host, command = "npx --no-install --package mason-context mason-auto") {
|
|
2141
|
+
const handler = { type: "command", command: command + " hook --host " + host, timeout: 30 };
|
|
2142
|
+
return { hooks: Object.fromEntries(HOOK_EVENTS.map((name) => [
|
|
2143
|
+
name,
|
|
2144
|
+
[{ ...["PreToolUse", "PostToolUse"].includes(name) ? { matcher: ".*" } : {}, hooks: [{ ...handler }] }]
|
|
2145
|
+
])) };
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// src/automation/install.ts
|
|
2149
|
+
import { z as z7 } from "zod";
|
|
2150
|
+
var groupSchema = z7.object({ hooks: z7.array(z7.object({ type: z7.string(), command: z7.string().optional() }).passthrough()) }).passthrough();
|
|
2151
|
+
var configSchema = z7.object({ hooks: z7.record(z7.array(groupSchema)).optional() }).passthrough();
|
|
2152
|
+
var recordSchema = z7.object({ version: z7.literal(1), hosts: z7.record(z7.object({ command: z7.string() })) });
|
|
2153
|
+
var configPath = (host) => host === "claude" ? ".claude/settings.json" : ".codex/hooks.json";
|
|
2154
|
+
async function installAutomation(dir, host, command) {
|
|
2155
|
+
const ws = await workspace(dir);
|
|
2156
|
+
return withLock(ws.root, ".mason/reports/automation-install", () => installLocked(ws.root, host, command));
|
|
2157
|
+
}
|
|
2158
|
+
async function installLocked(root, host, command) {
|
|
2159
|
+
const file = configPath(host);
|
|
2160
|
+
const existing = configSchema.parse(await readStoreJson(root, file) ?? {});
|
|
2161
|
+
const record = recordSchema.parse(await readStoreJson(root, ".mason/automation.json") ?? { version: 1, hosts: {} });
|
|
2162
|
+
const desired = hookConfig(host, command);
|
|
2163
|
+
const newCommand = desired.hooks.SessionStart[0].hooks[0].command;
|
|
2164
|
+
const previous = record.hosts[host]?.command;
|
|
2165
|
+
const hooks = existing.hooks ?? {};
|
|
2166
|
+
for (const event of HOOK_EVENTS) {
|
|
2167
|
+
hooks[event] = (hooks[event] ?? []).map((group) => ({
|
|
2168
|
+
...group,
|
|
2169
|
+
hooks: group.hooks.filter((handler) => !(handler.type === "command" && typeof handler.command === "string" && (handler.command === previous || handler.command === newCommand)))
|
|
2170
|
+
})).filter((group) => group.hooks.length);
|
|
2171
|
+
hooks[event].push(...desired.hooks[event]);
|
|
2172
|
+
}
|
|
2173
|
+
record.hosts[host] = { command: newCommand };
|
|
2174
|
+
await writeStoreJson(root, file, { ...existing, hooks });
|
|
2175
|
+
await writeStoreJson(root, ".mason/automation.json", record);
|
|
2176
|
+
return {
|
|
2177
|
+
version: 1,
|
|
2178
|
+
host,
|
|
2179
|
+
configPath: file,
|
|
2180
|
+
status: "configured",
|
|
2181
|
+
command: newCommand,
|
|
2182
|
+
events: HOOK_EVENTS,
|
|
2183
|
+
next: host === "codex" ? "Review/trust these hooks using Codex /hooks and start a new session. mason-auto status reports observed events separately from configuration." : "Start a new Claude Code session. mason-auto status reports observed events separately from configuration.",
|
|
2184
|
+
note: "Install mason-context in the project before using the default command. Ignore .mason/reports/ to keep local evidence out of commits. Hooks preserve evidence and suggest scoped repairs; they do not approve edits or decisions."
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
async function installedAutomation(dir) {
|
|
2188
|
+
const ws = await workspace(dir);
|
|
2189
|
+
const raw = await readStoreJson(ws.root, ".mason/automation.json");
|
|
2190
|
+
if (raw === null) return {};
|
|
2191
|
+
const record = recordSchema.parse(raw);
|
|
2192
|
+
const result = {};
|
|
2193
|
+
for (const host of ["claude", "codex"]) {
|
|
2194
|
+
const expected = record.hosts[host];
|
|
2195
|
+
if (!expected) continue;
|
|
2196
|
+
const current = configSchema.parse(await readStoreJson(ws.root, configPath(host)) ?? {});
|
|
2197
|
+
const configuredEvents = HOOK_EVENTS.filter((event) => current.hooks?.[event]?.some((group) => group.hooks.some((handler) => handler.type === "command" && handler.command === expected.command)));
|
|
2198
|
+
result[host] = {
|
|
2199
|
+
configPath: configPath(host),
|
|
2200
|
+
configuredEvents,
|
|
2201
|
+
status: current.disableAllHooks === true ? "disabled" : configuredEvents.length === HOOK_EVENTS.length ? "configured" : "incomplete",
|
|
2202
|
+
runtime: "Host version, trust, policy, and tool coverage still determine execution; inspect observed events."
|
|
2203
|
+
};
|
|
2204
|
+
}
|
|
2205
|
+
return result;
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
// src/automation/cli.ts
|
|
2209
|
+
var USAGE = `Usage: mason-auto <install|config|status|check|hook> [options]
|
|
2210
|
+
|
|
2211
|
+
install --host claude|codex Merge lifecycle hooks into this project's host config
|
|
2212
|
+
config --host claude|codex Print the host config without writing
|
|
2213
|
+
status Read configured hooks and observed runtime events
|
|
2214
|
+
check Capture/resume and verify retained audit evidence
|
|
2215
|
+
hook --host claude|codex Handle host JSON on stdin
|
|
2216
|
+
|
|
2217
|
+
--dir <path> Project directory (defaults to cwd)
|
|
2218
|
+
--command <prefix> Installed executable prefix for install/config
|
|
2219
|
+
--json Machine-readable check output (status always uses JSON)
|
|
2220
|
+
|
|
2221
|
+
check exits 0 for verified checks, 1 for issues, 2 for incomplete/unavailable.
|
|
2222
|
+
Hooks are advisory and exit 0; a failed capture is reported explicitly.
|
|
2223
|
+
Local evidence is written under .mason/reports/. No LLM calls or source edits.`;
|
|
2224
|
+
async function runAutomationCli(argv2, stdin = "", io = {
|
|
2225
|
+
out: (s) => process.stdout.write(s + "\n"),
|
|
2226
|
+
err: (s) => process.stderr.write(s + "\n")
|
|
2227
|
+
}) {
|
|
2228
|
+
try {
|
|
2229
|
+
const { values, positionals } = parseArgs({ args: argv2, allowPositionals: true, options: {
|
|
2230
|
+
dir: { type: "string" },
|
|
2231
|
+
host: { type: "string" },
|
|
2232
|
+
command: { type: "string" },
|
|
2233
|
+
json: { type: "boolean" },
|
|
2234
|
+
help: { type: "boolean", short: "h" }
|
|
2235
|
+
} });
|
|
2236
|
+
if (values.help || !positionals.length) {
|
|
2237
|
+
io.out(USAGE);
|
|
2238
|
+
return 0;
|
|
2239
|
+
}
|
|
2240
|
+
if (positionals.length !== 1) throw new Error("Expected one command.");
|
|
2241
|
+
const [action] = positionals;
|
|
2242
|
+
const dir = values.dir ?? process.cwd();
|
|
2243
|
+
if (action === "hook") {
|
|
2244
|
+
const output = await runAutomationHook(hostSchema.parse(values.host), stdin);
|
|
2245
|
+
if (output) io.out(JSON.stringify(output));
|
|
2246
|
+
return 0;
|
|
2247
|
+
}
|
|
2248
|
+
if (action === "install" || action === "config") {
|
|
2249
|
+
const host = hostSchema.parse(values.host);
|
|
2250
|
+
io.out(JSON.stringify(action === "install" ? await installAutomation(dir, host, values.command) : hookConfig(host, values.command), null, 2));
|
|
2251
|
+
return 0;
|
|
2252
|
+
}
|
|
2253
|
+
if (values.host || values.command) throw new Error("--host and --command apply only to install/config/hook.");
|
|
2254
|
+
if (action === "status") {
|
|
2255
|
+
io.out(JSON.stringify({ ...await automationStatus(dir), configured: await installedAutomation(dir) }, null, 2));
|
|
2256
|
+
return 0;
|
|
2257
|
+
}
|
|
2258
|
+
if (action !== "check") throw new Error("Unknown automation command: " + action);
|
|
2259
|
+
const { report } = await automate(dir, { event: "task_end" });
|
|
2260
|
+
io.out(values.json ? JSON.stringify(report, null, 2) : summarize(report));
|
|
2261
|
+
return report.status === "verified" ? 0 : report.status === "issues-remain" ? 1 : 2;
|
|
2262
|
+
} catch (error) {
|
|
2263
|
+
const message = "Mason automation unavailable: " + (error instanceof Error ? error.message : String(error));
|
|
2264
|
+
if (argv2.includes("hook")) {
|
|
2265
|
+
io.out(JSON.stringify({ systemMessage: message }));
|
|
2266
|
+
return 0;
|
|
2267
|
+
}
|
|
2268
|
+
io.err(message);
|
|
2269
|
+
return 2;
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
// bin/mason-auto.ts
|
|
2274
|
+
var argv = process.argv.slice(2);
|
|
2275
|
+
var input = "";
|
|
2276
|
+
if (argv.includes("hook") && !argv.some((a) => a === "--help" || a === "-h") && !process.stdin.isTTY) {
|
|
2277
|
+
for await (const chunk of process.stdin) {
|
|
2278
|
+
input += chunk.toString();
|
|
2279
|
+
if (Buffer.byteLength(input) > 1024 * 1024) break;
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
process.exitCode = await runAutomationCli(argv, input);
|
|
2283
|
+
//# sourceMappingURL=mason-auto.js.map
|