@haaaiawd/anws 2.2.6 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +1 -1
  2. package/bin/cli.js +52 -22
  3. package/lib/diff.js +5 -2
  4. package/lib/init.js +217 -96
  5. package/lib/install-state.js +18 -3
  6. package/lib/manifest.js +510 -213
  7. package/lib/prompt.js +68 -0
  8. package/lib/resources/index.js +36 -2
  9. package/lib/update.js +12 -6
  10. package/package.json +48 -47
  11. package/templates/.agents/skills/anws-system/SKILL.md +108 -108
  12. package/templates/.agents/skills/code-reviewer/SKILL.md +170 -103
  13. package/templates/.agents/skills/concept-modeler/SKILL.md +230 -179
  14. package/templates/.agents/skills/craft-authoring/SKILL.md +112 -49
  15. package/templates/.agents/skills/craft-authoring/references/BUNDLE_POLICY.md +61 -0
  16. package/templates/.agents/skills/craft-authoring/references/PROMPT_QUALITY_RUBRIC.md +99 -0
  17. package/templates/.agents/skills/craft-authoring/references/SCORECARD_TEMPLATE.md +64 -0
  18. package/templates/.agents/skills/design-reviewer/SKILL.md +265 -190
  19. package/templates/.agents/skills/e2e-testing-guide/SKILL.md +246 -135
  20. package/templates/.agents/skills/nexus-mapper/SKILL.md +321 -321
  21. package/templates/.agents/skills/output-contract/SKILL.md +37 -0
  22. package/templates/.agents/skills/report-template/SKILL.md +92 -92
  23. package/templates/.agents/skills/sequential-thinking/SKILL.md +222 -225
  24. package/templates/.agents/skills/spec-writer/SKILL.md +75 -30
  25. package/templates/.agents/skills/system-architect/SKILL.md +538 -678
  26. package/templates/.agents/skills/system-designer/SKILL.md +601 -601
  27. package/templates/.agents/skills/task-planner/SKILL.md +1 -2
  28. package/templates/.agents/skills/task-reviewer/SKILL.md +428 -388
  29. package/templates/.agents/skills/tech-evaluator/SKILL.md +252 -144
  30. package/templates/.agents/workflows/blueprint.md +166 -43
  31. package/templates/.agents/workflows/challenge.md +331 -497
  32. package/templates/.agents/workflows/change.md +182 -339
  33. package/templates/.agents/workflows/craft.md +159 -236
  34. package/templates/.agents/workflows/design-system.md +202 -674
  35. package/templates/.agents/workflows/explore.md +187 -399
  36. package/templates/.agents/workflows/forge.md +650 -550
  37. package/templates/.agents/workflows/genesis.md +439 -351
  38. package/templates/.agents/workflows/probe.md +219 -241
  39. package/templates/.agents/workflows/quickstart.md +302 -123
  40. package/templates/.agents/workflows/upgrade.md +145 -182
  41. package/templates_en/.agents/skills/anws-system/SKILL.md +108 -0
  42. package/templates_en/.agents/skills/code-reviewer/SKILL.md +170 -0
  43. package/templates_en/.agents/skills/concept-modeler/SKILL.md +230 -0
  44. package/templates_en/.agents/skills/craft-authoring/SKILL.md +179 -0
  45. package/templates_en/.agents/skills/craft-authoring/references/BUNDLE_POLICY.md +60 -0
  46. package/templates_en/.agents/skills/craft-authoring/references/PROMPT_QUALITY_RUBRIC.md +92 -0
  47. package/templates_en/.agents/skills/craft-authoring/references/SCORECARD_TEMPLATE.md +52 -0
  48. package/templates_en/.agents/skills/design-reviewer/SKILL.md +265 -0
  49. package/templates_en/.agents/skills/e2e-testing-guide/SKILL.md +246 -0
  50. package/templates_en/.agents/skills/nexus-mapper/SKILL.md +306 -0
  51. package/templates_en/.agents/skills/nexus-mapper/references/language-customization.md +167 -0
  52. package/templates_en/.agents/skills/nexus-mapper/references/output-schema.md +311 -0
  53. package/templates_en/.agents/skills/nexus-mapper/references/probe-protocol.md +246 -0
  54. package/templates_en/.agents/skills/nexus-mapper/scripts/extract_ast.py +706 -0
  55. package/templates_en/.agents/skills/nexus-mapper/scripts/git_detective.py +194 -0
  56. package/templates_en/.agents/skills/nexus-mapper/scripts/languages.json +127 -0
  57. package/templates_en/.agents/skills/nexus-mapper/scripts/query_graph.py +556 -0
  58. package/templates_en/.agents/skills/nexus-mapper/scripts/requirements.txt +6 -0
  59. package/templates_en/.agents/skills/nexus-query/SKILL.md +114 -0
  60. package/templates_en/.agents/skills/nexus-query/scripts/extract_ast.py +706 -0
  61. package/templates_en/.agents/skills/nexus-query/scripts/git_detective.py +194 -0
  62. package/templates_en/.agents/skills/nexus-query/scripts/languages.json +127 -0
  63. package/templates_en/.agents/skills/nexus-query/scripts/query_graph.py +556 -0
  64. package/templates_en/.agents/skills/nexus-query/scripts/requirements.txt +6 -0
  65. package/templates_en/.agents/skills/output-contract/SKILL.md +37 -0
  66. package/templates_en/.agents/skills/report-template/SKILL.md +85 -0
  67. package/templates_en/.agents/skills/report-template/references/REPORT_TEMPLATE.md +100 -0
  68. package/templates_en/.agents/skills/runtime-inspector/SKILL.md +101 -0
  69. package/templates_en/.agents/skills/sequential-thinking/SKILL.md +214 -0
  70. package/templates_en/.agents/skills/spec-writer/SKILL.md +153 -0
  71. package/templates_en/.agents/skills/spec-writer/references/prd_template.md +177 -0
  72. package/templates_en/.agents/skills/system-architect/SKILL.md +538 -0
  73. package/templates_en/.agents/skills/system-architect/references/rfc_template.md +59 -0
  74. package/templates_en/.agents/skills/system-designer/SKILL.md +534 -0
  75. package/templates_en/.agents/skills/system-designer/references/system-design-detail-template.md +187 -0
  76. package/templates_en/.agents/skills/system-designer/references/system-design-template.md +605 -0
  77. package/templates_en/.agents/skills/task-planner/SKILL.md +251 -0
  78. package/templates_en/.agents/skills/task-planner/references/TASK_TEMPLATE_05A.md +109 -0
  79. package/templates_en/.agents/skills/task-planner/references/TASK_TEMPLATE_05B.md +176 -0
  80. package/templates_en/.agents/skills/task-reviewer/SKILL.md +428 -0
  81. package/templates_en/.agents/skills/tech-evaluator/SKILL.md +252 -0
  82. package/templates_en/.agents/skills/tech-evaluator/references/ADR_TEMPLATE.md +78 -0
  83. package/templates_en/.agents/workflows/blueprint.md +200 -0
  84. package/templates_en/.agents/workflows/challenge.md +331 -0
  85. package/templates_en/.agents/workflows/change.md +182 -0
  86. package/templates_en/.agents/workflows/craft.md +159 -0
  87. package/templates_en/.agents/workflows/design-system.md +202 -0
  88. package/templates_en/.agents/workflows/explore.md +187 -0
  89. package/templates_en/.agents/workflows/forge.md +651 -0
  90. package/templates_en/.agents/workflows/genesis.md +439 -0
  91. package/templates_en/.agents/workflows/probe.md +219 -0
  92. package/templates_en/.agents/workflows/quickstart.md +303 -0
  93. package/templates_en/.agents/workflows/upgrade.md +145 -0
  94. package/templates_en/AGENTS.md +149 -0
