python-code-quality 0.1.15__py3-none-any.whl → 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
py_cq/cli.py CHANGED
@@ -10,27 +10,26 @@ 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
- import copy
13
+
14
14
  import io
15
15
  import json
16
16
  import logging
17
+ import time
17
18
  import tomllib
18
19
  from enum import Enum
19
- from importlib import import_module
20
20
  from importlib.metadata import requires, version
21
21
  from pathlib import Path
22
22
 
23
+ import tomlkit
23
24
  import typer
24
25
  from rich.console import Console
25
26
  from rich.logging import RichHandler
26
27
  from rich.table import Table
27
28
 
28
- from py_cq.config import load_user_config
29
- from py_cq.execution_engine import _cache as tool_cache
30
- from py_cq.execution_engine import run_tools
29
+ from py_cq.api import CQ, _apply_user_config
31
30
  from py_cq.language_detector import detect_language
32
- from py_cq.localtypes import CombinedToolResults, ToolConfig
33
31
  from py_cq.metric_aggregator import aggregate_metrics
32
+ from py_cq.table_formatter import format_as_table
34
33
  from py_cq.tool_registry import tool_registry
35
34
 
36
35
  logging.basicConfig(
@@ -44,60 +43,27 @@ app = typer.Typer(
44
43
  epilog=(
45
44
  "Examples:\n\n"
46
45
  " cq check . # full table with all metrics (default)\n\n"
47
- " cq check . -o llm # top defect as markdown (primary LLM workflow)\n\n"
48
- " cq check . -o score # numeric score only\n\n"
49
- " cq check . -o json # parsed metrics as json\n\n"
50
- " cq check . -o raw # unprocessed tool output as json\n\n"
51
- " cq config . # show effective tool configuration"
46
+ " cq check . -o llm # top defect as markdown (primary LLM workflow)\n\n"
47
+ " cq check . -o llm-json # top defect as JSON with fingerprint for automation\n\n"
48
+ " cq check . -o score # numeric score only\n\n"
49
+ " cq check . -o json # parsed metrics as json\n\n"
50
+ " cq check . -o raw # unprocessed tool output as json\n\n"
51
+ " cq config # show effective tool configuration\n"
52
+ " cq config --path . # show configuration for current project\n\n"
53
+ " cq config set radon-hal --warning 0.45 --error 0.25 # set thresholds\n\n"
54
+ " cq config set radon-hal --error 0.25 --path . # set with path"
52
55
  ),
53
56
  )
54
57
 
55
58
 
56
- def _apply_user_config(base: dict[str, ToolConfig], user_cfg: dict) -> dict[str, ToolConfig]:
57
- """Return a modified copy of base with user overrides applied.
58
-
59
- Supports:
60
- - ``disable``: list of tool IDs to remove
61
- - ``thresholds.<tool_id>.warning`` / ``.error``: override per-tool thresholds
62
- - ``tools.<tool_id>``: declare new tools (or override built-ins)
63
- """
64
- registry = {k: copy.copy(v) for k, v in base.items()}
65
- for tool_id in user_cfg.get("disable", []):
66
- registry.pop(tool_id, None)
67
- for tool_id, thresholds in user_cfg.get("thresholds", {}).items():
68
- if tool_id in registry:
69
- if "warning" in thresholds:
70
- registry[tool_id].warning_threshold = float(thresholds["warning"])
71
- if "error" in thresholds:
72
- registry[tool_id].error_threshold = float(thresholds["error"])
73
- for tool_id, tool_data in user_cfg.get("tools", {}).items():
74
- try:
75
- parser_name = tool_data["parser"]
76
- module = import_module(f"py_cq.parsers.{parser_name.lower()}")
77
- parser_class = getattr(module, parser_name)
78
- registry[tool_id] = ToolConfig(
79
- name=tool_id,
80
- command=tool_data["command"],
81
- parser_class=parser_class,
82
- order=tool_data["order"],
83
- warning_threshold=tool_data["warning_threshold"],
84
- error_threshold=tool_data["error_threshold"],
85
- run_in_target_env=tool_data.get("run_in_target_env", False),
86
- extra_deps=tool_data.get("extra_deps", []),
87
- parser_config=tool_data.get("parser_config", {}),
88
- exclude_format=tool_data.get("exclude_format", ""),
89
- )
90
- except KeyError as e:
91
- raise typer.BadParameter(f"[tool.cq.tools.{tool_id}] missing required field {e}")
92
- return registry
93
-
94
-
95
59
  class OutputMode(str, Enum):
96
60
  """Enum of output types."""
61
+
97
62
  TABLE = "table"
98
63
  SCORE = "score"
99
64
  JSON = "json"
100
65
  LLM = "llm"
66
+ LLM_JSON = "llm-json"
101
67
  RAW = "raw"
102
68
 
103
69
 
@@ -105,13 +71,11 @@ def _version_callback(value: bool) -> None:
105
71
  if not value:
106
72
  return
107
73
  import re
108
- import sys
109
- if isinstance(sys.stdout, io.TextIOWrapper):
110
- sys.stdout.reconfigure(encoding="utf-8")
74
+
111
75
  pkg = "python-code-quality"
112
76
  pkg_version = version(pkg)
113
77
  dep_versions: list[tuple[str, str]] = []
114
- for req in (requires(pkg) or []):
78
+ for req in requires(pkg) or []:
115
79
  if "; extra ==" in req:
116
80
  continue
117
81
  dep_name = re.split(r"[>=<!;\s\[]", req)[0]
@@ -121,17 +85,28 @@ def _version_callback(value: bool) -> None:
121
85
  pass
122
86
  typer.echo(f"{pkg} v{pkg_version}")
123
87
  for dep_name, dep_ver in sorted(dep_versions):
124
- typer.echo(f"\u251c\u2500\u2500 {dep_name} v{dep_ver}")
88
+ typer.echo(f"+-- {dep_name} v{dep_ver}")
125
89
  raise typer.Exit()
126
90
 
127
91
 
128
92
  @app.callback()
129
93
  def callback(
130
94
  _: bool = typer.Option(
131
- False, "--version", "-V", callback=_version_callback, is_eager=True, help="Show version and dependencies"
95
+ False,
96
+ "--version",
97
+ "-V",
98
+ callback=_version_callback,
99
+ is_eager=True,
100
+ help="Show version and dependencies",
132
101
  ),
133
102
  ) -> None:
134
103
  """Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm"""
104
+ import sys
105
+
106
+ if isinstance(sys.stdout, io.TextIOWrapper):
107
+ sys.stdout.reconfigure(encoding="utf-8")
108
+
109
+
135
110
  console = Console()
136
111
 
137
112
 
@@ -139,7 +114,10 @@ console = Console()
139
114
  def check(
140
115
  path: str = typer.Argument(".", help="Path to Python file or project directory"),
141
116
  output: OutputMode = typer.Option(
142
- OutputMode.TABLE, "--output", "-o", help="Output mode: table (default), score, json, llm"
117
+ OutputMode.TABLE,
118
+ "--output",
119
+ "-o",
120
+ help="Output mode: table (default), score, json, llm",
143
121
  ),
144
122
  log_level: str = typer.Option(
145
123
  "CRITICAL",
@@ -150,10 +128,15 @@ def check(
150
128
  False, "--clear-cache", help="Clear cached tool results before running"
151
129
  ),
152
130
  workers: int = typer.Option(
153
- 0, "--workers", help="Max parallel workers (default: one per tool, use 1 for sequential)"
131
+ 0,
132
+ "--workers",
133
+ help="Max parallel workers (default: one per tool, use 1 for sequential)",
154
134
  ),
155
135
  language: str | None = typer.Option(
156
- None, "--language", "-l", help="Override language detection (e.g. python, typescript, rust)"
136
+ None,
137
+ "--language",
138
+ "-l",
139
+ help="Override language detection (e.g. python, typescript, rust)",
157
140
  ),
158
141
  only: str | None = typer.Option(
159
142
  None, "--only", help="Comma-separated tool IDs to run (e.g. ruff,ty,pytest)"
@@ -164,8 +147,20 @@ def check(
164
147
  exclude: str | None = typer.Option(
165
148
  None, "--exclude", help="Comma-separated paths to exclude (e.g. demo,docs)"
166
149
  ),
150
+ hint: bool = typer.Option(
151
+ False, "--hint", help="Append 'run cq again to verify' to -o llm output"
152
+ ),
153
+ limit: int = typer.Option(
154
+ 1, "--limit", help="Number of issues to show with -o llm (default: 1)"
155
+ ),
156
+ silence: list[str] = typer.Option(
157
+ [],
158
+ "--silence",
159
+ "-s",
160
+ help="Silence issues from -o llm output (e.g. -s src/foo.py or -s src/foo.py:42:E501)",
161
+ ),
167
162
  ):
168
- """Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm""" # --help
163
+ """Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm""" # --help
169
164
  path_obj = Path(path)
170
165
  if not path_obj.exists():
171
166
  raise typer.BadParameter(f"Path does not exist: {path}")
@@ -179,49 +174,76 @@ def check(
179
174
  )
180
175
  raise typer.Exit(0)
181
176
 
182
- # Python path (or unknown — fall through to existing validation).
183
- # Note: --language python still requires pyproject.toml; the flag selects
184
- # the tool set, not the input validation rules.
185
177
  if path_obj.is_file():
186
178
  if path_obj.suffix != ".py":
187
179
  raise typer.BadParameter(f"File must be a Python file (.py): {path}")
188
- elif path_obj.is_dir():
180
+ elif path_obj.is_dir(): # pragma: no branch
189
181
  if not (path_obj / "pyproject.toml").exists():
190
182
  raise typer.BadParameter(f"Directory must contain pyproject.toml: {path}")
191
183
  log.setLevel(log_level)
192
- user_cfg = load_user_config(path_obj)
193
- context_lines: int = int(user_cfg.get("context_lines", 15))
194
- effective_registry = _apply_user_config(tool_registry, user_cfg)
195
- if only:
196
- keep = set(only.split(","))
197
- effective_registry = {k: v for k, v in effective_registry.items() if k in keep}
198
- if skip:
199
- drop = set(skip.split(","))
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))
204
- if clear_cache:
205
- tool_cache.clear()
206
- tool_results = run_tools(effective_registry.values(), path, workers, early_exit=(output == OutputMode.LLM), excludes=excludes)
207
- # for tr in tool_results:
208
- # log.debug(json.dumps(tr.to_dict(), indent=2))
209
- combined_metrics = aggregate_metrics(path=path, metrics=tool_results)
184
+
185
+ only_list = [t.strip() for t in only.split(",")] if only else None
186
+ skip_list = [t.strip() for t in skip.split(",")] if skip else None
187
+ exclude_list = [e.strip() for e in exclude.split(",")] if exclude else None
188
+
189
+ try:
190
+ cq = CQ(
191
+ path_obj,
192
+ only=only_list,
193
+ skip=skip_list,
194
+ exclude=exclude_list,
195
+ workers=workers,
196
+ clear_cache=clear_cache,
197
+ )
198
+ except ValueError as e:
199
+ raise typer.BadParameter(str(e))
200
+
201
+ is_llm = output in (OutputMode.LLM, OutputMode.LLM_JSON)
202
+ t0 = time.perf_counter()
203
+ tool_results = cq.raw(early_exit=is_llm)
204
+ total_s = time.perf_counter() - t0
205
+ combined = aggregate_metrics(path, tool_results)
206
+
210
207
  if output == OutputMode.SCORE:
211
- console.print(combined_metrics.score)
208
+ console.print(combined.score)
212
209
  elif output == OutputMode.JSON:
213
210
  print(json.dumps([tr.to_dict() for tr in tool_results], indent=2))
214
211
  elif output == OutputMode.RAW:
215
212
  print(json.dumps([tr.raw.to_dict() for tr in tool_results], indent=2))
216
213
  elif output == OutputMode.LLM:
217
- # log.setLevel("CRITICAL")
218
214
  from py_cq.llm_formatter import format_for_llm
219
- console.print(format_for_llm(effective_registry, combined_metrics, context_lines=context_lines))
215
+
216
+ print(
217
+ format_for_llm(
218
+ cq._registry,
219
+ combined,
220
+ context_lines=cq._context_lines,
221
+ hint=hint,
222
+ limit=limit,
223
+ silence=silence,
224
+ )
225
+ )
226
+ elif output == OutputMode.LLM_JSON:
227
+ from py_cq.llm_formatter import format_for_llm_json
228
+
229
+ print(
230
+ json.dumps(
231
+ format_for_llm_json(
232
+ cq._registry,
233
+ combined,
234
+ context_lines=cq._context_lines,
235
+ hint=hint,
236
+ limit=limit,
237
+ silence=silence,
238
+ project_root=cq._project_root,
239
+ )
240
+ )
241
+ )
220
242
  else:
221
243
  console.print(f"[bold green]{path_obj.resolve()}[/]")
222
- console.print(format_as_table(combined_metrics, effective_registry))
244
+ console.print(format_as_table(combined, cq._registry, total_s=total_s))
223
245
 
224
- tool_by_name = {tc.name: tc for tc in effective_registry.values()}
246
+ tool_by_name = {tc.name: tc for tc in cq._registry.values()}
225
247
  if any(
226
248
  min(tr.metrics.values()) < tool_by_name[tr.raw.tool_name].error_threshold
227
249
  for tr in tool_results
@@ -230,11 +252,20 @@ def check(
230
252
  raise typer.Exit(code=1)
231
253
 
232
254
 
233
- @app.command()
255
+ config_app = typer.Typer(help="Show or modify tool configuration")
256
+ app.add_typer(config_app, name="config")
257
+
258
+
259
+ @config_app.callback(invoke_without_command=True)
234
260
  def config(
235
- path: str = typer.Argument(".", help="Path to Python file or project directory"),
261
+ ctx: typer.Context,
262
+ path: str = typer.Option(
263
+ ".", "--path", "-p", help="Path to Python file or project directory"
264
+ ),
236
265
  ) -> None:
237
266
  """Show the effective tool configuration for a project."""
267
+ if ctx.invoked_subcommand is not None:
268
+ return
238
269
  path_obj = Path(path).resolve()
239
270
  toml_path = (
240
271
  path_obj.parent / "pyproject.toml"
@@ -258,7 +289,10 @@ def config(
258
289
 
259
290
  console.print(f"Config: [bold]{toml_path}[/bold] ({status_text})\n")
260
291
 
261
- effective_registry = _apply_user_config(tool_registry, user_cfg)
292
+ try:
293
+ effective_registry = _apply_user_config(tool_registry, user_cfg)
294
+ except ValueError as e:
295
+ raise typer.BadParameter(str(e))
262
296
  disabled_ids = set(tool_registry.keys()) - set(effective_registry.keys())
263
297
 
264
298
  table = Table()
@@ -269,7 +303,10 @@ def config(
269
303
  table.add_column("Status", justify="center")
270
304
 
271
305
  all_tool_ids = set(tool_registry) | set(effective_registry)
272
- for tool_id in sorted(all_tool_ids, key=lambda t: (effective_registry.get(t) or tool_registry[t]).order):
306
+ for tool_id in sorted(
307
+ all_tool_ids,
308
+ key=lambda t: (effective_registry.get(t) or tool_registry[t]).order,
309
+ ):
273
310
  tc = effective_registry.get(tool_id) or tool_registry[tool_id]
274
311
  is_disabled = tool_id in disabled_ids
275
312
  status = "[red]disabled[/red]" if is_disabled else "[green]enabled[/green]"
@@ -284,41 +321,93 @@ def config(
284
321
  console.print(table)
285
322
 
286
323
 
287
- def format_as_table(data: CombinedToolResults, registry: dict[str, ToolConfig]):
288
- """Format combined tool results into a Rich Table.
289
-
290
- Args:
291
- data (CombinedToolResults): Aggregated tool results, including the path,
292
- individual tool results, and the overall score.
293
-
294
- Returns:
295
- rich.table.Table: A Rich table with columns ``Tool``, ``Metric``, ``Score`` and
296
- ``Status``. Each metric row displays a status icon based on thresholds from
297
- the tool's configuration. The table is titled with the data path and ends
298
- with a row showing the overall score.
299
-
300
- Example:
301
- >>> table = format_as_table(combined_results)
302
- >>> console.print(table)
303
- """
304
- table = Table(width=80)
305
- table.add_column("Tool", justify="left", no_wrap=True)
306
- table.add_column("Time", justify="right", style="dim")
307
- table.add_column("Metric", justify="right", style="cyan", no_wrap=True)
308
- table.add_column("Score", style="magenta")
309
- table.add_column("Status")
310
- for tr in data.tool_results:
311
- tool_name = tr.raw.tool_name
312
- config = next((t for t in registry.values() if t.name == tool_name))
313
- for i, (name, value) in enumerate(tr.metrics.items()):
314
- status = ""
315
- if value < config.error_threshold:
316
- status = "[bold red]Error[/]"
317
- elif value < config.warning_threshold:
318
- status = "[yellow]Warning[/]"
319
- else:
320
- status = "[green]OK[/]"
321
- time_str = f"{tr.duration_s:.2f}s" if i == 0 else ""
322
- table.add_row(tool_name, time_str, name, f"{value:0.3f}", status)
323
- table.add_row("", "", "[bold]Score[/]", f"[bold]{data.score:0.3f}[/]", "")
324
- return table
324
+ @config_app.command("set")
325
+ def config_set(
326
+ tool_id: str = typer.Argument(..., help="Tool ID (e.g. radon-hal, ruff)"),
327
+ warning: float | None = typer.Option(
328
+ None, "--warning", "-w", help="Warning threshold (0-1)"
329
+ ),
330
+ error: float | None = typer.Option(
331
+ None, "--error", "-e", help="Error threshold (0-1)"
332
+ ),
333
+ path: str = typer.Option(".", "--path", "-p", help="Path to project directory"),
334
+ ) -> None:
335
+ """Set warning/error thresholds for a tool in pyproject.toml."""
336
+ if warning is None and error is None:
337
+ raise typer.BadParameter("At least one of --warning or --error is required")
338
+
339
+ if tool_id not in tool_registry:
340
+ available = ", ".join(sorted(tool_registry))
341
+ raise typer.BadParameter(
342
+ f"Unknown tool: {tool_id!r}. Available tools: {available}"
343
+ )
344
+
345
+ path_obj = Path(path).resolve()
346
+ if not path_obj.is_dir():
347
+ raise typer.BadParameter(f"Path must be a directory: {path}")
348
+
349
+ toml_path = path_obj / "pyproject.toml"
350
+ if not toml_path.exists():
351
+ raise typer.BadParameter(f"No pyproject.toml found at {toml_path}")
352
+
353
+ with toml_path.open("r", encoding="utf-8") as f:
354
+ doc = tomlkit.parse(f.read())
355
+
356
+ if "tool" not in doc:
357
+ doc["tool"] = tomlkit.table()
358
+ tool_tbl = doc["tool"]
359
+ if "cq" not in tool_tbl:
360
+ tool_tbl["cq"] = tomlkit.table()
361
+ cq_tbl = tool_tbl["cq"]
362
+ if "thresholds" not in cq_tbl:
363
+ cq_tbl["thresholds"] = tomlkit.table()
364
+ thresholds = cq_tbl["thresholds"]
365
+
366
+ if tool_id in thresholds:
367
+ entry = thresholds[tool_id]
368
+ else:
369
+ entry = tomlkit.inline_table()
370
+
371
+ if warning is not None:
372
+ entry["warning"] = warning
373
+ if error is not None:
374
+ entry["error"] = error
375
+ thresholds[tool_id] = entry
376
+
377
+ with toml_path.open("w", encoding="utf-8") as f:
378
+ f.write(tomlkit.dumps(doc))
379
+
380
+ parts = []
381
+ if warning is not None:
382
+ parts.append(f"warning={warning}")
383
+ if error is not None:
384
+ parts.append(f"error={error}")
385
+ console.print(
386
+ f"[green]Set {tool_id} thresholds ({', '.join(parts)}) in {toml_path}[/green]"
387
+ )
388
+
389
+ from py_cq.execution_engine import _cache
390
+
391
+ _cache.clear()
392
+ console.print("[dim]Tool cache cleared[/dim]")
393
+
394
+
395
+ @app.command()
396
+ def is_fixed(
397
+ fingerprint: str = typer.Argument(
398
+ ...,
399
+ help="Fingerprint from -o llm-json output (tool::project::path[::line[::code]])",
400
+ ),
401
+ ) -> None:
402
+ """Return True if the fingerprinted issue is no longer present."""
403
+ try:
404
+ cq = CQ(".")
405
+ fixed = cq.is_fixed(fingerprint)
406
+ except ValueError as e:
407
+ raise typer.BadParameter(str(e))
408
+
409
+ if fixed:
410
+ typer.echo("FIXED")
411
+ else:
412
+ typer.echo(f"FAILED: {fingerprint}")
413
+ raise typer.Exit(1)
@@ -0,0 +1,95 @@
1
+ [python.compile]
2
+ command = "{python} -m compileall -r 10 -j 8 \"{context_path}\" -x .*venv|\\.claude"
3
+ parser = "CompileParser"
4
+ order = 1
5
+ warning_threshold = 0.9999
6
+ error_threshold = 0.9999
7
+
8
+ [python.ruff]
9
+ command = "{python} -m ruff check --output-format concise --no-cache \"{context_path}\" --exclude .claude{exclude}"
10
+ exclude_format = " --exclude {path}"
11
+ parser = "RuffParser"
12
+ order = 2
13
+ warning_threshold = 0.9999
14
+ error_threshold = 0.9
15
+
16
+ [python.ty]
17
+ command = "{python} -m ty check --output-format concise --color never \"{context_path}\" --exclude .claude{exclude}"
18
+ exclude_format = " --exclude {path}"
19
+ parser = "TyParser"
20
+ order = 3
21
+ warning_threshold = 0.9999
22
+ error_threshold = 0.8
23
+ run_in_target_env = true
24
+ extra_deps = ["ty"]
25
+
26
+ [python.bandit]
27
+ command = "{python} -m bandit -r {scan_targets} -f json -q -s B101 --severity-level medium{exclude}"
28
+ scan_exclude_names = [".venv", ".claude", "tests"]
29
+ exclude_format = " --exclude {abs_native_path}"
30
+ parser = "BanditParser"
31
+ order = 4
32
+ warning_threshold = 0.9999
33
+ error_threshold = 0.8
34
+
35
+ [python.pytest]
36
+ command = "{python} -m pytest -vv \"{context_path}\" --ignore .claude{exclude}"
37
+ exclude_format = " --ignore {path}"
38
+ parser = "PytestParser"
39
+ order = 5
40
+ warning_threshold = 1.0
41
+ error_threshold = 1.0
42
+ run_in_target_env = true
43
+ extra_deps = ["pytest"]
44
+ skip_for_file = true
45
+
46
+ [python.coverage]
47
+ command = "{python} -m coverage run --omit=*/tests/*,*/test_*.py -m pytest \"{context_path}\" --ignore .claude{exclude} && {python} -m coverage report --show-missing --omit=*/tests/*,*/test_*.py"
48
+ exclude_format = " --ignore {path}"
49
+ parser = "CoverageParser"
50
+ order = 6
51
+ warning_threshold = 0.9
52
+ error_threshold = 0.5
53
+ run_in_target_env = true
54
+ extra_deps = ["coverage", "pytest"]
55
+ skip_for_file = true
56
+
57
+ [python.radon-cc]
58
+ command = "{python} -m radon cc --json --exclude '.claude/**' \"{context_path}\""
59
+ parser = "ComplexityParser"
60
+ order = 7
61
+ warning_threshold = 0.6
62
+ error_threshold = 0.4
63
+
64
+ [python.radon-mi]
65
+ command = "{python} -m radon mi -s --json --exclude '.claude/**' \"{context_path}\""
66
+ parser = "MaintainabilityParser"
67
+ order = 8
68
+ warning_threshold = 0.6
69
+ error_threshold = 0.4
70
+
71
+ [python.radon-hal]
72
+ command = "{python} -m radon hal -f --json --exclude '.claude/**' \"{context_path}\""
73
+ parser = "HalsteadParser"
74
+ order = 9
75
+ warning_threshold = 0.5
76
+ error_threshold = 0.3
77
+
78
+ [python.vulture]
79
+ command = "{python} -m vulture \"{context_path}\" --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git,.claude{exclude}"
80
+ exclude_format = ",{path}"
81
+ parser = "VultureParser"
82
+ order = 10
83
+ warning_threshold = 0.9999
84
+ error_threshold = 0.8
85
+
86
+ [python.interrogate]
87
+ command = "{python} -m interrogate \"{context_path}\" -e .claude{exclude} -v --fail-under 0"
88
+ exclude_format = " -e {path}"
89
+ parser = "InterrogateParser"
90
+ order = 11
91
+ warning_threshold = 0.8
92
+ error_threshold = 0.3
93
+
94
+ [python.interrogate.parser_config]
95
+ skip_empty_init = true
py_cq/context_hash.py CHANGED
@@ -44,15 +44,22 @@ def get_sigs(path: str):
44
44
  items = []
45
45
  with os.scandir(path) as entries:
46
46
  for entry in entries:
47
- if entry.is_file() and entry.name.endswith(".py"):
48
- stat_info = entry.stat()
47
+ # Use follow_symlinks=False to prevent cache poisoning from
48
+ # symlinks pointing outside the project tree (M-2)
49
+ if entry.is_file(follow_symlinks=False) and entry.name.endswith(".py"):
50
+ stat_info = entry.stat(follow_symlinks=False)
49
51
  items.append(f"{entry.path}:{stat_info.st_size}:{stat_info.st_mtime}")
50
- if entry.is_dir() and entry.name not in [".venv", "venv", "__pycache__"]:
52
+ if entry.is_dir(follow_symlinks=False) and entry.name not in [
53
+ ".venv",
54
+ "venv",
55
+ "__pycache__",
56
+ ".git",
57
+ ]:
51
58
  items.extend(get_sigs(entry.path))
52
59
  return items
53
60
 
54
61
 
55
- def get_context_hash(path: str):
62
+ def get_context_hash(path: str) -> str:
56
63
  """Compute an MD5 hash that uniquely identifies a file or directory.
57
64
 
58
65
  The hash is derived from a signature string. For a file, the signature consists of
@@ -72,10 +79,13 @@ def get_context_hash(path: str):
72
79
  >>> get_context_hash('/tmp/example.txt')
73
80
  '5d41402abc4b2a76b9719d911017c592'
74
81
  """
75
- sig = "empty"
82
+ h = hashlib.md5() # nosec
76
83
  if os.path.isfile(path):
77
84
  s = os.stat(path)
78
- sig = f"{path}:{s.st_size}:{s.st_mtime}"
85
+ h.update(f"{path}:{s.st_size}:{s.st_mtime}".encode())
79
86
  elif os.path.isdir(path):
80
- sig = "".join(get_sigs(path=path))
81
- return f"{hashlib.md5(sig.encode('utf-8')).hexdigest()}" # nosec
87
+ for sig in sorted(get_sigs(path)):
88
+ h.update(sig.encode())
89
+ else:
90
+ h.update(b"empty")
91
+ return h.hexdigest()