@barefootjs/jinja 0.31.4 → 0.31.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/jinja",
3
- "version": "0.31.4",
3
+ "version": "0.31.5",
4
4
  "description": "Jinja2 adapter for BarefootJS — compiles IR to .jinja templates and ships the Python BarefootJS rendering runtime; runs under any Python web framework (Flask, etc.)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -53,7 +53,7 @@
53
53
  "directory": "packages/adapter-jinja"
54
54
  },
55
55
  "dependencies": {
56
- "@barefootjs/shared": "0.31.4"
56
+ "@barefootjs/shared": "0.31.5"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "@barefootjs/jsx": ">=0.2.0",
@@ -70,9 +70,9 @@
70
70
  },
71
71
  "devDependencies": {
72
72
  "@barefootjs/adapter-tests": "0.1.0",
73
- "@barefootjs/jsx": "0.31.4",
74
- "@barefootjs/vite": "0.31.4",
75
- "@barefootjs/client": "0.31.4",
73
+ "@barefootjs/jsx": "0.31.5",
74
+ "@barefootjs/vite": "0.31.5",
75
+ "@barefootjs/client": "0.31.5",
76
76
  "typescript": "^5.0.0",
77
77
  "vite": "^6.0.0"
78
78
  }
@@ -414,11 +414,12 @@ export function Parent() {
414
414
  // `ir.metadata.localConstants` with no notion of AST scope — it used to
415
415
  // substitute an outer const's literal value even at an occurrence that is
416
416
  // actually an enclosing loop callback's own (shadowing) parameter, so every
417
- // iteration rendered the same hard-coded literal. Guarded with the same
418
- // coarse `collectLoopBoundNames` exclusion as #2212: any name a loop binds
419
- // anywhere in the component never inlines, falling back to the bare
420
- // identifier. SSR-only tests for the same #2222 reason as the #2212
421
- // describe below.
417
+ // iteration rendered the same hard-coded literal. Guarded via the threaded,
418
+ // position-accurate `BindingScope` (#2482 Stage 2 previously a coarse
419
+ // whole-component exclusion, same as #2212's, that also suppressed a
420
+ // genuinely non-shadowed occurrence outside the loop; the threaded scope
421
+ // only shadows AT the enclosing loop's own body). SSR-only tests for the
422
+ // same #2222 reason as the #2212 describe below.
422
423
  describe('JinjaAdapter - const inlining vs loop-param shadowing (#2221)', () => {
423
424
  test('a loop param shadowing an outer literal const emits the identifier, not the const value', () => {
424
425
  const { template } = compileAndGenerate(`
@@ -457,11 +458,11 @@ function Widget({ values }: { values: number[] }) {
457
458
  expect(template).toContain('1 + 5')
458
459
  })
459
460
 
460
- // The accepted coarse-exclusion trade-off (same as #2212): a name that is
461
- // loop-bound ANYWHERE in the component never inlines, even at a genuinely
462
- // non-shadowed occurrence outside the loop the bare identifier is
463
- // emitted instead of the value.
464
- test('a const referenced outside the loop whose name is loop-bound elsewhere falls back to the identifier (accepted trade-off)', () => {
461
+ // Position-accurate scope (#2482 Stage 2): a name that is loop-bound
462
+ // elsewhere in the component but NOT at THIS occurrence still inlines
463
+ // here the coarse whole-component exclusion this used to hit (and
464
+ // accept as a trade-off) is gone; only the loop's own body shadows.
465
+ test('a const referenced outside the loop whose name is loop-bound elsewhere still inlines outside, stays the identifier inside', () => {
465
466
  const { template } = compileAndGenerate(`
466
467
  function Widget({ values }: { values: number[] }) {
467
468
  const label: string = 'x'
@@ -471,7 +472,7 @@ function Widget({ values }: { values: number[] }) {
471
472
  </div>
472
473
  }
473
474
  `)
474
- expect(template).not.toContain("1 + 'x'")
475
+ expect(template).toContain("1 + 'x'")
475
476
  expect(template).toContain('2 + label')
476
477
  })
477
478
  })
@@ -483,9 +484,8 @@ function Widget({ values }: { values: number[] }) {
483
484
  // substitute the outer const's member value even at an occurrence that is
484
485
  // actually an enclosing loop callback's own (shadowing) parameter, so every
485
486
  // iteration rendered the same hard-coded literal instead of the per-item
486
- // value. Guarded with the same coarse `staticLoopSourceBoundNames`
487
- // exclusion as #2221: any name a loop binds anywhere in the component
488
- // never inlines, falling back to the bare `cfg['x']` member expression.
487
+ // value. Guarded via the threaded, position-accurate `BindingScope` (#2482
488
+ // Stage 2), same as #2221's fix above.
489
489
  describe('JinjaAdapter - record-literal member lookup vs loop-param shadowing (#2237)', () => {
490
490
  test('a loop param shadowing an outer module object const emits the member access, not the outer literal', () => {
491
491
  const { template } = compileAndGenerate(`
@@ -510,12 +510,12 @@ function Widget({ variant }: { variant: 'solid' | 'ghost' }) {
510
510
  expect(template).toContain("bf.string('bg-ghost')")
511
511
  })
512
512
 
513
- // The accepted coarse-exclusion trade-off (same as #2221/#2212): an
514
- // object name that is loop-bound ANYWHERE in the component never
515
- // inlines its member lookups, even at a genuinely non-shadowed
516
- // occurrence outside the loop the bare member expression is emitted
517
- // instead of the value.
518
- test('a record member referenced outside the loop whose object name is loop-bound elsewhere falls back to the member expression (accepted trade-off)', () => {
513
+ // Position-accurate scope (#2482 Stage 2): an object name that is
514
+ // loop-bound elsewhere in the component but NOT at THIS occurrence still
515
+ // inlines its member lookup here the coarse whole-component exclusion
516
+ // this used to hit (and accept as a trade-off) is gone; only the loop's
517
+ // own body shadows.
518
+ test('a record member referenced outside the loop whose object name is loop-bound elsewhere still inlines outside, stays the member expression inside', () => {
519
519
  const { template } = compileAndGenerate(`
520
520
  const cfg = { x: 'outer-lit' }
521
521
  function Widget({ rows }: { rows: { x: string }[] }) {
@@ -525,11 +525,49 @@ function Widget({ rows }: { rows: { x: string }[] }) {
525
525
  </div>
526
526
  }
527
527
  `)
528
- expect(template).not.toContain("bf.string('outer-lit')")
528
+ expect(template).toContain("bf.string('outer-lit')")
529
529
  expect(template).toContain("bf.string(cfg['x'])")
530
530
  })
531
531
  })
532
532
 
533
+ // Copilot review on #2600 (#2482 Stage 2 follow-up): the position-accurate
534
+ // `this.scope` (replacing the old coarse `staticLoopSourceBoundNames` set)
535
+ // was only threaded around `renderChildren(loop.children)` in `renderLoop`
536
+ // — narrower than the coarse set's always-on coverage. Two row-context
537
+ // conversions sit OUTSIDE that window: a `.map()` preamble local's own
538
+ // initializer, and the whole-item-conditional `loop-i:` key anchor
539
+ // (`loop.key`). Both genuinely evaluate PER ROW and must see the row's own
540
+ // bindings — a loop param shadowing a same-named outer const must resolve
541
+ // to the row value there too, exactly as it already does inside the loop
542
+ // body proper. `renderLoop` now enters the row scope before converting
543
+ // either and pops it after, closing the gap.
544
+ describe('JinjaAdapter - row scope covers preamble/key conversions outside renderChildren (#2482 Stage 2 Copilot follow-up)', () => {
545
+ test('a loop param shadowing an outer const resolves to the row value inside a whole-item-conditional loop-i: key anchor', () => {
546
+ const { template } = compileAndGenerate(`
547
+ function Widget({ values }: { values: number[] }) {
548
+ const label: string = 'x'
549
+ return <ul>{values.map((label) => (label > 0 ? <li key={label}>{label}</li> : null))}</ul>
550
+ }
551
+ `)
552
+ expect(template).toContain('bf.comment("loop-i:" ~ bf.string(label))')
553
+ expect(template).not.toContain("bf.comment(\"loop-i:\" ~ bf.string('x'))")
554
+ })
555
+
556
+ test('a loop param shadowing an outer const resolves to the row value inside a .map() preamble local initializer', () => {
557
+ const { template } = compileAndGenerate(`
558
+ function Widget({ values }: { values: number[] }) {
559
+ const label: string = 'x'
560
+ return <ul>{values.map((label) => {
561
+ const cls = label
562
+ return <li key={label} class={cls}>{label}</li>
563
+ })}</ul>
564
+ }
565
+ `)
566
+ expect(template).toContain('{% set cls = label %}')
567
+ expect(template).not.toContain("{% set cls = 'x' %}")
568
+ })
569
+ })
570
+
533
571
  // A fragment-rooted component (top-level `<>...</>` return) has no single
534
572
  // wrapping element to bound the client's scope query, so `renderFragment`
535
573
  // brackets its children with a comment-based scope marker pair instead of
@@ -145,8 +145,8 @@ import {
145
145
  dangerousInnerHtmlMetacharViolation,
146
146
  dangerousInnerHtmlDiagnostic,
147
147
  resolveStaticLoopSource,
148
- collectLoopBoundNames,
149
148
  derivesScopeFromSlot,
149
+ BindingScope,
150
150
  } from '@barefootjs/jsx'
151
151
  import { isAriaBooleanAttr, isBooleanResultExpr, isExplicitStringCall } from './boolean-result.ts'
152
152
  import type { ParsedExpr, LoweringMatcher } from '@barefootjs/jsx'
@@ -272,30 +272,24 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
272
272
  private localConstants: IRMetadata['localConstants'] = []
273
273
 
274
274
  /**
275
- * Every name a `.map()`/`.filter()` loop callback binds as its item/index
276
- * parameter anywhere in the component (#2208 fable review). A static
277
- * loop-SOURCE name (e.g. a function-scope `const items = [...]`) must
278
- * never resolve through `resolveStaticLoopSource` at a use site where a
279
- * DIFFERENT, enclosing loop's own callback param shadows it — same
280
- * shadowing hazard, and same coarse-but-safe mitigation, as #2212's
281
- * `collectLoopBoundNames` use in `collectStringValueNames`.
275
+ * The one canonical, position-accurate "names bound by an enclosing loop
276
+ * callback" service (#2482 Stage 2) replaces the two ad-hoc devices
277
+ * this adapter used to carry side by side: a coarse whole-component
278
+ * shadow-name Set (built once at `generate()` entry, used only to guard
279
+ * static-const inlining) and a ref-counted, position-accurate live map
280
+ * (pushed/popped around `renderLoop`'s body, used for the boolean-prop/
281
+ * nullable-optional classification sites (#2488) and `emitSpread`'s
282
+ * local-const fallback (#2489)). Both consulted the "is this name
283
+ * loop-bound" question with DIFFERENT meanings — the coarse/live drift
284
+ * #2482 Stage 2 ends. Every shadow-guard site now reads this ONE
285
+ * threaded, immutable scope: `enterLoopRow`/pop-by-reference around
286
+ * `renderChildren(loop.children)` in `renderLoop`, mirroring the Stage
287
+ * 1a/1b `ctx.scope` precedent in `jsx-to-ir.ts`. `IRLoop` already
288
+ * structurally satisfies `LoopBindingSource`
289
+ * (`param`/`index`/`paramBindings`/`preamble`), so `renderLoop` passes
290
+ * the loop node straight to `enterLoopRow`.
282
291
  */
283
- private staticLoopSourceBoundNames: Set<string> = new Set()
284
-
285
- /**
286
- * Names currently bound by an enclosing loop body — the block-param
287
- * locals `renderLoop` introduces (item, index, per-binding destructure
288
- * fields, `.map()` preamble declarations) — ref-counted so nested loops
289
- * compose. This is the POSITION-ACCURATE twin of the coarse
290
- * `staticLoopSourceBoundNames` above: that set is a whole-component
291
- * union used only to guard static-const inlining (safe to over-suppress
292
- * there — the fallback is just "don't inline"), whereas the boolean-prop
293
- * and nullable-optional classification sites (#2488) and `emitSpread`'s
294
- * local-const fallback (#2489) need to know whether a name is loop-bound
295
- * AT THIS POSITION, because for those the coarse fallback would silently
296
- * mis-render a genuine occurrence of the same name outside the loop too.
297
- */
298
- private loopBoundNames: Map<string, number> = new Map()
292
+ private scope: BindingScope = BindingScope.EMPTY
299
293
 
300
294
  /**
301
295
  * Optional, no-default props that are `None` when the caller omits them.
@@ -329,8 +323,7 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
329
323
  // ("True"/"False") (#1897, pagination's data-active).
330
324
  this.booleanTypedProps = collectBooleanTypedProps(ir)
331
325
  this.localConstants = ir.metadata.localConstants ?? []
332
- this.staticLoopSourceBoundNames = collectLoopBoundNames(ir)
333
- this.loopBoundNames.clear()
326
+ this.scope = BindingScope.EMPTY
334
327
  this.nullableOptionalProps = collectNullableOptionalProps(ir)
335
328
  this.stringValueNames = collectStringValueNames(ir)
336
329
  this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
@@ -829,9 +822,11 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
829
822
  // bare identifier expression below, same as before #2208 — which
830
823
  // still trips the pre-existing BF101 gate for an unresolvable local
831
824
  // const reference (a loud, conservative refusal, not a silent wrong
832
- // value).
825
+ // value). Canonical, position-accurate predicate (#2482 Stage 2) — the
826
+ // enclosing loop scope's own membership, not a coarse whole-component
827
+ // union.
833
828
  const staticItems = resolveStaticLoopSource(loop.arrayParsed, this.localConstants, {
834
- isNameShadowed: name => this.staticLoopSourceBoundNames.has(name),
829
+ isNameShadowed: this.scope.asShadowPredicate(),
835
830
  })
836
831
  const staticArray = staticItems !== null ? staticValueToJinja(staticItems) : null
837
832
 
@@ -941,6 +936,34 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
941
936
  // loop-child naming convention). renderedChildren above was computed with
942
937
  // the previous flag; recompute under the loop flag.
943
938
 
939
+ // This loop's row scope, for the position-accurate shadow guard
940
+ // (#2488/#2489, canonicalized on `BindingScope` in #2482 Stage 2).
941
+ // `IRLoop` already structurally satisfies `LoopBindingSource`
942
+ // (`param`/`index`/`paramBindings`/`preamble`) — `enterLoopRow(loop)`
943
+ // binds exactly the for-header target(s), each destructure binding,
944
+ // the index (when present), and the `.map()` preamble's declared
945
+ // locals, mirroring what THIS renderLoop's for-header + `indexLocalLines`
946
+ // actually introduce. Restored by reference (immutable — no ref-count
947
+ // bookkeeping) so nested loops compose for free. Entered BEFORE
948
+ // `preambleLines`/`bodyChildren` are converted and popped AFTER, not
949
+ // just around `renderChildren` — Copilot review on #2600 caught that
950
+ // the narrower window silently regressed the module-const shadow guard
951
+ // (`_resolveLiteralConst`/`_resolveStaticRecordLiteral`, now
952
+ // scope-driven instead of the old coarse whole-component set) for two
953
+ // row-context conversions that sit OUTSIDE `renderChildren`: a
954
+ // `.map()` preamble local's own initializer (`d.raw`, below) and the
955
+ // whole-item-conditional `loop-i:` key anchor (`loop.key`, in
956
+ // `bodyChildren` below) — both genuinely evaluate PER ROW and must see
957
+ // the row's own bindings. `loop.filterPredicate` deliberately stays
958
+ // OUTSIDE this window (further down, after the pop): per the Stage 0
959
+ // design a filter/sort callback's own param is a separate `callback`
960
+ // frame, never folded into the `.map()` row — and empirically
961
+ // `JinjaFilterEmitter` (the filter predicate's dedicated,
962
+ // self-contained emitter) never consults `this.scope` at all, so its
963
+ // position relative to the pop is inert either way.
964
+ const prevScope = this.scope
965
+ this.scope = prevScope.enterLoopRow(loop)
966
+
944
967
  // Per-row locals for a `.map()` callback preamble (#2447), in source
945
968
  // order so a later initializer sees an earlier local — same as the
946
969
  // source block. Phase 1 refuses the loop outright when the preamble
@@ -949,39 +972,7 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
949
972
  const preambleLines = (loop.preamble?.declarations ?? []).map(
950
973
  d => `{% set ${jinjaIdent(d.name)} = ${this.convertExpressionToJinja(d.raw, d.valueParsed)} %}`,
951
974
  )
952
- // Names this loop binds in body scope, for the position-accurate
953
- // `loopBoundNames` guard (#2488) — mirrors the ERB adapter's `loopBound`
954
- // derivation, adapted to what THIS renderLoop actually binds: the
955
- // for-header target(s) (`loopVar` / `objectIteration` key+value /
956
- // `iterationShape === 'keys'`'s `param`), each `indexLocalLines` name
957
- // (explicit index, or a destructure binding), and the `.map()`
958
- // preamble's declared locals. Ref-counted so nested loops compose.
959
- const loopBound: string[] = []
960
- if (loop.objectIteration === 'entries') {
961
- loopBound.push(loop.index ?? param, param)
962
- } else if (loop.objectIteration === 'keys' || loop.objectIteration === 'values') {
963
- loopBound.push(param)
964
- } else if (loop.iterationShape === 'keys') {
965
- // The header still binds the throwaway loop var; `param` is only the
966
- // index alias set beneath it (#2488).
967
- loopBound.push('__bf_item', param)
968
- } else if (supportableDestructure) {
969
- loopBound.push('__bf_item', ...(loop.paramBindings ?? []).map(b => b.name))
970
- if (loop.index) loopBound.push(loop.index)
971
- } else {
972
- loopBound.push(param)
973
- if (loop.index) loopBound.push(loop.index)
974
- }
975
- for (const d of loop.preamble?.declarations ?? []) loopBound.push(d.name)
976
- for (const n of loopBound) {
977
- this.loopBoundNames.set(n, (this.loopBoundNames.get(n) ?? 0) + 1)
978
- }
979
975
  const childrenUnderLoop = this.renderChildren(loop.children)
980
- for (const n of loopBound) {
981
- const c = (this.loopBoundNames.get(n) ?? 1) - 1
982
- if (c <= 0) this.loopBoundNames.delete(n)
983
- else this.loopBoundNames.set(n, c)
984
- }
985
976
  this.currentLoopKeyDepth = prevLoopKeyDepth
986
977
  this.inLoop = prevInLoop
987
978
  void renderedChildren
@@ -989,11 +980,13 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
989
980
  // Whole-item conditional: prepend an always-present `<!--bf-loop-i:KEY-->`
990
981
  // anchor before each item's (possibly empty) conditional content so the
991
982
  // client's `mapArrayAnchored` can hydrate every SSR-rendered item by its
992
- // anchor.
983
+ // anchor. Still under the row scope (see above) — `loop.key` is a
984
+ // per-row expression.
993
985
  const bodyChildren =
994
986
  loop.bodyIsItemConditional && loop.key
995
987
  ? `{{ bf.comment("loop-i:" ~ bf.string(${this.convertExpressionToJinja(loop.key)})) | safe }}\n${childrenUnderLoop}`
996
988
  : childrenUnderLoop
989
+ this.scope = prevScope
997
990
 
998
991
  const lines: string[] = []
999
992
  // Scoped per-call-site marker so sibling `.map()`s under the same parent
@@ -1472,13 +1465,13 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
1472
1465
  // function-scope (`!isModule`) consts whose value is NOT itself a bare
1473
1466
  // identifier (loop guard) are considered.
1474
1467
  //
1475
- // `loopBoundNames` guard (#2489): an enclosing `.map()` callback's own
1476
- // param can shadow this outer const's name (`.map((attrs) => <p
1468
+ // `this.scope` shadow guard (#2489): an enclosing `.map()` callback's
1469
+ // own param can shadow this outer const's name (`.map((attrs) => <p
1477
1470
  // {...attrs} />)`) — without the guard this forwarded the OUTER
1478
1471
  // const's value at every iteration instead of the per-item value.
1479
- // Must be the LIVE map, not `staticLoopSourceBoundNames` — the same
1480
- // name spread at ROOT must still resolve the const.
1481
- if (/^[A-Za-z_$][\w$]*$/.test(trimmed) && !this.loopBoundNames.has(trimmed)) {
1472
+ // Must be the threaded, position-accurate scope — the same name
1473
+ // spread at ROOT (no enclosing loop) must still resolve the const.
1474
+ if (/^[A-Za-z_$][\w$]*$/.test(trimmed) && !this.scope.isBound(trimmed)) {
1482
1475
  const localConst = (this.localConstants ?? []).find(
1483
1476
  c => c.name === trimmed && !c.isModule,
1484
1477
  )
@@ -1894,9 +1887,9 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
1894
1887
  return this.booleanTypedProps.has(bare)
1895
1888
  }
1896
1889
 
1897
- /** Position-accurate loop-bound-name check — see `loopBoundNames`'s docstring. */
1890
+ /** Position-accurate loop-bound-name check — see `this.scope`'s docstring. */
1898
1891
  private isLoopBoundName(name: string): boolean {
1899
- return this.loopBoundNames.has(name)
1892
+ return this.scope.isBound(name)
1900
1893
  }
1901
1894
 
1902
1895
  /**
@@ -1923,17 +1916,16 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
1923
1916
  * context, so a bare reference would resolve to Undefined.
1924
1917
  *
1925
1918
  * The lookup is a flat name match with no notion of AST scope, so a
1926
- * name that any loop callback binds as its item/index param never
1927
- * inlines (#2221) — the occurrence may be the loop's own (shadowing)
1928
- * binding, and substituting the outer const's value there renders every
1929
- * iteration with the same hard-coded literal. Coarse (a genuinely
1930
- * non-shadowed same-named const elsewhere in the component also stops
1931
- * inlining, falling back to the bare identifier) but safe — the same
1932
- * trade-off as #2212's `collectLoopBoundNames` use in
1933
- * `collectStringValueNames`.
1919
+ * name bound by the CURRENTLY ENCLOSING loop's item/index/destructure/
1920
+ * preamble param never inlines (#2221) — the occurrence may be the
1921
+ * loop's own (shadowing) binding, and substituting the outer const's
1922
+ * value there renders every iteration with the same hard-coded literal.
1923
+ * Position-accurate via the threaded `this.scope` (#2482 Stage 2) — a
1924
+ * same-named const elsewhere in the component, outside any loop that
1925
+ * shadows it, still inlines.
1934
1926
  */
1935
1927
  private _resolveLiteralConst(name: string): string | null {
1936
- if (this.staticLoopSourceBoundNames.has(name)) return null
1928
+ if (this.scope.isBound(name)) return null
1937
1929
  const c = (this.localConstants ?? []).find(lc => lc.name === name)
1938
1930
  if (c?.value === undefined) return null
1939
1931
  const v = c.value.trim()
@@ -1951,15 +1943,16 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
1951
1943
  * scope, so an enclosing loop callback's own param of the same name
1952
1944
  * (`.map((cfg) => <li>{cfg.x}</li>)` shadowing a module `const cfg = {…}`)
1953
1945
  * still resolved to the OUTER const's member value at every iteration
1954
- * (#2237) — the sibling hazard to #2221's `_resolveLiteralConst`. Same
1955
- * coarse-but-safe `staticLoopSourceBoundNames` guard: any name a loop
1956
- * binds anywhere in the component never inlines, falling back to the bare
1946
+ * (#2237) — the sibling hazard to #2221's `_resolveLiteralConst`.
1947
+ * Position-accurate via the threaded `this.scope` (#2482 Stage 2), passed
1948
+ * as `lookupStaticRecordLiteral`'s required guard: a name the CURRENTLY
1949
+ * ENCLOSING loop binds never inlines, falling back to the bare
1957
1950
  * `cfg['x']` member expression (which a Jinja for-loop binds correctly
1958
- * at the shadowed occurrences).
1951
+ * at the shadowed occurrence); a same-named const elsewhere, outside any
1952
+ * loop that shadows it, still inlines.
1959
1953
  */
1960
1954
  private _resolveStaticRecordLiteral(objectName: string, key: string): string | null {
1961
- if (this.staticLoopSourceBoundNames.has(objectName)) return null
1962
- const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
1955
+ const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants, name => this.scope.isBound(name))
1963
1956
  if (!hit) return null
1964
1957
  return hit.kind === 'number'
1965
1958
  ? hit.text