readability-cli 0.8.2__tar.gz → 0.8.4__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.4}/PKG-INFO +25 -17
  2. {readability_cli-0.8.2 → readability_cli-0.8.4}/README.md +24 -16
  3. readability_cli-0.8.4/configs/biome-default.json +14 -0
  4. {readability_cli-0.8.2 → readability_cli-0.8.4}/guides/jsoncstyleguide.md +1 -1
  5. {readability_cli-0.8.2 → readability_cli-0.8.4}/pyproject.toml +1 -1
  6. {readability_cli-0.8.2 → readability_cli-0.8.4}/readability.py +117 -41
  7. {readability_cli-0.8.2 → readability_cli-0.8.4}/test_readability.py +336 -7
  8. {readability_cli-0.8.2 → readability_cli-0.8.4}/uv.lock +1 -1
  9. {readability_cli-0.8.2 → readability_cli-0.8.4}/.github/workflows/ci.yml +0 -0
  10. {readability_cli-0.8.2 → readability_cli-0.8.4}/.github/workflows/publish.yml +0 -0
  11. {readability_cli-0.8.2 → readability_cli-0.8.4}/.github/workflows/update-guides.yml +0 -0
  12. {readability_cli-0.8.2 → readability_cli-0.8.4}/.gitignore +0 -0
  13. {readability_cli-0.8.2 → readability_cli-0.8.4}/.python-version +0 -0
  14. {readability_cli-0.8.2 → readability_cli-0.8.4}/LICENSE +0 -0
  15. {readability_cli-0.8.2 → readability_cli-0.8.4}/configs/pyrefly.toml +0 -0
  16. {readability_cli-0.8.2 → readability_cli-0.8.4}/configs/ruff.toml +0 -0
  17. {readability_cli-0.8.2 → readability_cli-0.8.4}/guides/Rguide.md +0 -0
  18. {readability_cli-0.8.2 → readability_cli-0.8.4}/guides/cppguide.md +0 -0
  19. {readability_cli-0.8.2 → readability_cli-0.8.4}/guides/csharp-style.md +0 -0
  20. {readability_cli-0.8.2 → readability_cli-0.8.4}/guides/docguide-style.md +0 -0
  21. {readability_cli-0.8.2 → readability_cli-0.8.4}/guides/go-guide.md +0 -0
  22. {readability_cli-0.8.2 → readability_cli-0.8.4}/guides/htmlcssguide.md +0 -0
  23. {readability_cli-0.8.2 → readability_cli-0.8.4}/guides/javaguide.md +0 -0
  24. {readability_cli-0.8.2 → readability_cli-0.8.4}/guides/jsguide.md +0 -0
  25. {readability_cli-0.8.2 → readability_cli-0.8.4}/guides/objcguide.md +0 -0
  26. {readability_cli-0.8.2 → readability_cli-0.8.4}/guides/pyguide.md +0 -0
  27. {readability_cli-0.8.2 → readability_cli-0.8.4}/guides/shellguide.md +0 -0
  28. {readability_cli-0.8.2 → readability_cli-0.8.4}/guides/tsguide.md +0 -0
  29. {readability_cli-0.8.2 → readability_cli-0.8.4}/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.4
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
+ }
@@ -123,7 +123,7 @@ Property names must conform to the following guidelines:
123
123
 
124
124
  * Property names should be meaningful names with defined semantics.
125
125
  * Property names must be camel-cased, ascii strings.
126
- * The first character must be a letter, an underscore (\_) or a dollar sign ($).
126
+ * The first character must be a letter, an underscore (_) or a dollar sign ($).
127
127
  * Subsequent characters can be a letter, a digit, an underscore, or a dollar sign.
128
128
  * Reserved JavaScript keywords should be avoided (A list of reserved JavaScript keywords can be found below).
