From cd5be1cf92d1a663abd05ec28e4835a81ae778cc Mon Sep 17 00:00:00 2001 From: Paul O'Reilly Date: Sun, 2 Aug 2026 21:27:53 +1200 Subject: [PATCH] idle-draft: strip :line-range suffixes before citation existence check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First pilot rejected valid citations like SPEC.md:1-80 — the form the research template itself mandates. 5 new test cases (83 total). Claude-Session: https://claude.ai/code/session_01YQDoWNM7XPPii28khFWoMc --- scripts/idle-draft | 13 ++++++++++++- specs/idle-draft.spec.md | 6 +++++- tests/test-idle-draft.sh | 20 ++++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/scripts/idle-draft b/scripts/idle-draft index ec40c89..5617787 100755 --- a/scripts/idle-draft +++ b/scripts/idle-draft @@ -92,6 +92,7 @@ RETRYABLE_STDERR_MARKERS = ( ITEM_FILENAME_RE = re.compile(r"^(\d+)-(.+)\.overview\.md$") ABS_PATH_RE = re.compile(r"/home/[^\s`)\]\"'>,;]+") +LINE_RANGE_SUFFIX_RE = re.compile(r"^(.*/[^/:]+):\d+(?:-\d+)?$") SCRIPT_PATH = Path(__file__).resolve() REPO_SELF = SCRIPT_PATH.parent.parent # small-scripts repo (holds data/idle-draft) @@ -721,6 +722,16 @@ def extract_absolute_paths(text: str) -> list[str]: return ABS_PATH_RE.findall(text) +def strip_line_range(p: str) -> str: + """Strip a trailing ':' or ':-' citation line-range suffix. + + Only strips when the remainder still looks like a path (has a filename + segment before the colon); a plain path with no suffix is unchanged. + """ + m = LINE_RANGE_SUFFIX_RE.match(p) + return m.group(1) if m else p + + def validate_output(work_type: str, text: str) -> tuple[bool, str]: if not text or not text.strip(): return False, "output is empty" @@ -729,7 +740,7 @@ def validate_output(work_type: str, text: str) -> tuple[bool, str]: if not first_line.startswith(prefix): return False, f"first non-blank line does not start with '{prefix}': {first_line!r}" if work_type == "research": - dead = [p for p in extract_absolute_paths(text) if not Path(p.rstrip(".,;:")).exists()] + dead = [p for p in extract_absolute_paths(text) if not Path(strip_line_range(p.rstrip(".,;:"))).exists()] if dead: return False, f"dead cited path(s): {dead}" return True, "ok" diff --git a/specs/idle-draft.spec.md b/specs/idle-draft.spec.md index 00f2310..09481d8 100644 --- a/specs/idle-draft.spec.md +++ b/specs/idle-draft.spec.md @@ -247,7 +247,11 @@ For the selected `(item_or_dossier, work_type, provider)`: 7. Validation (on exit 0, before promotion): the temp output file must be non-empty and its first non-blank line must be a top-level Markdown heading (`# ...`). For `research` work type specifically: every absolute path matching `/home/\S+` cited in - the file must exist on disk (`Path.exists()`); any dead path fails validation. + the file must exist on disk (`Path.exists()`); any dead path fails validation. Citations + may carry a trailing line-range suffix in `path:120-145` or `path:120` form (per the + research prompt template's required citation format); this suffix is stripped — via + `re.sub(r":\d+(-\d+)?$", ...)`, only when the remainder still looks like a path — before + the existence check, so a valid ranged citation is not flagged as a dead path. 8. On validation pass: `os.replace()` the temp file to the canonical path (`//..md`, or append to `/TOPIC-PROPOSALS.md` for `topic_ideas`) — atomic, never a partial file diff --git a/tests/test-idle-draft.sh b/tests/test-idle-draft.sh index dc9bad8..ef2645e 100755 --- a/tests/test-idle-draft.sh +++ b/tests/test-idle-draft.sh @@ -409,6 +409,26 @@ bad_text = "# Research: topic\n\nSee /home/nonexistent-user/definitely-not-here. ok, why = m.validate_output("research", bad_text) check("citation validation: rejects research output citing a dead path", ok is False, why) +ranged_good_text = f"# Research: topic\n\nSee {real_path}:12-18 for detail.\n" +ok, why = m.validate_output("research", ranged_good_text) +check("citation validation: accepts existing path cited with a :12-18 line range", ok is True, why) + +bare_good_text = f"# Research: topic\n\nSee {real_path} for detail.\n" +ok, why = m.validate_output("research", bare_good_text) +check("citation validation: accepts existing path cited bare (no line range)", ok is True, why) + +ranged_bad_text = "# Research: topic\n\nSee /home/nonexistent-user/definitely-not-here.md:1-20 for detail.\n" +ok, why = m.validate_output("research", ranged_bad_text) +check("citation validation: rejects nonexistent path cited with a :1-20 line range", ok is False, why) + +bare_bad_text = "# Research: topic\n\nSee /home/nonexistent-user/definitely-not-here.md for detail.\n" +ok, why = m.validate_output("research", bare_bad_text) +check("citation validation: rejects nonexistent path cited bare (no line range)", ok is False, why) + +noisy_text = f"# Research: topic\n\nSee ({real_path}), also `{real_path}`, and {real_path}, again.\n" +ok, why = m.validate_output("research", noisy_text) +check("citation validation: accepts existing path with trailing comma/backtick noise", ok is True, why) + ok, why = m.validate_output("research", "") check("citation validation: rejects empty output", ok is False, why)