diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 3b5fd94a6..90941ccd4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -4537,6 +4537,11 @@ impl PlatformArtSliceContractRollback { "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集合同待提交,但缺少 prepared 阶段锚定的事务目录句柄" ) })?; + trusted_transaction_directory.verify().map_err(|error| { + format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集合同发布 committed 前事务目录身份无效:{error}" + ) + })?; trusted_transaction_directory.publish_marker( STRICT_PLATFORM_ART_TRANSACTION_COMMITTED, b"committed\n", @@ -7957,6 +7962,51 @@ mod canvas_generation_tests { .exists()); } + #[cfg(unix)] + #[test] + fn prepared_transaction_rejects_pathname_replacement_before_live_commit() { + let temporary = tempfile::tempdir().expect("create live commit handle project"); + let root = temporary.path(); + init_local_game_project_at(root, "live-commit-handle", "committed 事务句柄测试") + .expect("init project"); + for (index, local_path) in STRICT_PLATFORM_ART_CONTRACT_PATHS.iter().enumerate() { + let path = root.join(local_path); + if path.exists() { + continue; + } + fs::create_dir_all(path.parent().expect("contract parent")) + .expect("create contract parent"); + fs::write(path, format!("committed-contract-{index}")) + .expect("write complete committed contract"); + } + let mut transaction = PlatformArtSliceContractRollback::capture(root, "live-commit-handle") + .expect("capture prepared transaction"); + let transaction_directory = transaction.transaction_directory.clone(); + let displaced = transaction_directory.with_file_name("transaction-commit-displaced"); + fs::rename(&transaction_directory, &displaced) + .expect("displace prepared transaction directory before commit"); + fs::create_dir(&transaction_directory).expect("create commit pathname substitute"); + fs::write( + transaction_directory.join(STRICT_PLATFORM_ART_TRANSACTION_PREPARED), + b"prepared\n", + ) + .expect("write substitute prepared marker"); + + let error = transaction + .commit() + .expect_err("live commit must reject a replaced transaction pathname"); + assert!( + error.contains("目录身份发生变化"), + "unexpected error: {error}" + ); + assert!(!displaced + .join(STRICT_PLATFORM_ART_TRANSACTION_COMMITTED) + .exists()); + assert!(!transaction_directory + .join(STRICT_PLATFORM_ART_TRANSACTION_COMMITTED) + .exists()); + } + #[test] fn durable_strict_contract_transaction_rollback_preserves_concurrently_changed_installed_target( ) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index dd7f95cc1..434498d80 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -1,8 +1,9 @@ use super::*; use oxc_allocator::Allocator as JavascriptAllocator; use oxc_ast::ast::{ - BindingPattern as JavascriptBindingPattern, CallExpression as JavascriptCallExpression, - ComputedMemberExpression as JavascriptComputedMemberExpression, + Argument as JavascriptArgument, BindingPattern as JavascriptBindingPattern, + BindingProperty as JavascriptBindingProperty, CallExpression as JavascriptCallExpression, + Class as JavascriptClass, ComputedMemberExpression as JavascriptComputedMemberExpression, Declaration as JavascriptDeclaration, ExportAllDeclaration as JavascriptExportAllDeclaration, ExportDeclaration as JavascriptExportDeclaration, ExportDefaultDeclarationKind as JavascriptExportDefaultDeclarationKind, @@ -11,7 +12,8 @@ use oxc_ast::ast::{ Function as JavascriptFunction, ImportDeclaration as JavascriptImportDeclaration, ImportDeclarationSpecifier as JavascriptImportDeclarationSpecifier, ImportExpression as JavascriptImportExpression, MethodDefinition as JavascriptMethodDefinition, - ModuleExportName as JavascriptModuleExportName, ObjectProperty as JavascriptObjectProperty, + ModuleExportName as JavascriptModuleExportName, ObjectExpression as JavascriptObjectExpression, + ObjectProperty as JavascriptObjectProperty, PropertyDefinition as JavascriptPropertyDefinition, RegExpLiteral as JavascriptRegExpLiteral, Statement as JavascriptStatement, StaticMemberExpression as JavascriptStaticMemberExpression, StringLiteral as JavascriptStringLiteral, TemplateElement as JavascriptTemplateElement, @@ -1049,27 +1051,56 @@ struct NamedJavascriptFunctionRange { start: usize, end: usize, binding_start: Option, + owner: Option>, + member_static: bool, + externally_callable: bool, invocations: Vec, } #[derive(Default)] struct JavascriptFunctionDefinitionCollector { ranges: Vec, + owners: Vec>, + static_context: Vec, } impl JavascriptFunctionDefinitionCollector { - fn push(&mut self, name: &str, start: usize, end: usize, binding_start: Option) { + fn push( + &mut self, + name: &str, + start: usize, + end: usize, + binding_start: Option, + member_static: bool, + ) { self.ranges.push(NamedJavascriptFunctionRange { - name: name.to_ascii_lowercase(), + name: name.to_string(), start, end, binding_start, + owner: self.owners.last().cloned(), + member_static, + externally_callable: false, invocations: Vec::new(), }); } } impl<'a> VisitJavascript<'a> for JavascriptFunctionDefinitionCollector { + fn visit_class(&mut self, class: &JavascriptClass<'a>) { + self.owners + .push(class.span.start as usize..class.span.end as usize); + oxc_ast_visit::walk::walk_class(self, class); + self.owners.pop(); + } + + fn visit_object_expression(&mut self, object: &JavascriptObjectExpression<'a>) { + self.owners + .push(object.span.start as usize..object.span.end as usize); + oxc_ast_visit::walk::walk_object_expression(self, object); + self.owners.pop(); + } + fn visit_function(&mut self, function: &JavascriptFunction<'a>, flags: JavascriptScopeFlags) { if let Some(identifier) = &function.id { self.push( @@ -1080,6 +1111,7 @@ impl<'a> VisitJavascript<'a> for JavascriptFunctionDefinitionCollector { .symbol_id .get() .map(|_| identifier.span.start as usize), + self.static_context.last().copied().unwrap_or(false), ); } oxc_ast_visit::walk::walk_function(self, function, flags); @@ -1101,6 +1133,7 @@ impl<'a> VisitJavascript<'a> for JavascriptFunctionDefinitionCollector { .symbol_id .get() .map(|_| identifier.span.start as usize), + self.static_context.last().copied().unwrap_or(false), ); } } @@ -1115,9 +1148,12 @@ impl<'a> VisitJavascript<'a> for JavascriptFunctionDefinitionCollector { method.span.start as usize, method.span.end as usize, None, + method.r#static, ); } + self.static_context.push(method.r#static); oxc_ast_visit::walk::walk_method_definition(self, method); + self.static_context.pop(); } fn visit_object_property(&mut self, property: &JavascriptObjectProperty<'a>) { @@ -1128,21 +1164,274 @@ impl<'a> VisitJavascript<'a> for JavascriptFunctionDefinitionCollector { property.span.start as usize, property.span.end as usize, None, + false, + ); + } + } else if matches!( + property.value, + JavascriptExpression::FunctionExpression(_) + | JavascriptExpression::ArrowFunctionExpression(_) + ) { + if let Some(name) = property.key.static_name() { + let span = property.value.span(); + self.push( + name.as_ref(), + span.start as usize, + span.end as usize, + None, + false, ); } } + self.static_context.push(false); oxc_ast_visit::walk::walk_object_property(self, property); + self.static_context.pop(); + } + + fn visit_property_definition(&mut self, property: &JavascriptPropertyDefinition<'a>) { + if property.value.as_ref().is_some_and(|value| { + matches!( + value, + JavascriptExpression::FunctionExpression(_) + | JavascriptExpression::ArrowFunctionExpression(_) + ) + }) { + if let Some(name) = property.key.static_name() { + let span = property + .value + .as_ref() + .expect("checked function value") + .span(); + self.push( + name.as_ref(), + span.start as usize, + span.end as usize, + None, + property.r#static, + ); + } + } + self.static_context.push(property.r#static); + oxc_ast_visit::walk::walk_property_definition(self, property); + self.static_context.pop(); + } +} + +struct JavascriptMethodReceiverOwnerCollector<'a> { + scoping: &'a JavascriptScoping, + ranges: &'a [NamedJavascriptFunctionRange], + content: &'a str, + owner_events: + BTreeMap>>, + super_aliases: Vec<((usize, usize), JavascriptSymbolId)>, +} + +#[derive(Clone)] +enum JavascriptReceiverOwnerValue { + Direct((usize, usize, bool)), + Alias { + source: JavascriptSymbolId, + force_instance: bool, + }, +} + +impl JavascriptMethodReceiverOwnerCollector<'_> { + fn referenced_symbol( + &self, + expression: &JavascriptExpression<'_>, + ) -> Option { + let identifier = expression.get_identifier_reference()?; + identifier + .reference_id + .get() + .and_then(|reference_id| self.scoping.get_reference(reference_id).symbol_id()) + } + + fn record_binding_initializer( + &mut self, + symbol_id: JavascriptSymbolId, + initializer: &JavascriptExpression<'_>, + position: usize, + ) { + let value = match initializer { + JavascriptExpression::ObjectExpression(object) => { + Some(JavascriptReceiverOwnerValue::Direct(( + object.span.start as usize, + object.span.end as usize, + false, + ))) + } + JavascriptExpression::ClassExpression(class) => { + Some(JavascriptReceiverOwnerValue::Direct(( + class.span.start as usize, + class.span.end as usize, + true, + ))) + } + JavascriptExpression::NewExpression(new_expression) => self + .referenced_symbol(&new_expression.callee) + .map(|source| JavascriptReceiverOwnerValue::Alias { + source, + force_instance: true, + }), + JavascriptExpression::Identifier(_) => { + self.referenced_symbol(initializer).map(|source| { + JavascriptReceiverOwnerValue::Alias { + source, + force_instance: false, + } + }) + } + _ => None, + }; + if let Some(value) = value { + if javascript_position_is_in_literal_false_block(self.content, position) { + return; + } + self.owner_events + .entry(symbol_id) + .or_default() + .push(JavascriptAliasEvent { + position, + scope: javascript_alias_scope_at(self.ranges, position), + value, + }); + } + } + + fn finish( + self, + ) -> ( + BTreeMap>>, + Vec<((usize, usize), JavascriptSymbolId)>, + ) { + let super_owners = self.super_aliases.into_iter().collect(); + (self.owner_events, super_owners) + } +} + +impl<'a> VisitJavascript<'a> for JavascriptMethodReceiverOwnerCollector<'_> { + fn visit_class(&mut self, class: &JavascriptClass<'a>) { + let owner = (class.span.start as usize, class.span.end as usize); + if let Some(identifier) = &class.id { + if let Some(symbol_id) = identifier.symbol_id.get() { + self.owner_events + .entry(symbol_id) + .or_default() + .push(JavascriptAliasEvent { + position: class.span.start as usize, + scope: javascript_alias_scope_at(self.ranges, class.span.start as usize), + value: JavascriptReceiverOwnerValue::Direct((owner.0, owner.1, true)), + }); + } + } + if let Some(super_class) = &class.super_class { + if let Some(parent_symbol) = self.referenced_symbol(super_class) { + self.super_aliases.push((owner, parent_symbol)); + } + } + oxc_ast_visit::walk::walk_class(self, class); + } + + fn visit_variable_declarator(&mut self, declarator: &JavascriptVariableDeclarator<'a>) { + if let (Some(identifier), Some(initializer)) = + (declarator.id.get_binding_identifier(), &declarator.init) + { + if let Some(symbol_id) = identifier.symbol_id.get() { + self.record_binding_initializer( + symbol_id, + initializer, + declarator.span.start as usize, + ); + } + } + oxc_ast_visit::walk::walk_variable_declarator(self, declarator); + } + + fn visit_assignment_expression(&mut self, assignment: &oxc_ast::ast::AssignmentExpression<'a>) { + if assignment.operator.is_assign() { + if let oxc_ast::ast::AssignmentTarget::AssignmentTargetIdentifier(identifier) = + &assignment.left + { + if let Some(symbol_id) = identifier + .reference_id + .get() + .and_then(|reference_id| self.scoping.get_reference(reference_id).symbol_id()) + { + self.record_binding_initializer( + symbol_id, + &assignment.right, + assignment.span.start as usize, + ); + } + } + } + oxc_ast_visit::walk::walk_assignment_expression(self, assignment); } } struct JavascriptFunctionInvocationCollector<'a, 'b> { scoping: &'b JavascriptScoping, + content: &'b str, + context_ranges: &'b [NamedJavascriptFunctionRange], ranges: &'b mut [NamedJavascriptFunctionRange], binding_ranges: BTreeMap>, - method_ranges: BTreeMap>, + method_ranges: BTreeMap<(Option<(usize, usize, bool)>, String), Vec>, + receiver_owner_events: + BTreeMap>>, + super_aliases: Vec<((usize, usize), JavascriptSymbolId)>, marker: std::marker::PhantomData<&'a ()>, } +fn javascript_call_executes_known_callback_arguments( + call: &JavascriptCallExpression<'_>, + scoping: &JavascriptScoping, +) -> bool { + let unresolved_global = |identifier: &oxc_ast::ast::IdentifierReference<'_>| { + identifier + .reference_id + .get() + .is_some_and(|reference_id| scoping.get_reference(reference_id).symbol_id().is_none()) + }; + if let JavascriptExpression::Identifier(identifier) = &call.callee { + return unresolved_global(identifier) + && matches!( + identifier.name.to_ascii_lowercase().as_str(), + "addeventlistener" + | "queuemicrotask" + | "requestanimationframe" + | "setinterval" + | "settimeout" + ); + } + let Some(member) = call.callee.as_member_expression() else { + return false; + }; + let Some(name) = member.static_property_name() else { + return false; + }; + let name = name.to_ascii_lowercase(); + if matches!(name.as_str(), "catch" | "finally" | "then") { + return matches!(member.object(), JavascriptExpression::ImportExpression(_)); + } + if matches!( + name.as_str(), + "every" | "filter" | "find" | "foreach" | "map" | "reduce" | "some" + ) { + return matches!(member.object(), JavascriptExpression::ArrayExpression(_)); + } + if matches!( + name.as_str(), + "addeventlistener" | "requestanimationframe" | "setinterval" | "settimeout" + ) { + return matches!( + member.object(), + JavascriptExpression::Identifier(identifier) if unresolved_global(identifier) + ); + } + false +} + impl JavascriptFunctionInvocationCollector<'_, '_> { fn record_identifier(&mut self, identifier: &oxc_ast::ast::IdentifierReference<'_>, at: usize) { let Some(reference_id) = identifier.reference_id.get() else { @@ -1159,30 +1448,158 @@ impl JavascriptFunctionInvocationCollector<'_, '_> { } } - fn record_expression(&mut self, expression: &JavascriptExpression<'_>, at: usize) { - if let JavascriptExpression::Identifier(identifier) = expression { - self.record_identifier(identifier, at); - } else if let Some(member) = expression.as_member_expression() { - if let Some(name) = member.static_property_name() { - if let Some(indices) = self.method_ranges.get(name) { - for index in indices { - self.ranges[*index].invocations.push(at); + fn current_owner(&self, at: usize) -> Option<(usize, usize, bool)> { + self.context_ranges + .iter() + .filter(|range| range.start <= at && at < range.end) + .min_by_key(|range| range.end - range.start) + .and_then(|range| range.owner.as_ref()) + .map(|owner| { + let range = self + .context_ranges + .iter() + .filter(|range| range.start <= at && at < range.end) + .min_by_key(|range| range.end - range.start) + .expect("current owner comes from an enclosing range"); + (owner.start, owner.end, range.member_static) + }) + } + + fn receiver_owners_for_symbol( + &self, + symbol_id: JavascriptSymbolId, + at: usize, + visiting: &mut BTreeSet, + ) -> Vec<(usize, usize, bool)> { + if !visiting.insert(symbol_id) { + return Vec::new(); + } + let mut owners = Vec::new(); + if let Some(events) = self.receiver_owner_events.get(&symbol_id) { + let mut values = + javascript_alias_event_values(events, self.context_ranges, self.content, at); + if values.is_empty() { + if let Some(first) = events.iter().min_by_key(|event| event.position) { + values.push(&first.value); + } + } + for value in values { + match value { + JavascriptReceiverOwnerValue::Direct(owner) => owners.push(*owner), + JavascriptReceiverOwnerValue::Alias { + source, + force_instance, + } => { + owners.extend( + self.receiver_owners_for_symbol(*source, at, visiting) + .into_iter() + .map(|(start, end, is_static)| { + (start, end, if *force_instance { false } else { is_static }) + }), + ); } } } } + visiting.remove(&symbol_id); + owners.sort_unstable(); + owners.dedup(); + owners } - fn call_executes_callback_arguments(call: &JavascriptCallExpression<'_>) -> bool { - let callback_api = if let JavascriptExpression::Identifier(identifier) = &call.callee { - Some(identifier.name.as_str()) - } else { - call.callee - .as_member_expression() - .and_then(|member| member.static_property_name()) + fn receiver_owner( + &self, + expression: &JavascriptExpression<'_>, + at: usize, + ) -> Vec<(usize, usize, bool)> { + match expression { + JavascriptExpression::ThisExpression(_) => self.current_owner(at).into_iter().collect(), + JavascriptExpression::Super(_) => self + .current_owner(at) + .into_iter() + .flat_map(|(start, end, is_static)| { + self.super_aliases + .iter() + .filter(move |(child, _)| *child == (start, end)) + .flat_map(move |(_, parent_symbol)| { + self.receiver_owners_for_symbol( + *parent_symbol, + at, + &mut BTreeSet::new(), + ) + .into_iter() + .map( + move |(parent_start, parent_end, _)| { + (parent_start, parent_end, is_static) + }, + ) + }) + }) + .collect(), + JavascriptExpression::Identifier(identifier) => identifier + .reference_id + .get() + .and_then(|reference_id| self.scoping.get_reference(reference_id).symbol_id()) + .map(|symbol_id| { + self.receiver_owners_for_symbol(symbol_id, at, &mut BTreeSet::new()) + }) + .unwrap_or_default(), + JavascriptExpression::NewExpression(new_expression) => self + .receiver_owner(&new_expression.callee, at) + .into_iter() + .map(|(start, end, _)| (start, end, false)) + .collect(), + _ => Vec::new(), + } + } + + fn method_indices_for_member( + &self, + member: &oxc_ast::ast::MemberExpression<'_>, + at: usize, + ) -> Vec { + let Some(name) = member.static_property_name() else { + return Vec::new(); }; - callback_api.is_some_and(|name| { - matches!( + let mut indices = self + .receiver_owner(member.object(), at) + .into_iter() + .flat_map(|owner| { + self.method_ranges + .get(&(Some(owner), name.to_string())) + .into_iter() + .flatten() + .copied() + }) + .collect::>(); + indices.sort_unstable(); + indices.dedup(); + indices + } + + fn record_expression(&mut self, expression: &JavascriptExpression<'_>, at: usize) { + if let JavascriptExpression::Identifier(identifier) = expression { + self.record_identifier(identifier, at); + } else if let Some(member) = expression.as_member_expression() { + for index in self.method_indices_for_member(member, at) { + self.ranges[index].invocations.push(at); + } + } + } + + fn call_executes_callback_arguments(&self, call: &JavascriptCallExpression<'_>) -> bool { + if matches!(call.callee, JavascriptExpression::Identifier(_)) { + return javascript_call_executes_known_callback_arguments(call, self.scoping); + } + let Some(member) = call.callee.as_member_expression() else { + return false; + }; + let Some(name) = member.static_property_name() else { + return false; + }; + self.receiver_owner(member.object(), call.span.start as usize) + .is_empty() + && matches!( name.to_ascii_lowercase().as_str(), "addeventlistener" | "catch" @@ -1192,15 +1609,10 @@ impl JavascriptFunctionInvocationCollector<'_, '_> { | "find" | "foreach" | "map" - | "queuemicrotask" | "reduce" - | "requestanimationframe" - | "setinterval" - | "settimeout" | "some" | "then" ) - }) } } @@ -1211,14 +1623,15 @@ impl<'a> VisitJavascript<'a> for JavascriptFunctionInvocationCollector<'a, '_> { if let Some(bind_member) = call.callee.as_member_expression() { if bind_member.static_property_name() == Some("bind") { if let Some(target_member) = bind_member.object().as_member_expression() { - if let Some(indices) = target_member - .static_property_name() - .and_then(|name| self.method_ranges.get(name)) - { + let indices = self.method_indices_for_member( + target_member, + declarator.span.start as usize, + ); + if !indices.is_empty() { self.binding_ranges .entry(identifier.span.start as usize) .or_default() - .extend(indices.iter().copied()); + .extend(indices); } } } @@ -1231,7 +1644,7 @@ impl<'a> VisitJavascript<'a> for JavascriptFunctionInvocationCollector<'a, '_> { fn visit_call_expression(&mut self, call: &JavascriptCallExpression<'a>) { let at = call.span.start as usize; self.record_expression(&call.callee, at); - if Self::call_executes_callback_arguments(call) { + if self.call_executes_callback_arguments(call) { for argument in &call.arguments { if let Some(expression) = argument.as_expression() { self.record_expression(expression, at); @@ -1262,26 +1675,134 @@ fn named_javascript_function_ranges(content: &str) -> Vec>(); + let synthetic = javascript_unique_default_export_binding(&occupied); + for statement in &parsed.program.body { + let JavascriptStatement::ExportDefaultDeclaration(export) = statement else { + continue; + }; + let span = match &export.declaration { + JavascriptExportDefaultDeclarationKind::FunctionDeclaration(function) + if function.id.is_none() => + { + Some(function.span) + } + declaration => declaration.as_expression().and_then(|expression| { + matches!( + expression, + JavascriptExpression::FunctionExpression(_) + | JavascriptExpression::ArrowFunctionExpression(_) + ) + .then(|| expression.span()) + }), + }; + if let Some(span) = span { + definitions.push( + &synthetic, + span.start as usize, + span.end as usize, + None, + false, + ); + if let Some(range) = definitions.ranges.last_mut() { + range.externally_callable = true; + } + } + } + definitions + .ranges + .sort_by_key(|range| (range.start, range.end)); + definitions.ranges.dedup_by(|left, right| { + left.name == right.name && left.start == right.start && left.end == right.end + }); + } + let top_level_declarations = javascript_top_level_declaration_ranges(content); + let exported_declaration_ranges = export_collector + .analysis + .exports + .values() + .filter_map(|target| match target { + JavascriptExportTarget::Local(local) => top_level_declarations.get(local).cloned(), + JavascriptExportTarget::Reexport { .. } => None, + }) + .collect::>(); + for range in &mut definitions.ranges { + range.externally_callable |= exported_declaration_ranges.iter().any(|declaration| { + range + .binding_start + .is_some_and(|binding| declaration.start <= binding && binding < declaration.end) + || range.owner.as_ref().is_some_and(|owner| { + declaration.start <= owner.start && owner.end <= declaration.end + }) + }); + } let mut binding_ranges = BTreeMap::>::new(); - let mut method_ranges = BTreeMap::>::new(); + let mut method_ranges = BTreeMap::<(Option<(usize, usize, bool)>, String), Vec>::new(); for (index, range) in definitions.ranges.iter().enumerate() { if let Some(binding_start) = range.binding_start { binding_ranges.entry(binding_start).or_default().push(index); } else { method_ranges - .entry(range.name.clone()) + .entry(( + range + .owner + .as_ref() + .map(|owner| (owner.start, owner.end, range.member_static)), + range.name.clone(), + )) .or_default() .push(index); } } - let mut invocations = JavascriptFunctionInvocationCollector { + let mut receiver_owner_collector = JavascriptMethodReceiverOwnerCollector { scoping: semantic.semantic.scoping(), - ranges: &mut definitions.ranges, - binding_ranges, - method_ranges, - marker: std::marker::PhantomData, + ranges: &definitions.ranges, + content, + owner_events: BTreeMap::new(), + super_aliases: Vec::new(), }; - invocations.visit_program(&parsed.program); + receiver_owner_collector.visit_program(&parsed.program); + let (receiver_owner_events, super_aliases) = receiver_owner_collector.finish(); + let mut context_ranges = definitions.ranges.clone(); + for _ in 0..16 { + for range in &mut definitions.ranges { + range.invocations.clear(); + } + let mut invocations = JavascriptFunctionInvocationCollector { + scoping: semantic.semantic.scoping(), + content, + context_ranges: &context_ranges, + ranges: &mut definitions.ranges, + binding_ranges: binding_ranges.clone(), + method_ranges: method_ranges.clone(), + receiver_owner_events: receiver_owner_events.clone(), + super_aliases: super_aliases.clone(), + marker: std::marker::PhantomData, + }; + invocations.visit_program(&parsed.program); + for range in &mut definitions.ranges { + range.invocations.sort_unstable(); + range.invocations.dedup(); + } + let stable = definitions + .ranges + .iter() + .zip(&context_ranges) + .all(|(current, previous)| current.invocations == previous.invocations); + context_ranges = definitions.ranges.clone(); + if stable { + break; + } + } definitions.ranges } @@ -1361,6 +1882,13 @@ fn javascript_position_is_in_literal_false_block(content: &str, position: usize) } fn javascript_position_is_in_uncalled_anonymous_function(content: &str, position: usize) -> bool { + javascript_uncalled_anonymous_function_range(content, position).is_some() +} + +fn javascript_uncalled_anonymous_function_range( + content: &str, + position: usize, +) -> Option> { let mut cursor = 0usize; while let Some(offset) = content[cursor..position.min(content.len())].find("function") { let start = cursor + offset; @@ -1422,7 +1950,7 @@ fn javascript_position_is_in_uncalled_anonymous_function(content: &str, position } } if content.as_bytes().get(invocation) == Some(&b'(') { - return false; + return None; } let assignment_prefix = content[..start].trim_end(); if assignment_prefix.ends_with('=') { @@ -1437,13 +1965,13 @@ fn javascript_position_is_in_uncalled_anonymous_function(content: &str, position && content.as_bytes()[call - 1] != b'$' }) { - return false; + return None; } } } - return true; + return Some(start..body_end + 1); } - false + None } fn identifier_before(content: &str, position: usize) -> Option { @@ -3484,26 +4012,54 @@ enum JavascriptExportTarget { Reexport { source: String, imported: String }, } +const JAVASCRIPT_DEFAULT_EXPORT_BINDING: &str = "__agc_default_export__"; + +fn javascript_unique_default_export_binding(occupied: &BTreeSet) -> String { + if !occupied.contains(JAVASCRIPT_DEFAULT_EXPORT_BINDING) { + return JAVASCRIPT_DEFAULT_EXPORT_BINDING.to_string(); + } + (1_u64..) + .map(|index| format!("{JAVASCRIPT_DEFAULT_EXPORT_BINDING}_{index}")) + .find(|candidate| !occupied.contains(candidate)) + .expect("a suffixed synthetic default binding must become unique") +} + #[derive(Clone, Debug, Eq, PartialEq)] struct JavascriptImportReferenceSpan { range: std::ops::Range, shorthand: bool, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct JavascriptDynamicImportBindingReference { + binding_range: std::ops::Range, + binding_shorthand: bool, + reference_ranges: Vec>, +} + #[derive(Clone, Debug, Default, Eq, PartialEq)] -struct JavascriptModuleAnalysis { +pub(super) struct JavascriptModuleAnalysis { static_sources: Vec, import_declaration_ranges: Vec>, imports: Vec<(String, Vec<(String, String)>)>, root_bindings: BTreeSet, + binding_names: BTreeSet, used_import_locals: BTreeSet, import_reference_spans: BTreeMap>, + pub(super) import_member_calls: BTreeMap, Vec>>, namespace_import_members: BTreeMap>>>, + namespace_destructuring_initializer_ranges: Vec>, exports: BTreeMap, link_exports: BTreeMap, synthetic_declarations: BTreeMap>, star_exports: Vec, dynamic_imports: Vec<(String, usize)>, + dynamic_import_exports: BTreeMap>, + pub(super) dynamic_import_demands: + BTreeMap<(String, usize), BTreeMap<(String, Vec), Vec>>, + dynamic_import_binding_references: + BTreeMap>>, + dynamic_import_member_ranges: BTreeMap>>>, } fn javascript_module_export_name(name: &JavascriptModuleExportName<'_>) -> String { @@ -3522,6 +4078,36 @@ struct JavascriptModuleAnalysisCollector { pending_export_references: Vec<(String, JavascriptReferenceId)>, } +fn javascript_binding_pattern_names( + pattern: &JavascriptBindingPattern<'_>, + names: &mut Vec, +) { + match pattern { + JavascriptBindingPattern::BindingIdentifier(identifier) => { + names.push(identifier.name.to_string()); + } + JavascriptBindingPattern::ObjectPattern(pattern) => { + for property in &pattern.properties { + javascript_binding_pattern_names(&property.value, names); + } + if let Some(rest) = &pattern.rest { + javascript_binding_pattern_names(&rest.argument, names); + } + } + JavascriptBindingPattern::ArrayPattern(pattern) => { + for element in pattern.elements.iter().flatten() { + javascript_binding_pattern_names(element, names); + } + if let Some(rest) = &pattern.rest { + javascript_binding_pattern_names(&rest.argument, names); + } + } + JavascriptBindingPattern::AssignmentPattern(pattern) => { + javascript_binding_pattern_names(&pattern.left, names); + } + } +} + impl JavascriptModuleAnalysisCollector { fn add_source(&mut self, source: &str) { self.analysis.static_sources.push(source.to_string()); @@ -3537,26 +4123,19 @@ impl JavascriptModuleAnalysisCollector { names.extend(class.id.iter().map(|id| id.name.to_string())); } JavascriptDeclaration::VariableDeclaration(declaration) => { - names.extend(declaration.declarations.iter().filter_map(|declarator| { - if let JavascriptBindingPattern::BindingIdentifier(identifier) = &declarator.id - { - Some(identifier.name.to_string()) - } else { - None - } - })); + for declarator in &declaration.declarations { + javascript_binding_pattern_names(&declarator.id, &mut names); + } } _ => {} } for name in names { - let normalized = name.to_ascii_lowercase(); self.analysis .link_exports + .insert(name.clone(), JavascriptExportTarget::Local(name.clone())); + self.analysis + .exports .insert(name.clone(), JavascriptExportTarget::Local(name)); - self.analysis.exports.insert( - normalized.clone(), - JavascriptExportTarget::Local(normalized), - ); } } } @@ -3636,10 +4215,9 @@ impl<'a> VisitJavascript<'a> for JavascriptModuleAnalysisCollector { exported.clone(), JavascriptExportTarget::Local(local.clone()), ); - self.analysis.exports.insert( - exported.to_ascii_lowercase(), - JavascriptExportTarget::Local(local.to_ascii_lowercase()), - ); + self.analysis + .exports + .insert(exported.clone(), JavascriptExportTarget::Local(local)); if let JavascriptModuleExportName::IdentifierReference(local) = &specifier.local { if let Some(reference_id) = local.reference_id.get() { self.pending_export_references @@ -3666,7 +4244,7 @@ impl<'a> VisitJavascript<'a> for JavascriptModuleAnalysisCollector { .map(|identifier| identifier.name.to_string()), } .unwrap_or_else(|| { - let local = "__agc_default_export__".to_string(); + let local = JAVASCRIPT_DEFAULT_EXPORT_BINDING.to_string(); let span = declaration.declaration.span(); self.analysis .synthetic_declarations @@ -3677,10 +4255,9 @@ impl<'a> VisitJavascript<'a> for JavascriptModuleAnalysisCollector { "default".to_string(), JavascriptExportTarget::Local(local.clone()), ); - self.analysis.exports.insert( - "default".to_string(), - JavascriptExportTarget::Local(local.to_ascii_lowercase()), - ); + self.analysis + .exports + .insert("default".to_string(), JavascriptExportTarget::Local(local)); oxc_ast_visit::walk::walk_export_default_declaration(self, declaration); } @@ -3698,10 +4275,10 @@ impl<'a> VisitJavascript<'a> for JavascriptModuleAnalysisCollector { }, ); self.analysis.exports.insert( - exported.to_ascii_lowercase(), + exported, JavascriptExportTarget::Reexport { source: source.clone(), - imported: imported.to_ascii_lowercase(), + imported, }, ); } @@ -3720,7 +4297,7 @@ impl<'a> VisitJavascript<'a> for JavascriptModuleAnalysisCollector { }, ); self.analysis.exports.insert( - exported.to_ascii_lowercase(), + exported, JavascriptExportTarget::Reexport { source, imported: "*".to_string(), @@ -3745,6 +4322,705 @@ struct JavascriptNamespaceUsageCollector<'a> { scoping: &'a JavascriptScoping, namespaces: BTreeMap, members: BTreeMap>>>, + destructured_bindings: BTreeMap, + destructuring_ranges: Vec>, +} + +#[derive(Clone)] +struct JavascriptAliasEvent { + position: usize, + scope: Option<(usize, usize)>, + value: T, +} + +fn javascript_alias_scope_at( + ranges: &[NamedJavascriptFunctionRange], + position: usize, +) -> Option<(usize, usize)> { + ranges + .iter() + .filter(|range| range.start <= position && position < range.end) + .min_by_key(|range| range.end - range.start) + .map(|range| (range.start, range.end)) +} + +fn javascript_alias_event_indices_at( + events: &[JavascriptAliasEvent], + ranges: &[NamedJavascriptFunctionRange], + content: &str, + use_position: usize, + visiting: &mut BTreeSet<(usize, usize)>, +) -> BTreeSet { + let use_scope = javascript_alias_scope_at(ranges, use_position); + let latest_in_scope = events + .iter() + .enumerate() + .filter(|(_, event)| event.scope == use_scope && event.position <= use_position) + .map(|(index, event)| (index, event.position)) + .collect::>(); + if let Some(latest_position) = latest_in_scope.iter().map(|(_, position)| *position).max() { + return latest_in_scope + .into_iter() + .filter_map(|(index, position)| (position == latest_position).then_some(index)) + .collect(); + } + let Some(scope) = use_scope else { + return BTreeSet::new(); + }; + if !visiting.insert(scope) { + return BTreeSet::new(); + } + let mut selected = BTreeSet::new(); + if let Some(range) = ranges + .iter() + .find(|range| range.start == scope.0 && range.end == scope.1) + { + for invocation in &range.invocations { + if (range.start..range.end).contains(invocation) + || javascript_position_is_in_literal_false_block(content, *invocation) + { + continue; + } + selected.extend(javascript_alias_event_indices_at( + events, + ranges, + content, + *invocation, + visiting, + )); + } + if range.externally_callable { + selected.extend(javascript_alias_event_indices_at( + events, + ranges, + content, + content.len(), + visiting, + )); + } else if range.invocations.is_empty() { + selected.extend(javascript_alias_event_indices_at( + events, + ranges, + content, + range.start.saturating_sub(1), + visiting, + )); + } + } + visiting.remove(&scope); + selected +} + +fn javascript_alias_event_values<'a, T>( + events: &'a [JavascriptAliasEvent], + ranges: &[NamedJavascriptFunctionRange], + content: &str, + use_position: usize, +) -> Vec<&'a T> { + javascript_alias_event_indices_at(events, ranges, content, use_position, &mut BTreeSet::new()) + .into_iter() + .map(|index| &events[index].value) + .collect() +} + +struct JavascriptDynamicImportUsageCollector<'a> { + scoping: &'a JavascriptScoping, + content: &'a str, + ranges: &'a [NamedJavascriptFunctionRange], + namespace_sources: BTreeMap>>, + exports: BTreeMap>, + demands: BTreeMap<(String, usize), BTreeMap<(String, Vec), Vec>>, + bindings: BTreeMap, bool)>, + binding_references: + BTreeMap>>, + member_ranges: BTreeMap>>>, +} + +struct JavascriptImportMemberCallCollector<'a> { + scoping: &'a JavascriptScoping, + content: &'a str, + ranges: &'a [NamedJavascriptFunctionRange], + imports: BTreeMap, + namespace_imports: BTreeSet, + instance_aliases: BTreeMap, + destructured_aliases: BTreeMap, + member_aliases: BTreeMap)>>>, + members: BTreeMap, Vec>>, +} + +impl JavascriptImportMemberCallCollector<'_> { + fn record(&mut self, local: &str, path: Vec, position: usize) { + self.members + .entry(local.to_string()) + .or_default() + .entry(path) + .or_default() + .push(position); + } + + fn record_alias( + &mut self, + symbol_id: JavascriptSymbolId, + value: (String, Vec), + position: usize, + ) { + if javascript_position_is_in_literal_false_block(self.content, position) { + return; + } + self.member_aliases + .entry(symbol_id) + .or_default() + .push(JavascriptAliasEvent { + position, + scope: javascript_alias_scope_at(self.ranges, position), + value, + }); + } + + fn resolve_identifier( + &self, + identifier: &oxc_ast::ast::IdentifierReference<'_>, + position: usize, + ) -> Vec<(String, Vec)> { + let Some(symbol_id) = identifier + .reference_id + .get() + .and_then(|reference_id| self.scoping.get_reference(reference_id).symbol_id()) + else { + return Vec::new(); + }; + if let Some(events) = self.member_aliases.get(&symbol_id) { + let values = javascript_alias_event_values(events, self.ranges, self.content, position) + .into_iter() + .cloned() + .collect::>(); + if !values.is_empty() { + return values; + } + } + if let Some((local, exported)) = self.destructured_aliases.get(&symbol_id) { + return vec![(local.clone(), vec![exported.clone()])]; + } + self.imports + .get(&symbol_id) + .or_else(|| self.instance_aliases.get(&symbol_id)) + .cloned() + .map(|local| (local, Vec::new())) + .into_iter() + .collect() + } + + fn resolve_expression( + &self, + expression: &JavascriptExpression<'_>, + position: usize, + ) -> Vec<(String, Vec)> { + if let JavascriptExpression::Identifier(identifier) = expression { + return self.resolve_identifier(identifier, position); + } + if let JavascriptExpression::NewExpression(new_expression) = expression { + return self + .resolve_expression(&new_expression.callee, position) + .into_iter() + .map(|(local, mut path)| { + if let Some(marker) = path.iter_mut().find(|member| { + member.as_str() == JAVASCRIPT_STATIC_MEMBER_DEMAND + || member.as_str() == JAVASCRIPT_INSTANCE_MEMBER_DEMAND + }) { + *marker = JAVASCRIPT_INSTANCE_MEMBER_DEMAND.to_string(); + } else { + path.push(JAVASCRIPT_INSTANCE_MEMBER_DEMAND.to_string()); + } + (local, path) + }) + .collect(); + } + let Some(member) = expression.as_member_expression() else { + return Vec::new(); + }; + let Some(property) = member.static_property_name().map(str::to_string) else { + return Vec::new(); + }; + let namespace_root = match member.object() { + JavascriptExpression::Identifier(identifier) => identifier + .reference_id + .get() + .and_then(|reference_id| self.scoping.get_reference(reference_id).symbol_id()) + .is_some_and(|symbol_id| self.namespace_imports.contains(&symbol_id)), + _ => false, + }; + self.resolve_expression(member.object(), position) + .into_iter() + .map(|(local, mut path)| { + let has_receiver_kind = path.iter().any(|member| { + member == JAVASCRIPT_STATIC_MEMBER_DEMAND + || member == JAVASCRIPT_INSTANCE_MEMBER_DEMAND + }); + if !has_receiver_kind { + if namespace_root { + path.push(property.clone()); + path.push(JAVASCRIPT_STATIC_MEMBER_DEMAND.to_string()); + return (local, path); + } + path.push(JAVASCRIPT_STATIC_MEMBER_DEMAND.to_string()); + } + path.push(property.clone()); + (local, path) + }) + .collect() + } +} + +impl<'a> VisitJavascript<'a> for JavascriptImportMemberCallCollector<'_> { + fn visit_variable_declarator(&mut self, declarator: &JavascriptVariableDeclarator<'a>) { + if let (Some(local), Some(JavascriptExpression::NewExpression(initializer))) = ( + declarator.id.get_binding_identifier(), + declarator.init.as_ref(), + ) { + if let JavascriptExpression::Identifier(constructor) = &initializer.callee { + if let (Some(alias_symbol), Some(import_local)) = ( + local.symbol_id.get(), + constructor + .reference_id + .get() + .and_then(|reference_id| { + self.scoping.get_reference(reference_id).symbol_id() + }) + .and_then(|symbol_id| self.imports.get(&symbol_id)) + .cloned(), + ) { + self.instance_aliases.insert(alias_symbol, import_local); + } + } + } + if let Some(initializer) = &declarator.init { + let position = declarator.span.start as usize; + for (local, path) in self.resolve_expression(initializer, position) { + match &declarator.id { + JavascriptBindingPattern::BindingIdentifier(identifier) => { + if let Some(symbol_id) = identifier.symbol_id.get() { + self.record_alias(symbol_id, (local, path), position); + } + } + JavascriptBindingPattern::ObjectPattern(pattern) if pattern.rest.is_none() => { + for property in &pattern.properties { + let (Some(identifier), Some(member)) = ( + property.value.get_binding_identifier(), + property.key.static_name(), + ) else { + continue; + }; + if let Some(symbol_id) = identifier.symbol_id.get() { + let mut member_path = path.clone(); + member_path.push(member.to_string()); + self.record_alias( + symbol_id, + (local.clone(), member_path), + position, + ); + } + } + } + _ => {} + } + } + } + oxc_ast_visit::walk::walk_variable_declarator(self, declarator); + } + + fn visit_call_expression(&mut self, call: &JavascriptCallExpression<'a>) { + let position = call.span.start as usize; + for (local, mut path) in self.resolve_expression(&call.callee, position) { + if matches!( + path.last().map(String::as_str), + Some("bind" | "call" | "apply") + ) { + path.pop(); + } + self.record(&local, path, position); + } + if javascript_call_executes_known_callback_arguments(call, self.scoping) { + for argument in &call.arguments { + if let Some(expression) = argument.as_expression() { + for (local, path) in self.resolve_expression(expression, position) { + self.record(&local, path, position); + } + } + } + } + oxc_ast_visit::walk::walk_call_expression(self, call); + } + + fn visit_new_expression(&mut self, expression: &oxc_ast::ast::NewExpression<'a>) { + let position = expression.span.start as usize; + for (local, mut constructor_path) in self.resolve_expression(&expression.callee, position) { + if let Some(marker) = constructor_path.iter_mut().find(|member| { + member.as_str() == JAVASCRIPT_STATIC_MEMBER_DEMAND + || member.as_str() == JAVASCRIPT_INSTANCE_MEMBER_DEMAND + }) { + *marker = JAVASCRIPT_INSTANCE_MEMBER_DEMAND.to_string(); + } else { + constructor_path.push(JAVASCRIPT_INSTANCE_MEMBER_DEMAND.to_string()); + } + constructor_path.push("constructor".to_string()); + self.record(&local, constructor_path, position); + } + oxc_ast_visit::walk::walk_new_expression(self, expression); + } + + fn visit_assignment_expression(&mut self, assignment: &oxc_ast::ast::AssignmentExpression<'a>) { + if assignment.operator.is_assign() { + if let oxc_ast::ast::AssignmentTarget::AssignmentTargetIdentifier(identifier) = + &assignment.left + { + let position = assignment.span.start as usize; + if let Some(symbol_id) = identifier + .reference_id + .get() + .and_then(|reference_id| self.scoping.get_reference(reference_id).symbol_id()) + { + for alias in self.resolve_expression(&assignment.right, position) { + self.record_alias(symbol_id, alias, position); + } + } + } + } + oxc_ast_visit::walk::walk_assignment_expression(self, assignment); + } +} + +fn javascript_string_import_source( + expression: &JavascriptExpression<'_>, +) -> Option<(String, usize)> { + let JavascriptExpression::ImportExpression(import) = expression else { + return None; + }; + let JavascriptExpression::StringLiteral(source) = &import.source else { + return None; + }; + Some((source.value.to_string(), import.span.start as usize)) +} + +impl JavascriptDynamicImportUsageCollector<'_> { + fn record_namespace_source( + &mut self, + symbol_id: JavascriptSymbolId, + source: String, + import_position: usize, + assignment_position: usize, + ) { + if javascript_position_is_in_literal_false_block(self.content, assignment_position) { + return; + } + self.namespace_sources + .entry(symbol_id) + .or_default() + .push(JavascriptAliasEvent { + position: assignment_position, + scope: javascript_alias_scope_at(self.ranges, assignment_position), + value: (source, import_position), + }); + } + + fn resolve_namespace_member( + &self, + expression: &JavascriptExpression<'_>, + position: usize, + ) -> Vec<(String, usize, Vec)> { + let Some(mut member) = expression.as_member_expression() else { + return Vec::new(); + }; + let mut path = Vec::new(); + loop { + let Some(property) = member.static_property_name() else { + return Vec::new(); + }; + path.push(property.to_string()); + match member.object() { + JavascriptExpression::Identifier(identifier) => { + let Some(symbol_id) = identifier.reference_id.get().and_then(|reference_id| { + self.scoping.get_reference(reference_id).symbol_id() + }) else { + return Vec::new(); + }; + path.reverse(); + return self + .namespace_sources + .get(&symbol_id) + .into_iter() + .flat_map(|events| { + javascript_alias_event_values( + events, + self.ranges, + self.content, + position, + ) + }) + .map(|(source, import_position)| { + (source.clone(), *import_position, path.clone()) + }) + .collect(); + } + expression => { + let Some(next) = expression.as_member_expression() else { + return Vec::new(); + }; + member = next; + } + } + } + } + + fn record_pattern( + &mut self, + source: &str, + import_position: usize, + pattern: &JavascriptBindingPattern<'_>, + ) { + match pattern { + JavascriptBindingPattern::BindingIdentifier(identifier) => { + if let Some(symbol_id) = identifier.symbol_id.get() { + self.record_namespace_source( + symbol_id, + source.to_string(), + import_position, + import_position, + ); + } + } + JavascriptBindingPattern::ObjectPattern(pattern) if pattern.rest.is_none() => { + let exports = self.exports.entry(source.to_string()).or_default(); + for property in &pattern.properties { + let (Some(identifier), Some(name)) = ( + property.value.get_binding_identifier(), + property.key.static_name(), + ) else { + continue; + }; + let exported = name.to_string(); + exports.insert(exported.clone()); + self.demands + .entry((source.to_string(), import_position)) + .or_default() + .entry((exported.clone(), Vec::new())) + .or_default() + .push(import_position); + if let Some(symbol_id) = identifier.symbol_id.get() { + self.bindings.insert( + symbol_id, + ( + source.to_string(), + import_position, + exported, + identifier.span.start as usize..identifier.span.end as usize, + property.shorthand, + ), + ); + } + } + } + JavascriptBindingPattern::AssignmentPattern(pattern) => { + self.record_pattern(source, import_position, &pattern.left); + } + _ => {} + } + } +} + +impl<'a> VisitJavascript<'a> for JavascriptDynamicImportUsageCollector<'_> { + fn visit_variable_declarator(&mut self, declarator: &JavascriptVariableDeclarator<'a>) { + let source = declarator.init.as_ref().and_then(|initializer| { + let JavascriptExpression::AwaitExpression(awaited) = initializer else { + return None; + }; + javascript_string_import_source(&awaited.argument) + }); + if let Some((source, import_position)) = source { + self.record_pattern(&source, import_position, &declarator.id); + } + oxc_ast_visit::walk::walk_variable_declarator(self, declarator); + } + + fn visit_assignment_expression(&mut self, assignment: &oxc_ast::ast::AssignmentExpression<'a>) { + if assignment.operator.is_assign() { + if let ( + oxc_ast::ast::AssignmentTarget::AssignmentTargetIdentifier(identifier), + JavascriptExpression::AwaitExpression(awaited), + ) = (&assignment.left, &assignment.right) + { + if let (Some(symbol_id), Some((source, import_position))) = ( + identifier.reference_id.get().and_then(|reference_id| { + self.scoping.get_reference(reference_id).symbol_id() + }), + javascript_string_import_source(&awaited.argument), + ) { + self.record_namespace_source( + symbol_id, + source, + import_position, + assignment.span.start as usize, + ); + } + } + } + oxc_ast_visit::walk::walk_assignment_expression(self, assignment); + } + + fn visit_call_expression(&mut self, call: &JavascriptCallExpression<'a>) { + let source = match &call.callee { + JavascriptExpression::StaticMemberExpression(member) + if member.property.name == "then" => + { + javascript_string_import_source(&member.object) + } + _ => None, + }; + if let Some((source, import_position)) = source { + let pattern = call + .arguments + .first() + .and_then(JavascriptArgument::as_expression) + .and_then(|callback| match callback { + JavascriptExpression::ArrowFunctionExpression(function) => function + .params + .items + .first() + .map(|parameter| ¶meter.pattern), + JavascriptExpression::FunctionExpression(function) => function + .params + .items + .first() + .map(|parameter| ¶meter.pattern), + _ => None, + }); + if let Some(pattern) = pattern { + self.record_pattern(&source, import_position, pattern); + } + } + for (source, import_position, path) in + self.resolve_namespace_member(&call.callee, call.span.start as usize) + { + if let Some((exported, member_path)) = path.split_first() { + self.exports + .entry(source.clone()) + .or_default() + .insert(exported.clone()); + self.demands + .entry((source, import_position)) + .or_default() + .entry((exported.clone(), member_path.to_vec())) + .or_default() + .push(call.span.start as usize); + } + } + oxc_ast_visit::walk::walk_call_expression(self, call); + } + + fn visit_static_member_expression(&mut self, member: &JavascriptStaticMemberExpression<'a>) { + if let JavascriptExpression::Identifier(identifier) = &member.object { + let position = member.span.start as usize; + if let Some(events) = identifier + .reference_id + .get() + .and_then(|reference_id| self.scoping.get_reference(reference_id).symbol_id()) + .and_then(|symbol_id| self.namespace_sources.get(&symbol_id)) + { + for (source, import_position) in + javascript_alias_event_values(events, self.ranges, self.content, position) + { + self.member_ranges + .entry(source.clone()) + .or_default() + .entry(member.property.name.to_string()) + .or_default() + .push(member.span.start as usize..member.span.end as usize); + self.exports + .entry(source.clone()) + .or_default() + .insert(member.property.name.to_string()); + self.demands + .entry((source.clone(), *import_position)) + .or_default() + .entry((member.property.name.to_string(), Vec::new())) + .or_default() + .push(member.span.start as usize); + } + } + } + oxc_ast_visit::walk::walk_static_member_expression(self, member); + } + + fn visit_computed_member_expression( + &mut self, + member: &JavascriptComputedMemberExpression<'a>, + ) { + if let (JavascriptExpression::Identifier(identifier), Some(property)) = + (&member.object, member.static_property_name()) + { + let position = member.span.start as usize; + if let Some(events) = identifier + .reference_id + .get() + .and_then(|reference_id| self.scoping.get_reference(reference_id).symbol_id()) + .and_then(|symbol_id| self.namespace_sources.get(&symbol_id)) + { + for (source, import_position) in + javascript_alias_event_values(events, self.ranges, self.content, position) + { + self.member_ranges + .entry(source.clone()) + .or_default() + .entry(property.to_string()) + .or_default() + .push(member.span.start as usize..member.span.end as usize); + self.exports + .entry(source.clone()) + .or_default() + .insert(property.to_string()); + self.demands + .entry((source.clone(), *import_position)) + .or_default() + .entry((property.to_string(), Vec::new())) + .or_default() + .push(member.span.start as usize); + } + } + } + oxc_ast_visit::walk::walk_computed_member_expression(self, member); + } + + fn visit_identifier_reference(&mut self, identifier: &oxc_ast::ast::IdentifierReference<'a>) { + if let Some((source, _, exported, binding_range, binding_shorthand)) = identifier + .reference_id + .get() + .and_then(|reference_id| self.scoping.get_reference(reference_id).symbol_id()) + .and_then(|symbol_id| self.bindings.get(&symbol_id)) + { + let references = self + .binding_references + .entry(source.clone()) + .or_default() + .entry(exported.clone()) + .or_default(); + if let Some(binding) = references.iter_mut().find(|binding| { + binding.binding_range == *binding_range + && binding.binding_shorthand == *binding_shorthand + }) { + binding + .reference_ranges + .push(identifier.span.start as usize..identifier.span.end as usize); + } else { + references.push(JavascriptDynamicImportBindingReference { + binding_range: binding_range.clone(), + binding_shorthand: *binding_shorthand, + reference_ranges: vec![ + identifier.span.start as usize..identifier.span.end as usize, + ], + }); + } + } + oxc_ast_visit::walk::walk_identifier_reference(self, identifier); + } } impl JavascriptNamespaceUsageCollector<'_> { @@ -3776,6 +5052,50 @@ impl JavascriptNamespaceUsageCollector<'_> { } impl<'a> VisitJavascript<'a> for JavascriptNamespaceUsageCollector<'_> { + fn visit_variable_declarator(&mut self, declarator: &JavascriptVariableDeclarator<'a>) { + let destructured = declarator + .init + .as_ref() + .and_then(JavascriptExpression::get_identifier_reference) + .and_then(|identifier| { + let namespace = identifier + .reference_id + .get() + .and_then(|reference_id| self.scoping.get_reference(reference_id).symbol_id()) + .and_then(|symbol_id| self.namespaces.get(&symbol_id).cloned())?; + let JavascriptBindingPattern::ObjectPattern(pattern) = &declarator.id else { + return None; + }; + if pattern.rest.is_some() { + return None; + } + let bindings = pattern + .properties + .iter() + .filter_map(|property| { + Some(( + property.value.get_binding_identifier()?.symbol_id.get()?, + property.key.static_name()?.to_string(), + )) + }) + .collect::>(); + (bindings.len() == pattern.properties.len()).then_some(( + namespace, + bindings, + identifier.span.start as usize..identifier.span.end as usize, + )) + }); + if let Some((namespace, bindings, initializer_range)) = destructured { + self.destructuring_ranges.push(initializer_range); + self.destructured_bindings.extend( + bindings + .into_iter() + .map(|(symbol_id, member)| (symbol_id, (namespace.clone(), member))), + ); + } + oxc_ast_visit::walk::walk_variable_declarator(self, declarator); + } + fn visit_static_member_expression(&mut self, member: &JavascriptStaticMemberExpression<'a>) { self.record( &member.object, @@ -3798,12 +5118,42 @@ impl<'a> VisitJavascript<'a> for JavascriptNamespaceUsageCollector<'_> { } } +struct JavascriptNamespaceDestructuredReferenceCollector<'a> { + scoping: &'a JavascriptScoping, + bindings: BTreeMap, + members: BTreeMap>>>, +} + +impl<'a> VisitJavascript<'a> for JavascriptNamespaceDestructuredReferenceCollector<'_> { + fn visit_identifier_reference(&mut self, identifier: &oxc_ast::ast::IdentifierReference<'a>) { + if let Some((namespace, member)) = identifier + .reference_id + .get() + .and_then(|reference_id| self.scoping.get_reference(reference_id).symbol_id()) + .and_then(|symbol_id| self.bindings.get(&symbol_id)) + { + self.members + .entry(namespace.clone()) + .or_default() + .entry(member.clone()) + .or_default() + .push(identifier.span.start as usize..identifier.span.end as usize); + } + oxc_ast_visit::walk::walk_identifier_reference(self, identifier); + } +} + struct JavascriptShorthandReferenceCollector<'a> { scoping: &'a JavascriptScoping, symbol_id: JavascriptSymbolId, ranges: BTreeSet<(usize, usize)>, } +struct JavascriptShorthandBindingCollector { + symbol_id: JavascriptSymbolId, + ranges: BTreeSet<(usize, usize)>, +} + struct JavascriptSymbolReferenceCollector<'a> { scoping: &'a JavascriptScoping, symbol_id: JavascriptSymbolId, @@ -3866,7 +5216,24 @@ impl<'a> VisitJavascript<'a> for JavascriptShorthandReferenceCollector<'_> { } } -fn javascript_module_analysis(content: &str, is_module: bool) -> Option { +impl<'a> VisitJavascript<'a> for JavascriptShorthandBindingCollector { + fn visit_binding_property(&mut self, property: &JavascriptBindingProperty<'a>) { + if property.shorthand { + if let Some(identifier) = property.value.get_binding_identifier() { + if identifier.symbol_id.get() == Some(self.symbol_id) { + self.ranges + .insert((identifier.span.start as usize, identifier.span.end as usize)); + } + } + } + oxc_ast_visit::walk::walk_binding_property(self, property); + } +} + +pub(super) fn javascript_module_analysis( + content: &str, + is_module: bool, +) -> Option { let allocator = JavascriptAllocator::default(); let parsed = JavascriptParser::new(&allocator, content, javascript_source_type(is_module)).parse(); @@ -3879,12 +5246,64 @@ fn javascript_module_analysis(content: &str, is_module: bool) -> Option>(); + let mut occupied = root_names; + let mut synthetic_declarations = BTreeMap::new(); + for (synthetic, range) in std::mem::take(&mut collector.analysis.synthetic_declarations) { + let replacement = if occupied.contains(&synthetic) { + (1_u64..) + .map(|index| format!("{synthetic}_{index}")) + .find(|candidate| !occupied.contains(candidate)) + .expect("a suffixed synthetic default binding must become unique") + } else { + synthetic.clone() + }; + occupied.insert(replacement.clone()); + if replacement != synthetic { + if let Some(JavascriptExportTarget::Local(local)) = + collector.analysis.exports.get_mut("default") + { + if local == &synthetic { + *local = replacement.clone(); + } + } + if let Some(JavascriptExportTarget::Local(local)) = + collector.analysis.link_exports.get_mut("default") + { + if local == &synthetic { + *local = replacement.clone(); + } + } + } + synthetic_declarations.insert(replacement, range); + } + collector.analysis.synthetic_declarations = synthetic_declarations; + } + let import_symbol_ids = collector + .import_symbols + .iter() + .map(|(_, symbol_id)| *symbol_id) + .collect::>(); collector.analysis.root_bindings = semantic .semantic .scoping() .get_bindings(semantic.semantic.scoping().root_scope_id()) - .keys() - .map(|name| name.to_ascii_lowercase()) + .iter() + .filter(|(_, symbol_id)| !import_symbol_ids.contains(symbol_id)) + .map(|(name, _)| name.to_string()) + .collect(); + collector.analysis.binding_names = semantic + .semantic + .scoping() + .symbol_names() + .map(str::to_string) .collect(); let mut namespace_usage = JavascriptNamespaceUsageCollector { scoping: semantic.semantic.scoping(), @@ -3894,9 +5313,56 @@ fn javascript_module_analysis(content: &str, is_module: bool) -> Option Option>(); + let mut import_member_calls = JavascriptImportMemberCallCollector { + scoping: semantic.semantic.scoping(), + content, + ranges: &alias_ranges, + imports: collector + .import_symbols + .iter() + .map(|(local, symbol_id)| (*symbol_id, local.clone())) + .collect(), + namespace_imports: collector + .import_symbols + .iter() + .filter(|(local, _)| namespace_import_locals.contains(local.as_str())) + .map(|(_, symbol_id)| *symbol_id) + .collect(), + instance_aliases: BTreeMap::new(), + destructured_aliases: namespace_destructured_bindings, + member_aliases: BTreeMap::new(), + members: BTreeMap::new(), + }; + import_member_calls.visit_program(&parsed.program); + import_member_calls.members.clear(); + import_member_calls.visit_program(&parsed.program); + for members in import_member_calls.members.values_mut() { + for positions in members.values_mut() { + positions.sort_unstable(); + positions.dedup(); + } + } + collector.analysis.import_member_calls = import_member_calls.members; for (exported, reference_id) in &collector.pending_export_references { let reference = semantic.semantic.scoping().get_reference(*reference_id); let Some(symbol_id) = reference.symbol_id() else { @@ -3935,10 +5439,10 @@ fn javascript_module_analysis(content: &str, is_module: bool) -> Option Option bool { + javascript_position_is_reachable(content, ranges, position) +} + +fn javascript_named_function_is_reachable_from_roots( + content: &str, + ranges: &[NamedJavascriptFunctionRange], + function_index: usize, + roots: &BTreeSet, + visiting: &mut BTreeSet, +) -> bool { + if roots.contains(&function_index) { + return true; + } + if !visiting.insert(function_index) { + return false; + } + let definition = &ranges[function_index]; + for call in &definition.invocations { + if (definition.start..definition.end).contains(call) + || javascript_position_is_in_literal_false_block(content, *call) + { + continue; + } + let parent = ranges + .iter() + .enumerate() + .filter(|(_, range)| (range.start..range.end).contains(call)) + .min_by_key(|(_, range)| range.end - range.start) + .map(|(index, _)| index); + if parent.is_none_or(|index| { + javascript_named_function_is_reachable_from_roots( + content, ranges, index, roots, visiting, + ) + }) { + visiting.remove(&function_index); + return true; + } + } + visiting.remove(&function_index); + false +} + +fn javascript_projected_dynamic_import_position_is_reachable( + content: &str, + ranges: &[NamedJavascriptFunctionRange], + roots: &BTreeSet, + position: usize, +) -> bool { + if javascript_position_is_in_literal_false_block(content, position) { + return false; + } + let enclosing = ranges + .iter() + .enumerate() + .filter(|(_, range)| range.start <= position && position < range.end) + .min_by_key(|(_, range)| range.end - range.start) + .map(|(index, _)| index); + if javascript_uncalled_anonymous_function_range(content, position).is_some_and(|anonymous| { + enclosing.is_none_or(|index| { + !roots.contains(&index) + || ranges[index].start != anonymous.start + || ranges[index].end != anonymous.end + }) + }) { + return false; + } + enclosing.is_none_or(|index| { + javascript_named_function_is_reachable_from_roots( + content, + ranges, + index, + roots, + &mut BTreeSet::new(), + ) + }) +} + +const JAVASCRIPT_STATIC_MEMBER_DEMAND: &str = "\0agc-static-member"; +const JAVASCRIPT_INSTANCE_MEMBER_DEMAND: &str = "\0agc-instance-member"; + +fn javascript_projected_root_function_indices( + content: &str, + ranges: &[NamedJavascriptFunctionRange], + root_binding: &str, + member_path: &[String], +) -> BTreeSet { + let Some(declaration_range) = + javascript_top_level_declaration_ranges(content).remove(root_binding) + else { + return BTreeSet::new(); + }; + if !member_path.is_empty() { + let (required_static, actual_member_path) = match member_path.first().map(String::as_str) { + Some(JAVASCRIPT_STATIC_MEMBER_DEMAND) => (Some(true), &member_path[1..]), + Some(JAVASCRIPT_INSTANCE_MEMBER_DEMAND) => (Some(false), &member_path[1..]), + _ => (None, member_path), + }; + let methods = javascript_member_method_ranges(content); + return methods + .get(actual_member_path) + .into_iter() + .flat_map(|method_ranges| method_ranges.iter()) + .filter(|method| { + declaration_range.start <= method.range.start + && method.range.end <= declaration_range.end + && required_static.is_none_or(|required| { + method + .is_static + .is_none_or(|is_static| is_static == required) + }) + }) + .flat_map(|method| { + ranges + .iter() + .enumerate() + .filter(move |(_, range)| { + method.range.start == range.start && range.end == method.range.end + }) + .map(|(index, _)| index) + }) + .collect(); + } + let top_level = ranges + .iter() + .enumerate() + .filter(|(_, range)| { + declaration_range.start <= range.start && range.end <= declaration_range.end + }) + .filter(|(index, range)| { + !ranges.iter().enumerate().any(|(parent_index, parent)| { + parent_index != *index + && declaration_range.start <= parent.start + && parent.end <= declaration_range.end + && parent.start <= range.start + && range.end <= parent.end + && (parent.start, parent.end) != (range.start, range.end) + }) + }) + .map(|(index, _)| index) + .collect::>(); + top_level + .iter() + .copied() + .filter(|index| ranges[*index].name == root_binding) + .collect() +} + +#[derive(Default)] +struct JavascriptMemberMethodRangeCollector { + path: Vec, + methods: BTreeMap, Vec>, +} + +struct JavascriptMemberMethodRange { + range: std::ops::Range, + is_static: Option, +} + +impl<'a> VisitJavascript<'a> for JavascriptMemberMethodRangeCollector { + fn visit_object_property(&mut self, property: &JavascriptObjectProperty<'a>) { + let name = property.key.static_name().map(|name| name.to_string()); + if let Some(name) = name { + self.path.push(name); + if property.method { + self.methods.entry(self.path.clone()).or_default().push( + JavascriptMemberMethodRange { + range: property.span.start as usize..property.span.end as usize, + is_static: None, + }, + ); + } else if matches!( + property.value, + JavascriptExpression::FunctionExpression(_) + | JavascriptExpression::ArrowFunctionExpression(_) + ) { + let span = property.value.span(); + self.methods.entry(self.path.clone()).or_default().push( + JavascriptMemberMethodRange { + range: span.start as usize..span.end as usize, + is_static: None, + }, + ); + } + oxc_ast_visit::walk::walk_object_property(self, property); + self.path.pop(); + } else { + oxc_ast_visit::walk::walk_object_property(self, property); + } + } + + fn visit_property_definition(&mut self, property: &JavascriptPropertyDefinition<'a>) { + if let Some(name) = property.key.static_name() { + self.path.push(name.to_string()); + if property.value.as_ref().is_some_and(|value| { + matches!( + value, + JavascriptExpression::FunctionExpression(_) + | JavascriptExpression::ArrowFunctionExpression(_) + ) + }) { + let span = property + .value + .as_ref() + .expect("checked function value") + .span(); + self.methods.entry(self.path.clone()).or_default().push( + JavascriptMemberMethodRange { + range: span.start as usize..span.end as usize, + is_static: Some(property.r#static), + }, + ); + } + oxc_ast_visit::walk::walk_property_definition(self, property); + self.path.pop(); + } else { + oxc_ast_visit::walk::walk_property_definition(self, property); + } + } + + fn visit_method_definition(&mut self, method: &JavascriptMethodDefinition<'a>) { + if let Some(name) = method.key.static_name() { + self.path.push(name.to_string()); + self.methods + .entry(self.path.clone()) + .or_default() + .push(JavascriptMemberMethodRange { + range: method.span.start as usize..method.span.end as usize, + is_static: Some(method.r#static), + }); + oxc_ast_visit::walk::walk_method_definition(self, method); + self.path.pop(); + } else { + oxc_ast_visit::walk::walk_method_definition(self, method); + } + } +} + +fn javascript_member_method_ranges( + content: &str, +) -> BTreeMap, Vec> { + let allocator = JavascriptAllocator::default(); + let parsed = JavascriptParser::new(&allocator, content, javascript_source_type(true)).parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { + return BTreeMap::new(); + } + let mut collector = JavascriptMemberMethodRangeCollector::default(); + collector.visit_program(&parsed.program); + collector.methods +} + fn reachable_local_javascript_module_sources( content: &str, allow_static_imports: bool, @@ -3964,76 +5723,189 @@ fn reachable_local_javascript_module_sources( }; let ranges = named_javascript_function_ranges(content); let mut sources = if allow_static_imports { - analysis.static_sources + analysis.static_sources.clone() } else { Vec::new() }; sources.extend( analysis .dynamic_imports - .into_iter() + .iter() .filter_map(|(source, position)| { - javascript_position_is_reachable(content, &ranges, position).then_some(source) + javascript_dynamic_import_position_is_reachable(content, &ranges, *position) + .then(|| source.clone()) }), ); sources } -fn javascript_top_level_declarations(content: &str) -> std::collections::BTreeMap { - let ranges = named_javascript_function_ranges(content); - let mut declarations = std::collections::BTreeMap::new(); - for range in &ranges { - if !ranges - .iter() - .any(|outer| outer.start < range.start && range.end <= outer.end) - { - declarations.insert( - range.name.clone(), - content[range.start..range.end].to_string(), +fn insert_javascript_top_level_declaration( + declarations: &mut BTreeMap, + content: &str, + declaration: &JavascriptDeclaration<'_>, + span: oxc_span::Span, +) { + let names = javascript_declaration_binding_names(declaration); + let Some(source) = content.get(span.start as usize..span.end as usize) else { + return; + }; + for name in names { + declarations.insert(name, source.to_string()); + } +} + +fn javascript_declaration_binding_names(declaration: &JavascriptDeclaration<'_>) -> Vec { + match declaration { + JavascriptDeclaration::FunctionDeclaration(function) => { + function.id.iter().map(|id| id.name.to_string()).collect() + } + JavascriptDeclaration::ClassDeclaration(class) => { + class.id.iter().map(|id| id.name.to_string()).collect() + } + JavascriptDeclaration::VariableDeclaration(declaration) => { + let mut names = Vec::new(); + for declarator in &declaration.declarations { + javascript_binding_pattern_names(&declarator.id, &mut names); + } + names + } + _ => Vec::new(), + } +} + +fn insert_javascript_top_level_declaration_range( + ranges: &mut BTreeMap>, + declaration: &JavascriptDeclaration<'_>, + span: oxc_span::Span, +) { + for name in javascript_declaration_binding_names(declaration) { + ranges.insert(name, span.start as usize..span.end as usize); + } +} + +fn javascript_top_level_declaration_ranges( + content: &str, +) -> BTreeMap> { + let mut ranges = BTreeMap::new(); + let mut anonymous_default = None; + let allocator = JavascriptAllocator::default(); + let parsed = JavascriptParser::new(&allocator, content, javascript_source_type(true)).parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { + return ranges; + } + let semantic = JavascriptSemanticBuilder::new_compiler().build(&parsed.program); + if !semantic.diagnostics.is_empty() { + return ranges; + } + for statement in &parsed.program.body { + if let Some(declaration) = statement.as_declaration() { + insert_javascript_top_level_declaration_range( + &mut ranges, + declaration, + declaration.span(), ); + continue; + } + match statement { + JavascriptStatement::ExportDeclaration(export) => { + insert_javascript_top_level_declaration_range( + &mut ranges, + &export.declaration, + export.declaration.span(), + ); + } + JavascriptStatement::ExportDefaultDeclaration(export) => match &export.declaration { + JavascriptExportDefaultDeclarationKind::FunctionDeclaration(function) => { + if let Some(id) = &function.id { + ranges.insert( + id.name.to_string(), + function.span.start as usize..function.span.end as usize, + ); + } else { + anonymous_default = + Some(function.span.start as usize..function.span.end as usize); + } + } + JavascriptExportDefaultDeclarationKind::ClassDeclaration(class) => { + if let Some(id) = &class.id { + ranges.insert( + id.name.to_string(), + class.span.start as usize..class.span.end as usize, + ); + } else { + anonymous_default = + Some(class.span.start as usize..class.span.end as usize); + } + } + declaration => { + let span = declaration.span(); + anonymous_default = Some(span.start as usize..span.end as usize); + } + }, + _ => {} } } - for keyword in ["const", "let", "var"] { - let mut cursor = 0usize; - while let Some(offset) = content[cursor..].find(keyword) { - let start = cursor + offset; - cursor = start + keyword.len(); - if start > 0 - && (is_ascii_word_byte(content.as_bytes()[start - 1]) - || content.as_bytes()[start - 1] == b'$') - || content - .as_bytes() - .get(cursor) - .is_some_and(|byte| is_ascii_word_byte(*byte) || *byte == b'$') - || position_is_inside_javascript_string(content, start) - { - continue; + if let Some(range) = anonymous_default { + let occupied = semantic + .semantic + .scoping() + .get_bindings(semantic.semantic.scoping().root_scope_id()) + .keys() + .map(|name| name.to_string()) + .collect::>(); + ranges.insert(javascript_unique_default_export_binding(&occupied), range); + } + ranges +} + +fn javascript_top_level_declarations(content: &str) -> std::collections::BTreeMap { + let mut declarations = std::collections::BTreeMap::new(); + let allocator = JavascriptAllocator::default(); + let parsed = JavascriptParser::new(&allocator, content, javascript_source_type(true)).parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { + return declarations; + } + for statement in &parsed.program.body { + if let Some(declaration) = statement.as_declaration() { + insert_javascript_top_level_declaration( + &mut declarations, + content, + declaration, + declaration.span(), + ); + continue; + } + match statement { + JavascriptStatement::ExportDeclaration(export) => { + insert_javascript_top_level_declaration( + &mut declarations, + content, + &export.declaration, + export.declaration.span(), + ); } - while content - .as_bytes() - .get(cursor) - .is_some_and(u8::is_ascii_whitespace) - { - cursor += 1; - } - let name_start = cursor; - while content - .as_bytes() - .get(cursor) - .is_some_and(|byte| is_ascii_word_byte(*byte) || *byte == b'$') - { - cursor += 1; - } - if cursor == name_start { - continue; - } - let name = content[name_start..cursor].to_string(); - let end = content[cursor..] - .find(';') - .map(|offset| cursor + offset + 1) - .or_else(|| content[cursor..].find('\n').map(|offset| cursor + offset)) - .unwrap_or(content.len()); - declarations.insert(name, content[start..end].to_string()); + JavascriptStatement::ExportDefaultDeclaration(export) => match &export.declaration { + JavascriptExportDefaultDeclarationKind::FunctionDeclaration(function) => { + if let Some(id) = &function.id { + if let Some(source) = + content.get(function.span.start as usize..function.span.end as usize) + { + declarations.insert(id.name.to_string(), source.to_string()); + } + } + } + JavascriptExportDefaultDeclarationKind::ClassDeclaration(class) => { + if let Some(id) = &class.id { + if let Some(source) = + content.get(class.span.start as usize..class.span.end as usize) + { + declarations.insert(id.name.to_string(), source.to_string()); + } + } + } + _ => {} + }, + _ => {} } } declarations @@ -4065,6 +5937,7 @@ pub(in crate::agent) fn javascript_module_binding_projection( }) .collect::>(); let mut included = BTreeSet::new(); + let mut included_declarations = BTreeSet::new(); let mut projection = String::new(); while let Some(name) = pending.pop() { if !included.insert(name.clone()) { @@ -4073,6 +5946,9 @@ pub(in crate::agent) fn javascript_module_binding_projection( let Some(declaration) = declarations.get(&name) else { continue; }; + if !included_declarations.insert(declaration.clone()) { + continue; + } projection.push_str(declaration); projection.push('\n'); let allocator = JavascriptAllocator::default(); @@ -4091,9 +5967,16 @@ pub(in crate::agent) fn javascript_module_binding_projection( .root_unresolved_references() .keys() { - let dependency = dependency.as_str(); - if declarations.contains_key(dependency) && !included.contains(dependency) { - pending.push(dependency.to_string()); + let dependency = dependency.as_str().to_string(); + if declarations.contains_key(&dependency) && !included.contains(&dependency) { + pending.push(dependency); + } + } + if let Some(dynamic_analysis) = javascript_module_analysis(declaration, true) { + for dependency in dynamic_analysis.dynamic_import_exports.values().flatten() { + if declarations.contains_key(dependency) && !included.contains(dependency) { + pending.push(dependency.clone()); + } } } } @@ -4125,7 +6008,7 @@ fn unique_javascript_projection_binding_name( assigned: &BTreeSet, remaining_projection: &BTreeSet, ) -> String { - let preferred = preferred.to_ascii_lowercase(); + let preferred = preferred.to_string(); if !assigned.contains(&preferred) && !remaining_projection.contains(&preferred) { return preferred; } @@ -4164,18 +6047,31 @@ pub(in crate::agent) fn rename_javascript_root_binding( ranges: BTreeSet::new(), }; shorthand_references.visit_program(&parsed.program); + let mut shorthand_bindings = JavascriptShorthandBindingCollector { + symbol_id, + ranges: BTreeSet::new(), + }; + shorthand_bindings.visit_program(&parsed.program); let mut symbol_references = JavascriptSymbolReferenceCollector { scoping: semantic.semantic.scoping(), symbol_id, ranges: BTreeSet::new(), }; symbol_references.visit_program(&parsed.program); - let mut replacements = vec![( + let binding_replacement = |range: std::ops::Range| { + let replacement = if shorthand_bindings + .ranges + .contains(&(range.start, range.end)) { - let span = semantic.semantic.scoping().symbol_span(symbol_id); - span.start as usize..span.end as usize - }, - to.to_string(), + format!("{from}: {to}") + } else { + to.to_string() + }; + (range, replacement) + }; + let symbol_span = semantic.semantic.scoping().symbol_span(symbol_id); + let mut replacements = vec![binding_replacement( + symbol_span.start as usize..symbol_span.end as usize, )]; replacements.extend( semantic @@ -4184,9 +6080,8 @@ pub(in crate::agent) fn rename_javascript_root_binding( .symbol_redeclarations(symbol_id) .iter() .map(|redeclaration| { - ( + binding_replacement( redeclaration.span.start as usize..redeclaration.span.end as usize, - to.to_string(), ) }), ); @@ -4219,8 +6114,13 @@ pub(in crate::agent) struct ExternalGameplayJavascript { impl ExternalGameplayJavascript { fn contains(&self, marker: &str) -> bool { - self.classic_global.contains(marker) - || self.module_units.iter().any(|unit| unit.contains(marker)) + self.classic_global + .to_ascii_lowercase() + .contains(&marker.to_ascii_lowercase()) + || self.module_units.iter().any(|unit| { + unit.to_ascii_lowercase() + .contains(&marker.to_ascii_lowercase()) + }) } #[cfg(test)] @@ -4256,6 +6156,56 @@ fn normalize_javascript_module_analysis_sources( for source in &mut analysis.star_exports { *source = normalize(source)?; } + for (source, _) in &mut analysis.dynamic_imports { + *source = normalize(source)?; + } + let mut dynamic_import_exports = BTreeMap::new(); + for (source, exports) in std::mem::take(&mut analysis.dynamic_import_exports) { + dynamic_import_exports + .entry(normalize(&source)?) + .or_insert_with(BTreeSet::new) + .extend(exports); + } + analysis.dynamic_import_exports = dynamic_import_exports; + let mut dynamic_import_demands = BTreeMap::new(); + for ((source, position), demands) in std::mem::take(&mut analysis.dynamic_import_demands) { + let normalized_demands = dynamic_import_demands + .entry((normalize(&source)?, position)) + .or_insert_with(BTreeMap::new); + for (demand, positions) in demands { + normalized_demands + .entry(demand) + .or_insert_with(Vec::new) + .extend(positions); + } + } + analysis.dynamic_import_demands = dynamic_import_demands; + let mut dynamic_import_binding_references = BTreeMap::new(); + for (source, exports) in std::mem::take(&mut analysis.dynamic_import_binding_references) { + let normalized_exports = dynamic_import_binding_references + .entry(normalize(&source)?) + .or_insert_with(BTreeMap::new); + for (exported, references) in exports { + normalized_exports + .entry(exported) + .or_insert_with(Vec::new) + .extend(references); + } + } + analysis.dynamic_import_binding_references = dynamic_import_binding_references; + let mut dynamic_import_member_ranges = BTreeMap::new(); + for (source, exports) in std::mem::take(&mut analysis.dynamic_import_member_ranges) { + let normalized_exports = dynamic_import_member_ranges + .entry(normalize(&source)?) + .or_insert_with(BTreeMap::new); + for (exported, ranges) in exports { + normalized_exports + .entry(exported) + .or_insert_with(Vec::new) + .extend(ranges); + } + } + analysis.dynamic_import_member_ranges = dynamic_import_member_ranges; Ok(analysis) } @@ -4354,12 +6304,34 @@ fn validate_javascript_module_links( Ok(()) } +fn javascript_reachable_dynamic_import_demands( + analysis: &JavascriptModuleAnalysis, + mut reachable: impl FnMut(usize) -> bool, +) -> BTreeMap)>> { + let mut reachable_demands = BTreeMap::)>>::new(); + for ((source, import_position), demands) in &analysis.dynamic_import_demands { + if !reachable(*import_position) { + continue; + } + for (demand, positions) in demands { + if positions.iter().any(|position| reachable(*position)) { + reachable_demands + .entry(source.clone()) + .or_default() + .insert(demand.clone()); + } + } + } + reachable_demands +} + pub(in crate::agent) fn read_external_gameplay_javascript_at( root: &Path, html: &str, ) -> Result { const MAX_EXTERNAL_GAMEPLAY_JAVASCRIPT_BYTES: u64 = 2 * 1024 * 1024; const MAX_EXTERNAL_GAMEPLAY_JAVASCRIPT_FILES: usize = 256; + const MAX_EXTERNAL_GAMEPLAY_JAVASCRIPT_PROJECTION_WORK_BYTES: usize = 32 * 1024 * 1024; if html_has_effective_base_href(html) { return Err("自主构建 Tetris 静态门不允许改变 game/index.html 的 base URL".to_string()); } @@ -4370,6 +6342,8 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( let mut module_analyses = BTreeMap::::new(); let mut module_bindings = std::collections::BTreeMap::)>>::new(); + let mut module_dynamic_dependencies = + BTreeMap::)>>>::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))?; @@ -4394,7 +6368,14 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( let analysis = javascript_module_analysis(&inline_module, true) .ok_or_else(|| format!("自主构建内联模块不是有效 JavaScript:{inline_id}"))?; let analysis = normalize_javascript_module_analysis_sources("game/index.html", analysis)?; - module_contents.insert(inline_id.clone(), inline_module.to_ascii_lowercase()); + let ranges = named_javascript_function_ranges(&inline_module); + module_dynamic_dependencies.insert( + inline_id.clone(), + javascript_reachable_dynamic_import_demands(&analysis, |position| { + javascript_dynamic_import_position_is_reachable(&inline_module, &ranges, position) + }), + ); + module_contents.insert(inline_id.clone(), inline_module.clone()); module_bindings.insert(inline_id.clone(), analysis.imports.clone()); module_analyses.insert(inline_id.clone(), analysis); for imported_source in reachable_local_javascript_module_sources(&inline_module, true) { @@ -4409,269 +6390,656 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( } } let mut visited = BTreeSet::new(); - while let Some((local_path, is_module)) = pending.pop_front() { - if !visited.insert((local_path.clone(), is_module)) { - continue; - } - if visited.len() > MAX_EXTERNAL_GAMEPLAY_JAVASCRIPT_FILES { - return Err("自主构建外部脚本累计超过 256 个文件".to_string()); - } - let path = resolve_local_project_path(root, &local_path)?; - let metadata = fs::symlink_metadata(&path) - .map_err(|error| format!("读取自主构建外部脚本元数据失败:{local_path}: {error}"))?; - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err(format!("自主构建外部脚本不是可信普通文件:{local_path}")); - } - let remaining = MAX_EXTERNAL_GAMEPLAY_JAVASCRIPT_BYTES.saturating_sub(total_bytes); - if metadata.len() > remaining { - return Err("自主构建外部脚本累计超过 2 MiB".to_string()); - } - let mut options = fs::OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NOFOLLOW); - } - let mut file = options - .open(&path) - .map_err(|error| format!("打开自主构建外部脚本失败:{local_path}: {error}"))?; - let mut script = String::new(); - (&mut file) - .take(remaining.saturating_add(1)) - .read_to_string(&mut script) - .map_err(|error| format!("读取自主构建外部脚本失败:{local_path}: {error}"))?; - let script_bytes = u64::try_from(script.len()).unwrap_or(u64::MAX); - if script_bytes > remaining { - return Err("自主构建外部脚本累计超过 2 MiB".to_string()); - } - total_bytes += script_bytes; - if !javascript_is_syntactically_valid(&script, is_module) { - if is_module { - return Err(format!("自主构建外部脚本不是有效 JavaScript:{local_path}")); + loop { + while let Some((local_path, is_module)) = pending.pop_front() { + if !visited.insert((local_path.clone(), is_module)) { + continue; + } + if visited.len() > MAX_EXTERNAL_GAMEPLAY_JAVASCRIPT_FILES { + return Err("自主构建外部脚本累计超过 256 个文件".to_string()); + } + let path = resolve_local_project_path(root, &local_path)?; + let metadata = fs::symlink_metadata(&path).map_err(|error| { + format!("读取自主构建外部脚本元数据失败:{local_path}: {error}") + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!("自主构建外部脚本不是可信普通文件:{local_path}")); + } + let remaining = MAX_EXTERNAL_GAMEPLAY_JAVASCRIPT_BYTES.saturating_sub(total_bytes); + if metadata.len() > remaining { + return Err("自主构建外部脚本累计超过 2 MiB".to_string()); + } + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let mut file = options + .open(&path) + .map_err(|error| format!("打开自主构建外部脚本失败:{local_path}: {error}"))?; + let mut script = String::new(); + (&mut file) + .take(remaining.saturating_add(1)) + .read_to_string(&mut script) + .map_err(|error| format!("读取自主构建外部脚本失败:{local_path}: {error}"))?; + let script_bytes = u64::try_from(script.len()).unwrap_or(u64::MAX); + if script_bytes > remaining { + return Err("自主构建外部脚本累计超过 2 MiB".to_string()); + } + total_bytes += script_bytes; + if !javascript_is_syntactically_valid(&script, is_module) { + if is_module { + return Err(format!("自主构建外部脚本不是有效 JavaScript:{local_path}")); + } + continue; + } + if is_module { + let analysis = javascript_module_analysis(&script, true) + .ok_or_else(|| format!("自主构建外部模块不是有效 JavaScript:{local_path}"))?; + let analysis = normalize_javascript_module_analysis_sources(&local_path, analysis)?; + let ranges = named_javascript_function_ranges(&script); + module_dynamic_dependencies.insert( + local_path.clone(), + javascript_reachable_dynamic_import_demands(&analysis, |position| { + javascript_dynamic_import_position_is_reachable(&script, &ranges, position) + }), + ); + module_bindings.insert(local_path.clone(), analysis.imports.clone()); + module_analyses.insert(local_path.clone(), analysis); + } + for imported_source in reachable_local_javascript_module_sources(&script, is_module) { + let imported_path = + local_gameplay_script_path_from(Some(&local_path), &imported_source) + .ok_or_else(|| { + format!( + "自主构建模块依赖路径不受支持:{local_path} -> {imported_source}" + ) + })?; + pending.push_back((imported_path, true)); + } + if is_module { + module_contents.insert(local_path.clone(), script); + } else { + output.classic_global.push_str(&script); + output.classic_global.push('\n'); } - continue; } - if is_module { - let analysis = javascript_module_analysis(&script, true) - .ok_or_else(|| format!("自主构建外部模块不是有效 JavaScript:{local_path}"))?; - let analysis = normalize_javascript_module_analysis_sources(&local_path, analysis)?; - module_bindings.insert(local_path.clone(), analysis.imports.clone()); - module_analyses.insert(local_path.clone(), analysis); + + let mut demanded_exports = Vec::<(String, String, Vec)>::new(); + for (importer, dependencies) in &module_bindings { + let Some(importer_analysis) = module_analyses.get(importer) else { + continue; + }; + let Some(importer_content) = module_contents.get(importer) else { + continue; + }; + let importer_ranges = named_javascript_function_ranges(importer_content); + for (dependency, bindings) in dependencies { + for (exported, local) in bindings { + let used_exports = if exported == "*" { + importer_analysis + .namespace_import_members + .get(local) + .into_iter() + .flat_map(|members| members.keys().cloned()) + .collect::>() + } else if importer_analysis.used_import_locals.contains(local) { + vec![exported.clone()] + } else { + Vec::new() + }; + for used_export in used_exports { + if let Some((origin, origin_export)) = javascript_module_export_origin( + dependency, + &used_export, + &module_analyses, + &mut BTreeSet::new(), + ) { + if origin_export != "*" { + demanded_exports.push((origin, origin_export, Vec::new())); + } + } + } + for (path, positions) in importer_analysis + .import_member_calls + .get(local) + .into_iter() + .flat_map(|members| members.iter()) + { + if !positions.iter().any(|position| { + javascript_position_is_reachable( + importer_content, + &importer_ranges, + *position, + ) + }) { + continue; + } + let Some((used_export, demanded_path)) = (if exported == "*" { + path.first().map(|exported| { + ( + exported.as_str(), + path.get(1..).unwrap_or_default().to_vec(), + ) + }) + } else { + Some((exported.as_str(), path.clone())) + }) else { + continue; + }; + if let Some((origin, origin_export)) = javascript_module_export_origin( + dependency, + used_export, + &module_analyses, + &mut BTreeSet::new(), + ) { + if origin_export != "*" { + demanded_exports.push((origin, origin_export, demanded_path)); + } + } + } + } + } } - for imported_source in reachable_local_javascript_module_sources(&script, is_module) { - let imported_path = - local_gameplay_script_path_from(Some(&local_path), &imported_source).ok_or_else( - || format!("自主构建模块依赖路径不受支持:{local_path} -> {imported_source}"), - )?; - pending.push_back((imported_path, true)); + for dependencies in module_dynamic_dependencies.values() { + for (dependency, exports) in dependencies { + for (exported, member_path) in exports { + if let Some((origin, origin_export)) = javascript_module_export_origin( + dependency, + exported, + &module_analyses, + &mut BTreeSet::new(), + ) { + if origin_export != "*" { + demanded_exports.push((origin, origin_export, member_path.clone())); + } + } + } + } } - if is_module { - module_contents.insert(local_path.clone(), script.to_ascii_lowercase()); - } else { - output.classic_global.push_str(&script); - output.classic_global.push('\n'); + + let mut visited_demands = BTreeSet::new(); + while let Some((owner, exported, member_path)) = demanded_exports.pop() { + if !visited_demands.insert((owner.clone(), exported.clone(), member_path.clone())) { + continue; + } + let Some(owner_content) = module_contents.get(&owner) else { + continue; + }; + let Some(owner_analysis) = module_analyses.get(&owner) else { + continue; + }; + let Some(root_local) = + owner_analysis + .exports + .get(&exported) + .and_then(|target| match target { + JavascriptExportTarget::Local(local) => Some(local.as_str()), + JavascriptExportTarget::Reexport { .. } => None, + }) + else { + continue; + }; + let projection = + javascript_module_binding_projection(owner_content, &BTreeSet::from([exported])); + if projection.is_empty() { + continue; + } + let owner_ranges = named_javascript_function_ranges(owner_content); + let owner_roots = javascript_projected_root_function_indices( + owner_content, + &owner_ranges, + root_local, + &member_path, + ); + for (local, members) in &owner_analysis.import_member_calls { + for (path, positions) in members { + if !positions.iter().any(|position| { + javascript_projected_dynamic_import_position_is_reachable( + owner_content, + &owner_ranges, + &owner_roots, + *position, + ) + }) { + continue; + } + for (dependency, bindings) in &owner_analysis.imports { + for (imported, binding_local) in bindings { + if binding_local != local { + continue; + } + let Some((used_export, demanded_path)) = (if imported == "*" { + path.first().map(|exported| { + ( + exported.as_str(), + path.get(1..).unwrap_or_default().to_vec(), + ) + }) + } else { + Some((imported.as_str(), path.clone())) + }) else { + continue; + }; + if let Some((origin, origin_export)) = javascript_module_export_origin( + dependency, + used_export, + &module_analyses, + &mut BTreeSet::new(), + ) { + if origin_export != "*" { + demanded_exports.push((origin, origin_export, demanded_path)); + } + } + } + } + } + } + let reachable_dynamic_sources = owner_analysis + .dynamic_imports + .iter() + .filter(|(_, position)| { + javascript_projected_dynamic_import_position_is_reachable( + owner_content, + &owner_ranges, + &owner_roots, + *position, + ) + }) + .map(|(source, _)| source.clone()) + .collect::>(); + for source in &reachable_dynamic_sources { + if !visited.contains(&(source.clone(), true)) { + pending.push_back((source.clone(), true)); + } + } + let dynamic_demands = + javascript_reachable_dynamic_import_demands(owner_analysis, |position| { + javascript_projected_dynamic_import_position_is_reachable( + owner_content, + &owner_ranges, + &owner_roots, + position, + ) + }); + for (source, demands) in dynamic_demands { + module_dynamic_dependencies + .entry(owner.clone()) + .or_default() + .entry(source.clone()) + .or_default() + .extend(demands.iter().cloned()); + for (dynamic_export, member_path) in demands { + if let Some((origin, origin_export)) = javascript_module_export_origin( + &source, + &dynamic_export, + &module_analyses, + &mut BTreeSet::new(), + ) { + if origin_export != "*" { + demanded_exports.push((origin, origin_export, member_path)); + } + } + } + } + } + if pending.is_empty() { + break; } } validate_javascript_module_links(&module_analyses)?; output .module_units .extend(module_contents.values().cloned()); - for (importer, dependencies) in module_bindings { - let Some(importer_content) = module_contents.get(&importer) else { - continue; - }; - let Some(importer_analysis) = module_analyses.get(&importer) else { - continue; - }; - let mut unit_bytes = importer_content.as_bytes().to_vec(); - if let Some(analysis) = module_analyses.get(&importer) { - for range in &analysis.import_declaration_ranges { - unit_bytes[range.clone()].fill(b' '); - } - } - let mut unit = String::from_utf8(unit_bytes) - .expect("masking parsed JavaScript imports preserves UTF-8"); - let mut unit_replacements = Vec::<(std::ops::Range, String)>::new(); - let mut added_projection = false; - let mut origins = BTreeMap::)>>::new(); - for (dependency, bindings) in dependencies { - if bindings.is_empty() { + let mut projection_contents = module_contents.clone(); + let mut projected_units = BTreeMap::::new(); + let mut projection_work_bytes = 0usize; + for _ in 0..module_bindings.len().max(1) { + let mut round_updates = BTreeMap::::new(); + for (importer, dependencies) in &module_bindings { + let Some(importer_content) = module_contents.get(importer) else { continue; + }; + let Some(importer_analysis) = module_analyses.get(importer) else { + continue; + }; + let mut unit_bytes = importer_content.as_bytes().to_vec(); + if let Some(analysis) = module_analyses.get(importer) { + for range in &analysis.import_declaration_ranges { + unit_bytes[range.clone()].fill(b' '); + } + for range in &analysis.namespace_destructuring_initializer_ranges { + unit_bytes[range.clone()].fill(b' '); + unit_bytes[range.start] = b'0'; + } } - let used_bindings = bindings - .into_iter() - .flat_map(|(exported, local)| { - if exported == "*" { - importer_analysis - .namespace_import_members - .get(&local) - .cloned() - .unwrap_or_default() - .into_iter() - .map(|(member, _)| (member.clone(), member, Some(local.clone()))) - .collect::>() - } else if importer_analysis.used_import_locals.contains(&local) { - vec![(exported, local, None)] - } else { - Vec::new() - } - }) - .collect::>(); - for (exported, local, namespace) in used_bindings { - if let Some((origin, origin_export)) = javascript_module_export_origin( - &dependency, - &exported, - &module_analyses, - &mut BTreeSet::new(), - ) { - if origin_export == "*" { - for member in importer_analysis - .namespace_import_members - .get(&local) - .cloned() - .unwrap_or_default() - { - let (member, _) = member; - if let Some((member_origin, member_export)) = - javascript_module_export_origin( - &origin, - &member, - &module_analyses, - &mut BTreeSet::new(), - ) - { - origins.entry(member_origin).or_default().push(( - member_export, - member, - Some(local.clone()), - )); - } + let mut unit = String::from_utf8(unit_bytes) + .expect("masking parsed JavaScript imports preserves UTF-8"); + let mut unit_replacements = Vec::<(std::ops::Range, String)>::new(); + let mut added_projection = false; + let mut origins = BTreeMap::< + String, + Vec<( + String, + String, + Option<(String, String)>, + Option<(String, String)>, + )>, + >::new(); + for (dependency, bindings) in dependencies.clone() { + if bindings.is_empty() { + continue; + } + let used_bindings = bindings + .into_iter() + .flat_map(|(exported, local)| { + if exported == "*" { + importer_analysis + .namespace_import_members + .get(&local) + .cloned() + .unwrap_or_default() + .into_iter() + .map(|(member, _)| (member.clone(), member, Some(local.clone()))) + .collect::>() + } else if importer_analysis.used_import_locals.contains(&local) { + vec![(exported, local, None)] + } else { + Vec::new() + } + }) + .collect::>(); + for (exported, local, namespace) in used_bindings { + if let Some((origin, origin_export)) = javascript_module_export_origin( + &dependency, + &exported, + &module_analyses, + &mut BTreeSet::new(), + ) { + if origin_export == "*" { + for member in importer_analysis + .namespace_import_members + .get(&local) + .cloned() + .unwrap_or_default() + { + let (member, _) = member; + if let Some((member_origin, member_export)) = + javascript_module_export_origin( + &origin, + &member, + &module_analyses, + &mut BTreeSet::new(), + ) + { + origins.entry(member_origin).or_default().push(( + member_export, + member.clone(), + Some((local.clone(), member)), + None, + )); + } + } + } else { + origins.entry(origin).or_default().push(( + origin_export, + local, + namespace.map(|namespace| (namespace, exported.clone())), + None, + )); } - } else { - origins - .entry(origin) - .or_default() - .push((origin_export, local, namespace)); } } } - } - let mut unit_root_bindings = importer_analysis.root_bindings.clone(); - for (_, bindings) in &importer_analysis.imports { - for (_, local) in bindings { - unit_root_bindings.remove(&local.to_ascii_lowercase()); + for (dependency, exports) in module_dynamic_dependencies + .get(importer) + .into_iter() + .flat_map(|dependencies| dependencies.iter()) + { + let mut projected_dynamic_exports = BTreeSet::new(); + for (exported, _) in exports { + if !projected_dynamic_exports.insert(exported) { + continue; + } + if let Some((origin, origin_export)) = javascript_module_export_origin( + dependency, + exported, + &module_analyses, + &mut BTreeSet::new(), + ) { + if origin_export != "*" { + origins.entry(origin).or_default().push(( + origin_export, + exported.clone(), + None, + Some((dependency.clone(), exported.clone())), + )); + } + } + } } - } - 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::>(); - let mut projection = javascript_module_binding_projection(origin_content, &used_names); - if projection.is_empty() { - continue; - } - let projection_analysis = javascript_module_analysis(&projection, true) - .ok_or_else(|| format!("自主构建模块投影不是有效 JavaScript:{origin}"))?; - let preferred_synthetic_alias = - bindings.iter().find_map(|(exported, local, namespace)| { + let mut unit_root_bindings = importer_analysis.root_bindings.clone(); + let mut unit_binding_names = importer_analysis.binding_names.clone(); + for (origin, bindings) in origins { + let Some(origin_content) = projection_contents.get(&origin) else { + continue; + }; + let used_names = bindings + .iter() + .map(|(exported, _, _, _)| exported.clone()) + .collect::>(); + let mut projection = + javascript_module_binding_projection(origin_content, &used_names); + if projection.is_empty() { + continue; + } + projection_work_bytes = projection_work_bytes + .checked_add(projection.len()) + .filter(|bytes| { + *bytes <= MAX_EXTERNAL_GAMEPLAY_JAVASCRIPT_PROJECTION_WORK_BYTES + }) + .ok_or_else(|| { + "自主构建模块投影累计处理超过 32 MiB,已拒绝继续展开".to_string() + })?; + if unit + .len() + .checked_add(1) + .and_then(|bytes| bytes.checked_add(projection.len())) + .is_none_or(|bytes| { + u64::try_from(bytes).unwrap_or(u64::MAX) + > MAX_EXTERNAL_GAMEPLAY_JAVASCRIPT_BYTES + }) + { + return Err("自主构建单个模块投影超过 2 MiB,已拒绝继续展开".to_string()); + } + let projection_analysis = javascript_module_analysis(&projection, true) + .ok_or_else(|| format!("自主构建模块投影不是有效 JavaScript:{origin}"))?; + let preferred_synthetic_alias = + bindings + .iter() + .find_map(|(exported, local, namespace, dynamic)| { + let origin_local = module_analyses + .get(&origin) + .and_then(|analysis| analysis.exports.get(exported)) + .and_then(|target| match target { + JavascriptExportTarget::Local(name) => Some(name.as_str()), + JavascriptExportTarget::Reexport { .. } => None, + }) + .unwrap_or(exported.as_str()); + (namespace.is_none() + && dynamic.is_none() + && module_analyses.get(&origin).is_some_and(|analysis| { + analysis.synthetic_declarations.contains_key(origin_local) + })) + .then(|| (origin_local.to_string(), local.clone())) + }); + let mut remaining_projection = projection_analysis.root_bindings.clone(); + let mut assigned_bindings = unit_root_bindings.clone(); + let mut projected_binding_names = BTreeMap::::new(); + for original in &projection_analysis.root_bindings { + remaining_projection.remove(original); + let preferred = preferred_synthetic_alias + .as_ref() + .filter(|(synthetic, _)| synthetic == original) + .map(|(_, local)| local.as_str()) + .unwrap_or(original); + let canonical = unique_javascript_projection_binding_name( + preferred, + &assigned_bindings, + &remaining_projection, + ); + assigned_bindings.insert(canonical.clone()); + projected_binding_names.insert(original.clone(), canonical); + } + for (original, canonical) in &projected_binding_names { + if original != canonical + && !rename_javascript_root_binding(&mut projection, original, canonical) + { + return Err(format!( + "自主构建模块投影绑定无法按符号重命名:{origin}::{original} -> {canonical}" + )); + } + } + if javascript_module_analysis(&projection, true).is_none() { + return Err(format!("自主构建模块投影重命名后语义无效:{origin}")); + } + unit_root_bindings = assigned_bindings; + unit_binding_names.extend(projected_binding_names.values().cloned()); + for (exported, local, namespace, dynamic) in bindings { let origin_local = module_analyses .get(&origin) - .and_then(|analysis| analysis.exports.get(&exported.to_ascii_lowercase())) + .and_then(|analysis| analysis.exports.get(&exported)) .and_then(|target| match target { JavascriptExportTarget::Local(name) => Some(name.as_str()), JavascriptExportTarget::Reexport { .. } => None, }) .unwrap_or(exported.as_str()); - (namespace.is_none() && origin_local == "__agc_default_export__") - .then(|| local.to_ascii_lowercase()) - }); - let mut remaining_projection = projection_analysis.root_bindings.clone(); - let mut assigned_bindings = unit_root_bindings.clone(); - let mut projected_binding_names = BTreeMap::::new(); - for original in &projection_analysis.root_bindings { - remaining_projection.remove(original); - let preferred = if original == "__agc_default_export__" { - preferred_synthetic_alias.as_deref().unwrap_or(original) - } else { - original - }; - let canonical = unique_javascript_projection_binding_name( - preferred, - &assigned_bindings, - &remaining_projection, - ); - assigned_bindings.insert(canonical.clone()); - projected_binding_names.insert(original.clone(), canonical); + let projected_local = projected_binding_names + .get(origin_local) + .map(String::as_str) + .unwrap_or(origin_local); + let normalized_local = local.clone(); + if let Some((dependency, imported)) = dynamic { + if let Some(ranges) = importer_analysis + .dynamic_import_member_ranges + .get(&dependency) + .and_then(|exports| exports.get(&imported)) + { + unit_replacements.extend( + ranges + .iter() + .cloned() + .map(|range| (range, projected_local.to_string())), + ); + } + if let Some(bindings) = importer_analysis + .dynamic_import_binding_references + .get(&dependency) + .and_then(|exports| exports.get(&imported)) + { + for binding in bindings { + let preferred = format!( + "__agc_dynamic_import_binding_{}_{}", + binding.binding_range.start, binding.binding_range.end + ); + let replacement = (0_u64..) + .map(|index| { + if index == 0 { + preferred.clone() + } else { + format!("{preferred}_{index}") + } + }) + .find(|candidate| !unit_binding_names.contains(candidate)) + .expect("a suffixed dynamic import binding must become unique"); + unit_binding_names.insert(replacement.clone()); + let binding_replacement = if binding.binding_shorthand { + format!("{imported}: {replacement}") + } else { + replacement + }; + unit_replacements + .push((binding.binding_range.clone(), binding_replacement)); + unit_replacements.extend( + binding + .reference_ranges + .iter() + .cloned() + .map(|range| (range, projected_local.to_string())), + ); + } + } + } else if let Some((namespace, importer_member)) = namespace { + if let Some(ranges) = importer_analysis + .namespace_import_members + .get(&namespace) + .and_then(|members| members.get(&importer_member)) + { + unit_replacements.extend( + ranges + .iter() + .cloned() + .map(|range| (range, projected_local.to_string())), + ); + } + } else if projected_local != normalized_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!("{normalized_local}: {projected_local}") + } else { + projected_local.to_string() + }; + (span.range.clone(), replacement) + })); + } + } + } + unit.push('\n'); + unit.push_str(&projection); + added_projection = true; } - for (original, canonical) in &projected_binding_names { - if original != canonical - && !rename_javascript_root_binding(&mut projection, original, canonical) + if added_projection { + if !apply_javascript_span_replacements(&mut unit, &mut unit_replacements) { + return Err(format!("自主构建模块投影引用范围冲突:{importer}")); + } + if u64::try_from(unit.len()).unwrap_or(u64::MAX) + > MAX_EXTERNAL_GAMEPLAY_JAVASCRIPT_BYTES { - return Err(format!( - "自主构建模块投影绑定无法按符号重命名:{origin}::{original} -> {canonical}" - )); + return Err("自主构建单个模块投影超过 2 MiB,已拒绝继续展开".to_string()); } - } - if javascript_module_analysis(&projection, true).is_none() { - return Err(format!("自主构建模块投影重命名后语义无效:{origin}")); - } - unit_root_bindings = assigned_bindings; - 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, + if javascript_module_analysis(&unit, true).is_none() { + return Err(format!("自主构建模块组合投影语义无效:{importer}")); + } + projection_work_bytes = projection_work_bytes + .checked_add(unit.len()) + .filter(|bytes| { + *bytes <= MAX_EXTERNAL_GAMEPLAY_JAVASCRIPT_PROJECTION_WORK_BYTES }) - .unwrap_or(exported.as_str()); - let projected_local = projected_binding_names - .get(&origin_local.to_ascii_lowercase()) - .map(String::as_str) - .unwrap_or(origin_local); - let normalized_local = local.to_ascii_lowercase(); - 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 projected_local != normalized_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!("{normalized_local}: {projected_local}") - } else { - projected_local.to_string() - }; - (span.range.clone(), replacement) - })); - } - } + .ok_or_else(|| { + "自主构建模块投影累计处理超过 32 MiB,已拒绝继续展开".to_string() + })?; + round_updates.insert(importer.clone(), unit); } - unit.push('\n'); - unit.push_str(&projection); - added_projection = true; } - if added_projection { - if !apply_javascript_span_replacements(&mut unit, &mut unit_replacements) { - return Err(format!("自主构建模块投影引用范围冲突:{importer}")); + if round_updates.is_empty() { + break; + } + let mut changed = false; + for (importer, unit) in round_updates { + if projection_contents.get(&importer) != Some(&unit) { + projection_contents.insert(importer.clone(), unit.clone()); + changed = true; } - output.module_units.push(unit); + projected_units.insert(importer, unit); + } + if !changed { + break; } } + output.module_units.extend(projected_units.into_values()); Ok(output) } @@ -5641,6 +8009,7 @@ fn tetris_clear_body_is_meaningful(body: &str) -> bool { fn tetris_executable_unit_semantics_gap(executable: &str) -> Option<&'static str> { let executable = javascript_without_string_literals_or_comments(executable); let ranges = named_javascript_function_ranges(&executable); + let executable = executable.to_ascii_lowercase(); let tokens = javascript_lexical_tokens(&executable); let has_board_initializer = tokens.windows(6).any(|window| { matches!(&window[0], JavascriptLexicalToken::Identifier(board) if board == "board") @@ -5670,6 +8039,7 @@ fn tetris_executable_unit_semantics_gap(executable: &str) -> Option<&'static str named_javascript_functions(&executable, &ranges, &["clear", "line", "row"]) .into_iter() .filter(|(_, body)| tetris_clear_body_is_meaningful(body)) + .map(|(name, body)| (name.to_ascii_lowercase(), body)) .collect::>(); if clear_functions.is_empty() { return Some("line-clear"); @@ -5688,6 +8058,7 @@ fn tetris_executable_unit_semantics_gap(executable: &str) -> Option<&'static str ) }) }) + .map(|(name, body)| (name.to_ascii_lowercase(), body)) .collect::>(); if lock_functions.is_empty() { return Some("piece-lock"); @@ -5717,11 +8088,7 @@ fn tetris_executable_semantics_gap( classic_global.clear(); } let mut units = vec![classic_global]; - units.extend( - executable_inline_module_javascript_units_from_html(content) - .into_iter() - .map(|unit| unit.to_ascii_lowercase()), - ); + units.extend(executable_inline_module_javascript_units_from_html(content).into_iter()); units.extend(external_javascript.module_units.iter().cloned()); let gap_rank = |gap: &str| match gap { "board-state" => 0, @@ -5734,7 +8101,7 @@ fn tetris_executable_semantics_gap( let mut best_gap = "board-state"; let mut complete_unit = false; for unit in units { - match tetris_executable_unit_semantics_gap(&unit.to_ascii_lowercase()) { + match tetris_executable_unit_semantics_gap(&unit) { None => complete_unit = true, Some(gap) if gap_rank(gap) > gap_rank(best_gap) => best_gap = gap, Some(_) => {} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index cf1b4b58e..6226f2667 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -2383,6 +2383,26 @@ import './gameplay/tetris.mjs';"#, "explicitly imported exported gameplay bindings may form one semantic chain", ); + fs::write( + root.join("game/scope-b.mjs"), + format!( + "const Foo = 1; const foo = 2;\nimport {{ board, current, spin as rotatePiece, clearLines as clearRows }} from './scope-a.mjs';\nrotatePiece();\n{}", + gameplay[split_at..].replace("clearLines();", "clearRows();") + ), + ) + .expect("write case-distinct roots beside the complete module projection"); + let case_distinct_modules = read_external_gameplay_javascript_at(root, &bound_module_html) + .expect("read complete projection with case-distinct roots"); + assert_eq!( + inherited_gameplay_semantics_gap_with_external_javascript( + task, + bound_module_html.as_bytes(), + &case_distinct_modules, + ), + None, + "case normalization must happen after AST masking and semantic validation", + ); + fs::write( root.join("game/scope-b.mjs"), format!( @@ -2399,10 +2419,12 @@ import './gameplay/tetris.mjs';"#, .module_units() .iter() .find(|unit| { + let unit = unit.to_ascii_lowercase(); unit.contains("function rotatepiece()") && unit.contains("const importermetadata={ rotatepiece: 'property-only' }") }) .expect("aliased importer must be joined with its renamed origin projection"); + let aliased_unit = aliased_unit.to_ascii_lowercase(); assert!( aliased_unit.contains("function shadow(turnpiece) { return turnpiece(); }"), "a local parameter shadow in the importer must not be confused with the import symbol", @@ -2592,9 +2614,11 @@ import './gameplay/tetris.mjs';"#, .module_units() .iter() .find(|unit| { + let unit = unit.to_ascii_lowercase(); unit.contains("function shadow(gameplay)") && unit.contains("function rotatepiece()") }) .expect("namespace consumer must be joined with the referenced export projection"); + let projected_namespace_unit = projected_namespace_unit.to_ascii_lowercase(); assert!( projected_namespace_unit.contains("function shadow(gameplay) { gameplay.rotatepiece(); }"), "a shadowed namespace parameter must retain its member access in the projected unit", @@ -2726,9 +2750,55 @@ import './gameplay/tetris.mjs';"#, #[test] fn javascript_module_projection_uses_symbols_for_dependencies_and_renames() { + let arrow_projection = javascript_module_binding_projection( + "const rotate = () => 1; export const rotatePiece = () => { const result = rotate(); return result > 0; };", + &BTreeSet::from(["rotatePiece".to_string()]), + ); + assert!(arrow_projection.contains("const rotatePiece = () =>")); + assert!(arrow_projection.contains("const result = rotate(); return result > 0;")); + assert!(arrow_projection.contains("const rotate = () => 1;")); + assert!(javascript_is_syntactically_valid(&arrow_projection, true)); + + let private_default_projection = javascript_module_binding_projection( + "const __agc_default_export__ = () => 'private'; export default function() { return __agc_default_export__(); }", + &BTreeSet::from(["default".to_string()]), + ); + assert!(private_default_projection.contains("const __agc_default_export__ =")); + assert!(private_default_projection.contains("const __agc_default_export___1 = function()")); + assert!(private_default_projection.contains("return __agc_default_export__();")); + assert!(javascript_is_syntactically_valid( + &private_default_projection, + true, + )); + + let explicit_private_projection = javascript_module_binding_projection( + "const __agc_default_export__ = () => 'private'; export { __agc_default_export__ }; export default function() { return __agc_default_export__(); }", + &BTreeSet::from(["__agc_default_export__".to_string()]), + ); + assert!(explicit_private_projection.contains("const __agc_default_export__ =")); + assert!(!explicit_private_projection.contains("const __agc_default_export___1 = function()")); + assert!(javascript_is_syntactically_valid( + &explicit_private_projection, + true, + )); + + let mut destructured_binding = + "const state = { board: [] }; const { board } = state; board.push(1);".to_string(); + assert!(rename_javascript_root_binding( + &mut destructured_binding, + "board", + "board__agc_import_1", + )); + assert!(destructured_binding.contains("const { board: board__agc_import_1 } = state")); + assert!(destructured_binding.contains("board__agc_import_1.push(1)")); + assert!(javascript_is_syntactically_valid( + &destructured_binding, + true, + )); + let projection = javascript_module_binding_projection( "const board = Array.from({ length: 20 }, () => Array(10).fill(0));\nfunction clearLines() { board.splice(0, 1); }\nexport function rotatePiece() { const metadata = { board: 'property-only' }; function shadow(clearLines) { return clearLines(); } return metadata; }", - &BTreeSet::from(["rotatepiece".to_string()]), + &BTreeSet::from(["rotatePiece".to_string()]), ); assert!(projection.contains("function rotatePiece()")); assert!( @@ -2805,10 +2875,12 @@ fn javascript_module_projection_preserves_duplicate_aliases_without_duplicate_or .module_units() .iter() .find(|unit| { + let unit = unit.to_ascii_lowercase(); unit.matches("rotatepiece();").count() == 2 && unit.contains("function rotatepiece()") }) .unwrap_or_else(|| panic!("both aliases must remain projected for {label}")); + let projected = projected.to_ascii_lowercase(); assert_eq!( projected.matches("function rotatepiece()").count(), 1, @@ -2816,6 +2888,61 @@ fn javascript_module_projection_preserves_duplicate_aliases_without_duplicate_or ); } + fs::write( + root.join("game/final.mjs"), + "export function finish() { return 'finished'; }", + ) + .expect("write anonymous default wrapper dependency"); + fs::write( + root.join("game/dependency.mjs"), + "export const api = { run() { return import('./final.mjs').then(({ finish }) => finish()); } };", + ) + .expect("write imported member used by anonymous defaults"); + for (label, default_export) in [ + ( + "anonymous default function wrapper", + "export default function() { return api.run(); }", + ), + ( + "anonymous default arrow wrapper", + "export default () => api.run();", + ), + ( + "anonymous default function with late alias", + "let facade; export default function() { return facade.run(); } facade = api;", + ), + ( + "anonymous default arrow with late alias", + "let facade; export default () => facade.run(); facade = api;", + ), + ] { + fs::write( + root.join("game/origin.mjs"), + format!("import {{ api }} from './dependency.mjs'; {default_export}"), + ) + .expect("write anonymous default imported-member wrapper"); + fs::write( + root.join("game/main.mjs"), + "import start from './origin.mjs'; start();", + ) + .expect("write anonymous default wrapper consumer"); + let modules = read_external_gameplay_javascript_at(root, html) + .unwrap_or_else(|error| panic!("read {label}: {error}")); + assert!( + modules + .module_units() + .iter() + .any(|unit| unit.contains("function finish()")), + "{label} must propagate its imported member's dynamic dependency: {:#?}", + modules.module_units(), + ); + } + fs::write( + root.join("game/origin.mjs"), + "export function rotatePiece() { return 'rotated'; }", + ) + .expect("restore named export after anonymous default wrapper cases"); + fs::write( root.join("game/bridge-a.mjs"), "export { rotatePiece } from './origin.mjs';", @@ -2837,9 +2964,11 @@ fn javascript_module_projection_preserves_duplicate_aliases_without_duplicate_or .module_units() .iter() .find(|unit| { + let unit = unit.to_ascii_lowercase(); unit.matches("rotatepiece();").count() == 2 && unit.contains("function rotatepiece()") }) .expect("both bridged aliases must resolve to the shared origin"); + let projected = projected.to_ascii_lowercase(); assert_eq!( projected.matches("function rotatepiece()").count(), 1, @@ -2867,14 +2996,15 @@ fn javascript_module_projection_preserves_duplicate_aliases_without_duplicate_or let projected = modules .module_units() .iter() - .find(|unit| unit.matches("turnleft();").count() == 2) + .find(|unit| unit.to_ascii_lowercase().matches("turnleft();").count() == 2) .or_else(|| { modules .module_units() .iter() - .find(|unit| unit.matches("turnright();").count() == 2) + .find(|unit| unit.to_ascii_lowercase().matches("turnright();").count() == 2) }) .unwrap_or_else(|| panic!("all anonymous default references must share one alias for {label}")); + let projected = projected.to_ascii_lowercase(); assert_eq!( projected.matches("const turnleft =").count() + projected.matches("const turnright =").count(), @@ -2902,16 +3032,20 @@ fn javascript_module_projection_preserves_duplicate_aliases_without_duplicate_or let projected = modules .module_units() .iter() - .find(|unit| unit.contains("const turn__agc_import_1 = function()")) + .find(|unit| { + unit.to_ascii_lowercase() + .contains("const turn__agc_import_1 = function()") + }) .unwrap_or_else(|| { panic!( "anonymous default must receive a collision-free canonical alias: {:#?}", modules.module_units() ) }); + let projected = projected.to_ascii_lowercase(); assert!(projected.contains("const turn = () => 'helper';")); assert!(projected.contains("turn__agc_import_1();")); - assert!(javascript_is_syntactically_valid(projected, true)); + assert!(javascript_is_syntactically_valid(&projected, true)); fs::write( root.join("game/bridge-a.mjs"), @@ -2933,14 +3067,15 @@ fn javascript_module_projection_preserves_duplicate_aliases_without_duplicate_or let projected = modules .module_units() .iter() - .find(|unit| unit.matches("turnleft();").count() == 2) + .find(|unit| unit.to_ascii_lowercase().matches("turnleft();").count() == 2) .or_else(|| { modules .module_units() .iter() - .find(|unit| unit.matches("turnright();").count() == 2) + .find(|unit| unit.to_ascii_lowercase().matches("turnright();").count() == 2) }) .expect("bridged anonymous default aliases must share one declaration"); + let projected = projected.to_ascii_lowercase(); assert_eq!( projected.matches("const turnleft =").count() + projected.matches("const turnright =").count(), @@ -2963,11 +3098,691 @@ fn javascript_module_projection_preserves_duplicate_aliases_without_duplicate_or let projected = modules .module_units() .iter() - .find(|unit| unit.contains("function rotatepiece__agc_import_1()")) + .find(|unit| { + unit.to_ascii_lowercase() + .contains("function rotatepiece__agc_import_1()") + }) .expect("named origin binding must avoid importer root bindings"); + let projected = projected.to_ascii_lowercase(); assert!(projected.contains("const rotatepiece = 'metadata';")); assert!(projected.contains("rotatepiece__agc_import_1();")); + assert!(javascript_is_syntactically_valid(&projected, true)); + + fs::write( + root.join("game/origin.mjs"), + "export function foo() { return 'origin'; }", + ) + .expect("write case-sensitive collision origin"); + fs::write( + root.join("game/main.mjs"), + "import { foo as Foo } from './origin.mjs'; const foo = 1; Foo();", + ) + .expect("write case-sensitive alias beside a distinct importer root"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("read case-sensitive collision-safe projection"); + let projected = modules + .module_units() + .iter() + .find(|unit| { + unit.to_ascii_lowercase() + .contains("function foo__agc_import_1()") + }) + .expect("case-sensitive import alias must not erase the importer root collision"); + let projected = projected.to_ascii_lowercase(); + assert!(projected.contains("const foo = 1;")); + assert!(projected.contains("foo__agc_import_1();")); + assert!(javascript_is_syntactically_valid(&projected, true)); + + fs::write( + root.join("game/origin.mjs"), + "export function Foo() { return 'upper'; } export function foo() { return 'lower'; }", + ) + .expect("write case-distinct exports"); + fs::write( + root.join("game/main.mjs"), + "import { Foo as selected } from './origin.mjs'; selected();", + ) + .expect("write importer selecting only the uppercase export"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("case-distinct ESM exports must keep separate binding identities"); + let projected = modules + .module_units() + .iter() + .find(|unit| unit.contains("function Foo()") && unit.contains("Foo();")) + .expect("the selected uppercase export must be projected"); + assert!( + !projected.contains("function foo()"), + "the unselected lowercase export must not leak into the projection", + ); + assert!(javascript_is_syntactically_valid(&projected, true)); + + fs::write( + root.join("game/origin.mjs"), + "const state = { board: [], nested: { current: 1 }, list: [2], extra: 3 }; export const { board, nested: { current }, list: [first], ...rest } = state;", + ) + .expect("write destructured exports"); + fs::write( + root.join("game/main.mjs"), + "import { board, current, first, rest } from './origin.mjs'; void [board, current, first, rest];", + ) + .expect("write destructured export consumer"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("nested object, array, and rest export bindings must link"); + let projected = modules + .module_units() + .iter() + .find(|unit| unit.contains("const { board, nested: { current }, list: [first], ...rest }")) + .expect("destructured declaration must be projected once"); + assert_eq!( + projected + .matches("const { board, nested: { current }, list: [first], ...rest }") + .count(), + 1, + ); assert!(javascript_is_syntactically_valid(projected, true)); + + fs::write( + root.join("game/dependency.mjs"), + "export function applyRotation() { return 'applied'; }", + ) + .expect("write transitive dependency origin"); + fs::write( + root.join("game/origin.mjs"), + "import { applyRotation } from './dependency.mjs'; export function rotatePiece() { return applyRotation(); }", + ) + .expect("write exported function with an imported dependency"); + fs::write( + root.join("game/main.mjs"), + "import { rotatePiece } from './origin.mjs'; rotatePiece();", + ) + .expect("write transitive dependency consumer"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("read transitive projection dependency closure"); + let projected = modules + .module_units() + .iter() + .find(|unit| { + unit.contains("rotatePiece();") + && unit.contains("function rotatePiece()") + && unit.contains("function applyRotation()") + }) + .expect("the consumer unit must include the complete transitive import closure"); + assert!(javascript_is_syntactically_valid(projected, true)); + + fs::write( + root.join("game/origin.mjs"), + "function unreachableHelper() { return import('./missing-false-helper.mjs'); } export function rotatePiece() { if (false) unreachableHelper(); if (false) import('./missing-false.mjs'); function neverCalled() { return import('./missing-nested.mjs'); } return import('./dependency.mjs').then(({ applyRotation }) => applyRotation()); } export function unused() { return import('./missing-unused-export.mjs'); } export class Game { unused() { return import('./missing-class-method.mjs'); } } export const api = { unused() { return import('./missing-object-method.mjs'); } };", + ) + .expect("write exported function with reachable and unreachable dynamic dependencies"); + fs::write( + root.join("game/main.mjs"), + "import { rotatePiece, Game, api } from './origin.mjs'; void [Game, api]; rotatePiece();", + ) + .expect("use exported class and object without invoking their dynamic methods"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("read reachable dynamic projection dependency closure"); + let projected = modules + .module_units() + .iter() + .find(|unit| { + unit.contains("rotatePiece();") + && unit.contains("function rotatePiece()") + && unit.contains("function applyRotation()") + }) + .unwrap_or_else(|| { + panic!( + "the consumer unit must include the reachable dynamic import closure: {:#?}", + modules.module_units() + ) + }); + assert!(javascript_is_syntactically_valid(projected, true)); + assert!( + projected.contains("({ applyRotation: __agc_dynamic_import_binding_") + && projected.contains("}) => applyRotation()"), + "the dynamic callback binding must be detached from the projected root symbol: {projected}", + ); + assert!( + !projected.contains("({ applyRotation }) => applyRotation()"), + "the callback-local binding must not continue to shadow the projected dependency", + ); + fs::write( + root.join("game/main.mjs"), + "import { rotatePiece } from './origin.mjs'; rotatePiece();", + ) + .expect("restore the dynamic function consumer"); + + fs::write( + root.join("game/arrow-dependency.mjs"), + "export function applyArrow() { return 'arrow'; }", + ) + .expect("write arrow property dependency"); + fs::write( + root.join("game/function-dependency.mjs"), + "export function applyFunction() { return 'function'; }", + ) + .expect("write function property dependency"); + fs::write( + root.join("game/field-dependency.mjs"), + "export function applyField() { return 'field'; }", + ) + .expect("write class field dependency"); + fs::write( + root.join("game/controls-dependency.mjs"), + "export function applyControls() { return 'controls'; }", + ) + .expect("write nested controls dependency"); + fs::write( + root.join("game/origin.mjs"), + "export const api = { run() { return import('./dependency.mjs').then(({ applyRotation }) => applyRotation()); }, callback: () => import('./arrow-dependency.mjs').then(({ applyArrow }) => applyArrow()), legacy: function() { return import('./function-dependency.mjs').then(({ applyFunction }) => applyFunction()); }, controls: { run() { return import('./controls-dependency.mjs').then(({ applyControls }) => applyControls()); }, unused() { return import('./missing-nested-object-method.mjs'); } }, unused() { return import('./missing-object-method.mjs'); }, unusedCallback: () => import('./missing-arrow-property.mjs') }; export class Game { static run() { return import('./dependency.mjs').then(({ applyRotation }) => applyRotation()); } instanceRun() { return import('./dependency.mjs').then(({ applyRotation }) => applyRotation()); } fieldRun = () => import('./field-dependency.mjs').then(({ applyField }) => applyField()); unusedField = () => import('./missing-class-field.mjs'); static unused() { return import('./missing-class-method.mjs'); } }", + ) + .expect("write exported object and class with selected dynamic methods"); + fs::write( + root.join("game/main.mjs"), + "import { api, Game } from './origin.mjs'; function setTimeout(value) { console.log(value); } const metadata = { map(value) { console.log(value); } }; if (false) api.unused(); if (false) api.controls.unused(); if (false) Game.unused(); setTimeout(api.unusedCallback); metadata.map(api.unusedCallback); api.run(); queueMicrotask(api.callback); const { legacy: legacyCallback } = api; queueMicrotask(legacyCallback); let facade; facade = api; if (false) facade = Game; facade.controls.run(); Game.run(); const { run: aliasedRun } = api; aliasedRun(); const game = new Game(); game.instanceRun(); requestAnimationFrame(game.fieldRun); if (false) game.unusedField();", + ) + .expect("call selected dynamic object and class methods"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("read selected object and class method dynamic dependencies"); + let projected = modules + .module_units() + .iter() + .find(|unit| { + unit.contains("api.run();") + && unit.contains("Game.run();") + && unit.contains("aliasedRun();") + && unit.contains("game.instanceRun();") + && unit.contains("function applyRotation()") + && unit.contains("function applyArrow()") + && unit.contains("function applyFunction()") + && unit.contains("function applyField()") + && unit.contains("function applyControls()") + }) + .unwrap_or_else(|| { + panic!( + "called object and class methods must bring in their dynamic dependency: {:#?}", + modules.module_units() + ) + }); + assert!(javascript_is_syntactically_valid(projected, true)); + fs::write( + root.join("game/main.mjs"), + "import * as ns from './origin.mjs'; if (false) ns.api.unused(); if (false) ns.api.controls.unused(); ns.api.run(); ns.api.controls.run(); const { api: pickedApi } = ns; pickedApi.run(); pickedApi.controls.run();", + ) + .expect("call a nested namespace member directly and through destructuring"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("read nested namespace member dynamic dependencies"); + let projected = modules + .module_units() + .iter() + .find(|unit| { + unit.matches("api.run();").count() >= 2 + && unit.matches("api.controls.run();").count() >= 2 + && unit.contains("function applyRotation()") + && unit.contains("function applyControls()") + }) + .expect("nested namespace member calls must select the exported object method"); + assert!(javascript_is_syntactically_valid(projected, true)); + fs::write( + root.join("game/member-wrapper.mjs"), + "import { api } from './origin.mjs'; export function start() { return api.run(); }", + ) + .expect("write exported wrapper around an imported member call"); + fs::write( + root.join("game/main.mjs"), + "import { start } from './member-wrapper.mjs'; start();", + ) + .expect("call the exported member wrapper"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("read member demand inherited through an exported wrapper"); + let projected = modules + .module_units() + .iter() + .find(|unit| { + unit.contains("start();") + && unit.contains("function start()") + && unit.contains("function applyRotation()") + }) + .expect("downstream demand must carry a reachable imported member call"); + assert!(javascript_is_syntactically_valid(projected, true)); + fs::write( + root.join("game/main.mjs"), + "import { rotatePiece } from './origin.mjs'; rotatePiece();", + ) + .expect("restore the dynamic function consumer after method projection"); + + fs::write( + root.join("game/origin.mjs"), + "export async function rotatePiece() { const { applyRotation: turn } = await import('./dependency.mjs'); return turn(); }", + ) + .expect("write awaited dynamic destructuring dependency"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("read awaited dynamic destructuring projection closure"); + let projected = modules + .module_units() + .iter() + .find(|unit| { + unit.contains("rotatePiece();") + && unit.contains("function rotatePiece()") + && unit.contains("function applyRotation()") + }) + .expect("the consumer unit must include the awaited dynamic import closure"); + assert!( + projected.contains("return applyRotation();") && !projected.contains("return turn();"), + "the awaited destructured alias must resolve to the projected root symbol: {projected}", + ); + assert!(javascript_is_syntactically_valid(projected, true)); + + fs::write( + root.join("game/deep-dynamic.mjs"), + "export function finishRotation() { return 'finished'; }", + ) + .expect("write second dynamic dependency origin"); + fs::write( + root.join("game/dependency.mjs"), + "export function applyRotation() { return import('./deep-dynamic.mjs').then(({ finishRotation }) => finishRotation()); }", + ) + .expect("write first dynamic dependency with a second dynamic hop"); + fs::write( + root.join("game/origin.mjs"), + "export function rotatePiece() { return import('./dependency.mjs').then(({ applyRotation }) => applyRotation()); }", + ) + .expect("write dynamic dependency chain root"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("read a two-hop dynamic projection closure"); + let projected = modules + .module_units() + .iter() + .find(|unit| { + unit.contains("rotatePiece();") + && unit.contains("function rotatePiece()") + && unit.contains("function applyRotation()") + && unit.contains("function finishRotation()") + }) + .expect("the consumer unit must include both dynamic dependency hops"); + assert!( + projected.contains("=> applyRotation()") && projected.contains("=> finishRotation()"), + "both dynamic callback symbols must connect to their projected roots: {projected}", + ); + assert!(javascript_is_syntactically_valid(projected, true)); + + fs::write( + root.join("game/origin.mjs"), + "export async function rotatePiece() { const gameplay = await import('./dependency.mjs'); return gameplay.applyRotation(); }", + ) + .expect("write awaited dynamic namespace dependency"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("read awaited dynamic namespace projection closure"); + let projected = modules + .module_units() + .iter() + .find(|unit| { + unit.contains("rotatePiece();") + && unit.contains("function rotatePiece()") + && unit.contains("function applyRotation()") + }) + .expect("the consumer unit must include the dynamic namespace closure"); + assert!( + projected.contains("return applyRotation();") + && !projected.contains("return gameplay.applyRotation();"), + "the dynamic namespace member must resolve to the projected root symbol: {projected}", + ); + assert!(javascript_is_syntactically_valid(projected, true)); + + fs::write( + root.join("game/origin.mjs"), + "export function rotatePiece() { return 'rotated'; }", + ) + .expect("restore namespace origin"); + fs::write( + root.join("game/bridge-a.mjs"), + "export { rotatePiece as turn } from './origin.mjs';", + ) + .expect("write renamed namespace bridge"); + fs::write( + root.join("game/main.mjs"), + "import * as gameplay from './bridge-a.mjs'; gameplay.turn();", + ) + .expect("write namespace consumer of a renamed re-export"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("read namespace through renamed re-export"); + let projected = modules + .module_units() + .iter() + .find(|unit| { + let unit = unit.to_ascii_lowercase(); + unit.contains("function rotatepiece()") && unit.contains("rotatepiece();") + }) + .expect("renamed namespace member must resolve to its origin projection"); + let projected = projected.to_ascii_lowercase(); + assert!(projected.contains("rotatepiece();")); + assert!(!projected.contains("gameplay.turn()")); + assert!(javascript_is_syntactically_valid(&projected, true)); + + fs::write( + root.join("game/main.mjs"), + "import * as gameplay from './origin.mjs'; const { rotatePiece: turn } = gameplay; turn();", + ) + .expect("write destructured namespace consumer"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("read destructured namespace projection"); + let projected = modules + .module_units() + .iter() + .find(|unit| unit.contains("function rotatePiece()") && unit.contains("rotatePiece();")) + .expect("destructured namespace alias must resolve to its origin projection"); + assert!(!projected.contains("turn();")); + assert!(javascript_is_syntactically_valid(projected, true)); +} + +#[test] +fn javascript_dynamic_projection_keeps_occurrence_member_and_constructor_identity() { + let temporary = tempfile::tempdir().expect("create dynamic projection edge project"); + let root = temporary.path(); + fs::create_dir_all(root.join("game")).expect("create game directory"); + let html = ""; + + fs::write( + root.join("game/dependency.mjs"), + "export function poison() { return import('./missing-poison.mjs'); }", + ) + .expect("write same-source poison dependency"); + fs::write( + root.join("game/origin.mjs"), + "export function start() { import('./dependency.mjs'); if (false) { import('./dependency.mjs').then(({ poison }) => poison()); } return 'ok'; }", + ) + .expect("write reachable bare import and unreachable same-source export use"); + fs::write( + root.join("game/main.mjs"), + "import { start } from './origin.mjs'; start();", + ) + .expect("write same-source occurrence consumer"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("unreachable same-source export demand must not load its missing dependency"); + let projected = modules + .module_units() + .iter() + .find(|unit| unit.contains("start();") && unit.contains("function start()")) + .expect("reachable bare dynamic import consumer must still project"); + assert!( + !projected.contains("function poison()"), + "an unreachable occurrence must not lend its export demand to a reachable bare import: {projected}", + ); + + fs::write( + root.join("game/final.mjs"), + "export function finish() { return 'finished'; }", + ) + .expect("write deep dynamic member dependency"); + fs::write( + root.join("game/deep-dependency.mjs"), + "export const api = { controls: { run() { return import('./final.mjs').then(({ finish }) => finish()); }, unused() { return import('./missing-deep-member.mjs'); } } };", + ) + .expect("write deep dynamic namespace export"); + fs::write( + root.join("game/origin.mjs"), + "export async function start() { let gameplay; gameplay = await import('./deep-dependency.mjs'); if (false) gameplay = await import('./missing-namespace-override.mjs'); return gameplay.api.controls.run(); }", + ) + .expect("write assignment-bound dynamic namespace consumer"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("assignment-bound dynamic namespace must retain its complete member demand"); + let projected = modules + .module_units() + .iter() + .find(|unit| { + unit.contains("start();") + && unit.contains("function start()") + && unit.contains("function finish()") + }) + .expect("deep dynamic namespace call must bring in its selected method dependency"); + assert!( + projected.contains("return api.controls.run();") + && !projected.contains("return gameplay.api.controls.run();"), + "the first dynamic namespace segment must be rewritten without flattening its member path: {projected}", + ); + assert!(javascript_is_syntactically_valid(projected, true)); + + for (path, source) in [ + ( + "game/constructor-dependency.mjs", + "export function finishConstructor() { return 'constructor'; }", + ), + ( + "game/start-dependency.mjs", + "export function finishStart() { return 'start'; }", + ), + ( + "game/run-dependency.mjs", + "export function finishRun() { return 'run'; }", + ), + ] { + fs::write(root.join(path), source).expect("write class member dependency"); + } + let class_source = "class Game { constructor() { import('./constructor-dependency.mjs').then(({ finishConstructor }) => finishConstructor()); } start() { this.run(); return import('./start-dependency.mjs').then(({ finishStart }) => finishStart()); } run() { return import('./run-dependency.mjs').then(({ finishRun }) => finishRun()); } decoy = { run() { return import('./missing-decoy-run.mjs'); } }; }"; + fs::write( + root.join("game/origin.mjs"), + format!("/* {class_source} */ export {class_source}"), + ) + .expect("write class whose source is duplicated in a leading comment"); + fs::write( + root.join("game/main.mjs"), + "import { Game } from './origin.mjs'; new Game().start();", + ) + .expect("write direct constructed member consumer"); + let modules = read_external_gameplay_javascript_at(root, html).expect( + "constructor, direct instance member, and owner-scoped this call must exclude the decoy", + ); + let projected = modules + .module_units() + .iter() + .find(|unit| { + unit.contains("new Game().start();") + && unit.contains("function finishConstructor()") + && unit.contains("function finishStart()") + && unit.contains("function finishRun()") + }) + .expect("all reachable class member dependencies must survive projection"); + assert!(javascript_is_syntactically_valid(projected, true)); + + fs::write( + root.join("game/main.mjs"), + "import * as ns from './origin.mjs'; const game = new ns.Game(); game.start();", + ) + .expect("write namespace-constructed instance alias consumer"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("a namespace constructor must bind its instance alias to the complete member path"); + let projected = modules + .module_units() + .iter() + .find(|unit| { + unit.contains("game.start();") + && unit.contains("function finishConstructor()") + && unit.contains("function finishStart()") + && unit.contains("function finishRun()") + }) + .expect("namespace instance alias must retain constructor and method dependencies"); + assert!(javascript_is_syntactically_valid(projected, true)); + + fs::write( + root.join("game/helper-dependency.mjs"), + "export function finishHelper() { return 'helper'; }", + ) + .expect("write object receiver dependency"); + fs::write( + root.join("game/base-dependency.mjs"), + "export function finishBase() { return 'base'; }", + ) + .expect("write super receiver dependency"); + fs::write( + root.join("game/origin.mjs"), + "const helper = { run() { return import('./helper-dependency.mjs').then(({ finishHelper }) => finishHelper()); }, decoy: { run() { return import('./missing-helper-decoy.mjs'); } } }; class Base { run() { return import('./base-dependency.mjs').then(({ finishBase }) => finishBase()); } } export class Game extends Base { start() { helper.run(); return super.run(); } decoy = { run() { return import('./missing-super-decoy.mjs'); } }; }", + ) + .expect("write exact object and super receiver graph with same-name decoys"); + fs::write( + root.join("game/main.mjs"), + "import { Game } from './origin.mjs'; new Game().start();", + ) + .expect("write object and super receiver consumer"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("object and super calls must select only methods owned by their receivers"); + let projected = modules + .module_units() + .iter() + .find(|unit| { + unit.contains("new Game().start();") + && unit.contains("function finishHelper()") + && unit.contains("function finishBase()") + }) + .expect("exact object and super receiver dependencies must survive projection"); + assert!(javascript_is_syntactically_valid(projected, true)); + + fs::write( + root.join("game/instance-dependency.mjs"), + "export function finishInstance() { return 'instance'; }", + ) + .expect("write instance method dependency"); + fs::write( + root.join("game/origin.mjs"), + "export class Game { static run() { return import('./missing-static-run.mjs'); } run() { return import('./instance-dependency.mjs').then(({ finishInstance }) => finishInstance()); } }", + ) + .expect("write class with same-name static and instance methods"); + fs::write( + root.join("game/main.mjs"), + "import { Game } from './origin.mjs'; new Game().run();", + ) + .expect("write instance-only same-name method consumer"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("instance call must not select the same-name static method"); + assert!(modules.module_units().iter().any( + |unit| unit.contains("new Game().run();") && unit.contains("function finishInstance()") + )); + + fs::write( + root.join("game/static-dependency.mjs"), + "export function finishStatic() { return 'static'; }", + ) + .expect("write static method dependency"); + fs::write( + root.join("game/origin.mjs"), + "export class Game { static run() { return import('./static-dependency.mjs').then(({ finishStatic }) => finishStatic()); } run() { return import('./missing-instance-run.mjs'); } }", + ) + .expect("write inverse same-name static and instance methods"); + fs::write( + root.join("game/main.mjs"), + "import { Game } from './origin.mjs'; Game.run();", + ) + .expect("write static-only same-name method consumer"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("static call must not select the same-name instance method"); + assert!(modules + .module_units() + .iter() + .any(|unit| unit.contains("Game.run();") && unit.contains("function finishStatic()"))); + + fs::write( + root.join("game/class-expression-dependency.mjs"), + "export function finishClassExpression() { return 'class-expression'; }", + ) + .expect("write class expression dependency"); + fs::write( + root.join("game/origin.mjs"), + "const Helper = class { run() { return import('./class-expression-dependency.mjs').then(({ finishClassExpression }) => finishClassExpression()); } }; const helper = new Helper(); export function start() { return helper.run(); }", + ) + .expect("write class expression receiver graph"); + fs::write( + root.join("game/main.mjs"), + "import { start } from './origin.mjs'; start();", + ) + .expect("write class expression consumer"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("class expression instances must retain their method owner"); + assert!(modules.module_units().iter().any(|unit| { + unit.contains("start();") && unit.contains("function finishClassExpression()") + })); + + fs::write( + root.join("game/local-alias-dependency.mjs"), + "export function finishLocalAlias() { return 'local-alias'; }", + ) + .expect("write reassigned local receiver dependency"); + fs::write( + root.join("game/origin.mjs"), + "const real = { run() { return import('./local-alias-dependency.mjs').then(({ finishLocalAlias }) => finishLocalAlias()); } }; let direct = { run() { return import('./missing-direct-owner.mjs'); } }; direct = real; const decoy = { run() { return import('./missing-alias-owner.mjs'); } }; let chained = decoy; chained = real; export function start() { direct.run(); return chained.run(); }", + ) + .expect("write reassigned local receiver graph"); + fs::write( + root.join("game/main.mjs"), + "import { start } from './origin.mjs'; start();", + ) + .expect("write reassigned local receiver consumer"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("the latest reachable local receiver assignments must win"); + assert!(modules + .module_units() + .iter() + .any(|unit| unit.contains("function finishLocalAlias()"))); +} + +#[test] +fn javascript_alias_events_follow_function_invocation_time() { + let static_source = "import * as real from './real.mjs'; import * as decoy from './decoy.mjs'; let facade = real; function start() { return facade.run(); } start(); facade = decoy;"; + let analysis = super::autonomous_completion::javascript_module_analysis(static_source, true) + .expect("analyze static alias timing"); + assert!(analysis.import_member_calls.contains_key("real")); + assert!( + !analysis.import_member_calls.contains_key("decoy"), + "an assignment after the only invocation must not rewrite the function's earlier receiver", + ); + + let repeated_source = format!("{static_source} start();"); + let analysis = super::autonomous_completion::javascript_module_analysis(&repeated_source, true) + .expect("analyze repeated alias timing"); + assert!(analysis.import_member_calls.contains_key("real")); + assert!( + analysis.import_member_calls.contains_key("decoy"), + "calls on both sides of an assignment must retain both possible receivers", + ); + + let dynamic_source = "let facade; facade = await import('./real.mjs'); function start() { return facade.run(); } start(); facade = await import('./decoy.mjs');"; + let analysis = super::autonomous_completion::javascript_module_analysis(dynamic_source, true) + .expect("analyze dynamic alias timing"); + assert!(analysis + .dynamic_import_demands + .keys() + .any(|(source, _)| source == "./real.mjs")); + assert!( + !analysis + .dynamic_import_demands + .keys() + .any(|(source, _)| source == "./decoy.mjs"), + "a dynamic namespace assignment after the only invocation must stay unrelated", + ); + + let exported_source = "import { api } from './origin.mjs'; let facade; export function start() { return facade.run(); } facade = api;"; + let analysis = super::autonomous_completion::javascript_module_analysis(exported_source, true) + .expect("analyze exported static alias timing"); + assert!( + analysis.import_member_calls.contains_key("api"), + "an exported function runs after module initialization and must see later top-level assignments", + ); + + let exported_dynamic_source = "let facade; export function start() { return facade.run(); } facade = await import('./real.mjs');"; + let analysis = + super::autonomous_completion::javascript_module_analysis(exported_dynamic_source, true) + .expect("analyze exported dynamic alias timing"); + assert!(analysis + .dynamic_import_demands + .keys() + .any(|(source, _)| source == "./real.mjs")); + + let local_and_exported_source = "import * as real from './real.mjs'; import * as decoy from './decoy.mjs'; let facade = real; export function start() { return facade.run(); } start(); facade = decoy;"; + let analysis = + super::autonomous_completion::javascript_module_analysis(local_and_exported_source, true) + .expect("analyze local and exported alias timing"); + assert!(analysis.import_member_calls.contains_key("real")); + assert!( + analysis.import_member_calls.contains_key("decoy"), + "an exported root with an earlier local call must retain both invocation-time receivers", + ); } #[test] diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4080b180d..d03be6888 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5977,7 +5977,11 @@ ## 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 状态把当前项纳入逆序回滚,不能留下新旧混合合同。 -- 图集事务 live identity:`prepared` 后继续由同一 trusted transaction directory handle 贯穿 canonical 提交、`committed` 清理和 live rollback,不再按路径重开并接受替换目录。恢复在任何写入前冻结九项 canonical 全部前态,晚序普通文件变化必须 CAS 失败且不得被旧快照覆盖。没有 durable journal 的 legacy `.previous / .replacement` 只做锚定识别并进入 reconciliation,不自动恢复 canonical 或删除残留。 +- 图集事务 live identity:`prepared` 后继续由同一 trusted transaction directory handle 贯穿 canonical 提交、`committed` 清理和 live rollback,不再按路径重开并接受替换目录;发布 `committed` marker 前必须验证 retained handle 的权威 pathname identity,Unix rename 漂移不得降级为成功 warning。恢复在任何写入前冻结九项 canonical 全部前态,晚序普通文件变化必须 CAS 失败且不得被旧快照覆盖。没有 durable journal 的 legacy `.previous / .replacement` 只做锚定识别并进入 reconciliation,不自动恢复 canonical 或删除残留。 - JavaScript / ESM:Tetris 静态连续性检查以 Oxc parser、semantic 与 AST visitor 为权威。无效语法、ASI、template interpolation、正则 / 注释、表达式体箭头、参数和词法遮蔽、export alias、import 后再 export 的 bridge、re-export 与缺失导出链接不再由字符串扫描猜测;跨模块 alias 保留 origin 根绑定名称,只按解析到 import symbol 的 reference span 改写 importer。同一 export 的多个本地 alias 按大小写敏感的 symbol identity 保序保留,同一 dependency 的多条 import declaration 合并绑定;投影在 importer 内按最终 origin 聚合,因此同一 origin 经不同 dependency 或 bridge 到达时也只生成一次根声明。组合单元为 importer 和所有 origin 根绑定分配无冲突的确定性名称,匿名 default、同源私有根名、importer 局部根名及不同 origin 都不得合并;重命名后必须重新通过 parser 与 semantic,只有最终启发式扫描文本统一小写。object shorthand 展开后保留原键;namespace 只改写绑定到 import symbol 的完整 member span,同文本属性和局部遮蔽均不得连带改写。HTML `type` 存在时优先于 legacy `language`。源码投影仍只是静态语义门,最终完成继续要求绑定当前 revision 的真实 Chromium 固定试玩回执。 +- JavaScript / ESM 声明与 namespace 补充:顶层 function/class/variable declaration 直接按 Oxc statement span 投影,禁止用首个分号或换行截断箭头函数、多行 initializer 或多 declarator;对象、数组、默认值和 rest 解构中的全部 binding 必须递归进入导出图,同一声明只投影一次。import symbol 不从 importer 自有 root binding 集合按小写文本扣除,大小写不同的合法绑定继续隔离;模块组合按依赖层数迭代到稳定闭包,使被导出函数引用的 imported dependency 继续进入最终 consumer 单元,同时限制最终 span replacement 后的单个组合单元最多 `2 MiB`、整轮投影累计处理最多 `32 MiB`,分支或循环图超限失败关闭。固定字符串 dynamic import 同样由下游实际使用的 export 反向驱动加载,未使用 export、未调用嵌套函数和恒假分支中的 source 不进入模块单元;被选声明中只有由 `await import` 解构、namespace member 或 `.then(...)` 静态解析到的 export 才投影。callback 参数、解构 alias 和 namespace member 的引用必须按 semantic symbol span 改接到投影根,禁止靠同名文本共现绕过局部遮蔽。完成全部 span replacement 后重新校验完整组合 unit,最终启发式扫描必须先按原始大小写完成 AST 解析和掩码,再归一化文本。匿名 default function / arrow 在原始 AST 中也必须以 collision-safe synthetic binding 注册可外调 root span,保证 wrapper 内 imported member 的传递依赖继续传播;synthetic binding 必须避开真实根绑定,冲突改名只更新 `default` target,不得改写用户同名命名导出。renamed re-export 的 namespace 投影同时携带 importer member 名和 origin export 名,分别用于定位 consumer span 与 origin declaration。named import、namespace import 及 namespace 解构 alias 的成员调用必须保留完整静态成员路径,并用调用 span 的恒假分支可达性过滤后再向上游传播 demand;对象 / class 的直接成员、解构 alias、实例 alias 与下游 wrapper 都遵循同一规则。对象的 method shorthand、函数表达式值、箭头函数值以及 class function-valued field 均按精确函数 span 注册成员根,不能因声明写法不同漏载其可达 dynamic dependency,也不能把同一属性内未调用的嵌套函数误当根。 +- JavaScript / ESM occurrence 与成员根补充:dynamic import demand 必须以 source 和 import occurrence position 共同隔离,同 source 的可达裸 import 不得借用不可达 occurrence 的 export;动态 namespace 保留首段 export 后的完整成员路径,声明 initializer 与后续赋值式 `await import` 都绑定 semantic symbol。named / namespace 成员作为回调参数、对象解构 alias、实例 alias 和下游 wrapper 时仍按实际调用 span 传播 demand。constructor、`new Game().method()` 与实例 alias 分别建立精确成员根,`this.method()` 只匹配同一 class / object owner。顶层声明位置直接保留 Oxc span,不得用源码文本 `find` 反查。 +- JavaScript / ESM alias 与 receiver 补充:imported member 作为参数时只允许受控的 callback API 建立执行 demand,普通日志或元数据传参不得推断为调用。普通 member alias 同时支持声明 initializer 与后续赋值,`new ns.Game()` 等完整 constructor path 必须传播到实例 alias。局部对象、class、实例、`this` 与 `super` 的方法调用统一按 semantic receiver owner 匹配,禁止再按末级方法名跨 owner 扩散到同名 decoy。 +- JavaScript / ESM 控制流身份补充:member alias、局部 receiver 与动态 namespace 的赋值必须保存 assignment position 和 enclosing function scope;函数体使用按真实 invocation position 选择当时事件,多次调用跨越赋值边界时合并可能 owner,恒假分支、未调用函数或调用之后的赋值不得覆盖更早使用点。导出的 function 与 class/object member 额外以模块初始化结束作为潜在外部调用点,使声明后生效的顶层赋值进入 demand,同时保留更早本地调用状态。动态依赖传播以原始 owner 模块 AST span 为权威,不因投影重排声明或省略独立赋值语句重算 alias。class method owner 进一步区分 static / instance,class expression 与实例化 alias 使用同一 owner 图。callback API 只接受 semantic 未解析的已知全局调度函数,以及可由 AST 证明的 literal array / dynamic import 调用;被用户定义或遮蔽的同名 `setTimeout / map / then` 不得推断执行参数。 - 浏览器因果:状态证据仍只冻结 trusted input listener 及其点击派生微任务内的变化;完整手势身份改由宿主在成功完成 Chromium 元素鼠标输入后调用隔离世界 finish。更早注册的 `window` capture listener 即使调用 `stopImmediatePropagation()` 也不能阻断探针自身的完成身份,页面脚本不能伪造 host finish,RAF / timer 继续不计入动作结果。 - 验证边界:Linux 定向回归覆盖目录相对读写与清理、祖先 symlink、CAS 安装后错误、九文件混合快照、Tetris AST 反例和七项真实 Chrome generic 试玩。Windows cfg 代码必须继续在真实 Windows CI / 发布构建验证;本地缺少 MinGW C compiler 时,安装了 Rust target 也不能把交叉 `cargo check` 失败误报为源码失败。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8148af1bb..a627afa98 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -828,9 +828,13 @@ game-project/ - game-chat 快车道只能在 `game/index.html` 缺失或仍是初始化占位,且当前 child run 尚未写入正式入口时使用首次 fallback `file.write`。项目已存在非占位入口时,后续 `code-prototype` 必须先保留并读取既有玩法,做真实局部修改并取得本人 `mutationRevision`,之后才能运行 `game.static_smoke` 与交付;禁止为了满足首版时限重新生成整份默认小游戏,也禁止连续只读 smoke。占位 fallback 仅允许俄罗斯方块和明确收集类等已有真实语义模板,未知玩法失败关闭。纯继续意图未能恢复唯一原始目标时同样失败关闭,不输出以“继续”为标题的兜底产物。 - `assets/art-spec.png` 的唯一语义是视觉规范与派生参考,不是运行时背景、角色、目标或图集。game-chat 的核心玩家、方块/目标、障碍/场景和反馈必须来自独立派生的透明 `assets/art-spritesheet.png` 及其服务端 `iconImageSrcs` 本地切片;Runtime 以 `sourceResourceId` 把切片清单绑定到当前图集,并要求活动 Canvas 分别绘制四类不同切片。纯代码核心实体、猜测图集等分坐标、单个裁切冒充全部类别、整图展示、隐藏引用、微小水印和诱饵路径均不构成真实美术使用。`playable-web-game-state.v1.sequence` 只在真实输入、状态迁移或模拟状态变化时递增,不得由纯渲染帧推进。 - game-chat canonical 图集进一步要求主图与四个切片都有非空且互不复用的 Canvas `assetObjectId`;同一对象在顶层、resource 与 asset 中重复返回的 `assetObjectId` 和 `taskId` 必须分别一致,冲突时失败关闭。公开 `assets/art-spritesheet-slices/manifest.json` 与私有 `.agent/runtime/art-spritesheet-contract.json` 必须同时绑定 `sourceResourceId / sourceAssetObjectId / sourceTaskId / sourceCanvasProjectId / sourceReferenceResourceIds`,并对四种 usage 的 `name / path / width / height / resourceId / assetObjectId / contentSha256 / pixelSha256` 做完整一致性比较。旧项目缺私有回执时不得从公开文件反向生成回执;只允许同一 game-chat root 下处于 running 的 scheduled `art-asset-plan` 对固定主图执行受限 `replaceExisting=true` repair,普通 pending、其它 Agent、其它路径或有效合同均拒绝。九个固定合同文件在任何 canonical 改动前必须先全部打开可信源句柄,完成有界双读,并在 journal 持久化后、发布 `prepared` marker 前再次整体复读与身份校验;任一文件在九文件捕获窗口变化都失败关闭,不能形成跨版本混合快照。事务控制文件在 Unix 通过锚定目录句柄的 `openat / unlinkat + O_NOFOLLOW + O_NONBLOCK` 读取和清理,FIFO 等非普通文件不得阻塞恢复;恢复前态在同一叶子句柄稳定双读并复核元数据、内容和当前 inode。Windows 祖先目录 pin 只请求读访问并拒绝 delete sharing,允许九文件捕获重复持有同一 root;只有事务叶子句柄请求删除访问。事务目录创建、合同源捕获与恢复 CAS 必须从 canonical 项目根句柄逐组件打开或创建父目录,staging、hard-link、no-replace move 与 unlink 全部相对固定父目录句柄执行,不能退回 pathname 预检后操作。CAS 安装先把目标字节持久化到同目录私有 staging,再以不覆盖切换安装;即使 canonical 已安装后清理 backup/staging 才报错,也必须把当前项纳入同轮逆序回滚。Canvas 资产登记成功并写 `committed` marker 后才可清理;恢复继续在同一项目写锁内完整验证全部 journal 条目和快照、形成内存恢复计划并缓存全部 canonical 的恢复前状态,逐项安装完成后、清理事务证据前还必须再次复核整组 installed 状态,再按 transaction id 整组回滚或幂等清理。回滚前必须 CAS 证明目标仍是本轮安装结果,外部修改不得被覆盖,冲突进入 reconciliation。远端下载阶段不得长期持锁,也不得在无锁 request 阶段修改 canonical 文件。 -- `prepared` 发布后,创建阶段锚定的事务目录句柄和 identity 必须由 live rollback 对象一直持有到 `committed` 清理或 rollback 结束,提交和回滚不得按 `PathBuf` 重新接受替换目录。恢复在首个 canonical 写入前一次性冻结九路径全部前态,晚序普通文件内容变化也必须触发 CAS 冲突并逆序回滚早序安装。历史 `.previous / .replacement` 若没有 durable journal,只允许通过锚定父目录识别后进入 reconciliation;不得凭 pathname 自动 hard-link、move、恢复 canonical 或删除残留。 +- `prepared` 发布后,创建阶段锚定的事务目录句柄和 identity 必须由 live rollback 对象一直持有到 `committed` 清理或 rollback 结束,提交和回滚不得按 `PathBuf` 重新接受替换目录;live commit 在发布 `committed` 前必须先验证 retained handle 仍对应权威 pathname,身份漂移不得降级为提交成功 warning。恢复在首个 canonical 写入前一次性冻结九路径全部前态,晚序普通文件内容变化也必须触发 CAS 冲突并逆序回滚早序安装。历史 `.previous / .replacement` 若没有 durable journal,只允许通过锚定父目录识别后进入 reconciliation;不得凭 pathname 自动 hard-link、move、恢复 canonical 或删除残留。 - 图集本地提交以主图 staging 为线性化前置:任何新主图先写随机私有 staging 文件,替换时保留 previous,canonical 主图完整安装后才写四切片、公开清单、私有回执和项目资产登记。进程若在 backup/install 窗口退出,同一 accepted External generation 恢复先识别唯一同 suffix 的 previous/replacement 对并恢复旧主图,再按远端结果完成替换;若 canonical 已等于远端摘要,则不再要求替换授权,直接补齐其余合同。成功后清理主图、四切片、公开清单、私有回执和项目 manifest 的全部遗留 staging/backup。首次生成也禁止直接流式写 canonical 路径,避免部分 PNG 被误认为已安装结果。 - 俄罗斯方块任务固定使用 `BrowserPlaytestScenario::TetrisV1`。`playable-web-game-state.v1.gameplay` 必须持续提供 `kind=tetris`、`activePieceId`、`rotation`、`row`、`lockedPieces`、`lineClearChecks`、`clearedLines` 和 `occupiedCells`;任何采样点删除字段都立即失败,但允许 `score / nextPieceId` 等额外 telemetry。静态连续性检查按 HTML 规定的五种空白解析标签,并在 `type` 存在时忽略 legacy `language`,再结合 `nomodule` 判断可执行脚本。JavaScript / ESM 必须先通过 Oxc parser 与 semantic;无效语法失败关闭,import/export production、ASI、default / namespace / alias / import 后再 export 的 bridge / re-export 链接、template `${...}` 内表达式、注释与正则边界都以 AST 为权威,不得跨换行猜测 `from` 或把未链接模块当完成证据。跨模块 alias 投影保留 origin 根绑定,只改写解析到 import symbol 的 importer reference span;同一 export 的多个本地 alias 必须按大小写敏感的 symbol identity 保序保留,同一 dependency 的多条 import declaration 合并绑定,同一 origin 即使经不同 dependency 或 bridge 到达也只投影一次。组合 importer 与 origin 前,全部投影根绑定都必须分配跨 importer、同源私有绑定和其它 origin 无冲突的确定性名称,重命名后重新通过 parser 与 semantic;只有最终交给静态启发式扫描的文本允许统一小写。object shorthand 展开为显式键值以保留原键,namespace 只改写完整 member span。同文本对象属性和局部遮蔽不得连带改写,投影依赖也只由 semantic 未解析根引用递归纳入。函数定义、表达式体箭头、调用可达性和参数 / 局部遮蔽按 semantic symbol identity 判断;guard return / throw 只终止其真实控制流分支,不能截断后续可达玩法。字符串、注释、HTML raw-text/RCDATA 与其它非执行容器、带 `src` 脚本的内联正文、非 JavaScript script、短路动态 import、恒真分支的 else、顶层无条件 return / throw 后正文和 `if(false)` / 明显恒假分支诱饵继续不构成证据;filter / splice 消行仍必须由满行判断真实控制,并作用于正式棋盘。本地 `.js / .mjs`、inline module 与传递依赖统一限制在 `game/`,文件按去重数量并受 256 文件、累计 2 MiB 上限约束。浏览器因果探针运行于 Chromium 隔离执行上下文,Promise 闭包保存点击前基线,MutationObserver 只冻结 trusted 输入 listener 及其点击派生微任务产生的最后状态;宿主只有在 Chromium 元素鼠标输入成功完成后才调用隔离世界 finish,把该 CDP 结果作为完整手势证据,页面无法伪造。这样更早注册的 `window` capture listener 即使 `stopImmediatePropagation()`,以及后注册的同步 click listener,都不会造成假阴性;RAF / timer 仍不会污染证据。探针 fingerprint 覆盖 install、ready 与 finish 的真实脚本。其余同方块旋转、重力/锁定、四格落盘或消行、`lineClearChecks` 和 restart 归零约束保持不变。旧/续跑合同及 game-chat 快车道在回执读取前按有效原任务迁移到该场景、重算 fingerprint 并回读一致,旧 generic-v1 回执视为 stale,不能交付完成。 +- ESM 投影中的顶层 function/class/variable 声明必须直接使用 Oxc statement span 提取,不能用首个分号或换行截断箭头函数、多行 initializer 或多 declarator;对象、数组、默认值与 rest 解构声明必须递归收集全部 binding,并保证同一声明只投影一次。import symbol 与 importer 自有 root binding 分开保存,即使名称仅大小写不同也不得在组合前折叠;模块组合必须按依赖深度迭代到稳定闭包,把被导出函数继续依赖的 imported origin 带入最终 consumer 单元,并以最终 span replacement 后的单 unit `2 MiB`、累计投影处理 `32 MiB` 为失败关闭上限。固定字符串 dynamic import 也必须由下游实际使用的 export 反向驱动加载;未使用 export、未调用嵌套函数和恒假分支中的 dynamic source 不得进入模块单元。只有被选声明中的 `await import` 解构、namespace member 或 `.then(...)` 静态 binding 才能进入组合投影;callback 参数、解构 alias 和 namespace member 必须按 semantic symbol span 改接到投影根,不能靠追加同名文本跨过局部遮蔽。span replacement 完成后还要对完整组合 unit 重跑 parser 与 semantic,启发式扫描只能在原始源码完成 AST 解析和掩码后再统一小写。匿名 default function / arrow 必须在原始 AST 中以 collision-safe synthetic binding 注册可外调 root span,使 wrapper 内 imported member 的传递依赖继续传播;synthetic binding 必须避开用户真实根名,改名只能更新 default target,不能污染同名命名导出。namespace 经过 renamed re-export 时,同时保留 importer 使用的 member 名和最终 origin export 名:前者定位 importer member span,后者选择 origin declaration,不能混用。named import、namespace import 和 namespace 解构 alias 的成员调用必须保留完整静态成员路径,并按调用 span 排除恒假分支后再把 demand 传播到上游;对象 / class 直接成员、对象解构 alias、实例 alias 与下游 wrapper 都使用同一条可达性链。对象 method shorthand、函数表达式值、箭头函数值以及 class function-valued field 必须按精确函数 span 注册成员根,只加载被实际调用成员中的 dynamic dependency,不能因声明形式遗漏,也不能把属性内未调用的嵌套函数升级为根。 +- dynamic import demand 必须以 source 与 import occurrence position 联合作为身份,同 source 的可达裸 import 不得继承不可达 occurrence 的 export。动态 namespace 在首段 export 之后继续保留完整 member path,声明 initializer 与后续赋值式 `await import` 都必须绑定 semantic symbol。named / namespace 成员作为回调参数、对象解构 alias、实例 alias 与下游 wrapper 时继续按真实调用 span 传播 demand。constructor、`new Game().method()` 和实例 alias 分别形成精确成员根,`this.method()` 只匹配相同 class / object owner;顶层声明位置只使用 Oxc span,禁止以源码文本 `find` 反查。 +- imported member 只有作为受控 callback API 的参数时才建立执行 demand;日志、注册元数据等普通传参不能被当作调用。普通 member alias 必须同时支持声明 initializer 与后续赋值,`new ns.Game()` 的完整 constructor path 继续传播到实例 alias。局部对象、class、实例、`this` 和 `super` 的方法调用统一按 semantic receiver owner 匹配,禁止按末级方法名跨 owner 选中同名 decoy。 +- member alias、局部 receiver 与动态 namespace 的 assignment 必须保留赋值位置和 enclosing function scope;函数体内的使用必须按真实 invocation position 选择当时已生效的事件,多次调用跨过赋值边界时合并全部可能 owner,恒假分支、未调用函数或调用之后的赋值不能倒灌覆盖更早使用点。被导出的 function、class/object member 还必须把模块初始化完成视为潜在外部调用时点,使声明之后生效的顶层赋值进入 demand,同时保留此前本地调用的旧状态。动态依赖传播继续以原始 owner 模块 AST span 为权威,不得因投影重排声明或省略独立赋值语句而重算出相反 alias。class owner 必须继续区分 static / instance,class expression 与实例化 alias 进入相同 receiver 图。callback API 只接受 semantic 未解析的已知全局调度函数或 AST 可证明的 literal array / dynamic import 调用;用户定义或遮蔽的同名 `setTimeout / map / then` 不能触发参数执行推断。 - 泥点不足是确定性业务中断,不是瞬态 Provider 故障或未知副作用。钱包的 `泥点余额不足` 与 `可消费泥点不足:...` 两种领域文案统一映射为稳定原因 `mud-points-insufficient`,不得自动重试;即使 External Generation durable ledger 已存在,也必须落为 `failed`,不能误入 `needs-reconciliation`。game-chat 顶部状态、持久失败对话与 `【Supervisor 阶段记录】` 统一显示“泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。”,并禁止透传 operationId、URL、路径、密钥或任意上游正文。 - tool-plan 成功响应落账前,对内置 Runtime 原生函数与 legacy wrapper 的合法、无重复 key JSON arguments 按工具 schema 的精确位置做项目路径 canonicalization:`file.*.path`、`project.patchset.changes[*].path`、`project.git_commit.paths[*]`、`command.*.cwd`、`image.inspect.paths[*]` 与 `canvas.asset_generate.outputPath` 若是当前项目根目录内的完整绝对路径,转换为 `/` 分隔的项目相对路径后再校验、持久化并执行;源码/叙述字段、任务产物描述、动态 MCP arguments 和项目外绝对路径不得改写,后两者继续由绝对路径门禁失败关闭。项目根只允许搜索/列举范围与命令 cwd 规范化为 `.`,不能成为文件目标。当前进程与重启恢复都必须从同一份规范化 handoff 重放,禁止分别执行原响应和持久响应。