Skip to main content

fallow_extract/
inventory.rs

1//! Function inventory walker for `fallow coverage upload-inventory`.
2//!
3//! Emits one [`InventoryEntry`] per function (declaration, expression, arrow,
4//! method) whose name matches what `oxc-coverage-instrument` produces at
5//! instrument time. This is the **static side** of the three-state production
6//! coverage story: uploaded inventory minus runtime-seen functions equals
7//! `untracked`.
8//!
9//! # Naming contract
10//!
11//! The cloud stores function identity as
12//! `(filePath, functionName, lineNumber)`. This walker is responsible for the
13//! `functionName` and `lineNumber` parts of that contract. Anonymous functions
14//! are named `(anonymous_N)` where `N` is a file-scoped monotonic counter that
15//! starts at 0 and increments in pre-order AST traversal each time a function
16//! is entered without a resolvable explicit name. Name resolution precedence:
17//!
18//! 1. Parent-provided `pending_name`: from a `MethodDefinition` /
19//!    `VariableDeclarator` binding, OR from the callee of the call / `new`
20//!    expression a function is passed to as an argument (`arr.map(cb)` ->
21//!    "map", `foo(cb)` -> "foo", `new Promise(cb)` -> "Promise"). The callee
22//!    case matches `oxc-coverage-instrument`'s opt-in `name_callback_arguments`
23//!    (which the Fallow runtime beacon enables), so a callback's static name
24//!    lines up with its runtime-instrumented name instead of both sides drifting
25//!    to different anonymous placeholders.
26//! 2. The function's own `id` (named `function foo() {}`, named function
27//!    expression `const x = function named() {}`).
28//! 3. `(anonymous_N)` with the current counter value; counter then increments.
29//!    Only genuinely unnamed functions reach this: an immediately-invoked
30//!    function expression, an arrow returned from another function, or a
31//!    computed non-string-key call.
32//!
33//! Counter scope is per-file. Reference implementation:
34//! `oxc-coverage-instrument/src/transform.rs` (`resolve_function_name` +
35//! `callback_argument_name`).
36
37use std::path::Path;
38
39use oxc_allocator::Allocator;
40#[allow(clippy::wildcard_imports, reason = "many AST types used")]
41use oxc_ast::ast::*;
42use oxc_ast_visit::{Visit, walk};
43use oxc_parser::Parser;
44use oxc_semantic::ScopeFlags;
45use oxc_span::{SourceType, Span};
46use rustc_hash::FxHashMap;
47
48/// A single static-inventory entry for one function.
49///
50/// `name` is beacon-compatible (see the module docs for the naming rule).
51/// `line` is 1-based, matching the AST span start. The `start_column` /
52/// `end_line` / `end_column` fields carry the function-node span in the
53/// 1-indexed UTF-16 convention the cross-surface `FunctionIdentity` join key
54/// expects (see `fallow_cov_protocol::FunctionIdentity::start_column`). They
55/// are descriptive metadata: the join hash is `(file, name, line)` only, so
56/// column fidelity never affects the join, only display / same-line
57/// disambiguation.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct InventoryEntry {
60    /// Beacon-compatible function name.
61    pub name: String,
62    /// 1-based source line of the function declaration (node `span.start`).
63    pub line: u32,
64    /// 1-indexed UTF-16 column of the function node start.
65    pub start_column: u32,
66    /// 1-based source line where the function node ends.
67    pub end_line: u32,
68    /// 1-indexed UTF-16 column of the function node end.
69    pub end_column: u32,
70    /// Content digest of the function's full-span source slice
71    /// (`&source[span.start..span.end]`): first 8 bytes of SHA-256 as 16
72    /// lowercase hex characters, via `fallow_cov_protocol::source_hash_for`.
73    /// The slice is the canonical body bytes (signature line + body + closing
74    /// brace, no whitespace normalization), identical for `Function` and
75    /// `ArrowFunctionExpression`. Stable across line moves, so a
76    /// moved-but-unedited function keeps the same hash.
77    pub source_hash: String,
78}
79
80/// Visitor that collects [`InventoryEntry`] values in file traversal order.
81struct InventoryVisitor<'a> {
82    source: &'a str,
83    line_offsets: &'a [u32],
84    entries: Vec<InventoryEntry>,
85    /// Parent-provided name override (method key, variable binding, etc.).
86    pending_name: Option<String>,
87    /// Callee name for a function passed as a call / `new` argument. Ranks BELOW
88    /// the function's own `id` (a named function expression keeps its id), so it
89    /// is a separate slot from `pending_name` (which ranks above the id).
90    pending_callee_name: Option<String>,
91    /// File-scoped monotonic counter for unnamed functions.
92    anonymous_counter: u32,
93}
94
95impl<'a> InventoryVisitor<'a> {
96    const fn new(source: &'a str, line_offsets: &'a [u32]) -> Self {
97        Self {
98            source,
99            line_offsets,
100            entries: Vec::new(),
101            pending_name: None,
102            pending_callee_name: None,
103            anonymous_counter: 0,
104        }
105    }
106
107    /// Resolve a function's name and advance the counter.
108    ///
109    /// Mirrors `oxc-coverage-instrument`'s two-step flow: `resolve_function_name`
110    /// reads the current counter value for the anonymous-case name, and
111    /// `add_function` advances the counter unconditionally on every
112    /// instrumented function (named or not). We collapse both into one call.
113    ///
114    /// Name precedence, matching the instrumenter's `resolve_function_name`:
115    /// parent `pending_name` (method key / variable binding) → function's own
116    /// `id` → call/`new` callee (`pending_callee_name`) → counter.
117    fn resolve_name(&mut self, explicit: Option<&str>) -> String {
118        let n = self.anonymous_counter;
119        self.anonymous_counter += 1;
120        if let Some(pending) = self.pending_name.take() {
121            return pending;
122        }
123        if let Some(name) = explicit {
124            return name.to_owned();
125        }
126        if let Some(callee) = self.pending_callee_name.take() {
127            return callee;
128        }
129        format!("(anonymous_{n})")
130    }
131
132    fn record(&mut self, name: String, span: Span) {
133        let (line, start_column) = self.line_col_utf16(span.start);
134        let (end_line, end_column) = self.line_col_utf16(span.end);
135        let source_hash = self
136            .source
137            .get(span.start as usize..span.end as usize)
138            .map_or_else(
139                || fallow_cov_protocol::source_hash_for(b""),
140                |slice| fallow_cov_protocol::source_hash_for(slice.as_bytes()),
141            );
142        self.entries.push(InventoryEntry {
143            name,
144            line,
145            start_column,
146            end_line,
147            end_column,
148            source_hash,
149        });
150    }
151
152    /// Map a UTF-8 byte offset to `(1-based line, 1-indexed UTF-16 column)`.
153    ///
154    /// The line comes from the precomputed offset table; the column counts
155    /// UTF-16 code units from the line start to `byte_offset`, matching the
156    /// `FunctionIdentity` column convention (Istanbul / V8 / oxc all normalize
157    /// to 1-indexed UTF-16). A byte offset that does not fall on a char
158    /// boundary (it always should for an AST span) clamps to the nearest
159    /// boundary at or before it rather than panicking.
160    fn line_col_utf16(&self, byte_offset: u32) -> (u32, u32) {
161        let line_idx = match self.line_offsets.binary_search(&byte_offset) {
162            Ok(idx) => idx,
163            Err(idx) => idx.saturating_sub(1),
164        };
165        let line = line_idx as u32 + 1;
166        let line_start = self.line_offsets[line_idx] as usize;
167        let mut end = byte_offset as usize;
168        while end > line_start && !self.source.is_char_boundary(end) {
169            end -= 1;
170        }
171        let col_utf16 = self
172            .source
173            .get(line_start..end)
174            .map_or(0, |slice| slice.encode_utf16().count());
175        (line, col_utf16 as u32 + 1)
176    }
177}
178
179impl<'ast> Visit<'ast> for InventoryVisitor<'_> {
180    fn visit_function(&mut self, func: &Function<'ast>, flags: ScopeFlags) {
181        if func.body.is_none() {
182            walk::walk_function(self, func, flags);
183            return;
184        }
185        let name = self.resolve_name(func.id.as_ref().map(|id| id.name.as_str()));
186        self.record(name, func.span);
187        walk::walk_function(self, func, flags);
188    }
189
190    fn visit_arrow_function_expression(&mut self, arrow: &ArrowFunctionExpression<'ast>) {
191        let name = self.resolve_name(None);
192        self.record(name, arrow.span);
193        walk::walk_arrow_function_expression(self, arrow);
194    }
195
196    fn visit_method_definition(&mut self, method: &MethodDefinition<'ast>) {
197        if let Some(name) = method.key.static_name() {
198            self.pending_name = Some(name.to_string());
199        }
200        walk::walk_method_definition(self, method);
201        self.pending_name = None;
202    }
203
204    fn visit_variable_declarator(&mut self, decl: &VariableDeclarator<'ast>) {
205        if let Some(id) = decl.id.get_binding_identifier()
206            && decl.init.as_ref().is_some_and(|init| {
207                matches!(
208                    init,
209                    Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_)
210                )
211            })
212        {
213            self.pending_name = Some(id.name.to_string());
214        }
215        walk::walk_variable_declarator(self, decl);
216        self.pending_name = None;
217    }
218
219    fn visit_object_property(&mut self, prop: &ObjectProperty<'ast>) {
220        self.pending_name = None;
221        walk::walk_object_property(self, prop);
222        self.pending_name = None;
223    }
224
225    /// Name each function-valued argument from the callee (`arr.map(cb)` ->
226    /// "map", `foo(cb)` -> "foo"), matching `oxc-coverage-instrument`'s
227    /// `name_callback_arguments`. The callee subtree is visited FIRST with no
228    /// inherited name, so a chained call (`a.b().c(cb)`) never leaks `b` onto
229    /// `c`'s callback; the callee's own name is then applied afresh to each
230    /// argument. A binding name from a parent (declarator / method) is already
231    /// consumed by its direct function child before the body's calls, so it
232    /// never collides here. Type arguments are skipped (types hold no function
233    /// to inventory).
234    fn visit_call_expression(&mut self, call: &CallExpression<'ast>) {
235        self.visit_expression(&call.callee);
236        let name = callee_name(&call.callee);
237        for argument in &call.arguments {
238            self.pending_callee_name.clone_from(&name);
239            self.visit_argument(argument);
240        }
241        self.pending_callee_name = None;
242    }
243
244    fn visit_new_expression(&mut self, new_expr: &NewExpression<'ast>) {
245        self.visit_expression(&new_expr.callee);
246        let name = callee_name(&new_expr.callee);
247        for argument in &new_expr.arguments {
248            self.pending_callee_name.clone_from(&name);
249            self.visit_argument(argument);
250        }
251        self.pending_callee_name = None;
252    }
253}
254
255/// Extract a display name from a call / `new` callee, matching
256/// `oxc-coverage-instrument`'s `callee_name`: a bare identifier keeps its name,
257/// a member access uses the (last) property, and a computed access uses a
258/// string-literal key. Anything else (a computed non-string index, a call
259/// result, a parenthesized expression) yields no name.
260fn callee_name(callee: &Expression<'_>) -> Option<String> {
261    match callee {
262        Expression::Identifier(ident) => Some(ident.name.to_string()),
263        Expression::StaticMemberExpression(member) => Some(member.property.name.to_string()),
264        Expression::ComputedMemberExpression(member) => match &member.expression {
265            Expression::StringLiteral(lit) => Some(lit.value.to_string()),
266            _ => None,
267        },
268        // A parenthesized callee (`(foo)(cb)`, `(a.b)(cb)`) unwraps to its inner
269        // callee, matching the instrumenter. oxc keeps paren nodes by default
270        // (`preserve_parens`), so both sides see this node.
271        Expression::ParenthesizedExpression(paren) => callee_name(&paren.expression),
272        _ => None,
273    }
274}
275
276/// Per-function static complexity collected alongside the inventory walk.
277///
278/// Keyed to an [`InventoryEntry`] by its `source_hash`, which both this and the
279/// inventory walk derive from the identical full-span byte slice over the same
280/// parsed program (see [`InventoryEntry::source_hash`]). The hash is stable
281/// across line moves, so the pairing survives reformatting that shifts line
282/// numbers. `cyclomatic` and `cognitive` are descriptive context for downstream
283/// importance weighting, never thresholds.
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub struct InventoryComplexity {
286    /// `McCabe` cyclomatic complexity (1 + decision points).
287    pub cyclomatic: u16,
288    /// `SonarSource` cognitive complexity (structural + nesting penalty).
289    pub cognitive: u16,
290}
291
292/// Parse `source` at `path` and return every function as an [`InventoryEntry`].
293///
294/// Only plain JS/TS/JSX/TSX sources are supported. Callers should skip SFC,
295/// Astro, MDX, CSS, HTML, and other non-JS inputs; those use different
296/// instrumentation paths and are out of scope for the first inventory release.
297///
298/// Errors are swallowed: the returned vector covers whatever could be parsed.
299/// This mirrors how the rest of the extract pipeline handles partial parse
300/// results.
301#[must_use]
302pub fn walk_source(path: &Path, source: &str) -> Vec<InventoryEntry> {
303    walk_source_with_complexity(path, source).0
304}
305
306/// Parse `source` at `path` once and return every function as an
307/// [`InventoryEntry`] together with a `source_hash -> InventoryComplexity` map.
308///
309/// Both the inventory entries and the complexity map come from the SAME parse
310/// (including the JSX fallback retry), so the per-function `source_hash` values
311/// line up exactly and a caller can enrich each entry's metrics by a hash
312/// lookup. Functions whose span slice could not be sliced share the empty-input
313/// hash and simply don't pair; that degrades to "no metrics", never a panic.
314///
315/// Errors are swallowed, matching [`walk_source`]: the returned data covers
316/// whatever could be parsed.
317#[must_use]
318pub fn walk_source_with_complexity(
319    path: &Path,
320    source: &str,
321) -> (Vec<InventoryEntry>, FxHashMap<String, InventoryComplexity>) {
322    let source_type = SourceType::from_path(path).unwrap_or_default();
323    let line_offsets = fallow_types::extract::compute_line_offsets(source);
324
325    let primary = walk_one_parse(source, source_type, &line_offsets);
326    if primary.0.is_empty() && !source_type.is_jsx() {
327        let jsx_type = if source_type.is_typescript() {
328            SourceType::tsx()
329        } else {
330            SourceType::jsx()
331        };
332        let retry = walk_one_parse(source, jsx_type, &line_offsets);
333        if !retry.0.is_empty() {
334            return retry;
335        }
336    }
337
338    primary
339}
340
341/// Run both the inventory and complexity visitors over a single parse of
342/// `source` under `source_type`, pairing them by `source_hash`.
343fn walk_one_parse(
344    source: &str,
345    source_type: SourceType,
346    line_offsets: &[u32],
347) -> (Vec<InventoryEntry>, FxHashMap<String, InventoryComplexity>) {
348    let allocator = Allocator::default();
349    let parser_return = Parser::new(&allocator, source, source_type).parse();
350
351    let mut visitor = InventoryVisitor::new(source, line_offsets);
352    visitor.visit_program(&parser_return.program);
353
354    let complexity =
355        crate::complexity::compute_complexity(&parser_return.program, source, line_offsets);
356    let metrics: FxHashMap<String, InventoryComplexity> = complexity
357        .into_iter()
358        .filter_map(|fc| {
359            fc.source_hash.map(|hash| {
360                (
361                    hash,
362                    InventoryComplexity {
363                        cyclomatic: fc.cyclomatic,
364                        cognitive: fc.cognitive,
365                    },
366                )
367            })
368        })
369        .collect();
370
371    (visitor.entries, metrics)
372}
373
374#[cfg(all(test, not(miri)))]
375mod tests {
376    use super::*;
377    use std::path::PathBuf;
378
379    fn walk(source: &str) -> Vec<InventoryEntry> {
380        walk_source(&PathBuf::from("test.ts"), source)
381    }
382
383    #[test]
384    fn named_function_declaration_uses_its_own_name() {
385        let entries = walk("function foo() { return 1; }");
386        assert_eq!(entries.len(), 1);
387        assert_eq!(entries[0].name, "foo");
388        assert_eq!(entries[0].line, 1);
389    }
390
391    #[test]
392    fn const_arrow_captures_binding_name() {
393        let entries = walk("const bar = () => 42;");
394        assert_eq!(entries.len(), 1);
395        assert_eq!(entries[0].name, "bar");
396    }
397
398    #[test]
399    fn const_function_expression_captures_binding_name_not_fn_id() {
400        let entries = walk("const outer = function inner() { return 1; };");
401        assert_eq!(entries.len(), 1);
402        assert_eq!(entries[0].name, "outer");
403    }
404
405    #[test]
406    fn class_methods_use_method_names() {
407        let entries = walk(
408            r"
409            class Foo {
410              bar() { return 1; }
411              baz() { return 2; }
412            }",
413        );
414        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
415        assert_eq!(names, vec!["bar", "baz"]);
416    }
417
418    #[test]
419    fn callback_argument_takes_the_callee_name() {
420        // An arrow passed as a call argument now takes the callee name (matches
421        // the instrumenter's name_callback_arguments), not the anonymous counter.
422        let entries = walk("setTimeout(() => { console.log('hi'); }, 10);");
423        assert_eq!(entries.len(), 1);
424        assert_eq!(entries[0].name, "setTimeout");
425    }
426
427    #[test]
428    fn member_callee_names_each_callback_in_source_order() {
429        let entries = walk(
430            r"
431            [1, 2, 3].map(() => 1);
432            [4, 5, 6].filter(() => true);
433            ",
434        );
435        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
436        assert_eq!(names, vec!["map", "filter"]);
437    }
438
439    #[test]
440    fn named_function_still_advances_counter_matching_instrumenter() {
441        // The counter still advances on every function (named or callee-named),
442        // matching the instrumenter, so a later genuinely-anonymous function
443        // gets the right N. Here the callback is callee-named "map".
444        let entries = walk(
445            r"
446            function named() { return 1; }
447            [1].map(() => 2);
448            ",
449        );
450        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
451        assert_eq!(names, vec!["named", "map"]);
452    }
453
454    #[test]
455    fn plain_identifier_callee_names_the_callback() {
456        let entries = walk("useMemo(() => compute());");
457        assert_eq!(entries[0].name, "useMemo");
458    }
459
460    #[test]
461    fn new_expression_callee_names_the_callback() {
462        let entries = walk("new Promise((resolve) => resolve(1));");
463        assert_eq!(entries[0].name, "Promise");
464    }
465
466    #[test]
467    fn callback_after_a_string_argument_is_named_from_the_callee() {
468        // The event/route-handler shape: the function is a later argument, after
469        // a string. It is named from the callee, not the string.
470        let entries = walk(r#"el.addEventListener("click", () => handle());"#);
471        assert_eq!(entries[0].name, "addEventListener");
472    }
473
474    #[test]
475    fn computed_string_key_callee_is_named() {
476        let entries = walk(r#"obj["handler"](() => run());"#);
477        assert_eq!(entries[0].name, "handler");
478    }
479
480    #[test]
481    fn chained_call_does_not_leak_the_earlier_callee_onto_the_later_callback() {
482        // `.then`'s callback must be "then" and `.catch`'s must be "catch": the
483        // callee subtree (`p.then(cb).catch`) is visited before the outer
484        // arguments, so the earlier callee never leaks onto the later callback.
485        let entries = walk("p.then(() => a).catch(() => b);");
486        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
487        assert_eq!(names, vec!["then", "catch"]);
488    }
489
490    #[test]
491    fn nested_callbacks_each_take_their_own_callee() {
492        let entries = walk("outer(() => inner(() => 1));");
493        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
494        assert_eq!(names, vec!["outer", "inner"]);
495    }
496
497    #[test]
498    fn binding_name_wins_over_callee() {
499        // A declarator binding is consumed on function entry, before the body's
500        // calls, so a bound arrow keeps its name even when its body is a call.
501        let entries = walk("const handler = () => run();");
502        assert_eq!(entries[0].name, "handler");
503    }
504
505    #[test]
506    fn named_function_expression_argument_keeps_its_own_id() {
507        let entries = walk("run(function inner() { return 1; });");
508        assert_eq!(entries[0].name, "inner");
509    }
510
511    #[test]
512    fn iife_callee_stays_anonymous() {
513        // The function is the callee, not an argument, so it is not a callback.
514        let entries = walk("(function () { return 1; })();");
515        assert_eq!(entries[0].name, "(anonymous_0)");
516    }
517
518    #[test]
519    fn computed_non_string_callee_stays_anonymous() {
520        let entries = walk("handlers[index](() => run());");
521        assert_eq!(entries[0].name, "(anonymous_0)");
522    }
523
524    #[test]
525    fn parenthesized_callee_unwraps_to_the_inner_name() {
526        assert_eq!(walk("(foo)(() => run());")[0].name, "foo");
527        assert_eq!(walk("(a.b)(() => run());")[0].name, "b");
528    }
529
530    #[test]
531    fn anonymous_after_named_chain_uses_next_counter_value() {
532        let entries = walk(
533            r"
534            function a() {}
535            function b() {}
536            function c() {}
537            const d = () => 4;
538            ",
539        );
540        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
541        assert_eq!(names, vec!["a", "b", "c", "d"]);
542    }
543
544    #[test]
545    fn typescript_overload_signatures_dont_emit_or_advance_counter() {
546        let entries = walk(
547            r"
548            function foo(): number;
549            function foo(s: string): string;
550            function foo(s?: string): number | string { return s ? s : 1; }
551            [1].map(() => 2);
552            ",
553        );
554        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
555        assert_eq!(names, vec!["foo", "map"]);
556    }
557
558    #[test]
559    fn export_default_named_function_keeps_explicit_name() {
560        let entries = walk("export default function foo() { return 1; }");
561        assert_eq!(entries.len(), 1);
562        assert_eq!(entries[0].name, "foo");
563    }
564
565    #[test]
566    fn export_default_anonymous_function_uses_counter() {
567        let entries = walk("export default function() { return 1; }");
568        assert_eq!(entries.len(), 1);
569        assert_eq!(entries[0].name, "(anonymous_0)");
570    }
571
572    #[test]
573    fn nested_function_numbered_after_parent_in_traversal_order() {
574        let entries = walk(
575            r"
576            function outer() {
577              return function() { return 1; };
578            }",
579        );
580        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
581        assert_eq!(names, vec!["outer", "(anonymous_1)"]);
582    }
583
584    #[test]
585    fn line_number_is_one_based_from_source_start() {
586        let entries = walk("\n\nfunction atLineThree() {}");
587        assert_eq!(entries.len(), 1);
588        assert_eq!(entries[0].line, 3);
589    }
590
591    #[test]
592    fn short_jsx_in_js_file_retries_with_jsx_parser() {
593        let entries = walk_source(&PathBuf::from("component.js"), "const A = () => <div />;");
594        assert_eq!(entries.len(), 1);
595        assert_eq!(entries[0].name, "A");
596        assert_eq!(entries[0].line, 1);
597    }
598
599    #[test]
600    fn object_method_shorthand_uses_anonymous_counter() {
601        let entries = walk("const obj = { run() { return 1; } };");
602        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
603        assert_eq!(names, vec!["(anonymous_0)"]);
604    }
605
606    #[test]
607    fn class_property_arrow_uses_anonymous_counter() {
608        let entries = walk(
609            r"
610            class Foo {
611              bar = () => 1;
612            }",
613        );
614        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
615        assert_eq!(names, vec!["(anonymous_0)"]);
616    }
617
618    #[test]
619    fn records_one_indexed_utf16_columns() {
620        let entries = walk("function foo() { return 1; }");
621        assert_eq!(entries.len(), 1);
622        assert_eq!(entries[0].start_column, 1);
623        assert_eq!(entries[0].end_line, 1);
624        assert!(entries[0].end_column > entries[0].start_column);
625    }
626
627    #[test]
628    fn utf16_column_counts_code_units_not_bytes() {
629        let entries = walk("const e = \"\u{1F600}\"; const f = () => 1;");
630        let f = entries.iter().find(|e| e.name == "f").expect("f present");
631        let byte_prefix_len = "const e = \"\u{1F600}\"; const f = ".len() as u32;
632        assert!(f.start_column < byte_prefix_len + 1);
633    }
634
635    #[test]
636    fn same_line_distinct_named_functions_have_distinct_positions() {
637        let entries = walk("function a() {} function b() {}");
638        let a = entries.iter().find(|e| e.name == "a").expect("a present");
639        let b = entries.iter().find(|e| e.name == "b").expect("b present");
640        assert_eq!(a.line, b.line, "both on line 1");
641        assert_ne!(
642            a.start_column, b.start_column,
643            "same-line functions are column-disambiguated"
644        );
645    }
646
647    #[test]
648    fn same_line_anonymous_functions_stay_distinct_via_counter() {
649        let entries = walk("const xs = [() => 1, () => 2];");
650        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
651        assert_eq!(names, vec!["(anonymous_0)", "(anonymous_1)"]);
652        assert_eq!(entries[0].line, entries[1].line, "both on line 1");
653        assert_ne!(
654            entries[0].name, entries[1].name,
655            "counter keeps them distinct"
656        );
657    }
658
659    #[test]
660    fn source_hash_is_the_content_digest_of_the_function_span() {
661        let src = "function foo() { return 1; }";
662        let entries = walk(src);
663        assert_eq!(entries.len(), 1);
664        assert_eq!(
665            entries[0].source_hash,
666            fallow_cov_protocol::source_hash_for(src.as_bytes())
667        );
668        assert_eq!(entries[0].source_hash.len(), 16);
669        assert!(
670            entries[0]
671                .source_hash
672                .chars()
673                .all(|c| c.is_ascii_hexdigit())
674        );
675    }
676
677    #[test]
678    fn source_hash_survives_line_moves_and_tracks_body_edits() {
679        let original = walk("function foo() { return 1; }");
680        let moved = walk("\n\nfunction foo() { return 1; }");
681        assert_eq!(
682            original[0].source_hash, moved[0].source_hash,
683            "a moved-but-unedited function must keep its source_hash"
684        );
685        let edited = walk("function foo() { return 2; }");
686        assert_ne!(
687            original[0].source_hash, edited[0].source_hash,
688            "an edited body must change the source_hash"
689        );
690    }
691}