修复视觉脚本与模块求值顺序
保留 raw-text JavaScript 原文并屏蔽 HTML 注释诱饵 按 HTML 标签顺序组合 classic inline 与 external 脚本 按依赖先于 importer 的顺序组合 ESM 投影 补充视觉、脚本顺序与模块初始化回归 同步静态视觉门技术方案与项目决策
This commit is contained in:
+200
-42
@@ -523,33 +523,44 @@ fn game_index_missing_visible_art_slice(
|
||||
}
|
||||
|
||||
fn strip_art_reference_comments(content: &str) -> String {
|
||||
let bytes = content.as_bytes();
|
||||
let mut output = Vec::with_capacity(bytes.len());
|
||||
let mut cursor = 0;
|
||||
while cursor < bytes.len() {
|
||||
if bytes[cursor..].starts_with(b"<!--") {
|
||||
cursor += 4;
|
||||
while cursor < bytes.len() && !bytes[cursor..].starts_with(b"-->") {
|
||||
cursor += 1;
|
||||
}
|
||||
cursor = (cursor + 3).min(bytes.len());
|
||||
} else if bytes[cursor..].starts_with(b"/*") {
|
||||
cursor += 2;
|
||||
while cursor < bytes.len() && !bytes[cursor..].starts_with(b"*/") {
|
||||
cursor += 1;
|
||||
}
|
||||
cursor = (cursor + 2).min(bytes.len());
|
||||
} else if bytes[cursor..].starts_with(b"//") {
|
||||
cursor += 2;
|
||||
while cursor < bytes.len() && !matches!(bytes[cursor], b'\r' | b'\n') {
|
||||
cursor += 1;
|
||||
let lower = content.to_ascii_lowercase();
|
||||
let mut output = String::with_capacity(content.len());
|
||||
let mut cursor = 0usize;
|
||||
while let Some(offset) = lower[cursor..].find('<') {
|
||||
let tag_start = cursor + offset;
|
||||
output.push_str(&content[cursor..tag_start]);
|
||||
if lower[tag_start..].starts_with("<!--") {
|
||||
let comment_end = lower[tag_start + 4..]
|
||||
.find("-->")
|
||||
.map(|end| tag_start + 4 + end + 3)
|
||||
.unwrap_or(lower.len());
|
||||
for character in content[tag_start..comment_end].chars() {
|
||||
output.push(if matches!(character, '\r' | '\n') {
|
||||
character
|
||||
} else {
|
||||
' '
|
||||
});
|
||||
}
|
||||
cursor = comment_end;
|
||||
continue;
|
||||
}
|
||||
let Some(tag_after) = html_tag_end(&lower, tag_start) else {
|
||||
output.push_str(&content[tag_start..]);
|
||||
return output;
|
||||
};
|
||||
let tag_end = tag_after - 1;
|
||||
let tag = &lower[tag_start..=tag_end];
|
||||
if let Some(raw_text_name) = html_raw_text_element_name(tag) {
|
||||
let raw_text_end = raw_text_html_element_end(&lower, tag_after, raw_text_name);
|
||||
output.push_str(&content[tag_start..raw_text_end]);
|
||||
cursor = raw_text_end;
|
||||
} else {
|
||||
output.push(bytes[cursor]);
|
||||
cursor += 1;
|
||||
output.push_str(&content[tag_start..tag_after]);
|
||||
cursor = tag_after;
|
||||
}
|
||||
}
|
||||
String::from_utf8(output).unwrap_or_default()
|
||||
output.push_str(&content[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
fn strip_script_blocks(content: &str) -> String {
|
||||
@@ -3263,8 +3274,7 @@ fn game_index_visibly_uses_visual_asset(
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let mut classic_global = executable_javascript_from_html(&original_content);
|
||||
classic_global.push_str(&external_javascript.classic_global);
|
||||
let mut classic_global = external_javascript.classic_unit(&original_content);
|
||||
if !javascript_is_syntactically_valid(&classic_global, false) {
|
||||
classic_global.clear();
|
||||
}
|
||||
@@ -4534,8 +4544,30 @@ fn html_non_executable_container_end(content: &str, tag: &str, tag_end: usize) -
|
||||
}
|
||||
|
||||
fn executable_javascript_from_html(content: &str) -> String {
|
||||
executable_classic_script_sources_from_html(content)
|
||||
.into_iter()
|
||||
.filter_map(|source| match source {
|
||||
ExecutableClassicScriptSource::Inline(body) => Some(body),
|
||||
ExecutableClassicScriptSource::External(_) => None,
|
||||
})
|
||||
.fold(String::new(), |mut executable, body| {
|
||||
executable.push_str(&body);
|
||||
executable.push('\n');
|
||||
executable
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
enum ExecutableClassicScriptSource {
|
||||
Inline(String),
|
||||
External(String),
|
||||
}
|
||||
|
||||
fn executable_classic_script_sources_from_html(
|
||||
content: &str,
|
||||
) -> Vec<ExecutableClassicScriptSource> {
|
||||
let lower = content.to_ascii_lowercase();
|
||||
let mut executable = String::new();
|
||||
let mut executable = Vec::new();
|
||||
let mut cursor = 0usize;
|
||||
while let Some(offset) = lower[cursor..].find('<') {
|
||||
let tag_start = cursor + offset;
|
||||
@@ -4568,14 +4600,20 @@ fn executable_javascript_from_html(content: &str) -> String {
|
||||
else {
|
||||
break;
|
||||
};
|
||||
if html_script_executes_in_modern_browser(tag)
|
||||
&& !html_script_is_module(tag)
|
||||
&& !html_has_attribute(tag, "src")
|
||||
{
|
||||
let body = &content[body_start..close_start];
|
||||
if javascript_is_syntactically_valid(body, false) {
|
||||
executable.push_str(body);
|
||||
executable.push('\n');
|
||||
if html_script_executes_in_modern_browser(tag) && !html_script_is_module(tag) {
|
||||
if html_has_attribute(tag, "src") {
|
||||
if let Some(source) = html_attribute_value(tag, "src") {
|
||||
let value_offset = source.as_ptr() as usize - tag.as_ptr() as usize;
|
||||
let original_tag = &content[tag_start..=tag_end];
|
||||
executable.push(ExecutableClassicScriptSource::External(
|
||||
original_tag[value_offset..value_offset + source.len()].to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
let body = &content[body_start..close_start];
|
||||
if javascript_is_syntactically_valid(body, false) {
|
||||
executable.push(ExecutableClassicScriptSource::Inline(body.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor = close_end;
|
||||
@@ -7701,10 +7739,19 @@ pub(in crate::agent) fn rename_javascript_root_binding(
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub(in crate::agent) struct ExternalGameplayJavascript {
|
||||
classic_global: String,
|
||||
classic_ordered_unit: Option<String>,
|
||||
module_units: Vec<String>,
|
||||
}
|
||||
|
||||
impl ExternalGameplayJavascript {
|
||||
fn classic_unit(&self, html: &str) -> String {
|
||||
self.classic_ordered_unit.clone().unwrap_or_else(|| {
|
||||
let mut classic = executable_javascript_from_html(html);
|
||||
classic.push_str(&self.classic_global);
|
||||
classic
|
||||
})
|
||||
}
|
||||
|
||||
fn contains(&self, marker: &str) -> bool {
|
||||
self.classic_global
|
||||
.to_ascii_lowercase()
|
||||
@@ -7930,6 +7977,8 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at(
|
||||
let mut output = ExternalGameplayJavascript::default();
|
||||
let mut total_bytes = 0_u64;
|
||||
let mut pending = std::collections::VecDeque::new();
|
||||
let classic_script_sources = executable_classic_script_sources_from_html(html);
|
||||
let mut classic_external_contents = BTreeMap::<String, String>::new();
|
||||
let mut module_contents = std::collections::BTreeMap::<String, String>::new();
|
||||
let mut module_analyses = BTreeMap::<String, JavascriptModuleAnalysis>::new();
|
||||
let mut module_bindings =
|
||||
@@ -8059,8 +8108,7 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at(
|
||||
if is_module {
|
||||
module_contents.insert(local_path.clone(), script);
|
||||
} else {
|
||||
output.classic_global.push_str(&script);
|
||||
output.classic_global.push('\n');
|
||||
classic_external_contents.insert(local_path, script);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8286,6 +8334,25 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at(
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut classic_ordered_unit = String::new();
|
||||
for source in classic_script_sources {
|
||||
let script = match source {
|
||||
ExecutableClassicScriptSource::Inline(script) => script,
|
||||
ExecutableClassicScriptSource::External(source) => {
|
||||
let local_path = local_gameplay_script_path_from(None, &source)
|
||||
.ok_or_else(|| format!("自主构建外部脚本路径不受支持:{source}"))?;
|
||||
let Some(script) = classic_external_contents.get(&local_path) else {
|
||||
continue;
|
||||
};
|
||||
output.classic_global.push_str(script);
|
||||
output.classic_global.push('\n');
|
||||
script.clone()
|
||||
}
|
||||
};
|
||||
classic_ordered_unit.push_str(&script);
|
||||
classic_ordered_unit.push('\n');
|
||||
}
|
||||
output.classic_ordered_unit = Some(classic_ordered_unit);
|
||||
validate_javascript_module_links(&module_analyses)?;
|
||||
output
|
||||
.module_units
|
||||
@@ -8328,6 +8395,7 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at(
|
||||
}
|
||||
let mut unit = String::from_utf8(unit_bytes)
|
||||
.expect("masking parsed JavaScript imports preserves UTF-8");
|
||||
let mut unit_prefix = String::new();
|
||||
let mut unit_replacements = Vec::<(std::ops::Range<usize>, String)>::new();
|
||||
let mut added_projection = false;
|
||||
let mut origins = BTreeMap::<
|
||||
@@ -8496,9 +8564,10 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at(
|
||||
.ok_or_else(|| {
|
||||
"自主构建模块投影累计处理超过 32 MiB,已拒绝继续展开".to_string()
|
||||
})?;
|
||||
if unit
|
||||
if unit_prefix
|
||||
.len()
|
||||
.checked_add(1)
|
||||
.checked_add(unit.len())
|
||||
.and_then(|bytes| bytes.checked_add(1))
|
||||
.and_then(|bytes| bytes.checked_add(projection.len()))
|
||||
.is_none_or(|bytes| {
|
||||
u64::try_from(bytes).unwrap_or(u64::MAX)
|
||||
@@ -8674,8 +8743,8 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at(
|
||||
}
|
||||
}
|
||||
if !projection.trim().is_empty() {
|
||||
unit.push('\n');
|
||||
unit.push_str(&projection);
|
||||
unit_prefix.push_str(&projection);
|
||||
unit_prefix.push('\n');
|
||||
added_projection = true;
|
||||
}
|
||||
if !unit_replacements.is_empty() {
|
||||
@@ -8686,6 +8755,8 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at(
|
||||
if !apply_javascript_span_replacements(&mut unit, &mut unit_replacements) {
|
||||
return Err(format!("自主构建模块投影引用范围冲突:{importer}"));
|
||||
}
|
||||
unit_prefix.push_str(&unit);
|
||||
unit = unit_prefix;
|
||||
if u64::try_from(unit.len()).unwrap_or(u64::MAX)
|
||||
> MAX_EXTERNAL_GAMEPLAY_JAVASCRIPT_BYTES
|
||||
{
|
||||
@@ -9843,8 +9914,7 @@ fn tetris_executable_semantics_gap(
|
||||
content: &str,
|
||||
external_javascript: &ExternalGameplayJavascript,
|
||||
) -> Option<&'static str> {
|
||||
let mut classic_global = executable_javascript_from_html(content);
|
||||
classic_global.push_str(&external_javascript.classic_global);
|
||||
let mut classic_global = external_javascript.classic_unit(content);
|
||||
if !javascript_is_syntactically_valid(&classic_global, false) {
|
||||
classic_global.clear();
|
||||
}
|
||||
@@ -10691,6 +10761,94 @@ mod visible_destination_tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_asset_reachability_preserves_javascript_comment_markers_inside_strings() {
|
||||
let root = tempfile::tempdir().expect("create visual string fixture");
|
||||
let html = br#"<!doctype html><html><body>
|
||||
<!-- <script>const decoy=new Image();decoy.src='../assets/art-spec.png';context.drawImage(decoy,0,0,64,64);</script> -->
|
||||
<canvas width=320 height=180></canvas>
|
||||
<script>
|
||||
const endpoint = "https://example.invalid/assets/*/manifest";
|
||||
const marker = "/* not a comment */";
|
||||
const context = document.querySelector('canvas').getContext('2d');
|
||||
const art = new Image();
|
||||
art.src = '../assets/art-spec.png';
|
||||
function render() { context.drawImage(art, 0, 0, 64, 64); }
|
||||
render();
|
||||
</script>
|
||||
</body></html>"#;
|
||||
assert!(game_index_visibly_uses_visual_asset(
|
||||
root.path(),
|
||||
html,
|
||||
"assets/art-spec.png",
|
||||
(64, 64),
|
||||
VisualAssetUsageRequirement::CanvasDraw,
|
||||
));
|
||||
|
||||
let comment_only = br#"<!doctype html><html><body>
|
||||
<canvas width=320 height=180></canvas>
|
||||
<!-- <script>const context=document.querySelector('canvas').getContext('2d');const art=new Image();art.src='../assets/art-spec.png';context.drawImage(art,0,0,64,64);</script> -->
|
||||
</body></html>"#;
|
||||
assert!(!game_index_visibly_uses_visual_asset(
|
||||
root.path(),
|
||||
comment_only,
|
||||
"assets/art-spec.png",
|
||||
(64, 64),
|
||||
VisualAssetUsageRequirement::CanvasDraw,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_inline_and_external_scripts_follow_html_evaluation_order() {
|
||||
let root = tempfile::tempdir().expect("create classic script order fixture");
|
||||
fs::create_dir_all(root.path().join("game")).expect("create game directory");
|
||||
fs::write(
|
||||
root.path().join("game/render.js"),
|
||||
"const context=document.querySelector('canvas').getContext('2d');const art=new Image();art.src='../assets/art-spec.png';renderFrame=()=>context.drawImage(art,0,0,64,64);",
|
||||
)
|
||||
.expect("write classic external script");
|
||||
let html = br#"<!doctype html><html><body>
|
||||
<canvas width=320 height=180></canvas>
|
||||
<script>let renderFrame;</script>
|
||||
<script src="./render.js"></script>
|
||||
<script>renderFrame();</script>
|
||||
</body></html>"#;
|
||||
assert!(game_index_visibly_uses_visual_asset(
|
||||
root.path(),
|
||||
html,
|
||||
"assets/art-spec.png",
|
||||
(64, 64),
|
||||
VisualAssetUsageRequirement::CanvasDraw,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esm_dependency_initialization_precedes_importer_evaluation() {
|
||||
let root = tempfile::tempdir().expect("create ESM evaluation order fixture");
|
||||
fs::create_dir_all(root.path().join("game")).expect("create game directory");
|
||||
fs::write(
|
||||
root.path().join("game/render.mjs"),
|
||||
"const context=document.querySelector('canvas').getContext('2d');const art=new Image();art.src='../assets/art-spec.png';export let renderFrame;renderFrame=()=>context.drawImage(art,0,0,64,64);",
|
||||
)
|
||||
.expect("write ESM dependency");
|
||||
fs::write(
|
||||
root.path().join("game/main.mjs"),
|
||||
"import {renderFrame} from './render.mjs';renderFrame();",
|
||||
)
|
||||
.expect("write ESM importer");
|
||||
let html = br#"<!doctype html><html><body>
|
||||
<canvas width=320 height=180></canvas>
|
||||
<script type="module" src="./main.mjs"></script>
|
||||
</body></html>"#;
|
||||
assert!(game_index_visibly_uses_visual_asset(
|
||||
root.path(),
|
||||
html,
|
||||
"assets/art-spec.png",
|
||||
(64, 64),
|
||||
VisualAssetUsageRequirement::CanvasDraw,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_draw_destination_rejects_offscreen_and_unbounded_dynamic_coordinates() {
|
||||
let canvas = (320.0, 180.0);
|
||||
|
||||
@@ -5994,3 +5994,9 @@
|
||||
- JavaScript callable 分支与参数快照补充:conditional expression 必须在 test 求值完成后,分别于 consequent / alternate 自身起点冻结 callable identity;受控 callback 参数按该参数自身起点解析,前置参数产生的 alias 副作用先于后续 callback identity 生效,callback 的执行边仍保留在注册调用完成位置。
|
||||
- JavaScript / ESM live binding 投影补充:被选 export root 的直接顶层 assignment 及其 RHS 依赖必须与原声明共同投影,覆盖 `export let x; x = impl`、导出对象成员安装和 class prototype 安装;assignment target 以 semantic root symbol 归属,函数体写入、嵌套控制流和无关 root 写入不得因同名文本进入投影。共享 declaration 的写入按原始源码位置合并,继续参与 canonical 重命名、循环去重和既有 `2 MiB / 32 MiB` 门禁;全部依赖声明必须先于延后的初始化写入输出,不能因 projection traversal 产生 TDZ。
|
||||
- JavaScript callback 时序、内建覆盖与 class expression owner 补充:受控异步 callback 的注册位置只建立可达调用边,闭包读取的外层 alias 状态选取注册所在同步作用域收尾点,不能冻结在注册点;函数体写副作用仍不得同步提交到注册调用末尾。数组字面量上的已知迭代 callback 继续按同步执行传播外层 alias 变化。函数对象自有 `.call / .apply / .bind` assignment 按函数对象身份形成成员 callable 状态,普通函数别名共享同一对象覆盖,`.bind()` 结果保持独立对象身份;存在覆盖时禁止回退到 Function.prototype 语义。`new (class { ... })` 赋给局部变量时冻结 class expression 的 instance owner,使后续实例方法调用保持可达。
|
||||
|
||||
## 2026-08-04 静态视觉门脚本与 ESM 求值顺序
|
||||
|
||||
- HTML 输入只在非 raw-text 区域屏蔽真正的 `<!-- -->` 注释;`script / style` 等 raw-text 原文保持逐字不变,再交给各自 parser / semantic 处理。禁止在整份 HTML 上按文本删除 `//` 或 `/* */`,否则字符串中的 `https://`、路径和注释形状会被破坏;HTML 注释内的标签、脚本和素材路径仍不得形成视觉证据。
|
||||
- classic script 分析单元按 `game/index.html` 的真实标签顺序交错组合 inline 与本地 external 正文;带 `src` 标签的 inline body 继续忽略。外部文件仍执行可信普通文件、`game/` 边界、文件数与累计体积门禁,重复标签按浏览器出现次数保留求值位置。
|
||||
- ESM 组合单元按 dependency 初始化先于 importer 顶层求值排列。import reference 的 span replacement 仍基于原 importer 完成,随后把已闭包的 dependency projection 放在 importer 前并对最终单元重跑 parser、semantic、单元 `2 MiB` 与累计投影 `32 MiB` 门禁;循环模块继续按 `(origin module, original root binding)` canonical identity 去重并要求有界固定点收敛。
|
||||
|
||||
@@ -842,6 +842,8 @@ game-project/
|
||||
- tool-plan 成功响应落账前,对内置 Runtime 原生函数与 legacy wrapper 的合法、无重复 key JSON arguments 按工具 schema 的精确位置做项目路径 canonicalization:`file.*.path`、`project.patchset.changes[*].path`、`project.git_commit.paths[*]`、`command.*.cwd`、`image.inspect.paths[*]` 与 `canvas.asset_generate.outputPath` 若是当前项目根目录内的完整绝对路径,转换为 `/` 分隔的项目相对路径后再校验、持久化并执行;源码/叙述字段、任务产物描述、动态 MCP arguments 和项目外绝对路径不得改写,后两者继续由绝对路径门禁失败关闭。项目根只允许搜索/列举范围与命令 cwd 规范化为 `.`,不能成为文件目标。当前进程与重启恢复都必须从同一份规范化 handoff 重放,禁止分别执行原响应和持久响应。
|
||||
|
||||
- 循环 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 注释诱饵仍不得进入标签或脚本证据。classic inline 与 external 脚本按 HTML 标签出现顺序组成同一个 global 分析单元,带 `src` 的标签忽略 inline body,不能把所有 inline 提前到 external 之前。
|
||||
- ESM 投影的最终组合顺序必须反映 dependency 先初始化、importer 后求值:先在原 importer 坐标上完成 import reference span replacement,再把依赖闭包投影放到 importer 顶层正文之前,使 dependency 在声明后安装的 function-valued export 能被 importer 调用看见。最终单元继续重跑 parser / semantic 和单元 `2 MiB`、累计投影 `32 MiB` 门禁;canonical identity、循环回接去重与有界固定点规则不因顺序调整而放宽。
|
||||
|
||||
## 2026-07-31 长耗时与恢复收口
|
||||
|
||||
|
||||
Reference in New Issue
Block a user