|
| 1 | +"""Every parser of a Sage artifact, run against the TEMPLATE that produces it. |
| 2 | +
|
| 3 | +THE CLASS THIS KILLS. The ledger schism (2026-08-04): ledger.py parsed only |
| 4 | +`## Task N` headings — the E9 fixture's convention — while the plan template |
| 5 | +Sage generates writes `- [ ] **Task N:**` bullets. Green suite, broken |
| 6 | +product: the parser had been tested exclusively against fixture-authored |
| 7 | +data and had never once met Sage's own output. The first real --subagents |
| 8 | +cycle was the first meeting, and it failed. |
| 9 | +
|
| 10 | +So: the templates are the contract, and this suite is the handshake. Every |
| 11 | +consumer of plan.md / spec.md / the ledger block parses the ACTUAL template |
| 12 | +file (or an artifact scaffolded from it) and must find what the template |
| 13 | +promises. A parser change that breaks template conformance fails HERE, in |
| 14 | +fastcheck, before any field report. If you add a template or a parser, add |
| 15 | +its handshake. |
| 16 | +
|
| 17 | +Python 3.8+, stdlib only. |
| 18 | +""" |
| 19 | +import json |
| 20 | +import pathlib |
| 21 | +import re |
| 22 | +import shutil |
| 23 | +import subprocess |
| 24 | +import sys |
| 25 | +import tempfile |
| 26 | +import unittest |
| 27 | + |
| 28 | +REPO = pathlib.Path(__file__).resolve().parents[3] |
| 29 | +sys.path.insert(0, str(REPO / "runtime" / "tools")) |
| 30 | + |
| 31 | +import ledger as LED # noqa: E402 |
| 32 | +import manifest as MAN # noqa: E402 |
| 33 | +import scope_judge as SJ # noqa: E402 |
| 34 | + |
| 35 | +PLAN_TEMPLATE = REPO / "core" / "templates" / "plan" / "standard.plan-template.md" |
| 36 | +SPEC_TEMPLATES = (REPO / "core" / "templates" / "spec" / "full.spec-template.md", |
| 37 | + REPO / "core" / "templates" / "spec" / "minimal.spec-template.md") |
| 38 | +SPEC_GATE = (REPO / "runtime" / "platforms" / "claude-code" / "hooks" |
| 39 | + / "sage-spec-gate.sh") |
| 40 | + |
| 41 | +PLAN_TEXT = PLAN_TEMPLATE.read_text(encoding="utf-8") |
| 42 | + |
| 43 | + |
| 44 | +class PlanTemplateHandshake(unittest.TestCase): |
| 45 | + """plan.md consumers × the plan template, verbatim.""" |
| 46 | + |
| 47 | + def test_ledger_parses_the_template_tasks(self): |
| 48 | + tasks = LED.parse_plan_tasks(PLAN_TEXT) |
| 49 | + self.assertGreaterEqual(len(tasks), 2, |
| 50 | + "the template ships a code task AND a DOC task") |
| 51 | + self.assertEqual(tasks[0][0], 1) |
| 52 | + self.assertNotIn("[DOC]", tasks[1][1], |
| 53 | + "[DOC] is presentation, not title") |
| 54 | + |
| 55 | + def test_manifest_plan_tasks_sees_tasks_not_section_headings(self): |
| 56 | + d = pathlib.Path(tempfile.mkdtemp()) |
| 57 | + self.addCleanup(shutil.rmtree, d, ignore_errors=True) |
| 58 | + (d / "plan.md").write_text(PLAN_TEXT) |
| 59 | + tasks = MAN.plan_tasks(d) |
| 60 | + self.assertTrue(tasks and all(t.startswith("Task ") for t in tasks), |
| 61 | + "bullet plans must yield tasks, not section headings " |
| 62 | + "(the /continue display printed 'Gate Log' as a task " |
| 63 | + "before 2026-08-04): %r" % tasks[:3]) |
| 64 | + |
| 65 | + def test_scope_judge_current_task_reads_the_template(self): |
| 66 | + d = pathlib.Path(tempfile.mkdtemp()) |
| 67 | + self.addCleanup(shutil.rmtree, d, ignore_errors=True) |
| 68 | + (d / "plan.md").write_text(PLAN_TEXT) |
| 69 | + tid, title = SJ.current_task(d) |
| 70 | + self.assertEqual(tid, 1) |
| 71 | + block = SJ.current_task_block(d) |
| 72 | + self.assertIn("**Files:**", block, |
| 73 | + "the packet must carry the task's declaration lines") |
| 74 | + |
| 75 | + def test_scope_derive_files_regex_matches_the_template_lines(self): |
| 76 | + files_lines = [l for l in PLAN_TEXT.splitlines() |
| 77 | + if MAN._FILES_LINE_RE.match(l)] |
| 78 | + self.assertGreaterEqual( |
| 79 | + len(files_lines), 2, |
| 80 | + "the template's `- **Files:**` and `- **Output:**` lines are what " |
| 81 | + "scope derive reads; if this regex stops matching them, every " |
| 82 | + "derived scope is empty") |
| 83 | + |
| 84 | + |
| 85 | +class SpecTemplateHandshake(unittest.TestCase): |
| 86 | + """spec.md consumers × both spec templates.""" |
| 87 | + |
| 88 | + def test_judge_boundary_section_found_in_both_templates(self): |
| 89 | + for tpl in SPEC_TEMPLATES: |
| 90 | + d = pathlib.Path(tempfile.mkdtemp()) |
| 91 | + self.addCleanup(shutil.rmtree, d, ignore_errors=True) |
| 92 | + (d / "spec.md").write_text(tpl.read_text(encoding="utf-8")) |
| 93 | + self.assertTrue(SJ.spec_boundary(d), |
| 94 | + "%s: the judge packet's boundary section came " |
| 95 | + "back empty — heading drifted?" % tpl.name) |
| 96 | + |
| 97 | + |
| 98 | +class LedgerRoundTrip(unittest.TestCase): |
| 99 | + """The cross-language seam: ledger.py WRITES the tasks block, the |
| 100 | + spec-gate hook READS it (R101). Scaffolded from a template-form plan, |
| 101 | + driven through the real gate, both directions — the live probe from the |
| 102 | + 2026-08-04 review, pinned.""" |
| 103 | + |
| 104 | + def setUp(self): |
| 105 | + self.root = pathlib.Path(tempfile.mkdtemp()) |
| 106 | + self.addCleanup(shutil.rmtree, self.root, ignore_errors=True) |
| 107 | + cyc = self.root / ".sage" / "work" / "001-x" |
| 108 | + cyc.mkdir(parents=True) |
| 109 | + (self.root / ".sage" / "config.yaml").write_text( |
| 110 | + "hard_enforcement: true\n") |
| 111 | + (cyc / "plan.md").write_text( |
| 112 | + "# Plan\n\n## Tasks\n\n" |
| 113 | + "- [ ] **Task 1:** one\n - **Files:** src/a.py\n" |
| 114 | + "- [ ] **Task 2:** two [DOC]\n - **Output:** docs/x.md\n") |
| 115 | + (cyc / "manifest.md").write_text( |
| 116 | + '---\ncycle_id: "001-x"\ngate_state: building\n' |
| 117 | + "status: in-progress\n---\n# x\n") |
| 118 | + self.cyc = cyc |
| 119 | + r = subprocess.run( |
| 120 | + [sys.executable, str(REPO / "runtime" / "tools" / "ledger.py"), |
| 121 | + "init", str(cyc / "manifest.md"), str(cyc / "plan.md")], |
| 122 | + capture_output=True, text=True) |
| 123 | + assert r.returncode == 0, r.stderr |
| 124 | + |
| 125 | + def gate(self): |
| 126 | + payload = json.dumps({ |
| 127 | + "tool_name": "Edit", |
| 128 | + "tool_input": {"file_path": str(self.cyc / "manifest.md"), |
| 129 | + "old_string": "gate_state: building", |
| 130 | + "new_string": "gate_state: gates-passed"}, |
| 131 | + "cwd": str(self.root)}) |
| 132 | + return subprocess.run( |
| 133 | + ["bash", str(SPEC_GATE)], input=payload, capture_output=True, |
| 134 | + text=True, env={"PATH": "/usr/bin:/bin", |
| 135 | + "CLAUDE_PROJECT_DIR": str(self.root)}).returncode |
| 136 | + |
| 137 | + def mark(self, status, review): |
| 138 | + m = self.cyc / "manifest.md" |
| 139 | + t = m.read_text() |
| 140 | + t = t.replace("status: pending", "status: %s" % status) |
| 141 | + t = t.replace("review: pending", "review: %s" % review) |
| 142 | + m.write_text(t) |
| 143 | + |
| 144 | + def test_gate_blocks_then_allows_the_scaffolded_ledger(self): |
| 145 | + self.assertEqual(self.gate(), 2, |
| 146 | + "pending ledger must block gates-passed (R101)") |
| 147 | + self.mark("done", "approved") |
| 148 | + self.assertEqual(self.gate(), 0, |
| 149 | + "done+approved (with the model: field present) " |
| 150 | + "must pass — the gate parser tolerates the schema") |
| 151 | + |
| 152 | + |
| 153 | +if __name__ == "__main__": |
| 154 | + unittest.main(verbosity=2) |
0 commit comments