perf(metrics): retire the per-node Node::parent calls in the metric bodies - #1101
Conversation
#1084 gave the metric walk an ancestor chain and #1062 / #1088 threaded it through every predicate that took an `Ancestors::unknown()`. What was left was the same defect one layer down: `node.parent()` calls inside per-language metric *bodies*, where no chain parameter existed. `tree_sitter` stores no parent pointer, so `Node::parent` descends from the root and is `O(depth)` — a per-node call is `O(depth^2)` over a deeply nested file however few steps it takes. `Getter::get_op_type` (and `get_op_type_with_code`), `Abc::compute`, `Npm::compute`, `Npa::compute`, `Cyclomatic::compute` / `compute_with_options`, and `Checker::is_useful_comment` gain an `Ancestors` parameter. All are `pub(crate)`, so the published API is unchanged, and metric values are unchanged for every language. The ABC condition walkers took a different shape. Their `<lang>_inspect_container` helpers resolved the container's parent only to seed a boolean-context flag, and every caller had already descended from that parent — so the parent is passed in rather than rediscovered. Those same helpers asked `Node::previous_sibling` whether a ternary's `?` / `:` precedes the operand, and `ts_node__prev_sibling` opens with `ts_node_parent`, so it carried the identical cost; the new `Node::previous_sibling_under` scans the known parent's children instead. That is #1088's lesson applied ahead of time: a chain-fed predicate is only as linear as the primitives it calls. `rm_comments` maintains a chain of its own by the same truncate/push rule the metric walk uses, built through `Ancestors::checked` so a bookkeeping slip fails a debug build rather than silently feeding Rust's `is_useful_comment` the wrong parent. Guarded by three new probes and two new controls. `halstead/nested-not` fits `time ~ depth^k` at 1.99 before and 0.99 after, `abc/nested-if` at 2.00 and 1.14, `loc/nested-quote` at 2.00 and 1.03. At depth 4000 the three drop from ~478 ms to ~0.57 ms, ~808 ms to ~3.3 ms, and ~9.6 s to ~9.2 ms. Their `halstead/nested-paren` and `abc/nested-block` shape controls hold at 0.97-1.18 either side. Fixes #1096
Review follow-up to the previous commit. Three `Node::previous_sibling` calls survived it, each carrying the same `O(depth)` (`ts_node__prev_sibling` opens with `ts_node_parent`): - Perl's and PHP's ABC container walkers, whose ternary boolean-context seed was written differently enough from the other five to escape the sweep. They now scan the parent they are handed, like the rest. - `kotlin_is_short_interp_name`, which the Halstead path reaches once per `string_content` token and once per operand. It now takes the predecessor rather than resolving it: `kotlin_string_has_interp` already holds it while scanning, and the two `Getter` entry points read it off the chain. `Getter::get_operand_id` gains an `Ancestors` parameter for the second of those; it has one override and one caller. Also folds PHP's `argument`-unwrap into a single `(inner, parent)` binding rather than recovering the parent from a node-id comparison.
Every language's `Halstead::compute` forwards to `compute_halstead` verbatim — the per-language classification lives in `Getter`, not here — so the 23 impls were 23 copies of one signature, and #1096's added `Ancestors` parameter grew each from three lines to eight. One macro emits them all. No impl carried a per-language comment to hoist.
`Node::parent_grandparent_match` climbed with `Node::parent` for both links, and Python's `Cyclomatic` calls it for every `else` token to ask whether the token opens a `for` / `while` / `try` else-clause rather than an `if`. The issue's `rg '\.parent\(\)' src/` inventory does not find this one: the climb is inside the helper, not at the call site. It takes an `Ancestors` now, like every other two-link predicate. Its only caller already had one in scope after the previous commits. A probe pair covers it. `cyclomatic/nested-ternary` — Python's only shape that nests at constant bytes per level, since a block would have to be indented — fits `time ~ depth^k` at 2.06 before and 1.27 after, dropping from ~1.13 s to ~3.4 ms at depth 4000. Its `cyclomatic/nested-and` control, the same nesting through an arm that counts the token from its own kind, holds at 1.26 / 1.31.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1101 +/- ##
==========================================
+ Coverage 98.08% 98.16% +0.08%
==========================================
Files 272 272
Lines 67795 68295 +500
Branches 67365 67865 +500
==========================================
+ Hits 66496 67045 +549
+ Misses 888 858 -30
+ Partials 411 392 -19
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Patch coverage on #1101 came in at 97.04%. Most of the shortfall is a reporting artifact — adding a parameter turned one-line signatures into five-line ones, and llvm-cov attributes no region to a trait method declaration or to a multi-line match-arm guard — but underneath it were six code paths that no test reached at all. The signature change is what made them visible, so they are covered here. - `Cyclomatic::compute` for Rust, the largest single block. The metric walk goes straight to `compute_with_options` so it can pass `count_cyclomatic_try` through, which left the plain trait entry point with no caller anywhere, tests included — while #409 fixes its default as "`?` counts". Now walked against both option settings, so a delegation with the flag inverted fails. - `Checker::is_useful_comment` for C, C++, Mozilla C++, and Objective-C. All five C-family impls share one Aho-Corasick automaton whose needle is bindgen's `<div rustbindgen` marker, and only `Ccomment` had a test. Each case pairs the marker comment with an ordinary one, so an impl that answered `true` unconditionally still fails. - The `add_multiline_string_ploc` call sites for Go, Kotlin, and Mozilla C++ — the three of nineteen that no test exercised. Folded into the existing cross-language parity test for #778, since that is the decision they implement. - `csharp_walk_for_statement`'s paren/unary condition slot and both halves of `groovy_walk_for_statement`'s child-index split. Every existing `for` test in those languages uses `i < n`, whose `LT` token arm counts the condition without entering the walker at all. What remains uncovered on the patch is either a declaration llvm-cov emits no region for or a defensive branch the dispatcher cannot reach; the reachability analysis is in #1102 rather than papered over with tests that build shapes production never produces.
|
Coverage follow-up in 7477384. Patch coverage was 97.04% (25 missing). Reproduced locally with the same Six real gaps, now covered — all pre-existing, made visible because
Each was verified by perturbation: inverting 23 lines remain uncovered, none of them reachable by a test:
The reachability analysis for those is filed as #1102 rather than
|
Closes #1096.
#1084 gave the metric walk an ancestor chain and
#1062 / #1088 threaded it through every
predicate that took an
Ancestors::unknown(). What was left was thesame defect one layer down:
node.parent()calls inside per-languagemetric bodies, where no chain parameter existed.
tree_sitterstoresno parent pointer, so
Node::parentdescends from the root and isO(depth)— a per-node call isO(depth²)over a deeply nested filehowever few steps it takes.
Metric values are unchanged for every language. The whole workspace
suite and the integration snapshots pass with no accepted snapshot.
The three groups
Halstead
get_op_type.Getter::get_op_type,get_op_type_with_code,get_operand_id,Halstead::compute, andcompute_halsteadtake anAncestors, fed fromcompute_per_nodeandops_inner— both already maintained a chain. The six impls that read aparent (
python,rust,cpp,mozcpp,bash,irules) index itnow; Python's docstring arm wants a grandparent as well, which
Ancestors::iterhands over in two steps.src/metrics/abc/. Not shaped the way the issue described. Thenode.parent()is not at the top ofcompute; it is inside eachlanguage's
<lang>_inspect_container, which resolves the conditionslot's parent only to seed a boolean-context flag — and every caller had
already descended from that parent. So the parent is passed in rather
than rediscovered.
Abc::computetakes anAncestorsfor the arms thatgenuinely ask about the node's own parent (
</>disambiguation,the
&&/||chain walkers).loc/npm/npa/cyclomatic/checker.loc's seven armsand the shared
add_multiline_string_plocread the chainLoc::computealready carried.Npm::compute,Npa::compute,Cyclomatic::compute/compute_with_options, andChecker::is_useful_commentgained the parameter.rm_commentsgrew achain of its own by the metric walk's truncate/push rule, built through
Ancestors::checkedso a bookkeeping slip fails a debug build ratherthan silently feeding Rust's
is_useful_commentthe wrong parent.Two climbs the inventory could not see
rg '\.parent\(\)' src/is the authority for direct calls, but twoO(depth)climbs hide behind helpers whose call sites it does notmatch. This is #1088's lesson — a chain-fed predicate is only as linear
as the primitives it calls — arriving from the other direction.
Node::previous_sibling. All seven ABC container walkers ask itwhether a ternary's
?/:precedes the operand, and Kotlin'sHalstead short-interpolation test asks it once per
string_contenttoken and once per operand.
ts_node__prev_siblingopens withts_node_parent, so it carries the identical cost; without this thefix would not have held on a ternary shape. New
Node::previous_sibling_underscans the known parent's childreninstead.
Node::parent_grandparent_match. Python'sCyclomaticcalls itfor every
elsetoken to ask whether the token opens afor/while/tryelse-clause rather than anif, and both linksclimbed.
Measurements
Four probes and three shape controls, added to
big-code-analysis-bench/src/shapes.rswith the fix, measured on thesame idle host either side:
halstead/nested-notabc/nested-ifcyclomatic/nested-ternaryloc/nested-quotehalstead/nested-paren(control)abc/nested-block(control)cyclomatic/nested-and(control)loc/nested-quotehas no shape control of its own: Elixir'sloccatch-all arm fires for every named node, so there is no version of the
shape with the trigger removed.
nom/nested-quote— the same sourceunder a metric that does not ask for a parent — stands in.
Tests
Both new unit tests were verified by perturbing the production line and
confirming each is the only failure:
rust_keeps_a_macro_token_comment_and_strips_an_ordinary_onefailsalone when
rm_commentshandsis_useful_commentan empty chain.previous_sibling_under_agrees_with_the_authoritative_lookupfailsalone (alongside
java_ternary_conditions) when the scan returns thewrong sibling. Its comment records the one break it cannot see — a
scan that never finds the node falls back to the authoritative lookup
and is correct but slow, which is the
abc/nested-ifprobe's job.The existing ABC suite is not vacuous about either change: passing the
wrong node as
parentfailsjava_if_single_conditionsandjava_bool_returning_terminal_kinds_count, and stubbing the ternarysibling seed to
Nonefailsjava_ternary_conditions.Also here
refactor(halstead): every language'sHalstead::computeforwards tocompute_halsteadverbatim, so the added parameter grew 23 identicalimpls from three lines to eight each. One macro emits them all
(−204 lines). No impl carried a per-language comment to hoist.
.bca-baseline.tomlrefreshed withmake self-scan-write-baseline-headroomin the same commits, per thebaseline-refresh discipline in
AGENTS.md.Not in scope
One
previous_siblingwalk is deliberately left: theexclude_testsattribute scan in
Checker::should_skip_subtree. Portingprevious_sibling_underto it would tradeO(attributes × depth)forO(attributes × children), which is worse on the shallow-but-wide treesthat dominate — it wants a single forward scan instead, plus a probe
that can select
exclude_tests, whichProbecannot express today.Filed as #1100.
After this,
rg '\.parent\(\)' src/returns only theAncestorsfallback itself,
Node::get_parent(dead code), the one-off start nodeof a
dump,act_on_node's chain seed, and tests.