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.
- code_jig-0.12.0.dist-info/METADATA +154 -0
- code_jig-0.12.0.dist-info/RECORD +52 -0
- code_jig-0.12.0.dist-info/WHEEL +4 -0
- code_jig-0.12.0.dist-info/entry_points.txt +2 -0
- code_jig-0.12.0.dist-info/licenses/LICENSE +202 -0
- codejig/__init__.py +1 -0
- codejig/__main__.py +106 -0
- codejig/_core/VERSION +2 -0
- codejig/_core/bench/requirements.toml +4 -0
- codejig/_core/core/AGENTS.core.md +9 -0
- codejig/_core/core/agents/explorer.toml +24 -0
- codejig/_core/core/agents/judge.toml +19 -0
- codejig/_core/core/agents/reviewer.toml +10 -0
- codejig/_core/core/tiers.toml +4 -0
- codejig/_core/skills/bug-hunt/SKILL.md +131 -0
- codejig/_core/skills/code-analysis/SKILL.md +72 -0
- codejig/_core/skills/codegen/SKILL.md +46 -0
- codejig/_core/skills/cross-review/SKILL.md +32 -0
- codejig/_core/skills/grilling/SKILL.md +60 -0
- codejig/_core/skills/hld-trace/SKILL.md +69 -0
- codejig/_core/skills/improve/SKILL.md +38 -0
- codejig/_core/skills/redacao/SKILL.md +114 -0
- codejig/_core/skills/redacao/checar.py +75 -0
- codejig/_core/skills/redacao/reference.md +118 -0
- codejig/_core/skills/regression-tests/SKILL.md +53 -0
- codejig/_core/skills/remove-ai-attribution/SKILL.md +33 -0
- codejig/_core/skills/skill-validate/SKILL.md +58 -0
- codejig/_core/skills/web-consensus/SKILL.md +55 -0
- codejig/analyze.py +499 -0
- codejig/atomicwrite.py +49 -0
- codejig/collect.py +311 -0
- codejig/doctor.py +370 -0
- codejig/evidence.py +418 -0
- codejig/feedback.py +165 -0
- codejig/gitgate.py +278 -0
- codejig/hooks.py +64 -0
- codejig/humanize.py +206 -0
- codejig/install.py +381 -0
- codejig/managed.py +64 -0
- codejig/migrate.py +515 -0
- codejig/overlay.py +43 -0
- codejig/paths.py +40 -0
- codejig/probe.py +136 -0
- codejig/review.py +73 -0
- codejig/scrub.py +255 -0
- codejig/skillcheck.py +400 -0
- codejig/skilllint.py +99 -0
- codejig/sources.py +248 -0
- codejig/tiers.py +63 -0
- codejig/tokens.py +118 -0
- codejig/trace.py +137 -0
- 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
|