mason-context 0.9.0 → 0.10.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 +25 -0
- package/README.md +167 -37
- package/dist/mason-audit.js +368 -149
- package/dist/mason-audit.js.map +1 -1
- package/dist/mason-drift.js +479 -209
- package/dist/mason-drift.js.map +1 -1
- package/dist/mason-hook.js +337 -123
- package/dist/mason-hook.js.map +1 -1
- package/dist/mason-mcp.js +3459 -1451
- package/dist/mason-mcp.js.map +1 -1
- package/dist/mason-review.js +988 -97
- package/dist/mason-review.js.map +1 -1
- package/package.json +7 -2
package/dist/mason-hook.js
CHANGED
|
@@ -2,35 +2,141 @@
|
|
|
2
2
|
|
|
3
3
|
// src/hook/hook.ts
|
|
4
4
|
import fs5 from "fs/promises";
|
|
5
|
-
import
|
|
5
|
+
import path10 from "path";
|
|
6
6
|
import os from "os";
|
|
7
7
|
|
|
8
8
|
// src/decisions/decisions.ts
|
|
9
9
|
import fs3 from "fs/promises";
|
|
10
|
-
import
|
|
10
|
+
import path7 from "path";
|
|
11
11
|
import { createHash } from "crypto";
|
|
12
12
|
|
|
13
|
-
// src/
|
|
13
|
+
// src/utils/storage.ts
|
|
14
14
|
import fs2 from "fs/promises";
|
|
15
15
|
import path3 from "path";
|
|
16
|
-
import {
|
|
17
|
-
import { promisify as promisify2 } from "util";
|
|
18
|
-
import fg3 from "fast-glob";
|
|
16
|
+
import { randomUUID } from "crypto";
|
|
19
17
|
|
|
20
|
-
// src/
|
|
21
|
-
import fs from "fs/promises";
|
|
18
|
+
// src/utils/paths.ts
|
|
22
19
|
import path from "path";
|
|
20
|
+
function normalizeRepoPath(value) {
|
|
21
|
+
const slash = value.replace(/\\/g, "/");
|
|
22
|
+
if (!slash || slash.includes("\0") || path.posix.isAbsolute(slash) || /^[A-Za-z]:/.test(slash)) return null;
|
|
23
|
+
if (slash.split("/").includes("..")) return null;
|
|
24
|
+
const normalized = path.posix.normalize(slash).replace(/\/$/, "");
|
|
25
|
+
return normalized === "." ? null : normalized;
|
|
26
|
+
}
|
|
27
|
+
function anchorMatches(anchor, file) {
|
|
28
|
+
const a = normalizeRepoPath(anchor);
|
|
29
|
+
const f = normalizeRepoPath(file);
|
|
30
|
+
return a !== null && f !== null && (a === f || f.startsWith(`${a}/`));
|
|
31
|
+
}
|
|
32
|
+
function matchingPaths(anchors, files) {
|
|
33
|
+
return [...new Set(files)].filter((file) => anchors.some((anchor) => anchorMatches(anchor, file)));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/utils/files.ts
|
|
37
|
+
import fs from "fs/promises";
|
|
38
|
+
import { constants } from "fs";
|
|
39
|
+
import path2 from "path";
|
|
23
40
|
import { execFile } from "child_process";
|
|
24
41
|
import { promisify } from "util";
|
|
25
42
|
import fg from "fast-glob";
|
|
26
43
|
var exec = promisify(execFile);
|
|
44
|
+
var SOURCE_EXTENSIONS = ["ts", "tsx", "js", "jsx", "mts", "cts", "mjs", "cjs", "vue", "svelte", "kt", "kts", "java", "py", "go", "rs", "swift", "rb", "cs", "cpp", "c", "h", "hpp", "dart", "php"];
|
|
45
|
+
var SOURCE_GLOB = `**/*.{${SOURCE_EXTENSIONS.join(",")}}`;
|
|
46
|
+
var MAX_SOURCE_BYTES = 1024 * 1024;
|
|
47
|
+
async function readBoundedFile(file, maxBytes) {
|
|
48
|
+
const handle = await fs.open(file, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
|
|
49
|
+
try {
|
|
50
|
+
const stat = await handle.stat();
|
|
51
|
+
if (!stat.isFile() || stat.size > maxBytes) return null;
|
|
52
|
+
const buffer = Buffer.alloc(Math.min(maxBytes + 1, stat.size + 1));
|
|
53
|
+
let bytes = 0;
|
|
54
|
+
while (bytes < buffer.length) {
|
|
55
|
+
const result = await handle.read(buffer, bytes, buffer.length - bytes, null);
|
|
56
|
+
if (result.bytesRead === 0) break;
|
|
57
|
+
bytes += result.bytesRead;
|
|
58
|
+
}
|
|
59
|
+
return bytes === buffer.length ? null : buffer.subarray(0, bytes).toString("utf8");
|
|
60
|
+
} finally {
|
|
61
|
+
await handle.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/utils/storage.ts
|
|
66
|
+
async function storePath(root, relative, createParents = false) {
|
|
67
|
+
const normalized = normalizeRepoPath(relative);
|
|
68
|
+
if (!normalized) throw new Error(`Invalid store path: ${relative}`);
|
|
69
|
+
let current = await fs2.realpath(root);
|
|
70
|
+
const parts = normalized.split("/");
|
|
71
|
+
for (let i = 0; i < parts.length; i++) {
|
|
72
|
+
current = path3.join(current, parts[i]);
|
|
73
|
+
let stat;
|
|
74
|
+
try {
|
|
75
|
+
stat = await fs2.lstat(current);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error.code !== "ENOENT") throw error;
|
|
78
|
+
if (createParents && i < parts.length - 1) {
|
|
79
|
+
try {
|
|
80
|
+
await fs2.mkdir(current);
|
|
81
|
+
} catch (mkdirError) {
|
|
82
|
+
if (mkdirError.code !== "EEXIST") throw mkdirError;
|
|
83
|
+
}
|
|
84
|
+
stat = await fs2.lstat(current);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (stat?.isSymbolicLink()) throw new Error(`Symlink in store path: ${relative}`);
|
|
88
|
+
}
|
|
89
|
+
return current;
|
|
90
|
+
}
|
|
91
|
+
async function readStoreJson(root, relative) {
|
|
92
|
+
try {
|
|
93
|
+
const file = await storePath(root, relative);
|
|
94
|
+
const raw = await readBoundedFile(file, 10 * 1024 * 1024);
|
|
95
|
+
if (raw === null) throw new Error("file is not regular or exceeds 10 MiB");
|
|
96
|
+
const parsed = JSON.parse(raw);
|
|
97
|
+
if (parsed === null) throw new Error("expected a JSON object, received null");
|
|
98
|
+
return parsed;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error.code === "ENOENT") return null;
|
|
101
|
+
throw new Error(`Invalid Mason store ${relative}: ${error instanceof Error ? error.message : String(error)}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/snapshot/snapshot.ts
|
|
106
|
+
import path5 from "path";
|
|
107
|
+
import { execFile as execFile2 } from "child_process";
|
|
108
|
+
import { promisify as promisify2 } from "util";
|
|
109
|
+
import { z } from "zod";
|
|
27
110
|
|
|
28
111
|
// src/test-map.ts
|
|
29
|
-
import
|
|
30
|
-
import fg2 from "fast-glob";
|
|
112
|
+
import path4 from "path";
|
|
31
113
|
|
|
32
114
|
// src/snapshot/snapshot.ts
|
|
33
115
|
var exec2 = promisify2(execFile2);
|
|
116
|
+
var repoPath = z.string().refine((value) => normalizeRepoPath(value) !== null, "Expected a relative repository path");
|
|
117
|
+
var verificationFields = {
|
|
118
|
+
refreshedHash: z.string().optional(),
|
|
119
|
+
verifiedAt: z.string().optional(),
|
|
120
|
+
verifiedHash: z.string().optional(),
|
|
121
|
+
verificationFailed: z.boolean().optional(),
|
|
122
|
+
verificationNote: z.string().optional()
|
|
123
|
+
};
|
|
124
|
+
var featureSchema = z.object({
|
|
125
|
+
description: z.string(),
|
|
126
|
+
files: z.array(repoPath),
|
|
127
|
+
tests: z.array(repoPath).optional(),
|
|
128
|
+
type: z.enum(["capability", "infrastructure"]).optional(),
|
|
129
|
+
...verificationFields
|
|
130
|
+
}).passthrough();
|
|
131
|
+
var flowSchema = z.object({ description: z.string(), chain: z.array(repoPath), ...verificationFields }).passthrough();
|
|
132
|
+
var snapshotSchema = z.object({
|
|
133
|
+
version: z.literal(2),
|
|
134
|
+
createdAt: z.string(),
|
|
135
|
+
updatedAt: z.string(),
|
|
136
|
+
gitHash: z.string(),
|
|
137
|
+
features: z.record(featureSchema),
|
|
138
|
+
flows: z.record(flowSchema)
|
|
139
|
+
}).passthrough();
|
|
34
140
|
async function getCurrentGitHash(rootDir) {
|
|
35
141
|
try {
|
|
36
142
|
const { stdout } = await exec2("git", ["rev-parse", "HEAD"], {
|
|
@@ -43,123 +149,228 @@ async function getCurrentGitHash(rootDir) {
|
|
|
43
149
|
}
|
|
44
150
|
|
|
45
151
|
// src/context/lexical.ts
|
|
46
|
-
import
|
|
152
|
+
import path6 from "path";
|
|
47
153
|
|
|
48
|
-
// src/decisions/
|
|
49
|
-
|
|
50
|
-
|
|
154
|
+
// src/decisions/provenance.ts
|
|
155
|
+
import { z as z2 } from "zod";
|
|
156
|
+
var text = (max) => z2.string().trim().min(1).max(max);
|
|
157
|
+
var decisionSourceSchema = z2.object({
|
|
158
|
+
kind: z2.enum(["pull_request", "issue", "incident", "discussion", "document", "other"]),
|
|
159
|
+
reference: text(1e3),
|
|
160
|
+
note: text(500).optional()
|
|
161
|
+
}).strict();
|
|
162
|
+
var attributionSchema = z2.object({
|
|
163
|
+
owner: text(200).nullable().optional(),
|
|
164
|
+
sources: z2.array(decisionSourceSchema).max(20).optional(),
|
|
165
|
+
actor: text(200).optional()
|
|
166
|
+
});
|
|
167
|
+
var contentSchema = z2.object({
|
|
168
|
+
title: z2.string().min(1),
|
|
169
|
+
body: z2.string().min(1),
|
|
170
|
+
category: z2.enum(["decision", "gotcha", "deprecation", "convention"]),
|
|
171
|
+
files: z2.array(z2.string().refine((f) => normalizeRepoPath(f) !== null)),
|
|
172
|
+
owner: text(200).optional(),
|
|
173
|
+
sources: z2.array(decisionSourceSchema).max(20)
|
|
174
|
+
});
|
|
175
|
+
var approvalSchema = z2.enum(["unreviewed", "proposed", "accepted"]);
|
|
176
|
+
var statusSchema = z2.enum(["active", "superseded", "retired"]);
|
|
177
|
+
var reviewEvidenceSchema = z2.object({
|
|
178
|
+
baseHash: z2.string(),
|
|
179
|
+
headHash: z2.string(),
|
|
180
|
+
historyAvailable: z2.boolean(),
|
|
181
|
+
changedFiles: z2.array(z2.string()),
|
|
182
|
+
localChanges: z2.array(z2.string())
|
|
183
|
+
});
|
|
184
|
+
var eventSchema = z2.object({
|
|
185
|
+
kind: z2.enum(["imported", "created", "revised", "accepted", "reaffirmed", "retired", "superseded"]),
|
|
186
|
+
at: z2.string().datetime(),
|
|
187
|
+
actor: text(200).optional(),
|
|
188
|
+
note: text(1500).optional(),
|
|
189
|
+
revision: z2.number().int().positive(),
|
|
190
|
+
content: contentSchema,
|
|
191
|
+
approval: approvalSchema,
|
|
192
|
+
status: statusSchema,
|
|
193
|
+
refreshedHash: z2.string(),
|
|
194
|
+
evidence: reviewEvidenceSchema.optional()
|
|
195
|
+
});
|
|
196
|
+
var legacySchema = z2.object({
|
|
197
|
+
version: z2.literal(1),
|
|
198
|
+
id: z2.string().regex(/^[a-zA-Z0-9_-]+$/),
|
|
199
|
+
title: z2.string().min(1),
|
|
200
|
+
body: z2.string().min(1),
|
|
201
|
+
category: contentSchema.shape.category,
|
|
202
|
+
files: contentSchema.shape.files,
|
|
203
|
+
createdAt: z2.string(),
|
|
204
|
+
updatedAt: z2.string(),
|
|
205
|
+
refreshedHash: z2.string(),
|
|
206
|
+
status: z2.enum(["active", "superseded"]),
|
|
207
|
+
supersededBy: z2.string().optional()
|
|
208
|
+
}).passthrough();
|
|
209
|
+
var currentSchema = legacySchema.extend({
|
|
210
|
+
version: z2.literal(2),
|
|
211
|
+
status: statusSchema,
|
|
212
|
+
approval: approvalSchema,
|
|
213
|
+
revision: z2.number().int().positive(),
|
|
214
|
+
owner: text(200).optional(),
|
|
215
|
+
sources: z2.array(decisionSourceSchema).max(20),
|
|
216
|
+
history: z2.array(eventSchema).min(1)
|
|
217
|
+
}).superRefine((record, ctx) => {
|
|
218
|
+
const invalid = (message) => ctx.addIssue({ code: "custom", message });
|
|
219
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
220
|
+
let previous;
|
|
221
|
+
for (const event of record.history) {
|
|
222
|
+
if (!previous) {
|
|
223
|
+
if (!["created", "imported"].includes(event.kind) || event.revision !== 1) invalid("History must begin with creation or legacy import at revision 1");
|
|
224
|
+
if (event.approval !== (event.kind === "created" ? "proposed" : "unreviewed")) invalid("Initial records cannot claim acceptance");
|
|
225
|
+
} else {
|
|
226
|
+
if (["created", "imported"].includes(event.kind)) invalid("History cannot restart");
|
|
227
|
+
if (previous.status !== "active") invalid("Archived decisions cannot be changed");
|
|
228
|
+
if (event.revision !== previous.revision + (event.kind === "revised" ? 1 : 0)) invalid("Invalid revision sequence");
|
|
229
|
+
if (event.kind !== "revised" && !same(event.content, previous.content)) invalid("A review cannot silently revise decision content");
|
|
230
|
+
if (event.kind === "reaffirmed" && previous.approval !== "accepted") invalid("Only accepted decisions can be reaffirmed");
|
|
231
|
+
if (event.kind === "accepted" && previous.approval === "accepted") invalid("Use reaffirmation for an accepted decision");
|
|
232
|
+
const approval = event.kind === "revised" ? "proposed" : ["accepted", "reaffirmed"].includes(event.kind) ? "accepted" : previous.approval;
|
|
233
|
+
if (event.approval !== approval) invalid("Approval disagrees with review history");
|
|
234
|
+
if (!["accepted", "reaffirmed"].includes(event.kind) && event.refreshedHash !== previous.refreshedHash) invalid("Only a review can refresh the evidence baseline");
|
|
235
|
+
}
|
|
236
|
+
if (event.kind !== "imported" && event.status !== (event.kind === "retired" ? "retired" : event.kind === "superseded" ? "superseded" : "active")) invalid("Lifecycle disagrees with history");
|
|
237
|
+
if (["accepted", "reaffirmed", "retired"].includes(event.kind) && (!event.actor || !event.note || !event.evidence)) invalid("Reviews require a named reviewer, reason, and code evidence");
|
|
238
|
+
if (["accepted", "reaffirmed"].includes(event.kind)) {
|
|
239
|
+
if (!event.content.owner || !event.content.sources.length) invalid("Accepted decisions require an owner and source");
|
|
240
|
+
if (!event.evidence || !/^[a-f0-9]{40,64}$/.test(event.evidence.headHash) || event.refreshedHash !== event.evidence.headHash || event.evidence.localChanges.length) invalid("Acceptance requires a committed evidence baseline");
|
|
241
|
+
}
|
|
242
|
+
previous = event;
|
|
243
|
+
}
|
|
244
|
+
if (!previous || !same(previous.content, decisionContent(record)) || previous.approval !== record.approval || previous.status !== record.status || previous.revision !== record.revision || previous.refreshedHash !== record.refreshedHash) invalid("Decision does not match the final history event");
|
|
245
|
+
});
|
|
246
|
+
var decisionSchema = z2.union([legacySchema, currentSchema]);
|
|
247
|
+
function decisionContent(record) {
|
|
248
|
+
return {
|
|
249
|
+
title: record.title,
|
|
250
|
+
body: record.body,
|
|
251
|
+
category: record.category,
|
|
252
|
+
files: record.files,
|
|
253
|
+
...typeof record.owner === "string" ? { owner: record.owner } : {},
|
|
254
|
+
sources: Array.isArray(record.sources) ? record.sources : []
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function decisionApproval(record) {
|
|
258
|
+
return record.version === 1 ? "unreviewed" : record.approval;
|
|
259
|
+
}
|
|
260
|
+
function decisionProvenance(record, freshness = "unknown") {
|
|
261
|
+
const approval = decisionApproval(record);
|
|
262
|
+
const review = record.version === 2 ? [...record.history].reverse().find((e) => ["accepted", "reaffirmed"].includes(e.kind) && e.revision === record.revision) : void 0;
|
|
263
|
+
return {
|
|
264
|
+
approval,
|
|
265
|
+
revision: record.version === 2 ? record.revision : 0,
|
|
266
|
+
owner: record.version === 2 ? record.owner ?? null : null,
|
|
267
|
+
sources: record.version === 2 ? record.sources : [],
|
|
268
|
+
guidance: record.status !== "active" ? "historical" : approval === "accepted" ? "constraint" : approval === "proposed" ? "proposal" : "unreviewed",
|
|
269
|
+
reviewRequired: record.status === "active" && (approval !== "accepted" || freshness !== "current"),
|
|
270
|
+
lastReview: review ? { reviewer: review.actor, at: review.at, note: review.note, gitHash: review.refreshedHash } : null
|
|
271
|
+
};
|
|
51
272
|
}
|
|
52
|
-
|
|
273
|
+
|
|
274
|
+
// src/decisions/decisions.ts
|
|
275
|
+
async function loadDecisionStore(rootDir) {
|
|
276
|
+
const records = [];
|
|
277
|
+
const diagnostics = [];
|
|
53
278
|
let entries;
|
|
54
279
|
try {
|
|
55
|
-
entries = await fs3.readdir(
|
|
56
|
-
} catch {
|
|
57
|
-
|
|
280
|
+
entries = await fs3.readdir(await storePath(rootDir, ".mason/decisions"));
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (error.code !== "ENOENT") diagnostics.push({ path: ".mason/decisions", message: String(error) });
|
|
283
|
+
return { records, diagnostics };
|
|
58
284
|
}
|
|
59
|
-
const
|
|
60
|
-
for (const entry of entries) {
|
|
285
|
+
for (const entry of entries.sort()) {
|
|
61
286
|
if (!entry.endsWith(".json")) continue;
|
|
287
|
+
const relative = `.mason/decisions/${entry}`;
|
|
62
288
|
try {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (parsed.version !== 1 || !parsed.id || !parsed.title || !parsed.body) {
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
records.push(parsed);
|
|
72
|
-
} catch {
|
|
73
|
-
continue;
|
|
289
|
+
const record = decisionSchema.parse(await readStoreJson(rootDir, relative));
|
|
290
|
+
if (entry !== `${record.id}.json`) throw new Error("Record id does not match its filename");
|
|
291
|
+
records.push(record);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
diagnostics.push({ path: relative, message: error instanceof Error ? error.message : String(error) });
|
|
74
294
|
}
|
|
75
295
|
}
|
|
76
|
-
return records
|
|
296
|
+
return { records, diagnostics };
|
|
77
297
|
}
|
|
78
298
|
|
|
299
|
+
// src/hook/hook.ts
|
|
300
|
+
import { createHash as createHash2 } from "crypto";
|
|
301
|
+
|
|
79
302
|
// src/decisions/drift.ts
|
|
80
|
-
import
|
|
303
|
+
import path9 from "path";
|
|
81
304
|
|
|
82
305
|
// src/drift/drift.ts
|
|
83
306
|
import fs4 from "fs/promises";
|
|
84
|
-
import
|
|
307
|
+
import path8 from "path";
|
|
85
308
|
import { execFile as execFile3 } from "child_process";
|
|
86
309
|
import { promisify as promisify3 } from "util";
|
|
87
310
|
var exec3 = promisify3(execFile3);
|
|
88
|
-
|
|
89
|
-
|
|
311
|
+
function parseChanges(output) {
|
|
312
|
+
const fields = output.split("\0");
|
|
313
|
+
const changes = [];
|
|
314
|
+
for (let i = 0; i < fields.length && fields[i]; ) {
|
|
315
|
+
const code = fields[i++];
|
|
316
|
+
const first = fields[i++];
|
|
317
|
+
if (!first) break;
|
|
318
|
+
const second = /^[RC]/.test(code) ? fields[i++] : void 0;
|
|
319
|
+
const change = second ? code.startsWith("R") ? { status: "renamed", path: second, previousPath: first } : { status: "added", path: second } : { status: code === "A" ? "added" : code === "D" ? "deleted" : "modified", path: first };
|
|
320
|
+
if (change.path.startsWith(".mason/") && (!change.previousPath || change.previousPath.startsWith(".mason/"))) continue;
|
|
321
|
+
changes.push(change);
|
|
322
|
+
}
|
|
323
|
+
return changes;
|
|
324
|
+
}
|
|
325
|
+
function touchedPaths(changes) {
|
|
326
|
+
return [...new Set(changes.flatMap((c) => c.previousPath ? [c.previousPath, c.path] : [c.path]))].sort();
|
|
327
|
+
}
|
|
328
|
+
async function getChangesWithStatus(resolvedRoot, fromHash, toHash = "HEAD") {
|
|
329
|
+
if (!fromHash || fromHash === "unknown" || fromHash.startsWith("-") || !toHash || toHash === "unknown" || toHash.startsWith("-")) return null;
|
|
90
330
|
try {
|
|
91
|
-
const { stdout } = await exec3(
|
|
92
|
-
|
|
93
|
-
["diff", "--name-status", "-M", fromHash, "HEAD"],
|
|
94
|
-
{ cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }
|
|
95
|
-
);
|
|
96
|
-
const changes = [];
|
|
97
|
-
for (const line of stdout.split("\n")) {
|
|
98
|
-
if (!line.trim()) continue;
|
|
99
|
-
const parts = line.split(" ");
|
|
100
|
-
if (parts.some((p) => p.startsWith(".mason/"))) continue;
|
|
101
|
-
const code = parts[0];
|
|
102
|
-
if (code.startsWith("R") && parts.length >= 3) {
|
|
103
|
-
changes.push({
|
|
104
|
-
status: "renamed",
|
|
105
|
-
path: parts[2],
|
|
106
|
-
previousPath: parts[1]
|
|
107
|
-
});
|
|
108
|
-
} else if (code.startsWith("C") && parts.length >= 3) {
|
|
109
|
-
changes.push({ status: "added", path: parts[2] });
|
|
110
|
-
} else if (code === "A" && parts.length >= 2) {
|
|
111
|
-
changes.push({ status: "added", path: parts[1] });
|
|
112
|
-
} else if (code === "D" && parts.length >= 2) {
|
|
113
|
-
changes.push({ status: "deleted", path: parts[1] });
|
|
114
|
-
} else if (parts.length >= 2) {
|
|
115
|
-
changes.push({ status: "modified", path: parts[1] });
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return changes;
|
|
331
|
+
const { stdout } = await exec3("git", ["diff", "--name-status", "-z", "-M", fromHash, toHash, "--"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 });
|
|
332
|
+
return parseChanges(stdout);
|
|
119
333
|
} catch {
|
|
120
334
|
return null;
|
|
121
335
|
}
|
|
122
336
|
}
|
|
337
|
+
async function getWorkingTree(resolvedRoot) {
|
|
338
|
+
try {
|
|
339
|
+
const [diff, untracked] = await Promise.all([
|
|
340
|
+
exec3("git", ["diff", "--name-status", "-z", "-M", "HEAD", "--"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }),
|
|
341
|
+
exec3("git", ["ls-files", "-z", "--others", "--exclude-standard"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 })
|
|
342
|
+
]);
|
|
343
|
+
const untrackedFiles = untracked.stdout.split("\0").filter((f) => f && !f.startsWith(".mason/"));
|
|
344
|
+
return { available: true, changedFiles: [.../* @__PURE__ */ new Set([...touchedPaths(parseChanges(diff.stdout)), ...untrackedFiles])].sort(), untrackedFiles };
|
|
345
|
+
} catch {
|
|
346
|
+
return { available: false, changedFiles: [], untrackedFiles: [] };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
123
349
|
|
|
124
350
|
// src/decisions/drift.ts
|
|
125
351
|
async function computeDecisionDrift(rootDir, decisions) {
|
|
126
|
-
const resolvedRoot =
|
|
127
|
-
const
|
|
128
|
-
const report = {
|
|
129
|
-
|
|
130
|
-
totalDecisions: records.length,
|
|
131
|
-
staleDecisions: {}
|
|
132
|
-
};
|
|
133
|
-
const head = await getCurrentGitHash(resolvedRoot);
|
|
352
|
+
const resolvedRoot = path9.resolve(rootDir);
|
|
353
|
+
const store = decisions ? { records: decisions, diagnostics: [] } : await loadDecisionStore(resolvedRoot);
|
|
354
|
+
const report = { historyAvailable: true, totalDecisions: store.records.length, staleDecisions: {}, freshness: {}, diagnostics: store.diagnostics };
|
|
355
|
+
const [head, workingTree] = await Promise.all([getCurrentGitHash(resolvedRoot), getWorkingTree(resolvedRoot)]);
|
|
134
356
|
const changesByHash = /* @__PURE__ */ new Map();
|
|
135
|
-
for (const record of records) {
|
|
136
|
-
if (record.status !== "active"
|
|
137
|
-
if (record.
|
|
357
|
+
for (const record of store.records) {
|
|
358
|
+
if (record.status !== "active") continue;
|
|
359
|
+
if (record.files.length === 0) {
|
|
360
|
+
report.freshness[record.id] = "unknown";
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
138
363
|
let touched = changesByHash.get(record.refreshedHash);
|
|
139
364
|
if (touched === void 0) {
|
|
140
|
-
const changes = await getChangesWithStatus(
|
|
141
|
-
|
|
142
|
-
record.refreshedHash
|
|
143
|
-
);
|
|
144
|
-
if (changes === null) {
|
|
145
|
-
touched = null;
|
|
146
|
-
} else {
|
|
147
|
-
touched = /* @__PURE__ */ new Set();
|
|
148
|
-
for (const change of changes) {
|
|
149
|
-
touched.add(change.path);
|
|
150
|
-
if (change.previousPath) touched.add(change.previousPath);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
365
|
+
const changes = record.refreshedHash === head && head !== "unknown" ? [] : await getChangesWithStatus(resolvedRoot, record.refreshedHash);
|
|
366
|
+
touched = changes === null ? null : touchedPaths(changes);
|
|
153
367
|
changesByHash.set(record.refreshedHash, touched);
|
|
154
368
|
}
|
|
155
|
-
if (touched === null)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (hits.length > 0) {
|
|
161
|
-
report.staleDecisions[record.id] = hits;
|
|
162
|
-
}
|
|
369
|
+
if (touched === null) report.historyAvailable = false;
|
|
370
|
+
const hits = touched ? matchingPaths(record.files, touched) : [];
|
|
371
|
+
if (hits.length) report.staleDecisions[record.id] = hits;
|
|
372
|
+
const localHits = matchingPaths(record.files, workingTree.changedFiles);
|
|
373
|
+
report.freshness[record.id] = touched === null || !workingTree.available ? "unknown" : hits.length || localHits.length ? "changed" : "current";
|
|
163
374
|
}
|
|
164
375
|
return report;
|
|
165
376
|
}
|
|
@@ -179,19 +390,16 @@ async function exists(p) {
|
|
|
179
390
|
async function findMasonRoot(startDir) {
|
|
180
391
|
let dir = startDir;
|
|
181
392
|
for (let i = 0; i < MAX_WALK_UP; i++) {
|
|
182
|
-
if (await exists(
|
|
183
|
-
if (await exists(
|
|
184
|
-
const parent =
|
|
393
|
+
if (await exists(path10.join(dir, ".mason", "decisions"))) return dir;
|
|
394
|
+
if (await exists(path10.join(dir, ".git"))) return null;
|
|
395
|
+
const parent = path10.dirname(dir);
|
|
185
396
|
if (parent === dir) return null;
|
|
186
397
|
dir = parent;
|
|
187
398
|
}
|
|
188
399
|
return null;
|
|
189
400
|
}
|
|
190
401
|
function anchorsCover(record, relPath) {
|
|
191
|
-
return record.files.some((anchor) =>
|
|
192
|
-
const a = anchor.replace(/\/+$/, "");
|
|
193
|
-
return a === relPath || relPath.startsWith(`${a}/`);
|
|
194
|
-
});
|
|
402
|
+
return record.files.some((anchor) => anchorMatches(anchor, relPath));
|
|
195
403
|
}
|
|
196
404
|
function exactAnchor(record, relPath) {
|
|
197
405
|
return record.files.some((a) => a.replace(/\/+$/, "") === relPath);
|
|
@@ -208,15 +416,17 @@ async function loadInjected(stateFile) {
|
|
|
208
416
|
return /* @__PURE__ */ new Set();
|
|
209
417
|
}
|
|
210
418
|
}
|
|
211
|
-
function formatContext(relPath, records,
|
|
419
|
+
function formatContext(relPath, records, freshness) {
|
|
212
420
|
const lines = [];
|
|
213
421
|
lines.push(
|
|
214
|
-
`Mason: recorded
|
|
422
|
+
`Mason: recorded knowledge anchored to ${relPath}. Accepted records are constraints subject to freshness; proposals are suggestions and legacy unreviewed records need confirmation. Retired or superseded records are no longer active. Do not modify decision records in .mason/decisions/.`
|
|
215
423
|
);
|
|
216
424
|
for (const record of records) {
|
|
217
|
-
const
|
|
425
|
+
const provenance = decisionProvenance(record, freshness[record.id] ?? "unknown");
|
|
426
|
+
const label = record.status === "active" ? provenance.approval : record.status;
|
|
427
|
+
const stale = freshness[record.id] === "current" ? "" : freshness[record.id] === "changed" ? " [recorded against changed files \u2013 verify against current code before relying on it]" : " [freshness unknown \u2013 verify against current code before relying on it]";
|
|
218
428
|
lines.push(
|
|
219
|
-
`- [${record.category}] ${record.title}: ${record.body} (anchors: ${record.files.join(", ")})${stale}`
|
|
429
|
+
`- [${label}] [${record.category}] ${record.title}: ${record.body} (anchors: ${record.files.join(", ")}; owner: ${provenance.owner ?? "unknown"}; sources: ${provenance.sources.slice(0, 2).map((s) => s.reference).join(", ") || "unrecorded"})${stale}`
|
|
220
430
|
);
|
|
221
431
|
}
|
|
222
432
|
return lines.join("\n");
|
|
@@ -228,33 +438,35 @@ async function runHook(stdinText, env = {}) {
|
|
|
228
438
|
} catch {
|
|
229
439
|
return null;
|
|
230
440
|
}
|
|
441
|
+
if (!input || typeof input !== "object") return null;
|
|
231
442
|
if (input.tool_name && !SUPPORTED_TOOLS.has(input.tool_name)) return null;
|
|
232
443
|
const filePath = input.tool_input?.file_path;
|
|
233
444
|
if (!filePath || typeof filePath !== "string") return null;
|
|
234
|
-
const absPath =
|
|
235
|
-
const root = await findMasonRoot(
|
|
445
|
+
const absPath = path10.isAbsolute(filePath) ? filePath : path10.resolve(input.cwd ?? process.cwd(), filePath);
|
|
446
|
+
const root = await findMasonRoot(path10.dirname(absPath));
|
|
236
447
|
if (!root) return null;
|
|
237
|
-
const relPath =
|
|
448
|
+
const relPath = path10.relative(root, absPath).split(path10.sep).join("/");
|
|
238
449
|
if (relPath.startsWith("..")) return null;
|
|
239
|
-
const records = await
|
|
240
|
-
const matched = records.filter(
|
|
241
|
-
(r) => r.status === "active" && anchorsCover(r, relPath)
|
|
242
|
-
);
|
|
450
|
+
const { records } = await loadDecisionStore(root);
|
|
451
|
+
const matched = records.filter((r) => anchorsCover(r, relPath));
|
|
243
452
|
if (matched.length === 0) return null;
|
|
244
453
|
const stateDir = env.stateDir ?? os.tmpdir();
|
|
245
|
-
const stateFile =
|
|
454
|
+
const stateFile = path10.join(stateDir, `mason-hook-${createHash2("sha256").update(root).digest("hex").slice(0, 12)}-${stateKey(input)}.json`);
|
|
246
455
|
const injected = await loadInjected(stateFile);
|
|
247
|
-
const
|
|
456
|
+
const recordKey = (record) => `${record.id}:${createHash2("sha256").update(JSON.stringify(record)).digest("hex")}`;
|
|
457
|
+
const fresh = matched.filter((r) => !injected.has(recordKey(r)) && (r.status === "active" || [...injected].some((key) => key === r.id || key.startsWith(`${r.id}:`))));
|
|
248
458
|
if (fresh.length === 0) return null;
|
|
249
459
|
fresh.sort((a, b) => {
|
|
460
|
+
const withdrawn = Number(b.status !== "active") - Number(a.status !== "active");
|
|
461
|
+
if (withdrawn) return withdrawn;
|
|
250
462
|
const exactDiff = Number(exactAnchor(b, relPath)) - Number(exactAnchor(a, relPath));
|
|
251
463
|
if (exactDiff !== 0) return exactDiff;
|
|
252
464
|
return b.updatedAt.localeCompare(a.updatedAt);
|
|
253
465
|
});
|
|
254
466
|
const selected = fresh.slice(0, MAX_INJECTED_DECISIONS);
|
|
255
467
|
const drift = await computeDecisionDrift(root, selected);
|
|
256
|
-
const
|
|
257
|
-
for (const record of selected) injected.add(record
|
|
468
|
+
const freshness = drift.freshness ?? {};
|
|
469
|
+
for (const record of selected) injected.add(recordKey(record));
|
|
258
470
|
try {
|
|
259
471
|
await fs5.writeFile(stateFile, JSON.stringify([...injected]), "utf-8");
|
|
260
472
|
} catch {
|
|
@@ -262,7 +474,7 @@ async function runHook(stdinText, env = {}) {
|
|
|
262
474
|
return JSON.stringify({
|
|
263
475
|
hookSpecificOutput: {
|
|
264
476
|
hookEventName: "PostToolUse",
|
|
265
|
-
additionalContext: formatContext(relPath, selected,
|
|
477
|
+
additionalContext: formatContext(relPath, selected, freshness)
|
|
266
478
|
}
|
|
267
479
|
});
|
|
268
480
|
}
|
|
@@ -297,17 +509,17 @@ var SETTINGS_CONFIG = {
|
|
|
297
509
|
]
|
|
298
510
|
}
|
|
299
511
|
};
|
|
300
|
-
async function runHookCli(
|
|
512
|
+
async function runHookCli(argv2, stdinText, io = {
|
|
301
513
|
out: (line) => process.stdout.write(`${line}
|
|
302
514
|
`),
|
|
303
515
|
err: (line) => process.stderr.write(`${line}
|
|
304
516
|
`)
|
|
305
517
|
}, env = {}) {
|
|
306
|
-
if (
|
|
518
|
+
if (argv2.includes("--help") || argv2.includes("-h")) {
|
|
307
519
|
io.out(USAGE);
|
|
308
520
|
return 0;
|
|
309
521
|
}
|
|
310
|
-
if (
|
|
522
|
+
if (argv2.includes("--print-config")) {
|
|
311
523
|
io.out(JSON.stringify(SETTINGS_CONFIG, null, 2));
|
|
312
524
|
return 0;
|
|
313
525
|
}
|
|
@@ -328,5 +540,7 @@ async function readStdin() {
|
|
|
328
540
|
}
|
|
329
541
|
return Buffer.concat(chunks).toString("utf-8");
|
|
330
542
|
}
|
|
331
|
-
|
|
543
|
+
var argv = process.argv.slice(2);
|
|
544
|
+
var informational = argv.some((arg) => ["--help", "-h", "--print-config"].includes(arg));
|
|
545
|
+
(informational ? Promise.resolve("") : readStdin()).then((stdinText) => runHookCli(argv, stdinText)).then((code) => process.exit(code)).catch(() => process.exit(0));
|
|
332
546
|
//# sourceMappingURL=mason-hook.js.map
|