mason-context 0.9.0 → 0.10.1

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,50 +1,53 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/audit/cli.ts
4
- import path14 from "path";
4
+ import path16 from "path";
5
5
 
6
6
  // src/audit/audit.ts
7
7
  import fs9 from "fs/promises";
8
- import path13 from "path";
8
+ import path15 from "path";
9
9
 
10
10
  // src/drift/drift.ts
11
11
  import fs3 from "fs/promises";
12
- import path4 from "path";
12
+ import path6 from "path";
13
13
  import { execFile as execFile3 } from "child_process";
14
14
  import { promisify as promisify3 } from "util";
15
15
 
16
16
  // src/snapshot/snapshot.ts
17
- import fs2 from "fs/promises";
18
- import path3 from "path";
17
+ import path5 from "path";
19
18
  import { execFile as execFile2 } from "child_process";
20
19
  import { promisify as promisify2 } from "util";
21
- import fg3 from "fast-glob";
22
20
 
23
- // src/mcp/sampler.ts
21
+ // src/utils/files.ts
24
22
  import fs from "fs/promises";
25
- import path from "path";
23
+ import { constants } from "fs";
24
+ import path2 from "path";
26
25
  import { execFile } from "child_process";
27
26
  import { promisify } from "util";
28
27
  import fg from "fast-glob";
29
- var exec = promisify(execFile);
30
-
31
- // src/test-map.ts
32
- import path2 from "path";
33
- import fg2 from "fast-glob";
34
28
 
