readability-cli 0.10.1__tar.gz → 0.10.3__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 (37) hide show
  1. {readability_cli-0.10.1 → readability_cli-0.10.3}/PKG-INFO +24 -12
  2. {readability_cli-0.10.1 → readability_cli-0.10.3}/README.md +23 -11
  3. {readability_cli-0.10.1 → readability_cli-0.10.3}/pyproject.toml +2 -2
  4. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/checking.py +16 -6
  5. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/cli.py +8 -2
  6. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/configs/ruff.toml +3 -2
  7. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/guide.py +2 -2
  8. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/tools.py +73 -3
  9. {readability_cli-0.10.1 → readability_cli-0.10.3}/test_checking.py +643 -4
  10. {readability_cli-0.10.1 → readability_cli-0.10.3}/test_outline.py +1 -1
  11. {readability_cli-0.10.1 → readability_cli-0.10.3}/uv.lock +1 -1
  12. {readability_cli-0.10.1 → readability_cli-0.10.3}/.github/workflows/ci.yml +0 -0
  13. {readability_cli-0.10.1 → readability_cli-0.10.3}/.github/workflows/publish.yml +0 -0
  14. {readability_cli-0.10.1 → readability_cli-0.10.3}/.github/workflows/update-guides.yml +0 -0
  15. {readability_cli-0.10.1 → readability_cli-0.10.3}/.gitignore +0 -0
  16. {readability_cli-0.10.1 → readability_cli-0.10.3}/.python-version +0 -0
  17. {readability_cli-0.10.1 → readability_cli-0.10.3}/LICENSE +0 -0
  18. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/__init__.py +0 -0
  19. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/configs/biome-default.json +0 -0
  20. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/configs/pyrefly.toml +0 -0
  21. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/guides/Rguide.md +0 -0
  22. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/guides/cppguide.md +0 -0
  23. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/guides/csharp-style.md +0 -0
  24. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/guides/docguide-style.md +0 -0
  25. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/guides/go-guide.md +0 -0
  26. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/guides/htmlcssguide.md +0 -0
  27. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/guides/javaguide.md +0 -0
  28. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/guides/jsguide.md +0 -0
  29. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/guides/jsoncstyleguide.md +0 -0
  30. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/guides/objcguide.md +0 -0
  31. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/guides/pyguide.md +0 -0
  32. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/guides/shellguide.md +0 -0
  33. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/guides/tsguide.md +0 -0
  34. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/guides/vimscriptguide.md +0 -0
  35. {readability_cli-0.10.1 → readability_cli-0.10.3}/readability/outline.py +0 -0
  36. {readability_cli-0.10.1 → readability_cli-0.10.3}/test_guide.py +0 -0
  37. {readability_cli-0.10.1 → readability_cli-0.10.3}/test_package.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: readability-cli
3
- Version: 0.10.1
3
+ Version: 0.10.3
4
4
  Summary: A CLI to lint, format, and type-check code with Google-style defaults, and pull Google style guides in markdown format.
5
5
  Project-URL: Homepage, https://github.com/owahltinez/readability
6
6
  Project-URL: Repository, https://github.com/owahltinez/readability
@@ -23,7 +23,8 @@ quick access to style conventions without browsing HTML pages.
23
23
  ## Features
24
24
 
25
25
  - **Linting & Formatting**: A `check` command that automatically detects and
26
- runs relevant tools (Ruff, Pyrefly, Biome, and gofmt) for your project.
26
+ runs relevant tools (Ruff, Pyrefly, Biome, gofmt, goimports, shfmt,
27
+ shellcheck, and rustfmt) for your project.
27
28
  - **Predictable Ownership**: Every supported format has fixed tools, so
28
29
  configuration customizes checks without changing which formatter runs.
29
30
  - **Style Guides**: A `guide` command that fetches the latest Google style
@@ -90,15 +91,25 @@ readability check src/ tests/ main.py
90
91
 
91
92
  # Automatically fix and format files
92
93
  readability check . --fix
94
+
95
+ # Also apply fixes that may change behavior (implies --fix)
96
+ readability check . --unsafe
93
97
  ```
94
98
 
99
+ Fixes that may change a program's behavior or drop its comments are opt-in
100
+ through `--unsafe`, which every tool applies under its own name: Ruff's
101
+ `--unsafe-fixes` and Biome's `--unsafe`. Tools drawing no such distinction,
102
+ such as gofmt and goimports, are unaffected by it.
103
+
95
104
  ### Supported Formats
96
105
 
97
106
  | Formats | Owners | Project configuration |
98
107
  | ---------------------------------------------------------------- | --------------------------------------- | ------------------------------------- |
99
108
  | `.py` | Ruff lint/format; Pyrefly type checking | Ruff and Pyrefly native configuration |
100
109
  | `.js`, `.jsx`, `.ts`, `.tsx`, `.json`, `.jsonc`, `.css`, `.html` | Biome | `biome.json` or `biome.jsonc` |
101
- | `.go` | gofmt | None |
110
+ | `.go` | gofmt, goimports | None |
111
+ | `.sh`, `.bash` | shfmt format; shellcheck lint | None |
112
+ | `.rs` | rustfmt | None |
102
113
 
103
114
  Markdown, YAML, SCSS, JSONL, and extensions not listed above are unsupported.
104
115
  An unsupported-only path reports that nothing was checked and exits
@@ -106,8 +117,8 @@ successfully.
106
117
 
107
118
  Biome first checks a project's `node_modules/.bin`. Ruff, Pyrefly, and Biome
108
119
  then use `PATH`, followed by `uvx` for the Python tools or `npx` for Biome. The
109
- runners cache downloads, so subsequent runs work offline. Gofmt ships with Go
110
- and must be available on `PATH`.
120
+ runners cache downloads, so subsequent runs work offline. Gofmt and goimports
121
+ must be available on `PATH`.
111
122
 
112
123
  That keeps this package at ~4 MB rather than the ~54 MB it would take to carry
113
124
  Ruff and Pyrefly itself — a cost that would fall on everyone using only `guide`.
@@ -125,8 +136,8 @@ Set `UV_OFFLINE=1` to forbid fetching. A tool that then cannot be reached fails
125
136
  the run rather than passing it.
126
137
 
127
138
  Ruff, Pyrefly, and Biome ship with bundled configurations, so they run on every
128
- file they own without project setup. Gofmt runs on `.go` files even when there
129
- is no `go.mod`.
139
+ file they own without project setup. Gofmt and goimports run on `.go` files even
140
+ when there is no `go.mod`.
130
141
 
131
142
  A tool that could not be reached at all is never skipped quietly:
132
143
 
@@ -181,8 +192,9 @@ caller happened to be standing in.
181
192
  Ruff and Pyrefly defaults follow the
182
193
  [Google Python style guide](https://google.github.io/styleguide/pyguide.html):
183
194
  80-column lines, Google docstrings, one import per line sorted as isort's
184
- `google` profile does, and full type checking. The Biome default applies the
185
- 80-column lines and two-space indentation of the
195
+ `google` profile does, best-practice lint rules (bugbear, pyupgrade, simplify,
196
+ comprehensions, and Ruff-specific rules), and full type checking. The Biome
197
+ default applies the 80-column lines and two-space indentation of the
186
198
  [Google JavaScript style guide](https://google.github.io/styleguide/jsguide.html)
187
199
  and enables Biome's recommended lint rules. Project `biome.json` and
188
200
  `biome.jsonc` files replace those bundled defaults for Biome-owned formats.
@@ -191,9 +203,9 @@ For Python, add `[tool.ruff]` or `[tool.pyrefly]` to `pyproject.toml`, or use
191
203
  `ruff.toml`, `.ruff.toml`, or `pyrefly.toml`. Ruff, Pyrefly, and Biome load
192
204
  their native project configurations in place of bundled defaults. Readability
193
205
  does not interpret EditorConfig itself; a canonical tool such as Biome may opt
194
- into it through that tool's native configuration. Gofmt has no project settings.
195
- The bundled Biome file requires Biome 2.5 or later, matching the fallback
196
- runner's version floor.
206
+ into it through that tool's native configuration. Neither gofmt nor goimports
207
+ has project settings. The bundled Biome file requires Biome 2.5 or later,
208
+ matching the fallback runner's version floor.
197
209
 
198
210
  Configuration is found per file, in the file's own directory and then its
199
211
  ancestors, which is where each canonical tool looks. A package keeping its
@@ -9,7 +9,8 @@ quick access to style conventions without browsing HTML pages.
9
9
  ## Features
10
10
 
11
11
  - **Linting & Formatting**: A `check` command that automatically detects and
12
- runs relevant tools (Ruff, Pyrefly, Biome, and gofmt) for your project.
12
+ runs relevant tools (Ruff, Pyrefly, Biome, gofmt, goimports, shfmt,
13
+ shellcheck, and rustfmt) for your project.
13
14
  - **Predictable Ownership**: Every supported format has fixed tools, so
14
15
  configuration customizes checks without changing which formatter runs.
15
16
  - **Style Guides**: A `guide` command that fetches the latest Google style
@@ -76,15 +77,25 @@ readability check src/ tests/ main.py
76
77
 
77
78
  # Automatically fix and format files
78
79
  readability check . --fix
80
+
81
+ # Also apply fixes that may change behavior (implies --fix)
82
+ readability check . --unsafe
79
83
  ```
