@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,449 @@
|
|
|
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: Regex Fallback Introspector.
|
|
6
|
+
*
|
|
7
|
+
* Verbatim move of the per-language regex helpers that were previously in
|
|
8
|
+
* `codebase-introspector.ts` (Plan #2 P3-001). NO regex logic changes — only
|
|
9
|
+
* the import path. This preserves Plan #2's 13 vitest cases as-is (re-imports
|
|
10
|
+
* are updated in `codebase-introspector.test.ts` only if needed; the moved
|
|
11
|
+
* functions retain their original signatures).
|
|
12
|
+
*
|
|
13
|
+
* The new 2-tier introspector (`codebase-introspector.ts`) calls these
|
|
14
|
+
* functions ONLY for fields where AST adapters returned 'none' confidence.
|
|
15
|
+
* AST adapters produce values into separate `detected.<adapter.id>` blocks;
|
|
16
|
+
* regex output continues to go into `detected.python` / `.swift` /
|
|
17
|
+
* `.typescript` blocks.
|
|
18
|
+
*
|
|
19
|
+
* Design rules carried over from Plan #2:
|
|
20
|
+
* - File size cap: 256KB per file (skip silently if larger).
|
|
21
|
+
* - Sample cap: at most 3 files per adapter (sorted, deterministic order).
|
|
22
|
+
* - Total wall-clock budget: <2s on a 10K-file repo.
|
|
23
|
+
* - Filesystem-only. No network, no child processes, no DB.
|
|
24
|
+
* - Returns `null` for any field it can't confidently extract.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
28
|
+
import { resolve, join, basename } from 'path';
|
|
29
|
+
import type { DetectionResult } from './index.ts';
|
|
30
|
+
|
|
31
|
+
export const MAX_FILE_BYTES = 256 * 1024;
|
|
32
|
+
export const MAX_SAMPLES_PER_ADAPTER = 3;
|
|
33
|
+
export const MAX_DIR_DEPTH = 6;
|
|
34
|
+
|
|
35
|
+
// ============================================================
|
|
36
|
+
// Public types (re-exported from codebase-introspector for compat)
|
|
37
|
+
// ============================================================
|
|
38
|
+
|
|
39
|
+
export interface DetectedPython {
|
|
40
|
+
auth_dep?: string;
|
|
41
|
+
api_prefix_base?: string;
|
|
42
|
+
test_async_pattern?: string;
|
|
43
|
+
_provenance?: Record<string, string>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface DetectedSwift {
|
|
47
|
+
api_client_class?: string;
|
|
48
|
+
biometric_policy?: string;
|
|
49
|
+
_provenance?: Record<string, string>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface DetectedTypeScript {
|
|
53
|
+
trpc_router_builder?: string;
|
|
54
|
+
procedure_pattern?: string;
|
|
55
|
+
_provenance?: Record<string, string>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ============================================================
|
|
59
|
+
// Python adapter (FastAPI + Django)
|
|
60
|
+
// ============================================================
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Introspect Python sources. Probes both FastAPI router files (`routers/*.py`,
|
|
64
|
+
* `api/*.py`) and Django views (`views.py`). Returns the most-extracted shape.
|
|
65
|
+
*/
|
|
66
|
+
export function introspectPython(
|
|
67
|
+
detection: DetectionResult,
|
|
68
|
+
projectRoot: string,
|
|
69
|
+
): DetectedPython | null {
|
|
70
|
+
const sourceDir = resolveSourceDir(detection, 'python', projectRoot);
|
|
71
|
+
if (!sourceDir) return null;
|
|
72
|
+
|
|
73
|
+
// Sample router-shaped files first (FastAPI / Flask), then views.py (Django),
|
|
74
|
+
// then any .py file as a last resort. Match on PATH, not just filename:
|
|
75
|
+
// FastAPI projects typically name routers by resource (`options.py`, `tax.py`)
|
|
76
|
+
// and place them under a `routers/` directory, so basename-only matching
|
|
77
|
+
// misses them. Also accept files where the basename itself is router-shaped
|
|
78
|
+
// (e.g., `endpoints.py`, `api.py`).
|
|
79
|
+
const routerFiles = sampleFiles(sourceDir, /\.py$/, (absPath, name) =>
|
|
80
|
+
/\/(routers?|api|endpoints?|views)\//.test(absPath) ||
|
|
81
|
+
/^(routers?|api|endpoints?)\.py$/.test(name),
|
|
82
|
+
);
|
|
83
|
+
const viewFiles = sampleFiles(sourceDir, /^views\.py$/);
|
|
84
|
+
const fallbackFiles = routerFiles.length === 0 && viewFiles.length === 0
|
|
85
|
+
? sampleFiles(sourceDir, /\.py$/)
|
|
86
|
+
: [];
|
|
87
|
+
const candidates = [...routerFiles, ...viewFiles, ...fallbackFiles].slice(
|
|
88
|
+
0,
|
|
89
|
+
MAX_SAMPLES_PER_ADAPTER,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
if (candidates.length === 0) return null;
|
|
93
|
+
|
|
94
|
+
const authDeps = new Map<string, string>(); // value → first source path
|
|
95
|
+
const prefixBases = new Map<string, string>();
|
|
96
|
+
const testAsyncPatterns = new Map<string, string>();
|
|
97
|
+
|
|
98
|
+
for (const path of candidates) {
|
|
99
|
+
const body = readSafe(path);
|
|
100
|
+
if (body === null) continue;
|
|
101
|
+
|
|
102
|
+
// Auth dependency: `Depends(<name>)` — capture the call's argument.
|
|
103
|
+
// ReDoS-safe: anchored, short window, no nested quantifiers.
|
|
104
|
+
const authRegex = /\bDepends\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/gu;
|
|
105
|
+
forEachMatch(authRegex, body, m => {
|
|
106
|
+
const name = m[1];
|
|
107
|
+
if (!authDeps.has(name)) authDeps.set(name, path);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Django: `@login_required` or `@permission_required` decorators.
|
|
111
|
+
const djangoAuthRegex = /^@\s*([a-z_][a-z0-9_]*(?:_required|_login))\b/gmu;
|
|
112
|
+
forEachMatch(djangoAuthRegex, body, m => {
|
|
113
|
+
const name = m[1];
|
|
114
|
+
if (!authDeps.has(name)) authDeps.set(name, path);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// API prefix base: `APIRouter(prefix="/api/...")` — capture just the BASE
|
|
118
|
+
// (everything up to the second `/` from the start).
|
|
119
|
+
const prefixRegex = /\bAPIRouter\s*\(\s*[^)]*?prefix\s*=\s*["']([^"']+)["']/gu;
|
|
120
|
+
forEachMatch(prefixRegex, body, m => {
|
|
121
|
+
const fullPrefix = m[1];
|
|
122
|
+
const base = extractPrefixBase(fullPrefix);
|
|
123
|
+
if (base && !prefixBases.has(base)) prefixBases.set(base, path);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Test async pattern: `@pytest.mark.asyncio` (with possible parens).
|
|
127
|
+
const asyncRegex = /^(@pytest\.mark\.asyncio(?:\s*\([^)]*\))?)/gmu;
|
|
128
|
+
forEachMatch(asyncRegex, body, m => {
|
|
129
|
+
const pat = m[1].trim();
|
|
130
|
+
if (!testAsyncPatterns.has(pat)) testAsyncPatterns.set(pat, path);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const authDep = pickBestSingleton(authDeps);
|
|
135
|
+
const apiPrefixBase = pickBestSingleton(prefixBases);
|
|
136
|
+
const testAsyncPattern = pickBestSingleton(testAsyncPatterns);
|
|
137
|
+
|
|
138
|
+
const result: DetectedPython = {};
|
|
139
|
+
const provenance: Record<string, string> = {};
|
|
140
|
+
|
|
141
|
+
if (authDep) {
|
|
142
|
+
result.auth_dep = authDep.value;
|
|
143
|
+
provenance.auth_dep_source = relativeTo(projectRoot, authDep.source);
|
|
144
|
+
}
|
|
145
|
+
if (apiPrefixBase) {
|
|
146
|
+
result.api_prefix_base = apiPrefixBase.value;
|
|
147
|
+
provenance.api_prefix_base_source = relativeTo(projectRoot, apiPrefixBase.source);
|
|
148
|
+
}
|
|
149
|
+
if (testAsyncPattern) {
|
|
150
|
+
result.test_async_pattern = testAsyncPattern.value;
|
|
151
|
+
provenance.test_async_pattern_source = relativeTo(projectRoot, testAsyncPattern.source);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Only emit a language block when at least one real field was extracted.
|
|
155
|
+
// An empty result (provenance-only) clutters the YAML for no value.
|
|
156
|
+
if (Object.keys(result).length === 0) return null;
|
|
157
|
+
if (Object.keys(provenance).length > 0) result._provenance = provenance;
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Reduce `/api/foo/bar` to `/api`. Returns null if the path doesn't have at
|
|
163
|
+
* least one slash-segment.
|
|
164
|
+
*/
|
|
165
|
+
function extractPrefixBase(prefix: string): string | null {
|
|
166
|
+
if (!prefix.startsWith('/')) return null;
|
|
167
|
+
const stripped = prefix.replace(/^\/+/, '');
|
|
168
|
+
const firstSeg = stripped.split('/')[0];
|
|
169
|
+
if (!firstSeg) return null;
|
|
170
|
+
return '/' + firstSeg;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ============================================================
|
|
174
|
+
// Swift adapter
|
|
175
|
+
// ============================================================
|
|
176
|
+
|
|
177
|
+
export function introspectSwift(
|
|
178
|
+
detection: DetectionResult,
|
|
179
|
+
projectRoot: string,
|
|
180
|
+
): DetectedSwift | null {
|
|
181
|
+
const sourceDir = resolveSourceDir(detection, 'swift', projectRoot);
|
|
182
|
+
if (!sourceDir) return null;
|
|
183
|
+
|
|
184
|
+
// Prefer View files first, then any .swift. Path-aware: also match files
|
|
185
|
+
// under a `Views/` directory, since SwiftUI projects often name files by
|
|
186
|
+
// feature (e.g., `OrdersList.swift` inside `Features/Orders/Views/`).
|
|
187
|
+
const viewFiles = sampleFiles(sourceDir, /\.swift$/, (absPath, name) =>
|
|
188
|
+
/View\.swift$/.test(name) || /\/Views\//.test(absPath),
|
|
189
|
+
);
|
|
190
|
+
const fallbackFiles = viewFiles.length === 0
|
|
191
|
+
? sampleFiles(sourceDir, /\.swift$/)
|
|
192
|
+
: [];
|
|
193
|
+
const candidates = [...viewFiles, ...fallbackFiles].slice(
|
|
194
|
+
0,
|
|
195
|
+
MAX_SAMPLES_PER_ADAPTER,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
if (candidates.length === 0) return null;
|
|
199
|
+
|
|
200
|
+
const apiClasses = new Map<string, string>();
|
|
201
|
+
const biometricPolicies = new Map<string, string>();
|
|
202
|
+
|
|
203
|
+
for (const path of candidates) {
|
|
204
|
+
const body = readSafe(path);
|
|
205
|
+
if (body === null) continue;
|
|
206
|
+
|
|
207
|
+
// API client class: looks like `let api = SomeAPI()` or
|
|
208
|
+
// `@StateObject var api: SomeAPI = .shared` etc.
|
|
209
|
+
// Extract `SomeAPI` from `SomeAPI(`/`SomeAPI.shared`/`: SomeAPI`.
|
|
210
|
+
const apiRegex = /\b([A-Z][A-Za-z0-9_]*API)\s*(?:\(|\.shared|\b)/gu;
|
|
211
|
+
forEachMatch(apiRegex, body, m => {
|
|
212
|
+
const name = m[1];
|
|
213
|
+
if (!apiClasses.has(name)) apiClasses.set(name, path);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// LocalAuthentication policy: `LAPolicy.<name>` or `.<name>` after
|
|
217
|
+
// `evaluatePolicy(`. Whitelist known good values to avoid false positives.
|
|
218
|
+
const policyRegex = /\.(deviceOwnerAuthentication(?:WithBiometrics)?)\b/gu;
|
|
219
|
+
forEachMatch(policyRegex, body, m => {
|
|
220
|
+
const name = m[1];
|
|
221
|
+
if (!biometricPolicies.has(name)) biometricPolicies.set(name, path);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const apiClass = pickBestSingleton(apiClasses);
|
|
226
|
+
const biometricPolicy = pickBestSingleton(biometricPolicies);
|
|
227
|
+
|
|
228
|
+
const result: DetectedSwift = {};
|
|
229
|
+
const provenance: Record<string, string> = {};
|
|
230
|
+
|
|
231
|
+
if (apiClass) {
|
|
232
|
+
result.api_client_class = apiClass.value;
|
|
233
|
+
provenance.api_client_class_source = relativeTo(projectRoot, apiClass.source);
|
|
234
|
+
}
|
|
235
|
+
if (biometricPolicy) {
|
|
236
|
+
result.biometric_policy = biometricPolicy.value;
|
|
237
|
+
provenance.biometric_policy_source = relativeTo(projectRoot, biometricPolicy.source);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (Object.keys(result).length === 0) return null;
|
|
241
|
+
if (Object.keys(provenance).length > 0) result._provenance = provenance;
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ============================================================
|
|
246
|
+
// TypeScript / Next.js + tRPC adapter
|
|
247
|
+
// ============================================================
|
|
248
|
+
|
|
249
|
+
export function introspectTypeScript(
|
|
250
|
+
detection: DetectionResult,
|
|
251
|
+
projectRoot: string,
|
|
252
|
+
): DetectedTypeScript | null {
|
|
253
|
+
const sourceDir = resolveSourceDir(detection, 'typescript', projectRoot)
|
|
254
|
+
?? resolveSourceDir(detection, 'javascript', projectRoot);
|
|
255
|
+
if (!sourceDir) return null;
|
|
256
|
+
|
|
257
|
+
// Look for tRPC router files first. Match on PATH or filename: tRPC
|
|
258
|
+
// projects typically place routers under `server/api/routers/<name>.ts`
|
|
259
|
+
// where filenames are resource-named (e.g., `accounts.ts`).
|
|
260
|
+
const routerFiles = sampleFiles(sourceDir, /\.tsx?$/, (absPath, name) =>
|
|
261
|
+
/(router|trpc)/i.test(name) ||
|
|
262
|
+
/\/(routers|trpc|server\/api)\//.test(absPath),
|
|
263
|
+
);
|
|
264
|
+
const candidates = routerFiles.slice(0, MAX_SAMPLES_PER_ADAPTER);
|
|
265
|
+
|
|
266
|
+
if (candidates.length === 0) return null;
|
|
267
|
+
|
|
268
|
+
const builders = new Map<string, string>();
|
|
269
|
+
const procedurePatterns = new Map<string, string>();
|
|
270
|
+
|
|
271
|
+
for (const path of candidates) {
|
|
272
|
+
const body = readSafe(path);
|
|
273
|
+
if (body === null) continue;
|
|
274
|
+
|
|
275
|
+
// tRPC router builder: `createTRPCRouter({ ... })`. Whitelist known names
|
|
276
|
+
// (createTRPCRouter, router, t.router) — never grep for arbitrary identifiers.
|
|
277
|
+
const builderRegex = /\b(createTRPCRouter|router|t\.router)\s*\(/gu;
|
|
278
|
+
forEachMatch(builderRegex, body, m => {
|
|
279
|
+
const name = m[1];
|
|
280
|
+
if (!builders.has(name)) builders.set(name, path);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Procedure pattern: `publicProcedure.input(...).query(...)` →
|
|
284
|
+
// capture just the procedure name (`publicProcedure`/`protectedProcedure`).
|
|
285
|
+
const procRegex = /\b([a-z]+Procedure)\b/gu;
|
|
286
|
+
forEachMatch(procRegex, body, m => {
|
|
287
|
+
const name = m[1];
|
|
288
|
+
if (!procedurePatterns.has(name)) procedurePatterns.set(name, path);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const builder = pickBestSingleton(builders);
|
|
293
|
+
const proc = pickBestSingleton(procedurePatterns);
|
|
294
|
+
|
|
295
|
+
const result: DetectedTypeScript = {};
|
|
296
|
+
const provenance: Record<string, string> = {};
|
|
297
|
+
|
|
298
|
+
if (builder) {
|
|
299
|
+
result.trpc_router_builder = builder.value;
|
|
300
|
+
provenance.trpc_router_builder_source = relativeTo(projectRoot, builder.source);
|
|
301
|
+
}
|
|
302
|
+
if (proc) {
|
|
303
|
+
result.procedure_pattern = proc.value;
|
|
304
|
+
provenance.procedure_pattern_source = relativeTo(projectRoot, proc.source);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (Object.keys(result).length === 0) return null;
|
|
308
|
+
if (Object.keys(provenance).length > 0) result._provenance = provenance;
|
|
309
|
+
return result;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ============================================================
|
|
313
|
+
// Helpers
|
|
314
|
+
// ============================================================
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Resolve the dominant source directory for a language, falling back to the
|
|
318
|
+
* project root if detection didn't surface anything specific.
|
|
319
|
+
*/
|
|
320
|
+
function resolveSourceDir(
|
|
321
|
+
detection: DetectionResult,
|
|
322
|
+
lang: string,
|
|
323
|
+
projectRoot: string,
|
|
324
|
+
): string | null {
|
|
325
|
+
const dirs = (detection.sourceDirs as unknown as Record<string, { source_dirs?: string[] }>);
|
|
326
|
+
const info = dirs[lang];
|
|
327
|
+
const list = info?.source_dirs ?? [];
|
|
328
|
+
if (list.length > 0) {
|
|
329
|
+
const first = list[0];
|
|
330
|
+
const abs = resolve(projectRoot, first);
|
|
331
|
+
return existsSync(abs) ? abs : null;
|
|
332
|
+
}
|
|
333
|
+
return existsSync(projectRoot) ? projectRoot : null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Walk `dir` and return up to MAX_SAMPLES_PER_ADAPTER files matching `nameRegex`,
|
|
338
|
+
* filtered further by `pathFilter` (called with absolute path AND basename, so
|
|
339
|
+
* filters can match either the filename (e.g., `views.py`) or a path
|
|
340
|
+
* component (e.g., a `routers/` parent directory). Skips dot-dirs,
|
|
341
|
+
* node_modules, .venv, etc. Bounded depth.
|
|
342
|
+
*/
|
|
343
|
+
export function sampleFiles(
|
|
344
|
+
dir: string,
|
|
345
|
+
nameRegex: RegExp,
|
|
346
|
+
pathFilter?: (absPath: string, basename: string) => boolean,
|
|
347
|
+
): string[] {
|
|
348
|
+
const out: string[] = [];
|
|
349
|
+
const stack: { path: string; depth: number }[] = [{ path: dir, depth: 0 }];
|
|
350
|
+
|
|
351
|
+
while (stack.length > 0 && out.length < MAX_SAMPLES_PER_ADAPTER * 4) {
|
|
352
|
+
const { path, depth } = stack.pop()!;
|
|
353
|
+
if (depth > MAX_DIR_DEPTH) continue;
|
|
354
|
+
|
|
355
|
+
let entries: string[];
|
|
356
|
+
try {
|
|
357
|
+
entries = readdirSync(path);
|
|
358
|
+
} catch {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
for (const entry of entries) {
|
|
363
|
+
if (entry.startsWith('.')) continue;
|
|
364
|
+
if (entry === 'node_modules') continue;
|
|
365
|
+
if (entry === '__pycache__') continue;
|
|
366
|
+
if (entry === 'venv' || entry === '.venv') continue;
|
|
367
|
+
if (entry === 'dist' || entry === 'build') continue;
|
|
368
|
+
|
|
369
|
+
const child = join(path, entry);
|
|
370
|
+
let st;
|
|
371
|
+
try {
|
|
372
|
+
st = statSync(child);
|
|
373
|
+
} catch {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (st.isDirectory()) {
|
|
378
|
+
stack.push({ path: child, depth: depth + 1 });
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (!nameRegex.test(entry)) continue;
|
|
383
|
+
if (pathFilter && !pathFilter(child, entry)) continue;
|
|
384
|
+
if (st.size > MAX_FILE_BYTES) continue;
|
|
385
|
+
out.push(child);
|
|
386
|
+
if (out.length >= MAX_SAMPLES_PER_ADAPTER * 4) break;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Stable ordering — deterministic test fixtures.
|
|
391
|
+
out.sort();
|
|
392
|
+
return out.slice(0, MAX_SAMPLES_PER_ADAPTER * 2);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Read a file as UTF-8. Returns null on any error or if too large. */
|
|
396
|
+
export function readSafe(path: string): string | null {
|
|
397
|
+
try {
|
|
398
|
+
const st = statSync(path);
|
|
399
|
+
if (st.size > MAX_FILE_BYTES) return null;
|
|
400
|
+
return readFileSync(path, 'utf-8');
|
|
401
|
+
} catch {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Run a global regex over `body` and call `cb` for each match. Caps iteration
|
|
408
|
+
* at 1000 matches per regex to defend against pathological inputs.
|
|
409
|
+
*/
|
|
410
|
+
export function forEachMatch(
|
|
411
|
+
re: RegExp,
|
|
412
|
+
body: string,
|
|
413
|
+
cb: (m: RegExpExecArray) => void,
|
|
414
|
+
): void {
|
|
415
|
+
if (!re.global) return;
|
|
416
|
+
re.lastIndex = 0;
|
|
417
|
+
let count = 0;
|
|
418
|
+
let m: RegExpExecArray | null;
|
|
419
|
+
while ((m = re.exec(body)) !== null) {
|
|
420
|
+
cb(m);
|
|
421
|
+
count++;
|
|
422
|
+
if (count > 1000) break;
|
|
423
|
+
// Defensive: zero-width match → bump lastIndex to avoid infinite loop.
|
|
424
|
+
if (m.index === re.lastIndex) re.lastIndex++;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* If `samples` has exactly one distinct value, return it.
|
|
430
|
+
* If it has 2 values, return the first-seen (stable, deterministic order from
|
|
431
|
+
* file walk).
|
|
432
|
+
* If it has 3+ distinct values, return null — Risk #6 (auth-dep ambiguity).
|
|
433
|
+
*/
|
|
434
|
+
export function pickBestSingleton(
|
|
435
|
+
samples: Map<string, string>,
|
|
436
|
+
): { value: string; source: string } | null {
|
|
437
|
+
if (samples.size === 0) return null;
|
|
438
|
+
if (samples.size >= 3) return null;
|
|
439
|
+
const [firstKey, firstSource] = samples.entries().next().value as [string, string];
|
|
440
|
+
return { value: firstKey, source: firstSource };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Make a file path relative to the project root. */
|
|
444
|
+
export function relativeTo(projectRoot: string, absPath: string): string {
|
|
445
|
+
if (absPath.startsWith(projectRoot + '/')) {
|
|
446
|
+
return absPath.slice(projectRoot.length + 1);
|
|
447
|
+
}
|
|
448
|
+
return basename(absPath);
|
|
449
|
+
}
|
|
@@ -16,6 +16,7 @@ import { parse as parseYaml } from 'yaml';
|
|
|
16
16
|
import type Database from 'better-sqlite3';
|
|
17
17
|
import { runDetection } from '../detect/index.ts';
|
|
18
18
|
import { computeFingerprint } from '../detect/drift.ts';
|
|
19
|
+
import { isPidAlive } from '../lib/pidLiveness.ts';
|
|
19
20
|
|
|
20
21
|
interface HookInput {
|
|
21
22
|
session_id: string;
|
|
@@ -68,9 +69,14 @@ async function main(): Promise<void> {
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
// P5-001: drift banner (runs after memory context, independent of it).
|
|
72
|
+
// Plan 3a Phase 6: when a live watcher daemon exists, the drift banner
|
|
73
|
+
// is suppressed in favor of a compact watcher banner.
|
|
71
74
|
const driftBanner = await buildDriftBanner();
|
|
72
75
|
if (driftBanner) {
|
|
73
76
|
process.stdout.write(driftBanner);
|
|
77
|
+
} else {
|
|
78
|
+
const watcherBanner = buildWatcherBanner();
|
|
79
|
+
if (watcherBanner) process.stdout.write(watcherBanner);
|
|
74
80
|
}
|
|
75
81
|
} finally {
|
|
76
82
|
db.close();
|
|
@@ -261,6 +267,15 @@ function readStdin(): Promise<string> {
|
|
|
261
267
|
*/
|
|
262
268
|
async function buildDriftBanner(): Promise<string> {
|
|
263
269
|
try {
|
|
270
|
+
// Plan #2 P4-004: explicit opt-out for users in deliberate mid-migration windows.
|
|
271
|
+
// Stays at the top so MASSU_DRIFT_QUIET=1 remains the strongest signal
|
|
272
|
+
// (iter-1 G8: env-var override beats watcher-state suppression).
|
|
273
|
+
if (process.env.MASSU_DRIFT_QUIET === '1') return '';
|
|
274
|
+
|
|
275
|
+
// Plan 3a Phase 6: if a live watcher daemon refreshed within the last 24h,
|
|
276
|
+
// suppress this banner — the watcher already keeps the config current.
|
|
277
|
+
if (watcherIsLiveAndFresh()) return '';
|
|
278
|
+
|
|
264
279
|
const configPath = resolve(process.cwd(), 'massu.config.yaml');
|
|
265
280
|
if (!existsSync(configPath)) return '';
|
|
266
281
|
const content = readFileSync(configPath, 'utf-8');
|
|
@@ -269,7 +284,10 @@ async function buildDriftBanner(): Promise<string> {
|
|
|
269
284
|
const det = parsed.detection as Record<string, unknown> | undefined;
|
|
270
285
|
const storedFp = typeof det?.fingerprint === 'string' ? (det.fingerprint as string) : null;
|
|
271
286
|
if (!storedFp) return '';
|
|
272
|
-
|
|
287
|
+
// Plan #2 P4-006: skip the codebase introspector pass — the drift banner
|
|
288
|
+
// only needs the fingerprint, not the introspected detail. Saves up to 2s
|
|
289
|
+
// wall-clock from the hook's 5-second budget.
|
|
290
|
+
const detection = await runDetection(process.cwd(), undefined, { skipIntrospect: true });
|
|
273
291
|
const currentFp = computeFingerprint(detection);
|
|
274
292
|
if (currentFp === storedFp) return '';
|
|
275
293
|
return (
|
|
@@ -277,6 +295,9 @@ async function buildDriftBanner(): Promise<string> {
|
|
|
277
295
|
'Detected stack has changed since last config refresh.\n' +
|
|
278
296
|
`Fingerprint: ${storedFp.slice(0, 16)} -> ${currentFp.slice(0, 16)}\n` +
|
|
279
297
|
'Run: npx massu config refresh\n' +
|
|
298
|
+
'(this will update massu.config.yaml AND any commands that need\n' +
|
|
299
|
+
' re-templating for your new stack)\n' +
|
|
300
|
+
'Tip: set MASSU_DRIFT_QUIET=1 to suppress this banner during mid-migration.\n' +
|
|
280
301
|
'=== END ===\n'
|
|
281
302
|
);
|
|
282
303
|
} catch (_e) {
|
|
@@ -306,8 +327,11 @@ function loadCorrectionsPreventionRules(): string[] {
|
|
|
306
327
|
const cwd = process.cwd();
|
|
307
328
|
const config = getConfig();
|
|
308
329
|
const claudeDirName = config.conventions?.claudeDirName ?? '.claude';
|
|
309
|
-
// Convert cwd to Claude's directory format: /Users/x/project -> -Users-x-project
|
|
310
|
-
|
|
330
|
+
// Convert cwd to Claude's directory format: /Users/x/project -> -Users-x-project.
|
|
331
|
+
// Match both forward slashes (POSIX) and backslashes (Windows) so the lookup
|
|
332
|
+
// works cross-platform — without this, Windows users silently miss prevention
|
|
333
|
+
// rules because their projectDirName never resolves.
|
|
334
|
+
const projectDirName = cwd.replace(/[/\\]/g, '-').replace(/^-/, '');
|
|
311
335
|
const correctionsPath = join(homeDir, claudeDirName, 'projects', projectDirName, 'memory', 'corrections.md');
|
|
312
336
|
|
|
313
337
|
if (!existsSync(correctionsPath)) return [];
|
|
@@ -341,4 +365,71 @@ function loadCorrectionsPreventionRules(): string[] {
|
|
|
341
365
|
}
|
|
342
366
|
}
|
|
343
367
|
|
|
368
|
+
// ============================================================
|
|
369
|
+
// Plan 3a Phase 6: watcher-aware banner support
|
|
370
|
+
// ============================================================
|
|
371
|
+
|
|
372
|
+
interface WatchStateShape {
|
|
373
|
+
schema_version?: number;
|
|
374
|
+
daemonPid?: number | null;
|
|
375
|
+
lastRefreshAt?: string | null;
|
|
376
|
+
startedAt?: string | null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function readWatchStateRaw(cwd: string): WatchStateShape | null {
|
|
380
|
+
try {
|
|
381
|
+
const path = resolve(cwd, '.massu', 'watch-state.json');
|
|
382
|
+
if (!existsSync(path)) return null;
|
|
383
|
+
const obj = JSON.parse(readFileSync(path, 'utf-8'));
|
|
384
|
+
if (!obj || typeof obj !== 'object') return null;
|
|
385
|
+
return obj as WatchStateShape;
|
|
386
|
+
} catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function watcherIsLiveAndFresh(): boolean {
|
|
392
|
+
// MASSU_DRIFT_QUIET takes precedence (caller already short-circuited).
|
|
393
|
+
// Fresh = last refresh within 24h AND daemonPid is alive.
|
|
394
|
+
const state = readWatchStateRaw(process.cwd());
|
|
395
|
+
if (!state) return false;
|
|
396
|
+
if (typeof state.daemonPid !== 'number' || state.daemonPid <= 0) return false;
|
|
397
|
+
if (!isPidAlive(state.daemonPid)) return false;
|
|
398
|
+
if (typeof state.lastRefreshAt !== 'string') return false;
|
|
399
|
+
const last = Date.parse(state.lastRefreshAt);
|
|
400
|
+
if (!Number.isFinite(last)) return false;
|
|
401
|
+
const ageMs = Date.now() - last;
|
|
402
|
+
return ageMs >= 0 && ageMs < 24 * 60 * 60 * 1000;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function buildWatcherBanner(): string {
|
|
406
|
+
// P4-004 ordering: MASSU_DRIFT_QUIET wins everywhere.
|
|
407
|
+
if (process.env.MASSU_DRIFT_QUIET === '1') return '';
|
|
408
|
+
const state = readWatchStateRaw(process.cwd());
|
|
409
|
+
if (!state) return '';
|
|
410
|
+
if (typeof state.daemonPid !== 'number' || state.daemonPid <= 0) return '';
|
|
411
|
+
if (!isPidAlive(state.daemonPid)) return '';
|
|
412
|
+
if (typeof state.lastRefreshAt !== 'string') return '';
|
|
413
|
+
const last = Date.parse(state.lastRefreshAt);
|
|
414
|
+
if (!Number.isFinite(last)) return '';
|
|
415
|
+
const ageMs = Date.now() - last;
|
|
416
|
+
if (ageMs < 0 || ageMs >= 24 * 60 * 60 * 1000) return '';
|
|
417
|
+
|
|
418
|
+
const ageStr = formatAge(ageMs);
|
|
419
|
+
return (
|
|
420
|
+
'=== Massu Watcher ===\n' +
|
|
421
|
+
`[massu] watcher running, last refresh: ${ageStr} ago (pid ${state.daemonPid})\n` +
|
|
422
|
+
'=== END ===\n'
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function formatAge(ms: number): string {
|
|
427
|
+
const sec = Math.round(ms / 1000);
|
|
428
|
+
if (sec < 60) return `${sec}s`;
|
|
429
|
+
const min = Math.round(sec / 60);
|
|
430
|
+
if (min < 60) return `${min}m`;
|
|
431
|
+
const hr = Math.round(min / 60);
|
|
432
|
+
return `${hr}h`;
|
|
433
|
+
}
|
|
434
|
+
|
|
344
435
|
main();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the git repository root for a given working directory, falling
|
|
6
|
+
* back to the cwd itself when not inside a git repo.
|
|
7
|
+
*
|
|
8
|
+
* Used by `massu watch` and `massu refresh-log` so the watcher root and the
|
|
9
|
+
* refresh-log path always anchor on the same toplevel rather than wherever
|
|
10
|
+
* the user happened to invoke the CLI from.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawnSync } from 'child_process';
|
|
14
|
+
|
|
15
|
+
export function gitToplevel(cwd: string): string {
|
|
16
|
+
const res = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
|
17
|
+
cwd,
|
|
18
|
+
encoding: 'utf-8',
|
|
19
|
+
});
|
|
20
|
+
if (res.status === 0 && res.stdout) return res.stdout.trim();
|
|
21
|
+
return cwd;
|
|
22
|
+
}
|