@massu/core 1.2.1 → 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 (61) hide show
  1. package/README.md +40 -0
  2. package/commands/README.md +137 -0
  3. package/commands/massu-deploy.python-docker.md +170 -0
  4. package/commands/massu-deploy.python-fly.md +189 -0
  5. package/commands/massu-deploy.python-launchd.md +144 -0
  6. package/commands/massu-deploy.python-systemd.md +163 -0
  7. package/commands/massu-deploy.python.md +200 -0
  8. package/commands/massu-scaffold-page.md +172 -59
  9. package/commands/massu-scaffold-page.swift.md +121 -0
  10. package/commands/massu-scaffold-router.python-django.md +153 -0
  11. package/commands/massu-scaffold-router.python-fastapi.md +145 -0
  12. package/commands/massu-scaffold-router.python.md +143 -0
  13. package/dist/cli.js +10170 -4138
  14. package/dist/hooks/auto-learning-pipeline.js +44 -6
  15. package/dist/hooks/classify-failure.js +44 -6
  16. package/dist/hooks/cost-tracker.js +44 -6
  17. package/dist/hooks/fix-detector.js +44 -6
  18. package/dist/hooks/incident-pipeline.js +44 -6
  19. package/dist/hooks/post-edit-context.js +44 -6
  20. package/dist/hooks/post-tool-use.js +44 -6
  21. package/dist/hooks/pre-compact.js +44 -6
  22. package/dist/hooks/pre-delete-check.js +44 -6
  23. package/dist/hooks/quality-event.js +44 -6
  24. package/dist/hooks/rule-enforcement-pipeline.js +44 -6
  25. package/dist/hooks/session-end.js +44 -6
  26. package/dist/hooks/session-start.js +4789 -410
  27. package/dist/hooks/user-prompt.js +44 -6
  28. package/package.json +10 -4
  29. package/src/cli.ts +28 -2
  30. package/src/commands/config-refresh.ts +88 -20
  31. package/src/commands/init.ts +130 -23
  32. package/src/commands/install-commands.ts +482 -42
  33. package/src/commands/refresh-log.ts +37 -0
  34. package/src/commands/show-template.ts +65 -0
  35. package/src/commands/template-engine.ts +262 -0
  36. package/src/commands/watch.ts +430 -0
  37. package/src/config.ts +69 -3
  38. package/src/detect/adapters/nextjs-trpc.ts +166 -0
  39. package/src/detect/adapters/parse-guard.ts +133 -0
  40. package/src/detect/adapters/python-django.ts +208 -0
  41. package/src/detect/adapters/python-fastapi.ts +223 -0
  42. package/src/detect/adapters/query-helpers.ts +170 -0
  43. package/src/detect/adapters/runner.ts +252 -0
  44. package/src/detect/adapters/swift-swiftui.ts +171 -0
  45. package/src/detect/adapters/tree-sitter-loader.ts +348 -0
  46. package/src/detect/adapters/types.ts +174 -0
  47. package/src/detect/codebase-introspector.ts +190 -0
  48. package/src/detect/index.ts +28 -2
  49. package/src/detect/regex-fallback.ts +449 -0
  50. package/src/hooks/session-start.ts +94 -3
  51. package/src/lib/gitToplevel.ts +22 -0
  52. package/src/lib/installLock.ts +179 -0
  53. package/src/lib/pidLiveness.ts +67 -0
  54. package/src/lsp/auto-detect.ts +89 -0
  55. package/src/lsp/client.ts +590 -0
  56. package/src/lsp/enrich.ts +127 -0
  57. package/src/lsp/types.ts +221 -0
  58. package/src/watch/daemon.ts +385 -0
  59. package/src/watch/lockfile-detector.ts +65 -0
  60. package/src/watch/paths.ts +279 -0
  61. package/src/watch/state.ts +178 -0
