From acda11b62b6aa74488a7234dc53cee42c87aec23 Mon Sep 17 00:00:00 2001 From: Tristen Pierson Date: Wed, 20 May 2026 15:14:01 -0400 Subject: [PATCH] =?UTF-8?q?feat(validate+watch):=20H23=20bare-sleep=20rule?= =?UTF-8?q?=20+=20watchdog=20hint=20=E2=80=94=20closes=20#184=20#182?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #184 — H23: No Bare Sleep Delays in Scripts - Add H23 to docs/governance/RULES.md and rules.md.j2 scaffold template - Add _check_bare_sleep() to validator.py: scans .sh/.ps1/.cmd/.bash for bare sleep/Start-Sleep without any loop/retry context (for/while/ until/max_retries/retry). Flags as a warning; files with loop context are exempt (sleep inside a polling loop is intentional). - Wire into run_validate() alongside _check_blocking_loops() #182 — watchdog recommendation - Improve specsmith watch message: shows interval, explains how to add watchdog>=4.0 to pyproject.toml [project.optional-dependencies].dev - Add watchdog>=4.0 to the pyproject.toml.j2 scaffold template so new projects get it in their dev extras automatically Co-Authored-By: Oz --- docs/governance/RULES.md | 18 ++++ src/specsmith/cli.py | 9 +- .../templates/governance/rules.md.j2 | 15 +++ .../templates/python/pyproject.toml.j2 | 7 ++ src/specsmith/validator.py | 92 +++++++++++++++++++ 5 files changed, 139 insertions(+), 2 deletions(-) diff --git a/docs/governance/RULES.md b/docs/governance/RULES.md index 1e0caa7..9d3b785 100644 --- a/docs/governance/RULES.md +++ b/docs/governance/RULES.md @@ -182,6 +182,24 @@ does NOT constitute cross-platform coverage. - `specsmith validate --strict` checks for the presence of a `.github/workflows/*.yml` file and emits a warning (H22) when none is found. +### H23 — No Bare Sleep Delays in Scripts +Scripts MUST NOT use `sleep N`, `Start-Sleep`, or equivalent blocking waits as a +standalone timing mechanism without a guaranteed exit path. + +Every wait that depends on an external condition MUST use a polling loop with: +1. A **maximum iteration count** or **wall-clock timeout** +2. A **non-zero exit code** on timeout +3. An **explicit sleep interval** inside the loop body + +Bare `sleep N; command` is the root cause of hung CI pipelines, stuck terminal sessions, +and zombie processes when the awaited condition never arrives. + +**Applies to:** `.sh`, `.bash`, `.ps1`, `.cmd`, `.bat`, CI `run:` blocks, Makefile recipes. +**Does NOT apply to:** production source code (`time.sleep`, `thread::sleep`, etc.). + +`specsmith validate` checks scripts under `scripts/` and the project root for bare sleep +patterns without adjacent loop/retry constructs and emits a warning (H23). + --- ## Governance Invariants for ESDB and Context Management diff --git a/src/specsmith/cli.py b/src/specsmith/cli.py index 9c8e6a5..aad1822 100644 --- a/src/specsmith/cli.py +++ b/src/specsmith/cli.py @@ -4778,8 +4778,13 @@ def watch_cmd(project_dir: str, interval: int, no_notify: bool) -> None: _has_watchdog = False if not _has_watchdog: console.print( - "[dim]watchdog not installed — using polling mode. " - "For faster detection: pip install watchdog[/dim]\n" + "[yellow]⚠[/yellow] [dim]watchdog not installed — using polling mode " + f"({interval}s interval).[/dim]\n" + " [dim]For native filesystem events, add [bold]watchdog>=4.0[/bold] to your " + "project's dev extras:[/dim]\n" + " [dim] pip install watchdog[/dim]\n" + " [dim] or add to pyproject.toml: " + '[bold][project.optional-dependencies] dev = ["watchdog>=4.0"][/bold][/dim]\n' ) from specsmith.auditor import run_audit diff --git a/src/specsmith/templates/governance/rules.md.j2 b/src/specsmith/templates/governance/rules.md.j2 index 26312c8..a221bc5 100644 --- a/src/specsmith/templates/governance/rules.md.j2 +++ b/src/specsmith/templates/governance/rules.md.j2 @@ -55,6 +55,21 @@ All proposals MUST state their epistemic boundaries. A proposal without explicit Hidden assumptions are not acceptable. Declare all epistemic boundaries in the `Assumptions:` field of every proposal. +### H23 — No Bare Sleep Delays in Scripts +Scripts MUST NOT use `sleep N`, `Start-Sleep`, or equivalent blocking waits as a +standalone timing mechanism without a guaranteed exit path. + +Every wait that depends on an external condition MUST use a polling loop with: +1. A **maximum iteration count** or **wall-clock timeout** +2. A **non-zero exit code** on timeout +3. An **explicit sleep interval** inside the loop body + +**Applies to:** `.sh`, `.bash`, `.ps1`, `.cmd`, `.bat`, CI `run:` blocks, Makefile recipes. +**Does NOT apply to:** production source code (`time.sleep`, `thread::sleep`, etc.). + +`specsmith validate` checks scripts for bare sleep patterns without loop/retry constructs +and emits a warning (H23). + --- ## Stop Conditions diff --git a/src/specsmith/templates/python/pyproject.toml.j2 b/src/specsmith/templates/python/pyproject.toml.j2 index 32bc3a4..3d7cff3 100644 --- a/src/specsmith/templates/python/pyproject.toml.j2 +++ b/src/specsmith/templates/python/pyproject.toml.j2 @@ -14,5 +14,12 @@ license = {text = "MIT"} {{ project.package_name }} = "{{ project.package_name }}.cli:main" {% endif %} +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "ruff>=0.4", + "watchdog>=4.0", # specsmith watch — native filesystem events +] + [tool.setuptools.packages.find] where = ["src"] diff --git a/src/specsmith/validator.py b/src/specsmith/validator.py index 5017949..c7a660f 100644 --- a/src/specsmith/validator.py +++ b/src/specsmith/validator.py @@ -63,6 +63,25 @@ def valid(self) -> bool: # Script file extensions to scan (exclude general source dirs to avoid false positives) _SCRIPT_EXTENSIONS = {".sh", ".cmd", ".ps1", ".bash"} +# Bare-sleep patterns: standalone timing delays that block indefinitely (H23) +# Applies to scripts only — NOT to general source code. +_BARE_SLEEP_PATTERNS = ( + re.compile(r"^\s*sleep\s+\d", re.MULTILINE | re.IGNORECASE), # bash: sleep N + re.compile(r"^\s*Start-Sleep\b", re.MULTILINE | re.IGNORECASE), # PowerShell +) + +# Loop / retry constructs that justify a sleep — if any are present the sleep +# is inside a polling loop and should not be flagged. +_SLEEP_LOOP_CONTEXT = ( + re.compile(r"\bfor\b"), + re.compile(r"\bwhile\b"), + re.compile(r"\buntil\b"), + re.compile(r"max_retries", re.IGNORECASE), + re.compile(r"max_attempts", re.IGNORECASE), + re.compile(r"\bretry\b", re.IGNORECASE), + re.compile(r"\bseq\s+\d"), # bash `for i in $(seq N)` +) + def _check_scaffold_yml(root: Path) -> list[ValidationResult]: """Check that scaffold.yml exists and is valid YAML.""" @@ -333,6 +352,78 @@ def _check_blocking_loops(root: Path) -> list[ValidationResult]: return results +def _check_bare_sleep(root: Path) -> list[ValidationResult]: + """Scan script files for bare sleep delays without a loop/retry context (H23). + + A bare sleep is a ``sleep N`` / ``Start-Sleep`` line that appears in a script + file that contains NO loop or retry constructs. This is a timing anti-pattern + that blocks indefinitely when the awaited condition never arrives. + + Files with a loop/retry context (``for``, ``while``, ``until``, ``max_retries``) + are skipped — the sleep is part of a legitimate polling loop in those cases. + + Emits a warning (not a hard error) to allow gradual adoption. + """ + results: list[ValidationResult] = [] + + candidates: list[Path] = [] + for path in root.iterdir(): + if path.is_file() and path.suffix.lower() in _SCRIPT_EXTENSIONS: + candidates.append(path) + scripts_dir = root / "scripts" + if scripts_dir.is_dir(): + for path in scripts_dir.rglob("*"): + if path.is_file() and path.suffix.lower() in _SCRIPT_EXTENSIONS: + candidates.append(path) + + if not candidates: + return results + + flagged: list[str] = [] + for script_path in candidates: + try: + text = script_path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + + has_sleep = any(p.search(text) for p in _BARE_SLEEP_PATTERNS) + if not has_sleep: + continue + + # Sleep inside a loop/retry context is acceptable + has_loop_context = any(p.search(text) for p in _SLEEP_LOOP_CONTEXT) + if not has_loop_context: + try: + rel = script_path.relative_to(root) + except ValueError: + rel = script_path + flagged.append(str(rel)) + + if flagged: + for name in flagged: + results.append( + ValidationResult( + name=f"bare-sleep:{name}", + passed=False, + message=( + f"{name}: bare sleep delay without a polling loop (H23 warning). " + "Replace with a retry loop that has a max iteration count and " + "non-zero exit on timeout." + ), + ) + ) + else: + results.append( + ValidationResult( + name="bare-sleep", + passed=True, + message=f"{len(candidates)} script file(s) checked — no bare sleep delays found", + ) + ) + + return results + + def run_validate(root: Path) -> ValidationReport: """Run all validation checks and return a report.""" report = ValidationReport() @@ -341,4 +432,5 @@ def run_validate(root: Path) -> ValidationReport: report.results.extend(_check_req_ids_unique(root)) report.results.extend(_check_architecture_reqs(root)) report.results.extend(_check_blocking_loops(root)) + report.results.extend(_check_bare_sleep(root)) return report