From 63fed66b9f6d458ba7b297221166bf70b795a508 Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:32:02 -0400 Subject: [PATCH 1/2] feat(semantics): report consumer roles per site with the unknown stated consumer_ranking() counts modules that mention a symbol; the RFC already says that number classifies no role and proves no data flow. B5 adds a bounded AST scan that classifies each consuming site as read, interpret, pass_through or unknown, carrying per row the location, the source SHA the scan ran against, the value domain and the limitation for that row. Anchors come from identities the registry already holds, which is why B2 was the precondition: the vocabulary's slot name and its registered owner class, bound through the same one-unrenamed-hop import discipline the producer scanner uses. No module registers itself as a consumer and the scan reach is code owned, so registry data cannot widen it. What the report establishes is syntactic use, not data flow. Anything dynamic, aliased or external is unknown with a recorded reason rather than dropped, so the per-vocabulary tables cannot read as complete: on 440b002fb over loopx/control_plane and loopx/cli_commands, 921 rows are 3 read, 141 interpret, 50 pass_through and 727 unknown, a 78.9% unknown share, or 39.2% across the 319 rows that belong to one vocabulary. Exposed as an opt-in section of the existing --report surface. It is advisory evidence in F3's lane; it is not a gate and must not become one. Refs #4447 (B5) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- .../semantic-vocabulary-convergence-v0.md | 49 ++ ...emantic-vocabulary-convergence-v0.zh-CN.md | 40 ++ loopx/semantics/consumer_report.py | 616 ++++++++++++++++++ scripts/generate_semantic_inventory.py | 20 + .../test_semantic_consumer_report.py | 333 ++++++++++ 5 files changed, 1058 insertions(+) create mode 100644 loopx/semantics/consumer_report.py create mode 100644 tests/architecture/test_semantic_consumer_report.py diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md index 581a73af80..9448ed4117 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md @@ -783,6 +783,7 @@ on the next full-tree scan; genuine shared-contract changes still need review. | Measurement covers both carrier shapes and filters local naming | `uv run --extra test python -m pytest tests/architecture/test_semantic_inventory.py` | pass, including the collision and module-local-convention fixtures | Rules come from this RFC, not from scanner output | | 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 | +| Consumer roles are reported per site, with the unknown stated (B5) | `uv run python scripts/generate_semantic_inventory.py --report --consumer-evidence` and `uv run --extra test python -m pytest tests/architecture/test_semantic_consumer_report.py` | the report prints read/interpret/pass-through/unknown counts, both unknown shares, and every unknown reason with its site count; the tests pass | Advisory only: syntactic use over a two-root scan reach, never data flow and never a gate | | 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 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 | @@ -1071,6 +1072,53 @@ introduce a competing target state. ## Appendix A: Execution ledger (non-normative) +### 2026-09-17 — B5: consumer roles reported per site, with the unknown stated + +Non-normative; advisory evidence only. No check changes its pass/fail result on +the current tree, and nothing added here gates a merge. + +- `consumer_ranking` counts modules that *mention* a symbol. Section 5 already + says that number "does not classify roles or prove data flow", so B5 adds + `loopx/semantics/consumer_report.py`: a bounded AST scan that classifies each + consuming site as `read`, `interpret`, `pass_through` or `unknown` and carries + per row the location (`module::symbol` and line), the source SHA the scan ran + against, the value domain, and the limitation that applies to that row. +- Anchors are identities the registry already carries, which is why B2 was the + precondition: the vocabulary's slot name (`literal_scan.field`, else the + vocabulary id) and its registered owner class, bound through the same + one-unrenamed-hop import discipline the producer scanner uses. No module + registers itself as a consumer, and the scan reach is code owned exactly as + `PRODUCER_ROOTS` is, so registry data cannot widen it. +- Measured on `440b002fb` across `loopx/control_plane` and `loopx/cli_commands`, + 484 of 1207 tracked sources: **921 rows — 3 `read`, 141 `interpret`, + 50 `pass_through`, 727 `unknown`, a 78.9% unknown share.** Restricted to the + 319 rows that belong to a single vocabulary, the unknown share is 39.2%. +- The unknown is most of the measurement, not a residue to tidy away. 602 sites + read a mapping under a computed key and are therefore unresolved for every + vocabulary at once; 76 modules spell a slot with no recognized anchor; + 46 tracked TypeScript sources carrying a slot are not walked because there is + no TypeScript parser on this path; 3 Python sites are an unstable local or an + unclassified context. Each is a row with a location and a recorded reason, + following the pattern B2 set for unresolved producer sites. +- What the report establishes is **syntactic use, not data flow**. A row says + this location performs a recognized read of this slot and that the syntax + around the read branches on the value or forwards it. It does not prove the + value came from a registered producer, that the branch is reachable, or that + a vocabulary with no rows has no reader — the computed-key population is + precisely why that last claim cannot be made from a name-keyed scan. +- Exposed through the existing report surface as + `scripts/generate_semantic_inventory.py --report --consumer-evidence`, opt-in + because it is extra work: the per-site scan costs 3.35-3.65s over three + isolated runs on the full tree, against roughly 217s for the ranking that + `--report` already prints. The drift smoke does not call it, and the only + pull-request path that reaches `--report` is a pytest fixture repository of + three files, so the per-PR cost is unchanged. +- Not addressed here: the reach is two roots rather than the tree; TypeScript is + counted but not parsed; and following a local is one hop, so a value moving + through two aliases is unknown rather than traced. Widening any of the three + is a separate change carrying its own risk. + + ### 2026-09-17 — Invariant statements bounded to their verified domains Normative; requires kernel-maintainer approval. No check changes its pass/fail @@ -1280,6 +1328,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-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 | B5: report consumer roles per site from registry-anchored AST evidence, and state the unknown share instead of classifying everything | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B5; PR review pending | Register consumers in the registry (rejected: the tracking issue forbids blanket consumer registration, and a declared list is a claim rather than evidence); make the report a merge gate (rejected: F3 is the advisory lane, and a 78.9% unknown share cannot gate anything); report only the sites the grammar resolves (rejected: the per-vocabulary tables would read as complete, so an unrecognized mention and a computed-key read are rows with reasons) | 9, Appendix A, Appendix B | | 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 | ## 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 abb3c455ec..3c7bb1cdb3 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md @@ -638,6 +638,7 @@ owner 符号集合的组:`EffectiveAction` 与 `EFFECTIVE_ACTIONS` 是同一 | 度量覆盖两种载体形状并过滤局部命名 | `uv run --extra test python -m pytest tests/architecture/test_semantic_inventory.py` | 通过,含冲突与模块局部约定两组夹具 | 规则来自本 RFC 而非扫描输出 | | 两处 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` | 通过 | 检查镜像、链接、索引 | +| 消费者角色按位点报告,并写明未知量(B5) | `uv run python scripts/generate_semantic_inventory.py --report --consumer-evidence` 与 `uv run --extra test python -m pytest tests/architecture/test_semantic_consumer_report.py` | 报告打印 read/interpret/pass-through/unknown 计数、两个未知占比,以及每种未知原因及其位点数;测试通过 | 仅为参考:在两个根目录的扫描范围内度量语法使用,既不是数据流,也永远不是闸门 | | 退休预算按子串而非标识符计数 | 分别以 `in file.text` 与 `\bgoal_boundary\b` 统计 `goal_boundary` | 基线上 35 对 30 个 Python 模块 | 已知边界;M3 的零读者门需要标识符计数,见第 12 节 | | 模块局部约定过滤器是一次代码修改 | 扩宽 `inventory.py` 的 `MODULE_LOCAL_CONVENTION` 并重新生成 | `*_semantic` 预算下降而别处无代码改动 | 已知边界;正则在代码里,扩宽是可评审的 diff,未过滤总数仍在预算内 | | 无人生产的注册值失败(M0.5) | 在基线上运行生产形式扫描 | 失败并点名 `effective_action` 与 `skip`;删除 `skip` 或列入 `compatibility_only` 后通过 | 第一个预期的 I12 失败;只被比较的值不算已携带 | @@ -871,6 +872,44 @@ PR review 保留这些层级。普通改动记录检查范围和理由,无共 ## 附录 A:执行账本(非规范) +### 2026-09-17 — B5:按位点报告消费者角色,并写明未知量 + +非规范性;仅为参考证据。当前源码树上没有任何检查的通过/失败结果改变, +本次新增的内容也不闸住任何合并。 + +- `consumer_ranking` 统计的是**提及**某符号的模块数。第 5 节早已写明这个数字 + “既不分类角色,也不证明数据流”,因此 B5 新增 + `loopx/semantics/consumer_report.py`:一次有界的 AST 扫描,把每个消费位点分类为 + `read`、`interpret`、`pass_through` 或 `unknown`,并逐行携带位置 + (`module::symbol` 与行号)、扫描所依据的源码 SHA、涉及的值域,以及适用于该行的 + 边界说明。 +- 锚点全部来自注册表已有的身份,这正是以 B2 为前置条件的原因:词表的槽位名 + (`literal_scan.field`,没有则用词表 id),以及它注册的 owner 类——后者沿用 + producer 扫描器同一套“一跳、未改名”的导入绑定纪律。没有任何模块需要把自己登记为 + 消费者,扫描范围与 `PRODUCER_ROOTS` 一样由代码所有,注册表数据无法扒宽它。 +- 在 `440b002fb` 上、跨 `loopx/control_plane` 与 `loopx/cli_commands`、 + 1207 个已跟踪源文件中的 484 个上实测:**921 行——`read` 3 行、`interpret` 141 行、 + `pass_through` 50 行、`unknown` 727 行,未知占比 78.9%。** 若只看归属于单一词表的 + 319 行,未知占比为 39.2%。 +- 未知量是这次度量的主体,不是待清扫的残渣。602 个位点以计算出的键读取映射, + 因而对每个词表都同时未解析;76 个模块写出了槽位名却没有可识别的锚点; + 46 个携带槽位名的已跟踪 TypeScript 源文件未被遍历,因为这条路径上没有 TypeScript + 解析器;3 个 Python 位点是不稳定局部变量或未分类上下文。每一条都是带位置与 + 记录原因的行,沿用 B2 为未解析 producer 位点确立的做法。 +- 该报告确立的是**语法使用,而非数据流**。一行的含义是:此位置对该槽位执行了一次 + 可识别的读取,且读取周围的语法对该值做了分支或做了转发。它不证明该值来自已注册的 + producer,不证明该分支可达,也不证明没有行的词表就没有读者——计算键那一群位点, + 恰恰就是按名字归组的扫描无法作出最后这个论断的原因。 +- 通过既有报告表面暴露为 + `scripts/generate_semantic_inventory.py --report --consumer-evidence`,采用显式开启: + 它是额外工作——全树上按位点扫描在三次独立运行中耗时 3.35–3.65 秒,而 `--report` + 本已打印的排名约需 217 秒。drift smoke 不会调用它,PR 路径上唯一触及 `--report` + 的是一个三文件的 pytest 夹具仓库,因此每个 PR 的开销不变。 +- 本次未处理:扫描范围是两个根目录而非整棵树;TypeScript 只计数、不解析; + 跟随局部变量只走一跳,因此经过两次别名传递的值是未知而非被追踪。扒宽这三者中的 + 任何一个,都是自带风险的另一个变更。 + + ### 2026-09-17 — 不变量表述收敛到各自已验证的值域 规范性变更;需要内核维护者批准。当前源码树上没有任何检查的通过/失败结果改变, @@ -1043,6 +1082,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-17 | B5:以注册表锚定的 AST 证据按位点报告消费者角色,并写明未知占比,而不是把一切都分类 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B5;PR 评审待完成 | 在注册表中登记消费者(否决:跟踪 issue 明令禁止全量消费者登记,且一份声明清单是主张而非证据);把该报告做成合并闸门(否决:F3 属于参考层级,78.9% 的未知占比也不足以闸住任何东西);只报告语法能解析的位点(否决:这会让各词表的表格读起来像是完整的,因此未识别的提及与计算键读取都作为带原因的行保留) | 9、附录 A、附录 B | | 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 | ## 附录 C:证据登记 diff --git a/loopx/semantics/consumer_report.py b/loopx/semantics/consumer_report.py new file mode 100644 index 0000000000..d9c5c0a6a0 --- /dev/null +++ b/loopx/semantics/consumer_report.py @@ -0,0 +1,616 @@ +"""Advisory consumer evidence for a registered vocabulary (RFC B5). + +The existing ``consumer_ranking`` counts modules that *mention* a symbol. That +number orders retirement work, but it classifies no role and proves no data +flow: a prompt string, a comment and a dispatch table all count the same. This +module answers the next question with bounded AST evidence -- for one registered +vocabulary, which sites **read** its value, which only **pass it through**, which +**interpret** it by branching, and which stay **unknown** with a recorded reason. + +**What this measures is syntactic use, not data flow.** Nothing here traces a +value from a producer to a consumer. A row says "this location performs a +recognized read of this vocabulary's slot, and the recognized syntax around that +read forwards it / branches on it / does neither". It does not say the value +actually observed at runtime came from a registered producer, nor that the +branch is reachable. Data-flow proof would need interprocedural analysis this +module deliberately does not attempt, and the ``limitation`` column on every row +says which gap applies to that row rather than leaving the reader to guess. + +Roles follow the RFC hierarchy, where interpreter and pass-through are consumer +subroles rather than a partition of whole modules. A transformer that reads one +vocabulary and emits another is an interpreter *of the vocabulary it reads*; +the same module can be a plain consumer of a second vocabulary in the next +function. Rows are therefore per site and per vocabulary, never per module, and +a site carrying both a branch and a forward is reported as ``interpret``, +the stronger of the two claims. + +Anchors come from identities the registry already carries -- the B2 producer and +slot work is the precondition -- so no module has to register itself as a +consumer and no new blanket registration exists: + +* **slot read** -- a literal-key read of the vocabulary's slot name, which is + ``literal_scan.field`` when the registry declares one and otherwise the + vocabulary id: ``payload["example_slot"]``, ``payload.get("example_slot")``, + ``record.example_slot``, ``"example_slot" in payload``. +* **owner member** -- a comparison or ``match`` case against a member of the + registered owner class, resolved through the same one-unrenamed-hop import + discipline the producer scanner uses. The operand on the other side is being + interpreted even when the slot name never appears. + +Four limits are part of the metric rather than caveats beside it: + +* **The slot is keyed by name.** A carrier of the same vocabulary under a + different field name is not found, and an unrelated field that happens to + share the name is misattributed. Every slot-read row carries that limitation. +* **A computed mapping key is unknown, not absent.** ``payload.get(name)`` may + read any slot, so no name-keyed scan can prove a vocabulary has no reader. + Those sites are reported as ``unknown`` rows against every vocabulary at once. + Computed *subscripts* are deliberately excluded: ``rows[index]`` and + ``payload[key]`` are the same syntax, and counting sequence indexing would + inflate the unknown until it stopped carrying information. +* **Following a local is one hop.** A read bound to a single-assignment, + unshadowed local is classified by that local's uses. A parameter, a reassigned + name, a name that escapes into a nested scope, or a second alias hop is + ``unknown`` with the reason recorded, never a silent drop. +* **TypeScript is not walked.** There is no TypeScript AST here, so a tracked + ``.ts`` file carrying the slot token is reported as one ``unknown`` row rather + than omitted from the reach. + +Nothing in this module gates anything. It is advisory evidence printed on +demand by ``scripts/generate_semantic_inventory.py --report``, matching F3's +``advisory`` lane in the formal model, and it must not become a merge gate +without the RFC decision that would license one. +""" + +from __future__ import annotations + +import ast +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +import subprocess +from typing import Any, Iterable, Mapping + +from .inventory import SourceFile +from .python_production import _qualified_bindings, enum_members + +# Module-private names on purpose: a module-level ``NAME = (...)`` of string +# literals is itself an inventory carrier, and this module must not add a +# closed-set carrier to the tree it measures. + +# The scan reach is code owned, exactly as PRODUCER_ROOTS is. Registry data +# cannot widen it, so a vocabulary cannot buy coverage with a data-only edit. +# These are the representative paths: the control plane where kernel values are +# decided and the CLI surface that renders them. +CONSUMER_SCAN_ROOTS = ('loopx/control_plane', 'loopx/cli_commands') + +_ROLES = ('read', 'interpret', 'pass_through', 'unknown') +# Precedence when one anchor supports more than one claim. Interpreting is the +# stronger statement about the value, so a site that branches and also forwards +# is an interpreter; a site that only forwards is a pass-through. +_ROLE_RANK = {'interpret': 3, 'pass_through': 2, 'read': 1} + +# Mapping accessors whose first argument names a slot. Unlike a subscript, +# these are unambiguously mapping reads, so a computed argument is real +# evidence of an unresolved read rather than an indexing false positive. +_MAPPING_READS = frozenset({'get', 'pop'}) + +# Wildcard vocabulary id for a site that may read any registered slot. +ANY_VOCABULARY = '*' + +_LIMITATIONS = { + 'read': 'slot_name_keyed: a recognized read of this slot name; no proof the value came from a registered producer', + 'interpret': 'branch_operands_only: the compared literals are syntactic; unmatched values take default paths this scan does not enumerate', + 'pass_through': 'forward_target_untraced: the receiving site is not followed, so the value is not proved to stay in the domain', + 'unknown': 'no_classification: recorded so the site stays visible; it is not evidence of absence', +} + + +@dataclass(frozen=True) +class ConsumerSite: + """One classified site. ``blocker`` is set only when ``role`` is unknown.""" + + vocabulary: str + site: str + line: int + role: str + anchor: str + domain: tuple[str, ...] + observed: tuple[str, ...] + limitation: str + blocker: str | None = None + + +@dataclass(frozen=True) +class ConsumerEvidence: + """A whole scan, with the tree it ran against and the reach it covered.""" + + source_sha: str + worktree_dirty: bool + scanned_files: int + tracked_files: int + skipped_typescript: int + rows: tuple[ConsumerSite, ...] + + def counts(self) -> dict[str, int]: + tally = Counter(row.role for row in self.rows) + return {role: tally.get(role, 0) for role in _ROLES} + + def unknown_share(self) -> float: + return (self.counts()['unknown'] / len(self.rows)) if self.rows else 0.0 + + def blockers(self) -> dict[str, int]: + return dict(sorted(Counter(row.blocker for row in self.rows if row.blocker).items())) + + +def source_revision(repo_root: Path) -> tuple[str, bool]: + """The commit the scan ran against, and whether the scanned tree differs. + + ``load_sources`` lists paths from the git index but reads their text from + the working tree, so a row's location is only reproducible at this commit + when the tree is clean. A dirty tree is reported, never hidden. + """ + def git(*arguments: str) -> str: + return subprocess.run(['git', *arguments], cwd=repo_root, check=True, + stdout=subprocess.PIPE).stdout.decode('utf-8').strip() + + try: + sha = git('rev-parse', 'HEAD') + dirty = bool(git('status', '--porcelain', '--', *CONSUMER_SCAN_ROOTS)) + except (subprocess.CalledProcessError, OSError): + return 'unknown', True + return sha, dirty + + +def slot_name(name: str, vocabulary: Mapping[str, Any]) -> str: + """The payload key this vocabulary travels under, from registry identity.""" + return vocabulary.get('literal_scan', {}).get('field') or name + + +def _in_reach(path: str) -> bool: + return any(path == root or path.startswith(root + '/') for root in CONSUMER_SCAN_ROOTS) + + +def _owner_members(vocabulary: Mapping[str, Any], by_path: Mapping[str, SourceFile]) -> dict[str, dict[str, str]]: + owner = (vocabulary.get('owners') or {}).get('python') + if not owner or '::' not in owner: + return {} + module, symbol = owner.split('::') + if module not in by_path or by_path[module].suffix != '.py': + return {} + try: + return {owner: enum_members(by_path[module], symbol)} + except ValueError: + # An owner this scan cannot read is not an excuse to claim coverage. + return {} + + +_TREES: dict[tuple[str, int], ast.Module] = {} + + +def _parsed(source: SourceFile) -> ast.Module: + """One parse per source text, reused across every registered vocabulary. + + Keyed by path and text hash, so an edited file is a different key and can + never be classified from a stale tree. + """ + key = (source.path, hash(source.text)) + tree = _TREES.get(key) + if tree is None: + tree = _TREES[key] = ast.parse(source.text, filename=source.path) + return tree + + +_SCOPES: dict[tuple[str, int], list[_Scope]] = {} + + +def _parents(nodes: Iterable[ast.AST]) -> dict[int, ast.AST]: + table: dict[int, ast.AST] = {} + for node in nodes: + for child in ast.iter_child_nodes(node): + table[id(child)] = node + return table + + +@dataclass +class _Scope: + name: str + nodes: list[ast.AST] + nested: list[ast.AST] + parents: dict[int, ast.AST] + assigned: Counter + parameters: set[str] + + +def _scopes(tree: ast.Module) -> list[_Scope]: + """Split a module into scopes, each holding only its own statements. + + Nested functions, classes and lambdas are separate scopes; their bodies are + kept so a name that escapes into one can be detected rather than mistaken + for an unused local. + """ + scopes: list[_Scope] = [] + + def walk(body: list[ast.stmt], name: str, parameters: set[str]) -> None: + nodes: list[ast.AST] = [] + nested: list[ast.AST] = [] + + def collect(node: ast.AST) -> None: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)): + nested.append(node) + return + nodes.append(node) + for child in ast.iter_child_nodes(node): + collect(child) + + for statement in body: + collect(statement) + assigned = Counter(n.id for n in nodes if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store)) + scopes.append(_Scope(name, nodes, nested, _parents(nodes), assigned, parameters)) + for child in nested: + if isinstance(child, ast.Lambda): + continue + inner = name + '.' + child.name if name != '' else child.name + params: set[str] = set() + if not isinstance(child, ast.ClassDef): + arguments = child.args + params = {a.arg for a in (*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs)} + params.update(a.arg for a in (arguments.vararg, arguments.kwarg) if a) + walk(child.body, inner, params) + + walk(tree.body, '', set()) + return scopes + + +def _module_scopes(source: SourceFile) -> list[_Scope]: + key = (source.path, hash(source.text)) + scopes = _SCOPES.get(key) + if scopes is None: + scopes = _SCOPES[key] = _scopes(_parsed(source)) + return scopes + + +def _literal_strings(node: ast.AST) -> set[str]: + if isinstance(node, ast.Constant): + return {node.value} if isinstance(node.value, str) else set() + if isinstance(node, (ast.Tuple, ast.List, ast.Set)): + return set().union(*(_literal_strings(item) for item in node.elts), set()) + if isinstance(node, ast.MatchValue): + return _literal_strings(node.value) + if isinstance(node, ast.MatchOr): + return set().union(*(_literal_strings(p) for p in node.patterns), set()) + return set() + + +def _classify_use(node: ast.AST, scope: _Scope) -> tuple[str | None, set[str], str | None]: + """Walk syntactic parents of one use; return role, named literals, local. + + A ``None`` role means the surrounding syntax is outside the recognized + grammar. The third element is the plain local name the climb ended at, if + any, so the caller can spend its single hop on that name's own uses. + """ + observed: set[str] = set() + role: str | None = None + current = node + for _ in range(12): # A finite climb; a deeper nesting stays unclassified. + parent = scope.parents.get(id(current)) + if parent is None: + break + if isinstance(parent, ast.Attribute) and parent.attr == 'value' and parent.value is current: + # Unwrapping an enum member to its string does not change meaning; + # keep climbing so the row is classified by what consumes the + # string. Any other attribute of the read is a different field and + # ends the climb rather than being attributed to this slot. + current = parent + continue + if isinstance(parent, ast.Compare): + for operand in (parent.left, *parent.comparators): + if operand is not current: + observed |= _literal_strings(operand) + role = 'interpret' + break + if isinstance(parent, ast.Match) and parent.subject is current: + for case in parent.cases: + observed |= _literal_strings(case.pattern) + role = 'interpret' + break + if isinstance(parent, (ast.If, ast.While, ast.Assert)) and getattr(parent, 'test', None) is current: + role = 'interpret' + break + if isinstance(parent, ast.IfExp) and parent.test is current: + role = 'interpret' + break + if isinstance(parent, ast.Subscript) and parent.slice is current: + # The value selects an entry of another mapping: one vocabulary + # read as the key into another, which is interpretation. + role = 'interpret' + break + if isinstance(parent, ast.Return): + role = _stronger(role, 'pass_through') + break + if isinstance(parent, ast.Dict) and current in parent.values: + role = _stronger(role, 'pass_through') + break + if isinstance(parent, (ast.Assign, ast.AnnAssign)) and parent.value is current: + targets = parent.targets if isinstance(parent, ast.Assign) else [parent.target] + if any(isinstance(t, (ast.Subscript, ast.Attribute)) for t in targets): + # A store into a payload or an object forwards the value out. + return _stronger(role, 'pass_through'), observed, None + if len(targets) == 1 and isinstance(targets[0], ast.Name): + return role, observed, targets[0].id + break + if isinstance(parent, ast.Call) and current is not parent.func: + role = _stronger(role, 'pass_through') + elif isinstance(parent, (ast.FormattedValue, ast.JoinedStr)): + role = _stronger(role, 'pass_through') + elif isinstance(parent, ast.Expr): + role = _stronger(role, 'read') + break + elif not isinstance(parent, (ast.BoolOp, ast.UnaryOp, ast.Tuple, ast.List, ast.Set, + ast.keyword, ast.Starred, ast.IfExp, ast.Await)): + break + current = parent + return role, observed, None + + +def _stronger(current: str | None, candidate: str) -> str: + return candidate if current is None or _ROLE_RANK[candidate] > _ROLE_RANK[current] else current + + +def _escapes(name: str, scope: _Scope) -> bool: + return any(isinstance(n, ast.Name) and n.id == name and isinstance(n.ctx, ast.Load) + for child in scope.nested for n in ast.walk(child)) + + +def _classify_anchor(node: ast.AST, scope: _Scope) -> tuple[str, set[str], str | None]: + """Classify one anchor expression, spending at most one local hop. + + A blocker is returned only when the climb produced no role of its own. A + read that is serialized into an unstable local has already shown a + pass-through; losing that to the local's instability would understate what + was actually observed. + """ + role, observed, name = _classify_use(node, scope) + + def settle(blocker: str) -> tuple[str, set[str], str | None]: + return (role, observed, None) if role is not None else ('unknown', observed, blocker) + + if name is None: + return settle('unclassified_context') + if name in scope.parameters or scope.assigned[name] != 1: + return settle('unstable_local') + if _escapes(name, scope): + return settle('nested_scope_escape') + uses = [n for n in scope.nodes + if isinstance(n, ast.Name) and n.id == name and isinstance(n.ctx, ast.Load)] + if not uses: + # Bound and never read again: a real read, and nothing more than that. + return _stronger(role, 'read'), observed, None + resolved = role + for use in uses: + used_role, named, next_name = _classify_use(use, scope) + observed |= named + if used_role is None: + # A second alias hop would be needed; one hop is the stated bound. + return settle('alias_chain' if next_name else 'unclassified_context') + resolved = _stronger(resolved, used_role) + return resolved or 'read', observed, None + + +def _slot_anchors(scope: _Scope, slot: str) -> list[ast.AST]: + anchors: list[ast.AST] = [] + for node in scope.nodes: + if isinstance(node, ast.Subscript) and isinstance(node.ctx, ast.Load): + if isinstance(node.slice, ast.Constant) and node.slice.value == slot: + anchors.append(node) + elif isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load) and node.attr == slot: + anchors.append(node) + elif (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + and node.func.attr in _MAPPING_READS and node.args + and isinstance(node.args[0], ast.Constant) and node.args[0].value == slot): + anchors.append(node) + return anchors + + +def _membership_anchors(scope: _Scope, slot: str) -> list[ast.AST]: + """``"slot" in payload`` proves the module checks for the slot's presence.""" + return [node for node in scope.nodes + if isinstance(node, ast.Compare) + and any(isinstance(op, (ast.In, ast.NotIn)) for op in node.ops) + and isinstance(node.left, ast.Constant) and node.left.value == slot] + + +def _owner_anchors(scope: _Scope, members: Mapping[str, str], bound: set[str]) -> list[tuple[ast.AST, set[str]]]: + """Comparisons and match cases naming a member of the bound owner class.""" + def member_values(node: ast.AST) -> set[str]: + if isinstance(node, ast.Attribute) and node.attr == 'value': + return member_values(node.value) + if (isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) + and node.value.id in bound and node.attr in members): + return {members[node.attr]} + if isinstance(node, (ast.Tuple, ast.List, ast.Set)): + return set().union(*(member_values(item) for item in node.elts), set()) + if isinstance(node, ast.MatchValue): + return member_values(node.value) + if isinstance(node, ast.MatchOr): + return set().union(*(member_values(p) for p in node.patterns), set()) + return set() + + anchors: list[tuple[ast.AST, set[str]]] = [] + for node in scope.nodes: + if isinstance(node, ast.Compare): + named: set[str] = set() + subject: ast.AST | None = None + for operand in (node.left, *node.comparators): + values = member_values(operand) + named |= values + if not values and subject is None: + subject = operand + if named and subject is not None: + anchors.append((node, named)) + elif isinstance(node, ast.Match): + named = set().union(*(member_values(case.pattern) for case in node.cases), set()) + if named: + anchors.append((node, named)) + return anchors + + +def _dynamic_anchors(scope: _Scope) -> list[ast.AST]: + """Mapping reads with a computed key: possible readers of any slot.""" + return [node for node in scope.nodes + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + and node.func.attr in _MAPPING_READS and node.args + and not isinstance(node.args[0], ast.Constant)] + + +def scan_python_consumers( + source: SourceFile, *, vocabulary: str, slot: str, values: Iterable[str], + owners: Mapping[str, Mapping[str, str]] | None = None, + modules: Mapping[str, SourceFile] | None = None, +) -> list[ConsumerSite]: + """Classify every recognized consuming site of one vocabulary in one file.""" + tree = _parsed(source) + domain = tuple(sorted(values)) + owners = owners or {} + members: dict[str, str] = {} + bound: set[str] = set() + if owners: + bindings = _qualified_bindings(source, tree, owners, modules) + bound = set(bindings) + for member_map in bindings.values(): + members.update(member_map) + rows: set[ConsumerSite] = set() + for scope in _module_scopes(source): + site = f'{source.path}::{scope.name}' + + def add(node: ast.AST, role: str, anchor: str, observed: Iterable[str], blocker: str | None) -> None: + named = tuple(sorted(set(observed) & set(domain))) if domain else tuple(sorted(set(observed))) + rows.add(ConsumerSite(vocabulary, site, node.lineno, role, anchor, domain, named, + _LIMITATIONS[role] if blocker is None else f'{blocker}: unresolved, not absent', + blocker)) + + for node in _slot_anchors(scope, slot): + role, observed, blocker = _classify_anchor(node, scope) + add(node, role, 'slot_read', observed, blocker) + for node in _membership_anchors(scope, slot): + add(node, 'read', 'slot_presence', set(), None) + for node, named in _owner_anchors(scope, members, bound): + add(node, 'interpret', 'owner_member', named, None) + return sorted(rows, key=lambda row: (row.site, row.line, row.role, row.anchor)) + + +def scan_dynamic_reads(source: SourceFile) -> list[ConsumerSite]: + """Sites that read a mapping under a computed key, unattributed by design. + + Such a site may read any registered slot, so it is recorded once against + ``ANY_VOCABULARY`` rather than duplicated into every vocabulary's table. + It is the reason a vocabulary measured at zero readers is measured against + a stated unknown instead of declared dead. + """ + rows: list[ConsumerSite] = [] + for scope in _module_scopes(source): + for node in _dynamic_anchors(scope): + rows.append(ConsumerSite( + ANY_VOCABULARY, f'{source.path}::{scope.name}', node.lineno, 'unknown', + 'dynamic_mapping_read', (), (), + 'dynamic_key: unresolved, not absent', 'dynamic_key')) + return rows + + +def _first_line(text: str, token: str) -> int: + """The first line carrying a token, so an unclassified mention has a location.""" + for number, line in enumerate(text.splitlines(), start=1): + if token in line: + return number + return 1 + + +def collect_consumer_evidence( + repo_root: Path, registry: Mapping[str, Any], sources: list[SourceFile], + *, names: Iterable[str] | None = None, +) -> ConsumerEvidence: + """Scan the code-owned reach for every selected registered vocabulary.""" + by_path = {s.path: s for s in sources} + selected = [s for s in sources if _in_reach(s.path)] + python = [s for s in selected if s.suffix == '.py'] + sha, dirty = source_revision(repo_root) + wanted = list(names) if names is not None else list(registry['vocabularies']) + rows: list[ConsumerSite] = [] + skipped = 0 + for name in wanted: + vocabulary = registry['vocabularies'][name] + slot = slot_name(name, vocabulary) + owners = _owner_members(vocabulary, by_path) + domain = tuple(sorted(vocabulary['values'])) + symbols = [owner.split('::')[1] for owner in owners] + for source in python: + # Cheap text prefilter: a file that never spells the slot and never + # imports the owner cannot yield a slot or owner anchor. + mentions_slot = slot in source.text + if not mentions_slot and not any(symbol in source.text for symbol in symbols): + continue + found = scan_python_consumers(source, vocabulary=name, slot=slot, + values=vocabulary['values'], owners=owners, modules=by_path) + rows.extend(found) + if not found and mentions_slot: + # The token is here and the grammar recognized nothing: prose, a + # field list, a local named after the slot, a carrier this scan + # does not model. Reported as unknown because the alternative is + # to drop the module and let the tables read as complete. + rows.append(ConsumerSite( + name, f'{source.path}::', _first_line(source.text, slot), 'unknown', + 'slot_mention', domain, (), + 'mention_without_recognized_anchor: unresolved, not absent', 'mention_without_recognized_anchor')) + for source in selected: + if source.suffix == '.ts' and slot in source.text: + skipped += 1 + rows.append(ConsumerSite( + name, f'{source.path}::', 1, 'unknown', 'typescript_source', domain, (), + 'typescript_not_walked: unresolved, not absent', 'typescript_not_walked')) + for source in python: + rows.extend(scan_dynamic_reads(source)) + rows.sort(key=lambda row: (row.vocabulary, row.site, row.line, row.role, row.anchor)) + return ConsumerEvidence(sha, dirty, len(python), len(sources), skipped, tuple(rows)) + + +def render_consumer_evidence(evidence: ConsumerEvidence, *, top: int = 25) -> list[str]: + """Advisory lines for the report surface; never a pass/fail verdict. + + Two unknown shares are printed because they answer different questions. The + overall share includes the computed-key sites, which belong to no single + vocabulary and dominate the count; the attributed share is what is unknown + once a row has a vocabulary. Printing only one of them would flatter or + inflate the result depending on which. + """ + counts = evidence.counts() + total = len(evidence.rows) + attributed = [row for row in evidence.rows if row.vocabulary != ANY_VOCABULARY] + attributed_unknown = sum(1 for row in attributed if row.role == 'unknown') + lines = [ + 'consumer evidence (advisory; syntactic use, not proved data flow, never a gate):', + f' source_sha={evidence.source_sha[:12]}' + f'{" +dirty-worktree" if evidence.worktree_dirty else ""}' + f' reach={evidence.scanned_files} scanned Python sources of {evidence.tracked_files} tracked' + f' roots={", ".join(CONSUMER_SCAN_ROOTS)}', + f' rows={total} ' + ' '.join(f'{role}={counts[role]}' for role in _ROLES) + + f' unknown_share={evidence.unknown_share():.1%}', + f' attributed_rows={len(attributed)} unknown={attributed_unknown}' + f' attributed_unknown_share={attributed_unknown / len(attributed):.1%}' + if attributed else ' attributed_rows=0', + ' unknown_reasons: ' + (', '.join(f'{k}={v}' for k, v in evidence.blockers().items()) or 'none'), + f' {ANY_VOCABULARY} rows read a mapping under a computed key, so they are unresolved ' + 'for every vocabulary at once and are never counted as a reader of one', + ] + per_vocabulary = Counter((row.vocabulary, row.role) for row in evidence.rows) + for name in sorted({row.vocabulary for row in evidence.rows}): + tally = ' '.join(f'{role}={per_vocabulary.get((name, role), 0)}' for role in _ROLES) + lines.append(f' {name}: {tally}') + lines.append(' rows (location | role | anchor | observed values | limitation):') + shown = [row for row in evidence.rows if row.vocabulary != ANY_VOCABULARY][:top] + for row in shown: + observed = ','.join(row.observed) or '-' + lines.append(f' {row.site}:{row.line} | {row.role} | {row.anchor} | {observed} | {row.limitation}') + if len(attributed) > len(shown): + lines.append(f' ... {len(attributed) - len(shown)} further attributed rows and ' + f'{total - len(attributed)} computed-key rows; raise --top to print more') + return lines diff --git a/scripts/generate_semantic_inventory.py b/scripts/generate_semantic_inventory.py index 0ba8593f28..f5bb558afd 100755 --- a/scripts/generate_semantic_inventory.py +++ b/scripts/generate_semantic_inventory.py @@ -6,6 +6,7 @@ uv run python scripts/generate_semantic_inventory.py --output .local/inventory.json uv run python scripts/generate_semantic_inventory.py --output .local/inventory.json --check uv run python scripts/generate_semantic_inventory.py --report # advisory consumer ranking + merge candidates + uv run python scripts/generate_semantic_inventory.py --report --consumer-evidence # + per-site consumer roles """ from __future__ import annotations @@ -19,6 +20,10 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) +from loopx.semantics.consumer_report import ( # noqa: E402 + collect_consumer_evidence, + render_consumer_evidence, +) from loopx.semantics.inventory import ( # noqa: E402 build_inventory, consumer_ranking, @@ -65,9 +70,16 @@ def main() -> int: "--report", action="store_true", help="print the advisory consumer ranking and the merge candidates the registry does not explain", ) + parser.add_argument( + "--consumer-evidence", action="store_true", + help="with --report, also classify each consuming site as read/interpret/pass-through/unknown " + "(RFC B5; advisory evidence over a bounded scan reach, never a gate)", + ) parser.add_argument("--check", action="store_true", help="compare an explicit --output report without writing") parser.add_argument("--top", type=int, default=25, help="rows to print with --report") args = parser.parse_args() + if args.consumer_evidence and not args.report: + parser.error("--consumer-evidence extends --report; it is advisory evidence, not a check") if args.check and args.output is None: parser.error("--check requires --output; inventories are no longer committed. " "Run examples/semantic-vocabulary-drift-smoke.py for semantic validation.") @@ -87,6 +99,14 @@ def main() -> int: print(f"{row['value_sets']:>10} {row['name']} {', '.join(row['definition_modules'])}") print() print_merge_candidates(inventory) + if args.consumer_evidence: + # Off by default: the per-site scan costs seconds on the full tree, + # and the ranking above answers the cheaper question. Nothing here + # gates anything, so a reviewer opts in when they want the roles. + print() + registry = json.loads((ROOT / REGISTRY_RELATIVE).read_text(encoding="utf-8")) + evidence = collect_consumer_evidence(ROOT, registry, load_sources(ROOT)) + print("\n".join(render_consumer_evidence(evidence, top=args.top))) return 0 if args.output is None: print(content, end="") diff --git a/tests/architecture/test_semantic_consumer_report.py b/tests/architecture/test_semantic_consumer_report.py new file mode 100644 index 0000000000..9cb19ab77b --- /dev/null +++ b/tests/architecture/test_semantic_consumer_report.py @@ -0,0 +1,333 @@ +"""Role classification rules for the advisory consumer evidence report (RFC B5). + +The rules come from the RFC, not from scanner output: a consumer reads or +accepts a value, and interpreter and pass-through are subroles of consumer +rather than a partition of modules. Every fixture here is finite and states +which side of the boundary it sits on -- a genuine read, a forward that changes +nothing, a branch on the value, and an access no name-keyed scan can resolve. + +The negative fixtures matter as much as the positive ones. A report that +classifies everything would be wrong, so these pin what the scan must *not* +claim: a same-prefix identifier is a different slot, a bare mention is not a +read, and sequence indexing is not an unresolved mapping read. +""" + +from __future__ import annotations + +from pathlib import Path +import subprocess + +import pytest + +from loopx.semantics.consumer_report import ( + ANY_VOCABULARY, + CONSUMER_SCAN_ROOTS, + ConsumerEvidence, + collect_consumer_evidence, + render_consumer_evidence, + scan_dynamic_reads, + scan_python_consumers, + slot_name, +) +from loopx.semantics.inventory import SourceFile, load_sources + +VALUES = ("alpha", "beta", "gamma") + + +def _source(text: str, path: str = "loopx/control_plane/probe.py") -> SourceFile: + return SourceFile(path=path, suffix=".py", text=text) + + +def _roles(text: str, *, slot: str = "probe_slot", owners=None, modules=None) -> dict[tuple[str, int], str]: + rows = scan_python_consumers( + _source(text), vocabulary="probe", slot=slot, values=VALUES, owners=owners, modules=modules + ) + return {(row.site.split("::")[1], row.line): row.role for row in rows} + + +def _rows(text: str, *, slot: str = "probe_slot", owners=None, modules=None): + return scan_python_consumers( + _source(text), vocabulary="probe", slot=slot, values=VALUES, owners=owners, modules=modules + ) + + +# --- positive fixtures: one role each ------------------------------------- + + +def test_a_bound_read_that_goes_nowhere_is_a_read() -> None: + """The site reads the slot and neither branches on it nor forwards it.""" + rows = _rows('def consume(payload):\n current = payload["probe_slot"]\n return None\n') + assert [(row.role, row.anchor) for row in rows] == [("read", "slot_read")] + assert rows[0].site == "loopx/control_plane/probe.py::consume" + assert rows[0].line == 2 + assert "slot_name_keyed" in rows[0].limitation + assert rows[0].blocker is None + + +def test_a_presence_check_is_a_read_not_a_branch_on_the_value() -> None: + """``"slot" in payload`` reads whether the slot is there, not which value.""" + rows = _rows('def consume(payload):\n if "probe_slot" in payload:\n return 1\n return 0\n') + assert [(row.role, row.anchor) for row in rows] == [("read", "slot_presence")] + assert rows[0].observed == () + + +@pytest.mark.parametrize( + "body", + [ + 'def forward(payload):\n return payload["probe_slot"]\n', + 'def forward(payload):\n return {"copied": payload["probe_slot"]}\n', + 'def forward(payload, out):\n out["copied"] = payload["probe_slot"]\n', + 'def forward(payload, sink):\n sink(payload["probe_slot"])\n', + 'def forward(payload):\n return f"got {payload[\'probe_slot\']}"\n', + 'def forward(record):\n return str(record.probe_slot)\n', + 'def forward(payload):\n return payload.get("probe_slot")\n', + ], +) +def test_a_site_that_only_moves_the_value_is_a_pass_through(body: str) -> None: + """Serializing, returning, copying and displaying do not change meaning.""" + rows = _rows(body) + assert [row.role for row in rows] == ["pass_through"], body + assert "forward_target_untraced" in rows[0].limitation + + +def test_an_enum_unwrap_into_a_payload_stays_a_pass_through() -> None: + """``.value`` serializes the member; the climb must not stop at it.""" + rows = _rows('def forward(snapshot):\n return {"probe_slot": snapshot.probe_slot.value}\n') + assert [row.role for row in rows] == ["pass_through"] + + +@pytest.mark.parametrize( + "body", + [ + 'def decide(payload):\n if payload["probe_slot"] == "alpha":\n return 1\n return 0\n', + 'def decide(payload):\n return payload["probe_slot"] in ("alpha", "beta")\n', + 'def decide(payload):\n match payload["probe_slot"]:\n case "alpha":\n return 1\n return 0\n', + 'def decide(payload, table):\n return table[payload["probe_slot"]]\n', + 'def decide(payload):\n current = payload["probe_slot"]\n if current == "alpha":\n return 1\n return 0\n', + ], +) +def test_a_site_that_branches_on_the_value_is_an_interpreter(body: str) -> None: + rows = _rows(body) + assert [row.role for row in rows] == ["interpret"], body + assert "branch_operands_only" in rows[0].limitation + + +def test_the_interpreter_records_only_the_registered_values_it_names() -> None: + """Observed values are a subdomain of the registered domain, never wider.""" + rows = _rows('def decide(payload):\n return payload["probe_slot"] in ("alpha", "not_registered")\n') + assert rows[0].domain == ("alpha", "beta", "gamma") + assert rows[0].observed == ("alpha",) + + +def test_interpreting_outranks_forwarding_when_a_site_does_both() -> None: + """The subroles are not exclusive; the stronger claim is reported.""" + rows = _rows( + 'def decide(payload, sink):\n' + ' current = payload["probe_slot"]\n' + ' sink(current)\n' + ' if current == "beta":\n' + ' return 1\n' + ' return 0\n' + ) + assert [row.role for row in rows] == ["interpret"] + assert rows[0].observed == ("beta",) + + +def test_an_owner_member_comparison_is_an_interpreter_without_the_slot_name() -> None: + """The registered owner class anchors sites that never spell the slot.""" + owner = _source( + 'from enum import Enum\n\n\nclass ProbeKind(str, Enum):\n' + ' ALPHA = "alpha"\n BETA = "beta"\n', + path="loopx/control_plane/owner.py", + ) + consumer = ( + 'from loopx.control_plane.owner import ProbeKind\n\n\n' + 'def decide(decision):\n' + ' if decision.kind is ProbeKind.ALPHA:\n' + ' return 1\n' + ' return 0\n' + ) + rows = scan_python_consumers( + _source(consumer), vocabulary="probe", slot="probe_slot", values=VALUES, + owners={"loopx/control_plane/owner.py::ProbeKind": {"ALPHA": "alpha", "BETA": "beta"}}, + modules={owner.path: owner}, + ) + assert [(row.role, row.anchor, row.observed) for row in rows] == [("interpret", "owner_member", ("alpha",))] + + +# --- unknowns, each with a recorded reason --------------------------------- + + +def test_a_computed_mapping_key_is_unknown_for_every_vocabulary_at_once() -> None: + """No name-keyed scan can prove this site is not a reader of some slot.""" + rows = scan_dynamic_reads(_source('def consume(payload, key):\n return payload.get(key)\n')) + assert [(row.vocabulary, row.role, row.blocker) for row in rows] == [ + (ANY_VOCABULARY, "unknown", "dynamic_key") + ] + assert rows[0].line == 2 + assert "unresolved, not absent" in rows[0].limitation + + +def test_a_reassigned_local_is_unknown_rather_than_guessed() -> None: + rows = _rows( + 'def consume(payload, flag):\n' + ' current = payload["probe_slot"]\n' + ' if flag:\n' + ' current = "beta"\n' + ' return current\n' + ) + assert [(row.role, row.blocker) for row in rows] == [("unknown", "unstable_local")] + + +def test_a_local_that_escapes_into_a_nested_scope_is_unknown() -> None: + rows = _rows( + 'def consume(payload):\n' + ' current = payload["probe_slot"]\n' + '\n' + ' def inner():\n' + ' return current\n' + ' return inner\n' + ) + assert [(row.role, row.blocker) for row in rows] == [("unknown", "nested_scope_escape")] + + +def test_an_unresolved_row_still_carries_its_location_and_domain() -> None: + """An unknown is a visible site, not a dropped one.""" + rows = _rows('def consume(payload, flag):\n current = payload["probe_slot"]\n' + ' current = flag\n return current\n') + assert rows[0].site == "loopx/control_plane/probe.py::consume" + assert rows[0].line == 2 + assert rows[0].domain == ("alpha", "beta", "gamma") + + +# --- negative fixtures: what the scan must not claim ------------------------ + + +def test_a_same_prefix_identifier_is_a_different_slot() -> None: + """``probe_slot_repair`` is its own field, not a use of ``probe_slot``.""" + assert _rows('def consume(payload):\n return payload["probe_slot_repair"]\n') == [] + assert _rows('def consume(record):\n return record.probe_slot_repair\n') == [] + + +def test_sequence_indexing_is_not_an_unresolved_mapping_read() -> None: + """``rows[index]`` and ``payload[key]`` are the same syntax; neither counts. + + Counting computed subscripts would inflate the unknown until it stopped + carrying information, so only unambiguous mapping accessors are counted. + """ + assert scan_dynamic_reads(_source('def consume(rows, index):\n return rows[index]\n')) == [] + + +def test_a_prose_mention_is_never_reported_as_a_read() -> None: + """A docstring carries the token without touching the value.""" + assert _rows('def consume(payload):\n """Handle the probe_slot field."""\n return payload\n') == [] + + +def test_an_unimported_owner_symbol_yields_no_owner_anchor() -> None: + """A locally defined class of the same name is not the registered owner.""" + owner = _source( + 'from enum import Enum\n\n\nclass ProbeKind(str, Enum):\n ALPHA = "alpha"\n', + path="loopx/control_plane/owner.py", + ) + rows = scan_python_consumers( + _source( + 'class ProbeKind:\n ALPHA = "alpha"\n\n\n' + 'def decide(decision):\n return decision.kind is ProbeKind.ALPHA\n' + ), + vocabulary="probe", slot="probe_slot", values=VALUES, + owners={"loopx/control_plane/owner.py::ProbeKind": {"ALPHA": "alpha"}}, + modules={owner.path: owner}, + ) + assert rows == [] + + +def test_the_slot_name_falls_back_to_the_vocabulary_id() -> None: + assert slot_name("probe", {}) == "probe" + assert slot_name("probe", {"literal_scan": {"field": "declared_slot"}}) == "declared_slot" + + +# --- whole-scan shape ------------------------------------------------------ + + +@pytest.fixture +def scan_repo(tmp_path: Path) -> Path: + def write(relative: str, text: str) -> None: + path = tmp_path / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + write( + "loopx/control_plane/decide.py", + 'def decide(payload):\n' + ' if payload["probe"] == "alpha":\n' + ' return 1\n' + ' return 0\n' + '\n' + '\n' + 'def lookup(payload, key):\n' + ' return payload.get(key)\n', + ) + write("loopx/control_plane/notes.py", '"""Mentions probe in prose only."""\n') + write("loopx/control_plane/twin.ts", 'export const PROBE = "probe";\n') + write("loopx/capabilities/out_of_reach.py", 'def decide(payload):\n return payload["probe"]\n') + for argv in (["init", "-q"], ["add", "-A"], ["-c", "user.email=t@e", "-c", "user.name=t", + "commit", "-qm", "fixture"]): + subprocess.run(["git", *argv], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL) + return tmp_path + + +REGISTRY = {"vocabularies": {"probe": {"values": list(VALUES), "owners": {}}}} + + +def _evidence(root: Path) -> ConsumerEvidence: + return collect_consumer_evidence(root, REGISTRY, load_sources(root)) + + +def test_every_row_carries_a_location_a_domain_and_a_limitation(scan_repo: Path) -> None: + evidence = _evidence(scan_repo) + assert evidence.rows + for row in evidence.rows: + assert row.site.count("::") == 1 and row.line >= 1 + assert row.limitation + assert (row.blocker is None) == (row.role != "unknown") + + +def test_the_scan_records_the_commit_it_ran_against(scan_repo: Path) -> None: + evidence = _evidence(scan_repo) + head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=scan_repo, check=True, + stdout=subprocess.PIPE).stdout.decode().strip() + assert evidence.source_sha == head + assert evidence.worktree_dirty is False + (scan_repo / "loopx/control_plane/decide.py").write_text("x = 1\n", encoding="utf-8") + assert _evidence(scan_repo).worktree_dirty is True + + +def test_the_scan_reach_is_code_owned_and_excludes_what_it_did_not_walk(scan_repo: Path) -> None: + """A module outside the roots yields no row, and the reach is reported.""" + evidence = _evidence(scan_repo) + assert CONSUMER_SCAN_ROOTS == ("loopx/control_plane", "loopx/cli_commands") + assert not any("capabilities" in row.site for row in evidence.rows) + assert evidence.scanned_files == 2 and evidence.tracked_files == 4 + + +def test_a_mention_the_grammar_does_not_recognize_is_unknown_not_absent(scan_repo: Path) -> None: + """Otherwise the per-vocabulary tables would read as complete.""" + evidence = _evidence(scan_repo) + blockers = {(row.site, row.blocker) for row in evidence.rows if row.role == "unknown"} + assert ("loopx/control_plane/notes.py::", "mention_without_recognized_anchor") in blockers + assert ("loopx/control_plane/twin.ts::", "typescript_not_walked") in blockers + assert ("loopx/control_plane/decide.py::lookup", "dynamic_key") in blockers + + +def test_the_report_states_both_unknown_shares_and_never_gates(scan_repo: Path) -> None: + evidence = _evidence(scan_repo) + assert evidence.counts()["interpret"] == 1 + assert 0.0 < evidence.unknown_share() < 1.0 + printed = "\n".join(render_consumer_evidence(evidence, top=5)) + assert "advisory" in printed and "not proved data flow" in printed + assert "unknown_share=" in printed and "attributed_unknown_share=" in printed + assert "unknown_reasons: " in printed + + +def test_the_same_tree_reports_the_same_rows(scan_repo: Path) -> None: + assert _evidence(scan_repo).rows == _evidence(scan_repo).rows From 278c94b64b563e1f374c6c83814a1e23a6fac7bb Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:49:02 -0400 Subject: [PATCH 2/2] fix(semantics): cover only the vocabularies that declare a slot B5 is optional in #4447 and gated on concrete slot identity. The scan fell back to the vocabulary id when a registry entry declared no `literal_scan.field`, which analysed 25 of 26 vocabularies against a field name nobody had claimed exists; every row downstream inherited that guess. The fallback is gone. A vocabulary with no declared slot is reported by name under `missing_slot_identity`, counted in the header, and not analysed at all -- coverage is now the 1 of 26 the registry actually declares, matching the `literal_scan_fields:1/1` the drift smoke already reports. Three further claims the evidence did not establish: * A call argument was reported `pass_through`. `Kind(value)`, `int(value)` and `sink(value)` are the same syntax, and none of them show whether the value comes out unchanged; an f-string and a further attribute are conversions the same way, and `.value` was read as an enum unwrap on an object the scan had not established was an enum member. All of these now end the climb as `unknown` with the construct named. `pass_through` means the AST shows the value itself relocated -- returned, stored, placed in a structure -- with nothing applied to it. An observed branch still wins over an unresolved sibling use, because interpreting is the top of the rank. * An owner-member comparison resolved against module-level imports only, so a parameter, a local assignment, a local import or an `except` target that had taken the owner's name over was still credited to the registered owner. Scope shadows now accumulate outwards-in, as `scan_python_production` already did, and a shadowed operand is `unknown`, not the owner it resembles. * The printed row listing dropped every computed-key and unattributable site, so the table read as a complete census of the slot's readers. Both populations are printed now, in their own blocks under the same `--top` budget, since a single location-ordered list would bury 111 classified rows under 602 unattributed ones -- the same concealment spelled differently. Measured on the merged tree: 713 rows over 485 of 1213 tracked sources -- 0 read, 61 interpret, 9 pass_through, 643 unknown, a 90.2% unknown share; of the 111 rows attributed to `effective_action`, 41 are unknown (36.9%). The guessing implementation reported 921 rows at 78.9%. Smaller and less confident is the direction the evidence supports. The cross-vocabulary parse and scope caches go with the narrowing: they existed so 26 vocabularies shared one parse, and one covered vocabulary does not need an unbounded module-level cache keyed on a text hash. Removing them costs nothing measurable (5.9-6.7s over three runs, unchanged). Still advisory: reachable only via `--report --consumer-evidence`, still refusing to run without `--report`, still not referenced by the drift smoke. Refs #4447 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- .../semantic-vocabulary-convergence-v0.md | 96 ++-- ...emantic-vocabulary-convergence-v0.zh-CN.md | 70 ++- loopx/semantics/consumer_report.py | 451 ++++++++++-------- .../test_semantic_consumer_report.py | 182 ++++--- 4 files changed, 483 insertions(+), 316 deletions(-) diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md index 398399c70e..a8aa25f72e 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md @@ -824,7 +824,7 @@ on the next full-tree scan; genuine shared-contract changes still need review. | Measurement covers both carrier shapes and filters local naming | `uv run --extra test python -m pytest tests/architecture/test_semantic_inventory.py` | pass, including the collision and module-local-convention fixtures | Rules come from this RFC, not from scanner output | | 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 | -| Consumer roles are reported per site, with the unknown stated (B5) | `uv run python scripts/generate_semantic_inventory.py --report --consumer-evidence` and `uv run --extra test python -m pytest tests/architecture/test_semantic_consumer_report.py` | the report prints read/interpret/pass-through/unknown counts, both unknown shares, and every unknown reason with its site count; the tests pass | Advisory only: syntactic use over a two-root scan reach, never data flow and never a gate | +| Consumer roles are reported per site for vocabularies that declare a slot, with the unknown stated (B5, optional) | `uv run python scripts/generate_semantic_inventory.py --report --consumer-evidence` and `uv run --extra test python -m pytest tests/architecture/test_semantic_consumer_report.py` | the report names its coverage and the vocabularies it refused to analyse, then prints read/interpret/pass-through/unknown counts, both unknown shares, and every unknown reason with its site count; the tests pass | Advisory only, and optional in #4447: syntactic use over a two-root scan reach, never data flow and never a gate. Coverage is the 1 of 26 registered vocabularies that declares `literal_scan.field`; the other 25 are reported as `missing_slot_identity` and are not analysed | | 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 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 | @@ -1124,10 +1124,11 @@ introduce a competing target state. ## Appendix A: Execution ledger (non-normative) -### 2026-09-17 — B5: consumer roles reported per site, with the unknown stated +### 2026-09-17 — B5: consumer roles for the vocabularies that declare a slot -Non-normative; advisory evidence only. No check changes its pass/fail result on -the current tree, and nothing added here gates a merge. +Non-normative; advisory evidence only, and B5 is an optional item of #4447. No +check changes its pass/fail result on the current tree, and nothing added here +gates a merge. - `consumer_ranking` counts modules that *mention* a symbol. Section 5 already says that number "does not classify roles or prove data flow", so B5 adds @@ -1135,36 +1136,66 @@ the current tree, and nothing added here gates a merge. consuming site as `read`, `interpret`, `pass_through` or `unknown` and carries per row the location (`module::symbol` and line), the source SHA the scan ran against, the value domain, and the limitation that applies to that row. +- **Coverage is the registry's declaration, not a guess, and it is 1 of 26.** + B5 is gated on concrete slot identity, and only `effective_action` declares + `literal_scan.field` — the same `literal_scan_fields:1/1` the drift smoke + already reports. The first implementation fell back to the vocabulary id when + no field was declared, which analysed 25 vocabularies against a field name + nobody had claimed exists and let every downstream row inherit the guess. The + fallback is removed: a vocabulary with no declared slot is reported by name + under `missing_slot_identity`, counted in the header, and not analysed at all + — not partially, and not by owner class alone. - Anchors are identities the registry already carries, which is why B2 was the - precondition: the vocabulary's slot name (`literal_scan.field`, else the - vocabulary id) and its registered owner class, bound through the same - one-unrenamed-hop import discipline the producer scanner uses. No module - registers itself as a consumer, and the scan reach is code owned exactly as - `PRODUCER_ROOTS` is, so registry data cannot widen it. -- Measured on `440b002fb` across `loopx/control_plane` and `loopx/cli_commands`, - 484 of 1207 tracked sources: **921 rows — 3 `read`, 141 `interpret`, - 50 `pass_through`, 727 `unknown`, a 78.9% unknown share.** Restricted to the - 319 rows that belong to a single vocabulary, the unknown share is 39.2%. + precondition: the declared slot name and the registered owner class, bound + through the same one-unrenamed-hop import discipline the producer scanner + uses, and only where no nearer binding has taken the owner's name over. The + scan reach is code owned exactly as `PRODUCER_ROOTS` is, so registry data + cannot widen it. +- **A role is claimed only from syntax that establishes it.** A call, an + f-string and a further attribute all end the climb as `unknown` with the + construct named. `Kind(value)` and `sink(value)` are the same syntax, so + neither may be read as proof that the value came out unchanged; the earlier + implementation reported both as `pass_through`, and `.value` as a + meaning-preserving enum unwrap on an object it had not established was an + enum member. `pass_through` now means the AST shows the value itself + relocated — returned, stored, placed in a structure — with nothing applied. + An observed branch still wins over an unresolved sibling use, because + interpreting is the top of the rank and nothing stronger could be hidden. +- Measured on `d8e7af141` across `loopx/control_plane` and `loopx/cli_commands`, + 485 of 1213 tracked sources: **713 rows — 0 `read`, 61 `interpret`, + 9 `pass_through`, 643 `unknown`, a 90.2% unknown share.** Restricted to the + 111 rows that belong to `effective_action`, the unknown share is 36.9%. + Against the guessing implementation this is 921 rows down to 713 and a 78.9% + unknown share up to 90.2%: the report became smaller and less confident, which + is the direction the evidence supports. - The unknown is most of the measurement, not a residue to tidy away. 602 sites - read a mapping under a computed key and are therefore unresolved for every - vocabulary at once; 76 modules spell a slot with no recognized anchor; - 46 tracked TypeScript sources carrying a slot are not walked because there is - no TypeScript parser on this path; 3 Python sites are an unstable local or an - unclassified context. Each is a row with a location and a recorded reason, - following the pattern B2 set for unresolved producer sites. -- What the report establishes is **syntactic use, not data flow**. A row says - this location performs a recognized read of this slot and that the syntax - around the read branches on the value or forwards it. It does not prove the - value came from a registered producer, that the branch is reachable, or that - a vocabulary with no rows has no reader — the computed-key population is - precisely why that last claim cannot be made from a name-keyed scan. + read a mapping under a computed key and are therefore unresolved for the + covered vocabulary; 18 modules spell the slot with no recognized anchor; + 13 sites hand the value to a callee this scan does not follow; 9 tracked + TypeScript sources carrying the slot are not walked because there is no + TypeScript parser on this path; 1 is an unstable local. Each is a row with a + location and a recorded reason, following the pattern B2 set for unresolved + producer sites, and neither population is filtered out of the printed listing + any more: the classified rows and the unattributable ones now get the same + `--top` budget in their own blocks, because a table that showed only what the + grammar resolved read as a complete census of the slot's readers -- and a + single location-ordered list would have buried the classified rows under the + 602, which is the same concealment spelled differently. +- What the report establishes is **syntactic use, not data flow**. It does not + prove the value came from a registered producer, that the branch is reachable, + or that a vocabulary with no rows has no reader — the computed-key population + is precisely why that last claim cannot be made from a name-keyed scan. - Exposed through the existing report surface as - `scripts/generate_semantic_inventory.py --report --consumer-evidence`, opt-in - because it is extra work: the per-site scan costs 3.35-3.65s over three - isolated runs on the full tree, against roughly 217s for the ranking that - `--report` already prints. The drift smoke does not call it, and the only - pull-request path that reaches `--report` is a pytest fixture repository of - three files, so the per-PR cost is unchanged. + `scripts/generate_semantic_inventory.py --report --consumer-evidence`, which + refuses to run without `--report` because it is advisory evidence, not a + check. The per-site scan costs 4.1-4.4s over three runs on the full tree, + against roughly 217s for the ranking that `--report` already prints. The drift + smoke does not call it. +- **What this leaves B5 worth.** One vocabulary, 111 attributed rows, 41 of them + unknown, and no second vocabulary can be covered until an M1/M3 migration + declares a slot for it. The honest reading is that the machinery is ahead of + the registry it reads; the value arrives when the first migration needs it, + not before. - Not addressed here: the reach is two roots rather than the tree; TypeScript is counted but not parsed; and following a local is one hop, so a value moving through two aliases is unknown rather than traced. Widening any of the three @@ -1418,7 +1449,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-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 | B5: report consumer roles per site from registry-anchored AST evidence, and state the unknown share instead of classifying everything | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B5; PR review pending | Register consumers in the registry (rejected: the tracking issue forbids blanket consumer registration, and a declared list is a claim rather than evidence); make the report a merge gate (rejected: F3 is the advisory lane, and a 78.9% unknown share cannot gate anything); report only the sites the grammar resolves (rejected: the per-vocabulary tables would read as complete, so an unrecognized mention and a computed-key read are rows with reasons) | 9, Appendix A, Appendix B | +| 2026-09-17 | B5 (optional): report consumer roles per site only for vocabularies that declare `literal_scan.field`, and state the unknown share instead of classifying everything | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B5; PR review pending | Fall back to the vocabulary id when no slot is declared (rejected on review: it analysed 25 of 26 vocabularies against a field name nobody declared, and every row downstream inherited the guess; they are now named under `missing_slot_identity` and not analysed); register consumers in the registry (rejected: the tracking issue forbids blanket consumer registration, and a declared list is a claim rather than evidence); make the report a merge gate (rejected: F3 is the advisory lane, and a 90.2% unknown share cannot gate anything); report only the sites the grammar resolves (rejected: the tables would read as complete, so an unrecognized mention and a computed-key read are printed rows with reasons) | 9, Appendix A, Appendix B, Appendix C | | 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 | | 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 | @@ -1448,6 +1479,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) | +| E26 | B5 consumer evidence covers 1 of 26 registered vocabularies | `d8e7af141` | `scripts/generate_semantic_inventory.py --report --consumer-evidence`, cross-read against the drift smoke's `literal_scan_fields` coverage | 1 vocabulary declares `literal_scan.field` (`effective_action`) and is analysed; 25 are reported `missing_slot_identity` and are not analysed. 713 rows over 485 of 1213 tracked sources: 0 `read`, 61 `interpret`, 9 `pass_through`, 643 `unknown` (90.2%); of the 111 rows attributed to `effective_action`, 41 are unknown (36.9%). The removed vocabulary-id fallback had reported 921 rows at a 78.9% unknown share | Coverage is a registry property, not a code one: it moves only when a vocabulary declares a slot. Rows are syntactic use over a two-root reach, never data flow, and the scan is advisory — it refuses to run without `--report` | | 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 e5f4f4b87b..7be5b829b0 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md @@ -666,7 +666,7 @@ owner 符号集合的组:`EffectiveAction` 与 `EFFECTIVE_ACTIONS` 是同一 | 度量覆盖两种载体形状并过滤局部命名 | `uv run --extra test python -m pytest tests/architecture/test_semantic_inventory.py` | 通过,含冲突与模块局部约定两组夹具 | 规则来自本 RFC 而非扫描输出 | | 两处 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` | 通过 | 检查镜像、链接、索引 | -| 消费者角色按位点报告,并写明未知量(B5) | `uv run python scripts/generate_semantic_inventory.py --report --consumer-evidence` 与 `uv run --extra test python -m pytest tests/architecture/test_semantic_consumer_report.py` | 报告打印 read/interpret/pass-through/unknown 计数、两个未知占比,以及每种未知原因及其位点数;测试通过 | 仅为参考:在两个根目录的扫描范围内度量语法使用,既不是数据流,也永远不是闸门 | +| 对声明了槽位的词表按位点报告消费者角色,并写明未知量(B5,可选项) | `uv run python scripts/generate_semantic_inventory.py --report --consumer-evidence` 与 `uv run --extra test python -m pytest tests/architecture/test_semantic_consumer_report.py` | 报告先写明自己的覆盖面以及它拒绝分析的词表,再打印 read/interpret/pass-through/unknown 计数、两个未知占比,以及每种未知原因及其位点数;测试通过 | 仅为参考,且在 #4447 中属可选项:在两个根目录的扫描范围内度量语法使用,既不是数据流,也永远不是闸门。覆盖面是 26 个已注册词表中声明了 `literal_scan.field` 的那 1 个;其余 25 个作为 `missing_slot_identity` 上报,不做分析 | | 退休预算按子串而非标识符计数 | 分别以 `in file.text` 与 `\bgoal_boundary\b` 统计 `goal_boundary` | 基线上 35 对 30 个 Python 模块 | 已知边界;M3 的零读者门需要标识符计数,见第 12 节 | | 模块局部约定过滤器是一次代码修改 | 扩宽 `inventory.py` 的 `MODULE_LOCAL_CONVENTION` 并重新生成 | `*_semantic` 预算下降而别处无代码改动 | 已知边界;正则在代码里,扩宽是可评审的 diff,未过滤总数仍在预算内 | | 无人生产的注册值失败(M0.5) | 在基线上运行生产形式扫描 | 失败并点名 `effective_action` 与 `skip`;删除 `skip` 或列入 `compatibility_only` 后通过 | 第一个预期的 I12 失败;只被比较的值不算已携带 | @@ -907,10 +907,10 @@ PR review 保留这些层级。普通改动记录检查范围和理由,无共 ## 附录 A:执行账本(非规范) -### 2026-09-17 — B5:按位点报告消费者角色,并写明未知量 +### 2026-09-17 — B5:对声明了槽位的词表报告消费者角色 -非规范性;仅为参考证据。当前源码树上没有任何检查的通过/失败结果改变, -本次新增的内容也不闸住任何合并。 +非规范性;仅为参考证据,且 B5 是 #4447 的可选项。当前源码树上没有任何检查的 +通过/失败结果改变,本次新增的内容也不闸住任何合并。 - `consumer_ranking` 统计的是**提及**某符号的模块数。第 5 节早已写明这个数字 “既不分类角色,也不证明数据流”,因此 B5 新增 @@ -918,28 +918,47 @@ PR review 保留这些层级。普通改动记录检查范围和理由,无共 `read`、`interpret`、`pass_through` 或 `unknown`,并逐行携带位置 (`module::symbol` 与行号)、扫描所依据的源码 SHA、涉及的值域,以及适用于该行的 边界说明。 -- 锚点全部来自注册表已有的身份,这正是以 B2 为前置条件的原因:词表的槽位名 - (`literal_scan.field`,没有则用词表 id),以及它注册的 owner 类——后者沿用 - producer 扫描器同一套“一跳、未改名”的导入绑定纪律。没有任何模块需要把自己登记为 - 消费者,扫描范围与 `PRODUCER_ROOTS` 一样由代码所有,注册表数据无法扒宽它。 -- 在 `440b002fb` 上、跨 `loopx/control_plane` 与 `loopx/cli_commands`、 - 1207 个已跟踪源文件中的 484 个上实测:**921 行——`read` 3 行、`interpret` 141 行、 - `pass_through` 50 行、`unknown` 727 行,未知占比 78.9%。** 若只看归属于单一词表的 - 319 行,未知占比为 39.2%。 +- **覆盖面由注册表声明决定,不靠猜测,而它是 26 中的 1。** B5 以具体的槽位身份为 + 前置条件,而只有 `effective_action` 声明了 `literal_scan.field`——与 drift smoke + 已经上报的 `literal_scan_fields:1/1` 是同一个事实。初版实现在没有声明字段时回退到 + 词表 id,于是把 25 个词表按一个谁也没声明过存在的字段名去分析,下游每一行都继承了 + 这个猜测。该回退已删除:没有声明槽位的词表按名字列在 `missing_slot_identity` 下、 + 计入表头,并且完全不做分析——不做部分分析,也不单靠 owner 类分析。 +- 锚点全部来自注册表已有的身份,这正是以 B2 为前置条件的原因:已声明的槽位名,以及 + 它注册的 owner 类——后者沿用 producer 扫描器同一套“一跳、未改名”的导入绑定纪律, + 且只在没有更近的绑定接管该名字时成立。扫描范围与 `PRODUCER_ROOTS` 一样由代码所有, + 注册表数据无法扒宽它。 +- **角色只从能确立它的语法中得出。** 调用、f-string 与再一层属性访问都会终止向上 + 攀爬,记为 `unknown` 并写明是哪种构造。`Kind(value)` 与 `sink(value)` 是同一种语法, + 因此都不能当作“值原样出来了”的证据;先前的实现把两者都报成 `pass_through`,还把 + `.value` 当成保义的枚举拆包——而它并未确立那个对象是枚举成员。现在 + `pass_through` 的含义是 AST 显示该值本身被搬运:被返回、被存入、被放进结构,且 + 路上没有施加任何函数。已观察到的分支仍然压过未解析的同级用法,因为解释是等级的 + 顶端,不可能有更强的东西被藏住。 +- 在 `d8e7af141` 上、跨 `loopx/control_plane` 与 `loopx/cli_commands`、 + 1213 个已跟踪源文件中的 485 个上实测:**713 行——`read` 0 行、`interpret` 61 行、 + `pass_through` 9 行、`unknown` 643 行,未知占比 90.2%。** 若只看归属 + `effective_action` 的 111 行,未知占比为 36.9%。相对带猜测的实现,这是 921 行降到 + 713 行、未知占比从 78.9% 升到 90.2%:报告变小了、也变得更不自信,而这正是证据 + 支持的方向。 - 未知量是这次度量的主体,不是待清扫的残渣。602 个位点以计算出的键读取映射, - 因而对每个词表都同时未解析;76 个模块写出了槽位名却没有可识别的锚点; - 46 个携带槽位名的已跟踪 TypeScript 源文件未被遍历,因为这条路径上没有 TypeScript - 解析器;3 个 Python 位点是不稳定局部变量或未分类上下文。每一条都是带位置与 - 记录原因的行,沿用 B2 为未解析 producer 位点确立的做法。 -- 该报告确立的是**语法使用,而非数据流**。一行的含义是:此位置对该槽位执行了一次 - 可识别的读取,且读取周围的语法对该值做了分支或做了转发。它不证明该值来自已注册的 - producer,不证明该分支可达,也不证明没有行的词表就没有读者——计算键那一群位点, - 恰恰就是按名字归组的扫描无法作出最后这个论断的原因。 + 因而对被覆盖的词表未解析;18 个模块写出了槽位名却没有可识别的锚点;13 个位点把值 + 交给了本扫描不跟进的被调方;9 个携带槽位名的已跟踪 TypeScript 源文件未被遍历, + 因为这条路径上没有 TypeScript 解析器;1 个是不稳定局部变量。每一条都是带位置与 + 记录原因的行,沿用 B2 为未解析 producer 位点确立的做法,且两类行都不再被过滤出打印 + 清单:有归属的行与无归属的行各自成块、共用同一个 `--top` 配额。只展示语法解析得出 + 的那些行,读起来就像是该槽位读者的一份完整普查;而把两类行合成一份按位置排序的清单, + 又会让那 602 行把有归属的行压下去——那是同一种遮蔽的另一种写法。 +- 该报告确立的是**语法使用,而非数据流**。它不证明该值来自已注册的 producer, + 不证明该分支可达,也不证明没有行的词表就没有读者——计算键那一群位点,恰恰就是按 + 名字归组的扫描无法作出最后这个论断的原因。 - 通过既有报告表面暴露为 - `scripts/generate_semantic_inventory.py --report --consumer-evidence`,采用显式开启: - 它是额外工作——全树上按位点扫描在三次独立运行中耗时 3.35–3.65 秒,而 `--report` - 本已打印的排名约需 217 秒。drift smoke 不会调用它,PR 路径上唯一触及 `--report` - 的是一个三文件的 pytest 夹具仓库,因此每个 PR 的开销不变。 + `scripts/generate_semantic_inventory.py --report --consumer-evidence`,缺少 + `--report` 时它会拒绝运行,因为它是参考证据而不是检查。全树上按位点扫描三次运行 + 耗时 4.1–4.4 秒,而 `--report` 本已打印的排名约需 217 秒。drift smoke 不会调用它。 +- **这让 B5 还剩多少价值。** 一个词表、111 行有归属的行、其中 41 行未知,并且在某次 + M1/M3 迁移为另一个词表声明槽位之前,覆盖面无法增加。诚实的读法是:这套机制跑在了 + 它所读的注册表前面;价值要等第一次迁移需要它时才出现,而不是现在。 - 本次未处理:扫描范围是两个根目录而非整棵树;TypeScript 只计数、不解析; 跟随局部变量只走一跳,因此经过两次别名传递的值是未知而非被追踪。扒宽这三者中的 任何一个,都是自带风险的另一个变更。 @@ -1145,7 +1164,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-17 | B5:以注册表锚定的 AST 证据按位点报告消费者角色,并写明未知占比,而不是把一切都分类 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B5;PR 评审待完成 | 在注册表中登记消费者(否决:跟踪 issue 明令禁止全量消费者登记,且一份声明清单是主张而非证据);把该报告做成合并闸门(否决:F3 属于参考层级,78.9% 的未知占比也不足以闸住任何东西);只报告语法能解析的位点(否决:这会让各词表的表格读起来像是完整的,因此未识别的提及与计算键读取都作为带原因的行保留) | 9、附录 A、附录 B | +| 2026-09-17 | B5(可选项):只对声明了 `literal_scan.field` 的词表按位点报告消费者角色,并写明未知占比,而不是把一切都分类 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B5;PR 评审待完成 | 没有声明槽位时回退到词表 id(评审中否决:这把 26 个词表中的 25 个按一个谁也没声明过的字段名去分析,下游每一行都继承了该猜测;它们现在列在 `missing_slot_identity` 下,不做分析);在注册表中登记消费者(否决:跟踪 issue 明令禁止全量消费者登记,且一份声明清单是主张而非证据);把该报告做成合并闸门(否决:F3 属于参考层级,90.2% 的未知占比也不足以闸住任何东西);只报告语法能解析的位点(否决:这会让表格读起来像是完整的,因此未识别的提及与计算键读取都作为带原因的行打印出来) | 9、附录 A、附录 B、附录 C | | 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 | | 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 | @@ -1175,6 +1194,7 @@ PR review 保留这些层级。普通改动记录检查范围和理由,无共 | 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) | +| E26 | B5 消费者证据覆盖 26 个已注册词表中的 1 个 | `d8e7af141` | `scripts/generate_semantic_inventory.py --report --consumer-evidence`,并与 drift smoke 的 `literal_scan_fields` 覆盖面互相印证 | 1 个词表声明了 `literal_scan.field`(`effective_action`)并被分析;25 个作为 `missing_slot_identity` 上报、不做分析。在 1213 个已跟踪源文件中的 485 个上共 713 行:`read` 0、`interpret` 61、`pass_through` 9、`unknown` 643(90.2%);归属 `effective_action` 的 111 行中有 41 行未知(36.9%)。被删除的“回退到词表 id”实现当时报出 921 行、未知占比 78.9% | 覆盖面是注册表的性质而非代码的性质:只有当某个词表声明了槽位时它才会变化。各行是两个根目录范围内的语法使用,不是数据流;该扫描仅为参考——缺少 `--report` 时会拒绝运行 | | E13 | 冲突预算主要在度量局部命名 | `1dc6ad8d8` | 对 `conflicting_values` 与 `same_runtime_forks` 名字应用 `MODULE_LOCAL_CONVENTION` | 18 个冲突中 16 个、25 个分叉中 7 个是模块局部约定;语义子集分别为 2 与 18 | 分类是名字模式,已在扫描器中说明并由夹具测试钉住 | ## 附录 D:被否决或取代的方案 diff --git a/loopx/semantics/consumer_report.py b/loopx/semantics/consumer_report.py index d9c5c0a6a0..cf6a5fcc90 100644 --- a/loopx/semantics/consumer_report.py +++ b/loopx/semantics/consumer_report.py @@ -1,65 +1,67 @@ -"""Advisory consumer evidence for a registered vocabulary (RFC B5). - -The existing ``consumer_ranking`` counts modules that *mention* a symbol. That -number orders retirement work, but it classifies no role and proves no data -flow: a prompt string, a comment and a dispatch table all count the same. This -module answers the next question with bounded AST evidence -- for one registered -vocabulary, which sites **read** its value, which only **pass it through**, which -**interpret** it by branching, and which stay **unknown** with a recorded reason. - -**What this measures is syntactic use, not data flow.** Nothing here traces a -value from a producer to a consumer. A row says "this location performs a -recognized read of this vocabulary's slot, and the recognized syntax around that -read forwards it / branches on it / does neither". It does not say the value -actually observed at runtime came from a registered producer, nor that the -branch is reachable. Data-flow proof would need interprocedural analysis this -module deliberately does not attempt, and the ``limitation`` column on every row -says which gap applies to that row rather than leaving the reader to guess. - -Roles follow the RFC hierarchy, where interpreter and pass-through are consumer -subroles rather than a partition of whole modules. A transformer that reads one -vocabulary and emits another is an interpreter *of the vocabulary it reads*; -the same module can be a plain consumer of a second vocabulary in the next -function. Rows are therefore per site and per vocabulary, never per module, and -a site carrying both a branch and a forward is reported as ``interpret``, -the stronger of the two claims. - -Anchors come from identities the registry already carries -- the B2 producer and -slot work is the precondition -- so no module has to register itself as a -consumer and no new blanket registration exists: - -* **slot read** -- a literal-key read of the vocabulary's slot name, which is - ``literal_scan.field`` when the registry declares one and otherwise the - vocabulary id: ``payload["example_slot"]``, ``payload.get("example_slot")``, +"""Advisory consumer evidence for vocabularies that declare a slot (RFC B5). + +``consumer_ranking`` counts modules that *mention* a symbol: a prompt string, a +comment and a dispatch table all count the same. This module answers the next +question with bounded AST evidence -- for a vocabulary that declares where its +value travels, which sites **read** it, which only **pass it through**, which +**interpret** it by branching, and which stay **unknown** with a reason. + +**Coverage is the registry's declaration, never a guess.** A vocabulary is +analysed only when it declares ``literal_scan.field``, the slot its value +travels under. Without one there is no slot identity to anchor a read, and +assuming the vocabulary id doubles as a field name would invent the anchor every +row rests on. Those vocabularies are reported by name under +``missing_slot_identity`` and are not analysed at all -- not partially, not by +owner class alone. The report therefore covers whatever subset has done the B2 +slot work, and states how large the rest is. + +**What this measures is syntactic use, not data flow.** A row says "this +location performs a recognized read of this slot, and the syntax around it +branches on the value / relocates it / does neither". It does not say the value +came from a registered producer or that the branch is reachable. The +``limitation`` column on every row names the gap that applies to that row. + +Interpreter and pass-through are consumer subroles, not a partition of modules: +rows are per site and per vocabulary. Anchors are identities the registry +already carries, so no module registers itself as a consumer: + +* **slot read** -- a literal-key read of the declared slot: + ``payload["example_slot"]``, ``payload.get("example_slot")``, ``record.example_slot``, ``"example_slot" in payload``. * **owner member** -- a comparison or ``match`` case against a member of the - registered owner class, resolved through the same one-unrenamed-hop import - discipline the producer scanner uses. The operand on the other side is being - interpreted even when the slot name never appears. - -Four limits are part of the metric rather than caveats beside it: - -* **The slot is keyed by name.** A carrier of the same vocabulary under a - different field name is not found, and an unrelated field that happens to - share the name is misattributed. Every slot-read row carries that limitation. + registered owner class, bound through the same one-unrenamed-hop import + discipline the producer scanner uses, and only where no nearer binding has + taken that name over. + +A role is claimed only from syntax that establishes it. The climb from a read to +its use passes through constructs that relocate or select the value without +applying anything to it, and stops at the first construct it cannot resolve. A +call, an f-string and a further attribute all stop it: none of them show whether +the value is forwarded, converted or branched on inside. Those sites are +``unknown`` with the construct named, because "it went into ``f()``" is not +evidence that it came out unchanged. + +Five limits are part of the metric rather than caveats beside it: + +* **Only declared slots are covered**, as above. +* **The slot is keyed by name.** The same vocabulary under a different field + name is not found; an unrelated field of the same name is misattributed. * **A computed mapping key is unknown, not absent.** ``payload.get(name)`` may read any slot, so no name-keyed scan can prove a vocabulary has no reader. - Those sites are reported as ``unknown`` rows against every vocabulary at once. - Computed *subscripts* are deliberately excluded: ``rows[index]`` and - ``payload[key]`` are the same syntax, and counting sequence indexing would - inflate the unknown until it stopped carrying information. -* **Following a local is one hop.** A read bound to a single-assignment, - unshadowed local is classified by that local's uses. A parameter, a reassigned - name, a name that escapes into a nested scope, or a second alias hop is - ``unknown`` with the reason recorded, never a silent drop. -* **TypeScript is not walked.** There is no TypeScript AST here, so a tracked - ``.ts`` file carrying the slot token is reported as one ``unknown`` row rather - than omitted from the reach. - -Nothing in this module gates anything. It is advisory evidence printed on -demand by ``scripts/generate_semantic_inventory.py --report``, matching F3's -``advisory`` lane in the formal model, and it must not become a merge gate -without the RFC decision that would license one. + Those sites are ``unknown`` against every covered vocabulary at once. Computed + *subscripts* are excluded: ``rows[index]`` and ``payload[key]`` are the same + syntax, and counting sequence indexing would inflate the unknown until it + stopped carrying information. +* **Following a local is one hop.** A parameter, a reassigned name, a name that + escapes into a nested scope, or a second alias hop is ``unknown`` with the + reason recorded, never a silent drop. +* **TypeScript is not walked.** A tracked ``.ts`` file carrying the slot token + is one ``unknown`` row rather than an omission from the reach. + +Nothing here gates anything. It is advisory evidence printed on demand by +``scripts/generate_semantic_inventory.py --report --consumer-evidence``, +matching F3's ``advisory`` lane, and it must not become a merge gate without the +RFC decision that would license one. """ from __future__ import annotations @@ -95,13 +97,13 @@ # evidence of an unresolved read rather than an indexing false positive. _MAPPING_READS = frozenset({'get', 'pop'}) -# Wildcard vocabulary id for a site that may read any registered slot. +# Wildcard vocabulary id for a site that may read any covered slot. ANY_VOCABULARY = '*' _LIMITATIONS = { 'read': 'slot_name_keyed: a recognized read of this slot name; no proof the value came from a registered producer', 'interpret': 'branch_operands_only: the compared literals are syntactic; unmatched values take default paths this scan does not enumerate', - 'pass_through': 'forward_target_untraced: the receiving site is not followed, so the value is not proved to stay in the domain', + 'pass_through': 'relocated_unchanged: the AST shows the value moved, not what the receiving site does with it', 'unknown': 'no_classification: recorded so the site stays visible; it is not evidence of absence', } @@ -123,13 +125,20 @@ class ConsumerSite: @dataclass(frozen=True) class ConsumerEvidence: - """A whole scan, with the tree it ran against and the reach it covered.""" + """A whole scan: the tree it ran against, the reach and the coverage. + + ``missing_slot_identity`` is the registered vocabularies this scan refused + to analyse because they declare no slot. It is part of the result, not a + footnote: the rows below describe ``covered`` only. + """ source_sha: str worktree_dirty: bool scanned_files: int tracked_files: int skipped_typescript: int + covered: tuple[str, ...] + missing_slot_identity: tuple[str, ...] rows: tuple[ConsumerSite, ...] def counts(self) -> dict[str, int]: @@ -162,9 +171,15 @@ def git(*arguments: str) -> str: return sha, dirty -def slot_name(name: str, vocabulary: Mapping[str, Any]) -> str: - """The payload key this vocabulary travels under, from registry identity.""" - return vocabulary.get('literal_scan', {}).get('field') or name +def declared_slot(vocabulary: Mapping[str, Any]) -> str | None: + """The payload key the registry says this vocabulary travels under. + + ``None`` when the registry declares none. There is deliberately no fallback + to the vocabulary id: an id is a name for a concept, not evidence that a + field of that name carries it, and every row this module emits for a slot + would inherit that assumption. + """ + return (vocabulary.get('literal_scan') or {}).get('field') or None def _in_reach(path: str) -> bool: @@ -185,25 +200,6 @@ def _owner_members(vocabulary: Mapping[str, Any], by_path: Mapping[str, SourceFi return {} -_TREES: dict[tuple[str, int], ast.Module] = {} - - -def _parsed(source: SourceFile) -> ast.Module: - """One parse per source text, reused across every registered vocabulary. - - Keyed by path and text hash, so an edited file is a different key and can - never be classified from a stale tree. - """ - key = (source.path, hash(source.text)) - tree = _TREES.get(key) - if tree is None: - tree = _TREES[key] = ast.parse(source.text, filename=source.path) - return tree - - -_SCOPES: dict[tuple[str, int], list[_Scope]] = {} - - def _parents(nodes: Iterable[ast.AST]) -> dict[int, ast.AST]: table: dict[int, ast.AST] = {} for node in nodes: @@ -220,6 +216,7 @@ class _Scope: parents: dict[int, ast.AST] assigned: Counter parameters: set[str] + shadows: set[str] def _scopes(tree: ast.Module) -> list[_Scope]: @@ -227,11 +224,16 @@ def _scopes(tree: ast.Module) -> list[_Scope]: Nested functions, classes and lambdas are separate scopes; their bodies are kept so a name that escapes into one can be detected rather than mistaken - for an unused local. + for an unused local. Each scope also records the names bound closer than the + module's imports -- parameters, assignments, local imports, nested + definitions, ``except`` targets, deletions -- so a module-level owner + binding is not credited to a site where a nearer name has taken it over. + Shadows accumulate outwards-in, because a nested scope also sees whatever + its enclosing scope rebound. """ scopes: list[_Scope] = [] - def walk(body: list[ast.stmt], name: str, parameters: set[str]) -> None: + def walk(body: list[ast.stmt], name: str, parameters: set[str], inherited: set[str]) -> None: nodes: list[ast.AST] = [] nested: list[ast.AST] = [] @@ -246,7 +248,16 @@ def collect(node: ast.AST) -> None: for statement in body: collect(statement) assigned = Counter(n.id for n in nodes if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store)) - scopes.append(_Scope(name, nodes, nested, _parents(nodes), assigned, parameters)) + shadows = set(inherited) | set(assigned) | set(parameters) + shadows |= {n.name for n in nodes if isinstance(n, ast.ExceptHandler) and n.name} + shadows |= {n.id for n in nodes if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Del)} + if name != '': + # At module scope an import and a definition *are* the binding the + # owner resolution already reads; deeper down they displace it. + shadows |= {alias.asname or alias.name.split('.')[0] + for n in nodes if isinstance(n, (ast.Import, ast.ImportFrom)) for alias in n.names} + shadows |= {child.name for child in nested if not isinstance(child, ast.Lambda)} + scopes.append(_Scope(name, nodes, nested, _parents(nodes), assigned, parameters, shadows)) for child in nested: if isinstance(child, ast.Lambda): continue @@ -256,17 +267,9 @@ def collect(node: ast.AST) -> None: arguments = child.args params = {a.arg for a in (*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs)} params.update(a.arg for a in (arguments.vararg, arguments.kwarg) if a) - walk(child.body, inner, params) - - walk(tree.body, '', set()) - return scopes + walk(child.body, inner, params, shadows) - -def _module_scopes(source: SourceFile) -> list[_Scope]: - key = (source.path, hash(source.text)) - scopes = _SCOPES.get(key) - if scopes is None: - scopes = _SCOPES[key] = _scopes(_parsed(source)) + walk(tree.body, '', set(), set()) return scopes @@ -282,79 +285,77 @@ def _literal_strings(node: ast.AST) -> set[str]: return set() -def _classify_use(node: ast.AST, scope: _Scope) -> tuple[str | None, set[str], str | None]: - """Walk syntactic parents of one use; return role, named literals, local. +# Constructs the climb passes through: they move or select the value without +# applying anything to it, so what happens above them still describes this read. +_RELOCATING = (ast.Tuple, ast.List, ast.Set, ast.Starred, ast.keyword, ast.BoolOp, ast.IfExp, ast.Await) + + +def _classify_use(node: ast.AST, scope: _Scope) -> tuple[str | None, set[str], str | None, str | None]: + """Walk syntactic parents of one use; return role, literals, local, blocker. + + A ``None`` role means the climb reached something this scan cannot resolve, + and ``blocker`` names it. The third element is the plain local name the + climb ended at, if any, so the caller can spend its single hop on that + name's own uses; a local hop is available, so it carries no blocker. - A ``None`` role means the surrounding syntax is outside the recognized - grammar. The third element is the plain local name the climb ended at, if - any, so the caller can spend its single hop on that name's own uses. + Every role returned here is claimed from syntax that establishes it. + ``pass_through`` in particular means the AST shows the value itself placed + somewhere else, with nothing applied to it on the way. """ observed: set[str] = set() - role: str | None = None current = node for _ in range(12): # A finite climb; a deeper nesting stays unclassified. parent = scope.parents.get(id(current)) if parent is None: - break - if isinstance(parent, ast.Attribute) and parent.attr == 'value' and parent.value is current: - # Unwrapping an enum member to its string does not change meaning; - # keep climbing so the row is classified by what consumes the - # string. Any other attribute of the read is a different field and - # ends the climb rather than being attributed to this slot. - current = parent - continue + return None, observed, None, 'unclassified_context' if isinstance(parent, ast.Compare): for operand in (parent.left, *parent.comparators): if operand is not current: observed |= _literal_strings(operand) - role = 'interpret' - break + return 'interpret', observed, None, None if isinstance(parent, ast.Match) and parent.subject is current: for case in parent.cases: observed |= _literal_strings(case.pattern) - role = 'interpret' - break + return 'interpret', observed, None, None if isinstance(parent, (ast.If, ast.While, ast.Assert)) and getattr(parent, 'test', None) is current: - role = 'interpret' - break + return 'interpret', observed, None, None if isinstance(parent, ast.IfExp) and parent.test is current: - role = 'interpret' - break + return 'interpret', observed, None, None if isinstance(parent, ast.Subscript) and parent.slice is current: # The value selects an entry of another mapping: one vocabulary # read as the key into another, which is interpretation. - role = 'interpret' - break + return 'interpret', observed, None, None if isinstance(parent, ast.Return): - role = _stronger(role, 'pass_through') - break + return 'pass_through', observed, None, None if isinstance(parent, ast.Dict) and current in parent.values: - role = _stronger(role, 'pass_through') - break + return 'pass_through', observed, None, None if isinstance(parent, (ast.Assign, ast.AnnAssign)) and parent.value is current: targets = parent.targets if isinstance(parent, ast.Assign) else [parent.target] if any(isinstance(t, (ast.Subscript, ast.Attribute)) for t in targets): - # A store into a payload or an object forwards the value out. - return _stronger(role, 'pass_through'), observed, None + # A store into a payload or an object relocates the value. + return 'pass_through', observed, None, None if len(targets) == 1 and isinstance(targets[0], ast.Name): - return role, observed, targets[0].id - break + return None, observed, targets[0].id, None + return None, observed, None, 'unclassified_context' if isinstance(parent, ast.Call) and current is not parent.func: - role = _stronger(role, 'pass_through') - elif isinstance(parent, (ast.FormattedValue, ast.JoinedStr)): - role = _stronger(role, 'pass_through') - elif isinstance(parent, ast.Expr): - role = _stronger(role, 'read') - break - elif not isinstance(parent, (ast.BoolOp, ast.UnaryOp, ast.Tuple, ast.List, ast.Set, - ast.keyword, ast.Starred, ast.IfExp, ast.Await)): - break + # The callee is not followed, so nothing here separates a forward + # from a conversion or a branch taken inside it. ``Kind(value)`` + # and ``sink(value)`` are the same syntax and must not be read as + # the same claim about the value. + return None, observed, None, 'call_target_untraced' + if isinstance(parent, (ast.FormattedValue, ast.JoinedStr)): + return None, observed, None, 'conversion_untraced' + if isinstance(parent, ast.Attribute): + # A further attribute of the read is a different access this scan + # cannot resolve. ``.value`` is only an enum unwrap when the object + # is an enum member, which nothing here establishes. + return None, observed, None, 'attribute_untraced' + if isinstance(parent, ast.Expr): + return 'read', observed, None, None + if not isinstance(parent, _RELOCATING): + return None, observed, None, 'unclassified_context' current = parent - return role, observed, None - - -def _stronger(current: str | None, candidate: str) -> str: - return candidate if current is None or _ROLE_RANK[candidate] > _ROLE_RANK[current] else current + return None, observed, None, 'climb_depth_exceeded' def _escapes(name: str, scope: _Scope) -> bool: @@ -365,36 +366,42 @@ def _escapes(name: str, scope: _Scope) -> bool: def _classify_anchor(node: ast.AST, scope: _Scope) -> tuple[str, set[str], str | None]: """Classify one anchor expression, spending at most one local hop. - A blocker is returned only when the climb produced no role of its own. A - read that is serialized into an unstable local has already shown a - pass-through; losing that to the local's instability would understate what - was actually observed. + A read bound to a plain local produces no role of its own, so that name's + own uses decide the row. Across them an observed branch wins outright: it + is the top of the rank, so no unresolved sibling use could be hiding + anything stronger. Any weaker summary is withdrawn to unknown once a + sibling use is unresolved, because ``pass_through`` and ``read`` carry an + implicit *only* that an unwalked use could falsify. """ - role, observed, name = _classify_use(node, scope) - - def settle(blocker: str) -> tuple[str, set[str], str | None]: - return (role, observed, None) if role is not None else ('unknown', observed, blocker) - + role, observed, name, blocker = _classify_use(node, scope) if name is None: - return settle('unclassified_context') + if role is not None: + return role, observed, None + return 'unknown', observed, blocker or 'unclassified_context' if name in scope.parameters or scope.assigned[name] != 1: - return settle('unstable_local') + return 'unknown', observed, 'unstable_local' if _escapes(name, scope): - return settle('nested_scope_escape') + return 'unknown', observed, 'nested_scope_escape' uses = [n for n in scope.nodes if isinstance(n, ast.Name) and n.id == name and isinstance(n.ctx, ast.Load)] if not uses: # Bound and never read again: a real read, and nothing more than that. - return _stronger(role, 'read'), observed, None - resolved = role + return 'read', observed, None + resolved: list[str] = [] + unresolved: str | None = None for use in uses: - used_role, named, next_name = _classify_use(use, scope) + used_role, named, next_name, used_blocker = _classify_use(use, scope) observed |= named if used_role is None: # A second alias hop would be needed; one hop is the stated bound. - return settle('alias_chain' if next_name else 'unclassified_context') - resolved = _stronger(resolved, used_role) - return resolved or 'read', observed, None + unresolved = unresolved or ('alias_chain' if next_name else used_blocker) + continue + resolved.append(used_role) + if 'interpret' in resolved: + return 'interpret', observed, None + if unresolved is not None: + return 'unknown', observed, unresolved + return max(resolved, key=_ROLE_RANK.__getitem__), observed, None def _slot_anchors(scope: _Scope, slot: str) -> list[ast.AST]: @@ -420,38 +427,54 @@ def _membership_anchors(scope: _Scope, slot: str) -> list[ast.AST]: and isinstance(node.left, ast.Constant) and node.left.value == slot] -def _owner_anchors(scope: _Scope, members: Mapping[str, str], bound: set[str]) -> list[tuple[ast.AST, set[str]]]: - """Comparisons and match cases naming a member of the bound owner class.""" - def member_values(node: ast.AST) -> set[str]: +def _owner_anchors(scope: _Scope, members: Mapping[str, str], + bound: set[str]) -> list[tuple[ast.AST, set[str], str | None]]: + """Comparisons and match cases naming a member of the bound owner class. + + A site qualifies only while the owner's name still means the owner *here*. + Where a parameter, a local assignment, a local import, a nested definition + or an ``except`` target has taken the name over, the operand belongs to + whatever that closer binding is, so the site is reported unknown rather than + credited to the registered owner it merely resembles. + """ + live = bound - scope.shadows + taken = bound & scope.shadows + + def member_values(node: ast.AST, names: set[str]) -> set[str]: if isinstance(node, ast.Attribute) and node.attr == 'value': - return member_values(node.value) + return member_values(node.value, names) if (isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) - and node.value.id in bound and node.attr in members): + and node.value.id in names and node.attr in members): return {members[node.attr]} if isinstance(node, (ast.Tuple, ast.List, ast.Set)): - return set().union(*(member_values(item) for item in node.elts), set()) + return set().union(*(member_values(item, names) for item in node.elts), set()) if isinstance(node, ast.MatchValue): - return member_values(node.value) + return member_values(node.value, names) if isinstance(node, ast.MatchOr): - return set().union(*(member_values(p) for p in node.patterns), set()) + return set().union(*(member_values(p, names) for p in node.patterns), set()) return set() - anchors: list[tuple[ast.AST, set[str]]] = [] + anchors: list[tuple[ast.AST, set[str], str | None]] = [] for node in scope.nodes: if isinstance(node, ast.Compare): + operands = (node.left, *node.comparators) named: set[str] = set() subject: ast.AST | None = None - for operand in (node.left, *node.comparators): - values = member_values(operand) + for operand in operands: + values = member_values(operand, live) named |= values if not values and subject is None: subject = operand if named and subject is not None: - anchors.append((node, named)) + anchors.append((node, named, None)) + elif taken and any(member_values(operand, taken) for operand in operands): + anchors.append((node, set(), 'shadowed_owner_binding')) elif isinstance(node, ast.Match): - named = set().union(*(member_values(case.pattern) for case in node.cases), set()) + named = set().union(*(member_values(case.pattern, live) for case in node.cases), set()) if named: - anchors.append((node, named)) + anchors.append((node, named, None)) + elif taken and any(member_values(case.pattern, taken) for case in node.cases): + anchors.append((node, set(), 'shadowed_owner_binding')) return anchors @@ -469,7 +492,7 @@ def scan_python_consumers( modules: Mapping[str, SourceFile] | None = None, ) -> list[ConsumerSite]: """Classify every recognized consuming site of one vocabulary in one file.""" - tree = _parsed(source) + tree = ast.parse(source.text, filename=source.path) domain = tuple(sorted(values)) owners = owners or {} members: dict[str, str] = {} @@ -480,7 +503,7 @@ def scan_python_consumers( for member_map in bindings.values(): members.update(member_map) rows: set[ConsumerSite] = set() - for scope in _module_scopes(source): + for scope in _scopes(tree): site = f'{source.path}::{scope.name}' def add(node: ast.AST, role: str, anchor: str, observed: Iterable[str], blocker: str | None) -> None: @@ -494,21 +517,21 @@ def add(node: ast.AST, role: str, anchor: str, observed: Iterable[str], blocker: add(node, role, 'slot_read', observed, blocker) for node in _membership_anchors(scope, slot): add(node, 'read', 'slot_presence', set(), None) - for node, named in _owner_anchors(scope, members, bound): - add(node, 'interpret', 'owner_member', named, None) + for node, named, blocker in _owner_anchors(scope, members, bound): + add(node, 'unknown' if blocker else 'interpret', 'owner_member', named, blocker) return sorted(rows, key=lambda row: (row.site, row.line, row.role, row.anchor)) def scan_dynamic_reads(source: SourceFile) -> list[ConsumerSite]: """Sites that read a mapping under a computed key, unattributed by design. - Such a site may read any registered slot, so it is recorded once against + Such a site may read any covered slot, so it is recorded once against ``ANY_VOCABULARY`` rather than duplicated into every vocabulary's table. It is the reason a vocabulary measured at zero readers is measured against a stated unknown instead of declared dead. """ rows: list[ConsumerSite] = [] - for scope in _module_scopes(source): + for scope in _scopes(ast.parse(source.text, filename=source.path)): for node in _dynamic_anchors(scope): rows.append(ConsumerSite( ANY_VOCABULARY, f'{source.path}::{scope.name}', node.lineno, 'unknown', @@ -527,19 +550,24 @@ def _first_line(text: str, token: str) -> int: def collect_consumer_evidence( repo_root: Path, registry: Mapping[str, Any], sources: list[SourceFile], - *, names: Iterable[str] | None = None, ) -> ConsumerEvidence: - """Scan the code-owned reach for every selected registered vocabulary.""" + """Scan the code-owned reach for every vocabulary that declares a slot.""" by_path = {s.path: s for s in sources} selected = [s for s in sources if _in_reach(s.path)] python = [s for s in selected if s.suffix == '.py'] sha, dirty = source_revision(repo_root) - wanted = list(names) if names is not None else list(registry['vocabularies']) + slots: list[tuple[str, str]] = [] + missing: list[str] = [] + for name, vocabulary in registry['vocabularies'].items(): + slot = declared_slot(vocabulary) + if slot is None: + missing.append(name) + else: + slots.append((name, slot)) rows: list[ConsumerSite] = [] skipped = 0 - for name in wanted: + for name, slot in slots: vocabulary = registry['vocabularies'][name] - slot = slot_name(name, vocabulary) owners = _owner_members(vocabulary, by_path) domain = tuple(sorted(vocabulary['values'])) symbols = [owner.split('::')[1] for owner in owners] @@ -567,23 +595,38 @@ def collect_consumer_evidence( rows.append(ConsumerSite( name, f'{source.path}::', 1, 'unknown', 'typescript_source', domain, (), 'typescript_not_walked: unresolved, not absent', 'typescript_not_walked')) - for source in python: - rows.extend(scan_dynamic_reads(source)) + if slots: + for source in python: + rows.extend(scan_dynamic_reads(source)) rows.sort(key=lambda row: (row.vocabulary, row.site, row.line, row.role, row.anchor)) - return ConsumerEvidence(sha, dirty, len(python), len(sources), skipped, tuple(rows)) + return ConsumerEvidence(sha, dirty, len(python), len(sources), skipped, + tuple(name for name, _ in slots), tuple(missing), tuple(rows)) def render_consumer_evidence(evidence: ConsumerEvidence, *, top: int = 25) -> list[str]: """Advisory lines for the report surface; never a pass/fail verdict. + Coverage is printed before the counts, because the counts describe only the + covered vocabularies and a reader who does not know how many were skipped + cannot size what the table leaves out. + Two unknown shares are printed because they answer different questions. The overall share includes the computed-key sites, which belong to no single vocabulary and dominate the count; the attributed share is what is unknown once a row has a vocabulary. Printing only one of them would flatter or inflate the result depending on which. + + Nothing is filtered out of the row listing. Computed-key and otherwise + unattributable sites are printed too, because a table that shows only the + rows the grammar resolved reads as a complete census of the slot's readers + and is not one. They get their own block under the same ``--top`` budget + rather than sharing one, since they outnumber the classified rows several + times over and a single location-ordered list would bury them under it -- + which is the same concealment as the filter, spelled differently. """ counts = evidence.counts() total = len(evidence.rows) + registered = len(evidence.covered) + len(evidence.missing_slot_identity) attributed = [row for row in evidence.rows if row.vocabulary != ANY_VOCABULARY] attributed_unknown = sum(1 for row in attributed if row.role == 'unknown') lines = [ @@ -592,6 +635,11 @@ def render_consumer_evidence(evidence: ConsumerEvidence, *, top: int = 25) -> li f'{" +dirty-worktree" if evidence.worktree_dirty else ""}' f' reach={evidence.scanned_files} scanned Python sources of {evidence.tracked_files} tracked' f' roots={", ".join(CONSUMER_SCAN_ROOTS)}', + f' covered={len(evidence.covered)} of {registered} registered vocabularies' + f' (only those declaring literal_scan.field): ' + (', '.join(evidence.covered) or 'none'), + f' missing_slot_identity={len(evidence.missing_slot_identity)}' + ' not analysed, no declared slot to anchor a read: ' + + (', '.join(evidence.missing_slot_identity) or 'none'), f' rows={total} ' + ' '.join(f'{role}={counts[role]}' for role in _ROLES) + f' unknown_share={evidence.unknown_share():.1%}', f' attributed_rows={len(attributed)} unknown={attributed_unknown}' @@ -599,18 +647,27 @@ def render_consumer_evidence(evidence: ConsumerEvidence, *, top: int = 25) -> li if attributed else ' attributed_rows=0', ' unknown_reasons: ' + (', '.join(f'{k}={v}' for k, v in evidence.blockers().items()) or 'none'), f' {ANY_VOCABULARY} rows read a mapping under a computed key, so they are unresolved ' - 'for every vocabulary at once and are never counted as a reader of one', + 'for every covered vocabulary at once and are never counted as a reader of one', ] per_vocabulary = Counter((row.vocabulary, row.role) for row in evidence.rows) for name in sorted({row.vocabulary for row in evidence.rows}): tally = ' '.join(f'{role}={per_vocabulary.get((name, role), 0)}' for role in _ROLES) lines.append(f' {name}: {tally}') - lines.append(' rows (location | role | anchor | observed values | limitation):') - shown = [row for row in evidence.rows if row.vocabulary != ANY_VOCABULARY][:top] - for row in shown: - observed = ','.join(row.observed) or '-' - lines.append(f' {row.site}:{row.line} | {row.role} | {row.anchor} | {observed} | {row.limitation}') - if len(attributed) > len(shown): - lines.append(f' ... {len(attributed) - len(shown)} further attributed rows and ' - f'{total - len(attributed)} computed-key rows; raise --top to print more') + unattributed = [row for row in evidence.rows if row.vocabulary == ANY_VOCABULARY] + for header, population in ( + (' rows (location | vocabulary | role | anchor | observed values | limitation):', attributed), + (' unattributed rows, same budget so neither population crowds the other out ' + '(a computed key may read any covered slot):', unattributed), + ): + lines.append(header) + shown = sorted(population, key=lambda row: (row.site, row.line, row.vocabulary, row.role))[:top] + for row in shown: + observed = ','.join(row.observed) or '-' + lines.append(f' {row.site}:{row.line} | {row.vocabulary} | {row.role} | {row.anchor} ' + f'| {observed} | {row.limitation}') + if not shown: + lines.append(' none') + elif len(population) > len(shown): + lines.append(f' ... {len(population) - len(shown)} further rows in the same order; ' + 'raise --top to print more') return lines diff --git a/tests/architecture/test_semantic_consumer_report.py b/tests/architecture/test_semantic_consumer_report.py index 9cb19ab77b..23fe7b2d1e 100644 --- a/tests/architecture/test_semantic_consumer_report.py +++ b/tests/architecture/test_semantic_consumer_report.py @@ -3,13 +3,16 @@ The rules come from the RFC, not from scanner output: a consumer reads or accepts a value, and interpreter and pass-through are subroles of consumer rather than a partition of modules. Every fixture here is finite and states -which side of the boundary it sits on -- a genuine read, a forward that changes -nothing, a branch on the value, and an access no name-keyed scan can resolve. +which side of the boundary it sits on -- a genuine read, a relocation that +changes nothing, a branch on the value, and an access no name-keyed scan can +resolve. The negative fixtures matter as much as the positive ones. A report that classifies everything would be wrong, so these pin what the scan must *not* -claim: a same-prefix identifier is a different slot, a bare mention is not a -read, and sequence indexing is not an unresolved mapping read. +claim: a vocabulary with no declared slot is not analysed under its own id, a +call is not a proved forward, a shadowed owner name is not the registered owner, +a same-prefix identifier is a different slot, a bare mention is not a read, and +sequence indexing is not an unresolved mapping read. """ from __future__ import annotations @@ -24,38 +27,51 @@ CONSUMER_SCAN_ROOTS, ConsumerEvidence, collect_consumer_evidence, + declared_slot, render_consumer_evidence, scan_dynamic_reads, scan_python_consumers, - slot_name, ) from loopx.semantics.inventory import SourceFile, load_sources VALUES = ("alpha", "beta", "gamma") +OWNER_PATH = "loopx/control_plane/owner.py" +OWNER_SOURCE = SourceFile( + path=OWNER_PATH, suffix=".py", + text='from enum import Enum\n\n\nclass ProbeKind(str, Enum):\n ALPHA = "alpha"\n BETA = "beta"\n', +) +OWNERS = {f"{OWNER_PATH}::ProbeKind": {"ALPHA": "alpha", "BETA": "beta"}} def _source(text: str, path: str = "loopx/control_plane/probe.py") -> SourceFile: return SourceFile(path=path, suffix=".py", text=text) -def _roles(text: str, *, slot: str = "probe_slot", owners=None, modules=None) -> dict[tuple[str, int], str]: - rows = scan_python_consumers( - _source(text), vocabulary="probe", slot=slot, values=VALUES, owners=owners, modules=modules - ) - return {(row.site.split("::")[1], row.line): row.role for row in rows} - - def _rows(text: str, *, slot: str = "probe_slot", owners=None, modules=None): return scan_python_consumers( _source(text), vocabulary="probe", slot=slot, values=VALUES, owners=owners, modules=modules ) -# --- positive fixtures: one role each ------------------------------------- +def _owner_rows(body: str): + return _rows(body, owners=OWNERS, modules={OWNER_PATH: OWNER_SOURCE}) + + +# --- coverage: only a declared slot is analysed ---------------------------- + + +def test_a_vocabulary_without_a_declared_field_has_no_slot_identity() -> None: + """There is no fallback to the vocabulary id; an id is not a field name.""" + assert declared_slot({}) is None + assert declared_slot({"literal_scan": {}}) is None + assert declared_slot({"literal_scan": {"field": "declared_slot"}}) == "declared_slot" + + +# --- positive fixtures: one proven role each ------------------------------- def test_a_bound_read_that_goes_nowhere_is_a_read() -> None: - """The site reads the slot and neither branches on it nor forwards it.""" + """The site reads the slot and neither branches on it nor relocates it.""" rows = _rows('def consume(payload):\n current = payload["probe_slot"]\n return None\n') assert [(row.role, row.anchor) for row in rows] == [("read", "slot_read")] assert rows[0].site == "loopx/control_plane/probe.py::consume" @@ -77,23 +93,14 @@ def test_a_presence_check_is_a_read_not_a_branch_on_the_value() -> None: 'def forward(payload):\n return payload["probe_slot"]\n', 'def forward(payload):\n return {"copied": payload["probe_slot"]}\n', 'def forward(payload, out):\n out["copied"] = payload["probe_slot"]\n', - 'def forward(payload, sink):\n sink(payload["probe_slot"])\n', - 'def forward(payload):\n return f"got {payload[\'probe_slot\']}"\n', - 'def forward(record):\n return str(record.probe_slot)\n', - 'def forward(payload):\n return payload.get("probe_slot")\n', + 'def forward(record, out):\n out.copied = record.probe_slot\n', ], ) -def test_a_site_that_only_moves_the_value_is_a_pass_through(body: str) -> None: - """Serializing, returning, copying and displaying do not change meaning.""" +def test_the_ast_showing_the_value_moved_unchanged_is_a_pass_through(body: str) -> None: + """Returning, copying and storing relocate the value with nothing applied.""" rows = _rows(body) assert [row.role for row in rows] == ["pass_through"], body - assert "forward_target_untraced" in rows[0].limitation - - -def test_an_enum_unwrap_into_a_payload_stays_a_pass_through() -> None: - """``.value`` serializes the member; the climb must not stop at it.""" - rows = _rows('def forward(snapshot):\n return {"probe_slot": snapshot.probe_slot.value}\n') - assert [row.role for row in rows] == ["pass_through"] + assert "relocated_unchanged" in rows[0].limitation @pytest.mark.parametrize( @@ -119,8 +126,8 @@ def test_the_interpreter_records_only_the_registered_values_it_names() -> None: assert rows[0].observed == ("alpha",) -def test_interpreting_outranks_forwarding_when_a_site_does_both() -> None: - """The subroles are not exclusive; the stronger claim is reported.""" +def test_an_observed_branch_survives_an_unresolved_sibling_use() -> None: + """Interpreting is the top of the rank, so nothing stronger can be hidden.""" rows = _rows( 'def decide(payload, sink):\n' ' current = payload["probe_slot"]\n' @@ -129,35 +136,67 @@ def test_interpreting_outranks_forwarding_when_a_site_does_both() -> None: ' return 1\n' ' return 0\n' ) - assert [row.role for row in rows] == ["interpret"] + assert [(row.role, row.blocker) for row in rows] == [("interpret", None)] assert rows[0].observed == ("beta",) def test_an_owner_member_comparison_is_an_interpreter_without_the_slot_name() -> None: """The registered owner class anchors sites that never spell the slot.""" - owner = _source( - 'from enum import Enum\n\n\nclass ProbeKind(str, Enum):\n' - ' ALPHA = "alpha"\n BETA = "beta"\n', - path="loopx/control_plane/owner.py", - ) - consumer = ( - 'from loopx.control_plane.owner import ProbeKind\n\n\n' + rows = _owner_rows( + f'from {OWNER_PATH[:-3].replace("/", ".")} import ProbeKind\n\n\n' 'def decide(decision):\n' ' if decision.kind is ProbeKind.ALPHA:\n' ' return 1\n' ' return 0\n' ) - rows = scan_python_consumers( - _source(consumer), vocabulary="probe", slot="probe_slot", values=VALUES, - owners={"loopx/control_plane/owner.py::ProbeKind": {"ALPHA": "alpha", "BETA": "beta"}}, - modules={owner.path: owner}, - ) assert [(row.role, row.anchor, row.observed) for row in rows] == [("interpret", "owner_member", ("alpha",))] # --- unknowns, each with a recorded reason --------------------------------- +@pytest.mark.parametrize( + "body, blocker", + [ + # A conversion and an opaque sink are the same syntax; neither shows + # whether the value survives, so neither may be claimed as a forward. + ('def use(payload):\n return ProbeKind(payload["probe_slot"])\n', "call_target_untraced"), + ('def use(payload):\n return int(payload["probe_slot"])\n', "call_target_untraced"), + ('def use(record):\n return str(record.probe_slot)\n', "call_target_untraced"), + ('def use(payload, sink):\n sink(payload["probe_slot"])\n', "call_target_untraced"), + ('def use(payload, table):\n return table.get(payload["probe_slot"])\n', "call_target_untraced"), + ('def use(payload):\n return f"got {payload[\'probe_slot\']}"\n', "conversion_untraced"), + ('def use(payload):\n return payload["probe_slot"].upper()\n', "attribute_untraced"), + ('def use(snapshot):\n return {"copied": snapshot.probe_slot.value}\n', "attribute_untraced"), + ], +) +def test_a_construct_between_the_value_and_its_use_is_unknown(body: str, blocker: str) -> None: + """``.value`` is an enum unwrap only if the object is a member; unproved.""" + rows = _rows(body) + assert [(row.role, row.blocker) for row in rows] == [("unknown", blocker)], body + assert "unresolved, not absent" in rows[0].limitation + + +@pytest.mark.parametrize( + "shadow", + [ + 'def decide(ProbeKind, decision):\n return decision.kind is ProbeKind.ALPHA\n', + 'def decide(decision, other):\n ProbeKind = other\n return decision.kind is ProbeKind.ALPHA\n', + 'def decide(decision):\n from elsewhere import ProbeKind\n\n' + ' return decision.kind is ProbeKind.ALPHA\n', + 'def decide(decision):\n try:\n pass\n except Exception as ProbeKind:\n' + ' return decision.kind is ProbeKind.ALPHA\n return 0\n', + ], +) +def test_a_nearer_binding_of_the_owner_name_is_not_the_registered_owner(shadow: str) -> None: + """The operand belongs to whatever took the name over, so it stays unknown.""" + rows = _owner_rows(f'from {OWNER_PATH[:-3].replace("/", ".")} import ProbeKind\n\n\n' + shadow) + assert [(row.role, row.anchor, row.blocker) for row in rows] == [ + ("unknown", "owner_member", "shadowed_owner_binding") + ], shadow + assert rows[0].observed == () + + def test_a_computed_mapping_key_is_unknown_for_every_vocabulary_at_once() -> None: """No name-keyed scan can prove this site is not a reader of some slot.""" rows = scan_dynamic_reads(_source('def consume(payload, key):\n return payload.get(key)\n')) @@ -225,27 +264,13 @@ def test_a_prose_mention_is_never_reported_as_a_read() -> None: def test_an_unimported_owner_symbol_yields_no_owner_anchor() -> None: """A locally defined class of the same name is not the registered owner.""" - owner = _source( - 'from enum import Enum\n\n\nclass ProbeKind(str, Enum):\n ALPHA = "alpha"\n', - path="loopx/control_plane/owner.py", - ) - rows = scan_python_consumers( - _source( - 'class ProbeKind:\n ALPHA = "alpha"\n\n\n' - 'def decide(decision):\n return decision.kind is ProbeKind.ALPHA\n' - ), - vocabulary="probe", slot="probe_slot", values=VALUES, - owners={"loopx/control_plane/owner.py::ProbeKind": {"ALPHA": "alpha"}}, - modules={owner.path: owner}, + rows = _owner_rows( + 'class ProbeKind:\n ALPHA = "alpha"\n\n\n' + 'def decide(decision):\n return decision.kind is ProbeKind.ALPHA\n' ) assert rows == [] -def test_the_slot_name_falls_back_to_the_vocabulary_id() -> None: - assert slot_name("probe", {}) == "probe" - assert slot_name("probe", {"literal_scan": {"field": "declared_slot"}}) == "declared_slot" - - # --- whole-scan shape ------------------------------------------------------ @@ -276,13 +301,28 @@ def write(relative: str, text: str) -> None: return tmp_path -REGISTRY = {"vocabularies": {"probe": {"values": list(VALUES), "owners": {}}}} +# One vocabulary declares where its value travels; the other does not, and the +# scan must name it rather than analyse it under its own id. +REGISTRY = { + "vocabularies": { + "probe": {"values": list(VALUES), "owners": {}, "literal_scan": {"field": "probe"}}, + "probe_undeclared": {"values": list(VALUES), "owners": {}}, + } +} def _evidence(root: Path) -> ConsumerEvidence: return collect_consumer_evidence(root, REGISTRY, load_sources(root)) +def test_a_vocabulary_with_no_declared_slot_is_named_not_analysed(scan_repo: Path) -> None: + """Its id is not treated as a field name, so it contributes no row at all.""" + evidence = _evidence(scan_repo) + assert evidence.covered == ("probe",) + assert evidence.missing_slot_identity == ("probe_undeclared",) + assert not any(row.vocabulary == "probe_undeclared" for row in evidence.rows) + + def test_every_row_carries_a_location_a_domain_and_a_limitation(scan_repo: Path) -> None: evidence = _evidence(scan_repo) assert evidence.rows @@ -319,15 +359,33 @@ def test_a_mention_the_grammar_does_not_recognize_is_unknown_not_absent(scan_rep assert ("loopx/control_plane/decide.py::lookup", "dynamic_key") in blockers -def test_the_report_states_both_unknown_shares_and_never_gates(scan_repo: Path) -> None: +def test_the_report_states_its_coverage_its_unknowns_and_never_gates(scan_repo: Path) -> None: evidence = _evidence(scan_repo) assert evidence.counts()["interpret"] == 1 assert 0.0 < evidence.unknown_share() < 1.0 - printed = "\n".join(render_consumer_evidence(evidence, top=5)) + printed = "\n".join(render_consumer_evidence(evidence, top=20)) assert "advisory" in printed and "not proved data flow" in printed + assert "covered=1 of 2 registered vocabularies" in printed + assert "missing_slot_identity=1" in printed and "probe_undeclared" in printed assert "unknown_share=" in printed and "attributed_unknown_share=" in printed assert "unknown_reasons: " in printed +def test_the_printed_rows_are_not_filtered_down_to_the_resolved_ones(scan_repo: Path) -> None: + """A table showing only classified sites would read as a complete census.""" + evidence = _evidence(scan_repo) + printed = "\n".join(render_consumer_evidence(evidence, top=20)) + assert "dynamic_mapping_read" in printed + assert "typescript_not_walked" in printed + assert "mention_without_recognized_anchor" in printed + + +def test_the_unattributable_sites_keep_their_own_row_budget(scan_repo: Path) -> None: + """Ordering must not bury them either; that is the filter spelled again.""" + printed = "\n".join(render_consumer_evidence(_evidence(scan_repo), top=1)) + assert "unattributed rows" in printed + assert "dynamic_mapping_read" in printed + + def test_the_same_tree_reports_the_same_rows(scan_repo: Path) -> None: assert _evidence(scan_repo).rows == _evidence(scan_repo).rows