mason-context 0.20.2 → 0.21.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 +7 -1
- package/README.md +14 -0
- package/dist/mason-audit.js +1690 -649
- package/dist/mason-audit.js.map +1 -1
- package/dist/mason-drift.js +630 -175
- package/dist/mason-drift.js.map +1 -1
- package/dist/mason-hook.js +828 -194
- package/dist/mason-hook.js.map +1 -1
- package/dist/mason-mcp.js +6413 -3828
- package/dist/mason-mcp.js.map +1 -1
- package/dist/mason-review.js +990 -312
- package/dist/mason-review.js.map +1 -1
- package/dist/mason-runtime.js +296 -148
- package/dist/mason-runtime.js.map +1 -1
- package/dist/mason.js +4520 -2211
- package/dist/mason.js.map +1 -1
- package/package.json +11 -1
package/dist/mason-hook.js
CHANGED
|
@@ -1,40 +1,56 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
if (!import.meta.url.endsWith("/mason-runtime.js")) await import("./mason-runtime.js").then(m => m.registerRuntime());
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __esm = (fn, res) => function __init() {
|
|
6
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
7
|
+
};
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
3
12
|
|
|
4
|
-
// src/
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
// src/audit/types.ts
|
|
14
|
+
var types_exports = {};
|
|
15
|
+
__export(types_exports, {
|
|
16
|
+
ALL_CHECKS: () => ALL_CHECKS
|
|
17
|
+
});
|
|
18
|
+
var ALL_CHECKS;
|
|
19
|
+
var init_types = __esm({
|
|
20
|
+
"src/audit/types.ts"() {
|
|
21
|
+
"use strict";
|
|
22
|
+
ALL_CHECKS = [
|
|
23
|
+
"deleted-reference",
|
|
24
|
+
"new-module",
|
|
25
|
+
"stale-count",
|
|
26
|
+
"dead-command",
|
|
27
|
+
"deps-changed",
|
|
28
|
+
"decision-anchor-drift"
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
});
|
|
14
32
|
|
|
15
|
-
// src/
|
|
16
|
-
import
|
|
17
|
-
import path3 from "path";
|
|
18
|
-
import { randomUUID } from "crypto";
|
|
33
|
+
// src/hook/hook.ts
|
|
34
|
+
import { z as z6 } from "zod";
|
|
19
35
|
|
|
20
|
-
// src/utils/
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const slash = value.replace(/\\/g, "/");
|
|
24
|
-
if (!slash || slash.includes("\0") || path.posix.isAbsolute(slash) || /^[A-Za-z]:/.test(slash)) return null;
|
|
25
|
-
if (slash.split("/").includes("..")) return null;
|
|
26
|
-
const normalized = path.posix.normalize(slash).replace(/\/$/, "");
|
|
27
|
-
return normalized === "." ? null : normalized;
|
|
28
|
-
}
|
|
29
|
-
function anchorMatches(anchor, file) {
|
|
30
|
-
const a = normalizeRepoPath(anchor);
|
|
31
|
-
const f = normalizeRepoPath(file);
|
|
32
|
-
return a !== null && f !== null && (a === f || f.startsWith(`${a}/`));
|
|
36
|
+
// src/utils/validation.ts
|
|
37
|
+
function isRecord(value) {
|
|
38
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
33
39
|
}
|
|
34
|
-
function
|
|
35
|
-
return
|
|
40
|
+
function isStringArray(value) {
|
|
41
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
36
42
|
}
|
|
37
43
|
|
|
44
|
+
// src/hook/hook.ts
|
|
45
|
+
import fs7 from "fs/promises";
|
|
46
|
+
import path11 from "path";
|
|
47
|
+
import os2 from "os";
|
|
48
|
+
|
|
49
|
+
// src/audit/inspection.ts
|
|
50
|
+
import fg2 from "fast-glob";
|
|
51
|
+
import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
|
|
52
|
+
import path3 from "path";
|
|
53
|
+
|
|
38
54
|
// src/utils/git-read.ts
|
|
39
55
|
import { execFile } from "child_process";
|
|
40
56
|
import { promisify } from "util";
|
|
@@ -57,7 +73,19 @@ async function profilePhase(label, run) {
|
|
|
57
73
|
|
|
58
74
|
// src/utils/git-read.ts
|
|
59
75
|
var execute = promisify(execFile);
|
|
60
|
-
var commands = /* @__PURE__ */ new Set([
|
|
76
|
+
var commands = /* @__PURE__ */ new Set([
|
|
77
|
+
"rev-parse",
|
|
78
|
+
"symbolic-ref",
|
|
79
|
+
"for-each-ref",
|
|
80
|
+
"ls-files",
|
|
81
|
+
"check-ignore",
|
|
82
|
+
"status",
|
|
83
|
+
"log",
|
|
84
|
+
"diff",
|
|
85
|
+
"ls-tree",
|
|
86
|
+
"show",
|
|
87
|
+
"merge-base"
|
|
88
|
+
]);
|
|
61
89
|
function execGit(args, options) {
|
|
62
90
|
const command = args.find((arg) => !arg.startsWith("-")) ?? "other";
|
|
63
91
|
return profilePhase("git." + (commands.has(command) ? command : "other"), async () => {
|
|
@@ -77,11 +105,127 @@ import fs from "fs/promises";
|
|
|
77
105
|
import { constants } from "fs";
|
|
78
106
|
import path2 from "path";
|
|
79
107
|
import fg from "fast-glob";
|
|
80
|
-
|
|
108
|
+
|
|
109
|
+
// src/utils/paths.ts
|
|
110
|
+
import path from "path";
|
|
111
|
+
function normalizeRepoPath(value) {
|
|
112
|
+
const slash = value.replace(/\\/g, "/");
|
|
113
|
+
if (!slash || slash.includes("\0") || path.posix.isAbsolute(slash) || /^[A-Za-z]:/.test(slash))
|
|
114
|
+
return null;
|
|
115
|
+
if (slash.split("/").includes("..")) return null;
|
|
116
|
+
const normalized = path.posix.normalize(slash).replace(/\/$/, "");
|
|
117
|
+
return normalized === "." ? null : normalized;
|
|
118
|
+
}
|
|
119
|
+
function isWithinRoot(root, candidate) {
|
|
120
|
+
const relative = path.relative(root, candidate);
|
|
121
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
122
|
+
}
|
|
123
|
+
function anchorMatches(anchor, file) {
|
|
124
|
+
const a = normalizeRepoPath(anchor);
|
|
125
|
+
const f = normalizeRepoPath(file);
|
|
126
|
+
return a !== null && f !== null && (a === f || f.startsWith(`${a}/`));
|
|
127
|
+
}
|
|
128
|
+
function matchingPaths(anchors, files) {
|
|
129
|
+
return [...new Set(files)].filter(
|
|
130
|
+
(file) => anchors.some((anchor) => anchorMatches(anchor, file))
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
function indexMatchingPaths(scopes, files) {
|
|
134
|
+
const node = () => ({ children: /* @__PURE__ */ new Map(), scopes: /* @__PURE__ */ new Set() });
|
|
135
|
+
const root = node();
|
|
136
|
+
const matches = scopes.map(() => []);
|
|
137
|
+
scopes.forEach((anchors, index) => {
|
|
138
|
+
for (const anchor of anchors) {
|
|
139
|
+
const normalized = normalizeRepoPath(anchor);
|
|
140
|
+
if (!normalized) continue;
|
|
141
|
+
let current = root;
|
|
142
|
+
for (const part of normalized.split("/")) {
|
|
143
|
+
if (!current.children.has(part)) current.children.set(part, node());
|
|
144
|
+
current = current.children.get(part);
|
|
145
|
+
}
|
|
146
|
+
current.scopes.add(index);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
for (const file of new Set(files)) {
|
|
150
|
+
const normalized = normalizeRepoPath(file);
|
|
151
|
+
if (!normalized) continue;
|
|
152
|
+
const selected = /* @__PURE__ */ new Set();
|
|
153
|
+
let current = root;
|
|
154
|
+
for (const part of normalized.split("/")) {
|
|
155
|
+
current = current.children.get(part);
|
|
156
|
+
if (!current) break;
|
|
157
|
+
for (const index of current.scopes) selected.add(index);
|
|
158
|
+
}
|
|
159
|
+
for (const index of selected) matches[index].push(file);
|
|
160
|
+
}
|
|
161
|
+
return matches;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/utils/files.ts
|
|
165
|
+
var SOURCE_EXTENSIONS = [
|
|
166
|
+
"ts",
|
|
167
|
+
"tsx",
|
|
168
|
+
"js",
|
|
169
|
+
"jsx",
|
|
170
|
+
"mts",
|
|
171
|
+
"cts",
|
|
172
|
+
"mjs",
|
|
173
|
+
"cjs",
|
|
174
|
+
"vue",
|
|
175
|
+
"svelte",
|
|
176
|
+
"kt",
|
|
177
|
+
"kts",
|
|
178
|
+
"java",
|
|
179
|
+
"py",
|
|
180
|
+
"go",
|
|
181
|
+
"rs",
|
|
182
|
+
"swift",
|
|
183
|
+
"rb",
|
|
184
|
+
"cs",
|
|
185
|
+
"cpp",
|
|
186
|
+
"c",
|
|
187
|
+
"h",
|
|
188
|
+
"hpp",
|
|
189
|
+
"dart",
|
|
190
|
+
"php"
|
|
191
|
+
];
|
|
81
192
|
var SOURCE_GLOB = `**/*.{${SOURCE_EXTENSIONS.join(",")}}`;
|
|
193
|
+
var SOURCE_IGNORE = [
|
|
194
|
+
"**/node_modules/**",
|
|
195
|
+
"**/dist/**",
|
|
196
|
+
"**/build/**",
|
|
197
|
+
"**/.gradle/**",
|
|
198
|
+
"**/target/**",
|
|
199
|
+
"**/.git/**",
|
|
200
|
+
"**/.mason/**",
|
|
201
|
+
"**/vendor/**",
|
|
202
|
+
"**/__pycache__/**",
|
|
203
|
+
"**/venv/**",
|
|
204
|
+
"**/.venv/**",
|
|
205
|
+
"**/*.min.*",
|
|
206
|
+
"**/*.map",
|
|
207
|
+
"**/*.lock",
|
|
208
|
+
"**/generated/**",
|
|
209
|
+
"**/*.generated.*",
|
|
210
|
+
"**/R.java",
|
|
211
|
+
"**/BuildConfig.java",
|
|
212
|
+
"**/package-lock.json",
|
|
213
|
+
"**/yarn.lock",
|
|
214
|
+
"**/pnpm-lock.yaml"
|
|
215
|
+
];
|
|
82
216
|
var MAX_SOURCE_BYTES = 1024 * 1024;
|
|
217
|
+
function isSensitiveFile(file) {
|
|
218
|
+
return file.split(/[\\/]/).some(
|
|
219
|
+
(part) => /^(?:\.env(?:\..*)?|id_rsa.*|id_ed25519.*)$|\.(?:pem|key|p12|pfx|jks|keystore)$|credentials\.|secret|^local\.properties$/i.test(
|
|
220
|
+
part
|
|
221
|
+
)
|
|
222
|
+
);
|
|
223
|
+
}
|
|
83
224
|
async function readBoundedFile(file, maxBytes) {
|
|
84
|
-
const handle = await fs.open(
|
|
225
|
+
const handle = await fs.open(
|
|
226
|
+
file,
|
|
227
|
+
constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW
|
|
228
|
+
);
|
|
85
229
|
try {
|
|
86
230
|
const stat = await handle.stat();
|
|
87
231
|
if (!stat.isFile() || stat.size > maxBytes) return null;
|
|
@@ -97,15 +241,168 @@ async function readBoundedFile(file, maxBytes) {
|
|
|
97
241
|
await handle.close();
|
|
98
242
|
}
|
|
99
243
|
}
|
|
244
|
+
async function loadProjectConfig(root) {
|
|
245
|
+
try {
|
|
246
|
+
const canonicalRoot = await fs.realpath(root);
|
|
247
|
+
const configPath = await fs.realpath(path2.join(root, ".mason/config.json"));
|
|
248
|
+
if (!isWithinRoot(canonicalRoot, configPath))
|
|
249
|
+
throw new Error("Project configuration resolves outside the repository");
|
|
250
|
+
const raw = await readBoundedFile(configPath, 64 * 1024);
|
|
251
|
+
if (raw === null)
|
|
252
|
+
throw new Error("Project configuration is not a regular file or exceeds 64 KiB");
|
|
253
|
+
const value = JSON.parse(raw);
|
|
254
|
+
if (!isRecord(value)) throw new Error("Expected a configuration object");
|
|
255
|
+
const config = {};
|
|
256
|
+
for (const key of ["patterns", "alwaysInclude", "ignore"]) {
|
|
257
|
+
if (value[key] === void 0) continue;
|
|
258
|
+
if (!isStringArray(value[key])) {
|
|
259
|
+
throw new Error(`Configuration ${key} must be an array of strings`);
|
|
260
|
+
}
|
|
261
|
+
config[key] = value[key];
|
|
262
|
+
}
|
|
263
|
+
if (value.audit !== void 0) {
|
|
264
|
+
const audit = value.audit;
|
|
265
|
+
if (!isRecord(audit)) throw new Error("Configuration audit must be an object");
|
|
266
|
+
const patterns = (raw2) => {
|
|
267
|
+
if (!isStringArray(raw2) || raw2.length > 100 || !raw2.every(
|
|
268
|
+
(s) => typeof s === "string" && normalizeRepoPath(s) === s && !s.startsWith("!") && !/[\x00-\x1f]/.test(s)
|
|
269
|
+
)) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
"Audit patterns must be at most 100 repository-relative globs without traversal or negation"
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
return raw2;
|
|
275
|
+
};
|
|
276
|
+
config.audit = {};
|
|
277
|
+
if (audit.include !== void 0) config.audit.include = patterns(audit.include);
|
|
278
|
+
if (audit.exclude !== void 0) {
|
|
279
|
+
if (!isRecord(audit.exclude))
|
|
280
|
+
throw new Error("audit.exclude must map check names to patterns");
|
|
281
|
+
const { ALL_CHECKS: ALL_CHECKS2 } = await Promise.resolve().then(() => (init_types(), types_exports));
|
|
282
|
+
config.audit.exclude = {};
|
|
283
|
+
for (const [check, globs] of Object.entries(audit.exclude)) {
|
|
284
|
+
if (!ALL_CHECKS2.includes(check))
|
|
285
|
+
throw new Error("Unknown audit check: " + check);
|
|
286
|
+
config.audit.exclude[check] = patterns(globs);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return config;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (error.code === "ENOENT") return {};
|
|
293
|
+
throw new Error(
|
|
294
|
+
`Cannot apply project file policy: ${error instanceof Error ? error.message : String(error)}`,
|
|
295
|
+
{ cause: error }
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
async function createFileAccess(rootDir) {
|
|
300
|
+
const root = path2.resolve(rootDir);
|
|
301
|
+
const canonicalRoot = await fs.realpath(root).catch(() => root);
|
|
302
|
+
const config = await loadProjectConfig(root);
|
|
303
|
+
const ignore = [...SOURCE_IGNORE, ...config.ignore ?? []];
|
|
304
|
+
let gitFiles = null;
|
|
305
|
+
try {
|
|
306
|
+
const { stdout } = await execGit(
|
|
307
|
+
["ls-files", "-z", "--cached", "--others", "--exclude-standard"],
|
|
308
|
+
{ cwd: root, maxBuffer: 50 * 1024 * 1024 }
|
|
309
|
+
);
|
|
310
|
+
gitFiles = new Set(stdout.split("\0").filter(Boolean));
|
|
311
|
+
} catch {
|
|
312
|
+
let inGit = false;
|
|
313
|
+
try {
|
|
314
|
+
await execGit(["rev-parse", "--git-dir"], { cwd: root });
|
|
315
|
+
inGit = true;
|
|
316
|
+
} catch {
|
|
317
|
+
}
|
|
318
|
+
if (inGit) throw new Error("Cannot enumerate Git files safely");
|
|
319
|
+
}
|
|
320
|
+
async function resolve(file) {
|
|
321
|
+
const relative = normalizeRepoPath(file);
|
|
322
|
+
if (!relative || isSensitiveFile(relative) || gitFiles && !gitFiles.has(relative))
|
|
323
|
+
return null;
|
|
324
|
+
const candidate = path2.join(root, relative);
|
|
325
|
+
try {
|
|
326
|
+
const real = await fs.realpath(candidate);
|
|
327
|
+
if (!isWithinRoot(canonicalRoot, real) || isSensitiveFile(path2.relative(canonicalRoot, real)))
|
|
328
|
+
return null;
|
|
329
|
+
const stat = await fs.stat(real);
|
|
330
|
+
if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES) return null;
|
|
331
|
+
if (gitFiles && !gitFiles.has(path2.relative(canonicalRoot, real).split(path2.sep).join("/")))
|
|
332
|
+
return null;
|
|
333
|
+
return real;
|
|
334
|
+
} catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
async function list(patterns = SOURCE_GLOB, options = {}) {
|
|
339
|
+
const found = await fg(patterns, { cwd: root, ignore, followSymbolicLinks: false, ...options });
|
|
340
|
+
const safe = await Promise.all(found.map(async (f) => await resolve(f) ? f : null));
|
|
341
|
+
return safe.filter((f) => f !== null).sort();
|
|
342
|
+
}
|
|
343
|
+
async function read(file) {
|
|
344
|
+
const relative = normalizeRepoPath(file);
|
|
345
|
+
if (!relative) return null;
|
|
346
|
+
const real = await resolve(relative);
|
|
347
|
+
if (!real) return null;
|
|
348
|
+
for (const rel of /* @__PURE__ */ new Set([
|
|
349
|
+
relative,
|
|
350
|
+
path2.relative(canonicalRoot, real).split(path2.sep).join("/")
|
|
351
|
+
])) {
|
|
352
|
+
if (!(await fg(fg.escapePath(rel), { cwd: root, ignore, dot: true })).length) return null;
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
const content = await readBoundedFile(real, MAX_SOURCE_BYTES);
|
|
356
|
+
return content === null ? null : { path: relative, content, totalLines: content.split("\n").length };
|
|
357
|
+
} catch {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return { root, config, list, read, inventory: () => gitFiles ? [...gitFiles].sort() : null };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// src/audit/inspection.ts
|
|
365
|
+
var inspections = new AsyncLocalStorage2();
|
|
366
|
+
function inspectionRead(root, key, read) {
|
|
367
|
+
const inspection = inspections.getStore();
|
|
368
|
+
if (!inspection || inspection.root !== path3.resolve(root)) return read();
|
|
369
|
+
if (!inspection.reads.has(key)) {
|
|
370
|
+
const pending = read();
|
|
371
|
+
inspection.reads.set(key, pending);
|
|
372
|
+
void pending.catch(() => {
|
|
373
|
+
if (inspection.reads.get(key) === pending) inspection.reads.delete(key);
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
return inspection.reads.get(key);
|
|
377
|
+
}
|
|
378
|
+
function inspectionGit(args, options) {
|
|
379
|
+
return inspectionRead(
|
|
380
|
+
options.cwd,
|
|
381
|
+
"git:" + JSON.stringify([args, options]),
|
|
382
|
+
() => execGit(args, options)
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
var sourceSpecs = SOURCE_EXTENSIONS.map((extension) => `:(glob)**/*.${extension}`);
|
|
386
|
+
var sourceSuffixes = new Set(SOURCE_EXTENSIONS.map((extension) => "." + extension));
|
|
387
|
+
|
|
388
|
+
// src/decisions/anchors.ts
|
|
389
|
+
import { createHash } from "crypto";
|
|
390
|
+
import fs3 from "fs/promises";
|
|
391
|
+
import { constants as constants2 } from "fs";
|
|
392
|
+
import { z } from "zod";
|
|
393
|
+
import fg3 from "fast-glob";
|
|
100
394
|
|
|
101
395
|
// src/utils/storage.ts
|
|
396
|
+
import fs2 from "fs/promises";
|
|
397
|
+
import path4 from "path";
|
|
398
|
+
import { randomUUID } from "crypto";
|
|
102
399
|
async function storePath(root, relative, createParents = false) {
|
|
103
400
|
const normalized = normalizeRepoPath(relative);
|
|
104
401
|
if (!normalized) throw new Error(`Invalid store path: ${relative}`);
|
|
105
402
|
let current = await fs2.realpath(root);
|
|
106
403
|
const parts = normalized.split("/");
|
|
107
404
|
for (let i = 0; i < parts.length; i++) {
|
|
108
|
-
current =
|
|
405
|
+
current = path4.join(current, parts[i]);
|
|
109
406
|
let stat;
|
|
110
407
|
try {
|
|
111
408
|
stat = await fs2.lstat(current);
|
|
@@ -124,56 +421,170 @@ async function storePath(root, relative, createParents = false) {
|
|
|
124
421
|
}
|
|
125
422
|
return current;
|
|
126
423
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
424
|
+
|
|
425
|
+
// src/decisions/anchors.ts
|
|
426
|
+
var repoPath = z.string().refine((p) => normalizeRepoPath(p) === p);
|
|
427
|
+
var anchorCaptureSchema = z.object({
|
|
428
|
+
anchors: z.array(repoPath),
|
|
429
|
+
files: z.record(repoPath, z.string().regex(/^[a-f0-9]{64}$/)),
|
|
430
|
+
complete: z.boolean()
|
|
431
|
+
}).refine(
|
|
432
|
+
(capture) => JSON.stringify(capture.anchors) === JSON.stringify([...new Set(capture.anchors)].sort()) && matchingPaths(capture.anchors, Object.keys(capture.files)).length === Object.keys(capture.files).length,
|
|
433
|
+
"Capture paths must belong to its normalized, sorted anchor scope"
|
|
434
|
+
);
|
|
435
|
+
async function captureAnchorScopes(root, scopes) {
|
|
436
|
+
const union = [...new Set(scopes.flat())].sort();
|
|
437
|
+
let context;
|
|
438
|
+
const prepareContext = async () => {
|
|
439
|
+
const access = await inspectionRead(
|
|
440
|
+
root,
|
|
441
|
+
"decision-anchor-access",
|
|
442
|
+
() => createFileAccess(root)
|
|
443
|
+
);
|
|
444
|
+
const inventory = access.inventory();
|
|
445
|
+
if (!inventory) throw new Error("Git anchor inventory unavailable");
|
|
446
|
+
const ignored = await inspectionGit(
|
|
447
|
+
[
|
|
448
|
+
"ls-files",
|
|
449
|
+
"-z",
|
|
450
|
+
"--others",
|
|
451
|
+
"--ignored",
|
|
452
|
+
"--exclude-standard",
|
|
453
|
+
"--",
|
|
454
|
+
...union.map((anchor) => `:(literal)${anchor}`)
|
|
455
|
+
],
|
|
456
|
+
{ cwd: root, maxBuffer: 8 * 1024 * 1024 }
|
|
457
|
+
);
|
|
458
|
+
const files = [
|
|
459
|
+
.../* @__PURE__ */ new Set([...inventory, ...ignored.stdout.split("\0").filter(Boolean)])
|
|
460
|
+
].sort();
|
|
461
|
+
const patterns = await Promise.all(
|
|
462
|
+
union.map(async (anchor) => {
|
|
463
|
+
const literal = fg3.escapePath(anchor);
|
|
464
|
+
try {
|
|
465
|
+
return (await fs3.lstat(await storePath(root, anchor))).isDirectory() ? `${literal}/**/*` : literal;
|
|
466
|
+
} catch {
|
|
467
|
+
return literal;
|
|
468
|
+
}
|
|
469
|
+
})
|
|
470
|
+
);
|
|
471
|
+
const eligible = new Set(await access.list(patterns, { dot: true }));
|
|
472
|
+
const indexed = indexMatchingPaths(scopes, files);
|
|
473
|
+
const filesByScope = new Map(
|
|
474
|
+
scopes.map((scope, index) => [JSON.stringify([...new Set(scope)].sort()), indexed[index]])
|
|
475
|
+
);
|
|
476
|
+
return { filesByScope, eligible };
|
|
477
|
+
};
|
|
478
|
+
return Promise.all(
|
|
479
|
+
scopes.map((scope) => {
|
|
480
|
+
const anchors = [...new Set(scope)].sort();
|
|
481
|
+
return inspectionRead(
|
|
482
|
+
root,
|
|
483
|
+
"decision-anchor-capture:" + JSON.stringify(anchors),
|
|
484
|
+
async () => {
|
|
485
|
+
const capture = { anchors, files: {}, complete: true };
|
|
486
|
+
if (!anchors.length) return capture;
|
|
487
|
+
try {
|
|
488
|
+
const { eligible, filesByScope } = await (context ??= prepareContext());
|
|
489
|
+
const files = filesByScope.get(JSON.stringify(anchors));
|
|
490
|
+
if (files.length > 2e3) return { ...capture, complete: false };
|
|
491
|
+
for (const file of files) {
|
|
492
|
+
const evidence = await inspectionRead(
|
|
493
|
+
root,
|
|
494
|
+
"decision-anchor-file:" + file,
|
|
495
|
+
async () => {
|
|
496
|
+
try {
|
|
497
|
+
const absolute = await storePath(root, file);
|
|
498
|
+
await fs3.lstat(absolute);
|
|
499
|
+
if (!eligible.has(file)) return { complete: false };
|
|
500
|
+
const handle = await fs3.open(
|
|
501
|
+
absolute,
|
|
502
|
+
constants2.O_RDONLY | constants2.O_NONBLOCK | constants2.O_NOFOLLOW
|
|
503
|
+
);
|
|
504
|
+
try {
|
|
505
|
+
const stat = await handle.stat();
|
|
506
|
+
if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES)
|
|
507
|
+
return { complete: false };
|
|
508
|
+
const bytes = Buffer.alloc(Math.min(stat.size + 1, MAX_SOURCE_BYTES + 1));
|
|
509
|
+
let size = 0;
|
|
510
|
+
while (size < bytes.length) {
|
|
511
|
+
const read = await handle.read(bytes, size, bytes.length - size, null);
|
|
512
|
+
if (!read.bytesRead) break;
|
|
513
|
+
size += read.bytesRead;
|
|
514
|
+
}
|
|
515
|
+
if (size === bytes.length) return { complete: false };
|
|
516
|
+
return {
|
|
517
|
+
complete: true,
|
|
518
|
+
hash: createHash("sha256").update(bytes.subarray(0, size)).digest("hex")
|
|
519
|
+
};
|
|
520
|
+
} finally {
|
|
521
|
+
await handle.close();
|
|
522
|
+
}
|
|
523
|
+
} catch (error) {
|
|
524
|
+
return { complete: error.code === "ENOENT" };
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
);
|
|
528
|
+
if (!evidence.complete) capture.complete = false;
|
|
529
|
+
if (evidence.hash) capture.files[file] = evidence.hash;
|
|
530
|
+
}
|
|
531
|
+
} catch {
|
|
532
|
+
capture.complete = false;
|
|
533
|
+
}
|
|
534
|
+
return capture;
|
|
535
|
+
}
|
|
536
|
+
);
|
|
537
|
+
})
|
|
538
|
+
);
|
|
139
539
|
}
|
|
540
|
+
function changedCaptureFiles(before, after) {
|
|
541
|
+
return [.../* @__PURE__ */ new Set([...Object.keys(before.files), ...Object.keys(after.files)])].filter((file) => before.files[file] !== after.files[file]).sort();
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// src/decisions/decisions.ts
|
|
545
|
+
import fs5 from "fs/promises";
|
|
546
|
+
import path8 from "path";
|
|
547
|
+
import { createHash as createHash3 } from "crypto";
|
|
548
|
+
import { z as z5 } from "zod";
|
|
140
549
|
|
|
141
550
|
// src/snapshot/snapshot.ts
|
|
142
|
-
import
|
|
143
|
-
import { z } from "zod";
|
|
551
|
+
import path6 from "path";
|
|
552
|
+
import { z as z3 } from "zod";
|
|
144
553
|
|
|
145
554
|
// src/utils/store-lock.ts
|
|
146
|
-
import
|
|
555
|
+
import { z as z2 } from "zod";
|
|
556
|
+
import fs4 from "fs/promises";
|
|
147
557
|
import os from "os";
|
|
558
|
+
var lockOwnerSchema = z2.object({ host: z2.string(), pid: z2.number().int().positive() });
|
|
148
559
|
|
|
149
560
|
// src/test-map.ts
|
|
150
|
-
import
|
|
561
|
+
import path5 from "path";
|
|
151
562
|
|
|
152
563
|
// src/snapshot/snapshot.ts
|
|
153
|
-
var
|
|
564
|
+
var repoPath2 = z3.string().refine((value) => normalizeRepoPath(value) !== null, "Expected a relative repository path");
|
|
154
565
|
var verificationFields = {
|
|
155
|
-
refreshedHash:
|
|
156
|
-
verifiedAt:
|
|
157
|
-
verifiedHash:
|
|
158
|
-
verificationToken:
|
|
159
|
-
verificationFailed:
|
|
160
|
-
verificationNote:
|
|
566
|
+
refreshedHash: z3.string().optional(),
|
|
567
|
+
verifiedAt: z3.string().optional(),
|
|
568
|
+
verifiedHash: z3.string().optional(),
|
|
569
|
+
verificationToken: z3.string().optional(),
|
|
570
|
+
verificationFailed: z3.boolean().optional(),
|
|
571
|
+
verificationNote: z3.string().optional()
|
|
161
572
|
};
|
|
162
|
-
var featureSchema =
|
|
163
|
-
description:
|
|
164
|
-
files:
|
|
165
|
-
tests:
|
|
166
|
-
type:
|
|
573
|
+
var featureSchema = z3.object({
|
|
574
|
+
description: z3.string(),
|
|
575
|
+
files: z3.array(repoPath2),
|
|
576
|
+
tests: z3.array(repoPath2).optional(),
|
|
577
|
+
type: z3.enum(["capability", "infrastructure"]).optional(),
|
|
167
578
|
...verificationFields
|
|
168
579
|
}).passthrough();
|
|
169
|
-
var flowSchema =
|
|
170
|
-
var snapshotSchema =
|
|
171
|
-
version:
|
|
172
|
-
createdAt:
|
|
173
|
-
updatedAt:
|
|
174
|
-
gitHash:
|
|
175
|
-
features:
|
|
176
|
-
flows:
|
|
580
|
+
var flowSchema = z3.object({ description: z3.string(), chain: z3.array(repoPath2), ...verificationFields }).passthrough();
|
|
581
|
+
var snapshotSchema = z3.object({
|
|
582
|
+
version: z3.literal(2),
|
|
583
|
+
createdAt: z3.string(),
|
|
584
|
+
updatedAt: z3.string(),
|
|
585
|
+
gitHash: z3.string(),
|
|
586
|
+
features: z3.record(featureSchema),
|
|
587
|
+
flows: z3.record(flowSchema)
|
|
177
588
|
}).passthrough();
|
|
178
589
|
async function getCurrentGitHash(rootDir) {
|
|
179
590
|
try {
|
|
@@ -187,114 +598,167 @@ async function getCurrentGitHash(rootDir) {
|
|
|
187
598
|
}
|
|
188
599
|
|
|
189
600
|
// src/context/lexical.ts
|
|
190
|
-
import
|
|
601
|
+
import path7 from "path";
|
|
191
602
|
|
|
192
603
|
// src/decisions/provenance.ts
|
|
193
|
-
import { z as
|
|
604
|
+
import { z as z4 } from "zod";
|
|
605
|
+
import { createHash as createHash2 } from "crypto";
|
|
194
606
|
|
|
195
607
|
// src/context/trust.ts
|
|
196
608
|
function assessTrust(entry, freshness) {
|
|
197
609
|
const verification = entry.verificationFailed ? "failed" : entry.verifiedAt ? "passed" : "unverified";
|
|
198
610
|
const reasons = [];
|
|
199
|
-
if (freshness === "unknown")
|
|
200
|
-
|
|
201
|
-
|
|
611
|
+
if (freshness === "unknown")
|
|
612
|
+
reasons.push(
|
|
613
|
+
"Anchors, history, or working-tree evidence are unavailable; verify before relying on this entry."
|
|
614
|
+
);
|
|
615
|
+
if (freshness === "changed")
|
|
616
|
+
reasons.push(
|
|
617
|
+
"Anchored files changed; verify against current code before relying on this entry."
|
|
618
|
+
);
|
|
619
|
+
if (verification === "failed")
|
|
620
|
+
reasons.push(
|
|
621
|
+
`Verification failed: ${entry.verificationNote ?? "re-map this entry before relying on it"}`
|
|
622
|
+
);
|
|
202
623
|
if (verification === "unverified") reasons.push("No correctness verification has been recorded.");
|
|
203
|
-
return {
|
|
624
|
+
return {
|
|
625
|
+
freshness,
|
|
626
|
+
verification,
|
|
627
|
+
verifiedAt: entry.verifiedAt,
|
|
628
|
+
verifiedHash: entry.verifiedHash,
|
|
629
|
+
reasons
|
|
630
|
+
};
|
|
204
631
|
}
|
|
205
632
|
|
|
206
633
|
// src/decisions/provenance.ts
|
|
207
|
-
var text = (max) =>
|
|
208
|
-
var decisionSourceSchema =
|
|
209
|
-
kind:
|
|
634
|
+
var text = (max) => z4.string().trim().min(1).max(max);
|
|
635
|
+
var decisionSourceSchema = z4.object({
|
|
636
|
+
kind: z4.enum(["pull_request", "issue", "incident", "discussion", "document", "other"]),
|
|
210
637
|
reference: text(1e3),
|
|
211
638
|
note: text(500).optional()
|
|
212
639
|
}).strict();
|
|
213
|
-
var attributionSchema =
|
|
640
|
+
var attributionSchema = z4.object({
|
|
214
641
|
owner: text(200).nullable().optional(),
|
|
215
|
-
sources:
|
|
642
|
+
sources: z4.array(decisionSourceSchema).max(20).optional(),
|
|
216
643
|
actor: text(200).optional()
|
|
217
644
|
});
|
|
218
|
-
var contentSchema =
|
|
219
|
-
title:
|
|
220
|
-
body:
|
|
221
|
-
category:
|
|
222
|
-
files:
|
|
645
|
+
var contentSchema = z4.object({
|
|
646
|
+
title: z4.string().min(1),
|
|
647
|
+
body: z4.string().min(1),
|
|
648
|
+
category: z4.enum(["decision", "gotcha", "deprecation", "convention"]),
|
|
649
|
+
files: z4.array(z4.string().refine((f) => normalizeRepoPath(f) !== null)),
|
|
223
650
|
owner: text(200).optional(),
|
|
224
|
-
sources:
|
|
651
|
+
sources: z4.array(decisionSourceSchema).max(20)
|
|
225
652
|
});
|
|
226
|
-
var approvalSchema =
|
|
227
|
-
var statusSchema =
|
|
228
|
-
var reviewEvidenceSchema =
|
|
229
|
-
baseHash:
|
|
230
|
-
headHash:
|
|
231
|
-
historyAvailable:
|
|
232
|
-
changedFiles:
|
|
233
|
-
localChanges:
|
|
653
|
+
var approvalSchema = z4.enum(["unreviewed", "proposed", "accepted"]);
|
|
654
|
+
var statusSchema = z4.enum(["active", "superseded", "retired"]);
|
|
655
|
+
var reviewEvidenceSchema = z4.object({
|
|
656
|
+
baseHash: z4.string(),
|
|
657
|
+
headHash: z4.string(),
|
|
658
|
+
historyAvailable: z4.boolean(),
|
|
659
|
+
changedFiles: z4.array(z4.string()),
|
|
660
|
+
localChanges: z4.array(z4.string())
|
|
234
661
|
});
|
|
235
|
-
var eventSchema =
|
|
236
|
-
kind:
|
|
237
|
-
|
|
662
|
+
var eventSchema = z4.object({
|
|
663
|
+
kind: z4.enum([
|
|
664
|
+
"imported",
|
|
665
|
+
"created",
|
|
666
|
+
"revised",
|
|
667
|
+
"accepted",
|
|
668
|
+
"reaffirmed",
|
|
669
|
+
"retired",
|
|
670
|
+
"superseded"
|
|
671
|
+
]),
|
|
672
|
+
at: z4.string().datetime(),
|
|
238
673
|
actor: text(200).optional(),
|
|
239
674
|
note: text(1500).optional(),
|
|
240
|
-
revision:
|
|
675
|
+
revision: z4.number().int().positive(),
|
|
241
676
|
content: contentSchema,
|
|
242
677
|
approval: approvalSchema,
|
|
243
678
|
status: statusSchema,
|
|
244
|
-
refreshedHash:
|
|
245
|
-
evidence: reviewEvidenceSchema.optional()
|
|
679
|
+
refreshedHash: z4.string(),
|
|
680
|
+
evidence: reviewEvidenceSchema.optional(),
|
|
681
|
+
capture: anchorCaptureSchema.optional()
|
|
246
682
|
});
|
|
247
|
-
var legacySchema =
|
|
248
|
-
version:
|
|
249
|
-
id:
|
|
250
|
-
title:
|
|
251
|
-
body:
|
|
683
|
+
var legacySchema = z4.object({
|
|
684
|
+
version: z4.literal(1),
|
|
685
|
+
id: z4.string().regex(/^[a-zA-Z0-9_-]+$/),
|
|
686
|
+
title: z4.string().min(1),
|
|
687
|
+
body: z4.string().min(1),
|
|
252
688
|
category: contentSchema.shape.category,
|
|
253
689
|
files: contentSchema.shape.files,
|
|
254
|
-
createdAt:
|
|
255
|
-
updatedAt:
|
|
256
|
-
refreshedHash:
|
|
257
|
-
status:
|
|
258
|
-
supersededBy:
|
|
690
|
+
createdAt: z4.string(),
|
|
691
|
+
updatedAt: z4.string(),
|
|
692
|
+
refreshedHash: z4.string(),
|
|
693
|
+
status: z4.enum(["active", "superseded"]),
|
|
694
|
+
supersededBy: z4.string().optional()
|
|
259
695
|
}).passthrough();
|
|
260
696
|
var currentSchema = legacySchema.extend({
|
|
261
|
-
version:
|
|
697
|
+
version: z4.literal(2),
|
|
262
698
|
status: statusSchema,
|
|
263
699
|
approval: approvalSchema,
|
|
264
|
-
revision:
|
|
700
|
+
revision: z4.number().int().positive(),
|
|
265
701
|
owner: text(200).optional(),
|
|
266
|
-
sources:
|
|
267
|
-
history:
|
|
702
|
+
sources: z4.array(decisionSourceSchema).max(20),
|
|
703
|
+
history: z4.array(eventSchema).min(1),
|
|
704
|
+
capture: anchorCaptureSchema.optional(),
|
|
705
|
+
inspections: z4.array(
|
|
706
|
+
z4.object({
|
|
707
|
+
at: z4.string().datetime(),
|
|
708
|
+
inspector: text(200),
|
|
709
|
+
note: text(1500),
|
|
710
|
+
outcome: z4.literal("no_contradiction_found"),
|
|
711
|
+
decisionDigest: z4.string().regex(/^[a-f0-9]{64}$/),
|
|
712
|
+
headHash: z4.string(),
|
|
713
|
+
capture: anchorCaptureSchema
|
|
714
|
+
})
|
|
715
|
+
).max(200).optional()
|
|
268
716
|
}).superRefine((record, ctx) => {
|
|
269
717
|
const invalid = (message) => ctx.addIssue({ code: "custom", message });
|
|
270
718
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
271
719
|
let previous;
|
|
272
720
|
for (const event of record.history) {
|
|
273
721
|
if (!previous) {
|
|
274
|
-
if (!["created", "imported"].includes(event.kind) || event.revision !== 1)
|
|
275
|
-
|
|
722
|
+
if (!["created", "imported"].includes(event.kind) || event.revision !== 1)
|
|
723
|
+
invalid("History must begin with creation or legacy import at revision 1");
|
|
724
|
+
if (event.approval !== (event.kind === "created" ? "proposed" : "unreviewed"))
|
|
725
|
+
invalid("Initial records cannot claim acceptance");
|
|
276
726
|
} else {
|
|
277
727
|
if (["created", "imported"].includes(event.kind)) invalid("History cannot restart");
|
|
278
728
|
if (previous.status !== "active") invalid("Archived decisions cannot be changed");
|
|
279
|
-
if (event.revision !== previous.revision + (event.kind === "revised" ? 1 : 0))
|
|
280
|
-
|
|
281
|
-
if (event.kind
|
|
282
|
-
|
|
729
|
+
if (event.revision !== previous.revision + (event.kind === "revised" ? 1 : 0))
|
|
730
|
+
invalid("Invalid revision sequence");
|
|
731
|
+
if (event.kind !== "revised" && !same(event.content, previous.content))
|
|
732
|
+
invalid("A review cannot silently revise decision content");
|
|
733
|
+
if (event.kind === "reaffirmed" && previous.approval !== "accepted")
|
|
734
|
+
invalid("Only accepted decisions can be reaffirmed");
|
|
735
|
+
if (event.kind === "accepted" && previous.approval === "accepted")
|
|
736
|
+
invalid("Use reaffirmation for an accepted decision");
|
|
283
737
|
const approval = event.kind === "revised" ? "proposed" : ["accepted", "reaffirmed"].includes(event.kind) ? "accepted" : previous.approval;
|
|
284
738
|
if (event.approval !== approval) invalid("Approval disagrees with review history");
|
|
285
|
-
if (!["accepted", "reaffirmed"].includes(event.kind) && event.refreshedHash !== previous.refreshedHash)
|
|
739
|
+
if (!["accepted", "reaffirmed"].includes(event.kind) && event.refreshedHash !== previous.refreshedHash)
|
|
740
|
+
invalid("Only a review can refresh the evidence baseline");
|
|
286
741
|
}
|
|
287
|
-
if (event.kind !== "imported" && event.status !== (event.kind === "retired" ? "retired" : event.kind === "superseded" ? "superseded" : "active"))
|
|
288
|
-
|
|
742
|
+
if (event.kind !== "imported" && event.status !== (event.kind === "retired" ? "retired" : event.kind === "superseded" ? "superseded" : "active"))
|
|
743
|
+
invalid("Lifecycle disagrees with history");
|
|
744
|
+
if (["accepted", "reaffirmed", "retired"].includes(event.kind) && (!event.actor || !event.note || !event.evidence))
|
|
745
|
+
invalid("Reviews require a named reviewer, reason, and code evidence");
|
|
289
746
|
if (["accepted", "reaffirmed"].includes(event.kind)) {
|
|
290
|
-
if (!event.content.owner || !event.content.sources.length)
|
|
291
|
-
|
|
747
|
+
if (!event.content.owner || !event.content.sources.length)
|
|
748
|
+
invalid("Accepted decisions require an owner and source");
|
|
749
|
+
if (!event.evidence || !/^[a-f0-9]{40,64}$/.test(event.evidence.headHash) || event.refreshedHash !== event.evidence.headHash || event.evidence.localChanges.length)
|
|
750
|
+
invalid("Acceptance requires a committed evidence baseline");
|
|
292
751
|
}
|
|
752
|
+
if (event.capture && !same([...event.content.files].sort(), event.capture.anchors))
|
|
753
|
+
invalid("Capture scope disagrees with decision anchors");
|
|
293
754
|
previous = event;
|
|
294
755
|
}
|
|
295
|
-
if (
|
|
756
|
+
if (previous && !same(previous.capture, record.capture))
|
|
757
|
+
invalid("Capture does not match the final history event");
|
|
758
|
+
if (!previous || !same(previous.content, decisionContent(record)) || previous.approval !== record.approval || previous.status !== record.status || previous.revision !== record.revision || previous.refreshedHash !== record.refreshedHash)
|
|
759
|
+
invalid("Decision does not match the final history event");
|
|
296
760
|
});
|
|
297
|
-
var decisionSchema =
|
|
761
|
+
var decisionSchema = z4.union([legacySchema, currentSchema]);
|
|
298
762
|
function decisionContent(record) {
|
|
299
763
|
return {
|
|
300
764
|
title: record.title,
|
|
@@ -309,7 +773,8 @@ function decisionApproval(record) {
|
|
|
309
773
|
return record.version === 1 ? "unreviewed" : record.approval;
|
|
310
774
|
}
|
|
311
775
|
function effectiveDecision(record) {
|
|
312
|
-
if (record.version !== 2 || record.status !== "active" || record.approval !== "proposed")
|
|
776
|
+
if (record.version !== 2 || record.status !== "active" || record.approval !== "proposed")
|
|
777
|
+
return record;
|
|
313
778
|
let index = record.history.length - 1;
|
|
314
779
|
while (index >= 0 && !["accepted", "reaffirmed"].includes(record.history[index].kind)) index--;
|
|
315
780
|
if (index < 0) return record;
|
|
@@ -321,6 +786,7 @@ function effectiveDecision(record) {
|
|
|
321
786
|
approval: "accepted",
|
|
322
787
|
revision: event.revision,
|
|
323
788
|
refreshedHash: event.refreshedHash,
|
|
789
|
+
capture: event.capture,
|
|
324
790
|
updatedAt: event.at,
|
|
325
791
|
history: record.history.slice(0, index + 1)
|
|
326
792
|
};
|
|
@@ -330,7 +796,9 @@ function decisionAnchors(record) {
|
|
|
330
796
|
}
|
|
331
797
|
function decisionProvenance(record, freshness = "unknown") {
|
|
332
798
|
const approval = decisionApproval(record);
|
|
333
|
-
const review = record.version === 2 ? [...record.history].reverse().find(
|
|
799
|
+
const review = record.version === 2 ? [...record.history].reverse().find(
|
|
800
|
+
(e) => ["accepted", "reaffirmed"].includes(e.kind) && e.revision === record.revision
|
|
801
|
+
) : void 0;
|
|
334
802
|
return {
|
|
335
803
|
approval,
|
|
336
804
|
revision: record.version === 2 ? record.revision : 0,
|
|
@@ -338,71 +806,149 @@ function decisionProvenance(record, freshness = "unknown") {
|
|
|
338
806
|
sources: record.version === 2 ? record.sources : [],
|
|
339
807
|
guidance: record.status !== "active" ? "historical" : approval === "accepted" ? "constraint" : approval === "proposed" ? "proposal" : "unreviewed",
|
|
340
808
|
reviewRequired: record.status === "active" && (approval !== "accepted" || freshness !== "current"),
|
|
341
|
-
lastReview: review ? {
|
|
809
|
+
lastReview: review ? {
|
|
810
|
+
reviewer: review.actor,
|
|
811
|
+
at: review.at,
|
|
812
|
+
note: review.note,
|
|
813
|
+
gitHash: review.refreshedHash
|
|
814
|
+
} : null
|
|
342
815
|
};
|
|
343
816
|
}
|
|
344
817
|
function decisionTrust(record, freshness) {
|
|
345
818
|
const review = decisionProvenance(record, freshness).lastReview;
|
|
346
|
-
return assessTrust(
|
|
819
|
+
return assessTrust(
|
|
820
|
+
review ? { verifiedAt: review.at, verifiedHash: review.gitHash } : {},
|
|
821
|
+
freshness
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
function decisionInspectionDigest(record) {
|
|
825
|
+
const effective = effectiveDecision(record);
|
|
826
|
+
return createHash2("sha256").update(
|
|
827
|
+
JSON.stringify([
|
|
828
|
+
record.status,
|
|
829
|
+
decisionApproval(record),
|
|
830
|
+
record.version === 2 ? record.revision : 0,
|
|
831
|
+
decisionContent(record),
|
|
832
|
+
decisionContent(effective),
|
|
833
|
+
effective.refreshedHash
|
|
834
|
+
])
|
|
835
|
+
).digest("hex");
|
|
836
|
+
}
|
|
837
|
+
function latestDecisionInspection(record) {
|
|
838
|
+
return record.version === 2 ? record.inspections?.filter((i) => i.decisionDigest === decisionInspectionDigest(record)).at(-1) : void 0;
|
|
347
839
|
}
|
|
348
840
|
function revisionKnowledge(record, freshness) {
|
|
349
|
-
return {
|
|
841
|
+
return {
|
|
842
|
+
...decisionContent(record),
|
|
843
|
+
...decisionProvenance(record, freshness),
|
|
844
|
+
trust: decisionTrust(record, freshness)
|
|
845
|
+
};
|
|
350
846
|
}
|
|
351
847
|
function decisionKnowledge(record, freshness = "unknown", proposalFreshness = "unknown") {
|
|
352
848
|
const effective = effectiveDecision(record);
|
|
849
|
+
const inspection = latestDecisionInspection(record);
|
|
353
850
|
return {
|
|
354
851
|
...revisionKnowledge(effective, freshness),
|
|
852
|
+
...inspection ? {
|
|
853
|
+
inspection: {
|
|
854
|
+
freshness,
|
|
855
|
+
at: inspection.at,
|
|
856
|
+
inspector: inspection.inspector,
|
|
857
|
+
note: inspection.note,
|
|
858
|
+
outcome: inspection.outcome,
|
|
859
|
+
headHash: inspection.headHash
|
|
860
|
+
}
|
|
861
|
+
} : {},
|
|
355
862
|
...effective !== record ? { pendingProposal: revisionKnowledge(record, proposalFreshness) } : {}
|
|
356
863
|
};
|
|
357
864
|
}
|
|
358
|
-
var DECISION_GUIDANCE = "Accepted decisions are recorded team constraints, subject to freshness checks. A pendingProposal is an unaccepted replacement; the accepted revision remains operative until explicit acceptance or retirement. Proposals are suggestions; legacy unreviewed records need confirmation. Use review_decision to
|
|
865
|
+
var DECISION_GUIDANCE = "Accepted decisions are recorded team constraints, subject to freshness checks. A pendingProposal is an unaccepted replacement; the accepted revision remains operative until explicit acceptance or retirement. Proposals are suggestions; legacy unreviewed records need confirmation. Use review_decision to prepare evidence and record a separate source inspection without human approval, or an authorized human review; identities and sources are recorded assertions, not authenticated proof.";
|
|
359
866
|
|
|
360
867
|
// src/decisions/decisions.ts
|
|
361
868
|
var TITLE_MAX_CHARS = 80;
|
|
362
869
|
var BODY_RECOMMENDED_CHARS = 1500;
|
|
363
870
|
var BODY_MAX_CHARS = 2500;
|
|
364
|
-
var boundedText = (field, max, hint) =>
|
|
365
|
-
errorMap: (issue, ctx) => ({
|
|
871
|
+
var boundedText = (field, max, hint) => z5.string({
|
|
872
|
+
errorMap: (issue, ctx) => ({
|
|
873
|
+
message: issue.code === "too_big" && typeof ctx.data === "string" ? `${field} has ${ctx.data.length} characters; maximum is ${max} (${ctx.data.length - max} over). ${hint}` : ctx.defaultError
|
|
874
|
+
})
|
|
366
875
|
}).trim().min(1, `${field} must be non-empty`).max(max);
|
|
367
|
-
var decisionTitleSchema = boundedText(
|
|
876
|
+
var decisionTitleSchema = boundedText(
|
|
877
|
+
"title",
|
|
878
|
+
TITLE_MAX_CHARS,
|
|
879
|
+
"Tighten it to a specific headline."
|
|
880
|
+
);
|
|
368
881
|
var decisionBodySchema = boundedText(
|
|
369
882
|
"body",
|
|
370
883
|
BODY_MAX_CHARS,
|
|
371
884
|
`Aim for ${BODY_RECOMMENDED_CHARS} characters or fewer. Preserve the rule, reason, and exceptions; put supporting references in sources.`
|
|
372
885
|
);
|
|
886
|
+
async function readDecisionInputs(rootDir) {
|
|
887
|
+
return structuredClone(
|
|
888
|
+
await inspectionRead(rootDir, "decision-store:inputs", async () => {
|
|
889
|
+
const inputs = [];
|
|
890
|
+
const diagnostics = [];
|
|
891
|
+
let entries;
|
|
892
|
+
try {
|
|
893
|
+
entries = await fs5.readdir(await storePath(rootDir, ".mason/decisions"));
|
|
894
|
+
} catch (error) {
|
|
895
|
+
if (error.code !== "ENOENT")
|
|
896
|
+
diagnostics.push({ path: ".mason/decisions", message: String(error) });
|
|
897
|
+
return { inputs, diagnostics };
|
|
898
|
+
}
|
|
899
|
+
for (const entry of entries.sort()) {
|
|
900
|
+
if (!entry.endsWith(".json")) continue;
|
|
901
|
+
const relative = `.mason/decisions/${entry}`;
|
|
902
|
+
try {
|
|
903
|
+
const raw = await readBoundedFile(await storePath(rootDir, relative), 10 * 1024 * 1024);
|
|
904
|
+
if (raw === null) throw new Error("file is not regular or exceeds 10 MiB");
|
|
905
|
+
inputs.push([relative, raw]);
|
|
906
|
+
} catch (error) {
|
|
907
|
+
if (error.code === "ENOENT") inputs.push([relative, null]);
|
|
908
|
+
else
|
|
909
|
+
diagnostics.push({
|
|
910
|
+
path: relative,
|
|
911
|
+
message: error instanceof Error ? error.message : String(error)
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
return { inputs, diagnostics };
|
|
916
|
+
})
|
|
917
|
+
);
|
|
918
|
+
}
|
|
373
919
|
async function loadDecisionStore(rootDir) {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
920
|
+
return structuredClone(
|
|
921
|
+
await inspectionRead(rootDir, "decision-store:parsed", async () => {
|
|
922
|
+
const raw = await readDecisionInputs(rootDir);
|
|
923
|
+
const records = [];
|
|
924
|
+
const diagnostics = [...raw.diagnostics];
|
|
925
|
+
for (const [relative, content] of raw.inputs) {
|
|
926
|
+
try {
|
|
927
|
+
const record = decisionSchema.parse(content === null ? null : JSON.parse(content));
|
|
928
|
+
if (relative !== `.mason/decisions/${record.id}.json`)
|
|
929
|
+
throw new Error("Record id does not match its filename");
|
|
930
|
+
records.push(record);
|
|
931
|
+
} catch (error) {
|
|
932
|
+
diagnostics.push({
|
|
933
|
+
path: relative,
|
|
934
|
+
message: error instanceof Error ? error.message : String(error)
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return { records, diagnostics };
|
|
939
|
+
})
|
|
940
|
+
);
|
|
395
941
|
}
|
|
396
942
|
|
|
397
943
|
// src/hook/hook.ts
|
|
398
|
-
import { createHash as
|
|
944
|
+
import { createHash as createHash4 } from "crypto";
|
|
399
945
|
|
|
400
946
|
// src/decisions/drift.ts
|
|
401
|
-
import
|
|
947
|
+
import path10 from "path";
|
|
402
948
|
|
|
403
949
|
// src/drift/drift.ts
|
|
404
|
-
import
|
|
405
|
-
import
|
|
950
|
+
import fs6 from "fs/promises";
|
|
951
|
+
import path9 from "path";
|
|
406
952
|
function parseChanges(output) {
|
|
407
953
|
const fields = output.split("\0");
|
|
408
954
|
const changes = [];
|
|
@@ -412,18 +958,25 @@ function parseChanges(output) {
|
|
|
412
958
|
if (!first) break;
|
|
413
959
|
const second = /^[RC]/.test(code) ? fields[i++] : void 0;
|
|
414
960
|
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 };
|
|
415
|
-
if (change.path.startsWith(".mason/") && (!change.previousPath || change.previousPath.startsWith(".mason/")))
|
|
961
|
+
if (change.path.startsWith(".mason/") && (!change.previousPath || change.previousPath.startsWith(".mason/")))
|
|
962
|
+
continue;
|
|
416
963
|
changes.push(change);
|
|
417
964
|
}
|
|
418
965
|
return changes;
|
|
419
966
|
}
|
|
420
967
|
function touchedPaths(changes) {
|
|
421
|
-
return [
|
|
968
|
+
return [
|
|
969
|
+
...new Set(changes.flatMap((c) => c.previousPath ? [c.previousPath, c.path] : [c.path]))
|
|
970
|
+
].sort();
|
|
422
971
|
}
|
|
423
972
|
async function getChangesWithStatus(resolvedRoot, fromHash, toHash = "HEAD") {
|
|
424
|
-
if (!fromHash || fromHash === "unknown" || fromHash.startsWith("-") || !toHash || toHash === "unknown" || toHash.startsWith("-"))
|
|
973
|
+
if (!fromHash || fromHash === "unknown" || fromHash.startsWith("-") || !toHash || toHash === "unknown" || toHash.startsWith("-"))
|
|
974
|
+
return null;
|
|
425
975
|
try {
|
|
426
|
-
const { stdout } = await execGit(
|
|
976
|
+
const { stdout } = await execGit(
|
|
977
|
+
["diff", "--name-status", "-z", "-M", fromHash, toHash, "--"],
|
|
978
|
+
{ cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }
|
|
979
|
+
);
|
|
427
980
|
return parseChanges(stdout);
|
|
428
981
|
} catch {
|
|
429
982
|
return null;
|
|
@@ -432,11 +985,23 @@ async function getChangesWithStatus(resolvedRoot, fromHash, toHash = "HEAD") {
|
|
|
432
985
|
async function getWorkingTree(resolvedRoot) {
|
|
433
986
|
try {
|
|
434
987
|
const [diff, untracked] = await Promise.all([
|
|
435
|
-
execGit(["diff", "--name-status", "-z", "-M", "HEAD", "--"], {
|
|
436
|
-
|
|
988
|
+
execGit(["diff", "--name-status", "-z", "-M", "HEAD", "--"], {
|
|
989
|
+
cwd: resolvedRoot,
|
|
990
|
+
maxBuffer: 10 * 1024 * 1024
|
|
991
|
+
}),
|
|
992
|
+
execGit(["ls-files", "-z", "--others", "--exclude-standard"], {
|
|
993
|
+
cwd: resolvedRoot,
|
|
994
|
+
maxBuffer: 10 * 1024 * 1024
|
|
995
|
+
})
|
|
437
996
|
]);
|
|
438
997
|
const untrackedFiles = untracked.stdout.split("\0").filter((f) => f && !f.startsWith(".mason/"));
|
|
439
|
-
return {
|
|
998
|
+
return {
|
|
999
|
+
available: true,
|
|
1000
|
+
changedFiles: [
|
|
1001
|
+
.../* @__PURE__ */ new Set([...touchedPaths(parseChanges(diff.stdout)), ...untrackedFiles])
|
|
1002
|
+
].sort(),
|
|
1003
|
+
untrackedFiles
|
|
1004
|
+
};
|
|
440
1005
|
} catch {
|
|
441
1006
|
return { available: false, changedFiles: [], untrackedFiles: [] };
|
|
442
1007
|
}
|
|
@@ -444,13 +1009,47 @@ async function getWorkingTree(resolvedRoot) {
|
|
|
444
1009
|
|
|
445
1010
|
// src/decisions/drift.ts
|
|
446
1011
|
async function computeDecisionDrift(rootDir, decisions) {
|
|
447
|
-
const resolvedRoot =
|
|
1012
|
+
const resolvedRoot = path10.resolve(rootDir);
|
|
448
1013
|
const store = decisions ? { records: decisions, diagnostics: [] } : await loadDecisionStore(resolvedRoot);
|
|
449
|
-
const report = {
|
|
450
|
-
|
|
1014
|
+
const report = {
|
|
1015
|
+
historyAvailable: true,
|
|
1016
|
+
totalDecisions: store.records.length,
|
|
1017
|
+
staleDecisions: {},
|
|
1018
|
+
freshness: {},
|
|
1019
|
+
diagnostics: store.diagnostics
|
|
1020
|
+
};
|
|
1021
|
+
const [head, workingTree] = await Promise.all([
|
|
1022
|
+
getCurrentGitHash(resolvedRoot),
|
|
1023
|
+
getWorkingTree(resolvedRoot)
|
|
1024
|
+
]);
|
|
451
1025
|
const changesByHash = /* @__PURE__ */ new Map();
|
|
452
|
-
const
|
|
1026
|
+
const baselines = store.records.filter((record) => record.status === "active").flatMap((record) => {
|
|
1027
|
+
const inspection = latestDecisionInspection(record);
|
|
1028
|
+
if (inspection) return [inspection.capture];
|
|
1029
|
+
return [.../* @__PURE__ */ new Set([effectiveDecision(record), record])].flatMap(
|
|
1030
|
+
(candidate) => candidate.version === 2 && candidate.capture ? [candidate.capture] : []
|
|
1031
|
+
);
|
|
1032
|
+
});
|
|
1033
|
+
const captures = await captureAnchorScopes(
|
|
1034
|
+
resolvedRoot,
|
|
1035
|
+
baselines.map((baseline) => baseline.anchors)
|
|
1036
|
+
);
|
|
1037
|
+
const currentCaptures = new Map(
|
|
1038
|
+
baselines.map((baseline, index) => [JSON.stringify(baseline.anchors), captures[index]])
|
|
1039
|
+
);
|
|
1040
|
+
const inspect = async (record, inspectionCapture) => {
|
|
453
1041
|
if (record.files.length === 0) return { freshness: "unknown", changedFiles: [] };
|
|
1042
|
+
const baseline = inspectionCapture ?? (record.version === 2 ? record.capture : void 0);
|
|
1043
|
+
if (baseline) {
|
|
1044
|
+
const current = currentCaptures.get(JSON.stringify(baseline.anchors));
|
|
1045
|
+
const hits2 = changedCaptureFiles(baseline, current).filter(
|
|
1046
|
+
(file) => matchingPaths(record.files, [file]).length
|
|
1047
|
+
);
|
|
1048
|
+
return {
|
|
1049
|
+
freshness: !baseline.complete || !current.complete ? "unknown" : hits2.length ? "changed" : "current",
|
|
1050
|
+
changedFiles: hits2
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
454
1053
|
let touched = changesByHash.get(record.refreshedHash);
|
|
455
1054
|
if (touched === void 0) {
|
|
456
1055
|
const changes = record.refreshedHash === head && head !== "unknown" ? [] : await getChangesWithStatus(resolvedRoot, record.refreshedHash);
|
|
@@ -460,15 +1059,20 @@ async function computeDecisionDrift(rootDir, decisions) {
|
|
|
460
1059
|
if (touched === null) report.historyAvailable = false;
|
|
461
1060
|
const hits = touched ? matchingPaths(record.files, touched) : [];
|
|
462
1061
|
const localHits = matchingPaths(record.files, workingTree.changedFiles);
|
|
463
|
-
return {
|
|
1062
|
+
return {
|
|
1063
|
+
freshness: touched === null || !workingTree.available ? "unknown" : hits.length || localHits.length ? "changed" : "current",
|
|
1064
|
+
changedFiles: [.../* @__PURE__ */ new Set([...hits, ...localHits])].sort()
|
|
1065
|
+
};
|
|
464
1066
|
};
|
|
465
1067
|
for (const record of store.records) {
|
|
466
1068
|
if (record.status !== "active") continue;
|
|
467
1069
|
const effective = effectiveDecision(record);
|
|
468
|
-
const
|
|
1070
|
+
const inspection = latestDecisionInspection(record);
|
|
1071
|
+
const state = await inspect(effective, inspection?.capture);
|
|
469
1072
|
report.freshness[record.id] = state.freshness;
|
|
470
1073
|
if (state.changedFiles.length) report.staleDecisions[record.id] = state.changedFiles;
|
|
471
|
-
if (effective !== record)
|
|
1074
|
+
if (effective !== record)
|
|
1075
|
+
(report.pendingProposals ??= {})[record.id] = await inspect(record, inspection?.capture);
|
|
472
1076
|
}
|
|
473
1077
|
return report;
|
|
474
1078
|
}
|
|
@@ -477,9 +1081,17 @@ async function computeDecisionDrift(rootDir, decisions) {
|
|
|
477
1081
|
var MAX_INJECTED_DECISIONS = 3;
|
|
478
1082
|
var MAX_WALK_UP = 30;
|
|
479
1083
|
var SUPPORTED_TOOLS = /* @__PURE__ */ new Set(["Read", "Edit", "Write"]);
|
|
1084
|
+
var hookStdinSchema = z6.object({
|
|
1085
|
+
session_id: z6.string().optional(),
|
|
1086
|
+
agent_id: z6.string().optional(),
|
|
1087
|
+
cwd: z6.string().optional(),
|
|
1088
|
+
hook_event_name: z6.string().optional(),
|
|
1089
|
+
tool_name: z6.string().optional(),
|
|
1090
|
+
tool_input: z6.object({ file_path: z6.string().optional() }).optional()
|
|
1091
|
+
});
|
|
480
1092
|
async function exists(p) {
|
|
481
1093
|
try {
|
|
482
|
-
await
|
|
1094
|
+
await fs7.access(p);
|
|
483
1095
|
return true;
|
|
484
1096
|
} catch {
|
|
485
1097
|
return false;
|
|
@@ -488,9 +1100,9 @@ async function exists(p) {
|
|
|
488
1100
|
async function findMasonRoot(startDir) {
|
|
489
1101
|
let dir = startDir;
|
|
490
1102
|
for (let i = 0; i < MAX_WALK_UP; i++) {
|
|
491
|
-
if (await exists(
|
|
492
|
-
if (await exists(
|
|
493
|
-
const parent =
|
|
1103
|
+
if (await exists(path11.join(dir, ".mason", "decisions"))) return dir;
|
|
1104
|
+
if (await exists(path11.join(dir, ".git"))) return null;
|
|
1105
|
+
const parent = path11.dirname(dir);
|
|
494
1106
|
if (parent === dir) return null;
|
|
495
1107
|
dir = parent;
|
|
496
1108
|
}
|
|
@@ -508,8 +1120,8 @@ function stateKey(input) {
|
|
|
508
1120
|
}
|
|
509
1121
|
async function loadInjected(stateFile) {
|
|
510
1122
|
try {
|
|
511
|
-
const parsed = JSON.parse(await
|
|
512
|
-
return new Set(
|
|
1123
|
+
const parsed = JSON.parse(await fs7.readFile(stateFile, "utf-8"));
|
|
1124
|
+
return new Set(isStringArray(parsed) ? parsed : []);
|
|
513
1125
|
} catch {
|
|
514
1126
|
return /* @__PURE__ */ new Set();
|
|
515
1127
|
}
|
|
@@ -517,7 +1129,7 @@ async function loadInjected(stateFile) {
|
|
|
517
1129
|
function formatContext(relPath, records, drift) {
|
|
518
1130
|
const lines = [];
|
|
519
1131
|
lines.push(
|
|
520
|
-
`Mason: decision knowledge relevant to ${relPath} or updated since this session saw it. This replaces earlier guidance for the same decision id. ${DECISION_GUIDANCE} Retired or superseded records are no longer active.
|
|
1132
|
+
`Mason: decision knowledge relevant to ${relPath} or updated since this session saw it. This replaces earlier guidance for the same decision id. ${DECISION_GUIDANCE} Retired or superseded records are no longer active. Use Mason tools to record decision changes and source inspections; do not edit the JSON in .mason/decisions/ directly.`
|
|
521
1133
|
);
|
|
522
1134
|
const append = (id, knowledge, label, freshness) => {
|
|
523
1135
|
const stale = freshness === "current" ? "" : freshness === "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]";
|
|
@@ -526,16 +1138,31 @@ function formatContext(relPath, records, drift) {
|
|
|
526
1138
|
);
|
|
527
1139
|
};
|
|
528
1140
|
for (const record of records) {
|
|
529
|
-
const knowledge = decisionKnowledge(
|
|
530
|
-
|
|
531
|
-
|
|
1141
|
+
const knowledge = decisionKnowledge(
|
|
1142
|
+
record,
|
|
1143
|
+
drift.freshness?.[record.id] ?? "unknown",
|
|
1144
|
+
drift.pendingProposals?.[record.id]?.freshness ?? "unknown"
|
|
1145
|
+
);
|
|
1146
|
+
append(
|
|
1147
|
+
record.id,
|
|
1148
|
+
knowledge,
|
|
1149
|
+
record.status === "active" ? knowledge.approval : record.status,
|
|
1150
|
+
knowledge.trust.freshness
|
|
1151
|
+
);
|
|
1152
|
+
if (knowledge.pendingProposal)
|
|
1153
|
+
append(
|
|
1154
|
+
record.id,
|
|
1155
|
+
knowledge.pendingProposal,
|
|
1156
|
+
"proposed",
|
|
1157
|
+
knowledge.pendingProposal.trust.freshness
|
|
1158
|
+
);
|
|
532
1159
|
}
|
|
533
1160
|
return lines.join("\n");
|
|
534
1161
|
}
|
|
535
1162
|
async function runHook(stdinText, env = {}) {
|
|
536
1163
|
let input;
|
|
537
1164
|
try {
|
|
538
|
-
input = JSON.parse(stdinText);
|
|
1165
|
+
input = hookStdinSchema.parse(JSON.parse(stdinText));
|
|
539
1166
|
} catch {
|
|
540
1167
|
return null;
|
|
541
1168
|
}
|
|
@@ -543,18 +1170,23 @@ async function runHook(stdinText, env = {}) {
|
|
|
543
1170
|
if (input.tool_name && !SUPPORTED_TOOLS.has(input.tool_name)) return null;
|
|
544
1171
|
const filePath = input.tool_input?.file_path;
|
|
545
1172
|
if (!filePath || typeof filePath !== "string") return null;
|
|
546
|
-
const absPath =
|
|
547
|
-
const root = await findMasonRoot(
|
|
1173
|
+
const absPath = path11.isAbsolute(filePath) ? filePath : path11.resolve(input.cwd ?? process.cwd(), filePath);
|
|
1174
|
+
const root = await findMasonRoot(path11.dirname(absPath));
|
|
548
1175
|
if (!root) return null;
|
|
549
|
-
const relPath =
|
|
1176
|
+
const relPath = path11.relative(root, absPath).split(path11.sep).join("/");
|
|
550
1177
|
if (relPath.startsWith("..")) return null;
|
|
551
1178
|
const { records } = await loadDecisionStore(root);
|
|
552
1179
|
const stateDir = env.stateDir ?? os2.tmpdir();
|
|
553
|
-
const stateFile =
|
|
1180
|
+
const stateFile = path11.join(
|
|
1181
|
+
stateDir,
|
|
1182
|
+
`mason-hook-${createHash4("sha256").update(root).digest("hex").slice(0, 12)}-${stateKey(input)}.json`
|
|
1183
|
+
);
|
|
554
1184
|
const injected = await loadInjected(stateFile);
|
|
555
|
-
const recordKey = (record) => `${record.id}:${
|
|
1185
|
+
const recordKey = (record) => `${record.id}:${createHash4("sha256").update(JSON.stringify(record)).digest("hex")}`;
|
|
556
1186
|
const previouslyInjected = (record) => [...injected].some((key) => key === record.id || key.startsWith(`${record.id}:`));
|
|
557
|
-
const fresh = records.filter(
|
|
1187
|
+
const fresh = records.filter(
|
|
1188
|
+
(r) => !injected.has(recordKey(r)) && (previouslyInjected(r) || r.status === "active" && anchorsCover(r, relPath))
|
|
1189
|
+
);
|
|
558
1190
|
if (fresh.length === 0) return null;
|
|
559
1191
|
fresh.sort((a, b) => {
|
|
560
1192
|
const withdrawn = Number(b.status !== "active") - Number(a.status !== "active");
|
|
@@ -567,7 +1199,7 @@ async function runHook(stdinText, env = {}) {
|
|
|
567
1199
|
const drift = await computeDecisionDrift(root, selected);
|
|
568
1200
|
for (const record of selected) injected.add(recordKey(record));
|
|
569
1201
|
try {
|
|
570
|
-
await
|
|
1202
|
+
await fs7.writeFile(stateFile, JSON.stringify([...injected]), "utf-8");
|
|
571
1203
|
} catch {
|
|
572
1204
|
}
|
|
573
1205
|
return JSON.stringify({
|
|
@@ -635,7 +1267,9 @@ async function readStdin() {
|
|
|
635
1267
|
if (process.stdin.isTTY) return "";
|
|
636
1268
|
const chunks = [];
|
|
637
1269
|
for await (const chunk of process.stdin) {
|
|
638
|
-
|
|
1270
|
+
const value = chunk;
|
|
1271
|
+
if (typeof value === "string" || value instanceof Uint8Array) chunks.push(Buffer.from(value));
|
|
1272
|
+
else throw new Error("Unsupported stdin chunk.");
|
|
639
1273
|
}
|
|
640
1274
|
return Buffer.concat(chunks).toString("utf-8");
|
|
641
1275
|
}
|