readability-cli 0.8.1__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.1 → readability_cli-0.8.3}/PKG-INFO +29 -18
  2. {readability_cli-0.8.1 → readability_cli-0.8.3}/README.md +28 -17
  3. readability_cli-0.8.3/configs/biome-default.json +14 -0
  4. {readability_cli-0.8.1 → readability_cli-0.8.3}/pyproject.toml +1 -1
  5. {readability_cli-0.8.1 → readability_cli-0.8.3}/readability.py +111 -41
  6. {readability_cli-0.8.1 → readability_cli-0.8.3}/test_readability.py +287 -10
  7. {readability_cli-0.8.1 → readability_cli-0.8.3}/uv.lock +1 -1
  8. {readability_cli-0.8.1 → readability_cli-0.8.3}/.github/workflows/ci.yml +0 -0
  9. {readability_cli-0.8.1 → readability_cli-0.8.3}/.github/workflows/publish.yml +0 -0
  10. {readability_cli-0.8.1 → readability_cli-0.8.3}/.github/workflows/update-guides.yml +0 -0
  11. {readability_cli-0.8.1 → readability_cli-0.8.3}/.gitignore +0 -0
  12. {readability_cli-0.8.1 → readability_cli-0.8.3}/.python-version +0 -0
  13. {readability_cli-0.8.1 → readability_cli-0.8.3}/LICENSE +0 -0
  14. {readability_cli-0.8.1 → readability_cli-0.8.3}/configs/pyrefly.toml +0 -0
  15. {readability_cli-0.8.1 → readability_cli-0.8.3}/configs/ruff.toml +0 -0
  16. {readability_cli-0.8.1 → readability_cli-0.8.3}/guides/Rguide.md +0 -0
  17. {readability_cli-0.8.1 → readability_cli-0.8.3}/guides/cppguide.md +0 -0
  18. {readability_cli-0.8.1 → readability_cli-0.8.3}/guides/csharp-style.md +0 -0
  19. {readability_cli-0.8.1 → readability_cli-0.8.3}/guides/docguide-style.md +0 -0
  20. {readability_cli-0.8.1 → readability_cli-0.8.3}/guides/go-guide.md +0 -0
  21. {readability_cli-0.8.1 → readability_cli-0.8.3}/guides/htmlcssguide.md +0 -0
  22. {readability_cli-0.8.1 → readability_cli-0.8.3}/guides/javaguide.md +0 -0
  23. {readability_cli-0.8.1 → readability_cli-0.8.3}/guides/jsguide.md +0 -0
  24. {readability_cli-0.8.1 → readability_cli-0.8.3}/guides/jsoncstyleguide.md +0 -0
  25. {readability_cli-0.8.1 → readability_cli-0.8.3}/guides/objcguide.md +0 -0
  26. {readability_cli-0.8.1 → readability_cli-0.8.3}/guides/pyguide.md +0 -0
  27. {readability_cli-0.8.1 → readability_cli-0.8.3}/guides/shellguide.md +0 -0
  28. {readability_cli-0.8.1 → readability_cli-0.8.3}/guides/tsguide.md +0 -0
  29. {readability_cli-0.8.1 → 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.1
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
 
@@ -164,17 +166,26 @@ if report.findings or report.failed or not report.ran:
164
166
 
165
167
  The returned `CheckReport` records only whether findings occurred and which
166
168
  tools ran, were skipped, or failed. Detailed tool findings are still written
167
- as each tool runs.
169
+ as each tool runs. Every path is validated before any tool runs, and a missing
170
+ one raises `FileNotFoundError` rather than being misreported as a finding.
171
+ Relative paths remain relative to the process working directory;
172
+ `project_root` controls configuration discovery only.
168
173
 
169
174
  ### Default Configurations
170
175
 
171
- For Ruff and Pyrefly, bundled defaults based on the
172
- [Google Python style guide](https://google.github.io/styleguide/pyguide.html)
173
- (80-column lines, Google docstring convention, import ordering, full type
174
- checking) are applied when the project does not define its own configuration.
175
- To override them, add a `[tool.ruff]` or `[tool.pyrefly]` section to your
176
- `pyproject.toml` (or a dedicated `ruff.toml` / `pyrefly.toml`) any
177
- 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.
178
189
 
179
190
  ## Style Guides
180
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
 
@@ -150,17 +152,26 @@ if report.findings or report.failed or not report.ran:
150
152
 
151
153
  The returned `CheckReport` records only whether findings occurred and which
152
154
  tools ran, were skipped, or failed. Detailed tool findings are still written
153
- as each tool runs.
155
+ as each tool runs. Every path is validated before any tool runs, and a missing
156
+ one raises `FileNotFoundError` rather than being misreported as a finding.
157
+ Relative paths remain relative to the process working directory;
158
+ `project_root` controls configuration discovery only.
154
159
 
155
160
  ### Default Configurations
156
161
 
157
- For Ruff and Pyrefly, bundled defaults based on the
158
- [Google Python style guide](https://google.github.io/styleguide/pyguide.html)
159
- (80-column lines, Google docstring convention, import ordering, full type
160
- checking) are applied when the project does not define its own configuration.
161
- To override them, add a `[tool.ruff]` or `[tool.pyrefly]` section to your
162
- `pyproject.toml` (or a dedicated `ruff.toml` / `pyrefly.toml`) any
163
- 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.
164
175
 
165
176
  ## Style Guides
166
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.1"
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(
@@ -1196,18 +1212,33 @@ def check_paths(
1196
1212
  the result.
1197
1213
 
1198
1214
  Args:
1199
- paths: Files or directories to check, as strings or paths.
1200
- project_root: Root used to discover tool configuration. Defaults to
1201
- the current working directory.
1215
+ paths: Files or directories to check, as strings or paths. Relative
1216
+ paths are interpreted from the current working directory.
1217
+ project_root: Root used only to discover tool configuration. Defaults
1218
+ to the current working directory; it does not rebase paths.
1202
1219
  fix: Whether to apply automatic fixes.
1203
1220
 
1204
1221
  Returns:
1205
1222
  A report aggregated across all provided paths.
1223
+
1224
+ Raises:
1225
+ FileNotFoundError: If any requested path does not exist. Every path is
1226
+ validated before any tools run.
1206
1227
  """
1207
1228
  root = Path.cwd() if project_root is None else project_root
1229
+ requested_paths = [Path(path) for path in paths]
1230
+ missing_path = next(
1231
+ (path for path in requested_paths if not path.exists()), None
1232
+ )
1233
+ if missing_path is not None:
1234
+ raise FileNotFoundError(f"Path does not exist: {missing_path}")
1235
+
1208
1236
  report = CheckReport()
1209
- for path in paths:
1210
- report.absorb(_check_path(Path(path), root, fix=fix))
1237
+ for path in requested_paths:
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)
1211
1242
  return report
1212
1243
 
1213
1244
 
@@ -1266,12 +1297,20 @@ def _should_run_tool(
1266
1297
  (project_root / t).exists() for t in tool["trigger"]
1267
1298
  )
1268
1299
 
1269
- # For files, also check if the extension matches one of the supported ones
1300
+ # Files must match one of the tool's supported extensions.
1270
1301
  if path.is_file():
1271
1302
  return has_trigger and path.suffix in tool["extensions"]
1272
1303
 
1273
- # For directories, the existence of a trigger file is sufficient
1274
- 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
+ )
1275
1314
 
1276
1315
 
1277
1316
  def _tool_is_installed(tool_name: str, tool_config: dict[str, Any]) -> bool:
@@ -1307,12 +1346,15 @@ def _bundled_config(tool_name: str) -> Path:
1307
1346
  """Get the path to the bundled default configuration for a tool.
1308
1347
 
1309
1348
  Args:
1310
- tool_name: The name of the tool (e.g. "ruff", "pyrefly").
1349
+ tool_name: The name of the tool (e.g. "ruff", "pyrefly", "biome").
1311
1350
 
1312
1351
  Returns:
1313
1352
  The path to the bundled default config file.
1314
1353
  """
1315
- 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
1316
1358
 
1317
1359
 
1318
1360
  def _has_project_config(
@@ -1444,6 +1486,26 @@ def _get_tool_definitions(
1444
1486
  pyrefly_config = _default_config_args(
1445
1487
  project_root, ["pyrefly.toml"], "pyrefly"
1446
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
+ )
1447
1509
 
1448
1510
  # Resolved once so every command for a tool reaches the same executable
1449
1511
  ruff = _tool_command("ruff", project_root)
@@ -1499,12 +1561,14 @@ def _get_tool_definitions(
1499
1561
  "check": [
1500
1562
  *biome,
1501
1563
  "lint",
1564
+ *biome_config,
1502
1565
  "--no-errors-on-unmatched",
1503
1566
  path_str,
1504
1567
  ],
1505
1568
  "check_format": [
1506
1569
  *biome,
1507
1570
  "format",
1571
+ *biome_config,
1508
1572
  "--no-errors-on-unmatched",
1509
1573
  path_str,
1510
1574
  ],
@@ -1512,6 +1576,7 @@ def _get_tool_definitions(
1512
1576
  *biome,
1513
1577
  "lint",
1514
1578
  "--write",
1579
+ *biome_config,
1515
1580
  "--no-errors-on-unmatched",
1516
1581
  path_str,
1517
1582
  ],
@@ -1519,6 +1584,7 @@ def _get_tool_definitions(
1519
1584
  *biome,
1520
1585
  "format",
1521
1586
  "--write",
1587
+ *biome_config,
1522
1588
  "--no-errors-on-unmatched",
1523
1589
  path_str,
1524
1590
  ],
@@ -1610,11 +1676,13 @@ def _run_tool(
1610
1676
  # and report what they could not deal with.
1611
1677
  for phase in ("format", "fix"):
1612
1678
  if phase in tool_config:
1613
- _execute_tool_command(tool_config[phase])
1614
- 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)
1615
1682
  elif "check_format" in tool_config:
1616
1683
  result = _capture_tool_command(tool_config["check_format"])
1617
- report.ran.add(tool_name)
1684
+ if _tool_checked_files(tool_name, result):
1685
+ report.ran.add(tool_name)
1618
1686
  # gofmt reports by naming files rather than by exit code
1619
1687
  if result.returncode != 0 or (
1620
1688
  tool_name == "go fmt" and result.stdout.strip()
@@ -1627,7 +1695,8 @@ def _run_tool(
1627
1695
 
1628
1696
  if "check" in tool_config:
1629
1697
  result = _capture_tool_command(tool_config["check"])
1630
- report.ran.add(tool_name)
1698
+ if _tool_checked_files(tool_name, result):
1699
+ report.ran.add(tool_name)
1631
1700
  if result.returncode != 0:
1632
1701
  report.findings = True
1633
1702
  click.echo(
@@ -1644,6 +1713,26 @@ def _run_tool(
1644
1713
  return report
1645
1714
 
1646
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
+
1647
1736
  def _capture_tool_command(cmd: list[str]) -> subprocess.CompletedProcess:
1648
1737
  """Run a tool and capture what it said, whatever its exit code.
1649
1738
 
@@ -1651,7 +1740,7 @@ def _capture_tool_command(cmd: list[str]) -> subprocess.CompletedProcess:
1651
1740
  cmd: The command list to execute.
1652
1741
 
1653
1742
  Returns:
1654
- The completed process, for the caller to read an exit code off.
1743
+ The completed process, including captured output and its exit code.
1655
1744
 
1656
1745
  Raises:
1657
1746
  subprocess.SubprocessError: If the command outlives the timeout.
@@ -1667,25 +1756,6 @@ def _capture_tool_command(cmd: list[str]) -> subprocess.CompletedProcess:
1667
1756
  )
1668
1757
 
1669
1758
 
1670
- def _execute_tool_command(cmd: list[str]) -> None:
1671
- """Execute a tool command, raising if it exits with a non-zero code.
1672
-
1673
- Args:
1674
- cmd: The command list to execute.
1675
-
1676
- Raises:
1677
- subprocess.SubprocessError: If the command outlives the timeout.
1678
- OSError: If the command cannot be started.
1679
- """
1680
- logger.debug("Executing: %s", " ".join(cmd))
1681
- # check=False: a fixer exits non-zero when findings are left over, and
1682
- # treating that as an error aborted the run before the check that
1683
- # reports them, so --fix passed on files the plain check failed.
1684
- subprocess.run(
1685
- cmd, capture_output=True, check=False, timeout=DEFAULT_TIMEOUT
1686
- )
1687
-
1688
-
1689
1759
  # Main entry point for the CLI
1690
1760
  def main() -> None:
1691
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
@@ -522,10 +580,17 @@ def test_bundled_default_configs_are_valid(tmp_path: Path) -> None:
522
580
 
523
581
  @patch("readability._check_path")
524
582
  def test_check_paths_aggregates_str_and_path_inputs(
525
- mock_check_path: MagicMock, tmp_path: Path
583
+ mock_check_path: MagicMock,
584
+ tmp_path: Path,
585
+ monkeypatch: pytest.MonkeyPatch,
526
586
  ) -> None:
527
587
  """The public API folds each per-path result into one limited report."""
528
588
  project_root = tmp_path / "project"
589
+ project_root.mkdir()
590
+ (tmp_path / "src").mkdir()
591
+ (tmp_path / "src" / "example.py").touch()
592
+ (tmp_path / "README.md").touch()
593
+ monkeypatch.chdir(tmp_path)
529
594
  mock_check_path.side_effect = [
530
595
  CheckReport(ran={"ruff"}, skipped={"pyrefly"}),
531
596
  CheckReport(
@@ -553,6 +618,29 @@ def test_check_paths_aggregates_str_and_path_inputs(
553
618
  ]
554
619
 
555
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
+
556
644
  @patch("readability._check_path", return_value=CheckReport(ran={"ruff"}))
557
645
  def test_check_paths_defaults_project_root_to_cwd(
558
646
  mock_check_path: MagicMock,
@@ -561,6 +649,7 @@ def test_check_paths_defaults_project_root_to_cwd(
561
649
  ) -> None:
562
650
  """The public API discovers configuration from the caller's directory."""
563
651
  monkeypatch.chdir(tmp_path)
652
+ (tmp_path / "example.py").touch()
564
653
 
565
654
  check_paths([Path("example.py")])
566
655
 
@@ -581,6 +670,23 @@ def test_check_paths_with_no_paths_reports_that_nothing_ran(
581
670
  mock_check_path.assert_not_called()
582
671
 
583
672
 
673
+ @patch("readability._check_path")
674
+ def test_check_paths_rejects_missing_paths_before_dispatch(
675
+ mock_check_path: MagicMock,
676
+ tmp_path: Path,
677
+ ) -> None:
678
+ """A missing path prevents partial checks and fixes on earlier paths."""
679
+ existing = tmp_path / "exists.py"
680
+ missing = tmp_path / "missing.py"
681
+ existing.touch()
682
+
683
+ with pytest.raises(FileNotFoundError) as excinfo:
684
+ check_paths([existing, missing], project_root=tmp_path, fix=True)
685
+
686
+ assert str(missing) in str(excinfo.value)
687
+ mock_check_path.assert_not_called()
688
+
689
+
584
690
  @patch(
585
691
  "readability._check_path",
586
692
  return_value=CheckReport(
@@ -590,10 +696,15 @@ def test_check_paths_with_no_paths_reports_that_nothing_ran(
590
696
  ),
591
697
  )
592
698
  def test_check_paths_returns_report_without_cli_status_prose(
593
- mock_check_path: MagicMock, capsys: pytest.CaptureFixture[str]
699
+ mock_check_path: MagicMock,
700
+ capsys: pytest.CaptureFixture[str],
701
+ tmp_path: Path,
594
702
  ) -> None:
595
703
  """Library callers receive report states without CLI policy or summaries."""
596
- report = check_paths(["example.py"])
704
+ path = tmp_path / "example.py"
705
+ path.touch()
706
+
707
+ report = check_paths([path])
597
708
 
598
709
  assert report.findings is True
599
710
  assert report.skipped == {"pyrefly"}
@@ -1759,10 +1870,10 @@ def test_check_runs_bundled_default_tools_without_a_trigger(
1759
1870
 
1760
1871
  @patch("shutil.which")
1761
1872
  @patch("subprocess.run")
1762
- def test_check_still_gates_tools_without_bundled_defaults(
1873
+ def test_check_runs_biome_without_a_project_config(
1763
1874
  mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
1764
1875
  ) -> None:
1765
- """Biome and Prettier have no defaults here, so a project must ask."""
1876
+ """A bundled Biome config covers web files in unconfigured projects."""
1766
1877
  mock_which.side_effect = lambda x: x
1767
1878
  mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
1768
1879
 
@@ -1773,13 +1884,179 @@ def test_check_still_gates_tools_without_bundled_defaults(
1773
1884
  result = runner.invoke(cli, ["check", "script.ts"])
1774
1885
 
1775
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
1776
2053
  called = [call.args[0][0] for call in mock_run.call_args_list]
1777
- assert "npx" not in called
2054
+ assert called == ["biome", "biome"]
1778
2055
 
1779
2056
 
1780
2057
  @patch("shutil.which")
1781
2058
  @patch("subprocess.run")
1782
- def test_check_without_defaults_respects_its_trigger(
2059
+ def test_check_with_project_config_runs_biome(
1783
2060
  mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
1784
2061
  ) -> None:
1785
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.1"
197
+ version = "0.8.3"
198
198
  source = { editable = "." }
199
199
  dependencies = [
200
200
  { name = "beautifulsoup4" },
File without changes