@hupan56/wlkj 2.4.4 → 2.4.6

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 CHANGED
@@ -278,6 +278,17 @@ function doUpdate() {
278
278
 
279
279
  console.log(`\nwlkj update${oldVer ? " " + oldVer : ""} -> v${PKG_VERSION}\n`);
280
280
 
281
+ // P1-15: 显式声明 update 绝不触碰的路径
282
+ console.log(" 受保护路径 (绝不覆盖):");
283
+ console.log(" .qoder/config.yaml (团队 git 配置)");
284
+ console.log(" .qoder/settings.json (权限/hook 配置)");
285
+ console.log(" .qoder/.developer (你的身份)");
286
+ console.log(" .qoder/.current-task (当前任务)");
287
+ console.log(" .qoder/.engine-version (版本戳)");
288
+ console.log(" workspace/ (你的全部产出)");
289
+ console.log(" data/ (代码仓库+知识图谱)");
290
+ console.log("");
291
+
281
292
  // === 1. 刷新引擎文件(保护 config.yaml / settings.json)===
282
293
  let copied = 0, protectedN = 0, skipped = 0;
283
294
  if (fs.existsSync(qoderSrc)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hupan56/wlkj",
3
- "version": "2.4.4",
3
+ "version": "2.4.6",
4
4
  "description": "AI Product R&D Workflow - PRD/Prototype/Search/Task/Report",
5
5
  "bin": {
6
6
  "wlkj": "bin/cli.js"
@@ -159,6 +159,9 @@ fields / non-canonical colors) and re-run until PASS. Then ask user to
159
159
  confirm publishing to workspace/specs/prd/.
160
160
  After publishing (and also after saving drafts/prototypes), run:
161
161
  ```bash
162
+ # 1. 归档到 data/docs/prd/ (立刻可搜, 不用等周五 cron)
163
+ python .qoder/scripts/collect_prds.py
164
+ # 2. 推送到团队
162
165
  python .qoder/scripts/team_sync.py push
163
166
  ```
164
167
  This silently commits & pushes the PM's outputs to the team repo.
@@ -188,7 +188,9 @@ def _compute_and_print(query, platform, page_type):
188
188
  ranked = sorted(hits.items(), key=lambda x: len(x[1]), reverse=True)[:12]
189
189
  print('\n## 1. 相关代码文件 ({} 个, 取前 12)'.format(len(hits)))
190
190
  for f, kws in ranked:
191
- print('- {} (命中: {})'.format(f, ','.join(sorted(kws)[:3])))
191
+ # 加完整路径前缀, AI 用 Read 工具能直接读
192
+ full = 'data/code/' + f.replace('\\', '/')
193
+ print('- {} (命中: {}) -> {}'.format(f, ','.join(sorted(kws)[:3]), full))
192
194
  if not hits:
193
195
  print('- (无命中 — 换业务词重试, 或该功能为全新)')
194
196
 
@@ -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
- # UTF-8 stdio (防御性: stdout 被捕获时不崩溃)
33
- try:
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
- # Simple lock to avoid two concurrent index writers (cron + manual run)
53
- LOCK_FILE = os.path.join(INDEX_DIR, '.sync-lock')
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
- def acquire_lock():
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 (json.JSONDecodeError, OSError) as e:
95
- backup = path + '.corrupt'
96
- try:
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
- """Atomic, deterministic JSON write (temp file + replace, sorted keys)."""
58
+ """Save JSON file"""
112
59
  os.makedirs(os.path.dirname(path), exist_ok=True)
113
- tmp = path + '.tmp'
114
- with open(tmp, 'w', encoding='utf-8') as f:
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, branch=None):
144
- """Git pull a single project on its configured branch.
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 = git('rev-parse', 'HEAD')
168
- if result.returncode != 0:
169
- fail(f'{project_name}: rev-parse failed - {result.stderr.strip()[:120]}')
170
- return None
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
- result = git('fetch', 'origin')
175
- if result.returncode != 0:
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
- pull_args = ['pull', 'origin'] + ([branch] if branch else [])
181
- result = git(*pull_args)
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
- fail(f'{project_name}: pull failed - {result.stderr.strip()[:120]}')
184
- return None
106
+ print(f' {project_name}: Pull failed - {result.stderr[:100]}')
107
+ return []
185
108
 
186
- result = git('rev-parse', 'HEAD')
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 = git('diff', '--name-only', old_commit, new_commit)
195
- if result.returncode != 0:
196
- fail(f'{project_name}: diff failed - run git_sync.py --full to reindex')
197
- return None
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, config=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
- fail('data/code/ not found')
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, branch_cfg.get(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
- for f in sorted(os.listdir(drafts_dir)):
274
- if not ((f.startswith('REQ-') or f.startswith('PRD-') or f.startswith('prd-')) and f.endswith('.md')):
275
- continue
276
- filepath = os.path.join(drafts_dir, f)
277
- digest = file_md5(filepath)
278
-
279
- prev = collected.get(f)
280
- if prev and prev.get('user') and prev['user'] != user_name:
281
- fail(f'PRD filename collision: {f} exists from "{prev["user"]}" '
282
- f'and "{user_name}". Rename one of them (REQ numbers must be unique).')
283
- continue
284
-
285
- is_new = prev is None
286
- is_updated = (not is_new) and prev.get('md5') != digest
287
- if not (is_new or is_updated):
288
- continue
289
-
290
- shutil.copy2(filepath, os.path.join(PRD_DIR, f))
291
- collected[f] = {
292
- 'user': user_name,
293
- 'md5': digest,
294
- 'collected_at': datetime.now().strftime('%Y-%m-%d %H:%M')
295
- }
296
- print(f' {"NEW" if is_new else "UPDATED"}: {f} (by {user_name})')
297
- if is_new:
298
- total_new += 1
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 (stop words come from common.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 PRD_STOP_WORDS and len(t) >= 2]))[:30]
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 (CN -> EN mapping from common.terms)
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 CN_TO_EN:
355
- keywords.add(CN_TO_EN[term])
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(keyword_index=None):
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
- if keyword_index is None:
384
- ki_path = os.path.join(INDEX_DIR, 'keyword-index.json')
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 build_file_keys_map(keyword_index):
490
- """构建反向索引 {file: set(keywords)} (性能优化 A5)。
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
- # 性能优化 A5: 优先用反向索引 O(1) 查找, 避免遍历全部 keys
515
- if file_keys_map is not None:
516
- involved_keys = file_keys_map.pop(rel, set())
517
- to_remove = []
518
- for kw in involved_keys:
519
- files = keyword_index.get(kw)
520
- if files and rel in files:
521
- files.remove(rel)
522
- if not files:
523
- to_remove.append(kw)
524
- for kw in to_remove:
525
- del keyword_index[kw]
526
- else:
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
- # required=True: refusing to "rebuild" on top of a corrupt/empty base
651
- keyword_index = load_json(os.path.join(INDEX_DIR, 'keyword-index.json'), required=True)
652
- api_index = load_json(os.path.join(INDEX_DIR, 'api-index.json'), required=True)
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 = (project_name + '/' + changed_file).replace(os.sep, '/')
464
+ rel = os.path.relpath(filepath, CODE_DIR).replace(os.sep, '/')
673
465
 
674
- # Remove old entries (用反向索引 O(1), 若不可用回退全扫描)
675
- remove_file_from_indexes(rel, keyword_index, api_index, file_keys_map=file_keys_map)
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
- if index_one_file(filepath, rel, ext, keyword_index, api_index):
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
- normalize_keyword_index(keyword_index)
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
- # 重建反向索引 (增量后 keys 关系变了, 重建最可靠)
691
- fkm_new = build_file_keys_map(keyword_index)
692
- module_map = rebuild_module_summary(api_index)
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
- # 性能优化 A4: 按 project 并行索引 (项目间独立, 适合 ProcessPool)
780
- # 项目数少 (3个), 序列化开销可接受; 单项目内仍串行 (避免 12k 文件的小任务开销)
781
- use_parallel = len(projects) >= 2
782
- if use_parallel:
783
- try:
784
- from concurrent.futures import ProcessPoolExecutor
785
- import multiprocessing as mp
786
- workers = min(len(projects), mp.cpu_count())
787
- print(f' Parallel indexing with {workers} workers...')
788
- with ProcessPoolExecutor(max_workers=workers) as pool:
789
- futures = []
790
- for pname, pdir in projects:
791
- futures.append(pool.submit(_index_project, pname, pdir, SKIP_DIRS))
792
- for fut in futures:
793
- pname, local_ki, local_api, stats = fut.result()
794
- # 合并到全局 index
795
- for kw, files in local_ki.items():
796
- keyword_index.setdefault(kw, []).extend(files)
797
- api_index.update(local_api)
798
- file_stats[pname] = stats
799
- total_files += stats['files']
800
- print(f' [{pname}] Files: {stats["files"]}')
801
- except Exception as e:
802
- # 并行失败回退串行 (鲁棒性)
803
- print(f' Parallel failed ({e}), falling back to serial...')
804
- use_parallel = False
805
-
806
- if not use_parallel:
807
- # 串行回退 (或单项目)
808
- for project_name, project_dir in projects:
809
- print(f' [{project_name}] Scanning...')
810
- pname, local_ki, local_api, stats = _index_project(project_name, project_dir, SKIP_DIRS)
811
- for kw, files in local_ki.items():
812
- keyword_index.setdefault(kw, []).extend(files)
813
- api_index.update(local_api)
814
- file_stats[pname] = stats
815
- total_files += stats['files']
816
- print(f' Files: {stats["files"]}')
817
-
818
- normalize_keyword_index(keyword_index)
819
- # 复用 file_stats, 不再二次 walk (A3)
820
- module_map = rebuild_module_summary(api_index, file_stats=file_stats)
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
- Args:
850
- keyword_index: 可选预加载 (A3: 避免重复加载)。
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
- if keyword_index is None:
856
- keyword_index = load_json(os.path.join(INDEX_DIR, 'keyword-index.json'))
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 not in all_text:
870
- continue
871
- for pattern in en_patterns:
872
- pat_lower = pattern.strip('/-').lower()
873
- if not pat_lower:
874
- continue
875
- for kw, files in keyword_index.items():
876
- kw_lower = kw.lower()
877
- if kw_lower == pat_lower or (
878
- len(kw_lower) >= MIN_PATTERN_FUZZY_LEN and kw_lower in pat_lower
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
- related_files.update(keyword_index[kw_lower][:3])
889
-
890
- # Strategy 3: CN to EN translation (shared map)
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 CN_TO_EN:
893
- en = CN_TO_EN[term].lower()
673
+ if term in cn_to_en:
674
+ en = cn_to_en[term].lower()
894
675
  if en in keyword_index:
895
- related_files.update(keyword_index[en][:5])
676
+ for f in keyword_index[en][:5]:
677
+ related_files.add(f)
896
678
 
897
- # Deduplicate, sort for determinism, and limit
898
- related_files = sorted(related_files)[:30]
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 + 1 < len(sys.argv):
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
- if not acquire_lock():
1010
- sys.exit(2)
725
+ changed_projects = {}
1011
726
 
1012
- try:
1013
- changed_projects = {}
1014
-
1015
- # 性能优化 A3: 共享 keyword_index 加载 (避免 build_prd_index 和
1016
- # build_prd_code_mapping 各自重复加载 4.5MB JSON)
1017
- shared_ki = None # 懒加载: 首次需要时加载, 后续复用
1018
-
1019
- # Step 1: Git sync
1020
- if not (index_only or prd_only):
1021
- changed_projects = git_sync_all(project_filter, config)
1022
-
1023
- # Step 2: Collect PRDs
1024
- if not (sync_only or index_only):
1025
- collect_prds()
1026
-
1027
- # Step 3: Parse PRDs and build PRD index (共享 keyword_index)
1028
- if not sync_only:
1029
- if shared_ki is None and os.path.isfile(os.path.join(INDEX_DIR, 'keyword-index.json')):
1030
- shared_ki = load_json(os.path.join(INDEX_DIR, 'keyword-index.json'))
1031
- build_prd_index(keyword_index=shared_ki)
1032
-
1033
- # Step 4: Update code indexes
1034
- if not (sync_only or prd_only):
1035
- if full_build or index_only:
1036
- build_full_indexes()
1037
- # full build 重写了 keyword-index, 失效旧缓存
1038
- shared_ki = None
1039
- elif changed_projects:
1040
- update_indexes_incremental(changed_projects)
1041
- shared_ki = None # 增量也改了, 失效
1042
- else:
1043
- print('\nNo code changes to index.')
1044
-
1045
- # Step 5: Build PRD ↔ Code mapping (共享 keyword_index, 重新加载若失效)
1046
- if not sync_only:
1047
- if shared_ki is None and os.path.isfile(os.path.join(INDEX_DIR, 'keyword-index.json')):
1048
- shared_ki = load_json(os.path.join(INDEX_DIR, 'keyword-index.json'))
1049
- build_prd_code_mapping(keyword_index=shared_ki)
1050
-
1051
- # Step 5.5: Verify index accuracy (跨项目泛化的保证)
1052
- verify_result = None
1053
- if not (sync_only or prd_only):
1054
- verify_result = verify_indexes(config)
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
 
@@ -315,7 +315,9 @@ def _search_keywords_impl(query, platform=None):
315
315
  for f in fs[:3]:
316
316
  parts = f.replace('\\', '/').split('/')
317
317
  short = '/'.join(parts[:2]) + '/.../' + '/'.join(parts[-2:]) if len(parts) > 5 else '/'.join(parts)
318
- print(' ' + short)
318
+ # 输出完整路径前缀, 让 AI 用 Read 工具能直接读 (不用猜路径)
319
+ full = 'data/code/' + f.replace('\\', '/')
320
+ print(' ' + short + ' -> ' + full)
319
321
  print()
320
322
  print('Total: {} matches'.format(total))
321
323
 
@@ -145,10 +145,16 @@ def report_conflict(stderr):
145
145
  # autostash 风险检查: --autostash 可能在 abort 后留下未弹回的 stash
146
146
  stash_r = git('stash', 'list')
147
147
  if stash_r.returncode == 0 and stash_r.stdout.strip():
148
- stash_count = len([l for l in stash_r.stdout.strip().split('\n') if l.strip()])
149
- print('WARNING: 检测到 {} 个 stash (可能是 autostash 未弹回)!'.format(stash_count))
150
- print(' 查看 stash: git stash list')
151
- print(' 恢复改动: git stash pop (如果包含你的草稿)')
148
+ stash_lines = [l for l in stash_r.stdout.strip().split('\n') if l.strip()]
149
+ if stash_lines:
150
+ print('检测到 {} stash (可能是 autostash 未弹回), 尝试自动恢复...'.format(len(stash_lines)))
151
+ # P1-6: 自动尝试 stash pop 恢复用户的草稿
152
+ pop_r = git('stash', 'pop')
153
+ if pop_r.returncode == 0:
154
+ print(' [OK] stash 已自动恢复 (你的改动回来了)')
155
+ else:
156
+ print(' WARNING: stash pop 失败, 你的改动可能还在 stash 里')
157
+ print(' 手动恢复: git stash list → git stash pop')
152
158
  print('SYNC_CONFLICT: 自动同步遇到冲突, 已安全回退 (本地产出未丢失)。')
153
159
  branch = current_branch()
154
160
  print('AI 请按以下步骤解决冲突:')
@@ -67,10 +67,15 @@ trigger: "用户描述需求、要 PRD、要原型、要 mockup,或直接 /wl-
67
67
  python .qoder/scripts/eval_prd.py <draft-prd.md> <prototype.html>
68
68
  ```
69
69
  <80% 就修到 PASS。
70
- 6. 发布 + 自动同步:
70
+ 6. 发布 + 归档 + 同步(3 合 1):
71
71
  ```bash
72
+ # a. 归档到 data/docs/prd/ (立刻可搜, 不用等 cron)
73
+ python .qoder/scripts/collect_prds.py
74
+ # b. 推送到团队 (push 后也会自动再 collect 一次)
72
75
  python .qoder/scripts/team_sync.py push
73
76
  ```
77
+ > collect_prds 把你的 PRD 从 workspace/members/{你的名字}/drafts/ 拷到 data/docs/prd/,
78
+ > 这样 search_index --prd 立刻能搜到新 PRD。
74
79
 
75
80
  详细生成规则、样式 token、原型规则见:
76
81
  - 功能型 skill:`.qoder/skills/prd-generator/SKILL.md`