From e14130f234dc7d085c12117493dc78bc138313b1 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 4 Aug 2026 16:39:34 +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=E7=8A=B6=E6=80=81=E4=B8=8E=E5=8F=AF=E8=A7=81=E8=AF=81?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canvas、context 与图片整体赋值进入状态流并失效旧绑定 无条件 return、throw 与 ESM 依赖失败阻断后续视觉证据 telemetry 仅接受可见 DOM 与可达 JavaScript 状态字段 CSS 资产路径保持大小写并要求命中可见元素 新增视觉反例正例并更新共享决策日志 --- .../runtime_protocol/autonomous_completion.rs | 642 +++++++++++++++--- .../shared-memory/decision-log.md | 8 + 2 files changed, 556 insertions(+), 94 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 c29cd298f..adc96c656 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 @@ -773,46 +773,52 @@ fn css_contains_resolving_url(style: &str, asset_path: &str) -> bool { false } -fn selector_is_bound_to_markup(selector: &str, markup: &str) -> bool { - selector.split(',').any(|selector| { - let selector = selector.trim(); - if let Some(class_name) = selector.strip_prefix('.') { - let class_name = class_name - .split(|character: char| { - !character.is_ascii_alphanumeric() && !matches!(character, '_' | '-') - }) - .next() - .unwrap_or_default(); - return !class_name.is_empty() - && markup.split('<').any(|tag| { - html_attribute_value(tag, "class").is_some_and(|classes| { - classes - .split_ascii_whitespace() - .any(|value| value == class_name) - }) - }); +fn selector_is_bound_to_visible_markup(selector: &str, markup: &str, stylesheets: &str) -> bool { + let mut hidden_stack = Vec::<(String, bool)>::new(); + for fragment in markup.split('<').skip(1) { + let tag = format!("<{fragment}"); + let lower_tag = tag.to_ascii_lowercase(); + let Some((name, closing)) = html_tag_name(&lower_tag) else { + continue; + }; + if closing { + while let Some((open, _)) = hidden_stack.pop() { + if open == name { + break; + } + } + continue; } - if let Some(id) = selector.strip_prefix('#') { - let id = id - .split(|character: char| { - !character.is_ascii_alphanumeric() && !matches!(character, '_' | '-') - }) - .next() - .unwrap_or_default(); - return !id.is_empty() - && markup - .split('<') - .any(|tag| html_attribute_value(tag, "id") == Some(id)); + let parent_hidden = hidden_stack.last().is_some_and(|(_, hidden)| *hidden); + let hidden = parent_hidden + || tag_is_obviously_hidden_or_tiny(&lower_tag, (300, 150)) + || tag_is_hidden_by_stylesheet(&lower_tag, stylesheets, (300, 150)); + if !hidden && selector_matches_tag(selector, &tag) { + return true; } - let tag_name = selector - .split(|character: char| !character.is_ascii_alphanumeric() && character != '-') - .next() - .unwrap_or_default(); - !tag_name.is_empty() - && markup - .split('<') - .any(|tag| tag.trim_start().starts_with(tag_name)) - }) + let is_void = matches!( + name.as_str(), + "area" + | "base" + | "br" + | "col" + | "embed" + | "hr" + | "img" + | "input" + | "link" + | "meta" + | "source" + | "track" + | "wbr" + ) || lower_tag + .trim_end_matches([' ', '\t', '\r', '\n', '>']) + .ends_with('/'); + if !is_void { + hidden_stack.push((name, hidden)); + } + } + false } fn selector_matches_tag(selector: &str, tag: &str) -> bool { @@ -975,6 +981,58 @@ fn javascript_statement_range(content: &str, start: usize) -> Option>, +} + +impl JavascriptUnreachableTailCollector { + fn mark_tail(&mut self, statements: &[JavascriptStatement<'_>]) { + let mut terminated = false; + for statement in statements { + if terminated { + let span = statement.span(); + self.ranges.push(span.start as usize..span.end as usize); + } else if javascript_statement_definitely_terminates(statement) { + terminated = true; + } + } + } +} + +impl<'a> VisitJavascript<'a> for JavascriptUnreachableTailCollector { + fn visit_program(&mut self, program: &oxc_ast::ast::Program<'a>) { + self.mark_tail(&program.body); + oxc_ast_visit::walk::walk_program(self, program); + } + + fn visit_function_body(&mut self, body: &oxc_ast::ast::FunctionBody<'a>) { + self.mark_tail(&body.statements); + oxc_ast_visit::walk::walk_function_body(self, body); + } + + fn visit_block_statement(&mut self, block: &oxc_ast::ast::BlockStatement<'a>) { + self.mark_tail(&block.body); + oxc_ast_visit::walk::walk_block_statement(self, block); + } +} + +fn javascript_unreachable_tail_ranges(content: &str) -> Vec> { + let allocator = JavascriptAllocator::default(); + let parsed = JavascriptParser::new( + &allocator, + content, + JavascriptSourceType::default().with_unambiguous(true), + ) + .parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { + return Vec::new(); + } + let mut collector = JavascriptUnreachableTailCollector::default(); + collector.visit_program(&parsed.program); + collector.ranges +} + #[derive(Clone, Debug, Default, Eq, PartialEq)] struct JavascriptLiteralFalseRangeIndex { ranges: Vec>, @@ -983,6 +1041,7 @@ struct JavascriptLiteralFalseRangeIndex { impl JavascriptLiteralFalseRangeIndex { fn analyze(content: &str) -> Self { let mut ranges = javascript_obvious_false_branch_ranges_uncached(content); + ranges.extend(javascript_unreachable_tail_ranges(content)); ranges.sort_by_key(|range| (range.start, range.end)); let mut merged = Vec::>::with_capacity(ranges.len()); for range in ranges { @@ -3775,8 +3834,8 @@ struct JavascriptCanvasVisualCollector<'a> { visible_canvases: &'a [(Option, (f64, f64))], asset_element_ids: &'a BTreeSet, asset_path: &'a str, - canvas_bindings: BTreeMap, - context_bindings: BTreeMap, + canvas_events: BTreeMap>>, + context_events: BTreeMap>>, source_events: BTreeMap>>, draws: Vec, } @@ -3826,13 +3885,14 @@ impl JavascriptCanvasVisualCollector<'_> { fn canvas_expression_dimensions( &self, expression: &JavascriptExpression<'_>, + at: usize, ) -> Option<(f64, f64)> { match expression { JavascriptExpression::Identifier(identifier) => self .symbol_for_identifier(identifier) - .and_then(|symbol_id| self.canvas_bindings.get(&symbol_id).copied()), + .and_then(|symbol_id| self.event_value_at(&self.canvas_events, symbol_id, at)), JavascriptExpression::ParenthesizedExpression(parenthesized) => { - self.canvas_expression_dimensions(&parenthesized.expression) + self.canvas_expression_dimensions(&parenthesized.expression, at) } JavascriptExpression::CallExpression(call) => { if let Some(id) = self.global_document_call_argument(call, "getElementById") { @@ -3865,21 +3925,52 @@ impl JavascriptCanvasVisualCollector<'_> { fn context_expression_dimensions( &self, expression: &JavascriptExpression<'_>, + at: usize, ) -> 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()); + .and_then(|symbol_id| self.event_value_at(&self.context_events, symbol_id, at)); } 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())) + .then(|| self.canvas_expression_dimensions(member.object(), at)) .flatten() } + fn event_value_at( + &self, + events_by_symbol: &BTreeMap>>, + symbol_id: JavascriptSymbolId, + at: usize, + ) -> Option { + let events = events_by_symbol.get(&symbol_id)?; + let selection = javascript_alias_event_indices_at( + events, + self.ranges, + self.content, + &self.ranges.literal_false_ranges, + self.conditional_ranges, + at, + &mut BTreeSet::new(), + false, + ); + if !selection.resolved || selection.indices.is_empty() { + return None; + } + let mut values = selection + .indices + .into_iter() + .map(|index| events[index].value); + let value = values.next().flatten()?; + values + .all(|candidate| candidate == Some(value)) + .then_some(value) + } + fn expression_selects_asset_element(&self, expression: &JavascriptExpression<'_>) -> bool { let JavascriptExpression::CallExpression(call) = expression else { return false; @@ -3892,34 +3983,59 @@ impl JavascriptCanvasVisualCollector<'_> { .is_some_and(|id| self.asset_element_ids.contains(id)) } + fn record_symbol_state( + &mut self, + symbol_id: JavascriptSymbolId, + initializer: &JavascriptExpression<'_>, + position: usize, + ) { + let scope = javascript_alias_scope_at(self.ranges, position); + let conditional = + javascript_position_is_conditionally_executed(self.conditional_ranges, position); + let canvas = self.canvas_expression_dimensions(initializer, position); + self.canvas_events + .entry(symbol_id) + .or_default() + .push(JavascriptAliasEvent { + position, + scope, + value: canvas, + conditional, + }); + let context = self.context_expression_dimensions(initializer, position); + self.context_events + .entry(symbol_id) + .or_default() + .push(JavascriptAliasEvent { + position, + scope, + value: context, + conditional, + }); + let source_matches = self.expression_selects_asset_element(initializer) + || initializer + .get_identifier_reference() + .and_then(|identifier| self.symbol_for_identifier(identifier)) + .and_then(|source| self.event_value_at(&self.source_events, source, position)) + .unwrap_or(false); + self.source_events + .entry(symbol_id) + .or_default() + .push(JavascriptAliasEvent { + position, + scope, + value: Some(source_matches), + conditional, + }); + } + 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, - ), - }); + if let Some(symbol_id) = identifier.symbol_id.get() { + self.record_symbol_state(symbol_id, initializer, initializer.span().end as usize); } } @@ -3944,6 +4060,21 @@ impl<'a> VisitJavascript<'a> for JavascriptCanvasVisualCollector<'_> { 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_symbol_state( + symbol_id, + &assignment.right, + assignment.span.end as usize, + ); + } + } if let Some(member) = assignment.left.as_member_expression() { if member .static_property_name() @@ -3997,8 +4128,10 @@ impl<'a> VisitJavascript<'a> for JavascriptCanvasVisualCollector<'_> { 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), + .and_then(|symbol_id| { + self.event_value_at(&self.context_events, symbol_id, position) + }), + expression => self.context_expression_dimensions(expression, position), }; let image = call .arguments @@ -4075,8 +4208,8 @@ fn javascript_canvas_visual_draws( visible_canvases, asset_element_ids, asset_path, - canvas_bindings: BTreeMap::new(), - context_bindings: BTreeMap::new(), + canvas_events: BTreeMap::new(), + context_events: BTreeMap::new(), source_events: BTreeMap::new(), draws: Vec::new(), }; @@ -4220,7 +4353,7 @@ fn game_index_visibly_uses_visual_asset( }; if requirement == VisualAssetUsageRequirement::AnyVisible && css_contains_resolving_url(declarations, asset_path) - && selector_is_bound_to_markup(selector, &original_markup) + && selector_is_bound_to_visible_markup(selector, &original_markup, &stylesheets) && !tag_is_obviously_hidden_or_tiny( &format!("
", declarations.to_ascii_lowercase()), (0, 0), @@ -5549,17 +5682,91 @@ 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 { +fn javascript_reachable_evidence_text(content: &str, is_module: bool) -> String { + if !javascript_is_syntactically_valid(content, is_module) { return String::new(); - }; - let content = strip_art_reference_comments(content); + } + let ranges = named_javascript_function_ranges(content); + let mut evidence = javascript_without_string_literals_or_comments(content).into_bytes(); + for range in &ranges.literal_false_ranges.ranges { + if range.end <= evidence.len() { + evidence[range.clone()].fill(b' '); + } + } + for (index, range) in ranges.iter().enumerate() { + if !javascript_named_function_is_reachable(content, &ranges, index, &mut BTreeSet::new()) + && range.end <= evidence.len() + { + evidence[range.start..range.end].fill(b' '); + } + } + String::from_utf8(evidence) + .expect("masking JavaScript evidence with ASCII spaces preserves UTF-8") +} + +fn javascript_reachable_identity_text(content: &str, is_module: bool) -> String { + if !javascript_is_syntactically_valid(content, is_module) { + return String::new(); + } + let allocator = JavascriptAllocator::default(); + let parsed = + JavascriptParser::new(&allocator, content, javascript_source_type(is_module)).parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { + return String::new(); + } + let ranges = named_javascript_function_ranges(content); + let mut identity = content.as_bytes().to_vec(); + for comment in &parsed.program.comments { + for byte in &mut identity[javascript_span_range(comment.span())] { + if !matches!(*byte, b'\r' | b'\n') { + *byte = b' '; + } + } + } + for range in &ranges.literal_false_ranges.ranges { + if range.end <= identity.len() { + identity[range.clone()].fill(b' '); + } + } + for (index, range) in ranges.iter().enumerate() { + if !javascript_named_function_is_reachable(content, &ranges, index, &mut BTreeSet::new()) + && range.end <= identity.len() + { + identity[range.start..range.end].fill(b' '); + } + } + String::from_utf8(identity) + .expect("masking JavaScript identity with ASCII spaces preserves UTF-8") +} + +fn html_tag_name(tag: &str) -> Option<(String, bool)> { + let trimmed = tag.trim_start_matches(['<', ' ', '\t', '\r', '\n']); + let (trimmed, closing) = trimmed + .strip_prefix('/') + .map_or((trimmed, false), |value| (value, true)); + let name = trimmed + .chars() + .take_while(|character| character.is_ascii_alphanumeric() || *character == '-') + .collect::(); + (!name.is_empty()).then_some((name, closing)) +} + +fn visible_html_and_reachable_inline_javascript( + content: &str, + include_javascript_literals: bool, +) -> String { + let (_, stylesheets) = rendered_html_markup_and_stylesheets(content); + let stylesheets = stylesheets.to_ascii_lowercase(); let lower = content.to_ascii_lowercase(); let mut output = String::new(); + let mut hidden_stack = Vec::<(String, bool)>::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 hidden_stack.last().is_none_or(|(_, hidden)| !*hidden) { + output.push_str(&content[cursor..tag_start]); + output.push(' '); + } if lower[tag_start..].starts_with("") @@ -5584,19 +5791,79 @@ fn gameplay_identity_and_telemetry_text(content: &[u8]) -> String { }; 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]); - } + let evidence = if include_javascript_literals { + javascript_reachable_identity_text(body, html_script_is_module(tag)) + } else { + javascript_reachable_evidence_text(body, html_script_is_module(tag)) + }; + output.push_str(&evidence); + output.push(' '); } cursor = close_end; continue; } + let Some((name, closing)) = html_tag_name(tag) else { + cursor = tag_after; + continue; + }; + if closing { + while let Some((open, _)) = hidden_stack.pop() { + if open == name { + break; + } + } + cursor = tag_after; + continue; + } + let parent_hidden = hidden_stack.last().is_some_and(|(_, hidden)| *hidden); + let hidden = parent_hidden + || tag_is_obviously_hidden_or_tiny(tag, (300, 150)) + || tag_is_hidden_by_stylesheet(tag, &stylesheets, (300, 150)); + let is_void = matches!( + name.as_str(), + "area" + | "base" + | "br" + | "col" + | "embed" + | "hr" + | "img" + | "input" + | "link" + | "meta" + | "source" + | "track" + | "wbr" + ) || tag + .trim_end_matches([' ', '\t', '\r', '\n', '>']) + .ends_with('/'); + if !is_void { + hidden_stack.push((name, hidden)); + } cursor = tag_after; } - output.push_str(&lower[cursor..]); + if hidden_stack.last().is_none_or(|(_, hidden)| !*hidden) { + output.push_str(&content[cursor..]); + } output } +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); + visible_html_and_reachable_inline_javascript(&content, false).to_ascii_lowercase() +} + +fn gameplay_identity_text(content: &[u8]) -> String { + let Ok(content) = std::str::from_utf8(content) else { + return String::new(); + }; + let content = strip_art_reference_comments(content); + visible_html_and_reachable_inline_javascript(&content, true).to_ascii_lowercase() +} + #[derive(Clone, Debug, Eq, PartialEq)] enum ExecutableClassicScriptSource { Inline(String), @@ -9592,15 +9859,34 @@ impl ExternalGameplayJavascript { } fn contains(&self, marker: &str) -> bool { - self.classic_global + let marker = marker.to_ascii_lowercase(); + let classic = self + .classic_ordered_unit + .as_deref() + .unwrap_or(self.classic_global.as_str()); + javascript_reachable_evidence_text(classic, false) .to_ascii_lowercase() - .contains(&marker.to_ascii_lowercase()) + .contains(&marker) || self.module_units.iter().any(|unit| { - unit.to_ascii_lowercase() - .contains(&marker.to_ascii_lowercase()) + javascript_reachable_evidence_text(unit, true) + .to_ascii_lowercase() + .contains(&marker) }) } + fn contains_identity(&self, marker: &str) -> bool { + let marker = marker.to_ascii_lowercase(); + let classic = self + .classic_ordered_unit + .as_deref() + .unwrap_or(self.classic_global.as_str()); + classic.to_ascii_lowercase().contains(&marker) + || self + .module_units + .iter() + .any(|unit| unit.to_ascii_lowercase().contains(&marker)) + } + #[cfg(test)] pub(in crate::agent) fn module_units(&self) -> &[String] { &self.module_units @@ -9866,10 +10152,11 @@ fn javascript_global_side_effect_projection(content: &str) -> String { let mut fragments = BTreeMap::<(usize, usize), String>::new(); let mut pending_dependencies = Vec::::new(); for statement in &parsed.program.body { - let JavascriptStatement::ExpressionStatement(statement) = statement else { - continue; + let (span, unconditional_throw) = match statement { + JavascriptStatement::ExpressionStatement(statement) => (statement.span, false), + JavascriptStatement::ThrowStatement(statement) => (statement.span, true), + _ => continue, }; - let span = statement.span; let Some(fragment) = content.get(span.start as usize..span.end as usize) else { continue; }; @@ -9885,9 +10172,10 @@ fn javascript_global_side_effect_projection(content: &str) -> String { continue; } let globals = semantic.semantic.scoping().root_unresolved_references(); - if !["globalThis", "window"] - .iter() - .any(|name| globals.keys().any(|global| global.as_str() == *name)) + if !unconditional_throw + && !["globalThis", "window"] + .iter() + .any(|name| globals.keys().any(|global| global.as_str() == *name)) { continue; } @@ -12061,7 +12349,6 @@ fn tetris_executable_semantics_gap( let telemetry_fields = [ "gameplay", "kind", - "tetris", "activepieceid", "rotation", "row", @@ -12090,10 +12377,10 @@ 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 = gameplay_identity_and_telemetry_text(content.as_bytes()); + let normalized_identity = gameplay_identity_text(content.as_bytes()); let contains_any = |markers: &[&str]| { markers.iter().any(|marker| { - normalized_content.contains(marker) || external_javascript.contains(marker) + normalized_identity.contains(marker) || external_javascript.contains_identity(marker) }) }; let missing = match gameplay { @@ -13348,6 +13635,173 @@ mod visible_destination_tests { assert!(scanned.contains(marker), "real evidence missing: {marker}"); } } + + #[test] + fn canvas_reassignments_invalidate_previous_visual_bindings() { + let root = tempfile::tempdir().expect("create canvas reassignment fixture"); + for script in [ + "let canvas=document.getElementById('game');let context=canvas.getContext('2d');let art=new Image();art.src='../assets/art-spec.png';canvas=document.createElement('canvas');context=canvas.getContext('2d');context.drawImage(art,0,0,64,64);", + "let canvas=document.getElementById('game');let context=canvas.getContext('2d');let art=new Image();art.src='../assets/art-spec.png';context=document.createElement('canvas').getContext('2d');context.drawImage(art,0,0,64,64);", + "let canvas=document.getElementById('game');let context=canvas.getContext('2d');let art=new Image();art.src='../assets/art-spec.png';art=new Image();context.drawImage(art,0,0,64,64);", + ] { + let html = format!( + "" + ); + assert!(!game_index_visibly_uses_visual_asset( + root.path(), + html.as_bytes(), + "assets/art-spec.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + } + + let restored = br#""#; + assert!(game_index_visibly_uses_visual_asset( + root.path(), + restored, + "assets/art-spec.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + } + + #[test] + fn canvas_draw_after_unconditional_return_or_throw_is_unreachable() { + let root = tempfile::tempdir().expect("create terminated canvas fixture"); + for body in [ + "return;context.drawImage(art,0,0,64,64);", + "throw new Error('stop');context.drawImage(art,0,0,64,64);", + ] { + let html = format!( + "" + ); + assert!(!game_index_visibly_uses_visual_asset( + root.path(), + html.as_bytes(), + "assets/art-spec.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + } + + let guarded = br#""#; + assert!(game_index_visibly_uses_visual_asset( + root.path(), + guarded, + "assets/art-spec.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + } + + #[test] + fn esm_dependency_failure_blocks_importer_visual_evidence() { + let root = tempfile::tempdir().expect("create failing ESM dependency fixture"); + fs::create_dir_all(root.path().join("game")).expect("create game directory"); + fs::write( + root.path().join("game/render.mjs"), + "throw new Error('dependency failed');const context=document.getElementById('game').getContext('2d');const art=new Image();art.src='../assets/art-spec.png';export function draw(){context.drawImage(art,0,0,64,64)}", + ) + .expect("write failing dependency"); + fs::write( + root.path().join("game/main.mjs"), + "import {draw} from './render.mjs';draw();", + ) + .expect("write importer"); + let html = br#""#; + assert!(!game_index_visibly_uses_visual_asset( + root.path(), + html, + "assets/art-spec.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + + fs::write( + root.path().join("game/render.mjs"), + "export function unused(){throw new Error('nested only')}if(false){throw new Error('guarded')}const context=document.getElementById('game').getContext('2d');const art=new Image();art.src='../assets/art-spec.png';export function draw(){context.drawImage(art,0,0,64,64)}", + ) + .expect("write non-failing dependency"); + assert!(game_index_visibly_uses_visual_asset( + root.path(), + html, + "assets/art-spec.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + } + + #[test] + fn telemetry_evidence_excludes_hidden_unreachable_and_string_decoys() { + let decoys = br#"

Tetris activePieceId

rotation

"#; + let scanned = gameplay_identity_and_telemetry_text(decoys); + for marker in [ + "tetris", + "activepieceid", + "rotation", + "lockedpieces", + "lineclearchecks", + "clearedlines", + "occupiedcells", + ] { + assert!(!scanned.contains(marker), "decoy leaked: {marker}"); + } + + let reachable = br#"

Tetris

"#; + let scanned = gameplay_identity_and_telemetry_text(reachable); + for marker in ["tetris", "activepieceid", "occupiedcells"] { + assert!( + scanned.contains(marker), + "reachable evidence missing: {marker}" + ); + } + + let external_decoys = ExternalGameplayJavascript { + classic_ordered_unit: Some( + "const bait='activePieceId';if(false){globalThis.rotation=1}function never(){globalThis.lockedPieces=1}" + .to_string(), + ), + ..ExternalGameplayJavascript::default() + }; + for marker in ["activepieceid", "rotation", "lockedpieces"] { + assert!( + !external_decoys.contains(marker), + "external decoy leaked: {marker}" + ); + } + let external_reachable = ExternalGameplayJavascript { + classic_ordered_unit: Some( + "function publish(){globalThis.activePieceId=1}publish();".to_string(), + ), + ..ExternalGameplayJavascript::default() + }; + assert!(external_reachable.contains("activepieceid")); + } + + #[test] + fn css_visual_evidence_requires_case_exact_url_on_visible_matching_element() { + let root = tempfile::tempdir().expect("create CSS visual fixture"); + for (html, expected) in [ + ("
", true), + ("
", false), + ("
", false), + ("
", false), + ("", false), + ] { + assert_eq!( + game_index_visibly_uses_visual_asset( + root.path(), + html.as_bytes(), + "assets/PLAYER.png", + (64, 64), + VisualAssetUsageRequirement::AnyVisible, + ), + expected, + "unexpected CSS visual result for {html}", + ); + } + } } #[cfg(test)] diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e281766fd..8df741b7f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6026,3 +6026,11 @@ - dynamic import namespace member 写入保持真实只读失败语义,视觉投影不得把写 target 改造成可变本地;可达写入的 RHS 与后续语句不提供视觉证据,纯 namespace read 继续传播 export demand。 - 身份与 telemetry 扫描只消费可渲染文本、可执行 inline JavaScript 和已链接外部 unit,排除 inert/raw-text、HTML 注释、带 `src` body 与非 JavaScript script。资产路径比较保持大小写,Linux 文件身份不得经 ASCII 小写副本合并。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + +## 2026-08-04 静态视觉状态流与可见证据收口 + +- Canvas、2D context 与图片变量统一按 semantic symbol 记录声明和整体赋值事件;重新指向离屏 Canvas、无效 context 或新图片时立即失效旧视觉身份,只有绘制点可证明的当前状态才作证。 +- 同一 Program、函数体或普通 block 内,无条件 `return / throw` 之后的语句统一进入不可达区间;ESM dependency 的直接顶层 `throw` 作为初始化副作用排在 importer 前并阻断后续 importer 视觉证据,嵌套函数或恒假分支中的 throw 不扩大阻断范围。 +- telemetry 只扫描可见 DOM 文本和 AST 可达的 JavaScript:hidden DOM、字符串/注释、恒假分支、未调用函数和 inert/raw-text 内容不得补齐状态字段;已链接 classic/module 单元沿同一可达扫描口径判定。玩法 identity 保留独立的现有识别口径,不能反向补齐 telemetry。 +- CSS `url(...)` 的资产路径保持原始大小写解析,stylesheet 证据必须同时命中实际可见元素;未命中 selector、元素自身或祖先 hidden、以及匹配隐藏规则的节点均不作证。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。