Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -1146,6 +1146,45 @@ introduce a competing target state.

## Appendix A: Execution ledger (non-normative)

### 2026-09-18 — Three producer-scan answers that were confidently wrong

Normative for what the producer scan may report as complete. No budget, floor or
anchor moved: `unresolved_producer_sites` is 40 before and after, because no
site in the tree exercises these shapes today. The fixes remove a way for a
future edit to pass the gate, not a current violation.

- **Why the direction matters.** F1 proves `Produced_scan(v) ⊆ S(v)`, so the
dangerous error is a value the scan does not see: an unregistered value then
passes. Over-reporting can only raise a false alarm. Each case below was a
*missing* value reported inside a complete-looking set.
- **A `global` rebinding was invisible.** `_module_functions` walks `tree.body`
and skips into no function, so `global pick; pick = other` inside another
function never counted as a second binding of `pick`, and a same-module call
still resolved to the original `def`'s returns for a name the module swaps at
runtime. A name some scope declares `global` and then stores is now
disqualified, and the call keeps `call_result`.
- **A `**` spread replayed a stale initializer.** `bound` follows plain
`name = expression` writes, so a dict mutated afterwards through a subscript
still resolved to its initializer. `{**overrides}` therefore reported the
key's original value as the produced one. The per-key union that `lookup`
applies is unreachable through a spread, which contributes every key at once,
so a spread of a mutated container now takes the unknown-key answer.
- **The TypeScript scanner had no scope model.** Any identifier spelled `String`
was read as the builtin conversion and any `undefined` as the literal, so
`function emit(String)` — a caller-supplied function that can return anything
— produced a confident value. Shadowing is now detected per file, which is
coarser than per scope and deliberately so: file granularity can only withhold
a builtin reading, never invent one.
- **One counterexample was relaxed, not removed.** A literal non-negative
subscript write *is* modelled, and the read resolves to the union of the
initializer and the write. Demanding `unresolved` there pinned a weaker scan
in place, so the assertion now states the property that matters: the reported
set may over-approximate but must never omit the written value.
- **What this does not establish.** The scan still models no cross-module data
flow, and file-granular shadow detection will withhold the builtin reading
from a file that shadows `String` in an unrelated function. Both are
conservative failures, and both stay measured rather than assumed.

### 2026-09-17 — Two measurements that contradicted themselves, corrected

Normative for what F5's `verified` count means and for closed-set collision
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,36 @@ PR review 保留这些层级。普通改动记录检查范围和理由,无共

## 附录 A:执行账本(非规范)

### 2026-09-18 — 生产者扫描三处自信而错误的答案

对"扫描可以把什么报成完整"是规范性的。没有移动任何预算、下限或锚点:
`unresolved_producer_sites` 修前修后都是 40,因为当前树里没有站点触发这些形状。
修的是"未来某次改动能蒙过门禁"的路径,不是现存的违规。

- **为什么方向重要。** F1 要证明 `Produced_scan(v) ⊆ S(v)`,所以危险的错误是
扫描**没看见**的值——那样一个未注册值就会通过。多报只会产生误报。下面每一条
都是一个**被漏掉**的值,却装在一个看起来完整的集合里。
- **`global` 重绑定完全不可见。** `_module_functions` 只遍历 `tree.body`,从不
进入任何函数体,所以另一个函数里的 `global pick; pick = other` 从未被算作
`pick` 的第二次绑定,同模块调用仍然解析到原始 `def` 的返回值——而这个名字在
运行时会被模块换掉。现在任何被某个作用域声明为 `global` 且在该作用域赋值的
名字都被取消资格,调用保留 `call_result`。
- **`**` 展开重播了陈旧的初始值。** `bound` 只跟随朴素的 `name = expression`
写入,所以一个随后被下标改写的字典仍解析到它的初始值,于是 `{**overrides}`
把该键的原值报成了产出值。`lookup` 的逐键并集在展开路径上不可达——展开一次
贡献所有键——因此被改写过的容器的展开现在走未知键答案。
- **TypeScript 扫描器没有作用域模型。** 任何拼作 `String` 的标识符都被读成内建
转换、任何 `undefined` 都被读成字面量,于是 `function emit(String)`(一个由
调用方提供、可以返回任何东西的函数)产出了一个自信的值。现在按**文件**粒度
检测遮蔽,比按作用域更粗,而且是刻意的:文件粒度只可能**扣留**一次内建读法,
永远不可能**凭空造出**一个。
- **有一条反例是被放松,而不是被删除。** 字面非负下标写入**确实**被建模,读回
解析为初始值与该写入的并集。在那里强求 `unresolved` 等于把一个更弱的扫描钉死,
所以断言改成陈述真正要紧的性质:报告的集合可以多报,但绝不能漏掉被写入的值。
- **它没有确立什么。** 扫描仍然不建模任何跨模块数据流;文件粒度的遮蔽检测会对
"在无关函数里遮蔽了 `String`"的文件也扣留内建读法。两者都是保守方向的失效,
且都是实测记录而非假设。

### 2026-09-17 — 两处自相矛盾的度量已修正

对 F5 的 `verified` 含义与闭集碰撞身份是规范性的。没有放松任何预算、下限或锚点;
Expand Down
42 changes: 41 additions & 1 deletion loopx/semantics/python_production.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,24 @@ def _is_generator(node: ast.FunctionDef) -> bool:
_MODULE_FUNCTIONS: dict[tuple[str, int], dict[str, ast.FunctionDef]] = {}


def _enclosing_scope(tree: ast.Module, target: ast.Global) -> ast.AST:
"""The function body that owns this ``global`` statement, else the module."""
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
for child in ast.walk(node):
if child is target:
return node
return tree


