diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md index 864aa624c1..cb96d9e580 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md @@ -752,7 +752,19 @@ Known limits, stated so the check is not over-trusted: side of a fork lowers the count without removing the drift. The advisory merge report is the review aid here; value-set equality cannot be a hard budget because `CONFIDENCE_LEVELS` and `EDGE_CASE_COMPLEXITIES` share `high/low/medium` - while meaning different things. + while meaning different things. The merge-candidate report alone does not cover + this limit: it groups *different* names carrying *identical* value sets, while a + fork is *one* name whose modules disagree, so the grouping never lists a fork. + `divergent_value_sets(inventory)`, printed by `--report`, is the name-keyed + companion that lists surviving forks with their count of disagreeing value sets, + where they were visible only as a number before. It is **not** a rename detector: + measured, a one-sided rename leaves the name with a single definition, so it + stops being a fork and drops out of both the budget and this report. The one + case that does fail closed is a **declared** name, because `scope_declarations` + names every defining module and a renamed side no longer matches. An undeclared + one-sided rename, and renaming every side at once, both lower the budget with + nothing reporting it. That residue is accepted for M0 along with the rest of + this entry. - **Single-element carriers are invisible.** A closed set with one string member is not a vocabulary, so reducing a two-value set to one removes it from the inventory entirely. @@ -1181,6 +1193,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-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 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 10d9e81cb2..05dfc8f3b9 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md @@ -617,7 +617,15 @@ TypeScript `as const` 数组,以及拆为跨运行时孪生、同运行时分 - **改名可以洗白冲突。** 冲突按名字归组,因此把分叉的一侧改名会降低计数而 不消除漂移。这里的评审辅助是建议性合并报告;值集相同不能做成硬预算,因为 `CONFIDENCE_LEVELS` 与 `EDGE_CASE_COMPLEXITIES` 共享 `high/low/medium` 却 - 含义不同。 + 含义不同。合并候选报告本身并不覆盖这一边界:它把**不同**名字、**值集完全相同** + 的项归为一组,而分叉是**同一个**名字下的模块互相分歧,所以那个分组永远不会列出 + 任何分叉。`divergent_value_sets(inventory)` 是按名字归组的补充报告,由 + `--report` 打印,列出**仍然存在**的分叉及其分歧值集数量——此前它们只是一串数字。 + 它**不是**改名检测器:实测表明,单侧改名后该名字只剩一份定义,因而不再见得分叉, + 会同时退出预算与这份报告。唯一会失败关闭的情形是**已声明**的名字,因为 + `scope_declarations` 指明了每个定义模块,被改名的一侧不再匹配。未声明的单侧改名、 + 以及把所有一侧同时改名,都会让预算下降且没有任何报告会说。这一残余与本节其余 + 条目一样,在 M0 被接受。 - **单元素载体不可见。** 只有一个字符串成员的闭集不构成词表,因此把一个两值 集合降为一个值会让它完全退出清单。 - **字面量扫描可能误读同一行上无关的比较。** 形如 @@ -962,6 +970,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-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:证据登记 diff --git a/loopx/semantics/inventory.py b/loopx/semantics/inventory.py index 02a05716ea..997b6eb130 100644 --- a/loopx/semantics/inventory.py +++ b/loopx/semantics/inventory.py @@ -432,6 +432,42 @@ def merge_candidate_groups(inventory: dict[str, Any]) -> list[dict[str, Any]]: return sorted(groups, key=lambda group: (-len(group["names"]), group["names"])) +def divergent_value_sets(inventory: dict[str, Any]) -> list[dict[str, Any]]: + """Advisory: forks, keyed by name, with how many value sets each carries. + + Complements ``merge_candidate_groups``, which groups *different* names with + *identical* value sets and therefore never lists a fork at all: a fork is + *one* name whose modules disagree, so the two reports answer different + questions and the merge-candidate grouping cannot substitute for this one. + + What this does **not** do is detect a rename. Renaming one side of a fork + leaves that name with a single definition, so it stops being a fork and + drops out of this report exactly as it drops out of the budget -- measured, + not assumed. The RFC Section 9 limit therefore stands unclosed; this report + only makes the *surviving* forks visible by name, with the count of + disagreeing value sets, where before they were visible only as a number. + A rename of one side is still caught when the name is declared, because the + declaration names every defining module and the renamed side no longer + matches. + + Nothing here is a second authority: advisory output over the same computed + inventory, never committed, never a budget input. + """ + entries: list[dict[str, Any]] = [] + for entry in inventory["duplicate_definitions"]["multi_value_forks"]: + value_sets = {tuple(sorted(item["values"])) for item in entry["definitions"]} + if len(value_sets) < 2: + continue + entries.append( + { + "name": entry["name"], + "value_sets": len(value_sets), + "definition_modules": sorted({item["module"] for item in entry["definitions"]}), + } + ) + return sorted(entries, key=lambda item: (-item["value_sets"], item["name"])) + + def consumer_ranking(inventory: dict[str, Any], sources: list[SourceFile]) -> list[dict[str, Any]]: """Advisory ranking: modules outside the definer that mention each symbol. diff --git a/scripts/generate_semantic_inventory.py b/scripts/generate_semantic_inventory.py index 60ea818471..b71a5f28af 100755 --- a/scripts/generate_semantic_inventory.py +++ b/scripts/generate_semantic_inventory.py @@ -22,6 +22,7 @@ from loopx.semantics.inventory import ( # noqa: E402 build_inventory, consumer_ranking, + divergent_value_sets, load_sources, render_inventory, ) @@ -46,6 +47,11 @@ def main() -> int: print("external_consumer_modules values name module") for row in rows: print(f"{row['external_consumer_modules']:>25} {row['values']:>6} {row['name']:<{width}} {row['module']}") + divergent = divergent_value_sets(inventory)[: args.top] + print() + print("value_sets name definition_modules") + for row in divergent: + print(f"{row['value_sets']:>10} {row['name']} {', '.join(row['definition_modules'])}") return 0 if args.output is None: print(content, end="") diff --git a/tests/architecture/test_semantic_vocabulary_drift.py b/tests/architecture/test_semantic_vocabulary_drift.py index 760c38d9aa..9855b8257e 100644 --- a/tests/architecture/test_semantic_vocabulary_drift.py +++ b/tests/architecture/test_semantic_vocabulary_drift.py @@ -193,6 +193,125 @@ def test_bounded_context_scope_excludes_only_declared_multi_value_fork() -> None assert smoke["check_scope_declarations"](registry, inventory) == 3 +def test_renaming_one_side_of_a_fork_launders_the_semantic_budget() -> None: + """Pin the RFC Section 9 known limit so a future fix cannot be a silent edit. + + Collisions are keyed by name, so renaming one module's definition removes the + name from ``multi_value_forks`` and lowers the semantic budget by one while + the drift stays in the tree. This test asserts the limit as it is documented, + not as it should be: if a change makes renaming refuse to lower the budget, + this test must fail so the RFC's known-limits section is updated with it. + """ + smoke = runpy.run_path(str(SMOKE)) + registry = smoke["load_registry"]() + sources = smoke["load_sources"](REPO_ROOT) + before = smoke["build_inventory"](REPO_ROOT, sources=sources) + assert smoke["check_scope_declarations"](registry, before) == 3 + + path = "loopx/state_projection.py" + name = "AGENT_TODO_HEADER_MARKERS" + renamed = [ + smoke["SourceFile"](source.path, source.suffix, source.text.replace(name, name + "_RENAMED", 1)) + if source.path == path + else source + for source in sources + ] + after = smoke["build_inventory"](REPO_ROOT, sources=renamed) + assert smoke["check_scope_declarations"](registry, after) == 2, ( + "a rename no longer lowers the semantic budget; the RFC known-limits entry " + "('Renames launder a collision') is now stale and must be revised" + ) + assert after["summary"]["multi_value_forks"] == before["summary"]["multi_value_forks"] - 1 + + +def test_divergent_value_sets_lists_the_names_a_rename_would_hide() -> None: + """The reviewer-facing signal for the laundering limit above. + + ``merge_candidate_groups`` groups *different* names with *identical* value + sets, so it cannot see a fork at all -- a fork is one name whose value sets + disagree. Renaming one side hides the name from both the budget and that + grouping, so this advisory is keyed by name and still lists the abandoned + name whenever the surviving definitions disagree. + """ + from loopx.semantics.inventory import divergent_value_sets + + smoke = runpy.run_path(str(SMOKE)) + sources = smoke["load_sources"](REPO_ROOT) + inventory = smoke["build_inventory"](REPO_ROOT, sources=sources) + listed = {row["name"] for row in divergent_value_sets(inventory)} + assert {"AGENT_TODO_HEADER_MARKERS", "USER_TODO_HEADER_MARKERS", "RAW_MATERIAL_KEY_HINTS"} <= listed + + # The advisory is not a budget input: it must not appear in the committed + # inventory, which stays the single computed authority. + assert "divergent_value_sets" not in inventory + + +def _restate(smoke, sources, path, name, replacement): + return [ + smoke["SourceFile"](source.path, source.suffix, source.text.replace(name, replacement, 1)) + if source.path == path + else source + for source in sources + ] + + +def test_rename_visibility_splits_into_three_cases() -> None: + """The complete boundary, measured, so no reader has to re-derive it. + + Case 1: a partial rename of a **declared** name is rejected outright -- the + declaration names every defining module and the renamed side no longer + matches, so the rename cannot lower the budget. + + Cases 2 and 3 are the RFC Section 9 limit, and this test records it as it + actually behaves rather than as the limit's heading suggests: renaming one + side leaves the name with a single definition, so it stops being a fork and + drops out of ``divergent_value_sets`` exactly as it drops out of the budget. + The advisory makes *surviving* forks visible by name; it does not detect the + rename. Both cases assert that so a future claim of coverage fails here. + """ + from loopx.semantics.inventory import divergent_value_sets + + smoke = runpy.run_path(str(SMOKE)) + registry = smoke["load_registry"]() + sources = smoke["load_sources"](REPO_ROOT) + name = "AGENT_TODO_HEADER_MARKERS" + + def renamed_in(paths): + out = sources + for path in paths: + out = [ + smoke["SourceFile"](source.path, source.suffix, source.text.replace(name, name + "_RENAMED", 1)) + if source.path == path + else source + for source in out + ] + return out + + # Case 1: declared name, one side renamed -> the declaration no longer resolves. + declared = _restate(smoke, sources, "loopx/global_todos.py", "SOURCE_SURFACES", "GT_SOURCE_SURFACES") + with pytest.raises(smoke["Drift"], match="every defining module"): + smoke["check_scope_declarations"](registry, smoke["build_inventory"](REPO_ROOT, sources=declared)) + + # Case 2: undeclared name, one side renamed -> gone from the budget AND the advisory. + partial = smoke["build_inventory"](REPO_ROOT, sources=renamed_in(["loopx/state_projection.py"])) + assert name not in {entry["name"] for entry in partial["duplicate_definitions"]["multi_value_forks"]} + assert name not in {row["name"] for row in divergent_value_sets(partial)} + + # Case 3: every side renamed -> also invisible; indistinguishable from an honest rename. + whole = smoke["build_inventory"]( + REPO_ROOT, + sources=renamed_in([ + "loopx/state_projection.py", + "loopx/control_plane/goals/active_state_metadata.py", + ]), + ) + assert name not in {entry["name"] for entry in whole["duplicate_definitions"]["multi_value_forks"]} + assert name not in {row["name"] for row in divergent_value_sets(whole)} + + # The surviving forks are what the advisory does list, by name. + assert "USER_TODO_HEADER_MARKERS" in {row["name"] for row in divergent_value_sets(partial)} + + def test_bounded_context_scope_requires_every_distinct_defining_module() -> None: smoke = runpy.run_path(str(SMOKE)) registry = copy.deepcopy(smoke["load_registry"]())