progmune-runtime 3.7.8 → 3.7.10

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.
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ /**
37
+ * nextjs-detector.test.ts — Next.js App Router 适配器规则回归(文件系统 I/O,
38
+ * 使用临时目录夹具——与 express-detector.test.ts 同款风格)
39
+ */
40
+ const vitest_1 = require("vitest");
41
+ const fs = __importStar(require("fs"));
42
+ const os = __importStar(require("os"));
43
+ const path = __importStar(require("path"));
44
+ const nextjs_detector_1 = require("./nextjs-detector");
45
+ let dir;
46
+ (0, vitest_1.beforeEach)(() => {
47
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "nextjs-det-"));
48
+ });
49
+ (0, vitest_1.afterEach)(() => {
50
+ fs.rmSync(dir, { recursive: true, force: true });
51
+ });
52
+ function writeRoute(rel, code) {
53
+ const full = path.join(dir, rel);
54
+ fs.mkdirSync(path.dirname(full), { recursive: true });
55
+ fs.writeFileSync(full, code);
56
+ }
57
+ const MUTATION_ROUTE = `export async function POST(req: Request) {
58
+ return Response.json({ ok: true });
59
+ }
60
+ `;
61
+ const AUTHED_ROUTE = `import { getServerSession } from "next-auth";
62
+ export async function POST(req: Request) {
63
+ const session = await getServerSession();
64
+ return Response.json({ ok: true });
65
+ }
66
+ `;
67
+ const AUTH_MIDDLEWARE = `import { withAuth } from "next-auth/middleware";
68
+ export default withAuth(function middleware(req) {});
69
+ `;
70
+ (0, vitest_1.describe)("nextjs-detector", () => {
71
+ (0, vitest_1.it)("R1:无认证 mutation 路由文件 → NEXT_ROUTE_NO_AUTH", () => {
72
+ writeRoute("app/api/transfer/route.ts", MUTATION_ROUTE);
73
+ const { hasNext, issues } = (0, nextjs_detector_1.analyzeNextApp)(dir);
74
+ (0, vitest_1.expect)(hasNext).toBe(true);
75
+ (0, vitest_1.expect)(issues.map((i) => i.rule)).toContain("NEXT_ROUTE_NO_AUTH");
76
+ });
77
+ (0, vitest_1.it)("R1:路由内 getServerSession 认证调用保护不报", () => {
78
+ writeRoute("app/api/transfer/route.ts", AUTHED_ROUTE);
79
+ const { issues } = (0, nextjs_detector_1.analyzeNextApp)(dir);
80
+ (0, vitest_1.expect)(issues).toHaveLength(0);
81
+ });
82
+ (0, vitest_1.it)("R1:认证 middleware 全局保护不报", () => {
83
+ writeRoute("app/api/transfer/route.ts", MUTATION_ROUTE);
84
+ writeRoute("middleware.ts", AUTH_MIDDLEWARE);
85
+ const mw = (0, nextjs_detector_1.readNextMiddleware)(dir);
86
+ const { issues } = (0, nextjs_detector_1.analyzeNextApp)(dir, mw);
87
+ (0, vitest_1.expect)(issues).toHaveLength(0);
88
+ });
89
+ (0, vitest_1.it)("R1:GET 导出不报(公开读)", () => {
90
+ writeRoute("app/api/articles/route.ts", `export async function GET() { return Response.json([]); }`);
91
+ const { issues } = (0, nextjs_detector_1.analyzeNextApp)(dir);
92
+ (0, vitest_1.expect)(issues).toHaveLength(0);
93
+ });
94
+ (0, vitest_1.it)("R1 豁免:login/auth 认证入口路径不报", () => {
95
+ writeRoute("app/api/auth/login/route.ts", MUTATION_ROUTE);
96
+ const { issues } = (0, nextjs_detector_1.analyzeNextApp)(dir);
97
+ (0, vitest_1.expect)(issues).toHaveLength(0);
98
+ });
99
+ (0, vitest_1.it)("pages/api 旧式路由同样覆盖", () => {
100
+ writeRoute("pages/api/transfer.ts", `export default function handler(req, res) { res.json({ok:true}); }`);
101
+ // 无 export function POST 的旧式 handler 不识别方法 → 无 flag(口径如实)
102
+ writeRoute("pages/api/transfer2.ts", `export default async function POST(req: Request) { return Response.json({}); }`);
103
+ const { hasNext, issues } = (0, nextjs_detector_1.analyzeNextApp)(dir);
104
+ (0, vitest_1.expect)(hasNext).toBe(true);
105
+ // transfer2 无 POST 导出匹配(default 导出非具名)——旧式页路由方法不可静态区分,如实
106
+ (0, vitest_1.expect)(issues).toHaveLength(0);
107
+ });
108
+ (0, vitest_1.it)("无 Next.js 结构的目录不产生问题", () => {
109
+ const { hasNext, issues } = (0, nextjs_detector_1.analyzeNextApp)(dir);
110
+ (0, vitest_1.expect)(hasNext).toBe(false);
111
+ (0, vitest_1.expect)(issues).toHaveLength(0);
112
+ });
113
+ });
package/dist/sdk.js CHANGED
@@ -20,7 +20,7 @@ const risk_model_1 = require("./risk-model");
20
20
  const protocol_knowledge_1 = require("./protocol-knowledge");
