首页
/ 从死钩子到框架感知路由图:codegraph 的 Framework Resolver `extract()` 接线设计与实现解析

从死钩子到框架感知路由图:codegraph 的 Framework Resolver `extract()` 接线设计与实现解析

2026-09-06 13:45:25作者:史锋燃Gardner

本文以 codegraph 仓库中的实现计划 2026-04-24-framework-resolver-extract.md 为主线,拆解一个具体的工程问题:如何把一个从未被调用的 extractNodes 死钩子,替换为统一的 extract(filePath, content): { nodes, references } 接口,让 Django、Flask、Express、Rails、Spring 等 13+ 个框架的路由文件都能向代码知识图谱贡献 route 节点和 route→handler 边。读完本文,你将掌握框架提取器的接口设计、按语言分发的检测机制、各框架正则提取的迁移模式,以及提取器接入 tree-sitter 解析管道(含 worker 线程边界)的关键实现细节。

一、问题起点:一个从未被调用的钩子

计划文档的 Background 一节开宗明义地指出了两个叠加的缺陷:

  1. 死钩子。当时每个 FrameworkResolver 都自带一个 extractNodes?(filePath, content) 方法(覆盖 express、laravel、python/django、python/flask、python/fastapi、ruby/rails、java/spring、go、rust、csharp、swift × 3、react、svelte),但整个 src/ 中对该符号的引用只有一处——src/resolution/types.ts 中的接口定义本身。钩子从未被编排器调用,结果就是图谱中实际不存在任何 route 类型的节点,路由文件里一条 URL 与其 view/controller/handler 之间的链路完全缺失,codegraph_callers(MyView) 会悄无声息地漏掉它最重要的调用方。
  2. 形状 bug。即使钩子"活着"也没用:Django 提取器的正则虽然把 view 名捕获在第 2 组,但 src/resolution/frameworks/python.ts 中的解构把它直接丢弃了。类似形状的问题遍布多数框架。

计划的 Goal 因此被定义为:把死钩子换成单一的 extract?() 方法,在提取阶段(tree-sitter 解析该文件之后)对每个"语言匹配"的已检出框架各调用一次;提取出的节点与 tree-sitter 节点一起入库,提取出的引用进入既有的 unresolved-references 管道,由 name-matcher / import-resolver / 框架自带 resolve() 这套既有机制生成最终边。净效果是:

path('/users', UserListView.as_view())

在图谱中产生一个 route 节点,并通过一条 references 边链接到 UserListView 类节点——且该性质对 Flask、FastAPI、Express、Rails、Laravel、Spring、Gin、Axum、ASP.NET、Vapor、React Router、SvelteKit 同样成立。

技术栈:TypeScript、vitest、tree-sitter(已有)、better-sqlite3(已有),不引入任何新依赖

文档开头还注明了面向 agentic workers 的执行方式建议(使用 subagent-driven-development 或 executing-plans 逐任务执行,checkbox 跟踪进度)。下文按计划的 Task 1–9 顺序展开,并在每一节标注当前仓库中落地后的真实代码证据。

二、接口改造(Task 1):FrameworkExtractionResultextract()

Task 1 遵循严格的 TDD 流程:先写失败测试(一个 extract() 返回 { nodes, references } 的最小 FrameworkResolver 对象,期望 toEqual({ nodes: [], references: [] })),运行 npx vitest run __tests__/frameworks.test.ts 确认失败(extract 不是 FrameworkResolver 的属性、languages 也不是),然后替换接口,再跑 typecheck——预期 typecheck 失败,所有 src/resolution/frameworks/*.ts 会因 extractNodes 不存在于接口上而报错,这是刻意的,由后续任务逐个修复。

计划给出的目标接口是:

/**
 * Result of framework-specific file extraction.
 */
export interface FrameworkExtractionResult {
  /** Framework-specific nodes (e.g. routes) */
  nodes: Node[];
  /** Framework-specific unresolved references (e.g. route -> handler) */
  references: UnresolvedRef[];
}

