cloak-cli 0.1.2__tar.gz → 0.2.0__tar.gz

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.
Files changed (41) hide show
  1. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/CHANGELOG.md +8 -0
  2. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/PKG-INFO +2 -2
  3. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/README.md +1 -1
  4. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/src/cloak/__init__.py +1 -1
  5. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/src/cloak/cli.py +1 -1
  6. cloak_cli-0.2.0/src/cloak/obfuscate/js_transformer.py +175 -0
  7. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/src/cloak/obfuscate/runner.py +22 -3
  8. cloak_cli-0.2.0/tests/test_obfuscate_js.py +201 -0
  9. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/.cloakpolicy.example +0 -0
  10. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/.github/workflows/ci.yml +0 -0
  11. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/.github/workflows/release.yml +0 -0
  12. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/.gitignore +0 -0
  13. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/CONTRIBUTING.md +0 -0
  14. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/LICENSE +0 -0
  15. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/docs/AGENT_INTEGRATION.md +0 -0
  16. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/docs/BUILD_PLAN.md +0 -0
  17. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/docs/research/COMPETITOR_RESEARCH.md +0 -0
  18. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/docs/research/PROMPTS.md +0 -0
  19. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/docs/research/quotecraft.py +0 -0
  20. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/docs/research/quotecraft.redacted.py +0 -0
  21. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/docs/research/result_prompt1.md +0 -0
  22. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/docs/research/result_prompt2.md +0 -0
  23. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/pyproject.toml +0 -0
  24. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/src/cloak/__main__.py +0 -0
  25. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/src/cloak/context/__init__.py +0 -0
  26. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/src/cloak/context/generator.py +0 -0
  27. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/src/cloak/context/js_redactor.py +0 -0
  28. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/src/cloak/filesystem.py +0 -0
  29. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/src/cloak/obfuscate/__init__.py +0 -0
  30. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/src/cloak/obfuscate/manifest.py +0 -0
  31. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/src/cloak/obfuscate/transformer.py +0 -0
  32. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/src/cloak/policy.py +0 -0
  33. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/src/cloak/policy_init.py +0 -0
  34. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/src/cloak/scan/__init__.py +0 -0
  35. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/src/cloak/scan/scanner.py +0 -0
  36. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/tests/__init__.py +0 -0
  37. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/tests/test_cli.py +0 -0
  38. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/tests/test_context.py +0 -0
  39. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/tests/test_context_js.py +0 -0
  40. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/tests/test_obfuscate.py +0 -0
  41. {cloak_cli-0.1.2 → cloak_cli-0.2.0}/tests/test_policy_init.py +0 -0
@@ -6,6 +6,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-05-08
10
+
11
+ Feature matrix complete: all three headline commands now work for both Python and JS/TS.
12
+
13
+ ### Added
14
+ - Phase 5: `cloak obfuscate` now handles JavaScript and TypeScript files, dispatched by extension. Per-file rename of module-level identifiers starting with `_` (function declarations, class declarations, generator functions, `const`/`let`/`var` simple bindings) using tree-sitter for parsing and byte-splice for output. Skips dunder-like names (`__version`), all-underscore names, and anything matching `policy.public_api` (with trailing-`*` wildcard support). Property access (`obj._foo`), shorthand object properties, and destructuring patterns are deliberately not renamed in v1 — they would silently change object shapes and risk runtime breakage. Limitations are caught by `--verify` (cross-file imports, local-variable shadowing of module-level `_names`).
15
+ - `cloak obfuscate` against a mixed Python+JS repo now transforms both languages in one pass with one manifest, one rename map, and one verify command.
16
+
9
17
  ## [0.1.2] - 2026-05-08
10
18
 
11
19
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloak-cli
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Local CLI for safer LLM workflows: redact code before pasting, generate verified obfuscated copies, enforce policy from your repo.
5
5
  Project-URL: Homepage, https://github.com/newtophilly/cloak
6
6
  Project-URL: Repository, https://github.com/newtophilly/cloak
@@ -385,7 +385,7 @@ CLOAK is designed to be called as a subprocess from other developer tools and AI
385
385
  | 3 | `cloak context` for Python | ✅ Done |
