From 52b7746e79c8741bc75217bb60c03d2cecaebf24 Mon Sep 17 00:00:00 2001 From: Taylor Gagne Date: Thu, 15 Oct 2026 14:35:23 -0700 Subject: [PATCH 1/3] fix(js): a minified bundle no longer takes hours to extract Indexing element-web's docs/lib/mermaid.min.js (2.9 MB, lines up to 278k chars) never finished: one build ran 2 h before it was killed. local_values walked the variable's whole scope for every read, and local_binding rescanned its block, both through ts_node_named_child(node, i), which is O(i); in a minified bundle the scope is most of the file and reads are thousands. - A per-parse ExtractionMemo indexes each scope's writes by the name they can affect and each block's declarations by name, once. - Child walks use a tree cursor (linear) instead of indexed child lookups. - binds_pattern is a lookup over pattern_names, the one traversal the write index also uses. mermaid.min.js: killed after 2 h before, 17-28 s now, identical graph (8,318 nodes, 180,429 links). The new scale test (3,000 locals, writes and calls in one function) takes 62.9 s on the old code and 0.15 s now. Co-Authored-By: Claude Opus 5.5 --- src/engine/extractor.cpp | 3 + src/engine/http_consumers.cpp | 259 +++++++++++++----- src/engine/include/cgraph/language_config.hpp | 7 + tests/smoke/http_consumers_test.cpp | 29 ++ 4 files changed, 226 insertions(+), 72 deletions(-) diff --git a/src/engine/extractor.cpp b/src/engine/extractor.cpp index aa62db9d..ea67c346 100644 --- a/src/engine/extractor.cpp +++ b/src/engine/extractor.cpp @@ -470,6 +470,9 @@ ExtractionResult extract_with_config( // node positions, and node_text all read the rewritten buffer transparently. std::string rewritten; ExtractionContext local = context; + // The memo holds nodes of the tree parsed below, so it lives and dies with + // this parse, even when a caller reuses one context for several. + local.memo = make_extraction_memo(); if (config.preprocess_source) { rewritten = config.preprocess_source(context.source); if (!rewritten.empty()) { diff --git a/src/engine/http_consumers.cpp b/src/engine/http_consumers.cpp index 89acfaaa..2788eb15 100644 --- a/src/engine/http_consumers.cpp +++ b/src/engine/http_consumers.cpp @@ -35,17 +35,54 @@ namespace { // Calls `visit` on every named descendant of `node` (not `node` itself). With // `skip_functions`, a nested function's subtree is not entered: its calls run // in another scope. +// Calls `visit` on each named child of `node` in order until it returns true; +// true when one did. A cursor, because ts_node_named_child(node, i) costs O(i). +template +bool find_named_child(const TSNode& node, Visit&& visit) { + TSTreeCursor cursor = ts_tree_cursor_new(node); + bool found = false; + if (ts_tree_cursor_goto_first_child(&cursor)) { + do { + const TSNode child = ts_tree_cursor_current_node(&cursor); + found = ts_node_is_named(child) && visit(child); + } while (!found && ts_tree_cursor_goto_next_sibling(&cursor)); + } + ts_tree_cursor_delete(&cursor); + return found; +} + +// Walks with a cursor: ts_node_named_child(node, i) costs O(i), which made a +// node with thousands of children (a minified bundle) quadratic. template void visit_named_descendants(const TSNode& node, bool skip_functions, Visit&& visit) { - const auto count = ts_node_named_child_count(node); - for (std::uint32_t index = 0; index < count; ++index) { - const TSNode child = ts_node_named_child(node, index); - if (skip_functions && is_function_node(ts_node_type(child))) { - continue; + TSTreeCursor cursor = ts_tree_cursor_new(node); + if (ts_tree_cursor_goto_first_child(&cursor)) { + for (;;) { + const TSNode current = ts_tree_cursor_current_node(&cursor); + bool descend = true; + if (ts_node_is_named(current)) { + if (skip_functions && is_function_node(ts_node_type(current))) { + descend = false; + } else { + visit(current); + } + } + if (descend && ts_tree_cursor_goto_first_child(&cursor)) { + continue; + } + while (!ts_tree_cursor_goto_next_sibling(&cursor)) { + if (!ts_tree_cursor_goto_parent(&cursor)) { + ts_tree_cursor_delete(&cursor); + return; + } + if (ts_node_eq(ts_tree_cursor_current_node(&cursor), node)) { + ts_tree_cursor_delete(&cursor); + return; + } + } } - visit(child); - visit_named_descendants(child, skip_functions, visit); } + ts_tree_cursor_delete(&cursor); } // The value of a module-level `const NAME = ...` in this file (through casts), @@ -172,12 +209,13 @@ void visit_named_descendants(const TSNode& node, bool skip_functions, Visit&& vi // nearest enclosing block declaring it directly. Empty when a function // parameter binds the name first, or no enclosing block declares it (it is a // module-level name, an import, or a global). -// True when a parameter of `function` binds `name`, destructured ones -// included (`({ apiUrl }) => ...` shadows an outer `apiUrl`). -[[nodiscard]] bool binds_pattern(const TSNode& node, std::string_view name, std::string_view source) { +// The names a binding pattern binds, destructured ones included +// (`({ apiUrl }) => ...` binds `apiUrl`). +void pattern_names(const TSNode& node, std::string_view source, std::vector& out) { const std::string_view type = ts_node_type(node); if (type == "identifier" || type == "shorthand_property_identifier_pattern") { - return node_text(node, source) == name; + out.push_back(node_text(node, source)); + return; } const auto count = ts_node_named_child_count(node); for (std::uint32_t index = 0; index < count; ++index) { @@ -190,11 +228,16 @@ void visit_named_descendants(const TSNode& node, bool skip_functions, Visit&& vi field_name == "right" || field_name == "key" || field_name == "type") { continue; } - if (binds_pattern(child, name, source)) { - return true; - } + pattern_names(child, source, out); } - return false; +} + +// True when a parameter of `function` binds `name`, destructured ones +// included (`({ apiUrl }) => ...` shadows an outer `apiUrl`). +[[nodiscard]] bool binds_pattern(const TSNode& node, std::string_view name, std::string_view source) { + std::vector names; + pattern_names(node, source, names); + return std::ranges::find(names, name) != names.end(); } [[nodiscard]] bool binds_parameter(const TSNode& function, std::string_view name, std::string_view source) { @@ -242,7 +285,30 @@ struct LocalBinding { return false; } -[[nodiscard]] std::optional local_binding(const TSNode& use, std::string_view name, std::string_view source) { +} // namespace + +// Per file, what the local-value analysis computes once and reads many times. +// In a minified bundle one scope is most of the file and reads are many; walking +// the scope (or scanning its block) per read took hours. +// - writes_by_scope: for each scope local_values reads, the writes in it +// (update, assignment and augmented assignment expressions, in document +// order) under each name they can affect. +// - declarations_by_block: for each statement block, the first declarator of +// each name its own `let`/`const`/`var` statements declare. +struct ExtractionMemo { + std::unordered_map>> writes_by_scope; + std::unordered_map> declarations_by_block; +}; + +std::shared_ptr make_extraction_memo() { + return std::make_shared(); +} + +namespace { + +[[nodiscard]] std::optional local_binding(const TSNode& use, std::string_view name, + const ExtractionContext& context) { + const auto source = context.source; for (TSNode ancestor = ts_node_parent(use); !ts_node_is_null(ancestor); ancestor = ts_node_parent(ancestor)) { const std::string_view type = ts_node_type(ancestor); if (is_function_node(type)) { @@ -260,26 +326,28 @@ struct LocalBinding { if (type != "statement_block") { continue; } - const auto count = ts_node_named_child_count(ancestor); - for (std::uint32_t index = 0; index < count; ++index) { - const TSNode statement = ts_node_named_child(ancestor, index); - const std::string_view statement_type = ts_node_type(statement); - if (statement_type != "lexical_declaration" && statement_type != "variable_declaration") { - continue; - } - const auto declarators = ts_node_named_child_count(statement); - for (std::uint32_t d = 0; d < declarators; ++d) { - const TSNode declarator = ts_node_named_child(statement, d); - if (std::string_view(ts_node_type(declarator)) == "variable_declarator" && - field_text(declarator, "name", source) == name) { - const TSNode keyword = ts_node_child(statement, 0); - return LocalBinding{ - .declarator = declarator, - .scope = ancestor, - .constant = !ts_node_is_null(keyword) && std::string_view(ts_node_type(keyword)) == "const", - }; + auto [entry, built] = context.memo->declarations_by_block.try_emplace(ancestor.id); + auto& declared = entry->second; + if (built) { + find_named_child(ancestor, [&](const TSNode& statement) { + const std::string_view statement_type = ts_node_type(statement); + if (statement_type != "lexical_declaration" && statement_type != "variable_declaration") { + return false; } - } + const TSNode keyword = ts_node_child(statement, 0); + const bool constant = !ts_node_is_null(keyword) && std::string_view(ts_node_type(keyword)) == "const"; + find_named_child(statement, [&](const TSNode& declarator) { + if (std::string_view(ts_node_type(declarator)) == "variable_declarator") { + declared.try_emplace(field_text(declarator, "name", source), + LocalBinding{.declarator = declarator, .scope = ancestor, .constant = constant}); + } + return false; + }); + return false; + }); + } + if (const auto found = declared.find(std::string(name)); found != declared.end()) { + return found->second; } } return std::nullopt; @@ -366,8 +434,50 @@ struct LocalBinding { // another function than the read (a test's `beforeAll`), bound by a loop // header or `catch`, built from itself, its path is rewritten (`url.pathname = // ...`), or it is never given a value. -[[nodiscard]] std::optional> local_values(const TSNode& use, std::string_view name, std::string_view source) { - const auto binding = local_binding(use, name, source); +// The writes in `scope` that can affect `name`: an update of it, an assignment +// to it or to its pathname/href, or a destructuring that binds it. +[[nodiscard]] const std::vector& scope_writes(const TSNode& scope, std::string_view name, + const ExtractionContext& context) { + auto [entry, built] = context.memo->writes_by_scope.try_emplace(scope.id); + auto& by_name = entry->second; + if (built) { + const auto source = context.source; + visit_named_descendants(scope, false, [&](const TSNode& node) { + const std::string_view type = ts_node_type(node); + const auto add = [&](std::string key) { + auto& writes = by_name[std::move(key)]; + if (writes.empty() || !ts_node_eq(writes.back(), node)) writes.push_back(node); + }; + if (type == "update_expression") { + const TSNode argument = unwrap_expression(ts_node_child_by_field_name(node, "argument", 8)); + if (!ts_node_is_null(argument)) add(node_text(argument, source)); + return; + } + if (type != "assignment_expression" && type != "augmented_assignment_expression") return; + const TSNode left = unwrap_expression(ts_node_child_by_field_name(node, "left", 4)); + if (ts_node_is_null(left)) return; + const std::string_view left_type = ts_node_type(left); + if (left_type == "member_expression") { + const TSNode object = unwrap_expression(ts_node_child_by_field_name(left, "object", 6)); + if (!ts_node_is_null(object) && std::string_view(ts_node_type(object)) == "identifier") add(node_text(object, source)); + } else if (left_type == "object_pattern" || left_type == "array_pattern") { + std::vector names; + pattern_names(left, source, names); + for (auto& bound : names) add(std::move(bound)); + } else if (left_type == "identifier") { + add(node_text(left, source)); + } + }); + } + static const std::vector none; + const auto found = by_name.find(std::string(name)); + return found == by_name.end() ? none : found->second; +} + +[[nodiscard]] std::optional> local_values(const TSNode& use, std::string_view name, + const ExtractionContext& context) { + const auto source = context.source; + const auto binding = local_binding(use, name, context); if (!binding) { return std::nullopt; } @@ -380,15 +490,16 @@ struct LocalBinding { const auto use_start = ts_node_start_byte(use); const TSNode use_function = enclosing_function(use); const bool use_in_loop = loops_between(use, binding->scope); - visit_named_descendants(binding->scope, false, [&](const TSNode& node) { + for (const TSNode& node : scope_writes(binding->scope, name, context)) [&] { const std::string_view type = ts_node_type(node); - if ((type == "update_expression" || type == "assignment_expression" || type == "augmented_assignment_expression") && - !use_in_loop && exclusive_branches(node, use, binding->scope)) { - return; // the other branch of an if/else from the read - } + // A write in the other branch of an if/else from the read never reaches it. + // Asked only of writes that touch `name`: the branch walk climbs to the + // scope through ts_node_parent, and a minified bundle's scope holds + // countless unrelated writes. + const auto other_branch = [&] { return !use_in_loop && exclusive_branches(node, use, binding->scope); }; if (type == "update_expression") { const TSNode argument = unwrap_expression(ts_node_child_by_field_name(node, "argument", 8)); - unknowable = unknowable || (!ts_node_is_null(argument) && node_text(argument, source) == name); + unknowable = unknowable || (!ts_node_is_null(argument) && node_text(argument, source) == name && !other_branch()); return; } if (type != "assignment_expression" && type != "augmented_assignment_expression") { @@ -403,22 +514,22 @@ struct LocalBinding { const TSNode object = unwrap_expression(ts_node_child_by_field_name(left, "object", 6)); const auto property = field_text(left, "property", source); if (!ts_node_is_null(object) && std::string_view(ts_node_type(object)) == "identifier" && - node_text(object, source) == name && (property == "pathname" || property == "href")) { + node_text(object, source) == name && (property == "pathname" || property == "href") && !other_branch()) { unknowable = true; } return; } if (left_type == "object_pattern" || left_type == "array_pattern") { - if (binds_pattern(left, name, source)) { - const auto same = local_binding(left, name, source); + if (binds_pattern(left, name, source) && !other_branch()) { + const auto same = local_binding(left, name, context); unknowable = unknowable || (same && !same->opaque && ts_node_eq(same->declarator, binding->declarator)); } return; } - if (left_type != "identifier" || node_text(left, source) != name) { + if (left_type != "identifier" || node_text(left, source) != name || other_branch()) { return; } - const auto same = local_binding(left, name, source); + const auto same = local_binding(left, name, context); if (!same || same->opaque || !ts_node_eq(same->declarator, binding->declarator)) { return; // a nested declaration of the same name } @@ -428,7 +539,7 @@ struct LocalBinding { return; } assignments.push_back(node); - }); + }(); std::vector values; if (!ts_node_is_null(initial)) { unknowable = unknowable || !assignments.empty(); // reassigned: which value the read sees is a guess @@ -840,7 +951,7 @@ void collect_url_template(const TSNode& node, const ExtractionContext& context, // call, several values) is the host, and one inside the path is a value // filling a segment, whatever this run happened to assign it, unless it // holds the query string. - if (const auto values = local_values(expression, name, context.source)) { + if (const auto values = local_values(expression, name, context)) { if (values->size() == 1 && !url.path.empty() && starts_query(values->front(), context.source)) { url.query(); return; @@ -1114,7 +1225,8 @@ constexpr int kMaxOptionsDepth = 3; // Absent is empty; any other value, method or spread the file cannot read is // `unknown`. [[nodiscard]] MethodSource options_method(const TSNode& options, const std::vector& parameters, - std::string_view source, int depth = 0) { + const ExtractionContext& context, int depth = 0) { + const auto source = context.source; const TSNode object = unwrap_expression(options); if (ts_node_is_null(object)) { return {}; @@ -1126,8 +1238,8 @@ constexpr int kMaxOptionsDepth = 3; return MethodSource{.options = at}; } if (depth < kMaxOptionsDepth) { - if (const auto values = local_values(object, name, source)) { - return values->size() == 1 ? options_method(values->front(), parameters, source, depth + 1) + if (const auto values = local_values(object, name, context)) { + return values->size() == 1 ? options_method(values->front(), parameters, context, depth + 1) : MethodSource{.unknown = true}; } if (const int at = rest_of_parameter(object, name, parameters, source); at >= 0) { @@ -1146,7 +1258,7 @@ constexpr int kMaxOptionsDepth = 3; const std::string_view member_type = ts_node_type(member); if (member_type == "spread_element") { const TSNode spread = ts_node_named_child_count(member) == 0 ? TSNode{} : ts_node_named_child(member, 0); - const auto inner = depth < kMaxOptionsDepth ? options_method(spread, parameters, source, depth + 1) + const auto inner = depth < kMaxOptionsDepth ? options_method(spread, parameters, context, depth + 1) : MethodSource{.unknown = true}; if (!inner.verb.empty() || inner.parameter >= 0 || !inner.choices.empty()) { found = inner; // a method of its own replaces what came before @@ -1330,7 +1442,7 @@ void compose_wrapper_call(const ClientCall& shape, std::string client, const TSN const int options_at = shape.method.options; if (options_at >= 0 && options_at < count) { const auto given = options_method(ts_node_named_child(arguments, static_cast(options_at)), - scope.parameters, context.source); + scope.parameters, context); if (given.unknown) { method = MethodSource{.unknown = true}; } else if (!given.verb.empty() || !given.choices.empty()) { @@ -1512,7 +1624,7 @@ struct ReceiverBase { } } if (descriptor.empty() && (type == "object" || type == "identifier")) { - if (const auto method = options_method(argument, scope.parameters, context.source); + if (const auto method = options_method(argument, scope.parameters, context); !method.unknown && method.parameter < 0 && method.options < 0) { descriptor = "O" + join_verbs(method); } @@ -1648,7 +1760,7 @@ std::vector analyze_client_call(const TSNode& node, const Extraction // `let url; if (a) url = x; else url = y; fetch(url)`: one consumer per value. std::vector values{first}; if (primitive && first_type == "identifier") { - if (const auto locals = local_values(first, node_text(first, context.source), context.source); + if (const auto locals = local_values(first, node_text(first, context.source), context); locals && locals->size() > 1) { values = *locals; } @@ -1661,7 +1773,7 @@ std::vector analyze_client_call(const TSNode& node, const Extraction // say (its `http_wrapper` fact records the index): contracts read the method // there from this call's `http_call_args`, never from the second argument. MethodSource method = primitive && argument_count >= 2 - ? options_method(ts_node_named_child(arguments, 1), scope.parameters, context.source) + ? options_method(ts_node_named_child(arguments, 1), scope.parameters, context) : MethodSource{}; if (!verb.empty()) { method.options = -1; // `axios.post(url, data)`: the second argument is the body, the verb is fixed @@ -1987,24 +2099,26 @@ struct SdkClient { std::string factory; // kImported: the imported function's name }; -[[nodiscard]] SdkClient sdk_client_value(const TSNode& node, const SdkImports& imports, std::string_view source, int depth); +[[nodiscard]] SdkClient sdk_client_value(const TSNode& node, const SdkImports& imports, const ExtractionContext& context, + int depth); // True when `function` returns an SDK client on every path that returns. -[[nodiscard]] bool returns_sdk_client(const TSNode& function, const SdkImports& imports, std::string_view source, +[[nodiscard]] bool returns_sdk_client(const TSNode& function, const SdkImports& imports, const ExtractionContext& context, int depth) { std::vector returns; function_return_values(function, returns); return !returns.empty() && std::ranges::all_of(returns, [&](const TSNode& value) { - return !ts_node_is_null(value) && sdk_client_value(value, imports, source, depth + 1).origin == SdkOrigin::kHere; + return !ts_node_is_null(value) && sdk_client_value(value, imports, context, depth + 1).origin == SdkOrigin::kHere; }); } // An identifier read through its one value: a block-scoped local set once, else // (unless a parameter shadows it) a module `const`. A module `let` or `var` may // be reassigned anywhere in the file, so it is not read. Null otherwise. -[[nodiscard]] TSNode single_value(const TSNode& identifier, std::string_view source) { +[[nodiscard]] TSNode single_value(const TSNode& identifier, const ExtractionContext& context) { + const auto source = context.source; const auto name = node_text(identifier, source); - if (const auto values = local_values(identifier, name, source)) { + if (const auto values = local_values(identifier, name, context)) { return values->size() == 1 ? values->front() : TSNode{}; } if (parameter_shadows(identifier, name, source)) { @@ -2020,7 +2134,8 @@ struct SdkClient { return TSNode{}; } -SdkClient sdk_client_value(const TSNode& node, const SdkImports& imports, std::string_view source, int depth) { +SdkClient sdk_client_value(const TSNode& node, const SdkImports& imports, const ExtractionContext& context, int depth) { + const auto source = context.source; const TSNode value = unwrap_expression(node); if (ts_node_is_null(value) || depth > kMaxSdkDepth) { return {}; @@ -2039,8 +2154,8 @@ SdkClient sdk_client_value(const TSNode& node, const SdkImports& imports, std::s return sdk ? SdkClient{.origin = SdkOrigin::kHere} : SdkClient{}; } if (type == "identifier") { - const TSNode held = single_value(value, source); - return ts_node_is_null(held) ? SdkClient{} : sdk_client_value(held, imports, source, depth + 1); + const TSNode held = single_value(value, context); + return ts_node_is_null(held) ? SdkClient{} : sdk_client_value(held, imports, context, depth + 1); } if (type == "member_expression") { // `this.client`, set once in its class. @@ -2050,7 +2165,7 @@ SdkClient sdk_client_value(const TSNode& node, const SdkImports& imports, std::s } const TSNode body = enclosing_class_body(value); const TSNode held = ts_node_is_null(body) ? TSNode{} : class_member_value(body, field_text(value, "property", source), source); - return ts_node_is_null(held) ? SdkClient{} : sdk_client_value(held, imports, source, depth + 1); + return ts_node_is_null(held) ? SdkClient{} : sdk_client_value(held, imports, context, depth + 1); } if (type != "call_expression") { return {}; @@ -2060,11 +2175,11 @@ SdkClient sdk_client_value(const TSNode& node, const SdkImports& imports, std::s return {}; } const auto name = node_text(callee, source); - if (local_binding(callee, name, source) || parameter_shadows(callee, name, source)) { + if (local_binding(callee, name, context) || parameter_shadows(callee, name, source)) { return {}; // a local or a parameter: not the module's function } if (const TSNode function = module_function(value, name, source); !ts_node_is_null(function)) { - return returns_sdk_client(function, imports, source, depth) ? SdkClient{.origin = SdkOrigin::kHere} : SdkClient{}; + return returns_sdk_client(function, imports, context, depth) ? SdkClient{.origin = SdkOrigin::kHere} : SdkClient{}; } return imports_name(value, name, source) ? SdkClient{.origin = SdkOrigin::kImported, .factory = name} : SdkClient{}; } @@ -2099,7 +2214,7 @@ struct SdkCall { } const SdkImports imports = langgraph_sdk_imports(node, context.source); const TSNode receiver = unwrap_expression(ts_node_child_by_field_name(group, "object", 6)); - SdkClient client = sdk_client_value(receiver, imports, context.source, 0); + SdkClient client = sdk_client_value(receiver, imports, context, 0); if (client.origin == SdkOrigin::kNone) { return std::nullopt; } @@ -2133,7 +2248,7 @@ void langgraph_factory_handler(const TSNode& node, const ExtractionContext& cont } const SdkImports imports = langgraph_sdk_imports(node, context.source); if ((imports.classes.empty() && imports.namespaces.empty()) || - sdk_client_value(node, imports, context.source, 0).origin != SdkOrigin::kHere) { + sdk_client_value(node, imports, context, 0).origin != SdkOrigin::kHere) { return; } const TSNode function = enclosing_function(node); @@ -2145,7 +2260,7 @@ void langgraph_factory_handler(const TSNode& node, const ExtractionContext& cont const auto name = declared_name(function, context.source); if (name.empty() || make_id(context.relative_path + ":" + name) != function_scope_id || !ts_node_eq(module_function(node, name, context.source), function) || - !returns_sdk_client(function, imports, context.source, 0)) { + !returns_sdk_client(function, imports, context, 0)) { return; } std::vector returns; diff --git a/src/engine/include/cgraph/language_config.hpp b/src/engine/include/cgraph/language_config.hpp index 6c5b33a1..30f1c2d4 100644 --- a/src/engine/include/cgraph/language_config.hpp +++ b/src/engine/include/cgraph/language_config.hpp @@ -69,6 +69,12 @@ struct RawRelation { bool allow_same_file = false; // heritage may resolve to a same-file declaration; references may not }; +// Per-parse memo an extractor may fill (what it computes once and reads many +// times while walking one file's tree). Defined by its user; the extractor gives +// each parse a fresh one, since it holds that parse's tree nodes. +struct ExtractionMemo; +[[nodiscard]] std::shared_ptr make_extraction_memo(); + struct ExtractionContext { // Where the bytes were read from; every emitted node's `source_file`, and the // key its hash is recorded under. @@ -78,6 +84,7 @@ struct ExtractionContext { // yields byte-identical ids and no id carries the machine's directory layout. std::string relative_path; std::string_view source; + std::shared_ptr memo = make_extraction_memo(); }; using ImportHandler = std::function; diff --git a/tests/smoke/http_consumers_test.cpp b/tests/smoke/http_consumers_test.cpp index 6c97fd80..89cc36fe 100644 --- a/tests/smoke/http_consumers_test.cpp +++ b/tests/smoke/http_consumers_test.cpp @@ -4,6 +4,7 @@ #include "cgraph/javascript_extractor.hpp" #include "cgraph/normalize.hpp" +#include #include #include #include @@ -625,5 +626,33 @@ export const api = { list: () => fetch('/api/v1/listed') }; } } + // Scale: one function holding thousands of locals, unrelated writes and + // calls that read a local (a minified bundle's shape). Each read used to walk + // the whole scope and rescan its block, which took hours on a 2.9 MB + // mermaid.min.js; every call must still resolve its local's value. + { + constexpr int kCount = 3000; + std::string source = "export function load() {\n"; + for (int i = 0; i < kCount; ++i) source += " let u" + std::to_string(i) + " = '/api/v1/items/" + std::to_string(i) + "';\n"; + for (int i = 0; i < kCount; ++i) source += " x" + std::to_string(i) + " = " + std::to_string(i) + ";\n"; + for (int i = 0; i < kCount; ++i) source += " fetch(u" + std::to_string(i) + ");\n"; + source += "}\n"; + const auto started = std::chrono::steady_clock::now(); + const auto calls = cgraph::extract_typescript({.source_file = "lib/bundle.ts", .relative_path = "lib/bundle.ts", .source = source}); + const auto seconds = std::chrono::duration(std::chrono::steady_clock::now() - started).count(); + std::set paths; + for (const auto& relation : calls.raw_relations) { + if (relation.relation == "http_call" && relation.target_label == "fetch") paths.insert(relation.context); + } + if (paths.size() != kCount || !paths.contains(" /api/v1/items/0") || !paths.contains(" /api/v1/items/2999")) { + std::cerr << "scale: " << paths.size() << " fetch paths resolved of " << kCount << "\n"; + return 1; + } + if (seconds > 10.0) { + std::cerr << "scale: extraction took " << seconds << " s\n"; + return 1; + } + } + return 0; } From 7e1057d91393d8ad99df7225b0d5ae63dc998eeb Mon Sep 17 00:00:00 2001 From: Taylor Gagne Date: Thu, 15 Oct 2026 14:56:39 -0700 Subject: [PATCH 2/3] test(client): wait for the lone repository's graph before asking the hook Same fix as #200 (identical hunk), so this branch's CI stops hitting the 'lone repository: null' race. Co-Authored-By: Claude Opus 5.5 --- tests/smoke/client_runtime_test.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/smoke/client_runtime_test.cpp b/tests/smoke/client_runtime_test.cpp index 6abb00c7..850ccc75 100644 --- a/tests/smoke/client_runtime_test.cpp +++ b/tests/smoke/client_runtime_test.cpp @@ -645,6 +645,17 @@ int main() { ++attempt) { std::this_thread::sleep_for(25ms); } + // The hook never waits for a build and says nothing while one runs, so wait + // for the graph itself, not just a daemon that accepts connections. + for (int attempt = 0; attempt < 400; ++attempt) { + cgraph::ClientRequest ready{.project_root = lone, .operation = "status"}; + ready.spawn = false; + const auto answer = cgraph::send_thin_client_request(ready, cgraph::default_client_runtime_hooks(ready)); + if (answer.response && answer.response->value("result", nlohmann::json::object()).value("build_state", std::string{}) == "ready") { + break; + } + std::this_thread::sleep_for(25ms); + } const nlohmann::json input{{"hook_event_name", "PreToolUse"}, {"tool_name", "MultiEdit"}, {"tool_input", {{"file_path", (lone / "src" / "helper.ts").generic_string()}, {"edits", {{{"old_string", " return n + 1;"}, {"new_string", " return n + 2;"}}}}}}}; From a225121d5dbab101f1ffe4de2d589796ba5d93cb Mon Sep 17 00:00:00 2001 From: Taylor Gagne Date: Thu, 15 Oct 2026 16:33:49 -0700 Subject: [PATCH 3/3] style(js): put visit_named_descendants' comment back above it Co-Authored-By: Claude Opus 5.5 --- src/engine/http_consumers.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/engine/http_consumers.cpp b/src/engine/http_consumers.cpp index 2788eb15..b5596de8 100644 --- a/src/engine/http_consumers.cpp +++ b/src/engine/http_consumers.cpp @@ -32,9 +32,6 @@ namespace { return node; } -// Calls `visit` on every named descendant of `node` (not `node` itself). With -// `skip_functions`, a nested function's subtree is not entered: its calls run -// in another scope. // Calls `visit` on each named child of `node` in order until it returns true; // true when one did. A cursor, because ts_node_named_child(node, i) costs O(i). template @@ -53,6 +50,9 @@ bool find_named_child(const TSNode& node, Visit&& visit) { // Walks with a cursor: ts_node_named_child(node, i) costs O(i), which made a // node with thousands of children (a minified bundle) quadratic. +// Calls `visit` on every named descendant of `node` (not `node` itself). With +// `skip_functions`, a nested function's subtree is not entered: its calls run +// in another scope. template void visit_named_descendants(const TSNode& node, bool skip_functions, Visit&& visit) { TSTreeCursor cursor = ts_tree_cursor_new(node);