@hupan56/wlkj 3.1.31 → 3.2.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 (70) hide show
  1. package/package.json +1 -1
  2. package/templates/qoder/agents/insight-planning.md +67 -67
  3. package/templates/qoder/agents/prd-reference.md +47 -47
  4. package/templates/qoder/commands/optional/wl-insight.md +276 -276
  5. package/templates/qoder/commands/optional/wl-spec.md +10 -2
  6. package/templates/qoder/commands/wl-code.md +63 -4
  7. package/templates/qoder/commands/wl-design.md +1 -1
  8. package/templates/qoder/commands/wl-prd.md +87 -13
  9. package/templates/qoder/commands/wl-req.md +10 -3
  10. package/templates/qoder/commands/wl-task.md +613 -613
  11. package/templates/qoder/commands/wl-test.md +2 -2
  12. package/templates/qoder/contracts/CHANGELOG.md +418 -0
  13. package/templates/qoder/contracts/README.md +180 -0
  14. package/templates/qoder/contracts/code.md +82 -0
  15. package/templates/qoder/contracts/commit.md +86 -0
  16. package/templates/qoder/contracts/contract-header.md +76 -0
  17. package/templates/qoder/contracts/design.md +106 -0
  18. package/templates/qoder/contracts/fallback.md +126 -0
  19. package/templates/qoder/contracts/isolation.md +119 -0
  20. package/templates/qoder/contracts/prd.md +118 -0
  21. package/templates/qoder/contracts/schemas/design-spec.schema.json +46 -0
  22. package/templates/qoder/contracts/schemas/prd.schema.json +36 -0
  23. package/templates/qoder/contracts/schemas/test-cases.schema.json +40 -0
  24. package/templates/qoder/contracts/spec.md +112 -0
  25. package/templates/qoder/contracts/task.md +125 -0
  26. package/templates/qoder/contracts/test.md +112 -0
  27. package/templates/qoder/hooks/post-tool-use.py +20 -0
  28. package/templates/qoder/hooks/stop-eval.py +47 -0
  29. package/templates/qoder/rules/wl-pipeline.md +37 -0
  30. package/templates/qoder/scripts/deployment/setup/install_qoderwork.py +11 -0
  31. package/templates/qoder/scripts/deployment/setup/setup.py +70 -0
  32. package/templates/qoder/scripts/deployment/setup/wlkj_shim.py +104 -0
  33. package/templates/qoder/scripts/domain/deployment/deploy_to_test.py +298 -0
  34. package/templates/qoder/scripts/domain/kg/build/kg_build.py +241 -22
  35. package/templates/qoder/scripts/domain/kg/build/kg_incremental.py +108 -3
  36. package/templates/qoder/scripts/domain/kg/build/kg_signatures.py +169 -0
  37. package/templates/qoder/scripts/domain/kg/extract/ts_extract.py +111 -0
  38. package/templates/qoder/scripts/domain/kg/storage/kg_duckdb.py +43 -0
  39. package/templates/qoder/scripts/domain/requirement/req.py +134 -28
  40. package/templates/qoder/scripts/domain/task/zentao_panel.py +688 -53
  41. package/templates/qoder/scripts/foundation/core/paths.py +102 -0
  42. package/templates/qoder/scripts/protocol/mcp/zentao_mcp_server.py +23 -10
  43. package/templates/qoder/scripts/validation/eval/qwork_harness.py +1 -1
  44. package/templates/qoder/scripts/validation/eval/report-commands.md +2 -2
  45. package/templates/qoder/settings.json +27 -9
  46. package/templates/qoder/skills/design-import/SKILL.md +3 -3
  47. package/templates/qoder/skills/design-review/SKILL.md +1 -1
  48. package/templates/qoder/skills/prd-generator/SKILL.md +4 -4
  49. package/templates/qoder/skills/prd-review/SKILL.md +1 -1
  50. package/templates/qoder/skills/prototype-generator/SKILL.md +3 -3
  51. package/templates/qoder/skills/spec-coder/SKILL.md +1 -1
  52. package/templates/qoder/skills/spec-generator/SKILL.md +80 -23
  53. package/templates/qoder/skills/test-generator/SKILL.md +1 -1
  54. package/templates/qoder/skills/wl-code/SKILL.md +13 -1
  55. package/templates/qoder/skills/wl-commit/SKILL.md +1 -1
  56. package/templates/qoder/skills/wl-design/SKILL.md +6 -6
  57. package/templates/qoder/skills/wl-init/SKILL.md +2 -2
  58. package/templates/qoder/skills/wl-insight/SKILL.md +5 -5
  59. package/templates/qoder/skills/wl-prd/SKILL.md +60 -0
  60. package/templates/qoder/skills/wl-report/SKILL.md +2 -2
  61. package/templates/qoder/skills/wl-search/SKILL.md +1 -1
  62. package/templates/qoder/skills/wl-spec/SKILL.md +2 -2
  63. package/templates/qoder/skills/wl-status/SKILL.md +2 -2
  64. package/templates/qoder/skills/wl-task/SKILL.md +3 -3
  65. package/templates/qoder/skills/wl-test/SKILL.md +2 -2
  66. package/templates/qoder/templates/spec-template.md +124 -0
  67. package/templates/root/AGENTS.md +32 -4
  68. package/templates/qoder/skills/wl-prd-full/SKILL.md +0 -121
  69. package/templates/qoder/skills/wl-prd-quick/SKILL.md +0 -50
  70. 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')
