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.
- {codigest-0.6.0 → codigest-0.7.0}/CITATION.cff +1 -1
- {codigest-0.6.0 → codigest-0.7.0}/PKG-INFO +9 -6
- {codigest-0.6.0 → codigest-0.7.0}/README.ko.md +8 -5
- {codigest-0.6.0 → codigest-0.7.0}/README.md +8 -5
- codigest-0.7.0/docs/dogfood.md +64 -0
- {codigest-0.6.0 → codigest-0.7.0}/pyproject.toml +1 -1
- {codigest-0.6.0 → codigest-0.7.0}/server.json +2 -2
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/pack.py +103 -23
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/term.py +2 -1
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/runner.py +7 -0
- {codigest-0.6.0 → codigest-0.7.0}/tests/test_pack.py +40 -5
- {codigest-0.6.0 → codigest-0.7.0}/tests/test_termstore.py +14 -0
- {codigest-0.6.0 → codigest-0.7.0}/uv.lock +1 -1
- {codigest-0.6.0 → codigest-0.7.0}/.gitattributes +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/.github/workflows/publish.yml +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/.github/workflows/tests.yml +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/.gitignore +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/.python-version +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/LICENSE +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/docs/demo/demo.gif +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/docs/demo/make_gif.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/__init__.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/__main__.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/cap.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/clean.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/diff.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/digest.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/init.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/mark.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/mcp.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/scan.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/semdiff.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/status.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/commands/tree.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/config.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/cache.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/common.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/git_ops.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/output.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/processor.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/prompts.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/redact.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/resolver.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/scanner.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/semdiff.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/shadow.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/shellhist.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/structure.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/tags.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/termstore.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/core/tokenizer.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/main.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/options.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/shell/hook.ps1 +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/src/codigest/shell/hook.sh +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/tests/test_shadow.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/tests/test_structure.py +0 -0
- {codigest-0.6.0 → codigest-0.7.0}/tests/test_term_install.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: codigest
|
|
3
|
-
Version: 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
|
|
189
|
-
5.
|
|
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. 바뀐
|
|
159
|
-
5.
|
|
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
|
|
163
|
-
5.
|
|
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.
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/NaNIsaac/codigest",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.7.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "codigest",
|
|
15
|
-
"version": "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
|
-
|
|
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
|
-
"""
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
124
|
-
wanted.setdefault(path.resolve(), "changed")
|
|
194
|
+
wanted.setdefault(path, "changed")
|
|
125
195
|
|
|
126
196
|
deps = resolver.DependencyResolver(root)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if
|
|
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
|
|
133
|
-
for dep in sorted(
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
passed = Entry(command="pytest", exit_code=0, cwd=cwd, started_at=
|
|
11
|
-
elsewhere = Entry(command="make", exit_code=1, cwd=str(tmp_path.parent), started_at=
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|