@hupan56/wlkj 3.2.0 → 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 (122) hide show
  1. package/bin/cli.js +117 -0
  2. package/package.json +1 -1
  3. package/templates/qoder/commands/optional/wl-insight.md +276 -276
  4. package/templates/qoder/commands/optional/wl-report.md +1 -1
  5. package/templates/qoder/commands/optional/wl-spec.md +13 -1
  6. package/templates/qoder/commands/optional/wl-status.md +12 -1
  7. package/templates/qoder/commands/wl-code.md +76 -4
  8. package/templates/qoder/commands/wl-commit.md +12 -1
  9. package/templates/qoder/commands/wl-design.md +69 -5
  10. package/templates/qoder/commands/wl-init.md +27 -0
  11. package/templates/qoder/commands/wl-prd.md +145 -4
  12. package/templates/qoder/commands/wl-search.md +74 -20
  13. package/templates/qoder/commands/wl-task.md +613 -613
  14. package/templates/qoder/commands/wl-test.md +16 -1
  15. package/templates/qoder/contracts/spec.md +4 -0
  16. package/templates/qoder/hooks/post-tool-use.py +41 -0
  17. package/templates/qoder/hooks/session-start.py +34 -66
  18. package/templates/qoder/scripts/capability/__pycache__/__init__.cpython-39.pyc +0 -0
  19. package/templates/qoder/scripts/capability/__pycache__/registry.cpython-39.pyc +0 -0
  20. package/templates/qoder/scripts/capability/__pycache__/registry_mcp.cpython-39.pyc +0 -0
  21. package/templates/qoder/scripts/capability/adapters/__init__.py +1 -1
  22. package/templates/qoder/scripts/capability/adapters/__pycache__/__init__.cpython-39.pyc +0 -0
  23. package/templates/qoder/scripts/capability/adapters/__pycache__/cli.cpython-39.pyc +0 -0
  24. package/templates/qoder/scripts/capability/adapters/__pycache__/mcp.cpython-39.pyc +0 -0
  25. package/templates/qoder/scripts/capability/adapters/__pycache__/qw.cpython-39.pyc +0 -0
  26. package/templates/qoder/scripts/capability/adapters/mcp.py +76 -100
  27. package/templates/qoder/scripts/capability/adapters/qw.py +295 -295
  28. package/templates/qoder/scripts/capability/caps/__init__.py +1 -1
  29. package/templates/qoder/scripts/capability/caps/__pycache__/__init__.cpython-39.pyc +0 -0
  30. package/templates/qoder/scripts/capability/caps/__pycache__/context.cpython-39.pyc +0 -0
  31. package/templates/qoder/scripts/capability/caps/__pycache__/cron.cpython-39.pyc +0 -0
  32. package/templates/qoder/scripts/capability/caps/__pycache__/identity.cpython-39.pyc +0 -0
  33. package/templates/qoder/scripts/capability/caps/__pycache__/memory.cpython-39.pyc +0 -0
  34. package/templates/qoder/scripts/capability/caps/__pycache__/notify.cpython-39.pyc +0 -0
  35. package/templates/qoder/scripts/capability/caps/__pycache__/present.cpython-39.pyc +0 -0
  36. package/templates/qoder/scripts/capability/caps/__pycache__/repo.cpython-39.pyc +0 -0
  37. package/templates/qoder/scripts/capability/caps/__pycache__/sandbox.cpython-39.pyc +0 -0
  38. package/templates/qoder/scripts/capability/caps/memory.py +1 -1
  39. package/templates/qoder/scripts/capability/registry.py +21 -23
  40. package/templates/qoder/scripts/capability/registry_mcp.py +69 -5
  41. package/templates/qoder/scripts/capability/smoke_test_report.json +34 -20
  42. package/templates/qoder/scripts/deployment/setup/carriers.py +3 -1
  43. package/templates/qoder/scripts/deployment/setup/init_doctor.py +10 -3
  44. package/templates/qoder/scripts/domain/__pycache__/__init__.cpython-39.pyc +0 -0
  45. package/templates/qoder/scripts/domain/integration/__init__.py +0 -0
  46. package/templates/qoder/scripts/domain/integration/__pycache__/__init__.cpython-39.pyc +0 -0
  47. package/templates/qoder/scripts/domain/integration/__pycache__/return_to_platform.cpython-39.pyc +0 -0
  48. package/templates/qoder/scripts/domain/integration/return_to_platform.py +392 -0
  49. package/templates/qoder/scripts/domain/integration/spec_upload.py +209 -0
  50. package/templates/qoder/scripts/domain/kg/build/kg_build.py +27 -3
  51. package/templates/qoder/scripts/domain/kg/extract/asset/__init__.py +10 -0
  52. package/templates/qoder/scripts/domain/kg/extract/asset/asset_tree.py +57 -0
  53. package/templates/qoder/scripts/domain/kg/extract/asset/discussion_importer.py +62 -0
  54. package/templates/qoder/scripts/domain/kg/extract/asset/prd_importer.py +146 -0
  55. package/templates/qoder/scripts/domain/kg/extract/asset/prototype_importer.py +64 -0
  56. package/templates/qoder/scripts/domain/kg/extract/asset/returns_importer.py +52 -0
  57. package/templates/qoder/scripts/domain/kg/extract/build_goal3.py +104 -0
  58. package/templates/qoder/scripts/domain/kg/extract/build_goal4.py +55 -0
  59. package/templates/qoder/scripts/domain/kg/extract/build_goal5.py +95 -0
  60. package/templates/qoder/scripts/domain/kg/extract/db/__init__.py +8 -0
  61. package/templates/qoder/scripts/domain/kg/extract/db/data_profile.py +22 -0
  62. package/templates/qoder/scripts/domain/kg/extract/db/fk_extractor.py +55 -0
  63. package/templates/qoder/scripts/domain/kg/extract/db/schema_extractor.py +90 -0
  64. package/templates/qoder/scripts/domain/kg/extract/extract.py +84 -0
  65. package/templates/qoder/scripts/domain/kg/extract/extract.py.bak +430 -0
  66. package/templates/qoder/scripts/domain/kg/extract/inference/__init__.py +9 -0
  67. package/templates/qoder/scripts/domain/kg/extract/inference/community_summarizer.py +206 -0
  68. package/templates/qoder/scripts/domain/kg/extract/inference/embed_builder.py +132 -0
  69. package/templates/qoder/scripts/domain/kg/extract/inference/naming_matcher.py +80 -0
  70. package/templates/qoder/scripts/domain/kg/extract/inference/promote.py +59 -0
  71. package/templates/qoder/scripts/domain/kg/extract/inference/recompute.py +93 -0
  72. package/templates/qoder/scripts/domain/kg/extract/inference/weak_link.py +421 -0
  73. package/templates/qoder/scripts/domain/kg/extract/java/__init__.py +15 -0
  74. package/templates/qoder/scripts/domain/kg/extract/java/_parser.py +271 -0
  75. package/templates/qoder/scripts/domain/kg/extract/java/all.py +145 -0
  76. package/templates/qoder/scripts/domain/kg/extract/java/build_java_to_pg.py +102 -0
  77. package/templates/qoder/scripts/domain/kg/extract/java/call_chain.py +49 -0
  78. package/templates/qoder/scripts/domain/kg/extract/java/class_extractor.py +141 -0
  79. package/templates/qoder/scripts/domain/kg/extract/java/domain_extractor.py +148 -0
  80. package/templates/qoder/scripts/domain/kg/extract/java/dubbo_extractor.py +33 -0
  81. package/templates/qoder/scripts/domain/kg/extract/java/endpoint_extractor.py +36 -0
  82. package/templates/qoder/scripts/domain/kg/extract/java/javadoc_extractor.py +110 -0
  83. package/templates/qoder/scripts/domain/kg/extract/java/llm_cn_filler.py +150 -0
  84. package/templates/qoder/scripts/domain/kg/extract/java/member_extractor.py +157 -0
  85. package/templates/qoder/scripts/domain/kg/extract/java/mybatisplus_extractor.py +34 -0
  86. package/templates/qoder/scripts/domain/kg/extract/java/pg_upsert.py +165 -0
  87. package/templates/qoder/scripts/domain/kg/extract/java/satoken_extractor.py +30 -0
  88. package/templates/qoder/scripts/domain/kg/extract/java/spring_extractor.py +39 -0
  89. package/templates/qoder/scripts/domain/kg/extract/java/validation_extractor.py +33 -0
  90. package/templates/qoder/scripts/domain/kg/extract/mybatis/__init__.py +9 -0
  91. package/templates/qoder/scripts/domain/kg/extract/mybatis/all.py +79 -0
  92. package/templates/qoder/scripts/domain/kg/extract/mybatis/mapper_parser.py +99 -0
  93. package/templates/qoder/scripts/domain/kg/extract/mybatis/relation_builder.py +69 -0
  94. package/templates/qoder/scripts/domain/kg/extract/mybatis/sql_extractor.py +78 -0
  95. package/templates/qoder/scripts/domain/kg/extract/prd/__init__.py +8 -0
  96. package/templates/qoder/scripts/domain/kg/extract/prd/prd_chunk_embed.py +105 -0
  97. package/templates/qoder/scripts/domain/kg/extract/prd/prd_llm_extract.py +153 -0
  98. package/templates/qoder/scripts/domain/kg/extract/prd/req_anchor.py +120 -0
  99. package/templates/qoder/scripts/domain/kg/graph/kg_semantic.py +4 -2
  100. package/templates/qoder/scripts/domain/kg/kg.py +42 -5
  101. package/templates/qoder/scripts/domain/kg/search/_remote.py +187 -0
  102. package/templates/qoder/scripts/domain/kg/search/context_pack.py +32 -2
  103. package/templates/qoder/scripts/domain/kg/search/search_index.py +74 -20
  104. package/templates/qoder/scripts/domain/kg/switch_project.py +159 -0
  105. package/templates/qoder/scripts/domain/kg/sync_repowiki.py +109 -0
  106. package/templates/qoder/scripts/domain/task/__pycache__/wlkj_panel.cpython-39.pyc +0 -0
  107. package/templates/qoder/scripts/domain/task/{zentao_panel.py → wlkj_panel.py} +315 -53
  108. package/templates/qoder/scripts/engine/poller.py +219 -0
  109. package/templates/qoder/scripts/foundation/__pycache__/__init__.cpython-39.pyc +0 -0
  110. package/templates/qoder/scripts/foundation/core/__pycache__/__init__.cpython-39.pyc +0 -0
  111. package/templates/qoder/scripts/foundation/core/__pycache__/paths.cpython-39.pyc +0 -0
  112. package/templates/qoder/scripts/foundation/integrations/active_task.py +2 -1
  113. package/templates/qoder/scripts/orchestration/wlkj.py +4 -0
  114. package/templates/qoder/scripts/protocol/__pycache__/__init__.cpython-39.pyc +0 -0
  115. package/templates/qoder/scripts/protocol/mcp/zentao_mcp_server.py +1 -1
  116. package/templates/qoder/scripts/protocol/transports/__pycache__/__init__.cpython-39.pyc +0 -0
  117. package/templates/qoder/scripts/protocol/transports/__pycache__/base.cpython-39.pyc +0 -0
  118. package/templates/qoder/scripts/protocol/transports/__pycache__/cli.cpython-39.pyc +0 -0
  119. package/templates/qoder/scripts/protocol/transports/__pycache__/http.cpython-39.pyc +0 -0
  120. package/templates/qoder/scripts/protocol/transports/__pycache__/stdio.cpython-39.pyc +0 -0
  121. package/templates/qoder/scripts/protocol/transports/http.py +7 -1
  122. package/templates/root/AGENTS.md +9 -10
