codigest 0.6.0__tar.gz → 0.7.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 (58) hide show
  1. {codigest-0.6.0 → codigest-0.7.0}/CITATION.cff +1 -1
  2. {codigest-0.6.0 → codigest-0.7.0}/PKG-INFO +9 -6
  3. {codigest-0.6.0 → codigest-0.7.0}/README.ko.md +8 -5
  4. {codigest-0.6.0 → codigest-0.7.0}/README.md +8 -5
  5. codigest-0.7.0/docs/dogfood.md +64 -0
  6. {codigest-0.6.0 → codigest-0.7.0}/pyproject.toml +1 -1
  7. {codigest-0.6.0 → codigest-0.7.0}/server.json +2 -2
  8. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/pack.py +103 -23
  9. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/term.py +2 -1
  10. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/runner.py +7 -0
  11. {codigest-0.6.0 → codigest-0.7.0}/tests/test_pack.py +40 -5
  12. {codigest-0.6.0 → codigest-0.7.0}/tests/test_termstore.py +14 -0
  13. {codigest-0.6.0 → codigest-0.7.0}/uv.lock +1 -1
  14. {codigest-0.6.0 → codigest-0.7.0}/.gitattributes +0 -0
  15. {codigest-0.6.0 → codigest-0.7.0}/.github/workflows/publish.yml +0 -0
  16. {codigest-0.6.0 → codigest-0.7.0}/.github/workflows/tests.yml +0 -0
  17. {codigest-0.6.0 → codigest-0.7.0}/.gitignore +0 -0
  18. {codigest-0.6.0 → codigest-0.7.0}/.python-version +0 -0
  19. {codigest-0.6.0 → codigest-0.7.0}/LICENSE +0 -0
  20. {codigest-0.6.0 → codigest-0.7.0}/docs/demo/demo.gif +0 -0
  21. {codigest-0.6.0 → codigest-0.7.0}/docs/demo/make_gif.py +0 -0
  22. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/__init__.py +0 -0
  23. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/__main__.py +0 -0
  24. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/cap.py +0 -0
  25. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/clean.py +0 -0
  26. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/diff.py +0 -0
  27. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/digest.py +0 -0
  28. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/init.py +0 -0
  29. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/mark.py +0 -0
  30. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/mcp.py +0 -0
  31. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/scan.py +0 -0
  32. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/semdiff.py +0 -0
  33. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/status.py +0 -0
  34. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/tree.py +0 -0
  35. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/config.py +0 -0
  36. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/cache.py +0 -0
  37. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/common.py +0 -0
  38. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/git_ops.py +0 -0
  39. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/output.py +0 -0
  40. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/processor.py +0 -0
  41. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/prompts.py +0 -0
  42. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/redact.py +0 -0
  43. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/resolver.py +0 -0
  44. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/scanner.py +0 -0
  45. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/semdiff.py +0 -0
  46. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/shadow.py +0 -0
  47. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/shellhist.py +0 -0
  48. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/structure.py +0 -0
  49. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/tags.py +0 -0
  50. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/termstore.py +0 -0
  51. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/tokenizer.py +0 -0
  52. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/main.py +0 -0
  53. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/options.py +0 -0
  54. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/shell/hook.ps1 +0 -0
  55. {codigest-0.6.0 → codigest-0.7.0}/src/codigest/shell/hook.sh +0 -0
  56. {codigest-0.6.0 → codigest-0.7.0}/tests/test_shadow.py +0 -0
  57. {codigest-0.6.0 → codigest-0.7.0}/tests/test_structure.py +0 -0
  58. {codigest-0.6.0 → codigest-0.7.0}/tests/test_term_install.py +0 -0
@@ -14,7 +14,7 @@ authors:
14
14
  repository-code: "https://github.com/NaNIsaac/codigest"
15
15
  url: "https://pypi.org/project/codigest/"
16
16
  license: MIT
17
- version: 0.6.0
17
+ version: 0.7.0
18
18
  date-released: "2026-10-17"
19
19
  keywords:
20
20
  - LLM
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: codigest
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Human-Centered Codebase Context Manager for LLMs
5
5
  Project-URL: Homepage, https://github.com/NaNIsaac/codigest
6
6
  Project-URL: Repository, https://github.com/NaNIsaac/codigest
@@ -180,13 +180,16 @@ When a file you expected is missing, or one you didn't expect is there, this is
180
180
  fills its token budget (default 30,000):
