readability-cli 0.8.2__tar.gz → 0.8.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 (29) hide show
  1. {readability_cli-0.8.2 → readability_cli-0.8.3}/PKG-INFO +25 -17
  2. {readability_cli-0.8.2 → readability_cli-0.8.3}/README.md +24 -16
  3. readability_cli-0.8.3/configs/biome-default.json +14 -0
  4. {readability_cli-0.8.2 → readability_cli-0.8.3}/pyproject.toml +1 -1
  5. {readability_cli-0.8.2 → readability_cli-0.8.3}/readability.py +95 -37
  6. {readability_cli-0.8.2 → readability_cli-0.8.3}/test_readability.py +254 -7
  7. {readability_cli-0.8.2 → readability_cli-0.8.3}/uv.lock +1 -1
  8. {readability_cli-0.8.2 → readability_cli-0.8.3}/.github/workflows/ci.yml +0 -0
  9. {readability_cli-0.8.2 → readability_cli-0.8.3}/.github/workflows/publish.yml +0 -0
  10. {readability_cli-0.8.2 → readability_cli-0.8.3}/.github/workflows/update-guides.yml +0 -0
  11. {readability_cli-0.8.2 → readability_cli-0.8.3}/.gitignore +0 -0
  12. {readability_cli-0.8.2 → readability_cli-0.8.3}/.python-version +0 -0
  13. {readability_cli-0.8.2 → readability_cli-0.8.3}/LICENSE +0 -0
  14. {readability_cli-0.8.2 → readability_cli-0.8.3}/configs/pyrefly.toml +0 -0
  15. {readability_cli-0.8.2 → readability_cli-0.8.3}/configs/ruff.toml +0 -0
  16. {readability_cli-0.8.2 → readability_cli-0.8.3}/guides/Rguide.md +0 -0
  17. {readability_cli-0.8.2 → readability_cli-0.8.3}/guides/cppguide.md +0 -0
  18. {readability_cli-0.8.2 → readability_cli-0.8.3}/guides/csharp-style.md +0 -0
  19. {readability_cli-0.8.2 → readability_cli-0.8.3}/guides/docguide-style.md +0 -0
  20. {readability_cli-0.8.2 → readability_cli-0.8.3}/guides/go-guide.md +0 -0
  21. {readability_cli-0.8.2 → readability_cli-0.8.3}/guides/htmlcssguide.md +0 -0
  22. {readability_cli-0.8.2 → readability_cli-0.8.3}/guides/javaguide.md +0 -0
  23. {readability_cli-0.8.2 → readability_cli-0.8.3}/guides/jsguide.md +0 -0
  24. {readability_cli-0.8.2 → readability_cli-0.8.3}/guides/jsoncstyleguide.md +0 -0
  25. {readability_cli-0.8.2 → readability_cli-0.8.3}/guides/objcguide.md +0 -0
  26. {readability_cli-0.8.2 → readability_cli-0.8.3}/guides/pyguide.md +0 -0
  27. {readability_cli-0.8.2 → readability_cli-0.8.3}/guides/shellguide.md +0 -0
  28. {readability_cli-0.8.2 → readability_cli-0.8.3}/guides/tsguide.md +0 -0
  29. {readability_cli-0.8.2 → readability_cli-0.8.3}/guides/vimscriptguide.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: readability-cli
3
- Version: 0.8.2
3
+ Version: 0.8.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
@@ -24,8 +24,9 @@ and quick access to style conventions without browsing HTML pages.
24
24
 
25
25
  - **Linting & Formatting**: A `check` command that automatically detects and
26
26
  runs relevant tools (Ruff, Pyrefly, Biome, Prettier, gofmt) for your project.
27
- - **Sensible Defaults**: Bundled Google-style configurations for Ruff and
28
- Pyrefly are used automatically when a project does not define its own.
27
+ - **Sensible Defaults**: Bundled Google-style configurations for Ruff,
28
+ Pyrefly, and Biome are used automatically when a project does not define its
29
+ own.
29
30
  - **Style Guides**: A `guide` command that fetches the latest Google style
30
31
  guides (Python, Shell, C++, Java, JS/TS, Go, etc.) converted to Markdown,
31
32
  and outlines, addresses, and searches them by section rather than serving
@@ -98,7 +99,7 @@ readability check . --fix
98
99
  |------|----------------------|---------------|------------|
99
100
  | **Ruff** | `.py` | none, bundled | `uvx` |
100
101
  | **Pyrefly** | `.py` | none, bundled | `uvx` |
101
- | **Biome** | `.js`, `.ts`, `.jsx`, `.tsx`, `.json`, `.jsonc`, `.css`, `.html` | `biome.json` or `biome.jsonc` | `npx` |
102
+ | **Biome** | `.js`, `.ts`, `.jsx`, `.tsx`, `.json`, `.jsonc`, `.css`, `.html` | none, bundled | `npx` |
102
103
  | **Prettier** | `.js`, `.ts`, `.jsx`, `.tsx`, `.json`, `.css`, `.scss`, `.html`, `.md`, `.yml`, `.yaml` | `.prettierrc*` or `prettier.config.*` | `npx` |
