修复ESM解构投影与模块完成性

按源码顺序投影副作用依赖声明并按声明身份去重重命名
支持对象和数组解构写入导出绑定及最终可调用根传播
按可达绑定使用建立动态导入解构需求
扩展顶层 Promise await 的静态不完成识别与回归
同步技术方案与共享决策记录
This commit is contained in:
2026-08-04 15:57:13 +08:00
parent eb83634fb0
commit 3212955c08
4 changed files with 671 additions and 57 deletions
@@ -3951,6 +3951,139 @@ fn javascript_side_effect_imports_precede_importers_without_linking_local_decoys
);
}
#[test]
fn javascript_side_effect_projection_keeps_required_local_declarations_in_source_order() {
let temporary = tempfile::tempdir().expect("create ordered side-effect dependency project");
let root = temporary.path();
fs::create_dir_all(root.join("game")).expect("create game directory");
fs::write(
root.join("game/setup.mjs"),
"const makeRenderer = () => () => 1; globalThis.renderFrame = makeRenderer(); const unrelated = 'decoy';",
)
.expect("write side-effect dependency with a local helper");
fs::write(
root.join("game/main.mjs"),
"import './setup.mjs'; window.renderFrame();",
)
.expect("write side-effect importer");
let modules = read_external_gameplay_javascript_at(
root,
"<script type=\"module\" src=\"./main.mjs\"></script>",
)
.expect("project the side effect together with its local dependency");
let projected = modules
.module_units()
.iter()
.find(|unit| unit.contains("window.renderFrame()") && unit.contains("makeRenderer"))
.expect("the ordered side-effect projection must join the importer");
let helper = projected
.find("const makeRenderer")
.expect("the side-effect projection must include its local declaration");
let side_effect = projected
.find("globalThis.renderFrame = makeRenderer()")
.expect("the side-effect projection must include the observable write");
assert!(
helper < side_effect,
"a required local declaration must remain before the side effect that reads it: {projected}",
);
assert!(
!projected.contains("const unrelated"),
"an unrelated dependency-local declaration must not leak into the projection: {projected}",
);
fs::write(
root.join("game/setup.mjs"),
"export const makeRenderer = () => () => 1; globalThis.renderFrame = makeRenderer();",
)
.expect("write a side-effect helper that is also imported");
fs::write(
root.join("game/main.mjs"),
"import { makeRenderer } from './setup.mjs'; window.renderFrame(); makeRenderer()();",
)
.expect("write an importer that also reads the side-effect helper");
let modules = read_external_gameplay_javascript_at(
root,
"<script type=\"module\" src=\"./main.mjs\"></script>",
)
.expect("deduplicate a declaration shared by side-effect and binding projection");
let projected = modules
.module_units()
.iter()
.find(|unit| {
unit.contains("globalThis.renderFrame = makeRenderer()")
&& unit.contains("window.renderFrame()")
&& unit.contains("makeRenderer()()")
})
.expect("the shared helper projection must join its importer");
assert_eq!(
projected.matches("const makeRenderer").count(),
1,
"a helper shared by side-effect and binding demand must be declared once: {projected}",
);
}
#[test]
fn javascript_destructuring_assignments_project_the_final_exported_callable() {
let html = "<script type=\"module\" src=\"./main.mjs\"></script>";
for (label, assignments) in [
(
"object",
"({ start } = { start: () => import('./old.mjs').then(({ oldRun }) => oldRun()) }); ({ start } = { start: () => import('./dependency.mjs').then(({ run }) => run()) });",
),
(
"array",
"[start] = [() => import('./old.mjs').then(({ oldRun }) => oldRun())]; [start] = [() => import('./dependency.mjs').then(({ run }) => run())];",
),
] {
let temporary = tempfile::tempdir().expect("create destructuring live-binding project");
let root = temporary.path();
fs::create_dir_all(root.join("game")).expect("create game directory");
fs::write(
root.join("game/origin.mjs"),
format!("export let start; {assignments}"),
)
.expect("write destructuring live-binding assignments");
fs::write(
root.join("game/old.mjs"),
"export function oldRun() { return import('./missing-old.mjs'); }",
)
.expect("write obsolete destructured callable");
fs::write(
root.join("game/dependency.mjs"),
"export function run() { return import('./final.mjs').then(({ finish }) => finish()); }",
)
.expect("write final destructured callable dependency");
fs::write(
root.join("game/final.mjs"),
"export function finish() { return 'done'; }",
)
.expect("write final destructured callable closure");
fs::write(
root.join("game/main.mjs"),
"import { start } from './origin.mjs'; start();",
)
.expect("write destructured callable consumer");
let modules = read_external_gameplay_javascript_at(root, html)
.unwrap_or_else(|error| panic!("project {label} destructuring assignment: {error}"));
let projected = modules
.module_units()
.iter()
.find(|unit| unit.contains("start();") && unit.contains("function finish()"))
.unwrap_or_else(|| {
panic!(
"{label} destructuring assignment must project its final callable closure: {:#?}",
modules.module_units()
)
});
assert!(
!projected.contains("function oldRun()"),
"only the final destructuring assignment may define the exported callable root: {projected}",
);
}
}
#[test]
fn javascript_projection_preserves_dependency_declaration_source_order() {
let temporary = tempfile::tempdir().expect("create declaration-order project");
@@ -4087,6 +4220,80 @@ fn javascript_top_level_await_fails_closed_only_when_statically_non_completing()
.expect("write completing top-level await with nested decoy");
read_external_gameplay_javascript_at(root, html)
.expect("a completing top-level await and uncalled nested decoy remain valid");
for source in [
"await new Promise(() => 42); export function start() { return 1; }",
"await new Promise((resolve) => console.log(resolve.name)); export function start() { return 1; }",
"await new Promise((resolve) => { function nested(resolve) { resolve(); } }); export function start() { return 1; }",
] {
fs::write(root.join("game/dependency.mjs"), source)
.expect("write a non-settling Promise executor");
let error = read_external_gameplay_javascript_at(root, html)
.expect_err("an executor that never invokes its resolver must not complete");
assert!(
error.contains("top-level await") || error.contains("顶层 await"),
"{error}",
);
}
for source in [
"await new Promise((resolve) => resolve(1)); export function start() { return 1; }",
"await new Promise((_resolve, reject) => reject(new Error('done'))).catch(() => {}); export function start() { return 1; }",
"const Promise = class { constructor() {} }; await new Promise(() => 42); export function start() { return 1; }",
"async function nested() { await new Promise(() => 42); } await Promise.resolve(); export function start() { return 1; }",
] {
fs::write(root.join("game/dependency.mjs"), source)
.expect("write a completing or out-of-scope Promise case");
read_external_gameplay_javascript_at(root, html)
.expect("resolver calls, Promise shadowing and nested awaits must remain valid");
}
}
#[test]
fn javascript_dynamic_then_destructuring_requires_a_reachable_binding_use() {
let temporary = tempfile::tempdir().expect("create dynamic then demand project");
let root = temporary.path();
fs::create_dir_all(root.join("game")).expect("create game directory");
fs::write(
root.join("game/dependency.mjs"),
"export function run() { return import('./missing-unused.mjs'); }",
)
.expect("write demand-sensitive dynamic export");
fs::write(
root.join("game/origin.mjs"),
"export function start() { return import('./dependency.mjs').then(({ run }) => { if (false) run(); }); }",
)
.expect("write unused dynamic destructuring callback");
fs::write(
root.join("game/main.mjs"),
"import { start } from './origin.mjs'; start();",
)
.expect("write dynamic then consumer");
let html = "<script type=\"module\" src=\"./main.mjs\"></script>";
read_external_gameplay_javascript_at(root, html)
.expect("pure destructuring and unreachable uses must not demand the export callable");
fs::write(
root.join("game/dependency.mjs"),
"export function run() { return import('./final.mjs').then(({ finish }) => finish()); }",
)
.expect("write reachable dynamic export");
fs::write(
root.join("game/final.mjs"),
"export function finish() { return 'used'; }",
)
.expect("write reachable dynamic export closure");
fs::write(
root.join("game/origin.mjs"),
"export function start() { return import('./dependency.mjs').then(({ run }) => run()); }",
)
.expect("write used dynamic destructuring callback");
let modules = read_external_gameplay_javascript_at(root, html)
.expect("a reachable destructured binding call must demand its export closure");
assert!(modules
.module_units()
.iter()
.any(|unit| unit.contains("start();") && unit.contains("function finish()")));
}
#[test]
@@ -6010,3 +6010,10 @@
- ESM 最终绑定与 occurrence 补充:外部 callable root 只认模块初始化完成时同一 live binding / member 的最后一次直接顶层 assignment,旧 RHS 不得加载;直接 awaited namespace member 在赋值、受控 callback、constructor 和深层静态 member path 中仍按 `(source, occurrence)` 传播并替换首段 export。assignment class expression 的 static / instance member demand 分离。
- ESM 初始化与阻塞补充:projection declaration 保持原模块源码顺序,dependency origin 保持 importer 声明顺序;循环 canonical 去重同时删除声明和对应初始化写入。side-effect static import 只联结按 dependency-before-importer 排列的直接 `globalThis / window` 顶层 effect,不暴露 dependency local binding;静态可判定永不完成的 top-level await 至少对未遮蔽全局 `await new Promise(() => {})` 失败关闭,嵌套函数同形 decoy 和可完成 await 保持允许。
## 2026-08-04 ESM 解构写入与模块完成性收口
- side-effect static import 若读取 dependency-local declaration,组合投影必须把该声明及其 semantic 依赖按原源码位置放在 effect 前;同一声明也被 importer 使用时按 origin declaration identity 去重并统一做 collision-safe canonical 重命名,既不泄露无关 local,也不引入 TDZ 或重复声明。
- exported live binding 的顶层 object / array destructuring assignment 纳入 projection;最终一次写入中与目标 root 对应的属性或槽位 callable 才作为 dynamic demand root,更早写入和相邻 decoy 不得回流。
- 未遮蔽全局 top-level `await new Promise(executor)` 的 executor 若不调用或传递 resolve / reject、也不显式 throw,则返回值不参与 Promise settle,静态门按不完成失败关闭;resolver 按 semantic symbol 识别,Promise 遮蔽与嵌套函数内 await decoy 继续保留。
- dynamic import `.then` callback 的对象解构只建立 occurrence-scoped binding,不再仅因读取 export 属性就形成 callable demand;只有该 binding 的可达引用或调用才向 export 内部传播动态依赖,未使用和恒假引用保持关闭。
File diff suppressed because one or more lines are too long