From ce02bbc1d5bddc0cb052f5d8283e5b30ce747003 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 04:03:43 +0900 Subject: [PATCH 01/40] test(ci): add exact-head change-scope classifier --- scripts/ci/classify_ci_change_scope.py | 79 ++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 scripts/ci/classify_ci_change_scope.py diff --git a/scripts/ci/classify_ci_change_scope.py b/scripts/ci/classify_ci_change_scope.py new file mode 100644 index 000000000..6308b2797 --- /dev/null +++ b/scripts/ci/classify_ci_change_scope.py @@ -0,0 +1,79 @@ +"""Classify exact-head pull-request paths for lightweight versus Rust-heavy CI.""" + +from __future__ import annotations + +import sys +from pathlib import PurePosixPath +from typing import Iterable + + +def parse_nul_paths(data: bytes) -> tuple[str, ...]: + """Decode a NUL-delimited Git path stream and reject ambiguous path input.""" + + if not data: + return () + + raw_paths = data.split(b"\0") + if raw_paths[-1] == b"": + raw_paths.pop() + + paths: list[str] = [] + for raw_path in raw_paths: + 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") + paths.append(path) + + 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 NUL-delimited paths from stdin and emit fail-closed CI scope outputs.""" + + try: + paths = parse_nul_paths(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()) From c8c898b013ef4260725d92e7ce9828f665b5879c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 04:04:02 +0900 Subject: [PATCH 02/40] test(ci): prove docs-only and code-bearing partition semantics --- tests/test_ci_change_scope.py | 101 ++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 tests/test_ci_change_scope.py diff --git a/tests/test_ci_change_scope.py b/tests/test_ci_change_scope.py new file mode 100644 index 000000000..3f01252f7 --- /dev/null +++ b/tests/test_ci_change_scope.py @@ -0,0 +1,101 @@ +"""Regression tests for exact-head CI path partitioning.""" + +from __future__ import annotations + +import pathlib +import unittest + +from scripts.ci.classify_ci_change_scope import ( + classify_paths, + is_documentation_path, + parse_nul_paths, + render_outputs, +) + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +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_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_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_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_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-only -z", workflow) + self.assertIn("classify_ci_change_scope.py", workflow) + + +if __name__ == "__main__": + unittest.main() From 52285e5ec173208674dd4ad051684129b45779de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 04:05:22 +0900 Subject: [PATCH 03/40] fix(ci): keep docs contracts while skipping Rust-heavy prose work --- .github/workflows/ci.yml | 52 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95c2fa1d7..ed3863d03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,54 @@ concurrency: cancel-in-progress: true jobs: + scope: + name: Classify CI scope + runs-on: ubuntu-24.04 + outputs: + documentation_only: ${{ steps.scope.outputs.documentation_only }} + rust_required: ${{ steps.scope.outputs.rust_required }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + fetch-depth: 1 + - name: Classify exact changed paths + id: scope + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + shell: bash + run: | + set -euo pipefail + if [ "${GITHUB_EVENT_NAME}" != "pull_request" ]; then + printf 'documentation_only=false\nrust_required=true\n' >> "${GITHUB_OUTPUT}" + exit 0 + fi + test -n "${BASE_SHA}" + test -n "${HEAD_SHA}" + git fetch --no-tags --depth=1 origin "${BASE_SHA}" + git diff --name-only -z --no-renames "${BASE_SHA}" "${HEAD_SHA}" | + python3 scripts/ci/classify_ci_change_scope.py >> "${GITHUB_OUTPUT}" + + contracts: + name: Repository and documentation contracts + needs: scope + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Check Python repository contracts + run: | + python3 -m compileall -q scripts tests + python3 -m unittest discover -s tests -p 'test_*.py' + rust: name: Rust contracts + needs: [scope, contracts] + if: needs.scope.outputs.rust_required == 'true' runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -25,10 +71,6 @@ jobs: with: toolchain: 1.97.1 components: clippy,rustfmt - - name: Check Python repository contracts - run: | - python3 -m compileall -q scripts tests - python3 -m unittest discover -s tests -p 'test_*.py' - name: Check formatting id: formatting run: cargo fmt --all --check @@ -65,6 +107,8 @@ jobs: coverage: name: Production coverage + needs: [scope, contracts] + if: needs.scope.outputs.rust_required == 'true' runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 From 044cd294bddd737433da7f37756b5b71d5fe8c39 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 04:11:09 +0900 Subject: [PATCH 04/40] chore(ci): restore protected-main workflow ownership boundary --- .github/workflows/ci.yml | 52 ++++------------------------------------ 1 file changed, 4 insertions(+), 48 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed3863d03..95c2fa1d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,54 +13,8 @@ concurrency: cancel-in-progress: true jobs: - scope: - name: Classify CI scope - runs-on: ubuntu-24.04 - outputs: - documentation_only: ${{ steps.scope.outputs.documentation_only }} - rust_required: ${{ steps.scope.outputs.rust_required }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - persist-credentials: false - fetch-depth: 1 - - name: Classify exact changed paths - id: scope - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - shell: bash - run: | - set -euo pipefail - if [ "${GITHUB_EVENT_NAME}" != "pull_request" ]; then - printf 'documentation_only=false\nrust_required=true\n' >> "${GITHUB_OUTPUT}" - exit 0 - fi - test -n "${BASE_SHA}" - test -n "${HEAD_SHA}" - git fetch --no-tags --depth=1 origin "${BASE_SHA}" - git diff --name-only -z --no-renames "${BASE_SHA}" "${HEAD_SHA}" | - python3 scripts/ci/classify_ci_change_scope.py >> "${GITHUB_OUTPUT}" - - contracts: - name: Repository and documentation contracts - needs: scope - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - persist-credentials: false - - name: Check Python repository contracts - run: | - python3 -m compileall -q scripts tests - python3 -m unittest discover -s tests -p 'test_*.py' - rust: name: Rust contracts - needs: [scope, contracts] - if: needs.scope.outputs.rust_required == 'true' runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -71,6 +25,10 @@ jobs: with: toolchain: 1.97.1 components: clippy,rustfmt + - name: Check Python repository contracts + run: | + python3 -m compileall -q scripts tests + python3 -m unittest discover -s tests -p 'test_*.py' - name: Check formatting id: formatting run: cargo fmt --all --check @@ -107,8 +65,6 @@ jobs: coverage: name: Production coverage - needs: [scope, contracts] - if: needs.scope.outputs.rust_required == 'true' runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 From 077704efbdd7431ab123d675db76dc47948600ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 04:15:44 +0900 Subject: [PATCH 05/40] test(ci): cover classifier CLI and conservative path edges --- tests/test_ci_change_scope.py | 48 +++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/test_ci_change_scope.py b/tests/test_ci_change_scope.py index 3f01252f7..aafce5e8f 100644 --- a/tests/test_ci_change_scope.py +++ b/tests/test_ci_change_scope.py @@ -3,6 +3,8 @@ from __future__ import annotations import pathlib +import subprocess +import sys import unittest from scripts.ci.classify_ci_change_scope import ( @@ -13,6 +15,7 @@ ) ROOT = pathlib.Path(__file__).resolve().parents[1] +CLASSIFIER = ROOT / "scripts/ci/classify_ci_change_scope.py" class CiChangeScopeTests(unittest.TestCase): @@ -25,6 +28,13 @@ def test_documentation_paths_are_lightweight(self) -> None: 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.""" @@ -64,6 +74,12 @@ def test_nul_path_parser_rejects_invalid_utf8(self) -> None: 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.""" @@ -82,6 +98,38 @@ def test_outputs_are_exact_booleans(self) -> None: "documentation_only=false\nrust_required=true\n", ) + def test_cli_emits_lightweight_scope_for_nul_delimited_docs(self) -> None: + """The executable boundary must preserve Git's NUL-framed documentation input.""" + + completed = subprocess.run( + [sys.executable, str(CLASSIFIER)], + input="docs/PRD.md\0README.md\0".encode(), + 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_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"docs/PRD.md\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_workflow_keeps_docs_contracts_separate_from_rust(self) -> None: """The CI workflow must always run docs contracts and gate Rust jobs by scope.""" From 0606fbeecff5f5aae7d69a4608a5ffee2b156aec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 05:06:46 +0900 Subject: [PATCH 06/40] test(ci): cover rename-aware scope classification --- tests/test_ci_change_scope.py | 64 ++++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/tests/test_ci_change_scope.py b/tests/test_ci_change_scope.py index aafce5e8f..f9a83b344 100644 --- a/tests/test_ci_change_scope.py +++ b/tests/test_ci_change_scope.py @@ -10,6 +10,7 @@ from scripts.ci.classify_ci_change_scope import ( classify_paths, is_documentation_path, + parse_nul_name_status, parse_nul_paths, render_outputs, ) @@ -86,6 +87,42 @@ def test_nul_path_parser_rejects_parent_traversal(self) -> None: with self.assertRaisesRegex(ValueError, "parent traversal"): parse_nul_paths(b"docs/../Cargo.toml\0") + 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_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.""" @@ -98,12 +135,12 @@ def test_outputs_are_exact_booleans(self) -> None: "documentation_only=false\nrust_required=true\n", ) - def test_cli_emits_lightweight_scope_for_nul_delimited_docs(self) -> None: - """The executable boundary must preserve Git's NUL-framed documentation input.""" + 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="docs/PRD.md\0README.md\0".encode(), + input=b"M\0docs/PRD.md\0A\0README.md\0", stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, @@ -115,12 +152,29 @@ def test_cli_emits_lightweight_scope_for_nul_delimited_docs(self) -> None: ) 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"docs/PRD.md\0\xff\0", + input=b"M\0docs/PRD.md\0M\0\xff\0", stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, @@ -141,7 +195,7 @@ def test_workflow_keeps_docs_contracts_separate_from_rust(self) -> None: workflow.count("needs.scope.outputs.rust_required == 'true'"), 2, ) - self.assertIn("git diff --name-only -z", workflow) + self.assertIn("git diff --name-status -z", workflow) self.assertIn("classify_ci_change_scope.py", workflow) From 51a1e819355306e630d1241e6e5a136cb195325e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 05:07:25 +0900 Subject: [PATCH 07/40] fix(ci): preserve rename preimages in scope classifier --- scripts/ci/classify_ci_change_scope.py | 83 +++++++++++++++++++++----- 1 file changed, 69 insertions(+), 14 deletions(-) diff --git a/scripts/ci/classify_ci_change_scope.py b/scripts/ci/classify_ci_change_scope.py index 6308b2797..59062e5d7 100644 --- a/scripts/ci/classify_ci_change_scope.py +++ b/scripts/ci/classify_ci_change_scope.py @@ -1,4 +1,4 @@ -"""Classify exact-head pull-request paths for lightweight versus Rust-heavy CI.""" +"""Classify exact-head pull-request changes for lightweight versus Rust-heavy CI.""" from __future__ import annotations @@ -7,6 +7,23 @@ 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.""" @@ -17,20 +34,58 @@ def parse_nul_paths(data: bytes) -> tuple[str, ...]: if raw_paths[-1] == b"": 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 () + + fields = data.split(b"\0") + if fields[-1] == b"": + fields.pop() + paths: list[str] = [] - for raw_path in raw_paths: + index = 0 + while index < len(fields): + raw_status = fields[index] + index += 1 try: - path = raw_path.decode("utf-8") + status = raw_status.decode("ascii") 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") - paths.append(path) + 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) @@ -62,10 +117,10 @@ def render_outputs(documentation_only: bool, rust_required: bool) -> str: def main() -> int: - """Read NUL-delimited paths from stdin and emit fail-closed CI scope outputs.""" + """Read status-aware NUL-delimited changes and emit fail-closed CI scope outputs.""" try: - paths = parse_nul_paths(sys.stdin.buffer.read()) + 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 From 910fd12481eba541b98f738ae3977a318621a1c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 07:31:03 +0900 Subject: [PATCH 08/40] test(ci): reject truncated NUL change streams --- tests/test_ci_change_scope.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_ci_change_scope.py b/tests/test_ci_change_scope.py index f9a83b344..e3cdd0e36 100644 --- a/tests/test_ci_change_scope.py +++ b/tests/test_ci_change_scope.py @@ -87,6 +87,12 @@ def test_nul_path_parser_rejects_parent_traversal(self) -> None: 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.""" @@ -117,6 +123,12 @@ def test_name_status_parser_rejects_truncated_rename(self) -> None: 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.""" @@ -184,6 +196,21 @@ def test_cli_fails_closed_on_invalid_path_bytes(self) -> None: 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.""" From e08f68614ca21a9a751461f9e642d2a66b4fe8ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 07:31:23 +0900 Subject: [PATCH 09/40] fix(ci): fail closed on truncated NUL change streams --- scripts/ci/classify_ci_change_scope.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/ci/classify_ci_change_scope.py b/scripts/ci/classify_ci_change_scope.py index 59062e5d7..7129f2ba5 100644 --- a/scripts/ci/classify_ci_change_scope.py +++ b/scripts/ci/classify_ci_change_scope.py @@ -29,10 +29,11 @@ def parse_nul_paths(data: bytes) -> tuple[str, ...]: 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") - if raw_paths[-1] == b"": - raw_paths.pop() + raw_paths.pop() return tuple(_decode_repository_path(raw_path) for raw_path in raw_paths) @@ -51,10 +52,11 @@ def parse_nul_name_status(data: bytes) -> tuple[str, ...]: if not data: return () + if not data.endswith(b"\0"): + raise ValueError("changed status stream must be NUL-terminated") fields = data.split(b"\0") - if fields[-1] == b"": - fields.pop() + fields.pop() paths: list[str] = [] index = 0 From a28270781244192b0f5bb57b2f58d8b5322c7870 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 08:01:58 +0900 Subject: [PATCH 10/40] test(ci): fail closed on mode-blind docs evidence --- tests/test_ci_change_scope_modes.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/test_ci_change_scope_modes.py diff --git a/tests/test_ci_change_scope_modes.py b/tests/test_ci_change_scope_modes.py new file mode 100644 index 000000000..d765e8031 --- /dev/null +++ b/tests/test_ci_change_scope_modes.py @@ -0,0 +1,29 @@ +"""Security regressions for mode-aware CI change-scope evidence.""" + +from __future__ import annotations + +import unittest + +from scripts.ci.classify_ci_change_scope import classify_paths, parse_nul_name_status + + +class CiChangeScopeModeTests(unittest.TestCase): + """Do not treat path/status-only Git evidence as proof of a prose-only blob.""" + + def test_mode_blind_modified_docs_path_fails_closed(self) -> None: + """A modified docs path could be a symlink or gitlink when modes are absent.""" + + paths = parse_nul_name_status(b"M\0docs/guide.md\0") + self.assertEqual(paths, ("docs/guide.md",)) + self.assertEqual(classify_paths(paths), (False, True)) + + def test_type_changed_docs_path_fails_closed(self) -> None: + """A regular-file-to-symlink type change must never enter the lightweight lane.""" + + paths = parse_nul_name_status(b"T\0docs/guide.md\0") + self.assertEqual(paths, ("docs/guide.md",)) + self.assertEqual(classify_paths(paths), (False, True)) + + +if __name__ == "__main__": + unittest.main() From 149c15c03b0d130d534882fc87308c73dfd90d2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 08:03:12 +0900 Subject: [PATCH 11/40] test(ci): require mode-aware raw diff evidence --- tests/test_ci_change_scope.py | 166 +++++++++++++++++++++++++++------- 1 file changed, 132 insertions(+), 34 deletions(-) diff --git a/tests/test_ci_change_scope.py b/tests/test_ci_change_scope.py index e3cdd0e36..686d17fd0 100644 --- a/tests/test_ci_change_scope.py +++ b/tests/test_ci_change_scope.py @@ -8,10 +8,12 @@ import unittest from scripts.ci.classify_ci_change_scope import ( + classify_changes, classify_paths, is_documentation_path, parse_nul_name_status, parse_nul_paths, + parse_nul_raw_changes, render_outputs, ) @@ -19,15 +21,29 @@ CLASSIFIER = ROOT / "scripts/ci/classify_ci_change_scope.py" +def _raw_record( + source_mode: str, + destination_mode: str, + status: str, + *paths: str, +) -> bytes: + """Build one deterministic NUL-framed raw-diff record for focused tests.""" + + metadata = ( + f":{source_mode} {destination_mode} 1111111 2222222 {status}\0" + ).encode() + return metadata + b"\0".join(path.encode() for path in paths) + b"\0" + + class CiChangeScopeTests(unittest.TestCase): - """Keep documentation verification fail-closed without forcing Rust on prose-only heads.""" + """Keep documentation verification fail-closed without forcing Rust on proven prose heads.""" - def test_documentation_paths_are_lightweight(self) -> None: - """Docs and root Markdown files should retain the lightweight contract lane.""" + def test_documentation_paths_need_mode_evidence_before_lightweight_classification(self) -> None: + """Paths alone cannot prove that a docs entry is an ordinary prose blob.""" 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)) + self.assertEqual(classify_paths(paths), (False, True)) def test_nested_markdown_outside_docs_requires_rust(self) -> None: """A Markdown suffix alone must not widen the reviewed prose-only surface.""" @@ -50,18 +66,105 @@ def test_non_documentation_path_requires_rust(self) -> None: 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.""" + def test_empty_change_fails_closed_to_rust(self) -> None: + """Missing changed-file evidence must not be treated as documentation-only.""" + + self.assertEqual(classify_paths(()), (False, True)) + self.assertEqual(classify_changes(()), (False, True)) - self.assertEqual( - classify_paths(("docs/PRD.md", "crates/originweave-policy/src/lib.rs")), - (False, True), + def test_raw_regular_docs_changes_are_lightweight(self) -> None: + """Ordinary non-executable blobs on both materialized sides prove the docs lane.""" + + data = ( + _raw_record("100644", "100644", "M", "docs/PRD.md") + + _raw_record("000000", "100644", "A", "README.md") + + _raw_record("100644", "000000", "D", "docs/old.md") ) + changes = parse_nul_raw_changes(data) + self.assertEqual(classify_changes(changes), (True, False)) - def test_empty_change_fails_closed_to_rust(self) -> None: - """Missing changed-path evidence must not be treated as documentation-only.""" + def test_raw_code_change_requires_rust(self) -> None: + """Mode-aware evidence does not weaken the existing path boundary.""" - self.assertEqual(classify_paths(()), (False, True)) + data = _raw_record( + "100644", + "100644", + "M", + "crates/originweave-core/src/lib.rs", + ) + self.assertEqual(classify_changes(parse_nul_raw_changes(data)), (False, True)) + + def test_raw_code_to_docs_rename_requires_rust(self) -> None: + """Raw rename records must preserve both the code preimage and docs postimage.""" + + data = _raw_record( + "100644", + "100644", + "R100", + "crates/demo/src/lib.rs", + "docs/lib.md", + ) + self.assertEqual(classify_changes(parse_nul_raw_changes(data)), (False, True)) + + def test_raw_docs_to_docs_rename_remains_lightweight(self) -> None: + """A regular-blob rename wholly inside docs stays in the lightweight lane.""" + + data = _raw_record( + "100644", + "100644", + "R095", + "docs/old.md", + "docs/new.md", + ) + self.assertEqual(classify_changes(parse_nul_raw_changes(data)), (True, False)) + + def test_raw_symlink_edit_requires_rust(self) -> None: + """A modified symlink under docs is not proven prose despite status M and a docs path.""" + + data = _raw_record("120000", "120000", "M", "docs/guide.md") + self.assertEqual(classify_changes(parse_nul_raw_changes(data)), (False, True)) + + def test_raw_gitlink_edit_requires_rust(self) -> None: + """A gitlink under docs must not be treated as lightweight documentation.""" + + data = _raw_record("160000", "160000", "M", "docs/vendor") + self.assertEqual(classify_changes(parse_nul_raw_changes(data)), (False, True)) + + def test_raw_executable_blob_requires_rust(self) -> None: + """Executable entries do not belong to the prose-only contract surface.""" + + data = _raw_record("100755", "100755", "M", "docs/generate.md") + self.assertEqual(classify_changes(parse_nul_raw_changes(data)), (False, True)) + + def test_raw_type_change_requires_rust(self) -> None: + """A regular docs blob converted to a symlink must fail closed to Rust.""" + + data = _raw_record("100644", "120000", "T", "docs/guide.md") + self.assertEqual(classify_changes(parse_nul_raw_changes(data)), (False, True)) + + def test_raw_parser_rejects_unterminated_stream(self) -> None: + """Truncated raw evidence must fail before a scope decision is emitted.""" + + with self.assertRaisesRegex(ValueError, "NUL-terminated"): + parse_nul_raw_changes( + b":100644 100644 1111111 2222222 M\0docs/PRD.md" + ) + + def test_raw_parser_rejects_invalid_mode(self) -> None: + """Malformed raw mode metadata cannot be accepted as ordinary prose evidence.""" + + with self.assertRaisesRegex(ValueError, "mode"): + parse_nul_raw_changes( + _raw_record("10064x", "100644", "M", "docs/PRD.md") + ) + + def test_raw_parser_rejects_invalid_utf8_path(self) -> None: + """Ambiguous raw pathname bytes fail before classification.""" + + with self.assertRaisesRegex(ValueError, "valid UTF-8"): + parse_nul_raw_changes( + b":100644 100644 1111111 2222222 M\0docs/ok.md\xff\0" + ) def test_nul_path_parser_preserves_spaces_and_unicode(self) -> None: """Git's NUL framing must preserve valid repository names without shell splitting.""" @@ -93,30 +196,22 @@ def test_nul_path_parser_rejects_unterminated_stream(self) -> None: 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.""" + def test_name_status_parser_preserves_paths_but_is_not_scope_authority(self) -> None: + """Legacy status evidence remains decodable but never proves ordinary blob modes.""" 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)) + self.assertEqual(classify_paths(paths), (False, True)) - def test_code_to_docs_rename_requires_rust(self) -> None: - """A post-image docs path must not hide a code-bearing rename preimage.""" + def test_name_status_parser_preserves_rename_preimage(self) -> None: + """Path preservation remains useful for diagnostics even though modes are absent.""" 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.""" @@ -124,7 +219,7 @@ def test_name_status_parser_rejects_truncated_rename(self) -> None: 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.""" + """Missing terminal NUL must fail closed before classification.""" with self.assertRaisesRegex(ValueError, "NUL-terminated"): parse_nul_name_status(b"M\0docs/PRD.md") @@ -147,12 +242,15 @@ def test_outputs_are_exact_booleans(self) -> None: "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.""" + def test_cli_emits_lightweight_scope_for_mode_aware_docs_raw_diff(self) -> None: + """The executable boundary must require raw modes before skipping Rust.""" completed = subprocess.run( [sys.executable, str(CLASSIFIER)], - input=b"M\0docs/PRD.md\0A\0README.md\0", + input=( + _raw_record("100644", "100644", "M", "docs/PRD.md") + + _raw_record("000000", "100644", "A", "README.md") + ), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, @@ -164,12 +262,12 @@ def test_cli_emits_lightweight_scope_for_nul_delimited_docs_status(self) -> None ) 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.""" + def test_cli_fails_closed_to_rust_for_mode_blind_name_status(self) -> None: + """Status/path-only input cannot prove that a docs entry is an ordinary blob.""" completed = subprocess.run( [sys.executable, str(CLASSIFIER)], - input=b"R100\0crates/demo/src/lib.rs\0docs/lib.md\0", + input=b"M\0docs/PRD.md\0", stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, @@ -212,7 +310,7 @@ def test_cli_fails_closed_on_unterminated_status_stream(self) -> None: 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.""" + """The CI workflow must always run docs contracts and gate Rust jobs by raw scope.""" workflow = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") self.assertIn("name: Repository and documentation contracts", workflow) @@ -222,7 +320,7 @@ def test_workflow_keeps_docs_contracts_separate_from_rust(self) -> None: workflow.count("needs.scope.outputs.rust_required == 'true'"), 2, ) - self.assertIn("git diff --name-status -z", workflow) + self.assertIn("git diff --raw -z", workflow) self.assertIn("classify_ci_change_scope.py", workflow) From 67460f0f0b45603243fce14c7007f1d698cb4e62 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 08:04:29 +0900 Subject: [PATCH 12/40] fix(ci): require raw file modes for docs-only scope --- scripts/ci/classify_ci_change_scope.py | 214 +++++++++++++++++++++---- 1 file changed, 186 insertions(+), 28 deletions(-) diff --git a/scripts/ci/classify_ci_change_scope.py b/scripts/ci/classify_ci_change_scope.py index 7129f2ba5..7fc517d45 100644 --- a/scripts/ci/classify_ci_change_scope.py +++ b/scripts/ci/classify_ci_change_scope.py @@ -2,10 +2,25 @@ from __future__ import annotations +import string import sys +from dataclasses import dataclass from pathlib import PurePosixPath from typing import Iterable +_ABSENT_MODE = "000000" +_PLAIN_DOCUMENT_MODE = "100644" + + +@dataclass(frozen=True, slots=True) +class RawChange: + """One non-combined Git raw-diff record with mode and path authority intact.""" + + source_mode: str + destination_mode: str + status: str + paths: tuple[str, ...] + def _decode_repository_path(raw_path: bytes) -> str: """Decode one Git pathname and enforce the repository-relative path boundary.""" @@ -25,7 +40,7 @@ def _decode_repository_path(raw_path: bytes) -> str: def parse_nul_paths(data: bytes) -> tuple[str, ...]: - """Decode a NUL-delimited Git path stream and reject ambiguous path input.""" + """Decode a NUL-delimited Git path stream without granting scope authority.""" if not data: return () @@ -34,7 +49,6 @@ def parse_nul_paths(data: bytes) -> tuple[str, ...]: raw_paths = data.split(b"\0") raw_paths.pop() - return tuple(_decode_repository_path(raw_path) for raw_path in raw_paths) @@ -47,8 +61,27 @@ def _similarity_status_is_valid(status: str, kind: str) -> bool: return score.isascii() and score.isdigit() and 0 <= int(score) <= 100 +def _status_path_count(status: str) -> int: + """Return the raw-diff pathname cardinality for a supported Git status.""" + + if status in {"A", "D", "M", "T", "U"} or _similarity_status_is_valid( + status, "M" + ): + return 1 + if _similarity_status_is_valid(status, "R") or _similarity_status_is_valid( + status, "C" + ): + return 2 + raise ValueError(f"changed record has unsupported Git status {status!r}") + + def parse_nul_name_status(data: bytes) -> tuple[str, ...]: - """Decode ``git diff --name-status -z`` while preserving rename/copy preimages.""" + """Decode legacy name/status evidence while preserving rename/copy preimages. + + This representation intentionally carries no lightweight-classification authority + because Git file modes are absent. It remains useful for diagnostics and for + failing closed when a producer has not yet migrated to raw-diff evidence. + """ if not data: return () @@ -68,44 +101,162 @@ def parse_nul_name_status(data: bytes) -> tuple[str, ...]: 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): + path_count = _status_path_count(status) + if index + path_count > len(fields): + if path_count == 2: 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("changed status record is missing its path") - raise ValueError(f"changed record has unsupported Git status {status!r}") + paths.extend( + _decode_repository_path(raw_path) + for raw_path in fields[index : index + path_count] + ) + index += path_count return tuple(paths) +def _validate_raw_mode(mode: str) -> None: + """Reject malformed Git modes before they can influence scope classification.""" + + if len(mode) != 6 or any(character not in "01234567" for character in mode): + raise ValueError(f"changed raw record has invalid mode {mode!r}") + + +def _validate_raw_object_id(object_id: str) -> None: + """Reject malformed abbreviated object identifiers in raw-diff metadata.""" + + if not 4 <= len(object_id) <= 64 or any( + character not in string.hexdigits for character in object_id + ): + raise ValueError("changed raw record has an invalid object id") + + +def _validate_raw_mode_semantics(source_mode: str, destination_mode: str, status: str) -> None: + """Reject status/mode combinations that cannot describe a normal two-tree diff.""" + + kind = status[0] + source_exists = source_mode != _ABSENT_MODE + destination_exists = destination_mode != _ABSENT_MODE + + valid = False + if kind == "A": + valid = not source_exists and destination_exists + elif kind == "D": + valid = source_exists and not destination_exists + elif kind == "U": + valid = not source_exists and not destination_exists + elif kind == "T": + valid = source_exists and destination_exists and source_mode != destination_mode + elif kind in {"M", "R", "C"}: + valid = source_exists and destination_exists + + if not valid: + raise ValueError("changed raw record has inconsistent status/mode metadata") + + +def _parse_raw_metadata(raw_metadata: bytes) -> tuple[str, str, str]: + """Decode one canonical non-combined ``git diff --raw -z`` metadata field.""" + + try: + metadata = raw_metadata.decode("ascii") + except UnicodeDecodeError as error: + raise ValueError("changed raw record metadata is not ASCII") from error + + fields = metadata.split(" ") + if len(fields) != 5 or any(not field for field in fields): + raise ValueError("changed raw record metadata has an invalid field layout") + + source_mode_field, destination_mode, source_oid, destination_oid, status = fields + if not source_mode_field.startswith(":") or source_mode_field.startswith("::"): + raise ValueError("combined or malformed raw diff metadata is unsupported") + + source_mode = source_mode_field[1:] + _validate_raw_mode(source_mode) + _validate_raw_mode(destination_mode) + _validate_raw_object_id(source_oid) + _validate_raw_object_id(destination_oid) + _status_path_count(status) + _validate_raw_mode_semantics(source_mode, destination_mode, status) + return source_mode, destination_mode, status + + +def parse_nul_raw_changes(data: bytes) -> tuple[RawChange, ...]: + """Decode ``git diff --raw -z`` without discarding modes or rename preimages.""" + + if not data: + return () + if not data.endswith(b"\0"): + raise ValueError("changed raw stream must be NUL-terminated") + + fields = data.split(b"\0") + fields.pop() + + changes: list[RawChange] = [] + index = 0 + while index < len(fields): + source_mode, destination_mode, status = _parse_raw_metadata(fields[index]) + index += 1 + path_count = _status_path_count(status) + if index + path_count > len(fields): + if path_count == 2: + raise ValueError("raw rename/copy record must include both paths") + raise ValueError("changed raw record is missing its path") + + paths = tuple( + _decode_repository_path(raw_path) + for raw_path in fields[index : index + path_count] + ) + index += path_count + changes.append( + RawChange( + source_mode=source_mode, + destination_mode=destination_mode, + status=status, + paths=paths, + ) + ) + + return tuple(changes) + + def is_documentation_path(path: str) -> bool: - """Return whether a repository path belongs to the prose-only contract surface.""" + """Return whether a repository path belongs to the reviewed documentation 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.""" + """Fail closed for path-only evidence because file modes are not represented.""" + + tuple(paths) + return False, True + - materialized = tuple(paths) +def _change_is_plain_documentation(change: RawChange) -> bool: + """Return whether one raw change is proven to affect ordinary documentation blobs.""" + + if change.status.startswith(("T", "U")): + return False + if any(not is_documentation_path(path) for path in change.paths): + return False + + return all( + mode in {_ABSENT_MODE, _PLAIN_DOCUMENT_MODE} + for mode in (change.source_mode, change.destination_mode) + ) + + +def classify_changes(changes: Iterable[RawChange]) -> tuple[bool, bool]: + """Return ``(documentation_only, rust_required)`` from mode-aware raw evidence.""" + + materialized = tuple(changes) if not materialized: return False, True - documentation_only = all(is_documentation_path(path) for path in materialized) + documentation_only = all( + _change_is_plain_documentation(change) for change in materialized + ) return documentation_only, not documentation_only @@ -119,15 +270,22 @@ def render_outputs(documentation_only: bool, rust_required: bool) -> str: def main() -> int: - """Read status-aware NUL-delimited changes and emit fail-closed CI scope outputs.""" + """Read exact Git diff evidence and emit fail-closed CI scope outputs.""" + data = sys.stdin.buffer.read() try: - paths = parse_nul_name_status(sys.stdin.buffer.read()) + if data.startswith(b":"): + documentation_only, rust_required = classify_changes( + parse_nul_raw_changes(data) + ) + else: + documentation_only, rust_required = classify_paths( + parse_nul_name_status(data) + ) 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 From ccbf987dd10b50ffafd8a0766917afaaeca1aed7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 09:02:05 +0900 Subject: [PATCH 13/40] test(ci): reject impossible raw object identities --- tests/test_ci_change_scope_object_ids.py | 39 ++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/test_ci_change_scope_object_ids.py diff --git a/tests/test_ci_change_scope_object_ids.py b/tests/test_ci_change_scope_object_ids.py new file mode 100644 index 000000000..1ebc73def --- /dev/null +++ b/tests/test_ci_change_scope_object_ids.py @@ -0,0 +1,39 @@ +"""Security regressions for Git raw-diff mode/object identity coupling.""" + +from __future__ import annotations + +import unittest + +from scripts.ci.classify_ci_change_scope import parse_nul_raw_changes + + +class CiChangeScopeObjectIdTests(unittest.TestCase): + """Reject raw records that could not come from the intended two-tree Git diff.""" + + def test_addition_rejects_nonzero_source_object_id(self) -> None: + """An absent addition preimage must carry Git's all-zero object identity.""" + + with self.assertRaisesRegex(ValueError, "object id"): + parse_nul_raw_changes( + b":000000 100644 1111111 2222222 A\0README.md\0" + ) + + def test_deletion_rejects_nonzero_destination_object_id(self) -> None: + """An absent deletion postimage must carry Git's all-zero object identity.""" + + with self.assertRaisesRegex(ValueError, "object id"): + parse_nul_raw_changes( + b":100644 000000 1111111 2222222 D\0docs/old.md\0" + ) + + def test_materialized_side_rejects_zero_object_id(self) -> None: + """A present two-tree side must not use the absence sentinel object identity.""" + + with self.assertRaisesRegex(ValueError, "object id"): + parse_nul_raw_changes( + b":100644 100644 0000000 2222222 M\0docs/PRD.md\0" + ) + + +if __name__ == "__main__": + unittest.main() From 75d1432e8a0d2f83ed8780bc0a7f2300cee7bdc8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 09:02:57 +0900 Subject: [PATCH 14/40] fix(ci): validate raw mode and object identity coupling --- scripts/ci/classify_ci_change_scope.py | 30 ++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/scripts/ci/classify_ci_change_scope.py b/scripts/ci/classify_ci_change_scope.py index 7fc517d45..78ac795e5 100644 --- a/scripts/ci/classify_ci_change_scope.py +++ b/scripts/ci/classify_ci_change_scope.py @@ -2,7 +2,6 @@ from __future__ import annotations -import string import sys from dataclasses import dataclass from pathlib import PurePosixPath @@ -127,11 +126,32 @@ def _validate_raw_object_id(object_id: str) -> None: """Reject malformed abbreviated object identifiers in raw-diff metadata.""" if not 4 <= len(object_id) <= 64 or any( - character not in string.hexdigits for character in object_id + character not in "0123456789abcdef" for character in object_id ): raise ValueError("changed raw record has an invalid object id") +def _validate_raw_object_identity_semantics( + source_mode: str, + destination_mode: str, + source_oid: str, + destination_oid: str, +) -> None: + """Require raw object identities to agree with two-tree presence metadata.""" + + if len(source_oid) != len(destination_oid): + raise ValueError("changed raw record object ids have inconsistent widths") + + for mode, object_id in ( + (source_mode, source_oid), + (destination_mode, destination_oid), + ): + side_absent = mode == _ABSENT_MODE + identity_absent = not object_id.strip("0") + if side_absent != identity_absent: + raise ValueError("changed raw record has inconsistent mode/object id metadata") + + def _validate_raw_mode_semantics(source_mode: str, destination_mode: str, status: str) -> None: """Reject status/mode combinations that cannot describe a normal two-tree diff.""" @@ -178,6 +198,12 @@ def _parse_raw_metadata(raw_metadata: bytes) -> tuple[str, str, str]: _validate_raw_object_id(destination_oid) _status_path_count(status) _validate_raw_mode_semantics(source_mode, destination_mode, status) + _validate_raw_object_identity_semantics( + source_mode, + destination_mode, + source_oid, + destination_oid, + ) return source_mode, destination_mode, status From 49f94d4cef45cf861e2de73abf3ab7c39d265211 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:03:00 +0900 Subject: [PATCH 15/40] test(ci): align raw-diff fixtures with object identity invariants --- tests/test_ci_change_scope.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_ci_change_scope.py b/tests/test_ci_change_scope.py index 686d17fd0..0e4e859fd 100644 --- a/tests/test_ci_change_scope.py +++ b/tests/test_ci_change_scope.py @@ -29,8 +29,10 @@ def _raw_record( ) -> bytes: """Build one deterministic NUL-framed raw-diff record for focused tests.""" + source_oid = "0000000" if source_mode == "000000" else "1111111" + destination_oid = "0000000" if destination_mode == "000000" else "2222222" metadata = ( - f":{source_mode} {destination_mode} 1111111 2222222 {status}\0" + f":{source_mode} {destination_mode} {source_oid} {destination_oid} {status}\0" ).encode() return metadata + b"\0".join(path.encode() for path in paths) + b"\0" From 4f604fe55f7a63c30f637c7a2c91060d5638f213 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 11:00:32 +0900 Subject: [PATCH 16/40] test(ci): reject non-prose blobs under docs from lightweight scope --- tests/test_ci_change_scope_prose_boundary.py | 55 ++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 tests/test_ci_change_scope_prose_boundary.py diff --git a/tests/test_ci_change_scope_prose_boundary.py b/tests/test_ci_change_scope_prose_boundary.py new file mode 100644 index 000000000..703fdf52c --- /dev/null +++ b/tests/test_ci_change_scope_prose_boundary.py @@ -0,0 +1,55 @@ +"""Regression tests for the reviewed prose-only CI surface.""" + +from __future__ import annotations + +import unittest + +from scripts.ci.classify_ci_change_scope import ( + classify_changes, + is_documentation_path, + parse_nul_raw_changes, +) + + +def _raw_record(path: str) -> bytes: + """Build one regular-blob modification for an exact repository path.""" + + return ( + b":100644 100644 1111111 2222222 M\0" + + path.encode("utf-8") + + b"\0" + ) + + +class CiChangeScopeProseBoundaryTests(unittest.TestCase): + """Do not let the docs directory become a generic code-bearing skip surface.""" + + def test_non_prose_regular_blobs_under_docs_require_rust(self) -> None: + """A docs/ prefix alone must not authorize skipping code-oriented gates.""" + + for path in ( + "docs/browser_fixture.js", + "docs/generate.py", + "docs/runtime.rs", + "docs/package.json", + ): + with self.subTest(path=path): + self.assertFalse(is_documentation_path(path)) + self.assertEqual( + classify_changes(parse_nul_raw_changes(_raw_record(path))), + (False, True), + ) + + def test_markdown_under_docs_remains_lightweight(self) -> None: + """The existing reviewed Markdown documentation surface remains eligible.""" + + path = "docs/doctoring/browser-policy.md" + self.assertTrue(is_documentation_path(path)) + self.assertEqual( + classify_changes(parse_nul_raw_changes(_raw_record(path))), + (True, False), + ) + + +if __name__ == "__main__": + unittest.main() From 7c87bc30b38572eb392f9317e91eb8cfce5e69dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 11:01:05 +0900 Subject: [PATCH 17/40] fix(ci): keep non-prose docs artifacts on Rust-heavy path --- scripts/ci/classify_ci_change_scope.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/ci/classify_ci_change_scope.py b/scripts/ci/classify_ci_change_scope.py index 78ac795e5..1a273ac64 100644 --- a/scripts/ci/classify_ci_change_scope.py +++ b/scripts/ci/classify_ci_change_scope.py @@ -247,9 +247,9 @@ def parse_nul_raw_changes(data: bytes) -> tuple[RawChange, ...]: def is_documentation_path(path: str) -> bool: - """Return whether a repository path belongs to the reviewed documentation surface.""" + """Return whether a repository path belongs to the reviewed Markdown prose surface.""" - return path.startswith("docs/") or ("/" not in path and path.endswith(".md")) + return path.endswith(".md") and (path.startswith("docs/") or "/" not in path) def classify_paths(paths: Iterable[str]) -> tuple[bool, bool]: From c01c703c911b1d36dde3577d7f96db71f20fe969 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 12:07:09 +0900 Subject: [PATCH 18/40] test(ci): fail closed on agent instruction control plane --- tests/test_ci_change_scope_prose_boundary.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_ci_change_scope_prose_boundary.py b/tests/test_ci_change_scope_prose_boundary.py index 703fdf52c..4000af6b5 100644 --- a/tests/test_ci_change_scope_prose_boundary.py +++ b/tests/test_ci_change_scope_prose_boundary.py @@ -40,6 +40,17 @@ def test_non_prose_regular_blobs_under_docs_require_rust(self) -> None: (False, True), ) + def test_agent_instruction_control_plane_requires_rust(self) -> None: + """Contributor authority documents must never enter the lightweight prose lane.""" + + for path in ("AGENTS.md", "CLAUDE.md"): + with self.subTest(path=path): + self.assertFalse(is_documentation_path(path)) + self.assertEqual( + classify_changes(parse_nul_raw_changes(_raw_record(path))), + (False, True), + ) + def test_markdown_under_docs_remains_lightweight(self) -> None: """The existing reviewed Markdown documentation surface remains eligible.""" From dc429da3318cdc14740317bed702497fe62dd562 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 12:09:11 +0900 Subject: [PATCH 19/40] fix(ci): keep agent instruction authority on full gate --- scripts/ci/classify_ci_change_scope.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/ci/classify_ci_change_scope.py b/scripts/ci/classify_ci_change_scope.py index 1a273ac64..af513edfa 100644 --- a/scripts/ci/classify_ci_change_scope.py +++ b/scripts/ci/classify_ci_change_scope.py @@ -9,6 +9,7 @@ _ABSENT_MODE = "000000" _PLAIN_DOCUMENT_MODE = "100644" +_AGENT_INSTRUCTION_AUTHORITY = frozenset({"AGENTS.md", "CLAUDE.md"}) @dataclass(frozen=True, slots=True) @@ -247,8 +248,10 @@ def parse_nul_raw_changes(data: bytes) -> tuple[RawChange, ...]: def is_documentation_path(path: str) -> bool: - """Return whether a repository path belongs to the reviewed Markdown prose surface.""" + """Return whether a path belongs to prose that may use lightweight CI.""" + if path in _AGENT_INSTRUCTION_AUTHORITY: + return False return path.endswith(".md") and (path.startswith("docs/") or "/" not in path) From 5860cdff17b81c33656dba9f2ad51cb146aa60e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 12:59:14 +0900 Subject: [PATCH 20/40] test(ci): reject nested agent instruction authority from prose lane --- tests/test_ci_change_scope_prose_boundary.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_ci_change_scope_prose_boundary.py b/tests/test_ci_change_scope_prose_boundary.py index 4000af6b5..9be269a7e 100644 --- a/tests/test_ci_change_scope_prose_boundary.py +++ b/tests/test_ci_change_scope_prose_boundary.py @@ -43,7 +43,14 @@ def test_non_prose_regular_blobs_under_docs_require_rust(self) -> None: def test_agent_instruction_control_plane_requires_rust(self) -> None: """Contributor authority documents must never enter the lightweight prose lane.""" - for path in ("AGENTS.md", "CLAUDE.md"): + for path in ( + "AGENTS.md", + "CLAUDE.md", + "docs/AGENTS.md", + "docs/CLAUDE.md", + "docs/doctoring/AGENTS.md", + "docs/doctoring/CLAUDE.md", + ): with self.subTest(path=path): self.assertFalse(is_documentation_path(path)) self.assertEqual( From 5e88f9c5d008dcaf5d010ab6147605ef83036488 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 12:59:37 +0900 Subject: [PATCH 21/40] fix(ci): keep nested agent instruction files on full Rust path --- scripts/ci/classify_ci_change_scope.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/ci/classify_ci_change_scope.py b/scripts/ci/classify_ci_change_scope.py index af513edfa..fa4b81672 100644 --- a/scripts/ci/classify_ci_change_scope.py +++ b/scripts/ci/classify_ci_change_scope.py @@ -9,7 +9,7 @@ _ABSENT_MODE = "000000" _PLAIN_DOCUMENT_MODE = "100644" -_AGENT_INSTRUCTION_AUTHORITY = frozenset({"AGENTS.md", "CLAUDE.md"}) +_AGENT_INSTRUCTION_FILENAMES = frozenset({"AGENTS.md", "CLAUDE.md"}) @dataclass(frozen=True, slots=True) @@ -250,7 +250,7 @@ def parse_nul_raw_changes(data: bytes) -> tuple[RawChange, ...]: def is_documentation_path(path: str) -> bool: """Return whether a path belongs to prose that may use lightweight CI.""" - if path in _AGENT_INSTRUCTION_AUTHORITY: + if PurePosixPath(path).name in _AGENT_INSTRUCTION_FILENAMES: return False return path.endswith(".md") and (path.startswith("docs/") or "/" not in path) From b1e2b8ac8cdd57a906dcefc182cdb17050e8cd4c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 14:03:13 +0900 Subject: [PATCH 22/40] test(ci): reject unchanged blob modification metadata --- tests/test_ci_change_scope_object_ids.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_ci_change_scope_object_ids.py b/tests/test_ci_change_scope_object_ids.py index 1ebc73def..b6df8fc6d 100644 --- a/tests/test_ci_change_scope_object_ids.py +++ b/tests/test_ci_change_scope_object_ids.py @@ -34,6 +34,14 @@ def test_materialized_side_rejects_zero_object_id(self) -> None: b":100644 100644 0000000 2222222 M\0docs/PRD.md\0" ) + def test_same_mode_modification_rejects_identical_object_ids(self) -> None: + """A same-mode two-tree modification must identify two different blobs.""" + + with self.assertRaisesRegex(ValueError, "object id"): + parse_nul_raw_changes( + b":100644 100644 1111111 1111111 M\0docs/PRD.md\0" + ) + if __name__ == "__main__": unittest.main() From b2bd01b90b176c340b44aeebb9a55383570fa323 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 14:03:53 +0900 Subject: [PATCH 23/40] fix(ci): reject impossible unchanged modification records --- scripts/ci/classify_ci_change_scope.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/ci/classify_ci_change_scope.py b/scripts/ci/classify_ci_change_scope.py index fa4b81672..4d6b6b394 100644 --- a/scripts/ci/classify_ci_change_scope.py +++ b/scripts/ci/classify_ci_change_scope.py @@ -137,6 +137,7 @@ def _validate_raw_object_identity_semantics( destination_mode: str, source_oid: str, destination_oid: str, + status: str, ) -> None: """Require raw object identities to agree with two-tree presence metadata.""" @@ -152,6 +153,13 @@ def _validate_raw_object_identity_semantics( if side_absent != identity_absent: raise ValueError("changed raw record has inconsistent mode/object id metadata") + if ( + status.startswith("M") + and source_mode == destination_mode + and source_oid == destination_oid + ): + raise ValueError("changed raw record modification has identical object ids") + def _validate_raw_mode_semantics(source_mode: str, destination_mode: str, status: str) -> None: """Reject status/mode combinations that cannot describe a normal two-tree diff.""" @@ -204,6 +212,7 @@ def _parse_raw_metadata(raw_metadata: bytes) -> tuple[str, str, str]: destination_mode, source_oid, destination_oid, + status, ) return source_mode, destination_mode, status From 819db4c35b72b5f6afffa544c4425cd42e2c4c9a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 15:05:50 +0900 Subject: [PATCH 24/40] test(ci): reject noncanonical raw diff paths --- tests/test_ci_change_scope_prose_boundary.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_ci_change_scope_prose_boundary.py b/tests/test_ci_change_scope_prose_boundary.py index 9be269a7e..18afae233 100644 --- a/tests/test_ci_change_scope_prose_boundary.py +++ b/tests/test_ci_change_scope_prose_boundary.py @@ -68,6 +68,18 @@ def test_markdown_under_docs_remains_lightweight(self) -> None: (True, False), ) + def test_noncanonical_docs_paths_fail_before_scope_classification(self) -> None: + """Tree-diff evidence must use Git's canonical repository-relative path spelling.""" + + for path in ( + "docs//browser-policy.md", + "docs/./browser-policy.md", + "docs/doctoring//browser-policy.md", + ): + with self.subTest(path=path): + with self.assertRaisesRegex(ValueError, "canonical repository path"): + parse_nul_raw_changes(_raw_record(path)) + if __name__ == "__main__": unittest.main() From 2a6b6221016babee2573f0aee229af21f15f8d97 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 15:07:26 +0900 Subject: [PATCH 25/40] fix(ci): reject noncanonical raw diff paths --- scripts/ci/classify_ci_change_scope.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/ci/classify_ci_change_scope.py b/scripts/ci/classify_ci_change_scope.py index 4d6b6b394..75a43eda5 100644 --- a/scripts/ci/classify_ci_change_scope.py +++ b/scripts/ci/classify_ci_change_scope.py @@ -33,7 +33,11 @@ def _decode_repository_path(raw_path: bytes) -> str: if not path or path.startswith("/"): raise ValueError("changed path must be a non-empty repository-relative path") - parts = PurePosixPath(path).parts + parsed_path = PurePosixPath(path) + if path == "." or parsed_path.as_posix() != path: + raise ValueError("changed path must use a canonical repository path") + + parts = parsed_path.parts if ".." in parts: raise ValueError("changed path must not contain parent traversal") return path From 6a846168163e7aaed347a374a34bdc682b5ce71b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 15:15:46 +0900 Subject: [PATCH 26/40] test(ci): keep Gemini instructions on full-Rust lane --- tests/test_ci_change_scope_prose_boundary.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_ci_change_scope_prose_boundary.py b/tests/test_ci_change_scope_prose_boundary.py index 18afae233..813aba836 100644 --- a/tests/test_ci_change_scope_prose_boundary.py +++ b/tests/test_ci_change_scope_prose_boundary.py @@ -46,10 +46,27 @@ def test_agent_instruction_control_plane_requires_rust(self) -> None: for path in ( "AGENTS.md", "CLAUDE.md", + "GEMINI.md", "docs/AGENTS.md", "docs/CLAUDE.md", + "docs/GEMINI.md", "docs/doctoring/AGENTS.md", "docs/doctoring/CLAUDE.md", + "docs/doctoring/GEMINI.md", + ): + with self.subTest(path=path): + self.assertFalse(is_documentation_path(path)) + self.assertEqual( + classify_changes(parse_nul_raw_changes(_raw_record(path))), + (False, True), + ) + + def test_github_copilot_instruction_surfaces_require_rust(self) -> None: + """GitHub Copilot instruction paths are control-plane Markdown, not prose-only docs.""" + + for path in ( + ".github/copilot-instructions.md", + ".github/instructions/browser.instructions.md", ): with self.subTest(path=path): self.assertFalse(is_documentation_path(path)) From bd7046f1dbec5e26f49e68c624ebb911c4c8e674 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 15:16:34 +0900 Subject: [PATCH 27/40] fix(ci): treat Gemini instructions as control plane --- scripts/ci/classify_ci_change_scope.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/classify_ci_change_scope.py b/scripts/ci/classify_ci_change_scope.py index 75a43eda5..4d4799216 100644 --- a/scripts/ci/classify_ci_change_scope.py +++ b/scripts/ci/classify_ci_change_scope.py @@ -9,7 +9,7 @@ _ABSENT_MODE = "000000" _PLAIN_DOCUMENT_MODE = "100644" -_AGENT_INSTRUCTION_FILENAMES = frozenset({"AGENTS.md", "CLAUDE.md"}) +_AGENT_INSTRUCTION_FILENAMES = frozenset({"AGENTS.md", "CLAUDE.md", "GEMINI.md"}) @dataclass(frozen=True, slots=True) From f0d8a5696c8f9dc901584d81ebf3d1c4dfa77301 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 15:31:52 +0900 Subject: [PATCH 28/40] test(ci): reject noncanonical raw similarity scores --- .../test_ci_change_scope_similarity_scores.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 tests/test_ci_change_scope_similarity_scores.py diff --git a/tests/test_ci_change_scope_similarity_scores.py b/tests/test_ci_change_scope_similarity_scores.py new file mode 100644 index 000000000..da462e870 --- /dev/null +++ b/tests/test_ci_change_scope_similarity_scores.py @@ -0,0 +1,44 @@ +"""Regression tests for canonical Git raw-diff similarity score spelling.""" + +from __future__ import annotations + +import unittest + +from scripts.ci.classify_ci_change_scope import classify_changes, parse_nul_raw_changes + + +def _raw_record(status: str) -> bytes: + """Build a docs-to-doc rename record with a caller-controlled score suffix.""" + + return ( + f":100644 100644 1111111 2222222 {status}\0" + "docs/old.md\0docs/new.md\0" + ).encode() + + +class CiChangeScopeSimilarityScoreTests(unittest.TestCase): + """Only score spellings emitted by Git may influence lightweight CI authority.""" + + def test_canonical_three_digit_similarity_score_is_accepted(self) -> None: + """Git raw output zero-pads scored statuses to three decimal digits.""" + + changes = parse_nul_raw_changes(_raw_record("R074")) + self.assertEqual(classify_changes(changes), (True, False)) + + def test_noncanonical_similarity_score_widths_are_rejected(self) -> None: + """Malformed producer text must fail closed instead of authorizing docs-only CI.""" + + for status in ("R74", "R0074", "C5", "M5", "M0000"): + with self.subTest(status=status): + with self.assertRaisesRegex(ValueError, "status"): + parse_nul_raw_changes(_raw_record(status)) + + def test_out_of_range_three_digit_score_is_rejected(self) -> None: + """Three digits are necessary but a percentage above 100 is still invalid.""" + + with self.assertRaisesRegex(ValueError, "status"): + parse_nul_raw_changes(_raw_record("R101")) + + +if __name__ == "__main__": + unittest.main() From b947dcdd0a652217425e255f27ed2b42baeba293 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 15:32:37 +0900 Subject: [PATCH 29/40] fix(ci): require canonical raw similarity score spelling --- scripts/ci/classify_ci_change_scope.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/scripts/ci/classify_ci_change_scope.py b/scripts/ci/classify_ci_change_scope.py index 4d4799216..7bd639baf 100644 --- a/scripts/ci/classify_ci_change_scope.py +++ b/scripts/ci/classify_ci_change_scope.py @@ -57,12 +57,17 @@ def parse_nul_paths(data: bytes) -> tuple[str, ...]: def _similarity_status_is_valid(status: str, kind: str) -> bool: - """Return whether a scored Git status has a canonical 0..100 similarity value.""" + """Return whether a scored Git status has canonical three-digit percent spelling.""" - if not status.startswith(kind) or len(status) == 1: + if not status.startswith(kind): return False score = status[1:] - return score.isascii() and score.isdigit() and 0 <= int(score) <= 100 + return ( + len(score) == 3 + and score.isascii() + and score.isdigit() + and 0 <= int(score) <= 100 + ) def _status_path_count(status: str) -> int: From 24ff89d833fe8cc7deb711036e7bd62671232866 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 16:03:32 +0900 Subject: [PATCH 30/40] test(ci): reject identical rename/copy paths --- tests/test_ci_change_scope_path_identity.py | 42 +++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/test_ci_change_scope_path_identity.py diff --git a/tests/test_ci_change_scope_path_identity.py b/tests/test_ci_change_scope_path_identity.py new file mode 100644 index 000000000..31b61293c --- /dev/null +++ b/tests/test_ci_change_scope_path_identity.py @@ -0,0 +1,42 @@ +"""Security regressions for Git raw-diff rename/copy path identity.""" + +from __future__ import annotations + +import unittest + +from scripts.ci.classify_ci_change_scope import classify_changes, parse_nul_raw_changes + + +def _raw_record(status: str, source_path: str, destination_path: str) -> bytes: + """Build one canonical scored rename/copy record with caller-controlled paths.""" + + return ( + f":100644 100644 1111111 2222222 {status}\0" + f"{source_path}\0{destination_path}\0" + ).encode() + + +class CiChangeScopePathIdentityTests(unittest.TestCase): + """Reject rename/copy evidence that cannot describe two distinct repository entries.""" + + def test_distinct_docs_rename_remains_lightweight_eligible(self) -> None: + """A normal docs-to-doc rename preserves both distinct path identities.""" + + changes = parse_nul_raw_changes( + _raw_record("R100", "docs/old.md", "docs/new.md") + ) + self.assertEqual(classify_changes(changes), (True, False)) + + def test_rename_or_copy_with_identical_paths_is_rejected(self) -> None: + """Malformed R/C records must not authorize the prose-only CI lane.""" + + for status in ("R100", "C100"): + with self.subTest(status=status): + with self.assertRaisesRegex(ValueError, "path"): + parse_nul_raw_changes( + _raw_record(status, "docs/PRD.md", "docs/PRD.md") + ) + + +if __name__ == "__main__": + unittest.main() From 06d04b786fc470f821cf2e91c6546cb53d488783 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 16:04:51 +0900 Subject: [PATCH 31/40] fix(ci): reject identical rename/copy paths --- scripts/ci/classify_ci_change_scope.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/ci/classify_ci_change_scope.py b/scripts/ci/classify_ci_change_scope.py index 7bd639baf..622d22a16 100644 --- a/scripts/ci/classify_ci_change_scope.py +++ b/scripts/ci/classify_ci_change_scope.py @@ -253,6 +253,8 @@ def parse_nul_raw_changes(data: bytes) -> tuple[RawChange, ...]: for raw_path in fields[index : index + path_count] ) index += path_count + if path_count == 2 and paths[0] == paths[1]: + raise ValueError("raw rename/copy record must include distinct paths") changes.append( RawChange( source_mode=source_mode, From c157cd177068a03cd5657419bb5546d6a28078e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 21:31:28 +0900 Subject: [PATCH 32/40] fix(ci): require complete raw-diff object IDs Signed-off-by: Seongho Bae --- CHANGELOG.md | 4 ++- scripts/ci/classify_ci_change_scope.py | 4 +-- tests/test_ci_change_scope.py | 18 ++++++++--- tests/test_ci_change_scope_object_ids.py | 31 ++++++++++++++++--- tests/test_ci_change_scope_path_identity.py | 2 +- tests/test_ci_change_scope_prose_boundary.py | 2 +- .../test_ci_change_scope_similarity_scores.py | 2 +- 7 files changed, 48 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f747adeae..f5fcf87cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +- Require complete SHA-1 or SHA-256 object identities before exact-head Git raw-diff evidence may authorize lightweight documentation CI. + - Refreshed the product-gap queue to 126 open pull requests (54 ready, 72 draft) after #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 were merged into their immediate stacked prerequisites. PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review; these are queue-consolidation results, not protected-main shipment. ### Added @@ -102,4 +104,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD diff --git a/scripts/ci/classify_ci_change_scope.py b/scripts/ci/classify_ci_change_scope.py index 622d22a16..233d40606 100644 --- a/scripts/ci/classify_ci_change_scope.py +++ b/scripts/ci/classify_ci_change_scope.py @@ -133,9 +133,9 @@ def _validate_raw_mode(mode: str) -> None: def _validate_raw_object_id(object_id: str) -> None: - """Reject malformed abbreviated object identifiers in raw-diff metadata.""" + """Reject malformed or abbreviated object identifiers in raw-diff metadata.""" - if not 4 <= len(object_id) <= 64 or any( + if len(object_id) not in {40, 64} or any( character not in "0123456789abcdef" for character in object_id ): raise ValueError("changed raw record has an invalid object id") diff --git a/tests/test_ci_change_scope.py b/tests/test_ci_change_scope.py index 0e4e859fd..54d6adae9 100644 --- a/tests/test_ci_change_scope.py +++ b/tests/test_ci_change_scope.py @@ -29,8 +29,8 @@ def _raw_record( ) -> bytes: """Build one deterministic NUL-framed raw-diff record for focused tests.""" - source_oid = "0000000" if source_mode == "000000" else "1111111" - destination_oid = "0000000" if destination_mode == "000000" else "2222222" + source_oid = "0" * 40 if source_mode == "000000" else "1" * 40 + destination_oid = "0" * 40 if destination_mode == "000000" else "2" * 40 metadata = ( f":{source_mode} {destination_mode} {source_oid} {destination_oid} {status}\0" ).encode() @@ -149,7 +149,11 @@ def test_raw_parser_rejects_unterminated_stream(self) -> None: with self.assertRaisesRegex(ValueError, "NUL-terminated"): parse_nul_raw_changes( - b":100644 100644 1111111 2222222 M\0docs/PRD.md" + b":100644 100644 " + + b"1" * 40 + + b" " + + b"2" * 40 + + b" M\0docs/PRD.md" ) def test_raw_parser_rejects_invalid_mode(self) -> None: @@ -165,7 +169,11 @@ def test_raw_parser_rejects_invalid_utf8_path(self) -> None: with self.assertRaisesRegex(ValueError, "valid UTF-8"): parse_nul_raw_changes( - b":100644 100644 1111111 2222222 M\0docs/ok.md\xff\0" + b":100644 100644 " + + b"1" * 40 + + b" " + + b"2" * 40 + + b" M\0docs/ok.md\xff\0" ) def test_nul_path_parser_preserves_spaces_and_unicode(self) -> None: @@ -322,7 +330,7 @@ def test_workflow_keeps_docs_contracts_separate_from_rust(self) -> None: workflow.count("needs.scope.outputs.rust_required == 'true'"), 2, ) - self.assertIn("git diff --raw -z", workflow) + self.assertIn("git diff --raw -z --no-abbrev", workflow) self.assertIn("classify_ci_change_scope.py", workflow) diff --git a/tests/test_ci_change_scope_object_ids.py b/tests/test_ci_change_scope_object_ids.py index b6df8fc6d..cd554ffee 100644 --- a/tests/test_ci_change_scope_object_ids.py +++ b/tests/test_ci_change_scope_object_ids.py @@ -6,16 +6,39 @@ from scripts.ci.classify_ci_change_scope import parse_nul_raw_changes +ZERO = "0" * 40 +ONE = "1" * 40 +TWO = "2" * 40 + class CiChangeScopeObjectIdTests(unittest.TestCase): """Reject raw records that could not come from the intended two-tree Git diff.""" + def test_abbreviated_object_ids_are_rejected(self) -> None: + """Lightweight authority requires complete SHA-1 or SHA-256 identities.""" + + with self.assertRaisesRegex(ValueError, "object id"): + parse_nul_raw_changes( + b":100644 100644 1111111 2222222 M\0docs/PRD.md\0" + ) + + def test_complete_sha256_object_ids_are_accepted(self) -> None: + """Repositories using SHA-256 retain complete-object compatibility.""" + + changes = parse_nul_raw_changes( + ( + f":100644 100644 {'1' * 64} {'2' * 64} M\0" + "docs/PRD.md\0" + ).encode() + ) + self.assertEqual(len(changes), 1) + def test_addition_rejects_nonzero_source_object_id(self) -> None: """An absent addition preimage must carry Git's all-zero object identity.""" with self.assertRaisesRegex(ValueError, "object id"): parse_nul_raw_changes( - b":000000 100644 1111111 2222222 A\0README.md\0" + f":000000 100644 {ONE} {TWO} A\0README.md\0".encode() ) def test_deletion_rejects_nonzero_destination_object_id(self) -> None: @@ -23,7 +46,7 @@ def test_deletion_rejects_nonzero_destination_object_id(self) -> None: with self.assertRaisesRegex(ValueError, "object id"): parse_nul_raw_changes( - b":100644 000000 1111111 2222222 D\0docs/old.md\0" + f":100644 000000 {ONE} {TWO} D\0docs/old.md\0".encode() ) def test_materialized_side_rejects_zero_object_id(self) -> None: @@ -31,7 +54,7 @@ def test_materialized_side_rejects_zero_object_id(self) -> None: with self.assertRaisesRegex(ValueError, "object id"): parse_nul_raw_changes( - b":100644 100644 0000000 2222222 M\0docs/PRD.md\0" + f":100644 100644 {ZERO} {TWO} M\0docs/PRD.md\0".encode() ) def test_same_mode_modification_rejects_identical_object_ids(self) -> None: @@ -39,7 +62,7 @@ def test_same_mode_modification_rejects_identical_object_ids(self) -> None: with self.assertRaisesRegex(ValueError, "object id"): parse_nul_raw_changes( - b":100644 100644 1111111 1111111 M\0docs/PRD.md\0" + f":100644 100644 {ONE} {ONE} M\0docs/PRD.md\0".encode() ) diff --git a/tests/test_ci_change_scope_path_identity.py b/tests/test_ci_change_scope_path_identity.py index 31b61293c..be8974db4 100644 --- a/tests/test_ci_change_scope_path_identity.py +++ b/tests/test_ci_change_scope_path_identity.py @@ -11,7 +11,7 @@ def _raw_record(status: str, source_path: str, destination_path: str) -> bytes: """Build one canonical scored rename/copy record with caller-controlled paths.""" return ( - f":100644 100644 1111111 2222222 {status}\0" + f":100644 100644 {'1' * 40} {'2' * 40} {status}\0" f"{source_path}\0{destination_path}\0" ).encode() diff --git a/tests/test_ci_change_scope_prose_boundary.py b/tests/test_ci_change_scope_prose_boundary.py index 813aba836..0ce7e77cd 100644 --- a/tests/test_ci_change_scope_prose_boundary.py +++ b/tests/test_ci_change_scope_prose_boundary.py @@ -15,7 +15,7 @@ def _raw_record(path: str) -> bytes: """Build one regular-blob modification for an exact repository path.""" return ( - b":100644 100644 1111111 2222222 M\0" + b":100644 100644 " + b"1" * 40 + b" " + b"2" * 40 + b" M\0" + path.encode("utf-8") + b"\0" ) diff --git a/tests/test_ci_change_scope_similarity_scores.py b/tests/test_ci_change_scope_similarity_scores.py index da462e870..1e4c69e20 100644 --- a/tests/test_ci_change_scope_similarity_scores.py +++ b/tests/test_ci_change_scope_similarity_scores.py @@ -11,7 +11,7 @@ def _raw_record(status: str) -> bytes: """Build a docs-to-doc rename record with a caller-controlled score suffix.""" return ( - f":100644 100644 1111111 2222222 {status}\0" + f":100644 100644 {'1' * 40} {'2' * 40} {status}\0" "docs/old.md\0docs/new.md\0" ).encode() From b2a120f892973e76c0ea0f06e7105bdf7a268009 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 21:38:00 +0900 Subject: [PATCH 33/40] test(ci): reject impossible rename similarity identities --- .../test_ci_change_scope_similarity_scores.py | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/tests/test_ci_change_scope_similarity_scores.py b/tests/test_ci_change_scope_similarity_scores.py index 1e4c69e20..9127d96cc 100644 --- a/tests/test_ci_change_scope_similarity_scores.py +++ b/tests/test_ci_change_scope_similarity_scores.py @@ -7,17 +7,24 @@ from scripts.ci.classify_ci_change_scope import classify_changes, parse_nul_raw_changes -def _raw_record(status: str) -> bytes: - """Build a docs-to-doc rename record with a caller-controlled score suffix.""" - +def _raw_record( + status: str, + *, + source_oid: str | None = None, + destination_oid: str | None = None, +) -> bytes: + """Build a docs-to-doc rename/copy record with caller-controlled identity evidence.""" + + source_identity = source_oid or "1" * 40 + destination_identity = destination_oid or "2" * 40 return ( - f":100644 100644 {'1' * 40} {'2' * 40} {status}\0" + f":100644 100644 {source_identity} {destination_identity} {status}\0" "docs/old.md\0docs/new.md\0" ).encode() class CiChangeScopeSimilarityScoreTests(unittest.TestCase): - """Only score spellings emitted by Git may influence lightweight CI authority.""" + """Only score spellings and identities emitted by Git may authorize lightweight CI.""" def test_canonical_three_digit_similarity_score_is_accepted(self) -> None: """Git raw output zero-pads scored statuses to three decimal digits.""" @@ -39,6 +46,29 @@ def test_out_of_range_three_digit_score_is_rejected(self) -> None: with self.assertRaisesRegex(ValueError, "status"): parse_nul_raw_changes(_raw_record("R101")) + def test_perfect_similarity_requires_identical_blob_identity(self) -> None: + """R100/C100 cannot describe different blob contents in exact two-tree evidence.""" + + for status in ("R100", "C100"): + with self.subTest(status=status): + with self.assertRaisesRegex(ValueError, "similarity"): + parse_nul_raw_changes(_raw_record(status)) + + def test_nonperfect_similarity_rejects_identical_blob_identity(self) -> None: + """Identical blobs are 100% similar and cannot carry a lower R/C score.""" + + identity = "1" * 40 + for status in ("R074", "C074"): + with self.subTest(status=status): + with self.assertRaisesRegex(ValueError, "similarity"): + parse_nul_raw_changes( + _raw_record( + status, + source_oid=identity, + destination_oid=identity, + ) + ) + if __name__ == "__main__": unittest.main() From 8f885abaafededaf31c0e08847a95cc6e52ad0f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 21:41:21 +0900 Subject: [PATCH 34/40] fix(ci): bind rename similarity to blob identity --- scripts/ci/classify_ci_change_scope.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/ci/classify_ci_change_scope.py b/scripts/ci/classify_ci_change_scope.py index 233d40606..ac64d37cb 100644 --- a/scripts/ci/classify_ci_change_scope.py +++ b/scripts/ci/classify_ci_change_scope.py @@ -169,6 +169,14 @@ def _validate_raw_object_identity_semantics( ): raise ValueError("changed raw record modification has identical object ids") + if status.startswith(("R", "C")): + similarity = int(status[1:]) + identities_match = source_oid == destination_oid + if (similarity == 100) != identities_match: + raise ValueError( + "changed raw record has inconsistent similarity/object id metadata" + ) + def _validate_raw_mode_semantics(source_mode: str, destination_mode: str, status: str) -> None: """Reject status/mode combinations that cannot describe a normal two-tree diff.""" From 4137d720e0da838bb93addd559b3535f93b9cb94 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 21:43:40 +0900 Subject: [PATCH 35/40] test(ci): cover rename similarity identity boundaries --- .../test_ci_change_scope_similarity_scores.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_ci_change_scope_similarity_scores.py b/tests/test_ci_change_scope_similarity_scores.py index 9127d96cc..51e24ffb7 100644 --- a/tests/test_ci_change_scope_similarity_scores.py +++ b/tests/test_ci_change_scope_similarity_scores.py @@ -54,6 +54,21 @@ def test_perfect_similarity_requires_identical_blob_identity(self) -> None: with self.assertRaisesRegex(ValueError, "similarity"): parse_nul_raw_changes(_raw_record(status)) + def test_perfect_similarity_accepts_identical_blob_identity(self) -> None: + """A content-identical rename or copy retains the same blob object identity.""" + + identity = "1" * 40 + for status in ("R100", "C100"): + with self.subTest(status=status): + changes = parse_nul_raw_changes( + _raw_record( + status, + source_oid=identity, + destination_oid=identity, + ) + ) + self.assertEqual(classify_changes(changes), (True, False)) + def test_nonperfect_similarity_rejects_identical_blob_identity(self) -> None: """Identical blobs are 100% similar and cannot carry a lower R/C score.""" @@ -69,6 +84,14 @@ def test_nonperfect_similarity_rejects_identical_blob_identity(self) -> None: ) ) + def test_nonperfect_similarity_accepts_distinct_blob_identity(self) -> None: + """A non-perfect rename/copy similarity score requires distinct blob contents.""" + + for status in ("R074", "C074"): + with self.subTest(status=status): + changes = parse_nul_raw_changes(_raw_record(status)) + self.assertEqual(classify_changes(changes), (True, False)) + if __name__ == "__main__": unittest.main() From a33f1daf3e20df34554b0daf97ef13d4a00f9a29 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 21:44:10 +0900 Subject: [PATCH 36/40] test(ci): align rename fixtures with Git similarity Signed-off-by: Seongho Bae --- tests/test_ci_change_scope.py | 8 +++++++- tests/test_ci_change_scope_path_identity.py | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/test_ci_change_scope.py b/tests/test_ci_change_scope.py index 54d6adae9..908927d0f 100644 --- a/tests/test_ci_change_scope.py +++ b/tests/test_ci_change_scope.py @@ -30,7 +30,13 @@ def _raw_record( """Build one deterministic NUL-framed raw-diff record for focused tests.""" source_oid = "0" * 40 if source_mode == "000000" else "1" * 40 - destination_oid = "0" * 40 if destination_mode == "000000" else "2" * 40 + destination_oid = ( + "0" * 40 + if destination_mode == "000000" + else source_oid + if status in {"R100", "C100"} + else "2" * 40 + ) metadata = ( f":{source_mode} {destination_mode} {source_oid} {destination_oid} {status}\0" ).encode() diff --git a/tests/test_ci_change_scope_path_identity.py b/tests/test_ci_change_scope_path_identity.py index be8974db4..a48fd15d9 100644 --- a/tests/test_ci_change_scope_path_identity.py +++ b/tests/test_ci_change_scope_path_identity.py @@ -10,8 +10,9 @@ def _raw_record(status: str, source_path: str, destination_path: str) -> bytes: """Build one canonical scored rename/copy record with caller-controlled paths.""" + destination_oid = "1" * 40 if status.endswith("100") else "2" * 40 return ( - f":100644 100644 {'1' * 40} {'2' * 40} {status}\0" + f":100644 100644 {'1' * 40} {destination_oid} {status}\0" f"{source_path}\0{destination_path}\0" ).encode() From afe7738de3024803b0ae313ef9a233e8ff6fd2d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 23:10:25 +0900 Subject: [PATCH 37/40] docs(ci): record similarity identity contract Bind the classifier security invariant to the release record without weakening the workflow-owner RED. Signed-off-by: Seongho Bae --- CHANGELOG.md | 1 + tests/test_ci_change_scope_similarity_scores.py | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5fcf87cc..4c84a2395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] - Require complete SHA-1 or SHA-256 object identities before exact-head Git raw-diff evidence may authorize lightweight documentation CI. +- Bind Git rename/copy similarity to blob identity so perfect scores require equal objects and lower scores require distinct objects. - Refreshed the product-gap queue to 126 open pull requests (54 ready, 72 draft) after #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 were merged into their immediate stacked prerequisites. PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review; these are queue-consolidation results, not protected-main shipment. diff --git a/tests/test_ci_change_scope_similarity_scores.py b/tests/test_ci_change_scope_similarity_scores.py index 51e24ffb7..5f13ad11e 100644 --- a/tests/test_ci_change_scope_similarity_scores.py +++ b/tests/test_ci_change_scope_similarity_scores.py @@ -3,6 +3,7 @@ from __future__ import annotations import unittest +from pathlib import Path from scripts.ci.classify_ci_change_scope import classify_changes, parse_nul_raw_changes @@ -92,6 +93,12 @@ def test_nonperfect_similarity_accepts_distinct_blob_identity(self) -> None: changes = parse_nul_raw_changes(_raw_record(status)) self.assertEqual(classify_changes(changes), (True, False)) + def test_changelog_records_similarity_object_identity_contract(self) -> None: + """The release record must name the security invariant added by this slice.""" + + changelog = (Path(__file__).resolve().parents[1] / "CHANGELOG.md").read_text() + self.assertIn("Bind Git rename/copy similarity to blob identity", changelog) + if __name__ == "__main__": unittest.main() From 5fd4b3d3831a86d5f862dea3bb2eb59963b47727 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:00:18 +0900 Subject: [PATCH 38/40] fix(ci): restore fail-closed change partition Signed-off-by: Seongho Bae --- .github/workflows/ci.yml | 52 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ce43675a..a9a25b050 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,54 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + scope: + name: Classify CI scope + runs-on: ubuntu-24.04 + outputs: + documentation_only: ${{ steps.scope.outputs.documentation_only }} + rust_required: ${{ steps.scope.outputs.rust_required }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + fetch-depth: 1 + - name: Classify exact changed objects + id: scope + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + shell: bash + run: | + set -euo pipefail + if [ "${GITHUB_EVENT_NAME}" != "pull_request" ]; then + printf 'documentation_only=false\nrust_required=true\n' >> "${GITHUB_OUTPUT}" + exit 0 + fi + test -n "${BASE_SHA}" + test -n "${HEAD_SHA}" + git fetch --no-tags --depth=1 origin "${BASE_SHA}" + git diff --raw -z --no-abbrev "${BASE_SHA}" "${HEAD_SHA}" | + python3 scripts/ci/classify_ci_change_scope.py >> "${GITHUB_OUTPUT}" + + contracts: + name: Repository and documentation contracts + needs: scope + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Check Python repository contracts + run: | + python3 -m compileall -q scripts tests + python3 -m unittest discover -s tests -p 'test_*.py' + rust: name: Rust contracts + needs: [scope, contracts] + if: needs.scope.outputs.rust_required == 'true' runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -25,10 +71,6 @@ jobs: with: toolchain: 1.97.1 components: clippy,rustfmt - - name: Check Python repository contracts - run: | - python3 -m compileall -q scripts tests - python3 -m unittest discover -s tests -p 'test_*.py' - name: Check formatting id: formatting run: cargo fmt --all --check @@ -63,6 +105,8 @@ jobs: coverage: name: Production coverage + needs: [scope, contracts] + if: needs.scope.outputs.rust_required == 'true' runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 From 87c80f86d6b32e9e894bf7904c77bbd77c3b6c5c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:38:26 +0900 Subject: [PATCH 39/40] fix(ci): trust protected-base scope classifier Signed-off-by: Seongho Bae --- .github/workflows/ci.yml | 9 ++++++++- CHANGELOG.md | 1 + tests/test_ci_change_scope.py | 13 ++++++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9a25b050..fb339d94c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,8 +40,15 @@ jobs: test -n "${BASE_SHA}" test -n "${HEAD_SHA}" git fetch --no-tags --depth=1 origin "${BASE_SHA}" + if ! git cat-file -e "${BASE_SHA}:scripts/ci/classify_ci_change_scope.py"; then + # The protected base does not yet contain the trusted classifier. + printf 'documentation_only=false\nrust_required=true\n' >> "${GITHUB_OUTPUT}" + exit 0 + fi + trusted_classifier="${RUNNER_TEMP}/classify_ci_change_scope.py" + git show "${BASE_SHA}:scripts/ci/classify_ci_change_scope.py" > "${trusted_classifier}" git diff --raw -z --no-abbrev "${BASE_SHA}" "${HEAD_SHA}" | - python3 scripts/ci/classify_ci_change_scope.py >> "${GITHUB_OUTPUT}" + python3 "${trusted_classifier}" >> "${GITHUB_OUTPUT}" contracts: name: Repository and documentation contracts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c84a2395..6787fb821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +- Execute the documentation-only CI classifier from the protected base revision; the bootstrap PR fails closed to full Rust verification when that trusted classifier is not yet present. - Require complete SHA-1 or SHA-256 object identities before exact-head Git raw-diff evidence may authorize lightweight documentation CI. - Bind Git rename/copy similarity to blob identity so perfect scores require equal objects and lower scores require distinct objects. diff --git a/tests/test_ci_change_scope.py b/tests/test_ci_change_scope.py index 908927d0f..6a2b4ad43 100644 --- a/tests/test_ci_change_scope.py +++ b/tests/test_ci_change_scope.py @@ -337,7 +337,18 @@ def test_workflow_keeps_docs_contracts_separate_from_rust(self) -> None: 2, ) self.assertIn("git diff --raw -z --no-abbrev", workflow) - self.assertIn("classify_ci_change_scope.py", workflow) + self.assertIn( + 'git show "${BASE_SHA}:scripts/ci/classify_ci_change_scope.py"', + workflow, + ) + self.assertNotIn( + "python3 scripts/ci/classify_ci_change_scope.py", + workflow, + ) + self.assertIn( + "The protected base does not yet contain the trusted classifier", + workflow, + ) if __name__ == "__main__": From b64e0708584beff3fb54acf226cb3e667773e473 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:56:22 +0900 Subject: [PATCH 40/40] fix(actions): avoid inactive PR runs (#289) --- .github/workflows/ci.yml | 10 +++++----- tests/test_repository_contract.py | 4 +++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0451dcbdb..7f3677c5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: pull_request: - types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] + types: [opened, synchronize, reopened, ready_for_review] push: branches: [main] @@ -15,7 +15,7 @@ concurrency: jobs: scope: - if: ${{ github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) }} + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} name: Classify CI scope runs-on: ubuntu-24.04 outputs: @@ -53,7 +53,7 @@ jobs: python3 "${trusted_classifier}" >> "${GITHUB_OUTPUT}" contracts: - if: ${{ github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) }} + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} name: Repository and documentation contracts needs: scope runs-on: ubuntu-24.04 @@ -70,7 +70,7 @@ jobs: rust: name: Rust contracts needs: [scope, contracts] - if: ${{ (github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false)) && needs.scope.outputs.rust_required == 'true' }} + if: ${{ (github.event_name != 'pull_request' || github.event.pull_request.draft == false) && needs.scope.outputs.rust_required == 'true' }} runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -116,7 +116,7 @@ jobs: coverage: name: Production coverage needs: [scope, contracts] - if: ${{ (github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false)) && needs.scope.outputs.rust_required == 'true' }} + if: ${{ (github.event_name != 'pull_request' || github.event.pull_request.draft == false) && needs.scope.outputs.rust_required == 'true' }} runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/tests/test_repository_contract.py b/tests/test_repository_contract.py index 0bad04acc..1e9990795 100644 --- a/tests/test_repository_contract.py +++ b/tests/test_repository_contract.py @@ -124,9 +124,11 @@ def test_ci_validates_the_exact_pull_request_head(self) -> None: self.assertIn("${{ github.event.pull_request.number || github.run_id }}", workflow) self.assertIn("cancel-in-progress: ${{ github.event_name == 'pull_request' }}", workflow) self.assertIn( - "types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed]", + "types: [opened, synchronize, reopened, ready_for_review]", workflow, ) + self.assertNotIn("converted_to_draft", workflow) + self.assertNotIn("github.event.action != 'closed'", workflow) self.assertEqual(workflow.count("github.event.pull_request.draft == false"), 4) self.assertNotIn("cargo check --locked --workspace --all-targets", workflow)