@massu/core 1.3.0 → 1.4.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/commands/README.md +23 -11
- package/commands/massu-deploy.python-docker.md +170 -0
- package/commands/massu-deploy.python-fly.md +189 -0
- package/commands/massu-deploy.python-launchd.md +144 -0
- package/commands/massu-deploy.python-systemd.md +163 -0
- package/commands/massu-scaffold-page.swift.md +10 -10
- package/commands/massu-scaffold-router.python-django.md +153 -0
- package/commands/massu-scaffold-router.python-fastapi.md +145 -0
- package/dist/cli.js +9914 -4133
- package/dist/hooks/auto-learning-pipeline.js +45 -2
- package/dist/hooks/classify-failure.js +45 -2
- package/dist/hooks/cost-tracker.js +45 -2
- package/dist/hooks/fix-detector.js +45 -2
- package/dist/hooks/incident-pipeline.js +45 -2
- package/dist/hooks/post-edit-context.js +45 -2
- package/dist/hooks/post-tool-use.js +45 -2
- package/dist/hooks/pre-compact.js +45 -2
- package/dist/hooks/pre-delete-check.js +45 -2
- package/dist/hooks/quality-event.js +45 -2
- package/dist/hooks/rule-enforcement-pipeline.js +45 -2
- package/dist/hooks/session-end.js +45 -2
- package/dist/hooks/session-start.js +4790 -406
- package/dist/hooks/user-prompt.js +45 -2
- package/package.json +13 -4
- package/src/cli.ts +22 -2
- package/src/commands/config-refresh.ts +91 -23
- package/src/commands/init.ts +131 -24
- package/src/commands/install-commands.ts +142 -26
- package/src/commands/refresh-log.ts +37 -0
- package/src/commands/template-engine.ts +260 -0
- package/src/commands/watch.ts +430 -0
- package/src/config.ts +71 -0
- package/src/detect/adapters/nextjs-trpc.ts +166 -0
- package/src/detect/adapters/parse-guard.ts +133 -0
- package/src/detect/adapters/python-django.ts +208 -0
- package/src/detect/adapters/python-fastapi.ts +223 -0
- package/src/detect/adapters/query-helpers.ts +170 -0
- package/src/detect/adapters/runner.ts +252 -0
- package/src/detect/adapters/swift-swiftui.ts +171 -0
- package/src/detect/adapters/tree-sitter-loader.ts +467 -0
- package/src/detect/adapters/types.ts +173 -0
- package/src/detect/codebase-introspector.ts +190 -0
- package/src/detect/index.ts +28 -2
- package/src/detect/migrate.ts +4 -4
- package/src/detect/regex-fallback.ts +449 -0
- package/src/hooks/session-start.ts +94 -3
- package/src/lib/gitToplevel.ts +22 -0
- package/src/lib/installLock.ts +179 -0
- package/src/lib/pidLiveness.ts +67 -0
- package/src/lsp/auto-detect.ts +98 -0
- package/src/lsp/client.ts +776 -0
- package/src/lsp/enrich.ts +127 -0
- package/src/lsp/types.ts +221 -0
- package/src/watch/daemon.ts +385 -0
- package/src/watch/lockfile-detector.ts +65 -0
- package/src/watch/paths.ts +279 -0
- package/src/watch/state.ts +178 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `massu watch` CLI runner.
|
|
6
|
+
*
|
|
7
|
+
* Default mode: spawn `claude-bg start ... -- npx @massu/core watch --foreground`
|
|
8
|
+
* so the daemon registers in ~/.claude/bg-registry.jsonl and inherits the
|
|
9
|
+
* SessionEnd reaper. (Phase 0 decision: claude-bg is a peer dependency, not
|
|
10
|
+
* embedded.)
|
|
11
|
+
*
|
|
12
|
+
* --foreground: run the daemon in the current terminal. Used internally by
|
|
13
|
+
* claude-bg-spawn AND by users who prefer their own process supervisor.
|
|
14
|
+
*
|
|
15
|
+
* --status / --stop: thin wrappers over `claude-bg list --mine` and
|
|
16
|
+
* `claude-bg kill <name>`.
|
|
17
|
+
*
|
|
18
|
+
* --apply-now: bypass quiescence and apply immediately. Useful in CI.
|
|
19
|
+
*
|
|
20
|
+
* Library discipline (CR / VR-LIBRARY-NO-PROCESS-EXIT): runWatch() returns
|
|
21
|
+
* a result object; only cli.ts calls process.exit on the returned code.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawnSync } from 'child_process';
|
|
25
|
+
import { basename, dirname, resolve } from 'path';
|
|
26
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'fs';
|
|
27
|
+
import { runDetection } from '../detect/index.ts';
|
|
28
|
+
import { computeFingerprint } from '../detect/drift.ts';
|
|
29
|
+
import { runConfigRefresh } from './config-refresh.ts';
|
|
30
|
+
import { installAll } from './install-commands.ts';
|
|
31
|
+
import { withInstallLock, InstallLockBusyError } from '../lib/installLock.ts';
|
|
32
|
+
import { isPidAlive } from '../lib/pidLiveness.ts';
|
|
33
|
+
import { gitToplevel } from '../lib/gitToplevel.ts';
|
|
34
|
+
import { startDaemon } from '../watch/daemon.ts';
|
|
35
|
+
import { readState, updateState, watchStatePath } from '../watch/state.ts';
|
|
36
|
+
|
|
37
|
+
export interface WatchResult {
|
|
38
|
+
exitCode: 0 | 1 | 2;
|
|
39
|
+
message?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ParsedFlags {
|
|
43
|
+
foreground: boolean;
|
|
44
|
+
status: boolean;
|
|
45
|
+
stop: boolean;
|
|
46
|
+
applyNow: boolean;
|
|
47
|
+
root?: string;
|
|
48
|
+
help: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseFlags(args: string[]): ParsedFlags {
|
|
52
|
+
const out: ParsedFlags = {
|
|
53
|
+
foreground: false,
|
|
54
|
+
status: false,
|
|
55
|
+
stop: false,
|
|
56
|
+
applyNow: false,
|
|
57
|
+
help: false,
|
|
58
|
+
};
|
|
59
|
+
for (let i = 0; i < args.length; i++) {
|
|
60
|
+
const a = args[i];
|
|
61
|
+
if (a === '--foreground') out.foreground = true;
|
|
62
|
+
else if (a === '--status') out.status = true;
|
|
63
|
+
else if (a === '--stop') out.stop = true;
|
|
64
|
+
else if (a === '--apply-now') out.applyNow = true;
|
|
65
|
+
else if (a === '--help' || a === '-h') out.help = true;
|
|
66
|
+
else if (a === '--root') {
|
|
67
|
+
out.root = args[i + 1];
|
|
68
|
+
i++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function findClaudeBg(): string | null {
|
|
75
|
+
// Prefer the conventional ~/.claude/bin install path; fall back to PATH.
|
|
76
|
+
const home = process.env.HOME ?? '';
|
|
77
|
+
const fixed = home ? resolve(home, '.claude', 'bin', 'claude-bg') : null;
|
|
78
|
+
if (fixed && existsSync(fixed)) return fixed;
|
|
79
|
+
const which = spawnSync('which', ['claude-bg'], { encoding: 'utf-8' });
|
|
80
|
+
if (which.status === 0 && which.stdout) {
|
|
81
|
+
const p = which.stdout.trim();
|
|
82
|
+
if (p) return p;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function watchName(root: string): string {
|
|
88
|
+
return `massu-watch-${basename(root)}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function printHelp(out: (s: string) => void): void {
|
|
92
|
+
out(`
|
|
93
|
+
Usage: massu watch [flags]
|
|
94
|
+
|
|
95
|
+
Default Start the watcher under claude-bg.
|
|
96
|
+
--foreground Run the daemon in the current terminal (used by claude-bg).
|
|
97
|
+
--status Show running watcher info.
|
|
98
|
+
--stop Stop the watcher (kills via claude-bg).
|
|
99
|
+
--apply-now Bypass quiescence and apply immediately (CI use).
|
|
100
|
+
--root <dir> Daemon root (default: git rev-parse --show-toplevel).
|
|
101
|
+
-h, --help Show this help.
|
|
102
|
+
`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function runWatch(args: string[]): Promise<WatchResult> {
|
|
106
|
+
const flags = parseFlags(args);
|
|
107
|
+
if (flags.help) {
|
|
108
|
+
printHelp((s) => process.stdout.write(s));
|
|
109
|
+
return { exitCode: 0 };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const cwd = process.cwd();
|
|
113
|
+
const root = flags.root ?? gitToplevel(cwd);
|
|
114
|
+
|
|
115
|
+
if (flags.status) return runStatus(root);
|
|
116
|
+
if (flags.stop) return runStop(root);
|
|
117
|
+
if (flags.applyNow) return runApplyNow(root);
|
|
118
|
+
if (flags.foreground) return runForeground(root);
|
|
119
|
+
|
|
120
|
+
// Default: spawn under claude-bg.
|
|
121
|
+
const claudeBg = findClaudeBg();
|
|
122
|
+
if (!claudeBg) {
|
|
123
|
+
const msg =
|
|
124
|
+
'massu watch needs `claude-bg` on your PATH (or installed at ~/.claude/bin/claude-bg).\n' +
|
|
125
|
+
'Install: see https://massu.ai/docs/watch (or run `massu watch --foreground` to skip the bg supervisor).\n';
|
|
126
|
+
process.stderr.write(msg);
|
|
127
|
+
return { exitCode: 1, message: msg };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const name = watchName(root);
|
|
131
|
+
const res = spawnSync(claudeBg, [
|
|
132
|
+
'start',
|
|
133
|
+
'--name', name,
|
|
134
|
+
'--port', '0',
|
|
135
|
+
'--',
|
|
136
|
+
'npx', '@massu/core', 'watch', '--foreground', '--root', root,
|
|
137
|
+
], { stdio: 'inherit' });
|
|
138
|
+
|
|
139
|
+
return { exitCode: (res.status ?? 1) === 0 ? 0 : 1 };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function runStatus(root: string): WatchResult {
|
|
143
|
+
const path = watchStatePath(root);
|
|
144
|
+
if (!existsSync(path)) {
|
|
145
|
+
process.stdout.write('massu watch: not running (no state file)\n');
|
|
146
|
+
return { exitCode: 0 };
|
|
147
|
+
}
|
|
148
|
+
let state;
|
|
149
|
+
try {
|
|
150
|
+
state = readState(root);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
process.stderr.write(`massu watch: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
153
|
+
return { exitCode: 2 };
|
|
154
|
+
}
|
|
155
|
+
const alive = state.daemonPid ? isPidAlive(state.daemonPid) : false;
|
|
156
|
+
process.stdout.write(
|
|
157
|
+
`massu watch: ${alive ? 'running' : 'stale'}\n` +
|
|
158
|
+
` pid: ${state.daemonPid ?? 'n/a'}\n` +
|
|
159
|
+
` started: ${state.startedAt ?? 'n/a'}\n` +
|
|
160
|
+
` last refresh: ${state.lastRefreshAt ?? 'never'}\n` +
|
|
161
|
+
` last tick: ${state.tickedAt ?? 'never'}\n` +
|
|
162
|
+
` last error: ${formatLastError(state.lastError)}\n`
|
|
163
|
+
);
|
|
164
|
+
return { exitCode: 0 };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Iter-7 fix: `state.lastError` may be multi-line (e.g. a chokidar stack
|
|
169
|
+
* trace persisted via `updateState({lastError: msg})`). Rendering it raw on
|
|
170
|
+
* the single-line `last error: ...` row produces messy column-broken output.
|
|
171
|
+
* Collapse to the first non-empty line + " (...)" indicator when there were
|
|
172
|
+
* additional lines, capped at 200 chars so a runaway message can't push
|
|
173
|
+
* `runStatus` into multi-screen output.
|
|
174
|
+
*/
|
|
175
|
+
function formatLastError(raw: string | null): string {
|
|
176
|
+
if (raw === null || raw === undefined || raw === '') return 'none';
|
|
177
|
+
const lines = raw.split(/\r?\n/).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
178
|
+
if (lines.length === 0) return 'none';
|
|
179
|
+
const head = lines[0];
|
|
180
|
+
const truncated = head.length > 200 ? head.slice(0, 197) + '...' : head;
|
|
181
|
+
return lines.length > 1 ? `${truncated} (+${lines.length - 1} more line(s))` : truncated;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function runStop(root: string): WatchResult {
|
|
185
|
+
const claudeBg = findClaudeBg();
|
|
186
|
+
if (!claudeBg) {
|
|
187
|
+
process.stderr.write('massu watch --stop: claude-bg not found; cannot kill registered daemon\n');
|
|
188
|
+
return { exitCode: 1 };
|
|
189
|
+
}
|
|
190
|
+
const res = spawnSync(claudeBg, ['kill', watchName(root)], { stdio: 'inherit' });
|
|
191
|
+
return { exitCode: (res.status ?? 1) === 0 ? 0 : 1 };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function runApplyNow(root: string): Promise<WatchResult> {
|
|
195
|
+
// One-shot manual refresh — same code path as the daemon's onQuiescent.
|
|
196
|
+
await runOnQuiescent(root);
|
|
197
|
+
return { exitCode: 0 };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Iter-8 fix (Plan 3a §256 risk #6): "second watcher with same toplevel
|
|
202
|
+
* refuses to start". Returns a non-null message when another live daemon
|
|
203
|
+
* already owns this root, else null. Exposed for unit testing — the
|
|
204
|
+
* runForeground caller writes the message to stderr and returns exit 1.
|
|
205
|
+
*
|
|
206
|
+
* Without this guard, two `massu watch --foreground` calls racing on the
|
|
207
|
+
* same repo result in two chokidar watchers, two debounce timers, two
|
|
208
|
+
* fireRefresh paths writing watch-state.json + refresh-log concurrently —
|
|
209
|
+
* the install-lock prevents data corruption but the user gets bursty
|
|
210
|
+
* `installAll already running` stderr spew on every quiescence cycle.
|
|
211
|
+
*
|
|
212
|
+
* The pre-flight peek at watch-state.json + isPidAlive is cheap and
|
|
213
|
+
* catches the common case (user re-running the command in another shell)
|
|
214
|
+
* before any side effects.
|
|
215
|
+
*/
|
|
216
|
+
export function checkConflictingDaemon(
|
|
217
|
+
root: string,
|
|
218
|
+
selfPid: number = process.pid,
|
|
219
|
+
pidAlive: (pid: number) => boolean = isPidAlive,
|
|
220
|
+
): string | null {
|
|
221
|
+
let existingState;
|
|
222
|
+
try {
|
|
223
|
+
existingState = readState(root);
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
if (
|
|
228
|
+
!existingState ||
|
|
229
|
+
typeof existingState.daemonPid !== 'number' ||
|
|
230
|
+
existingState.daemonPid <= 0 ||
|
|
231
|
+
existingState.daemonPid === selfPid ||
|
|
232
|
+
!pidAlive(existingState.daemonPid)
|
|
233
|
+
) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
return (
|
|
237
|
+
`massu watch: another daemon is already running for this root (PID=${existingState.daemonPid}).\n` +
|
|
238
|
+
` root: ${root}\n` +
|
|
239
|
+
` state: ${watchStatePath(root)}\n` +
|
|
240
|
+
` to stop it: massu watch --stop\n`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Foreground daemon entry. Resolves when SIGINT / SIGTERM is received.
|
|
246
|
+
* cli.ts (the only allowed process.exit caller per VR-LIBRARY-NO-PROCESS-EXIT)
|
|
247
|
+
* exits the process with the returned code.
|
|
248
|
+
*/
|
|
249
|
+
async function runForeground(root: string): Promise<WatchResult> {
|
|
250
|
+
const conflict = checkConflictingDaemon(root);
|
|
251
|
+
if (conflict !== null) {
|
|
252
|
+
process.stderr.write(conflict);
|
|
253
|
+
return { exitCode: 1, message: conflict };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Ensure the watcher writes startup state at the registered root, not cwd.
|
|
257
|
+
// Save the prior cwd so shutdown can restore it (defense-in-depth — when
|
|
258
|
+
// SIGINT/SIGTERM fires the process is exiting, but tests and any embedder
|
|
259
|
+
// calling runForeground multiple times benefit from a clean restore).
|
|
260
|
+
const priorCwd = process.cwd();
|
|
261
|
+
process.chdir(root);
|
|
262
|
+
|
|
263
|
+
// Iter-2 fix: if startDaemon throws (e.g., invalid config, chokidar
|
|
264
|
+
// bootstrap fails), we must restore cwd before propagating — otherwise
|
|
265
|
+
// tests and embedders are left with a permanently-changed cwd.
|
|
266
|
+
let stopped = false;
|
|
267
|
+
let handle;
|
|
268
|
+
try {
|
|
269
|
+
handle = await startDaemon(root, {
|
|
270
|
+
onQuiescent: () => runOnQuiescent(root),
|
|
271
|
+
});
|
|
272
|
+
} catch (err) {
|
|
273
|
+
try { process.chdir(priorCwd); } catch { /* best-effort */ }
|
|
274
|
+
throw err;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return new Promise<WatchResult>((resolve) => {
|
|
278
|
+
const shutdown = async (): Promise<void> => {
|
|
279
|
+
if (stopped) return;
|
|
280
|
+
stopped = true;
|
|
281
|
+
process.stderr.write('[massu] shutting down watcher\n');
|
|
282
|
+
await handle.stop();
|
|
283
|
+
try {
|
|
284
|
+
process.chdir(priorCwd);
|
|
285
|
+
} catch {
|
|
286
|
+
// priorCwd may have been removed — best-effort restore.
|
|
287
|
+
}
|
|
288
|
+
resolve({ exitCode: 0 });
|
|
289
|
+
};
|
|
290
|
+
process.on('SIGINT', () => { void shutdown(); });
|
|
291
|
+
process.on('SIGTERM', () => { void shutdown(); });
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Plan 3a Phase 6 quiescence callback:
|
|
297
|
+
* 1. runDetection() — fresh stack inventory
|
|
298
|
+
* 2. computeFingerprint() — compare to lastFingerprint
|
|
299
|
+
* 3. on change: runConfigRefresh({silent, autoYes:true, skipCommands:true})
|
|
300
|
+
* (per iter-3 G3-A9 option A: skip the recursive installAll inside
|
|
301
|
+
* runConfigRefresh so we own the single `installAll` call ourselves)
|
|
302
|
+
* 4. installAll(projectRoot) under withInstallLock
|
|
303
|
+
* 5. updateState({lastFingerprint, lastRefreshAt})
|
|
304
|
+
* 6. Append refresh-log event
|
|
305
|
+
* 7. Stderr: `[massu] Stack changed, commands updated (N files). Diff: massu refresh-log latest`
|
|
306
|
+
*/
|
|
307
|
+
export async function runOnQuiescent(projectRoot: string): Promise<void> {
|
|
308
|
+
const detection = await runDetection(projectRoot);
|
|
309
|
+
const newFingerprint = computeFingerprint(detection);
|
|
310
|
+
const state = readState(projectRoot);
|
|
311
|
+
|
|
312
|
+
if (state.lastFingerprint === newFingerprint && state.lastFingerprint !== null) {
|
|
313
|
+
// Nothing actually changed in the stack; only file events fired.
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Refresh the YAML (auto-apply, but DO NOT recursively installAll).
|
|
318
|
+
const refresh = await runConfigRefresh({
|
|
319
|
+
cwd: projectRoot,
|
|
320
|
+
silent: true,
|
|
321
|
+
autoYes: true,
|
|
322
|
+
skipCommands: true,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
if (refresh.exitCode !== 0) {
|
|
326
|
+
updateState(projectRoot, { lastError: refresh.message ?? 'refresh failed' });
|
|
327
|
+
process.stderr.write(`[massu] config refresh failed: ${refresh.message ?? 'unknown'}\n`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let installResult;
|
|
332
|
+
try {
|
|
333
|
+
installResult = withInstallLock(projectRoot, () => installAll(projectRoot));
|
|
334
|
+
} catch (err) {
|
|
335
|
+
if (err instanceof InstallLockBusyError) {
|
|
336
|
+
// Another caller already installing — let them finish; next event re-fires.
|
|
337
|
+
process.stderr.write(`[massu] ${err.message}\n`);
|
|
338
|
+
updateState(projectRoot, { lastError: err.message });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
throw err;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const filesTouched =
|
|
345
|
+
installResult.totalInstalled +
|
|
346
|
+
installResult.totalUpdated;
|
|
347
|
+
|
|
348
|
+
appendRefreshLog(projectRoot, {
|
|
349
|
+
at: new Date().toISOString(),
|
|
350
|
+
fromFingerprint: state.lastFingerprint,
|
|
351
|
+
toFingerprint: newFingerprint,
|
|
352
|
+
filesInstalled: installResult.totalInstalled,
|
|
353
|
+
filesUpdated: installResult.totalUpdated,
|
|
354
|
+
filesKept: installResult.totalKept,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
updateState(projectRoot, {
|
|
358
|
+
lastFingerprint: newFingerprint,
|
|
359
|
+
lastRefreshAt: new Date().toISOString(),
|
|
360
|
+
lastError: null,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
process.stderr.write(
|
|
364
|
+
`[massu] Stack changed, commands updated (${filesTouched} files). Diff: massu refresh-log latest\n`
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ============================================================
|
|
369
|
+
// refresh-log support — append-only JSONL at .massu/refresh-log.jsonl
|
|
370
|
+
// ============================================================
|
|
371
|
+
|
|
372
|
+
export interface RefreshLogEvent {
|
|
373
|
+
at: string;
|
|
374
|
+
fromFingerprint: string | null;
|
|
375
|
+
toFingerprint: string;
|
|
376
|
+
filesInstalled: number;
|
|
377
|
+
filesUpdated: number;
|
|
378
|
+
filesKept: number;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function refreshLogPath(projectRoot: string): string {
|
|
382
|
+
return resolve(projectRoot, '.massu', 'refresh-log.jsonl');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function appendRefreshLog(projectRoot: string, event: RefreshLogEvent): void {
|
|
386
|
+
const path = refreshLogPath(projectRoot);
|
|
387
|
+
try {
|
|
388
|
+
// Append-only — at-most-one-line-per-event ensures partial writes corrupt
|
|
389
|
+
// only the trailing line, which JSONL readers naturally tolerate.
|
|
390
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
391
|
+
appendFileSync(path, JSON.stringify(event) + '\n', 'utf-8');
|
|
392
|
+
} catch {
|
|
393
|
+
// best-effort; never let log-write crash the daemon.
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export interface ReadRefreshLogOpts {
|
|
398
|
+
/** Stderr writer for corrupt-line warnings (test seam; defaults to process.stderr). */
|
|
399
|
+
warn?: (s: string) => void;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function readRefreshLog(
|
|
403
|
+
projectRoot: string,
|
|
404
|
+
limit = 10,
|
|
405
|
+
opts: ReadRefreshLogOpts = {},
|
|
406
|
+
): RefreshLogEvent[] {
|
|
407
|
+
const path = refreshLogPath(projectRoot);
|
|
408
|
+
if (!existsSync(path)) return [];
|
|
409
|
+
const warn = opts.warn ?? ((s: string) => { process.stderr.write(s); });
|
|
410
|
+
const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean);
|
|
411
|
+
const tail = lines.slice(-limit);
|
|
412
|
+
const out: RefreshLogEvent[] = [];
|
|
413
|
+
let corrupt = 0;
|
|
414
|
+
for (const line of tail) {
|
|
415
|
+
try {
|
|
416
|
+
const obj = JSON.parse(line);
|
|
417
|
+
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
418
|
+
out.push(obj as RefreshLogEvent);
|
|
419
|
+
} else {
|
|
420
|
+
corrupt++;
|
|
421
|
+
}
|
|
422
|
+
} catch {
|
|
423
|
+
corrupt++;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (corrupt > 0) {
|
|
427
|
+
warn(`[massu] refresh-log: skipped ${corrupt} corrupt line(s) in ${path}\n`);
|
|
428
|
+
}
|
|
429
|
+
return out;
|
|
430
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -301,6 +301,15 @@ const FrameworkConfigSchema = z.object({
|
|
|
301
301
|
}).passthrough();
|
|
302
302
|
export type FrameworkConfig = z.infer<typeof FrameworkConfigSchema>;
|
|
303
303
|
|
|
304
|
+
// --- Codebase-aware templates (Plan #2): `detected:` block ---
|
|
305
|
+
// Detector-owned per-language conventions extracted from existing source files
|
|
306
|
+
// (auth dep names, common imports, biometric policies, etc.). Refreshed on
|
|
307
|
+
// every `init`/`config refresh` and consumed by the templating engine when
|
|
308
|
+
// installing slash commands. Free-form via `.passthrough()` so future detector
|
|
309
|
+
// fields don't break parsing of older configs.
|
|
310
|
+
const DetectedConfigSchema = z.object({}).passthrough().optional();
|
|
311
|
+
export type DetectedConfig = z.infer<typeof DetectedConfigSchema>;
|
|
312
|
+
|
|
304
313
|
// --- P2-004: Verification command map ---
|
|
305
314
|
// Map of language name -> command strings for each verification type.
|
|
306
315
|
// User entries take precedence over any Phase 1 auto-defaults.
|
|
@@ -342,6 +351,53 @@ const DetectionConfigSchema = z.object({
|
|
|
342
351
|
}).passthrough().optional();
|
|
343
352
|
export type DetectionConfig = z.infer<typeof DetectionConfigSchema>;
|
|
344
353
|
|
|
354
|
+
// --- Watch Config (Plan 3a — `massu watch` daemon) ---
|
|
355
|
+
// Tunable knobs for the file-watcher daemon. All optional with sensible
|
|
356
|
+
// defaults; users override only when their repo has unusual quiescence
|
|
357
|
+
// patterns (e.g. monorepos with continuous codegen, NFS-mounted volumes).
|
|
358
|
+
const WatchConfigSchema = z.object({
|
|
359
|
+
debounce_ms: z.number().int().positive().default(3000),
|
|
360
|
+
storm_threshold: z.number().int().positive().default(50),
|
|
361
|
+
deep_storm_threshold: z.number().int().positive().default(500),
|
|
362
|
+
hard_timeout_ms: z.number().int().positive().default(300_000),
|
|
363
|
+
scope: z.enum(['paths', 'full']).default('paths'),
|
|
364
|
+
// Plan 3a hotfix 2026-05-02: refuse to start if the watch surface
|
|
365
|
+
// exceeds this many files. Prevents the misconfig pattern where
|
|
366
|
+
// `paths.source_dirs` includes `.` or otherwise expands to a 60K+
|
|
367
|
+
// file tree, producing 30-100% steady CPU. Override via
|
|
368
|
+
// `paths_full_root_opt_in: true` for users on small repos who genuinely
|
|
369
|
+
// need root-level watching.
|
|
370
|
+
max_watched_files: z.number().int().positive().default(10_000),
|
|
371
|
+
paths_full_root_opt_in: z.boolean().default(false),
|
|
372
|
+
}).passthrough().optional();
|
|
373
|
+
export type WatchConfig = z.infer<typeof WatchConfigSchema>;
|
|
374
|
+
|
|
375
|
+
// --- LSP Config (Plan 3b — Phase 4: LSP integration) ---
|
|
376
|
+
// Top-level optional `lsp:` block configuring optional LSP enrichment of AST
|
|
377
|
+
// adapter results. Per VR-LSP-AUTODETECT-OFF-BY-DEFAULT (audit-iter-1 fix G4):
|
|
378
|
+
// `autoDetect.viaPortScan` defaults to false — port-scanning is opt-in only.
|
|
379
|
+
// Empty-servers + enabled is a valid runtime state (see auto-detect.ts);
|
|
380
|
+
// schema does not reject it.
|
|
381
|
+
export const LSPConfigSchema = z.object({
|
|
382
|
+
enabled: z.boolean().default(false),
|
|
383
|
+
servers: z.array(z.object({
|
|
384
|
+
language: z.string(),
|
|
385
|
+
command: z.string(),
|
|
386
|
+
// F-014 (closed 2026-05-06): explicit opt-in to spawn SUID/SGID
|
|
387
|
+
// binaries. Default false — argv[0] with the SUID bit is rejected
|
|
388
|
+
// unless this is true. Decision is auditable in the YAML.
|
|
389
|
+
allow_setuid: z.boolean().default(false),
|
|
390
|
+
// F-015 (closed 2026-05-06): per-server RSS budget (MB). Watchdog
|
|
391
|
+
// SIGKILLs the server after sustained breach. Default 1024 MB.
|
|
392
|
+
// Set to 0 to disable the watchdog for this server.
|
|
393
|
+
max_rss_mb: z.number().int().nonnegative().default(1024),
|
|
394
|
+
})).default([]),
|
|
395
|
+
autoDetect: z.object({
|
|
396
|
+
viaPortScan: z.boolean().default(false),
|
|
397
|
+
}).optional(),
|
|
398
|
+
}).passthrough();
|
|
399
|
+
export type LSPConfig = z.infer<typeof LSPConfigSchema>;
|
|
400
|
+
|
|
345
401
|
// --- Top-level Raw Config Schema ---
|
|
346
402
|
// This validates the raw YAML output, coercing types and providing defaults.
|
|
347
403
|
// P2-001: schema_version tracks the config shape version. Defaults to 1 so
|
|
@@ -380,6 +436,12 @@ const RawConfigSchema = z.object({
|
|
|
380
436
|
canonical_paths: CanonicalPathsSchema,
|
|
381
437
|
verification_types: VerificationTypesSchema,
|
|
382
438
|
detection: DetectionConfigSchema,
|
|
439
|
+
// Plan #2: detector-owned per-language conventions (free-form passthrough)
|
|
440
|
+
detected: DetectedConfigSchema,
|
|
441
|
+
// Plan 3a: file-watcher daemon tunables
|
|
442
|
+
watch: WatchConfigSchema,
|
|
443
|
+
// Plan 3b Phase 4: optional LSP enrichment of AST adapter results.
|
|
444
|
+
lsp: LSPConfigSchema.optional(),
|
|
383
445
|
}).passthrough();
|
|
384
446
|
|
|
385
447
|
// --- Final Config interface (derived from Zod) ---
|
|
@@ -418,6 +480,12 @@ export interface Config {
|
|
|
418
480
|
canonical_paths?: CanonicalPaths;
|
|
419
481
|
verification_types?: VerificationTypes;
|
|
420
482
|
detection?: DetectionConfig;
|
|
483
|
+
// Plan #2: detector-owned per-language conventions
|
|
484
|
+
detected?: DetectedConfig;
|
|
485
|
+
// Plan 3a: file-watcher daemon tunables
|
|
486
|
+
watch?: WatchConfig;
|
|
487
|
+
// Plan 3b Phase 4: optional LSP enrichment block (default disabled).
|
|
488
|
+
lsp?: LSPConfig;
|
|
421
489
|
}
|
|
422
490
|
|
|
423
491
|
let _config: Config | null = null;
|
|
@@ -565,6 +633,9 @@ export function getConfig(): Config {
|
|
|
565
633
|
canonical_paths: parsed.canonical_paths,
|
|
566
634
|
verification_types: parsed.verification_types,
|
|
567
635
|
detection: parsed.detection,
|
|
636
|
+
detected: parsed.detected,
|
|
637
|
+
watch: parsed.watch,
|
|
638
|
+
lsp: parsed.lsp,
|
|
568
639
|
};
|
|
569
640
|
|
|
570
641
|
// Allow environment variable override for API key (security best practice)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Plan 3b — Phase 1: Next.js + tRPC AST adapter.
|
|
6
|
+
*
|
|
7
|
+
* Extracts:
|
|
8
|
+
* - trpc_router_builder: name of router-creation call (createTRPCRouter, t.router, router)
|
|
9
|
+
* - procedure_pattern: identifier ending in `Procedure` (publicProcedure, protectedProcedure)
|
|
10
|
+
* - ctx_shape: 'object' | 'function' | null — based on resolver signature shape
|
|
11
|
+
*
|
|
12
|
+
* Looks under `server/api/routers/` or `server/trpc/` paths. The runner is
|
|
13
|
+
* responsible for sampling files into those paths; this adapter assumes the
|
|
14
|
+
* `files` it receives are router-shaped.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Parser } from 'web-tree-sitter';
|
|
18
|
+
import type { CodebaseAdapter, AdapterResult, DetectionSignals, Provenance, SourceFile } from './types.ts';
|
|
19
|
+
import { runQuery, InvalidQueryError } from './query-helpers.ts';
|
|
20
|
+
import { loadGrammar } from './tree-sitter-loader.ts';
|
|
21
|
+
import { isParsableSource, MAX_AST_FILE_BYTES } from './parse-guard.ts';
|
|
22
|
+
|
|
23
|
+
// ============================================================
|
|
24
|
+
// Queries
|
|
25
|
+
// ============================================================
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Router builder call: `createTRPCRouter({...})` or `t.router({...})`. Captures
|
|
29
|
+
* the call's function expression so the runner can normalize it.
|
|
30
|
+
*/
|
|
31
|
+
const ROUTER_BUILDER_QUERY = `
|
|
32
|
+
(call_expression
|
|
33
|
+
function: (identifier) @builder_id (#match? @builder_id "^(createTRPCRouter|router)$"))
|
|
34
|
+
|
|
35
|
+
(call_expression
|
|
36
|
+
function: (member_expression
|
|
37
|
+
object: (identifier) @_obj
|
|
38
|
+
property: (property_identifier) @_prop (#eq? @_prop "router"))) @member_call
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Procedure usage: `publicProcedure.input(...)` / `protectedProcedure.query(...)`.
|
|
43
|
+
* Captures any identifier that ends in `Procedure`.
|
|
44
|
+
*/
|
|
45
|
+
const PROCEDURE_QUERY = `
|
|
46
|
+
(member_expression
|
|
47
|
+
object: (identifier) @procedure_id (#match? @procedure_id "Procedure$"))
|
|
48
|
+
|
|
49
|
+
(call_expression
|
|
50
|
+
function: (identifier) @procedure_call (#match? @procedure_call "Procedure$"))
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
// ============================================================
|
|
54
|
+
// Adapter
|
|
55
|
+
// ============================================================
|
|
56
|
+
|
|
57
|
+
const KNOWN_BUILDERS = new Set(['createTRPCRouter', 'router']);
|
|
58
|
+
|
|
59
|
+
export const nextjsTrpcAdapter: CodebaseAdapter = {
|
|
60
|
+
id: 'nextjs-trpc',
|
|
61
|
+
languages: ['typescript'],
|
|
62
|
+
|
|
63
|
+
matches(signals: DetectionSignals): boolean {
|
|
64
|
+
// package.json deps include @trpc/* OR there's a server/api/routers dir
|
|
65
|
+
const pkgJson = signals.packageJson;
|
|
66
|
+
if (pkgJson) {
|
|
67
|
+
const deps = pkgJson.dependencies as Record<string, unknown> | undefined;
|
|
68
|
+
const devDeps = pkgJson.devDependencies as Record<string, unknown> | undefined;
|
|
69
|
+
const all = { ...(deps ?? {}), ...(devDeps ?? {}) };
|
|
70
|
+
if (Object.keys(all).some(k => k.startsWith('@trpc/'))) return true;
|
|
71
|
+
}
|
|
72
|
+
if (signals.presentDirs.has('server')) return true;
|
|
73
|
+
return false;
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
async introspect(files: SourceFile[], _rootDir: string): Promise<AdapterResult> {
|
|
77
|
+
if (files.length === 0) {
|
|
78
|
+
return { conventions: {}, provenance: [], confidence: 'none' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let language;
|
|
82
|
+
try {
|
|
83
|
+
language = await loadGrammar('typescript');
|
|
84
|
+
} catch {
|
|
85
|
+
return { conventions: {}, provenance: [], confidence: 'none' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const parser = new Parser();
|
|
89
|
+
parser.setLanguage(language);
|
|
90
|
+
|
|
91
|
+
const builders = new Map<string, { line: number; file: string }>();
|
|
92
|
+
const procedures = new Map<string, { line: number; file: string }>();
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
for (const file of files) {
|
|
96
|
+
const skip = isParsableSource(file.content, file.size);
|
|
97
|
+
if (skip) {
|
|
98
|
+
process.stderr.write(
|
|
99
|
+
`[massu/ast] WARN: nextjs-trpc skipping ${file.path}: ${skip.reason} (${skip.detail}). Cap=${MAX_AST_FILE_BYTES}. (Phase 3.5 mitigation)\n`,
|
|
100
|
+
);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
for (const hit of runQuery(parser, file.content, ROUTER_BUILDER_QUERY, 'trpc-router-builder', file.path)) {
|
|
105
|
+
// Either capture group `builder_id` (direct call) or
|
|
106
|
+
// `member_call` (the whole `t.router(...)` expression).
|
|
107
|
+
const directId = hit.captures.builder_id;
|
|
108
|
+
const memberCall = hit.captures.member_call;
|
|
109
|
+
let label: string | null = null;
|
|
110
|
+
if (directId && KNOWN_BUILDERS.has(directId)) {
|
|
111
|
+
label = directId;
|
|
112
|
+
} else if (memberCall) {
|
|
113
|
+
// Normalize `t.router` text (member_call captures the whole call)
|
|
114
|
+
// into the bare `t.router` form by extracting the leading
|
|
115
|
+
// identifier.foo pattern.
|
|
116
|
+
const m = memberCall.match(/([A-Za-z_$][A-Za-z0-9_$]*)\.router/);
|
|
117
|
+
if (m) label = `${m[1]}.router`;
|
|
118
|
+
}
|
|
119
|
+
if (label && !builders.has(label)) {
|
|
120
|
+
builders.set(label, { line: hit.line, file: file.path });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const hit of runQuery(parser, file.content, PROCEDURE_QUERY, 'trpc-procedure', file.path)) {
|
|
125
|
+
const proc = hit.captures.procedure_id ?? hit.captures.procedure_call;
|
|
126
|
+
if (proc && !procedures.has(proc)) {
|
|
127
|
+
procedures.set(proc, { line: hit.line, file: file.path });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} catch (e) {
|
|
131
|
+
if (e instanceof InvalidQueryError) throw e;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} finally {
|
|
136
|
+
try { parser.delete(); } catch { /* ignore */ }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const conventions: Record<string, unknown> = {};
|
|
140
|
+
const provenance: Provenance[] = [];
|
|
141
|
+
|
|
142
|
+
if (builders.size > 0) {
|
|
143
|
+
const [name, { line, file }] = builders.entries().next().value as [string, { line: number; file: string }];
|
|
144
|
+
conventions.trpc_router_builder = name;
|
|
145
|
+
provenance.push({ field: 'trpc_router_builder', sourceFile: file, line, query: 'trpc-router-builder' });
|
|
146
|
+
}
|
|
147
|
+
if (procedures.size > 0) {
|
|
148
|
+
const [name, { line, file }] = procedures.entries().next().value as [string, { line: number; file: string }];
|
|
149
|
+
conventions.procedure_pattern = name;
|
|
150
|
+
provenance.push({ field: 'procedure_pattern', sourceFile: file, line, query: 'trpc-procedure' });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let confidence: AdapterResult['confidence'];
|
|
154
|
+
if (Object.keys(conventions).length === 0) {
|
|
155
|
+
confidence = 'none';
|
|
156
|
+
} else if (builders.size === 1 && procedures.size <= 2) {
|
|
157
|
+
confidence = 'high';
|
|
158
|
+
} else if (builders.size > 1) {
|
|
159
|
+
confidence = 'low';
|
|
160
|
+
} else {
|
|
161
|
+
confidence = 'medium';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { conventions, provenance, confidence };
|
|
165
|
+
},
|
|
166
|
+
};
|