Skip to content
Draft
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
ce02bbc
test(ci): add exact-head change-scope classifier
seonghobae Sep 3, 2026
c8c898b
test(ci): prove docs-only and code-bearing partition semantics
seonghobae Sep 3, 2026
52285e5
fix(ci): keep docs contracts while skipping Rust-heavy prose work
seonghobae Sep 3, 2026
044cd29
chore(ci): restore protected-main workflow ownership boundary
seonghobae Sep 3, 2026
077704e
test(ci): cover classifier CLI and conservative path edges
seonghobae Sep 3, 2026
0606fbe
test(ci): cover rename-aware scope classification
seonghobae Sep 3, 2026
51a1e81
fix(ci): preserve rename preimages in scope classifier
seonghobae Sep 3, 2026
910fd12
test(ci): reject truncated NUL change streams
seonghobae Sep 3, 2026
e08f686
fix(ci): fail closed on truncated NUL change streams
seonghobae Sep 3, 2026
a282707
test(ci): fail closed on mode-blind docs evidence
seonghobae Sep 3, 2026
149c15c
test(ci): require mode-aware raw diff evidence
seonghobae Sep 3, 2026
67460f0
fix(ci): require raw file modes for docs-only scope
seonghobae Sep 3, 2026
ccbf987
test(ci): reject impossible raw object identities
seonghobae Sep 4, 2026
75d1432
fix(ci): validate raw mode and object identity coupling
seonghobae Sep 4, 2026
49f94d4
test(ci): align raw-diff fixtures with object identity invariants
seonghobae Sep 4, 2026
4f604fe
test(ci): reject non-prose blobs under docs from lightweight scope
seonghobae Sep 4, 2026
7c87bc3
fix(ci): keep non-prose docs artifacts on Rust-heavy path
seonghobae Sep 4, 2026
c01c703
test(ci): fail closed on agent instruction control plane
seonghobae Sep 4, 2026
dc429da
fix(ci): keep agent instruction authority on full gate
seonghobae Sep 4, 2026
5860cdf
test(ci): reject nested agent instruction authority from prose lane
seonghobae Sep 4, 2026
5e88f9c
fix(ci): keep nested agent instruction files on full Rust path
seonghobae Sep 4, 2026
b1e2b8a
test(ci): reject unchanged blob modification metadata
seonghobae Sep 4, 2026
b2bd01b
fix(ci): reject impossible unchanged modification records
seonghobae Sep 4, 2026
819db4c
test(ci): reject noncanonical raw diff paths
seonghobae Sep 4, 2026
2a6b622
fix(ci): reject noncanonical raw diff paths
seonghobae Sep 4, 2026
6a84616
test(ci): keep Gemini instructions on full-Rust lane
seonghobae Sep 4, 2026
bd7046f
fix(ci): treat Gemini instructions as control plane
seonghobae Sep 4, 2026
f0d8a56
test(ci): reject noncanonical raw similarity scores
seonghobae Sep 4, 2026
b947dcd
fix(ci): require canonical raw similarity score spelling
seonghobae Sep 4, 2026
24ff89d
test(ci): reject identical rename/copy paths
seonghobae Sep 4, 2026
06d04b7
fix(ci): reject identical rename/copy paths
seonghobae Sep 4, 2026
c157cd1
fix(ci): require complete raw-diff object IDs
seonghobae Sep 4, 2026
b2a120f
test(ci): reject impossible rename similarity identities
seonghobae Sep 4, 2026
8f885ab
fix(ci): bind rename similarity to blob identity
seonghobae Sep 4, 2026
4137d72
test(ci): cover rename similarity identity boundaries
seonghobae Sep 4, 2026
a33f1da
test(ci): align rename fixtures with Git similarity
seonghobae Sep 4, 2026
e3a40a4
chore(ci): merge similarity boundary tests
seonghobae Sep 4, 2026
afe7738
docs(ci): record similarity identity contract
seonghobae Sep 4, 2026
ba0c1c9
chore(ci): adopt current protected main for partition contracts
seonghobae Sep 4, 2026
5fd4b3d
fix(ci): restore fail-closed change partition
seonghobae Sep 4, 2026
87c80f8
fix(ci): trust protected-base scope classifier
seonghobae Sep 4, 2026
b54a585
Merge remote-tracking branch 'origin/main' into codex/pr282-trusted-b…
seonghobae Sep 4, 2026
b64e070
fix(actions): avoid inactive PR runs (#289)
seonghobae Sep 5, 2026
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
136 changes: 136 additions & 0 deletions scripts/ci/classify_ci_change_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""Classify exact-head pull-request changes for lightweight versus Rust-heavy CI."""

from __future__ import annotations

import sys
from pathlib import PurePosixPath
from typing import Iterable


def _decode_repository_path(raw_path: bytes) -> str:
"""Decode one Git pathname and enforce the repository-relative path boundary."""

try:
path = raw_path.decode("utf-8")
except UnicodeDecodeError as error:
raise ValueError("changed path is not valid UTF-8") from error

if not path or path.startswith("/"):
raise ValueError("changed path must be a non-empty repository-relative path")

parts = PurePosixPath(path).parts
if ".." in parts:
raise ValueError("changed path must not contain parent traversal")
return path


def parse_nul_paths(data: bytes) -> tuple[str, ...]:
"""Decode a NUL-delimited Git path stream and reject ambiguous path input."""

if not data:
return ()
if not data.endswith(b"\0"):
raise ValueError("changed path stream must be NUL-terminated")

raw_paths = data.split(b"\0")
raw_paths.pop()

return tuple(_decode_repository_path(raw_path) for raw_path in raw_paths)


def _similarity_status_is_valid(status: str, kind: str) -> bool:
"""Return whether a scored Git status has a canonical 0..100 similarity value."""

if not status.startswith(kind) or len(status) == 1:
return False
score = status[1:]
return score.isascii() and score.isdigit() and 0 <= int(score) <= 100


def parse_nul_name_status(data: bytes) -> tuple[str, ...]:
"""Decode ``git diff --name-status -z`` while preserving rename/copy preimages."""

if not data:
return ()
if not data.endswith(b"\0"):
raise ValueError("changed status stream must be NUL-terminated")

fields = data.split(b"\0")
fields.pop()

paths: list[str] = []
index = 0
while index < len(fields):
raw_status = fields[index]
index += 1
try:
status = raw_status.decode("ascii")
except UnicodeDecodeError as error:
raise ValueError("changed record has an invalid Git status") from error

if status in {"A", "D", "M", "T", "U"} or _similarity_status_is_valid(
status, "M"
):
if index >= len(fields):
raise ValueError("changed status record is missing its path")
paths.append(_decode_repository_path(fields[index]))
index += 1
continue

if _similarity_status_is_valid(status, "R") or _similarity_status_is_valid(
status, "C"
):
if index + 1 >= len(fields):
raise ValueError("rename/copy status record must include both paths")
paths.append(_decode_repository_path(fields[index]))
paths.append(_decode_repository_path(fields[index + 1]))
index += 2
continue

raise ValueError(f"changed record has unsupported Git status {status!r}")

return tuple(paths)


def is_documentation_path(path: str) -> bool:
"""Return whether a repository path belongs to the prose-only contract surface."""

return path.startswith("docs/") or ("/" not in path and path.endswith(".md"))


def classify_paths(paths: Iterable[str]) -> tuple[bool, bool]:
"""Return ``(documentation_only, rust_required)`` for exact changed paths."""

materialized = tuple(paths)
if not materialized:
return False, True

documentation_only = all(is_documentation_path(path) for path in materialized)
return documentation_only, not documentation_only


def render_outputs(documentation_only: bool, rust_required: bool) -> str:
"""Render deterministic GitHub Actions outputs without accepting extra state."""

return (
f"documentation_only={'true' if documentation_only else 'false'}\n"
f"rust_required={'true' if rust_required else 'false'}\n"
)


def main() -> int:
"""Read status-aware NUL-delimited changes and emit fail-closed CI scope outputs."""

try:
paths = parse_nul_name_status(sys.stdin.buffer.read())
except ValueError as error:
print(f"CI scope classification failed: {error}", file=sys.stderr)
return 2

documentation_only, rust_required = classify_paths(paths)
sys.stdout.write(render_outputs(documentation_only, rust_required))
return 0


if __name__ == "__main__":
raise SystemExit(main())
230 changes: 230 additions & 0 deletions tests/test_ci_change_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
"""Regression tests for exact-head CI path partitioning."""

from __future__ import annotations

import pathlib
import subprocess
import sys
import unittest

from scripts.ci.classify_ci_change_scope import (
classify_paths,
is_documentation_path,
parse_nul_name_status,
parse_nul_paths,
render_outputs,
)

ROOT = pathlib.Path(__file__).resolve().parents[1]
CLASSIFIER = ROOT / "scripts/ci/classify_ci_change_scope.py"


class CiChangeScopeTests(unittest.TestCase):
"""Keep documentation verification fail-closed without forcing Rust on prose-only heads."""

def test_documentation_paths_are_lightweight(self) -> None:
"""Docs and root Markdown files should retain the lightweight contract lane."""

paths = ("docs/adr/0103-example.md", "README.md", "CHANGELOG.md")
self.assertTrue(all(is_documentation_path(path) for path in paths))
self.assertEqual(classify_paths(paths), (True, False))

def test_nested_markdown_outside_docs_requires_rust(self) -> None:
"""A Markdown suffix alone must not widen the reviewed prose-only surface."""

path = "crates/originweave-core/README.md"
self.assertFalse(is_documentation_path(path))
self.assertEqual(classify_paths((path,)), (False, True))

def test_non_documentation_path_requires_rust(self) -> None:
"""Any code, workflow, test, config, or other non-prose path keeps Rust-heavy CI."""

for path in (
"Cargo.toml",
"crates/originweave-core/src/lib.rs",
".github/workflows/ci.yml",
"tests/test_repository_contract.py",
"scripts/ci/verify_coverage.py",
):
with self.subTest(path=path):
self.assertFalse(is_documentation_path(path))
self.assertEqual(classify_paths((path,)), (False, True))

def test_mixed_change_requires_rust(self) -> None:
"""A prose edit cannot hide a code-bearing delta from the Rust lanes."""

self.assertEqual(
classify_paths(("docs/PRD.md", "crates/originweave-policy/src/lib.rs")),
(False, True),
)

def test_empty_change_fails_closed_to_rust(self) -> None:
"""Missing changed-path evidence must not be treated as documentation-only."""

self.assertEqual(classify_paths(()), (False, True))

def test_nul_path_parser_preserves_spaces_and_unicode(self) -> None:
"""Git's NUL framing must preserve valid repository names without shell splitting."""

data = "docs/운영 문서.md\0README.md\0".encode()
self.assertEqual(parse_nul_paths(data), ("docs/운영 문서.md", "README.md"))

def test_nul_path_parser_rejects_invalid_utf8(self) -> None:
"""Ambiguous path bytes must fail before a CI scope decision is emitted."""

with self.assertRaisesRegex(ValueError, "valid UTF-8"):
parse_nul_paths(b"docs/ok.md\0\xff\0")

def test_nul_path_parser_rejects_absolute_path(self) -> None:
"""Classification accepts repository-relative paths only."""

with self.assertRaisesRegex(ValueError, "repository-relative"):
parse_nul_paths(b"/docs/PRD.md\0")

def test_nul_path_parser_rejects_parent_traversal(self) -> None:
"""Classification accepts repository paths, never parent-relative spellings."""

with self.assertRaisesRegex(ValueError, "parent traversal"):
parse_nul_paths(b"docs/../Cargo.toml\0")

def test_nul_path_parser_rejects_unterminated_stream(self) -> None:
"""A truncated path stream must not be accepted as complete change evidence."""

with self.assertRaisesRegex(ValueError, "NUL-terminated"):
parse_nul_paths(b"docs/PRD.md")

def test_name_status_parser_preserves_docs_only_changes(self) -> None:
"""Status framing must not force Rust when every affected path is prose-only."""

data = b"M\0docs/PRD.md\0A\0README.md\0D\0docs/old.md\0"
paths = parse_nul_name_status(data)
self.assertEqual(paths, ("docs/PRD.md", "README.md", "docs/old.md"))
self.assertEqual(classify_paths(paths), (True, False))

def test_code_to_docs_rename_requires_rust(self) -> None:
"""A post-image docs path must not hide a code-bearing rename preimage."""

data = b"R100\0crates/demo/src/lib.rs\0docs/lib.md\0"
paths = parse_nul_name_status(data)
self.assertEqual(paths, ("crates/demo/src/lib.rs", "docs/lib.md"))
self.assertEqual(classify_paths(paths), (False, True))

def test_docs_to_docs_rename_remains_lightweight(self) -> None:
"""A rename whose preimage and postimage are both docs stays in the lightweight lane."""

data = b"R095\0docs/old.md\0docs/new.md\0"
paths = parse_nul_name_status(data)
self.assertEqual(paths, ("docs/old.md", "docs/new.md"))
self.assertEqual(classify_paths(paths), (True, False))

def test_name_status_parser_rejects_truncated_rename(self) -> None:
"""Rename/copy records must include both source and destination paths."""

with self.assertRaisesRegex(ValueError, "rename/copy"):
parse_nul_name_status(b"R100\0docs/old.md\0")

def test_name_status_parser_rejects_unterminated_stream(self) -> None:
"""Missing terminal NUL must fail closed before docs-only classification."""

with self.assertRaisesRegex(ValueError, "NUL-terminated"):
parse_nul_name_status(b"M\0docs/PRD.md")

def test_name_status_parser_rejects_unknown_status(self) -> None:
"""Unknown Git status records fail closed instead of guessing path cardinality."""

with self.assertRaisesRegex(ValueError, "status"):
parse_nul_name_status(b"Q\0docs/PRD.md\0")

def test_outputs_are_exact_booleans(self) -> None:
"""Workflow outputs stay deterministic for job-level conditions."""

self.assertEqual(
render_outputs(True, False),
"documentation_only=true\nrust_required=false\n",
)
self.assertEqual(
render_outputs(False, True),
"documentation_only=false\nrust_required=true\n",
)

def test_cli_emits_lightweight_scope_for_nul_delimited_docs_status(self) -> None:
"""The executable boundary must preserve Git's status-aware NUL framing."""

completed = subprocess.run(
[sys.executable, str(CLASSIFIER)],
input=b"M\0docs/PRD.md\0A\0README.md\0",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
self.assertEqual(completed.returncode, 0, completed.stderr.decode())
self.assertEqual(
completed.stdout.decode(),
"documentation_only=true\nrust_required=false\n",
)
self.assertEqual(completed.stderr, b"")

def test_cli_requires_rust_for_code_to_docs_rename(self) -> None:
"""The executable boundary must classify both sides of a rename."""

completed = subprocess.run(
[sys.executable, str(CLASSIFIER)],
input=b"R100\0crates/demo/src/lib.rs\0docs/lib.md\0",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
self.assertEqual(completed.returncode, 0, completed.stderr.decode())
self.assertEqual(
completed.stdout.decode(),
"documentation_only=false\nrust_required=true\n",
)
self.assertEqual(completed.stderr, b"")

def test_cli_fails_closed_on_invalid_path_bytes(self) -> None:
"""Malformed Git path evidence must return non-zero without emitting scope outputs."""

completed = subprocess.run(
[sys.executable, str(CLASSIFIER)],
input=b"M\0docs/PRD.md\0M\0\xff\0",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
self.assertEqual(completed.returncode, 2)
self.assertEqual(completed.stdout, b"")
self.assertIn(b"CI scope classification failed", completed.stderr)
self.assertIn(b"valid UTF-8", completed.stderr)

def test_cli_fails_closed_on_unterminated_status_stream(self) -> None:
"""A truncated Git stream must not emit a lightweight CI decision."""

completed = subprocess.run(
[sys.executable, str(CLASSIFIER)],
input=b"M\0docs/PRD.md",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
self.assertEqual(completed.returncode, 2)
self.assertEqual(completed.stdout, b"")
self.assertIn(b"CI scope classification failed", completed.stderr)
self.assertIn(b"NUL-terminated", completed.stderr)

def test_workflow_keeps_docs_contracts_separate_from_rust(self) -> None:
"""The CI workflow must always run docs contracts and gate Rust jobs by scope."""

workflow = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8")
self.assertIn("name: Repository and documentation contracts", workflow)
self.assertIn("python3 -m unittest discover -s tests -p 'test_*.py'", workflow)
self.assertIn("needs: [scope, contracts]", workflow)
self.assertGreaterEqual(
workflow.count("needs.scope.outputs.rust_required == 'true'"),
2,
)
self.assertIn("git diff --name-status -z", workflow)
self.assertIn("classify_ci_change_scope.py", workflow)


if __name__ == "__main__":
unittest.main()
Loading