@@ -0,0 +1,55 @@
1
+ # -*- coding: utf-8 -*-
2
+ """外键关系提取器: KEY_COLUMN_USAGE → references 边。
3
+
4
+ 让 table_impact 真正能做"改表影响谁"的跨表追溯。
5
+ """
6
+ from __future__ import annotations
7
+ from . import schema_extractor as _se
8
+
9
+
10
+ def extract_foreign_keys(repo_id: str = 'fywl-ics', logger=print):
11
+ """连 MySQL → 提取外键关系 → references 边。
12
+
13
+ Returns: [{from_id, to_id, edge_type:'references', ...}] 连不上返回 []
14
+ """
15
+ if not _se._can_connect():
16
+ return []
17
+ try:
18
+ import pymysql
19
+ except ImportError:
20
+ return []
21
+
22
+ conn = pymysql.connect(host=_se.MYSQL_HOST, port=_se.MYSQL_PORT, user=_se.MYSQL_USER,
23
+ password=_se.MYSQL_PASSWORD, charset='utf8mb4',
24
+ cursorclass=pymysql.cursors.DictCursor,
25
+ connect_timeout=5, read_timeout=30)
26
+ edges = []
27
+ try:
28
+ cur = conn.cursor()
29
+ cur.execute("SELECT schema_name FROM information_schema.schemata "
30
+ "WHERE schema_name LIKE %s", (_se.DB_NAME_PREFIX + '%',))
31
+ dbs = [r['schema_name'] for r in cur.fetchall()]
32
+ for db in dbs:
33
+ # 外键关系
34
+ cur.execute("""
35
+ SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME
36
+ FROM information_schema.KEY_COLUMN_USAGE
37
+ WHERE TABLE_SCHEMA=%s AND REFERENCED_TABLE_NAME IS NOT NULL
38
+ """, (db,))
39
+ for r in cur.fetchall():
40
+ from_tbl = r['TABLE_NAME'].lower()
41
+ to_tbl = r['REFERENCED_TABLE_NAME'].lower()
42
+ edges.append({
43
+ 'from_id': 'TBL:%s:%s' % (repo_id, from_tbl),
44
+ 'to_id': 'TBL:%s:%s' % (repo_id, to_tbl),
45
+ 'edge_type': 'references',
46
+ 'from_repo': repo_id, 'to_repo': repo_id,
47
+ 'confidence': 1.0, 'source': 'db_fk',
48
+ 'props': {'column': r['COLUMN_NAME'],
49
+ 'ref_column': r['REFERENCED_COLUMN_NAME'],
50
+ 'schema': db},
51
+ })
52
+ logger(' [db-fk] %d 条外键关系' % len(edges))
53
+ finally:
54
+ conn.close()
55
+ return edges
@@ -0,0 +1,90 @@
1
+ # -*- coding: utf-8 -*-
2
+ """连 MySQL information_schema → 灌 db_tables / db_columns。
3
+
4
+ 复用 kg_build_db.py 的 MySQL 连接配置 (内网 10.54.6.9, fywl_ics_* 库)。
5
+ 连不上内网时 best-effort 返回空, 不阻塞 (GOAL 3 主链路是 mapper XML)。
6
+ """
7
+ from __future__ import annotations
8
+ import os
9
+ import sys
10
+ import socket
11
+
12
+ _THIS = os.path.dirname(os.path.abspath(__file__))
13
+ for _i in range(8):
14
+ _p = os.path.dirname(_THIS)
15
+ if os.path.isfile(os.path.join(_p, 'foundation', 'bootstrap.py')):
16
+ sys.path.insert(0, _p); break
17
+ _THIS = _p
18
+
19
+ # 复用 kg_build_db 的配置
20
+ MYSQL_HOST = os.environ.get('MYSQL_HOST', '10.54.6.9')
21
+ MYSQL_PORT = int(os.environ.get('MYSQL_PORT', '3306'))
22
+ MYSQL_USER = os.environ.get('MYSQL_USER', 'fywl_ics_test')
23
+ MYSQL_PASSWORD = os.environ.get('MYSQL_PASSWORD', 'Fywl_ics_test56476')
24
+ DB_NAME_PREFIX = os.environ.get('DB_NAME_PREFIX', 'fywl_ics_')
25
+
26
+
27
+ def _can_connect(timeout=3):
28
+ """探测内网 MySQL 是否可达。"""
29
+ try:
30
+ with socket.create_connection((MYSQL_HOST, MYSQL_PORT), timeout=timeout):
31
+ return True
32
+ except Exception:
33
+ return False
34
+
35
+
36
+ def extract_all_schema(repo_id: str = 'fywl-ics', logger=print):
37
+ """连 MySQL → 灌 db_tables + db_columns。
38
+
39
+ Returns: (tables: list[dict], columns: list[dict]) 连不上返回 ([], [])
40
+ """
41
+ if not _can_connect():
42
+ logger(' [db] ⚠️ 内网 MySQL 不可达 (%s:%s), 跳过 schema 提取' % (MYSQL_HOST, MYSQL_PORT))
43
+ return [], []
44
+ try:
45
+ import pymysql
46
+ except ImportError:
47
+ logger(' [db] pymysql 未装, 跳过')
48
+ return [], []
49
+
50
+ logger(' [db] 连接 MySQL %s:%s ...' % (MYSQL_HOST, MYSQL_PORT))
51
+ conn = pymysql.connect(host=MYSQL_HOST, port=MYSQL_PORT, user=MYSQL_USER,
52
+ password=MYSQL_PASSWORD, charset='utf8mb4',
53
+ cursorclass=pymysql.cursors.DictCursor,
54
+ connect_timeout=5, read_timeout=30)
55
+ tables, columns = [], []
56
+ try:
57
+ cur = conn.cursor()
58
+ cur.execute("SELECT schema_name FROM information_schema.schemata "
59
+ "WHERE schema_name LIKE %s ORDER BY schema_name",
60
+ (DB_NAME_PREFIX + '%'))
61
+ dbs = [r['schema_name'] for r in cur.fetchall()]
62
+ logger(' [db] 发现 %d 个业务库: %s' % (len(dbs), dbs[:3]))
63
+
64
+ for db in dbs:
65
+ # 表
66
+ cur.execute("SELECT table_name, table_comment, table_rows "
67
+ "FROM information_schema.tables WHERE table_schema=%s", (db,))
68
+ for r in cur.fetchall():
69
+ tables.append({
70
+ 'repo_id': repo_id, 'schema_name': db,
71
+ 'table_name': r['table_name'], 'table_comment': r.get('table_comment') or '',
72
+ 'rows_count': r.get('table_rows'),
73
+ })
74
+ # 列
75
+ cur.execute("SELECT table_name, column_name, data_type, is_nullable, "
76
+ "column_comment, ordinal_position "
77
+ "FROM information_schema.columns WHERE table_schema=%s "
78
+ "ORDER BY table_name, ordinal_position", (db,))
79
+ for r in cur.fetchall():
80
+ columns.append({
81
+ 'repo_id': repo_id, 'schema_name': db,
82
+ 'table_name': r['table_name'], 'column_name': r['column_name'],
83
+ 'data_type': r['data_type'], 'is_nullable': r['is_nullable'],
84
+ 'column_comment': r.get('column_comment') or '',
85
+ 'ordinal_position': r.get('ordinal_position'),
86
+ })
87
+ logger(' [db] 完成: %d 表 / %d 列' % (len(tables), len(columns)))
88
+ finally:
89
+ conn.close()
90
+ return tables, columns
@@ -428,3 +428,87 @@ def trace_clicks(vue_path, vue_content, api_table):
428
428
  traces.append(trace)
