cloak-cli 0.1.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.
cloak/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CLOAK — local CLI for safer LLM workflows."""
2
+
3
+ __version__ = "0.1.0"
cloak/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from cloak.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
cloak/cli.py ADDED
@@ -0,0 +1,380 @@
1
+ """CLOAK CLI entry point."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+
12
+ from cloak import __version__
13
+ from cloak.context.generator import generate as run_context
14
+ from cloak.filesystem import walk_repo
15
+ from cloak.obfuscate.runner import ObfuscateError, ObfuscateResult
16
+ from cloak.obfuscate.runner import run_obfuscate as do_obfuscate
17
+ from cloak.policy import Policy, find_policy, load_policy
18
+ from cloak.scan.scanner import Finding
19
+ from cloak.scan.scanner import scan as run_scan
20
+
21
+ app = typer.Typer(
22
+ name="cloak",
23
+ help="Local CLI for safer LLM workflows. Run `cloak --help` for commands.",
24
+ no_args_is_help=True,
25
+ context_settings={"help_option_names": ["-h", "--help"]},
26
+ )
27
+ console = Console()
28
+
29
+
30
+ def _version_callback(value: bool) -> None:
31
+ if value:
32
+ console.print(f"cloak {__version__}")
33
+ raise typer.Exit()
34
+
35
+
36
+ @app.callback()
37
+ def root(
38
+ version: Annotated[
39
+ bool,
40
+ typer.Option(
41
+ "--version",
42
+ callback=_version_callback,
43
+ is_eager=True,
44
+ help="Show version and exit.",
45
+ ),
46
+ ] = False,
47
+ ) -> None:
48
+ """CLOAK — local CLI for safer LLM workflows."""
49
+ del version # handled by callback
50
+
51
+
52
+ @app.command()
53
+ def scan(
54
+ path: Annotated[Path, typer.Argument(help="Path to scan (file or directory).")],
55
+ policy_path: Annotated[
56
+ Path | None,
57
+ typer.Option("--policy", help="Path to .cloakpolicy file. Default: walks up from `path`."),
58
+ ] = None,
59
+ json_out: Annotated[
60
+ bool,
61
+ typer.Option("--json", help="Emit JSON instead of human-readable output."),
62
+ ] = False,
63
+ ) -> None:
64
+ """Find secrets and proprietary markers in code.
65
+
66
+ Wraps detect-secrets and layers in policy.secret_rules. Raw secrets are never printed —
67
+ only redacted previews. Exits 1 if findings exist, 0 if clean.
68
+ """
69
+ policy = load_policy(policy_path or find_policy(path))
70
+ repo_root = path.resolve() if path.is_dir() else path.resolve().parent
71
+ files = list(walk_repo(path, policy))
72
+ findings = run_scan(files, policy, repo_root=repo_root)
73
+
74
+ if json_out:
75
+ _emit_scan_json(policy, files, findings)
76
+ else:
77
+ _emit_scan_terminal(policy, files, findings)
78
+
79
+ raise typer.Exit(code=1 if findings else 0)
80
+
81
+
82
+ @app.command()
83
+ def context(
84
+ path: Annotated[Path, typer.Argument(help="Path to generate context from.")],
85
+ out: Annotated[
86
+ Path | None,
87
+ typer.Option("--out", help="Output file. Default: stdout."),
88
+ ] = None,
89
+ copy: Annotated[
90
+ bool,
91
+ typer.Option("--copy", help="Copy result to system clipboard."),
92
+ ] = False,
93
+ strict: Annotated[
94
+ bool,
95
+ typer.Option(
96
+ "--strict",
97
+ help="Use strict redaction (alias enums, paraphrase docstrings).",
98
+ ),
99
+ ] = False,
100
+ policy_path: Annotated[
101
+ Path | None,
102
+ typer.Option("--policy", help="Path to .cloakpolicy file."),
103
+ ] = None,
104
+ json_out: Annotated[
105
+ bool,
106
+ typer.Option("--json", help="Emit JSON status instead of generating context."),
107
+ ] = False,
108
+ ) -> None:
109
+ """Generate redacted markdown safe to paste into an LLM.
110
+
111
+ Function bodies are replaced with `...`; module-level UPPER_SNAKE constants holding
112
+ dict/list/set/tuple literals are redacted. `--strict` aliases enum values and strips
113
+ docstrings. Output goes to stdout, `--out`, and/or `--copy` (clipboard) — flags compose.
114
+ """
115
+ policy = load_policy(policy_path or find_policy(path))
116
+ repo_root = path.resolve() if path.is_dir() else path.resolve().parent
117
+ files = list(walk_repo(path, policy))
118
+
119
+ if json_out:
120
+ _emit_context_status_json(policy, files, strict=strict)
121
+ return
122
+
123
+ markdown = run_context(files, policy, strict=strict, repo_root=repo_root)
124
+
125
+ wrote_anywhere = False
126
+ if out is not None:
127
+ out.write_text(markdown, encoding="utf-8")
128
+ console.print(f"[green]✓[/green] wrote {len(markdown):,} chars to {out}")
129
+ wrote_anywhere = True
130
+
131
+ if copy:
132
+ if _copy_to_clipboard(markdown):
133
+ console.print(f"[green]✓[/green] copied {len(markdown):,} chars to clipboard")
134
+ wrote_anywhere = True
135
+ else:
136
+ console.print(
137
+ "[yellow]![/yellow] no clipboard tool found "
138
+ "(install pbcopy / xclip / wl-copy / clip.exe)"
139
+ )
140
+
141
+ if not wrote_anywhere:
142
+ # Default: print to stdout (use plain print to avoid rich wrapping markdown).
143
+ print(markdown)
144
+
145
+
146
+ @app.command()
147
+ def obfuscate(
148
+ path: Annotated[Path, typer.Argument(help="Path to obfuscate.")],
149
+ out: Annotated[Path, typer.Option("--out", help="Output directory.")],
150
+ verify: Annotated[
151
+ str | None,
152
+ typer.Option(
153
+ "--verify",
154
+ help="Test command to run against the output (must pass for the operation to succeed).",
155
+ ),
156
+ ] = None,
157
+ profile: Annotated[
158
+ str,
159
+ typer.Option("--profile", help="Obfuscation profile: standard or aggressive."),
160
+ ] = "standard",
161
+ policy_path: Annotated[
162
+ Path | None,
163
+ typer.Option("--policy", help="Path to .cloakpolicy file."),
164
+ ] = None,
165
+ json_out: Annotated[
166
+ bool,
167
+ typer.Option("--json", help="Emit JSON status instead of running obfuscation."),
168
+ ] = False,
169
+ ) -> None:
170
+ """Produce a transformed copy of a repo, verified against a test command.
171
+
172
+ v1 (Python only): renames module-private `_names`, optionally strips docstrings per
173
+ policy, copies non-Python files unchanged, writes a `cloak-manifest.json` audit trail
174
+ with source/output sha256 hashes and the rename map. If `--verify` is given, runs the
175
+ test command in the output dir; on non-zero exit, the operation fails.
176
+ """
177
+ policy = load_policy(policy_path or find_policy(path))
178
+
179
+ try:
180
+ result = do_obfuscate(
181
+ path,
182
+ out,
183
+ policy,
184
+ verify_command=verify,
185
+ profile=profile,
186
+ )
187
+ except ObfuscateError as e:
188
+ if json_out:
189
+ print(json.dumps({"command": "obfuscate", "status": "error", "error": str(e)}))
190
+ else:
191
+ console.print(f"[red]✗ obfuscate failed:[/red] {e}")
192
+ raise typer.Exit(code=2) from e
193
+
194
+ exit_code = 0
195
+ if verify and result.verify_passed is False:
196
+ exit_code = 1
197
+
198
+ if json_out:
199
+ _emit_obfuscate_json(policy, result)
200
+ else:
201
+ _emit_obfuscate_terminal(policy, result)
202
+
203
+ raise typer.Exit(code=exit_code)
204
+
205
+
206
+ def _emit_obfuscate_json(policy: Policy, result: ObfuscateResult) -> None:
207
+ payload = {
208
+ "command": "obfuscate",
209
+ "status": ("ok" if result.verify_passed in (True, None) else "verify_failed"),
210
+ "output_dir": str(result.output_dir),
211
+ "manifest_path": str(result.manifest_path),
212
+ "files_copied": result.files_copied,
213
+ "files_transformed": result.files_transformed,
214
+ "rename_count": len(result.rename_map),
215
+ "policy_loaded_from": str(policy.source) if policy.source else None,
216
+ "verify_command": result.verify_command,
217
+ "verify_passed": result.verify_passed,
218
+ }
219
+ print(json.dumps(payload, indent=2))
220
+
221
+
222
+ def _emit_obfuscate_terminal(policy: Policy, result: ObfuscateResult) -> None:
223
+ if result.verify_command and result.verify_passed is False:
224
+ console.print(
225
+ Panel.fit(
226
+ f"[bold red]✗ Verify failed[/bold red]\n"
227
+ f"command: {result.verify_command}\n\n"
228
+ f"{(result.verify_output or '').rstrip()[:2000]}",
229
+ border_style="red",
230
+ title="cloak obfuscate",
231
+ )
232
+ )
233
+ console.print(
234
+ " [yellow]Output written, but verification failed — do not ship this bundle.[/yellow]"
235
+ )
236
+ return
237
+
238
+ title = "cloak obfuscate"
239
+ lines = [
240
+ "[bold green]✓ Obfuscated[/bold green]",
241
+ f"output: {result.output_dir}",
242
+ f"manifest: {result.manifest_path}",
243
+ f"transformed: {result.files_transformed} python files",
244
+ f"copied: {result.files_copied} other files",
245
+ f"renames: {len(result.rename_map)} module-private identifiers",
246
+ ]
247
+ if result.verify_command:
248
+ lines.append(f"verify: [green]passed[/green] ({result.verify_command})")
249
+ elif result.verify_command is None:
250
+ lines.append(
251
+ 'verify: [yellow]not run[/yellow] — pass --verify "pytest" to gate the output'
252
+ )
253
+ console.print(Panel.fit("\n".join(lines), border_style="green", title=title))
254
+ if not policy.source:
255
+ console.print(" [dim]no .cloakpolicy found; default obfuscate rules applied[/dim]")
256
+
257
+
258
+ def _copy_to_clipboard(text: str) -> bool:
259
+ """Best-effort copy to system clipboard. Returns True on success."""
260
+ import shutil
261
+ import subprocess
262
+
263
+ candidates: list[list[str]] = []
264
+ if shutil.which("pbcopy"): # macOS
265
+ candidates.append(["pbcopy"])
266
+ if shutil.which("wl-copy"): # Wayland
267
+ candidates.append(["wl-copy"])
268
+ if shutil.which("xclip"): # X11
269
+ candidates.append(["xclip", "-selection", "clipboard"])
270
+ if shutil.which("clip.exe"): # Windows
271
+ candidates.append(["clip.exe"])
272
+
273
+ for cmd in candidates:
274
+ try:
275
+ subprocess.run(cmd, input=text, text=True, check=True, timeout=5)
276
+ return True
277
+ except (subprocess.SubprocessError, OSError):
278
+ continue
279
+ return False
280
+
281
+
282
+ def _emit_context_status_json(policy: Policy, files: list[Path], *, strict: bool) -> None:
283
+ payload = {
284
+ "command": "context",
285
+ "status": "ok",
286
+ "files_discovered": len(files),
287
+ "policy_loaded_from": str(policy.source) if policy.source else None,
288
+ "policy_version": policy.version,
289
+ "strict": strict,
290
+ "implementation_status": "Phase 3 — Python supported; JS/TS in Phase 3.5.",
291
+ }
292
+ print(json.dumps(payload, indent=2))
293
+
294
+
295
+ def _emit_scan_json(policy: Policy, files: list[Path], findings: list[Finding]) -> None:
296
+ payload = {
297
+ "command": "scan",
298
+ "status": "ok" if not findings else "findings",
299
+ "files_scanned": len(files),
300
+ "policy_loaded_from": str(policy.source) if policy.source else None,
301
+ "policy_version": policy.version,
302
+ "findings": [f.to_dict() for f in findings],
303
+ }
304
+ print(json.dumps(payload, indent=2))
305
+
306
+
307
+ def _emit_scan_terminal(policy: Policy, files: list[Path], findings: list[Finding]) -> None:
308
+ if not findings:
309
+ console.print(
310
+ Panel.fit(
311
+ f"[bold green]✓ Clean — {len(files)} files scanned, 0 findings[/bold green]",
312
+ border_style="green",
313
+ title="cloak scan",
314
+ )
315
+ )
316
+ if not policy.source:
317
+ console.print(" [dim]no .cloakpolicy found; default scanner rules applied[/dim]")
318
+ return
319
+
320
+ table = Table(title=f"cloak scan — {len(findings)} finding(s)", header_style="bold red")
321
+ table.add_column("severity", style="bold")
322
+ table.add_column("file")
323
+ table.add_column("line", justify="right")
324
+ table.add_column("rule", style="dim")
325
+ table.add_column("preview")
326
+ for f in findings:
327
+ sev_style = {"high": "red", "medium": "yellow", "low": "cyan"}.get(f.severity, "white")
328
+ table.add_row(
329
+ f"[{sev_style}]{f.severity}[/{sev_style}]",
330
+ f.file,
331
+ str(f.line),
332
+ f.rule_id,
333
+ f.redacted_preview,
334
+ )
335
+ console.print(table)
336
+ console.print(
337
+ f"\n [dim]{len(files)} files scanned • "
338
+ f"policy: {policy.source or 'defaults'}[/dim]\n"
339
+ f" [yellow]Action:[/yellow] rotate any real credentials and remove from source."
340
+ )
341
+
342
+
343
+ def _emit_skeleton_status(
344
+ command: str,
345
+ policy: Policy,
346
+ files: list[Path],
347
+ *,
348
+ json_out: bool,
349
+ ) -> None:
350
+ """Placeholder output for not-yet-implemented commands."""
351
+ if json_out:
352
+ payload = {
353
+ "command": command,
354
+ "status": "scaffold-only",
355
+ "policy_loaded_from": str(policy.source) if policy.source else None,
356
+ "policy_version": policy.version,
357
+ "files_discovered": len(files),
358
+ "implementation_status": ("Scaffolding only — real logic arrives in a later phase."),
359
+ }
360
+ print(json.dumps(payload, indent=2))
361
+ return
362
+
363
+ console.print(f"[bold cyan]cloak {command}[/bold cyan] [dim](scaffold)[/dim]")
364
+ console.print()
365
+ if policy.source:
366
+ console.print(f" policy: {policy.source}")
367
+ else:
368
+ console.print(" policy: [yellow]none found, using defaults[/yellow]")
369
+ console.print(f" files found: {len(files)}")
370
+ console.print()
371
+ console.print(
372
+ f" [yellow]implementation:[/yellow] not yet wired — "
373
+ f"`cloak {command}` arrives in a later phase.\n"
374
+ " See [link=https://github.com/newtophilly/cloak/blob/main/docs/BUILD_PLAN.md]"
375
+ "docs/BUILD_PLAN.md[/link] for the roadmap."
376
+ )
377
+
378
+
379
+ if __name__ == "__main__":
380
+ app()
@@ -0,0 +1 @@
1
+ """Safe context generation for LLM workflows. Phase 3."""
@@ -0,0 +1,255 @@
1
+ """Phase 3 — safe context generator.
2
+
3
+ Walks Python source via the stdlib `ast` module and emits a redacted markdown view safe
4
+ to paste into an LLM:
5
+ - Function/method bodies replaced with `...` (stub-file convention).
6
+ - Module-level UPPER_SNAKE constants holding Dict/List/Set/Tuple literals replaced with `...`
7
+ (this is the "proprietary tables" pattern from the Phase 0 case study).
8
+ - Class bodies preserved with method signatures and (per policy) docstrings.
9
+ - `--strict` mode (or policy `alias_enums: true`): enum values are aliased to opaque names.
10
+ - `--strict` mode also forces `keep_docstrings=False`.
11
+
12
+ Python-only for now. JS/TS lands in Phase 3.5 via tree-sitter; we'll refactor to a shared
13
+ parser interface then.
14
+ """
15
+
16
+ import ast
17
+ from pathlib import Path
18
+
19
+ from cloak import __version__
20
+ from cloak.policy import Policy
21
+
22
+ _ENUM_BASE_NAMES = {"Enum", "IntEnum", "StrEnum", "Flag", "IntFlag"}
23
+
24
+
25
+ def generate(
26
+ paths: list[Path],
27
+ policy: Policy,
28
+ *,
29
+ strict: bool = False,
30
+ repo_root: Path | None = None,
31
+ ) -> str:
32
+ """Return redacted markdown safe to paste into an LLM."""
33
+ if repo_root is None:
34
+ repo_root = paths[0].parent if paths else Path.cwd()
35
+
36
+ # --strict overrides policy: drop docstrings, alias enums, regardless of policy defaults.
37
+ keep_docstrings = policy.context_defaults.keep_docstrings and not strict
38
+ alias_enums = policy.context_defaults.alias_enums or strict
39
+
40
+ sections: list[str] = [_header(policy, strict=strict)]
41
+
42
+ py_files = [p for p in paths if p.suffix == ".py" and p.is_file()]
43
+ other_files = [p for p in paths if p.is_file() and p.suffix != ".py"]
44
+
45
+ sections.append(_file_tree(py_files + other_files, repo_root))
46
+
47
+ for path in py_files:
48
+ sections.append(
49
+ _python_section(
50
+ path,
51
+ repo_root=repo_root,
52
+ keep_docstrings=keep_docstrings,
53
+ alias_enums=alias_enums,
54
+ )
55
+ )
56
+
57
+ if other_files:
58
+ sections.append(_unsupported_files_note(other_files, repo_root))
59
+
60
+ return "\n\n".join(sections) + "\n"
61
+
62
+
63
+ def _header(policy: Policy, *, strict: bool) -> str:
64
+ policy_src = str(policy.source) if policy.source else "defaults"
65
+ strict_note = (
66
+ "\n\nThis is `--strict` mode: enum values are aliased and docstrings are stripped."
67
+ if strict
68
+ else ""
69
+ )
70
+ return (
71
+ f"<!-- generated by cloak {__version__}; "
72
+ f"policy: {policy_src}; strict: {str(strict).lower()} -->\n"
73
+ f"# Safe context\n\n"
74
+ f"This is a redacted view of the source, suitable for sharing with an LLM. "
75
+ f"Function and method bodies are replaced with `...`. "
76
+ f"Module-level constants holding dict/list/set/tuple literals are redacted. "
77
+ f"Imports, class shapes, and signatures are preserved."
78
+ f"{strict_note}"
79
+ )
80
+
81
+
82
+ def _file_tree(files: list[Path], repo_root: Path) -> str:
83
+ if not files:
84
+ return "## Files\n\n_(none)_"
85
+ rels = sorted({_safe_rel(f, repo_root) for f in files})
86
+ body = "\n".join(rels)
87
+ return f"## Files\n\n```\n{body}\n```"
88
+
89
+
90
+ def _python_section(
91
+ path: Path,
92
+ *,
93
+ repo_root: Path,
94
+ keep_docstrings: bool,
95
+ alias_enums: bool,
96
+ ) -> str:
97
+ rel = _safe_rel(path, repo_root)
98
+ try:
99
+ text = path.read_text(encoding="utf-8")
100
+ except OSError as e:
101
+ return f"## `{rel}`\n\n_(unreadable: {e})_"
102
+
103
+ try:
104
+ tree = ast.parse(text)
105
+ except SyntaxError as e:
106
+ return f"## `{rel}`\n\n_(parse error: {e.msg})_"
107
+
108
+ redactor = _Redactor(keep_docstrings=keep_docstrings, alias_enums=alias_enums)
109
+ redacted = redactor.visit(tree)
110
+ ast.fix_missing_locations(redacted)
111
+ return f"## `{rel}`\n\n```python\n{ast.unparse(redacted)}\n```"
112
+
113
+
114
+ def _unsupported_files_note(files: list[Path], repo_root: Path) -> str:
115
+ rels = sorted(_safe_rel(f, repo_root) for f in files)
116
+ items = "\n".join(f"- `{r}`" for r in rels)
117
+ return (
118
+ "## Files not parsed\n\n"
119
+ "These files were discovered but are not yet supported by `cloak context` "
120
+ "(JS/TS arrives in Phase 3.5):\n\n"
121
+ f"{items}"
122
+ )
123
+
124
+
125
+ def _safe_rel(p: Path, repo_root: Path) -> str:
126
+ try:
127
+ return str(p.resolve().relative_to(repo_root.resolve()))
128
+ except ValueError:
129
+ return str(p)
130
+
131
+
132
+ class _Redactor(ast.NodeTransformer):
133
+ """Redact function bodies, sensitive module-level tables, and (optionally) enum values."""
134
+
135
+ def __init__(self, *, keep_docstrings: bool, alias_enums: bool) -> None:
136
+ self.keep_docstrings = keep_docstrings
137
+ self.alias_enums = alias_enums
138
+
139
+ # --- function/method body redaction -------------------------------------------------
140
+
141
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.AST:
142
+ return self._redact_callable(node)
143
+
144
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> ast.AST:
145
+ return self._redact_callable(node)
146
+
147
+ def _redact_callable(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> ast.AST:
148
+ new_body: list[ast.stmt] = []
149
+ if self.keep_docstrings and _is_docstring(node.body[0] if node.body else None):
150
+ new_body.append(node.body[0])
151
+ # `...` ellipsis as the body — Python stub-file convention. Unparses cleanly.
152
+ new_body.append(ast.Expr(value=ast.Constant(value=Ellipsis)))
153
+ node.body = new_body
154
+ return node
155
+
156
+ # --- class redaction (preserve method shapes; optionally alias enum values) ---------
157
+
158
+ def visit_ClassDef(self, node: ast.ClassDef) -> ast.AST:
159
+ if self.alias_enums and _is_enum_class(node):
160
+ return self._alias_enum_class(node)
161
+
162
+ new_body: list[ast.stmt] = []
163
+ for i, stmt in enumerate(node.body):
164
+ if i == 0 and _is_docstring(stmt):
165
+ if self.keep_docstrings:
166
+ new_body.append(stmt)
167
+ continue
168
+ visited = self.visit(stmt)
169
+ if isinstance(visited, ast.stmt):
170
+ new_body.append(visited)
171
+ node.body = new_body or [ast.Pass()]
172
+ return node
173
+
174
+ def _alias_enum_class(self, node: ast.ClassDef) -> ast.ClassDef:
175
+ new_body: list[ast.stmt] = []
176
+ i = 0
177
+ if i < len(node.body) and _is_docstring(node.body[i]):
178
+ if self.keep_docstrings:
179
+ new_body.append(node.body[i])
180
+ i += 1
181
+
182
+ counter = 0
183
+ for stmt in node.body[i:]:
184
+ if (
185
+ isinstance(stmt, ast.Assign)
186
+ and len(stmt.targets) == 1
187
+ and isinstance(stmt.targets[0], ast.Name)
188
+ and isinstance(stmt.value, ast.Constant)
189
+ ):
190
+ aliased = ast.Assign(
191
+ targets=[ast.Name(id=f"VALUE_{counter}", ctx=ast.Store())],
192
+ value=ast.Constant(value=f"V{counter}"),
193
+ )
194
+ new_body.append(aliased)
195
+ counter += 1
196
+ else:
197
+ new_body.append(stmt)
198
+ node.body = new_body or [ast.Pass()]
199
+ return node
200
+
201
+ # --- module-level: redact UPPER_SNAKE table constants ------------------------------
202
+
203
+ def visit_Module(self, node: ast.Module) -> ast.AST:
204
+ new_body: list[ast.stmt] = []
205
+ for i, stmt in enumerate(node.body):
206
+ if i == 0 and _is_docstring(stmt):
207
+ if self.keep_docstrings:
208
+ new_body.append(stmt)
209
+ continue
210
+
211
+ if _is_proprietary_table(stmt):
212
+ # _is_proprietary_table guarantees stmt is an ast.Assign.
213
+ assert isinstance(stmt, ast.Assign)
214
+ redacted = ast.Assign(
215
+ targets=stmt.targets,
216
+ value=ast.Constant(value=Ellipsis),
217
+ )
218
+ new_body.append(redacted)
219
+ continue
220
+
221
+ visited = self.visit(stmt)
222
+ if isinstance(visited, ast.stmt):
223
+ new_body.append(visited)
224
+ node.body = new_body
225
+ return node
226
+
227
+
228
+ def _is_docstring(stmt: ast.stmt | None) -> bool:
229
+ return (
230
+ isinstance(stmt, ast.Expr)
231
+ and isinstance(stmt.value, ast.Constant)
232
+ and isinstance(stmt.value.value, str)
233
+ )
234
+
235
+
236
+ def _is_enum_class(node: ast.ClassDef) -> bool:
237
+ for base in node.bases:
238
+ if isinstance(base, ast.Name) and base.id in _ENUM_BASE_NAMES:
239
+ return True
240
+ if isinstance(base, ast.Attribute) and base.attr in _ENUM_BASE_NAMES:
241
+ return True
242
+ return False
243
+
244
+
245
+ def _is_proprietary_table(stmt: ast.stmt) -> bool:
246
+ """Heuristic: module-level UPPER_SNAKE (or _UPPER_SNAKE) name = {dict/list/set/tuple}."""
247
+ if not isinstance(stmt, ast.Assign) or len(stmt.targets) != 1:
248
+ return False
249
+ target = stmt.targets[0]
250
+ if not isinstance(target, ast.Name):
251
+ return False
252
+ name = target.id.lstrip("_")
253
+ if not name or not name.replace("_", "").isupper() or not any(c.isalpha() for c in name):
254
+ return False
255
+ return isinstance(stmt.value, (ast.Dict, ast.List, ast.Set, ast.Tuple))