@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,261 @@
|
|
|
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: go-chi AST adapter.
|
|
6
|
+
*
|
|
7
|
+
* First Phase 7 framework that introduces a NEW Tree-sitter grammar
|
|
8
|
+
* (`go`) — added to GRAMMAR_MANIFEST in the preceding Commit 1
|
|
9
|
+
* (plan-3c-phase7-grammar-infra). End-to-end proof that the grammar
|
|
10
|
+
* load + parse + query path works for non-Python/JS/Swift languages.
|
|
11
|
+
*
|
|
12
|
+
* Establishes the per-framework deliverable pattern (4 artifacts) for
|
|
13
|
+
* the remaining registry-verified frameworks (rails / aspnet / spring /
|
|
14
|
+
* phoenix) and the bundled Go adapters (gin / echo / fiber / net-http):
|
|
15
|
+
* 1. packages/core/templates/go-chi/massu.config.yaml (variant template)
|
|
16
|
+
* 2. packages/core/src/detect/adapters/go-chi.ts (this file — AST adapter)
|
|
17
|
+
* 3. Adversarial fixtures (inline in the test file via mkdirSync+writeFileSync)
|
|
18
|
+
* 4. packages/core/src/__tests__/go-chi.test.ts (golden-output test)
|
|
19
|
+
*
|
|
20
|
+
* Extracts:
|
|
21
|
+
* - route_method: most-common HTTP method registered via `r.<Method>(...)`
|
|
22
|
+
* (one of Get/Post/Put/Delete/Patch/Head/Options/Connect/Trace).
|
|
23
|
+
* Useful for scaffold-router templates that need to know which verb
|
|
24
|
+
* style the project uses.
|
|
25
|
+
* - mount_prefix_base: first path segment of `r.Mount("/api/...", ...)`
|
|
26
|
+
* mirroring python-fastapi's APIRouter prefix extraction. The chi
|
|
27
|
+
* `Mount` call is the canonical way to attach a sub-router under
|
|
28
|
+
* a path prefix (per chi docs: https://go-chi.io/#/pages/routing).
|
|
29
|
+
* - middleware_name: first chi middleware registered via
|
|
30
|
+
* `r.Use(middleware.<Name>)` (e.g., "Logger", "Recoverer", "RequestID").
|
|
31
|
+
* Captured from the canonical chi `middleware.*` package qualifier.
|
|
32
|
+
*
|
|
33
|
+
* Confidence rules (mirror python-fastapi/python-flask):
|
|
34
|
+
* - 'high' if exactly ONE distinct route_method seen (clear convention).
|
|
35
|
+
* - 'medium' if mount_prefix_base or middleware_name found but no route_method.
|
|
36
|
+
* - 'low' if multiple distinct route_methods seen (mixed convention).
|
|
37
|
+
* - 'none' if no chi signals at all (regex fallback takes over).
|
|
38
|
+
*
|
|
39
|
+
* Does NOT use regex on file content — only Tree-sitter S-expression queries
|
|
40
|
+
* compiled via query-helpers.ts. Regex would be the regex-fallback path.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { Parser } from 'web-tree-sitter';
|
|
44
|
+
import type { CodebaseAdapter, AdapterResult, DetectionSignals, Provenance, SourceFile } from './types.ts';
|
|
45
|
+
import { runQuery, InvalidQueryError } from './query-helpers.ts';
|
|
46
|
+
import { loadGrammar } from './tree-sitter-loader.ts';
|
|
47
|
+
import { isParsableSource, MAX_AST_FILE_BYTES } from './parse-guard.ts';
|
|
48
|
+
|
|
49
|
+
// ============================================================
|
|
50
|
+
// Tree-sitter S-expression queries (Go grammar)
|
|
51
|
+
// ============================================================
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* HTTP method route registration: matches `r.Get("/path", handler)`,
|
|
55
|
+
* `r.Post(...)`, etc. Anchored on the chi-canonical method names; we
|
|
56
|
+
* deliberately do NOT match arbitrary `r.<Anything>(...)` calls because
|
|
57
|
+
* Go's selector_expression shape is shared by every method call.
|
|
58
|
+
*
|
|
59
|
+
* The Tree-sitter Go grammar represents `r.Get(...)` as:
|
|
60
|
+
* call_expression {
|
|
61
|
+
* function: selector_expression {
|
|
62
|
+
* operand: identifier (e.g. "r")
|
|
63
|
+
* field: field_identifier (e.g. "Get")
|
|
64
|
+
* }
|
|
65
|
+
* arguments: argument_list { ... }
|
|
66
|
+
* }
|
|
67
|
+
*
|
|
68
|
+
* The #match? predicate constrains @method to chi's HTTP verb set.
|
|
69
|
+
*/
|
|
70
|
+
const ROUTE_METHOD_QUERY = `
|
|
71
|
+
(call_expression
|
|
72
|
+
function: (selector_expression
|
|
73
|
+
field: (field_identifier) @method (#match? @method "^(Get|Post|Put|Delete|Patch|Head|Options|Connect|Trace)$"))
|
|
74
|
+
arguments: (argument_list
|
|
75
|
+
.
|
|
76
|
+
(interpreted_string_literal) @route_path))
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Subrouter mount: `r.Mount("/api/v1", apiRouter)`. Captures the path
|
|
81
|
+
* literal so we can split off the base segment, mirroring python-fastapi's
|
|
82
|
+
* APIRouter prefix extraction.
|
|
83
|
+
*
|
|
84
|
+
* Per chi docs (https://go-chi.io/#/pages/routing#mounting):
|
|
85
|
+
* "Mount attaches another http.Handler along ./pattern/*"
|
|
86
|
+
*/
|
|
87
|
+
const MOUNT_PREFIX_QUERY = `
|
|
88
|
+
(call_expression
|
|
89
|
+
function: (selector_expression
|
|
90
|
+
field: (field_identifier) @_field (#eq? @_field "Mount"))
|
|
91
|
+
arguments: (argument_list
|
|
92
|
+
.
|
|
93
|
+
(interpreted_string_literal) @mount_path))
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* chi middleware registration: `r.Use(middleware.Logger)`,
|
|
98
|
+
* `r.Use(middleware.Recoverer)`, etc. We require the canonical
|
|
99
|
+
* `middleware.<Name>` qualifier to avoid matching user-defined
|
|
100
|
+
* middleware (which we couldn't classify by name alone).
|
|
101
|
+
*
|
|
102
|
+
* The selector_expression inside the argument captures the package
|
|
103
|
+
* qualifier (`middleware`) and the function name (`Logger`/etc).
|
|
104
|
+
*/
|
|
105
|
+
const MIDDLEWARE_USE_QUERY = `
|
|
106
|
+
(call_expression
|
|
107
|
+
function: (selector_expression
|
|
108
|
+
field: (field_identifier) @_use (#eq? @_use "Use"))
|
|
109
|
+
arguments: (argument_list
|
|
110
|
+
.
|
|
111
|
+
(selector_expression
|
|
112
|
+
operand: (identifier) @_pkg (#eq? @_pkg "middleware")
|
|
113
|
+
field: (field_identifier) @middleware_name)))
|
|
114
|
+
`;
|
|
115
|
+
|
|
116
|
+
// ============================================================
|
|
117
|
+
// Adapter
|
|
118
|
+
// ============================================================
|
|
119
|
+
|
|
120
|
+
export const goChiAdapter: CodebaseAdapter = {
|
|
121
|
+
id: 'go-chi',
|
|
122
|
+
languages: ['go'],
|
|
123
|
+
|
|
124
|
+
matches(signals: DetectionSignals): boolean {
|
|
125
|
+
// Cheap signal-only check. No file IO. Match if:
|
|
126
|
+
// 1. go.mod text mentions github.com/go-chi/chi (canonical require), OR
|
|
127
|
+
// 2. project has cmd/ + internal/ AND go.mod is present (Go layout) — but
|
|
128
|
+
// only when go.mod ALSO mentions chi (avoids matching every Go project).
|
|
129
|
+
if (!signals.goMod) return false;
|
|
130
|
+
if (/github\.com\/go-chi\/chi/i.test(signals.goMod)) return true;
|
|
131
|
+
return false;
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
async introspect(files: SourceFile[], _rootDir: string): Promise<AdapterResult> {
|
|
135
|
+
if (files.length === 0) {
|
|
136
|
+
return { conventions: {}, provenance: [], confidence: 'none' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let language;
|
|
140
|
+
try {
|
|
141
|
+
language = await loadGrammar('go');
|
|
142
|
+
} catch (e) {
|
|
143
|
+
// Grammar unavailable → adapter returns 'none' so regex fallback takes over.
|
|
144
|
+
return { conventions: {}, provenance: [], confidence: 'none' };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const parser = new Parser();
|
|
148
|
+
parser.setLanguage(language);
|
|
149
|
+
|
|
150
|
+
const routeMethods = new Map<string, { line: number; file: string }>();
|
|
151
|
+
const mountBases = new Map<string, { line: number; file: string }>();
|
|
152
|
+
const middlewareNames = new Map<string, { line: number; file: string }>();
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
for (const file of files) {
|
|
156
|
+
// Phase 3.5 defense-in-depth size + depth gate at adapter tier.
|
|
157
|
+
const skip = isParsableSource(file.content, file.size);
|
|
158
|
+
if (skip) {
|
|
159
|
+
process.stderr.write(
|
|
160
|
+
`[massu/ast] WARN: go-chi skipping ${file.path}: ${skip.reason} (${skip.detail}). Cap=${MAX_AST_FILE_BYTES}. (Phase 3.5 mitigation)\n`,
|
|
161
|
+
);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
for (const hit of runQuery(parser, file.content, ROUTE_METHOD_QUERY, 'chi-route-method', file.path)) {
|
|
166
|
+
const method = hit.captures.method;
|
|
167
|
+
if (method && !routeMethods.has(method)) {
|
|
168
|
+
routeMethods.set(method, { line: hit.line, file: file.path });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const hit of runQuery(parser, file.content, MOUNT_PREFIX_QUERY, 'chi-mount-prefix', file.path)) {
|
|
172
|
+
const raw = hit.captures.mount_path;
|
|
173
|
+
if (!raw) continue;
|
|
174
|
+
const literal = raw.replace(/^["`]/, '').replace(/["`]$/, '');
|
|
175
|
+
const base = extractPrefixBase(literal);
|
|
176
|
+
if (base && !mountBases.has(base)) {
|
|
177
|
+
mountBases.set(base, { line: hit.line, file: file.path });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
for (const hit of runQuery(parser, file.content, MIDDLEWARE_USE_QUERY, 'chi-middleware-use', file.path)) {
|
|
181
|
+
const name = hit.captures.middleware_name;
|
|
182
|
+
if (name && !middlewareNames.has(name)) {
|
|
183
|
+
middlewareNames.set(name, { line: hit.line, file: file.path });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} catch (e) {
|
|
187
|
+
if (e instanceof InvalidQueryError) {
|
|
188
|
+
// Compile-time failure of OUR query is a developer bug — surface it.
|
|
189
|
+
throw e;
|
|
190
|
+
}
|
|
191
|
+
// Per-file parse error: skip + keep going.
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} finally {
|
|
196
|
+
try { parser.delete(); } catch { /* ignore */ }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const conventions: Record<string, unknown> = {};
|
|
200
|
+
const provenance: Provenance[] = [];
|
|
201
|
+
|
|
202
|
+
if (routeMethods.size === 1) {
|
|
203
|
+
const [name, { line, file }] = routeMethods.entries().next().value as [string, { line: number; file: string }];
|
|
204
|
+
conventions.route_method = name;
|
|
205
|
+
provenance.push({ field: 'route_method', sourceFile: file, line, query: 'chi-route-method' });
|
|
206
|
+
} else if (routeMethods.size >= 2) {
|
|
207
|
+
// Mixed convention — emit first-seen for visibility.
|
|
208
|
+
const [name, { line, file }] = routeMethods.entries().next().value as [string, { line: number; file: string }];
|
|
209
|
+
conventions.route_method = name;
|
|
210
|
+
provenance.push({ field: 'route_method', sourceFile: file, line, query: 'chi-route-method' });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (mountBases.size >= 1) {
|
|
214
|
+
const [base, { line, file }] = mountBases.entries().next().value as [string, { line: number; file: string }];
|
|
215
|
+
conventions.mount_prefix_base = base;
|
|
216
|
+
provenance.push({ field: 'mount_prefix_base', sourceFile: file, line, query: 'chi-mount-prefix' });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (middlewareNames.size >= 1) {
|
|
220
|
+
const [name, { line, file }] = middlewareNames.entries().next().value as [string, { line: number; file: string }];
|
|
221
|
+
conventions.middleware_name = name;
|
|
222
|
+
provenance.push({ field: 'middleware_name', sourceFile: file, line, query: 'chi-middleware-use' });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let confidence: AdapterResult['confidence'];
|
|
226
|
+
if (Object.keys(conventions).length === 0) {
|
|
227
|
+
confidence = 'none';
|
|
228
|
+
} else if (routeMethods.size === 1) {
|
|
229
|
+
confidence = 'high';
|
|
230
|
+
} else if (routeMethods.size >= 2) {
|
|
231
|
+
confidence = 'low';
|
|
232
|
+
} else {
|
|
233
|
+
confidence = 'medium';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { conventions, provenance, confidence };
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// ============================================================
|
|
241
|
+
// Helpers
|
|
242
|
+
// ============================================================
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Extract the first path segment of a chi mount path. Mirrors
|
|
246
|
+
* python-fastapi/python-flask's extractPrefixBase. Returns null if input
|
|
247
|
+
* doesn't start with `/`.
|
|
248
|
+
*
|
|
249
|
+
* NOTE: future refactor opportunity once a third adapter needs identical
|
|
250
|
+
* logic — extracting a shared helper module. The Phase 7 Flask commit
|
|
251
|
+
* deliberately copied this from python-fastapi rather than premature
|
|
252
|
+
* extraction (per its self-attest #3); the same reasoning applies here
|
|
253
|
+
* until a fourth consumer appears.
|
|
254
|
+
*/
|
|
255
|
+
function extractPrefixBase(prefix: string): string | null {
|
|
256
|
+
if (!prefix.startsWith('/')) return null;
|
|
257
|
+
const stripped = prefix.replace(/^\/+/, '');
|
|
258
|
+
const firstSeg = stripped.split('/')[0];
|
|
259
|
+
if (!firstSeg) return null;
|
|
260
|
+
return '/' + firstSeg;
|
|
261
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORE-BUNDLED adapter id source of truth.
|
|
3
|
+
*
|
|
4
|
+
* Plan 3c Phase 5 5H + 5I deliverable. The CLI's `adapters list` consumes
|
|
5
|
+
* this set to know which ids should be classified as CORE-BUNDLED via the
|
|
6
|
+
* three-class trust model (security/adapter-origin.ts:getAdapterOrigin).
|
|
7
|
+
*
|
|
8
|
+
* CR-46 / Rule 0 drift-prevention: a static set is the wrong shape (it
|
|
9
|
+
* inevitably drifts as Plan 3b/Phase 7 adds adapters). Instead the set
|
|
10
|
+
* is paired with a drift-guard test
|
|
11
|
+
* (__tests__/core-bundled-ids-drift.test.ts) that asserts CORE_BUNDLED_IDS
|
|
12
|
+
* matches the actual filesystem state of `detect/adapters/*.ts` minus the
|
|
13
|
+
* support modules (types, runner, query-helpers, parse-guard, tree-sitter-
|
|
14
|
+
* loader, index, discover). A new adapter file added to the directory
|
|
15
|
+
* without updating this set fails the drift-guard test → cannot merge.
|
|
16
|
+
*
|
|
17
|
+
* Plan 3b shipped 4 (next, fastapi, django, swiftui); Phase 7 ships 31
|
|
18
|
+
* more for 35 total. Each Phase 7 commit MUST update this set in lockstep
|
|
19
|
+
* with the new adapter file under detect/adapters/.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const CORE_BUNDLED_IDS: ReadonlySet<string> = new Set([
|
|
23
|
+
'aspnet',
|
|
24
|
+
'go-chi',
|
|
25
|
+
'nextjs-trpc',
|
|
26
|
+
'phoenix',
|
|
27
|
+
'python-django',
|
|
28
|
+
'python-fastapi',
|
|
29
|
+
'python-flask',
|
|
30
|
+
'rails',
|
|
31
|
+
'spring',
|
|
32
|
+
'swift-swiftui',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Filenames in `detect/adapters/` that are NOT first-party adapters but
|
|
37
|
+
* support modules (the adapter contract types, the runner, helpers).
|
|
38
|
+
* The drift-guard test uses this list to subtract support files from the
|
|
39
|
+
* filesystem scan before asserting equality with CORE_BUNDLED_IDS.
|
|
40
|
+
*/
|
|
41
|
+
export const ADAPTER_SUPPORT_FILES: ReadonlySet<string> = new Set([
|
|
42
|
+
'types.ts',
|
|
43
|
+
'runner.ts',
|
|
44
|
+
'query-helpers.ts',
|
|
45
|
+
'parse-guard.ts',
|
|
46
|
+
'tree-sitter-loader.ts',
|
|
47
|
+
'index.ts',
|
|
48
|
+
'discover.ts',
|
|
49
|
+
]);
|
|
@@ -0,0 +1,277 @@
|
|
|
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: Phoenix AST adapter.
|
|
6
|
+
*
|
|
7
|
+
* Fourth Phase 7 framework after go-chi + Flask + Rails. First to consume
|
|
8
|
+
* the `elixir` Tree-sitter grammar entry from GRAMMAR_MANIFEST (commit
|
|
9
|
+
* fbb8aa9). Together with the @massu/adapter-phoenix workspace stub from
|
|
10
|
+
* Stage 2 P1-004 (commit ebf2983), completes the per-framework deliverable
|
|
11
|
+
* pattern (4 artifacts):
|
|
12
|
+
* 1. packages/core/templates/phoenix/massu.config.yaml (variant template)
|
|
13
|
+
* 2. packages/core/src/detect/adapters/phoenix.ts (this file — AST adapter)
|
|
14
|
+
* 3. Adversarial fixtures (inline in the test file via mkdirSync+writeFileSync)
|
|
15
|
+
* 4. packages/core/src/__tests__/phoenix.test.ts (golden-output test)
|
|
16
|
+
* + adapter-grammar-strict.test.ts entry (structural gate)
|
|
17
|
+
*
|
|
18
|
+
* Phoenix routes live in `lib/<app>_web/router.ex` using Elixir DSL macros
|
|
19
|
+
* (per Phoenix routing guide: https://hexdocs.pm/phoenix/routing.html).
|
|
20
|
+
* The adapter walks the router DSL invocations directly rather than scanning
|
|
21
|
+
* controller/live directories, mirroring the rails approach against
|
|
22
|
+
* routes.rb.
|
|
23
|
+
*
|
|
24
|
+
* Extracts:
|
|
25
|
+
* - route_method: most-common explicit HTTP verb macro (`get`, `post`,
|
|
26
|
+
* `put`, `patch`, `delete`, `head`, `options`) used at the router scope
|
|
27
|
+
* level with a string-literal path argument. Mirrors python-fastapi/
|
|
28
|
+
* python-flask/go-chi/rails route_method semantics. Excludes the
|
|
29
|
+
* Phoenix-specific `live` and `live_session` macros (those are
|
|
30
|
+
* LiveView-specific and not interchangeable with HTTP verbs), and
|
|
31
|
+
* excludes `resources "/users", UserController` (RESTful sugar — same
|
|
32
|
+
* reasoning as rails resources :users).
|
|
33
|
+
* - scope_prefix_base: first path segment of the first `scope "/api", …
|
|
34
|
+
* do …` block, normalized to a leading-slash path. Mirrors rails
|
|
35
|
+
* api_namespace / python-flask blueprint_url_prefix / go-chi
|
|
36
|
+
* mount_prefix_base. Per Phoenix routing guide §Scoped routes:
|
|
37
|
+
* https://hexdocs.pm/phoenix/routing.html#scoped-routes
|
|
38
|
+
* - router_module: full module name from `defmodule MyAppWeb.Router do`,
|
|
39
|
+
* restricted to modules ending in `Router` (canonical Phoenix naming).
|
|
40
|
+
* Useful for scaffold-router templates that need to know the project's
|
|
41
|
+
* Web module convention.
|
|
42
|
+
*
|
|
43
|
+
* Confidence rules (mirror rails / go-chi):
|
|
44
|
+
* - 'high' if exactly ONE distinct route_method seen (clear convention).
|
|
45
|
+
* - 'low' if multiple distinct route_methods seen (mixed convention).
|
|
46
|
+
* - 'medium' if scope_prefix_base or router_module found but no
|
|
47
|
+
* route_method.
|
|
48
|
+
* - 'none' if no Phoenix DSL signals at all (regex fallback takes over).
|
|
49
|
+
*
|
|
50
|
+
* Tree-sitter-elixir grammar shape (verified via AST probe 2026-05-07):
|
|
51
|
+
* - Macro invocations parse as `(call target: (identifier) (arguments ...))`
|
|
52
|
+
* OR `(call (identifier) (arguments ...))` — both forms accepted; we use
|
|
53
|
+
* the positional shape (no `target:` field) so queries also match the
|
|
54
|
+
* parens-ful invocation `get(...)`.
|
|
55
|
+
* - Module names are `(alias)` (full dotted chain as one node).
|
|
56
|
+
* - String literals are `(string (quoted_content))`; node.text returns
|
|
57
|
+
* the quotes intact.
|
|
58
|
+
* - `do … end` blocks are `(do_block ...)` siblings of `(arguments)`.
|
|
59
|
+
* - Atoms (`:foo`) are `(atom)` — NOT used in any phoenix query (we
|
|
60
|
+
* exclude atom-arg DSL forms by anchoring on `(string)` first arg).
|
|
61
|
+
*
|
|
62
|
+
* Does NOT use regex on file content — only Tree-sitter S-expression queries
|
|
63
|
+
* compiled via query-helpers.ts. Regex would be the regex-fallback path.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
import { Parser } from 'web-tree-sitter';
|
|
67
|
+
import type { CodebaseAdapter, AdapterResult, DetectionSignals, Provenance, SourceFile } from './types.ts';
|
|
68
|
+
import { runQuery, InvalidQueryError } from './query-helpers.ts';
|
|
69
|
+
import { loadGrammar } from './tree-sitter-loader.ts';
|
|
70
|
+
import { isParsableSource, MAX_AST_FILE_BYTES } from './parse-guard.ts';
|
|
71
|
+
|
|
72
|
+
// ============================================================
|
|
73
|
+
// Tree-sitter S-expression queries (Elixir grammar)
|
|
74
|
+
// ============================================================
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* HTTP method route registration with a STRING-LITERAL first argument:
|
|
78
|
+
* `get "/health", HealthController, :show`,
|
|
79
|
+
* `post "/login", SessionController, :create`.
|
|
80
|
+
*
|
|
81
|
+
* Anchored (`.`) on the first argument so we ONLY match string-literal
|
|
82
|
+
* paths. The `#match?` predicate restricts to canonical HTTP verbs,
|
|
83
|
+
* excluding Phoenix-specific `live` / `live_session` / `forward` /
|
|
84
|
+
* `resources` macros (those have different semantics).
|
|
85
|
+
*
|
|
86
|
+
* Per Phoenix routing guide §HTTP Methods:
|
|
87
|
+
* https://hexdocs.pm/phoenix/routing.html#http-methods
|
|
88
|
+
*/
|
|
89
|
+
const ROUTE_METHOD_QUERY = `
|
|
90
|
+
(call
|
|
91
|
+
(identifier) @method (#match? @method "^(get|post|put|patch|delete|options|head)$")
|
|
92
|
+
(arguments
|
|
93
|
+
.
|
|
94
|
+
(string) @route_path))
|
|
95
|
+
`;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Scope block: `scope "/api", MyAppWeb do … end` or `scope "/admin" do …
|
|
99
|
+
* end`. Captures the FIRST positional argument as a string. The shape
|
|
100
|
+
* `scope MyAppWeb do … end` (alias-only, no path) deliberately doesn't
|
|
101
|
+
* match because the first arg is `(alias)` not `(string)` — verified
|
|
102
|
+
* negative case in AST probe.
|
|
103
|
+
*
|
|
104
|
+
* The presence of a `do_block` is required — `scope "/api", MyAppWeb` (no
|
|
105
|
+
* do/end) wouldn't be a valid Phoenix router scope anyway, but anchoring
|
|
106
|
+
* on the do_block makes the query semantically tighter.
|
|
107
|
+
*/
|
|
108
|
+
const SCOPE_PATH_QUERY = `
|
|
109
|
+
(call
|
|
110
|
+
(identifier) @_method (#eq? @_method "scope")
|
|
111
|
+
(arguments
|
|
112
|
+
.
|
|
113
|
+
(string) @scope_path)
|
|
114
|
+
(do_block))
|
|
115
|
+
`;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Router module definition: `defmodule MyAppWeb.Router do …`. We restrict
|
|
119
|
+
* to module names ending in `Router` to avoid capturing every `defmodule`
|
|
120
|
+
* in the project (Phoenix apps have many modules, only one is THE router).
|
|
121
|
+
*
|
|
122
|
+
* The tree-sitter-elixir grammar represents `MyAppWeb.Router` as a single
|
|
123
|
+
* `(alias)` node — the dotted chain is part of the alias node's text, not
|
|
124
|
+
* a sub-tree of nested aliases. Verified via AST probe 2026-05-07.
|
|
125
|
+
*/
|
|
126
|
+
const ROUTER_MODULE_QUERY = `
|
|
127
|
+
(call
|
|
128
|
+
(identifier) @_method (#eq? @_method "defmodule")
|
|
129
|
+
(arguments
|
|
130
|
+
.
|
|
131
|
+
(alias) @module_name (#match? @module_name "Router$"))
|
|
132
|
+
(do_block))
|
|
133
|
+
`;
|
|
134
|
+
|
|
135
|
+
// ============================================================
|
|
136
|
+
// Adapter
|
|
137
|
+
// ============================================================
|
|
138
|
+
|
|
139
|
+
export const phoenixAdapter: CodebaseAdapter = {
|
|
140
|
+
id: 'phoenix',
|
|
141
|
+
languages: ['elixir'],
|
|
142
|
+
|
|
143
|
+
matches(signals: DetectionSignals): boolean {
|
|
144
|
+
// Cheap signal-only check. No file IO. The canonical Phoenix
|
|
145
|
+
// declaration in mix.exs is `{:phoenix, "~> 1.7.10"}` (per Phoenix
|
|
146
|
+
// install guide: https://hexdocs.pm/phoenix/installation.html). The
|
|
147
|
+
// negative-lookahead `(?!_)` after `:phoenix\b` rejects
|
|
148
|
+
// `:phoenix_live_view` (a sibling dep that Phoenix-LiveView-only
|
|
149
|
+
// projects pull in without Phoenix itself in some Plug-based stacks).
|
|
150
|
+
if (!signals.mixExs) return false;
|
|
151
|
+
return /\{\s*:phoenix\b(?!_)/.test(signals.mixExs);
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
async introspect(files: SourceFile[], _rootDir: string): Promise<AdapterResult> {
|
|
155
|
+
if (files.length === 0) {
|
|
156
|
+
return { conventions: {}, provenance: [], confidence: 'none' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let language;
|
|
160
|
+
try {
|
|
161
|
+
language = await loadGrammar('elixir');
|
|
162
|
+
} catch (e) {
|
|
163
|
+
// Grammar unavailable → adapter returns 'none' so regex fallback takes over.
|
|
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 scopePaths = new Map<string, { line: number; file: string }>();
|
|
172
|
+
const routerModules = new Map<string, { line: number; file: string }>();
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
for (const file of files) {
|
|
176
|
+
// Phase 3.5 defense-in-depth size + depth gate at adapter tier.
|
|
177
|
+
const skip = isParsableSource(file.content, file.size);
|
|
178
|
+
if (skip) {
|
|
179
|
+
process.stderr.write(
|
|
180
|
+
`[massu/ast] WARN: phoenix skipping ${file.path}: ${skip.reason} (${skip.detail}). Cap=${MAX_AST_FILE_BYTES}. (Phase 3.5 mitigation)\n`,
|
|
181
|
+
);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
for (const hit of runQuery(parser, file.content, ROUTE_METHOD_QUERY, 'phoenix-route-method', file.path)) {
|
|
186
|
+
const method = hit.captures.method;
|
|
187
|
+
if (method && !routeMethods.has(method)) {
|
|
188
|
+
routeMethods.set(method, { line: hit.line, file: file.path });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
for (const hit of runQuery(parser, file.content, SCOPE_PATH_QUERY, 'phoenix-scope-path', file.path)) {
|
|
192
|
+
const raw = hit.captures.scope_path;
|
|
193
|
+
if (!raw) continue;
|
|
194
|
+
const literal = raw.replace(/^["']/, '').replace(/["']$/, '');
|
|
195
|
+
const base = extractPrefixBase(literal);
|
|
196
|
+
if (base && !scopePaths.has(base)) {
|
|
197
|
+
scopePaths.set(base, { line: hit.line, file: file.path });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
for (const hit of runQuery(parser, file.content, ROUTER_MODULE_QUERY, 'phoenix-router-module', file.path)) {
|
|
201
|
+
const name = hit.captures.module_name;
|
|
202
|
+
if (name && !routerModules.has(name)) {
|
|
203
|
+
routerModules.set(name, { line: hit.line, file: file.path });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
} catch (e) {
|
|
207
|
+
if (e instanceof InvalidQueryError) {
|
|
208
|
+
// Compile-time failure of OUR query is a developer bug — surface it.
|
|
209
|
+
throw e;
|
|
210
|
+
}
|
|
211
|
+
// Per-file parse error: skip + keep going.
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} finally {
|
|
216
|
+
try { parser.delete(); } catch { /* ignore */ }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const conventions: Record<string, unknown> = {};
|
|
220
|
+
const provenance: Provenance[] = [];
|
|
221
|
+
|
|
222
|
+
if (routeMethods.size === 1) {
|
|
223
|
+
const [name, { line, file }] = routeMethods.entries().next().value as [string, { line: number; file: string }];
|
|
224
|
+
conventions.route_method = name;
|
|
225
|
+
provenance.push({ field: 'route_method', sourceFile: file, line, query: 'phoenix-route-method' });
|
|
226
|
+
} else if (routeMethods.size >= 2) {
|
|
227
|
+
// Mixed convention — emit first-seen for visibility.
|
|
228
|
+
const [name, { line, file }] = routeMethods.entries().next().value as [string, { line: number; file: string }];
|
|
229
|
+
conventions.route_method = name;
|
|
230
|
+
provenance.push({ field: 'route_method', sourceFile: file, line, query: 'phoenix-route-method' });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (scopePaths.size >= 1) {
|
|
234
|
+
const [base, { line, file }] = scopePaths.entries().next().value as [string, { line: number; file: string }];
|
|
235
|
+
conventions.scope_prefix_base = base;
|
|
236
|
+
provenance.push({ field: 'scope_prefix_base', sourceFile: file, line, query: 'phoenix-scope-path' });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (routerModules.size >= 1) {
|
|
240
|
+
const [name, { line, file }] = routerModules.entries().next().value as [string, { line: number; file: string }];
|
|
241
|
+
conventions.router_module = name;
|
|
242
|
+
provenance.push({ field: 'router_module', sourceFile: file, line, query: 'phoenix-router-module' });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let confidence: AdapterResult['confidence'];
|
|
246
|
+
if (Object.keys(conventions).length === 0) {
|
|
247
|
+
confidence = 'none';
|
|
248
|
+
} else if (routeMethods.size === 1) {
|
|
249
|
+
confidence = 'high';
|
|
250
|
+
} else if (routeMethods.size >= 2) {
|
|
251
|
+
confidence = 'low';
|
|
252
|
+
} else {
|
|
253
|
+
confidence = 'medium';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { conventions, provenance, confidence };
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// ============================================================
|
|
261
|
+
// Helpers
|
|
262
|
+
// ============================================================
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Extract the first path segment of a Phoenix scope path. Mirrors
|
|
266
|
+
* python-fastapi/python-flask/go-chi/rails prefix-base extractors.
|
|
267
|
+
* Returns null if input doesn't start with `/`.
|
|
268
|
+
*
|
|
269
|
+
* "/api/v1/users" → "/api"; "/" → null (root scope is uninformative).
|
|
270
|
+
*/
|
|
271
|
+
function extractPrefixBase(prefix: string): string | null {
|
|
272
|
+
if (!prefix.startsWith('/')) return null;
|
|
273
|
+
const stripped = prefix.replace(/^\/+/, '');
|
|
274
|
+
const firstSeg = stripped.split('/')[0];
|
|
275
|
+
if (!firstSeg) return null;
|
|
276
|
+
return '/' + firstSeg;
|
|
277
|
+
}
|