python-code-quality 0.1.13__tar.gz → 0.1.15__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 (33) hide show
  1. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/PKG-INFO +43 -45
  2. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/README.md +42 -44
  3. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/pyproject.toml +8 -1
  4. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/__init__.py +0 -4
  5. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/cli.py +39 -3
  6. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/config/config.yaml +12 -6
  7. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/execution_engine.py +18 -6
  8. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/localtypes.py +1 -0
  9. python_code_quality-0.1.15/src/py_cq/parsers/__init__.py +1 -0
  10. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/parsers/common.py +106 -2
  11. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/parsers/compileparser.py +8 -1
  12. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/parsers/interrogateparser.py +4 -4
  13. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/parsers/pytestparser.py +91 -4
  14. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/parsers/typarser.py +19 -1
  15. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/tool_registry.py +1 -0
  16. python_code_quality-0.1.13/src/py_cq/parsers/__init__.py +0 -0
  17. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/config/__init__.py +0 -0
  18. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/context_hash.py +0 -0
  19. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/language_detector.py +0 -0
  20. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/llm_formatter.py +0 -0
  21. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/main.py +0 -0
  22. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/metric_aggregator.py +0 -0
  23. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/parsers/banditparser.py +0 -0
  24. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/parsers/complexityparser.py +0 -0
  25. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/parsers/coverageparser.py +0 -0
  26. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/parsers/exitcodeparser.py +0 -0
  27. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/parsers/halsteadparser.py +0 -0
  28. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/parsers/linecountparser.py +0 -0
  29. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/parsers/maintainabilityparser.py +0 -0
  30. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/parsers/regexcountparser.py +0 -0
  31. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/parsers/ruffparser.py +0 -0
  32. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/parsers/vultureparser.py +0 -0
  33. {python_code_quality-0.1.13 → python_code_quality-0.1.15}/src/py_cq/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-code-quality
3
- Version: 0.1.13
3
+ Version: 0.1.15
4
4
  Summary: Python Code Quality Analysis Tool - feed the results from 11 CQ tools straight into an LLM. Minimal tokens.
5
5
  Author: Chris Kilner
6
6
  Author-email: Chris Kilner <chris@rhiza.fr>
