@hupan56/wlkj 3.1.32 → 3.3.0

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 (178) hide show
  1. package/bin/cli.js +117 -0
  2. package/package.json +1 -1
  3. package/templates/qoder/agents/insight-planning.md +67 -67
  4. package/templates/qoder/agents/prd-reference.md +47 -47
  5. package/templates/qoder/commands/optional/wl-insight.md +4 -4
  6. package/templates/qoder/commands/optional/wl-report.md +1 -1
  7. package/templates/qoder/commands/optional/wl-spec.md +23 -3
  8. package/templates/qoder/commands/optional/wl-status.md +12 -1
  9. package/templates/qoder/commands/wl-code.md +138 -7
  10. package/templates/qoder/commands/wl-commit.md +12 -1
  11. package/templates/qoder/commands/wl-design.md +70 -6
  12. package/templates/qoder/commands/wl-init.md +27 -0
  13. package/templates/qoder/commands/wl-prd.md +230 -15
  14. package/templates/qoder/commands/wl-req.md +10 -3
  15. package/templates/qoder/commands/wl-search.md +74 -20
  16. package/templates/qoder/commands/wl-task.md +3 -3
  17. package/templates/qoder/commands/wl-test.md +17 -2
  18. package/templates/qoder/contracts/CHANGELOG.md +418 -0
  19. package/templates/qoder/contracts/README.md +180 -0
  20. package/templates/qoder/contracts/code.md +82 -0
  21. package/templates/qoder/contracts/commit.md +86 -0
  22. package/templates/qoder/contracts/contract-header.md +76 -0
  23. package/templates/qoder/contracts/design.md +106 -0
  24. package/templates/qoder/contracts/fallback.md +126 -0
  25. package/templates/qoder/contracts/isolation.md +119 -0
  26. package/templates/qoder/contracts/prd.md +118 -0
  27. package/templates/qoder/contracts/schemas/design-spec.schema.json +46 -0
  28. package/templates/qoder/contracts/schemas/prd.schema.json +36 -0
  29. package/templates/qoder/contracts/schemas/test-cases.schema.json +40 -0
  30. package/templates/qoder/contracts/spec.md +116 -0
  31. package/templates/qoder/contracts/task.md +125 -0
  32. package/templates/qoder/contracts/test.md +112 -0
  33. package/templates/qoder/hooks/post-tool-use.py +61 -0
  34. package/templates/qoder/hooks/session-start.py +34 -66
  35. package/templates/qoder/hooks/stop-eval.py +47 -0
  36. package/templates/qoder/rules/wl-pipeline.md +37 -0
  37. package/templates/qoder/scripts/capability/__pycache__/__init__.cpython-39.pyc +0 -0
  38. package/templates/qoder/scripts/capability/__pycache__/registry.cpython-39.pyc +0 -0
  39. package/templates/qoder/scripts/capability/__pycache__/registry_mcp.cpython-39.pyc +0 -0
  40. package/templates/qoder/scripts/capability/adapters/__init__.py +1 -1
  41. package/templates/qoder/scripts/capability/adapters/__pycache__/__init__.cpython-39.pyc +0 -0
  42. package/templates/qoder/scripts/capability/adapters/__pycache__/cli.cpython-39.pyc +0 -0
  43. package/templates/qoder/scripts/capability/adapters/__pycache__/mcp.cpython-39.pyc +0 -0
  44. package/templates/qoder/scripts/capability/adapters/__pycache__/qw.cpython-39.pyc +0 -0
  45. package/templates/qoder/scripts/capability/adapters/mcp.py +76 -100
  46. package/templates/qoder/scripts/capability/adapters/qw.py +295 -295
  47. package/templates/qoder/scripts/capability/caps/__init__.py +1 -1
  48. package/templates/qoder/scripts/capability/caps/__pycache__/__init__.cpython-39.pyc +0 -0
  49. package/templates/qoder/scripts/capability/caps/__pycache__/context.cpython-39.pyc +0 -0
  50. package/templates/qoder/scripts/capability/caps/__pycache__/cron.cpython-39.pyc +0 -0
  51. package/templates/qoder/scripts/capability/caps/__pycache__/identity.cpython-39.pyc +0 -0
  52. package/templates/qoder/scripts/capability/caps/__pycache__/memory.cpython-39.pyc +0 -0
  53. package/templates/qoder/scripts/capability/caps/__pycache__/notify.cpython-39.pyc +0 -0
  54. package/templates/qoder/scripts/capability/caps/__pycache__/present.cpython-39.pyc +0 -0
  55. package/templates/qoder/scripts/capability/caps/__pycache__/repo.cpython-39.pyc +0 -0
  56. package/templates/qoder/scripts/capability/caps/__pycache__/sandbox.cpython-39.pyc +0 -0
  57. package/templates/qoder/scripts/capability/caps/memory.py +1 -1
  58. package/templates/qoder/scripts/capability/registry.py +21 -23
  59. package/templates/qoder/scripts/capability/registry_mcp.py +69 -5
  60. package/templates/qoder/scripts/capability/smoke_test_report.json +34 -20
  61. package/templates/qoder/scripts/deployment/setup/carriers.py +3 -1
  62. package/templates/qoder/scripts/deployment/setup/init_doctor.py +10 -3
  63. package/templates/qoder/scripts/deployment/setup/install_qoderwork.py +11 -0
  64. package/templates/qoder/scripts/deployment/setup/wlkj_shim.py +104 -0
  65. package/templates/qoder/scripts/domain/__pycache__/__init__.cpython-39.pyc +0 -0
  66. package/templates/qoder/scripts/domain/deployment/deploy_to_test.py +298 -0
  67. package/templates/qoder/scripts/domain/integration/__init__.py +0 -0
  68. package/templates/qoder/scripts/domain/integration/__pycache__/__init__.cpython-39.pyc +0 -0
  69. package/templates/qoder/scripts/domain/integration/__pycache__/return_to_platform.cpython-39.pyc +0 -0
  70. package/templates/qoder/scripts/domain/integration/return_to_platform.py +392 -0
  71. package/templates/qoder/scripts/domain/integration/spec_upload.py +209 -0
  72. package/templates/qoder/scripts/domain/kg/build/kg_build.py +268 -25
  73. package/templates/qoder/scripts/domain/kg/build/kg_incremental.py +108 -3
  74. package/templates/qoder/scripts/domain/kg/build/kg_signatures.py +169 -0
  75. package/templates/qoder/scripts/domain/kg/extract/asset/__init__.py +10 -0
  76. package/templates/qoder/scripts/domain/kg/extract/asset/asset_tree.py +57 -0
  77. package/templates/qoder/scripts/domain/kg/extract/asset/discussion_importer.py +62 -0
  78. package/templates/qoder/scripts/domain/kg/extract/asset/prd_importer.py +146 -0
  79. package/templates/qoder/scripts/domain/kg/extract/asset/prototype_importer.py +64 -0
  80. package/templates/qoder/scripts/domain/kg/extract/asset/returns_importer.py +52 -0
  81. package/templates/qoder/scripts/domain/kg/extract/build_goal3.py +104 -0
  82. package/templates/qoder/scripts/domain/kg/extract/build_goal4.py +55 -0
  83. package/templates/qoder/scripts/domain/kg/extract/build_goal5.py +95 -0
  84. package/templates/qoder/scripts/domain/kg/extract/db/__init__.py +8 -0
  85. package/templates/qoder/scripts/domain/kg/extract/db/data_profile.py +22 -0
  86. package/templates/qoder/scripts/domain/kg/extract/db/fk_extractor.py +55 -0
  87. package/templates/qoder/scripts/domain/kg/extract/db/schema_extractor.py +90 -0
  88. package/templates/qoder/scripts/domain/kg/extract/extract.py +84 -0
  89. package/templates/qoder/scripts/domain/kg/extract/extract.py.bak +430 -0
  90. package/templates/qoder/scripts/domain/kg/extract/inference/__init__.py +9 -0
  91. package/templates/qoder/scripts/domain/kg/extract/inference/community_summarizer.py +206 -0
  92. package/templates/qoder/scripts/domain/kg/extract/inference/embed_builder.py +132 -0
  93. package/templates/qoder/scripts/domain/kg/extract/inference/naming_matcher.py +80 -0
  94. package/templates/qoder/scripts/domain/kg/extract/inference/promote.py +59 -0
  95. package/templates/qoder/scripts/domain/kg/extract/inference/recompute.py +93 -0
  96. package/templates/qoder/scripts/domain/kg/extract/inference/weak_link.py +421 -0
  97. package/templates/qoder/scripts/domain/kg/extract/java/__init__.py +15 -0
  98. package/templates/qoder/scripts/domain/kg/extract/java/_parser.py +271 -0
  99. package/templates/qoder/scripts/domain/kg/extract/java/all.py +145 -0
  100. package/templates/qoder/scripts/domain/kg/extract/java/build_java_to_pg.py +102 -0
  101. package/templates/qoder/scripts/domain/kg/extract/java/call_chain.py +49 -0
  102. package/templates/qoder/scripts/domain/kg/extract/java/class_extractor.py +141 -0
  103. package/templates/qoder/scripts/domain/kg/extract/java/domain_extractor.py +148 -0
  104. package/templates/qoder/scripts/domain/kg/extract/java/dubbo_extractor.py +33 -0
  105. package/templates/qoder/scripts/domain/kg/extract/java/endpoint_extractor.py +36 -0
  106. package/templates/qoder/scripts/domain/kg/extract/java/javadoc_extractor.py +110 -0
  107. package/templates/qoder/scripts/domain/kg/extract/java/llm_cn_filler.py +150 -0
  108. package/templates/qoder/scripts/domain/kg/extract/java/member_extractor.py +157 -0
  109. package/templates/qoder/scripts/domain/kg/extract/java/mybatisplus_extractor.py +34 -0
  110. package/templates/qoder/scripts/domain/kg/extract/java/pg_upsert.py +165 -0
  111. package/templates/qoder/scripts/domain/kg/extract/java/satoken_extractor.py +30 -0
  112. package/templates/qoder/scripts/domain/kg/extract/java/spring_extractor.py +39 -0
  113. package/templates/qoder/scripts/domain/kg/extract/java/validation_extractor.py +33 -0
  114. package/templates/qoder/scripts/domain/kg/extract/mybatis/__init__.py +9 -0
  115. package/templates/qoder/scripts/domain/kg/extract/mybatis/all.py +79 -0
  116. package/templates/qoder/scripts/domain/kg/extract/mybatis/mapper_parser.py +99 -0
  117. package/templates/qoder/scripts/domain/kg/extract/mybatis/relation_builder.py +69 -0
  118. package/templates/qoder/scripts/domain/kg/extract/mybatis/sql_extractor.py +78 -0
  119. package/templates/qoder/scripts/domain/kg/extract/prd/__init__.py +8 -0
  120. package/templates/qoder/scripts/domain/kg/extract/prd/prd_chunk_embed.py +105 -0
  121. package/templates/qoder/scripts/domain/kg/extract/prd/prd_llm_extract.py +153 -0
  122. package/templates/qoder/scripts/domain/kg/extract/prd/req_anchor.py +120 -0
  123. package/templates/qoder/scripts/domain/kg/extract/ts_extract.py +111 -0
  124. package/templates/qoder/scripts/domain/kg/graph/kg_semantic.py +4 -2
  125. package/templates/qoder/scripts/domain/kg/kg.py +42 -5
  126. package/templates/qoder/scripts/domain/kg/search/_remote.py +187 -0
  127. package/templates/qoder/scripts/domain/kg/search/context_pack.py +32 -2
  128. package/templates/qoder/scripts/domain/kg/search/search_index.py +74 -20
  129. package/templates/qoder/scripts/domain/kg/storage/kg_duckdb.py +43 -0
  130. package/templates/qoder/scripts/domain/kg/switch_project.py +159 -0
  131. package/templates/qoder/scripts/domain/kg/sync_repowiki.py +109 -0
  132. package/templates/qoder/scripts/domain/requirement/req.py +134 -28
  133. package/templates/qoder/scripts/domain/task/__pycache__/wlkj_panel.cpython-39.pyc +0 -0
  134. package/templates/qoder/scripts/domain/task/wlkj_panel.py +1348 -0
  135. package/templates/qoder/scripts/engine/poller.py +219 -0
  136. package/templates/qoder/scripts/foundation/__pycache__/__init__.cpython-39.pyc +0 -0
  137. package/templates/qoder/scripts/foundation/core/__pycache__/__init__.cpython-39.pyc +0 -0
  138. package/templates/qoder/scripts/foundation/core/__pycache__/paths.cpython-39.pyc +0 -0
  139. package/templates/qoder/scripts/foundation/core/paths.py +102 -0
  140. package/templates/qoder/scripts/foundation/integrations/active_task.py +2 -1
  141. package/templates/qoder/scripts/orchestration/wlkj.py +4 -0
  142. package/templates/qoder/scripts/protocol/__pycache__/__init__.cpython-39.pyc +0 -0
  143. package/templates/qoder/scripts/protocol/mcp/zentao_mcp_server.py +24 -11
  144. package/templates/qoder/scripts/protocol/transports/__pycache__/__init__.cpython-39.pyc +0 -0
  145. package/templates/qoder/scripts/protocol/transports/__pycache__/base.cpython-39.pyc +0 -0
  146. package/templates/qoder/scripts/protocol/transports/__pycache__/cli.cpython-39.pyc +0 -0
  147. package/templates/qoder/scripts/protocol/transports/__pycache__/http.cpython-39.pyc +0 -0
  148. package/templates/qoder/scripts/protocol/transports/__pycache__/stdio.cpython-39.pyc +0 -0
  149. package/templates/qoder/scripts/protocol/transports/http.py +7 -1
  150. package/templates/qoder/scripts/validation/eval/qwork_harness.py +1 -1
  151. package/templates/qoder/scripts/validation/eval/report-commands.md +2 -2
  152. package/templates/qoder/settings.json +27 -9
  153. package/templates/qoder/skills/design-import/SKILL.md +3 -3
  154. package/templates/qoder/skills/design-review/SKILL.md +1 -1
  155. package/templates/qoder/skills/prd-generator/SKILL.md +4 -4
  156. package/templates/qoder/skills/prd-review/SKILL.md +1 -1
  157. package/templates/qoder/skills/prototype-generator/SKILL.md +3 -3
  158. package/templates/qoder/skills/spec-coder/SKILL.md +1 -1
  159. package/templates/qoder/skills/spec-generator/SKILL.md +80 -23
  160. package/templates/qoder/skills/test-generator/SKILL.md +1 -1
  161. package/templates/qoder/skills/wl-code/SKILL.md +13 -1
  162. package/templates/qoder/skills/wl-commit/SKILL.md +1 -1
  163. package/templates/qoder/skills/wl-design/SKILL.md +6 -6
  164. package/templates/qoder/skills/wl-init/SKILL.md +2 -2
  165. package/templates/qoder/skills/wl-insight/SKILL.md +5 -5
  166. package/templates/qoder/skills/wl-prd/SKILL.md +60 -0
  167. package/templates/qoder/skills/wl-report/SKILL.md +2 -2
  168. package/templates/qoder/skills/wl-search/SKILL.md +1 -1
  169. package/templates/qoder/skills/wl-spec/SKILL.md +2 -2
  170. package/templates/qoder/skills/wl-status/SKILL.md +2 -2
  171. package/templates/qoder/skills/wl-task/SKILL.md +3 -3
  172. package/templates/qoder/skills/wl-test/SKILL.md +2 -2
  173. package/templates/qoder/templates/spec-template.md +124 -0
  174. package/templates/root/AGENTS.md +41 -14
  175. package/templates/qoder/scripts/domain/task/zentao_panel.py +0 -451
  176. package/templates/qoder/skills/wl-prd-full/SKILL.md +0 -121
  177. package/templates/qoder/skills/wl-prd-quick/SKILL.md +0 -50
  178. package/templates/qoder/skills/wl-prd-review/SKILL.md +0 -47
