cli-usereq 0.68.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. cli_usereq-0.68.0.dist-info/METADATA +1134 -0
  2. cli_usereq-0.68.0.dist-info/RECORD +37 -0
  3. cli_usereq-0.68.0.dist-info/WHEEL +5 -0
  4. cli_usereq-0.68.0.dist-info/entry_points.txt +4 -0
  5. cli_usereq-0.68.0.dist-info/licenses/LICENSE +674 -0
  6. cli_usereq-0.68.0.dist-info/top_level.txt +1 -0
  7. usereq/__init__.py +62 -0
  8. usereq/__main__.py +17 -0
  9. usereq/cli.py +4963 -0
  10. usereq/compress.py +393 -0
  11. usereq/compress_files.py +134 -0
  12. usereq/doxygen_parser.py +172 -0
  13. usereq/find_constructs.py +394 -0
  14. usereq/generate_markdown.py +158 -0
  15. usereq/resources/docs/Document_Source_Code_in_Doxygen_Style.md +130 -0
  16. usereq/resources/docs/HDT_Test_Authoring_Guide.md +318 -0
  17. usereq/resources/docs/Requirements_Template.md +78 -0
  18. usereq/resources/prompts/analyze.md +191 -0
  19. usereq/resources/prompts/change.md +279 -0
  20. usereq/resources/prompts/check.md +190 -0
  21. usereq/resources/prompts/cover.md +271 -0
  22. usereq/resources/prompts/create.md +134 -0
  23. usereq/resources/prompts/fix.md +273 -0
  24. usereq/resources/prompts/flowchart.md +272 -0
  25. usereq/resources/prompts/implement.md +194 -0
  26. usereq/resources/prompts/new.md +278 -0
  27. usereq/resources/prompts/readme.md +234 -0
  28. usereq/resources/prompts/recreate.md +265 -0
  29. usereq/resources/prompts/refactor.md +265 -0
  30. usereq/resources/prompts/references.md +131 -0
  31. usereq/resources/prompts/renumber.md +150 -0
  32. usereq/resources/prompts/workflow.md +254 -0
  33. usereq/resources/prompts/write.md +129 -0
  34. usereq/resources/prompts-version.txt +1 -0
  35. usereq/source_analyzer.py +2280 -0
  36. usereq/static_check.py +812 -0
  37. usereq/token_counter.py +123 -0