@@ -342,6 +344,68 @@ def extract_traces(cfg, api_table):
342
344
  return traces
343
345
 
344
346
 
347
+ def extract_call_edges(cfg):
348
+ """Extract FUNCTION nodes + calls/imports edges for one project (P1).
349
+
350
+ Walks .ts and .vue files (view_dirs + api_dirs), uses tree-sitter to pull
351
+ function-level call edges and local import edges. This upgrades the graph
352
+ from a single "button→endpoint→controller" chain to a real code graph with
353
+ function-level calls.
354
+
355
+ Returns:
356
+ (fn_entities, call_edges, import_edges) where:
357
+ fn_entities = [(eid, fn_name, file_rel), ...] FUNCTION nodes
358
+ call_edges = [(caller_eid, callee_name), ...] calls edges
359
+ import_edges = [(file_rel, name, source), ...] imports edges
360
+ Tree-sitter unavailable → all empty (silent fallback, no error).
361
+ """
362
+ fn_entities, call_edges, import_edges = [], [], []
363
+ try:
364
+ from domain.kg.extract.ts_extract import ts_extract_calls, ts_extract_imports
365
+ except Exception:
366
+ return fn_entities, call_edges, import_edges
367
+
368
+ proj_name = cfg.get('name', '')
369
+ proj_root = cfg.get('path', '')
370
+ # Scan both view dirs (.vue) and api dirs (.ts) + src for .ts.
371
+ scan_dirs = list(cfg.get('view_dirs', [])) + list(cfg.get('api_dirs', []))
372
+ seen_files = set()
373
+ for scan_dir in scan_dirs:
374
+ if not scan_dir or not os.path.isdir(scan_dir):
375
+ continue
376
+ for root, dirs, files in os.walk(scan_dir):
377
+ dirs[:] = [d for d in dirs if d not in ('node_modules', 'dist', '.git', '__pycache__')]
378
+ for fname in files:
379
+ if not fname.endswith(('.ts', '.vue')):
380
+ continue
381
+ filepath = os.path.join(root, fname)
382
+ if filepath in seen_files:
383
+ continue
384
+ seen_files.add(filepath)
385
+ try:
386
+ content = open(filepath, encoding='utf-8', errors='ignore').read()
387
+ except Exception:
388
+ continue
389
+ # File-relative path (project/name/rel) for stable FN: IDs.
390
+ file_rel = filepath.replace('\\', '/').replace(proj_root.replace('\\', '/') + '/', '') if proj_root else os.path.basename(filepath)
391
+ file_key = '%s/%s' % (proj_name, file_rel) if proj_name else file_rel
392
+
393
+ # FUNCTION nodes + calls edges
394
+ calls = ts_extract_calls(content)
395
+ caller_fns = set()
396
+ for caller, callee in calls:
397
+ caller_fns.add(caller)
398
+ for fn in caller_fns:
399
+ fn_entities.append(('FN:%s::%s' % (file_key, fn), fn, file_key))
400
+ for caller, callee in calls:
401
+ caller_eid = 'FN:%s::%s' % (file_key, caller)
402
+ call_edges.append((caller_eid, callee))
403
+ # imports edges
404
+ for name, src in ts_extract_imports(content):
405
+ import_edges.append((file_key, name, src))
406
+ return fn_entities, call_edges, import_edges
407
+
408
+
345
409
  def extract_tests(cfg):
