@massu/core 1.4.0-soak.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/commands/README.md +0 -3
- package/dist/cli.js +9423 -5453
- package/dist/hooks/auto-learning-pipeline.js +27 -1
- package/dist/hooks/classify-failure.js +27 -1
- package/dist/hooks/cost-tracker.js +27 -1
- package/dist/hooks/fix-detector.js +27 -1
- package/dist/hooks/incident-pipeline.js +27 -1
- package/dist/hooks/post-edit-context.js +27 -1
- package/dist/hooks/post-tool-use.js +27 -1
- package/dist/hooks/pre-compact.js +27 -1
- package/dist/hooks/pre-delete-check.js +27 -1
- package/dist/hooks/quality-event.js +27 -1
- package/dist/hooks/rule-enforcement-pipeline.js +27 -1
- package/dist/hooks/session-end.js +27 -1
- package/dist/hooks/session-start.js +2677 -2675
- package/dist/hooks/user-prompt.js +27 -1
- package/docs/AUTHORING-ADAPTERS.md +207 -0
- package/docs/SECURITY.md +250 -0
- package/package.json +10 -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 +4 -3
- package/src/commands/config-upgrade.ts +1 -0
- package/src/commands/doctor.ts +2 -0
- package/src/commands/init.ts +3 -1
- package/src/commands/template-engine.ts +0 -2
- package/src/commands/watch.ts +1 -1
- package/src/config.ts +71 -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 +171 -2
- package/src/detect/adapters/types.ts +19 -2
- package/src/detect/migrate.ts +4 -4
- 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/lsp/auto-detect.ts +10 -1
- package/src/lsp/client.ts +188 -2
- 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/src/watch/daemon.ts +1 -1
- package/src/watch/paths.ts +2 -2
- 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
|
+
}
|
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* Plan 3b — Phase 1: Tree-sitter WASM grammar loader (Strategy A).
|
|
6
6
|
*
|
|
7
|
-
* Strategy A
|
|
8
|
-
* §1, §8): grammars are NOT bundled in the npm tarball. The loader downloads
|
|
7
|
+
* Strategy A: grammars are NOT bundled in the npm tarball. The loader downloads
|
|
9
8
|
* each requested grammar at first use from a pinned URL, verifies SHA-256
|
|
10
9
|
* against a hardcoded manifest, caches under `~/.massu/wasm-cache/`.
|
|
11
10
|
*
|
|
@@ -25,12 +24,14 @@
|
|
|
25
24
|
import { createHash } from 'crypto';
|
|
26
25
|
import {
|
|
27
26
|
mkdirSync,
|
|
27
|
+
readdirSync,
|
|
28
28
|
readFileSync,
|
|
29
29
|
writeFileSync,
|
|
30
30
|
renameSync,
|
|
31
31
|
unlinkSync,
|
|
32
32
|
lstatSync,
|
|
33
33
|
chmodSync,
|
|
34
|
+
utimesSync,
|
|
34
35
|
} from 'fs';
|
|
35
36
|
import { homedir } from 'os';
|
|
36
37
|
import { dirname, join } from 'path';
|
|
@@ -160,6 +161,56 @@ export const GRAMMAR_MANIFEST: Partial<Record<TreeSitterLanguage, ManifestEntry>
|
|
|
160
161
|
sha256: '41c4fdb2249a3aa6d87eed0d383081ff09725c2248b4977043a43825980ffcc7',
|
|
161
162
|
version: '0.1.13',
|
|
162
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
|
+
},
|
|
163
214
|
};
|
|
164
215
|
|
|
165
216
|
// ============================================================
|
|
@@ -174,6 +225,118 @@ function getCachedPath(language: TreeSitterLanguage, sha: string): string {
|
|
|
174
225
|
return join(getCacheDir(), `${language}-${sha}.wasm`);
|
|
175
226
|
}
|
|
176
227
|
|
|
228
|
+
// ============================================================
|
|
229
|
+
// LRU cache eviction (Phase 3.5 audit F-011 — closed 2026-05-06)
|
|
230
|
+
// ============================================================
|
|
231
|
+
//
|
|
232
|
+
// F-011 was deferred at v1 ("at ~3MB per grammar, full cache footprint is
|
|
233
|
+
// <100MB — not an attack vector"). The 2026-05-06 audit-leak retrospective
|
|
234
|
+
// elevated it: now that the cache path + naming convention are publicly
|
|
235
|
+
// known (the security audit doc was visible for 9 days), opportunistic
|
|
236
|
+
// disk-fill attacks become slightly less hypothetical, AND the cost of
|
|
237
|
+
// retrofitting LRU once Plan 3c expands the supported grammar set is
|
|
238
|
+
// strictly higher than doing it now while only 4 grammars exist.
|
|
239
|
+
//
|
|
240
|
+
// Eviction rule: keep the N most-recently-USED entries (mtime, updated by
|
|
241
|
+
// the cache-hit path on every read). Default cap = 16 — leaves headroom
|
|
242
|
+
// for Plan 3c's 31-grammar expansion plus dev-time version churn, while
|
|
243
|
+
// bounding total cache to ~50MB at 3MB/grammar.
|
|
244
|
+
|
|
245
|
+
const DEFAULT_CACHE_RETAIN_COUNT = 16;
|
|
246
|
+
|
|
247
|
+
function getCacheRetainCount(): number {
|
|
248
|
+
const env = process.env.MASSU_WASM_CACHE_RETAIN;
|
|
249
|
+
if (env) {
|
|
250
|
+
const n = Number(env);
|
|
251
|
+
if (Number.isFinite(n) && n >= 1 && n <= 1024) return Math.floor(n);
|
|
252
|
+
}
|
|
253
|
+
return DEFAULT_CACHE_RETAIN_COUNT;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Touch a cache file's mtime to mark "most recently used." Called on every
|
|
258
|
+
* cache-hit. Best-effort: any failure is silently swallowed — touching is
|
|
259
|
+
* an optimization signal for eviction, not load-bearing.
|
|
260
|
+
*
|
|
261
|
+
* Uses utimes via writeFileSync round-trip would be expensive; instead we
|
|
262
|
+
* use the same filesystem touch trick as `touch -a`: open + close. On
|
|
263
|
+
* macOS/Linux Node, `chmodSync` to the same mode does NOT update mtime,
|
|
264
|
+
* so we do a no-op write of empty content via a tmp marker file. Cheaper
|
|
265
|
+
* approach: just rely on atime if filesystem records it. Most modern
|
|
266
|
+
* filesystems are mounted with `relatime` so atime updates only when
|
|
267
|
+
* older than mtime — which means after our first eviction-relevant
|
|
268
|
+
* read, atime IS the right signal.
|
|
269
|
+
*
|
|
270
|
+
* Decision: use mtime via `utimesSync` — explicit and portable.
|
|
271
|
+
*/
|
|
272
|
+
function touchCacheFile(path: string): void {
|
|
273
|
+
try {
|
|
274
|
+
const now = new Date();
|
|
275
|
+
utimesSync(path, now, now);
|
|
276
|
+
} catch {
|
|
277
|
+
// best-effort
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Evict cache entries beyond the retain count, keeping the N most recently
|
|
283
|
+
* used (by mtime). Called after every successful cache write. Best-effort:
|
|
284
|
+
* eviction failure never blocks a load.
|
|
285
|
+
*
|
|
286
|
+
* Rejects symlinks and non-regular files via lstat — the same defense as
|
|
287
|
+
* the cache-hit path (F-008 fix). A symlink in the cache dir is logged
|
|
288
|
+
* as a security warning but not deleted (don't act on attacker-controlled
|
|
289
|
+
* paths automatically).
|
|
290
|
+
*/
|
|
291
|
+
function evictBeyondRetainCount(retain: number = getCacheRetainCount()): void {
|
|
292
|
+
const dir = getCacheDir();
|
|
293
|
+
let entries: string[];
|
|
294
|
+
try {
|
|
295
|
+
entries = readdirSync(dir);
|
|
296
|
+
} catch {
|
|
297
|
+
return; // Dir doesn't exist yet; nothing to evict.
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const candidates: { path: string; mtimeMs: number }[] = [];
|
|
301
|
+
for (const name of entries) {
|
|
302
|
+
if (!name.endsWith('.wasm')) continue; // Don't touch non-grammar files.
|
|
303
|
+
const path = join(dir, name);
|
|
304
|
+
let stat;
|
|
305
|
+
try {
|
|
306
|
+
stat = lstatSync(path);
|
|
307
|
+
} catch {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
311
|
+
// Skip — never automatically delete what could be an attacker-placed
|
|
312
|
+
// symlink. Surface via stderr; user's cache dir is suspect.
|
|
313
|
+
console.error(
|
|
314
|
+
`[tree-sitter-loader] cache eviction skipped non-regular file: ${path} ` +
|
|
315
|
+
`(possible symlink attack — see Phase 3.5 finding F-008).`,
|
|
316
|
+
);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
candidates.push({ path, mtimeMs: stat.mtimeMs });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (candidates.length <= retain) return;
|
|
323
|
+
|
|
324
|
+
// Sort newest-first; everything beyond `retain` is evictable.
|
|
325
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
326
|
+
for (const victim of candidates.slice(retain)) {
|
|
327
|
+
try {
|
|
328
|
+
unlinkSync(victim.path);
|
|
329
|
+
} catch {
|
|
330
|
+
// best-effort
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Test-injection hook: lets tests force eviction without writing a new grammar. */
|
|
336
|
+
export function _evictCacheForTest(retain?: number): void {
|
|
337
|
+
evictBeyondRetainCount(retain);
|
|
338
|
+
}
|
|
339
|
+
|
|
177
340
|
function sha256(bytes: Uint8Array): string {
|
|
178
341
|
return createHash('sha256').update(bytes).digest('hex');
|
|
179
342
|
}
|
|
@@ -271,6 +434,9 @@ export async function loadGrammar(
|
|
|
271
434
|
}
|
|
272
435
|
const lang = await Language.load(bytes);
|
|
273
436
|
loadedGrammars.set(language, lang);
|
|
437
|
+
// F-011 LRU: mark this entry as most-recently-used so it survives
|
|
438
|
+
// future evictions.
|
|
439
|
+
touchCacheFile(cachePath);
|
|
274
440
|
return lang;
|
|
275
441
|
}
|
|
276
442
|
}
|
|
@@ -326,6 +492,9 @@ export async function loadGrammar(
|
|
|
326
492
|
}
|
|
327
493
|
throw e;
|
|
328
494
|
}
|
|
495
|
+
// F-011 LRU: prune cache to retain count after every successful write.
|
|
496
|
+
// Best-effort — eviction failure never blocks a load.
|
|
497
|
+
evictBeyondRetainCount();
|
|
329
498
|
} catch (e) {
|
|
330
499
|
// Cache write failure is non-fatal — we still have `body` in memory and
|
|
331
500
|
// can load directly. Log to stderr per VR-USER-ERROR-MESSAGES style.
|
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* Plan 3b — Phase 1: AST Adapter contract types.
|
|
6
6
|
*
|
|
7
|
-
* Lives at `packages/core/src/detect/adapters/types.ts
|
|
8
|
-
* (`docs/internal/2026-04-26-ast-lsp-spec.md` §2). All types are local —
|
|
7
|
+
* Lives at `packages/core/src/detect/adapters/types.ts`. All types are local —
|
|
9
8
|
* NONE re-exported from `web-tree-sitter`.
|
|
10
9
|
*
|
|
11
10
|
* Adapter authors import from this module only; the runner (`runner.ts`)
|
|
@@ -69,6 +68,24 @@ export interface DetectionSignals {
|
|
|
69
68
|
cargoToml?: Record<string, unknown>;
|
|
70
69
|
/** Raw `go.mod` text — undefined if absent. */
|
|
71
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;
|
|
72
89
|
/** Set of present directory names directly under the project root (one level). */
|
|
73
90
|
presentDirs: Set<string>;
|
|
74
91
|
/** Set of present file basenames directly under the project root (one level). */
|
package/src/detect/migrate.ts
CHANGED
|
@@ -192,7 +192,7 @@ export function migrateV1ToV2(
|
|
|
192
192
|
framework.languages = languageEntries;
|
|
193
193
|
}
|
|
194
194
|
// P1-004: preserve any v1Framework subkey the explicit rebuild didn't emit
|
|
195
|
-
// (e.g.,
|
|
195
|
+
// (e.g., a multi-runtime monorepo's `framework.{python, rust, swift, typescript}` language sub-blocks).
|
|
196
196
|
preserveNestedSubkeys(v1Framework, framework);
|
|
197
197
|
|
|
198
198
|
// Paths: preserve user-set fields; fill `source` from detection if user had 'src' default.
|
|
@@ -234,13 +234,13 @@ export function migrateV1ToV2(
|
|
|
234
234
|
if (typeof v1Paths[k] === 'string') paths[k] = v1Paths[k];
|
|
235
235
|
}
|
|
236
236
|
// P1-005: preserve any v1Paths subkey the explicit rebuild didn't emit
|
|
237
|
-
// (e.g.,
|
|
237
|
+
// (e.g., a downstream consumer's 19 custom `paths.*` entries like adr, plans, monorepo_root).
|
|
238
238
|
preserveNestedSubkeys(v1Paths, paths);
|
|
239
239
|
|
|
240
240
|
const verification = buildVerificationBlock(detection, v1Verification);
|
|
241
241
|
|
|
242
242
|
// P1-006: build project block with nested passthrough so custom subkeys
|
|
243
|
-
// (e.g.,
|
|
243
|
+
// (e.g., a downstream consumer's `project.description`) survive the migration.
|
|
244
244
|
const project: Record<string, unknown> = {
|
|
245
245
|
name: typeof v1Project.name === 'string' ? v1Project.name : 'my-project',
|
|
246
246
|
root: typeof v1Project.root === 'string' ? v1Project.root : 'auto',
|
|
@@ -265,7 +265,7 @@ export function migrateV1ToV2(
|
|
|
265
265
|
|
|
266
266
|
// P1-001: preserve any v1 top-level key not already handled by the explicit
|
|
267
267
|
// migrator. This is the generalization of PRESERVED_FIELDS — custom sections
|
|
268
|
-
// like `services`, `workflow`, `north_stars`
|
|
268
|
+
// like `services`, `workflow`, `north_stars` now pass through.
|
|
269
269
|
//
|
|
270
270
|
// `detection` is intentionally NOT in handledTopLevel: when a v2 config is
|
|
271
271
|
// fed back in (idempotence check at migrate.ts:16), the existing `detection`
|
|
@@ -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;
|