21
21
  const evidence_repository_1 = require("./evidence-repository");
22
22
  /** Runtime version — stable public identifier. Internal layers evolve underneath. */
23
- exports.RUNTIME_VERSION = "3.7.8";
23
+ exports.RUNTIME_VERSION = "3.7.10";
24
24
  function verify(filePath) {
25
25
  const cert = (0, certify_1.certify)(filePath);
26
26
  const kb = (0, protocol_knowledge_1.buildKnowledgeBase)();
@@ -64,6 +64,9 @@ const annotation_suggest_1 = require("../annotation-suggest");
64
64
  const call_graph_propagator_1 = require("./call-graph-propagator");
65
65
  const fastapi_detector_1 = require("../frameworks/fastapi-detector");
66
66
  const django_detector_1 = require("../frameworks/django-detector");
67
+ const flask_detector_1 = require("../frameworks/flask-detector");
68
+ const fastify_detector_1 = require("../frameworks/fastify-detector");
69
+ const nextjs_detector_1 = require("../frameworks/nextjs-detector");
67
70
  const ssg_bridge_1 = require("./ssg-bridge");
68
71
  const call_sequence_1 = require("../call-sequence");
69
72
  const extract_ir_1 = require("../extract-ir");
@@ -96,6 +99,9 @@ async function evaluateTrust(ctx) {
96
99
  const trpcResult = collectTRPCViolations(ctx);
97
100
  const fastapiResult = collectFastapiViolations(ctx);
98
101
  const djangoResult = collectDjangoViolations(ctx);
102
+ const flaskResult = collectFlaskViolations(ctx);
103
+ const fastifyResult = collectFastifyViolations(ctx);
104
+ const nextjsResult = collectNextjsViolations(ctx);
99
105
  const coverageData = collectVerificationCoverage(ctx);
100
106
  const governanceDefects = collectGovernanceDefects(ctx);
101
107
  // ═══════════════════════════════════════
@@ -115,6 +121,9 @@ async function evaluateTrust(ctx) {
115
121
  ...trpcResult.violations,
116
122
  ...fastapiResult.violations,
117
123
  ...djangoResult.violations,
124
+ ...flaskResult.violations,
125
+ ...fastifyResult.violations,
126
+ ...nextjsResult.violations,
118
127
  ];
119
128
  // ═══════════════════════════════════════
120
129
  // PHASE 3: SCORE
@@ -252,6 +261,33 @@ async function evaluateTrust(ctx) {
252
261
  issuesFound: djangoResult.violations.length,
253
262
  }
254
263
  : undefined,
264
+ /** Flask framework adapter coverage — route/auth-guard analysis */
265
+ flaskCoverage: flaskResult.coverage.routes > 0
266
+ ? {
267
+ appsDetected: flaskResult.coverage.apps,
268
+ totalRoutes: flaskResult.coverage.routes,
269
+ filesScanned: flaskResult.coverage.filesScanned,
270
+ issuesFound: flaskResult.violations.length,
271
+ }
272
+ : undefined,
273
+ /** Fastify framework adapter coverage — route/auth-hook analysis */
274
+ fastifyCoverage: fastifyResult.coverage.routes > 0
275
+ ? {
276
+ appsDetected: fastifyResult.coverage.apps,
277
+ totalRoutes: fastifyResult.coverage.routes,
278
+ filesScanned: fastifyResult.coverage.filesScanned,
279
+ issuesFound: fastifyResult.violations.length,
280
+ }
281
+ : undefined,
282
+ /** Next.js framework adapter coverage — App Router route handler analysis */
283
+ nextjsCoverage: nextjsResult.coverage.routes > 0
284
+ ? {
285
+ appsDetected: nextjsResult.coverage.apps,
286
+ totalRoutes: nextjsResult.coverage.routes,
287
+ filesScanned: nextjsResult.coverage.filesScanned,
288
+ issuesFound: nextjsResult.violations.length,
289
+ }
290
+ : undefined,
255
291
  },
256
292
  dimensions: {
257
293
  policyCompliance: {
@@ -390,6 +426,129 @@ function mapPolicyViolation(rv, filePath, _enterprisePolicy) {
390
426
  * Collect Express-specific security violations from the framework detector.
391
427
  * Maps ExpressSecurityIssue[] → TrustViolation[].
392
428
  */
429
+ function collectNextjsViolations(ctx) {
430
+ const violations = [];
431
+ const coverage = { apps: 0, routes: 0, filesScanned: 0 };
432
+ try {
433
+ const middlewareCode = (0, nextjs_detector_1.readNextMiddleware)(ctx.projectPath);
434
+ const analysis = (0, nextjs_detector_1.analyzeNextApp)(ctx.projectPath, middlewareCode);
435
+ if (!analysis.hasNext)
436
+ return { violations, coverage };
437
+ coverage.apps = 1;
438
+ coverage.routes = analysis.routeFiles.length;
439
+ coverage.filesScanned = analysis.routeFiles.length;
440
+ for (const issue of analysis.issues) {
441
+ violations.push({
442
+ severity: issue.severity === "low" ? "low" : issue.severity,
443
+ rule_id: issue.rule,
444
+ file: issue.file || "",
445
+ function: "unknown",
446
+ message: issue.message,
447
+ evidence: issue.route || "",
448
+ why: `Framework structural analysis: ${issue.message}`,
449
+ fix: `Add an auth check inside the route handler (e.g. getServerSession) or protect the app with auth middleware.`,
450
+ policy_ref: "framework-safety.nextjs",
451
+ });
452
+ }
453
+ }
454
+ catch { /* best-effort */ }
455
+ return { violations, coverage };
456
+ }
457
+ function collectFastifyViolations(ctx) {
458
+ const violations = [];
459
+ const coverage = { apps: 0, routes: 0, filesScanned: 0 };
460
+ try {
461
+ const fs = require("fs");
462
+ const candidateDirs = ["src", "server", "app", "api", "routes", "lib"];
463
+ const extensions = languageToExtensions(ctx.language);
464
+ for (const dir of candidateDirs) {
465
+ const dirPath = path.join(ctx.projectPath, dir);
466
+ if (!fs.existsSync(dirPath))
467
+ continue;
468
+ let files;
469
+ try {
470
+ files = walkDir(dirPath, extensions, 100);
471
+ }
472
+ catch {
473
+ continue;
474
+ }
475
+ for (const file of files) {
476
+ if (/\.(test|spec)\.(ts|tsx|js|jsx)$/.test(file))
477
+ continue;
478
+ coverage.filesScanned++;
479
+ try {
480
+ const analysis = (0, fastify_detector_1.analyzeFastifyFile)(file);
481
+ if (!analysis || !analysis.hasFastify)
482
+ continue;
483
+ coverage.apps++;
484
+ coverage.routes += analysis.routes.length;
485
+ for (const issue of analysis.issues) {
486
+ violations.push({
487
+ severity: issue.severity === "low" ? "low" : issue.severity,
488
+ rule_id: issue.rule,
489
+ file: path.relative(ctx.projectPath, file),
490
+ function: "unknown",
491
+ message: issue.message,
492
+ evidence: issue.route || "",
493
+ why: `Framework structural analysis: ${issue.message}`,
494
+ fix: `Add preHandler/preValidation auth to the route options, or register an auth addHook.`,
495
+ policy_ref: "framework-safety.fastify",
496
+ });
497
+ }
498
+ }
499
+ catch { /* skip unreadable files */ }
500
+ }
501
+ }
502
+ }
503
+ catch { /* best-effort */ }
504
+ return { violations, coverage };
505
+ }
506
+ function collectFlaskViolations(ctx) {
507
+ const violations = [];
508
+ const coverage = { apps: 0, routes: 0, filesScanned: 0 };
509
+ // 仅 Python 项目跑框架结构扫描
510
+ const lang = ctx.language || "typescript";
511
+ if (lang !== "python")
512
+ return { violations, coverage };
513
+ try {
514
+ const { execSync } = require("child_process");
515
+ const fs = require("fs");
516
+ const os = require("os");
517
+ // engine.js 位于 dist/trust/ → 仓库根 tools/ 需要两级向上
518
+ const scriptPath = path.resolve(__dirname, "..", "..", "tools", "extract_framework_flask.py");
519
+ const outPath = path.join(os.tmpdir(), `progmune-fwfl-${process.pid}-${Date.now()}.json`);
520
+ execSync(`python3 "${scriptPath}" "${ctx.projectPath}" "${outPath}"`, {
521
+ encoding: "utf-8",
522
+ stdio: "pipe",
523
+ timeout: 60000,
524
+ });
525
+ if (!fs.existsSync(outPath))
526
+ return { violations, coverage };
527
+ const structure = JSON.parse(fs.readFileSync(outPath, "utf-8"));
528
+ fs.unlinkSync(outPath);
529
+ const analysis = (0, flask_detector_1.analyzeFlaskStructure)(structure);
530
+ if (!analysis.hasFlask)
531
+ return { violations, coverage };
532
+ coverage.apps = (structure.apps || []).length + (structure.blueprints || []).length;
533
+ coverage.routes = (structure.routes || []).length;
534
+ coverage.filesScanned = structure.filesScanned || 0;
535
+ for (const issue of analysis.issues) {
536
+ violations.push({
537
+ severity: issue.severity === "low" ? "low" : issue.severity,
538
+ rule_id: issue.rule,
539
+ file: issue.file || "",
540
+ function: issue.handler || "unknown",
541
+ message: issue.message,
542
+ evidence: issue.route || "",
543
+ why: `Framework structural analysis: ${issue.message}`,
544
+ fix: `Add an auth decorator (@login_required or custom) to the route handler, or register an auth before_request guard.`,
545
+ policy_ref: "framework-safety.flask",
546
+ });
547
+ }
548
+ }
549
+ catch { /* best-effort — framework analysis must never break evaluation */ }
550
+ return { violations, coverage };
551
+ }
393
552
  function collectDjangoViolations(ctx) {
394
553
  const violations = [];
395
554
  const coverage = { apps: 0, routes: 0, filesScanned: 0 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "progmune-runtime",
3
- "version": "3.7.8",
3
+ "version": "3.7.10",
4
4
  "description": "Progmune — AI Trust Decision Engine. Verify AI-generated code before it reaches production. Outputs APPROVED / NEEDS_REVIEW / BLOCKED with evidence.",
5
5
  "files": [
6
6
  "dist/",
@@ -8,7 +8,11 @@
8
8
  "c-aliases.json",
9
9
  "CHANGELOG.md",
10
10
  "docs/Progmune_项目全解.html",
11
- "docs/Progmune_投资人白皮书_v2.0.html"
11
+ "docs/Progmune_投资人白皮书_v2.0.html",
12
+ "tools/extract_ir.py",
13
+ "tools/extract_framework_py.py",
14
+ "tools/extract_framework_django.py",
15
+ "tools/extract_framework_flask.py"
12
16
  ],
13
17
  "main": "dist/mcp-server.mjs",
14
18
  "bin": {
@@ -110,4 +114,4 @@
110
114
  "tsx": "^4.22.4",
111
115
  "vitest": "^3.2.6"
112
116
  }
113
- }
117
+ }
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Django Framework Structure Extractor — 框架结构扫描(M2)
4
+
5
+ 从 Python AST 提取 Django 结构:
6
+ 1) urlpatterns 解析:url()/path()/re_path() → 视图引用(FBV views.foo /
7
+ CBV Foo.as_view() / include('module.urls') 递归)
8
+ 2) 视图注册表:
9
+ - FBV:函数装饰器(login_required/permission_required/staff_member_required/
10
+ user_passes_test/authentication_decorator 等 *auth* 名装饰器)
11
+ - CBV:基类(APIView/View/generics.*/LoginRequiredMixin/
12
+ PermissionRequiredMixin)、方法(get/post/put/patch/delete)、
13
+ @method_decorator、DRF permission_classes(AllowAny/IsAuthenticated/
14
+ IsAdminUser/其他类名)
15
+ - @api_view 装饰器:methods + permission_classes kwarg
16
+ 3) 每个 urlpattern → 视图解析(按短名匹配注册表)
17
+
18
+ 输出 JSON:{hasDjango, routes:[{pattern,urlname,view,kind,file}],
19
+ views:{name:{file,decorators,methods,permissionClasses,isDrf,isProtected}},
20
+ filesScanned}
21
+
22
+ 用法:python3 extract_framework_django.py <projectRoot> <outJson>
23
+ """
24
+
25
+ import ast
26
+ import json
27
+ import os
28
+ import re
29
+ import sys
30
+
31
+ SKIP_DIRS = {"tests", "test", "deps", "venv", "env", "node_modules", "vendor",
32
+ ".git", "migrations", "__pycache__", "scripts", "docs",
33
+ "staticfiles", "static"}
34
+
35
+ AUTH_DECORATORS = {
36
+ "login_required", "permission_required", "staff_member_required",
37
+ "user_passes_test", "authentication_decorator", "login_required_decorator",
38
+ "require_http_methods",
39
+ }
40
+
41
+ AUTH_MIXINS = {"LoginRequiredMixin", "PermissionRequiredMixin",
42
+ "UserPassesTestMixin", "StaffMemberRequiredMixin"}
43
+
44
+ DRF_BASES = {"APIView", "GenericAPIView", "RetrieveAPIView", "ListAPIView",
45
+ "CreateAPIView", "UpdateAPIView", "DestroyAPIView",
46
+ "RetrieveUpdateAPIView", "ListCreateAPIView",
47
+ "RetrieveUpdateDestroyAPIView", "ViewSet", "ModelViewSet",
48
+ "GenericViewSet", "ReadOnlyModelViewSet"}
49
+
50
+ MUTATION_METHODS = {"post", "put", "patch", "delete"}
51
+
52
+ VIEW_BASES = DRF_BASES | {"View", "TemplateView", "FormView", "CreateView",
53
+ "UpdateView", "DeleteView", "ListView", "DetailView"}
54
+
55
+ OPEN_PERMISSIONS = {"AllowAny"}
56
+
57
+
58
+ def name_of(node):
59
+ if isinstance(node, ast.Name):
60
+ return node.id
61
+ if isinstance(node, ast.Attribute):
62
+ return node.attr
63
+ return None
64
+
65
+
66
+ def short_view_name(ref):
67
+ """views.auth_lab → auth_lab;Foo.as_view() → Foo"""
68
+ if isinstance(ref, ast.Attribute):
69
+ return ref.attr
70
+ if isinstance(ref, ast.Call) and isinstance(ref.func, ast.Attribute):
71
+ return ref.func.value.attr if isinstance(ref.func.value, ast.Attribute) \
72
+ else name_of(ref.func.value)
73
+ if isinstance(ref, ast.Name):
74
+ return ref.id
75
+ return None
76
+
77
+
78
+ class ViewCollector(ast.NodeVisitor):
79
+ """收集全部函数/类的视图特征(与 urlpatterns 解耦)"""
80
+
81
+ def __init__(self, filepath):
82
+ self.file = filepath
83
+ self.fbvs = {} # name -> {decorators, apiView: [methods]}
84
+ self.classes = {} # name -> {bases, methods, decorators, permissionClasses, isDrf, protected}
85
+
86
+ def visit_FunctionDef(self, node):
87
+ self._scan_function(node, async_=False)
88
+ self.generic_visit(node)
89
+
90
+ def visit_AsyncFunctionDef(self, node):
91
+ self._scan_function(node, async_=True)
92
+ self.generic_visit(node)
93
+
94
+ def _scan_function(self, node, async_):
95
+ decorators = []
96
+ api_view_methods = None
97
+ permission_classes = []
98
+ for dec in node.decorator_list:
99
+ if isinstance(dec, ast.Name):
100
+ decorators.append(dec.id)
101
+ elif isinstance(dec, ast.Call) and isinstance(dec.func, ast.Name):
102
+ decorators.append(dec.func.id)
103
+ if dec.func.id == "api_view":
104
+ if dec.args and isinstance(dec.args[0], (ast.List, ast.Tuple)):
105
+ api_view_methods = [
106
+ e.value.lower() for e in dec.args[0].elts
107
+ if isinstance(e, ast.Constant)
108
+ ]
109
+ for kw in dec.keywords:
110
+ if kw.arg == "permission_classes" and isinstance(kw.value, (ast.List, ast.Tuple)):
111
+ permission_classes = [name_of(e) for e in kw.value.elts if name_of(e)]
112
+ elif isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
113
+ decorators.append(dec.func.attr) # django.views.decorators.login_required
114
+ self.fbvs[node.name] = {
115
+ "file": self.file,
116
+ "decorators": decorators,
117
+ "apiViewMethods": api_view_methods,
118
+ "permissionClasses": permission_classes,
119
+ }
120
+
121
+ def visit_ClassDef(self, node):
122
+ bases = [name_of(b) for b in node.bases if name_of(b)]
123
+ methods = []
124
+ permission_classes = []
125
+ decorators = []
126
+ for item in node.body:
127
+ if isinstance(item, ast.FunctionDef) and item.name in (
128
+ "get", "post", "put", "patch", "delete", "create",
129
+ "update", "destroy", "list", "retrieve"):
130
+ methods.append(item.name)
131
+ if isinstance(item, ast.Assign):
132
+ for t in item.targets:
133
+ if isinstance(t, ast.Name) and t.id == "permission_classes":
134
+ if isinstance(item.value, (ast.List, ast.Tuple)):
135
+ permission_classes = [name_of(e) for e in item.value.elts if name_of(e)]
136
+ elif isinstance(item.value, ast.Name):
137
+ permission_classes = [item.value.id]
138
+ for dec in node.decorator_list:
139
+ if isinstance(dec, ast.Name):
140
+ decorators.append(dec.id)
141
+ elif isinstance(dec, ast.Call) and isinstance(dec.func, ast.Name):
142
+ decorators.append(dec.func.id)
143
+ elif isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
144
+ decorators.append(dec.func.attr)
145
+ is_drf = any(b in DRF_BASES for b in bases)
146
+ has_mixin = any(b in AUTH_MIXINS for b in bases)
147
+ self.classes[node.name] = {
148
+ "file": self.file,
149
+ "bases": bases,
150
+ "methods": methods,
151
+ "decorators": decorators,
152
+ "permissionClasses": permission_classes,
153
+ "isDrf": is_drf,
154
+ "protectedByMixin": has_mixin,
155
+ }
156
+
157
+
158
+ class UrlCollector(ast.NodeVisitor):
159
+ """收集 urlpatterns 列表里的视图引用"""
160
+
161
+ def __init__(self, filepath):
162
+ self.file = filepath
163
+ self.routes = []
164
+
165
+ def visit_Assign(self, node):
166
+ for t in node.targets:
167
+ if isinstance(t, ast.Name) and t.id == "urlpatterns" and isinstance(node.value, (ast.List, ast.Tuple)):
168
+ for elt in node.value.elts:
169
+ self._scan_entry(elt)
170
+ self.generic_visit(node)
171
+
172
+ def _scan_entry(self, elt):
173
+ if not isinstance(elt, ast.Call):
174
+ return
175
+ fn = name_of(elt.func) # url / path / re_path
176
+ if fn not in ("url", "path", "re_path"):
177
+ return
178
+ pattern = ""
179
+ if elt.args and isinstance(elt.args[0], ast.Constant):
180
+ pattern = str(elt.args[0].value)
181
+ view_ref = elt.args[1] if len(elt.args) > 1 else None
182
+ if view_ref is None:
183
+ return
184
+ kind = "other"
185
+ view_name = None
186
+ if isinstance(view_ref, ast.Call) and isinstance(view_ref.func, ast.Attribute):
187
+ if view_ref.func.attr == "as_view":
188
+ kind = "cbv"
189
+ view_name = short_view_name(view_ref)
190
+ elif view_ref.func.id == "include" if isinstance(view_ref.func, ast.Name) else False:
191
+ kind = "include"
192
+ if view_name is None:
193
+ if isinstance(view_ref, ast.Name):
194
+ kind = "include" if False else "fbv"
195
+ view_name = view_ref.id
196
+ elif isinstance(view_ref, ast.Attribute):
197
+ kind = "fbv"
198
+ view_name = view_ref.attr
199
+ elif isinstance(view_ref, ast.Call) and isinstance(view_ref.func, ast.Name):
200
+ if view_ref.func.id == "include":
201
+ kind = "include"
202
+ else:
203
+ kind = "fbv"
204
+ view_name = view_ref.func.id
205
+ urlname = ""
206
+ for kw in elt.keywords:
207
+ if kw.arg == "name" and isinstance(kw.value, ast.Constant):
208
+ urlname = str(kw.value)
209
+ self.routes.append({
210
+ "pattern": pattern,
211
+ "urlname": urlname,
212
+ "view": view_name,
213
+ "kind": kind,
214
+ "file": self.file,
215
+ })
216
+
217
+
218
+ def walk_py_files(root):
219
+ for dirpath, dirnames, filenames in os.walk(root):
220
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
221
+ for fn in filenames:
222
+ if fn.endswith(".py") and not fn.startswith("test_"):
223
+ yield os.path.join(dirpath, fn)
224
+
225
+
226
+ def main():
227
+ root, out = sys.argv[1], sys.argv[2]
228
+ views, routes, files_scanned = {}, [], 0
229
+ for fp in walk_py_files(root):
230
+ files_scanned += 1
231
+ try:
232
+ with open(fp, "r", encoding="utf-8", errors="replace") as f:
233
+ tree = ast.parse(f.read(), filename=fp)
234
+ except (SyntaxError, UnicodeDecodeError, OSError):
235
+ continue
236
+ vc, uc = ViewCollector(fp), UrlCollector(fp)
237
+ vc.visit(tree)
238
+ uc.visit(tree)
239
+ for name, info in vc.fbvs.items():
240
+ info["kind"] = "fbv"
241
+ views[name] = info
242
+ for name, info in vc.classes.items():
243
+ info["kind"] = "cbv"
244
+ views[name] = info
245
+ routes.extend(uc.routes)
246
+
247
+ # 保护判定(扫描器只算事实,规则在 TS 检测器侧)
248
+ for name, info in views.items():
249
+ auth_dec = [d for d in info.get("decorators", [])
250
+ if d in AUTH_DECORATORS or "auth" in d.lower() or "login" in d.lower()]
251
+ info["authDecorators"] = auth_dec
252
+ if info["kind"] == "cbv":
253
+ pc = info.get("permissionClasses") or []
254
+ info["openPermission"] = (len(pc) == 0) or all(
255
+ p in OPEN_PERMISSIONS for p in pc
256
+ )
257
+
258
+ result = {
259
+ "hasDjango": bool(routes),
260
+ "routes": routes,
261
+ "views": views,
262
+ "filesScanned": files_scanned,
263
+ }
264
+ with open(out, "w", encoding="utf-8") as f:
265
+ json.dump(result, f, ensure_ascii=False, indent=2)
266
+
267
+
268
+ if __name__ == "__main__":
269
+ main()