diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-15 16:56:39 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-15 16:56:39 -0500 |
| commit | c1d4e3c4a42abd01bc7ef83b1d6ae036ee32ef1d (patch) | |
| tree | 3e6dcc682cbf2311409e7f71d83a7d4088392068 /claude-templates/.ai/scripts/cj-scan.py | |
| parent | 2b471da4bab014a2e096f63edc7aac235fc40fdd (diff) | |
| parent | 69c5e4ace81586c05dea6a9a3afd54dafa61a73b (diff) | |
| download | rulesets-c1d4e3c4a42abd01bc7ef83b1d6ae036ee32ef1d.tar.gz rulesets-c1d4e3c4a42abd01bc7ef83b1d6ae036ee32ef1d.zip | |
Merge commit '69c5e4ace81586c05dea6a9a3afd54dafa61a73b' as 'claude-templates'
Diffstat (limited to 'claude-templates/.ai/scripts/cj-scan.py')
| -rw-r--r-- | claude-templates/.ai/scripts/cj-scan.py | 162 |
1 files changed, 162 insertions, 0 deletions
diff --git a/claude-templates/.ai/scripts/cj-scan.py b/claude-templates/.ai/scripts/cj-scan.py new file mode 100644 index 0000000..54e2bf9 --- /dev/null +++ b/claude-templates/.ai/scripts/cj-scan.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""cj-scan — Parse an org file for cj annotations and VERIFY-placement audit. + +Output: JSON to stdout with three top-level keys: +- cj_blocks: every cj annotation found (source-block or legacy-inline form) +- verify_tasks: every VERIFY heading with placement validity + suggested promotion target +- unclosed_blocks: any source-block fence that opened but never closed + +Usage: + cj-scan FILE.org + +Companion to the /respond-to-cj-comments skill — the skill calls this script +to get a single structured view of every cj annotation and every VERIFY +placement violation in a single tool call, instead of stitching the picture +together from multiple grep + Read round-trips. +""" + +from __future__ import annotations + +import json +import re +import sys +from dataclasses import asdict, dataclass +from pathlib import Path + +# VERIFY placement: top-level under a `*` section, or first-level child of a +# `**` parent task. Anything else gets a promotion_target suggestion. +VALID_VERIFY_DEPTHS = {2, 3} + +HEADING_RE = re.compile(r"^(\*+)\s+(.*)$") +SRC_OPEN_RE = re.compile(r"^\s*#\+begin_src\s+cj:\s*(\S*)\s*$", re.IGNORECASE) +SRC_CLOSE_RE = re.compile(r"^\s*#\+end_src\s*$", re.IGNORECASE) +LEGACY_CJ_RE = re.compile(r"^\s*cj:\s*(.*)$") +VERIFY_KEYWORD_RE = re.compile(r"^VERIFY(\s|\[|$)") + + +@dataclass +class HeadingFrame: + depth: int + heading: str + + +def promotion_target(depth: int) -> int | None: + """Return the suggested target depth for a misplaced VERIFY, or None if valid.""" + if depth in VALID_VERIFY_DEPTHS: + return None + if depth < 2: + return 2 + return 3 + + +def is_verify_heading(heading_text: str) -> bool: + """True when heading text begins with the VERIFY keyword (optional priority cookie).""" + return bool(VERIFY_KEYWORD_RE.match(heading_text)) + + +def scan_file(path: Path) -> dict[str, object]: + """Scan an org file and return cj_blocks + verify_tasks + unclosed_blocks.""" + cj_blocks: list[dict[str, object]] = [] + verify_tasks: list[dict[str, object]] = [] + unclosed_blocks: list[dict[str, object]] = [] + heading_stack: list[HeadingFrame] = [] + + in_cj_block = False + block_start_line: int | None = None + block_label: str | None = None + block_body: list[str] = [] + + file_str = str(path) + lines = path.read_text().splitlines() + + for lineno, line in enumerate(lines, start=1): + if in_cj_block: + if SRC_CLOSE_RE.match(line): + cj_blocks.append({ + "file": file_str, + "form": "source-block", + "start_line": block_start_line, + "end_line": lineno, + "body": "\n".join(block_body), + "label": block_label, + "parent_heading_chain": [asdict(h) for h in heading_stack], + "parent_depth": heading_stack[-1].depth if heading_stack else 0, + }) + in_cj_block = False + block_start_line = None + block_label = None + block_body = [] + else: + block_body.append(line) + continue + + m_heading = HEADING_RE.match(line) + if m_heading: + depth = len(m_heading.group(1)) + heading_text = m_heading.group(2).strip() + # Pop frames at this depth or deeper before pushing the new one. + while heading_stack and heading_stack[-1].depth >= depth: + heading_stack.pop() + heading_stack.append(HeadingFrame(depth=depth, heading=heading_text)) + if is_verify_heading(heading_text): + pt = promotion_target(depth) + verify_tasks.append({ + "file": file_str, + "line": lineno, + "depth": depth, + "heading": heading_text, + "valid_depth": pt is None, + "promotion_target": pt, + }) + continue + + m_src_open = SRC_OPEN_RE.match(line) + if m_src_open: + in_cj_block = True + block_start_line = lineno + block_label = m_src_open.group(1) or None + block_body = [] + continue + + m_legacy = LEGACY_CJ_RE.match(line) + if m_legacy: + cj_blocks.append({ + "file": file_str, + "form": "legacy-inline", + "start_line": lineno, + "end_line": lineno, + "body": m_legacy.group(1).strip(), + "parent_heading_chain": [asdict(h) for h in heading_stack], + "parent_depth": heading_stack[-1].depth if heading_stack else 0, + }) + + if in_cj_block: + unclosed_blocks.append({ + "file": file_str, + "start_line": block_start_line, + "label": block_label, + }) + + return { + "cj_blocks": cj_blocks, + "verify_tasks": verify_tasks, + "unclosed_blocks": unclosed_blocks, + } + + +def main() -> int: + if len(sys.argv) != 2: + print("Usage: cj-scan FILE.org", file=sys.stderr) + return 2 + path = Path(sys.argv[1]) + if not path.is_file(): + print(f"Not a file: {path}", file=sys.stderr) + return 2 + result = scan_file(path) + json.dump(result, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) |
