Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/governance/RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions src/specsmith/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/specsmith/templates/governance/rules.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/specsmith/templates/python/pyproject.toml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
92 changes: 92 additions & 0 deletions src/specsmith/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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()
Expand All @@ -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
Loading