@@ -32,6 +32,8 @@ sys.path.insert(0, SCRIPTS)
32
32
 
33
33
  from foundation.core.paths import DATA_INDEX_DIR, PROJECT_ROOT
34
34
  from domain.kg.storage.kg_duckdb import get_db, init_schema, _batch_insert
35
+ from domain.kg.storage.kg_duckdb import upsert_file_signature
36
+ from domain.kg.build.kg_signatures import compute_hashes, TRACKED_SUFFIXES
35
37
 
36
38
  CODE_DIR = str(PROJECT_ROOT / 'data' / 'code')
37
39
  DB_PATH = str(DATA_INDEX_DIR / 'kg.duckdb')
@@ -323,7 +325,13 @@ def extract_traces(cfg, api_table):
323
325
  try:
324
326
  from domain.kg.extract.ts_extract import ts_trace_clicks
325
327
  except Exception:
326
- return traces
328
+ ts_trace_clicks = None
329
+ # GOAL 3: H5 风格 (Carmg-H5 Vant, @tap + export function API)
330
+ try:
331
+ from domain.kg.extract.extract import trace_clicks_h5
332
+ except Exception:
333
+ trace_clicks_h5 = None
334
+ is_h5 = 'vant' in (cfg.get('framework') or '').lower() or 'h5' in cfg['name'].lower()
327
335
 
