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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions examples/semantic-vocabulary-drift-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']}"
Expand Down
21 changes: 5 additions & 16 deletions loopx/control_plane/content_digest.py
Original file line number Diff line number Diff line change
@@ -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}$")
40 changes: 40 additions & 0 deletions scripts/generate_semantic_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import argparse
import json
import re
from pathlib import Path
import sys

Expand All @@ -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:
Expand Down Expand Up @@ -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)}
Expand All @@ -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


Expand Down
57 changes: 57 additions & 0 deletions tests/architecture/test_semantic_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(), '<generated digest binding>', '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()
Loading