@@ -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+
432480export 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 ;
0 commit comments