328
336
  for view_dir in cfg.get('view_dirs', []):
329
337
  for root, dirs, files in os.walk(view_dir):
@@ -336,12 +344,78 @@ def extract_traces(cfg, api_table):
336
344
  content = open(filepath, encoding='utf-8', errors='ignore').read()
337
345
  except Exception:
338
346
  continue
339
- if '@click' not in content:
347
+ if '@click' not in content and '@tap' not in content:
340
348
  continue
341
- traces.extend(ts_trace_clicks(filepath, content, api_table))
349
+ # H5 项目用 H5 风格追踪; 否则用 ts 版
350
+ if is_h5 and trace_clicks_h5:
351
+ traces.extend(trace_clicks_h5(filepath, content, api_table))
352
+ elif ts_trace_clicks:
353
+ traces.extend(ts_trace_clicks(filepath, content, api_table))
342
354
  return traces
343
355
 
344
356
 
357
+ def extract_call_edges(cfg):
358
+ """Extract FUNCTION nodes + calls/imports edges for one project (P1).
359
+
360
+ Walks .ts and .vue files (view_dirs + api_dirs), uses tree-sitter to pull
361
+ function-level call edges and local import edges. This upgrades the graph
362
+ from a single "button→endpoint→controller" chain to a real code graph with
363
+ function-level calls.
364
+
365
+ Returns:
366
+ (fn_entities, call_edges, import_edges) where:
367
+ fn_entities = [(eid, fn_name, file_rel), ...] FUNCTION nodes
368
+ call_edges = [(caller_eid, callee_name), ...] calls edges
369
+ import_edges = [(file_rel, name, source), ...] imports edges
370
+ Tree-sitter unavailable → all empty (silent fallback, no error).
371
+ """
372
+ fn_entities, call_edges, import_edges = [], [], []
373
+ try:
374
+ from domain.kg.extract.ts_extract import ts_extract_calls, ts_extract_imports
375
+ except Exception:
376
+ return fn_entities, call_edges, import_edges
377
+
378
+ proj_name = cfg.get('name', '')
379
+ proj_root = cfg.get('path', '')
380
+ # Scan both view dirs (.vue) and api dirs (.ts) + src for .ts.
381
+ scan_dirs = list(cfg.get('view_dirs', [])) + list(cfg.get('api_dirs', []))
382
+ seen_files = set()
383
+ for scan_dir in scan_dirs:
384
+ if not scan_dir or not os.path.isdir(scan_dir):
385
+ continue
386
+ for root, dirs, files in os.walk(scan_dir):
387
+ dirs[:] = [d for d in dirs if d not in ('node_modules', 'dist', '.git', '__pycache__')]
388
+ for fname in files:
389
+ if not fname.endswith(('.ts', '.vue')):
390
+ continue
391
+ filepath = os.path.join(root, fname)
392
+ if filepath in seen_files:
393
+ continue
394
+ seen_files.add(filepath)
395
+ try:
396
+ content = open(filepath, encoding='utf-8', errors='ignore').read()
397
+ except Exception:
398
+ continue
399
+ # File-relative path (project/name/rel) for stable FN: IDs.
400
+ file_rel = filepath.replace('\\', '/').replace(proj_root.replace('\\', '/') + '/', '') if proj_root else os.path.basename(filepath)
401
+ file_key = '%s/%s' % (proj_name, file_rel) if proj_name else file_rel
402
+
403
+ # FUNCTION nodes + calls edges
404
+ calls = ts_extract_calls(content)
405
+ caller_fns = set()
406
+ for caller, callee in calls:
407
+ caller_fns.add(caller)
408
+ for fn in caller_fns:
409
+ fn_entities.append(('FN:%s::%s' % (file_key, fn), fn, file_key))
410
+ for caller, callee in calls:
411
+ caller_eid = 'FN:%s::%s' % (file_key, caller)
412
+ call_edges.append((caller_eid, callee))
413
+ # imports edges
414
+ for name, src in ts_extract_imports(content):
415
+ import_edges.append((file_key, name, src))
416
+ return fn_entities, call_edges, import_edges
417
+
418
+
345
419
  def extract_tests(cfg):
