Skip to content

Commit 9e2083a

Browse files
committed
fix(protocol): lock release artifact content
1 parent affc2af commit 9e2083a

2 files changed

Lines changed: 154 additions & 8 deletions

File tree

scripts/validate_docs.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,19 @@
44
from __future__ import annotations
55

66
import re
7+
import stat
78
import sys
89
from hashlib import sha256
910
from pathlib import Path
1011

1112

1213
ROOT = Path(__file__).resolve().parents[1]
1314
MAX_BYTES = 28_672
15+
RELEASE_LOCK_VERSION = "1.12.1"
16+
PROTOCOL_CONTENT_DIGESTS = {
17+
"AGENTS.md": "95922355f2fa3eec04ca2c34e468b6b181c53b35156475fae69f08547c571084",
18+
"AGENTS.zh-CN.md": "6e63fb29305466449c42d7502ecb2629df68661cab57e19654c11cf2667eae47",
19+
}
1420
NUMERIC_IDENTIFIER = r"(?:0|[1-9][0-9]*)"
1521
NON_NUMERIC_IDENTIFIER = r"(?:[0-9]*[A-Za-z-][0-9A-Za-z-]*)"
1622
PRERELEASE_IDENTIFIER = (
@@ -472,6 +478,25 @@ def extract_version(text: str) -> str | None:
472478
return match.group("version") if match else None
473479

474480

481+
def content_digest(text: str) -> str:
482+
"""Return the release-lock digest for a protocol document."""
483+
return sha256(text.encode("utf-8")).hexdigest()
484+
485+
486+
def read_protocol_file(path: Path) -> str:
487+
"""Read one UTF-8 protocol artifact without following non-regular files."""
488+
try:
489+
mode = path.lstat().st_mode
490+
except OSError as error:
491+
raise ValueError(f"{path.name} must be a regular file") from error
492+
if not stat.S_ISREG(mode):
493+
raise ValueError(f"{path.name} must be a regular file")
494+
try:
495+
return path.read_bytes().decode("utf-8")
496+
except (OSError, UnicodeError) as error:
497+
raise ValueError(f"{path.name} must be readable UTF-8") from error
498+
499+
475500
def leading_indent_columns(line: str) -> int:
476501
"""Measure leading Markdown indentation with four-column tab stops."""
477502
columns = 0
@@ -775,10 +800,12 @@ def validate_texts(
775800
) -> list[str]:
776801
errors: list[str] = []
777802
documents = (("AGENTS.md", english), ("AGENTS.zh-CN.md", chinese))
803+
oversized = False
778804

779805
for name, text in documents:
780806
size = len(text.encode("utf-8"))
781807
if size > max_bytes:
808+
oversized = True
782809
errors.append(f"{name} is {size} bytes; limit is {max_bytes}")
783810

784811
english_version = extract_version(english)
@@ -794,6 +821,16 @@ def validate_texts(
794821
f"AGENTS.md={english_version}, AGENTS.zh-CN.md={chinese_version}"
795822
)
796823

824+
if oversized:
825+
return errors
826+
827+
for name, text in documents:
828+
if content_digest(text) != PROTOCOL_CONTENT_DIGESTS[name]:
829+
errors.append(
830+
f"{name} content differs from the v{RELEASE_LOCK_VERSION} "
831+
"release lock"
832+
)
833+
797834
expected_english_h2 = [pair[0] for pair in H2_PAIRS]
798835
expected_chinese_h2 = [pair[1] for pair in H2_PAIRS]
799836
if extract_h2(english) != expected_english_h2:
@@ -847,8 +884,12 @@ def validate_texts(
847884
def main() -> int:
848885
english_path = ROOT / "AGENTS.md"
849886
chinese_path = ROOT / "AGENTS.zh-CN.md"
850-
english = english_path.read_text(encoding="utf-8")
851-
chinese = chinese_path.read_text(encoding="utf-8")
887+
try:
888+
english = read_protocol_file(english_path)
889+
chinese = read_protocol_file(chinese_path)
890+
except ValueError as error:
891+
print(f"ERROR: {error}", file=sys.stderr)
892+
return 1
852893
errors = validate_texts(english, chinese)
853894

854895
if errors:

tests/test_docs_contract.py

Lines changed: 111 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import importlib.util
2+
import io
23
import re
4+
import tempfile
35
import unittest
46
from pathlib import Path
7+
from unittest import mock
58

69

710
ROOT = Path(__file__).resolve().parents[1]
@@ -18,8 +21,8 @@ class DocsContractTests(unittest.TestCase):
1821
def setUpClass(cls):
1922
cls.english_path = ROOT / "AGENTS.md"
2023
cls.chinese_path = ROOT / "AGENTS.zh-CN.md"
21-
cls.english = cls.english_path.read_text(encoding="utf-8")
22-
cls.chinese = cls.chinese_path.read_text(encoding="utf-8")
24+
cls.english = cls.english_path.read_bytes().decode("utf-8")
25+
cls.chinese = cls.chinese_path.read_bytes().decode("utf-8")
2326

2427
def assert_single_mutation_rejected(
2528
self,
@@ -59,6 +62,102 @@ def test_documents_fit_context_budget(self):
5962
f"{path.name} exceeds {validate_docs.MAX_BYTES} bytes",
6063
)
6164

65+
def test_protocol_content_digests_match_release_lock(self):
66+
documents = {
67+
"AGENTS.md": self.english,
68+
"AGENTS.zh-CN.md": self.chinese,
69+
}
70+
self.assertEqual(
71+
{
72+
name: validate_docs.content_digest(text)
73+
for name, text in documents.items()
74+
},
75+
validate_docs.PROTOCOL_CONTENT_DIGESTS,
76+
)
77+
self.assertEqual(
78+
{validate_docs.extract_version(text) for text in documents.values()},
79+
{validate_docs.RELEASE_LOCK_VERSION},
80+
)
81+
82+
def test_content_lock_rejects_known_markdown_context_bypasses(self):
83+
authority = next(
84+
line
85+
for line in self.english.splitlines()
86+
if line.startswith("Only project-tree `AGENTS.md`")
87+
)
88+
directory = validate_docs.extract_heading_section(
89+
self.english,
90+
3,
91+
"Directory Layout",
92+
)
93+
tree_match = re.search(
94+
r"^```[ \t]*\n.*?^```[ \t]*$",
95+
directory,
96+
flags=re.MULTILINE | re.DOTALL,
97+
)
98+
self.assertIsNotNone(tree_match)
99+
tree = tree_match.group(0)
100+
mutations = (
101+
(
102+
authority,
103+
f'[visible](<foo)bar> "\n{authority}\n")',
104+
),
105+
(
106+
authority,
107+
f"> <div>\n> raw\n<x-agentgo>\n{authority}\n</x-agentgo>",
108+
),
109+
(
110+
tree,
111+
f"<x-agentgo>\n{tree}\n</x-agentgo>",
112+
),
113+
)
114+
for old, new in mutations:
115+
with self.subTest(replacement=new.splitlines()[0]):
116+
mutated = self.english.replace(old, new, 1)
117+
errors = validate_docs.validate_texts(
118+
mutated,
119+
self.chinese,
120+
max_bytes=100_000,
121+
)
122+
self.assertIn(
123+
"AGENTS.md content differs from the v1.12.1 release lock",
124+
errors,
125+
)
126+
127+
def test_oversized_documents_skip_deep_parsing(self):
128+
with mock.patch.object(
129+
validate_docs,
130+
"mask_fenced_code_blocks",
131+
side_effect=AssertionError("deep parser must not run"),
132+
):
133+
errors = validate_docs.validate_texts(
134+
self.english + "<" * 200_000,
135+
self.chinese + "<" * 200_000,
136+
)
137+
self.assertEqual(len(errors), 2)
138+
self.assertTrue(all("limit is" in error for error in errors))
139+
140+
def test_main_rejects_symlinked_protocol_artifacts(self):
141+
with tempfile.TemporaryDirectory() as temporary_directory:
142+
root = Path(temporary_directory)
143+
target = root / "canonical-english.md"
144+
target.write_bytes(self.english.encode("utf-8"))
145+
(root / "AGENTS.md").symlink_to(target.name)
146+
(root / "AGENTS.zh-CN.md").write_bytes(
147+
self.chinese.encode("utf-8")
148+
)
149+
stderr = io.StringIO()
150+
stdout = io.StringIO()
151+
with (
152+
mock.patch.object(validate_docs, "ROOT", root),
153+
mock.patch("sys.stderr", stderr),
154+
mock.patch("sys.stdout", stdout),
155+
):
156+
result = validate_docs.main()
157+
self.assertEqual(result, 1)
158+
self.assertIn("AGENTS.md must be a regular file", stderr.getvalue())
159+
self.assertEqual(stdout.getvalue(), "")
160+
62161
def test_first_line_versions_match_semver(self):
63162
english_version = validate_docs.extract_version(self.english)
64163
chinese_version = validate_docs.extract_version(self.chinese)
@@ -165,7 +264,7 @@ def test_validator_rejects_extra_h3_outside_h2_sections(self):
165264
),
166265
)
167266