@@ -0,0 +1,166 @@
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: Next.js + tRPC AST adapter.
6
+ *
7
+ * Extracts:
8
+ * - trpc_router_builder: name of router-creation call (createTRPCRouter, t.router, router)
9
+ * - procedure_pattern: identifier ending in `Procedure` (publicProcedure, protectedProcedure)
10
+ * - ctx_shape: 'object' | 'function' | null — based on resolver signature shape
11
+ *
12
+ * Looks under `server/api/routers/` or `server/trpc/` paths. The runner is
13
+ * responsible for sampling files into those paths; this adapter assumes the
14
+ * `files` it receives are router-shaped.
15
+ */
16
+
17
+ import { Parser } from 'web-tree-sitter';
18
+ import type { CodebaseAdapter, AdapterResult, DetectionSignals, Provenance, SourceFile } from './types.ts';
19
+ import { runQuery, InvalidQueryError } from './query-helpers.ts';
20
+ import { loadGrammar } from './tree-sitter-loader.ts';
21
+ import { isParsableSource, MAX_AST_FILE_BYTES } from './parse-guard.ts';
22
+
23
+ // ============================================================
24
+ // Queries
25
+ // ============================================================
26
+
27
+ /**
28
+ * Router builder call: `createTRPCRouter({...})` or `t.router({...})`. Captures
29
+ * the call's function expression so the runner can normalize it.
30
+ */
31
+ const ROUTER_BUILDER_QUERY = `
32
+ (call_expression
33
+ function: (identifier) @builder_id (#match? @builder_id "^(createTRPCRouter|router)$"))
34
+
35
+ (call_expression
36
+ function: (member_expression
37
+ object: (identifier) @_obj
38
+ property: (property_identifier) @_prop (#eq? @_prop "router"))) @member_call
39
+ `;
40
+
41
+ /**
42
+ * Procedure usage: `publicProcedure.input(...)` / `protectedProcedure.query(...)`.
43
+ * Captures any identifier that ends in `Procedure`.
44
+ */
45
+ const PROCEDURE_QUERY = `
46
+ (member_expression
47
+ object: (identifier) @procedure_id (#match? @procedure_id "Procedure$"))
48
+
49
+ (call_expression
50
+ function: (identifier) @procedure_call (#match? @procedure_call "Procedure$"))
51
+ `;
52
+
53
+ // ============================================================
54
+ // Adapter
55
+ // ============================================================
56
+
57
+ const KNOWN_BUILDERS = new Set(['createTRPCRouter', 'router']);
58
+
59
+ export const nextjsTrpcAdapter: CodebaseAdapter = {
60
+ id: 'nextjs-trpc',
61
+ languages: ['typescript'],
62
+
63
+ matches(signals: DetectionSignals): boolean {
64
+ // package.json deps include @trpc/* OR there's a server/api/routers dir
65
+ const pkgJson = signals.packageJson;
66
+ if (pkgJson) {
67
+ const deps = pkgJson.dependencies as Record<string, unknown> | undefined;
68
+ const devDeps = pkgJson.devDependencies as Record<string, unknown> | undefined;
69
+ const all = { ...(deps ?? {}), ...(devDeps ?? {}) };
70
+ if (Object.keys(all).some(k => k.startsWith('@trpc/'))) return true;
71
+ }
72
+ if (signals.presentDirs.has('server')) return true;
73
+ return false;
74
+ },
75
+
76
+ async introspect(files: SourceFile[], _rootDir: string): Promise<AdapterResult> {
77
+ if (files.length === 0) {
78
+ return { conventions: {}, provenance: [], confidence: 'none' };
79
+ }
80
+
81
+ let language;
82
+ try {
83
+ language = await loadGrammar('typescript');
84
+ } catch {
85
+ return { conventions: {}, provenance: [], confidence: 'none' };
86
+ }
87
+
88
+ const parser = new Parser();
89
+ parser.setLanguage(language);
90
+
91
+ const builders = new Map<string, { line: number; file: string }>();
92
+ const procedures = new Map<string, { line: number; file: string }>();
93
+
94
+ try {
95
+ for (const file of files) {
96
+ const skip = isParsableSource(file.content, file.size);
97
+ if (skip) {
98
+ process.stderr.write(
99
+ `[massu/ast] WARN: nextjs-trpc skipping ${file.path}: ${skip.reason} (${skip.detail}). Cap=${MAX_AST_FILE_BYTES}. (Phase 3.5 mitigation)\n`,
100
+ );
101
+ continue;
102
+ }
103
+ try {
104
+ for (const hit of runQuery(parser, file.content, ROUTER_BUILDER_QUERY, 'trpc-router-builder', file.path)) {
105
+ // Either capture group `builder_id` (direct call) or
106
+ // `member_call` (the whole `t.router(...)` expression).
107
+ const directId = hit.captures.builder_id;
108
+ const memberCall = hit.captures.member_call;
109
+ let label: string | null = null;
110
+ if (directId && KNOWN_BUILDERS.has(directId)) {
111
+ label = directId;
112
+ } else if (memberCall) {
113
+ // Normalize `t.router` text (member_call captures the whole call)
114
+ // into the bare `t.router` form by extracting the leading
115
+ // identifier.foo pattern.
116
+ const m = memberCall.match(/([A-Za-z_$][A-Za-z0-9_$]*)\.router/);
117
+ if (m) label = `${m[1]}.router`;
118
+ }
119
+ if (label && !builders.has(label)) {
120
+ builders.set(label, { line: hit.line, file: file.path });
121
+ }
122
+ }
123
+
124
+ for (const hit of runQuery(parser, file.content, PROCEDURE_QUERY, 'trpc-procedure', file.path)) {
125
+ const proc = hit.captures.procedure_id ?? hit.captures.procedure_call;
126
+ if (proc && !procedures.has(proc)) {
127
+ procedures.set(proc, { line: hit.line, file: file.path });
128
+ }
129
+ }
130
+ } catch (e) {
131
+ if (e instanceof InvalidQueryError) throw e;
132
+ continue;
133
+ }
134
+ }
135
+ } finally {
136
+ try { parser.delete(); } catch { /* ignore */ }
137
+ }
138
+
139
+ const conventions: Record<string, unknown> = {};
140
+ const provenance: Provenance[] = [];
141
+
142
+ if (builders.size > 0) {
143
+ const [name, { line, file }] = builders.entries().next().value as [string, { line: number; file: string }];
144
+ conventions.trpc_router_builder = name;
145
+ provenance.push({ field: 'trpc_router_builder', sourceFile: file, line, query: 'trpc-router-builder' });
146
+ }
147
+ if (procedures.size > 0) {
148
+ const [name, { line, file }] = procedures.entries().next().value as [string, { line: number; file: string }];
149
+ conventions.procedure_pattern = name;
150
+ provenance.push({ field: 'procedure_pattern', sourceFile: file, line, query: 'trpc-procedure' });
151
+ }
152
+
153
+ let confidence: AdapterResult['confidence'];
154
+ if (Object.keys(conventions).length === 0) {
155
+ confidence = 'none';
156
+ } else if (builders.size === 1 && procedures.size <= 2) {
157
+ confidence = 'high';
158
+ } else if (builders.size > 1) {
159
+ confidence = 'low';
160
+ } else {
161
+ confidence = 'medium';
162
+ }
163
+
164
+ return { conventions, provenance, confidence };
165
+ },
166
+ };
@@ -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
+ };