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/sources.py ADDED
@@ -0,0 +1,248 @@
1
+ import contextlib
2
+ import errno
3
+ import glob
4
+ import http.client
5
+ import ipaddress
6
+ import json
7
+ import os
8
+ import re
9
+ import socket
10
+ import sys
11
+ import urllib.error
12
+ import urllib.request
13
+ from concurrent.futures import ThreadPoolExecutor
14
+ from dataclasses import dataclass
15
+ from html.parser import HTMLParser
16
+
17
+ MAX_BYTES = 3_000_000
18
+ TIMEOUT = 20
19
+ USER_AGENT = "Mozilla/5.0 (compatible; code-jig check-sources)"
20
+ BLOCKED = {401, 403, 429, 451}
21
+ SKIP_TAGS = {"script", "style", "noscript", "template"}
22
+ TEXT_TYPES = ("text/", "application/xhtml", "application/xml", "application/json")
23
+ FAILING = {"dead", "quote-missing", "bad-url", "no-quote", "malformed"}
24
+
25
+
26
+ @dataclass
27
+ class Claim:
28
+ where: str
29
+ claim: str = ""
30
+ url: str = ""
31
+ quote: str = ""
32
+ verdict: str = ""
33
+ detail: str = ""
34
+
35
+
36
+ class _Text(HTMLParser):
37
+ def __init__(self):
38
+ super().__init__(convert_charrefs=True)
39
+ self.parts, self.skip = [], 0
40
+
41
+ def handle_starttag(self, tag, _attrs):
42
+ self.skip += tag in SKIP_TAGS
43
+ self.parts.append(" ")
44
+
45
+ def handle_endtag(self, tag):
46
+ self.skip -= tag in SKIP_TAGS and self.skip > 0
47
+ self.parts.append(" ")
48
+
49
+ def handle_data(self, data):
50
+ if not self.skip:
51
+ self.parts.append(data)
52
+
53
+
54
+ def normalize(text: str) -> str:
55
+ return " ".join(re.sub(r"[\W_]+", " ", text.casefold()).split())
56
+
57
+
58
+ def page_text(body: bytes, ctype: str) -> str:
59
+ charset = re.search(r"charset=([\w-]+)", ctype)
60
+ text = body.decode(charset.group(1) if charset else "utf-8", errors="replace")
61
+ if "html" in ctype or "xml" in ctype:
62
+ parser = _Text()
63
+ try:
64
+ parser.feed(text)
65
+ except AssertionError:
66
+ pass
67
+ text = "".join(parser.parts)
68
+ return normalize(text)
69
+
70
+
71
+ def is_public_ip(ip: str) -> bool:
72
+ return ipaddress.ip_address(ip.split("%")[0]).is_global
73
+
74
+
75
+ def is_public(host: str) -> bool | None:
76
+ """True when every address the host resolves to is public, False when any is not,
77
+ None when it does not resolve here."""
78
+ try:
79
+ infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
80
+ except (OSError, UnicodeError):
81
+ return None
82
+ return all(is_public_ip(info[4][0]) for info in infos)
83
+
84
+
85
+ def _target(url: str) -> tuple[str, str] | None:
86
+ """(scheme, host) exactly as urllib and http.client will parse them for the connection."""
87
+ try:
88
+ req = urllib.request.Request(url)
89
+ return req.type.lower(), http.client.HTTPConnection(req.host).host
90
+ except (ValueError, http.client.InvalidURL):
91
+ return None
92
+
93
+
94
+ def refused(url: str) -> str | None:
95
+ """Why `url` must not be requested, or None. Through a proxy, a host that does not resolve here
96
+ is refused: the proxy may resolve internal names this machine cannot."""
97
+ target = _target(url)
98
+ if target is None or target[0] not in ("http", "https") or not target[1]:
99
+ return "only http and https are checked"
100
+ public = is_public(target[1])
101
+ if public is False:
102
+ return f"{target[1]} is a private or local address"
103
+ if public is None and proxied(url):
104
+ return f"cannot confirm {target[1]} is public: it does not resolve here and a proxy would fetch it"
105
+ return None
106
+
107
+
108
+ def proxied(url: str) -> bool:
109
+ target = _target(url)
110
+ proxies = urllib.request.getproxies()
111
+ return bool(target) and target[0] in proxies and not urllib.request.proxy_bypass(target[1])
112
+
113
+
114
+ def _check_peer(conn: http.client.HTTPConnection) -> None:
115
+ if conn._tunnel_host:
116
+ return
117
+ ip = conn.sock.getpeername()[0]
118
+ if not is_public_ip(ip):
119
+ conn.close()
120
+ raise OSError(f"refused: {ip} is a private or local address")
121
+
122
+
123
+ class _HTTPConnection(http.client.HTTPConnection):
124
+ def connect(self):
125
+ super().connect()
126
+ _check_peer(self)
127
+
128
+
129
+ class _HTTPSConnection(http.client.HTTPSConnection):
130
+ def connect(self):
131
+ super().connect()
132
+ _check_peer(self)
133
+
134
+
135
+ class _HTTPHandler(urllib.request.HTTPHandler):
136
+ def http_open(self, req):
137
+ return self.do_open(_HTTPConnection, req)
138
+
139
+
140
+ class _HTTPSHandler(urllib.request.HTTPSHandler):
141
+ def https_open(self, req):
142
+ return self.do_open(_HTTPSConnection, req, context=self._context)
143
+
144
+
145
+ class _Redirects(urllib.request.HTTPRedirectHandler):
146
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
147
+ reason = refused(newurl)
148
+ if reason:
149
+ raise urllib.error.URLError(f"redirect refused: {reason}")
150
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
151
+
152
+
153
+ DIRECT = urllib.request.build_opener(urllib.request.ProxyHandler({}), _HTTPHandler, _HTTPSHandler, _Redirects)
154
+ VIA_PROXY = urllib.request.build_opener(_Redirects)
155
+
156
+
157
+ def fetch(url: str) -> tuple[str, str]:
158
+ """(verdict, page text or detail) for one URL. Only public http and https addresses are requested,
159
+ redirects included; without a proxy, the address the socket actually connected to is checked too."""
160
+ reason = refused(url)
161
+ if reason:
162
+ return "bad-url", reason
163
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "text/html,text/plain;q=0.9,*/*;q=0.5"})
164
+ try:
165
+ with (VIA_PROXY if proxied(url) else DIRECT).open(req, timeout=TIMEOUT) as r:
166
+ ctype = r.headers.get("Content-Type", "")
167
+ if not ctype.startswith(TEXT_TYPES):
168
+ return "unreadable", ctype or "unknown type"
169
+ return "ok", page_text(r.read(MAX_BYTES), ctype)
170
+ except urllib.error.HTTPError as e:
171
+ e.close()
172
+ return ("blocked" if e.code in BLOCKED else "dead"), f"HTTP {e.code}"
173
+ except (urllib.error.URLError, OSError, ValueError) as e:
174
+ return "dead", str(getattr(e, "reason", e))
175
+
176
+
177
+ def expand(patterns: list[str]) -> list[str]:
178
+ """File names with shell patterns expanded here, so PowerShell and cmd behave like a POSIX shell."""
179
+ out = []
180
+ for p in patterns:
181
+ hits = (sorted(glob.glob(p)) or ([p] if os.path.exists(p) else [])) if glob.has_magic(p) else [p]
182
+ if not hits:
183
+ raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), p)
184
+ out += hits
185
+ return out
186
+
187
+
188
+ def _pre_verdict(c: Claim) -> None:
189
+ if not c.url:
190
+ c.verdict, c.detail = "malformed", "no url"
191
+ elif not c.url.lower().startswith(("http://", "https://")):
192
+ c.verdict, c.detail = "bad-url", "only http and https are checked"
193
+ elif not normalize(c.quote):
194
+ c.verdict, c.detail = "no-quote", "a claim needs an exact quote with words in it"
195
+
196
+
197
+ def _parse_line(where: str, line: str) -> Claim:
198
+ c = Claim(where)
199
+ try:
200
+ row = json.loads(line)
201
+ c.claim, c.url, c.quote = (str(row.get(k) or "").strip() for k in ("claim", "url", "quote"))
202
+ except (ValueError, AttributeError):
203
+ c.verdict, c.detail = "malformed", "not a JSON object"
204
+ return c
205
+ _pre_verdict(c)
206
+ return c
207
+
208
+
209
+ def load(files: list[str]) -> list[Claim]:
210
+ claims = []
211
+ for name in expand(files):
212
+ with contextlib.nullcontext(sys.stdin) if name == "-" else open(name, encoding="utf-8-sig") as f:
213
+ claims += [_parse_line(f"{name}:{n}", line) for n, line in enumerate(f, 1) if line.strip()]
214
+ return claims
215
+
216
+
217
+ def check(claims: list[Claim]) -> list[Claim]:
218
+ todo = [c for c in claims if not c.verdict]
219
+ urls = list(dict.fromkeys(c.url for c in todo))
220
+ with ThreadPoolExecutor(max_workers=8) as pool:
221
+ pages = dict(zip(urls, pool.map(fetch, urls), strict=True))
222
+ for c in todo:
223
+ verdict, text = pages[c.url]
224
+ if verdict != "ok":
225
+ c.verdict, c.detail = verdict, text
226
+ elif normalize(c.quote) in text:
227
+ c.verdict = "ok"
228
+ else:
229
+ c.verdict, c.detail = "quote-missing", "the page does not contain the quote"
230
+ return claims
231
+
232
+
233
+ def cli(args) -> int:
234
+ if os.environ.get("CODEX_SANDBOX_NETWORK_DISABLED") == "1":
235
+ print("code-jig: network is disabled in this sandbox; run check-sources with escalation", file=sys.stderr)
236
+ return 2
237
+ try:
238
+ claims = check(load(args.files))
239
+ except OSError as e:
240
+ print(f"code-jig: {e}", file=sys.stderr)
241
+ return 2
242
+ counts: dict[str, int] = {}
243
+ for c in claims:
244
+ counts[c.verdict] = counts.get(c.verdict, 0) + 1
245
+ note = f" ({c.detail})" if c.detail else ""
246
+ print(f"{c.verdict:13} {c.where} {c.claim[:70]} {c.url}{note}")
247
+ print(f"{len(claims)} claims: " + ", ".join(f"{n} {v}" for v, n in sorted(counts.items(), key=lambda kv: kv[0] != "ok")))
248
+ return 1 if FAILING & counts.keys() else 0
codejig/tiers.py ADDED
@@ -0,0 +1,63 @@
1
+ import json
2
+ import tomllib
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Tier:
9
+ name: str
10
+ model: str
11
+ effort: str
12
+
13
+
14
+ def read_toml(path: Path) -> dict:
15
+ try:
16
+ return tomllib.loads(path.read_text(encoding="utf-8-sig"))
17
+ except FileNotFoundError:
18
+ raise ValueError(f"{path}: not found") from None
19
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError) as e:
20
+ raise ValueError(f"{path}: {e}") from e
21
+
22
+
23
+ def _strings(path: Path, data: dict) -> dict:
24
+ bad = [k for k, v in data.items() if not isinstance(v, str) or not v.strip()]
25
+ if bad:
26
+ raise ValueError(f"{path}: {', '.join(bad)} must be a non-empty string")
27
+ return data
28
+
29
+
30
+ def load(core: Path, overlay_root: Path) -> list[Tier]:
31
+ efforts_path = core / "core" / "tiers.toml"
32
+ models_path = overlay_root / "profiles.toml"
33
+ efforts = _strings(efforts_path, read_toml(efforts_path))
34
+ models = _strings(models_path, read_toml(models_path))
35
+ missing = [t for t in efforts if t not in models]
36
+ if missing:
37
+ raise ValueError(f"{models_path}: no model for tier {', '.join(missing)}")
38
+ unknown = [t for t in models if t not in efforts]
39
+ if unknown:
40
+ raise ValueError(f"{models_path}: unknown tier {', '.join(unknown)}; tiers are {', '.join(efforts)}")
41
+ return [Tier(name, models[name], effort) for name, effort in efforts.items()]
42
+
43
+
44
+ def extra_policy(overlay_root: Path) -> str:
45
+ path = overlay_root / "guardian-policy.md"
46
+ return path.read_text(encoding="utf-8-sig").strip() if path.is_file() else ""
47
+
48
+
49
+ def toml_str(s: str) -> str:
50
+ return json.dumps(s, ensure_ascii=False).replace("\x7f", "\\u007f")
51
+
52
+
53
+ def profile_body(tier: Tier, policy: str, compact: int | None = None) -> str:
54
+ lines = [
55
+ f"model = {toml_str(tier.model)}",
56
+ f"model_reasoning_effort = {toml_str(tier.effort)}",
57
+ 'approvals_reviewer = "auto_review"',
58
+ ]
59
+ if compact is not None:
60
+ lines.append(f"model_auto_compact_token_limit = {compact}")
61
+ if policy:
62
+ lines += ["", "[auto_review]", f"extra_policy = {toml_str(policy)}"]
63
+ return "\n".join(lines) + "\n"
codejig/tokens.py ADDED
@@ -0,0 +1,118 @@
1
+ import sqlite3
2
+ import sys
3
+ from collections import Counter, defaultdict
4
+ from datetime import UTC, datetime, timedelta
5
+
6
+ from codejig import collect
7
+
8
+ TWO_WORD = {"git", "npm", "pnpm", "yarn", "cargo", "go", "uv", "dotnet", "docker", "kubectl", "gh"}
9
+ POLL = ("sleep", "wait")
10
+
11
+
12
+ def prefix(cmd: str) -> str:
13
+ words = [w for w in cmd.split() if not ("=" in w and not w.startswith("-") and w.split("=", 1)[0].isidentifier())]
14
+ if not words:
15
+ return ""
16
+ first = words[0].replace("\\", "/").rsplit("/", 1)[-1]
17
+ if first in TWO_WORD and len(words) > 1 and not words[1].startswith("-"):
18
+ return f"{first} {words[1]}"
19
+ return first
20
+
21
+
22
+ def _tally(rows: list, comps: Counter) -> tuple[dict, dict, dict, set]:
23
+ by_kind: dict[str, int] = defaultdict(int)
24
+ models: dict[str, list[int]] = defaultdict(lambda: [0, 0])
25
+ sessions: dict[str, dict] = {}
26
+ reviews = set()
27
+ for thread, kind, parent, turn, model, inp, cached, out in rows:
28
+ by_kind[kind] += inp
29
+ models[model or "(unknown)"][0] += inp
30
+ models[model or "(unknown)"][1] += out
31
+ owner = parent if kind != "main" and parent else thread
32
+ s = sessions.setdefault(owner, {"id": owner, "turns": 0, "input": 0, "cached": 0, "guardian_input": 0,
33
+ "subagent_input": 0, "compactions": comps.get(owner, 0), "polling": 0})
34
+ if kind == "guardian":
35
+ s["guardian_input"] += inp
36
+ reviews.add((thread, turn))
37
+ elif kind == "subagent":
38
+ s["subagent_input"] += inp
39
+ else:
40
+ s["input"] += inp
41
+ s["cached"] += cached
42
+ s["turns"] += 1
43
+ return by_kind, models, sessions, reviews
44
+
45
+
46
+ def _count_polling(calls: list, sessions: dict) -> None:
47
+ for thread, cmd, _ in calls:
48
+ if thread in sessions and cmd.lstrip().split(" ", 1)[0] in POLL:
49
+ sessions[thread]["polling"] += 1
50
+
51
+
52
+ def summary(con: sqlite3.Connection, since: str, top: int = 10, long_turns: int = 300) -> dict:
53
+ rows = con.execute("SELECT thread_id, kind, parent_id, turn_id, model, input, cached, output FROM turn_usage WHERE ts >= ?", (since,)).fetchall()
54
+ calls = con.execute("SELECT thread_id, cmd, escalated FROM calls WHERE ts >= ?", (since,)).fetchall()
55
+ comps = Counter(t for (t,) in con.execute("SELECT thread_id FROM compactions WHERE ts >= ?", (since,)))
56
+ by_kind, models, sessions, reviews = _tally(rows, comps)
57
+ _count_polling(calls, sessions)
58
+ total = sum(by_kind.values())
59
+ guardian = by_kind.get("guardian", 0)
60
+ ranked = sorted(sessions.values(), key=lambda s: s["input"] + s["guardian_input"] + s["subagent_input"], reverse=True)
61
+ return {
62
+ "input": total,
63
+ "cached": sum(r[6] for r in rows),
64
+ "output": sum(r[7] for r in rows),
65
+ "by_kind": dict(by_kind),
66
+ "guardian_share": guardian / total if total else 0.0,
67
+ "reviews": len(reviews),
68
+ "input_per_review": guardian / len(reviews) if reviews else 0,
69
+ "escalations": Counter(prefix(c) for _, c, e in calls if e).most_common(),
70
+ "sessions": ranked[:top],
71
+ "over_long": sum(1 for s in sessions.values() if s["turns"] > long_turns),
72
+ "models": sorted(((m, v[0], v[1]) for m, v in models.items()), key=lambda r: r[1], reverse=True),
73
+ "quarantined": con.execute("SELECT COUNT(*) FROM quarantine").fetchone()[0],
74
+ }
75
+
76
+
77
+ def _safe(text: str) -> str:
78
+ return "".join(ch if ch.isprintable() else f"\\x{ord(ch):02x}" for ch in text)
79
+
80
+
81
+ def _pct(a: int, b: int) -> str:
82
+ return f"{100 * a / b:.1f}%" if b else "-"
83
+
84
+
85
+ def render(s: dict, floor: float) -> list[str]:
86
+ out = [
87
+ f"Input {s['input']:,} cached {_pct(s['cached'], s['input'])} output {s['output']:,}",
88
+ f"Guardian share of input: {100 * s['guardian_share']:.1f}% reviews {s['reviews']} input per review {s['input_per_review']:,.0f}",
89
+ f"Sessions over 300 model requests: {s['over_long']}",
90
+ "",
91
+ "Top sessions (own input, cache hit, model requests, compactions, polling calls, Guardian input, subagent input):",
92
+ ]
93
+ for x in s["sessions"]:
94
+ hit = 100 * x["cached"] / x["input"] if x["input"] else 100.0
95
+ flag = " LOW" if x["input"] and hit < floor else ""
96
+ out.append(f" {_safe(x['id'])} {x['input']:,} {hit:.1f}%{flag} {x['turns']} {x['compactions']} {x['polling']} {x['guardian_input']:,} {x['subagent_input']:,}")
97
+ out += ["", "Escalations sent to Guardian, by command:"]
98
+ out += [f" {_safe(p) or '(empty)'} {n}" for p, n in s["escalations"]] or [" none"]
99
+ out += ["", "By model (input, output):"]
100
+ out += [f" {_safe(m)} {i:,} {o:,}" for m, i, o in s["models"]] or [" none"]
101
+ if s["quarantined"]:
102
+ out += ["", f"warning: {s['quarantined']} rollout lines quarantined; see the quarantine table"]
103
+ return out
104
+
105
+
106
+ def cli(args) -> int:
107
+ path = collect.db_path()
108
+ if not path.is_file():
109
+ print(f"code-jig: {path} not found; run code-jig collect", file=sys.stderr)
110
+ return 2
111
+ con = collect.connect(path)
112
+ try:
113
+ since = (datetime.now(UTC) - timedelta(days=args.days)).strftime("%Y-%m-%dT%H:%M:%SZ")
114
+ for line in render(summary(con, since, args.top), args.cache_floor):
115
+ print(line)
116
+ finally:
117
+ con.close()
118
+ return 0
codejig/trace.py ADDED
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import codecs
4
+ import json
5
+ import re
6
+ import sys
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from codejig.analyze import printable, source_files, utf8_stdout
11
+
12
+ TEST_PATH = re.compile(
13
+ r"(^|/)(tests?|__tests__|specs?)/"
14
+ r"|\.(test|spec)\.[cm]?[jt]sx?$"
15
+ r"|(^|/)test_[^/]*\.py$"
16
+ r"|_test\.py$"
17
+ r"|Tests?\.cs$"
18
+ r"|(^|/)[^/]*\.Tests?/"
19
+ )
20
+ ID_CHARS = r"[A-Za-z0-9_]+(?:[.-][A-Za-z0-9_]+)*"
21
+ MAX_BYTES = 2_000_000
22
+ LINE_ID = re.compile(rf"^\s*(?:[-*+]\s+|\d+[.)]\s+)?#?(?:REQ-)?({ID_CHARS})", re.IGNORECASE)
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Result:
27
+ covered: dict[str, list[str]]
28
+ missing: list[str]
29
+ unknown: dict[str, list[str]]
30
+
31
+
32
+ def _norm(value) -> str:
33
+ text = str(value).strip().lstrip("#")
34
+ return text[4:] if text.lower().startswith("req-") else text
35
+
36
+
37
+ def _json_ids(data) -> list:
38
+ if isinstance(data, dict):
39
+ data = data.get("workItems") or data.get("value") or ([data] if "id" in data else [])
40
+ if isinstance(data, (int, str)) and not isinstance(data, bool):
41
+ return [data]
42
+ if not isinstance(data, list):
43
+ return []
44
+ return [item.get("id") if isinstance(item, dict) else item for item in data if not isinstance(item, (list, bool))]
45
+
46
+
47
+ def read_ids(text: str) -> list[str]:
48
+ text = text.lstrip("\ufeff")
49
+ try:
50
+ raw = _json_ids(json.loads(text))
51
+ except ValueError:
52
+ if text.lstrip().startswith(("[", "{")):
53
+ raise ValueError("looks like JSON but does not parse") from None
54
+ raw = [m.group(1) for m in map(LINE_ID.match, text.splitlines()) if m]
55
+ ids = [_norm(v) for v in raw if v is not None and _norm(v)]
56
+ return list(dict.fromkeys(ids))
57
+
58
+
59
+ def _regex(pattern: str, id_part: str) -> re.Pattern:
60
+ if "{id}" not in pattern:
61
+ raise ValueError("pattern must contain {id}")
62
+ head, _, tail = pattern.partition("{id}")
63
+ return re.compile(rf"(?<![\w-]){re.escape(head)}{id_part}{re.escape(tail)}(?!\w|[.-]\w)", re.IGNORECASE)
64
+
65
+
66
+ def trace(root: Path, ids: list[str], pattern: str = "REQ-{id}") -> Result:
67
+ wanted = {i.lower(): i for i in ids}
68
+ finder = _regex(pattern, f"({ID_CHARS})")
69
+ covered: dict[str, list[str]] = {}
70
+ unknown: dict[str, list[str]] = {}
71
+ for name in source_files(root):
72
+ if not TEST_PATH.search(name):
73
+ continue
74
+ f = root / name
75
+ if f.stat().st_size > MAX_BYTES:
76
+ continue
77
+ text = f.read_text(encoding="utf-8", errors="replace")
78
+ for found in dict.fromkeys(m.group(1) for m in finder.finditer(text)):
79
+ target = covered if found.lower() in wanted else unknown
80
+ key = wanted.get(found.lower(), found)
81
+ if name not in target.setdefault(key, []):
82
+ target[key].append(name)
83
+ ordered = {i: covered[i] for i in ids if i in covered}
84
+ return Result(ordered, [i for i in ids if i not in covered], dict(sorted(unknown.items())))
85
+
86
+
87
+ def _files(names: list[str], limit: int = 3) -> str:
88
+ extra = f" and {len(names) - limit} more" if len(names) > limit else ""
89
+ return printable(", ".join(names[:limit]) + extra)
90
+
91
+
92
+ def render(result: Result) -> list[str]:
93
+ total = len(result.covered) + len(result.missing)
94
+ out = [f"trace: {len(result.covered)} of {total} requirements have tests"]
95
+ out += [f" {printable(i)} {_files(names)}" for i, names in result.covered.items()]
96
+ if result.missing:
97
+ out.append(f"missing: {printable(', '.join(result.missing))}")
98
+ out += [f"unknown in tests: {printable(i)} ({_files(names)})" for i, names in result.unknown.items()]
99
+ return out
100
+
101
+
102
+ def decode(data: bytes) -> str:
103
+ if data.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
104
+ return data.decode("utf-16")
105
+ return data.decode("utf-8-sig")
106
+
107
+
108
+ def _read(name: str) -> str:
109
+ if name != "-":
110
+ return decode(Path(name).read_bytes())
111
+ return decode(sys.stdin.buffer.read()) if hasattr(sys.stdin, "buffer") else sys.stdin.read()
112
+
113
+
114
+ def cli(args) -> int:
115
+ utf8_stdout()
116
+ try:
117
+ ids = read_ids(_read(args.ids))
118
+ except OSError as e:
119
+ print(f"code-jig: {args.ids}: {e.strerror or e}", file=sys.stderr)
120
+ return 2
121
+ except ValueError as e:
122
+ print(f"code-jig: {args.ids}: {e}", file=sys.stderr)
123
+ return 2
124
+ if not ids:
125
+ print(f"code-jig: no requirement IDs in {args.ids}", file=sys.stderr)
126
+ return 2
127
+ try:
128
+ result = trace(Path(args.path).resolve(), ids, args.pattern)
129
+ except (ValueError, re.error) as e:
130
+ print(f"code-jig: --pattern: {e}", file=sys.stderr)
131
+ return 2
132
+ if args.json:
133
+ print(json.dumps({"covered": result.covered, "missing": result.missing, "unknown": result.unknown}, ensure_ascii=False, indent=1))
134
+ else:
135
+ for line in render(result):
136
+ print(line)
137
+ return 1 if result.missing or result.unknown else 0