80
84
 
85
+ Fixes that may change a program's behavior or drop its comments are opt-in
86
+ through `--unsafe`, which every tool applies under its own name: Ruff's
87
+ `--unsafe-fixes` and Biome's `--unsafe`. Tools drawing no such distinction,
88
+ such as gofmt and goimports, are unaffected by it.
89
+
81
90
  ### Supported Formats
82
91
 
83
92
  | Formats | Owners | Project configuration |
84
93
  | ---------------------------------------------------------------- | --------------------------------------- | ------------------------------------- |
85
94
  | `.py` | Ruff lint/format; Pyrefly type checking | Ruff and Pyrefly native configuration |
86
95
  | `.js`, `.jsx`, `.ts`, `.tsx`, `.json`, `.jsonc`, `.css`, `.html` | Biome | `biome.json` or `biome.jsonc` |
87
- | `.go` | gofmt | None |
96
+ | `.go` | gofmt, goimports | None |
97
+ | `.sh`, `.bash` | shfmt format; shellcheck lint | None |
98
+ | `.rs` | rustfmt | None |
88
99
 
89
100
  Markdown, YAML, SCSS, JSONL, and extensions not listed above are unsupported.
90
101
  An unsupported-only path reports that nothing was checked and exits
@@ -92,8 +103,8 @@ successfully.
92
103
 
93
104
  Biome first checks a project's `node_modules/.bin`. Ruff, Pyrefly, and Biome
94
105
  then use `PATH`, followed by `uvx` for the Python tools or `npx` for Biome. The
95
- runners cache downloads, so subsequent runs work offline. Gofmt ships with Go
96
- and must be available on `PATH`.
106
+ runners cache downloads, so subsequent runs work offline. Gofmt and goimports
107
+ must be available on `PATH`.
97
108
 
98
109
  That keeps this package at ~4 MB rather than the ~54 MB it would take to carry
99
110
  Ruff and Pyrefly itself — a cost that would fall on everyone using only `guide`.
@@ -111,8 +122,8 @@ Set `UV_OFFLINE=1` to forbid fetching. A tool that then cannot be reached fails
111
122
  the run rather than passing it.
112
123
 
113
124
  Ruff, Pyrefly, and Biome ship with bundled configurations, so they run on every
114
- file they own without project setup. Gofmt runs on `.go` files even when there
115
- is no `go.mod`.
125
+ file they own without project setup. Gofmt and goimports run on `.go` files even
126
+ when there is no `go.mod`.
116
127
 
117
128
  A tool that could not be reached at all is never skipped quietly:
118
129
 
@@ -167,8 +178,9 @@ caller happened to be standing in.
167
178
  Ruff and Pyrefly defaults follow the
