readability-cli 0.10.2__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.2 → readability_cli-0.10.3}/PKG-INFO +17 -13
  2. {readability_cli-0.10.2 → readability_cli-0.10.3}/README.md +16 -12
  3. {readability_cli-0.10.2 → readability_cli-0.10.3}/pyproject.toml +2 -2
  4. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/checking.py +3 -2
  5. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/configs/ruff.toml +3 -2
  6. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/guide.py +2 -2
  7. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/tools.py +61 -2
  8. {readability_cli-0.10.2 → readability_cli-0.10.3}/test_checking.py +496 -0
  9. {readability_cli-0.10.2 → readability_cli-0.10.3}/test_outline.py +1 -1
  10. {readability_cli-0.10.2 → readability_cli-0.10.3}/uv.lock +1 -1
  11. {readability_cli-0.10.2 → readability_cli-0.10.3}/.github/workflows/ci.yml +0 -0
  12. {readability_cli-0.10.2 → readability_cli-0.10.3}/.github/workflows/publish.yml +0 -0
  13. {readability_cli-0.10.2 → readability_cli-0.10.3}/.github/workflows/update-guides.yml +0 -0
  14. {readability_cli-0.10.2 → readability_cli-0.10.3}/.gitignore +0 -0
  15. {readability_cli-0.10.2 → readability_cli-0.10.3}/.python-version +0 -0
  16. {readability_cli-0.10.2 → readability_cli-0.10.3}/LICENSE +0 -0
  17. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/__init__.py +0 -0
  18. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/cli.py +0 -0
  19. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/configs/biome-default.json +0 -0
  20. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/configs/pyrefly.toml +0 -0
  21. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/guides/Rguide.md +0 -0
  22. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/guides/cppguide.md +0 -0
  23. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/guides/csharp-style.md +0 -0
  24. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/guides/docguide-style.md +0 -0
  25. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/guides/go-guide.md +0 -0
  26. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/guides/htmlcssguide.md +0 -0
  27. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/guides/javaguide.md +0 -0
  28. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/guides/jsguide.md +0 -0
  29. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/guides/jsoncstyleguide.md +0 -0
  30. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/guides/objcguide.md +0 -0
  31. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/guides/pyguide.md +0 -0
  32. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/guides/shellguide.md +0 -0
  33. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/guides/tsguide.md +0 -0
  34. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/guides/vimscriptguide.md +0 -0
  35. {readability_cli-0.10.2 → readability_cli-0.10.3}/readability/outline.py +0 -0
  36. {readability_cli-0.10.2 → readability_cli-0.10.3}/test_guide.py +0 -0
  37. {readability_cli-0.10.2 → 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.2
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
@@ -98,7 +99,7 @@ readability check . --unsafe
98
99
  Fixes that may change a program's behavior or drop its comments are opt-in
99
100
  through `--unsafe`, which every tool applies under its own name: Ruff's
100
101
  `--unsafe-fixes` and Biome's `--unsafe`. Tools drawing no such distinction,
101
- such as gofmt, are unaffected by it.
102
+ such as gofmt and goimports, are unaffected by it.
102
103
 
103
104
  ### Supported Formats
104
105
 
@@ -106,7 +107,9 @@ such as gofmt, are unaffected by it.
106
107
  | ---------------------------------------------------------------- | --------------------------------------- | ------------------------------------- |
107
108
  | `.py` | Ruff lint/format; Pyrefly type checking | Ruff and Pyrefly native configuration |
108
109
  | `.js`, `.jsx`, `.ts`, `.tsx`, `.json`, `.jsonc`, `.css`, `.html` | Biome | `biome.json` or `biome.jsonc` |
109
- | `.go` | gofmt | None |
110
+ | `.go` | gofmt, goimports | None |
111
+ | `.sh`, `.bash` | shfmt format; shellcheck lint | None |
112
+ | `.rs` | rustfmt | None |
110
113
 
111
114
  Markdown, YAML, SCSS, JSONL, and extensions not listed above are unsupported.
112
115
  An unsupported-only path reports that nothing was checked and exits
@@ -114,8 +117,8 @@ successfully.
114
117
 
115
118
  Biome first checks a project's `node_modules/.bin`. Ruff, Pyrefly, and Biome
116
119
  then use `PATH`, followed by `uvx` for the Python tools or `npx` for Biome. The
117
- runners cache downloads, so subsequent runs work offline. Gofmt ships with Go
118
- and must be available on `PATH`.
120
+ runners cache downloads, so subsequent runs work offline. Gofmt and goimports
121
+ must be available on `PATH`.
119
122
 
120
123
  That keeps this package at ~4 MB rather than the ~54 MB it would take to carry
121
124
  Ruff and Pyrefly itself — a cost that would fall on everyone using only `guide`.
@@ -133,8 +136,8 @@ Set `UV_OFFLINE=1` to forbid fetching. A tool that then cannot be reached fails
133
136
  the run rather than passing it.
134
137
 
135
138
  Ruff, Pyrefly, and Biome ship with bundled configurations, so they run on every
136
- file they own without project setup. Gofmt runs on `.go` files even when there
137
- 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`.
138
141
 
139
142
  A tool that could not be reached at all is never skipped quietly:
140
143
 
@@ -189,8 +192,9 @@ caller happened to be standing in.
189
192
  Ruff and Pyrefly defaults follow the
190
193
  [Google Python style guide](https://google.github.io/styleguide/pyguide.html):
191
194
  80-column lines, Google docstrings, one import per line sorted as isort's
192
- `google` profile does, and full type checking. The Biome default applies the
193
- 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
194
198
  [Google JavaScript style guide](https://google.github.io/styleguide/jsguide.html)
195
199
  and enables Biome's recommended lint rules. Project `biome.json` and
196
200
  `biome.jsonc` files replace those bundled defaults for Biome-owned formats.
@@ -199,9 +203,9 @@ For Python, add `[tool.ruff]` or `[tool.pyrefly]` to `pyproject.toml`, or use
199
203
  `ruff.toml`, `.ruff.toml`, or `pyrefly.toml`. Ruff, Pyrefly, and Biome load
200
204
  their native project configurations in place of bundled defaults. Readability
201
205
  does not interpret EditorConfig itself; a canonical tool such as Biome may opt
202
- into it through that tool's native configuration. Gofmt has no project settings.
203
- The bundled Biome file requires Biome 2.5 or later, matching the fallback
204
- 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.
205
209
 
206
210
  Configuration is found per file, in the file's own directory and then its
207
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
@@ -84,7 +85,7 @@ readability check . --unsafe
84
85
  Fixes that may change a program's behavior or drop its comments are opt-in
85
86
  through `--unsafe`, which every tool applies under its own name: Ruff's
86
87
  `--unsafe-fixes` and Biome's `--unsafe`. Tools drawing no such distinction,
87
- such as gofmt, are unaffected by it.
88
+ such as gofmt and goimports, are unaffected by it.
88
89
 
89
90
  ### Supported Formats
90
91
 
@@ -92,7 +93,9 @@ such as gofmt, are unaffected by it.
92
93
  | ---------------------------------------------------------------- | --------------------------------------- | ------------------------------------- |
93
94
  | `.py` | Ruff lint/format; Pyrefly type checking | Ruff and Pyrefly native configuration |
94
95
  | `.js`, `.jsx`, `.ts`, `.tsx`, `.json`, `.jsonc`, `.css`, `.html` | Biome | `biome.json` or `biome.jsonc` |
95
- | `.go` | gofmt | None |
96
+ | `.go` | gofmt, goimports | None |
97
+ | `.sh`, `.bash` | shfmt format; shellcheck lint | None |
98
+ | `.rs` | rustfmt | None |
96
99
 
97
100
  Markdown, YAML, SCSS, JSONL, and extensions not listed above are unsupported.
98
101
  An unsupported-only path reports that nothing was checked and exits
@@ -100,8 +103,8 @@ successfully.
100
103
 
101
104
  Biome first checks a project's `node_modules/.bin`. Ruff, Pyrefly, and Biome
102
105
  then use `PATH`, followed by `uvx` for the Python tools or `npx` for Biome. The
103
- runners cache downloads, so subsequent runs work offline. Gofmt ships with Go
104
- and must be available on `PATH`.
106
+ runners cache downloads, so subsequent runs work offline. Gofmt and goimports
107
+ must be available on `PATH`.
105
108
 
106
109
  That keeps this package at ~4 MB rather than the ~54 MB it would take to carry
107
110
  Ruff and Pyrefly itself — a cost that would fall on everyone using only `guide`.
@@ -119,8 +122,8 @@ Set `UV_OFFLINE=1` to forbid fetching. A tool that then cannot be reached fails
119
122
  the run rather than passing it.
120
123
 
121
124
  Ruff, Pyrefly, and Biome ship with bundled configurations, so they run on every
122
- file they own without project setup. Gofmt runs on `.go` files even when there
123
- 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`.
124
127
 
125
128
  A tool that could not be reached at all is never skipped quietly:
126
129
 
@@ -175,8 +178,9 @@ caller happened to be standing in.
175
178
  Ruff and Pyrefly defaults follow the
176
179
  [Google Python style guide](https://google.github.io/styleguide/pyguide.html):
177
180
  80-column lines, Google docstrings, one import per line sorted as isort's
178
- `google` profile does, and full type checking. The Biome default applies the
179
- 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
180
184
  [Google JavaScript style guide](https://google.github.io/styleguide/jsguide.html)
181
185
  and enables Biome's recommended lint rules. Project `biome.json` and
182
186
  `biome.jsonc` files replace those bundled defaults for Biome-owned formats.
@@ -185,9 +189,9 @@ For Python, add `[tool.ruff]` or `[tool.pyrefly]` to `pyproject.toml`, or use
185
189
  `ruff.toml`, `.ruff.toml`, or `pyrefly.toml`. Ruff, Pyrefly, and Biome load
186
190
  their native project configurations in place of bundled defaults. Readability
187
191
  does not interpret EditorConfig itself; a canonical tool such as Biome may opt
188
- into it through that tool's native configuration. Gofmt has no project settings.
189
- The bundled Biome file requires Biome 2.5 or later, matching the fallback
190
- 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.
191
195
 
192
196
  Configuration is found per file, in the file's own directory and then its
193
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.2"
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]
@@ -204,9 +204,10 @@ def _run_tool(
204
204
  result = _capture_tool_command(command, cwd=tool.cwd)
205
205
  if _tool_checked_files(tool.name, result):
206
206
  report.ran.add(tool.name)
207
- # gofmt reports by naming files rather than by exit code
207
+ # gofmt and goimports name files rather than using exit codes
208
208
  if result.returncode != 0 or (
209
- tool.name == "gofmt" and result.stdout.strip()
209
+ tool.name in ("gofmt", "goimports")
210
+ and result.stdout.strip()
210
211
  ):
211
212
  report.findings = True
212
213
  click.echo(
@@ -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)
@@ -347,6 +347,10 @@ TOOL_EXTENSIONS = {
347
347
  ".html",
348
348
  ),
349
349
  "gofmt": (".go",),
350
+ "goimports": (".go",),
351
+ "shfmt": (".sh", ".bash"),
352
+ "shellcheck": (".sh", ".bash"),
353
+ "rustfmt": (".rs",),
350
354
  }
351
355
 
352
356
  # Headroom below common argv limits; long target lists are split before exec
@@ -354,8 +358,20 @@ MAX_COMMAND_BYTES = 16 * 1024
354
358
 
355
359
  # Named files bypass a tool's own exclusions, and Pyrefly ignores its excludes
356
360
  PRUNED_DIRECTORIES = frozenset(
357
- ".git .hg .svn .venv venv .tox .nox node_modules "
358
- "__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
+ ]
359
375
  )
360
376
 
361
377
  # Per tool: config filenames, pyproject section (None if it has none), flag
@@ -392,6 +408,13 @@ TOOL_PHASES = {
392
408
  "format": ("format", "--write", "--no-errors-on-unmatched"),
393
409
  },
394
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": ()},
395
418
  }