181
181
 
182
182
  1. Files you name: `cdg pack src/prices.py`.
183
- 2. Files named in the output of the last failed command run in this project:
184
- `File "x.py", line 7` (Python), `x.py:7` (pytest, gcc, rustc, eslint), `x.ts(7,3)` (tsc).
185
- Once the same command passes again, that failure is no longer used.
183
+ 2. Files named in the output of the last failed command run in this project in the past hour:
184
+ `File "x.py", line 7` (Python), `x.py:7` (pytest, ruff, gcc, rustc, eslint), `x.ts(7,3)` (tsc).
185
+ Once the same command passes again, that failure is no longer used. A file that is only named
186
+ in the output, not changed by you, comes as an excerpt: ten lines around each place the output
187
+ points at, marked with a `lines="40-60"` attribute. Short files come whole.
186
188
  3. Files you changed: uncommitted and untracked files in Git, or changes since `cdg mark` if you
187
189
  set a baseline (see [below](#see-what-changed-since-a-point)).
188
- 4. Tests that import a changed Python file (`test_*.py`, `*_test.py`).
189
- 5. Local Python modules imported by the files above.
190
+ 4. Tests that import a changed file (`test_*.py`, `*_test.py`, `*.test.ts`, `*.spec.js`, ...).
191
+ 5. Project files imported by the files from steps 1, 3 and 4: Python imports, and relative imports in
192
+ JS/TS (`./x`, `./x.js` for `x.ts`, `./dir` for `dir/index.ts`).
190
193
 
191
194
  The failed command and the diff are always included. `.env`, `*.pem`, `*.key` and SSH keys never
192
195
  are. Tokens are estimated at four characters each, so the budget is approximate.
@@ -150,13 +150,16 @@ claude mcp add --scope user codigest -- cdg mcp # Claude Code
150
150
  예산(기본 30,000)을 채웁니다.
151
151
 
152
152
  1. 직접 지정한 파일: `cdg pack src/prices.py`
153
- 2. 이 프로젝트에서 마지막으로 실패한 명령의 출력에 나온 파일:
154
- `File "x.py", line 7`(Python), `x.py:7`(pytest, gcc, rustc, eslint), `x.ts(7,3)`(tsc).
155
- 같은 명령이 다시 통과하면 그 실패는 더 이상 쓰지 않습니다.
153
+ 2. 이 프로젝트에서 최근 한 시간 안에 마지막으로 실패한 명령의 출력에 나온 파일:
154
+ `File "x.py", line 7`(Python), `x.py:7`(pytest, ruff, gcc, rustc, eslint), `x.ts(7,3)`(tsc).
155
+ 같은 명령이 다시 통과하면 그 실패는 더 이상 쓰지 않습니다. 내가 바꾸지 않고 출력에만 나온
156
+ 파일은 발췌로 들어갑니다. 출력이 가리킨 곳마다 앞뒤 열 줄이며 `lines="40-60"` 속성이
157
+ 붙습니다. 짧은 파일은 통째로 들어갑니다.
156
158
  3. 바꾼 파일: Git의 커밋 안 된 파일과 추적 안 된 파일. `cdg mark`로 기준점을 잡았다면 그
157
159
  이후의 변경([아래](#어느-시점-이후-바뀐-것-보기) 참고).
158
- 4. 바뀐 Python 파일을 import하는 테스트(`test_*.py`, `*_test.py`).
159
- 5. 위 파일들이 import하는 프로젝트 내 Python 모듈.
160
+ 4. 바뀐 파일을 import하는 테스트(`test_*.py`, `*_test.py`, `*.test.ts`, `*.spec.js` 등).
161
+ 5. 1·3·4단계 파일이 import하는 프로젝트 파일: Python import와 JS/TS 상대 경로 import
162
+ (`./x`, `x.ts`를 가리키는 `./x.js`, `dir/index.ts`를 가리키는 `./dir`).
160
163
 
161
164
  실패한 명령과 diff는 항상 들어가고, `.env`, `*.pem`, `*.key`, SSH 키는 절대 들어가지 않습니다.
162
165
  토큰은 네 글자를 하나로 어림하므로 예산은 대략적인 값입니다.
@@ -154,13 +154,16 @@ When a file you expected is missing, or one you didn't expect is there, this is
154
154
  fills its token budget (default 30,000):
155
155
 
156
156
  1. Files you name: `cdg pack src/prices.py`.
157
- 2. Files named in the output of the last failed command run in this project:
158
- `File "x.py", line 7` (Python), `x.py:7` (pytest, gcc, rustc, eslint), `x.ts(7,3)` (tsc).
159
- Once the same command passes again, that failure is no longer used.
157
+ 2. Files named in the output of the last failed command run in this project in the past hour:
158
+ `File "x.py", line 7` (Python), `x.py:7` (pytest, ruff, gcc, rustc, eslint), `x.ts(7,3)` (tsc).
159
+ Once the same command passes again, that failure is no longer used. A file that is only named
160
+ in the output, not changed by you, comes as an excerpt: ten lines around each place the output
161
+ points at, marked with a `lines="40-60"` attribute. Short files come whole.
160
162
  3. Files you changed: uncommitted and untracked files in Git, or changes since `cdg mark` if you
161
163
  set a baseline (see [below](#see-what-changed-since-a-point)).
162
- 4. Tests that import a changed Python file (`test_*.py`, `*_test.py`).
163
- 5. Local Python modules imported by the files above.
164
+ 4. Tests that import a changed file (`test_*.py`, `*_test.py`, `*.test.ts`, `*.spec.js`, ...).
165
+ 5. Project files imported by the files from steps 1, 3 and 4: Python imports, and relative imports in
166
+ JS/TS (`./x`, `./x.js` for `x.ts`, `./dir` for `dir/index.ts`).
164
167
 
165
168
  The failed command and the diff are always included. `.env`, `*.pem`, `*.key` and SSH keys never
166
169
  are. Tokens are estimated at four characters each, so the budget is approximate.
@@ -0,0 +1,64 @@
1
+ # Using Codigest on itself
2
+
3
+ Notes from running `cdg` on real work in this repository and a few sibling projects, on Windows
4
+ (PowerShell and Git Bash). Each entry says what happened, what was wrong, and what changed.
5
+
6
+ ## Lint errors across a whole package
7
+
8
+ `cdg cap -- uvx ruff check src`, then `cdg pack`.
9
+
10
+ - **Before:** ruff named 32 files. `pack` added 22 of them whole and dropped 10 for budget:
11
+ about 30k tokens, most of it code far from any error.
12
+ - **Change:** a file that the output only points at, and that you did not change, now comes as
13
+ an excerpt: ten lines around each reference, with a `lines="..."` attribute. All 33 files fit
14
+ in about 26k tokens, each with the context around its errors.
15
+ - **Change:** the summary line now says `4 places in failing output (excerpt)` instead of
16
+ listing every line number, which wrapped at 80 columns.
17
+
18
+ ## A traceback into a large module
19
+
20
+ A real `AttributeError` raised at line 255 of the 286-line `core/termstore.py`.
21
+
22
+ - The pack was about 1.6k tokens: the traceback, and lines 245 to 265 of `termstore.py`. The
23
+ failing line and the loop around it were in the excerpt.
24
+ - **Bug:** the command `render([object()])` showed as `render()` in the summary; the terminal
25
+ library read `[object()]` as a style tag. Commands and paths are now escaped, in `cdg term` too.
26
+ - **Change:** the excerpted file's imports (`redact.py`, `tags.py`) were added whole and were not
27
+ relevant. Imports are now followed only from files you named, changed, or tests of those.
28
+
29
+ ## A passing run after an old failure
30
+
31
+ Tests passed, but `pack` still used a ruff failure from minutes earlier and filled the budget with
32
+ lint files.
33
+
34
+ - With the shell hook, every `ls` and `git status` is captured, so "the last command passed"
35
+ cannot mean "nothing is failing". Failures older than an hour are now ignored.
36
+ - A failure from a few minutes ago is still used. The summary names it and its age on the first
37
+ line, so it is visible, but this case stays a judgment call.
38
+
39
+ ## `cdg cap -- npm test` on Windows
40
+
41
+ - **Bug:** `Command not found: npm`. npm on Windows is `npm.cmd`, which the process API does not
42
+ find without the extension. Relative paths with forward slashes (`.venv/Scripts/python`) failed
43
+ the same way from Git Bash. The program is now resolved the way a shell resolves it.
44
+ - **Bug:** npm's exit code `-4058` was stored as `4294963238`. Exit codes are now shown signed.
45
+
46
+ ## Other projects
47
+
48
+ `cdg cap -- uv run --with pytest pytest -q` in deai, hanfix, QoraPhase and QoraGeom.
49
+
50
+ - Passing suites with a clean tree: `pack` says there is nothing to pack and what to run. Correct.
51
+ - hanfix exited 5 (pytest collected no tests); the pack was the 64-token output and nothing else.
52
+ Correct, if not very useful.
53
+
54
+ ## Where it helps, and where it doesn't
55
+
56
+ - **Pasting into a chat model.** The clearest win. One command replaces copying the error, the
57
+ test and the changed file, and the `reason` on each file plus the `<omitted>` list tell the
58
+ model what it has and lacks.
59
+ - **An agent that can read files itself** (Claude Code, Codex). The file contents save a few tool
60
+ calls but are not essential. What the agent cannot get otherwise is the output of commands the
61
+ person ran in their own terminal; that is the part of `pack` and `terminal_history` that matters
62
+ here. When the agent runs the command itself, it already has the output.
63
+ - **Token counts** are estimates (four characters per token). They were within the budget in
64
+ every run above, but they are not exact.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codigest"
3
- version = "0.6.0"
3
+ version = "0.7.0"
4
4
  description = "Human-Centered Codebase Context Manager for LLMs"
5
5
  authors = [{ name = "Isaac Yong", email = "isaacyong@naver.com" }]
6
6
  readme = "README.md"
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/NaNIsaac/codigest",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.6.0",
10
+ "version": "0.7.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "codigest",
15
- "version": "0.6.0",
15
+ "version": "0.7.0",
16
16
  "runtimeHint": "uvx",
17
17
  "transport": {
18
18
  "type": "stdio"
@@ -6,10 +6,11 @@ in that order until the token budget runs out; whatever is left out is listed,
6
6
  so the reader knows what it is not seeing.
7
7
  """
8
8
  import re
9
- from datetime import datetime
9
+ from datetime import datetime, timedelta, timezone
10
10
  from pathlib import Path
11
11
 
12
12
  import typer
13
+ from rich.markup import escape
13
14
 
14
15
  from ..options import CopyOption, MessageOption, StdoutOption
15
16
 
@@ -18,19 +19,33 @@ lazy from ..core import common, git_ops, processor, resolver, shadow, tags, term
18
19
  lazy from ..core.output import OutputHandler
19
20
 
20
21
  DEFAULT_BUDGET = 30_000
22
+ # A file that is only named in the failing output gets this many lines around each reference.
23
+ EXCERPT_CONTEXT = 10
24
+ EXCERPT_MIN_LINES = 60
25
+ FAILURE_MAX_AGE = timedelta(hours=1)
21
26
 
22
27
  # `File "x.py", line 7` (Python) or `x.py:7` / `x.ts(7,3)` (pytest, tsc, gcc, rustc, eslint, ...)
23
28
  _LOCATION_RE = re.compile(r'File "(?P<py>[^"]+)", line (?P<py_line>\d+)|(?P<path>[\w./\\~-]+\.\w+)[:(](?P<line>\d+)')
24
29
 
25
- _TEST_FILE_RE = re.compile(r"^test_.*\.py$|^.*_test\.py$")
30
+ _TEST_FILE_RE = re.compile(r"^test_.*\.py$|^.*_test\.py$|\.(test|spec)\.[cm]?[jt]sx?$")
26
31
 
27
- _SECRET_FILE_RE =re.compile(r"^\.env(\..+)?$|\.(pem|key|p12|pfx)$|^id_(rsa|dsa|ecdsa|ed25519)$")
32
+ _JS_SUFFIXES = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")
33
+ # Relative specifiers only: `from './x'`, `import './x'`, `require('./x')`, `import('./x')`.
34
+ _JS_IMPORT_RE = re.compile(r"""(?:\bfrom|\bimport|\brequire\(|\bimport\()\s*['"](\.{1,2}/[^'"]+)['"]""")
35
+
36
+ _SECRET_FILE_RE = re.compile(r"^\.env(\..+)?$|\.(pem|key|p12|pfx)$|^id_(rsa|dsa|ecdsa|ed25519)$")
28
37
 
29
38
 
30
39
  def pick_failure(entries: list[termstore.Entry], root: Path) -> termstore.Entry | None:
31
- """Latest failed command run under `root`, unless the same command has passed since."""
40
+ """
41
+ Latest failed command run under `root` within the last hour, unless the same command has
42
+ passed since. Older failures are rarely what someone running `pack` is looking at.
43
+ """
44
+ cutoff = datetime.now().astimezone() - FAILURE_MAX_AGE
32
45
  passed: set[str] = set()
33
46
  for entry in reversed(termstore.select(entries, under=root)):
47
+ if _started(entry) < cutoff:
48
+ return None
34
49
  if entry.exit_code == 0:
35
50
  passed.add(entry.command)
36
51
  elif entry.exit_code is not None and entry.command not in passed:
@@ -38,14 +53,66 @@ def pick_failure(entries: list[termstore.Entry], root: Path) -> termstore.Entry
38
53
  return None
39
54
 
40
55
 
41
- def referenced_files(output: str, bases: list[Path], known: set[Path]) -> dict[Path, int]:
42
- """Project files named in command output, with the first line number given for each."""
43
- found: dict[Path, int] = {}
56
+ def _started(entry: termstore.Entry) -> datetime:
57
+ try:
58
+ return datetime.fromisoformat(entry.started_at)
59
+ except ValueError:
60
+ return datetime.min.replace(tzinfo=timezone.utc)
61
+
62
+
63
+ def referenced_files(output: str, bases: list[Path], known: set[Path]) -> dict[Path, list[int]]:
64
+ """Project files named in command output, with the line numbers given for each, in order."""
65
+ found: dict[Path, list[int]] = {}
44
66
  for m in _LOCATION_RE.finditer(output):
45
67
  raw = Path(m["py"] or m["path"]).expanduser()
46
68
  for base in bases:
47
69
  if (path := (base / raw).resolve()) in known:
48
- found.setdefault(path, int(m["py_line"] or m["line"]))
70
+ if (line := int(m["py_line"] or m["line"])) not in (lines := found.setdefault(path, [])):
71
+ lines.append(line)
72
+ break
73
+ return found
74
+
75
+
76
+ def excerpt(path: Path, lines: list[int]) -> tuple[str, str] | None:
77
+ """
78
+ The lines around each reference, numbered, with `...` between gaps, plus the ranges shown
79
+ ("3-23,40-60"). None when the file is short or the excerpt would be most of it anyway.
80
+ """
81
+ try:
82
+ rows = path.read_text(encoding="utf-8").splitlines()
83
+ except (OSError, UnicodeDecodeError):
84
+ return None
85
+ spans: list[list[int]] = []
86
+ for line in sorted(lines):
87
+ lo, hi = max(1, line - EXCERPT_CONTEXT), min(len(rows), line + EXCERPT_CONTEXT)
88
+ if spans and lo <= spans[-1][1] + 1:
89
+ spans[-1][1] = max(spans[-1][1], hi)
90
+ else:
91
+ spans.append([lo, hi])
92
+ if len(rows) <= EXCERPT_MIN_LINES or sum(hi - lo + 1 for lo, hi in spans) > len(rows) * 0.7:
93
+ return None
94
+ width = len(str(len(rows)))
95
+ text = "\n...\n".join(
96
+ "\n".join(f"{i:>{width}}: {rows[i - 1]}" for i in range(lo, hi + 1)) for lo, hi in spans
97
+ )
98
+ return text, ",".join(f"{lo}-{hi}" for lo, hi in spans)
99
+
100
+
101
+ def js_imports(path: Path) -> set[Path]:
102
+ """Files a JS/TS module imports by relative path, resolved the way bundlers and tsc do."""
103
+ try:
104
+ source = path.read_text(encoding="utf-8")
105
+ except (OSError, UnicodeDecodeError):
106
+ return set()
107
+ found = set()
108
+ for spec in _JS_IMPORT_RE.findall(source):
109
+ base = (path.parent / spec).resolve()
110
+ # TS ESM code imports `./x.js` for a file that is `x.ts` on disk.
111
+ stem = base.with_suffix("") if base.suffix in _JS_SUFFIXES else base
112
+ for candidate in (base, *(stem.with_name(stem.name + s) for s in _JS_SUFFIXES),
113
+ *(base / f"index{s}" for s in _JS_SUFFIXES)):
114
+ if candidate.is_file():
115
+ found.add(candidate)
49
116
  break
50
117
  return found
51
118
 
@@ -112,25 +179,34 @@ def handle(
112
179
  for path in ctx.get_target_files(targets=targets) if targets else []:
113
180
  wanted.setdefault(path, "requested")
114
181
 
182
+ refs: dict[Path, list[int]] = {}
115
183
  failure = pick_failure(termstore.load_entries(), root)
116
184
  if failure:
117
185
  bases = [Path(failure.cwd), root] if failure.cwd else [root]
118
- for path, line in referenced_files(failure.output, bases, known).items():
119
- wanted.setdefault(path, f"line {line} in failing output")
186
+ refs = referenced_files(failure.output, bases, known)
187
+ for path, lines in refs.items():
188
+ shown = ", ".join(map(str, lines[:3])) + (", ..." if len(lines) > 3 else "")
189
+ wanted.setdefault(path, f"line{'s' * (len(lines) > 1)} {shown} in failing output")
120
190
 
121
191
  diff, diff_source, changed = _changes(root, sorted(known))
192
+ changed = [p.resolve() for p in changed if p.resolve() in known]
122
193
  for path in changed:
123
- if path.resolve() in known:
124
- wanted.setdefault(path.resolve(), "changed")
194
+ wanted.setdefault(path, "changed")
125
195
 
126
196
  deps = resolver.DependencyResolver(root)
127
- edited = {p for p, why in wanted.items() if why == "changed" and p.suffix == ".py"}
128
- for test in sorted(p for p in known if _TEST_FILE_RE.match(p.name)) if edited else []:
129
- if hit := sorted(deps.get_imports(test) & edited):
197
+
198
+ def imports(path: Path) -> set[Path]:
199
+ if path.suffix == ".py":
200
+ return deps.get_imports(path) & known
201
+ return js_imports(path) & known if path.suffix in _JS_SUFFIXES else set()
202
+
203
+ edited = {p for p, why in wanted.items() if why == "changed"}
204
+ for test in sorted(p for p in known if _TEST_FILE_RE.search(p.name)) if edited else []:
205
+ if hit := sorted(imports(test) & edited):
130
206
  wanted.setdefault(test, f"tests {hit[0].relative_to(root).as_posix()}")
131
207
 
132
- for path in [p for p in wanted if p.suffix == ".py"]:
133
- for dep in sorted(deps.get_imports(path) & known):
208
+ for path in [p for p, why in wanted.items() if not why.endswith("failing output")]:
209
+ for dep in sorted(imports(path)):
134
210
  wanted.setdefault(dep, f"imported by {path.relative_to(root).as_posix()}")
135
211
 
136
212
  if not (failure or diff.strip() or wanted):
@@ -155,15 +231,19 @@ def handle(
155
231
  if _SECRET_FILE_RE.search(path.name):
156
232
  omitted.append((rel, "may contain secrets"))
157
233
  continue
158
- content = tags.escape_xml_value(processor.read_file_content(path, add_line_numbers=True))
159
- block = f'<file path="{html.escape(rel)}" reason="{html.escape(reason)}">\n{content}\n</file>'
234
+ # Files you changed or asked for come whole; files the output only points at come as excerpts.
235
+ part = excerpt(path, refs[path]) if path in refs and reason.endswith("failing output") and path not in changed else None
236
+ content, shown = part or (processor.read_file_content(path, add_line_numbers=True), "")
237
+ lines_attr = f' lines="{shown}"' if shown else ""
238
+ block = f'<file path="{html.escape(rel)}" reason="{html.escape(reason)}"{lines_attr}>\n{tags.escape_xml_value(content)}\n</file>'
160
239
  cost = tokenizer.estimate_tokens(block)
161
240
  if used + cost > budget:
162
241
  omitted.append((rel, f"over budget (~{cost:,} tokens)"))
163
242
  continue
164
243
  used += cost
165
244
  blocks.append(block)
166
- included.append((rel, reason))
245
+ brief = f"{len(refs[path])} places in failing output" if reason.startswith("lines ") else reason
246
+ included.append((rel, f"{brief} (excerpt)" if shown else brief))
167
247
 
168
248
  if omitted:
169
249
  listing = "\n".join(f"{rel}: {why}" for rel, why in omitted)
@@ -175,13 +255,13 @@ def handle(
175
255
  log.print(f"[bold]Packed ~{used:,} / {budget:,} tokens[/bold]{over}")
176
256
  if failure:
177
257
  when = _ago(failure.started_at)
178
- log.print(f" [red]failed [/red] [cyan]{failure.command}[/cyan] [dim](exit {failure.exit_code}{', ' + when if when else ''})[/dim]")
258
+ log.print(f" [red]failed [/red] [cyan]{escape(failure.command)}[/cyan] [dim](exit {failure.exit_code}{', ' + when if when else ''})[/dim]")
179
259
  if diff.strip():
180
260
  log.print(f" [yellow]changes[/yellow] [dim]{diff_source}[/dim]")
181
261
  width = max((len(rel) for rel, _ in included + omitted), default=0)
182
262
  for rel, why in included:
183
- log.print(f" [green]+[/green] {rel:<{width}} [dim]{why}[/dim]")
263
+ log.print(f" [green]+[/green] {escape(rel):<{width}} [dim]{escape(why)}[/dim]")
184
264
  for rel, why in omitted:
185
- log.print(f" [dim]- {rel:<{width}} {why}[/dim]")
265
+ log.print(f" [dim]- {escape(rel):<{width}} {escape(why)}[/dim]")
186
266
 
187
267
  handler.handle_output(pack, item_name="pack")
@@ -10,6 +10,7 @@ from typing import Annotated
10
10
 
11
11
  import typer
12
12
  from rich.console import Console
13
+ from rich.markup import escape
13
14
 
14
15
  from ..options import CopyOption, StdoutOption
15
16
 
@@ -65,7 +66,7 @@ def _summarize(handler: OutputHandler, entries: list[termstore.Entry]) -> None:
65
66
  case 0: status = " [green]✓[/green]"
66
67
  case code: status = f" [red](exit {code})[/red]"
67
68
  marker = "" if entry.captured else " [yellow](no output)[/yellow]"
68
- log.print(f"[dim]{index}.[/dim] [cyan]{entry.command}[/cyan]{status}{marker}")
69
+ log.print(f"[dim]{index}.[/dim] [cyan]{escape(entry.command)}[/cyan]{status}{marker}")
69
70
 
70
71
 
71
72
  @app.callback(invoke_without_command=True)
@@ -67,6 +67,10 @@ def resolve_shell() -> list[str] | None:
67
67
  def build_argv(args: list[str]) -> list[str]:
68
68
  """Maps user-supplied arguments onto an argv for subprocess."""
69
69
  if not needs_shell(args):
70
+ # CreateProcess finds neither `.cmd` shims (npm, yarn, pnpm) nor relative paths written
71
+ # with forward slashes; which() resolves both the way the user's shell would.
72
+ if sys.platform == "win32" and (found := shutil.which(args[0])):
73
+ return [found, *args[1:]]
70
74
  return args
71
75
  if not (prefix := resolve_shell()):
72
76
  return args
@@ -131,6 +135,9 @@ def run(args: list[str]) -> tuple[int, str, int]:
131
135
  if process.stdout is not None:
132
136
  process.stdout.close()
133
137
 
138
+ # Windows reports exit codes as unsigned 32-bit; shells show them signed (-4058, not 4294963238).
139
+ if sys.platform == "win32" and exit_code >= 2**31:
140
+ exit_code -= 2**32
134
141
  duration_ms = int((time.monotonic() - started) * 1000)
135
142
  output = b"".join(chunks).decode("utf-8", errors="replace")
136
143
  return exit_code, output, duration_ms
@@ -4,21 +4,28 @@ from codigest.core.termstore import Entry
4
4
 
5
5
 
6
6
  def test_failure_is_dropped_once_the_same_command_passes(tmp_path):
7
+ from datetime import datetime, timedelta
8
+
9
+ def at(minutes_ago):
10
+ return (datetime.now().astimezone() - timedelta(minutes=minutes_ago)).isoformat()
11
+
7
12
  cwd = str(tmp_path)
8
- fail = Entry(command="pytest", exit_code=1, cwd=cwd, started_at="1")
9
- other = Entry(command="mypy .", exit_code=2, cwd=cwd, started_at="0")
10
- passed = Entry(command="pytest", exit_code=0, cwd=cwd, started_at="2")
11
- elsewhere = Entry(command="make", exit_code=1, cwd=str(tmp_path.parent), started_at="3")
13
+ other = Entry(command="mypy .", exit_code=2, cwd=cwd, started_at=at(4))
14
+ fail = Entry(command="pytest", exit_code=1, cwd=cwd, started_at=at(3))
15
+ passed = Entry(command="pytest", exit_code=0, cwd=cwd, started_at=at(2))
16
+ elsewhere = Entry(command="make", exit_code=1, cwd=str(tmp_path.parent), started_at=at(1))
12
17
 
13
18
  assert pack.pick_failure([other, fail], tmp_path) is fail
14
19
  assert pack.pick_failure([other, fail, passed, elsewhere], tmp_path) is other
20
+ stale = Entry(command="ruff check", exit_code=1, cwd=cwd, started_at=at(90))
21
+ assert pack.pick_failure([stale], tmp_path) is None
15
22
 
16
23
 
17
24
  def test_referenced_files_reads_python_and_compiler_locations(tmp_path):
18
25
  a, b = tmp_path / "a.py", tmp_path / "src" / "b.ts"
19
26
  output = f'File "{a}", line 7, in f\nsrc/b.ts(3,5): error\n/usr/lib/python3/os.py:12: nope'
20
27
  found = pack.referenced_files(output, [tmp_path], {a, b})
21
- assert found == {a: 7, b: 3}
28
+ assert found == {a: [7], b: [3]}
22
29
 
23
30
 
24
31
  def test_changed_paths_skips_deleted_files(tmp_path):
@@ -50,3 +57,31 @@ def test_pack_adds_the_test_that_imports_a_changed_module(tmp_path, monkeypatch)
50
57
  out = CliRunner().invoke(app, ["pack", "-s", "--no-copy"]).output
51
58
  assert 'path="tests/test_prices.py" reason="tests shop/prices.py"' in out
52
59
  assert "test_other.py" not in out
60
+
61
+
62
+ def test_js_imports_resolve_like_tsc(tmp_path):
63
+ (tmp_path / "lib").mkdir()
64
+ for name in ("price.ts", "lib/index.ts", "old.cjs", "app.service.ts"):
65
+ (tmp_path / name).write_text("")
66
+ main = tmp_path / "main.ts"
67
+ main.write_text(
68
+ "import { a } from './price.js'\n"
69
+ "export * from \"./lib\"\n"
70
+ "const o = require('./old.cjs')\n"
71
+ "import './app.service'\n"
72
+ "import React from 'react'\n"
73
+ )
74
+ found = {p.relative_to(tmp_path).as_posix() for p in pack.js_imports(main)}
75
+ assert found == {"price.ts", "lib/index.ts", "old.cjs", "app.service.ts"}
76
+
77
+
78
+ def test_excerpt_merges_nearby_references_and_skips_short_files(tmp_path):
79
+ long = tmp_path / "long.py"
80
+ long.write_text("".join(f"x{i} = {i}\n" for i in range(1, 201)))
81
+ text, shown = pack.excerpt(long, [50, 55, 150])
82
+ assert shown == "40-65,140-160"
83
+ assert text.splitlines()[0] == " 40: x40 = 40" and "\n...\n" in text
84
+
85
+ short = tmp_path / "short.py"
86
+ short.write_text("a = 1\n" * 30)
87
+ assert pack.excerpt(short, [3]) is None
@@ -63,3 +63,17 @@ def test_mcp_refuses_paths_outside_the_project(tmp_path, monkeypatch):
63
63
  result = mcp._call_tool("tree", {"path": path})
64
64
  assert result["isError"] and "outside the project" in result["content"][0]["text"]
65
65
  assert mcp._in_project(".") == str((tmp_path / "proj").resolve())
66
+
67
+
68
+ def test_cap_resolves_cmd_shims_and_relative_paths_on_windows(tmp_path, monkeypatch):
69
+ import sys
70
+
71
+ import pytest
72
+
73
+ from codigest.core import runner
74
+ if sys.platform != "win32":
75
+ pytest.skip("CreateProcess lookup rules are Windows-only")
76
+ (tmp_path / "tool.cmd").write_text("@echo off\r\nexit /b 3\r\n")
77
+ monkeypatch.setenv("PATH", str(tmp_path))
78
+ assert runner.build_argv(["tool", "-x"])[0].lower() == str(tmp_path / "tool.cmd").lower()
79
+ assert runner.run(["tool"])[0] == 3
@@ -16,7 +16,7 @@ wheels = [
16
16
 
17
17
  [[package]]
18
18
  name = "codigest"
19
- version = "0.6.0"
19
+ version = "0.7.0"
20
20
  source = { editable = "." }
21
21
  dependencies = [
22
22
  { name = "loguru" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes