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/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())