@haaaiawd/anws 2.3.0 → 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.
- package/README.md +1 -1
- package/bin/cli.js +52 -22
- package/lib/diff.js +5 -2
- package/lib/init.js +217 -96
- package/lib/install-state.js +18 -3
- package/lib/manifest.js +376 -79
- package/lib/prompt.js +68 -0
- package/lib/resources/index.js +36 -2
- package/lib/update.js +12 -6
- package/package.json +48 -47
- package/templates/.agents/skills/anws-system/SKILL.md +108 -108
- package/templates/.agents/skills/code-reviewer/SKILL.md +170 -115
- package/templates/.agents/skills/concept-modeler/SKILL.md +230 -179
- package/templates/.agents/skills/craft-authoring/SKILL.md +186 -183
- package/templates/.agents/skills/craft-authoring/references/BUNDLE_POLICY.md +61 -0
- package/templates/.agents/skills/design-reviewer/SKILL.md +265 -190
- package/templates/.agents/skills/e2e-testing-guide/SKILL.md +246 -135
- package/templates/.agents/skills/nexus-mapper/SKILL.md +321 -321
- package/templates/.agents/skills/output-contract/SKILL.md +37 -0
- package/templates/.agents/skills/report-template/SKILL.md +92 -92
- package/templates/.agents/skills/sequential-thinking/SKILL.md +222 -225
- package/templates/.agents/skills/spec-writer/SKILL.md +75 -30
- package/templates/.agents/skills/system-architect/SKILL.md +538 -678
- package/templates/.agents/skills/system-designer/SKILL.md +601 -601
- package/templates/.agents/skills/task-planner/SKILL.md +1 -2
- package/templates/.agents/skills/task-reviewer/SKILL.md +428 -388
- package/templates/.agents/skills/tech-evaluator/SKILL.md +252 -144
- package/templates/.agents/workflows/blueprint.md +157 -69
- package/templates/.agents/workflows/challenge.md +331 -497
- package/templates/.agents/workflows/change.md +182 -339
- package/templates/.agents/workflows/craft.md +159 -197
- package/templates/.agents/workflows/design-system.md +202 -674
- package/templates/.agents/workflows/explore.md +187 -399
- package/templates/.agents/workflows/forge.md +650 -609
- package/templates/.agents/workflows/genesis.md +439 -351
- package/templates/.agents/workflows/probe.md +219 -241
- package/templates/.agents/workflows/quickstart.md +302 -123
- package/templates/.agents/workflows/upgrade.md +145 -182
- package/templates_en/.agents/skills/anws-system/SKILL.md +108 -0
- package/templates_en/.agents/skills/code-reviewer/SKILL.md +170 -0
- package/templates_en/.agents/skills/concept-modeler/SKILL.md +230 -0
- package/templates_en/.agents/skills/craft-authoring/SKILL.md +179 -0
- package/templates_en/.agents/skills/craft-authoring/references/BUNDLE_POLICY.md +60 -0
- package/templates_en/.agents/skills/craft-authoring/references/PROMPT_QUALITY_RUBRIC.md +92 -0
- package/templates_en/.agents/skills/craft-authoring/references/SCORECARD_TEMPLATE.md +52 -0
- package/templates_en/.agents/skills/design-reviewer/SKILL.md +265 -0
- package/templates_en/.agents/skills/e2e-testing-guide/SKILL.md +246 -0
- package/templates_en/.agents/skills/nexus-mapper/SKILL.md +306 -0
- package/templates_en/.agents/skills/nexus-mapper/references/language-customization.md +167 -0
- package/templates_en/.agents/skills/nexus-mapper/references/output-schema.md +311 -0
- package/templates_en/.agents/skills/nexus-mapper/references/probe-protocol.md +246 -0
- package/templates_en/.agents/skills/nexus-mapper/scripts/extract_ast.py +706 -0
- package/templates_en/.agents/skills/nexus-mapper/scripts/git_detective.py +194 -0
- package/templates_en/.agents/skills/nexus-mapper/scripts/languages.json +127 -0
- package/templates_en/.agents/skills/nexus-mapper/scripts/query_graph.py +556 -0
- package/templates_en/.agents/skills/nexus-mapper/scripts/requirements.txt +6 -0
- package/templates_en/.agents/skills/nexus-query/SKILL.md +114 -0
- package/templates_en/.agents/skills/nexus-query/scripts/extract_ast.py +706 -0
- package/templates_en/.agents/skills/nexus-query/scripts/git_detective.py +194 -0
- package/templates_en/.agents/skills/nexus-query/scripts/languages.json +127 -0
- package/templates_en/.agents/skills/nexus-query/scripts/query_graph.py +556 -0
- package/templates_en/.agents/skills/nexus-query/scripts/requirements.txt +6 -0
- package/templates_en/.agents/skills/output-contract/SKILL.md +37 -0
- package/templates_en/.agents/skills/report-template/SKILL.md +85 -0
- package/templates_en/.agents/skills/report-template/references/REPORT_TEMPLATE.md +100 -0
- package/templates_en/.agents/skills/runtime-inspector/SKILL.md +101 -0
- package/templates_en/.agents/skills/sequential-thinking/SKILL.md +214 -0
- package/templates_en/.agents/skills/spec-writer/SKILL.md +153 -0
- package/templates_en/.agents/skills/spec-writer/references/prd_template.md +177 -0
- package/templates_en/.agents/skills/system-architect/SKILL.md +538 -0
- package/templates_en/.agents/skills/system-architect/references/rfc_template.md +59 -0
- package/templates_en/.agents/skills/system-designer/SKILL.md +534 -0
- package/templates_en/.agents/skills/system-designer/references/system-design-detail-template.md +187 -0
- package/templates_en/.agents/skills/system-designer/references/system-design-template.md +605 -0
- package/templates_en/.agents/skills/task-planner/SKILL.md +251 -0
- package/templates_en/.agents/skills/task-planner/references/TASK_TEMPLATE_05A.md +109 -0
- package/templates_en/.agents/skills/task-planner/references/TASK_TEMPLATE_05B.md +176 -0
- package/templates_en/.agents/skills/task-reviewer/SKILL.md +428 -0
- package/templates_en/.agents/skills/tech-evaluator/SKILL.md +252 -0
- package/templates_en/.agents/skills/tech-evaluator/references/ADR_TEMPLATE.md +78 -0
- package/templates_en/.agents/workflows/blueprint.md +200 -0
- package/templates_en/.agents/workflows/challenge.md +331 -0
- package/templates_en/.agents/workflows/change.md +182 -0
- package/templates_en/.agents/workflows/craft.md +159 -0
- package/templates_en/.agents/workflows/design-system.md +202 -0
- package/templates_en/.agents/workflows/explore.md +187 -0
- package/templates_en/.agents/workflows/forge.md +651 -0
- package/templates_en/.agents/workflows/genesis.md +439 -0
- package/templates_en/.agents/workflows/probe.md +219 -0
- package/templates_en/.agents/workflows/quickstart.md +303 -0
- package/templates_en/.agents/workflows/upgrade.md +145 -0
- 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".
|