@hupan56/wlkj 2.7.12 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. package/bin/cli.js +1435 -1107
  2. package/package.json +29 -29
  3. package/templates/qoder/agents/insight-planning.md +1 -1
  4. package/templates/qoder/agents/insight-research.md +28 -15
  5. package/templates/qoder/commands/optional/wl-insight.md +161 -161
  6. package/templates/qoder/commands/optional/wl-report.md +20 -39
  7. package/templates/qoder/commands/optional/wl-status.md +4 -4
  8. package/templates/qoder/commands/wl-code.md +2 -2
  9. package/templates/qoder/commands/wl-design.md +6 -6
  10. package/templates/qoder/commands/wl-init.md +3 -3
  11. package/templates/qoder/commands/wl-prd.md +26 -13
  12. package/templates/qoder/commands/wl-req.md +43 -0
  13. package/templates/qoder/commands/wl-search.md +78 -47
  14. package/templates/qoder/commands/wl-task.md +343 -40
  15. package/templates/qoder/commands/wl-test.md +43 -17
  16. package/templates/qoder/config.yaml +52 -14
  17. package/templates/qoder/hooks/post-tool-use.py +181 -0
  18. package/templates/qoder/hooks/session-start.py +94 -23
  19. package/templates/qoder/hooks/stop-eval.py +207 -0
  20. package/templates/qoder/hooks/user-prompt-submit.py +139 -0
  21. package/templates/qoder/rules/wl-pipeline.md +85 -49
  22. package/templates/qoder/scripts/README.md +102 -0
  23. package/templates/qoder/scripts/capability/__init__.py +26 -0
  24. package/templates/qoder/scripts/capability/__main__.py +72 -0
  25. package/templates/qoder/scripts/capability/adapters/__init__.py +29 -0
  26. package/templates/qoder/scripts/capability/adapters/cli.py +316 -0
  27. package/templates/qoder/scripts/capability/adapters/mcp.py +334 -0
  28. package/templates/qoder/scripts/capability/adapters/qw.py +271 -0
  29. package/templates/qoder/scripts/capability/caps/__init__.py +27 -0
  30. package/templates/qoder/scripts/capability/caps/context.py +36 -0
  31. package/templates/qoder/scripts/capability/caps/cron.py +50 -0
  32. package/templates/qoder/scripts/capability/caps/identity.py +43 -0
  33. package/templates/qoder/scripts/capability/caps/memory.py +71 -0
  34. package/templates/qoder/scripts/capability/caps/notify.py +41 -0
  35. package/templates/qoder/scripts/capability/caps/present.py +48 -0
  36. package/templates/qoder/scripts/capability/caps/repo.py +31 -0
  37. package/templates/qoder/scripts/capability/caps/sandbox.py +43 -0
  38. package/templates/qoder/scripts/capability/chain.py +92 -0
  39. package/templates/qoder/scripts/capability/memory_chain.py +41 -0
  40. package/templates/qoder/scripts/capability/registry.py +246 -0
  41. package/templates/qoder/scripts/capability/registry_mcp.py +250 -0
  42. package/templates/qoder/scripts/capability/smoke_test.py +211 -0
  43. package/templates/qoder/scripts/capability/smoke_test_report.json +94 -0
  44. package/templates/qoder/scripts/deployment/setup/__init__.py +11 -0
  45. package/templates/qoder/scripts/deployment/setup/carriers.py +669 -0
  46. package/templates/qoder/scripts/deployment/setup/carriers_verify.py +148 -0
  47. package/templates/qoder/scripts/{init_doctor.py → deployment/setup/init_doctor.py} +100 -39
  48. package/templates/qoder/scripts/{install_qoderwork.py → deployment/setup/install_qoderwork.py} +48 -30
  49. package/templates/qoder/scripts/{platform_doctor.py → deployment/setup/platform_doctor.py} +271 -259
  50. package/templates/qoder/scripts/{repo_root.py → deployment/setup/repo_root.py} +9 -2
  51. package/templates/qoder/scripts/{setup.py → deployment/setup/setup.py} +143 -14
  52. package/templates/qoder/scripts/{setup_lanhu.py → deployment/setup/setup_lanhu.py} +979 -963
  53. package/templates/qoder/scripts/domain/design/__init__.py +0 -0
  54. package/templates/qoder/scripts/{fill_prototype.py → domain/design/fill_prototype.py} +17 -5
  55. package/templates/qoder/scripts/{gen_design_doc.py → domain/design/gen_design_doc.py} +406 -394
  56. package/templates/qoder/scripts/domain/kg/__init__.py +11 -0
  57. package/templates/qoder/scripts/domain/kg/build/__init__.py +0 -0
  58. package/templates/qoder/scripts/domain/kg/build/build_entity_registry.py +201 -0
  59. package/templates/qoder/scripts/domain/kg/build/build_relations.py +132 -0
  60. package/templates/qoder/scripts/{build_style_index.py → domain/kg/build/build_style_index.py} +50 -15
  61. package/templates/qoder/scripts/domain/kg/build/build_workflows.py +149 -0
  62. package/templates/qoder/scripts/{kg_build.py → domain/kg/build/kg_build.py} +685 -612
  63. package/templates/qoder/scripts/{kg_build_db.py → domain/kg/build/kg_build_db.py} +338 -327
  64. package/templates/qoder/scripts/{kg_incremental.py → domain/kg/build/kg_incremental.py} +425 -393
  65. package/templates/qoder/scripts/{learn_aggregate.py → domain/kg/build/learn_aggregate.py} +212 -201
  66. package/templates/qoder/scripts/domain/kg/extract/__init__.py +0 -0
  67. package/templates/qoder/scripts/{common → domain/kg/extract}/extract.py +430 -419
  68. package/templates/qoder/scripts/domain/kg/extract/test_extract.py +115 -0
  69. package/templates/qoder/scripts/{common → domain/kg/extract}/ts_extract.py +614 -536
  70. package/templates/qoder/scripts/domain/kg/graph/__init__.py +0 -0
  71. package/templates/qoder/scripts/{kg_link_db.py → domain/kg/graph/kg_link_db.py} +235 -224
  72. package/templates/qoder/scripts/domain/kg/graph/kg_semantic.py +683 -0
  73. package/templates/qoder/scripts/{kg.py → domain/kg/kg.py} +871 -708
  74. package/templates/qoder/scripts/domain/kg/kg_capabilities.py +182 -0
  75. package/templates/qoder/scripts/domain/kg/search/__init__.py +0 -0
  76. package/templates/qoder/scripts/{context_pack.py → domain/kg/search/context_pack.py} +45 -17
  77. package/templates/qoder/scripts/{enrich_prompt.py → domain/kg/search/enrich_prompt.py} +238 -226
  78. package/templates/qoder/scripts/domain/kg/search/prefetch.py +384 -0
  79. package/templates/qoder/scripts/{common → domain/kg/search}/search_engine.py +207 -205
  80. package/templates/qoder/scripts/{search_index.py → domain/kg/search/search_index.py} +210 -88
  81. package/templates/qoder/scripts/domain/kg/server/__init__.py +0 -0
  82. package/templates/qoder/scripts/domain/kg/server/kgd.py +549 -0
  83. package/templates/qoder/scripts/domain/kg/server/perf_bench.py +197 -0
  84. package/templates/qoder/scripts/domain/kg/storage/__init__.py +0 -0
  85. package/templates/qoder/scripts/{kg_duckdb.py → domain/kg/storage/kg_duckdb.py} +70 -45
  86. package/templates/qoder/scripts/domain/learning/__init__.py +0 -0
  87. package/templates/qoder/scripts/{learn.py → domain/learning/learn.py} +157 -146
  88. package/templates/qoder/scripts/domain/report/__init__.py +11 -0
  89. package/templates/qoder/scripts/{add_session.py → domain/report/add_session.py} +256 -244
  90. package/templates/qoder/scripts/{export.py → domain/report/export.py} +72 -4
  91. package/templates/qoder/scripts/{report.py → domain/report/report.py} +292 -281
  92. package/templates/qoder/scripts/domain/report/report_snapshot.py +261 -0
  93. package/templates/qoder/scripts/domain/report/role.py +39 -0
  94. package/templates/qoder/scripts/{status.py → domain/report/status.py} +737 -628
  95. package/templates/qoder/scripts/domain/report/status_snapshot.py +191 -0
  96. package/templates/qoder/scripts/domain/requirement/__init__.py +0 -0
  97. package/templates/qoder/scripts/{archive_prd.py → domain/requirement/archive_prd.py} +389 -377
  98. package/templates/qoder/scripts/domain/requirement/req.py +228 -0
  99. package/templates/qoder/scripts/domain/task/__init__.py +11 -0
  100. package/templates/qoder/scripts/{git_sync.py → domain/task/git_sync.py} +90 -50
  101. package/templates/qoder/scripts/{syncgate.py → domain/task/syncgate.py} +18 -7
  102. package/templates/qoder/scripts/domain/task/task.py +229 -0
  103. package/templates/qoder/scripts/domain/task/task_lifecycle.py +606 -0
  104. package/templates/qoder/scripts/domain/task/task_query.py +162 -0
  105. package/templates/qoder/scripts/domain/task/task_relations.py +425 -0
  106. package/templates/qoder/scripts/{team_sync.py → domain/task/team_sync.py} +101 -23
  107. package/templates/qoder/scripts/domain/task/zentao_sync.py +370 -0
  108. package/templates/qoder/scripts/foundation/__init__.py +0 -0
  109. package/templates/qoder/scripts/foundation/bootstrap.py +145 -0
  110. package/templates/qoder/scripts/foundation/core/__init__.py +0 -0
  111. package/templates/qoder/scripts/foundation/core/cmd_registry.py +112 -0
  112. package/templates/qoder/scripts/foundation/core/config.py +187 -0
  113. package/templates/qoder/scripts/{common → foundation/core}/paths.py +706 -400
  114. package/templates/qoder/scripts/foundation/data/__init__.py +0 -0
  115. package/templates/qoder/scripts/foundation/data/contract.py +317 -0
  116. package/templates/qoder/scripts/{common → foundation/data}/events.py +2 -2
  117. package/templates/qoder/scripts/foundation/data/result.py +223 -0
  118. package/templates/qoder/scripts/{common → foundation/data}/task_utils.py +427 -392
  119. package/templates/qoder/scripts/foundation/identity/__init__.py +0 -0
  120. package/templates/qoder/scripts/foundation/identity/check_publish.py +103 -0
  121. package/templates/qoder/scripts/{common → foundation/identity}/developer.py +239 -238
  122. package/templates/qoder/scripts/foundation/identity/guard.py +159 -0
  123. package/templates/qoder/scripts/{common → foundation/identity}/identity.py +132 -9
  124. package/templates/qoder/scripts/foundation/identity/roles.py +60 -0
  125. package/templates/qoder/scripts/foundation/integrations/__init__.py +0 -0
  126. package/templates/qoder/scripts/{common → foundation/integrations}/active_task.py +30 -15
  127. package/templates/qoder/scripts/{common → foundation/integrations}/feishu.py +11 -10
  128. package/templates/qoder/scripts/{common → foundation/integrations}/terms.py +3 -3
  129. package/templates/qoder/scripts/foundation/integrations/zentao_client.py +220 -0
  130. package/templates/qoder/scripts/foundation/io/__init__.py +0 -0
  131. package/templates/qoder/scripts/{common → foundation/io}/filelock.py +1 -1
  132. package/templates/qoder/scripts/{common → foundation/io}/reqid.py +1 -1
  133. package/templates/qoder/scripts/foundation/protocol/__init__.py +0 -0
  134. package/templates/qoder/scripts/foundation/protocol/duckdb_conn.py +39 -0
  135. package/templates/qoder/scripts/foundation/protocol/mcp_base.py +268 -0
  136. package/templates/qoder/scripts/foundation/utils/__init__.py +0 -0
  137. package/templates/qoder/scripts/{common → foundation/utils}/eval_api.py +12 -1
  138. package/templates/qoder/scripts/{common → foundation/utils}/pip_install.py +23 -6
  139. package/templates/qoder/scripts/{common → foundation/utils}/platform_guard.py +1 -1
  140. package/templates/qoder/scripts/orchestration/__init__.py +0 -0
  141. package/templates/qoder/scripts/orchestration/wlkj.py +185 -0
  142. package/templates/qoder/scripts/protocol/__init__.py +0 -0
  143. package/templates/qoder/scripts/protocol/browser/README.md +207 -0
  144. package/templates/qoder/scripts/protocol/browser/SKILL.md +265 -0
  145. package/templates/qoder/scripts/protocol/browser/config.env +9 -0
  146. package/templates/qoder/scripts/protocol/browser/references/cdp-api.md +110 -0
  147. package/templates/qoder/scripts/protocol/browser/references/migration-2.5.3.md +72 -0
  148. package/templates/qoder/scripts/protocol/browser/references/site-patterns/.gitkeep +0 -0
  149. package/templates/qoder/scripts/protocol/browser/scripts/browser-discovery.mjs +138 -0
  150. package/templates/qoder/scripts/protocol/browser/scripts/cdp-proxy.mjs +672 -0
  151. package/templates/qoder/scripts/protocol/browser/scripts/check-deps.mjs +206 -0
  152. package/templates/qoder/scripts/protocol/browser/scripts/find-url.mjs +253 -0
  153. package/templates/qoder/scripts/protocol/browser/scripts/match-site.mjs +46 -0
  154. package/templates/qoder/scripts/protocol/browser/templates/config.env.template +9 -0
  155. package/templates/qoder/scripts/protocol/mcp/__init__.py +11 -0
  156. package/templates/qoder/scripts/protocol/mcp/kg_mcp_server.py +478 -0
  157. package/templates/qoder/scripts/{lanhu_stdio_wrapper.py → protocol/mcp/lanhu_stdio_wrapper.py} +131 -119
  158. package/templates/qoder/scripts/protocol/mcp/mcp_doctor.py +548 -0
  159. package/templates/qoder/scripts/protocol/mcp/mcp_launcher.py +600 -0
  160. package/templates/qoder/scripts/protocol/mcp/mysql_mcp_server.py +461 -0
  161. package/templates/qoder/scripts/protocol/mcp/zentao_mcp_server.py +1920 -0
  162. package/templates/qoder/scripts/protocol/transports/__init__.py +56 -0
  163. package/templates/qoder/scripts/protocol/transports/base.py +87 -0
  164. package/templates/qoder/scripts/protocol/transports/cli.py +132 -0
  165. package/templates/qoder/scripts/protocol/transports/http.py +141 -0
  166. package/templates/qoder/scripts/protocol/transports/stdio.py +293 -0
  167. package/templates/qoder/scripts/run_weekly_update.bat +27 -18
  168. package/templates/qoder/scripts/run_weekly_update.sh +22 -13
  169. package/templates/qoder/scripts/validation/__init__.py +0 -0
  170. package/templates/qoder/scripts/validation/eval/__init__.py +0 -0
  171. package/templates/qoder/scripts/validation/eval/perf-report-2026-06-24.md +139 -0
  172. package/templates/qoder/scripts/validation/eval/qwork_harness.py +617 -0
  173. package/templates/qoder/scripts/validation/eval/report-commands.md +220 -0
  174. package/templates/qoder/scripts/validation/eval/transcript_timing.py +386 -0
  175. package/templates/qoder/scripts/validation/metrics/__init__.py +0 -0
  176. package/templates/qoder/scripts/{eval_prd.py → validation/metrics/eval_prd.py} +84 -15
  177. package/templates/qoder/scripts/validation/metrics/usability_score.py +750 -0
  178. package/templates/qoder/scripts/validation/test/__init__.py +11 -0
  179. package/templates/qoder/scripts/validation/test/assertion_gen.py +551 -0
  180. package/templates/qoder/scripts/{autotest.py → validation/test/autotest.py} +1234 -1751
  181. package/templates/qoder/scripts/validation/test/autotest_auth.py +109 -0
  182. package/templates/qoder/scripts/{autotest_batch.py → validation/test/autotest_batch.py} +255 -224
  183. package/templates/qoder/scripts/validation/test/autotest_data.py +680 -0
  184. package/templates/qoder/scripts/validation/test/autotest_opencli.py +646 -0
  185. package/templates/qoder/scripts/{autotest_run.py → validation/test/autotest_run.py} +323 -297
  186. package/templates/qoder/scripts/validation/test/autotest_webaccess.py +571 -0
  187. package/templates/qoder/scripts/{benchmark.py → validation/test/benchmark.py} +17 -5
  188. package/templates/qoder/scripts/{kg_auto_login.py → validation/test/kg_auto_login.py} +208 -196
  189. package/templates/qoder/scripts/{kg_test_runner.py → validation/test/kg_test_runner.py} +13 -2
  190. package/templates/qoder/scripts/{page_probe.py → validation/test/page_probe.py} +470 -459
  191. package/templates/qoder/scripts/validation/test/smoke_all_commands.py +170 -0
  192. package/templates/qoder/settings.json +23 -2
  193. package/templates/qoder/skills/design-import/SKILL.md +235 -226
  194. package/templates/qoder/skills/design-review/SKILL.md +91 -82
  195. package/templates/qoder/skills/prd-generator/SKILL.md +38 -24
  196. package/templates/qoder/skills/prd-review/SKILL.md +8 -6
  197. package/templates/qoder/skills/prototype-generator/SKILL.md +296 -247
  198. package/templates/qoder/skills/spec-coder/SKILL.md +24 -15
  199. package/templates/qoder/skills/spec-generator/SKILL.md +24 -12
  200. package/templates/qoder/skills/test-generator/SKILL.md +9 -7
  201. package/templates/qoder/skills/wl-code/SKILL.md +25 -13
  202. package/templates/qoder/skills/wl-commit/SKILL.md +10 -9
  203. package/templates/qoder/skills/wl-design/SKILL.md +7 -5
  204. package/templates/qoder/skills/wl-init/SKILL.md +8 -8
  205. package/templates/qoder/skills/wl-insight/SKILL.md +25 -12
  206. package/templates/qoder/skills/wl-prd-full/SKILL.md +59 -8
  207. package/templates/qoder/skills/wl-prd-quick/SKILL.md +7 -7
  208. package/templates/qoder/skills/wl-prd-review/SKILL.md +18 -6
  209. package/templates/qoder/skills/wl-report/SKILL.md +27 -25
  210. package/templates/qoder/skills/wl-search/SKILL.md +151 -80
  211. package/templates/qoder/skills/wl-spec/SKILL.md +9 -7
  212. package/templates/qoder/skills/wl-status/SKILL.md +50 -18
  213. package/templates/qoder/skills/wl-task/SKILL.md +94 -12
  214. package/templates/qoder/skills/wl-test/SKILL.md +154 -45
  215. package/templates/qoder/templates/prd-full-template.md +7 -0
  216. package/templates/root/AGENTS.md +275 -237
  217. package/templates/root/requirements.txt +3 -0
  218. package/templates/root//344/275/277/347/224/250/350/257/264/346/230/216.md +3 -3
  219. package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +2 -2
  220. package/templates/qoder/hooks/inject-workflow-state.py +0 -169
  221. package/templates/qoder/scripts/__pycache__/check_mcp_launch.cpython-39.pyc +0 -0
  222. package/templates/qoder/scripts/__pycache__/install_qoderwork.cpython-39.pyc +0 -0
  223. package/templates/qoder/scripts/__pycache__/mcp_launcher.cpython-39.pyc +0 -0
  224. package/templates/qoder/scripts/__pycache__/platform_doctor.cpython-39.pyc +0 -0
  225. package/templates/qoder/scripts/check_carriers.py +0 -238
  226. package/templates/qoder/scripts/check_mcp.py +0 -298
  227. package/templates/qoder/scripts/check_mcp_launch.py +0 -183
  228. package/templates/qoder/scripts/check_qoderwork_consistency.py +0 -166
  229. package/templates/qoder/scripts/collect_prds.py +0 -31
  230. package/templates/qoder/scripts/common/mentions.py +0 -134
  231. package/templates/qoder/scripts/common/utf8.py +0 -38
  232. package/templates/qoder/scripts/extract_api_params.py +0 -246
  233. package/templates/qoder/scripts/extract_routes.py +0 -54
  234. package/templates/qoder/scripts/extract_routes_tree.py +0 -78
  235. package/templates/qoder/scripts/handoff.py +0 -22
  236. package/templates/qoder/scripts/init_developer.py +0 -76
  237. package/templates/qoder/scripts/kg_mcp_server.py +0 -801
  238. package/templates/qoder/scripts/kg_semantic.py +0 -150
  239. package/templates/qoder/scripts/mcp_launcher.py +0 -414
  240. package/templates/qoder/scripts/mysql_mcp_server.py +0 -396
  241. package/templates/qoder/scripts/parse_prds.py +0 -33
  242. package/templates/qoder/scripts/role.py +0 -51
  243. package/templates/qoder/scripts/sync_carriers.py +0 -259
  244. package/templates/qoder/scripts/task.py +0 -1261
  245. package/templates/qoder/scripts/workspace_init.py +0 -102
  246. package/templates/qoder/scripts/zentao_mcp_server.py +0 -424
  247. package/templates/qoder/skills/prompt-enrich/SKILL.md +0 -90
  248. /package/templates/qoder/scripts/{common → deployment}/__init__.py +0 -0
  249. /package/templates/qoder/{skills/prototype-generator/SKILL.md.zcode-79180-2af4721f-f9a6-412c-88db-c0af680d211b.tmp → scripts/domain/__init__.py} +0 -0
  250. /package/templates/qoder/scripts/{common → domain/kg/graph}/graph_traverse.py +0 -0
  251. /package/templates/qoder/scripts/{common → domain/kg/server}/repowiki.py +0 -0
  252. /package/templates/qoder/scripts/{common → foundation/io}/atomicio.py +0 -0
  253. /package/templates/qoder/scripts/{secure-ls.js → validation/test/secure-ls.js} +0 -0
