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-drift.js
CHANGED
|
@@ -31,7 +31,7 @@ var init_types = __esm({
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
// src/drift/cli.ts
|
|
34
|
-
import
|
|
34
|
+
import path11 from "path";
|
|
35
35
|
|
|
36
36
|
// src/utils/git-read.ts
|
|
37
37
|
import { execFile } from "child_process";
|
|
@@ -55,7 +55,19 @@ async function profilePhase(label, run) {
|
|
|
55
55
|
|
|
56
56
|
// src/utils/git-read.ts
|
|
57
57
|
var execute = promisify(execFile);
|
|
58
|
-
var commands = /* @__PURE__ */ new Set([
|
|
58
|
+
var commands = /* @__PURE__ */ new Set([
|
|
59
|
+
"rev-parse",
|
|
60
|
+
"symbolic-ref",
|
|
61
|
+
"for-each-ref",
|
|
62
|
+
"ls-files",
|
|
63
|
+
"check-ignore",
|
|
64
|
+
"status",
|
|
65
|
+
"log",
|
|
66
|
+
"diff",
|
|
67
|
+
"ls-tree",
|
|
68
|
+
"show",
|
|
69
|
+
"merge-base"
|
|
70
|
+
]);
|
|
59
71
|
function execGit(args, options) {
|
|
60
72
|
const command = args.find((arg) => !arg.startsWith("-")) ?? "other";
|
|
61
73
|
return profilePhase("git." + (commands.has(command) ? command : "other"), async () => {
|
|
@@ -77,6 +89,14 @@ import path6 from "path";
|
|
|
77
89
|
// src/snapshot/snapshot.ts
|
|
78
90
|
import path5 from "path";
|
|
79
91
|
|
|
92
|
+
// src/utils/validation.ts
|
|
93
|
+
function isRecord(value) {
|
|
94
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
95
|
+
}
|
|
96
|
+
function isStringArray(value) {
|
|
97
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
98
|
+
}
|
|
99
|
+
|
|
80
100
|
// src/utils/files.ts
|
|
81
101
|
import fs from "fs/promises";
|
|
82
102
|
import { constants } from "fs";
|
|
@@ -87,7 +107,8 @@ import fg from "fast-glob";
|
|
|
87
107
|
import path from "path";
|
|
88
108
|
function normalizeRepoPath(value) {
|
|
89
109
|
const slash = value.replace(/\\/g, "/");
|
|
90
|
-
if (!slash || slash.includes("\0") || path.posix.isAbsolute(slash) || /^[A-Za-z]:/.test(slash))
|
|
110
|
+
if (!slash || slash.includes("\0") || path.posix.isAbsolute(slash) || /^[A-Za-z]:/.test(slash))
|
|
111
|
+
return null;
|
|
91
112
|
if (slash.split("/").includes("..")) return null;
|
|
92
113
|
const normalized = path.posix.normalize(slash).replace(/\/$/, "");
|
|
93
114
|
return normalized === "." ? null : normalized;
|
|
@@ -102,11 +123,69 @@ function anchorMatches(anchor, file) {
|
|
|
102
123
|
return a !== null && f !== null && (a === f || f.startsWith(`${a}/`));
|
|
103
124
|
}
|
|
104
125
|
function matchingPaths(anchors, files) {
|
|
105
|
-
return [...new Set(files)].filter(
|
|
126
|
+
return [...new Set(files)].filter(
|
|
127
|
+
(file) => anchors.some((anchor) => anchorMatches(anchor, file))
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
function indexMatchingPaths(scopes, files) {
|
|
131
|
+
const node = () => ({ children: /* @__PURE__ */ new Map(), scopes: /* @__PURE__ */ new Set() });
|
|
132
|
+
const root = node();
|
|
133
|
+
const matches = scopes.map(() => []);
|
|
134
|
+
scopes.forEach((anchors, index) => {
|
|
135
|
+
for (const anchor of anchors) {
|
|
136
|
+
const normalized = normalizeRepoPath(anchor);
|
|
137
|
+
if (!normalized) continue;
|
|
138
|
+
let current = root;
|
|
139
|
+
for (const part of normalized.split("/")) {
|
|
140
|
+
if (!current.children.has(part)) current.children.set(part, node());
|
|
141
|
+
current = current.children.get(part);
|
|
142
|
+
}
|
|
143
|
+
current.scopes.add(index);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
for (const file of new Set(files)) {
|
|
147
|
+
const normalized = normalizeRepoPath(file);
|
|
148
|
+
if (!normalized) continue;
|
|
149
|
+
const selected = /* @__PURE__ */ new Set();
|
|
150
|
+
let current = root;
|
|
151
|
+
for (const part of normalized.split("/")) {
|
|
152
|
+
current = current.children.get(part);
|
|
153
|
+
if (!current) break;
|
|
154
|
+
for (const index of current.scopes) selected.add(index);
|
|
155
|
+
}
|
|
156
|
+
for (const index of selected) matches[index].push(file);
|
|
157
|
+
}
|
|
158
|
+
return matches;
|
|
106
159
|
}
|
|
107
160
|
|
|
108
161
|
// src/utils/files.ts
|
|
109
|
-
var SOURCE_EXTENSIONS = [
|
|
162
|
+
var SOURCE_EXTENSIONS = [
|
|
163
|
+
"ts",
|
|
164
|
+
"tsx",
|
|
165
|
+
"js",
|
|
166
|
+
"jsx",
|
|
167
|
+
"mts",
|
|
168
|
+
"cts",
|
|
169
|
+
"mjs",
|
|
170
|
+
"cjs",
|
|
171
|
+
"vue",
|
|
172
|
+
"svelte",
|
|
173
|
+
"kt",
|
|
174
|
+
"kts",
|
|
175
|
+
"java",
|
|
176
|
+
"py",
|
|
177
|
+
"go",
|
|
178
|
+
"rs",
|
|
179
|
+
"swift",
|
|
180
|
+
"rb",
|
|
181
|
+
"cs",
|
|
182
|
+
"cpp",
|
|
183
|
+
"c",
|
|
184
|
+
"h",
|
|
185
|
+
"hpp",
|
|
186
|
+
"dart",
|
|
187
|
+
"php"
|
|
188
|
+
];
|
|
110
189
|
var SOURCE_GLOB = `**/*.{${SOURCE_EXTENSIONS.join(",")}}`;
|
|
111
190
|
var SOURCE_IGNORE = [
|
|
112
191
|
"**/node_modules/**",
|
|
@@ -134,11 +213,16 @@ var SOURCE_IGNORE = [
|
|
|
134
213
|
var MAX_SOURCE_BYTES = 1024 * 1024;
|
|
135
214
|
function isSensitiveFile(file) {
|
|
136
215
|
return file.split(/[\\/]/).some(
|
|
137
|
-
(part) => /^(?:\.env(?:\..*)?|id_rsa.*|id_ed25519.*)$|\.(?:pem|key|p12|pfx|jks|keystore)$|credentials\.|secret|^local\.properties$/i.test(
|
|
216
|
+
(part) => /^(?:\.env(?:\..*)?|id_rsa.*|id_ed25519.*)$|\.(?:pem|key|p12|pfx|jks|keystore)$|credentials\.|secret|^local\.properties$/i.test(
|
|
217
|
+
part
|
|
218
|
+
)
|
|
138
219
|
);
|
|
139
220
|
}
|
|
140
221
|
async function readBoundedFile(file, maxBytes) {
|
|
141
|
-
const handle = await fs.open(
|
|
222
|
+
const handle = await fs.open(
|
|
223
|
+
file,
|
|
224
|
+
constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW
|
|
225
|
+
);
|
|
142
226
|
try {
|
|
143
227
|
const stat = await handle.stat();
|
|
144
228
|
if (!stat.isFile() || stat.size > maxBytes) return null;
|
|
@@ -158,36 +242,44 @@ async function loadProjectConfig(root) {
|
|
|
158
242
|
try {
|
|
159
243
|
const canonicalRoot = await fs.realpath(root);
|
|
160
244
|
const configPath = await fs.realpath(path2.join(root, ".mason/config.json"));
|
|
161
|
-
if (!isWithinRoot(canonicalRoot, configPath))
|
|
245
|
+
if (!isWithinRoot(canonicalRoot, configPath))
|
|
246
|
+
throw new Error("Project configuration resolves outside the repository");
|
|
162
247
|
const raw = await readBoundedFile(configPath, 64 * 1024);
|
|
163
|
-
if (raw === null)
|
|
248
|
+
if (raw === null)
|
|
249
|
+
throw new Error("Project configuration is not a regular file or exceeds 64 KiB");
|
|
164
250
|
const value = JSON.parse(raw);
|
|
165
|
-
if (!
|
|
251
|
+
if (!isRecord(value)) throw new Error("Expected a configuration object");
|
|
166
252
|
const config = {};
|
|
167
253
|
for (const key of ["patterns", "alwaysInclude", "ignore"]) {
|
|
168
254
|
if (value[key] === void 0) continue;
|
|
169
|
-
if (!
|
|
255
|
+
if (!isStringArray(value[key])) {
|
|
170
256
|
throw new Error(`Configuration ${key} must be an array of strings`);
|
|
171
257
|
}
|
|
172
258
|
config[key] = value[key];
|
|
173
259
|
}
|
|
174
260
|
if (value.audit !== void 0) {
|
|
175
261
|
const audit = value.audit;
|
|
176
|
-
if (!
|
|
262
|
+
if (!isRecord(audit)) throw new Error("Configuration audit must be an object");
|
|
177
263
|
const patterns = (raw2) => {
|
|
178
|
-
if (!
|
|
179
|
-
|
|
264
|
+
if (!isStringArray(raw2) || raw2.length > 100 || !raw2.every(
|
|
265
|
+
(s) => typeof s === "string" && normalizeRepoPath(s) === s && !s.startsWith("!") && !/[\x00-\x1f]/.test(s)
|
|
266
|
+
)) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
"Audit patterns must be at most 100 repository-relative globs without traversal or negation"
|
|
269
|
+
);
|
|
180
270
|
}
|
|
181
271
|
return raw2;
|
|
182
272
|
};
|
|
183
273
|
config.audit = {};
|
|
184
274
|
if (audit.include !== void 0) config.audit.include = patterns(audit.include);
|
|
185
275
|
if (audit.exclude !== void 0) {
|
|
186
|
-
if (!
|
|
276
|
+
if (!isRecord(audit.exclude))
|
|
277
|
+
throw new Error("audit.exclude must map check names to patterns");
|
|
187
278
|
const { ALL_CHECKS: ALL_CHECKS2 } = await Promise.resolve().then(() => (init_types(), types_exports));
|
|
188
279
|
config.audit.exclude = {};
|
|
189
280
|
for (const [check, globs] of Object.entries(audit.exclude)) {
|
|
190
|
-
if (!ALL_CHECKS2.includes(check))
|
|
281
|
+
if (!ALL_CHECKS2.includes(check))
|
|
282
|
+
throw new Error("Unknown audit check: " + check);
|
|
191
283
|
config.audit.exclude[check] = patterns(globs);
|
|
192
284
|
}
|
|
193
285
|
}
|
|
@@ -195,7 +287,10 @@ async function loadProjectConfig(root) {
|
|
|
195
287
|
return config;
|
|
196
288
|
} catch (error) {
|
|
197
289
|
if (error.code === "ENOENT") return {};
|
|
198
|
-
throw new Error(
|
|
290
|
+
throw new Error(
|
|
291
|
+
`Cannot apply project file policy: ${error instanceof Error ? error.message : String(error)}`,
|
|
292
|
+
{ cause: error }
|
|
293
|
+
);
|
|
199
294
|
}
|
|
200
295
|
}
|
|
201
296
|
async function createFileAccess(rootDir) {
|
|
@@ -205,7 +300,10 @@ async function createFileAccess(rootDir) {
|
|
|
205
300
|
const ignore = [...SOURCE_IGNORE, ...config.ignore ?? []];
|
|
206
301
|
let gitFiles = null;
|
|
207
302
|
try {
|
|
208
|
-
const { stdout } = await execGit(
|
|
303
|
+
const { stdout } = await execGit(
|
|
304
|
+
["ls-files", "-z", "--cached", "--others", "--exclude-standard"],
|
|
305
|
+
{ cwd: root, maxBuffer: 50 * 1024 * 1024 }
|
|
306
|
+
);
|
|
209
307
|
gitFiles = new Set(stdout.split("\0").filter(Boolean));
|
|
210
308
|
} catch {
|
|
211
309
|
let inGit = false;
|
|
@@ -218,14 +316,17 @@ async function createFileAccess(rootDir) {
|
|
|
218
316
|
}
|
|
219
317
|
async function resolve(file) {
|
|
220
318
|
const relative = normalizeRepoPath(file);
|
|
221
|
-
if (!relative || isSensitiveFile(relative) || gitFiles && !gitFiles.has(relative))
|
|
319
|
+
if (!relative || isSensitiveFile(relative) || gitFiles && !gitFiles.has(relative))
|
|
320
|
+
return null;
|
|
222
321
|
const candidate = path2.join(root, relative);
|
|
223
322
|
try {
|
|
224
323
|
const real = await fs.realpath(candidate);
|
|
225
|
-
if (!isWithinRoot(canonicalRoot, real) || isSensitiveFile(path2.relative(canonicalRoot, real)))
|
|
324
|
+
if (!isWithinRoot(canonicalRoot, real) || isSensitiveFile(path2.relative(canonicalRoot, real)))
|
|
325
|
+
return null;
|
|
226
326
|
const stat = await fs.stat(real);
|
|
227
327
|
if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES) return null;
|
|
228
|
-
if (gitFiles && !gitFiles.has(path2.relative(canonicalRoot, real).split(path2.sep).join("/")))
|
|
328
|
+
if (gitFiles && !gitFiles.has(path2.relative(canonicalRoot, real).split(path2.sep).join("/")))
|
|
329
|
+
return null;
|
|
229
330
|
return real;
|
|
230
331
|
} catch {
|
|
231
332
|
return null;
|
|
@@ -241,7 +342,10 @@ async function createFileAccess(rootDir) {
|
|
|
241
342
|
if (!relative) return null;
|
|
242
343
|
const real = await resolve(relative);
|
|
243
344
|
if (!real) return null;
|
|
244
|
-
for (const rel of /* @__PURE__ */ new Set([
|
|
345
|
+
for (const rel of /* @__PURE__ */ new Set([
|
|
346
|
+
relative,
|
|
347
|
+
path2.relative(canonicalRoot, real).split(path2.sep).join("/")
|
|
348
|
+
])) {
|
|
245
349
|
if (!(await fg(fg.escapePath(rel), { cwd: root, ignore, dot: true })).length) return null;
|
|
246
350
|
}
|
|
247
351
|
try {
|
|
@@ -251,7 +355,7 @@ async function createFileAccess(rootDir) {
|
|
|
251
355
|
return null;
|
|
252
356
|
}
|
|
253
357
|
}
|
|
254
|
-
return { root, config, list, read };
|
|
358
|
+
return { root, config, list, read, inventory: () => gitFiles ? [...gitFiles].sort() : null };
|
|
255
359
|
}
|
|
256
360
|
|
|
257
361
|
// src/utils/storage.ts
|
|
@@ -293,45 +397,50 @@ async function readStoreJson(root, relative) {
|
|
|
293
397
|
return parsed;
|
|
294
398
|
} catch (error) {
|
|
295
399
|
if (error.code === "ENOENT") return null;
|
|
296
|
-
throw new Error(
|
|
400
|
+
throw new Error(
|
|
401
|
+
`Invalid Mason store ${relative}: ${error instanceof Error ? error.message : String(error)}`,
|
|
402
|
+
{ cause: error }
|
|
403
|
+
);
|
|
297
404
|
}
|
|
298
405
|
}
|
|
299
406
|
|
|
300
407
|
// src/snapshot/snapshot.ts
|
|
301
|
-
import { z } from "zod";
|
|
408
|
+
import { z as z2 } from "zod";
|
|
302
409
|
|
|
303
410
|
// src/utils/store-lock.ts
|
|
411
|
+
import { z } from "zod";
|
|
304
412
|
import fs3 from "fs/promises";
|
|
305
413
|
import os from "os";
|
|
414
|
+
var lockOwnerSchema = z.object({ host: z.string(), pid: z.number().int().positive() });
|
|
306
415
|
|
|
307
416
|
// src/test-map.ts
|
|
308
417
|
import path4 from "path";
|
|
309
418
|
|
|
310
419
|
// src/snapshot/snapshot.ts
|
|
311
|
-
var repoPath =
|
|
420
|
+
var repoPath = z2.string().refine((value) => normalizeRepoPath(value) !== null, "Expected a relative repository path");
|
|
312
421
|
var verificationFields = {
|
|
313
|
-
refreshedHash:
|
|
314
|
-
verifiedAt:
|
|
315
|
-
verifiedHash:
|
|
316
|
-
verificationToken:
|
|
317
|
-
verificationFailed:
|
|
318
|
-
verificationNote:
|
|
422
|
+
refreshedHash: z2.string().optional(),
|
|
423
|
+
verifiedAt: z2.string().optional(),
|
|
424
|
+
verifiedHash: z2.string().optional(),
|
|
425
|
+
verificationToken: z2.string().optional(),
|
|
426
|
+
verificationFailed: z2.boolean().optional(),
|
|
427
|
+
verificationNote: z2.string().optional()
|
|
319
428
|
};
|
|
320
|
-
var featureSchema =
|
|
321
|
-
description:
|
|
322
|
-
files:
|
|
323
|
-
tests:
|
|
324
|
-
type:
|
|
429
|
+
var featureSchema = z2.object({
|
|
430
|
+
description: z2.string(),
|
|
431
|
+
files: z2.array(repoPath),
|
|
432
|
+
tests: z2.array(repoPath).optional(),
|
|
433
|
+
type: z2.enum(["capability", "infrastructure"]).optional(),
|
|
325
434
|
...verificationFields
|
|
326
435
|
}).passthrough();
|
|
327
|
-
var flowSchema =
|
|
328
|
-
var snapshotSchema =
|
|
329
|
-
version:
|
|
330
|
-
createdAt:
|
|
331
|
-
updatedAt:
|
|
332
|
-
gitHash:
|
|
333
|
-
features:
|
|
334
|
-
flows:
|
|
436
|
+
var flowSchema = z2.object({ description: z2.string(), chain: z2.array(repoPath), ...verificationFields }).passthrough();
|
|
437
|
+
var snapshotSchema = z2.object({
|
|
438
|
+
version: z2.literal(2),
|
|
439
|
+
createdAt: z2.string(),
|
|
440
|
+
updatedAt: z2.string(),
|
|
441
|
+
gitHash: z2.string(),
|
|
442
|
+
features: z2.record(featureSchema),
|
|
443
|
+
flows: z2.record(flowSchema)
|
|
335
444
|
}).passthrough();
|
|
336
445
|
async function loadSnapshot(rootDir) {
|
|
337
446
|
const parsed = await readStoreJson(rootDir, ".mason/snapshot.json");
|
|
@@ -366,18 +475,25 @@ function parseChanges(output) {
|
|
|
366
475
|
if (!first) break;
|
|
367
476
|
const second = /^[RC]/.test(code) ? fields[i++] : void 0;
|
|
368
477
|
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 };
|
|
369
|
-
if (change.path.startsWith(".mason/") && (!change.previousPath || change.previousPath.startsWith(".mason/")))
|
|
478
|
+
if (change.path.startsWith(".mason/") && (!change.previousPath || change.previousPath.startsWith(".mason/")))
|
|
479
|
+
continue;
|
|
370
480
|
changes.push(change);
|
|
371
481
|
}
|
|
372
482
|
return changes;
|
|
373
483
|
}
|
|
374
484
|
function touchedPaths(changes) {
|
|
375
|
-
return [
|
|
485
|
+
return [
|
|
486
|
+
...new Set(changes.flatMap((c) => c.previousPath ? [c.previousPath, c.path] : [c.path]))
|
|
487
|
+
].sort();
|
|
376
488
|
}
|
|
377
489
|
async function getChangesWithStatus(resolvedRoot, fromHash, toHash = "HEAD") {
|
|
378
|
-
if (!fromHash || fromHash === "unknown" || fromHash.startsWith("-") || !toHash || toHash === "unknown" || toHash.startsWith("-"))
|
|
490
|
+
if (!fromHash || fromHash === "unknown" || fromHash.startsWith("-") || !toHash || toHash === "unknown" || toHash.startsWith("-"))
|
|
491
|
+
return null;
|
|
379
492
|
try {
|
|
380
|
-
const { stdout } = await execGit(
|
|
493
|
+
const { stdout } = await execGit(
|
|
494
|
+
["diff", "--name-status", "-z", "-M", fromHash, toHash, "--"],
|
|
495
|
+
{ cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }
|
|
496
|
+
);
|
|
381
497
|
return parseChanges(stdout);
|
|
382
498
|
} catch {
|
|
383
499
|
return null;
|
|
@@ -386,11 +502,23 @@ async function getChangesWithStatus(resolvedRoot, fromHash, toHash = "HEAD") {
|
|
|
386
502
|
async function getWorkingTree(resolvedRoot) {
|
|
387
503
|
try {
|
|
388
504
|
const [diff, untracked] = await Promise.all([
|
|
389
|
-
execGit(["diff", "--name-status", "-z", "-M", "HEAD", "--"], {
|
|
390
|
-
|
|
505
|
+
execGit(["diff", "--name-status", "-z", "-M", "HEAD", "--"], {
|
|
506
|
+
cwd: resolvedRoot,
|
|
507
|
+
maxBuffer: 10 * 1024 * 1024
|
|
508
|
+
}),
|
|
509
|
+
execGit(["ls-files", "-z", "--others", "--exclude-standard"], {
|
|
510
|
+
cwd: resolvedRoot,
|
|
511
|
+
maxBuffer: 10 * 1024 * 1024
|
|
512
|
+
})
|
|
391
513
|
]);
|
|
392
514
|
const untrackedFiles = untracked.stdout.split("\0").filter((f) => f && !f.startsWith(".mason/"));
|
|
393
|
-
return {
|
|
515
|
+
return {
|
|
516
|
+
available: true,
|
|
517
|
+
changedFiles: [
|
|
518
|
+
.../* @__PURE__ */ new Set([...touchedPaths(parseChanges(diff.stdout)), ...untrackedFiles])
|
|
519
|
+
].sort(),
|
|
520
|
+
untrackedFiles
|
|
521
|
+
};
|
|
394
522
|
} catch {
|
|
395
523
|
return { available: false, changedFiles: [], untrackedFiles: [] };
|
|
396
524
|
}
|
|
@@ -398,10 +526,9 @@ async function getWorkingTree(resolvedRoot) {
|
|
|
398
526
|
async function countCommitsBehind(resolvedRoot, fromHash) {
|
|
399
527
|
if (!fromHash || fromHash === "unknown" || fromHash.startsWith("-")) return null;
|
|
400
528
|
try {
|
|
401
|
-
const { stdout } = await execGit(
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
);
|
|
529
|
+
const { stdout } = await execGit(["rev-list", "--count", `${fromHash}..HEAD`], {
|
|
530
|
+
cwd: resolvedRoot
|
|
531
|
+
});
|
|
405
532
|
const count = Number.parseInt(stdout.trim(), 10);
|
|
406
533
|
return Number.isNaN(count) ? null : count;
|
|
407
534
|
} catch {
|
|
@@ -434,14 +561,22 @@ async function computeDrift(rootDir) {
|
|
|
434
561
|
const root = path6.resolve(rootDir);
|
|
435
562
|
const snapshot = await loadSnapshot(root);
|
|
436
563
|
if (!snapshot) return null;
|
|
437
|
-
const [headHash, workingTree] = await Promise.all([
|
|
564
|
+
const [headHash, workingTree] = await Promise.all([
|
|
565
|
+
getCurrentGitHash(root),
|
|
566
|
+
getWorkingTree(root)
|
|
567
|
+
]);
|
|
438
568
|
const hashFor = (entry) => entry.refreshedHash ?? snapshot.gitHash;
|
|
439
569
|
const entries = [...Object.values(snapshot.features), ...Object.values(snapshot.flows)];
|
|
440
570
|
const hashes = /* @__PURE__ */ new Set([snapshot.gitHash, ...entries.map(hashFor)]);
|
|
441
571
|
const changesByHash = /* @__PURE__ */ new Map();
|
|
442
|
-
await Promise.all(
|
|
443
|
-
|
|
444
|
-
|
|
572
|
+
await Promise.all(
|
|
573
|
+
[...hashes].map(async (hash) => {
|
|
574
|
+
changesByHash.set(
|
|
575
|
+
hash,
|
|
576
|
+
hash === headHash && headHash !== "unknown" ? [] : await getChangesWithStatus(root, hash)
|
|
577
|
+
);
|
|
578
|
+
})
|
|
579
|
+
);
|
|
445
580
|
const historyAvailable = headHash !== "unknown" && [...changesByHash.values()].every((changes) => changes !== null);
|
|
446
581
|
const mappedFiles = collectMappedFiles(snapshot);
|
|
447
582
|
const report = {
|
|
@@ -467,7 +602,9 @@ async function computeDrift(rootDir) {
|
|
|
467
602
|
failed: [...Object.entries(snapshot.features), ...Object.entries(snapshot.flows)].filter(([, e]) => e.verificationFailed).map(([name]) => name)
|
|
468
603
|
}
|
|
469
604
|
};
|
|
470
|
-
const counts = await Promise.all(
|
|
605
|
+
const counts = await Promise.all(
|
|
606
|
+
[...hashes].map(async (hash) => hash === headHash ? 0 : countCommitsBehind(root, hash))
|
|
607
|
+
);
|
|
471
608
|
const knownCounts = counts.filter((n) => n !== null);
|
|
472
609
|
report.commitsBehind = knownCounts.length ? Math.max(...knownCounts) : null;
|
|
473
610
|
const check = (name, files, hash, staleEntries, freshness) => {
|
|
@@ -478,7 +615,13 @@ async function computeDrift(rootDir) {
|
|
|
478
615
|
freshness[name] = files.length === 0 || changes === null || changes === void 0 || !workingTree.available ? "unknown" : committedHits.length || localHits.length || files.some((f) => report.ghostFiles.includes(f)) ? "changed" : "current";
|
|
479
616
|
};
|
|
480
617
|
for (const [name, feature] of Object.entries(snapshot.features)) {
|
|
481
|
-
check(
|
|
618
|
+
check(
|
|
619
|
+
name,
|
|
620
|
+
[...feature.files, ...feature.tests ?? []],
|
|
621
|
+
hashFor(feature),
|
|
622
|
+
report.staleFeatures,
|
|
623
|
+
report.featureFreshness
|
|
624
|
+
);
|
|
482
625
|
}
|
|
483
626
|
for (const [name, flow] of Object.entries(snapshot.flows)) {
|
|
484
627
|
check(name, flow.chain, hashFor(flow), report.staleFlows, report.flowFreshness);
|
|
@@ -488,7 +631,10 @@ async function computeDrift(rootDir) {
|
|
|
488
631
|
const sourceFiles = new Set(await listSourceFiles(root));
|
|
489
632
|
let committedFiles = /* @__PURE__ */ new Set();
|
|
490
633
|
try {
|
|
491
|
-
const { stdout } = await execGit(["ls-tree", "-r", "--name-only", "-z", "HEAD"], {
|
|
634
|
+
const { stdout } = await execGit(["ls-tree", "-r", "--name-only", "-z", "HEAD"], {
|
|
635
|
+
cwd: root,
|
|
636
|
+
maxBuffer: 50 * 1024 * 1024
|
|
637
|
+
});
|
|
492
638
|
committedFiles = new Set(stdout.split("\0").filter(Boolean));
|
|
493
639
|
} catch {
|
|
494
640
|
report.historyAvailable = false;
|
|
@@ -497,123 +643,322 @@ async function computeDrift(rootDir) {
|
|
|
497
643
|
report.unmappedFiles = [...sourceFiles].filter((f) => committedFiles.has(f) && !mappedFiles.has(f)).sort();
|
|
498
644
|
const renames = /* @__PURE__ */ new Map();
|
|
499
645
|
for (const change of allChanges) {
|
|
500
|
-
if (change.status === "renamed" && change.previousPath)
|
|
646
|
+
if (change.status === "renamed" && change.previousPath)
|
|
647
|
+
renames.set(`${change.previousPath}\0${change.path}`, {
|
|
648
|
+
from: change.previousPath,
|
|
649
|
+
to: change.path
|
|
650
|
+
});
|
|
501
651
|
}
|
|
502
652
|
report.renames = [...renames.values()];
|
|
503
|
-
const changedMapped = /* @__PURE__ */ new Set([
|
|
653
|
+
const changedMapped = /* @__PURE__ */ new Set([
|
|
654
|
+
...Object.values(report.staleFeatures).flat(),
|
|
655
|
+
...Object.values(report.staleFlows).flat()
|
|
656
|
+
]);
|
|
504
657
|
const committedGhosts = report.ghostFiles.filter((f) => !workingTree.changedFiles.includes(f));
|
|
505
658
|
report.stale ||= changedMapped.size > 0 || report.unmappedFiles.length > 0 || committedGhosts.length > 0;
|
|
506
659
|
if (!report.historyAvailable) report.recommendation = "full-rebuild";
|
|
507
660
|
else if (!report.stale) report.recommendation = "up-to-date";
|
|
508
|
-
else
|
|
661
|
+
else
|
|
662
|
+
report.recommendation = changedMapped.size >= FULL_REBUILD_MIN_CHANGED_MAPPED_FILES && changedMapped.size / Math.max(1, mappedFiles.size) > FULL_REBUILD_FRACTION ? "full-rebuild" : "incremental";
|
|
509
663
|
return report;
|
|
510
664
|
}
|
|
511
665
|
|
|
666
|
+
// src/decisions/anchors.ts
|
|
667
|
+
import { createHash } from "crypto";
|
|
668
|
+
import fs5 from "fs/promises";
|
|
669
|
+
import { constants as constants2 } from "fs";
|
|
670
|
+
import { z as z3 } from "zod";
|
|
671
|
+
import fg3 from "fast-glob";
|
|
672
|
+
|
|
673
|
+
// src/audit/inspection.ts
|
|
674
|
+
import fg2 from "fast-glob";
|
|
675
|
+
import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
|
|
676
|
+
import path7 from "path";
|
|
677
|
+
var inspections = new AsyncLocalStorage2();
|
|
678
|
+
function inspectionRead(root, key, read) {
|
|
679
|
+
const inspection = inspections.getStore();
|
|
680
|
+
if (!inspection || inspection.root !== path7.resolve(root)) return read();
|
|
681
|
+
if (!inspection.reads.has(key)) {
|
|
682
|
+
const pending = read();
|
|
683
|
+
inspection.reads.set(key, pending);
|
|
684
|
+
void pending.catch(() => {
|
|
685
|
+
if (inspection.reads.get(key) === pending) inspection.reads.delete(key);
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
return inspection.reads.get(key);
|
|
689
|
+
}
|
|
690
|
+
function inspectionGit(args, options) {
|
|
691
|
+
return inspectionRead(
|
|
692
|
+
options.cwd,
|
|
693
|
+
"git:" + JSON.stringify([args, options]),
|
|
694
|
+
() => execGit(args, options)
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
var sourceSpecs = SOURCE_EXTENSIONS.map((extension) => `:(glob)**/*.${extension}`);
|
|
698
|
+
var sourceSuffixes = new Set(SOURCE_EXTENSIONS.map((extension) => "." + extension));
|
|
699
|
+
|
|
700
|
+
// src/decisions/anchors.ts
|
|
701
|
+
var repoPath2 = z3.string().refine((p) => normalizeRepoPath(p) === p);
|
|
702
|
+
var anchorCaptureSchema = z3.object({
|
|
703
|
+
anchors: z3.array(repoPath2),
|
|
704
|
+
files: z3.record(repoPath2, z3.string().regex(/^[a-f0-9]{64}$/)),
|
|
705
|
+
complete: z3.boolean()
|
|
706
|
+
}).refine(
|
|
707
|
+
(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,
|
|
708
|
+
"Capture paths must belong to its normalized, sorted anchor scope"
|
|
709
|
+
);
|
|
710
|
+
async function captureAnchorScopes(root, scopes) {
|
|
711
|
+
const union = [...new Set(scopes.flat())].sort();
|
|
712
|
+
let context;
|
|
713
|
+
const prepareContext = async () => {
|
|
714
|
+
const access = await inspectionRead(
|
|
715
|
+
root,
|
|
716
|
+
"decision-anchor-access",
|
|
717
|
+
() => createFileAccess(root)
|
|
718
|
+
);
|
|
719
|
+
const inventory = access.inventory();
|
|
720
|
+
if (!inventory) throw new Error("Git anchor inventory unavailable");
|
|
721
|
+
const ignored = await inspectionGit(
|
|
722
|
+
[
|
|
723
|
+
"ls-files",
|
|
724
|
+
"-z",
|
|
725
|
+
"--others",
|
|
726
|
+
"--ignored",
|
|
727
|
+
"--exclude-standard",
|
|
728
|
+
"--",
|
|
729
|
+
...union.map((anchor) => `:(literal)${anchor}`)
|
|
730
|
+
],
|
|
731
|
+
{ cwd: root, maxBuffer: 8 * 1024 * 1024 }
|
|
732
|
+
);
|
|
733
|
+
const files = [
|
|
734
|
+
.../* @__PURE__ */ new Set([...inventory, ...ignored.stdout.split("\0").filter(Boolean)])
|
|
735
|
+
].sort();
|
|
736
|
+
const patterns = await Promise.all(
|
|
737
|
+
union.map(async (anchor) => {
|
|
738
|
+
const literal = fg3.escapePath(anchor);
|
|
739
|
+
try {
|
|
740
|
+
return (await fs5.lstat(await storePath(root, anchor))).isDirectory() ? `${literal}/**/*` : literal;
|
|
741
|
+
} catch {
|
|
742
|
+
return literal;
|
|
743
|
+
}
|
|
744
|
+
})
|
|
745
|
+
);
|
|
746
|
+
const eligible = new Set(await access.list(patterns, { dot: true }));
|
|
747
|
+
const indexed = indexMatchingPaths(scopes, files);
|
|
748
|
+
const filesByScope = new Map(
|
|
749
|
+
scopes.map((scope, index) => [JSON.stringify([...new Set(scope)].sort()), indexed[index]])
|
|
750
|
+
);
|
|
751
|
+
return { filesByScope, eligible };
|
|
752
|
+
};
|
|
753
|
+
return Promise.all(
|
|
754
|
+
scopes.map((scope) => {
|
|
755
|
+
const anchors = [...new Set(scope)].sort();
|
|
756
|
+
return inspectionRead(
|
|
757
|
+
root,
|
|
758
|
+
"decision-anchor-capture:" + JSON.stringify(anchors),
|
|
759
|
+
async () => {
|
|
760
|
+
const capture = { anchors, files: {}, complete: true };
|
|
761
|
+
if (!anchors.length) return capture;
|
|
762
|
+
try {
|
|
763
|
+
const { eligible, filesByScope } = await (context ??= prepareContext());
|
|
764
|
+
const files = filesByScope.get(JSON.stringify(anchors));
|
|
765
|
+
if (files.length > 2e3) return { ...capture, complete: false };
|
|
766
|
+
for (const file of files) {
|
|
767
|
+
const evidence = await inspectionRead(
|
|
768
|
+
root,
|
|
769
|
+
"decision-anchor-file:" + file,
|
|
770
|
+
async () => {
|
|
771
|
+
try {
|
|
772
|
+
const absolute = await storePath(root, file);
|
|
773
|
+
await fs5.lstat(absolute);
|
|
774
|
+
if (!eligible.has(file)) return { complete: false };
|
|
775
|
+
const handle = await fs5.open(
|
|
776
|
+
absolute,
|
|
777
|
+
constants2.O_RDONLY | constants2.O_NONBLOCK | constants2.O_NOFOLLOW
|
|
778
|
+
);
|
|
779
|
+
try {
|
|
780
|
+
const stat = await handle.stat();
|
|
781
|
+
if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES)
|
|
782
|
+
return { complete: false };
|
|
783
|
+
const bytes = Buffer.alloc(Math.min(stat.size + 1, MAX_SOURCE_BYTES + 1));
|
|
784
|
+
let size = 0;
|
|
785
|
+
while (size < bytes.length) {
|
|
786
|
+
const read = await handle.read(bytes, size, bytes.length - size, null);
|
|
787
|
+
if (!read.bytesRead) break;
|
|
788
|
+
size += read.bytesRead;
|
|
789
|
+
}
|
|
790
|
+
if (size === bytes.length) return { complete: false };
|
|
791
|
+
return {
|
|
792
|
+
complete: true,
|
|
793
|
+
hash: createHash("sha256").update(bytes.subarray(0, size)).digest("hex")
|
|
794
|
+
};
|
|
795
|
+
} finally {
|
|
796
|
+
await handle.close();
|
|
797
|
+
}
|
|
798
|
+
} catch (error) {
|
|
799
|
+
return { complete: error.code === "ENOENT" };
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
);
|
|
803
|
+
if (!evidence.complete) capture.complete = false;
|
|
804
|
+
if (evidence.hash) capture.files[file] = evidence.hash;
|
|
805
|
+
}
|
|
806
|
+
} catch {
|
|
807
|
+
capture.complete = false;
|
|
808
|
+
}
|
|
809
|
+
return capture;
|
|
810
|
+
}
|
|
811
|
+
);
|
|
812
|
+
})
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
function changedCaptureFiles(before, after) {
|
|
816
|
+
return [.../* @__PURE__ */ new Set([...Object.keys(before.files), ...Object.keys(after.files)])].filter((file) => before.files[file] !== after.files[file]).sort();
|
|
817
|
+
}
|
|
818
|
+
|
|
512
819
|
// src/decisions/drift.ts
|
|
513
|
-
import
|
|
820
|
+
import path10 from "path";
|
|
514
821
|
|
|
515
822
|
// src/decisions/decisions.ts
|
|
516
|
-
import
|
|
517
|
-
import
|
|
518
|
-
import { createHash } from "crypto";
|
|
519
|
-
import { z as
|
|
823
|
+
import fs6 from "fs/promises";
|
|
824
|
+
import path9 from "path";
|
|
825
|
+
import { createHash as createHash3 } from "crypto";
|
|
826
|
+
import { z as z5 } from "zod";
|
|
520
827
|
|
|
521
828
|
// src/context/lexical.ts
|
|
522
|
-
import
|
|
829
|
+
import path8 from "path";
|
|
523
830
|
|
|
524
831
|
// src/decisions/provenance.ts
|
|
525
|
-
import { z as
|
|
526
|
-
|
|
527
|
-
var
|
|
528
|
-
|
|
832
|
+
import { z as z4 } from "zod";
|
|
833
|
+
import { createHash as createHash2 } from "crypto";
|
|
834
|
+
var text = (max) => z4.string().trim().min(1).max(max);
|
|
835
|
+
var decisionSourceSchema = z4.object({
|
|
836
|
+
kind: z4.enum(["pull_request", "issue", "incident", "discussion", "document", "other"]),
|
|
529
837
|
reference: text(1e3),
|
|
530
838
|
note: text(500).optional()
|
|
531
839
|
}).strict();
|
|
532
|
-
var attributionSchema =
|
|
840
|
+
var attributionSchema = z4.object({
|
|
533
841
|
owner: text(200).nullable().optional(),
|
|
534
|
-
sources:
|
|
842
|
+
sources: z4.array(decisionSourceSchema).max(20).optional(),
|
|
535
843
|
actor: text(200).optional()
|
|
536
844
|
});
|
|
537
|
-
var contentSchema =
|
|
538
|
-
title:
|
|
539
|
-
body:
|
|
540
|
-
category:
|
|
541
|
-
files:
|
|
845
|
+
var contentSchema = z4.object({
|
|
846
|
+
title: z4.string().min(1),
|
|
847
|
+
body: z4.string().min(1),
|
|
848
|
+
category: z4.enum(["decision", "gotcha", "deprecation", "convention"]),
|
|
849
|
+
files: z4.array(z4.string().refine((f) => normalizeRepoPath(f) !== null)),
|
|
542
850
|
owner: text(200).optional(),
|
|
543
|
-
sources:
|
|
851
|
+
sources: z4.array(decisionSourceSchema).max(20)
|
|
544
852
|
});
|
|
545
|
-
var approvalSchema =
|
|
546
|
-
var statusSchema =
|
|
547
|
-
var reviewEvidenceSchema =
|
|
548
|
-
baseHash:
|
|
549
|
-
headHash:
|
|
550
|
-
historyAvailable:
|
|
551
|
-
changedFiles:
|
|
552
|
-
localChanges:
|
|
853
|
+
var approvalSchema = z4.enum(["unreviewed", "proposed", "accepted"]);
|
|
854
|
+
var statusSchema = z4.enum(["active", "superseded", "retired"]);
|
|
855
|
+
var reviewEvidenceSchema = z4.object({
|
|
856
|
+
baseHash: z4.string(),
|
|
857
|
+
headHash: z4.string(),
|
|
858
|
+
historyAvailable: z4.boolean(),
|
|
859
|
+
changedFiles: z4.array(z4.string()),
|
|
860
|
+
localChanges: z4.array(z4.string())
|
|
553
861
|
});
|
|
554
|
-
var eventSchema =
|
|
555
|
-
kind:
|
|
556
|
-
|
|
862
|
+
var eventSchema = z4.object({
|
|
863
|
+
kind: z4.enum([
|
|
864
|
+
"imported",
|
|
865
|
+
"created",
|
|
866
|
+
"revised",
|
|
867
|
+
"accepted",
|
|
868
|
+
"reaffirmed",
|
|
869
|
+
"retired",
|
|
870
|
+
"superseded"
|
|
871
|
+
]),
|
|
872
|
+
at: z4.string().datetime(),
|
|
557
873
|
actor: text(200).optional(),
|
|
558
874
|
note: text(1500).optional(),
|
|
559
|
-
revision:
|
|
875
|
+
revision: z4.number().int().positive(),
|
|
560
876
|
content: contentSchema,
|
|
561
877
|
approval: approvalSchema,
|
|
562
878
|
status: statusSchema,
|
|
563
|
-
refreshedHash:
|
|
564
|
-
evidence: reviewEvidenceSchema.optional()
|
|
879
|
+
refreshedHash: z4.string(),
|
|
880
|
+
evidence: reviewEvidenceSchema.optional(),
|
|
881
|
+
capture: anchorCaptureSchema.optional()
|
|
565
882
|
});
|
|
566
|
-
var legacySchema =
|
|
567
|
-
version:
|
|
568
|
-
id:
|
|
569
|
-
title:
|
|
570
|
-
body:
|
|
883
|
+
var legacySchema = z4.object({
|
|
884
|
+
version: z4.literal(1),
|
|
885
|
+
id: z4.string().regex(/^[a-zA-Z0-9_-]+$/),
|
|
886
|
+
title: z4.string().min(1),
|
|
887
|
+
body: z4.string().min(1),
|
|
571
888
|
category: contentSchema.shape.category,
|
|
572
889
|
files: contentSchema.shape.files,
|
|
573
|
-
createdAt:
|
|
574
|
-
updatedAt:
|
|
575
|
-
refreshedHash:
|
|
576
|
-
status:
|
|
577
|
-
supersededBy:
|
|
890
|
+
createdAt: z4.string(),
|
|
891
|
+
updatedAt: z4.string(),
|
|
892
|
+
refreshedHash: z4.string(),
|
|
893
|
+
status: z4.enum(["active", "superseded"]),
|
|
894
|
+
supersededBy: z4.string().optional()
|
|
578
895
|
}).passthrough();
|
|
579
896
|
var currentSchema = legacySchema.extend({
|
|
580
|
-
version:
|
|
897
|
+
version: z4.literal(2),
|
|
581
898
|
status: statusSchema,
|
|
582
899
|
approval: approvalSchema,
|
|
583
|
-
revision:
|
|
900
|
+
revision: z4.number().int().positive(),
|
|
584
901
|
owner: text(200).optional(),
|
|
585
|
-
sources:
|
|
586
|
-
history:
|
|
902
|
+
sources: z4.array(decisionSourceSchema).max(20),
|
|
903
|
+
history: z4.array(eventSchema).min(1),
|
|
904
|
+
capture: anchorCaptureSchema.optional(),
|
|
905
|
+
inspections: z4.array(
|
|
906
|
+
z4.object({
|
|
907
|
+
at: z4.string().datetime(),
|
|
908
|
+
inspector: text(200),
|
|
909
|
+
note: text(1500),
|
|
910
|
+
outcome: z4.literal("no_contradiction_found"),
|
|
911
|
+
decisionDigest: z4.string().regex(/^[a-f0-9]{64}$/),
|
|
912
|
+
headHash: z4.string(),
|
|
913
|
+
capture: anchorCaptureSchema
|
|
914
|
+
})
|
|
915
|
+
).max(200).optional()
|
|
587
916
|
}).superRefine((record, ctx) => {
|
|
588
917
|
const invalid = (message) => ctx.addIssue({ code: "custom", message });
|
|
589
918
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
590
919
|
let previous;
|
|
591
920
|
for (const event of record.history) {
|
|
592
921
|
if (!previous) {
|
|
593
|
-
if (!["created", "imported"].includes(event.kind) || event.revision !== 1)
|
|
594
|
-
|
|
922
|
+
if (!["created", "imported"].includes(event.kind) || event.revision !== 1)
|
|
923
|
+
invalid("History must begin with creation or legacy import at revision 1");
|
|
924
|
+
if (event.approval !== (event.kind === "created" ? "proposed" : "unreviewed"))
|
|
925
|
+
invalid("Initial records cannot claim acceptance");
|
|
595
926
|
} else {
|
|
596
927
|
if (["created", "imported"].includes(event.kind)) invalid("History cannot restart");
|
|
597
928
|
if (previous.status !== "active") invalid("Archived decisions cannot be changed");
|
|
598
|
-
if (event.revision !== previous.revision + (event.kind === "revised" ? 1 : 0))
|
|
599
|
-
|
|
600
|
-
if (event.kind
|
|
601
|
-
|
|
929
|
+
if (event.revision !== previous.revision + (event.kind === "revised" ? 1 : 0))
|
|
930
|
+
invalid("Invalid revision sequence");
|
|
931
|
+
if (event.kind !== "revised" && !same(event.content, previous.content))
|
|
932
|
+
invalid("A review cannot silently revise decision content");
|
|
933
|
+
if (event.kind === "reaffirmed" && previous.approval !== "accepted")
|
|
934
|
+
invalid("Only accepted decisions can be reaffirmed");
|
|
935
|
+
if (event.kind === "accepted" && previous.approval === "accepted")
|
|
936
|
+
invalid("Use reaffirmation for an accepted decision");
|
|
602
937
|
const approval = event.kind === "revised" ? "proposed" : ["accepted", "reaffirmed"].includes(event.kind) ? "accepted" : previous.approval;
|
|
603
938
|
if (event.approval !== approval) invalid("Approval disagrees with review history");
|
|
604
|
-
if (!["accepted", "reaffirmed"].includes(event.kind) && event.refreshedHash !== previous.refreshedHash)
|
|
939
|
+
if (!["accepted", "reaffirmed"].includes(event.kind) && event.refreshedHash !== previous.refreshedHash)
|
|
940
|
+
invalid("Only a review can refresh the evidence baseline");
|
|
605
941
|
}
|
|
606
|
-
if (event.kind !== "imported" && event.status !== (event.kind === "retired" ? "retired" : event.kind === "superseded" ? "superseded" : "active"))
|
|
607
|
-
|
|
942
|
+
if (event.kind !== "imported" && event.status !== (event.kind === "retired" ? "retired" : event.kind === "superseded" ? "superseded" : "active"))
|
|
943
|
+
invalid("Lifecycle disagrees with history");
|
|
944
|
+
if (["accepted", "reaffirmed", "retired"].includes(event.kind) && (!event.actor || !event.note || !event.evidence))
|
|
945
|
+
invalid("Reviews require a named reviewer, reason, and code evidence");
|
|
608
946
|
if (["accepted", "reaffirmed"].includes(event.kind)) {
|
|
609
|
-
if (!event.content.owner || !event.content.sources.length)
|
|
610
|
-
|
|
947
|
+
if (!event.content.owner || !event.content.sources.length)
|
|
948
|
+
invalid("Accepted decisions require an owner and source");
|
|
949
|
+
if (!event.evidence || !/^[a-f0-9]{40,64}$/.test(event.evidence.headHash) || event.refreshedHash !== event.evidence.headHash || event.evidence.localChanges.length)
|
|
950
|
+
invalid("Acceptance requires a committed evidence baseline");
|
|
611
951
|
}
|
|
952
|
+
if (event.capture && !same([...event.content.files].sort(), event.capture.anchors))
|
|
953
|
+
invalid("Capture scope disagrees with decision anchors");
|
|
612
954
|
previous = event;
|
|
613
955
|
}
|
|
614
|
-
if (
|
|
956
|
+
if (previous && !same(previous.capture, record.capture))
|
|
957
|
+
invalid("Capture does not match the final history event");
|
|
958
|
+
if (!previous || !same(previous.content, decisionContent(record)) || previous.approval !== record.approval || previous.status !== record.status || previous.revision !== record.revision || previous.refreshedHash !== record.refreshedHash)
|
|
959
|
+
invalid("Decision does not match the final history event");
|
|
615
960
|
});
|
|
616
|
-
var decisionSchema =
|
|
961
|
+
var decisionSchema = z4.union([legacySchema, currentSchema]);
|
|
617
962
|
function decisionContent(record) {
|
|
618
963
|
return {
|
|
619
964
|
title: record.title,
|
|
@@ -624,8 +969,12 @@ function decisionContent(record) {
|
|
|
624
969
|
sources: Array.isArray(record.sources) ? record.sources : []
|
|
625
970
|
};
|
|
626
971
|
}
|
|
972
|
+
function decisionApproval(record) {
|
|
973
|
+
return record.version === 1 ? "unreviewed" : record.approval;
|
|
974
|
+
}
|
|
627
975
|
function effectiveDecision(record) {
|
|
628
|
-
if (record.version !== 2 || record.status !== "active" || record.approval !== "proposed")
|
|
976
|
+
if (record.version !== 2 || record.status !== "active" || record.approval !== "proposed")
|
|
977
|
+
return record;
|
|
629
978
|
let index = record.history.length - 1;
|
|
630
979
|
while (index >= 0 && !["accepted", "reaffirmed"].includes(record.history[index].kind)) index--;
|
|
631
980
|
if (index < 0) return record;
|
|
@@ -637,57 +986,147 @@ function effectiveDecision(record) {
|
|
|
637
986
|
approval: "accepted",
|
|
638
987
|
revision: event.revision,
|
|
639
988
|
refreshedHash: event.refreshedHash,
|
|
989
|
+
capture: event.capture,
|
|
640
990
|
updatedAt: event.at,
|
|
641
991
|
history: record.history.slice(0, index + 1)
|
|
642
992
|
};
|
|
643
993
|
}
|
|
994
|
+
function decisionInspectionDigest(record) {
|
|
995
|
+
const effective = effectiveDecision(record);
|
|
996
|
+
return createHash2("sha256").update(
|
|
997
|
+
JSON.stringify([
|
|
998
|
+
record.status,
|
|
999
|
+
decisionApproval(record),
|
|
1000
|
+
record.version === 2 ? record.revision : 0,
|
|
1001
|
+
decisionContent(record),
|
|
1002
|
+
decisionContent(effective),
|
|
1003
|
+
effective.refreshedHash
|
|
1004
|
+
])
|
|
1005
|
+
).digest("hex");
|
|
1006
|
+
}
|
|
1007
|
+
function latestDecisionInspection(record) {
|
|
1008
|
+
return record.version === 2 ? record.inspections?.filter((i) => i.decisionDigest === decisionInspectionDigest(record)).at(-1) : void 0;
|
|
1009
|
+
}
|
|
644
1010
|
|
|
645
1011
|
// src/decisions/decisions.ts
|
|
646
1012
|
var TITLE_MAX_CHARS = 80;
|
|
647
1013
|
var BODY_RECOMMENDED_CHARS = 1500;
|
|
648
1014
|
var BODY_MAX_CHARS = 2500;
|
|
649
|
-
var boundedText = (field, max, hint) =>
|
|
650
|
-
errorMap: (issue, ctx) => ({
|
|
1015
|
+
var boundedText = (field, max, hint) => z5.string({
|
|
1016
|
+
errorMap: (issue, ctx) => ({
|
|
1017
|
+
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
|
|
1018
|
+
})
|
|
651
1019
|
}).trim().min(1, `${field} must be non-empty`).max(max);
|
|
652
|
-
var decisionTitleSchema = boundedText(
|
|
1020
|
+
var decisionTitleSchema = boundedText(
|
|
1021
|
+
"title",
|
|
1022
|
+
TITLE_MAX_CHARS,
|
|
1023
|
+
"Tighten it to a specific headline."
|
|
1024
|
+
);
|
|
653
1025
|
var decisionBodySchema = boundedText(
|
|
654
1026
|
"body",
|
|
655
1027
|
BODY_MAX_CHARS,
|
|
656
1028
|
`Aim for ${BODY_RECOMMENDED_CHARS} characters or fewer. Preserve the rule, reason, and exceptions; put supporting references in sources.`
|
|
657
1029
|
);
|
|
1030
|
+
async function readDecisionInputs(rootDir) {
|
|
1031
|
+
return structuredClone(
|
|
1032
|
+
await inspectionRead(rootDir, "decision-store:inputs", async () => {
|
|
1033
|
+
const inputs = [];
|
|
1034
|
+
const diagnostics = [];
|
|
1035
|
+
let entries;
|
|
1036
|
+
try {
|
|
1037
|
+
entries = await fs6.readdir(await storePath(rootDir, ".mason/decisions"));
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
if (error.code !== "ENOENT")
|
|
1040
|
+
diagnostics.push({ path: ".mason/decisions", message: String(error) });
|
|
1041
|
+
return { inputs, diagnostics };
|
|
1042
|
+
}
|
|
1043
|
+
for (const entry of entries.sort()) {
|
|
1044
|
+
if (!entry.endsWith(".json")) continue;
|
|
1045
|
+
const relative = `.mason/decisions/${entry}`;
|
|
1046
|
+
try {
|
|
1047
|
+
const raw = await readBoundedFile(await storePath(rootDir, relative), 10 * 1024 * 1024);
|
|
1048
|
+
if (raw === null) throw new Error("file is not regular or exceeds 10 MiB");
|
|
1049
|
+
inputs.push([relative, raw]);
|
|
1050
|
+
} catch (error) {
|
|
1051
|
+
if (error.code === "ENOENT") inputs.push([relative, null]);
|
|
1052
|
+
else
|
|
1053
|
+
diagnostics.push({
|
|
1054
|
+
path: relative,
|
|
1055
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
return { inputs, diagnostics };
|
|
1060
|
+
})
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
658
1063
|
async function loadDecisionStore(rootDir) {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
1064
|
+
return structuredClone(
|
|
1065
|
+
await inspectionRead(rootDir, "decision-store:parsed", async () => {
|
|
1066
|
+
const raw = await readDecisionInputs(rootDir);
|
|
1067
|
+
const records = [];
|
|
1068
|
+
const diagnostics = [...raw.diagnostics];
|
|
1069
|
+
for (const [relative, content] of raw.inputs) {
|
|
1070
|
+
try {
|
|
1071
|
+
const record = decisionSchema.parse(content === null ? null : JSON.parse(content));
|
|
1072
|
+
if (relative !== `.mason/decisions/${record.id}.json`)
|
|
1073
|
+
throw new Error("Record id does not match its filename");
|
|
1074
|
+
records.push(record);
|
|
1075
|
+
} catch (error) {
|
|
1076
|
+
diagnostics.push({
|
|
1077
|
+
path: relative,
|
|
1078
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return { records, diagnostics };
|
|
1083
|
+
})
|
|
1084
|
+
);
|
|
680
1085
|
}
|
|
681
1086
|
|
|
682
1087
|
// src/decisions/drift.ts
|
|
683
1088
|
async function computeDecisionDrift(rootDir, decisions) {
|
|
684
|
-
const resolvedRoot =
|
|
1089
|
+
const resolvedRoot = path10.resolve(rootDir);
|
|
685
1090
|
const store = decisions ? { records: decisions, diagnostics: [] } : await loadDecisionStore(resolvedRoot);
|
|
686
|
-
const report = {
|
|
687
|
-
|
|
1091
|
+
const report = {
|
|
1092
|
+
historyAvailable: true,
|
|
1093
|
+
totalDecisions: store.records.length,
|
|
1094
|
+
staleDecisions: {},
|
|
1095
|
+
freshness: {},
|
|
1096
|
+
diagnostics: store.diagnostics
|
|
1097
|
+
};
|
|
1098
|
+
const [head, workingTree] = await Promise.all([
|
|
1099
|
+
getCurrentGitHash(resolvedRoot),
|
|
1100
|
+
getWorkingTree(resolvedRoot)
|
|
1101
|
+
]);
|
|
688
1102
|
const changesByHash = /* @__PURE__ */ new Map();
|
|
689
|
-
const
|
|
1103
|
+
const baselines = store.records.filter((record) => record.status === "active").flatMap((record) => {
|
|
1104
|
+
const inspection = latestDecisionInspection(record);
|
|
1105
|
+
if (inspection) return [inspection.capture];
|
|
1106
|
+
return [.../* @__PURE__ */ new Set([effectiveDecision(record), record])].flatMap(
|
|
1107
|
+
(candidate) => candidate.version === 2 && candidate.capture ? [candidate.capture] : []
|
|
1108
|
+
);
|
|
1109
|
+
});
|
|
1110
|
+
const captures = await captureAnchorScopes(
|
|
1111
|
+
resolvedRoot,
|
|
1112
|
+
baselines.map((baseline) => baseline.anchors)
|
|
1113
|
+
);
|
|
1114
|
+
const currentCaptures = new Map(
|
|
1115
|
+
baselines.map((baseline, index) => [JSON.stringify(baseline.anchors), captures[index]])
|
|
1116
|
+
);
|
|
1117
|
+
const inspect = async (record, inspectionCapture) => {
|
|
690
1118
|
if (record.files.length === 0) return { freshness: "unknown", changedFiles: [] };
|
|
1119
|
+
const baseline = inspectionCapture ?? (record.version === 2 ? record.capture : void 0);
|
|
1120
|
+
if (baseline) {
|
|
1121
|
+
const current = currentCaptures.get(JSON.stringify(baseline.anchors));
|
|
1122
|
+
const hits2 = changedCaptureFiles(baseline, current).filter(
|
|
1123
|
+
(file) => matchingPaths(record.files, [file]).length
|
|
1124
|
+
);
|
|
1125
|
+
return {
|
|
1126
|
+
freshness: !baseline.complete || !current.complete ? "unknown" : hits2.length ? "changed" : "current",
|
|
1127
|
+
changedFiles: hits2
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
691
1130
|
let touched = changesByHash.get(record.refreshedHash);
|
|
692
1131
|
if (touched === void 0) {
|
|
693
1132
|
const changes = record.refreshedHash === head && head !== "unknown" ? [] : await getChangesWithStatus(resolvedRoot, record.refreshedHash);
|
|
@@ -697,15 +1136,20 @@ async function computeDecisionDrift(rootDir, decisions) {
|
|
|
697
1136
|
if (touched === null) report.historyAvailable = false;
|
|
698
1137
|
const hits = touched ? matchingPaths(record.files, touched) : [];
|
|
699
1138
|
const localHits = matchingPaths(record.files, workingTree.changedFiles);
|
|
700
|
-
return {
|
|
1139
|
+
return {
|
|
1140
|
+
freshness: touched === null || !workingTree.available ? "unknown" : hits.length || localHits.length ? "changed" : "current",
|
|
1141
|
+
changedFiles: [.../* @__PURE__ */ new Set([...hits, ...localHits])].sort()
|
|
1142
|
+
};
|
|
701
1143
|
};
|
|
702
1144
|
for (const record of store.records) {
|
|
703
1145
|
if (record.status !== "active") continue;
|
|
704
1146
|
const effective = effectiveDecision(record);
|
|
705
|
-
const
|
|
1147
|
+
const inspection = latestDecisionInspection(record);
|
|
1148
|
+
const state = await inspect(effective, inspection?.capture);
|
|
706
1149
|
report.freshness[record.id] = state.freshness;
|
|
707
1150
|
if (state.changedFiles.length) report.staleDecisions[record.id] = state.changedFiles;
|
|
708
|
-
if (effective !== record)
|
|
1151
|
+
if (effective !== record)
|
|
1152
|
+
(report.pendingProposals ??= {})[record.id] = await inspect(record, inspection?.capture);
|
|
709
1153
|
}
|
|
710
1154
|
return report;
|
|
711
1155
|
}
|
|
@@ -758,9 +1202,17 @@ function parseArgs(argv) {
|
|
|
758
1202
|
}
|
|
759
1203
|
function formatDriftSummary(report) {
|
|
760
1204
|
if (!report.stale) {
|
|
761
|
-
const lines2 = [
|
|
762
|
-
|
|
763
|
-
|
|
1205
|
+
const lines2 = [
|
|
1206
|
+
`Concept map is up to date against committed source (HEAD ${report.headHash.slice(0, 7)}).`
|
|
1207
|
+
];
|
|
1208
|
+
if (report.workingTree?.changedFiles.length)
|
|
1209
|
+
lines2.push(
|
|
1210
|
+
`Working tree has ${report.workingTree.changedFiles.length} changed files; live context checks report these separately.`
|
|
1211
|
+
);
|
|
1212
|
+
if (report.verification?.failed.length)
|
|
1213
|
+
lines2.push(
|
|
1214
|
+
`Verification FAILED: ${report.verification.failed.join(", ")}. Correct these entries before relying on them.`
|
|
1215
|
+
);
|
|
764
1216
|
return lines2.join("\n");
|
|
765
1217
|
}
|
|
766
1218
|
const lines = [];
|
|
@@ -779,9 +1231,7 @@ function formatDriftSummary(report) {
|
|
|
779
1231
|
);
|
|
780
1232
|
}
|
|
781
1233
|
if (staleFlows.length > 0) {
|
|
782
|
-
lines.push(
|
|
783
|
-
`Stale flows (${staleFlows.length}/${report.totalFlows}): ${staleFlows.join(", ")}`
|
|
784
|
-
);
|
|
1234
|
+
lines.push(`Stale flows (${staleFlows.length}/${report.totalFlows}): ${staleFlows.join(", ")}`);
|
|
785
1235
|
}
|
|
786
1236
|
if (report.unmappedFiles.length > 0) {
|
|
787
1237
|
lines.push(
|
|
@@ -840,11 +1290,12 @@ function formatRefreshPrompt(report, decisionDrift) {
|
|
|
840
1290
|
);
|
|
841
1291
|
}
|
|
842
1292
|
const changedProposals = Object.entries(decisionDrift.pendingProposals ?? {}).filter(([, state]) => state.freshness !== "current").map(([id]) => id);
|
|
843
|
-
if (changedProposals.length)
|
|
1293
|
+
if (changedProposals.length)
|
|
1294
|
+
lines.push(
|
|
1295
|
+
`NOTE: pending proposals [${changedProposals.join(", ")}] need source review. Their accepted revisions remain operative. Do NOT modify or accept these proposals during automated map refresh.`
|
|
1296
|
+
);
|
|
844
1297
|
lines.push("");
|
|
845
|
-
lines.push(
|
|
846
|
-
"Finish by confirming the map was saved and summarizing which entries changed."
|
|
847
|
-
);
|
|
1298
|
+
lines.push("Finish by confirming the map was saved and summarizing which entries changed.");
|
|
848
1299
|
return lines.join("\n");
|
|
849
1300
|
}
|
|
850
1301
|
async function runDriftCli(argv, io = {
|
|
@@ -868,7 +1319,7 @@ async function runDriftCli(argv, io = {
|
|
|
868
1319
|
io.out(USAGE);
|
|
869
1320
|
return 0;
|
|
870
1321
|
}
|
|
871
|
-
const rootDir =
|
|
1322
|
+
const rootDir = path11.resolve(args.dir);
|
|
872
1323
|
let report;
|
|
873
1324
|
try {
|
|
874
1325
|
report = await computeDrift(rootDir);
|
|
@@ -878,7 +1329,7 @@ async function runDriftCli(argv, io = {
|
|
|
878
1329
|
}
|
|
879
1330
|
if (!report) {
|
|
880
1331
|
io.err(
|
|
881
|
-
`No Mason snapshot found at ${
|
|
1332
|
+
`No Mason snapshot found at ${path11.join(rootDir, ".mason", "snapshot.json")}. Build one via the Mason MCP server first.`
|
|
882
1333
|
);
|
|
883
1334
|
return 2;
|
|
884
1335
|
}
|
|
@@ -890,9 +1341,7 @@ async function runDriftCli(argv, io = {
|
|
|
890
1341
|
}
|
|
891
1342
|
const decisionDrift = await computeDecisionDrift(rootDir);
|
|
892
1343
|
if (args.refreshPrompt) {
|
|
893
|
-
io.out(
|
|
894
|
-
report.stale ? formatRefreshPrompt(report, decisionDrift) : formatDriftSummary(report)
|
|
895
|
-
);
|
|
1344
|
+
io.out(report.stale ? formatRefreshPrompt(report, decisionDrift) : formatDriftSummary(report));
|
|
896
1345
|
return report.stale ? 1 : 0;
|
|
897
1346
|
}
|
|
898
1347
|
if (args.json) {
|
|
@@ -903,9 +1352,13 @@ async function runDriftCli(argv, io = {
|
|
|
903
1352
|
io.out(JSON.stringify(output, null, 2));
|
|
904
1353
|
} else {
|
|
905
1354
|
const lines = [formatDriftSummary(report)];
|
|
906
|
-
for (const diagnostic of decisionDrift.diagnostics ?? [])
|
|
1355
|
+
for (const diagnostic of decisionDrift.diagnostics ?? [])
|
|
1356
|
+
lines.push(`Invalid decision record ${diagnostic.path}: ${diagnostic.message}`);
|
|
907
1357
|
const unknownIds = Object.entries(decisionDrift.freshness ?? {}).filter(([, state]) => state === "unknown").map(([id]) => id);
|
|
908
|
-
if (unknownIds.length)
|
|
1358
|
+
if (unknownIds.length)
|
|
1359
|
+
lines.push(
|
|
1360
|
+
`Decision freshness unknown: ${unknownIds.join(", ")}. Verify before relying on them.`
|
|
1361
|
+
);
|
|
909
1362
|
const staleIds = Object.keys(decisionDrift.staleDecisions);
|
|
910
1363
|
if (staleIds.length > 0) {
|
|
911
1364
|
lines.push(
|
|
@@ -913,7 +1366,9 @@ async function runDriftCli(argv, io = {
|
|
|
913
1366
|
);
|
|
914
1367
|
}
|
|
915
1368
|
for (const [id, proposal] of Object.entries(decisionDrift.pendingProposals ?? {})) {
|
|
916
|
-
lines.push(
|
|
1369
|
+
lines.push(
|
|
1370
|
+
`Pending proposal ${id}: freshness ${proposal.freshness}; the accepted revision remains operative.`
|
|
1371
|
+
);
|
|
917
1372
|
}
|
|
918
1373
|
io.out(lines.join("\n"));
|
|
919
1374
|
}
|