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.
@@ -1,62 +1,53 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/drift/cli.ts
4
- import path8 from "path";
4
+ import path10 from "path";
5
5
 
6
6
  // src/drift/drift.ts
7
7
  import fs3 from "fs/promises";
8
- import path4 from "path";
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 fs2 from "fs/promises";
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/mcp/sampler.ts
17
+ // src/utils/files.ts
20
18
  import fs from "fs/promises";
21
- import path from "path";
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/snapshot/snapshot.ts
32
- var exec2 = promisify2(execFile2);
33
- function snapshotDir(rootDir) {
34
- return path3.join(rootDir, ".mason");
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 snapshotPath(rootDir) {
37
- return path3.join(snapshotDir(rootDir), "snapshot.json");
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
- async function loadSnapshot(rootDir) {
40
- try {
41
- const raw = await fs2.readFile(snapshotPath(rootDir), "utf-8");
42
- const parsed = JSON.parse(raw);
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
- async function getCurrentGitHash(rootDir) {
50
- try {
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
- var SOURCE_GLOB = "**/*.{ts,tsx,js,jsx,kt,kts,java,py,go,rs,swift,rb,cs,cpp,c,dart}";
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
- const all = await fg3(SOURCE_GLOB, {
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
- async function getChangesWithStatus(resolvedRoot, fromHash) {
90
- if (!fromHash || fromHash === "unknown") return null;
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
- "git",
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(path4.join(resolvedRoot, file));
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 resolvedRoot = path4.resolve(rootDir);
161
- const snapshot = await loadSnapshot(resolvedRoot);
351
+ const root = path6.resolve(rootDir);
352
+ const snapshot = await loadSnapshot(root);
162
353
  if (!snapshot) return null;
163
- const headHash = await getCurrentGitHash(resolvedRoot);
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 distinctHashes = /* @__PURE__ */ new Set([snapshot.gitHash]);
168
- for (const feature of Object.values(snapshot.features)) {
169
- distinctHashes.add(hashFor(feature));
170
- }
171
- for (const flow of Object.values(snapshot.flows)) {
172
- distinctHashes.add(hashFor(flow));
173
- }
174
- distinctHashes.delete("unknown");
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: stale ? null : 0,
182
- historyAvailable: true,
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
- if (!stale) return report;
194
- const mappedFiles = collectMappedFiles(snapshot);
195
- report.ghostFiles = await findGhostFiles(resolvedRoot, mappedFiles);
196
- const changesByHash = /* @__PURE__ */ new Map();
197
- const touchedByHash = /* @__PURE__ */ new Map();
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
- touchedByHash.set(hash, touched);
212
- }
213
- const commitCounts = await Promise.all(
214
- staleHashes.map((hash) => countCommitsBehind(resolvedRoot, hash))
215
- );
216
- const validCounts = commitCounts.filter((c) => c !== null);
217
- report.commitsBehind = validCounts.length > 0 ? Math.max(...validCounts) : null;
218
- const emptySet = /* @__PURE__ */ new Set();
219
- const touchedFor = (entry) => touchedByHash.get(hashFor(entry)) ?? emptySet;
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
- const touched = touchedFor(feature);
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
- const touched = touchedFor(flow);
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()].flat();
403
+ const allChanges = [...changesByHash.values()].flatMap((changes) => changes ?? []);
233
404
  report.changedFiles = [...new Set(allChanges.map((c) => c.path))].sort();
234
- const sourceFileSet = new Set(await listSourceFiles(resolvedRoot));
235
- const newPaths = allChanges.filter((c) => c.status === "added" || c.status === "renamed").map((c) => c.path);
236
- report.unmappedFiles = [...new Set(newPaths)].filter((p) => sourceFileSet.has(p) && !mappedFiles.has(p)).sort();
237
- const renameKeys = /* @__PURE__ */ new Set();
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 !== "renamed" || !change.previousPath) continue;
240
- const key = `${change.previousPath}\0${change.path}`;
241
- if (renameKeys.has(key)) continue;
242
- renameKeys.add(key);
243
- report.renames.push({ from: change.previousPath, to: change.path });
244
- }
245
- const changedMapped = /* @__PURE__ */ new Set([
246
- ...Object.values(report.staleFeatures).flat(),
247
- ...Object.values(report.staleFlows).flat()
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 path7 from "path";
430
+ import path9 from "path";
256
431
 
257
432
  // src/decisions/decisions.ts
258
433
  import fs4 from "fs/promises";
259
- import path6 from "path";
434
+ import path8 from "path";
260
435
  import { createHash } from "crypto";
261
436
 
262
437
  // src/context/lexical.ts
263
- import path5 from "path";
438
+ import path7 from "path";
264
439
 
265
- // src/decisions/decisions.ts
266
- function decisionsDir(rootDir) {
267
- return path6.join(rootDir, ".mason", "decisions");
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
- async function loadDecisions(rootDir) {
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(decisionsDir(rootDir));
273
- } catch {
274
- return [];
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 records = [];
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 raw = await fs4.readFile(
281
- path6.join(decisionsDir(rootDir), entry),
282
- "utf-8"
283
- );
284
- const parsed = JSON.parse(raw);
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.sort((a, b) => a.id.localeCompare(b.id));
566
+ return { records, diagnostics };
294
567
  }
295
568
 
296
569
  // src/decisions/drift.ts
297
570
  async function computeDecisionDrift(rootDir, decisions) {
298
- const resolvedRoot = path7.resolve(rootDir);
299
- const records = decisions ?? await loadDecisions(resolvedRoot);
300
- const report = {
301
- historyAvailable: true,
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" || record.files.length === 0) continue;
309
- if (record.refreshedHash === head) continue;
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
- resolvedRoot,
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
- report.historyAvailable = false;
329
- continue;
330
- }
331
- const hits = record.files.filter((f) => touched.has(f));
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
- return `Concept map is up to date (HEAD ${report.headHash.slice(0, 7)}).`;
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 = path8.resolve(args.dir);
493
- const report = await computeDrift(rootDir);
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 ${path8.join(rootDir, ".mason", "snapshot.json")}. Build one via the Mason MCP server first.`
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(