sack-cli 0.5.0__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.
- sack/__init__.py +2 -0
- sack/__main__.py +4 -0
- sack/cli.py +827 -0
- sack_cli-0.5.0.dist-info/METADATA +126 -0
- sack_cli-0.5.0.dist-info/RECORD +9 -0
- sack_cli-0.5.0.dist-info/WHEEL +5 -0
- sack_cli-0.5.0.dist-info/entry_points.txt +2 -0
- sack_cli-0.5.0.dist-info/licenses/LICENSE +648 -0
- sack_cli-0.5.0.dist-info/top_level.txt +1 -0
sack/cli.py
ADDED
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SACK CLI: compact structural context for AI-assisted codebases.
|
|
3
|
+
Copyright (C) 2026 Sadaqat Ali.
|
|
4
|
+
Licensed under the GNU General Public License v3.0 (GPL-3.0).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import ast
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
__version__ = "0.5.0"
|
|
18
|
+
|
|
19
|
+
IGNORED_DIRS = {
|
|
20
|
+
".git", ".hg", ".svn", ".idea", ".vscode", "__pycache__", ".mypy_cache",
|
|
21
|
+
".pytest_cache", ".ruff_cache", "node_modules", "venv", ".venv", "env",
|
|
22
|
+
"dist", "build", "target", "coverage", ".next", ".nuxt", ".svelte-kit",
|
|
23
|
+
".dart_tool", ".fvm", ".symlinks", "bin", "obj", ".vs", ".gradle",
|
|
24
|
+
".build", ".swiftpm", "pods", ".bundle", "vendor", ".tox", ".nox",
|
|
25
|
+
".cache", "out", ".cxx", ".turbo", ".output", "deriveddata",
|
|
26
|
+
}
|
|
27
|
+
EXTENSIONS = {
|
|
28
|
+
".py", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts",
|
|
29
|
+
".rs", ".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx",
|
|
30
|
+
".go", ".java", ".php", ".dart", ".cs", ".kt", ".kts", ".swift", ".rb",
|
|
31
|
+
".html", ".css", ".scss", ".svelte", ".vue",
|
|
32
|
+
}
|
|
33
|
+
MARKUP_EXTENSIONS = {".html", ".css", ".scss"}
|
|
34
|
+
MAX_BYTES = 2_000_000
|
|
35
|
+
CACHE_NAME = ".sack_cache.json"
|
|
36
|
+
CONTEXT_NAME = "sack_context.txt"
|
|
37
|
+
GUARDRAILS_NAME = "sack_guardrails.md"
|
|
38
|
+
GENERATED_FILE_SUFFIXES = (
|
|
39
|
+
".min.js", ".min.css", ".bundle.js",
|
|
40
|
+
".g.dart", ".freezed.dart", ".mocks.dart",
|
|
41
|
+
".designer.cs", ".generated.cs", ".g.cs", ".g.i.cs",
|
|
42
|
+
".pb.go", ".gen.go", "_gen.go",
|
|
43
|
+
"_pb2.py", "_pb2_grpc.py",
|
|
44
|
+
".moc.cpp",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_generated_or_minified(name: str) -> bool:
|
|
49
|
+
"""Check if a file is machine-generated or minified code that should be excluded."""
|
|
50
|
+
name_lower = name.lower()
|
|
51
|
+
return name_lower.endswith(GENERATED_FILE_SUFFIXES) or name_lower.startswith("moc_")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ScanResult(tuple):
|
|
55
|
+
"""Result tuple for scan_project, preserving 3-element unpack while exposing stats."""
|
|
56
|
+
def __new__(cls, scanned: int, warnings: list[str], changed: bool, stats: dict[str, Any] | None = None):
|
|
57
|
+
instance = super().__new__(cls, (scanned, warnings, changed))
|
|
58
|
+
instance.stats = stats or {}
|
|
59
|
+
return instance
|
|
60
|
+
|
|
61
|
+
GUARDRAILS = """# SACK AI Code Generation Guardrails
|
|
62
|
+
|
|
63
|
+
## Universal rules
|
|
64
|
+
- Read `sack_context.txt` and the relevant source files before editing.
|
|
65
|
+
- Preserve architecture and public APIs unless explicitly asked to change them.
|
|
66
|
+
- Do not invent dependencies, configuration keys, or runtime behavior.
|
|
67
|
+
- Handle errors explicitly, add tests for new behavior, and avoid unrelated edits.
|
|
68
|
+
- Never hard-code secrets. State uncertainty rather than claiming unverified correctness.
|
|
69
|
+
- Run available tests, formatters, and type checkers when possible.
|
|
70
|
+
|
|
71
|
+
## Python
|
|
72
|
+
- Annotate every new or modified function/method parameter and return type.
|
|
73
|
+
- Add a concise PEP 257 docstring to every public function, method, and class.
|
|
74
|
+
- Prefer specific types such as `list[str]`, `dict[str, int]`, and `Path`.
|
|
75
|
+
|
|
76
|
+
## JavaScript
|
|
77
|
+
- Add JSDoc directly above exported functions/classes; include `@param` and `@returns`.
|
|
78
|
+
- Prefer `const`, validate external input, and handle rejected promises.
|
|
79
|
+
|
|
80
|
+
## TypeScript
|
|
81
|
+
- Explicitly type exported function parameters and return values.
|
|
82
|
+
- Use interfaces/type aliases for structured data; avoid unjustified `any`.
|
|
83
|
+
- Add JSDoc above exported functions, classes, interfaces, and types.
|
|
84
|
+
|
|
85
|
+
## Svelte
|
|
86
|
+
- In Svelte 4, declare props with explicit type annotations: `export let propName: Type = defaultValue;`.
|
|
87
|
+
- In Svelte 5, declare props with typed runes: `let { prop1, prop2 = default }: Props = $props();`, and manage state using `$state()` and `$derived()`.
|
|
88
|
+
- Do not mutate props directly; dispatch events (Svelte 4 `createEventDispatcher`) or invoke callback props (Svelte 5).
|
|
89
|
+
- Always clean up event listeners, timers, and subscriptions in `onDestroy` (Svelte 4) or `$effect` teardown (Svelte 5).
|
|
90
|
+
|
|
91
|
+
## Tauri
|
|
92
|
+
- Strictly type IPC calls: use `invoke<ReturnType>('command_name', { payload })` on the frontend and `#[tauri::command]` on the Rust backend.
|
|
93
|
+
- Keep argument names matching identically between Rust function parameters and frontend JSON payload keys.
|
|
94
|
+
- Always handle IPC errors gracefully using `try/catch` on the frontend and `Result<T, E>` in Rust commands.
|
|
95
|
+
|
|
96
|
+
## Flutter & Dart
|
|
97
|
+
- Use `const` constructors wherever possible to prevent unnecessary widget rebuilds.
|
|
98
|
+
- Strictly adhere to sound null safety; avoid unjustified `!` force unwraps and prefer `?` or default values.
|
|
99
|
+
- Check `if (!context.mounted) return;` after any `await` before using `BuildContext` across async gaps.
|
|
100
|
+
- Declare widget parameters as `final` fields and dispose controllers/streams in `dispose()`.
|
|
101
|
+
|
|
102
|
+
## Rust
|
|
103
|
+
- Give public functions explicit parameter and return types.
|
|
104
|
+
- Add `///` docs to every public function, struct, enum, trait, and public module.
|
|
105
|
+
- Prefer `Result<T, E>` for recoverable errors; avoid unnecessary `unwrap()`.
|
|
106
|
+
|
|
107
|
+
## C++
|
|
108
|
+
- Use explicit parameter and return types and document public APIs with `///` or `/** ... */`.
|
|
109
|
+
- Prefer const correctness, RAII, standard-library types, and consistent declarations/definitions.
|
|
110
|
+
|
|
111
|
+
## Go
|
|
112
|
+
- Use explicit parameter and return types.
|
|
113
|
+
- Add a Go doc comment above every exported identifier, beginning with its name.
|
|
114
|
+
- Handle errors rather than ignoring them.
|
|
115
|
+
|
|
116
|
+
## Java
|
|
117
|
+
- Use explicit parameter and return types where applicable.
|
|
118
|
+
- Add Javadoc to every public class, interface, enum, constructor, and public method.
|
|
119
|
+
- Prefer generics over raw collection types.
|
|
120
|
+
|
|
121
|
+
## PHP
|
|
122
|
+
- Declare parameter and return types for functions and methods.
|
|
123
|
+
- Add PHPDoc to public classes and methods, including `@param`, `@return`, and relevant `@throws`.
|
|
124
|
+
- Validate external input and type properties where supported.
|
|
125
|
+
|
|
126
|
+
## C#
|
|
127
|
+
- Annotate public APIs and controller actions with appropriate attributes (`[HttpGet]`, `[Authorize]`, etc.).
|
|
128
|
+
- Use explicit parameter and return types; prefer async `Task<T>` for asynchronous methods and configure cancellation tokens.
|
|
129
|
+
- Follow standard PascalCase naming conventions for classes, methods, and public properties.
|
|
130
|
+
|
|
131
|
+
## Kotlin
|
|
132
|
+
- Declare variables with `val` (immutable) whenever possible instead of `var`.
|
|
133
|
+
- Use Kotlin null-safety idioms (`?`, `?:`, `let`) and avoid `!!`.
|
|
134
|
+
- Use `data class` for data holders and `sealed class`/`sealed interface` for restricted class hierarchies.
|
|
135
|
+
|
|
136
|
+
## Swift
|
|
137
|
+
- Prefer `let` over `var` for immutability; leverage optionals and optional binding (`if let`, `guard let`).
|
|
138
|
+
- Use protocols and extensions to organize code and avoid massive view controllers/structs.
|
|
139
|
+
- Mark mutating methods explicitly in structs and actors.
|
|
140
|
+
|
|
141
|
+
## Ruby
|
|
142
|
+
- Follow the Ruby community style guide (snake_case for methods/variables, CamelCase for classes/modules).
|
|
143
|
+
- Prefer explicit return over ambiguous implicit values for complex public methods.
|
|
144
|
+
- Avoid modifying core or standard library classes (monkey patching).
|
|
145
|
+
|
|
146
|
+
## Completion checklist
|
|
147
|
+
1. Public functions have appropriate types and documentation.
|
|
148
|
+
2. Imports and module-level constants are consistent.
|
|
149
|
+
3. Tests cover the change and no unrelated files were modified.
|
|
150
|
+
4. Limitations and unverified behavior are disclosed.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def unique(items: list[str]) -> list[str]:
|
|
155
|
+
"""Deduplicate non-empty strings while preserving order."""
|
|
156
|
+
return list(dict.fromkeys(item.rstrip() for item in items if item.strip()))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _python_extract(path: Path, source: str) -> dict[str, Any]:
|
|
160
|
+
"""Extract Python imports, globals, decorators, signatures, and docstrings."""
|
|
161
|
+
result: dict[str, Any] = {"imports": [], "globals": [], "signatures": [], "warnings": []}
|
|
162
|
+
try:
|
|
163
|
+
tree = ast.parse(source, filename=str(path), type_comments=True)
|
|
164
|
+
except SyntaxError as exc:
|
|
165
|
+
result["warnings"].append(f"Python syntax error at line {exc.lineno}: {exc.msg}")
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
def seg(node: ast.AST) -> str:
|
|
169
|
+
"""Return source text for an AST node."""
|
|
170
|
+
return (ast.get_source_segment(source, node) or "").strip()
|
|
171
|
+
|
|
172
|
+
def decorators(node: ast.AST) -> list[str]:
|
|
173
|
+
"""Format decorators attached to a Python declaration."""
|
|
174
|
+
return [f"@{seg(item)}" for item in getattr(node, "decorator_list", [])]
|
|
175
|
+
|
|
176
|
+
def function(node: ast.FunctionDef | ast.AsyncFunctionDef, indent: str = "") -> str:
|
|
177
|
+
"""Render a function signature, decorators, and concise docstring."""
|
|
178
|
+
args = node.args
|
|
179
|
+
positional = list(args.posonlyargs) + list(args.args)
|
|
180
|
+
defaults = [None] * (len(positional) - len(args.defaults)) + list(args.defaults)
|
|
181
|
+
params: list[str] = []
|
|
182
|
+
for index, (arg, default) in enumerate(zip(positional, defaults)):
|
|
183
|
+
value = arg.arg
|
|
184
|
+
if arg.annotation:
|
|
185
|
+
value += f": {seg(arg.annotation)}"
|
|
186
|
+
if default is not None:
|
|
187
|
+
value += f" = {seg(default)}"
|
|
188
|
+
if args.posonlyargs and index == len(args.posonlyargs) - 1:
|
|
189
|
+
value += "/"
|
|
190
|
+
params.append(value)
|
|
191
|
+
if args.vararg:
|
|
192
|
+
value = "*" + args.vararg.arg
|
|
193
|
+
if args.vararg.annotation:
|
|
194
|
+
value += f": {seg(args.vararg.annotation)}"
|
|
195
|
+
params.append(value)
|
|
196
|
+
elif args.kwonlyargs:
|
|
197
|
+
params.append("*")
|
|
198
|
+
for arg, default in zip(args.kwonlyargs, args.kw_defaults):
|
|
199
|
+
value = arg.arg + (f": {seg(arg.annotation)}" if arg.annotation else "")
|
|
200
|
+
if default is not None:
|
|
201
|
+
value += f" = {seg(default)}"
|
|
202
|
+
params.append(value)
|
|
203
|
+
if args.kwarg:
|
|
204
|
+
value = "**" + args.kwarg.arg
|
|
205
|
+
if args.kwarg.annotation:
|
|
206
|
+
value += f": {seg(args.kwarg.annotation)}"
|
|
207
|
+
params.append(value)
|
|
208
|
+
prefix = "async " if isinstance(node, ast.AsyncFunctionDef) else ""
|
|
209
|
+
ret = f" -> {seg(node.returns)}" if node.returns else ""
|
|
210
|
+
head = f"{indent}{prefix}def {node.name}({', '.join(params)}){ret}:"
|
|
211
|
+
body = [indent + line for line in decorators(node)]
|
|
212
|
+
body.append(head)
|
|
213
|
+
doc = ast.get_docstring(node, clean=True)
|
|
214
|
+
if doc:
|
|
215
|
+
body.append(f'{indent} """{doc.splitlines()[0].strip()}"""')
|
|
216
|
+
return "\n".join(body)
|
|
217
|
+
|
|
218
|
+
def klass(node: ast.ClassDef, indent: str = "") -> str:
|
|
219
|
+
"""Render a class, its decorators/docstring, and method signatures."""
|
|
220
|
+
bases = [seg(base) for base in node.bases]
|
|
221
|
+
bases.extend(seg(keyword) for keyword in node.keywords)
|
|
222
|
+
text = [indent + line for line in decorators(node)]
|
|
223
|
+
text.append(f"{indent}class {node.name}{'(' + ', '.join(bases) + ')' if bases else ''}:")
|
|
224
|
+
doc = ast.get_docstring(node, clean=True)
|
|
225
|
+
if doc:
|
|
226
|
+
text.append(f'{indent} """{doc.splitlines()[0].strip()}"""')
|
|
227
|
+
for child in node.body:
|
|
228
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
229
|
+
text.extend(["", function(child, indent + " ")])
|
|
230
|
+
elif isinstance(child, ast.AnnAssign) and isinstance(child.target, ast.Name):
|
|
231
|
+
text.append(f"{indent} {seg(child)}")
|
|
232
|
+
return "\n".join(text)
|
|
233
|
+
|
|
234
|
+
for node in tree.body:
|
|
235
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
236
|
+
result["imports"].append(seg(node))
|
|
237
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
238
|
+
result["signatures"].append(function(node))
|
|
239
|
+
elif isinstance(node, ast.ClassDef):
|
|
240
|
+
result["signatures"].append(klass(node))
|
|
241
|
+
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
|
|
242
|
+
value = seg(node)
|
|
243
|
+
if value:
|
|
244
|
+
result["globals"].append(value)
|
|
245
|
+
for key in ("imports", "globals", "signatures"):
|
|
246
|
+
result[key] = unique(result[key])
|
|
247
|
+
return result
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _balanced_declaration(lines: list[str], start: int) -> tuple[str, int]:
|
|
251
|
+
"""Collect a multiline declaration until delimiters balance or a body starts."""
|
|
252
|
+
collected = [lines[start].strip()]
|
|
253
|
+
text = collected[0]
|
|
254
|
+
# Declarations often span lines while parentheses/brackets/generics are open.
|
|
255
|
+
# This is deliberately conservative and not a complete language parser.
|
|
256
|
+
depth = text.count("(") + text.count("[") + text.count("{") - text.count(")") - text.count("]") - text.count("}")
|
|
257
|
+
cursor = start + 1
|
|
258
|
+
while cursor < len(lines) and cursor < start + 30:
|
|
259
|
+
if depth <= 0 and not text.rstrip().endswith((",", "=", "->", ":", "extends", "implements")):
|
|
260
|
+
break
|
|
261
|
+
next_line = lines[cursor].strip()
|
|
262
|
+
if not next_line or next_line.startswith(("//", "/*", "*", "#")):
|
|
263
|
+
if depth > 0:
|
|
264
|
+
cursor += 1
|
|
265
|
+
continue
|
|
266
|
+
break
|
|
267
|
+
collected.append(next_line)
|
|
268
|
+
text += " " + next_line
|
|
269
|
+
depth += next_line.count("(") + next_line.count("[") + next_line.count("{") - next_line.count(")") - next_line.count("]") - next_line.count("}")
|
|
270
|
+
cursor += 1
|
|
271
|
+
return " ".join(collected), max(1, cursor - start)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _extract_tauri_calls(source: str) -> list[str]:
|
|
275
|
+
"""Extract invoke, listen, and emit IPC calls from Tauri frontend code."""
|
|
276
|
+
calls: list[str] = []
|
|
277
|
+
# Match invoke calls with balanced parentheses for argument payloads
|
|
278
|
+
for match in re.finditer(r"\binvoke\s*(?:<(?P<ret>[^>]+)>)?\s*\(\s*(?P<cmd>['\"][^'\"]+['\"])", source):
|
|
279
|
+
ret = match.group("ret")
|
|
280
|
+
cmd = match.group("cmd").strip()
|
|
281
|
+
start_idx = match.end()
|
|
282
|
+
depth = 1
|
|
283
|
+
cursor = start_idx
|
|
284
|
+
while cursor < len(source) and depth > 0:
|
|
285
|
+
ch = source[cursor]
|
|
286
|
+
if ch == "(":
|
|
287
|
+
depth += 1
|
|
288
|
+
elif ch == ")":
|
|
289
|
+
depth -= 1
|
|
290
|
+
cursor += 1
|
|
291
|
+
raw_args = source[start_idx:cursor - 1].strip()
|
|
292
|
+
arg_str = ""
|
|
293
|
+
if raw_args.startswith(","):
|
|
294
|
+
arg_str = re.sub(r"\s+", " ", raw_args[1:].strip())
|
|
295
|
+
if len(arg_str) > 60:
|
|
296
|
+
arg_str = arg_str[:57] + "..."
|
|
297
|
+
type_str = f"<{ret.strip()}>" if ret else ""
|
|
298
|
+
if arg_str:
|
|
299
|
+
calls.append(f"invoke{type_str}({cmd}, {arg_str})")
|
|
300
|
+
else:
|
|
301
|
+
calls.append(f"invoke{type_str}({cmd})")
|
|
302
|
+
|
|
303
|
+
# Match listen calls
|
|
304
|
+
for match in re.finditer(r"\blisten\s*(?:<(?P<ret>[^>]+)>)?\s*\(\s*(?P<event>['\"][^'\"]+['\"])", source):
|
|
305
|
+
event = match.group("event").strip()
|
|
306
|
+
ret = match.group("ret")
|
|
307
|
+
type_str = f"<{ret.strip()}>" if ret else ""
|
|
308
|
+
calls.append(f"listen{type_str}({event})")
|
|
309
|
+
|
|
310
|
+
# Match emit calls
|
|
311
|
+
for match in re.finditer(r"\bemit\s*\(\s*(?P<event>['\"][^'\"]+['\"])", source):
|
|
312
|
+
event = match.group("event").strip()
|
|
313
|
+
calls.append(f"emit({event})")
|
|
314
|
+
|
|
315
|
+
return unique(calls)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _svelte_extract(path: Path, source: str) -> dict[str, Any]:
|
|
319
|
+
"""Extract Svelte component props (v4 and v5 runes), state, types, Tauri calls, and signatures."""
|
|
320
|
+
result: dict[str, Any] = {"imports": [], "globals": [], "signatures": [], "warnings": []}
|
|
321
|
+
lines = source.splitlines()
|
|
322
|
+
|
|
323
|
+
# Find all <script> blocks (instance and module scripts)
|
|
324
|
+
script_blocks = list(re.finditer(r"<script\b([^>]*)>(.*?)</script\s*>", source, re.I | re.DOTALL))
|
|
325
|
+
if not script_blocks:
|
|
326
|
+
result["signatures"] = [f"[Svelte] {path.as_posix()} ({len(lines)} lines - template only)"]
|
|
327
|
+
return result
|
|
328
|
+
|
|
329
|
+
# 1. Extract Tauri calls anywhere in component
|
|
330
|
+
tauri_calls = _extract_tauri_calls(source)
|
|
331
|
+
if tauri_calls:
|
|
332
|
+
result["globals"].extend(f"[Tauri] {c}" for c in tauri_calls)
|
|
333
|
+
|
|
334
|
+
# 2. Extract emitted events (createEventDispatcher and dispatch calls)
|
|
335
|
+
for disp_match in re.finditer(r"createEventDispatcher\s*<\s*\{([^}]+)\}\s*>", source, re.DOTALL):
|
|
336
|
+
for entry in disp_match.group(1).split(";"):
|
|
337
|
+
ev = entry.split(":")[0].strip()
|
|
338
|
+
if ev:
|
|
339
|
+
result["globals"].append(f"[Event Emitted] '{ev}'")
|
|
340
|
+
for m in re.finditer(r"\bdispatch\s*\(\s*(['\"][^'\"]+['\"])", source):
|
|
341
|
+
result["globals"].append(f"[Event Emitted] {m.group(1).strip()}")
|
|
342
|
+
|
|
343
|
+
# 3. Process script blocks
|
|
344
|
+
for match in script_blocks:
|
|
345
|
+
attrs = match.group(1)
|
|
346
|
+
code = match.group(2)
|
|
347
|
+
is_module = bool(re.search(r"\b(?:context\s*=\s*['\"]module['\"]|module)\b", attrs, re.I))
|
|
348
|
+
script_lines = code.splitlines()
|
|
349
|
+
|
|
350
|
+
cursor = 0
|
|
351
|
+
while cursor < len(script_lines):
|
|
352
|
+
line = script_lines[cursor].strip()
|
|
353
|
+
if not line:
|
|
354
|
+
cursor += 1
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
# Imports
|
|
358
|
+
if re.match(r"^(?:import\b|export\s+.*\sfrom\b)", line):
|
|
359
|
+
decl, consumed = _balanced_declaration(script_lines, cursor)
|
|
360
|
+
result["imports"].append(decl)
|
|
361
|
+
cursor += consumed
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
# Svelte 5 $props Rune
|
|
365
|
+
if "$props" in line and re.match(r"^(?:let|const)\s+", line):
|
|
366
|
+
decl, consumed = _balanced_declaration(script_lines, cursor)
|
|
367
|
+
result["globals"].append(f"[Rune $props] {decl.rstrip(';')}")
|
|
368
|
+
cursor += consumed
|
|
369
|
+
continue
|
|
370
|
+
|
|
371
|
+
# Svelte 5 $state or $derived Runes
|
|
372
|
+
if any(rune in line for rune in ("$state", "$derived")) and re.match(r"^(?:let|const)\s+", line):
|
|
373
|
+
decl, consumed = _balanced_declaration(script_lines, cursor)
|
|
374
|
+
result["globals"].append(f"[Rune] {decl.rstrip(';')}")
|
|
375
|
+
cursor += consumed
|
|
376
|
+
continue
|
|
377
|
+
|
|
378
|
+
# Svelte 4 Props: export let ...
|
|
379
|
+
m_prop = re.match(r"^export\s+let\s+([^;]+)", line)
|
|
380
|
+
if m_prop:
|
|
381
|
+
decl, consumed = _balanced_declaration(script_lines, cursor)
|
|
382
|
+
result["globals"].append(f"[Prop] {decl.rstrip(';')}")
|
|
383
|
+
cursor += consumed
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
# Svelte 4 Reactive statement: $: varName = ...
|
|
387
|
+
m_rx = re.match(r"^(\$:\s*[\w$]+\s*=\s*[^;]+)", line)
|
|
388
|
+
if m_rx:
|
|
389
|
+
decl, consumed = _balanced_declaration(script_lines, cursor)
|
|
390
|
+
result["globals"].append(f"[Reactive] {decl.rstrip(';')}")
|
|
391
|
+
cursor += consumed
|
|
392
|
+
continue
|
|
393
|
+
|
|
394
|
+
# Module exports (constants / variables in <script module>)
|
|
395
|
+
if is_module and re.match(r"^export\s+(?:const|let|var)\s+[\w$]+", line):
|
|
396
|
+
decl, consumed = _balanced_declaration(script_lines, cursor)
|
|
397
|
+
result["globals"].append(f"[Module Export] {decl.rstrip(';')}")
|
|
398
|
+
cursor += consumed
|
|
399
|
+
continue
|
|
400
|
+
|
|
401
|
+
# TypeScript interfaces & types
|
|
402
|
+
if re.match(r"^(?:export\s+)?(?:interface|type|enum)\s+\w+", line):
|
|
403
|
+
decl, consumed = _balanced_declaration(script_lines, cursor)
|
|
404
|
+
result["signatures"].append(decl)
|
|
405
|
+
cursor += consumed
|
|
406
|
+
continue
|
|
407
|
+
|
|
408
|
+
# Functions: function / async function / export function
|
|
409
|
+
if re.match(r"^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+[\w$]+\s*\(", line):
|
|
410
|
+
decl, consumed = _balanced_declaration(script_lines, cursor)
|
|
411
|
+
header = decl.split("{")[0].strip() if "{" in decl else decl
|
|
412
|
+
result["signatures"].append(header)
|
|
413
|
+
cursor += consumed
|
|
414
|
+
continue
|
|
415
|
+
|
|
416
|
+
# Arrow functions: const fn = () => or export const fn = () =>
|
|
417
|
+
m_arrow = re.match(r"^((?:export\s+)?const\s+[\w$]+\s*=\s*(?:async\s*)?\([^)]*\)(?:\s*:\s*.+?)?\s*=>)", line)
|
|
418
|
+
if m_arrow:
|
|
419
|
+
result["signatures"].append(m_arrow.group(1))
|
|
420
|
+
cursor += 1
|
|
421
|
+
continue
|
|
422
|
+
|
|
423
|
+
# General top-level constants / variables / stores
|
|
424
|
+
if re.match(r"^(?:const|let|var)\s+[\w$]+(?:\s*:\s*[^=;]+)?\s*=", line):
|
|
425
|
+
decl, consumed = _balanced_declaration(script_lines, cursor)
|
|
426
|
+
if "$props" not in decl and "$state" not in decl and "$derived" not in decl:
|
|
427
|
+
result["globals"].append(decl.rstrip(";"))
|
|
428
|
+
cursor += consumed
|
|
429
|
+
continue
|
|
430
|
+
|
|
431
|
+
cursor += 1
|
|
432
|
+
|
|
433
|
+
for key in ("imports", "globals", "signatures"):
|
|
434
|
+
result[key] = unique(result[key])
|
|
435
|
+
return result
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _other_extract(path: Path, source: str) -> dict[str, Any]:
|
|
439
|
+
"""Extract approximate dependencies, globals, and declarations from other languages."""
|
|
440
|
+
ext = path.suffix.lower()
|
|
441
|
+
result: dict[str, Any] = {"imports": [], "globals": [], "signatures": [], "warnings": []}
|
|
442
|
+
lines = source.splitlines()
|
|
443
|
+
|
|
444
|
+
if ext in MARKUP_EXTENSIONS:
|
|
445
|
+
result["signatures"] = [f"[Markup] {path.as_posix()} ({len(lines)} lines - AST Skipped)"]
|
|
446
|
+
return result
|
|
447
|
+
if ext == ".vue":
|
|
448
|
+
blocks = re.findall(r"<script\b([^>]*)>(.*?)</script\s*>", source, re.I | re.S)
|
|
449
|
+
if not blocks:
|
|
450
|
+
result["signatures"] = [f"[Markup] {path.as_posix()} ({len(lines)} lines - no script block)"]
|
|
451
|
+
return result
|
|
452
|
+
is_ts = any("ts" in b[0].lower() for b in blocks)
|
|
453
|
+
lines = "\n".join(b[1] for b in blocks).splitlines()
|
|
454
|
+
lang = "ts" if is_ts else "js"
|
|
455
|
+
# Check for Tauri calls in Vue files
|
|
456
|
+
tauri_calls = _extract_tauri_calls(source)
|
|
457
|
+
if tauri_calls:
|
|
458
|
+
result["globals"].extend(f"[Tauri] {c}" for c in tauri_calls)
|
|
459
|
+
else:
|
|
460
|
+
ext_groups = {
|
|
461
|
+
"js": {".js", ".jsx", ".mjs", ".cjs"},
|
|
462
|
+
"ts": {".ts", ".tsx", ".mts", ".cts"},
|
|
463
|
+
"rust": {".rs"},
|
|
464
|
+
"cpp": {".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx"},
|
|
465
|
+
"go": {".go"}, "java": {".java"}, "php": {".php"},
|
|
466
|
+
"dart": {".dart"},
|
|
467
|
+
"csharp": {".cs"},
|
|
468
|
+
"kotlin": {".kt", ".kts"},
|
|
469
|
+
"swift": {".swift"},
|
|
470
|
+
"ruby": {".rb"},
|
|
471
|
+
}
|
|
472
|
+
lang = next((key for key, vals in ext_groups.items() if ext in vals), "js")
|
|
473
|
+
|
|
474
|
+
imports = {
|
|
475
|
+
"js": r"^(?:import\b|export\s+.*\sfrom\b|const\s+\w+\s*=\s*require\s*\()",
|
|
476
|
+
"ts": r"^(?:import\b|export\s+.*\sfrom\b|const\s+\w+\s*=\s*require\s*\()",
|
|
477
|
+
"rust": r"^(?:use\b|mod\s+\w+\s*;)",
|
|
478
|
+
"cpp": r"^#include\b",
|
|
479
|
+
"go": r"^(?:package\s+\w+|import\b)",
|
|
480
|
+
"java": r"^(?:package\b|import\b)",
|
|
481
|
+
"php": r"^(?:namespace\b|use\b|require(?:_once)?\b|include(?:_once)?\b)",
|
|
482
|
+
"dart": r"^(?:import\b|export\b|part\b)",
|
|
483
|
+
"csharp": r"^(?:(?:global\s+)?using\b|namespace\b)",
|
|
484
|
+
"kotlin": r"^(?:package\b|import\b)",
|
|
485
|
+
"swift": r"^(?:@testable\s+)?import\b",
|
|
486
|
+
"ruby": r"^(?:require\b|require_relative\b|include\b|extend\b)",
|
|
487
|
+
}
|
|
488
|
+
globals_patterns = {
|
|
489
|
+
"js": r"^(?:export\s+)?(?:const|let|var)\s+[\w$]+(?:\s*:\s*[^=;]+)?\s*=",
|
|
490
|
+
"ts": r"^(?:export\s+)?(?:declare\s+)?(?:const|let|var)\s+[\w$]+(?:\s*:\s*[^=;]+)?\s*=",
|
|
491
|
+
"rust": r"^(?:pub(?:\([^)]*\))?\s+)?(?:const|static)\s+\w+",
|
|
492
|
+
"cpp": r"^(?:extern\s+)?(?:const\s+)?[\w:*&<>]+\s+\w+\s*(?:=|;)",
|
|
493
|
+
"go": r"^(?:const|var)\s+\w+",
|
|
494
|
+
"java": r"^(?:(?:public|private|protected|static|final)\s+)+[\w<>\[\].]+\s+\w+\s*(?:=|;)",
|
|
495
|
+
"php": r"^(?:const\s+\w+|(?:public|protected|private)?\s*\$[A-Za-z_]\w*\s*=)",
|
|
496
|
+
"dart": r"^(?!(?:class|typedef|enum|mixin|extension)\b)(?:(?:static\s+)?(?:late\s+)?(?:final\s+|const\s+|var\s+)?(?:[\w<>?,\[\]]+\s+)?[\w$]+\s*=)",
|
|
497
|
+
"csharp": r"^(?:(?:public|private|protected|internal|static|readonly|const)\s+)+[\w<>?,\[\]\s*]+\s+\w+\s*(?:=|;)",
|
|
498
|
+
"kotlin": r"^(?:(?:public|private|protected|internal)\s+)?(?:const\s+)?(?:lateinit\s+)?(?:val|var)\s+\w+",
|
|
499
|
+
"swift": r"^(?:@\w+(?:\([^)]*\))?\s+)?(?:(?:public|private|fileprivate|internal|open|static)\s+)?(?:let|var)\s+\w+",
|
|
500
|
+
"ruby": r"^(?:[A-Z]\w*|[$@]{1,2}\w+)\s*=",
|
|
501
|
+
}
|
|
502
|
+
declarations = {
|
|
503
|
+
"js": r"^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+[\w$]+\s*\(|^(?:export\s+)?(?:default\s+)?class\s+\w+|^(?:export\s+)?(?:const|let|var)\s+[\w$]+\s*(?::[^=]+)?=",
|
|
504
|
+
"ts": r"^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+[\w$]+\s*\(|^(?:export\s+)?(?:interface|type|enum|class)\s+\w+|^(?:export\s+)?(?:const|let|var)\s+[\w$]+\s*(?::[^=]+)?=",
|
|
505
|
+
"rust": r"^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+\w+|^(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait)\s+\w+",
|
|
506
|
+
"cpp": r"^(?:template\s*<.*>|(?:[\w:*&<>\[\],]+\s+)+[*&]?\w+\s*\([^;]*\)\s*(?:const\s*)?(?:;|\{)?)|^(?:class|struct|enum)\s+\w+",
|
|
507
|
+
"go": r"^func\s+(?:\([^)]*\)\s*)?\w+\s*\(|^type\s+\w+\s+(?:struct|interface)",
|
|
508
|
+
"java": r"^(?:(?:public|protected|private|static|final|abstract|synchronized)\s+)*[\w<>?,.\[\]]+\s+\w+\s*\(|^(?:public\s+)?(?:class|interface|enum|record)\s+\w+",
|
|
509
|
+
"php": r"^(?:(?:public|protected|private|static|final|abstract)\s+)*function\s+&?\s*\w+\s*\(|^(?:abstract\s+)?class\s+\w+|^(?:interface|trait|enum)\s+\w+",
|
|
510
|
+
"dart": r"^(?:abstract\s+)?class\s+\w+|^(?:mixin|enum|extension)\s+\w+|^typedef\s+\w+|^(?:const\s+)?(?:factory\s+)?\w+(?:\.\w+)?\s*\([^;{]*\)|^(?!return\b|throw\b|yield\b|super\b|await\b)(?:[\w<>?,\[\]]+\s+)+(?:get\s+|set\s+)?[\w$]+\s*\(",
|
|
511
|
+
"csharp": r"^(?:(?:public|private|protected|internal|static|abstract|virtual|override|async|sealed|partial)\s+)*(?:class|interface|struct|record|enum)\s+\w+|(?:(?:public|private|protected|internal|static|abstract|virtual|override|async|sealed)\s+)+[\w<>?,\[\]\s*]+\s+\w+\s*\([^;]*\)",
|
|
512
|
+
"kotlin": r"^(?:(?:public|private|protected|internal|abstract|open|sealed|data|enum|annotation)\s+)*(?:class|interface|object)\s+\w+|^(?:(?:public|private|protected|internal|inline|suspend|override|open)\s+)*fun\s+(?:<[^>]+>\s+)?\w+\s*\(",
|
|
513
|
+
"swift": r"^(?:(?:public|private|fileprivate|internal|open|final)\s+)*(?:struct|class|actor|protocol|enum|extension)\s+\w+|^(?:(?:public|private|fileprivate|internal|open|override|mutating|static)\s+)*(?:func\s+\w+|init\s*\(|deinit\b|var\s+body\b)",
|
|
514
|
+
"ruby": r"^(?:class|module)\s+[A-Z]\w*|^def\s+(?:self\.)?[\w?!]+",
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
comments: list[str] = []
|
|
518
|
+
skip_until = -1
|
|
519
|
+
for index, raw in enumerate(lines):
|
|
520
|
+
if index < skip_until:
|
|
521
|
+
continue
|
|
522
|
+
line = raw.strip()
|
|
523
|
+
if not line:
|
|
524
|
+
continue
|
|
525
|
+
if (
|
|
526
|
+
line.startswith(("///", "//!", "/**", "/*", "*"))
|
|
527
|
+
or (lang == "ruby" and line.startswith("#"))
|
|
528
|
+
or (lang == "rust" and line.startswith("#["))
|
|
529
|
+
or (lang == "csharp" and line.startswith("[") and line.endswith("]") and not re.search(r"\b(?:var|return|throw|new)\b", line))
|
|
530
|
+
or (
|
|
531
|
+
lang in {"js", "ts", "java", "php", "dart", "kotlin", "swift"}
|
|
532
|
+
and line.startswith("@")
|
|
533
|
+
and not re.search(r"\b(?:var|let|val|fun|func|class|struct|interface|def)\b", line)
|
|
534
|
+
)
|
|
535
|
+
):
|
|
536
|
+
comments.append(line)
|
|
537
|
+
continue
|
|
538
|
+
if re.match(imports[lang], line):
|
|
539
|
+
decl, consumed = _balanced_declaration(lines, index)
|
|
540
|
+
result["imports"].append(decl)
|
|
541
|
+
skip_until = index + consumed
|
|
542
|
+
comments.clear()
|
|
543
|
+
continue
|
|
544
|
+
if re.match(globals_patterns[lang], line):
|
|
545
|
+
decl, consumed = _balanced_declaration(lines, index)
|
|
546
|
+
result["globals"].append(decl)
|
|
547
|
+
skip_until = index + consumed
|
|
548
|
+
comments.clear()
|
|
549
|
+
continue
|
|
550
|
+
if re.match(declarations[lang], line):
|
|
551
|
+
declaration, consumed = _balanced_declaration(lines, index)
|
|
552
|
+
decorators = []
|
|
553
|
+
if comments:
|
|
554
|
+
decorators.extend(comments)
|
|
555
|
+
result["signatures"].append(("\n".join(decorators) + "\n" if decorators else "") + declaration)
|
|
556
|
+
skip_until = index + consumed
|
|
557
|
+
comments.clear()
|
|
558
|
+
|
|
559
|
+
for key in ("imports", "globals", "signatures"):
|
|
560
|
+
result[key] = unique(result[key])
|
|
561
|
+
return result
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def extract(path: Path, source: str) -> dict[str, Any]:
|
|
565
|
+
"""Dispatch extraction according to the source file extension."""
|
|
566
|
+
suffix = path.suffix.lower()
|
|
567
|
+
if suffix == ".py":
|
|
568
|
+
return _python_extract(path, source)
|
|
569
|
+
if suffix == ".svelte":
|
|
570
|
+
return _svelte_extract(path, source)
|
|
571
|
+
return _other_extract(path, source)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _module_candidates(path_text: str) -> set[str]:
|
|
575
|
+
"""Create candidate import names from a relative project path."""
|
|
576
|
+
path = Path(path_text)
|
|
577
|
+
no_suffix = path.with_suffix("").as_posix()
|
|
578
|
+
values = {
|
|
579
|
+
no_suffix,
|
|
580
|
+
no_suffix.replace("/", "."),
|
|
581
|
+
path.stem,
|
|
582
|
+
path.as_posix(),
|
|
583
|
+
path.name,
|
|
584
|
+
path.as_posix().replace("/", "."),
|
|
585
|
+
}
|
|
586
|
+
if path_text.startswith("lib/"):
|
|
587
|
+
no_lib = path_text.removeprefix("lib/")
|
|
588
|
+
p_nl = Path(no_lib)
|
|
589
|
+
values.add(no_lib)
|
|
590
|
+
values.add(p_nl.as_posix().replace("/", "."))
|
|
591
|
+
values.add(p_nl.with_suffix("").as_posix())
|
|
592
|
+
values.add(p_nl.with_suffix("").as_posix().replace("/", "."))
|
|
593
|
+
if path.name.startswith("index."):
|
|
594
|
+
values.add(path.parent.as_posix().replace("/", "."))
|
|
595
|
+
values.add(path.parent.as_posix())
|
|
596
|
+
if path.name == "__init__.py":
|
|
597
|
+
values.add(path.parent.as_posix().replace("/", "."))
|
|
598
|
+
values.add(path.parent.as_posix())
|
|
599
|
+
return {v.removeprefix("./") for v in values if v and v != "."}
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def build_dependency_graph(files: dict[str, dict[str, Any]]) -> dict[str, list[str]]:
|
|
603
|
+
"""Map files to project files that their imports appear to reference."""
|
|
604
|
+
candidates: dict[str, set[str]] = {}
|
|
605
|
+
for rel in files:
|
|
606
|
+
for candidate in _module_candidates(rel):
|
|
607
|
+
candidates.setdefault(candidate.lower(), set()).add(rel)
|
|
608
|
+
graph: dict[str, list[str]] = {}
|
|
609
|
+
for rel, facts in files.items():
|
|
610
|
+
targets: set[str] = set()
|
|
611
|
+
for imp in facts.get("imports", []):
|
|
612
|
+
# Strip comments and quote delimiters to avoid matching arbitrary prose.
|
|
613
|
+
clean = imp.strip()
|
|
614
|
+
match = re.search(r"(?:from\s+|import\s+|export\s+|part\s+|using\s+(?:static\s+)?|use\s+|require\s*\(\s*|=\s*['\"])(['\"]?)([\w./@$:-]+)", clean)
|
|
615
|
+
if match:
|
|
616
|
+
raw_mod = match.group(2).strip(".'\";")
|
|
617
|
+
# Handle path aliases like package:app_name/, crate::, $lib/, @/
|
|
618
|
+
module = re.sub(r"^(?:package:[\w-]+/|crate::|[$@]\w*/)", "", raw_mod)
|
|
619
|
+
normalized = module.replace("/", ".").replace("::", ".").lower()
|
|
620
|
+
stem = Path(module).stem.lower()
|
|
621
|
+
mod_name = Path(module).name.lower()
|
|
622
|
+
for candidate, paths in candidates.items():
|
|
623
|
+
if (
|
|
624
|
+
candidate == normalized
|
|
625
|
+
or candidate.endswith("." + normalized)
|
|
626
|
+
or normalized.endswith("." + candidate)
|
|
627
|
+
or candidate == module.lower()
|
|
628
|
+
or candidate == mod_name
|
|
629
|
+
or (stem and candidate == stem)
|
|
630
|
+
):
|
|
631
|
+
targets.update(p for p in paths if p != rel)
|
|
632
|
+
# Rust paths and C includes can contain project-relative path fragments.
|
|
633
|
+
for candidate, paths in candidates.items():
|
|
634
|
+
if candidate and candidate in clean.lower():
|
|
635
|
+
targets.update(p for p in paths if p != rel)
|
|
636
|
+
graph[rel] = sorted(targets)
|
|
637
|
+
return graph
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _render_context(root: Path, facts: dict[str, dict[str, Any]], markup: dict[str, str]) -> str:
|
|
641
|
+
"""Render dependency graph first, then imports/globals, then signatures."""
|
|
642
|
+
graph = build_dependency_graph(facts)
|
|
643
|
+
output: list[str] = [
|
|
644
|
+
"SACK CONTEXT INDEX",
|
|
645
|
+
f"Project root: {root}",
|
|
646
|
+
"",
|
|
647
|
+
"=" * 54,
|
|
648
|
+
"DEPENDENCY OVERVIEW",
|
|
649
|
+
"=" * 54,
|
|
650
|
+
]
|
|
651
|
+
if not graph:
|
|
652
|
+
output.append("(no source modules found)")
|
|
653
|
+
for rel in sorted(graph):
|
|
654
|
+
deps = graph[rel]
|
|
655
|
+
output.append(f"{rel} -> {', '.join(deps) if deps else '(no internal dependency detected)'}")
|
|
656
|
+
|
|
657
|
+
output.extend(["", "=" * 54, "CORE IMPORTS & GLOBALS", "=" * 54])
|
|
658
|
+
for rel in sorted(facts):
|
|
659
|
+
item = facts[rel]
|
|
660
|
+
output.extend(["", f"FILE: {rel}", "[IMPORTS]", *(item["imports"] or ["(none detected)"]),
|
|
661
|
+
"[GLOBALS]", *(item["globals"] or ["(none detected)"])])
|
|
662
|
+
if markup:
|
|
663
|
+
output.extend(["", "=" * 54, "MARKUP / ASSET SUMMARIES", "=" * 54])
|
|
664
|
+
output.extend(markup[key] for key in sorted(markup))
|
|
665
|
+
|
|
666
|
+
output.extend(["", "=" * 54, "FUNCTION & CLASS SIGNATURES", "=" * 54])
|
|
667
|
+
for rel in sorted(facts):
|
|
668
|
+
output.extend(["", f"FILE: {rel}", *(facts[rel]["signatures"] or ["(no supported declarations detected)"])])
|
|
669
|
+
output.append("")
|
|
670
|
+
return "\n".join(output)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def scan_project(root: Path, changed: bool = False) -> tuple[int, list[str], bool]:
|
|
674
|
+
"""Scan a project, optionally reusing cached extraction for unchanged files."""
|
|
675
|
+
root = root.resolve()
|
|
676
|
+
cache_path = root / CACHE_NAME
|
|
677
|
+
output_path = root / CONTEXT_NAME
|
|
678
|
+
old_cache: dict[str, Any] = {}
|
|
679
|
+
if changed and cache_path.exists():
|
|
680
|
+
try:
|
|
681
|
+
old_cache = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
682
|
+
except (OSError, json.JSONDecodeError):
|
|
683
|
+
old_cache = {}
|
|
684
|
+
old_files: dict[str, Any] = old_cache.get("files", {})
|
|
685
|
+
current: dict[str, tuple[Path, int, int]] = {}
|
|
686
|
+
for folder, dirs, names in os.walk(root, topdown=True, followlinks=False):
|
|
687
|
+
dirs[:] = sorted(
|
|
688
|
+
d for d in dirs
|
|
689
|
+
if d.lower() not in IGNORED_DIRS
|
|
690
|
+
and not d.lower().endswith(".egg-info")
|
|
691
|
+
and not (Path(folder) / d).is_symlink()
|
|
692
|
+
)
|
|
693
|
+
for name in sorted(names):
|
|
694
|
+
if is_generated_or_minified(name):
|
|
695
|
+
continue
|
|
696
|
+
path = Path(folder) / name
|
|
697
|
+
if path.suffix.lower() not in EXTENSIONS or name in {CONTEXT_NAME, GUARDRAILS_NAME, CACHE_NAME}:
|
|
698
|
+
continue
|
|
699
|
+
rel = path.relative_to(root).as_posix()
|
|
700
|
+
try:
|
|
701
|
+
stat = path.stat()
|
|
702
|
+
if path.is_symlink() or stat.st_size > MAX_BYTES:
|
|
703
|
+
continue
|
|
704
|
+
current[rel] = (path, stat.st_mtime_ns, stat.st_size)
|
|
705
|
+
except OSError:
|
|
706
|
+
continue
|
|
707
|
+
|
|
708
|
+
files: dict[str, dict[str, Any]] = {}
|
|
709
|
+
markup: dict[str, str] = {}
|
|
710
|
+
warnings: list[str] = []
|
|
711
|
+
scanned = 0
|
|
712
|
+
for rel, (path, mtime_ns, size) in current.items():
|
|
713
|
+
cached = old_files.get(rel)
|
|
714
|
+
if changed and cached and cached.get("mtime_ns") == mtime_ns and cached.get("size") == size:
|
|
715
|
+
if cached.get("kind") == "markup":
|
|
716
|
+
markup[rel] = cached["summary"]
|
|
717
|
+
elif "facts" in cached:
|
|
718
|
+
files[rel] = cached["facts"]
|
|
719
|
+
continue
|
|
720
|
+
try:
|
|
721
|
+
source = path.read_text(encoding="utf-8")
|
|
722
|
+
except (OSError, UnicodeError) as exc:
|
|
723
|
+
warnings.append(f"{rel}: could not read: {exc}")
|
|
724
|
+
continue
|
|
725
|
+
scanned += 1
|
|
726
|
+
if path.suffix.lower() in MARKUP_EXTENSIONS:
|
|
727
|
+
summary = f"[Markup] {rel} ({len(source.splitlines())} lines - AST Skipped)"
|
|
728
|
+
markup[rel] = summary
|
|
729
|
+
old_files[rel] = {"mtime_ns": mtime_ns, "size": size, "kind": "markup", "summary": summary}
|
|
730
|
+
else:
|
|
731
|
+
facts = extract(path, source)
|
|
732
|
+
files[rel] = facts
|
|
733
|
+
old_files[rel] = {"mtime_ns": mtime_ns, "size": size, "kind": "source", "facts": facts}
|
|
734
|
+
warnings.extend(f"{rel}: {warning}" for warning in facts.get("warnings", []))
|
|
735
|
+
|
|
736
|
+
# Remove deleted files from cache and preserve only currently discovered files.
|
|
737
|
+
old_files = {rel: old_files[rel] for rel in current if rel in old_files}
|
|
738
|
+
# Cached facts for unchanged files are loaded above; if the cache entry was absent,
|
|
739
|
+
# parse it now (e.g. a cache created by an interrupted earlier run).
|
|
740
|
+
for rel, (path, mtime_ns, size) in current.items():
|
|
741
|
+
if rel in files or rel in markup:
|
|
742
|
+
continue
|
|
743
|
+
try:
|
|
744
|
+
source = path.read_text(encoding="utf-8")
|
|
745
|
+
except (OSError, UnicodeError) as exc:
|
|
746
|
+
warnings.append(f"{rel}: could not read: {exc}")
|
|
747
|
+
continue
|
|
748
|
+
scanned += 1
|
|
749
|
+
if path.suffix.lower() in MARKUP_EXTENSIONS:
|
|
750
|
+
summary = f"[Markup] {rel} ({len(source.splitlines())} lines - AST Skipped)"
|
|
751
|
+
markup[rel] = summary
|
|
752
|
+
old_files[rel] = {"mtime_ns": mtime_ns, "size": size, "kind": "markup", "summary": summary}
|
|
753
|
+
else:
|
|
754
|
+
facts = extract(path, source)
|
|
755
|
+
files[rel] = facts
|
|
756
|
+
old_files[rel] = {"mtime_ns": mtime_ns, "size": size, "kind": "source", "facts": facts}
|
|
757
|
+
warnings.extend(f"{rel}: {warning}" for warning in facts.get("warnings", []))
|
|
758
|
+
|
|
759
|
+
context = _render_context(root, files, markup)
|
|
760
|
+
try:
|
|
761
|
+
output_path.write_text(context, encoding="utf-8")
|
|
762
|
+
cache_path.write_text(json.dumps({"version": 1, "files": old_files}, indent=2), encoding="utf-8")
|
|
763
|
+
except OSError as exc:
|
|
764
|
+
raise OSError(f"Cannot write scan output/cache: {exc}") from exc
|
|
765
|
+
|
|
766
|
+
total_files = len(files) + len(markup)
|
|
767
|
+
line_count = len(context.splitlines())
|
|
768
|
+
size_bytes = len(context.encode("utf-8"))
|
|
769
|
+
approx_tokens = max(1, len(context) // 4)
|
|
770
|
+
stats = {
|
|
771
|
+
"scanned": scanned,
|
|
772
|
+
"total_files": total_files,
|
|
773
|
+
"lines": line_count,
|
|
774
|
+
"bytes": size_bytes,
|
|
775
|
+
"tokens": approx_tokens,
|
|
776
|
+
}
|
|
777
|
+
return ScanResult(scanned, warnings, changed, stats)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def main(argv: list[str] | None = None) -> int:
|
|
781
|
+
"""Run SACK's scan or init command."""
|
|
782
|
+
parser = argparse.ArgumentParser(prog="sack", description="Compact source context for AI-assisted coding.")
|
|
783
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
784
|
+
subs = parser.add_subparsers(dest="command", required=True)
|
|
785
|
+
scan = subs.add_parser("scan", help="generate sack_context.txt")
|
|
786
|
+
scan.add_argument("--root", type=Path, default=Path.cwd(), help="project root")
|
|
787
|
+
scan.add_argument("--changed", action="store_true", help="re-extract only new or modified files using cached mtimes")
|
|
788
|
+
init = subs.add_parser("init", help="generate sack_guardrails.md")
|
|
789
|
+
init.add_argument("--force", action="store_true", help="overwrite existing guardrails")
|
|
790
|
+
args = parser.parse_args(argv)
|
|
791
|
+
try:
|
|
792
|
+
if args.command == "scan":
|
|
793
|
+
root = args.root.resolve()
|
|
794
|
+
if not root.is_dir():
|
|
795
|
+
print(f"[!] Not a directory: {root}", file=sys.stderr)
|
|
796
|
+
return 2
|
|
797
|
+
print(f"SACK - Sadaqat Ali Context Hack (v{__version__})")
|
|
798
|
+
res = scan_project(root, changed=args.changed)
|
|
799
|
+
count, warnings, _ = res
|
|
800
|
+
stats = getattr(res, "stats", {})
|
|
801
|
+
mode = "changed-file scan" if args.changed else "full scan"
|
|
802
|
+
size_kb = f"{stats.get('bytes', 0) / 1024:.1f}"
|
|
803
|
+
tokens = stats.get("tokens", 0)
|
|
804
|
+
tokens_str = f"{tokens / 1000:.1f}k" if tokens >= 1000 else str(tokens)
|
|
805
|
+
print(
|
|
806
|
+
f"[+] SACK {mode}: {count} file(s) re-extracted | "
|
|
807
|
+
f"Context: {stats.get('total_files', 0)} file(s), {stats.get('lines', 0)} lines, "
|
|
808
|
+
f"~{tokens_str} tokens ({size_kb} KB) -> {root / CONTEXT_NAME}"
|
|
809
|
+
)
|
|
810
|
+
for warning in warnings:
|
|
811
|
+
print(f"[!] {warning}", file=sys.stderr)
|
|
812
|
+
return 0
|
|
813
|
+
destination = Path.cwd() / GUARDRAILS_NAME
|
|
814
|
+
if destination.exists() and not args.force:
|
|
815
|
+
print("[!] sack_guardrails.md already exists; use 'sack init --force'.", file=sys.stderr)
|
|
816
|
+
return 2
|
|
817
|
+
destination.write_text(GUARDRAILS.strip() + "\n", encoding="utf-8")
|
|
818
|
+
print(f"SACK - Sadaqat Ali Context Hack (v{__version__})")
|
|
819
|
+
print("[+] Successfully generated sack_guardrails.md in project root.")
|
|
820
|
+
return 0
|
|
821
|
+
except OSError as exc:
|
|
822
|
+
print(f"[!] SACK error: {exc}", file=sys.stderr)
|
|
823
|
+
return 1
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
if __name__ == "__main__":
|
|
827
|
+
raise SystemExit(main())
|