export interface FrameworkResolver {
  name: string;
  /** Languages this framework applies to. If omitted, applies to all languages. */
  languages?: Language[];
  /** Detect if project uses this framework (project-level, called once at startup) */
  detect(context: ResolutionContext): boolean;
  /** Resolve a reference using framework-specific patterns */
  resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null;
  /**
   * Extract framework-specific nodes and references from a file.
   * Returns route nodes, middleware nodes, etc., plus unresolved references
   * that link those nodes to handlers (view classes, controller methods,
   * included modules). Unresolved references flow into the normal resolution
   * pipeline; the framework's own `resolve()` is one of the strategies tried.
   */
  extract?(filePath: string, content: string): FrameworkExtractionResult;
}

仓库源码佐证:当前 src/resolution/types.ts#L186-L236FrameworkExtractionResultFrameworkResolver.extract?() 均已按上述形状落地。从源码结构看,接口在计划之后还演化出两个可选钩子,值得注意:

  • claimsReference?(name):让"即使图谱中没有同名节点也要放行"的引用名通过解析器的 name-exists 预过滤(典型场景是 Django ORM 的 self._iterable_class(...) 这类属性作为可调用对象的动态分发);
  • postExtract?(context):跨文件收尾通道,在所有 per-file 提取完成后(以及每次增量同步时)调用一次,用于 NestJS RouterModule.register([...]) 这类"符号最终形态取决于兄弟文件"的场景。实现方返回带变更字段的节点(通常是 name),且必须保留节点 id 以使既有的 route→handler 边不被破坏。

这两个钩子的存在说明 extract() 接口设计为"每文件、纯函数、无跨文件状态"是刻意的:所有需要全局视角的逻辑被隔离到 postExtract,保持主提取路径简单可测试。

三、语言分发(Task 2):detectFrameworksgetApplicableFrameworks

Task 2 在保持 detectFrameworks 签名不变的前提下,新增一个按语言过滤的辅助函数:

/**
 * Filter a list of detected frameworks down to ones that apply to a given language.
 * Frameworks without an explicit `languages` list are treated as universal.
 */
export function getApplicableFrameworks(
  detected: FrameworkResolver[],
  language: Language
): FrameworkResolver[] {
  return detected.filter(
    (fw) => !fw.languages || fw.languages.includes(language)
  );
}

对应的失败→通过测试验证了两种行为:['python'] 文件只匹配 python 框架与"无 languages 字段"的通用框架;当语言无任何专属框架时只返回通用框架。

仓库源码佐证:当前实现位于 src/resolution/frameworks/index.ts#L98-L119。其中 detectFrameworks 对每个 resolver 的 detect(context) 调用做了 try/catch 包裹——检测失败按"未检出"处理,保证单个框架的 detect 异常不会炸掉整个索引。同一个文件中的注册表(FRAMEWORK_RESOLVERSL36-L79)从源码结构看已扩展为 20+ 个 resolver:在计划覆盖的 13 个之外,还纳入了 Drupal、NestJS、Vue、Astro、Play、GoFrame、Swift-ObjC 桥、React Native 桥、Expo Modules、Fabric、CICS、Terraform 等,并提供了 registerFrameworkResolver 允许外部同名覆盖注册。这说明该机制已从一个一次性接线计划沉淀为可扩展的框架注册表

四、Django 迁移(Task 3):动机案例的完整实现

Django 是整个计划的动机案例(motivating case),因此拥有最富的测试覆盖。计划中的目标测试覆盖了 6 种形状:

输入形状 期望产出
path('users/', UserListView.as_view(), name='user-list') route 节点 users/ + reference UserListView(kind=referencesfromNodeId 指向 route 节点 id)
path('api/', api_v1_views.UserListView.as_view()) reference 取点分路径最后一段 UserListView
path('home/', home_view) reference home_view
path('api/', include('api.urls')) reference api.urls,kind=imports
re_path(r'^users/$', UserView)url(r'^old/$', OldView) 两个 route 节点,name 分别为 ^users$/^old/$
urls.py 的普通 python 文件 {nodes: [], references: []}

计划给出的 extract() 核心实现(含路由正则与 handler 解析函数):

