mason-context 0.3.7 → 0.7.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.
@@ -0,0 +1,541 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/drift/cli.ts
4
+ import path8 from "path";
5
+
6
+ // src/drift/drift.ts
7
+ import fs3 from "fs/promises";
8
+ import path4 from "path";
9
+ import { execFile as execFile3 } from "child_process";
10
+ import { promisify as promisify3 } from "util";
11
+
12
+ // src/snapshot/snapshot.ts
13
+ import fs2 from "fs/promises";
14
+ import path3 from "path";
15
+ import { execFile as execFile2 } from "child_process";
16
+ import { promisify as promisify2 } from "util";
17
+ import fg3 from "fast-glob";
18
+
19
+ // src/mcp/sampler.ts
20
+ import fs from "fs/promises";
21
+ import path from "path";
22
+ import { execFile } from "child_process";
23
+ import { promisify } from "util";
24
+ import fg from "fast-glob";
25
+ var exec = promisify(execFile);
26
+
27
+ // src/test-map.ts
28
+ import path2 from "path";
29
+ import fg2 from "fast-glob";
30
+
31
+ // src/snapshot/snapshot.ts
32
+ var exec2 = promisify2(execFile2);
33
+ function snapshotDir(rootDir) {
34
+ return path3.join(rootDir, ".mason");
35
+ }
36
+ function snapshotPath(rootDir) {
37
+ return path3.join(snapshotDir(rootDir), "snapshot.json");
38
+ }
39
+ async function loadSnapshot(rootDir) {
40
+ try {
41
+ const raw = await fs2.readFile(snapshotPath(rootDir), "utf-8");
42
+ const parsed = JSON.parse(raw);
43
+ if (parsed.version !== 2) return null;
44
+ return parsed;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+ async function getCurrentGitHash(rootDir) {
50
+ try {
51
+ const { stdout } = await exec2("git", ["rev-parse", "HEAD"], {
52
+ cwd: rootDir
53
+ });
54
+ return stdout.trim();
55
+ } catch {
56
+ return "unknown";
57
+ }
58
+ }
59
+ var SOURCE_GLOB = "**/*.{ts,tsx,js,jsx,kt,kts,java,py,go,rs,swift,rb,cs,cpp,c,dart}";
60
+ var SOURCE_IGNORE = [
61
+ "**/node_modules/**",
62
+ "**/dist/**",
63
+ "**/build/**",
64
+ "**/.gradle/**",
65
+ "**/target/**",
66
+ "**/.git/**",
67
+ "**/vendor/**",
68
+ "**/__pycache__/**",
69
+ "**/venv/**",
70
+ "**/.venv/**",
71
+ "**/*.min.*",
72
+ "**/*.map",
73
+ "**/generated/**",
74
+ "**/R.java",
75
+ "**/BuildConfig.java"
76
+ ];
77
+ async function listSourceFiles(resolvedRoot) {
78
+ const all = await fg3(SOURCE_GLOB, {
79
+ cwd: resolvedRoot,
80
+ ignore: SOURCE_IGNORE
81
+ });
82
+ return [...all].sort();
83
+ }
84
+
85
+ // src/drift/drift.ts
86
+ var exec3 = promisify3(execFile3);
87
+ var FULL_REBUILD_FRACTION = 0.4;
88
+ var FULL_REBUILD_MIN_CHANGED_MAPPED_FILES = 10;
89
+ async function getChangesWithStatus(resolvedRoot, fromHash) {
90
+ if (!fromHash || fromHash === "unknown") return null;
91
+ try {
92
+ const { stdout } = await exec3(
93
+ "git",
94
+ ["diff", "--name-status", "-M", fromHash, "HEAD"],
95
+ { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }
96
+ );
97
+ const changes = [];
98
+ for (const line of stdout.split("\n")) {
99
+ if (!line.trim()) continue;
100
+ const parts = line.split(" ");
101
+ if (parts.some((p) => p.startsWith(".mason/"))) continue;
102
+ const code = parts[0];
103
+ if (code.startsWith("R") && parts.length >= 3) {
104
+ changes.push({
105
+ status: "renamed",
106
+ path: parts[2],
107
+ previousPath: parts[1]
108
+ });
109
+ } else if (code.startsWith("C") && parts.length >= 3) {
110
+ changes.push({ status: "added", path: parts[2] });
111
+ } else if (code === "A" && parts.length >= 2) {
112
+ changes.push({ status: "added", path: parts[1] });
113
+ } else if (code === "D" && parts.length >= 2) {
114
+ changes.push({ status: "deleted", path: parts[1] });
115
+ } else if (parts.length >= 2) {
116
+ changes.push({ status: "modified", path: parts[1] });
117
+ }
118
+ }
119
+ return changes;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+ async function countCommitsBehind(resolvedRoot, fromHash) {
125
+ try {
126
+ const { stdout } = await exec3(
127
+ "git",
128
+ ["rev-list", "--count", `${fromHash}..HEAD`],
129
+ { cwd: resolvedRoot }
130
+ );
131
+ const count = Number.parseInt(stdout.trim(), 10);
132
+ return Number.isNaN(count) ? null : count;
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+ function collectMappedFiles(snapshot) {
138
+ const mappedFiles = /* @__PURE__ */ new Set();
139
+ for (const feature of Object.values(snapshot.features)) {
140
+ for (const f of feature.files) mappedFiles.add(f);
141
+ for (const t of feature.tests ?? []) mappedFiles.add(t);
142
+ }
143
+ for (const flow of Object.values(snapshot.flows)) {
144
+ for (const f of flow.chain) mappedFiles.add(f);
145
+ }
146
+ return mappedFiles;
147
+ }
148
+ async function findGhostFiles(resolvedRoot, mappedFiles) {
149
+ const ghosts = [];
150
+ for (const file of mappedFiles) {
151
+ try {
152
+ await fs3.access(path4.join(resolvedRoot, file));
153
+ } catch {
154
+ ghosts.push(file);
155
+ }
156
+ }
157
+ return ghosts.sort();
158
+ }
159
+ async function computeDrift(rootDir) {
160
+ const resolvedRoot = path4.resolve(rootDir);
161
+ const snapshot = await loadSnapshot(resolvedRoot);
162
+ if (!snapshot) return null;
163
+ const headHash = await getCurrentGitHash(resolvedRoot);
164
+ const totalFeatures = Object.keys(snapshot.features).length;
165
+ const totalFlows = Object.keys(snapshot.flows).length;
166
+ const hashFor = (entry) => entry.refreshedHash ?? snapshot.gitHash;
167
+ const distinctHashes = /* @__PURE__ */ new Set([snapshot.gitHash]);
168
+ for (const feature of Object.values(snapshot.features)) {
169
+ distinctHashes.add(hashFor(feature));
170
+ }
171
+ for (const flow of Object.values(snapshot.flows)) {
172
+ distinctHashes.add(hashFor(flow));
173
+ }
174
+ distinctHashes.delete("unknown");
175
+ const staleHashes = headHash === "unknown" ? [] : [...distinctHashes].filter((h) => h !== headHash);
176
+ const stale = staleHashes.length > 0;
177
+ const report = {
178
+ stale,
179
+ snapshotHash: snapshot.gitHash,
180
+ headHash,
181
+ commitsBehind: stale ? null : 0,
182
+ historyAvailable: true,
183
+ changedFiles: [],
184
+ staleFeatures: {},
185
+ staleFlows: {},
186
+ totalFeatures,
187
+ totalFlows,
188
+ unmappedFiles: [],
189
+ ghostFiles: [],
190
+ renames: [],
191
+ recommendation: "up-to-date"
192
+ };
193
+ if (!stale) return report;
194
+ const mappedFiles = collectMappedFiles(snapshot);
195
+ report.ghostFiles = await findGhostFiles(resolvedRoot, mappedFiles);
196
+ const changesByHash = /* @__PURE__ */ new Map();
197
+ const touchedByHash = /* @__PURE__ */ new Map();
198
+ for (const hash of staleHashes) {
199
+ const changes = await getChangesWithStatus(resolvedRoot, hash);
200
+ if (changes === null) {
201
+ report.historyAvailable = false;
202
+ report.recommendation = "full-rebuild";
203
+ return report;
204
+ }
205
+ changesByHash.set(hash, changes);
206
+ const touched = /* @__PURE__ */ new Set();
207
+ for (const change of changes) {
208
+ touched.add(change.path);
209
+ if (change.previousPath) touched.add(change.previousPath);
210
+ }
211
+ touchedByHash.set(hash, touched);
212
+ }
213
+ const commitCounts = await Promise.all(
214
+ staleHashes.map((hash) => countCommitsBehind(resolvedRoot, hash))
215
+ );
216
+ const validCounts = commitCounts.filter((c) => c !== null);
217
+ report.commitsBehind = validCounts.length > 0 ? Math.max(...validCounts) : null;
218
+ const emptySet = /* @__PURE__ */ new Set();
219
+ const touchedFor = (entry) => touchedByHash.get(hashFor(entry)) ?? emptySet;
220
+ for (const [name, feature] of Object.entries(snapshot.features)) {
221
+ const touched = touchedFor(feature);
222
+ const hits = [...feature.files, ...feature.tests ?? []].filter(
223
+ (f) => touched.has(f)
224
+ );
225
+ if (hits.length > 0) report.staleFeatures[name] = [...new Set(hits)];
226
+ }
227
+ for (const [name, flow] of Object.entries(snapshot.flows)) {
228
+ const touched = touchedFor(flow);
229
+ const hits = flow.chain.filter((f) => touched.has(f));
230
+ if (hits.length > 0) report.staleFlows[name] = [...new Set(hits)];
231
+ }
232
+ const allChanges = [...changesByHash.values()].flat();
233
+ report.changedFiles = [...new Set(allChanges.map((c) => c.path))].sort();
234
+ const sourceFileSet = new Set(await listSourceFiles(resolvedRoot));
235
+ const newPaths = allChanges.filter((c) => c.status === "added" || c.status === "renamed").map((c) => c.path);
236
+ report.unmappedFiles = [...new Set(newPaths)].filter((p) => sourceFileSet.has(p) && !mappedFiles.has(p)).sort();
237
+ const renameKeys = /* @__PURE__ */ new Set();
238
+ for (const change of allChanges) {
239
+ if (change.status !== "renamed" || !change.previousPath) continue;
240
+ const key = `${change.previousPath}\0${change.path}`;
241
+ if (renameKeys.has(key)) continue;
242
+ renameKeys.add(key);
243
+ report.renames.push({ from: change.previousPath, to: change.path });
244
+ }
245
+ const changedMapped = /* @__PURE__ */ new Set([
246
+ ...Object.values(report.staleFeatures).flat(),
247
+ ...Object.values(report.staleFlows).flat()
248
+ ]);
249
+ const changedFraction = mappedFiles.size > 0 ? changedMapped.size / mappedFiles.size : 0;
250
+ report.recommendation = changedMapped.size >= FULL_REBUILD_MIN_CHANGED_MAPPED_FILES && changedFraction > FULL_REBUILD_FRACTION ? "full-rebuild" : "incremental";
251
+ return report;
252
+ }
253
+
254
+ // src/decisions/drift.ts
255
+ import path7 from "path";
256
+
257
+ // src/decisions/decisions.ts
258
+ import fs4 from "fs/promises";
259
+ import path6 from "path";
260
+ import { createHash } from "crypto";
261
+
262
+ // src/context/lexical.ts
263
+ import path5 from "path";
264
+
265
+ // src/decisions/decisions.ts
266
+ function decisionsDir(rootDir) {
267
+ return path6.join(rootDir, ".mason", "decisions");
268
+ }
269
+ async function loadDecisions(rootDir) {
270
+ let entries;
271
+ try {
272
+ entries = await fs4.readdir(decisionsDir(rootDir));
273
+ } catch {
274
+ return [];
275
+ }
276
+ const records = [];
277
+ for (const entry of entries) {
278
+ if (!entry.endsWith(".json")) continue;
279
+ try {
280
+ const raw = await fs4.readFile(
281
+ path6.join(decisionsDir(rootDir), entry),
282
+ "utf-8"
283
+ );
284
+ const parsed = JSON.parse(raw);
285
+ if (parsed.version !== 1 || !parsed.id || !parsed.title || !parsed.body) {
286
+ continue;
287
+ }
288
+ records.push(parsed);
289
+ } catch {
290
+ continue;
291
+ }
292
+ }
293
+ return records.sort((a, b) => a.id.localeCompare(b.id));
294
+ }
295
+
296
+ // src/decisions/drift.ts
297
+ async function computeDecisionDrift(rootDir, decisions) {
298
+ const resolvedRoot = path7.resolve(rootDir);
299
+ const records = decisions ?? await loadDecisions(resolvedRoot);
300
+ const report = {
301
+ historyAvailable: true,
302
+ totalDecisions: records.length,
303
+ staleDecisions: {}
304
+ };
305
+ const head = await getCurrentGitHash(resolvedRoot);
306
+ const changesByHash = /* @__PURE__ */ new Map();
307
+ for (const record of records) {
308
+ if (record.status !== "active" || record.files.length === 0) continue;
309
+ if (record.refreshedHash === head) continue;
310
+ let touched = changesByHash.get(record.refreshedHash);
311
+ if (touched === void 0) {
312
+ const changes = await getChangesWithStatus(
313
+ resolvedRoot,
314
+ record.refreshedHash
315
+ );
316
+ if (changes === null) {
317
+ touched = null;
318
+ } else {
319
+ touched = /* @__PURE__ */ new Set();
320
+ for (const change of changes) {
321
+ touched.add(change.path);
322
+ if (change.previousPath) touched.add(change.previousPath);
323
+ }
324
+ }
325
+ changesByHash.set(record.refreshedHash, touched);
326
+ }
327
+ if (touched === null) {
328
+ report.historyAvailable = false;
329
+ continue;
330
+ }
331
+ const hits = record.files.filter((f) => touched.has(f));
332
+ if (hits.length > 0) {
333
+ report.staleDecisions[record.id] = hits;
334
+ }
335
+ }
336
+ return report;
337
+ }
338
+
339
+ // src/drift/cli.ts
340
+ var USAGE = `Usage: mason-drift [--dir <path>] [--json | --refresh-prompt]
341
+
342
+ Checks the Mason concept map (.mason/snapshot.json) against git HEAD.
343
+ Deterministic: no LLM call, no network \u2014 safe for CI.
344
+
345
+ Options:
346
+ --dir <path> Project root to check (default: current directory)
347
+ --json Print the full drift report as JSON
348
+ --refresh-prompt When stale, print refresh instructions for ANY coding
349
+ assistant with the Mason MCP server connected (Claude,
350
+ Codex, Gemini, ...) \u2014 pipe it to your agent CLI to
351
+ close the loop. Prints nothing extra when fresh.
352
+ --help Show this help
353
+
354
+ Exit codes:
355
+ 0 concept map is up to date
356
+ 1 concept map is stale
357
+ 2 error (no snapshot, not a git repository, bad arguments)`;
358
+ function parseArgs(argv) {
359
+ const parsed = {
360
+ dir: process.cwd(),
361
+ json: false,
362
+ refreshPrompt: false,
363
+ help: false
364
+ };
365
+ for (let i = 0; i < argv.length; i++) {
366
+ const arg = argv[i];
367
+ if (arg === "--json") {
368
+ parsed.json = true;
369
+ } else if (arg === "--refresh-prompt") {
370
+ parsed.refreshPrompt = true;
371
+ } else if (arg === "--help" || arg === "-h") {
372
+ parsed.help = true;
373
+ } else if (arg === "--dir") {
374
+ const value = argv[++i];
375
+ if (!value) throw new Error("--dir requires a path argument");
376
+ parsed.dir = value;
377
+ } else if (!arg.startsWith("-") && parsed.dir === process.cwd()) {
378
+ parsed.dir = arg;
379
+ } else {
380
+ throw new Error(`Unknown argument: ${arg}`);
381
+ }
382
+ }
383
+ return parsed;
384
+ }
385
+ function formatDriftSummary(report) {
386
+ if (!report.stale) {
387
+ return `Concept map is up to date (HEAD ${report.headHash.slice(0, 7)}).`;
388
+ }
389
+ const lines = [];
390
+ const behind = report.commitsBehind !== null ? `${report.commitsBehind} commit${report.commitsBehind === 1 ? "" : "s"} behind HEAD` : "an unknown number of commits behind HEAD";
391
+ lines.push(`Concept map is STALE \u2014 ${behind}.`);
392
+ if (!report.historyAvailable) {
393
+ lines.push(
394
+ "The snapshot's base commit is unreachable (shallow clone or rewritten history); per-feature drift could not be computed."
395
+ );
396
+ }
397
+ const staleFeatures = Object.keys(report.staleFeatures);
398
+ const staleFlows = Object.keys(report.staleFlows);
399
+ if (staleFeatures.length > 0) {
400
+ lines.push(
401
+ `Stale features (${staleFeatures.length}/${report.totalFeatures}): ${staleFeatures.join(", ")}`
402
+ );
403
+ }
404
+ if (staleFlows.length > 0) {
405
+ lines.push(
406
+ `Stale flows (${staleFlows.length}/${report.totalFlows}): ${staleFlows.join(", ")}`
407
+ );
408
+ }
409
+ if (report.unmappedFiles.length > 0) {
410
+ lines.push(
411
+ `Unmapped new files (${report.unmappedFiles.length}): ${report.unmappedFiles.join(", ")}`
412
+ );
413
+ }
414
+ if (report.ghostFiles.length > 0) {
415
+ lines.push(
416
+ `Ghost files \u2014 mapped but deleted (${report.ghostFiles.length}): ${report.ghostFiles.join(", ")}`
417
+ );
418
+ }
419
+ lines.push(`Recommendation: ${report.recommendation}`);
420
+ return lines.join("\n");
421
+ }
422
+ function formatRefreshPrompt(report, decisionDrift) {
423
+ const lines = [];
424
+ lines.push(
425
+ "The Mason concept map for this project is stale. Refresh it using the Mason MCP tools (server name: mason). Work autonomously; do not ask questions. Modify ONLY the concept map via Mason tools \u2014 do not edit source files."
426
+ );
427
+ lines.push("");
428
+ lines.push("DRIFT REPORT (deterministic, computed against git HEAD):");
429
+ lines.push(
430
+ JSON.stringify(
431
+ {
432
+ commitsBehind: report.commitsBehind,
433
+ recommendation: report.recommendation,
434
+ staleFeatures: report.staleFeatures,
435
+ staleFlows: report.staleFlows,
436
+ changedFiles: report.changedFiles,
437
+ unmappedFiles: report.unmappedFiles,
438
+ ghostFiles: report.ghostFiles,
439
+ renames: report.renames
440
+ },
441
+ null,
442
+ 2
443
+ )
444
+ );
445
+ lines.push("");
446
+ const scopedFiles = [...report.changedFiles, ...report.unmappedFiles];
447
+ if (!report.historyAvailable || report.recommendation === "full-rebuild") {
448
+ lines.push(
449
+ "PROCEDURE (full rebuild): run the complete Map-Reduce build. Call generate_snapshot_batch repeatedly (follow nextOffset until null), calling save_partial_snapshot after each batch, then reduce_snapshot, then save_snapshot once with the unified map. Derive features ONLY from files shown in each batch prompt \u2014 never invent paths."
450
+ );
451
+ } else {
452
+ lines.push(
453
+ "PROCEDURE (scoped refresh): call generate_snapshot_batch with the files list below \u2014 the SAME list on every call \u2014 following nextOffset until null, calling save_partial_snapshot after each batch. Then call reduce_snapshot (it merges into the existing map, preserving untouched entries) and save_snapshot once. Use save_snapshot's removeFeatures/removeFlows for features that no longer exist (see ghostFiles/renames). Derive features ONLY from files shown in each batch prompt \u2014 never invent paths."
454
+ );
455
+ lines.push("");
456
+ lines.push(`files: ${JSON.stringify(scopedFiles)}`);
457
+ }
458
+ const staleDecisionIds = Object.keys(decisionDrift.staleDecisions);
459
+ if (staleDecisionIds.length > 0) {
460
+ lines.push("");
461
+ lines.push(
462
+ `NOTE: decisions [${staleDecisionIds.join(", ")}] have anchor files that changed. Do NOT modify decision records in this automated run \u2014 they encode human knowledge. Mention them in your final summary so the team re-verifies them.`
463
+ );
464
+ }
465
+ lines.push("");
466
+ lines.push(
467
+ "Finish by confirming the map was saved and summarizing which entries changed."
468
+ );
469
+ return lines.join("\n");
470
+ }
471
+ async function runDriftCli(argv, io = {
472
+ out: (line) => process.stdout.write(`${line}
473
+ `),
474
+ err: (line) => process.stderr.write(`${line}
475
+ `)
476
+ }) {
477
+ let args;
478
+ try {
479
+ args = parseArgs(argv);
480
+ if (args.json && args.refreshPrompt) {
481
+ throw new Error("--json and --refresh-prompt are mutually exclusive");
482
+ }
483
+ } catch (error) {
484
+ io.err(error instanceof Error ? error.message : String(error));
485
+ io.err(USAGE);
486
+ return 2;
487
+ }
488
+ if (args.help) {
489
+ io.out(USAGE);
490
+ return 0;
491
+ }
492
+ const rootDir = path8.resolve(args.dir);
493
+ const report = await computeDrift(rootDir);
494
+ if (!report) {
495
+ io.err(
496
+ `No Mason snapshot found at ${path8.join(rootDir, ".mason", "snapshot.json")}. Build one via the Mason MCP server first.`
497
+ );
498
+ return 2;
499
+ }
500
+ if (report.headHash === "unknown") {
501
+ io.err(
502
+ `Could not determine git HEAD in ${rootDir} \u2014 not a git repository, or git is unavailable.`
503
+ );
504
+ return 2;
505
+ }
506
+ const decisionDrift = await computeDecisionDrift(rootDir);
507
+ if (args.refreshPrompt) {
508
+ io.out(
509
+ report.stale ? formatRefreshPrompt(report, decisionDrift) : formatDriftSummary(report)
510
+ );
511
+ return report.stale ? 1 : 0;
512
+ }
513
+ if (args.json) {
514
+ const output = report;
515
+ if (decisionDrift.totalDecisions > 0) {
516
+ output.decisions = decisionDrift;
517
+ }
518
+ io.out(JSON.stringify(output, null, 2));
519
+ } else {
520
+ const lines = [formatDriftSummary(report)];
521
+ const staleIds = Object.keys(decisionDrift.staleDecisions);
522
+ if (staleIds.length > 0) {
523
+ lines.push(
524
+ `Decisions needing verification (${staleIds.length}/${decisionDrift.totalDecisions}): ${staleIds.join(", ")}`
525
+ );
526
+ }
527
+ io.out(lines.join("\n"));
528
+ }
529
+ return report.stale ? 1 : 0;
530
+ }
531
+
532
+ // bin/mason-drift.ts
533
+ runDriftCli(process.argv.slice(2)).then(
534
+ (code) => process.exit(code),
535
+ (err) => {
536
+ process.stderr.write(`mason-drift error: ${err}
537
+ `);
538
+ process.exit(2);
539
+ }
540
+ );
541
+ //# sourceMappingURL=mason-drift.js.map