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.
- package/CHANGELOG.md +19 -0
- package/README.md +32 -5
- package/dist/mason-audit.js +392 -36
- package/dist/mason-audit.js.map +1 -1
- package/dist/mason-drift.js +33 -8
- package/dist/mason-drift.js.map +1 -1
- package/dist/mason-hook.js +74 -22
- package/dist/mason-hook.js.map +1 -1
- package/dist/mason-mcp.js +2631 -2220
- package/dist/mason-mcp.js.map +1 -1
- package/dist/mason-review.js +70 -18
- package/dist/mason-review.js.map +1 -1
- package/package.json +1 -1
package/dist/mason-audit.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/audit/cli.ts
|
|
4
|
-
import
|
|
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
|
|
798
|
-
if (
|
|
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
|
|
807
|
-
if (
|
|
808
|
-
await flag(`${topDir}/${sub}`,
|
|
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
|
|
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
|
-
|
|
1250
|
-
if (record.
|
|
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
|
-
|
|
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
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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(", ")
|
|
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
|
-
"-
|
|
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 (
|
|
1844
|
+
lines.push("AUDIT REPORT (current context files and repository evidence, including local edits):");
|
|
1515
1845
|
lines.push(
|
|
1516
1846
|
JSON.stringify(
|
|
1517
|
-
{
|
|
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
|
-
"
|
|
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 =
|
|
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
|
}
|