@massu/core 1.4.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +9431 -5167
- package/dist/hooks/auto-learning-pipeline.js +18 -0
- package/dist/hooks/classify-failure.js +18 -0
- package/dist/hooks/cost-tracker.js +18 -0
- package/dist/hooks/fix-detector.js +18 -0
- package/dist/hooks/incident-pipeline.js +18 -0
- package/dist/hooks/post-edit-context.js +18 -0
- package/dist/hooks/post-tool-use.js +18 -0
- package/dist/hooks/pre-compact.js +18 -0
- package/dist/hooks/pre-delete-check.js +18 -0
- package/dist/hooks/quality-event.js +18 -0
- package/dist/hooks/rule-enforcement-pipeline.js +18 -0
- package/dist/hooks/session-end.js +18 -0
- package/dist/hooks/session-start.js +2952 -2740
- package/dist/hooks/user-prompt.js +18 -0
- package/docs/AUTHORING-ADAPTERS.md +207 -0
- package/docs/SECURITY.md +250 -0
- package/package.json +7 -3
- package/src/adapter.ts +90 -0
- package/src/cli.ts +7 -0
- package/src/commands/adapters.ts +824 -0
- package/src/commands/config-check-drift.ts +1 -0
- package/src/commands/config-refresh.ts +1 -0
- package/src/commands/config-upgrade.ts +1 -0
- package/src/commands/doctor.ts +2 -0
- package/src/commands/init.ts +151 -2
- package/src/config.ts +63 -0
- package/src/detect/adapters/aspnet.ts +293 -0
- package/src/detect/adapters/discover.ts +469 -0
- package/src/detect/adapters/go-chi.ts +261 -0
- package/src/detect/adapters/index.ts +49 -0
- package/src/detect/adapters/phoenix.ts +277 -0
- package/src/detect/adapters/python-flask.ts +235 -0
- package/src/detect/adapters/rails.ts +279 -0
- package/src/detect/adapters/runner.ts +32 -0
- package/src/detect/adapters/spring.ts +284 -0
- package/src/detect/adapters/tree-sitter-loader.ts +50 -0
- package/src/detect/adapters/types.ts +18 -0
- package/src/detect/framework-detector.ts +26 -0
- package/src/detect/manifest-registry.ts +261 -0
- package/src/detect/monorepo-detector.ts +1 -0
- package/src/detect/package-detector.ts +162 -62
- package/src/detect/source-dir-detector.ts +7 -0
- package/src/hooks/post-tool-use.ts +1 -0
- package/src/hooks/session-start.ts +1 -0
- package/src/lib/fileLock.ts +203 -0
- package/src/lib/installLock.ts +31 -144
- package/src/memory-file-ingest.ts +1 -0
- package/src/security/adapter-origin.ts +130 -0
- package/src/security/adapter-verifier.ts +319 -0
- package/src/security/atomic-write.ts +164 -0
- package/src/security/fetcher.ts +200 -0
- package/src/security/install-tracking.ts +319 -0
- package/src/security/local-fingerprint.ts +225 -0
- package/src/security/manifest-cache.ts +333 -0
- package/src/security/manifest-schema.ts +129 -0
- package/src/security/registry-pubkey.generated.ts +35 -0
- package/src/security/telemetry.ts +320 -0
- package/templates/aspnet/massu.config.yaml +61 -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,235 @@
|
|
|
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: Flask AST adapter.
|
|
6
|
+
*
|
|
7
|
+
* First Phase 7 framework after Plan 3b's 4 (FastAPI/Django/tRPC/SwiftUI).
|
|
8
|
+
* Establishes the per-framework deliverable pattern (4 artifacts) all
|
|
9
|
+
* remaining Phase 7 frameworks follow:
|
|
10
|
+
* 1. packages/core/templates/python-flask/massu.config.yaml (variant template)
|
|
11
|
+
* 2. packages/core/src/detect/adapters/python-flask.ts (this file — AST adapter)
|
|
12
|
+
* 3. Adversarial fixtures (inline in the test file via mkdirSync+writeFileSync,
|
|
13
|
+
* following the python-fastapi pattern in ast-adapters-adversarial.test.ts)
|
|
14
|
+
* 4. packages/core/src/__tests__/python-flask.test.ts (golden-output snapshot)
|
|
15
|
+
*
|
|
16
|
+
* Extracts:
|
|
17
|
+
* - auth_decorator: name of the auth-gating decorator (`@login_required`,
|
|
18
|
+
* `@auth_required`, or other Flask-Login-style decorator on a view).
|
|
19
|
+
* - blueprint_url_prefix: first path segment of `Blueprint(name, __name__,
|
|
20
|
+
* url_prefix="/api/...")`, mirroring the python-fastapi APIRouter prefix
|
|
21
|
+
* extraction.
|
|
22
|
+
* - app_factory: name of the Flask app-factory function (`def create_app():`
|
|
23
|
+
* or similar). Useful for scaffold-router templates that need to know
|
|
24
|
+
* where to register a new Blueprint.
|
|
25
|
+
*
|
|
26
|
+
* Confidence rules (mirror python-fastapi):
|
|
27
|
+
* - 'high' if exactly ONE auth_decorator is found (clear single convention).
|
|
28
|
+
* - 'medium' if blueprint_url_prefix or app_factory found but no auth decorator.
|
|
29
|
+
* - 'low' if multiple distinct auth_decorators are found (ambiguous).
|
|
30
|
+
* - 'none' if no Flask signals at all (regex fallback takes over).
|
|
31
|
+
*
|
|
32
|
+
* Does NOT use regex on file content — only Tree-sitter S-expression queries
|
|
33
|
+
* compiled via query-helpers.ts. Regex would be the regex-fallback path.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { Parser } from 'web-tree-sitter';
|
|
37
|
+
import type { CodebaseAdapter, AdapterResult, DetectionSignals, Provenance, SourceFile } from './types.ts';
|
|
38
|
+
import { runQuery, InvalidQueryError } from './query-helpers.ts';
|
|
39
|
+
import { loadGrammar } from './tree-sitter-loader.ts';
|
|
40
|
+
import { isParsableSource, MAX_AST_FILE_BYTES } from './parse-guard.ts';
|
|
41
|
+
|
|
42
|
+
// ============================================================
|
|
43
|
+
// Tree-sitter S-expression queries
|
|
44
|
+
// ============================================================
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Auth decorator: matches `@login_required`, `@auth_required`,
|
|
48
|
+
* `@some_auth_required`, etc. on a view function.
|
|
49
|
+
*
|
|
50
|
+
* The Tree-sitter Python grammar represents `@some_name` decorators as
|
|
51
|
+
* a `decorator` node containing an `identifier` (for bare names) OR an
|
|
52
|
+
* `attribute` (for `@module.name`). We capture both shapes and filter to
|
|
53
|
+
* names ending in `_required` since that's the Flask-Login convention
|
|
54
|
+
* and avoids matching unrelated decorators like `@app.route(...)`.
|
|
55
|
+
*/
|
|
56
|
+
const AUTH_DECORATOR_QUERY = `
|
|
57
|
+
(decorator
|
|
58
|
+
(identifier) @auth_decorator (#match? @auth_decorator "_required$"))
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Blueprint URL prefix: `Blueprint(name, __name__, url_prefix="/api/orders")`.
|
|
63
|
+
* Captures the keyword-arg string literal so the runner can split off the
|
|
64
|
+
* base segment.
|
|
65
|
+
*/
|
|
66
|
+
const BLUEPRINT_URL_PREFIX_QUERY = `
|
|
67
|
+
(call
|
|
68
|
+
function: (identifier) @_callee (#eq? @_callee "Blueprint")
|
|
69
|
+
arguments: (argument_list
|
|
70
|
+
(keyword_argument
|
|
71
|
+
name: (identifier) @_kw (#eq? @_kw "url_prefix")
|
|
72
|
+
value: (string) @url_prefix)))
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* App factory: `def create_app():` (or any function whose name starts with
|
|
77
|
+
* `create_` and contains `Flask`). The factory pattern is canonical in Flask
|
|
78
|
+
* (per Flask docs: https://flask.palletsprojects.com/en/2.3.x/patterns/appfactories/).
|
|
79
|
+
* We capture the function name + assert its body contains `Flask(`.
|
|
80
|
+
*
|
|
81
|
+
* The Tree-sitter Python grammar represents `def name():` as a
|
|
82
|
+
* `function_definition` node with `name: (identifier)` field.
|
|
83
|
+
*/
|
|
84
|
+
const APP_FACTORY_QUERY = `
|
|
85
|
+
(function_definition
|
|
86
|
+
name: (identifier) @factory_name (#match? @factory_name "^create_")
|
|
87
|
+
body: (block
|
|
88
|
+
(expression_statement
|
|
89
|
+
(assignment
|
|
90
|
+
right: (call
|
|
91
|
+
function: (identifier) @_flask_call (#eq? @_flask_call "Flask"))))))
|
|
92
|
+
`;
|
|
93
|
+
|
|
94
|
+
// ============================================================
|
|
95
|
+
// Adapter
|
|
96
|
+
// ============================================================
|
|
97
|
+
|
|
98
|
+
export const pythonFlaskAdapter: CodebaseAdapter = {
|
|
99
|
+
id: 'python-flask',
|
|
100
|
+
languages: ['python'],
|
|
101
|
+
|
|
102
|
+
matches(signals: DetectionSignals): boolean {
|
|
103
|
+
// Cheap signal-only check. No file IO. Match if:
|
|
104
|
+
// 1. pyproject.toml mentions flask (raw text contains 'flask'), OR
|
|
105
|
+
// 2. project has app/ + python files at top level (Flask convention)
|
|
106
|
+
const pyToml = signals.pyprojectToml as { __raw?: string } | undefined;
|
|
107
|
+
if (pyToml?.__raw && /\bflask\b/i.test(pyToml.__raw)) return true;
|
|
108
|
+
if (signals.presentDirs.has('app') && signals.presentFiles.has('app.py')) return true;
|
|
109
|
+
if (signals.presentDirs.has('app') && signals.presentFiles.has('wsgi.py')) return true;
|
|
110
|
+
return false;
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
async introspect(files: SourceFile[], _rootDir: string): Promise<AdapterResult> {
|
|
114
|
+
if (files.length === 0) {
|
|
115
|
+
return { conventions: {}, provenance: [], confidence: 'none' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let language;
|
|
119
|
+
try {
|
|
120
|
+
language = await loadGrammar('python');
|
|
121
|
+
} catch (e) {
|
|
122
|
+
// Grammar unavailable → adapter returns 'none' so regex fallback takes over.
|
|
123
|
+
return { conventions: {}, provenance: [], confidence: 'none' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const parser = new Parser();
|
|
127
|
+
parser.setLanguage(language);
|
|
128
|
+
|
|
129
|
+
const authDecorators = new Map<string, { line: number; file: string }>();
|
|
130
|
+
const urlPrefixes = new Map<string, { line: number; file: string }>();
|
|
131
|
+
const appFactories = new Map<string, { line: number; file: string }>();
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
for (const file of files) {
|
|
135
|
+
// Phase 3.5 defense-in-depth size + depth gate at adapter tier.
|
|
136
|
+
const skip = isParsableSource(file.content, file.size);
|
|
137
|
+
if (skip) {
|
|
138
|
+
process.stderr.write(
|
|
139
|
+
`[massu/ast] WARN: python-flask skipping ${file.path}: ${skip.reason} (${skip.detail}). Cap=${MAX_AST_FILE_BYTES}. (Phase 3.5 mitigation)\n`,
|
|
140
|
+
);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
for (const hit of runQuery(parser, file.content, AUTH_DECORATOR_QUERY, 'flask-auth-decorator', file.path)) {
|
|
145
|
+
const name = hit.captures.auth_decorator;
|
|
146
|
+
if (name && !authDecorators.has(name)) {
|
|
147
|
+
authDecorators.set(name, { line: hit.line, file: file.path });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
for (const hit of runQuery(parser, file.content, BLUEPRINT_URL_PREFIX_QUERY, 'flask-blueprint-url-prefix', file.path)) {
|
|
151
|
+
const raw = hit.captures.url_prefix;
|
|
152
|
+
if (!raw) continue;
|
|
153
|
+
const literal = raw.replace(/^['"]/, '').replace(/['"]$/, '');
|
|
154
|
+
const base = extractPrefixBase(literal);
|
|
155
|
+
if (base && !urlPrefixes.has(base)) {
|
|
156
|
+
urlPrefixes.set(base, { line: hit.line, file: file.path });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const hit of runQuery(parser, file.content, APP_FACTORY_QUERY, 'flask-app-factory', file.path)) {
|
|
160
|
+
const name = hit.captures.factory_name;
|
|
161
|
+
if (name && !appFactories.has(name)) {
|
|
162
|
+
appFactories.set(name, { line: hit.line, file: file.path });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch (e) {
|
|
166
|
+
if (e instanceof InvalidQueryError) {
|
|
167
|
+
// Compile-time failure of OUR query is a developer bug — surface it.
|
|
168
|
+
throw e;
|
|
169
|
+
}
|
|
170
|
+
// Per-file parse error: skip + keep going.
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} finally {
|
|
175
|
+
try { parser.delete(); } catch { /* ignore */ }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Build result
|
|
179
|
+
const conventions: Record<string, unknown> = {};
|
|
180
|
+
const provenance: Provenance[] = [];
|
|
181
|
+
|
|
182
|
+
if (authDecorators.size === 1) {
|
|
183
|
+
const [name, { line, file }] = authDecorators.entries().next().value as [string, { line: number; file: string }];
|
|
184
|
+
conventions.auth_decorator = name;
|
|
185
|
+
provenance.push({ field: 'auth_decorator', sourceFile: file, line, query: 'flask-auth-decorator' });
|
|
186
|
+
} else if (authDecorators.size >= 2) {
|
|
187
|
+
// Ambiguous — prefer first-seen (stable order from input file list).
|
|
188
|
+
const [name, { line, file }] = authDecorators.entries().next().value as [string, { line: number; file: string }];
|
|
189
|
+
conventions.auth_decorator = name;
|
|
190
|
+
provenance.push({ field: 'auth_decorator', sourceFile: file, line, query: 'flask-auth-decorator' });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (urlPrefixes.size >= 1) {
|
|
194
|
+
const [base, { line, file }] = urlPrefixes.entries().next().value as [string, { line: number; file: string }];
|
|
195
|
+
conventions.blueprint_url_prefix = base;
|
|
196
|
+
provenance.push({ field: 'blueprint_url_prefix', sourceFile: file, line, query: 'flask-blueprint-url-prefix' });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (appFactories.size >= 1) {
|
|
200
|
+
const [name, { line, file }] = appFactories.entries().next().value as [string, { line: number; file: string }];
|
|
201
|
+
conventions.app_factory = name;
|
|
202
|
+
provenance.push({ field: 'app_factory', sourceFile: file, line, query: 'flask-app-factory' });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let confidence: AdapterResult['confidence'];
|
|
206
|
+
if (Object.keys(conventions).length === 0) {
|
|
207
|
+
confidence = 'none';
|
|
208
|
+
} else if (authDecorators.size === 1) {
|
|
209
|
+
confidence = 'high';
|
|
210
|
+
} else if (authDecorators.size >= 2) {
|
|
211
|
+
confidence = 'low';
|
|
212
|
+
} else {
|
|
213
|
+
confidence = 'medium';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { conventions, provenance, confidence };
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// ============================================================
|
|
221
|
+
// Helpers
|
|
222
|
+
// ============================================================
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Extract the first path segment of a Blueprint url_prefix. Mirrors
|
|
226
|
+
* python-fastapi's extractPrefixBase. Returns null if input doesn't
|
|
227
|
+
* start with `/`.
|
|
228
|
+
*/
|
|
229
|
+
function extractPrefixBase(prefix: string): string | null {
|
|
230
|
+
if (!prefix.startsWith('/')) return null;
|
|
231
|
+
const stripped = prefix.replace(/^\/+/, '');
|
|
232
|
+
const firstSeg = stripped.split('/')[0];
|
|
233
|
+
if (!firstSeg) return null;
|
|
234
|
+
return '/' + firstSeg;
|
|
235
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
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: Rails AST adapter.
|
|
6
|
+
*
|
|
7
|
+
* Second Phase 7 framework after go-chi; first to consume the new `ruby`
|
|
8
|
+
* Tree-sitter grammar entry from GRAMMAR_MANIFEST (commit fbb8aa9). Together
|
|
9
|
+
* with the @massu/adapter-rails workspace stub created in Stage 2 P1-004
|
|
10
|
+
* (commit ebf2983), this completes the per-framework deliverable pattern
|
|
11
|
+
* established by Phase 7 commits 1–3:
|
|
12
|
+
* 1. packages/core/templates/rails/massu.config.yaml (variant template)
|
|
13
|
+
* 2. packages/core/src/detect/adapters/rails.ts (this file — AST adapter)
|
|
14
|
+
* 3. Adversarial fixtures (inline in the test file via mkdirSync+writeFileSync)
|
|
15
|
+
* 4. packages/core/src/__tests__/rails.test.ts (golden-output test)
|
|
16
|
+
*
|
|
17
|
+
* Rails uses a DSL-heavy `config/routes.rb` (per Rails routing guide:
|
|
18
|
+
* https://guides.rubyonrails.org/routing.html). The adapter walks the
|
|
19
|
+
* routes.rb DSL invocations directly rather than scanning controller
|
|
20
|
+
* directories, mirroring the go-chi approach against router.go files.
|
|
21
|
+
*
|
|
22
|
+
* Extracts:
|
|
23
|
+
* - route_method: most-common explicit HTTP verb (`get`, `post`, `put`,
|
|
24
|
+
* `patch`, `delete`, `head`, `options`) used at TOP LEVEL with a
|
|
25
|
+
* string-literal path argument. Mirrors python-fastapi/python-flask/
|
|
26
|
+
* go-chi route_method semantics. Distinct from `resources :users`
|
|
27
|
+
* RESTful blocks — those imply all seven verbs in one declaration and
|
|
28
|
+
* should NOT pin the project to one verb. The string-literal anchor
|
|
29
|
+
* also excludes member/collection block calls like `get :preview`
|
|
30
|
+
* (where the arg is a symbol, not a string).
|
|
31
|
+
* - api_namespace: first segment of the first `namespace :foo do …` or
|
|
32
|
+
* `namespace 'foo' do …` block, normalized to a leading-slash path
|
|
33
|
+
* (mirrors python-fastapi/flask's blueprint_url_prefix and go-chi's
|
|
34
|
+
* mount_prefix_base). Per Rails routing guide §3:
|
|
35
|
+
* https://guides.rubyonrails.org/routing.html#controller-namespaces-and-routing
|
|
36
|
+
* - root_controller: controller name from `root 'pages#home'` or
|
|
37
|
+
* `root to: 'pages#home'`. The `Foo#bar` syntax canonically denotes
|
|
38
|
+
* `FooController#bar`. Useful for scaffold-router/scaffold-page
|
|
39
|
+
* templates that need to know the project's home route convention.
|
|
40
|
+
*
|
|
41
|
+
* Confidence rules (mirror go-chi):
|
|
42
|
+
* - 'high' if exactly ONE distinct route_method seen (clear convention).
|
|
43
|
+
* - 'low' if multiple distinct route_methods seen (mixed convention).
|
|
44
|
+
* - 'medium' if api_namespace or root_controller found but no route_method.
|
|
45
|
+
* - 'none' if no Rails DSL signals at all (regex fallback takes over).
|
|
46
|
+
*
|
|
47
|
+
* Does NOT use regex on file content — only Tree-sitter S-expression queries
|
|
48
|
+
* compiled via query-helpers.ts. Regex would be the regex-fallback path.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { Parser } from 'web-tree-sitter';
|
|
52
|
+
import type { CodebaseAdapter, AdapterResult, DetectionSignals, Provenance, SourceFile } from './types.ts';
|
|
53
|
+
import { runQuery, InvalidQueryError } from './query-helpers.ts';
|
|
54
|
+
import { loadGrammar } from './tree-sitter-loader.ts';
|
|
55
|
+
import { isParsableSource, MAX_AST_FILE_BYTES } from './parse-guard.ts';
|
|
56
|
+
|
|
57
|
+
// ============================================================
|
|
58
|
+
// Tree-sitter S-expression queries (Ruby grammar)
|
|
59
|
+
// ============================================================
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* HTTP method route registration with a STRING-LITERAL first argument:
|
|
63
|
+
* `get '/health', to: 'health#show'`, `post "/login", to: 'sessions#create'`.
|
|
64
|
+
*
|
|
65
|
+
* Anchored (`.`) on the first argument so that we ONLY match string-literal
|
|
66
|
+
* paths — symbol arguments (`get :preview` inside a member block) are
|
|
67
|
+
* deliberately excluded because those are nested action declarations, NOT
|
|
68
|
+
* top-level routes.
|
|
69
|
+
*
|
|
70
|
+
* tree-sitter-ruby (v0.20.1, pinned by tree-sitter-wasms@0.1.13) emits
|
|
71
|
+
* `(call method: (identifier) arguments: (argument_list ...))` for the
|
|
72
|
+
* parens-less DSL form `get '/x', ...` AND for the parens-ful `get('/x', ...)`
|
|
73
|
+
* form. Verified by AST probe (2026-05-07): there is NO separate `method_call`
|
|
74
|
+
* node in this grammar — historical web sources mentioning `method_call`
|
|
75
|
+
* refer to a different/older grammar fork.
|
|
76
|
+
*/
|
|
77
|
+
const ROUTE_METHOD_QUERY = `
|
|
78
|
+
(call
|
|
79
|
+
method: (identifier) @method (#match? @method "^(get|post|put|patch|delete|options|head)$")
|
|
80
|
+
arguments: (argument_list
|
|
81
|
+
.
|
|
82
|
+
(string) @route_path))
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Namespace block: `namespace :api do ... end` or `namespace 'api' do ... end`.
|
|
87
|
+
* Captures the first symbol-or-string argument so we can normalize to a
|
|
88
|
+
* leading-slash path.
|
|
89
|
+
*
|
|
90
|
+
* Rails accepts both `namespace :api` (symbol — canonical) and the rarer
|
|
91
|
+
* `namespace 'api'` (string). Both forms produce a `namespace` identifier
|
|
92
|
+
* call; the first arg differs only in node type.
|
|
93
|
+
*/
|
|
94
|
+
const NAMESPACE_QUERY = `
|
|
95
|
+
(call
|
|
96
|
+
method: (identifier) @_method (#eq? @_method "namespace")
|
|
97
|
+
arguments: (argument_list
|
|
98
|
+
.
|
|
99
|
+
[
|
|
100
|
+
(simple_symbol) @namespace_symbol
|
|
101
|
+
(string) @namespace_string
|
|
102
|
+
]))
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Root route: `root 'pages#home'`, `root "pages#home"`, or
|
|
107
|
+
* `root to: 'pages#home'`. We capture the string literal in either the
|
|
108
|
+
* positional or `to:` keyword position; the controller is whatever
|
|
109
|
+
* precedes the `#`.
|
|
110
|
+
*
|
|
111
|
+
* Rails routing guide §2.6:
|
|
112
|
+
* https://guides.rubyonrails.org/routing.html#using-root
|
|
113
|
+
*/
|
|
114
|
+
const ROOT_ROUTE_QUERY = `
|
|
115
|
+
(call
|
|
116
|
+
method: (identifier) @_method (#eq? @_method "root")
|
|
117
|
+
arguments: (argument_list
|
|
118
|
+
.
|
|
119
|
+
(string) @root_target))
|
|
120
|
+
|
|
121
|
+
(call
|
|
122
|
+
method: (identifier) @_method (#eq? @_method "root")
|
|
123
|
+
arguments: (argument_list
|
|
124
|
+
(pair
|
|
125
|
+
key: (hash_key_symbol) @_key (#eq? @_key "to")
|
|
126
|
+
value: (string) @root_target)))
|
|
127
|
+
`;
|
|
128
|
+
|
|
129
|
+
// ============================================================
|
|
130
|
+
// Adapter
|
|
131
|
+
// ============================================================
|
|
132
|
+
|
|
133
|
+
export const railsAdapter: CodebaseAdapter = {
|
|
134
|
+
id: 'rails',
|
|
135
|
+
languages: ['ruby'],
|
|
136
|
+
|
|
137
|
+
matches(signals: DetectionSignals): boolean {
|
|
138
|
+
// Cheap signal-only check. No file IO. The canonical Rails declaration
|
|
139
|
+
// is `gem 'rails'` in Gemfile (per Rails install guide:
|
|
140
|
+
// https://guides.rubyonrails.org/getting_started.html). The strict
|
|
141
|
+
// `gem ['"]rails['"]` regex deliberately rejects `gem 'rails-api'`,
|
|
142
|
+
// `gem 'rails_admin'`, etc. — those are Rails-adjacent gems that may
|
|
143
|
+
// appear in non-Rails Ruby projects.
|
|
144
|
+
if (!signals.gemfile) return false;
|
|
145
|
+
return /^\s*gem\s+['"]rails['"]/im.test(signals.gemfile);
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
async introspect(files: SourceFile[], _rootDir: string): Promise<AdapterResult> {
|
|
149
|
+
if (files.length === 0) {
|
|
150
|
+
return { conventions: {}, provenance: [], confidence: 'none' };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let language;
|
|
154
|
+
try {
|
|
155
|
+
language = await loadGrammar('ruby');
|
|
156
|
+
} catch (e) {
|
|
157
|
+
// Grammar unavailable → adapter returns 'none' so regex fallback takes over.
|
|
158
|
+
return { conventions: {}, provenance: [], confidence: 'none' };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const parser = new Parser();
|
|
162
|
+
parser.setLanguage(language);
|
|
163
|
+
|
|
164
|
+
const routeMethods = new Map<string, { line: number; file: string }>();
|
|
165
|
+
const namespaces = new Map<string, { line: number; file: string }>();
|
|
166
|
+
const rootControllers = new Map<string, { line: number; file: string }>();
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
for (const file of files) {
|
|
170
|
+
// Phase 3.5 defense-in-depth size + depth gate at adapter tier.
|
|
171
|
+
const skip = isParsableSource(file.content, file.size);
|
|
172
|
+
if (skip) {
|
|
173
|
+
process.stderr.write(
|
|
174
|
+
`[massu/ast] WARN: rails skipping ${file.path}: ${skip.reason} (${skip.detail}). Cap=${MAX_AST_FILE_BYTES}. (Phase 3.5 mitigation)\n`,
|
|
175
|
+
);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
for (const hit of runQuery(parser, file.content, ROUTE_METHOD_QUERY, 'rails-route-method', file.path)) {
|
|
180
|
+
const method = hit.captures.method;
|
|
181
|
+
if (method && !routeMethods.has(method)) {
|
|
182
|
+
routeMethods.set(method, { line: hit.line, file: file.path });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
for (const hit of runQuery(parser, file.content, NAMESPACE_QUERY, 'rails-namespace', file.path)) {
|
|
186
|
+
const symbolRaw = hit.captures.namespace_symbol;
|
|
187
|
+
const stringRaw = hit.captures.namespace_string;
|
|
188
|
+
const name = symbolRaw
|
|
189
|
+
? symbolRaw.replace(/^:/, '')
|
|
190
|
+
: stringRaw
|
|
191
|
+
? stringRaw.replace(/^['"]/, '').replace(/['"]$/, '')
|
|
192
|
+
: null;
|
|
193
|
+
if (!name) continue;
|
|
194
|
+
const path = '/' + name;
|
|
195
|
+
if (!namespaces.has(path)) {
|
|
196
|
+
namespaces.set(path, { line: hit.line, file: file.path });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (const hit of runQuery(parser, file.content, ROOT_ROUTE_QUERY, 'rails-root', file.path)) {
|
|
200
|
+
const raw = hit.captures.root_target;
|
|
201
|
+
if (!raw) continue;
|
|
202
|
+
const literal = raw.replace(/^['"]/, '').replace(/['"]$/, '');
|
|
203
|
+
const controller = extractRootController(literal);
|
|
204
|
+
if (controller && !rootControllers.has(controller)) {
|
|
205
|
+
rootControllers.set(controller, { line: hit.line, file: file.path });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} catch (e) {
|
|
209
|
+
if (e instanceof InvalidQueryError) {
|
|
210
|
+
// Compile-time failure of OUR query is a developer bug — surface it.
|
|
211
|
+
throw e;
|
|
212
|
+
}
|
|
213
|
+
// Per-file parse error: skip + keep going.
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
} finally {
|
|
218
|
+
try { parser.delete(); } catch { /* ignore */ }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const conventions: Record<string, unknown> = {};
|
|
222
|
+
const provenance: Provenance[] = [];
|
|
223
|
+
|
|
224
|
+
if (routeMethods.size === 1) {
|
|
225
|
+
const [name, { line, file }] = routeMethods.entries().next().value as [string, { line: number; file: string }];
|
|
226
|
+
conventions.route_method = name;
|
|
227
|
+
provenance.push({ field: 'route_method', sourceFile: file, line, query: 'rails-route-method' });
|
|
228
|
+
} else if (routeMethods.size >= 2) {
|
|
229
|
+
// Mixed convention — emit first-seen for visibility.
|
|
230
|
+
const [name, { line, file }] = routeMethods.entries().next().value as [string, { line: number; file: string }];
|
|
231
|
+
conventions.route_method = name;
|
|
232
|
+
provenance.push({ field: 'route_method', sourceFile: file, line, query: 'rails-route-method' });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (namespaces.size >= 1) {
|
|
236
|
+
const [path, { line, file }] = namespaces.entries().next().value as [string, { line: number; file: string }];
|
|
237
|
+
conventions.api_namespace = path;
|
|
238
|
+
provenance.push({ field: 'api_namespace', sourceFile: file, line, query: 'rails-namespace' });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (rootControllers.size >= 1) {
|
|
242
|
+
const [name, { line, file }] = rootControllers.entries().next().value as [string, { line: number; file: string }];
|
|
243
|
+
conventions.root_controller = name;
|
|
244
|
+
provenance.push({ field: 'root_controller', sourceFile: file, line, query: 'rails-root' });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let confidence: AdapterResult['confidence'];
|
|
248
|
+
if (Object.keys(conventions).length === 0) {
|
|
249
|
+
confidence = 'none';
|
|
250
|
+
} else if (routeMethods.size === 1) {
|
|
251
|
+
confidence = 'high';
|
|
252
|
+
} else if (routeMethods.size >= 2) {
|
|
253
|
+
confidence = 'low';
|
|
254
|
+
} else {
|
|
255
|
+
confidence = 'medium';
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return { conventions, provenance, confidence };
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// ============================================================
|
|
263
|
+
// Helpers
|
|
264
|
+
// ============================================================
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Extract the controller name from a Rails `controller#action` string.
|
|
268
|
+
* `'pages#home'` → `'pages'`; `'admin/dashboard#index'` → `'admin/dashboard'`.
|
|
269
|
+
* Returns null for malformed input (no `#` separator).
|
|
270
|
+
*
|
|
271
|
+
* Per Rails routing guide §2.6: the string before `#` is the controller
|
|
272
|
+
* (with optional namespace prefix), the part after is the action.
|
|
273
|
+
*/
|
|
274
|
+
function extractRootController(target: string): string | null {
|
|
275
|
+
const idx = target.indexOf('#');
|
|
276
|
+
if (idx <= 0) return null;
|
|
277
|
+
const controller = target.slice(0, idx).trim();
|
|
278
|
+
return controller || null;
|
|
279
|
+
}
|
|
@@ -216,11 +216,43 @@ export function buildDetectionSignals(rootDir: string): DetectionSignals {
|
|
|
216
216
|
gemfile: tryReadString(join(rootDir, 'Gemfile')),
|
|
217
217
|
cargoToml: tryReadToml(join(rootDir, 'Cargo.toml')),
|
|
218
218
|
goMod: tryReadString(join(rootDir, 'go.mod')),
|
|
219
|
+
mixExs: tryReadString(join(rootDir, 'mix.exs')),
|
|
220
|
+
csproj: tryReadFirstCsproj(rootDir, presentFiles),
|
|
221
|
+
pomXml: tryReadString(join(rootDir, 'pom.xml')),
|
|
222
|
+
gradleBuild: tryReadGradleBuild(rootDir, presentFiles),
|
|
219
223
|
presentDirs,
|
|
220
224
|
presentFiles,
|
|
221
225
|
};
|
|
222
226
|
}
|
|
223
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Find the first `.csproj` file at the project root (sorted alphabetically
|
|
230
|
+
* for determinism) and return its raw XML content. Returns undefined if
|
|
231
|
+
* none exist. Multi-project solutions where the root has no top-level
|
|
232
|
+
* .csproj (the `.sln` lives at root and projects are in subdirs) get
|
|
233
|
+
* `csproj=undefined` — adapters then degrade per their own logic.
|
|
234
|
+
*/
|
|
235
|
+
function tryReadFirstCsproj(rootDir: string, presentFiles: Set<string>): string | undefined {
|
|
236
|
+
const csprojNames = [...presentFiles].filter((f) => f.endsWith('.csproj')).sort();
|
|
237
|
+
if (csprojNames.length === 0) return undefined;
|
|
238
|
+
return tryReadString(join(rootDir, csprojNames[0]!));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Read `build.gradle.kts` (Kotlin DSL — modern default per Gradle 7+) if
|
|
243
|
+
* present; otherwise fall back to `build.gradle` (legacy Groovy DSL).
|
|
244
|
+
* Returns undefined if neither exists.
|
|
245
|
+
*/
|
|
246
|
+
function tryReadGradleBuild(rootDir: string, presentFiles: Set<string>): string | undefined {
|
|
247
|
+
if (presentFiles.has('build.gradle.kts')) {
|
|
248
|
+
return tryReadString(join(rootDir, 'build.gradle.kts'));
|
|
249
|
+
}
|
|
250
|
+
if (presentFiles.has('build.gradle')) {
|
|
251
|
+
return tryReadString(join(rootDir, 'build.gradle'));
|
|
252
|
+
}
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
224
256
|
function tryReadString(path: string): string | undefined {
|
|
225
257
|
if (!existsSync(path)) return undefined;
|
|
226
258
|
try {
|