mason-context 0.10.1 → 0.12.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 +22 -0
- package/README.md +63 -2
- package/dist/mason-audit.js +362 -23
- package/dist/mason-audit.js.map +1 -1
- package/dist/mason-auto.js +2283 -0
- package/dist/mason-auto.js.map +1 -0
- package/dist/mason-mcp.js +4605 -3545
- package/dist/mason-mcp.js.map +1 -1
- package/package.json +3 -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 },
|
|
@@ -1353,6 +1388,8 @@ async function computeAudit(rootDir, options = {}) {
|
|
|
1353
1388
|
version: 1,
|
|
1354
1389
|
root: resolvedRoot,
|
|
1355
1390
|
gitAvailable: headHash !== "unknown",
|
|
1391
|
+
headHash,
|
|
1392
|
+
checksRun: [],
|
|
1356
1393
|
docs: docs.map((d) => ({
|
|
1357
1394
|
path: d.path,
|
|
1358
1395
|
lastCommit: d.lastCommit,
|
|
@@ -1362,6 +1399,7 @@ async function computeAudit(rootDir, options = {}) {
|
|
|
1362
1399
|
decisionsChecked: false,
|
|
1363
1400
|
issues: [],
|
|
1364
1401
|
advisories: [],
|
|
1402
|
+
suppressedAdvisories: [],
|
|
1365
1403
|
skippedChecks: [],
|
|
1366
1404
|
clean: true
|
|
1367
1405
|
};
|
|
@@ -1390,15 +1428,269 @@ async function computeAudit(rootDir, options = {}) {
|
|
|
1390
1428
|
const selected = options.checks ?? ALL_CHECKS;
|
|
1391
1429
|
for (const name of ALL_CHECKS) {
|
|
1392
1430
|
if (!selected.includes(name)) continue;
|
|
1393
|
-
const { issues, advisories, skipped } = await CHECKS[name](ctx);
|
|
1431
|
+
const { issues, advisories, suppressedAdvisories, skipped } = await (options.runCheck ? options.runCheck(name, ctx) : CHECKS[name](ctx));
|
|
1432
|
+
report.checksRun.push(name);
|
|
1394
1433
|
report.issues.push(...issues);
|
|
1395
1434
|
report.advisories.push(...advisories);
|
|
1435
|
+
report.suppressedAdvisories.push(...suppressedAdvisories ?? []);
|
|
1396
1436
|
report.skippedChecks.push(...skipped);
|
|
1397
1437
|
}
|
|
1398
1438
|
report.clean = report.issues.length === 0;
|
|
1399
1439
|
return report;
|
|
1400
1440
|
}
|
|
1401
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 checkResultSchema = z3.object({
|
|
1504
|
+
issues: z3.array(issueSchema),
|
|
1505
|
+
advisories: z3.array(advisorySchema),
|
|
1506
|
+
suppressedAdvisories: z3.array(advisorySchema).optional(),
|
|
1507
|
+
skipped: z3.array(z3.object({ check: z3.string(), reason: z3.string(), doc: z3.string().optional() }))
|
|
1508
|
+
});
|
|
1509
|
+
var reportSchema = z3.object({
|
|
1510
|
+
version: z3.literal(1),
|
|
1511
|
+
root: z3.string(),
|
|
1512
|
+
gitAvailable: z3.literal(true),
|
|
1513
|
+
headHash: commitSchema.shape.hash,
|
|
1514
|
+
checksRun: z3.array(checkSchema).nonempty(),
|
|
1515
|
+
docs: z3.array(z3.object({
|
|
1516
|
+
path: z3.enum(DOC_CANDIDATES),
|
|
1517
|
+
lastCommit: commitSchema.nullable(),
|
|
1518
|
+
dirty: z3.boolean(),
|
|
1519
|
+
lineCount: count
|
|
1520
|
+
})).nonempty(),
|
|
1521
|
+
decisionsChecked: z3.boolean(),
|
|
1522
|
+
clean: z3.boolean(),
|
|
1523
|
+
issues: z3.array(issueSchema),
|
|
1524
|
+
advisories: z3.array(advisorySchema),
|
|
1525
|
+
suppressedAdvisories: z3.array(advisorySchema).optional(),
|
|
1526
|
+
skippedChecks: z3.array(z3.object({ check: z3.string(), reason: z3.string(), doc: z3.string().optional() }))
|
|
1527
|
+
});
|
|
1528
|
+
var baselineSchema = z3.object({
|
|
1529
|
+
kind: z3.literal("mason-audit-repair"),
|
|
1530
|
+
version: z3.literal(1),
|
|
1531
|
+
createdAt: z3.string().datetime(),
|
|
1532
|
+
report: reportSchema,
|
|
1533
|
+
digest: z3.string().regex(/^[a-f0-9]{64}$/)
|
|
1534
|
+
});
|
|
1535
|
+
var digest = (value) => createHash2("sha256").update(JSON.stringify(value)).digest("hex");
|
|
1536
|
+
function findingId(finding) {
|
|
1537
|
+
const e = finding.evidence;
|
|
1538
|
+
let key;
|
|
1539
|
+
switch (e.kind) {
|
|
1540
|
+
case "missing-path":
|
|
1541
|
+
key = e.claimed;
|
|
1542
|
+
break;
|
|
1543
|
+
case "unmentioned-dir":
|
|
1544
|
+
key = e.dir;
|
|
1545
|
+
break;
|
|
1546
|
+
case "count-mismatch":
|
|
1547
|
+
key = [e.unit.replace(/s$/, ""), e.countedFrom];
|
|
1548
|
+
break;
|
|
1549
|
+
case "missing-script":
|
|
1550
|
+
key = e.scriptName;
|
|
1551
|
+
break;
|
|
1552
|
+
case "doc-behind-manifests":
|
|
1553
|
+
key = null;
|
|
1554
|
+
break;
|
|
1555
|
+
case "decision-anchor":
|
|
1556
|
+
key = [e.decisionId, e.provenance?.revision, e.provenance?.approval];
|
|
1557
|
+
break;
|
|
1558
|
+
}
|
|
1559
|
+
return digest([finding.type, finding.anchor.doc, key]);
|
|
1560
|
+
}
|
|
1561
|
+
function allFindings(report) {
|
|
1562
|
+
return [...report.issues, ...report.advisories, ...report.suppressedAdvisories ?? []];
|
|
1563
|
+
}
|
|
1564
|
+
async function docState(root) {
|
|
1565
|
+
const docs = [];
|
|
1566
|
+
for (const doc of DOC_CANDIDATES) {
|
|
1567
|
+
try {
|
|
1568
|
+
const content = await readBoundedFile(await storePath(root, doc), 10 * 1024 * 1024);
|
|
1569
|
+
if (content === null) throw new Error("Context file is not regular or exceeds 10 MiB: " + doc);
|
|
1570
|
+
docs.push([doc, digest(content)]);
|
|
1571
|
+
} catch (error) {
|
|
1572
|
+
if (error.code !== "ENOENT") throw error;
|
|
1573
|
+
docs.push([doc, null]);
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
return digest(docs);
|
|
1577
|
+
}
|
|
1578
|
+
async function stableAudit(root, checks, options = {}) {
|
|
1579
|
+
const head = await getCurrentGitHash(root);
|
|
1580
|
+
const before = await docState(root);
|
|
1581
|
+
const report = await computeAudit(root, { ...options, checks });
|
|
1582
|
+
if (head !== await getCurrentGitHash(root) || before !== await docState(root) || report && report.headHash !== head) {
|
|
1583
|
+
throw new Error("HEAD or context files changed during the audit; retry against a stable checkout.");
|
|
1584
|
+
}
|
|
1585
|
+
return report;
|
|
1586
|
+
}
|
|
1587
|
+
async function prepareRepair(rootDir, checks = ALL_CHECKS, options = {}) {
|
|
1588
|
+
const root = await fs10.realpath(rootDir);
|
|
1589
|
+
const selected = z3.array(checkSchema).nonempty().parse(checks);
|
|
1590
|
+
const report = await stableAudit(root, selected, options);
|
|
1591
|
+
if (!report) throw new Error("No context files found to prepare a repair.");
|
|
1592
|
+
if (!report.gitAvailable) throw new Error("Readable Git history is required to prepare a repair.");
|
|
1593
|
+
const storedReport = reportSchema.parse(report);
|
|
1594
|
+
const payload = {
|
|
1595
|
+
kind: "mason-audit-repair",
|
|
1596
|
+
version: 1,
|
|
1597
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1598
|
+
report: storedReport
|
|
1599
|
+
};
|
|
1600
|
+
const baselinePath = ".mason/reports/repairs/" + randomUUID2() + ".json";
|
|
1601
|
+
await writeStoreJson(root, baselinePath, { ...payload, digest: digest(payload) });
|
|
1602
|
+
return { version: 1, action: "prepare", baselinePath, report };
|
|
1603
|
+
}
|
|
1604
|
+
async function verifyRepair(rootDir, baselinePath, options = {}) {
|
|
1605
|
+
const root = await fs10.realpath(rootDir);
|
|
1606
|
+
const declaredRoot = path16.resolve(rootDir);
|
|
1607
|
+
const relative = path16.isAbsolute(baselinePath) ? path16.relative(isWithinRoot(declaredRoot, baselinePath) ? declaredRoot : root, baselinePath) : baselinePath;
|
|
1608
|
+
const stored = baselineSchema.parse(await readStoreJson(root, relative));
|
|
1609
|
+
const { digest: savedDigest, ...payload } = stored;
|
|
1610
|
+
if (digest(payload) !== savedDigest) throw new Error("Repair baseline was modified; use the original baseline.");
|
|
1611
|
+
if (stored.report.root !== root) throw new Error("Repair baseline belongs to a different repository.");
|
|
1612
|
+
const original = stored.report;
|
|
1613
|
+
const diagnostics = [];
|
|
1614
|
+
let current = null;
|
|
1615
|
+
try {
|
|
1616
|
+
current = await stableAudit(root, original.checksRun, options);
|
|
1617
|
+
if (!current) diagnostics.push("No context files remain available to audit.");
|
|
1618
|
+
else if (!current.gitAvailable) diagnostics.push("Git history is unavailable.");
|
|
1619
|
+
for (const doc of original.docs) {
|
|
1620
|
+
if (!original.issues.some((f) => f.anchor.doc === doc.path) || !current?.docs.some((d) => d.path === doc.path)) continue;
|
|
1621
|
+
const content = await readBoundedFile(await storePath(root, doc.path), 10 * 1024 * 1024);
|
|
1622
|
+
if (content === null || !content.trim()) {
|
|
1623
|
+
diagnostics.push("Original context file " + doc.path + " is empty or unreadable; losing its claims does not verify a repair.");
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
if (await getChangesWithStatus(root, original.headHash) === null) {
|
|
1627
|
+
diagnostics.push("The original audit commit is unavailable; repair history cannot be verified.");
|
|
1628
|
+
}
|
|
1629
|
+
} catch (error) {
|
|
1630
|
+
diagnostics.push(error instanceof Error ? error.message : String(error));
|
|
1631
|
+
}
|
|
1632
|
+
const currentById = new Map((current ? allFindings(current) : []).map((f) => [findingId(f), f]));
|
|
1633
|
+
const originalFindings = allFindings(original);
|
|
1634
|
+
const originalIds = new Set(originalFindings.map(findingId));
|
|
1635
|
+
const missingDocs = original.docs.filter((doc) => !current?.docs.some((d) => d.path === doc.path));
|
|
1636
|
+
for (const doc of missingDocs) diagnostics.push("Original context file " + doc.path + " is unavailable; removing it does not verify a repair.");
|
|
1637
|
+
const findings = originalFindings.map((finding) => {
|
|
1638
|
+
const id = findingId(finding);
|
|
1639
|
+
const now = currentById.get(id);
|
|
1640
|
+
const base = { id, original: finding, ...now ? { current: now } : {} };
|
|
1641
|
+
if (diagnostics.length || !current) {
|
|
1642
|
+
return { ...base, status: "unverified", reason: "The original audit scope could not be verified. See diagnostics." };
|
|
1643
|
+
}
|
|
1644
|
+
if ("confidence" in finding && now) {
|
|
1645
|
+
return { ...base, status: "unresolved", reason: "The original check still reports this claim." };
|
|
1646
|
+
}
|
|
1647
|
+
const skipped = current.skippedChecks.filter((s) => s.check === finding.type && (!s.doc || s.doc === finding.anchor.doc));
|
|
1648
|
+
if (!current.checksRun?.includes(finding.type) || skipped.length) {
|
|
1649
|
+
return { ...base, status: "unverified", reason: skipped.map((s) => s.reason).join("; ") || "The original check did not run." };
|
|
1650
|
+
}
|
|
1651
|
+
if (!("confidence" in finding)) {
|
|
1652
|
+
return {
|
|
1653
|
+
...base,
|
|
1654
|
+
status: "review-required",
|
|
1655
|
+
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."
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
return { ...base, status: "resolved", reason: "The original check ran and no longer reports this claim. Inspect the edit for semantic correctness." };
|
|
1659
|
+
});
|
|
1660
|
+
const newFindings = [...currentById].filter(([id]) => !originalIds.has(id)).map(([, f]) => f);
|
|
1661
|
+
const counts = { resolved: 0, unresolved: 0, "review-required": 0, unverified: 0 };
|
|
1662
|
+
for (const f of findings) counts[f.status]++;
|
|
1663
|
+
const incomplete = diagnostics.length > 0 || counts.unverified > 0 || counts["review-required"] > 0 || (current?.skippedChecks.length ?? 0) > 0 || newFindings.some((f) => !("confidence" in f));
|
|
1664
|
+
const issuesRemain = counts.unresolved > 0 || newFindings.some((f) => "confidence" in f);
|
|
1665
|
+
return {
|
|
1666
|
+
version: 1,
|
|
1667
|
+
action: "verify",
|
|
1668
|
+
baselinePath: relative,
|
|
1669
|
+
baselineHead: original.headHash,
|
|
1670
|
+
currentHead: current?.gitAvailable ? current.headHash : null,
|
|
1671
|
+
status: incomplete ? "incomplete" : issuesRemain ? "issues-remain" : "verified",
|
|
1672
|
+
findings,
|
|
1673
|
+
newFindings,
|
|
1674
|
+
diagnostics,
|
|
1675
|
+
currentAudit: current,
|
|
1676
|
+
counts,
|
|
1677
|
+
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."
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
function repairExitCode(report) {
|
|
1681
|
+
return report.status === "verified" ? 0 : report.status === "issues-remain" ? 1 : 2;
|
|
1682
|
+
}
|
|
1683
|
+
function formatRepairSummary(report) {
|
|
1684
|
+
return [
|
|
1685
|
+
"Repair verification: " + report.status + ". Baseline: " + report.baselinePath,
|
|
1686
|
+
...report.findings.map((f) => " [" + f.status + "] " + f.original.type + " " + f.original.anchor.doc + ": " + f.original.message + "\n " + f.reason),
|
|
1687
|
+
...report.newFindings.map((f) => " [new] " + f.type + " " + f.anchor.doc + ": " + f.message),
|
|
1688
|
+
...report.diagnostics.map((d) => " [unverified] " + d),
|
|
1689
|
+
...(report.currentAudit?.skippedChecks ?? []).map((s) => " [skipped] " + s.check + ": " + s.reason),
|
|
1690
|
+
report.scope
|
|
1691
|
+
].join("\n");
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1402
1694
|
// src/audit/cli.ts
|
|
1403
1695
|
var USAGE = `Usage: mason-audit [--dir <path>] [--json | --fix-prompt] [--checks <list>]
|
|
1404
1696
|
|
|
@@ -1413,7 +1705,10 @@ Options:
|
|
|
1413
1705
|
--json Print the full audit report as JSON (additive-only schema)
|
|
1414
1706
|
--fix-prompt When issues exist, print a work order for ANY coding agent
|
|
1415
1707
|
(Claude, Codex, Gemini, ...) \u2013 pipe it to your agent CLI to
|
|
1416
|
-
|
|
1708
|
+
repair the findings. Includes advisories that require review.
|
|
1709
|
+
--prepare-repair Save the original audit under .mason/reports/repairs/ before edits
|
|
1710
|
+
--verify-repair <path>
|
|
1711
|
+
Compare against that saved baseline, using its original checks
|
|
1417
1712
|
--checks <list> Comma-separated subset of checks to run (default: all):
|
|
1418
1713
|
${ALL_CHECKS.join(", ")}
|
|
1419
1714
|
--help Show this help
|
|
@@ -1421,14 +1716,19 @@ Options:
|
|
|
1421
1716
|
Exit codes:
|
|
1422
1717
|
0 no issues (advisories may still be present)
|
|
1423
1718
|
1 provable issues found
|
|
1424
|
-
2 error (no context file, not a git repository, bad arguments)
|
|
1719
|
+
2 error (no context file, not a git repository, bad arguments)
|
|
1720
|
+
|
|
1721
|
+
With --verify-repair: 0 verified by the original checks; 1 issues remain;
|
|
1722
|
+
2 incomplete (unverified findings, skipped checks, or advisories needing review).
|
|
1723
|
+
Preparation writes only a baseline; verification and ordinary audits are read-only.`;
|
|
1425
1724
|
function parseArgs(argv) {
|
|
1426
1725
|
const parsed = {
|
|
1427
1726
|
dir: process.cwd(),
|
|
1428
1727
|
json: false,
|
|
1429
1728
|
fixPrompt: false,
|
|
1430
1729
|
help: false,
|
|
1431
|
-
checks: void 0
|
|
1730
|
+
checks: void 0,
|
|
1731
|
+
prepareRepair: false
|
|
1432
1732
|
};
|
|
1433
1733
|
for (let i = 0; i < argv.length; i++) {
|
|
1434
1734
|
const arg = argv[i];
|
|
@@ -1436,6 +1736,12 @@ function parseArgs(argv) {
|
|
|
1436
1736
|
parsed.json = true;
|
|
1437
1737
|
} else if (arg === "--fix-prompt") {
|
|
1438
1738
|
parsed.fixPrompt = true;
|
|
1739
|
+
} else if (arg === "--prepare-repair") {
|
|
1740
|
+
parsed.prepareRepair = true;
|
|
1741
|
+
} else if (arg === "--verify-repair") {
|
|
1742
|
+
const value = argv[++i];
|
|
1743
|
+
if (!value || value.startsWith("--")) throw new Error("--verify-repair requires a baseline path");
|
|
1744
|
+
parsed.baseline = value;
|
|
1439
1745
|
} else if (arg === "--help" || arg === "-h") {
|
|
1440
1746
|
parsed.help = true;
|
|
1441
1747
|
} else if (arg === "--dir") {
|
|
@@ -1446,6 +1752,7 @@ function parseArgs(argv) {
|
|
|
1446
1752
|
const value = argv[++i];
|
|
1447
1753
|
if (!value) throw new Error("--checks requires a comma-separated list");
|
|
1448
1754
|
const names = value.split(",").map((n) => n.trim()).filter(Boolean);
|
|
1755
|
+
if (!names.length) throw new Error("--checks requires at least one check");
|
|
1449
1756
|
for (const name of names) {
|
|
1450
1757
|
if (!ALL_CHECKS.includes(name)) {
|
|
1451
1758
|
throw new Error(
|
|
@@ -1469,6 +1776,7 @@ function issueLine(issue) {
|
|
|
1469
1776
|
}
|
|
1470
1777
|
function formatAuditSummary(report) {
|
|
1471
1778
|
const lines = [];
|
|
1779
|
+
const reviewCount = report.advisories.length + (report.suppressedAdvisories?.length ?? 0);
|
|
1472
1780
|
for (const doc of report.docs) {
|
|
1473
1781
|
const docIssues = report.issues.filter((i) => i.anchor.doc === doc.path);
|
|
1474
1782
|
const committed = doc.lastCommit ? `last committed ${doc.lastCommit.date.slice(0, 10)}, ${doc.lastCommit.hash.slice(0, 7)}` : "untracked";
|
|
@@ -1496,21 +1804,25 @@ function formatAuditSummary(report) {
|
|
|
1496
1804
|
lines.push(` [skipped] ${skip.check}: ${skip.reason}`);
|
|
1497
1805
|
}
|
|
1498
1806
|
}
|
|
1807
|
+
for (const advisory of report.suppressedAdvisories ?? []) {
|
|
1808
|
+
lines.push(` [suppressed; unresolved] ${advisory.type} ${advisory.anchor.doc}: ${advisory.message}`);
|
|
1809
|
+
}
|
|
1499
1810
|
lines.push(
|
|
1500
|
-
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"}.`
|
|
1811
|
+
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"}.`
|
|
1501
1812
|
);
|
|
1502
1813
|
return lines.join("\n");
|
|
1503
1814
|
}
|
|
1504
|
-
function formatFixPrompt(report) {
|
|
1815
|
+
function formatFixPrompt(report, baselinePath) {
|
|
1505
1816
|
const flaggedDocs = [...new Set(report.issues.map((i) => i.anchor.doc))];
|
|
1506
1817
|
const lines = [];
|
|
1507
1818
|
lines.push(
|
|
1508
|
-
"
|
|
1819
|
+
"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."
|
|
1509
1820
|
);
|
|
1510
1821
|
lines.push("");
|
|
1511
1822
|
lines.push("RULES:");
|
|
1823
|
+
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.");
|
|
1512
1824
|
lines.push(
|
|
1513
|
-
`- Edit ONLY these files: ${flaggedDocs.join(", ")
|
|
1825
|
+
`- 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.`
|
|
1514
1826
|
);
|
|
1515
1827
|
lines.push(
|
|
1516
1828
|
"- Keep diffs minimal: change the smallest span that makes each claim true."
|
|
@@ -1518,6 +1830,7 @@ function formatFixPrompt(report) {
|
|
|
1518
1830
|
lines.push(
|
|
1519
1831
|
"- Never invent content. Every replacement must be grounded in the evidence below or in files you read from this repository."
|
|
1520
1832
|
);
|
|
1833
|
+
lines.push("- Inspect likely findings before editing: heuristic evidence may describe an intentional omission or an example.");
|
|
1521
1834
|
lines.push(
|
|
1522
1835
|
"- 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."
|
|
1523
1836
|
);
|
|
@@ -1531,20 +1844,27 @@ function formatFixPrompt(report) {
|
|
|
1531
1844
|
"- 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."
|
|
1532
1845
|
);
|
|
1533
1846
|
lines.push(
|
|
1534
|
-
"-
|
|
1847
|
+
"- 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."
|
|
1535
1848
|
);
|
|
1536
1849
|
lines.push("");
|
|
1537
|
-
lines.push("AUDIT REPORT (
|
|
1850
|
+
lines.push("AUDIT REPORT (current context files and repository evidence, including local edits):");
|
|
1538
1851
|
lines.push(
|
|
1539
1852
|
JSON.stringify(
|
|
1540
|
-
{
|
|
1853
|
+
{
|
|
1854
|
+
root: report.root,
|
|
1855
|
+
checks: report.checksRun,
|
|
1856
|
+
issues: report.issues,
|
|
1857
|
+
advisories: report.advisories,
|
|
1858
|
+
suppressedAdvisories: report.suppressedAdvisories,
|
|
1859
|
+
skippedChecks: report.skippedChecks
|
|
1860
|
+
},
|
|
1541
1861
|
null,
|
|
1542
1862
|
2
|
|
1543
1863
|
)
|
|
1544
1864
|
);
|
|
1545
1865
|
lines.push("");
|
|
1546
1866
|
lines.push(
|
|
1547
|
-
"
|
|
1867
|
+
"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."
|
|
1548
1868
|
);
|
|
1549
1869
|
return lines.join("\n");
|
|
1550
1870
|
}
|
|
@@ -1560,6 +1880,9 @@ async function runAuditCli(argv, io = {
|
|
|
1560
1880
|
if (args.json && args.fixPrompt) {
|
|
1561
1881
|
throw new Error("--json and --fix-prompt are mutually exclusive");
|
|
1562
1882
|
}
|
|
1883
|
+
if (args.baseline && (args.prepareRepair || args.checks || args.fixPrompt)) {
|
|
1884
|
+
throw new Error("--verify-repair cannot be combined with --prepare-repair, --checks, or --fix-prompt; verification uses the original scope");
|
|
1885
|
+
}
|
|
1563
1886
|
} catch (error) {
|
|
1564
1887
|
io.err(error instanceof Error ? error.message : String(error));
|
|
1565
1888
|
io.err(USAGE);
|
|
@@ -1569,7 +1892,23 @@ async function runAuditCli(argv, io = {
|
|
|
1569
1892
|
io.out(USAGE);
|
|
1570
1893
|
return 0;
|
|
1571
1894
|
}
|
|
1572
|
-
const rootDir =
|
|
1895
|
+
const rootDir = path17.resolve(args.dir);
|
|
1896
|
+
if (args.baseline || args.prepareRepair) {
|
|
1897
|
+
try {
|
|
1898
|
+
if (args.baseline) {
|
|
1899
|
+
const verification = await verifyRepair(rootDir, args.baseline);
|
|
1900
|
+
io.out(args.json ? JSON.stringify(verification, null, 2) : formatRepairSummary(verification));
|
|
1901
|
+
return repairExitCode(verification);
|
|
1902
|
+
}
|
|
1903
|
+
const prepared = await prepareRepair(rootDir, args.checks);
|
|
1904
|
+
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}
|
|
1905
|
+
${formatAuditSummary(prepared.report)}`);
|
|
1906
|
+
return prepared.report.clean ? 0 : 1;
|
|
1907
|
+
} catch (error) {
|
|
1908
|
+
io.err(error instanceof Error ? error.message : String(error));
|
|
1909
|
+
return 2;
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1573
1912
|
const report = await computeAudit(rootDir, { checks: args.checks });
|
|
1574
1913
|
if (!report) {
|
|
1575
1914
|
io.err(
|
|
@@ -1585,7 +1924,7 @@ async function runAuditCli(argv, io = {
|
|
|
1585
1924
|
}
|
|
1586
1925
|
if (args.fixPrompt) {
|
|
1587
1926
|
io.out(
|
|
1588
|
-
report.clean ? formatAuditSummary(report) : formatFixPrompt(report)
|
|
1927
|
+
report.clean && !report.advisories.length && !report.suppressedAdvisories?.length ? formatAuditSummary(report) : formatFixPrompt(report)
|
|
1589
1928
|
);
|
|
1590
1929
|
return report.clean ? 0 : 1;
|
|
1591
1930
|
}
|