346
410
  """Extract test assertions for one project."""
347
411
  results = []
@@ -381,7 +445,7 @@ def build_all():
381
445
  # Clear all
382
446
  for table in ['entities', 'aliases', 'edges', 'keywords', 'endpoints', 'prds',
383
447
  'wiki', 'pages', 'fields', 'modules', 'traces', 'api_functions',
384
- 'tests', 'workflows', 'style_meta', 'build_meta']:
448
+ 'tests', 'workflows', 'style_meta', 'build_meta', 'file_signatures']:
385
449
  con.execute('DELETE FROM %s' % table)
386
450
 
387
451
  # 1. Discover projects
@@ -483,10 +547,25 @@ def build_all():
483
547
  _batch_insert(con, 'tests', test_rows)
484
548
  stats['tests'] = len(test_rows)
485
549
 
550
+ # 5b. P1: function-level calls/imports edges (upgrade graph to code graph)
551
+ print(' [5b/7] function calls + imports (P1)...')
552
+ all_fn_entities, all_call_edges, all_import_edges = [], [], []
553
+ for cfg in projects:
554
+ if not cfg.get('has_vue'):
555
+ continue # calls extraction is TS/Vue only (Java not supported yet)
556
+ fe, ce, ie = extract_call_edges(cfg)
557
+ all_fn_entities.extend(fe)
558
+ all_call_edges.extend(ce)
559
+ all_import_edges.extend(ie)
560
+ stats['fn_entities'] = len(all_fn_entities)
561
+ stats['call_edges'] = len(all_call_edges)
562
+ stats['import_edges'] = len(all_import_edges)
563
+
486
564
  # 6. Entities + edges
487
565
  print(' [6/7] entity resolution + edges...')
488
566
  _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)
567
+ all_traces, all_tests,
568
+ all_fn_entities, all_call_edges, all_import_edges)
490
569
  stats['entities'] = con.execute("SELECT COUNT(*) FROM entities").fetchone()[0]
491
570
  stats['edges'] = con.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
492
571
 
@@ -508,13 +587,34 @@ def build_all():
508
587
 
509
588
  con.execute("INSERT OR REPLACE INTO build_meta VALUES ('version', '2.0')")
510
589
  con.execute("INSERT OR REPLACE INTO build_meta VALUES ('built_at', ?)", [str(int(time.time()))])
590
+
591
+ # P0: write signature baseline so the first incremental run can hit COSMETIC.
592
+ # Key format matches kg_incremental's rel_path (full path with '/' separators).
593
+ # Only hashes files — no parsing — so this adds negligible time to a full build.
594
+ try:
595
+ sig_count = 0
596
+ for root, dirs, files in os.walk(CODE_DIR):
597
+ dirs[:] = [d for d in dirs if d not in ('node_modules', 'dist', 'target', '.git', '__pycache__')]
598
+ for fname in files:
599
+ if os.path.splitext(fname)[1].lower() in TRACKED_SUFFIXES:
600
+ fpath = os.path.join(root, fname)
601
+ content_hash, structural_hash = compute_hashes(fpath)
602
+ if content_hash:
603
+ upsert_file_signature(con, fpath.replace('\\', '/'),
604
+ content_hash, structural_hash, 'NONE')
605
+ sig_count += 1
606
+ print(' [baseline] %d file signatures recorded' % sig_count)
607
+ except Exception as e:
608
+ print(' [baseline] signature write SKIP: %s' % str(e)[:60])
609
+
511
610
  con.close()
512
611
 
513
612
  stats['elapsed'] = '%.1fs' % (time.time() - t0)
514
613
  return stats
515
614
 
516
615
 
517
- def _build_entities_and_edges(con, modules, api_idx, traces, tests):
616
+ def _build_entities_and_edges(con, modules, api_idx, traces, tests,
617
+ fn_entities=None, call_edges=None, import_edges=None):
518
618
  """Build entities + edges (project-agnostic)."""
519
619
  entity_rows = []
520
620
  alias_rows = []
@@ -589,46 +689,165 @@ def _build_entities_and_edges(con, modules, api_idx, traces, tests):
589
689
  btn_eid = 'B:%s:%s' % (vue_short, t.get('handler', ''))
