@massu/core 1.3.0 → 1.4.0-soak.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 (56) hide show
  1. package/commands/README.md +23 -8
  2. package/commands/massu-deploy.python-docker.md +170 -0
  3. package/commands/massu-deploy.python-fly.md +189 -0
  4. package/commands/massu-deploy.python-launchd.md +144 -0
  5. package/commands/massu-deploy.python-systemd.md +163 -0
  6. package/commands/massu-scaffold-page.swift.md +10 -10
  7. package/commands/massu-scaffold-router.python-django.md +153 -0
  8. package/commands/massu-scaffold-router.python-fastapi.md +145 -0
  9. package/dist/cli.js +9906 -4133
  10. package/dist/hooks/auto-learning-pipeline.js +37 -2
  11. package/dist/hooks/classify-failure.js +37 -2
  12. package/dist/hooks/cost-tracker.js +37 -2
  13. package/dist/hooks/fix-detector.js +37 -2
  14. package/dist/hooks/incident-pipeline.js +37 -2
  15. package/dist/hooks/post-edit-context.js +37 -2
  16. package/dist/hooks/post-tool-use.js +37 -2
  17. package/dist/hooks/pre-compact.js +37 -2
  18. package/dist/hooks/pre-delete-check.js +37 -2
  19. package/dist/hooks/quality-event.js +37 -2
  20. package/dist/hooks/rule-enforcement-pipeline.js +37 -2
  21. package/dist/hooks/session-end.js +37 -2
  22. package/dist/hooks/session-start.js +4782 -406
  23. package/dist/hooks/user-prompt.js +37 -2
  24. package/package.json +10 -4
  25. package/src/cli.ts +22 -2
  26. package/src/commands/config-refresh.ts +88 -20
  27. package/src/commands/init.ts +130 -23
  28. package/src/commands/install-commands.ts +142 -26
  29. package/src/commands/refresh-log.ts +37 -0
  30. package/src/commands/template-engine.ts +262 -0
  31. package/src/commands/watch.ts +430 -0
  32. package/src/config.ts +63 -0
  33. package/src/detect/adapters/nextjs-trpc.ts +166 -0
  34. package/src/detect/adapters/parse-guard.ts +133 -0
  35. package/src/detect/adapters/python-django.ts +208 -0
  36. package/src/detect/adapters/python-fastapi.ts +223 -0
  37. package/src/detect/adapters/query-helpers.ts +170 -0
  38. package/src/detect/adapters/runner.ts +252 -0
  39. package/src/detect/adapters/swift-swiftui.ts +171 -0
  40. package/src/detect/adapters/tree-sitter-loader.ts +348 -0
  41. package/src/detect/adapters/types.ts +174 -0
  42. package/src/detect/codebase-introspector.ts +190 -0
  43. package/src/detect/index.ts +28 -2
  44. package/src/detect/regex-fallback.ts +449 -0
  45. package/src/hooks/session-start.ts +94 -3
  46. package/src/lib/gitToplevel.ts +22 -0
  47. package/src/lib/installLock.ts +179 -0
  48. package/src/lib/pidLiveness.ts +67 -0
  49. package/src/lsp/auto-detect.ts +89 -0
  50. package/src/lsp/client.ts +590 -0
  51. package/src/lsp/enrich.ts +127 -0
  52. package/src/lsp/types.ts +221 -0
  53. package/src/watch/daemon.ts +385 -0
  54. package/src/watch/lockfile-detector.ts +65 -0
  55. package/src/watch/paths.ts +279 -0
  56. 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
+ }