修复 ESM 重复别名投影

保留同一导出的多个别名并合并同源导入声明
按最终模块源去重投影并统一匿名默认导出的规范别名
增加重复别名与跨桥接回归并同步技术约束
This commit is contained in:
2026-08-04 07:24:05 +08:00
parent 4bb9f77f20
commit 7118b03f1a
4 changed files with 248 additions and 61 deletions
@@ -3494,7 +3494,7 @@ struct JavascriptImportReferenceSpan {
struct JavascriptModuleAnalysis {
static_sources: Vec<String>,
import_declaration_ranges: Vec<std::ops::Range<usize>>,
imports: Vec<(String, BTreeMap<String, String>)>,
imports: Vec<(String, Vec<(String, String)>)>,
used_import_locals: BTreeSet<String>,
import_reference_spans: BTreeMap<String, Vec<JavascriptImportReferenceSpan>>,
namespace_import_members: BTreeMap<String, BTreeMap<String, Vec<std::ops::Range<usize>>>>,
@@ -3567,15 +3567,15 @@ impl<'a> VisitJavascript<'a> for JavascriptModuleAnalysisCollector {
self.analysis
.import_declaration_ranges
.push(declaration.span.start as usize..declaration.span.end as usize);
let mut bindings = BTreeMap::new();
let mut bindings = Vec::new();
for specifier in declaration.specifiers.iter().flatten() {
match specifier {
JavascriptImportDeclarationSpecifier::ImportSpecifier(specifier) => {
let local = specifier.local.name.to_string().to_ascii_lowercase();
bindings.insert(
bindings.push((
javascript_module_export_name(&specifier.imported),
local.clone(),
);
));
if let Some(symbol_id) = specifier.local.symbol_id.get() {
self.import_symbols.push((local, symbol_id));
self.import_origins.insert(
@@ -3589,7 +3589,7 @@ impl<'a> VisitJavascript<'a> for JavascriptModuleAnalysisCollector {
}
JavascriptImportDeclarationSpecifier::ImportDefaultSpecifier(specifier) => {
let local = specifier.local.name.to_string().to_ascii_lowercase();
bindings.insert("default".to_string(), local.clone());
bindings.push(("default".to_string(), local.clone()));
if let Some(symbol_id) = specifier.local.symbol_id.get() {
self.import_symbols.push((local, symbol_id));
self.import_origins
@@ -3598,7 +3598,7 @@ impl<'a> VisitJavascript<'a> for JavascriptModuleAnalysisCollector {
}
JavascriptImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => {
let local = specifier.local.name.to_string().to_ascii_lowercase();
bindings.insert("*".to_string(), local.clone());
bindings.push(("*".to_string(), local.clone()));
if let Some(symbol_id) = specifier.local.symbol_id.get() {
self.import_symbols.push((local.clone(), symbol_id));
self.import_origins
@@ -3607,7 +3607,16 @@ impl<'a> VisitJavascript<'a> for JavascriptModuleAnalysisCollector {
}
}
}
self.analysis.imports.push((source, bindings));
if let Some((_, existing)) = self
.analysis
.imports
.iter_mut()
.find(|(dependency, _)| dependency == &source)
{
existing.extend(bindings);
} else {
self.analysis.imports.push((source, bindings));
}
}
fn visit_export_declaration(&mut self, declaration: &JavascriptExportDeclaration<'a>) {
@@ -4281,7 +4290,7 @@ fn validate_javascript_module_links(
) -> Result<(), String> {
for (module, analysis) in analyses {
for (dependency, bindings) in &analysis.imports {
for exported in bindings.keys() {
for (exported, _) in bindings {
if !javascript_module_resolves_export(
dependency,
exported,
@@ -4335,10 +4344,8 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at(
let mut pending = std::collections::VecDeque::new();
let mut module_contents = std::collections::BTreeMap::<String, String>::new();
let mut module_analyses = BTreeMap::<String, JavascriptModuleAnalysis>::new();
let mut module_bindings = std::collections::BTreeMap::<
String,
Vec<(String, std::collections::BTreeMap<String, String>)>,
>::new();
let mut module_bindings =
std::collections::BTreeMap::<String, Vec<(String, Vec<(String, String)>)>>::new();
for source in executable_external_script_sources_from_html(html) {
let local_path = local_gameplay_script_path_from(None, &source.source)
.ok_or_else(|| format!("自主构建外部脚本路径不受支持:{}", source.source))?;
@@ -4463,6 +4470,7 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at(
.expect("masking parsed JavaScript imports preserves UTF-8");
let mut unit_replacements = Vec::<(std::ops::Range<usize>, String)>::new();
let mut added_projection = false;
let mut origins = BTreeMap::<String, Vec<(String, String, Option<String>)>>::new();
for (dependency, bindings) in dependencies {
if bindings.is_empty() {
continue;
@@ -4486,7 +4494,6 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at(
}
})
.collect::<Vec<_>>();
let mut origins = BTreeMap::<String, Vec<(String, String, Option<String>)>>::new();
for (exported, local, namespace) in used_bindings {
if let Some((origin, origin_export)) = javascript_module_export_origin(
&dependency,
@@ -4525,62 +4532,95 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at(
}
}
}
for (origin, bindings) in origins {
let Some(origin_content) = module_contents.get(&origin) else {
continue;
};
let used_names = bindings
.iter()
.map(|(exported, _, _)| exported.to_ascii_lowercase())
.collect::<BTreeSet<_>>();
let mut projection =
javascript_module_binding_projection(origin_content, &used_names);
for (exported, local, namespace) in bindings {
let origin_local = module_analyses
.get(&origin)
.and_then(|analysis| analysis.exports.get(&exported.to_ascii_lowercase()))
.and_then(|target| match target {
JavascriptExportTarget::Local(name) => Some(name.as_str()),
JavascriptExportTarget::Reexport { .. } => None,
})
.unwrap_or(exported.as_str());
if let Some(namespace) = namespace {
if let Some(ranges) = importer_analysis
.namespace_import_members
.get(&namespace)
.and_then(|members| members.get(&exported))
{
unit_replacements.extend(
ranges
.iter()
.cloned()
.map(|range| (range, origin_local.to_string())),
);
}
} else if origin_local == "__agc_default_export__" {
if !rename_javascript_root_binding(&mut projection, origin_local, &local) {
return Err(format!(
"自主构建模块匿名 default 投影绑定无法按符号重命名:{origin}::{exported} -> {local}"
));
}
} else if origin_local != local {
}
for (origin, bindings) in origins {
let Some(origin_content) = module_contents.get(&origin) else {
continue;
};
let used_names = bindings
.iter()
.map(|(exported, _, _)| exported.to_ascii_lowercase())
.collect::<BTreeSet<_>>();
let mut projection = javascript_module_binding_projection(origin_content, &used_names);
let mut synthetic_aliases = BTreeMap::<String, String>::new();
for (exported, local, namespace) in &bindings {
let origin_local = module_analyses
.get(&origin)
.and_then(|analysis| analysis.exports.get(&exported.to_ascii_lowercase()))
.and_then(|target| match target {
JavascriptExportTarget::Local(name) => Some(name.as_str()),
JavascriptExportTarget::Reexport { .. } => None,
})
.unwrap_or(exported.as_str());
if namespace.is_none() && origin_local == "__agc_default_export__" {
synthetic_aliases
.entry(origin_local.to_string())
.or_insert_with(|| local.clone());
}
}
for (synthetic, canonical) in &synthetic_aliases {
if !rename_javascript_root_binding(&mut projection, synthetic, canonical) {
return Err(format!(
"自主构建模块匿名 default 投影绑定无法按符号重命名:{origin}::{synthetic} -> {canonical}"
));
}
}
for (exported, local, namespace) in bindings {
let origin_local = module_analyses
.get(&origin)
.and_then(|analysis| analysis.exports.get(&exported.to_ascii_lowercase()))
.and_then(|target| match target {
JavascriptExportTarget::Local(name) => Some(name.as_str()),
JavascriptExportTarget::Reexport { .. } => None,
})
.unwrap_or(exported.as_str());
let projected_local = synthetic_aliases
.get(origin_local)
.map(String::as_str)
.unwrap_or(origin_local);
if let Some(namespace) = namespace {
if let Some(ranges) = importer_analysis
.namespace_import_members
.get(&namespace)
.and_then(|members| members.get(&exported))
{
unit_replacements.extend(
ranges
.iter()
.cloned()
.map(|range| (range, projected_local.to_string())),
);
}
} else if origin_local == "__agc_default_export__" {
if projected_local != local {
if let Some(spans) = importer_analysis.import_reference_spans.get(&local) {
unit_replacements.extend(spans.iter().map(|span| {
let replacement = if span.shorthand {
format!("{local}: {origin_local}")
format!("{local}: {projected_local}")
} else {
origin_local.to_string()
projected_local.to_string()
};
(span.range.clone(), replacement)
}));
}
}
} else if origin_local != local {
if let Some(spans) = importer_analysis.import_reference_spans.get(&local) {
unit_replacements.extend(spans.iter().map(|span| {
let replacement = if span.shorthand {
format!("{local}: {origin_local}")
} else {
origin_local.to_string()
};
(span.range.clone(), replacement)
}));
}
}
if !projection.is_empty() {
unit.push('\n');
unit.push_str(&projection);
added_projection = true;
}
}
if !projection.is_empty() {
unit.push('\n');
unit.push_str(&projection);
added_projection = true;
}
}
if added_projection {
@@ -2772,6 +2772,153 @@ fn javascript_module_projection_uses_symbols_for_dependencies_and_renames() {
);
}
#[test]
fn javascript_module_projection_preserves_duplicate_aliases_without_duplicate_origins() {
let temporary = tempfile::tempdir().expect("create duplicate alias module project");
let root = temporary.path();
fs::create_dir_all(root.join("game")).expect("create game directory");
fs::write(
root.join("game/origin.mjs"),
"export function rotatePiece() { return 'rotated'; }",
)
.expect("write shared origin module");
let html = "<script type=\"module\" src=\"./main.mjs\"></script>";
for (label, source) in [
(
"one declaration with two aliases",
"import { rotatePiece as turnLeft, rotatePiece as turnRight } from './origin.mjs'; turnLeft(); turnRight();",
),
(
"two declarations from one dependency",
"import { rotatePiece as turnLeft } from './origin.mjs'; import { rotatePiece as turnRight } from './origin.mjs'; turnLeft(); turnRight();",
),
] {
fs::write(root.join("game/main.mjs"), source).expect("write duplicate alias importer");
let modules = read_external_gameplay_javascript_at(root, html)
.unwrap_or_else(|error| panic!("read {label}: {error}"));
let projected = modules
.module_units()
.iter()
.find(|unit| {
unit.matches("rotatepiece();").count() == 2
&& unit.contains("function rotatepiece()")
})
.unwrap_or_else(|| panic!("both aliases must remain projected for {label}"));
assert_eq!(
projected.matches("function rotatepiece()").count(),
1,
"one origin must be projected once for {label}",
);
}
fs::write(
root.join("game/bridge-a.mjs"),
"export { rotatePiece } from './origin.mjs';",
)
.expect("write first re-export bridge");
fs::write(
root.join("game/bridge-b.mjs"),
"export { rotatePiece } from './origin.mjs';",
)
.expect("write second re-export bridge");
fs::write(
root.join("game/main.mjs"),
"import { rotatePiece as turnLeft } from './bridge-a.mjs'; import { rotatePiece as turnRight } from './bridge-b.mjs'; turnLeft(); turnRight();",
)
.expect("write importer with two bridges to one origin");
let modules = read_external_gameplay_javascript_at(root, html)
.expect("read two bridges to one origin module");
let projected = modules
.module_units()
.iter()
.find(|unit| {
unit.matches("rotatepiece();").count() == 2 && unit.contains("function rotatepiece()")
})
.expect("both bridged aliases must resolve to the shared origin");
assert_eq!(
projected.matches("function rotatepiece()").count(),
1,
"one origin reached through multiple bridges must be projected once",
);
fs::write(
root.join("game/origin.mjs"),
"export default function() { return 'rotated'; }",
)
.expect("write anonymous default origin module");
for (label, source) in [
(
"anonymous default aliases",
"import turnLeft from './origin.mjs'; import turnRight from './origin.mjs'; turnLeft(); turnRight();",
),
(
"anonymous default namespace and alias",
"import * as gameplay from './origin.mjs'; import turnRight from './origin.mjs'; gameplay.default(); turnRight();",
),
] {
fs::write(root.join("game/main.mjs"), source).expect("write anonymous default importer");
let modules = read_external_gameplay_javascript_at(root, html)
.unwrap_or_else(|error| panic!("read {label}: {error}"));
let projected = modules
.module_units()
.iter()
.find(|unit| unit.matches("turnleft();").count() == 2)
.or_else(|| {
modules
.module_units()
.iter()
.find(|unit| unit.matches("turnright();").count() == 2)
})
.unwrap_or_else(|| panic!("all anonymous default references must share one alias for {label}"));
assert_eq!(
projected.matches("const turnleft =").count()
+ projected.matches("const turnright =").count(),
1,
"an anonymous default origin must receive one canonical declaration for {label}",
);
assert!(
!projected.contains("__agc_default_export__"),
"the synthetic anonymous default name must not leak into {label}",
);
}
fs::write(
root.join("game/bridge-a.mjs"),
"export { default } from './origin.mjs';",
)
.expect("write first default bridge");
fs::write(
root.join("game/bridge-b.mjs"),
"export { default } from './origin.mjs';",
)
.expect("write second default bridge");
fs::write(
root.join("game/main.mjs"),
"import turnLeft from './bridge-a.mjs'; import turnRight from './bridge-b.mjs'; turnLeft(); turnRight();",
)
.expect("write anonymous default importer through two bridges");
let modules = read_external_gameplay_javascript_at(root, html)
.expect("read anonymous default through two bridges");
let projected = modules
.module_units()
.iter()
.find(|unit| unit.matches("turnleft();").count() == 2)
.or_else(|| {
modules
.module_units()
.iter()
.find(|unit| unit.matches("turnright();").count() == 2)
})
.expect("bridged anonymous default aliases must share one declaration");
assert_eq!(
projected.matches("const turnleft =").count()
+ projected.matches("const turnright =").count(),
1,
"an anonymous default reached through two bridges must be projected once",
);
}
#[test]
fn game_chat_pure_continue_does_not_inherit_across_sessions() {
let (_temporary, root, original_state, original_contract) =
@@ -5977,6 +5977,6 @@
## 2026-08-04 图集事务与 Tetris 完成门使用句柄和 AST 收口
- 图集事务:九文件旧合同在写 `prepared` 前必须全部持有可信源句柄并整体复读;Unix 事务控制文件统一通过锚定目录句柄的 `openat / unlinkat + O_NOFOLLOW + O_NONBLOCK` 操作,FIFO 等非普通文件必须在读取前失败关闭,恢复前态也必须在同一叶子句柄上稳定双读并复核前后元数据与当前 inode。Windows 祖先 pin 只请求读访问并拒绝 delete sharing,可重复持有;只有事务叶子句柄请求删除访问。事务捕获与恢复 CAS 从 canonical 项目根句柄逐组件打开或创建父目录,staging、no-replace link/move 与 unlink 均相对固定父目录句柄执行;清理事务证据前再次复核整组安装结果。安装后的任何清理错误都按实际 canonical 状态把当前项纳入逆序回滚,不能留下新旧混合合同。
- JavaScript / ESMTetris 静态连续性检查以 Oxc parser、semantic 与 AST visitor 为权威。无效语法、ASI、template interpolation、正则 / 注释、表达式体箭头、参数和词法遮蔽、export alias、import 后再 export 的 bridge、re-export 与缺失导出链接不再由字符串扫描猜测;跨模块 alias 保留 origin 根绑定名称,只按解析到 import symbol 的 reference span 改写 importerobject shorthand 展开后保留原键;namespace 只改写绑定到 import symbol 的完整 member span,同文本属性和局部遮蔽均不得连带改写。HTML `type` 存在时优先于 legacy `language`。源码投影仍只是静态语义门,最终完成继续要求绑定当前 revision 的真实 Chromium 固定试玩回执。
- JavaScript / ESMTetris 静态连续性检查以 Oxc parser、semantic 与 AST visitor 为权威。无效语法、ASI、template interpolation、正则 / 注释、表达式体箭头、参数和词法遮蔽、export alias、import 后再 export 的 bridge、re-export 与缺失导出链接不再由字符串扫描猜测;跨模块 alias 保留 origin 根绑定名称,只按解析到 import symbol 的 reference span 改写 importer。同一 export 的多个本地 alias 保序保留,同一 dependency 的多条 import declaration 合并绑定;投影在 importer 内按最终 origin 聚合,因此同一 origin 经不同 dependency 或 bridge 到达时也只生成一次根声明。object shorthand 展开后保留原键;namespace 只改写绑定到 import symbol 的完整 member span,同文本属性和局部遮蔽均不得连带改写。HTML `type` 存在时优先于 legacy `language`。源码投影仍只是静态语义门,最终完成继续要求绑定当前 revision 的真实 Chromium 固定试玩回执。
- 浏览器因果:状态证据仍只冻结 trusted input listener 及其点击派生微任务内的变化;完整手势身份改由宿主在成功完成 Chromium 元素鼠标输入后调用隔离世界 finish。更早注册的 `window` capture listener 即使调用 `stopImmediatePropagation()` 也不能阻断探针自身的完成身份,页面脚本不能伪造 host finishRAF / timer 继续不计入动作结果。
- 验证边界:Linux 定向回归覆盖目录相对读写与清理、祖先 symlink、CAS 安装后错误、九文件混合快照、Tetris AST 反例和七项真实 Chrome generic 试玩。Windows cfg 代码必须继续在真实 Windows CI / 发布构建验证;本地缺少 MinGW C compiler 时,安装了 Rust target 也不能把交叉 `cargo check` 失败误报为源码失败。
File diff suppressed because one or more lines are too long