429
429
 
430
430
  return traces
431
+
432
+
433
+ # ============================================================
434
+ # GOAL 3 泛化: Carmg-H5 (Vant/H5) 风格 API + 调用提取
435
+ # ============================================================
436
+ # Carmg-H5 的 API 定义方式和 fywl-ui 不同:
437
+ # fywl-ui: enum Api { X='/url' } + requestClient.post(Api.X)
438
+ # Carmg-H5: export function fn(data){ const params={url:'/x',method:'post'}; return fetch(params) }
439
+ # 以及 van-button 的 @click/@tap 事件。
440
+ # 下面两个函数让 extract.py 能同时处理两种风格。
441
+
442
+ # Carmg-H5 API: export function fnName(...) { ... url: '/xxx' ... method: 'post' ... }
443
+ RE_H5_API_FN = re.compile(
444
+ r"export\s+function\s+(\w+)\s*\([^)]*\)\s*\{(?:(?!\}).)*?"
445
+ r"url:\s*['\"]([^'\"]+)['\"](?:(?!\}).)*?"
446
+ r"(?:method:\s*['\"](\w+)['\"])?",
447
+ re.DOTALL
448
+ )
449
+
450
+
451
+ def build_api_fn_table_h5(api_dir):
452
+ """扫 src/api/**/*.js (Carmg-H5 风格) → {fnName: {url, verb, file}}。
453
+
454
+ 识别 export function + url: 字面量模式。
455
+ """
456
+ table = {}
457
+ if not os.path.isdir(api_dir):
458
+ return table
459
+ for root, dirs, files in os.walk(api_dir):
460
+ dirs[:] = [d for d in dirs if d not in ('dist', 'node_modules', '__pycache__')]
461
+ for f in files:
462
+ if not (f.endswith('.js') or f.endswith('.ts')):
463
+ continue
464
+ fp = os.path.join(root, f)
465
+ try:
466
+ content = open(fp, encoding='utf-8', errors='ignore').read()
467
+ except Exception:
468
+ continue
469
+ for m in RE_H5_API_FN.finditer(content):
470
+ fn_name, url, verb = m.group(1), m.group(2), m.group(3) or 'get'
471
+ table[fn_name] = {'url': url, 'verb': verb.lower(), 'file': fp}
472
+ return table
473
+
474
+
475
+ def trace_clicks_h5(vue_path, vue_content, api_table):
476
+ """Vant/H5 风格的 click→api 追踪。
477
+
478
+ 和 trace_clicks 区别:
479
+ - 同时认 @click 和 @tap (Vant 用 @tap)
480
+ - API 调用是 import { fn } from '@/api/xx' + fn(args) (不是 requestClient.xx)
481
+ """
482
+ if not api_table:
483
+ return []
484
+ traces = []
485
+ # 收集 @click / @tap
486
+ clicks = list(re.finditer(r"@(?:click|tap)(?:\.\w+)?\s*=\s*['\"]([^'\"]+)['\"]", vue_content))
487
+ if not clicks:
488
+ return []
489
+ # 收集 import { fn1, fn2 } from '@/api/...'
490
+ imported = set()
491
+ for m in re.finditer(r"import\s*\{([^}]+)\}\s*from\s*['\"][^'\"]*api[^'\"]*['\"]", vue_content):
492
+ for n in re.split(r'[,\s]+', m.group(1)):
493
+ n = n.strip()
494
+ if n:
495
+ imported.add(n)
496
+ # script 里直接调用的 api 函数 (imported 集合内的)
497
+ for click in clicks:
498
+ handler = click.group(1).split('(')[0].strip()
499
+ if not handler:
500
+ continue
501
+ # 在 script 里找 handler 函数体, 看它调了哪些 imported api
502
+ handler_body = _get_fn_body(vue_content, handler)
503
+ if not handler_body:
504
+ continue
505
+ for api_fn in imported:
506
+ if api_fn in api_table and re.search(r'\b' + re.escape(api_fn) + r'\s*\(', handler_body):
507
+ info = api_table[api_fn]
508
+ traces.append({
509
+ 'vue_file': vue_path, 'button_text': handler,
510
+ 'handler': handler, 'api_fn': api_fn,
511
+ 'endpoint': info['url'], 'verb': info['verb'],
512
+ 'api_file': info['file'], 'confidence': 'high',
513
+ })
514
+ return traces
@@ -0,0 +1,430 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Shared extraction utilities for the QODER knowledge graph.
4
+
5
+ Centralizes regex-based extraction that was previously copy-pasted across
6
+ build_style_index.py and git_sync.py. The headline feature is the **call-chain
7
+ extractor**: given a project's source tree, it builds a table mapping each
8
+ clickable UI element (button @click + label) to the backend API endpoint it
9
+ triggers, so test automation and impact analysis can answer:
10
+
11
+ - "点击'新增'按钮会调哪个接口?" (test automation / QoderWork)
12
+ - "改这个接口会影响哪些页面/按钮?" (dev impact analysis)
13
+
14
+ Two passes:
15
+ 1. ``build_api_fn_table`` — scan ``src/api/**/*.ts``, parse ``enum Api``
16
+ + ``export function`` → {fnName: {url, verb, file}}. ~100% reliable.
17
+ 2. ``trace_clicks`` — scan ``src/views/**/*.vue``, resolve each
18
+ ``@click="handleX"`` to an api function via 4 patterns (direct / drawer
19
+ onConfirm / Modal.confirm onOk / router.push deferred).
20
+
21
+ Confidence is tagged per edge (high/medium/low/deferred) so consumers know
22
+ which traces are statically certain vs need runtime confirmation.
23
+ """
24
+ import os
25
+ import re
26
+
27
+
28
+ # ── Reusable extraction regexes (previously duplicated) ──────────────
29
+
30
+ # Table column: title + field (VxeGrid/Vben schema). Used by build_style_index.
31
+ RE_TABLE_COL_TF = re.compile(
32
+ r"title:\s*['\"]([^'\"]+)['\"]\s*,\s*field:\s*['\"]([^'\"]+)['\"]")
33
+ RE_TABLE_COL_FT = re.compile(
34
+ r"field:\s*['\"]([^'\"]+)['\"]\s*,\s*title:\s*['\"]([^'\"]+)['\"]")
35
+
36
+ # Form field: fieldName + label
37
+ RE_FORM_FIELD = re.compile(
38
+ r"fieldName:\s*['\"]([^'\"]+)['\"]\s*,\s*label:\s*['\"]([^'\"]+)['\"]")
39
+
40
+ # Ant Design Vue component imports
41
+ RE_ANT_IMPORTS = re.compile(
42
+ r"import\s+\{([^}]+)\}\s+from\s+['\"]ant-design-vue['\"]")
43
+
44
+
45
+ # ── Call-chain extraction ────────────────────────────────────────────
46
+
47
+ # enum Api { key = '/url', ... } — the URL constant block
48
+ RE_ENUM_API = re.compile(r"enum\s+Api\s*\{([^}]*)\}", re.S)
49
+ RE_ENUM_ENTRY = re.compile(r"(\w+)\s*=\s*['\"`]([^'\"`]+)['\"`]")
50
+
51
+ # export function fnName(...) { return requestClient.<verb>(<urlExpr>, ...) }
52
+ # urlExpr can be Api.key (symbolic) or a template literal `${Api.key}/...`
53
+ # export function fnName(...) { ... }
54
+ # Also matches: export const fnName = (async)? (...) => { (arrow function consts,
55
+ # used for commonExport wrappers like `export const exportFactoryList = async (p) => ...`)
56
+ RE_EXPORT_FN = re.compile(
57
+ r"(?:export\s+(?:async\s+)?function\s+(\w+)\s*\(|"
58
+ r"export\s+const\s+(\w+)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>\s*\{?)", re.M)
59
+ RE_REQUEST_CALL = re.compile(
60
+ r"requestClient\.(\w+)(?:<[^)]*?>)?\s*\(\s*(Api\.\w+|`[^`]*`|'[^']*'|\"[^\"]*\")")
61
+
62
+ # commonExport(Api.key, ...) / commonDownloadExcel(fn, ...) — wrapper that
63
+ # delegates to requestClient. commonExport takes a URL; commonDownloadExcel
64
+ # takes an api *function* as first arg.
65
+ RE_COMMON_EXPORT = re.compile(r"commonExport\s*\(\s*(Api\.\w+)")
66
+ RE_COMMON_DOWNLOAD = re.compile(r"commonDownloadExcel\s*\(\s*(\w+)")
67
+
68
+ # Vue template: @click="handler" or @click.stop="handler(...)"
69
+ RE_CLICK = re.compile(r"@click(?:\.\w+)?\s*=\s*['\"]([^'\"]+)['\"]")
70
+
71
+ # Button text between tags (after @click line, take nearest text node)
72
+ # We grab the text right after the clickable element opening tag.
73
+ RE_BTN_TEXT_AFTER_CLICK = re.compile(
74
+ r"@click(?:\.\w+)?\s*=\s*['\"][^'\"]*['\"][^>]*>\s*([^\n<]{1,20})", re.S)
75
+
76
+ # import { fn1, fn2 } from '#/api/...' — api imports in a Vue SFC
77
+ RE_API_IMPORT = re.compile(
78
+ r"import\s+\{([^}]+)\}\s+from\s+['\"](?:#|@)/(?:api|@/api)/([^'\"]+)['\"]")
79
+
80
+ # connectedComponent: localImport (drawer/modal wiring)
81
+ RE_CONNECTED_COMP = re.compile(r"connectedComponent:\s*(\w+)")
82
+
83
+ # import localName from './xxx.vue' (local default import for drawer)
84
+ RE_LOCAL_VUE_IMPORT = re.compile(
85
+ r"import\s+(\w+)\s+from\s+['\"]([^'\"]+\.vue)['\"]")
86
+
87
+ # function handlerName(...) { ... } — capture full function body (greedy to next function/export/</script)
88
+ RE_FN_BODY = re.compile(
89
+ r"(?:async\s+)?function\s+(\w+)\s*\([^)]*\)\s*\{", re.M)
90
+
91
+ # modalApi.open() / drawerApi.open() — drawer trigger
92
+ RE_DRAWER_OPEN = re.compile(r"(\w*[Aa]pi)\??\.open\s*\(\s*\)")
93
+
94
+ # Modal.confirm({ onOk: ... }) — delete confirmation
95
+ RE_MODAL_CONFIRM = re.compile(r"Modal\.confirm\s*\(\s*\{", re.S)
96
+
97
+ # useVbenDrawer({ connectedComponent: X }) — drawer declaration
98
+ RE_USE_DRAWER = re.compile(
99
+ r"useVbenDrawer\s*\(\s*\{[^}]*connectedComponent:\s*(\w+)", re.S)
100
+
101
+ # router.push({ path: '...' })
102
+ RE_ROUTER_PUSH = re.compile(r"router\.push\s*\(")
103
+
104
+ # Pattern G: proxyConfig.ajax.query 函数 — ICS VxeGrid 表格数据加载入口
105
+ # 真实写法 (审核 601 个文件确认):
106
+ # proxyConfig: { ajax: { query: async ({page}, formValues) => {
107
+ # return await getUnconventionalDetailList({...}) ← 导入的 api 函数
108
+ # }}}
109
+ # 这不是 proxyConfig.ajax({url}) 内联 url, 而是在 query 函数体里调用已导入的 api 函数。
110
+ # 提取 query 函数体, 交给 _find_api_calls_in_text 匹配。
111
+ RE_PROXY_AJAX = re.compile(
112
+ r"proxyConfig\s*:\s*\{\s*ajax\s*:\s*\{\s*"
113
+ r"(?:query|queryAll|queryPage)\s*:\s*async\s*\(", re.S)
114
+
115
+
116
+ def parse_api_enum_block(content):
117
+ """Extract {enumKey: '/url'} from an ``enum Api { ... }`` block.
118
+
119
+ Handles enum values containing template literals with ${VAR} — the nested
120
+ braces would break a naive [^}]* regex, so we use brace-depth matching.
121
+ """
122
+ urls = {}
123
+ # Find "enum Api {" then manually match braces to find the closing }
124
+ for m in re.finditer(r'enum\s+Api\s*\{', content):
125
+ start = m.end()
126
+ depth = 1
127
+ i = start
128
+ while i < len(content) and depth > 0:
129
+ if content[i] == '{':
130
+ depth += 1
131
+ elif content[i] == '}':
132
+ depth -= 1
133
+ i += 1
134
+ block = content[start:i-1] if depth == 0 else content[start:]
135
+ for em in RE_ENUM_ENTRY.finditer(block):
136
+ urls[em.group(1)] = em.group(2)
137
+ return urls
138
+
139
+
140
+ def build_api_fn_table(api_dir):
141
+ """Scan ``src/api/**/*.ts`` and build {fnName: {url, verb, file}}.
142
+
143
+ Resolves symbolic ``Api.key`` references via the file's ``enum Api`` block.
144
+ Handles ``commonExport(Api.key)`` wrappers. Returns dict + the enum map
145
+ per file (for cross-file resolution).
146
+ """
147
+ table = {}
148
+ if not api_dir or not os.path.isdir(api_dir):
149
+ return table
150
+
151
+ for root, dirs, files in os.walk(api_dir):
152
+ dirs[:] = [d for d in dirs if d not in ('node_modules', 'dist', '__pycache__')]
153
+ for f in files:
154
+ if not f.endswith('.ts'):
155
+ continue
156
+ fpath = os.path.join(root, f)
157
+ try:
158
+ content = open(fpath, encoding='utf-8', errors='ignore').read()
159
+ except Exception:
160
+ continue
161
+ urls = parse_api_enum_block(content)
162
+
163
+ # For each exported function, find requestClient call inside its
164
+ # EXACT body (brace-matched, not a fixed-length snippet — a snippet
165
+ # would bleed into the next function and mis-resolve the Api key).
166
+ for fm in RE_EXPORT_FN.finditer(content):
167
+ fn_name = fm.group(1) or fm.group(2) # function name or const name
168
+ if not fn_name:
169
+ continue
170
+ body = _get_fn_body(content, fn_name, max_len=500)
171
+ if not body:
172
+ continue
173
+ entry = None
174
+ # Direct requestClient.<verb>(Api.key or 'literal')
175
+ rm = RE_REQUEST_CALL.search(body)
176
+ if rm:
177
+ verb = rm.group(1)
178
+ url_expr = rm.group(2)
179
+ url = _resolve_url_expr(url_expr, urls)
180
+ if url:
181
+ entry = {'url': url, 'verb': verb, 'file': fpath.replace('\\', '/')}
182
+ # commonExport(Api.key) wrapper
183
+ if not entry:
184
+ cm = RE_COMMON_EXPORT.search(body)
185
+ if cm:
186
+ url = _resolve_url_expr(cm.group(1), urls)
187
+ if url:
188
+ entry = {'url': url, 'verb': 'post', 'file': fpath.replace('\\', '/')}
189
+ if entry:
190
+ table[fn_name] = entry
191
+ return table
192
+
193
+
194
+ def _resolve_url_expr(expr, urls):
195
+ """Resolve ``Api.key`` or template literal to a concrete URL string."""
196
+ expr = expr.strip()
197
+ if expr.startswith('Api.'):
198
+ key = expr[4:]
199
+ return urls.get(key)
200
+ # Template literal `${Api.key}/xxx` or plain string
201
+ if expr.startswith('`') or expr.startswith("'") or expr.startswith('"'):
202
+ inner = expr.strip('`\'"')
203
+ # Replace ${Api.key} placeholders
204
+ def repl(m):
205
+ return urls.get(m.group(1), m.group(0))
206
+ inner = re.sub(r"\$\{Api\.(\w+)\}", repl, inner)
207
+ # If still has Api. references, try direct
208
+ inner = re.sub(r"Api\.(\w+)", lambda m: urls.get(m.group(1), m.group(0)), inner)
209
+ return inner if '/' in inner else None
210
+ return None
211
+
212
+
213
+ def _get_fn_body(content, fn_name, max_len=800):
214
+ """Get the body text of a named function (best-effort, ~max_len chars).
215
+
216
+ Handles both ``function fnName() {`` and ``const fnName = () => {`` forms.
217
+ """
218
+ # Try: function fnName( OR const fnName = (async) (...) => {
219
+ for pat in [
220
+ re.compile(r"(?:async\s+)?function\s+" + re.escape(fn_name) + r"\s*\([^)]*\)\s*\{"),
221
+ re.compile(r"const\s+" + re.escape(fn_name) + r"\s*=\s*(?:async\s*)?\([^)]*\)\s*=>\s*\{?"),
222
+ ]:
223
+ m = pat.search(content)
224
+ if m:
225
+ break
226
+ else:
227
+ return ''
228
+ start = m.end()
229
+ # If arrow fn without explicit {, grab the expression line instead
230
+ if '=>' in m.group(0) and '{' not in m.group(0):
231
+ return content[start:start + max_len]
232
+ # Simple brace matching
233
+ depth = 1
234
+ i = start
235
+ end = min(len(content), start + max_len)
236
+ while i < end and depth > 0:
237
+ if content[i] == '{':
238
+ depth += 1
239
+ elif content[i] == '}':
240
+ depth -= 1
241
+ i += 1
242
+ return content[start:i]
243
+
244
+
245
+ def _find_api_calls_in_text(text, api_table, api_imports):
246
+ """Return list of (api_fn, endpoint, verb) found in a text snippet."""
247
+ hits = []
248
+ for fn in api_imports:
249
+ if fn in api_table and re.search(r'\b' + re.escape(fn) + r'\b', text):
250
+ info = api_table[fn]
251
+ hits.append((fn, info['url'], info.get('verb', '?')))
252
+ # commonDownloadExcel(fnName, ...) — first arg is an api function
253
+ for dm in RE_COMMON_DOWNLOAD.finditer(text):
254
+ fn = dm.group(1)
255
+ if fn in api_table:
256
+ info = api_table[fn]
257
+ hits.append((fn, info['url'], info.get('verb', '?')))
258
+ return hits
259
+
260
+
261
+ def _resolve_drawer_api(content, drawer_var):
262
+ """Given a drawer/modal api var name, find its connectedComponent import path."""
263
+ # useVbenDrawer({ connectedComponent: CompName }) declared as [CompName, drawerVar]
264
+ # Pattern: const [CompName, drawerVar] = useVbenDrawer({ connectedComponent: compImport })
265
+ pat = re.compile(
266
+ r"const\s+\[\s*(\w+)\s*,\s*" + re.escape(drawer_var) + r"\s*\]\s*=\s*useVbenDrawer",
267
+ re.S)
268
+ m = pat.search(content)
269
+ if not m:
270
+ # Fallback: find connectedComponent near the drawer var
271
+ for cm in RE_CONNECTED_COMP.finditer(content):
272
+ comp = cm.group(1)
273
+ # Verify this comp's drawer var matches by proximity (within 200 chars)
274
+ if drawer_var in content[max(0, cm.start()-200):cm.start()+200]:
275
+ m = type('x', (), {'group': lambda self, i: comp})()
276
+ break
277
+ if not m:
278
+ return None
279
+ comp_name = m.group(1) if hasattr(m, 'group') else None
280
+ if not comp_name:
281
+ return None
282
+ # Find import localName from './xxx.vue' matching comp_name
283
+ for im in RE_LOCAL_VUE_IMPORT.finditer(content):
284
+ if im.group(1) == comp_name:
285
+ return im.group(2)
286
+ return None
287
+
288
+
289
+ def trace_clicks(vue_path, vue_content, api_table):
290
+ """Extract click→api traces from a single Vue SFC.
291
+
292
+ Returns list of trace dicts:
293
+ {vue_file, button, handler, api_fn, endpoint, verb, confidence}
294
+ confidence: 'high' (direct), 'medium' (drawer 1-hop), 'low' (modal confirm),
295
+ 'deferred' (router.push).
296
+ """
297
+ traces = []
298
+ dirname = os.path.dirname(vue_path)
299
+
300
+ # 1. Collect api imports in this SFC
301
+ api_imports = set()
302
+ for im in RE_API_IMPORT.finditer(vue_content):
303
+ for name in im.group(1).split(','):
304
+ name = name.strip().split(' as ')[0].strip()
305
+ if name and name in api_table:
306
+ api_imports.add(name)
307
+
308
+ # 2. Find all @click handlers + nearest button text
309
+ clicks = list(RE_CLICK.finditer(vue_content))
310
+ if not clicks:
311
+ return traces
312
+
313
+ # Build drawer map: drawerVar → connected component .vue path.
314
+ # Pattern: const [Comp, drawerVar] = useVbenDrawer({ connectedComponent: compImport })
315
+ # Note: Comp name may differ from compImport (e.g. [FactoryModal, modalApi] with
316
+ # connectedComponent: factoryModal). So we link drawerVar → compImport → import path.
317
+ drawer_map = {}
318
+ # Find all useVbenDrawer declarations and capture the drawer var + connectedComponent
319
+ for dm in re.finditer(
320
+ r"const\s+\[\s*\w+\s*,\s*(\w+)\s*\]\s*=\s*useVbenDrawer\s*\(\s*\{([^}]*)\}",
321
+ vue_content, re.S):
322
+ drawer_var = dm.group(1)
323
+ decl_body = dm.group(2)
324
+ cm = re.search(r"connectedComponent:\s*(\w+)", decl_body)
325
+ if not cm:
326
+ continue
327
+ comp_import_name = cm.group(1)
328
+ # Resolve comp_import_name → './xxx.vue' via local imports
329
+ for im in RE_LOCAL_VUE_IMPORT.finditer(vue_content):
330
+ if im.group(1) == comp_import_name:
331
+ drawer_map[drawer_var] = im.group(2)
332
+ break
333
+
334
+ # 3. For each click, resolve to api
335
+ for i, cm in enumerate(clicks):
336
+ handler_expr = cm.group(1).strip()
337
+ # Button text: text right after the element, or previous text node
338
+ btn_text = ''
339
+ tm = RE_BTN_TEXT_AFTER_CLICK.search(vue_content, cm.start())
340
+ if tm:
341
+ btn_text = tm.group(1).strip()[:12]
342
+ # Strip Vue interpolation {{ }} from button text (e.g. "{{ item.name }}")
343
+ btn_text = re.sub(r'\{\{[^}]*\}\}', '', btn_text).strip()[:12]
344
+
345
+ # Skip inline expressions that aren't function calls:
346
+ # $emit, item.remove(), splice, push — pure frontend, no API
347
+ if handler_expr.startswith('$') or '.splice(' in handler_expr or '.push(' in handler_expr:
348
+ continue
349
+
350
+ # Get handler function name (strip args/calls)
351
+ handler_name = handler_expr.split('(')[0].split('.')[0].strip()
352
+ if not handler_name or handler_name in ('undefined', '', 'true', 'false'):
353
+ continue
354
+ # Skip single-word inline handlers (add, remove, item, emit) that are
355
+ # likely data/methods shorthand, not named handler functions
356
+ if handler_name in ('item', 'emit', 'row', 'data') and '.' not in handler_expr:
357
+ continue
358
+
359
+ fn_body = _get_fn_body(vue_content, handler_name)
360
+
361
+ trace = {
362
+ 'vue_file': vue_path.replace('\\', '/'),
363
+ 'button': btn_text,
364
+ 'handler': handler_name,
365
+ 'api_fn': None, 'endpoint': None, 'verb': None,
366
+ 'confidence': None,
367
+ }
368
+
369
+ # Pattern B: direct api call in handler body
370
+ hits = _find_api_calls_in_text(fn_body, api_table, api_imports)
371
+ if hits:
372
+ fn, url, verb = hits[0]
373
+ trace.update(api_fn=fn, endpoint=url, verb=verb, confidence='high')
374
+ traces.append(trace)
375
+ continue
376
+
377
+ # Pattern D: drawer/modal open → resolve connected component
378
+ open_m = RE_DRAWER_OPEN.search(fn_body)
379
+ if open_m:
380
+ drawer_var = open_m.group(1)
381
+ comp_rel = drawer_map.get(drawer_var)
382
+ if comp_rel:
383
+ comp_path = os.path.normpath(os.path.join(dirname, comp_rel))
384
+ if os.path.isfile(comp_path):
385
+ try:
386
+ comp_content = open(comp_path, encoding='utf-8', errors='ignore').read()
387
+ comp_api_imports = set()
388
+ for im in RE_API_IMPORT.finditer(comp_content):
389
+ for name in im.group(1).split(','):
390
+ name = name.strip().split(' as ')[0].strip()
391
+ if name in api_table:
392
+ comp_api_imports.add(name)
393
+ # Find onConfirm/handleConfirm body in the component
394
+ for confirm_name in ('handleConfirm', 'onConfirm', 'handleSubmit', 'onOk'):
395
+ cbody = _get_fn_body(comp_content, confirm_name)
396
+ if cbody:
397
+ chits = _find_api_calls_in_text(cbody, api_table, comp_api_imports)
398
+ if chits:
399
+ fn, url, verb = chits[0]
400
+ trace.update(api_fn=fn, endpoint=url, verb=verb,
401
+ confidence='medium')
402
+ break
403
+ except Exception:
404
+ pass
405
+ if trace['endpoint']:
406
+ traces.append(trace)
407
+ continue
408
+
409
+ # Modal.confirm({ onOk }) pattern
410
+ mc_m = RE_MODAL_CONFIRM.search(fn_body)
411
+ if mc_m:
412
+ onok_body = fn_body[mc_m.end():]
413
+ hits = _find_api_calls_in_text(onok_body[:400], api_table, api_imports)
414
+ if hits:
415
+ fn, url, verb = hits[0]
416
+ trace.update(api_fn=fn, endpoint=url, verb=verb, confidence='low')
417
+ traces.append(trace)
418
+ continue
419
+
420
+ # router.push — deferred
421
+ if RE_ROUTER_PUSH.search(fn_body):
422
+ trace['confidence'] = 'deferred'
423
+ traces.append(trace)
424
+ continue
425
+
426
+ # Unresolved — record as low confidence (handler exists but no api found)
427
+ trace['confidence'] = 'unresolved'
428
+ traces.append(trace)
429
+
430
+ return traces
@@ -0,0 +1,9 @@
1
+ # -*- coding: utf-8 -*-
2
+ """L3 推断层 (GOAL 5): 跨源锚点关联。
3
+
4
+ 子模块:
5
+ weak_link embedding 弱关联推理 (向量召回, 需 pgvector; 不可用时降级命名匹配)
6
+ naming_matcher 命名同源匹配 (AssetController ↔ asset ↔ 资产管理)
7
+ recompute C 方案指纹重算 (30天过期弱边对比指纹)
8
+ promote 弱边升格 (出现强关联时弱边删除)
9
+ """