103
104
  | **gofmt** | `.go` | `go.mod` | — |
104
105
 
@@ -123,10 +124,9 @@ always wins over a fetched one.
123
124
  Set `UV_OFFLINE=1` to forbid fetching. A tool that then cannot be reached
124
125
  fails the run rather than passing it.
125
126
 
126
- Ruff and Pyrefly ship with bundled configurations, so they run on any file
127
- they handle without the project arranging anything. Biome, Prettier and gofmt
128
- bring no defaults here, so they wait until the project asks with a config
129
- file.
127
+ Ruff, Pyrefly, and Biome ship with bundled configurations, so they run on any
128
+ file they handle without the project arranging anything. Prettier and gofmt
129
+ bring no defaults here, so they wait until the project asks with a config file.
130
130
 
131
131
  A tool that could not be reached at all is never skipped quietly:
132
132
 
@@ -144,8 +144,10 @@ Error: Every tool for 1 path(s) is missing, so nothing was verified.
144
144
  That second case needs neither the tool nor a runner to be present, which is
145
145
  the state a container image is usually in.
146
146
 
147
- `check` exits non-zero on findings and on having verified nothing, so it can
148
- gate CI without a clean exit ever meaning "the tools were absent".
147
+ `check` exits non-zero on findings, tool failures, and when every applicable
148
+ tool is absent, so it can gate CI without a clean exit meaning required tools
149
+ were missing. If no tool applies to the requested paths, it reports that fact
150
+ and exits successfully.
149
151
 
150
152
  ### Python API
151
153
 
@@ -171,13 +173,19 @@ Relative paths remain relative to the process working directory;
171
173
 
172
174
  ### Default Configurations
173
175
 
174
- For Ruff and Pyrefly, bundled defaults based on the
175
- [Google Python style guide](https://google.github.io/styleguide/pyguide.html)
176
- (80-column lines, Google docstring convention, import ordering, full type
177
- checking) are applied when the project does not define its own configuration.
178
- To override them, add a `[tool.ruff]` or `[tool.pyrefly]` section to your
179
- `pyproject.toml` (or a dedicated `ruff.toml` / `pyrefly.toml`) any
180
- project-level configuration takes full precedence over the bundled defaults.
176
+ Ruff and Pyrefly defaults follow the
177
+ [Google Python style guide](https://google.github.io/styleguide/pyguide.html):
178
+ 80-column lines, Google docstrings, import ordering, and full type checking.
179
+ The Biome default applies the 80-column lines and two-space indentation of the
180
+ [Google JavaScript style guide](https://google.github.io/styleguide/jsguide.html)
181
+ and enables Biome's recommended lint rules. These defaults apply only when the
182
+ project does not define its own configuration. To override them, add a
183
+ `[tool.ruff]` or `[tool.pyrefly]` section to your `pyproject.toml`, or a
184
+ dedicated `ruff.toml`, `pyrefly.toml`, `biome.json`, or `biome.jsonc` — any
185
+ project-level configuration takes full precedence. The bundled Biome file
186
+ intentionally uses only settings supported by both Biome 1.x and 2.x, so a
187
+ project-local installation remains authoritative without creating a schema
188
+ mismatch.
181
189
 
182
190
  ## Style Guides
183
191
 
@@ -10,8 +10,9 @@ and quick access to style conventions without browsing HTML pages.
10
10
 
11
11
  - **Linting & Formatting**: A `check` command that automatically detects and
12
12
  runs relevant tools (Ruff, Pyrefly, Biome, Prettier, gofmt) for your project.
13
- - **Sensible Defaults**: Bundled Google-style configurations for Ruff and
14
- Pyrefly are used automatically when a project does not define its own.
13
+ - **Sensible Defaults**: Bundled Google-style configurations for Ruff,
14
+ Pyrefly, and Biome are used automatically when a project does not define its
15
+ own.
15
16
  - **Style Guides**: A `guide` command that fetches the latest Google style
16
17
  guides (Python, Shell, C++, Java, JS/TS, Go, etc.) converted to Markdown,
17
18
  and outlines, addresses, and searches them by section rather than serving
@@ -84,7 +85,7 @@ readability check . --fix
84
85
  |------|----------------------|---------------|------------|
85
86
  | **Ruff** | `.py` | none, bundled | `uvx` |
86
87
  | **Pyrefly** | `.py` | none, bundled | `uvx` |
87
- | **Biome** | `.js`, `.ts`, `.jsx`, `.tsx`, `.json`, `.jsonc`, `.css`, `.html` | `biome.json` or `biome.jsonc` | `npx` |
88
+ | **Biome** | `.js`, `.ts`, `.jsx`, `.tsx`, `.json`, `.jsonc`, `.css`, `.html` | none, bundled | `npx` |
88
89
  | **Prettier** | `.js`, `.ts`, `.jsx`, `.tsx`, `.json`, `.css`, `.scss`, `.html`, `.md`, `.yml`, `.yaml` | `.prettierrc*` or `prettier.config.*` | `npx` |
89
90
  | **gofmt** | `.go` | `go.mod` | — |
90
91
 
@@ -109,10 +110,9 @@ always wins over a fetched one.
109
110
  Set `UV_OFFLINE=1` to forbid fetching. A tool that then cannot be reached
110
111
  fails the run rather than passing it.
111
112
 
112
- Ruff and Pyrefly ship with bundled configurations, so they run on any file
113
- they handle without the project arranging anything. Biome, Prettier and gofmt
114
- bring no defaults here, so they wait until the project asks with a config
115
- file.
113
+ Ruff, Pyrefly, and Biome ship with bundled configurations, so they run on any
114
+ file they handle without the project arranging anything. Prettier and gofmt
115
+ bring no defaults here, so they wait until the project asks with a config file.
116
116
 
117
117
  A tool that could not be reached at all is never skipped quietly:
118
118
 
@@ -130,8 +130,10 @@ Error: Every tool for 1 path(s) is missing, so nothing was verified.
130
130
  That second case needs neither the tool nor a runner to be present, which is
131
131
  the state a container image is usually in.
132
132
 
133
- `check` exits non-zero on findings and on having verified nothing, so it can
134
- gate CI without a clean exit ever meaning "the tools were absent".
133
+ `check` exits non-zero on findings, tool failures, and when every applicable
134
+ tool is absent, so it can gate CI without a clean exit meaning required tools
135
+ were missing. If no tool applies to the requested paths, it reports that fact
136
+ and exits successfully.
135
137
 
136
138
  ### Python API
137
139
 
@@ -157,13 +159,19 @@ Relative paths remain relative to the process working directory;
157
159
 
158
160
  ### Default Configurations
159
161
 
160
- For Ruff and Pyrefly, bundled defaults based on the
161
- [Google Python style guide](https://google.github.io/styleguide/pyguide.html)
162
- (80-column lines, Google docstring convention, import ordering, full type
163
- checking) are applied when the project does not define its own configuration.
164
- To override them, add a `[tool.ruff]` or `[tool.pyrefly]` section to your
165
- `pyproject.toml` (or a dedicated `ruff.toml` / `pyrefly.toml`) any
166
- project-level configuration takes full precedence over the bundled defaults.
162
+ Ruff and Pyrefly defaults follow the
163
+ [Google Python style guide](https://google.github.io/styleguide/pyguide.html):
164
+ 80-column lines, Google docstrings, import ordering, and full type checking.
165
+ The Biome default applies the 80-column lines and two-space indentation of the
166
+ [Google JavaScript style guide](https://google.github.io/styleguide/jsguide.html)
167
+ and enables Biome's recommended lint rules. These defaults apply only when the
168
+ project does not define its own configuration. To override them, add a
169
+ `[tool.ruff]` or `[tool.pyrefly]` section to your `pyproject.toml`, or a
170
+ dedicated `ruff.toml`, `pyrefly.toml`, `biome.json`, or `biome.jsonc` — any
171
+ project-level configuration takes full precedence. The bundled Biome file
172
+ intentionally uses only settings supported by both Biome 1.x and 2.x, so a
173
+ project-local installation remains authoritative without creating a schema
174
+ mismatch.
167
175
 
168
176
  ## Style Guides
169
177
 
@@ -0,0 +1,14 @@
1
+ {
2
+ "formatter": {
3
+ "enabled": true,
4
+ "indentStyle": "space",
5
+ "indentWidth": 2,
6
+ "lineWidth": 80
7
+ },
8
+ "linter": {
9
+ "enabled": true,
10
+ "rules": {
11
+ "recommended": true
12
+ }
13
+ }
14
+ }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "readability-cli"
3
- version = "0.8.2"
3
+ version = "0.8.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"
@@ -1116,6 +1116,11 @@ def check(paths: Sequence[str], fix: bool, verbose: bool) -> None:
1116
1116
  )
1117
1117
  sys.exit(1)
1118
1118
 
1119
+ # Findings remain a failed check even when a tool reports that it could
1120
+ # not process any files (for example, a Biome configuration error).
1121
+ if report.findings:
1122
+ sys.exit(1)
1123
+
1119
1124
  # Having run nothing is not a pass. Reporting it as one is how this
1120
1125
  # command became a silent no-op wherever its tools were absent, gating
1121
1126
  # nothing while every caller read the exit code as approval.
@@ -1137,13 +1142,19 @@ def check(paths: Sequence[str], fix: bool, verbose: bool) -> None:
1137
1142
  )
1138
1143
  return
1139
1144
 
1140
- if report.findings:
1141
- sys.exit(1)
1145
+ checked_path_count = len(paths) - len(report.unverified_paths)
1146
+ if report.unverified_paths:
1147
+ click.echo(
1148
+ f"Warning: nothing was checked for "
1149
+ f"{len(report.unverified_paths)} path(s): "
1150
+ f"{', '.join(map(str, report.unverified_paths))}.",
1151
+ err=True,
1152
+ )
1142
1153
 
1143
1154
  # Findings are the only thing this command printed, so a clean run said
1144
1155
  # nothing at all and left the caller unable to tell it from a no-op.
1145
1156
  click.echo(
1146
- f"No findings in {len(paths)} path(s) "
1157
+ f"No findings in {checked_path_count} path(s) "
1147
1158
  f"({', '.join(sorted(report.ran))}).",
1148
1159
  err=True,
1149
1160
  )
@@ -1155,21 +1166,25 @@ class CheckReport:
1155
1166
 
1156
1167
  Attributes:
1157
1168
  findings: Whether any tool that ran reported something.
1158
- ran: Names of the tools that completed at least one process. This
1159
- records what happened, not what was intended: a tool that
1160
- resolved but never started belongs in failed, or a check that
1161
- verified nothing reports itself as clean.
1169
+ ran: Names of the tools that processed at least one file. This records
1170
+ what happened, not what was intended. A tool that resolved but
1171
+ never started belongs in failed, and a tool that explicitly
1172
+ reported processing no files is not recorded as having run.
1162
1173
  skipped: Names of the tools that were applicable but not installed.
1163
1174
  Tools with no trigger file are in neither set: they were never
1164
1175
  wanted, so passing without them is not a gap in coverage.
1165
1176
  failed: Names of the tools that started and could not finish, by
1166
1177
  failing to exec or by running past the timeout.
1178
+ unverified_paths: Requested paths for which no tool processed a file.
1179
+ Keeping these paths through aggregation prevents a checked path
1180
+ from hiding another path that a tool ignored.
1167
1181
  """
1168
1182
 
1169
1183
  findings: bool = False
1170
1184
  ran: set[str] = dataclasses.field(default_factory=set)
1171
1185
  skipped: set[str] = dataclasses.field(default_factory=set)
1172
1186
  failed: set[str] = dataclasses.field(default_factory=set)
1187
+ unverified_paths: list[Path] = dataclasses.field(default_factory=list)
1173
1188
 
1174
1189
  def absorb(self, other: "CheckReport") -> None:
1175
1190
  """Fold another report into this one.
@@ -1181,6 +1196,7 @@ class CheckReport:
1181
1196
  self.ran |= other.ran
1182
1197
  self.skipped |= other.skipped
1183
1198
  self.failed |= other.failed
1199
+ self.unverified_paths.extend(other.unverified_paths)
1184
1200
 
1185
1201
 
1186
1202
  def check_paths(
@@ -1219,7 +1235,10 @@ def check_paths(
1219
1235
 
1220
1236
  report = CheckReport()
1221
1237
  for path in requested_paths:
1222
- report.absorb(_check_path(path, root, fix=fix))
1238
+ path_report = _check_path(path, root, fix=fix)
1239
+ if not path_report.ran:
1240
+ path_report.unverified_paths.append(path)
1241
+ report.absorb(path_report)
1223
1242
  return report
1224
1243
 
1225
1244
 
@@ -1278,12 +1297,20 @@ def _should_run_tool(
1278
1297
  (project_root / t).exists() for t in tool["trigger"]
1279
1298
  )
1280
1299
 
1281
- # For files, also check if the extension matches one of the supported ones
1300
+ # Files must match one of the tool's supported extensions.
1282
1301
  if path.is_file():
1283
1302
  return has_trigger and path.suffix in tool["extensions"]
1284
1303
 
1285
- # For directories, the existence of a trigger file is sufficient
1286
- return has_trigger
1304
+ if not has_trigger:
1305
+ return False
1306
+
1307
+ # A directory trigger alone is not evidence that the tool checked a file.
1308
+ # Several tools deliberately exit zero when nothing matches, which would
1309
+ # otherwise turn an unsupported-only tree into a reported clean result.
1310
+ return any(
1311
+ candidate.is_file() and candidate.suffix in tool["extensions"]
1312
+ for candidate in path.rglob("*")
1313
+ )
1287
1314
 
1288
1315
 
1289
1316
  def _tool_is_installed(tool_name: str, tool_config: dict[str, Any]) -> bool:
@@ -1319,12 +1346,15 @@ def _bundled_config(tool_name: str) -> Path:
1319
1346
  """Get the path to the bundled default configuration for a tool.
1320
1347
 
1321
1348
  Args:
1322
- tool_name: The name of the tool (e.g. "ruff", "pyrefly").
1349
+ tool_name: The name of the tool (e.g. "ruff", "pyrefly", "biome").
1323
1350
 
1324
1351
  Returns:
1325
1352
  The path to the bundled default config file.
1326
1353
  """
1327
- return Path(__file__).parent / "configs" / f"{tool_name}.toml"
1354
+ filename = (
1355
+ "biome-default.json" if tool_name == "biome" else f"{tool_name}.toml"
1356
+ )
1357
+ return Path(__file__).parent / "configs" / filename
1328
1358
 
1329
1359
 
1330
1360
  def _has_project_config(
@@ -1456,6 +1486,26 @@ def _get_tool_definitions(
1456
1486
  pyrefly_config = _default_config_args(
1457
1487
  project_root, ["pyrefly.toml"], "pyrefly"
1458
1488
  )
1489
+ biome_config = []
1490
+ if not any(
1491
+ (project_root / filename).exists()
1492
+ for filename in ("biome.json", "biome.jsonc")
1493
+ ):
1494
+ biome_config = ["--config-path", str(_bundled_config("biome"))]
1495
+ ignore_files = (
1496
+ project_root / ".gitignore",
1497
+ project_root / ".ignore",
1498
+ project_root / ".git" / "info" / "exclude",
1499
+ )
1500
+ if any(ignore_file.is_file() for ignore_file in ignore_files):
1501
+ biome_config.extend(
1502
+ [
1503
+ "--vcs-enabled=true",
1504
+ "--vcs-client-kind=git",
1505
+ "--vcs-use-ignore-file=true",
1506
+ f"--vcs-root={project_root}",
1507
+ ]
1508
+ )
1459
1509
 
1460
1510
  # Resolved once so every command for a tool reaches the same executable
1461
1511
  ruff = _tool_command("ruff", project_root)
@@ -1511,12 +1561,14 @@ def _get_tool_definitions(
1511
1561
  "check": [
1512
1562
  *biome,
1513
1563
  "lint",
1564
+ *biome_config,
1514
1565
  "--no-errors-on-unmatched",
1515
1566
  path_str,
1516
1567
  ],
1517
1568
  "check_format": [
1518
1569
  *biome,
1519
1570
  "format",
1571
+ *biome_config,
1520
1572
  "--no-errors-on-unmatched",
1521
1573
  path_str,
1522
1574
  ],
@@ -1524,6 +1576,7 @@ def _get_tool_definitions(
1524
1576
  *biome,
1525
1577
  "lint",
1526
1578
  "--write",
1579
+ *biome_config,
1527
1580
  "--no-errors-on-unmatched",
1528
1581
  path_str,
1529
1582
  ],
@@ -1531,6 +1584,7 @@ def _get_tool_definitions(
1531
1584
  *biome,
1532
1585
  "format",
1533
1586
  "--write",
1587
+ *biome_config,
1534
1588
  "--no-errors-on-unmatched",
1535
1589
  path_str,
1536
1590
  ],
@@ -1622,11 +1676,13 @@ def _run_tool(
1622
1676
  # and report what they could not deal with.
1623
1677
  for phase in ("format", "fix"):
1624
1678
  if phase in tool_config:
1625
- _execute_tool_command(tool_config[phase])
1626
- report.ran.add(tool_name)
1679
+ result = _capture_tool_command(tool_config[phase])
1680
+ if _tool_checked_files(tool_name, result):
1681
+ report.ran.add(tool_name)
1627
1682
  elif "check_format" in tool_config:
1628
1683
  result = _capture_tool_command(tool_config["check_format"])
1629
- report.ran.add(tool_name)
1684
+ if _tool_checked_files(tool_name, result):
1685
+ report.ran.add(tool_name)
1630
1686
  # gofmt reports by naming files rather than by exit code
1631
1687
  if result.returncode != 0 or (
1632
1688
  tool_name == "go fmt" and result.stdout.strip()
@@ -1639,7 +1695,8 @@ def _run_tool(
1639
1695
 
1640
1696
  if "check" in tool_config:
1641
1697
  result = _capture_tool_command(tool_config["check"])
1642
- report.ran.add(tool_name)
1698
+ if _tool_checked_files(tool_name, result):
1699
+ report.ran.add(tool_name)
1643
1700
  if result.returncode != 0:
1644
1701
  report.findings = True
1645
1702
  click.echo(
@@ -1656,6 +1713,26 @@ def _run_tool(
1656
1713
  return report
1657
1714
 
1658
1715
 
1716
+ def _tool_checked_files(
1717
+ tool_name: str, result: subprocess.CompletedProcess
1718
+ ) -> bool:
1719
+ """Report whether a completed command actually processed any files.
1720
+
1721
+ Args:
1722
+ tool_name: The tool whose command completed.
1723
+ result: The completed subprocess.
1724
+
1725
+ Returns:
1726
+ False when Biome explicitly reports that it checked zero files.
1727
+ """
1728
+ if tool_name != "biome":
1729
+ return True
1730
+ output = f"{result.stdout or ''}\n{result.stderr or ''}"
1731
+ # Biome 1.x and 2.x use these summaries for an unmatched target.
1732
+ zero_file_summaries = ("Checked 0 files", "Formatted 0 files")
1733
+ return not any(summary in output for summary in zero_file_summaries)
1734
+
1735
+
1659
1736
  def _capture_tool_command(cmd: list[str]) -> subprocess.CompletedProcess:
1660
1737
  """Run a tool and capture what it said, whatever its exit code.
1661
1738
 
@@ -1663,7 +1740,7 @@ def _capture_tool_command(cmd: list[str]) -> subprocess.CompletedProcess:
1663
1740
  cmd: The command list to execute.
1664
1741
 
1665
1742
  Returns:
1666
- The completed process, for the caller to read an exit code off.
1743
+ The completed process, including captured output and its exit code.
1667
1744
 
1668
1745
  Raises:
1669
1746
  subprocess.SubprocessError: If the command outlives the timeout.
@@ -1679,25 +1756,6 @@ def _capture_tool_command(cmd: list[str]) -> subprocess.CompletedProcess:
1679
1756
  )
1680
1757
 
1681
1758
 
1682
- def _execute_tool_command(cmd: list[str]) -> None:
1683
- """Execute a tool command, raising if it exits with a non-zero code.
1684
-
1685
- Args:
1686
- cmd: The command list to execute.
1687
-
1688
- Raises:
1689
- subprocess.SubprocessError: If the command outlives the timeout.
1690
- OSError: If the command cannot be started.
1691
- """
1692
- logger.debug("Executing: %s", " ".join(cmd))
1693
- # check=False: a fixer exits non-zero when findings are left over, and
1694
- # treating that as an error aborted the run before the check that
1695
- # reports them, so --fix passed on files the plain check failed.
1696
- subprocess.run(
1697
- cmd, capture_output=True, check=False, timeout=DEFAULT_TIMEOUT
1698
- )
1699
-
1700
-
1701
1759
  # Main entry point for the CLI
1702
1760
  def main() -> None:
1703
1761
  """Main entry point for the CLI."""
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import logging
2
3
  import os
3
4
  import subprocess
@@ -505,15 +506,72 @@ def test_default_configs_omitted_when_project_configured(
505
506
  assert "--config" not in tools["ruff"]["format"]
506
507
  assert "--config" not in tools["pyrefly"]["check"]
507
508
 
509
+ ts_file = tmp_path / "script.ts"
510
+ ts_file.touch()
511
+ (tmp_path / "biome.json").write_text("{}")
512
+ tools = {t["name"]: t for t in _get_tool_definitions(ts_file, tmp_path)}
513
+ for command in ("check", "check_format", "fix", "format"):
514
+ assert "--config-path" not in tools["biome"][command]
508
515
 
509
- def test_bundled_default_configs_are_valid(tmp_path: Path) -> None:
510
- """Tests that the bundled default configs exist and parse as TOML."""
511
- # Both bundled configs must exist and be valid TOML
516
+
517
+ def test_biome_bundled_config_is_injected_into_every_command(
518
+ tmp_path: Path,
519
+ ) -> None:
520
+ """Linting, formatting, and their fix forms share the safe default."""
521
+ ts_file = tmp_path / "script.ts"
522
+ ts_file.touch()
523
+
524
+ tools = {t["name"]: t for t in _get_tool_definitions(ts_file, tmp_path)}
525
+ config_path = str(_bundled_config("biome"))
526
+
527
+ for command_name in ("check", "check_format", "fix", "format"):
528
+ command = tools["biome"][command_name]
529
+ assert command[command.index("--config-path") + 1] == config_path
530
+ assert "--vcs-enabled=true" not in command
531
+
532
+
533
+ @pytest.mark.parametrize(
534
+ "ignore_file", (".gitignore", ".ignore", ".git/info/exclude")
535
+ )
536
+ def test_biome_bundled_config_respects_project_ignore_files(
537
+ tmp_path: Path, ignore_file: str
538
+ ) -> None:
539
+ """An external default keeps ignore discovery rooted in the project."""
540
+ ts_file = tmp_path / "script.ts"
541
+ ts_file.touch()
542
+ ignore_path = tmp_path / ignore_file
543
+ ignore_path.parent.mkdir(parents=True, exist_ok=True)
544
+ ignore_path.write_text("node_modules/\n")
545
+
546
+ tools = {t["name"]: t for t in _get_tool_definitions(ts_file, tmp_path)}
547
+
548
+ for command_name in ("check", "check_format", "fix", "format"):
549
+ command = tools["biome"][command_name]
550
+ assert "--vcs-enabled=true" in command
551
+ assert "--vcs-client-kind=git" in command
552
+ assert "--vcs-use-ignore-file=true" in command
553
+ assert f"--vcs-root={tmp_path}" in command
554
+
555
+
556
+ def test_bundled_default_configs_are_valid() -> None:
557
+ """Tests that the bundled default configs exist and parse."""
558
+ # Both Python configs must exist and be valid TOML
512
559
  for tool in ("ruff", "pyrefly"):
513
560
  config_path = _bundled_config(tool)
514
561
  assert config_path.exists()
515
562
  tomllib.loads(config_path.read_text())
516
563
 
564
+ biome_config_path = _bundled_config("biome")
565
+ assert biome_config_path.exists()
566
+ biome_config = json.loads(biome_config_path.read_text())
567
+ assert biome_config["formatter"] == {
568
+ "enabled": True,
569
+ "indentStyle": "space",
570
+ "indentWidth": 2,
571
+ "lineWidth": 80,
572
+ }
573
+ assert biome_config["linter"]["rules"]["recommended"] is True
574
+
517
575
  # The ruff defaults follow the Google Python style guide
518
576
  ruff_config = tomllib.loads(_bundled_config("ruff").read_text())
519
577
  assert ruff_config["line-length"] == 80
@@ -560,6 +618,29 @@ def test_check_paths_aggregates_str_and_path_inputs(
560
618
  ]
561
619
 
562
620
 
621
+ @patch("readability._check_path")
622
+ def test_check_paths_preserves_unverified_paths_when_another_path_ran(
623
+ mock_check_path: MagicMock,
624
+ tmp_path: Path,
625
+ ) -> None:
626
+ """One checked path must not hide another path that processed no files."""
627
+ checked = tmp_path / "src"
628
+ unchecked = tmp_path / "ignored"
629
+ checked.mkdir()
630
+ unchecked.mkdir()
631
+ mock_check_path.side_effect = [
632
+ CheckReport(ran={"biome"}),
633
+ CheckReport(),
634
+ ]
635
+
636
+ report = check_paths([checked, unchecked], project_root=tmp_path)
637
+
638
+ assert report == CheckReport(
639
+ ran={"biome"},
640
+ unverified_paths=[unchecked],
641
+ )
642
+
643
+
563
644
  @patch("readability._check_path", return_value=CheckReport(ran={"ruff"}))
564
645
  def test_check_paths_defaults_project_root_to_cwd(
565
646
  mock_check_path: MagicMock,
@@ -1789,10 +1870,10 @@ def test_check_runs_bundled_default_tools_without_a_trigger(
1789
1870
 
1790
1871
  @patch("shutil.which")
1791
1872
  @patch("subprocess.run")
1792
- def test_check_still_gates_tools_without_bundled_defaults(
1873
+ def test_check_runs_biome_without_a_project_config(
1793
1874
  mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
1794
1875
  ) -> None:
1795
- """Biome and Prettier have no defaults here, so a project must ask."""
1876
+ """A bundled Biome config covers web files in unconfigured projects."""
1796
1877
  mock_which.side_effect = lambda x: x
1797
1878
  mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
1798
1879
 
@@ -1803,13 +1884,179 @@ def test_check_still_gates_tools_without_bundled_defaults(
1803
1884
  result = runner.invoke(cli, ["check", "script.ts"])
1804
1885
 
1805
1886
  assert result.exit_code == 0
1887
+ called = [call.args[0] for call in mock_run.call_args_list]
1888
+ assert any(command[0] == "biome" for command in called)
1889
+ assert all("prettier" not in command for command in called)
1890
+ config_path = str(_bundled_config("biome"))
1891
+ biome_commands = [command for command in called if command[0] == "biome"]
1892
+ assert biome_commands
1893
+ assert all(
1894
+ command[command.index("--config-path") + 1] == config_path
1895
+ for command in biome_commands
1896
+ )
1897
+
1898
+
1899
+ @patch("shutil.which")
1900
+ @patch("subprocess.run")
1901
+ def test_bundled_tools_do_not_claim_an_unsupported_directory(
1902
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
1903
+ ) -> None:
1904
+ """A zero-file tool run must not turn nothing checked into a clean pass."""
1905
+ mock_which.side_effect = lambda x: x
1906
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
1907
+
1908
+ runner = CliRunner()
1909
+ with runner.isolated_filesystem(temp_dir=tmp_path):
1910
+ Path("notes.md").touch()
1911
+
1912
+ result = runner.invoke(cli, ["check", "."])
1913
+
1914
+ assert result.exit_code == 0
1915
+ output = result.stdout + result.stderr
1916
+ assert "nothing was checked" in output
1917
+ assert "No findings" not in output
1918
+ mock_run.assert_not_called()
1919
+
1920
+
1921
+ @patch("shutil.which")
1922
+ @patch("subprocess.run")
1923
+ def test_biome_zero_file_result_is_not_reported_as_clean(
1924
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
1925
+ ) -> None:
1926
+ """Ignored matches must not make an unverified directory look clean."""
1927
+ mock_which.side_effect = lambda x: x if x == "biome" else None
1928
+ checked_nothing = MagicMock(
1929
+ returncode=0,
1930
+ stdout="",
1931
+ stderr="Checked 0 files in 1ms. No fixes applied.\n",
1932
+ )
1933
+ formatted_nothing = MagicMock(
1934
+ returncode=0,
1935
+ stdout="Formatted 0 files in 1ms. No fixes applied.\n",
1936
+ stderr="",
1937
+ )
1938
+
1939
+ runner = CliRunner()
1940
+ with runner.isolated_filesystem(temp_dir=tmp_path):
1941
+ Path(".gitignore").write_text("node_modules/\n")
1942
+ Path("node_modules/example").mkdir(parents=True)
1943
+ Path("node_modules/example/index.js").touch()
1944
+ Path("README.md").touch()
1945
+
1946
+ for extra_args, expected_calls in (([], 2), (["--fix"], 3)):
1947
+ mock_run.side_effect = (
1948
+ [checked_nothing, checked_nothing]
1949
+ if not extra_args
1950
+ else [formatted_nothing, checked_nothing, checked_nothing]
1951
+ )
1952
+ result = runner.invoke(cli, ["check", ".", *extra_args])
1953
+
1954
+ assert result.exit_code == 0
1955
+ output = result.stdout + result.stderr
1956
+ assert "nothing was checked" in output
1957
+ assert "No findings" not in output
1958
+ assert mock_run.call_count == expected_calls
1959
+ mock_run.reset_mock()
1960
+
1961
+
1962
+ @patch("shutil.which")
1963
+ @patch("subprocess.run")
1964
+ def test_biome_zero_file_path_is_not_hidden_by_a_checked_path(
1965
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
1966
+ ) -> None:
1967
+ """A clean aggregate identifies requested paths Biome did not inspect."""
1968
+ mock_which.side_effect = lambda x: x if x == "biome" else None
1969
+ checked_files = MagicMock(returncode=0, stdout="", stderr="")
1970
+ checked_nothing = MagicMock(
1971
+ returncode=0,
1972
+ stdout="Checked 0 files in 1ms. No fixes applied.\n",
1973
+ stderr="",
1974
+ )
1975
+
1976
+ runner = CliRunner()
1977
+ with runner.isolated_filesystem(temp_dir=tmp_path):
1978
+ Path(".gitignore").write_text("ignored/\n")
1979
+ Path("src").mkdir()
1980
+ Path("src/good.js").touch()
1981
+ Path("ignored").mkdir()
1982
+ Path("ignored/generated.js").touch()
1983
+ mock_run.side_effect = [
1984
+ checked_files,
1985
+ checked_files,
1986
+ checked_nothing,
1987
+ checked_nothing,
1988
+ ]
1989
+
1990
+ result = runner.invoke(cli, ["check", "src", "ignored"])
1991
+
1992
+ assert result.exit_code == 0
1993
+ assert "nothing was checked for 1 path(s): ignored" in result.stderr
1994
+ assert "No findings in 1 path(s) (biome)." in result.stderr
1995
+ assert "No findings in 2 path(s)" not in result.stderr
1996
+
1997
+
1998
+ @patch("shutil.which")
1999
+ @patch("subprocess.run")
2000
+ def test_biome_zero_file_failure_still_exits_nonzero(
2001
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2002
+ ) -> None:
2003
+ """A configuration failure must not become a successful no-tool result."""
2004
+ mock_which.side_effect = lambda x: x if x == "biome" else None
2005
+ checked_nothing = MagicMock(
2006
+ returncode=1,
2007
+ stdout="Checked 0 files in 1ms.\nConfiguration error.\n",
2008
+ stderr="",
2009
+ )
2010
+ formatted_nothing = MagicMock(
2011
+ returncode=1,
2012
+ stdout="Formatted 0 files in 1ms.\nConfiguration error.\n",
2013
+ stderr="",
2014
+ )
2015
+
2016
+ runner = CliRunner()
2017
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2018
+ Path("script.ts").touch()
2019
+
2020
+ for extra_args in ([], ["--fix"]):
2021
+ mock_run.side_effect = (
2022
+ [checked_nothing, checked_nothing]
2023
+ if not extra_args
2024
+ else [formatted_nothing, checked_nothing, checked_nothing]
2025
+ )
2026
+ result = runner.invoke(cli, ["check", "script.ts", *extra_args])
2027
+
2028
+ assert result.exit_code == 1
2029
+ output = result.stdout + result.stderr
2030
+ assert "biome findings" in output
2031
+ assert "nothing was checked" not in output
2032
+ mock_run.reset_mock()
2033
+
2034
+
2035
+ @patch("shutil.which")
2036
+ @patch("subprocess.run")
2037
+ def test_bundled_biome_applies_to_a_nested_web_file(
2038
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2039
+ ) -> None:
2040
+ """Directory applicability still finds nested web files."""
2041
+ mock_which.side_effect = lambda x: x if x == "biome" else None
2042
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
2043
+
2044
+ runner = CliRunner()
2045
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2046
+ Path("src").mkdir()
2047
+ Path("src/script.ts").touch()
2048
+
2049
+ result = runner.invoke(cli, ["check", "."])
2050
+
2051
+ assert result.exit_code == 0
2052
+ assert "No findings in 1 path(s) (biome)." in result.stderr
1806
2053
  called = [call.args[0][0] for call in mock_run.call_args_list]
1807
- assert "npx" not in called
2054
+ assert called == ["biome", "biome"]
1808
2055
 
1809
2056
 
1810
2057
  @patch("shutil.which")
1811
2058
  @patch("subprocess.run")
1812
- def test_check_without_defaults_respects_its_trigger(
2059
+ def test_check_with_project_config_runs_biome(
1813
2060
  mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
1814
2061
  ) -> None:
1815
2062
  """With biome.json present, biome runs as it always did."""
@@ -194,7 +194,7 @@ wheels = [
194
194
 
195
195
  [[package]]
196
196
  name = "readability-cli"
197
- version = "0.8.2"
197
+ version = "0.8.3"
198
198
  source = { editable = "." }
199
199
  dependencies = [
200
200
  { name = "beautifulsoup4" },
File without changes