#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ build_graph.py — 项目知识图谱构建/查询工具(dev-expert)。 正则抽取,结果写盘 {root}/.ai-memory/knowledge-graph/{graph.json,meta.json,symbols.json,graph.md}(不进 LLM 上下文)。 边类型:include/use/autoload/extends/template/tpimport/import/cssimport/asset/calls(跨 PHP/JS/TS/Java/Py/CSS/HTML 多语言)。 查询:--query [--direction up|down|both] [--depth N] 裁剪子图(默认 2 跳,MAX_NODES=80)。 --query 入口先比对 mtime+内容哈希做新鲜度检测,过期则全量重建(v1 增量=全量重抽)。 约束:graph.md 仅人类兜底(agent 不加载);符号孤儿检测占位未实现(留口);LSP 仅记录可用性、抽取仍走正则(留口);纯 v1 正则、无 AST/tree-sitter。 """ import argparse import hashlib import json import os import re import sys import time # ---------- 默认排除目录(规划 §5.6 #7) ---------- DEFAULT_EXCLUDE_DIRS = { # 依赖/构建产物 'vendor', 'node_modules', 'dist', 'build', '__pycache__', '.cache', # 版本控制(避免抽 .git/.hg/.svn 内部文件) '.git', '.hg', '.svn', # 图谱产物自身(避免自引用重遍历) '.ai-memory', # 各 IDE / 编辑器项目级元数据目录 '.codebuddy', # CodeBuddy '.vscode', # VS Code '.idea', # JetBrains '.claude', # Claude Code '.cursor', # Cursor '.zed', # Zed '.fleet', # JetBrains Fleet '.sublime', # Sublime Text # 国内 AI IDE '.trae', # Trae '.qoder', # Qoder / 通义灵码 # 其他主流 IDE '.windsurf', # Windsurf '.codeium', # Codeium '.atom', # Atom '.brackets', # Brackets '.vs', # Visual Studio '.metadata', # Eclipse '.settings', # Eclipse '.gradle', # Gradle 'nbproject', # NetBeans 'target', # Maven / Eclipse 构建产物 } # 运行时排除集(由 init_exclude_dirs 在 main 开头初始化,合并默认 + .graphignore + --exclude) _EXCLUDE_DIRS = set(DEFAULT_EXCLUDE_DIRS) def load_exclude_dirs(root, extra=None): """合并默认排除 + .graphignore + CLI --exclude。 .graphignore 格式:每行一个目录名(不含路径分隔符),# 开头为注释,空行忽略。 """ dirs = set(DEFAULT_EXCLUDE_DIRS) gi = os.path.join(root, '.graphignore') if os.path.isfile(gi): try: with open(gi, encoding='utf-8', errors='ignore') as f: for line in f: line = line.strip() if line and not line.startswith('#'): dirs.add(line.rstrip('/\\')) except Exception: pass if extra: dirs.update(x.strip().rstrip('/\\') for x in extra.split(',') if x.strip()) return dirs # ---------- 纳入图谱的源码扩展名(遍历与新鲜度检测须一致) ---------- SRC_EXTS = ('.php', '.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.py', '.java', '.htm', '.html', '.css', '.scss', '.less') # ---------- 抽取正则(PHP/JS,v1 一等支持) ---------- RE_REQ_STMT = re.compile(r"(?:require|include)(?:_once)?\s*\(?\s*(.+?)\s*\)?\s*;", re.I | re.S) RE_STR = re.compile(r"['\"]([^'\"]+)['\"]") RE_DYNAMIC_MARK = re.compile(r"\$\w+") # 变量拼路径(如 $base . '/x')→ 抽不出 RE_CLASS = [ re.compile(r"(?:class|interface|trait|enum)\s+(\w+)(?:\s+extends\s+(\w+))?(?:\s+implements\s+([\w\s,]+))?"), re.compile(r"(?:public\s+|private\s+|protected\s+)?function\s+(\w+)\s*\("), ] # ---------- 命名空间 / use(覆盖 composer PSR-4 框架:ThinkPHP/易优/迅睿/FastAdmin/Laravel) ---------- RE_NAMESPACE = re.compile(r"^\s*namespace\s+([\w\\]+)\s*;", re.M) RE_USE = re.compile(r"^\s*use\s+([\w\\]+)(?:\s+as\s+(\w+))?\s*;", re.M) RE_NEW_FQN = re.compile(r"new\s+\\?([\w\\]+)\s*\(") # new \App\X 或 new App\X # ---------- 非 PHP 语言模块导入(覆盖 Django/Flask/Spring/Express/NestJS/Next/React/Vue/Angular/Strapi) ---------- RE_PY_IMPORT = re.compile(r"^\s*(?:from\s+([\w.]+)\s+import\s+\w+|import\s+([\w.]+))", re.M) RE_JS_REQUIRE = re.compile(r"require\s*\(\s*['\"]([^'\"]+)['\"]\s*\)") RE_JS_IMPORT = re.compile(r"import\s+(?:[^'\"(]*?\s+from\s+)?['\"]([^'\"]+)['\"]") RE_JS_DYNAMIC_IMPORT = re.compile(r"import\s*\(\s*['\"]([^'\"]+)['\"]\s*\)") RE_JAVA_IMPORT = re.compile(r"^\s*import\s+(?:static\s+)?([\w.]+)\s*;", re.M) RE_JAVA_PKG = re.compile(r"^\s*package\s+([\w.]+)\s*;", re.M) # ---------- ThinkPHP 显式类库导入(TP3/TP5 官方机制,thinkphp.cn/info/126) ---------- RE_TP_IMPORT = re.compile(r"(import|vendor|Loader::import)\s*\(\s*['\"]([^'\"]+)['\"]") RE_TPL_INCLUDE = re.compile( r"\{\s*[a-z]*:?include\b[^}]*?\bfile\s*=\s*['\"]([^'\"]+)['\"]", re.I) RE_TPL_REQUIRE = re.compile(r"template\s*\(\s*['\"]([^'\"]+)['\"]\s*\)") # template('x') # ---------- CSS / HTML 资源依赖(前端样式与脚本依赖) ---------- RE_CSS_IMPORT = re.compile(r"@import\s+(?:url\()?\s*['\"]?([^'\"()\s;]+)['\"]?\s*\)?\s*;", re.I) RE_HTML_LINK = re.compile(r"]*\bhref\s*=\s*['\"]([^'\"]+)['\"]", re.I) RE_HTML_SCRIPT = re.compile(r"]*\bsrc\s*=\s*['\"]([^'\"]+)['\"]", re.I) def _resolve_const_include(head, lit, rel, const_table, root): """解析 require 表达式里的常量前缀拼接:CONST . 'lit' / CONST.CONST . 'lit' / 纯 CONST。 逐 token 调 resolve_const 解析常量值,路径段斜杠拼接 + 绝对/相对归一;返回相对 root 的 rel。 常量未定义/循环/含变量 → None(交由调用方落字面量兜底)。 """ lit = lit or '' base = '' ok = True if re.match(r"^[A-Z_][A-Z0-9_]*$", head): base = resolve_const(head, const_table, root) if head in const_table else None if base is None: ok = False else: # 常量链 CONST . CONST . ... for tok in re.split(r"\s*\.\s*", head): rb = resolve_const(tok, const_table, root) if tok in const_table else None if rb is None: ok = False break base = (base.rstrip('/') + '/' + rb.lstrip('/')) if base else rb if not ok: return None base = os.path.normpath(base) if base else '' if lit: lit2 = lit.lstrip('/') if base and (base.startswith('/') or re.match(r'^[A-Za-z]:', base) or os.path.isabs(base)): target = os.path.normpath(base.rstrip('/') + '/' + lit2) else: target = os.path.normpath(os.path.join(base, lit2)) if base else os.path.normpath(lit2) else: target = base if base and not (target.startswith('/') or re.match(r'^[A-Za-z]:', target) or os.path.isabs(target)): target = os.path.normpath(os.path.join(root, target)) return target def read_text(path): """读文本文件:UTF-8 BOM 自动剥 + UTF-8 解码失败回退 GBK。 解决帝国 CMS 等 GBK 编码文件的中文路径/注释抽取漏报(errors='ignore' 会丢字节致路径截断)。 """ with open(path, 'rb') as f: raw = f.read() if raw.startswith(b'\xef\xbb\xbf'): raw = raw[3:] try: return raw.decode('utf-8') except UnicodeDecodeError: try: return raw.decode('gbk') except UnicodeDecodeError: return raw.decode('utf-8', errors='ignore') def strip_php_comments(text): """B2:抽取 include 前剥离 PHP 注释,避免注释/死代码被当真边。 启发式(非解析器): - 块注释:字符串感知状态机剥离,跳过字符串内的 /* */(4c 增强,避免误删 $sql="/* x */") - heredoc/nowdoc:跳过 heredoc 内的 # //(4b 增强,避免误剥 SQL 内 #) - 行注释:// 和 #,仅当注释符前为行首空白或语句结束符才视为注释 已知局限:跨行字符串状态可能误判(罕见)。 """ # 1) 块注释:字符串感知状态机,跳过字符串内的 /* */ out_chars = [] in_str = False quote = '' i = 0 n = len(text) while i < n: ch = text[i] if in_str: if ch == '\\' and i + 1 < n: out_chars.append(text[i:i + 2]) i += 2 continue out_chars.append(ch) if ch == quote: in_str = False i += 1 else: if ch in ('"', "'"): in_str = True quote = ch out_chars.append(ch) i += 1 elif ch == '/' and i + 1 < n and text[i + 1] == '*': end = text.find('*/', i + 2) if end == -1: nl = text.find('\n', i) if nl == -1: nl = n out_chars.append('\n' * text[i:nl].count('\n')) i = nl else: out_chars.append('\n' * text[i:end + 2].count('\n')) i = end + 2 else: out_chars.append(ch) i += 1 text = ''.join(out_chars) # 2) 行注释:跳过 heredoc 内的 # //,仅当注释符前为行首空白或语句结束符才视为注释 out = [] in_heredoc = False heredoc_tag = '' for line in text.split('\n'): if in_heredoc: out.append(line) if line.strip().rstrip(';,').rstrip() == heredoc_tag: in_heredoc = False continue m = re.match(r'^\s*<<= 0: # 行注释符前需为行首空白或语句结束符,否则视为字符串/URL 内 head = line[:cut].rstrip() if head == '' or head.endswith((';', '}', '{', ')', '?')): out.append(line[:cut]) continue out.append(line) return '\n'.join(out) def extract_includes(text, rel, root, const_table=None): """提取 include/require 目标(覆盖帝国/易优/迅睿/ThinkPHP 等 CMS)。 支持:字面量/相对路径、__DIR__/dirname 拼接、define() 常量链(递归解 RHS)、变量拼路径(动态抽不出跳过)。 常量无法静态解析(未定义/循环/含变量)→ 落字面量兜底,不跳过(避免误边)。 B2:PHP 文件抽取前先剥离注释(见 strip_php_comments)。 """ text = strip_php_comments(text) const_table = const_table or {} includes = [] for m in RE_REQ_STMT.finditer(text): expr = m.group(1).strip() # 1) __DIR__ / dirname(...) 拼接(保持原行为) if '__DIR__' in expr or 'dirname(' in expr: sm = RE_STR.search(expr) if sm: raw = sm.group(1) target = os.path.normpath(os.path.join(os.path.dirname(rel), raw.lstrip('/'))) includes.append(target) continue # 2) define() 常量前缀拼接:CONST . 'lit' / 链 / 纯 CONST(_resolve_const_include 提取,语义 1:1 保留)。 # 刻意不复用 _resolve_const_rhs:其路径段拼接不加斜杠,多常量链(A.B)会生成 e/asubx.php 错误路径。 cm = re.match( r"^((?:[A-Z_][A-Z0-9_]*)(?:\s*\.\s*[A-Z_][A-Z0-9_]*)*)" r"\s*\.\s*['\"]([^'\"]*)['\"]$", expr) or \ re.match(r"^([A-Z_][A-Z0-9_]*)$", expr) if cm: target = _resolve_const_include(cm.group(1), cm.group(2) if len(cm.groups()) >= 2 else '', rel, const_table, root) if target is not None: includes.append(target) continue # 常量未定义或无法静态解析 → 落到下方字面量兜底(旧行为),不跳过 # 3) 字面量 / 相对路径(原逻辑) sm = RE_STR.search(expr) if not sm: continue raw = sm.group(1) # 变量拼路径(如 $base . '/x')→ 动态,正则抽不出,跳过(规划漏抽边界) if RE_DYNAMIC_MARK.search(expr): continue if raw.startswith('/') or re.match(r'^[A-Za-z]:', raw): target = os.path.normpath(raw) elif raw.startswith('./') or raw.startswith('../'): target = os.path.normpath(os.path.join(os.path.dirname(rel), raw)) else: # 无前缀 include_path 风格,回退 root target = os.path.normpath(os.path.join(os.path.dirname(rel), raw)) if not os.path.exists(target): target = os.path.normpath(os.path.join(root, raw)) includes.append(target) return includes def file_hash(path): """返回文件 md5;无读权限/不存在等异常返回 None(B4:避免 need_rebuild/build 因单文件不可读而整体崩溃)。""" try: h = hashlib.md5() with open(path, 'rb') as f: for chunk in iter(lambda: f.read(8192), b''): h.update(chunk) return h.hexdigest() except Exception: return None def _safe_mtime(path): """返回文件 mtime;异常返回 None(need_rebuild 的廉价新鲜度前置过滤用)。""" try: return os.path.getmtime(path) except Exception: return None def detect_lsp(root): """运行时 LSP 探测(仅记录可用性,v1 抽取仍走正则)。""" import shutil avail = {} # D-2:统一用 shutil.which 探测(移除跨平台脆弱的 os.system 同步阻塞);未命中 → False(仅可用性记录)。 try: avail['php'] = bool(shutil.which('intelephense') or shutil.which('phpactor')) except Exception: avail['php'] = False # 其他语言默认 False(本机实测) for lang in ('js', 'python', 'java', 'go'): avail[lang] = False return avail # ---------- define() 常量前缀解析(覆盖国内 CMS/框架) ---------- RE_DEFINE = re.compile( r"define\s*\(\s*['\"]([A-Za-z_][A-Za-z0-9_]*)['\"]\s*,\s*(.+?)\s*\)\s*;", re.I | re.S) RE_CONST_DECL = re.compile( r"(?:public\s+|private\s+|protected\s+|final\s+|static\s+)*" r"const\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([^;]+?)\s*;", re.I) def extract_defines(text, rel): """抽取本文件 define()/const 常量 -> {NAME: (raw_rhs, defining_rel)}。""" out = {} for m in RE_DEFINE.finditer(text): out[m.group(1)] = (m.group(2).strip(), rel) for m in RE_CONST_DECL.finditer(text): out[m.group(1)] = (m.group(2).strip(), rel) return out def _resolve_segment(seg, const_table, root, seen): """解析单个拼接段:字面量 / dirname() / 裸常量名。无法静态解析返回 None。""" seg = seg.strip() if not seg: return '' sm = re.match(r"^['\"]([^'\"]*)['\"]$", seg) if sm: return sm.group(1) dm = re.match(r"^dirname\(\s*(.*?)\s*\)$", seg) if dm: inner = _resolve_segment(dm.group(1), const_table, root, seen) if inner is None: return None return os.path.dirname(inner) if re.match(r"^[A-Z_][A-Z0-9_]*$", seg): return resolve_const(seg, const_table, root, seen) return None # 含 $ / 函数调用等无法静态解析 def _resolve_const_rhs(raw, defining_rel, const_table, root, seen): """解析常量 RHS:字面量 / . 拼接链;__DIR__/__FILE__ 用 defining_rel 替换。""" raw = raw.strip() sm = re.match(r"^['\"]([^'\"]*)['\"]$", raw) if sm: return sm.group(1) work = raw if '__DIR__' in work: work = work.replace('__DIR__', "'" + os.path.normpath(os.path.join(root, os.path.dirname(defining_rel))).replace(os.sep, '/') + "'") if '__FILE__' in work: work = work.replace('__FILE__', "'" + os.path.normpath(os.path.join(root, defining_rel)).replace(os.sep, '/') + "'") # 按 ` . `(带空格的拼接运算符)切分,避免误切字符串内的点(如 .php) parts = re.split(r"\s+\.\s+", work) res = '' for p in parts: if p.strip() == '': continue seg = _resolve_segment(p, const_table, root, seen) if seg is None: return None res += seg if res: res = os.path.normpath(res) # 解析结果若落在 root 内(绝对),转回相对路径,保持图谱可移植 if os.path.isabs(res) and res.startswith(os.path.abspath(root)): res = os.path.relpath(res, os.path.abspath(root)).replace(os.sep, '/') if res == '.': res = '' # 解析结果恰为项目根 → 空串,避免拼接出 '.extend' 这类错误路径 return res return None def resolve_const(name, const_table, root, seen=None): """递归解析一个 define 常量的值为真实路径片段;循环/未定义/含变量返回 None。""" if seen is None: seen = set() if name in seen: return None entry = const_table.get(name) if not entry: return None raw, drel = entry seen.add(name) return _resolve_const_rhs(raw, drel, const_table, root, seen) def parse_composer(root): """解析 composer.json 的 autoload(psr-4 / psr-0 / files / classmap)。 返回 {psr4: {prefix: dir}, psr0: {prefix: dir}, files: [rel...], classmap: [dir...]}。 国内 ThinkPHP/易优/迅睿/FastAdmin/Laravel 等框架靠此把命名空间映射到文件。 """ out = {'psr4': {}, 'psr0': {}, 'files': [], 'classmap': []} cj = os.path.join(root, 'composer.json') if not os.path.isfile(cj): return out try: data = json.load(open(cj, encoding='utf-8', errors='ignore')) except Exception: return out al = data.get('autoload', {}) for kind, key in (('psr4', 'psr-4'), ('psr0', 'psr-0')): for pref, d in (al.get(key) or {}).items(): pref = pref.rstrip('/').rstrip('\\') # A-1:剥尾部反斜杠,否则 fqn_to_file 的 pref+'\\' 拼接多一反斜杠致 startswith 永 False if isinstance(d, list): for x in d: out[kind][pref] = x.rstrip('/').rstrip('\\') else: out[kind][pref] = d.rstrip('/').rstrip('\\') for f in (al.get('files') or []): out['files'].append(f) for cm in (al.get('classmap') or []): out['classmap'].append(cm) return out def fqn_to_file(fqn, composer, root): """按 PSR-4 / PSR-0 把完全限定类名映射到文件路径;找不到返回 None。""" fqn = fqn.lstrip('\\') parts = fqn.split('\\') # PSR-4:最长前缀匹配 for pref in sorted(composer['psr4'], key=len, reverse=True): if fqn == pref or fqn.startswith(pref + '\\'): rest = fqn[len(pref):].lstrip('\\').split('\\') d = composer['psr4'][pref] rel = os.path.normpath(os.path.join(d, *rest)) + '.php' if os.path.isfile(os.path.join(root, rel)): return rel.replace(os.sep, '/') return rel.replace(os.sep, '/') # 即便文件暂缺也返回预期路径(标悬空) # PSR-0:每段映射(\ 和 _ 都转目录分隔符,PSR-0 规范) for pref in sorted(composer['psr0'], key=len, reverse=True): if fqn == pref or fqn.startswith(pref + '\\'): rest = fqn[len(pref):].lstrip('\\').replace('\\', '/').replace('_', '/') rest_parts = [p for p in rest.split('/') if p] d = composer['psr0'][pref] rel = os.path.normpath(os.path.join(d, *rest_parts)) + '.php' if os.path.isfile(os.path.join(root, rel)): return rel.replace(os.sep, '/') return rel.replace(os.sep, '/') return None def resolve_module(spec, lang, rel, root): """按语言把 import 说明符解析为项目内文件 rel(或 None):Python/JS(ts)/Java。 相对路径优先相对源文件;裸说明符回退 node_modules / 包根启发式。 """ spec = spec.strip() if not spec: return None # rel 在 Windows 下是 '/' 分隔,os.path.dirname 不认 '/',须转成本地分隔符 rel_native = rel.replace('/', os.sep) src_dir = os.path.dirname(rel_native) src_full = os.path.join(root, src_dir) if src_dir else root if lang == 'py': parts = spec.split('.') if parts and parts[0] == '': parts = parts[1:] # 去前导点(.models) cands = [] for base in (src_full, root): cands.append(os.path.join(base, *parts) + '.py') cands.append(os.path.join(base, *parts, '__init__.py')) for c in cands: if os.path.isfile(c): return os.path.relpath(c, root).replace(os.sep, '/') return None if lang in ('js', 'ts'): if spec.startswith('./') or spec.startswith('../'): base = os.path.normpath(os.path.join(src_full, spec)) # 若说明符自身已带扩展名(./styles.css),优先按原样试 if os.path.splitext(spec)[1]: if os.path.isfile(base): return os.path.relpath(base, root).replace(os.sep, '/') for ext in ('.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs', '.json', '.css', '.scss', '.less'): c = base + ext if os.path.isfile(c): return os.path.relpath(c, root).replace(os.sep, '/') for idx in ('index.js', 'index.ts', 'index.jsx', 'index.tsx'): c = os.path.join(base, idx) if os.path.isfile(c): return os.path.relpath(c, root).replace(os.sep, '/') return None # 裸说明符:node_modules 启发式 nm_dirs = [os.path.join(root, 'node_modules')] if src_dir: nm_dirs.append(os.path.join(root, src_dir, 'node_modules')) for nm in (spec, spec.lstrip('@')): for top in nm_dirs: c = os.path.join(top, nm) for entry in ('index.js', 'index.ts', 'index.jsx', 'index.tsx', 'src/index.js', 'src/index.ts'): if os.path.isfile(os.path.join(c, entry)): return os.path.relpath(os.path.join(c, entry), root).replace(os.sep, '/') return None if lang == 'java': parts = spec.split('.') rest = parts[:-1] if spec.endswith('.*') else parts relp = os.path.normpath(os.path.join(*rest)) + '.java' c = os.path.join(root, relp) if os.path.isfile(c): return os.path.relpath(c, root).replace(os.sep, '/') # 包路径相对源码根(com/x/Y.java 可能位于 root 下任意 java/ 子目录),递归搜索 for dp, _, fns in os.walk(root): if any(x in dp for x in _EXCLUDE_DIRS): continue cand = os.path.join(dp, relp) if os.path.isfile(cand): return os.path.relpath(cand, root).replace(os.sep, '/') return None return None def resolve_tp_import(spec, root, const_table): """解析 ThinkPHP import()/vendor()/Loader::import()(官方规则 thinkphp.cn/info/126)。 点号转目录;基库前缀映射(依赖项目定义的常量,经 resolve_const 解析): Think.* -> THINK_PATH/Lib/* (去 Think 前缀,后缀 .class.php) ORG.* -> EXTEND_PATH/Library/ORG/* (保留 ORG 段,后缀 .class.php) Com.* -> EXTEND_PATH/Library/Com/* (保留 Com 段,后缀 .class.php) Vendor.* -> VENDOR_PATH/* (去 Vendor 前缀;vendor() 调用已补 Vendor.) @.* -> APP_PATH/* (去 @ 前缀,后缀 .class.php) 其它(项目类库,如 Common.Tool/MyApp.Action.User) -> APP_PATH/* 完整点路径(.class.php 优先,缺失回退 .php) 别名导入(单段无点,如 import('rbac')) 需 alias.php 映射,静态难穷举,留口 None。 """ spec = spec.strip() if not spec or '.' not in spec: return None # 别名/多参仅首参带点形式留口 parts = spec.split('.') head = parts[0] if spec.startswith('@'): base = resolve_const('APP_PATH', const_table, root) or 'application' parts = parts[1:] # 去 @ 别名段 suffix = '.class.php' elif head == 'Think': tp = resolve_const('THINK_PATH', const_table, root) or \ resolve_const('LIB_PATH', const_table, root) or 'ThinkPHP' base = (tp.rstrip('/') + '/Lib') if tp else 'ThinkPHP/Lib' parts = parts[1:] # 去 Think 前缀 suffix = '.class.php' elif head in ('ORG', 'Com'): ep = resolve_const('EXTEND_PATH', const_table, root) or 'Extend/Library' base = (ep.rstrip('/') + '/Library') if ep else 'Extend/Library' # 保留 ORG/Com 段(EXTEND_PATH/Library/ORG/...);用首段精确匹配避免 Common 误命中 Com suffix = '.class.php' elif head == 'Vendor': vp = resolve_const('VENDOR_PATH', const_table, root) or 'Vendor' base = vp.rstrip('/') if vp else 'Vendor' parts = parts[1:] # 去 Vendor 前缀(base 已是 VENDOR_PATH) suffix = '.class.php' else: # 项目/应用类库:完整点路径映射目录(Common.Tool 不会误命中 Com 前缀) base = resolve_const('APP_PATH', const_table, root) or 'application' suffix = '.class.php' if not base or not parts: return None relp = os.path.normpath(os.path.join(base, *parts)) + suffix c = os.path.join(root, relp) if os.path.isfile(c): return os.path.relpath(c, root).replace(os.sep, '/') # 项目类库非标准后缀回退 .php(import('X', APP_PATH, '.php') 形态) if suffix == '.class.php': c2 = os.path.join(root, os.path.normpath(os.path.join(base, *parts)) + '.php') if os.path.isfile(c2): return os.path.relpath(c2, root).replace(os.sep, '/') return None def extract_file(path, root, const_table=None): """抽取单文件:返回 {includes, classes, calls, namespace, uses, tpl_includes}。""" text = '' try: text = read_text(path) except Exception: return {'includes': [], 'classes': [], 'calls': [], 'namespace': None, 'uses': [], 'tpl_includes': []} rel = os.path.relpath(path, root).replace(os.sep, '/') # include/require 是 PHP 专属语法;其余语言走各自模块导入机制,禁止用 PHP 正则误抽(否则 JS `src:` 等成噪声边)。 PHP_EXTS = ('.php', '.inc', '.phtml', '.php3', '.php4', '.php5') # B-?:符号抽取须在剥离 PHP 注释后进行(避免注释/死代码里的 `// class X` 被当真实符号生成假边)。 # extract_includes 内部已自剥离;此处对非 include 的符号层统一前置剥离(仅 PHP 文件,非 PHP 仍用原文)。 code = strip_php_comments(text) if rel.endswith(PHP_EXTS) else text includes = extract_includes(text, rel, root, const_table) if rel.endswith(PHP_EXTS) else [] # PHP 专属符号抽取(namespace/use/class/function/calls/new/::/FQN/tpimport)仅对 PHP 运行; # 非 PHP 文件这些字段置空,禁止 .py/.js/.ts/.java 被 PHP 正则误抽污染符号表(ERR-006 同族)。 if rel.endswith(PHP_EXTS): ns_m = RE_NAMESPACE.search(code) namespace = ns_m.group(1) if ns_m else None uses = [{'fqn': m.group(1), 'alias': m.group(2)} for m in RE_USE.finditer(code)] classes = [] for m in RE_CLASS[0].finditer(code): cls = m.group(1) ext = m.group(2) impl = m.group(3) classes.append({'name': cls, 'line': code[:m.start()].count('\n') + 1, 'extends': ext, 'implements': impl}) funcs = [] for m in RE_CLASS[1].finditer(code): funcs.append(m.group(1)) # calls:new X() / X::const(跨文件才记,规划 §4 敏感边约束) # 注:$obj->method() 抽不出类名(变量非类型,静态分析边界),属动态调用漏抽, # 由 G2' grep 复核兜底;保留 new/:: 两类可静态解析类名的调用。 calls = [] for m in re.finditer(r"new\s+(\w+)\s*\(", code): calls.append(m.group(1)) for m in re.finditer(r"(\w+)::\w+", code): calls.append(m.group(1)) # new \Ns\Class(框架类实例化,composer 自动加载) new_fqns = [m.group(1) for m in RE_NEW_FQN.finditer(code)] # ThinkPHP 显式类库导入 import()/vendor()/Loader::import()(TP3/TP5 官方机制) tp_imports = [(m.group(1), m.group(2)) for m in RE_TP_IMPORT.finditer(code)] else: namespace = None uses = [] classes = [] funcs = [] calls = [] new_fqns = [] tp_imports = [] # 模板 include 标签(帝国/Dede/Discuz/易优 模板依赖;CMS 模板标签非 PHP 语法,保留原扫描 text) tpl_includes = [] for m in RE_TPL_INCLUDE.finditer(text): tpl_includes.append(m.group(1)) for m in RE_TPL_REQUIRE.finditer(text): tpl_includes.append(m.group(1)) # 非 PHP 语言模块导入(Django/Flask/Spring/React/Vue/...) lang = None if rel.endswith('.py'): lang = 'py' elif rel.endswith(('.js', '.jsx', '.mjs', '.cjs')): lang = 'js' elif rel.endswith(('.ts', '.tsx')): lang = 'ts' elif rel.endswith('.java'): lang = 'java' module_imports = [] if lang == 'py': for m in RE_PY_IMPORT.finditer(text): module_imports.append(m.group(1) or m.group(2)) elif lang in ('js', 'ts'): for m in RE_JS_REQUIRE.finditer(text): module_imports.append(m.group(1)) for m in RE_JS_IMPORT.finditer(text): module_imports.append(m.group(1)) for m in RE_JS_DYNAMIC_IMPORT.finditer(text): module_imports.append(m.group(1)) elif lang == 'java': java_pkg = RE_JAVA_PKG.search(text) for m in RE_JAVA_IMPORT.finditer(text): module_imports.append(m.group(1)) # CSS @import 依赖(指向其它样式文件,真实样式级联依赖) css_imports = [] if rel.endswith(('.css', '.scss', '.less')): for m in RE_CSS_IMPORT.finditer(text): css_imports.append(m.group(1)) # HTML /