129
129
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "readability-cli"
3
- version = "0.8.2"
3
+ version = "0.8.4"
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,32 @@ def _get_tool_definitions(
1456
1486
  pyrefly_config = _default_config_args(
1457
1487
  project_root, ["pyrefly.toml"], "pyrefly"
1458
1488
  )
1489
+ # An explicit path switches Pyrefly to single-file mode, where the
1490
+ # project's includes, excludes, and project-rooted import resolution are
1491
+ # deliberately ignored. A directory backed by project-owned settings is
1492
+ # a project check, so let Pyrefly discover its files from that config.
1493
+ pyrefly_project_mode = path.is_dir() and not pyrefly_config
1494
+ pyrefly_targets = [] if pyrefly_project_mode else [path_str]
1495
+ biome_config = []
1496
+ if not any(
1497
+ (project_root / filename).exists()
1498
+ for filename in ("biome.json", "biome.jsonc")
1499
+ ):
1500
+ biome_config = ["--config-path", str(_bundled_config("biome"))]
1501
+ ignore_files = (
1502
+ project_root / ".gitignore",
1503
+ project_root / ".ignore",
1504
+ project_root / ".git" / "info" / "exclude",
1505
+ )
1506
+ if any(ignore_file.is_file() for ignore_file in ignore_files):
1507
+ biome_config.extend(
1508
+ [
1509
+ "--vcs-enabled=true",
1510
+ "--vcs-client-kind=git",
1511
+ "--vcs-use-ignore-file=true",
1512
+ f"--vcs-root={project_root}",
1513
+ ]
1514
+ )
1459
1515
 
1460
1516
  # Resolved once so every command for a tool reaches the same executable
1461
1517
  ruff = _tool_command("ruff", project_root)
@@ -1502,7 +1558,13 @@ def _get_tool_definitions(
1502
1558
  {
1503
1559
  # Type checker only: it reports findings but cannot fix or format
1504
1560
  "name": "pyrefly",
1505
- "check": [*pyrefly, "check", *pyrefly_config, path_str],
1561
+ "check": [
1562
+ *pyrefly,
1563
+ "check",
1564
+ *pyrefly_config,
1565
+ *pyrefly_targets,
1566
+ ],
1567
+ **({"cwd": project_root} if pyrefly_project_mode else {}),
1506
1568
  "trigger": ["pyproject.toml", "pyrefly.toml"],
1507
1569
  "extensions": [".py"],
1508
1570
  },
@@ -1511,12 +1573,14 @@ def _get_tool_definitions(
1511
1573
  "check": [
1512
1574
  *biome,
1513
1575
  "lint",
1576
+ *biome_config,
1514
1577
  "--no-errors-on-unmatched",
1515
1578
  path_str,
1516
1579
  ],
1517
1580
  "check_format": [
1518
1581
  *biome,
1519
1582
  "format",
1583
+ *biome_config,
1520
1584
  "--no-errors-on-unmatched",
1521
1585
  path_str,
1522
1586
  ],
@@ -1524,6 +1588,7 @@ def _get_tool_definitions(
1524
1588
  *biome,
1525
1589
  "lint",
1526
1590
  "--write",
1591
+ *biome_config,
1527
1592
  "--no-errors-on-unmatched",
1528
1593
  path_str,
1529
1594
  ],
@@ -1531,6 +1596,7 @@ def _get_tool_definitions(
1531
1596
  *biome,
1532
1597
  "format",
1533
1598
  "--write",
1599
+ *biome_config,
1534
1600
  "--no-errors-on-unmatched",
1535
1601
  path_str,
1536
1602
  ],
@@ -1614,6 +1680,7 @@ def _run_tool(
1614
1680
  logger.info("Running %s...", tool_name)
1615
1681
 
1616
1682
  report = CheckReport()
1683
+ cwd = tool_config.get("cwd")
1617
1684
  try:
1618
1685
  if fix:
1619
1686
  # Formatters rewrite files, fixers apply what they can. Both
@@ -1622,11 +1689,13 @@ def _run_tool(
1622
1689
  # and report what they could not deal with.
1623
1690
  for phase in ("format", "fix"):
1624
1691
  if phase in tool_config:
1625
- _execute_tool_command(tool_config[phase])
1626
- report.ran.add(tool_name)
1692
+ result = _capture_tool_command(tool_config[phase], cwd=cwd)
1693
+ if _tool_checked_files(tool_name, result):
1694
+ report.ran.add(tool_name)
1627
1695
  elif "check_format" in tool_config:
1628
- result = _capture_tool_command(tool_config["check_format"])
1629
- report.ran.add(tool_name)
1696
+ result = _capture_tool_command(tool_config["check_format"], cwd=cwd)
1697
+ if _tool_checked_files(tool_name, result):
1698
+ report.ran.add(tool_name)
1630
1699
  # gofmt reports by naming files rather than by exit code
1631
1700
  if result.returncode != 0 or (
1632
1701
  tool_name == "go fmt" and result.stdout.strip()
@@ -1638,8 +1707,9 @@ def _run_tool(
1638
1707
  )
1639
1708
 
1640
1709
  if "check" in tool_config:
1641
- result = _capture_tool_command(tool_config["check"])
1642
- report.ran.add(tool_name)
1710
+ result = _capture_tool_command(tool_config["check"], cwd=cwd)
1711
+ if _tool_checked_files(tool_name, result):
1712
+ report.ran.add(tool_name)
1643
1713
  if result.returncode != 0:
1644
1714
  report.findings = True
1645
1715
  click.echo(
@@ -1656,14 +1726,38 @@ def _run_tool(
1656
1726
  return report
1657
1727
 
1658
1728
 
1659
- def _capture_tool_command(cmd: list[str]) -> subprocess.CompletedProcess:
1729
+ def _tool_checked_files(
1730
+ tool_name: str, result: subprocess.CompletedProcess
1731
+ ) -> bool:
1732
+ """Report whether a completed command actually processed any files.
1733
+
1734
+ Args:
1735
+ tool_name: The tool whose command completed.
1736
+ result: The completed subprocess.
1737
+
1738
+ Returns:
1739
+ False when Biome explicitly reports that it checked zero files.
1740
+ """
1741
+ if tool_name != "biome":
1742
+ return True
1743
+ output = f"{result.stdout or ''}\n{result.stderr or ''}"
1744
+ # Biome 1.x and 2.x use these summaries for an unmatched target.
1745
+ zero_file_summaries = ("Checked 0 files", "Formatted 0 files")
1746
+ return not any(summary in output for summary in zero_file_summaries)
1747
+
1748
+
1749
+ def _capture_tool_command(
1750
+ cmd: list[str], cwd: Path | None = None
1751
+ ) -> subprocess.CompletedProcess:
1660
1752
  """Run a tool and capture what it said, whatever its exit code.
1661
1753
 
1662
1754
  Args:
1663
1755
  cmd: The command list to execute.
1756
+ cwd: Directory in which the tool should run. Defaults to the caller's
1757
+ current working directory.
1664
1758
 
1665
1759
  Returns:
1666
- The completed process, for the caller to read an exit code off.
1760
+ The completed process, including captured output and its exit code.
1667
1761
 
1668
1762
  Raises:
1669
1763
  subprocess.SubprocessError: If the command outlives the timeout.
@@ -1676,25 +1770,7 @@ def _capture_tool_command(cmd: list[str]) -> subprocess.CompletedProcess:
1676
1770
  text=True,
1677
1771
  check=False,
1678
1772
  timeout=DEFAULT_TIMEOUT,
1679
- )
1680
-
1681
-
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
1773
+ cwd=cwd,
1698
1774
  )
1699
1775
 
1700
1776
 
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import logging
2
3
  import os
3
4
  import subprocess
@@ -458,6 +459,36 @@ def test_check_command_pyrefly(
458
459
  assert ["pyrefly", "check", "--config", cfg, "script.py"] in called_cmds
459
460
 
460
461
 
462
+ @patch("shutil.which")
463
+ @patch("subprocess.run")
464
+ def test_check_command_runs_configured_directory_as_pyrefly_project(
465
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
466
+ ) -> None:
467
+ """The CLI lets project-owned includes and excludes select Python files."""
468
+ mock_which.side_effect = lambda name: name if name == "pyrefly" else None
469
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
470
+
471
+ runner = CliRunner()
472
+ with runner.isolated_filesystem(temp_dir=tmp_path):
473
+ Path("pyrefly.toml").write_text(
474
+ 'project-excludes = ["**/excluded.py"]\n'
475
+ )
476
+ Path("main.py").touch()
477
+ project_root = Path.cwd()
478
+
479
+ result = runner.invoke(cli, ["check", "."])
480
+
481
+ assert result.exit_code == 0
482
+ pyrefly_calls = [
483
+ invocation
484
+ for invocation in mock_run.call_args_list
485
+ if invocation.args[0][0] == "pyrefly"
486
+ ]
487
+ assert len(pyrefly_calls) == 1
488
+ assert pyrefly_calls[0].args[0] == ["pyrefly", "check"]
489
+ assert pyrefly_calls[0].kwargs["cwd"] == project_root
490
+
491
+
461
492
  def test_has_project_config(tmp_path: Path) -> None:
462
493
  """Tests project config detection via dedicated files and pyproject.
463
494
 
@@ -505,15 +536,124 @@ def test_default_configs_omitted_when_project_configured(
505
536
  assert "--config" not in tools["ruff"]["format"]
506
537
  assert "--config" not in tools["pyrefly"]["check"]
507
538
 
539
+ ts_file = tmp_path / "script.ts"
540
+ ts_file.touch()
541
+ (tmp_path / "biome.json").write_text("{}")
542
+ tools = {t["name"]: t for t in _get_tool_definitions(ts_file, tmp_path)}
543
+ for command in ("check", "check_format", "fix", "format"):
544
+ assert "--config-path" not in tools["biome"][command]
545
+
546
+
547
+ def test_pyrefly_uses_project_mode_only_for_configured_directories(
548
+ tmp_path: Path,
549
+ ) -> None:
550
+ """Directories use config discovery while explicit files stay explicit."""
551
+ (tmp_path / "pyrefly.toml").write_text(
552
+ 'project-excludes = ["**/excluded.py"]\n'
553
+ )
554
+ py_file = tmp_path / "main.py"
555
+ py_file.touch()
556
+
557
+ with patch("shutil.which", side_effect=lambda name: name):
558
+ directory_tools = {
559
+ tool["name"]: tool
560
+ for tool in _get_tool_definitions(tmp_path, tmp_path)
561
+ }
562
+ file_tools = {
563
+ tool["name"]: tool
564
+ for tool in _get_tool_definitions(py_file, tmp_path)
565
+ }
566
+
567
+ assert directory_tools["pyrefly"]["check"] == ["pyrefly", "check"]
568
+ assert directory_tools["pyrefly"]["cwd"] == tmp_path
569
+ assert file_tools["pyrefly"]["check"] == [
570
+ "pyrefly",
571
+ "check",
572
+ str(py_file),
573
+ ]
574
+ assert "cwd" not in file_tools["pyrefly"]
575
+
576
+
577
+ def test_pyrefly_bundled_config_keeps_an_explicit_directory(
578
+ tmp_path: Path,
579
+ ) -> None:
580
+ """Project mode cannot use a config rooted in the installed package."""
581
+ (tmp_path / "main.py").touch()
582
+
583
+ with patch("shutil.which", side_effect=lambda name: name):
584
+ tools = {
585
+ tool["name"]: tool
586
+ for tool in _get_tool_definitions(tmp_path, tmp_path)
587
+ }
588
+
589
+ assert tools["pyrefly"]["check"] == [
590
+ "pyrefly",
591
+ "check",
592
+ "--config",
593
+ str(_bundled_config("pyrefly")),
594
+ str(tmp_path),
595
+ ]
596
+ assert "cwd" not in tools["pyrefly"]
597
+
598
+
599
+ def test_biome_bundled_config_is_injected_into_every_command(
600
+ tmp_path: Path,
601
+ ) -> None:
602
+ """Linting, formatting, and their fix forms share the safe default."""
603
+ ts_file = tmp_path / "script.ts"
604
+ ts_file.touch()
508
605
 
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
606
+ tools = {t["name"]: t for t in _get_tool_definitions(ts_file, tmp_path)}
607
+ config_path = str(_bundled_config("biome"))
608
+
609
+ for command_name in ("check", "check_format", "fix", "format"):
610
+ command = tools["biome"][command_name]
611
+ assert command[command.index("--config-path") + 1] == config_path
612
+ assert "--vcs-enabled=true" not in command
613
+
614
+
615
+ @pytest.mark.parametrize(
616
+ "ignore_file", (".gitignore", ".ignore", ".git/info/exclude")
617
+ )
618
+ def test_biome_bundled_config_respects_project_ignore_files(
619
+ tmp_path: Path, ignore_file: str
620
+ ) -> None:
621
+ """An external default keeps ignore discovery rooted in the project."""
622
+ ts_file = tmp_path / "script.ts"
623
+ ts_file.touch()
624
+ ignore_path = tmp_path / ignore_file
625
+ ignore_path.parent.mkdir(parents=True, exist_ok=True)
626
+ ignore_path.write_text("node_modules/\n")
627
+
628
+ tools = {t["name"]: t for t in _get_tool_definitions(ts_file, tmp_path)}
629
+
630
+ for command_name in ("check", "check_format", "fix", "format"):
631
+ command = tools["biome"][command_name]
632
+ assert "--vcs-enabled=true" in command
633
+ assert "--vcs-client-kind=git" in command
634
+ assert "--vcs-use-ignore-file=true" in command
635
+ assert f"--vcs-root={tmp_path}" in command
636
+
637
+
638
+ def test_bundled_default_configs_are_valid() -> None:
639
+ """Tests that the bundled default configs exist and parse."""
640
+ # Both Python configs must exist and be valid TOML
512
641
  for tool in ("ruff", "pyrefly"):
513
642
  config_path = _bundled_config(tool)
514
643
  assert config_path.exists()
515
644
  tomllib.loads(config_path.read_text())
516
645
 
646
+ biome_config_path = _bundled_config("biome")
647
+ assert biome_config_path.exists()
648
+ biome_config = json.loads(biome_config_path.read_text())
649
+ assert biome_config["formatter"] == {
650
+ "enabled": True,
651
+ "indentStyle": "space",
652
+ "indentWidth": 2,
653
+ "lineWidth": 80,
654
+ }
655
+ assert biome_config["linter"]["rules"]["recommended"] is True
656
+
517
657
  # The ruff defaults follow the Google Python style guide
518
658
  ruff_config = tomllib.loads(_bundled_config("ruff").read_text())
519
659
  assert ruff_config["line-length"] == 80
@@ -560,6 +700,29 @@ def test_check_paths_aggregates_str_and_path_inputs(
560
700
  ]
561
701
 
562
702
 
703
+ @patch("readability._check_path")
704
+ def test_check_paths_preserves_unverified_paths_when_another_path_ran(
705
+ mock_check_path: MagicMock,
706
+ tmp_path: Path,
707
+ ) -> None:
708
+ """One checked path must not hide another path that processed no files."""
709
+ checked = tmp_path / "src"
710
+ unchecked = tmp_path / "ignored"
711
+ checked.mkdir()
712
+ unchecked.mkdir()
713
+ mock_check_path.side_effect = [
714
+ CheckReport(ran={"biome"}),
715
+ CheckReport(),
716
+ ]
717
+
718
+ report = check_paths([checked, unchecked], project_root=tmp_path)
719
+
720
+ assert report == CheckReport(
721
+ ran={"biome"},
722
+ unverified_paths=[unchecked],
723
+ )
724
+
725
+
563
726
  @patch("readability._check_path", return_value=CheckReport(ran={"ruff"}))
564
727
  def test_check_paths_defaults_project_root_to_cwd(
565
728
  mock_check_path: MagicMock,
@@ -1789,10 +1952,10 @@ def test_check_runs_bundled_default_tools_without_a_trigger(
1789
1952
 
1790
1953
  @patch("shutil.which")
1791
1954
  @patch("subprocess.run")
1792
- def test_check_still_gates_tools_without_bundled_defaults(
1955
+ def test_check_runs_biome_without_a_project_config(
1793
1956
  mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
1794
1957
  ) -> None:
1795
- """Biome and Prettier have no defaults here, so a project must ask."""
1958
+ """A bundled Biome config covers web files in unconfigured projects."""
1796
1959
  mock_which.side_effect = lambda x: x
1797
1960
  mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
1798
1961
 
@@ -1803,13 +1966,179 @@ def test_check_still_gates_tools_without_bundled_defaults(
1803
1966
  result = runner.invoke(cli, ["check", "script.ts"])
1804
1967
 
1805
1968
  assert result.exit_code == 0
1969
+ called = [call.args[0] for call in mock_run.call_args_list]
1970
+ assert any(command[0] == "biome" for command in called)
1971
+ assert all("prettier" not in command for command in called)
1972
+ config_path = str(_bundled_config("biome"))
1973
+ biome_commands = [command for command in called if command[0] == "biome"]
1974
+ assert biome_commands
1975
+ assert all(
1976
+ command[command.index("--config-path") + 1] == config_path
1977
+ for command in biome_commands
1978
+ )
1979
+
1980
+
1981
+ @patch("shutil.which")
1982
+ @patch("subprocess.run")
1983
+ def test_bundled_tools_do_not_claim_an_unsupported_directory(
1984
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
1985
+ ) -> None:
1986
+ """A zero-file tool run must not turn nothing checked into a clean pass."""
1987
+ mock_which.side_effect = lambda x: x
1988
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
1989
+
1990
+ runner = CliRunner()
1991
+ with runner.isolated_filesystem(temp_dir=tmp_path):
1992
+ Path("notes.md").touch()
1993
+
1994
+ result = runner.invoke(cli, ["check", "."])
1995
+
1996
+ assert result.exit_code == 0
1997
+ output = result.stdout + result.stderr
1998
+ assert "nothing was checked" in output
1999
+ assert "No findings" not in output
2000
+ mock_run.assert_not_called()
2001
+
2002
+
2003
+ @patch("shutil.which")
2004
+ @patch("subprocess.run")
2005
+ def test_biome_zero_file_result_is_not_reported_as_clean(
2006
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2007
+ ) -> None:
2008
+ """Ignored matches must not make an unverified directory look clean."""
2009
+ mock_which.side_effect = lambda x: x if x == "biome" else None
2010
+ checked_nothing = MagicMock(
2011
+ returncode=0,
2012
+ stdout="",
2013
+ stderr="Checked 0 files in 1ms. No fixes applied.\n",
2014
+ )
2015
+ formatted_nothing = MagicMock(
2016
+ returncode=0,
2017
+ stdout="Formatted 0 files in 1ms. No fixes applied.\n",
2018
+ stderr="",
2019
+ )
2020
+
2021
+ runner = CliRunner()
2022
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2023
+ Path(".gitignore").write_text("node_modules/\n")
2024
+ Path("node_modules/example").mkdir(parents=True)
2025
+ Path("node_modules/example/index.js").touch()
2026
+ Path("README.md").touch()
2027
+
2028
+ for extra_args, expected_calls in (([], 2), (["--fix"], 3)):
2029
+ mock_run.side_effect = (
2030
+ [checked_nothing, checked_nothing]
2031
+ if not extra_args
2032
+ else [formatted_nothing, checked_nothing, checked_nothing]
2033
+ )
2034
+ result = runner.invoke(cli, ["check", ".", *extra_args])
2035
+
2036
+ assert result.exit_code == 0
2037
+ output = result.stdout + result.stderr
2038
+ assert "nothing was checked" in output
2039
+ assert "No findings" not in output
2040
+ assert mock_run.call_count == expected_calls
2041
+ mock_run.reset_mock()
2042
+
2043
+
2044
+ @patch("shutil.which")
2045
+ @patch("subprocess.run")
2046
+ def test_biome_zero_file_path_is_not_hidden_by_a_checked_path(
2047
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2048
+ ) -> None:
2049
+ """A clean aggregate identifies requested paths Biome did not inspect."""
2050
+ mock_which.side_effect = lambda x: x if x == "biome" else None
2051
+ checked_files = MagicMock(returncode=0, stdout="", stderr="")
2052
+ checked_nothing = MagicMock(
2053
+ returncode=0,
2054
+ stdout="Checked 0 files in 1ms. No fixes applied.\n",
2055
+ stderr="",
2056
+ )
2057
+
2058
+ runner = CliRunner()
2059
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2060
+ Path(".gitignore").write_text("ignored/\n")
2061
+ Path("src").mkdir()
2062
+ Path("src/good.js").touch()
2063
+ Path("ignored").mkdir()
2064
+ Path("ignored/generated.js").touch()
2065
+ mock_run.side_effect = [
2066
+ checked_files,
2067
+ checked_files,
2068
+ checked_nothing,
2069
+ checked_nothing,
2070
+ ]
2071
+
2072
+ result = runner.invoke(cli, ["check", "src", "ignored"])
2073
+
2074
+ assert result.exit_code == 0
2075
+ assert "nothing was checked for 1 path(s): ignored" in result.stderr
2076
+ assert "No findings in 1 path(s) (biome)." in result.stderr
2077
+ assert "No findings in 2 path(s)" not in result.stderr
2078
+
2079
+
2080
+ @patch("shutil.which")
2081
+ @patch("subprocess.run")
2082
+ def test_biome_zero_file_failure_still_exits_nonzero(
2083
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2084
+ ) -> None:
2085
+ """A configuration failure must not become a successful no-tool result."""
2086
+ mock_which.side_effect = lambda x: x if x == "biome" else None
2087
+ checked_nothing = MagicMock(
2088
+ returncode=1,
2089
+ stdout="Checked 0 files in 1ms.\nConfiguration error.\n",
2090
+ stderr="",
2091
+ )
2092
+ formatted_nothing = MagicMock(
2093
+ returncode=1,
2094
+ stdout="Formatted 0 files in 1ms.\nConfiguration error.\n",
2095
+ stderr="",
2096
+ )
2097
+
2098
+ runner = CliRunner()
2099
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2100
+ Path("script.ts").touch()
2101
+
2102
+ for extra_args in ([], ["--fix"]):
2103
+ mock_run.side_effect = (
2104
+ [checked_nothing, checked_nothing]
2105
+ if not extra_args
2106
+ else [formatted_nothing, checked_nothing, checked_nothing]
2107
+ )
2108
+ result = runner.invoke(cli, ["check", "script.ts", *extra_args])
2109
+
2110
+ assert result.exit_code == 1
2111
+ output = result.stdout + result.stderr
2112
+ assert "biome findings" in output
2113
+ assert "nothing was checked" not in output
2114
+ mock_run.reset_mock()
2115
+
2116
+
2117
+ @patch("shutil.which")
2118
+ @patch("subprocess.run")
2119
+ def test_bundled_biome_applies_to_a_nested_web_file(
2120
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2121
+ ) -> None:
2122
+ """Directory applicability still finds nested web files."""
2123
+ mock_which.side_effect = lambda x: x if x == "biome" else None
2124
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
2125
+
2126
+ runner = CliRunner()
2127
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2128
+ Path("src").mkdir()
2129
+ Path("src/script.ts").touch()
2130
+
2131
+ result = runner.invoke(cli, ["check", "."])
2132
+
2133
+ assert result.exit_code == 0
2134
+ assert "No findings in 1 path(s) (biome)." in result.stderr
1806
2135
  called = [call.args[0][0] for call in mock_run.call_args_list]
1807
- assert "npx" not in called
2136
+ assert called == ["biome", "biome"]
1808
2137
 
1809
2138
 
1810
2139
  @patch("shutil.which")
1811
2140
  @patch("subprocess.run")
1812
- def test_check_without_defaults_respects_its_trigger(
2141
+ def test_check_with_project_config_runs_biome(
1813
2142
  mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
1814
2143
  ) -> None:
1815
2144
  """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.4"
198
198
  source = { editable = "." }
199
199
  dependencies = [
200
200
  { name = "beautifulsoup4" },
File without changes