mason-context 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +167 -37
- package/dist/mason-audit.js +368 -149
- package/dist/mason-audit.js.map +1 -1
- package/dist/mason-drift.js +479 -209
- package/dist/mason-drift.js.map +1 -1
- package/dist/mason-hook.js +337 -123
- package/dist/mason-hook.js.map +1 -1
- package/dist/mason-mcp.js +3459 -1451
- package/dist/mason-mcp.js.map +1 -1
- package/dist/mason-review.js +988 -97
- package/dist/mason-review.js.map +1 -1
- package/package.json +7 -2
package/dist/mason-drift.js
CHANGED
|
@@ -1,62 +1,53 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/drift/cli.ts
|
|
4
|
-
import
|
|
4
|
+
import path10 from "path";
|
|
5
5
|
|
|
6
6
|
// src/drift/drift.ts
|
|
7
7
|
import fs3 from "fs/promises";
|
|
8
|
-
import
|
|
8
|
+
import path6 from "path";
|
|
9
9
|
import { execFile as execFile3 } from "child_process";
|
|
10
10
|
import { promisify as promisify3 } from "util";
|
|
11
11
|
|
|
12
12
|
// src/snapshot/snapshot.ts
|
|
13
|
-
import
|
|
14
|
-
import path3 from "path";
|
|
13
|
+
import path5 from "path";
|
|
15
14
|
import { execFile as execFile2 } from "child_process";
|
|
16
15
|
import { promisify as promisify2 } from "util";
|
|
17
|
-
import fg3 from "fast-glob";
|
|
18
16
|
|
|
19
|
-
// src/
|
|
17
|
+
// src/utils/files.ts
|
|
20
18
|
import fs from "fs/promises";
|
|
21
|
-
import
|
|
19
|
+
import { constants } from "fs";
|
|
20
|
+
import path2 from "path";
|
|
22
21
|
import { execFile } from "child_process";
|
|
23
22
|
import { promisify } from "util";
|
|
24
23
|
import fg from "fast-glob";
|
|
25
|
-
var exec = promisify(execFile);
|
|
26
|
-
|
|
27
|
-
// src/test-map.ts
|
|
28
|
-
import path2 from "path";
|
|
29
|
-
import fg2 from "fast-glob";
|
|
30
24
|
|
|
31
|
-
// src/
|
|
32
|
-
|
|
33
|
-
function
|
|
34
|
-
|
|
25
|
+
// src/utils/paths.ts
|
|
26
|
+
import path from "path";
|
|
27
|
+
function normalizeRepoPath(value) {
|
|
28
|
+
const slash = value.replace(/\\/g, "/");
|
|
29
|
+
if (!slash || slash.includes("\0") || path.posix.isAbsolute(slash) || /^[A-Za-z]:/.test(slash)) return null;
|
|
30
|
+
if (slash.split("/").includes("..")) return null;
|
|
31
|
+
const normalized = path.posix.normalize(slash).replace(/\/$/, "");
|
|
32
|
+
return normalized === "." ? null : normalized;
|
|
35
33
|
}
|
|
36
|
-
function
|
|
37
|
-
|
|
34
|
+
function isWithinRoot(root, candidate) {
|
|
35
|
+
const relative = path.relative(root, candidate);
|
|
36
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
38
37
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (parsed.version !== 2) return null;
|
|
44
|
-
return parsed;
|
|
45
|
-
} catch {
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
38
|
+
function anchorMatches(anchor, file) {
|
|
39
|
+
const a = normalizeRepoPath(anchor);
|
|
40
|
+
const f = normalizeRepoPath(file);
|
|
41
|
+
return a !== null && f !== null && (a === f || f.startsWith(`${a}/`));
|
|
48
42
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const { stdout } = await exec2("git", ["rev-parse", "HEAD"], {
|
|
52
|
-
cwd: rootDir
|
|
53
|
-
});
|
|
54
|
-
return stdout.trim();
|
|
55
|
-
} catch {
|
|
56
|
-
return "unknown";
|
|
57
|
-
}
|
|
43
|
+
function matchingPaths(anchors, files) {
|
|
44
|
+
return [...new Set(files)].filter((file) => anchors.some((anchor) => anchorMatches(anchor, file)));
|
|
58
45
|
}
|
|
59
|
-
|
|
46
|
+
|
|
47
|
+
// src/utils/files.ts
|
|
48
|
+
var exec = promisify(execFile);
|
|
49
|
+
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"];
|
|
50
|
+
var SOURCE_GLOB = `**/*.{${SOURCE_EXTENSIONS.join(",")}}`;
|
|
60
51
|
var SOURCE_IGNORE = [
|
|
61
52
|
"**/node_modules/**",
|
|
62
53
|
"**/dist/**",
|
|
@@ -64,64 +55,264 @@ var SOURCE_IGNORE = [
|
|
|
64
55
|
"**/.gradle/**",
|
|
65
56
|
"**/target/**",
|
|
66
57
|
"**/.git/**",
|
|
58
|
+
"**/.mason/**",
|
|
67
59
|
"**/vendor/**",
|
|
68
60
|
"**/__pycache__/**",
|
|
69
61
|
"**/venv/**",
|
|
70
62
|
"**/.venv/**",
|
|
71
63
|
"**/*.min.*",
|
|
72
64
|
"**/*.map",
|
|
65
|
+
"**/*.lock",
|
|
73
66
|
"**/generated/**",
|
|
67
|
+
"**/*.generated.*",
|
|
74
68
|
"**/R.java",
|
|
75
|
-
"**/BuildConfig.java"
|
|
69
|
+
"**/BuildConfig.java",
|
|
70
|
+
"**/package-lock.json",
|
|
71
|
+
"**/yarn.lock",
|
|
72
|
+
"**/pnpm-lock.yaml"
|
|
76
73
|
];
|
|
74
|
+
var MAX_SOURCE_BYTES = 1024 * 1024;
|
|
75
|
+
function isSensitiveFile(file) {
|
|
76
|
+
return file.split(/[\\/]/).some(
|
|
77
|
+
(part) => /^(?:\.env(?:\..*)?|id_rsa.*|id_ed25519.*)$|\.(?:pem|key|p12|pfx|jks|keystore)$|credentials\.|secret|^local\.properties$/i.test(part)
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
async function readBoundedFile(file, maxBytes) {
|
|
81
|
+
const handle = await fs.open(file, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
|
|
82
|
+
try {
|
|
83
|
+
const stat = await handle.stat();
|
|
84
|
+
if (!stat.isFile() || stat.size > maxBytes) return null;
|
|
85
|
+
const buffer = Buffer.alloc(Math.min(maxBytes + 1, stat.size + 1));
|
|
86
|
+
let bytes = 0;
|
|
87
|
+
while (bytes < buffer.length) {
|
|
88
|
+
const result = await handle.read(buffer, bytes, buffer.length - bytes, null);
|
|
89
|
+
if (result.bytesRead === 0) break;
|
|
90
|
+
bytes += result.bytesRead;
|
|
91
|
+
}
|
|
92
|
+
return bytes === buffer.length ? null : buffer.subarray(0, bytes).toString("utf8");
|
|
93
|
+
} finally {
|
|
94
|
+
await handle.close();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function loadProjectConfig(root) {
|
|
98
|
+
try {
|
|
99
|
+
const canonicalRoot = await fs.realpath(root);
|
|
100
|
+
const configPath = await fs.realpath(path2.join(root, ".mason/config.json"));
|
|
101
|
+
if (!isWithinRoot(canonicalRoot, configPath)) throw new Error("Project configuration resolves outside the repository");
|
|
102
|
+
const raw = await readBoundedFile(configPath, 64 * 1024);
|
|
103
|
+
if (raw === null) throw new Error("Project configuration is not a regular file or exceeds 64 KiB");
|
|
104
|
+
const value = JSON.parse(raw);
|
|
105
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Expected a configuration object");
|
|
106
|
+
const config = {};
|
|
107
|
+
for (const key of ["patterns", "alwaysInclude", "ignore"]) {
|
|
108
|
+
if (value[key] === void 0) continue;
|
|
109
|
+
if (!Array.isArray(value[key]) || !value[key].every((s) => typeof s === "string")) {
|
|
110
|
+
throw new Error(`Configuration ${key} must be an array of strings`);
|
|
111
|
+
}
|
|
112
|
+
config[key] = value[key];
|
|
113
|
+
}
|
|
114
|
+
return config;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error.code === "ENOENT") return {};
|
|
117
|
+
throw new Error(`Cannot apply project file policy: ${error instanceof Error ? error.message : String(error)}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async function createFileAccess(rootDir) {
|
|
121
|
+
const root = path2.resolve(rootDir);
|
|
122
|
+
const canonicalRoot = await fs.realpath(root).catch(() => root);
|
|
123
|
+
const config = await loadProjectConfig(root);
|
|
124
|
+
const ignore = [...SOURCE_IGNORE, ...config.ignore ?? []];
|
|
125
|
+
let gitFiles = null;
|
|
126
|
+
try {
|
|
127
|
+
const { stdout } = await exec("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], { cwd: root, maxBuffer: 50 * 1024 * 1024 });
|
|
128
|
+
gitFiles = new Set(stdout.split("\0").filter(Boolean));
|
|
129
|
+
} catch {
|
|
130
|
+
let inGit = false;
|
|
131
|
+
try {
|
|
132
|
+
await exec("git", ["rev-parse", "--git-dir"], { cwd: root });
|
|
133
|
+
inGit = true;
|
|
134
|
+
} catch {
|
|
135
|
+
}
|
|
136
|
+
if (inGit) throw new Error("Cannot enumerate Git files safely");
|
|
137
|
+
}
|
|
138
|
+
async function resolve(file) {
|
|
139
|
+
const relative = normalizeRepoPath(file);
|
|
140
|
+
if (!relative || isSensitiveFile(relative) || gitFiles && !gitFiles.has(relative)) return null;
|
|
141
|
+
const candidate = path2.join(root, relative);
|
|
142
|
+
try {
|
|
143
|
+
const real = await fs.realpath(candidate);
|
|
144
|
+
if (!isWithinRoot(canonicalRoot, real) || isSensitiveFile(path2.relative(canonicalRoot, real))) return null;
|
|
145
|
+
const stat = await fs.stat(real);
|
|
146
|
+
if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES) return null;
|
|
147
|
+
if (gitFiles && !gitFiles.has(path2.relative(canonicalRoot, real).split(path2.sep).join("/"))) return null;
|
|
148
|
+
return real;
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async function list(patterns = SOURCE_GLOB, options = {}) {
|
|
154
|
+
const found = await fg(patterns, { cwd: root, ignore, followSymbolicLinks: false, ...options });
|
|
155
|
+
const safe = await Promise.all(found.map(async (f) => await resolve(f) ? f : null));
|
|
156
|
+
return safe.filter((f) => f !== null).sort();
|
|
157
|
+
}
|
|
158
|
+
async function read(file) {
|
|
159
|
+
const relative = normalizeRepoPath(file);
|
|
160
|
+
if (!relative) return null;
|
|
161
|
+
const real = await resolve(relative);
|
|
162
|
+
if (!real) return null;
|
|
163
|
+
for (const rel of /* @__PURE__ */ new Set([relative, path2.relative(canonicalRoot, real).split(path2.sep).join("/")])) {
|
|
164
|
+
if (!(await fg(fg.escapePath(rel), { cwd: root, ignore, dot: true })).length) return null;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const content = await readBoundedFile(real, MAX_SOURCE_BYTES);
|
|
168
|
+
return content === null ? null : { path: relative, content, totalLines: content.split("\n").length };
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { root, config, list, read };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// src/utils/storage.ts
|
|
177
|
+
import fs2 from "fs/promises";
|
|
178
|
+
import path3 from "path";
|
|
179
|
+
import { randomUUID } from "crypto";
|
|
180
|
+
async function storePath(root, relative, createParents = false) {
|
|
181
|
+
const normalized = normalizeRepoPath(relative);
|
|
182
|
+
if (!normalized) throw new Error(`Invalid store path: ${relative}`);
|
|
183
|
+
let current = await fs2.realpath(root);
|
|
184
|
+
const parts = normalized.split("/");
|
|
185
|
+
for (let i = 0; i < parts.length; i++) {
|
|
186
|
+
current = path3.join(current, parts[i]);
|
|
187
|
+
let stat;
|
|
188
|
+
try {
|
|
189
|
+
stat = await fs2.lstat(current);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (error.code !== "ENOENT") throw error;
|
|
192
|
+
if (createParents && i < parts.length - 1) {
|
|
193
|
+
try {
|
|
194
|
+
await fs2.mkdir(current);
|
|
195
|
+
} catch (mkdirError) {
|
|
196
|
+
if (mkdirError.code !== "EEXIST") throw mkdirError;
|
|
197
|
+
}
|
|
198
|
+
stat = await fs2.lstat(current);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (stat?.isSymbolicLink()) throw new Error(`Symlink in store path: ${relative}`);
|
|
202
|
+
}
|
|
203
|
+
return current;
|
|
204
|
+
}
|
|
205
|
+
async function readStoreJson(root, relative) {
|
|
206
|
+
try {
|
|
207
|
+
const file = await storePath(root, relative);
|
|
208
|
+
const raw = await readBoundedFile(file, 10 * 1024 * 1024);
|
|
209
|
+
if (raw === null) throw new Error("file is not regular or exceeds 10 MiB");
|
|
210
|
+
const parsed = JSON.parse(raw);
|
|
211
|
+
if (parsed === null) throw new Error("expected a JSON object, received null");
|
|
212
|
+
return parsed;
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (error.code === "ENOENT") return null;
|
|
215
|
+
throw new Error(`Invalid Mason store ${relative}: ${error instanceof Error ? error.message : String(error)}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// src/snapshot/snapshot.ts
|
|
220
|
+
import { z } from "zod";
|
|
221
|
+
|
|
222
|
+
// src/test-map.ts
|
|
223
|
+
import path4 from "path";
|
|
224
|
+
|
|
225
|
+
// src/snapshot/snapshot.ts
|
|
226
|
+
var exec2 = promisify2(execFile2);
|
|
227
|
+
var repoPath = z.string().refine((value) => normalizeRepoPath(value) !== null, "Expected a relative repository path");
|
|
228
|
+
var verificationFields = {
|
|
229
|
+
refreshedHash: z.string().optional(),
|
|
230
|
+
verifiedAt: z.string().optional(),
|
|
231
|
+
verifiedHash: z.string().optional(),
|
|
232
|
+
verificationFailed: z.boolean().optional(),
|
|
233
|
+
verificationNote: z.string().optional()
|
|
234
|
+
};
|
|
235
|
+
var featureSchema = z.object({
|
|
236
|
+
description: z.string(),
|
|
237
|
+
files: z.array(repoPath),
|
|
238
|
+
tests: z.array(repoPath).optional(),
|
|
239
|
+
type: z.enum(["capability", "infrastructure"]).optional(),
|
|
240
|
+
...verificationFields
|
|
241
|
+
}).passthrough();
|
|
242
|
+
var flowSchema = z.object({ description: z.string(), chain: z.array(repoPath), ...verificationFields }).passthrough();
|
|
243
|
+
var snapshotSchema = z.object({
|
|
244
|
+
version: z.literal(2),
|
|
245
|
+
createdAt: z.string(),
|
|
246
|
+
updatedAt: z.string(),
|
|
247
|
+
gitHash: z.string(),
|
|
248
|
+
features: z.record(featureSchema),
|
|
249
|
+
flows: z.record(flowSchema)
|
|
250
|
+
}).passthrough();
|
|
251
|
+
async function loadSnapshot(rootDir) {
|
|
252
|
+
const parsed = await readStoreJson(rootDir, ".mason/snapshot.json");
|
|
253
|
+
if (parsed === null || parsed.version === 1) return null;
|
|
254
|
+
const result = snapshotSchema.safeParse(parsed);
|
|
255
|
+
if (!result.success) throw new Error(`Invalid Mason snapshot: ${result.error.message}`);
|
|
256
|
+
return result.data;
|
|
257
|
+
}
|
|
258
|
+
async function getCurrentGitHash(rootDir) {
|
|
259
|
+
try {
|
|
260
|
+
const { stdout } = await exec2("git", ["rev-parse", "HEAD"], {
|
|
261
|
+
cwd: rootDir
|
|
262
|
+
});
|
|
263
|
+
return stdout.trim();
|
|
264
|
+
} catch {
|
|
265
|
+
return "unknown";
|
|
266
|
+
}
|
|
267
|
+
}
|
|
77
268
|
async function listSourceFiles(resolvedRoot) {
|
|
78
|
-
|
|
79
|
-
cwd: resolvedRoot,
|
|
80
|
-
ignore: SOURCE_IGNORE
|
|
81
|
-
});
|
|
82
|
-
return [...all].sort();
|
|
269
|
+
return (await createFileAccess(resolvedRoot)).list();
|
|
83
270
|
}
|
|
84
271
|
|
|
85
272
|
// src/drift/drift.ts
|
|
86
273
|
var exec3 = promisify3(execFile3);
|
|
87
274
|
var FULL_REBUILD_FRACTION = 0.4;
|
|
88
275
|
var FULL_REBUILD_MIN_CHANGED_MAPPED_FILES = 10;
|
|
89
|
-
|
|
90
|
-
|
|
276
|
+
function parseChanges(output) {
|
|
277
|
+
const fields = output.split("\0");
|
|
278
|
+
const changes = [];
|
|
279
|
+
for (let i = 0; i < fields.length && fields[i]; ) {
|
|
280
|
+
const code = fields[i++];
|
|
281
|
+
const first = fields[i++];
|
|
282
|
+
if (!first) break;
|
|
283
|
+
const second = /^[RC]/.test(code) ? fields[i++] : void 0;
|
|
284
|
+
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 };
|
|
285
|
+
if (change.path.startsWith(".mason/") && (!change.previousPath || change.previousPath.startsWith(".mason/"))) continue;
|
|
286
|
+
changes.push(change);
|
|
287
|
+
}
|
|
288
|
+
return changes;
|
|
289
|
+
}
|
|
290
|
+
function touchedPaths(changes) {
|
|
291
|
+
return [...new Set(changes.flatMap((c) => c.previousPath ? [c.previousPath, c.path] : [c.path]))].sort();
|
|
292
|
+
}
|
|
293
|
+
async function getChangesWithStatus(resolvedRoot, fromHash, toHash = "HEAD") {
|
|
294
|
+
if (!fromHash || fromHash === "unknown" || fromHash.startsWith("-") || !toHash || toHash === "unknown" || toHash.startsWith("-")) return null;
|
|
91
295
|
try {
|
|
92
|
-
const { stdout } = await exec3(
|
|
93
|
-
|
|
94
|
-
["diff", "--name-status", "-M", fromHash, "HEAD"],
|
|
95
|
-
{ cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }
|
|
96
|
-
);
|
|
97
|
-
const changes = [];
|
|
98
|
-
for (const line of stdout.split("\n")) {
|
|
99
|
-
if (!line.trim()) continue;
|
|
100
|
-
const parts = line.split(" ");
|
|
101
|
-
if (parts.some((p) => p.startsWith(".mason/"))) continue;
|
|
102
|
-
const code = parts[0];
|
|
103
|
-
if (code.startsWith("R") && parts.length >= 3) {
|
|
104
|
-
changes.push({
|
|
105
|
-
status: "renamed",
|
|
106
|
-
path: parts[2],
|
|
107
|
-
previousPath: parts[1]
|
|
108
|
-
});
|
|
109
|
-
} else if (code.startsWith("C") && parts.length >= 3) {
|
|
110
|
-
changes.push({ status: "added", path: parts[2] });
|
|
111
|
-
} else if (code === "A" && parts.length >= 2) {
|
|
112
|
-
changes.push({ status: "added", path: parts[1] });
|
|
113
|
-
} else if (code === "D" && parts.length >= 2) {
|
|
114
|
-
changes.push({ status: "deleted", path: parts[1] });
|
|
115
|
-
} else if (parts.length >= 2) {
|
|
116
|
-
changes.push({ status: "modified", path: parts[1] });
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
return changes;
|
|
296
|
+
const { stdout } = await exec3("git", ["diff", "--name-status", "-z", "-M", fromHash, toHash, "--"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 });
|
|
297
|
+
return parseChanges(stdout);
|
|
120
298
|
} catch {
|
|
121
299
|
return null;
|
|
122
300
|
}
|
|
123
301
|
}
|
|
302
|
+
async function getWorkingTree(resolvedRoot) {
|
|
303
|
+
try {
|
|
304
|
+
const [diff, untracked] = await Promise.all([
|
|
305
|
+
exec3("git", ["diff", "--name-status", "-z", "-M", "HEAD", "--"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }),
|
|
306
|
+
exec3("git", ["ls-files", "-z", "--others", "--exclude-standard"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 })
|
|
307
|
+
]);
|
|
308
|
+
const untrackedFiles = untracked.stdout.split("\0").filter((f) => f && !f.startsWith(".mason/"));
|
|
309
|
+
return { available: true, changedFiles: [.../* @__PURE__ */ new Set([...touchedPaths(parseChanges(diff.stdout)), ...untrackedFiles])].sort(), untrackedFiles };
|
|
310
|
+
} catch {
|
|
311
|
+
return { available: false, changedFiles: [], untrackedFiles: [] };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
124
314
|
async function countCommitsBehind(resolvedRoot, fromHash) {
|
|
315
|
+
if (!fromHash || fromHash === "unknown" || fromHash.startsWith("-")) return null;
|
|
125
316
|
try {
|
|
126
317
|
const { stdout } = await exec3(
|
|
127
318
|
"git",
|
|
@@ -149,7 +340,7 @@ async function findGhostFiles(resolvedRoot, mappedFiles) {
|
|
|
149
340
|
const ghosts = [];
|
|
150
341
|
for (const file of mappedFiles) {
|
|
151
342
|
try {
|
|
152
|
-
await fs3.access(
|
|
343
|
+
await fs3.access(path6.join(resolvedRoot, file));
|
|
153
344
|
} catch {
|
|
154
345
|
ghosts.push(file);
|
|
155
346
|
}
|
|
@@ -157,181 +348,248 @@ async function findGhostFiles(resolvedRoot, mappedFiles) {
|
|
|
157
348
|
return ghosts.sort();
|
|
158
349
|
}
|
|
159
350
|
async function computeDrift(rootDir) {
|
|
160
|
-
const
|
|
161
|
-
const snapshot = await loadSnapshot(
|
|
351
|
+
const root = path6.resolve(rootDir);
|
|
352
|
+
const snapshot = await loadSnapshot(root);
|
|
162
353
|
if (!snapshot) return null;
|
|
163
|
-
const headHash = await getCurrentGitHash(
|
|
164
|
-
const totalFeatures = Object.keys(snapshot.features).length;
|
|
165
|
-
const totalFlows = Object.keys(snapshot.flows).length;
|
|
354
|
+
const [headHash, workingTree] = await Promise.all([getCurrentGitHash(root), getWorkingTree(root)]);
|
|
166
355
|
const hashFor = (entry) => entry.refreshedHash ?? snapshot.gitHash;
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const staleHashes = headHash === "unknown" ? [] : [...distinctHashes].filter((h) => h !== headHash);
|
|
176
|
-
const stale = staleHashes.length > 0;
|
|
356
|
+
const entries = [...Object.values(snapshot.features), ...Object.values(snapshot.flows)];
|
|
357
|
+
const hashes = /* @__PURE__ */ new Set([snapshot.gitHash, ...entries.map(hashFor)]);
|
|
358
|
+
const changesByHash = /* @__PURE__ */ new Map();
|
|
359
|
+
await Promise.all([...hashes].map(async (hash) => {
|
|
360
|
+
changesByHash.set(hash, hash === headHash && headHash !== "unknown" ? [] : await getChangesWithStatus(root, hash));
|
|
361
|
+
}));
|
|
362
|
+
const historyAvailable = headHash !== "unknown" && [...changesByHash.values()].every((changes) => changes !== null);
|
|
363
|
+
const mappedFiles = collectMappedFiles(snapshot);
|
|
177
364
|
const report = {
|
|
178
|
-
stale,
|
|
365
|
+
stale: !historyAvailable,
|
|
179
366
|
snapshotHash: snapshot.gitHash,
|
|
180
367
|
headHash,
|
|
181
|
-
commitsBehind:
|
|
182
|
-
historyAvailable
|
|
368
|
+
commitsBehind: 0,
|
|
369
|
+
historyAvailable,
|
|
183
370
|
changedFiles: [],
|
|
184
371
|
staleFeatures: {},
|
|
185
372
|
staleFlows: {},
|
|
186
|
-
totalFeatures,
|
|
187
|
-
totalFlows,
|
|
373
|
+
totalFeatures: Object.keys(snapshot.features).length,
|
|
374
|
+
totalFlows: Object.keys(snapshot.flows).length,
|
|
188
375
|
unmappedFiles: [],
|
|
189
|
-
ghostFiles:
|
|
376
|
+
ghostFiles: await findGhostFiles(root, mappedFiles),
|
|
190
377
|
renames: [],
|
|
191
|
-
recommendation: "up-to-date"
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
for (const hash of staleHashes) {
|
|
199
|
-
const changes = await getChangesWithStatus(resolvedRoot, hash);
|
|
200
|
-
if (changes === null) {
|
|
201
|
-
report.historyAvailable = false;
|
|
202
|
-
report.recommendation = "full-rebuild";
|
|
203
|
-
return report;
|
|
204
|
-
}
|
|
205
|
-
changesByHash.set(hash, changes);
|
|
206
|
-
const touched = /* @__PURE__ */ new Set();
|
|
207
|
-
for (const change of changes) {
|
|
208
|
-
touched.add(change.path);
|
|
209
|
-
if (change.previousPath) touched.add(change.previousPath);
|
|
378
|
+
recommendation: historyAvailable ? "up-to-date" : "full-rebuild",
|
|
379
|
+
featureFreshness: {},
|
|
380
|
+
flowFreshness: {},
|
|
381
|
+
workingTree,
|
|
382
|
+
verification: {
|
|
383
|
+
neverVerified: entries.filter((e) => !e.verifiedAt).length,
|
|
384
|
+
failed: [...Object.entries(snapshot.features), ...Object.entries(snapshot.flows)].filter(([, e]) => e.verificationFailed).map(([name]) => name)
|
|
210
385
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
)
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
386
|
+
};
|
|
387
|
+
const counts = await Promise.all([...hashes].map((hash) => hash === headHash ? 0 : countCommitsBehind(root, hash)));
|
|
388
|
+
const knownCounts = counts.filter((n) => n !== null);
|
|
389
|
+
report.commitsBehind = knownCounts.length ? Math.max(...knownCounts) : null;
|
|
390
|
+
const check = (name, files, hash, staleEntries, freshness) => {
|
|
391
|
+
const changes = changesByHash.get(hash);
|
|
392
|
+
const committedHits = changes ? matchingPaths(files, touchedPaths(changes)) : [];
|
|
393
|
+
if (committedHits.length) staleEntries[name] = committedHits;
|
|
394
|
+
const localHits = matchingPaths(files, workingTree.changedFiles);
|
|
395
|
+
freshness[name] = files.length === 0 || changes === null || changes === void 0 || !workingTree.available ? "unknown" : committedHits.length || localHits.length || files.some((f) => report.ghostFiles.includes(f)) ? "changed" : "current";
|
|
396
|
+
};
|
|
220
397
|
for (const [name, feature] of Object.entries(snapshot.features)) {
|
|
221
|
-
|
|
222
|
-
const hits = [...feature.files, ...feature.tests ?? []].filter(
|
|
223
|
-
(f) => touched.has(f)
|
|
224
|
-
);
|
|
225
|
-
if (hits.length > 0) report.staleFeatures[name] = [...new Set(hits)];
|
|
398
|
+
check(name, [...feature.files, ...feature.tests ?? []], hashFor(feature), report.staleFeatures, report.featureFreshness);
|
|
226
399
|
}
|
|
227
400
|
for (const [name, flow] of Object.entries(snapshot.flows)) {
|
|
228
|
-
|
|
229
|
-
const hits = flow.chain.filter((f) => touched.has(f));
|
|
230
|
-
if (hits.length > 0) report.staleFlows[name] = [...new Set(hits)];
|
|
401
|
+
check(name, flow.chain, hashFor(flow), report.staleFlows, report.flowFreshness);
|
|
231
402
|
}
|
|
232
|
-
const allChanges = [...changesByHash.values()].
|
|
403
|
+
const allChanges = [...changesByHash.values()].flatMap((changes) => changes ?? []);
|
|
233
404
|
report.changedFiles = [...new Set(allChanges.map((c) => c.path))].sort();
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
405
|
+
const sourceFiles = new Set(await listSourceFiles(root));
|
|
406
|
+
let committedFiles = /* @__PURE__ */ new Set();
|
|
407
|
+
try {
|
|
408
|
+
const { stdout } = await exec3("git", ["ls-tree", "-r", "--name-only", "-z", "HEAD"], { cwd: root, maxBuffer: 50 * 1024 * 1024 });
|
|
409
|
+
committedFiles = new Set(stdout.split("\0").filter(Boolean));
|
|
410
|
+
} catch {
|
|
411
|
+
report.historyAvailable = false;
|
|
412
|
+
report.stale = true;
|
|
413
|
+
}
|
|
414
|
+
report.unmappedFiles = [...sourceFiles].filter((f) => committedFiles.has(f) && !mappedFiles.has(f)).sort();
|
|
415
|
+
const renames = /* @__PURE__ */ new Map();
|
|
238
416
|
for (const change of allChanges) {
|
|
239
|
-
if (change.status
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
]);
|
|
249
|
-
const changedFraction = mappedFiles.size > 0 ? changedMapped.size / mappedFiles.size : 0;
|
|
250
|
-
report.recommendation = changedMapped.size >= FULL_REBUILD_MIN_CHANGED_MAPPED_FILES && changedFraction > FULL_REBUILD_FRACTION ? "full-rebuild" : "incremental";
|
|
417
|
+
if (change.status === "renamed" && change.previousPath) renames.set(`${change.previousPath}\0${change.path}`, { from: change.previousPath, to: change.path });
|
|
418
|
+
}
|
|
419
|
+
report.renames = [...renames.values()];
|
|
420
|
+
const changedMapped = /* @__PURE__ */ new Set([...Object.values(report.staleFeatures).flat(), ...Object.values(report.staleFlows).flat()]);
|
|
421
|
+
const committedGhosts = report.ghostFiles.filter((f) => !workingTree.changedFiles.includes(f));
|
|
422
|
+
report.stale ||= changedMapped.size > 0 || report.unmappedFiles.length > 0 || committedGhosts.length > 0;
|
|
423
|
+
if (!report.historyAvailable) report.recommendation = "full-rebuild";
|
|
424
|
+
else if (!report.stale) report.recommendation = "up-to-date";
|
|
425
|
+
else report.recommendation = changedMapped.size >= FULL_REBUILD_MIN_CHANGED_MAPPED_FILES && changedMapped.size / Math.max(1, mappedFiles.size) > FULL_REBUILD_FRACTION ? "full-rebuild" : "incremental";
|
|
251
426
|
return report;
|
|
252
427
|
}
|
|
253
428
|
|
|
254
429
|
// src/decisions/drift.ts
|
|
255
|
-
import
|
|
430
|
+
import path9 from "path";
|
|
256
431
|
|
|
257
432
|
// src/decisions/decisions.ts
|
|
258
433
|
import fs4 from "fs/promises";
|
|
259
|
-
import
|
|
434
|
+
import path8 from "path";
|
|
260
435
|
import { createHash } from "crypto";
|
|
261
436
|
|
|
262
437
|
// src/context/lexical.ts
|
|
263
|
-
import
|
|
438
|
+
import path7 from "path";
|
|
264
439
|
|
|
265
|
-
// src/decisions/
|
|
266
|
-
|
|
267
|
-
|
|
440
|
+
// src/decisions/provenance.ts
|
|
441
|
+
import { z as z2 } from "zod";
|
|
442
|
+
var text = (max) => z2.string().trim().min(1).max(max);
|
|
443
|
+
var decisionSourceSchema = z2.object({
|
|
444
|
+
kind: z2.enum(["pull_request", "issue", "incident", "discussion", "document", "other"]),
|
|
445
|
+
reference: text(1e3),
|
|
446
|
+
note: text(500).optional()
|
|
447
|
+
}).strict();
|
|
448
|
+
var attributionSchema = z2.object({
|
|
449
|
+
owner: text(200).nullable().optional(),
|
|
450
|
+
sources: z2.array(decisionSourceSchema).max(20).optional(),
|
|
451
|
+
actor: text(200).optional()
|
|
452
|
+
});
|
|
453
|
+
var contentSchema = z2.object({
|
|
454
|
+
title: z2.string().min(1),
|
|
455
|
+
body: z2.string().min(1),
|
|
456
|
+
category: z2.enum(["decision", "gotcha", "deprecation", "convention"]),
|
|
457
|
+
files: z2.array(z2.string().refine((f) => normalizeRepoPath(f) !== null)),
|
|
458
|
+
owner: text(200).optional(),
|
|
459
|
+
sources: z2.array(decisionSourceSchema).max(20)
|
|
460
|
+
});
|
|
461
|
+
var approvalSchema = z2.enum(["unreviewed", "proposed", "accepted"]);
|
|
462
|
+
var statusSchema = z2.enum(["active", "superseded", "retired"]);
|
|
463
|
+
var reviewEvidenceSchema = z2.object({
|
|
464
|
+
baseHash: z2.string(),
|
|
465
|
+
headHash: z2.string(),
|
|
466
|
+
historyAvailable: z2.boolean(),
|
|
467
|
+
changedFiles: z2.array(z2.string()),
|
|
468
|
+
localChanges: z2.array(z2.string())
|
|
469
|
+
});
|
|
470
|
+
var eventSchema = z2.object({
|
|
471
|
+
kind: z2.enum(["imported", "created", "revised", "accepted", "reaffirmed", "retired", "superseded"]),
|
|
472
|
+
at: z2.string().datetime(),
|
|
473
|
+
actor: text(200).optional(),
|
|
474
|
+
note: text(1500).optional(),
|
|
475
|
+
revision: z2.number().int().positive(),
|
|
476
|
+
content: contentSchema,
|
|
477
|
+
approval: approvalSchema,
|
|
478
|
+
status: statusSchema,
|
|
479
|
+
refreshedHash: z2.string(),
|
|
480
|
+
evidence: reviewEvidenceSchema.optional()
|
|
481
|
+
});
|
|
482
|
+
var legacySchema = z2.object({
|
|
483
|
+
version: z2.literal(1),
|
|
484
|
+
id: z2.string().regex(/^[a-zA-Z0-9_-]+$/),
|
|
485
|
+
title: z2.string().min(1),
|
|
486
|
+
body: z2.string().min(1),
|
|
487
|
+
category: contentSchema.shape.category,
|
|
488
|
+
files: contentSchema.shape.files,
|
|
489
|
+
createdAt: z2.string(),
|
|
490
|
+
updatedAt: z2.string(),
|
|
491
|
+
refreshedHash: z2.string(),
|
|
492
|
+
status: z2.enum(["active", "superseded"]),
|
|
493
|
+
supersededBy: z2.string().optional()
|
|
494
|
+
}).passthrough();
|
|
495
|
+
var currentSchema = legacySchema.extend({
|
|
496
|
+
version: z2.literal(2),
|
|
497
|
+
status: statusSchema,
|
|
498
|
+
approval: approvalSchema,
|
|
499
|
+
revision: z2.number().int().positive(),
|
|
500
|
+
owner: text(200).optional(),
|
|
501
|
+
sources: z2.array(decisionSourceSchema).max(20),
|
|
502
|
+
history: z2.array(eventSchema).min(1)
|
|
503
|
+
}).superRefine((record, ctx) => {
|
|
504
|
+
const invalid = (message) => ctx.addIssue({ code: "custom", message });
|
|
505
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
506
|
+
let previous;
|
|
507
|
+
for (const event of record.history) {
|
|
508
|
+
if (!previous) {
|
|
509
|
+
if (!["created", "imported"].includes(event.kind) || event.revision !== 1) invalid("History must begin with creation or legacy import at revision 1");
|
|
510
|
+
if (event.approval !== (event.kind === "created" ? "proposed" : "unreviewed")) invalid("Initial records cannot claim acceptance");
|
|
511
|
+
} else {
|
|
512
|
+
if (["created", "imported"].includes(event.kind)) invalid("History cannot restart");
|
|
513
|
+
if (previous.status !== "active") invalid("Archived decisions cannot be changed");
|
|
514
|
+
if (event.revision !== previous.revision + (event.kind === "revised" ? 1 : 0)) invalid("Invalid revision sequence");
|
|
515
|
+
if (event.kind !== "revised" && !same(event.content, previous.content)) invalid("A review cannot silently revise decision content");
|
|
516
|
+
if (event.kind === "reaffirmed" && previous.approval !== "accepted") invalid("Only accepted decisions can be reaffirmed");
|
|
517
|
+
if (event.kind === "accepted" && previous.approval === "accepted") invalid("Use reaffirmation for an accepted decision");
|
|
518
|
+
const approval = event.kind === "revised" ? "proposed" : ["accepted", "reaffirmed"].includes(event.kind) ? "accepted" : previous.approval;
|
|
519
|
+
if (event.approval !== approval) invalid("Approval disagrees with review history");
|
|
520
|
+
if (!["accepted", "reaffirmed"].includes(event.kind) && event.refreshedHash !== previous.refreshedHash) invalid("Only a review can refresh the evidence baseline");
|
|
521
|
+
}
|
|
522
|
+
if (event.kind !== "imported" && event.status !== (event.kind === "retired" ? "retired" : event.kind === "superseded" ? "superseded" : "active")) invalid("Lifecycle disagrees with history");
|
|
523
|
+
if (["accepted", "reaffirmed", "retired"].includes(event.kind) && (!event.actor || !event.note || !event.evidence)) invalid("Reviews require a named reviewer, reason, and code evidence");
|
|
524
|
+
if (["accepted", "reaffirmed"].includes(event.kind)) {
|
|
525
|
+
if (!event.content.owner || !event.content.sources.length) invalid("Accepted decisions require an owner and source");
|
|
526
|
+
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");
|
|
527
|
+
}
|
|
528
|
+
previous = event;
|
|
529
|
+
}
|
|
530
|
+
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");
|
|
531
|
+
});
|
|
532
|
+
var decisionSchema = z2.union([legacySchema, currentSchema]);
|
|
533
|
+
function decisionContent(record) {
|
|
534
|
+
return {
|
|
535
|
+
title: record.title,
|
|
536
|
+
body: record.body,
|
|
537
|
+
category: record.category,
|
|
538
|
+
files: record.files,
|
|
539
|
+
...typeof record.owner === "string" ? { owner: record.owner } : {},
|
|
540
|
+
sources: Array.isArray(record.sources) ? record.sources : []
|
|
541
|
+
};
|
|
268
542
|
}
|
|
269
|
-
|
|
543
|
+
|
|
544
|
+
// src/decisions/decisions.ts
|
|
545
|
+
async function loadDecisionStore(rootDir) {
|
|
546
|
+
const records = [];
|
|
547
|
+
const diagnostics = [];
|
|
270
548
|
let entries;
|
|
271
549
|
try {
|
|
272
|
-
entries = await fs4.readdir(
|
|
273
|
-
} catch {
|
|
274
|
-
|
|
550
|
+
entries = await fs4.readdir(await storePath(rootDir, ".mason/decisions"));
|
|
551
|
+
} catch (error) {
|
|
552
|
+
if (error.code !== "ENOENT") diagnostics.push({ path: ".mason/decisions", message: String(error) });
|
|
553
|
+
return { records, diagnostics };
|
|
275
554
|
}
|
|
276
|
-
const
|
|
277
|
-
for (const entry of entries) {
|
|
555
|
+
for (const entry of entries.sort()) {
|
|
278
556
|
if (!entry.endsWith(".json")) continue;
|
|
557
|
+
const relative = `.mason/decisions/${entry}`;
|
|
279
558
|
try {
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if (parsed.version !== 1 || !parsed.id || !parsed.title || !parsed.body) {
|
|
286
|
-
continue;
|
|
287
|
-
}
|
|
288
|
-
records.push(parsed);
|
|
289
|
-
} catch {
|
|
290
|
-
continue;
|
|
559
|
+
const record = decisionSchema.parse(await readStoreJson(rootDir, relative));
|
|
560
|
+
if (entry !== `${record.id}.json`) throw new Error("Record id does not match its filename");
|
|
561
|
+
records.push(record);
|
|
562
|
+
} catch (error) {
|
|
563
|
+
diagnostics.push({ path: relative, message: error instanceof Error ? error.message : String(error) });
|
|
291
564
|
}
|
|
292
565
|
}
|
|
293
|
-
return records
|
|
566
|
+
return { records, diagnostics };
|
|
294
567
|
}
|
|
295
568
|
|
|
296
569
|
// src/decisions/drift.ts
|
|
297
570
|
async function computeDecisionDrift(rootDir, decisions) {
|
|
298
|
-
const resolvedRoot =
|
|
299
|
-
const
|
|
300
|
-
const report = {
|
|
301
|
-
|
|
302
|
-
totalDecisions: records.length,
|
|
303
|
-
staleDecisions: {}
|
|
304
|
-
};
|
|
305
|
-
const head = await getCurrentGitHash(resolvedRoot);
|
|
571
|
+
const resolvedRoot = path9.resolve(rootDir);
|
|
572
|
+
const store = decisions ? { records: decisions, diagnostics: [] } : await loadDecisionStore(resolvedRoot);
|
|
573
|
+
const report = { historyAvailable: true, totalDecisions: store.records.length, staleDecisions: {}, freshness: {}, diagnostics: store.diagnostics };
|
|
574
|
+
const [head, workingTree] = await Promise.all([getCurrentGitHash(resolvedRoot), getWorkingTree(resolvedRoot)]);
|
|
306
575
|
const changesByHash = /* @__PURE__ */ new Map();
|
|
307
|
-
for (const record of records) {
|
|
308
|
-
if (record.status !== "active"
|
|
309
|
-
if (record.
|
|
576
|
+
for (const record of store.records) {
|
|
577
|
+
if (record.status !== "active") continue;
|
|
578
|
+
if (record.files.length === 0) {
|
|
579
|
+
report.freshness[record.id] = "unknown";
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
310
582
|
let touched = changesByHash.get(record.refreshedHash);
|
|
311
583
|
if (touched === void 0) {
|
|
312
|
-
const changes = await getChangesWithStatus(
|
|
313
|
-
|
|
314
|
-
record.refreshedHash
|
|
315
|
-
);
|
|
316
|
-
if (changes === null) {
|
|
317
|
-
touched = null;
|
|
318
|
-
} else {
|
|
319
|
-
touched = /* @__PURE__ */ new Set();
|
|
320
|
-
for (const change of changes) {
|
|
321
|
-
touched.add(change.path);
|
|
322
|
-
if (change.previousPath) touched.add(change.previousPath);
|
|
323
|
-
}
|
|
324
|
-
}
|
|
584
|
+
const changes = record.refreshedHash === head && head !== "unknown" ? [] : await getChangesWithStatus(resolvedRoot, record.refreshedHash);
|
|
585
|
+
touched = changes === null ? null : touchedPaths(changes);
|
|
325
586
|
changesByHash.set(record.refreshedHash, touched);
|
|
326
587
|
}
|
|
327
|
-
if (touched === null)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (hits.length > 0) {
|
|
333
|
-
report.staleDecisions[record.id] = hits;
|
|
334
|
-
}
|
|
588
|
+
if (touched === null) report.historyAvailable = false;
|
|
589
|
+
const hits = touched ? matchingPaths(record.files, touched) : [];
|
|
590
|
+
if (hits.length) report.staleDecisions[record.id] = hits;
|
|
591
|
+
const localHits = matchingPaths(record.files, workingTree.changedFiles);
|
|
592
|
+
report.freshness[record.id] = touched === null || !workingTree.available ? "unknown" : hits.length || localHits.length ? "changed" : "current";
|
|
335
593
|
}
|
|
336
594
|
return report;
|
|
337
595
|
}
|
|
@@ -384,7 +642,10 @@ function parseArgs(argv) {
|
|
|
384
642
|
}
|
|
385
643
|
function formatDriftSummary(report) {
|
|
386
644
|
if (!report.stale) {
|
|
387
|
-
|
|
645
|
+
const lines2 = [`Concept map is up to date against committed source (HEAD ${report.headHash.slice(0, 7)}).`];
|
|
646
|
+
if (report.workingTree?.changedFiles.length) lines2.push(`Working tree has ${report.workingTree.changedFiles.length} changed files; live context checks report these separately.`);
|
|
647
|
+
if (report.verification?.failed.length) lines2.push(`Verification FAILED: ${report.verification.failed.join(", ")}. Correct these entries before relying on them.`);
|
|
648
|
+
return lines2.join("\n");
|
|
388
649
|
}
|
|
389
650
|
const lines = [];
|
|
390
651
|
const behind = report.commitsBehind !== null ? `${report.commitsBehind} commit${report.commitsBehind === 1 ? "" : "s"} behind HEAD` : "an unknown number of commits behind HEAD";
|
|
@@ -489,11 +750,17 @@ async function runDriftCli(argv, io = {
|
|
|
489
750
|
io.out(USAGE);
|
|
490
751
|
return 0;
|
|
491
752
|
}
|
|
492
|
-
const rootDir =
|
|
493
|
-
|
|
753
|
+
const rootDir = path10.resolve(args.dir);
|
|
754
|
+
let report;
|
|
755
|
+
try {
|
|
756
|
+
report = await computeDrift(rootDir);
|
|
757
|
+
} catch (error) {
|
|
758
|
+
io.err(error instanceof Error ? error.message : String(error));
|
|
759
|
+
return 2;
|
|
760
|
+
}
|
|
494
761
|
if (!report) {
|
|
495
762
|
io.err(
|
|
496
|
-
`No Mason snapshot found at ${
|
|
763
|
+
`No Mason snapshot found at ${path10.join(rootDir, ".mason", "snapshot.json")}. Build one via the Mason MCP server first.`
|
|
497
764
|
);
|
|
498
765
|
return 2;
|
|
499
766
|
}
|
|
@@ -512,12 +779,15 @@ async function runDriftCli(argv, io = {
|
|
|
512
779
|
}
|
|
513
780
|
if (args.json) {
|
|
514
781
|
const output = report;
|
|
515
|
-
if (decisionDrift.totalDecisions > 0) {
|
|
782
|
+
if (decisionDrift.totalDecisions > 0 || decisionDrift.diagnostics?.length) {
|
|
516
783
|
output.decisions = decisionDrift;
|
|
517
784
|
}
|
|
518
785
|
io.out(JSON.stringify(output, null, 2));
|
|
519
786
|
} else {
|
|
520
787
|
const lines = [formatDriftSummary(report)];
|
|
788
|
+
for (const diagnostic of decisionDrift.diagnostics ?? []) lines.push(`Invalid decision record ${diagnostic.path}: ${diagnostic.message}`);
|
|
789
|
+
const unknownIds = Object.entries(decisionDrift.freshness ?? {}).filter(([, state]) => state === "unknown").map(([id]) => id);
|
|
790
|
+
if (unknownIds.length) lines.push(`Decision freshness unknown: ${unknownIds.join(", ")}. Verify before relying on them.`);
|
|
521
791
|
const staleIds = Object.keys(decisionDrift.staleDecisions);
|
|
522
792
|
if (staleIds.length > 0) {
|
|
523
793
|
lines.push(
|