mason-context 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/audit/cli.ts
4
- import path16 from "path";
4
+ import path17 from "path";
5
5
 
6
6
  // src/audit/audit.ts
7
7
  import fs9 from "fs/promises";
@@ -35,6 +35,10 @@ function normalizeRepoPath(value) {
35
35
  const normalized = path.posix.normalize(slash).replace(/\/$/, "");
36
36
  return normalized === "." ? null : normalized;
37
37
  }
38
+ function isWithinRoot(root, candidate) {
39
+ const relative = path.relative(root, candidate);
40
+ return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
41
+ }
38
42
  function anchorMatches(anchor, file) {
39
43
  const a = normalizeRepoPath(anchor);
40
44
  const f = normalizeRepoPath(file);
@@ -132,6 +136,26 @@ async function readStoreJson(root, relative) {
132
136
  throw new Error(`Invalid Mason store ${relative}: ${error instanceof Error ? error.message : String(error)}`);
133
137
  }
134
138
  }
139
+ async function writeStoreJson(root, relative, value) {
140
+ const payload = JSON.stringify(value, null, 2) + "\n";
141
+ if (Buffer.byteLength(payload) > 10 * 1024 * 1024) {
142
+ throw new Error(`Mason store ${relative} exceeds 10 MiB`);
143
+ }
144
+ const file = await storePath(root, relative, true);
145
+ const temporary = path3.join(path3.dirname(file), `.${path3.basename(file)}.${randomUUID()}.tmp`);
146
+ try {
147
+ const handle = await fs2.open(temporary, "wx", 384);
148
+ try {
149
+ await handle.writeFile(payload, "utf8");
150
+ await handle.sync();
151
+ } finally {
152
+ await handle.close();
153
+ }
154
+ await fs2.rename(temporary, file);
155
+ } finally {
156
+ await fs2.rm(temporary, { force: true });
157
+ }
158
+ }
135
159
 
136
160
  // src/snapshot/snapshot.ts
137
161
  import { z } from "zod";