386
386
  | 3.5 | `cloak context` JS/TS via tree-sitter | ✅ Done |
387
387
  | 4 | `cloak obfuscate` Python with `--verify` | ✅ Done (v1) |
388
- | 5 | `cloak obfuscate` JS/TS (javascript-obfuscator) | |
388
+ | 5 | `cloak obfuscate` JS/TS via tree-sitter | Done (v1) |
389
389
  | 6 | `cloak eval` (LLM-prompt-based regression harness) | ⏳ |
390
390
 
391
391
  ## Contributing
@@ -144,7 +144,7 @@ CLOAK is designed to be called as a subprocess from other developer tools and AI
144
144
  | 3 | `cloak context` for Python | ✅ Done |
145
145
  | 3.5 | `cloak context` JS/TS via tree-sitter | ✅ Done |
146
146
  | 4 | `cloak obfuscate` Python with `--verify` | ✅ Done (v1) |
147
- | 5 | `cloak obfuscate` JS/TS (javascript-obfuscator) | |
147
+ | 5 | `cloak obfuscate` JS/TS via tree-sitter | Done (v1) |
148
148
  | 6 | `cloak eval` (LLM-prompt-based regression harness) | ⏳ |
149
149
 
150
150
  ## Contributing
@@ -1,3 +1,3 @@
1
1
  """CLOAK — local CLI for safer LLM workflows."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.2.0"
@@ -247,7 +247,7 @@ def _emit_obfuscate_terminal(policy: Policy, result: ObfuscateResult) -> None:
247
247
  "[bold green]✓ Obfuscated[/bold green]",
248
248
  f"output: {result.output_dir}",
249
249
  f"manifest: {result.manifest_path}",
250
- f"transformed: {result.files_transformed} python files",
250
+ f"transformed: {result.files_transformed} source files",
251
251
  f"copied: {result.files_copied} other files",
252
252
  f"renames: {len(result.rename_map)} module-private identifiers",
253
253
  ]
@@ -0,0 +1,175 @@
1
+ """Phase 5 — JS/TS obfuscation transformer.
2
+
3
+ Per-file rename of module-level identifiers that start with `_` (and aren't dunder-like or
4
+ all underscores). Same shape as the Python obfuscator: collect candidate names, build a
5
+ deterministic rename map (`_a000`, `_a001`, ...), then byte-splice every reference of those
6
+ names in the file with their new identifier.
7
+
8
+ Tree-sitter is used as a parser only — we don't unparse. Edits happen at the byte level so
9
+ all formatting and comments outside renamed identifiers survive.
10
+
11
+ v1 limitations (deferred — caught by `--verify`):
12
+ - Cross-file rename. If `import { _helper } from './lib'`, the import keeps `_helper` but
13
+ the target file renames it; tests will fail and the user knows immediately.
14
+ - Local-variable shadowing. If a function body defines `const _helper = ...` shadowing a
15
+ module-level `_helper`, both get renamed; tests will catch logic bugs that arise.
16
+ - Shorthand property in object literals (`{ _helper }`) and destructuring patterns
17
+ (`const { _helper } = ...`) are NOT renamed (would change object shape silently). This
18
+ means a module-level `_helper` referenced via shorthand goes un-renamed there.
19
+ - Class methods are NOT renamed (could be public API).
20
+ """
21
+
22
+ from dataclasses import dataclass, field
23
+
24
+ from tree_sitter import Node, Parser
25
+
26
+ from cloak.context.js_redactor import JsLikeKind, _get_language
27
+ from cloak.policy import Policy
28
+
29
+ _RENAMEABLE_IDENTIFIER_TYPES = {"identifier", "type_identifier"}
30
+
31
+ _DEFINITION_NODE_TYPES = {
32
+ "function_declaration",
33
+ "generator_function_declaration",
34
+ "class_declaration",
35
+ "lexical_declaration",
36
+ "variable_declaration",
37
+ }
38
+
39
+
40
+ @dataclass
41
+ class JsTransformResult:
42
+ """Result of transforming a single JS/TS file."""
43
+
44
+ source_text: str
45
+ output_text: str
46
+ rename_map: dict[str, str] = field(default_factory=dict)
47
+
48
+
49
+ def transform_js_like_source(
50
+ source: str,
51
+ kind: JsLikeKind,
52
+ policy: Policy,
53
+ ) -> JsTransformResult:
54
+ """Apply v1 obfuscation to a JS/TS source string. Returns transformed text + rename map."""
55
+ src_bytes = source.encode("utf-8")
56
+ parser = Parser(_get_language(kind))
57
+ tree = parser.parse(src_bytes)
58
+
59
+ names = _collect_top_level_private_names(tree.root_node, src_bytes, policy)
60
+ rename_map = _build_rename_map(names)
61
+
62
+ if not rename_map:
63
+ return JsTransformResult(source_text=source, output_text=source)
64
+
65
+ edits: list[tuple[int, int, bytes]] = []
66
+ _collect_rename_edits(tree.root_node, src_bytes, rename_map, edits)
67
+
68
+ if not edits:
69
+ return JsTransformResult(source_text=source, output_text=source, rename_map=rename_map)
70
+
71
+ edits.sort(key=lambda e: e[0], reverse=True)
72
+ out = bytearray(src_bytes)
73
+ for start, end, replacement in edits:
74
+ out[start:end] = replacement
75
+ return JsTransformResult(
76
+ source_text=source,
77
+ output_text=out.decode("utf-8", errors="replace"),
78
+ rename_map=rename_map,
79
+ )
80
+
81
+
82
+ def _collect_top_level_private_names(root_node: Node, src: bytes, policy: Policy) -> list[str]:
83
+ """Find top-level definitions whose name starts with `_`. Skips dunders, all-underscore."""
84
+ names: list[str] = []
85
+ seen: set[str] = set()
86
+ public_api = set(policy.public_api)
87
+
88
+ for child in root_node.children:
89
+ # `export ...` wraps a real declaration — peek through one level.
90
+ target = child
91
+ if child.type == "export_statement":
92
+ for inner in child.children:
93
+ if inner.type in _DEFINITION_NODE_TYPES:
94
+ target = inner
95
+ break
96
+
97
+ for name in _extract_definition_names(target, src):
98
+ if _should_rename(name, public_api) and name not in seen:
99
+ seen.add(name)
100
+ names.append(name)
101
+
102
+ return names
103
+
104
+
105
+ def _extract_definition_names(node: Node, src: bytes) -> list[str]:
106
+ """Return the symbol names this top-level definition introduces."""
107
+ if node.type in {
108
+ "function_declaration",
109
+ "generator_function_declaration",
110
+ "class_declaration",
111
+ }:
112
+ name_node = node.child_by_field_name("name")
113
+ if name_node is not None:
114
+ return [_text(name_node, src)]
115
+ return []
116
+
117
+ if node.type in {"lexical_declaration", "variable_declaration"}:
118
+ result: list[str] = []
119
+ for declarator in node.children:
120
+ if declarator.type != "variable_declarator":
121
+ continue
122
+ name_node = declarator.child_by_field_name("name")
123
+ # Only handle the simple `const _foo = ...` case. Destructuring patterns
124
+ # (object_pattern, array_pattern) are skipped — too risky to rename in v1.
125
+ if name_node is not None and name_node.type == "identifier":
126
+ result.append(_text(name_node, src))
127
+ return result
128
+
129
+ return []
130
+
131
+
132
+ def _should_rename(name: str, public_api: set[str]) -> bool:
133
+ if not name.startswith("_"):
134
+ return False
135
+ if name.startswith("__"):
136
+ # Dunder-like; conservative skip (e.g. `__dirname` in CommonJS).
137
+ return False
138
+ if all(c == "_" for c in name):
139
+ return False
140
+ if name in public_api:
141
+ return False
142
+ for pattern in public_api:
143
+ if pattern.endswith("*") and name.startswith(pattern.rstrip("*")):
144
+ return False
145
+ return True
146
+
147
+
148
+ def _build_rename_map(names: list[str]) -> dict[str, str]:
149
+ return {name: f"_a{i:03d}" for i, name in enumerate(names)}
150
+
151
+
152
+ def _collect_rename_edits(
153
+ node: Node,
154
+ src: bytes,
155
+ rename_map: dict[str, str],
156
+ edits: list[tuple[int, int, bytes]],
157
+ ) -> None:
158
+ """Walk the tree; replace `identifier`/`type_identifier` nodes whose text is in the map.
159
+
160
+ Critically: we do NOT touch `property_identifier` (member access — `.foo` is a different
161
+ scope), `shorthand_property_identifier` (would silently change object shape), or
162
+ destructuring identifiers — see v1 limitations in module docstring.
163
+ """
164
+ if node.type in _RENAMEABLE_IDENTIFIER_TYPES:
165
+ text = _text(node, src)
166
+ if text in rename_map:
167
+ edits.append((node.start_byte, node.end_byte, rename_map[text].encode("utf-8")))
168
+ return # leaf
169
+
170
+ for child in node.children:
171
+ _collect_rename_edits(child, src, rename_map, edits)
172
+
173
+
174
+ def _text(node: Node, src: bytes) -> str:
175
+ return src[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
@@ -17,7 +17,9 @@ from dataclasses import dataclass, field
17
17
  from pathlib import Path
18
18
 
19
19
  from cloak import __version__
20
+ from cloak.context.js_redactor import language_for_extension
20
21
  from cloak.filesystem import walk_repo
22
+ from cloak.obfuscate.js_transformer import transform_js_like_source
21
23
  from cloak.obfuscate.manifest import Manifest
22
24
  from cloak.obfuscate.transformer import transform_python_source
23
25
  from cloak.policy import Policy
@@ -81,6 +83,8 @@ def run_obfuscate(
81
83
 
82
84
  source_hashes[str(rel)] = _sha256_file(src_file)
83
85
 
86
+ js_like_kind = language_for_extension(src_file.suffix)
87
+
84
88
  if src_file.suffix == ".py":
85
89
  try:
86
90
  source_text = src_file.read_text(encoding="utf-8")
@@ -91,7 +95,7 @@ def run_obfuscate(
91
95
  continue
92
96
 
93
97
  try:
94
- result = transform_python_source(source_text, policy, file_label=str(rel))
98
+ py_result = transform_python_source(source_text, policy, file_label=str(rel))
95
99
  except SyntaxError:
96
100
  # Don't break on un-parseable files. Copy them through unchanged.
97
101
  shutil.copy2(src_file, out_file)
@@ -99,9 +103,24 @@ def run_obfuscate(
99
103
  files_copied += 1
100
104
  continue
101
105
 
102
- out_file.write_text(result.output_text, encoding="utf-8")
106
+ out_file.write_text(py_result.output_text, encoding="utf-8")
107
+ output_hashes[str(rel)] = _sha256_file(out_file)
108
+ for orig, new in py_result.rename_map.items():
109
+ rename_map_global[f"{rel}:{orig}"] = new
110
+ files_transformed += 1
111
+ elif js_like_kind is not None:
112
+ try:
113
+ source_text = src_file.read_text(encoding="utf-8")
114
+ except (UnicodeDecodeError, OSError):
115
+ shutil.copy2(src_file, out_file)
116
+ output_hashes[str(rel)] = _sha256_file(out_file)
117
+ files_copied += 1
118
+ continue
119
+
120
+ js_result = transform_js_like_source(source_text, js_like_kind, policy)
121
+ out_file.write_text(js_result.output_text, encoding="utf-8")
103
122
  output_hashes[str(rel)] = _sha256_file(out_file)
104
- for orig, new in result.rename_map.items():
123
+ for orig, new in js_result.rename_map.items():
105
124
  rename_map_global[f"{rel}:{orig}"] = new
106
125
  files_transformed += 1
107
126
  else:
@@ -0,0 +1,201 @@
1
+ """Tests for `cloak obfuscate` JS/TS support (Phase 5)."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from typer.testing import CliRunner
7
+
8
+ from cloak.cli import app
9
+ from cloak.obfuscate.js_transformer import transform_js_like_source
10
+ from cloak.obfuscate.runner import run_obfuscate
11
+ from cloak.policy import Policy
12
+
13
+ runner = CliRunner()
14
+
15
+
16
+ # ---------- transformer unit tests ----------
17
+
18
+
19
+ def test_renames_module_level_function() -> None:
20
+ src = (
21
+ "function _helper(x) {\n"
22
+ " return x + 1;\n"
23
+ "}\n"
24
+ "\n"
25
+ "export function publicFn(x) {\n"
26
+ " return _helper(x);\n"
27
+ "}\n"
28
+ )
29
+ result = transform_js_like_source(src, "javascript", Policy())
30
+ assert "_helper" not in result.output_text
31
+ assert "_a000" in result.output_text
32
+ # Public name preserved
33
+ assert "publicFn" in result.output_text
34
+ # Reference inside publicFn correctly rewritten
35
+ assert "_a000(x)" in result.output_text
36
+ assert result.rename_map == {"_helper": "_a000"}
37
+
38
+
39
+ def test_renames_module_level_const() -> None:
40
+ src = "const _internal = 42;\nexport const value = _internal * 2;\n"
41
+ result = transform_js_like_source(src, "javascript", Policy())
42
+ assert "_internal" not in result.output_text
43
+ assert "_a000" in result.output_text
44
+ assert "value = _a000 * 2" in result.output_text
45
+
46
+
47
+ def test_renames_module_level_class() -> None:
48
+ src = (
49
+ "class _Internal {\n"
50
+ " constructor() { this.x = 1; }\n"
51
+ "}\n"
52
+ "\n"
53
+ "export function make() {\n"
54
+ " return new _Internal();\n"
55
+ "}\n"
56
+ )
57
+ result = transform_js_like_source(src, "javascript", Policy())
58
+ assert "_Internal" not in result.output_text
59
+ assert "_a000" in result.output_text
60
+ assert "new _a000()" in result.output_text
61
+
62
+
63
+ def test_does_not_rename_dunder_like_names() -> None:
64
+ src = "const __version = '1.0';\nconst _foo = 1;\n"
65
+ result = transform_js_like_source(src, "javascript", Policy())
66
+ assert "__version" in result.output_text # preserved
67
+ assert "_foo" not in result.output_text # renamed
68
+ assert "_a000" in result.output_text
69
+
70
+
71
+ def test_respects_public_api_in_policy() -> None:
72
+ src = "export function _publicHelper(x) { return x; }\n"
73
+ policy = Policy(public_api=["_publicHelper"])
74
+ result = transform_js_like_source(src, "javascript", policy)
75
+ assert "_publicHelper" in result.output_text
76
+ assert not result.rename_map
77
+
78
+
79
+ def test_handles_export_statement_with_lexical_declaration() -> None:
80
+ src = "export const _privateUtil = (x) => x * 2;\n"
81
+ result = transform_js_like_source(src, "javascript", Policy())
82
+ # The name should still be renamed even though it's wrapped in `export`.
83
+ assert "_privateUtil" not in result.output_text
84
+ assert "_a000" in result.output_text
85
+
86
+
87
+ def test_preserves_property_access_with_same_name() -> None:
88
+ """obj._helper is a member access — different scope; we don't rename it."""
89
+ src = (
90
+ "function _helper(x) { return x * 2; }\n"
91
+ "const obj = { _helper: 'something else' };\n"
92
+ "export function go() {\n"
93
+ " return _helper(obj._helper);\n"
94
+ "}\n"
95
+ )
96
+ result = transform_js_like_source(src, "javascript", Policy())
97
+ # The bare `_helper` reference (function call) should be renamed.
98
+ # The property access `obj._helper` should NOT be renamed (different scope).
99
+ assert "obj._helper" in result.output_text
100
+ # The standalone calls should be renamed.
101
+ assert "_a000(obj._helper)" in result.output_text
102
+
103
+
104
+ def test_typescript_renames_top_level_types() -> None:
105
+ """TypeScript: rename top-level _-prefixed type identifiers as well."""
106
+ src = (
107
+ "type _Internal = { x: number };\nexport function make(): _Internal { return { x: 1 }; }\n"
108
+ )
109
+ result = transform_js_like_source(src, "typescript", Policy())
110
+ # type_alias_declaration: tree-sitter-typescript should expose this; for v1, our
111
+ # _DEFINITION_NODE_TYPES doesn't include it, so the rename won't happen. This is
112
+ # documented as a limitation. The point of this test: confirm we don't crash.
113
+ assert result.output_text # non-empty
114
+ # The visible behavior may or may not rename; either is acceptable for v1.
115
+
116
+
117
+ def test_returns_unchanged_when_no_private_names() -> None:
118
+ src = "export function pub(x) { return x; }\n"
119
+ result = transform_js_like_source(src, "javascript", Policy())
120
+ assert result.output_text == src
121
+ assert not result.rename_map
122
+
123
+
124
+ def test_handles_invalid_source_gracefully() -> None:
125
+ src = "function broken( {{{"
126
+ # Should not raise.
127
+ transform_js_like_source(src, "javascript", Policy())
128
+
129
+
130
+ # ---------- pipeline integration ----------
131
+
132
+
133
+ def _make_js_repo(tmp_path: Path) -> Path:
134
+ """Sample JS repo: lib + matching test that imports the public function."""
135
+ repo = tmp_path / "src_repo"
136
+ repo.mkdir()
137
+ (repo / "lib.js").write_text(
138
+ "function _double(x) {\n"
139
+ " return x * 2;\n"
140
+ "}\n"
141
+ "\n"
142
+ "export function publicFn(x) {\n"
143
+ " return _double(x);\n"
144
+ "}\n"
145
+ )
146
+ (repo / "package.json").write_text('{"type":"module"}\n')
147
+ return repo
148
+
149
+
150
+ def test_runner_handles_mixed_python_and_js(tmp_path: Path) -> None:
151
+ repo = tmp_path / "repo"
152
+ repo.mkdir()
153
+ (repo / "lib.py").write_text(
154
+ "def _helper(x):\n return x + 1\n\ndef pub(x):\n return _helper(x)\n"
155
+ )
156
+ (repo / "lib.js").write_text(
157
+ "function _helper(x) { return x + 1; }\nexport function pub(x) { return _helper(x); }\n"
158
+ )
159
+ out = tmp_path / "obfuscated"
160
+
161
+ result = run_obfuscate(repo, out, Policy())
162
+ assert result.files_transformed == 2 # both py and js were transformed
163
+ py_text = (out / "lib.py").read_text()
164
+ js_text = (out / "lib.js").read_text()
165
+ assert "_helper" not in py_text
166
+ assert "_helper" not in js_text
167
+ assert "_a000" in py_text
168
+ assert "_a000" in js_text
169
+
170
+
171
+ def test_runner_writes_manifest_with_js_renames(tmp_path: Path) -> None:
172
+ repo = _make_js_repo(tmp_path)
173
+ out = tmp_path / "obfuscated"
174
+ run_obfuscate(repo, out, Policy())
175
+
176
+ manifest = json.loads((out / "cloak-manifest.json").read_text())
177
+ assert "lib.js:_double" in manifest["rename_map"]
178
+ assert manifest["rename_map"]["lib.js:_double"] == "_a000"
179
+
180
+
181
+ # ---------- CLI integration ----------
182
+
183
+
184
+ def test_cli_obfuscate_against_js_repo(tmp_path: Path) -> None:
185
+ repo = _make_js_repo(tmp_path)
186
+ out = tmp_path / "obf"
187
+ result = runner.invoke(app, ["obfuscate", str(repo), "--out", str(out)])
188
+ assert result.exit_code == 0, result.stdout
189
+ obfuscated = (out / "lib.js").read_text()
190
+ assert "_double" not in obfuscated
191
+ assert "_a000" in obfuscated
192
+ assert "publicFn" in obfuscated # public preserved
193
+
194
+
195
+ def test_cli_obfuscate_panel_shows_renames(tmp_path: Path) -> None:
196
+ repo = _make_js_repo(tmp_path)
197
+ out = tmp_path / "obf"
198
+ result = runner.invoke(app, ["obfuscate", str(repo), "--out", str(out)])
199
+ assert result.exit_code == 0
200
+ assert "Obfuscated" in result.stdout
201
+ assert "1 module-private" in result.stdout or "renames:" in result.stdout
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes