Skip to content

Commit 0e7e4e9

Browse files
committed
output suggestions on function name misses
1 parent 32b5921 commit 0e7e4e9

3 files changed

Lines changed: 87 additions & 3 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,11 @@ Hot lines (self time): (per source line; includes inlined code)
7373
### Drilling down
7474

7575
```bash
76-
flamebearer trace.json --stacks parseConfig # callers, callees, hot paths, and hot lines for one function
76+
flamebearer trace.json --stacks load # summary for a specific function
7777
flamebearer trace.json --thread main --top 30 # restrict threads, more rows
7878
flamebearer trace.json --from 1200 --to 1800 # slice a time range (ms)
7979

80-
flamebearer-node bench.js arg1 arg2 -- --top 30 --thread main # pass drilldown flags to the Node wrapper
80+
flamebearer-node bench.js arg1 -- --stacks load # pass drilldown flags to the Node wrapper
8181
```
8282

8383
Run `flamebearer --help` for the full flag list.

index.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,54 @@ export function topPaths(thread, {cutoffPct = 5, maxDepth = 10, maxBranch = 3, b
429429
return buildHotTree(thread, systemParents(thread), cutoff, totalsCache(thread), {maxDepth, maxBranch, budget});
430430
}
431431

432+
// Levenshtein edit distance, capped early — names are short so a full DP table is cheap.
433+
function editDistance(a, b) {
434+
const m = a.length, n = b.length;
435+
if (!m) return n;
436+
if (!n) return m;
437+
let prev = Array.from({length: n + 1}, (_, j) => j);
438+
let cur = new Array(n + 1);
439+
for (let i = 1; i <= m; i++) {
440+
cur[0] = i;
441+
for (let j = 1; j <= n; j++) {
442+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
443+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
444+
}
445+
[prev, cur] = [cur, prev];
446+
}
447+
return prev[n];
448+
}
449+
450+
// Nearest displayed function names to a missed --stacks query, ranked by relevance: substring
451+
// hits first (the common near-miss), then by edit distance, tie-broken by self time so hot
452+
// functions win. Powers the no-match hint so an agent's mistyped name answers in one round-trip.
453+
export function suggestNames(trace, pattern, limit = 3) {
454+
const needle = pattern.toLowerCase();
455+
const byName = new Map();
456+
for (const t of trace.threads) {
457+
if (!t.nodes) continue;
458+
for (const n of t.nodes.values()) {
459+
const f = n.callFrame;
460+
if (!f) continue;
461+
const name = f.functionName || '(anonymous)';
462+
if (SYSTEM_NAMES.has(name) || name === '(anonymous)') continue;
463+
const self = (t.nodeSelfTime?.get(n.id) ?? 0);
464+
byName.set(name, (byName.get(name) ?? 0) + self);
465+
}
466+
}
467+
const scored = [];
468+
for (const [name, self] of byName) {
469+
const lower = name.toLowerCase();
470+
const dist = editDistance(needle, lower);
471+
const substring = lower.includes(needle) || needle.includes(lower);
472+
// skip names that are neither a substring relation nor reasonably close
473+
if (!substring && dist > Math.max(2, Math.ceil(needle.length / 2))) continue;
474+
scored.push({name, self, substring, dist});
475+
}
476+
scored.sort((a, b) => (b.substring - a.substring) || (a.dist - b.dist) || (b.self - a.self));
477+
return scored.slice(0, limit).map(s => s.name);
478+
}
479+
432480
export function findStacks(thread, pattern) {
433481
const {nodes, nodeParent, nodeChildren, nodeSelfTime} = thread;
434482
if (!nodes) return [];
@@ -754,6 +802,16 @@ export function formatReport(input, {top = 20, color = false, sourceMaps = true,
754802
for (const line of table(rows, hasTags ? ['left', 'left'] : ['left'])) out.push(line);
755803
}
756804

805+
if (stacks && !stackResults.some(g => g.length)) {
806+
// Principle 7: a silent empty report reads as "function isn't hot." Say it's a miss,
807+
// and answer the likely near-miss in the same round-trip.
808+
const suggestions = suggestNames(trace, stacks);
809+
out.push(suggestions.length ?
810+
`no exact match for "${stacks}"; closest: ${suggestions.join(', ')}` :
811+
`no exact match for "${stacks}"`);
812+
return out.join('\n');
813+
}
814+
757815
for (let ti = 0; ti < trace.threads.length; ti++) {
758816
const t = trace.threads[ti];
759817
const totalUs = t.busy + t.idle;

test/index.test.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {test} from 'node:test';
22
import assert from 'node:assert/strict';
33
import fs from 'node:fs';
44
import {TraceMap} from '@jridgewell/trace-mapping';
5-
import {parseInput, parseTrace, parseCpuProfile, buildShortener, formatReport, resolveSourceMaps, findStacks, topPaths} from '../index.js';
5+
import {parseInput, parseTrace, parseCpuProfile, buildShortener, formatReport, resolveSourceMaps, findStacks, topPaths, suggestNames} from '../index.js';
66

77
const tinyCpuProfile = {
88
nodes: [
@@ -230,6 +230,32 @@ test('findStacks aggregates callers and callees of matching frames', () => {
230230
assert.deepEqual(findStacks(t, 'nonexistent'), []);
231231
});
232232

233+
test('suggestNames returns nearest names for a missed --stacks query', () => {
234+
const profile = {
235+
nodes: [
236+
{id: 1, callFrame: {functionName: '(root)', url: '', lineNumber: -1, columnNumber: -1}, children: [2, 3, 4]},
237+
{id: 2, callFrame: {functionName: 'withinInto', url: 'a.js', lineNumber: 0, columnNumber: 0}},
238+
{id: 3, callFrame: {functionName: 'sqDist', url: 'a.js', lineNumber: 1, columnNumber: 0}},
239+
{id: 4, callFrame: {functionName: '(anonymous)', url: 'a.js', lineNumber: 2, columnNumber: 0}}
240+
],
241+
samples: [2, 3, 4],
242+
timeDeltas: [1000, 1000, 1000],
243+
startTime: 0
244+
};
245+
const trace = {threads: [parseCpuProfile(profile, 'tt')]};
246+
247+
// substring near-miss surfaces the containing name
248+
assert.deepEqual(suggestNames(trace, 'within'), ['withinInto']);
249+
// typo within edit-distance budget
250+
assert.deepEqual(suggestNames(trace, 'sqdis'), ['sqDist']);
251+
// system frames and (anonymous) are never suggested; gibberish yields nothing
252+
assert.deepEqual(suggestNames(trace, 'zzzqqq'), []);
253+
254+
// end-to-end: the no-match report says it's a miss and names the closest
255+
const report = formatReport(trace, {stacks: 'within'});
256+
assert.match(report, /no exact match for "within"; closest: withinInto/);
257+
});
258+
233259
test('topPaths builds a heaviest-stacks tree rooted at real (non-system) entries', () => {
234260
// root -> render -> renderLayer -> drawLine (dominant)
235261
// -> drawSymbols

0 commit comments

Comments
 (0)