@hupan56/wlkj 2.6.0 → 2.7.1
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.
- package/bin/cli.js +269 -11
- package/package.json +1 -1
- package/templates/qoder/agents/insight-planning.md +67 -0
- package/templates/qoder/agents/insight-research.md +61 -0
- package/templates/qoder/agents/prd-reference.md +8 -2
- package/templates/qoder/commands/optional/wl-insight.md +275 -0
- package/templates/qoder/commands/{wl-report.md → optional/wl-report.md} +13 -5
- package/templates/qoder/commands/{wl-spec.md → optional/wl-spec.md} +1 -1
- package/templates/qoder/commands/{wl-status.md → optional/wl-status.md} +28 -2
- package/templates/qoder/commands/wl-code.md +10 -2
- package/templates/qoder/commands/wl-commit.md +1 -1
- package/templates/qoder/commands/wl-design-draw.md +78 -0
- package/templates/qoder/commands/wl-design-scan.md +108 -0
- package/templates/qoder/commands/wl-design-spec.md +154 -0
- package/templates/qoder/commands/wl-design.md +32 -0
- package/templates/qoder/commands/wl-init.md +24 -3
- package/templates/qoder/commands/wl-prd-full.md +226 -0
- package/templates/qoder/commands/wl-prd-quick.md +134 -0
- package/templates/qoder/commands/wl-prd-review.md +104 -0
- package/templates/qoder/commands/wl-prd.md +17 -311
- package/templates/qoder/commands/wl-search.md +66 -30
- package/templates/qoder/commands/wl-task.md +290 -59
- package/templates/qoder/commands/wl-test.md +92 -24
- package/templates/qoder/config.yaml +51 -15
- package/templates/qoder/hooks/inject-workflow-state.py +2 -2
- package/templates/qoder/hooks/session-start.py +82 -51
- package/templates/qoder/rules/wl-pipeline.md +216 -105
- package/templates/qoder/scripts/__pycache__/search_index.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/archive_prd.py +377 -0
- package/templates/qoder/scripts/autotest.py +1715 -0
- package/templates/qoder/scripts/autotest_batch.py +224 -0
- package/templates/qoder/scripts/autotest_run.py +297 -0
- package/templates/qoder/scripts/benchmark.py +210 -209
- package/templates/qoder/scripts/build_style_index.py +444 -4
- package/templates/qoder/scripts/check_carriers.py +238 -0
- package/templates/qoder/scripts/check_mcp.py +298 -0
- package/templates/qoder/scripts/check_qoderwork_consistency.py +166 -0
- package/templates/qoder/scripts/common/events.py +46 -0
- package/templates/qoder/scripts/common/extract.py +419 -0
- package/templates/qoder/scripts/common/graph_traverse.py +533 -0
- package/templates/qoder/scripts/common/paths.py +89 -0
- package/templates/qoder/scripts/common/pip_install.py +144 -0
- package/templates/qoder/scripts/common/platform_guard.py +61 -0
- package/templates/qoder/scripts/common/search_engine.py +205 -205
- package/templates/qoder/scripts/common/terms.py +57 -0
- package/templates/qoder/scripts/common/ts_extract.py +536 -0
- package/templates/qoder/scripts/context_pack.py +73 -13
- package/templates/qoder/scripts/enrich_prompt.py +226 -0
- package/templates/qoder/scripts/eval_prd.py +318 -231
- package/templates/qoder/scripts/export.py +487 -487
- package/templates/qoder/scripts/extract_api_params.py +246 -0
- package/templates/qoder/scripts/extract_routes.py +54 -0
- package/templates/qoder/scripts/extract_routes_tree.py +78 -0
- package/templates/qoder/scripts/fill_prototype.py +707 -0
- package/templates/qoder/scripts/gen_design_doc.py +394 -0
- package/templates/qoder/scripts/git_sync.py +27 -15
- package/templates/qoder/scripts/init_doctor.py +292 -40
- package/templates/qoder/scripts/install_qoderwork.py +366 -9
- package/templates/qoder/scripts/kg.py +708 -0
- package/templates/qoder/scripts/kg_auto_login.py +196 -0
- package/templates/qoder/scripts/kg_build.py +612 -0
- package/templates/qoder/scripts/kg_build_db.py +327 -0
- package/templates/qoder/scripts/kg_duckdb.py +549 -0
- package/templates/qoder/scripts/kg_incremental.py +393 -0
- package/templates/qoder/scripts/kg_link_db.py +224 -0
- package/templates/qoder/scripts/kg_mcp_server.py +801 -0
- package/templates/qoder/scripts/kg_semantic.py +150 -0
- package/templates/qoder/scripts/kg_test_runner.py +241 -0
- package/templates/qoder/scripts/lanhu_stdio_wrapper.py +119 -0
- package/templates/qoder/scripts/learn.py +118 -39
- package/templates/qoder/scripts/learn_aggregate.py +201 -0
- package/templates/qoder/scripts/mcp_launcher.py +359 -0
- package/templates/qoder/scripts/mysql_mcp_server.py +396 -0
- package/templates/qoder/scripts/repo_root.py +106 -0
- package/templates/qoder/scripts/role.py +12 -0
- package/templates/qoder/scripts/run_weekly_update.bat +5 -0
- package/templates/qoder/scripts/run_weekly_update.sh +5 -0
- package/templates/qoder/scripts/search_index.py +307 -60
- package/templates/qoder/scripts/secure-ls.js +5640 -0
- package/templates/qoder/scripts/setup.py +706 -641
- package/templates/qoder/scripts/setup_lanhu.py +963 -0
- package/templates/qoder/scripts/status.py +250 -11
- package/templates/qoder/scripts/sync_carriers.py +259 -0
- package/templates/qoder/scripts/syncgate.py +2 -2
- package/templates/qoder/scripts/task.py +75 -0
- package/templates/qoder/scripts/team_sync.py +60 -4
- package/templates/qoder/scripts/workspace_init.py +1 -1
- package/templates/qoder/skills/design-import/SKILL.md +226 -0
- package/templates/qoder/skills/design-import/figma-workflow.md +81 -0
- package/templates/qoder/skills/design-review/SKILL.md +82 -25
- package/templates/qoder/skills/prd-generator/SKILL.md +184 -60
- package/templates/qoder/skills/prd-review/SKILL.md +18 -1
- package/templates/qoder/skills/prompt-enrich/SKILL.md +90 -0
- package/templates/qoder/skills/prototype-generator/SKILL.md +256 -141
- package/templates/qoder/skills/prototype-generator/SKILL.md.zcode-79180-2af4721f-f9a6-412c-88db-c0af680d211b.tmp +0 -0
- package/templates/qoder/skills/spec-coder/SKILL.md +18 -1
- package/templates/qoder/skills/spec-generator/SKILL.md +18 -1
- package/templates/qoder/skills/test-generator/SKILL.md +15 -2
- package/templates/qoder/skills/wl-code/SKILL.md +55 -36
- package/templates/qoder/skills/wl-commit/SKILL.md +89 -76
- package/templates/qoder/skills/wl-design/SKILL.md +55 -0
- package/templates/qoder/skills/wl-init/SKILL.md +76 -67
- package/templates/qoder/skills/wl-insight/SKILL.md +201 -81
- package/templates/qoder/skills/wl-prd-full/SKILL.md +69 -0
- package/templates/qoder/skills/wl-prd-quick/SKILL.md +49 -0
- package/templates/qoder/skills/wl-prd-review/SKILL.md +34 -0
- package/templates/qoder/skills/wl-report/SKILL.md +131 -107
- package/templates/qoder/skills/wl-search/SKILL.md +141 -75
- package/templates/qoder/skills/wl-spec/SKILL.md +49 -39
- package/templates/qoder/skills/wl-status/SKILL.md +83 -61
- package/templates/qoder/skills/wl-task/SKILL.md +132 -58
- package/templates/qoder/skills/wl-test/SKILL.md +406 -40
- package/templates/qoder/templates/prototype-app.html +13 -8
- package/templates/qoder/templates/prototype-web.html +376 -93
- package/templates/root/AGENTS.md +89 -34
- package/templates/root/requirements.txt +21 -0
- package/templates/root//344/275/277/347/224/250/350/257/264/346/230/216.md +259 -259
- package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +186 -186
- package/templates/qoder/agents/prd-planning.md +0 -57
- package/templates/qoder/agents/prd-research.md +0 -34
- package/templates/qoder/commands/wl-insight.md +0 -51
- package/templates/qoder/scripts/__pycache__/init_doctor.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/__pycache__/setup.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/common/__pycache__/developer.cpython-39.pyc +0 -0
- package/templates/qoder/skills/wl-prd/SKILL.md +0 -92
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
check_qoderwork_consistency.py — 防退化检查:命令/skills 文件不得用裸相对路径调脚本。
|
|
5
|
+
|
|
6
|
+
背景:
|
|
7
|
+
QoderWork 桌面端工作目录是临时会话目录(workspace/xxx), 不是仓库根。
|
|
8
|
+
命令/技能文件里写 `python .qoder/scripts/xxx.py` → 在 QoderWork 下找不到脚本 →
|
|
9
|
+
Shell 直接报错, Python 根本没启动。这就是"ZCode 行、QoderWork 不行"的根因。
|
|
10
|
+
|
|
11
|
+
统一规约: 所有脚本调用必须用仓库根定位, 二选一:
|
|
12
|
+
① python "$R/.qoder/scripts/xxx.py" ... (R = repo_root.py 输出)
|
|
13
|
+
② cd "$R" && python .qoder/scripts/xxx.py ... (cd 到仓库根后相对路径)
|
|
14
|
+
|
|
15
|
+
本脚本扫 .qoder/commands/**/*.md 和 .qoder/skills/**/SKILL.md,
|
|
16
|
+
找出违反规约的裸相对路径调用, 退出码非 0 表示有退化。
|
|
17
|
+
可挂 pre-commit / CI。
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
python .qoder/scripts/check_qoderwork_consistency.py
|
|
21
|
+
python .qoder/scripts/check_qoderwork_consistency.py --strict # 警告也算失败
|
|
22
|
+
Exit:
|
|
23
|
+
0 = 通过(无裸相对路径调用, 或仅有 --strict 才算失败的警告)
|
|
24
|
+
1 = 发现裸相对路径调用(退化)
|
|
25
|
+
"""
|
|
26
|
+
import argparse
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
if sys.platform == 'win32':
|
|
32
|
+
try:
|
|
33
|
+
sys.stdout.reconfigure(encoding='utf-8')
|
|
34
|
+
except (AttributeError, IOError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
38
|
+
from common.paths import get_repo_root # noqa: E402
|
|
39
|
+
|
|
40
|
+
# 扫描目标: commands 全部 .md + skills 的 SKILL.md
|
|
41
|
+
SCAN_GLOBS = [
|
|
42
|
+
('commands', '.qoder/commands', '*.md'),
|
|
43
|
+
('skills', '.qoder/skills', 'SKILL.md'),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# 裸相对路径调用: `python .qoder/scripts/xxx` 但前面没有 cd "$R" 或 "$R/ 前缀
|
|
47
|
+
# 匹配 `python .qoder/scripts/` 或 `python3 .qoder/scripts/`, 不带 $R 引号包裹
|
|
48
|
+
BARE_PAT = re.compile(r'(?<![\"\$])\bpython3?\s+\.qoder/scripts/([A-Za-z0-9_./-]+)')
|
|
49
|
+
|
|
50
|
+
# 已安全模式: cd "$R" && python .qoder/scripts/...
|
|
51
|
+
SAFE_CD_PAT = re.compile(r'cd\s+["\']?\$R["\']?\s*&&\s*python3?\s+\.qoder/scripts/')
|
|
52
|
+
|
|
53
|
+
# 仓库根定位块存在的标志
|
|
54
|
+
ROOT_LOCATOR_PAT = re.compile(r'repo_root\.py|R=\$\(python.*repo_root')
|
|
55
|
+
|
|
56
|
+
# 在 install_qoderwork.py 提示行里的相对路径(说明性文字, 非真实调用), 豁免
|
|
57
|
+
HINT_CTX = 'install_qoderwork.py'
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def scan_file(path, strict):
|
|
61
|
+
"""扫一个文件, 返回 (errors, warnings, root_block_present)。
|
|
62
|
+
errors = 裸相对路径真实调用(必修); warnings = 无 R 块等(建议修)。
|
|
63
|
+
"""
|
|
64
|
+
errors = []
|
|
65
|
+
warnings = []
|
|
66
|
+
try:
|
|
67
|
+
with open(path, encoding='utf-8') as f:
|
|
68
|
+
lines = f.readlines()
|
|
69
|
+
except (OSError, IOError):
|
|
70
|
+
return errors, warnings, True
|
|
71
|
+
|
|
72
|
+
root_block = bool(ROOT_LOCATOR_PAT.search(''.join(lines)))
|
|
73
|
+
|
|
74
|
+
for i, line in enumerate(lines, 1):
|
|
75
|
+
# 豁免: 说明性文字里提到 install_qoderwork.py 的相对路径(这是"找不到时跑这个"的指引)
|
|
76
|
+
if HINT_CTX in line:
|
|
77
|
+
continue
|
|
78
|
+
# 豁免: 已安全的 cd "$R" && python 模式
|
|
79
|
+
if SAFE_CD_PAT.search(line):
|
|
80
|
+
continue
|
|
81
|
+
# 找裸相对路径
|
|
82
|
+
for m in BARE_PAT.finditer(line):
|
|
83
|
+
script = m.group(1)
|
|
84
|
+
# 过滤掉明显是文字描述(行里既无 bash 代码块上下文也无命令感)
|
|
85
|
+
# 简化: 只要匹配到就算(宁可误报让 reviewer 看一眼)
|
|
86
|
+
errors.append((i, line.rstrip(), script))
|
|
87
|
+
|
|
88
|
+
# 无 R 块但文件里有 $R 引用 → 警告
|
|
89
|
+
if not root_block and '$R' in ''.join(lines):
|
|
90
|
+
warnings.append((0, '(无 R 定位块, 但文件里用了 $R)', ''))
|
|
91
|
+
|
|
92
|
+
return errors, warnings, root_block
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main():
|
|
96
|
+
ap = argparse.ArgumentParser(description='检查命令/skills 是否含裸相对路径(防 ZCode/QoderWork 割裂)')
|
|
97
|
+
ap.add_argument('--strict', action='store_true', help='警告也算失败')
|
|
98
|
+
args = ap.parse_args()
|
|
99
|
+
|
|
100
|
+
repo = get_repo_root()
|
|
101
|
+
total_errors = 0
|
|
102
|
+
total_warnings = 0
|
|
103
|
+
files_with_errors = []
|
|
104
|
+
files_with_warnings = []
|
|
105
|
+
|
|
106
|
+
for label, rel_dir, pattern in SCAN_GLOBS:
|
|
107
|
+
scan_dir = repo / rel_dir
|
|
108
|
+
if not scan_dir.is_dir():
|
|
109
|
+
continue
|
|
110
|
+
for dirpath, dirnames, filenames in os.walk(str(scan_dir)):
|
|
111
|
+
for fn in filenames:
|
|
112
|
+
import fnmatch
|
|
113
|
+
if not fnmatch.fnmatch(fn, pattern):
|
|
114
|
+
continue
|
|
115
|
+
path = os.path.join(dirpath, fn)
|
|
116
|
+
errors, warnings, root_block = scan_file(path, args.strict)
|
|
117
|
+
rel = os.path.relpath(path, str(repo))
|
|
118
|
+
if errors:
|
|
119
|
+
files_with_errors.append((rel, errors))
|
|
120
|
+
total_errors += len(errors)
|
|
121
|
+
if warnings:
|
|
122
|
+
files_with_warnings.append((rel, warnings))
|
|
123
|
+
total_warnings += len(warnings)
|
|
124
|
+
|
|
125
|
+
# 输出
|
|
126
|
+
print('=' * 64)
|
|
127
|
+
print('QoderWork 一致性检查 (防 ZCode 行 / QoderWork 挂 的割裂)')
|
|
128
|
+
print('=' * 64)
|
|
129
|
+
|
|
130
|
+
if files_with_errors:
|
|
131
|
+
print()
|
|
132
|
+
print('🔴 裸相对路径调用 (QoderWork 必挂, 必修):')
|
|
133
|
+
for rel, errs in files_with_errors:
|
|
134
|
+
print(' ' + rel)
|
|
135
|
+
for lineno, line, script in errs:
|
|
136
|
+
snippet = line.strip()[:80]
|
|
137
|
+
print(' L%d: %s' % (lineno, snippet))
|
|
138
|
+
|
|
139
|
+
if files_with_warnings:
|
|
140
|
+
print()
|
|
141
|
+
print('🟡 警告 (建议修, --strict 才算失败):')
|
|
142
|
+
for rel, ws in files_with_warnings:
|
|
143
|
+
print(' ' + rel)
|
|
144
|
+
for lineno, line, script in ws:
|
|
145
|
+
print(' %s' % line)
|
|
146
|
+
|
|
147
|
+
if not files_with_errors and not files_with_warnings:
|
|
148
|
+
print('✅ 全部通过: 命令/skills 无裸相对路径调用, ZCode ≡ QoderWork 一致。')
|
|
149
|
+
|
|
150
|
+
print('-' * 64)
|
|
151
|
+
print('统计: 错误 %d (文件 %d) / 警告 %d (文件 %d)'
|
|
152
|
+
% (total_errors, len(files_with_errors), total_warnings, len(files_with_warnings)))
|
|
153
|
+
print()
|
|
154
|
+
print('规约: 脚本调用必须二选一:')
|
|
155
|
+
print(' ① python "$R/.qoder/scripts/xxx.py" ... (R = repo_root.py 输出)')
|
|
156
|
+
print(' ② cd "$R" && python .qoder/scripts/xxx.py ...')
|
|
157
|
+
|
|
158
|
+
if total_errors > 0:
|
|
159
|
+
return 1
|
|
160
|
+
if args.strict and total_warnings > 0:
|
|
161
|
+
return 1
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == '__main__':
|
|
166
|
+
sys.exit(main())
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""events.py - 流水线事件字典 (Single Source of Truth)
|
|
3
|
+
|
|
4
|
+
为什么需要它:
|
|
5
|
+
之前 learn.py 的 stats_map 和 learn_aggregate.py 的 SQL FILTER 各自硬编码
|
|
6
|
+
事件名, 两边漂移 → learning_stats 表的 reviews/commits 永远是 0 (没人埋点)。
|
|
7
|
+
统一到这里, 任何 skill 加埋点只需在此声明, 两端自动对齐。
|
|
8
|
+
|
|
9
|
+
事件 → 统计列 → 归属 skill 的映射是 SSOT:
|
|
10
|
+
EVENT_TYPES[event] = {
|
|
11
|
+
'stat': 该事件累加到 learning.yaml / learning_stats 的哪一列 (None=不计数)
|
|
12
|
+
'skill': 归属哪个 skill (用于「埋点覆盖率」自检)
|
|
13
|
+
'label': 人类可读名 (/wl-status 展示用)
|
|
14
|
+
}
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
# 事件类型注册表 —— 唯一事实源
|
|
18
|
+
# 新增埋点: 在这里加一行 → learn.py 计数 + learn_aggregate.py SQL 自动生效
|
|
19
|
+
EVENT_TYPES = {
|
|
20
|
+
# PRD 链路
|
|
21
|
+
'prd_accepted': {'stat': 'prds_generated', 'skill': 'wl-prd-full', 'label': 'PRD 发布'},
|
|
22
|
+
'prd_rejected': {'stat': 'rejections', 'skill': 'wl-prd-full', 'label': 'PRD 打回'},
|
|
23
|
+
'review_done': {'stat': 'reviews_done', 'skill': 'prd-review', 'label': 'PRD 评审'},
|
|
24
|
+
# 设计链路
|
|
25
|
+
'design_import': {'stat': 'designs_imported','skill': 'design-import', 'label': '设计录入'},
|
|
26
|
+
# 任务链路
|
|
27
|
+
'task_completed': {'stat': 'tasks_completed', 'skill': 'wl-task', 'label': '任务完成'},
|
|
28
|
+
# 提交链路
|
|
29
|
+
'commit_done': {'stat': 'commits_done', 'skill': 'wl-commit', 'label': '代码提交'},
|
|
30
|
+
# 流水线自检事件 (不计入正向统计, 用于健康度看板)
|
|
31
|
+
'rule_violation': {'stat': None, 'skill': None, 'label': '规则违规'},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def stats_event_map():
|
|
36
|
+
"""返回 {event: stat_column} (只含需要计数的, None 的过滤掉)。
|
|
37
|
+
learn.py 用它驱动 _update_stats。
|
|
38
|
+
"""
|
|
39
|
+
return {ev: meta['stat'] for ev, meta in EVENT_TYPES.items() if meta['stat']}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def event_to_sql_filter():
|
|
43
|
+
"""返回 {stat_column: event} (反向映射)。
|
|
44
|
+
learn_aggregate.py 用它生成 SQL FILTER 子句, 不再硬编码。
|
|
45
|
+
"""
|
|
46
|
+
return {meta['stat']: ev for ev, meta in EVENT_TYPES.items() if meta['stat']}
|
|
@@ -0,0 +1,419 @@
|
|
|
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
|
+
|
|
105
|
+
def parse_api_enum_block(content):
|
|
106
|
+
"""Extract {enumKey: '/url'} from an ``enum Api { ... }`` block.
|
|
107
|
+
|
|
108
|
+
Handles enum values containing template literals with ${VAR} — the nested
|
|
109
|
+
braces would break a naive [^}]* regex, so we use brace-depth matching.
|
|
110
|
+
"""
|
|
111
|
+
urls = {}
|
|
112
|
+
# Find "enum Api {" then manually match braces to find the closing }
|
|
113
|
+
for m in re.finditer(r'enum\s+Api\s*\{', content):
|
|
114
|
+
start = m.end()
|
|
115
|
+
depth = 1
|
|
116
|
+
i = start
|
|
117
|
+
while i < len(content) and depth > 0:
|
|
118
|
+
if content[i] == '{':
|
|
119
|
+
depth += 1
|
|
120
|
+
elif content[i] == '}':
|
|
121
|
+
depth -= 1
|
|
122
|
+
i += 1
|
|
123
|
+
block = content[start:i-1] if depth == 0 else content[start:]
|
|
124
|
+
for em in RE_ENUM_ENTRY.finditer(block):
|
|
125
|
+
urls[em.group(1)] = em.group(2)
|
|
126
|
+
return urls
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def build_api_fn_table(api_dir):
|
|
130
|
+
"""Scan ``src/api/**/*.ts`` and build {fnName: {url, verb, file}}.
|
|
131
|
+
|
|
132
|
+
Resolves symbolic ``Api.key`` references via the file's ``enum Api`` block.
|
|
133
|
+
Handles ``commonExport(Api.key)`` wrappers. Returns dict + the enum map
|
|
134
|
+
per file (for cross-file resolution).
|
|
135
|
+
"""
|
|
136
|
+
table = {}
|
|
137
|
+
if not api_dir or not os.path.isdir(api_dir):
|
|
138
|
+
return table
|
|
139
|
+
|
|
140
|
+
for root, dirs, files in os.walk(api_dir):
|
|
141
|
+
dirs[:] = [d for d in dirs if d not in ('node_modules', 'dist', '__pycache__')]
|
|
142
|
+
for f in files:
|
|
143
|
+
if not f.endswith('.ts'):
|
|
144
|
+
continue
|
|
145
|
+
fpath = os.path.join(root, f)
|
|
146
|
+
try:
|
|
147
|
+
content = open(fpath, encoding='utf-8', errors='ignore').read()
|
|
148
|
+
except Exception:
|
|
149
|
+
continue
|
|
150
|
+
urls = parse_api_enum_block(content)
|
|
151
|
+
|
|
152
|
+
# For each exported function, find requestClient call inside its
|
|
153
|
+
# EXACT body (brace-matched, not a fixed-length snippet — a snippet
|
|
154
|
+
# would bleed into the next function and mis-resolve the Api key).
|
|
155
|
+
for fm in RE_EXPORT_FN.finditer(content):
|
|
156
|
+
fn_name = fm.group(1) or fm.group(2) # function name or const name
|
|
157
|
+
if not fn_name:
|
|
158
|
+
continue
|
|
159
|
+
body = _get_fn_body(content, fn_name, max_len=500)
|
|
160
|
+
if not body:
|
|
161
|
+
continue
|
|
162
|
+
entry = None
|
|
163
|
+
# Direct requestClient.<verb>(Api.key or 'literal')
|
|
164
|
+
rm = RE_REQUEST_CALL.search(body)
|
|
165
|
+
if rm:
|
|
166
|
+
verb = rm.group(1)
|
|
167
|
+
url_expr = rm.group(2)
|
|
168
|
+
url = _resolve_url_expr(url_expr, urls)
|
|
169
|
+
if url:
|
|
170
|
+
entry = {'url': url, 'verb': verb, 'file': fpath.replace('\\', '/')}
|
|
171
|
+
# commonExport(Api.key) wrapper
|
|
172
|
+
if not entry:
|
|
173
|
+
cm = RE_COMMON_EXPORT.search(body)
|
|
174
|
+
if cm:
|
|
175
|
+
url = _resolve_url_expr(cm.group(1), urls)
|
|
176
|
+
if url:
|
|
177
|
+
entry = {'url': url, 'verb': 'post', 'file': fpath.replace('\\', '/')}
|
|
178
|
+
if entry:
|
|
179
|
+
table[fn_name] = entry
|
|
180
|
+
return table
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _resolve_url_expr(expr, urls):
|
|
184
|
+
"""Resolve ``Api.key`` or template literal to a concrete URL string."""
|
|
185
|
+
expr = expr.strip()
|
|
186
|
+
if expr.startswith('Api.'):
|
|
187
|
+
key = expr[4:]
|
|
188
|
+
return urls.get(key)
|
|
189
|
+
# Template literal `${Api.key}/xxx` or plain string
|
|
190
|
+
if expr.startswith('`') or expr.startswith("'") or expr.startswith('"'):
|
|
191
|
+
inner = expr.strip('`\'"')
|
|
192
|
+
# Replace ${Api.key} placeholders
|
|
193
|
+
def repl(m):
|
|
194
|
+
return urls.get(m.group(1), m.group(0))
|
|
195
|
+
inner = re.sub(r"\$\{Api\.(\w+)\}", repl, inner)
|
|
196
|
+
# If still has Api. references, try direct
|
|
197
|
+
inner = re.sub(r"Api\.(\w+)", lambda m: urls.get(m.group(1), m.group(0)), inner)
|
|
198
|
+
return inner if '/' in inner else None
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _get_fn_body(content, fn_name, max_len=800):
|
|
203
|
+
"""Get the body text of a named function (best-effort, ~max_len chars).
|
|
204
|
+
|
|
205
|
+
Handles both ``function fnName() {`` and ``const fnName = () => {`` forms.
|
|
206
|
+
"""
|
|
207
|
+
# Try: function fnName( OR const fnName = (async) (...) => {
|
|
208
|
+
for pat in [
|
|
209
|
+
re.compile(r"(?:async\s+)?function\s+" + re.escape(fn_name) + r"\s*\([^)]*\)\s*\{"),
|
|
210
|
+
re.compile(r"const\s+" + re.escape(fn_name) + r"\s*=\s*(?:async\s*)?\([^)]*\)\s*=>\s*\{?"),
|
|
211
|
+
]:
|
|
212
|
+
m = pat.search(content)
|
|
213
|
+
if m:
|
|
214
|
+
break
|
|
215
|
+
else:
|
|
216
|
+
return ''
|
|
217
|
+
start = m.end()
|
|
218
|
+
# If arrow fn without explicit {, grab the expression line instead
|
|
219
|
+
if '=>' in m.group(0) and '{' not in m.group(0):
|
|
220
|
+
return content[start:start + max_len]
|
|
221
|
+
# Simple brace matching
|
|
222
|
+
depth = 1
|
|
223
|
+
i = start
|
|
224
|
+
end = min(len(content), start + max_len)
|
|
225
|
+
while i < end and depth > 0:
|
|
226
|
+
if content[i] == '{':
|
|
227
|
+
depth += 1
|
|
228
|
+
elif content[i] == '}':
|
|
229
|
+
depth -= 1
|
|
230
|
+
i += 1
|
|
231
|
+
return content[start:i]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _find_api_calls_in_text(text, api_table, api_imports):
|
|
235
|
+
"""Return list of (api_fn, endpoint, verb) found in a text snippet."""
|
|
236
|
+
hits = []
|
|
237
|
+
for fn in api_imports:
|
|
238
|
+
if fn in api_table and re.search(r'\b' + re.escape(fn) + r'\b', text):
|
|
239
|
+
info = api_table[fn]
|
|
240
|
+
hits.append((fn, info['url'], info.get('verb', '?')))
|
|
241
|
+
# commonDownloadExcel(fnName, ...) — first arg is an api function
|
|
242
|
+
for dm in RE_COMMON_DOWNLOAD.finditer(text):
|
|
243
|
+
fn = dm.group(1)
|
|
244
|
+
if fn in api_table:
|
|
245
|
+
info = api_table[fn]
|
|
246
|
+
hits.append((fn, info['url'], info.get('verb', '?')))
|
|
247
|
+
return hits
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _resolve_drawer_api(content, drawer_var):
|
|
251
|
+
"""Given a drawer/modal api var name, find its connectedComponent import path."""
|
|
252
|
+
# useVbenDrawer({ connectedComponent: CompName }) declared as [CompName, drawerVar]
|
|
253
|
+
# Pattern: const [CompName, drawerVar] = useVbenDrawer({ connectedComponent: compImport })
|
|
254
|
+
pat = re.compile(
|
|
255
|
+
r"const\s+\[\s*(\w+)\s*,\s*" + re.escape(drawer_var) + r"\s*\]\s*=\s*useVbenDrawer",
|
|
256
|
+
re.S)
|
|
257
|
+
m = pat.search(content)
|
|
258
|
+
if not m:
|
|
259
|
+
# Fallback: find connectedComponent near the drawer var
|
|
260
|
+
for cm in RE_CONNECTED_COMP.finditer(content):
|
|
261
|
+
comp = cm.group(1)
|
|
262
|
+
# Verify this comp's drawer var matches by proximity (within 200 chars)
|
|
263
|
+
if drawer_var in content[max(0, cm.start()-200):cm.start()+200]:
|
|
264
|
+
m = type('x', (), {'group': lambda self, i: comp})()
|
|
265
|
+
break
|
|
266
|
+
if not m:
|
|
267
|
+
return None
|
|
268
|
+
comp_name = m.group(1) if hasattr(m, 'group') else None
|
|
269
|
+
if not comp_name:
|
|
270
|
+
return None
|
|
271
|
+
# Find import localName from './xxx.vue' matching comp_name
|
|
272
|
+
for im in RE_LOCAL_VUE_IMPORT.finditer(content):
|
|
273
|
+
if im.group(1) == comp_name:
|
|
274
|
+
return im.group(2)
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def trace_clicks(vue_path, vue_content, api_table):
|
|
279
|
+
"""Extract click→api traces from a single Vue SFC.
|
|
280
|
+
|
|
281
|
+
Returns list of trace dicts:
|
|
282
|
+
{vue_file, button, handler, api_fn, endpoint, verb, confidence}
|
|
283
|
+
confidence: 'high' (direct), 'medium' (drawer 1-hop), 'low' (modal confirm),
|
|
284
|
+
'deferred' (router.push).
|
|
285
|
+
"""
|
|
286
|
+
traces = []
|
|
287
|
+
dirname = os.path.dirname(vue_path)
|
|
288
|
+
|
|
289
|
+
# 1. Collect api imports in this SFC
|
|
290
|
+
api_imports = set()
|
|
291
|
+
for im in RE_API_IMPORT.finditer(vue_content):
|
|
292
|
+
for name in im.group(1).split(','):
|
|
293
|
+
name = name.strip().split(' as ')[0].strip()
|
|
294
|
+
if name and name in api_table:
|
|
295
|
+
api_imports.add(name)
|
|
296
|
+
|
|
297
|
+
# 2. Find all @click handlers + nearest button text
|
|
298
|
+
clicks = list(RE_CLICK.finditer(vue_content))
|
|
299
|
+
if not clicks:
|
|
300
|
+
return traces
|
|
301
|
+
|
|
302
|
+
# Build drawer map: drawerVar → connected component .vue path.
|
|
303
|
+
# Pattern: const [Comp, drawerVar] = useVbenDrawer({ connectedComponent: compImport })
|
|
304
|
+
# Note: Comp name may differ from compImport (e.g. [FactoryModal, modalApi] with
|
|
305
|
+
# connectedComponent: factoryModal). So we link drawerVar → compImport → import path.
|
|
306
|
+
drawer_map = {}
|
|
307
|
+
# Find all useVbenDrawer declarations and capture the drawer var + connectedComponent
|
|
308
|
+
for dm in re.finditer(
|
|
309
|
+
r"const\s+\[\s*\w+\s*,\s*(\w+)\s*\]\s*=\s*useVbenDrawer\s*\(\s*\{([^}]*)\}",
|
|
310
|
+
vue_content, re.S):
|
|
311
|
+
drawer_var = dm.group(1)
|
|
312
|
+
decl_body = dm.group(2)
|
|
313
|
+
cm = re.search(r"connectedComponent:\s*(\w+)", decl_body)
|
|
314
|
+
if not cm:
|
|
315
|
+
continue
|
|
316
|
+
comp_import_name = cm.group(1)
|
|
317
|
+
# Resolve comp_import_name → './xxx.vue' via local imports
|
|
318
|
+
for im in RE_LOCAL_VUE_IMPORT.finditer(vue_content):
|
|
319
|
+
if im.group(1) == comp_import_name:
|
|
320
|
+
drawer_map[drawer_var] = im.group(2)
|
|
321
|
+
break
|
|
322
|
+
|
|
323
|
+
# 3. For each click, resolve to api
|
|
324
|
+
for i, cm in enumerate(clicks):
|
|
325
|
+
handler_expr = cm.group(1).strip()
|
|
326
|
+
# Button text: text right after the element, or previous text node
|
|
327
|
+
btn_text = ''
|
|
328
|
+
tm = RE_BTN_TEXT_AFTER_CLICK.search(vue_content, cm.start())
|
|
329
|
+
if tm:
|
|
330
|
+
btn_text = tm.group(1).strip()[:12]
|
|
331
|
+
# Strip Vue interpolation {{ }} from button text (e.g. "{{ item.name }}")
|
|
332
|
+
btn_text = re.sub(r'\{\{[^}]*\}\}', '', btn_text).strip()[:12]
|
|
333
|
+
|
|
334
|
+
# Skip inline expressions that aren't function calls:
|
|
335
|
+
# $emit, item.remove(), splice, push — pure frontend, no API
|
|
336
|
+
if handler_expr.startswith('$') or '.splice(' in handler_expr or '.push(' in handler_expr:
|
|
337
|
+
continue
|
|
338
|
+
|
|
339
|
+
# Get handler function name (strip args/calls)
|
|
340
|
+
handler_name = handler_expr.split('(')[0].split('.')[0].strip()
|
|
341
|
+
if not handler_name or handler_name in ('undefined', '', 'true', 'false'):
|
|
342
|
+
continue
|
|
343
|
+
# Skip single-word inline handlers (add, remove, item, emit) that are
|
|
344
|
+
# likely data/methods shorthand, not named handler functions
|
|
345
|
+
if handler_name in ('item', 'emit', 'row', 'data') and '.' not in handler_expr:
|
|
346
|
+
continue
|
|
347
|
+
|
|
348
|
+
fn_body = _get_fn_body(vue_content, handler_name)
|
|
349
|
+
|
|
350
|
+
trace = {
|
|
351
|
+
'vue_file': vue_path.replace('\\', '/'),
|
|
352
|
+
'button': btn_text,
|
|
353
|
+
'handler': handler_name,
|
|
354
|
+
'api_fn': None, 'endpoint': None, 'verb': None,
|
|
355
|
+
'confidence': None,
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
# Pattern B: direct api call in handler body
|
|
359
|
+
hits = _find_api_calls_in_text(fn_body, api_table, api_imports)
|
|
360
|
+
if hits:
|
|
361
|
+
fn, url, verb = hits[0]
|
|
362
|
+
trace.update(api_fn=fn, endpoint=url, verb=verb, confidence='high')
|
|
363
|
+
traces.append(trace)
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
# Pattern D: drawer/modal open → resolve connected component
|
|
367
|
+
open_m = RE_DRAWER_OPEN.search(fn_body)
|
|
368
|
+
if open_m:
|
|
369
|
+
drawer_var = open_m.group(1)
|
|
370
|
+
comp_rel = drawer_map.get(drawer_var)
|
|
371
|
+
if comp_rel:
|
|
372
|
+
comp_path = os.path.normpath(os.path.join(dirname, comp_rel))
|
|
373
|
+
if os.path.isfile(comp_path):
|
|
374
|
+
try:
|
|
375
|
+
comp_content = open(comp_path, encoding='utf-8', errors='ignore').read()
|
|
376
|
+
comp_api_imports = set()
|
|
377
|
+
for im in RE_API_IMPORT.finditer(comp_content):
|
|
378
|
+
for name in im.group(1).split(','):
|
|
379
|
+
name = name.strip().split(' as ')[0].strip()
|
|
380
|
+
if name in api_table:
|
|
381
|
+
comp_api_imports.add(name)
|
|
382
|
+
# Find onConfirm/handleConfirm body in the component
|
|
383
|
+
for confirm_name in ('handleConfirm', 'onConfirm', 'handleSubmit', 'onOk'):
|
|
384
|
+
cbody = _get_fn_body(comp_content, confirm_name)
|
|
385
|
+
if cbody:
|
|
386
|
+
chits = _find_api_calls_in_text(cbody, api_table, comp_api_imports)
|
|
387
|
+
if chits:
|
|
388
|
+
fn, url, verb = chits[0]
|
|
389
|
+
trace.update(api_fn=fn, endpoint=url, verb=verb,
|
|
390
|
+
confidence='medium')
|
|
391
|
+
break
|
|
392
|
+
except Exception:
|
|
393
|
+
pass
|
|
394
|
+
if trace['endpoint']:
|
|
395
|
+
traces.append(trace)
|
|
396
|
+
continue
|
|
397
|
+
|
|
398
|
+
# Modal.confirm({ onOk }) pattern
|
|
399
|
+
mc_m = RE_MODAL_CONFIRM.search(fn_body)
|
|
400
|
+
if mc_m:
|
|
401
|
+
onok_body = fn_body[mc_m.end():]
|
|
402
|
+
hits = _find_api_calls_in_text(onok_body[:400], api_table, api_imports)
|
|
403
|
+
if hits:
|
|
404
|
+
fn, url, verb = hits[0]
|
|
405
|
+
trace.update(api_fn=fn, endpoint=url, verb=verb, confidence='low')
|
|
406
|
+
traces.append(trace)
|
|
407
|
+
continue
|
|
408
|
+
|
|
409
|
+
# router.push — deferred
|
|
410
|
+
if RE_ROUTER_PUSH.search(fn_body):
|
|
411
|
+
trace['confidence'] = 'deferred'
|
|
412
|
+
traces.append(trace)
|
|
413
|
+
continue
|
|
414
|
+
|
|
415
|
+
# Unresolved — record as low confidence (handler exists but no api found)
|
|
416
|
+
trace['confidence'] = 'unresolved'
|
|
417
|
+
traces.append(trace)
|
|
418
|
+
|
|
419
|
+
return traces
|