@@ -439,12 +439,21 @@ function getUnsupportedShellPosition(
439439 return undefined
440440}
441441
442+ function heredocBodyRanges ( heredocs : HeredocDeclaration [ ] ) : Array < [ number , number ] > {
443+ return heredocs . map ( ( heredoc ) => [ heredoc . bodyStart , heredoc . removalEnd ] )
444+ }
445+
446+ /**
447+ * Jumps over `skippedRanges` (sorted heredoc bodies, which bash reads as data) so body prose
448+ * cannot shift quote context; bodies that need contexts are scanned on their own with `literalRoot`.
449+ */
442450function collectShellOccurrenceContexts (
443451 code : string ,
444452 occurrences : CodePlaceholderOccurrence [ ] ,
445453 start : number ,
446454 end : number ,
447- literalRoot : boolean
455+ literalRoot : boolean ,
456+ skippedRanges : Array < [ number , number ] > = [ ]
448457) : Map < CodePlaceholderOccurrence , ShellOccurrenceContext > {
449458 const occurrenceByStart = new Map (
450459 occurrences
@@ -455,11 +464,24 @@ function collectShellOccurrenceContexts(
455464 const frames : ShellScanFrame [ ] = [
456465 { kind : 'root' , quote : 'none' , parenthesisDepth : 0 , literalRoot } ,
457466 ]
467+ let skippedRangeIndex = 0
458468
459469 for ( let index = start ; index < end ; ) {
460470 const frame = frames . at ( - 1 )
461471 if ( ! frame ) break
462472
473+ while (
474+ skippedRangeIndex < skippedRanges . length &&
475+ skippedRanges [ skippedRangeIndex ] [ 1 ] <= index
476+ ) {
477+ skippedRangeIndex += 1
478+ }
479+ const skippedRange = skippedRanges . at ( skippedRangeIndex )
480+ if ( skippedRange && index >= skippedRange [ 0 ] ) {
481+ index = skippedRange [ 1 ]
482+ continue
483+ }
484+
463485 const occurrence = occurrenceByStart . get ( index )
464486 if ( occurrence ) {
465487 contexts . set ( occurrence , { quote : frame . quote } )
@@ -635,36 +657,46 @@ function recordShellDirectEnvironmentReads(
635657 }
636658 if ( matches . length === 0 ) return
637659
638- /**
639- * A heredoc with a quoted delimiter (`<<'EOF'`) is literal, so nothing in its body expands.
640- * The frame scanner below models quoting within a line, not heredoc bodies, so those are
641- * excluded up front — otherwise a `$NAME` printed verbatim would be reported as a read that
642- * never happened, and a usage trail must not claim uses that did not occur.
643- */
644- const literalHeredocBodies = collectHeredocs ( code )
645- . filter ( ( heredoc ) => heredoc . quoted )
646- . map ( ( heredoc ) : [ number , number ] => [ heredoc . bodyStart , heredoc . bodyEnd ] )
647-
648- const candidates : CodePlaceholderOccurrence [ ] = [ ]
649- for ( const candidate of matches ) {
650- if ( isOffsetInRanges ( candidate . index , literalHeredocBodies ) ) continue
651- candidates . push ( {
660+ const candidates = matches . map (
661+ ( candidate ) : CodePlaceholderOccurrence => ( {
652662 start : candidate . index ,
653663 end : candidate . index + candidate [ 0 ] . length ,
654664 raw : candidate [ 0 ] ,
655665 name : ( candidate [ 1 ] ?? candidate [ 2 ] ) as string ,
656666 } )
657- }
658- if ( candidates . length === 0 ) return
667+ )
659668
660- const contexts = collectShellOccurrenceContexts ( code , candidates , 0 , code . length , false )
669+ const heredocs = collectHeredocs ( code )
670+ const contexts = collectShellOccurrenceContexts (
671+ code ,
672+ candidates ,
673+ 0 ,
674+ code . length ,
675+ false ,
676+ heredocBodyRanges ( heredocs )
677+ )
678+ /**
679+ * A heredoc with a quoted delimiter (`<<'EOF'`) is literal, so nothing in its body expands
680+ * and its candidates stay contextless — a usage trail must not claim uses that did not occur.
681+ */
682+ for ( const heredoc of heredocs ) {
683+ if ( heredoc . quoted ) continue
684+ const bodyContexts = collectShellOccurrenceContexts (
685+ code ,
686+ candidates ,
687+ heredoc . bodyStart ,
688+ heredoc . bodyEnd ,
689+ true
690+ )
691+ for ( const [ candidate , shellContext ] of bodyContexts ) contexts . set ( candidate , shellContext )
692+ }
661693 for ( const candidate of candidates ) {
662694 const shellContext = contexts . get ( candidate )
663695 /**
664696 * No context means the scanner never reached this offset — it skipped the region as a
665- * comment. Absence is therefore evidence the expansion does not run, not permission to
666- * record it, so this reads as an allowlist rather than a denylist. Single quotes suppress
667- * expansion outright.
697+ * comment or a quoted heredoc body . Absence is therefore evidence the expansion does not
698+ * run, not permission to record it, so this reads as an allowlist rather than a denylist.
699+ * Single quotes suppress expansion outright.
668700 */
669701 if ( ! shellContext || shellContext . quote === 'single' ) continue
670702 context . recordDirectEnvironmentRead ( candidate . name , candidate . start )
@@ -828,7 +860,8 @@ export async function compileShellPlaceholders(
828860 rootOccurrences ,
829861 0 ,
830862 input . code . length ,
831- false
863+ false ,
864+ heredocBodyRanges ( heredocs )
832865 )
833866 for ( const occurrence of rootOccurrences ) {
834867 const occurrenceContext = rootContexts . get ( occurrence )
0 commit comments