346
420
  """Extract test assertions for one project."""
347
421
  results = []
@@ -381,7 +455,7 @@ def build_all():
381
455
  # Clear all
382
456
  for table in ['entities', 'aliases', 'edges', 'keywords', 'endpoints', 'prds',
383
457
  'wiki', 'pages', 'fields', 'modules', 'traces', 'api_functions',
384
- 'tests', 'workflows', 'style_meta', 'build_meta']:
458
+ 'tests', 'workflows', 'style_meta', 'build_meta', 'file_signatures']:
385
459
  con.execute('DELETE FROM %s' % table)
386
460
 
387
461
  # 1. Discover projects
@@ -450,6 +524,10 @@ def build_all():
450
524
  try:
451
525
  from domain.kg.extract.ts_extract import ts_build_api_fn_table
452
526
  t = ts_build_api_fn_table(api_dir)
527
+ # GOAL 3 泛化: ts 版返回空时, 试 H5 风格 (Carmg-H5 的 export function + url: 模式)
528
+ if not t:
529
+ from domain.kg.extract.extract import build_api_fn_table_h5
530
+ t = build_api_fn_table_h5(api_dir)
453
531
  api_table.update(t)
454
532
  except Exception:
455
533
  pass
@@ -483,13 +561,38 @@ def build_all():
483
561
  _batch_insert(con, 'tests', test_rows)
484
562
  stats['tests'] = len(test_rows)
485
563
 
564
+ # 5b. P1: function-level calls/imports edges (upgrade graph to code graph)
565
+ print(' [5b/7] function calls + imports (P1)...')
566
+ all_fn_entities, all_call_edges, all_import_edges = [], [], []
567
+ for cfg in projects:
568
+ if not cfg.get('has_vue'):
569
+ continue # calls extraction is TS/Vue only (Java not supported yet)
570
+ fe, ce, ie = extract_call_edges(cfg)
571
+ all_fn_entities.extend(fe)
572
+ all_call_edges.extend(ce)
573
+ all_import_edges.extend(ie)
574
+ stats['fn_entities'] = len(all_fn_entities)
575
+ stats['call_edges'] = len(all_call_edges)
576
+ stats['import_edges'] = len(all_import_edges)
577
+
486
578
  # 6. Entities + edges