168-
def test_validator_ignores_h3_examples_inside_fenced_code(self):
267+
def test_content_lock_rejects_fenced_h3_example_drift(self):
169268
old = "## Startup Instructions\n"
170269
self.assertEqual(self.english.count(old), 1)
171270
english = self.english.replace(
@@ -179,7 +278,10 @@ def test_validator_ignores_h3_examples_inside_fenced_code(self):
179278
self.chinese,
180279
max_bytes=100_000,
181280
)
182-
self.assertEqual(errors, [])
281+
self.assertEqual(
282+
errors,
283+
["AGENTS.md content differs from the v1.12.1 release lock"],
284+
)
183285

184286
def test_validator_rejects_marker_relocated_into_fenced_code(self):
185287
marker = (
@@ -801,7 +903,7 @@ def test_validator_rejects_changed_extended_changelog_format(self):
801903
),
802904
)
803905

804-
def test_validator_does_not_require_equal_physical_line_counts(self):
906+
def test_content_lock_rejects_physical_line_drift(self):
805907
chinese_with_extra_blank_line = self.chinese + "\n"
806908
self.assertNotEqual(
807909
len(self.english.splitlines()),
@@ -813,7 +915,10 @@ def test_validator_does_not_require_equal_physical_line_counts(self):
813915
chinese_with_extra_blank_line,
814916
max_bytes=100_000,
815917
)
816-
self.assertEqual(errors, [])
918+
self.assertEqual(
919+
errors,
920+
["AGENTS.zh-CN.md content differs from the v1.12.1 release lock"],
921+
)
817922

818923
def test_readme_stable_examples_match_protocol_version(self):
819924
version = validate_docs.extract_version(self.english)

0 commit comments

Comments
 (0)