@@ -794,8 +818,8 @@ async function checkNewModules(ctx) {
794
818
  const absTop = path9.join(ctx.root, topDir);
795
819
  const topMentioned = isMentioned(combinedDocs, topDir);
796
820
  if (!topMentioned) {
797
- const count = await countSourceFiles(absTop);
798
- if (count >= 1) await flag(topDir, count);
821
+ const count2 = await countSourceFiles(absTop);
822
+ if (count2 >= 1) await flag(topDir, count2);
799
823
  continue;
800
824
  }
801
825
  const subdirs = await listSubdirs(absTop);
@@ -803,9 +827,9 @@ async function checkNewModules(ctx) {
803
827
  if (mentioned.length < ENUMERATION_THRESHOLD) continue;
804
828
  for (const sub of subdirs) {
805
829
  if (isMentioned(combinedDocs, sub)) continue;
806
- const count = await countSourceFiles(path9.join(absTop, sub));
807
- if (count >= SECOND_LEVEL_MIN_SOURCE_FILES) {
808
- await flag(`${topDir}/${sub}`, count);
830
+ const count2 = await countSourceFiles(path9.join(absTop, sub));
831
+ if (count2 >= SECOND_LEVEL_MIN_SOURCE_FILES) {
832
+ await flag(`${topDir}/${sub}`, count2);
809
833
  }
810
834
  }
811
835
  }
@@ -931,7 +955,15 @@ async function checkStaleCounts(ctx) {
931
955
  for (const doc of ctx.docs) {
932
956
  for (const claim of doc.claims.counts) {
933
957
  const source = await resolveCountSource(ctx.root, claim);
934
- if (source === null || source.actual === claim.count) continue;
958
+ if (source === null) {
959
+ result.skipped.push({
960
+ check: "stale-count",
961
+ doc: doc.path,
962
+ reason: `${doc.path}: cannot resolve a workspace manifest for "${claim.excerpt}"`
963
+ });
964
+ continue;
965
+ }
966
+ if (source.actual === claim.count) continue;
935
967
  result.issues.push({
936
968
  type: "stale-count",
937
969
  message: `says "${claim.excerpt}" but ${source.countedFrom} resolves to ${source.actual}`,
@@ -1039,10 +1071,12 @@ var MANIFEST_PATHSPECS = [
1039
1071
  ];
1040
1072
  async function checkDepsChanged(ctx) {
1041
1073
  const result = emptyResult();
1074
+ result.suppressedAdvisories = [];
1042
1075
  for (const doc of ctx.docs) {
1043
1076
  if (!doc.lastCommit) {
1044
1077
  result.skipped.push({
1045
1078
  check: "deps-changed",
1079
+ doc: doc.path,
1046
1080
  reason: `${doc.path} has no commit history`
1047
1081
  });
1048
1082
  continue;
@@ -1050,9 +1084,9 @@ async function checkDepsChanged(ctx) {
1050
1084
  if (doc.dirty) {
1051
1085
  result.skipped.push({
1052
1086
  check: "deps-changed",
1087
+ doc: doc.path,
1053
1088
  reason: `${doc.path} has uncommitted edits \u2013 suppressed while in flight`
1054
1089
  });
1055
- continue;
1056
1090
  }
1057
1091
  const range = await commitsTouchingSince(
1058
1092
  ctx.root,
@@ -1062,13 +1096,14 @@ async function checkDepsChanged(ctx) {
1062
1096
  if (range === null) {
1063
1097
  result.skipped.push({
1064
1098
  check: "deps-changed",
1099
+ doc: doc.path,
1065
1100
  reason: `${doc.path}: commit range unreachable (shallow clone?)`
1066
1101
  });
1067
1102
  continue;
1068
1103
  }
1069
1104
  if (range.total === 0) continue;
1070
1105
  const latest = range.commits[0];
1071
- result.advisories.push({
1106
+ (doc.dirty ? result.suppressedAdvisories : result.advisories).push({
1072
1107
  type: "deps-changed",
1073
1108
  message: `dependency manifests touched by ${range.total} commit${range.total === 1 ? "" : "s"} since ${doc.path} was last committed (latest: ${latest.hash.slice(0, 7)} "${latest.subject}")`,
1074
1109
  anchor: { doc: doc.path, line: null, excerpt: null },
@@ -1200,6 +1235,23 @@ function decisionContent(record) {
1200
1235
  function decisionApproval(record) {
1201
1236
  return record.version === 1 ? "unreviewed" : record.approval;
1202
1237
  }
1238
+ function effectiveDecision(record) {
1239
+ if (record.version !== 2 || record.status !== "active" || record.approval !== "proposed") return record;
1240
+ let index = record.history.length - 1;
1241
+ while (index >= 0 && !["accepted", "reaffirmed"].includes(record.history[index].kind)) index--;
1242
+ if (index < 0) return record;
1243
+ const event = record.history[index];
1244
+ return {
1245
+ ...record,
1246
+ ...event.content,
1247
+ owner: event.content.owner,
1248
+ approval: "accepted",
1249
+ revision: event.revision,
1250
+ refreshedHash: event.refreshedHash,
1251
+ updatedAt: event.at,
1252
+ history: record.history.slice(0, index + 1)
1253
+ };
1254
+ }
1203
1255
  function decisionProvenance(record, freshness = "unknown") {
1204
1256
  const approval = decisionApproval(record);
1205
1257
  const review = record.version === 2 ? [...record.history].reverse().find((e) => ["accepted", "reaffirmed"].includes(e.kind) && e.revision === record.revision) : void 0;
@@ -1246,12 +1298,8 @@ async function computeDecisionDrift(rootDir, decisions) {
1246
1298
  const report = { historyAvailable: true, totalDecisions: store.records.length, staleDecisions: {}, freshness: {}, diagnostics: store.diagnostics };
1247
1299
  const [head, workingTree] = await Promise.all([getCurrentGitHash(resolvedRoot), getWorkingTree(resolvedRoot)]);
1248
1300
  const changesByHash = /* @__PURE__ */ new Map();
1249
- for (const record of store.records) {
1250
- if (record.status !== "active") continue;
1251
- if (record.files.length === 0) {
1252
- report.freshness[record.id] = "unknown";
1253
- continue;
1254
- }
1301
+ const inspect = async (record) => {
1302
+ if (record.files.length === 0) return { freshness: "unknown", changedFiles: [] };
1255
1303
  let touched = changesByHash.get(record.refreshedHash);
1256
1304
  if (touched === void 0) {
1257
1305
  const changes = record.refreshedHash === head && head !== "unknown" ? [] : await getChangesWithStatus(resolvedRoot, record.refreshedHash);
@@ -1260,9 +1308,16 @@ async function computeDecisionDrift(rootDir, decisions) {
1260
1308
  }
1261
1309
  if (touched === null) report.historyAvailable = false;
1262
1310
  const hits = touched ? matchingPaths(record.files, touched) : [];
1263
- if (hits.length) report.staleDecisions[record.id] = hits;
1264
1311
  const localHits = matchingPaths(record.files, workingTree.changedFiles);
1265
- report.freshness[record.id] = touched === null || !workingTree.available ? "unknown" : hits.length || localHits.length ? "changed" : "current";
1312
+ return { freshness: touched === null || !workingTree.available ? "unknown" : hits.length || localHits.length ? "changed" : "current", changedFiles: hits };
1313
+ };
1314
+ for (const record of store.records) {
1315
+ if (record.status !== "active") continue;
1316
+ const effective = effectiveDecision(record);
1317
+ const state = await inspect(effective);
1318
+ report.freshness[record.id] = state.freshness;
1319
+ if (state.changedFiles.length) report.staleDecisions[record.id] = state.changedFiles;
1320
+ if (effective !== record) (report.pendingProposals ??= {})[record.id] = await inspect(record);
1266
1321
  }
1267
1322
  return report;
1268
1323
  }
@@ -1281,11 +1336,14 @@ async function checkDecisionAnchors(ctx) {
1281
1336
  reason: "some decision base commits are unreachable (shallow clone?)"
1282
1337
  });
1283
1338
  }
1284
- const byId = new Map(records.map((r) => [r.id, r]));
1285
- for (const [id, changedFiles] of Object.entries(drift.staleDecisions)) {
1286
- const record = byId.get(id);
1287
- if (!record) continue;
1288
- const provenance = decisionProvenance(record, drift.freshness?.[id] ?? "unknown");
1339
+ const changed = records.flatMap((record) => [
1340
+ { record: effectiveDecision(record), changedFiles: drift.staleDecisions[record.id] ?? [], freshness: drift.freshness?.[record.id] ?? "unknown" },
1341
+ { record, changedFiles: drift.pendingProposals?.[record.id]?.changedFiles ?? [], freshness: drift.pendingProposals?.[record.id]?.freshness ?? "unknown" }
1342
+ ]);
1343
+ for (const { record, changedFiles, freshness } of changed) {
1344
+ if (!changedFiles.length) continue;
1345
+ const id = record.id;
1346
+ const provenance = decisionProvenance(record, freshness);
1289
1347
  result.advisories.push({
1290
1348
  type: "decision-anchor-drift",
1291
1349
  message: `decision "${record.title}" (${provenance.approval}) has anchor files that changed since its evidence baseline \u2013 needs human review`,
@@ -1330,6 +1388,8 @@ async function computeAudit(rootDir, options = {}) {
1330
1388
  version: 1,
1331
1389
  root: resolvedRoot,
1332
1390
  gitAvailable: headHash !== "unknown",
1391
+ headHash,
1392
+ checksRun: [],
1333
1393
  docs: docs.map((d) => ({
1334
1394
  path: d.path,
1335
1395
  lastCommit: d.lastCommit,
@@ -1339,6 +1399,7 @@ async function computeAudit(rootDir, options = {}) {
1339
1399
  decisionsChecked: false,
1340
1400
  issues: [],
1341
1401
  advisories: [],
1402
+ suppressedAdvisories: [],
1342
1403
  skippedChecks: [],
1343
1404
  clean: true
1344
1405
  };
@@ -1367,15 +1428,263 @@ async function computeAudit(rootDir, options = {}) {
1367
1428
  const selected = options.checks ?? ALL_CHECKS;
1368
1429
  for (const name of ALL_CHECKS) {
1369
1430
  if (!selected.includes(name)) continue;
1370
- const { issues, advisories, skipped } = await CHECKS[name](ctx);
1431
+ const { issues, advisories, suppressedAdvisories, skipped } = await CHECKS[name](ctx);
1432
+ report.checksRun.push(name);
1371
1433
  report.issues.push(...issues);
1372
1434
  report.advisories.push(...advisories);
1435
+ report.suppressedAdvisories.push(...suppressedAdvisories ?? []);
1373
1436
  report.skippedChecks.push(...skipped);
1374
1437
  }
1375
1438
  report.clean = report.issues.length === 0;
1376
1439
  return report;
1377
1440
  }
1378
1441
 
1442
+ // src/audit/repair.ts
1443
+ import fs10 from "fs/promises";
1444
+ import path16 from "path";
1445
+ import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
1446
+ import { z as z3 } from "zod";
1447
+ var checkSchema = z3.enum(["deleted-reference", "new-module", "stale-count", "dead-command", "deps-changed", "decision-anchor-drift"]);
1448
+ var commitSchema = z3.object({ hash: z3.string().regex(/^[a-f0-9]{40,64}$/), date: z3.string(), subject: z3.string() });
1449
+ var anchorSchema = z3.object({ doc: z3.string(), line: z3.number().int().positive().nullable(), excerpt: z3.string().nullable() });
1450
+ var count = z3.number().int().nonnegative();
1451
+ var evidenceSchema = z3.discriminatedUnion("kind", [
1452
+ z3.object({
1453
+ kind: z3.literal("missing-path"),
1454
+ claimed: z3.string(),
1455
+ renamedTo: z3.string().nullable(),
1456
+ deletedInCommit: commitSchema.nullable(),
1457
+ everTracked: z3.boolean(),
1458
+ parentDirExists: z3.boolean()
1459
+ }),
1460
+ z3.object({
1461
+ kind: z3.literal("unmentioned-dir"),
1462
+ dir: z3.string(),
1463
+ sourceFileCount: count,
1464
+ firstCommit: commitSchema.nullable(),
1465
+ checkedDocs: z3.array(z3.string())
1466
+ }),
1467
+ z3.object({
1468
+ kind: z3.literal("count-mismatch"),
1469
+ claimed: count,
1470
+ actual: count,
1471
+ unit: z3.string(),
1472
+ countedFrom: z3.string(),
1473
+ members: z3.array(z3.string())
1474
+ }),
1475
+ z3.object({
1476
+ kind: z3.literal("missing-script"),
1477
+ scriptName: z3.string(),
1478
+ invocation: z3.string(),
1479
+ manifestsChecked: z3.array(z3.string()),
1480
+ availableScripts: z3.array(z3.string())
1481
+ }),
1482
+ z3.object({
1483
+ kind: z3.literal("doc-behind-manifests"),
1484
+ docLastCommit: commitSchema,
1485
+ manifestCommits: z3.array(commitSchema.extend({ files: z3.array(z3.string()) })),
1486
+ totalCommits: count
1487
+ }),
1488
+ z3.object({
1489
+ kind: z3.literal("decision-anchor"),
1490
+ decisionId: z3.string(),
1491
+ title: z3.string(),
1492
+ changedFiles: z3.array(z3.string()),
1493
+ refreshedHash: z3.string(),
1494
+ provenance: z3.object({}).passthrough().optional()
1495
+ })
1496
+ ]);
1497
+ var findingSchema = z3.object({ message: z3.string(), anchor: anchorSchema, evidence: evidenceSchema });
1498
+ var issueSchema = findingSchema.extend({
1499
+ type: z3.enum(["deleted-reference", "new-module", "stale-count", "dead-command"]),
1500
+ confidence: z3.enum(["certain", "likely"])
1501
+ });
1502
+ var advisorySchema = findingSchema.extend({ type: z3.enum(["deps-changed", "decision-anchor-drift"]) });
1503
+ var reportSchema = z3.object({
1504
+ version: z3.literal(1),
1505
+ root: z3.string(),
1506
+ gitAvailable: z3.literal(true),
1507
+ headHash: commitSchema.shape.hash,
1508
+ checksRun: z3.array(checkSchema).nonempty(),
1509
+ docs: z3.array(z3.object({
1510
+ path: z3.enum(DOC_CANDIDATES),
1511
+ lastCommit: commitSchema.nullable(),
1512
+ dirty: z3.boolean(),
1513
+ lineCount: count
1514
+ })).nonempty(),
1515
+ decisionsChecked: z3.boolean(),
1516
+ clean: z3.boolean(),
1517
+ issues: z3.array(issueSchema),
1518
+ advisories: z3.array(advisorySchema),
1519
+ suppressedAdvisories: z3.array(advisorySchema).optional(),
1520
+ skippedChecks: z3.array(z3.object({ check: z3.string(), reason: z3.string(), doc: z3.string().optional() }))
1521
+ });
1522
+ var baselineSchema = z3.object({
1523
+ kind: z3.literal("mason-audit-repair"),
1524
+ version: z3.literal(1),
1525
+ createdAt: z3.string().datetime(),
1526
+ report: reportSchema,
1527
+ digest: z3.string().regex(/^[a-f0-9]{64}$/)
1528
+ });
1529
+ var digest = (value) => createHash2("sha256").update(JSON.stringify(value)).digest("hex");
1530
+ function findingId(finding) {
1531
+ const e = finding.evidence;
1532
+ let key;
1533
+ switch (e.kind) {
1534
+ case "missing-path":
1535
+ key = e.claimed;
1536
+ break;
1537
+ case "unmentioned-dir":
1538
+ key = e.dir;
1539
+ break;
1540
+ case "count-mismatch":
1541
+ key = [e.unit.replace(/s$/, ""), e.countedFrom];
1542
+ break;
1543
+ case "missing-script":
1544
+ key = e.scriptName;
1545
+ break;
1546
+ case "doc-behind-manifests":
1547
+ key = null;
1548
+ break;
1549
+ case "decision-anchor":
1550
+ key = [e.decisionId, e.provenance?.revision, e.provenance?.approval];
1551
+ break;
1552
+ }
1553
+ return digest([finding.type, finding.anchor.doc, key]);
1554
+ }
1555
+ function allFindings(report) {
1556
+ return [...report.issues, ...report.advisories, ...report.suppressedAdvisories ?? []];
1557
+ }
1558
+ async function docState(root) {
1559
+ const docs = [];
1560
+ for (const doc of DOC_CANDIDATES) {
1561
+ try {
1562
+ const content = await readBoundedFile(await storePath(root, doc), 10 * 1024 * 1024);
1563
+ if (content === null) throw new Error("Context file is not regular or exceeds 10 MiB: " + doc);
1564
+ docs.push([doc, digest(content)]);
1565
+ } catch (error) {
1566
+ if (error.code !== "ENOENT") throw error;
1567
+ docs.push([doc, null]);
1568
+ }
1569
+ }
1570
+ return digest(docs);
1571
+ }
1572
+ async function stableAudit(root, checks) {
1573
+ const head = await getCurrentGitHash(root);
1574
+ const before = await docState(root);
1575
+ const report = await computeAudit(root, { checks });
1576
+ if (head !== await getCurrentGitHash(root) || before !== await docState(root) || report && report.headHash !== head) {
1577
+ throw new Error("HEAD or context files changed during the audit; retry against a stable checkout.");
1578
+ }
1579
+ return report;
1580
+ }
1581
+ async function prepareRepair(rootDir, checks = ALL_CHECKS) {
1582
+ const root = await fs10.realpath(rootDir);
1583
+ const selected = z3.array(checkSchema).nonempty().parse(checks);
1584
+ const report = await stableAudit(root, selected);
1585
+ if (!report) throw new Error("No context files found to prepare a repair.");
1586
+ if (!report.gitAvailable) throw new Error("Readable Git history is required to prepare a repair.");
1587
+ const storedReport = reportSchema.parse(report);
1588
+ const payload = {
1589
+ kind: "mason-audit-repair",
1590
+ version: 1,
1591
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1592
+ report: storedReport
1593
+ };
1594
+ const baselinePath = ".mason/reports/repairs/" + randomUUID2() + ".json";
1595
+ await writeStoreJson(root, baselinePath, { ...payload, digest: digest(payload) });
1596
+ return { version: 1, action: "prepare", baselinePath, report };
1597
+ }
1598
+ async function verifyRepair(rootDir, baselinePath) {
1599
+ const root = await fs10.realpath(rootDir);
1600
+ const declaredRoot = path16.resolve(rootDir);
1601
+ const relative = path16.isAbsolute(baselinePath) ? path16.relative(isWithinRoot(declaredRoot, baselinePath) ? declaredRoot : root, baselinePath) : baselinePath;
1602
+ const stored = baselineSchema.parse(await readStoreJson(root, relative));
1603
+ const { digest: savedDigest, ...payload } = stored;
1604
+ if (digest(payload) !== savedDigest) throw new Error("Repair baseline was modified; use the original baseline.");
1605
+ if (stored.report.root !== root) throw new Error("Repair baseline belongs to a different repository.");
1606
+ const original = stored.report;
1607
+ const diagnostics = [];
1608
+ let current = null;
1609
+ try {
1610
+ current = await stableAudit(root, original.checksRun);
1611
+ if (!current) diagnostics.push("No context files remain available to audit.");
1612
+ else if (!current.gitAvailable) diagnostics.push("Git history is unavailable.");
1613
+ for (const doc of original.docs) {
1614
+ if (!original.issues.some((f) => f.anchor.doc === doc.path) || !current?.docs.some((d) => d.path === doc.path)) continue;
1615
+ const content = await readBoundedFile(await storePath(root, doc.path), 10 * 1024 * 1024);
1616
+ if (content === null || !content.trim()) {
1617
+ diagnostics.push("Original context file " + doc.path + " is empty or unreadable; losing its claims does not verify a repair.");
1618
+ }
1619
+ }
1620
+ if (await getChangesWithStatus(root, original.headHash) === null) {
1621
+ diagnostics.push("The original audit commit is unavailable; repair history cannot be verified.");
1622
+ }
1623
+ } catch (error) {
1624
+ diagnostics.push(error instanceof Error ? error.message : String(error));
1625
+ }
1626
+ const currentById = new Map((current ? allFindings(current) : []).map((f) => [findingId(f), f]));
1627
+ const originalFindings = allFindings(original);
1628
+ const originalIds = new Set(originalFindings.map(findingId));
1629
+ const missingDocs = original.docs.filter((doc) => !current?.docs.some((d) => d.path === doc.path));
1630
+ for (const doc of missingDocs) diagnostics.push("Original context file " + doc.path + " is unavailable; removing it does not verify a repair.");
1631
+ const findings = originalFindings.map((finding) => {
1632
+ const id = findingId(finding);
1633
+ const now = currentById.get(id);
1634
+ const base = { id, original: finding, ...now ? { current: now } : {} };
1635
+ if (diagnostics.length || !current) {
1636
+ return { ...base, status: "unverified", reason: "The original audit scope could not be verified. See diagnostics." };
1637
+ }
1638
+ if ("confidence" in finding && now) {
1639
+ return { ...base, status: "unresolved", reason: "The original check still reports this claim." };
1640
+ }
1641
+ const skipped = current.skippedChecks.filter((s) => s.check === finding.type && (!s.doc || s.doc === finding.anchor.doc));
1642
+ if (!current.checksRun?.includes(finding.type) || skipped.length) {
1643
+ return { ...base, status: "unverified", reason: skipped.map((s) => s.reason).join("; ") || "The original check did not run." };
1644
+ }
1645
+ if (!("confidence" in finding)) {
1646
+ return {
1647
+ ...base,
1648
+ status: "review-required",
1649
+ reason: "An audit cannot establish that this advisory was reviewed. Retain its original evidence and report a separate assessment; editing or committing the doc is not approval."
1650
+ };
1651
+ }
1652
+ return { ...base, status: "resolved", reason: "The original check ran and no longer reports this claim. Inspect the edit for semantic correctness." };
1653
+ });
1654
+ const newFindings = [...currentById].filter(([id]) => !originalIds.has(id)).map(([, f]) => f);
1655
+ const counts = { resolved: 0, unresolved: 0, "review-required": 0, unverified: 0 };
1656
+ for (const f of findings) counts[f.status]++;
1657
+ const incomplete = diagnostics.length > 0 || counts.unverified > 0 || counts["review-required"] > 0 || (current?.skippedChecks.length ?? 0) > 0 || newFindings.some((f) => !("confidence" in f));
1658
+ const issuesRemain = counts.unresolved > 0 || newFindings.some((f) => "confidence" in f);
1659
+ return {
1660
+ version: 1,
1661
+ action: "verify",
1662
+ baselinePath: relative,
1663
+ baselineHead: original.headHash,
1664
+ currentHead: current?.gitAvailable ? current.headHash : null,
1665
+ status: incomplete ? "incomplete" : issuesRemain ? "issues-remain" : "verified",
1666
+ findings,
1667
+ newFindings,
1668
+ diagnostics,
1669
+ currentAudit: current,
1670
+ counts,
1671
+ scope: "Original audit checks over current context files and repository evidence. Resolved means no longer detected by that check. Advisories require separate review; this is not a certification of documentation or application correctness."
1672
+ };
1673
+ }
1674
+ function repairExitCode(report) {
1675
+ return report.status === "verified" ? 0 : report.status === "issues-remain" ? 1 : 2;
1676
+ }
1677
+ function formatRepairSummary(report) {
1678
+ return [
1679
+ "Repair verification: " + report.status + ". Baseline: " + report.baselinePath,
1680
+ ...report.findings.map((f) => " [" + f.status + "] " + f.original.type + " " + f.original.anchor.doc + ": " + f.original.message + "\n " + f.reason),
1681
+ ...report.newFindings.map((f) => " [new] " + f.type + " " + f.anchor.doc + ": " + f.message),
1682
+ ...report.diagnostics.map((d) => " [unverified] " + d),
1683
+ ...(report.currentAudit?.skippedChecks ?? []).map((s) => " [skipped] " + s.check + ": " + s.reason),
1684
+ report.scope
1685
+ ].join("\n");
1686
+ }
1687
+
1379
1688
  // src/audit/cli.ts
1380
1689
  var USAGE = `Usage: mason-audit [--dir <path>] [--json | --fix-prompt] [--checks <list>]
1381
1690
 
@@ -1390,7 +1699,10 @@ Options:
1390
1699
  --json Print the full audit report as JSON (additive-only schema)
1391
1700
  --fix-prompt When issues exist, print a work order for ANY coding agent
1392
1701
  (Claude, Codex, Gemini, ...) \u2013 pipe it to your agent CLI to
1393
- close the loop. Prints the clean summary when there are none.
1702
+ repair the findings. Includes advisories that require review.
1703
+ --prepare-repair Save the original audit under .mason/reports/repairs/ before edits
1704
+ --verify-repair <path>
1705
+ Compare against that saved baseline, using its original checks
1394
1706
  --checks <list> Comma-separated subset of checks to run (default: all):
1395
1707
  ${ALL_CHECKS.join(", ")}
1396
1708
  --help Show this help
@@ -1398,14 +1710,19 @@ Options:
1398
1710
  Exit codes:
1399
1711
  0 no issues (advisories may still be present)
1400
1712
  1 provable issues found
1401
- 2 error (no context file, not a git repository, bad arguments)`;
1713
+ 2 error (no context file, not a git repository, bad arguments)
1714
+
1715
+ With --verify-repair: 0 verified by the original checks; 1 issues remain;
1716
+ 2 incomplete (unverified findings, skipped checks, or advisories needing review).
1717
+ Preparation writes only a baseline; verification and ordinary audits are read-only.`;
1402
1718
  function parseArgs(argv) {
1403
1719
  const parsed = {
1404
1720
  dir: process.cwd(),
1405
1721
  json: false,
1406
1722
  fixPrompt: false,
1407
1723
  help: false,
1408
- checks: void 0
1724
+ checks: void 0,
1725
+ prepareRepair: false
1409
1726
  };
1410
1727
  for (let i = 0; i < argv.length; i++) {
1411
1728
  const arg = argv[i];
@@ -1413,6 +1730,12 @@ function parseArgs(argv) {
1413
1730
  parsed.json = true;
1414
1731
  } else if (arg === "--fix-prompt") {
1415
1732
  parsed.fixPrompt = true;
1733
+ } else if (arg === "--prepare-repair") {
1734
+ parsed.prepareRepair = true;
1735
+ } else if (arg === "--verify-repair") {
1736
+ const value = argv[++i];
1737
+ if (!value || value.startsWith("--")) throw new Error("--verify-repair requires a baseline path");
1738
+ parsed.baseline = value;
1416
1739
  } else if (arg === "--help" || arg === "-h") {
1417
1740
  parsed.help = true;
1418
1741
  } else if (arg === "--dir") {
@@ -1423,6 +1746,7 @@ function parseArgs(argv) {
1423
1746
  const value = argv[++i];
1424
1747
  if (!value) throw new Error("--checks requires a comma-separated list");
1425
1748
  const names = value.split(",").map((n) => n.trim()).filter(Boolean);
1749
+ if (!names.length) throw new Error("--checks requires at least one check");
1426
1750
  for (const name of names) {
1427
1751
  if (!ALL_CHECKS.includes(name)) {
1428
1752
  throw new Error(
@@ -1446,6 +1770,7 @@ function issueLine(issue) {
1446
1770
  }
1447
1771
  function formatAuditSummary(report) {
1448
1772
  const lines = [];
1773
+ const reviewCount = report.advisories.length + (report.suppressedAdvisories?.length ?? 0);
1449
1774
  for (const doc of report.docs) {
1450
1775
  const docIssues = report.issues.filter((i) => i.anchor.doc === doc.path);
1451
1776
  const committed = doc.lastCommit ? `last committed ${doc.lastCommit.date.slice(0, 10)}, ${doc.lastCommit.hash.slice(0, 7)}` : "untracked";
@@ -1473,21 +1798,25 @@ function formatAuditSummary(report) {
1473
1798
  lines.push(` [skipped] ${skip.check}: ${skip.reason}`);
1474
1799
  }
1475
1800
  }
1801
+ for (const advisory of report.suppressedAdvisories ?? []) {
1802
+ lines.push(` [suppressed; unresolved] ${advisory.type} ${advisory.anchor.doc}: ${advisory.message}`);
1803
+ }
1476
1804
  lines.push(
1477
- report.clean ? `Context files are clean (${report.docs.length} doc${report.docs.length === 1 ? "" : "s"} audited).` : `${report.issues.length} issue${report.issues.length === 1 ? "" : "s"} across ${report.docs.length} doc${report.docs.length === 1 ? "" : "s"}.`
1805
+ report.clean ? reviewCount || report.skippedChecks.length ? `No audit issues detected (${report.docs.length} docs audited); ${reviewCount} advisories remain for review, ${report.skippedChecks.length} checks skipped.` : `Context files are clean (${report.docs.length} doc${report.docs.length === 1 ? "" : "s"} audited).` : `${report.issues.length} issue${report.issues.length === 1 ? "" : "s"} across ${report.docs.length} doc${report.docs.length === 1 ? "" : "s"}.`
1478
1806
  );
1479
1807
  return lines.join("\n");
1480
1808
  }
1481
- function formatFixPrompt(report) {
1809
+ function formatFixPrompt(report, baselinePath) {
1482
1810
  const flaggedDocs = [...new Set(report.issues.map((i) => i.anchor.doc))];
1483
1811
  const lines = [];
1484
1812
  lines.push(
1485
- "The AI context files in this repository contain claims that are provably out of date. Fix ONLY the flagged claims. Work autonomously; do not ask questions."
1813
+ "Review the flagged context claims using the evidence below. Make minimal repairs within the user's authorized scope. A setup-only or audit-only request does not authorize rewriting existing documentation."
1486
1814
  );
1487
1815
  lines.push("");
1488
1816
  lines.push("RULES:");
1817
+ lines.push(baselinePath ? `- Preserve the original repair baseline: ${JSON.stringify(baselinePath)}. Do not replace it after editing.` : "- Before the first edit, call mason_repair with action: prepare, or run mason-audit --prepare-repair --json with the same --dir and --checks. Keep the returned baselinePath through verification.");
1489
1818
  lines.push(
1490
- `- Edit ONLY these files: ${flaggedDocs.join(", ")}. Never modify source code, configs, or anything else \u2013 the docs must be brought to match the code, not the other way around.`
1819
+ `- Edit ONLY these files: ${flaggedDocs.join(", ") || "none (advisory review only)"}. Bring the docs into agreement with verified source evidence; do not change source code or configs to silence findings.`
1491
1820
  );
1492
1821
  lines.push(
1493
1822
  "- Keep diffs minimal: change the smallest span that makes each claim true."
@@ -1495,6 +1824,7 @@ function formatFixPrompt(report) {
1495
1824
  lines.push(
1496
1825
  "- Never invent content. Every replacement must be grounded in the evidence below or in files you read from this repository."
1497
1826
  );
1827
+ lines.push("- Inspect likely findings before editing: heuristic evidence may describe an intentional omission or an example.");
1498
1828
  lines.push(
1499
1829
  "- deleted-reference: if evidence shows renamedTo, update the path; otherwise remove the reference, or rephrase to past tense if the sentence is about history. Deleted paths inside directory trees: delete the tree line."
1500
1830
  );
@@ -1508,20 +1838,27 @@ function formatFixPrompt(report) {
1508
1838
  "- new-module: add a one-line factual mention of the directory where sibling modules are described; read the directory's files first and describe only what you verified."
1509
1839
  );
1510
1840
  lines.push(
1511
- "- Do NOT touch anything listed under ADVISORIES \u2013 list them in your summary for human review instead."
1841
+ "- ADVISORIES require a separate assessment of the cited commits or decision evidence. Report any review you perform and what remains unknown. Their disappearance after edits or a commit does not establish review or approval."
1512
1842
  );
1513
1843
  lines.push("");
1514
- lines.push("AUDIT REPORT (deterministic, computed against git HEAD):");
1844
+ lines.push("AUDIT REPORT (current context files and repository evidence, including local edits):");
1515
1845
  lines.push(
1516
1846
  JSON.stringify(
1517
- { issues: report.issues, advisories: report.advisories },
1847
+ {
1848
+ root: report.root,
1849
+ checks: report.checksRun,
1850
+ issues: report.issues,
1851
+ advisories: report.advisories,
1852
+ suppressedAdvisories: report.suppressedAdvisories,
1853
+ skippedChecks: report.skippedChecks
1854
+ },
1518
1855
  null,
1519
1856
  2
1520
1857
  )
1521
1858
  );
1522
1859
  lines.push("");
1523
1860
  lines.push(
1524
- "Finish by summarizing each edit and citing the evidence item it resolves."
1861
+ "After edits, call mason_repair with action: verify and the original baselinePath, or mason-audit --verify-repair <baselinePath> --dir <project>. Repeat against the same baseline after any final documentation commit. Summarize resolved, unresolved, review-required, unverified, and new findings with their evidence. Do not report a suppressed or unavailable check as fixed. This audit covers the listed context files; independently discovered README or application issues need their own validation."
1525
1862
  );
1526
1863
  return lines.join("\n");
1527
1864
  }
@@ -1537,6 +1874,9 @@ async function runAuditCli(argv, io = {
1537
1874
  if (args.json && args.fixPrompt) {
1538
1875
  throw new Error("--json and --fix-prompt are mutually exclusive");
1539
1876
  }
1877
+ if (args.baseline && (args.prepareRepair || args.checks || args.fixPrompt)) {
1878
+ throw new Error("--verify-repair cannot be combined with --prepare-repair, --checks, or --fix-prompt; verification uses the original scope");
1879
+ }
1540
1880
  } catch (error) {
1541
1881
  io.err(error instanceof Error ? error.message : String(error));
1542
1882
  io.err(USAGE);
@@ -1546,7 +1886,23 @@ async function runAuditCli(argv, io = {
1546
1886
  io.out(USAGE);
1547
1887
  return 0;
1548
1888
  }
1549
- const rootDir = path16.resolve(args.dir);
1889
+ const rootDir = path17.resolve(args.dir);
1890
+ if (args.baseline || args.prepareRepair) {
1891
+ try {
1892
+ if (args.baseline) {
1893
+ const verification = await verifyRepair(rootDir, args.baseline);
1894
+ io.out(args.json ? JSON.stringify(verification, null, 2) : formatRepairSummary(verification));
1895
+ return repairExitCode(verification);
1896
+ }
1897
+ const prepared = await prepareRepair(rootDir, args.checks);
1898
+ io.out(args.json ? JSON.stringify({ ...prepared, workOrder: formatFixPrompt(prepared.report, prepared.baselinePath) }, null, 2) : args.fixPrompt ? formatFixPrompt(prepared.report, prepared.baselinePath) : `Repair baseline: ${prepared.baselinePath}
1899
+ ${formatAuditSummary(prepared.report)}`);
1900
+ return prepared.report.clean ? 0 : 1;
1901
+ } catch (error) {
1902
+ io.err(error instanceof Error ? error.message : String(error));
1903
+ return 2;
1904
+ }
1905
+ }
1550
1906
  const report = await computeAudit(rootDir, { checks: args.checks });
1551
1907
  if (!report) {
1552
1908
  io.err(
@@ -1562,7 +1918,7 @@ async function runAuditCli(argv, io = {
1562
1918
  }
1563
1919
  if (args.fixPrompt) {
1564
1920
  io.out(
1565
- report.clean ? formatAuditSummary(report) : formatFixPrompt(report)
1921
+ report.clean && !report.advisories.length && !report.suppressedAdvisories?.length ? formatAuditSummary(report) : formatFixPrompt(report)
1566
1922
  );
1567
1923
  return report.clean ? 0 : 1;
1568
1924
  }