diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md index 852f660c71..399484804b 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md @@ -824,6 +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 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 | @@ -1123,6 +1124,83 @@ introduce a competing target state. ## Appendix A: Execution ledger (non-normative) +### 2026-09-17 — B5: consumer roles for the vocabularies that declare a slot + +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 + `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. +- **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 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 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`, 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 + is a separate change carrying its own risk. + ### 2026-09-17 — Per-value meaning for the `cross_runtime` tier Non-normative for the model; it adds no invariant and changes no existing @@ -1437,6 +1515,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 (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 | | 2026-09-17 | Document every `cross_runtime` value with the condition that produces it, taking per-value coverage from 68/149 to 149/149, and ratchet it in a separate test file | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) Track A; PR review pending | Append to the kernel ratchet at the end of `test_semantic_vocabulary_drift.py` (rejected: three open PRs already collide on that tail, and a same-diff rule is exactly what a merge there loses); infer a meaning for the two values with no producer (rejected by the evidence rules: a guessed note is indistinguishable from a verified one once it is in the table); document only the values a branch selects (rejected: it would leave the author-declared values looking undocumented rather than declared, which is the more useful fact); enforce the "not a restatement" bar with a character floor plus a non-stopword word count, and cap the unresolved count at 2 (both rejected under review: a word count cannot show that a note names the producing condition and only rewards padding, and a budget on honesty pressures the next author to invent a condition rather than record missing evidence) | Appendix A, Appendix B | @@ -1467,6 +1546,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 39bc651b86..94192e59d8 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md @@ -666,6 +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 计数、两个未知占比,以及每种未知原因及其位点数;测试通过 | 仅为参考,且在 #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 失败;只被比较的值不算已携带 | @@ -906,6 +907,62 @@ PR review 保留这些层级。普通改动记录检查范围和理由,无共 ## 附录 A:执行账本(非规范) +### 2026-09-17 — B5:对声明了槽位的词表报告消费者角色 + +非规范性;仅为参考证据,且 B5 是 #4447 的可选项。当前源码树上没有任何检查的 +通过/失败结果改变,本次新增的内容也不闸住任何合并。 + +- `consumer_ranking` 统计的是**提及**某符号的模块数。第 5 节早已写明这个数字 + “既不分类角色,也不证明数据流”,因此 B5 新增 + `loopx/semantics/consumer_report.py`:一次有界的 AST 扫描,把每个消费位点分类为 + `read`、`interpret`、`pass_through` 或 `unknown`,并逐行携带位置 + (`module::symbol` 与行号)、扫描所依据的源码 SHA、涉及的值域,以及适用于该行的 + 边界说明。 +- **覆盖面由注册表声明决定,不靠猜测,而它是 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 个位点以计算出的键读取映射, + 因而对被覆盖的词表未解析;18 个模块写出了槽位名却没有可识别的锚点;13 个位点把值 + 交给了本扫描不跟进的被调方;9 个携带槽位名的已跟踪 TypeScript 源文件未被遍历, + 因为这条路径上没有 TypeScript 解析器;1 个是不稳定局部变量。每一条都是带位置与 + 记录原因的行,沿用 B2 为未解析 producer 位点确立的做法,且两类行都不再被过滤出打印 + 清单:有归属的行与无归属的行各自成块、共用同一个 `--top` 配额。只展示语法解析得出 + 的那些行,读起来就像是该槽位读者的一份完整普查;而把两类行合成一份按位置排序的清单, + 又会让那 602 行把有归属的行压下去——那是同一种遮蔽的另一种写法。 +- 该报告确立的是**语法使用,而非数据流**。它不证明该值来自已注册的 producer, + 不证明该分支可达,也不证明没有行的词表就没有读者——计算键那一群位点,恰恰就是按 + 名字归组的扫描无法作出最后这个论断的原因。 +- 通过既有报告表面暴露为 + `scripts/generate_semantic_inventory.py --report --consumer-evidence`,缺少 + `--report` 时它会拒绝运行,因为它是参考证据而不是检查。全树上按位点扫描三次运行 + 耗时 4.1–4.4 秒,而 `--report` 本已打印的排名约需 217 秒。drift smoke 不会调用它。 +- **这让 B5 还剩多少价值。** 一个词表、111 行有归属的行、其中 41 行未知,并且在某次 + M1/M3 迁移为另一个词表声明槽位之前,覆盖面无法增加。诚实的读法是:这套机制跑在了 + 它所读的注册表前面;价值要等第一次迁移需要它时才出现,而不是现在。 +- 本次未处理:扫描范围是两个根目录而非整棵树;TypeScript 只计数、不解析; + 跟随局部变量只走一跳,因此经过两次别名传递的值是未知而非被追踪。扒宽这三者中的 + 任何一个,都是自带风险的另一个变更。 + ### 2026-09-17 — `cross_runtime` 层的逐值含义 对形式模型非规范:不新增任何不变量,也不改变任何既有检查的结论。改变的是每个 @@ -1154,6 +1211,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(可选项):只对声明了 `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 | | 2026-09-17 | 为每个 `cross_runtime` 值写明产生它的条件,把逐值覆盖率从 68/149 提到 149/149,并用一个独立测试文件加以棘轮化 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) Track A;PR 评审待完成 | 追加到 `test_semantic_vocabulary_drift.py` 末尾的 kernel 棘轮(否决:已有三个未合 PR 在该处冲突,而同一 diff 内的规则正是合并时最容易丢失的东西);为两个没有生产者的值推断含义(按证据规则否决:一旦写进表里,臆测的备注与经核实的备注无法区分);只记录由分支选择的值(否决:那会让由作者声明的值看起来像是没写,而“由作者声明”本身才是更有用的事实);用字符下限加非停用词计数来强制“不得只是复述”这条标准,并把未解析数量上限钉在 2(评审中双双否决:词数无法说明一条备注写出了产生条件,只会奖励灌水;而给诚实设预算会逼迫下一位作者编造条件,而不是如实记下证据缺失) | 附录 A、附录 B | @@ -1184,6 +1242,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 new file mode 100644 index 0000000000..cf6a5fcc90 --- /dev/null +++ b/loopx/semantics/consumer_report.py @@ -0,0 +1,673 @@ +"""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, 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 ``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 + +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 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': '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', +} + + +@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: 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]: + 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 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: + 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 {} + + +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] + shadows: 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. 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], inherited: 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)) + 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 + 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, shadows) + + walk(tree.body, '', set(), set()) + 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() + + +# 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. + + 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() + current = node + for _ in range(12): # A finite climb; a deeper nesting stays unclassified. + parent = scope.parents.get(id(current)) + if parent is None: + 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) + 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) + return 'interpret', observed, None, None + if isinstance(parent, (ast.If, ast.While, ast.Assert)) and getattr(parent, 'test', None) is current: + return 'interpret', observed, None, None + if isinstance(parent, ast.IfExp) and parent.test is current: + 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. + return 'interpret', observed, None, None + if isinstance(parent, ast.Return): + return 'pass_through', observed, None, None + if isinstance(parent, ast.Dict) and current in parent.values: + 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 relocates the value. + return 'pass_through', observed, None, None + if len(targets) == 1 and isinstance(targets[0], ast.Name): + return None, observed, targets[0].id, None + return None, observed, None, 'unclassified_context' + if isinstance(parent, ast.Call) and current is not parent.func: + # 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 None, observed, None, 'climb_depth_exceeded' + + +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 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, blocker = _classify_use(node, scope) + if name is None: + 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 'unknown', observed, 'unstable_local' + if _escapes(name, scope): + 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 'read', observed, None + resolved: list[str] = [] + unresolved: str | None = None + for use in uses: + 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. + 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]: + 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], 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, names) + if (isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) + 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, names) for item in node.elts), set()) + if isinstance(node, ast.MatchValue): + return member_values(node.value, names) + if isinstance(node, ast.MatchOr): + return set().union(*(member_values(p, names) for p in node.patterns), set()) + return set() + + 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 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, 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, live) for case in node.cases), set()) + if 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 + + +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 = ast.parse(source.text, filename=source.path) + 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 _scopes(tree): + 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, 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 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 _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', + '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], +) -> ConsumerEvidence: + """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) + 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, slot in slots: + vocabulary = registry['vocabularies'][name] + 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')) + 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(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 = [ + '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' 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}' + 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 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}') + 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/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..23fe7b2d1e --- /dev/null +++ b/tests/architecture/test_semantic_consumer_report.py @@ -0,0 +1,391 @@ +"""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 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 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 + +from pathlib import Path +import subprocess + +import pytest + +from loopx.semantics.consumer_report import ( + ANY_VOCABULARY, + CONSUMER_SCAN_ROOTS, + ConsumerEvidence, + collect_consumer_evidence, + declared_slot, + render_consumer_evidence, + scan_dynamic_reads, + scan_python_consumers, +) +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 _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 + ) + + +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 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" + 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(record, out):\n out.copied = record.probe_slot\n', + ], +) +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 "relocated_unchanged" in rows[0].limitation + + +@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_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' + ' sink(current)\n' + ' if current == "beta":\n' + ' return 1\n' + ' return 0\n' + ) + 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.""" + 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' + ) + 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')) + 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.""" + rows = _owner_rows( + 'class ProbeKind:\n ALPHA = "alpha"\n\n\n' + 'def decide(decision):\n return decision.kind is ProbeKind.ALPHA\n' + ) + assert rows == [] + + +# --- 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 + + +# 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 + 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_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=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