@massu/core 1.4.0 → 1.5.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/dist/cli.js +9445 -5483
- package/dist/hooks/auto-learning-pipeline.js +18 -0
- package/dist/hooks/classify-failure.js +18 -0
- package/dist/hooks/cost-tracker.js +18 -0
- package/dist/hooks/fix-detector.js +18 -0
- package/dist/hooks/incident-pipeline.js +18 -0
- package/dist/hooks/post-edit-context.js +18 -0
- package/dist/hooks/post-tool-use.js +18 -0
- package/dist/hooks/pre-compact.js +18 -0
- package/dist/hooks/pre-delete-check.js +18 -0
- package/dist/hooks/quality-event.js +18 -0
- package/dist/hooks/rule-enforcement-pipeline.js +18 -0
- package/dist/hooks/session-end.js +18 -0
- package/dist/hooks/session-start.js +2668 -2674
- package/dist/hooks/user-prompt.js +18 -0
- package/docs/AUTHORING-ADAPTERS.md +207 -0
- package/docs/SECURITY.md +250 -0
- package/package.json +7 -3
- package/src/adapter.ts +90 -0
- package/src/cli.ts +7 -0
- package/src/commands/adapters.ts +824 -0
- package/src/commands/config-check-drift.ts +1 -0
- package/src/commands/config-refresh.ts +1 -0
- package/src/commands/config-upgrade.ts +1 -0
- package/src/commands/doctor.ts +2 -0
- package/src/commands/init.ts +2 -0
- package/src/config.ts +63 -0
- package/src/detect/adapters/aspnet.ts +293 -0
- package/src/detect/adapters/discover.ts +469 -0
- package/src/detect/adapters/go-chi.ts +261 -0
- package/src/detect/adapters/index.ts +49 -0
- package/src/detect/adapters/phoenix.ts +277 -0
- package/src/detect/adapters/python-flask.ts +235 -0
- package/src/detect/adapters/rails.ts +279 -0
- package/src/detect/adapters/runner.ts +32 -0
- package/src/detect/adapters/spring.ts +284 -0
- package/src/detect/adapters/tree-sitter-loader.ts +50 -0
- package/src/detect/adapters/types.ts +18 -0
- package/src/detect/monorepo-detector.ts +1 -0
- package/src/hooks/post-tool-use.ts +1 -0
- package/src/hooks/session-start.ts +1 -0
- package/src/lib/fileLock.ts +203 -0
- package/src/lib/installLock.ts +31 -144
- package/src/memory-file-ingest.ts +1 -0
- package/src/security/adapter-origin.ts +130 -0
- package/src/security/adapter-verifier.ts +319 -0
- package/src/security/atomic-write.ts +164 -0
- package/src/security/fetcher.ts +200 -0
- package/src/security/install-tracking.ts +319 -0
- package/src/security/local-fingerprint.ts +225 -0
- package/src/security/manifest-cache.ts +333 -0
- package/src/security/manifest-schema.ts +129 -0
- package/src/security/registry-pubkey.generated.ts +35 -0
- package/src/security/telemetry.ts +320 -0
- package/templates/aspnet/massu.config.yaml +57 -0
- package/templates/go-chi/massu.config.yaml +52 -0
- package/templates/phoenix/massu.config.yaml +54 -0
- package/templates/python-flask/massu.config.yaml +51 -0
- package/templates/rails/massu.config.yaml +56 -0
- package/templates/spring/massu.config.yaml +56 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Plan 3c — Phase 7: Spring (Spring Boot / Spring MVC) AST adapter.
|
|
6
|
+
*
|
|
7
|
+
* Sixth and final Phase 7 framework after go-chi + Flask + Rails + Phoenix
|
|
8
|
+
* + ASP.NET. First to consume the `java` Tree-sitter grammar entry from
|
|
9
|
+
* GRAMMAR_MANIFEST (commit fbb8aa9). All four queries verified against
|
|
10
|
+
* actual tree-sitter-java AST shape via probe (R-011) BEFORE writing the
|
|
11
|
+
* adapter — same discipline as phoenix + aspnet.
|
|
12
|
+
*
|
|
13
|
+
* Spring uses annotation-based routing per the Spring MVC reference
|
|
14
|
+
* (https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller.html):
|
|
15
|
+
* - `@RestController` / `@Controller` at the class level
|
|
16
|
+
* - `@RequestMapping("/api/users")` at class level for path prefix
|
|
17
|
+
* - `@GetMapping("/{id}")` / `@PostMapping` / etc. on methods for verb +
|
|
18
|
+
* optional sub-path
|
|
19
|
+
*
|
|
20
|
+
* Extracts:
|
|
21
|
+
* - route_method: most-common HTTP verb captured from
|
|
22
|
+
* `@<Verb>Mapping` annotations. Normalized: `GetMapping` → `Get`,
|
|
23
|
+
* `PostMapping` → `Post`, etc. Mirrors aspnet's MapGet/HttpGet
|
|
24
|
+
* normalization for downstream consumer consistency.
|
|
25
|
+
* - route_prefix_base: first segment of the first class-level
|
|
26
|
+
* `@RequestMapping("/api/...")` template, normalized to a leading-
|
|
27
|
+
* slash path. Mirrors aspnet route_prefix_base / phoenix
|
|
28
|
+
* scope_prefix_base.
|
|
29
|
+
* - controller_class: name of the first class annotated with
|
|
30
|
+
* `@RestController` or `@Controller`. Mirrors aspnet controller_class
|
|
31
|
+
* / phoenix router_module.
|
|
32
|
+
*
|
|
33
|
+
* Confidence rules (mirror phoenix / rails / aspnet):
|
|
34
|
+
* - 'high' if exactly ONE distinct route_method seen.
|
|
35
|
+
* - 'low' if multiple distinct route_methods seen.
|
|
36
|
+
* - 'medium' if route_prefix_base or controller_class found but no
|
|
37
|
+
* route_method.
|
|
38
|
+
* - 'none' if no Spring signals at all.
|
|
39
|
+
*
|
|
40
|
+
* Tree-sitter-java AST shape (verified via probe 2026-05-07):
|
|
41
|
+
* - Annotations come in TWO node-type flavors:
|
|
42
|
+
* - `(marker_annotation name: (identifier))` for parameterless
|
|
43
|
+
* annotations like `@PostMapping`, `@RestController`.
|
|
44
|
+
* - `(annotation name: (identifier) arguments: (annotation_argument_list
|
|
45
|
+
* (string_literal) ...))` for parameterized annotations like
|
|
46
|
+
* `@GetMapping("/{id}")`, `@RequestMapping("/api/users")`.
|
|
47
|
+
* Adapter queries cover BOTH where applicable.
|
|
48
|
+
* - Class declarations: `(class_declaration (modifiers (annotation ...) /
|
|
49
|
+
* (marker_annotation ...)) name: (identifier) body: (class_body ...))`.
|
|
50
|
+
* - String literals are `(string_literal (string_fragment))`; node.text
|
|
51
|
+
* returns the quoted source verbatim.
|
|
52
|
+
*
|
|
53
|
+
* Does NOT use regex on file content — only Tree-sitter S-expression queries
|
|
54
|
+
* compiled via query-helpers.ts.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import { Parser } from 'web-tree-sitter';
|
|
58
|
+
import type { CodebaseAdapter, AdapterResult, DetectionSignals, Provenance, SourceFile } from './types.ts';
|
|
59
|
+
import { runQuery, InvalidQueryError } from './query-helpers.ts';
|
|
60
|
+
import { loadGrammar } from './tree-sitter-loader.ts';
|
|
61
|
+
import { isParsableSource, MAX_AST_FILE_BYTES } from './parse-guard.ts';
|
|
62
|
+
|
|
63
|
+
// ============================================================
|
|
64
|
+
// Tree-sitter S-expression queries (Java grammar)
|
|
65
|
+
// ============================================================
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parameterized HTTP mapping annotations: `@GetMapping("/{id}")`,
|
|
69
|
+
* `@PostMapping("/login")`, etc. Captures both the verb (from the
|
|
70
|
+
* annotation name) AND the path string for prefix-base extraction.
|
|
71
|
+
*
|
|
72
|
+
* Per Spring docs:
|
|
73
|
+
* https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-requestmapping.html
|
|
74
|
+
*/
|
|
75
|
+
const HTTP_MAPPING_QUERY = `
|
|
76
|
+
(annotation
|
|
77
|
+
name: (identifier) @method (#match? @method "^(Get|Post|Put|Patch|Delete|Head|Options)Mapping$")
|
|
78
|
+
arguments: (annotation_argument_list
|
|
79
|
+
(string_literal) @route_path))
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parameterless HTTP mapping annotations: `@PostMapping`, `@DeleteMapping`,
|
|
84
|
+
* etc. The tree-sitter-java grammar uses a separate `marker_annotation`
|
|
85
|
+
* node for annotations without an argument list — distinct from the
|
|
86
|
+
* parameterized `annotation` node above.
|
|
87
|
+
*/
|
|
88
|
+
const HTTP_MAPPING_NO_ARGS_QUERY = `
|
|
89
|
+
(marker_annotation
|
|
90
|
+
name: (identifier) @method (#match? @method "^(Get|Post|Put|Patch|Delete|Head|Options)Mapping$"))
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Class-level `@RequestMapping("/api/users")`. Captures the path template
|
|
95
|
+
* so we can extract its first segment.
|
|
96
|
+
*/
|
|
97
|
+
const REQUEST_MAPPING_QUERY = `
|
|
98
|
+
(annotation
|
|
99
|
+
name: (identifier) @_name (#eq? @_name "RequestMapping")
|
|
100
|
+
arguments: (annotation_argument_list
|
|
101
|
+
(string_literal) @route_template))
|
|
102
|
+
`;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Class declarations annotated with `@RestController` or `@Controller`.
|
|
106
|
+
* Both annotation flavors (marker + parameterized) are captured because
|
|
107
|
+
* Spring controllers usually use parameterless `@RestController` /
|
|
108
|
+
* `@Controller` but some use `@Controller(value = "name")`.
|
|
109
|
+
*/
|
|
110
|
+
const CONTROLLER_CLASS_QUERY = `
|
|
111
|
+
(class_declaration
|
|
112
|
+
(modifiers
|
|
113
|
+
(marker_annotation
|
|
114
|
+
name: (identifier) @_anno (#match? @_anno "^(RestController|Controller)$")))
|
|
115
|
+
name: (identifier) @class_name)
|
|
116
|
+
|
|
117
|
+
(class_declaration
|
|
118
|
+
(modifiers
|
|
119
|
+
(annotation
|
|
120
|
+
name: (identifier) @_anno (#match? @_anno "^(RestController|Controller)$")))
|
|
121
|
+
name: (identifier) @class_name)
|
|
122
|
+
`;
|
|
123
|
+
|
|
124
|
+
// ============================================================
|
|
125
|
+
// Adapter
|
|
126
|
+
// ============================================================
|
|
127
|
+
|
|
128
|
+
export const springAdapter: CodebaseAdapter = {
|
|
129
|
+
id: 'spring',
|
|
130
|
+
languages: ['java'],
|
|
131
|
+
|
|
132
|
+
matches(signals: DetectionSignals): boolean {
|
|
133
|
+
// Cheap signal-only check. No file IO. The canonical Spring Boot
|
|
134
|
+
// declaration is the `spring-boot-starter-web` artifact (Maven) or
|
|
135
|
+
// dependency string (Gradle), per the Spring Boot reference:
|
|
136
|
+
// https://docs.spring.io/spring-boot/reference/using/build-systems.html
|
|
137
|
+
if (signals.pomXml && /\bspring-boot-starter[\w-]*\b/.test(signals.pomXml)) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
if (signals.gradleBuild && /\bspring-boot-starter[\w-]*\b/.test(signals.gradleBuild)) {
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
// Fallback: explicit `spring-webmvc` or `org.springframework` references
|
|
144
|
+
// catch projects that use Spring without Spring Boot (rare today but
|
|
145
|
+
// canonical Spring MVC pre-Boot apps).
|
|
146
|
+
if (signals.pomXml && /\borg\.springframework\b/.test(signals.pomXml)) {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
if (signals.gradleBuild && /\borg\.springframework\b/.test(signals.gradleBuild)) {
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
async introspect(files: SourceFile[], _rootDir: string): Promise<AdapterResult> {
|
|
156
|
+
if (files.length === 0) {
|
|
157
|
+
return { conventions: {}, provenance: [], confidence: 'none' };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let language;
|
|
161
|
+
try {
|
|
162
|
+
language = await loadGrammar('java');
|
|
163
|
+
} catch (e) {
|
|
164
|
+
return { conventions: {}, provenance: [], confidence: 'none' };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const parser = new Parser();
|
|
168
|
+
parser.setLanguage(language);
|
|
169
|
+
|
|
170
|
+
const routeMethods = new Map<string, { line: number; file: string }>();
|
|
171
|
+
const prefixBases = new Map<string, { line: number; file: string }>();
|
|
172
|
+
const controllerClasses = new Map<string, { line: number; file: string }>();
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
for (const file of files) {
|
|
176
|
+
const skip = isParsableSource(file.content, file.size);
|
|
177
|
+
if (skip) {
|
|
178
|
+
process.stderr.write(
|
|
179
|
+
`[massu/ast] WARN: spring skipping ${file.path}: ${skip.reason} (${skip.detail}). Cap=${MAX_AST_FILE_BYTES}. (Phase 3.5 mitigation)\n`,
|
|
180
|
+
);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
// Parameterized @<Verb>Mapping("/path")
|
|
185
|
+
for (const hit of runQuery(parser, file.content, HTTP_MAPPING_QUERY, 'spring-http-mapping', file.path)) {
|
|
186
|
+
const methodRaw = hit.captures.method;
|
|
187
|
+
if (!methodRaw) continue;
|
|
188
|
+
const verb = methodRaw.replace(/Mapping$/, ''); // GetMapping → Get
|
|
189
|
+
if (!routeMethods.has(verb)) {
|
|
190
|
+
routeMethods.set(verb, { line: hit.line, file: file.path });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Parameterless @<Verb>Mapping
|
|
194
|
+
for (const hit of runQuery(parser, file.content, HTTP_MAPPING_NO_ARGS_QUERY, 'spring-http-mapping-marker', file.path)) {
|
|
195
|
+
const methodRaw = hit.captures.method;
|
|
196
|
+
if (!methodRaw) continue;
|
|
197
|
+
const verb = methodRaw.replace(/Mapping$/, '');
|
|
198
|
+
if (!routeMethods.has(verb)) {
|
|
199
|
+
routeMethods.set(verb, { line: hit.line, file: file.path });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// @RequestMapping("/api/...")
|
|
203
|
+
for (const hit of runQuery(parser, file.content, REQUEST_MAPPING_QUERY, 'spring-request-mapping', file.path)) {
|
|
204
|
+
const tplRaw = hit.captures.route_template;
|
|
205
|
+
if (!tplRaw) continue;
|
|
206
|
+
const literal = tplRaw.replace(/^["']/, '').replace(/["']$/, '');
|
|
207
|
+
const base = extractPrefixBase(literal);
|
|
208
|
+
if (base && !prefixBases.has(base)) {
|
|
209
|
+
prefixBases.set(base, { line: hit.line, file: file.path });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// @RestController / @Controller class
|
|
213
|
+
for (const hit of runQuery(parser, file.content, CONTROLLER_CLASS_QUERY, 'spring-controller-class', file.path)) {
|
|
214
|
+
const name = hit.captures.class_name;
|
|
215
|
+
if (name && !controllerClasses.has(name)) {
|
|
216
|
+
controllerClasses.set(name, { line: hit.line, file: file.path });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} catch (e) {
|
|
220
|
+
if (e instanceof InvalidQueryError) {
|
|
221
|
+
throw e;
|
|
222
|
+
}
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} finally {
|
|
227
|
+
try { parser.delete(); } catch { /* ignore */ }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const conventions: Record<string, unknown> = {};
|
|
231
|
+
const provenance: Provenance[] = [];
|
|
232
|
+
|
|
233
|
+
if (routeMethods.size === 1) {
|
|
234
|
+
const [name, { line, file }] = routeMethods.entries().next().value as [string, { line: number; file: string }];
|
|
235
|
+
conventions.route_method = name;
|
|
236
|
+
provenance.push({ field: 'route_method', sourceFile: file, line, query: 'spring-http-mapping' });
|
|
237
|
+
} else if (routeMethods.size >= 2) {
|
|
238
|
+
const [name, { line, file }] = routeMethods.entries().next().value as [string, { line: number; file: string }];
|
|
239
|
+
conventions.route_method = name;
|
|
240
|
+
provenance.push({ field: 'route_method', sourceFile: file, line, query: 'spring-http-mapping' });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (prefixBases.size >= 1) {
|
|
244
|
+
const [base, { line, file }] = prefixBases.entries().next().value as [string, { line: number; file: string }];
|
|
245
|
+
conventions.route_prefix_base = base;
|
|
246
|
+
provenance.push({ field: 'route_prefix_base', sourceFile: file, line, query: 'spring-request-mapping' });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (controllerClasses.size >= 1) {
|
|
250
|
+
const [name, { line, file }] = controllerClasses.entries().next().value as [string, { line: number; file: string }];
|
|
251
|
+
conventions.controller_class = name;
|
|
252
|
+
provenance.push({ field: 'controller_class', sourceFile: file, line, query: 'spring-controller-class' });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let confidence: AdapterResult['confidence'];
|
|
256
|
+
if (Object.keys(conventions).length === 0) {
|
|
257
|
+
confidence = 'none';
|
|
258
|
+
} else if (routeMethods.size === 1) {
|
|
259
|
+
confidence = 'high';
|
|
260
|
+
} else if (routeMethods.size >= 2) {
|
|
261
|
+
confidence = 'low';
|
|
262
|
+
} else {
|
|
263
|
+
confidence = 'medium';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return { conventions, provenance, confidence };
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// ============================================================
|
|
271
|
+
// Helpers
|
|
272
|
+
// ============================================================
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Extract the first path segment of a Spring `@RequestMapping` template.
|
|
276
|
+
* Mirrors aspnet/phoenix/rails/python-flask/go-chi extractors. Returns
|
|
277
|
+
* null if input is empty or `/`-only.
|
|
278
|
+
*/
|
|
279
|
+
function extractPrefixBase(prefix: string): string | null {
|
|
280
|
+
const stripped = prefix.replace(/^\/+/, '');
|
|
281
|
+
const firstSeg = stripped.split('/')[0];
|
|
282
|
+
if (!firstSeg) return null;
|
|
283
|
+
return '/' + firstSeg;
|
|
284
|
+
}
|
|
@@ -161,6 +161,56 @@ export const GRAMMAR_MANIFEST: Partial<Record<TreeSitterLanguage, ManifestEntry>
|
|
|
161
161
|
sha256: '41c4fdb2249a3aa6d87eed0d383081ff09725c2248b4977043a43825980ffcc7',
|
|
162
162
|
version: '0.1.13',
|
|
163
163
|
},
|
|
164
|
+
// ----------------------------------------------------------------
|
|
165
|
+
// Plan 3c Phase 7 expansion (2026-05-07):
|
|
166
|
+
//
|
|
167
|
+
// Six additional grammars to support the registry-verified framework
|
|
168
|
+
// adapters (go-chi, rails, aspnet, spring, ktor, phoenix) plus the
|
|
169
|
+
// bundled adapters in the same language families (gin/echo/fiber,
|
|
170
|
+
// sinatra, etc.). All entries use the SAME pinned tree-sitter-wasms
|
|
171
|
+
// version (0.1.13) as the v1 four to keep the dependency surface
|
|
172
|
+
// single-source.
|
|
173
|
+
//
|
|
174
|
+
// SHA-256s computed 2026-05-07 via:
|
|
175
|
+
// curl -fsSL <url> | shasum -a 256
|
|
176
|
+
//
|
|
177
|
+
// The unpkg filename for C# uses an underscore (`c_sharp`) while the
|
|
178
|
+
// TreeSitterLanguage identifier uses no separator (`csharp`); the map
|
|
179
|
+
// key is the type identifier, the URL is the storage path — they do
|
|
180
|
+
// NOT need to match, the same as how `python` maps to `tree-sitter-
|
|
181
|
+
// python.wasm`. This is intentional and validated by the manifest
|
|
182
|
+
// shape test in tree-sitter-loader-manifest.test.ts.
|
|
183
|
+
// ----------------------------------------------------------------
|
|
184
|
+
go: {
|
|
185
|
+
url: 'https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-go.wasm',
|
|
186
|
+
sha256: '9963ca89b616eaf04b08a43bc1fb0f07b85395bec313330851f1f1ead2f755b6',
|
|
187
|
+
version: '0.1.13',
|
|
188
|
+
},
|
|
189
|
+
ruby: {
|
|
190
|
+
url: 'https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-ruby.wasm',
|
|
191
|
+
sha256: '93a5022855314cdb45458c7bb026a24a0ebc3a5ff6439e542e881f14dfa13a39',
|
|
192
|
+
version: '0.1.13',
|
|
193
|
+
},
|
|
194
|
+
csharp: {
|
|
195
|
+
url: 'https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-c_sharp.wasm',
|
|
196
|
+
sha256: '6266a7e32d68a3459104d994dc848df15d5672b0ea8e86d327274b694f8e6991',
|
|
197
|
+
version: '0.1.13',
|
|
198
|
+
},
|
|
199
|
+
java: {
|
|
200
|
+
url: 'https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-java.wasm',
|
|
201
|
+
sha256: '637aac4415fb39a211a4f4292d63c66b5ce9c32fa2cd35464af4f681d91b9a1f',
|
|
202
|
+
version: '0.1.13',
|
|
203
|
+
},
|
|
204
|
+
kotlin: {
|
|
205
|
+
url: 'https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-kotlin.wasm',
|
|
206
|
+
sha256: 'b5cb00c8d06ed0f10f1dbe497205b437809d7e87db1f638721a8cfb30e044449',
|
|
207
|
+
version: '0.1.13',
|
|
208
|
+
},
|
|
209
|
+
elixir: {
|
|
210
|
+
url: 'https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-elixir.wasm',
|
|
211
|
+
sha256: '82e91b9759ddca30d8978ebbfa8e347b4451b64c931f9ae62112e6db9b8fac20',
|
|
212
|
+
version: '0.1.13',
|
|
213
|
+
},
|
|
164
214
|
};
|
|
165
215
|
|
|
166
216
|
// ============================================================
|
|
@@ -68,6 +68,24 @@ export interface DetectionSignals {
|
|
|
68
68
|
cargoToml?: Record<string, unknown>;
|
|
69
69
|
/** Raw `go.mod` text — undefined if absent. */
|
|
70
70
|
goMod?: string;
|
|
71
|
+
/** Raw `mix.exs` text — undefined if absent. Elixir/Mix manifest. */
|
|
72
|
+
mixExs?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Raw text of the FIRST `.csproj` file found at the project root —
|
|
75
|
+
* undefined if absent. .NET project file (XML). Adapters that need to
|
|
76
|
+
* inspect more than one csproj (multi-project solutions) can re-scan from
|
|
77
|
+
* `presentFiles`.
|
|
78
|
+
*/
|
|
79
|
+
csproj?: string;
|
|
80
|
+
/** Raw `pom.xml` text — undefined if absent. Maven build manifest. */
|
|
81
|
+
pomXml?: string;
|
|
82
|
+
/**
|
|
83
|
+
* Raw text of `build.gradle` OR `build.gradle.kts` — whichever exists at
|
|
84
|
+
* the project root, with `.kts` (Kotlin DSL) preferred when both are
|
|
85
|
+
* present (Kotlin DSL is the modern default per Gradle 7+ docs).
|
|
86
|
+
* Undefined if neither exists.
|
|
87
|
+
*/
|
|
88
|
+
gradleBuild?: string;
|
|
71
89
|
/** Set of present directory names directly under the project root (one level). */
|
|
72
90
|
presentDirs: Set<string>;
|
|
73
91
|
/** Set of present file basenames directly under the project root (one level). */
|
|
@@ -232,6 +232,7 @@ function detectPnpmWorkspaces(root: string): WorkspacePackage[] | null {
|
|
|
232
232
|
const raw = safeReadText(join(root, 'pnpm-workspace.yaml'));
|
|
233
233
|
if (!raw) return null;
|
|
234
234
|
try {
|
|
235
|
+
// pattern-scanner-allow: yaml-parse — reason: Phase 1 auto-detection parses pnpm-workspace.yaml to discover workspace packages BEFORE any massu config exists. This runs PRE-getConfig in the init flow, so getConfig() is unavailable by definition.
|
|
235
236
|
const parsed = parseYaml(raw) as { packages?: unknown } | null;
|
|
236
237
|
const list = Array.isArray(parsed?.packages)
|
|
237
238
|
? (parsed!.packages as unknown[]).filter((x) => typeof x === 'string') as string[]
|
|
@@ -245,6 +245,7 @@ function readConventions(cwd?: string): {
|
|
|
245
245
|
const configPath = join(projectRoot, 'massu.config.yaml');
|
|
246
246
|
if (!existsSync(configPath)) return defaults;
|
|
247
247
|
const content = readFileSync(configPath, 'utf-8');
|
|
248
|
+
// pattern-scanner-allow: yaml-parse — reason: compiled standalone hook (esbuild bundle). Per P2-023a, hooks cannot import getConfig() — they run in the Claude Code subprocess context with no module resolution path back to packages/core. Direct YAML parse is the only available access pattern.
|
|
248
249
|
const parsed = parseYaml(content) as Record<string, unknown> | null;
|
|
249
250
|
if (!parsed || typeof parsed !== 'object') return defaults;
|
|
250
251
|
const conventions = parsed.conventions as Record<string, unknown> | undefined;
|
|
@@ -279,6 +279,7 @@ async function buildDriftBanner(): Promise<string> {
|
|
|
279
279
|
const configPath = resolve(process.cwd(), 'massu.config.yaml');
|
|
280
280
|
if (!existsSync(configPath)) return '';
|
|
281
281
|
const content = readFileSync(configPath, 'utf-8');
|
|
282
|
+
// pattern-scanner-allow: yaml-parse — reason: compiled standalone hook (esbuild bundle). Per P2-023a, hooks cannot import getConfig() — they run in the Claude Code subprocess context with no module resolution path back to packages/core. Direct YAML parse is the only available access pattern.
|
|
282
283
|
const parsed = parseYaml(content) as Record<string, unknown> | null;
|
|
283
284
|
if (!parsed || typeof parsed !== 'object') return '';
|
|
284
285
|
const det = parsed.detection as Record<string, unknown> | undefined;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generic synchronous file-lock primitive built on `proper-lockfile`.
|
|
6
|
+
*
|
|
7
|
+
* Plan 3c gap-59 deliverable. Single source of truth for "acquire-lock,
|
|
8
|
+
* run fn, release on every exit path" across the codebase. Both
|
|
9
|
+
* `lib/installLock.ts:withInstallLock` (Plan 3a installAll serialization)
|
|
10
|
+
* AND `security/manifest-cache.ts:refreshManifest` (Plan 3c manifest
|
|
11
|
+
* cache writes) MUST delegate to `withFileLockSync` here — there is NO
|
|
12
|
+
* parallel lock implementation in the codebase per CR-46 / Rule 0
|
|
13
|
+
* (single-source-of-truth for lock semantics).
|
|
14
|
+
*
|
|
15
|
+
* What this primitive provides:
|
|
16
|
+
* 1. mkdirSync the lock parent dir if absent (fresh-repo / fresh-home case).
|
|
17
|
+
* 2. proper-lockfile.lockSync acquires the lock; we wrap the manual retry
|
|
18
|
+
* loop because lockSync rejects retries>0 (`Cannot use retries with
|
|
19
|
+
* the sync api` per node_modules/proper-lockfile/lib/adapter.js).
|
|
20
|
+
* 3. Surface ELOCKED (POSIX) and EBUSY (Windows) as the same FileLockBusyError.
|
|
21
|
+
* 4. Persist the lock-holder PID alongside the lock as `<lockPath>.pid` so
|
|
22
|
+
* the next contender can include it in a user-friendly error message.
|
|
23
|
+
* 5. Default 30s block-then-bail per Plan 3a §190; configurable per-callsite.
|
|
24
|
+
* 6. `errorFactory` opt lets callers customize the busy-error class so
|
|
25
|
+
* domain-specific helpers (`InstallLockBusyError`, future Phase 5
|
|
26
|
+
* `ManifestCacheBusyError`) can extend the base type without each
|
|
27
|
+
* re-implementing the lock logic.
|
|
28
|
+
*
|
|
29
|
+
* NOT provided by this primitive:
|
|
30
|
+
* - Async variant (`withFileLockAsync`). The current Phase 5 cache-write
|
|
31
|
+
* path resolves the async fetch BEFORE acquiring the lock, so the lock
|
|
32
|
+
* is held only during the brief sync atomicWrite. Async-while-holding-
|
|
33
|
+
* the-lock would deadlock under contention; the design is "fetch first,
|
|
34
|
+
* then lock-for-write only".
|
|
35
|
+
* - Reentrancy. `proper-lockfile.lockSync` is non-reentrant; calling
|
|
36
|
+
* withFileLockSync recursively from inside its own `fn` will fail with
|
|
37
|
+
* ELOCKED. Plan 3a observed and documented this in
|
|
38
|
+
* __tests__/watch/config-refresh-autoyes.test.ts:129.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
42
|
+
import { dirname } from 'path';
|
|
43
|
+
import * as lockfile from 'proper-lockfile';
|
|
44
|
+
|
|
45
|
+
export interface FileLockOpts {
|
|
46
|
+
/** Default 30s — proper-lockfile considers a lock stale after this elapses. */
|
|
47
|
+
staleMs?: number;
|
|
48
|
+
/**
|
|
49
|
+
* How long the manual retry loop should block waiting for the holder to
|
|
50
|
+
* release before bailing with the busy error. Default 30s.
|
|
51
|
+
* Pass `0` to bail immediately (used in tests).
|
|
52
|
+
*/
|
|
53
|
+
blockMs?: number;
|
|
54
|
+
/** Sleep granularity inside the retry loop. Default 100ms. */
|
|
55
|
+
pollIntervalMs?: number;
|
|
56
|
+
/**
|
|
57
|
+
* Backwards-compat: legacy callers pass `retries: 0` to mean "do not
|
|
58
|
+
* block". When set to a positive integer, used by tests that want to
|
|
59
|
+
* exercise a specific retry count instead of the default time-based loop.
|
|
60
|
+
*/
|
|
61
|
+
retries?: number;
|
|
62
|
+
/** Override clock (test seam). */
|
|
63
|
+
now?: () => number;
|
|
64
|
+
/** Override sleep (test seam). Defaults to a busy-wait spinloop. */
|
|
65
|
+
sleep?: (ms: number) => void;
|
|
66
|
+
/**
|
|
67
|
+
* Optional custom busy-error factory. When provided, the default
|
|
68
|
+
* FileLockBusyError throw is replaced with whatever this factory returns.
|
|
69
|
+
* Domain-specific callers (installLock, manifest-cache) use this to
|
|
70
|
+
* keep their own user-facing error types and messages.
|
|
71
|
+
*/
|
|
72
|
+
errorFactory?: (
|
|
73
|
+
lockPath: string,
|
|
74
|
+
holderPid: number | null,
|
|
75
|
+
retryAfterSeconds: number,
|
|
76
|
+
causeCode: string | undefined,
|
|
77
|
+
) => Error;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class FileLockBusyError extends Error {
|
|
81
|
+
constructor(
|
|
82
|
+
public lockPath: string,
|
|
83
|
+
public holderPid: number | null,
|
|
84
|
+
public retryAfterSeconds: number,
|
|
85
|
+
public causeCode?: string,
|
|
86
|
+
) {
|
|
87
|
+
const pidPart = holderPid != null ? `(PID=${holderPid})` : '(PID=unknown)';
|
|
88
|
+
super(`File lock at ${lockPath} held by another process ${pidPart} — try again in ${retryAfterSeconds}s`);
|
|
89
|
+
this.name = 'FileLockBusyError';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Best-effort: read the PID of the current lock holder from the
|
|
95
|
+
* `<lockPath>.pid` sidecar file. Returns null on any read error.
|
|
96
|
+
*/
|
|
97
|
+
export function readLockHolderPid(lockPath: string): number | null {
|
|
98
|
+
try {
|
|
99
|
+
const raw = readFileSync(`${lockPath}.pid`, 'utf-8').trim();
|
|
100
|
+
const pid = Number.parseInt(raw, 10);
|
|
101
|
+
if (!Number.isFinite(pid) || pid <= 0) return null;
|
|
102
|
+
return pid;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* CR-9 audit L3 fix: REQUIRES SharedArrayBuffer + Atomics for the sync
|
|
110
|
+
* wait. The prior fallback (a tight `while (Date.now() < end)` spinloop)
|
|
111
|
+
* burned 100% CPU on environments without Atomics — typically sandboxed
|
|
112
|
+
* serverless runtimes — making contended manifest refreshes a DoS
|
|
113
|
+
* surface against the host. With this throw, callers running on
|
|
114
|
+
* Atomics-less environments fail loudly + early instead of silently
|
|
115
|
+
* spinning.
|
|
116
|
+
*/
|
|
117
|
+
export function busyWaitSync(ms: number): void {
|
|
118
|
+
if (typeof SharedArrayBuffer === 'undefined' || typeof Atomics === 'undefined') {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`withFileLockSync requires SharedArrayBuffer + Atomics for its retry-loop wait. ` +
|
|
121
|
+
`This Node runtime does not provide them — refusing to fall back to a CPU spinloop. ` +
|
|
122
|
+
`If you hit this in a sandboxed serverless env, the fix is to perform the ` +
|
|
123
|
+
`lock-protected operation in a host runtime that supports Atomics.`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const sab = new SharedArrayBuffer(4);
|
|
127
|
+
const view = new Int32Array(sab);
|
|
128
|
+
Atomics.wait(view, 0, 0, ms);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Acquire the lock at `lockPath`, run `fn`, release on every exit path.
|
|
133
|
+
* Synchronous all the way through. See module-level doc for guarantees.
|
|
134
|
+
*
|
|
135
|
+
* Throws:
|
|
136
|
+
* - The result of `opts.errorFactory(...)` if provided AND lock is busy
|
|
137
|
+
* beyond `blockMs`. Otherwise throws FileLockBusyError.
|
|
138
|
+
* - Any non-ELOCKED/EBUSY filesystem error from proper-lockfile is
|
|
139
|
+
* re-thrown unchanged.
|
|
140
|
+
*/
|
|
141
|
+
export function withFileLockSync<T>(lockPath: string, fn: () => T, opts: FileLockOpts = {}): T {
|
|
142
|
+
// Ensure the lock's parent directory exists. Fresh repos / fresh user-home
|
|
143
|
+
// .massu/ may not have the parent yet.
|
|
144
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
145
|
+
|
|
146
|
+
const staleMs = opts.staleMs ?? 30_000;
|
|
147
|
+
const blockMs = opts.retries === 0 ? 0 : (opts.blockMs ?? 30_000);
|
|
148
|
+
const pollIntervalMs = opts.pollIntervalMs ?? 100;
|
|
149
|
+
const now = opts.now ?? Date.now;
|
|
150
|
+
const sleep = opts.sleep ?? busyWaitSync;
|
|
151
|
+
const makeBusyError =
|
|
152
|
+
opts.errorFactory ??
|
|
153
|
+
((path, pid, retrySeconds, code) => new FileLockBusyError(path, pid, retrySeconds, code));
|
|
154
|
+
|
|
155
|
+
let release: (() => void) | null = null;
|
|
156
|
+
const deadline = now() + blockMs;
|
|
157
|
+
|
|
158
|
+
for (;;) {
|
|
159
|
+
try {
|
|
160
|
+
release = lockfile.lockSync(lockPath, {
|
|
161
|
+
stale: staleMs,
|
|
162
|
+
retries: 0,
|
|
163
|
+
realpath: false,
|
|
164
|
+
});
|
|
165
|
+
try {
|
|
166
|
+
writeFileSync(`${lockPath}.pid`, String(process.pid), 'utf-8');
|
|
167
|
+
} catch {
|
|
168
|
+
// best-effort
|
|
169
|
+
}
|
|
170
|
+
break;
|
|
171
|
+
} catch (err) {
|
|
172
|
+
const e = err as NodeJS.ErrnoException;
|
|
173
|
+
const code = e.code;
|
|
174
|
+
if (code !== 'ELOCKED' && code !== 'EBUSY') {
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
if (now() >= deadline) {
|
|
178
|
+
const holderPid = readLockHolderPid(lockPath);
|
|
179
|
+
const remainingMs = Math.max(0, deadline - now());
|
|
180
|
+
const retryAfterSeconds = blockMs === 0
|
|
181
|
+
? Math.round(staleMs / 1000)
|
|
182
|
+
: Math.round(remainingMs / 1000);
|
|
183
|
+
throw makeBusyError(lockPath, holderPid, retryAfterSeconds, code);
|
|
184
|
+
}
|
|
185
|
+
sleep(pollIntervalMs);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
return fn();
|
|
191
|
+
} finally {
|
|
192
|
+
try {
|
|
193
|
+
if (release) release();
|
|
194
|
+
} catch {
|
|
195
|
+
// best-effort
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
rmSync(`${lockPath}.pid`, { force: true });
|
|
199
|
+
} catch {
|
|
200
|
+
// best-effort
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|