35
- // src/snapshot/snapshot.ts
36
- var exec2 = promisify2(execFile2);
37
- async function getCurrentGitHash(rootDir) {
38
- try {
39
- const { stdout } = await exec2("git", ["rev-parse", "HEAD"], {
40
- cwd: rootDir
41
- });
42
- return stdout.trim();
43
- } catch {
44
- return "unknown";
45
- }
29
+ // src/utils/paths.ts
30
+ import path from "path";
31
+ function normalizeRepoPath(value) {
32
+ const slash = value.replace(/\\/g, "/");
33
+ if (!slash || slash.includes("\0") || path.posix.isAbsolute(slash) || /^[A-Za-z]:/.test(slash)) return null;
34
+ if (slash.split("/").includes("..")) return null;
35
+ const normalized = path.posix.normalize(slash).replace(/\/$/, "");
36
+ return normalized === "." ? null : normalized;
37
+ }
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}/`));
46
42
  }
47
- var SOURCE_GLOB = "**/*.{ts,tsx,js,jsx,kt,kts,java,py,go,rs,swift,rb,cs,cpp,c,dart}";
43
+ function matchingPaths(anchors, files) {
44
+ return [...new Set(files)].filter((file) => anchors.some((anchor) => anchorMatches(anchor, file)));
45
+ }
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(",")}}`;
48
51
  var SOURCE_IGNORE = [
49
52
  "**/node_modules/**",
50
53
  "**/dist/**",
@@ -52,58 +55,171 @@ var SOURCE_IGNORE = [
52
55
  "**/.gradle/**",
53
56
  "**/target/**",
54
57
  "**/.git/**",
58
+ "**/.mason/**",
55
59
  "**/vendor/**",
56
60
  "**/__pycache__/**",
57
61
  "**/venv/**",
58
62
  "**/.venv/**",
59
63
  "**/*.min.*",
60
64
  "**/*.map",
65
+ "**/*.lock",
61
66
  "**/generated/**",
67
+ "**/*.generated.*",
62
68
  "**/R.java",
63
- "**/BuildConfig.java"
69
+ "**/BuildConfig.java",
70
+ "**/package-lock.json",
71
+ "**/yarn.lock",
72
+ "**/pnpm-lock.yaml"
64
73
  ];
74
+ var MAX_SOURCE_BYTES = 1024 * 1024;
75
+ async function readBoundedFile(file, maxBytes) {
76
+ const handle = await fs.open(file, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
77
+ try {
78
+ const stat = await handle.stat();
79
+ if (!stat.isFile() || stat.size > maxBytes) return null;
80
+ const buffer = Buffer.alloc(Math.min(maxBytes + 1, stat.size + 1));
81
+ let bytes = 0;
82
+ while (bytes < buffer.length) {
83
+ const result = await handle.read(buffer, bytes, buffer.length - bytes, null);
84
+ if (result.bytesRead === 0) break;
85
+ bytes += result.bytesRead;
86
+ }
87
+ return bytes === buffer.length ? null : buffer.subarray(0, bytes).toString("utf8");
88
+ } finally {
89
+ await handle.close();
90
+ }
91
+ }
92
+
93
+ // src/utils/storage.ts
94
+ import fs2 from "fs/promises";
95
+ import path3 from "path";
96
+ import { randomUUID } from "crypto";
97
+ async function storePath(root, relative, createParents = false) {
98
+ const normalized = normalizeRepoPath(relative);
99
+ if (!normalized) throw new Error(`Invalid store path: ${relative}`);
100
+ let current = await fs2.realpath(root);
101
+ const parts = normalized.split("/");
102
+ for (let i = 0; i < parts.length; i++) {
103
+ current = path3.join(current, parts[i]);
104
+ let stat;
105
+ try {
106
+ stat = await fs2.lstat(current);
107
+ } catch (error) {
108
+ if (error.code !== "ENOENT") throw error;
109
+ if (createParents && i < parts.length - 1) {
110
+ try {
111
+ await fs2.mkdir(current);
112
+ } catch (mkdirError) {
113
+ if (mkdirError.code !== "EEXIST") throw mkdirError;
114
+ }
115
+ stat = await fs2.lstat(current);
116
+ }
117
+ }
118
+ if (stat?.isSymbolicLink()) throw new Error(`Symlink in store path: ${relative}`);
119
+ }
120
+ return current;
121
+ }
122
+ async function readStoreJson(root, relative) {
123
+ try {
124
+ const file = await storePath(root, relative);
125
+ const raw = await readBoundedFile(file, 10 * 1024 * 1024);
126
+ if (raw === null) throw new Error("file is not regular or exceeds 10 MiB");
127
+ const parsed = JSON.parse(raw);
128
+ if (parsed === null) throw new Error("expected a JSON object, received null");
129
+ return parsed;
130
+ } catch (error) {
131
+ if (error.code === "ENOENT") return null;
132
+ throw new Error(`Invalid Mason store ${relative}: ${error instanceof Error ? error.message : String(error)}`);
133
+ }
134
+ }
135
+
136
+ // src/snapshot/snapshot.ts
137
+ import { z } from "zod";
138
+
139
+ // src/test-map.ts
140
+ import path4 from "path";
141
+
142
+ // src/snapshot/snapshot.ts
143
+ var exec2 = promisify2(execFile2);
144
+ var repoPath = z.string().refine((value) => normalizeRepoPath(value) !== null, "Expected a relative repository path");
145
+ var verificationFields = {
146
+ refreshedHash: z.string().optional(),
147
+ verifiedAt: z.string().optional(),
148
+ verifiedHash: z.string().optional(),
149
+ verificationFailed: z.boolean().optional(),
150
+ verificationNote: z.string().optional()
151
+ };
152
+ var featureSchema = z.object({
153
+ description: z.string(),
154
+ files: z.array(repoPath),
155
+ tests: z.array(repoPath).optional(),
156
+ type: z.enum(["capability", "infrastructure"]).optional(),
157
+ ...verificationFields
158
+ }).passthrough();
159
+ var flowSchema = z.object({ description: z.string(), chain: z.array(repoPath), ...verificationFields }).passthrough();
160
+ var snapshotSchema = z.object({
161
+ version: z.literal(2),
162
+ createdAt: z.string(),
163
+ updatedAt: z.string(),
164
+ gitHash: z.string(),
165
+ features: z.record(featureSchema),
166
+ flows: z.record(flowSchema)
167
+ }).passthrough();
168
+ async function getCurrentGitHash(rootDir) {
169
+ try {
170
+ const { stdout } = await exec2("git", ["rev-parse", "HEAD"], {
171
+ cwd: rootDir
172
+ });
173
+ return stdout.trim();
174
+ } catch {
175
+ return "unknown";
176
+ }
177
+ }
65
178
 
66
179
  // src/drift/drift.ts
67
180
  var exec3 = promisify3(execFile3);
68
- async function getChangesWithStatus(resolvedRoot, fromHash) {
69
- if (!fromHash || fromHash === "unknown") return null;
181
+ function parseChanges(output) {
182
+ const fields = output.split("\0");
183
+ const changes = [];
184
+ for (let i = 0; i < fields.length && fields[i]; ) {
185
+ const code = fields[i++];
186
+ const first = fields[i++];
187
+ if (!first) break;
188
+ const second = /^[RC]/.test(code) ? fields[i++] : void 0;
189
+ 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 };
190
+ if (change.path.startsWith(".mason/") && (!change.previousPath || change.previousPath.startsWith(".mason/"))) continue;
191
+ changes.push(change);
192
+ }
193
+ return changes;
194
+ }
195
+ function touchedPaths(changes) {
196
+ return [...new Set(changes.flatMap((c) => c.previousPath ? [c.previousPath, c.path] : [c.path]))].sort();
197
+ }
198
+ async function getChangesWithStatus(resolvedRoot, fromHash, toHash = "HEAD") {
199
+ if (!fromHash || fromHash === "unknown" || fromHash.startsWith("-") || !toHash || toHash === "unknown" || toHash.startsWith("-")) return null;
70
200
  try {
71
- const { stdout } = await exec3(
72
- "git",
73
- ["diff", "--name-status", "-M", fromHash, "HEAD"],
74
- { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }
75
- );
76
- const changes = [];
77
- for (const line of stdout.split("\n")) {
78
- if (!line.trim()) continue;
79
- const parts = line.split(" ");
80
- if (parts.some((p) => p.startsWith(".mason/"))) continue;
81
- const code = parts[0];
82
- if (code.startsWith("R") && parts.length >= 3) {
83
- changes.push({
84
- status: "renamed",
85
- path: parts[2],
86
- previousPath: parts[1]
87
- });
88
- } else if (code.startsWith("C") && parts.length >= 3) {
89
- changes.push({ status: "added", path: parts[2] });
90
- } else if (code === "A" && parts.length >= 2) {
91
- changes.push({ status: "added", path: parts[1] });
92
- } else if (code === "D" && parts.length >= 2) {
93
- changes.push({ status: "deleted", path: parts[1] });
94
- } else if (parts.length >= 2) {
95
- changes.push({ status: "modified", path: parts[1] });
96
- }
97
- }
98
- return changes;
201
+ const { stdout } = await exec3("git", ["diff", "--name-status", "-z", "-M", fromHash, toHash, "--"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 });
202
+ return parseChanges(stdout);
99
203
  } catch {
100
204
  return null;
101
205
  }
102
206
  }
207
+ async function getWorkingTree(resolvedRoot) {
208
+ try {
209
+ const [diff, untracked] = await Promise.all([
210
+ exec3("git", ["diff", "--name-status", "-z", "-M", "HEAD", "--"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }),
211
+ exec3("git", ["ls-files", "-z", "--others", "--exclude-standard"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 })
212
+ ]);
213
+ const untrackedFiles = untracked.stdout.split("\0").filter((f) => f && !f.startsWith(".mason/"));
214
+ return { available: true, changedFiles: [.../* @__PURE__ */ new Set([...touchedPaths(parseChanges(diff.stdout)), ...untrackedFiles])].sort(), untrackedFiles };
215
+ } catch {
216
+ return { available: false, changedFiles: [], untrackedFiles: [] };
217
+ }
218
+ }
103
219
 
104
220
  // src/audit/docs.ts
105
221
  import fs4 from "fs/promises";
106
- import path5 from "path";
222
+ import path7 from "path";
107
223
  import { execFile as execFile5 } from "child_process";
108
224
  import { promisify as promisify5 } from "util";
109
225
 
@@ -486,7 +602,7 @@ async function discoverDocs(resolvedRoot) {
486
602
  for (const candidate of DOC_CANDIDATES) {
487
603
  let content;
488
604
  try {
489
- content = await fs4.readFile(path5.join(resolvedRoot, candidate), "utf-8");
605
+ content = await fs4.readFile(path7.join(resolvedRoot, candidate), "utf-8");
490
606
  } catch {
491
607
  continue;
492
608
  }
@@ -514,7 +630,7 @@ var ALL_CHECKS = [
514
630
 
515
631
  // src/audit/checks/deleted-reference.ts
516
632
  import fs5 from "fs/promises";
517
- import path6 from "path";
633
+ import path8 from "path";
518
634
  async function exists(absPath) {
519
635
  try {
520
636
  await fs5.access(absPath);
@@ -537,7 +653,7 @@ async function checkDeletedReferences(ctx) {
537
653
  if (claim.path === ".mason" || claim.path.startsWith(".mason/")) {
538
654
  continue;
539
655
  }
540
- if (await exists(path6.join(ctx.root, claim.path))) continue;
656
+ if (await exists(path8.join(ctx.root, claim.path))) continue;
541
657
  const anchor = { doc: doc.path, line: claim.line, excerpt: claim.excerpt };
542
658
  const renamedTo = renames.get(claim.path) ?? null;
543
659
  if (renamedTo) {
@@ -573,14 +689,14 @@ async function checkDeletedReferences(ctx) {
573
689
  deletedInCommit: deleted,
574
690
  everTracked: true,
575
691
  parentDirExists: await exists(
576
- path6.join(ctx.root, path6.dirname(claim.path))
692
+ path8.join(ctx.root, path8.dirname(claim.path))
577
693
  )
578
694
  }
579
695
  });
580
696
  continue;
581
697
  }
582
698
  const parentDirExists = await exists(
583
- path6.join(ctx.root, path6.dirname(claim.path))
699
+ path8.join(ctx.root, path8.dirname(claim.path))
584
700
  );
585
701
  if (!parentDirExists) continue;
586
702
  const issue = {
@@ -604,8 +720,8 @@ async function checkDeletedReferences(ctx) {
604
720
  }
605
721
 
606
722
  // src/audit/checks/new-module.ts
607
- import fg4 from "fast-glob";
608
- import path7 from "path";
723
+ import fg2 from "fast-glob";
724
+ import path9 from "path";
609
725
  var DIR_DENYLIST = /* @__PURE__ */ new Set([
610
726
  "node_modules",
611
727
  "dist",
@@ -627,8 +743,8 @@ var DIR_DENYLIST = /* @__PURE__ */ new Set([
627
743
  ]);
628
744
  var SECOND_LEVEL_MIN_SOURCE_FILES = 2;
629
745
  var ENUMERATION_THRESHOLD = 2;
630
- function escapeRegExp(text) {
631
- return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
746
+ function escapeRegExp(text2) {
747
+ return text2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
632
748
  }
633
749
  function isMentioned(combinedDocs, name) {
634
750
  const re = new RegExp(
@@ -638,7 +754,7 @@ function isMentioned(combinedDocs, name) {
638
754
  return re.test(combinedDocs);
639
755
  }
640
756
  async function listSubdirs(absDir) {
641
- const dirs = await fg4("*", {
757
+ const dirs = await fg2("*", {
642
758
  cwd: absDir,
643
759
  onlyDirectories: true,
644
760
  suppressErrors: true
@@ -646,7 +762,7 @@ async function listSubdirs(absDir) {
646
762
  return dirs.filter((d) => !DIR_DENYLIST.has(d)).sort();
647
763
  }
648
764
  async function countSourceFiles(absDir) {
649
- const files = await fg4(SOURCE_GLOB, {
765
+ const files = await fg2(SOURCE_GLOB, {
650
766
  cwd: absDir,
651
767
  ignore: SOURCE_IGNORE,
652
768
  suppressErrors: true
@@ -675,7 +791,7 @@ async function checkNewModules(ctx) {
675
791
  });
676
792
  };
677
793
  for (const topDir of await listSubdirs(ctx.root)) {
678
- const absTop = path7.join(ctx.root, topDir);
794
+ const absTop = path9.join(ctx.root, topDir);
679
795
  const topMentioned = isMentioned(combinedDocs, topDir);
680
796
  if (!topMentioned) {
681
797
  const count = await countSourceFiles(absTop);
@@ -687,7 +803,7 @@ async function checkNewModules(ctx) {
687
803
  if (mentioned.length < ENUMERATION_THRESHOLD) continue;
688
804
  for (const sub of subdirs) {
689
805
  if (isMentioned(combinedDocs, sub)) continue;
690
- const count = await countSourceFiles(path7.join(absTop, sub));
806
+ const count = await countSourceFiles(path9.join(absTop, sub));
691
807
  if (count >= SECOND_LEVEL_MIN_SOURCE_FILES) {
692
808
  await flag(`${topDir}/${sub}`, count);
693
809
  }
@@ -698,8 +814,8 @@ async function checkNewModules(ctx) {
698
814
 
699
815
  // src/audit/checks/stale-count.ts
700
816
  import fs6 from "fs/promises";
701
- import path8 from "path";
702
- import fg5 from "fast-glob";
817
+ import path10 from "path";
818
+ import fg3 from "fast-glob";
703
819
  var MEMBERS_CAP = 50;
704
820
  async function readIfExists(absPath) {
705
821
  try {
@@ -710,7 +826,7 @@ async function readIfExists(absPath) {
710
826
  }
711
827
  async function countGradleModules(root) {
712
828
  for (const name of ["settings.gradle.kts", "settings.gradle"]) {
713
- const content = await readIfExists(path8.join(root, name));
829
+ const content = await readIfExists(path10.join(root, name));
714
830
  if (content === null) continue;
715
831
  const members = [];
716
832
  for (const call of content.matchAll(/include\s*\(([^)]*)\)/g)) {
@@ -724,26 +840,26 @@ async function countGradleModules(root) {
724
840
  return null;
725
841
  }
726
842
  async function countNpmWorkspaces(root) {
727
- const pkgRaw = await readIfExists(path8.join(root, "package.json"));
843
+ const pkgRaw = await readIfExists(path10.join(root, "package.json"));
728
844
  if (pkgRaw !== null) {
729
845
  try {
730
846
  const pkg = JSON.parse(pkgRaw);
731
847
  const globs = Array.isArray(pkg.workspaces) ? pkg.workspaces : Array.isArray(pkg.workspaces?.packages) ? pkg.workspaces.packages : [];
732
848
  if (globs.length > 0) {
733
- const matched = await fg5(
849
+ const matched = await fg3(
734
850
  globs.map((g) => `${g.replace(/\/+$/, "")}/package.json`),
735
851
  { cwd: root, ignore: ["**/node_modules/**"] }
736
852
  );
737
853
  return {
738
854
  actual: matched.length,
739
855
  countedFrom: "package.json workspaces",
740
- members: matched.map((m) => path8.dirname(m)).sort()
856
+ members: matched.map((m) => path10.dirname(m)).sort()
741
857
  };
742
858
  }
743
859
  } catch {
744
860
  }
745
861
  }
746
- const pnpmRaw = await readIfExists(path8.join(root, "pnpm-workspace.yaml"));
862
+ const pnpmRaw = await readIfExists(path10.join(root, "pnpm-workspace.yaml"));
747
863
  if (pnpmRaw !== null) {
748
864
  const globs = [];
749
865
  let inPackages = false;
@@ -762,21 +878,21 @@ async function countNpmWorkspaces(root) {
762
878
  }
763
879
  }
764
880
  if (globs.length > 0) {
765
- const matched = await fg5(
881
+ const matched = await fg3(
766
882
  globs.map((g) => `${g.replace(/\/+$/, "")}/package.json`),
767
883
  { cwd: root, ignore: ["**/node_modules/**"] }
768
884
  );
769
885
  return {
770
886
  actual: matched.length,
771
887
  countedFrom: "pnpm-workspace.yaml",
772
- members: matched.map((m) => path8.dirname(m)).sort()
888
+ members: matched.map((m) => path10.dirname(m)).sort()
773
889
  };
774
890
  }
775
891
  }
776
892
  return null;
777
893
  }
778
894
  async function countCargoCrates(root) {
779
- const content = await readIfExists(path8.join(root, "Cargo.toml"));
895
+ const content = await readIfExists(path10.join(root, "Cargo.toml"));
780
896
  if (content === null) return null;
781
897
  const membersBlock = content.match(/members\s*=\s*\[([\s\S]*?)\]/);
782
898
  if (!membersBlock) return null;
@@ -787,12 +903,12 @@ async function countCargoCrates(root) {
787
903
  const members = /* @__PURE__ */ new Set();
788
904
  for (const entry of entries) {
789
905
  if (/[*?[\]{}]/.test(entry)) {
790
- const matched = await fg5(`${entry.replace(/\/+$/, "")}/Cargo.toml`, {
906
+ const matched = await fg3(`${entry.replace(/\/+$/, "")}/Cargo.toml`, {
791
907
  cwd: root,
792
908
  ignore: ["**/target/**"]
793
909
  });
794
- for (const m of matched) members.add(path8.dirname(m));
795
- } else if (await readIfExists(path8.join(root, entry, "Cargo.toml")) !== null) {
910
+ for (const m of matched) members.add(path10.dirname(m));
911
+ } else if (await readIfExists(path10.join(root, entry, "Cargo.toml")) !== null) {
796
912
  members.add(entry);
797
913
  }
798
914
  }
@@ -837,8 +953,8 @@ async function checkStaleCounts(ctx) {
837
953
 
838
954
  // src/audit/checks/dead-command.ts
839
955
  import fs7 from "fs/promises";
840
- import path9 from "path";
841
- import fg6 from "fast-glob";
956
+ import path11 from "path";
957
+ import fg4 from "fast-glob";
842
958
  var AVAILABLE_SCRIPTS_CAP = 30;
843
959
  async function scriptsOf(absManifest) {
844
960
  try {
@@ -854,7 +970,7 @@ async function checkDeadCommands(ctx) {
854
970
  (doc) => doc.claims.commands.map((claim) => ({ doc, claim }))
855
971
  );
856
972
  if (commandClaims.length === 0) return result;
857
- const rootScripts = await scriptsOf(path9.join(ctx.root, "package.json"));
973
+ const rootScripts = await scriptsOf(path11.join(ctx.root, "package.json"));
858
974
  if (rootScripts === null) {
859
975
  result.skipped.push({
860
976
  check: "dead-command",
@@ -868,7 +984,7 @@ async function checkDeadCommands(ctx) {
868
984
  const loadWorkspaceScripts = async () => {
869
985
  if (workspaceScripts !== null) return workspaceScripts;
870
986
  workspaceScripts = /* @__PURE__ */ new Set();
871
- const manifests = await fg6("**/package.json", {
987
+ const manifests = await fg4("**/package.json", {
872
988
  cwd: ctx.root,
873
989
  ignore: [
874
990
  "**/node_modules/**",
@@ -879,7 +995,7 @@ async function checkDeadCommands(ctx) {
879
995
  });
880
996
  manifestsChecked = ["package.json", ...manifests.sort()];
881
997
  for (const manifest of manifests) {
882
- const scripts = await scriptsOf(path9.join(ctx.root, manifest));
998
+ const scripts = await scriptsOf(path11.join(ctx.root, manifest));
883
999
  for (const name of scripts ?? []) workspaceScripts.add(name);
884
1000
  }
885
1001
  return workspaceScripts;
@@ -968,86 +1084,205 @@ async function checkDepsChanged(ctx) {
968
1084
  }
969
1085
 
970
1086
  // src/decisions/drift.ts
971
- import path12 from "path";
1087
+ import path14 from "path";
972
1088
 
973
1089
  // src/decisions/decisions.ts
974
1090
  import fs8 from "fs/promises";
975
- import path11 from "path";
1091
+ import path13 from "path";
976
1092
  import { createHash } from "crypto";
977
1093
 
978
1094
  // src/context/lexical.ts
979
- import path10 from "path";
1095
+ import path12 from "path";
980
1096
 
981
- // src/decisions/decisions.ts
982
- function decisionsDir(rootDir) {
983
- return path11.join(rootDir, ".mason", "decisions");
1097
+ // src/decisions/provenance.ts
1098
+ import { z as z2 } from "zod";
1099
+ var text = (max) => z2.string().trim().min(1).max(max);
1100
+ var decisionSourceSchema = z2.object({
1101
+ kind: z2.enum(["pull_request", "issue", "incident", "discussion", "document", "other"]),
1102
+ reference: text(1e3),
1103
+ note: text(500).optional()
1104
+ }).strict();
1105
+ var attributionSchema = z2.object({
1106
+ owner: text(200).nullable().optional(),
1107
+ sources: z2.array(decisionSourceSchema).max(20).optional(),
1108
+ actor: text(200).optional()
1109
+ });
1110
+ var contentSchema = z2.object({
1111
+ title: z2.string().min(1),
1112
+ body: z2.string().min(1),
1113
+ category: z2.enum(["decision", "gotcha", "deprecation", "convention"]),
1114
+ files: z2.array(z2.string().refine((f) => normalizeRepoPath(f) !== null)),
1115
+ owner: text(200).optional(),
1116
+ sources: z2.array(decisionSourceSchema).max(20)
1117
+ });
1118
+ var approvalSchema = z2.enum(["unreviewed", "proposed", "accepted"]);
1119
+ var statusSchema = z2.enum(["active", "superseded", "retired"]);
1120
+ var reviewEvidenceSchema = z2.object({
1121
+ baseHash: z2.string(),
1122
+ headHash: z2.string(),
1123
+ historyAvailable: z2.boolean(),
1124
+ changedFiles: z2.array(z2.string()),
1125
+ localChanges: z2.array(z2.string())
1126
+ });
1127
+ var eventSchema = z2.object({
1128
+ kind: z2.enum(["imported", "created", "revised", "accepted", "reaffirmed", "retired", "superseded"]),
1129
+ at: z2.string().datetime(),
1130
+ actor: text(200).optional(),
1131
+ note: text(1500).optional(),
1132
+ revision: z2.number().int().positive(),
1133
+ content: contentSchema,
1134
+ approval: approvalSchema,
1135
+ status: statusSchema,
1136
+ refreshedHash: z2.string(),
1137
+ evidence: reviewEvidenceSchema.optional()
1138
+ });
1139
+ var legacySchema = z2.object({
1140
+ version: z2.literal(1),
1141
+ id: z2.string().regex(/^[a-zA-Z0-9_-]+$/),
1142
+ title: z2.string().min(1),
1143
+ body: z2.string().min(1),
1144
+ category: contentSchema.shape.category,
1145
+ files: contentSchema.shape.files,
1146
+ createdAt: z2.string(),
1147
+ updatedAt: z2.string(),
1148
+ refreshedHash: z2.string(),
1149
+ status: z2.enum(["active", "superseded"]),
1150
+ supersededBy: z2.string().optional()
1151
+ }).passthrough();
1152
+ var currentSchema = legacySchema.extend({
1153
+ version: z2.literal(2),
1154
+ status: statusSchema,
1155
+ approval: approvalSchema,
1156
+ revision: z2.number().int().positive(),
1157
+ owner: text(200).optional(),
1158
+ sources: z2.array(decisionSourceSchema).max(20),
1159
+ history: z2.array(eventSchema).min(1)
1160
+ }).superRefine((record, ctx) => {
1161
+ const invalid = (message) => ctx.addIssue({ code: "custom", message });
1162
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
1163
+ let previous;
1164
+ for (const event of record.history) {
1165
+ if (!previous) {
1166
+ if (!["created", "imported"].includes(event.kind) || event.revision !== 1) invalid("History must begin with creation or legacy import at revision 1");
1167
+ if (event.approval !== (event.kind === "created" ? "proposed" : "unreviewed")) invalid("Initial records cannot claim acceptance");
1168
+ } else {
1169
+ if (["created", "imported"].includes(event.kind)) invalid("History cannot restart");
1170
+ if (previous.status !== "active") invalid("Archived decisions cannot be changed");
1171
+ if (event.revision !== previous.revision + (event.kind === "revised" ? 1 : 0)) invalid("Invalid revision sequence");
1172
+ if (event.kind !== "revised" && !same(event.content, previous.content)) invalid("A review cannot silently revise decision content");
1173
+ if (event.kind === "reaffirmed" && previous.approval !== "accepted") invalid("Only accepted decisions can be reaffirmed");
1174
+ if (event.kind === "accepted" && previous.approval === "accepted") invalid("Use reaffirmation for an accepted decision");
1175
+ const approval = event.kind === "revised" ? "proposed" : ["accepted", "reaffirmed"].includes(event.kind) ? "accepted" : previous.approval;
1176
+ if (event.approval !== approval) invalid("Approval disagrees with review history");
1177
+ if (!["accepted", "reaffirmed"].includes(event.kind) && event.refreshedHash !== previous.refreshedHash) invalid("Only a review can refresh the evidence baseline");
1178
+ }
1179
+ if (event.kind !== "imported" && event.status !== (event.kind === "retired" ? "retired" : event.kind === "superseded" ? "superseded" : "active")) invalid("Lifecycle disagrees with history");
1180
+ if (["accepted", "reaffirmed", "retired"].includes(event.kind) && (!event.actor || !event.note || !event.evidence)) invalid("Reviews require a named reviewer, reason, and code evidence");
1181
+ if (["accepted", "reaffirmed"].includes(event.kind)) {
1182
+ if (!event.content.owner || !event.content.sources.length) invalid("Accepted decisions require an owner and source");
1183
+ 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");
1184
+ }
1185
+ previous = event;
1186
+ }
1187
+ 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");
1188
+ });
1189
+ var decisionSchema = z2.union([legacySchema, currentSchema]);
1190
+ function decisionContent(record) {
1191
+ return {
1192
+ title: record.title,
1193
+ body: record.body,
1194
+ category: record.category,
1195
+ files: record.files,
1196
+ ...typeof record.owner === "string" ? { owner: record.owner } : {},
1197
+ sources: Array.isArray(record.sources) ? record.sources : []
1198
+ };
1199
+ }
1200
+ function decisionApproval(record) {
1201
+ return record.version === 1 ? "unreviewed" : record.approval;
984
1202
  }
985
- async function loadDecisions(rootDir) {
1203
+ function effectiveDecision(record) {
1204
+ if (record.version !== 2 || record.status !== "active" || record.approval !== "proposed") return record;
1205
+ let index = record.history.length - 1;
1206
+ while (index >= 0 && !["accepted", "reaffirmed"].includes(record.history[index].kind)) index--;
1207
+ if (index < 0) return record;
1208
+ const event = record.history[index];
1209
+ return {
1210
+ ...record,
1211
+ ...event.content,
1212
+ owner: event.content.owner,
1213
+ approval: "accepted",
1214
+ revision: event.revision,
1215
+ refreshedHash: event.refreshedHash,
1216
+ updatedAt: event.at,
1217
+ history: record.history.slice(0, index + 1)
1218
+ };
1219
+ }
1220
+ function decisionProvenance(record, freshness = "unknown") {
1221
+ const approval = decisionApproval(record);
1222
+ const review = record.version === 2 ? [...record.history].reverse().find((e) => ["accepted", "reaffirmed"].includes(e.kind) && e.revision === record.revision) : void 0;
1223
+ return {
1224
+ approval,
1225
+ revision: record.version === 2 ? record.revision : 0,
1226
+ owner: record.version === 2 ? record.owner ?? null : null,
1227
+ sources: record.version === 2 ? record.sources : [],
1228
+ guidance: record.status !== "active" ? "historical" : approval === "accepted" ? "constraint" : approval === "proposed" ? "proposal" : "unreviewed",
1229
+ reviewRequired: record.status === "active" && (approval !== "accepted" || freshness !== "current"),
1230
+ lastReview: review ? { reviewer: review.actor, at: review.at, note: review.note, gitHash: review.refreshedHash } : null
1231
+ };
1232
+ }
1233
+
1234
+ // src/decisions/decisions.ts
1235
+ async function loadDecisionStore(rootDir) {
1236
+ const records = [];
1237
+ const diagnostics = [];
986
1238
  let entries;
987
1239
  try {
988
- entries = await fs8.readdir(decisionsDir(rootDir));
989
- } catch {
990
- return [];
1240
+ entries = await fs8.readdir(await storePath(rootDir, ".mason/decisions"));
1241
+ } catch (error) {
1242
+ if (error.code !== "ENOENT") diagnostics.push({ path: ".mason/decisions", message: String(error) });
1243
+ return { records, diagnostics };
991
1244
  }
992
- const records = [];
993
- for (const entry of entries) {
1245
+ for (const entry of entries.sort()) {
994
1246
  if (!entry.endsWith(".json")) continue;
1247
+ const relative = `.mason/decisions/${entry}`;
995
1248
  try {
996
- const raw = await fs8.readFile(
997
- path11.join(decisionsDir(rootDir), entry),
998
- "utf-8"
999
- );
1000
- const parsed = JSON.parse(raw);
1001
- if (parsed.version !== 1 || !parsed.id || !parsed.title || !parsed.body) {
1002
- continue;
1003
- }
1004
- records.push(parsed);
1005
- } catch {
1006
- continue;
1249
+ const record = decisionSchema.parse(await readStoreJson(rootDir, relative));
1250
+ if (entry !== `${record.id}.json`) throw new Error("Record id does not match its filename");
1251
+ records.push(record);
1252
+ } catch (error) {
1253
+ diagnostics.push({ path: relative, message: error instanceof Error ? error.message : String(error) });
1007
1254
  }
1008
1255
  }
1009
- return records.sort((a, b) => a.id.localeCompare(b.id));
1256
+ return { records, diagnostics };
1010
1257
  }
1011
1258
 
1012
1259
  // src/decisions/drift.ts
1013
1260
  async function computeDecisionDrift(rootDir, decisions) {
1014
- const resolvedRoot = path12.resolve(rootDir);
1015
- const records = decisions ?? await loadDecisions(resolvedRoot);
1016
- const report = {
1017
- historyAvailable: true,
1018
- totalDecisions: records.length,
1019
- staleDecisions: {}
1020
- };
1021
- const head = await getCurrentGitHash(resolvedRoot);
1261
+ const resolvedRoot = path14.resolve(rootDir);
1262
+ const store = decisions ? { records: decisions, diagnostics: [] } : await loadDecisionStore(resolvedRoot);
1263
+ const report = { historyAvailable: true, totalDecisions: store.records.length, staleDecisions: {}, freshness: {}, diagnostics: store.diagnostics };
1264
+ const [head, workingTree] = await Promise.all([getCurrentGitHash(resolvedRoot), getWorkingTree(resolvedRoot)]);
1022
1265
  const changesByHash = /* @__PURE__ */ new Map();
1023
- for (const record of records) {
1024
- if (record.status !== "active" || record.files.length === 0) continue;
1025
- if (record.refreshedHash === head) continue;
1266
+ const inspect = async (record) => {
1267
+ if (record.files.length === 0) return { freshness: "unknown", changedFiles: [] };
1026
1268
  let touched = changesByHash.get(record.refreshedHash);
1027
1269
  if (touched === void 0) {
1028
- const changes = await getChangesWithStatus(
1029
- resolvedRoot,
1030
- record.refreshedHash
1031
- );
1032
- if (changes === null) {
1033
- touched = null;
1034
- } else {
1035
- touched = /* @__PURE__ */ new Set();
1036
- for (const change of changes) {
1037
- touched.add(change.path);
1038
- if (change.previousPath) touched.add(change.previousPath);
1039
- }
1040
- }
1270
+ const changes = record.refreshedHash === head && head !== "unknown" ? [] : await getChangesWithStatus(resolvedRoot, record.refreshedHash);
1271
+ touched = changes === null ? null : touchedPaths(changes);
1041
1272
  changesByHash.set(record.refreshedHash, touched);
1042
1273
  }
1043
- if (touched === null) {
1044
- report.historyAvailable = false;
1045
- continue;
1046
- }
1047
- const hits = record.files.filter((f) => touched.has(f));
1048
- if (hits.length > 0) {
1049
- report.staleDecisions[record.id] = hits;
1050
- }
1274
+ if (touched === null) report.historyAvailable = false;
1275
+ const hits = touched ? matchingPaths(record.files, touched) : [];
1276
+ const localHits = matchingPaths(record.files, workingTree.changedFiles);
1277
+ return { freshness: touched === null || !workingTree.available ? "unknown" : hits.length || localHits.length ? "changed" : "current", changedFiles: hits };
1278
+ };
1279
+ for (const record of store.records) {
1280
+ if (record.status !== "active") continue;
1281
+ const effective = effectiveDecision(record);
1282
+ const state = await inspect(effective);
1283
+ report.freshness[record.id] = state.freshness;
1284
+ if (state.changedFiles.length) report.staleDecisions[record.id] = state.changedFiles;
1285
+ if (effective !== record) (report.pendingProposals ??= {})[record.id] = await inspect(record);
1051
1286
  }
1052
1287
  return report;
1053
1288
  }
@@ -1056,7 +1291,9 @@ async function computeDecisionDrift(rootDir, decisions) {
1056
1291
  async function checkDecisionAnchors(ctx) {
1057
1292
  const result = emptyResult();
1058
1293
  if (!ctx.decisionsPresent) return result;
1059
- const records = await loadDecisions(ctx.root);
1294
+ const store = await loadDecisionStore(ctx.root);
1295
+ const records = store.records;
1296
+ for (const diagnostic of store.diagnostics) result.skipped.push({ check: "decision-anchor-drift", reason: `${diagnostic.path}: ${diagnostic.message}` });
1060
1297
  const drift = await computeDecisionDrift(ctx.root, records);
1061
1298
  if (!drift.historyAvailable) {
1062
1299
  result.skipped.push({
@@ -1064,13 +1301,17 @@ async function checkDecisionAnchors(ctx) {
1064
1301
  reason: "some decision base commits are unreachable (shallow clone?)"
1065
1302
  });
1066
1303
  }
1067
- const byId = new Map(records.map((r) => [r.id, r]));
1068
- for (const [id, changedFiles] of Object.entries(drift.staleDecisions)) {
1069
- const record = byId.get(id);
1070
- if (!record) continue;
1304
+ const changed = records.flatMap((record) => [
1305
+ { record: effectiveDecision(record), changedFiles: drift.staleDecisions[record.id] ?? [], freshness: drift.freshness?.[record.id] ?? "unknown" },
1306
+ { record, changedFiles: drift.pendingProposals?.[record.id]?.changedFiles ?? [], freshness: drift.pendingProposals?.[record.id]?.freshness ?? "unknown" }
1307
+ ]);
1308
+ for (const { record, changedFiles, freshness } of changed) {
1309
+ if (!changedFiles.length) continue;
1310
+ const id = record.id;
1311
+ const provenance = decisionProvenance(record, freshness);
1071
1312
  result.advisories.push({
1072
1313
  type: "decision-anchor-drift",
1073
- message: `decision "${record.title}" has anchor files that changed since it was verified \u2013 needs human re-verification`,
1314
+ message: `decision "${record.title}" (${provenance.approval}) has anchor files that changed since its evidence baseline \u2013 needs human review`,
1074
1315
  anchor: {
1075
1316
  doc: `.mason/decisions/${id}.json`,
1076
1317
  line: null,
@@ -1078,6 +1319,7 @@ async function checkDecisionAnchors(ctx) {
1078
1319
  },
1079
1320
  evidence: {
1080
1321
  kind: "decision-anchor",
1322
+ provenance,
1081
1323
  decisionId: id,
1082
1324
  title: record.title,
1083
1325
  changedFiles,
@@ -1103,7 +1345,7 @@ function emptyResult() {
1103
1345
 
1104
1346
  // src/audit/audit.ts
1105
1347
  async function computeAudit(rootDir, options = {}) {
1106
- const resolvedRoot = path13.resolve(rootDir);
1348
+ const resolvedRoot = path15.resolve(rootDir);
1107
1349
  const docs = await discoverDocs(resolvedRoot);
1108
1350
  if (docs.length === 0) return null;
1109
1351
  const headHash = await getCurrentGitHash(resolvedRoot);
@@ -1133,7 +1375,7 @@ async function computeAudit(rootDir, options = {}) {
1133
1375
  }
1134
1376
  let decisionsPresent = false;
1135
1377
  try {
1136
- await fs9.access(path13.join(resolvedRoot, ".mason", "decisions"));
1378
+ await fs9.access(path15.join(resolvedRoot, ".mason", "decisions"));
1137
1379
  decisionsPresent = true;
1138
1380
  } catch {
1139
1381
  }
@@ -1327,7 +1569,7 @@ async function runAuditCli(argv, io = {
1327
1569
  io.out(USAGE);
1328
1570
  return 0;
1329
1571
  }
1330
- const rootDir = path14.resolve(args.dir);
1572
+ const rootDir = path16.resolve(args.dir);
1331
1573
  const report = await computeAudit(rootDir, { checks: args.checks });
1332
1574
  if (!report) {
1333
1575
  io.err(