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