diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md index da2c5681b8..6d60683ac5 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md @@ -56,7 +56,14 @@ not amend normative sections. wire values or move value authority to the registry. M2 now generates the Turn vocabularies, route projection and ordered controller rules from `turn_loop_controller_contract_v0.json` via `generate_turn_contract.py`; - this does not migrate every cross-runtime vocabulary. + this does not migrate every cross-runtime vocabulary. The semantic binding + generator also derives the existing Python digest-pattern module from its TS + owner. This narrow literal grammar rejects flags, dynamic expressions and + unsupported regex syntax. The twin ratchet excludes a source-derived binding + only after recomputing and matching its entire artifact; a filename/header + alone never qualifies. Digest callers keep their imports and matching behavior, + and no runtime bridge is added. This restores 43 independently maintained + pairs without raising the 43-pair ceiling. 3. **Default and opt-in boundary.** The check is always on for the repository. It has no runtime flag because it never runs inside the product. 4. **Principal constraint.** Fail closed, deterministic, and not weakenable by diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md index 7401518778..f2e15f07e7 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md @@ -780,6 +780,11 @@ TypeScript effective-action/frontier 绑定与[术语表](../../reference/glossa 检查生成物新鲜度,不新增 required CI job。运行 TypeScript 生产者扫描之前, 先用 `npm ci --ignore-scripts` 安装锁定的 Node 依赖。 +同一语义绑定生成器还从 TS owner 生成现有 Python 摘要格式模块:仅接受两条无 flags +的受限字面量正则,动态表达式与不支持的语法明确拒绝。孪生预算只在重新生成并逐字 +核对产物后,排除独立维护计数;文件名或 generated 注释不能豁免。调用方导入、匹配 +行为保持不变,也不增加运行时桥接。独立维护对数因此恢复为 43,上限仍为 43。 + Turn 契约使用独立的生成器和来源。只读验证入口: ```bash diff --git a/examples/semantic-vocabulary-drift-smoke.py b/examples/semantic-vocabulary-drift-smoke.py index d8e5cb5e91..9e46a3122e 100755 --- a/examples/semantic-vocabulary-drift-smoke.py +++ b/examples/semantic-vocabulary-drift-smoke.py @@ -1058,8 +1058,11 @@ def check_dual_runtime_twins(registry: dict[str, Any]) -> str: paths = {file.path for file in load_sources(REPO_ROOT, entry["root"])} twins = sorted(path for path in paths if path.endswith(".py") and not path.endswith("/__init__.py") and path[:-3] + ".ts" in paths) from scripts.generate_turn_contract import verified_generated_paths - generated = verified_generated_paths() - generated_twins = [path for path in twins if path in generated and path[:-3] + '.ts' in generated] + from scripts.generate_semantic_bindings import verified_generated_paths as semantic_generated_paths + generated = verified_generated_paths() | semantic_generated_paths() + # A verified source-derived binding has no independently maintained rule; + # its owner may be authored TS/Python or a shared generated contract. + generated_twins = [path for path in twins if path in generated or path[:-3] + '.ts' in generated] maintained = len(twins) - len(generated_twins) require(maintained <= entry['module_budget'], f"{maintained} independently maintained py/ts twins; budget is {entry['module_budget']}") return f"twins_raw={len(twins)} generated_verified={len(generated_twins)} independently_maintained={maintained}/{entry['module_budget']}" diff --git a/loopx/control_plane/content_digest.py b/loopx/control_plane/content_digest.py index 4eb81f56c3..6572b6e69d 100644 --- a/loopx/control_plane/content_digest.py +++ b/loopx/control_plane/content_digest.py @@ -1,20 +1,9 @@ -"""One owner for the two shapes a stored SHA-256 digest can take. - -A digest reaches a record in one of two envelopes: the bare 64 lowercase hex -characters, or that same hex behind the ``sha256:`` prefix that the -periodic-report and content-ops writers concatenate. Before this module the -decision was compiled independently in eighteen modules under three spellings. - -Patterns that merely contain a hex digest inside a larger grammar (a -``cadence_…`` identifier, a journal filename, a ``40|64`` Git object id, a -compound cursor) answer a different question and stay with the surface that -owns that grammar. Producers that build the envelope by hand are the other half -of this decision and are deliberately unchanged here. -""" - +# Generated by scripts/generate_semantic_bindings.py; do not edit. +# Value owner: loopx/control_plane/content_digest.ts +# Existing imports remain stable; Python performs no runtime bridge call. from __future__ import annotations import re -ENVELOPED_SHA256_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$") -BARE_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") +ENVELOPED_SHA256_PATTERN = re.compile("^sha256:[0-9a-f]{64}$") +BARE_SHA256_PATTERN = re.compile("^[0-9a-f]{64}$") diff --git a/scripts/generate_semantic_bindings.py b/scripts/generate_semantic_bindings.py index c456a71985..cbe1401017 100644 --- a/scripts/generate_semantic_bindings.py +++ b/scripts/generate_semantic_bindings.py @@ -9,6 +9,7 @@ import argparse import json +import re from pathlib import Path import sys @@ -24,6 +25,8 @@ BINDING = ROOT / 'loopx/control_plane/quota/effective_action.generated.ts' FRONTIER_BINDING = ROOT / 'loopx/control_plane/agents/agent_scope_frontier.generated.ts' GLOSSARY = ROOT / 'docs/reference/glossary.md' +DIGEST_OWNER = ROOT / 'loopx/control_plane/content_digest.ts' +DIGEST_BINDING = DIGEST_OWNER.with_suffix('.py') def render_binding(members: dict[str, str], source_owner: str, symbol: str, array: str) -> str: @@ -76,6 +79,42 @@ def render_glossary(registry: dict) -> str: return '\n'.join(lines) +def render_digest_binding(source: str) -> str: + # These two flagless literal patterns are a deliberately narrow cross-runtime + # contract. Reject extra syntax rather than execute TS or guess its meaning. + declarations = re.sub(r"/\*.*?\*/", "", source, flags=re.S).strip().splitlines() + patterns = {} + for declaration in declarations: + if not declaration.strip(): + continue + match = re.fullmatch(r"export const (ENVELOPED_SHA256_PATTERN|BARE_SHA256_PATTERN) = /([^/]+)/;", declaration.strip()) + if match is None or match[1] in patterns: + raise ValueError("digest owner requires exactly two flagless literal pattern exports") + # Only the shared digest alphabet, anchors and quantifier are supported; + # language-specific regex features require a reviewed generator change. + if re.fullmatch(r"\^(?:sha256:)?\[0-9a-f\]\{[1-9][0-9]*\}\$", match[2]) is None: + raise ValueError("digest owner pattern is outside the shared regex subset") + patterns[match[1]] = match[2] + if set(patterns) != {"ENVELOPED_SHA256_PATTERN", "BARE_SHA256_PATTERN"}: + raise ValueError("digest owner requires both exports") + return "\n".join([ + '# Generated by scripts/generate_semantic_bindings.py; do not edit.', + '# Value owner: loopx/control_plane/content_digest.ts', + '# Existing imports remain stable; Python performs no runtime bridge call.', + 'from __future__ import annotations', '', 'import re', '', + *(f'{name} = re.compile({json.dumps(pattern)})' for name, pattern in patterns.items()), '', + ]) + + +def verified_generated_paths(): + """Qualify generated bindings by rebuilding, not by a filename or comment.""" + artifacts = build_artifacts() + for path, content in artifacts.items(): + if not path.is_file() or path.read_text(encoding="utf-8") != content: + raise ValueError(f"stale generated semantic artifact: {path.relative_to(ROOT)}; run scripts/generate_semantic_bindings.py") + return frozenset(path.relative_to(ROOT).as_posix() for path in artifacts) + + def build_artifacts() -> dict[Path, str]: registry = json.loads(REGISTRY.read_text(encoding='utf-8')) sources = {source.path: source for source in load_sources(ROOT)} @@ -100,6 +139,7 @@ def build_artifacts() -> dict[Path, str]: 'export type QuotaEffectiveActionValue = EffectiveActionValue | AgentScopeFrontierActionValue;\n' ) artifacts[GLOSSARY] = render_glossary(registry) + artifacts[DIGEST_BINDING] = render_digest_binding(DIGEST_OWNER.read_text(encoding="utf-8")) return artifacts diff --git a/tests/architecture/test_semantic_bindings.py b/tests/architecture/test_semantic_bindings.py index f0d1d9138b..a4701a3498 100644 --- a/tests/architecture/test_semantic_bindings.py +++ b/tests/architecture/test_semantic_bindings.py @@ -150,3 +150,60 @@ def test_strict_extraction_does_not_execute_inspected_source(): source = SourceFile('loopx/owner.py', '.py', 'raise RuntimeError("source must not execute")\nclass Action(str, Enum):\n RUN: str = "run"\n') assert generator.enum_members(source, 'Action', strict=True) == {'RUN': 'run'} + + +def test_digest_binding_is_derived_from_the_typescript_owner(): + import re + + namespace = {} + exec(compile(generator.DIGEST_BINDING.read_text(), '', 'exec'), namespace) + assert namespace['BARE_SHA256_PATTERN'].fullmatch('a' * 64) + assert namespace['ENVELOPED_SHA256_PATTERN'].fullmatch('sha256:' + 'a' * 64) + for value in ['A' * 64, 'a' * 63, 'a' * 65, 'g' * 64, 'a' * 64 + '\n']: + assert namespace['BARE_SHA256_PATTERN'].fullmatch(value) is None + assert namespace['ENVELOPED_SHA256_PATTERN'].fullmatch('a' * 64) is None + assert namespace['BARE_SHA256_PATTERN'].fullmatch('sha256:' + 'a' * 64) is None + assert isinstance(namespace['BARE_SHA256_PATTERN'], re.Pattern) + assert generator.DIGEST_BINDING.relative_to(generator.ROOT).as_posix() in generator.verified_generated_paths() + + +@pytest.mark.parametrize('suffix', ['i', 'g', 'm']) +def test_digest_generator_rejects_regex_flags(suffix): + source = generator.DIGEST_OWNER.read_text().replace('/;', f'/{suffix};', 1) + with pytest.raises(ValueError, match='flagless'): + generator.render_digest_binding(source) + + +@pytest.mark.parametrize('mutation', ['extra', 'missing', 'dynamic', 'unsupported']) +def test_digest_generator_rejects_untranslated_source(mutation): + source = generator.DIGEST_OWNER.read_text() + if mutation == 'extra': + source += '\nthrow new Error("must not execute");\n' + elif mutation == 'missing': + source = source[:source.index('export const BARE_SHA256_PATTERN')] + elif mutation == 'dynamic': + source = source.replace('/^[0-9a-f]{64}$/', 'buildPattern()') + else: + source = source.replace('[0-9a-f]', r'\p{ASCII}') + with pytest.raises(ValueError, match='digest owner'): + generator.render_digest_binding(source) + + +def test_changed_digest_owner_invalidates_old_binding(tmp_path, monkeypatch): + source = tmp_path / 'content_digest.ts' + source.write_text(generator.DIGEST_OWNER.read_text().replace('{64}', '{63}')) + monkeypatch.setattr(generator, 'DIGEST_OWNER', source) + # Detect source drift without rewriting any checked-in binding. + with pytest.raises(ValueError, match='stale generated semantic artifact'): + generator.verified_generated_paths() + + +def test_generated_filename_or_header_does_not_exempt_hand_edits(tmp_path, monkeypatch): + artifact = tmp_path / 'content_digest.py' + expected = generator.render_digest_binding(generator.DIGEST_OWNER.read_text()) + artifact.write_text(expected.replace('{64}', '{63}')) + monkeypatch.setattr(generator, 'ROOT', tmp_path) + monkeypatch.setattr(generator, 'build_artifacts', lambda: {artifact: expected}) + with pytest.raises(ValueError, match='stale generated semantic artifact'): + generator.verified_generated_paths() + assert '{63}' in artifact.read_text()