@@ -1,393 +1,425 @@
1
- # -*- coding: utf-8 -*-
2
- """
3
- Incremental UPSERT builder for the DuckDB knowledge graph.
4
-
5
- Instead of full rebuild (build_style_index.py → kg_duckdb.py bulk_import),
6
- this takes a list of changed files (from git diff) and only updates the
7
- entities + edges that belong to those files.
8
-
9
- Performance: ~0.2s per file vs 60s full rebuild.
10
- For 500 changed files: ~2 minutes incremental vs 5+ minutes full rebuild.
11
-
12
- Usage:
13
- python kg_incremental.py # auto-detect changes via git diff
14
- python kg_incremental.py file1.vue file2.ts # explicit file list
15
- """
16
- import json
17
- import os
18
- import re
19
- import sys
20
-
21
- SCRIPTS = os.path.join(os.path.dirname(os.path.abspath(__file__)))
22
- sys.path.insert(0, SCRIPTS)
23
-
24
- from common.paths import DATA_INDEX_DIR, PROJECT_ROOT
25
- from kg_duckdb import get_db, init_schema, upsert_entity, upsert_alias, upsert_edge, delete_edges_from
26
- from common.ts_extract import ts_build_api_fn_table, ts_trace_clicks
27
- from common.extract import build_api_fn_table
28
- from common.test_extract import extract_test_assertions
29
-
30
- CODE_DIR = str(PROJECT_ROOT / 'data' / 'code')
31
- INDEX_DIR = str(DATA_INDEX_DIR)
32
-
33
-
34
- def get_changed_files():
35
- """Auto-detect ALL changed files since last kg_build via stored commit hash.
36
-
37
- Records the last-built commit per project in build_meta table.
38
- Next run diffs from that commit to HEAD, capturing ALL changes (not just last 2 commits).
39
- """
40
- changed = []
41
- if not os.path.isdir(CODE_DIR):
42
- return changed
43
- import subprocess
44
-
45
- # Read last-built commits from DuckDB build_meta
46
- last_commits = {}
47
- try:
48
- con = get_db()
49
- rows = con.execute("SELECT key, value FROM build_meta WHERE key LIKE 'last_commit_%'").fetchall()
50
- con.close()
51
- for k, v in rows:
52
- last_commits[k.replace('last_commit_', '')] = v
53
- except Exception:
54
- pass
55
-
56
- current_commits = {}
57
- for proj in os.listdir(CODE_DIR):
58
- proj_path = os.path.join(CODE_DIR, proj)
59
- if not os.path.isdir(os.path.join(proj_path, '.git')):
60
- continue
61
- try:
62
- # Get current HEAD
63
- r = subprocess.run(['git', 'rev-parse', 'HEAD'],
64
- cwd=proj_path, capture_output=True, text=True, encoding='utf-8')
65
- if r.returncode != 0:
66
- continue
67
- current_head = r.stdout.strip()
68
- current_commits[proj] = current_head
69
-
70
- # Determine diff range: from last-built commit to HEAD
71
- last_built = last_commits.get(proj)
72
- if last_built and last_built != current_head:
73
- # Diff from last build to now — captures ALL changes since last build
74
- diff_args = ['git', 'diff', '--name-only', last_built, current_head]
75
- elif not last_built:
76
- # First run — get last 7 days of changes (or all if repo is new)
77
- r3 = subprocess.run(['git', 'log', '--since=7 days ago', '--oneline'],
78
- cwd=proj_path, capture_output=True, text=True, encoding='utf-8')
79
- log_lines = [l for l in r3.stdout.strip().split('\n') if l.strip()]
80
- if len(log_lines) >= 2:
81
- old_commit = log_lines[-1].split()[0]
82
- diff_args = ['git', 'diff', '--name-only', old_commit, current_head]
83
- else:
84
- continue # No changes or single commit
85
- else:
86
- continue # Already up to date, no diff needed
87
-
88
- r2 = subprocess.run(diff_args,
89
- cwd=proj_path, capture_output=True, text=True, encoding='utf-8')
90
- for f in r2.stdout.strip().split('\n'):
91
- f = f.strip()
92
- if f:
93
- changed.append(os.path.join(proj_path, f.replace('/', os.sep)))
94
- except Exception:
95
- pass
96
-
97
- # Save current commits as last-built
98
- try:
99
- con = get_db()
100
- for proj, commit in current_commits.items():
101
- con.execute(
102
- "INSERT OR REPLACE INTO build_meta VALUES (?, ?)",
103
- ['last_commit_%s' % proj, commit]
104
- )
105
- con.close()
106
- except Exception:
107
- pass
108
-
109
- return changed
110
-
111
-
112
- def incremental_update(changed_files):
113
- """Update DuckDB incrementally for a list of changed files.
114
-
115
- For each file:
116
- - .vue: re-extract trace_clicks, UPSERT button entities + click edges
117
- - .ts (api): re-extract api functions, UPSERT endpoint entities
118
- - .java (test): re-extract test assertions, UPSERT test entities
119
- """
120
- con = get_db()
121
- init_schema(con)
122
-
123
- stats = {'vue': 0, 'ts': 0, 'java_test': 0, 'edges_updated': 0, 'entities_upserted': 0}
124
-
125
- # 判断是否有 api 文件变更 — 决定是否需要重建 api_table
126
- has_api_changes = any(
127
- f.replace('\\', '/').endswith('.ts') and '/api/' in f.replace('\\', '/')
128
- for f in changed_files if os.path.isfile(f)
129
- )
130
-
131
- api_dir = _find_api_dir()
132
-
133
- if has_api_changes:
134
- # api 文件变了 必须重建完整 api_table
135
- api_table = ts_build_api_fn_table(api_dir) if api_dir else {}
136
- # 缓存到文件, 供下次增量复用
137
- if api_table and api_dir:
138
- _save_api_cache(api_table, api_dir)
139
- else:
140
- # api 文件没变 — 从缓存加载 (毫秒级, 不扫文件系统)
141
- api_table = _load_api_cache(api_dir)
142
- if not api_table:
143
- # 缓存不存在 (首次运行) — 重建并缓存
144
- api_table = ts_build_api_fn_table(api_dir) if api_dir else {}
145
- if api_table and api_dir:
146
- _save_api_cache(api_table, api_dir)
147
-
148
- for filepath in changed_files:
149
- if not os.path.isfile(filepath):
150
- # File was deleted clean up its entities
151
- _cleanup_deleted_file(con, filepath)
152
- continue
153
-
154
- rel_path = filepath.replace('\\', '/')
155
-
156
- if filepath.endswith('.vue'):
157
- stats['vue'] += 1
158
- _update_vue_file(con, filepath, api_table, stats)
159
-
160
- elif filepath.endswith('.ts') and '/api/' in rel_path:
161
- stats['ts'] += 1
162
- _update_api_file(con, filepath, stats)
163
-
164
- elif filepath.endswith('Test.java'):
165
- stats['java_test'] += 1
166
- _update_test_file(con, filepath, stats)
167
-
168
- # After file updates, refresh PRD + Wiki from git_sync's JSON output
169
- _refresh_prds_and_wiki(con, stats)
170
-
171
- con.close()
172
- return stats
173
-
174
-
175
- def _refresh_prds_and_wiki(con, stats):
176
- """Import PRD index + Wiki from git_sync's JSON output into DuckDB.
177
-
178
- git_sync.py writes these as JSON; kg_incremental reads them and UPSERTs
179
- into DuckDB so MCP tools can query them.
180
- """
181
- INDEX = str(DATA_INDEX_DIR)
182
- import json as _json
183
-
184
- # PRD
185
- prd_path = os.path.join(INDEX, '_build_cache', 'prd-index.json')
186
- if not os.path.isfile(prd_path):
187
- prd_path = os.path.join(INDEX, 'prd-index.json')
188
- if os.path.isfile(prd_path):
189
- try:
190
- prds = _json.load(open(prd_path, encoding='utf-8'))
191
- con.execute("DELETE FROM prds")
192
- rows = [(f, info.get('title', ''),
193
- _json.dumps(info.get('keywords', []), ensure_ascii=False),
194
- _json.dumps(info.get('features', []), ensure_ascii=False),
195
- _json.dumps(info.get('cn_terms', []), ensure_ascii=False))
196
- for f, info in prds.items()]
197
- if rows:
198
- _batch_insert(con, 'prds', rows, ignore=True)
199
- stats['prds_refreshed'] = len(rows)
200
- except Exception:
201
- pass
202
-
203
- # Wiki
204
- wiki_path = os.path.join(INDEX, 'wiki-index.json')
205
- if os.path.isfile(wiki_path):
206
- try:
207
- wiki = _json.load(open(wiki_path, encoding='utf-8'))
208
- con.execute("DELETE FROM wiki")
209
- wiki_rows = []
210
- for kw, entries in (wiki.get('index', {}) if isinstance(wiki, dict) else {}).items():
211
- for e in (entries if isinstance(entries, list) else []):
212
- wiki_rows.append((kw, e.get('project', ''), e.get('title', ''),
213
- e.get('md_path', ''), e.get('description', '')))
214
- if wiki_rows:
215
- _batch_insert(con, 'wiki', wiki_rows)
216
- stats['wiki_refreshed'] = len(wiki_rows)
217
- except Exception:
218
- pass
219
-
220
-
221
- def _find_api_dir():
222
- """Find the fywl-ui api directory."""
223
- apps = os.path.join(CODE_DIR, 'fywl-ui', 'apps')
224
- if os.path.isdir(apps):
225
- for app in os.listdir(apps):
226
- candidate = os.path.join(apps, app, 'src', 'api')
227
- if os.path.isdir(candidate):
228
- return candidate
229
- return None
230
-
231
-
232
- def _load_api_table_from_duckdb(con):
233
- """从 DuckDB 加载 api_table (api 函数名→端点), 避免重建。"""
234
- table = {}
235
- try:
236
- rows = con.execute("SELECT id, props FROM entities WHERE type = 'ENDPOINT'").fetchall()
237
- for eid, props_json in rows:
238
- url = eid.replace('E:', '')
239
- props = json.loads(props_json) if props_json else {}
240
- fn_name = props.get('api_fn', '')
241
- if fn_name:
242
- table[fn_name] = {'url': url, 'verb': props.get('verb', '?'),
243
- 'file': props.get('source_file', '')}
244
- except Exception:
245
- pass
246
- return table
247
-
248
-
249
- _API_CACHE_PATH = os.path.join(INDEX_DIR, '.api-table-cache.json')
250
-
251
-
252
- def _save_api_cache(api_table, api_dir):
253
- """保存 api_table 到缓存文件 (含 api_dir 的 mtime 作为失效依据)。"""
254
- import time
255
- cache = {
256
- 'api_dir_mtime': os.path.getmtime(api_dir) if os.path.isdir(api_dir) else 0,
257
- 'saved_at': time.time(),
258
- 'table': api_table,
259
- }
260
- with open(_API_CACHE_PATH, 'w', encoding='utf-8') as f:
261
- json.dump(cache, f, ensure_ascii=False)
262
-
263
-
264
- def _load_api_cache(api_dir):
265
- """从缓存文件加载 api_table。如果缓存不存在或过期返回 {}。"""
266
- if not os.path.isfile(_API_CACHE_PATH):
267
- return {}
268
- try:
269
- cache = json.load(open(_API_CACHE_PATH, encoding='utf-8'))
270
- # 检查 api_dir 的 mtime 是否变了
271
- current_mtime = os.path.getmtime(api_dir) if api_dir and os.path.isdir(api_dir) else 0
272
- if cache.get('api_dir_mtime') == current_mtime:
273
- return cache.get('table', {})
274
- except Exception:
275
- pass
276
- return {}
277
-
278
-
279
- def _update_vue_file(con, filepath, api_table, stats):
280
- """Re-extract traces from one Vue file and UPSERT its button entities + edges."""
281
- try:
282
- content = open(filepath, 'rb').read().decode('utf-8', errors='replace')
283
- except Exception:
284
- return
285
- if '@click' not in content:
286
- return
287
-
288
- traces = ts_trace_clicks(filepath, content, api_table)
289
- vue_norm = filepath.replace('\\', '/')
290
- # Normalize vue_short — MUST match kg_build.py's format exactly
291
- if '/views/' in vue_norm:
292
- vue_short = vue_norm.split('/views/')[-1]
293
- elif '/pages/' in vue_norm:
294
- vue_short = vue_norm.split('/pages/')[-1]
295
- else:
296
- vue_short = os.path.basename(filepath)
297
-
298
- for t in traces:
299
- handler = t.get('handler', '')
300
- eid = 'B:%s:%s' % (vue_short, handler)
301
- # UPSERT entity (INSERT OR REPLACE, no duplicates)
302
- upsert_entity(con, eid, 'BUTTON', handler, '',
303
- {'button_text': t.get('button', ''),
304
- 'vue_file': vue_norm,
305
- 'confidence': t.get('confidence', '')})
306
- upsert_alias(con, handler.lower(), eid)
307
- stats['entities_upserted'] += 1
308
-
309
- # Delete ALL old edges from this button first, then insert fresh
310
- delete_edges_from(con, eid)
311
- ep = t.get('endpoint')
312
- if ep and ep != 'None':
313
- to_eid = 'E:%s' % ep
314
- upsert_edge(con, eid, to_eid, 'clicks', props={
315
- 'button': t.get('button', ''),
316
- 'confidence': t.get('confidence', ''),
317
- })
318
- stats['edges_updated'] += 1
319
-
320
-
321
- def _update_api_file(con, filepath, stats):
322
- """Re-extract API functions from one .ts file and UPSERT endpoint entities."""
323
- # Re-use the bulk api_table extraction on this single file's directory
324
- api_dir = os.path.dirname(filepath)
325
- file_table = build_api_fn_table(api_dir)
326
- for fn_name, info in file_table.items():
327
- # Only update functions defined in THIS file
328
- if info.get('file', '').replace('\\', '/') != filepath.replace('\\', '/'):
329
- continue
330
- eid = 'E:%s' % info['url']
331
- upsert_entity(con, eid, 'ENDPOINT', info['url'], '',
332
- {'verb': info.get('verb', '?'),
333
- 'api_fn': fn_name,
334
- 'source_file': filepath.replace('\\', '/')})
335
- upsert_alias(con, fn_name.lower(), eid)
336
- stats['entities_upserted'] += 1
337
-
338
-
339
- def _update_test_file(con, filepath, stats):
340
- """Re-extract test assertions from one Test.java and UPSERT test entities."""
341
- tests = extract_test_assertions(os.path.dirname(filepath))
342
- # Filter to only this file
343
- file_norm = filepath.replace('\\', '/')
344
- for t in tests:
345
- if t.get('file', '').replace('\\', '/') != file_norm:
346
- continue
347
- name = '%s.%s' % (t['class'], t['method'])
348
- eid = 'T:%s' % name
349
- upsert_entity(con, eid, 'TEST', name, '',
350
- {'display': t.get('display_name', ''),
351
- 'deps': t.get('injected_deps', []),
352
- 'assertions': t.get('assertions', []),
353
- 'package': t.get('package', '')})
354
- upsert_alias(con, name.lower(), eid)
355
- stats['entities_upserted'] += 1
356
-
357
-
358
- def _cleanup_deleted_file(con, filepath):
359
- """Remove entities belonging to a deleted file."""
360
- rel = filepath.replace('\\', '/')
361
- # Delete buttons from this vue file
362
- if '.vue' in rel:
363
- vue_short = rel.split('/views/')[-1] if '/views/' in rel else os.path.basename(rel)
364
- con.execute("DELETE FROM entities WHERE id LIKE 'B:%s:%%'" % vue_short.replace("'", "''"))
365
- con.execute("DELETE FROM edges WHERE from_id LIKE 'B:%s:%%'" % vue_short.replace("'", "''"))
366
-
367
-
368
- if __name__ == '__main__':
369
- if len(sys.argv) > 1:
370
- changed = sys.argv[1:]
371
- else:
372
- print('Auto-detecting changes via git diff...')
373
- changed = get_changed_files()
374
-
375
- if not changed:
376
- print('No changes detected.')
377
- sys.exit(0)
378
-
379
- # Filter to relevant file types
380
- relevant = [f for f in changed if f.endswith(('.vue', '.ts', '.java'))]
381
- print('Changed files: %d total, %d relevant' % (len(changed), len(relevant)))
382
-
383
- if relevant:
384
- import time
385
- t0 = time.time()
386
- stats = incremental_update(relevant)
387
- elapsed = time.time() - t0
388
- print('Incremental update done in %.1fs' % elapsed)
389
- print(' Vue: %d, TS: %d, Java: %d' % (stats['vue'], stats['ts'], stats['java_test']))
390
- print(' Entities UPSERTed: %d' % stats['entities_upserted'])
391
- print(' Edges updated: %d' % stats['edges_updated'])
392
- else:
393
- print('No relevant files to update.')
1
+ # -*- coding: utf-8 -*-
2
+ # v3.0 路径自举: 引导到 common/bootstrap, 统一 sys.path 逻辑
3
+ import os as _o, sys as _s
4
+ _f = _o.path.abspath(__file__)
5
+ for _ in range(10):
6
+ _f = _o.path.dirname(_f)
7
+ _cp = _o.path.join(_f, 'foundation')
8
+ if _o.path.isfile(_o.path.join(_cp, 'bootstrap.py')):
9
+ break
10
+ if _cp not in _s.path: _s.path.insert(0, _cp)
11
+ from bootstrap import setup; setup()
12
+
13
+ """
14
+ Incremental UPSERT builder for the DuckDB knowledge graph.
15
+
16
+ Instead of full rebuild (build_style_index.py → kg_duckdb.py bulk_import),
17
+ this takes a list of changed files (from git diff) and only updates the
18
+ entities + edges that belong to those files.
19
+
20
+ Performance: ~0.2s per file vs 60s full rebuild.
21
+ For 500 changed files: ~2 minutes incremental vs 5+ minutes full rebuild.
22
+
23
+ Usage:
24
+ python kg_incremental.py # auto-detect changes via git diff
25
+ python kg_incremental.py file1.vue file2.ts # explicit file list
26
+ """
27
+ import json
28
+ import os
29
+ import re
30
+ import sys
31
+
32
+ SCRIPTS = os.path.join(os.path.dirname(os.path.abspath(__file__)))
33
+ sys.path.insert(0, SCRIPTS)
34
+
35
+ from foundation.core.paths import DATA_INDEX_DIR, PROJECT_ROOT
36
+ from domain.kg.storage.kg_duckdb import get_db, init_schema, upsert_entity, upsert_alias, upsert_edge, delete_edges_from
37
+ from domain.kg.extract.ts_extract import ts_build_api_fn_table, ts_trace_clicks
38
+ from domain.kg.extract.extract import build_api_fn_table
39
+ from domain.kg.extract.test_extract import extract_test_assertions
40
+
41
+ CODE_DIR = str(PROJECT_ROOT / 'data' / 'code')
42
+ INDEX_DIR = str(DATA_INDEX_DIR)
43
+
44
+
45
+ def _git(*args, cwd=None, timeout=None):
46
+ """git 命令包装(与 git_sync._git 一致)。网络操作必传 timeout; 本地操作可不传。
47
+
48
+ 返回 rc=124 表示超时, rc=127 表示 git 未安装, 不让进程崩溃或挂死。
49
+ """
50
+ import subprocess
51
+ try:
52
+ kw = dict(cwd=cwd, capture_output=True, text=True, encoding='utf-8', errors='replace')
53
+ if timeout:
54
+ kw['timeout'] = timeout
55
+ return subprocess.run(['git'] + list(args), **kw)
56
+ except FileNotFoundError:
57
+ import types
58
+ return types.SimpleNamespace(returncode=127, stdout='', stderr='git not installed')
59
+ except subprocess.TimeoutExpired:
60
+ import types
61
+ return types.SimpleNamespace(returncode=124, stdout='', stderr='git {} timed out'.format(' '.join(args[:3])))
62
+
63
+
64
+ def get_changed_files():
65
+ """Auto-detect ALL changed files since last kg_build via stored commit hash.
66
+
67
+ Records the last-built commit per project in build_meta table.
68
+ Next run diffs from that commit to HEAD, capturing ALL changes (not just last 2 commits).
69
+ """
70
+ changed = []
71
+ if not os.path.isdir(CODE_DIR):
72
+ return changed
73
+ import subprocess
74
+
75
+ # Read last-built commits from DuckDB build_meta
76
+ last_commits = {}
77
+ try:
78
+ con = get_db()
79
+ rows = con.execute("SELECT key, value FROM build_meta WHERE key LIKE 'last_commit_%'").fetchall()
80
+ con.close()
81
+ for k, v in rows:
82
+ last_commits[k.replace('last_commit_', '')] = v
83
+ except Exception:
84
+ pass
85
+
86
+ current_commits = {}
87
+ for proj in os.listdir(CODE_DIR):
88
+ proj_path = os.path.join(CODE_DIR, proj)
89
+ if not os.path.isdir(os.path.join(proj_path, '.git')):
90
+ continue
91
+ try:
92
+ # Get current HEAD (本地操作, 快速)
93
+ r = _git('rev-parse', 'HEAD', cwd=proj_path)
94
+ if r.returncode != 0:
95
+ continue
96
+ current_head = r.stdout.strip()
97
+ current_commits[proj] = current_head
98
+
99
+ # Determine diff range: from last-built commit to HEAD
100
+ last_built = last_commits.get(proj)
101
+ if last_built and last_built != current_head:
102
+ # Diff from last build to now — captures ALL changes since last build
103
+ diff_rev_range = ['diff', '--name-only', last_built, current_head]
104
+ elif not last_built:
105
+ # First run — get last 7 days of changes (or all if repo is new)
106
+ r3 = _git('log', '--since=7 days ago', '--oneline', cwd=proj_path)
107
+ log_lines = [l for l in r3.stdout.strip().split('\n') if l.strip()]
108
+ if len(log_lines) >= 2:
109
+ old_commit = log_lines[-1].split()[0]
110
+ diff_rev_range = ['diff', '--name-only', old_commit, current_head]
111
+ else:
112
+ continue # No changes or single commit
113
+ else:
114
+ continue # Already up to date, no diff needed
115
+
116
+ r2 = _git(*diff_rev_range, cwd=proj_path)
117
+ # P2-11: 必须判 returncode。若 diff 失败 ( last_built commit git gc
118
+ # 掉、对象库损坏), r2.stdout 可能是错误信息, 把它当文件名加入 changed
119
+ # 会让后续 _cleanup_deleted_file 误删真实文件。
120
+ if r2.returncode != 0:
121
+ continue
122
+ for f in r2.stdout.strip().split('\n'):
123
+ f = f.strip()
124
+ if f:
125
+ changed.append(os.path.join(proj_path, f.replace('/', os.sep)))
126
+ except Exception:
127
+ pass
128
+
129
+ # Save current commits as last-built
130
+ try:
131
+ con = get_db()
132
+ for proj, commit in current_commits.items():
133
+ con.execute(
134
+ "INSERT OR REPLACE INTO build_meta VALUES (?, ?)",
135
+ ['last_commit_%s' % proj, commit]
136
+ )
137
+ con.close()
138
+ except Exception:
139
+ pass
140
+
141
+ return changed
142
+
143
+
144
+ def incremental_update(changed_files):
145
+ """Update DuckDB incrementally for a list of changed files.
146
+
147
+ For each file:
148
+ - .vue: re-extract trace_clicks, UPSERT button entities + click edges
149
+ - .ts (api): re-extract api functions, UPSERT endpoint entities
150
+ - .java (test): re-extract test assertions, UPSERT test entities
151
+ """
152
+ con = get_db()
153
+ init_schema(con)
154
+
155
+ stats = {'vue': 0, 'ts': 0, 'java_test': 0, 'edges_updated': 0, 'entities_upserted': 0}
156
+
157
+ # 判断是否有 api 文件变更 — 决定是否需要重建 api_table
158
+ has_api_changes = any(
159
+ f.replace('\\', '/').endswith('.ts') and '/api/' in f.replace('\\', '/')
160
+ for f in changed_files if os.path.isfile(f)
161
+ )
162
+
163
+ api_dir = _find_api_dir()
164
+
165
+ if has_api_changes:
166
+ # api 文件变了 — 必须重建完整 api_table
167
+ api_table = ts_build_api_fn_table(api_dir) if api_dir else {}
168
+ # 缓存到文件, 供下次增量复用
169
+ if api_table and api_dir:
170
+ _save_api_cache(api_table, api_dir)
171
+ else:
172
+ # api 文件没变 — 从缓存加载 (毫秒级, 不扫文件系统)
173
+ api_table = _load_api_cache(api_dir)
174
+ if not api_table:
175
+ # 缓存不存在 (首次运行) — 重建并缓存
176
+ api_table = ts_build_api_fn_table(api_dir) if api_dir else {}
177
+ if api_table and api_dir:
178
+ _save_api_cache(api_table, api_dir)
179
+
180
+ for filepath in changed_files:
181
+ if not os.path.isfile(filepath):
182
+ # File was deleted — clean up its entities
183
+ _cleanup_deleted_file(con, filepath)
184
+ continue
185
+
186
+ rel_path = filepath.replace('\\', '/')
187
+
188
+ if filepath.endswith('.vue'):
189
+ stats['vue'] += 1
190
+ _update_vue_file(con, filepath, api_table, stats)
191
+
192
+ elif filepath.endswith('.ts') and '/api/' in rel_path:
193
+ stats['ts'] += 1
194
+ _update_api_file(con, filepath, stats)
195
+
196
+ elif filepath.endswith('Test.java'):
197
+ stats['java_test'] += 1
198
+ _update_test_file(con, filepath, stats)
199
+
200
+ # After file updates, refresh PRD + Wiki from git_sync's JSON output
201
+ _refresh_prds_and_wiki(con, stats)
202
+
203
+ con.close()
204
+ return stats
205
+
206
+
207
+ def _refresh_prds_and_wiki(con, stats):
208
+ """Import PRD index + Wiki from git_sync's JSON output into DuckDB.
209
+
210
+ git_sync.py writes these as JSON; kg_incremental reads them and UPSERTs
211
+ into DuckDB so MCP tools can query them.
212
+ """
213
+ INDEX = str(DATA_INDEX_DIR)
214
+ import json as _json
215
+
216
+ # PRD
217
+ prd_path = os.path.join(INDEX, '_build_cache', 'prd-index.json')
218
+ if not os.path.isfile(prd_path):
219
+ prd_path = os.path.join(INDEX, 'prd-index.json')
220
+ if os.path.isfile(prd_path):
221
+ try:
222
+ prds = _json.load(open(prd_path, encoding='utf-8'))
223
+ con.execute("DELETE FROM prds")
224
+ rows = [(f, info.get('title', ''),
225
+ _json.dumps(info.get('keywords', []), ensure_ascii=False),
226
+ _json.dumps(info.get('features', []), ensure_ascii=False),
227
+ _json.dumps(info.get('cn_terms', []), ensure_ascii=False))
228
+ for f, info in prds.items()]
229
+ if rows:
230
+ _batch_insert(con, 'prds', rows, ignore=True)
231
+ stats['prds_refreshed'] = len(rows)
232
+ except Exception:
233
+ pass
234
+
235
+ # Wiki
236
+ wiki_path = os.path.join(INDEX, 'wiki-index.json')
237
+ if os.path.isfile(wiki_path):
238
+ try:
239
+ wiki = _json.load(open(wiki_path, encoding='utf-8'))
240
+ con.execute("DELETE FROM wiki")
241
+ wiki_rows = []
242
+ for kw, entries in (wiki.get('index', {}) if isinstance(wiki, dict) else {}).items():
243
+ for e in (entries if isinstance(entries, list) else []):
244
+ wiki_rows.append((kw, e.get('project', ''), e.get('title', ''),
245
+ e.get('md_path', ''), e.get('description', '')))
246
+ if wiki_rows:
247
+ _batch_insert(con, 'wiki', wiki_rows)
248
+ stats['wiki_refreshed'] = len(wiki_rows)
249
+ except Exception:
250
+ pass
251
+
252
+
253
+ def _find_api_dir():
254
+ """Find the fywl-ui api directory."""
255
+ apps = os.path.join(CODE_DIR, 'fywl-ui', 'apps')
256
+ if os.path.isdir(apps):
257
+ for app in os.listdir(apps):
258
+ candidate = os.path.join(apps, app, 'src', 'api')
259
+ if os.path.isdir(candidate):
260
+ return candidate
261
+ return None
262
+
263
+
264
+ def _load_api_table_from_duckdb(con):
265
+ """ DuckDB 加载 api_table (api 函数名→端点), 避免重建。"""
266
+ table = {}
267
+ try:
268
+ rows = con.execute("SELECT id, props FROM entities WHERE type = 'ENDPOINT'").fetchall()
269
+ for eid, props_json in rows:
270
+ url = eid.replace('E:', '')
271
+ props = json.loads(props_json) if props_json else {}
272
+ fn_name = props.get('api_fn', '')
273
+ if fn_name:
274
+ table[fn_name] = {'url': url, 'verb': props.get('verb', '?'),
275
+ 'file': props.get('source_file', '')}
276
+ except Exception:
277
+ pass
278
+ return table
279
+
280
+
281
+ _API_CACHE_PATH = os.path.join(INDEX_DIR, '.api-table-cache.json')
282
+
283
+
284
+ def _save_api_cache(api_table, api_dir):
285
+ """保存 api_table 到缓存文件 (含 api_dir 的 mtime 作为失效依据)。"""
286
+ import time
287
+ cache = {
288
+ 'api_dir_mtime': os.path.getmtime(api_dir) if os.path.isdir(api_dir) else 0,
289
+ 'saved_at': time.time(),
290
+ 'table': api_table,
291
+ }
292
+ with open(_API_CACHE_PATH, 'w', encoding='utf-8') as f:
293
+ json.dump(cache, f, ensure_ascii=False)
294
+
295
+
296
+ def _load_api_cache(api_dir):
297
+ """从缓存文件加载 api_table。如果缓存不存在或过期返回 {}。"""
298
+ if not os.path.isfile(_API_CACHE_PATH):
299
+ return {}
300
+ try:
301
+ cache = json.load(open(_API_CACHE_PATH, encoding='utf-8'))
302
+ # 检查 api_dir mtime 是否变了
303
+ current_mtime = os.path.getmtime(api_dir) if api_dir and os.path.isdir(api_dir) else 0
304
+ if cache.get('api_dir_mtime') == current_mtime:
305
+ return cache.get('table', {})
306
+ except Exception:
307
+ pass
308
+ return {}
309
+
310
+
311
+ def _update_vue_file(con, filepath, api_table, stats):
312
+ """Re-extract traces from one Vue file and UPSERT its button entities + edges."""
313
+ try:
314
+ content = open(filepath, 'rb').read().decode('utf-8', errors='replace')
315
+ except Exception:
316
+ return
317
+ if '@click' not in content:
318
+ return
319
+
320
+ traces = ts_trace_clicks(filepath, content, api_table)
321
+ vue_norm = filepath.replace('\\', '/')
322
+ # Normalize vue_short MUST match kg_build.py's format exactly
323
+ if '/views/' in vue_norm:
324
+ vue_short = vue_norm.split('/views/')[-1]
325
+ elif '/pages/' in vue_norm:
326
+ vue_short = vue_norm.split('/pages/')[-1]
327
+ else:
328
+ vue_short = os.path.basename(filepath)
329
+
330
+ for t in traces:
331
+ handler = t.get('handler', '')
332
+ eid = 'B:%s:%s' % (vue_short, handler)
333
+ # UPSERT entity (INSERT OR REPLACE, no duplicates)
334
+ upsert_entity(con, eid, 'BUTTON', handler, '',
335
+ {'button_text': t.get('button', ''),
336
+ 'vue_file': vue_norm,
337
+ 'confidence': t.get('confidence', '')})
338
+ upsert_alias(con, handler.lower(), eid)
339
+ stats['entities_upserted'] += 1
340
+
341
+ # Delete ALL old edges from this button first, then insert fresh
342
+ delete_edges_from(con, eid)
343
+ ep = t.get('endpoint')
344
+ if ep and ep != 'None':
345
+ to_eid = 'E:%s' % ep
346
+ upsert_edge(con, eid, to_eid, 'clicks', props={
347
+ 'button': t.get('button', ''),
348
+ 'confidence': t.get('confidence', ''),
349
+ })
350
+ stats['edges_updated'] += 1
351
+
352
+
353
+ def _update_api_file(con, filepath, stats):
354
+ """Re-extract API functions from one .ts file and UPSERT endpoint entities."""
355
+ # Re-use the bulk api_table extraction on this single file's directory
356
+ api_dir = os.path.dirname(filepath)
357
+ file_table = build_api_fn_table(api_dir)
358
+ for fn_name, info in file_table.items():
359
+ # Only update functions defined in THIS file
360
+ if info.get('file', '').replace('\\', '/') != filepath.replace('\\', '/'):
361
+ continue
362
+ eid = 'E:%s' % info['url']
363
+ upsert_entity(con, eid, 'ENDPOINT', info['url'], '',
364
+ {'verb': info.get('verb', '?'),
365
+ 'api_fn': fn_name,
366
+ 'source_file': filepath.replace('\\', '/')})
367
+ upsert_alias(con, fn_name.lower(), eid)
368
+ stats['entities_upserted'] += 1
369
+
370
+
371
+ def _update_test_file(con, filepath, stats):
372
+ """Re-extract test assertions from one Test.java and UPSERT test entities."""
373
+ tests = extract_test_assertions(os.path.dirname(filepath))
374
+ # Filter to only this file
375
+ file_norm = filepath.replace('\\', '/')
376
+ for t in tests:
377
+ if t.get('file', '').replace('\\', '/') != file_norm:
378
+ continue
379
+ name = '%s.%s' % (t['class'], t['method'])
380
+ eid = 'T:%s' % name
381
+ upsert_entity(con, eid, 'TEST', name, '',
382
+ {'display': t.get('display_name', ''),
383
+ 'deps': t.get('injected_deps', []),
384
+ 'assertions': t.get('assertions', []),
385
+ 'package': t.get('package', '')})
386
+ upsert_alias(con, name.lower(), eid)
387
+ stats['entities_upserted'] += 1
388
+
389
+
390
+ def _cleanup_deleted_file(con, filepath):
391
+ """Remove entities belonging to a deleted file."""
392
+ rel = filepath.replace('\\', '/')
393
+ # Delete buttons from this vue file
394
+ if '.vue' in rel:
395
+ vue_short = rel.split('/views/')[-1] if '/views/' in rel else os.path.basename(rel)
396
+ con.execute("DELETE FROM entities WHERE id LIKE 'B:%s:%%'" % vue_short.replace("'", "''"))
397
+ con.execute("DELETE FROM edges WHERE from_id LIKE 'B:%s:%%'" % vue_short.replace("'", "''"))
398
+
399
+
400
+ if __name__ == '__main__':
401
+ if len(sys.argv) > 1:
402
+ changed = sys.argv[1:]
403
+ else:
404
+ print('Auto-detecting changes via git diff...')
405
+ changed = get_changed_files()
406
+
407
+ if not changed:
408
+ print('No changes detected.')
409
+ sys.exit(0)
410
+
411
+ # Filter to relevant file types
412
+ relevant = [f for f in changed if f.endswith(('.vue', '.ts', '.java'))]
413
+ print('Changed files: %d total, %d relevant' % (len(changed), len(relevant)))
414
+
415
+ if relevant:
416
+ import time
417
+ t0 = time.time()
418
+ stats = incremental_update(relevant)
419
+ elapsed = time.time() - t0
420
+ print('Incremental update done in %.1fs' % elapsed)
421
+ print(' Vue: %d, TS: %d, Java: %d' % (stats['vue'], stats['ts'], stats['java_test']))
422
+ print(' Entities UPSERTed: %d' % stats['entities_upserted'])
423
+ print(' Edges updated: %d' % stats['edges_updated'])
424
+ else:
425
+ print('No relevant files to update.')