@massu/core 1.3.0 → 1.4.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 +23 -11
- package/commands/massu-deploy.python-docker.md +170 -0
- package/commands/massu-deploy.python-fly.md +189 -0
- package/commands/massu-deploy.python-launchd.md +144 -0
- package/commands/massu-deploy.python-systemd.md +163 -0
- package/commands/massu-scaffold-page.swift.md +10 -10
- package/commands/massu-scaffold-router.python-django.md +153 -0
- package/commands/massu-scaffold-router.python-fastapi.md +145 -0
- package/dist/cli.js +9914 -4133
- package/dist/hooks/auto-learning-pipeline.js +45 -2
- package/dist/hooks/classify-failure.js +45 -2
- package/dist/hooks/cost-tracker.js +45 -2
- package/dist/hooks/fix-detector.js +45 -2
- package/dist/hooks/incident-pipeline.js +45 -2
- package/dist/hooks/post-edit-context.js +45 -2
- package/dist/hooks/post-tool-use.js +45 -2
- package/dist/hooks/pre-compact.js +45 -2
- package/dist/hooks/pre-delete-check.js +45 -2
- package/dist/hooks/quality-event.js +45 -2
- package/dist/hooks/rule-enforcement-pipeline.js +45 -2
- package/dist/hooks/session-end.js +45 -2
- package/dist/hooks/session-start.js +4790 -406
- package/dist/hooks/user-prompt.js +45 -2
- package/package.json +13 -4
- package/src/cli.ts +22 -2
- package/src/commands/config-refresh.ts +91 -23
- package/src/commands/init.ts +131 -24
- package/src/commands/install-commands.ts +142 -26
- package/src/commands/refresh-log.ts +37 -0
- package/src/commands/template-engine.ts +260 -0
- package/src/commands/watch.ts +430 -0
- package/src/config.ts +71 -0
- package/src/detect/adapters/nextjs-trpc.ts +166 -0
- package/src/detect/adapters/parse-guard.ts +133 -0
- package/src/detect/adapters/python-django.ts +208 -0
- package/src/detect/adapters/python-fastapi.ts +223 -0
- package/src/detect/adapters/query-helpers.ts +170 -0
- package/src/detect/adapters/runner.ts +252 -0
- package/src/detect/adapters/swift-swiftui.ts +171 -0
- package/src/detect/adapters/tree-sitter-loader.ts +467 -0
- package/src/detect/adapters/types.ts +173 -0
- package/src/detect/codebase-introspector.ts +190 -0
- package/src/detect/index.ts +28 -2
- package/src/detect/migrate.ts +4 -4
- package/src/detect/regex-fallback.ts +449 -0
- package/src/hooks/session-start.ts +94 -3
- package/src/lib/gitToplevel.ts +22 -0
- package/src/lib/installLock.ts +179 -0
- package/src/lib/pidLiveness.ts +67 -0
- package/src/lsp/auto-detect.ts +98 -0
- package/src/lsp/client.ts +776 -0
- package/src/lsp/enrich.ts +127 -0
- package/src/lsp/types.ts +221 -0
- package/src/watch/daemon.ts +385 -0
- package/src/watch/lockfile-detector.ts +65 -0
- package/src/watch/paths.ts +279 -0
- package/src/watch/state.ts +178 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Plan 3b — Phase 3.5 (security audit): centralized AST parse-time safety.
|
|
6
|
+
*
|
|
7
|
+
* Adapters consume oversized / pathological user source. Tree-sitter is
|
|
8
|
+
* fast but synchronous — there is no native timeout, no native size cap.
|
|
9
|
+
* This module provides:
|
|
10
|
+
*
|
|
11
|
+
* 1. `MAX_AST_FILE_BYTES` — 1MB hard cap per file (covers DoS vector
|
|
12
|
+
* "oversized file"). Plan §1 cited 5MB; we apply a tighter bound at
|
|
13
|
+
* the adapter tier because adapter sample size is small (≤3 files
|
|
14
|
+
* per adapter) and 1MB is already an order of magnitude beyond
|
|
15
|
+
* reasonable convention-defining files.
|
|
16
|
+
* 2. `MAX_AST_PARSE_DEPTH` — 5K-deep nested-structure rejection — a
|
|
17
|
+
* static text scan for runaway open-paren / open-brace runs that
|
|
18
|
+
* would push Tree-sitter into deep recursion. Cheap pre-check.
|
|
19
|
+
* 3. `parseTimeout(ms, fn)` — wraps a synchronous Tree-sitter call in
|
|
20
|
+
* a deadline guard. Tree-sitter's pure-JS path can't be interrupted
|
|
21
|
+
* mid-parse, but we record the elapsed time AFTER the call returns
|
|
22
|
+
* and emit a stderr warning when budget is exceeded so daemon ops
|
|
23
|
+
* see the abuse signal.
|
|
24
|
+
* 4. `isParsableSource(source)` — static gate combining size + depth.
|
|
25
|
+
* Returns `null` if accepted, or a `{ reason, detail }` object when
|
|
26
|
+
* rejected so the adapter can record provenance and skip the file.
|
|
27
|
+
*
|
|
28
|
+
* Library purity: never terminates the process, no DB calls, no network.
|
|
29
|
+
* Pure helper module.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** Hard size cap for an individual file fed to Tree-sitter. */
|
|
33
|
+
export const MAX_AST_FILE_BYTES = 1 * 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Maximum nested-bracket depth allowed in a file. Pathological inputs
|
|
37
|
+
* with 10K-deep nesting can push Tree-sitter into runaway recursion on
|
|
38
|
+
* some grammar versions. Cheap O(n) text scan picks them off before parse.
|
|
39
|
+
*/
|
|
40
|
+
export const MAX_AST_PARSE_DEPTH = 5000;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Per-file Tree-sitter parse budget (ms). Tree-sitter parses are
|
|
44
|
+
* synchronous in JS, so this is enforced as a post-call elapsed-time
|
|
45
|
+
* check rather than a hard timer. The check still serves the purpose of
|
|
46
|
+
* giving operators visibility into adversarial files.
|
|
47
|
+
*/
|
|
48
|
+
export const MAX_AST_PARSE_MS = 2000;
|
|
49
|
+
|
|
50
|
+
export type ParseSkipReason =
|
|
51
|
+
| 'size-cap'
|
|
52
|
+
| 'depth-cap'
|
|
53
|
+
| 'control-bytes'
|
|
54
|
+
| 'utf8-validation';
|
|
55
|
+
|
|
56
|
+
export interface ParseSkip {
|
|
57
|
+
reason: ParseSkipReason;
|
|
58
|
+
detail: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Static safety gate. Call BEFORE invoking `parser.parse()`. Returns
|
|
63
|
+
* `null` if the source is acceptable, or a `ParseSkip` describing why
|
|
64
|
+
* the file is rejected.
|
|
65
|
+
*
|
|
66
|
+
* Cheap to evaluate — single linear scan + size check.
|
|
67
|
+
*/
|
|
68
|
+
export function isParsableSource(source: string, sizeBytes?: number): ParseSkip | null {
|
|
69
|
+
// Size cap: reject before Tree-sitter sees the bytes. We compute
|
|
70
|
+
// byte-length conservatively when not provided.
|
|
71
|
+
const bytes = sizeBytes ?? Buffer.byteLength(source, 'utf-8');
|
|
72
|
+
if (bytes > MAX_AST_FILE_BYTES) {
|
|
73
|
+
return {
|
|
74
|
+
reason: 'size-cap',
|
|
75
|
+
detail: `${bytes} bytes > ${MAX_AST_FILE_BYTES} cap`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Depth cap: count maximal nesting depth across `(` `[` `{` runs.
|
|
80
|
+
// Single-pass O(n) — no regex backtracking.
|
|
81
|
+
let depth = 0;
|
|
82
|
+
let maxDepth = 0;
|
|
83
|
+
// Also reject NUL bytes (Tree-sitter handles them but they're a
|
|
84
|
+
// canary for binary-file mislabeling).
|
|
85
|
+
for (let i = 0; i < source.length; i++) {
|
|
86
|
+
const c = source.charCodeAt(i);
|
|
87
|
+
if (c === 0) {
|
|
88
|
+
return { reason: 'control-bytes', detail: 'NUL byte at offset ' + i };
|
|
89
|
+
}
|
|
90
|
+
// 40 = '(', 91 = '[', 123 = '{'
|
|
91
|
+
if (c === 40 || c === 91 || c === 123) {
|
|
92
|
+
depth++;
|
|
93
|
+
if (depth > maxDepth) maxDepth = depth;
|
|
94
|
+
if (depth > MAX_AST_PARSE_DEPTH) {
|
|
95
|
+
return {
|
|
96
|
+
reason: 'depth-cap',
|
|
97
|
+
detail: `nesting depth exceeded ${MAX_AST_PARSE_DEPTH}`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
} else if (c === 41 || c === 93 || c === 125) {
|
|
101
|
+
// Close brackets — clamp to 0 (mismatched code shouldn't crash this).
|
|
102
|
+
depth = depth > 0 ? depth - 1 : 0;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Wrap a synchronous Tree-sitter call and emit a warning when the call
|
|
111
|
+
* exceeds the budget. Returns the call's result regardless — callers may
|
|
112
|
+
* decide to discard it based on `elapsed`.
|
|
113
|
+
*
|
|
114
|
+
* Note: Tree-sitter's WASM path has no co-operative cancellation, so
|
|
115
|
+
* this is observability rather than a hard kill. The size + depth caps
|
|
116
|
+
* are the load-bearing mitigations; this is the third belt.
|
|
117
|
+
*/
|
|
118
|
+
export function withParseDeadline<T>(
|
|
119
|
+
fn: () => T,
|
|
120
|
+
filePath: string,
|
|
121
|
+
budgetMs: number = MAX_AST_PARSE_MS,
|
|
122
|
+
): { value: T; elapsedMs: number; overBudget: boolean } {
|
|
123
|
+
const start = Date.now();
|
|
124
|
+
const value = fn();
|
|
125
|
+
const elapsedMs = Date.now() - start;
|
|
126
|
+
const overBudget = elapsedMs > budgetMs;
|
|
127
|
+
if (overBudget) {
|
|
128
|
+
process.stderr.write(
|
|
129
|
+
`[massu/ast] WARN: parse of ${filePath} took ${elapsedMs}ms (budget ${budgetMs}ms) — file may be adversarial. (Phase 3.5 mitigation)\n`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return { value, elapsedMs, overBudget };
|
|
133
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Plan 3b — Phase 1: Django AST adapter.
|
|
6
|
+
*
|
|
7
|
+
* Extracts:
|
|
8
|
+
* - mixin_classes: class-based-view base classes inheriting LoginRequiredMixin etc.
|
|
9
|
+
* - decorator_usage: `@login_required` or `@permission_required` decorators
|
|
10
|
+
* - urlpatterns_shape: 'function-views' | 'class-views' | 'mixed'
|
|
11
|
+
*
|
|
12
|
+
* Conservative gate: returns 'none' if no `INSTALLED_APPS` Django marker is
|
|
13
|
+
* present in the signal bundle. Adapter wants HIGH precision: false-positive
|
|
14
|
+
* Django classification would push regex fallback aside for projects that
|
|
15
|
+
* don't actually use Django.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Parser } from 'web-tree-sitter';
|
|
19
|
+
import type { CodebaseAdapter, AdapterResult, DetectionSignals, Provenance, SourceFile } from './types.ts';
|
|
20
|
+
import { runQuery, InvalidQueryError } from './query-helpers.ts';
|
|
21
|
+
import { loadGrammar } from './tree-sitter-loader.ts';
|
|
22
|
+
import { isParsableSource, MAX_AST_FILE_BYTES } from './parse-guard.ts';
|
|
23
|
+
|
|
24
|
+
// ============================================================
|
|
25
|
+
// Queries
|
|
26
|
+
// ============================================================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `@login_required` / `@permission_required` decorator on a function def.
|
|
30
|
+
* Captures the decorator name (`login_required` or any `*_required`/`*_login`).
|
|
31
|
+
*/
|
|
32
|
+
const DECORATOR_QUERY = `
|
|
33
|
+
(decorator
|
|
34
|
+
(identifier) @decorator_name)
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Class definition with a base list — captures bases like `LoginRequiredMixin`,
|
|
39
|
+
* `PermissionRequiredMixin`, `View`, `ListView`. The runner filters for the
|
|
40
|
+
* Django-specific names.
|
|
41
|
+
*/
|
|
42
|
+
const CLASS_BASE_QUERY = `
|
|
43
|
+
(class_definition
|
|
44
|
+
name: (identifier) @class_name
|
|
45
|
+
superclasses: (argument_list
|
|
46
|
+
(identifier) @base_name))
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* urlpatterns assignment — captures the rhs (a list) so we can inspect what's
|
|
51
|
+
* inside (path() / re_path() calls indicate function-view style; class names
|
|
52
|
+
* indicate class-view style).
|
|
53
|
+
*/
|
|
54
|
+
const URLPATTERNS_QUERY = `
|
|
55
|
+
(assignment
|
|
56
|
+
left: (identifier) @_target (#eq? @_target "urlpatterns")
|
|
57
|
+
right: (list) @urlpatterns_list)
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
// ============================================================
|
|
61
|
+
// Adapter
|
|
62
|
+
// ============================================================
|
|
63
|
+
|
|
64
|
+
const DJANGO_MIXIN_NAMES = new Set([
|
|
65
|
+
'LoginRequiredMixin',
|
|
66
|
+
'PermissionRequiredMixin',
|
|
67
|
+
'UserPassesTestMixin',
|
|
68
|
+
'StaffuserRequiredMixin',
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
const DJANGO_DECORATOR_PATTERNS = [
|
|
72
|
+
/^login_required$/,
|
|
73
|
+
/^permission_required$/,
|
|
74
|
+
/_required$/,
|
|
75
|
+
/^require_/,
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
export const pythonDjangoAdapter: CodebaseAdapter = {
|
|
79
|
+
id: 'python-django',
|
|
80
|
+
languages: ['python'],
|
|
81
|
+
|
|
82
|
+
matches(signals: DetectionSignals): boolean {
|
|
83
|
+
// Conservative gate: require an explicit Django marker.
|
|
84
|
+
// - manage.py at root, OR
|
|
85
|
+
// - settings.py with INSTALLED_APPS reference, OR
|
|
86
|
+
// - pyproject.toml dep on Django
|
|
87
|
+
if (signals.presentFiles.has('manage.py')) return true;
|
|
88
|
+
const pyToml = signals.pyprojectToml as { __raw?: string } | undefined;
|
|
89
|
+
if (pyToml?.__raw && /\bdjango\b/i.test(pyToml.__raw)) return true;
|
|
90
|
+
return false;
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
async introspect(files: SourceFile[], _rootDir: string): Promise<AdapterResult> {
|
|
94
|
+
if (files.length === 0) {
|
|
95
|
+
return { conventions: {}, provenance: [], confidence: 'none' };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let language;
|
|
99
|
+
try {
|
|
100
|
+
language = await loadGrammar('python');
|
|
101
|
+
} catch {
|
|
102
|
+
return { conventions: {}, provenance: [], confidence: 'none' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const parser = new Parser();
|
|
106
|
+
parser.setLanguage(language);
|
|
107
|
+
|
|
108
|
+
const decoratorsFound = new Map<string, { line: number; file: string }>();
|
|
109
|
+
const mixinsFound = new Map<string, { line: number; file: string }>();
|
|
110
|
+
const urlpatternsShape: { value: 'function-views' | 'class-views' | 'mixed' | null; line: number; file: string } = {
|
|
111
|
+
value: null,
|
|
112
|
+
line: 0,
|
|
113
|
+
file: '',
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
for (const file of files) {
|
|
118
|
+
const skip = isParsableSource(file.content, file.size);
|
|
119
|
+
if (skip) {
|
|
120
|
+
process.stderr.write(
|
|
121
|
+
`[massu/ast] WARN: python-django skipping ${file.path}: ${skip.reason} (${skip.detail}). Cap=${MAX_AST_FILE_BYTES}. (Phase 3.5 mitigation)\n`,
|
|
122
|
+
);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
// Decorators
|
|
127
|
+
for (const hit of runQuery(parser, file.content, DECORATOR_QUERY, 'django-decorator', file.path)) {
|
|
128
|
+
const name = hit.captures.decorator_name;
|
|
129
|
+
if (!name) continue;
|
|
130
|
+
// Filter to Django-shaped decorator names only.
|
|
131
|
+
if (DJANGO_DECORATOR_PATTERNS.some(re => re.test(name)) && !decoratorsFound.has(name)) {
|
|
132
|
+
decoratorsFound.set(name, { line: hit.line, file: file.path });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Mixins
|
|
136
|
+
for (const hit of runQuery(parser, file.content, CLASS_BASE_QUERY, 'django-mixin', file.path)) {
|
|
137
|
+
const base = hit.captures.base_name;
|
|
138
|
+
if (base && DJANGO_MIXIN_NAMES.has(base) && !mixinsFound.has(base)) {
|
|
139
|
+
mixinsFound.set(base, { line: hit.line, file: file.path });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// urlpatterns shape — naive heuristic: inspect the captured text
|
|
143
|
+
for (const hit of runQuery(parser, file.content, URLPATTERNS_QUERY, 'django-urlpatterns', file.path)) {
|
|
144
|
+
const listText = hit.captures.urlpatterns_list ?? '';
|
|
145
|
+
const hasFunctionForm = /\bpath\s*\(/.test(listText) || /\bre_path\s*\(/.test(listText);
|
|
146
|
+
const hasClassForm = /\.as_view\s*\(/.test(listText);
|
|
147
|
+
let shape: 'function-views' | 'class-views' | 'mixed' | null = null;
|
|
148
|
+
if (hasFunctionForm && hasClassForm) shape = 'mixed';
|
|
149
|
+
else if (hasFunctionForm) shape = 'function-views';
|
|
150
|
+
else if (hasClassForm) shape = 'class-views';
|
|
151
|
+
if (shape && !urlpatternsShape.value) {
|
|
152
|
+
urlpatternsShape.value = shape;
|
|
153
|
+
urlpatternsShape.line = hit.line;
|
|
154
|
+
urlpatternsShape.file = file.path;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch (e) {
|
|
158
|
+
if (e instanceof InvalidQueryError) throw e;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} finally {
|
|
163
|
+
try { parser.delete(); } catch { /* ignore */ }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const conventions: Record<string, unknown> = {};
|
|
167
|
+
const provenance: Provenance[] = [];
|
|
168
|
+
|
|
169
|
+
if (decoratorsFound.size > 0) {
|
|
170
|
+
const list = Array.from(decoratorsFound.keys());
|
|
171
|
+
conventions.decorator_usage = list;
|
|
172
|
+
const [first, { line, file }] = decoratorsFound.entries().next().value as [string, { line: number; file: string }];
|
|
173
|
+
provenance.push({ field: 'decorator_usage', sourceFile: file, line, query: 'django-decorator' });
|
|
174
|
+
// Also emit first decorator as auth_dep proxy for compatibility with
|
|
175
|
+
// regex-fallback's behavior — auth_dep was the regex's primary output.
|
|
176
|
+
conventions.auth_dep = first;
|
|
177
|
+
provenance.push({ field: 'auth_dep', sourceFile: file, line, query: 'django-decorator' });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (mixinsFound.size > 0) {
|
|
181
|
+
const list = Array.from(mixinsFound.keys());
|
|
182
|
+
conventions.mixin_classes = list;
|
|
183
|
+
const [, { line, file }] = mixinsFound.entries().next().value as [string, { line: number; file: string }];
|
|
184
|
+
provenance.push({ field: 'mixin_classes', sourceFile: file, line, query: 'django-mixin' });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (urlpatternsShape.value) {
|
|
188
|
+
conventions.urlpatterns_shape = urlpatternsShape.value;
|
|
189
|
+
provenance.push({
|
|
190
|
+
field: 'urlpatterns_shape',
|
|
191
|
+
sourceFile: urlpatternsShape.file,
|
|
192
|
+
line: urlpatternsShape.line,
|
|
193
|
+
query: 'django-urlpatterns',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let confidence: AdapterResult['confidence'];
|
|
198
|
+
if (Object.keys(conventions).length === 0) {
|
|
199
|
+
confidence = 'none';
|
|
200
|
+
} else if (decoratorsFound.size > 0 || mixinsFound.size > 0) {
|
|
201
|
+
confidence = 'high';
|
|
202
|
+
} else {
|
|
203
|
+
confidence = 'medium';
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { conventions, provenance, confidence };
|
|
207
|
+
},
|
|
208
|
+
};
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Plan 3b — Phase 1: FastAPI AST adapter.
|
|
6
|
+
*
|
|
7
|
+
* Extracts:
|
|
8
|
+
* - auth_dep: name passed to `Depends(...)` in router files
|
|
9
|
+
* - api_prefix_base: first path segment of `APIRouter(prefix="/...")`
|
|
10
|
+
* - test_async_pattern: `@pytest.mark.asyncio` (with or without parens)
|
|
11
|
+
*
|
|
12
|
+
* Confidence rules:
|
|
13
|
+
* - 'high' if the auth dep is found exactly ONCE in routers/ and matches
|
|
14
|
+
* known FastAPI signatures.
|
|
15
|
+
* - 'medium' if found in non-routers/ paths (e.g., a deps.py module).
|
|
16
|
+
* - 'low' if multiple candidate auth deps are found (ambiguous — but still
|
|
17
|
+
* emitted so the user can see what was found).
|
|
18
|
+
* - 'none' if no `Depends(...)` calls found AND no `APIRouter(prefix=)` —
|
|
19
|
+
* adapter doesn't apply, regex fallback takes over.
|
|
20
|
+
*
|
|
21
|
+
* Does NOT use regex on file content — only Tree-sitter S-expression queries
|
|
22
|
+
* compiled via `query-helpers.ts`. Regex would be the regex-fallback path.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { Parser } from 'web-tree-sitter';
|
|
26
|
+
import type { CodebaseAdapter, AdapterResult, DetectionSignals, Provenance, SourceFile } from './types.ts';
|
|
27
|
+
import { runQuery, InvalidQueryError } from './query-helpers.ts';
|
|
28
|
+
import { loadGrammar } from './tree-sitter-loader.ts';
|
|
29
|
+
import { isParsableSource, MAX_AST_FILE_BYTES } from './parse-guard.ts';
|
|
30
|
+
|
|
31
|
+
// ============================================================
|
|
32
|
+
// Tree-sitter S-expression queries
|
|
33
|
+
// ============================================================
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Auth dependency: catches `Depends(get_current_user)`, `Depends(require_tier_or_guardian)`,
|
|
37
|
+
* etc. Anchored on the canonical `Depends` call shape.
|
|
38
|
+
*
|
|
39
|
+
* Per the spec doc §3, predicate constraints (#eq?) keep the query from
|
|
40
|
+
* matching arbitrary `<x>(<y>)` calls.
|
|
41
|
+
*/
|
|
42
|
+
const AUTH_DEP_QUERY = `
|
|
43
|
+
(call
|
|
44
|
+
function: (identifier) @_callee (#eq? @_callee "Depends")
|
|
45
|
+
arguments: (argument_list
|
|
46
|
+
(identifier) @auth_dep))
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* APIRouter prefix: `APIRouter(prefix="/api/orders", ...)`. Captures the
|
|
51
|
+
* string literal so the runner can split off the base segment.
|
|
52
|
+
*/
|
|
53
|
+
const API_PREFIX_QUERY = `
|
|
54
|
+
(call
|
|
55
|
+
function: (identifier) @_callee (#eq? @_callee "APIRouter")
|
|
56
|
+
arguments: (argument_list
|
|
57
|
+
(keyword_argument
|
|
58
|
+
name: (identifier) @_kw (#eq? @_kw "prefix")
|
|
59
|
+
value: (string) @prefix_value)))
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `@pytest.mark.asyncio` decorator. Captures the decorator name string for
|
|
64
|
+
* provenance; the value field is fixed as the canonical form.
|
|
65
|
+
*/
|
|
66
|
+
const PYTEST_ASYNCIO_QUERY = `
|
|
67
|
+
(decorator
|
|
68
|
+
(attribute
|
|
69
|
+
object: (attribute
|
|
70
|
+
object: (identifier) @_pkg (#eq? @_pkg "pytest")
|
|
71
|
+
attribute: (identifier) @_mark (#eq? @_mark "mark"))
|
|
72
|
+
attribute: (identifier) @_marker (#eq? @_marker "asyncio"))) @decorator
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
// ============================================================
|
|
76
|
+
// Adapter
|
|
77
|
+
// ============================================================
|
|
78
|
+
|
|
79
|
+
export const pythonFastApiAdapter: CodebaseAdapter = {
|
|
80
|
+
id: 'python-fastapi',
|
|
81
|
+
languages: ['python'],
|
|
82
|
+
|
|
83
|
+
matches(signals: DetectionSignals): boolean {
|
|
84
|
+
// Cheap signal-only check. No file IO. Match if:
|
|
85
|
+
// 1. pyproject.toml mentions fastapi (raw text contains 'fastapi'), OR
|
|
86
|
+
// 2. project has a routers/ directory (FastAPI convention), OR
|
|
87
|
+
// 3. project has app/ + python files at top level
|
|
88
|
+
const pyToml = signals.pyprojectToml as { __raw?: string } | undefined;
|
|
89
|
+
if (pyToml?.__raw && /\bfastapi\b/i.test(pyToml.__raw)) return true;
|
|
90
|
+
if (signals.presentDirs.has('routers')) return true;
|
|
91
|
+
if (signals.presentDirs.has('app') && signals.presentFiles.has('main.py')) return true;
|
|
92
|
+
return false;
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
async introspect(files: SourceFile[], _rootDir: string): Promise<AdapterResult> {
|
|
96
|
+
if (files.length === 0) {
|
|
97
|
+
return { conventions: {}, provenance: [], confidence: 'none' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let language;
|
|
101
|
+
try {
|
|
102
|
+
language = await loadGrammar('python');
|
|
103
|
+
} catch (e) {
|
|
104
|
+
// Grammar unavailable → adapter returns 'none' so regex fallback takes
|
|
105
|
+
// over. The runner's stderr line is emitted at the introspector tier.
|
|
106
|
+
return { conventions: {}, provenance: [], confidence: 'none' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const parser = new Parser();
|
|
110
|
+
parser.setLanguage(language);
|
|
111
|
+
|
|
112
|
+
// Per-field collection: { value -> { fileLine, queryName } }
|
|
113
|
+
const authDeps = new Map<string, { line: number; file: string }>();
|
|
114
|
+
const prefixBases = new Map<string, { line: number; file: string }>();
|
|
115
|
+
const testAsyncPatterns = new Map<string, { line: number; file: string }>();
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
for (const file of files) {
|
|
119
|
+
// Phase 3.5 fix: defense-in-depth size + depth gate at adapter
|
|
120
|
+
// tier (the runner also gates, but adapters may be invoked
|
|
121
|
+
// directly from tests/CLI).
|
|
122
|
+
const skip = isParsableSource(file.content, file.size);
|
|
123
|
+
if (skip) {
|
|
124
|
+
process.stderr.write(
|
|
125
|
+
`[massu/ast] WARN: python-fastapi skipping ${file.path}: ${skip.reason} (${skip.detail}). Cap=${MAX_AST_FILE_BYTES}. (Phase 3.5 mitigation)\n`,
|
|
126
|
+
);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
// Auth dep
|
|
131
|
+
for (const hit of runQuery(parser, file.content, AUTH_DEP_QUERY, 'fastapi-auth-dep', file.path)) {
|
|
132
|
+
const name = hit.captures.auth_dep;
|
|
133
|
+
if (name && !authDeps.has(name)) {
|
|
134
|
+
authDeps.set(name, { line: hit.line, file: file.path });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// API prefix
|
|
138
|
+
for (const hit of runQuery(parser, file.content, API_PREFIX_QUERY, 'fastapi-api-prefix', file.path)) {
|
|
139
|
+
const raw = hit.captures.prefix_value;
|
|
140
|
+
if (!raw) continue;
|
|
141
|
+
// Strip enclosing quotes (string node text includes them)
|
|
142
|
+
const literal = raw.replace(/^['"]/, '').replace(/['"]$/, '');
|
|
143
|
+
const base = extractPrefixBase(literal);
|
|
144
|
+
if (base && !prefixBases.has(base)) {
|
|
145
|
+
prefixBases.set(base, { line: hit.line, file: file.path });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// pytest.mark.asyncio
|
|
149
|
+
for (const hit of runQuery(parser, file.content, PYTEST_ASYNCIO_QUERY, 'fastapi-pytest-asyncio', file.path)) {
|
|
150
|
+
const pat = '@pytest.mark.asyncio';
|
|
151
|
+
if (!testAsyncPatterns.has(pat)) {
|
|
152
|
+
testAsyncPatterns.set(pat, { line: hit.line, file: file.path });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch (e) {
|
|
156
|
+
if (e instanceof InvalidQueryError) {
|
|
157
|
+
// Compile-time failure of OUR query is a developer bug — surface it.
|
|
158
|
+
throw e;
|
|
159
|
+
}
|
|
160
|
+
// Per-file parse error: skip this file, keep going. Tree-sitter is
|
|
161
|
+
// error-tolerant so this is rare; usually means we got a binary or
|
|
162
|
+
// a non-Python file mislabeled.
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} finally {
|
|
167
|
+
try { parser.delete(); } catch { /* ignore */ }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Build result
|
|
171
|
+
const conventions: Record<string, unknown> = {};
|
|
172
|
+
const provenance: Provenance[] = [];
|
|
173
|
+
|
|
174
|
+
// Auth dep: high if exactly 1, low if >1 (still emit first), none if 0.
|
|
175
|
+
if (authDeps.size === 1) {
|
|
176
|
+
const [name, { line, file }] = authDeps.entries().next().value as [string, { line: number; file: string }];
|
|
177
|
+
conventions.auth_dep = name;
|
|
178
|
+
provenance.push({ field: 'auth_dep', sourceFile: file, line, query: 'fastapi-auth-dep' });
|
|
179
|
+
} else if (authDeps.size >= 2) {
|
|
180
|
+
// Ambiguous — prefer the first-seen (stable order from input file list).
|
|
181
|
+
const [name, { line, file }] = authDeps.entries().next().value as [string, { line: number; file: string }];
|
|
182
|
+
conventions.auth_dep = name;
|
|
183
|
+
provenance.push({ field: 'auth_dep', sourceFile: file, line, query: 'fastapi-auth-dep' });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (prefixBases.size >= 1) {
|
|
187
|
+
const [base, { line, file }] = prefixBases.entries().next().value as [string, { line: number; file: string }];
|
|
188
|
+
conventions.api_prefix_base = base;
|
|
189
|
+
provenance.push({ field: 'api_prefix_base', sourceFile: file, line, query: 'fastapi-api-prefix' });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (testAsyncPatterns.size >= 1) {
|
|
193
|
+
const [pat, { line, file }] = testAsyncPatterns.entries().next().value as [string, { line: number; file: string }];
|
|
194
|
+
conventions.test_async_pattern = pat;
|
|
195
|
+
provenance.push({ field: 'test_async_pattern', sourceFile: file, line, query: 'fastapi-pytest-asyncio' });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let confidence: AdapterResult['confidence'];
|
|
199
|
+
if (Object.keys(conventions).length === 0) {
|
|
200
|
+
confidence = 'none';
|
|
201
|
+
} else if (authDeps.size === 1 || (authDeps.size === 0 && prefixBases.size > 0)) {
|
|
202
|
+
confidence = 'high';
|
|
203
|
+
} else if (authDeps.size >= 2) {
|
|
204
|
+
confidence = 'low';
|
|
205
|
+
} else {
|
|
206
|
+
confidence = 'medium';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { conventions, provenance, confidence };
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// ============================================================
|
|
214
|
+
// Helpers
|
|
215
|
+
// ============================================================
|
|
216
|
+
|
|
217
|
+
function extractPrefixBase(prefix: string): string | null {
|
|
218
|
+
if (!prefix.startsWith('/')) return null;
|
|
219
|
+
const stripped = prefix.replace(/^\/+/, '');
|
|
220
|
+
const firstSeg = stripped.split('/')[0];
|
|
221
|
+
if (!firstSeg) return null;
|
|
222
|
+
return '/' + firstSeg;
|
|
223
|
+
}
|