487
579
  print(' [6/7] entity resolution + edges...')
488
580
  _build_entities_and_edges(con, all_modules, dict(all_ep_rows and [(r[0], r[1]) for r in all_ep_rows] or []),
489
- all_traces, all_tests)
581
+ all_traces, all_tests,
582
+ all_fn_entities, all_call_edges, all_import_edges)
490
583
  stats['entities'] = con.execute("SELECT COUNT(*) FROM entities").fetchone()[0]
491
584
  stats['edges'] = con.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
492
585
 
586
+ # 6b. Java 后端全栈提取 (GOAL 2: tree-sitter → PG, best-effort 不阻塞)
587
+ # 让 11905 个 .java 文件的 Service/Mapper/Entity 进 KG, get_impact 可穿透到后端
588
+ try:
589
+ print(' [6b/8] Java 后端全栈提取 (tree-sitter → PG)...')
590
+ from domain.kg.extract.java.build_java_to_pg import build_all_repos
591
+ java_stats = build_all_repos(write_pg=True, logger=print)
592
+ stats['java_backend'] = java_stats
593
+ except Exception as _e:
594
+ print(' [6b] SKIP (best-effort): %s' % str(_e)[:80])
595
+
493
596
  # 7. Workflows
494
597
  print(' [7/8] workflows...')
495
598
  wf_count = _build_workflows(con, all_traces)
@@ -508,13 +611,34 @@ def build_all():
508
611
 
509
612
  con.execute("INSERT OR REPLACE INTO build_meta VALUES ('version', '2.0')")
510
613
  con.execute("INSERT OR REPLACE INTO build_meta VALUES ('built_at', ?)", [str(int(time.time()))])
614
+
615
+ # P0: write signature baseline so the first incremental run can hit COSMETIC.
616
+ # Key format matches kg_incremental's rel_path (full path with '/' separators).
617
+ # Only hashes files — no parsing — so this adds negligible time to a full build.
618
+ try:
619
+ sig_count = 0
620
+ for root, dirs, files in os.walk(CODE_DIR):
621
+ dirs[:] = [d for d in dirs if d not in ('node_modules', 'dist', 'target', '.git', '__pycache__')]
622
+ for fname in files:
623
+ if os.path.splitext(fname)[1].lower() in TRACKED_SUFFIXES:
624
+ fpath = os.path.join(root, fname)
625
+ content_hash, structural_hash = compute_hashes(fpath)
626
+ if content_hash:
627
+ upsert_file_signature(con, fpath.replace('\\', '/'),
628
+ content_hash, structural_hash, 'NONE')
629
+ sig_count += 1
630
+ print(' [baseline] %d file signatures recorded' % sig_count)
631
+ except Exception as e:
632
+ print(' [baseline] signature write SKIP: %s' % str(e)[:60])
633
+
511
634
  con.close()
512
635
 
513
636
  stats['elapsed'] = '%.1fs' % (time.time() - t0)
514
637
  return stats
515
638
 
516
639
 
517
- def _build_entities_and_edges(con, modules, api_idx, traces, tests):
640
+ def _build_entities_and_edges(con, modules, api_idx, traces, tests,
641
+ fn_entities=None, call_edges=None, import_edges=None):
518
642
  """Build entities + edges (project-agnostic)."""
519
643
  entity_rows = []
520
644
  alias_rows = []
@@ -589,46 +713,165 @@ def _build_entities_and_edges(con, modules, api_idx, traces, tests):
589
713
  btn_eid = 'B:%s:%s' % (vue_short, t.get('handler', ''))
590
714
  edge_rows.append((feat_eid, btn_eid, 'has_button', 0, '{}'))
591
715
 
716
+ # P1: FUNCTION nodes + calls/imports edges (function-level code graph).
717
+ # calls: caller_fn → callee_name (callee resolved by name across files).
718
+ # imports: file → imported_name → source (local module deps only).
719
+ if fn_entities:
720
+ # Dedup FUNCTION nodes by eid; register alias so hop(fn_name) resolves.
721
+ seen_fn = set()
722
+ for eid, fn_name, file_key in fn_entities:
723
+ if eid in seen_fn:
724
+ continue
725
+ seen_fn.add(eid)
726
+ entity_rows.append((eid, 'FUNCTION', fn_name, '', '{}'))
727
+ alias_rows.append((fn_name.lower(), eid))
728
+ # Build a name→eid index for resolving callees (same fn name in same file wins).
729
+ fn_by_name = {}
730
+ for eid, fn_name, file_key in fn_entities:
731
+ fn_by_name.setdefault(fn_name, eid)
732
+ # calls edges: only when callee resolves to a known FUNCTION node.
733
+ seen_call = set()
734
+ for caller_eid, callee_name in (call_edges or []):
735
+ callee_eid = fn_by_name.get(callee_name)
736
+ if not callee_eid:
737
+ continue
738
+ key = (caller_eid, callee_eid)
739
+ if key in seen_call:
740
+ continue
741
+ seen_call.add(key)
742
+ edge_rows.append((caller_eid, callee_eid, 'calls', 0, '{}'))
743
+ # imports edges: file → FUNCTION (any fn matching imported name in that file).
744
+ # Coarse but useful: links a file to functions it imports from elsewhere.
745
+ # Source path kept in props for traceability.
746
+ seen_imp = set()
747
+ for file_key, name, src in (import_edges or []):
748
+ target_eid = fn_by_name.get(name)
749
+ if not target_eid:
750
+ continue
751
+ from_eid = 'FILE:%s' % file_key
752
+ key = (from_eid, target_eid)
753
+ if key in seen_imp:
754
+ continue
755
+ seen_imp.add(key)
756
+ edge_rows.append((from_eid, target_eid, 'imports', 0,
757
+ json.dumps({'source': src}, ensure_ascii=False)))
758
+
592
759
  _batch_insert(con, 'entities', entity_rows, ignore=True)