extract(filePath, content) {
  if (!filePath.endsWith('.py')) return { nodes: [], references: [] };
  const nodes: Node[] = [];
  const references: UnresolvedRef[] = [];
  const now = Date.now();

  // path('url', handler, name=...) / re_path(r'...', handler) / url(r'...', handler)
  const routeRegex = /\b(path|re_path|url)\s*\(\s*r?'"['"]\s*,\s*([^)]*?)(?:\)|,\s*name=)/g;
  let match: RegExpExecArray | null;
  while ((match = routeRegex.exec(content)) !== null) {
    const [, _fn, urlPath, handlerExpr] = match;
    const line = content.slice(0, match.index).split('\n').length;

    const routeNode: Node = {
      id: `route:${filePath}:${line}:${urlPath}`,
      kind: 'route',
      name: urlPath,
      qualifiedName: `${filePath}::route:${urlPath}`,
      filePath, startLine: line, endLine: line,
      startColumn: 0, endColumn: match[0].length,
      language: 'python', updatedAt: now,
    };
    nodes.push(routeNode);

    const handler = handlerExpr.trim();
    const target = resolveHandlerName(handler);
    if (target) {
      references.push({
        fromNodeId: routeNode.id,
        referenceName: target.name,
        referenceKind: target.kind,
        line, column: 0, filePath, language: 'python',
      });
    }
  }
  return { nodes, references };
}

其中 resolveHandlerName 的归一化规则是理解该机制的关键:

function resolveHandlerName(expr: string): { name: string; kind: 'references' | 'imports' } | null {
  // include('module.path') → 整条模块路径,kind = imports
  const includeMatch = expr.match(/^include\s*\(\s*'"['"]/);
  if (includeMatch) return { name: includeMatch[1], kind: 'imports' };

  // 剥掉尾部 .as_view(...) / .as_view
  let head = expr.replace(/\.as_view\s*\([^)]*\)\s*$/, '');
  // 再剥掉任意尾部方法调用 .some_method()
  head = head.replace(/\.\w+\s*\([^)]*\)\s*$/, '');

  // head 要么是裸名要么是点分路径,取最后一段
  const dotted = head.split('.').filter(Boolean);
  if (dotted.length === 0) return null;
  const last = dotted[dotted.length - 1];
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(last)) return null;
  return { name: last, kind: 'references' };
}

注意 include() 走的是 imports 而非 references:它把 URLconf 之间的包含关系建模为"模块依赖",交给 import 解析链路去落到 api/urls.py 文件;而 .as_view() 剥壳后取尾标识符走 references,交给名字匹配链路去落到 view 类节点。同一次正则扫描,两种引用类型分流到既有的两条解析通路。

仓库源码佐证与演化:当前 src/resolution/frameworks/python.ts#L61-L138 的 django 实现相比计划有三处增强:

  1. 提取前先过 stripCommentsForRegex(content, 'python')src/resolution/strip-comments.ts),避免注释里的 path(...) 字面量产生幽灵 route 节点;
  2. handler 捕获组从"任意非 ) 字符"收紧为 [\w.]+(?:\s*\([^)]*\))?,即"点分标识符 + 至多一层平衡括号",注释里明确说明这是为了匹配 View.as_view()include('x.y') 这两类真实形状;
  3. 计划中列为已知 gap 的 DRF router.register 已被后续实现:python.ts#L109-L135.register(r'prefix', SomeViewSet) 正则产生 VIEWSET /prefix route 节点,用"第一参是字符串 + 第二参以 View/ViewSet 结尾"两个条件把它与 admin.site.register(Model, Admin) 区分开。

五、Flask 与 FastAPI(Task 4):装饰器路由的共享抽象

Flask/FastAPI 的路由写在装饰器里,且 handler 是装饰器下方"下一个 def",与 Django 的"调用参数即 handler"不同。计划为此抽象了一个 extractDecoratorRoutes 共享助手,用一组配置参数区分两个框架:

interface DecoratorRouteOpts {
  decoratorRegex: RegExp;
  defaultMethod: string;
  methodGroup?: number;        // @x.post(...) 的方法动词组
  methodFromGroup?: number;   // methods=[...] 列表里提取方法
  pathGroup: number;
  handlerGroup?: number;
  findHandler?: boolean;      // handler 不在正则里,向后找下一个 def
  language: 'python';
}

