code-jig 0.12.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.
Files changed (52) hide show
  1. code_jig-0.12.0.dist-info/METADATA +154 -0
  2. code_jig-0.12.0.dist-info/RECORD +52 -0
  3. code_jig-0.12.0.dist-info/WHEEL +4 -0
  4. code_jig-0.12.0.dist-info/entry_points.txt +2 -0
  5. code_jig-0.12.0.dist-info/licenses/LICENSE +202 -0
  6. codejig/__init__.py +1 -0
  7. codejig/__main__.py +106 -0
  8. codejig/_core/VERSION +2 -0
  9. codejig/_core/bench/requirements.toml +4 -0
  10. codejig/_core/core/AGENTS.core.md +9 -0
  11. codejig/_core/core/agents/explorer.toml +24 -0
  12. codejig/_core/core/agents/judge.toml +19 -0
  13. codejig/_core/core/agents/reviewer.toml +10 -0
  14. codejig/_core/core/tiers.toml +4 -0
  15. codejig/_core/skills/bug-hunt/SKILL.md +131 -0
  16. codejig/_core/skills/code-analysis/SKILL.md +72 -0
  17. codejig/_core/skills/codegen/SKILL.md +46 -0
  18. codejig/_core/skills/cross-review/SKILL.md +32 -0
  19. codejig/_core/skills/grilling/SKILL.md +60 -0
  20. codejig/_core/skills/hld-trace/SKILL.md +69 -0
  21. codejig/_core/skills/improve/SKILL.md +38 -0
  22. codejig/_core/skills/redacao/SKILL.md +114 -0
  23. codejig/_core/skills/redacao/checar.py +75 -0
  24. codejig/_core/skills/redacao/reference.md +118 -0
  25. codejig/_core/skills/regression-tests/SKILL.md +53 -0
  26. codejig/_core/skills/remove-ai-attribution/SKILL.md +33 -0
  27. codejig/_core/skills/skill-validate/SKILL.md +58 -0
  28. codejig/_core/skills/web-consensus/SKILL.md +55 -0
  29. codejig/analyze.py +499 -0
  30. codejig/atomicwrite.py +49 -0
  31. codejig/collect.py +311 -0
  32. codejig/doctor.py +370 -0
  33. codejig/evidence.py +418 -0
  34. codejig/feedback.py +165 -0
  35. codejig/gitgate.py +278 -0
  36. codejig/hooks.py +64 -0
  37. codejig/humanize.py +206 -0
  38. codejig/install.py +381 -0
  39. codejig/managed.py +64 -0
  40. codejig/migrate.py +515 -0
  41. codejig/overlay.py +43 -0
  42. codejig/paths.py +40 -0
  43. codejig/probe.py +136 -0
  44. codejig/review.py +73 -0
  45. codejig/scrub.py +255 -0
  46. codejig/skillcheck.py +400 -0
  47. codejig/skilllint.py +99 -0
  48. codejig/sources.py +248 -0
  49. codejig/tiers.py +63 -0
  50. codejig/tokens.py +118 -0
  51. codejig/trace.py +137 -0
  52. codejig/watermark.py +147 -0