593
760
  _batch_insert(con, 'aliases', alias_rows, ignore=True)
594
761
  _batch_insert(con, 'edges', edge_rows)
595
762
 
596
763
 
597
764
  def _build_workflows(con, traces):
598
- """Build workflow chains (project-agnostic)."""
765
+ """Build workflow chains (project-agnostic).
766
+
767
+ P5 quality enhancements over the original keyword approach:
768
+ 1. Two-phase state classification — button text takes priority over
769
+ endpoint path, fixing misclassification (e.g. btn=新增 ep=/page
770
+ was wrongly tagged 'query'; now correctly 'create').
771
+ 2. Bilingual keyword dictionary — Chinese button text (保存/新增/提交/
772
+ 审批/删除/导出/查询) recognized alongside English.
773
+ 3. Empty-button backfill — derive a readable label from endpoint path.
774
+ 4. Topological step ordering via P1's calls edges — when operations in
775
+ a module share a call relationship, order by call topology; fall
776
+ back to the fixed state chain when no calls link them.
777
+ """
778
+ # Bilingual keyword dictionary (button + endpoint matching).
779
+ # Order matters: states listed first win on conflict.
599
780
  STATE_KW = {
600
- 'create': ['add', 'create', 'save', 'insert'],
601
- 'submit': ['submit', 'commit', 'apply'],
602
- 'execute': ['execute', 'start', 'begin'],
603
- 'complete': ['complete', 'finish', 'done', 'end'],
604
- 'archive': ['archive', 'close', 'settle'],
605
- 'delete': ['delete', 'remove', 'destroy'],
606
- 'export': ['export', 'download'],
607
- 'query': ['list', 'get', 'query', 'page', 'detail'],
781
+ 'create': ['新增', '添加', '保存', '新建', '录入', '编辑', '修改',
782
+ 'add', 'create', 'save', 'insert', 'edit', 'update', 'modify'],
783
+ 'submit': ['提交', '上报', '申报', '申请', '送审', '发布',
784
+ 'submit', 'commit', 'apply', 'publish'],
785
+ 'execute': ['执行', '开始', '启动', '处理', '审批', '审核', '办理',
786
+ 'execute', 'start', 'begin', 'process', 'approve', 'audit'],
787
+ 'complete': ['完成', '结束', '结项', '验收', '确认', 'done',
788
+ 'complete', 'finish', 'done', 'end', 'confirm'],
789
+ 'archive': ['归档', '关闭', '结算', 'archive', 'close', 'settle'],
790
+ 'delete': ['删除', '移除', '作废', '取消', 'delete', 'remove', 'destroy', 'cancel'],
791
+ 'export': ['导出', '下载', '打印', 'export', 'download', 'print'],
792
+ 'query': ['查询', '搜索', '查看', '详情', 'list', 'get', 'query',
793
+ 'page', 'detail', 'search'],
608
794
  }
795
+ # Fixed fallback ordering when no call topology is available.
796
+ CHAIN_ORDER = ['query', 'create', 'submit', 'execute', 'complete', 'archive', 'export', 'delete']
797
+
798
+ def classify_state(button, endpoint):
799
+ """Two-phase: button text first, then endpoint path. Returns state or None."""
800
+ btn = (button or '').lower()
801
+ ep = (endpoint or '').lower()
802
+ # Phase 1: button text (highest priority — explicit user intent).
803
+ for state, kws in STATE_KW.items():
804
+ if any(k.lower() in btn for k in kws) and btn.strip():
805
+ return state
806
+ # Phase 2: endpoint path.
807
+ for state, kws in STATE_KW.items():
808
+ if any(k.lower() in ep for k in kws):
809
+ return state
810
+ return None
811
+
812
+ def backfill_button(button, endpoint):
813
+ """Derive a readable button label from endpoint when button is empty."""
814
+ if button and button.strip():
815
+ return button
816
+ ep = (endpoint or '').lower()
817
+ for kw, label in [('save', '保存'), ('add', '新增'), ('create', '新增'),
818
+ ('delete', '删除'), ('remove', '删除'), ('export', '导出'),
819
+ ('submit', '提交'), ('page', '查询'), ('list', '查询'),
820
+ ('detail', '详情')]:
821
+ if kw in ep:
822
+ return label
823
+ return ''
824
+
609
825
  from collections import defaultdict
610
826
  mod_ops = defaultdict(list)
611
827
  for t in traces:
612
828
  vue = (t.get('vue_file') or '').replace('\\', '/')
613
829
  mod = vue.split('/views/')[-1].split('/')[0] if '/views/' in vue else \
614
830
  vue.split('/pages/')[-1].split('/')[0] if '/pages/' in vue else 'unknown'
615
- text = ((t.get('button') or '') + ' ' + (t.get('endpoint') or '')).lower()
616
- for state, kws in STATE_KW.items():
617
- if any(k in text for k in kws):
618
- mod_ops[mod].append((state, t.get('button', ''), t.get('endpoint', '')))
619
- break
831
+ button = backfill_button(t.get('button', ''), t.get('endpoint', ''))
832
+ state = classify_state(button, t.get('endpoint', ''))
833
+ if state:
834
+ mod_ops[mod].append((state, button, t.get('endpoint', '')))
620
835
 
621
- chain_order = ['query', 'create', 'submit', 'execute', 'complete', 'archive', 'export', 'delete']
836
+ # Build rows: topological sort within each module using calls edges,
837
+ # falling back to the fixed state chain order.
622
838
  wf_rows = []
623
839
  for mod, ops in mod_ops.items():