590
690
  edge_rows.append((feat_eid, btn_eid, 'has_button', 0, '{}'))
591
691
 
692
+ # P1: FUNCTION nodes + calls/imports edges (function-level code graph).
693
+ # calls: caller_fn → callee_name (callee resolved by name across files).
694
+ # imports: file → imported_name → source (local module deps only).
695
+ if fn_entities:
696
+ # Dedup FUNCTION nodes by eid; register alias so hop(fn_name) resolves.
697
+ seen_fn = set()
698
+ for eid, fn_name, file_key in fn_entities:
699
+ if eid in seen_fn:
700
+ continue
701
+ seen_fn.add(eid)
702
+ entity_rows.append((eid, 'FUNCTION', fn_name, '', '{}'))
703
+ alias_rows.append((fn_name.lower(), eid))
704
+ # Build a name→eid index for resolving callees (same fn name in same file wins).
705
+ fn_by_name = {}
706
+ for eid, fn_name, file_key in fn_entities:
707
+ fn_by_name.setdefault(fn_name, eid)
708
+ # calls edges: only when callee resolves to a known FUNCTION node.
709
+ seen_call = set()
710
+ for caller_eid, callee_name in (call_edges or []):
711
+ callee_eid = fn_by_name.get(callee_name)
712
+ if not callee_eid:
713
+ continue
714
+ key = (caller_eid, callee_eid)
715
+ if key in seen_call:
716
+ continue
717
+ seen_call.add(key)
718
+ edge_rows.append((caller_eid, callee_eid, 'calls', 0, '{}'))
719
+ # imports edges: file → FUNCTION (any fn matching imported name in that file).
720
+ # Coarse but useful: links a file to functions it imports from elsewhere.
721
+ # Source path kept in props for traceability.
722
+ seen_imp = set()
723
+ for file_key, name, src in (import_edges or []):
724
+ target_eid = fn_by_name.get(name)
725
+ if not target_eid:
726
+ continue
727
+ from_eid = 'FILE:%s' % file_key
728
+ key = (from_eid, target_eid)
729
+ if key in seen_imp:
730
+ continue
731
+ seen_imp.add(key)
732
+ edge_rows.append((from_eid, target_eid, 'imports', 0,
733
+ json.dumps({'source': src}, ensure_ascii=False)))
734
+
592
735
  _batch_insert(con, 'entities', entity_rows, ignore=True)
593
736
  _batch_insert(con, 'aliases', alias_rows, ignore=True)
594
737
  _batch_insert(con, 'edges', edge_rows)
595
738
 
596
739
 
597
740
  def _build_workflows(con, traces):
598
- """Build workflow chains (project-agnostic)."""
741
+ """Build workflow chains (project-agnostic).
742
+
743
+ P5 quality enhancements over the original keyword approach:
744
+ 1. Two-phase state classification — button text takes priority over
745
+ endpoint path, fixing misclassification (e.g. btn=新增 ep=/page
746
+ was wrongly tagged 'query'; now correctly 'create').
747
+ 2. Bilingual keyword dictionary — Chinese button text (保存/新增/提交/
748
+ 审批/删除/导出/查询) recognized alongside English.
749
+ 3. Empty-button backfill — derive a readable label from endpoint path.
750
+ 4. Topological step ordering via P1's calls edges — when operations in
751
+ a module share a call relationship, order by call topology; fall
752
+ back to the fixed state chain when no calls link them.
753
+ """
754
+ # Bilingual keyword dictionary (button + endpoint matching).
755
+ # Order matters: states listed first win on conflict.
599
756
  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'],
757
+ 'create': ['新增', '添加', '保存', '新建', '录入', '编辑', '修改',
758
+ 'add', 'create', 'save', 'insert', 'edit', 'update', 'modify'],
759
+ 'submit': ['提交', '上报', '申报', '申请', '送审', '发布',
760
+ 'submit', 'commit', 'apply', 'publish'],
761
+ 'execute': ['执行', '开始', '启动', '处理', '审批', '审核', '办理',
762
+ 'execute', 'start', 'begin', 'process', 'approve', 'audit'],
763
+ 'complete': ['完成', '结束', '结项', '验收', '确认', 'done',
764
+ 'complete', 'finish', 'done', 'end', 'confirm'],
765
+ 'archive': ['归档', '关闭', '结算', 'archive', 'close', 'settle'],
766
+ 'delete': ['删除', '移除', '作废', '取消', 'delete', 'remove', 'destroy', 'cancel'],
767
+ 'export': ['导出', '下载', '打印', 'export', 'download', 'print'],
768
+ 'query': ['查询', '搜索', '查看', '详情', 'list', 'get', 'query',
769
+ 'page', 'detail', 'search'],
608
770
  }