codejig/gitgate.py ADDED
@@ -0,0 +1,278 @@
1
+ import os
2
+ import re
3
+ import shlex
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from codejig.scrub import (
9
+ Abort,
10
+ blocker,
11
+ common_dir,
12
+ my_emails,
13
+ range_revs,
14
+ rewrite_range,
15
+ )
16
+ from codejig.watermark import clean_message, has_watermark, is_binary, strip_bytes
17
+
18
+ ZERO = "0" * 40
19
+ AGENT_ENV = ("CODEX_THREAD_ID", "CLAUDECODE")
20
+ DASHES = {"\u2014": "-", "\u2013": "-", "\u2015": "-"}
21
+ PROSE = re.compile(r"(?i)(?:^|/)(?:readme|changelog|changes|history|contributing)(?:\.[^/]*)?$|\.(?:md|mdx|markdown|rst|adoc)$")
22
+ NOT_PROSE = re.compile(r"(?:^|/)(?:AGENTS(?:\.override)?\.md|CLAUDE\.md|SKILL\.md)$|(?:^|/)specs?/")
23
+ VENDORED = re.compile(r"(?:^|/)(?:node_modules|vendor|dist|\.obsidian)/|\.min\.(?:js|css)$")
24
+ HOOK_GIT_ENV = ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR", "GIT_PREFIX",
25
+ "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES")
26
+ GATED = ("commit-msg", "pre-commit", "pre-push")
27
+ HOOKS = ("applypatch-msg", "pre-applypatch", "post-applypatch", "pre-commit", "pre-merge-commit",
28
+ "prepare-commit-msg", "commit-msg", "post-commit", "pre-rebase", "post-checkout", "post-merge",
29
+ "pre-push", "pre-receive", "update", "post-receive", "post-update", "push-to-checkout",
30
+ "pre-auto-gc", "post-rewrite", "sendemail-validate")
31
+
32
+
33
+ def _git(*args: str, stdin: bytes | None = None, check: bool = True) -> bytes:
34
+ r = subprocess.run(["git", *args], capture_output=True, input=stdin, check=False)
35
+ if check and r.returncode:
36
+ raise RuntimeError(f"git {' '.join(args)}: {r.stderr.decode('utf-8', 'replace').strip()}")
37
+ return r.stdout
38
+
39
+
40
+ def undash(data: bytes) -> bytes:
41
+ text = data.decode("utf-8")
42
+ for dash, plain in DASHES.items():
43
+ text = text.replace(dash, plain)
44
+ return text.encode("utf-8")
45
+
46
+
47
+ def commit_msg(path: str) -> int:
48
+ p = Path(path)
49
+ data = p.read_bytes()
50
+ try:
51
+ new = undash(clean_message(data))
52
+ except UnicodeDecodeError:
53
+ return 0
54
+ if new != data:
55
+ p.write_bytes(new)
56
+ print("code-jig: cleaned the commit message (AI attribution, dashes)", file=sys.stderr)
57
+ return 0
58
+
59
+
60
+ def is_prose(path: str) -> bool:
61
+ return bool(PROSE.search(path)) and not NOT_PROSE.search(path)
62
+
63
+
64
+ def _fix(data: bytes, path: str) -> bytes | None:
65
+ if data.isascii() or b"\0" in data[:8192] or is_binary(data):
66
+ return None
67
+ cleaned, _ = strip_bytes(data, attribution=False)
68
+ if is_prose(path):
69
+ cleaned = undash(cleaned)
70
+ return cleaned if cleaned != data else None
71
+
72
+
73
+ def _from_agent() -> bool:
74
+ return any(os.environ.get(k) for k in AGENT_ENV)
75
+
76
+
77
+ def _prose_allowed() -> bool:
78
+ return _git("config", "--local", "--bool", "codejig.allowProse", check=False).strip() == b"true"
79
+
80
+
81
+ def _fix_staged(path: str, mode: str, sha: str) -> bool:
82
+ new = _fix(_git("cat-file", "blob", sha), path)
83
+ if new is not None:
84
+ blob = _git("hash-object", "-w", "--no-filters", "--stdin", stdin=new).decode().strip()
85
+ _git("update-index", "--cacheinfo", f"{mode},{blob},{path}")
86
+ work = Path(path)
87
+ if work.is_file() and not work.is_symlink():
88
+ fixed = _fix(work.read_bytes(), path)
89
+ if fixed is not None:
90
+ work.write_bytes(fixed)
91
+ return new is not None
92
+
93
+
94
+ def _staged() -> list[tuple[str, str, str]]:
95
+ """(path, mode, blob) of every added or modified regular file, renames counted as additions.
96
+
97
+ Read from one raw diff, so a file name is never interpreted as a pathspec."""
98
+ fields = _git("diff", "--cached", "--raw", "-z", "--no-renames", "--no-abbrev", "--diff-filter=AM").split(b"\0")
99
+ out = []
100
+ for meta, path in zip(fields[0::2], fields[1::2], strict=False):
101
+ parts = meta.decode().lstrip(":").split()
102
+ if len(parts) == 5 and parts[1] in ("100644", "100755"):
103
+ out.append((os.fsdecode(path), parts[1], parts[3]))
104
+ return out
105
+
106
+
107
+ def pre_commit(prose: bool) -> int:
108
+ staged = _staged()
109
+ prose_files = [path for path, _, _ in staged if is_prose(path)]
110
+ fixed = [path for path, mode, sha in staged if not VENDORED.search(path) and _fix_staged(path, mode, sha)]
111
+ if fixed:
112
+ print(f"code-jig: replaced invisible characters in {', '.join(fixed)}", file=sys.stderr)
113
+ if prose and prose_files and _from_agent() and not _prose_allowed():
114
+ print("code-jig: refusing to commit prose written in an agent session:", file=sys.stderr)
115
+ for path in prose_files:
116
+ print(f" {path}", file=sys.stderr)
117
+ print("Unstage these files (git restore --staged <file>) and commit the rest; the user reviews and commits "
118
+ "the prose. A repo where agents may commit prose: git config codejig.allowProse true",
119
+ file=sys.stderr)
120
+ return 1
121
+ return 0
122
+
123
+
124
+ def _known(remote: str) -> bool:
125
+ return remote != ZERO and subprocess.run(["git", "cat-file", "-e", f"{remote}^{{commit}}"], capture_output=True, check=False).returncode == 0
126
+
127
+
128
+ def _outgoing(local: str, remote: str) -> list[str]:
129
+ spec = [f"{remote}..{local}"] if _known(remote) else [local, "--not", "--remotes"]
130
+ return _git("rev-list", "--no-merges", *spec).decode().split()
131
+
132
+
133
+ def _cleanup_on(default: bool) -> bool:
134
+ value = _git("config", "--bool", "--get", "codejig.pushCleanup", check=False).strip()
135
+ return {b"true": True, b"false": False}.get(value, default)
136
+
137
+
138
+ def _base(remote: str) -> str | None:
139
+ return remote if _known(remote) else None
140
+
141
+
142
+ def _read(path: Path) -> str:
143
+ return path.read_text(encoding="utf-8", errors="replace") if path.is_file() else ""
144
+
145
+
146
+ def _rebasing(ref: str) -> bool:
147
+ common = common_dir(Path.cwd())
148
+ for gitdir in [common, *common.glob("worktrees/*")]:
149
+ merge, apply = gitdir / "rebase-merge", gitdir / "rebase-apply"
150
+ busy = [_read(merge / "head-name"), _read(apply / "head-name"), *_read(merge / "update-refs").splitlines()]
151
+ if (apply / "applying").exists():
152
+ busy.append(_read(gitdir / "HEAD").strip().removeprefix("ref: "))
153
+ if ref in (b.strip() for b in busy):
154
+ return True
155
+ return False
156
+
157
+
158
+ def _cleanable(ref: str, local: str, remote: str, bad: list[str], emails: set[str]) -> bool:
159
+ if not ref.startswith("refs/heads/") or _git("rev-parse", "-q", "--verify", ref, check=False).decode().strip() != local:
160
+ return False
161
+ if _rebasing(ref) or not set(bad) <= set(_git("rev-list", *bad, "--not", "--remotes").decode().split()):
162
+ return False
163
+ return blocker(Path.cwd(), range_revs(ref, _base(remote)), emails) is None
164
+
165
+
166
+ def _clean(branches: dict[str, tuple[str, str, list[str]]], emails: set[str]) -> bool:
167
+ done, notes = {}, []
168
+ try:
169
+ for ref, (local, remote, _) in branches.items():
170
+ done[ref] = local
171
+ n, bundle = rewrite_range(Path.cwd(), ref, _base(remote), emails)
172
+ notes.append(f"code-jig: cleaned AI attribution from {n} commits on {ref.removeprefix('refs/heads/')}; "
173
+ f"backup {bundle}; run the same push again")
174
+ except Abort as e:
175
+ for ref, old in done.items():
176
+ _git("update-ref", ref, old)
177
+ print(f"code-jig: cleanup aborted, nothing changed: {e}", file=sys.stderr)
178
+ return False
179
+ print("\n".join(notes), file=sys.stderr)
180
+ return True
181
+
182
+
183
+ def _bad_commits(local: str, remote: str, emails: set[str]) -> list[str]:
184
+ bad = []
185
+ for sha in _outgoing(local, remote):
186
+ email, _, body = _git("log", "-1", "--format=%ae%x00%B", sha).partition(b"\0")
187
+ if email.decode("utf-8", "replace").lower() in emails and has_watermark(body):
188
+ bad.append(sha)
189
+ return bad
190
+
191
+
192
+ def _push_ref(ref: str) -> str:
193
+ if ref == "HEAD":
194
+ return _git("symbolic-ref", "-q", "HEAD", check=False).decode().strip() or ref
195
+ return ref
196
+
197
+
198
+ def _collect_problems(lines: list[str], emails: set[str]) -> tuple[list[str], dict]:
199
+ problems, branches = [], {}
200
+ for parts in (line.split() for line in lines):
201
+ if len(parts) != 4 or parts[1] == ZERO:
202
+ continue
203
+ bad = _bad_commits(parts[1], parts[3], emails)
204
+ if bad:
205
+ problems += bad
206
+ branches[_push_ref(parts[0])] = (parts[1], parts[3], bad)
207
+ return problems, branches
208
+
209
+
210
+ def _refuse(problems: list[str]) -> int:
211
+ print("code-jig: push refused, commit messages carry AI attribution:", file=sys.stderr)
212
+ for sha in dict.fromkeys(problems):
213
+ print(f" {sha[:10]} {_git('log', '-1', '--format=%s', sha).decode().strip()}", file=sys.stderr)
214
+ print("Remove it with: code-jig scrub (rewrites messages only, then prints the push commands)", file=sys.stderr)
215
+ return 1
216
+
217
+
218
+ def pre_push(lines: list[str], cleanup: bool = False) -> int:
219
+ emails = my_emails(Path.cwd())
220
+ problems, branches = _collect_problems(lines, emails)
221
+ if not problems:
222
+ return _run_tests()
223
+ if _cleanup_on(cleanup) and all(_cleanable(ref, local, remote, bad, emails)
224
+ for ref, (local, remote, bad) in branches.items()) and _clean(branches, emails):
225
+ return 1
226
+ return _refuse(problems)
227
+
228
+
229
+ def _run_tests() -> int:
230
+ cmd = _git("config", "--get", "codejig.testCommand", check=False).decode("utf-8", "replace").strip()
231
+ if not cmd:
232
+ return 0
233
+ if os.environ.get("CODE_JIG_SKIP_TESTS") == "1":
234
+ print("code-jig: CODE_JIG_SKIP_TESTS=1, skipping the pre-push tests", file=sys.stderr)
235
+ return 0
236
+ try:
237
+ top = _git("rev-parse", "--show-toplevel").decode("utf-8", "replace").strip()
238
+ except RuntimeError:
239
+ top = ""
240
+ if not top:
241
+ print("code-jig: push refused, cannot find the repo top level to run tests", file=sys.stderr)
242
+ return 1
243
+ env = {k: v for k, v in os.environ.items() if k not in HOOK_GIT_ENV}
244
+ rc = subprocess.run(cmd, shell=True, cwd=top, env=env, check=False).returncode
245
+ if rc:
246
+ print(f"code-jig: push refused, tests fail ({cmd} exited {rc})", file=sys.stderr)
247
+ return 1
248
+ return 0
249
+
250
+
251
+ def shims(lib: Path, python: str, prose: bool, cleanup: bool = False) -> dict[str, str]:
252
+ """One POSIX shell hook per git hook name: the gate for the gated ones, then the repo's own hook."""
253
+ run = f"PYTHONPATH={shlex.quote(Path(lib).as_posix())} {shlex.quote(Path(python).as_posix())} -P -s -m codejig git-hook"
254
+ local = 'dir="$(git rev-parse --git-common-dir 2>/dev/null)/hooks"'
255
+ out = {}
256
+ for name in HOOKS:
257
+ if name == "pre-push":
258
+ body = (f'input=$(cat)\nprintf \'%s\\n\' "$input" | {run}{" --cleanup" if cleanup else ""} pre-push "$@" || exit 1\n{local}\n'
259
+ f'[ -x "$dir/{name}" ] && {{ printf \'%s\\n\' "$input" | "$dir/{name}" "$@"; exit $?; }}\nexit 0\n')
260
+ else:
261
+ flag = " --prose" if name == "pre-commit" and prose else ""
262
+ gate = f'{run}{flag} {name} "$@" || exit 1\n' if name in GATED else ""
263
+ body = f'{gate}{local}\n[ -x "$dir/{name}" ] && exec "$dir/{name}" "$@"\nexit 0\n'
264
+ out[name] = "#!/bin/sh\n" + body
265
+ return out
266
+
267
+
268
+ def cli(args) -> int:
269
+ try:
270
+ if args.name == "commit-msg" and args.rest:
271
+ return commit_msg(args.rest[0])
272
+ if args.name == "pre-commit":
273
+ return pre_commit(args.prose)
274
+ if args.name == "pre-push":
275
+ return pre_push(sys.stdin.read().splitlines(), args.cleanup)
276
+ except (OSError, RuntimeError) as e:
277
+ print(f"code-jig: git hook {args.name} skipped: {e}", file=sys.stderr)
278
+ return 0
codejig/hooks.py ADDED
@@ -0,0 +1,64 @@
1
+ import json
2
+ import re
3
+ import sys
4
+
5
+ from codejig.feedback import lint, patch_bodies, tamper
6
+ from codejig.humanize import humanize_file
7
+ from codejig.humanize import report_result as report_humanize
8
+ from codejig.watermark import report_result as report_watermark
9
+ from codejig.watermark import strip_watermark
10
+
11
+ PATCH_PATH = re.compile(r"^\*\*\* (?:Add File|Update File|Move to): (.+?)\s*$", re.MULTILINE)
12
+ CLAUDE_WRITE_TOOLS = {"Write", "Edit", "MultiEdit", "NotebookEdit"}
13
+
14
+
15
+ def changed_paths(tool_name: object, tool_input: object) -> list[str]:
16
+ """Files this tool call wrote, and nothing else.
17
+
18
+ Claude Code's write tools name their file; Codex's apply_patch names its
19
+ targets inside the patch argument. Every other tool, including ones that
20
+ merely read a `path`, gives an empty list.
21
+ """
22
+ if tool_name in CLAUDE_WRITE_TOOLS and isinstance(tool_input, dict):
23
+ path = tool_input.get("file_path") or tool_input.get("notebook_path")
24
+ return [path] if isinstance(path, str) else []
25
+ if tool_name == "apply_patch":
26
+ found = [p for body in patch_bodies(tool_input) for p in PATCH_PATH.findall(body)]
27
+ return list(dict.fromkeys(found))
28
+ return []
29
+
30
+
31
+ def clean(paths: list[str], root: str | None) -> list[str]:
32
+ notes = []
33
+ for path in paths:
34
+ for note in (
35
+ report_watermark(strip_watermark(path, root)),
36
+ report_humanize(humanize_file(path, root)),
37
+ ):
38
+ if note:
39
+ notes.append(note)
40
+ return notes
41
+
42
+
43
+ def handle_posttooluse() -> int:
44
+ """PostToolUse for Claude Code and Codex: clean what the tool wrote, then say what changed."""
45
+ try:
46
+ payload = json.loads(sys.stdin.read() or "{}")
47
+ name, tool_input, cwd = payload.get("tool_name"), payload.get("tool_input"), payload.get("cwd")
48
+ written = changed_paths(name, tool_input)
49
+ notes = clean(written, cwd)
50
+ notes += [n for n in (lint(p, cwd) for p in written) if n]
51
+ notes += tamper(name, tool_input)
52
+ if notes:
53
+ msg = "; ".join(notes)
54
+ sys.stdout.write(json.dumps({
55
+ "systemMessage": msg,
56
+ "hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": msg},
57
+ }) + "\n")
58
+ except Exception as e:
59
+ print(f"watermark: hook error: {e}", file=sys.stderr)
60
+ return 0
61
+
62
+
63
+ def cli(args) -> int:
64
+ return handle_posttooluse()
codejig/humanize.py ADDED
@@ -0,0 +1,206 @@
1
+ import io
2
+ import os
3
+ import re
4
+ import sys
5
+ import tokenize
6
+ from pathlib import Path
7
+
8
+ from codejig.atomicwrite import atomic_write, resolve_inside
9
+
10
+ DASHES = str.maketrans({"—": "-", "–": "-", "―": "-"})
11
+ CURLY_QUOTES = str.maketrans({
12
+ "‘": "'", "’": "'",
13
+ "“": '"', "”": '"',
14
+ })
15
+ ELLIPSIS = "…"
16
+
17
+ LLM_BOILERPLATE = [
18
+ "Here's the updated",
19
+ "Here is the updated",
20
+ "I hope this helps",
21
+ "Let me know if",
22
+ "Feel free to",
23
+ "As an AI",
24
+ "As an assistant",
25
+ "Add more as needed",
26
+ "Rest of the code",
27
+ "... existing code ...",
28
+ "... your code ...",
29
+ "Your code here",
30
+ ]
31
+
32
+ BOILERPLATE_PATTERN = re.compile(
33
+ r"(?:certainly[!.,]?\s*$|(?:" + "|".join(re.escape(p) for p in LLM_BOILERPLATE) + r")(?![\w']))",
34
+ re.IGNORECASE,
35
+ )
36
+
37
+ COMMENT_MARKERS = {
38
+ ".py": "#",
39
+ ".js": ("//", "/*"),
40
+ ".ts": ("//", "/*"),
41
+ ".tsx": ("//", "/*"),
42
+ ".jsx": ("//", "/*"),
43
+ ".mjs": ("//", "/*"),
44
+ ".sh": "#",
45
+ ".bash": "#",
46
+ ".css": "/*",
47
+ ".go": "//",
48
+ ".rs": ("//", "/*"),
49
+ ".java": ("//", "/*"),
50
+ ".c": ("//", "/*"),
51
+ ".cpp": ("//", "/*"),
52
+ ".cc": ("//", "/*"),
53
+ ".cs": ("//", "/*"),
54
+ ".rb": "#",
55
+ ".yaml": "#",
56
+ ".yml": "#",
57
+ ".toml": "#",
58
+ }
59
+
60
+ EMOJI_PATTERN = re.compile(
61
+ r"[\U0001F300-\U0001F9FF]|[☀-➿]|[\U0001F600-\U0001F64F]|[\U0001F900-\U0001F9FF]",
62
+ flags=re.UNICODE
63
+ )
64
+
65
+
66
+ def get_file_extension(path: str) -> str:
67
+ p = Path(path)
68
+ return p.suffix.lower()
69
+
70
+
71
+ def is_comment_line(line: str, markers) -> bool:
72
+ stripped = line.lstrip()
73
+ if isinstance(markers, str):
74
+ return stripped.startswith(markers)
75
+ if isinstance(markers, tuple):
76
+ return any(stripped.startswith(m) for m in markers)
77
+ return False
78
+
79
+
80
+ def is_boilerplate_line(line: str, markers=None) -> bool:
81
+ stripped = line.strip()
82
+ if not stripped:
83
+ return False
84
+
85
+ comment_text = stripped
86
+ if markers:
87
+ if isinstance(markers, str):
88
+ if comment_text.startswith(markers):
89
+ comment_text = comment_text[len(markers):].lstrip()
90
+ elif isinstance(markers, tuple):
91
+ for m in markers:
92
+ if comment_text.startswith(m):
93
+ comment_text = comment_text[len(m):].lstrip()
94
+ break
95
+
96
+ return BOILERPLATE_PATTERN.match(comment_text) is not None
97
+
98
+
99
+ def python_comment_lines(text: str) -> set[int] | None:
100
+ """0-based numbers of lines that hold only a comment, read by the tokenizer so
101
+ a `#` inside a string literal is never mistaken for one. None if it cannot parse."""
102
+ try:
103
+ tokens = list(tokenize.generate_tokens(io.StringIO(text).readline))
104
+ except (tokenize.TokenError, SyntaxError):
105
+ return None
106
+ lines = text.split("\n")
107
+ return {
108
+ t.start[0] - 1
109
+ for t in tokens
110
+ if t.type == tokenize.COMMENT and not lines[t.start[0] - 1][: t.start[1]].strip()
111
+ }
112
+
113
+
114
+ def humanize_text(text: str, markers, comment_lines: set[int] | None = None) -> tuple[str, dict]:
115
+ counts = {"dashes": 0, "quotes": 0, "ellipsis": 0, "emoji": 0, "boilerplate": 0}
116
+ kept = []
117
+ for number, line in enumerate(text.split("\n")):
118
+ is_comment = number in comment_lines if comment_lines is not None else is_comment_line(line, markers)
119
+ if not is_comment:
120
+ kept.append(line)
121
+ continue
122
+ if is_boilerplate_line(line, markers):
123
+ counts["boilerplate"] += 1
124
+ continue
125
+ counts["dashes"] += sum(line.count(d) for d in "—–―")
126
+ counts["quotes"] += sum(1 for c in line if c in "‘’“”")
127
+ counts["ellipsis"] += line.count(ELLIPSIS)
128
+ counts["emoji"] += len(EMOJI_PATTERN.findall(line))
129
+ line = EMOJI_PATTERN.sub("", line.translate(DASHES).translate(CURLY_QUOTES).replace(ELLIPSIS, "..."))
130
+ kept.append(line)
131
+ return "\n".join(kept), counts
132
+
133
+
134
+ def humanize_file(path: str, root: str | None = None, write: bool = True) -> dict:
135
+ result = {"file": path, "dashes": 0, "quotes": 0, "ellipsis": 0, "emoji": 0, "boilerplate": 0}
136
+ markers = COMMENT_MARKERS.get(get_file_extension(path))
137
+ target = resolve_inside(path, root)
138
+ if markers is None or target is None:
139
+ return result
140
+ try:
141
+ original = target.read_bytes()
142
+ if b"\0" in original[:8192]:
143
+ return result
144
+ text = original.decode("utf-8")
145
+ comment_lines = None
146
+ if target.suffix.lower() == ".py":
147
+ comment_lines = python_comment_lines(text)
148
+ if comment_lines is None:
149
+ return result
150
+ new_text, counts = humanize_text(text, markers, comment_lines)
151
+ result.update(counts)
152
+ if new_text != text:
153
+ result["changed"] = True
154
+ if write:
155
+ atomic_write(target, new_text.encode("utf-8"))
156
+ except UnicodeDecodeError:
157
+ return result
158
+ except Exception as e:
159
+ print(f"humanize: error processing {path}: {e}", file=sys.stderr)
160
+ return result
161
+
162
+
163
+ def report_result(result: dict) -> str | None:
164
+ if not result.get("changed"):
165
+ return None
166
+ parts = []
167
+ if result.get("boilerplate"):
168
+ parts.append(f"{result['boilerplate']} boilerplate")
169
+ if result.get("dashes"):
170
+ parts.append(f"{result['dashes']} dashes")
171
+ if result.get("quotes"):
172
+ parts.append(f"{result['quotes']} quotes")
173
+ if result.get("ellipsis"):
174
+ parts.append(f"{result['ellipsis']} ellipsis")
175
+ if result.get("emoji"):
176
+ parts.append(f"{result['emoji']} emoji")
177
+
178
+ msg = f"humanize: {result['file']}: fixed {', '.join(parts)}"
179
+ print(msg, file=sys.stderr)
180
+ return msg
181
+
182
+
183
+ SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build"}
184
+
185
+
186
+ def _files(root: Path):
187
+ if root.is_file():
188
+ yield root
189
+ return
190
+ for dirpath, dirnames, filenames in os.walk(root):
191
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
192
+ for name in filenames:
193
+ yield Path(dirpath) / name
194
+
195
+
196
+ def cli(args) -> int:
197
+ changed = 0
198
+ for path_str in args.path or []:
199
+ start = Path(path_str).resolve()
200
+ root = start if start.is_dir() else start.parent
201
+ for f in _files(start):
202
+ result = humanize_file(str(f), root=str(root), write=not args.check)
203
+ if result.get("changed"):
204
+ changed += 1
205
+ report_result(result)
206
+ return 1 if args.check and changed else 0