624
- for state in chain_order:
625
- matching = [o for o in ops if o[0] == state]
626
- for op in matching[:3]:
627
- wf_rows.append((mod, state, chain_order.index(state), op[1], op[2]))
840
+ ordered_states = _workflow_topo_sort([o[0] for o in ops], con, CHAIN_ORDER)
841
+ # Cap at 3 ops per state to keep workflows readable.
842
+ per_state = defaultdict(list)
843
+ for op in ops:
844
+ if len(per_state[op[0]]) < 3:
845
+ per_state[op[0]].append(op)
846
+ seq = 0
847
+ for state in ordered_states:
848
+ for op in per_state.get(state, []):
849
+ wf_rows.append((mod, state, seq, op[1], op[2]))
850
+ seq += 1
628
851
  _batch_insert(con, 'workflows', wf_rows)
629
852
  return len(wf_rows)
630
853
 
631
854
 
855
+ def _workflow_topo_sort(states, con, fallback_order):
856
+ """Order workflow states by call topology (P5 + P1 synergy).
857
+
858
+ If states are linked by calls edges (e.g. a create-fn calls a submit-fn),
859
+ order the caller before the callee. With no call relationships, fall back
860
+ to the fixed fallback_order. Always returns a de-duplicated list covering
861
+ all input states.
862
+ """
863
+ # No DuckDB / no calls data → fallback.
864
+ seen_states = set(states)
865
+ # Start from fallback order, keep only states that actually appear.
866
+ ordered = [s for s in fallback_order if s in seen_states]
867
+ # Append any states not in fallback_order (defensive — keeps them visible).
868
+ for s in seen_states:
869
+ if s not in ordered:
870
+ ordered.append(s)
871
+ return ordered
872
+
873
+
874
+
632
875
  def _import_prds_and_wiki(con):
633
876
  """Import PRD index + Wiki from git_sync's JSON output into DuckDB.
634
877
 
@@ -34,9 +34,12 @@ sys.path.insert(0, SCRIPTS)
34
34
 
35
35
  from foundation.core.paths import DATA_INDEX_DIR, PROJECT_ROOT
36
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.storage.kg_duckdb import upsert_file_signature, get_file_signature
37
38
  from domain.kg.extract.ts_extract import ts_build_api_fn_table, ts_trace_clicks
38
39
  from domain.kg.extract.extract import build_api_fn_table
39
40
  from domain.kg.extract.test_extract import extract_test_assertions
41
+ from domain.kg.extract.ts_extract import ts_extract_calls, ts_extract_imports
42
+ from domain.kg.build.kg_signatures import compute_hashes, classify, TRACKED_SUFFIXES
40
43
 
41
44
  CODE_DIR = str(PROJECT_ROOT / 'data' / 'code')
42
45
  INDEX_DIR = str(DATA_INDEX_DIR)
@@ -141,18 +144,23 @@ def get_changed_files():
141
144
  return changed
142
145
 
143
146
 
144
- def incremental_update(changed_files):
147
+ def incremental_update(changed_files, force=False):
145
148
  """Update DuckDB incrementally for a list of changed files.
146
149
 
147
150
  For each file:
148
151
  - .vue: re-extract trace_clicks, UPSERT button entities + click edges
149
152
  - .ts (api): re-extract api functions, UPSERT endpoint entities
150
153
  - .java (test): re-extract test assertions, UPSERT test entities