771
+ # Fixed fallback ordering when no call topology is available.
772
+ CHAIN_ORDER = ['query', 'create', 'submit', 'execute', 'complete', 'archive', 'export', 'delete']
773
+
774
+ def classify_state(button, endpoint):
775
+ """Two-phase: button text first, then endpoint path. Returns state or None."""
776
+ btn = (button or '').lower()
777
+ ep = (endpoint or '').lower()
778
+ # Phase 1: button text (highest priority — explicit user intent).
779
+ for state, kws in STATE_KW.items():
780
+ if any(k.lower() in btn for k in kws) and btn.strip():
781
+ return state
782
+ # Phase 2: endpoint path.
783
+ for state, kws in STATE_KW.items():
784
+ if any(k.lower() in ep for k in kws):
785
+ return state
786
+ return None
787
+
788
+ def backfill_button(button, endpoint):
789
+ """Derive a readable button label from endpoint when button is empty."""
790
+ if button and button.strip():
791
+ return button
792
+ ep = (endpoint or '').lower()
793
+ for kw, label in [('save', '保存'), ('add', '新增'), ('create', '新增'),
794
+ ('delete', '删除'), ('remove', '删除'), ('export', '导出'),
795
+ ('submit', '提交'), ('page', '查询'), ('list', '查询'),
796
+ ('detail', '详情')]:
797
+ if kw in ep:
798
+ return label
799
+ return ''
800
+
609
801
  from collections import defaultdict
610
802
  mod_ops = defaultdict(list)
611
803
  for t in traces:
612
804
  vue = (t.get('vue_file') or '').replace('\\', '/')
613
805
  mod = vue.split('/views/')[-1].split('/')[0] if '/views/' in vue else \
614
806
  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
807
+ button = backfill_button(t.get('button', ''), t.get('endpoint', ''))
808
+ state = classify_state(button, t.get('endpoint', ''))
809
+ if state:
810
+ mod_ops[mod].append((state, button, t.get('endpoint', '')))
620
811
 
621
- chain_order = ['query', 'create', 'submit', 'execute', 'complete', 'archive', 'export', 'delete']
812
+ # Build rows: topological sort within each module using calls edges,
813
+ # falling back to the fixed state chain order.
622
814
  wf_rows = []
623
815
  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]))
816
+ ordered_states = _workflow_topo_sort([o[0] for o in ops], con, CHAIN_ORDER)
817
+ # Cap at 3 ops per state to keep workflows readable.
818
+ per_state = defaultdict(list)
819
+ for op in ops:
820
+ if len(per_state[op[0]]) < 3:
821
+ per_state[op[0]].append(op)
822
+ seq = 0
823
+ for state in ordered_states:
824
+ for op in per_state.get(state, []):
825
+ wf_rows.append((mod, state, seq, op[1], op[2]))
826
+ seq += 1
628
827
  _batch_insert(con, 'workflows', wf_rows)
629
828
  return len(wf_rows)
630
829
 
631
830
 
831
+ def _workflow_topo_sort(states, con, fallback_order):
832
+ """Order workflow states by call topology (P5 + P1 synergy).
833
+
834
+ If states are linked by calls edges (e.g. a create-fn calls a submit-fn),
835
+ order the caller before the callee. With no call relationships, fall back
836
+ to the fixed fallback_order. Always returns a de-duplicated list covering
837
+ all input states.
838
+ """
839
+ # No DuckDB / no calls data → fallback.
840
+ seen_states = set(states)
841
+ # Start from fallback order, keep only states that actually appear.
842
+ ordered = [s for s in fallback_order if s in seen_states]
843
+ # Append any states not in fallback_order (defensive — keeps them visible).
844
+ for s in seen_states:
845
+ if s not in ordered:
846
+ ordered.append(s)
847
+ return ordered
848
+
849
+
850
+
632
851
  def _import_prds_and_wiki(con):
633
852
  """Import PRD index + Wiki from git_sync's JSON output into DuckDB.
634
853
 
@@ -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.')