build_graph.py 68 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. build_graph.py — 项目知识图谱构建/查询工具(dev-expert)。
  5. 正则抽取,结果写盘 {root}/.ai-memory/knowledge-graph/{graph.json,meta.json,symbols.json,graph.md}(不进 LLM 上下文)。
  6. 边类型:include/use/autoload/extends/template/tpimport/import/cssimport/asset/calls(跨 PHP/JS/TS/Java/Py/CSS/HTML 多语言)。
  7. 查询:--query <file|symbol> [--direction up|down|both] [--depth N] 裁剪子图(默认 2 跳,MAX_NODES=80)。
  8. --query 入口先比对 mtime+内容哈希做新鲜度检测,过期则全量重建(v1 增量=全量重抽)。
  9. 约束:graph.md 仅人类兜底(agent 不加载);符号孤儿检测占位未实现(留口);LSP 仅记录可用性、抽取仍走正则(留口);纯 v1 正则、无 AST/tree-sitter。
  10. """
  11. import argparse
  12. import hashlib
  13. import json
  14. import os
  15. import re
  16. import sys
  17. import time
  18. # ---------- 默认排除目录(规划 §5.6 #7) ----------
  19. DEFAULT_EXCLUDE_DIRS = {
  20. # 依赖/构建产物
  21. 'vendor', 'node_modules', 'dist', 'build', '__pycache__', '.cache',
  22. # 版本控制(避免抽 .git/.hg/.svn 内部文件)
  23. '.git', '.hg', '.svn',
  24. # 图谱产物自身(避免自引用重遍历)
  25. '.ai-memory',
  26. # 各 IDE / 编辑器项目级元数据目录
  27. '.codebuddy', # CodeBuddy
  28. '.vscode', # VS Code
  29. '.idea', # JetBrains
  30. '.claude', # Claude Code
  31. '.cursor', # Cursor
  32. '.zed', # Zed
  33. '.fleet', # JetBrains Fleet
  34. '.sublime', # Sublime Text
  35. # 国内 AI IDE
  36. '.trae', # Trae
  37. '.qoder', # Qoder / 通义灵码
  38. # 其他主流 IDE
  39. '.windsurf', # Windsurf
  40. '.codeium', # Codeium
  41. '.atom', # Atom
  42. '.brackets', # Brackets
  43. '.vs', # Visual Studio
  44. '.metadata', # Eclipse
  45. '.settings', # Eclipse
  46. '.gradle', # Gradle
  47. 'nbproject', # NetBeans
  48. 'target', # Maven / Eclipse 构建产物
  49. }
  50. # 运行时排除集(由 init_exclude_dirs 在 main 开头初始化,合并默认 + .graphignore + --exclude)
  51. _EXCLUDE_DIRS = set(DEFAULT_EXCLUDE_DIRS)
  52. def load_exclude_dirs(root, extra=None):
  53. """合并默认排除 + .graphignore + CLI --exclude。
  54. .graphignore 格式:每行一个目录名(不含路径分隔符),# 开头为注释,空行忽略。
  55. """
  56. dirs = set(DEFAULT_EXCLUDE_DIRS)
  57. gi = os.path.join(root, '.graphignore')
  58. if os.path.isfile(gi):
  59. try:
  60. with open(gi, encoding='utf-8', errors='ignore') as f:
  61. for line in f:
  62. line = line.strip()
  63. if line and not line.startswith('#'):
  64. dirs.add(line.rstrip('/\\'))
  65. except Exception:
  66. pass
  67. if extra:
  68. dirs.update(x.strip().rstrip('/\\') for x in extra.split(',') if x.strip())
  69. return dirs
  70. # ---------- 纳入图谱的源码扩展名(遍历与新鲜度检测须一致) ----------
  71. SRC_EXTS = ('.php', '.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx',
  72. '.py', '.java', '.htm', '.html', '.css', '.scss', '.less')
  73. # ---------- 抽取正则(PHP/JS,v1 一等支持) ----------
  74. RE_REQ_STMT = re.compile(r"(?:require|include)(?:_once)?\s*\(?\s*(.+?)\s*\)?\s*;", re.I | re.S)
  75. RE_STR = re.compile(r"['\"]([^'\"]+)['\"]")
  76. RE_DYNAMIC_MARK = re.compile(r"\$\w+") # 变量拼路径(如 $base . '/x')→ 抽不出
  77. RE_CLASS = [
  78. re.compile(r"(?:class|interface|trait|enum)\s+(\w+)(?:\s+extends\s+(\w+))?(?:\s+implements\s+([\w\s,]+))?"),
  79. re.compile(r"(?:public\s+|private\s+|protected\s+)?function\s+(\w+)\s*\("),
  80. ]
  81. # ---------- 命名空间 / use(覆盖 composer PSR-4 框架:ThinkPHP/易优/迅睿/FastAdmin/Laravel) ----------
  82. RE_NAMESPACE = re.compile(r"^\s*namespace\s+([\w\\]+)\s*;", re.M)
  83. RE_USE = re.compile(r"^\s*use\s+([\w\\]+)(?:\s+as\s+(\w+))?\s*;", re.M)
  84. RE_NEW_FQN = re.compile(r"new\s+\\?([\w\\]+)\s*\(") # new \App\X 或 new App\X
  85. # ---------- 非 PHP 语言模块导入(覆盖 Django/Flask/Spring/Express/NestJS/Next/React/Vue/Angular/Strapi) ----------
  86. RE_PY_IMPORT = re.compile(r"^\s*(?:from\s+([\w.]+)\s+import\s+\w+|import\s+([\w.]+))", re.M)
  87. RE_JS_REQUIRE = re.compile(r"require\s*\(\s*['\"]([^'\"]+)['\"]\s*\)")
  88. RE_JS_IMPORT = re.compile(r"import\s+(?:[^'\"(]*?\s+from\s+)?['\"]([^'\"]+)['\"]")
  89. RE_JS_DYNAMIC_IMPORT = re.compile(r"import\s*\(\s*['\"]([^'\"]+)['\"]\s*\)")
  90. RE_JAVA_IMPORT = re.compile(r"^\s*import\s+(?:static\s+)?([\w.]+)\s*;", re.M)
  91. RE_JAVA_PKG = re.compile(r"^\s*package\s+([\w.]+)\s*;", re.M)
  92. # ---------- ThinkPHP 显式类库导入(TP3/TP5 官方机制,thinkphp.cn/info/126) ----------
  93. RE_TP_IMPORT = re.compile(r"(import|vendor|Loader::import)\s*\(\s*['\"]([^'\"]+)['\"]")
  94. RE_TPL_INCLUDE = re.compile(
  95. r"\{\s*[a-z]*:?include\b[^}]*?\bfile\s*=\s*['\"]([^'\"]+)['\"]", re.I)
  96. RE_TPL_REQUIRE = re.compile(r"template\s*\(\s*['\"]([^'\"]+)['\"]\s*\)") # template('x')
  97. # ---------- CSS / HTML 资源依赖(前端样式与脚本依赖) ----------
  98. RE_CSS_IMPORT = re.compile(r"@import\s+(?:url\()?\s*['\"]?([^'\"()\s;]+)['\"]?\s*\)?\s*;", re.I)
  99. RE_HTML_LINK = re.compile(r"<link\b[^>]*\bhref\s*=\s*['\"]([^'\"]+)['\"]", re.I)
  100. RE_HTML_SCRIPT = re.compile(r"<script\b[^>]*\bsrc\s*=\s*['\"]([^'\"]+)['\"]", re.I)
  101. def _resolve_const_include(head, lit, rel, const_table, root):
  102. """解析 require 表达式里的常量前缀拼接:CONST . 'lit' / CONST.CONST . 'lit' / 纯 CONST。
  103. 逐 token 调 resolve_const 解析常量值,路径段斜杠拼接 + 绝对/相对归一;返回相对 root 的 rel。
  104. 常量未定义/循环/含变量 → None(交由调用方落字面量兜底)。
  105. """
  106. lit = lit or ''
  107. base = ''
  108. ok = True
  109. if re.match(r"^[A-Z_][A-Z0-9_]*$", head):
  110. base = resolve_const(head, const_table, root) if head in const_table else None
  111. if base is None:
  112. ok = False
  113. else: # 常量链 CONST . CONST . ...
  114. for tok in re.split(r"\s*\.\s*", head):
  115. rb = resolve_const(tok, const_table, root) if tok in const_table else None
  116. if rb is None:
  117. ok = False
  118. break
  119. base = (base.rstrip('/') + '/' + rb.lstrip('/')) if base else rb
  120. if not ok:
  121. return None
  122. base = os.path.normpath(base) if base else ''
  123. if lit:
  124. lit2 = lit.lstrip('/')
  125. if base and (base.startswith('/') or re.match(r'^[A-Za-z]:', base) or os.path.isabs(base)):
  126. target = os.path.normpath(base.rstrip('/') + '/' + lit2)
  127. else:
  128. target = os.path.normpath(os.path.join(base, lit2)) if base else os.path.normpath(lit2)
  129. else:
  130. target = base
  131. if base and not (target.startswith('/') or re.match(r'^[A-Za-z]:', target) or os.path.isabs(target)):
  132. target = os.path.normpath(os.path.join(root, target))
  133. return target
  134. def read_text(path):
  135. """读文本文件:UTF-8 BOM 自动剥 + UTF-8 解码失败回退 GBK。
  136. 解决帝国 CMS 等 GBK 编码文件的中文路径/注释抽取漏报(errors='ignore' 会丢字节致路径截断)。
  137. """
  138. with open(path, 'rb') as f:
  139. raw = f.read()
  140. if raw.startswith(b'\xef\xbb\xbf'):
  141. raw = raw[3:]
  142. try:
  143. return raw.decode('utf-8')
  144. except UnicodeDecodeError:
  145. try:
  146. return raw.decode('gbk')
  147. except UnicodeDecodeError:
  148. return raw.decode('utf-8', errors='ignore')
  149. def strip_php_comments(text):
  150. """B2:抽取 include 前剥离 PHP 注释,避免注释/死代码被当真边。
  151. 启发式(非解析器):
  152. - 块注释:字符串感知状态机剥离,跳过字符串内的 /* */(4c 增强,避免误删 $sql="/* x */")
  153. - heredoc/nowdoc:跳过 heredoc 内的 # //(4b 增强,避免误剥 SQL 内 #)
  154. - 行注释:// 和 #,仅当注释符前为行首空白或语句结束符才视为注释
  155. 已知局限:跨行字符串状态可能误判(罕见)。
  156. """
  157. # 1) 块注释:字符串感知状态机,跳过字符串内的 /* */
  158. out_chars = []
  159. in_str = False
  160. quote = ''
  161. i = 0
  162. n = len(text)
  163. while i < n:
  164. ch = text[i]
  165. if in_str:
  166. if ch == '\\' and i + 1 < n:
  167. out_chars.append(text[i:i + 2])
  168. i += 2
  169. continue
  170. out_chars.append(ch)
  171. if ch == quote:
  172. in_str = False
  173. i += 1
  174. else:
  175. if ch in ('"', "'"):
  176. in_str = True
  177. quote = ch
  178. out_chars.append(ch)
  179. i += 1
  180. elif ch == '/' and i + 1 < n and text[i + 1] == '*':
  181. end = text.find('*/', i + 2)
  182. if end == -1:
  183. nl = text.find('\n', i)
  184. if nl == -1:
  185. nl = n
  186. out_chars.append('\n' * text[i:nl].count('\n'))
  187. i = nl
  188. else:
  189. out_chars.append('\n' * text[i:end + 2].count('\n'))
  190. i = end + 2
  191. else:
  192. out_chars.append(ch)
  193. i += 1
  194. text = ''.join(out_chars)
  195. # 2) 行注释:跳过 heredoc 内的 # //,仅当注释符前为行首空白或语句结束符才视为注释
  196. out = []
  197. in_heredoc = False
  198. heredoc_tag = ''
  199. for line in text.split('\n'):
  200. if in_heredoc:
  201. out.append(line)
  202. if line.strip().rstrip(';,').rstrip() == heredoc_tag:
  203. in_heredoc = False
  204. continue
  205. m = re.match(r'^\s*<<<?\s*([\'"]?)(\w+)\1', line)
  206. if m:
  207. in_heredoc = True
  208. heredoc_tag = m.group(2)
  209. out.append(line)
  210. continue
  211. # 找行注释起点:遍历字符,遇未闭合引号内的 // # 跳过
  212. in_s = False
  213. quote = ''
  214. i = 0
  215. n = len(line)
  216. cut = -1
  217. while i < n:
  218. ch = line[i]
  219. if in_s:
  220. if ch == '\\':
  221. i += 2
  222. continue
  223. if ch == quote:
  224. in_s = False
  225. else:
  226. if ch in ('"', "'"):
  227. in_s = True
  228. quote = ch
  229. elif ch == '#':
  230. cut = i
  231. break
  232. elif ch == '/' and i + 1 < n and line[i + 1] == '/':
  233. cut = i
  234. break
  235. i += 1
  236. if cut >= 0:
  237. # 行注释符前需为行首空白或语句结束符,否则视为字符串/URL 内
  238. head = line[:cut].rstrip()
  239. if head == '' or head.endswith((';', '}', '{', ')', '?')):
  240. out.append(line[:cut])
  241. continue
  242. out.append(line)
  243. return '\n'.join(out)
  244. def extract_includes(text, rel, root, const_table=None):
  245. """提取 include/require 目标(覆盖帝国/易优/迅睿/ThinkPHP 等 CMS)。
  246. 支持:字面量/相对路径、__DIR__/dirname 拼接、define() 常量链(递归解 RHS)、变量拼路径(动态抽不出跳过)。
  247. 常量无法静态解析(未定义/循环/含变量)→ 落字面量兜底,不跳过(避免误边)。
  248. B2:PHP 文件抽取前先剥离注释(见 strip_php_comments)。
  249. """
  250. text = strip_php_comments(text)
  251. const_table = const_table or {}
  252. includes = []
  253. for m in RE_REQ_STMT.finditer(text):
  254. expr = m.group(1).strip()
  255. # 1) __DIR__ / dirname(...) 拼接(保持原行为)
  256. if '__DIR__' in expr or 'dirname(' in expr:
  257. sm = RE_STR.search(expr)
  258. if sm:
  259. raw = sm.group(1)
  260. target = os.path.normpath(os.path.join(os.path.dirname(rel), raw.lstrip('/')))
  261. includes.append(target)
  262. continue
  263. # 2) define() 常量前缀拼接:CONST . 'lit' / 链 / 纯 CONST(_resolve_const_include 提取,语义 1:1 保留)。
  264. # 刻意不复用 _resolve_const_rhs:其路径段拼接不加斜杠,多常量链(A.B)会生成 e/asubx.php 错误路径。
  265. cm = re.match(
  266. r"^((?:[A-Z_][A-Z0-9_]*)(?:\s*\.\s*[A-Z_][A-Z0-9_]*)*)"
  267. r"\s*\.\s*['\"]([^'\"]*)['\"]$", expr) or \
  268. re.match(r"^([A-Z_][A-Z0-9_]*)$", expr)
  269. if cm:
  270. target = _resolve_const_include(cm.group(1),
  271. cm.group(2) if len(cm.groups()) >= 2 else '',
  272. rel, const_table, root)
  273. if target is not None:
  274. includes.append(target)
  275. continue
  276. # 常量未定义或无法静态解析 → 落到下方字面量兜底(旧行为),不跳过
  277. # 3) 字面量 / 相对路径(原逻辑)
  278. sm = RE_STR.search(expr)
  279. if not sm:
  280. continue
  281. raw = sm.group(1)
  282. # 变量拼路径(如 $base . '/x')→ 动态,正则抽不出,跳过(规划漏抽边界)
  283. if RE_DYNAMIC_MARK.search(expr):
  284. continue
  285. if raw.startswith('/') or re.match(r'^[A-Za-z]:', raw):
  286. target = os.path.normpath(raw)
  287. elif raw.startswith('./') or raw.startswith('../'):
  288. target = os.path.normpath(os.path.join(os.path.dirname(rel), raw))
  289. else:
  290. # 无前缀 include_path 风格,回退 root
  291. target = os.path.normpath(os.path.join(os.path.dirname(rel), raw))
  292. if not os.path.exists(target):
  293. target = os.path.normpath(os.path.join(root, raw))
  294. includes.append(target)
  295. return includes
  296. def file_hash(path):
  297. """返回文件 md5;无读权限/不存在等异常返回 None(B4:避免 need_rebuild/build 因单文件不可读而整体崩溃)。"""
  298. try:
  299. h = hashlib.md5()
  300. with open(path, 'rb') as f:
  301. for chunk in iter(lambda: f.read(8192), b''):
  302. h.update(chunk)
  303. return h.hexdigest()
  304. except Exception:
  305. return None
  306. def _safe_mtime(path):
  307. """返回文件 mtime;异常返回 None(need_rebuild 的廉价新鲜度前置过滤用)。"""
  308. try:
  309. return os.path.getmtime(path)
  310. except Exception:
  311. return None
  312. def detect_lsp(root):
  313. """运行时 LSP 探测(仅记录可用性,v1 抽取仍走正则)。"""
  314. import shutil
  315. avail = {}
  316. # D-2:统一用 shutil.which 探测(移除跨平台脆弱的 os.system 同步阻塞);未命中 → False(仅可用性记录)。
  317. try:
  318. avail['php'] = bool(shutil.which('intelephense') or shutil.which('phpactor'))
  319. except Exception:
  320. avail['php'] = False
  321. # 其他语言默认 False(本机实测)
  322. for lang in ('js', 'python', 'java', 'go'):
  323. avail[lang] = False
  324. return avail
  325. # ---------- define() 常量前缀解析(覆盖国内 CMS/框架) ----------
  326. RE_DEFINE = re.compile(
  327. r"define\s*\(\s*['\"]([A-Za-z_][A-Za-z0-9_]*)['\"]\s*,\s*(.+?)\s*\)\s*;",
  328. re.I | re.S)
  329. RE_CONST_DECL = re.compile(
  330. r"(?:public\s+|private\s+|protected\s+|final\s+|static\s+)*"
  331. r"const\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([^;]+?)\s*;", re.I)
  332. def extract_defines(text, rel):
  333. """抽取本文件 define()/const 常量 -> {NAME: (raw_rhs, defining_rel)}。"""
  334. out = {}
  335. for m in RE_DEFINE.finditer(text):
  336. out[m.group(1)] = (m.group(2).strip(), rel)
  337. for m in RE_CONST_DECL.finditer(text):
  338. out[m.group(1)] = (m.group(2).strip(), rel)
  339. return out
  340. def _resolve_segment(seg, const_table, root, seen):
  341. """解析单个拼接段:字面量 / dirname() / 裸常量名。无法静态解析返回 None。"""
  342. seg = seg.strip()
  343. if not seg:
  344. return ''
  345. sm = re.match(r"^['\"]([^'\"]*)['\"]$", seg)
  346. if sm:
  347. return sm.group(1)
  348. dm = re.match(r"^dirname\(\s*(.*?)\s*\)$", seg)
  349. if dm:
  350. inner = _resolve_segment(dm.group(1), const_table, root, seen)
  351. if inner is None:
  352. return None
  353. return os.path.dirname(inner)
  354. if re.match(r"^[A-Z_][A-Z0-9_]*$", seg):
  355. return resolve_const(seg, const_table, root, seen)
  356. return None # 含 $ / 函数调用等无法静态解析
  357. def _resolve_const_rhs(raw, defining_rel, const_table, root, seen):
  358. """解析常量 RHS:字面量 / . 拼接链;__DIR__/__FILE__ 用 defining_rel 替换。"""
  359. raw = raw.strip()
  360. sm = re.match(r"^['\"]([^'\"]*)['\"]$", raw)
  361. if sm:
  362. return sm.group(1)
  363. work = raw
  364. if '__DIR__' in work:
  365. work = work.replace('__DIR__',
  366. "'" + os.path.normpath(os.path.join(root,
  367. os.path.dirname(defining_rel))).replace(os.sep, '/') + "'")
  368. if '__FILE__' in work:
  369. work = work.replace('__FILE__',
  370. "'" + os.path.normpath(os.path.join(root,
  371. defining_rel)).replace(os.sep, '/') + "'")
  372. # 按 ` . `(带空格的拼接运算符)切分,避免误切字符串内的点(如 .php)
  373. parts = re.split(r"\s+\.\s+", work)
  374. res = ''
  375. for p in parts:
  376. if p.strip() == '':
  377. continue
  378. seg = _resolve_segment(p, const_table, root, seen)
  379. if seg is None:
  380. return None
  381. res += seg
  382. if res:
  383. res = os.path.normpath(res)
  384. # 解析结果若落在 root 内(绝对),转回相对路径,保持图谱可移植
  385. if os.path.isabs(res) and res.startswith(os.path.abspath(root)):
  386. res = os.path.relpath(res, os.path.abspath(root)).replace(os.sep, '/')
  387. if res == '.':
  388. res = '' # 解析结果恰为项目根 → 空串,避免拼接出 '.extend' 这类错误路径
  389. return res
  390. return None
  391. def resolve_const(name, const_table, root, seen=None):
  392. """递归解析一个 define 常量的值为真实路径片段;循环/未定义/含变量返回 None。"""
  393. if seen is None:
  394. seen = set()
  395. if name in seen:
  396. return None
  397. entry = const_table.get(name)
  398. if not entry:
  399. return None
  400. raw, drel = entry
  401. seen.add(name)
  402. return _resolve_const_rhs(raw, drel, const_table, root, seen)
  403. def parse_composer(root):
  404. """解析 composer.json 的 autoload(psr-4 / psr-0 / files / classmap)。
  405. 返回 {psr4: {prefix: dir}, psr0: {prefix: dir}, files: [rel...], classmap: [dir...]}。
  406. 国内 ThinkPHP/易优/迅睿/FastAdmin/Laravel 等框架靠此把命名空间映射到文件。
  407. """
  408. out = {'psr4': {}, 'psr0': {}, 'files': [], 'classmap': []}
  409. cj = os.path.join(root, 'composer.json')
  410. if not os.path.isfile(cj):
  411. return out
  412. try:
  413. data = json.load(open(cj, encoding='utf-8', errors='ignore'))
  414. except Exception:
  415. return out
  416. al = data.get('autoload', {})
  417. for kind, key in (('psr4', 'psr-4'), ('psr0', 'psr-0')):
  418. for pref, d in (al.get(key) or {}).items():
  419. pref = pref.rstrip('/').rstrip('\\') # A-1:剥尾部反斜杠,否则 fqn_to_file 的 pref+'\\' 拼接多一反斜杠致 startswith 永 False
  420. if isinstance(d, list):
  421. for x in d:
  422. out[kind][pref] = x.rstrip('/').rstrip('\\')
  423. else:
  424. out[kind][pref] = d.rstrip('/').rstrip('\\')
  425. for f in (al.get('files') or []):
  426. out['files'].append(f)
  427. for cm in (al.get('classmap') or []):
  428. out['classmap'].append(cm)
  429. return out
  430. def fqn_to_file(fqn, composer, root):
  431. """按 PSR-4 / PSR-0 把完全限定类名映射到文件路径;找不到返回 None。"""
  432. fqn = fqn.lstrip('\\')
  433. parts = fqn.split('\\')
  434. # PSR-4:最长前缀匹配
  435. for pref in sorted(composer['psr4'], key=len, reverse=True):
  436. if fqn == pref or fqn.startswith(pref + '\\'):
  437. rest = fqn[len(pref):].lstrip('\\').split('\\')
  438. d = composer['psr4'][pref]
  439. rel = os.path.normpath(os.path.join(d, *rest)) + '.php'
  440. if os.path.isfile(os.path.join(root, rel)):
  441. return rel.replace(os.sep, '/')
  442. return rel.replace(os.sep, '/') # 即便文件暂缺也返回预期路径(标悬空)
  443. # PSR-0:每段映射(\ 和 _ 都转目录分隔符,PSR-0 规范)
  444. for pref in sorted(composer['psr0'], key=len, reverse=True):
  445. if fqn == pref or fqn.startswith(pref + '\\'):
  446. rest = fqn[len(pref):].lstrip('\\').replace('\\', '/').replace('_', '/')
  447. rest_parts = [p for p in rest.split('/') if p]
  448. d = composer['psr0'][pref]
  449. rel = os.path.normpath(os.path.join(d, *rest_parts)) + '.php'
  450. if os.path.isfile(os.path.join(root, rel)):
  451. return rel.replace(os.sep, '/')
  452. return rel.replace(os.sep, '/')
  453. return None
  454. def resolve_module(spec, lang, rel, root):
  455. """按语言把 import 说明符解析为项目内文件 rel(或 None):Python/JS(ts)/Java。
  456. 相对路径优先相对源文件;裸说明符回退 node_modules / 包根启发式。
  457. """
  458. spec = spec.strip()
  459. if not spec:
  460. return None
  461. # rel 在 Windows 下是 '/' 分隔,os.path.dirname 不认 '/',须转成本地分隔符
  462. rel_native = rel.replace('/', os.sep)
  463. src_dir = os.path.dirname(rel_native)
  464. src_full = os.path.join(root, src_dir) if src_dir else root
  465. if lang == 'py':
  466. parts = spec.split('.')
  467. if parts and parts[0] == '':
  468. parts = parts[1:] # 去前导点(.models)
  469. cands = []
  470. for base in (src_full, root):
  471. cands.append(os.path.join(base, *parts) + '.py')
  472. cands.append(os.path.join(base, *parts, '__init__.py'))
  473. for c in cands:
  474. if os.path.isfile(c):
  475. return os.path.relpath(c, root).replace(os.sep, '/')
  476. return None
  477. if lang in ('js', 'ts'):
  478. if spec.startswith('./') or spec.startswith('../'):
  479. base = os.path.normpath(os.path.join(src_full, spec))
  480. # 若说明符自身已带扩展名(./styles.css),优先按原样试
  481. if os.path.splitext(spec)[1]:
  482. if os.path.isfile(base):
  483. return os.path.relpath(base, root).replace(os.sep, '/')
  484. for ext in ('.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs', '.json', '.css', '.scss', '.less'):
  485. c = base + ext
  486. if os.path.isfile(c):
  487. return os.path.relpath(c, root).replace(os.sep, '/')
  488. for idx in ('index.js', 'index.ts', 'index.jsx', 'index.tsx'):
  489. c = os.path.join(base, idx)
  490. if os.path.isfile(c):
  491. return os.path.relpath(c, root).replace(os.sep, '/')
  492. return None
  493. # 裸说明符:node_modules 启发式
  494. nm_dirs = [os.path.join(root, 'node_modules')]
  495. if src_dir:
  496. nm_dirs.append(os.path.join(root, src_dir, 'node_modules'))
  497. for nm in (spec, spec.lstrip('@')):
  498. for top in nm_dirs:
  499. c = os.path.join(top, nm)
  500. for entry in ('index.js', 'index.ts', 'index.jsx', 'index.tsx', 'src/index.js', 'src/index.ts'):
  501. if os.path.isfile(os.path.join(c, entry)):
  502. return os.path.relpath(os.path.join(c, entry), root).replace(os.sep, '/')
  503. return None
  504. if lang == 'java':
  505. parts = spec.split('.')
  506. rest = parts[:-1] if spec.endswith('.*') else parts
  507. relp = os.path.normpath(os.path.join(*rest)) + '.java'
  508. c = os.path.join(root, relp)
  509. if os.path.isfile(c):
  510. return os.path.relpath(c, root).replace(os.sep, '/')
  511. # 包路径相对源码根(com/x/Y.java 可能位于 root 下任意 java/ 子目录),递归搜索
  512. for dp, _, fns in os.walk(root):
  513. if any(x in dp for x in _EXCLUDE_DIRS):
  514. continue
  515. cand = os.path.join(dp, relp)
  516. if os.path.isfile(cand):
  517. return os.path.relpath(cand, root).replace(os.sep, '/')
  518. return None
  519. return None
  520. def resolve_tp_import(spec, root, const_table):
  521. """解析 ThinkPHP import()/vendor()/Loader::import()(官方规则 thinkphp.cn/info/126)。
  522. 点号转目录;基库前缀映射(依赖项目定义的常量,经 resolve_const 解析):
  523. Think.* -> THINK_PATH/Lib/* (去 Think 前缀,后缀 .class.php)
  524. ORG.* -> EXTEND_PATH/Library/ORG/* (保留 ORG 段,后缀 .class.php)
  525. Com.* -> EXTEND_PATH/Library/Com/* (保留 Com 段,后缀 .class.php)
  526. Vendor.* -> VENDOR_PATH/* (去 Vendor 前缀;vendor() 调用已补 Vendor.)
  527. @.* -> APP_PATH/* (去 @ 前缀,后缀 .class.php)
  528. 其它(项目类库,如 Common.Tool/MyApp.Action.User) -> APP_PATH/* 完整点路径(.class.php 优先,缺失回退 .php)
  529. 别名导入(单段无点,如 import('rbac')) 需 alias.php 映射,静态难穷举,留口 None。
  530. """
  531. spec = spec.strip()
  532. if not spec or '.' not in spec:
  533. return None # 别名/多参仅首参带点形式留口
  534. parts = spec.split('.')
  535. head = parts[0]
  536. if spec.startswith('@'):
  537. base = resolve_const('APP_PATH', const_table, root) or 'application'
  538. parts = parts[1:] # 去 @ 别名段
  539. suffix = '.class.php'
  540. elif head == 'Think':
  541. tp = resolve_const('THINK_PATH', const_table, root) or \
  542. resolve_const('LIB_PATH', const_table, root) or 'ThinkPHP'
  543. base = (tp.rstrip('/') + '/Lib') if tp else 'ThinkPHP/Lib'
  544. parts = parts[1:] # 去 Think 前缀
  545. suffix = '.class.php'
  546. elif head in ('ORG', 'Com'):
  547. ep = resolve_const('EXTEND_PATH', const_table, root) or 'Extend/Library'
  548. base = (ep.rstrip('/') + '/Library') if ep else 'Extend/Library'
  549. # 保留 ORG/Com 段(EXTEND_PATH/Library/ORG/...);用首段精确匹配避免 Common 误命中 Com
  550. suffix = '.class.php'
  551. elif head == 'Vendor':
  552. vp = resolve_const('VENDOR_PATH', const_table, root) or 'Vendor'
  553. base = vp.rstrip('/') if vp else 'Vendor'
  554. parts = parts[1:] # 去 Vendor 前缀(base 已是 VENDOR_PATH)
  555. suffix = '.class.php'
  556. else: # 项目/应用类库:完整点路径映射目录(Common.Tool 不会误命中 Com 前缀)
  557. base = resolve_const('APP_PATH', const_table, root) or 'application'
  558. suffix = '.class.php'
  559. if not base or not parts:
  560. return None
  561. relp = os.path.normpath(os.path.join(base, *parts)) + suffix
  562. c = os.path.join(root, relp)
  563. if os.path.isfile(c):
  564. return os.path.relpath(c, root).replace(os.sep, '/')
  565. # 项目类库非标准后缀回退 .php(import('X', APP_PATH, '.php') 形态)
  566. if suffix == '.class.php':
  567. c2 = os.path.join(root, os.path.normpath(os.path.join(base, *parts)) + '.php')
  568. if os.path.isfile(c2):
  569. return os.path.relpath(c2, root).replace(os.sep, '/')
  570. return None
  571. def extract_file(path, root, const_table=None):
  572. """抽取单文件:返回 {includes, classes, calls, namespace, uses, tpl_includes}。"""
  573. text = ''
  574. try:
  575. text = read_text(path)
  576. except Exception:
  577. return {'includes': [], 'classes': [], 'calls': [], 'namespace': None,
  578. 'uses': [], 'tpl_includes': []}
  579. rel = os.path.relpath(path, root).replace(os.sep, '/')
  580. # include/require 是 PHP 专属语法;其余语言走各自模块导入机制,禁止用 PHP 正则误抽(否则 JS `src:` 等成噪声边)。
  581. PHP_EXTS = ('.php', '.inc', '.phtml', '.php3', '.php4', '.php5')
  582. # B-?:符号抽取须在剥离 PHP 注释后进行(避免注释/死代码里的 `// class X` 被当真实符号生成假边)。
  583. # extract_includes 内部已自剥离;此处对非 include 的符号层统一前置剥离(仅 PHP 文件,非 PHP 仍用原文)。
  584. code = strip_php_comments(text) if rel.endswith(PHP_EXTS) else text
  585. includes = extract_includes(text, rel, root, const_table) if rel.endswith(PHP_EXTS) else []
  586. # PHP 专属符号抽取(namespace/use/class/function/calls/new/::/FQN/tpimport)仅对 PHP 运行;
  587. # 非 PHP 文件这些字段置空,禁止 .py/.js/.ts/.java 被 PHP 正则误抽污染符号表(ERR-006 同族)。
  588. if rel.endswith(PHP_EXTS):
  589. ns_m = RE_NAMESPACE.search(code)
  590. namespace = ns_m.group(1) if ns_m else None
  591. uses = [{'fqn': m.group(1), 'alias': m.group(2)} for m in RE_USE.finditer(code)]
  592. classes = []
  593. for m in RE_CLASS[0].finditer(code):
  594. cls = m.group(1)
  595. ext = m.group(2)
  596. impl = m.group(3)
  597. classes.append({'name': cls, 'line': code[:m.start()].count('\n') + 1,
  598. 'extends': ext, 'implements': impl})
  599. funcs = []
  600. for m in RE_CLASS[1].finditer(code):
  601. funcs.append(m.group(1))
  602. # calls:new X() / X::const(跨文件才记,规划 §4 敏感边约束)
  603. # 注:$obj->method() 抽不出类名(变量非类型,静态分析边界),属动态调用漏抽,
  604. # 由 G2' grep 复核兜底;保留 new/:: 两类可静态解析类名的调用。
  605. calls = []
  606. for m in re.finditer(r"new\s+(\w+)\s*\(", code):
  607. calls.append(m.group(1))
  608. for m in re.finditer(r"(\w+)::\w+", code):
  609. calls.append(m.group(1))
  610. # new \Ns\Class(框架类实例化,composer 自动加载)
  611. new_fqns = [m.group(1) for m in RE_NEW_FQN.finditer(code)]
  612. # ThinkPHP 显式类库导入 import()/vendor()/Loader::import()(TP3/TP5 官方机制)
  613. tp_imports = [(m.group(1), m.group(2)) for m in RE_TP_IMPORT.finditer(code)]
  614. else:
  615. namespace = None
  616. uses = []
  617. classes = []
  618. funcs = []
  619. calls = []
  620. new_fqns = []
  621. tp_imports = []
  622. # 模板 include 标签(帝国/Dede/Discuz/易优 模板依赖;CMS 模板标签非 PHP 语法,保留原扫描 text)
  623. tpl_includes = []
  624. for m in RE_TPL_INCLUDE.finditer(text):
  625. tpl_includes.append(m.group(1))
  626. for m in RE_TPL_REQUIRE.finditer(text):
  627. tpl_includes.append(m.group(1))
  628. # 非 PHP 语言模块导入(Django/Flask/Spring/React/Vue/...)
  629. lang = None
  630. if rel.endswith('.py'):
  631. lang = 'py'
  632. elif rel.endswith(('.js', '.jsx', '.mjs', '.cjs')):
  633. lang = 'js'
  634. elif rel.endswith(('.ts', '.tsx')):
  635. lang = 'ts'
  636. elif rel.endswith('.java'):
  637. lang = 'java'
  638. module_imports = []
  639. if lang == 'py':
  640. for m in RE_PY_IMPORT.finditer(text):
  641. module_imports.append(m.group(1) or m.group(2))
  642. elif lang in ('js', 'ts'):
  643. for m in RE_JS_REQUIRE.finditer(text):
  644. module_imports.append(m.group(1))
  645. for m in RE_JS_IMPORT.finditer(text):
  646. module_imports.append(m.group(1))
  647. for m in RE_JS_DYNAMIC_IMPORT.finditer(text):
  648. module_imports.append(m.group(1))
  649. elif lang == 'java':
  650. java_pkg = RE_JAVA_PKG.search(text)
  651. for m in RE_JAVA_IMPORT.finditer(text):
  652. module_imports.append(m.group(1))
  653. # CSS @import 依赖(指向其它样式文件,真实样式级联依赖)
  654. css_imports = []
  655. if rel.endswith(('.css', '.scss', '.less')):
  656. for m in RE_CSS_IMPORT.finditer(text):
  657. css_imports.append(m.group(1))
  658. # HTML <link href> / <script src> 资源依赖(样式表与脚本,排除图片等噪声)
  659. # .php 亦输出 HTML(CMS 模板普遍在 .php 中直接写 <link>/<script>)
  660. html_assets = []
  661. if rel.endswith(('.php', '.htm', '.html')):
  662. for m in RE_HTML_LINK.finditer(text):
  663. if m.group(1).endswith(('.css', '.scss', '.less', '.js', '.jsx',
  664. '.ts', '.tsx', '.mjs', '.cjs')):
  665. html_assets.append(m.group(1))
  666. for m in RE_HTML_SCRIPT.finditer(text):
  667. if m.group(1).endswith(('.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs')):
  668. html_assets.append(m.group(1))
  669. return {'rel': rel, 'includes': includes, 'classes': classes,
  670. 'funcs': funcs, 'calls': calls, 'namespace': namespace,
  671. 'uses': uses, 'tpl_includes': tpl_includes, 'new_fqns': new_fqns,
  672. 'lang': lang, 'module_imports': module_imports, 'tp_imports': tp_imports,
  673. 'css_imports': css_imports, 'html_assets': html_assets}
  674. def _acquire_lock(root, max_wait=10, stale_timeout=60):
  675. """获取构建锁,防多进程并发构建。等待 max_wait 秒,超 stale_timeout 的锁视为僵尸强制接管。"""
  676. lock_path = os.path.join(root, '.ai-memory', 'knowledge-graph', '.lock')
  677. for _ in range(max_wait):
  678. if os.path.isfile(lock_path):
  679. try:
  680. age = time.time() - os.path.getmtime(lock_path)
  681. if age > stale_timeout:
  682. break # 僵尸锁,强制接管
  683. except Exception:
  684. break
  685. time.sleep(1)
  686. else:
  687. break
  688. try:
  689. os.makedirs(os.path.dirname(lock_path), exist_ok=True)
  690. with open(lock_path, 'w') as f:
  691. f.write(str(os.getpid()))
  692. return True
  693. except Exception:
  694. return False
  695. def _release_lock(root):
  696. """释放构建锁。"""
  697. lock_path = os.path.join(root, '.ai-memory', 'knowledge-graph', '.lock')
  698. try:
  699. if os.path.isfile(lock_path):
  700. os.remove(lock_path)
  701. except Exception:
  702. pass
  703. def build(root):
  704. """全量构建。返回 (graph, meta, symbols)。"""
  705. out_dir = os.path.join(root, '.ai-memory', 'knowledge-graph')
  706. _acquire_lock(root) # 并发锁:防多进程同时构建(超 60s 僵尸锁强制接管)
  707. os.makedirs(out_dir, exist_ok=True)
  708. files = []
  709. for dirpath, dirnames, filenames in os.walk(root):
  710. dirnames[:] = [d for d in dirnames if d not in _EXCLUDE_DIRS
  711. and not d.startswith('.')]
  712. for fn in filenames:
  713. if fn.endswith(SRC_EXTS):
  714. files.append(os.path.join(dirpath, fn))
  715. file_list = [os.path.relpath(p, root).replace(os.sep, '/') for p in files]
  716. lsp = detect_lsp(root)
  717. # 常量预扫描(支持 define()/const 前缀拼路径的国内 CMS/框架)
  718. CONST_TABLE = {}
  719. for f, p in sorted(zip(file_list, files)): # 常量预扫描按 rel 排序,避免同常量多定义时解析随遍历顺序漂移(B-1)
  720. try:
  721. txt = read_text(p)
  722. except Exception:
  723. continue
  724. for nm, val in extract_defines(txt, f).items():
  725. CONST_TABLE[nm] = val # 后定义覆盖
  726. extracted = {f: extract_file(p, root, CONST_TABLE) for f, p in zip(file_list, files)}
  727. COMPOSER = parse_composer(root)
  728. nodes = {} # rel -> {type, classes, funcs}
  729. edges = [] # {from, to, kind}
  730. edge_keys = set() # 边去重键集合(B-4):(from, to, kind) 精确去重,避免重复 require 产生重复边
  731. symbols = {} # name -> {file, line}
  732. fqn_symbols = {} # FQN -> file(命名空间类,覆盖 composer 框架)
  733. dangling = []
  734. orphan_symbols = []
  735. include_total = 0
  736. include_valid = 0
  737. # 第一轮:建符号表(含命名空间 FQN)
  738. for rel, ex in extracted.items():
  739. ns = ex['namespace']
  740. for c in ex['classes']:
  741. symbols[c['name']] = {'file': rel, 'line': c['line']}
  742. if ns:
  743. fqn_symbols[(ns + '\\' + c['name']).lower()] = rel
  744. nodes[rel] = {'file': rel, 'classes': [c['name'] for c in ex['classes']],
  745. 'funcs': ex['funcs']}
  746. def add_edge_if_exists(rel, target_rel, kind, symbol=None):
  747. """已存在则加 include 边;否则记悬空(不进图)。"""
  748. nonlocal include_total, include_valid, edge_keys
  749. # define() 常量链解析可能产生项目内绝对路径 → 归一为相对 rel(保证图谱可移植、query 可遍历)
  750. if os.path.isabs(target_rel):
  751. normed = os.path.normpath(target_rel) # 统一分隔符后再判项目内(Windows 下正斜杠/反斜杠)
  752. if normed.startswith(os.path.abspath(root)):
  753. target_rel = os.path.relpath(normed, os.path.abspath(root)).replace(os.sep, '/')
  754. else:
  755. return # 项目外绝对路径,不纳入图谱
  756. if kind == 'include':
  757. include_total += 1
  758. if target_rel and os.path.exists(os.path.join(root, target_rel)):
  759. if any(d in target_rel.split('/') for d in _EXCLUDE_DIRS):
  760. return
  761. if kind == 'include':
  762. include_valid += 1
  763. key = (rel, target_rel, kind)
  764. if key in edge_keys:
  765. return
  766. edge_keys.add(key)
  767. e = {'from': rel, 'to': target_rel, 'kind': kind}
  768. if symbol:
  769. e['symbol'] = symbol
  770. edges.append(e)
  771. else:
  772. dangling.append({'from': rel, 'raw': target_rel, 'resolved': target_rel,
  773. 'base': 'ns' if kind != 'include' else 'src'})
  774. for rel, ex in extracted.items():
  775. for c in ex['classes']:
  776. if c.get('extends'):
  777. ext = c['extends']
  778. # extends 可能是短名(同文件/全局)或 FQN
  779. tgt = None
  780. if '\\' in ext:
  781. tgt = fqn_symbols.get(ext.lower()) or fqn_to_file(ext, COMPOSER, root)
  782. elif ext in symbols:
  783. tgt = symbols[ext]['file']
  784. if tgt:
  785. add_edge_if_exists(rel, tgt, 'extends')
  786. for inc in ex['includes']:
  787. add_edge_if_exists(rel, inc.replace(os.sep, '/'), 'include')
  788. # use 导入解析(composer PSR-4 框架核心机制)
  789. for u in ex['uses']:
  790. fqn = u['fqn']
  791. tgt = fqn_symbols.get(fqn.lower()) or fqn_to_file(fqn, COMPOSER, root)
  792. if tgt:
  793. add_edge_if_exists(rel, tgt, 'use', symbol=fqn)
  794. # new \Ns\Class / \Ns\Class:: (框架类实例化/静态调用)
  795. for fqn in ex['new_fqns']:
  796. tgt = fqn_symbols.get(fqn.lower()) or fqn_to_file(fqn, COMPOSER, root)
  797. if tgt and tgt != rel:
  798. add_edge_if_exists(rel, tgt, 'autoload', symbol=fqn)
  799. # ThinkPHP import()/vendor() 显式类库导入(TP3/TP5,官方 thinkphp.cn/info/126)
  800. for func, spec in ex['tp_imports']:
  801. if func == 'vendor':
  802. spec = 'Vendor.' + spec # vendor() 说明符隐含 Vendor. 前缀
  803. tgt = resolve_tp_import(spec, root, CONST_TABLE)
  804. if tgt and tgt != rel:
  805. add_edge_if_exists(rel, tgt, 'tpimport', symbol=spec)
  806. # 非 PHP 语言模块导入(Django/Flask/Spring/React/Vue/Angular/Next/Nest/Strapi)
  807. if ex['lang']:
  808. for spec in ex['module_imports']:
  809. tgt = resolve_module(spec, ex['lang'], rel, root)
  810. if tgt and tgt != rel:
  811. add_edge_if_exists(rel, tgt, 'import', symbol=spec)
  812. # 模板 {include file=}(帝国/Dede/Discuz/易优 模板依赖)
  813. for tpl in ex['tpl_includes']:
  814. trel = os.path.normpath(tpl.lstrip('/'))
  815. if not (trel.startswith('/') or re.match(r'^[A-Za-z]:', trel)):
  816. trel = os.path.normpath(os.path.join(os.path.dirname(rel), trel))
  817. add_edge_if_exists(rel, trel.replace(os.sep, '/'), 'template')
  818. # CSS @import 依赖(样式级联,指向其它样式文件)
  819. for imp in ex['css_imports']:
  820. trel = os.path.normpath(os.path.join(os.path.dirname(rel), imp.lstrip('/')))
  821. add_edge_if_exists(rel, trel.replace(os.sep, '/'), 'cssimport')
  822. # HTML <link href> / <script src> 资源依赖(样式表与脚本)
  823. for a in ex['html_assets']:
  824. if a.startswith(('/', 'http://', 'https://', '//', '#', 'data:')):
  825. continue # 绝对/外链不进项目图
  826. trel = os.path.normpath(os.path.join(os.path.dirname(rel), a))
  827. add_edge_if_exists(rel, trel.replace(os.sep, '/'), 'asset')
  828. for call in ex['calls']:
  829. # 跨文件调用边(仅当目标符号在别处定义)
  830. if call in symbols and symbols[call]['file'] != rel:
  831. key = (rel, symbols[call]['file'], 'calls')
  832. if key not in edge_keys:
  833. edge_keys.add(key)
  834. edges.append({'from': rel, 'to': symbols[call]['file'],
  835. 'kind': 'calls', 'symbol': call})
  836. # 孤儿检测(规划 §5.6② 仅告警不裁决):定义了但无人 extends/use/calls 引用的符号
  837. referenced = set()
  838. for ex in extracted.values():
  839. for c in ex['classes']:
  840. if c.get('extends'):
  841. referenced.add(c['extends'].lstrip('\\').split('\\')[-1].lower())
  842. for u in ex['uses']:
  843. referenced.add(u.split('\\')[-1].lower())
  844. for call in ex['calls']:
  845. referenced.add(call.lower())
  846. seen_orphan = set()
  847. for name, loc in symbols.items():
  848. short = name.split('\\')[-1].lower()
  849. if short not in referenced:
  850. key = (loc['file'], loc.get('line', 0), short)
  851. if key not in seen_orphan:
  852. seen_orphan.add(key)
  853. orphan_symbols.append({'name': name, 'file': loc['file'], 'line': loc.get('line', 0)})
  854. graph = {'nodes': nodes, 'edges': edges}
  855. meta = {
  856. 'built_at': __import__('datetime').datetime.now().isoformat(),
  857. 'tool_version': '1.0.0',
  858. 'root': root,
  859. 'file_hashes': {f: file_hash(os.path.join(root, f)) for f in file_list},
  860. 'file_mtimes': {f: _safe_mtime(os.path.join(root, f)) for f in file_list},
  861. 'lsp_available': lsp,
  862. 'accuracy_report': {
  863. 'edges_total': len(edges),
  864. 'edges_valid': len([e for e in edges if os.path.exists(
  865. os.path.join(root, e['to']))]) if edges else 0,
  866. 'edges_dangling': len(dangling),
  867. 'symbols_total': len(symbols),
  868. 'symbols_resolved': len(symbols),
  869. 'symbols_orphan': len(orphan_symbols),
  870. 'static_coverage': round(include_valid / include_total, 3) if include_total else 1.0,
  871. 'include_total': include_total,
  872. 'include_valid': include_valid,
  873. 'lsp_available': lsp,
  874. },
  875. 'dangling_edges': dangling,
  876. 'orphan_symbols': orphan_symbols,
  877. 'script_hash': file_hash(os.path.abspath(__file__)),
  878. # composer.json 哈希:autoload 映射改了 must 重建(extends/use 边依赖 PSR-4 映射)
  879. 'composer_hash': file_hash(os.path.join(root, 'composer.json'))
  880. if os.path.isfile(os.path.join(root, 'composer.json')) else None,
  881. }
  882. # 原子写(规划 §5.5)
  883. _atomic_write(os.path.join(out_dir, 'graph.json'), graph)
  884. _atomic_write(os.path.join(out_dir, 'meta.json'), meta)
  885. _atomic_write(os.path.join(out_dir, 'symbols.json'), symbols)
  886. _write_markdown(os.path.join(out_dir, 'graph.md'), graph, root)
  887. print(f"[GRAPH-ACCURACY] 有效边 {meta['accuracy_report']['edges_valid']} / "
  888. f"悬空 {meta['accuracy_report']['edges_dangling']}(已剔除)/ "
  889. f"include解析率 {meta['accuracy_report']['static_coverage']} / LSP: "
  890. f"{','.join(k for k, v in lsp.items() if v) or 'none'}")
  891. _release_lock(root)
  892. return graph, meta, symbols
  893. def _atomic_write(path, obj):
  894. tmp = path + '.tmp'
  895. with open(tmp, 'w', encoding='utf-8') as f:
  896. json.dump(obj, f, ensure_ascii=False, indent=2)
  897. os.replace(tmp, path)
  898. def _write_markdown(path, graph, root):
  899. """graph.md 人类兜底(agent 不加载,规划 §10)。"""
  900. lines = ['# 项目结构图谱(人类可读兜底,agent 不加载)', '']
  901. for rel, n in graph['nodes'].items():
  902. lines.append(f"- {rel} [classes: {', '.join(n['classes']) or '-'}]")
  903. lines.append('')
  904. lines.append('## 边')
  905. for e in graph['edges']:
  906. lines.append(f"- {e['from']} --{e['kind']}--> {e['to']}")
  907. with open(path, 'w', encoding='utf-8') as f:
  908. f.write('\n'.join(lines))
  909. def query(graph, meta, symbols, root, q, direction='both', depth=2, all_cycles=False, max_nodes=80):
  910. """§9 查询接口 + 环检测,返回裁剪子图。
  911. all_cycles: False=仅检测 direction 可达闭包内的环(与「只警告已遍历子图」一致,
  912. down=依赖闭包/up=被依赖闭包互不越界);True=对全图做有向 DFS 环检测,任意查询全局预警循环依赖
  913. (--direction down 也能报依赖间环)。默认行为不变,E2E-8 不破坏。
  914. """
  915. # 解析入口
  916. entry = None
  917. if q in graph['nodes']:
  918. entry = q
  919. elif q in symbols:
  920. entry = symbols[q]['file']
  921. else:
  922. # 尝试按文件名匹配
  923. for rel in graph['nodes']:
  924. if rel.endswith('/' + q) or rel == q:
  925. entry = rel
  926. break
  927. if not entry:
  928. print(f"[GRAPH-QUERY] 符号索引缺失:{q}(返回空子图,未做全图扫描)")
  929. return {'nodes': {}, 'edges': [], 'cycle': 0}
  930. # 构建邻接(按 direction)+ 平行边 kind 聚合(同 from→to 多种 kind 全保留),(from,to) 去重防重复遍历。
  931. adj = {'up': {}, 'down': {}}
  932. edge_kind_map = {} # (from,to) -> 该有向边的全部 kind 列表(平行边聚合)
  933. seen_pair = set()
  934. for e in graph['edges']:
  935. key = (e['from'], e['to'])
  936. if key not in seen_pair:
  937. seen_pair.add(key)
  938. if e['to'] not in adj['down'].setdefault(e['from'], []):
  939. adj['down'][e['from']].append(e['to'])
  940. if e['from'] not in adj['up'].setdefault(e['to'], []):
  941. adj['up'][e['to']].append(e['from'])
  942. edge_kind_map.setdefault(key, [])
  943. if e['kind'] not in edge_kind_map[key]:
  944. edge_kind_map[key].append(e['kind'])
  945. MAX_NODES = max_nodes # 节点预算硬上限(规划 §9 省 token:防枢纽模块 2 跳爆炸)
  946. visited = set() # 全局去重:该节点已纳入子图(不论是否在当前递归路径)
  947. stack = set() # 当前递归路径(仅用于真环判定的回边检测)
  948. subgraph_nodes = {}
  949. subgraph_edges = []
  950. cycles = [0]
  951. seen_cycle = set() # 环边按 (from,to) 去重,仅保留一条(规划 §9「环只保留一条边」)
  952. # 环检测:在「有向(down)图」独立 DFS 回边计数,与查询方向解耦,保证守恒②③①:
  953. # ② 回边须沿 authored 方向(down)闭合,禁止 both/up 反向命中祖先的伪回边(菱形 DAG 假阳性);
  954. # ① 自环 A→A 在有向 DFS 中只计 1 次(旧 both 模式 up/down 邻接各含 A 会计 2)。
  955. # 范围=direction 可达闭包(非仅 entry 的 down 集),否则 up/both 漏检 entry 纯上游环。
  956. def _reachable(entry, direction):
  957. dirs = []
  958. if direction in ('up', 'both'):
  959. dirs.append('up')
  960. if direction in ('down', 'both'):
  961. dirs.append('down')
  962. seen = {entry}
  963. stack = [entry]
  964. while stack:
  965. u = stack.pop()
  966. for dr in dirs:
  967. for v in adj[dr].get(u, []):
  968. if v not in seen:
  969. seen.add(v)
  970. stack.append(v)
  971. return seen
  972. def detect_cycles(starts, allowed):
  973. vis = set(); st = set(); pairs = []
  974. def dfs(u):
  975. vis.add(u); st.add(u)
  976. for v in adj['down'].get(u, []):
  977. if v not in allowed: # 不走出查询遍历范围,避免把范围外下游环算进来
  978. continue
  979. if v in st:
  980. pairs.append((u, v)) # u→v 沿 authored 方向闭合环
  981. elif v not in vis:
  982. dfs(v)
  983. st.discard(u)
  984. for s in starts:
  985. if s not in vis:
  986. dfs(s)
  987. return pairs
  988. # 全局环检测模式:起点/范围=全图节点(不受 direction 闭包限制);默认模式=遍历闭包
  989. if all_cycles:
  990. cyc_scope = set(graph['nodes'])
  991. else:
  992. cyc_scope = _reachable(entry, direction)
  993. cyc_pairs = detect_cycles(cyc_scope, cyc_scope)
  994. cyc_set = set(cyc_pairs)
  995. cycles[0] = len(cyc_pairs)
  996. def walk(node, d, came_from=None, came_dir=None):
  997. if node in visited:
  998. return
  999. visited.add(node)
  1000. stack.add(node)
  1001. if node in graph['nodes']:
  1002. subgraph_nodes[node] = graph['nodes'][node]
  1003. if d >= depth or len(subgraph_nodes) >= MAX_NODES:
  1004. stack.discard(node)
  1005. return
  1006. dirs = []
  1007. if direction in ('up', 'both'):
  1008. dirs.append('up')
  1009. if direction in ('down', 'both'):
  1010. dirs.append('down')
  1011. for dr in dirs:
  1012. for nxt in adj[dr].get(node, []):
  1013. if len(subgraph_nodes) >= MAX_NODES:
  1014. return
  1015. # both 模式"刚走过的同一条边的逆向遍历"是伪环(A→B 再 up 回 A);
  1016. # 仅 nxt==came_from 且 dr!=came_dir 才跳过。真 2 节点互环两向相同(dr==came_dir),不算伪环。
  1017. if nxt == came_from and dr != came_dir:
  1018. continue
  1019. # 仅「有向图 DFS 真回边」(node,nxt)∈cyc_set 且在当前路径上才算环;
  1020. # 菱形/重复边目标节点虽 visited 但非 stack 上的 down 回边→不计。
  1021. if nxt in stack and dr == 'down' and (node, nxt) in cyc_set:
  1022. # 环边引用的祖先节点可能不在子图节点集,补入避免人类兜底 graph.md 引用缺失(B-2)
  1023. if nxt in graph['nodes']:
  1024. subgraph_nodes.setdefault(nxt, graph['nodes'][nxt])
  1025. if (node, nxt) not in seen_cycle:
  1026. seen_cycle.add((node, nxt))
  1027. subgraph_edges.append({'from': node, 'to': nxt,
  1028. 'kind': 'cycle'})
  1029. continue
  1030. # 平行边:取 (node,nxt) 或 (nxt,node) 的全部 kind,逐条产出(不覆盖)
  1031. kinds = edge_kind_map.get((node, nxt)) or edge_kind_map.get(
  1032. (nxt, node)) or ['include']
  1033. for k in kinds:
  1034. subgraph_edges.append({'from': node, 'to': nxt, 'kind': k})
  1035. walk(nxt, d + 1, came_from=node, came_dir=dr)
  1036. stack.discard(node)
  1037. walk(entry, 0)
  1038. if cycles[0]:
  1039. print(f"[GRAPH-CYCLE] 检测到 {cycles[0]} 个环(已在子图中折叠)")
  1040. return {'nodes': subgraph_nodes, 'edges': subgraph_edges, 'cycle': cycles[0]}
  1041. def _graph_files_present(root):
  1042. """B1:图谱三件套(graph/meta/symbols)是否齐全。任一缺失 → query 入口须重建,避免 open 崩溃。"""
  1043. kg = os.path.join(root, '.ai-memory', 'knowledge-graph')
  1044. return all(os.path.isfile(os.path.join(kg, f))
  1045. for f in ('graph.json', 'meta.json', 'symbols.json'))
  1046. def need_rebuild(root):
  1047. """规划 §6 新鲜度:比对 mtime+内容哈希。
  1048. 返回 (stale, changed_files):
  1049. - changed_files: set of changed file rels(增量提示),None 表示需全量重建
  1050. (脚本升级/composer.json 变更/文件删除/产物缺失等无法增量的情况)。
  1051. 注意:即便返回 changed_files,build 仍全量 extract——因 CONST_TABLE 全局依赖
  1052. (改 A.php 的 define 会影响 B.php 的 include 解析),extract 增量风险过高,留作 v2。
  1053. """
  1054. meta_path = os.path.join(root, '.ai-memory', 'knowledge-graph', 'meta.json')
  1055. if not os.path.exists(meta_path):
  1056. return True, None
  1057. try:
  1058. with open(meta_path, encoding='utf-8') as f:
  1059. meta = json.load(f)
  1060. except Exception:
  1061. return True, None
  1062. changed_files = set()
  1063. current_rels = set() # B3:记录现存源文件,检测"删除"
  1064. mtimes = meta.get('file_mtimes', {}) # 廉价 mtime 前置过滤:mtime 未变则内容必未变
  1065. for dirpath, dirnames, filenames in os.walk(root):
  1066. dirnames[:] = [d for d in dirnames if d not in _EXCLUDE_DIRS
  1067. and not d.startswith('.')]
  1068. for fn in filenames:
  1069. if fn.endswith(SRC_EXTS):
  1070. p = os.path.join(dirpath, fn)
  1071. rel = os.path.relpath(p, root).replace(os.sep, '/')
  1072. current_rels.add(rel)
  1073. # mtime 一致(旧版无 file_mtimes 时 mtimes={} → 回退全量 md5)
  1074. if rel in mtimes and mtimes[rel] == _safe_mtime(p):
  1075. continue
  1076. h = file_hash(p)
  1077. if rel not in meta.get('file_hashes', {}) or meta['file_hashes'][rel] != h:
  1078. changed_files.add(rel)
  1079. # B3:meta 记录的文件已不在当前树(被删)→ 无法增量,返回 None
  1080. if set(meta.get('file_hashes', {})) - current_rels:
  1081. return True, None
  1082. # 脚本版本/composer.json 变化:无法增量,返回 None
  1083. if meta.get('script_hash') != file_hash(os.path.abspath(__file__)):
  1084. return True, None
  1085. cj = os.path.join(root, 'composer.json')
  1086. if os.path.isfile(cj):
  1087. if file_hash(cj) != meta.get('composer_hash'):
  1088. return True, None
  1089. elif meta.get('composer_hash') is not None:
  1090. return True, None
  1091. return len(changed_files) > 0, changed_files
  1092. def main():
  1093. ap = argparse.ArgumentParser()
  1094. ap.add_argument('--root', default=os.getcwd())
  1095. ap.add_argument('--query', help='入口 file 或 symbol,多个逗号分隔合并子图')
  1096. ap.add_argument('--direction', default='both', choices=['up', 'down', 'both'])
  1097. ap.add_argument('--depth', type=int, default=2)
  1098. ap.add_argument('--rebuild', action='store_true', help='强制全量重建')
  1099. ap.add_argument('--no-rebuild', action='store_true',
  1100. help='跳过新鲜度检测,直接复用已缓存图谱(仅在确认图谱新鲜的连续查询时使用)')
  1101. ap.add_argument('--all-cycles', action='store_true',
  1102. help='全局环检测:对任意查询都全图 DFS 预警循环依赖(不受 --direction 闭包限制),默认关')
  1103. ap.add_argument('--exclude', default=None,
  1104. help='追加排除目录(逗号分隔),合并到默认排除 + .graphignore')
  1105. ap.add_argument('--max-nodes', type=int, default=80,
  1106. help='查询子图节点预算硬上限(默认 80,防枢纽模块爆炸)')
  1107. ap.add_argument('--selftest', action='store_true',
  1108. help='运行内置 E2E 自检(删文件无悬挂边 + 改文件过期判定),不改交付物')
  1109. args = ap.parse_args()
  1110. root = os.path.abspath(args.root)
  1111. # 初始化运行时排除集(合并默认 + .graphignore + --exclude)
  1112. _EXCLUDE_DIRS.clear()
  1113. _EXCLUDE_DIRS.update(load_exclude_dirs(root, args.exclude))
  1114. if args.selftest:
  1115. import tempfile as _tf
  1116. import shutil as _shutil
  1117. # B-3:在副本运行,避免触碰用户原始交付物(旧版会 rename/改写原文件)
  1118. work = _tf.mkdtemp(prefix='kg_selftest_')
  1119. _shutil.copytree(root, work, dirs_exist_ok=True,
  1120. ignore=_shutil.ignore_patterns('.ai-memory', '.codebuddy', '.git'))
  1121. kg = os.path.join(work, '.ai-memory', 'knowledge-graph')
  1122. def load():
  1123. return (json.load(open(os.path.join(kg, 'graph.json'), encoding='utf-8')),
  1124. json.load(open(os.path.join(kg, 'meta.json'), encoding='utf-8')),
  1125. json.load(open(os.path.join(kg, 'symbols.json'), encoding='utf-8')))
  1126. build(work)
  1127. g, m, s = load()
  1128. # E2E-5: 注释伪符号回归(build_graph 注释缺陷防护,ERR-006)。
  1129. # 构造最小 PHP fixture:真实符号 + 注释/死代码中的伪符号,build 后断言
  1130. # symbols.json 不含伪符号、edges 不含指向伪符号的假 extends/calls 边。
  1131. # 须置于 victim 早返回(非 include 项目会早退)之前,确保任何项目均跑此断言。
  1132. fx_dir = os.path.join(work, '__kg_phantom_fixture__')
  1133. os.makedirs(fx_dir, exist_ok=True)
  1134. fx = os.path.join(fx_dir, 'phantom.php')
  1135. with open(fx, 'w', encoding='utf-8') as _f:
  1136. _f.write(
  1137. "<?php\n"
  1138. "// class KgTestPhantomCls extends RealClass\n" # 行注释伪类
  1139. "// KgTestPhantomHelper::go();\n" # 行注释伪静态调用
  1140. "/* class KgTestPhantomDead {} */\n" # 块注释伪类
  1141. "class RealClass {}\n" # 真实类
  1142. "function realFunc() {}\n" # 真实函数
  1143. "$o = new RealClass();\n" # 真实实例化
  1144. "RealClass::const();\n" # 真实静态调用
  1145. )
  1146. build(work)
  1147. g5, _, s5 = load()
  1148. phantom_names = ('KgTestPhantomCls', 'KgTestPhantomDead', 'KgTestPhantomHelper')
  1149. # 仅断言 fixture 文件自身的 symbols:build() 会扫描 build_graph.py 自身,
  1150. # 其源码内嵌的 fixture 字符串字面量里 `class Xxx` 会被 RE_CLASS 误抽,
  1151. # 故须按文件归属过滤,避免自扫描导致的假阳性(非代码缺陷)。
  1152. fx_rel = '__kg_phantom_fixture__/phantom.php'
  1153. fx_syms = [n for n, loc in s5.items()
  1154. if isinstance(loc, dict) and loc.get('file') == fx_rel]
  1155. phantom_syms = [n for n in phantom_names if n in fx_syms]
  1156. print('[E2E-5] 伪符号命中:', phantom_syms, '(期望 [])')
  1157. assert not phantom_syms, '[E2E-5] 注释伪符号未过滤: %s' % phantom_syms
  1158. # 真实符号须保留(避免 strip 误伤正常代码)。须断言 fixture 文件自身 fx_syms 而非全局 s5:
  1159. # build() 会扫描自身(.py),其内嵌 fixture 字符串里的 `class RealClass` 曾被 RE_CLASS 误抽进 s5 兜底,
  1160. # 用全局 s5 会使本断言"假通过"——即使 fixture 真实符号被误剥,s5 仍有同名符号。
  1161. assert 'RealClass' in fx_syms, '[E2E-5] fixture 真实符号 RealClass 被误剥(fx_syms 缺失)'
  1162. # edges 不得含指向伪符号的 symbol(extends/calls/autoload 均经 symbols 命中,伪符号不在表中则无假边)
  1163. phantom_edges = [e for e in g5['edges']
  1164. if e.get('symbol') in phantom_names]
  1165. print('[E2E-5] 指向伪符号的假边:', len(phantom_edges), '(期望 0)')
  1166. assert not phantom_edges, '[E2E-5] 存在指向注释伪符号的假边'
  1167. print('[E2E-5] 通过:注释伪符号已过滤,真实符号保留')
  1168. # E2E-6: strip_php_comments 须保留换行(ERR-006 同族),否则真实符号行号上移、改动定位失真。
  1169. _s6_in = (
  1170. "<?php\n"
  1171. "class A {}\n" # line 2
  1172. "/* multi\n" # line 3
  1173. " line comment\n" # line 4
  1174. "*/\n" # line 5
  1175. "class B {}\n" # line 6(strip 后仍须在第 6 行)
  1176. )
  1177. _s6_out = strip_php_comments(_s6_in)
  1178. _s6_nl_in = _s6_in.count('\n')
  1179. _s6_nl_out = _s6_out.count('\n')
  1180. print('[E2E-6] 换行数 输入/输出:', _s6_nl_in, '/', _s6_nl_out, '(期望相等)')
  1181. assert _s6_nl_out == _s6_nl_in, \
  1182. '[E2E-6] 块注释删除内部换行致行号位移:去 %d 行' % (_s6_nl_in - _s6_nl_out)
  1183. assert 'class B' in _s6_out, '[E2E-6] 真实符号 B 被块注释误吞'
  1184. print('[E2E-6] 通过:块注释内部换行已保留,行号不位移')
  1185. # E2E-7: 符号抽取语言隔离(issue-2)。非 PHP 文件不得被 PHP 正则误抽;PHP 文件仍须正常抽取。
  1186. _py_f = os.path.join(work, '__kg_lang_iso__.py')
  1187. with open(_py_f, 'w', encoding='utf-8') as _f:
  1188. _f.write("class FakeCls:\n x = new RealCls()\n")
  1189. _js_f = os.path.join(work, '__kg_lang_iso__.js')
  1190. with open(_js_f, 'w', encoding='utf-8') as _f:
  1191. _f.write("class KgTestPhantomCls { go() { this.helper() } }\n")
  1192. _php_f = os.path.join(work, '__kg_lang_iso__.php')
  1193. with open(_php_f, 'w', encoding='utf-8') as _f:
  1194. _f.write("<?php\nclass RealPhpCls {}\n")
  1195. _ex_py = extract_file(_py_f, work)
  1196. _ex_js = extract_file(_js_f, work)
  1197. _ex_php = extract_file(_php_f, work)
  1198. _py_classes = [c['name'] for c in _ex_py['classes']]
  1199. _js_classes = [c['name'] for c in _ex_js['classes']]
  1200. print('[E2E-7] .py 误抽 class:', _py_classes, '(期望 [])')
  1201. print('[E2E-7] .js 误抽 class:', _js_classes, '(期望 [])')
  1202. assert not _py_classes, '[E2E-7] .py 被误抽 PHP 符号: %s' % _py_classes
  1203. assert not _js_classes, '[E2E-7] .js 被误抽 PHP 符号: %s' % _js_classes
  1204. # PHP 抽取未被连带禁用(回归:非 PHP 门禁不能误伤 PHP)
  1205. _php_classes = [c['name'] for c in _ex_php['classes']]
  1206. assert 'RealPhpCls' in _php_classes, \
  1207. '[E2E-7] PHP 真实类未被抽取(门禁误伤): %s' % _php_classes
  1208. print('[E2E-7] 通过:非 PHP 零误抽,PHP 抽取保留')
  1209. # E2E-8: 环检测三守恒回归(历史犯错最多,karpathy 守恒 + ERR-004):
  1210. # ① 计数不重不漏 ② 遍历方向与边有向性一致 ③ 平行边不翻倍。
  1211. # 直接构造图 dict 调 query()(免建文件),覆盖 7 例(菱形DAG/3环/互环2/自环/平行边/3环(up)/上游环(up))。
  1212. def _mk_g(nodes, edges):
  1213. return {'nodes': {n: {} for n in nodes},
  1214. 'edges': [{'from': a, 'to': b, 'kind': 'include'} for a, b in edges]}
  1215. _cyc_cases = [
  1216. # (name, nodes, edges, direction, expect_cycle) — 期望见 karpathy 守恒
  1217. ('菱形DAG(both)', ['A', 'B', 'C', 'D'],
  1218. [('A', 'B'), ('A', 'C'), ('B', 'D'), ('C', 'D')], 'both', 0), # 守恒②:反向遍历命中祖先不得算环
  1219. ('真实3环(both)', ['A', 'B', 'C'],
  1220. [('A', 'B'), ('B', 'C'), ('C', 'A')], 'both', 1), # 守恒①②
  1221. ('互环2(both)', ['A', 'B'],
  1222. [('A', 'B'), ('B', 'A')], 'both', 1), # 守恒①②
  1223. ('自环(both)', ['A'], [('A', 'A')], 'both', 1), # 守恒①:旧实现计 2,须 1
  1224. ('平行边(both)', ['A', 'B'],
  1225. [('A', 'B'), ('A', 'B')], 'both', 0), # 守恒③:平行边不翻倍
  1226. ('真实3环(up)', ['A', 'B', 'C'],
  1227. [('A', 'B'), ('B', 'C'), ('C', 'A')], 'up', 1), # 守恒②:up 模式仍须检出真环
  1228. ('上游环(up)', ['X', 'Y', 'Z'],
  1229. [('Y', 'X'), ('Y', 'Z'), ('Z', 'Y')], 'up', 1), # 守恒②:entry 纯上游 Y↔Z 环须检出(entry 不在环上)
  1230. ]
  1231. for _cn, _nd, _ed, _dir, _exp in _cyc_cases:
  1232. _gr = _mk_g(_nd, _ed)
  1233. _r = query(_gr, {}, {}, work, _nd[0], direction=_dir, depth=5)
  1234. print('[E2E-8] %s 环数: %s (期望 %s)' % (_cn, _r['cycle'], _exp))
  1235. assert _r['cycle'] == _exp, \
  1236. '[E2E-8] %s 环数=%s 期望 %s(守恒违反)' % (_cn, _r['cycle'], _exp)
  1237. print('[E2E-8] 通过:环检测三守恒(不重不漏/方向一致/平行边不翻倍)均满足')
  1238. # E2E-9: 全局环检测开关(改进项1)。图 A↔B 成环、A→X(X 下游汇点);
  1239. # entry=X --direction down 默认只遍历闭包 {X} → 0 环;--all-cycles 全图 DFS → 命中 A↔B → 1 环。
  1240. _g9 = _mk_g(['A', 'B', 'X'], [('A', 'B'), ('B', 'A'), ('A', 'X')])
  1241. _r9_def = query(_g9, {}, {}, work, 'X', direction='down', depth=5, all_cycles=False)
  1242. print('[E2E-9] 默认(down,entry=X) 环数: %s (期望 0)' % _r9_def['cycle'])
  1243. assert _r9_def['cycle'] == 0, '[E2E-9] 默认模式误报闭包外环: %s' % _r9_def['cycle']
  1244. _r9_all = query(_g9, {}, {}, work, 'X', direction='down', depth=5, all_cycles=True)
  1245. print('[E2E-9] 全局(--all-cycles,down,entry=X) 环数: %s (期望 1)' % _r9_all['cycle'])
  1246. assert _r9_all['cycle'] == 1, '[E2E-9] 全局模式漏报全图环: %s' % _r9_all['cycle']
  1247. print('[E2E-9] 通过:全局环检测开关生效且不破坏默认闭包行为')
  1248. # E2E-3: 选一个被依赖的真实源文件,删之,重建后其依赖边应不残留(悬空已剔除)
  1249. victim = None
  1250. for e in g['edges']:
  1251. if e['kind'] == 'include' and os.path.exists(os.path.join(work, e['to'])):
  1252. victim = e['to']
  1253. break
  1254. if not victim:
  1255. # 非 PHP/JS include 项目(纯 import/use、空图谱等)无可用 victim,
  1256. # 优雅跳过并清理临时副本,避免 AssertionError 崩溃 + 残留目录。
  1257. _shutil.rmtree(work)
  1258. print('[SELFTEST] 跳过 E2E-3/4:当前目录图谱无 include 边(非 PHP/JS include 项目),无法构造 victim(副本已清理)')
  1259. return
  1260. dependents = [e['from'] for e in g['edges'] if e['to'] == victim]
  1261. print('[E2E-3] 依赖', victim, '的文件数:', len(dependents))
  1262. bak = os.path.join(work, victim) + '.baktest'
  1263. os.rename(os.path.join(work, victim), bak)
  1264. build(work)
  1265. g2, _, _ = load()
  1266. hanging = [e for e in g2['edges'] if e['to'] == victim]
  1267. print('[E2E-3] 删除后悬挂边:', len(hanging), '(期望 0)')
  1268. os.rename(bak, os.path.join(work, victim))
  1269. # E2E-4: 改一个真实文件,need_rebuild 应报 stale
  1270. orig = open(os.path.join(work, victim), encoding='utf-8', errors='ignore').read()
  1271. open(os.path.join(work, victim), 'a', encoding='utf-8').write('\n// e2e touch\n')
  1272. stale, changed_info = need_rebuild(work)
  1273. cnt = len(changed_info) if changed_info else '全量'
  1274. print('[E2E-4] 编辑后过期:', stale, '变更文件数:', cnt, '(期望 True/>0)')
  1275. open(os.path.join(work, victim), 'w', encoding='utf-8').write(orig)
  1276. _shutil.rmtree(work)
  1277. print('[SELFTEST] 完成(副本已清理,原交付物未改动)')
  1278. return
  1279. kg_dir = os.path.join(root, '.ai-memory', 'knowledge-graph')
  1280. # 冷启动 / 新鲜度(规划 §6)
  1281. if args.rebuild:
  1282. build(root)
  1283. return
  1284. if args.query:
  1285. if args.no_rebuild and _graph_files_present(root):
  1286. print('[GRAPH-USED-CACHE] 跳过新鲜度检测,复用已缓存图谱')
  1287. else:
  1288. stale, changed_info = need_rebuild(root)
  1289. # B1:meta.json 在但 graph.json/symbols.json 缺失(被误删/损坏)→ 即便源码未变也必须重建,
  1290. # 否则下方直接 open(graph.json) 会抛 FileNotFoundError 崩溃。
  1291. if stale or not _graph_files_present(root):
  1292. if changed_info:
  1293. preview = ', '.join(sorted(changed_info)[:5])
  1294. suffix = '…' if len(changed_info) > 5 else ''
  1295. print(f"[GRAPH-STALE] {len(changed_info)} 个文件变更:{preview}{suffix},正在重建…")
  1296. else:
  1297. print("[GRAPH-STALE] 图谱过期(产物缺失/脚本升级/composer.json 变更),正在全量重建…")
  1298. build(root) # 全量重抽保准确性(extract 增量因 CONST_TABLE 依赖风险留作 v2)
  1299. else:
  1300. print("[GRAPH-FRESH] 图谱新鲜")
  1301. with open(os.path.join(kg_dir, 'graph.json'), encoding='utf-8') as f:
  1302. graph = json.load(f)
  1303. with open(os.path.join(kg_dir, 'meta.json'), encoding='utf-8') as f:
  1304. meta = json.load(f)
  1305. with open(os.path.join(kg_dir, 'symbols.json'), encoding='utf-8') as f:
  1306. symbols = json.load(f)
  1307. # P1:支持多入口(逗号分隔),合并子图——覆盖 SKILL.md Step2 P1「--query <改动文件1,改动文件2,…>」
  1308. # 查依赖闭包的使用方式;单入口时退化为原行为。
  1309. merged = {'nodes': {}, 'edges': [], 'cycle': 0}
  1310. for q in args.query.split(','):
  1311. q = q.strip()
  1312. if not q:
  1313. continue
  1314. sub = query(graph, meta, symbols, root, q, args.direction, args.depth,
  1315. args.all_cycles, args.max_nodes)
  1316. merged['nodes'].update(sub['nodes'])
  1317. merged['edges'].extend(sub['edges'])
  1318. merged['cycle'] += sub['cycle']
  1319. print(json.dumps(merged, ensure_ascii=False))
  1320. return
  1321. # 默认:全量构建
  1322. build(root)
  1323. if __name__ == '__main__':
  1324. main()