usereq/static_check.py ADDED
@@ -0,0 +1,812 @@
1
+ """!
2
+ @file static_check.py
3
+ @brief Static code analysis dispatch module implementing Dummy/Pylance/Ruff/Command check classes.
4
+ @details Provides a class hierarchy for running static analysis tools against resolved file lists.
5
+ Exposes `run_static_check(argv)` as the primary entry point for `--test-static-check` called
6
+ from cli.py. Also exposes `parse_enable_static_check`, `dispatch_static_check_for_file`,
7
+ `STATIC_CHECK_LANG_CANONICAL`, and `STATIC_CHECK_EXT_TO_LANG` for `--enable-static-check`,
8
+ `files-static-check` and `static-check` subcommand support.
9
+ Class hierarchy: StaticCheckBase (Dummy) -> StaticCheckPylance, StaticCheckRuff, StaticCheckCommand.
10
+ File resolution supports: explicit file paths, glob patterns (with full `**` recursive expansion),
11
+ and direct-children-only directory traversal. No custom `--recursive` flag; recursive traversal
12
+ is expressed via `**` glob syntax (e.g., `src/**/*.py`).
13
+ @author useReq
14
+ @version 0.0.72
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import glob
20
+ import importlib.util
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ from pathlib import Path
25
+ from typing import List, Optional, Sequence
26
+
27
+ from .cli import ReqError
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Language configuration constants (SRS-258, SRS-259)
32
+ # ---------------------------------------------------------------------------
33
+
34
+ STATIC_CHECK_LANG_CANONICAL: dict[str, str] = {
35
+ "python": "Python",
36
+ "c": "C",
37
+ "c++": "C++",
38
+ "cpp": "C++",
39
+ "c#": "C#",
40
+ "csharp": "C#",
41
+ "rust": "Rust",
42
+ "javascript": "JavaScript",
43
+ "js": "JavaScript",
44
+ "typescript": "TypeScript",
45
+ "ts": "TypeScript",
46
+ "java": "Java",
47
+ "go": "Go",
48
+ "ruby": "Ruby",
49
+ "php": "PHP",
50
+ "swift": "Swift",
51
+ "kotlin": "Kotlin",
52
+ "scala": "Scala",
53
+ "lua": "Lua",
54
+ "shell": "Shell",
55
+ "sh": "Shell",
56
+ "perl": "Perl",
57
+ "haskell": "Haskell",
58
+ "zig": "Zig",
59
+ "elixir": "Elixir",
60
+ }
61
+ """!
62
+ @brief Maps lowercase language identifiers (including aliases) to canonical language names.
63
+ @details Covers all 20 languages from SRS-249 plus common aliases (cpp, csharp, js, ts, sh).
64
+ Canonical names: Python, C, C++, C#, Rust, JavaScript, TypeScript, Java, Go, Ruby,
65
+ PHP, Swift, Kotlin, Scala, Lua, Shell, Perl, Haskell, Zig, Elixir.
66
+ Lookup MUST be performed on `value.strip().lower()` of the user-supplied language token.
67
+ @see SRS-258, SRS-249
68
+ """
69
+
70
+ STATIC_CHECK_EXT_TO_LANG: dict[str, str] = {
71
+ ".py": "Python",
72
+ ".c": "C",
73
+ ".cpp": "C++",
74
+ ".cs": "C#",
75
+ ".rs": "Rust",
76
+ ".js": "JavaScript",
77
+ ".mjs": "JavaScript",
78
+ ".ts": "TypeScript",
79
+ ".java": "Java",
80
+ ".go": "Go",
81
+ ".rb": "Ruby",
82
+ ".php": "PHP",
83
+ ".swift": "Swift",
84
+ ".kt": "Kotlin",
85
+ ".scala": "Scala",
86
+ ".lua": "Lua",
87
+ ".sh": "Shell",
88
+ ".pl": "Perl",
89
+ ".hs": "Haskell",
90
+ ".zig": "Zig",
91
+ ".ex": "Elixir",
92
+ }
93
+ """!
94
+ @brief Maps file extensions (lowercase, dot-prefixed) to canonical language names.
95
+ @details Uses the extension set defined by SRS-131 language coverage, including both `.js` and `.mjs` for JavaScript.
96
+ Lookup MUST be performed on `Path(filepath).suffix.lower()`.
97
+ @see SRS-259, SRS-131
98
+ """
99
+
100
+ #: Canonical module names (lowercase -> display form).
101
+ _CANONICAL_MODULES: dict[str, str] = {
102
+ "dummy": "Dummy",
103
+ "pylance": "Pylance",
104
+ "ruff": "Ruff",
105
+ "command": "Command",
106
+ }
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Configuration parsing helpers (SRS-260)
111
+ # ---------------------------------------------------------------------------
112
+
113
+ def _split_csv_like_tokens(spec_rhs: str) -> list[str]:
114
+ """!
115
+ @brief Split a comma-separated SPEC right-hand side with quote-aware token boundaries.
116
+ @param spec_rhs Text after `LANG=` in `--enable-static-check`.
117
+ @return Token list where commas inside `'...'` or `"..."` do not split tokens.
118
+ @details
119
+ - Supported quote delimiters: single quote `'` and double quote `"`.
120
+ - Commas split tokens only when parser is outside a quoted segment.
121
+ - Quote delimiters are not included in output tokens.
122
+ - Leading and trailing whitespace for each token is stripped.
123
+ @see SRS-260, SRS-250
124
+ """
125
+ tokens: list[str] = []
126
+ current: list[str] = []
127
+ active_quote: str | None = None
128
+
129
+ for ch in spec_rhs:
130
+ if active_quote is None:
131
+ if ch in ("'", '"'):
132
+ active_quote = ch
133
+ elif ch == ",":
134
+ tokens.append("".join(current).strip())
135
+ current = []
136
+ else:
137
+ current.append(ch)
138
+ else:
139
+ if ch == active_quote:
140
+ active_quote = None
141
+ else:
142
+ current.append(ch)
143
+
144
+ tokens.append("".join(current).strip())
145
+ return tokens
146
+
147
+
148
+ def parse_enable_static_check(spec: str) -> tuple[str, dict]:
149
+ """!
150
+ @brief Parse a single `--enable-static-check` SPEC string into a (lang, config_dict) pair.
151
+ @param spec Raw SPEC string in the format `LANG=MODULE[,CMD[,PARAM...]]`.
152
+ @return Tuple `(canonical_lang, config_dict)` where `config_dict` contains `"module"` and
153
+ optionally `"cmd"` (Command only) and `"params"` (non-empty list only).
154
+ @throws ReqError If `=` separator is absent, language is unknown, or module is unknown.
155
+ @details
156
+ Parse steps:
157
+ 1. Split on the first `=`; left side is LANG token, right side is `MODULE[,...]`.
158
+ 2. Normalize LANG via `STATIC_CHECK_LANG_CANONICAL` (case-insensitive).
159
+ 3. Parse right side as comma-separated tokens; first token is MODULE
160
+ (case-insensitive, validated against `_CANONICAL_MODULES`).
161
+ 4. For Command: next token is `cmd` (mandatory); all subsequent tokens are `params`.
162
+ 5. For all other modules: all tokens after MODULE are `params`.
163
+ 6. `params` key is omitted when the list is empty.
164
+ 7. `cmd` key is omitted for non-Command modules.
165
+ 8. Surrounding quote delimiters (`'` or `"`) are stripped from parsed tokens.
166
+ Note: PARAM values containing `,` must be wrapped with `'` or `"` in SPEC.
167
+ @see SRS-260, SRS-248, SRS-249, SRS-250
168
+ """
169
+ if "=" not in spec:
170
+ raise ReqError(
171
+ "Error: --enable-static-check requires format LANG=MODULE[,CMD[,PARAM...]]; "
172
+ "missing '=' separator.",
173
+ 1,
174
+ )
175
+ lang_raw, rest = spec.split("=", 1)
176
+ lang_key = lang_raw.strip().lower()
177
+ if lang_key not in STATIC_CHECK_LANG_CANONICAL:
178
+ valid = ", ".join(sorted({v for v in STATIC_CHECK_LANG_CANONICAL.values()}, key=str.lower))
179
+ raise ReqError(
180
+ f"Error: unknown language '{lang_raw.strip()}' in --enable-static-check. "
181
+ f"Valid language names (case-insensitive): {valid}",
182
+ 1,
183
+ )
184
+ canonical_lang = STATIC_CHECK_LANG_CANONICAL[lang_key]
185
+
186
+ parts = _split_csv_like_tokens(rest)
187
+ if not parts or not parts[0].strip():
188
+ raise ReqError(
189
+ "Error: --enable-static-check requires MODULE after '='. "
190
+ "Valid modules (case-insensitive): Dummy, Pylance, Ruff, Command",
191
+ 1,
192
+ )
193
+
194
+ module_raw = parts[0].strip()
195
+ module_key = module_raw.lower()
196
+ if module_key not in _CANONICAL_MODULES:
197
+ raise ReqError(
198
+ f"Error: unknown module '{module_raw}' in --enable-static-check. "
199
+ "Valid modules (case-insensitive): Dummy, Pylance, Ruff, Command",
200
+ 1,
201
+ )
202
+ module = _CANONICAL_MODULES[module_key]
203
+ remaining = [p.strip() for p in parts[1:]]
204
+
205
+ config: dict = {"module": module}
206
+ if module == "Command":
207
+ if not remaining or not remaining[0]:
208
+ raise ReqError(
209
+ "Error: Command module requires a cmd argument in --enable-static-check. "
210
+ "Format: LANG=Command,CMD[,PARAM...]",
211
+ 1,
212
+ )
213
+ config["cmd"] = remaining[0]
214
+ params = [p for p in remaining[1:] if p]
215
+ if params:
216
+ config["params"] = params
217
+ else:
218
+ params = [p for p in remaining if p]
219
+ if params:
220
+ config["params"] = params
221
+
222
+ return canonical_lang, config
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Per-file dispatch helper (SRS-261)
227
+ # ---------------------------------------------------------------------------
228
+
229
+ def dispatch_static_check_for_file(
230
+ filepath: str,
231
+ lang_config: dict,
232
+ *,
233
+ fail_only: bool = False,
234
+ project_base: Optional[Path] = None,
235
+ ) -> int:
236
+ """!
237
+ @brief Dispatch static-check for a single file based on a language config dict.
238
+ @param filepath Absolute path of the file to analyse.
239
+ @param lang_config Dict with keys `"module"` (str), optional `"cmd"` (str, Command only),
240
+ optional `"params"` (list[str]).
241
+ @param fail_only When True, suppress all stdout output for passing checks (SRS-253, SRS-256).
242
+ @param project_base Absolute project root used for checker runtime context.
243
+ @return Exit code: 0 on pass, 1 on fail.
244
+ @throws ReqError If module is unknown, or Command module is missing `"cmd"`.
245
+ @details
246
+ Module dispatch table:
247
+ - `"Dummy"` (case-insensitive) -> `StaticCheckBase`
248
+ - `"Pylance"` -> `StaticCheckPylance`
249
+ - `"Ruff"` -> `StaticCheckRuff`
250
+ - `"Command"` -> `StaticCheckCommand` (requires `"cmd"` key)
251
+ Instantiates the checker with `inputs=[filepath]`, `extra_args=params`, and `fail_only`.
252
+ For Pylance, forwards `project_base` runtime context.
253
+ Delegates actual check to `checker.run()`.
254
+ @see SRS-261, SRS-253, SRS-256, SRS-341
255
+ """
256
+ module = lang_config.get("module", "")
257
+ params: List[str] = lang_config.get("params", [])
258
+ cmd: Optional[str] = lang_config.get("cmd")
259
+
260
+ module_key = module.lower()
261
+ checker: StaticCheckBase
262
+ if module_key == "dummy":
263
+ checker = StaticCheckBase(inputs=[filepath], extra_args=params, fail_only=fail_only)
264
+ elif module_key == "pylance":
265
+ checker = StaticCheckPylance(
266
+ inputs=[filepath],
267
+ extra_args=params,
268
+ fail_only=fail_only,
269
+ project_base=project_base,
270
+ )
271
+ elif module_key == "ruff":
272
+ checker = StaticCheckRuff(inputs=[filepath], extra_args=params, fail_only=fail_only)
273
+ elif module_key == "command":
274
+ if not cmd:
275
+ raise ReqError(
276
+ f"Error: Command module requires 'cmd' in static-check config for '{filepath}'.",
277
+ 1,
278
+ )
279
+ checker = StaticCheckCommand(cmd=cmd, inputs=[filepath], extra_args=params, fail_only=fail_only)
280
+ else:
281
+ raise ReqError(
282
+ f"Error: unknown static-check module '{module}'. "
283
+ "Valid modules: Dummy, Pylance, Ruff, Command",
284
+ 1,
285
+ )
286
+ return checker.run()
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # File resolution helpers
291
+ # ---------------------------------------------------------------------------
292
+
293
+ def _resolve_files(inputs: Sequence[str]) -> List[str]:
294
+ """!
295
+ @brief Resolve a mixed list of paths, glob patterns, and directories into regular files.
296
+ @param inputs Sequence of raw path strings (file, directory, or glob pattern).
297
+ @return Sorted deduplicated list of resolved absolute file paths (regular files only).
298
+ @details
299
+ Resolution order per element:
300
+ 1. If the element contains a glob wildcard character (`*`, `?`, `[`) expand via
301
+ `glob.glob(entry, recursive=True)`, enabling full `**` recursive expansion
302
+ (e.g., `src/**/*.py` matches all `.py` files under `src/` at any depth).
303
+ 2. If the element is an existing directory, iterate direct children only (flat traversal).
304
+ 3. Otherwise treat as a literal file path; include if it is a regular file.
305
+ Symlinks to regular files are included. Non-existent paths that do not match a glob produce
306
+ a warning on stderr and are skipped.
307
+ """
308
+ resolved: dict[str, None] = {} # ordered set via dict
309
+
310
+ for entry in inputs:
311
+ if any(c in entry for c in ("*", "?", "[")):
312
+ # Glob pattern; recursive=True enables ** expansion (SRS-245)
313
+ matches = glob.glob(entry, recursive=True)
314
+ for m in sorted(matches):
315
+ p = Path(m)
316
+ if p.is_file():
317
+ resolved[str(p.resolve())] = None
318
+ else:
319
+ p = Path(entry)
320
+ if p.is_dir():
321
+ # Direct children only; recursive traversal is expressed via ** glob patterns
322
+ for child in sorted(p.iterdir()):
323
+ if child.is_file():
324
+ resolved[str(child.resolve())] = None
325
+ elif p.is_file():
326
+ resolved[str(p.resolve())] = None
327
+ else:
328
+ print(
329
+ f" Warning: skipping (not found or not a file): {entry}",
330
+ file=sys.stderr,
331
+ )
332
+
333
+ return list(resolved.keys())
334
+
335
+
336
+ # ---------------------------------------------------------------------------
337
+ # Base class
338
+ # ---------------------------------------------------------------------------
339
+
340
+ class StaticCheckBase:
341
+ """!
342
+ @brief Dummy static-check class; base of the static analysis class hierarchy.
343
+ @details Iterates over resolved input files and emits a per-file header line plus `Result: OK`.
344
+ Subclasses override `_check_file` to provide tool-specific logic.
345
+ File resolution is delegated to `_resolve_files`.
346
+ When `fail_only` is True, passing files produce no output (SRS-241, SRS-253, SRS-256).
347
+ """
348
+
349
+ #: Tool label used in the header line. Subclasses override this.
350
+ LABEL: str = "Dummy"
351
+
352
+ def __init__(
353
+ self,
354
+ inputs: Sequence[str],
355
+ extra_args: Optional[Sequence[str]] = None,
356
+ *,
357
+ fail_only: bool = False,
358
+ ) -> None:
359
+ """!
360
+ @brief Initialize the static checker with resolved inputs and options.
361
+ @param inputs Raw path/pattern/directory entries from CLI.
362
+ @param extra_args Additional CLI arguments forwarded to the external tool (may be None).
363
+ @param fail_only When True, suppress all stdout output for passing checks (SRS-241).
364
+ @details Resolves `inputs` immediately into `self._files` via `_resolve_files`.
365
+ Recursive traversal is expressed via `**` glob patterns in `inputs` (e.g., `src/**/*.py`);
366
+ no separate recursive flag exists (SRS-240, SRS-245).
367
+ @return {None} Function return value.
368
+ """
369
+ self._extra_args: List[str] = list(extra_args) if extra_args else []
370
+ self._fail_only: bool = fail_only
371
+ self._files = _resolve_files(inputs)
372
+
373
+ # ------------------------------------------------------------------
374
+ # Public interface
375
+ # ------------------------------------------------------------------
376
+
377
+ def is_available(self) -> tuple[bool, str]:
378
+ """!
379
+ @brief Probe checker availability before execution.
380
+ @return {tuple[bool, str]} Tuple `(available, reason)`; `(True, "")` when available.
381
+ @details Base implementation (Dummy): always available. Subclasses override to
382
+ verify tool module or binary availability (SRS-427).
383
+ @satisfies SRS-427
384
+ """
385
+ return True, ""
386
+
387
+ def run(self) -> int:
388
+ """!
389
+ @brief Execute the static check for all resolved files.
390
+ @return Exit code: 0 if all files pass (or file list is empty), 1 if any file fails.
391
+ @details
392
+ If the resolved file list is empty a warning is printed to stderr and 0 is returned.
393
+ Before any file is checked the SRS-427 pre-flight availability probe runs; when the
394
+ checker is unavailable every file emits header, `Result: FAIL`, English evidence
395
+ identifying the missing checker or binary, and one trailing blank line in both
396
+ `fail_only` modes, the overall exit code is 1, and no exception is raised (SRS-428).
397
+ For each file `_check_file` is called; the overall return code is the maximum of all
398
+ per-file return codes (0 = all OK, 1 = at least one FAIL).
399
+ When `fail_only` is True, trailing blank separator lines are emitted only for failing files.
400
+ @satisfies SRS-427, SRS-428
401
+ """
402
+ if not self._files:
403
+ print(
404
+ " Warning: no files resolved for static check.",
405
+ file=sys.stderr,
406
+ )
407
+ return 0
408
+
409
+ available, reason = self.is_available()
410
+ if not available:
411
+ for filepath in self._files:
412
+ self._emit_line(self._header_line(filepath))
413
+ self._emit_line("Result: FAIL")
414
+ self._emit_line("Evidence:")
415
+ self._emit_line(
416
+ f"Error: static checker {self.LABEL} is not available: {reason}"
417
+ )
418
+ self._emit_line("")
419
+ return 1
420
+
421
+ overall = 0
422
+ for filepath in self._files:
423
+ rc = self._check_file(filepath)
424
+ if rc != 0:
425
+ overall = 1
426
+ if not self._fail_only or rc != 0:
427
+ self._emit_line("")
428
+ return overall
429
+
430
+ # ------------------------------------------------------------------
431
+ # Internal helpers
432
+ # ------------------------------------------------------------------
433
+
434
+ def _header_line(self, filepath: str) -> str:
435
+ """!
436
+ @brief Build the per-file header line for output.
437
+ @param filepath Absolute path of the file being checked.
438
+ @return Formatted header string including label, filename, and extra args.
439
+ @details Format: `# Static-Check(<LABEL>): <filepath> [<extra_args>]`.
440
+ When `extra_args` is empty the bracket section is omitted.
441
+ """
442
+ opts = f" [{' '.join(self._extra_args)}]" if self._extra_args else ""
443
+ return f"# Static-Check({self.LABEL}): {filepath}{opts}"
444
+
445
+ def _check_file(self, filepath: str) -> int:
446
+ """!
447
+ @brief Perform the static analysis for a single file.
448
+ @param filepath Absolute path of the file to check.
449
+ @return 0 on pass, non-zero on failure.
450
+ @details Base implementation (Dummy): always passes.
451
+ When `fail_only` is False, prints the header and `Result: OK`.
452
+ When `fail_only` is True, produces no output (SRS-241).
453
+ Subclasses override this method to invoke external tools.
454
+ """
455
+ if not self._fail_only:
456
+ self._emit_line(self._header_line(filepath))
457
+ self._emit_line("Result: OK")
458
+ return 0
459
+
460
+ def _emit_line(self, line: str) -> None:
461
+ """!
462
+ @brief Emit one markdown output line.
463
+ @param line Line content to emit on stdout.
464
+ @details Emits `line` followed by a newline.
465
+ @return {None} Function return value.
466
+ """
467
+ print(line)
468
+
469
+
470
+ # ---------------------------------------------------------------------------
471
+ # Pylance (pyright) checker
472
+ # ---------------------------------------------------------------------------
473
+
474
+ class StaticCheckPylance(StaticCheckBase):
475
+ """!
476
+ @brief Pylance static-check class; runs pyright on each resolved file via `sys.executable -m pyright`.
477
+ @details Derived from `StaticCheckBase`; overrides `_check_file` to invoke `pyright`
478
+ via `[sys.executable, '-m', 'pyright']` subprocess using the active runtime interpreter,
479
+ without requiring external PATH availability, and parse its exit code.
480
+ Header label: `Pylance`.
481
+ Evidence block is emitted on failure by concatenating stdout and stderr from pyright.
482
+ @see StaticCheckBase
483
+ @satisfies SRS-242, SRS-339, SRS-341
484
+ """
485
+
486
+ LABEL = "Pylance"
487
+
488
+ def is_available(self) -> tuple[bool, str]:
489
+ """!
490
+ @brief Probe pyright module availability in the active interpreter.
491
+ @return {tuple[bool, str]} `(False, reason)` when `importlib.util.find_spec('pyright')`
492
+ is `None`; `(True, "")` otherwise.
493
+ @details Availability matches the `[sys.executable, '-m', 'pyright']` dispatch exactly:
494
+ the module must be importable by the running interpreter (SRS-427).
495
+ @satisfies SRS-427
496
+ """
497
+ if importlib.util.find_spec("pyright") is None:
498
+ return (
499
+ False,
500
+ f"module 'pyright' is not installed for the active interpreter '{sys.executable}'",
501
+ )
502
+ return True, ""
503
+
504
+ def __init__(
505
+ self,
506
+ inputs: Sequence[str],
507
+ extra_args: Optional[Sequence[str]] = None,
508
+ *,
509
+ fail_only: bool = False,
510
+ project_base: Optional[Path] = None,
511
+ ) -> None:
512
+ """!
513
+ @brief Initialize Pylance checker with runtime context.
514
+ @param inputs Raw path/pattern/directory entries from CLI.
515
+ @param extra_args Additional CLI arguments forwarded to pyright.
516
+ @param fail_only When True, suppress all stdout output for passing checks.
517
+ @param project_base Absolute project root available as runtime context.
518
+ @return {None} Function return value.
519
+ @details Stores runtime context for uv-first pyright invocation. No `.venv` probing is used.
520
+ @satisfies SRS-242, SRS-339, SRS-341
521
+ """
522
+ super().__init__(inputs=inputs, extra_args=extra_args, fail_only=fail_only)
523
+ self._project_base = project_base.resolve() if project_base is not None else None
524
+
525
+ def _check_file(self, filepath: str) -> int:
526
+ """!
527
+ @brief Run pyright on `filepath` via `sys.executable -m pyright` and emit OK or FAIL with evidence.
528
+ @param filepath Absolute path of the file to analyse with pyright.
529
+ @return 0 when pyright exits 0, 1 otherwise.
530
+ @details
531
+ Invokes `[sys.executable, '-m', 'pyright', '--pythonpath', sys.executable,
532
+ <filepath>, <extra_args>...]` to use the active runtime interpreter without requiring
533
+ external PATH availability or project `.venv` detection.
534
+ No `--extra-path` argument is passed.
535
+ Captures combined stdout+stderr.
536
+ When `fail_only` is False: prints header, then `Result: OK` or `Result: FAIL` with evidence.
537
+ When `fail_only` is True: on pass produces no output; on fail emits header, FAIL, evidence (SRS-242).
538
+ @exception ReqError Not raised; subprocess errors are surfaced as FAIL evidence.
539
+ @satisfies SRS-242, SRS-339, SRS-341
540
+ """
541
+ cmd = [
542
+ sys.executable,
543
+ "-m",
544
+ "pyright",
545
+ "--pythonpath",
546
+ sys.executable,
547
+ filepath,
548
+ *self._extra_args,
549
+ ]
550
+ try:
551
+ result = subprocess.run(
552
+ cmd,
553
+ capture_output=True,
554
+ text=True,
555
+ )
556
+ except FileNotFoundError:
557
+ self._emit_line(self._header_line(filepath))
558
+ self._emit_line("Result: FAIL")
559
+ self._emit_line("Evidence:")
560
+ self._emit_line(" pyright module not available via sys.executable")
561
+ return 1
562
+
563
+ if result.returncode == 0:
564
+ if not self._fail_only:
565
+ self._emit_line(self._header_line(filepath))
566
+ self._emit_line("Result: OK")
567
+ return 0
568
+ else:
569
+ self._emit_line(self._header_line(filepath))
570
+ self._emit_line("Result: FAIL")
571
+ self._emit_line("Evidence:")
572
+ evidence = (result.stdout or "") + (result.stderr or "")
573
+ self._emit_line(evidence.rstrip())
574
+ return 1
575
+
576
+
577
+ # ---------------------------------------------------------------------------
578
+ # Ruff checker
579
+ # ---------------------------------------------------------------------------
580
+
581
+ class StaticCheckRuff(StaticCheckBase):
582
+ """!
583
+ @brief Ruff static-check class; runs `ruff check` on each resolved file via `sys.executable -m ruff`.
584
+ @details Derived from `StaticCheckBase`; overrides `_check_file` to invoke `ruff check`
585
+ via `[sys.executable, '-m', 'ruff', 'check']` subprocess using the package-installed ruff module,
586
+ without requiring external PATH availability, and parse its exit code.
587
+ Header label: `Ruff`.
588
+ Evidence block is emitted on failure by concatenating stdout and stderr from ruff.
589
+ @see StaticCheckBase
590
+ @satisfies SRS-243, SRS-339
591
+ """
592
+
593
+ LABEL = "Ruff"
594
+
595
+ def is_available(self) -> tuple[bool, str]:
596
+ """!
597
+ @brief Probe ruff module availability in the active interpreter.
598
+ @return {tuple[bool, str]} `(False, reason)` when `importlib.util.find_spec('ruff')`
599
+ is `None`; `(True, "")` otherwise.
600
+ @details Availability matches the `[sys.executable, '-m', 'ruff']` dispatch exactly:
601
+ the module must be importable by the running interpreter (SRS-427).
602
+ @satisfies SRS-427
603
+ """
604
+ if importlib.util.find_spec("ruff") is None:
605
+ return (
606
+ False,
607
+ f"module 'ruff' is not installed for the active interpreter '{sys.executable}'",
608
+ )
609
+ return True, ""
610
+
611
+ def _check_file(self, filepath: str) -> int:
612
+ """!
613
+ @brief Run `ruff check` on `filepath` via `sys.executable -m ruff` and emit OK or FAIL with evidence.
614
+ @param filepath Absolute path of the file to analyse with ruff.
615
+ @return 0 when ruff exits 0, 1 otherwise.
616
+ @details
617
+ Invokes `[sys.executable, '-m', 'ruff', 'check', <filepath>, <extra_args>...]` to use the
618
+ package-installed ruff module without requiring external PATH availability.
619
+ Captures combined stdout+stderr.
620
+ When `fail_only` is False: prints header, then `Result: OK` or `Result: FAIL` with evidence.
621
+ When `fail_only` is True: on pass produces no output; on fail emits header, FAIL, evidence (SRS-243).
622
+ @exception ReqError Not raised; subprocess errors are surfaced as FAIL evidence.
623
+ @satisfies SRS-243, SRS-339
624
+ """
625
+ cmd = [sys.executable, "-m", "ruff", "check", filepath] + self._extra_args
626
+ try:
627
+ result = subprocess.run(
628
+ cmd,
629
+ capture_output=True,
630
+ text=True,
631
+ )
632
+ except FileNotFoundError:
633
+ self._emit_line(self._header_line(filepath))
634
+ self._emit_line("Result: FAIL")
635
+ self._emit_line("Evidence:")
636
+ self._emit_line(" ruff module not available via sys.executable")
637
+ return 1
638
+
639
+ if result.returncode == 0:
640
+ if not self._fail_only:
641
+ self._emit_line(self._header_line(filepath))
642
+ self._emit_line("Result: OK")
643
+ return 0
644
+ else:
645
+ self._emit_line(self._header_line(filepath))
646
+ self._emit_line("Result: FAIL")
647
+ self._emit_line("Evidence:")
648
+ evidence = (result.stdout or "") + (result.stderr or "")
649
+ self._emit_line(evidence.rstrip())
650
+ return 1
651
+
652
+
653
+ # ---------------------------------------------------------------------------
654
+ # Arbitrary external command checker
655
+ # ---------------------------------------------------------------------------
656
+
657
+ class StaticCheckCommand(StaticCheckBase):
658
+ """!
659
+ @brief Command static-check class; runs an arbitrary external command on each resolved file.
660
+ @details Derived from `StaticCheckBase`; overrides `_check_file` to invoke the user-supplied
661
+ `cmd` as a subprocess.
662
+ Header label: `Command[<cmd>]`.
663
+ `cmd` availability is resolved at execution time by the SRS-427 pre-flight via
664
+ `shutil.which`; an unavailable `cmd` yields a per-file `Result: FAIL` outcome with
665
+ English evidence and execution continues (SRS-428); the constructor does not raise.
666
+ @see StaticCheckBase
667
+ @satisfies SRS-427, SRS-428
668
+ """
669
+
670
+ def __init__(
671
+ self,
672
+ cmd: str,
673
+ inputs: Sequence[str],
674
+ extra_args: Optional[Sequence[str]] = None,
675
+ *,
676
+ fail_only: bool = False,
677
+ ) -> None:
678
+ """!
679
+ @brief Initialize the command checker.
680
+ @param cmd External command name (availability resolved by the SRS-427 pre-flight).
681
+ @param inputs Raw path/pattern/directory entries from CLI.
682
+ @param extra_args Additional CLI arguments forwarded to the external command.
683
+ @param fail_only When True, suppress all stdout output for passing checks (SRS-244).
684
+ @details Stores `cmd` and sets `LABEL` dynamically to `Command[<cmd>]`.
685
+ @return {None} Function return value.
686
+ """
687
+ self._cmd = cmd
688
+ self.LABEL = f"Command[{cmd}]"
689
+ super().__init__(inputs=inputs, extra_args=extra_args, fail_only=fail_only)
690
+
691
+ def is_available(self) -> tuple[bool, str]:
692
+ """!
693
+ @brief Probe the external command binary on PATH.
694
+ @return {tuple[bool, str]} `(False, reason)` when `shutil.which(self._cmd)` is `None`;
695
+ `(True, "")` otherwise.
696
+ @details Availability of the checker binary is required before any subprocess dispatch
697
+ (SRS-427); unavailability is reported as FAIL evidence without raising (SRS-428).
698
+ @satisfies SRS-427, SRS-428
699
+ """
700
+ if shutil.which(self._cmd) is None:
701
+ return False, f"command '{self._cmd}' is not available on PATH"
702
+ return True, ""
703
+
704
+ def _check_file(self, filepath: str) -> int:
705
+ """!
706
+ @brief Run the external command on `filepath` and emit OK or FAIL with evidence.
707
+ @param filepath Absolute path of the file to analyse.
708
+ @return 0 when the command exits 0, 1 otherwise.
709
+ @details
710
+ Invokes `<cmd> [extra_args...] <filepath>`.
711
+ Captures combined stdout+stderr.
712
+ When `fail_only` is False: prints header, then `Result: OK` or `Result: FAIL` with evidence.
713
+ When `fail_only` is True: on pass produces no output; on fail emits header, FAIL, evidence (SRS-244).
714
+ @satisfies SRS-244, SRS-253, SRS-256
715
+ """
716
+ cmd = [self._cmd] + self._extra_args + [filepath]
717
+ try:
718
+ result = subprocess.run(
719
+ cmd,
720
+ capture_output=True,
721
+ text=True,
722
+ )
723
+ except FileNotFoundError:
724
+ self._emit_line(self._header_line(filepath))
725
+ self._emit_line("Result: FAIL")
726
+ self._emit_line("Evidence:")
727
+ self._emit_line(f" command '{self._cmd}' not found on PATH")
728
+ return 1
729
+
730
+ if result.returncode == 0:
731
+ if not self._fail_only:
732
+ self._emit_line(self._header_line(filepath))
733
+ self._emit_line("Result: OK")
734
+ return 0
735
+ else:
736
+ self._emit_line(self._header_line(filepath))
737
+ self._emit_line("Result: FAIL")
738
+ self._emit_line("Evidence:")
739
+ evidence = (result.stdout or "") + (result.stderr or "")
740
+ self._emit_line(evidence.rstrip())
741
+ return 1
742
+
743
+
744
+ # ---------------------------------------------------------------------------
745
+ # CLI dispatcher
746
+ # ---------------------------------------------------------------------------
747
+
748
+ def run_static_check(argv: Sequence[str]) -> int:
749
+ """!
750
+ @brief Parse `--test-static-check` sub-argv and dispatch to the appropriate checker class.
751
+ @param argv Remaining argument tokens after `--test-static-check` (i.e. [subcommand, ...]).
752
+ @return Process exit code from the selected checker's `run()` method.
753
+ @throws ReqError If subcommand is missing, unknown, or `command` subcommand is missing `cmd`.
754
+ @details
755
+ Expected argument format:
756
+ - `dummy [FILES...]`
757
+ - `pylance [FILES...]`
758
+ - `ruff [FILES...]`
759
+ - `command <cmd> [FILES...]`
760
+
761
+ No custom `--recursive` flag is parsed; recursive traversal is expressed via `**` glob
762
+ patterns in `[FILES]` (e.g., `src/**/*.py`).
763
+ For `command`, the first token after `command` is treated as `<cmd>`.
764
+ All remaining tokens (after subcommand and optional cmd) are treated as FILES.
765
+
766
+ Dispatches to:
767
+ - `dummy` -> `StaticCheckBase`
768
+ - `pylance` -> `StaticCheckPylance`
769
+ - `ruff` -> `StaticCheckRuff`
770
+ - `command` -> `StaticCheckCommand`
771
+ """
772
+ tokens = list(argv)
773
+ if not tokens:
774
+ raise ReqError(
775
+ "Error: --test-static-check requires a subcommand: dummy, pylance, ruff, command.",
776
+ 1,
777
+ )
778
+
779
+ subcommand = tokens.pop(0)
780
+ remaining = tokens
781
+
782
+ if subcommand == "dummy":
783
+ checker: StaticCheckBase = StaticCheckBase(
784
+ inputs=remaining,
785
+ )
786
+ elif subcommand == "pylance":
787
+ checker = StaticCheckPylance(
788
+ inputs=remaining,
789
+ )
790
+ elif subcommand == "ruff":
791
+ checker = StaticCheckRuff(
792
+ inputs=remaining,
793
+ )
794
+ elif subcommand == "command":
795
+ if not remaining:
796
+ raise ReqError(
797
+ "Error: --test-static-check command requires a <cmd> argument.", 1
798
+ )
799
+ cmd = remaining[0]
800
+ remaining = remaining[1:]
801
+ checker = StaticCheckCommand(
802
+ cmd=cmd,
803
+ inputs=remaining,
804
+ )
805
+ else:
806
+ raise ReqError(
807
+ f"Error: unknown --test-static-check subcommand '{subcommand}'. "
808
+ "Valid subcommands: dummy, pylance, ruff, command.",
809
+ 1,
810
+ )
811
+
812
+ return checker.run()