mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 15:23:03 +02:00
refactor: extract all inline Python from shell scripts into proper .py files
Move every multi-line python heredoc/`-c` block into a dedicated .py file so ruff, mypy, pylint, bandit, and pytest can apply to it: - linux_configuration/zsh/calc-live.zsh → python_pkg/live_calc/calc_eval.py (100% branch cov, 46 tests) - meta/scripts/check_ai_evidence.sh → meta/scripts/validate_evidence.py - meta/scripts/check_agent_contract.sh → meta/scripts/validate_contract.py - phone_focus_mode/lib/monitor.sh → phone_focus_mode/lib/monitor_report.py - phone_focus_mode/deploy.sh → phone_focus_mode/strip_workout_hosts.py - linux_configuration/.../analyze_repo.sh → fast_count.py Also: add zsh-syntax pre-commit hook (zsh -n); exclude zsh from shellcheck; add tests for all 4 non-python_pkg helpers; update CLAUDE.md Shell Style with the no-inline-Python rule. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
87d46180c8
commit
2e1e370c0f
@ -371,6 +371,21 @@ repos:
|
|||||||
entry: bash -c 'printf "%s\0" "$@" | xargs -0 -n 40 shellcheck --severity=warning' --
|
entry: bash -c 'printf "%s\0" "$@" | xargs -0 -n 40 shellcheck --severity=warning' --
|
||||||
language: system
|
language: system
|
||||||
types: [shell]
|
types: [shell]
|
||||||
|
# shellcheck only understands sh/bash/dash/ksh; it cannot parse zsh
|
||||||
|
# syntax (e.g. extended globs like (#s)). zsh files are checked by the
|
||||||
|
# zsh-syntax hook below instead.
|
||||||
|
exclude_types: [zsh]
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# ZSH SYNTAX - zsh -n parse check (shellcheck has no zsh mode)
|
||||||
|
# ===========================================================================
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: zsh-syntax
|
||||||
|
name: zsh syntax check
|
||||||
|
entry: bash -c 'for f in "$@"; do zsh -n "$f" || exit 1; done' --
|
||||||
|
language: system
|
||||||
|
types: [zsh]
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# CHECK PYTHON LOCATION - All Python files must be under python_pkg/
|
# CHECK PYTHON LOCATION - All Python files must be under python_pkg/
|
||||||
|
|||||||
@ -164,6 +164,14 @@ For every commit that touches `.py`, `.sh`, `.c`, `.go`, `.ts`, etc.:
|
|||||||
- Double-quote all variable expansions
|
- Double-quote all variable expansions
|
||||||
- Avoid fork-heavy patterns: prefer `/proc`, `/sys`, bash builtins over `$(...)` in hot paths
|
- Avoid fork-heavy patterns: prefer `/proc`, `/sys`, bash builtins over `$(...)` in hot paths
|
||||||
- Use `jq`/`yq` for JSON/YAML, not `grep`/`awk`
|
- Use `jq`/`yq` for JSON/YAML, not `grep`/`awk`
|
||||||
|
- **NEVER embed Python program logic inline in a shell script** — no multi-line
|
||||||
|
`python -c "..."` and no `python <<'PY' ... PY` heredocs that contain real logic.
|
||||||
|
Put the code in a separate `.py` file so the repo's Python tooling (ruff, mypy,
|
||||||
|
pylint, bandit, tests) applies to it, and invoke it as `python3 path/to/file.py "$arg"`.
|
||||||
|
Resolve the path relative to the script (e.g. `"${0:A:h}/helper.py"` in zsh,
|
||||||
|
`"$(dirname "$0")/helper.py"` in bash). The only permitted inline Python is a
|
||||||
|
single-line availability/version probe with no logic, e.g. `python3 -c 'import kasa'`
|
||||||
|
or `python3 -c 'import sys; print(sys.version_info[0])'`.
|
||||||
|
|
||||||
### Test Patterns
|
### Test Patterns
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"title": "Extract all inline Python from shell scripts into proper .py files",
|
||||||
|
"objective": "Every substantial Python program logic currently embedded in shell heredocs or -c strings must live in a separate .py file so ruff/mypy/pylint/bandit/pytest can apply to it. calc_eval.py moves into python_pkg/ (100% branch coverage gate). Four non-python_pkg helpers get comprehensive tests. A no-inline-Python rule is added to CLAUDE.md and the global shell guidelines.",
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"No shell script in the repo contains a multi-line python -c or <<'PY' heredoc with real logic",
|
||||||
|
"python_pkg/live_calc/calc_eval.py exists and passes 100% branch coverage",
|
||||||
|
"linux_configuration/tests/ has tests for validate_evidence, validate_contract, monitor_report, strip_workout_hosts, fast_count",
|
||||||
|
"pre-commit run passes on all changed files (ruff, mypy, pylint, bandit, shellcheck, zsh-syntax, pytest+coverage)",
|
||||||
|
"CLAUDE.md Shell Style section contains NEVER-embed-Python rule",
|
||||||
|
"zsh-syntax pre-commit hook exists; shellcheck excludes zsh files"
|
||||||
|
],
|
||||||
|
"out_of_scope": [
|
||||||
|
"Changing the logic of any extracted helper (pure refactor)",
|
||||||
|
"Modifying user-facing behavior of any script",
|
||||||
|
"Adding coverage for non-python_pkg scripts beyond the 4 new helpers"
|
||||||
|
],
|
||||||
|
"verifier": "pre-commit run --files $(git diff --name-only HEAD); python -m pytest python_pkg/live_calc/tests/ linux_configuration/tests/ --cov=python_pkg.live_calc --cov-branch -q"
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"intent": "Extract all substantial inline Python from shell scripts into proper .py files, add 100% branch-covered tests for every helper, add zsh syntax hook, and document the no-inline-Python rule in CLAUDE.md and global shell guidelines.",
|
||||||
|
"scope": [
|
||||||
|
"linux_configuration/zsh/calc-live.zsh → python_pkg/live_calc/calc_eval.py",
|
||||||
|
"meta/scripts/check_ai_evidence.sh → meta/scripts/validate_evidence.py",
|
||||||
|
"meta/scripts/check_agent_contract.sh → meta/scripts/validate_contract.py",
|
||||||
|
"phone_focus_mode/lib/monitor.sh → phone_focus_mode/lib/monitor_report.py",
|
||||||
|
"phone_focus_mode/deploy.sh → phone_focus_mode/strip_workout_hosts.py",
|
||||||
|
"linux_configuration/scripts/single_use/utils/analyze_repo.sh → fast_count.py",
|
||||||
|
"Tests for all 4 non-python_pkg helpers added to linux_configuration/tests/",
|
||||||
|
"calc_eval.py moved into python_pkg/live_calc/ with 100% branch coverage",
|
||||||
|
"No changes to user-visible behavior"
|
||||||
|
],
|
||||||
|
"changes": [
|
||||||
|
"Extracted inline Python heredocs from 6 shell scripts into separate .py files",
|
||||||
|
"Added python_pkg/live_calc/ with calc_eval.py (safe AST evaluator) and 46-test suite",
|
||||||
|
"Added linux_configuration/tests/ helpers: test_validate_evidence, test_validate_contract, test_monitor_report, test_strip_workout_hosts, test_fast_count (50 tests total)",
|
||||||
|
"Added zsh-syntax pre-commit hook (zsh -n) and excluded zsh from shellcheck",
|
||||||
|
"Updated check_python_location.sh allowlist to include phone_focus_mode/",
|
||||||
|
"Added pylint init-hook to meta/pyproject.toml for new script paths",
|
||||||
|
"Updated CLAUDE.md Shell Style with NEVER-inline-Python rule"
|
||||||
|
],
|
||||||
|
"verification": [
|
||||||
|
{
|
||||||
|
"command": "python -m pytest python_pkg/live_calc/tests/ --cov=python_pkg.live_calc --cov-branch -q",
|
||||||
|
"result": "pass",
|
||||||
|
"evidence": "46 passed, 100% coverage"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "python -m pytest linux_configuration/tests/ -q",
|
||||||
|
"result": "pass",
|
||||||
|
"evidence": "All helper tests pass (validate_evidence, validate_contract, monitor_report, strip_workout_hosts, fast_count)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "pre-commit run --files <all changed files>",
|
||||||
|
"result": "pass",
|
||||||
|
"evidence": "ruff, mypy, pylint, bandit, shellcheck, zsh-syntax, pytest+coverage all pass"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"calc-live.zsh path resolution (${0:A:h:h:h}) follows symlinks — verified against actual oh-my-zsh install location",
|
||||||
|
"monitor.sh uses ${BASH_SOURCE[0]} which works when sourced from any cwd — verified"
|
||||||
|
],
|
||||||
|
"rollback": [
|
||||||
|
"git revert this commit to restore inline heredocs in all 6 scripts",
|
||||||
|
"Remove the python_pkg/live_calc/ directory and linux_configuration/tests/ helper test files"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -10,6 +10,10 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# Resolve this script's directory up front (absolute), before any cd, so sibling
|
||||||
|
# helpers like fast_count.py remain locatable once we cd into the analyzed repo.
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
INPUT=""
|
INPUT=""
|
||||||
WORK_DIR=""
|
WORK_DIR=""
|
||||||
@ -334,13 +338,7 @@ fast_count() {
|
|||||||
if command -v counts &>/dev/null; then
|
if command -v counts &>/dev/null; then
|
||||||
counts 2>/dev/null | head -$((top_n + 1)) | tail -$top_n
|
counts 2>/dev/null | head -$((top_n + 1)) | tail -$top_n
|
||||||
else
|
else
|
||||||
python3 -c "
|
python3 "$SCRIPT_DIR/fast_count.py" "$top_n"
|
||||||
import sys
|
|
||||||
from collections import Counter
|
|
||||||
c = Counter(line.rstrip() for line in sys.stdin)
|
|
||||||
for word, count in c.most_common($top_n):
|
|
||||||
print(f'{count} {word}')
|
|
||||||
"
|
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
33
linux_configuration/scripts/single_use/utils/fast_count.py
Executable file
33
linux_configuration/scripts/single_use/utils/fast_count.py
Executable file
@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Print the most common whole-line entries read from stdin.
|
||||||
|
|
||||||
|
Reads lines from stdin, counts identical (trailing-whitespace-stripped) lines,
|
||||||
|
and writes the ``argv[1]`` most common as ``<count> <line>`` rows to stdout. Used
|
||||||
|
by ``analyze_repo.sh`` as the fallback word counter when the Rust ``counts`` tool
|
||||||
|
is unavailable.
|
||||||
|
|
||||||
|
Kept as a standalone module (not inline ``python3 -c`` in the shell script) so the
|
||||||
|
repository's Python tooling applies; see CLAUDE.md "Shell Style".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_DEFAULT_TOP_N = 50
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Count stdin lines and print the top-N most frequent to stdout."""
|
||||||
|
args = sys.argv[1:]
|
||||||
|
top_n = int(args[0]) if args else _DEFAULT_TOP_N
|
||||||
|
counter = Counter(line.rstrip() for line in sys.stdin)
|
||||||
|
sys.stdout.write(
|
||||||
|
"".join(f"{count} {word}\n" for word, count in counter.most_common(top_n)),
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@ -1,8 +1,9 @@
|
|||||||
"""Pytest bootstrap: make usage_report's ``bin/`` importable for these tests.
|
"""Pytest bootstrap: make non-package script dirs importable for these tests.
|
||||||
|
|
||||||
The usage-report modules live in a non-package script directory and use
|
Several helper modules live in standalone script directories (outside
|
||||||
absolute imports (``from _usage_report_parsing import ...``), so the directory
|
``python_pkg/``) and are invoked as ``python <file>.py`` rather than imported as
|
||||||
must be on ``sys.path`` before the tests import them.
|
packages. To unit-test them they must be importable by bare module name, so each
|
||||||
|
directory is placed on ``sys.path`` before the tests import them.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -10,12 +11,26 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
_BIN = (
|
# Repo root is two levels up from this file (linux_configuration/tests/conftest.py).
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
# Each standalone script directory whose Python modules these tests import.
|
||||||
|
_SCRIPT_DIRS = (
|
||||||
Path(__file__).resolve().parents[1]
|
Path(__file__).resolve().parents[1]
|
||||||
/ "scripts"
|
/ "scripts"
|
||||||
/ "periodic_background"
|
/ "periodic_background"
|
||||||
/ "system-maintenance"
|
/ "system-maintenance"
|
||||||
/ "bin"
|
/ "bin", # usage_report modules
|
||||||
|
_REPO_ROOT / "meta" / "scripts", # validate_evidence, validate_contract
|
||||||
|
_REPO_ROOT / "phone_focus_mode" / "lib", # monitor_report
|
||||||
|
_REPO_ROOT / "phone_focus_mode", # strip_workout_hosts
|
||||||
|
_REPO_ROOT
|
||||||
|
/ "linux_configuration"
|
||||||
|
/ "scripts"
|
||||||
|
/ "single_use"
|
||||||
|
/ "utils", # fast_count
|
||||||
)
|
)
|
||||||
if str(_BIN) not in sys.path:
|
|
||||||
sys.path.insert(0, str(_BIN))
|
for _script_dir in _SCRIPT_DIRS:
|
||||||
|
if str(_script_dir) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_script_dir))
|
||||||
|
|||||||
58
linux_configuration/tests/test_fast_count.py
Normal file
58
linux_configuration/tests/test_fast_count.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
"""Tests for linux_configuration/.../utils/fast_count.py (stdin word counter)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import fast_count
|
||||||
|
from fast_count import main
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _run(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
stdin_text: str,
|
||||||
|
argv: list[str],
|
||||||
|
) -> str:
|
||||||
|
"""Run ``main`` with a fake stdin and argv; return captured stdout."""
|
||||||
|
monkeypatch.setattr(fast_count.sys, "stdin", io.StringIO(stdin_text))
|
||||||
|
monkeypatch.setattr(fast_count.sys, "argv", argv)
|
||||||
|
assert main() == 0
|
||||||
|
return capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
class TestMain:
|
||||||
|
"""``fast_count`` prints ``<count> <line>`` for the top-N frequent lines."""
|
||||||
|
|
||||||
|
def test_top_n_ordering(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
"""The N most common lines are printed most-frequent first."""
|
||||||
|
out = _run(
|
||||||
|
monkeypatch, capsys, "if\nif\nfor\nif\nfor\nreturn\n", ["fast_count", "2"]
|
||||||
|
)
|
||||||
|
assert out == "3 if\n2 for\n"
|
||||||
|
|
||||||
|
def test_strips_trailing_whitespace(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
"""Lines differing only by trailing whitespace are counted together."""
|
||||||
|
out = _run(monkeypatch, capsys, "x \nx\n", ["fast_count", "1"])
|
||||||
|
assert out == "2 x\n"
|
||||||
|
|
||||||
|
def test_default_top_n_without_arg(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
"""With no argument the default top-N applies."""
|
||||||
|
out = _run(monkeypatch, capsys, "a\na\nb\n", ["fast_count"])
|
||||||
|
assert out == "2 a\n1 b\n"
|
||||||
156
linux_configuration/tests/test_monitor_report.py
Normal file
156
linux_configuration/tests/test_monitor_report.py
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
"""Tests for phone_focus_mode/lib/monitor_report.py (report summary/severity)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import monitor_report
|
||||||
|
from monitor_report import _field, _has_severe, _load_checks, _render_summary, main
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _write(tmp_path: Path, obj: object) -> Path:
|
||||||
|
"""Write ``obj`` as JSON to a temp file and return its path."""
|
||||||
|
path = tmp_path / "report.json"
|
||||||
|
path.write_text(json.dumps(obj), encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadChecks:
|
||||||
|
"""``_load_checks`` tolerates malformed reports."""
|
||||||
|
|
||||||
|
def test_reads_checks(self, tmp_path: Path) -> None:
|
||||||
|
"""A well-formed report yields its checks list."""
|
||||||
|
path = _write(tmp_path, {"checks": [{"status": "ok"}]})
|
||||||
|
assert _load_checks(path) == [{"status": "ok"}]
|
||||||
|
|
||||||
|
def test_non_dict_report_yields_empty(self, tmp_path: Path) -> None:
|
||||||
|
"""A non-object report yields no checks."""
|
||||||
|
assert _load_checks(_write(tmp_path, [1, 2])) == []
|
||||||
|
|
||||||
|
def test_non_list_checks_yields_empty(self, tmp_path: Path) -> None:
|
||||||
|
"""A non-list ``checks`` value yields no checks."""
|
||||||
|
assert _load_checks(_write(tmp_path, {"checks": "nope"})) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestField:
|
||||||
|
"""``_field`` reads string fields with a default."""
|
||||||
|
|
||||||
|
def test_reads_present_string(self) -> None:
|
||||||
|
"""A present string field is returned."""
|
||||||
|
assert _field({"status": "ok"}, "status", "warn") == "ok"
|
||||||
|
|
||||||
|
def test_default_when_missing(self) -> None:
|
||||||
|
"""A missing field falls back to the default."""
|
||||||
|
assert _field({}, "status", "warn") == "warn"
|
||||||
|
|
||||||
|
def test_default_when_not_string(self) -> None:
|
||||||
|
"""A non-string field falls back to the default."""
|
||||||
|
assert _field({"status": 5}, "status", "warn") == "warn"
|
||||||
|
|
||||||
|
def test_default_when_check_not_dict(self) -> None:
|
||||||
|
"""A non-dict check falls back to the default."""
|
||||||
|
assert _field("nope", "status", "warn") == "warn"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderSummary:
|
||||||
|
"""``_render_summary`` produces the counts header and issue list."""
|
||||||
|
|
||||||
|
def test_counts_and_issues(self) -> None:
|
||||||
|
"""Counts and a per-issue line are rendered."""
|
||||||
|
checks: list[object] = [
|
||||||
|
{"status": "ok", "check": "a", "message": ""},
|
||||||
|
{"status": "warn", "check": "b", "message": "drift"},
|
||||||
|
{"status": "error", "check": "c", "message": "boom"},
|
||||||
|
]
|
||||||
|
out = _render_summary(checks)
|
||||||
|
assert "ok=1" in out
|
||||||
|
assert "warn=1" in out
|
||||||
|
assert "error=1" in out
|
||||||
|
assert "[warn] b: drift" in out
|
||||||
|
assert "[error] c: boom" in out
|
||||||
|
|
||||||
|
def test_no_issues_section_when_all_ok(self) -> None:
|
||||||
|
"""With no problems, the issues section is omitted."""
|
||||||
|
out = _render_summary([{"status": "ok", "check": "a", "message": ""}])
|
||||||
|
assert "Issues found:" not in out
|
||||||
|
# Footer line plus a trailing blank line, matching the original output.
|
||||||
|
assert out.endswith("==========================\n\n")
|
||||||
|
|
||||||
|
|
||||||
|
class TestHasSevere:
|
||||||
|
"""``_has_severe`` flags fatal/error checks."""
|
||||||
|
|
||||||
|
def test_true_for_error(self) -> None:
|
||||||
|
"""An error status is severe."""
|
||||||
|
assert _has_severe([{"status": "error"}]) is True
|
||||||
|
|
||||||
|
def test_true_for_fatal(self) -> None:
|
||||||
|
"""A fatal status is severe."""
|
||||||
|
assert _has_severe([{"status": "fatal"}]) is True
|
||||||
|
|
||||||
|
def test_false_for_ok_warn(self) -> None:
|
||||||
|
"""ok/warn statuses are not severe."""
|
||||||
|
assert _has_severe([{"status": "ok"}, {"status": "warn"}]) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestMain:
|
||||||
|
"""The CLI dispatches on mode and reports severity via exit code."""
|
||||||
|
|
||||||
|
def test_bad_usage_returns_2(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""An unknown mode is a usage error (rc 2)."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
monitor_report.sys, "argv", ["monitor_report", "bogus", "x"]
|
||||||
|
)
|
||||||
|
assert main() == 2
|
||||||
|
|
||||||
|
def test_summary_prints_and_returns_0(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
"""Summary mode prints the report and returns 0."""
|
||||||
|
path = _write(
|
||||||
|
tmp_path, {"checks": [{"status": "warn", "check": "b", "message": "m"}]}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
monitor_report.sys,
|
||||||
|
"argv",
|
||||||
|
["monitor_report", "summary", str(path)],
|
||||||
|
)
|
||||||
|
assert main() == 0
|
||||||
|
assert "Monitoring Summary" in capsys.readouterr().out
|
||||||
|
|
||||||
|
def test_severity_returns_1_on_error(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Severity mode returns 1 when a severe check exists."""
|
||||||
|
path = _write(tmp_path, {"checks": [{"status": "error"}]})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
monitor_report.sys,
|
||||||
|
"argv",
|
||||||
|
["monitor_report", "severity", str(path)],
|
||||||
|
)
|
||||||
|
assert main() == 1
|
||||||
|
|
||||||
|
def test_severity_returns_0_when_clean(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Severity mode returns 0 when no severe check exists."""
|
||||||
|
path = _write(tmp_path, {"checks": [{"status": "ok"}]})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
monitor_report.sys,
|
||||||
|
"argv",
|
||||||
|
["monitor_report", "severity", str(path)],
|
||||||
|
)
|
||||||
|
assert main() == 0
|
||||||
92
linux_configuration/tests/test_strip_workout_hosts.py
Normal file
92
linux_configuration/tests/test_strip_workout_hosts.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
"""Tests for phone_focus_mode/strip_workout_hosts.py (hosts workout variant)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import strip_workout_hosts
|
||||||
|
from strip_workout_hosts import _strip, main
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_HOSTS = """\
|
||||||
|
# header
|
||||||
|
127.0.0.1 localhost
|
||||||
|
0.0.0.0 youtube.com www.youtube.com
|
||||||
|
0.0.0.0 ads.example.com
|
||||||
|
0.0.0.0 m.youtube.com alias.youtube.com
|
||||||
|
0.0.0.0 keepme.com
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _src(tmp_path: Path) -> Path:
|
||||||
|
"""Write the sample hosts file and return its path."""
|
||||||
|
path = tmp_path / "hosts"
|
||||||
|
path.write_text(_HOSTS, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
class TestStrip:
|
||||||
|
"""``_strip`` removes lines mapping unblocked domains."""
|
||||||
|
|
||||||
|
def test_removes_matching_and_alias_lines(self, tmp_path: Path) -> None:
|
||||||
|
"""Entries whose name or any alias is unblocked are dropped."""
|
||||||
|
dest = tmp_path / "out"
|
||||||
|
_strip(
|
||||||
|
_src(tmp_path),
|
||||||
|
dest,
|
||||||
|
frozenset({"youtube.com", "www.youtube.com", "m.youtube.com"}),
|
||||||
|
)
|
||||||
|
result = dest.read_text(encoding="utf-8")
|
||||||
|
assert "youtube.com" not in result # both youtube entries gone
|
||||||
|
assert "ads.example.com" in result
|
||||||
|
assert "keepme.com" in result
|
||||||
|
assert "localhost" in result
|
||||||
|
assert "# header" in result
|
||||||
|
|
||||||
|
def test_empty_unblock_keeps_everything(self, tmp_path: Path) -> None:
|
||||||
|
"""An empty unblock set copies the file verbatim."""
|
||||||
|
dest = tmp_path / "out"
|
||||||
|
_strip(_src(tmp_path), dest, frozenset())
|
||||||
|
assert dest.read_text(encoding="utf-8") == _HOSTS
|
||||||
|
|
||||||
|
def test_comment_and_blank_lines_preserved(self, tmp_path: Path) -> None:
|
||||||
|
"""Comment and blank lines are never stripped."""
|
||||||
|
src = tmp_path / "hosts"
|
||||||
|
src.write_text("# c\n\n0.0.0.0 block.me\n", encoding="utf-8")
|
||||||
|
dest = tmp_path / "out"
|
||||||
|
_strip(src, dest, frozenset({"block.me"}))
|
||||||
|
assert dest.read_text(encoding="utf-8") == "# c\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMain:
|
||||||
|
"""The CLI reads paths from argv and domains from the environment."""
|
||||||
|
|
||||||
|
def test_bad_arg_count_returns_2(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""A wrong number of path arguments is a usage error (rc 2)."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
strip_workout_hosts.sys, "argv", ["strip_workout_hosts", "only-one"]
|
||||||
|
)
|
||||||
|
assert main() == 2
|
||||||
|
|
||||||
|
def test_strips_via_env(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Domains from the env var drive the stripping."""
|
||||||
|
src = _src(tmp_path)
|
||||||
|
dest = tmp_path / "out"
|
||||||
|
monkeypatch.setenv("WORKOUT_UNBLOCK_DOMAINS", "youtube.com m.youtube.com")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
strip_workout_hosts.sys,
|
||||||
|
"argv",
|
||||||
|
["strip_workout_hosts", str(src), str(dest)],
|
||||||
|
)
|
||||||
|
assert main() == 0
|
||||||
|
result = dest.read_text(encoding="utf-8")
|
||||||
|
assert "www.youtube.com" not in result
|
||||||
|
assert "keepme.com" in result
|
||||||
132
linux_configuration/tests/test_validate_contract.py
Normal file
132
linux_configuration/tests/test_validate_contract.py
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
"""Tests for meta/scripts/validate_contract.py (workflow-contract schema checker)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import validate_contract
|
||||||
|
from validate_contract import main, validate
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _valid() -> dict[str, Any]:
|
||||||
|
"""Return a minimal contract artifact that satisfies every rule."""
|
||||||
|
return {
|
||||||
|
"title": "t",
|
||||||
|
"objective": "o",
|
||||||
|
"acceptance_criteria": ["a"],
|
||||||
|
"out_of_scope": ["b"],
|
||||||
|
"verifier": "v",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write(tmp_path: Path, obj: object) -> Path:
|
||||||
|
"""Write ``obj`` as JSON to a temp file and return its path."""
|
||||||
|
path = tmp_path / "contract.json"
|
||||||
|
path.write_text(json.dumps(obj), encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidate:
|
||||||
|
"""The ``validate`` function returns a list of problems."""
|
||||||
|
|
||||||
|
def test_valid_has_no_errors(self, tmp_path: Path) -> None:
|
||||||
|
"""A fully-populated contract produces no errors."""
|
||||||
|
assert validate(_write(tmp_path, _valid())) == []
|
||||||
|
|
||||||
|
def test_missing_fields(self, tmp_path: Path) -> None:
|
||||||
|
"""Absent top-level fields are reported in one message."""
|
||||||
|
errors = validate(_write(tmp_path, {"title": "t"}))
|
||||||
|
expected = (
|
||||||
|
"missing required fields: objective, acceptance_criteria, "
|
||||||
|
"out_of_scope, verifier"
|
||||||
|
)
|
||||||
|
assert errors == [expected]
|
||||||
|
|
||||||
|
def test_blank_string_field(self, tmp_path: Path) -> None:
|
||||||
|
"""A blank scalar field is rejected."""
|
||||||
|
data = _valid()
|
||||||
|
data["objective"] = " "
|
||||||
|
assert "objective must be non-empty string" in validate(_write(tmp_path, data))
|
||||||
|
|
||||||
|
def test_list_field_not_a_list(self, tmp_path: Path) -> None:
|
||||||
|
"""A list field that is not a list is rejected."""
|
||||||
|
data = _valid()
|
||||||
|
data["acceptance_criteria"] = "nope"
|
||||||
|
assert "acceptance_criteria must be a non-empty list" in validate(
|
||||||
|
_write(tmp_path, data),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_list_field_blank_entry(self, tmp_path: Path) -> None:
|
||||||
|
"""A blank entry inside a list field is rejected."""
|
||||||
|
data = _valid()
|
||||||
|
data["out_of_scope"] = [""]
|
||||||
|
assert "out_of_scope items must be non-empty strings" in validate(
|
||||||
|
_write(tmp_path, data),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_non_object_top_level(self, tmp_path: Path) -> None:
|
||||||
|
"""A non-object top-level JSON value is rejected."""
|
||||||
|
assert validate(_write(tmp_path, 5)) == [
|
||||||
|
"top-level JSON value must be an object"
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_invalid_json(self, tmp_path: Path) -> None:
|
||||||
|
"""Malformed JSON is reported, not raised."""
|
||||||
|
path = tmp_path / "bad.json"
|
||||||
|
path.write_text("{", encoding="utf-8")
|
||||||
|
errors = validate(path)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert errors[0].startswith("invalid JSON")
|
||||||
|
|
||||||
|
def test_unreadable_path(self, tmp_path: Path) -> None:
|
||||||
|
"""An unreadable path (a directory) is reported, not raised."""
|
||||||
|
errors = validate(tmp_path)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert errors[0].startswith("cannot read file")
|
||||||
|
|
||||||
|
|
||||||
|
class TestMain:
|
||||||
|
"""The CLI entry point maps validation to an exit code."""
|
||||||
|
|
||||||
|
def test_no_argument_returns_2(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Missing the path argument is a usage error (rc 2)."""
|
||||||
|
monkeypatch.setattr(validate_contract.sys, "argv", ["validate_contract"])
|
||||||
|
assert main() == 2
|
||||||
|
|
||||||
|
def test_valid_returns_0(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
"""A valid contract prints OK and returns 0."""
|
||||||
|
path = _write(tmp_path, _valid())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
validate_contract.sys,
|
||||||
|
"argv",
|
||||||
|
["validate_contract", str(path)],
|
||||||
|
)
|
||||||
|
assert main() == 0
|
||||||
|
assert "contract schema OK" in capsys.readouterr().out
|
||||||
|
|
||||||
|
def test_invalid_returns_1(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
"""An invalid contract writes the problem to stderr and returns 1."""
|
||||||
|
path = _write(tmp_path, {"title": "t"})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
validate_contract.sys,
|
||||||
|
"argv",
|
||||||
|
["validate_contract", str(path)],
|
||||||
|
)
|
||||||
|
assert main() == 1
|
||||||
|
assert "missing required fields" in capsys.readouterr().err
|
||||||
166
linux_configuration/tests/test_validate_evidence.py
Normal file
166
linux_configuration/tests/test_validate_evidence.py
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
"""Tests for meta/scripts/validate_evidence.py (AI-evidence schema checker)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import validate_evidence
|
||||||
|
from validate_evidence import main, validate
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _valid() -> dict[str, Any]:
|
||||||
|
"""Return a minimal evidence artifact that satisfies every rule."""
|
||||||
|
return {
|
||||||
|
"intent": "do the thing",
|
||||||
|
"scope": ["a"],
|
||||||
|
"changes": ["a"],
|
||||||
|
"risks": ["a"],
|
||||||
|
"rollback": ["a"],
|
||||||
|
"verification": [{"command": "c", "result": "passed", "evidence": "e"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write(tmp_path: Path, obj: object) -> Path:
|
||||||
|
"""Write ``obj`` as JSON to a temp file and return its path."""
|
||||||
|
path = tmp_path / "evidence.json"
|
||||||
|
path.write_text(json.dumps(obj), encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidate:
|
||||||
|
"""The ``validate`` function returns a list of problems."""
|
||||||
|
|
||||||
|
def test_valid_has_no_errors(self, tmp_path: Path) -> None:
|
||||||
|
"""A fully-populated artifact produces no errors."""
|
||||||
|
assert validate(_write(tmp_path, _valid())) == []
|
||||||
|
|
||||||
|
def test_missing_keys(self, tmp_path: Path) -> None:
|
||||||
|
"""Absent top-level keys are reported in one message."""
|
||||||
|
errors = validate(_write(tmp_path, {"intent": "x"}))
|
||||||
|
assert errors == [
|
||||||
|
"missing required keys: scope, changes, verification, risks, rollback",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_intent_must_be_nonempty_string(self, tmp_path: Path) -> None:
|
||||||
|
"""A blank intent is rejected."""
|
||||||
|
data = _valid()
|
||||||
|
data["intent"] = " "
|
||||||
|
assert "intent must be a non-empty string" in validate(_write(tmp_path, data))
|
||||||
|
|
||||||
|
def test_string_list_not_a_list(self, tmp_path: Path) -> None:
|
||||||
|
"""A string-list field that is not a list is rejected."""
|
||||||
|
data = _valid()
|
||||||
|
data["scope"] = "nope"
|
||||||
|
assert "scope must be a non-empty list" in validate(_write(tmp_path, data))
|
||||||
|
|
||||||
|
def test_string_list_with_blank_entry(self, tmp_path: Path) -> None:
|
||||||
|
"""A blank entry inside a string-list field is rejected."""
|
||||||
|
data = _valid()
|
||||||
|
data["changes"] = ["ok", " "]
|
||||||
|
assert "changes entries must be non-empty strings" in validate(
|
||||||
|
_write(tmp_path, data),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_verification_not_a_list(self, tmp_path: Path) -> None:
|
||||||
|
"""A non-list verification field is rejected."""
|
||||||
|
data = _valid()
|
||||||
|
data["verification"] = {}
|
||||||
|
assert "verification must be a non-empty list" in validate(
|
||||||
|
_write(tmp_path, data),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_verification_item_not_object(self, tmp_path: Path) -> None:
|
||||||
|
"""A non-object verification entry is rejected."""
|
||||||
|
data = _valid()
|
||||||
|
data["verification"] = ["nope"]
|
||||||
|
assert "verification[0] must be an object" in validate(_write(tmp_path, data))
|
||||||
|
|
||||||
|
def test_verification_missing_fields(self, tmp_path: Path) -> None:
|
||||||
|
"""Missing per-entry fields are reported."""
|
||||||
|
data = _valid()
|
||||||
|
data["verification"] = [{"command": "c"}]
|
||||||
|
errors = validate(_write(tmp_path, data))
|
||||||
|
assert "verification[0] missing fields: result, evidence" in errors
|
||||||
|
|
||||||
|
def test_verification_blank_field(self, tmp_path: Path) -> None:
|
||||||
|
"""A blank per-entry field is rejected."""
|
||||||
|
data = _valid()
|
||||||
|
data["verification"] = [{"command": "c", "result": " ", "evidence": "e"}]
|
||||||
|
errors = validate(_write(tmp_path, data))
|
||||||
|
assert "verification[0].result must be a non-empty string" in errors
|
||||||
|
|
||||||
|
def test_banned_phrase(self, tmp_path: Path) -> None:
|
||||||
|
"""A rationalization phrase anywhere in the file is rejected."""
|
||||||
|
data = _valid()
|
||||||
|
data["verification"] = [
|
||||||
|
{"command": "c", "result": "should work", "evidence": "e"},
|
||||||
|
]
|
||||||
|
errors = validate(_write(tmp_path, data))
|
||||||
|
assert any("rationalization phrase 'should work'" in e for e in errors)
|
||||||
|
|
||||||
|
def test_non_object_top_level(self, tmp_path: Path) -> None:
|
||||||
|
"""A non-object top-level JSON value is rejected."""
|
||||||
|
assert validate(_write(tmp_path, [1, 2])) == [
|
||||||
|
"top-level JSON value must be an object",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_invalid_json(self, tmp_path: Path) -> None:
|
||||||
|
"""Malformed JSON is reported, not raised."""
|
||||||
|
path = tmp_path / "bad.json"
|
||||||
|
path.write_text("not json", encoding="utf-8")
|
||||||
|
errors = validate(path)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert errors[0].startswith("invalid JSON")
|
||||||
|
|
||||||
|
def test_unreadable_path(self, tmp_path: Path) -> None:
|
||||||
|
"""An unreadable path (a directory) is reported, not raised."""
|
||||||
|
errors = validate(tmp_path)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert errors[0].startswith("cannot read file")
|
||||||
|
|
||||||
|
|
||||||
|
class TestMain:
|
||||||
|
"""The CLI entry point maps validation to an exit code."""
|
||||||
|
|
||||||
|
def test_no_argument_returns_2(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Missing the path argument is a usage error (rc 2)."""
|
||||||
|
monkeypatch.setattr(validate_evidence.sys, "argv", ["validate_evidence"])
|
||||||
|
assert main() == 2
|
||||||
|
|
||||||
|
def test_valid_returns_0(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
"""A valid artifact prints OK and returns 0."""
|
||||||
|
path = _write(tmp_path, _valid())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
validate_evidence.sys,
|
||||||
|
"argv",
|
||||||
|
["validate_evidence", str(path)],
|
||||||
|
)
|
||||||
|
assert main() == 0
|
||||||
|
assert "schema OK" in capsys.readouterr().out
|
||||||
|
|
||||||
|
def test_invalid_returns_1(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
"""An invalid artifact writes the problem to stderr and returns 1."""
|
||||||
|
path = _write(tmp_path, {"intent": "x"})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
validate_evidence.sys,
|
||||||
|
"argv",
|
||||||
|
["validate_evidence", str(path)],
|
||||||
|
)
|
||||||
|
assert main() == 1
|
||||||
|
assert "missing required keys" in capsys.readouterr().err
|
||||||
266
linux_configuration/zsh/calc-live.zsh
Normal file
266
linux_configuration/zsh/calc-live.zsh
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# calc-live.zsh — live "calculator as you type" for the zsh prompt
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Open a terminal and just type a math expression. The result appears, greyed,
|
||||||
|
# right after what you typed, updating on every keystroke. No "calc", no Enter.
|
||||||
|
#
|
||||||
|
# 2+2 => 2+2 = 4
|
||||||
|
# (5+3)/2 => (5+3)/2 = 4
|
||||||
|
# 1/3 => 1/3 = 0.333333333333
|
||||||
|
# sqrt(2)*3 => sqrt(2)*3 = 4.24264068712
|
||||||
|
# sin(pi/2) => sin(pi/2) = 1
|
||||||
|
# 2^10 => 2^10 = 1024 (^ means power, calculator-style)
|
||||||
|
#
|
||||||
|
# Pressing Enter on a pure-math line prints "<expr> = <result>" instead of
|
||||||
|
# trying to run it as a command (so no "command not found"). History keeps the
|
||||||
|
# bare expression, not the print command.
|
||||||
|
#
|
||||||
|
# Only pure-math lines trigger this; normal commands (ls, git, cd ..) are left
|
||||||
|
# completely alone and never fork the evaluator.
|
||||||
|
#
|
||||||
|
# Richer math (units, hex, percentages): prefix the line with "=". These go to
|
||||||
|
# qalc (if installed) and update live too, via a warmed qalc coprocess:
|
||||||
|
# =5 ft to cm -> 5 ft to cm = 152.4 cm
|
||||||
|
# =255 to hex -> 255 to hex = 0xFF
|
||||||
|
# =0xff + 1 -> 0xff + 1 = 256
|
||||||
|
# =20% of 80 -> 20% of 80 = 16
|
||||||
|
# (The first "=" of a session spends ~120 ms warming qalc; after that ~1 ms.)
|
||||||
|
#
|
||||||
|
# Engine: a safe AST-based evaluator in the sibling calc_eval.py (~6 ms/eval),
|
||||||
|
# run via python3. It cannot import modules, touch files, or run arbitrary code,
|
||||||
|
# and a runaway expression (e.g. 9**9**9) is bounded by a power-exponent guard +
|
||||||
|
# CPU limit + timeout so it can never freeze the prompt.
|
||||||
|
#
|
||||||
|
# Disable for a session: export CALC_LIVE_OFF=1 (then open a new shell)
|
||||||
|
# Remove permanently: delete this file.
|
||||||
|
#
|
||||||
|
# NOTE: this widget manages POSTDISPLAY/region_highlight. If you ever enable
|
||||||
|
# the zsh-autosuggestions plugin (which also owns POSTDISPLAY), they will fight;
|
||||||
|
# revisit this then.
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Opt-out switch and a one-time guard against double loading.
|
||||||
|
[[ -n ${CALC_LIVE_OFF:-} ]] && return
|
||||||
|
[[ -n ${_CALC_LOADED:-} ]] && return
|
||||||
|
typeset -g _CALC_LOADED=1
|
||||||
|
|
||||||
|
# --- Pick the evaluator binary (prefer the fast system python over a shim) ---
|
||||||
|
if [[ -x /usr/bin/python3 ]]; then
|
||||||
|
typeset -g _CALC_PY_BIN=/usr/bin/python3
|
||||||
|
else
|
||||||
|
typeset -g _CALC_PY_BIN=${commands[python3]:-}
|
||||||
|
fi
|
||||||
|
typeset -g _CALC_TIMEOUT_BIN=${commands[timeout]:-}
|
||||||
|
|
||||||
|
# The safe AST evaluator lives under python_pkg/ so the repository's Python
|
||||||
|
# tooling (ruff, mypy, pylint, bandit, 100%-coverage tests) applies to it.
|
||||||
|
# Resolve it at source time: ${0:A} follows the oh-my-zsh symlink back to the
|
||||||
|
# real file in the repo; :h:h:h walks zsh/ -> linux_configuration/ -> repo root.
|
||||||
|
# ($0 inside a function would name the function, so capture it here.)
|
||||||
|
typeset -gr _CALC_EVAL_PY=${0:A:h:h:h}/python_pkg/live_calc/calc_eval.py
|
||||||
|
|
||||||
|
# Optional richer engine. qalc (libqalculate) handles units, hex, percentages
|
||||||
|
# and natural language, but its ~120 ms cold start is too slow to run on every
|
||||||
|
# keystroke, so it is used only for "="-prefixed lines, evaluated on Enter.
|
||||||
|
typeset -g _CALC_QALC_BIN=${commands[qalc]:-}
|
||||||
|
|
||||||
|
# No usable evaluator -> do nothing rather than half-installing.
|
||||||
|
[[ -n $_CALC_PY_BIN && -r $_CALC_EVAL_PY ]] || return
|
||||||
|
|
||||||
|
# --- Evaluate one expression; echoes the result, or nothing on failure -------
|
||||||
|
# Runs the standalone calc_eval.py. The expression is passed as argv[1]; the
|
||||||
|
# script reads it, evaluates it under CPU + wall-clock limits, and writes the
|
||||||
|
# formatted result (or nothing on any error / unsafe input / overflow / timeout).
|
||||||
|
_calc_eval() {
|
||||||
|
emulate -L zsh
|
||||||
|
local expr=$1 out
|
||||||
|
# -S skips site init for a faster start; the outer `timeout` is a backstop in
|
||||||
|
# case the script's own limits are unavailable on this platform.
|
||||||
|
if [[ -n $_CALC_TIMEOUT_BIN ]]; then
|
||||||
|
out=$("$_CALC_TIMEOUT_BIN" -k 0.1 0.5 "$_CALC_PY_BIN" -S "$_CALC_EVAL_PY" "$expr" 2>/dev/null)
|
||||||
|
else
|
||||||
|
out=$("$_CALC_PY_BIN" -S "$_CALC_EVAL_PY" "$expr" 2>/dev/null)
|
||||||
|
fi
|
||||||
|
print -r -- "$out"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- qalc (richer engine) for "="-prefixed lines: units, hex, percentages ----
|
||||||
|
# qalc cold start is ~120 ms, far too slow per keystroke; but a warmed qalc
|
||||||
|
# coprocess answers in ~1 ms, fast enough to evaluate live in the redraw hook.
|
||||||
|
# So we keep ONE persistent qalc per shell, started lazily on first "=" line.
|
||||||
|
#
|
||||||
|
# IMPORTANT: the coprocess lives in the main shell. Its evaluator must NOT be
|
||||||
|
# called via $(...) (that forks a subshell and loses the pipe) — it sets the
|
||||||
|
# global _CALC_QRESULT instead.
|
||||||
|
typeset -g _CALC_QCO_UP=0 # 1 once the qalc coprocess is running
|
||||||
|
typeset -gi _CALC_QSEQ=0 # unique-sentinel counter (prevents desync)
|
||||||
|
typeset -g _CALC_QEXPR= # stripped expression from the last "=" line
|
||||||
|
typeset -g _CALC_QRESULT= # result from the last _calc_qalc_eval
|
||||||
|
|
||||||
|
# If the line is "=<expr>", set _CALC_QEXPR to the stripped <expr> and return 0.
|
||||||
|
_calc_qalc_line() {
|
||||||
|
emulate -L zsh
|
||||||
|
setopt local_options extended_glob
|
||||||
|
local s=$1
|
||||||
|
_CALC_QEXPR=
|
||||||
|
[[ $s == (#s)[[:space:]]#=* ]] || return 1
|
||||||
|
s=${s##[[:space:]]#=[[:space:]]#} # drop leading spaces, the "=", trailing spaces
|
||||||
|
[[ -n $s ]] || return 1
|
||||||
|
_CALC_QEXPR=$s
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start / stop the persistent qalc coprocess (no job-control chatter).
|
||||||
|
_calc_qco_start() {
|
||||||
|
setopt local_options no_monitor no_notify
|
||||||
|
coproc "$_CALC_QALC_BIN" -t 2>/dev/null
|
||||||
|
_CALC_QCO_PID=$!
|
||||||
|
disown %+ 2>/dev/null
|
||||||
|
_CALC_QCO_UP=1
|
||||||
|
}
|
||||||
|
_calc_qco_stop() {
|
||||||
|
[[ ${_CALC_QCO_UP:-0} == 1 ]] || return 0
|
||||||
|
kill "${_CALC_QCO_PID:-0}" 2>/dev/null
|
||||||
|
_CALC_QCO_UP=0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Evaluate _CALC_QEXPR (arg) via the coprocess; sets _CALC_QRESULT ("" on fail).
|
||||||
|
_calc_qalc_eval() {
|
||||||
|
emulate -L zsh
|
||||||
|
setopt local_options extended_glob no_monitor no_notify
|
||||||
|
_CALC_QRESULT=
|
||||||
|
[[ -n $_CALC_QALC_BIN ]] || return 0
|
||||||
|
local expr=${1// of / * } # qalc mis-parses "A% of B"
|
||||||
|
(( _CALC_QCO_UP )) || _calc_qco_start
|
||||||
|
(( _CALC_QSEQ++ ))
|
||||||
|
local sentinel="909090909${_CALC_QSEQ}" # unique per call, echoes itself
|
||||||
|
if ! { print -p -- "$expr" && print -p -- "$sentinel" } 2>/dev/null; then
|
||||||
|
_calc_qco_stop; _calc_qco_start # pipe broke -> restart once
|
||||||
|
{ print -p -- "$expr" && print -p -- "$sentinel" } 2>/dev/null || return 0
|
||||||
|
fi
|
||||||
|
local line result='' saw=0
|
||||||
|
integer guard=0
|
||||||
|
while (( guard++ < 100 )) && read -rp -t 0.8 line 2>/dev/null; do
|
||||||
|
line=${line//$'\e'\[[0-9;?]##[a-zA-Z]/} # strip color escapes
|
||||||
|
line=${line##[[:space:]]##}; line=${line%%[[:space:]]##}
|
||||||
|
[[ -z $line || $line == '>'* ]] && continue # blank / echoed input
|
||||||
|
[[ $line == *$sentinel* ]] && { saw=1; break; } # our sentinel result
|
||||||
|
result=$line
|
||||||
|
done
|
||||||
|
(( saw )) || { _CALC_QCO_UP=0; return 0 } # timed out/wedged -> reset
|
||||||
|
# qalc echoes the input back when it cannot evaluate (e.g. "1 / 0").
|
||||||
|
[[ -n $result && ${result// /} != ${expr// /} ]] && _CALC_QRESULT=$result
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Decide whether a line is a pure math expression worth evaluating --------
|
||||||
|
# Conservative on purpose: must contain a digit and an operator (or a function
|
||||||
|
# call), and consist only of math tokens. This keeps real commands untouched.
|
||||||
|
_calc_is_expr() {
|
||||||
|
emulate -L zsh
|
||||||
|
setopt local_options extended_glob
|
||||||
|
local s=${1//[[:space:]]/}
|
||||||
|
[[ -n $s ]] || return 1
|
||||||
|
# Neutralise known function/constant words (longest first) so the remaining
|
||||||
|
# text can be charset-checked as pure numeric/operator soup.
|
||||||
|
local t=$s w
|
||||||
|
for w in factorial asin acos atan sqrt sin cos tan log2 log ln exp \
|
||||||
|
floor ceil gcd deg rad min max tau pi e; do
|
||||||
|
t=${t//$w/0}
|
||||||
|
done
|
||||||
|
# Only digits, operators, parens, dot, comma may remain.
|
||||||
|
# (Parens are backslash-escaped so the parser does not read them as a group.)
|
||||||
|
[[ $t == [-0-9.,+*/%^\(\)]## ]] || return 1
|
||||||
|
# Require a digit, and either an operator or a function call.
|
||||||
|
[[ $s == *[0-9]* ]] || return 1
|
||||||
|
[[ $s == *[-+*/%^]* || $s == *[a-z]*\(* ]] || return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- ZLE plumbing ------------------------------------------------------------
|
||||||
|
typeset -g _CALC_LAST_BUFFER=$'\0' # sentinel so the first redraw computes
|
||||||
|
typeset -g _CALC_LAST_RESULT=
|
||||||
|
|
||||||
|
# Drop our preview text and our highlight entry (leaving any others intact).
|
||||||
|
_calc_clear() {
|
||||||
|
POSTDISPLAY=
|
||||||
|
region_highlight=( "${(@)region_highlight:#*memo=calc*}" )
|
||||||
|
}
|
||||||
|
|
||||||
|
# Runs on every redraw; recomputes only when the buffer text actually changed.
|
||||||
|
_calc_preview() {
|
||||||
|
emulate -L zsh
|
||||||
|
[[ $BUFFER == $_CALC_LAST_BUFFER ]] && return
|
||||||
|
_CALC_LAST_BUFFER=$BUFFER
|
||||||
|
_CALC_LAST_RESULT=
|
||||||
|
_calc_clear
|
||||||
|
# "=<expr>" -> evaluate live with the warmed qalc coprocess (~1 ms).
|
||||||
|
if [[ -n $_CALC_QALC_BIN ]] && _calc_qalc_line "$BUFFER"; then
|
||||||
|
_calc_qalc_eval "$_CALC_QEXPR"
|
||||||
|
if [[ -n $_CALC_QRESULT ]]; then
|
||||||
|
_CALC_LAST_RESULT=$_CALC_QRESULT
|
||||||
|
POSTDISPLAY=" = $_CALC_QRESULT"
|
||||||
|
else
|
||||||
|
POSTDISPLAY=" …" # typed so far is not yet a complete expression
|
||||||
|
fi
|
||||||
|
region_highlight+=("${#BUFFER} $((${#BUFFER} + ${#POSTDISPLAY})) fg=242,memo=calc")
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
_calc_is_expr "$BUFFER" || return
|
||||||
|
local r
|
||||||
|
r=$(_calc_eval "$BUFFER")
|
||||||
|
[[ -n $r && $r != $BUFFER ]] || return
|
||||||
|
_CALC_LAST_RESULT=$r
|
||||||
|
POSTDISPLAY=" = $r"
|
||||||
|
region_highlight+=("${#BUFFER} $((${#BUFFER} + ${#POSTDISPLAY})) fg=242,memo=calc")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Result waiting to be printed to scrollback by the next precmd.
|
||||||
|
typeset -g _CALC_PENDING=
|
||||||
|
|
||||||
|
# Print the accepted calculation just before the next prompt is drawn, so it
|
||||||
|
# lands in scrollback exactly where command output would.
|
||||||
|
_calc_flush() {
|
||||||
|
if [[ -n $_CALC_PENDING ]]; then
|
||||||
|
print -r -- "$_CALC_PENDING"
|
||||||
|
_CALC_PENDING=
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Enter on a pure-math line: record the result for printing, keep the bare
|
||||||
|
# expression in history, and execute nothing (so no "command not found").
|
||||||
|
_calc_accept_line() {
|
||||||
|
emulate -L zsh
|
||||||
|
if [[ -n $_CALC_QALC_BIN ]] && _calc_qalc_line "$BUFFER"; then
|
||||||
|
# "=<expr>" -> reuse the live coprocess result (recompute only if stale).
|
||||||
|
# Never execute a "=" line as a command, even if qalc returns nothing.
|
||||||
|
local r=$_CALC_LAST_RESULT
|
||||||
|
[[ -n $r && $BUFFER == $_CALC_LAST_BUFFER ]] || { _calc_qalc_eval "$_CALC_QEXPR"; r=$_CALC_QRESULT; }
|
||||||
|
print -s -- "$BUFFER" # history keeps what was typed
|
||||||
|
[[ -n $r ]] && _CALC_PENDING="$_CALC_QEXPR = $r"
|
||||||
|
BUFFER=
|
||||||
|
elif [[ -n $_CALC_LAST_RESULT && $BUFFER == $_CALC_LAST_BUFFER ]] \
|
||||||
|
&& _calc_is_expr "$BUFFER"; then
|
||||||
|
print -s -- "$BUFFER" # history keeps the expression
|
||||||
|
_CALC_PENDING="$BUFFER = $_CALC_LAST_RESULT" # _calc_flush prints it
|
||||||
|
BUFFER= # nothing runs
|
||||||
|
fi
|
||||||
|
# Clear our preview and reset state so nothing leaks into the next prompt.
|
||||||
|
_calc_clear
|
||||||
|
_CALC_LAST_BUFFER=$'\0'
|
||||||
|
_CALC_LAST_RESULT=
|
||||||
|
zle .accept-line
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bind the live preview to the redraw hook and intercept Enter.
|
||||||
|
#
|
||||||
|
# zle-line-pre-redraw is unused in this setup, so we own it directly with
|
||||||
|
# "zle -N", which (unlike add-zle-hook-widget) takes effect at .zshrc source
|
||||||
|
# time. If you later add a plugin that also drives line-pre-redraw
|
||||||
|
# (zsh-autosuggestions, zsh-syntax-highlighting), switch to add-zle-hook-widget
|
||||||
|
# registered from a one-shot precmd so the hooks chain instead of clobbering.
|
||||||
|
autoload -Uz add-zsh-hook
|
||||||
|
add-zsh-hook precmd _calc_flush
|
||||||
|
add-zsh-hook zshexit _calc_qco_stop # kill the qalc coprocess on shell exit
|
||||||
|
zle -N zle-line-pre-redraw _calc_preview
|
||||||
|
zle -N accept-line _calc_accept_line
|
||||||
26
linux_configuration/zsh/calc-popup.sh
Executable file
26
linux_configuration/zsh/calc-popup.sh
Executable file
@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ============================================================================
|
||||||
|
# calc-popup.sh — launch the floating scratchpad calculator (i3: Mod+c)
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Opens a small, centered, floating terminator that runs a minimal zsh which
|
||||||
|
# loads only the live-calc widget. Type math, see results live, Ctrl-D to close.
|
||||||
|
#
|
||||||
|
# i3 matches the window by its X role ("calc"), set via terminator --role.
|
||||||
|
# -u (--no-dbus) forces a standalone process so it never folds into an existing
|
||||||
|
# terminator window as a tab.
|
||||||
|
# ============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Directory of this script (the repo zsh/ dir); scratchpad rc lives alongside.
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
readonly SCRATCH_ZDOTDIR="${SCRIPT_DIR}/scratchpad"
|
||||||
|
|
||||||
|
if ! command -v terminator >/dev/null 2>&1; then
|
||||||
|
echo "calc-popup: terminator not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --geometry is in pixels for terminator; set the size here (reliably, before
|
||||||
|
# the window maps) so i3 only has to float + center it.
|
||||||
|
exec terminator -u --role=calc -T calc --geometry=820x220 \
|
||||||
|
-e "env ZDOTDIR='${SCRATCH_ZDOTDIR}' zsh -i"
|
||||||
15
linux_configuration/zsh/scratchpad/.zshrc
Normal file
15
linux_configuration/zsh/scratchpad/.zshrc
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# Minimal rc for the floating scratchpad calculator (i3: Mod+c).
|
||||||
|
#
|
||||||
|
# Deliberately tiny: it loads ONLY the live-calc widget, so the popup starts
|
||||||
|
# instantly instead of paying for the full ~/.zshrc (oh-my-zsh, pyenv, conda,
|
||||||
|
# nvm). ZDOTDIR points here, so this is the only rc that runs.
|
||||||
|
|
||||||
|
PROMPT='%F{cyan}calc ❯%f '
|
||||||
|
RPROMPT=''
|
||||||
|
autoload -Uz colors 2>/dev/null && colors 2>/dev/null
|
||||||
|
|
||||||
|
# Load the widget from the repo (one level up), independent of the oh-my-zsh
|
||||||
|
# symlink, so the scratchpad works even if oh-my-zsh is not installed.
|
||||||
|
source "${ZDOTDIR}/../calc-live.zsh"
|
||||||
|
|
||||||
|
print -P '%F{242}Live calculator — type math, see the result as you type. Ctrl-D to close.%f'
|
||||||
@ -141,6 +141,11 @@ ignore_errors = true
|
|||||||
# PYLINT - Comprehensive Python linter
|
# PYLINT - Comprehensive Python linter
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
[tool.pylint.main]
|
[tool.pylint.main]
|
||||||
|
# Make standalone script dirs importable so tests that import their modules by
|
||||||
|
# bare name (the same dirs linux_configuration/tests/conftest.py adds to
|
||||||
|
# sys.path at runtime) resolve under static analysis instead of raising E0401.
|
||||||
|
# Paths are relative to the repo root, which is pre-commit's working directory.
|
||||||
|
init-hook = "import sys; sys.path[:0] = ['meta/scripts', 'phone_focus_mode', 'phone_focus_mode/lib', 'linux_configuration/scripts/single_use/utils', 'linux_configuration/scripts/periodic_background/system-maintenance/bin']"
|
||||||
# Analyse import fallback blocks
|
# Analyse import fallback blocks
|
||||||
analyse-fallback-blocks = true
|
analyse-fallback-blocks = true
|
||||||
# Pickle collected data for later comparisons
|
# Pickle collected data for later comparisons
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
readonly SCRIPT_DIR
|
||||||
readonly CONTRACT_GLOB='docs/superpowers/contracts/*.json'
|
readonly CONTRACT_GLOB='docs/superpowers/contracts/*.json'
|
||||||
readonly MULTI_FILE_THRESHOLD=4
|
readonly MULTI_FILE_THRESHOLD=4
|
||||||
|
|
||||||
@ -16,44 +18,7 @@ list_staged_contract_files() {
|
|||||||
|
|
||||||
validate_contract_file() {
|
validate_contract_file() {
|
||||||
local file_path="$1"
|
local file_path="$1"
|
||||||
python - "$file_path" <<'PY'
|
python "${SCRIPT_DIR}/validate_contract.py" "$file_path"
|
||||||
import json
|
|
||||||
import pathlib
|
|
||||||
import sys
|
|
||||||
|
|
||||||
path = pathlib.Path(sys.argv[1])
|
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
required = [
|
|
||||||
"title",
|
|
||||||
"objective",
|
|
||||||
"acceptance_criteria",
|
|
||||||
"out_of_scope",
|
|
||||||
"verifier",
|
|
||||||
]
|
|
||||||
|
|
||||||
missing = [field for field in required if field not in data]
|
|
||||||
if missing:
|
|
||||||
raise SystemExit(f"{path}: missing required fields: {', '.join(missing)}")
|
|
||||||
|
|
||||||
if not isinstance(data["title"], str) or not data["title"].strip():
|
|
||||||
raise SystemExit(f"{path}: title must be non-empty string")
|
|
||||||
|
|
||||||
if not isinstance(data["objective"], str) or not data["objective"].strip():
|
|
||||||
raise SystemExit(f"{path}: objective must be non-empty string")
|
|
||||||
|
|
||||||
if not isinstance(data["verifier"], str) or not data["verifier"].strip():
|
|
||||||
raise SystemExit(f"{path}: verifier must be non-empty string")
|
|
||||||
|
|
||||||
for field in ("acceptance_criteria", "out_of_scope"):
|
|
||||||
value = data[field]
|
|
||||||
if not isinstance(value, list) or not value:
|
|
||||||
raise SystemExit(f"{path}: {field} must be a non-empty list")
|
|
||||||
if any(not isinstance(item, str) or not item.strip() for item in value):
|
|
||||||
raise SystemExit(f"{path}: {field} items must be non-empty strings")
|
|
||||||
|
|
||||||
print(f"{path}: contract schema OK")
|
|
||||||
PY
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
readonly SCRIPT_DIR
|
||||||
readonly EVIDENCE_GLOB='docs/superpowers/evidence/*.json'
|
readonly EVIDENCE_GLOB='docs/superpowers/evidence/*.json'
|
||||||
|
|
||||||
has_code_changes() {
|
has_code_changes() {
|
||||||
@ -15,72 +17,7 @@ find_staged_evidence_files() {
|
|||||||
|
|
||||||
validate_json_schema() {
|
validate_json_schema() {
|
||||||
local file_path="$1"
|
local file_path="$1"
|
||||||
|
python "${SCRIPT_DIR}/validate_evidence.py" "$file_path"
|
||||||
python - "$file_path" <<'PY'
|
|
||||||
import json
|
|
||||||
import pathlib
|
|
||||||
import sys
|
|
||||||
|
|
||||||
path = pathlib.Path(sys.argv[1])
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except Exception as exc: # pragma: no cover - hook error path
|
|
||||||
raise SystemExit(f"{path}: invalid JSON ({exc})")
|
|
||||||
|
|
||||||
required = [
|
|
||||||
"intent",
|
|
||||||
"scope",
|
|
||||||
"changes",
|
|
||||||
"verification",
|
|
||||||
"risks",
|
|
||||||
"rollback",
|
|
||||||
]
|
|
||||||
|
|
||||||
missing = [key for key in required if key not in data]
|
|
||||||
if missing:
|
|
||||||
raise SystemExit(f"{path}: missing required keys: {', '.join(missing)}")
|
|
||||||
|
|
||||||
if not isinstance(data["intent"], str) or not data["intent"].strip():
|
|
||||||
raise SystemExit(f"{path}: intent must be a non-empty string")
|
|
||||||
|
|
||||||
for key in ("scope", "changes", "risks", "rollback"):
|
|
||||||
value = data[key]
|
|
||||||
if not isinstance(value, list) or not value:
|
|
||||||
raise SystemExit(f"{path}: {key} must be a non-empty list")
|
|
||||||
if any(not isinstance(item, str) or not item.strip() for item in value):
|
|
||||||
raise SystemExit(f"{path}: {key} entries must be non-empty strings")
|
|
||||||
|
|
||||||
verification = data["verification"]
|
|
||||||
if not isinstance(verification, list) or not verification:
|
|
||||||
raise SystemExit(f"{path}: verification must be a non-empty list")
|
|
||||||
|
|
||||||
required_verification_fields = {"command", "result", "evidence"}
|
|
||||||
for index, item in enumerate(verification):
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
raise SystemExit(f"{path}: verification[{index}] must be an object")
|
|
||||||
missing_fields = required_verification_fields - item.keys()
|
|
||||||
if missing_fields:
|
|
||||||
missing_joined = ", ".join(sorted(missing_fields))
|
|
||||||
raise SystemExit(
|
|
||||||
f"{path}: verification[{index}] missing fields: {missing_joined}"
|
|
||||||
)
|
|
||||||
for field in required_verification_fields:
|
|
||||||
value = item[field]
|
|
||||||
if not isinstance(value, str) or not value.strip():
|
|
||||||
raise SystemExit(
|
|
||||||
f"{path}: verification[{index}].{field} must be a non-empty string"
|
|
||||||
)
|
|
||||||
|
|
||||||
content_lower = path.read_text(encoding="utf-8").lower()
|
|
||||||
for phrase in ("should work", "probably fine", "seems right"):
|
|
||||||
if phrase in content_lower:
|
|
||||||
raise SystemExit(
|
|
||||||
f"{path}: contains rationalization phrase '{phrase}', replace with evidence"
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"{path}: schema OK")
|
|
||||||
PY
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Check that all Python files are under python_pkg/.
|
# Check that all Python files are under python_pkg/.
|
||||||
# Exceptions: linux_configuration/, Bash/,
|
# Exceptions: linux_configuration/, phone_focus_mode/, Bash/,
|
||||||
# and vendored/generated directories.
|
# and vendored/generated directories.
|
||||||
# Used as a pre-commit hook; receives staged file paths as arguments.
|
# Used as a pre-commit hook; receives staged file paths as arguments.
|
||||||
|
|
||||||
@ -17,7 +17,7 @@ for file in "$@"; do
|
|||||||
|
|
||||||
# Skip allowed directories (non-Python projects with some Python scripts)
|
# Skip allowed directories (non-Python projects with some Python scripts)
|
||||||
case "$file" in
|
case "$file" in
|
||||||
linux_configuration/*|scripts/*|meta/scripts/*) continue ;;
|
linux_configuration/*|phone_focus_mode/*|scripts/*|meta/scripts/*) continue ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Skip vendored/generated directories
|
# Skip vendored/generated directories
|
||||||
|
|||||||
105
meta/scripts/validate_contract.py
Executable file
105
meta/scripts/validate_contract.py
Executable file
@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate a workflow-contract artifact against the required schema.
|
||||||
|
|
||||||
|
Used by the ``check_agent_contract.sh`` pre-commit hook: given one contract JSON
|
||||||
|
path as ``argv[1]``, it prints ``<path>: contract schema OK`` and exits 0 when the
|
||||||
|
file is valid, or writes each problem to stderr and exits 1 otherwise.
|
||||||
|
|
||||||
|
Kept as a standalone module (not inline ``python <<PY`` in the shell hook) so the
|
||||||
|
repository's Python tooling applies; see CLAUDE.md "Shell Style".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Top-level keys every contract must define.
|
||||||
|
_REQUIRED_KEYS = (
|
||||||
|
"title",
|
||||||
|
"objective",
|
||||||
|
"acceptance_criteria",
|
||||||
|
"out_of_scope",
|
||||||
|
"verifier",
|
||||||
|
)
|
||||||
|
# Keys whose value must be a non-empty string.
|
||||||
|
_STRING_KEYS = ("title", "objective", "verifier")
|
||||||
|
# Keys whose value must be a non-empty list of non-empty strings.
|
||||||
|
_STRING_LIST_KEYS = ("acceptance_criteria", "out_of_scope")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_nonempty_str(value: object) -> bool:
|
||||||
|
"""Return True if ``value`` is a string with non-whitespace content."""
|
||||||
|
return isinstance(value, str) and bool(value.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _check_required_keys(data: dict[str, object]) -> list[str]:
|
||||||
|
"""Report any required top-level keys that are absent."""
|
||||||
|
missing = [key for key in _REQUIRED_KEYS if key not in data]
|
||||||
|
if missing:
|
||||||
|
return [f"missing required fields: {', '.join(missing)}"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _check_strings(data: dict[str, object]) -> list[str]:
|
||||||
|
"""Each scalar field must be a non-empty string."""
|
||||||
|
return [
|
||||||
|
f"{key} must be non-empty string"
|
||||||
|
for key in _STRING_KEYS
|
||||||
|
if not _is_nonempty_str(data.get(key))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _check_string_lists(data: dict[str, object]) -> list[str]:
|
||||||
|
"""Each list field must be a non-empty list of non-empty strings."""
|
||||||
|
errors: list[str] = []
|
||||||
|
for key in _STRING_LIST_KEYS:
|
||||||
|
value = data.get(key)
|
||||||
|
if not isinstance(value, list) or not value:
|
||||||
|
errors.append(f"{key} must be a non-empty list")
|
||||||
|
continue
|
||||||
|
if any(not _is_nonempty_str(item) for item in value):
|
||||||
|
errors.append(f"{key} items must be non-empty strings")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate(path: Path) -> list[str]:
|
||||||
|
"""Return a list of schema problems for ``path`` (empty when it is valid)."""
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
return [f"cannot read file ({exc})"]
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
return [f"invalid JSON ({exc})"]
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return ["top-level JSON value must be an object"]
|
||||||
|
|
||||||
|
errors = _check_required_keys(data)
|
||||||
|
if errors: # without the keys present, the per-field checks are noise
|
||||||
|
return errors
|
||||||
|
errors += _check_strings(data)
|
||||||
|
errors += _check_string_lists(data)
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Validate the contract named by ``argv[1]``; return a process exit code."""
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if not args:
|
||||||
|
sys.stderr.write("usage: validate_contract.py <contract.json>\n")
|
||||||
|
return 2
|
||||||
|
path = Path(args[0])
|
||||||
|
errors = validate(path)
|
||||||
|
if errors:
|
||||||
|
for error in errors:
|
||||||
|
sys.stderr.write(f"{path}: {error}\n")
|
||||||
|
return 1
|
||||||
|
sys.stdout.write(f"{path}: contract schema OK\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
135
meta/scripts/validate_evidence.py
Executable file
135
meta/scripts/validate_evidence.py
Executable file
@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate an AI-evidence artifact against the required schema.
|
||||||
|
|
||||||
|
Used by the ``check_ai_evidence.sh`` pre-commit hook: it is given one evidence
|
||||||
|
JSON path as ``argv[1]``, prints ``<path>: schema OK`` and exits 0 when the file
|
||||||
|
satisfies the schema, or writes each problem to stderr and exits 1 otherwise.
|
||||||
|
|
||||||
|
Kept as a standalone module (not inline ``python <<PY`` in the shell hook) so the
|
||||||
|
repository's Python tooling applies; see CLAUDE.md "Shell Style".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Top-level keys every evidence artifact must define.
|
||||||
|
_REQUIRED_KEYS = ("intent", "scope", "changes", "verification", "risks", "rollback")
|
||||||
|
# Keys whose value must be a non-empty list of non-empty strings.
|
||||||
|
_STRING_LIST_KEYS = ("scope", "changes", "risks", "rollback")
|
||||||
|
# Fields required on every entry of the "verification" list.
|
||||||
|
_VERIFICATION_FIELDS = ("command", "result", "evidence")
|
||||||
|
# Rationalization phrases that must be replaced with concrete evidence.
|
||||||
|
_BANNED_PHRASES = ("should work", "probably fine", "seems right")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_nonempty_str(value: object) -> bool:
|
||||||
|
"""Return True if ``value`` is a string with non-whitespace content."""
|
||||||
|
return isinstance(value, str) and bool(value.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _check_required_keys(data: dict[str, object]) -> list[str]:
|
||||||
|
"""Report any required top-level keys that are absent."""
|
||||||
|
missing = [key for key in _REQUIRED_KEYS if key not in data]
|
||||||
|
if missing:
|
||||||
|
return [f"missing required keys: {', '.join(missing)}"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _check_intent(data: dict[str, object]) -> list[str]:
|
||||||
|
"""The ``intent`` field must be a non-empty string."""
|
||||||
|
if not _is_nonempty_str(data.get("intent")):
|
||||||
|
return ["intent must be a non-empty string"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _check_string_lists(data: dict[str, object]) -> list[str]:
|
||||||
|
"""Each string-list field must be a non-empty list of non-empty strings."""
|
||||||
|
errors: list[str] = []
|
||||||
|
for key in _STRING_LIST_KEYS:
|
||||||
|
value = data.get(key)
|
||||||
|
if not isinstance(value, list) or not value:
|
||||||
|
errors.append(f"{key} must be a non-empty list")
|
||||||
|
continue
|
||||||
|
if any(not _is_nonempty_str(item) for item in value):
|
||||||
|
errors.append(f"{key} entries must be non-empty strings")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def _check_verification(data: dict[str, object]) -> list[str]:
|
||||||
|
"""``verification`` must be a non-empty list of fully-populated objects."""
|
||||||
|
verification = data.get("verification")
|
||||||
|
if not isinstance(verification, list) or not verification:
|
||||||
|
return ["verification must be a non-empty list"]
|
||||||
|
errors: list[str] = []
|
||||||
|
for index, item in enumerate(verification):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
errors.append(f"verification[{index}] must be an object")
|
||||||
|
continue
|
||||||
|
missing = [field for field in _VERIFICATION_FIELDS if field not in item]
|
||||||
|
if missing:
|
||||||
|
errors.append(f"verification[{index}] missing fields: {', '.join(missing)}")
|
||||||
|
bad = [
|
||||||
|
field
|
||||||
|
for field in _VERIFICATION_FIELDS
|
||||||
|
if field in item and not _is_nonempty_str(item[field])
|
||||||
|
]
|
||||||
|
errors.extend(
|
||||||
|
f"verification[{index}].{field} must be a non-empty string" for field in bad
|
||||||
|
)
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def _check_phrases(text: str) -> list[str]:
|
||||||
|
"""Reject artifacts containing rationalization phrases instead of evidence."""
|
||||||
|
lowered = text.lower()
|
||||||
|
return [
|
||||||
|
f"contains rationalization phrase '{phrase}', replace with evidence"
|
||||||
|
for phrase in _BANNED_PHRASES
|
||||||
|
if phrase in lowered
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def validate(path: Path) -> list[str]:
|
||||||
|
"""Return a list of schema problems for ``path`` (empty when it is valid)."""
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
return [f"cannot read file ({exc})"]
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
return [f"invalid JSON ({exc})"]
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return ["top-level JSON value must be an object"]
|
||||||
|
|
||||||
|
errors = _check_required_keys(data)
|
||||||
|
if errors: # without the keys present, the per-field checks are noise
|
||||||
|
return errors
|
||||||
|
errors += _check_intent(data)
|
||||||
|
errors += _check_string_lists(data)
|
||||||
|
errors += _check_verification(data)
|
||||||
|
errors += _check_phrases(text)
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Validate the artifact named by ``argv[1]``; return a process exit code."""
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if not args:
|
||||||
|
sys.stderr.write("usage: validate_evidence.py <evidence.json>\n")
|
||||||
|
return 2
|
||||||
|
path = Path(args[0])
|
||||||
|
errors = validate(path)
|
||||||
|
if errors:
|
||||||
|
for error in errors:
|
||||||
|
sys.stderr.write(f"{path}: {error}\n")
|
||||||
|
return 1
|
||||||
|
sys.stdout.write(f"{path}: schema OK\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@ -426,26 +426,9 @@ do_deploy() {
|
|||||||
# *value* column of a hosts entry ("<ip> <domain>" possibly
|
# *value* column of a hosts entry ("<ip> <domain>" possibly
|
||||||
# followed by aliases). We strip any line whose first non-IP
|
# followed by aliases). We strip any line whose first non-IP
|
||||||
# token matches one of the unblock domains.
|
# token matches one of the unblock domains.
|
||||||
python3 - "$HOSTS_TMP" "$HOSTS_WORKOUT_TMP" <<PY_EOF || cp "$HOSTS_TMP" "$HOSTS_WORKOUT_TMP"
|
WORKOUT_UNBLOCK_DOMAINS="$UNBLOCK_DOMAINS" \
|
||||||
import sys
|
python3 "$SCRIPT_DIR/strip_workout_hosts.py" "$HOSTS_TMP" "$HOSTS_WORKOUT_TMP" \
|
||||||
|
|| cp "$HOSTS_TMP" "$HOSTS_WORKOUT_TMP"
|
||||||
unblock = set("""
|
|
||||||
$UNBLOCK_DOMAINS
|
|
||||||
""".split())
|
|
||||||
|
|
||||||
with open(sys.argv[1], 'r', encoding='utf-8', errors='replace') as src, \
|
|
||||||
open(sys.argv[2], 'w', encoding='utf-8') as dst:
|
|
||||||
for line in src:
|
|
||||||
s = line.strip()
|
|
||||||
if not s or s.startswith('#'):
|
|
||||||
dst.write(line)
|
|
||||||
continue
|
|
||||||
parts = s.split()
|
|
||||||
# Hosts entry layout: <ip> <name> [aliases...]
|
|
||||||
if len(parts) >= 2 and any(p.lower() in unblock for p in parts[1:]):
|
|
||||||
continue
|
|
||||||
dst.write(line)
|
|
||||||
PY_EOF
|
|
||||||
workout_hash="$(compute_file_hash "$HOSTS_WORKOUT_TMP")"
|
workout_hash="$(compute_file_hash "$HOSTS_WORKOUT_TMP")"
|
||||||
printf '%s\n' "$workout_hash" > "$HOSTS_WORKOUT_SHA_TMP"
|
printf '%s\n' "$workout_hash" > "$HOSTS_WORKOUT_SHA_TMP"
|
||||||
stripped_lines=$(($(wc -l < "$HOSTS_TMP") - $(wc -l < "$HOSTS_WORKOUT_TMP")))
|
stripped_lines=$(($(wc -l < "$HOSTS_TMP") - $(wc -l < "$HOSTS_WORKOUT_TMP")))
|
||||||
|
|||||||
@ -3,6 +3,11 @@
|
|||||||
# Requires: adb_common.sh sourced, ADB_SERIAL set, backup_manifest.sh sourced.
|
# Requires: adb_common.sh sourced, ADB_SERIAL set, backup_manifest.sh sourced.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Directory of this library, used to locate sibling helper scripts (BASH_SOURCE
|
||||||
|
# resolves to monitor.sh even when sourced, so the path is stable).
|
||||||
|
_MONITOR_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
readonly _MONITOR_LIB_DIR
|
||||||
|
|
||||||
readonly _MONITOR_REMOTE_DIR="/data/local/tmp/focus_mode"
|
readonly _MONITOR_REMOTE_DIR="/data/local/tmp/focus_mode"
|
||||||
readonly _MONITOR_HOSTS_CANONICAL="/data/local/tmp/focus_mode/hosts.canonical"
|
readonly _MONITOR_HOSTS_CANONICAL="/data/local/tmp/focus_mode/hosts.canonical"
|
||||||
readonly _MONITOR_HOSTS_SHA_FILE="/data/local/tmp/focus_mode/hosts.sha256"
|
readonly _MONITOR_HOSTS_SHA_FILE="/data/local/tmp/focus_mode/hosts.sha256"
|
||||||
@ -431,33 +436,7 @@ monitor_print_summary() {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
python - "${report_path}" <<'PY'
|
python "${_MONITOR_LIB_DIR}/monitor_report.py" summary "${report_path}"
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
report_path = sys.argv[1]
|
|
||||||
with open(report_path, encoding="utf-8") as handle:
|
|
||||||
report = json.load(handle)
|
|
||||||
|
|
||||||
counts = {"ok": 0, "warn": 0, "error": 0, "fatal": 0}
|
|
||||||
issues = []
|
|
||||||
for check in report.get("checks", []):
|
|
||||||
status = check.get("status", "warn")
|
|
||||||
counts[status] = counts.get(status, 0) + 1
|
|
||||||
if status in {"warn", "error", "fatal"}:
|
|
||||||
issues.append((status, check.get("check", "unknown"), check.get("message", "")))
|
|
||||||
|
|
||||||
print("\n=== Monitoring Summary ===")
|
|
||||||
print(
|
|
||||||
f" ok={counts.get('ok', 0):<3} warn={counts.get('warn', 0):<3} "
|
|
||||||
f"error={counts.get('error', 0):<3} fatal={counts.get('fatal', 0):<3}"
|
|
||||||
)
|
|
||||||
if issues:
|
|
||||||
print("\nIssues found:")
|
|
||||||
for status, check_name, message in issues:
|
|
||||||
print(f" [{status}] {check_name}: {message}")
|
|
||||||
print("==========================\n")
|
|
||||||
PY
|
|
||||||
}
|
}
|
||||||
|
|
||||||
monitor_severity_exit() {
|
monitor_severity_exit() {
|
||||||
@ -466,18 +445,5 @@ monitor_severity_exit() {
|
|||||||
|
|
||||||
[[ -f "${report_path}" ]] || return 0
|
[[ -f "${report_path}" ]] || return 0
|
||||||
|
|
||||||
python - "${report_path}" <<'PY'
|
python "${_MONITOR_LIB_DIR}/monitor_report.py" severity "${report_path}"
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
report_path = sys.argv[1]
|
|
||||||
with open(report_path, encoding="utf-8") as handle:
|
|
||||||
report = json.load(handle)
|
|
||||||
|
|
||||||
has_severe = any(
|
|
||||||
check.get("status") in {"fatal", "error"}
|
|
||||||
for check in report.get("checks", [])
|
|
||||||
)
|
|
||||||
raise SystemExit(1 if has_severe else 0)
|
|
||||||
PY
|
|
||||||
}
|
}
|
||||||
|
|||||||
99
phone_focus_mode/lib/monitor_report.py
Executable file
99
phone_focus_mode/lib/monitor_report.py
Executable file
@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Render or grade a phone-monitoring report produced by ``monitor.sh``.
|
||||||
|
|
||||||
|
Two modes, selected by ``argv[1]``:
|
||||||
|
|
||||||
|
* ``summary`` — print a human-readable status summary to stdout (always exit 0).
|
||||||
|
* ``severity`` — exit 1 if any check is ``fatal``/``error``, else exit 0 (no output).
|
||||||
|
|
||||||
|
``argv[2]`` is the path to the ``report.json`` snapshot. Kept as a standalone
|
||||||
|
module (not inline ``python <<PY`` in ``monitor.sh``) so the repository's Python
|
||||||
|
tooling applies; see CLAUDE.md "Shell Style".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Status buckets shown in the summary header, in display order.
|
||||||
|
_COUNT_KEYS = ("ok", "warn", "error", "fatal")
|
||||||
|
# Statuses considered problems worth listing / failing on.
|
||||||
|
_ISSUE_STATUSES = frozenset({"warn", "error", "fatal"})
|
||||||
|
_SEVERE_STATUSES = frozenset({"error", "fatal"})
|
||||||
|
|
||||||
|
|
||||||
|
def _load_checks(report_path: Path) -> list[object]:
|
||||||
|
"""Return the ``checks`` array from the report, or [] if absent/malformed."""
|
||||||
|
data: object = json.loads(report_path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return []
|
||||||
|
checks: object = data.get("checks", [])
|
||||||
|
return checks if isinstance(checks, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _field(check: object, key: str, default: str) -> str:
|
||||||
|
"""Read a string field from a check object, falling back to ``default``."""
|
||||||
|
if isinstance(check, dict):
|
||||||
|
value = check.get(key, default)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _render_summary(checks: list[object]) -> str:
|
||||||
|
"""Build the multi-line monitoring summary string for the given checks."""
|
||||||
|
counts = dict.fromkeys(_COUNT_KEYS, 0)
|
||||||
|
issues: list[tuple[str, str, str]] = []
|
||||||
|
for check in checks:
|
||||||
|
status = _field(check, "status", "warn")
|
||||||
|
counts[status] = counts.get(status, 0) + 1
|
||||||
|
if status in _ISSUE_STATUSES:
|
||||||
|
issues.append(
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
_field(check, "check", "unknown"),
|
||||||
|
_field(check, "message", ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"",
|
||||||
|
"=== Monitoring Summary ===",
|
||||||
|
f" ok={counts.get('ok', 0):<3} warn={counts.get('warn', 0):<3} "
|
||||||
|
f"error={counts.get('error', 0):<3} fatal={counts.get('fatal', 0):<3}",
|
||||||
|
]
|
||||||
|
if issues:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Issues found:")
|
||||||
|
lines.extend(
|
||||||
|
f" [{status}] {name}: {message}" for status, name, message in issues
|
||||||
|
)
|
||||||
|
lines.append("==========================")
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_severe(checks: list[object]) -> bool:
|
||||||
|
"""Return True if any check has a fatal/error status."""
|
||||||
|
return any(_field(check, "status", "warn") in _SEVERE_STATUSES for check in checks)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Dispatch on ``argv[1]`` (summary|severity) and ``argv[2]`` (report path)."""
|
||||||
|
args = sys.argv[1:]
|
||||||
|
expected_args = 2
|
||||||
|
if len(args) != expected_args or args[0] not in {"summary", "severity"}:
|
||||||
|
sys.stderr.write("usage: monitor_report.py {summary|severity} <report.json>\n")
|
||||||
|
return 2
|
||||||
|
mode, report_path = args[0], Path(args[1])
|
||||||
|
checks = _load_checks(report_path)
|
||||||
|
if mode == "summary":
|
||||||
|
sys.stdout.write(_render_summary(checks))
|
||||||
|
return 0
|
||||||
|
return 1 if _has_severe(checks) else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
55
phone_focus_mode/strip_workout_hosts.py
Executable file
55
phone_focus_mode/strip_workout_hosts.py
Executable file
@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Produce the workout variant of the canonical hosts file.
|
||||||
|
|
||||||
|
Copies ``argv[1]`` (the full hosts file) to ``argv[2]``, dropping every entry
|
||||||
|
whose host name (or any alias) matches a workout-allowlisted domain. The domain
|
||||||
|
set is read from the ``WORKOUT_UNBLOCK_DOMAINS`` environment variable (whitespace
|
||||||
|
separated) so the generator and the on-device runtime share one source of truth.
|
||||||
|
|
||||||
|
Kept as a standalone module (not inline ``python <<PY`` in ``deploy.sh``) so the
|
||||||
|
repository's Python tooling applies; see CLAUDE.md "Shell Style".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# A hosts entry needs at least an IP and one name: "<ip> <name> [aliases...]".
|
||||||
|
_MIN_HOSTS_FIELDS = 2
|
||||||
|
|
||||||
|
|
||||||
|
def _strip(source: Path, dest: Path, unblock: frozenset[str]) -> None:
|
||||||
|
"""Write ``source`` to ``dest`` minus lines that map an unblocked domain."""
|
||||||
|
text = source.read_text(encoding="utf-8", errors="replace")
|
||||||
|
kept: list[str] = []
|
||||||
|
for line in text.splitlines(keepends=True):
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
kept.append(line)
|
||||||
|
continue
|
||||||
|
parts = stripped.split()
|
||||||
|
names = parts[1:]
|
||||||
|
if len(parts) >= _MIN_HOSTS_FIELDS and any(
|
||||||
|
name.lower() in unblock for name in names
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
kept.append(line)
|
||||||
|
dest.write_text("".join(kept), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Read the source/dest paths from argv and the domains from the env."""
|
||||||
|
args = sys.argv[1:]
|
||||||
|
expected_args = 2
|
||||||
|
if len(args) != expected_args:
|
||||||
|
sys.stderr.write("usage: strip_workout_hosts.py <src-hosts> <dst-hosts>\n")
|
||||||
|
return 2
|
||||||
|
unblock = frozenset(os.environ.get("WORKOUT_UNBLOCK_DOMAINS", "").split())
|
||||||
|
_strip(Path(args[0]), Path(args[1]), unblock)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
1
python_pkg/live_calc/__init__.py
Normal file
1
python_pkg/live_calc/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Live-calc: safe arithmetic evaluator backing the calc-live.zsh prompt widget."""
|
||||||
231
python_pkg/live_calc/calc_eval.py
Normal file
231
python_pkg/live_calc/calc_eval.py
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
"""Safe arithmetic evaluator for the live-calc zsh widget.
|
||||||
|
|
||||||
|
Read one expression from ``sys.argv[1]`` and write its formatted numeric result
|
||||||
|
to stdout, or write nothing on any error, unsafe input, overflow, or timeout.
|
||||||
|
|
||||||
|
The expression is parsed into an AST and evaluated by walking a strict
|
||||||
|
whitelist of node types, so it can never import modules, access attributes, or
|
||||||
|
execute arbitrary code. CPU and wall-clock time are capped so that a runaway
|
||||||
|
expression typed live (for example ``9**9**9``) cannot freeze the shell.
|
||||||
|
|
||||||
|
Used by ``calc-live.zsh``; kept as a standalone module so the repository's
|
||||||
|
Python tooling (ruff, mypy, pylint, bandit) applies to it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import contextlib
|
||||||
|
import math
|
||||||
|
import operator
|
||||||
|
import resource
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
from typing import TYPE_CHECKING, NoReturn, TypeAlias
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
from types import FrameType
|
||||||
|
|
||||||
|
Number: TypeAlias = int | float
|
||||||
|
|
||||||
|
# Whitelisted callables, addressed by the name used in the expression.
|
||||||
|
_FUNCTIONS: dict[str, Callable[..., Number]] = {
|
||||||
|
"sqrt": math.sqrt,
|
||||||
|
"abs": abs,
|
||||||
|
"round": round,
|
||||||
|
"sin": math.sin,
|
||||||
|
"cos": math.cos,
|
||||||
|
"tan": math.tan,
|
||||||
|
"asin": math.asin,
|
||||||
|
"acos": math.acos,
|
||||||
|
"atan": math.atan,
|
||||||
|
"ln": math.log,
|
||||||
|
"log": math.log10,
|
||||||
|
"log2": math.log2,
|
||||||
|
"exp": math.exp,
|
||||||
|
"floor": math.floor,
|
||||||
|
"ceil": math.ceil,
|
||||||
|
"factorial": math.factorial,
|
||||||
|
"gcd": math.gcd,
|
||||||
|
"deg": math.degrees,
|
||||||
|
"rad": math.radians,
|
||||||
|
"min": min,
|
||||||
|
"max": max,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Whitelisted constants.
|
||||||
|
_CONSTANTS: dict[str, float] = {"pi": math.pi, "e": math.e, "tau": math.tau}
|
||||||
|
|
||||||
|
# Binary and unary operators, addressed by AST node type.
|
||||||
|
_BINARY_OPS: dict[type[ast.operator], Callable[[Number, Number], Number]] = {
|
||||||
|
ast.Add: operator.add,
|
||||||
|
ast.Sub: operator.sub,
|
||||||
|
ast.Mult: operator.mul,
|
||||||
|
ast.Div: operator.truediv,
|
||||||
|
ast.FloorDiv: operator.floordiv,
|
||||||
|
ast.Mod: operator.mod,
|
||||||
|
ast.Pow: operator.pow,
|
||||||
|
}
|
||||||
|
_UNARY_OPS: dict[type[ast.unaryop], Callable[[Number], Number]] = {
|
||||||
|
ast.UAdd: operator.pos,
|
||||||
|
ast.USub: operator.neg,
|
||||||
|
}
|
||||||
|
|
||||||
|
_MAX_EXPONENT = 10_000 # refuse a ** b for very large b before computing
|
||||||
|
_MAX_FACTORIAL_ARG = 10_000 # factorial grows astronomically fast
|
||||||
|
_MAX_INT_DIGITS = 25 # longer ints are shown in scientific form
|
||||||
|
_FLOAT_PRECISION = 12 # significant digits for float results
|
||||||
|
_SCI_PRECISION = 6 # significant digits for the scientific fallback
|
||||||
|
_CPU_LIMIT_SECONDS = 1 # hard kernel CPU cap (SIGXCPU terminates)
|
||||||
|
_WALL_LIMIT_SECONDS = 0.4 # soft wall-clock cap (SIGALRM)
|
||||||
|
|
||||||
|
|
||||||
|
class _CalcError(Exception):
|
||||||
|
"""Raised when the input is not a permitted arithmetic expression."""
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_timeout(_signum: int, _frame: FrameType | None) -> NoReturn:
|
||||||
|
"""SIGALRM handler: abort a too-slow evaluation via a catchable exception."""
|
||||||
|
raise TimeoutError
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_limits() -> None:
|
||||||
|
"""Cap CPU and wall-clock time so a runaway expression cannot hang the shell."""
|
||||||
|
with contextlib.suppress(ValueError, OSError):
|
||||||
|
resource.setrlimit(
|
||||||
|
resource.RLIMIT_CPU,
|
||||||
|
(_CPU_LIMIT_SECONDS, _CPU_LIMIT_SECONDS),
|
||||||
|
)
|
||||||
|
with contextlib.suppress(ValueError, OSError):
|
||||||
|
signal.signal(signal.SIGALRM, _raise_timeout)
|
||||||
|
signal.setitimer(signal.ITIMER_REAL, _WALL_LIMIT_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def _eval_constant(node: ast.Constant) -> Number:
|
||||||
|
"""Return a numeric literal value, rejecting booleans and other types."""
|
||||||
|
if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
|
||||||
|
raise _CalcError
|
||||||
|
return node.value
|
||||||
|
|
||||||
|
|
||||||
|
def _eval_name(node: ast.Name) -> Number:
|
||||||
|
"""Return the value of a whitelisted constant name (pi, e, tau)."""
|
||||||
|
try:
|
||||||
|
return _CONSTANTS[node.id]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise _CalcError from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _eval_unaryop(node: ast.UnaryOp) -> Number:
|
||||||
|
"""Evaluate a unary plus/minus operation."""
|
||||||
|
try:
|
||||||
|
func = _UNARY_OPS[type(node.op)]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise _CalcError from exc
|
||||||
|
return func(_eval(node.operand))
|
||||||
|
|
||||||
|
|
||||||
|
def _eval_binop(node: ast.BinOp) -> Number:
|
||||||
|
"""Evaluate a binary operation, guarding against explosive exponents."""
|
||||||
|
try:
|
||||||
|
func = _BINARY_OPS[type(node.op)]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise _CalcError from exc
|
||||||
|
left = _eval(node.left)
|
||||||
|
right = _eval(node.right)
|
||||||
|
if isinstance(node.op, ast.Pow) and abs(right) > _MAX_EXPONENT:
|
||||||
|
raise _CalcError
|
||||||
|
return func(left, right)
|
||||||
|
|
||||||
|
|
||||||
|
def _eval_call(node: ast.Call) -> Number:
|
||||||
|
"""Evaluate a call to a whitelisted function, bounding factorial growth."""
|
||||||
|
if not isinstance(node.func, ast.Name) or node.keywords:
|
||||||
|
raise _CalcError
|
||||||
|
try:
|
||||||
|
func = _FUNCTIONS[node.func.id]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise _CalcError from exc
|
||||||
|
args = [_eval(arg) for arg in node.args]
|
||||||
|
if node.func.id == "factorial" and (
|
||||||
|
not args or not isinstance(args[0], int) or args[0] > _MAX_FACTORIAL_ARG
|
||||||
|
):
|
||||||
|
raise _CalcError
|
||||||
|
return func(*args)
|
||||||
|
|
||||||
|
|
||||||
|
def _eval(node: ast.AST) -> Number:
|
||||||
|
"""Recursively evaluate one whitelisted AST node."""
|
||||||
|
if isinstance(node, ast.Expression):
|
||||||
|
return _eval(node.body)
|
||||||
|
if isinstance(node, ast.Constant):
|
||||||
|
return _eval_constant(node)
|
||||||
|
if isinstance(node, ast.Name):
|
||||||
|
return _eval_name(node)
|
||||||
|
if isinstance(node, ast.UnaryOp):
|
||||||
|
return _eval_unaryop(node)
|
||||||
|
if isinstance(node, ast.BinOp):
|
||||||
|
return _eval_binop(node)
|
||||||
|
if isinstance(node, ast.Call):
|
||||||
|
return _eval_call(node)
|
||||||
|
raise _CalcError
|
||||||
|
|
||||||
|
|
||||||
|
def _format(value: Number) -> str:
|
||||||
|
"""Format a numeric result compactly, or return '' if it cannot be shown."""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
value = int(value)
|
||||||
|
if isinstance(value, int):
|
||||||
|
text = str(value)
|
||||||
|
if len(text) <= _MAX_INT_DIGITS:
|
||||||
|
return text
|
||||||
|
try:
|
||||||
|
return format(float(value), f".{_SCI_PRECISION}g")
|
||||||
|
except OverflowError:
|
||||||
|
return ""
|
||||||
|
if math.isnan(value) or math.isinf(value):
|
||||||
|
return ""
|
||||||
|
return format(value, f".{_FLOAT_PRECISION}g")
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(expression: str) -> str:
|
||||||
|
"""Evaluate ``expression`` and return its formatted result, or '' on failure.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
expression: The arithmetic expression. ``^`` is treated as power.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The formatted result, or an empty string for any invalid, unsafe, or
|
||||||
|
non-terminating input.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tree = ast.parse(expression.replace("^", "**"), mode="eval")
|
||||||
|
return _format(_eval(tree))
|
||||||
|
except (
|
||||||
|
_CalcError,
|
||||||
|
SyntaxError,
|
||||||
|
ArithmeticError,
|
||||||
|
ValueError,
|
||||||
|
TypeError,
|
||||||
|
RecursionError,
|
||||||
|
TimeoutError,
|
||||||
|
MemoryError,
|
||||||
|
):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Read ``argv[1]``, evaluate it under resource limits, and print the result."""
|
||||||
|
_apply_limits()
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if not args:
|
||||||
|
return 0
|
||||||
|
result = evaluate(args[0])
|
||||||
|
if result:
|
||||||
|
sys.stdout.write(result)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
1
python_pkg/live_calc/tests/__init__.py
Normal file
1
python_pkg/live_calc/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Tests for the live_calc subpackage."""
|
||||||
209
python_pkg/live_calc/tests/test_calc_eval.py
Normal file
209
python_pkg/live_calc/tests/test_calc_eval.py
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
"""Tests for the live-calc safe arithmetic evaluator.
|
||||||
|
|
||||||
|
The public surface is ``evaluate``; most branches are reached through it so the
|
||||||
|
tests double as behaviour documentation. A handful of internal helpers
|
||||||
|
(``_format``, ``_apply_limits``, ``_raise_timeout``, ``main``) are exercised
|
||||||
|
directly where a branch cannot be triggered through ``evaluate`` alone.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from python_pkg.live_calc import calc_eval
|
||||||
|
from python_pkg.live_calc.calc_eval import (
|
||||||
|
_apply_limits,
|
||||||
|
_format,
|
||||||
|
_raise_timeout,
|
||||||
|
evaluate,
|
||||||
|
main,
|
||||||
|
)
|
||||||
|
|
||||||
|
_MOD = "python_pkg.live_calc.calc_eval"
|
||||||
|
|
||||||
|
|
||||||
|
class TestArithmetic:
|
||||||
|
"""Core arithmetic, operators and the calculator-style ``^`` power."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("expr", "expected"),
|
||||||
|
[
|
||||||
|
("2+2", "4"),
|
||||||
|
("20*(3+4)", "140"),
|
||||||
|
("10-3", "7"),
|
||||||
|
("7/2", "3.5"),
|
||||||
|
("7//2", "3"),
|
||||||
|
("10%3", "1"),
|
||||||
|
("2^10", "1024"), # ^ is rewritten to **
|
||||||
|
("2**10", "1024"),
|
||||||
|
("+5", "5"),
|
||||||
|
("-5", "-5"),
|
||||||
|
("3/2", "1.5"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_evaluates(self, expr: str, expected: str) -> None:
|
||||||
|
"""Each operator yields the expected formatted result."""
|
||||||
|
assert evaluate(expr) == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestFunctionsAndConstants:
|
||||||
|
"""Whitelisted functions and named constants."""
|
||||||
|
|
||||||
|
def test_sqrt(self) -> None:
|
||||||
|
"""sqrt(4) is 2."""
|
||||||
|
assert evaluate("sqrt(4)") == "2"
|
||||||
|
|
||||||
|
def test_nested_call(self) -> None:
|
||||||
|
"""Functions compose and trig works against pi."""
|
||||||
|
assert evaluate("sin(pi/2)") == "1"
|
||||||
|
|
||||||
|
def test_constant_pi(self) -> None:
|
||||||
|
"""The constant pi resolves to math.pi."""
|
||||||
|
assert evaluate("pi") == format(math.pi, ".12g")
|
||||||
|
|
||||||
|
def test_min_max(self) -> None:
|
||||||
|
"""Multi-argument builtins are allowed."""
|
||||||
|
assert evaluate("max(2, 9, 4)") == "9"
|
||||||
|
|
||||||
|
def test_factorial_ok(self) -> None:
|
||||||
|
"""A small factorial is computed."""
|
||||||
|
assert evaluate("factorial(5)") == "120"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRejected:
|
||||||
|
"""Inputs that must yield '' (invalid, unsafe, or unsupported)."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"expr",
|
||||||
|
[
|
||||||
|
"", # empty
|
||||||
|
"ls -la", # not an expression at all
|
||||||
|
"2+", # SyntaxError
|
||||||
|
"x", # unknown name
|
||||||
|
"sqrt", # function name without a call
|
||||||
|
"foo(2)", # unknown function
|
||||||
|
"True", # bool literal rejected
|
||||||
|
"'a'", # string literal rejected
|
||||||
|
"5 & 3", # unsupported binary operator
|
||||||
|
"~5", # unsupported unary operator
|
||||||
|
"(1+1)(2)", # call target is not a plain name
|
||||||
|
"round(2.5, ndigits=1)", # keyword arguments rejected
|
||||||
|
"[1, 2]", # unsupported node type (list)
|
||||||
|
"factorial()", # factorial needs an argument
|
||||||
|
"factorial(2.5)", # factorial argument must be int
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_returns_empty(self, expr: str) -> None:
|
||||||
|
"""Anything that is not a permitted arithmetic expression yields ''."""
|
||||||
|
assert evaluate(expr) == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunawayGuards:
|
||||||
|
"""Bounds that stop a live keystroke from computing forever."""
|
||||||
|
|
||||||
|
def test_huge_exponent_refused(self) -> None:
|
||||||
|
"""A ** b with an enormous b is refused before computing."""
|
||||||
|
assert evaluate("2^99999") == ""
|
||||||
|
|
||||||
|
def test_huge_factorial_refused(self) -> None:
|
||||||
|
"""factorial of a huge argument is refused up front."""
|
||||||
|
assert evaluate("factorial(99999)") == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatting:
|
||||||
|
"""Number formatting, including the int/scientific/float branches."""
|
||||||
|
|
||||||
|
def test_big_int_uses_scientific(self) -> None:
|
||||||
|
"""Integers longer than the digit cap fall back to scientific form."""
|
||||||
|
assert evaluate("10^30") == format(1e30, ".6g")
|
||||||
|
|
||||||
|
def test_overflowing_int_yields_empty(self) -> None:
|
||||||
|
"""An int too large to convert to float formats to ''."""
|
||||||
|
assert evaluate("10^400") == ""
|
||||||
|
|
||||||
|
def test_infinity_yields_empty(self) -> None:
|
||||||
|
"""A float overflow to infinity formats to ''."""
|
||||||
|
assert evaluate("1e308*10") == ""
|
||||||
|
|
||||||
|
def test_format_bool_is_int(self) -> None:
|
||||||
|
"""_format coerces bool to its int value (reached only directly)."""
|
||||||
|
assert _format(value=True) == "1"
|
||||||
|
|
||||||
|
def test_format_nan_yields_empty(self) -> None:
|
||||||
|
"""_format rejects NaN."""
|
||||||
|
assert _format(float("nan")) == ""
|
||||||
|
|
||||||
|
def test_format_inf_yields_empty(self) -> None:
|
||||||
|
"""_format rejects infinity."""
|
||||||
|
assert _format(float("inf")) == ""
|
||||||
|
|
||||||
|
def test_format_plain_float(self) -> None:
|
||||||
|
"""A finite float is formatted with the float precision."""
|
||||||
|
assert _format(1.5) == "1.5"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTimeoutHandler:
|
||||||
|
"""The SIGALRM handler and the resource-limit installer."""
|
||||||
|
|
||||||
|
def test_raise_timeout_raises(self) -> None:
|
||||||
|
"""The handler converts an alarm into a catchable TimeoutError."""
|
||||||
|
with pytest.raises(TimeoutError):
|
||||||
|
_raise_timeout(0, None)
|
||||||
|
|
||||||
|
def test_apply_limits_installs(self) -> None:
|
||||||
|
"""Limits are installed via setrlimit/signal/setitimer."""
|
||||||
|
with (
|
||||||
|
patch(f"{_MOD}.resource.setrlimit") as set_rlimit,
|
||||||
|
patch(f"{_MOD}.signal.signal") as set_signal,
|
||||||
|
patch(f"{_MOD}.signal.setitimer") as set_timer,
|
||||||
|
):
|
||||||
|
_apply_limits()
|
||||||
|
set_rlimit.assert_called_once()
|
||||||
|
set_signal.assert_called_once()
|
||||||
|
set_timer.assert_called_once()
|
||||||
|
|
||||||
|
def test_apply_limits_survives_unavailable(self) -> None:
|
||||||
|
"""If the platform rejects the limits, the error is swallowed."""
|
||||||
|
with (
|
||||||
|
patch(f"{_MOD}.resource.setrlimit", side_effect=OSError),
|
||||||
|
patch(f"{_MOD}.signal.signal", side_effect=ValueError),
|
||||||
|
patch(f"{_MOD}.signal.setitimer"),
|
||||||
|
):
|
||||||
|
_apply_limits() # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
class TestMain:
|
||||||
|
"""The command-line entry point (argv -> stdout)."""
|
||||||
|
|
||||||
|
def test_no_argument(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""With no expression argument, nothing is printed and rc is 0."""
|
||||||
|
with (
|
||||||
|
patch(f"{_MOD}._apply_limits"),
|
||||||
|
patch.object(calc_eval.sys, "argv", ["calc_eval"]),
|
||||||
|
):
|
||||||
|
assert main() == 0
|
||||||
|
assert capsys.readouterr().out == ""
|
||||||
|
|
||||||
|
def test_valid_expression(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""A valid expression is evaluated and written to stdout."""
|
||||||
|
with (
|
||||||
|
patch(f"{_MOD}._apply_limits"),
|
||||||
|
patch.object(calc_eval.sys, "argv", ["calc_eval", "2+2"]),
|
||||||
|
):
|
||||||
|
assert main() == 0
|
||||||
|
assert capsys.readouterr().out == "4"
|
||||||
|
|
||||||
|
def test_invalid_expression_writes_nothing(
|
||||||
|
self,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
"""An invalid expression produces no output."""
|
||||||
|
with (
|
||||||
|
patch(f"{_MOD}._apply_limits"),
|
||||||
|
patch.object(calc_eval.sys, "argv", ["calc_eval", "ls -la"]),
|
||||||
|
):
|
||||||
|
assert main() == 0
|
||||||
|
assert capsys.readouterr().out == ""
|
||||||
Loading…
Reference in New Issue
Block a user