From 3a70b0ed87c392c8cf4808d95e46081c4f3e1928 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 4 Aug 2026 16:08:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=9D=99=E6=80=81=E8=A7=86?= =?UTF-8?q?=E8=A7=89=E8=AF=81=E6=8D=AE=E4=B8=8E=E8=84=9A=E6=9C=AC=E8=AF=AD?= =?UTF-8?q?=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 Script Record 边界组合 classic 脚本并保持 ASI 与 defer 顺序 以 AST 和 semantic binding 关联可见 Canvas、绘制调用与资源赋值时序 保持动态 namespace 只读写入语义并排除不可达投影证据 过滤 inert 与非 JavaScript 玩法诱饵并保持资产路径大小写 补充视觉回归、完成合同预期与长期技术文档 --- .../runtime_protocol/autonomous_completion.rs | 1048 +++++++++++++---- .../autonomous_completion_contract_tests.rs | 38 +- .../shared-memory/decision-log.md | 8 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 + 4 files changed, 834 insertions(+), 262 deletions(-) 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 ee48d5fc8..37a60d86f 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 @@ -589,7 +589,7 @@ fn rendered_html_markup_and_stylesheets(content: &str) -> (String, String) { else { break; }; - stylesheets.push_str(&lower[tag_after..close_start]); + stylesheets.push_str(&content[tag_after..close_start]); stylesheets.push('\n'); cursor = close_end; continue; @@ -602,7 +602,7 @@ fn rendered_html_markup_and_stylesheets(content: &str) -> (String, String) { cursor = end; continue; } - markup.push_str(tag); + markup.push_str(&content[tag_start..tag_after]); markup.push('\n'); cursor = tag_after; } @@ -868,25 +868,6 @@ fn tag_is_hidden_by_stylesheet( }) } -fn position_is_inside_javascript_string(content: &str, position: usize) -> bool { - let mut quote = None; - let mut escaped = false; - for byte in content.as_bytes().iter().copied().take(position) { - if escaped { - escaped = false; - } else if byte == b'\\' { - escaped = true; - } else if let Some(active) = quote { - if byte == active { - quote = None; - } - } else if matches!(byte, b'\'' | b'"' | b'`') { - quote = Some(byte); - } - } - quote.is_some() -} - fn matching_javascript_brace(content: &str, open: usize) -> Option { let mut depth = 0usize; let mut quote = None; @@ -3519,165 +3500,357 @@ fn identifier_before(content: &str, position: usize) -> Option { (start < end).then(|| content[start..end].to_string()) } -fn canvas_visual_identifiers(content: &str, markup: &str, asset_path: &str) -> BTreeSet { - let mut identifiers = BTreeSet::new(); - let mut latest_src_assignments = std::collections::BTreeMap::new(); - let mut cursor = 0; - while let Some(offset) = content[cursor..].find(".src") { - let dot = cursor + offset; - cursor = dot + 4; - if position_is_inside_javascript_string(content, dot) { - continue; - } - let Some(identifier) = identifier_before(content, dot) else { - continue; - }; - let mut value_start = cursor; - while content - .as_bytes() - .get(value_start) - .is_some_and(u8::is_ascii_whitespace) - { - value_start += 1; - } - if content.as_bytes().get(value_start) != Some(&b'=') { - continue; - } - value_start += 1; - while content - .as_bytes() - .get(value_start) - .is_some_and(u8::is_ascii_whitespace) - { - value_start += 1; - } - let Some(quote @ (b'\'' | b'"' | b'`')) = content.as_bytes().get(value_start).copied() - else { - continue; - }; - value_start += 1; - let Some(end_offset) = content.as_bytes()[value_start..] - .iter() - .position(|byte| *byte == quote) - else { - continue; - }; - latest_src_assignments.insert( - identifier, - relative_visual_url_resolves_to_asset( - &content[value_start..value_start + end_offset], - asset_path, - ), - ); - } - identifiers.extend( - latest_src_assignments - .iter() - .filter_map(|(identifier, matches)| matches.then_some(identifier.clone())), - ); +#[derive(Clone)] +struct JavascriptCanvasDraw { + position: usize, + image: JavascriptSymbolId, + canvas_dimensions: (f64, f64), + arguments: Vec, +} - for tag in markup.split('>').filter(|tag| tag.contains(asset_path)) { - let Some(id) = html_attribute_value(tag, "id") else { - continue; - }; - let source_matches = ["src", "href", "data", "poster"].iter().any(|attribute| { - html_attribute_value(tag, attribute) - .is_some_and(|url| relative_visual_url_resolves_to_asset(url, asset_path)) - }); - if !source_matches { - continue; +struct JavascriptCanvasVisualCollector<'a> { + scoping: &'a JavascriptScoping, + content: &'a str, + ranges: &'a NamedJavascriptFunctionRanges, + conditional_ranges: &'a [std::ops::Range], + visible_canvases: &'a [(Option, (f64, f64))], + asset_element_ids: &'a BTreeSet, + asset_path: &'a str, + canvas_bindings: BTreeMap, + context_bindings: BTreeMap, + source_events: BTreeMap>>, + draws: Vec, +} + +impl JavascriptCanvasVisualCollector<'_> { + fn symbol_for_identifier( + &self, + identifier: &oxc_ast::ast::IdentifierReference<'_>, + ) -> Option { + identifier + .reference_id + .get() + .and_then(|reference_id| self.scoping.get_reference(reference_id).symbol_id()) + } + + fn global_document_call_argument<'a>( + &self, + call: &'a JavascriptCallExpression<'a>, + expected_method: &str, + ) -> Option<&'a str> { + let member = call.callee.as_member_expression()?; + if member.static_property_name()? != expected_method { + return None; } - for marker in ["getelementbyid(", "queryselector("] { - let mut binding_cursor = 0; - while let Some(offset) = content[binding_cursor..].find(marker) { - let call = binding_cursor + offset; - binding_cursor = call + marker.len(); - if position_is_inside_javascript_string(content, call) { - continue; + let JavascriptExpression::Identifier(document) = member.object() else { + return None; + }; + if document.name != "document" + || document.reference_id.get().is_none_or(|reference_id| { + self.scoping + .get_reference(reference_id) + .symbol_id() + .is_some() + }) + { + return None; + } + call.arguments + .first() + .and_then(JavascriptArgument::as_expression) + .and_then(|argument| match argument { + JavascriptExpression::StringLiteral(value) => Some(value.value.as_str()), + _ => None, + }) + } + + fn canvas_expression_dimensions( + &self, + expression: &JavascriptExpression<'_>, + ) -> Option<(f64, f64)> { + match expression { + JavascriptExpression::Identifier(identifier) => self + .symbol_for_identifier(identifier) + .and_then(|symbol_id| self.canvas_bindings.get(&symbol_id).copied()), + JavascriptExpression::ParenthesizedExpression(parenthesized) => { + self.canvas_expression_dimensions(&parenthesized.expression) + } + JavascriptExpression::CallExpression(call) => { + if let Some(id) = self.global_document_call_argument(call, "getElementById") { + return self + .visible_canvases + .iter() + .find(|(canvas_id, _)| canvas_id.as_deref() == Some(id)) + .map(|(_, dimensions)| *dimensions) + .filter(|(width, height)| *width > 0.0 && *height > 0.0); } - let argument_tail = &content[binding_cursor..]; - let Some(argument_end) = argument_tail.find(')') else { - continue; - }; - let argument = argument_tail[..argument_end] - .trim() - .trim_matches(|character| matches!(character, '\'' | '"' | '#')); - if argument != id { - continue; + let selector = self.global_document_call_argument(call, "querySelector")?; + if selector == "canvas" { + return self + .visible_canvases + .first() + .map(|(_, dimensions)| *dimensions) + .filter(|(width, height)| *width > 0.0 && *height > 0.0); } - let Some(equals) = content[..call].rfind('=') else { - continue; - }; - if call.saturating_sub(equals) > 24 { - continue; - } - if let Some(identifier) = identifier_before(content, equals) { - if latest_src_assignments.get(&identifier) == Some(&false) { - continue; + let id = selector.strip_prefix('#')?; + self.visible_canvases + .iter() + .find(|(canvas_id, _)| canvas_id.as_deref() == Some(id)) + .map(|(_, dimensions)| *dimensions) + .filter(|(width, height)| *width > 0.0 && *height > 0.0) + } + _ => None, + } + } + + fn context_expression_dimensions( + &self, + expression: &JavascriptExpression<'_>, + ) -> Option<(f64, f64)> { + if let JavascriptExpression::Identifier(identifier) = expression { + return self + .symbol_for_identifier(identifier) + .and_then(|symbol_id| self.context_bindings.get(&symbol_id).copied()); + } + let JavascriptExpression::CallExpression(call) = expression else { + return None; + }; + let member = call.callee.as_member_expression()?; + (member.static_property_name()? == "getContext") + .then(|| self.canvas_expression_dimensions(member.object())) + .flatten() + } + + fn expression_selects_asset_element(&self, expression: &JavascriptExpression<'_>) -> bool { + let JavascriptExpression::CallExpression(call) = expression else { + return false; + }; + self.global_document_call_argument(call, "getElementById") + .is_some_and(|id| self.asset_element_ids.contains(id)) + || self + .global_document_call_argument(call, "querySelector") + .and_then(|selector| selector.strip_prefix('#')) + .is_some_and(|id| self.asset_element_ids.contains(id)) + } + + fn record_binding( + &mut self, + identifier: &oxc_ast::ast::BindingIdentifier<'_>, + initializer: &JavascriptExpression<'_>, + ) { + let Some(symbol_id) = identifier.symbol_id.get() else { + return; + }; + if let Some(dimensions) = self.canvas_expression_dimensions(initializer) { + self.canvas_bindings.insert(symbol_id, dimensions); + } + if let Some(dimensions) = self.context_expression_dimensions(initializer) { + self.context_bindings.insert(symbol_id, dimensions); + } + if self.expression_selects_asset_element(initializer) { + let position = initializer.span().end as usize; + self.source_events + .entry(symbol_id) + .or_default() + .push(JavascriptAliasEvent { + position, + scope: javascript_alias_scope_at(self.ranges, position), + value: Some(true), + conditional: javascript_position_is_conditionally_executed( + self.conditional_ranges, + position, + ), + }); + } + } + + fn argument_source(&self, argument: &JavascriptArgument<'_>) -> Option { + let span = argument.span(); + self.content + .get(span.start as usize..span.end as usize) + .map(str::to_string) + } +} + +impl<'a> VisitJavascript<'a> for JavascriptCanvasVisualCollector<'_> { + fn visit_variable_declarator(&mut self, declarator: &JavascriptVariableDeclarator<'a>) { + if let (Some(identifier), Some(initializer)) = ( + declarator.id.get_binding_identifier(), + declarator.init.as_ref(), + ) { + self.record_binding(identifier, initializer); + } + 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 Some(member) = assignment.left.as_member_expression() { + if member + .static_property_name() + .is_some_and(|name| name == "src") + { + if let JavascriptExpression::Identifier(identifier) = member.object() { + if let Some(symbol_id) = self.symbol_for_identifier(identifier) { + let position = assignment.span.end as usize; + if !javascript_position_is_in_literal_false_block( + self.content, + &self.ranges.literal_false_ranges, + position, + ) { + let source_matches = match &assignment.right { + JavascriptExpression::StringLiteral(source) => { + relative_visual_url_resolves_to_asset( + source.value.as_str(), + self.asset_path, + ) + } + _ => false, + }; + self.source_events.entry(symbol_id).or_default().push( + JavascriptAliasEvent { + position, + scope: javascript_alias_scope_at(self.ranges, position), + value: Some(source_matches), + conditional: javascript_position_is_conditionally_executed( + self.conditional_ranges, + position, + ), + }, + ); + } + } } - identifiers.insert(identifier); } } } + oxc_ast_visit::walk::walk_assignment_expression(self, assignment); + } + + fn visit_call_expression(&mut self, call: &JavascriptCallExpression<'a>) { + let position = call.span.start as usize; + if javascript_position_is_reachable(self.content, self.ranges, position) { + if let Some(member) = call.callee.as_member_expression() { + if member + .static_property_name() + .is_some_and(|name| name == "drawImage") + { + let dimensions = match member.object() { + JavascriptExpression::Identifier(identifier) => self + .symbol_for_identifier(identifier) + .and_then(|symbol_id| self.context_bindings.get(&symbol_id).copied()), + expression => self.context_expression_dimensions(expression), + }; + let image = call + .arguments + .first() + .and_then(JavascriptArgument::as_expression) + .and_then(JavascriptExpression::get_identifier_reference) + .and_then(|identifier| self.symbol_for_identifier(identifier)); + if let (Some(canvas_dimensions), Some(image)) = (dimensions, image) { + let arguments = call + .arguments + .iter() + .filter_map(|argument| self.argument_source(argument)) + .collect::>(); + if arguments.len() == call.arguments.len() { + self.draws.push(JavascriptCanvasDraw { + position, + image, + canvas_dimensions, + arguments, + }); + } + } + } + } + } + oxc_ast_visit::walk::walk_call_expression(self, call); } - identifiers } -fn split_javascript_arguments(arguments: &str) -> Vec<&str> { - let mut result = Vec::new(); - let mut start = 0; - let mut depth: usize = 0; - let mut quote = None; - let mut escaped = false; - for (index, byte) in arguments.bytes().enumerate() { - if escaped { - escaped = false; - } else if byte == b'\\' { - escaped = true; - } else if let Some(active) = quote { - if byte == active { - quote = None; - } - } else if matches!(byte, b'\'' | b'"' | b'`') { - quote = Some(byte); - } else if byte == b'(' { - depth += 1; - } else if byte == b')' { - depth = depth.saturating_sub(1); - } else if byte == b',' && depth == 0 { - result.push(arguments[start..index].trim()); - start = index + 1; - } +fn javascript_canvas_visual_draws( + content: &str, + visible_canvases: &[(Option, (f64, f64))], + asset_element_ids: &BTreeSet, + asset_path: &str, + is_module: bool, +) -> Vec { + let allocator = JavascriptAllocator::default(); + let parsed = + JavascriptParser::new(&allocator, content, javascript_source_type(is_module)).parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { + return Vec::new(); } - result.push(arguments[start..].trim()); - result -} - -fn javascript_call_arguments_end(content: &str, start: usize) -> Option { - let mut depth: usize = 0; - let mut quote = None; - let mut escaped = false; - for (offset, byte) in content.as_bytes()[start..].iter().copied().enumerate() { - if escaped { - escaped = false; - } else if byte == b'\\' { - escaped = true; - } else if let Some(active) = quote { - if byte == active { - quote = None; - } - } else if matches!(byte, b'\'' | b'"' | b'`') { - quote = Some(byte); - } else if byte == b'(' { - depth += 1; - } else if byte == b')' { - if depth == 0 { - return Some(start + offset); - } - depth -= 1; - } + let semantic = JavascriptSemanticBuilder::new_compiler().build(&parsed.program); + if !semantic.diagnostics.is_empty() { + return Vec::new(); } - None + let ranges = named_javascript_function_ranges(content); + let conditional_ranges = javascript_conditional_execution_ranges(content); + let namespace_write_assignments = javascript_module_analysis(content, is_module) + .map(|analysis| { + let dynamic_members = analysis + .dynamic_import_member_ranges + .values() + .flat_map(|exports| exports.values()) + .flatten() + .map(|range| (range.start, range.end)) + .collect::>(); + analysis + .dynamic_import_member_write_assignments + .into_iter() + .filter(|(member, _)| dynamic_members.contains(&(member.start, member.end))) + .map(|(_, assignment)| assignment) + .filter(|assignment| { + javascript_position_is_reachable(content, &ranges, assignment.start) + }) + .collect::>() + }) + .unwrap_or_default(); + let mut collector = JavascriptCanvasVisualCollector { + scoping: semantic.semantic.scoping(), + content, + ranges: &ranges, + conditional_ranges: &conditional_ranges, + visible_canvases, + asset_element_ids, + asset_path, + canvas_bindings: BTreeMap::new(), + context_bindings: BTreeMap::new(), + source_events: BTreeMap::new(), + draws: Vec::new(), + }; + collector.visit_program(&parsed.program); + collector + .draws + .into_iter() + .filter(|draw| { + if namespace_write_assignments.iter().any(|assignment| { + assignment.contains(&draw.position) + || (assignment.end <= draw.position + && !ranges.iter().any(|range| { + range.start <= assignment.start && assignment.start < range.end + })) + }) { + return false; + } + collector + .source_events + .get(&draw.image) + .and_then(|events| { + javascript_alias_event_values( + events, + &ranges, + content, + &ranges.literal_false_ranges, + &conditional_ranges, + draw.position, + ) + }) + .is_some_and(|values| !values.is_empty() && values.into_iter().all(|value| *value)) + }) + .collect() } fn draw_image_metrics(arguments: &[&str], asset_dimensions: (u32, u32)) -> (bool, bool, bool) { @@ -3715,11 +3888,11 @@ fn tag_visibly_uses_visual_asset( asset_path: &str, asset_dimensions: (u32, u32), ) -> bool { - let tag = tag.to_ascii_lowercase(); - if !tag.contains(asset_path) || tag_is_obviously_hidden_or_tiny(&tag, asset_dimensions) { + let lower_tag = tag.to_ascii_lowercase(); + if !tag.contains(asset_path) || tag_is_obviously_hidden_or_tiny(&lower_tag, asset_dimensions) { return false; } - let trimmed = tag.trim_start_matches(['<', ' ', '\t', '\r', '\n']); + let trimmed = lower_tag.trim_start_matches(['<', ' ', '\t', '\r', '\n']); let visual_element = ["img", "image", "object", "embed", "video", "input"] .iter() .any(|name| { @@ -3731,11 +3904,11 @@ fn tag_visibly_uses_visual_asset( }); let direct_source = visual_element && ["src", "href", "data", "poster"].iter().any(|attribute| { - html_attribute_value(&tag, attribute) + html_attribute_value(tag, attribute) .is_some_and(|url| relative_visual_url_resolves_to_asset(url, asset_path)) }); direct_source - || html_attribute_value(&tag, "style") + || html_attribute_value(tag, "style") .is_some_and(|style| css_contains_resolving_url(style, asset_path)) } @@ -3753,60 +3926,93 @@ fn game_index_visibly_uses_visual_asset( asset_dimensions: (u32, u32), requirement: VisualAssetUsageRequirement, ) -> bool { - let asset_path = asset_path.to_ascii_lowercase(); let Ok(html) = std::str::from_utf8(html) else { return false; }; let original_content = strip_art_reference_comments(html); - let heuristic_content = original_content.to_ascii_lowercase(); - let (markup, stylesheets) = rendered_html_markup_and_stylesheets(&heuristic_content); + let (original_markup, original_stylesheets) = + rendered_html_markup_and_stylesheets(&original_content); + let stylesheets = original_stylesheets.to_ascii_lowercase(); let mut tag_cursor = 0; - while let Some(start_offset) = markup[tag_cursor..].find('<') { + while let Some(start_offset) = original_markup[tag_cursor..].find('<') { let start = tag_cursor + start_offset; - let Some(end_offset) = markup[start..].find('>') else { + let Some(end_offset) = original_markup[start..].find('>') else { break; }; let end = start + end_offset + 1; - let tag = &markup[start..end]; + let tag = &original_markup[start..end]; if requirement == VisualAssetUsageRequirement::AnyVisible - && tag_visibly_uses_visual_asset(tag, &asset_path, asset_dimensions) - && !tag_is_hidden_by_stylesheet(tag, &stylesheets, asset_dimensions) + && tag_visibly_uses_visual_asset(tag, asset_path, asset_dimensions) + && !tag_is_hidden_by_stylesheet( + &tag.to_ascii_lowercase(), + &stylesheets, + asset_dimensions, + ) { return true; } tag_cursor = end; } - for rule in stylesheets.split('}') { + for rule in original_stylesheets.split('}') { let Some((selector, declarations)) = rule.rsplit_once('{') else { continue; }; if requirement == VisualAssetUsageRequirement::AnyVisible - && css_contains_resolving_url(declarations, &asset_path) - && selector_is_bound_to_markup(selector, &markup) - && !tag_is_obviously_hidden_or_tiny(&format!("
"), (0, 0)) + && css_contains_resolving_url(declarations, asset_path) + && selector_is_bound_to_markup(selector, &original_markup) + && !tag_is_obviously_hidden_or_tiny( + &format!("
", declarations.to_ascii_lowercase()), + (0, 0), + ) { return true; } } - let Some(canvas_dimensions) = markup.split('>').find_map(|tag| { - let is_canvas = tag - .trim_start_matches(['<', ' ', '\t', '\r', '\n']) - .starts_with("canvas"); - (is_canvas - && !tag_is_obviously_hidden_or_tiny(tag, (300, 150)) - && !tag_is_hidden_by_stylesheet(tag, &stylesheets, (300, 150))) - .then(|| { - ( - tag_dimension(tag, "width", "width").unwrap_or(300.0), - tag_dimension(tag, "height", "height").unwrap_or(150.0), - ) + let visible_canvases = original_markup + .split('>') + .filter_map(|tag| { + let lower_tag = tag.to_ascii_lowercase(); + let is_canvas = lower_tag + .trim_start_matches(['<', ' ', '\t', '\r', '\n']) + .starts_with("canvas"); + is_canvas.then(|| { + let visible = !tag_is_obviously_hidden_or_tiny(&lower_tag, (300, 150)) + && !tag_is_hidden_by_stylesheet(&lower_tag, &stylesheets, (300, 150)); + ( + html_attribute_value(tag, "id").map(str::to_string), + if visible { + ( + tag_dimension(tag, "width", "width").unwrap_or(300.0), + tag_dimension(tag, "height", "height").unwrap_or(150.0), + ) + } else { + (-1.0, -1.0) + }, + ) + }) }) - }) else { + .collect::>(); + let asset_element_ids = original_markup + .split('>') + .filter_map(|tag| { + let source_matches = ["src", "href", "data", "poster"].iter().any(|attribute| { + html_attribute_value(tag, attribute) + .is_some_and(|url| relative_visual_url_resolves_to_asset(url, asset_path)) + }); + source_matches + .then(|| html_attribute_value(tag, "id").map(str::to_string)) + .flatten() + }) + .collect::>(); + if !visible_canvases + .iter() + .any(|(_, (width, height))| *width > 0.0 && *height > 0.0) + { return false; - }; + } let Ok(external_javascript) = read_external_gameplay_javascript_at(root, &original_content) else { return false; @@ -3815,37 +4021,29 @@ fn game_index_visibly_uses_visual_asset( if !javascript_is_syntactically_valid(&classic_global, false) { classic_global.clear(); } - let mut javascript_units = vec![classic_global]; - javascript_units.extend(external_javascript.module_units); - javascript_units.into_iter().any(|original_javascript| { - let heuristic_javascript = original_javascript.to_ascii_lowercase(); - let identifiers = canvas_visual_identifiers(&heuristic_javascript, &markup, &asset_path); - if identifiers.is_empty() { - return false; - } - let mut significant_draws = 0usize; - let function_ranges = named_javascript_function_ranges(&original_javascript); - let mut draw_cursor = 0; - while let Some(offset) = heuristic_javascript[draw_cursor..].find("drawimage(") { - let call = draw_cursor + offset; - let arguments_start = call + "drawimage(".len(); - draw_cursor = arguments_start; - if position_is_inside_javascript_string(&heuristic_javascript, call) - || !javascript_position_is_reachable(&original_javascript, &function_ranges, call) - { - continue; - } - let Some(arguments_end) = - javascript_call_arguments_end(&heuristic_javascript, arguments_start) - else { - break; - }; - let arguments = - split_javascript_arguments(&heuristic_javascript[arguments_start..arguments_end]); - if arguments - .first() - .is_some_and(|identifier| identifiers.contains(*identifier)) - { + let mut javascript_units = vec![(classic_global, false)]; + javascript_units.extend( + external_javascript + .module_units + .into_iter() + .map(|unit| (unit, true)), + ); + javascript_units + .into_iter() + .any(|(original_javascript, is_module)| { + let mut significant_draws = 0usize; + for draw in javascript_canvas_visual_draws( + &original_javascript, + &visible_canvases, + &asset_element_ids, + asset_path, + is_module, + ) { + let arguments = draw + .arguments + .iter() + .map(String::as_str) + .collect::>(); let (significant, _, _) = draw_image_metrics(&arguments, asset_dimensions); significant_draws += usize::from(significant); if requirement == VisualAssetUsageRequirement::AnyVisible && significant { @@ -3853,21 +4051,19 @@ fn game_index_visibly_uses_visual_asset( } if requirement == VisualAssetUsageRequirement::CanvasDraw && matches!(arguments.len(), 5 | 9) - && draw_image_has_visible_destination(&arguments, canvas_dimensions) + && draw_image_has_visible_destination(&arguments, draw.canvas_dimensions) { return true; } if requirement == VisualAssetUsageRequirement::AtlasCanvasCrop && arguments.len() == 9 - && draw_image_has_visible_destination(&arguments, canvas_dimensions) + && draw_image_has_visible_destination(&arguments, draw.canvas_dimensions) { return true; } } - draw_cursor = arguments_end + 1; - } - requirement == VisualAssetUsageRequirement::AnyVisible && significant_draws > 0 - }) + requirement == VisualAssetUsageRequirement::AnyVisible && significant_draws > 0 + }) } fn dynamic_canvas_coordinate_is_bounded(value: &str, axis_extent: &str) -> bool { @@ -5094,6 +5290,54 @@ fn executable_javascript_from_html(content: &str) -> String { }) } +fn gameplay_identity_and_telemetry_text(content: &[u8]) -> String { + let Ok(content) = std::str::from_utf8(content) else { + return String::new(); + }; + let content = strip_art_reference_comments(content); + let lower = content.to_ascii_lowercase(); + let mut output = String::new(); + let mut cursor = 0usize; + while let Some(offset) = lower[cursor..].find('<') { + let tag_start = cursor + offset; + output.push_str(&lower[cursor..tag_start]); + if lower[tag_start..].starts_with("") + .map(|end| tag_start + 4 + end + 3) + .unwrap_or(lower.len()); + continue; + } + let Some(tag_after) = html_tag_end(&lower, tag_start) else { + break; + }; + let tag_end = tag_after - 1; + let tag = &lower[tag_start..=tag_end]; + if let Some(end) = html_non_executable_container_end(&lower, tag, tag_end) { + cursor = end; + continue; + } + if html_tag_starts_element(tag, "script") { + let Some((close_start, close_end)) = + raw_text_html_element_close(&lower, tag_after, "script") + else { + break; + }; + if html_script_executes_in_modern_browser(tag) && !html_has_attribute(tag, "src") { + let body = &content[tag_after..close_start]; + if javascript_is_syntactically_valid(body, html_script_is_module(tag)) { + output.push_str(&lower[tag_after..close_start]); + } + } + cursor = close_end; + continue; + } + cursor = tag_after; + } + output.push_str(&lower[cursor..]); + output +} + #[derive(Clone, Debug, Eq, PartialEq)] enum ExecutableClassicScriptSource { Inline(String), @@ -5633,6 +5877,31 @@ pub(super) struct JavascriptModuleAnalysis { BTreeMap<(String, usize), BTreeMap>>, dynamic_import_member_ranges: BTreeMap<(String, usize), BTreeMap>>>, + dynamic_import_member_write_ranges: Vec>, + dynamic_import_member_write_assignments: Vec<(std::ops::Range, std::ops::Range)>, +} + +#[derive(Default)] +struct JavascriptAssignmentMemberRangeCollector { + ranges: Vec>, + assignments: Vec<(std::ops::Range, std::ops::Range)>, +} + +impl<'a> VisitJavascript<'a> for JavascriptAssignmentMemberRangeCollector { + fn visit_assignment_expression(&mut self, assignment: &oxc_ast::ast::AssignmentExpression<'a>) { + if assignment.operator.is_assign() { + if let Some(member) = assignment.left.as_member_expression() { + let span = member.span(); + let member_range = span.start as usize..span.end as usize; + self.ranges.push(member_range.clone()); + self.assignments.push(( + member_range, + assignment.span.start as usize..assignment.span.end as usize, + )); + } + } + oxc_ast_visit::walk::walk_assignment_expression(self, assignment); + } } fn javascript_module_export_name(name: &JavascriptModuleExportName<'_>) -> String { @@ -7592,6 +7861,10 @@ pub(super) fn javascript_module_analysis( collector.analysis.dynamic_import_demands = dynamic_import_usage.demands; collector.analysis.dynamic_import_binding_references = dynamic_import_usage.binding_references; collector.analysis.dynamic_import_member_ranges = dynamic_import_usage.member_ranges; + let mut assignment_members = JavascriptAssignmentMemberRangeCollector::default(); + assignment_members.visit_program(&parsed.program); + collector.analysis.dynamic_import_member_write_ranges = assignment_members.ranges; + collector.analysis.dynamic_import_member_write_assignments = assignment_members.assignments; collector.analysis.binding_names = semantic .semantic .scoping() @@ -9251,12 +9524,37 @@ fn javascript_reachable_dynamic_import_demands( mut reachable: impl FnMut(usize) -> bool, ) -> BTreeMap<(String, usize), BTreeSet<(String, Vec)>> { let mut reachable_demands = BTreeMap::<(String, usize), BTreeSet<(String, Vec)>>::new(); + let mut namespace_write_ranges = analysis + .dynamic_import_member_ranges + .values() + .flat_map(|exports| exports.values()) + .flatten() + .filter(|member| { + analysis + .dynamic_import_member_write_ranges + .iter() + .any(|write| write.start <= member.start && member.end <= write.end) + }) + .cloned() + .collect::>(); + namespace_write_ranges.sort_by_key(|range| (range.start, range.end)); + namespace_write_ranges.dedup(); + let reachable_namespace_writes = namespace_write_ranges + .iter() + .filter(|range| reachable(range.start)) + .cloned() + .collect::>(); 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)) { + if positions.iter().any(|position| { + reachable(*position) + && !reachable_namespace_writes + .iter() + .any(|write| write.contains(position) || write.end <= *position) + }) { reachable_demands .entry((source.clone(), *import_position)) .or_default() @@ -9267,6 +9565,29 @@ fn javascript_reachable_dynamic_import_demands( reachable_demands } +fn javascript_reachable_dynamic_namespace_write_sources( + analysis: &JavascriptModuleAnalysis, + mut reachable: impl FnMut(usize) -> bool, +) -> BTreeSet { + analysis + .dynamic_import_member_ranges + .iter() + .filter_map(|((source, _), exports)| { + exports + .values() + .flatten() + .any(|member| { + reachable(member.start) + && analysis + .dynamic_import_member_write_ranges + .iter() + .any(|write| write.start <= member.start && member.end <= write.end) + }) + .then(|| source.clone()) + }) + .collect() +} + fn javascript_global_side_effect_projection(content: &str) -> String { let allocator = JavascriptAllocator::default(); let parsed = JavascriptParser::new(&allocator, content, javascript_source_type(true)).parse(); @@ -9404,6 +9725,7 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( std::collections::BTreeMap::)>>::new(); let mut module_dynamic_dependencies = BTreeMap::)>>>::new(); + let mut blocked_dynamic_modules = BTreeSet::::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))?; @@ -9439,6 +9761,12 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( } let analysis = normalize_javascript_module_analysis_sources("game/index.html", analysis)?; let ranges = named_javascript_function_ranges(&inline_module); + blocked_dynamic_modules.extend(javascript_reachable_dynamic_namespace_write_sources( + &analysis, + |position| { + javascript_dynamic_import_position_is_reachable(&inline_module, &ranges, position) + }, + )); module_dynamic_dependencies.insert( inline_id.clone(), javascript_reachable_dynamic_import_demands(&analysis, |position| { @@ -9515,6 +9843,11 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( } let analysis = normalize_javascript_module_analysis_sources(&local_path, analysis)?; let ranges = named_javascript_function_ranges(&script); + blocked_dynamic_modules.extend( + javascript_reachable_dynamic_namespace_write_sources(&analysis, |position| { + javascript_dynamic_import_position_is_reachable(&script, &ranges, position) + }), + ); module_dynamic_dependencies.insert( local_path.clone(), javascript_reachable_dynamic_import_demands(&analysis, |position| { @@ -9787,14 +10120,18 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( ClassicExternalScriptTiming::Async => unreachable!("async rejected before loading"), }; unit.push_str(&script); - unit.push('\n'); + // Each classic script tag creates a distinct Script Record. A hard empty + // statement keeps ASI-sensitive tails from absorbing the next record. + unit.push_str("\n;\n"); } parser_blocking_classic_unit.push_str(&deferred_classic_unit); output.classic_ordered_unit = Some(parser_blocking_classic_unit); validate_javascript_module_links(&module_analyses)?; output .module_units - .extend(module_contents.values().cloned()); + .extend(module_contents.iter().filter_map(|(module, content)| { + (!blocked_dynamic_modules.contains(module)).then(|| content.clone()) + })); let mut projection_contents = module_contents.clone(); let original_projection_identities = module_contents .iter() @@ -10174,6 +10511,14 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( unit_replacements.extend( ranges .iter() + .filter(|range| { + !importer_analysis + .dynamic_import_member_write_ranges + .iter() + .any(|write| { + write.start <= range.start && range.end <= write.end + }) + }) .cloned() .map(|range| (range, projected_local.to_string())), ); @@ -11458,7 +11803,7 @@ fn tetris_executable_semantics_gap( "clearedlines", "occupiedcells", ]; - let normalized_content = content.to_ascii_lowercase(); + let normalized_content = gameplay_identity_and_telemetry_text(content.as_bytes()); if telemetry_fields .iter() .any(|field| !normalized_content.contains(field) && !external_javascript.contains(field)) @@ -11478,11 +11823,11 @@ pub(in crate::agent) fn inherited_gameplay_semantics_gap_with_external_javascrip return Some("invalid-html-utf8".to_string()); }; let content = strip_art_reference_comments(html); - let normalized_content = content.to_ascii_lowercase(); + let normalized_content = gameplay_identity_and_telemetry_text(content.as_bytes()); let contains_any = |markers: &[&str]| { - markers - .iter() - .any(|marker| normalized_content.contains(marker)) + markers.iter().any(|marker| { + normalized_content.contains(marker) || external_javascript.contains(marker) + }) }; let missing = match gameplay { AutonomousInheritedGameplaySemantics::Tetris => { @@ -12519,6 +12864,223 @@ mod visible_destination_tests { canvas, )); } + + #[test] + fn dynamic_import_namespace_writes_do_not_become_mutable_projected_bindings() { + let root = tempfile::tempdir().expect("create dynamic namespace write fixture"); + fs::create_dir_all(root.path().join("game")).expect("create game directory"); + fs::write( + root.path().join("game/render.mjs"), + "export function draw() { const context=document.getElementById('game').getContext('2d'); const art=new Image(); art.src='../assets/art-spec.png'; context.drawImage(art, 0, 0, 64, 64); }", + ) + .expect("write render module"); + let write_then_read = br#" + + + "#; + assert!(!game_index_visibly_uses_visual_asset( + root.path(), + write_then_read, + "assets/art-spec.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + + let read_only = br#" + + + "#; + assert!(game_index_visibly_uses_visual_asset( + root.path(), + read_only, + "assets/art-spec.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + } + + #[test] + fn classic_script_records_have_hard_asi_boundaries() { + let root = tempfile::tempdir().expect("create classic ASI fixture"); + fs::create_dir_all(root.path().join("game")).expect("create game directory"); + fs::write( + root.path().join("game/setup.js"), + "const context=document.getElementById('game').getContext('2d');const art=new Image();art.src='../assets/art-spec.png';renderFrame=()=>context.drawImage(art,0,0,64,64)", + ) + .expect("write parser blocking setup"); + fs::write( + root.path().join("game/deferred.js"), + "deferredFrame=()=>context.drawImage(art,0,0,64,64)", + ) + .expect("write deferred setup"); + fs::write( + root.path().join("game/invoke-deferred.js"), + "(deferredFrame)()", + ) + .expect("write deferred invocation"); + for html in [ + br#""#.as_slice(), + br#""#.as_slice(), + ] { + assert!(game_index_visibly_uses_visual_asset( + root.path(), + html, + "assets/art-spec.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + } + } + + #[test] + fn canvas_source_assignments_respect_reachability_and_draw_time() { + let root = tempfile::tempdir().expect("create source assignment fixture"); + for decoy in [ + "if(false){art.src='../assets/decoy.png';}", + "function neverCalled(){art.src='../assets/decoy.png';}", + "context.drawImage(art,0,0,64,64);art.src='../assets/decoy.png';", + ] { + let draw = if decoy.starts_with("context.drawImage") { + String::new() + } else { + "context.drawImage(art,0,0,64,64);".to_string() + }; + let html = format!( + "" + ); + assert!(game_index_visibly_uses_visual_asset( + root.path(), + html.as_bytes(), + "assets/PLAYER.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + } + } + + #[test] + fn canvas_draw_requires_exact_callee_and_visible_canvas_context() { + let root = tempfile::tempdir().expect("create canvas association fixture"); + let fake = br#""#; + assert!(!game_index_visibly_uses_visual_asset( + root.path(), + fake, + "assets/art-spec.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + + let offscreen = br#""#; + assert!(!game_index_visibly_uses_visual_asset( + root.path(), + offscreen, + "assets/art-spec.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + + let hidden_first = br#""#; + assert!(!game_index_visibly_uses_visual_asset( + root.path(), + hidden_first, + "assets/art-spec.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + + let visible = br#""#; + assert!(game_index_visibly_uses_visual_asset( + root.path(), + visible, + "assets/art-spec.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + + let dom_image = br#""#; + assert!(game_index_visibly_uses_visual_asset( + root.path(), + dom_image, + "assets/art-spec.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + } + + #[test] + fn visual_asset_paths_remain_case_sensitive() { + let root = tempfile::tempdir().expect("create case-sensitive asset fixture"); + for (source, expected) in [ + ("../assets/player.png", false), + ("../assets/PLAYER.png", true), + ] { + let html = format!( + "" + ); + assert_eq!( + game_index_visibly_uses_visual_asset( + root.path(), + html.as_bytes(), + "assets/PLAYER.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + ), + expected, + ); + } + for (source, expected) in [ + ("../assets/player.png", false), + ("../assets/PLAYER.png", true), + ] { + let html = format!( + "" + ); + assert_eq!( + game_index_visibly_uses_visual_asset( + root.path(), + html.as_bytes(), + "assets/PLAYER.png", + (64, 64), + VisualAssetUsageRequirement::AnyVisible, + ), + expected, + ); + } + } + + #[test] + fn gameplay_identity_and_telemetry_scan_only_rendered_or_executable_content() { + let decoys = br#" + + + + "#; + let scanned = gameplay_identity_and_telemetry_text(decoys); + for decoy in [ + "tetris", + "activepieceid", + "rotation", + "lockedpieces", + "lineclearchecks", + "clearedlines", + "occupiedcells", + ] { + assert!(!scanned.contains(decoy), "inert decoy leaked: {decoy}"); + } + + let executable = br#"

Tetris

"#; + let scanned = gameplay_identity_and_telemetry_text(executable); + for marker in ["tetris", "activepieceid", "occupiedcells"] { + assert!(scanned.contains(marker), "real evidence missing: {marker}"); + } + } } #[cfg(test)] 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 efcead174..2cd3b3af9 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 @@ -1389,7 +1389,7 @@ fn inherited_tetris_contract_scans_only_executable_html_scripts() { ); assert_eq!( inherited_gameplay_semantics_gap(task, plain_text.as_bytes()).as_deref(), - Some("board-state"), + Some("tetris-identity"), "text/plain script content must not count as executable gameplay", ); @@ -1405,7 +1405,7 @@ fn inherited_tetris_contract_scans_only_executable_html_scripts() { template.insert_str(template_close + "".len(), ""); assert_eq!( inherited_gameplay_semantics_gap(task, template.as_bytes()).as_deref(), - Some("board-state"), + Some("tetris-identity"), "template script content must not count as executable gameplay", ); @@ -1417,7 +1417,7 @@ fn inherited_tetris_contract_scans_only_executable_html_scripts() { comment.insert_str(comment_close + "".len(), " -->"); assert_eq!( inherited_gameplay_semantics_gap(task, comment.as_bytes()).as_deref(), - Some("board-state"), + Some("tetris-identity"), "HTML-commented script content must not count as executable gameplay", ); @@ -1429,7 +1429,7 @@ fn inherited_tetris_contract_scans_only_executable_html_scripts() { noscript.insert_str(noscript_close + "".len(), ""); assert_eq!( inherited_gameplay_semantics_gap(task, noscript.as_bytes()).as_deref(), - Some("board-state"), + Some("tetris-identity"), "noscript content must not count as executable gameplay in a scripting browser", ); @@ -1456,7 +1456,7 @@ fn inherited_tetris_contract_scans_only_executable_html_scripts() { } assert_eq!( inherited_gameplay_semantics_gap(task, wrapped.as_bytes()).as_deref(), - Some("board-state"), + Some("tetris-identity"), "{container} content must not count as executable gameplay", ); assert!( @@ -1490,7 +1490,7 @@ fn inherited_tetris_contract_scans_only_executable_html_scripts() { self_closing_textarea.insert_str(textarea_script_close + "".len(), ""); assert_eq!( inherited_gameplay_semantics_gap(task, self_closing_textarea.as_bytes()).as_deref(), - Some("board-state"), + Some("tetris-identity"), "a slash does not make a non-void textarea self-closing in HTML", ); @@ -1506,21 +1506,21 @@ fn inherited_tetris_contract_scans_only_executable_html_scripts() { template_with_fake_close.insert_str(template_script_close + "".len(), ""); assert_eq!( inherited_gameplay_semantics_gap(task, template_with_fake_close.as_bytes()).as_deref(), - Some("board-state"), + Some("tetris-identity"), "a template close marker inside script text must not expose later inert scripts", ); let sourced_inline_body = valid.replacen("