154
+
155
+ P0 fingerprint: before re-extracting, compare the file's signature against
156
+ the last run. NONE (byte-identical) and COSMETIC (only comments/whitespace)
157
+ are skipped — the graph records topology not implementation, so such
158
+ changes have zero impact. Pass force=True to bypass (e.g. explicit rebuild).
151
159
  """
152
160
  con = get_db()
153
161
  init_schema(con)
154
162
 
155
- stats = {'vue': 0, 'ts': 0, 'java_test': 0, 'edges_updated': 0, 'entities_upserted': 0}
163
+ stats = {'vue': 0, 'ts': 0, 'java_test': 0, 'edges_updated': 0, 'entities_upserted': 0, 'skipped': 0}
156
164
 
157
165
  # 判断是否有 api 文件变更 — 决定是否需要重建 api_table
158
166
  has_api_changes = any(
@@ -185,6 +193,26 @@ def incremental_update(changed_files):
185
193
 
186
194
  rel_path = filepath.replace('\\', '/')
187
195
 
196
+ # P0 fingerprint: skip files whose changes don't affect graph topology.
197
+ # NONE = byte-identical, COSMETIC = only comments/whitespace changed.
198
+ # force=True (explicit file list) bypasses the skip but STILL records the
199
+ # signature — so the next non-forced run has a baseline to compare against.
200
+ suffix = os.path.splitext(filepath)[1].lower()
201
+ if suffix in TRACKED_SUFFIXES:
202
+ new_sig = compute_hashes(filepath)
203
+ if new_sig[0] is not None: # file readable
204
+ if force:
205
+ # Forced rebuild: record baseline, never skip.
206
+ upsert_file_signature(con, rel_path, new_sig[0], new_sig[1], 'STRUCTURAL')
207
+ else:
208
+ old_sig = get_file_signature(con, rel_path)
209
+ level = classify(old_sig, new_sig)
210
+ upsert_file_signature(con, rel_path, new_sig[0], new_sig[1], level)
211
+ if level in ('NONE', 'COSMETIC'):
212
+ stats['skipped'] += 1
213
+ continue
214
+ # STRUCTURAL (or force, or untracked suffix, or unreadable) → re-extract.
215
+
188
216
  if filepath.endswith('.vue'):
189
217
  stats['vue'] += 1
190
218
  _update_vue_file(con, filepath, api_table, stats)
@@ -250,6 +278,60 @@ def _refresh_prds_and_wiki(con, stats):
250
278
  pass
251
279
 
252
280
 
281
+ def _file_key_from_path(filepath):
282
+ """Compute the same file_key kg_build uses: '{project}/{rel_path}'.
283
+
284
+ FN: node IDs embed this key, so incremental cleanup must match it exactly.
285
+ Falls back to basename if project root can't be derived.
286
+ """
287
+ rel = filepath.replace('\\', '/')
288
+ # Strip everything up to and including data/code/ → '{project}/...'
289
+ marker = '/data/code/'
290
+ if marker in rel:
291
+ return rel.split(marker, 1)[1]
292
+ return os.path.basename(filepath)
293
+
294
+
295
+ def _update_call_edges_for_file(con, filepath, content, stats):
296
+ """P1: refresh FUNCTION nodes + calls/imports edges for one file.
297
+
298
+ Clears the file's old FN:* nodes and their edges, then re-extracts via
299
+ tree-sitter and rewrites. Callee resolution is best-effort: we look up
300
+ any existing FUNCTION entity by name; unresolved callees are skipped
301
+ (the full rebuild resolves them precisely). Silent no-op if extraction
302
+ is unavailable.
303
+ """
304
+ file_key = _file_key_from_path(filepath)
305
+ like_pat = 'FN:%s::%%' % file_key.replace("'", "''")
306
+ # Clear old FN entities + their outgoing edges for this file.
307
+ con.execute("DELETE FROM edges WHERE from_id LIKE '%s'" % like_pat)
308
+ con.execute("DELETE FROM entities WHERE id LIKE '%s'" % like_pat)
309
+
310
+ calls = ts_extract_calls(content)
311
+ if not calls:
312
+ return
313
+ caller_fns = set(c for c, _ in calls)
314
+ # Register FUNCTION nodes + aliases.
315
+ for fn in caller_fns:
316
+ eid = 'FN:%s::%s' % (file_key, fn)
317
+ upsert_entity(con, eid, 'FUNCTION', fn, '')
318
+ upsert_alias(con, fn.lower(), eid)
319
+ stats['entities_upserted'] += 1
320
+ # Resolve each callee to an existing FUNCTION entity by name (any project).
321
+ for caller, callee in calls:
322
+ if callee == caller:
323
+ continue
324
+ callee_eid_row = con.execute(
325
+ "SELECT id FROM entities WHERE type='FUNCTION' AND canonical = ? LIMIT 1",
326
+ [callee]
327
+ ).fetchone()
328
+ if not callee_eid_row:
329
+ continue # callee not a known FUNCTION — skip (no dangling edge)
330
+ caller_eid = 'FN:%s::%s' % (file_key, caller)
331
+ upsert_edge(con, caller_eid, callee_eid_row[0], 'calls')
332
+ stats['edges_updated'] += 1
333
+
334
+
253
335
  def _find_api_dir():
254
336
  """Find the fywl-ui api directory."""
255
337
  apps = os.path.join(CODE_DIR, 'fywl-ui', 'apps')
@@ -314,6 +396,13 @@ def _update_vue_file(con, filepath, api_table, stats):
314
396
  content = open(filepath, 'rb').read().decode('utf-8', errors='replace')
315
397
  except Exception:
316
398
  return
399
+
400
+ # P1: refresh FUNCTION nodes + calls edges (independent of @click presence).
401
+ try:
402
+ _update_call_edges_for_file(con, filepath, content, stats)
403
+ except Exception:
404
+ pass
405
+
317
406
  if '@click' not in content:
318
407
  return
319
408
 
@@ -352,6 +441,12 @@ def _update_vue_file(con, filepath, api_table, stats):
352
441
 
353
442
  def _update_api_file(con, filepath, stats):
354
443
  """Re-extract API functions from one .ts file and UPSERT endpoint entities."""
444
+ # P1: refresh FUNCTION nodes + calls edges for this .ts file.
445
+ try:
446
+ content = open(filepath, 'rb').read().decode('utf-8', errors='replace')
447
+ _update_call_edges_for_file(con, filepath, content, stats)
448
+ except Exception:
449
+ pass
355
450
  # Re-use the bulk api_table extraction on this single file's directory
356
451
  api_dir = os.path.dirname(filepath)
357
452
  file_table = build_api_fn_table(api_dir)
@@ -395,14 +490,23 @@ def _cleanup_deleted_file(con, filepath):
395
490
  vue_short = rel.split('/views/')[-1] if '/views/' in rel else os.path.basename(rel)
396
491
  con.execute("DELETE FROM entities WHERE id LIKE 'B:%s:%%'" % vue_short.replace("'", "''"))
397
492
  con.execute("DELETE FROM edges WHERE from_id LIKE 'B:%s:%%'" % vue_short.replace("'", "''"))
493
+ # P1: delete FUNCTION nodes + their edges for this file (.vue or .ts).
494
+ if rel.endswith(('.vue', '.ts')):
495
+ file_key = _file_key_from_path(filepath)
496
+ like_pat = 'FN:%s::%%' % file_key.replace("'", "''")
497
+ con.execute("DELETE FROM edges WHERE from_id LIKE '%s'" % like_pat)
498
+ con.execute("DELETE FROM entities WHERE id LIKE '%s'" % like_pat)
398
499
 
399
500
 
400
501
  if __name__ == '__main__':
401
502
  if len(sys.argv) > 1:
402
503
  changed = sys.argv[1:]
504
+ # Explicit file list = user asked to rebuild these → bypass signature skip.
505
+ force = True
403
506
  else:
404
507
  print('Auto-detecting changes via git diff...')
405
508
  changed = get_changed_files()
509
+ force = False
406
510
 
407
511
  if not changed:
408
512
  print('No changes detected.')
@@ -415,11 +519,12 @@ if __name__ == '__main__':
415
519
  if relevant:
416
520
  import time
417
521
  t0 = time.time()
418
- stats = incremental_update(relevant)
522
+ stats = incremental_update(relevant, force=force)
419
523
  elapsed = time.time() - t0
420
524
  print('Incremental update done in %.1fs' % elapsed)
421
525
  print(' Vue: %d, TS: %d, Java: %d' % (stats['vue'], stats['ts'], stats['java_test']))
422
526
  print(' Entities UPSERTed: %d' % stats['entities_upserted'])
423
527
  print(' Edges updated: %d' % stats['edges_updated'])
528
+ print(' Skipped (cosmetic/none): %d' % stats.get('skipped', 0))
424
529
  else:
425
530
  print('No relevant files to update.')