@@ -35,39 +35,19 @@ Description-Content-Type: text/markdown
35
35
  [![Python versions](https://img.shields.io/pypi/pyversions/python-code-quality?)](https://pypi.org/project/python-code-quality/)
36
36
  [![License](https://img.shields.io/github/license/rhiza-fr/py-cq)](LICENSE)
37
37
 
38
- Feed the results from 11+ code quality tools to an LLM. Minimal tokens.
38
+ Run 11+ code quality tools, aggregate results into one score, and surface the single most critical defect as a focused markdown prompt — ready to pipe to any LLM.
39
39
 
40
- Why? It removes the mental burden of understanding all these tools and parsing their results.
40
+ This can dramatically reduce the amount of noise for LLMs (and humans) and remove the need for them to know about these tools.
41
41
 
42
- The primary workflow is:
42
+ Note: It never edits your files. This is a job for you or an LLM. You may wish to run `ruff check --fix` and `ruff format` first.
43
43
 
44
44
  ```bash
45
- # get the single most critical defect as markdown
46
- cq check . -o llm
47
- ```
48
-
49
- ```python
50
- `data/problems/travelling_salesman/ts_bad.py:21` — **F841**: Local variable `unused_variable` is assigned to but never used
51
-
52
- 18: min_dist = float("inf")
53
- 19: nearest_city = None
54
- 20: for city in cities:
55
- 21: unused_variable = 67
56
- 22: dist = calc_dist(current_city, city)
57
- 23: if dist < min_dist:
58
- 24: min_dist = dist
59
- 25: nearest_city = city
60
-
61
- Please fix only this issue. After fixing, run `cq check . -o llm` to verify.
62
- ```
63
- Feed to an LLM with edit tools and repeat until there are no issues, e.g.
64
-
65
- ```python
66
- cq check . -o llm | claude -p "fix this"
67
- # or
68
- cq check . -o llm | ollama run gpt-oss:20b "Explain how to fix this"
45
+ cq check . -o llm # top defect as markdown, pipe to an LLM
46
+ cq check . # table overview of all scores
47
+ cq check . -o score # numeric score only, exits 1 on errors (CI gate)
69
48
  ```
70
49
 
50
+ ![cq demo](demo/output/demo.gif)
71
51
 
72
52
  ## Install
73
53
 
@@ -114,6 +94,7 @@ cq check . -o raw # Raw tool output for debug
114
94
  cq check path/to/file.py # Just one file (skips pytest and coverage)
115
95
  cq check . --only ruff,ty # Run only specific tools
116
96
  cq check . --skip bandit # Skip specific tools
97
+ cq check . --exclude demo # Exclude paths from all tools
117
98
  cq check . --workers 1 # Run sequentially if you like things slow
118
99
  cq check . --clear-cache # Clear cached results before running (rarely needed)
119
100
  cq config path/to/project/ # Show effective tool configuration
@@ -133,10 +114,17 @@ Add a stop hook to your project's `.claude/settings.json` so Claude automaticall
133
114
  ```json
134
115
  {
135
116
  "hooks": {
136
- "Stop": [{
137
- "matcher": "",
138
- "hooks": [{"type": "command", "command": "cq check . -o score && echo 'CQ: all clear' || cq check . -o llm"}]
139
- }]
117
+ "Stop": [
118
+ {
119
+ "matcher": "",
120
+ "hooks": [
121
+ {
122
+ "type": "command",
123
+ "command": "cq check . -o score && echo 'CQ: all clear' || cq check . -o llm; true"
124
+ }
125
+ ]
126
+ }
127
+ ]
140
128
  }
141
129
  }
142
130
  ```
@@ -191,7 +179,7 @@ Then invoke it with `/cq-fix` in Claude Code. The `$(...)` embeds the live `cq`
191
179
  ```bash
192
180
  > cq check . -o score
193
181
  ```
194
- ```python
182
+ ```
195
183
  0.9662730667181059 # this is designed to approach but not reach 1.0
196
184
  ```
197
185
 
@@ -252,6 +240,9 @@ Add a `[tool.cq]` section to your project's `pyproject.toml`:
252
240
  # Skip tools that are slow or not relevant to your project
253
241
  disable = ["coverage", "interrogate"]
254
242
 
243
+ # Exclude paths from all tools (merged with --exclude CLI flag)
244
+ exclude = ["demo", "docs"]
245
+
255
246
  # Lines of source context shown around each defect in LLM output (default: 15)
256
247
  context_lines = 15
257
248
 
@@ -270,21 +261,23 @@ Tool IDs match the keys in `config/config.yaml`: `compile`, `ruff`, `ty`, `bandi
270
261
  python:
271
262
 
272
263
  compile:
273
- command: "{python} -m compileall -r 10 -j 8 {context_path} -x .*venv"
264
+ command: "{python} -m compileall -r 10 -j 8 \"{context_path}\" -x .*venv"
274
265
  parser: "CompileParser"
275
266
  order: 1
276
267
  warning_threshold: 0.9999
277
268
  error_threshold: 0.9999
278
269
 
279
270
  ruff:
280
- command: "{python} -m ruff check --output-format concise --no-cache {context_path}"
271
+ command: "{python} -m ruff check --output-format concise --no-cache \"{context_path}\"{exclude}"
272
+ exclude_format: " --exclude {path}"
281
273
  parser: "RuffParser"
282
274
  order: 2
283
275
  warning_threshold: 0.9999
284
276
  error_threshold: 0.9
285
277
 
286
278
  ty:
287
- command: "{python} -m ty check --output-format concise --color never {context_path}"
279
+ command: "{python} -m ty check --output-format concise --color never \"{context_path}\"{exclude}"
280
+ exclude_format: " --exclude {path}"
288
281
  parser: "TyParser"
289
282
  order: 3
290
283
  warning_threshold: 0.9999
@@ -294,22 +287,26 @@ python:
294
287
  - ty
295
288
 
296
289
  bandit:
297
- command: "{python} -m bandit -r {context_path} -f json -q -s B101 --severity-level medium --exclude {input_path_posix}/.venv,{input_path_posix}/tests"
290
+ command: "{python} -m bandit -r \"{context_path}\" -f json -q -s B101 --severity-level medium --exclude \"{input_path_posix}/.venv,{input_path_posix}/tests{exclude}\""
291
+ exclude_format: ",{input_path_posix}/{path}"
298
292
  parser: "BanditParser"
299
293
  order: 4
300
294
  warning_threshold: 0.9999
301
295
  error_threshold: 0.8
302
296
 
303
297
  pytest:
304
- command: "{python} -m pytest -v {context_path}"
298
+ command: "{python} -m pytest -v \"{context_path}\"{exclude}"
299
+ exclude_format: " --ignore {path}"
305
300
  parser: "PytestParser"
306
301
  order: 5
307
302
  warning_threshold: 1.0
308
303
  error_threshold: 1.0
309
304
  run_in_target_env: true
305
+ extra_deps:
306
+ - pytest
310
307
 
311
308
  coverage:
312
- command: "{python} -m coverage run --omit=*/tests/*,*/test_*.py -m pytest {context_path} && {python} -m coverage report --omit=*/tests/*,*/test_*.py"
309
+ command: "{python} -m coverage run --omit=*/tests/*,*/test_*.py -m pytest \"{context_path}\" && {python} -m coverage report --omit=*/tests/*,*/test_*.py"
313
310
  parser: "CoverageParser"
314
311
  order: 6
315
312
  warning_threshold: 0.9
@@ -320,40 +317,41 @@ python:
320
317
  - pytest
321
318
 
322
319
  radon-cc:
323
- command: "{python} -m radon cc --json {context_path}"
320
+ command: "{python} -m radon cc --json \"{context_path}\""
324
321
  parser: "ComplexityParser"
325
322
  order: 7
326
323
  warning_threshold: 0.6
327
324
  error_threshold: 0.4
328
325
 
329
326
  radon-mi:
330
- command: "{python} -m radon mi -s --json {context_path}"
327
+ command: "{python} -m radon mi -s --json \"{context_path}\""
331
328
  parser: "MaintainabilityParser"
332
329
  order: 8
333
330
  warning_threshold: 0.6
334
331
  error_threshold: 0.4
335
332
 
336
333
  radon-hal:
337
- command: "{python} -m radon hal -f --json {context_path}"
334
+ command: "{python} -m radon hal -f --json \"{context_path}\""
338
335
  parser: "HalsteadParser"
339
336
  order: 9
340
337
  warning_threshold: 0.5
341
338
  error_threshold: 0.3
342
339
 
343
340
  vulture:
344
- command: "{python} -m vulture {context_path} --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git"
341
+ command: "{python} -m vulture \"{context_path}\" --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git{exclude}"
342
+ exclude_format: ",{path}"
345
343
  parser: "VultureParser"
346
344
  order: 10
347
345
  warning_threshold: 0.9999
348
346
  error_threshold: 0.8
349
347
 
350
348
  interrogate:
351
- command: "{python} -m interrogate {context_path} -v --fail-under 0"
349
+ command: "{python} -m interrogate \"{context_path}\" -e tests{exclude} -v --fail-under 0"
350
+ exclude_format: " -e {path}"
352
351
  parser: "InterrogateParser"
353
352
  order: 11
354
353
  warning_threshold: 0.8
355
354
  error_threshold: 0.3
356
-
357
355
  ```
358
356
 
359
357
  ## Respect
@@ -6,39 +6,19 @@
6
6
  [![Python versions](https://img.shields.io/pypi/pyversions/python-code-quality?)](https://pypi.org/project/python-code-quality/)
7
7
  [![License](https://img.shields.io/github/license/rhiza-fr/py-cq)](LICENSE)
8
8
 
9
- Feed the results from 11+ code quality tools to an LLM. Minimal tokens.
9
+ Run 11+ code quality tools, aggregate results into one score, and surface the single most critical defect as a focused markdown prompt — ready to pipe to any LLM.
10
10
 
11
- Why? It removes the mental burden of understanding all these tools and parsing their results.
11
+ This can dramatically reduce the amount of noise for LLMs (and humans) and remove the need for them to know about these tools.
12
12
 
13
- The primary workflow is:
13
+ Note: It never edits your files. This is a job for you or an LLM. You may wish to run `ruff check --fix` and `ruff format` first.
14
14
 
15
15
  ```bash
16
- # get the single most critical defect as markdown
17
- cq check . -o llm
18
- ```
19
-
20
- ```python
21
- `data/problems/travelling_salesman/ts_bad.py:21` — **F841**: Local variable `unused_variable` is assigned to but never used
22
-
23
- 18: min_dist = float("inf")
24
- 19: nearest_city = None
25
- 20: for city in cities:
26
- 21: unused_variable = 67
27
- 22: dist = calc_dist(current_city, city)
28
- 23: if dist < min_dist:
29
- 24: min_dist = dist
30
- 25: nearest_city = city
31
-
32
- Please fix only this issue. After fixing, run `cq check . -o llm` to verify.
33
- ```
34
- Feed to an LLM with edit tools and repeat until there are no issues, e.g.
35
-
36
- ```python
37
- cq check . -o llm | claude -p "fix this"
38
- # or
39
- cq check . -o llm | ollama run gpt-oss:20b "Explain how to fix this"
16
+ cq check . -o llm # top defect as markdown, pipe to an LLM
17
+ cq check . # table overview of all scores
18
+ cq check . -o score # numeric score only, exits 1 on errors (CI gate)
40
19
  ```
41
20
 
21
+ ![cq demo](demo/output/demo.gif)
42
22
 
43
23
  ## Install
44
24
 
@@ -85,6 +65,7 @@ cq check . -o raw # Raw tool output for debug
85
65
  cq check path/to/file.py # Just one file (skips pytest and coverage)
86
66
  cq check . --only ruff,ty # Run only specific tools
87
67
  cq check . --skip bandit # Skip specific tools
68
+ cq check . --exclude demo # Exclude paths from all tools
88
69
  cq check . --workers 1 # Run sequentially if you like things slow
89
70
  cq check . --clear-cache # Clear cached results before running (rarely needed)
90
71
  cq config path/to/project/ # Show effective tool configuration
@@ -104,10 +85,17 @@ Add a stop hook to your project's `.claude/settings.json` so Claude automaticall
104
85
  ```json
105
86
  {
106
87
  "hooks": {
107
- "Stop": [{
108
- "matcher": "",
109
- "hooks": [{"type": "command", "command": "cq check . -o score && echo 'CQ: all clear' || cq check . -o llm"}]
110
- }]
88
+ "Stop": [
89
+ {
90
+ "matcher": "",
91
+ "hooks": [
92
+ {
93
+ "type": "command",
94
+ "command": "cq check . -o score && echo 'CQ: all clear' || cq check . -o llm; true"
95
+ }
96
+ ]
97
+ }
98
+ ]
111
99
  }
112
100
  }
113
101
  ```
@@ -162,7 +150,7 @@ Then invoke it with `/cq-fix` in Claude Code. The `$(...)` embeds the live `cq`
162
150
  ```bash
163
151
  > cq check . -o score
164
152
  ```
165
- ```python
153
+ ```
166
154
  0.9662730667181059 # this is designed to approach but not reach 1.0
167
155
  ```
168
156
 
@@ -223,6 +211,9 @@ Add a `[tool.cq]` section to your project's `pyproject.toml`:
223
211
  # Skip tools that are slow or not relevant to your project
224
212
  disable = ["coverage", "interrogate"]
225
213
 
214
+ # Exclude paths from all tools (merged with --exclude CLI flag)
215
+ exclude = ["demo", "docs"]
216
+
226
217
  # Lines of source context shown around each defect in LLM output (default: 15)
227
218
  context_lines = 15
228
219
 
@@ -241,21 +232,23 @@ Tool IDs match the keys in `config/config.yaml`: `compile`, `ruff`, `ty`, `bandi
241
232
  python:
242
233
 
243
234
  compile:
244
- command: "{python} -m compileall -r 10 -j 8 {context_path} -x .*venv"
235
+ command: "{python} -m compileall -r 10 -j 8 \"{context_path}\" -x .*venv"
245
236
  parser: "CompileParser"
246
237
  order: 1
247
238
  warning_threshold: 0.9999
248
239
  error_threshold: 0.9999
249
240
 
250
241
  ruff:
251
- command: "{python} -m ruff check --output-format concise --no-cache {context_path}"
242
+ command: "{python} -m ruff check --output-format concise --no-cache \"{context_path}\"{exclude}"
243
+ exclude_format: " --exclude {path}"
252
244
  parser: "RuffParser"
253
245
  order: 2
254
246
  warning_threshold: 0.9999
255
247
  error_threshold: 0.9
256
248
 
257
249
  ty:
258
- command: "{python} -m ty check --output-format concise --color never {context_path}"
250
+ command: "{python} -m ty check --output-format concise --color never \"{context_path}\"{exclude}"
251
+ exclude_format: " --exclude {path}"
259
252
  parser: "TyParser"
260
253
  order: 3
261
254
  warning_threshold: 0.9999
@@ -265,22 +258,26 @@ python:
265
258
  - ty
266
259
 
267
260
  bandit:
268
- command: "{python} -m bandit -r {context_path} -f json -q -s B101 --severity-level medium --exclude {input_path_posix}/.venv,{input_path_posix}/tests"
261
+ command: "{python} -m bandit -r \"{context_path}\" -f json -q -s B101 --severity-level medium --exclude \"{input_path_posix}/.venv,{input_path_posix}/tests{exclude}\""
262
+ exclude_format: ",{input_path_posix}/{path}"
269
263
  parser: "BanditParser"
270
264
  order: 4
271
265
  warning_threshold: 0.9999
272
266
  error_threshold: 0.8
273
267
 
274
268
  pytest:
275
- command: "{python} -m pytest -v {context_path}"
269
+ command: "{python} -m pytest -v \"{context_path}\"{exclude}"
270
+ exclude_format: " --ignore {path}"
276
271
  parser: "PytestParser"
277
272
  order: 5
278
273
  warning_threshold: 1.0
279
274
  error_threshold: 1.0
280
275
  run_in_target_env: true
276
+ extra_deps:
277
+ - pytest
281
278
 
282
279
  coverage:
283
- command: "{python} -m coverage run --omit=*/tests/*,*/test_*.py -m pytest {context_path} && {python} -m coverage report --omit=*/tests/*,*/test_*.py"
280
+ command: "{python} -m coverage run --omit=*/tests/*,*/test_*.py -m pytest \"{context_path}\" && {python} -m coverage report --omit=*/tests/*,*/test_*.py"
284
281
  parser: "CoverageParser"
285
282
  order: 6
286
283
  warning_threshold: 0.9
@@ -291,40 +288,41 @@ python:
291
288
  - pytest
292
289
 
293
290
  radon-cc:
294
- command: "{python} -m radon cc --json {context_path}"
291
+ command: "{python} -m radon cc --json \"{context_path}\""
295
292
  parser: "ComplexityParser"
296
293
  order: 7
297
294
  warning_threshold: 0.6
298
295
  error_threshold: 0.4
299
296
 
300
297
  radon-mi:
301
- command: "{python} -m radon mi -s --json {context_path}"
298
+ command: "{python} -m radon mi -s --json \"{context_path}\""
302
299
  parser: "MaintainabilityParser"
303
300
  order: 8
304
301
  warning_threshold: 0.6
305
302
  error_threshold: 0.4
306
303
 
307
304
  radon-hal:
308
- command: "{python} -m radon hal -f --json {context_path}"
305
+ command: "{python} -m radon hal -f --json \"{context_path}\""
309
306
  parser: "HalsteadParser"
310
307
  order: 9
311
308
  warning_threshold: 0.5
312
309
  error_threshold: 0.3
313
310
 
314
311
  vulture:
315
- command: "{python} -m vulture {context_path} --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git"
312
+ command: "{python} -m vulture \"{context_path}\" --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git{exclude}"
313
+ exclude_format: ",{path}"
316
314
  parser: "VultureParser"
317
315
  order: 10
318
316
  warning_threshold: 0.9999
319
317
  error_threshold: 0.8
320
318
 
321
319
  interrogate:
322
- command: "{python} -m interrogate {context_path} -v --fail-under 0"
320
+ command: "{python} -m interrogate \"{context_path}\" -e tests{exclude} -v --fail-under 0"
321
+ exclude_format: " -e {path}"
323
322
  parser: "InterrogateParser"
324
323
  order: 11
325
324
  warning_threshold: 0.8
326
325
  error_threshold: 0.3
327
-
328
326
  ```
329
327
 
330
328
  ## Respect
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-code-quality"
3
- version = "0.1.13"
3
+ version = "0.1.15"
4
4
  description = "Python Code Quality Analysis Tool - feed the results from 11 CQ tools straight into an LLM. Minimal tokens."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -42,3 +42,10 @@ module-name = "py_cq"
42
42
 
43
43
  [project.scripts]
44
44
  cq = "py_cq.main:main"
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+ norecursedirs = [".venv", "dist"]
49
+
50
+ [tool.cq]
51
+ exclude = ["demo"]
@@ -4,7 +4,3 @@ The module defines a single function, `hello`, which returns the string
4
4
  `'Hello from py_cq!'`. It can serve as a minimal example, placeholder, or
5
5
  testing stub in larger applications."""
6
6
 
7
-
8
- def hello() -> str:
9
- """Returns the greeting string `'Hello from py_cq!'`."""
10
- return "Hello from py_cq!"
@@ -10,13 +10,14 @@ analysis.
10
10
  Helper functions such as `format_as_table` convert the aggregated tool
11
11
  results into a Rich Table for convenient console display.
12
12
  """
13
-
14
13
  import copy
14
+ import io
15
15
  import json
16
16
  import logging
17
17
  import tomllib
18
18
  from enum import Enum
19
19
  from importlib import import_module
20
+ from importlib.metadata import requires, version
20
21
  from pathlib import Path
21
22
 
22
23
  import typer
@@ -84,6 +85,7 @@ def _apply_user_config(base: dict[str, ToolConfig], user_cfg: dict) -> dict[str,
84
85
  run_in_target_env=tool_data.get("run_in_target_env", False),
85
86
  extra_deps=tool_data.get("extra_deps", []),
86
87
  parser_config=tool_data.get("parser_config", {}),
88
+ exclude_format=tool_data.get("exclude_format", ""),
87
89
  )
88
90
  except KeyError as e:
89
91
  raise typer.BadParameter(f"[tool.cq.tools.{tool_id}] missing required field {e}")
@@ -99,8 +101,36 @@ class OutputMode(str, Enum):
99
101
  RAW = "raw"
100
102
 
101
103
 
104
+ def _version_callback(value: bool) -> None:
105
+ if not value:
106
+ return
107
+ import re
108
+ import sys
109
+ if isinstance(sys.stdout, io.TextIOWrapper):
110
+ sys.stdout.reconfigure(encoding="utf-8")
111
+ pkg = "python-code-quality"
112
+ pkg_version = version(pkg)
113
+ dep_versions: list[tuple[str, str]] = []
114
+ for req in (requires(pkg) or []):
115
+ if "; extra ==" in req:
116
+ continue
117
+ dep_name = re.split(r"[>=<!;\s\[]", req)[0]
118
+ try:
119
+ dep_versions.append((dep_name, version(dep_name)))
120
+ except Exception:
121
+ pass
122
+ typer.echo(f"{pkg} v{pkg_version}")
123
+ for dep_name, dep_ver in sorted(dep_versions):
124
+ typer.echo(f"\u251c\u2500\u2500 {dep_name} v{dep_ver}")
125
+ raise typer.Exit()
126
+
127
+
102
128
  @app.callback()
103
- def callback():
129
+ def callback(
130
+ _: bool = typer.Option(
131
+ False, "--version", "-V", callback=_version_callback, is_eager=True, help="Show version and dependencies"
132
+ ),
133
+ ) -> None:
104
134
  """Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm"""
105
135
  console = Console()
106
136
 
@@ -131,6 +161,9 @@ def check(
131
161
  skip: str | None = typer.Option(
132
162
  None, "--skip", help="Comma-separated tool IDs to skip (e.g. bandit,vulture)"
133
163
  ),
164
+ exclude: str | None = typer.Option(
165
+ None, "--exclude", help="Comma-separated paths to exclude (e.g. demo,docs)"
166
+ ),
134
167
  ):
135
168
  """Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm""" # --help
136
169
  path_obj = Path(path)
@@ -165,9 +198,12 @@ def check(
165
198
  if skip:
166
199
  drop = set(skip.split(","))
167
200
  effective_registry = {k: v for k, v in effective_registry.items() if k not in drop}
201
+ config_excludes: list[str] = user_cfg.get("exclude", [])
202
+ cli_excludes: list[str] = [e.strip() for e in exclude.split(",")] if exclude else []
203
+ excludes = list(dict.fromkeys(config_excludes + cli_excludes))
168
204
  if clear_cache:
169
205
  tool_cache.clear()
170
- tool_results = run_tools(effective_registry.values(), path, workers, early_exit=(output == OutputMode.LLM))
206
+ tool_results = run_tools(effective_registry.values(), path, workers, early_exit=(output == OutputMode.LLM), excludes=excludes)
171
207
  # for tr in tool_results:
172
208
  # log.debug(json.dumps(tr.to_dict(), indent=2))
173
209
  combined_metrics = aggregate_metrics(path=path, metrics=tool_results)
@@ -8,14 +8,16 @@ python:
8
8
  error_threshold: 0.9999
9
9
 
10
10
  ruff:
11
- command: "{python} -m ruff check --output-format concise --no-cache \"{context_path}\""
11
+ command: "{python} -m ruff check --output-format concise --no-cache \"{context_path}\"{exclude}"
12
+ exclude_format: " --exclude {path}"
12
13
  parser: "RuffParser"
13
14
  order: 2
14
15
  warning_threshold: 0.9999
15
16
  error_threshold: 0.9
16
17
 
17
18
  ty:
18
- command: "{python} -m ty check --output-format concise --color never \"{context_path}\""
19
+ command: "{python} -m ty check --output-format concise --color never \"{context_path}\"{exclude}"
20
+ exclude_format: " --exclude {path}"
19
21
  parser: "TyParser"
20
22
  order: 3
21
23
  warning_threshold: 0.9999
@@ -25,14 +27,16 @@ python:
25
27
  - ty
26
28
 
27
29
  bandit:
28
- command: "{python} -m bandit -r \"{context_path}\" -f json -q -s B101 --severity-level medium --exclude \"{input_path_posix}/.venv,{input_path_posix}/tests\""
30
+ command: "{python} -m bandit -r \"{context_path}\" -f json -q -s B101 --severity-level medium --exclude \"{input_path_posix}/.venv,{input_path_posix}/tests{exclude}\""
31
+ exclude_format: ",{input_path_posix}/{path}"
29
32
  parser: "BanditParser"
30
33
  order: 4
31
34
  warning_threshold: 0.9999
32
35
  error_threshold: 0.8
33
36
 
34
37
  pytest:
35
- command: "{python} -m pytest -v \"{context_path}\""
38
+ command: "{python} -m pytest -v \"{context_path}\"{exclude}"
39
+ exclude_format: " --ignore {path}"
36
40
  parser: "PytestParser"
37
41
  order: 5
38
42
  warning_threshold: 1.0
@@ -74,14 +78,16 @@ python:
74
78
  error_threshold: 0.3
75
79
 
76
80
  vulture:
77
- command: "{python} -m vulture \"{context_path}\" --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git"
81
+ command: "{python} -m vulture \"{context_path}\" --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git{exclude}"
82
+ exclude_format: ",{path}"
78
83
  parser: "VultureParser"
79
84
  order: 10
80
85
  warning_threshold: 0.9999
81
86
  error_threshold: 0.8
82
87
 
83
88
  interrogate:
84
- command: "{python} -m interrogate \"{context_path}\" -v --fail-under 0"
89
+ command: "{python} -m interrogate \"{context_path}\" -e tests{exclude} -v --fail-under 0"
90
+ exclude_format: " -e {path}"
85
91
  parser: "InterrogateParser"
86
92
  order: 11
87
93
  warning_threshold: 0.8
@@ -53,7 +53,17 @@ def _dep_in_venv(dep: str, project_root: Path) -> bool:
53
53
  return False
54
54
 
55
55
 
56
- def run_tool(tool_config: ToolConfig, context_path: str) -> RawResult:
56
+ def _build_exclude_str(exclude_format: str, excludes: list[str], **extra_vars: str) -> str:
57
+ if not exclude_format or not excludes:
58
+ return ""
59
+ parts = []
60
+ for exc in excludes:
61
+ abs_posix_path = Path(exc).resolve().as_posix()
62
+ parts.append(exclude_format.format(path=exc, abs_posix_path=abs_posix_path, **extra_vars))
63
+ return "".join(parts)
64
+
65
+
66
+ def run_tool(tool_config: ToolConfig, context_path: str, excludes: list[str] | None = None) -> RawResult:
57
67
  """Runs a tool defined by its configuration and returns the execution result.
58
68
 
59
69
  Args:
@@ -72,7 +82,7 @@ def run_tool(tool_config: ToolConfig, context_path: str) -> RawResult:
72
82
  >>> result.return_code
73
83
  0"""
74
84
  python = sys.executable
75
- path = context_path
85
+ path = str(Path(context_path))
76
86
  if tool_config.run_in_target_env:
77
87
  uv = shutil.which("uv")
78
88
  if uv:
@@ -87,10 +97,12 @@ def run_tool(tool_config: ToolConfig, context_path: str) -> RawResult:
87
97
  project_root_path = Path(abs_dir)
88
98
  missing_deps = [d for d in tool_config.extra_deps if not _dep_in_venv(d, project_root_path)]
89
99
  with_flags = " ".join(f"--with {dep}" for dep in missing_deps)
90
- python = f'"{uv}" run --directory "{abs_dir}" {with_flags}'.rstrip()
100
+ no_sync = "--no-sync" if sys.executable.startswith(abs_dir) else ""
101
+ python = f'"{uv}" run {no_sync} --directory "{abs_dir}" {with_flags}'.strip()
91
102
  abs_context_path = str(Path(context_path).resolve())
92
103
  input_path_posix = Path(context_path).as_posix().rstrip("/")
93
- command = tool_config.command.format(context_path=path, abs_context_path=abs_context_path, input_path_posix=input_path_posix, python=python)
104
+ exclude = _build_exclude_str(tool_config.exclude_format, excludes or [], input_path_posix=input_path_posix)
105
+ command = tool_config.command.format(context_path=path, abs_context_path=abs_context_path, input_path_posix=input_path_posix, python=python, exclude=exclude)
94
106
  cache_key = f"{command}:{get_context_hash(context_path)}"
95
107
  if cache_key in _cache:
96
108
  log.info(f"Cache hit: {command}")
@@ -110,7 +122,7 @@ def run_tool(tool_config: ToolConfig, context_path: str) -> RawResult:
110
122
  return raw_result
111
123
 
112
124
 
113
- def run_tools(tool_configs: Collection[ToolConfig], path: str, max_workers: int = 0, early_exit: bool = False) -> list[ToolResult]:
125
+ def run_tools(tool_configs: Collection[ToolConfig], path: str, max_workers: int = 0, early_exit: bool = False, excludes: list[str] | None = None) -> list[ToolResult]:
114
126
  """Run multiple tools and return their parsed results.
115
127
 
116
128
  Runs each tool specified in *tool_configs* on the file or directory at
@@ -152,7 +164,7 @@ def run_tools(tool_configs: Collection[ToolConfig], path: str, max_workers: int
152
164
  >>> results = run_tools(configs, '/path/to/project', parallel=True)"""
153
165
  def _run_and_parse(tool_config: ToolConfig) -> tuple[int, ToolResult]:
154
166
  t0 = time.perf_counter()
155
- raw_result = run_tool(tool_config, path)
167
+ raw_result = run_tool(tool_config, path, excludes)
156
168
  tr = tool_config.parser_class(tool_config.parser_config).parse(raw_result)
157
169
  tr.duration_s = time.perf_counter() - t0
158
170
  return tool_config.order, tr
@@ -22,6 +22,7 @@ class ToolConfig:
22
22
  run_in_target_env: bool = False # If True, run in target project's env via uv
23
23
  extra_deps: list[str] = field(default_factory=list) # Extra deps to inject via uv --with
24
24
  parser_config: dict[str, Any] = field(default_factory=dict)
25
+ exclude_format: str = "" # Per-path template for --exclude injection, e.g. " --exclude {path}"
25
26
 
26
27
 
27
28
  @dataclass
@@ -0,0 +1 @@
1
+ """Tool Response parsers"""
@@ -12,6 +12,7 @@ performance metrics or error scores:
12
12
  Both functions return a float and can be used directly in downstream analytics,
13
13
  visualisation or decision-making pipelines."""
14
14
 
15
+ from pathlib import Path
15
16
 
16
17
 
17
18
  def read_source_lines(file_path: str, line: int, count: int = 5) -> str:
@@ -26,17 +27,118 @@ def read_source_lines(file_path: str, line: int, count: int = 5) -> str:
26
27
 
27
28
 
28
29
  def format_source_context(file: str, line: int | str, context: int = 3, count: int = 8) -> str:
29
- """Return a fenced python code block for the source around `line`, or '' if unavailable."""
30
+ """Return a fenced python code block for the source around `line`, or '' if unavailable.
31
+
32
+ Stops before spilling into the next top-level ``def`` or ``class`` definition.
33
+ """
30
34
  if not isinstance(line, int):
31
35
  return ""
32
36
  context_start = max(1, line - context)
33
37
  raw_lines = read_source_lines(file, context_start, count=count).splitlines()
34
38
  if not raw_lines:
35
39
  return ""
36
- src = "\n".join(f"{context_start + i}: {rline}" for i, rline in enumerate(raw_lines))
40
+ error_offset = line - context_start # 0-based index of the error line in raw_lines
41
+ collected = []
42
+ for i, rline in enumerate(raw_lines):
43
+ if i > error_offset and (
44
+ rline.startswith("def ")
45
+ or rline.startswith("async def ")
46
+ or rline.startswith("class ")
47
+ ):
48
+ break
49
+ collected.append(f"{context_start + i}: {rline}")
50
+ src = "\n".join(collected)
37
51
  return f"\n```python\n{src}\n```"
38
52
 
39
53
 
54
+ _PYTHON_KEYWORDS = frozenset([
55
+ "if", "elif", "else", "for", "while", "with", "assert", "return",
56
+ "raise", "import", "from", "class", "def", "lambda", "yield",
57
+ "del", "pass", "break", "continue", "not", "and", "or", "in", "is",
58
+ "print", "super", "type", "len", "range",
59
+ ])
60
+
61
+
62
+ def extract_callee_name(source_line: str) -> str | None:
63
+ """Extract the primary callee function name from a source line, or None.
64
+
65
+ Prefers the RHS of an assignment so that ``result = func(...)`` returns
66
+ ``func`` rather than the variable on the left. Python keywords and
67
+ built-ins listed in ``_PYTHON_KEYWORDS`` are excluded.
68
+ """
69
+ import re
70
+ stripped = source_line.strip()
71
+ rhs = stripped
72
+ if "=" in stripped and not stripped.startswith(("assert", "return")):
73
+ rhs = stripped.split("=", 1)[1].strip()
74
+ m = re.search(r"\b([a-zA-Z_]\w*)\s*\(", rhs)
75
+ if m and m.group(1) not in _PYTHON_KEYWORDS:
76
+ return m.group(1)
77
+ return None
78
+
79
+
80
+ def _find_project_root(hint_file: str) -> Path:
81
+ from pathlib import Path
82
+ root = Path(hint_file).resolve().parent
83
+ current = root
84
+ for _ in range(8):
85
+ if (current / "pyproject.toml").exists() or (current / "setup.py").exists():
86
+ return current
87
+ parent = current.parent
88
+ if parent == current:
89
+ break
90
+ current = parent
91
+ return root
92
+
93
+
94
+ def find_in_project(func_name: str, hint_file: str, max_lines: int = 10) -> tuple[str, str]:
95
+ """Find func_name definition in project files; same file first, then project-wide.
96
+
97
+ Returns ``(file_path, code_block)`` for the first match, or ``("", "")`` if not found.
98
+ """
99
+ from pathlib import Path
100
+ result = find_function_source(hint_file, func_name, max_lines=max_lines)
101
+ if result:
102
+ return hint_file, result
103
+ root = _find_project_root(hint_file)
104
+ for py_file in sorted(root.rglob("*.py")):
105
+ if py_file.resolve() == Path(hint_file).resolve():
106
+ continue
107
+ r = find_function_source(str(py_file), func_name, max_lines=max_lines)
108
+ if r:
109
+ return str(py_file), r
110
+ return "", ""
111
+
112
+
113
+ def _relative_path(path: str) -> str:
114
+ """Return path relative to cwd, normalised to forward slashes."""
115
+ from pathlib import Path
116
+ try:
117
+ return str(Path(path).relative_to(Path.cwd())).replace("\\", "/")
118
+ except ValueError:
119
+ return path.replace("\\", "/")
120
+
121
+
122
+ def format_callee_context(func_name: str, hint_file: str, max_lines: int = 10) -> str:
123
+ """Return a labelled callee definition block, or '' if not found in project.
124
+
125
+ Output format::
126
+
127
+ Callee `func_name` — `relative/path/to/file.py`
128
+ ```python
129
+ N: def func_name(...):
130
+ ...
131
+ ```
132
+ """
133
+ import re
134
+ callee_file, code_block = find_in_project(func_name, hint_file, max_lines=max_lines)
135
+ if not code_block:
136
+ return ""
137
+ m = re.search(r"```python\n(\d+):", code_block)
138
+ line_ref = f":{m.group(1)}" if m else ""
139
+ return f"\n`{func_name}` is defined at: `{_relative_path(callee_file)}{line_ref}`{code_block}"
140
+
141
+
40
142
  def find_function_source(file: str, func_name: str, max_lines: int = 15) -> str:
41
143
  """Return a fenced python block for the body of func_name, or '' if unavailable."""
42
144
  from pathlib import Path
@@ -64,6 +166,8 @@ def find_function_source(file: str, func_name: str, max_lines: int = 15) -> str:
64
166
  collected.append(line)
65
167
  if len(collected) >= max_lines:
66
168
  break
169
+ while collected and not collected[-1].strip():
170
+ collected.pop()
67
171
  numbered = "\n".join(f"{start_idx + 1 + i}: {ln}" for i, ln in enumerate(collected))
68
172
  return f"\n```python\n{numbered}\n```"
69
173
 
@@ -125,4 +125,11 @@ class CompileParser(AbstractParser):
125
125
  typ = info.get("type", "Error")
126
126
  help_msg = info.get("help", "")
127
127
  code_block = format_source_context(file, line, count=context_lines) or (f"\n```python\n{info['src']}\n```" if info.get("src") else "")
128
- return f"`{file}:{line}` — **{typ}**: {help_msg}{code_block}"
128
+ callee = ""
129
+ src_line = info.get("src", "")
130
+ if src_line:
131
+ from py_cq.parsers.common import extract_callee_name, format_callee_context
132
+ func_name = extract_callee_name(src_line)
133
+ if func_name:
134
+ callee = format_callee_context(func_name, file)
135
+ return f"`{file}:{line}` — **{typ}**: {help_msg}{code_block}{callee}"
@@ -3,8 +3,8 @@
3
3
  Interrogate is invoked with ``-v --fail-under 0``, producing a table of
4
4
  per-file docstring coverage on stdout::
5
5
 
6
- | src/foo.py | 5 | 2 | 60% |
7
- | TOTAL | 5 | 2 | 60% |
6
+ | src/foo.py | 5 | 2 | 3 | 60% |
7
+ | TOTAL | 5 | 2 | 3 | 60.0% |
8
8
 
9
9
  The parser extracts per-file coverage and the TOTAL row, storing the TOTAL
10
10
  as the ``doc_coverage`` metric (0.0–1.0).
@@ -14,7 +14,7 @@ import re
14
14
 
15
15
  from py_cq.localtypes import AbstractParser, RawResult, ToolResult
16
16
 
17
- _ROW_RE = re.compile(r"^\|\s+(.+?)\s+\|\s+(\d+)\s+\|\s+(\d+)\s+\|\s+(\d+)%\s+\|")
17
+ _ROW_RE = re.compile(r"^\|\s+(.+?)\s+\|\s+(\d+)\s+\|\s+(\d+)\s+\|\s+\d+\s+\|\s+(\d+(?:\.\d+)?)%\s*\|")
18
18
 
19
19
 
20
20
  class InterrogateParser(AbstractParser):
@@ -30,7 +30,7 @@ class InterrogateParser(AbstractParser):
30
30
  name = m.group(1).strip()
31
31
  total = int(m.group(2))
32
32
  miss = int(m.group(3))
33
- cover = int(m.group(4))
33
+ cover = float(m.group(4))
34
34
  if name == "TOTAL":
35
35
  total_coverage = cover / 100.0
36
36
  elif total > 0:
@@ -13,6 +13,58 @@ import re as _re
13
13
  from py_cq.localtypes import AbstractParser, RawResult, ToolResult
14
14
 
15
15
 
16
+ def _last_call_line_for_test(stdout: str, test_name: str) -> str:
17
+ """Return the last source line before E-lines in a test's failure section.
18
+
19
+ Captures both indented context lines and pytest's ``>``-prefixed
20
+ current-executing-line marker.
21
+ """
22
+ lines = stdout.splitlines()
23
+ pattern = _re.compile(rf"_{{4,}}\s+{_re.escape(test_name)}\s+_{{4,}}")
24
+ in_section = False
25
+ last_src = ""
26
+ for line in lines:
27
+ if not in_section:
28
+ if pattern.search(line):
29
+ in_section = True
30
+ else:
31
+ stripped = line.strip()
32
+ if stripped.startswith(("_", "=")):
33
+ break
34
+ if stripped.startswith("E ") or stripped == "E":
35
+ break
36
+ if line.startswith((" ", "\t", ">")):
37
+ src = line.lstrip("> \t")
38
+ if src:
39
+ last_src = src
40
+ return last_src
41
+
42
+
43
+ _COLLECTION_FILE_RE = _re.compile(r'E\s+File "([^"]+)", line (\d+)')
44
+ _COLLECTION_ERROR_RE = _re.compile(r"E\s+(\w+(?:Error|Warning|Exception)):\s*(.*)")
45
+
46
+
47
+ def _extract_collection_error(stdout: str) -> dict | None:
48
+ """Return {file, line, type, help} if pytest stdout contains a collection error."""
49
+ file_match = None
50
+ error_match = None
51
+ for line in stdout.splitlines():
52
+ m = _COLLECTION_FILE_RE.search(line)
53
+ if m:
54
+ file_match = m
55
+ m = _COLLECTION_ERROR_RE.search(line)
56
+ if m:
57
+ error_match = m
58
+ if file_match and error_match:
59
+ return {
60
+ "file": file_match.group(1).replace("\\", "/"),
61
+ "line": int(file_match.group(2)),
62
+ "type": error_match.group(1),
63
+ "help": error_match.group(2).strip(),
64
+ }
65
+ return None
66
+
67
+
16
68
  def _extract_failure(stdout: str, test_name: str, max_lines: int) -> str:
17
69
  """Extract the failure section for test_name from pytest stdout."""
18
70
  lines = stdout.splitlines()
@@ -102,8 +154,12 @@ class PytestParser(AbstractParser):
102
154
  return tr
103
155
 
104
156
  def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
105
- """Return the first failing test with function body and failure output."""
106
- from py_cq.parsers.common import find_function_source
157
+ """Return the first failing test with function body, failure output, and callee signature."""
158
+ from py_cq.parsers.common import (
159
+ extract_callee_name,
160
+ find_function_source,
161
+ format_callee_context,
162
+ )
107
163
  for file, tests in tr.details.items():
108
164
  if not isinstance(tests, dict):
109
165
  continue
@@ -111,13 +167,22 @@ class PytestParser(AbstractParser):
111
167
  if status != "FAILED":
112
168
  continue
113
169
  header = f"`{file}::{test_name}` — test **FAILED**"
114
- body = find_function_source(file, test_name, max_lines=context_lines)
170
+ bare_name = test_name.split("[")[0]
171
+ body = find_function_source(file, bare_name, max_lines=context_lines)
115
172
  failure = _extract_failure(tr.raw.stdout, test_name, max_lines=context_lines)
173
+ callee = ""
174
+ call_line = _last_call_line_for_test(tr.raw.stdout, test_name)
175
+ if call_line:
176
+ func_name = extract_callee_name(call_line)
177
+ if func_name and func_name != bare_name:
178
+ callee = format_callee_context(func_name, file)
116
179
  parts = [header]
117
180
  if body:
118
181
  parts.append(body)
119
182
  if failure:
120
183
  parts.append(failure)
184
+ if callee:
185
+ parts.append(callee)
121
186
  return "\n".join(parts)
122
187
  if "no tests ran" in tr.raw.stdout:
123
188
  return (
@@ -125,7 +190,29 @@ class PytestParser(AbstractParser):
125
190
  "Add a `tests/` directory with at least one test file (e.g. `tests/test_basic.py`) "
126
191
  "and write a first test covering a core function."
127
192
  )
128
- output = (tr.raw.stdout + tr.raw.stderr).strip()
193
+ from py_cq.parsers.common import (
194
+ extract_callee_name,
195
+ format_callee_context,
196
+ format_source_context,
197
+ )
198
+ combined = tr.raw.stdout + tr.raw.stderr
199
+ err = _extract_collection_error(combined)
200
+ if err:
201
+ file, line, typ, help_msg = err["file"], err["line"], err["type"], err["help"]
202
+ code_block = format_source_context(file, line, count=context_lines) or ""
203
+ callee = ""
204
+ # try to find callee from the offending source line via format_source_context result
205
+ src_line = ""
206
+ for ln in (tr.raw.stdout + tr.raw.stderr).splitlines():
207
+ m = _re.match(r"E\s{6,}(\S.*)", ln)
208
+ if m:
209
+ src_line = m.group(1)
210
+ if src_line:
211
+ func_name = extract_callee_name(src_line)
212
+ if func_name:
213
+ callee = format_callee_context(func_name, file)
214
+ return f"`{file}:{line}` — **{typ}**: {help_msg}{code_block}{callee}"
215
+ output = combined.strip()
129
216
  if output:
130
217
  tail = "\n".join(output.splitlines()[-30:])
131
218
  return f"pytest reported failures:\n\n```\n{tail}\n```"
@@ -18,6 +18,16 @@ from py_cq.parsers.common import format_source_context, score_logistic_variant
18
18
 
19
19
  _DIAG_RE = re.compile(r"^(.+):(\d+):\d+:\s+(error|warning)\[([^\]]+)\] (.+)$")
20
20
 
21
+ _CALL_CODES = frozenset([
22
+ "call-non-callable",
23
+ "missing-argument",
24
+ "unexpected-keyword",
25
+ "argument-type",
26
+ "too-many-positional-arguments",
27
+ "invalid-argument-type",
28
+ "no-matching-overload",
29
+ ])
30
+
21
31
 
22
32
  class TyParser(AbstractParser):
23
33
  """Parses raw output from ``ty check`` into a structured ToolResult."""
@@ -58,4 +68,12 @@ class TyParser(AbstractParser):
58
68
  line = issue.get("line", "?")
59
69
  code = issue.get("code", "")
60
70
  message = issue.get("message", "")
61
- return f"`{file}:{line}` — **{code}**: {message}{format_source_context(file, line, count=context_lines)}"
71
+ src_ctx = format_source_context(file, line, count=context_lines)
72
+ callee = ""
73
+ if code in _CALL_CODES and isinstance(line, int):
74
+ from py_cq.parsers.common import extract_callee_name, format_callee_context, read_source_lines
75
+ src_line = read_source_lines(file, line, count=1)
76
+ func_name = extract_callee_name(src_line)
77
+ if func_name:
78
+ callee = format_callee_context(func_name, file)
79
+ return f"`{file}:{line}` — **{code}**: {message}{src_ctx}{callee}"
@@ -30,6 +30,7 @@ def load_tool_configs() -> dict[str, ToolConfig]:
30
30
  run_in_target_env=tool_data.get("run_in_target_env", False),
31
31
  extra_deps=tool_data.get("extra_deps", []),
32
32
  parser_config=tool_data.get("parser_config", {}),
33
+ exclude_format=tool_data.get("exclude_format", ""),
33
34
  )
34
35
  return registry
35
36
 
File without changes