mirror of
https://github.com/storytold/craft-anything.git
synced 2026-11-05 20:47:45 +01:00
Keep ARM32 conditional call values, esbuild dependencies and window labels right (#656)
Replays #640–#642. Conditional A32 calls keep the skipped path's value, esbuild dependencies survive unrelated calls, and window comparison labels stay distinct without renumbering occurrences. Co-authored-by: Afloat <132809511+Afloat16@users.noreply.github.com>
This commit is contained in:
@@ -235,16 +235,37 @@ pub fn compare_versions(l: &AppGraph, r: &AppGraph) -> Value {
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
};
|
||||
let (lw, rw) = (windows(l), windows(r));
|
||||
let keys: BTreeSet<_> = lw.keys().chain(rw.keys()).collect();
|
||||
let window_name = |(module, binding, occurrence): &(String, String, usize)| {
|
||||
if binding.is_empty() || *occurrence > 0 { format!("{module}:{binding}[{}]", occurrence.saturating_add(1)) } else { format!("{module}:{binding}") }
|
||||
};
|
||||
let reserved: BTreeSet<_> = keys.iter().map(|key| window_name(key)).collect();
|
||||
let mut assigned = BTreeSet::new();
|
||||
let mut labels = BTreeMap::new();
|
||||
let mut suffixes = BTreeMap::<String, usize>::new();
|
||||
let named = |key: &&(String, String, usize)| !key.1.is_empty() && key.2 == 0;
|
||||
// Reserve every natural label, including unchanged windows, and give literal names priority
|
||||
// over generated occurrence labels. A collision suffix never changes the occurrence index.
|
||||
for key in keys.iter().copied().filter(named).chain(keys.iter().copied().filter(|key| !named(key))) {
|
||||
let base = window_name(key);
|
||||
let mut label = base.clone();
|
||||
if !assigned.insert(label.clone()) {
|
||||
let suffix = suffixes.entry(base.clone()).or_default();
|
||||
loop {
|
||||
*suffix = suffix.saturating_add(1);
|
||||
label = format!("{base} #{suffix}");
|
||||
if !reserved.contains(&label) && assigned.insert(label.clone()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
labels.insert(key, label);
|
||||
}
|
||||
let window_changes: Vec<Value> = lw
|
||||
.iter()
|
||||
.filter_map(|(key, v)| {
|
||||
rw.get(key).filter(|w| *w != v).map(|w| {
|
||||
let (module, binding, occurrence) = key;
|
||||
let name = if binding.is_empty() || *occurrence > 0 {
|
||||
format!("{module}:{binding}[{}]", occurrence.saturating_add(1))
|
||||
} else {
|
||||
format!("{module}:{binding}")
|
||||
};
|
||||
let name = labels.get(key).cloned().unwrap_or_else(|| window_name(key));
|
||||
json!({"window": name, "from": v, "to": w})
|
||||
})
|
||||
})
|
||||
@@ -404,6 +425,41 @@ mod tests {
|
||||
assert_eq!(windows["changed"].as_array().unwrap().len(), 1, "the first preference change survives shifted lines");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_labels_preserve_literal_names_and_occurrence_indices() {
|
||||
let graph = |preferences: [bool; 4], prefix: &str| {
|
||||
let mut source = format!("{prefix}const {{ BrowserWindow }} = require('electron');\nconst obj = {{}};\n");
|
||||
for (binding, preference) in ["obj.x", "obj.x", r#"obj["x[2]"]"#, r#"obj["x[2] #1"]"#].into_iter().zip(preferences) {
|
||||
source.push_str(&format!("{binding} = new BrowserWindow({{ webPreferences: {{ backgroundThrottling: {preference} }} }});\n"));
|
||||
}
|
||||
graph_of(vec![("package.json", r#"{"name":"window-labels","main":"main.js"}"#.into()), ("main.js", source)])
|
||||
};
|
||||
let left = graph([false; 4], "");
|
||||
let right = graph([true; 4], "// Source lines may shift.\n\n");
|
||||
assert_eq!(left.windows.len(), 4);
|
||||
assert_eq!(left.windows[2]["binding"], "obj.x[2]", "the real parser retains the literal property name");
|
||||
let windows = compare_versions(&left, &right)["windows"].clone();
|
||||
assert_eq!((windows["left"].as_u64(), windows["right"].as_u64()), (Some(4), Some(4)));
|
||||
let changes = windows["changed"].as_array().unwrap();
|
||||
assert_eq!(changes.len(), 4);
|
||||
let labels: Vec<_> = changes.iter().map(|change| change["window"].as_str().unwrap()).collect();
|
||||
assert_eq!(labels.iter().copied().collect::<BTreeSet<_>>().len(), 4, "every construction has a distinct label: {labels:?}");
|
||||
assert_eq!(labels[0], "main.js:obj.x");
|
||||
assert!(labels[1].starts_with("main.js:obj.x[2] #"), "the second occurrence retains [2]: {labels:?}");
|
||||
assert_eq!(labels[2], "main.js:obj.x[2]", "a literal binding keeps its original label");
|
||||
assert_eq!(labels[3], "main.js:obj.x[2] #1", "a literal collision suffix is reserved too");
|
||||
for (index, label) in labels.iter().enumerate() {
|
||||
let mut preferences = [false; 4];
|
||||
preferences[index] = true;
|
||||
let one = compare_versions(&left, &graph(preferences, ""))["windows"].clone();
|
||||
let changed = one["changed"].as_array().unwrap();
|
||||
assert_eq!(changed.len(), 1);
|
||||
assert_eq!(changed[0]["window"].as_str(), Some(*label), "unchanged constructions still reserve their labels");
|
||||
assert_eq!(changed[0]["from"]["backgroundThrottling"], false);
|
||||
assert_eq!(changed[0]["to"]["backgroundThrottling"], true);
|
||||
}
|
||||
}
|
||||
|
||||
/// #236: an edited inline script had no hash on either side and counted as unchanged.
|
||||
#[test]
|
||||
fn an_edited_inline_script_is_changed() {
|
||||
|
||||
@@ -155,15 +155,29 @@ pub fn def_use(img: &Image, prog: &Program, f: &Function, max_nodes: usize, max_
|
||||
for (r, id) in ¶m_node {
|
||||
entry_defs.insert(r.clone(), BTreeSet::from([*id]));
|
||||
}
|
||||
let noreturn_calls: BTreeSet<u64> = f.calls.iter().filter(|call| prog.call_is_noreturn(call)).map(|call| call.site).collect();
|
||||
let record_writes = |cur: &mut Defs, i: &Insn, id: usize| {
|
||||
let conditional_call = i.cond.is_some() && matches!(i.flow, Flow::Call { .. } | Flow::IndirectCall { .. });
|
||||
if conditional_call && noreturn_calls.contains(&i.addr) {
|
||||
// Only the skipped path continues; a non-returning call supplies no later value.
|
||||
return;
|
||||
}
|
||||
for w in &i.writes {
|
||||
if !ignored(w) {
|
||||
let definitions = cur.entry(w.clone()).or_default();
|
||||
// A skipped call keeps the earlier value; an unconditional write replaces it.
|
||||
if !conditional_call {
|
||||
definitions.clear();
|
||||
}
|
||||
definitions.insert(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
let transfer = |inp: &Defs, insns: &[Insn]| -> Defs {
|
||||
let mut cur = inp.clone();
|
||||
for i in insns {
|
||||
if let Some(&id) = node_of.get(&i.addr) {
|
||||
for w in &i.writes {
|
||||
if !ignored(w) {
|
||||
cur.insert(w.clone(), BTreeSet::from([id]));
|
||||
}
|
||||
}
|
||||
record_writes(&mut cur, i, id);
|
||||
}
|
||||
}
|
||||
cur
|
||||
@@ -240,11 +254,7 @@ pub fn def_use(img: &Image, prog: &Program, f: &Function, max_nodes: usize, max_
|
||||
.collect();
|
||||
out.effects.push(Effect { addr: i.addr, kind: "call", text: name, inputs });
|
||||
}
|
||||
for w in &i.writes {
|
||||
if !ignored(w) {
|
||||
cur.insert(w.clone(), BTreeSet::from([me]));
|
||||
}
|
||||
}
|
||||
record_writes(&mut cur, i, me);
|
||||
}
|
||||
}
|
||||
out.parameters = args.iter().filter(|a| params_used.contains(**a)).map(|a| (*a).to_string()).collect();
|
||||
|
||||
@@ -987,7 +987,8 @@ fn decode_a32(mode: Mode, bytes: &[u8], addr: u64, fmt: bool) -> Option<Insn> {
|
||||
}
|
||||
}
|
||||
let kind = if exact { kind } else { Kind::Other };
|
||||
let cond = if matches!(flow, Flow::Cond { .. }) {
|
||||
let conditional_call = conditional && matches!(flow, Flow::Call { .. } | Flow::IndirectCall { .. });
|
||||
let cond = if conditional_call || matches!(flow, Flow::Cond { .. }) {
|
||||
Some(match inst.condition {
|
||||
C::EQ => Cond::Eq,
|
||||
C::NE => Cond::Ne,
|
||||
|
||||
@@ -200,6 +200,7 @@ pub struct Program {
|
||||
/// block start → (end, function entry)
|
||||
block_index: BTreeMap<u64, (u64, u64)>,
|
||||
name_index: BTreeMap<String, u64>,
|
||||
symbol_noreturn: BTreeSet<u64>,
|
||||
}
|
||||
|
||||
const NORETURN: &[&str] = &[
|
||||
@@ -527,7 +528,20 @@ impl Program {
|
||||
"functions reached only through computed pointers or at run time may be missing; absence is not proof".into(),
|
||||
));
|
||||
ctl.progress(1.0, "done");
|
||||
Ok(Program { mode, functions, strings, xrefs_to, xrefs_from, import_slots: slots, data_names, stats, limitations, block_index, name_index })
|
||||
Ok(Program {
|
||||
mode,
|
||||
functions,
|
||||
strings,
|
||||
xrefs_to,
|
||||
xrefs_from,
|
||||
import_slots: slots,
|
||||
data_names,
|
||||
stats,
|
||||
limitations,
|
||||
block_index,
|
||||
name_index,
|
||||
symbol_noreturn: sym_noreturn,
|
||||
})
|
||||
}
|
||||
|
||||
/// True when function discovery stopped at the function or instruction cap, so the function
|
||||
@@ -536,6 +550,11 @@ impl Program {
|
||||
self.limitations.iter().any(|(c, _)| c == "function_limit" || c == "instruction_limit")
|
||||
}
|
||||
|
||||
/// The same known targets used to stop function scanning, independent of call conditions.
|
||||
pub(crate) fn call_is_noreturn(&self, call: &CallSite) -> bool {
|
||||
call.import.as_deref().is_some_and(is_noreturn) || call.target.is_some_and(|target| self.symbol_noreturn.contains(&target))
|
||||
}
|
||||
|
||||
/// The function whose blocks contain `addr` (or whose range does).
|
||||
pub fn function_containing(&self, addr: u64) -> Option<&Function> {
|
||||
if let Some((_, &(end, f))) = self.block_index.range(..=addr).next_back()
|
||||
@@ -857,7 +876,7 @@ fn scan_function(ctx: &Ctx<'_>, entry: u64, known: &BTreeSet<u64>) -> Scan {
|
||||
let import = ctx.stubs.get(&target).cloned();
|
||||
let noreturn = import.as_deref().is_some_and(is_noreturn) || ctx.sym_noreturn.contains(&target);
|
||||
sc.calls.push(CallSite { site: a, target: Some(target), import, indirect: false });
|
||||
if noreturn {
|
||||
if noreturn && ins.cond.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -868,7 +887,7 @@ fn scan_function(ctx: &Ctx<'_>, entry: u64, known: &BTreeSet<u64>) -> Scan {
|
||||
}
|
||||
let noreturn = import.as_deref().is_some_and(is_noreturn);
|
||||
sc.calls.push(CallSite { site: a, target: None, import, indirect: true });
|
||||
if noreturn {
|
||||
if noreturn && ins.cond.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
use craft_anything_disasm::dataflow::{DefUse, def_use};
|
||||
use craft_anything_disasm::{Cond, Flow, Image, Limits, Mode, NoControl, Program, decode};
|
||||
use craft_anything_formats::{Arch, Binary, Symbol, SymbolKind};
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn regression_a32_calls_keep_conditions_without_changing_call_flow() {
|
||||
let cases = [
|
||||
(0x1b00_0004u32, Some(Cond::Ne), Flow::Call { target: 0x1020 }),
|
||||
(0x0b00_0004, Some(Cond::Eq), Flow::Call { target: 0x1020 }),
|
||||
(0xeb00_0004, None, Flow::Call { target: 0x1020 }),
|
||||
(0x112f_ff33, Some(Cond::Ne), Flow::IndirectCall { slot: None }),
|
||||
(0x012f_ff33, Some(Cond::Eq), Flow::IndirectCall { slot: None }),
|
||||
(0xe12f_ff33, None, Flow::IndirectCall { slot: None }),
|
||||
// The unconditional BLX immediate encoding uses the otherwise reserved condition bits.
|
||||
(0xfaff_fffe, None, Flow::Call { target: 0x1008 }),
|
||||
];
|
||||
for (word, condition, flow) in cases {
|
||||
for formatted in [false, true] {
|
||||
let insn = decode(Mode::A32, &word.to_le_bytes(), 0x1008, formatted).unwrap();
|
||||
assert_eq!(insn.flow, flow, "{word:#010x}, formatted={formatted}");
|
||||
assert_eq!(insn.cond, condition, "{word:#010x}, formatted={formatted}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn program_after_call(call: u32, split_block: bool, noreturn: bool) -> (Image, Program) {
|
||||
let words = [
|
||||
0xe92d_4010, // push {r4, lr}: preserve the caller's return address
|
||||
0xe3a0_0007, // mov r0, #7
|
||||
call,
|
||||
// A branch to the next instruction makes reaching definitions cross a block boundary.
|
||||
if split_block { 0xeaff_ffff } else { 0xe1a0_4004 }, // b 0x1010 / mov r4, r4
|
||||
0xe8bd_8010, // pop {r4, pc}
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
if noreturn { 0xe120_0070 } else { 0xe12f_ff1e }, // bkpt / bx lr at the direct-call target
|
||||
];
|
||||
let bytes: Vec<u8> = words.into_iter().flat_map(u32::to_le_bytes).collect();
|
||||
let mut bin = Binary::raw(Arch::Arm, 0x1000, bytes.len() as u64, 0);
|
||||
if noreturn {
|
||||
bin.symbols.push(Symbol {
|
||||
name: "abort".into(),
|
||||
demangled: None,
|
||||
addr: 0x1020,
|
||||
size: 4,
|
||||
kind: SymbolKind::Function,
|
||||
global: true,
|
||||
weak: false,
|
||||
defined: true,
|
||||
dynamic: false,
|
||||
});
|
||||
}
|
||||
let img = Image::from_binary(Arc::from(bytes), bin);
|
||||
let prog = Program::analyze(&img, &Limits::default(), &NoControl).unwrap();
|
||||
(img, prog)
|
||||
}
|
||||
|
||||
fn flow_after_call(call: u32, split_block: bool) -> DefUse {
|
||||
let (img, prog) = program_after_call(call, split_block, false);
|
||||
let function = prog.functions.get(&0x1000).unwrap();
|
||||
assert!(function.returns && !function.truncated);
|
||||
assert_eq!(function.calls.len(), 1);
|
||||
assert_eq!(function.blocks.len(), if split_block { 2 } else { 1 });
|
||||
let flow = def_use(&img, &prog, function, 5000, 10000);
|
||||
assert!(!flow.truncated);
|
||||
flow
|
||||
}
|
||||
|
||||
fn definitions_reaching_return(flow: &DefUse) -> BTreeSet<u64> {
|
||||
let ret = flow.nodes.iter().find(|node| node.addr == Some(0x1010)).unwrap();
|
||||
flow.edges.iter().filter(|edge| edge.to == ret.id && edge.register == "r0").map(|edge| flow.nodes.get(edge.from).unwrap().addr.unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regression_a32_conditional_calls_keep_the_skipped_result() {
|
||||
// Both BLNE and BLXNE r3 can leave the preceding r0 definition intact.
|
||||
for call in [0x1b00_0004, 0x112f_ff33] {
|
||||
for split_block in [false, true] {
|
||||
let flow = flow_after_call(call, split_block);
|
||||
assert_eq!(definitions_reaching_return(&flow), BTreeSet::from([0x1004, 0x1008]), "call={call:#010x}, split_block={split_block}: {flow:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regression_a32_unconditional_calls_replace_the_previous_result() {
|
||||
for call in [0xeb00_0004, 0xe12f_ff33] {
|
||||
for split_block in [false, true] {
|
||||
let flow = flow_after_call(call, split_block);
|
||||
assert_eq!(definitions_reaching_return(&flow), BTreeSet::from([0x1008]), "call={call:#010x}, split_block={split_block}: {flow:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regression_a32_conditional_noreturn_calls_keep_only_the_skipped_result() {
|
||||
for call in [0x1b00_0004, 0x0b00_0004] {
|
||||
for split_block in [false, true] {
|
||||
let (img, prog) = program_after_call(call, split_block, true);
|
||||
assert!(prog.function_by_name("abort").is_some());
|
||||
let function = prog.functions.get(&0x1000).unwrap();
|
||||
assert_eq!(function.calls.len(), 1);
|
||||
assert!(function.returns && function.contains(0x1010), "a skipped abort must reach the return: {function:?}");
|
||||
let flow = def_use(&img, &prog, function, 5000, 10000);
|
||||
assert!(!flow.truncated);
|
||||
assert_eq!(definitions_reaching_return(&flow), BTreeSet::from([0x1004]), "abort cannot supply a returned value: {flow:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regression_a32_unconditional_noreturn_calls_still_stop() {
|
||||
for split_block in [false, true] {
|
||||
let (img, prog) = program_after_call(0xeb00_0004, split_block, true);
|
||||
let function = prog.functions.get(&0x1000).unwrap();
|
||||
assert_eq!(function.calls.len(), 1);
|
||||
assert!(!function.returns && !function.contains(0x1010), "an unconditional abort must stop: {function:?}");
|
||||
let flow = def_use(&img, &prog, function, 5000, 10000);
|
||||
assert!(!flow.truncated);
|
||||
assert!(flow.nodes.iter().all(|node| node.addr != Some(0x1010)));
|
||||
}
|
||||
}
|
||||
+78
-23
@@ -23,8 +23,6 @@ pub(crate) struct BundleVisitor<'s> {
|
||||
pub info: BundleInfo,
|
||||
/// esbuild wrapper binding → module id, for dependency resolution.
|
||||
esbuild_vars: HashMap<Key, String>,
|
||||
/// Raw esbuild deps (callee bindings) per module index.
|
||||
esbuild_raw: Vec<(usize, Vec<Key>)>,
|
||||
}
|
||||
|
||||
/// A function-ish table value.
|
||||
@@ -80,6 +78,7 @@ impl Func<'_, '_> {
|
||||
struct DepVisitor<'n> {
|
||||
require_name: Option<&'n str>,
|
||||
scoping: Option<&'n Scoping>,
|
||||
esbuild_vars: Option<&'n HashMap<Key, String>>,
|
||||
deps: Vec<String>,
|
||||
esbuild: Vec<Key>,
|
||||
esbuild_seen: HashSet<Key>,
|
||||
@@ -101,7 +100,10 @@ impl<'a> Visit<'a> for DepVisitor<'_> {
|
||||
{
|
||||
self.deps.push(d);
|
||||
}
|
||||
} else if it.arguments.is_empty() && self.esbuild.len() < 4096 {
|
||||
} else if it.arguments.is_empty()
|
||||
&& self.esbuild.len() < 4096
|
||||
&& let Some(wrappers) = self.esbuild_vars
|
||||
{
|
||||
// Minifiers rename the wrapper. Resolve by binding, not by its spelling, so a
|
||||
// short name also works without confusing a local parameter with the wrapper.
|
||||
let key = match self.scoping {
|
||||
@@ -109,6 +111,7 @@ impl<'a> Visit<'a> for DepVisitor<'_> {
|
||||
None => Some(Key::Name(n.to_string())),
|
||||
};
|
||||
if let Some(key) = key
|
||||
&& wrappers.contains_key(&key)
|
||||
&& self.esbuild_seen.insert(key.clone())
|
||||
{
|
||||
self.esbuild.push(key);
|
||||
@@ -119,22 +122,68 @@ impl<'a> Visit<'a> for DepVisitor<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Revisit only discovered module bodies once all wrapper bindings, including forward
|
||||
/// declarations, are known. Unrelated calls never occupy the bounded dependency buffer.
|
||||
struct EsbuildDeps<'s> {
|
||||
scoping: Option<&'s Scoping>,
|
||||
wrappers: &'s HashMap<Key, String>,
|
||||
modules: &'s mut [BundleModule],
|
||||
module_indices: HashMap<(u32, u32), usize>,
|
||||
}
|
||||
|
||||
impl EsbuildDeps<'_> {
|
||||
fn collect(&mut self, f: &Func<'_, '_>) -> bool {
|
||||
let span = f.span();
|
||||
let Some(&idx) = self.module_indices.get(&(span.start, span.end)) else { return false };
|
||||
let mut dv = DepVisitor {
|
||||
require_name: None,
|
||||
scoping: self.scoping,
|
||||
esbuild_vars: Some(self.wrappers),
|
||||
deps: Vec::new(),
|
||||
esbuild: Vec::new(),
|
||||
esbuild_seen: HashSet::new(),
|
||||
};
|
||||
match f {
|
||||
Func::Function(func) => dv.visit_function(func, ScopeFlags::Function),
|
||||
Func::Arrow(a) => dv.visit_arrow_function_expression(a),
|
||||
}
|
||||
if let Some(module) = self.modules.get_mut(idx) {
|
||||
for key in dv.esbuild {
|
||||
if let Some(dep) = self.wrappers.get(&key)
|
||||
&& !module.deps.contains(dep)
|
||||
{
|
||||
module.deps.push(dep.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Visit<'a> for EsbuildDeps<'_> {
|
||||
fn visit_function(&mut self, it: &Function<'a>, flags: ScopeFlags) {
|
||||
if !self.collect(&Func::Function(it)) {
|
||||
walk::walk_function(self, it, flags);
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_arrow_function_expression(&mut self, it: &ArrowFunctionExpression<'a>) {
|
||||
if !self.collect(&Func::Arrow(it)) {
|
||||
walk::walk_arrow_function_expression(self, it);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'s> BundleVisitor<'s> {
|
||||
pub fn new(src: &'s str, lines: &'s LineIndex, webpack_hint: bool, scoping: Option<&'s Scoping>) -> Self {
|
||||
BundleVisitor { src, lines, scoping, webpack_hint, info: BundleInfo::default(), esbuild_vars: HashMap::new(), esbuild_raw: Vec::new() }
|
||||
BundleVisitor { src, lines, scoping, webpack_hint, info: BundleInfo::default(), esbuild_vars: HashMap::new() }
|
||||
}
|
||||
|
||||
/// Resolve esbuild wrapper calls to module ids once every wrapper is known.
|
||||
pub fn finish(mut self) -> BundleInfo {
|
||||
for (idx, raw) in std::mem::take(&mut self.esbuild_raw) {
|
||||
let deps: Vec<String> = raw.iter().filter_map(|v| self.esbuild_vars.get(v).cloned()).collect();
|
||||
if let Some(m) = self.info.modules.get_mut(idx) {
|
||||
for d in deps {
|
||||
if !m.deps.contains(&d) {
|
||||
m.deps.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn finish(mut self, program: &Program<'_>) -> BundleInfo {
|
||||
if !self.esbuild_vars.is_empty() {
|
||||
let module_indices = self.info.modules.iter().enumerate().map(|(idx, m)| ((m.start, m.end), idx)).collect();
|
||||
EsbuildDeps { scoping: self.scoping, wrappers: &self.esbuild_vars, modules: &mut self.info.modules, module_indices }.visit_program(program);
|
||||
}
|
||||
self.info
|
||||
}
|
||||
@@ -151,16 +200,26 @@ impl<'s> BundleVisitor<'s> {
|
||||
"browserify" => params.first().cloned(),
|
||||
_ => None,
|
||||
};
|
||||
let mut dv =
|
||||
DepVisitor { require_name: require_name.as_deref(), scoping: self.scoping, deps: Vec::new(), esbuild: Vec::new(), esbuild_seen: HashSet::new() };
|
||||
let mut dv = DepVisitor {
|
||||
require_name: require_name.as_deref(),
|
||||
scoping: self.scoping,
|
||||
esbuild_vars: None,
|
||||
deps: Vec::new(),
|
||||
esbuild: Vec::new(),
|
||||
esbuild_seen: HashSet::new(),
|
||||
};
|
||||
let mut fp = Fingerprint::new();
|
||||
match f {
|
||||
Func::Function(func) => {
|
||||
dv.visit_function(func, ScopeFlags::Function);
|
||||
if require_name.is_some() {
|
||||
dv.visit_function(func, ScopeFlags::Function);
|
||||
}
|
||||
fp.visit_function(func, ScopeFlags::Function);
|
||||
}
|
||||
Func::Arrow(a) => {
|
||||
dv.visit_arrow_function_expression(a);
|
||||
if require_name.is_some() {
|
||||
dv.visit_arrow_function_expression(a);
|
||||
}
|
||||
fp.visit_arrow_function_expression(a);
|
||||
}
|
||||
}
|
||||
@@ -178,11 +237,7 @@ impl<'s> BundleVisitor<'s> {
|
||||
sha256: sha256_hex(text.as_bytes()),
|
||||
fingerprint: fp.finish(),
|
||||
});
|
||||
let idx = self.info.modules.len() - 1;
|
||||
if !dv.esbuild.is_empty() {
|
||||
self.esbuild_raw.push((idx, dv.esbuild));
|
||||
}
|
||||
Some(idx)
|
||||
Some(self.info.modules.len() - 1)
|
||||
}
|
||||
|
||||
fn key_id(&self, key: &PropertyKey<'_>) -> Option<String> {
|
||||
|
||||
@@ -228,7 +228,7 @@ fn parse_and_visit(src: &str, st: SourceType) -> Option<Analysis> {
|
||||
let webpack_hint = facts.bundler_markers.keys().any(|k| k.contains("webpack"));
|
||||
let mut bv = bundle::BundleVisitor::new(src, &lines, webpack_hint, scoping);
|
||||
bv.visit_program(&ret.program);
|
||||
let bundle = bv.finish();
|
||||
let bundle = bv.finish(&ret.program);
|
||||
Some(Analysis { facts, bundle })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
use craft_anything_js::{Limits, ParseMode, analyze};
|
||||
use std::fmt::Write;
|
||||
|
||||
#[test]
|
||||
fn ordinary_calls_do_not_exhaust_esbuild_dependency_budget() {
|
||||
for (params, format) in [("exports, module", "esbuild-cjs"), ("", "esbuild-esm")] {
|
||||
let mut source = format!("var a = h({{\"src/a.js\"({params}) {{\n");
|
||||
for i in 0..4096 {
|
||||
writeln!(source, "function local{i}() {{}} local{i}();").unwrap();
|
||||
}
|
||||
// The dependency is declared later, so collecting only already-seen wrappers is insufficient.
|
||||
source.push_str("b(); b(); }});\nvar b = h({\"src/b.js\"() { globalThis.ready = true; }});\n");
|
||||
|
||||
let analysis = analyze("bundle.js", &source, &Limits::default());
|
||||
assert_eq!(analysis.facts.mode, ParseMode::Parsed);
|
||||
assert_eq!(analysis.facts.parse_errors, 0);
|
||||
assert_eq!(analysis.bundle.modules.len(), 2);
|
||||
let module = analysis.bundle.modules.iter().find(|module| module.id == "src/a.js").unwrap();
|
||||
assert_eq!(module.format, format);
|
||||
assert_eq!(module.deps, ["src/b.js"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esbuild_dependency_limit_keeps_first_distinct_edges() {
|
||||
let mut source = String::from("var main = h({\"src/main.js\"() {\n");
|
||||
for i in (0..4097).rev() {
|
||||
writeln!(source, "m{i}(); m{i}();").unwrap();
|
||||
}
|
||||
source.push_str("}});\n");
|
||||
for i in 0..4097 {
|
||||
writeln!(source, "var m{i} = h({{\"src/{i}.js\"() {{}}}});").unwrap();
|
||||
}
|
||||
|
||||
let analysis = analyze("bundle.js", &source, &Limits::default());
|
||||
assert_eq!(analysis.facts.mode, ParseMode::Parsed);
|
||||
assert_eq!(analysis.facts.parse_errors, 0);
|
||||
assert_eq!(analysis.bundle.modules.len(), 4098);
|
||||
assert!(!analysis.bundle.truncated);
|
||||
let module = analysis.bundle.modules.iter().find(|module| module.id == "src/main.js").unwrap();
|
||||
let expected: Vec<_> = (1..4097).rev().map(|i| format!("src/{i}.js")).collect();
|
||||
assert_eq!(module.deps, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_module_bodies_keep_forward_wrapper_dependencies() {
|
||||
let source = r#"
|
||||
var a = h({"src/a.js": (exports, module) => { b(); b(); }});
|
||||
var b = h({"src/b.js": () => { globalThis.ready = true; }});
|
||||
"#;
|
||||
let analysis = analyze("bundle.js", source, &Limits::default());
|
||||
assert_eq!(analysis.facts.mode, ParseMode::Parsed);
|
||||
assert_eq!(analysis.facts.parse_errors, 0);
|
||||
assert_eq!(analysis.bundle.modules.len(), 2);
|
||||
let module = analysis.bundle.modules.iter().find(|module| module.id == "src/a.js").unwrap();
|
||||
assert_eq!(module.format, "esbuild-cjs");
|
||||
assert_eq!(module.deps, ["src/b.js"]);
|
||||
assert_eq!(analysis.bundle.modules.iter().find(|module| module.id == "src/b.js").unwrap().format, "esbuild-esm");
|
||||
}
|
||||
+3
-3
@@ -19,12 +19,12 @@ workspace clippy, layers, fmt and the secret scan of all history were clean. Chi
|
||||
| Tool argument correction | Shared parameter parsing keeps schema types strict and reports the rejected value's JSON Pointer when known, the original type error and guidance to correct the call. Unknown locations are null; a root failure uses the empty pointer | Parser regressions cover string/array mismatches, nested indices, escaped keys, oversized paths, enum locations, defaults and strict fields. Shared engine dispatch is checked for Agent, MCP and CLI callers: rejected arrays create no record, and corrected input creates one. No live Qwen or Ollama build was run for this correction |
|
||||
| Java class usage | `java_class_users_find` includes the component class of multidimensional object arrays; primitive-array descriptors do not match an unrelated class with the same descriptor letter | Synthetic class files parsed by the built-in reader: one- and two-dimensional allocations, class names beginning with `L`, primitive arrays, hit limits and method filters; formats tests. This correction was not checked with javac output or a JVM |
|
||||
| Single-file apps and game files | `core_packed_files_list` and `core_packed_files_unpack` read Bun and Node.js single-file programs, .NET single-file apps, PyInstaller and Godot packs through one seam (`formats::packed`); `formats_bytecode_show` reads Python bytecode, `game_unity_code_names_show` Unity IL2CPP metadata, `formats_ai_weights_show` GGUF and safetensors headers. `app_folder_explain` says what an install folder is made with and what to run next | Unit tests with damaged files. Real files: a 279 MB Bun program, Node blobs, PyInstaller programs (Python 3.9, 3.14), .NET 8 single-file apps (macOS, Linux, Windows, compressed), Godot 3.6, 4.3 and 4.5 packs, 214 Python modules against CPython's own reader, a GGUF and a safetensors file, about 25 installed Mac apps. IL2CPP on synthetic files only |
|
||||
| esbuild module dependencies | Recognised CommonJS and ESM wrappers keep their dependency edges after identifier minification; semantic bindings distinguish shadowed and reused names, and repeated calls produce one edge | 44 JS unit tests pass. New synthetic regressions cover short names, forward declarations, both wrapper kinds, shadowed parameters, reused names, unrelated calls and duplicates. No real minified application was run for this correction; sources above the existing semantic-analysis size limit retain name-based resolution |
|
||||
| esbuild module dependencies | Recognised CommonJS and ESM wrappers keep their dependency edges after identifier minification; semantic bindings distinguish shadowed and reused names, and repeated calls produce one edge. Ordinary calls do not consume the 4096-wrapper dependency budget, including when the wrapper is declared later | 50 JS tests pass. Synthetic regressions cover short names, forward declarations, both wrapper kinds and function/arrow bodies, shadowed parameters, reused names, unrelated calls and duplicates. Public analysis fixtures include 4096 distinct ordinary calls before a wrapper dependency and 4097 real wrapper dependencies to check the cap and first-seen order. No real minified application was run for this correction; sources above the existing semantic-analysis size limit retain name-based resolution |
|
||||
| Program facts across builds | `native_library_calls_list` (delay-load too), `native_offered_symbols_list`, `native_publisher_show` (checks the file hash and the signature, not Windows trust), `app_builds_line_up` pairs functions across two builds and says how, `native_source_folder_write` writes pseudo-C to a folder and picks up where it stopped, `android_components_list` | Unit tests. Real PE, ELF and Mach-O files; two builds of a 276 MB program; a signed Microsoft DLL; Android components from one real APK, spot-checked |
|
||||
| ARM32 value flow | A32 and Thumb calls define ARM scratch registers, and word results flow through `r0` into returns in `native_value_flow_show` | Synthetic instruction fixtures for leaf results and indirect calls in both modes, with AArch64 and x86-64 controls; disassembler tests. This correction was not checked on real ARM binaries or devices |
|
||||
| ARM32 value flow | A32 and Thumb calls define ARM scratch registers, and word results flow through `r0` into returns in `native_value_flow_show`. Conditional A32 calls retain the earlier value when skipped and a possible returned result; calls to known non-returning targets keep only the skipped path | Synthetic instruction fixtures for leaf results and indirect calls in both modes, with AArch64 and x86-64 controls; direct and indirect A32 conditional calls within and across basic blocks, including a named `abort` target and unconditional controls. This correction was not checked on real ARM binaries or devices |
|
||||
| Stand-in backends | `web_standin_build` turns a HAR into `standin.json` (secrets dropped, varying path parts templated); `web_standin_start` answers from it on loopback; `web_standin_show` lists what it had no answer for. A 304 without a usable cached body is counted at build time and skipped during replay; if its matching group has no usable answer, requests get a 404 with `cached_body_unavailable`, also shown in the request log. Writes replay what was recorded and don't change later reads. App maps list GraphQL and tRPC operations; `web_walkthrough_check` checks a scripted session without a browser | Unit tests, real loopback requests, and a run with curl on a hand-written HAR. The 304 correction passes 99 analysis tests and a real-loopback regression, covering mixed samples, omitted bodies, matching boundaries and existing v1 files. [Hosted CI for #543](https://github.com/storytold/craft-anything/actions/runs/41585168291) also passed the full engine tests and workspace gates. No browser-made recording or real front end yet |
|
||||
| Websites as targets | `web_site_open` keeps a private browser for the session and every browser tool inspects that page; `web_site_capture` records screenshots at each width (desktop, tablet and phone), rendered HTML, design tokens (colours in hex, spacing, content widths), the CSS breakpoints, the layout at each width (page width, heading sizes, each section's padding, gap, content column and items per row), page health (script and console errors, failed requests) and an accessibility outline, layout outline, assets with font files and requests, links and form outlines. In Clean room the capture keeps only what a visitor sees (no HTML, CSS rules, scripts, requests or errors), and opening a site reports no load errors | Live run on example.com; unit tests |
|
||||
| Electron window comparisons | `app_graphs_compare` retains every unassigned window and every construction with a reused local binding. Windows are paired by module, binding and occurrence, so inserting comments does not hide preference changes | Synthetic application graphs exercise changes in either anonymous window, unique and reused bindings, shifted source lines and unequal counts. Pairing remains positional within each binding group; reordered constructions and real application releases were not checked for this correction |
|
||||
| Electron window comparisons | `app_graphs_compare` retains every unassigned window and every construction with a reused local binding. Windows are paired by module, binding and occurrence, so inserting comments does not hide preference changes. Colliding display labels receive a separate `#N` suffix without changing literal binding names or occurrence indices | Synthetic applications parsed through the ordinary analyzer exercise anonymous and reused bindings, shifted source lines, unequal counts, literal names resembling occurrence or collision suffixes, and stable labels when only one window changes. Pairing remains positional within each binding group; reordered constructions and real application releases were not checked for this correction |
|
||||
| Rebuild workflow (Study → Build → Check → Refine) | `crates/ai/src/rebuild/` (kinds, checks and stacks are pluggable: docs/extending.md). Three kinds: website, app, game (a project whose stack is a game engine is always a game). The request decides how to build: a language, framework, engine or platform it names wins over the kind's default stack (Rust + egui for apps, Unity for games, the original's for websites), and the build check, the recorded stack and Play follow what was built (an Electron app opens in its own Electron; a plain static site has nothing to build and passes the build check as it is). Websites: Study also captures up to 4 other pages of the site; menus are opened on a computer and on a phone; the check serves the build on loopback and compares every page at 1440, 768 and 390 px (likeness = mean of layout and perceptual detail, SSIM, averaged over pages, leaving out the parts of the original that change by themselves, found by photographing it three times; the note adds text, page structure, the colours, font, text sizes and spacing the build misses, and per width a page that scrolls sideways, heading sizes and sections whose padding, items per row or content column differ, the errors, failed requests and accessibility gaps the build adds, and the opened menus and pop-ups). A stack or library named in the request (Next.js, shadcn/ui, Tailwind CSS…, `stacks.toml`) is what the builder uses; with none named, a website's stack is the builder's choice (told to match what the original is built with) and the stack it used is recorded from app/package.json. The builder leads: it studies, asks the tools follow-up questions, records what it can't find out as unknowns (`core_unknown_add`; the run reports the open ones), and tests its own build mid-turn with `rebuild_check` (the same checks, for every kind); a check with anything it couldn't compare, or any claim that fails (a website page that scrolls sideways, logs new errors, fails to load a file, lacks a landmark, or a menu that doesn't open; for games each self-test step, for Deep-mode sheets each original number in the code, for console apps the same exit and output as the original's runs), isn't good enough; apps and games by whether they build. Up to 6 refinement rounds, or until a website reaches 90% likeness. A turn cut off by a time limit, crash or dropped connection resumes (up to 3 times); builder turns may run 24 h. A project copied from another computer (or moved to another folder) keeps building with Claude Code: the project keeps a copy of the builder's session (`agent-session.jsonl`) after every turn and puts it back where Claude Code finds it on a computer without it, so the same conversation carries on (#451); when the session still isn't there, a new one starts from the brief, what's in app/ and the kept requests and replies (or the last summary), and questions about the build put the kept session back too, else are answered from the files and forget the missing session (#452). Live runs with Claude Code 2.1.316 on one Mac (2026-11-02), a second computer stood in for by a separate signed-in `CLAUDE_CONFIG_DIR`: a copied project's session was put back and resumed (the builder recalled its first reply from memory); a kept copy of another session was refused and the new session used the handoff; a real project synced from Windows whose session was missing started afresh and reached Ready. Not yet run between two real computers | Unit tests for prompts, stack choice, site detection, feed folding; live runs reached Build and decompiling (below) |
|
||||
| Context-summary usage | Successful API context summaries contribute to the message totals consumed by build usage and receive a `model_turn` journal entry with `purpose: context_summary`. Cached-token pricing is retained; an unknown summary price makes the total unknown, and a failed call adds no invented usage | Scripted-model tests exercise the real `ApiConversation`, transcript resumption, exact input/output and cache-price totals, journal entries, empty summaries, local zero cost, unknown prices and failed summaries. No live provider or end-to-end rebuild was used for this correction |
|
||||
| Backend context budgets | `context_tokens` in `[[ai.backends]]` controls when an API conversation is trimmed and summarised. Zero or omission retains local/cloud defaults; a nonzero conversation override takes priority. Before each attempt, including a fallback, fitting adds estimated history growth since the last successful request to its reported input size. Trimming resets that reference, and successful requests refresh it; summary usage remains in message totals | Regressions cover settings and model construction, defaults/overrides, smaller fallback budgets, large-budget arithmetic and new tool output growing beyond a fallback budget even when the history estimate alone is below the reported input. The latest tool results are preserved, and a short follow-up does not count already-sent history twice. This does not set server context parameters or guarantee that a complete request fits; no live provider was used |
|
||||
|
||||
@@ -261,6 +261,24 @@ but headless chat waits for `TurnDone`, not just an idle worker. Preflight failu
|
||||
must finish the event sequence too; regression tests check both the error/status and the terminal event.
|
||||
Worker-thread creation failures need the same terminal event even though no worker ever started.
|
||||
|
||||
**A conditional call erased the skipped path's value.** The ARM decoder discarded conditions on
|
||||
calls, and value flow replaced the earlier register definitions as if every call executed. Keep
|
||||
the call's condition and merge its possible writes with the skipped path in both block transfer
|
||||
and edge construction. A known non-returning target stops only an unconditional call; after a
|
||||
conditional one, only the skipped path contributes values. Test both kinds within one block and
|
||||
across blocks, with unconditional calls as controls.
|
||||
|
||||
**A candidate cap discarded a real dependency.** The esbuild module reader kept the first 4096
|
||||
resolved zero-argument calls, then filtered for known wrappers; ordinary local calls could fill
|
||||
the buffer before a real dependency appeared. Discover wrapper bindings first and count only
|
||||
recognised dependencies against their limit. Keep the buffer bounded, and test unrelated calls,
|
||||
forward declarations and more actual dependencies than the limit permits.
|
||||
|
||||
**An occurrence label was also a binding name.** The second `obj.x` window and the first
|
||||
`obj["x[2]"]` window both appeared as `obj.x[2]`. Reserve literal labels and use a separate
|
||||
disambiguator for collisions; never change the occurrence index to make a label unique. Include
|
||||
unchanged windows when reserving labels so a different set of changes cannot rename a window.
|
||||
|
||||
**Reading literals and skipping the rest.** The Electron window reader skipped a spread inside
|
||||
`webPreferences`, so a window whose preferences came partly from another object was reported as
|
||||
"contextIsolation is not set; the default applies", which nobody had checked until a live run on
|
||||
|
||||
@@ -70,6 +70,8 @@ Owner: open · Status: done (branch chore/r0.5-cleanups)
|
||||
Rt = 15 (e.g. the word `0xc0c7fef3`), so one such word failed the whole analysis of a 32-bit
|
||||
ARM program; the word now decodes as invalid
|
||||
- [x] ARM32 value-flow graphs use r0 for word results and ARM scratch registers for calls (#239).
|
||||
Conditional A32 calls also keep definitions from the skipped path, including calls to known
|
||||
non-returning targets (follow-up to #436).
|
||||
- [x] Unchecked `o + 4`/`o + 8` on section and program header offsets in `formats/src/dart.rs`
|
||||
(`Elf::parse`): a header table near `usize::MAX` panicked in debug builds (in release the
|
||||
wrapped reads were thrown away, since the entry's first field was already out of range); an
|
||||
@@ -81,6 +83,8 @@ Owner: open · Status: done (branch chore/r0.5-cleanups)
|
||||
- [x] Missing `database` icon for SQLite in the UI (`kind_icon`)
|
||||
- [x] Deduplicate the five private `fn spec(` helpers in the tool modules
|
||||
- [x] Keep dependencies between recognised esbuild wrappers after identifier minification (#270).
|
||||
Follow-up to #438: only recognised wrapper calls count toward the 4096 dependency
|
||||
budget; public regressions cover unrelated calls, forward declarations, arrows and the cap.
|
||||
- [x] rustfmt drift on main (11 files)
|
||||
- [x] Checked arithmetic on file offsets in `fsread/extfs.rs`: an inode offset near `usize::MAX`
|
||||
overflows `o + 128` (two sites), found by a lint scan of `crates/formats` (#42)
|
||||
@@ -899,6 +903,8 @@ Owner: open · Status: in progress
|
||||
A saved analysis should answer a narrow question without running again, and an app's own code
|
||||
should give away more of how it is set up.
|
||||
- [x] Preserve every unassigned window when comparing application preferences (#237).
|
||||
- [x] Keep window labels distinct when a literal binding resembles an occurrence suffix,
|
||||
preserving the binding name and actual occurrence number (review follow-up to #440).
|
||||
- [x] `core_evidence_browse` pages keep only the rows that match (`matching`: field → value or list
|
||||
of values, `a.b` inside a row, null for a missing field), return only chosen fields (`fields`)
|
||||
and list the field names of the matching rows (`field_names`); offsets count matching rows.
|
||||
|
||||
Reference in New Issue
Block a user