@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.
- package/README.md +40 -0
- package/commands/README.md +137 -0
- 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-deploy.python.md +200 -0
- package/commands/massu-scaffold-page.md +172 -59
- package/commands/massu-scaffold-page.swift.md +121 -0
- package/commands/massu-scaffold-router.python-django.md +153 -0
- package/commands/massu-scaffold-router.python-fastapi.md +145 -0
- package/commands/massu-scaffold-router.python.md +143 -0
- package/dist/cli.js +10170 -4138
- package/dist/hooks/auto-learning-pipeline.js +44 -6
- package/dist/hooks/classify-failure.js +44 -6
- package/dist/hooks/cost-tracker.js +44 -6
- package/dist/hooks/fix-detector.js +44 -6
- package/dist/hooks/incident-pipeline.js +44 -6
- package/dist/hooks/post-edit-context.js +44 -6
- package/dist/hooks/post-tool-use.js +44 -6
- package/dist/hooks/pre-compact.js +44 -6
- package/dist/hooks/pre-delete-check.js +44 -6
- package/dist/hooks/quality-event.js +44 -6
- package/dist/hooks/rule-enforcement-pipeline.js +44 -6
- package/dist/hooks/session-end.js +44 -6
- package/dist/hooks/session-start.js +4789 -410
- package/dist/hooks/user-prompt.js +44 -6
- package/package.json +10 -4
- package/src/cli.ts +28 -2
- package/src/commands/config-refresh.ts +88 -20
- package/src/commands/init.ts +130 -23
- package/src/commands/install-commands.ts +482 -42
- package/src/commands/refresh-log.ts +37 -0
- package/src/commands/show-template.ts +65 -0
- package/src/commands/template-engine.ts +262 -0
- package/src/commands/watch.ts +430 -0
- package/src/config.ts +69 -3
- 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 +348 -0
- package/src/detect/adapters/types.ts +174 -0
- package/src/detect/codebase-introspector.ts +190 -0
- package/src/detect/index.ts +28 -2
- 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 +89 -0
- package/src/lsp/client.ts +590 -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,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
|
+
};
|