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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@ Every community PR that lands in main is credited here — that's a project rule
- [@davekopecek](https://github.com/davekopecek) (Dave Kopecek) — committed the design-reference fixture so the design-token guard runs on every machine (#30)
- [@snapsynapse](https://github.com/snapsynapse) (Sam Rogers) — graceful shutdown on SIGINT/SIGTERM with worker-tree cleanup and finished state, plus the 14-test end-to-end CLI regression suite (#4)
- [@mlava](https://github.com/mlava) (Mark Lavercombe) — named setup failures across every diagnostic surface (#37), `run --baseline`, the no-workers check preflight (#38), and guidance on check-writing failure modes (#57)
- [@bluemihai](https://github.com/bluemihai) (Mihai Banulescu) — `scripts/assert-file`, file assertions for checks without the nested-quoting minefield

Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for the philosophy and what gets a PR merged fast. The short version: small and scoped, rebased on current main, every claim backed by an executed test. Authorship is always preserved — where a maintainer pushes a mechanical fix to your branch, you remain the commit author.

Expand Down
146 changes: 146 additions & 0 deletions scripts/assert-file
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""assert-file — file assertions for Ringer checks, without the quoting minefield.

Ringer checks live inside a JSON string, inside a shell command, and every
hand-written `ruby -e '...'` assertion therefore passes through JSON escaping,
shell quoting, and Ruby string parsing before its regex is even compiled. On
2026-09-09 four consecutive checks failed CORRECT worker output:

* one aborted because the banned string it grepped for appeared in the
explanatory COMMENT the brief had asked the worker to write;
* two carried `\\"` sequences that arrived at Ruby as a syntax error or as a
literal that could never match;
* one asserted on a notes.md that belonged to a different task entirely.

Every one of those was the check being wrong, not the work. This script exists
so that class of bug cannot be written again: no nested quoting, comments
stripped by file type, and a non-zero exit that says which assertion failed and
what it actually saw.

Usage:
assert-file PATH --absent PATTERN [...] pattern must NOT appear
assert-file PATH --contains PATTERN [...] pattern MUST appear
assert-file PATH --count PATTERN=N pattern appears exactly N times
assert-file PATH --before A B A must appear before B

Options:
--strip-comments remove comments before matching (default ON; --raw to disable)
--raw match the file verbatim, comments included
--fixed treat patterns as literal strings, not regexes (default)
--regex treat patterns as Python regexes
--label TEXT prefix for the failure message

Exit 0 with a one-line OK when every assertion holds; exit 1 naming the first
failure and, for --absent, the line the match was found on.
"""
import argparse
import re
import sys
from pathlib import Path

# Comment syntaxes by extension, as (pattern, spans_newlines) pairs.
#
# The flag is load-bearing and was a real bug on first write: applying DOTALL to
# every pattern let the LINE-comment form `^\s*#.*$` swallow the entire file from
# its first comment onward (a 5979-byte controller stripped to 175 bytes, so every
# --contains after it reported a false absence). Block comments need `.` to cross
# newlines; line comments must not. ERB's <%# %> is listed first because it is the
# exact shape that failed on 2026-09-09.
COMMENT_PATTERNS = {
".erb": [(r"<%#.*?%>", True)],
".html": [(r"<!--.*?-->", True)],
".rb": [(r"^[ \t]*#.*$", False)],
".py": [(r"^[ \t]*#.*$", False)],
".js": [(r"/\*.*?\*/", True), (r"^[ \t]*//.*$", False)],
".css": [(r"/\*.*?\*/", True)],
".md": [(r"<!--.*?-->", True)],
}


def strip_comments(text, suffix):
for pat, spans_newlines in COMMENT_PATTERNS.get(suffix, []):
flags = re.MULTILINE | (re.DOTALL if spans_newlines else 0)
text = re.sub(pat, "", text, flags=flags)
return text


def find_all(haystack, needle, use_regex):
if use_regex:
return [m.start() for m in re.finditer(needle, haystack)]
return [m.start() for m in re.finditer(re.escape(needle), haystack)]


def line_of(text, offset):
return text.count("\n", 0, offset) + 1


def main():
p = argparse.ArgumentParser(add_help=True)
p.add_argument("path")
p.add_argument("--absent", action="append", default=[])
p.add_argument("--contains", action="append", default=[])
p.add_argument("--count", action="append", default=[])
p.add_argument("--before", nargs=2, action="append", default=[])
p.add_argument("--raw", action="store_true")
p.add_argument("--regex", action="store_true")
p.add_argument("--fixed", action="store_true")
p.add_argument("--label", default="")
a = p.parse_args()

path = Path(a.path)
if not path.exists():
print(f"FAIL: {a.label or a.path}: file does not exist")
return 1

raw = path.read_text(errors="replace")
text = raw if a.raw else strip_comments(raw, path.suffix)
tag = a.label or path.name
scope = "verbatim" if a.raw else "comments stripped"

for pat in a.contains:
if not find_all(text, pat, a.regex):
print(f"FAIL: {tag}: expected to find {pat!r} ({scope}), and it is absent")
return 1

for pat in a.absent:
hits = find_all(text, pat, a.regex)
if hits:
print(
f"FAIL: {tag}: {pat!r} must not appear ({scope}) — "
f"found {len(hits)}x, first at line {line_of(text, hits[0])}"
)
return 1

for spec in a.count:
if "=" not in spec:
print(f"FAIL: --count needs PATTERN=N, got {spec!r}")
return 1
pat, _, want = spec.rpartition("=")
hits = find_all(text, pat, a.regex)
if len(hits) != int(want):
print(f"FAIL: {tag}: expected {pat!r} {want}x ({scope}), found {len(hits)}x")
return 1

for first, second in a.before:
fh = find_all(text, first, a.regex)
sh = find_all(text, second, a.regex)
if not fh:
print(f"FAIL: {tag}: {first!r} not found, so it cannot precede {second!r}")
return 1
if not sh:
print(f"FAIL: {tag}: {second!r} not found, so {first!r} cannot precede it")
return 1
if fh[0] >= sh[0]:
print(
f"FAIL: {tag}: {first!r} (line {line_of(text, fh[0])}) must come before "
f"{second!r} (line {line_of(text, sh[0])})"
)
return 1

n = len(a.contains) + len(a.absent) + len(a.count) + len(a.before)
print(f"OK: {tag} — {n} assertion(s) held ({scope})")
return 0


if __name__ == "__main__":
sys.exit(main())
75 changes: 75 additions & 0 deletions tests/test_assert_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""scripts/assert-file — the behaviours a Ringer check depends on.

Each test runs the real script as a subprocess, because the thing under test IS
its command-line contract: a check invokes it from a shell and reads its exit
code. Asserting on an imported function would prove something a check never
exercises.
"""
from __future__ import annotations

import subprocess
import sys
import tempfile
import unittest
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "assert-file"


class AssertFileTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.dir = Path(self.temp.name)

def write(self, name: str, body: str) -> Path:
path = self.dir / name
path.write_text(body, encoding="utf-8")
return path

def run_it(self, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(SCRIPT), *args],
text=True, capture_output=True, check=False,
)

def test_contains_passes_and_absent_fails_on_the_same_file(self) -> None:
path = self.write("notes.md", "the report is complete\n")
self.assertEqual(0, self.run_it(str(path), "--contains", "report").returncode)
self.assertNotEqual(0, self.run_it(str(path), "--absent", "report").returncode)

def test_comments_are_stripped_before_matching(self) -> None:
# The originating bug: a brief asks the worker to explain itself in a
# comment, the check greps for a banned word, and the worker's own
# explanation trips it. Correct work, failed check.
path = self.write("thing.py", "# we deliberately avoid sleep() here\nvalue = 1\n")
self.assertEqual(0, self.run_it(str(path), "--absent", "sleep(").returncode)
# --raw opts back in to matching the comment text.
self.assertNotEqual(0, self.run_it(str(path), "--raw", "--absent", "sleep(").returncode)

def test_failure_output_names_the_pattern_it_could_not_find(self) -> None:
path = self.write("notes.md", "nothing useful here\n")
result = self.run_it(str(path), "--contains", "conclusion")
self.assertNotEqual(0, result.returncode)
self.assertIn("conclusion", result.stdout + result.stderr)

def test_count_is_exact(self) -> None:
path = self.write("log.txt", "hit\nhit\n")
self.assertEqual(0, self.run_it(str(path), "--count", "hit=2").returncode)
self.assertNotEqual(0, self.run_it(str(path), "--count", "hit=3").returncode)

def test_before_checks_order_not_mere_presence(self) -> None:
path = self.write("report.md", "## Findings\n## Conclusion\n")
self.assertEqual(0, self.run_it(str(path), "--before", "Findings", "Conclusion").returncode)
self.assertNotEqual(0, self.run_it(str(path), "--before", "Conclusion", "Findings").returncode)

def test_a_missing_file_fails_rather_than_passing_vacuously(self) -> None:
# A check that greps a file which was never written must not go green.
missing = self.dir / "never-written.md"
self.assertNotEqual(0, self.run_it(str(missing), "--contains", "anything").returncode)


if __name__ == "__main__":
unittest.main()