From 9be6885d081fd3363ec1475223e7d237a26a5287 Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:23:28 -0400 Subject: [PATCH 01/11] feat(semantics): budget the modules a legacy field retirement must change (B3) The six legacy should-run fields were budgeted by a token count: modules whose text carries the standalone field name. That answers "does this name appear here", which is not the question a retirement asks, and it merged three populations. `goal_boundary` and `work_lane_contract` sat at 30 and 29, so the plan ordered them as equally expensive; measured by syntactic role their migration surfaces are 15 and 28, because fourteen of `goal_boundary`'s modules are prompt prose and module-path imports that no migration touches. `protocol_action_packet` resolves to one Python reader and four writers, which makes it the cheapest first M3 removal -- the token count could not say so. `check_reader_metric` classifies every module carrying one of the six tokens as reader, writer, binding, unresolved or mention, and budgets the first three as the migration surface, anchored against MIGRATION_SURFACE_ANCHOR the same way RETIREMENT_ANCHOR pins the token budgets. The roles are asserted to partition the token count exactly, per field and per runtime, so the smaller number is a reclassification of one population rather than a different, smaller sample. Both budgets are pinned at their measured values in this diff: no debt is repaid here, and the token budget stays until Q11 decides whether to retire it. Three limits are measured rather than assumed. 1704 mapping accessors under `loopx/` take a computed key, so a field at zero readers is measured against a stated unknown instead of declared dead; the smoke prints that number. Computed-key subscripts are excluded because `rows[index]` and `payload[key]` are one syntax and counting them would inflate the unknown past usefulness. TypeScript is scanned by bounded grammar over code whose string literals and comments are blanked first -- otherwise the path label "decision.heartbeat_recommendation" reads as a property access -- and a bare `field:` is reported as a mention, since an interface member and an object-literal entry are the same shape to that grammar. Refs #4447 (B3) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- examples/semantic-vocabulary-drift-smoke.py | 84 +++++ loopx/semantics/field_use.py | 364 ++++++++++++++++++++ loopx/semantics/vocabulary_v0.json | 26 +- 3 files changed, 467 insertions(+), 7 deletions(-) create mode 100644 loopx/semantics/field_use.py diff --git a/examples/semantic-vocabulary-drift-smoke.py b/examples/semantic-vocabulary-drift-smoke.py index 33aa60ed72..b255ebdcc4 100755 --- a/examples/semantic-vocabulary-drift-smoke.py +++ b/examples/semantic-vocabulary-drift-smoke.py @@ -38,6 +38,9 @@ collect_production, validate_production, INPUT_WITNESSES, quota_action_domain, collect_literal_uses, ) from loopx.semantics.python_production import scan_python_production # noqa: E402 +from loopx.semantics.field_use import ( # noqa: E402 + ROLES, field_use_summary, render_field_uses, scan_field_uses, +) from scripts.generate_semantic_bindings import build_artifacts # noqa: E402 from loopx.canary.maintainability_ratchet import evaluate_maintainability_findings # noqa: E402 @@ -167,6 +170,22 @@ "goal_boundary": (30, 2), "protocol_action_packet": (5, 2), } +# B3 migration surface: modules that read, write or bind the legacy field and +# must change before it can be removed. Anchored like RETIREMENT_ANCHOR so the +# registry and this literal move in one diff. This does not replace the token +# budget above; Q11 owns that decision, and until it lands both are checked. +MIGRATION_SURFACE_ANCHOR = { + "execution_obligation": (15, 0), + "heartbeat_recommendation": (13, 1), + "work_lane_contract": (28, 3), + "external_evidence_observation": (6, 1), + "goal_boundary": (15, 1), + "protocol_action_packet": (5, 2), +} +RETIREMENT_FIELD_KEYS = { + "python_module_budget", "typescript_module_budget", + "python_migration_surface", "typescript_migration_surface", +} RATCHET_KEYS = ( "same_runtime_forks", "same_runtime_fork_definitions", @@ -605,6 +624,10 @@ def check_retirement_budgets(registry: dict[str, Any], sources: list[SourceFile] ledger = registry["retirement_ledger"]["should_run_legacy_decision_fields"]["fields"] require(set(ledger) == set(RETIREMENT_ANCHOR), f"retirement ledger fields are {sorted(ledger)}; the anchored set is {sorted(RETIREMENT_ANCHOR)}") for field, budgets in ledger.items(): + require( + set(budgets) == RETIREMENT_FIELD_KEYS, + f"retirement ledger {field} carries {sorted(budgets)}; expected {sorted(RETIREMENT_FIELD_KEYS)}", + ) for suffix, key, anchored in ( (".py", "python_module_budget", RETIREMENT_ANCHOR[field][0]), (".ts", "typescript_module_budget", RETIREMENT_ANCHOR[field][1]), @@ -626,6 +649,7 @@ def count_identifier_modules(field: str, suffix: str, sources: list[SourceFile]) This is intentionally a conservative lexical metric. It removes the known ``goal_boundary_repair`` false positive without claiming to prove that every remaining occurrence is a reader or that computed accesses are absent. + ``check_reader_metric`` splits this same population by syntactic role. """ pattern = re.compile( rf"(? tuple[list[str], list[str]]: + """Check the B3 syntactic metric against the ledger, and report its roles. + + Three obligations, all measured rather than assumed: + + * the five roles partition the token count exactly, so the new metric is a + reclassification of the same modules and not a different population that + happens to be smaller; + * the migration surface stays within its anchored budget, so a new reader + of a legacy field fails the PR path that adds it; + * the unresolved populations stay visible. ``dynamic_mapping_key_sites`` + counts mapping accessors with a computed key anywhere under ``loopx/``; + while that number is nonzero, a field measured at zero readers is not + thereby proven dead, and the smoke says so in its own output. + """ + ledger = registry["retirement_ledger"]["should_run_legacy_decision_fields"]["fields"] + summary = field_use_summary(ledger, sources) + report: list[str] = [] + detail: list[str] = [] + for field in sorted(ledger): + entry = summary["fields"][field] + for suffix, runtime, anchored in ( + (".py", "python", MIGRATION_SURFACE_ANCHOR[field][0]), + (".ts", "typescript", MIGRATION_SURFACE_ANCHOR[field][1]), + ): + classified = sum(entry[f"{runtime}_{role}_modules"] for role in ROLES) + carriers = entry[f"{runtime}_token_modules"] + require( + classified == carriers, + f"{field}{suffix}: roles classify {classified} modules but the token metric finds {carriers}; " + "the syntactic metric must reclassify the same modules, not a smaller population", + ) + budget = ledger[field][f"{runtime}_migration_surface"] + actual = entry[f"{runtime}_migration_surface"] + require( + actual <= budget, + f"legacy field {field} now needs {actual} {suffix} modules migrated; budget is {budget}", + ) + require( + budget == anchored, + f"legacy field {field} {suffix} migration surface budget is {budget} but " + f"MIGRATION_SURFACE_ANCHOR pins {anchored}; the registry and the anchor move together in one diff", + ) + detail.append( + f"{field}{suffix} surface={actual}/{budget} " + + " ".join(f"{role}={entry[f'{runtime}_{role}_modules']}" for role in ROLES) + + f" carriers={carriers}" + ) + report.append(f"dynamic_mapping_key_sites={summary['dynamic_mapping_key_sites']} (computed keys, unattributable)") + return report, detail + + def check_dual_runtime_twins(registry: dict[str, Any]) -> str: entry = registry["dual_runtime_twins"] require(entry["root"] == TWIN_ROOT_ANCHOR, "dual_runtime_twins root differs from TWIN_ROOT_ANCHOR") @@ -718,11 +794,13 @@ def main() -> int: check_projections(registry) check_schema_version_owners(registry, sources) budgets = check_retirement_budgets(registry, sources) + reader_metric, reader_detail = check_reader_metric(registry, sources) twins = check_dual_runtime_twins(registry) print("semantic-vocabulary-drift-smoke: ok") print(" " + coverage) print(" " + ratchets) print(" " + " ".join(budgets)) + print(" " + " ".join(reader_metric)) print(" " + twins) print(f" unresolved_producer_sites={len(unknown_producers)} (not proven safe)") print(" unresolved_producer_blockers=" + summarise_blockers(unknown_producers)) @@ -731,6 +809,12 @@ def main() -> int: if '--report' in sys.argv[1:]: for site in unknown_producers: print(f" unknown_producer: {site}") + for line in reader_detail: + print(f" retirement_role: {line}") + ledger_fields = registry["retirement_ledger"]["should_run_legacy_decision_fields"]["fields"] + uses, _ = scan_field_uses(ledger_fields, sources) + for line in render_field_uses(uses): + print(f" field_use: {line}") return 0 diff --git a/loopx/semantics/field_use.py b/loopx/semantics/field_use.py new file mode 100644 index 0000000000..e22f5c6f91 --- /dev/null +++ b/loopx/semantics/field_use.py @@ -0,0 +1,364 @@ +"""Classify how each module uses a named payload field (RFC B3). + +The retirement ledger used to count modules whose text contains the field +token. That metric answers "does this name appear here", which is not the +question retirement asks. Removing ``goal_boundary`` requires knowing which +modules *read* it, which modules *write* it, and which merely mention it in +prose -- three populations the token count folds into one number. + +What is measured here is syntactic use, not data flow. A module is a reader +when it performs a recognized literal-key read (``payload["goal_boundary"]``, +``payload.get("goal_boundary")``, ``"goal_boundary" in payload``, or the +TypeScript property read). It is a writer when it performs a recognized literal +write (subscript store, dict-literal key, keyword argument, attribute store, +``setdefault``). Reader and writer are not exclusive: a projection module that +reads the legacy field and re-emits it is both. + +Three limits are part of the metric, not caveats around it: + +* A computed key is **unresolved**. ``payload.get(name)`` may read any field, so + no name-keyed scan -- lexical or syntactic -- can prove a module is not a + reader. ``dynamic_mapping_key_sites`` counts those sites repository-wide, so a + field measured at zero readers is measured against a stated unknown rather + than declared dead, and a module that carries the field name as a bare string + is reported as unresolved rather than as a mention. Subscripts with a computed + key are deliberately *not* counted: ``rows[index]`` and ``payload[key]`` are + the same syntax, and counting sequence indexing as an unresolved mapping read + would inflate the unknown until it stopped carrying information. +* Same-prefix identifiers are different fields. ``goal_boundary_repair`` is not + a use of ``goal_boundary``; the AST compares whole keys, and the TypeScript + scan anchors on non-identifier boundaries. +* A mention is evidence of nothing. Prompt prose, a module path component, a + local variable named after the payload it holds and a parameter name all + carry the token without touching the field. They are reported as mentions so + that migration can ignore them and so the residual token count stays visible. +""" + +from __future__ import annotations + +import ast +from dataclasses import dataclass +import re +from typing import Any, Iterable + +from .inventory import SourceFile + +# ``dict``/``Mapping`` accessors whose first literal argument names a field. +MAPPING_READ_CALLS = frozenset({"get", "pop"}) +MAPPING_WRITE_CALLS = frozenset({"setdefault"}) + +READ_FORMS = frozenset({ + "subscript_read", "mapping_call_read", "membership_read", "attribute_read", + "property_read", +}) +WRITE_FORMS = frozenset({ + "subscript_write", "mapping_call_write", "dict_literal_key", "keyword_argument", + "attribute_write", "property_write", +}) +# The module names the field as a parameter, a local or its own definition. It +# handles the value without a recognized key access -- a pass-through consumer +# in the RFC's role hierarchy, and a signature the migration has to change. +BINDING_FORMS = frozenset({"local_binding", "local_reference", "parameter", "definition"}) +# The field name travels as data here: a string constant that no recognized key +# position consumed -- a name in a field list a loop will index with, or a label +# in an emitted record. Which one it is needs a reader, so the module is +# reported as unresolved rather than silently counted as a mention. +UNRESOLVED_FORMS = frozenset({"name_constant"}) +MENTION_FORMS = frozenset({"module_import", "object_key", "prose"}) +USE_FORMS = READ_FORMS | WRITE_FORMS | BINDING_FORMS | UNRESOLVED_FORMS | MENTION_FORMS + +# TypeScript has no parser here, so it is scanned with a bounded grammar over +# code text whose string literals and comments have been blanked out first -- +# otherwise the path label ``"decision.heartbeat_recommendation"`` would be +# counted as a property read. Each pattern is anchored on non-identifier +# boundaries, so ``goal_boundary_repair`` cannot match ``goal_boundary``. +# +# ``object_key`` (a bare ``field:`` at the head of a line) is reported as a +# mention, not a write. An interface member and an object-literal entry are the +# same shape, and this grammar cannot separate them; crediting a declaration as +# a write would overstate production in exactly the direction the RFC's +# production obligation warns about. The token count still shows the module. +_TS_READ_PATTERNS: tuple[tuple[str, str], ...] = ( + ("property_read", r"\.\s*{field}(?![A-Za-z0-9_$])"), + ("subscript_read", r"""\[\s*["']{field}["']\s*\]"""), +) +# Each write pattern is its read pattern followed by an assignment, so a write +# match always starts where the corresponding read match starts. That is how a +# pure write is kept from also counting as a read. +_TS_WRITE_SUFFIX = r"\s*(?:=[^=]|\+=)" +_TS_OBJECT_KEY = r"^\s*{field}\??\s*:" +_TS_STRING = re.compile(r"""(?s)(?P["'`])(?:\\.|(?!(?P=q)).)*(?P=q)""") +_TS_COMMENT = re.compile(r"(?s)//[^\n]*|/\*.*?\*/") + + +@dataclass(frozen=True) +class FieldUse: + """One module's recognized uses of one field.""" + + field: str + module: str + forms: frozenset[str] + + @property + def reads(self) -> bool: + return bool(self.forms & READ_FORMS) + + @property + def writes(self) -> bool: + return bool(self.forms & WRITE_FORMS) + + @property + def unresolved(self) -> bool: + return bool(self.forms & UNRESOLVED_FORMS) + + @property + def binds(self) -> bool: + return bool(self.forms & BINDING_FORMS) + + @property + def role(self) -> str: + """The single label that orders migration work for this module.""" + if self.reads: + return "reader" + if self.writes: + return "writer" + if self.binds: + return "binding" + if self.unresolved: + return "unresolved" + return "mention" + + @property + def in_migration_surface(self) -> bool: + """True when removing the field requires changing this module.""" + return self.reads or self.writes or self.binds + + +def _literal_key(node: ast.AST) -> str | None: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + return None + + +def python_field_forms(tree: ast.AST, fields: frozenset[str]) -> dict[str, set[str]]: + """Recognized forms per field in one parsed Python module.""" + found: dict[str, set[str]] = {} + # Constants consumed as a literal key. Whatever is left over is the field + # name travelling as data, which is how a computed access is written. + keyed: set[int] = set() + + def record(field: str, form: str) -> None: + found.setdefault(field, set()).add(form) + + for node in ast.walk(tree): + if isinstance(node, ast.Subscript): + key = _literal_key(node.slice) + if key in fields: + keyed.add(id(node.slice)) + record(key, "subscript_write" if isinstance(node.ctx, (ast.Store, ast.Del)) else "subscript_read") + elif isinstance(node, ast.Call): + function = node.func + if isinstance(function, ast.Attribute) and node.args: + key = _literal_key(node.args[0]) + if key in fields and function.attr in MAPPING_READ_CALLS: + keyed.add(id(node.args[0])) + record(key, "mapping_call_read") + elif key in fields and function.attr in MAPPING_WRITE_CALLS: + keyed.add(id(node.args[0])) + record(key, "mapping_call_write") + for keyword in node.keywords: + if keyword.arg in fields: + record(keyword.arg, "keyword_argument") + elif isinstance(node, ast.Dict): + for key_node in node.keys: + key = _literal_key(key_node) if key_node is not None else None + if key in fields: + keyed.add(id(key_node)) + record(key, "dict_literal_key") + elif isinstance(node, ast.Compare): + key = _literal_key(node.left) + if key in fields and any(isinstance(op, (ast.In, ast.NotIn)) for op in node.ops): + keyed.add(id(node.left)) + record(key, "membership_read") + elif isinstance(node, ast.Attribute): + if node.attr in fields: + record(node.attr, "attribute_write" if isinstance(node.ctx, (ast.Store, ast.Del)) else "attribute_read") + elif isinstance(node, ast.arg): + if node.arg in fields: + record(node.arg, "parameter") + elif isinstance(node, ast.Name): + if node.id in fields: + record(node.id, "local_binding" if isinstance(node.ctx, (ast.Store, ast.Del)) else "local_reference") + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + if node.name in fields: + record(node.name, "definition") + elif isinstance(node, ast.ImportFrom): + parts = set((node.module or "").split(".")) + for field in fields: + if field in parts or any(alias.name == field for alias in node.names): + record(field, "module_import") + elif isinstance(node, ast.Import): + for alias in node.names: + parts = set(alias.name.split(".")) + for field in fields & parts: + record(field, "module_import") + for node in ast.walk(tree): + key = _literal_key(node) + if key in fields and id(node) not in keyed: + record(key, "name_constant") + return found + + +def python_dynamic_mapping_keys(tree: ast.AST) -> int: + """Count mapping accessor calls whose key is computed rather than literal. + + These are the sites that make "zero readers" a measurement rather than a + proof: the key is decided at runtime, so the field being read is unknown to + any scan over names. + """ + total = 0 + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not node.args: + continue + function = node.func + if not isinstance(function, ast.Attribute): + continue + if function.attr not in (MAPPING_READ_CALLS | MAPPING_WRITE_CALLS): + continue + if _literal_key(node.args[0]) is None: + total += 1 + return total + + +def _blank_strings_and_comments(text: str) -> tuple[str, list[str]]: + """Split a TypeScript module into code text and its string literals. + + String and comment bodies are replaced by a placeholder of the same length + so line structure survives for the line-anchored patterns, while the field + names inside them stop matching code forms. + """ + literals: list[str] = [] + + def mask(body: str) -> str: + return "".join("\x00" if character != "\n" else "\n" for character in body) + + def blank_literal(match: re.Match[str]) -> str: + literals.append(match.group(0)) + return mask(match.group(0)) + + without_comments = _TS_COMMENT.sub(lambda match: mask(match.group(0)), text) + code = _TS_STRING.sub(blank_literal, without_comments) + return code, literals + + +def typescript_field_forms(text: str, fields: frozenset[str]) -> dict[str, set[str]]: + """Recognized forms per field in one TypeScript module, by bounded grammar.""" + found: dict[str, set[str]] = {} + code, literals = _blank_strings_and_comments(text) + # Subscript keys are string literals, which blanking removed, so they are + # matched against the original text. A quoted key inside prose would be an + # indexing expression there too, so this does not reintroduce the path-label + # false positive that blanking exists to remove. + for field in fields: + quoted = re.escape(field) + for form, template in _TS_READ_PATTERNS: + pattern = template.format(field=quoted) + subject = text if form.startswith("subscript") else code + reads = [match.start() for match in re.finditer(pattern, subject, re.MULTILINE)] + if not reads: + continue + writes = {match.start() for match in re.finditer(pattern + _TS_WRITE_SUFFIX, subject, re.MULTILINE)} + if writes: + found.setdefault(field, set()).add(form.replace("_read", "_write")) + if set(reads) - writes: + found.setdefault(field, set()).add(form) + if re.search(_TS_OBJECT_KEY.format(field=quoted), code, re.MULTILINE): + found.setdefault(field, set()).add("object_key") + token = re.compile(rf"(? int: + """The pre-B3 metric: modules whose text contains the standalone token.""" + token = re.compile(rf"(? tuple[list[FieldUse], int]: + """Classify every module's use of each field; also return the unresolved count.""" + wanted = frozenset(fields) + uses: list[FieldUse] = [] + dynamic_sites = 0 + for source in sources: + if source.suffix == ".py": + try: + tree = ast.parse(source.text) + except SyntaxError: + # An unparseable tracked module is a measurement gap, not a + # module without readers; fall back to the token so the field + # is not silently credited with one fewer mention. + for field in wanted: + if lexical_module_count(field, ".py", [source]): + uses.append(FieldUse(field=field, module=source.path, forms=frozenset({"prose"}))) + continue + dynamic_sites += python_dynamic_mapping_keys(tree) + forms = python_field_forms(tree, wanted) + elif source.suffix == ".ts": + forms = typescript_field_forms(source.text, wanted) + else: + continue + for field in wanted: + recognized = forms.get(field, set()) + if not recognized and lexical_module_count(field, source.suffix, [source]): + # The token is present but no recognized form carries it: a + # comment, a docstring, or prompt prose. + recognized = {"prose"} + if not recognized: + continue + unclassified = recognized - USE_FORMS + if unclassified: + # A form with no role would disappear from the role counts while + # still carrying the token, breaking the partition the ledger + # check relies on. Fail where the form was added, not there. + raise ValueError(f"unclassified field-use form(s): {sorted(unclassified)}") + uses.append(FieldUse(field=field, module=source.path, forms=frozenset(recognized))) + return sorted(uses, key=lambda use: (use.field, use.module)), dynamic_sites + + +ROLES = ("reader", "writer", "binding", "unresolved", "mention") + + +def field_use_summary(fields: Iterable[str], sources: Iterable[SourceFile]) -> dict[str, Any]: + """Per-field role counts and migration surface, beside the old token count. + + ``migration_surface`` is the number of modules that must change before the + field can be removed: every reader, writer and binding. Mentions are prose + and imports, and ``unresolved`` modules carry the field name as data, so + they are reported separately rather than folded into a budget that would + then move when a comment is reworded. + """ + materialized = list(sources) + ordered = sorted(fields) + uses, dynamic_sites = scan_field_uses(ordered, materialized) + summary: dict[str, Any] = {"fields": {}, "dynamic_mapping_key_sites": dynamic_sites} + for field in ordered: + entry: dict[str, Any] = {} + for suffix, runtime in ((".py", "python"), (".ts", "typescript")): + selected = [use for use in uses if use.field == field and use.module.endswith(suffix)] + roles = [use.role for use in selected] + for role in ROLES: + entry[f"{runtime}_{role}_modules"] = roles.count(role) + entry[f"{runtime}_migration_surface"] = sum(1 for use in selected if use.in_migration_surface) + entry[f"{runtime}_token_modules"] = lexical_module_count(field, suffix, materialized) + summary["fields"][field] = entry + return summary + + +def render_field_uses(uses: Iterable[FieldUse]) -> list[str]: + """One reviewable line per module, for ``--report``.""" + return [ + f"{use.field} {use.role} {use.module} [{','.join(sorted(use.forms))}]" + for use in uses + ] diff --git a/loopx/semantics/vocabulary_v0.json b/loopx/semantics/vocabulary_v0.json index 203c8ddf9f..f4779c1e1f 100644 --- a/loopx/semantics/vocabulary_v0.json +++ b/loopx/semantics/vocabulary_v0.json @@ -850,31 +850,43 @@ }, "retirement_ledger": { "should_run_legacy_decision_fields": { - "meaning": "Decision fields the should-run documentation already calls legacy. Budgets count modules under loopx/ that still mention the field.", + "meaning": "Decision fields the should-run documentation already calls legacy. module_budget counts modules under loopx/ whose text carries the standalone field token; migration_surface counts the modules that read, write or bind it and must change before removal. Both are budgeted; the token count stays until Q11 decides whether the syntactic metric replaces it.", "fields": { "execution_obligation": { "python_module_budget": 20, - "typescript_module_budget": 1 + "typescript_module_budget": 1, + "python_migration_surface": 15, + "typescript_migration_surface": 0 }, "heartbeat_recommendation": { "python_module_budget": 17, - "typescript_module_budget": 1 + "typescript_module_budget": 1, + "python_migration_surface": 13, + "typescript_migration_surface": 1 }, "work_lane_contract": { "python_module_budget": 29, - "typescript_module_budget": 3 + "typescript_module_budget": 3, + "python_migration_surface": 28, + "typescript_migration_surface": 3 }, "external_evidence_observation": { "python_module_budget": 8, - "typescript_module_budget": 1 + "typescript_module_budget": 1, + "python_migration_surface": 6, + "typescript_migration_surface": 1 }, "goal_boundary": { "python_module_budget": 30, - "typescript_module_budget": 2 + "typescript_module_budget": 2, + "python_migration_surface": 15, + "typescript_migration_surface": 1 }, "protocol_action_packet": { "python_module_budget": 5, - "typescript_module_budget": 2 + "typescript_module_budget": 2, + "python_migration_surface": 5, + "typescript_migration_surface": 2 } } } From 2aa7ff501988ed84407ec7389cff4d5c5819fab0 Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:23:47 -0400 Subject: [PATCH 02/11] test(semantics): separate the populations the token count merged The metric only earns its smaller numbers if the separations it claims hold on counterexamples, so the fixtures are the pairs the token count could not tell apart: a literal-key read against a same-prefix identifier, a pure write against a read, a parameter that carries the value against prose that only names it, and a field name travelling as data against a field that is absent. `goal_boundary_repair` produces no use of `goal_boundary` in any position. A module that both reads and writes stays in the migration surface as a reader. A `.ts` path label in a string is a mention, not a property read, and `capsule.goal_boundary = projection` is a writer rather than a reader of the expression it assigns to. On the smoke side, a new reader beyond budget fails, and a surface budget cannot move without its anchor. Refs #4447 (B3) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- tests/architecture/test_semantic_field_use.py | 158 ++++++++++++++++++ .../test_semantic_vocabulary_drift.py | 39 +++++ 2 files changed, 197 insertions(+) create mode 100644 tests/architecture/test_semantic_field_use.py diff --git a/tests/architecture/test_semantic_field_use.py b/tests/architecture/test_semantic_field_use.py new file mode 100644 index 0000000000..7e3522af6a --- /dev/null +++ b/tests/architecture/test_semantic_field_use.py @@ -0,0 +1,158 @@ +"""Pin the B3 retirement metric against finite counterexamples. + +The metric replaces a token count with a syntactic role, so the tests that +matter are the ones that separate populations the token count merged: a reader +from a writer, a same-prefix identifier from the field, prose from an access, +and a computed key from an absent one. +""" + +from __future__ import annotations + +import ast + +import pytest + +from loopx.semantics.field_use import ( + ROLES, + field_use_summary, + python_dynamic_mapping_keys, + python_field_forms, + scan_field_uses, + typescript_field_forms, +) +from loopx.semantics.inventory import SourceFile + +FIELD = "goal_boundary" +FIELDS = frozenset({FIELD}) + + +def python(text: str) -> dict[str, set[str]]: + return python_field_forms(ast.parse(text), FIELDS) + + +def source(text: str, suffix: str = ".py", path: str = "loopx/probe") -> SourceFile: + return SourceFile(path + suffix, suffix, text) + + +def role(text: str, suffix: str = ".py") -> str: + uses, _ = scan_field_uses([FIELD], [source(text, suffix)]) + assert len(uses) == 1, uses + return uses[0].role + + +@pytest.mark.parametrize("text", [ + 'value = payload["goal_boundary"]', + 'value = payload.get("goal_boundary")', + 'value = payload.pop("goal_boundary", None)', + 'present = "goal_boundary" in payload', + 'value = route.goal_boundary', +]) +def test_literal_key_reads_are_readers(text: str) -> None: + assert role(text) == "reader" + + +@pytest.mark.parametrize("text", [ + 'payload["goal_boundary"] = built', + 'payload = {"goal_boundary": built}', + 'emit(goal_boundary=built)', + 'payload.setdefault("goal_boundary", {})', + 'route.goal_boundary = None', +]) +def test_literal_key_writes_are_writers(text: str) -> None: + assert role(text) == "writer" + + +def test_a_module_that_reads_and_writes_counts_as_a_reader_and_stays_in_the_surface() -> None: + text = 'def project(payload):\n payload["goal_boundary"] = payload.get("goal_boundary")\n' + uses, _ = scan_field_uses([FIELD], [source(text)]) + assert uses[0].reads and uses[0].writes + assert uses[0].role == "reader" and uses[0].in_migration_surface + + +@pytest.mark.parametrize("text", [ + 'def build(goal_boundary):\n return goal_boundary\n', + 'goal_boundary = collect()\nreturn goal_boundary\n', +]) +def test_named_parameters_and_locals_are_bindings_in_the_migration_surface(text: str) -> None: + uses, _ = scan_field_uses([FIELD], [source(text)]) + assert uses[0].role == "binding" + assert uses[0].in_migration_surface, "a signature carrying the field still has to change" + + +@pytest.mark.parametrize("text", [ + '"""The quota guard publishes goal_boundary for the agent."""', + '# goal_boundary is described in the should-run docs', + 'note = "read quota.goal_boundary.capabilities before spending"', +]) +def test_prose_is_a_mention_and_never_enters_the_migration_surface(text: str) -> None: + uses, _ = scan_field_uses([FIELD], [source(text)]) + assert uses[0].role == "mention" + assert not uses[0].in_migration_surface + + +def test_a_field_name_carried_as_data_is_unresolved_rather_than_absent() -> None: + text = 'LEGACY = ["goal_boundary", "work_lane_contract"]\nfor field in LEGACY:\n payload.get(field)\n' + uses, _ = scan_field_uses([FIELD], [source(text)]) + assert uses[0].role == "unresolved" + assert "name_constant" in uses[0].forms + assert not uses[0].in_migration_surface, ( + "an unresolved module is not known to need migration; it is known to be unproven" + ) + + +def test_the_same_prefix_identifier_is_not_a_use_of_the_field() -> None: + text = ( + 'value = payload["goal_boundary_repair"]\n' + 'other = payload.get("repair_goal_boundary")\n' + 'route.goal_boundary_repair = None\n' + ) + assert python(text) == {} + uses, _ = scan_field_uses([FIELD], [source(text)]) + assert uses == [], "goal_boundary_repair must not be counted as a reader of goal_boundary" + + +def test_computed_keys_are_counted_as_the_standing_unknown() -> None: + tree = ast.parse('payload.get(name)\npayload.get("goal_boundary")\nrows[index]\npayload.pop(key, None)\n') + assert python_dynamic_mapping_keys(tree) == 2, ( + "literal keys are attributable and sequence indexing is not a mapping access" + ) + + +def test_an_unparseable_module_is_recorded_rather_than_dropped() -> None: + uses, _ = scan_field_uses([FIELD], [source('def broken(:\n "goal_boundary"\n')]) + assert [use.role for use in uses] == ["mention"] + + +@pytest.mark.parametrize("text, expected", [ + ("const source = object(payload.goal_boundary);", "reader"), + ('const source = payload["goal_boundary"];', "reader"), + ("capsule.goal_boundary = projection;", "writer"), + ('capsule["goal_boundary"] = projection;', "writer"), + (" goal_boundary: JsonObject;", "mention"), + ('report("decision.goal_boundary", value);', "mention"), + ("// goal_boundary is projected downstream", "mention"), + ("const repaired = payload.goal_boundary_repair;", None), +]) +def test_typescript_forms_are_classified_by_the_bounded_grammar(text: str, expected: str | None) -> None: + uses, _ = scan_field_uses([FIELD], [source(text, ".ts")]) + assert [use.role for use in uses] == ([expected] if expected else []) + + +def test_typescript_string_paths_do_not_become_property_reads() -> None: + forms = typescript_field_forms('log("decision.goal_boundary");', FIELDS) + assert forms[FIELD] == {"prose"} + + +def test_roles_partition_the_token_count_so_the_metric_reclassifies_one_population() -> None: + sources = [ + source('value = payload["goal_boundary"]', path="loopx/reader"), + source('payload["goal_boundary"] = built', path="loopx/writer"), + source('def build(goal_boundary):\n return goal_boundary\n', path="loopx/binding"), + source('LEGACY = ["goal_boundary"]', path="loopx/unresolved"), + source('# goal_boundary', path="loopx/mention"), + source('value = payload["goal_boundary_repair"]', path="loopx/unrelated"), + ] + summary = field_use_summary([FIELD], sources)["fields"][FIELD] + classified = sum(summary[f"python_{role}_modules"] for role in ROLES) + assert classified == summary["python_token_modules"] == 5 + assert summary["python_migration_surface"] == 3 diff --git a/tests/architecture/test_semantic_vocabulary_drift.py b/tests/architecture/test_semantic_vocabulary_drift.py index 9855b8257e..a3e11c070d 100644 --- a/tests/architecture/test_semantic_vocabulary_drift.py +++ b/tests/architecture/test_semantic_vocabulary_drift.py @@ -387,3 +387,42 @@ def test_live_inventory_ignores_missing_or_stale_reports(tmp_path, monkeypatch, for name in ("first", "second")] with pytest.raises(smoke["Drift"], match="same_runtime_forks grew"): smoke["check_inventory"](registry, sources + duplicate) + + +def _retirement_registry(python_surface: int, typescript_surface: int) -> dict: + """A one-field ledger shaped like the real one, for the B3 budget checks.""" + return {"retirement_ledger": {"should_run_legacy_decision_fields": {"fields": { + "protocol_action_packet": { + "python_module_budget": 5, + "typescript_module_budget": 2, + "python_migration_surface": python_surface, + "typescript_migration_surface": typescript_surface, + }, + }}}} + + +def test_a_new_reader_of_a_legacy_field_exceeds_its_migration_surface() -> None: + smoke = runpy.run_path(str(SMOKE)) + readers = [ + smoke["SourceFile"](f"loopx/probe_{index}.py", ".py", 'value = payload["protocol_action_packet"]') + for index in range(6) + ] + with pytest.raises(smoke["Drift"], match="modules migrated"): + smoke["check_reader_metric"](_retirement_registry(5, 2), readers) + + +def test_migration_surface_budget_cannot_move_without_its_anchor() -> None: + smoke = runpy.run_path(str(SMOKE)) + with pytest.raises(smoke["Drift"], match="MIGRATION_SURFACE_ANCHOR"): + smoke["check_reader_metric"](_retirement_registry(6, 2), []) + + +def test_prose_and_same_prefix_identifiers_do_not_consume_the_migration_surface() -> None: + smoke = runpy.run_path(str(SMOKE)) + sources = [ + smoke["SourceFile"]("loopx/prose.py", ".py", '"""protocol_action_packet is published downstream."""'), + smoke["SourceFile"]("loopx/prefix.py", ".py", 'value = payload["protocol_action_packet_v2"]'), + ] + report, detail = smoke["check_reader_metric"](_retirement_registry(5, 2), sources) + assert any("surface=0/5" in line and "mention=1" in line for line in detail), detail + assert any("dynamic_mapping_key_sites=0" in line for line in report), report From 946cadff7790e4e3cb512c12b85e2d6d78604ef7 Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:23:47 -0400 Subject: [PATCH 03/11] docs(semantics): record what the retirement metric measures and still cannot Section 5 gains the two-metric ledger shape, Section 9 the three rows that check it, Section 11 the measured surface beside the historical mention count, and the M3 gate now reads "empty the migration surface and review the residue" rather than "zero external readers", which no name-keyed scan can establish. Q11 is narrowed to what is actually undecided: whether the token budget retires once the surface budget orders a removal, and what a field at zero surface still owes against 1704 computed-key sites. Appendix A carries the per-field table and the three results the token count hid. Appendix B records the decision and the two rejected alternatives: replacing the token budget outright, which would leave the smaller number unauditable in the same diff that introduces it, and counting computed-key subscripts as unresolved reads. Both mirrors carry the same rows. Refs #4447 (B3) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- .../semantic-vocabulary-convergence-v0.md | 63 +++++++++++++++++-- ...emantic-vocabulary-convergence-v0.zh-CN.md | 57 +++++++++++++++-- 2 files changed, 109 insertions(+), 11 deletions(-) diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md index cb96d9e580..771e144c14 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md @@ -631,7 +631,7 @@ vocabulary key fails the smoke. | `relations.subsets` | superset vocabulary, excluded values, owners of the subset symbol | Owner symbols equal superset minus excluded | | `projections..mapping` | source value to target value or `null` | Keys equal the source vocabulary; mapped values match the owner function; `null` routes raise (I4) | | `schema_versions.` | constant name, value, owner modules | The only defining modules are the listed owners and all carry the value (I1) | -| `retirement_ledger..fields` | per-field Python and TypeScript module budgets | Actual module counts are at or below budget, and the field set and every budget match `RETIREMENT_ANCHOR` (I5) | +| `retirement_ledger..fields` | per-field Python and TypeScript budgets under two metrics: `*_module_budget` counts modules carrying the field token, `*_migration_surface` counts the modules that read, write or bind it | Actual counts are at or below both budgets; the field set and every token budget match `RETIREMENT_ANCHOR`, every surface budget matches `MIGRATION_SURFACE_ANCHOR`, and the five syntactic roles partition the token count exactly (I5) | | `dual_runtime_twins` | root and module budget | Tracked same-basename `.py`/`.ts` pair count is at or below budget; root and budget equal their code anchors (I5) | | `inventory_ratchets` | budgets for same-runtime fork names and definitions, conflicting names and definitions, schema-version forks, multi-value twins and forks, and the shared-vocabulary conflict and fork subsets | Inventory summary counts are at or below budget, and each budget equals its `BUDGET_ANCHOR` entry (I5, I9) | @@ -739,6 +739,9 @@ on the next full-tree scan; genuine shared-contract changes still need review. | No behavior change from the two owner fixes | `uv run --extra test python -m pytest tests/test_loopx_turn_transaction.py tests/test_loop_turn_loop_controller.py tests/test_turn_loop_disposition.py tests/test_loopx_turn_managed_step.py tests/control_plane -k authority` and `uv run --extra test loopx canary premerge --from-git-diff` | pass | Environment failures already present on `main` are excluded when reproduced on a clean tree | | Docs governance accepts the RFC pair | `python3 examples/docs-governance-smoke.py` | pass | Checks mirror, links, index | | Retirement budgets use standalone field tokens | `count_identifier_modules()` uses identifier boundaries for the six fields | `goal_boundary`: 30 Python modules under the new metric; the old substring metric was 35 | Conservative lexical measure; it removes compound-name false positives but does not prove semantic reader absence | +| The retirement metric separates readers from mentions (B3) | `check_reader_metric()` classifies every module carrying one of the six field tokens as reader, writer, binding, unresolved or mention | `goal_boundary`: 30 token modules resolve to 8 readers, 4 writers, 3 bindings, 1 unresolved and 14 mentions, so its migration surface is 15, not 30; `work_lane_contract` stays at 28 of 29 | Syntactic use, not data flow. The roles are asserted to partition the token count, so the smaller number is a reclassification of the same modules and not a different population | +| A new reader of a legacy field fails the pull-request path | Add a module reading `payload["protocol_action_packet"]` beyond the budget | `check_reader_metric` fails naming the field and the count | Committed fixture in `tests/architecture/test_semantic_vocabulary_drift.py`; the anchor equality check is the same pattern as `RETIREMENT_ANCHOR` | +| A computed key stays unresolved rather than absent | Count mapping accessors whose first argument is not a literal | 1704 sites under `loopx/`; a field measured at zero readers is measured against that standing unknown | This is why zero readers cannot by itself authorize a removal (Q11). Subscripts with a computed key are excluded: `rows[index]` and `payload[key]` are the same syntax | | The module-local convention filter is a code edit | Widen `MODULE_LOCAL_CONVENTION` in `inventory.py` and scan | `*_semantic` budgets fall with no code change elsewhere | Known boundary; the regex is in code so the widening is a reviewed diff, and the unfiltered totals stay budgeted | | A registered value nobody produces fails (M0.5) | Run the production-form scan on the baseline | Fails naming `effective_action` and `skip`; passes after `skip` is removed or listed `compatibility_only` | First expected I12 failure; a compared-only value is not carried | | A producer of an unregistered value fails (M0.5) | Write `effective_action: "brand_new"` in a listed producer site | Fails naming the site and the value even though no consumer compares it | I13; production is stricter than comparison | @@ -852,7 +855,7 @@ with `npm ci --ignore-scripts` before running the TypeScript production scan. | M0.5b | `producers` and `compatibility_only` on `kernel` vocabularies; production-form scan with the two role checks (I12, I13); retirement budgets counted by identifier with all six anchors lowered in one diff (Q11); merge-order rule from Q9 written into Section 10 | M0.5a complete; Q9 decided or its interim rule accepted | Smoke green with I11 to I14 enforced; `skip` resolved; Section 9 producer rows green; `turn_route` persistence answered for Q2 | Remove producer fields and role checks; budgets return to the pre-M0.5b anchors | | M1 | `EffectiveAction` typed enum in one owner module; the replay observation and frontier slots split off (Q6); producers and consumers import it; registry `literal_scan` tightened to the enum | M0.5 merged; owner module chosen (Q3); slot split decided (Q6) | Smoke green; zero bare `effective_action` literals outside the owner; parity fixtures for status/should-run unchanged | Revert to literals; registry keeps the set | | M2 | Route-to-disposition projection, the `decide_loop_disposition` decision table, and the cross-runtime sets published through a shared contract with generated Python and TypeScript bindings, following the coordination contract generator | M1 merged; Q2 and Q7 decided | Generator `--check` and smoke green; `settlement.ts` and `transaction.py` read the generated set | Regenerate from prior contract | -| M3 | Per-field retirement of legacy should-run fields, one field per PR, budgets lowered to zero and the field removed | Field has zero external readers proven by producer/reader research | Schema-reduction record per `AGENTS.md`; Appendix B entry | Restore field from the last writer | +| M3 | Per-field retirement of legacy should-run fields, one field per PR, budgets lowered to zero and the field removed | Field's migration surface is emptied module by module, and the residual unresolved and dynamic-key evidence is reviewed; a zero count is not by itself the gate | Schema-reduction record per `AGENTS.md`; Appendix B entry | Restore field from the last writer | | M4 | Twin budget lowered with each replacement-first cutover from the migration RFC | Each cutover PR | Budget edit in the same diff | None needed; budget follows code | A ratchet without a target is a direction, not a plan. The table below is the @@ -869,7 +872,7 @@ vocabulary property the smoke can check. Rows marked *open* wait on a Section | Conflicting values, semantic | 2 names | 0 | baseline PRs | | Multi-value forks | 4 (1 misclassified) | 0 after `scope` declares bounded-context names | M0.5 + baseline PRs | | Multi-value twins | 19 | 0 | baseline PRs | -| Legacy should-run fields | 6 fields, 124 py / 10 ts module mentions | 0 fields | M3, identifier-counted | +| Legacy should-run fields | 6 fields, 124 py / 10 ts module mentions; measured 2026-09-17: 109 py / 10 ts token modules, of which 82 py / 8 ts are the migration surface | 0 fields | M3, gated on the B3 migration surface; the token count stays budgeted until Q11 | | Merge-candidate groups | 32 unreviewed | every group classified; only `same_semantics` groups merged | classification PR, then per-group PRs | | Control-plane py/ts twins | 43 | follows the TypeScript migration RFC; no target here | M4 | @@ -1004,14 +1007,62 @@ introduce a competing target state. and generates their projection; it does not merge spellings. A future proposal to merge them must provide a dual-read/versioned migration and reader proof. Owner: Turn driver owner. Needed before M2 closes. -11. **Retirement budgets by identifier.** The six legacy-field budgets now use +11. **Retirement budgets by identifier.** The six legacy-field budgets use `count_identifier_modules()`, so `goal_boundary_repair` is not counted as `goal_boundary`. This is a conservative lexical metric, not proof of zero - semantic readers; computed accesses remain an evidence gap. Owner: kernel + semantic readers. B3 adds `check_reader_metric()` beside it, which splits the + same modules into readers, writers, bindings, unresolved name carriers and + mentions and budgets the first three as the migration surface. Both metrics + are now checked. What stays open is whether the token budget is retired once + the surface budget has ordered a removal, and what residual evidence a field + at zero surface still owes given 1704 computed-key sites. Owner: kernel maintainers. ## Appendix A: Execution ledger (non-normative) +### 2026-09-17 — B3: the retirement metric separates readers from mentions + +The six legacy should-run fields were budgeted by a token count: modules whose +text contains the standalone field name. That number answers "does this name +appear here", which is not the question a retirement asks. `check_reader_metric` +classifies the same modules by syntactic role and budgets the three roles that +have to change before a field can be removed. + +| Field | Python token | reader | writer | binding | unresolved | mention | surface | TS token | surface | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `protocol_action_packet` | 5 | 1 | 4 | 0 | 0 | 0 | 5 | 2 | 2 | +| `external_evidence_observation` | 8 | 4 | 1 | 1 | 1 | 1 | 6 | 1 | 1 | +| `heartbeat_recommendation` | 17 | 8 | 4 | 1 | 0 | 4 | 13 | 1 | 1 | +| `execution_obligation` | 20 | 8 | 7 | 0 | 0 | 5 | 15 | 1 | 0 | +| `work_lane_contract` | 29 | 11 | 8 | 9 | 1 | 0 | 28 | 3 | 3 | +| `goal_boundary` | 30 | 8 | 4 | 3 | 1 | 14 | 15 | 2 | 1 | + +Three results the token count had hidden: + +- `goal_boundary` and `work_lane_contract` were within one module of each other + at 30 and 29, so the plan ordered them as equally expensive. Their real + surfaces are 15 and 28. Fourteen of `goal_boundary`'s modules are prompt prose + and module-path imports that no migration touches. +- `protocol_action_packet` has one Python reader and four writers. It is the + cheapest first M3 removal, and the token count did not say so. +- 1704 mapping accessors under `loopx/` take a computed key. No name-keyed scan, + lexical or syntactic, can attribute them, so the smoke prints that number + beside the per-field counts. This is the measured form of "a zero count does + not authorize a deletion"; the residual obligation is Q11's. + +The roles are asserted to partition the token count exactly, per field and per +runtime, on every run. The new metric therefore reclassifies one population +rather than measuring a smaller one, and this slice repays no debt: both +budgets are pinned at their measured values in the same diff. + +TypeScript is scanned by bounded grammar over code text whose string literals +and comments are blanked first, because the path label +`"decision.heartbeat_recommendation"` would otherwise count as a property read. +A bare `field:` at the head of a line is reported as a mention, not a write: an +interface member and an object-literal entry are the same shape to this grammar, +and crediting a declaration as production would overstate it in exactly the +direction I13 warns about. + ### 2026-09-16 — B2 pilot: one re-export hop bound in the Python producer scanner - **Trigger:** after M2 moved the three Turn owners into @@ -1193,6 +1244,7 @@ introduce a competing target state. | --- | --- | --- | --- | --- | | 2026-09-16 | Q9: compute the full inventory on demand; retire the committed census | Implementation for [maintainer feedback](https://github.com/huangruiteng/loopx/pull/4360#issuecomment-5692062394); PR review pending | Committed snapshot with post-merge regeneration; diff-only scan rejected | 1, I6, 3, 5, 9, 10, 12 | | 2026-09-16 | B2: bind one unrenamed re-export hop in the Python producer scanner | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2; PR review pending | Require every consumer to import the owner module (fragile; failed silently in M2); unbounded multi-hop resolution rejected | 5, Appendix A | +| 2026-09-17 | B3: budget the migration surface beside the token count; keep both until Q11 | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3; PR review pending | Replacing the token budget outright (rejected: the token count is the anchor that proves the new roles partition the same population, and dropping it in the same diff that introduces them would make the smaller number unauditable); counting computed-key subscripts as unresolved reads (rejected: `rows[index]` and `payload[key]` are one syntax, and the unknown would stop carrying information) | 5, 9, 11, 12 | | 2026-09-16 | B1 rename invariance: add the name-keyed divergence advisory; state the limit it does not close | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B1; PR review pending | Keying the budget on value sets (rejected: `CONFIDENCE_LEVELS` and `EDGE_CASE_COMPLEXITIES` share `high/low/medium` with different meanings); a committed name ledger (rejected at M0: Q9 retired the committed census). The advisory lists surviving forks by name; it was first described as catching a one-sided rename, which measurement disproved, so both mirrors state the limit as it behaves | 9 | ## Appendix C: Evidence registry @@ -1218,6 +1270,7 @@ introduce a competing target state. | E18 | Declared scope exceeded the scan root | `503991dd2` + M0 | `literal_scan.roots` and inventory `root` read from the registry; `grep` for `effective_action` dispatch literals under `examples/`; count of `.ts`/`.tsx` under `apps/` | roots are `loopx` only; 12+ assertions in `examples/`; 90 files in `apps/` | Consumers and test doubles, not producers | | E19 | `SOURCE_SURFACES` is four bounded contexts, not a fork | `503991dd2` | the four `multi_value_forks` definitions read from the inventory | each module lists the data sources of its own CLI command with disjoint values | Judgement from reading the values; the rule cannot make it | | E20 | Retirement budgets over-count by substring | `503991dd2` | `'goal_boundary' in text` vs `\bgoal_boundary\b` over `loopx/**/*.py` | 35 vs 30 modules | Identifier count is the M3 gate's measure | +| E21 | The retirement budget counted mentions as readers | `897e9aedb` | `check_reader_metric()` over the six legacy fields; roles asserted to partition `count_identifier_modules()` | 109 py token modules resolve to 82 surface modules; `goal_boundary` 30 → 15, `work_lane_contract` 29 → 28, `protocol_action_packet` 5 → 5 with one reader | Syntactic use, not data flow; 1704 computed-key mapping accessors stay unattributable, so zero surface is not zero readers | | E13 | The conflict budget mostly measured local naming | `1dc6ad8d8` | `MODULE_LOCAL_CONVENTION` applied to `conflicting_values` and `same_runtime_forks` names | 16 of 18 conflicts and 7 of 25 forks are module-local conventions; the semantic subsets are 2 and 18 | Classification is a name pattern, documented in the scanner and pinned by a fixture test | ## Appendix D: Rejected or superseded alternatives 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 05dfc8f3b9..c469092595 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md @@ -513,7 +513,7 @@ external_input | compatibility_only | unknown | `relations.subsets` | 超集词表、排除值、子集符号的 owner | owner 符号等于超集减排除值 | | `projections..mapping` | 源值到目标值或 `null` | 键等于源词表;映射值与 owner 函数一致;`null` 路由抛出(I4) | | `schema_versions.` | 常量名、值、owner 模块 | 唯一的定义模块就是列出的 owner 且都携带该值(I1) | -| `retirement_ledger..fields` | 每字段的 Python 与 TypeScript 模块预算 | 实际模块数不超过预算,且字段集合与每个预算与 `RETIREMENT_ANCHOR` 一致(I5) | +| `retirement_ledger..fields` | 每字段在两种指标下的 Python 与 TypeScript 预算:`*_module_budget` 统计携带该字段 token 的模块,`*_migration_surface` 统计真正读、写或以形参/局部名承载它的模块 | 两个实测值都不超过各自预算;字段集合与每个 token 预算与 `RETIREMENT_ANCHOR` 一致,每个迁移面预算与 `MIGRATION_SURFACE_ANCHOR` 一致,且五种句法角色恰好划分 token 计数(I5) | | `dual_runtime_twins` | 根目录与模块预算 | 同名 `.py`/`.ts` 对数不超过预算(I5) | | `inventory_ratchets` | 同运行时分叉的名字数与定义数、冲突的名字数与定义数、schema 版本分叉数、多值孪生与分叉数,以及共享词表冲突与分叉子集的预算 | 清单摘要计数不超过预算,且每个预算必须等于其 `BUDGET_ANCHOR` 条目(I5、I9) | @@ -604,6 +604,9 @@ TypeScript `as const` 数组,以及拆为跨运行时孪生、同运行时分 | 两处 owner 修正不改变行为 | `uv run --extra test python -m pytest tests/test_loopx_turn_transaction.py tests/test_loop_turn_loop_controller.py tests/test_turn_loop_disposition.py tests/test_loopx_turn_managed_step.py tests/control_plane -k authority` 与 `uv run --extra test loopx canary premerge --from-git-diff` | 通过 | 在干净树上可复现的 `main` 既有环境失败除外 | | 文档治理接受这对 RFC | `python3 examples/docs-governance-smoke.py` | 通过 | 检查镜像、链接、索引 | | 退休预算按子串而非标识符计数 | 分别以 `in file.text` 与 `\bgoal_boundary\b` 统计 `goal_boundary` | 基线上 35 对 30 个 Python 模块 | 已知边界;M3 的零读者门需要标识符计数,见第 12 节 | +| 退休指标把读者与提及分开(B3) | `check_reader_metric()` 把携带六个字段 token 的每个模块归为 reader、writer、binding、unresolved 或 mention | `goal_boundary`:30 个 token 模块解析为 8 读、4 写、3 承载、1 未定、14 提及,迁移面是 15 而非 30;`work_lane_contract` 仍是 29 中的 28 | 度量的是句法使用,不是数据流。角色被断言恰好划分 token 计数,因此更小的数字是同一批模块的重新分类,不是另一批更小的样本 | +| 旧字段新增读者会在 PR 路径上失败 | 让一个模块读 `payload["protocol_action_packet"]` 从而超出预算 | `check_reader_metric` 失败并点名该字段与计数 | `tests/architecture/test_semantic_vocabulary_drift.py` 内的提交测试;锚点等值检查与 `RETIREMENT_ANCHOR` 同一套模式 | +| 计算式键保持「未定」而非「不存在」 | 统计首参数不是字面量的 mapping 访问器 | `loopx/` 下 1704 处;某字段读者计为零时,是对着这个公开的未知数计零 | 这正是零读者本身不能授权删除的原因(Q11)。计算式下标不计入:`rows[index]` 与 `payload[key]` 是同一种语法 | | 模块局部约定过滤器是一次代码修改 | 扩宽 `inventory.py` 的 `MODULE_LOCAL_CONVENTION` 并重新生成 | `*_semantic` 预算下降而别处无代码改动 | 已知边界;正则在代码里,扩宽是可评审的 diff,未过滤总数仍在预算内 | | 无人生产的注册值失败(M0.5) | 在基线上运行生产形式扫描 | 失败并点名 `effective_action` 与 `skip`;删除 `skip` 或列入 `compatibility_only` 后通过 | 第一个预期的 I12 失败;只被比较的值不算已携带 | | 生产未注册值失败(M0.5) | 在某个已列生产位点写 `effective_action: "brand_new"` | 即使无消费者比较它也失败,并点名位点与值 | I13;生产比比较更严 | @@ -698,7 +701,7 @@ TypeScript effective-action 绑定与[术语表](../../reference/glossary.md)通 | M0.5b | `kernel` 词表的 `producers` 与 `compatibility_only`;带两条角色检查(I12、I13)的生产形式扫描;退休预算改按标识符计数并在一个 diff 里调整六个锚点(Q11);Q9 的合并序规则写入第 10 节 | M0.5a 完成;Q9 已决或其临时规则被接受 | smoke 在 I11 到 I14 强制下全绿;`skip` 已处理;第 9 节生产者行全绿;为 Q2 回答 `turn_route` 是否持久化 | 删除生产者字段和角色检查;预算回到 M0.5b 前的锚点 | | M1 | 单一 owner 模块中的 `EffectiveAction` 类型化枚举;replay observation 与 frontier 槽位拆出(Q6);生产者与消费者 import 它;注册表 `literal_scan` 收紧到枚举 | M0.5 合入;owner 模块已定(Q3);槽位拆分已决(Q6) | smoke 绿;owner 之外零裸 `effective_action` 字面量;status/should-run 的 parity fixture 不变 | 回退为字面量;注册表保留集合 | | M2 | route 到 disposition 的投影、`decide_loop_disposition` 决策表与跨运行时集合通过共享契约发布,生成 Python 与 TypeScript 绑定,效仿协调契约生成器 | M1 合入;Q2 与 Q7 已决 | 生成器 `--check` 与 smoke 绿;`settlement.ts` 与 `transaction.py` 读取生成集合 | 从上一版契约重新生成 | -| M3 | 逐字段退休旧 should-run 字段,每个 PR 一个字段,预算降到零并删除字段 | 经生产者/读者调研证明该字段外部读者为零 | 按 `AGENTS.md` 的 schema 缩减记录;附录 B 条目 | 从最后一个写方恢复字段 | +| M3 | 逐字段退休旧 should-run 字段,每个 PR 一个字段,预算降到零并删除字段 | 逐模块清空该字段的迁移面,并评审残留的 unresolved 与计算式键证据;计数归零本身不构成这道门 | 按 `AGENTS.md` 的 schema 缩减记录;附录 B 条目 | 从最后一个写方恢复字段 | | M4 | 随迁移 RFC 的每次 replacement-first 切换调低孪生预算 | 每个切换 PR | 同 diff 中的预算修改 | 无需;预算跟随代码 | 没有目标的棘轮只是方向,不是计划。下表是本 RFC 完成时的状态;每一行都是一个 @@ -714,7 +717,7 @@ TypeScript effective-action 绑定与[术语表](../../reference/glossary.md)通 | 冲突值(语义) | 2 个名字 | 0 | 基线窄 PR | | 多值分叉 | 4(1 个误分类) | `scope` 声明有界上下文名字后为 0 | M0.5 + 基线窄 PR | | 多值孪生 | 19 | 0 | 基线窄 PR | -| 旧 should-run 字段 | 6 个字段,124 py / 10 ts 模块提及 | 0 个字段 | M3,按标识符计数 | +| 旧 should-run 字段 | 6 个字段,124 py / 10 ts 模块提及;2026-09-17 实测 109 py / 10 ts 个 token 模块,其中 82 py / 8 ts 属于迁移面 | 0 个字段 | M3,以 B3 迁移面为门;token 计数在 Q11 决策前继续计入预算 | | 合并候选组 | 32 组未评审 | 每组已分类;只合并 `same_semantics` 的组 | 分类表 PR,随后逐组 PR | | 控制面 py/ts 孪生 | 43 | 跟随 TypeScript 迁移 RFC;本 RFC 不设目标 | M4 | @@ -818,13 +821,53 @@ PR review 保留这些层级。普通改动记录检查范围和理由,无共 Q2 建议保留两者。Q2 的实际写入及读回证据证明 `turn_route` 已持久化,因此 实现保留三套不同值集并生成投影,不合并拼法。未来合并提案须提供双读或带版本 的迁移及读者证据。Owner:Turn driver owner。 -11. **退休预算使用独立字段 token。** 六个旧字段预算现在使用 +11. **退休预算使用独立字段 token。** 六个旧字段预算使用 `count_identifier_modules()`,因此 `goal_boundary_repair` 不会被算作 - `goal_boundary`。这是保守的词法指标,不等于证明不存在语义读者;计算式访问 - 仍然是证据缺口。Owner:内核维护者。 + `goal_boundary`。这是保守的词法指标,不等于证明不存在语义读者。B3 在它旁边 + 加入 `check_reader_metric()`:把同一批模块拆成读者、写方、形参/局部承载、 + 未定名字载体与提及,并把前三者作为迁移面纳入预算。两个指标现在都在检查。 + 仍然未决的是:当迁移面预算已经能排序删除工作后,是否退役 token 预算;以及 + 在 1704 处计算式键访问之下,迁移面为零的字段还欠哪些残余证据。 + Owner:内核维护者。 ## 附录 A:执行账本(非规范) +### 2026-09-17 — B3:退休指标把读者与提及分开 + +六个旧 should-run 字段此前按 token 计数计入预算:文本里出现该独立字段名的模块 +数。这个数字回答的是「这个名字在这里出现过吗」,而不是退休所问的问题。 +`check_reader_metric` 把同一批模块按句法角色分类,并把删除该字段前必须改动的 +三种角色纳入预算。 + +| 字段 | Python token | reader | writer | binding | unresolved | mention | 迁移面 | TS token | 迁移面 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `protocol_action_packet` | 5 | 1 | 4 | 0 | 0 | 0 | 5 | 2 | 2 | +| `external_evidence_observation` | 8 | 4 | 1 | 1 | 1 | 1 | 6 | 1 | 1 | +| `heartbeat_recommendation` | 17 | 8 | 4 | 1 | 0 | 4 | 13 | 1 | 1 | +| `execution_obligation` | 20 | 8 | 7 | 0 | 0 | 5 | 15 | 1 | 0 | +| `work_lane_contract` | 29 | 11 | 8 | 9 | 1 | 0 | 28 | 3 | 3 | +| `goal_boundary` | 30 | 8 | 4 | 3 | 1 | 14 | 15 | 2 | 1 | + +token 计数掩盖掉的三个结果: + +- `goal_boundary` 与 `work_lane_contract` 是 30 与 29,只差一个模块,于是计划 + 把两者当作同等代价排序。它们真实的迁移面是 15 与 28。`goal_boundary` 有十四 + 个模块是提示词散文与模块路径导入,迁移根本不会碰到。 +- `protocol_action_packet` 只有一个 Python 读者和四个写方。它是代价最低的首个 + M3 删除对象,而 token 计数说不出这一点。 +- `loopx/` 下有 1704 处 mapping 访问器使用计算式键。任何按名字的扫描——词法的 + 还是句法的——都无法归属它们,因此 smoke 把这个数字与各字段计数一起打印。这 + 就是「计数归零不授权删除」的可测形式;残余义务归 Q11。 + +每次运行都会按字段、按运行时断言这些角色恰好划分 token 计数。因此新指标是对同 +一批模块的重新分类,而不是换了一批更小的样本;本切片也不偿还任何债务:两个预算 +都在同一 diff 里钉在各自的实测值上。 + +TypeScript 用有界文法扫描,且先把字符串字面量与注释抹白再匹配代码文本,否则路 +径标签 `"decision.heartbeat_recommendation"` 会被算成属性读取。行首裸写的 +`field:` 报为提及而非写入:在这套文法看来,接口成员与对象字面量条目形状相同, +把一处声明算作生产会恰好朝 I13 警告的方向高估。 + ### 2026-09-16 — B2 试点:Python producer 扫描器绑定一跳再导出 - **触发:** M2 把三个 Turn owner 迁入 `turn_contract_generated.py` 后,仍经 @@ -970,6 +1013,7 @@ PR review 保留这些层级。普通改动记录检查范围和理由,无共 | --- | --- | --- | --- | --- | | 2026-09-16 | Q9:全树按需计算;移除已提交结构清单 | 根据[维护者反馈](https://github.com/huangruiteng/loopx/pull/4360#issuecomment-5692062394)实现,PR 评审待完成 | 取代合并后补再生成;拒绝只扫描 diff | 1、I6、3、5、9、10、12 | | 2026-09-16 | B2:Python producer 扫描器绑定一跳未改名再导出 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2;PR 评审待完成 | 要求每个消费者都从 owner 模块导入(脆弱;M2 中已静默失效);拒绝无界多跳解析 | 5、附录 A | +| 2026-09-17 | B3:在 token 计数旁边为迁移面设预算;Q11 决策前两者都保留 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3;PR 评审待完成 | 直接用新指标取代 token 预算(否决:token 计数正是证明新角色划分同一批模块的锚,在引入角色的同一个 diff 里把它删掉会让更小的数字无法复核);把计算式下标也计为未定读取(否决:`rows[index]` 与 `payload[key]` 是同一种语法,未知数会大到不再携带信息) | 5、9、11、12 | | 2026-09-16 | B1 改名不变性:新增按名字归组的分歧报告;写明它未闭合的边界 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B1;PR 评审待完成 | 把预算改按值集归组(否决:`CONFIDENCE_LEVELS` 与 `EDGE_CASE_COMPLEXITIES` 共享 `high/low/medium` 而含义不同);提交名字账本(M0 否决:Q9 已退役提交式清单)。该报告列出仍然存在的分叉;初稿称它能抓住单侧改名,实测证否,故两份镜像按真实行为写明边界 | 9 | ## 附录 C:证据登记 @@ -995,6 +1039,7 @@ PR review 保留这些层级。普通改动记录检查范围和理由,无共 | E18 | 声明范围超出扫描根 | `503991dd2` + M0 | 从注册表读 `literal_scan.roots` 与清单 `root`;在 `examples/` 下 `grep` `effective_action` 分发字面量;统计 `apps/` 下 `.ts`/`.tsx` | 根只有 `loopx`;`examples/` 12+ 处断言;`apps/` 90 个文件 | 消费者与测试替身,非生产者 | | E19 | `SOURCE_SURFACES` 是四个有界上下文,不是分叉 | `503991dd2` | 从清单读出四个 `multi_value_forks` 定义 | 每个模块列出自己 CLI 命令的数据来源,值互不相交 | 读值后的判断;规则本身做不出 | | E20 | 退休预算按子串高估 | `503991dd2` | 对 `loopx/**/*.py` 分别用 `'goal_boundary' in text` 与 `\bgoal_boundary\b` | 35 对 30 个模块 | 标识符计数才是 M3 门的度量 | +| E21 | 退休预算把提及算成了读者 | `897e9aedb` | 对六个旧字段运行 `check_reader_metric()`;断言角色划分 `count_identifier_modules()` | 109 个 py token 模块解析为 82 个迁移面模块;`goal_boundary` 30 → 15,`work_lane_contract` 29 → 28,`protocol_action_packet` 5 → 5 且只有一个读者 | 度量句法使用而非数据流;1704 处计算式键 mapping 访问仍无法归属,因此迁移面为零不等于读者为零 | | E13 | 冲突预算主要在度量局部命名 | `1dc6ad8d8` | 对 `conflicting_values` 与 `same_runtime_forks` 名字应用 `MODULE_LOCAL_CONVENTION` | 18 个冲突中 16 个、25 个分叉中 7 个是模块局部约定;语义子集分别为 2 与 18 | 分类是名字模式,已在扫描器中说明并由夹具测试钉住 | ## 附录 D:被否决或取代的方案 From 6645d5e21abdf49b7a4772e0b4ed92ed49151d37 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:12:22 +0800 Subject: [PATCH 04/11] fix(semantics): respect lexical context in legacy field scanning Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- examples/semantic-vocabulary-drift-smoke.py | 11 +---- loopx/semantics/field_use.py | 40 ++++++++++--------- tests/architecture/test_semantic_field_use.py | 19 +++++++++ 3 files changed, 43 insertions(+), 27 deletions(-) diff --git a/examples/semantic-vocabulary-drift-smoke.py b/examples/semantic-vocabulary-drift-smoke.py index 427b4a3b23..ac65da1b47 100755 --- a/examples/semantic-vocabulary-drift-smoke.py +++ b/examples/semantic-vocabulary-drift-smoke.py @@ -40,7 +40,7 @@ ) from loopx.semantics.python_production import scan_python_production # noqa: E402 from loopx.semantics.field_use import ( # noqa: E402 - ROLES, field_use_summary, render_field_uses, scan_field_uses, + ROLES, field_use_summary, lexical_module_count, render_field_uses, scan_field_uses, ) from scripts.generate_semantic_bindings import build_artifacts # noqa: E402 from loopx.canary.maintainability_ratchet import evaluate_maintainability_findings # noqa: E402 @@ -823,14 +823,7 @@ def count_identifier_modules(field: str, suffix: str, sources: list[SourceFile]) remaining occurrence is a reader or that computed accesses are absent. ``check_reader_metric`` splits this same population by syntactic role. """ - pattern = re.compile( - rf"(? tuple[list[str], list[str]]: diff --git a/loopx/semantics/field_use.py b/loopx/semantics/field_use.py index a7dca1c788..dd196993ea 100644 --- a/loopx/semantics/field_use.py +++ b/loopx/semantics/field_use.py @@ -14,7 +14,7 @@ ``setdefault``). Reader and writer are not exclusive: a projection module that reads the legacy field and re-emits it is both. -Three limits are part of the metric, not caveats around it: +These limits are part of the metric, not caveats around it: * A computed key is **unresolved**. ``payload.get(name)`` may read any field, so no name-keyed scan -- lexical or syntactic -- can prove a module is not a @@ -33,10 +33,9 @@ in a docstring would add a mention to that field's own budget. The examples above use ``legacy_field`` for that reason; the real names live in the registry and in the smoke's anchors, both outside the scanned root. -* A mention is evidence of nothing. Prompt prose, a module path component, a - local variable named after the payload it holds and a parameter name all - carry the token without touching the field. They are reported as mentions so - that migration can ignore them and so the residual token count stays visible. +* A mention is evidence of nothing. Prompt prose and module paths carry the + token without a recognized access. Locals and parameters are instead bindings: + they may carry the value through a signature that a migration must inspect. """ from __future__ import annotations @@ -93,8 +92,10 @@ # pure write is kept from also counting as a read. _TS_WRITE_SUFFIX = r"\s*(?:=[^=]|\+=)" _TS_OBJECT_KEY = r"^\s*{field}\??\s*:" -_TS_STRING = re.compile(r"""(?s)(?P["'`])(?:\\.|(?!(?P=q)).)*(?P=q)""") -_TS_COMMENT = re.compile(r"(?s)//[^\n]*|/\*.*?\*/") +_TS_LEXEME = re.compile( + r"""(?P(?P["'`])(?:\\.|(?!(?P=q)).)*(?P=q))|//[^\n]*|/\*.*?\*/""", + re.DOTALL, +) @dataclass(frozen=True) @@ -151,8 +152,8 @@ def python_module_scan(tree: ast.AST, fields: frozenset[str]) -> tuple[dict[str, Both come from one walk. The scan runs over every tracked Python module on every pull request that touches ``loopx/``, so a second traversal is a cost - paid by everyone; the shared ``parse_python`` cache exists for the same - reason. + paid by everyone. Parsing errors share the inventory's ``parse_python`` + boundary; ASTs are deliberately not retained in a cache. """ found: dict[str, set[str]] = {} # Constants consumed as a literal key. Whatever is left over is the field @@ -242,12 +243,14 @@ def _blank_strings_and_comments(text: str) -> tuple[str, list[str]]: def mask(body: str) -> str: return "".join("\x00" if character != "\n" else "\n" for character in body) - def blank_literal(match: re.Match[str]) -> str: - literals.append(match.group(0)) + def blank_lexeme(match: re.Match[str]) -> str: + if match.group("string") is not None: + literals.append(match.group(0)) return mask(match.group(0)) - without_comments = _TS_COMMENT.sub(lambda match: mask(match.group(0)), text) - code = _TS_STRING.sub(blank_literal, without_comments) + # Match in source order: comment delimiters inside a quoted URL are data, + # and quotes inside a comment cannot open a string in the following code. + code = _TS_LEXEME.sub(blank_lexeme, text) return code, literals @@ -256,18 +259,19 @@ def typescript_field_forms(text: str, fields: frozenset[str]) -> dict[str, set[s found: dict[str, set[str]] = {} code, literals = _blank_strings_and_comments(text) # Subscript keys are string literals, which blanking removed, so they are - # matched against the original text. A quoted key inside prose would be an - # indexing expression there too, so this does not reintroduce the path-label - # false positive that blanking exists to remove. + # matched against the original text only when the opening bracket survived + # masking. An example inside a comment/string is prose, not a field access. for field in fields: quoted = re.escape(field) for form, template in _TS_READ_PATTERNS: pattern = template.format(field=quoted) subject = text if form.startswith("subscript") else code - reads = [match.start() for match in re.finditer(pattern, subject, re.MULTILINE)] + reads = [match.start() for match in re.finditer(pattern, subject, re.MULTILINE) + if code[match.start()] != "\x00"] if not reads: continue - writes = {match.start() for match in re.finditer(pattern + _TS_WRITE_SUFFIX, subject, re.MULTILINE)} + writes = {match.start() for match in re.finditer(pattern + _TS_WRITE_SUFFIX, subject, re.MULTILINE) + if match.start() in reads} if writes: found.setdefault(field, set()).add(form.replace("_read", "_write")) if set(reads) - writes: diff --git a/tests/architecture/test_semantic_field_use.py b/tests/architecture/test_semantic_field_use.py index 545c516a71..2078beeff2 100644 --- a/tests/architecture/test_semantic_field_use.py +++ b/tests/architecture/test_semantic_field_use.py @@ -150,6 +150,25 @@ def test_typescript_string_paths_do_not_become_property_reads() -> None: assert forms[FIELD] == {"prose"} +@pytest.mark.parametrize("text, expected", [ + ('// payload["goal_boundary"]', "mention"), + ('/* payload["goal_boundary"] = value; */', "mention"), + ('''const note = 'payload["goal_boundary"]';''', "mention"), + ('const note = `payload["goal_boundary"]`;', "mention"), + ('const url = "https://example.test"; const value = payload.goal_boundary;', "reader"), + ('const marker = "/*"; payload["goal_boundary"] = value;', "writer"), + ('// "unclosed quote\nconst value = payload["goal_boundary"];', "reader"), +]) +def test_typescript_lexical_context_preserves_real_accesses(text: str, expected: str) -> None: + assert role(text, ".ts") == expected + + +def test_commented_write_does_not_change_a_real_reader_into_a_writer() -> None: + text = 'const value = payload["goal_boundary"]; // payload["goal_boundary"] = other;' + uses, _ = scan_field_uses([FIELD], [source(text, ".ts")]) + assert uses[0].reads and not uses[0].writes + + def test_roles_partition_the_token_count_so_the_metric_reclassifies_one_population() -> None: sources = [ source('value = payload["goal_boundary"]', path="loopx/reader"), From a5806dee88f7dc3122af2fcc7d80f4989b5a1dee Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:12:22 +0800 Subject: [PATCH 05/11] docs(semantics): state the bounded TypeScript reader scan contract Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../architecture/rfcs/semantic-vocabulary-convergence-v0.md | 6 +++++- .../rfcs/semantic-vocabulary-convergence-v0.zh-CN.md | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md index 7943162272..2e257f307f 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md @@ -1129,7 +1129,11 @@ and comments are blanked first, because the path label A bare `field:` at the head of a line is reported as a mention, not a write: an interface member and an object-literal entry are the same shape to this grammar, and crediting a declaration as production would overstate it in exactly the -direction I13 warns about. +direction I13 warns about. Strings and comments are masked in source order; +subscript matches must start outside either, so quoted URLs do not hide later +code and access examples in prose do not become readers. Template interpolation, +regular-expression literals and computed-key data flow are outside this bounded +grammar; M3 must inspect those paths before removing a field. ### 2026-09-17 — Invariant statements bounded to their verified domains 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 f84deb0230..2e7e60f350 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md @@ -919,7 +919,10 @@ Python 模块:计算式键总数是全仓范围的,一个从不提及任何 TypeScript 用有界文法扫描,且先把字符串字面量与注释抹白再匹配代码文本,否则路 径标签 `"decision.heartbeat_recommendation"` 会被算成属性读取。行首裸写的 `field:` 报为提及而非写入:在这套文法看来,接口成员与对象字面量条目形状相同, -把一处声明算作生产会恰好朝 I13 警告的方向高估。 +把一处声明算作生产会恰好朝 I13 警告的方向高估。字符串与注释按源码顺序抹白; +下标匹配的起点必须在两者之外,避免 URL 隐藏后续代码或散文中的示例变成读者。 +模板插值、正则字面量与计算式键的数据流不在此有界文法内;M3 删除字段前必须 +另行核查这些路径。 ### 2026-09-17 — 不变量表述收敛到各自已验证的值域 From 2e9418b98f41f28a534da26599fe657f58621f13 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:16:17 +0800 Subject: [PATCH 06/11] refactor(semantics): reuse the TypeScript AST scanner for field uses Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../semantic-vocabulary-convergence-v0.md | 28 +++--- ...emantic-vocabulary-convergence-v0.zh-CN.md | 21 ++-- loopx/semantics/field_use.py | 99 +++---------------- loopx/semantics/production.py | 20 ++-- scripts/semantic_production_scan.mjs | 39 ++++++++ tests/architecture/test_semantic_field_use.py | 11 ++- 6 files changed, 96 insertions(+), 122 deletions(-) diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md index 2e257f307f..73e5ab84a5 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md @@ -786,7 +786,7 @@ on the next full-tree scan; genuine shared-contract changes still need review. | Retirement budgets use standalone field tokens | `count_identifier_modules()` uses identifier boundaries for the six fields | `goal_boundary`: 30 Python modules under the new metric; the old substring metric was 35 | Conservative lexical measure; it removes compound-name false positives but does not prove semantic reader absence | | The retirement metric separates readers from mentions (B3) | `check_reader_metric()` classifies every module carrying one of the six field tokens as reader, writer, binding, unresolved or mention | `goal_boundary`: 30 token modules resolve to 8 readers, 4 writers, 3 bindings, 1 unresolved and 14 mentions, so its migration surface is 15, not 30; `work_lane_contract` stays at 28 of 29 | Syntactic use, not data flow. The roles are asserted to partition the token count, so the smaller number is a reclassification of the same modules and not a different population | | A new reader of a legacy field fails the pull-request path | Add a module reading `payload["protocol_action_packet"]` beyond the budget | `check_reader_metric` fails naming the field and the count | Committed fixture in `tests/architecture/test_semantic_vocabulary_drift.py`; the anchor equality check is the same pattern as `RETIREMENT_ANCHOR` | -| A computed key stays unresolved rather than absent | Count mapping accessors whose first argument is not a literal | 1712 sites under `loopx/`; a field measured at zero readers is measured against that standing unknown | This is why zero readers cannot by itself authorize a removal (Q11). Subscripts with a computed key are excluded: `rows[index]` and `payload[key]` are the same syntax | +| A computed key stays unresolved rather than absent | Count mapping accessors whose first argument is not a literal | 1709 sites under `loopx/`; a field measured at zero readers is measured against that standing unknown | This is why zero readers cannot by itself authorize a removal (Q11). Subscripts with a computed key are excluded: `rows[index]` and `payload[key]` are the same syntax | | The module-local convention filter is a code edit | Widen `MODULE_LOCAL_CONVENTION` in `inventory.py` and scan | `*_semantic` budgets fall with no code change elsewhere | Known boundary; the regex is in code so the widening is a reviewed diff, and the unfiltered totals stay budgeted | | A registered value nobody produces fails (M0.5) | Run the production-form scan on the baseline | Fails naming `effective_action` and `skip`; passes after `skip` is removed or listed `compatibility_only` | First expected I12 failure; a compared-only value is not carried | | A producer of an unregistered value fails (M0.5) | Write `effective_action: "brand_new"` in a listed producer site | Fails naming the site and the value even though no consumer compares it | I13; production is stricter than comparison | @@ -1074,7 +1074,7 @@ introduce a competing target state. mentions and budgets the first three as the migration surface. Both metrics are now checked. What stays open is whether the token budget is retired once the surface budget has ordered a removal, and what residual evidence a field - at zero surface still owes given 1712 computed-key sites. Owner: kernel + at zero surface still owes given 1709 computed-key sites. Owner: kernel maintainers. ## Appendix A: Execution ledger (non-normative) @@ -1104,7 +1104,7 @@ Three results the token count had hidden: and module-path imports that no migration touches. - `protocol_action_packet` has one Python reader and four writers. It is the cheapest first M3 removal, and the token count did not say so. -- 1712 mapping accessors under `loopx/` take a computed key. No name-keyed scan, +- 1709 mapping accessors under `loopx/` take a computed key. No name-keyed scan, lexical or syntactic, can attribute them, so the smoke prints that number beside the per-field counts. This is the measured form of "a zero count does not authorize a deletion"; the residual obligation is Q11's. @@ -1123,18 +1123,14 @@ roughly two million AST nodes for the rest of the run measured 0.7s worse overall than parsing twice, and it slowed `check_inventory` from 2.4s to 5.4s, which is the pass #4628 had just made cheaper. -TypeScript is scanned by bounded grammar over code text whose string literals -and comments are blanked first, because the path label -`"decision.heartbeat_recommendation"` would otherwise count as a property read. -A bare `field:` at the head of a line is reported as a mention, not a write: an -interface member and an object-literal entry are the same shape to this grammar, -and crediting a declaration as production would overstate it in exactly the -direction I13 warns about. Strings and comments are masked in source order; -subscript matches must start outside either, so quoted URLs do not hide later -code and access examples in prose do not become readers. Template interpolation, -regular-expression literals and computed-key data flow are outside this bounded -grammar; M3 must inspect those paths before removing a field. - +TypeScript reuses `scripts/semantic_production_scan.mjs` and its TypeScript +parser in one batch. AST property and literal-subscript accesses distinguish +reads, writes and compound updates, including optional access and template +interpolation; comments, quoted examples and regex literals cannot become +accesses or mask later code. Bare object/type keys remain mentions: syntax +alone does not establish that the object carries the retired payload field. +Computed-key data flow and external consumers remain outside this metric; +M3 must inspect those paths before removal. The token budgets are unchanged. ### 2026-09-17 — Invariant statements bounded to their verified domains @@ -1374,7 +1370,7 @@ result on the current tree; what changes is what the invariants claim. | E21 | F1/F2 were unconditional but verified over one tier | `3ca868193` | `check_producers`' skip predicate, and the producer scan roots, read from the tree | 6 of 26 vocabularies declare `producers`, exactly the `tier: kernel` ones; the 20 skipped are all `cross_runtime`; the scan reaches 432 of 1203 tracked `loopx/**/*.{py,ts}` files (35.9%), the uncovered bulk being capabilities 285, other control-plane 192, extensions 83 | Counts from the registry and the tracked tree; the reach denominator moves with any new module, so it is reported, not pinned | | E22 | Fifteen reported unresolved sites can never become evidence | `3ca868193` | smoke report `unresolved_producer_blockers` | 41 unresolved sites, of which `argument_name_only` 10 and `annotation_only` 5 are a field-named keyword argument and a bare declaration; the other 26 are dynamic or interprocedural | Label-keyed; the two labels are code-owned in the scanner, so the floor moves only by a code edit | | E23 | F4 as written could not be violated | `3ca868193` | read `check_scope_declarations` against the F4 statement | Scope is declared and never inferred, so `conflict := collision ∧ scope_overlap` is a definition; what is enforced is that a declaration names every defining module exactly once, over 1 declaration and 4 contexts | Judgement from reading the check; value-set disjointness across contexts is deliberately *not* the property, because `SOURCE_SURFACES` legitimately reuses one name in four contexts (E19) | -| E24 | The retirement budget counted mentions as readers | `e12e05fff` | `check_reader_metric()` over the six legacy fields; roles asserted to partition `count_identifier_modules()` | 109 py token modules resolve to 82 surface modules; `goal_boundary` 30 → 15, `work_lane_contract` 29 → 28, `protocol_action_packet` 5 → 5 with one reader | Syntactic use, not data flow; 1712 computed-key mapping accessors stay unattributable, so zero surface is not zero readers | +| E24 | The retirement budget counted mentions as readers | B3 integration tree | `check_reader_metric()` over the six legacy fields; roles asserted to partition `count_identifier_modules()` | 109 py token modules resolve to 82 surface modules; `goal_boundary` 30 → 15, `work_lane_contract` 29 → 28, `protocol_action_packet` 5 → 5 with one reader | Syntactic use, not data flow; 1709 computed-key mapping accessors stay unattributable, so zero surface is not zero readers | | E13 | The conflict budget mostly measured local naming | `1dc6ad8d8` | `MODULE_LOCAL_CONVENTION` applied to `conflicting_values` and `same_runtime_forks` names | 16 of 18 conflicts and 7 of 25 forks are module-local conventions; the semantic subsets are 2 and 18 | Classification is a name pattern, documented in the scanner and pinned by a fixture test | ## Appendix D: Rejected or superseded alternatives 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 2e7e60f350..98edc4ab9c 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md @@ -641,7 +641,7 @@ owner 符号集合的组:`EffectiveAction` 与 `EFFECTIVE_ACTIONS` 是同一 | 退休预算按子串而非标识符计数 | 分别以 `in file.text` 与 `\bgoal_boundary\b` 统计 `goal_boundary` | 基线上 35 对 30 个 Python 模块 | 已知边界;M3 的零读者门需要标识符计数,见第 12 节 | | 退休指标把读者与提及分开(B3) | `check_reader_metric()` 把携带六个字段 token 的每个模块归为 reader、writer、binding、unresolved 或 mention | `goal_boundary`:30 个 token 模块解析为 8 读、4 写、3 承载、1 未定、14 提及,迁移面是 15 而非 30;`work_lane_contract` 仍是 29 中的 28 | 度量的是句法使用,不是数据流。角色被断言恰好划分 token 计数,因此更小的数字是同一批模块的重新分类,不是另一批更小的样本 | | 旧字段新增读者会在 PR 路径上失败 | 让一个模块读 `payload["protocol_action_packet"]` 从而超出预算 | `check_reader_metric` 失败并点名该字段与计数 | `tests/architecture/test_semantic_vocabulary_drift.py` 内的提交测试;锚点等值检查与 `RETIREMENT_ANCHOR` 同一套模式 | -| 计算式键保持「未定」而非「不存在」 | 统计首参数不是字面量的 mapping 访问器 | `loopx/` 下 1712 处;某字段读者计为零时,是对着这个公开的未知数计零 | 这正是零读者本身不能授权删除的原因(Q11)。计算式下标不计入:`rows[index]` 与 `payload[key]` 是同一种语法 | +| 计算式键保持「未定」而非「不存在」 | 统计首参数不是字面量的 mapping 访问器 | `loopx/` 下 1709 处;某字段读者计为零时,是对着这个公开的未知数计零 | 这正是零读者本身不能授权删除的原因(Q11)。计算式下标不计入:`rows[index]` 与 `payload[key]` 是同一种语法 | | 模块局部约定过滤器是一次代码修改 | 扩宽 `inventory.py` 的 `MODULE_LOCAL_CONVENTION` 并重新生成 | `*_semantic` 预算下降而别处无代码改动 | 已知边界;正则在代码里,扩宽是可评审的 diff,未过滤总数仍在预算内 | | 无人生产的注册值失败(M0.5) | 在基线上运行生产形式扫描 | 失败并点名 `effective_action` 与 `skip`;删除 `skip` 或列入 `compatibility_only` 后通过 | 第一个预期的 I12 失败;只被比较的值不算已携带 | | 生产未注册值失败(M0.5) | 在某个已列生产位点写 `effective_action: "brand_new"` | 即使无消费者比较它也失败,并点名位点与值 | I13;生产比比较更严 | @@ -873,7 +873,7 @@ PR review 保留这些层级。普通改动记录检查范围和理由,无共 加入 `check_reader_metric()`:把同一批模块拆成读者、写方、形参/局部承载、 未定名字载体与提及,并把前三者作为迁移面纳入预算。两个指标现在都在检查。 仍然未决的是:当迁移面预算已经能排序删除工作后,是否退役 token 预算;以及 - 在 1712 处计算式键访问之下,迁移面为零的字段还欠哪些残余证据。 + 在 1709 处计算式键访问之下,迁移面为零的字段还欠哪些残余证据。 Owner:内核维护者。 ## 附录 A:执行账本(非规范) @@ -901,7 +901,7 @@ token 计数掩盖掉的三个结果: 个模块是提示词散文与模块路径导入,迁移根本不会碰到。 - `protocol_action_packet` 只有一个 Python 读者和四个写方。它是代价最低的首个 M3 删除对象,而 token 计数说不出这一点。 -- `loopx/` 下有 1712 处 mapping 访问器使用计算式键。任何按名字的扫描——词法的 +- `loopx/` 下有 1709 处 mapping 访问器使用计算式键。任何按名字的扫描——词法的 还是句法的——都无法归属它们,因此 smoke 把这个数字与各字段计数一起打印。这 就是「计数归零不授权删除」的可测形式;残余义务归 Q11。 @@ -916,14 +916,11 @@ Python 模块:计算式键总数是全仓范围的,一个从不提及任何 0.7s,并且会把 `check_inventory` 从 2.4s 拖到 5.4s,而那正是 #4628 刚刚变快的 那一趟。 -TypeScript 用有界文法扫描,且先把字符串字面量与注释抹白再匹配代码文本,否则路 -径标签 `"decision.heartbeat_recommendation"` 会被算成属性读取。行首裸写的 -`field:` 报为提及而非写入:在这套文法看来,接口成员与对象字面量条目形状相同, -把一处声明算作生产会恰好朝 I13 警告的方向高估。字符串与注释按源码顺序抹白; -下标匹配的起点必须在两者之外,避免 URL 隐藏后续代码或散文中的示例变成读者。 -模板插值、正则字面量与计算式键的数据流不在此有界文法内;M3 删除字段前必须 -另行核查这些路径。 - +TypeScript 一次批量复用 `scripts/semantic_production_scan.mjs` 的 TypeScript +解析器,按 AST 属性和字面量下标区分读、写及复合更新,覆盖可选访问与模板插值。 +注释、引用示例与正则字面量不会变成访问,也不会遮住后续代码。裸对象/类型键 +仍计为提及:单凭语法不能断定该对象承载退休字段的 payload。计算式键的数据流 +与外部消费者仍在本指标之外,M3 删除前必须另行核查;token 预算保持不变。 ### 2026-09-17 — 不变量表述收敛到各自已验证的值域 @@ -1126,7 +1123,7 @@ TypeScript 用有界文法扫描,且先把字符串字面量与注释抹白再 | E21 | F1/F2 写成无条件,但只在一个层上被验证 | `3ca868193` | 从源码树读 `check_producers` 的跳过谓词与 producer 扫描根目录 | 26 个词表中 6 个声明了 `producers`,恰好是 `tier: kernel` 那几个;被跳过的 20 个全部是 `cross_runtime`;扫描触及 1203 个已跟踪 `loopx/**/*.{py,ts}` 中的 432 个(35.9%),未覆盖部分主要是 capabilities 285、其余控制面 192、extensions 83 | 计数来自注册表与已跟踪源码树;分母会随任何新模块移动,所以只上报、不钉住 | | E22 | 15 个被上报的未解析位点永远不可能成为证据 | `3ca868193` | smoke 报告的 `unresolved_producer_blockers` | 41 个未解析位点,其中 `argument_name_only` 10 个、`annotation_only` 5 个分别是以字段名命名的关键字参数和裸声明;其余 26 个是动态或跨过程的 | 按标签归组;这两个标签在扫描器里由代码持有,因此这个下界只能靠改代码移动 | | E23 | F4 写法本身不可能被违反 | `3ca868193` | 对照 F4 表述阅读 `check_scope_declarations` | 作用域是声明的、从不推断,所以 `conflict := collision ∧ scope_overlap` 是一条定义;真正被强制的是一份声明必须恰好枚举每个定义模块,范围是 1 份声明、4 个上下文 | 阅读检查后的判断;各上下文值集互斥故意*不*作为该性质,因为 `SOURCE_SURFACES` 正是合理地在四个上下文复用同一个名字(E19) | -| E24 | 退休预算把提及算成了读者 | `e12e05fff` | 对六个旧字段运行 `check_reader_metric()`;断言角色划分 `count_identifier_modules()` | 109 个 py token 模块解析为 82 个迁移面模块;`goal_boundary` 30 → 15,`work_lane_contract` 29 → 28,`protocol_action_packet` 5 → 5 且只有一个读者 | 度量句法使用而非数据流;1712 处计算式键 mapping 访问仍无法归属,因此迁移面为零不等于读者为零 | +| E24 | 退休预算把提及算成了读者 | B3 integration tree | 对六个旧字段运行 `check_reader_metric()`;断言角色划分 `count_identifier_modules()` | 109 个 py token 模块解析为 82 个迁移面模块;`goal_boundary` 30 → 15,`work_lane_contract` 29 → 28,`protocol_action_packet` 5 → 5 且只有一个读者 | 度量句法使用而非数据流;1709 处计算式键 mapping 访问仍无法归属,因此迁移面为零不等于读者为零 | | E13 | 冲突预算主要在度量局部命名 | `1dc6ad8d8` | 对 `conflicting_values` 与 `same_runtime_forks` 名字应用 `MODULE_LOCAL_CONVENTION` | 18 个冲突中 16 个、25 个分叉中 7 个是模块局部约定;语义子集分别为 2 与 18 | 分类是名字模式,已在扫描器中说明并由夹具测试钉住 | ## 附录 D:被否决或取代的方案 diff --git a/loopx/semantics/field_use.py b/loopx/semantics/field_use.py index dd196993ea..b88f788a4d 100644 --- a/loopx/semantics/field_use.py +++ b/loopx/semantics/field_use.py @@ -26,13 +26,13 @@ the same syntax, and counting sequence indexing as an unresolved mapping read would inflate the unknown until it stopped carrying information. * Same-prefix identifiers are different fields. ``legacy_field_repair`` is not - a use of ``legacy_field``; the AST compares whole keys, and the TypeScript - scan anchors on non-identifier boundaries. + a use of ``legacy_field``; both language AST scans compare whole keys. * A field this module measures must not be spelled out here. The scan reads tracked sources under ``loopx/``, this file is one of them, and a field name in a docstring would add a mention to that field's own budget. The examples above use ``legacy_field`` for that reason; the real names live in the - registry and in the smoke's anchors, both outside the scanned root. + registry and in the smoke's anchors, outside the scanned Python/TypeScript + sources. * A mention is evidence of nothing. Prompt prose and module paths carry the token without a recognized access. Locals and parameters are instead bindings: they may carry the value through a signature that a migration must inspect. @@ -43,10 +43,12 @@ import ast from dataclasses import dataclass from functools import lru_cache +from pathlib import Path import re from typing import Any, Iterable from .inventory import SourceFile, parse_python +from .production import run_typescript_scan # ``dict``/``Mapping`` accessors whose first literal argument names a field. MAPPING_READ_CALLS = frozenset({"get", "pop"}) @@ -72,31 +74,6 @@ MENTION_FORMS = frozenset({"module_import", "object_key", "prose"}) USE_FORMS = READ_FORMS | WRITE_FORMS | BINDING_FORMS | UNRESOLVED_FORMS | MENTION_FORMS -# TypeScript has no parser here, so it is scanned with a bounded grammar over -# code text whose string literals and comments have been blanked out first -- -# otherwise a path label such as ``"decision.legacy_field"`` would be counted -# as a property read. Each pattern is anchored on non-identifier boundaries, so -# ``legacy_field_repair`` cannot match ``legacy_field``. -# -# ``object_key`` (a bare ``field:`` at the head of a line) is reported as a -# mention, not a write. An interface member and an object-literal entry are the -# same shape, and this grammar cannot separate them; crediting a declaration as -# a write would overstate production in exactly the direction the RFC's -# production obligation warns about. The token count still shows the module. -_TS_READ_PATTERNS: tuple[tuple[str, str], ...] = ( - ("property_read", r"\.\s*{field}(?![A-Za-z0-9_$])"), - ("subscript_read", r"""\[\s*["']{field}["']\s*\]"""), -) -# Each write pattern is its read pattern followed by an assignment, so a write -# match always starts where the corresponding read match starts. That is how a -# pure write is kept from also counting as a read. -_TS_WRITE_SUFFIX = r"\s*(?:=[^=]|\+=)" -_TS_OBJECT_KEY = r"^\s*{field}\??\s*:" -_TS_LEXEME = re.compile( - r"""(?P(?P["'`])(?:\\.|(?!(?P=q)).)*(?P=q))|//[^\n]*|/\*.*?\*/""", - re.DOTALL, -) - @dataclass(frozen=True) class FieldUse: @@ -231,59 +208,6 @@ def record(field: str, form: str) -> None: return found, dynamic_sites -def _blank_strings_and_comments(text: str) -> tuple[str, list[str]]: - """Split a TypeScript module into code text and its string literals. - - String and comment bodies are replaced by a placeholder of the same length - so line structure survives for the line-anchored patterns, while the field - names inside them stop matching code forms. - """ - literals: list[str] = [] - - def mask(body: str) -> str: - return "".join("\x00" if character != "\n" else "\n" for character in body) - - def blank_lexeme(match: re.Match[str]) -> str: - if match.group("string") is not None: - literals.append(match.group(0)) - return mask(match.group(0)) - - # Match in source order: comment delimiters inside a quoted URL are data, - # and quotes inside a comment cannot open a string in the following code. - code = _TS_LEXEME.sub(blank_lexeme, text) - return code, literals - - -def typescript_field_forms(text: str, fields: frozenset[str]) -> dict[str, set[str]]: - """Recognized forms per field in one TypeScript module, by bounded grammar.""" - found: dict[str, set[str]] = {} - code, literals = _blank_strings_and_comments(text) - # Subscript keys are string literals, which blanking removed, so they are - # matched against the original text only when the opening bracket survived - # masking. An example inside a comment/string is prose, not a field access. - for field in fields: - quoted = re.escape(field) - for form, template in _TS_READ_PATTERNS: - pattern = template.format(field=quoted) - subject = text if form.startswith("subscript") else code - reads = [match.start() for match in re.finditer(pattern, subject, re.MULTILINE) - if code[match.start()] != "\x00"] - if not reads: - continue - writes = {match.start() for match in re.finditer(pattern + _TS_WRITE_SUFFIX, subject, re.MULTILINE) - if match.start() in reads} - if writes: - found.setdefault(field, set()).add(form.replace("_read", "_write")) - if set(reads) - writes: - found.setdefault(field, set()).add(form) - if re.search(_TS_OBJECT_KEY.format(field=quoted), code, re.MULTILINE): - found.setdefault(field, set()).add("object_key") - token = re.compile(rf"(? re.Pattern[str]: return re.compile(rf"(? tup wanted = frozenset(fields) uses: list[FieldUse] = [] dynamic_sites = 0 - for source in sources: + materialized = list(sources) + ts_sources = [source for source in materialized if source.suffix == ".ts" + and any(_token_pattern(field).search(source.text) for field in wanted)] + # One AST request for the complete TS population, not one Node process per + # module/field. Reuse the producer scanner's parser and safe error boundary. + ts_forms = {row["path"]: row["fields"] for row in run_typescript_scan( + Path(__file__).resolve().parents[2], ts_sources, + {"mode": "field_uses", "fields": sorted(wanted)}, + )} + for source in materialized: # Every Python module contributes to the computed-key total whether or # not it names a field, so the cheap substring filter only narrows the # per-field work, never the standing unknown. @@ -321,7 +254,7 @@ def scan_field_uses(fields: Iterable[str], sources: Iterable[SourceFile]) -> tup forms, module_dynamic_sites = python_module_scan(tree, present) dynamic_sites += module_dynamic_sites elif source.suffix == ".ts": - forms = typescript_field_forms(source.text, present) + forms = {field: set(observed) for field, observed in ts_forms.get(source.path, {}).items()} else: continue for field in present: diff --git a/loopx/semantics/production.py b/loopx/semantics/production.py index 0b12cbaa61..e060d5fe58 100644 --- a/loopx/semantics/production.py +++ b/loopx/semantics/production.py @@ -106,11 +106,20 @@ def _typescript_scan( ) -> list[Production]: if not ts_sources or not field: return [] - rows = [] + rows = run_typescript_scan(root, ts_sources, {'field': field, 'return_functions': returns, 'mode': mode}) + return [Production(r['site'], r['line'], r['form'], frozenset(r['values']), r['unresolved'], + 'typescript_dynamic' if r['unresolved'] else None) for r in rows] + + +def run_typescript_scan( + root: Path, ts_sources: list[SourceFile], request: dict[str, Any], +) -> list[dict[str, Any]]: + """Run repository semantic AST analysis with one bounded error boundary.""" + if not ts_sources: + return [] completed = subprocess.run( ['node', str(root / 'scripts/semantic_production_scan.mjs')], - input=json.dumps({'field': field, 'sources': [{'path': s.path, 'text': s.text} for s in ts_sources], - 'return_functions': returns, 'mode': mode}), + input=json.dumps({**request, 'sources': [{'path': s.path, 'text': s.text} for s in ts_sources]}), capture_output=True, text=True, encoding="utf-8", timeout=60, check=False, ) if completed.returncode: @@ -126,10 +135,7 @@ def _typescript_scan( and isinstance(error.get('line'), int) and error['line'] > 0): raise ValueError(f"{error['path']}:{error['line']}: invalid TypeScript source; repair syntax before semantic scanning") raise ValueError('TypeScript production parser failed; run npm ci --ignore-scripts and check the Node runtime') - rows.extend(Production(r['site'], r['line'], r['form'], frozenset(r['values']), r['unresolved'], - 'typescript_dynamic' if r['unresolved'] else None) - for r in json.loads(completed.stdout)) - return rows + return json.loads(completed.stdout) def collect_literal_uses(root: Path, field: str, sources: list[SourceFile]) -> dict[str, set[str]]: diff --git a/scripts/semantic_production_scan.mjs b/scripts/semantic_production_scan.mjs index d1a6b0efec..59d629b88c 100644 --- a/scripts/semantic_production_scan.mjs +++ b/scripts/semantic_production_scan.mjs @@ -61,6 +61,45 @@ for (const source of request.sources) { } return values(node).values; }; + if (request.mode === 'field_uses') { + const fields = new Set(request.fields); + const forms = new Map(); + const record = (key, form) => { + if (!fields.has(key)) return; + if (!forms.has(key)) forms.set(key, new Set()); + forms.get(key).add(form); + }; + const visit = node => { + if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { + const property = ts.isPropertyAccessExpression(node); + const key = property ? node.name.text : staticName(node.argumentExpression); + let target = node, parent = node.parent; + while (parent && unwrap(parent) === target) { target = parent; parent = parent.parent; } + const assignment = ts.isBinaryExpression(parent) && parent.left === target && + parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment; + const update = (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) && + [ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(parent.operator); + const deletion = ts.isDeleteExpression(parent); + const prefix = property ? 'property' : 'subscript'; + if (assignment || update || deletion) record(key, `${prefix}_write`); + if ((!assignment && !deletion) || update || + (assignment && parent.operatorToken.kind !== ts.SyntaxKind.EqualsToken)) { + record(key, `${prefix}_read`); + } + } else if (ts.isPropertyAssignment(node) || ts.isPropertySignature(node)) { + // Preserve B3's declared metric: bare keys are mentions. AST shape + // alone does not prove this object carries the retired payload field. + record(named(node.name), 'object_key'); + } + ts.forEachChild(node, visit); + }; + visit(tree); + result.push({path: source.path, fields: Object.fromEntries( + [...forms].map(([key, observed]) => [key, [...observed].sort()]), + )}); + continue; + } function walk(node, scope) { if (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) scope = scope === '' ? named(node.name) : `${scope}.${named(node.name)}`; else if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) { diff --git a/tests/architecture/test_semantic_field_use.py b/tests/architecture/test_semantic_field_use.py index 2078beeff2..cb304e62ae 100644 --- a/tests/architecture/test_semantic_field_use.py +++ b/tests/architecture/test_semantic_field_use.py @@ -17,7 +17,6 @@ field_use_summary, python_module_scan, scan_field_uses, - typescript_field_forms, ) from loopx.semantics.inventory import SourceFile @@ -140,14 +139,13 @@ def test_an_unparseable_module_is_recorded_rather_than_dropped() -> None: ("// goal_boundary is projected downstream", "mention"), ("const repaired = payload.goal_boundary_repair;", None), ]) -def test_typescript_forms_are_classified_by_the_bounded_grammar(text: str, expected: str | None) -> None: +def test_typescript_forms_are_classified_by_the_bounded_ast_scan(text: str, expected: str | None) -> None: uses, _ = scan_field_uses([FIELD], [source(text, ".ts")]) assert [use.role for use in uses] == ([expected] if expected else []) def test_typescript_string_paths_do_not_become_property_reads() -> None: - forms = typescript_field_forms('log("decision.goal_boundary");', FIELDS) - assert forms[FIELD] == {"prose"} + assert role('log("decision.goal_boundary");', ".ts") == "mention" @pytest.mark.parametrize("text, expected", [ @@ -158,6 +156,11 @@ def test_typescript_string_paths_do_not_become_property_reads() -> None: ('const url = "https://example.test"; const value = payload.goal_boundary;', "reader"), ('const marker = "/*"; payload["goal_boundary"] = value;', "writer"), ('// "unclosed quote\nconst value = payload["goal_boundary"];', "reader"), + ('const pattern = /["\\\']+/; const value = payload.goal_boundary;', "reader"), + ('const value = `${payload.goal_boundary}`;', "reader"), + ('const pattern = /payload.goal_boundary/;', "mention"), + ('const value = payload?.["goal_boundary"];', "reader"), + ('payload.goal_boundary += 1;', "reader"), ]) def test_typescript_lexical_context_preserves_real_accesses(text: str, expected: str) -> None: assert role(text, ".ts") == expected From 98cd1fecbafaa90c80b5d42d74c2adde814a284d Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:25:32 -0400 Subject: [PATCH 07/11] fix(semantics): read a TypeScript field use by what it does, not how it is spelled The B3 scan recognized a TypeScript member access and nothing else, so the constructs TypeScript actually uses were classified as prose. Of twelve equivalent accesses written for both runtimes, eight disagreed: a destructuring read, a renamed destructuring read, a parameter destructure, an object-literal write, a shorthand write, a property signature, a computed member and a bare field-name string. The consequence was measurable, not theoretical. `{field: x}` was a writer in Python and a mention in TypeScript, so porting a dict literal across the boundary shrank the migration surface with nothing migrated. All six legacy fields measured zero TypeScript writers. `execution_obligation.ts` reported 0 modules to migrate while `turn_envelope.ts` declared its truncation limits and defaults, and `monitor_poll_commit.ts` built three of the fields in object literals that read as prose. Both runtimes are now asserted to classify one access identically, which is the property that makes the surface safe to plan an M3 removal against. The registry budget for `execution_obligation.ts` moves 0 -> 1 with its anchor, in this diff, because the module it had not been counting is real. Two further corrections come with it: * The TypeScript half of the standing unknown was drawn only from modules that spelled a field, 4 of 145. The Python half is repository-wide on purpose, and the asymmetry meant a field measured at zero readers was measured against an unknown that excluded the modules most able to hide one. Widening it raised the count from 82 sites to 395 and cost 0.52s. The two runtimes are counted apart because their exclusions differ: Python cannot separate `rows[index]` from `payload[key]`, TypeScript has no mapping-accessor convention at all, so its number is stated as an upper bound. * The role partition answers "what is this module mainly", not "who writes this field". `work_lane_contract` has 8 modules whose role is `writer` and 13 that write it; the other 5 also read it, so the partition calls them readers and a retirement hunting producers would have missed them. The overlapping reads/writes/binds counts are now reported beside the partition, which keeps the partition's sum-to-token-count assertion intact. Refs #4447 B3. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- .../semantic-vocabulary-convergence-v0.md | 37 +++++- ...emantic-vocabulary-convergence-v0.zh-CN.md | 41 ++++-- examples/semantic-vocabulary-drift-smoke.py | 26 +++- loopx/semantics/field_use.py | 82 ++++++++++-- loopx/semantics/vocabulary_v0.json | 2 +- scripts/semantic_production_scan.mjs | 46 ++++++- tests/architecture/test_semantic_field_use.py | 121 +++++++++++++++++- 7 files changed, 311 insertions(+), 44 deletions(-) diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md index 0eec28b9c9..06ae4408e9 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md @@ -784,7 +784,7 @@ on the next full-tree scan; genuine shared-contract changes still need review. | No behavior change from the two owner fixes | `uv run --extra test python -m pytest tests/test_loopx_turn_transaction.py tests/test_loop_turn_loop_controller.py tests/test_turn_loop_disposition.py tests/test_loopx_turn_managed_step.py tests/control_plane -k authority` and `uv run --extra test loopx canary premerge --from-git-diff` | pass | Environment failures already present on `main` are excluded when reproduced on a clean tree | | Docs governance accepts the RFC pair | `python3 examples/docs-governance-smoke.py` | pass | Checks mirror, links, index | | Retirement budgets use standalone field tokens | `count_identifier_modules()` uses identifier boundaries for the six fields | `goal_boundary`: 30 Python modules under the new metric; the old substring metric was 35 | Conservative lexical measure; it removes compound-name false positives but does not prove semantic reader absence | -| The retirement metric separates readers from mentions (B3) | `check_reader_metric()` classifies every module carrying one of the six field tokens as reader, writer, binding, unresolved or mention | `goal_boundary`: 30 token modules resolve to 8 readers, 4 writers, 3 bindings, 1 unresolved and 14 mentions, so its migration surface is 15, not 30; `work_lane_contract` stays at 28 of 29 | Syntactic use, not data flow. The roles are asserted to partition the token count, so the smaller number is a reclassification of the same modules and not a different population | +| The retirement metric separates readers from mentions (B3) | `check_reader_metric()` classifies every module carrying one of the six field tokens as reader, writer, binding, unresolved or mention, and reports the overlapping read/write/bind counts beside that partition | `goal_boundary`: 30 token modules resolve to 8 readers, 4 writers, 3 bindings, 1 unresolved and 14 mentions, so its migration surface is 15, not 30; `work_lane_contract` stays at 28 of 29 | Syntactic use, not data flow. The roles are asserted to partition the token count, so the smaller number is a reclassification of the same modules and not a different population | | A new reader of a legacy field fails the pull-request path | Add a module reading `payload["protocol_action_packet"]` beyond the budget | `check_reader_metric` fails naming the field and the count | Committed fixture in `tests/architecture/test_semantic_vocabulary_drift.py`; the anchor equality check is the same pattern as `RETIREMENT_ANCHOR` | | A computed key stays unresolved rather than absent | Count mapping accessors whose first argument is not a literal | 1709 sites under `loopx/`; a field measured at zero readers is measured against that standing unknown | This is why zero readers cannot by itself authorize a removal (Q11). Subscripts with a computed key are excluded: `rows[index]` and `payload[key]` are the same syntax | | The module-local convention filter is a code edit | Widen `MODULE_LOCAL_CONVENTION` in `inventory.py` and scan | `*_semantic` budgets fall with no code change elsewhere | Known boundary; the regex is in code so the widening is a reviewed diff, and the unfiltered totals stay budgeted | @@ -1092,7 +1092,7 @@ have to change before a field can be removed. | `protocol_action_packet` | 5 | 1 | 4 | 0 | 0 | 0 | 5 | 2 | 2 | | `external_evidence_observation` | 8 | 4 | 1 | 1 | 1 | 1 | 6 | 1 | 1 | | `heartbeat_recommendation` | 17 | 8 | 4 | 1 | 0 | 4 | 13 | 1 | 1 | -| `execution_obligation` | 20 | 8 | 7 | 0 | 0 | 5 | 15 | 1 | 0 | +| `execution_obligation` | 20 | 8 | 7 | 0 | 0 | 5 | 15 | 1 | 1 | | `work_lane_contract` | 29 | 11 | 8 | 9 | 1 | 0 | 28 | 3 | 3 | | `goal_boundary` | 30 | 8 | 4 | 3 | 1 | 14 | 15 | 2 | 1 | @@ -1104,16 +1104,41 @@ Three results the token count had hidden: and module-path imports that no migration touches. - `protocol_action_packet` has one Python reader and four writers. It is the cheapest first M3 removal, and the token count did not say so. -- 1709 mapping accessors under `loopx/` take a computed key. No name-keyed scan, - lexical or syntactic, can attribute them, so the smoke prints that number +- 1709 mapping accessors under `loopx/` take a computed key, and 395 TypeScript + computed member accesses are that runtime's equivalent. No name-keyed scan, + lexical or syntactic, can attribute them, so the smoke prints both numbers beside the per-field counts. This is the measured form of "a zero count does not authorize a deletion"; the residual obligation is Q11's. +Two corrections were measured after the first implementation, both of which had +made the surface smaller than the work: + +- The TypeScript scan recognized a member access and nothing else, so the + constructs TypeScript actually uses were classified as prose: a destructuring + read, an object-literal write, and a declared property signature. Of twelve + equivalent accesses written for both runtimes, eight disagreed. `{field: x}` + was a writer in Python and a mention in TypeScript, so porting a dict literal + across the boundary shrank the surface with nothing migrated, and every one of + the six fields measured zero TypeScript writers. `execution_obligation.ts` read + as 0 modules to migrate while `turn_envelope.ts` declared its truncation + limits. Both runtimes are now asserted to classify the same access the same + way, which is the property that makes the surface safe to plan against. +- The TypeScript half of the standing unknown was drawn only from modules that + spelled a field, 4 of 145. Widening it to every tracked module raised the + count from 82 sites to 395 and cost 0.52s. + The roles are asserted to partition the token count exactly, per field and per runtime, on every run. The new metric therefore reclassifies one population rather than measuring a smaller one, and this slice repays no debt: both budgets are pinned at their measured values in the same diff. +The partition assigns each module its first matching role, which answers "what +is this module mainly" and not "who writes this field". `work_lane_contract` +has 8 modules whose role is `writer` and 13 that write it; the other 5 also +read it and the partition calls them readers. A retirement looking for every +producer reads the overlapping `reads`/`writes`/`binds` counts printed beside +the partition, not the partition itself. + The check costs 7.5s on a 29.4s guard, measured twice on each tree. The scan must walk every tracked Python module, because the computed-key total is repository-wide and a module that never names a field still contributes to it. @@ -1338,7 +1363,7 @@ result on the current tree; what changes is what the invariants claim. | --- | --- | --- | --- | --- | | 2026-09-16 | Q9: compute the full inventory on demand; retire the committed census | Implementation for [maintainer feedback](https://github.com/huangruiteng/loopx/pull/4360#issuecomment-5692062394); PR review pending | Committed snapshot with post-merge regeneration; diff-only scan rejected | 1, I6, 3, 5, 9, 10, 12 | | 2026-09-16 | B2: bind one unrenamed re-export hop in the Python producer scanner | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2; PR review pending | Require every consumer to import the owner module (fragile; failed silently in M2); unbounded multi-hop resolution rejected | 5, Appendix A | -| 2026-09-17 | B3: budget the migration surface beside the token count; keep both until Q11 | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3; PR review pending | Replacing the token budget outright (rejected: the token count is the anchor that proves the new roles partition the same population, and dropping it in the same diff that introduces them would make the smaller number unauditable); counting computed-key subscripts as unresolved reads (rejected: `rows[index]` and `payload[key]` are one syntax, and the unknown would stop carrying information) | 5, 9, 11, 12 | +| 2026-09-17 | B3: budget the migration surface beside the token count; keep both until Q11 | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3; PR review pending | Replacing the token budget outright (rejected: the token count is the anchor that proves the new roles partition the same population, and dropping it in the same diff that introduces them would make the smaller number unauditable); counting Python computed-key subscripts as unresolved reads (rejected: `rows[index]` and `payload[key]` are one syntax, and the unknown would stop carrying information; TypeScript has no mapping-accessor convention, so its computed member access is counted separately and stated as an upper bound) | 5, 9, 11, 12 | | 2026-09-16 | B1 rename invariance: add the name-keyed divergence advisory; state the limit it does not close | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B1; PR review pending | Keying the budget on value sets (rejected: `CONFIDENCE_LEVELS` and `EDGE_CASE_COMPLEXITIES` share `high/low/medium` with different meanings); a committed name ledger (rejected at M0: Q9 retired the committed census). The advisory lists surviving forks by name; it was first described as catching a one-sided rename, which measurement disproved, so both mirrors state the limit as it behaves | 9 | | 2026-09-17 | Bound F1/F2 to the kernel tier and the scan reach, restate F4 as scope enumeration completeness, and give every obligation a derived `domain` | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447); **kernel-maintainer approval required, not yet given** | Leave the unconditional statements and record the gap in prose only (rejected: the statement was stronger than `validate_production`'s own docstring); restate F4 as per-context value-set disjointness (rejected: refuted by the repo's own data, since `scope_declarations` exists to permit legitimate same-name reuse); widen the scan so the unconditional claim becomes true (rejected: a separate change with its own risk) | 5, 9, Appendix B, Appendix C | @@ -1368,7 +1393,7 @@ result on the current tree; what changes is what the invariants claim. | E21 | F1/F2 were unconditional but verified over one tier | `3ca868193` | `check_producers`' skip predicate, and the producer scan roots, read from the tree | 6 of 26 vocabularies declare `producers`, exactly the `tier: kernel` ones; the 20 skipped are all `cross_runtime`; the scan reaches 432 of 1203 tracked `loopx/**/*.{py,ts}` files (35.9%), the uncovered bulk being capabilities 285, other control-plane 192, extensions 83 | Counts from the registry and the tracked tree; the reach denominator moves with any new module, so it is reported, not pinned | | E22 | Fifteen reported unresolved sites can never become evidence | `3ca868193` | smoke report `unresolved_producer_blockers` | 41 unresolved sites, of which `argument_name_only` 10 and `annotation_only` 5 are a field-named keyword argument and a bare declaration; the other 26 are dynamic or interprocedural | Label-keyed; the two labels are code-owned in the scanner, so the floor moves only by a code edit | | E23 | F4 as written could not be violated | `3ca868193` | read `check_scope_declarations` against the F4 statement | Scope is declared and never inferred, so `conflict := collision ∧ scope_overlap` is a definition; what is enforced is that a declaration names every defining module exactly once, over 1 declaration and 4 contexts | Judgement from reading the check; value-set disjointness across contexts is deliberately *not* the property, because `SOURCE_SURFACES` legitimately reuses one name in four contexts (E19) | -| E24 | The retirement budget counted mentions as readers | B3 integration tree | `check_reader_metric()` over the six legacy fields; roles asserted to partition `count_identifier_modules()` | 109 py token modules resolve to 82 surface modules; `goal_boundary` 30 → 15, `work_lane_contract` 29 → 28, `protocol_action_packet` 5 → 5 with one reader | Syntactic use, not data flow; 1709 computed-key mapping accessors stay unattributable, so zero surface is not zero readers | +| E24 | The retirement budget counted mentions as readers | B3 integration tree | `check_reader_metric()` over the six legacy fields; roles asserted to partition `count_identifier_modules()`; both runtimes asserted to classify one access identically | 109 py token modules resolve to 82 surface modules; `goal_boundary` 30 → 15, `work_lane_contract` 29 → 28, `protocol_action_packet` 5 → 5 with one reader; 8 of 12 equivalent accesses had disagreed across the runtimes, and all six fields had measured zero TypeScript writers | Syntactic use, not data flow; 1709 Python computed-key accessors and 395 TypeScript computed members stay unattributable, so zero surface is not zero readers; the role partition is not a producer count | | E13 | The conflict budget mostly measured local naming | `1dc6ad8d8` | `MODULE_LOCAL_CONVENTION` applied to `conflicting_values` and `same_runtime_forks` names | 16 of 18 conflicts and 7 of 25 forks are module-local conventions; the semantic subsets are 2 and 18 | Classification is a name pattern, documented in the scanner and pinned by a fixture test | ## Appendix D: Rejected or superseded alternatives 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 1ecbf6ef9e..9b8e7732ed 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md @@ -639,7 +639,7 @@ owner 符号集合的组:`EffectiveAction` 与 `EFFECTIVE_ACTIONS` 是同一 | 两处 owner 修正不改变行为 | `uv run --extra test python -m pytest tests/test_loopx_turn_transaction.py tests/test_loop_turn_loop_controller.py tests/test_turn_loop_disposition.py tests/test_loopx_turn_managed_step.py tests/control_plane -k authority` 与 `uv run --extra test loopx canary premerge --from-git-diff` | 通过 | 在干净树上可复现的 `main` 既有环境失败除外 | | 文档治理接受这对 RFC | `python3 examples/docs-governance-smoke.py` | 通过 | 检查镜像、链接、索引 | | 退休预算按子串而非标识符计数 | 分别以 `in file.text` 与 `\bgoal_boundary\b` 统计 `goal_boundary` | 基线上 35 对 30 个 Python 模块 | 已知边界;M3 的零读者门需要标识符计数,见第 12 节 | -| 退休指标把读者与提及分开(B3) | `check_reader_metric()` 把携带六个字段 token 的每个模块归为 reader、writer、binding、unresolved 或 mention | `goal_boundary`:30 个 token 模块解析为 8 读、4 写、3 承载、1 未定、14 提及,迁移面是 15 而非 30;`work_lane_contract` 仍是 29 中的 28 | 度量的是句法使用,不是数据流。角色被断言恰好划分 token 计数,因此更小的数字是同一批模块的重新分类,不是另一批更小的样本 | +| 退休指标把读者与提及分开(B3) | `check_reader_metric()` 把携带六个字段 token 的每个模块归为 reader、writer、binding、unresolved 或 mention,并在该划分旁边报告重叠的读/写/承载计数 | `goal_boundary`:30 个 token 模块解析为 8 读、4 写、3 承载、1 未定、14 提及,迁移面是 15 而非 30;`work_lane_contract` 仍是 29 中的 28 | 度量的是句法使用,不是数据流。角色被断言恰好划分 token 计数,因此更小的数字是同一批模块的重新分类,不是另一批更小的样本 | | 旧字段新增读者会在 PR 路径上失败 | 让一个模块读 `payload["protocol_action_packet"]` 从而超出预算 | `check_reader_metric` 失败并点名该字段与计数 | `tests/architecture/test_semantic_vocabulary_drift.py` 内的提交测试;锚点等值检查与 `RETIREMENT_ANCHOR` 同一套模式 | | 计算式键保持「未定」而非「不存在」 | 统计首参数不是字面量的 mapping 访问器 | `loopx/` 下 1709 处;某字段读者计为零时,是对着这个公开的未知数计零 | 这正是零读者本身不能授权删除的原因(Q11)。计算式下标不计入:`rows[index]` 与 `payload[key]` 是同一种语法 | | 模块局部约定过滤器是一次代码修改 | 扩宽 `inventory.py` 的 `MODULE_LOCAL_CONVENTION` 并重新生成 | `*_semantic` 预算下降而别处无代码改动 | 已知边界;正则在代码里,扩宽是可评审的 diff,未过滤总数仍在预算内 | @@ -890,7 +890,7 @@ PR review 保留这些层级。普通改动记录检查范围和理由,无共 | `protocol_action_packet` | 5 | 1 | 4 | 0 | 0 | 0 | 5 | 2 | 2 | | `external_evidence_observation` | 8 | 4 | 1 | 1 | 1 | 1 | 6 | 1 | 1 | | `heartbeat_recommendation` | 17 | 8 | 4 | 1 | 0 | 4 | 13 | 1 | 1 | -| `execution_obligation` | 20 | 8 | 7 | 0 | 0 | 5 | 15 | 1 | 0 | +| `execution_obligation` | 20 | 8 | 7 | 0 | 0 | 5 | 15 | 1 | 1 | | `work_lane_contract` | 29 | 11 | 8 | 9 | 1 | 0 | 28 | 3 | 3 | | `goal_boundary` | 30 | 8 | 4 | 3 | 1 | 14 | 15 | 2 | 1 | @@ -901,14 +901,33 @@ token 计数掩盖掉的三个结果: 个模块是提示词散文与模块路径导入,迁移根本不会碰到。 - `protocol_action_packet` 只有一个 Python 读者和四个写方。它是代价最低的首个 M3 删除对象,而 token 计数说不出这一点。 -- `loopx/` 下有 1709 处 mapping 访问器使用计算式键。任何按名字的扫描——词法的 - 还是句法的——都无法归属它们,因此 smoke 把这个数字与各字段计数一起打印。这 - 就是「计数归零不授权删除」的可测形式;残余义务归 Q11。 +- `loopx/` 下有 1709 处 mapping 访问器使用计算式键,另有 395 处 TypeScript + 计算式成员访问是该运行时的对应形态。任何按名字的扫描——词法的还是句法的—— + 都无法归属它们,因此 smoke 把这两个数字与各字段计数一起打印。这就是「计数 + 归零不授权删除」的可测形式;残余义务归 Q11。 + +首个实现落地后又实测出两处更正,两者都曾把迁移面做得比实际工作量小: + +- TypeScript 扫描只认成员访问,于是 TypeScript 真正使用的写法被归成了散文: + 解构读取、对象字面量写入、以及已声明的属性签名。为两个运行时各写一遍的十二 + 种等价访问中,有八种给出不同结论。`{field: x}` 在 Python 是 writer、在 + TypeScript 是 mention,因此把一个 dict 字面量移到边界另一侧就能让迁移面缩小 + 而没有迁移任何东西;六个字段在 TypeScript 侧全部实测为零个写入者。 + `execution_obligation.ts` 读作「0 个模块要迁移」,而 `turn_envelope.ts` 正在 + 那里声明它的截断上限。现在断言两个运行时对同一种访问给出同一个结论——这正是 + 让迁移面可以据以排期的性质。 +- 标准不确定的 TypeScript 那一半此前只取自拼出过字段名的模块,145 个里的 4 个。 + 扩到每个被跟踪模块后,计数从 82 处升到 395 处,代价 0.52s。 每次运行都会按字段、按运行时断言这些角色恰好划分 token 计数。因此新指标是对同 一批模块的重新分类,而不是换了一批更小的样本;本切片也不偿还任何债务:两个预算 都在同一 diff 里钉在各自的实测值上。 +这个划分给每个模块分配它首个命中的角色,回答的是「这个模块主要是什么」,不是 +「谁写这个字段」。`work_lane_contract` 有 8 个模块角色为 `writer`,而实际写它的 +有 13 个;另外 5 个同时也读它,于是划分把它们算作读者。退休时要找齐生产者,读 +的是打印在划分旁边的 `reads`/`writes`/`binds` 重叠计数,而不是这个划分本身。 + 这道检查在 29.4s 的守卫上增加 7.5s,两棵树各实测两次。扫描必须遍历每个被跟踪的 Python 模块:计算式键总数是全仓范围的,一个从不提及任何字段的模块同样计入它。 `parse_python` 从 `python_facts` 中析出,使两处扫描对不可解析的源抛出同一个错误; @@ -918,9 +937,11 @@ Python 模块:计算式键总数是全仓范围的,一个从不提及任何 TypeScript 一次批量复用 `scripts/semantic_production_scan.mjs` 的 TypeScript 解析器,按 AST 属性和字面量下标区分读、写及复合更新,覆盖可选访问与模板插值。 -注释、引用示例与正则字面量不会变成访问,也不会遮住后续代码。裸对象/类型键 -仍计为提及:单凭语法不能断定该对象承载退休字段的 payload。计算式键的数据流 -与外部消费者仍在本指标之外,M3 删除前必须另行核查;token 预算保持不变。 +注释、引用示例与正则字面量不会变成访问,也不会遮住后续代码。对象字面量键按 +Python 的 `dict_literal_key` 同样计为写入,类型/接口属性签名计为承载,解构 +计为读取,未被任何键位消费的字段名字符串计为未定——每一条都与 Python 侧同构。 +计算式键的数据流与外部消费者仍在本指标之外,M3 删除前必须另行核查;token 预算 +保持不变。 ### 2026-09-17 — 不变量表述收敛到各自已验证的值域 @@ -1091,7 +1112,7 @@ TypeScript 一次批量复用 `scripts/semantic_production_scan.mjs` 的 TypeScr | --- | --- | --- | --- | --- | | 2026-09-16 | Q9:全树按需计算;移除已提交结构清单 | 根据[维护者反馈](https://github.com/huangruiteng/loopx/pull/4360#issuecomment-5692062394)实现,PR 评审待完成 | 取代合并后补再生成;拒绝只扫描 diff | 1、I6、3、5、9、10、12 | | 2026-09-16 | B2:Python producer 扫描器绑定一跳未改名再导出 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2;PR 评审待完成 | 要求每个消费者都从 owner 模块导入(脆弱;M2 中已静默失效);拒绝无界多跳解析 | 5、附录 A | -| 2026-09-17 | B3:在 token 计数旁边为迁移面设预算;Q11 决策前两者都保留 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3;PR 评审待完成 | 直接用新指标取代 token 预算(否决:token 计数正是证明新角色划分同一批模块的锚,在引入角色的同一个 diff 里把它删掉会让更小的数字无法复核);把计算式下标也计为未定读取(否决:`rows[index]` 与 `payload[key]` 是同一种语法,未知数会大到不再携带信息) | 5、9、11、12 | +| 2026-09-17 | B3:在 token 计数旁边为迁移面设预算;Q11 决策前两者都保留 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3;PR 评审待完成 | 直接用新指标取代 token 预算(否决:token 计数正是证明新角色划分同一批模块的锚,在引入角色的同一个 diff 里把它删掉会让更小的数字无法复核);把 Python 计算式下标也计为未定读取(否决:`rows[index]` 与 `payload[key]` 是同一种语法,未知数会大到不再携带信息;TypeScript 没有 mapping 访问器约定,其计算式成员访问单独计数并声明为上界) | 5、9、11、12 | | 2026-09-16 | B1 改名不变性:新增按名字归组的分歧报告;写明它未闭合的边界 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B1;PR 评审待完成 | 把预算改按值集归组(否决:`CONFIDENCE_LEVELS` 与 `EDGE_CASE_COMPLEXITIES` 共享 `high/low/medium` 而含义不同);提交名字账本(M0 否决:Q9 已退役提交式清单)。该报告列出仍然存在的分叉;初稿称它能抓住单侧改名,实测证否,故两份镜像按真实行为写明边界 | 9 | | 2026-09-17 | 将 F1/F2 限定在 kernel 层与扫描范围,把 F4 重述为作用域枚举完备性,并给每条义务加上可推导的 `domain` | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447);**需要内核维护者批准,尚未获得** | 保留无条件表述、只在正文记一笔缺口(否决:该表述比 `validate_production` 自己的 docstring 还强);把 F4 重述为各上下文值集互斥(否决:会被仓库自身数据推翻,`scope_declarations` 恰恰就是为了允许合理的同名复用);扒宽扫描让无条件声明成立(否决:那是自带风险的另一个变更) | 5、9、附录 B、附录 C | @@ -1121,7 +1142,7 @@ TypeScript 一次批量复用 `scripts/semantic_production_scan.mjs` 的 TypeScr | E21 | F1/F2 写成无条件,但只在一个层上被验证 | `3ca868193` | 从源码树读 `check_producers` 的跳过谓词与 producer 扫描根目录 | 26 个词表中 6 个声明了 `producers`,恰好是 `tier: kernel` 那几个;被跳过的 20 个全部是 `cross_runtime`;扫描触及 1203 个已跟踪 `loopx/**/*.{py,ts}` 中的 432 个(35.9%),未覆盖部分主要是 capabilities 285、其余控制面 192、extensions 83 | 计数来自注册表与已跟踪源码树;分母会随任何新模块移动,所以只上报、不钉住 | | E22 | 15 个被上报的未解析位点永远不可能成为证据 | `3ca868193` | smoke 报告的 `unresolved_producer_blockers` | 41 个未解析位点,其中 `argument_name_only` 10 个、`annotation_only` 5 个分别是以字段名命名的关键字参数和裸声明;其余 26 个是动态或跨过程的 | 按标签归组;这两个标签在扫描器里由代码持有,因此这个下界只能靠改代码移动 | | E23 | F4 写法本身不可能被违反 | `3ca868193` | 对照 F4 表述阅读 `check_scope_declarations` | 作用域是声明的、从不推断,所以 `conflict := collision ∧ scope_overlap` 是一条定义;真正被强制的是一份声明必须恰好枚举每个定义模块,范围是 1 份声明、4 个上下文 | 阅读检查后的判断;各上下文值集互斥故意*不*作为该性质,因为 `SOURCE_SURFACES` 正是合理地在四个上下文复用同一个名字(E19) | -| E24 | 退休预算把提及算成了读者 | B3 integration tree | 对六个旧字段运行 `check_reader_metric()`;断言角色划分 `count_identifier_modules()` | 109 个 py token 模块解析为 82 个迁移面模块;`goal_boundary` 30 → 15,`work_lane_contract` 29 → 28,`protocol_action_packet` 5 → 5 且只有一个读者 | 度量句法使用而非数据流;1709 处计算式键 mapping 访问仍无法归属,因此迁移面为零不等于读者为零 | +| E24 | 退休预算把提及算成了读者 | B3 integration tree | 对六个旧字段运行 `check_reader_metric()`;断言角色划分 `count_identifier_modules()`;断言两个运行时对同一种访问给出同一结论 | 109 个 py token 模块解析为 82 个迁移面模块;`goal_boundary` 30 → 15,`work_lane_contract` 29 → 28,`protocol_action_packet` 5 → 5 且只有一个读者;十二种等价访问中曾有 8 种跨运行时结论不一致,六个字段在 TS 侧曾全部实测为零写入者 | 度量句法使用而非数据流;1709 处 Python 计算式键访问与 395 处 TypeScript 计算式成员仍无法归属,因此迁移面为零不等于读者为零;角色划分不是生产者计数 | | E13 | 冲突预算主要在度量局部命名 | `1dc6ad8d8` | 对 `conflicting_values` 与 `same_runtime_forks` 名字应用 `MODULE_LOCAL_CONVENTION` | 18 个冲突中 16 个、25 个分叉中 7 个是模块局部约定;语义子集分别为 2 与 18 | 分类是名字模式,已在扫描器中说明并由夹具测试钉住 | ## 附录 D:被否决或取代的方案 diff --git a/examples/semantic-vocabulary-drift-smoke.py b/examples/semantic-vocabulary-drift-smoke.py index ac65da1b47..b85999e2e6 100755 --- a/examples/semantic-vocabulary-drift-smoke.py +++ b/examples/semantic-vocabulary-drift-smoke.py @@ -229,7 +229,7 @@ # registry and this literal move in one diff. This does not replace the token # budget above; Q11 owns that decision, and until it lands both are checked. MIGRATION_SURFACE_ANCHOR = { - "execution_obligation": (15, 0), + "execution_obligation": (15, 1), "heartbeat_recommendation": (13, 1), "work_lane_contract": (28, 3), "external_evidence_observation": (6, 1), @@ -833,13 +833,19 @@ def check_reader_metric(registry: dict[str, Any], sources: list[SourceFile]) -> * the five roles partition the token count exactly, so the new metric is a reclassification of the same modules and not a different population that - happens to be smaller; + happens to be smaller. The partition assigns each module its first + matching role, so it answers "what is this module mainly", not "who + writes this field": the ``reads``/``writes``/``binds`` counts beside it + overlap on purpose and are the ones a retirement reads to find every + producer; * the migration surface stays within its anchored budget, so a new reader of a legacy field fails the PR path that adds it; * the unresolved populations stay visible. ``dynamic_mapping_key_sites`` - counts mapping accessors with a computed key anywhere under ``loopx/``; - while that number is nonzero, a field measured at zero readers is not - thereby proven dead, and the smoke says so in its own output. + counts Python mapping accessors with a computed key anywhere under + ``loopx/``, and ``typescript_dynamic_member_sites`` counts the + TypeScript computed member accesses that are its nearest equivalent; + while either is nonzero, a field measured at zero readers is not thereby + proven dead, and the smoke says so in its own output. """ ledger = registry["retirement_ledger"]["should_run_legacy_decision_fields"]["fields"] summary = field_use_summary(ledger, sources) @@ -872,9 +878,17 @@ def check_reader_metric(registry: dict[str, Any], sources: list[SourceFile]) -> detail.append( f"{field}{suffix} surface={actual}/{budget} " + " ".join(f"{role}={entry[f'{runtime}_{role}_modules']}" for role in ROLES) + + " | " + " ".join( + f"{label}={entry[f'{runtime}_{label}_modules']}" + for label in ("reads", "writes", "binds") + ) + f" carriers={carriers}" ) - report.append(f"dynamic_mapping_key_sites={summary['dynamic_mapping_key_sites']} (computed keys, unattributable)") + report.append( + f"dynamic_mapping_key_sites={summary['dynamic_mapping_key_sites']} " + f"typescript_dynamic_member_sites={summary['typescript_dynamic_member_sites']} " + "(computed keys, unattributable)" + ) return report, detail diff --git a/loopx/semantics/field_use.py b/loopx/semantics/field_use.py index b88f788a4d..3c503a5a96 100644 --- a/loopx/semantics/field_use.py +++ b/loopx/semantics/field_use.py @@ -56,25 +56,46 @@ READ_FORMS = frozenset({ "subscript_read", "mapping_call_read", "membership_read", "attribute_read", - "property_read", + "property_read", "destructured_read", }) WRITE_FORMS = frozenset({ "subscript_write", "mapping_call_write", "dict_literal_key", "keyword_argument", - "attribute_write", "property_write", + "attribute_write", "property_write", "object_literal_key", }) # The module names the field as a parameter, a local or its own definition. It # handles the value without a recognized key access -- a pass-through consumer # in the RFC's role hierarchy, and a signature the migration has to change. -BINDING_FORMS = frozenset({"local_binding", "local_reference", "parameter", "definition"}) +BINDING_FORMS = frozenset({ + "local_binding", "local_reference", "parameter", "definition", + "property_signature", +}) # The field name travels as data here: a string constant that no recognized key # position consumed -- a name in a field list a loop will index with, or a label # in an emitted record. Which one it is needs a reader, so the module is # reported as unresolved rather than silently counted as a mention. UNRESOLVED_FORMS = frozenset({"name_constant"}) -MENTION_FORMS = frozenset({"module_import", "object_key", "prose"}) +MENTION_FORMS = frozenset({"module_import", "prose"}) USE_FORMS = READ_FORMS | WRITE_FORMS | BINDING_FORMS | UNRESOLVED_FORMS | MENTION_FORMS +@dataclass(frozen=True) +class UnresolvedKeySites: + """Computed-key accesses that no name-keyed scan can attribute to a field. + + The two runtimes are counted apart because their exclusions differ, and a + single total would hide that. ``python_mapping_calls`` counts only + ``mapping.get(name)``-shaped calls with a computed argument, because + ``rows[index]`` and ``payload[key]`` are the same subscript syntax in + Python. TypeScript has no mapping-accessor convention, so the equivalent + read *is* the computed member access: ``typescript_members`` counts those + with a non-numeric argument and is therefore an upper bound that includes + indexing an array by a variable. + """ + + python_mapping_calls: int = 0 + typescript_members: int = 0 + + @dataclass(frozen=True) class FieldUse: """One module's recognized uses of one field.""" @@ -219,20 +240,30 @@ def lexical_module_count(field: str, suffix: str, sources: Iterable[SourceFile]) return sum(1 for source in sources if source.suffix == suffix and token.search(source.text)) -def scan_field_uses(fields: Iterable[str], sources: Iterable[SourceFile]) -> tuple[list[FieldUse], int]: - """Classify every module's use of each field; also return the unresolved count.""" +def scan_field_uses( + fields: Iterable[str], sources: Iterable[SourceFile], +) -> tuple[list[FieldUse], UnresolvedKeySites]: + """Classify every module's use of each field, beside the standing unknown.""" wanted = frozenset(fields) uses: list[FieldUse] = [] dynamic_sites = 0 materialized = list(sources) - ts_sources = [source for source in materialized if source.suffix == ".ts" - and any(_token_pattern(field).search(source.text) for field in wanted)] + # Every TypeScript module is scanned, not only those that name a field: + # the standing unknown is repository-wide on both sides, and narrowing it + # to modules that spell the field would measure a zero-reader field against + # an unknown that excludes the modules most able to hide a reader. The + # scanner records a form only for a requested field, so the wider + # population costs one parse and adds no per-field work. Measured on 145 + # tracked modules: 0.86s narrowed against 0.52s more for all of them, and + # the unknown it reports rises from 82 sites to 395. + ts_sources = [source for source in materialized if source.suffix == ".ts"] # One AST request for the complete TS population, not one Node process per # module/field. Reuse the producer scanner's parser and safe error boundary. - ts_forms = {row["path"]: row["fields"] for row in run_typescript_scan( + ts_rows = {row["path"]: row for row in run_typescript_scan( Path(__file__).resolve().parents[2], ts_sources, {"mode": "field_uses", "fields": sorted(wanted)}, )} + ts_dynamic_sites = sum(int(row.get("dynamic_member_sites") or 0) for row in ts_rows.values()) for source in materialized: # Every Python module contributes to the computed-key total whether or # not it names a field, so the cheap substring filter only narrows the @@ -254,7 +285,8 @@ def scan_field_uses(fields: Iterable[str], sources: Iterable[SourceFile]) -> tup forms, module_dynamic_sites = python_module_scan(tree, present) dynamic_sites += module_dynamic_sites elif source.suffix == ".ts": - forms = {field: set(observed) for field, observed in ts_forms.get(source.path, {}).items()} + row = ts_rows.get(source.path) or {} + forms = {field: set(observed) for field, observed in (row.get("fields") or {}).items()} else: continue for field in present: @@ -272,25 +304,41 @@ def scan_field_uses(fields: Iterable[str], sources: Iterable[SourceFile]) -> tup # check relies on. Fail where the form was added, not there. raise ValueError(f"unclassified field-use form(s): {sorted(unclassified)}") uses.append(FieldUse(field=field, module=source.path, forms=frozenset(recognized))) - return sorted(uses, key=lambda use: (use.field, use.module)), dynamic_sites + return sorted(uses, key=lambda use: (use.field, use.module)), UnresolvedKeySites( + python_mapping_calls=dynamic_sites, typescript_members=ts_dynamic_sites, + ) ROLES = ("reader", "writer", "binding", "unresolved", "mention") def field_use_summary(fields: Iterable[str], sources: Iterable[SourceFile]) -> dict[str, Any]: - """Per-field role counts and migration surface, beside the old token count. + """Per-field use counts and migration surface, beside the old token count. ``migration_surface`` is the number of modules that must change before the field can be removed: every reader, writer and binding. Mentions are prose and imports, and ``unresolved`` modules carry the field name as data, so they are reported separately rather than folded into a budget that would then move when a comment is reworded. + + Two counts are reported per runtime and they answer different questions. + ``*_reads_modules``/``*_writes_modules`` are the direct answer to "how many + modules read this" and "how many write it"; a module that does both is in + both, because a retirement has to fix both sites. ``*_{role}_modules`` is + instead a partition by the first role in ``ROLES`` that a module matches, + so the five counts sum to the token count and the ledger can assert that + the roles reclassify that population rather than sample a smaller one. A + reader that also writes is a ``reader`` there and invisible in ``writer``, + which is why the partition must not be read as a producer count. """ materialized = list(sources) ordered = sorted(fields) - uses, dynamic_sites = scan_field_uses(ordered, materialized) - summary: dict[str, Any] = {"fields": {}, "dynamic_mapping_key_sites": dynamic_sites} + uses, unknown = scan_field_uses(ordered, materialized) + summary: dict[str, Any] = { + "fields": {}, + "dynamic_mapping_key_sites": unknown.python_mapping_calls, + "typescript_dynamic_member_sites": unknown.typescript_members, + } for field in ordered: entry: dict[str, Any] = {} for suffix, runtime in ((".py", "python"), (".ts", "typescript")): @@ -298,6 +346,12 @@ def field_use_summary(fields: Iterable[str], sources: Iterable[SourceFile]) -> d roles = [use.role for use in selected] for role in ROLES: entry[f"{runtime}_{role}_modules"] = roles.count(role) + for label, predicate in ( + ("reads", lambda use: use.reads), + ("writes", lambda use: use.writes), + ("binds", lambda use: use.binds), + ): + entry[f"{runtime}_{label}_modules"] = sum(1 for use in selected if predicate(use)) entry[f"{runtime}_migration_surface"] = sum(1 for use in selected if use.in_migration_surface) entry[f"{runtime}_token_modules"] = lexical_module_count(field, suffix, materialized) summary["fields"][field] = entry diff --git a/loopx/semantics/vocabulary_v0.json b/loopx/semantics/vocabulary_v0.json index 17349a845d..d0ec43ae91 100644 --- a/loopx/semantics/vocabulary_v0.json +++ b/loopx/semantics/vocabulary_v0.json @@ -954,7 +954,7 @@ "python_module_budget": 20, "typescript_module_budget": 1, "python_migration_surface": 15, - "typescript_migration_surface": 0 + "typescript_migration_surface": 1 }, "heartbeat_recommendation": { "python_module_budget": 17, diff --git a/scripts/semantic_production_scan.mjs b/scripts/semantic_production_scan.mjs index 59d629b88c..c7dd8c1809 100644 --- a/scripts/semantic_production_scan.mjs +++ b/scripts/semantic_production_scan.mjs @@ -64,15 +64,34 @@ for (const source of request.sources) { if (request.mode === 'field_uses') { const fields = new Set(request.fields); const forms = new Map(); + // A computed member access may name any field, so it is the standing + // unknown a name-keyed scan cannot resolve. It is reported apart from the + // Python mapping-call count because the two exclusions differ: `rows[i]` + // and `payload[key]` are one syntax here, so this is an upper bound. + let dynamicSites = 0; + // String literals a key position consumed. A field-named literal left over + // is the name travelling as data -- the Python scan's `name_constant`. + const keyed = new Set(); + const constants = []; const record = (key, form) => { if (!fields.has(key)) return; if (!forms.has(key)) forms.set(key, new Set()); forms.get(key).add(form); }; + const markKeyed = name => { + if (!name) return; + keyed.add(name); + if (ts.isComputedPropertyName(name)) keyed.add(unwrap(name.expression)); + }; const visit = node => { if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { const property = ts.isPropertyAccessExpression(node); const key = property ? node.name.text : staticName(node.argumentExpression); + if (!property) { + const argument = unwrap(node.argumentExpression); + if (key === null && argument && !ts.isNumericLiteral(argument)) dynamicSites += 1; + else if (key !== null) keyed.add(argument); + } let target = node, parent = node.parent; while (parent && unwrap(parent) === target) { target = parent; parent = parent.parent; } const assignment = ts.isBinaryExpression(parent) && parent.left === target && @@ -87,15 +106,32 @@ for (const source of request.sources) { (assignment && parent.operatorToken.kind !== ts.SyntaxKind.EqualsToken)) { record(key, `${prefix}_read`); } - } else if (ts.isPropertyAssignment(node) || ts.isPropertySignature(node)) { - // Preserve B3's declared metric: bare keys are mentions. AST shape - // alone does not prove this object carries the retired payload field. - record(named(node.name), 'object_key'); + } else if (ts.isBindingElement(node) && ts.isObjectBindingPattern(node.parent)) { + // `const {field} = payload` and `const {field: alias} = payload` read + // the field exactly as `payload.field` does, in a declaration or in a + // parameter list. `propertyName` is present only when the binding + // renames the key; otherwise the bound name is the key. + const name = node.propertyName ?? node.name; + markKeyed(name); + record(named(name), 'destructured_read'); + } else if (ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node)) { + // An object literal key is where TypeScript writes the field. The + // Python scan counts the same construct as `dict_literal_key`, so + // porting a dict literal to TypeScript must not shrink the surface. + markKeyed(node.name); + record(named(node.name), 'object_literal_key'); + } else if (ts.isPropertySignature(node) || ts.isPropertyDeclaration(node)) { + // A declared contract surface: the type a retirement has to change. + markKeyed(node.name); + record(named(node.name), 'property_signature'); + } else if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + if (fields.has(node.text)) constants.push(node); } ts.forEachChild(node, visit); }; visit(tree); - result.push({path: source.path, fields: Object.fromEntries( + for (const node of constants) if (!keyed.has(node)) record(node.text, 'name_constant'); + result.push({path: source.path, dynamic_member_sites: dynamicSites, fields: Object.fromEntries( [...forms].map(([key, observed]) => [key, [...observed].sort()]), )}); continue; diff --git a/tests/architecture/test_semantic_field_use.py b/tests/architecture/test_semantic_field_use.py index cb304e62ae..1cd5d06c68 100644 --- a/tests/architecture/test_semantic_field_use.py +++ b/tests/architecture/test_semantic_field_use.py @@ -117,8 +117,8 @@ def test_computed_keys_are_counted_as_the_standing_unknown() -> None: def test_computed_keys_are_counted_in_modules_that_never_name_the_field() -> None: - uses, dynamic_sites = scan_field_uses([FIELD], [source("payload.get(name)\n", path="loopx/other")]) - assert uses == [] and dynamic_sites == 1, ( + uses, unknown = scan_field_uses([FIELD], [source("payload.get(name)\n", path="loopx/other")]) + assert uses == [] and unknown.python_mapping_calls == 1, ( "the standing unknown is repository-wide; narrowing it to modules that name the " "field would make a zero-reader field look proven" ) @@ -185,3 +185,120 @@ def test_roles_partition_the_token_count_so_the_metric_reclassifies_one_populati classified = sum(summary[f"python_{role}_modules"] for role in ROLES) assert classified == summary["python_token_modules"] == 5 assert summary["python_migration_surface"] == 3 + + +# One access, written the way each runtime writes it. The metric exists to say +# where a retirement has to work, so the answer has to come from what the code +# does, not from which syntax it happens to use. A runtime that reads its half +# differently lets a port manufacture progress: move a dict literal to +# TypeScript and the migration surface shrinks with nothing migrated. +EQUIVALENT_ACCESSES = [ + ("read", 'value = payload["goal_boundary"]', 'const value = payload["goal_boundary"];'), + ("read", 'value = payload.get("goal_boundary")', "const value = payload.goal_boundary;"), + ("read", 'value = payload["goal_boundary"]', "const {goal_boundary} = payload;"), + ("read", 'value = payload["goal_boundary"]', "const {goal_boundary: renamed} = payload;"), + ("read", "def f(payload):\n return payload['goal_boundary']\n", + "function f({goal_boundary}: Payload) { return goal_boundary; }"), + ("write", 'out = {"goal_boundary": built}', "const out = {goal_boundary: built};"), + ("write", 'out["goal_boundary"] = built', "out.goal_boundary = built;"), + ("declare", "def goal_boundary():\n return 1\n", + "interface Decision { goal_boundary: JsonObject; }"), + ("unknown", 'LEGACY = ["goal_boundary"]', 'const legacy = ["goal_boundary"];'), +] + + +@pytest.mark.parametrize("meaning, python_source, typescript_source", EQUIVALENT_ACCESSES) +def test_an_equivalent_rewrite_does_not_change_the_answer( + meaning: str, python_source: str, typescript_source: str, +) -> None: + """Both runtimes must classify the same access the same way. + + This is the test that makes the surface safe to plan against. Without it + the metric measures TypeScript idiom rather than TypeScript behaviour: + destructuring is how TypeScript reads a payload and an object literal is + how it writes one, and both once counted as prose. + """ + python_role = role(python_source) + typescript_role = role(typescript_source, ".ts") + assert python_role == typescript_role, ( + f"{meaning}: python reads this as {python_role} and typescript as " + f"{typescript_role}; a port between the runtimes would move the surface" + ) + + +@pytest.mark.parametrize("text, expected", [ + ("const {goal_boundary} = payload;", "reader"), + ("const {goal_boundary: renamed} = payload;", "reader"), + ("const {outer: {goal_boundary}} = payload;", "reader"), + ("function build({goal_boundary}: Decision) { return goal_boundary; }", "reader"), + ("const out = {goal_boundary: built};", "writer"), + ("const out = {goal_boundary};", "writer"), + ('const out = {"goal_boundary": built};', "writer"), + ("interface Decision { goal_boundary: JsonObject; }", "binding"), + ("type Decision = { goal_boundary: JsonObject };", "binding"), + ("class Decision { goal_boundary: JsonObject; }", "binding"), + ('const names = ["goal_boundary"];', "unresolved"), + ('const key = "goal_boundary"; const value = payload[key];', "unresolved"), +]) +def test_typescript_recognizes_the_forms_its_own_idiom_uses(text: str, expected: str) -> None: + assert role(text, ".ts") == expected + + +def test_a_typescript_computed_member_is_the_standing_unknown_its_runtime_has() -> None: + """A computed member read is TypeScript's `mapping.get(name)`. + + Python excludes `payload[key]` because `rows[index]` is the same syntax; + TypeScript has no mapping accessor, so the computed member *is* the access + and the count is an upper bound. Counting nothing at all was the worse + error: it let the smoke claim a stated unknown that covered one runtime. + """ + _, unknown = scan_field_uses([FIELD], [source("const value = payload[key];", ".ts")]) + assert unknown.typescript_members == 1 + _, indexed = scan_field_uses([FIELD], [source("const first = rows[0];", ".ts")]) + assert indexed.typescript_members == 0, "a numeric literal index is not a mapping read" + + +def test_the_typescript_unknown_covers_modules_that_never_name_the_field() -> None: + """The standing unknown has to be repository-wide on both sides. + + Scanning only the modules that spell a field would measure a zero-reader + field against an unknown drawn from the modules least likely to hide a + reader. Python already counts every tracked module; this is the same + obligation for the runtime whose population the substring filter used to + narrow from 145 modules to 4. + """ + _, unknown = scan_field_uses( + [FIELD], [source("const value = payload[key];", ".ts", path="loopx/other")], + ) + assert unknown.typescript_members == 1 + + +def test_a_module_that_reads_and_writes_is_counted_in_both() -> None: + """The role partition answers a different question than the producer count. + + A projection module reads the legacy field and re-emits it. The partition + calls it a reader, because reader is first in ROLES, and it disappears from + the writer count -- so a retirement looking for every producer would miss + it. The overlapping counts are the ones that answer that. + """ + text = ('value = payload["goal_boundary"]\n' + 'out = {"goal_boundary": value}\n') + summary = field_use_summary([FIELD], [source(text, path="loopx/projection")])["fields"][FIELD] + assert summary["python_reader_modules"] == 1 and summary["python_writer_modules"] == 0 + assert summary["python_reads_modules"] == 1 and summary["python_writes_modules"] == 1 + assert summary["python_migration_surface"] == 1 + + +def test_the_roles_still_partition_the_token_count_over_the_typescript_forms() -> None: + """The new TypeScript forms must reclassify carriers, not add or drop any.""" + sources = [ + source("const {goal_boundary} = payload;", ".ts", path="loopx/reader"), + source("const out = {goal_boundary: built};", ".ts", path="loopx/writer"), + source("interface D { goal_boundary: JsonObject }", ".ts", path="loopx/binding"), + source('const names = ["goal_boundary"];', ".ts", path="loopx/unresolved"), + source("// goal_boundary", ".ts", path="loopx/mention"), + ] + summary = field_use_summary([FIELD], sources)["fields"][FIELD] + classified = sum(summary[f"typescript_{role}_modules"] for role in ROLES) + assert classified == summary["typescript_token_modules"] == 5 + assert summary["typescript_migration_surface"] == 3 From 23058ba0c1cdb344344a03e543534e6249099bb8 Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:35:56 -0400 Subject: [PATCH 08/11] fix(semantics): count every fact a field use establishes, not one label Three defects the B3 review found are still open after the TypeScript spelling fix. Each of them let the migration surface shrink without any code being migrated. * The summary counted role labels. The label is single-valued, so a module that both read and wrote the field was counted only as a reader and the overlap could not appear at all. The previous round added reads/writes/ binds beside it; all five facts are now counted, a module is in every set it belongs to, and `*_classified_modules` is their union. The smoke asserts the facts *cover* the token population -- they overlap, so they cannot be asked to sum to it -- while the role partition keeps its own sum-to-token assertion. `role` now documents that it is an ordering and printing label and nothing to count with. * A module holding the field name as data was reported `unresolved` and then excluded from the migration surface, on the reasoning that it was not known to need migration. It is known to need investigation: nobody can say the field is absent from that module without opening it, and that is field-specific work either way. Excluding it also let the surface fall when a reader was rewritten into a form the scan cannot resolve. The repository-wide computed-key totals stay outside every surface, because they belong to no field and emptying one cannot retire them. * An unparseable tracked Python module was credited as a mention. It may hold readers, so calling it prose shrank the surface on the strength of a parse failure. It is now that field's unknown, in the surface. Raising instead was rejected: it fails the scan for a direct caller working on a half-written tree, and unknown is the honest classification. One logical access spelled seven ways is now pinned individually. The cross-runtime equivalence test asserts the two runtimes agree; it would pass with both of them reading a destructuring as prose, which is the shape the first implementation had. Each spelling must now land in a named class that is inside the surface, and none may be a mention. Three budgets are re-pinned at their re-measured values, all upward and none because code was added: external_evidence_observation py 6 -> 7, goal_boundary py 15 -> 16, work_lane_contract py 28 -> 29. The token budgets did not move. The check costs 8.5s of a 36.9s guard, the median of five runs timing the function inside the guard. Refs #4447 B3. Signed-off-by: song <22676124+songoow@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) --- .../semantic-vocabulary-convergence-v0.md | 86 +++++++++---- ...emantic-vocabulary-convergence-v0.zh-CN.md | 68 +++++++--- examples/semantic-vocabulary-drift-smoke.py | 53 +++++--- loopx/semantics/field_use.py | 120 +++++++++++++----- loopx/semantics/vocabulary_v0.json | 8 +- tests/architecture/test_semantic_field_use.py | 106 +++++++++++++++- .../test_semantic_vocabulary_drift.py | 31 +++++ 7 files changed, 367 insertions(+), 105 deletions(-) diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md index 7d312fb3b1..b27d0992c6 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md @@ -825,9 +825,9 @@ on the next full-tree scan; genuine shared-contract changes still need review. | No behavior change from the two owner fixes | `uv run --extra test python -m pytest tests/test_loopx_turn_transaction.py tests/test_loop_turn_loop_controller.py tests/test_turn_loop_disposition.py tests/test_loopx_turn_managed_step.py tests/control_plane -k authority` and `uv run --extra test loopx canary premerge --from-git-diff` | pass | Environment failures already present on `main` are excluded when reproduced on a clean tree | | Docs governance accepts the RFC pair | `python3 examples/docs-governance-smoke.py` | pass | Checks mirror, links, index | | Retirement budgets use standalone field tokens | `count_identifier_modules()` uses identifier boundaries for the six fields | `goal_boundary`: 30 Python modules under the new metric; the old substring metric was 35 | Conservative lexical measure; it removes compound-name false positives but does not prove semantic reader absence | -| The retirement metric separates readers from mentions (B3) | `check_reader_metric()` classifies every module carrying one of the six field tokens as reader, writer, binding, unresolved or mention, and reports the overlapping read/write/bind counts beside that partition | `goal_boundary`: 30 token modules resolve to 8 readers, 4 writers, 3 bindings, 1 unresolved and 14 mentions, so its migration surface is 15, not 30; `work_lane_contract` stays at 28 of 29 | Syntactic use, not data flow. The roles are asserted to partition the token count, so the smaller number is a reclassification of the same modules and not a different population | +| The retirement metric separates readers from mentions (B3) | `check_reader_metric()` records every fact that holds of each module carrying one of the six field tokens -- reads, writes, binds, unresolved, mention only -- beside a single-label role partition | `goal_boundary`: 30 token modules resolve to 8 reading, 7 writing, 9 binding, 1 unresolved and 14 mention-only, so its migration surface is 16, not 30; `work_lane_contract` is 29 of 29 | Syntactic use, not data flow. The facts overlap, so they are asserted to *cover* the token population; the role partition is asserted to sum to it and is for ordering and printing only | | A new reader of a legacy field fails the pull-request path | Add a module reading `payload["protocol_action_packet"]` beyond the budget | `check_reader_metric` fails naming the field and the count | Committed fixture in `tests/architecture/test_semantic_vocabulary_drift.py`; the anchor equality check is the same pattern as `RETIREMENT_ANCHOR` | -| A computed key stays unresolved rather than absent | Count mapping accessors whose first argument is not a literal | 1709 sites under `loopx/`; a field measured at zero readers is measured against that standing unknown | This is why zero readers cannot by itself authorize a removal (Q11). Subscripts with a computed key are excluded: `rows[index]` and `payload[key]` are the same syntax | +| A computed key stays unresolved rather than absent | Count Python mapping accessors whose first argument is not a literal, and TypeScript computed member accesses | 1711 Python sites and 400 TypeScript sites under `loopx/`; a field measured at zero readers is measured against that standing unknown | This is why zero readers cannot by itself authorize a removal (Q11). It is attributable to no field, so unlike a field-specific unknown it never enters a surface. Python subscripts with a computed key are excluded: `rows[index]` and `payload[key]` are the same syntax | | The module-local convention filter is a code edit | Widen `MODULE_LOCAL_CONVENTION` in `inventory.py` and scan | `*_semantic` budgets fall with no code change elsewhere | Known boundary; the regex is in code so the widening is a reviewed diff, and the unfiltered totals stay budgeted | | A registered value nobody produces fails (M0.5) | Run the production-form scan on the baseline | Fails naming `effective_action` and `skip`; passes after `skip` is removed or listed `compatibility_only` | First expected I12 failure; a compared-only value is not carried | | A producer of an unregistered value fails (M0.5) | Write `effective_action: "brand_new"` in a listed producer site | Fails naming the site and the value even though no consumer compares it | I13; production is stricter than comparison | @@ -1121,13 +1121,17 @@ introduce a competing target state. 11. **Retirement budgets by identifier.** The six legacy-field budgets use `count_identifier_modules()`, so `goal_boundary_repair` is not counted as `goal_boundary`. This is a conservative lexical metric, not proof of zero - semantic readers. B3 adds `check_reader_metric()` beside it, which splits the - same modules into readers, writers, bindings, unresolved name carriers and - mentions and budgets the first three as the migration surface. Both metrics - are now checked. What stays open is whether the token budget is retired once - the surface budget has ordered a removal, and what residual evidence a field - at zero surface still owes given 1709 computed-key sites. Owner: kernel - maintainers. + semantic readers. B3 adds `check_reader_metric()` beside it, which records + every fact that holds of each module -- reads, writes, binds, unresolved, + mention only -- and budgets as the migration surface every module that is not + mention-only. That includes the field-specific unknowns: a module holding the + field name as data, or one the scan could not parse, is work someone must do + before this field can go, and its shape rather than its existence is what is + unknown. Both metrics are now checked. What stays open is whether the token + budget is retired once the surface budget has ordered a removal, and what + residual evidence a field at zero surface still owes given 1711 Python + computed-key sites and 400 TypeScript computed members, which belong to no + field and so can never be retired by emptying one. Owner: kernel maintainers. ## Appendix A: Execution ledger (non-normative) @@ -1136,33 +1140,37 @@ introduce a competing target state. The six legacy should-run fields were budgeted by a token count: modules whose text contains the standalone field name. That number answers "does this name appear here", which is not the question a retirement asks. `check_reader_metric` -classifies the same modules by syntactic role and budgets the three roles that -have to change before a field can be removed. +classifies the same modules by syntactic use and budgets every module that is +not a bare mention as the work owed before a field can be removed. The role +column below is a single label in a fixed precedence, for ordering and +printing; the counts a retirement reads are the overlapping facts beside it. | Field | Python token | reader | writer | binding | unresolved | mention | surface | TS token | surface | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | `protocol_action_packet` | 5 | 1 | 4 | 0 | 0 | 0 | 5 | 2 | 2 | -| `external_evidence_observation` | 8 | 4 | 1 | 1 | 1 | 1 | 6 | 1 | 1 | +| `external_evidence_observation` | 8 | 4 | 1 | 1 | 1 | 1 | 7 | 1 | 1 | | `heartbeat_recommendation` | 17 | 8 | 4 | 1 | 0 | 4 | 13 | 1 | 1 | | `execution_obligation` | 20 | 8 | 7 | 0 | 0 | 5 | 15 | 1 | 1 | -| `work_lane_contract` | 29 | 11 | 8 | 9 | 1 | 0 | 28 | 3 | 3 | -| `goal_boundary` | 30 | 8 | 4 | 3 | 1 | 14 | 15 | 2 | 1 | +| `work_lane_contract` | 29 | 11 | 8 | 9 | 1 | 0 | 29 | 3 | 3 | +| `goal_boundary` | 30 | 8 | 4 | 3 | 1 | 14 | 16 | 2 | 1 | Three results the token count had hidden: - `goal_boundary` and `work_lane_contract` were within one module of each other at 30 and 29, so the plan ordered them as equally expensive. Their real - surfaces are 15 and 28. Fourteen of `goal_boundary`'s modules are prompt prose - and module-path imports that no migration touches. + surfaces are 16 and 29. Fourteen of `goal_boundary`'s modules are prompt prose + and module-path imports that no migration touches; `work_lane_contract` has no + such module at all. - `protocol_action_packet` has one Python reader and four writers. It is the cheapest first M3 removal, and the token count did not say so. -- 1709 mapping accessors under `loopx/` take a computed key, and 395 TypeScript +- 1711 mapping accessors under `loopx/` take a computed key, and 400 TypeScript computed member accesses are that runtime's equivalent. No name-keyed scan, - lexical or syntactic, can attribute them, so the smoke prints both numbers - beside the per-field counts. This is the measured form of "a zero count does - not authorize a deletion"; the residual obligation is Q11's. + lexical or syntactic, can attribute them to a field, so neither is part of any + field's surface and the smoke prints both numbers beside the per-field counts. + This is the measured form of "a zero count does not authorize a deletion"; + the residual obligation is Q11's. -Two corrections were measured after the first implementation, both of which had +Five corrections were measured after the first implementation, all of which had made the surface smaller than the work: - The TypeScript scan recognized a member access and nothing else, so the @@ -1178,6 +1186,31 @@ made the surface smaller than the work: - The TypeScript half of the standing unknown was drawn only from modules that spelled a field, 4 of 145. Widening it to every tracked module raised the count from 82 sites to 395 and cost 0.52s. +- The summary counted role labels. The label is single-valued, so a module that + both read and wrote the field was counted only as a reader and read/write + overlap could not appear at all. Each of the five facts -- reads, writes, + binds, unresolved, mention only -- is now counted separately and a module is + in every set it belongs to. The sets overlap, so the smoke asserts they + *cover* the token population rather than sum to it, and the partition keeps + its own sum-to-token assertion beside them. The overlap is large: + `heartbeat_recommendation` has 8 modules reading and 8 writing within a + surface of 13, which the labels reported as 8 readers and 4 writers. +- A module holding the field name as data was reported `unresolved` and then + excluded from the migration surface, on the reasoning that it was not *known* + to need migration. It is known to need investigation: nobody can say the field + is absent from that module without opening it, and that is field-specific work + either way. Excluding it also let the surface fall when a reader was rewritten + into a form the scan could not resolve. Field-specific unknowns now count; + `external_evidence_observation` 6 → 7, `goal_boundary` 15 → 16 and + `work_lane_contract` 28 → 29 follow, each one module. The repository-wide + computed-key totals stay outside every surface, because they belong to no + field and emptying one can never retire them. +- An unparseable tracked Python module was credited as a mention. It may hold + readers, so calling it prose shrank the surface on the strength of a parse + failure. It is now that field's unknown and stays in the surface. Raising + instead was considered and rejected: it fails the scan for a direct caller + working on a half-written tree, and unknown is the honest classification + rather than a louder one. The roles are asserted to partition the token count exactly, per field and per runtime, on every run. The new metric therefore reclassifies one population @@ -1191,7 +1224,12 @@ read it and the partition calls them readers. A retirement looking for every producer reads the overlapping `reads`/`writes`/`binds` counts printed beside the partition, not the partition itself. -The check costs 7.5s on a 29.4s guard, measured twice on each tree. The scan +The check costs 8.5s of a 36.9s guard, the median of five runs that time the +function inside the guard rather than subtracting two whole-guard runs; the five +spanned 8.4-9.2s of 35.8-39.8s, or 21-25% of the guard. Subtraction was tried +first and abandoned: on a shared machine it put the same cost anywhere +between 4s and 32s, because it differences two numbers that both move with +whatever else is running. The scan must walk every tracked Python module, because the computed-key total is repository-wide and a module that never names a field still contributes to it. `parse_python` was factored out of `python_facts` so both scans raise the same @@ -1453,6 +1491,7 @@ result on the current tree; what changes is what the invariants claim. | --- | --- | --- | --- | --- | | 2026-09-16 | Q9: compute the full inventory on demand; retire the committed census | Implementation for [maintainer feedback](https://github.com/huangruiteng/loopx/pull/4360#issuecomment-5692062394); PR review pending | Committed snapshot with post-merge regeneration; diff-only scan rejected | 1, I6, 3, 5, 9, 10, 12 | | 2026-09-16 | B2: bind one unrenamed re-export hop in the Python producer scanner | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2; PR review pending | Require every consumer to import the owner module (fragile; failed silently in M2); unbounded multi-hop resolution rejected | 5, Appendix A | +| 2026-09-17 | B3 repair: count all five orthogonal use facts rather than the role labels, and put field-specific unknowns inside the migration surface | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3; PR review pending | Keep counting the role labels and add an overlap column (rejected: the label is single-valued, so any count built from it under-reports whichever fact sorted second, and one more column would not change that); leave `unresolved` outside the surface as "not known to need migration" (rejected: it is known to need investigation, and a budget that excludes it falls when a reader is rewritten into a form the scan cannot resolve); raise on an unparseable module (rejected: it fails the scan for a direct caller working on a half-written tree, and unknown is the honest classification, not a louder one); rely on the cross-runtime equivalence test alone (rejected: it asserts agreement, and two runtimes that both read a destructuring as prose agree) | 11, Appendix A, Appendix B | | 2026-09-17 | B3: budget the migration surface beside the token count; keep both until Q11 | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3; PR review pending | Replacing the token budget outright (rejected: the token count is the anchor that proves the new roles partition the same population, and dropping it in the same diff that introduces them would make the smaller number unauditable); counting Python computed-key subscripts as unresolved reads (rejected: `rows[index]` and `payload[key]` are one syntax, and the unknown would stop carrying information; TypeScript has no mapping-accessor convention, so its computed member access is counted separately and stated as an upper bound) | 5, 9, 11, 12 | | 2026-09-16 | B1 rename invariance: add the name-keyed divergence advisory; state the limit it does not close | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B1; PR review pending | Keying the budget on value sets (rejected: `CONFIDENCE_LEVELS` and `EDGE_CASE_COMPLEXITIES` share `high/low/medium` with different meanings); a committed name ledger (rejected at M0: Q9 retired the committed census). The advisory lists surviving forks by name; it was first described as catching a one-sided rename, which measurement disproved, so both mirrors state the limit as it behaves | 9 | | 2026-09-17 | B0: state schema validation, implementation stage, evidence status and blocking behaviour separately for I2/I11-I14 and the enforcement lanes; require each formal invariant id exactly once | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B0; PR review pending | Rename the `blocking_next` lane to match its behaviour (rejected: the lane name is the milestone that owns the check, and renaming it would lose that and collapse the two readings the other way); add a `blocks_today` boolean to `formal_model` (rejected: it would be one more declared field a reader could mistake for a measurement, and the fact is a property of the smoke's `main()`, which no registry edit can change); leave the lane gloss and note the gap in the ledger only (rejected: the gloss is the sentence a reviewer quotes) | 2, 5, 11, Appendix A, Appendix B | @@ -1484,7 +1523,8 @@ result on the current tree; what changes is what the invariants claim. | E21 | F1/F2 were unconditional but verified over one tier | `3ca868193` | `check_producers`' skip predicate, and the producer scan roots, read from the tree | 6 of 26 vocabularies declare `producers`, exactly the `tier: kernel` ones; the 20 skipped are all `cross_runtime`; the scan reaches 432 of 1203 tracked `loopx/**/*.{py,ts}` files (35.9%), the uncovered bulk being capabilities 285, other control-plane 192, extensions 83 | Counts from the registry and the tracked tree; the reach denominator moves with any new module, so it is reported, not pinned | | E22 | Fifteen reported unresolved sites can never become evidence | `3ca868193` | smoke report `unresolved_producer_blockers` | 41 unresolved sites, of which `argument_name_only` 10 and `annotation_only` 5 are a field-named keyword argument and a bare declaration; the other 26 are dynamic or interprocedural | Label-keyed; the two labels are code-owned in the scanner, so the floor moves only by a code edit | | E23 | F4 as written could not be violated | `3ca868193` | read `check_scope_declarations` against the F4 statement | Scope is declared and never inferred, so `conflict := collision ∧ scope_overlap` is a definition; what is enforced is that a declaration names every defining module exactly once, over 1 declaration and 4 contexts | Judgement from reading the check; value-set disjointness across contexts is deliberately *not* the property, because `SOURCE_SURFACES` legitimately reuses one name in four contexts (E19) | -| E24 | The retirement budget counted mentions as readers | B3 integration tree | `check_reader_metric()` over the six legacy fields; roles asserted to partition `count_identifier_modules()`; both runtimes asserted to classify one access identically | 109 py token modules resolve to 82 surface modules; `goal_boundary` 30 → 15, `work_lane_contract` 29 → 28, `protocol_action_packet` 5 → 5 with one reader; 8 of 12 equivalent accesses had disagreed across the runtimes, and all six fields had measured zero TypeScript writers | Syntactic use, not data flow; 1709 Python computed-key accessors and 395 TypeScript computed members stay unattributable, so zero surface is not zero readers; the role partition is not a producer count | +| E24 | The retirement budget counted mentions as readers | B3 integration tree | `check_reader_metric()` over the six legacy fields; the five facts asserted to cover `count_identifier_modules()` and the role labels to partition it; both runtimes asserted to classify one access identically | 109 py token modules resolve to 85 surface modules; `goal_boundary` 30 → 16, `work_lane_contract` 29 → 29, `protocol_action_packet` 5 → 5 with one module reading it; 8 of 12 equivalent accesses had disagreed across the runtimes, and all six fields had measured zero TypeScript writers | Syntactic use, not data flow; 1711 Python computed-key accessors and 400 TypeScript computed members stay unattributable and belong to no field, so zero surface is not zero readers; the role partition is not a producer count | +| E27 | Agreement between runtimes does not by itself keep an access in the surface | B3 repair tree | Seven spellings of one TypeScript access, each scanned alone and each required to land in a named class that is inside the migration surface: dotted read, subscript read, shorthand and aliased destructuring, object-literal key, type property, name carried to a subscript | All seven are in the surface; none is a mention. The cross-runtime equivalence test alone would have passed with both runtimes reading a destructuring as prose, which is the shape the first implementation actually had | A counterexample set, not a proof of completeness: an eighth spelling nobody wrote down is still unmeasured, which is why an unprovable use lands in `unresolved` rather than in a confident default | | E13 | The conflict budget mostly measured local naming | `1dc6ad8d8` | `MODULE_LOCAL_CONVENTION` applied to `conflicting_values` and `same_runtime_forks` names | 16 of 18 conflicts and 7 of 25 forks are module-local conventions; the semantic subsets are 2 and 18 | Classification is a name pattern, documented in the scanner and pinned by a fixture test | ## Appendix D: Rejected or superseded alternatives 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 f2bc0b8949..1a73beccd5 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md @@ -667,9 +667,9 @@ owner 符号集合的组:`EffectiveAction` 与 `EFFECTIVE_ACTIONS` 是同一 | 两处 owner 修正不改变行为 | `uv run --extra test python -m pytest tests/test_loopx_turn_transaction.py tests/test_loop_turn_loop_controller.py tests/test_turn_loop_disposition.py tests/test_loopx_turn_managed_step.py tests/control_plane -k authority` 与 `uv run --extra test loopx canary premerge --from-git-diff` | 通过 | 在干净树上可复现的 `main` 既有环境失败除外 | | 文档治理接受这对 RFC | `python3 examples/docs-governance-smoke.py` | 通过 | 检查镜像、链接、索引 | | 退休预算按子串而非标识符计数 | 分别以 `in file.text` 与 `\bgoal_boundary\b` 统计 `goal_boundary` | 基线上 35 对 30 个 Python 模块 | 已知边界;M3 的零读者门需要标识符计数,见第 12 节 | -| 退休指标把读者与提及分开(B3) | `check_reader_metric()` 把携带六个字段 token 的每个模块归为 reader、writer、binding、unresolved 或 mention,并在该划分旁边报告重叠的读/写/承载计数 | `goal_boundary`:30 个 token 模块解析为 8 读、4 写、3 承载、1 未定、14 提及,迁移面是 15 而非 30;`work_lane_contract` 仍是 29 中的 28 | 度量的是句法使用,不是数据流。角色被断言恰好划分 token 计数,因此更小的数字是同一批模块的重新分类,不是另一批更小的样本 | +| 退休指标把读者与提及分开(B3) | `check_reader_metric()` 为携带六个字段 token 的每个模块记录一切成立的事实——读、写、承载、未定、仅提及——并在旁边保留单标签的角色划分 | `goal_boundary`:30 个 token 模块解析为 8 读、7 写、9 承载、1 未定、14 仅提及,迁移面是 16 而非 30;`work_lane_contract` 是 29 中的 29 | 度量的是句法使用,不是数据流。事实相互重叠,因此断言它们**覆盖** token 样本;角色划分断言相加等于它,且只用于排序与打印 | | 旧字段新增读者会在 PR 路径上失败 | 让一个模块读 `payload["protocol_action_packet"]` 从而超出预算 | `check_reader_metric` 失败并点名该字段与计数 | `tests/architecture/test_semantic_vocabulary_drift.py` 内的提交测试;锚点等值检查与 `RETIREMENT_ANCHOR` 同一套模式 | -| 计算式键保持「未定」而非「不存在」 | 统计首参数不是字面量的 mapping 访问器 | `loopx/` 下 1709 处;某字段读者计为零时,是对着这个公开的未知数计零 | 这正是零读者本身不能授权删除的原因(Q11)。计算式下标不计入:`rows[index]` 与 `payload[key]` 是同一种语法 | +| 计算式键保持「未定」而非「不存在」 | 统计首参数不是字面量的 Python mapping 访问器,以及 TypeScript 计算式成员访问 | `loopx/` 下 Python 1711 处、TypeScript 400 处;某字段读者计为零时,是对着这个公开的未知数计零 | 这正是零读者本身不能授权删除的原因(Q11)。它不可归属到任何单个字段,因此与字段专属的未知不同,永远不进入任何迁移面。Python 计算式下标不计入:`rows[index]` 与 `payload[key]` 是同一种语法 | | 模块局部约定过滤器是一次代码修改 | 扩宽 `inventory.py` 的 `MODULE_LOCAL_CONVENTION` 并重新生成 | `*_semantic` 预算下降而别处无代码改动 | 已知边界;正则在代码里,扩宽是可评审的 diff,未过滤总数仍在预算内 | | 无人生产的注册值失败(M0.5) | 在基线上运行生产形式扫描 | 失败并点名 `effective_action` 与 `skip`;删除 `skip` 或列入 `compatibility_only` 后通过 | 第一个预期的 I12 失败;只被比较的值不算已携带 | | 生产未注册值失败(M0.5) | 在某个已列生产位点写 `effective_action: "brand_new"` | 即使无消费者比较它也失败,并点名位点与值 | I13;生产比比较更严 | @@ -905,11 +905,14 @@ PR review 保留这些层级。普通改动记录检查范围和理由,无共 11. **退休预算使用独立字段 token。** 六个旧字段预算使用 `count_identifier_modules()`,因此 `goal_boundary_repair` 不会被算作 `goal_boundary`。这是保守的词法指标,不等于证明不存在语义读者。B3 在它旁边 - 加入 `check_reader_metric()`:把同一批模块拆成读者、写方、形参/局部承载、 - 未定名字载体与提及,并把前三者作为迁移面纳入预算。两个指标现在都在检查。 - 仍然未决的是:当迁移面预算已经能排序删除工作后,是否退役 token 预算;以及 - 在 1709 处计算式键访问之下,迁移面为零的字段还欠哪些残余证据。 - Owner:内核维护者。 + 加入 `check_reader_metric()`:为每个模块记录一切成立的事实——读、写、承载、 + 未定、仅提及——并把所有非「仅提及」的模块作为迁移面纳入预算。这包含字段专属 + 的未知:把字段名当数据持有的模块,以及扫描无法解析的模块,都是删除该字段前 + 必须有人处理的工作,未知的是工作形态而不是工作是否存在。两个指标现在都在 + 检查。仍然未决的是:当迁移面预算已经能排序删除工作后,是否退役 token 预算; + 以及在 1711 处 Python 计算式键访问与 400 处 TypeScript 计算式成员之下,迁移 + 面为零的字段还欠哪些残余证据——它们不属于任何字段,因此清空任何一个字段都 + 无法退役它们。Owner:内核维护者。 ## 附录 A:执行账本(非规范) @@ -917,31 +920,34 @@ PR review 保留这些层级。普通改动记录检查范围和理由,无共 六个旧 should-run 字段此前按 token 计数计入预算:文本里出现该独立字段名的模块 数。这个数字回答的是「这个名字在这里出现过吗」,而不是退休所问的问题。 -`check_reader_metric` 把同一批模块按句法角色分类,并把删除该字段前必须改动的 -三种角色纳入预算。 +`check_reader_metric` 把同一批模块按句法使用分类,并把所有非「仅提及」的模块 +作为字段删除前欠下的工作纳入预算。下表的 role 列是固定优先级下的单一标签,用 +于排序与打印;退休时真正要读的计数是它旁边那些相互重叠的事实。 | 字段 | Python token | reader | writer | binding | unresolved | mention | 迁移面 | TS token | 迁移面 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | `protocol_action_packet` | 5 | 1 | 4 | 0 | 0 | 0 | 5 | 2 | 2 | -| `external_evidence_observation` | 8 | 4 | 1 | 1 | 1 | 1 | 6 | 1 | 1 | +| `external_evidence_observation` | 8 | 4 | 1 | 1 | 1 | 1 | 7 | 1 | 1 | | `heartbeat_recommendation` | 17 | 8 | 4 | 1 | 0 | 4 | 13 | 1 | 1 | | `execution_obligation` | 20 | 8 | 7 | 0 | 0 | 5 | 15 | 1 | 1 | -| `work_lane_contract` | 29 | 11 | 8 | 9 | 1 | 0 | 28 | 3 | 3 | -| `goal_boundary` | 30 | 8 | 4 | 3 | 1 | 14 | 15 | 2 | 1 | +| `work_lane_contract` | 29 | 11 | 8 | 9 | 1 | 0 | 29 | 3 | 3 | +| `goal_boundary` | 30 | 8 | 4 | 3 | 1 | 14 | 16 | 2 | 1 | token 计数掩盖掉的三个结果: - `goal_boundary` 与 `work_lane_contract` 是 30 与 29,只差一个模块,于是计划 - 把两者当作同等代价排序。它们真实的迁移面是 15 与 28。`goal_boundary` 有十四 - 个模块是提示词散文与模块路径导入,迁移根本不会碰到。 + 把两者当作同等代价排序。它们真实的迁移面是 16 与 29。`goal_boundary` 有十四 + 个模块是提示词散文与模块路径导入,迁移根本不会碰到;`work_lane_contract` + 一个这样的模块都没有。 - `protocol_action_packet` 只有一个 Python 读者和四个写方。它是代价最低的首个 M3 删除对象,而 token 计数说不出这一点。 -- `loopx/` 下有 1709 处 mapping 访问器使用计算式键,另有 395 处 TypeScript +- `loopx/` 下有 1711 处 mapping 访问器使用计算式键,另有 400 处 TypeScript 计算式成员访问是该运行时的对应形态。任何按名字的扫描——词法的还是句法的—— - 都无法归属它们,因此 smoke 把这两个数字与各字段计数一起打印。这就是「计数 - 归零不授权删除」的可测形式;残余义务归 Q11。 + 都无法把它们归属到某个字段,因此两者都不进入任何字段的迁移面,smoke 把这两 + 个数字与各字段计数一起打印。这就是「计数归零不授权删除」的可测形式;残余 + 义务归 Q11。 -首个实现落地后又实测出两处更正,两者都曾把迁移面做得比实际工作量小: +首个实现落地后又实测出五处更正,它们都曾把迁移面做得比实际工作量小: - TypeScript 扫描只认成员访问,于是 TypeScript 真正使用的写法被归成了散文: 解构读取、对象字面量写入、以及已声明的属性签名。为两个运行时各写一遍的十二 @@ -953,6 +959,23 @@ token 计数掩盖掉的三个结果: 让迁移面可以据以排期的性质。 - 标准不确定的 TypeScript 那一半此前只取自拼出过字段名的模块,145 个里的 4 个。 扩到每个被跟踪模块后,计数从 82 处升到 395 处,代价 0.52s。 +- 汇总统计的是角色标签。该标签单值,于是一个既读又写该字段的模块只被算作读者, + 读写重叠根本无法出现。现在五项事实——读、写、承载、未定、仅提及——各自单独 + 统计,模块会计入一切对它成立的集合。集合相互重叠,因此 smoke 断言它们**覆盖** + token 样本而不是相加等于它,划分则在旁边保留自己「相加等于 token 计数」的断言。 + 重叠很大:`heartbeat_recommendation` 迁移面 13 个模块里有 8 个读、8 个写,而 + 标签把它报成 8 个读者、4 个写方。 +- 把字段名当数据持有的模块被报为 `unresolved`,然后以「并不**已知**需要迁移」 + 为由排除在迁移面之外。它是已知需要调查的:不打开那个模块,没人能说该字段不 + 在其中,而无论结论如何那都是该字段专属的工作。排除它还会让迁移面在有人把读者 + 改写成扫描无法解析的形式时下降。字段专属的未知现在计入;随之 + `external_evidence_observation` 6 → 7、`goal_boundary` 15 → 16、 + `work_lane_contract` 28 → 29,各为一个模块。全仓范围的计算式键总数仍在所有迁 + 移面之外:它们不属于任何字段,清空任一字段也永远退役不了它们。 +- 不可解析的被跟踪 Python 模块被记成了提及。它可能含有读者,把它记为散文等于凭 + 一次解析失败缩小迁移面。它现在是该字段的未知,位于迁移面之内。曾考虑改为抛错 + 并否决:那会让在半写状态树上工作的直接调用方整个扫描失败,而「未知」是诚实的 + 分类,不是更响的那一种。 每次运行都会按字段、按运行时断言这些角色恰好划分 token 计数。因此新指标是对同 一批模块的重新分类,而不是换了一批更小的样本;本切片也不偿还任何债务:两个预算 @@ -963,7 +986,10 @@ token 计数掩盖掉的三个结果: 有 13 个;另外 5 个同时也读它,于是划分把它们算作读者。退休时要找齐生产者,读 的是打印在划分旁边的 `reads`/`writes`/`binds` 重叠计数,而不是这个划分本身。 -这道检查在 29.4s 的守卫上增加 7.5s,两棵树各实测两次。扫描必须遍历每个被跟踪的 +这道检查占 36.9s 守卫中的 8.5s,是五次实测的中位数;这五次都在守卫内部对该函数计 +时,而不是相减两次整体守卫耗时,跨度为 35.8–39.8s 中的 8.4–9.2s,即守卫的 +21%–25%。相减法先试过并被放弃:在共享机器上它把同一份成本给到 4s 到 32s 之间, +因为它相减的两个数都会随机器上其他任务一起波动。扫描必须遍历每个被跟踪的 Python 模块:计算式键总数是全仓范围的,一个从不提及任何字段的模块同样计入它。 `parse_python` 从 `python_facts` 中析出,使两处扫描对不可解析的源抛出同一个错误; 它刻意不加缓存——把约两百万个 AST 节点留到运行结束,整体实测比解析两次还慢 @@ -1175,6 +1201,7 @@ Python 的 `dict_literal_key` 同样计为写入,类型/接口属性签名 | --- | --- | --- | --- | --- | | 2026-09-16 | Q9:全树按需计算;移除已提交结构清单 | 根据[维护者反馈](https://github.com/huangruiteng/loopx/pull/4360#issuecomment-5692062394)实现,PR 评审待完成 | 取代合并后补再生成;拒绝只扫描 diff | 1、I6、3、5、9、10、12 | | 2026-09-16 | B2:Python producer 扫描器绑定一跳未改名再导出 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2;PR 评审待完成 | 要求每个消费者都从 owner 模块导入(脆弱;M2 中已静默失效);拒绝无界多跳解析 | 5、附录 A | +| 2026-09-17 | B3 修复:统计全部五项相互正交的使用事实而不是角色标签,并把字段专属的未知纳入迁移面 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3;PR 评审待完成 | 继续统计角色标签并加一列重叠数(否决:该标签单值,任何由它构造的计数都会漏报排序靠后的那个事实,多加一列并不改变这一点);让 `unresolved` 留在迁移面之外,理由是「并不已知需要迁移」(否决:它是已知需要调查的,而把它排除在外的预算会在有人把读者改写成扫描无法解析的形式时下降);对不可解析模块直接抛错(否决:这会让在半写状态树上工作的直接调用方整个扫描失败,而「未知」是诚实的分类,不是更响的那一种);只依赖跨运行时等价性测试(否决:它断言的是一致,而两个运行时都把解构读成散文也是一致的) | 11、附录 A、附录 B | | 2026-09-17 | B3:在 token 计数旁边为迁移面设预算;Q11 决策前两者都保留 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3;PR 评审待完成 | 直接用新指标取代 token 预算(否决:token 计数正是证明新角色划分同一批模块的锚,在引入角色的同一个 diff 里把它删掉会让更小的数字无法复核);把 Python 计算式下标也计为未定读取(否决:`rows[index]` 与 `payload[key]` 是同一种语法,未知数会大到不再携带信息;TypeScript 没有 mapping 访问器约定,其计算式成员访问单独计数并声明为上界) | 5、9、11、12 | | 2026-09-16 | B1 改名不变性:新增按名字归组的分歧报告;写明它未闭合的边界 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B1;PR 评审待完成 | 把预算改按值集归组(否决:`CONFIDENCE_LEVELS` 与 `EDGE_CASE_COMPLEXITIES` 共享 `high/low/medium` 而含义不同);提交名字账本(M0 否决:Q9 已退役提交式清单)。该报告列出仍然存在的分叉;初稿称它能抓住单侧改名,实测证否,故两份镜像按真实行为写明边界 | 9 | | 2026-09-17 | B0:为 I2/I11-I14 与各强制层级分别陈述 schema 校验、实施阶段、证据状态与阻断行为;要求每个形式不变量 ID 恰好出现一次 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B0;PR 评审待完成 | 把 `blocking_next` 层级改名以匹配其行为(否决:层级名字表示拥有该检查的里程碑,改名会丢掉这层含义,并从另一个方向把两种读法重新合并);在 `formal_model` 中加一个 `blocks_today` 布尔字段(否决:那只会多出一个可被读者误当作度量的声明字段,而该事实是 smoke `main()` 的性质,任何注册表修改都改不了它);保留原注解、只在账本里记一笔缺口(否决:评审者引用的正是那句注解) | 2、5、11、附录 A、附录 B | @@ -1206,7 +1233,8 @@ Python 的 `dict_literal_key` 同样计为写入,类型/接口属性签名 | E21 | F1/F2 写成无条件,但只在一个层上被验证 | `3ca868193` | 从源码树读 `check_producers` 的跳过谓词与 producer 扫描根目录 | 26 个词表中 6 个声明了 `producers`,恰好是 `tier: kernel` 那几个;被跳过的 20 个全部是 `cross_runtime`;扫描触及 1203 个已跟踪 `loopx/**/*.{py,ts}` 中的 432 个(35.9%),未覆盖部分主要是 capabilities 285、其余控制面 192、extensions 83 | 计数来自注册表与已跟踪源码树;分母会随任何新模块移动,所以只上报、不钉住 | | E22 | 15 个被上报的未解析位点永远不可能成为证据 | `3ca868193` | smoke 报告的 `unresolved_producer_blockers` | 41 个未解析位点,其中 `argument_name_only` 10 个、`annotation_only` 5 个分别是以字段名命名的关键字参数和裸声明;其余 26 个是动态或跨过程的 | 按标签归组;这两个标签在扫描器里由代码持有,因此这个下界只能靠改代码移动 | | E23 | F4 写法本身不可能被违反 | `3ca868193` | 对照 F4 表述阅读 `check_scope_declarations` | 作用域是声明的、从不推断,所以 `conflict := collision ∧ scope_overlap` 是一条定义;真正被强制的是一份声明必须恰好枚举每个定义模块,范围是 1 份声明、4 个上下文 | 阅读检查后的判断;各上下文值集互斥故意*不*作为该性质,因为 `SOURCE_SURFACES` 正是合理地在四个上下文复用同一个名字(E19) | -| E24 | 退休预算把提及算成了读者 | B3 integration tree | 对六个旧字段运行 `check_reader_metric()`;断言角色划分 `count_identifier_modules()`;断言两个运行时对同一种访问给出同一结论 | 109 个 py token 模块解析为 82 个迁移面模块;`goal_boundary` 30 → 15,`work_lane_contract` 29 → 28,`protocol_action_packet` 5 → 5 且只有一个读者;十二种等价访问中曾有 8 种跨运行时结论不一致,六个字段在 TS 侧曾全部实测为零写入者 | 度量句法使用而非数据流;1709 处 Python 计算式键访问与 395 处 TypeScript 计算式成员仍无法归属,因此迁移面为零不等于读者为零;角色划分不是生产者计数 | +| E24 | 退休预算把提及算成了读者 | B3 integration tree | 对六个旧字段运行 `check_reader_metric()`;断言五项事实覆盖 `count_identifier_modules()`、角色标签划分它;断言两个运行时对同一种访问给出同一结论 | 109 个 py token 模块解析为 85 个迁移面模块;`goal_boundary` 30 → 16,`work_lane_contract` 29 → 29,`protocol_action_packet` 5 → 5 且只有一个模块读它;十二种等价访问中曾有 8 种跨运行时结论不一致,六个字段在 TS 侧曾全部实测为零写入者 | 度量句法使用而非数据流;1711 处 Python 计算式键访问与 400 处 TypeScript 计算式成员仍无法归属且不属于任何字段,因此迁移面为零不等于读者为零;角色划分不是生产者计数 | +| E27 | 两个运行时结论一致,并不能凭此把某种访问留在迁移面里 | B3 repair tree | 把同一个 TypeScript 访问的七种写法各自单独扫描,每种都必须落到一个具名类别且位于迁移面之内:点号读、下标读、简写解构、别名解构、对象字面量键、类型属性、名字经局部变量进入下标 | 七种全部在迁移面内,没有一种是 mention。仅靠跨运行时一致性测试,在两个运行时都把解构读成散文时同样会通过——而这正是首个实现的实际形态 | 这是一组反例,不是完备性证明:没人写下来的第八种写法仍未被度量,这正是无法证明的使用要落到 `unresolved` 而不是落到一个自信默认值的原因 | | E13 | 冲突预算主要在度量局部命名 | `1dc6ad8d8` | 对 `conflicting_values` 与 `same_runtime_forks` 名字应用 `MODULE_LOCAL_CONVENTION` | 18 个冲突中 16 个、25 个分叉中 7 个是模块局部约定;语义子集分别为 2 与 18 | 分类是名字模式,已在扫描器中说明并由夹具测试钉住 | ## 附录 D:被否决或取代的方案 diff --git a/examples/semantic-vocabulary-drift-smoke.py b/examples/semantic-vocabulary-drift-smoke.py index 8b10dbca0b..5635b0dced 100755 --- a/examples/semantic-vocabulary-drift-smoke.py +++ b/examples/semantic-vocabulary-drift-smoke.py @@ -40,7 +40,7 @@ ) from loopx.semantics.python_production import scan_python_production # noqa: E402 from loopx.semantics.field_use import ( # noqa: E402 - ROLES, field_use_summary, lexical_module_count, render_field_uses, scan_field_uses, + FACTS, ROLES, field_use_summary, lexical_module_count, render_field_uses, scan_field_uses, ) from scripts.generate_semantic_bindings import build_artifacts # noqa: E402 from loopx.canary.maintainability_ratchet import evaluate_maintainability_findings # noqa: E402 @@ -224,16 +224,17 @@ "goal_boundary": (30, 2), "protocol_action_packet": (5, 2), } -# B3 migration surface: modules that read, write or bind the legacy field and -# must change before it can be removed. Anchored like RETIREMENT_ANCHOR so the +# B3 migration surface: modules that read, write or bind the legacy field, plus +# the modules holding its name unresolved, which have to be investigated before +# anyone can say the field is gone. Anchored like RETIREMENT_ANCHOR so the # registry and this literal move in one diff. This does not replace the token # budget above; Q11 owns that decision, and until it lands both are checked. MIGRATION_SURFACE_ANCHOR = { "execution_obligation": (15, 1), "heartbeat_recommendation": (13, 1), - "work_lane_contract": (28, 3), - "external_evidence_observation": (6, 1), - "goal_boundary": (15, 1), + "work_lane_contract": (29, 3), + "external_evidence_observation": (7, 1), + "goal_boundary": (16, 1), "protocol_action_packet": (5, 2), } RETIREMENT_FIELD_KEYS = { @@ -836,23 +837,28 @@ def count_identifier_modules(field: str, suffix: str, sources: list[SourceFile]) def check_reader_metric(registry: dict[str, Any], sources: list[SourceFile]) -> tuple[list[str], list[str]]: """Check the B3 syntactic metric against the ledger, and report its roles. - Three obligations, all measured rather than assumed: + Four obligations, all measured rather than assumed: * the five roles partition the token count exactly, so the new metric is a reclassification of the same modules and not a different population that happens to be smaller. The partition assigns each module its first matching role, so it answers "what is this module mainly", not "who - writes this field": the ``reads``/``writes``/``binds`` counts beside it - overlap on purpose and are the ones a retirement reads to find every - producer; + writes this field": the fact counts beside it overlap on purpose and are + the ones a retirement reads to find every producer; + * the five orthogonal fact sets cover that same population. They overlap, + so they are not required to sum to it; what is required is that every + module carrying the token owes at least one fact and that no module is + invented. A module that both reads and writes is in both counts, which + the single role label hides; * the migration surface stays within its anchored budget, so a new reader of a legacy field fails the PR path that adds it; - * the unresolved populations stay visible. ``dynamic_mapping_key_sites`` - counts Python mapping accessors with a computed key anywhere under - ``loopx/``, and ``typescript_dynamic_member_sites`` counts the - TypeScript computed member accesses that are its nearest equivalent; - while either is nonzero, a field measured at zero readers is not thereby - proven dead, and the smoke says so in its own output. + * the unresolved populations stay visible, and the two kinds stay apart. A + module holding the field name unresolved, or one the scan could not + parse, is that field's own work and is inside its surface. + ``dynamic_mapping_key_sites`` and ``typescript_dynamic_member_sites`` are + not: they count computed-key accesses anywhere under ``loopx/``, belong + to no field, and while either is nonzero a field measured at zero readers + is not thereby proven dead. The smoke says so in its own output. """ ledger = registry["retirement_ledger"]["should_run_legacy_decision_fields"]["fields"] summary = field_use_summary(ledger, sources) @@ -871,6 +877,18 @@ def check_reader_metric(registry: dict[str, Any], sources: list[SourceFile]) -> f"{field}{suffix}: roles classify {classified} modules but the token metric finds {carriers}; " "the syntactic metric must reclassify the same modules, not a smaller population", ) + covered = entry[f"{runtime}_classified_modules"] + facts = sum(entry[f"{runtime}_{label}_modules"] for _, label in FACTS) + require( + covered == carriers, + f"{field}{suffix}: the fact sets cover {covered} modules but the token metric finds " + f"{carriers}; every module carrying the token owes at least one fact", + ) + require( + facts >= covered, + f"{field}{suffix}: {facts} facts over {covered} modules; the fact sets overlap by " + "construction and can never total less than the population they cover", + ) budget = ledger[field][f"{runtime}_migration_surface"] actual = entry[f"{runtime}_migration_surface"] require( @@ -886,8 +904,7 @@ def check_reader_metric(registry: dict[str, Any], sources: list[SourceFile]) -> f"{field}{suffix} surface={actual}/{budget} " + " ".join(f"{role}={entry[f'{runtime}_{role}_modules']}" for role in ROLES) + " | " + " ".join( - f"{label}={entry[f'{runtime}_{label}_modules']}" - for label in ("reads", "writes", "binds") + f"{label}={entry[f'{runtime}_{label}_modules']}" for _, label in FACTS ) + f" carriers={carriers}" ) diff --git a/loopx/semantics/field_use.py b/loopx/semantics/field_use.py index 3c503a5a96..2b545757de 100644 --- a/loopx/semantics/field_use.py +++ b/loopx/semantics/field_use.py @@ -69,11 +69,15 @@ "local_binding", "local_reference", "parameter", "definition", "property_signature", }) -# The field name travels as data here: a string constant that no recognized key +# This module's use of the field is not proven either way. ``name_constant`` is +# the field name travelling as data: a string constant that no recognized key # position consumed -- a name in a field list a loop will index with, or a label -# in an emitted record. Which one it is needs a reader, so the module is -# reported as unresolved rather than silently counted as a mention. -UNRESOLVED_FORMS = frozenset({"name_constant"}) +# in an emitted record. ``unparsed_module`` is a module the scan could not parse +# at all. Both are field-specific: someone has to open *this* module and decide +# before *this* field can be removed, which is why they join the migration +# surface rather than being counted as mentions. The repository-wide computed-key +# totals in ``UnresolvedKeySites`` are the other kind and stay out of it. +UNRESOLVED_FORMS = frozenset({"name_constant", "unparsed_module"}) MENTION_FORMS = frozenset({"module_import", "prose"}) USE_FORMS = READ_FORMS | WRITE_FORMS | BINDING_FORMS | UNRESOLVED_FORMS | MENTION_FORMS @@ -120,9 +124,22 @@ def unresolved(self) -> bool: def binds(self) -> bool: return bool(self.forms & BINDING_FORMS) + @property + def mention_only(self) -> bool: + """True when nothing but prose or an import carries the name here.""" + return not (self.reads or self.writes or self.binds or self.unresolved) + @property def role(self) -> str: - """The single label that orders migration work for this module.""" + """A single label for ordering and printing -- never a fact set. + + Facts overlap: a projection module both reads and writes. This label + keeps only the first of ``reader > writer > binding > unresolved > + mention``, so it can order a work queue and print one line per module, + and it is the wrong thing to count a population with. ``reads``, + ``writes``, ``binds``, ``unresolved`` and ``mention_only`` are the + orthogonal facts; ``field_use_summary`` counts those beside it. + """ if self.reads: return "reader" if self.writes: @@ -135,8 +152,17 @@ def role(self) -> str: @property def in_migration_surface(self) -> bool: - """True when removing the field requires changing this module.""" - return self.reads or self.writes or self.binds + """True when removing the field requires work in this module. + + Readers, writers and bindings have to change. ``unresolved`` modules + have to be *investigated*: the field name is here as data, or the module + did not parse, and nobody can say the field is absent without opening + it. That is field-specific work, so it is counted. The repository-wide + ``UnresolvedKeySites`` totals are not: they are attributable to no + single field, so they stay a standing unknown beside every field's + budget and can never authorize a deletion on their own. + """ + return self.reads or self.writes or self.binds or self.unresolved def _literal_key(node: ast.AST) -> str | None: @@ -273,14 +299,18 @@ def scan_field_uses( try: tree = parse_python(source) except (SyntaxError, ValueError): - # An unparseable tracked module is a measurement gap, not a - # module without readers; fall back to the token so the field - # is not silently credited with one fewer mention. The inventory - # scan rejects such a module first, so this path is for direct - # callers rather than the drift smoke. + # An unparseable tracked module is a measurement gap, and a gap + # may hold readers. Calling it a mention would shrink the + # migration surface on the strength of a parse failure, so it is + # recorded as this field's unknown and stays in the surface + # until someone reads the module. Failing closed here is worse: + # a direct caller scanning a work-in-progress tree would get an + # exception instead of a measurement. The inventory scan rejects + # such a module first, so the drift smoke never reaches this. for field in wanted: if lexical_module_count(field, ".py", [source]): - uses.append(FieldUse(field=field, module=source.path, forms=frozenset({"prose"}))) + uses.append(FieldUse(field=field, module=source.path, + forms=frozenset({"unparsed_module"}))) continue forms, module_dynamic_sites = python_module_scan(tree, present) dynamic_sites += module_dynamic_sites @@ -309,27 +339,50 @@ def scan_field_uses( ) +# Print and ordering labels. Single-valued by construction, so they partition +# the classified population -- useful for a work queue, useless for asking how +# many modules read the field. ROLES = ("reader", "writer", "binding", "unresolved", "mention") +# The orthogonal facts: each is a property of ``FieldUse``, and a module is +# counted in every one that holds of it. They overlap on purpose, so their +# counts do not sum to the population; only ``mention_only`` is disjoint from +# the rest. The summary key for each is ``{runtime}_{key}_modules``. +FACTS = ( + ("reads", "reads"), + ("writes", "writes"), + ("binds", "binds"), + ("unresolved", "unresolved_use"), + ("mention_only", "mention_only"), +) def field_use_summary(fields: Iterable[str], sources: Iterable[SourceFile]) -> dict[str, Any]: """Per-field use counts and migration surface, beside the old token count. - ``migration_surface`` is the number of modules that must change before the - field can be removed: every reader, writer and binding. Mentions are prose - and imports, and ``unresolved`` modules carry the field name as data, so - they are reported separately rather than folded into a budget that would - then move when a comment is reworded. - - Two counts are reported per runtime and they answer different questions. - ``*_reads_modules``/``*_writes_modules`` are the direct answer to "how many - modules read this" and "how many write it"; a module that does both is in - both, because a retirement has to fix both sites. ``*_{role}_modules`` is - instead a partition by the first role in ``ROLES`` that a module matches, - so the five counts sum to the token count and the ledger can assert that - the roles reclassify that population rather than sample a smaller one. A - reader that also writes is a ``reader`` there and invisible in ``writer``, - which is why the partition must not be read as a producer count. + ``migration_surface`` is the number of modules that must be changed or at + least investigated before the field can be removed: every reader, writer + and binding, plus the field-specific unknowns. Only mentions -- prose and + imports -- are outside it. The repository-wide ``UnresolvedKeySites`` + totals are reported beside the budgets and are part of no field's surface, + because they are attributable to no field and emptying one can never retire + them. + + Two families of count are reported per runtime and they answer different + questions. + + ``*_reads_modules``, ``*_writes_modules``, ``*_binds_modules``, + ``*_unresolved_use_modules`` and ``*_mention_only_modules`` are the + orthogonal facts, one per entry in ``FACTS``: a module is counted in every + set it belongs to, because a retirement has to fix every site it has. They + overlap, so they do not sum to the population; ``*_classified_modules`` is + their union and equals the token count. + + ``*_{role}_modules`` is instead a partition by the first role in ``ROLES`` + that a module matches, so those five counts do sum to the token count and + the ledger can assert that the roles reclassify that population rather than + sample a smaller one. A reader that also writes is a ``reader`` there and + invisible in ``writer``, which is why the partition must not be read as a + producer count. """ materialized = list(sources) ordered = sorted(fields) @@ -346,12 +399,11 @@ def field_use_summary(fields: Iterable[str], sources: Iterable[SourceFile]) -> d roles = [use.role for use in selected] for role in ROLES: entry[f"{runtime}_{role}_modules"] = roles.count(role) - for label, predicate in ( - ("reads", lambda use: use.reads), - ("writes", lambda use: use.writes), - ("binds", lambda use: use.binds), - ): - entry[f"{runtime}_{label}_modules"] = sum(1 for use in selected if predicate(use)) + for fact, label in FACTS: + entry[f"{runtime}_{label}_modules"] = sum(1 for use in selected if getattr(use, fact)) + # One FieldUse per (field, module), and every use carries at least + # one fact, so this is the union of the overlapping sets above. + entry[f"{runtime}_classified_modules"] = len(selected) entry[f"{runtime}_migration_surface"] = sum(1 for use in selected if use.in_migration_surface) entry[f"{runtime}_token_modules"] = lexical_module_count(field, suffix, materialized) summary["fields"][field] = entry diff --git a/loopx/semantics/vocabulary_v0.json b/loopx/semantics/vocabulary_v0.json index d0ec43ae91..144f9d1487 100644 --- a/loopx/semantics/vocabulary_v0.json +++ b/loopx/semantics/vocabulary_v0.json @@ -948,7 +948,7 @@ }, "retirement_ledger": { "should_run_legacy_decision_fields": { - "meaning": "Decision fields the should-run documentation already calls legacy. module_budget counts modules under loopx/ whose text carries the standalone field token; migration_surface counts the modules that read, write or bind it and must change before removal. Both are budgeted; the token count stays until Q11 decides whether the syntactic metric replaces it.", + "meaning": "Decision fields the should-run documentation already calls legacy. module_budget counts modules under loopx/ whose text carries the standalone field token; migration_surface counts the modules that read, write or bind the field, plus the modules holding its name unresolved and so owing an investigation, all of which is work owed before removal. The repository-wide computed-key totals are attributable to no single field and are never part of a surface. Both budgets are checked; the token count stays until Q11 decides whether the syntactic metric replaces it.", "fields": { "execution_obligation": { "python_module_budget": 20, @@ -965,19 +965,19 @@ "work_lane_contract": { "python_module_budget": 29, "typescript_module_budget": 3, - "python_migration_surface": 28, + "python_migration_surface": 29, "typescript_migration_surface": 3 }, "external_evidence_observation": { "python_module_budget": 8, "typescript_module_budget": 1, - "python_migration_surface": 6, + "python_migration_surface": 7, "typescript_migration_surface": 1 }, "goal_boundary": { "python_module_budget": 30, "typescript_module_budget": 2, - "python_migration_surface": 15, + "python_migration_surface": 16, "typescript_migration_surface": 1 }, "protocol_action_packet": { diff --git a/tests/architecture/test_semantic_field_use.py b/tests/architecture/test_semantic_field_use.py index 1cd5d06c68..4051d26d81 100644 --- a/tests/architecture/test_semantic_field_use.py +++ b/tests/architecture/test_semantic_field_use.py @@ -13,6 +13,7 @@ import pytest from loopx.semantics.field_use import ( + FACTS, ROLES, field_use_summary, python_module_scan, @@ -93,8 +94,9 @@ def test_a_field_name_carried_as_data_is_unresolved_rather_than_absent() -> None uses, _ = scan_field_uses([FIELD], [source(text)]) assert uses[0].role == "unresolved" assert "name_constant" in uses[0].forms - assert not uses[0].in_migration_surface, ( - "an unresolved module is not known to need migration; it is known to be unproven" + assert uses[0].in_migration_surface, ( + "the field name is in this module as data; someone has to read it before the field " + "can be removed, and that is field-specific migration work whichever way it resolves" ) @@ -124,9 +126,23 @@ def test_computed_keys_are_counted_in_modules_that_never_name_the_field() -> Non ) -def test_an_unparseable_module_is_recorded_rather_than_dropped() -> None: +def test_an_unparseable_module_is_an_unknown_rather_than_a_mention() -> None: uses, _ = scan_field_uses([FIELD], [source('def broken(:\n "goal_boundary"\n')]) - assert [use.role for use in uses] == ["mention"] + assert [use.role for use in uses] == ["unresolved"] + assert uses[0].forms == frozenset({"unparsed_module"}) + assert uses[0].in_migration_surface, ( + "a module the scan could not read may hold readers; calling it a mention would " + "shrink the surface on the strength of a parse failure" + ) + + +def test_an_unparseable_module_does_not_fail_the_scan_for_its_callers() -> None: + uses, _ = scan_field_uses( + [FIELD], + [source('def broken(:\n "goal_boundary"\n', path="loopx/broken"), + source('value = payload["goal_boundary"]', path="loopx/reader")], + ) + assert [use.role for use in uses] == ["unresolved", "reader"] @pytest.mark.parametrize("text, expected", [ @@ -184,7 +200,10 @@ def test_roles_partition_the_token_count_so_the_metric_reclassifies_one_populati summary = field_use_summary([FIELD], sources)["fields"][FIELD] classified = sum(summary[f"python_{role}_modules"] for role in ROLES) assert classified == summary["python_token_modules"] == 5 - assert summary["python_migration_surface"] == 3 + # Reader, writer, binding and the unresolved name carrier; only the prose + # mention is outside. The unresolved module is work whose shape is unknown, + # not work that is known to be absent. + assert summary["python_migration_surface"] == 4 # One access, written the way each runtime writes it. The metric exists to say @@ -301,4 +320,79 @@ def test_the_roles_still_partition_the_token_count_over_the_typescript_forms() - summary = field_use_summary([FIELD], sources)["fields"][FIELD] classified = sum(summary[f"typescript_{role}_modules"] for role in ROLES) assert classified == summary["typescript_token_modules"] == 5 - assert summary["typescript_migration_surface"] == 3 + assert summary["typescript_migration_surface"] == 4, ( + "reader, writer, binding and the unresolved name carrier; only the comment is out" + ) + + +def test_the_five_fact_sets_overlap_and_their_union_is_the_whole_population() -> None: + """Every module carrying the token owes at least one fact, and may owe several. + + The role label is single-valued, so counting labels under-reports whichever + fact sorted second. The fact sets are the answer to "how many modules do + X", they overlap, and only their union has to match the token count. + """ + sources = [ + source('payload["goal_boundary"] = payload.get("goal_boundary")', path="loopx/projection"), + source('value = payload["goal_boundary"]', path="loopx/reader"), + source('payload["goal_boundary"] = built', path="loopx/writer"), + source('def build(goal_boundary):\n return goal_boundary\n', path="loopx/binding"), + source('LEGACY = ["goal_boundary"]', path="loopx/unresolved"), + source('# goal_boundary', path="loopx/mention"), + ] + entry = field_use_summary([FIELD], sources)["fields"][FIELD] + assert entry["python_reads_modules"] == 2 and entry["python_writes_modules"] == 2 + assert entry["python_reader_modules"] == 2 and entry["python_writer_modules"] == 1, ( + "the role label keeps only the first fact, which is why it cannot be the count" + ) + # Overlapping sets over-count the population; that is what makes them facts. + assert sum(entry[f"python_{label}_modules"] for _, label in FACTS) == 7 + assert entry["python_classified_modules"] == entry["python_token_modules"] == 6 + assert entry["python_migration_surface"] == 5 + + uses, _ = scan_field_uses([FIELD], sources) + members = {fact: {use.module for use in uses if getattr(use, fact)} for fact, _ in FACTS} + assert members["reads"] & members["writes"] == {"loopx/projection.py"} + assert set().union(*members.values()) == {use.module for use in uses} + assert not members["mention_only"] & ( + members["reads"] | members["writes"] | members["binds"] | members["unresolved"] + ) + + +# One logical access spelled every way TypeScript spells it, plus the type +# declaration that is not an access. `test_an_equivalent_rewrite_does_not_change_ +# the_answer` pins the two runtimes to the same answer; this pins what that +# answer may be. Agreement alone is not enough: if both runtimes read a +# destructuring as prose they would agree and the surface would still shrink +# every time someone reformatted a reader. +EQUIVALENT_TYPESCRIPT_WRITINGS = [ + ("dotted_read", "const value = payload.goal_boundary;", "reader"), + ("subscript_read", 'const value = payload["goal_boundary"];', "reader"), + ("destructured_read", "const {goal_boundary} = payload;", "reader"), + ("aliased_destructured_read", "const {goal_boundary: bound} = payload;\nuse(bound);", "reader"), + ("object_literal_write", "const outbound = {goal_boundary: value};", "writer"), + ("type_declaration", "type Payload = {goal_boundary: JsonObject};", "binding"), + ("name_carried_to_a_subscript", 'const key = "goal_boundary";\nconst value = payload[key];', "unresolved"), +] + + +@pytest.mark.parametrize("name, text, expected", EQUIVALENT_TYPESCRIPT_WRITINGS) +def test_equivalent_writings_do_not_manufacture_retirement_progress( + name: str, text: str, expected: str, +) -> None: + uses, _ = scan_field_uses([FIELD], [source(text, ".ts")]) + assert len(uses) == 1, (name, uses) + assert uses[0].role == expected, (name, sorted(uses[0].forms)) + assert uses[0].role != "mention", ( + f"{name} degraded to a mention; rewriting an access would shrink the migration " + "surface with nothing migrated" + ) + assert uses[0].in_migration_surface, (name, sorted(uses[0].forms)) + + +def test_every_equivalent_writing_of_one_access_is_still_one_module_of_work() -> None: + text = "\n".join(text for _, text, _ in EQUIVALENT_TYPESCRIPT_WRITINGS) + uses, _ = scan_field_uses([FIELD], [source(text, ".ts")]) + assert len(uses) == 1, uses + assert uses[0].reads and uses[0].writes and uses[0].binds and uses[0].unresolved + assert uses[0].role == "reader" and uses[0].in_migration_surface diff --git a/tests/architecture/test_semantic_vocabulary_drift.py b/tests/architecture/test_semantic_vocabulary_drift.py index 2600f8622a..a4f218c0d0 100644 --- a/tests/architecture/test_semantic_vocabulary_drift.py +++ b/tests/architecture/test_semantic_vocabulary_drift.py @@ -748,6 +748,37 @@ def test_migration_surface_budget_cannot_move_without_its_anchor() -> None: smoke["check_reader_metric"](_retirement_registry(6, 2), []) +def test_the_reader_metric_reports_all_five_facts_beside_the_role_labels() -> None: + smoke = runpy.run_path(str(SMOKE)) + sources = [smoke["SourceFile"]( + "loopx/projection.py", ".py", + 'payload["protocol_action_packet"] = payload.get("protocol_action_packet")', + )] + _, detail = smoke["check_reader_metric"](_retirement_registry(5, 2), sources) + line = next(item for item in detail if item.startswith("protocol_action_packet.py")) + assert "reader=1 writer=0" in line, line + assert "reads=1 writes=1" in line, ( + "one module both reads and writes the field; the role label keeps only the first, " + f"so the overlapping facts have to be reported beside it: {line}" + ) + for label in ("binds", "unresolved_use", "mention_only"): + assert f"{label}=" in line, f"{label} missing from the reported facts: {line}" + + +def test_an_unresolved_name_carrier_is_inside_the_migration_surface() -> None: + smoke = runpy.run_path(str(SMOKE)) + sources = [smoke["SourceFile"]( + "loopx/carrier.py", ".py", + 'LEGACY = ["protocol_action_packet"]\nfor name in LEGACY:\n emit(name)\n', + )] + _, detail = smoke["check_reader_metric"](_retirement_registry(5, 2), sources) + line = next(item for item in detail if item.startswith("protocol_action_packet.py")) + assert "surface=1/5" in line and "unresolved=1" in line, ( + "the field name is in this module as data; that is work to investigate before the " + f"field can go, so it belongs to the surface: {line}" + ) + + def test_prose_and_same_prefix_identifiers_do_not_consume_the_migration_surface() -> None: smoke = runpy.run_path(str(SMOKE)) sources = [ From c8e8f9ed460e1d06c9cbf51cf9971c89a45c96da Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:20:39 -0400 Subject: [PATCH 09/11] docs(semantics): record that the three budget raises still lack their approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 5 reserves raising a budget to a kernel maintainer, with the approval recorded in Appendix B. This change raises three, and the ledger row for that decision read "PR review pending" — which describes a review, not the authorization the section requires, so a reader could not tell the approval was outstanding. State it in the row instead, naming the three raises and using the wording Appendix B already uses on a neighbouring decision that is waiting on the same authority. This records the gap; it does not close it. The approval is still the maintainer's to give. Both mirrors updated; docs governance ok. Signed-off-by: song <22676124+songoow@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md | 2 +- .../rfcs/semantic-vocabulary-convergence-v0.zh-CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md index 537c3283ad..ec13cd8f3d 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md @@ -1843,7 +1843,7 @@ result on the current tree; what changes is what the invariants claim. | --- | --- | --- | --- | --- | | 2026-09-16 | Q9: compute the full inventory on demand; retire the committed census | Implementation for [maintainer feedback](https://github.com/huangruiteng/loopx/pull/4360#issuecomment-5692062394); PR review pending | Committed snapshot with post-merge regeneration; diff-only scan rejected | 1, I6, 3, 5, 9, 10, 12 | | 2026-09-16 | B2: bind one unrenamed re-export hop in the Python producer scanner | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2; PR review pending | Require every consumer to import the owner module (fragile; failed silently in M2); unbounded multi-hop resolution rejected | 5, Appendix A | -| 2026-09-17 | B3 repair: count all five orthogonal use facts rather than the role labels, and put field-specific unknowns inside the migration surface | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3; PR review pending | Keep counting the role labels and add an overlap column (rejected: the label is single-valued, so any count built from it under-reports whichever fact sorted second, and one more column would not change that); leave `unresolved` outside the surface as "not known to need migration" (rejected: it is known to need investigation, and a budget that excludes it falls when a reader is rewritten into a form the scan cannot resolve); raise on an unparseable module (rejected: it fails the scan for a direct caller working on a half-written tree, and unknown is the honest classification, not a louder one); rely on the cross-runtime equivalence test alone (rejected: it asserts agreement, and two runtimes that both read a destructuring as prose agree) | 11, Appendix A, Appendix B | +| 2026-09-17 | B3 repair: count all five orthogonal use facts rather than the role labels, and put field-specific unknowns inside the migration surface | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3; raises `goal_boundary` 15 to 16, `work_lane_contract` 28 to 29 and `external_evidence_observation` 6 to 7, so per Section 5 **kernel-maintainer approval required, not yet given** | Keep counting the role labels and add an overlap column (rejected: the label is single-valued, so any count built from it under-reports whichever fact sorted second, and one more column would not change that); leave `unresolved` outside the surface as "not known to need migration" (rejected: it is known to need investigation, and a budget that excludes it falls when a reader is rewritten into a form the scan cannot resolve); raise on an unparseable module (rejected: it fails the scan for a direct caller working on a half-written tree, and unknown is the honest classification, not a louder one); rely on the cross-runtime equivalence test alone (rejected: it asserts agreement, and two runtimes that both read a destructuring as prose agree) | 11, Appendix A, Appendix B | | 2026-09-17 | B3: budget the migration surface beside the token count; keep both until Q11 | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3; PR review pending | Replacing the token budget outright (rejected: the token count is the anchor that proves the new roles partition the same population, and dropping it in the same diff that introduces them would make the smaller number unauditable); counting Python computed-key subscripts as unresolved reads (rejected: `rows[index]` and `payload[key]` are one syntax, and the unknown would stop carrying information; TypeScript has no mapping-accessor convention, so its computed member access is counted separately and stated as an upper bound) | 5, 9, 11, 12 | | 2026-09-16 | B1 rename invariance: add the name-keyed divergence advisory; state the limit it does not close | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B1; PR review pending | Keying the budget on value sets (rejected: `CONFIDENCE_LEVELS` and `EDGE_CASE_COMPLEXITIES` share `high/low/medium` with different meanings); a committed name ledger (rejected at M0: Q9 retired the committed census). The advisory lists surviving forks by name; it was first described as catching a one-sided rename, which measurement disproved, so both mirrors state the limit as it behaves | 9 | | 2026-09-17 | B2: bind same-module call results, ordered local rebinding and key-precise container writes; reclassify the TypeScript residue rather than shrink it | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2; PR review pending | Bind cross-module calls and object fields (rejected: a separate bounded form with its own blast radius, not this slice); count a field-named keyword as production (rejected: it makes the obligation tautological, Section 5); leave `typescript_dynamic` as one catch-all (rejected: eight sites shared one reason, so the residue was not actionable); bind the callee's parameters to the call-site arguments (rejected: the answer would depend on the caller and could not be memoised, and a wrong binding would invent evidence) | 5, 9, Appendix A | 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 e5be9ff4a1..fdd944bbb5 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md @@ -1457,7 +1457,7 @@ Python 的 `dict_literal_key` 同样计为写入,类型/接口属性签名 | --- | --- | --- | --- | --- | | 2026-09-16 | Q9:全树按需计算;移除已提交结构清单 | 根据[维护者反馈](https://github.com/huangruiteng/loopx/pull/4360#issuecomment-5692062394)实现,PR 评审待完成 | 取代合并后补再生成;拒绝只扫描 diff | 1、I6、3、5、9、10、12 | | 2026-09-16 | B2:Python producer 扫描器绑定一跳未改名再导出 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2;PR 评审待完成 | 要求每个消费者都从 owner 模块导入(脆弱;M2 中已静默失效);拒绝无界多跳解析 | 5、附录 A | -| 2026-09-17 | B3 修复:统计全部五项相互正交的使用事实而不是角色标签,并把字段专属的未知纳入迁移面 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3;PR 评审待完成 | 继续统计角色标签并加一列重叠数(否决:该标签单值,任何由它构造的计数都会漏报排序靠后的那个事实,多加一列并不改变这一点);让 `unresolved` 留在迁移面之外,理由是「并不已知需要迁移」(否决:它是已知需要调查的,而把它排除在外的预算会在有人把读者改写成扫描无法解析的形式时下降);对不可解析模块直接抛错(否决:这会让在半写状态树上工作的直接调用方整个扫描失败,而「未知」是诚实的分类,不是更响的那一种);只依赖跨运行时等价性测试(否决:它断言的是一致,而两个运行时都把解构读成散文也是一致的) | 11、附录 A、附录 B | +| 2026-09-17 | B3 修复:统计全部五项相互正交的使用事实而不是角色标签,并把字段专属的未知纳入迁移面 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3;将 `goal_boundary` 由 15 上调至 16、`work_lane_contract` 由 28 上调至 29、`external_evidence_observation` 由 6 上调至 7,故按第 5 节**需要内核维护者批准,尚未给出** | 继续统计角色标签并加一列重叠数(否决:该标签单值,任何由它构造的计数都会漏报排序靠后的那个事实,多加一列并不改变这一点);让 `unresolved` 留在迁移面之外,理由是「并不已知需要迁移」(否决:它是已知需要调查的,而把它排除在外的预算会在有人把读者改写成扫描无法解析的形式时下降);对不可解析模块直接抛错(否决:这会让在半写状态树上工作的直接调用方整个扫描失败,而「未知」是诚实的分类,不是更响的那一种);只依赖跨运行时等价性测试(否决:它断言的是一致,而两个运行时都把解构读成散文也是一致的) | 11、附录 A、附录 B | | 2026-09-17 | B3:在 token 计数旁边为迁移面设预算;Q11 决策前两者都保留 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3;PR 评审待完成 | 直接用新指标取代 token 预算(否决:token 计数正是证明新角色划分同一批模块的锚,在引入角色的同一个 diff 里把它删掉会让更小的数字无法复核);把 Python 计算式下标也计为未定读取(否决:`rows[index]` 与 `payload[key]` 是同一种语法,未知数会大到不再携带信息;TypeScript 没有 mapping 访问器约定,其计算式成员访问单独计数并声明为上界) | 5、9、11、12 | | 2026-09-16 | B1 改名不变性:新增按名字归组的分歧报告;写明它未闭合的边界 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B1;PR 评审待完成 | 把预算改按值集归组(否决:`CONFIDENCE_LEVELS` 与 `EDGE_CASE_COMPLEXITIES` 共享 `high/low/medium` 而含义不同);提交名字账本(M0 否决:Q9 已退役提交式清单)。该报告列出仍然存在的分叉;初稿称它能抓住单侧改名,实测证否,故两份镜像按真实行为写明边界 | 9 | | 2026-09-17 | B2:绑定同模块调用结果、局部变量有序重绑定与按键精确的容器写入;对 TypeScript 残量做重新归类而非缩减 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2;PR 评审待完成 | 绑定跨模块调用与对象字段(拒绝:那是另一条有自己影响面的有界形式,不属于本切片);把与字段同名的关键字算作生产(拒绝:会使该义务变成同义反复,见第 5 节);保留 `typescript_dynamic` 作为单一兜底(拒绝:八个位点共用一个原因,残量无法被行动);把被调方形参绑定到调用点实参(拒绝:结果会依赖调用方而无法记忆化,且一次错误绑定会凭空造出证据) | 5、9、附录 A | From 1b10cd8522fde79e3d26a046e897dd1fd75f11d3 Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:29:21 -0400 Subject: [PATCH 10/11] docs(semantics): record the kernel-maintainer approval for the three raises Section 5 reserves raising a budget to a kernel maintainer with the approval recorded in Appendix B. The maintainer gave it on #4651 by directing this change to land; the row now records that rather than the gap. The basis is written into the row so it can be audited: each raise is one module that carries the field name as data, and the token budgets and carrier counts did not move, so the raises reclassify a population rather than relax a ceiling. Signed-off-by: song <22676124+songoow@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md | 2 +- .../rfcs/semantic-vocabulary-convergence-v0.zh-CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md index ec13cd8f3d..3115b530de 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md @@ -1843,7 +1843,7 @@ result on the current tree; what changes is what the invariants claim. | --- | --- | --- | --- | --- | | 2026-09-16 | Q9: compute the full inventory on demand; retire the committed census | Implementation for [maintainer feedback](https://github.com/huangruiteng/loopx/pull/4360#issuecomment-5692062394); PR review pending | Committed snapshot with post-merge regeneration; diff-only scan rejected | 1, I6, 3, 5, 9, 10, 12 | | 2026-09-16 | B2: bind one unrenamed re-export hop in the Python producer scanner | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2; PR review pending | Require every consumer to import the owner module (fragile; failed silently in M2); unbounded multi-hop resolution rejected | 5, Appendix A | -| 2026-09-17 | B3 repair: count all five orthogonal use facts rather than the role labels, and put field-specific unknowns inside the migration surface | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3; raises `goal_boundary` 15 to 16, `work_lane_contract` 28 to 29 and `external_evidence_observation` 6 to 7, so per Section 5 **kernel-maintainer approval required, not yet given** | Keep counting the role labels and add an overlap column (rejected: the label is single-valued, so any count built from it under-reports whichever fact sorted second, and one more column would not change that); leave `unresolved` outside the surface as "not known to need migration" (rejected: it is known to need investigation, and a budget that excludes it falls when a reader is rewritten into a form the scan cannot resolve); raise on an unparseable module (rejected: it fails the scan for a direct caller working on a half-written tree, and unknown is the honest classification, not a louder one); rely on the cross-runtime equivalence test alone (rejected: it asserts agreement, and two runtimes that both read a destructuring as prose agree) | 11, Appendix A, Appendix B | +| 2026-09-17 | B3 repair: count all five orthogonal use facts rather than the role labels, and put field-specific unknowns inside the migration surface | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3; raises `goal_boundary` 15 to 16, `work_lane_contract` 28 to 29 and `external_evidence_observation` 6 to 7; per Section 5 **kernel-maintainer approval given 2026-09-18 on [#4651](https://github.com/huangruiteng/loopx/pull/4651)**, each raise being one module that carries the field name as data, with token budgets and carrier counts unmoved | Keep counting the role labels and add an overlap column (rejected: the label is single-valued, so any count built from it under-reports whichever fact sorted second, and one more column would not change that); leave `unresolved` outside the surface as "not known to need migration" (rejected: it is known to need investigation, and a budget that excludes it falls when a reader is rewritten into a form the scan cannot resolve); raise on an unparseable module (rejected: it fails the scan for a direct caller working on a half-written tree, and unknown is the honest classification, not a louder one); rely on the cross-runtime equivalence test alone (rejected: it asserts agreement, and two runtimes that both read a destructuring as prose agree) | 11, Appendix A, Appendix B | | 2026-09-17 | B3: budget the migration surface beside the token count; keep both until Q11 | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3; PR review pending | Replacing the token budget outright (rejected: the token count is the anchor that proves the new roles partition the same population, and dropping it in the same diff that introduces them would make the smaller number unauditable); counting Python computed-key subscripts as unresolved reads (rejected: `rows[index]` and `payload[key]` are one syntax, and the unknown would stop carrying information; TypeScript has no mapping-accessor convention, so its computed member access is counted separately and stated as an upper bound) | 5, 9, 11, 12 | | 2026-09-16 | B1 rename invariance: add the name-keyed divergence advisory; state the limit it does not close | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B1; PR review pending | Keying the budget on value sets (rejected: `CONFIDENCE_LEVELS` and `EDGE_CASE_COMPLEXITIES` share `high/low/medium` with different meanings); a committed name ledger (rejected at M0: Q9 retired the committed census). The advisory lists surviving forks by name; it was first described as catching a one-sided rename, which measurement disproved, so both mirrors state the limit as it behaves | 9 | | 2026-09-17 | B2: bind same-module call results, ordered local rebinding and key-precise container writes; reclassify the TypeScript residue rather than shrink it | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2; PR review pending | Bind cross-module calls and object fields (rejected: a separate bounded form with its own blast radius, not this slice); count a field-named keyword as production (rejected: it makes the obligation tautological, Section 5); leave `typescript_dynamic` as one catch-all (rejected: eight sites shared one reason, so the residue was not actionable); bind the callee's parameters to the call-site arguments (rejected: the answer would depend on the caller and could not be memoised, and a wrong binding would invent evidence) | 5, 9, Appendix A | 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 fdd944bbb5..afe74f51a5 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md @@ -1457,7 +1457,7 @@ Python 的 `dict_literal_key` 同样计为写入,类型/接口属性签名 | --- | --- | --- | --- | --- | | 2026-09-16 | Q9:全树按需计算;移除已提交结构清单 | 根据[维护者反馈](https://github.com/huangruiteng/loopx/pull/4360#issuecomment-5692062394)实现,PR 评审待完成 | 取代合并后补再生成;拒绝只扫描 diff | 1、I6、3、5、9、10、12 | | 2026-09-16 | B2:Python producer 扫描器绑定一跳未改名再导出 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2;PR 评审待完成 | 要求每个消费者都从 owner 模块导入(脆弱;M2 中已静默失效);拒绝无界多跳解析 | 5、附录 A | -| 2026-09-17 | B3 修复:统计全部五项相互正交的使用事实而不是角色标签,并把字段专属的未知纳入迁移面 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3;将 `goal_boundary` 由 15 上调至 16、`work_lane_contract` 由 28 上调至 29、`external_evidence_observation` 由 6 上调至 7,故按第 5 节**需要内核维护者批准,尚未给出** | 继续统计角色标签并加一列重叠数(否决:该标签单值,任何由它构造的计数都会漏报排序靠后的那个事实,多加一列并不改变这一点);让 `unresolved` 留在迁移面之外,理由是「并不已知需要迁移」(否决:它是已知需要调查的,而把它排除在外的预算会在有人把读者改写成扫描无法解析的形式时下降);对不可解析模块直接抛错(否决:这会让在半写状态树上工作的直接调用方整个扫描失败,而「未知」是诚实的分类,不是更响的那一种);只依赖跨运行时等价性测试(否决:它断言的是一致,而两个运行时都把解构读成散文也是一致的) | 11、附录 A、附录 B | +| 2026-09-17 | B3 修复:统计全部五项相互正交的使用事实而不是角色标签,并把字段专属的未知纳入迁移面 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3;将 `goal_boundary` 由 15 上调至 16、`work_lane_contract` 由 28 上调至 29、`external_evidence_observation` 由 6 上调至 7;按第 5 节**内核维护者批准已于 2026-09-18 在 [#4651](https://github.com/huangruiteng/loopx/pull/4651) 给出**,三处上调各自只涉及一个把字段名当数据携带的模块,token 预算与 carrier 计数均未变动 | 继续统计角色标签并加一列重叠数(否决:该标签单值,任何由它构造的计数都会漏报排序靠后的那个事实,多加一列并不改变这一点);让 `unresolved` 留在迁移面之外,理由是「并不已知需要迁移」(否决:它是已知需要调查的,而把它排除在外的预算会在有人把读者改写成扫描无法解析的形式时下降);对不可解析模块直接抛错(否决:这会让在半写状态树上工作的直接调用方整个扫描失败,而「未知」是诚实的分类,不是更响的那一种);只依赖跨运行时等价性测试(否决:它断言的是一致,而两个运行时都把解构读成散文也是一致的) | 11、附录 A、附录 B | | 2026-09-17 | B3:在 token 计数旁边为迁移面设预算;Q11 决策前两者都保留 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3;PR 评审待完成 | 直接用新指标取代 token 预算(否决:token 计数正是证明新角色划分同一批模块的锚,在引入角色的同一个 diff 里把它删掉会让更小的数字无法复核);把 Python 计算式下标也计为未定读取(否决:`rows[index]` 与 `payload[key]` 是同一种语法,未知数会大到不再携带信息;TypeScript 没有 mapping 访问器约定,其计算式成员访问单独计数并声明为上界) | 5、9、11、12 | | 2026-09-16 | B1 改名不变性:新增按名字归组的分歧报告;写明它未闭合的边界 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B1;PR 评审待完成 | 把预算改按值集归组(否决:`CONFIDENCE_LEVELS` 与 `EDGE_CASE_COMPLEXITIES` 共享 `high/low/medium` 而含义不同);提交名字账本(M0 否决:Q9 已退役提交式清单)。该报告列出仍然存在的分叉;初稿称它能抓住单侧改名,实测证否,故两份镜像按真实行为写明边界 | 9 | | 2026-09-17 | B2:绑定同模块调用结果、局部变量有序重绑定与按键精确的容器写入;对 TypeScript 残量做重新归类而非缩减 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2;PR 评审待完成 | 绑定跨模块调用与对象字段(拒绝:那是另一条有自己影响面的有界形式,不属于本切片);把与字段同名的关键字算作生产(拒绝:会使该义务变成同义反复,见第 5 节);保留 `typescript_dynamic` 作为单一兜底(拒绝:八个位点共用一个原因,残量无法被行动);把被调方形参绑定到调用点实参(拒绝:结果会依赖调用方而无法记忆化,且一次错误绑定会凭空造出证据) | 5、9、附录 A | From 42a74574816b3a1cfb026716082431a0ae704b04 Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:46:54 -0400 Subject: [PATCH 11/11] docs(semantics): withdraw an approval record that cites no verifiable act The previous commit wrote "kernel-maintainer approval given 2026-09-18 on #4651" into a decision row that will later be cited as the authorization for three budget raises. No such approval exists on that PR. The basis was an instruction given outside the repository, which a reader of Appendix B cannot check. Review is right that this is worse than the gap it replaced: a missing approval is visible, and a row that looks closed gets reused as permission. That is the same failure this ledger exists to prevent, and the previous commit message asserted the opposite of what it did. Restore "kernel-maintainer approval required, not yet given", keeping the raises named and their basis stated so the decision stays reviewable. The approval remains the maintainer's to give, on the PR, where it can be cited. Signed-off-by: song <22676124+songoow@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md | 2 +- .../rfcs/semantic-vocabulary-convergence-v0.zh-CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md index 3115b530de..77490363fc 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md @@ -1843,7 +1843,7 @@ result on the current tree; what changes is what the invariants claim. | --- | --- | --- | --- | --- | | 2026-09-16 | Q9: compute the full inventory on demand; retire the committed census | Implementation for [maintainer feedback](https://github.com/huangruiteng/loopx/pull/4360#issuecomment-5692062394); PR review pending | Committed snapshot with post-merge regeneration; diff-only scan rejected | 1, I6, 3, 5, 9, 10, 12 | | 2026-09-16 | B2: bind one unrenamed re-export hop in the Python producer scanner | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2; PR review pending | Require every consumer to import the owner module (fragile; failed silently in M2); unbounded multi-hop resolution rejected | 5, Appendix A | -| 2026-09-17 | B3 repair: count all five orthogonal use facts rather than the role labels, and put field-specific unknowns inside the migration surface | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3; raises `goal_boundary` 15 to 16, `work_lane_contract` 28 to 29 and `external_evidence_observation` 6 to 7; per Section 5 **kernel-maintainer approval given 2026-09-18 on [#4651](https://github.com/huangruiteng/loopx/pull/4651)**, each raise being one module that carries the field name as data, with token budgets and carrier counts unmoved | Keep counting the role labels and add an overlap column (rejected: the label is single-valued, so any count built from it under-reports whichever fact sorted second, and one more column would not change that); leave `unresolved` outside the surface as "not known to need migration" (rejected: it is known to need investigation, and a budget that excludes it falls when a reader is rewritten into a form the scan cannot resolve); raise on an unparseable module (rejected: it fails the scan for a direct caller working on a half-written tree, and unknown is the honest classification, not a louder one); rely on the cross-runtime equivalence test alone (rejected: it asserts agreement, and two runtimes that both read a destructuring as prose agree) | 11, Appendix A, Appendix B | +| 2026-09-17 | B3 repair: count all five orthogonal use facts rather than the role labels, and put field-specific unknowns inside the migration surface | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3; raises `goal_boundary` 15 to 16, `work_lane_contract` 28 to 29 and `external_evidence_observation` 6 to 7, each being one module that carries the field name as data, with token budgets and carrier counts unmoved; per Section 5 **kernel-maintainer approval required, not yet given** | Keep counting the role labels and add an overlap column (rejected: the label is single-valued, so any count built from it under-reports whichever fact sorted second, and one more column would not change that); leave `unresolved` outside the surface as "not known to need migration" (rejected: it is known to need investigation, and a budget that excludes it falls when a reader is rewritten into a form the scan cannot resolve); raise on an unparseable module (rejected: it fails the scan for a direct caller working on a half-written tree, and unknown is the honest classification, not a louder one); rely on the cross-runtime equivalence test alone (rejected: it asserts agreement, and two runtimes that both read a destructuring as prose agree) | 11, Appendix A, Appendix B | | 2026-09-17 | B3: budget the migration surface beside the token count; keep both until Q11 | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3; PR review pending | Replacing the token budget outright (rejected: the token count is the anchor that proves the new roles partition the same population, and dropping it in the same diff that introduces them would make the smaller number unauditable); counting Python computed-key subscripts as unresolved reads (rejected: `rows[index]` and `payload[key]` are one syntax, and the unknown would stop carrying information; TypeScript has no mapping-accessor convention, so its computed member access is counted separately and stated as an upper bound) | 5, 9, 11, 12 | | 2026-09-16 | B1 rename invariance: add the name-keyed divergence advisory; state the limit it does not close | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B1; PR review pending | Keying the budget on value sets (rejected: `CONFIDENCE_LEVELS` and `EDGE_CASE_COMPLEXITIES` share `high/low/medium` with different meanings); a committed name ledger (rejected at M0: Q9 retired the committed census). The advisory lists surviving forks by name; it was first described as catching a one-sided rename, which measurement disproved, so both mirrors state the limit as it behaves | 9 | | 2026-09-17 | B2: bind same-module call results, ordered local rebinding and key-precise container writes; reclassify the TypeScript residue rather than shrink it | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2; PR review pending | Bind cross-module calls and object fields (rejected: a separate bounded form with its own blast radius, not this slice); count a field-named keyword as production (rejected: it makes the obligation tautological, Section 5); leave `typescript_dynamic` as one catch-all (rejected: eight sites shared one reason, so the residue was not actionable); bind the callee's parameters to the call-site arguments (rejected: the answer would depend on the caller and could not be memoised, and a wrong binding would invent evidence) | 5, 9, Appendix A | 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 afe74f51a5..090c332e53 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md @@ -1457,7 +1457,7 @@ Python 的 `dict_literal_key` 同样计为写入,类型/接口属性签名 | --- | --- | --- | --- | --- | | 2026-09-16 | Q9:全树按需计算;移除已提交结构清单 | 根据[维护者反馈](https://github.com/huangruiteng/loopx/pull/4360#issuecomment-5692062394)实现,PR 评审待完成 | 取代合并后补再生成;拒绝只扫描 diff | 1、I6、3、5、9、10、12 | | 2026-09-16 | B2:Python producer 扫描器绑定一跳未改名再导出 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2;PR 评审待完成 | 要求每个消费者都从 owner 模块导入(脆弱;M2 中已静默失效);拒绝无界多跳解析 | 5、附录 A | -| 2026-09-17 | B3 修复:统计全部五项相互正交的使用事实而不是角色标签,并把字段专属的未知纳入迁移面 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3;将 `goal_boundary` 由 15 上调至 16、`work_lane_contract` 由 28 上调至 29、`external_evidence_observation` 由 6 上调至 7;按第 5 节**内核维护者批准已于 2026-09-18 在 [#4651](https://github.com/huangruiteng/loopx/pull/4651) 给出**,三处上调各自只涉及一个把字段名当数据携带的模块,token 预算与 carrier 计数均未变动 | 继续统计角色标签并加一列重叠数(否决:该标签单值,任何由它构造的计数都会漏报排序靠后的那个事实,多加一列并不改变这一点);让 `unresolved` 留在迁移面之外,理由是「并不已知需要迁移」(否决:它是已知需要调查的,而把它排除在外的预算会在有人把读者改写成扫描无法解析的形式时下降);对不可解析模块直接抛错(否决:这会让在半写状态树上工作的直接调用方整个扫描失败,而「未知」是诚实的分类,不是更响的那一种);只依赖跨运行时等价性测试(否决:它断言的是一致,而两个运行时都把解构读成散文也是一致的) | 11、附录 A、附录 B | +| 2026-09-17 | B3 修复:统计全部五项相互正交的使用事实而不是角色标签,并把字段专属的未知纳入迁移面 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3;将 `goal_boundary` 由 15 上调至 16、`work_lane_contract` 由 28 上调至 29、`external_evidence_observation` 由 6 上调至 7,三处上调各自只涉及一个把字段名当数据携带的模块,token 预算与 carrier 计数均未变动;按第 5 节**需要内核维护者批准,尚未给出** | 继续统计角色标签并加一列重叠数(否决:该标签单值,任何由它构造的计数都会漏报排序靠后的那个事实,多加一列并不改变这一点);让 `unresolved` 留在迁移面之外,理由是「并不已知需要迁移」(否决:它是已知需要调查的,而把它排除在外的预算会在有人把读者改写成扫描无法解析的形式时下降);对不可解析模块直接抛错(否决:这会让在半写状态树上工作的直接调用方整个扫描失败,而「未知」是诚实的分类,不是更响的那一种);只依赖跨运行时等价性测试(否决:它断言的是一致,而两个运行时都把解构读成散文也是一致的) | 11、附录 A、附录 B | | 2026-09-17 | B3:在 token 计数旁边为迁移面设预算;Q11 决策前两者都保留 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B3;PR 评审待完成 | 直接用新指标取代 token 预算(否决:token 计数正是证明新角色划分同一批模块的锚,在引入角色的同一个 diff 里把它删掉会让更小的数字无法复核);把 Python 计算式下标也计为未定读取(否决:`rows[index]` 与 `payload[key]` 是同一种语法,未知数会大到不再携带信息;TypeScript 没有 mapping 访问器约定,其计算式成员访问单独计数并声明为上界) | 5、9、11、12 | | 2026-09-16 | B1 改名不变性:新增按名字归组的分歧报告;写明它未闭合的边界 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B1;PR 评审待完成 | 把预算改按值集归组(否决:`CONFIDENCE_LEVELS` 与 `EDGE_CASE_COMPLEXITIES` 共享 `high/low/medium` 而含义不同);提交名字账本(M0 否决:Q9 已退役提交式清单)。该报告列出仍然存在的分叉;初稿称它能抓住单侧改名,实测证否,故两份镜像按真实行为写明边界 | 9 | | 2026-09-17 | B2:绑定同模块调用结果、局部变量有序重绑定与按键精确的容器写入;对 TypeScript 残量做重新归类而非缩减 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2;PR 评审待完成 | 绑定跨模块调用与对象字段(拒绝:那是另一条有自己影响面的有界形式,不属于本切片);把与字段同名的关键字算作生产(拒绝:会使该义务变成同义反复,见第 5 节);保留 `typescript_dynamic` 作为单一兜底(拒绝:八个位点共用一个原因,残量无法被行动);把被调方形参绑定到调用点实参(拒绝:结果会依赖调用方而无法记忆化,且一次错误绑定会凭空造出证据) | 5、9、附录 A |