mason-context 0.17.4 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.0 — 2026-09-16
4
+
5
+ - Fix overlapping snapshot writes restoring old descriptions and deleted flows. Snapshot saves, verdicts, and partial writes share a per-checkout cross-process lock; reads, replacement, and consolidation cleanup stay within the protected operation.
6
+ - Bind map verification to entry kind, content, and bounded source evidence through review tokens. Changed or deleted entries return conflicts; older calls without tokens request a fresh review without recording a verdict. Existing snapshot files remain compatible; callers must include the returned entry kind and review token to record a verdict. Restart all Mason MCP processes for a checkout after upgrading; older binaries do not honor the new snapshot lock.
7
+ - Recheck saved map verification evidence on retrieval. Changed evidence reports stale verification; unavailable evidence and legacy verdicts without tokens report unknown. Preserve historical verdicts and failure reasons, including when reviewed local edits are reverted to a clean checkout.
8
+ - Diagnose incomplete lock owners and interrupted recovery guards, document safe manual cleanup after writers stop, and cover both interruption windows with controlled process-termination tests.
9
+ - Cover the reported overwrite with a controlled regression, exercise both call orders through one MCP session and separate processes, and test interrupted writers, contention, partial cleanup, and stale review evidence. Run the snapshot regressions in the existing macOS, Linux, and Windows distribution matrix before publishing.
10
+
11
+ Run `mason upgrade` for standalone installations, or `npm install -g mason-context@0.18.0` for npm installations. Restart **every Mason MCP process** sharing a checkout after upgrading: older processes do not participate in the new snapshot lock. Existing decisions and snapshot files are preserved. Older snapshot verdicts remain visible but report unknown verification until reviewed again with an evidence token. Existing integrations do not need setup again.
12
+
3
13
  ## 0.17.4 — 2026-09-15
4
14
 
5
15
  - Share Git document/source inventories within each automation inspection, with a separate fresh inventory before publication. Batch module ignore checks by directory level and reuse only identical scoped history queries.
@@ -234,6 +234,102 @@ var init_storage = __esm({
234
234
  }
235
235
  });
236
236
 