396
419
 
397
420
  # Per tool: how it names fixes that may change behavior or drop comments
@@ -549,6 +572,42 @@ def _get_tool_definitions(
549
572
  path,
550
573
  )
551
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
+ )
552
611
 
553
612
  # A directory owning its config alone lets Pyrefly select the files
554
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,
@@ -1608,6 +1621,170 @@ def test_gofmt_fix_failure_is_reported(
1608
1621
  assert "gofmt formatting findings" in result.output
1609
1622
 
1610
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
+
1611
1788
  @patch("shutil.which")
1612
1789
  @patch("subprocess.run")
1613
1790
  def test_large_biome_directory_uses_bounded_commands(
@@ -1653,6 +1830,7 @@ def test_missing_gofmt_is_reported_for_configless_go(
1653
1830
 
1654
1831
  assert result.exit_code == 1
1655
1832
  assert "gofmt" in result.stderr
1833
+ assert "goimports" in result.stderr
1656
1834
  assert "nothing was verified" in result.stderr
1657
1835
 
1658
1836
 
@@ -2195,3 +2373,321 @@ def test_unsafe_is_a_no_op_for_tools_without_one(
2195
2373
  assert [invocation.args[0] for invocation in mock_run.call_args_list] == [
2196
2374
  ["gofmt", "-w", "main.go"]
2197
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.2"
197
+ version = "0.10.3"
198
198
  source = { editable = "." }
199
199
  dependencies = [
200
200
  { name = "beautifulsoup4" },