@hupan56/wlkj 2.4.5 → 2.4.7
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 +11 -0
- package/package.json +1 -1
- package/templates/qoder/commands/wl-prd.md +43 -10
- package/templates/qoder/hooks/inject-workflow-state.py +14 -0
- package/templates/qoder/hooks/session-start.py +17 -0
- package/templates/qoder/scripts/git_sync.py +313 -641
- package/templates/qoder/scripts/syncgate.py +9 -0
- package/templates/qoder/scripts/team_sync.py +15 -5
|
@@ -23,23 +23,13 @@ Config: .qoder/config.yaml -> git_sync section
|
|
|
23
23
|
import os
|
|
24
24
|
import sys
|
|
25
25
|
import json
|
|
26
|
-
import hashlib
|
|
27
26
|
import subprocess
|
|
28
27
|
import re
|
|
29
28
|
import shutil
|
|
30
29
|
from datetime import datetime
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
35
|
-
except (AttributeError, TypeError, OSError, IOError):
|
|
36
|
-
try:
|
|
37
|
-
sys.stdout.reconfigure(encoding='utf-8')
|
|
38
|
-
except Exception:
|
|
39
|
-
pass
|
|
40
|
-
|
|
41
|
-
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
42
|
-
from common.terms import BUSINESS_PATH_MAP, CN_TO_EN, PRD_STOP_WORDS
|
|
31
|
+
if sys.platform == 'win32':
|
|
32
|
+
sys.stdout.reconfigure(encoding='utf-8')
|
|
43
33
|
|
|
44
34
|
BASE = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
45
35
|
DATA_DIR = os.path.join(BASE, 'data')
|
|
@@ -49,79 +39,26 @@ PRD_DIR = os.path.join(DATA_DIR, 'docs', 'prd')
|
|
|
49
39
|
WORKSPACE = os.path.join(BASE, 'workspace', 'members')
|
|
50
40
|
CONFIG_PATH = os.path.join(BASE, '.qoder', 'config.yaml')
|
|
51
41
|
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
LOCK_STALE_SECONDS = 2 * 60 * 60
|
|
55
|
-
|
|
56
|
-
# Collected at runtime; non-empty => exit code 1 so cron/bat can detect failure
|
|
57
|
-
FAILURES = []
|
|
58
|
-
|
|
42
|
+
# Track files for incremental updates
|
|
43
|
+
CHANGE_TRACK_FILE = os.path.join(INDEX_DIR, '.change-track.json')
|
|
59
44
|
|
|
60
|
-
def fail(msg):
|
|
61
|
-
FAILURES.append(msg)
|
|
62
|
-
print('ERROR: ' + msg)
|
|
63
45
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
os.makedirs(INDEX_DIR, exist_ok=True)
|
|
67
|
-
if os.path.isfile(LOCK_FILE):
|
|
68
|
-
age = datetime.now().timestamp() - os.path.getmtime(LOCK_FILE)
|
|
69
|
-
if age < LOCK_STALE_SECONDS:
|
|
70
|
-
print('Another sync appears to be running (lock age {:.0f}s).'.format(age))
|
|
71
|
-
print('If you are sure it is not, delete: ' + LOCK_FILE)
|
|
72
|
-
return False
|
|
73
|
-
print('Removing stale lock ({:.0f}s old)'.format(age))
|
|
74
|
-
os.remove(LOCK_FILE)
|
|
75
|
-
with open(LOCK_FILE, 'w', encoding='utf-8') as f:
|
|
76
|
-
f.write('{} pid={}\n'.format(datetime.now().isoformat(), os.getpid()))
|
|
77
|
-
return True
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def release_lock():
|
|
81
|
-
try:
|
|
82
|
-
os.remove(LOCK_FILE)
|
|
83
|
-
except OSError:
|
|
84
|
-
pass
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def load_json(path, default=None, required=False):
|
|
88
|
-
"""Load JSON file. A corrupt file is a hard error when required=True:
|
|
89
|
-
silently continuing would let an empty dict overwrite the real index."""
|
|
46
|
+
def load_json(path, default=None):
|
|
47
|
+
"""Safely load JSON file"""
|
|
90
48
|
if os.path.isfile(path):
|
|
91
49
|
try:
|
|
92
50
|
with open(path, 'r', encoding='utf-8') as f:
|
|
93
51
|
return json.load(f)
|
|
94
|
-
except
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
shutil.copy2(path, backup)
|
|
98
|
-
except OSError:
|
|
99
|
-
backup = '(backup failed)'
|
|
100
|
-
fail('corrupt JSON {}: {} (backed up to {})'.format(
|
|
101
|
-
os.path.basename(path), e, backup))
|
|
102
|
-
if required:
|
|
103
|
-
print('Aborting: refusing to rebuild on top of a corrupt index.')
|
|
104
|
-
print('Fix or delete the file, then run: python git_sync.py --full')
|
|
105
|
-
release_lock()
|
|
106
|
-
sys.exit(1)
|
|
107
|
-
return default if default is not None else {}
|
|
52
|
+
except:
|
|
53
|
+
pass
|
|
54
|
+
return default or {}
|
|
108
55
|
|
|
109
56
|
|
|
110
57
|
def save_json(path, data):
|
|
111
|
-
"""
|
|
58
|
+
"""Save JSON file"""
|
|
112
59
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
json.dump(data, f, indent=2, ensure_ascii=False, sort_keys=True)
|
|
116
|
-
os.replace(tmp, path)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def file_md5(path):
|
|
120
|
-
h = hashlib.md5()
|
|
121
|
-
with open(path, 'rb') as f:
|
|
122
|
-
for chunk in iter(lambda: f.read(65536), b''):
|
|
123
|
-
h.update(chunk)
|
|
124
|
-
return h.hexdigest()
|
|
60
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
61
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
125
62
|
|
|
126
63
|
|
|
127
64
|
def load_config():
|
|
@@ -140,50 +77,40 @@ def load_config():
|
|
|
140
77
|
# Part 1: Git Sync (Code Pull)
|
|
141
78
|
# ============================================================
|
|
142
79
|
|
|
143
|
-
def git_pull_project(project_name
|
|
144
|
-
"""Git pull a single project
|
|
145
|
-
|
|
146
|
-
Returns: list of changed files ([] = no change), or None on failure.
|
|
147
|
-
"""
|
|
80
|
+
def git_pull_project(project_name):
|
|
81
|
+
"""Git pull a single project, return list of changed files"""
|
|
148
82
|
project_dir = os.path.join(CODE_DIR, project_name)
|
|
149
83
|
if not os.path.isdir(os.path.join(project_dir, '.git')):
|
|
150
84
|
print(f' {project_name}: Not a git repo, skipping')
|
|
151
85
|
return []
|
|
152
86
|
|
|
153
|
-
def git(*args):
|
|
154
|
-
return subprocess.run(['git'] + list(args), cwd=project_dir,
|
|
155
|
-
capture_output=True, text=True, encoding='utf-8',
|
|
156
|
-
errors='replace')
|
|
157
|
-
|
|
158
|
-
# Verify we are on the configured branch (config.yaml git_sync.projects)
|
|
159
|
-
result = git('rev-parse', '--abbrev-ref', 'HEAD')
|
|
160
|
-
current_branch = result.stdout.strip() if result.returncode == 0 else ''
|
|
161
|
-
if branch and current_branch != branch:
|
|
162
|
-
fail(f'{project_name}: on branch "{current_branch}", expected "{branch}". '
|
|
163
|
-
f'Checkout the right branch manually, then re-run.')
|
|
164
|
-
return None
|
|
165
|
-
|
|
166
87
|
# Get current commit before pull
|
|
167
|
-
result =
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
old_commit = result.stdout.strip()[:8]
|
|
88
|
+
result = subprocess.run(
|
|
89
|
+
['git', 'rev-parse', 'HEAD'],
|
|
90
|
+
cwd=project_dir, capture_output=True, text=True, encoding='utf-8'
|
|
91
|
+
)
|
|
92
|
+
old_commit = result.stdout.strip()[:8] if result.returncode == 0 else ''
|
|
172
93
|
|
|
94
|
+
# Fetch + pull
|
|
173
95
|
print(f' {project_name}: Fetching...')
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
fail(f'{project_name}: fetch failed - {result.stderr.strip()[:120]}')
|
|
177
|
-
return None
|
|
96
|
+
subprocess.run(['git', 'fetch', 'origin'],
|
|
97
|
+
cwd=project_dir, capture_output=True)
|
|
178
98
|
|
|
179
99
|
print(f' {project_name}: Pulling...')
|
|
180
|
-
|
|
181
|
-
|
|
100
|
+
result = subprocess.run(
|
|
101
|
+
['git', 'pull', 'origin'],
|
|
102
|
+
cwd=project_dir, capture_output=True, text=True, encoding='utf-8'
|
|
103
|
+
)
|
|
104
|
+
|
|
182
105
|
if result.returncode != 0:
|
|
183
|
-
|
|
184
|
-
return
|
|
106
|
+
print(f' {project_name}: Pull failed - {result.stderr[:100]}')
|
|
107
|
+
return []
|
|
185
108
|
|
|
186
|
-
|
|
109
|
+
# Get new commit
|
|
110
|
+
result = subprocess.run(
|
|
111
|
+
['git', 'rev-parse', 'HEAD'],
|
|
112
|
+
cwd=project_dir, capture_output=True, text=True, encoding='utf-8'
|
|
113
|
+
)
|
|
187
114
|
new_commit = result.stdout.strip()[:8] if result.returncode == 0 else ''
|
|
188
115
|
|
|
189
116
|
if old_commit == new_commit:
|
|
@@ -191,30 +118,24 @@ def git_pull_project(project_name, branch=None):
|
|
|
191
118
|
return []
|
|
192
119
|
|
|
193
120
|
# Get changed files
|
|
194
|
-
result =
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
121
|
+
result = subprocess.run(
|
|
122
|
+
['git', 'diff', '--name-only', old_commit, new_commit],
|
|
123
|
+
cwd=project_dir, capture_output=True, text=True, encoding='utf-8'
|
|
124
|
+
)
|
|
198
125
|
changed = [f.strip() for f in result.stdout.strip().split('\n') if f.strip()]
|
|
199
126
|
print(f' {project_name}: {old_commit} -> {new_commit}, {len(changed)} files changed')
|
|
200
127
|
return changed
|
|
201
128
|
|
|
202
129
|
|
|
203
|
-
def git_sync_all(project_filter=None
|
|
130
|
+
def git_sync_all(project_filter=None):
|
|
204
131
|
"""Sync all projects"""
|
|
205
132
|
print('\n=== Git Sync ===')
|
|
206
133
|
print(f'Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\n')
|
|
207
134
|
|
|
208
135
|
if not os.path.exists(CODE_DIR):
|
|
209
|
-
|
|
136
|
+
print('data/code/ not found')
|
|
210
137
|
return {}
|
|
211
138
|
|
|
212
|
-
branch_cfg = {}
|
|
213
|
-
if config:
|
|
214
|
-
for name, proj in (config.get('git_sync', {}).get('projects', {}) or {}).items():
|
|
215
|
-
if isinstance(proj, dict) and proj.get('branch'):
|
|
216
|
-
branch_cfg[name] = proj['branch']
|
|
217
|
-
|
|
218
139
|
changed_map = {}
|
|
219
140
|
|
|
220
141
|
for project_name in sorted(os.listdir(CODE_DIR)):
|
|
@@ -223,7 +144,7 @@ def git_sync_all(project_filter=None, config=None):
|
|
|
223
144
|
if project_filter and project_name != project_filter:
|
|
224
145
|
continue
|
|
225
146
|
|
|
226
|
-
changed = git_pull_project(project_name
|
|
147
|
+
changed = git_pull_project(project_name)
|
|
227
148
|
if changed:
|
|
228
149
|
changed_map[project_name] = changed
|
|
229
150
|
|
|
@@ -241,13 +162,7 @@ def git_sync_all(project_filter=None, config=None):
|
|
|
241
162
|
# ============================================================
|
|
242
163
|
|
|
243
164
|
def collect_prds():
|
|
244
|
-
"""Collect PRDs from all user workspaces
|
|
245
|
-
|
|
246
|
-
Uses content hash (not mtime) to decide updates - mtime is not preserved
|
|
247
|
-
by git, so a fresh clone would otherwise re-collect everything.
|
|
248
|
-
Same filename from two different users is a collision and is skipped
|
|
249
|
-
with a warning instead of silently overwriting.
|
|
250
|
-
"""
|
|
165
|
+
"""Collect PRDs from all user workspaces"""
|
|
251
166
|
print('\n=== Collecting PRDs ===\n')
|
|
252
167
|
|
|
253
168
|
if not os.path.isdir(WORKSPACE):
|
|
@@ -270,34 +185,32 @@ def collect_prds():
|
|
|
270
185
|
if not os.path.isdir(drafts_dir):
|
|
271
186
|
continue
|
|
272
187
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
is_new
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
'
|
|
294
|
-
'
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
else:
|
|
300
|
-
total_updated += 1
|
|
188
|
+
user_prds = []
|
|
189
|
+
for f in os.listdir(drafts_dir):
|
|
190
|
+
if (f.startswith('REQ-') or f.startswith('PRD-') or f.startswith('prd-')) and f.endswith('.md'):
|
|
191
|
+
filepath = os.path.join(drafts_dir, f)
|
|
192
|
+
mtime = os.path.getmtime(filepath)
|
|
193
|
+
user_prds.append({'file': f, 'path': filepath, 'user': user_name, 'mtime': mtime})
|
|
194
|
+
|
|
195
|
+
for prd in user_prds:
|
|
196
|
+
key = prd['file']
|
|
197
|
+
is_new = key not in collected
|
|
198
|
+
is_updated = not is_new and collected[key].get('mtime', 0) < prd['mtime']
|
|
199
|
+
|
|
200
|
+
if is_new or is_updated:
|
|
201
|
+
dst = os.path.join(PRD_DIR, prd['file'])
|
|
202
|
+
shutil.copy2(prd['path'], dst)
|
|
203
|
+
collected[key] = {
|
|
204
|
+
'user': prd['user'],
|
|
205
|
+
'mtime': prd['mtime'],
|
|
206
|
+
'collected_at': datetime.now().strftime('%Y-%m-%d %H:%M')
|
|
207
|
+
}
|
|
208
|
+
status = 'NEW' if is_new else 'UPDATED'
|
|
209
|
+
print(f' {status}: {prd["file"]} (by {prd["user"]})')
|
|
210
|
+
if is_new:
|
|
211
|
+
total_new += 1
|
|
212
|
+
else:
|
|
213
|
+
total_updated += 1
|
|
301
214
|
|
|
302
215
|
save_json(track_file, collected)
|
|
303
216
|
|
|
@@ -311,9 +224,20 @@ def collect_prds():
|
|
|
311
224
|
# ============================================================
|
|
312
225
|
|
|
313
226
|
def extract_chinese_terms(text):
|
|
314
|
-
"""Extract meaningful business terms
|
|
227
|
+
"""Extract meaningful business terms"""
|
|
228
|
+
stop = {
|
|
229
|
+
'需要', '可以', '使用', '进行', '功能', '实现', '显示', '支持', '包括', '或者',
|
|
230
|
+
'以及', '如果', '那么', '但是', '因为', '所以', '用户', '系统', '页面', '数据',
|
|
231
|
+
'信息', '操作', '管理', '列表', '详情', '新增', '修改', '删除', '查询', '搜索',
|
|
232
|
+
'筛选', '点击', '选择', '输入', '确认', '取消', '保存', '提交', '返回', '跳转',
|
|
233
|
+
'当前', '前端', '后端', '影响', '范围', '下拉', '条件', '字段', '全部', '异常',
|
|
234
|
+
'正常', '记录', '面板', '统计', '表页', '搜索表', '与其他', '表单', '背景',
|
|
235
|
+
'一个', '这个', '那个', '通过', '根据', '按照', '以及', '同时', '并且',
|
|
236
|
+
'要求', '需求', '描述', '说明', '备注', '注意', '重要', '优先', '级别',
|
|
237
|
+
'方案', '设计', '开发', '测试', '上线', '版本', '迭代', '更新', '发布',
|
|
238
|
+
}
|
|
315
239
|
raw = re.findall(r'[一-鿿]{2,4}', text)
|
|
316
|
-
return list(set([t for t in raw if t not in
|
|
240
|
+
return list(set([t for t in raw if t not in stop and len(t) >= 2]))[:30]
|
|
317
241
|
|
|
318
242
|
|
|
319
243
|
def parse_prd_file(filepath):
|
|
@@ -345,14 +269,21 @@ def parse_prd_file(filepath):
|
|
|
345
269
|
rules.extend(re.findall(p, text))
|
|
346
270
|
rules = [r.strip()[:200] for r in rules][:15]
|
|
347
271
|
|
|
348
|
-
# Extract keywords
|
|
272
|
+
# Extract keywords
|
|
349
273
|
cn_terms = extract_chinese_terms(text)
|
|
274
|
+
cn_to_en = {
|
|
275
|
+
'保险': 'insurance', '考勤': 'attendance', '薪资': 'salary',
|
|
276
|
+
'车辆': 'vehicle', '设备': 'equipment', '资产': 'asset',
|
|
277
|
+
'维修': 'maintain', '品质': 'quality', '巡查': 'inspection',
|
|
278
|
+
'排班': 'schedule', '请假': 'leave', '加班': 'overtime',
|
|
279
|
+
'审批': 'approval', '监控': 'monitor', '告警': 'alarm',
|
|
280
|
+
}
|
|
350
281
|
|
|
351
282
|
keywords = set()
|
|
352
283
|
for term in cn_terms:
|
|
353
284
|
keywords.add(term)
|
|
354
|
-
if term in
|
|
355
|
-
keywords.add(
|
|
285
|
+
if term in cn_to_en:
|
|
286
|
+
keywords.add(cn_to_en[term])
|
|
356
287
|
|
|
357
288
|
return {
|
|
358
289
|
'file': filename,
|
|
@@ -366,23 +297,17 @@ def parse_prd_file(filepath):
|
|
|
366
297
|
}
|
|
367
298
|
|
|
368
299
|
|
|
369
|
-
def build_prd_index(
|
|
370
|
-
"""Build PRD index from all PRD files
|
|
371
|
-
|
|
372
|
-
Args:
|
|
373
|
-
keyword_index: 可选预加载的 keyword-index (性能优化 A3: 避免重复加载 4.5MB JSON)。
|
|
374
|
-
None 则自行加载。
|
|
375
|
-
"""
|
|
300
|
+
def build_prd_index():
|
|
301
|
+
"""Build PRD index from all PRD files"""
|
|
376
302
|
print('\n=== Building PRD Index ===\n')
|
|
377
303
|
|
|
378
304
|
if not os.path.isdir(PRD_DIR):
|
|
379
305
|
print('PRD directory not found')
|
|
380
306
|
return {}
|
|
381
307
|
|
|
382
|
-
# Load keyword index for code matching
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
keyword_index = load_json(ki_path)
|
|
308
|
+
# Load keyword index for code matching
|
|
309
|
+
ki_path = os.path.join(INDEX_DIR, 'keyword-index.json')
|
|
310
|
+
keyword_index = load_json(ki_path)
|
|
386
311
|
|
|
387
312
|
prd_index = {}
|
|
388
313
|
|
|
@@ -486,53 +411,24 @@ def parse_frontend_file(filepath):
|
|
|
486
411
|
return entities
|
|
487
412
|
|
|
488
413
|
|
|
489
|
-
def
|
|
490
|
-
"""
|
|
491
|
-
|
|
492
|
-
用于 remove_file_from_indexes 的 O(1) 查找, 替代 O(all_keys) 全扫描。
|
|
493
|
-
每次全量/增量构建后调用一次, 持久化到 .file-keys.json。
|
|
494
|
-
"""
|
|
495
|
-
fkm = {}
|
|
496
|
-
for kw, files in keyword_index.items():
|
|
497
|
-
for f in files:
|
|
498
|
-
fkm.setdefault(f, set()).add(kw)
|
|
499
|
-
return fkm
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
def remove_file_from_indexes(filepath, keyword_index, api_index, file_keys_map=None):
|
|
503
|
-
"""Remove a file's entries from keyword/api indexes.
|
|
504
|
-
|
|
505
|
-
module-map.json stores per-project COUNTS (not file lists) and is
|
|
506
|
-
recomputed by rebuild_module_summary() after each update.
|
|
507
|
-
|
|
508
|
-
Args:
|
|
509
|
-
file_keys_map: 可选反向索引 {file: set(keywords)} (A5)。
|
|
510
|
-
有则 O(1) 查找涉及的 keys; None 则回退到 O(all_keys) 全扫描。
|
|
511
|
-
"""
|
|
414
|
+
def remove_file_from_indexes(filepath, module_map, keyword_index, api_index):
|
|
415
|
+
"""Remove a file's entries from all indexes"""
|
|
512
416
|
rel = filepath.replace(os.sep, '/')
|
|
513
417
|
|
|
514
|
-
#
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
to_remove = []
|
|
529
|
-
for kw, files in keyword_index.items():
|
|
530
|
-
if rel in files:
|
|
531
|
-
files.remove(rel)
|
|
532
|
-
if not files:
|
|
533
|
-
to_remove.append(kw)
|
|
534
|
-
for kw in to_remove:
|
|
535
|
-
del keyword_index[kw]
|
|
418
|
+
# Remove from module_map
|
|
419
|
+
for proj in module_map:
|
|
420
|
+
if rel in module_map[proj].get('files', []):
|
|
421
|
+
module_map[proj]['files'].remove(rel)
|
|
422
|
+
|
|
423
|
+
# Remove from keyword_index
|
|
424
|
+
to_remove = []
|
|
425
|
+
for kw, files in keyword_index.items():
|
|
426
|
+
if rel in files:
|
|
427
|
+
files.remove(rel)
|
|
428
|
+
if not files:
|
|
429
|
+
to_remove.append(kw)
|
|
430
|
+
for kw in to_remove:
|
|
431
|
+
del keyword_index[kw]
|
|
536
432
|
|
|
537
433
|
# Remove from api_index
|
|
538
434
|
to_remove_api = [api for api, f in api_index.items() if f == rel]
|
|
@@ -540,123 +436,15 @@ def remove_file_from_indexes(filepath, keyword_index, api_index, file_keys_map=N
|
|
|
540
436
|
del api_index[api]
|
|
541
437
|
|
|
542
438
|
|
|
543
|
-
INDEXED_EXTS_JAVA = ('.java',)
|
|
544
|
-
INDEXED_EXTS_FRONTEND = ('.vue', '.js', '.ts', '.jsx', '.tsx')
|
|
545
|
-
INDEXED_EXTS_CONFIG = ('.xml', '.yml', '.yaml', '.properties')
|
|
546
|
-
SKIP_DIRS = ['node_modules', 'target', 'build', 'dist', '__pycache__']
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
def rebuild_module_summary(api_index, file_stats=None):
|
|
550
|
-
"""Recompute module-map.json counts.
|
|
551
|
-
|
|
552
|
-
Schema (counts only, matching what's committed):
|
|
553
|
-
{project: {files: int, classes: int, apis: int, components: int}}
|
|
554
|
-
|
|
555
|
-
Args:
|
|
556
|
-
api_index: endpoint→file map.
|
|
557
|
-
file_stats: 可选的预统计 {project: {files, classes, components}},
|
|
558
|
-
避免二次全盘 walk (性能优化 A3)。None 则回退到 walk。
|
|
559
|
-
"""
|
|
560
|
-
if file_stats:
|
|
561
|
-
# 快速路径: 复用 build_full_indexes 已有的统计, 不再 walk
|
|
562
|
-
module_map = {}
|
|
563
|
-
for project_name, st in file_stats.items():
|
|
564
|
-
prefix = project_name + '/'
|
|
565
|
-
module_map[project_name] = {
|
|
566
|
-
'files': st.get('files', 0),
|
|
567
|
-
'classes': st.get('classes', 0),
|
|
568
|
-
'components': st.get('components', 0),
|
|
569
|
-
'apis': sum(1 for f in api_index.values() if f.startswith(prefix)),
|
|
570
|
-
}
|
|
571
|
-
return module_map
|
|
572
|
-
|
|
573
|
-
# 回退路径: walk 统计 (旧逻辑, 兼容)
|
|
574
|
-
module_map = {}
|
|
575
|
-
if not os.path.isdir(CODE_DIR):
|
|
576
|
-
return module_map
|
|
577
|
-
for project_name in sorted(os.listdir(CODE_DIR)):
|
|
578
|
-
project_dir = os.path.join(CODE_DIR, project_name)
|
|
579
|
-
if not os.path.isdir(project_dir):
|
|
580
|
-
continue
|
|
581
|
-
total = java = fe = 0
|
|
582
|
-
for root, dirs, files in os.walk(project_dir):
|
|
583
|
-
dirs[:] = [d for d in dirs if not d.startswith('.') and d not in SKIP_DIRS]
|
|
584
|
-
for f in files:
|
|
585
|
-
ext = os.path.splitext(f)[1]
|
|
586
|
-
if ext in INDEXED_EXTS_JAVA:
|
|
587
|
-
java += 1
|
|
588
|
-
total += 1
|
|
589
|
-
elif ext in INDEXED_EXTS_FRONTEND:
|
|
590
|
-
fe += 1
|
|
591
|
-
total += 1
|
|
592
|
-
elif ext in INDEXED_EXTS_CONFIG:
|
|
593
|
-
total += 1
|
|
594
|
-
prefix = project_name + '/'
|
|
595
|
-
module_map[project_name] = {
|
|
596
|
-
'files': total,
|
|
597
|
-
'classes': java,
|
|
598
|
-
'components': fe,
|
|
599
|
-
'apis': sum(1 for f in api_index.values() if f.startswith(prefix)),
|
|
600
|
-
}
|
|
601
|
-
return module_map
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
def normalize_keyword_index(keyword_index):
|
|
605
|
-
"""Dedupe and sort file lists so output is deterministic (diff-friendly)."""
|
|
606
|
-
for kw in keyword_index:
|
|
607
|
-
keyword_index[kw] = sorted(set(keyword_index[kw]))
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
def index_one_file(filepath, rel, ext, keyword_index, api_index):
|
|
611
|
-
"""Parse a single source file into the keyword/api indexes.
|
|
612
|
-
|
|
613
|
-
Shared by incremental and full build so both produce identical entries.
|
|
614
|
-
Returns True if the file was indexed.
|
|
615
|
-
"""
|
|
616
|
-
if ext in INDEXED_EXTS_JAVA:
|
|
617
|
-
for e in parse_java_file(filepath):
|
|
618
|
-
for api in e.get('apis', []):
|
|
619
|
-
api_index[api] = rel
|
|
620
|
-
for kw in e.get('keywords', []):
|
|
621
|
-
kl = kw.lower()
|
|
622
|
-
if len(kl) >= 2:
|
|
623
|
-
keyword_index.setdefault(kl, [])
|
|
624
|
-
if rel not in keyword_index[kl]:
|
|
625
|
-
keyword_index[kl].append(rel)
|
|
626
|
-
return True
|
|
627
|
-
if ext in INDEXED_EXTS_FRONTEND:
|
|
628
|
-
for e in parse_frontend_file(filepath):
|
|
629
|
-
for api in e.get('api_calls', []):
|
|
630
|
-
key = 'api:' + api
|
|
631
|
-
keyword_index.setdefault(key, [])
|
|
632
|
-
if rel not in keyword_index[key]:
|
|
633
|
-
keyword_index[key].append(rel)
|
|
634
|
-
comp = e.get('component', '')
|
|
635
|
-
if comp:
|
|
636
|
-
cl = comp.lower()
|
|
637
|
-
keyword_index.setdefault(cl, [])
|
|
638
|
-
if rel not in keyword_index[cl]:
|
|
639
|
-
keyword_index[cl].append(rel)
|
|
640
|
-
return True
|
|
641
|
-
return False
|
|
642
|
-
|
|
643
|
-
|
|
644
439
|
def update_indexes_incremental(changed_projects):
|
|
645
440
|
"""Update indexes incrementally for changed projects only"""
|
|
646
441
|
print('\n=== Incremental Index Update ===\n')
|
|
647
442
|
|
|
648
443
|
os.makedirs(INDEX_DIR, exist_ok=True)
|
|
649
444
|
|
|
650
|
-
|
|
651
|
-
keyword_index = load_json(os.path.join(INDEX_DIR, 'keyword-index.json')
|
|
652
|
-
api_index = load_json(os.path.join(INDEX_DIR, 'api-index.json')
|
|
653
|
-
|
|
654
|
-
# 性能优化 A5: 加载反向索引 (若存在) 加速 remove_file_from_indexes
|
|
655
|
-
fkm_path = os.path.join(INDEX_DIR, '.file-keys.json')
|
|
656
|
-
file_keys_map = load_json(fkm_path) if os.path.isfile(fkm_path) else None
|
|
657
|
-
if file_keys_map:
|
|
658
|
-
# 转 set 形式 (内存里操作用 set, 持久化时转 list)
|
|
659
|
-
file_keys_map = {f: set(ks) for f, ks in file_keys_map.items()}
|
|
445
|
+
module_map = load_json(os.path.join(INDEX_DIR, 'module-map.json'))
|
|
446
|
+
keyword_index = load_json(os.path.join(INDEX_DIR, 'keyword-index.json'))
|
|
447
|
+
api_index = load_json(os.path.join(INDEX_DIR, 'api-index.json'))
|
|
660
448
|
|
|
661
449
|
total_files = 0
|
|
662
450
|
|
|
@@ -665,37 +453,61 @@ def update_indexes_incremental(changed_projects):
|
|
|
665
453
|
if not os.path.isdir(project_dir):
|
|
666
454
|
continue
|
|
667
455
|
|
|
456
|
+
# Init project if needed
|
|
457
|
+
if project_name not in module_map:
|
|
458
|
+
module_map[project_name] = {'files': [], 'classes': 0, 'apis': 0}
|
|
459
|
+
|
|
668
460
|
print(f' [{project_name}] Updating {len(changed_files)} files...')
|
|
669
461
|
|
|
670
462
|
for changed_file in changed_files:
|
|
671
463
|
filepath = os.path.join(project_dir, changed_file)
|
|
672
|
-
rel = (
|
|
464
|
+
rel = os.path.relpath(filepath, CODE_DIR).replace(os.sep, '/')
|
|
673
465
|
|
|
674
|
-
# Remove old entries
|
|
675
|
-
remove_file_from_indexes(rel, keyword_index, api_index
|
|
466
|
+
# Remove old entries
|
|
467
|
+
remove_file_from_indexes(rel, module_map, keyword_index, api_index)
|
|
676
468
|
|
|
677
469
|
# Skip if file deleted
|
|
678
470
|
if not os.path.isfile(filepath):
|
|
679
471
|
continue
|
|
680
472
|
|
|
473
|
+
# Parse and add new entries
|
|
681
474
|
ext = os.path.splitext(changed_file)[1]
|
|
682
|
-
|
|
475
|
+
|
|
476
|
+
if ext == '.java':
|
|
477
|
+
entities = parse_java_file(filepath)
|
|
478
|
+
for e in entities:
|
|
479
|
+
module_map[project_name]['files'].append(rel)
|
|
480
|
+
for api in e.get('apis', []):
|
|
481
|
+
api_index[api] = rel
|
|
482
|
+
for kw in e.get('keywords', []):
|
|
483
|
+
kl = kw.lower()
|
|
484
|
+
if len(kl) >= 2:
|
|
485
|
+
keyword_index.setdefault(kl, [])
|
|
486
|
+
if rel not in keyword_index[kl]:
|
|
487
|
+
keyword_index[kl].append(rel)
|
|
683
488
|
total_files += 1
|
|
684
|
-
# 同步更新反向索引
|
|
685
|
-
if file_keys_map is not None:
|
|
686
|
-
file_keys_map.setdefault(rel, set())
|
|
687
489
|
|
|
688
|
-
|
|
490
|
+
elif ext in ('.vue', '.js', '.ts', '.jsx', '.tsx'):
|
|
491
|
+
entities = parse_frontend_file(filepath)
|
|
492
|
+
for e in entities:
|
|
493
|
+
module_map[project_name]['files'].append(rel)
|
|
494
|
+
for api in e.get('api_calls', []):
|
|
495
|
+
keyword_index.setdefault('api:' + api, [])
|
|
496
|
+
keyword_index['api:' + api].append(rel)
|
|
497
|
+
comp = e.get('component', '')
|
|
498
|
+
if comp:
|
|
499
|
+
keyword_index.setdefault(comp.lower(), [])
|
|
500
|
+
keyword_index[comp.lower()].append(rel)
|
|
501
|
+
total_files += 1
|
|
689
502
|
|
|
690
|
-
#
|
|
691
|
-
|
|
692
|
-
|
|
503
|
+
# Deduplicate
|
|
504
|
+
for proj in module_map:
|
|
505
|
+
module_map[proj]['files'] = list(set(module_map[proj]['files']))
|
|
693
506
|
|
|
507
|
+
# Save
|
|
694
508
|
save_json(os.path.join(INDEX_DIR, 'module-map.json'), module_map)
|
|
695
509
|
save_json(os.path.join(INDEX_DIR, 'keyword-index.json'), keyword_index)
|
|
696
510
|
save_json(os.path.join(INDEX_DIR, 'api-index.json'), api_index)
|
|
697
|
-
save_json(os.path.join(INDEX_DIR, '.file-keys.json'),
|
|
698
|
-
{f: sorted(ks) for f, ks in fkm_new.items()})
|
|
699
511
|
|
|
700
512
|
print(f'\n Updated: {total_files} files indexed')
|
|
701
513
|
print(f' Keywords: {len(keyword_index)}')
|
|
@@ -704,130 +516,73 @@ def update_indexes_incremental(changed_projects):
|
|
|
704
516
|
return total_files
|
|
705
517
|
|
|
706
518
|
|
|
707
|
-
def _index_project(project_name, project_dir, skip_dirs):
|
|
708
|
-
"""索引单个项目的所有文件 (并行 worker 函数, A4)。
|
|
709
|
-
|
|
710
|
-
在子进程里运行: walk + parse, 返回该项目的 (keyword_dict, api_dict, stats)。
|
|
711
|
-
保持与串行版完全一致的解析逻辑 (复用 index_one_file 的核心)。
|
|
712
|
-
"""
|
|
713
|
-
local_ki = {}
|
|
714
|
-
local_api = {}
|
|
715
|
-
proj_count = proj_java = proj_fe = 0
|
|
716
|
-
|
|
717
|
-
for root, dirs, files in os.walk(project_dir):
|
|
718
|
-
dirs[:] = [d for d in dirs if not d.startswith('.') and d not in skip_dirs]
|
|
719
|
-
for f in sorted(files):
|
|
720
|
-
ext = os.path.splitext(f)[1]
|
|
721
|
-
filepath = os.path.join(root, f)
|
|
722
|
-
rel = os.path.relpath(filepath, os.path.dirname(project_dir)).replace(os.sep, '/')
|
|
723
|
-
if ext in INDEXED_EXTS_JAVA:
|
|
724
|
-
for e in parse_java_file(filepath):
|
|
725
|
-
for api in e.get('apis', []):
|
|
726
|
-
local_api[api] = rel
|
|
727
|
-
for kw in e.get('keywords', []):
|
|
728
|
-
kl = kw.lower()
|
|
729
|
-
if len(kl) >= 2:
|
|
730
|
-
local_ki.setdefault(kl, [])
|
|
731
|
-
if rel not in local_ki[kl]:
|
|
732
|
-
local_ki[kl].append(rel)
|
|
733
|
-
proj_count += 1
|
|
734
|
-
proj_java += 1
|
|
735
|
-
elif ext in INDEXED_EXTS_FRONTEND:
|
|
736
|
-
for e in parse_frontend_file(filepath):
|
|
737
|
-
for api in e.get('api_calls', []):
|
|
738
|
-
key = 'api:' + api
|
|
739
|
-
local_ki.setdefault(key, [])
|
|
740
|
-
if rel not in local_ki[key]:
|
|
741
|
-
local_ki[key].append(rel)
|
|
742
|
-
comp = e.get('component', '')
|
|
743
|
-
if comp:
|
|
744
|
-
cl = comp.lower()
|
|
745
|
-
local_ki.setdefault(cl, [])
|
|
746
|
-
if rel not in local_ki[cl]:
|
|
747
|
-
local_ki[cl].append(rel)
|
|
748
|
-
proj_count += 1
|
|
749
|
-
proj_fe += 1
|
|
750
|
-
elif ext in INDEXED_EXTS_CONFIG:
|
|
751
|
-
proj_count += 1
|
|
752
|
-
|
|
753
|
-
stats = {'files': proj_count, 'classes': proj_java, 'components': proj_fe}
|
|
754
|
-
return project_name, local_ki, local_api, stats
|
|
755
|
-
|
|
756
|
-
|
|
757
519
|
def build_full_indexes():
|
|
758
520
|
"""Build full indexes from scratch"""
|
|
759
521
|
print('\n=== Full Index Build ===\n')
|
|
760
522
|
|
|
761
|
-
if not os.path.isdir(CODE_DIR):
|
|
762
|
-
fail('data/code/ not found - cannot build indexes')
|
|
763
|
-
return 0
|
|
764
|
-
|
|
765
523
|
os.makedirs(INDEX_DIR, exist_ok=True)
|
|
766
524
|
|
|
767
|
-
|
|
768
|
-
projects = []
|
|
769
|
-
for project_name in sorted(os.listdir(CODE_DIR)):
|
|
770
|
-
project_dir = os.path.join(CODE_DIR, project_name)
|
|
771
|
-
if os.path.isdir(project_dir):
|
|
772
|
-
projects.append((project_name, project_dir))
|
|
773
|
-
|
|
525
|
+
module_map = {}
|
|
774
526
|
keyword_index = {}
|
|
775
527
|
api_index = {}
|
|
776
528
|
total_files = 0
|
|
777
|
-
file_stats = {}
|
|
778
529
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
530
|
+
for project_name in sorted(os.listdir(CODE_DIR)):
|
|
531
|
+
project_dir = os.path.join(CODE_DIR, project_name)
|
|
532
|
+
if not os.path.isdir(project_dir):
|
|
533
|
+
continue
|
|
534
|
+
|
|
535
|
+
module_map[project_name] = {'files': [], 'classes': 0, 'apis': 0}
|
|
536
|
+
print(f' [{project_name}] Scanning...')
|
|
537
|
+
|
|
538
|
+
for root, dirs, files in os.walk(project_dir):
|
|
539
|
+
dirs[:] = [d for d in dirs if not d.startswith('.') and d not in
|
|
540
|
+
['node_modules', 'target', 'build', 'dist', '__pycache__']]
|
|
541
|
+
|
|
542
|
+
for f in files:
|
|
543
|
+
ext = os.path.splitext(f)[1]
|
|
544
|
+
filepath = os.path.join(root, f)
|
|
545
|
+
rel = os.path.relpath(filepath, CODE_DIR).replace(os.sep, '/')
|
|
546
|
+
|
|
547
|
+
if ext == '.java':
|
|
548
|
+
entities = parse_java_file(filepath)
|
|
549
|
+
for e in entities:
|
|
550
|
+
module_map[project_name]['files'].append(rel)
|
|
551
|
+
module_map[project_name]['classes'] += 1
|
|
552
|
+
for api in e.get('apis', []):
|
|
553
|
+
api_index[api] = rel
|
|
554
|
+
module_map[project_name]['apis'] += 1
|
|
555
|
+
for kw in e.get('keywords', []):
|
|
556
|
+
kl = kw.lower()
|
|
557
|
+
if len(kl) >= 2:
|
|
558
|
+
keyword_index.setdefault(kl, [])
|
|
559
|
+
if rel not in keyword_index[kl]:
|
|
560
|
+
keyword_index[kl].append(rel)
|
|
561
|
+
total_files += 1
|
|
562
|
+
|
|
563
|
+
elif ext in ('.vue', '.js', '.ts', '.jsx', '.tsx'):
|
|
564
|
+
entities = parse_frontend_file(filepath)
|
|
565
|
+
for e in entities:
|
|
566
|
+
module_map[project_name]['files'].append(rel)
|
|
567
|
+
for api in e.get('api_calls', []):
|
|
568
|
+
keyword_index.setdefault('api:' + api, [])
|
|
569
|
+
keyword_index['api:' + api].append(rel)
|
|
570
|
+
comp = e.get('component', '')
|
|
571
|
+
if comp:
|
|
572
|
+
keyword_index.setdefault(comp.lower(), [])
|
|
573
|
+
keyword_index[comp.lower()].append(rel)
|
|
574
|
+
total_files += 1
|
|
575
|
+
|
|
576
|
+
elif ext in ('.xml', '.yml', '.yaml', '.properties'):
|
|
577
|
+
module_map[project_name]['files'].append(rel)
|
|
578
|
+
total_files += 1
|
|
579
|
+
|
|
580
|
+
print(f' Files: {len(module_map[project_name]["files"])}')
|
|
821
581
|
|
|
822
582
|
save_json(os.path.join(INDEX_DIR, 'module-map.json'), module_map)
|
|
823
583
|
save_json(os.path.join(INDEX_DIR, 'keyword-index.json'), keyword_index)
|
|
824
584
|
save_json(os.path.join(INDEX_DIR, 'api-index.json'), api_index)
|
|
825
585
|
|
|
826
|
-
# 性能优化 A5: 持久化反向索引 {file: [keywords]} 供增量更新 O(1) 查找
|
|
827
|
-
fkm = build_file_keys_map(keyword_index)
|
|
828
|
-
save_json(os.path.join(INDEX_DIR, '.file-keys.json'),
|
|
829
|
-
{f: sorted(ks) for f, ks in fkm.items()})
|
|
830
|
-
|
|
831
586
|
print(f'\n Total: {total_files} files, {len(keyword_index)} keywords, {len(api_index)} APIs')
|
|
832
587
|
return total_files
|
|
833
588
|
|
|
@@ -835,25 +590,49 @@ def build_full_indexes():
|
|
|
835
590
|
# ============================================================
|
|
836
591
|
# Part 5: PRD ↔ Code Mapping
|
|
837
592
|
# ============================================================
|
|
838
|
-
# Business term -> code path mapping comes from common.terms (BUSINESS_PATH_MAP)
|
|
839
|
-
# so that index building and searching share the same semantics.
|
|
840
|
-
|
|
841
|
-
# Below this length, only exact matches count (substring matching on short
|
|
842
|
-
# keywords like "in"/"sa" pollutes the mapping)
|
|
843
|
-
MIN_PATTERN_FUZZY_LEN = 4
|
|
844
|
-
|
|
845
593
|
|
|
846
|
-
def build_prd_code_mapping(keyword_index=None):
|
|
847
|
-
"""Build bidirectional PRD to Code mapping with business term awareness.
|
|
848
594
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
595
|
+
# Business term to code path mapping
|
|
596
|
+
BUSINESS_PATH_MAP = {
|
|
597
|
+
'保险': ['insurance', 'Insurance', 'ins-', '/ins/', 'vehlife/insurance'],
|
|
598
|
+
'考勤': ['attendance', 'Attendance', 'clock', 'Clock', 'time/clock', 'TimeClock'],
|
|
599
|
+
'打卡': ['clock', 'Clock', 'photoClock', 'PhotoClock'],
|
|
600
|
+
'车辆': ['vehicle', 'Vehicle', 'veh', 'vehlife', '/veh/'],
|
|
601
|
+
'品质': ['quality', 'Quality', 'QualityInspection', 'patrol', 'Patrol'],
|
|
602
|
+
'巡查': ['inspection', 'Inspection', 'patrol', 'Patrol'],
|
|
603
|
+
'资产': ['asset', 'Asset', '/asset/'],
|
|
604
|
+
'设备': ['equipment', 'Equipment', 'device', 'Device'],
|
|
605
|
+
'维修': ['maintain', 'Maintain', 'repair', 'Repair', 'maintenance'],
|
|
606
|
+
'排班': ['schedule', 'Schedule', 'shift', 'Shift'],
|
|
607
|
+
'请假': ['leave', 'Leave', 'holiday', 'Holiday'],
|
|
608
|
+
'审批': ['approval', 'Approval', 'audit', 'Audit', 'workflow'],
|
|
609
|
+
'在线率': ['online', 'Online', 'onlineRate', 'OnlineRate'],
|
|
610
|
+
'告警': ['alarm', 'Alarm', 'alert', 'Alert'],
|
|
611
|
+
'油耗': ['fuel', 'Fuel', 'oil', 'Oil', 'oilConsumption'],
|
|
612
|
+
'里程': ['mileage', 'Mileage', 'distance', 'Distance'],
|
|
613
|
+
'薪资': ['salary', 'Salary', 'wage', 'Wage', 'pay', 'Pay'],
|
|
614
|
+
'报表': ['report', 'Report', 'statistics', 'Statistics'],
|
|
615
|
+
'统计': ['statistics', 'Statistics', 'stats', 'Stats'],
|
|
616
|
+
'年检': ['inspection', 'Inspection', 'annual', 'Annual', 'vehInspection'],
|
|
617
|
+
'预算': ['budget', 'Budget'],
|
|
618
|
+
'费用': ['cost', 'Cost', 'expense', 'Expense'],
|
|
619
|
+
'安全': ['safety', 'Safety', 'safe', 'Safe', 'ehs'],
|
|
620
|
+
'监控': ['monitor', 'Monitor', 'video', 'Video'],
|
|
621
|
+
'GPS': ['gps', 'GPS', 'location', 'Location', 'track', 'Track'],
|
|
622
|
+
'合同': ['contract', 'Contract'],
|
|
623
|
+
'组织': ['org', 'Org', 'department', 'Department'],
|
|
624
|
+
'员工': ['employee', 'Employee', 'staff', 'Staff', 'personnel'],
|
|
625
|
+
'权限': ['permission', 'Permission', 'role', 'Role', 'auth'],
|
|
626
|
+
'流程': ['workflow', 'Workflow', 'process', 'Process'],
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
def build_prd_code_mapping():
|
|
630
|
+
"""Build bidirectional PRD to Code mapping with business term awareness"""
|
|
852
631
|
print('\n=== Building PRD to Code Mapping ===\n')
|
|
853
632
|
|
|
854
633
|
prd_index = load_json(os.path.join(INDEX_DIR, 'prd-index.json'))
|
|
855
|
-
|
|
856
|
-
|
|
634
|
+
keyword_index = load_json(os.path.join(INDEX_DIR, 'keyword-index.json'))
|
|
635
|
+
module_map = load_json(os.path.join(INDEX_DIR, 'module-map.json'))
|
|
857
636
|
|
|
858
637
|
mapping = {'prd_to_code': {}, 'code_to_prd': {}}
|
|
859
638
|
|
|
@@ -866,36 +645,39 @@ def build_prd_code_mapping(keyword_index=None):
|
|
|
866
645
|
# Strategy 1: Business term to path pattern matching
|
|
867
646
|
all_text = title + ' ' + ' '.join(cn_terms) + ' ' + ' '.join(keywords)
|
|
868
647
|
for cn_term, en_patterns in BUSINESS_PATH_MAP.items():
|
|
869
|
-
if cn_term
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
) or (
|
|
880
|
-
len(pat_lower) >= MIN_PATTERN_FUZZY_LEN and pat_lower in kw_lower
|
|
881
|
-
):
|
|
882
|
-
related_files.update(files[:5])
|
|
648
|
+
if cn_term in all_text:
|
|
649
|
+
for pattern in en_patterns:
|
|
650
|
+
pat_lower = pattern.lower()
|
|
651
|
+
for kw, files in keyword_index.items():
|
|
652
|
+
kw_lower = kw.lower()
|
|
653
|
+
if pat_lower in kw_lower or kw_lower in pat_lower:
|
|
654
|
+
for f in files[:5]:
|
|
655
|
+
related_files.add(f)
|
|
656
|
+
# Note: module_map stores counts not file lists
|
|
657
|
+
# File paths are searched via keyword_index instead
|
|
883
658
|
|
|
884
659
|
# Strategy 2: Direct keyword matching
|
|
885
660
|
for kw in keywords[:20]:
|
|
886
661
|
kw_lower = kw.lower()
|
|
887
662
|
if kw_lower in keyword_index:
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
663
|
+
for f in keyword_index[kw_lower][:3]:
|
|
664
|
+
related_files.add(f)
|
|
665
|
+
|
|
666
|
+
# Strategy 3: CN to EN translation
|
|
667
|
+
cn_to_en = {
|
|
668
|
+
'保险': 'insurance', '考勤': 'attendance', '薪资': 'salary',
|
|
669
|
+
'车辆': 'vehicle', '设备': 'equipment', '资产': 'asset',
|
|
670
|
+
'维修': 'maintain', '品质': 'quality', '巡查': 'inspection',
|
|
671
|
+
}
|
|
891
672
|
for term in cn_terms:
|
|
892
|
-
if term in
|
|
893
|
-
en =
|
|
673
|
+
if term in cn_to_en:
|
|
674
|
+
en = cn_to_en[term].lower()
|
|
894
675
|
if en in keyword_index:
|
|
895
|
-
|
|
676
|
+
for f in keyword_index[en][:5]:
|
|
677
|
+
related_files.add(f)
|
|
896
678
|
|
|
897
|
-
# Deduplicate
|
|
898
|
-
related_files =
|
|
679
|
+
# Deduplicate and limit
|
|
680
|
+
related_files = list(related_files)[:30]
|
|
899
681
|
|
|
900
682
|
mapping['prd_to_code'][prd_file] = {
|
|
901
683
|
'title': title,
|
|
@@ -922,72 +704,6 @@ def build_prd_code_mapping(keyword_index=None):
|
|
|
922
704
|
return mapping
|
|
923
705
|
|
|
924
706
|
|
|
925
|
-
# ============================================================
|
|
926
|
-
# Part 6: Index Verification (准确性校验)
|
|
927
|
-
# ============================================================
|
|
928
|
-
|
|
929
|
-
# 条目数比上次下降超过这个比例视为异常 (防止静默清空/构建残废)
|
|
930
|
-
MAX_SHRINK_RATIO = 0.3
|
|
931
|
-
# 抽样检查的文件数下限命中率
|
|
932
|
-
MIN_SAMPLE_HIT = 0.8
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
def verify_indexes(config):
|
|
936
|
-
"""Post-build sanity checks. Returns dict written into .index-meta.json.
|
|
937
|
-
|
|
938
|
-
1. 每个配置的项目都在 module-map 里且 files > 0
|
|
939
|
-
2. keyword/api 条目数与上次相比未暴跌 (>30% 下降 = 异常)
|
|
940
|
-
3. 抽样 20 个索引条目, 验证文件真实存在于磁盘
|
|
941
|
-
"""
|
|
942
|
-
print('\n=== Verifying Indexes ===\n')
|
|
943
|
-
result = {'checked_at': datetime.now().strftime('%Y-%m-%d %H:%M'), 'checks': {}}
|
|
944
|
-
|
|
945
|
-
module_map = load_json(os.path.join(INDEX_DIR, 'module-map.json'))
|
|
946
|
-
keyword_index = load_json(os.path.join(INDEX_DIR, 'keyword-index.json'))
|
|
947
|
-
api_index = load_json(os.path.join(INDEX_DIR, 'api-index.json'))
|
|
948
|
-
|
|
949
|
-
# Check 1: 配置的项目全部被索引
|
|
950
|
-
configured = list(((config.get('git_sync', {}) or {}).get('projects', {}) or {}).keys())
|
|
951
|
-
for proj in configured:
|
|
952
|
-
info = module_map.get(proj)
|
|
953
|
-
files = info.get('files', 0) if isinstance(info, dict) else 0
|
|
954
|
-
if not files:
|
|
955
|
-
fail(f'verify: 项目 {proj} 不在索引中或 files=0')
|
|
956
|
-
else:
|
|
957
|
-
print(f' [OK] {proj}: {files} files indexed')
|
|
958
|
-
result['checks']['projects'] = {p: (module_map.get(p) or {}).get('files', 0) for p in configured}
|
|
959
|
-
|
|
960
|
-
# Check 2: 条目数突变检测 (与上次 meta 对比)
|
|
961
|
-
prev_meta = load_json(os.path.join(INDEX_DIR, '.index-meta.json'))
|
|
962
|
-
prev_counts = prev_meta.get('counts', {})
|
|
963
|
-
counts = {'keywords': len(keyword_index), 'apis': len(api_index)}
|
|
964
|
-
for name, now in counts.items():
|
|
965
|
-
prev = prev_counts.get(name, 0)
|
|
966
|
-
if prev > 50 and now < prev * (1 - MAX_SHRINK_RATIO):
|
|
967
|
-
fail(f'verify: {name} 条目数从 {prev} 暴跌到 {now} (>30%), 索引可能损坏')
|
|
968
|
-
else:
|
|
969
|
-
print(f' [OK] {name}: {now} (prev {prev})')
|
|
970
|
-
result['checks']['counts'] = counts
|
|
971
|
-
|
|
972
|
-
# Check 3: 抽样验证索引指向的文件真实存在
|
|
973
|
-
sample, step = [], max(1, len(keyword_index) // 20)
|
|
974
|
-
for i, (kw, files) in enumerate(sorted(keyword_index.items())):
|
|
975
|
-
if i % step == 0 and files:
|
|
976
|
-
sample.append(files[0])
|
|
977
|
-
if len(sample) >= 20:
|
|
978
|
-
break
|
|
979
|
-
if sample:
|
|
980
|
-
hits = sum(1 for f in sample if os.path.isfile(os.path.join(CODE_DIR, f)))
|
|
981
|
-
ratio = hits / len(sample)
|
|
982
|
-
if ratio < MIN_SAMPLE_HIT:
|
|
983
|
-
fail(f'verify: 抽样 {len(sample)} 条仅 {hits} 条文件存在 ({ratio:.0%}), 索引与磁盘脱节')
|
|
984
|
-
else:
|
|
985
|
-
print(f' [OK] 抽样 {len(sample)} 条, {hits} 条文件存在 ({ratio:.0%})')
|
|
986
|
-
result['checks']['sample_hit_ratio'] = round(ratio, 2)
|
|
987
|
-
|
|
988
|
-
return result
|
|
989
|
-
|
|
990
|
-
|
|
991
707
|
def main():
|
|
992
708
|
config = load_config()
|
|
993
709
|
|
|
@@ -998,7 +714,7 @@ def main():
|
|
|
998
714
|
|
|
999
715
|
project_filter = None
|
|
1000
716
|
for i, arg in enumerate(sys.argv[1:], 1):
|
|
1001
|
-
if arg == '--project' and i
|
|
717
|
+
if arg == '--project' and i < len(sys.argv):
|
|
1002
718
|
project_filter = sys.argv[i + 1]
|
|
1003
719
|
|
|
1004
720
|
print('=' * 50)
|
|
@@ -1006,98 +722,54 @@ def main():
|
|
|
1006
722
|
print(f'Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
|
|
1007
723
|
print('=' * 50)
|
|
1008
724
|
|
|
1009
|
-
|
|
1010
|
-
sys.exit(2)
|
|
725
|
+
changed_projects = {}
|
|
1011
726
|
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
# Step 5.6: Build Repo Wiki index (Qoder IDE 生成的语义级模块文档)
|
|
1057
|
-
if not (sync_only or prd_only):
|
|
1058
|
-
try:
|
|
1059
|
-
from common.repowiki import build_wiki_index, find_repowiki_dirs
|
|
1060
|
-
code_dir = os.path.join(BASE, 'data', 'code')
|
|
1061
|
-
wiki_dirs = find_repowiki_dirs(code_dir)
|
|
1062
|
-
if wiki_dirs:
|
|
1063
|
-
print('\n=== Building Wiki Index (Repo Wiki 语义图谱) ===')
|
|
1064
|
-
out = os.path.join(INDEX_DIR, 'wiki-index.json')
|
|
1065
|
-
r = build_wiki_index(code_dir, out)
|
|
1066
|
-
if isinstance(r, dict) and r.get('stats'):
|
|
1067
|
-
s = r['stats']
|
|
1068
|
-
print(' Wiki: {} 项目 / {} 模块文档 / {} 关键词'.format(
|
|
1069
|
-
s.get('projects',0), s.get('pages',0), s.get('keywords',0)))
|
|
1070
|
-
else:
|
|
1071
|
-
print('\n=== Wiki Index: 无 Repo Wiki (可选, Qoder IDE 生成后自动纳入) ===')
|
|
1072
|
-
except Exception as e:
|
|
1073
|
-
print(' [WARN] Wiki index build failed (不阻塞): {}'.format(str(e)[:80]))
|
|
1074
|
-
|
|
1075
|
-
# Step 6: Write meta (读 module-map 拿真实 project 数; counts 用已加载的 ki)
|
|
1076
|
-
module_map = load_json(os.path.join(INDEX_DIR, 'module-map.json'))
|
|
1077
|
-
if shared_ki is None:
|
|
1078
|
-
shared_ki = load_json(os.path.join(INDEX_DIR, 'keyword-index.json'))
|
|
1079
|
-
api_idx = load_json(os.path.join(INDEX_DIR, 'api-index.json'))
|
|
1080
|
-
meta = {
|
|
1081
|
-
'last_sync': datetime.now().strftime('%Y-%m-%d %H:%M'),
|
|
1082
|
-
'projects': {p: info.get('files', 0) for p, info in module_map.items()},
|
|
1083
|
-
'counts': {
|
|
1084
|
-
'keywords': len(shared_ki),
|
|
1085
|
-
'apis': len(api_idx),
|
|
1086
|
-
},
|
|
1087
|
-
'verify': verify_result,
|
|
1088
|
-
'failures': FAILURES,
|
|
1089
|
-
}
|
|
1090
|
-
save_json(os.path.join(INDEX_DIR, '.index-meta.json'), meta)
|
|
1091
|
-
finally:
|
|
1092
|
-
release_lock()
|
|
727
|
+
# Step 1: Git sync
|
|
728
|
+
if not (index_only or prd_only):
|
|
729
|
+
changed_projects = git_sync_all(project_filter)
|
|
730
|
+
|
|
731
|
+
# Step 2: Collect PRDs
|
|
732
|
+
if not (sync_only or index_only):
|
|
733
|
+
collect_prds()
|
|
734
|
+
|
|
735
|
+
# Step 3: Parse PRDs and build PRD index
|
|
736
|
+
if not sync_only:
|
|
737
|
+
build_prd_index()
|
|
738
|
+
|
|
739
|
+
# Step 4: Update code indexes
|
|
740
|
+
if not (sync_only or prd_only):
|
|
741
|
+
if full_build or index_only:
|
|
742
|
+
build_full_indexes()
|
|
743
|
+
elif changed_projects:
|
|
744
|
+
update_indexes_incremental(changed_projects)
|
|
745
|
+
else:
|
|
746
|
+
print('\nNo code changes to index.')
|
|
747
|
+
|
|
748
|
+
# Step 5: Build PRD ↔ Code mapping
|
|
749
|
+
if not sync_only:
|
|
750
|
+
build_prd_code_mapping()
|
|
751
|
+
|
|
752
|
+
# Step 6: Write meta
|
|
753
|
+
meta = {
|
|
754
|
+
'last_sync': datetime.now().strftime('%Y-%m-%d %H:%M'),
|
|
755
|
+
'projects': list(module_map.keys()) if 'module_map' in dir() else [],
|
|
756
|
+
}
|
|
757
|
+
save_json(os.path.join(INDEX_DIR, '.index-meta.json'), meta)
|
|
758
|
+
|
|
759
|
+
# P0-2: clear stale caches after index rebuild
|
|
760
|
+
_rt = os.path.join(BASE, '.qoder', '.runtime')
|
|
761
|
+
if os.path.isdir(_rt):
|
|
762
|
+
_n = 0
|
|
763
|
+
for _fn in os.listdir(_rt):
|
|
764
|
+
if _fn.startswith('search-cache-') or _fn.startswith('ctx-cache-'):
|
|
765
|
+
try:
|
|
766
|
+
os.remove(os.path.join(_rt, _fn)); _n += 1
|
|
767
|
+
except OSError:
|
|
768
|
+
pass
|
|
769
|
+
if _n:
|
|
770
|
+
print(' cache cleanup: %d stale files removed' % _n)
|
|
1093
771
|
|
|
1094
772
|
print('\n' + '=' * 50)
|
|
1095
|
-
if FAILURES:
|
|
1096
|
-
print(f'Update finished with {len(FAILURES)} ERROR(S):')
|
|
1097
|
-
for msg in FAILURES:
|
|
1098
|
-
print(' - ' + msg)
|
|
1099
|
-
print('=' * 50)
|
|
1100
|
-
sys.exit(1)
|
|
1101
773
|
print('Update complete!')
|
|
1102
774
|
print('=' * 50)
|
|
1103
775
|
|