diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md index 852f660c71..4d4b6f4ed7 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md @@ -415,6 +415,19 @@ and declared returns use the repository's TypeScript parser rather than regex. Neither parser executes inspected source. These are syntactic result witnesses, not a proof of reachability or whole-program data flow. +Neither scanner interprets the module it reads. There is no local environment, +no loop or call-graph fixpoint, and no model of container mutation: the bound is +the syntax in front of the reader, not the program's behaviour. What the scan +adds instead is a **reason** on every unresolved site, drawn from one taxonomy +shared by both runtimes — `argument_name_only`, `unstable_local`, `call_result`, +`dynamic_key`, `serialized_value`, `annotation_only`, `attribute_read`, `other`, +with `typescript_dynamic` kept only as the TypeScript fallback for a write the +parser cannot classify further. A reason narrows nothing; it tells a reviewer +which residue is a registry edit away and which needs a different analysis. When +the scan cannot enumerate the complete set of possible outputs, the site stays +unresolved with its reason. It is never reported as fully resolved, and never +treated as dead. + `uv run python examples/semantic-vocabulary-drift-smoke.py --report` lists unresolved production locations. Unresolved parts cannot supply missing value evidence; known conditional branches remain structural witnesses, not reachability proofs. @@ -832,6 +845,7 @@ on the next full-tree scan; genuine shared-contract changes still need review. | Historical committed snapshots could become stale across merges | Replay the scanner over the first parent and the merge of the last twenty `upstream/main` merge commits | 8 of 20 merges change at least one carrier | Historical cost motivating Q9; current checks compute the combined tree without a committed snapshot | | The formal model cannot silently lose a proof obligation | Remove an invariant, role, relation, candidate decision, or proof-boundary category from `formal_model` | The drift smoke fails on the exact formal-model shape | The model is a finite contract and proof ledger; it does not prove the listed properties by itself | | An obligation cannot claim a domain nobody counts | `uv run --extra test python -m pytest tests/architecture/test_semantic_vocabulary_drift.py -k domain` | Dropping `domain`, inflating `verified` or `registered`, inventing a selector, claiming an unanchored selector or an out-of-stage evidence bound, and an advisory invariant claiming verified members each fail closed | The sizes are derived from the registry, so the check grounds the declared domain in registry data; it does not prove the obligation over that domain | +| An unprovable write cannot be reported as a confident value | `uv run --extra test python -m pytest tests/architecture/test_semantic_producer_binding.py` | pass; the five constructs an earlier local-interpretation attempt reported as fully resolved — a loop-carried write, a negative-index write, a mutated dict re-read through a `**` spread, a helper rebound through `global`, and a TypeScript parameter shadowing `String` — each stay unresolved with their own reason | Fixture repository; a reason names the obstacle without narrowing it, so a site the scan cannot bind stays unresolved, never dead | | F1/F2 quantify over exactly what the producer check walks | Same test module: compare `check_producers`' predicate with the declared F1/F2 domain | The vocabularies with `producers` are exactly the `kernel` tier, 6 of 26; the other 20 are all `cross_runtime` | The scan reach bounds the claim further and is reported, not pinned | Known limits, stated so the check is not over-trusted: @@ -1123,6 +1137,98 @@ introduce a competing target state. ## Appendix A: Execution ledger (non-normative) +### 2026-09-17 — B2: a bounded blocker taxonomy; the residue stays unresolved + +- **Trigger:** [#4447](https://github.com/huangruiteng/loopx/issues/4447) asks B2 + for *bounded* producer identification, with dynamic, aliased, external and + unprovable paths staying **explicitly unresolved**. The issue's residue figure + ("34 total minus the 15 unprovable by design") was stale. Re-measured on + `d8e7af141` the total is **41**, across every scanned vocabulary rather than + `effective_action` alone, split `annotation_only=5, argument_name_only=10, + attribute_read=2, call_result=11, other=1, typescript_dynamic=8, + unstable_local=4`. +- **Rejected first, recorded because the failure is the lesson.** The first + attempt at this slice grew `python_production` into a local abstract + interpreter: ordered rebinding of a local, container-mutation tracking with + alias invalidation, `**` spread flattening, and same-module call resolution, + plus `String()`/`undefined` special cases in the TypeScript scanner. It closed + **one** site, and review reproduced five confidently wrong "fully resolved" + verdicts: a loop whose second iteration emits a value the scan never sees; a + write through `container[-1]` missed by a read of `container[0]`; a dict + mutated after construction and then spread with `**`, read back from its stale + initializer; a helper rebound through `global` still attributed to the + module-level `def`; and a TypeScript parameter shadowing `String` still treated + as the builtin conversion. For a gate that decides whether code is safe, a + wrong confident answer is strictly worse than an admitted unknown, so all of it + was removed. Patching them in place was tried first and is the reason the + removal is not an overreaction: a back-edge rule and a negative-index rule + closed the first two, left **every number on the tree unchanged** — so the + generality had bought nothing those fixes took away — and still left the + spread of a mutated dict, the `global` rebinding and the shadowed `String` + answering confidently and wrongly. An inference whose soundness is repaired + one counterexample at a time is not bounded; it is unbounded with a growing + patch list. The five reproductions are kept as tests in + `tests/architecture/test_semantic_producer_binding.py`, now asserting the + conservative outcome: each stays unresolved, with its reason. Four loop shapes + are pinned — a `for` back edge, a `while` back edge, a write carried by an + outer loop, and a `finally` that rebinds — beside a positive twin, so staying + unresolved cannot quietly become staying blind. +- **Delivered: the reason, not the value.** Both scanners keep exactly the + bounded syntactic reach they had, and every unresolved site now carries a + specific, actionable blocker from **one taxonomy shared by both runtimes**. + The TypeScript scanner's single `typescript_dynamic` catch-all is replaced by + `attribute_read`, `call_result`, `unstable_local` and `dynamic_key`, with + `typescript_dynamic` kept only as the fallback for a write the parser cannot + classify further; a `??`/`||` fallback reports the reason of the operand that + could not be read, since the fallback is not itself the obstacle. An + owner-member result (`enum_result`) carries its reason too; an unlabelled + unknown was invisible in the report breakdown. A label narrows nothing: it + changes no value set and no `unresolved` flag, and it is checked as such. +- **Result:** unresolved sites **41 → 41**, and the site list is byte-identical + to the baseline's. The split moves only between labels: + `annotation_only=5, argument_name_only=10, attribute_read=7, call_result=14, + other=1, unstable_local=4`, and `typescript_dynamic` empties. All eight + TypeScript sites are reclassified; none was + resolvable, so this is a taxonomy, not a shrink. That is the intended outcome — + where the scan cannot prove the complete set of possible outputs, the honest + answer is `unresolved`, never a confident default. Registry values, budgets, + anchors and the producer site list are unchanged, and no site becomes newly + visible or unregistered. +- **Deliberately not narrowed, with the reason recorded:** + - `annotation_only` (5) — all five are bare `effective_action: str` field + declarations carrying **no value node at all**. Unprovable by design; the + issue's classification is confirmed. + - `argument_name_only` (10) — unprovable as a *production role*, which is not + the same as unresolvable as an expression. One of the ten already carries a + fully resolved value set and is still correctly unresolved, because the + callee reads the field rather than emitting it. Counting any field-named + keyword as production would make the obligation tautological, which Section 5 + forbids. The honest way to narrow this bucket is a registry `call_producers` + declaration naming a reviewed output builder — a data edit a reviewer sees — + never a cleverer scanner. + - `attribute_read` (7), `call_result` (14), `unstable_local` (4) and `other` + (1) — every remaining site bottoms out in one of four things outside this + scan's bound: a read off a caller-supplied mapping or object, a call into + another module, a returned parameter, or a method chain. Binding any of them + needs cross-module or object-field resolution — a separate bounded form with + its own blast radius, and, as the rejected attempt showed, one whose + soundness has to be argued before its convenience. It is not attempted here. + **A site this scan cannot bind stays `unresolved` with its recorded reason — + it is never treated as dead.** +- **Cost:** the producer scan runs on every pull request touching `loopx/`. + Best of three on one tree, over the six vocabularies that declare `producers`: + baseline **28.6 s**, this branch **26.4 s**, the rejected interpreter **25.6 s**. + The spread inside a single variant is 26-31 s on the measuring host, so none of + these differences is outside run-to-run noise: the reduction neither costs nor + saves measurable time, because a label is computed on a path that had already + failed to resolve. The interpreter's apparent speedup came from a parse memo it + shipped alongside, not from the inference; reusing `_parsed` in + `scan_python_production` is a sound one-line change on its own and is left to a + separate PR rather than folded into a reduction. +- **Effect on normative design:** Section 5's bounded producer model gains the + shared blocker taxonomy and the statement that neither scanner interprets the + module it reads; no invariant or milestone changes. + ### 2026-09-17 — Per-value meaning for the `cross_runtime` tier Non-normative for the model; it adds no invariant and changes no existing @@ -1438,6 +1544,7 @@ result on the current tree; what changes is what the invariants claim. | 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 | 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 | B2: give every unresolved producer site a specific blocker reason from one taxonomy shared by both runtimes; leave the bounded syntactic reach unchanged | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2; PR review pending | Bind same-module call results, ordered local rebinding, key-precise container writes and `**` spreads (implemented, then **rejected on review**: it closed one site and produced five confidently wrong "fully resolved" verdicts — a loop-carried write, a negative-index write, a spread of a mutated dict, a `global` rebinding, and a shadowed `String`; a wrong confident answer is worse for this gate than an admitted unknown); count a field-named keyword as production (rejected: it makes the obligation tautological, Section 5); leave `typescript_dynamic` as one catch-all (rejected: eight sites shared one reason, so the residue was not actionable); loosen a budget or anchor to bank the one closed site (rejected: the site was never proven) | 5, 9, Appendix A, Appendix C | | 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 +1574,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) | +| E25 | Local interpretation bought one site and five wrong answers | `d8e7af141` + `a02200591` | replay the producer scan with and without the rejected local interpreter, and run the five reproductions in `tests/architecture/test_semantic_producer_binding.py` against both, including the later `bf7a7cc41` patch attempt | The interpreter moved the residue 41 → 40, closing only `driver.py::build_loopx_turn_plan:500`, which needed container-mutation tracking to read a `payload` dict through subscript writes it could not order. On the same build it reported five constructs as **fully resolved** whose true value set it never saw: a loop-carried write (`{"run"}` while iteration two emits `drop`), `codes[-1] = "drop"` read back as `codes[0] == "run"`, a mutated dict re-read through `**` from its stale initializer, a `global`-rebound helper still read off the module `def`, and `String("run")` under a parameter named `String`. A follow-up patch (`bf7a7cc41`) closed the first two in place and moved no number on the tree, leaving the other three still wrong. Removed outright, the residue returns to 41 with a byte-identical site list | Fixture constructs plus one real site; they show the inference is unsound, not how often it misfires in this tree. The five now stand as regression tests asserting the unresolved outcome, so the failure mode cannot return unnoticed | | 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..294b5d6fcf 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md @@ -321,6 +321,15 @@ owner 成员结果及声明函数的标量返回。导入枚举的别名只解 解析器。两个解析器都不执行被检查源码。这些是句法结果证据,不是可达性或全程序 数据流证明。 +两个扫描器都不解释它们读到的模块:没有局部环境,没有循环或调用图不动点,也没有 +任何容器改动模型;边界就是读者眼前的语法,而不是程序的行为。扫描新增的是给每一个 +未解析位点附上**原因**,取自两个运行时共用的同一套分类——`argument_name_only`、 +`unstable_local`、`call_result`、`dynamic_key`、`serialized_value`、 +`annotation_only`、`attribute_read`、`other`,而 `typescript_dynamic` 只保留为 +TypeScript 侧对解析器无法进一步归类的写入的兜底。原因不缩小任何东西;它告诉评审者 +哪些残量只差一次注册表编辑,哪些需要另一种分析。当扫描无法枚举出可能输出的完整集合 +时,该位点就带着原因保持未解析。它绝不会被报成完全解析,也绝不会被当作 dead。 + `uv run python examples/semantic-vocabulary-drift-smoke.py --report` 列出未解析的生产 位置。unknown 不能补足缺失值的生产证据。生产者守卫对六个 kernel 条目使用不同证据:`effective_action`、`turn_route`、 `loop_disposition` 和 `agent_scope_frontier_action` 使用源码见证; @@ -675,6 +684,7 @@ owner 符号集合的组:`EffectiveAction` 与 `EFFECTIVE_ACTIONS` 是同一 | 历史上的已提交清单会因上游合并而过期 | 对 `upstream/main` 最近二十个合并提交,在第一父提交与合并结果之间重放扫描器 | 20 次合并中 8 次至少改变一个载体 | Q9 的历史动机;当前检查直接计算合并后的全树,不再依赖提交快照 | | 形式模型不能静默丢失证明义务 | 从 `formal_model` 删除不变量、角色、候选决策、关系或证明边界分类 | 漂移 smoke 针对形式模型结构失败 | 该模型是有限契约和证明账本,本身不等于这些性质已经被证明 | | 义务不能声称一个无人清点的值域 | `uv run --extra test python -m pytest tests/architecture/test_semantic_vocabulary_drift.py -k domain` | 删掉 `domain`、调大 `verified` 或 `registered`、自造 selector、使用未钉住的 selector 或跨阶段的证据边界、以及 advisory 不变量声称已验证成员,逐项失败关闭 | 规模从注册表推导,因此该检查把声明值域接地到注册表数据;它不证明该义务在那个值域上成立 | +| 无法证明的写入不能被报成一个自信的取值 | `uv run --extra test python -m pytest tests/architecture/test_semantic_producer_binding.py` | 通过;五个曾被上一版局部解释报成“完全解析”的构造——循环携带的写入、负下标写入、被改动后再 `**` 展开的字典、经 `global` 重绑定的辅助函数,以及遮蔽 `String` 的 TypeScript 形参——逐一保持未解析并带上各自的原因 | 夹具仓库;原因只命名障碍,不缩小它:本扫描无法绑定的位点保持未解析,绝不当作 dead | | F1/F2 恰好量化 producer 检查真正走到的集合 | 同一测试模块:将 `check_producers` 的谓词与 F1/F2 声明的值域对比 | 声明了 `producers` 的词表恰好是 `kernel` 层,26 中的 6;其余 20 个全部是 `cross_runtime` | 扫描范围进一步约束该声明,它被上报而不被钉住 | 已知边界,写明是为了不让这个检查被过度信任: @@ -906,6 +916,70 @@ PR review 保留这些层级。普通改动记录检查范围和理由,无共 ## 附录 A:执行账本(非规范) +### 2026-09-17 — B2:有界的阻塞原因分类;残量继续保持未解析 + +- **起因:**[#4447](https://github.com/huangruiteng/loopx/issues/4447) 要求 B2 做 + *有界的* producer 识别,并明确要求动态、别名、外部与不可证的路径**保持显式未解析**。 + issue 里的残量数字(“34 个减去 15 个设计上不可证的”)已经过期。在 `d8e7af141` + 上重新测量,总数是 **41**,且分布覆盖全部被扫描词表,而不只是 `effective_action`: + `annotation_only=5, argument_name_only=10, attribute_read=2, call_result=11, + other=1, typescript_dynamic=8, unstable_local=4`。 +- **先被否决的做法,记录在案是因为这次失败本身就是结论。**本切片的第一版把 + `python_production` 长成了一个局部抽象解释器:局部变量的有序重绑定、带别名失效的 + 容器改动跟踪、`**` 展开的展平、同模块调用结果解析,外加 TypeScript 扫描器里的 + `String()`/`undefined` 特例。它只闭合了**一个**位点,而评审复现出五个自信但错误的 + “完全解析”判定:第二次迭代才产出、扫描根本看不到的循环写入;被 `container[0]` 读取 + 却遗漏的 `container[-1]` 写入;被改动之后再用 `**` 展开、却从陈旧初始化值读回的字典; + 经 `global` 重绑定却仍被算到模块级 `def` 头上的辅助函数;以及遮蔽了 `String`、却仍被 + 当成内建转换的 TypeScript 形参。对一个要判定代码是否安全的门禁来说,一个自信的错误 + 答案严格劣于一个承认的未知,因此这些推断被整体移除。先尝试过逐个修补,这正是本次 + 移除不算反应过度的理由:一条回边规则与一条负下标规则闭合了前两个,却让**树上的每一个 + 数字都没有变化**——也就是说那份泛化并没有带来这些修复所拿走的东西——而被改动后再展开的 + 字典、`global` 重绑定与被遮蔽的 `String` 仍然在自信地给出错误答案。一条靠逐个反例 + 修补可靠性的推断不是有界的;它是无界的,只是附带一张不断变长的补丁清单。五个复现作为 + 测试保留在 `tests/architecture/test_semantic_producer_binding.py`,现在断言的是保守 + 结果:每一个都保持未解析,并带上各自的原因。其中四种循环形态被钉住——`for` 回边、 + `while` 回边、由外层循环携带的写入,以及会重绑定的 `finally`——并配有一个正例孪生, + 使“保持未解析”不会悄悄变成“保持失明”。 +- **交付的是原因,不是取值。**两个扫描器的有界句法范围与此前完全一致,而每一个未解析 + 位点现在都带上一个具体、可行动的阻塞原因,取自**两个运行时共用的同一套分类**。 + TypeScript 侧单一的 `typescript_dynamic` 兜底被 `attribute_read`、`call_result`、 + `unstable_local` 与 `dynamic_key` 取代,`typescript_dynamic` 只保留为解析器无法进一步 + 归类时的兜底;`??`/`||` 的兜底表达式上报的是那个读不到的操作数的原因,因为兜底本身 + 并不是障碍。owner 成员结果(`enum_result`)也带上自己的原因;未标注的未知在报告分布里 + 是不可见的。原因不缩小任何东西:它不改变任何值集合,也不改变任何 `unresolved` 标志, + 并且这一点本身被测试钉住。 +- **结果:**未解析位点 **41 → 41**,且位点清单与基线逐字相同。变化只发生在标签之间: + `annotation_only=5, argument_name_only=10, attribute_read=7, call_result=14, + other=1, unstable_local=4`,`typescript_dynamic` 归零。八个 TypeScript 位点全部 + 被重新归类;其中没有一个是可解析的,所以这是一次分类,而不是一次缩减。这正是预期结果——当扫描无法证明可能输出的完整集合 + 时,诚实的答案是 `unresolved`,而不是一个自信的默认值。注册表取值、预算、锚点与 + producer 位点清单均无变化,也没有任何位点变成新可见或未登记。 +- **刻意不去缩小的部分,并记录理由:** + - `annotation_only`(5)——五个全部是裸的 `effective_action: str` 字段声明,**根本没有 + 取值节点**。设计上不可证;issue 的分类得到确认。 + - `argument_name_only`(10)——不可证的是*生产角色*,这与“表达式无法解析”不是一回事。 + 十个里已经有一个带着完全解析的值集合,而它仍然正确地保持未解析,因为被调方是在 + *读*这个字段而不是产出它。把任何与字段同名的关键字都算作生产,会使该义务变成同义 + 反复,这是第 5 节所禁止的。缩小这个桶的诚实办法是在注册表里用 `call_producers` + 声明一个经过评审的输出构建器——一次评审者看得见的数据修改——而不是一个更聪明的扫描器。 + - `attribute_read`(7)、`call_result`(14)、`unstable_local`(4)与 `other`(1) + ——其余每一个位点最终都落到本扫描边界之外的四件事之一:从调用方传入的映射或对象上 + 读取、跨模块调用、返回形参、方法链。绑定其中任何一种都需要跨模块或对象字段解析——那是 + 另一条有自己影响面的有界形式,而且正如被否决的那一版所显示的,它的可靠性必须先于它 + 的便利被论证。本次不做尝试。**本扫描无法绑定的位点带着被记录的原因保持 + `unresolved`——它绝不会被当作 dead。** +- **成本:**producer 扫描会在每一个触及 `loopx/` 的 PR 上运行。 + 同一棵树上、对声明了 `producers` 的六个词表、三次取最好:基线 **28.6 s**, + 本分支 **26.4 s**,被否决的解释器版 **25.6 s**。在测量主机上,单个变体自身的 + 波动区间就是 26-31 s,因此这些差异没有一个超出运行间噪声:这次缩减既不增加也不 + 节省可测量的时间,因为原因标签是在一条本来就没能解析的路径上计算出来的。解释器版 + 看起来更快,来自它一并带入的解析结果记忆化,而不是来自那些推断;在 + `scan_python_production` 里复用 `_parsed` 本身是一处可靠的单行改动,留给另一个 PR, + 不并入这次缩减。 +- **对规范设计的影响:**第 5 节的有界 producer 模型加入共享的阻塞原因分类,以及“两个 + 扫描器都不解释它们读到的模块”这一表述;不变量与里程碑均无变化。 + ### 2026-09-17 — `cross_runtime` 层的逐值含义 对形式模型非规范:不新增任何不变量,也不改变任何既有检查的结论。改变的是每个 @@ -1155,6 +1229,7 @@ PR review 保留这些层级。普通改动记录检查范围和理由,无共 | 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 | 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 | B2:给每一个未解析的 producer 位点附上取自两个运行时共用分类的具体阻塞原因;有界句法范围保持不变 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2;PR 评审待完成 | 绑定同模块调用结果、局部变量有序重绑定、按键精确的容器写入与 `**` 展开(已实现,随后在**评审中被否决**:它只闭合一个位点,却产出五个自信而错误的“完全解析”判定——循环携带的写入、负下标写入、被改动后再展开的字典、`global` 重绑定,以及被遮蔽的 `String`;对这个门禁而言,一个自信的错误答案劣于一个承认的未知);把与字段同名的关键字算作生产(拒绝:会使该义务变成同义反复,见第 5 节);保留 `typescript_dynamic` 作为单一兜底(拒绝:八个位点共用一个原因,残量无法被行动);放宽预算或锚点以把那一个闭合位点收入账(拒绝:该位点从未被证明) | 5、9、附录 A、附录 C | | 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 +1259,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) | +| E25 | 局部解释换来一个位点和五个错误答案 | `d8e7af141` + `a02200591` | 在有/无被否决的局部解释器两种情况下重放 producer 扫描,并对两者运行 `tests/architecture/test_semantic_producer_binding.py` 里的五个复现(含后续的 `bf7a7cc41` 修补尝试) | 解释器把残量从 41 推到 40,唯一闭合的是 `driver.py::build_loopx_turn_plan:500`,它需要容器改动跟踪才能读出一个经由它无法定序的下标写入被改动的 `payload` 字典。在同一次构建上,它把五个自己从未看全取值集合的构造报成**完全解析**:循环携带的写入(报 `{"run"}`,而第二次迭代产出 `drop`)、被当作 `codes[0] == "run"` 读回的 `codes[-1] = "drop"`、被改动后再经 `**` 从陈旧初始化值读回的字典、经 `global` 重绑定却仍从模块级 `def` 读出的辅助函数,以及在名为 `String` 的形参下的 `String("run")`。后续补丁(`bf7a7cc41`)就地闭合了前两个,树上没有任何数字变化,另外三个仍然是错的。整体移除之后残量回到 41,位点清单逐字相同 | 夹具构造加一个真实位点;它们证明这条推断不可靠,而不是它在本树上误报多频繁。五个复现现在作为回归测试断言未解析结果,因此该失效模式无法再无声回归 | | E13 | 冲突预算主要在度量局部命名 | `1dc6ad8d8` | 对 `conflicting_values` 与 `same_runtime_forks` 名字应用 `MODULE_LOCAL_CONVENTION` | 18 个冲突中 16 个、25 个分叉中 7 个是模块局部约定;语义子集分别为 2 与 18 | 分类是名字模式,已在扫描器中说明并由夹具测试钉住 | ## 附录 D:被否决或取代的方案 diff --git a/loopx/semantics/production.py b/loopx/semantics/production.py index 0b12cbaa61..97eedc2c7a 100644 --- a/loopx/semantics/production.py +++ b/loopx/semantics/production.py @@ -126,8 +126,10 @@ def _typescript_scan( and isinstance(error.get('line'), int) and error['line'] > 0): raise ValueError(f"{error['path']}:{error['line']}: invalid TypeScript source; repair syntax before semantic scanning") raise ValueError('TypeScript production parser failed; run npm ci --ignore-scripts and check the Node runtime') + # The parser names the same blocker vocabulary as the Python scanner; + # ``typescript_dynamic`` stays the fallback for a form it cannot classify. rows.extend(Production(r['site'], r['line'], r['form'], frozenset(r['values']), r['unresolved'], - 'typescript_dynamic' if r['unresolved'] else None) + (r.get('blocker') or 'typescript_dynamic') if r['unresolved'] else None) for r in json.loads(completed.stdout)) return rows diff --git a/loopx/semantics/python_production.py b/loopx/semantics/python_production.py index 3bcf1be656..0b417170f5 100644 --- a/loopx/semantics/python_production.py +++ b/loopx/semantics/python_production.py @@ -30,8 +30,16 @@ class Production: or non-literal subscript. ``serialized_value`` a string where an enum object was required. ``annotation_only`` a bare annotation that declares the field without a value. ``attribute_read`` an attribute of an unresolved object. - ``typescript_dynamic`` the TypeScript scanner could not resolve the write. ``other`` anything else; it keeps the site visible. + + Every label above is shared by both runtimes, so one residue taxonomy covers + the Python and the TypeScript scanner. ``typescript_dynamic`` is the + TypeScript-only fallback for a write the parser cannot classify further. + + A label is a reason, never a value: naming the obstacle does not narrow it. + When the scan cannot enumerate the complete set of possible outputs the site + stays unresolved with its reason; it is never reported as fully resolved and + never treated as dead. """ @@ -505,9 +513,14 @@ def record(node: ast.AST | None, form: str, location: ast.AST) -> None: if scope in return_functions: record(node.value, 'return', node) else: + blockers.clear() values, unknown = resolve(node.value, enum_only=True) + # An owner-member result keeps its reason too; an unlabelled + # unknown would be invisible in the report breakdown. + reason = blockers[0] if blockers else None if values: - result.append(Production(f'{source.path}::{scope}', node.lineno, 'enum_result', frozenset(values), unknown)) + result.append(Production(f'{source.path}::{scope}', node.lineno, 'enum_result', + frozenset(values), unknown, reason if unknown else None)) for child in nested: name = child.name if scope == '' else f'{scope}.{child.name}' params: set[str] = set() diff --git a/scripts/semantic_production_scan.mjs b/scripts/semantic_production_scan.mjs index d1a6b0efec..4ff9ec38cd 100644 --- a/scripts/semantic_production_scan.mjs +++ b/scripts/semantic_production_scan.mjs @@ -19,16 +19,38 @@ for (const source of request.sources) { while (node && (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node))) node = node.expression; return node; }; + // Name why a write stayed unknown using the same labels as the Python scanner, + // so one residue taxonomy covers both runtimes instead of a single catch-all. + // This only labels the residue; it never narrows it. Nothing here resolves a + // value, so a site the parser cannot enumerate stays unresolved as before. + const blockerFor = node => { + if (!node) return 'other'; + // A ``??``/``||`` fallback is not itself the obstacle; the operand that + // could not be read is. Reporting that reason renames the residue only -- + // the site stays unresolved with no value either way. + if (ts.isBinaryExpression(node) && [ts.SyntaxKind.BarBarToken, + ts.SyntaxKind.QuestionQuestionToken].includes(node.operatorToken.kind)) { + return blockerFor(unwrap(node.left)); + } + if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) return 'attribute_read'; + if (ts.isCallExpression(node) || ts.isNewExpression(node) || ts.isAwaitExpression(node)) return 'call_result'; + if (ts.isIdentifier(node)) return 'unstable_local'; + if (ts.isObjectLiteralExpression(node) || ts.isArrayLiteralExpression(node) || + ts.isTemplateExpression(node)) return 'dynamic_key'; + return 'typescript_dynamic'; + }; const values = expression => { const node = unwrap(expression); - if (!node) return {values: [], unresolved: true}; + if (!node) return {values: [], unresolved: true, blocker: 'other'}; if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return {values: node.text ? [node.text] : [], unresolved: false}; if (node.kind === ts.SyntaxKind.NullKeyword) return {values: [], unresolved: false}; if (ts.isConditionalExpression(node)) { const left = values(node.whenTrue), right = values(node.whenFalse); - return {values: [...new Set([...left.values, ...right.values])].sort(), unresolved: left.unresolved || right.unresolved}; + return {values: [...new Set([...left.values, ...right.values])].sort(), + unresolved: left.unresolved || right.unresolved, + blocker: (left.unresolved ? left : right).blocker}; } - return {values: [], unresolved: true}; + return {values: [], unresolved: true, blocker: blockerFor(node)}; }; const staticName = expression => { const node = unwrap(expression); diff --git a/tests/architecture/test_semantic_producer_binding.py b/tests/architecture/test_semantic_producer_binding.py new file mode 100644 index 0000000000..a17ec66451 --- /dev/null +++ b/tests/architecture/test_semantic_producer_binding.py @@ -0,0 +1,218 @@ +"""The producer scan's residue stays unresolved; it never becomes a guess. + +Track B item B2 of #4447 asks for *bounded* producer identification: dynamic, +aliased, external and unprovable paths must stay explicitly unresolved. A +confident wrong answer is the worst failure this gate can produce, because a +reviewer reads "fully resolved" as "these are all the values this site can +emit". Every construct below was reported as fully resolved by a local +abstract-interpretation attempt that this slice removed; each must now come +back unresolved, carrying the blocker label that says why. + +The blocker labels themselves are what B2 keeps: a reason a reviewer can act on +in place of one opaque bucket. A label narrows nothing -- it only names the +obstacle -- so these tests assert the reason *and* that the site stayed unknown. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from loopx.semantics.inventory import SourceFile +from loopx.semantics.production import collect_production +from loopx.semantics.python_production import scan_python_production + +ROOT = Path(__file__).resolve().parents[2] +OWNER = 'loopx/quota/owner.py::Action' +ENUMS = {OWNER: {'RUN': 'run', 'WAIT': 'wait'}} +CONSUMER = 'loopx/quota/client.py' +TS_CONSUMER = 'loopx/control_plane/quota/probe.ts' + + +def scan(text, *, returns=(), paths=None, calls=None, field='action', enums=ENUMS): + return scan_python_production(SourceFile(CONSUMER, '.py', text), field=field, enums=enums, + return_functions=frozenset(returns), return_paths=paths, + call_arguments=calls) + + +def known(rows): + return set().union(*(row.values for row in rows if row.form != 'keyword_unproved')) + + +def blockers(rows): + return {row.blocker for row in rows if row.unresolved} + + +def only(rows, form): + return [row for row in rows if row.form == form] + + +def ts_vocabulary(): + return {'values': ['run', 'wait'], 'producers': ['loopx/control_plane/quota/probe.py::emit'], + 'owners': {'python': None}, 'literal_scan': {'field': 'action'}} + + +def ts_scan(text): + return collect_production(ROOT, ts_vocabulary(), [SourceFile(TS_CONSUMER, '.ts', text)]) + + +# --- five reproductions of confidently wrong answers -------------------------- +# +# Each case below has a value the scan cannot see. Reporting the values it *can* +# see as the complete set would be the false verdict; the honest answer is that +# the complete set is unknown. + + +@pytest.mark.parametrize('text', [ + # a `for` back edge: iteration two emits 'drop' + 'def emit(rows):\n chosen = "run"\n for row in rows:\n' + ' build({"action": chosen})\n chosen = "drop"\n', + # a `while` back edge + 'def emit(rows):\n chosen = "run"\n while rows:\n' + ' build({"action": chosen})\n chosen = "drop"\n rows = rows[1:]\n', + # the carrying write sits in the outer loop, the read in the inner one + 'def emit(rows):\n chosen = "run"\n for row in rows:\n for inner in row:\n' + ' build({"action": chosen})\n chosen = "drop"\n', + # `finally` runs after the read and feeds the next iteration + 'def emit(rows):\n chosen = "run"\n for row in rows:\n try:\n' + ' build({"action": chosen})\n finally:\n chosen = "drop"\n', +]) +def test_a_loop_carried_write_is_not_the_first_iteration_alone(text): + """The second iteration emits ``drop``; only the first write precedes the read. + + Ordering a local's writes by source position is execution order only where + no back edge crosses it. Inside a loop the textually *later* write reaches + the read on the next iteration, so "the writes above this line" is not the + set of possible values, and reporting it states a closed value set that is + not closed. F1 asks whether a producer writes only registered values; a + producer emitting an unregistered value on every iteration after the first + would pass it. + """ + rows = only(scan(text), 'dict') + assert rows, 'the dict write must still be observed' + assert all(row.unresolved for row in rows) + assert blockers(rows) == {'unstable_local'} + assert known(rows) == set() + + +def test_a_single_write_inside_a_loop_is_still_its_only_value(): + """Staying unresolved must not become staying blind: one store is one value.""" + rows = only(scan('def emit(rows):\n for row in rows:\n chosen = "run"\n' + ' build({"action": chosen})\n'), 'dict') + assert known(rows) == {'run'} and not any(row.unresolved for row in rows) + + +@pytest.mark.parametrize('store', [ + # `codes[-1]` and `codes[0]` are the same element of a one-item list, so a + # key map recorded under -1 leaves the read looking at a replaced slot. + 'codes[-1] = "drop"', + # and a literal non-negative store is no safer: this scan does not model + # container mutation at all, so the whole container stops being evidence. + 'codes[0] = "drop"', +]) +def test_a_subscript_write_discards_the_container_it_mutates(store): + rows = only(scan('def emit():\n codes = ["run"]\n ' + store + '\n' + ' return {"action": codes[0]}\n'), 'dict') + assert rows and all(row.unresolved for row in rows) + assert blockers(rows) == {'unstable_local'} + assert 'run' not in known(rows) + + +def test_a_dict_mutated_after_construction_is_not_read_from_its_initializer(): + """A ``**`` spread of a mutated local must not replay the stale literal.""" + rows = only(scan('def emit():\n overrides = {"action": "run"}\n' + ' overrides["action"] = "drop"\n return {**overrides}\n', + returns=['emit'], paths={'emit': ('action',)}), 'return') + assert rows and all(row.unresolved for row in rows) + assert blockers(rows) == {'dynamic_key'} + assert known(rows) == set() + + +def test_a_helper_rebound_through_global_is_not_the_module_level_function(): + """``install()`` replaces ``pick``; the call site cannot be read off the ``def``.""" + rows = only(scan('def pick():\n return "run"\n' + 'def install():\n global pick\n pick = other\n' + 'def emit():\n return {"action": pick()}\n'), 'dict') + assert rows and all(row.unresolved for row in rows) + assert blockers(rows) == {'call_result'} + assert known(rows) == set() + + +def test_a_typescript_parameter_shadowing_string_is_not_the_builtin_conversion(): + """``String`` here is a caller-supplied function that can return anything.""" + rows = ts_scan('function emit(String) {\n return {action: String("run")};\n}\n') + assert rows and all(row.unresolved for row in rows) + assert blockers(rows) == {'call_result'} + assert set().union(*(row.values for row in rows)) == set() + + +# --- the residue is labelled, and a label is not a narrowing ------------------- + + +@pytest.mark.parametrize('text, reason', [ + # An attribute of an object this scan never resolved. + ('function emit(decision) {\n return {action: decision.effective_action};\n}\n', 'attribute_read'), + # A fallback is not the obstacle; the operand that could not be read is. + ('function emit(decision) {\n return {action: decision.effective_action ?? ""};\n}\n', 'attribute_read'), + # A value handed back by a call. + ('function emit(input) {\n return {action: project(input)};\n}\n', 'call_result'), + ('function emit(input) {\n return {action: await project(input)};\n}\n', 'call_result'), + # A bare local name. + ('function emit(choice) {\n return {action: choice};\n}\n', 'unstable_local'), +]) +def test_typescript_unresolved_writes_name_their_own_reason(text, reason): + """B2 keeps the taxonomy: one opaque bucket told a reviewer nothing.""" + rows = ts_scan(text) + assert rows and all(row.unresolved and not row.values for row in rows) + assert blockers(rows) == {reason} + + +def test_typescript_dynamic_remains_only_as_the_unclassifiable_fallback(): + rows = ts_scan('function emit(a, b) {\n return {action: a + b};\n}\n') + assert blockers(rows) == {'typescript_dynamic'} + + +def test_python_and_typescript_report_the_same_labels_for_the_same_shape(): + shape = 'attribute_read' + python = scan('def emit(decision):\n return {"action": decision.effective_action}\n') + typescript = ts_scan('function emit(decision) {\n return {action: decision.effective_action};\n}\n') + assert blockers(python) == blockers(typescript) == {shape} + + +def test_an_unbound_site_is_unresolved_and_never_silently_dropped(): + """No recognized producer means unresolved; it never means dead.""" + rows = scan('def emit(payload):\n return {"action": payload.get("action")}\n') + assert len(rows) == 1 + assert rows[0].unresolved and rows[0].blocker == 'call_result' + assert rows[0].values == frozenset() + + +# --- argument_name_only: resolved values, and still correctly unresolved ------- + + +def test_a_field_named_keyword_stays_unproved_even_when_its_value_is_known(): + """A resolved expression is not a resolved *role*, and the gate wants the role. + + The scan can read this argument perfectly well -- it is an owner member. The + site stays unresolved because the callee is not a reviewed output builder: + a helper that takes a field-named keyword is at least as likely to read the + field as to emit it. Counting any field-named keyword as production would + make the obligation tautological, which Section 5 of the RFC forbids. + + Narrowing this bucket therefore needs a registry ``call_producers`` entry + naming the builder -- a data edit a reviewer sees and approves -- and not a + cleverer scanner. That is why these sites keep a fully resolved value set + alongside ``unresolved``; the pair is the evidence for the registry edit. + """ + rows = scan('from .owner import Action\ndef emit():\n return record(action=Action.RUN.value)\n') + assert len(rows) == 1 + assert rows[0].form == 'keyword_unproved' + assert rows[0].unresolved and rows[0].blocker == 'argument_name_only' + assert rows[0].values == frozenset({'run'}) + + +def test_a_declared_output_builder_is_what_turns_that_value_into_production(): + rows = scan('from .owner import Action\ndef emit():\n return record(action=Action.RUN.value)\n', + calls={f'{CONSUMER}::record': {'action': 0}}) + assert [row.form for row in rows] == ['call_argument'] + assert known(rows) == {'run'} and not rows[0].unresolved