mason-context 0.20.2 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,7 +31,7 @@ var init_types = __esm({
31
31
  });
32
32
 
33
33
  // src/drift/cli.ts
34
- import path10 from "path";
34
+ import path11 from "path";
35
35
 
36
36
  // src/utils/git-read.ts
37
37
  import { execFile } from "child_process";
@@ -55,7 +55,19 @@ async function profilePhase(label, run) {
55
55
 
56
56
  // src/utils/git-read.ts
57
57
  var execute = promisify(execFile);
58
- var commands = /* @__PURE__ */ new Set(["rev-parse", "symbolic-ref", "for-each-ref", "ls-files", "check-ignore", "status", "log", "diff", "ls-tree", "show", "merge-base"]);
58
+ var commands = /* @__PURE__ */ new Set([
59
+ "rev-parse",
60
+ "symbolic-ref",
61
+ "for-each-ref",
62
+ "ls-files",
63
+ "check-ignore",
64
+ "status",
65
+ "log",
66
+ "diff",
67
+ "ls-tree",
68
+ "show",
69
+ "merge-base"
70
+ ]);
59
71
  function execGit(args, options) {
60
72
  const command = args.find((arg) => !arg.startsWith("-")) ?? "other";
61
73
  return profilePhase("git." + (commands.has(command) ? command : "other"), async () => {
@@ -77,6 +89,14 @@ import path6 from "path";
77
89
  // src/snapshot/snapshot.ts
78
90
  import path5 from "path";
79
91
 
92
+ // src/utils/validation.ts
93
+ function isRecord(value) {
94
+ return value !== null && typeof value === "object" && !Array.isArray(value);
95
+ }
96
+ function isStringArray(value) {
97
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
98
+ }
99
+
80
100
  // src/utils/files.ts
81
101
  import fs from "fs/promises";
82
102
  import { constants } from "fs";
@@ -87,7 +107,8 @@ import fg from "fast-glob";
87
107
  import path from "path";
88
108
  function normalizeRepoPath(value) {
89
109
  const slash = value.replace(/\\/g, "/");
90
- if (!slash || slash.includes("\0") || path.posix.isAbsolute(slash) || /^[A-Za-z]:/.test(slash)) return null;
110
+ if (!slash || slash.includes("\0") || path.posix.isAbsolute(slash) || /^[A-Za-z]:/.test(slash))
111
+ return null;
91
112
  if (slash.split("/").includes("..")) return null;
92
113
  const normalized = path.posix.normalize(slash).replace(/\/$/, "");
93
114
  return normalized === "." ? null : normalized;
@@ -102,11 +123,69 @@ function anchorMatches(anchor, file) {
102
123
  return a !== null && f !== null && (a === f || f.startsWith(`${a}/`));
103
124
  }
104
125
  function matchingPaths(anchors, files) {
105
- return [...new Set(files)].filter((file) => anchors.some((anchor) => anchorMatches(anchor, file)));
126
+ return [...new Set(files)].filter(
127
+ (file) => anchors.some((anchor) => anchorMatches(anchor, file))
128
+ );
129
+ }
130
+ function indexMatchingPaths(scopes, files) {
131
+ const node = () => ({ children: /* @__PURE__ */ new Map(), scopes: /* @__PURE__ */ new Set() });
132
+ const root = node();
133
+ const matches = scopes.map(() => []);
134
+ scopes.forEach((anchors, index) => {
135
+ for (const anchor of anchors) {
136
+ const normalized = normalizeRepoPath(anchor);
137
+ if (!normalized) continue;
138
+ let current = root;
139
+ for (const part of normalized.split("/")) {
140
+ if (!current.children.has(part)) current.children.set(part, node());
141
+ current = current.children.get(part);
142
+ }
143
+ current.scopes.add(index);
144
+ }
145
+ });
146
+ for (const file of new Set(files)) {
147
+ const normalized = normalizeRepoPath(file);
148
+ if (!normalized) continue;
149
+ const selected = /* @__PURE__ */ new Set();
150
+ let current = root;
151
+ for (const part of normalized.split("/")) {
152
+ current = current.children.get(part);
153
+ if (!current) break;
154
+ for (const index of current.scopes) selected.add(index);
155
+ }
156
+ for (const index of selected) matches[index].push(file);
157
+ }
158
+ return matches;
106
159
  }
107
160
 
108
161
  // src/utils/files.ts
109
- 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"];
162
+ var SOURCE_EXTENSIONS = [
163
+ "ts",
164
+ "tsx",
165
+ "js",
166
+ "jsx",
167
+ "mts",
168
+ "cts",
169
+ "mjs",
170
+ "cjs",
171
+ "vue",
172
+ "svelte",
173
+ "kt",
174
+ "kts",
175
+ "java",
176
+ "py",
177
+ "go",
178
+ "rs",
179
+ "swift",
180
+ "rb",
181
+ "cs",
182
+ "cpp",
183
+ "c",
184
+ "h",
185
+ "hpp",
186
+ "dart",
187
+ "php"
188
+ ];
110
189
  var SOURCE_GLOB = `**/*.{${SOURCE_EXTENSIONS.join(",")}}`;
111
190
  var SOURCE_IGNORE = [
112
191
  "**/node_modules/**",
@@ -134,11 +213,16 @@ var SOURCE_IGNORE = [
134
213
  var MAX_SOURCE_BYTES = 1024 * 1024;
135
214
  function isSensitiveFile(file) {
136
215
  return file.split(/[\\/]/).some(
137
- (part) => /^(?:\.env(?:\..*)?|id_rsa.*|id_ed25519.*)$|\.(?:pem|key|p12|pfx|jks|keystore)$|credentials\.|secret|^local\.properties$/i.test(part)
216
+ (part) => /^(?:\.env(?:\..*)?|id_rsa.*|id_ed25519.*)$|\.(?:pem|key|p12|pfx|jks|keystore)$|credentials\.|secret|^local\.properties$/i.test(
217
+ part
218
+ )
138
219
  );
139
220
  }
140
221
  async function readBoundedFile(file, maxBytes) {
141
- const handle = await fs.open(file, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
222
+ const handle = await fs.open(
223
+ file,
224
+ constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW
225
+ );
142
226
  try {
143
227
  const stat = await handle.stat();
144
228
  if (!stat.isFile() || stat.size > maxBytes) return null;
@@ -158,36 +242,44 @@ async function loadProjectConfig(root) {
158
242
  try {
159
243
  const canonicalRoot = await fs.realpath(root);
160
244
  const configPath = await fs.realpath(path2.join(root, ".mason/config.json"));
161
- if (!isWithinRoot(canonicalRoot, configPath)) throw new Error("Project configuration resolves outside the repository");
245
+ if (!isWithinRoot(canonicalRoot, configPath))
246
+ throw new Error("Project configuration resolves outside the repository");
162
247
  const raw = await readBoundedFile(configPath, 64 * 1024);
163
- if (raw === null) throw new Error("Project configuration is not a regular file or exceeds 64 KiB");
248
+ if (raw === null)
249
+ throw new Error("Project configuration is not a regular file or exceeds 64 KiB");
164
250
  const value = JSON.parse(raw);
165
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Expected a configuration object");
251
+ if (!isRecord(value)) throw new Error("Expected a configuration object");
166
252
  const config = {};
167
253
  for (const key of ["patterns", "alwaysInclude", "ignore"]) {
168
254
  if (value[key] === void 0) continue;
169
- if (!Array.isArray(value[key]) || !value[key].every((s) => typeof s === "string")) {
255
+ if (!isStringArray(value[key])) {
170
256
  throw new Error(`Configuration ${key} must be an array of strings`);
171
257
  }
172
258
  config[key] = value[key];
173
259
  }
174
260
  if (value.audit !== void 0) {
175
261
  const audit = value.audit;
176
- if (!audit || typeof audit !== "object" || Array.isArray(audit)) throw new Error("Configuration audit must be an object");
262
+ if (!isRecord(audit)) throw new Error("Configuration audit must be an object");
177
263
  const patterns = (raw2) => {
178
- if (!Array.isArray(raw2) || raw2.length > 100 || !raw2.every((s) => typeof s === "string" && normalizeRepoPath(s) === s && !s.startsWith("!") && !/[\x00-\x1f]/.test(s))) {
179
- throw new Error("Audit patterns must be at most 100 repository-relative globs without traversal or negation");
264
+ if (!isStringArray(raw2) || raw2.length > 100 || !raw2.every(
265
+ (s) => typeof s === "string" && normalizeRepoPath(s) === s && !s.startsWith("!") && !/[\x00-\x1f]/.test(s)
266
+ )) {
267
+ throw new Error(
268
+ "Audit patterns must be at most 100 repository-relative globs without traversal or negation"
269
+ );
180
270
  }
181
271
  return raw2;
182
272
  };
183
273
  config.audit = {};
184
274
  if (audit.include !== void 0) config.audit.include = patterns(audit.include);
185
275
  if (audit.exclude !== void 0) {
186
- if (!audit.exclude || typeof audit.exclude !== "object" || Array.isArray(audit.exclude)) throw new Error("audit.exclude must map check names to patterns");
276
+ if (!isRecord(audit.exclude))
277
+ throw new Error("audit.exclude must map check names to patterns");
187
278
  const { ALL_CHECKS: ALL_CHECKS2 } = await Promise.resolve().then(() => (init_types(), types_exports));
188
279
  config.audit.exclude = {};
189
280
  for (const [check, globs] of Object.entries(audit.exclude)) {
190
- if (!ALL_CHECKS2.includes(check)) throw new Error("Unknown audit check: " + check);
281
+ if (!ALL_CHECKS2.includes(check))
282
+ throw new Error("Unknown audit check: " + check);
191
283
  config.audit.exclude[check] = patterns(globs);
192
284
  }
193
285
  }
@@ -195,7 +287,10 @@ async function loadProjectConfig(root) {
195
287
  return config;
196
288
  } catch (error) {
197
289
  if (error.code === "ENOENT") return {};
198
- throw new Error(`Cannot apply project file policy: ${error instanceof Error ? error.message : String(error)}`);
290
+ throw new Error(
291
+ `Cannot apply project file policy: ${error instanceof Error ? error.message : String(error)}`,
292
+ { cause: error }
293
+ );
199
294
  }
200
295
  }
201
296
  async function createFileAccess(rootDir) {
@@ -205,7 +300,10 @@ async function createFileAccess(rootDir) {
205
300
  const ignore = [...SOURCE_IGNORE, ...config.ignore ?? []];
206
301
  let gitFiles = null;
207
302
  try {
208
- const { stdout } = await execGit(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], { cwd: root, maxBuffer: 50 * 1024 * 1024 });
303
+ const { stdout } = await execGit(
304
+ ["ls-files", "-z", "--cached", "--others", "--exclude-standard"],
305
+ { cwd: root, maxBuffer: 50 * 1024 * 1024 }
306
+ );
209
307
  gitFiles = new Set(stdout.split("\0").filter(Boolean));
210
308
  } catch {
211
309
  let inGit = false;
@@ -218,14 +316,17 @@ async function createFileAccess(rootDir) {
218
316
  }
219
317
  async function resolve(file) {
220
318
  const relative = normalizeRepoPath(file);
221
- if (!relative || isSensitiveFile(relative) || gitFiles && !gitFiles.has(relative)) return null;
319
+ if (!relative || isSensitiveFile(relative) || gitFiles && !gitFiles.has(relative))
320
+ return null;
222
321
  const candidate = path2.join(root, relative);
223
322
  try {
224
323
  const real = await fs.realpath(candidate);
225
- if (!isWithinRoot(canonicalRoot, real) || isSensitiveFile(path2.relative(canonicalRoot, real))) return null;
324
+ if (!isWithinRoot(canonicalRoot, real) || isSensitiveFile(path2.relative(canonicalRoot, real)))
325
+ return null;
226
326
  const stat = await fs.stat(real);
227
327
  if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES) return null;
228
- if (gitFiles && !gitFiles.has(path2.relative(canonicalRoot, real).split(path2.sep).join("/"))) return null;
328
+ if (gitFiles && !gitFiles.has(path2.relative(canonicalRoot, real).split(path2.sep).join("/")))
329
+ return null;
229
330
  return real;
230
331
  } catch {
231
332
  return null;
@@ -241,7 +342,10 @@ async function createFileAccess(rootDir) {
241
342
  if (!relative) return null;
242
343
  const real = await resolve(relative);
243
344
  if (!real) return null;
244
- for (const rel of /* @__PURE__ */ new Set([relative, path2.relative(canonicalRoot, real).split(path2.sep).join("/")])) {
345
+ for (const rel of /* @__PURE__ */ new Set([
346
+ relative,
347
+ path2.relative(canonicalRoot, real).split(path2.sep).join("/")
348
+ ])) {
245
349
  if (!(await fg(fg.escapePath(rel), { cwd: root, ignore, dot: true })).length) return null;
246
350
  }
247
351
  try {
@@ -251,7 +355,7 @@ async function createFileAccess(rootDir) {
251
355
  return null;
252
356
  }
253
357
  }
254
- return { root, config, list, read };
358
+ return { root, config, list, read, inventory: () => gitFiles ? [...gitFiles].sort() : null };
255
359
  }
256
360
 
257
361
  // src/utils/storage.ts
@@ -293,45 +397,50 @@ async function readStoreJson(root, relative) {
293
397
  return parsed;
294
398
  } catch (error) {
295
399
  if (error.code === "ENOENT") return null;
296
- throw new Error(`Invalid Mason store ${relative}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
400
+ throw new Error(
401
+ `Invalid Mason store ${relative}: ${error instanceof Error ? error.message : String(error)}`,
402
+ { cause: error }
403
+ );
297
404
  }
298
405
  }
299
406
 
300
407
  // src/snapshot/snapshot.ts
301
- import { z } from "zod";
408
+ import { z as z2 } from "zod";
302
409
 
303
410
  // src/utils/store-lock.ts
411
+ import { z } from "zod";
304
412
  import fs3 from "fs/promises";
305
413
  import os from "os";
414
+ var lockOwnerSchema = z.object({ host: z.string(), pid: z.number().int().positive() });
306
415
 
307
416
  // src/test-map.ts
308
417
  import path4 from "path";
309
418
 
310
419
  // src/snapshot/snapshot.ts
311
- var repoPath = z.string().refine((value) => normalizeRepoPath(value) !== null, "Expected a relative repository path");
420
+ var repoPath = z2.string().refine((value) => normalizeRepoPath(value) !== null, "Expected a relative repository path");
312
421
  var verificationFields = {
313
- refreshedHash: z.string().optional(),
314
- verifiedAt: z.string().optional(),
315
- verifiedHash: z.string().optional(),
316
- verificationToken: z.string().optional(),
317
- verificationFailed: z.boolean().optional(),
318
- verificationNote: z.string().optional()
422
+ refreshedHash: z2.string().optional(),
423
+ verifiedAt: z2.string().optional(),
424
+ verifiedHash: z2.string().optional(),
425
+ verificationToken: z2.string().optional(),
426
+ verificationFailed: z2.boolean().optional(),
427
+ verificationNote: z2.string().optional()
319
428
  };
320
- var featureSchema = z.object({
321
- description: z.string(),
322
- files: z.array(repoPath),
323
- tests: z.array(repoPath).optional(),
324
- type: z.enum(["capability", "infrastructure"]).optional(),
429
+ var featureSchema = z2.object({
430
+ description: z2.string(),
431
+ files: z2.array(repoPath),
432
+ tests: z2.array(repoPath).optional(),
433
+ type: z2.enum(["capability", "infrastructure"]).optional(),
325
434
  ...verificationFields
326
435
  }).passthrough();
327
- var flowSchema = z.object({ description: z.string(), chain: z.array(repoPath), ...verificationFields }).passthrough();
328
- var snapshotSchema = z.object({
329
- version: z.literal(2),
330
- createdAt: z.string(),
331
- updatedAt: z.string(),
332
- gitHash: z.string(),
333
- features: z.record(featureSchema),
334
- flows: z.record(flowSchema)
436
+ var flowSchema = z2.object({ description: z2.string(), chain: z2.array(repoPath), ...verificationFields }).passthrough();
437
+ var snapshotSchema = z2.object({
438
+ version: z2.literal(2),
439
+ createdAt: z2.string(),
440
+ updatedAt: z2.string(),
441
+ gitHash: z2.string(),
442
+ features: z2.record(featureSchema),
443
+ flows: z2.record(flowSchema)
335
444
  }).passthrough();
336
445
  async function loadSnapshot(rootDir) {
337
446
  const parsed = await readStoreJson(rootDir, ".mason/snapshot.json");
@@ -366,18 +475,25 @@ function parseChanges(output) {
366
475
  if (!first) break;
367
476
  const second = /^[RC]/.test(code) ? fields[i++] : void 0;
368
477
  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 };
369
- if (change.path.startsWith(".mason/") && (!change.previousPath || change.previousPath.startsWith(".mason/"))) continue;
478
+ if (change.path.startsWith(".mason/") && (!change.previousPath || change.previousPath.startsWith(".mason/")))
479
+ continue;
370
480
  changes.push(change);
371
481
  }
372
482
  return changes;
373
483
  }
374
484
  function touchedPaths(changes) {
375
- return [...new Set(changes.flatMap((c) => c.previousPath ? [c.previousPath, c.path] : [c.path]))].sort();
485
+ return [
486
+ ...new Set(changes.flatMap((c) => c.previousPath ? [c.previousPath, c.path] : [c.path]))
487
+ ].sort();
376
488
  }
377
489
  async function getChangesWithStatus(resolvedRoot, fromHash, toHash = "HEAD") {
378
- if (!fromHash || fromHash === "unknown" || fromHash.startsWith("-") || !toHash || toHash === "unknown" || toHash.startsWith("-")) return null;
490
+ if (!fromHash || fromHash === "unknown" || fromHash.startsWith("-") || !toHash || toHash === "unknown" || toHash.startsWith("-"))
491
+ return null;
379
492
  try {
380
- const { stdout } = await execGit(["diff", "--name-status", "-z", "-M", fromHash, toHash, "--"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 });
493
+ const { stdout } = await execGit(
494
+ ["diff", "--name-status", "-z", "-M", fromHash, toHash, "--"],
495
+ { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }
496
+ );
381
497
  return parseChanges(stdout);
382
498
  } catch {
383
499
  return null;
@@ -386,11 +502,23 @@ async function getChangesWithStatus(resolvedRoot, fromHash, toHash = "HEAD") {
386
502
  async function getWorkingTree(resolvedRoot) {
387
503
  try {
388
504
  const [diff, untracked] = await Promise.all([
389
- execGit(["diff", "--name-status", "-z", "-M", "HEAD", "--"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }),
390
- execGit(["ls-files", "-z", "--others", "--exclude-standard"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 })
505
+ execGit(["diff", "--name-status", "-z", "-M", "HEAD", "--"], {
506
+ cwd: resolvedRoot,
507
+ maxBuffer: 10 * 1024 * 1024
508
+ }),
509
+ execGit(["ls-files", "-z", "--others", "--exclude-standard"], {
510
+ cwd: resolvedRoot,
511
+ maxBuffer: 10 * 1024 * 1024
512
+ })
391
513
  ]);
392
514
  const untrackedFiles = untracked.stdout.split("\0").filter((f) => f && !f.startsWith(".mason/"));
393
- return { available: true, changedFiles: [.../* @__PURE__ */ new Set([...touchedPaths(parseChanges(diff.stdout)), ...untrackedFiles])].sort(), untrackedFiles };
515
+ return {
516
+ available: true,
517
+ changedFiles: [
518
+ .../* @__PURE__ */ new Set([...touchedPaths(parseChanges(diff.stdout)), ...untrackedFiles])
519
+ ].sort(),
520
+ untrackedFiles
521
+ };
394
522
  } catch {
395
523
  return { available: false, changedFiles: [], untrackedFiles: [] };
396
524
  }
@@ -398,10 +526,9 @@ async function getWorkingTree(resolvedRoot) {
398
526
  async function countCommitsBehind(resolvedRoot, fromHash) {
399
527
  if (!fromHash || fromHash === "unknown" || fromHash.startsWith("-")) return null;
400
528
  try {
401
- const { stdout } = await execGit(
402
- ["rev-list", "--count", `${fromHash}..HEAD`],
403
- { cwd: resolvedRoot }
404
- );
529
+ const { stdout } = await execGit(["rev-list", "--count", `${fromHash}..HEAD`], {
530
+ cwd: resolvedRoot
531
+ });
405
532
  const count = Number.parseInt(stdout.trim(), 10);
406
533
  return Number.isNaN(count) ? null : count;
407
534
  } catch {
@@ -434,14 +561,22 @@ async function computeDrift(rootDir) {
434
561
  const root = path6.resolve(rootDir);
435
562
  const snapshot = await loadSnapshot(root);
436
563
  if (!snapshot) return null;
437
- const [headHash, workingTree] = await Promise.all([getCurrentGitHash(root), getWorkingTree(root)]);
564
+ const [headHash, workingTree] = await Promise.all([
565
+ getCurrentGitHash(root),
566
+ getWorkingTree(root)
567
+ ]);
438
568
  const hashFor = (entry) => entry.refreshedHash ?? snapshot.gitHash;
439
569
  const entries = [...Object.values(snapshot.features), ...Object.values(snapshot.flows)];
440
570
  const hashes = /* @__PURE__ */ new Set([snapshot.gitHash, ...entries.map(hashFor)]);
441
571
  const changesByHash = /* @__PURE__ */ new Map();
442
- await Promise.all([...hashes].map(async (hash) => {
443
- changesByHash.set(hash, hash === headHash && headHash !== "unknown" ? [] : await getChangesWithStatus(root, hash));
444
- }));
572
+ await Promise.all(
573
+ [...hashes].map(async (hash) => {
574
+ changesByHash.set(
575
+ hash,
576
+ hash === headHash && headHash !== "unknown" ? [] : await getChangesWithStatus(root, hash)
577
+ );
578
+ })
579
+ );
445
580
  const historyAvailable = headHash !== "unknown" && [...changesByHash.values()].every((changes) => changes !== null);
446
581
  const mappedFiles = collectMappedFiles(snapshot);
447
582
  const report = {
@@ -467,7 +602,9 @@ async function computeDrift(rootDir) {
467
602
  failed: [...Object.entries(snapshot.features), ...Object.entries(snapshot.flows)].filter(([, e]) => e.verificationFailed).map(([name]) => name)
468
603
  }
469
604
  };
470
- const counts = await Promise.all([...hashes].map((hash) => hash === headHash ? 0 : countCommitsBehind(root, hash)));
605
+ const counts = await Promise.all(
606
+ [...hashes].map(async (hash) => hash === headHash ? 0 : countCommitsBehind(root, hash))
607
+ );
471
608
  const knownCounts = counts.filter((n) => n !== null);
472
609
  report.commitsBehind = knownCounts.length ? Math.max(...knownCounts) : null;
473
610
  const check = (name, files, hash, staleEntries, freshness) => {
@@ -478,7 +615,13 @@ async function computeDrift(rootDir) {
478
615
  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";
479
616
  };
480
617
  for (const [name, feature] of Object.entries(snapshot.features)) {
481
- check(name, [...feature.files, ...feature.tests ?? []], hashFor(feature), report.staleFeatures, report.featureFreshness);
618
+ check(
619
+ name,
620
+ [...feature.files, ...feature.tests ?? []],
621
+ hashFor(feature),
622
+ report.staleFeatures,
623
+ report.featureFreshness
624
+ );
482
625
  }
483
626
  for (const [name, flow] of Object.entries(snapshot.flows)) {
484
627
  check(name, flow.chain, hashFor(flow), report.staleFlows, report.flowFreshness);
@@ -488,7 +631,10 @@ async function computeDrift(rootDir) {
488
631
  const sourceFiles = new Set(await listSourceFiles(root));
489
632
  let committedFiles = /* @__PURE__ */ new Set();
490
633
  try {
491
- const { stdout } = await execGit(["ls-tree", "-r", "--name-only", "-z", "HEAD"], { cwd: root, maxBuffer: 50 * 1024 * 1024 });
634
+ const { stdout } = await execGit(["ls-tree", "-r", "--name-only", "-z", "HEAD"], {
635
+ cwd: root,
636
+ maxBuffer: 50 * 1024 * 1024
637
+ });
492
638
  committedFiles = new Set(stdout.split("\0").filter(Boolean));
493
639
  } catch {
494
640
  report.historyAvailable = false;
@@ -497,123 +643,322 @@ async function computeDrift(rootDir) {
497
643
  report.unmappedFiles = [...sourceFiles].filter((f) => committedFiles.has(f) && !mappedFiles.has(f)).sort();
498
644
  const renames = /* @__PURE__ */ new Map();
499
645
  for (const change of allChanges) {
500
- if (change.status === "renamed" && change.previousPath) renames.set(`${change.previousPath}\0${change.path}`, { from: change.previousPath, to: change.path });
646
+ if (change.status === "renamed" && change.previousPath)
647
+ renames.set(`${change.previousPath}\0${change.path}`, {
648
+ from: change.previousPath,
649
+ to: change.path
650
+ });
501
651
  }
502
652
  report.renames = [...renames.values()];
503
- const changedMapped = /* @__PURE__ */ new Set([...Object.values(report.staleFeatures).flat(), ...Object.values(report.staleFlows).flat()]);
653
+ const changedMapped = /* @__PURE__ */ new Set([
654
+ ...Object.values(report.staleFeatures).flat(),
655
+ ...Object.values(report.staleFlows).flat()
656
+ ]);
504
657
  const committedGhosts = report.ghostFiles.filter((f) => !workingTree.changedFiles.includes(f));
505
658
  report.stale ||= changedMapped.size > 0 || report.unmappedFiles.length > 0 || committedGhosts.length > 0;
506
659
  if (!report.historyAvailable) report.recommendation = "full-rebuild";
507
660
  else if (!report.stale) report.recommendation = "up-to-date";
508
- else report.recommendation = changedMapped.size >= FULL_REBUILD_MIN_CHANGED_MAPPED_FILES && changedMapped.size / Math.max(1, mappedFiles.size) > FULL_REBUILD_FRACTION ? "full-rebuild" : "incremental";
661
+ else
662
+ report.recommendation = changedMapped.size >= FULL_REBUILD_MIN_CHANGED_MAPPED_FILES && changedMapped.size / Math.max(1, mappedFiles.size) > FULL_REBUILD_FRACTION ? "full-rebuild" : "incremental";
509
663
  return report;
510
664
  }
511
665
 
666
+ // src/decisions/anchors.ts
667
+ import { createHash } from "crypto";
668
+ import fs5 from "fs/promises";
669
+ import { constants as constants2 } from "fs";
670
+ import { z as z3 } from "zod";
671
+ import fg3 from "fast-glob";
672
+
673
+ // src/audit/inspection.ts
674
+ import fg2 from "fast-glob";
675
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
676
+ import path7 from "path";
677
+ var inspections = new AsyncLocalStorage2();
678
+ function inspectionRead(root, key, read) {
679
+ const inspection = inspections.getStore();
680
+ if (!inspection || inspection.root !== path7.resolve(root)) return read();
681
+ if (!inspection.reads.has(key)) {
682
+ const pending = read();
683
+ inspection.reads.set(key, pending);
684
+ void pending.catch(() => {
685
+ if (inspection.reads.get(key) === pending) inspection.reads.delete(key);
686
+ });
687
+ }
688
+ return inspection.reads.get(key);
689
+ }
690
+ function inspectionGit(args, options) {
691
+ return inspectionRead(
692
+ options.cwd,
693
+ "git:" + JSON.stringify([args, options]),
694
+ () => execGit(args, options)
695
+ );
696
+ }
697
+ var sourceSpecs = SOURCE_EXTENSIONS.map((extension) => `:(glob)**/*.${extension}`);
698
+ var sourceSuffixes = new Set(SOURCE_EXTENSIONS.map((extension) => "." + extension));
699
+
700
+ // src/decisions/anchors.ts
701
+ var repoPath2 = z3.string().refine((p) => normalizeRepoPath(p) === p);
702
+ var anchorCaptureSchema = z3.object({
703
+ anchors: z3.array(repoPath2),
704
+ files: z3.record(repoPath2, z3.string().regex(/^[a-f0-9]{64}$/)),
705
+ complete: z3.boolean()
706
+ }).refine(
707
+ (capture) => JSON.stringify(capture.anchors) === JSON.stringify([...new Set(capture.anchors)].sort()) && matchingPaths(capture.anchors, Object.keys(capture.files)).length === Object.keys(capture.files).length,
708
+ "Capture paths must belong to its normalized, sorted anchor scope"
709
+ );
710
+ async function captureAnchorScopes(root, scopes) {
711
+ const union = [...new Set(scopes.flat())].sort();
712
+ let context;
713
+ const prepareContext = async () => {
714
+ const access = await inspectionRead(
715
+ root,
716
+ "decision-anchor-access",
717
+ () => createFileAccess(root)
718
+ );
719
+ const inventory = access.inventory();
720
+ if (!inventory) throw new Error("Git anchor inventory unavailable");
721
+ const ignored = await inspectionGit(
722
+ [
723
+ "ls-files",
724
+ "-z",
725
+ "--others",
726
+ "--ignored",
727
+ "--exclude-standard",
728
+ "--",
729
+ ...union.map((anchor) => `:(literal)${anchor}`)
730
+ ],
731
+ { cwd: root, maxBuffer: 8 * 1024 * 1024 }
732
+ );
733
+ const files = [
734
+ .../* @__PURE__ */ new Set([...inventory, ...ignored.stdout.split("\0").filter(Boolean)])
735
+ ].sort();
736
+ const patterns = await Promise.all(
737
+ union.map(async (anchor) => {
738
+ const literal = fg3.escapePath(anchor);
739
+ try {
740
+ return (await fs5.lstat(await storePath(root, anchor))).isDirectory() ? `${literal}/**/*` : literal;
741
+ } catch {
742
+ return literal;
743
+ }
744
+ })
745
+ );
746
+ const eligible = new Set(await access.list(patterns, { dot: true }));
747
+ const indexed = indexMatchingPaths(scopes, files);
748
+ const filesByScope = new Map(
749
+ scopes.map((scope, index) => [JSON.stringify([...new Set(scope)].sort()), indexed[index]])
750
+ );
751
+ return { filesByScope, eligible };
752
+ };
753
+ return Promise.all(
754
+ scopes.map((scope) => {
755
+ const anchors = [...new Set(scope)].sort();
756
+ return inspectionRead(
757
+ root,
758
+ "decision-anchor-capture:" + JSON.stringify(anchors),
759
+ async () => {
760
+ const capture = { anchors, files: {}, complete: true };
761
+ if (!anchors.length) return capture;
762
+ try {
763
+ const { eligible, filesByScope } = await (context ??= prepareContext());
764
+ const files = filesByScope.get(JSON.stringify(anchors));
765
+ if (files.length > 2e3) return { ...capture, complete: false };
766
+ for (const file of files) {
767
+ const evidence = await inspectionRead(
768
+ root,
769
+ "decision-anchor-file:" + file,
770
+ async () => {
771
+ try {
772
+ const absolute = await storePath(root, file);
773
+ await fs5.lstat(absolute);
774
+ if (!eligible.has(file)) return { complete: false };
775
+ const handle = await fs5.open(
776
+ absolute,
777
+ constants2.O_RDONLY | constants2.O_NONBLOCK | constants2.O_NOFOLLOW
778
+ );
779
+ try {
780
+ const stat = await handle.stat();
781
+ if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES)
782
+ return { complete: false };
783
+ const bytes = Buffer.alloc(Math.min(stat.size + 1, MAX_SOURCE_BYTES + 1));
784
+ let size = 0;
785
+ while (size < bytes.length) {
786
+ const read = await handle.read(bytes, size, bytes.length - size, null);
787
+ if (!read.bytesRead) break;
788
+ size += read.bytesRead;
789
+ }
790
+ if (size === bytes.length) return { complete: false };
791
+ return {
792
+ complete: true,
793
+ hash: createHash("sha256").update(bytes.subarray(0, size)).digest("hex")
794
+ };
795
+ } finally {
796
+ await handle.close();
797
+ }
798
+ } catch (error) {
799
+ return { complete: error.code === "ENOENT" };
800
+ }
801
+ }
802
+ );
803
+ if (!evidence.complete) capture.complete = false;
804
+ if (evidence.hash) capture.files[file] = evidence.hash;
805
+ }
806
+ } catch {
807
+ capture.complete = false;
808
+ }
809
+ return capture;
810
+ }
811
+ );
812
+ })
813
+ );
814
+ }
815
+ function changedCaptureFiles(before, after) {
816
+ return [.../* @__PURE__ */ new Set([...Object.keys(before.files), ...Object.keys(after.files)])].filter((file) => before.files[file] !== after.files[file]).sort();
817
+ }
818
+
512
819
  // src/decisions/drift.ts
513
- import path9 from "path";
820
+ import path10 from "path";
514
821
 
515
822
  // src/decisions/decisions.ts
516
- import fs5 from "fs/promises";
517
- import path8 from "path";
518
- import { createHash } from "crypto";
519
- import { z as z3 } from "zod";
823
+ import fs6 from "fs/promises";
824
+ import path9 from "path";
825
+ import { createHash as createHash3 } from "crypto";
826
+ import { z as z5 } from "zod";
520
827
 
521
828
  // src/context/lexical.ts
522
- import path7 from "path";
829
+ import path8 from "path";
523
830
 
524
831
  // src/decisions/provenance.ts
525
- import { z as z2 } from "zod";
526
- var text = (max) => z2.string().trim().min(1).max(max);
527
- var decisionSourceSchema = z2.object({
528
- kind: z2.enum(["pull_request", "issue", "incident", "discussion", "document", "other"]),
832
+ import { z as z4 } from "zod";
833
+ import { createHash as createHash2 } from "crypto";
834
+ var text = (max) => z4.string().trim().min(1).max(max);
835
+ var decisionSourceSchema = z4.object({
836
+ kind: z4.enum(["pull_request", "issue", "incident", "discussion", "document", "other"]),
529
837
  reference: text(1e3),
530
838
  note: text(500).optional()
531
839
  }).strict();
532
- var attributionSchema = z2.object({
840
+ var attributionSchema = z4.object({
533
841
  owner: text(200).nullable().optional(),
534
- sources: z2.array(decisionSourceSchema).max(20).optional(),
842
+ sources: z4.array(decisionSourceSchema).max(20).optional(),
535
843
  actor: text(200).optional()
536
844
  });
537
- var contentSchema = z2.object({
538
- title: z2.string().min(1),
539
- body: z2.string().min(1),
540
- category: z2.enum(["decision", "gotcha", "deprecation", "convention"]),
541
- files: z2.array(z2.string().refine((f) => normalizeRepoPath(f) !== null)),
845
+ var contentSchema = z4.object({
846
+ title: z4.string().min(1),
847
+ body: z4.string().min(1),
848
+ category: z4.enum(["decision", "gotcha", "deprecation", "convention"]),
849
+ files: z4.array(z4.string().refine((f) => normalizeRepoPath(f) !== null)),
542
850
  owner: text(200).optional(),
543
- sources: z2.array(decisionSourceSchema).max(20)
851
+ sources: z4.array(decisionSourceSchema).max(20)
544
852
  });
545
- var approvalSchema = z2.enum(["unreviewed", "proposed", "accepted"]);
546
- var statusSchema = z2.enum(["active", "superseded", "retired"]);
547
- var reviewEvidenceSchema = z2.object({
548
- baseHash: z2.string(),
549
- headHash: z2.string(),
550
- historyAvailable: z2.boolean(),
551
- changedFiles: z2.array(z2.string()),
552
- localChanges: z2.array(z2.string())
853
+ var approvalSchema = z4.enum(["unreviewed", "proposed", "accepted"]);
854
+ var statusSchema = z4.enum(["active", "superseded", "retired"]);
855
+ var reviewEvidenceSchema = z4.object({
856
+ baseHash: z4.string(),
857
+ headHash: z4.string(),
858
+ historyAvailable: z4.boolean(),
859
+ changedFiles: z4.array(z4.string()),
860
+ localChanges: z4.array(z4.string())
553
861
  });
554
- var eventSchema = z2.object({
555
- kind: z2.enum(["imported", "created", "revised", "accepted", "reaffirmed", "retired", "superseded"]),
556
- at: z2.string().datetime(),
862
+ var eventSchema = z4.object({
863
+ kind: z4.enum([
864
+ "imported",
865
+ "created",
866
+ "revised",
867
+ "accepted",
868
+ "reaffirmed",
869
+ "retired",
870
+ "superseded"
871
+ ]),
872
+ at: z4.string().datetime(),
557
873
  actor: text(200).optional(),
558
874
  note: text(1500).optional(),
559
- revision: z2.number().int().positive(),
875
+ revision: z4.number().int().positive(),
560
876
  content: contentSchema,
561
877
  approval: approvalSchema,
562
878
  status: statusSchema,
563
- refreshedHash: z2.string(),
564
- evidence: reviewEvidenceSchema.optional()
879
+ refreshedHash: z4.string(),
880
+ evidence: reviewEvidenceSchema.optional(),
881
+ capture: anchorCaptureSchema.optional()
565
882
  });
566
- var legacySchema = z2.object({
567
- version: z2.literal(1),
568
- id: z2.string().regex(/^[a-zA-Z0-9_-]+$/),
569
- title: z2.string().min(1),
570
- body: z2.string().min(1),
883
+ var legacySchema = z4.object({
884
+ version: z4.literal(1),
885
+ id: z4.string().regex(/^[a-zA-Z0-9_-]+$/),
886
+ title: z4.string().min(1),
887
+ body: z4.string().min(1),
571
888
  category: contentSchema.shape.category,
572
889
  files: contentSchema.shape.files,
573
- createdAt: z2.string(),
574
- updatedAt: z2.string(),
575
- refreshedHash: z2.string(),
576
- status: z2.enum(["active", "superseded"]),
577
- supersededBy: z2.string().optional()
890
+ createdAt: z4.string(),
891
+ updatedAt: z4.string(),
892
+ refreshedHash: z4.string(),
893
+ status: z4.enum(["active", "superseded"]),
894
+ supersededBy: z4.string().optional()
578
895
  }).passthrough();
579
896
  var currentSchema = legacySchema.extend({
580
- version: z2.literal(2),
897
+ version: z4.literal(2),
581
898
  status: statusSchema,
582
899
  approval: approvalSchema,
583
- revision: z2.number().int().positive(),
900
+ revision: z4.number().int().positive(),
584
901
  owner: text(200).optional(),
585
- sources: z2.array(decisionSourceSchema).max(20),
586
- history: z2.array(eventSchema).min(1)
902
+ sources: z4.array(decisionSourceSchema).max(20),
903
+ history: z4.array(eventSchema).min(1),
904
+ capture: anchorCaptureSchema.optional(),
905
+ inspections: z4.array(
906
+ z4.object({
907
+ at: z4.string().datetime(),
908
+ inspector: text(200),
909
+ note: text(1500),
910
+ outcome: z4.literal("no_contradiction_found"),
911
+ decisionDigest: z4.string().regex(/^[a-f0-9]{64}$/),
912
+ headHash: z4.string(),
913
+ capture: anchorCaptureSchema
914
+ })
915
+ ).max(200).optional()
587
916
  }).superRefine((record, ctx) => {
588
917
  const invalid = (message) => ctx.addIssue({ code: "custom", message });
589
918
  const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
590
919
  let previous;
591
920
  for (const event of record.history) {
592
921
  if (!previous) {
593
- if (!["created", "imported"].includes(event.kind) || event.revision !== 1) invalid("History must begin with creation or legacy import at revision 1");
594
- if (event.approval !== (event.kind === "created" ? "proposed" : "unreviewed")) invalid("Initial records cannot claim acceptance");
922
+ if (!["created", "imported"].includes(event.kind) || event.revision !== 1)
923
+ invalid("History must begin with creation or legacy import at revision 1");
924
+ if (event.approval !== (event.kind === "created" ? "proposed" : "unreviewed"))
925
+ invalid("Initial records cannot claim acceptance");
595
926
  } else {
596
927
  if (["created", "imported"].includes(event.kind)) invalid("History cannot restart");
597
928
  if (previous.status !== "active") invalid("Archived decisions cannot be changed");
598
- if (event.revision !== previous.revision + (event.kind === "revised" ? 1 : 0)) invalid("Invalid revision sequence");
599
- if (event.kind !== "revised" && !same(event.content, previous.content)) invalid("A review cannot silently revise decision content");
600
- if (event.kind === "reaffirmed" && previous.approval !== "accepted") invalid("Only accepted decisions can be reaffirmed");
601
- if (event.kind === "accepted" && previous.approval === "accepted") invalid("Use reaffirmation for an accepted decision");
929
+ if (event.revision !== previous.revision + (event.kind === "revised" ? 1 : 0))
930
+ invalid("Invalid revision sequence");
931
+ if (event.kind !== "revised" && !same(event.content, previous.content))
932
+ invalid("A review cannot silently revise decision content");
933
+ if (event.kind === "reaffirmed" && previous.approval !== "accepted")
934
+ invalid("Only accepted decisions can be reaffirmed");
935
+ if (event.kind === "accepted" && previous.approval === "accepted")
936
+ invalid("Use reaffirmation for an accepted decision");
602
937
  const approval = event.kind === "revised" ? "proposed" : ["accepted", "reaffirmed"].includes(event.kind) ? "accepted" : previous.approval;
603
938
  if (event.approval !== approval) invalid("Approval disagrees with review history");
604
- if (!["accepted", "reaffirmed"].includes(event.kind) && event.refreshedHash !== previous.refreshedHash) invalid("Only a review can refresh the evidence baseline");
939
+ if (!["accepted", "reaffirmed"].includes(event.kind) && event.refreshedHash !== previous.refreshedHash)
940
+ invalid("Only a review can refresh the evidence baseline");
605
941
  }
606
- if (event.kind !== "imported" && event.status !== (event.kind === "retired" ? "retired" : event.kind === "superseded" ? "superseded" : "active")) invalid("Lifecycle disagrees with history");
607
- if (["accepted", "reaffirmed", "retired"].includes(event.kind) && (!event.actor || !event.note || !event.evidence)) invalid("Reviews require a named reviewer, reason, and code evidence");
942
+ if (event.kind !== "imported" && event.status !== (event.kind === "retired" ? "retired" : event.kind === "superseded" ? "superseded" : "active"))
943
+ invalid("Lifecycle disagrees with history");
944
+ if (["accepted", "reaffirmed", "retired"].includes(event.kind) && (!event.actor || !event.note || !event.evidence))
945
+ invalid("Reviews require a named reviewer, reason, and code evidence");
608
946
  if (["accepted", "reaffirmed"].includes(event.kind)) {
609
- if (!event.content.owner || !event.content.sources.length) invalid("Accepted decisions require an owner and source");
610
- 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");
947
+ if (!event.content.owner || !event.content.sources.length)
948
+ invalid("Accepted decisions require an owner and source");
949
+ if (!event.evidence || !/^[a-f0-9]{40,64}$/.test(event.evidence.headHash) || event.refreshedHash !== event.evidence.headHash || event.evidence.localChanges.length)
950
+ invalid("Acceptance requires a committed evidence baseline");
611
951
  }
952
+ if (event.capture && !same([...event.content.files].sort(), event.capture.anchors))
953
+ invalid("Capture scope disagrees with decision anchors");
612
954
  previous = event;
613
955
  }
614
- 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");
956
+ if (previous && !same(previous.capture, record.capture))
957
+ invalid("Capture does not match the final history event");
958
+ if (!previous || !same(previous.content, decisionContent(record)) || previous.approval !== record.approval || previous.status !== record.status || previous.revision !== record.revision || previous.refreshedHash !== record.refreshedHash)
959
+ invalid("Decision does not match the final history event");
615
960
  });
616
- var decisionSchema = z2.union([legacySchema, currentSchema]);
961
+ var decisionSchema = z4.union([legacySchema, currentSchema]);
617
962
  function decisionContent(record) {
618
963
  return {
619
964
  title: record.title,
@@ -624,8 +969,12 @@ function decisionContent(record) {
624
969
  sources: Array.isArray(record.sources) ? record.sources : []
625
970
  };
626
971
  }
972
+ function decisionApproval(record) {
973
+ return record.version === 1 ? "unreviewed" : record.approval;
974
+ }
627
975
  function effectiveDecision(record) {
628
- if (record.version !== 2 || record.status !== "active" || record.approval !== "proposed") return record;
976
+ if (record.version !== 2 || record.status !== "active" || record.approval !== "proposed")
977
+ return record;
629
978
  let index = record.history.length - 1;
630
979
  while (index >= 0 && !["accepted", "reaffirmed"].includes(record.history[index].kind)) index--;
631
980
  if (index < 0) return record;
@@ -637,57 +986,147 @@ function effectiveDecision(record) {
637
986
  approval: "accepted",
638
987
  revision: event.revision,
639
988
  refreshedHash: event.refreshedHash,
989
+ capture: event.capture,
640
990
  updatedAt: event.at,
641
991
  history: record.history.slice(0, index + 1)
642
992
  };
643
993
  }
994
+ function decisionInspectionDigest(record) {
995
+ const effective = effectiveDecision(record);
996
+ return createHash2("sha256").update(
997
+ JSON.stringify([
998
+ record.status,
999
+ decisionApproval(record),
1000
+ record.version === 2 ? record.revision : 0,
1001
+ decisionContent(record),
1002
+ decisionContent(effective),
1003
+ effective.refreshedHash
1004
+ ])
1005
+ ).digest("hex");
1006
+ }
1007
+ function latestDecisionInspection(record) {
1008
+ return record.version === 2 ? record.inspections?.filter((i) => i.decisionDigest === decisionInspectionDigest(record)).at(-1) : void 0;
1009
+ }
644
1010
 
645
1011
  // src/decisions/decisions.ts
646
1012
  var TITLE_MAX_CHARS = 80;
647
1013
  var BODY_RECOMMENDED_CHARS = 1500;
648
1014
  var BODY_MAX_CHARS = 2500;
649
- var boundedText = (field, max, hint) => z3.string({
650
- errorMap: (issue, ctx) => ({ message: issue.code === "too_big" ? `${field} has ${ctx.data.length} characters; maximum is ${max} (${ctx.data.length - max} over). ${hint}` : ctx.defaultError })
1015
+ var boundedText = (field, max, hint) => z5.string({
1016
+ errorMap: (issue, ctx) => ({
1017
+ message: issue.code === "too_big" && typeof ctx.data === "string" ? `${field} has ${ctx.data.length} characters; maximum is ${max} (${ctx.data.length - max} over). ${hint}` : ctx.defaultError
1018
+ })
651
1019
  }).trim().min(1, `${field} must be non-empty`).max(max);
652
- var decisionTitleSchema = boundedText("title", TITLE_MAX_CHARS, "Tighten it to a specific headline.");
1020
+ var decisionTitleSchema = boundedText(
1021
+ "title",
1022
+ TITLE_MAX_CHARS,
1023
+ "Tighten it to a specific headline."
1024
+ );
653
1025
  var decisionBodySchema = boundedText(
654
1026
  "body",
655
1027
  BODY_MAX_CHARS,
656
1028
  `Aim for ${BODY_RECOMMENDED_CHARS} characters or fewer. Preserve the rule, reason, and exceptions; put supporting references in sources.`
657
1029
  );
1030
+ async function readDecisionInputs(rootDir) {
1031
+ return structuredClone(
1032
+ await inspectionRead(rootDir, "decision-store:inputs", async () => {
1033
+ const inputs = [];
1034
+ const diagnostics = [];
1035
+ let entries;
1036
+ try {
1037
+ entries = await fs6.readdir(await storePath(rootDir, ".mason/decisions"));
1038
+ } catch (error) {
1039
+ if (error.code !== "ENOENT")
1040
+ diagnostics.push({ path: ".mason/decisions", message: String(error) });
1041
+ return { inputs, diagnostics };
1042
+ }
1043
+ for (const entry of entries.sort()) {
1044
+ if (!entry.endsWith(".json")) continue;
1045
+ const relative = `.mason/decisions/${entry}`;
1046
+ try {
1047
+ const raw = await readBoundedFile(await storePath(rootDir, relative), 10 * 1024 * 1024);
1048
+ if (raw === null) throw new Error("file is not regular or exceeds 10 MiB");
1049
+ inputs.push([relative, raw]);
1050
+ } catch (error) {
1051
+ if (error.code === "ENOENT") inputs.push([relative, null]);
1052
+ else
1053
+ diagnostics.push({
1054
+ path: relative,
1055
+ message: error instanceof Error ? error.message : String(error)
1056
+ });
1057
+ }
1058
+ }
1059
+ return { inputs, diagnostics };
1060
+ })
1061
+ );
1062
+ }
658
1063
  async function loadDecisionStore(rootDir) {
659
- const records = [];
660
- const diagnostics = [];
661
- let entries;
662
- try {
663
- entries = await fs5.readdir(await storePath(rootDir, ".mason/decisions"));
664
- } catch (error) {
665
- if (error.code !== "ENOENT") diagnostics.push({ path: ".mason/decisions", message: String(error) });
666
- return { records, diagnostics };
667
- }
668
- for (const entry of entries.sort()) {
669
- if (!entry.endsWith(".json")) continue;
670
- const relative = `.mason/decisions/${entry}`;
671
- try {
672
- const record = decisionSchema.parse(await readStoreJson(rootDir, relative));
673
- if (entry !== `${record.id}.json`) throw new Error("Record id does not match its filename");
674
- records.push(record);
675
- } catch (error) {
676
- diagnostics.push({ path: relative, message: error instanceof Error ? error.message : String(error) });
677
- }
678
- }
679
- return { records, diagnostics };
1064
+ return structuredClone(
1065
+ await inspectionRead(rootDir, "decision-store:parsed", async () => {
1066
+ const raw = await readDecisionInputs(rootDir);
1067
+ const records = [];
1068
+ const diagnostics = [...raw.diagnostics];
1069
+ for (const [relative, content] of raw.inputs) {
1070
+ try {
1071
+ const record = decisionSchema.parse(content === null ? null : JSON.parse(content));
1072
+ if (relative !== `.mason/decisions/${record.id}.json`)
1073
+ throw new Error("Record id does not match its filename");
1074
+ records.push(record);
1075
+ } catch (error) {
1076
+ diagnostics.push({
1077
+ path: relative,
1078
+ message: error instanceof Error ? error.message : String(error)
1079
+ });
1080
+ }
1081
+ }
1082
+ return { records, diagnostics };
1083
+ })
1084
+ );
680
1085
  }
681
1086
 
682
1087
  // src/decisions/drift.ts
683
1088
  async function computeDecisionDrift(rootDir, decisions) {
684
- const resolvedRoot = path9.resolve(rootDir);
1089
+ const resolvedRoot = path10.resolve(rootDir);
685
1090
  const store = decisions ? { records: decisions, diagnostics: [] } : await loadDecisionStore(resolvedRoot);
686
- const report = { historyAvailable: true, totalDecisions: store.records.length, staleDecisions: {}, freshness: {}, diagnostics: store.diagnostics };
687
- const [head, workingTree] = await Promise.all([getCurrentGitHash(resolvedRoot), getWorkingTree(resolvedRoot)]);
1091
+ const report = {
1092
+ historyAvailable: true,
1093
+ totalDecisions: store.records.length,
1094
+ staleDecisions: {},
1095
+ freshness: {},
1096
+ diagnostics: store.diagnostics
1097
+ };
1098
+ const [head, workingTree] = await Promise.all([
1099
+ getCurrentGitHash(resolvedRoot),
1100
+ getWorkingTree(resolvedRoot)
1101
+ ]);
688
1102
  const changesByHash = /* @__PURE__ */ new Map();
689
- const inspect = async (record) => {
1103
+ const baselines = store.records.filter((record) => record.status === "active").flatMap((record) => {
1104
+ const inspection = latestDecisionInspection(record);
1105
+ if (inspection) return [inspection.capture];
1106
+ return [.../* @__PURE__ */ new Set([effectiveDecision(record), record])].flatMap(
1107
+ (candidate) => candidate.version === 2 && candidate.capture ? [candidate.capture] : []
1108
+ );
1109
+ });
1110
+ const captures = await captureAnchorScopes(
1111
+ resolvedRoot,
1112
+ baselines.map((baseline) => baseline.anchors)
1113
+ );
1114
+ const currentCaptures = new Map(
1115
+ baselines.map((baseline, index) => [JSON.stringify(baseline.anchors), captures[index]])
1116
+ );
1117
+ const inspect = async (record, inspectionCapture) => {
690
1118
  if (record.files.length === 0) return { freshness: "unknown", changedFiles: [] };
1119
+ const baseline = inspectionCapture ?? (record.version === 2 ? record.capture : void 0);
1120
+ if (baseline) {
1121
+ const current = currentCaptures.get(JSON.stringify(baseline.anchors));
1122
+ const hits2 = changedCaptureFiles(baseline, current).filter(
1123
+ (file) => matchingPaths(record.files, [file]).length
1124
+ );
1125
+ return {
1126
+ freshness: !baseline.complete || !current.complete ? "unknown" : hits2.length ? "changed" : "current",
1127
+ changedFiles: hits2
1128
+ };
1129
+ }
691
1130
  let touched = changesByHash.get(record.refreshedHash);
692
1131
  if (touched === void 0) {
693
1132
  const changes = record.refreshedHash === head && head !== "unknown" ? [] : await getChangesWithStatus(resolvedRoot, record.refreshedHash);
@@ -697,15 +1136,20 @@ async function computeDecisionDrift(rootDir, decisions) {
697
1136
  if (touched === null) report.historyAvailable = false;
698
1137
  const hits = touched ? matchingPaths(record.files, touched) : [];
699
1138
  const localHits = matchingPaths(record.files, workingTree.changedFiles);
700
- return { freshness: touched === null || !workingTree.available ? "unknown" : hits.length || localHits.length ? "changed" : "current", changedFiles: hits };
1139
+ return {
1140
+ freshness: touched === null || !workingTree.available ? "unknown" : hits.length || localHits.length ? "changed" : "current",
1141
+ changedFiles: [.../* @__PURE__ */ new Set([...hits, ...localHits])].sort()
1142
+ };
701
1143
  };
702
1144
  for (const record of store.records) {
703
1145
  if (record.status !== "active") continue;
704
1146
  const effective = effectiveDecision(record);
705
- const state = await inspect(effective);
1147
+ const inspection = latestDecisionInspection(record);
1148
+ const state = await inspect(effective, inspection?.capture);
706
1149
  report.freshness[record.id] = state.freshness;
707
1150
  if (state.changedFiles.length) report.staleDecisions[record.id] = state.changedFiles;
708
- if (effective !== record) (report.pendingProposals ??= {})[record.id] = await inspect(record);
1151
+ if (effective !== record)
1152
+ (report.pendingProposals ??= {})[record.id] = await inspect(record, inspection?.capture);
709
1153
  }
710
1154
  return report;
711
1155
  }
@@ -758,9 +1202,17 @@ function parseArgs(argv) {
758
1202
  }
759
1203
  function formatDriftSummary(report) {
760
1204
  if (!report.stale) {
761
- const lines2 = [`Concept map is up to date against committed source (HEAD ${report.headHash.slice(0, 7)}).`];
762
- if (report.workingTree?.changedFiles.length) lines2.push(`Working tree has ${report.workingTree.changedFiles.length} changed files; live context checks report these separately.`);
763
- if (report.verification?.failed.length) lines2.push(`Verification FAILED: ${report.verification.failed.join(", ")}. Correct these entries before relying on them.`);
1205
+ const lines2 = [
1206
+ `Concept map is up to date against committed source (HEAD ${report.headHash.slice(0, 7)}).`
1207
+ ];
1208
+ if (report.workingTree?.changedFiles.length)
1209
+ lines2.push(
1210
+ `Working tree has ${report.workingTree.changedFiles.length} changed files; live context checks report these separately.`
1211
+ );
1212
+ if (report.verification?.failed.length)
1213
+ lines2.push(
1214
+ `Verification FAILED: ${report.verification.failed.join(", ")}. Correct these entries before relying on them.`
1215
+ );
764
1216
  return lines2.join("\n");
765
1217
  }
766
1218
  const lines = [];
@@ -779,9 +1231,7 @@ function formatDriftSummary(report) {
779
1231
  );
780
1232
  }
781
1233
  if (staleFlows.length > 0) {
782
- lines.push(
783
- `Stale flows (${staleFlows.length}/${report.totalFlows}): ${staleFlows.join(", ")}`
784
- );
1234
+ lines.push(`Stale flows (${staleFlows.length}/${report.totalFlows}): ${staleFlows.join(", ")}`);
785
1235
  }
786
1236
  if (report.unmappedFiles.length > 0) {
787
1237
  lines.push(
@@ -840,11 +1290,12 @@ function formatRefreshPrompt(report, decisionDrift) {
840
1290
  );
841
1291
  }
842
1292
  const changedProposals = Object.entries(decisionDrift.pendingProposals ?? {}).filter(([, state]) => state.freshness !== "current").map(([id]) => id);
843
- if (changedProposals.length) lines.push(`NOTE: pending proposals [${changedProposals.join(", ")}] need source review. Their accepted revisions remain operative. Do NOT modify or accept these proposals during automated map refresh.`);
1293
+ if (changedProposals.length)
1294
+ lines.push(
1295
+ `NOTE: pending proposals [${changedProposals.join(", ")}] need source review. Their accepted revisions remain operative. Do NOT modify or accept these proposals during automated map refresh.`
1296
+ );
844
1297
  lines.push("");
845
- lines.push(
846
- "Finish by confirming the map was saved and summarizing which entries changed."
847
- );
1298
+ lines.push("Finish by confirming the map was saved and summarizing which entries changed.");
848
1299
  return lines.join("\n");
849
1300
  }
850
1301
  async function runDriftCli(argv, io = {
@@ -868,7 +1319,7 @@ async function runDriftCli(argv, io = {
868
1319
  io.out(USAGE);
869
1320
  return 0;
870
1321
  }
871
- const rootDir = path10.resolve(args.dir);
1322
+ const rootDir = path11.resolve(args.dir);
872
1323
  let report;
873
1324
  try {
874
1325
  report = await computeDrift(rootDir);
@@ -878,7 +1329,7 @@ async function runDriftCli(argv, io = {
878
1329
  }
879
1330
  if (!report) {
880
1331
  io.err(
881
- `No Mason snapshot found at ${path10.join(rootDir, ".mason", "snapshot.json")}. Build one via the Mason MCP server first.`
1332
+ `No Mason snapshot found at ${path11.join(rootDir, ".mason", "snapshot.json")}. Build one via the Mason MCP server first.`
882
1333
  );
883
1334
  return 2;
884
1335
  }
@@ -890,9 +1341,7 @@ async function runDriftCli(argv, io = {
890
1341
  }
891
1342
  const decisionDrift = await computeDecisionDrift(rootDir);
892
1343
  if (args.refreshPrompt) {
893
- io.out(
894
- report.stale ? formatRefreshPrompt(report, decisionDrift) : formatDriftSummary(report)
895
- );
1344
+ io.out(report.stale ? formatRefreshPrompt(report, decisionDrift) : formatDriftSummary(report));
896
1345
  return report.stale ? 1 : 0;
897
1346
  }
898
1347
  if (args.json) {
@@ -903,9 +1352,13 @@ async function runDriftCli(argv, io = {
903
1352
  io.out(JSON.stringify(output, null, 2));
904
1353
  } else {
905
1354
  const lines = [formatDriftSummary(report)];
906
- for (const diagnostic of decisionDrift.diagnostics ?? []) lines.push(`Invalid decision record ${diagnostic.path}: ${diagnostic.message}`);
1355
+ for (const diagnostic of decisionDrift.diagnostics ?? [])
1356
+ lines.push(`Invalid decision record ${diagnostic.path}: ${diagnostic.message}`);
907
1357
  const unknownIds = Object.entries(decisionDrift.freshness ?? {}).filter(([, state]) => state === "unknown").map(([id]) => id);
908
- if (unknownIds.length) lines.push(`Decision freshness unknown: ${unknownIds.join(", ")}. Verify before relying on them.`);
1358
+ if (unknownIds.length)
1359
+ lines.push(
1360
+ `Decision freshness unknown: ${unknownIds.join(", ")}. Verify before relying on them.`
1361
+ );
909
1362
  const staleIds = Object.keys(decisionDrift.staleDecisions);
910
1363
  if (staleIds.length > 0) {
911
1364
  lines.push(
@@ -913,7 +1366,9 @@ async function runDriftCli(argv, io = {
913
1366
  );
914
1367
  }
915
1368
  for (const [id, proposal] of Object.entries(decisionDrift.pendingProposals ?? {})) {
916
- lines.push(`Pending proposal ${id}: freshness ${proposal.freshness}; the accepted revision remains operative.`);
1369
+ lines.push(
1370
+ `Pending proposal ${id}: freshness ${proposal.freshness}; the accepted revision remains operative.`
1371
+ );
917
1372
  }
918
1373
  io.out(lines.join("\n"));
919
1374
  }