From ddc240c4f64f24d40ff20f7b56d0116460a092d7 Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:12:24 -0400 Subject: [PATCH 1/3] feat(semantics): report the name-keyed value-set divergence a rename hides Collisions are keyed by name, so renaming one side of a multi-value fork removes the name from multi_value_forks and lowers the semantic budget while the drift stays in the tree. The RFC records this as a known limit and names the advisory merge report as the review aid, but that report groups *different* names carrying *identical* value sets, while a fork is *one* name whose modules disagree -- so it lists no fork at all. Measured on the baseline: all three undeclared forks (AGENT_TODO_HEADER_MARKERS, USER_TODO_HEADER_MARKERS, RAW_MATERIAL_KEY_HINTS) are absent from merge_candidate_groups, and renaming one side drops the semantic count 3 -> 2 with nothing reporting it. Add divergent_value_sets(inventory), the name-keyed companion printed by --report, and pin both behaviours as tests. Renaming one side still lowers the budget -- the limit is real and the test asserts it as documented so a future fix cannot land as a silent edit -- but the abandoned name now stays listed while its surviving definitions disagree. Renaming every side at once remains indistinguishable from an honest rename and is recorded as accepted residue; the RFC known-limits entry in both mirrors now states exactly that boundary instead of implying the merge-candidate report covers it. No budget or inventory count changes: the advisory is not a budget input. Co-Authored-By: Claude Fable 5 Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- .../semantic-vocabulary-convergence-v0.md | 10 +++- ...emantic-vocabulary-convergence-v0.zh-CN.md | 8 ++- loopx/semantics/inventory.py | 28 ++++++++++ scripts/generate_semantic_inventory.py | 6 +++ .../test_semantic_vocabulary_drift.py | 53 +++++++++++++++++++ 5 files changed, 103 insertions(+), 2 deletions(-) diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md index 864aa624c1..08753643d8 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md @@ -752,7 +752,15 @@ 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)` is the name-keyed companion, printed by + `--report`, and it catches a rename of **one** side because the surviving + definitions still disagree under the abandoned name. Renaming **every** side at + once is indistinguishable from an honest rename: no committed snapshot or name + ledger exists, so the budget falls and nothing reports 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. 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..797fc20c0c 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,13 @@ TypeScript `as const` 数组,以及拆为跨运行时孪生、同运行时分 - **改名可以洗白冲突。** 冲突按名字归组,因此把分叉的一侧改名会降低计数而 不消除漂移。这里的评审辅助是建议性合并报告;值集相同不能做成硬预算,因为 `CONFIDENCE_LEVELS` 与 `EDGE_CASE_COMPLEXITIES` 共享 `high/low/medium` 却 - 含义不同。 + 含义不同。合并候选报告本身并不覆盖这一边界:它把**不同**名字、**值集完全相同** + 的项归为一组,而分叉是**同一个**名字下的模块互相分歧,所以那个分组永远不会列出 + 任何分叉。`divergent_value_sets(inventory)` 是按名字归组的补充报告,由 + `--report` 打印;它能抓住**单侧**改名,因为残余定义在被我方遗弃的名字下仍然分歧。 + 而把**所有**一侧同时改名,与一次诚实的改名无法区分:仓库没有提交的快照,也没有 + 名字账本,因此预算下降且没有任何报告会说。这一残余与本节其余条目一样,在 M0 被 + 接受。 - **单元素载体不可见。** 只有一个字符串成员的闭集不构成词表,因此把一个两值 集合降为一个值会让它完全退出清单。 - **字面量扫描可能误读同一行上无关的比较。** 形如 diff --git a/loopx/semantics/inventory.py b/loopx/semantics/inventory.py index 02a05716ea..acb03deb69 100644 --- a/loopx/semantics/inventory.py +++ b/loopx/semantics/inventory.py @@ -432,6 +432,34 @@ 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: one name whose modules carry different multi-value sets. + + Collisions are keyed by name, so renaming one side of a fork removes the + name from ``multi_value_forks`` and lowers the semantic budget without + removing the drift. ``merge_candidate_groups`` cannot see that move: it + groups *different* names with *identical* value sets, while the split is + precisely a disagreement between value sets under one name. This report is + keyed by name instead, so a rename lowers the budget while the name it + abandoned stays listed here for the reviewer. Nothing here is a second + authority: it is advisory output over the same computed inventory, never + committed, and 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..753065f07e 100644 --- a/tests/architecture/test_semantic_vocabulary_drift.py +++ b/tests/architecture/test_semantic_vocabulary_drift.py @@ -193,6 +193,59 @@ 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 test_bounded_context_scope_requires_every_distinct_defining_module() -> None: smoke = runpy.run_path(str(SMOKE)) registry = copy.deepcopy(smoke["load_registry"]()) From c973bd73585eb03085ecf81cd08116d602e89ddc Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:32:21 -0400 Subject: [PATCH 2/3] docs(semantics): log the B1 rename-invariance decision in Appendix B The Section 9 boundary change needs its decision-log row: what was added, what it does not close, and which alternatives were rejected. Records the value-set budget as rejected (CONFIDENCE_LEVELS and EDGE_CASE_COMPLEXITIES share high/low/medium with different meanings) and a committed name ledger as rejected at M0 (Q9 retired the committed census), so the residue is a decision rather than an omission. Co-Authored-By: Claude Fable 5 Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md | 1 + .../rfcs/semantic-vocabulary-convergence-v0.zh-CN.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md index 08753643d8..9a2cfbe205 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md @@ -1189,6 +1189,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) | 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 797fc20c0c..8cd9ded89d 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md @@ -968,6 +968,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:证据登记 From a0826c93eb92efc780350e33af49b990cd091546 Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:50:31 -0400 Subject: [PATCH 3/3] fix(semantics): state the rename limit as measured, not as assumed The first version of this branch claimed divergent_value_sets catches a one-sided rename. Measurement disproves it: renaming one side leaves the name with a single definition, so it stops being a fork and drops out of the advisory exactly as it drops out of the budget. The earlier reading mistook a neighbouring unrenamed fork in the report for the renamed name. Correct the docstring, both RFC mirrors and the Appendix B row to state the boundary as it behaves, and pin all three cases as tests: a declared name rejects a one-sided rename (the declaration names every defining module), while an undeclared one-sided rename and a whole-name rename both lower the budget with nothing reporting them. The advisory's real value is narrower and now stated as such -- it lists surviving forks by name with their disagreement count, where they were visible only as a number before. Co-Authored-By: Claude Fable 5 Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- .../semantic-vocabulary-convergence-v0.md | 18 +++-- ...emantic-vocabulary-convergence-v0.zh-CN.md | 12 ++-- loopx/semantics/inventory.py | 30 +++++---- .../test_semantic_vocabulary_drift.py | 66 +++++++++++++++++++ 4 files changed, 103 insertions(+), 23 deletions(-) diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md index 9a2cfbe205..cb96d9e580 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md @@ -755,12 +755,16 @@ Known limits, stated so the check is not over-trusted: 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)` is the name-keyed companion, printed by - `--report`, and it catches a rename of **one** side because the surviving - definitions still disagree under the abandoned name. Renaming **every** side at - once is indistinguishable from an honest rename: no committed snapshot or name - ledger exists, so the budget falls and nothing reports it. That residue is - accepted for M0 along with the rest of this entry. + `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. @@ -1189,7 +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) | 9 | +| 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 8cd9ded89d..05dfc8f3b9 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md @@ -620,10 +620,12 @@ TypeScript `as const` 数组,以及拆为跨运行时孪生、同运行时分 含义不同。合并候选报告本身并不覆盖这一边界:它把**不同**名字、**值集完全相同** 的项归为一组,而分叉是**同一个**名字下的模块互相分歧,所以那个分组永远不会列出 任何分叉。`divergent_value_sets(inventory)` 是按名字归组的补充报告,由 - `--report` 打印;它能抓住**单侧**改名,因为残余定义在被我方遗弃的名字下仍然分歧。 - 而把**所有**一侧同时改名,与一次诚实的改名无法区分:仓库没有提交的快照,也没有 - 名字账本,因此预算下降且没有任何报告会说。这一残余与本节其余条目一样,在 M0 被 - 接受。 + `--report` 打印,列出**仍然存在**的分叉及其分歧值集数量——此前它们只是一串数字。 + 它**不是**改名检测器:实测表明,单侧改名后该名字只剩一份定义,因而不再见得分叉, + 会同时退出预算与这份报告。唯一会失败关闭的情形是**已声明**的名字,因为 + `scope_declarations` 指明了每个定义模块,被改名的一侧不再匹配。未声明的单侧改名、 + 以及把所有一侧同时改名,都会让预算下降且没有任何报告会说。这一残余与本节其余 + 条目一样,在 M0 被接受。 - **单元素载体不可见。** 只有一个字符串成员的闭集不构成词表,因此把一个两值 集合降为一个值会让它完全退出清单。 - **字面量扫描可能误读同一行上无关的比较。** 形如 @@ -968,7 +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 | +| 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 acb03deb69..997b6eb130 100644 --- a/loopx/semantics/inventory.py +++ b/loopx/semantics/inventory.py @@ -433,17 +433,25 @@ def merge_candidate_groups(inventory: dict[str, Any]) -> list[dict[str, Any]]: def divergent_value_sets(inventory: dict[str, Any]) -> list[dict[str, Any]]: - """Advisory: one name whose modules carry different multi-value sets. - - Collisions are keyed by name, so renaming one side of a fork removes the - name from ``multi_value_forks`` and lowers the semantic budget without - removing the drift. ``merge_candidate_groups`` cannot see that move: it - groups *different* names with *identical* value sets, while the split is - precisely a disagreement between value sets under one name. This report is - keyed by name instead, so a rename lowers the budget while the name it - abandoned stays listed here for the reviewer. Nothing here is a second - authority: it is advisory output over the same computed inventory, never - committed, and never a budget input. + """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"]: diff --git a/tests/architecture/test_semantic_vocabulary_drift.py b/tests/architecture/test_semantic_vocabulary_drift.py index 753065f07e..9855b8257e 100644 --- a/tests/architecture/test_semantic_vocabulary_drift.py +++ b/tests/architecture/test_semantic_vocabulary_drift.py @@ -246,6 +246,72 @@ def test_divergent_value_sets_lists_the_names_a_rename_would_hide() -> None: 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"]())