237
+ // src/utils/store-lock.ts
238
+ import fs3 from "fs/promises";
239
+ import os from "os";
240
+ async function lockDiagnostic(file) {
241
+ let reason;
242
+ try {
243
+ const owner = JSON.parse(await fs3.readFile(file, "utf8"));
244
+ if (!owner || typeof owner.host !== "string" || !Number.isInteger(owner.pid) || owner.pid <= 0) {
245
+ reason = "Lock owner data is incomplete or malformed.";
246
+ } else if (owner.host !== os.hostname()) {
247
+ reason = "The lock belongs to another host; its process cannot be checked locally.";
248
+ } else {
249
+ try {
250
+ process.kill(owner.pid, 0);
251
+ reason = `Local owner PID ${owner.pid} is still running.`;
252
+ } catch (error) {
253
+ reason = error.code === "ESRCH" ? `Local owner PID ${owner.pid} has exited, but lock recovery has not completed.` : `Local owner PID ${owner.pid} could not be checked.`;
254
+ }
255
+ }
256
+ } catch (error) {
257
+ reason = error instanceof SyntaxError ? "Lock owner data is incomplete or malformed." : "Lock owner data could not be read; the lock may have changed.";
258
+ }
259
+ if (await fs3.lstat(file + ".reclaim").then(() => true, () => false)) {
260
+ reason += " A recovery guard also needs inspection: " + file + ".reclaim.";
261
+ }
262
+ return reason + " Retry if a writer is active. For manual recovery, stop all Mason writers for this checkout, confirm they have exited (including other hosts), then remove only confirmed abandoned lock and lock.reclaim files. See docs/reference.md#snapshot-repairs-and-verification.";
263
+ }
264
+ async function withStoreLock(root, directory, run, waitMs = 5e3, label = "Mason store") {
265
+ const file = await storePath(root, directory + "/lock", true);
266
+ const deadline = Date.now() + waitMs;
267
+ let handle;
268
+ while (!handle) {
269
+ try {
270
+ handle = await fs3.open(file, "wx", 384);
271
+ try {
272
+ await handle.writeFile(JSON.stringify({ pid: process.pid, host: os.hostname() }));
273
+ } catch (error) {
274
+ await handle.close().catch(() => {
275
+ });
276
+ handle = void 0;
277
+ await fs3.rm(file, { force: true }).catch(() => {
278
+ });
279
+ throw error;
280
+ }
281
+ } catch (error) {
282
+ if (error.code !== "EEXIST") throw error;
283
+ try {
284
+ const owner = JSON.parse(await fs3.readFile(file, "utf8"));
285
+ if (owner.host === os.hostname() && Number.isInteger(owner.pid) && owner.pid > 0) {
286
+ try {
287
+ process.kill(owner.pid, 0);
288
+ } catch (probe) {
289
+ if (probe.code === "ESRCH") {
290
+ const reclaim = file + ".reclaim";
291
+ let guard;
292
+ try {
293
+ guard = await fs3.open(reclaim, "wx", 384);
294
+ const current = JSON.parse(await fs3.readFile(file, "utf8"));
295
+ if (current.pid === owner.pid && current.host === owner.host) await fs3.unlink(file);
296
+ } finally {
297
+ if (guard) {
298
+ await guard.close();
299
+ await fs3.rm(reclaim, { force: true });
300
+ }
301
+ }
302
+ }
303
+ }
304
+ }
305
+ } catch {
306
+ }
307
+ if (Date.now() >= deadline) throw new Error(label + " is busy or its lock needs inspection: " + file + ". " + await lockDiagnostic(file));
308
+ await new Promise((resolve) => setTimeout(resolve, 40));
309
+ }
310
+ }
311
+ try {
312
+ return await run();
313
+ } finally {
314
+ await handle.close();
315
+ await fs3.unlink(file);
316
+ }
317
+ }
318
+ var init_store_lock = __esm({
319
+ "src/utils/store-lock.ts"() {
320
+ "use strict";
321
+ init_storage();
322
+ }
323
+ });
324
+
325
+ // src/snapshot/lock.ts
326
+ var init_lock = __esm({
327
+ "src/snapshot/lock.ts"() {
328
+ "use strict";
329
+ init_store_lock();
330
+ }
331
+ });
332
+
237
333
  // src/test-map.ts
238
334
  import path4 from "path";
