@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.
Files changed (60) hide show
  1. package/dist/cli.js +9445 -5483
  2. package/dist/hooks/auto-learning-pipeline.js +18 -0
  3. package/dist/hooks/classify-failure.js +18 -0
  4. package/dist/hooks/cost-tracker.js +18 -0
  5. package/dist/hooks/fix-detector.js +18 -0
  6. package/dist/hooks/incident-pipeline.js +18 -0
  7. package/dist/hooks/post-edit-context.js +18 -0
  8. package/dist/hooks/post-tool-use.js +18 -0
  9. package/dist/hooks/pre-compact.js +18 -0
  10. package/dist/hooks/pre-delete-check.js +18 -0
  11. package/dist/hooks/quality-event.js +18 -0
  12. package/dist/hooks/rule-enforcement-pipeline.js +18 -0
  13. package/dist/hooks/session-end.js +18 -0
  14. package/dist/hooks/session-start.js +2668 -2674
  15. package/dist/hooks/user-prompt.js +18 -0
  16. package/docs/AUTHORING-ADAPTERS.md +207 -0
  17. package/docs/SECURITY.md +250 -0
  18. package/package.json +7 -3
  19. package/src/adapter.ts +90 -0
  20. package/src/cli.ts +7 -0
  21. package/src/commands/adapters.ts +824 -0
  22. package/src/commands/config-check-drift.ts +1 -0
  23. package/src/commands/config-refresh.ts +1 -0
  24. package/src/commands/config-upgrade.ts +1 -0
  25. package/src/commands/doctor.ts +2 -0
  26. package/src/commands/init.ts +2 -0
  27. package/src/config.ts +63 -0
  28. package/src/detect/adapters/aspnet.ts +293 -0
  29. package/src/detect/adapters/discover.ts +469 -0
  30. package/src/detect/adapters/go-chi.ts +261 -0
  31. package/src/detect/adapters/index.ts +49 -0
  32. package/src/detect/adapters/phoenix.ts +277 -0
  33. package/src/detect/adapters/python-flask.ts +235 -0
  34. package/src/detect/adapters/rails.ts +279 -0
  35. package/src/detect/adapters/runner.ts +32 -0
  36. package/src/detect/adapters/spring.ts +284 -0
  37. package/src/detect/adapters/tree-sitter-loader.ts +50 -0
  38. package/src/detect/adapters/types.ts +18 -0
  39. package/src/detect/monorepo-detector.ts +1 -0
  40. package/src/hooks/post-tool-use.ts +1 -0
  41. package/src/hooks/session-start.ts +1 -0
  42. package/src/lib/fileLock.ts +203 -0
  43. package/src/lib/installLock.ts +31 -144
  44. package/src/memory-file-ingest.ts +1 -0
  45. package/src/security/adapter-origin.ts +130 -0
  46. package/src/security/adapter-verifier.ts +319 -0
  47. package/src/security/atomic-write.ts +164 -0
  48. package/src/security/fetcher.ts +200 -0
  49. package/src/security/install-tracking.ts +319 -0
  50. package/src/security/local-fingerprint.ts +225 -0
  51. package/src/security/manifest-cache.ts +333 -0
  52. package/src/security/manifest-schema.ts +129 -0
  53. package/src/security/registry-pubkey.generated.ts +35 -0
  54. package/src/security/telemetry.ts +320 -0
  55. package/templates/aspnet/massu.config.yaml +57 -0
  56. package/templates/go-chi/massu.config.yaml +52 -0
  57. package/templates/phoenix/massu.config.yaml +54 -0
  58. package/templates/python-flask/massu.config.yaml +51 -0
  59. package/templates/rails/massu.config.yaml +56 -0
  60. 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
+ }