@@ -0,0 +1,556 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ query_graph.py — On-demand AST query tool
4
+
5
+ Read ast_nodes.json produced by extract_ast.py and provide multiple query modes,
6
+ output concise text that is easy for agents to consume.
7
+
8
+ Purpose:
9
+ - Assist REASON/OBJECT/EMIT stages in the PROBE workflow to generate cognition files
10
+ - Perform bug investigation, change impact analysis, and refactor analysis during development
11
+
12
+ Usage:
13
+ python query_graph.py <ast_nodes.json> --file <path>
14
+ python query_graph.py <ast_nodes.json> --who-imports <module_or_path>
15
+ python query_graph.py <ast_nodes.json> --impact <path>
16
+ python query_graph.py <ast_nodes.json> --hub-analysis [--top N]
17
+ python query_graph.py <ast_nodes.json> --summary
18
+ """
19
+
20
+ import sys
21
+ import json
22
+ import argparse
23
+ from pathlib import Path, PurePosixPath
24
+ from collections import defaultdict
25
+
26
+
27
+ class GitStats:
28
+ """Query helper for git_stats.json. Optional loading, no impact on core AST queries."""
29
+
30
+ def __init__(self, data: dict):
31
+ self.period_days: int = data.get('analysis_period_days', 90)
32
+ self.hotspots: dict[str, dict] = {} # path → {changes, risk}
33
+ for h in data.get('hotspots', []):
34
+ self.hotspots[h['path']] = h
35
+ self.coupling: dict[str, list[dict]] = defaultdict(list) # path → [{peer, co_changes, score}]
36
+ for c in data.get('coupling_pairs', []):
37
+ self.coupling[c['file_a']].append({
38
+ 'peer': c['file_b'], 'co_changes': c['co_changes'],
39
+ 'score': c['coupling_score'],
40
+ })
41
+ self.coupling[c['file_b']].append({
42
+ 'peer': c['file_a'], 'co_changes': c['co_changes'],
43
+ 'score': c['coupling_score'],
44
+ })
45
+
46
+ def file_risk(self, path: str) -> dict | None:
47
+ return self.hotspots.get(path)
48
+
49
+ def file_coupling(self, path: str) -> list[dict]:
50
+ return sorted(self.coupling.get(path, []), key=lambda x: x['score'], reverse=True)
51
+
52
+ RISK_ICON = {'high': '🔴', 'medium': '🟡', 'low': '🟢'}
53
+
54
+ def format_risk_block(self, path: str) -> list[str]:
55
+ """Generate git risk + coupling text lines for a file (empty list means no data)."""
56
+ lines: list[str] = []
57
+ risk = self.file_risk(path)
58
+ if risk:
59
+ icon = self.RISK_ICON.get(risk['risk'], '⚪')
60
+ lines.append(f"Git risk: {icon} {risk['risk']} ({risk['changes']} changes in {self.period_days} days)")
61
+ coupling = self.file_coupling(path)
62
+ if coupling:
63
+ lines.append("Coupled files (co-change):")
64
+ for c in coupling[:5]:
65
+ lines.append(f" - {c['peer']} (coupling: {c['score']:.2f}, {c['co_changes']} co-changes)")
66
+ return lines
67
+
68
+
69
+ class ASTGraph:
70
+ """In-memory AST graph index supporting multiple query modes."""
71
+
72
+ SOURCE_ROOT_MARKERS = (
73
+ ('src',),
74
+ ('backend', 'src'),
75
+ ('frontend', 'src'),
76
+ ('client', 'src'),
77
+ ('src', 'main', 'python'),
78
+ ('src', 'test', 'python'),
79
+ ('src', 'main', 'java'),
80
+ ('src', 'test', 'java'),
81
+ ('src', 'main', 'kotlin'),
82
+ ('src', 'test', 'kotlin'),
83
+ )
84
+
85
+ def __init__(self, data: dict, git_stats: GitStats | None = None):
86
+ self.data = data
87
+ self.nodes: list[dict] = data.get('nodes', [])
88
+ self.edges: list[dict] = data.get('edges', [])
89
+ self.stats: dict = data.get('stats', {})
90
+ self.languages: list[str] = data.get('languages', [])
91
+ self.git: GitStats | None = git_stats
92
+
93
+ # Indexes
94
+ self.nodes_by_id: dict[str, dict] = {}
95
+ self.nodes_by_path: dict[str, list[dict]] = defaultdict(list)
96
+ self.modules_by_path: dict[str, dict] = {}
97
+ self.imports_forward: dict[str, set[str]] = defaultdict(set)
98
+ self.imports_reverse: dict[str, set[str]] = defaultdict(set)
99
+ self.internal_imports_forward: dict[str, set[str]] = defaultdict(set)
100
+ self.internal_imports_reverse: dict[str, set[str]] = defaultdict(set)
101
+ self.contains_children: dict[str, list[dict]] = defaultdict(list)
102
+ self.path_to_module_id: dict[str, str] = {}
103
+ self.alias_to_module_ids: dict[str, set[str]] = defaultdict(set)
104
+
105
+ self._build_index()
106
+
107
+ def _build_index(self) -> None:
108
+ for node in self.nodes:
109
+ nid = node['id']
110
+ self.nodes_by_id[nid] = node
111
+ path = node.get('path', '')
112
+ if path:
113
+ self.nodes_by_path[path].append(node)
114
+ if node['type'] == 'Module' and path:
115
+ self.modules_by_path[path] = node
116
+ self.path_to_module_id[path] = nid
117
+ for alias in self._module_aliases(nid, path):
118
+ self.alias_to_module_ids[alias].add(nid)
119
+
120
+ for edge in self.edges:
121
+ src, tgt, etype = edge['source'], edge['target'], edge['type']
122
+ if etype == 'imports':
123
+ self.imports_forward[src].add(tgt)
124
+ self.imports_reverse[tgt].add(src)
125
+ elif etype == 'contains':
126
+ child = self.nodes_by_id.get(tgt)
127
+ if child:
128
+ self.contains_children[src].append(child)
129
+
130
+ module_ids = {n['id'] for n in self.nodes if n['type'] == 'Module'}
131
+ for source, targets in self.imports_forward.items():
132
+ if source not in module_ids:
133
+ continue
134
+ for target in targets:
135
+ resolved = self.resolve_import_target(target)
136
+ if resolved and resolved in module_ids and resolved != source:
137
+ self.internal_imports_forward[source].add(resolved)
138
+ self.internal_imports_reverse[resolved].add(source)
139
+
140
+ def _module_aliases(self, module_id: str, path: str) -> set[str]:
141
+ aliases = {module_id}
142
+ parts = list(PurePosixPath(path.replace('\\', '/')).parts)
143
+ if not parts:
144
+ return aliases
145
+
146
+ stem = PurePosixPath(parts[-1]).stem
147
+ normalized_parts = parts[:-1] if stem == '__init__' else parts[:-1] + [stem]
148
+
149
+ for marker in self.SOURCE_ROOT_MARKERS:
150
+ if tuple(normalized_parts[:len(marker)]) == marker and len(normalized_parts) > len(marker):
151
+ aliases.add('.'.join(normalized_parts[len(marker):]))
152
+
153
+ for idx, part in enumerate(normalized_parts):
154
+ if part == 'src' and idx + 1 < len(normalized_parts):
155
+ aliases.add('.'.join(normalized_parts[idx + 1:]))
156
+
157
+ return {alias for alias in aliases if alias}
158
+
159
+ def resolve_import_target(self, target: str) -> str | None:
160
+ if target in self.nodes_by_id and self.nodes_by_id[target]['type'] == 'Module':
161
+ return target
162
+
163
+ direct = self.alias_to_module_ids.get(target)
164
+ if direct and len(direct) == 1:
165
+ return next(iter(direct))
166
+
167
+ parts = target.split('.')
168
+ while len(parts) > 1:
169
+ parts = parts[:-1]
170
+ candidate = '.'.join(parts)
171
+ matches = self.alias_to_module_ids.get(candidate)
172
+ if matches and len(matches) == 1:
173
+ return next(iter(matches))
174
+
175
+ return None
176
+
177
+ def _classify_imports(self, imports: set[str]) -> tuple[list[tuple[str, str]], list[str]]:
178
+ internal: list[tuple[str, str]] = []
179
+ external: list[str] = []
180
+ for imp in sorted(imports):
181
+ resolved = self.resolve_import_target(imp)
182
+ if resolved:
183
+ internal.append((imp, resolved))
184
+ else:
185
+ external.append(imp)
186
+ return internal, external
187
+
188
+ def resolve_to_module_id(self, query: str) -> str | None:
189
+ """Resolve file path or module id to module id."""
190
+ # Try direct module id lookup
191
+ if query in self.nodes_by_id and self.nodes_by_id[query]['type'] == 'Module':
192
+ return query
193
+ # Try as file path (compatible with \\ and /)
194
+ normalized = query.replace('\\', '/')
195
+ if normalized in self.path_to_module_id:
196
+ return self.path_to_module_id[normalized]
197
+ # Fuzzy match: remove leading repo-relative path prefix
198
+ for path, mid in self.path_to_module_id.items():
199
+ if path.endswith(normalized) or normalized.endswith(path):
200
+ return mid
201
+ return None
202
+
203
+ def resolve_to_path(self, module_id: str) -> str | None:
204
+ """Resolve module id to file path."""
205
+ node = self.nodes_by_id.get(module_id)
206
+ if node:
207
+ return node.get('path')
208
+ return None
209
+
210
+ # ── Query mode implementation ──────────────────────────────────────────────
211
+
212
+ def query_file(self, file_query: str) -> str:
213
+ """--file: Show full structure and import list for a file."""
214
+ mid = self.resolve_to_module_id(file_query)
215
+ if not mid:
216
+ return f"[NOT FOUND] No module matching '{file_query}'"
217
+
218
+ module_node = self.nodes_by_id[mid]
219
+ path = module_node.get('path', mid)
220
+ lines = module_node.get('lines', '?')
221
+ lang = module_node.get('lang', '?')
222
+
223
+ out = [f"=== {path} ==="]
224
+ out.append(f"Module: {mid} ({lines} lines, {lang})")
225
+ out.append("")
226
+
227
+ # Classes and functions
228
+ classes = [n for n in self.contains_children.get(mid, []) if n['type'] == 'Class']
229
+ top_funcs = [n for n in self.contains_children.get(mid, []) if n['type'] == 'Function']
230
+
231
+ if classes:
232
+ out.append("Classes:")
233
+ for cls in classes:
234
+ sl = cls.get('start_line', '?')
235
+ el = cls.get('end_line', '?')
236
+ out.append(f" {cls['label']} (L{sl}-L{el})")
237
+ methods = [n for n in self.contains_children.get(cls['id'], []) if n['type'] == 'Function']
238
+ for i, m in enumerate(methods):
239
+ prefix = "└─" if i == len(methods) - 1 else "├─"
240
+ ml = m.get('start_line', '?')
241
+ me = m.get('end_line', '?')
242
+ out.append(f" {prefix} {m['label']} (L{ml}-L{me})")
243
+ out.append("")
244
+
245
+ if top_funcs:
246
+ out.append("Top-level Functions:")
247
+ for f in top_funcs:
248
+ sl = f.get('start_line', '?')
249
+ el = f.get('end_line', '?')
250
+ out.append(f" {f['label']} (L{sl}-L{el})")
251
+ out.append("")
252
+
253
+ # Imports
254
+ imports = sorted(self.imports_forward.get(mid, set()))
255
+ if imports:
256
+ internal, external = self._classify_imports(set(imports))
257
+ out.append("Imports:")
258
+ for raw_imp, resolved_imp in internal:
259
+ imp_path = self.resolve_to_path(resolved_imp)
260
+ suffix = f" ({imp_path})" if imp_path else ""
261
+ if raw_imp == resolved_imp:
262
+ out.append(f" → {raw_imp}{suffix}")
263
+ else:
264
+ out.append(f" → {raw_imp} [resolved: {resolved_imp}{suffix}]")
265
+ for imp in external:
266
+ out.append(f" → {imp} (external)")
267
+ out.append("")
268
+
269
+ if not classes and not top_funcs and not imports:
270
+ out.append("(no classes, functions, or imports detected)")
271
+
272
+ # Git stats (optional)
273
+ if self.git:
274
+ git_lines = self.git.format_risk_block(path)
275
+ if git_lines:
276
+ out.append("Git:")
277
+ out.extend(f" {l}" for l in git_lines)
278
+ out.append("")
279
+
280
+ return "\n".join(out)
281
+
282
+ def query_who_imports(self, module_query: str) -> str:
283
+ """--who-imports: Reverse dependency query."""
284
+ mid = self.resolve_to_module_id(module_query)
285
+
286
+ # Also try direct lookup in imports_reverse (for external package names, etc.)
287
+ if not mid:
288
+ # Could be a partial match (e.g. 'flask' in import targets)
289
+ matches = set()
290
+ normalized = module_query.replace('\\', '/')
291
+ for target, sources in self.imports_reverse.items():
292
+ if target == normalized or target == module_query:
293
+ matches.update(sources)
294
+ if matches:
295
+ return self._format_who_imports(module_query, matches)
296
+ return f"[NOT FOUND] No module matching '{module_query}'"
297
+
298
+ importers: set[str] = set(self.internal_imports_reverse.get(mid, set()))
299
+
300
+ return self._format_who_imports(mid, importers)
301
+
302
+ def _format_who_imports(self, query: str, importers: set[str]) -> str:
303
+ out = [f"=== Who imports {query}? ==="]
304
+ if not importers:
305
+ out.append("Not imported by any module in the project.")
306
+ return "\n".join(out)
307
+
308
+ out.append(f"Imported by {len(importers)} module(s):")
309
+ for imp in sorted(importers):
310
+ imp_path = self.resolve_to_path(imp)
311
+ suffix = f" ({imp_path})" if imp_path else ""
312
+ out.append(f" ← {imp}{suffix}")
313
+ return "\n".join(out)
314
+
315
+ def query_impact(self, file_query: str) -> str:
316
+ """--impact: Impact radius analysis (upstream/downstream dependencies)."""
317
+ mid = self.resolve_to_module_id(file_query)
318
+ if not mid:
319
+ return f"[NOT FOUND] No module matching '{file_query}'"
320
+
321
+ module_node = self.nodes_by_id[mid]
322
+ path = module_node.get('path', mid)
323
+
324
+ out = [f"=== Impact radius: {path} ===", ""]
325
+
326
+ # Upstream: what this file imports
327
+ forward = sorted(self.imports_forward.get(mid, set()))
328
+ internal_forward, external_forward = self._classify_imports(set(forward))
329
+
330
+ out.append("Depends on (this file imports):")
331
+ if internal_forward:
332
+ for raw_dep, resolved_dep in internal_forward:
333
+ dep_path = self.resolve_to_path(resolved_dep)
334
+ suffix = f" ({dep_path})" if dep_path else ""
335
+ if raw_dep == resolved_dep:
336
+ out.append(f" → {raw_dep}{suffix}")
337
+ else:
338
+ out.append(f" → {raw_dep} [resolved: {resolved_dep}{suffix}]")
339
+ if external_forward:
340
+ for dep in external_forward:
341
+ out.append(f" → {dep} (external)")
342
+ if not forward:
343
+ out.append(" (none)")
344
+ out.append("")
345
+
346
+ # Downstream: who imports this file
347
+ importers: set[str] = set(self.internal_imports_reverse.get(mid, set()))
348
+
349
+ out.append("Depended by (other files import this):")
350
+ if importers:
351
+ for imp in sorted(importers):
352
+ imp_path = self.resolve_to_path(imp)
353
+ suffix = f" ({imp_path})" if imp_path else ""
354
+ out.append(f" ← {imp}{suffix}")
355
+ else:
356
+ out.append(" (none)")
357
+ out.append("")
358
+
359
+ downstream_count = len(importers)
360
+ upstream_count = len({resolved for _raw, resolved in internal_forward})
361
+ out.append(
362
+ f"Impact summary: {upstream_count} upstream dependencies, "
363
+ f"{downstream_count} downstream dependents"
364
+ )
365
+
366
+ # Git stats (optional)
367
+ if self.git:
368
+ out.append("")
369
+ git_lines = self.git.format_risk_block(path)
370
+ if git_lines:
371
+ out.extend(git_lines)
372
+
373
+ return "\n".join(out)
374
+
375
+ def query_hub_analysis(self, top_n: int = 10) -> str:
376
+ """--hub-analysis: Identify core nodes with high fan-in/fan-out."""
377
+ fan_in = {target: len(sources) for target, sources in self.internal_imports_reverse.items()}
378
+ fan_out = {source: len(targets) for source, targets in self.internal_imports_forward.items()}
379
+
380
+ out = ["=== Hub Analysis ===", ""]
381
+
382
+ # Top fan-in
383
+ top_fan_in = sorted(fan_in.items(), key=lambda x: x[1], reverse=True)[:top_n]
384
+ out.append("Top fan-in (most imported by others):")
385
+ if top_fan_in:
386
+ for i, (mid, count) in enumerate(top_fan_in, 1):
387
+ path = self.resolve_to_path(mid) or ""
388
+ out.append(f" {i}. {mid} — imported by {count} module(s) [{path}]")
389
+ else:
390
+ out.append(" (no internal import relationships found)")
391
+ out.append("")
392
+
393
+ # Top fan-out
394
+ top_fan_out = sorted(fan_out.items(), key=lambda x: x[1], reverse=True)[:top_n]
395
+ out.append("Top fan-out (imports most others):")
396
+ if top_fan_out:
397
+ for i, (mid, count) in enumerate(top_fan_out, 1):
398
+ path = self.resolve_to_path(mid) or ""
399
+ out.append(f" {i}. {mid} — imports {count} internal module(s) [{path}]")
400
+ else:
401
+ out.append(" (no internal import relationships found)")
402
+
403
+ return "\n".join(out)
404
+
405
+ def query_summary(self) -> str:
406
+ """--summary: Structural summary aggregated by top-level directories."""
407
+ # Aggregate by first-level or second-level directory
408
+ dir_stats: dict[str, dict] = defaultdict(
409
+ lambda: {'modules': 0, 'classes': 0, 'functions': 0, 'lines': 0,
410
+ 'class_names': [], 'import_dirs': set()}
411
+ )
412
+
413
+ # Aggregation granularity: use first 2 path levels
414
+ def _dir_key(path: str) -> str:
415
+ parts = path.split('/')
416
+ if len(parts) <= 2:
417
+ return parts[0] + '/'
418
+ return '/'.join(parts[:2]) + '/'
419
+
420
+ for node in self.nodes:
421
+ path = node.get('path', '')
422
+ if not path:
423
+ continue
424
+ dk = _dir_key(path)
425
+ ntype = node['type']
426
+ if ntype == 'Module':
427
+ dir_stats[dk]['modules'] += 1
428
+ dir_stats[dk]['lines'] += node.get('lines', 0)
429
+ elif ntype == 'Class':
430
+ dir_stats[dk]['classes'] += 1
431
+ dir_stats[dk]['class_names'].append(node['label'])
432
+ elif ntype == 'Function':
433
+ dir_stats[dk]['functions'] += 1
434
+
435
+ # Collect import source directories for each directory
436
+ for mid, targets in self.imports_forward.items():
437
+ src_node = self.nodes_by_id.get(mid)
438
+ if not src_node or src_node['type'] != 'Module':
439
+ continue
440
+ src_path = src_node.get('path', '')
441
+ if not src_path:
442
+ continue
443
+ src_dk = _dir_key(src_path)
444
+ for t in targets:
445
+ t_node = self.nodes_by_id.get(t)
446
+ if t_node and t_node.get('path'):
447
+ t_dk = _dir_key(t_node['path'])
448
+ if t_dk != src_dk:
449
+ dir_stats[src_dk]['import_dirs'].add(t_dk.rstrip('/'))
450
+
451
+ out = ["=== Directory Summary ===", ""]
452
+
453
+ for dk in sorted(dir_stats.keys()):
454
+ s = dir_stats[dk]
455
+ out.append(
456
+ f"{dk} ({s['modules']} modules, {s['classes']} classes, "
457
+ f"{s['functions']} functions, {s['lines']} lines)"
458
+ )
459
+ if s['class_names']:
460
+ # Show at most 8
461
+ names = s['class_names'][:8]
462
+ suffix = f" ... +{len(s['class_names']) - 8}" if len(s['class_names']) > 8 else ""
463
+ out.append(f" Key classes: {', '.join(names)}{suffix}")
464
+ import_dirs = sorted(s['import_dirs'])
465
+ if import_dirs:
466
+ out.append(f" Key imports from: {', '.join(import_dirs)}")
467
+ else:
468
+ out.append(f" Key imports from: (none / external only)")
469
+ out.append("")
470
+
471
+ if not dir_stats:
472
+ out.append("(no modules found in ast_nodes.json)")
473
+
474
+ return "\n".join(out)
475
+
476
+
477
+ def main() -> None:
478
+ parser = argparse.ArgumentParser(
479
+ description='Query AST graph from ast_nodes.json',
480
+ formatter_class=argparse.RawDescriptionHelpFormatter,
481
+ epilog="""
482
+ Examples:
483
+ %(prog)s ast_nodes.json --file src/server/handler.py
484
+ %(prog)s ast_nodes.json --who-imports src.server.handler
485
+ %(prog)s ast_nodes.json --impact src/server/handler.py
486
+ %(prog)s ast_nodes.json --hub-analysis --top 10
487
+ %(prog)s ast_nodes.json --summary
488
+ """,
489
+ )
490
+ parser.add_argument('ast_json', help='Path to ast_nodes.json')
491
+ parser.add_argument('--file', dest='file_query', help='Show structure and imports of a file')
492
+ parser.add_argument('--who-imports', dest='who_imports', help='Find modules that import the given module')
493
+ parser.add_argument('--impact', dest='impact_query', help='Show impact radius (deps + dependents)')
494
+ parser.add_argument('--hub-analysis', action='store_true', help='Show top fan-in/fan-out modules')
495
+ parser.add_argument('--summary', action='store_true', help='Show per-directory structural summary')
496
+ parser.add_argument('--top', type=int, default=10, help='Number of results for hub-analysis (default: 10)')
497
+ parser.add_argument('--git-stats', dest='git_stats_path', metavar='GIT_STATS_JSON',
498
+ help='Optional git_stats.json to enrich --file and --impact with risk/coupling data')
499
+
500
+ args = parser.parse_args()
501
+
502
+ # Ensure at least one query mode is provided
503
+ has_query = any([args.file_query, args.who_imports, args.impact_query,
504
+ args.hub_analysis, args.summary])
505
+ if not has_query:
506
+ parser.print_help()
507
+ sys.exit(1)
508
+
509
+ # Load JSON
510
+ ast_path = Path(args.ast_json)
511
+ if not ast_path.exists():
512
+ sys.stderr.write(f"[ERROR] File not found: {ast_path}\n")
513
+ sys.exit(1)
514
+
515
+ try:
516
+ raw_text = ast_path.read_text(encoding='utf-8')
517
+ # Skip possible mixed-in stderr lines (e.g. [WARNING]); parse from first '{'
518
+ json_start = raw_text.find('{')
519
+ if json_start < 0:
520
+ sys.stderr.write(f"[ERROR] No JSON object found in {ast_path}\n")
521
+ sys.exit(1)
522
+ data = json.loads(raw_text[json_start:])
523
+ except json.JSONDecodeError as e:
524
+ sys.stderr.write(f"[ERROR] Invalid JSON: {e}\n")
525
+ sys.exit(1)
526
+
527
+ # Optionally load git stats
528
+ git_stats: GitStats | None = None
529
+ if args.git_stats_path:
530
+ gs_path = Path(args.git_stats_path)
531
+ if not gs_path.exists():
532
+ sys.stderr.write(f"[WARNING] git_stats file not found: {gs_path}, ignoring\n")
533
+ else:
534
+ try:
535
+ gs_data = json.loads(gs_path.read_text(encoding='utf-8'))
536
+ git_stats = GitStats(gs_data)
537
+ except (json.JSONDecodeError, KeyError) as e:
538
+ sys.stderr.write(f"[WARNING] git_stats parse error: {e}, ignoring\n")
539
+
540
+ graph = ASTGraph(data, git_stats=git_stats)
541
+
542
+ # Execute query
543
+ if args.file_query:
544
+ print(graph.query_file(args.file_query))
545
+ elif args.who_imports:
546
+ print(graph.query_who_imports(args.who_imports))
547
+ elif args.impact_query:
548
+ print(graph.query_impact(args.impact_query))
549
+ elif args.hub_analysis:
550
+ print(graph.query_hub_analysis(args.top))
551
+ elif args.summary:
552
+ print(graph.query_summary())
553
+
554
+
555
+ if __name__ == '__main__':
556
+ main()
@@ -0,0 +1,6 @@
1
+ # nexus-mapper scripts dependencies
2
+ # For standalone use or distribution installation
3
+ # When used inside the Nexus project, dependencies are already covered by the poetry environment (tree-sitter ^0.25.2 + tree-sitter-language-pack)
4
+
5
+ tree-sitter>=0.22.0
6
+ tree-sitter-language-pack
@@ -0,0 +1,114 @@
1
+ ---
2
+ name: nexus-query
3
+ description: "Precise, instant code structure queries for active development — answer 'who depends on this interface before I refactor it', 'how many modules break if I change this', 'what is the real impact radius of this feature change', 'which module is the true high-coupling hotspot in this legacy codebase'. Essential before any interface change, continuous refactoring task, sprint work estimation, or when navigating unfamiliar or large legacy codebases. Requires Python 3.10+ and shell. Use nexus-mapper instead when building a full .nexus-map/ knowledge base."
4
+ ---
5
+
6
+ # nexus-query — Precise Code Structure Queries
7
+
8
+
9
+ ## When to Use
10
+
11
+ | Scenario | Use |
12
+ |------|:----:|
13
+ | "What classes/methods are in this file, and what does it depend on?" | Yes |
14
+ | "If I change this interface/module, which files are affected?" | Yes |
15
+ | "What is the impact radius of this change?" | Yes |
16
+ | "Which node is the true core dependency in this project?" | Yes |
17
+ | "How is the project roughly partitioned?" | Yes |
18
+ | User wants to generate full `.nexus-map/` knowledge base | No -> use nexus-mapper |
19
+ | Runtime has no shell execution capability | No |
20
+ | Host has no local Python 3.10+ | No |
21
+
22
+ ---
23
+
24
+ ## Prerequisite: Ensure ast_nodes.json Exists
25
+
26
+ ```
27
+ Before querying -> check if ast_nodes.json exists
28
+ ├── Exists (.nexus-map/raw/ast_nodes.json or user-provided path) -> query directly
29
+ └── Missing -> run extract_ast.py to generate -> then query
30
+ ```
31
+
32
+ ```bash
33
+ # Default paths (compatible with nexus-mapper .nexus-map/, interchangeable)
34
+ AST_JSON="$repo_path/.nexus-map/raw/ast_nodes.json"
35
+ GIT_JSON="$repo_path/.nexus-map/raw/git_stats.json" # optional
36
+
37
+ # If ast_nodes.json is missing, create dir first, then generate (usually seconds)
38
+ mkdir -p "$repo_path/.nexus-map/raw"
39
+ python $SKILL_DIR/scripts/extract_ast.py $repo_path > $AST_JSON
40
+
41
+ # If git risk data needed (optional, only when .git exists)
42
+ python $SKILL_DIR/scripts/git_detective.py $repo_path --days 90 > $GIT_JSON
43
+ ```
44
+
45
+ > `$SKILL_DIR` is this skill's install path (`.agent/skills/nexus-query` or standalone repo path).
46
+
47
+ **Dependency install (first use)**:
48
+ ```bash
49
+ pip install -r $SKILL_DIR/scripts/requirements.txt
50
+ ```
51
+
52
+ ---
53
+
54
+ ## Five Query Modes
55
+
56
+ ```bash
57
+ # File skeleton: classes, methods, line numbers, import list
58
+ python $SKILL_DIR/scripts/query_graph.py $AST_JSON --file <path>
59
+ python $SKILL_DIR/scripts/query_graph.py $AST_JSON --file <path> --git-stats $GIT_JSON
60
+
61
+ # Reverse dependency: who imports this module (separates source and test files)
62
+ python $SKILL_DIR/scripts/query_graph.py $AST_JSON --who-imports <module_or_path>
63
+
64
+ # Impact radius: upstream dependencies + downstream dependents (X upstream, Y downstream)
65
+ python $SKILL_DIR/scripts/query_graph.py $AST_JSON --impact <path>
66
+ python $SKILL_DIR/scripts/query_graph.py $AST_JSON --impact <path> --git-stats $GIT_JSON
67
+
68
+ # Core repo nodes: rank by fan-in (most referenced) and fan-out (references most)
69
+ python $SKILL_DIR/scripts/query_graph.py $AST_JSON --hub-analysis [--top N]
70
+
71
+ # Aggregate structural summary by top-level directory
72
+ python $SKILL_DIR/scripts/query_graph.py $AST_JSON --summary
73
+ ```
74
+
75
+ ### Core Value of Each Mode
76
+
77
+ | Mode | One-line value | Typical trigger |
78
+ |------|-----------|------------|
79
+ | `--file` | Understand file skeleton without full source reading, down to exact lines | Before taking over large module; narrow read scope in bug investigation |
80
+ | `--who-imports` | Pre-change "blast list" of all callers | Must run before deleting funcs/changing signatures/renaming classes |
81
+ | `--impact` | `0 upstream, 24 downstream` shows scope at a glance | Sprint estimation; decide local surgery vs global surgery |
82
+ | `--hub-analysis` | Find true high-coupling core without guessing by directory names | Architecture review; technical debt prioritization |
83
+ | `--summary` | Build global layered understanding in 5 seconds, more objective than README | First contact with project; identify cyclic-risk regions |
84
+
85
+ ---
86
+
87
+ ## Scenario Quick Reference
88
+
89
+ | Your question now | Use |
90
+ |-------------|--------|
91
+ | What classes/methods are in this file, and at what lines | `--file` |
92
+ | If I change this interface/delete func, which files must change | `--who-imports` |
93
+ | How many modules are ultimately affected by this change | `--impact` |
94
+ | How risky is this change (with git heat) | `--impact --git-stats` |
95
+ | Which module is true high-coupling core | `--hub-analysis` |
96
+ | Overall module distribution and layering | `--summary` |
97
+ | Continuous refactoring, need impact chain after one change | `--who-imports` -> `--impact` |
98
+ | Estimate workload for technical debt refactor | `--hub-analysis` -> `--impact` |
99
+
100
+ ---
101
+
102
+ ## Execution Rules
103
+
104
+ **Rule 1: Skeleton before query**
105
+ Before using `--impact` or `--who-imports` on a module, preferably run `--file` first to understand responsibilities and imports, reducing misinterpretation of query results.
106
+
107
+ **Rule 2: git-stats is optional bonus, not hard blocker**
108
+ If no `.git` or insufficient history, skip `git_detective.py` and query with AST only.
109
+
110
+ **Rule 3: Path matching is flexible but verify**
111
+ Path fragment matching is supported (e.g., `vision.py` can match `src/core/vision.py`). If result is `[NOT FOUND]`, run `--summary` first to confirm module path format in repo, then query again.
112
+
113
+ **Rule 4: Present results directly; let numbers speak**
114
+ `--impact` output `X upstream, Y downstream` is objective. Report it directly; do not replace with vague wording like "possibly large impact".