Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py \
plugins/codex-fable5/skills/codex-fable5/scripts/fable_coverage.py \
plugins/codex-fable5/skills/codex-fable5/scripts/make_litellm_config.py \
tests/test_scripts.py
tests/*.py
sh -n plugins/codex-fable5/bin/codex-fable5
sh -n plugins/codex-fable5/bin/codex-findings
sh -n plugins/codex-fable5/bin/codex-goals
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ python3 -m py_compile \
plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py \
plugins/codex-fable5/skills/codex-fable5/scripts/fable_coverage.py \
plugins/codex-fable5/skills/codex-fable5/scripts/make_litellm_config.py \
tests/test_scripts.py
tests/*.py
sh -n plugins/codex-fable5/bin/codex-fable5
sh -n plugins/codex-fable5/bin/codex-findings
sh -n plugins/codex-fable5/bin/codex-goals
Expand Down
2 changes: 1 addition & 1 deletion docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ python3 -m py_compile \
plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py \
plugins/codex-fable5/skills/codex-fable5/scripts/fable_coverage.py \
plugins/codex-fable5/skills/codex-fable5/scripts/make_litellm_config.py \
tests/test_scripts.py
tests/*.py
sh -n plugins/codex-fable5/bin/codex-fable5
sh -n plugins/codex-fable5/bin/codex-findings
sh -n plugins/codex-fable5/bin/codex-goals
Expand Down
Empty file added tests/__init__.py
Empty file.
73 changes: 73 additions & 0 deletions tests/support.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from __future__ import annotations

import importlib.util
import json
import os
import re
import subprocess
import sys
import tempfile
import textwrap
import unittest
from pathlib import Path
from types import SimpleNamespace


ROOT = Path(__file__).resolve().parents[1]
SKILL_ROOT = ROOT / "plugins" / "codex-fable5" / "skills" / "codex-fable5"
SCRIPTS = SKILL_ROOT / "scripts"
BIN = ROOT / "plugins" / "codex-fable5" / "bin"


def load_script(name: str):
path = SCRIPTS / f"{name}.py"
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise RuntimeError(f"could not load {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module


def read_skill_body() -> str:
skill = (SKILL_ROOT / "SKILL.md").read_text(encoding="utf-8")
match = re.match(r"\A---\r?\n.*?\r?\n---\r?\n(?P<body>.*)\Z", skill, re.DOTALL)
if match is None:
raise AssertionError("SKILL.md frontmatter block not found")
return match.group("body")


def parse_routing_map(body: str) -> list[tuple[str, str]]:
match = re.search(r"^## Routing Map\r?\n\r?\n(?P<table>.*?)(?:\r?\n## |\Z)", body, re.DOTALL | re.MULTILINE)
if match is None:
raise AssertionError("Routing Map section not found")
rows: list[tuple[str, str]] = []
for line in match.group("table").splitlines():
if not line.startswith("|"):
continue
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
if len(cells) != 2 or cells[0] == "Signal" or set(cells[0]) == {"-"}:
continue
rows.append((cells[0], cells[1]))
return rows


def read_readme_fable_pin() -> str:
readme = (ROOT / "README.md").read_text(encoding="utf-8")
match = re.search(
r"`elder-plinius/CL4R1T4S`\s+`ANTHROPIC/CLAUDE-FABLE-5\.md`\s+"
r"at commit\s+`([0-9a-f]{40})`",
readme,
)
if match is None:
raise AssertionError("pinned FABLE-5 SHA not found in README")
return match.group(1)


class ScriptTestBase(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.fable_coverage = load_script("fable_coverage")
cls.codex_goals = load_script("codex_goals")
cls.codex_findings = load_script("codex_findings")
cls.make_litellm_config = load_script("make_litellm_config")
203 changes: 203 additions & 0 deletions tests/test_ci_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
from __future__ import annotations

try:
from tests.support import (
BIN,
Path,
ROOT,
SCRIPTS,
SKILL_ROOT,
ScriptTestBase,
SimpleNamespace,
json,
os,
parse_routing_map,
read_readme_fable_pin,
read_skill_body,
re,
subprocess,
sys,
tempfile,
textwrap,
)
except ModuleNotFoundError: # unittest discovery with tests/ as top-level.
from support import (
BIN,
Path,
ROOT,
SCRIPTS,
SKILL_ROOT,
ScriptTestBase,
SimpleNamespace,
json,
os,
parse_routing_map,
read_readme_fable_pin,
read_skill_body,
re,
subprocess,
sys,
tempfile,
textwrap,
)


class CiReleaseTests(ScriptTestBase):
def test_ci_workflow_runs_project_verification(self) -> None:
workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")

self.assertIn("actions/checkout@v6", workflow)
self.assertIn("actions/setup-python@v6", workflow)
self.assertIn("python3 -m unittest discover -s tests -v", workflow)
self.assertIn("python3 -m py_compile", workflow)
self.assertIn("codex_findings.py", workflow)
self.assertIn("sh -n plugins/codex-fable5/bin/codex-fable5", workflow)
self.assertIn("sh -n plugins/codex-fable5/bin/codex-findings", workflow)
self.assertIn("sh -n plugins/codex-fable5/bin/codex-goals", workflow)
self.assertIn("fable_coverage.py", workflow)
self.assertIn('python-version: ["3.11", "3.12", "3.13"]', workflow)

def test_ci_workflow_validates_against_pinned_source(self) -> None:
"""The CI workflow must fetch the pinned upstream source and pass
it to the validator. Without this, the default `--matrix`-only run
is self-consistent and can hide a fabricated row."""
workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
self.assertIn("CLAUDE-FABLE-5.md", workflow)
self.assertIn("elder-plinius/CL4R1T4S", workflow)
fetch_step = re.search(
r" - name: Fetch pinned FABLE-5 source\n(?P<body>.*?)(?:\n - name: |\Z)",
workflow,
re.DOTALL,
)
self.assertIsNotNone(fetch_step, "CI workflow must define the pinned source fetch step")
assert fetch_step is not None # for type checkers
fetch_step_body = fetch_step.group("body")
self.assertIn(
"raw.githubusercontent.com/elder-plinius/CL4R1T4S/${PIN}/ANTHROPIC/CLAUDE-FABLE-5.md",
fetch_step_body,
"CI should fetch the public pinned source directly, matching the release checklist",
)
self.assertNotIn(
"Authorization: Bearer",
fetch_step_body,
"CI source fetch should not rely on a hand-built Authorization header; malformed quoting can hide curl failures",
)
pin_match = re.search(r'PIN="([0-9a-f]{40})"', workflow)
self.assertIsNotNone(pin_match, "CI workflow must define the pinned upstream SHA")
assert pin_match is not None # for type checkers
self.assertEqual(
read_readme_fable_pin(),
pin_match.group(1),
"CI workflow pin must match README source note pin",
)
# The validator invocation must include --source.
# Look for lines that invoke fable_coverage.py and contain --source.
has_source_arg = False
for line in workflow.splitlines():
if "fable_coverage.py" in line and "--source" in line:
has_source_arg = True
break
self.assertTrue(
has_source_arg,
"CI workflow must pass --source to fable_coverage.py "
"so the matrix is validated against the upstream FABLE-5 source",
)

def test_release_checklist_validates_against_pinned_source(self) -> None:
releasing = (ROOT / "docs" / "RELEASING.md").read_text(encoding="utf-8")

self.assertIn("README.md", releasing)
self.assertIn("raw.githubusercontent.com/elder-plinius/CL4R1T4S/${PIN}", releasing)
self.assertIn("--source build/fable5/CLAUDE-FABLE-5.md", releasing)
self.assertIn(r"\s+`ANTHROPIC/CLAUDE-FABLE-5\.md`\s+at commit\s+", releasing)
self.assertNotIn(r"\\s+", releasing)

def test_user_facing_wrappers_run_from_path(self) -> None:
env = {**os.environ, "PATH": f"{BIN}{os.pathsep}{os.environ['PATH']}"}
for command in ["codex-fable5", "codex-findings", "codex-goals"]:
with self.subTest(command=command):
wrapper = BIN / command
self.assertTrue(wrapper.is_file())
self.assertTrue(os.access(wrapper, os.X_OK))

syntax = subprocess.run(
["sh", "-n", str(wrapper)],
cwd=ROOT,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(syntax.returncode, 0, syntax.stderr)

with tempfile.TemporaryDirectory() as tmp:
status = subprocess.run(
["codex-fable5", "status"],
cwd=tmp,
env=env,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(status.returncode, 0, status.stderr)
self.assertIn("0 findings", status.stdout)
self.assertIn("no goal plan", status.stdout)

created = subprocess.run(
[
"codex-fable5",
"goals",
"create",
"--brief",
"Wrapper smoke",
"--goal",
"inspect::Check wrapper path",
],
cwd=tmp,
env=env,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(created.returncode, 0, created.stderr)
self.assertIn("plan created", created.stdout)

started = subprocess.run(
["codex-fable5", "goals", "next"],
cwd=tmp,
env=env,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(started.returncode, 0, started.stderr)

added = subprocess.run(
[
"codex-fable5",
"findings",
"add",
"--title",
"Wrapper finding",
"--evidence",
"PATH wrapper should call the findings script.",
],
cwd=tmp,
env=env,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(added.returncode, 0, added.stderr)
self.assertIn("goal=G001", added.stdout)

status_with_plan = subprocess.run(
["codex-fable5", "status"],
cwd=tmp,
env=env,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(status_with_plan.returncode, 0, status_with_plan.stderr)
self.assertIn("1 open", status_with_plan.stdout)
self.assertIn("0/1 complete", status_with_plan.stdout)
Loading