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 117f3635d..0b3fd5a4c 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 @@ -5226,8 +5226,9 @@ pub(super) struct JavascriptModuleAnalysis { pub(super) dynamic_import_demands: BTreeMap<(String, usize), BTreeMap<(String, Vec), Vec>>, dynamic_import_binding_references: - BTreeMap>>, - dynamic_import_member_ranges: BTreeMap>>>, + BTreeMap<(String, usize), BTreeMap>>, + dynamic_import_member_ranges: + BTreeMap<(String, usize), BTreeMap>>>, } fn javascript_module_export_name(name: &JavascriptModuleExportName<'_>) -> String { @@ -5922,9 +5923,9 @@ struct JavascriptDynamicImportUsageCollector<'a> { demands: BTreeMap<(String, usize), BTreeMap<(String, Vec), Vec>>, bindings: BTreeMap, bool)>, binding_references: - BTreeMap>>, + BTreeMap<(String, usize), BTreeMap>>, shorthand_ranges: BTreeSet<(usize, usize)>, - member_ranges: BTreeMap>>>, + member_ranges: BTreeMap<(String, usize), BTreeMap>>>, } struct JavascriptImportMemberCallCollector<'a> { @@ -6237,7 +6238,178 @@ fn javascript_awaited_import_source( } } +fn javascript_promise_executor_is_statically_empty(expression: &JavascriptExpression<'_>) -> bool { + match expression { + JavascriptExpression::ArrowFunctionExpression(function) => match &function.body { + oxc_ast::ast::ArrowFunctionBody::FunctionBody(body) => body.statements.is_empty(), + _ => false, + }, + JavascriptExpression::FunctionExpression(function) => function + .body + .as_ref() + .is_some_and(|body| body.statements.is_empty()), + JavascriptExpression::ParenthesizedExpression(parenthesized) => { + javascript_promise_executor_is_statically_empty(&parenthesized.expression) + } + _ => false, + } +} + +struct JavascriptNonCompletingTopLevelAwaitCollector<'a> { + scoping: &'a JavascriptScoping, + function_depth: usize, + found: bool, +} + +impl<'a> VisitJavascript<'a> for JavascriptNonCompletingTopLevelAwaitCollector<'_> { + fn visit_function(&mut self, function: &JavascriptFunction<'a>, flags: JavascriptScopeFlags) { + self.function_depth += 1; + oxc_ast_visit::walk::walk_function(self, function, flags); + self.function_depth -= 1; + } + + fn visit_arrow_function_expression( + &mut self, + function: &JavascriptArrowFunctionExpression<'a>, + ) { + self.function_depth += 1; + oxc_ast_visit::walk::walk_arrow_function_expression(self, function); + self.function_depth -= 1; + } + + fn visit_await_expression(&mut self, awaited: &oxc_ast::ast::AwaitExpression<'a>) { + if self.function_depth == 0 { + if let JavascriptExpression::NewExpression(new_expression) = &awaited.argument { + let promise_is_global = new_expression + .callee + .get_identifier_reference() + .filter(|identifier| identifier.name == "Promise") + .and_then(|identifier| identifier.reference_id.get()) + .is_some_and(|reference_id| { + self.scoping + .get_reference(reference_id) + .symbol_id() + .is_none() + }); + let executor_is_empty = new_expression + .arguments + .first() + .and_then(JavascriptArgument::as_expression) + .is_some_and(javascript_promise_executor_is_statically_empty); + if promise_is_global && executor_is_empty { + self.found = true; + } + } + } + oxc_ast_visit::walk::walk_await_expression(self, awaited); + } +} + +fn javascript_has_statically_non_completing_top_level_await(content: &str) -> bool { + let allocator = JavascriptAllocator::default(); + let parsed = JavascriptParser::new(&allocator, content, javascript_source_type(true)).parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { + return false; + } + let semantic = JavascriptSemanticBuilder::new_compiler().build(&parsed.program); + if !semantic.diagnostics.is_empty() { + return false; + } + let mut collector = JavascriptNonCompletingTopLevelAwaitCollector { + scoping: semantic.semantic.scoping(), + function_depth: 0, + found: false, + }; + collector.visit_program(&parsed.program); + collector.found +} + impl JavascriptDynamicImportUsageCollector<'_> { + fn record_member_demand( + &mut self, + source: String, + import_position: usize, + path: Vec, + position: usize, + replacement_range: Option>, + ) { + let Some((exported, member_path)) = path.split_first() else { + return; + }; + self.exports + .entry(source.clone()) + .or_default() + .insert(exported.clone()); + self.demands + .entry((source.clone(), import_position)) + .or_default() + .entry((exported.clone(), member_path.to_vec())) + .or_default() + .push(position); + if let Some(range) = replacement_range { + self.member_ranges + .entry((source, import_position)) + .or_default() + .entry(exported.clone()) + .or_default() + .push(range); + } + } + + fn record_member_expression( + &mut self, + object: &JavascriptExpression<'_>, + property: &str, + range: std::ops::Range, + position: usize, + ) { + if let JavascriptExpression::Identifier(identifier) = object { + 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)) + { + let resolved = javascript_alias_event_values( + events, + self.ranges, + self.content, + self.literal_false_ranges, + self.conditional_ranges, + position, + ) + .into_iter() + .flatten() + .cloned() + .collect::>(); + for (source, import_position) in resolved { + self.record_member_demand( + source, + import_position, + vec![property.to_string()], + position, + Some(range.clone()), + ); + } + } + return; + } + if let Some((source, import_position)) = javascript_awaited_import_source(object) { + self.record_member_demand( + source, + import_position, + vec![property.to_string()], + position, + Some(range), + ); + return; + } + for (source, import_position, mut path) in self.resolve_namespace_member(object, position) { + path.push(property.to_string()); + self.record_member_demand(source, import_position, path, position, None); + } + } + fn record_namespace_source( &mut self, symbol_id: JavascriptSymbolId, @@ -6271,6 +6443,9 @@ impl JavascriptDynamicImportUsageCollector<'_> { expression: &JavascriptExpression<'_>, position: usize, ) -> Vec<(String, usize, Vec)> { + if let JavascriptExpression::ParenthesizedExpression(parenthesized) = expression { + return self.resolve_namespace_member(&parenthesized.expression, position); + } let Some(mut member) = expression.as_member_expression() else { return Vec::new(); }; @@ -6316,6 +6491,19 @@ impl JavascriptDynamicImportUsageCollector<'_> { path.reverse(); return vec![(source, import_position, path)]; } + if let JavascriptExpression::NewExpression(construction) = expression { + path.reverse(); + return self + .resolve_namespace_member(&construction.callee, position) + .into_iter() + .map(|(source, import_position, mut constructor_path)| { + constructor_path + .push(JAVASCRIPT_INSTANCE_MEMBER_DEMAND.to_string()); + constructor_path.extend(path.clone()); + (source, import_position, constructor_path) + }) + .collect(); + } let Some(next) = expression.as_member_expression() else { return Vec::new(); }; @@ -6489,44 +6677,12 @@ impl<'a> VisitJavascript<'a> for JavascriptDynamicImportUsageCollector<'_> { } 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, - self.literal_false_ranges, - self.conditional_ranges, - position, - ) - .into_iter() - .flatten() - { - 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); - } - } - } + self.record_member_expression( + &member.object, + member.property.name.as_str(), + member.span.start as usize..member.span.end as usize, + member.span.start as usize, + ); oxc_ast_visit::walk::walk_static_member_expression(self, member); } @@ -6534,59 +6690,28 @@ impl<'a> VisitJavascript<'a> for JavascriptDynamicImportUsageCollector<'_> { &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, - self.literal_false_ranges, - self.conditional_ranges, - position, - ) - .into_iter() - .flatten() - { - 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); - } - } + if let Some(property) = member.static_property_name() { + self.record_member_expression( + &member.object, + property.as_str(), + member.span.start as usize..member.span.end as usize, + 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)) + if let Some((source, import_position, 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()) + .entry((source.clone(), *import_position)) .or_default() .entry(exported.clone()) .or_default(); @@ -7291,15 +7416,15 @@ fn javascript_projected_assignment_root_function_indices( root_binding: &str, required_static: Option, member_path: &[String], -) -> BTreeSet { +) -> Option> { let allocator = JavascriptAllocator::default(); let parsed = JavascriptParser::new(&allocator, content, javascript_source_type(true)).parse(); if parsed.panicked || !parsed.diagnostics.is_empty() { - return BTreeSet::new(); + return None; } let semantic = JavascriptSemanticBuilder::new_compiler().build(&parsed.program); if !semantic.diagnostics.is_empty() { - return BTreeSet::new(); + return None; } let scoping = semantic.semantic.scoping(); let Some(root_symbol) = scoping @@ -7307,10 +7432,10 @@ fn javascript_projected_assignment_root_function_indices( .get(root_binding) .copied() else { - return BTreeSet::new(); + return None; }; let method_ranges = javascript_member_method_ranges(content); - let mut roots = BTreeSet::new(); + let mut final_roots = None; for statement in &parsed.program.body { let JavascriptStatement::ExpressionStatement(statement) = statement else { continue; @@ -7331,10 +7456,11 @@ fn javascript_projected_assignment_root_function_indices( } match required_static { Some(false) => { - if target_path.first().map(String::as_str) != Some("prototype") { + if target_path.first().map(String::as_str) == Some("prototype") { + target_path.remove(0); + } else if !target_path.is_empty() { continue; } - target_path.remove(0); } Some(true) if target_path.first().map(String::as_str) == Some("prototype") => { continue; @@ -7342,7 +7468,7 @@ fn javascript_projected_assignment_root_function_indices( _ => {} } if target_path == member_path { - roots.extend(javascript_callable_expression_function_indices( + final_roots = Some(javascript_callable_expression_function_indices( &assignment.right, ranges, scoping, @@ -7356,7 +7482,7 @@ fn javascript_projected_assignment_root_function_indices( continue; } let assignment_span = assignment.right.span(); - roots.extend( + final_roots = Some( method_ranges .get(suffix) .into_iter() @@ -7378,10 +7504,11 @@ fn javascript_projected_assignment_root_function_indices( method.range.start == range.start && method.range.end == range.end }) .map(|(index, _)| index) - }), + }) + .collect(), ); } - roots + final_roots } fn javascript_projected_root_function_indices( @@ -7402,7 +7529,7 @@ fn javascript_projected_root_function_indices( _ => (None, member_path), }; let methods = javascript_member_method_ranges(content); - let mut roots = methods + let roots = methods .get(actual_member_path) .into_iter() .flat_map(|method_ranges| method_ranges.iter()) @@ -7425,13 +7552,15 @@ fn javascript_projected_root_function_indices( .map(|(index, _)| index) }) .collect::>(); - roots.extend(javascript_projected_assignment_root_function_indices( + if let Some(assigned_roots) = javascript_projected_assignment_root_function_indices( content, ranges, root_binding, required_static, actual_member_path, - )); + ) { + return assigned_roots; + } return roots; } let top_level = ranges @@ -7452,19 +7581,22 @@ fn javascript_projected_root_function_indices( }) .map(|(index, _)| index) .collect::>(); - let mut roots = top_level + let roots = top_level .iter() .copied() .filter(|index| ranges[*index].name == root_binding) .collect::>(); - roots.extend(javascript_projected_assignment_root_function_indices( + if let Some(assigned_roots) = javascript_projected_assignment_root_function_indices( content, ranges, root_binding, None, &[], - )); - roots + ) { + assigned_roots + } else { + roots + } } #[derive(Default)] @@ -7800,7 +7932,16 @@ fn javascript_assignment_target_root_symbol( } } -fn javascript_top_level_assignment_writes(content: &str) -> BTreeMap> { +#[derive(Clone)] +struct JavascriptTopLevelAssignmentWrite { + position: usize, + range: std::ops::Range, + source: String, +} + +fn javascript_top_level_assignment_writes( + content: &str, +) -> BTreeMap> { let allocator = JavascriptAllocator::default(); let parsed = JavascriptParser::new(&allocator, content, javascript_source_type(true)).parse(); if parsed.panicked || !parsed.diagnostics.is_empty() { @@ -7816,7 +7957,7 @@ fn javascript_top_level_assignment_writes(content: &str) -> BTreeMap>(); - let mut writes = BTreeMap::>::new(); + let mut writes = BTreeMap::>::new(); for statement in &parsed.program.body { let JavascriptStatement::ExpressionStatement(statement) = statement else { continue; @@ -7838,7 +7979,11 @@ fn javascript_top_level_assignment_writes(content: &str) -> BTreeMap>(); let mut included = BTreeSet::new(); - let mut included_declarations = BTreeSet::new(); + let declaration_ranges = javascript_top_level_declaration_ranges(content); + let mut included_declarations = BTreeMap::<(usize, usize), String>::new(); let mut included_write_bindings = BTreeSet::new(); let mut selected_writes = BTreeMap::::new(); - let mut projection = String::new(); while let Some(name) = pending.pop() { if !included.insert(name.clone()) { continue; @@ -7906,9 +8051,18 @@ pub(in crate::agent) fn javascript_module_binding_projection( let Some(declaration) = declarations.get(&name) else { continue; }; - if included_declarations.insert(declaration.clone()) { - projection.push_str(declaration); - projection.push('\n'); + let declaration_range = declaration_ranges + .get(&name) + .cloned() + .or_else(|| analysis.synthetic_declarations.get(&name).cloned()) + .unwrap_or(usize::MAX..usize::MAX); + if included_declarations + .insert( + (declaration_range.start, declaration_range.end), + declaration.clone(), + ) + .is_none() + { let Some(dependencies) = javascript_projection_fragment_dependencies(declaration, &declarations) else { @@ -7921,12 +8075,12 @@ pub(in crate::agent) fn javascript_module_binding_projection( ); } if included_write_bindings.insert(name.clone()) { - for (position, write) in assignment_writes.get(&name).into_iter().flatten() { + for write in assignment_writes.get(&name).into_iter().flatten() { selected_writes - .entry(*position) - .or_insert_with(|| write.clone()); + .entry(write.position) + .or_insert_with(|| write.source.clone()); let Some(dependencies) = - javascript_projection_fragment_dependencies(write, &declarations) + javascript_projection_fragment_dependencies(&write.source, &declarations) else { return String::new(); }; @@ -7938,6 +8092,11 @@ pub(in crate::agent) fn javascript_module_binding_projection( } } } + let mut projection = String::new(); + for declaration in included_declarations.into_values() { + projection.push_str(&declaration); + projection.push('\n'); + } for write in selected_writes.into_values() { projection.push_str(&write); projection.push('\n'); @@ -7997,6 +8156,15 @@ fn javascript_remove_duplicate_projection_declarations( } renamed_duplicates.insert(existing.clone()); } + let duplicate_write_ranges = javascript_top_level_assignment_writes(projection) + .into_iter() + .filter(|(binding, _)| renamed_duplicates.contains(binding)) + .flat_map(|(_, writes)| { + writes + .into_iter() + .map(|write| (write.range.start, write.range.end)) + }) + .collect::>(); let duplicate_ranges = javascript_top_level_declaration_ranges(projection) .into_iter() .filter(|(binding, _)| renamed_duplicates.contains(binding)) @@ -8004,6 +8172,11 @@ fn javascript_remove_duplicate_projection_declarations( .collect::>(); let mut replacements = duplicate_ranges .into_iter() + .chain( + duplicate_write_ranges + .into_iter() + .map(|(start, end)| (start, end)), + ) .map(|(start, end)| (start..end, String::new())) .collect::>(); if !apply_javascript_span_replacements(projection, &mut replacements) { @@ -8222,9 +8395,11 @@ fn normalize_javascript_module_analysis_sources( } 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) { + for ((source, import_position), exports) in + std::mem::take(&mut analysis.dynamic_import_binding_references) + { let normalized_exports = dynamic_import_binding_references - .entry(normalize(&source)?) + .entry((normalize(&source)?, import_position)) .or_insert_with(BTreeMap::new); for (exported, references) in exports { normalized_exports @@ -8235,9 +8410,11 @@ fn normalize_javascript_module_analysis_sources( } 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) { + for ((source, import_position), exports) in + std::mem::take(&mut analysis.dynamic_import_member_ranges) + { let normalized_exports = dynamic_import_member_ranges - .entry(normalize(&source)?) + .entry((normalize(&source)?, import_position)) .or_insert_with(BTreeMap::new); for (exported, ranges) in exports { normalized_exports @@ -8348,8 +8525,8 @@ fn validate_javascript_module_links( fn javascript_reachable_dynamic_import_demands( analysis: &JavascriptModuleAnalysis, mut reachable: impl FnMut(usize) -> bool, -) -> BTreeMap)>> { - let mut reachable_demands = BTreeMap::)>>::new(); +) -> BTreeMap<(String, usize), BTreeSet<(String, Vec)>> { + let mut reachable_demands = BTreeMap::<(String, usize), BTreeSet<(String, Vec)>>::new(); for ((source, import_position), demands) in &analysis.dynamic_import_demands { if !reachable(*import_position) { continue; @@ -8357,7 +8534,7 @@ fn javascript_reachable_dynamic_import_demands( for (demand, positions) in demands { if positions.iter().any(|position| reachable(*position)) { reachable_demands - .entry(source.clone()) + .entry((source.clone(), *import_position)) .or_default() .insert(demand.clone()); } @@ -8366,6 +8543,72 @@ fn javascript_reachable_dynamic_import_demands( reachable_demands } +fn javascript_global_side_effect_projection(content: &str) -> String { + let allocator = JavascriptAllocator::default(); + let parsed = JavascriptParser::new(&allocator, content, javascript_source_type(true)).parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { + return String::new(); + } + let mut projection = String::new(); + for statement in &parsed.program.body { + let JavascriptStatement::ExpressionStatement(statement) = statement else { + continue; + }; + let span = statement.span; + let Some(fragment) = content.get(span.start as usize..span.end as usize) else { + continue; + }; + let fragment_allocator = JavascriptAllocator::default(); + let fragment_parse = + JavascriptParser::new(&fragment_allocator, fragment, javascript_source_type(true)) + .parse(); + if fragment_parse.panicked || !fragment_parse.diagnostics.is_empty() { + continue; + } + let semantic = JavascriptSemanticBuilder::new_compiler().build(&fragment_parse.program); + if !semantic.diagnostics.is_empty() { + continue; + } + let globals = semantic.semantic.scoping().root_unresolved_references(); + if !["globalThis", "window"] + .iter() + .any(|name| globals.keys().any(|global| global.as_str() == *name)) + { + continue; + } + projection.push_str(fragment); + projection.push('\n'); + } + projection +} + +fn javascript_static_dependency_initialization_order( + importer: &str, + module_bindings: &BTreeMap)>>, +) -> Vec { + fn visit( + module: &str, + module_bindings: &BTreeMap)>>, + visited: &mut BTreeSet, + order: &mut Vec, + ) { + if !visited.insert(module.to_string()) { + return; + } + for (dependency, _) in module_bindings.get(module).into_iter().flatten() { + visit(dependency, module_bindings, visited, order); + } + order.push(module.to_string()); + } + + let mut visited = BTreeSet::from([importer.to_string()]); + let mut order = Vec::new(); + for (dependency, _) in module_bindings.get(importer).into_iter().flatten() { + visit(dependency, module_bindings, &mut visited, &mut order); + } + order +} + pub(in crate::agent) fn read_external_gameplay_javascript_at( root: &Path, html: &str, @@ -8399,7 +8642,7 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( let mut module_bindings = std::collections::BTreeMap::)>>::new(); let mut module_dynamic_dependencies = - BTreeMap::)>>>::new(); + 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))?; @@ -8428,6 +8671,11 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( .ok_or_else(|| "自主构建外部脚本累计超过 2 MiB".to_string())?; let analysis = javascript_module_analysis(&inline_module, true) .ok_or_else(|| format!("自主构建内联模块不是有效 JavaScript:{inline_id}"))?; + if javascript_has_statically_non_completing_top_level_await(&inline_module) { + return Err(format!( + "自主构建模块包含静态不可完成的 top-level await:{inline_id}" + )); + } let analysis = normalize_javascript_module_analysis_sources("game/index.html", analysis)?; let ranges = named_javascript_function_ranges(&inline_module); module_dynamic_dependencies.insert( @@ -8499,6 +8747,11 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( if is_module { let analysis = javascript_module_analysis(&script, true) .ok_or_else(|| format!("自主构建外部模块不是有效 JavaScript:{local_path}"))?; + if javascript_has_statically_non_completing_top_level_await(&script) { + return Err(format!( + "自主构建模块包含静态不可完成的 top-level await:{local_path}" + )); + } let analysis = normalize_javascript_module_analysis_sources(&local_path, analysis)?; let ranges = named_javascript_function_ranges(&script); module_dynamic_dependencies.insert( @@ -8604,7 +8857,7 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( } } for dependencies in module_dynamic_dependencies.values() { - for (dependency, exports) in dependencies { + for ((dependency, _), exports) in dependencies { for (exported, member_path) in exports { if let Some((origin, origin_export)) = javascript_module_export_origin( dependency, @@ -8724,11 +8977,11 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( position, ) }); - for (source, demands) in dynamic_demands { + for ((source, import_position), demands) in dynamic_demands { module_dynamic_dependencies .entry(owner.clone()) .or_default() - .entry(source.clone()) + .entry((source.clone(), import_position)) .or_default() .extend(demands.iter().cloned()); for (dynamic_export, member_path) in demands { @@ -8822,15 +9075,29 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( let mut unit_prefix = String::new(); let mut unit_replacements = Vec::<(std::ops::Range, String)>::new(); let mut added_projection = false; + for dependency in + javascript_static_dependency_initialization_order(importer, &module_bindings) + { + let Some(dependency_content) = module_contents.get(&dependency) else { + continue; + }; + let side_effects = javascript_global_side_effect_projection(dependency_content); + if side_effects.is_empty() { + continue; + } + unit_prefix.push_str(&side_effects); + added_projection = true; + } let mut origins = BTreeMap::< String, Vec<( String, String, Option<(String, String)>, - Option<(String, String)>, + Option<(String, usize, String)>, )>, >::new(); + let mut origin_order = Vec::::new(); for (dependency, bindings) in dependencies.clone() { if bindings.is_empty() { continue; @@ -8877,6 +9144,9 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( &mut BTreeSet::new(), ) { + if !origins.contains_key(&member_origin) { + origin_order.push(member_origin.clone()); + } origins.entry(member_origin).or_default().push(( member_export, member.clone(), @@ -8886,6 +9156,9 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( } } } else { + if !origins.contains_key(&origin) { + origin_order.push(origin.clone()); + } origins.entry(origin).or_default().push(( origin_export, local, @@ -8896,7 +9169,7 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( } } } - for (dependency, exports) in module_dynamic_dependencies + for ((dependency, import_position), exports) in module_dynamic_dependencies .get(importer) .into_iter() .flat_map(|dependencies| dependencies.iter()) @@ -8913,11 +9186,14 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( &mut BTreeSet::new(), ) { if origin_export != "*" { + if !origins.contains_key(&origin) { + origin_order.push(origin.clone()); + } origins.entry(origin).or_default().push(( origin_export, exported.clone(), None, - Some((dependency.clone(), exported.clone())), + Some((dependency.clone(), *import_position, exported.clone())), )); } } @@ -8933,7 +9209,10 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( .iter() .map(|(binding, identity)| (identity.clone(), binding.clone())) .collect::>(); - for (origin, bindings) in origins { + for origin in origin_order { + let Some(bindings) = origins.remove(&origin) else { + continue; + }; let Some(origin_content) = projection_contents.get(&origin) else { continue; }; @@ -9084,10 +9363,10 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( .map(String::as_str) .unwrap_or(origin_local); let normalized_local = local.clone(); - if let Some((dependency, imported)) = dynamic { + if let Some((dependency, import_position, imported)) = dynamic { if let Some(ranges) = importer_analysis .dynamic_import_member_ranges - .get(&dependency) + .get(&(dependency.clone(), import_position)) .and_then(|exports| exports.get(&imported)) { unit_replacements.extend( @@ -9099,7 +9378,7 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( } if let Some(bindings) = importer_analysis .dynamic_import_binding_references - .get(&dependency) + .get(&(dependency.clone(), import_position)) .and_then(|exports| exports.get(&imported)) { for binding in bindings { 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 a2b9f790d..b1faead64 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 @@ -3782,6 +3782,370 @@ fn javascript_dynamic_projection_reaches_export_assignments_and_direct_await_mem } } +#[test] +fn javascript_dynamic_projection_uses_only_the_final_live_binding_callable() { + let temporary = tempfile::tempdir().expect("create final live binding project"); + let root = temporary.path(); + fs::create_dir_all(root.join("game")).expect("create game directory"); + fs::write( + root.join("game/origin.mjs"), + "export let start; start = () => import('./old.mjs').then(({ oldRun }) => oldRun()); start = () => import('./final.mjs').then(({ finalRun }) => finalRun());", + ) + .expect("write reassigned live binding"); + fs::write( + root.join("game/old.mjs"), + "export function oldRun() { return import('./missing-old.mjs'); }", + ) + .expect("write obsolete live binding dependency"); + fs::write( + root.join("game/final.mjs"), + "export function finalRun() { return 'final'; }", + ) + .expect("write final live binding dependency"); + fs::write( + root.join("game/main.mjs"), + "import { start } from './origin.mjs'; start();", + ) + .expect("write live binding consumer"); + + let modules = read_external_gameplay_javascript_at( + root, + "", + ) + .expect("the obsolete callable must not load its missing closure"); + let projected = modules + .module_units() + .iter() + .find(|unit| unit.contains("start();") && unit.contains("function finalRun()")) + .expect("final callable dependency must join the consumer"); + assert!( + !projected.contains("function oldRun()"), + "only the callable installed at module initialization completion is externally observable: {projected}", + ); +} + +#[test] +fn javascript_direct_awaited_members_project_in_non_immediate_call_forms() { + let html = ""; + for (label, origin, dependency, final_binding) in [ + ( + "assigned callback", + "export async function start() { const callback = (await import('./dependency.mjs')).run; queueMicrotask(callback); }", + "export function run() { return import('./final.mjs').then(({ finishCallback }) => finishCallback()); }", + "finishCallback", + ), + ( + "callback argument", + "export async function start() { setTimeout((await import('./dependency.mjs')).run, 0); }", + "export function run() { return import('./final.mjs').then(({ finishTimer }) => finishTimer()); }", + "finishTimer", + ), + ( + "constructed member", + "export async function start() { return new ((await import('./dependency.mjs')).Game)().run(); }", + "export class Game { run() { return import('./final.mjs').then(({ finishConstructor }) => finishConstructor()); } }", + "finishConstructor", + ), + ] { + let temporary = tempfile::tempdir().expect("create direct awaited member project"); + let root = temporary.path(); + fs::create_dir_all(root.join("game")).expect("create game directory"); + fs::write(root.join("game/origin.mjs"), origin).expect("write awaited member origin"); + fs::write(root.join("game/dependency.mjs"), dependency) + .expect("write awaited member dependency"); + fs::write( + root.join("game/final.mjs"), + format!("export function {final_binding}() {{ return '{label}'; }}"), + ) + .expect("write awaited member transitive dependency"); + fs::write( + root.join("game/main.mjs"), + "import { start } from './origin.mjs'; start(); if (false) (await import('./missing-decoy.mjs')).run;", + ) + .expect("write awaited member consumer and decoy"); + + let modules = read_external_gameplay_javascript_at(root, html) + .unwrap_or_else(|error| panic!("project {label}: {error}")); + let projected = modules + .module_units() + .iter() + .find(|unit| unit.contains("start();") && unit.contains(&format!("function {final_binding}()"))) + .unwrap_or_else(|| panic!("{label} must project its occurrence-scoped member: {:#?}", modules.module_units())); + assert!( + !projected.contains("await import('./dependency.mjs')"), + "the direct awaited member must be replaced with its projected binding: {projected}", + ); + } + + let temporary = tempfile::tempdir().expect("create same-source occurrence project"); + let root = temporary.path(); + fs::create_dir_all(root.join("game")).expect("create game directory"); + fs::write( + root.join("game/origin.mjs"), + "export async function start() { if (false) { const decoy = (await import('./dependency.mjs')).run; decoy(); } const callback = (await import('./dependency.mjs')).run; queueMicrotask(callback); }", + ) + .expect("write same-source reachable and unreachable occurrences"); + fs::write( + root.join("game/dependency.mjs"), + "export function run() { return 1; }", + ) + .expect("write same-source dependency"); + 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("project only the reachable same-source occurrence"); + let projected = modules + .module_units() + .iter() + .find(|unit| unit.contains("queueMicrotask(callback)") && unit.contains("function run()")) + .expect("same-source occurrence projection must join consumer"); + assert_eq!( + projected.matches("await import('./dependency.mjs')").count(), + 1, + "the unreachable occurrence must retain its own member span while the reachable occurrence is replaced: {projected}", + ); +} + +#[test] +fn javascript_side_effect_imports_precede_importers_without_linking_local_decoys() { + let temporary = tempfile::tempdir().expect("create side-effect module project"); + let root = temporary.path(); + fs::create_dir_all(root.join("game")).expect("create game directory"); + fs::write( + root.join("game/setup.mjs"), + "const localOnly = 'dependency-local'; globalThis.sequence = ['dependency']; window.renderFrame = () => globalThis.sequence.push('render');", + ) + .expect("write observable side-effect dependency"); + fs::write( + root.join("game/unlinked.mjs"), + "globalThis.sequence = ['unlinked-decoy'];", + ) + .expect("write unlinked side-effect decoy"); + fs::write( + root.join("game/main.mjs"), + "import './setup.mjs'; globalThis.sequence.push('importer'); window.renderFrame(); void localOnly;", + ) + .expect("write side-effect importer"); + + let modules = read_external_gameplay_javascript_at( + root, + "", + ) + .expect("read linked side-effect graph"); + let projected = modules + .module_units() + .iter() + .find(|unit| unit.contains("['dependency']") && unit.contains("sequence.push('importer')")) + .expect("side-effect dependency and importer must share an ordered unit"); + assert!( + projected.find("['dependency']") < projected.find("sequence.push('importer')"), + "dependency side effects must be evaluated before importer top-level code: {projected}", + ); + assert!(!projected.contains("unlinked-decoy")); + assert!( + !projected.contains("const localOnly = 'dependency-local'"), + "a side-effect import must not expose an imported module's local binding to its importer: {projected}", + ); +} + +#[test] +fn javascript_projection_preserves_dependency_declaration_source_order() { + let temporary = tempfile::tempdir().expect("create declaration-order project"); + let root = temporary.path(); + fs::create_dir_all(root.join("game")).expect("create game directory"); + fs::write( + root.join("game/dependency.mjs"), + "const helper = () => 1; const implementation = () => helper(); export const start = implementation;", + ) + .expect("write ordered declaration dependency"); + fs::write( + root.join("game/main.mjs"), + "import { start } from './dependency.mjs'; start();", + ) + .expect("write ordered declaration consumer"); + + let modules = read_external_gameplay_javascript_at( + root, + "", + ) + .expect("project dependency declarations"); + let projected = modules + .module_units() + .iter() + .find(|unit| unit.contains("start();") && unit.contains("const helper")) + .expect("ordered declarations must join importer"); + let helper = projected.find("const helper").expect("find helper"); + let implementation = projected + .find("const implementation") + .expect("find implementation"); + let start = projected.find("const start").expect("find start"); + assert!( + helper < implementation && implementation < start, + "projection traversal must not reverse dependency/source declaration order: {projected}", + ); + + fs::write( + root.join("game/z-first.mjs"), + "export function firstDependency() { return 1; }", + ) + .expect("write source-first dependency"); + fs::write( + root.join("game/a-second.mjs"), + "export function secondDependency() { return 2; }", + ) + .expect("write lexically earlier second dependency"); + fs::write( + root.join("game/main.mjs"), + "import { firstDependency } from './z-first.mjs'; import { secondDependency } from './a-second.mjs'; firstDependency(); secondDependency();", + ) + .expect("write dependency-order consumer"); + let modules = read_external_gameplay_javascript_at( + root, + "", + ) + .expect("project dependencies in importer source order"); + let projected = modules + .module_units() + .iter() + .find(|unit| { + unit.contains("function firstDependency()") + && unit.contains("function secondDependency()") + }) + .expect("both ordered dependencies must join importer"); + assert!( + projected.find("function firstDependency()") + < projected.find("function secondDependency()"), + "dependency projections must follow importer source order instead of path sort order: {projected}", + ); +} + +#[test] +fn javascript_cyclic_projection_deduplicates_initialization_writes() { + let temporary = tempfile::tempdir().expect("create cyclic initialization project"); + let root = temporary.path(); + fs::create_dir_all(root.join("game")).expect("create game directory"); + fs::write( + root.join("game/a.mjs"), + "import { b } from './b.mjs'; export let a; a = () => b();", + ) + .expect("write cyclic module a"); + fs::write( + root.join("game/b.mjs"), + "import { a } from './a.mjs'; export let b; b = () => a();", + ) + .expect("write cyclic module b"); + fs::write( + root.join("game/main.mjs"), + "import { a } from './a.mjs'; a();", + ) + .expect("write cyclic consumer"); + + let modules = read_external_gameplay_javascript_at( + root, + "", + ) + .expect("cyclic projection must converge"); + let projected = modules + .module_units() + .iter() + .find(|unit| unit.contains("a();") && unit.contains("a = ()") && unit.contains("b")) + .expect("cyclic initialization must join consumer"); + assert_eq!(projected.matches("a = ()").count(), 1, "{projected}"); + assert_eq!(projected.matches("b = ()").count(), 1, "{projected}"); +} + +#[test] +fn javascript_top_level_await_fails_closed_only_when_statically_non_completing() { + let temporary = tempfile::tempdir().expect("create top-level await project"); + let root = temporary.path(); + fs::create_dir_all(root.join("game")).expect("create game directory"); + fs::write( + root.join("game/dependency.mjs"), + "await new Promise(() => {}); export function start() { return 1; }", + ) + .expect("write non-completing top-level await"); + fs::write( + root.join("game/main.mjs"), + "import { start } from './dependency.mjs'; start();", + ) + .expect("write top-level await importer"); + let html = ""; + let error = read_external_gameplay_javascript_at(root, html) + .expect_err("a statically non-completing dependency must block its importer"); + assert!( + error.contains("top-level await") || error.contains("顶层 await"), + "{error}" + ); + + fs::write( + root.join("game/dependency.mjs"), + "async function decoy() { await new Promise(() => {}); } await Promise.resolve(); export function start() { return 1; }", + ) + .expect("write completing top-level await with nested decoy"); + read_external_gameplay_javascript_at(root, html) + .expect("a completing top-level await and uncalled nested decoy remain valid"); +} + +#[test] +fn javascript_exported_class_assignment_preserves_instance_and_static_member_demands() { + let html = ""; + for (importer, expected, missing) in [ + ( + "import { Game } from './origin.mjs'; new Game().run();", + "finishInstance", + "./missing-static.mjs", + ), + ( + "import { Game } from './origin.mjs'; Game.boot();", + "finishStatic", + "./missing-instance.mjs", + ), + ] { + let temporary = tempfile::tempdir().expect("create assigned class project"); + let root = temporary.path(); + fs::create_dir_all(root.join("game")).expect("create game directory"); + fs::write( + root.join("game/origin.mjs"), + "export let Game; Game = class { static boot() { return import('./static.mjs').then(({ finishStatic }) => finishStatic()); } run() { return import('./instance.mjs').then(({ finishInstance }) => finishInstance()); } };", + ) + .expect("write assigned class export"); + fs::write( + root.join("game/static.mjs"), + if expected == "finishStatic" { + "export function finishStatic() { return 'static'; }" + } else { + "export function finishStatic() { return import('./missing-static.mjs'); }" + }, + ) + .expect("write static class dependency"); + fs::write( + root.join("game/instance.mjs"), + if expected == "finishInstance" { + "export function finishInstance() { return 'instance'; }" + } else { + "export function finishInstance() { return import('./missing-instance.mjs'); }" + }, + ) + .expect("write instance class dependency"); + fs::write(root.join("game/main.mjs"), importer).expect("write assigned class consumer"); + + let modules = read_external_gameplay_javascript_at(root, html).unwrap_or_else(|error| { + panic!("selected assigned class member must not load {missing}: {error}") + }); + assert!( + modules + .module_units() + .iter() + .any(|unit| unit.contains(&format!("function {expected}()"))), + "assigned class expression must preserve the selected member demand: {:#?}", + modules.module_units(), + ); + } +} + #[test] fn javascript_projection_preserves_nested_names_shorthand_keys_and_cycles() { let temporary = tempfile::tempdir().expect("create projection identity project"); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index fcd810038..c84cb8519 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6002,8 +6002,10 @@ - classic script 分析单元把 inline 与无 `defer / async` 的本地 external 正文按 `game/index.html` 标签顺序交错组成 parser-blocking 段,再把 classic external `defer` 按文档顺序放到解析完成后的 deferred 段;不得把 defer-before-inline 误投影为外链先执行。classic external `async` 的下载完成顺序不可静态证明,当前静态门直接失败关闭。带 `src` 标签的 inline body 继续忽略;外部文件仍执行可信普通文件、`game/` 边界、文件数与累计体积门禁,重复标签按浏览器出现次数保留求值位置。 - Canvas 尺寸、可见性、元素绑定和 stylesheet 选择器扫描只消费浏览器可渲染标记;`template / textarea / noscript / title / style / xmp / iframe / noembed / plaintext` 内的 Canvas、标签和样式诱饵全部跳过。活动顶层 stylesheet 与可见标记分开提取,既允许真实 CSS 参与隐藏/尺寸判断,也不把 CSS raw-text 中的伪标签当作 DOM。 - ESM 组合单元按 dependency 初始化先于 importer 顶层求值排列。import reference 的 span replacement 仍基于原 importer 完成,随后把已闭包的 dependency projection 放在 importer 前并对最终单元重跑 parser、semantic、单元 `2 MiB` 与累计投影 `32 MiB` 门禁;循环模块继续按 `(origin module, original root binding)` canonical identity 去重并要求有界固定点收敛。 - ## 2026-08-04 JavaScript 延迟状态与复合调用边 - 受控异步 callback 的 alias 读取按完整 enclosing invocation 链延迟到各层函数同步收尾,最外层再延迟到当前 job 末尾;callback 写入仍不在注册点同步提交。conditional / assignment expression callee 分别在 test / RHS 求值后建立调用边,`new` 同时执行普通 function constructor 及 alias。 - 函数对象自有 `.call / .apply / .bind` 覆盖允许以普通对象静态 member callable 作为 RHS,并继续按函数对象身份跨普通 alias 共享。`delete` 自有覆盖后恢复 Function.prototype intrinsic;条件删除合并覆盖与 intrinsic,非 callable 自有值仍视为属性存在并禁止 intrinsic 回退。 + +- ESM 最终绑定与 occurrence 补充:外部 callable root 只认模块初始化完成时同一 live binding / member 的最后一次直接顶层 assignment,旧 RHS 不得加载;直接 awaited namespace member 在赋值、受控 callback、constructor 和深层静态 member path 中仍按 `(source, occurrence)` 传播并替换首段 export。assignment class expression 的 static / instance member demand 分离。 +- ESM 初始化与阻塞补充:projection declaration 保持原模块源码顺序,dependency origin 保持 importer 声明顺序;循环 canonical 去重同时删除声明和对应初始化写入。side-effect static import 只联结按 dependency-before-importer 排列的直接 `globalThis / window` 顶层 effect,不暴露 dependency local binding;静态可判定永不完成的 top-level await 至少对未遮蔽全局 `await new Promise(() => {})` 失败关闭,嵌套函数同形 decoy 和可完成 await 保持允许。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index c9a33bb26..b7d83f09a 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -845,6 +845,9 @@ game-project/ - 循环 ESM 的投影身份固定为 `(origin module, original root binding)`;canonical 重命名只改变组合单元中的展示名,身份随投影闭包传播。删除已存在的回流声明后仍必须把 import 引用改接到 importer 中同一身份的既有 canonical;固定点未在“模块数 + 1”轮内收敛时失败关闭,不能返回最后一轮仍变化的部分结果。inline module 必须先提取原文并计入与外部脚本共享的累计源码 `2 MiB` 上限,再执行语法和语义校验;超限源码不能因无效语法被过滤,小型无效模块也必须明确失败关闭。 - HTML 静态门只屏蔽 raw-text 容器之外的真正 HTML 注释,`script / style` 原文必须逐字交给对应 parser;禁止对整份 HTML 做非词法 `//`、`/* */` 删除,字符串中的 `https://`、路径和注释形状不能被截断,HTML 注释诱饵仍不得进入标签或脚本证据。Canvas 尺寸、可见性、元素绑定和 stylesheet 选择器扫描只读取浏览器可渲染标记,必须完整跳过 `template / textarea / noscript / title / style / xmp / iframe / noembed / plaintext` 的标签诱饵;活动顶层 stylesheet 独立提取,不能借用 inert 容器中的样式或把 CSS raw-text 当 HTML 标签。classic inline 与无 `defer / async` 的 external 脚本按 HTML 标签出现顺序组成 parser-blocking global 段;classic external `defer` 必须在解析阻塞段之后按文档顺序求值,不能提前到标签之后的 inline 前,`async` 因下载完成顺序不可静态证明而失败关闭。带 `src` 的标签忽略 inline body,普通脚本顺序不得退化。 - ESM 投影的最终组合顺序必须反映 dependency 先初始化、importer 后求值:先在原 importer 坐标上完成 import reference span replacement,再把依赖闭包投影放到 importer 顶层正文之前,使 dependency 在声明后安装的 function-valued export 能被 importer 调用看见。最终单元继续重跑 parser / semantic 和单元 `2 MiB`、累计投影 `32 MiB` 门禁;canonical identity、循环回接去重与有界固定点规则不因顺序调整而放宽。 +- ESM live binding 的外部 callable demand 以模块初始化完成后的最终直接顶层 assignment 为准;同一 root 或 member 的旧 RHS 仍按源码参与初始化投影,但不得继续作为外部调用根加载旧 dynamic dependency。projection declaration 按原模块源码位置输出,静态 dependency origin 按 importer 中的声明顺序输出;循环回流去重必须同时删除对应声明与初始化写入,确保同一模块初始化只出现一次。 +- 直接 `(await import(source)).member` 不限定为立即调用:赋值、受控 callback、constructor 和后续完整静态 member path 都保留 `(source, import occurrence)` demand,并只替换首段 export member span;恒假 occurrence 仍不得借用可达 occurrence。`export let Game; Game = class { ... }` 的 class expression assignment 同时支持 static 与 instance member root,二者不得串线。 +- side-effect-only static import 只把 dependency 中直接可观察的 `globalThis / window` 顶层 effect 按有界 DFS dependency-before-importer 顺序加入组合 unit,不把 dependency local binding 暴露给 importer,也不采纳未链接 module。模块含可静态证明永不完成的 top-level await(当前至少覆盖未遮蔽全局 `await new Promise(() => {})`)时读取失败关闭;嵌套 async function 内同形 await 和可完成的 top-level await 不误拒绝。 ## 2026-07-31 长耗时与恢复收口