Flask 配置:

decoratorRegex: /@(\w+)\.route\s*\(\s*'"'"\])?\s*\)\s*\n\s*(?:async\s+)?def\s+(\w+)/g,
defaultMethod: 'GET',
methodFromGroup: 3,
pathGroup: 2,
handlerGroup: 4,

FastAPI 配置:

decoratorRegex: /@(\w+)\.(get|post|put|patch|delete|options|head)\s*\(\s*'"['"]/g,
defaultMethod: '',
methodGroup: 2,
pathGroup: 3,
handlerGroup: undefined,
findHandler: true,

findHandler 分支的核心是在装饰器匹配结束之后向后扫描 \n\s*(?:async\s+)?def\s+(\w+)。两个框架的测试目标分别是:

  • Flask:@app.route('/users') → route 节点 GET /users + reference list_users@users_bp.route('/<id>', methods=['POST'])POST /<id> + create_user
  • FastAPI:@app.get('/users')GET /users + list_users@router.post('/items')POST /items + create_item

仓库源码佐证与演化:当前 python.ts#L210-L229 的 flask extract() 显示装饰器正则已改为"只匹配装饰器头 + 向后找 def"的两段式:注释明确解释动机——"handler 是下一个 def,允许中间夹着 @login_required 和叠层 @x.route() 行"。这正是 tests/frameworks-integration.test.ts#L68-L90 中的端到端用例所验证的回归:两个叠层 @bp.route 装饰器 + 一个 @login_required 之下,GET /GET /index 两个 route 节点都必须被提取出来(旧假设"装饰器后必须紧跟 def"会丢掉第一个叠层装饰器)。Flask 检测逻辑同样被加固为"入口文件(app/main/wsgi/__init__.py,限前 50 个)中同时出现 import flaskFlask("的 app-factory 友好判定。

六、Express(Task 5):中间件约定与尾标识符

Express 的形态是 (app|router).METHOD('/path', handler-expr),计划的三条测试规则:

  1. app.get('/users', listUsers) → route GET /users + reference listUsers
  2. router.post('/items', auth, createItem) → reference 取最后一个 handler createItem(约定:中间件在前,handler 在后);
  3. app.get('/x', userController.list) → reference 取尾标识符 list

计划实现要点:用 handlers.split(',') 取最后一段,再经 extractTailIdent 归一化:

function extractTailIdent(expr: string): string | null {
  const cleaned = expr.replace(/\s+/g, '').replace(/\(\)$/, '');
  const m = cleaned.match(/(?:\.|^)([A-Za-z_][A-Za-z0-9_]*)$/);
  return m ? m[1] : null;
}

另外,method === 'use' 且路径不以 / 开头的匹配被跳过(app.use(mw) 无路径、app.use('/api', mw) 才算前缀路由)。

仓库源码佐证与演化:当前 src/resolution/frameworks/express.ts#L134-L160 中,单条全匹配正则被替换为"头正则 + 括号配平"两段式:头正则只匹配到 (app|router).METHOD('/path', 为止,随后用字符串感知的括号配平函数 matchDelimL21-L35,逐字符跳过 "'` 字符串字面量再数括号深度)圈出完整参数列表。注释直说了动机:handler 经常是内联箭头函数,旧的正则 [^)]+ 无法跨过箭头函数体里的 ){},导致"内联 handler 的路由连不到任何东西"。这是 Task 5 计划在实战仓库中留下的最典型的一处"形状扩展"。

七、其余框架迁移(Task 6):同一模式,逐框架提交

Task 6 把 Laravel、Rails、Spring、Gin、Axum、ASP.NET、Swift/Vapor、React、Svelte 归入同一迁移模式(加 languages 字段、extractNodes 改名 extract 并返回双元组、每个 route 匹配同时产出节点与 UnresolvedRef、每框架至少一条单测),并强调每个框架独立提交,便于任一框架回归时单独回滚。各框架的提取规则要点:

框架(文件) 路由形状 handler 引用取法 languages
Laravel(laravel.ts Route::get('/x', [Ctrl::class, 'method'])'Ctrl@method'Route::resource('users', UserController::class) 数组取第二元 / @ 后段 / 类名本身 ['php']
Rails(ruby.ts get '/x', to: 'users#index'resources :users 展开为每个 CRUD action 一个节点 # 拆分取方法名,controller 段用于限定作用域 ['ruby']
Spring(java.ts 方法上的 @GetMapping("/x") 向后扫描到下一个 public/private 方法名 ['java']
Gin/chi/gorilla(go.ts r.GET("/x", handler) 最后一个参数里的最后一个标识符 ['go']
Axum/actix(rust.ts .route("/x", get(handler)) get(...) 内部的标识符 ['rust']
ASP.NET(csharp.ts [HttpGet("/x")] + 同类 action 方法 向前扫描到首个访问修饰符方法名 ['csharp']
Vapor(swift.ts app.get("/x", use: handler) use: 之后的标识符末段 ['swift']
React / Svelte(react.tssvelte.ts UI 框架,路由映射到组件而非服务端 handler 保持只产节点、references: [] 的迁移,<Route element={<Page/>}/>Page 的边留作后续 ['javascript','typescript'] / ['svelte']

八、管道接线(Task 7):主线程与 worker 线程的双覆盖

这是整个计划的核心接线改动,发生在每个文件被 tree-sitter 解析之后。计划给出的主线程侧片段(在 extractFromSource 返回结果前追加):

// Framework-specific extraction (routes, etc.)
if (detectedFrameworks && detectedFrameworks.length > 0) {
  const applicable = getApplicableFrameworks(detectedFrameworks, language);
  for (const fw of applicable) {
    if (!fw.extract) continue;
    try {
      const fwResult = fw.extract(filePath, content);
      result.nodes.push(...fwResult.nodes);
      result.unresolvedReferences.push(...fwResult.references);
    } catch (err) {
      result.errors.push({
        message: `Framework extractor '${fw.name}' failed: ${err instanceof Error ? err.message : String(err)}`,
        filePath,
        severity: 'warning',
      });
    }
  }
}

三个设计细节值得强调:

  • 单框架失败不拖垮整文件:每个 fw.extract 独立 try/catch,失败降级为一条 severity: 'warning' 的提取错误记录;
  • 检出只做一次:在 indexAll 启动解析 worker 之前,调用 detectFrameworks(buildResolutionContext(rootDir, queries)) 完成项目级检测("项目级信号,启动时调一次"),随后每个文件只做廉价的按语言过滤;
  • worker 线程边界:这是计划中最容易被忽略的坑——解析在 worker 线程中执行,而"带函数的对象无法穿越 worker_threadspostMessage 边界"。因此跨线程只传框架名字符串数组,worker 内部用 getAllFrameworkResolvers().filter(f => detectedNames.includes(f.name)) 重新解析出 resolver 对象。

仓库源码佐证:这条双覆盖路径在现行代码中完整可追:

  • 主线程侧:src/extraction/tree-sitter.ts#L6788-L6811extractFromSource 在 kernel/wasm 提取完成后,依据 frameworkNames 过滤 getAllFrameworkResolvers() 再经 getApplicableFrameworks(..., detectedLanguage) 二次过滤,逐个调用 fw.extract(filePath, source) 并把 nodes/references 合入同一 ExtractionResult——与计划片段逐行对应,且对"文件级语言"(如 CFML 之外只做 file-record 的语言)同样生效,注释指出 Drupal 路由 yml、Spring @Valueapplication.yml 的解析就依赖这一通道;
  • 编排侧:src/extraction/index.ts#L1634ensureDetectedFrameworks(files) 把检出框架名随每个解析任务下发(L1741-L1742extractFromSource/pool.requestParse 两处都携带 frameworkNames),src/extraction/parse-pool.ts#L53 的 job 类型带 frameworkNames?: string[]
  • worker 侧:src/extraction/parse-worker.ts#L68-L114msg.frameworkNames 取出名字数组,按计划所述在 worker 内 filter 出 resolver 对象,最终同样汇入 extractFromSource(filePath, content, language, frameworkNames) 的统一路径。

九、测试分层与端到端验证

计划把测试文件刻意一分为二,理由写在 File Structure 一节:单元测试是确定性的 string-in/array-out,毫秒级;集成测试要启动一个 CodeGraph DB,慢但给出最强的行为保证

单元测试 tests/frameworks.test.ts(当前 1800+ 行)从源码结构看完整继承了计划中的骨架:接口契约测试(extract() 返回 {nodes, references})、getApplicableFrameworks 的语言过滤测试,以及 django/flask/fastapi/express 等每个 resolver 的形状测试。

集成测试 tests/frameworks-integration.test.ts#L20-L58 中的 Django 用例与计划 Task 7 Step 1 几乎逐行一致:

  1. 在临时目录落一个微型 Django 项目(manage.py 标记文件 + requirements.txtdjango==4.2 + users/views.py 定义 UserListView + users/urls.pypath("users/", UserListView.as_view()));
  2. CodeGraph.initSync(tmpDir)indexAll()
  3. 断言三层:route 节点存在且 name 为 users/UserListView 类节点存在;getOutgoingEdges(route.id) 中存在一条 target === view.idkind === 'references' 的边。

这个断言链正是全计划的验收标准:它验证的不是"正则匹配成功",而是 route 节点经 unresolved-reference 管道、经既有解析机制最终落成了图上的真实边

十、收尾(Task 8)与验收标准

Task 8 定义了迁移完成的两条硬性判据:

  1. grep -rn "extractNodes" src/ __tests__/ 零匹配——死钩子彻底移除;
  2. npm run build && npm test 全绿。

并规定了 README 需要补充的 "Framework-aware Routes" 章节内容(Django urlpatternspath()/re_path()/url()/include()、Flask/FastAPI 装饰器、Express、Laravel、Rails、Spring、Gin、Axum、ASP.NET、Vapor 的覆盖清单,以及一句使用提示:Query codegraph_callers(YourView) and the route pattern will appear as an incoming edge)。计划同时要求在 PR 描述中记录生产代码/测试/文档的三行行数统计(占位 X/Y/Z 在 PR 阶段填写,不在计划阶段虚构)。

十一、范围边界与已知缺口

计划的 Scope Note 与 Known gaps 一节刻意划清了"本次不做"的边界,理解这些边界有助于判断当前实现的能力上限:

  • 不迁 AST:Django 提取保持正则而非 tree-sitter AST 遍历。正则对目标形状(path/re_path/url/include/DRF register/CBV.as_view/点分模块路径)够用;AST 化是更大的独立变更,不阻塞本接线。同理,Laravel/Spring 等框架的正则都只承诺覆盖常见声明形状;
  • DRF router 展开:计划时代 router.register 只产单个指向 ViewSet 的 route 节点,展开成 6 个 CRUD action 节点列为后续——从源码看,router.register 的 route 节点 + ViewSet 引用已在现行 python.ts 中实现,六 action 展开仍是未做项;
  • React Router handler 边<Route element={<Page/>}/> 目前只产 route 节点,route -> Page 引用留作后续;
  • Spring 类级作用域:方法级 mapping 可用,类级 @RequestMapping 基础路径的组合是后续工作。

十二、小结:这套接线设计可复用的工程经验

回顾整份计划及其在仓库中的落地,有四条经验对任何"给静态分析管线加领域感知提取器"的工作都有参考价值:

  1. 死代码的实证定位:计划用"全仓 grep 只有一处接口定义"这种可复核的证据确立问题真实性,而不是凭印象断言;
  2. 接口收敛 + 引用复用既有管道extract() 只产出 FrameworkExtractionResult,不自己造边——节点入库、引用走既有 unresolved-refs 解析链,让框架提取器成为"数据生产者"而非"图写入者",职责边界干净;
  3. 跨线程只传数据:resolver 对象含函数,postMessage 传不过去;约定"传名字、两端各自 filter 注册表"是低成本且对注册表演化(resolver 数量从 13 扩到 20+)保持稳定的做法;
  4. 逐框架独立提交 + 双层测试:13 个框架各占一个可回滚的 commit,形状测试毫秒级兜底、Django e2e 用例作为最强行为保证,使"正则这类脆弱实现"的回归面被压到最小。
登录后查看全文
热门项目推荐
相关项目推荐