168
179
  [Google Python style guide](https://google.github.io/styleguide/pyguide.html):
169
180
  80-column lines, Google docstrings, one import per line sorted as isort's
170
- `google` profile does, and full type checking. The Biome default applies the
171
- 80-column lines and two-space indentation of the
181
+ `google` profile does, best-practice lint rules (bugbear, pyupgrade, simplify,
182
+ comprehensions, and Ruff-specific rules), and full type checking. The Biome
183
+ default applies the 80-column lines and two-space indentation of the
172
184
  [Google JavaScript style guide](https://google.github.io/styleguide/jsguide.html)
173
185
  and enables Biome's recommended lint rules. Project `biome.json` and
174
186
  `biome.jsonc` files replace those bundled defaults for Biome-owned formats.
@@ -177,9 +189,9 @@ For Python, add `[tool.ruff]` or `[tool.pyrefly]` to `pyproject.toml`, or use
177
189
  `ruff.toml`, `.ruff.toml`, or `pyrefly.toml`. Ruff, Pyrefly, and Biome load
178
190
  their native project configurations in place of bundled defaults. Readability
179
191
  does not interpret EditorConfig itself; a canonical tool such as Biome may opt
180
- into it through that tool's native configuration. Gofmt has no project settings.
181
- The bundled Biome file requires Biome 2.5 or later, matching the fallback
182
- runner's version floor.
192
+ into it through that tool's native configuration. Neither gofmt nor goimports
193
+ has project settings. The bundled Biome file requires Biome 2.5 or later,
194
+ matching the fallback runner's version floor.
183
195
 
184
196
  Configuration is found per file, in the file's own directory and then its
185
197
  ancestors, which is where each canonical tool looks. A package keeping its
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "readability-cli"
3
- version = "0.10.1"
3
+ version = "0.10.3"
4
4
  description = "A CLI to lint, format, and type-check code with Google-style defaults, and pull Google style guides in markdown format."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -41,7 +41,7 @@ dev = [
41
41
  line-length = 80
42
42
 
43
43
  [tool.ruff.lint]
44
- select = ["E", "W", "F", "I", "N", "D", "PL"]
44
+ select = ["E", "W", "F", "I", "N", "D", "PL", "B", "UP", "SIM", "C4", "RUF"]
45
45
  ignore = ["PLR0911", "PLR0912", "PLR0913", "PLR0915", "PLR2004"]
46
46
 
47
47
  [tool.ruff.lint.pydocstyle]
@@ -62,6 +62,7 @@ def check_paths(
62
62
  paths: Sequence[str | Path],
63
63
  project_root: Path | None = None,
64
64
  fix: bool = False,
65
+ unsafe: bool = False,
65
66
  ) -> CheckReport:
66
67
  """Run relevant checks for paths and aggregate what the tools did.
67
68
 
@@ -78,6 +79,8 @@ def check_paths(
78
79
  and ignore files. Defaults to the repository the process is in.
79
80
  It does not rebase paths.
80
81
  fix: Whether to apply automatic fixes.
82
+ unsafe: Whether those fixes may change behavior or drop comments.
83
+ Tools that draw no such distinction are unaffected.
81
84
 
82
85
  Returns:
83
86
  A report aggregated across all provided paths.
@@ -100,7 +103,7 @@ def check_paths(
100
103
 
101
104
  report = CheckReport()
102
105
  for path in requested_paths:
103
- path_report = _check_path(path, root, boundary, fix=fix)
106
+ path_report = _check_path(path, root, boundary, fix=fix, unsafe=unsafe)
104
107
  if not path_report.ran:
105
108
  path_report.unverified_paths.append(path)
106
109
  report.absorb(path_report)
@@ -112,6 +115,7 @@ def _check_path(
112
115
  project_root: Path,
113
116
  boundary: Path | None = None,
114
117
  fix: bool = False,
118
+ unsafe: bool = False,
115
119
  ) -> CheckReport:
116
120
  """Apply relevant tools to a single path.
117
121
 
@@ -121,6 +125,7 @@ def _check_path(
121
125
  boundary: Outermost directory config discovery may consider, or
122
126
  None to bound it by the path's own repository.
123
127
  fix: Whether to apply automatic fixes.
128
+ unsafe: Whether those fixes may change behavior or drop comments.
124
129
 
125
130
  Returns:
126
131
  What the tools applicable to this path did.
@@ -141,7 +146,7 @@ def _check_path(
141
146
  report.skipped.add(tool.name)
142
147
  continue
143
148
 
144
- report.absorb(_run_tool(tool, fix=fix))
149
+ report.absorb(_run_tool(tool, fix=fix, unsafe=unsafe))
145
150
 
146
151
  return report
147
152
 
@@ -149,12 +154,14 @@ def _check_path(
149
154
  def _run_tool(
150
155
  tool: ToolPlan,
151
156
  fix: bool = False,
157
+ unsafe: bool = False,
152
158
  ) -> CheckReport:
153
159
  """Orchestrate the execution of a specific formatting or linting tool.
154
160
 
155
161
  Args:
156
162
  tool: The executable plan to run.
157
163
  fix: Whether to apply automatic fixes.
164
+ unsafe: Whether those fixes may change behavior or drop comments.
158
165
 
159
166
  Returns:
160
167
  What the tool did: whether it reported findings, and whether it
@@ -168,9 +175,11 @@ def _run_tool(
168
175
  target_count = len(tool.targets or ())
169
176
  try:
170
177
  if fix:
178
+ # A tool with no unsafe mode still fixes what it safely can
179
+ unsafe_fix = tool.unsafe_fix if unsafe else ()
180
+ phases = (("format", tool.format), ("fix", unsafe_fix or tool.fix))
171
181
  # Leftovers exit non-zero, which is a finding rather than a failure
172
- for phase in ("format", "fix"):
173
- configured_command = getattr(tool, phase)
182
+ for phase, configured_command in phases:
174
183
  if configured_command:
175
184
  commands = _command_batches(
176
185
  configured_command, target_count
@@ -195,9 +204,10 @@ def _run_tool(
195
204
  result = _capture_tool_command(command, cwd=tool.cwd)
196
205
  if _tool_checked_files(tool.name, result):
197
206
  report.ran.add(tool.name)
198
- # gofmt reports by naming files rather than by exit code
207
+ # gofmt and goimports name files rather than using exit codes
199
208
  if result.returncode != 0 or (
200
- tool.name == "gofmt" and result.stdout.strip()
209
+ tool.name in ("gofmt", "goimports")
210
+ and result.stdout.strip()
201
211
  ):
202
212
  report.findings = True
203
213
  click.echo(
@@ -167,8 +167,13 @@ def _echo_languages() -> None:
167
167
  @click.option(
168
168
  "--fix", is_flag=True, help="Automatically fix issues if possible."
169
169
  )
170
+ @click.option(
171
+ "--unsafe",
172
+ is_flag=True,
173
+ help="Also apply fixes that may change behavior. Implies --fix.",
174
+ )
170
175
  @click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging.")
171
- def check(paths: Sequence[str], fix: bool, verbose: bool) -> None:
176
+ def check(paths: Sequence[str], fix: bool, unsafe: bool, verbose: bool) -> None:
172
177
  """Run relevant formatters and linters for given paths.
173
178
 
174
179
  Exits with a non-zero status code if any tool reports findings, so the
@@ -177,7 +182,8 @@ def check(paths: Sequence[str], fix: bool, verbose: bool) -> None:
177
182
  if verbose:
178
183
  logger.setLevel(logging.DEBUG)
179
184
 
180
- report = check_paths(paths, fix=fix)
185
+ # Unsafe fixes are still fixes, so asking for them is asking to fix
186
+ report = check_paths(paths, fix=fix or unsafe, unsafe=unsafe)
181
187
 
182
188
  # Coverage the caller does not know is missing reads as coverage
183
189
  if report.skipped:
@@ -6,8 +6,9 @@ line-length = 80
6
6
 
7
7
  [lint]
8
8
  # Pycodestyle (E, W), Pyflakes (F), import order (I), naming (N),
9
- # docstrings (D), and pylint (PL) rules
10
- select = ["E", "W", "F", "I", "N", "D", "PL"]
9
+ # docstrings (D), pylint (PL), bugbear (B), pyupgrade (UP),
10
+ # simplify (SIM), comprehensions (C4), and ruff-specific (RUF) rules
11
+ select = ["E", "W", "F", "I", "N", "D", "PL", "B", "UP", "SIM", "C4", "RUF"]
11
12
  # Complexity counters and magic-value warnings are guidance, not errors
12
13
  ignore = ["PLR0911", "PLR0912", "PLR0913", "PLR0915", "PLR2004"]
13
14
 
@@ -82,7 +82,7 @@ def get_guide_content(url: str) -> str:
82
82
  logger.error("Failed to fetch content from %s: %s", url, e)
83
83
  raise click.ClickException(
84
84
  f"Failed to fetch style guide from {url}: {e}"
85
- )
85
+ ) from e
86
86
 
87
87
  return response.text
88
88
 
@@ -242,7 +242,7 @@ def get_guide(language: str, remote: bool = False) -> str:
242
242
  # If remote is False, check for local file first
243
243
  if not remote and os.path.exists(local_path):
244
244
  logger.info("Reading style guide from local file: %s", local_path)
245
- with open(local_path, "r", encoding="utf-8") as f:
245
+ with open(local_path, encoding="utf-8") as f:
246
246
  return f.read()
247
247
 
248
248
  return refresh_guide(filename)
@@ -21,6 +21,7 @@ class ToolPlan:
21
21
  check: tuple[str, ...] = ()
22
22
  check_format: tuple[str, ...] = ()
23
23
  fix: tuple[str, ...] = ()
24
+ unsafe_fix: tuple[str, ...] = ()
24
25
  format: tuple[str, ...] = ()
25
26
  targets: tuple[str, ...] | None = None
26
27
  cwd: Path | None = None
@@ -300,10 +301,16 @@ def _plan(
300
301
  for exclude in excludes
301
302
  for argument in (str(flag), str(exclude))
302
303
  ]
304
+ # Every tool spells its own opt-in, so callers only ask for the intent
305
+ words_by_phase: dict[str, tuple[str, ...]] = dict(TOOL_PHASES[tool])
306
+ unsafe_flag = UNSAFE_FLAGS.get(tool)
307
+ if unsafe_flag and "fix" in words_by_phase:
308
+ words_by_phase["unsafe_fix"] = (*words_by_phase["fix"], unsafe_flag)
309
+
303
310
  # Named rather than unpacked, so the type checker still sees the fields
304
311
  phases = {
305
312
  phase: (*binary, *words, *config, *carved, *argv)
306
- for phase, words in TOOL_PHASES[tool].items()
313
+ for phase, words in words_by_phase.items()
307
314
  }
308
315
  return ToolPlan(
309
316
  name=tool,
@@ -313,6 +320,7 @@ def _plan(
313
320
  check=phases.get("check", ()),
314
321
  check_format=phases.get("check_format", ()),
315
322
  fix=phases.get("fix", ()),
323
+ unsafe_fix=phases.get("unsafe_fix", ()),
316
324
  format=phases.get("format", ()),
317
325
  )
318
326
 
@@ -339,6 +347,10 @@ TOOL_EXTENSIONS = {
339
347
  ".html",
340
348
  ),
341
349
  "gofmt": (".go",),
350
+ "goimports": (".go",),
351
+ "shfmt": (".sh", ".bash"),
352
+ "shellcheck": (".sh", ".bash"),
353
+ "rustfmt": (".rs",),
342
354
  }
343
355
 
344
356
  # Headroom below common argv limits; long target lists are split before exec
@@ -346,8 +358,20 @@ MAX_COMMAND_BYTES = 16 * 1024
346
358
 
347
359
  # Named files bypass a tool's own exclusions, and Pyrefly ignores its excludes
348
360
  PRUNED_DIRECTORIES = frozenset(
349
- ".git .hg .svn .venv venv .tox .nox node_modules "
350
- "__pycache__ .mypy_cache .pytest_cache .ruff_cache".split()
361
+ [
362
+ ".git",
363
+ ".hg",
364
+ ".svn",
365
+ ".venv",
366
+ "venv",
367
+ ".tox",
368
+ ".nox",
369
+ "node_modules",
370
+ "__pycache__",
371
+ ".mypy_cache",
372
+ ".pytest_cache",
373
+ ".ruff_cache",
374
+ ]
351
375
  )
352
376
 
353
377
  # Per tool: config filenames, pyproject section (None if it has none), flag
@@ -384,8 +408,18 @@ TOOL_PHASES = {
384
408
  "format": ("format", "--write", "--no-errors-on-unmatched"),
385
409
  },
386
410
  "gofmt": {"check_format": ("-l",), "format": ("-w",)},
411
+ "goimports": {"check_format": ("-l",), "format": ("-w",)},
412
+ "shfmt": {
413
+ "check_format": ("-i", "2", "-ci", "-d"),
414
+ "format": ("-i", "2", "-ci", "-w"),
415
+ },
416
+ "shellcheck": {"check": ()},
417
+ "rustfmt": {"check_format": ("--check",), "format": ()},
387
418
  }
388
419
 
420
+ # Per tool: how it names fixes that may change behavior or drop comments
421
+ UNSAFE_FLAGS = {"ruff": "--unsafe-fixes", "biome": "--unsafe"}
422
+
389
423
 
390
424
  def _matching_paths(path: Path, extensions: Sequence[str]) -> list[str]:
391
425
  """Return files under a requested path owned by one tool.
@@ -538,6 +572,42 @@ def _get_tool_definitions(
538
572
  path,
539
573
  )
540
574
  )
575
+ plans.append(
576
+ _plan(
577
+ "goimports",
578
+ ("goimports",),
579
+ [],
580
+ _matching_paths(path, TOOL_EXTENSIONS["goimports"]),
581
+ path,
582
+ )
583
+ )
584
+ plans.append(
585
+ _plan(
586
+ "shfmt",
587
+ ("shfmt",),
588
+ [],
589
+ _matching_paths(path, TOOL_EXTENSIONS["shfmt"]),
590
+ path,
591
+ )
592
+ )
593
+ plans.append(
594
+ _plan(
595
+ "shellcheck",
596
+ ("shellcheck",),
597
+ [],
598
+ _matching_paths(path, TOOL_EXTENSIONS["shellcheck"]),
599
+ path,
600
+ )
601
+ )
602
+ plans.append(
603
+ _plan(
604
+ "rustfmt",
605
+ ("rustfmt",),
606
+ [],
607
+ _matching_paths(path, TOOL_EXTENSIONS["rustfmt"]),
608
+ path,
609
+ )
610
+ )
541
611
 
542
612
  # A directory owning its config alone lets Pyrefly select the files
543
613
  files, section = CONFIG_SOURCES["pyrefly"][:2]
@@ -15,6 +15,8 @@ from readability.cli import cli
15
15
  from readability.tools import _bundled_config
16
16
  from readability.tools import _get_tool_definitions
17
17
  from readability.tools import _has_project_config
18
+ from readability.tools import TOOL_EXTENSIONS
19
+ from readability.tools import TOOL_PHASES
18
20
  from readability.tools import TOOL_RUNNERS
19
21
 
20
22
 
@@ -1045,6 +1047,17 @@ def test_bundled_default_configs_are_valid() -> None:
1045
1047
  }
1046
1048
 
1047
1049
 
1050
+ def test_bundled_ruff_config_includes_enriched_rules() -> None:
1051
+ """The default ruff config selects best-practice and modernization rules."""
1052
+ # Enriched rules for bug prevention, modern syntax, and simplification
1053
+ ruff_config = tomllib.loads(_bundled_config("ruff").read_text())
1054
+ selected_rules = set(ruff_config["lint"]["select"])
1055
+ expected_rules = {"B", "UP", "SIM", "C4", "RUF"}
1056
+
1057
+ # All enriched rule sets must be present
1058
+ assert expected_rules.issubset(selected_rules)
1059
+
1060
+
1048
1061
  @patch("readability.checking._check_path")
1049
1062
  def test_check_paths_aggregates_str_and_path_inputs(
1050
1063
  mock_check_path: MagicMock,
@@ -1080,8 +1093,16 @@ def test_check_paths_aggregates_str_and_path_inputs(
1080
1093
  failed={"biome"},
1081
1094
  )
1082
1095
  assert mock_check_path.call_args_list == [
1083
- call(Path("src/example.py"), project_root, project_root, fix=True),
1084
- call(Path("main.go"), project_root, project_root, fix=True),
1096
+ call(
1097
+ Path("src/example.py"),
1098
+ project_root,
1099
+ project_root,
1100
+ fix=True,
1101
+ unsafe=False,
1102
+ ),
1103
+ call(
1104
+ Path("main.go"), project_root, project_root, fix=True, unsafe=False
1105
+ ),
1085
1106
  ]
1086
1107
 
1087
1108
 
@@ -1128,7 +1149,7 @@ def test_check_paths_defaults_project_root_to_cwd(
1128
1149
  check_paths([Path("example.py")])
1129
1150
 
1130
1151
  mock_check_path.assert_called_once_with(
1131
- Path("example.py"), tmp_path, None, fix=False
1152
+ Path("example.py"), tmp_path, None, fix=False, unsafe=False
1132
1153
  )
1133
1154
 
1134
1155
 
@@ -1197,7 +1218,9 @@ def test_check_command_delegates_to_public_api(
1197
1218
  Path("script.py").touch()
1198
1219
  result = runner.invoke(cli, ["check", "script.py"])
1199
1220
 
1200
- mock_check_paths.assert_called_once_with(("script.py",), fix=False)
1221
+ mock_check_paths.assert_called_once_with(
1222
+ ("script.py",), fix=False, unsafe=False
1223
+ )
1201
1224
  assert result.exit_code == 0
1202
1225
  assert result.stdout == ""
1203
1226
  assert result.stderr == "No findings in 1 path(s) (ruff).\n"
@@ -1598,6 +1621,170 @@ def test_gofmt_fix_failure_is_reported(
1598
1621
  assert "gofmt formatting findings" in result.output
1599
1622
 
1600
1623
 
1624
+ def test_goimports_tool_plan_generation(tmp_path: Path) -> None:
1625
+ """Go files produce goimports plans alongside gofmt."""
1626
+ go_file = tmp_path / "main.go"
1627
+ go_file.touch()
1628
+
1629
+ # Verify tool registration in extensions and phases
1630
+ assert TOOL_EXTENSIONS["goimports"] == (".go",)
1631
+ assert TOOL_PHASES["goimports"] == {
1632
+ "check_format": ("-l",),
1633
+ "format": ("-w",),
1634
+ }
1635
+
1636
+ # Verify tool plan creation
1637
+ plans = {
1638
+ plan.name: plan for plan in _get_tool_definitions(go_file, tmp_path)
1639
+ }
1640
+ assert "goimports" in plans
1641
+ goimports = plans["goimports"]
1642
+ assert goimports.extensions == (".go",)
1643
+ assert goimports.check_format == ("goimports", "-l", str(go_file))
1644
+ assert goimports.format == ("goimports", "-w", str(go_file))
1645
+ assert goimports.check == ()
1646
+ assert goimports.fix == ()
1647
+ assert goimports.targets == (str(go_file),)
1648
+
1649
+
1650
+ @pytest.mark.parametrize("fix", (False, True))
1651
+ @patch("shutil.which")
1652
+ @patch("subprocess.run")
1653
+ def test_configless_go_uses_goimports_for_check_and_fix(
1654
+ mock_run: MagicMock,
1655
+ mock_which: MagicMock,
1656
+ tmp_path: Path,
1657
+ fix: bool,
1658
+ ) -> None:
1659
+ """Standalone Go files use symmetric goimports commands without go.mod."""
1660
+ mock_which.side_effect = lambda name: name if name == "goimports" else None
1661
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
1662
+
1663
+ # Run check or format on Go file
1664
+ runner = CliRunner()
1665
+ with runner.isolated_filesystem(temp_dir=tmp_path):
1666
+ Path("main.go").touch()
1667
+ arguments = ["check", "main.go"]
1668
+ if fix:
1669
+ arguments.append("--fix")
1670
+
1671
+ result = runner.invoke(cli, arguments)
1672
+
1673
+ # Verify goimports command invoked with correct flags
1674
+ assert result.exit_code == 0
1675
+ expected = ["goimports", "-w" if fix else "-l", "main.go"]
1676
+ assert [invocation.args[0] for invocation in mock_run.call_args_list] == [
1677
+ expected
1678
+ ]
1679
+
1680
+
1681
+ @patch("shutil.which")
1682
+ @patch("subprocess.run")
1683
+ def test_goimports_scopes_a_directory_to_go_files(
1684
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
1685
+ ) -> None:
1686
+ """Goimports receives recursive Go files instead of an invalid directory."""
1687
+ mock_which.side_effect = lambda name: name if name == "goimports" else None
1688
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
1689
+
1690
+ # Populate directory structure with mixed files
1691
+ runner = CliRunner()
1692
+ with runner.isolated_filesystem(temp_dir=tmp_path):
1693
+ Path("src/nested").mkdir(parents=True)
1694
+ Path("src/main.go").touch()
1695
+ Path("src/nested/helper.go").touch()
1696
+ Path("src/notes.txt").touch()
1697
+
1698
+ result = runner.invoke(cli, ["check", "src"])
1699
+
1700
+ # Verify only Go files are passed to goimports
1701
+ assert result.exit_code == 0
1702
+ assert [invocation.args[0] for invocation in mock_run.call_args_list] == [
1703
+ ["goimports", "-l", "src/main.go", "src/nested/helper.go"]
1704
+ ]
1705
+
1706
+
1707
+ @patch("shutil.which")
1708
+ @patch("subprocess.run")
1709
+ def test_goimports_check_reports_diff_in_stdout_as_findings(
1710
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
1711
+ ) -> None:
1712
+ """Goimports reports diffs on stdout while exiting zero."""
1713
+ mock_which.side_effect = lambda name: name if name == "goimports" else None
1714
+ mock_run.return_value = MagicMock(
1715
+ returncode=0, stdout="main.go\n", stderr=""
1716
+ )
1717
+
1718
+ # Invoke check on unformatted Go file
1719
+ runner = CliRunner()
1720
+ with runner.isolated_filesystem(temp_dir=tmp_path):
1721
+ Path("main.go").touch()
1722
+
1723
+ result = runner.invoke(cli, ["check", "main.go"])
1724
+
1725
+ # Findings in stdout must fail the check
1726
+ assert result.exit_code == 1
1727
+ assert "--- goimports formatting findings ---" in result.output
1728
+ assert "main.go" in result.output
1729
+
1730
+
1731
+ @patch("shutil.which")
1732
+ @patch("subprocess.run")
1733
+ def test_goimports_fix_failure_is_reported(
1734
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
1735
+ ) -> None:
1736
+ """A failed goimports write cannot be reported as a clean fix."""
1737
+ mock_which.side_effect = lambda name: name if name == "goimports" else None
1738
+ mock_run.return_value = MagicMock(
1739
+ returncode=1, stdout="", stderr="invalid Go syntax"
1740
+ )
1741
+
1742
+ # Invoke check --fix on invalid Go file
1743
+ runner = CliRunner()
1744
+ with runner.isolated_filesystem(temp_dir=tmp_path):
1745
+ Path("main.go").touch()
1746
+
1747
+ result = runner.invoke(cli, ["check", "main.go", "--fix"])
1748
+
1749
+ # Fix error must fail the check
1750
+ assert result.exit_code == 1
1751
+ assert "goimports formatting findings" in result.output
1752
+
1753
+
1754
+ @pytest.mark.parametrize("fix", (False, True))
1755
+ @patch("shutil.which")
1756
+ @patch("subprocess.run")
1757
+ def test_go_runs_both_gofmt_and_goimports(
1758
+ mock_run: MagicMock,
1759
+ mock_which: MagicMock,
1760
+ tmp_path: Path,
1761
+ fix: bool,
1762
+ ) -> None:
1763
+ """When both gofmt and goimports are installed, both run on Go files."""
1764
+ mock_which.side_effect = lambda name: (
1765
+ name if name in ("gofmt", "goimports") else None
1766
+ )
1767
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
1768
+
1769
+ # Run check or fix with both Go tools available
1770
+ runner = CliRunner()
1771
+ with runner.isolated_filesystem(temp_dir=tmp_path):
1772
+ Path("main.go").touch()
1773
+ arguments = ["check", "main.go"]
1774
+ if fix:
1775
+ arguments.append("--fix")
1776
+
1777
+ result = runner.invoke(cli, arguments)
1778
+
1779
+ # Verify both tools ran with the appropriate flag
1780
+ assert result.exit_code == 0
1781
+ flag = "-w" if fix else "-l"
1782
+ assert [invocation.args[0] for invocation in mock_run.call_args_list] == [
1783
+ ["gofmt", flag, "main.go"],
1784
+ ["goimports", flag, "main.go"],
1785
+ ]
1786
+
1787
+
1601
1788
  @patch("shutil.which")
1602
1789
  @patch("subprocess.run")
1603
1790
  def test_large_biome_directory_uses_bounded_commands(
@@ -1643,6 +1830,7 @@ def test_missing_gofmt_is_reported_for_configless_go(
1643
1830
 
1644
1831
  assert result.exit_code == 1
1645
1832
  assert "gofmt" in result.stderr
1833
+ assert "goimports" in result.stderr
1646
1834
  assert "nothing was verified" in result.stderr
1647
1835
 
1648
1836
 
@@ -2052,3 +2240,454 @@ def test_fix_still_reports_what_it_could_not_fix(
2052
2240
  commands = [call.args[0] for call in mock_run.call_args_list]
2053
2241
  assert any("--fix" not in c and "check" in c for c in commands)
2054
2242
  assert result.exit_code != 0
2243
+
2244
+
2245
+ @patch("shutil.which")
2246
+ @patch("subprocess.run")
2247
+ def test_unsafe_translates_to_the_ruff_flag(
2248
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2249
+ ) -> None:
2250
+ """--unsafe reaches ruff under the name ruff gives it.
2251
+
2252
+ Args:
2253
+ mock_run: The mocked subprocess.run function.
2254
+ mock_which: The mocked shutil.which function.
2255
+ tmp_path: The temporary directory fixture.
2256
+ """
2257
+ mock_which.side_effect = lambda name: name if name == "ruff" else None
2258
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
2259
+
2260
+ runner = CliRunner()
2261
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2262
+ Path("pyproject.toml").touch()
2263
+ Path("script.py").touch()
2264
+
2265
+ result = runner.invoke(cli, ["check", "--fix", "--unsafe", "script.py"])
2266
+
2267
+ assert result.exit_code == 0
2268
+ cfg = str(_bundled_config("ruff"))
2269
+ called_cmds = [call.args[0] for call in mock_run.call_args_list]
2270
+ assert [
2271
+ "ruff",
2272
+ "check",
2273
+ "--fix",
2274
+ "--force-exclude",
2275
+ "--unsafe-fixes",
2276
+ "--config",
2277
+ cfg,
2278
+ "script.py",
2279
+ ] in called_cmds
2280
+ # Formatters have no unsafe mode, so the flag must not reach them
2281
+ assert [
2282
+ "ruff",
2283
+ "format",
2284
+ "--force-exclude",
2285
+ "--config",
2286
+ cfg,
2287
+ "script.py",
2288
+ ] in called_cmds
2289
+
2290
+
2291
+ @patch("shutil.which")
2292
+ @patch("subprocess.run")
2293
+ def test_unsafe_translates_to_the_biome_flag(
2294
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2295
+ ) -> None:
2296
+ """Biome spells the same intent differently and gets its own spelling.
2297
+
2298
+ Args:
2299
+ mock_run: The mocked subprocess.run function.
2300
+ mock_which: The mocked shutil.which function.
2301
+ tmp_path: The temporary directory fixture.
2302
+ """
2303
+ mock_which.side_effect = lambda name: name if name == "biome" else None
2304
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
2305
+
2306
+ runner = CliRunner()
2307
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2308
+ Path("script.ts").touch()
2309
+
2310
+ result = runner.invoke(cli, ["check", "--fix", "--unsafe", "script.ts"])
2311
+
2312
+ assert result.exit_code == 0
2313
+ called_cmds = [call.args[0] for call in mock_run.call_args_list]
2314
+ lint = next(c for c in called_cmds if "lint" in c)
2315
+ assert lint[:5] == [
2316
+ "biome",
2317
+ "lint",
2318
+ "--write",
2319
+ "--no-errors-on-unmatched",
2320
+ "--unsafe",
2321
+ ]
2322
+
2323
+
2324
+ @patch("shutil.which")
2325
+ @patch("subprocess.run")
2326
+ def test_unsafe_implies_fix(
2327
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2328
+ ) -> None:
2329
+ """Asking for unsafe fixes without --fix asks for nothing otherwise.
2330
+
2331
+ Args:
2332
+ mock_run: The mocked subprocess.run function.
2333
+ mock_which: The mocked shutil.which function.
2334
+ tmp_path: The temporary directory fixture.
2335
+ """
2336
+ mock_which.side_effect = lambda name: name if name == "ruff" else None
2337
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
2338
+
2339
+ runner = CliRunner()
2340
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2341
+ Path("pyproject.toml").touch()
2342
+ Path("script.py").touch()
2343
+
2344
+ result = runner.invoke(cli, ["check", "--unsafe", "script.py"])
2345
+
2346
+ assert result.exit_code == 0
2347
+ called_cmds = [call.args[0] for call in mock_run.call_args_list]
2348
+ assert any("--unsafe-fixes" in command for command in called_cmds)
2349
+
2350
+
2351
+ @patch("shutil.which")
2352
+ @patch("subprocess.run")
2353
+ def test_unsafe_is_a_no_op_for_tools_without_one(
2354
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2355
+ ) -> None:
2356
+ """A tool with no unsafe mode still fixes rather than being skipped.
2357
+
2358
+ Args:
2359
+ mock_run: The mocked subprocess.run function.
2360
+ mock_which: The mocked shutil.which function.
2361
+ tmp_path: The temporary directory fixture.
2362
+ """
2363
+ mock_which.side_effect = lambda name: name if name == "gofmt" else None
2364
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
2365
+
2366
+ runner = CliRunner()
2367
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2368
+ Path("main.go").touch()
2369
+
2370
+ result = runner.invoke(cli, ["check", "--fix", "--unsafe", "main.go"])
2371
+
2372
+ assert result.exit_code == 0
2373
+ assert [invocation.args[0] for invocation in mock_run.call_args_list] == [
2374
+ ["gofmt", "-w", "main.go"]
2375
+ ]
2376
+
2377
+
2378
+ @pytest.mark.parametrize("extension", (".sh", ".bash"))
2379
+ def test_tool_definitions_for_shell(extension: str, tmp_path: Path) -> None:
2380
+ """Shell files yield shfmt and shellcheck plans with style arguments."""
2381
+ # Create the target shell file
2382
+ script = tmp_path / f"script{extension}"
2383
+ script.touch()
2384
+
2385
+ # Resolve tool plans for the file
2386
+ tools = {
2387
+ tool.name: tool for tool in _get_tool_definitions(script, tmp_path)
2388
+ }
2389
+
2390
+ # Verify shfmt plan
2391
+ shfmt = tools["shfmt"]
2392
+ assert shfmt.extensions == (".sh", ".bash")
2393
+ assert shfmt.check_format == ("shfmt", "-i", "2", "-ci", "-d", str(script))
2394
+ assert shfmt.format == ("shfmt", "-i", "2", "-ci", "-w", str(script))
2395
+ assert shfmt.check == ()
2396
+ assert shfmt.fix == ()
2397
+
2398
+ # Verify shellcheck plan
2399
+ shellcheck = tools["shellcheck"]
2400
+ assert shellcheck.extensions == (".sh", ".bash")
2401
+ assert shellcheck.check == ("shellcheck", str(script))
2402
+ assert shellcheck.check_format == ()
2403
+ assert shellcheck.format == ()
2404
+ assert shellcheck.fix == ()
2405
+
2406
+
2407
+ def test_tool_definitions_for_rust(tmp_path: Path) -> None:
2408
+ """Rust files yield rustfmt plans with check and in-place formatting."""
2409
+ # Create the target Rust file
2410
+ source = tmp_path / "main.rs"
2411
+ source.touch()
2412
+
2413
+ # Resolve tool plans for the file
2414
+ tools = {
2415
+ tool.name: tool for tool in _get_tool_definitions(source, tmp_path)
2416
+ }
2417
+
2418
+ # Verify rustfmt plan
2419
+ rustfmt = tools["rustfmt"]
2420
+ assert rustfmt.extensions == (".rs",)
2421
+ assert rustfmt.check_format == ("rustfmt", "--check", str(source))
2422
+ assert rustfmt.format == ("rustfmt", str(source))
2423
+ assert rustfmt.check == ()
2424
+ assert rustfmt.fix == ()
2425
+
2426
+
2427
+ @pytest.mark.parametrize("extension", (".sh", ".bash"))
2428
+ @pytest.mark.parametrize("fix", (False, True))
2429
+ @patch("shutil.which")
2430
+ @patch("subprocess.run")
2431
+ def test_shell_uses_shfmt_and_shellcheck_for_check_and_fix(
2432
+ mock_run: MagicMock,
2433
+ mock_which: MagicMock,
2434
+ tmp_path: Path,
2435
+ extension: str,
2436
+ fix: bool,
2437
+ ) -> None:
2438
+ """Shell files invoke shfmt and shellcheck during check and fix."""
2439
+ # Allow shfmt and shellcheck to appear installed
2440
+ mock_which.side_effect = lambda name: (
2441
+ name if name in ("shfmt", "shellcheck") else None
2442
+ )
2443
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
2444
+
2445
+ # Run check command in an isolated environment
2446
+ runner = CliRunner()
2447
+ filename = f"script{extension}"
2448
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2449
+ Path(filename).touch()
2450
+ arguments = ["check", filename]
2451
+ if fix:
2452
+ arguments.append("--fix")
2453
+ result = runner.invoke(cli, arguments)
2454
+
2455
+ # Verify clean exit and expected tool invocations
2456
+ assert result.exit_code == 0
2457
+ shfmt_action = "-w" if fix else "-d"
2458
+ expected = [
2459
+ ["shfmt", "-i", "2", "-ci", shfmt_action, filename],
2460
+ ["shellcheck", filename],
2461
+ ]
2462
+ assert [
2463
+ invocation.args[0] for invocation in mock_run.call_args_list
2464
+ ] == expected
2465
+
2466
+
2467
+ @pytest.mark.parametrize("fix", (False, True))
2468
+ @patch("shutil.which")
2469
+ @patch("subprocess.run")
2470
+ def test_rust_uses_rustfmt_for_check_and_fix(
2471
+ mock_run: MagicMock,
2472
+ mock_which: MagicMock,
2473
+ tmp_path: Path,
2474
+ fix: bool,
2475
+ ) -> None:
2476
+ """Rust files invoke rustfmt with check flag or in-place format."""
2477
+ # Allow rustfmt to appear installed
2478
+ mock_which.side_effect = lambda name: name if name == "rustfmt" else None
2479
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
2480
+
2481
+ # Run check command in an isolated environment
2482
+ runner = CliRunner()
2483
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2484
+ Path("main.rs").touch()
2485
+ arguments = ["check", "main.rs"]
2486
+ if fix:
2487
+ arguments.append("--fix")
2488
+ result = runner.invoke(cli, arguments)
2489
+
2490
+ # Verify clean exit and expected tool invocations
2491
+ assert result.exit_code == 0
2492
+ expected = (
2493
+ [["rustfmt", "main.rs"]] if fix else [["rustfmt", "--check", "main.rs"]]
2494
+ )
2495
+ assert [
2496
+ invocation.args[0] for invocation in mock_run.call_args_list
2497
+ ] == expected
2498
+
2499
+
2500
+ @patch("shutil.which")
2501
+ @patch("subprocess.run")
2502
+ def test_shell_tools_scope_a_directory_to_shell_files(
2503
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2504
+ ) -> None:
2505
+ """Shell tools only receive shell files under checked directories."""
2506
+ # Allow shfmt and shellcheck to appear installed
2507
+ mock_which.side_effect = lambda name: (
2508
+ name if name in ("shfmt", "shellcheck") else None
2509
+ )
2510
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
2511
+
2512
+ # Populate directory with shell and non-shell files
2513
+ runner = CliRunner()
2514
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2515
+ Path("src/nested").mkdir(parents=True)
2516
+ Path("src/script.sh").touch()
2517
+ Path("src/nested/setup.bash").touch()
2518
+ Path("src/notes.txt").touch()
2519
+ result = runner.invoke(cli, ["check", "src"])
2520
+
2521
+ # Verify tool calls are scoped to matching files
2522
+ assert result.exit_code == 0
2523
+ expected_files = ["src/nested/setup.bash", "src/script.sh"]
2524
+ assert [invocation.args[0] for invocation in mock_run.call_args_list] == [
2525
+ ["shfmt", "-i", "2", "-ci", "-d", *expected_files],
2526
+ ["shellcheck", *expected_files],
2527
+ ]
2528
+
2529
+
2530
+ @patch("shutil.which")
2531
+ @patch("subprocess.run")
2532
+ def test_rustfmt_scopes_a_directory_to_rust_files(
2533
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2534
+ ) -> None:
2535
+ """Rustfmt only receives Rust files under checked directories."""
2536
+ # Allow rustfmt to appear installed
2537
+ mock_which.side_effect = lambda name: name if name == "rustfmt" else None
2538
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
2539
+
2540
+ # Populate directory with Rust and non-Rust files
2541
+ runner = CliRunner()
2542
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2543
+ Path("src/nested").mkdir(parents=True)
2544
+ Path("src/main.rs").touch()
2545
+ Path("src/nested/lib.rs").touch()
2546
+ Path("src/notes.txt").touch()
2547
+ result = runner.invoke(cli, ["check", "src"])
2548
+
2549
+ # Verify tool calls are scoped to matching files
2550
+ assert result.exit_code == 0
2551
+ assert [invocation.args[0] for invocation in mock_run.call_args_list] == [
2552
+ ["rustfmt", "--check", "src/main.rs", "src/nested/lib.rs"]
2553
+ ]
2554
+
2555
+
2556
+ @patch("shutil.which")
2557
+ @patch("subprocess.run")
2558
+ def test_shfmt_formatting_findings_reported(
2559
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2560
+ ) -> None:
2561
+ """Non-zero exit from shfmt diff is reported as a formatting finding."""
2562
+ # Allow shfmt to appear installed
2563
+ mock_which.side_effect = lambda name: (
2564
+ name if name in ("shfmt", "shellcheck") else None
2565
+ )
2566
+ mock_run.side_effect = [
2567
+ MagicMock(
2568
+ returncode=1, stdout="--- script.sh\n+++ script.sh\n", stderr=""
2569
+ ),
2570
+ MagicMock(returncode=0, stdout="", stderr=""),
2571
+ ]
2572
+
2573
+ # Run check command
2574
+ runner = CliRunner()
2575
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2576
+ Path("script.sh").touch()
2577
+ result = runner.invoke(cli, ["check", "script.sh"])
2578
+
2579
+ # Verify exit code and finding output
2580
+ assert result.exit_code == 1
2581
+ assert "shfmt formatting findings" in result.output
2582
+
2583
+
2584
+ @patch("shutil.which")
2585
+ @patch("subprocess.run")
2586
+ def test_shellcheck_findings_reported(
2587
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2588
+ ) -> None:
2589
+ """Non-zero exit from shellcheck is reported as findings."""
2590
+ # Allow shellcheck to appear installed
2591
+ mock_which.side_effect = lambda name: (
2592
+ name if name in ("shfmt", "shellcheck") else None
2593
+ )
2594
+ mock_run.side_effect = [
2595
+ MagicMock(returncode=0, stdout="", stderr=""),
2596
+ MagicMock(
2597
+ returncode=1,
2598
+ stdout="SC2086: Double quote to prevent globbing",
2599
+ stderr="",
2600
+ ),
2601
+ ]
2602
+
2603
+ # Run check command
2604
+ runner = CliRunner()
2605
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2606
+ Path("script.sh").touch()
2607
+ result = runner.invoke(cli, ["check", "script.sh"])
2608
+
2609
+ # Verify exit code and finding output
2610
+ assert result.exit_code == 1
2611
+ assert "shellcheck findings" in result.output
2612
+
2613
+
2614
+ @patch("shutil.which")
2615
+ @patch("subprocess.run")
2616
+ def test_rustfmt_formatting_findings_reported(
2617
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2618
+ ) -> None:
2619
+ """Non-zero exit from rustfmt check is reported as a formatting finding."""
2620
+ # Allow rustfmt to appear installed
2621
+ mock_which.side_effect = lambda name: name if name == "rustfmt" else None
2622
+ mock_run.return_value = MagicMock(
2623
+ returncode=1, stdout="Diff in main.rs", stderr=""
2624
+ )
2625
+
2626
+ # Run check command
2627
+ runner = CliRunner()
2628
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2629
+ Path("main.rs").touch()
2630
+ result = runner.invoke(cli, ["check", "main.rs"])
2631
+
2632
+ # Verify exit code and finding output
2633
+ assert result.exit_code == 1
2634
+ assert "rustfmt formatting findings" in result.output
2635
+
2636
+
2637
+ @patch("shutil.which", return_value=None)
2638
+ def test_missing_shell_tools_reported(
2639
+ mock_which: MagicMock, tmp_path: Path
2640
+ ) -> None:
2641
+ """Missing shell tools leave shell files unverified."""
2642
+ # Run check command with missing tools
2643
+ runner = CliRunner()
2644
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2645
+ Path("script.sh").touch()
2646
+ result = runner.invoke(cli, ["check", "script.sh"])
2647
+
2648
+ # Verify error message and exit code
2649
+ assert result.exit_code == 1
2650
+ assert "shfmt" in result.stderr
2651
+ assert "shellcheck" in result.stderr
2652
+ assert "nothing was verified" in result.stderr
2653
+
2654
+
2655
+ @patch("shutil.which", return_value=None)
2656
+ def test_missing_rustfmt_reported(
2657
+ mock_which: MagicMock, tmp_path: Path
2658
+ ) -> None:
2659
+ """Missing rustfmt leaves Rust files unverified."""
2660
+ # Run check command with missing rustfmt
2661
+ runner = CliRunner()
2662
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2663
+ Path("main.rs").touch()
2664
+ result = runner.invoke(cli, ["check", "main.rs"])
2665
+
2666
+ # Verify error message and exit code
2667
+ assert result.exit_code == 1
2668
+ assert "rustfmt" in result.stderr
2669
+ assert "nothing was verified" in result.stderr
2670
+
2671
+
2672
+ @patch("shutil.which")
2673
+ @patch("subprocess.run")
2674
+ def test_partial_shell_tools_installed_warns_and_runs_installed(
2675
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2676
+ ) -> None:
2677
+ """A lone installed shell tool runs and warns that the other is absent."""
2678
+ # Only shfmt is installed
2679
+ mock_which.side_effect = lambda name: name if name == "shfmt" else None
2680
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
2681
+
2682
+ # Run check command
2683
+ runner = CliRunner()
2684
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2685
+ Path("script.sh").touch()
2686
+ result = runner.invoke(cli, ["check", "script.sh"])
2687
+
2688
+ # Verify shfmt ran, shellcheck was skipped with warning, exit code 0
2689
+ assert result.exit_code == 0
2690
+ assert "shellcheck" in result.stderr
2691
+ assert [invocation.args[0] for invocation in mock_run.call_args_list] == [
2692
+ ["shfmt", "-i", "2", "-ci", "-d", "script.sh"]
2693
+ ]
@@ -523,7 +523,7 @@ def _shipped_guides() -> list[tuple[str, str]]:
523
523
  if filename in seen or not os.path.exists(path):
524
524
  continue
525
525
  seen.add(filename)
526
- with open(path, "r", encoding="utf-8") as f:
526
+ with open(path, encoding="utf-8") as f:
527
527
  guides.append((language, f.read()))
528
528
  return guides
529
529
 
@@ -194,7 +194,7 @@ wheels = [
194
194
 
195
195
  [[package]]
196
196
  name = "readability-cli"
197
- version = "0.10.1"
197
+ version = "0.10.3"
198
198
  source = { editable = "." }
199
199
  dependencies = [
200
200
  { name = "beautifulsoup4" },