agent-wiki-cli 0.3.28__py3-none-any.whl
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.
- agent_wiki_cli-0.3.28.dist-info/METADATA +425 -0
- agent_wiki_cli-0.3.28.dist-info/RECORD +47 -0
- agent_wiki_cli-0.3.28.dist-info/WHEEL +5 -0
- agent_wiki_cli-0.3.28.dist-info/entry_points.txt +2 -0
- agent_wiki_cli-0.3.28.dist-info/licenses/LICENSE +21 -0
- agent_wiki_cli-0.3.28.dist-info/top_level.txt +1 -0
- llm_wiki_cli/__init__.py +7 -0
- llm_wiki_cli/cli.py +231 -0
- llm_wiki_cli/commands/__init__.py +1 -0
- llm_wiki_cli/commands/bootstrap_cmd.py +1072 -0
- llm_wiki_cli/commands/bump_cmd.py +55 -0
- llm_wiki_cli/commands/context_cmd.py +427 -0
- llm_wiki_cli/commands/extract_cmd.py +745 -0
- llm_wiki_cli/commands/generate_prompt_cmd.py +89 -0
- llm_wiki_cli/commands/hook_cmd.py +161 -0
- llm_wiki_cli/commands/init_cmd.py +92 -0
- llm_wiki_cli/commands/lint_cmd.py +294 -0
- llm_wiki_cli/commands/migrate_cmd.py +892 -0
- llm_wiki_cli/commands/release_cmd.py +163 -0
- llm_wiki_cli/commands/status_cmd.py +70 -0
- llm_wiki_cli/commands/sync_cmd.py +521 -0
- llm_wiki_cli/commands/trigger_cmd.py +205 -0
- llm_wiki_cli/commands/uninstall_cmd.py +221 -0
- llm_wiki_cli/commands/upgrade_cmd.py +196 -0
- llm_wiki_cli/config.py +318 -0
- llm_wiki_cli/extractors/__init__.py +46 -0
- llm_wiki_cli/extractors/common.py +90 -0
- llm_wiki_cli/extractors/go_extractor.py +143 -0
- llm_wiki_cli/extractors/go_scripts/go.mod +3 -0
- llm_wiki_cli/extractors/go_scripts/main.go +668 -0
- llm_wiki_cli/extractors/python_extractor.py +346 -0
- llm_wiki_cli/extractors/rust_extractor.py +143 -0
- llm_wiki_cli/extractors/rust_scripts/Cargo.lock +110 -0
- llm_wiki_cli/extractors/rust_scripts/Cargo.toml +11 -0
- llm_wiki_cli/extractors/rust_scripts/src/main.rs +803 -0
- llm_wiki_cli/extractors/ts_extractor.py +206 -0
- llm_wiki_cli/extractors/ts_scripts/extract.js +485 -0
- llm_wiki_cli/extractors/ts_scripts/package.json +10 -0
- llm_wiki_cli/services/__init__.py +0 -0
- llm_wiki_cli/services/circuit_breaker.py +79 -0
- llm_wiki_cli/services/io.py +47 -0
- llm_wiki_cli/services/lockfile.py +60 -0
- llm_wiki_cli/services/packages.py +173 -0
- llm_wiki_cli/services/paths.py +31 -0
- llm_wiki_cli/services/schema.py +214 -0
- llm_wiki_cli/services/secure_file.py +22 -0
- llm_wiki_cli/services/versioning.py +193 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from ..services.versioning import (
|
|
6
|
+
find_version_file,
|
|
7
|
+
read_version,
|
|
8
|
+
write_version,
|
|
9
|
+
bump_patch,
|
|
10
|
+
bump_minor,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run(args):
|
|
15
|
+
root = getattr(args, "root", ".")
|
|
16
|
+
version_file = find_version_file(root)
|
|
17
|
+
|
|
18
|
+
if version_file is None:
|
|
19
|
+
print("Error: No version file found (pyproject.toml, setup.cfg, package.json, VERSION).")
|
|
20
|
+
sys.exit(1)
|
|
21
|
+
|
|
22
|
+
current = read_version(version_file)
|
|
23
|
+
if current is None:
|
|
24
|
+
print(f"Error: Could not parse version from {version_file}")
|
|
25
|
+
sys.exit(1)
|
|
26
|
+
|
|
27
|
+
if args.bump_type == "patch":
|
|
28
|
+
new_version = bump_patch(current)
|
|
29
|
+
elif args.bump_type == "minor":
|
|
30
|
+
new_version = bump_minor(current)
|
|
31
|
+
else:
|
|
32
|
+
print(f"Error: Unknown bump type '{args.bump_type}'")
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
|
|
35
|
+
write_version(version_file, new_version)
|
|
36
|
+
print(f"{current} -> {new_version} ({version_file})")
|
|
37
|
+
|
|
38
|
+
# If --stage is set, git-add the version file so it's included in the current commit
|
|
39
|
+
if getattr(args, "stage", False):
|
|
40
|
+
try:
|
|
41
|
+
subprocess.run(
|
|
42
|
+
["git", "add", str(version_file)],
|
|
43
|
+
check=True,
|
|
44
|
+
capture_output=True,
|
|
45
|
+
text=True,
|
|
46
|
+
)
|
|
47
|
+
except FileNotFoundError:
|
|
48
|
+
print("Error: git not found; could not stage version file.", file=sys.stderr)
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
except subprocess.CalledProcessError as exc:
|
|
51
|
+
detail = (exc.stderr or exc.stdout or "").strip()
|
|
52
|
+
print(f"Error: git add failed for {version_file}.", file=sys.stderr)
|
|
53
|
+
if detail:
|
|
54
|
+
print(detail, file=sys.stderr)
|
|
55
|
+
sys.exit(1)
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""Structured context budgeting — return priority-ranked, token-budgeted
|
|
2
|
+
codebase context for LLM agents.
|
|
3
|
+
|
|
4
|
+
Priority tiers:
|
|
5
|
+
|
|
6
|
+
- **high**: files changed in the last commit → full deep inventory detail
|
|
7
|
+
- **medium**: 1-hop import neighbors of changed files → slim detail
|
|
8
|
+
- **low**: everything else → names only
|
|
9
|
+
|
|
10
|
+
Usage::
|
|
11
|
+
|
|
12
|
+
llm-wiki context --budget 32000
|
|
13
|
+
llm-wiki context --budget 8000 --format markdown
|
|
14
|
+
llm-wiki context --budget 32000 --focus all
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .extract_cmd import _git_changed_files, get_inventory_result, print_inventory_failures
|
|
24
|
+
from ..config import validate_path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── Token estimation ──────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _estimate_tokens(text: str) -> int:
|
|
31
|
+
"""Approximate token count using the ~4 chars/token heuristic.
|
|
32
|
+
|
|
33
|
+
No external dependency — good enough for budgeting (within ~10-20%).
|
|
34
|
+
"""
|
|
35
|
+
return len(text) // 4
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── Import graph ──────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _build_import_graph(inventory: dict) -> dict[str, set[str]]:
|
|
42
|
+
"""Build a bidirectional import adjacency map from a deep inventory.
|
|
43
|
+
|
|
44
|
+
For each file A that imports a symbol from file B (both present in *inventory*),
|
|
45
|
+
adds edges A→B and B→A. External / stdlib imports are silently skipped.
|
|
46
|
+
|
|
47
|
+
Returns ``{filepath: {neighbor_filepaths}}`` with every key appearing
|
|
48
|
+
even if it has no neighbors.
|
|
49
|
+
"""
|
|
50
|
+
# Build a lookup: dotted module path → inventory filepath
|
|
51
|
+
# e.g. "llm_wiki_cli.config" → "src/llm_wiki_cli/config.py"
|
|
52
|
+
module_to_file: dict[str, str] = {}
|
|
53
|
+
for filepath in inventory:
|
|
54
|
+
# Convert filepath to dotted module path
|
|
55
|
+
mod = _filepath_to_module(filepath)
|
|
56
|
+
if mod:
|
|
57
|
+
module_to_file[mod] = filepath
|
|
58
|
+
|
|
59
|
+
graph: dict[str, set[str]] = {fp: set() for fp in inventory}
|
|
60
|
+
|
|
61
|
+
for filepath, file_data in inventory.items():
|
|
62
|
+
for imp in file_data.get("imports", []):
|
|
63
|
+
imp_module = imp.get("module", "")
|
|
64
|
+
if not imp_module:
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# Try exact match, then parent module (for ``from pkg.mod import X``)
|
|
68
|
+
target = module_to_file.get(imp_module)
|
|
69
|
+
if target is None:
|
|
70
|
+
# Try progressively shorter prefixes
|
|
71
|
+
parts = imp_module.split(".")
|
|
72
|
+
for i in range(len(parts) - 1, 0, -1):
|
|
73
|
+
candidate = ".".join(parts[:i])
|
|
74
|
+
target = module_to_file.get(candidate)
|
|
75
|
+
if target is not None:
|
|
76
|
+
break
|
|
77
|
+
|
|
78
|
+
if target is not None and target != filepath:
|
|
79
|
+
graph[filepath].add(target)
|
|
80
|
+
graph[target].add(filepath)
|
|
81
|
+
|
|
82
|
+
return graph
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _filepath_to_module(filepath: str) -> str | None:
|
|
86
|
+
"""Convert ``"src/llm_wiki_cli/config.py"`` → ``"llm_wiki_cli.config"``.
|
|
87
|
+
|
|
88
|
+
Strips a leading ``src/`` directory and the ``.py`` suffix, then
|
|
89
|
+
converts path separators to dots. Returns None for non-Python files.
|
|
90
|
+
"""
|
|
91
|
+
p = Path(filepath)
|
|
92
|
+
if p.suffix != ".py":
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
parts = list(p.with_suffix("").parts)
|
|
96
|
+
|
|
97
|
+
# Strip leading "src" directory (common in Python projects)
|
|
98
|
+
if parts and parts[0] == "src":
|
|
99
|
+
parts = parts[1:]
|
|
100
|
+
|
|
101
|
+
# Strip __init__ (package init files map to the package itself)
|
|
102
|
+
if parts and parts[-1] == "__init__":
|
|
103
|
+
parts = parts[:-1]
|
|
104
|
+
|
|
105
|
+
return ".".join(parts) if parts else None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ── Classification ────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _classify_files(
|
|
112
|
+
all_files: list[str],
|
|
113
|
+
changed: list[str] | None,
|
|
114
|
+
import_graph: dict[str, set[str]],
|
|
115
|
+
focus: str,
|
|
116
|
+
) -> dict[str, str]:
|
|
117
|
+
"""Assign a priority tier to every file in the inventory.
|
|
118
|
+
|
|
119
|
+
Returns ``{filepath: "high"|"medium"|"low"}``.
|
|
120
|
+
"""
|
|
121
|
+
if focus == "all":
|
|
122
|
+
return {fp: "high" for fp in all_files}
|
|
123
|
+
|
|
124
|
+
classification: dict[str, str] = {}
|
|
125
|
+
changed_set = set(changed) if changed else set()
|
|
126
|
+
|
|
127
|
+
# High: changed files
|
|
128
|
+
for fp in all_files:
|
|
129
|
+
if fp in changed_set:
|
|
130
|
+
classification[fp] = "high"
|
|
131
|
+
|
|
132
|
+
# Medium: 1-hop neighbors of changed files
|
|
133
|
+
for fp in changed_set:
|
|
134
|
+
for neighbor in import_graph.get(fp, set()):
|
|
135
|
+
if neighbor not in classification:
|
|
136
|
+
classification[neighbor] = "medium"
|
|
137
|
+
|
|
138
|
+
# Low: everything else
|
|
139
|
+
for fp in all_files:
|
|
140
|
+
if fp not in classification:
|
|
141
|
+
classification[fp] = "low"
|
|
142
|
+
|
|
143
|
+
return classification
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ── Detail-level serializers ──────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _deep_entry(file_data: dict) -> dict:
|
|
150
|
+
"""Full detail: classes with methods, params, docstrings, imports."""
|
|
151
|
+
return {k: v for k, v in file_data.items() if k != "language"}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _slim_entry(file_data: dict) -> dict:
|
|
155
|
+
"""Slim detail: class names/bases/line, function names/lines."""
|
|
156
|
+
return {
|
|
157
|
+
"classes": [
|
|
158
|
+
{"name": c["name"], "bases": c.get("bases", []), "line": c.get("line")}
|
|
159
|
+
for c in file_data.get("classes", [])
|
|
160
|
+
],
|
|
161
|
+
"functions": [
|
|
162
|
+
{"name": f["name"], "line": f.get("line")}
|
|
163
|
+
for f in file_data.get("functions", [])
|
|
164
|
+
],
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _summary_entry(file_data: dict) -> dict:
|
|
169
|
+
"""Names only: lists of class names and function names."""
|
|
170
|
+
entry: dict[str, list[str]] = {}
|
|
171
|
+
cls_names = [c["name"] for c in file_data.get("classes", [])]
|
|
172
|
+
fn_names = [f["name"] for f in file_data.get("functions", [])]
|
|
173
|
+
if cls_names:
|
|
174
|
+
entry["classes"] = cls_names
|
|
175
|
+
if fn_names:
|
|
176
|
+
entry["functions"] = fn_names
|
|
177
|
+
return entry
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
_DETAIL_SERIALIZERS = {
|
|
181
|
+
"deep": _deep_entry,
|
|
182
|
+
"slim": _slim_entry,
|
|
183
|
+
"summary": _summary_entry,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
_PREFERRED_DETAILS = {
|
|
187
|
+
"high": "deep",
|
|
188
|
+
"medium": "slim",
|
|
189
|
+
"low": "summary",
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
_DETAIL_FALLBACKS = {
|
|
193
|
+
"high": ("deep", "slim", "summary"),
|
|
194
|
+
"medium": ("slim", "summary"),
|
|
195
|
+
"low": ("summary",),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ── Budgeting ─────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _build_context_payload(
|
|
203
|
+
inventory: dict,
|
|
204
|
+
classification: dict[str, str],
|
|
205
|
+
budget: int,
|
|
206
|
+
) -> dict:
|
|
207
|
+
"""Build a token-budgeted context payload.
|
|
208
|
+
|
|
209
|
+
Greedy allocation: high-priority files first, then medium, then low.
|
|
210
|
+
Files are downgraded to smaller detail levels before they are omitted.
|
|
211
|
+
|
|
212
|
+
Returns::
|
|
213
|
+
|
|
214
|
+
{
|
|
215
|
+
"budget": <requested>,
|
|
216
|
+
"used": <estimated tokens>,
|
|
217
|
+
"truncated": <whether files were downgraded or omitted>,
|
|
218
|
+
"omitted_files": ["path/too_large.py"],
|
|
219
|
+
"downgraded_files": {"path/file.py": "summary"},
|
|
220
|
+
"files": {
|
|
221
|
+
"path/file.py": {"priority": "high", "detail": "deep", ...detail...},
|
|
222
|
+
...
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
"""
|
|
226
|
+
files_out: dict[str, dict] = {}
|
|
227
|
+
omitted_files: list[str] = []
|
|
228
|
+
downgraded_files: dict[str, str] = {}
|
|
229
|
+
used = 0
|
|
230
|
+
|
|
231
|
+
# Process tiers in priority order
|
|
232
|
+
for tier in ("high", "medium", "low"):
|
|
233
|
+
tier_files = sorted(
|
|
234
|
+
fp for fp, pri in classification.items() if pri == tier
|
|
235
|
+
)
|
|
236
|
+
for fp in tier_files:
|
|
237
|
+
file_data = inventory.get(fp, {})
|
|
238
|
+
selected_entry: dict | None = None
|
|
239
|
+
selected_tokens = 0
|
|
240
|
+
selected_detail = ""
|
|
241
|
+
|
|
242
|
+
for detail in _DETAIL_FALLBACKS[tier]:
|
|
243
|
+
entry = _build_entry(file_data, tier, detail)
|
|
244
|
+
entry_tokens = _entry_tokens(fp, entry)
|
|
245
|
+
if used + entry_tokens <= budget:
|
|
246
|
+
selected_entry = entry
|
|
247
|
+
selected_tokens = entry_tokens
|
|
248
|
+
selected_detail = detail
|
|
249
|
+
break
|
|
250
|
+
|
|
251
|
+
if selected_entry is None:
|
|
252
|
+
omitted_files.append(fp)
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
files_out[fp] = selected_entry
|
|
256
|
+
used += selected_tokens
|
|
257
|
+
if selected_detail != _PREFERRED_DETAILS[tier]:
|
|
258
|
+
downgraded_files[fp] = selected_detail
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
"budget": budget,
|
|
262
|
+
"used": used,
|
|
263
|
+
"truncated": bool(omitted_files or downgraded_files),
|
|
264
|
+
"omitted_files": omitted_files,
|
|
265
|
+
"downgraded_files": downgraded_files,
|
|
266
|
+
"files": files_out,
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _build_entry(file_data: dict, priority: str, detail: str) -> dict:
|
|
271
|
+
"""Serialize one file at a specific detail level."""
|
|
272
|
+
entry = _DETAIL_SERIALIZERS[detail](file_data)
|
|
273
|
+
entry["priority"] = priority
|
|
274
|
+
entry["detail"] = detail
|
|
275
|
+
return entry
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _entry_tokens(filepath: str, entry: dict) -> int:
|
|
279
|
+
return _estimate_tokens(json.dumps({filepath: entry}))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# ── Markdown renderer ─────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _render_markdown(payload: dict) -> str:
|
|
286
|
+
"""Render the context payload as agent-friendly markdown."""
|
|
287
|
+
lines: list[str] = []
|
|
288
|
+
lines.append(f"# Context Budget: {payload['used']} / {payload['budget']} tokens")
|
|
289
|
+
lines.append("")
|
|
290
|
+
|
|
291
|
+
tier_labels = {
|
|
292
|
+
"high": "Changed Files (High Priority)",
|
|
293
|
+
"medium": "Neighbor Files (Medium Priority)",
|
|
294
|
+
"low": "Index (Low Priority)",
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
for tier in ("high", "medium", "low"):
|
|
298
|
+
tier_files = {
|
|
299
|
+
fp: data for fp, data in payload["files"].items()
|
|
300
|
+
if data.get("priority") == tier
|
|
301
|
+
}
|
|
302
|
+
if not tier_files:
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
lines.append(f"## {tier_labels[tier]}")
|
|
306
|
+
lines.append("")
|
|
307
|
+
|
|
308
|
+
for fp, data in sorted(tier_files.items()):
|
|
309
|
+
lines.append(f"### `{fp}`")
|
|
310
|
+
lines.append("")
|
|
311
|
+
|
|
312
|
+
for cls in data.get("classes", []):
|
|
313
|
+
if isinstance(cls, str):
|
|
314
|
+
lines.append(f"- class **{cls}**")
|
|
315
|
+
else:
|
|
316
|
+
bases = ", ".join(cls.get("bases", []))
|
|
317
|
+
base_str = f"({bases})" if bases else ""
|
|
318
|
+
lines.append(f"- class **{cls['name']}**{base_str}")
|
|
319
|
+
if cls.get("docstring"):
|
|
320
|
+
lines.append(f" > {cls['docstring'].splitlines()[0]}")
|
|
321
|
+
for method in cls.get("methods", []):
|
|
322
|
+
params = ", ".join(
|
|
323
|
+
p.get("name", "") for p in method.get("params", [])
|
|
324
|
+
)
|
|
325
|
+
ret = f" → {method['return_type']}" if method.get("return_type") else ""
|
|
326
|
+
async_prefix = "async " if method.get("is_async") else ""
|
|
327
|
+
lines.append(f" - {async_prefix}`{method['name']}({params})`{ret}")
|
|
328
|
+
|
|
329
|
+
for fn in data.get("functions", []):
|
|
330
|
+
if isinstance(fn, str):
|
|
331
|
+
lines.append(f"- def **{fn}**()")
|
|
332
|
+
else:
|
|
333
|
+
params = ", ".join(
|
|
334
|
+
p.get("name", "") for p in fn.get("params", [])
|
|
335
|
+
)
|
|
336
|
+
ret = f" → {fn['return_type']}" if fn.get("return_type") else ""
|
|
337
|
+
async_prefix = "async " if fn.get("is_async") else ""
|
|
338
|
+
lines.append(f"- {async_prefix}def **{fn['name']}**({params}){ret}")
|
|
339
|
+
if fn.get("docstring"):
|
|
340
|
+
lines.append(f" > {fn['docstring'].splitlines()[0]}")
|
|
341
|
+
|
|
342
|
+
lines.append("")
|
|
343
|
+
|
|
344
|
+
omitted = payload.get("omitted_files", [])
|
|
345
|
+
if omitted:
|
|
346
|
+
lines.append("## Omitted Files")
|
|
347
|
+
lines.append("")
|
|
348
|
+
for fp in omitted:
|
|
349
|
+
lines.append(f"- `{fp}`")
|
|
350
|
+
lines.append("")
|
|
351
|
+
|
|
352
|
+
return "\n".join(lines)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# ── CLI entry point ───────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def run(args) -> None:
|
|
359
|
+
src_dir: str = getattr(args, "src_dir", ".")
|
|
360
|
+
budget: int = getattr(args, "budget", 32000)
|
|
361
|
+
fmt: str = getattr(args, "format", "json")
|
|
362
|
+
focus: str = getattr(args, "focus", "changed")
|
|
363
|
+
|
|
364
|
+
validate_path(src_dir, "--src-dir")
|
|
365
|
+
|
|
366
|
+
# 1. Get full deep inventory (imports needed for graph building)
|
|
367
|
+
inventory_result = get_inventory_result(src_dir, deep=True)
|
|
368
|
+
if inventory_result.failed:
|
|
369
|
+
print_inventory_failures(inventory_result)
|
|
370
|
+
sys.exit(1)
|
|
371
|
+
inventory = inventory_result.inventory
|
|
372
|
+
|
|
373
|
+
if not inventory:
|
|
374
|
+
print("{}" if fmt == "json" else "No source files found.")
|
|
375
|
+
return
|
|
376
|
+
|
|
377
|
+
# 2. Determine changed files
|
|
378
|
+
changed: list[str] | None = None
|
|
379
|
+
if focus == "changed":
|
|
380
|
+
changed = _git_changed_files(src_dir)
|
|
381
|
+
if changed is None:
|
|
382
|
+
print("Warning: Could not get changed files from git. Treating all files as high priority.",
|
|
383
|
+
file=sys.stderr, flush=True)
|
|
384
|
+
focus = "all"
|
|
385
|
+
elif not changed:
|
|
386
|
+
print("Warning: No files changed in the last commit. Treating all files as high priority.",
|
|
387
|
+
file=sys.stderr, flush=True)
|
|
388
|
+
focus = "all"
|
|
389
|
+
else:
|
|
390
|
+
# Normalise changed paths to match inventory keys
|
|
391
|
+
changed = _normalise_changed_paths(changed, inventory)
|
|
392
|
+
|
|
393
|
+
# 3. Build import graph and classify files
|
|
394
|
+
import_graph = _build_import_graph(inventory)
|
|
395
|
+
classification = _classify_files(list(inventory.keys()), changed, import_graph, focus)
|
|
396
|
+
|
|
397
|
+
# 4. Build budgeted payload
|
|
398
|
+
payload = _build_context_payload(inventory, classification, budget)
|
|
399
|
+
|
|
400
|
+
# 5. Output
|
|
401
|
+
if fmt == "markdown":
|
|
402
|
+
print(_render_markdown(payload))
|
|
403
|
+
else:
|
|
404
|
+
print(json.dumps(payload, indent=2))
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _normalise_changed_paths(
|
|
408
|
+
changed: list[str], inventory: dict
|
|
409
|
+
) -> list[str]:
|
|
410
|
+
"""Match git-reported changed paths to inventory keys.
|
|
411
|
+
|
|
412
|
+
Git paths are relative to the repo root; inventory keys may include
|
|
413
|
+
a leading ``src/`` or similar prefix. This function tries exact
|
|
414
|
+
match first, then suffix match against inventory keys.
|
|
415
|
+
"""
|
|
416
|
+
inv_keys = list(inventory.keys())
|
|
417
|
+
normalised: list[str] = []
|
|
418
|
+
for ch in changed:
|
|
419
|
+
if ch in inventory:
|
|
420
|
+
normalised.append(ch)
|
|
421
|
+
continue
|
|
422
|
+
# Try suffix matching
|
|
423
|
+
for key in inv_keys:
|
|
424
|
+
if key.endswith(ch) or ch.endswith(key):
|
|
425
|
+
normalised.append(key)
|
|
426
|
+
break
|
|
427
|
+
return normalised
|