def _stores_name(scope: ast.AST, name: str) -> bool:
"""True when ``scope`` assigns, augments or deletes ``name``."""
for child in ast.walk(scope):
if isinstance(child, ast.Name) and child.id == name and isinstance(child.ctx, (ast.Store, ast.Del)):
return True
return False


def _module_functions(source: SourceFile, tree: ast.Module) -> dict[str, ast.FunctionDef]:
"""Top-level plain ``def``s a same-module call may be bound to.

Expand Down Expand Up @@ -289,6 +307,18 @@ def _module_functions(source: SourceFile, tree: ast.Module) -> dict[str, ast.Fun
bound[alias.asname or alias.name.split('.')[0]] += 1
elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
bound[child.name] += 1
# A ``global`` rebinding lives inside a function body, which the loop above
# never enters, so the module-level name it replaces looked untouched. Any
# name some scope declares ``global`` and then stores is no longer reliably
# the ``def`` above; binding a call to that ``def`` would report the original
# body's returns for a function the module can swap at runtime.
for node in ast.walk(tree):
if not isinstance(node, ast.Global):
continue
scope = _enclosing_scope(tree, node)
for name in node.names:
if name in defined and _stores_name(scope, name):
bound[name] += 1
functions = {name: node for name, node in defined.items()
if bound[name] == 1 and not node.decorator_list and not _is_generator(node)}
_MODULE_FUNCTIONS[key] = functions
Expand Down Expand Up @@ -563,7 +593,17 @@ def flatten(container: ast.Dict, seen: frozenset[str],
spread = True
arms = [value]
while arms:
arm, visited = bound(arms.pop(), seen)
candidate = arms.pop()
# ``bound`` follows plain ``name = expression`` writes only,
# so a container mutated afterwards through a subscript still
# resolves to its initializer. Spreading it would replay the
# stale literal and report a key's original value as the one
# produced. The per-key union that ``lookup`` applies is not
# reachable here, because a spread contributes every key at
# once, so the honest answer is the unknown-key fallback.
if isinstance(candidate, ast.Name) and candidate.id in written:
return None
arm, visited = bound(candidate, seen)
if isinstance(arm, ast.IfExp):
arms.extend((arm.body, arm.orelse))
continue
Expand Down
30 changes: 27 additions & 3 deletions scripts/semantic_production_scan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,28 @@ for (const source of request.sources) {
ts.isTemplateExpression(node)) return 'other';
return 'typescript_dynamic';
};
// The scanner has no scope model, so it cannot tell the builtin `String`
// from a parameter that shadows it, nor the `undefined` literal from a
// local of that name. A caller-supplied `String` can return anything, so
// trusting the builtin reading would report a produced value the code never
// produces. Shadowing is therefore detected per file -- coarser than per
// scope, which can only withhold a builtin reading, never invent one.
const shadowedGlobals = new Set();
{
const noteName = name => {
if (!name) return;
if (ts.isIdentifier(name)) shadowedGlobals.add(name.text);
else if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name))
for (const element of name.elements) if (ts.isBindingElement(element)) noteName(element.name);
};
const collect = node => {
if (ts.isParameter(node) || ts.isVariableDeclaration(node) || ts.isBindingElement(node)) noteName(node.name);
else if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) noteName(node.name);
else if (ts.isImportSpecifier(node) || ts.isImportClause(node) || ts.isNamespaceImport(node)) noteName(node.name);
ts.forEachChild(node, collect);
};
collect(tree);
}
const merge = parts => ({
values: [...new Set(parts.flatMap(part => part.values))].sort(),
unresolved: parts.some(part => part.unresolved),
Expand All @@ -47,7 +69,7 @@ for (const source of request.sources) {
if (node.kind === ts.SyntaxKind.NullKeyword) return {values: [], unresolved: false};
// ``undefined`` carries no value and is not an unknown, matching the way
// the Python scanner treats an explicit ``None``.
if (ts.isIdentifier(node) && node.text === 'undefined') return {values: [], unresolved: false};
if (ts.isIdentifier(node) && node.text === 'undefined' && !shadowedGlobals.has('undefined')) return {values: [], unresolved: false};
if (ts.isConditionalExpression(node)) return merge([values(node.whenTrue), values(node.whenFalse)]);
// ``a || b`` and ``a ?? b`` are a finite selection, exactly like the
// Python scanner's BoolOp arms; ``String(x)`` is a transparent wrapper.
Expand All @@ -56,7 +78,8 @@ for (const source of request.sources) {
return merge([values(node.left), values(node.right)]);
}
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) &&
node.expression.text === 'String' && node.arguments.length === 1) return values(node.arguments[0]);
node.expression.text === 'String' && node.arguments.length === 1 &&
!shadowedGlobals.has('String')) return values(node.arguments[0]);
return {values: [], unresolved: true, blocker: blockerFor(node)};
};
const staticName = expression => {
Expand All @@ -76,7 +99,8 @@ for (const source of request.sources) {
if (!node) return false;
if (target(node)) return true;
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) &&
node.expression.text === 'String' && node.arguments.length === 1) return reads(node.arguments[0]);
node.expression.text === 'String' && node.arguments.length === 1 &&
!shadowedGlobals.has('String')) return reads(node.arguments[0]);
return ts.isBinaryExpression(node) &&
[ts.SyntaxKind.BarBarToken, ts.SyntaxKind.QuestionQuestionToken].includes(node.operatorToken.kind) &&
reads(node.left) && (staticName(node.right) === "" || unwrap(node.right).kind === ts.SyntaxKind.NullKeyword);
Expand Down
Loading
Loading