check-parens: standalone paren balance checker for lisp blocks in org files

Scans #+begin_src lisp ... #+end_src blocks, strips strings and
comments, reports unbalanced parens per-block with line numbers.
Detects unterminated blocks (no matching #+end_src).

Zero dependencies (stdlib Python). Called from AGENTS.md step:
  projects/check-parens/check-parens org/file.org
This commit is contained in:
2026-05-13 12:02:52 -04:00
parent 3a65052641
commit fc7bc2fef8
3 changed files with 194 additions and 0 deletions

View File

@@ -167,6 +167,8 @@ when the runtime itself cannot start.
## Rules
- After copying code from the REPL to the .org file, run ~../check-parens/check-parens <file.org>~ to verify all Lisp blocks have balanced parentheses. This catches mismatched parens before tangling:
~projects/check-parens/check-parens org/channel-tui-view.org~
- .org is source of truth; .lisp is generated — never edit .lisp directly
- Every code change starts with a contract and a failing test
- Prove RED before writing implementation

View File

@@ -0,0 +1,42 @@
#+TITLE: check-parens
#+FILETAGS: :tool:lisp:org:
Standalone parentheseis checker for Lisp source blocks in Org files.
Scans all ~#+begin_src lisp … #+end_src~ blocks in an Org file, strips
string and comment content, and reports unbalanced parentheses per block.
== Usage
#+begin_src shell
check-parens <file.org> [<file.org> ...]
check-parens -v <file.org>
#+end_src
Exit 0 if all blocks are balanced and terminated, 1 otherwise.
== Output
~file.org: OK~ — all blocks balanced
~file.org: Block at line 27: +2 (missing 2 closes) — near …~
~file.org: Block at line 103: unterminated — no matching #+end_src~
The ~-v~ flag prints the full block content for each issue.
== Integration
Pre-commit hook:
#+begin_src shell
cat > .git/hooks/pre-commit <<'HOOK'
#!/bin/sh
for f in $(git diff --cached --name-only --diff-filter=ACM | grep '\.org$'); do
projects/check-parens/check-parens "$f" || exit 1
done
HOOK
chmod +x .git/hooks/pre-commit
#+end_src
== Dependencies
None (stdlib Python 3).

View File

@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Check paren balance in #+begin_src lisp blocks of .org files.
Usage: check-parens <file.org> [<file.org> ...]
check-parens projects/**/*.org
Exit 0 if all blocks balanced and terminated, 1 otherwise.
"""
import sys
import re
def check_file(path, verbose):
lines = read_lines(path)
if lines is None:
return False
blocks = extract_blocks(lines)
ok = True
for block_start, block_lines in blocks:
# Check termination
if not is_terminated(block_lines):
print(f"{path}: Block starting at line {block_start} — no matching #+end_src")
ok = False
continue
# Extract block body (between begin and end markers)
body = block_lines[1:-1]
if not body:
continue
stripped = strip_strings_and_comments(body)
open_parens = stripped.count("(")
close_parens = stripped.count(")")
diff = open_parens - close_parens
if diff != 0:
show = body[0][:60]
if diff > 0:
print(f"{path}: Block at line {block_start}: +{diff} (missing {diff} close{'s' if diff > 1 else ''}) — near {show!r}")
else:
print(f"{path}: Block at line {block_start}: {diff} (extra {-diff} close{'s' if -diff > 1 else ''}) — near {show!r}")
if verbose:
for l in body:
print(f" | {l}")
ok = False
return ok
def read_lines(path):
try:
with open(path, encoding="utf-8") as f:
return f.readlines()
except FileNotFoundError:
print(f"{path}: file not found", file=sys.stderr)
return None
except Exception as e:
print(f"{path}: error reading file — {e}", file=sys.stderr)
return None
LISP_BEGIN = re.compile(r"#\+begin_src\s+lisp\b", re.IGNORECASE)
END_SRC = re.compile(r"#\+end_src\b", re.IGNORECASE)
def extract_blocks(lines):
blocks = []
start = None
block_lines = []
for i, line in enumerate(lines, start=1):
if start is None:
if LISP_BEGIN.match(line.lstrip()):
start = i
block_lines = [line.rstrip("\n")]
else:
block_lines.append(line.rstrip("\n"))
if END_SRC.match(line.lstrip()):
blocks.append((start, block_lines))
start = None
block_lines = []
if start is not None:
blocks.append((start, block_lines))
return blocks
def is_terminated(block_lines):
return END_SRC.match(block_lines[-1].lstrip()) if block_lines else False
def strip_strings_and_comments(lines):
parens = []
for line in lines:
i = 0
while i < len(line):
c = line[i]
if c == '"':
i += 1
while i < len(line):
ec = line[i]
if ec == '\\' and i + 1 < len(line):
i += 2
elif ec == '"':
i += 1
break
else:
i += 1
elif c == ';':
break
elif c == '(':
parens.append(c)
i += 1
elif c == ')':
parens.append(c)
i += 1
else:
i += 1
return "".join(parens)
def main():
verbose = False
files = []
for arg in sys.argv[1:]:
if arg == "-v" or arg == "--verbose":
verbose = True
elif arg.startswith("-"):
print(f"Usage: {sys.argv[0]} [-v] <file.org> [...]", file=sys.stderr)
return 2
else:
files.append(arg)
if not files:
print(f"Usage: {sys.argv[0]} [-v] <file.org> [...]", file=sys.stderr)
return 2
all_ok = True
for path in files:
if not check_file(path, verbose):
all_ok = False
return 0 if all_ok else 1
if __name__ == "__main__":
sys.exit(main())