239
335
  var init_test_map = __esm({
@@ -264,6 +360,7 @@ var init_snapshot = __esm({
264
360
  init_files();
265
361
  init_files();
266
362
  init_storage();
363
+ init_lock();
267
364
  init_paths();
268
365
  init_test_map();
269
366
  repoPath = z.string().refine((value) => normalizeRepoPath(value) !== null, "Expected a relative repository path");
@@ -271,6 +368,7 @@ var init_snapshot = __esm({
271
368
  refreshedHash: z.string().optional(),
272
369
  verifiedAt: z.string().optional(),
273
370
  verifiedHash: z.string().optional(),
371
+ verificationToken: z.string().optional(),
274
372
  verificationFailed: z.boolean().optional(),
275
373
  verificationNote: z.string().optional()
276
374
  };
@@ -294,7 +392,7 @@ var init_snapshot = __esm({
294
392
  });
295
393
 
296
394
  // src/drift/drift.ts
297
- import fs3 from "fs/promises";
395
+ import fs4 from "fs/promises";
298
396
  import path6 from "path";
299
397
  function parseChanges(output) {
300
398
  const fields = output.split("\0");
@@ -855,12 +953,12 @@ var init_git = __esm({
855
953
  });
856
954
 
857
955
  // src/audit/inputs.ts
858
- import fs4 from "fs/promises";
956
+ import fs5 from "fs/promises";
859
957
  import * as nativeFs from "fs";
860
958
  import path9 from "path";
861
959
  import fg2 from "fast-glob";
862
960
  async function auditInputPath(root, file) {
863
- if (file === ".") return fs4.realpath(root);
961
+ if (file === ".") return fs5.realpath(root);
864
962
  try {
865
963
  return await storePath(root, file);
866
964
  } catch (error) {
@@ -965,7 +1063,7 @@ var init_inputs = __esm({
965
1063
  });
966
1064
 
967
1065
  // src/audit/docs.ts
968
- import fs5 from "fs/promises";
1066
+ import fs6 from "fs/promises";
969
1067
  import path10 from "path";
970
1068
  import fg3 from "fast-glob";
971
1069
  async function dirtyDocs(resolvedRoot, files) {
@@ -1037,7 +1135,7 @@ async function localDocPaths(root) {
1037
1135
  for (const dir of [".", ".claude"]) {
1038
1136
  let entries;
1039
1137
  try {
1040
- entries = await fs5.readdir(await auditInputPath(root, dir), { withFileTypes: true });
1138
+ entries = await fs6.readdir(await auditInputPath(root, dir), { withFileTypes: true });
1041
1139
  } catch (error) {
1042
1140
  if (error.code === "ENOENT") continue;
1043
1141
  throw error;
@@ -1116,7 +1214,7 @@ var init_types = __esm({
1116
1214
  });
1117
1215
 
1118
1216
  // src/audit/scope.ts
1119
- import fs6 from "fs/promises";
1217
+ import fs7 from "fs/promises";
1120
1218
  import path11 from "path";
1121
1219
  function scopedPath(directory, value) {
1122
1220
  if (path11.posix.isAbsolute(value) || value.includes("\\") || /[\x00-\x1f]/.test(value)) return null;
@@ -1136,7 +1234,7 @@ function pathClaimScope(doc, claim) {
1136
1234
  }
1137
1235
  async function pathExists(root, file) {
1138
1236
  try {
1139
- await fs6.access(await auditInputPath(root, file));
1237
+ await fs7.access(await auditInputPath(root, file));
1140
1238
  return true;
1141
1239
  } catch (error) {
1142
1240
  if (["ENOENT", "ENOTDIR"].includes(error.code ?? "")) return false;
@@ -1226,7 +1324,7 @@ var init_deleted_reference = __esm({
1226
1324
 
1227
1325
  // src/audit/checks/new-module.ts
1228
1326
  import fg4 from "fast-glob";
1229
- import fs7 from "fs/promises";
1327
+ import fs8 from "fs/promises";
1230
1328
  import path13 from "path";
1231
1329
  function escapeRegExp(text2) {
1232
1330
  return text2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -1278,7 +1376,7 @@ async function collectModuleCandidates(root, combinedDocs) {
1278
1376
  const byParent = /* @__PURE__ */ new Map();
1279
1377
  for (let offset = 0; offset < parents.length; offset += 16) {
1280
1378
  await Promise.all(parents.slice(offset, offset + 16).map(async (dir) => {
1281
- const entries = await fs7.readdir(await auditInputPath(root, dir), { withFileTypes: true });
1379
+ const entries = await fs8.readdir(await auditInputPath(root, dir), { withFileTypes: true });
1282
1380
  const dirs = entries.filter((entry) => (entry.isDirectory() || entry.isSymbolicLink() && sources.some((file) => file.startsWith(path13.posix.join(dir, entry.name) + "/"))) && !entry.name.startsWith(".") && !DIR_DENYLIST.has(entry.name)).map((entry) => path13.posix.join(dir, entry.name));
1283
1381
  byParent.set(dir, dirs);
1284
1382
  }));
@@ -1942,7 +2040,7 @@ var init_provenance = __esm({
1942
2040
  });
1943
2041
 
1944
2042
  // src/decisions/decisions.ts
1945
- import fs8 from "fs/promises";
2043
+ import fs9 from "fs/promises";
1946
2044
  import path17 from "path";
1947
2045
  import { createHash } from "crypto";
1948
2046
  import { z as z3 } from "zod";
@@ -1951,7 +2049,7 @@ async function loadDecisionStore(rootDir) {
1951
2049
  const diagnostics = [];
1952
2050
  let entries;
1953
2051
  try {
1954
- entries = await fs8.readdir(await storePath(rootDir, ".mason/decisions"));
2052
+ entries = await fs9.readdir(await storePath(rootDir, ".mason/decisions"));
1955
2053
  } catch (error) {
1956
2054
  if (error.code !== "ENOENT") diagnostics.push({ path: ".mason/decisions", message: String(error) });
1957
2055
  return { records, diagnostics };
@@ -2219,68 +2317,15 @@ var init_findings = __esm({
2219
2317
  });
2220
2318
 
2221
2319
  // src/automation/store.ts
2222
- import fs9 from "fs/promises";
2223
- import os from "os";
2224
2320
  import { z as z5 } from "zod";
2225
- async function withLock(root, directory, run, waitMs = 5e3) {
2226
- const file = await storePath(root, directory + "/lock", true);
2227
- const deadline = Date.now() + waitMs;
2228
- let handle;
2229
- while (!handle) {
2230
- try {
2231
- handle = await fs9.open(file, "wx", 384);
2232
- try {
2233
- await handle.writeFile(JSON.stringify({ pid: process.pid, host: os.hostname() }));
2234
- } catch (error) {
2235
- await handle.close().catch(() => {
2236
- });
2237
- handle = void 0;
2238
- await fs9.rm(file, { force: true }).catch(() => {
2239
- });
2240
- throw error;
2241
- }
2242
- } catch (error) {
2243
- if (error.code !== "EEXIST") throw error;
2244
- try {
2245
- const owner = JSON.parse(await fs9.readFile(file, "utf8"));
2246
- if (owner.host === os.hostname() && Number.isInteger(owner.pid) && owner.pid > 0) {
2247
- try {
2248
- process.kill(owner.pid, 0);
2249
- } catch (probe) {
2250
- if (probe.code === "ESRCH") {
2251
- const reclaim = file + ".reclaim";
2252
- let guard;
2253
- try {
2254
- guard = await fs9.open(reclaim, "wx", 384);
2255
- const current = JSON.parse(await fs9.readFile(file, "utf8"));
2256
- if (current.pid === owner.pid && current.host === owner.host) await fs9.unlink(file);
2257
- } finally {
2258
- if (guard) {
2259
- await guard.close();
2260
- await fs9.rm(reclaim, { force: true });
2261
- }
2262
- }
2263
- }
2264
- }
2265
- }
2266
- } catch {
2267
- }
2268
- if (Date.now() >= deadline) throw new Error("Automation is busy or its lock needs inspection: " + file);
2269
- await new Promise((resolve) => setTimeout(resolve, 40));
2270
- }
2271
- }
2272
- try {
2273
- return await run();
2274
- } finally {
2275
- await handle.close();
2276
- await fs9.unlink(file);
2277
- }
2321
+ function withLock(root, directory, run, waitMs = 5e3) {
2322
+ return withStoreLock(root, directory, run, waitMs, "Automation");
2278
2323
  }
2279
2324
  var hostSchema, stateSchema;
2280
2325
  var init_store = __esm({
2281
2326
  "src/automation/store.ts"() {
2282
2327
  "use strict";
2283
- init_storage();
2328
+ init_store_lock();
2284
2329
  hostSchema = z5.enum(["claude", "codex"]);
2285
2330
  stateSchema = z5.object({
2286
2331
  version: z5.literal(1),