@cleocode/skills 2026.4.161 → 2026.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/ct-council/SKILL.md +377 -0
- package/skills/ct-council/optimization/HARDENING-PLAYBOOK.md +107 -0
- package/skills/ct-council/optimization/README.md +74 -0
- package/skills/ct-council/optimization/scenarios.yaml +121 -0
- package/skills/ct-council/optimization/scripts/campaign.py +543 -0
- package/skills/ct-council/optimization/scripts/test_campaign.py +143 -0
- package/skills/ct-council/references/chairman.md +119 -0
- package/skills/ct-council/references/contrarian.md +70 -0
- package/skills/ct-council/references/evidence-pack.md +145 -0
- package/skills/ct-council/references/examples.md +235 -0
- package/skills/ct-council/references/executor.md +83 -0
- package/skills/ct-council/references/expansionist.md +68 -0
- package/skills/ct-council/references/first-principles.md +73 -0
- package/skills/ct-council/references/outsider.md +73 -0
- package/skills/ct-council/references/peer-review.md +125 -0
- package/skills/ct-council/scripts/analyze_runs.py +293 -0
- package/skills/ct-council/scripts/fixtures/executor_multi.md +198 -0
- package/skills/ct-council/scripts/fixtures/missing_advisor.md +117 -0
- package/skills/ct-council/scripts/fixtures/missing_convergence.md +190 -0
- package/skills/ct-council/scripts/fixtures/thin_evidence.md +193 -0
- package/skills/ct-council/scripts/fixtures/valid.md +226 -0
- package/skills/ct-council/scripts/fixtures/valid_with_llmtxt.md +226 -0
- package/skills/ct-council/scripts/llmtxt_ref.py +223 -0
- package/skills/ct-council/scripts/run_council.py +578 -0
- package/skills/ct-council/scripts/telemetry.py +624 -0
- package/skills/ct-council/scripts/test_telemetry.py +509 -0
- package/skills/ct-council/scripts/test_validate.py +452 -0
- package/skills/ct-council/scripts/validate.py +396 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
test_validate.py — unit tests for the Council output validator.
|
|
4
|
+
|
|
5
|
+
Run from the skill's scripts/ directory:
|
|
6
|
+
python3 -m unittest test_validate.py -v
|
|
7
|
+
|
|
8
|
+
Or from anywhere:
|
|
9
|
+
python3 <path-to-council>/scripts/test_validate.py
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
import unittest
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
19
|
+
FIXTURES_DIR = SCRIPTS_DIR / "fixtures"
|
|
20
|
+
|
|
21
|
+
# Make validate.py importable regardless of how tests are invoked.
|
|
22
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
23
|
+
from validate import Validator, detect_phase # noqa: E402
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_fixture(name: str) -> str:
|
|
27
|
+
return (FIXTURES_DIR / name).read_text()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestValidCouncilOutput(unittest.TestCase):
|
|
31
|
+
def test_valid_fixture_has_no_structural_violations(self):
|
|
32
|
+
v = Validator(load_fixture("valid.md"))
|
|
33
|
+
violations = v.validate()
|
|
34
|
+
structural = [x for x in violations if x.kind == "structural"]
|
|
35
|
+
self.assertEqual(
|
|
36
|
+
structural, [],
|
|
37
|
+
f"Valid fixture produced structural violations: {[(x.section, x.message) for x in structural]}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def test_valid_fixture_passes_all_advisors(self):
|
|
41
|
+
v = Validator(load_fixture("valid.md"))
|
|
42
|
+
violations = v.validate()
|
|
43
|
+
advisor_errors = [x for x in violations if "Advisor:" in x.section and x.kind == "structural"]
|
|
44
|
+
self.assertEqual(advisor_errors, [], f"Advisor-section errors: {advisor_errors}")
|
|
45
|
+
|
|
46
|
+
def test_valid_fixture_passes_all_peer_reviews(self):
|
|
47
|
+
v = Validator(load_fixture("valid.md"))
|
|
48
|
+
violations = v.validate()
|
|
49
|
+
review_errors = [x for x in violations if "reviewing" in x.section and x.kind == "structural"]
|
|
50
|
+
self.assertEqual(review_errors, [], f"Peer-review errors: {review_errors}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestMissingAdvisor(unittest.TestCase):
|
|
54
|
+
"""missing_advisor.md omits the Outsider section."""
|
|
55
|
+
|
|
56
|
+
def test_flags_missing_outsider(self):
|
|
57
|
+
v = Validator(load_fixture("missing_advisor.md"))
|
|
58
|
+
violations = v.validate()
|
|
59
|
+
sections = [x.section for x in violations]
|
|
60
|
+
self.assertIn("Advisor: Outsider", sections,
|
|
61
|
+
f"Expected missing-advisor violation for Outsider; got sections: {sections}")
|
|
62
|
+
|
|
63
|
+
def test_flags_missing_peer_reviews_that_depend_on_outsider(self):
|
|
64
|
+
v = Validator(load_fixture("missing_advisor.md"))
|
|
65
|
+
violations = v.validate()
|
|
66
|
+
# Without Outsider, several peer-review pairs are missing — those are flagged under
|
|
67
|
+
# section "Peer review" with the specific pair named in the message.
|
|
68
|
+
review_violations = [
|
|
69
|
+
x for x in violations
|
|
70
|
+
if x.section == "Peer review" and "reviewing" in x.message
|
|
71
|
+
]
|
|
72
|
+
self.assertGreaterEqual(
|
|
73
|
+
len(review_violations), 1,
|
|
74
|
+
f"Expected ≥1 missing peer-review violation; got: {[v.message for v in violations]}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TestExecutorMultipleActions(unittest.TestCase):
|
|
79
|
+
"""executor_multi.md has a numbered list under 'The action (one)'."""
|
|
80
|
+
|
|
81
|
+
def test_flags_multiple_actions(self):
|
|
82
|
+
v = Validator(load_fixture("executor_multi.md"))
|
|
83
|
+
violations = v.validate()
|
|
84
|
+
executor_errors = [x for x in violations if x.section == "Advisor: Executor"]
|
|
85
|
+
self.assertTrue(
|
|
86
|
+
any("numbered" in x.message or "bulleted" in x.message for x in executor_errors),
|
|
87
|
+
f"Expected multi-action violation; got: {[x.message for x in executor_errors]}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestThinEvidencePack(unittest.TestCase):
|
|
92
|
+
"""thin_evidence.md has only 2 evidence items."""
|
|
93
|
+
|
|
94
|
+
def test_flags_evidence_pack_minimum(self):
|
|
95
|
+
v = Validator(load_fixture("thin_evidence.md"))
|
|
96
|
+
violations = v.validate()
|
|
97
|
+
ep_errors = [x for x in violations if x.section == "Evidence pack" and x.kind == "structural"]
|
|
98
|
+
self.assertTrue(
|
|
99
|
+
any("minimum is 3" in x.message for x in ep_errors),
|
|
100
|
+
f"Expected evidence-pack minimum violation; got: {[x.message for x in ep_errors]}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestMissingConvergenceSection(unittest.TestCase):
|
|
105
|
+
"""missing_convergence.md skips Phase 2.5."""
|
|
106
|
+
|
|
107
|
+
def test_flags_missing_phase_2_5(self):
|
|
108
|
+
v = Validator(load_fixture("missing_convergence.md"))
|
|
109
|
+
violations = v.validate()
|
|
110
|
+
phase25_errors = [x for x in violations if x.section == "Phase 2.5"]
|
|
111
|
+
self.assertTrue(
|
|
112
|
+
any("Missing" in x.message for x in phase25_errors),
|
|
113
|
+
f"Expected Phase 2.5 missing violation; got: {[x.message for x in phase25_errors]}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TestHeaderValidation(unittest.TestCase):
|
|
118
|
+
def test_missing_h1_flagged(self):
|
|
119
|
+
md = "## Evidence pack\n\n1. `foo.py:1` — something.\n"
|
|
120
|
+
v = Validator(md)
|
|
121
|
+
violations = v.validate()
|
|
122
|
+
self.assertTrue(any(x.section == "H1" for x in violations))
|
|
123
|
+
|
|
124
|
+
def test_short_restated_question_flagged(self):
|
|
125
|
+
md = "# The Council — X\n\n## Evidence pack\n"
|
|
126
|
+
v = Validator(md)
|
|
127
|
+
violations = v.validate()
|
|
128
|
+
h1_errors = [x for x in violations if x.section == "H1"]
|
|
129
|
+
self.assertTrue(any("too short" in x.message for x in h1_errors))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class TestChairmanVerdict(unittest.TestCase):
|
|
133
|
+
def test_valid_fixture_has_all_chairman_subsections(self):
|
|
134
|
+
v = Validator(load_fixture("valid.md"))
|
|
135
|
+
violations = v.validate()
|
|
136
|
+
phase3_errors = [x for x in violations if x.section == "Phase 3" and x.kind == "structural"]
|
|
137
|
+
self.assertEqual(
|
|
138
|
+
phase3_errors, [],
|
|
139
|
+
f"Chairman verdict has issues: {[x.message for x in phase3_errors]}"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class TestGateFormat(unittest.TestCase):
|
|
144
|
+
def test_valid_fixture_gate_lines_accepted(self):
|
|
145
|
+
"""Valid fixture has properly formatted 'G1 Rigor: PASS — <evidence>' lines."""
|
|
146
|
+
v = Validator(load_fixture("valid.md"))
|
|
147
|
+
violations = v.validate()
|
|
148
|
+
gate_errors = [x for x in violations if "Gate" in x.message]
|
|
149
|
+
self.assertEqual(gate_errors, [], f"Valid fixture has gate-format errors: {gate_errors}")
|
|
150
|
+
|
|
151
|
+
def test_partial_pass_is_rejected(self):
|
|
152
|
+
"""Regression test: 'PARTIAL PASS' is not a valid gate state per peer-review.md."""
|
|
153
|
+
# Start from the valid fixture and corrupt exactly one gate to PARTIAL PASS.
|
|
154
|
+
md = load_fixture("valid.md").replace(
|
|
155
|
+
"- G3 Frame integrity: PASS — stayed in atomic-truth lane.",
|
|
156
|
+
"- G3 Frame integrity: PARTIAL PASS — stayed in lane mostly.",
|
|
157
|
+
1,
|
|
158
|
+
)
|
|
159
|
+
v = Validator(md)
|
|
160
|
+
violations = v.validate()
|
|
161
|
+
g3_errors = [
|
|
162
|
+
x for x in violations
|
|
163
|
+
if "G3 Frame integrity" in x.message and "missing or malformed" in x.message
|
|
164
|
+
]
|
|
165
|
+
self.assertTrue(
|
|
166
|
+
g3_errors,
|
|
167
|
+
f"Expected validator to reject 'PARTIAL PASS' as malformed; got: {[v.message for v in violations]}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def test_code_fence_shell_comments_do_not_fake_headers(self):
|
|
171
|
+
"""Regression: `# 0.` shell comments inside ```bash fences must not be parsed as H1 headers,
|
|
172
|
+
which would truncate the containing section and hide required subsections."""
|
|
173
|
+
md = load_fixture("valid.md")
|
|
174
|
+
# Inject a bash code block with a `# comment` that looks like an H1 into Phase 3's action.
|
|
175
|
+
injected = md.replace(
|
|
176
|
+
"### Next 60-minute action\n",
|
|
177
|
+
"### Next 60-minute action\n\n```bash\n# 0. This is a shell comment, not a header.\ncleo verify T1234\n```\n\n",
|
|
178
|
+
1,
|
|
179
|
+
)
|
|
180
|
+
v = Validator(injected)
|
|
181
|
+
violations = v.validate()
|
|
182
|
+
missing_conf = [
|
|
183
|
+
x for x in violations
|
|
184
|
+
if x.section == "Phase 3" and "Confidence" in x.message
|
|
185
|
+
]
|
|
186
|
+
self.assertEqual(
|
|
187
|
+
missing_conf, [],
|
|
188
|
+
"Shell comments inside code fences should not truncate the Phase 3 body; "
|
|
189
|
+
f"unexpected violations: {[v.message for v in violations]}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def test_mixed_is_rejected(self):
|
|
193
|
+
"""Regression test: 'MIXED' is not a valid gate state."""
|
|
194
|
+
md = load_fixture("valid.md").replace(
|
|
195
|
+
"- G1 Rigor: PASS — \"Non-idempotent requests cannot be blindly retried\" is specific.",
|
|
196
|
+
"- G1 Rigor: MIXED — partially specific.",
|
|
197
|
+
1,
|
|
198
|
+
)
|
|
199
|
+
v = Validator(md)
|
|
200
|
+
violations = v.validate()
|
|
201
|
+
g1_errors = [
|
|
202
|
+
x for x in violations
|
|
203
|
+
if "G1 Rigor" in x.message and "missing or malformed" in x.message
|
|
204
|
+
]
|
|
205
|
+
self.assertTrue(
|
|
206
|
+
g1_errors,
|
|
207
|
+
f"Expected validator to reject 'MIXED' as malformed; got: {[v.message for v in violations]}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class TestValidatorReportFormat(unittest.TestCase):
|
|
212
|
+
def test_report_mentions_violation_count(self):
|
|
213
|
+
from validate import report
|
|
214
|
+
v = Validator(load_fixture("missing_advisor.md"))
|
|
215
|
+
violations = v.validate()
|
|
216
|
+
text = report(violations, as_json=False)
|
|
217
|
+
self.assertIn("violation", text.lower())
|
|
218
|
+
|
|
219
|
+
def test_json_report_parseable(self):
|
|
220
|
+
import json as _json
|
|
221
|
+
from validate import report
|
|
222
|
+
v = Validator(load_fixture("valid.md"))
|
|
223
|
+
violations = v.validate()
|
|
224
|
+
text = report(violations, as_json=True)
|
|
225
|
+
data = _json.loads(text)
|
|
226
|
+
self.assertIn("valid", data)
|
|
227
|
+
self.assertIn("violations", data)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class TestLlmtxtRefParsing(unittest.TestCase):
|
|
231
|
+
"""llmtxt_ref.py parsing + cache-path logic — no network."""
|
|
232
|
+
|
|
233
|
+
def test_parse_ref_slug_only(self):
|
|
234
|
+
from llmtxt_ref import parse_ref
|
|
235
|
+
slug, version = parse_ref("my-doc")
|
|
236
|
+
self.assertEqual(slug, "my-doc")
|
|
237
|
+
self.assertIsNone(version)
|
|
238
|
+
|
|
239
|
+
def test_parse_ref_with_version(self):
|
|
240
|
+
from llmtxt_ref import parse_ref
|
|
241
|
+
slug, version = parse_ref("my-doc@v2")
|
|
242
|
+
self.assertEqual(slug, "my-doc")
|
|
243
|
+
self.assertEqual(version, "v2")
|
|
244
|
+
|
|
245
|
+
def test_parse_ref_single_char_slug(self):
|
|
246
|
+
from llmtxt_ref import parse_ref
|
|
247
|
+
slug, version = parse_ref("a")
|
|
248
|
+
self.assertEqual(slug, "a")
|
|
249
|
+
self.assertIsNone(version)
|
|
250
|
+
|
|
251
|
+
def test_parse_ref_rejects_uppercase(self):
|
|
252
|
+
from llmtxt_ref import parse_ref
|
|
253
|
+
with self.assertRaises(ValueError):
|
|
254
|
+
parse_ref("Invalid")
|
|
255
|
+
|
|
256
|
+
def test_parse_ref_rejects_leading_dash(self):
|
|
257
|
+
from llmtxt_ref import parse_ref
|
|
258
|
+
with self.assertRaises(ValueError):
|
|
259
|
+
parse_ref("-leading")
|
|
260
|
+
|
|
261
|
+
def test_parse_ref_rejects_trailing_dash(self):
|
|
262
|
+
from llmtxt_ref import parse_ref
|
|
263
|
+
with self.assertRaises(ValueError):
|
|
264
|
+
parse_ref("trailing-")
|
|
265
|
+
|
|
266
|
+
def test_parse_ref_rejects_empty_version(self):
|
|
267
|
+
from llmtxt_ref import parse_ref
|
|
268
|
+
with self.assertRaises(ValueError):
|
|
269
|
+
parse_ref("slug@")
|
|
270
|
+
|
|
271
|
+
def test_cache_path_no_version(self):
|
|
272
|
+
from llmtxt_ref import cache_path
|
|
273
|
+
p = cache_path("my-doc", None)
|
|
274
|
+
self.assertTrue(str(p).endswith("my-doc/_latest.md"))
|
|
275
|
+
|
|
276
|
+
def test_cache_path_with_version(self):
|
|
277
|
+
from llmtxt_ref import cache_path
|
|
278
|
+
p = cache_path("my-doc", "v2")
|
|
279
|
+
self.assertTrue(str(p).endswith("my-doc/v2.md"))
|
|
280
|
+
|
|
281
|
+
def test_cache_path_sanitizes_version(self):
|
|
282
|
+
"""Version string is sanitized for filename safety but slug is preserved."""
|
|
283
|
+
from llmtxt_ref import cache_path
|
|
284
|
+
p = cache_path("my-doc", "v2/weird")
|
|
285
|
+
self.assertTrue("my-doc" in str(p))
|
|
286
|
+
self.assertTrue("v2_weird.md" in str(p))
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class TestLlmtxtCacheFreshness(unittest.TestCase):
|
|
290
|
+
"""cache_is_fresh logic — immutable vs mutable behavior."""
|
|
291
|
+
|
|
292
|
+
def test_missing_file_is_not_fresh(self):
|
|
293
|
+
from llmtxt_ref import cache_is_fresh
|
|
294
|
+
self.assertFalse(cache_is_fresh(Path("/tmp/nonexistent-xyz-abc.md"), immutable=True))
|
|
295
|
+
|
|
296
|
+
def test_immutable_file_is_always_fresh(self):
|
|
297
|
+
import tempfile
|
|
298
|
+
from llmtxt_ref import cache_is_fresh
|
|
299
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".md") as f:
|
|
300
|
+
f.write(b"cached")
|
|
301
|
+
tmp = Path(f.name)
|
|
302
|
+
try:
|
|
303
|
+
self.assertTrue(cache_is_fresh(tmp, immutable=True))
|
|
304
|
+
finally:
|
|
305
|
+
tmp.unlink()
|
|
306
|
+
|
|
307
|
+
def test_mutable_file_respects_ttl(self):
|
|
308
|
+
import os
|
|
309
|
+
import tempfile
|
|
310
|
+
import time
|
|
311
|
+
from llmtxt_ref import cache_is_fresh, LATEST_TTL_SECONDS
|
|
312
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".md") as f:
|
|
313
|
+
f.write(b"cached")
|
|
314
|
+
tmp = Path(f.name)
|
|
315
|
+
try:
|
|
316
|
+
# Set mtime to (TTL + 5) seconds ago — should read as stale.
|
|
317
|
+
stale_time = time.time() - (LATEST_TTL_SECONDS + 5)
|
|
318
|
+
os.utime(tmp, (stale_time, stale_time))
|
|
319
|
+
self.assertFalse(cache_is_fresh(tmp, immutable=False))
|
|
320
|
+
finally:
|
|
321
|
+
tmp.unlink()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class TestLlmtxtFormatting(unittest.TestCase):
|
|
325
|
+
def test_format_includes_evidence_pack_header(self):
|
|
326
|
+
from llmtxt_ref import format_for_evidence_pack
|
|
327
|
+
formatted = format_for_evidence_pack("my-doc", "v2", "# Overview\n\nBody")
|
|
328
|
+
self.assertIn("<!-- evidence-pack item: `llmtxt:my-doc@v2` -->", formatted)
|
|
329
|
+
self.assertIn("# Overview", formatted)
|
|
330
|
+
|
|
331
|
+
def test_format_without_version(self):
|
|
332
|
+
from llmtxt_ref import format_for_evidence_pack
|
|
333
|
+
formatted = format_for_evidence_pack("my-doc", None, "body")
|
|
334
|
+
self.assertIn("`llmtxt:my-doc`", formatted)
|
|
335
|
+
self.assertNotIn("@", formatted.split("-->")[0])
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class TestLlmtxtEvidencePackItem(unittest.TestCase):
|
|
339
|
+
"""The validator accepts evidence-pack items using the llmtxt:<slug>[@version] citation format."""
|
|
340
|
+
|
|
341
|
+
def test_valid_fixture_with_llmtxt_item_passes(self):
|
|
342
|
+
v = Validator(load_fixture("valid_with_llmtxt.md"))
|
|
343
|
+
violations = v.validate()
|
|
344
|
+
structural = [x for x in violations if x.kind == "structural"]
|
|
345
|
+
self.assertEqual(
|
|
346
|
+
structural, [],
|
|
347
|
+
f"valid_with_llmtxt.md should validate cleanly; got: {[(x.section, x.message) for x in structural]}"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def test_llmtxt_citation_satisfies_has_citation_check(self):
|
|
351
|
+
"""A minimal evidence item with only an llmtxt: citation still counts as a citation."""
|
|
352
|
+
md = (
|
|
353
|
+
"# The Council — Is the llmtxt citation format accepted by the validator?\n\n"
|
|
354
|
+
"## Evidence pack\n\n"
|
|
355
|
+
"1. `llmtxt:some-external-doc` — external SDK reference.\n"
|
|
356
|
+
"2. `llmtxt:other-doc@v3` — pinned version.\n"
|
|
357
|
+
"3. `src/foo.py:L1-L5` — local anchor.\n"
|
|
358
|
+
)
|
|
359
|
+
v = Validator(md)
|
|
360
|
+
v.check_evidence_pack()
|
|
361
|
+
ep_errors = [x for x in v.violations if x.section == "Evidence pack" and x.kind == "structural"]
|
|
362
|
+
self.assertEqual(ep_errors, [], f"llmtxt citations should pass; got: {ep_errors}")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class TestPhaseAwareValidation(unittest.TestCase):
|
|
366
|
+
"""Tests for --phase N partial-validation mode + auto-detect.
|
|
367
|
+
|
|
368
|
+
Regression test for the bug where running `validate.py` against a
|
|
369
|
+
phase0.md-only file produced 12 noise errors about missing downstream
|
|
370
|
+
sections. After fix, partial files validate cleanly when phase is
|
|
371
|
+
explicitly specified or auto-detected."""
|
|
372
|
+
|
|
373
|
+
PHASE_0_ONLY = (
|
|
374
|
+
"# The Council — Should we ship X?\n\n"
|
|
375
|
+
"## Evidence pack\n\n"
|
|
376
|
+
"1. `packages/foo.ts:L10-L20` — does the thing.\n"
|
|
377
|
+
"2. `packages/bar.ts:L5-L8` — relevant baseline.\n"
|
|
378
|
+
"3. `commit a1b2c3d` — last touched.\n"
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def test_phase_0_only_file_validates_cleanly_with_phase_arg(self):
|
|
382
|
+
v = Validator(self.PHASE_0_ONLY)
|
|
383
|
+
violations = v.validate(phase=0)
|
|
384
|
+
structural = [x for x in violations if x.kind == "structural"]
|
|
385
|
+
self.assertEqual(structural, [],
|
|
386
|
+
f"phase=0 should not flag missing downstream sections; got: {structural}")
|
|
387
|
+
|
|
388
|
+
def test_phase_0_file_default_validation_FAILS_without_phase(self):
|
|
389
|
+
# Old behavior at the API level: if validate() is called with phase=None
|
|
390
|
+
# and the file is missing downstream sections, those ARE flagged.
|
|
391
|
+
v = Validator(self.PHASE_0_ONLY)
|
|
392
|
+
violations = v.validate() # phase=None defaults to 3 inside the validator
|
|
393
|
+
structural = [x for x in violations if x.kind == "structural"]
|
|
394
|
+
self.assertGreater(len(structural), 0,
|
|
395
|
+
"Without phase arg, missing downstream sections should still be flagged")
|
|
396
|
+
|
|
397
|
+
def test_detect_phase_returns_0_for_phase0_only(self):
|
|
398
|
+
self.assertEqual(detect_phase(self.PHASE_0_ONLY), 0)
|
|
399
|
+
|
|
400
|
+
def test_detect_phase_returns_1_when_advisor_present(self):
|
|
401
|
+
md = self.PHASE_0_ONLY + "\n## Phase 1\n\n### Advisor: Contrarian\n\nSome content.\n"
|
|
402
|
+
self.assertEqual(detect_phase(md), 1)
|
|
403
|
+
|
|
404
|
+
def test_detect_phase_returns_2_when_peer_review_present(self):
|
|
405
|
+
md = self.PHASE_0_ONLY + (
|
|
406
|
+
"\n## Phase 1\n\n### Advisor: Contrarian\nbody\n\n"
|
|
407
|
+
"## Phase 2\n\n### Contrarian reviewing First Principles\n\nbody\n"
|
|
408
|
+
)
|
|
409
|
+
self.assertEqual(detect_phase(md), 2)
|
|
410
|
+
|
|
411
|
+
def test_detect_phase_returns_3_when_phase_3_present(self):
|
|
412
|
+
md = self.PHASE_0_ONLY + "\n## Phase 3 — Chairman's verdict\n\nbody\n"
|
|
413
|
+
self.assertEqual(detect_phase(md), 3)
|
|
414
|
+
|
|
415
|
+
def test_detect_phase_ignores_headers_inside_code_fences(self):
|
|
416
|
+
# Section markers inside ``` blocks shouldn't count.
|
|
417
|
+
md = (
|
|
418
|
+
"# The Council — fenced test\n\n"
|
|
419
|
+
"## Evidence pack\n\n"
|
|
420
|
+
"1. `foo.ts` — bar\n"
|
|
421
|
+
"2. `baz.ts` — qux\n"
|
|
422
|
+
"3. `quux.ts` — flob\n\n"
|
|
423
|
+
"Here is some example output:\n"
|
|
424
|
+
"```\n"
|
|
425
|
+
"## Phase 3 — Chairman's verdict\n"
|
|
426
|
+
"### Advisor: Contrarian\n"
|
|
427
|
+
"```\n"
|
|
428
|
+
)
|
|
429
|
+
# Inside the fence, those headers should NOT be detected.
|
|
430
|
+
self.assertEqual(detect_phase(md), 0)
|
|
431
|
+
|
|
432
|
+
def test_phase_1_validation_checks_advisors_but_not_peer_reviews(self):
|
|
433
|
+
md = self.PHASE_0_ONLY + (
|
|
434
|
+
"\n## Phase 1 — Advisor analyses\n\n"
|
|
435
|
+
"### Advisor: Contrarian\n\n"
|
|
436
|
+
"**Frame:** ...\n\n"
|
|
437
|
+
"**Evidence anchored:**\n- foo — bar\n- baz — qux\n\n"
|
|
438
|
+
"**Verdict from this lens:** ...\n\n"
|
|
439
|
+
"**Single sharpest point:** ...\n"
|
|
440
|
+
)
|
|
441
|
+
v = Validator(md)
|
|
442
|
+
violations = v.validate(phase=1)
|
|
443
|
+
# Should flag missing advisors (4 missing) but NOT peer reviews / convergence / chairman.
|
|
444
|
+
msgs = [(x.section, x.message) for x in violations if x.kind == "structural"]
|
|
445
|
+
# No peer-review or Phase 2.5 or Phase 3 errors expected.
|
|
446
|
+
self.assertFalse(any("Peer review" in s for s, _ in msgs))
|
|
447
|
+
self.assertFalse(any("Phase 2.5" in s for s, _ in msgs))
|
|
448
|
+
self.assertFalse(any("Phase 3" in s for s, _ in msgs))
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
if __name__ == "__main__":
|
|
452
|
+
unittest.main(verbosity=2)
|