@barefootjs/xslate 0.17.0 → 0.17.1

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/dist/index.js CHANGED
@@ -187291,12 +187291,12 @@ Additional information: BADCLIENT: Bad error code, ${badCode} not found in range
187291
187291
  import {
187292
187292
  BaseAdapter,
187293
187293
  isBooleanAttr,
187294
- parseExpression as parseExpression3,
187294
+ parseExpression as parseExpression2,
187295
187295
  stringifyParsedExpr as stringifyParsedExpr2,
187296
187296
  exprToString,
187297
187297
  parseProviderObjectLiteral,
187298
187298
  parseStyleObjectEntries,
187299
- isSupported as isSupported2,
187299
+ isSupported,
187300
187300
  emitParsedExpr,
187301
187301
  emitIRNode,
187302
187302
  emitAttrValue,
@@ -187417,13 +187417,6 @@ function collectRootScopeNodes(node) {
187417
187417
  visit(node);
187418
187418
  return out;
187419
187419
  }
187420
- function referencedVarsAreAvailable(expr, available) {
187421
- for (const m of expr.matchAll(/\$([A-Za-z_]\w*)/g)) {
187422
- if (!available.has(m[1]))
187423
- return false;
187424
- }
187425
- return true;
187426
- }
187427
187420
 
187428
187421
  // src/adapter/expr/array-method.ts
187429
187422
  import {
@@ -187593,6 +187586,13 @@ function renderFlatMapEval(recv, body, param, emit) {
187593
187586
  const env = emitEvalEnvArg(body, [param], emit);
187594
187587
  return `$bf.flat_map_eval(${recv}, '${escapePerlSingleQuote(json)}', '${param}', ${env})`;
187595
187588
  }
187589
+ function renderMapEval(recv, body, param, emit) {
187590
+ const json = serializeParsedExpr(body);
187591
+ if (json === null)
187592
+ return null;
187593
+ const env = emitEvalEnvArg(body, [param], emit);
187594
+ return `$bf.map_eval(${recv}, '${escapePerlSingleQuote(json)}', '${param}', ${env})`;
187595
+ }
187596
187596
  function renderSortMethod(recv, c) {
187597
187597
  const keyHashes = c.keys.map((k) => {
187598
187598
  const keyEntry = k.key.kind === "self" ? `key_kind => 'self'` : `key_kind => 'field', key => '${k.key.field}'`;
@@ -187625,10 +187625,12 @@ class XslateFilterEmitter {
187625
187625
  param;
187626
187626
  localVarMap;
187627
187627
  isStringName;
187628
- constructor(param, localVarMap, isStringName = () => false) {
187628
+ onUnsupported;
187629
+ constructor(param, localVarMap, isStringName = () => false, onUnsupported) {
187629
187630
  this.param = param;
187630
187631
  this.localVarMap = localVarMap;
187631
187632
  this.isStringName = isStringName;
187633
+ this.onUnsupported = onUnsupported;
187632
187634
  }
187633
187635
  identifier(name) {
187634
187636
  if (name === this.param)
@@ -187699,6 +187701,7 @@ class XslateFilterEmitter {
187699
187701
  return `(${l} // ${r})`;
187700
187702
  }
187701
187703
  callbackMethod(method, object, _arrow, _restArgs, emit) {
187704
+ this.onUnsupported?.(`Filter predicate contains a nested '.${method}(...)' callback, which has no Kolon scalar form`, `Rewrite the predicate without a nested callback method, or add /* @client */ for client-only evaluation (no SSR).`);
187702
187705
  return emit(object);
187703
187706
  }
187704
187707
  arrayLiteral(elements, emit) {
@@ -187859,6 +187862,13 @@ class XslateTopLevelEmitter {
187859
187862
  this.ctx._recordExprBF101(`'.flatMap(...)' projection is outside the Xslate adapter's evaluable surface`, `Pre-compute the projected array, or move this position to a '/* @client */' boundary.`);
187860
187863
  return "''";
187861
187864
  }
187865
+ if (method === "map") {
187866
+ const evalForm = renderMapEval(recv, body, params[0], emit);
187867
+ if (evalForm !== null)
187868
+ return evalForm;
187869
+ this.ctx._recordExprBF101(`'.map(...)' projection is outside the Xslate adapter's evaluable surface`, `Pre-compute the projected array, or move this position to a '/* @client */' boundary.`);
187870
+ return "''";
187871
+ }
187862
187872
  return recv;
187863
187873
  }
187864
187874
  _emitPredicateCallback(call, arrayExpr, emit) {
@@ -188092,9 +188102,7 @@ function recordIndexAccessToKolon(ctx, val) {
188092
188102
  // src/adapter/memo/seed.ts
188093
188103
  import {
188094
188104
  collectContextConsumers,
188095
- extractArrowBodyExpression,
188096
- isSupported,
188097
- parseExpression as parseExpression2
188105
+ computeSsrSeedPlan
188098
188106
  } from "@barefootjs/jsx";
188099
188107
  function contextDefaultKolon(c) {
188100
188108
  const d = c.defaultValue;
@@ -188115,44 +188123,22 @@ function generateContextConsumerSeed(ir) {
188115
188123
  `;
188116
188124
  }
188117
188125
  function generateDerivedMemoSeed(ctx, ir) {
188118
- const memos = ir.metadata.memos ?? [];
188119
- const signals = ir.metadata.signals ?? [];
188120
- if (memos.length === 0 && signals.length === 0)
188121
- return "";
188122
- const available = new Set(ir.metadata.propsParams.map((p) => p.name));
188126
+ const plan = ir.metadata.ssrSeedPlan ?? computeSsrSeedPlan(ir.metadata);
188123
188127
  const lines = [];
188124
- for (const signal of signals) {
188125
- const kolon = tryLowerToKolon(ctx, signal.initialValue, available);
188126
- const refsSelf = kolon !== null && new RegExp(`\\$${signal.getter}\\b`).test(kolon);
188127
- if (kolon !== null && !refsSelf)
188128
- lines.push(`: my $${signal.getter} = ${kolon};`);
188129
- available.add(signal.getter);
188130
- }
188131
- for (const memo of memos) {
188132
- const body = extractArrowBodyExpression(memo.computation);
188133
- if (body !== null) {
188134
- const kolon = tryLowerToKolon(ctx, body, available);
188135
- const refsSelf = kolon !== null && new RegExp(`\\$${memo.name}\\b`).test(kolon);
188136
- if (kolon !== null && !refsSelf)
188137
- lines.push(`: my $${memo.name} = ${kolon};`);
188138
- }
188139
- available.add(memo.name);
188128
+ for (const step of plan.steps) {
188129
+ if (step.kind !== "derived")
188130
+ continue;
188131
+ const kolon = ctx.convertExpressionToKolon(step.expr, step.parsed);
188132
+ if (kolon === "" || !/\$[A-Za-z_]\w*/.test(kolon))
188133
+ continue;
188134
+ if (new RegExp(`\\$${step.name}\\b`).test(kolon))
188135
+ continue;
188136
+ lines.push(`: my $${step.name} = ${kolon};`);
188140
188137
  }
188141
188138
  return lines.length > 0 ? lines.join(`
188142
188139
  `) + `
188143
188140
  ` : "";
188144
188141
  }
188145
- function tryLowerToKolon(ctx, expr, available) {
188146
- const trimmed = expr.trim();
188147
- if (!trimmed)
188148
- return null;
188149
- if (!isSupported(parseExpression2(trimmed)).supported)
188150
- return null;
188151
- const kolon = ctx.convertExpressionToKolon(trimmed);
188152
- if (kolon === "" || !/\$[A-Za-z_]\w*/.test(kolon))
188153
- return null;
188154
- return referencedVarsAreAvailable(kolon, available) ? kolon : null;
188155
- }
188156
188142
 
188157
188143
  // src/adapter/value/parsed-literal.ts
188158
188144
  import { evalStringArrayJoin } from "@barefootjs/jsx";
@@ -188452,8 +188438,9 @@ ${whenTrue}
188452
188438
  return `<: $bf.comment("cond-start:${condId}") | mark_raw :>${content}<: $bf.comment("cond-end:${condId}") | mark_raw :>`;
188453
188439
  }
188454
188440
  renderLoop(loop) {
188455
- if (loop.clientOnly)
188456
- return "";
188441
+ if (loop.clientOnly) {
188442
+ return `<: $bf.comment("loop:${loop.markerId}") | mark_raw :><: $bf.comment("/loop:${loop.markerId}") | mark_raw :>`;
188443
+ }
188457
188444
  const destructure = !!(loop.paramBindings && loop.paramBindings.length > 0);
188458
188445
  const supportableDestructure = destructure && isLowerableObjectRestDestructure(loop);
188459
188446
  if (destructure && !supportableDestructure) {
@@ -188709,7 +188696,7 @@ ${name}="<: ${val} :>"
188709
188696
  if (localConst?.value !== undefined) {
188710
188697
  const initTrimmed = localConst.value.trim();
188711
188698
  if (!/^[A-Za-z_$][\w$]*$/.test(initTrimmed)) {
188712
- const resolved = conditionalSpreadToKolon(this.spreadCtx, parseExpression3(initTrimmed));
188699
+ const resolved = conditionalSpreadToKolon(this.spreadCtx, parseExpression2(initTrimmed));
188713
188700
  if (resolved !== null) {
188714
188701
  return `<: $bf.spread_attrs(${resolved}) | mark_raw :>`;
188715
188702
  }
@@ -188727,7 +188714,7 @@ ${name}="<: ${val} :>"
188727
188714
  if (!entries)
188728
188715
  return null;
188729
188716
  for (const e of entries) {
188730
- if (e.kind === "expr" && !isSupported2(parseExpression3(e.expr)).supported)
188717
+ if (e.kind === "expr" && !isSupported(parseExpression2(e.expr)).supported)
188731
188718
  return null;
188732
188719
  }
188733
188720
  return entries.map((e) => e.kind === "literal" ? `${this.escapeAttrText(e.cssKey)}:${this.escapeAttrText(e.value)}` : `${this.escapeAttrText(e.cssKey)}:<: ${this.convertExpressionToKolon(e.expr)} :>`).join(";");
@@ -188763,7 +188750,7 @@ ${name}="<: ${val} :>"
188763
188750
  return `${BF_COND}="${condId}"`;
188764
188751
  }
188765
188752
  renderKolonFilterExpr(expr, param, localVarMap = new Map) {
188766
- return emitParsedExpr(expr, new XslateFilterEmitter(param, localVarMap, (n) => this._isStringValueName(n)));
188753
+ return emitParsedExpr(expr, new XslateFilterEmitter(param, localVarMap, (n) => this._isStringValueName(n), (message, reason) => this._recordExprBF101(message, reason)));
188767
188754
  }
188768
188755
  convertTemplateLiteralPartsToKolon(literalParts) {
188769
188756
  const parts = [];
@@ -188808,8 +188795,8 @@ ${name}="<: ${val} :>"
188808
188795
  const hasTaggedTemplate = /[A-Za-z_$][\w$]*\s*`/.test(probe);
188809
188796
  if (!startsAsObjectLiteral && !hasTaggedTemplate)
188810
188797
  return false;
188811
- const parsed = parseExpression3(expr.trim());
188812
- const support = isSupported2(parsed);
188798
+ const parsed = parseExpression2(expr.trim());
188799
+ const support = isSupported(parsed);
188813
188800
  if (parsed.kind !== "unsupported" && support.supported)
188814
188801
  return false;
188815
188802
  const reason = support.reason ?? (parsed.kind === "unsupported" ? parsed.reason : undefined);
@@ -188856,7 +188843,7 @@ ${reason}` : "";
188856
188843
  const trimmed = expr.trim();
188857
188844
  if (trimmed === "")
188858
188845
  return "''";
188859
- parsed = parseExpression3(trimmed);
188846
+ parsed = parseExpression2(trimmed);
188860
188847
  }
188861
188848
  if (parsed.kind === "call") {
188862
188849
  for (const matcher of this._loweringMatchers) {
@@ -188867,7 +188854,7 @@ ${reason}` : "";
188867
188854
  }
188868
188855
  }
188869
188856
  }
188870
- const support = isSupported2(parsed);
188857
+ const support = isSupported(parsed);
188871
188858
  if (!support.supported) {
188872
188859
  this.errors.push({
188873
188860
  code: "BF101",
@@ -188895,7 +188882,7 @@ Options:
188895
188882
  return this.stringValueNames.has(name);
188896
188883
  }
188897
188884
  parseUndefinedAlternateTernary(expr) {
188898
- const parsed = parseExpression3(expr.trim());
188885
+ const parsed = parseExpression2(expr.trim());
188899
188886
  if (parsed?.kind !== "conditional")
188900
188887
  return null;
188901
188888
  const alt = parsed.alternate;
@@ -1,5 +1,5 @@
1
1
  package BarefootJS::Backend::Xslate;
2
- our $VERSION = "0.16.0";
2
+ our $VERSION = "0.17.0";
3
3
  use strict;
4
4
  use warnings;
5
5
  use utf8;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/xslate",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Text::Xslate (Kolon) adapter for BarefootJS — compiles IR to .tx templates and ships the Xslate rendering backend; runs under any PSGI/Plack app",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -55,14 +55,14 @@
55
55
  "directory": "packages/adapter-xslate"
56
56
  },
57
57
  "dependencies": {
58
- "@barefootjs/shared": "0.17.0"
58
+ "@barefootjs/shared": "0.17.1"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@barefootjs/jsx": ">=0.2.0"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@barefootjs/adapter-tests": "0.1.0",
65
- "@barefootjs/jsx": "0.17.0",
65
+ "@barefootjs/jsx": "0.17.1",
66
66
  "typescript": "^5.0.0"
67
67
  }
68
68
  }
@@ -101,10 +101,29 @@ runAdapterConformanceTests({
101
101
  // Tagged-template-literal call in a className — same family, same
102
102
  // refusal (BF101).
103
103
  'tagged-template-classname': [{ code: 'BF101', severity: 'error' }],
104
- // NB: `.find` / `.findIndex` / `.findLast` / `.findLastIndex` are NOT
105
- // pinned here unlike mojo (which refuses them), Xslate lowers them to
106
- // `$bf.find` / `find_index` / `find_last` / `find_last_index` via the same
107
- // Kolon-lambda mechanism as `.filter` / `.every` / `.some`, so they render.
104
+ // #2038: a filter predicate whose body contains a NESTED callback call
105
+ // (`t => !picked().some(p => …)` / `t => picked().find(p => …)`). Kolon
106
+ // has no inline `grep` form, so `XslateFilterEmitter.callbackMethod` used
107
+ // to degrade the inner call to its receiver, silently changing predicate
108
+ // semantics — the compiler is loud instead of lossy. (Mojo is pinned only
109
+ // for the `.find` variant: it lowers a nested `.some` to a real inline
110
+ // Perl `grep`.) The `/* @client */` twin
111
+ // (`filter-nested-callback-predicate-client`) has no pin here: it must
112
+ // render clean on every adapter, which asserts the suppression contract.
113
+ // https://github.com/piconic-ai/barefootjs/issues/2038
114
+ 'filter-nested-callback-predicate': [{ code: 'BF101', severity: 'error' }],
115
+ 'filter-nested-find-predicate': [{ code: 'BF101', severity: 'error' }],
116
+ // NB: TOP-LEVEL `.find` / `.findIndex` / `.findLast` / `.findLastIndex`
117
+ // (text position) are NOT pinned here — unlike mojo (which refuses them),
118
+ // Xslate lowers them to `$bf.find` / `find_index` / `find_last` /
119
+ // `find_last_index` via the same Kolon-lambda mechanism as `.filter` /
120
+ // `.every` / `.some`, so they render. Only the NESTED-in-a-predicate form
121
+ // above is refused (#2038).
122
+ // #2073 follow-up: a function-reference `.map(format)` callback has no
123
+ // arrow body to serialize — not a CALLBACK_METHODS shape — so the
124
+ // UNSUPPORTED_METHODS gate refuses it with BF101 rather than emitting
125
+ // a broken template.
126
+ 'array-map-function-reference': [{ code: 'BF101', severity: 'error' }],
108
127
  },
109
128
  // Template-primitive registry parity: same V1 surface as mojo, so the
110
129
  // same two cases stay skipped (bespoke user import + customSerialize
@@ -113,11 +132,15 @@ runAdapterConformanceTests({
113
132
  TemplatePrimitiveCaseId.USER_IMPORT_VIA_CONST,
114
133
  TemplatePrimitiveCaseId.NO_DOUBLE_REWRITE_OF_PROPS_OBJECT,
115
134
  ]),
116
- // Loop boundary markers for `@client` loops aren't emitted by the
117
- // Xslate adapter yet (ported from mojo, which skips the same set).
135
+ // `client-only` / `client-only-loop-with-sibling-cond` /
136
+ // `filter-nested-callback-predicate-client` are no longer skipped
137
+ // `renderLoop` now emits the `$bf.comment("loop:<id>")` boundary pair
138
+ // for clientOnly loops (Hono / Go parity), so mapArray() can locate
139
+ // its insertion anchor at hydration time (#872 / #1087).
118
140
  skipMarkerConformance: new Set([
119
- 'client-only',
120
- 'client-only-loop-with-sibling-cond',
141
+ // Same as Hono / Mojo: `/* @client */` markers on TodoApp's keyed
142
+ // `.map` intentionally elide a slot id from the SSR template that
143
+ // the IR still declares (s6). See hono-adapter.test for the contract.
121
144
  'todo-app',
122
145
  // #1467 Phase 2e: same `/* @client */` keyed-map elision (data-table).
123
146
  'data-table',
@@ -296,6 +319,98 @@ export function C() {
296
319
  // (`$bf.*_eval`), isomorphic with the Go / Mojo `*_eval` helpers. A predicate
297
320
  // the evaluator can't model (a method-call predicate) falls back to the Kolon
298
321
  // lambda runtime call. Template-text pins guard against silent divergence.
322
+ describe('XslateAdapter - #2075 searchParams()-derived memo seeding', () => {
323
+ test('seeds an aliased scalar derived memo from the canonical reader', () => {
324
+ const { template } = compileAndGenerate(`
325
+ 'use client'
326
+ import { createMemo, createSearchParams } from '@barefootjs/client'
327
+ export function SortStatus() {
328
+ const [sp] = createSearchParams()
329
+ const sort = createMemo(() => sp().get('sort') ?? 'date')
330
+ return <p>sort: {sort()}</p>
331
+ }
332
+ `)
333
+ expect(template).toContain(": my $sort = ($searchParams.get('sort') // 'date');")
334
+ })
335
+
336
+ // The Kolon lambda param and the `$bf` runtime object are
337
+ // lowering-internal, not out-of-scope template vars (#2075).
338
+ test('seeds a filter memo chained off the derived memo', () => {
339
+ const { template } = compileAndGenerate(`
340
+ 'use client'
341
+ import { createMemo, createSearchParams } from '@barefootjs/client'
342
+ export function TaggedList(props: { items: { title: string; tags: string[] }[] }) {
343
+ const [searchParams] = createSearchParams()
344
+ const tag = createMemo(() => searchParams().get('tag') ?? '')
345
+ const visible = createMemo(() => props.items.filter((p) => !tag() || p.tags.includes(tag())))
346
+ return <ul>{visible().map((p) => <li key={p.title}>{p.title}</li>)}</ul>
347
+ }
348
+ `)
349
+ expect(template).toContain(": my $tag = ($searchParams.get('tag') // '');")
350
+ expect(template).toContain(': my $visible = $bf.filter($items,')
351
+ })
352
+
353
+ // The seed-scope guard used to scan the LOWERED
354
+ // Kolon string, allowing every arrow-callback param tree-wide. That let an
355
+ // outer, unbound `p` (shadowed only inside the callback) slip past the
356
+ // guard as if it were the callback's own bound `$p` — emitting a bogus
357
+ // seed line. The guard now walks the parsed SOURCE tree with proper
358
+ // lexical scoping (`freeIdentifiers`), so this shape seeds nothing and
359
+ // falls back to the null/ssr-defaults path.
360
+ test('an outer unbound `p` shadowed only inside the callback does not seed', () => {
361
+ const { template } = compileAndGenerate(`
362
+ 'use client'
363
+ import { createMemo } from '@barefootjs/client'
364
+ export function C(props: { items: { ok: boolean }[] }) {
365
+ const visible = createMemo(() => props.items.filter((p) => p.ok) && p)
366
+ return <div>{String(visible())}</div>
367
+ }
368
+ `)
369
+ expect(template).not.toContain('my $visible')
370
+ })
371
+
372
+ // An out-of-scope bare `_` reference must not seed either — the old
373
+ // unconditional `allowed.add('_')` / `allowed.add('bf')` masked this.
374
+ test('an out-of-scope bare `_` reference does not seed', () => {
375
+ const { template } = compileAndGenerate(`
376
+ 'use client'
377
+ import { createMemo } from '@barefootjs/client'
378
+ export function C(props: { count: number }) {
379
+ const doubled = createMemo(() => props.count * 2 + _)
380
+ return <div>{doubled()}</div>
381
+ }
382
+ `)
383
+ expect(template).not.toContain('my $doubled')
384
+ })
385
+ })
386
+
387
+ describe('XslateAdapter - #2073 value-producing .map(cb)', () => {
388
+ // The blog-showcase shape (#1938/#1939): a value-returning `.map` (string
389
+ // projection, not JSX) lowers through the evaluator — `$bf.map_eval`
390
+ // projects each element (no flatten) and composes through `$bf.join`.
391
+ test('.map(t => `#${t}`).join(" ") emits $bf.map_eval composed into $bf.join', () => {
392
+ const { template } = compileAndGenerate(`
393
+ function TagLine({ tags }: { tags: string[] }) {
394
+ return <p>{tags.map((t) => \`#\${t}\`).join(' ')}</p>
395
+ }
396
+ export { TagLine }
397
+ `)
398
+ expect(template).toContain("$bf.join($bf.map_eval($tags,")
399
+ expect(template).toContain('"kind":"template-literal"')
400
+ })
401
+
402
+ test('.map(u => u.name) emits $bf.map_eval with the field projection', () => {
403
+ const { template } = compileAndGenerate(`
404
+ function NameList({ users }: { users: { name: string }[] }) {
405
+ return <div>{users.map((u) => u.name).join(', ')}</div>
406
+ }
407
+ export { NameList }
408
+ `)
409
+ expect(template).toContain('$bf.map_eval($users,')
410
+ expect(template).toContain('"property":"name"')
411
+ })
412
+ })
413
+
299
414
  describe('XslateAdapter - higher-order predicate lowering (#2018 P2)', () => {
300
415
  test('a serializable predicate lowers to $bf.filter_eval with the JSON body + env', () => {
301
416
  // A standalone `.filter().length` exercises the higher-order emitter (the
@@ -332,17 +447,39 @@ export { A }
332
447
  expect(findLast).toContain(', 0, {})')
333
448
  })
334
449
 
335
- test('a method-call predicate falls back to the Kolon-lambda runtime call', () => {
450
+ test('.includes() in a predicate now lowers via the evaluator, not the Kolon-lambda fallback', () => {
451
+ // #2075: `.includes(x)` joined the evaluator's `array-method` surface
452
+ // (shared with the Perl `Evaluator.pm` runtime), so a predicate built
453
+ // from it routes through `$bf.every_eval` like any other pure predicate.
336
454
  const { template } = compileAndGenerate(`
337
455
  function A({ items }: { items: { name: string }[] }) {
338
456
  return <div>{items.every(x => x.name.includes('a')) ? 'y' : 'n'}</div>
339
457
  }
340
458
  export { A }
341
459
  `)
342
- // No evaluator helper — the unsupported predicate keeps the `-> $x { … }`
343
- // lambda form passed to the runtime `$bf.every`.
460
+ expect(template).toContain('$bf.every_eval(')
461
+ expect(template).toContain('"method":"includes"')
462
+ expect(template).not.toContain('-> $x {')
463
+ })
464
+
465
+ test('a method-call predicate outside the evaluator surface falls back to the Kolon-lambda runtime call', () => {
466
+ const { template } = compileAndGenerate(`
467
+ function A({ items }: { items: { name: string }[] }) {
468
+ return <div>{items.every(x => x.name.toUpperCase() === 'A') ? 'y' : 'n'}</div>
469
+ }
470
+ export { A }
471
+ `)
472
+ // `.toUpperCase()` is outside the evaluator's `array-method` gate (only
473
+ // `includes` is recognized there), so the predicate keeps the
474
+ // `-> $x { … }` lambda form passed to the runtime `$bf.every`.
344
475
  expect(template).not.toContain('every_eval')
345
476
  expect(template).toContain('$bf.every(')
346
477
  expect(template).toContain('-> $x {')
347
478
  })
348
479
  })
480
+
481
+ // #2038 nested-callback-predicate loudness is pinned at the shared
482
+ // conformance layer: `filter-nested-callback-predicate` /
483
+ // `filter-nested-find-predicate` (BF101 via `expectedDiagnostics` above) and
484
+ // `filter-nested-callback-predicate-client` (the `/* @client */` suppression
485
+ // twin, which must render clean).
@@ -287,6 +287,27 @@ export function renderFlatMapEval(
287
287
  return `$bf.flat_map_eval(${recv}, '${escapePerlSingleQuote(json)}', '${param}', ${env})`
288
288
  }
289
289
 
290
+ /**
291
+ * Emit a value-producing `.map(cb)` via the runtime evaluator (#2073): the
292
+ * projection body serializes to JSON and `$bf.map_eval` projects each element,
293
+ * one result per element (no flatten — the JS `.map` contract). Composes
294
+ * through the array-method chain (`.map(cb).join(' ')`). Returns null when
295
+ * the projection is outside the evaluator surface (→ caller refuses with
296
+ * BF101). The JSX-returning `.map` is an IRLoop upstream and never reaches
297
+ * this emit.
298
+ */
299
+ export function renderMapEval(
300
+ recv: string,
301
+ body: ParsedExpr,
302
+ param: string,
303
+ emit: (e: ParsedExpr) => string,
304
+ ): string | null {
305
+ const json = serializeParsedExpr(body)
306
+ if (json === null) return null
307
+ const env = emitEvalEnvArg(body, [param], emit)
308
+ return `$bf.map_eval(${recv}, '${escapePerlSingleQuote(json)}', '${param}', ${env})`
309
+ }
310
+
290
311
  /**
291
312
  * Shared Kolon emit for `.sort(cmp)` / `.toSorted(cmp)`. Used by both the
292
313
  * filter-context emitter and the top-level emitter, plus the loop-array
@@ -35,6 +35,7 @@ import {
35
35
  renderPredicateEval,
36
36
  renderFlatMethod,
37
37
  renderFlatMapEval,
38
+ renderMapEval,
38
39
  } from './array-method.ts'
39
40
 
40
41
  /**
@@ -62,18 +63,20 @@ const PREDICATE_METHODS = new Set<HigherOrderMethod>([
62
63
  * filters. Higher-order predicates are emitted using Kolon's own scalar
63
64
  * comparison operators (which delegate to Perl semantics).
64
65
  *
65
- * NOTE: Kolon has no `grep { } @{...}` form, so nested higher-order chains
66
- * (`x.tags.filter(...).length`) inside a predicate route through the
67
- * top-level emitter's `$bf`-helper higher-order lowering. This emitter keeps
68
- * the scalar-comparison surface the predicates the adapter accepts actually
69
- * use; richer nested shapes fall back to the helper or surface as BF101 via
70
- * the top-level emitter.
66
+ * NOTE: Kolon has no `grep { } @{...}` form, so a nested higher-order call
67
+ * (`x.tags.filter(...)`, `other.some(...)`) inside a predicate has no faithful
68
+ * scalar lowering here. Such predicates surface BF101 via `onUnsupported`
69
+ * (#2038) instead of silently degrading to the callback's receiver.
71
70
  */
72
71
  export class XslateFilterEmitter implements ParsedExprEmitter {
73
72
  constructor(
74
73
  private readonly param: string,
75
74
  private readonly localVarMap: Map<string, string>,
76
75
  private readonly isStringName: (n: string) => boolean = () => false,
76
+ // Records a BF101 for predicate shapes this emitter can only degrade
77
+ // (#2038). Optional so emitter construction stays possible without an
78
+ // adapter; a missing hook keeps the old silent-degrade emit.
79
+ private readonly onUnsupported?: (message: string, reason?: string) => void,
77
80
  ) {}
78
81
 
79
82
  identifier(name: string): string {
@@ -155,12 +158,15 @@ export class XslateFilterEmitter implements ParsedExprEmitter {
155
158
  _restArgs: ParsedExpr[],
156
159
  emit: (e: ParsedExpr) => string,
157
160
  ): string {
158
- // Nested callback method inside a filter predicate has no Kolon scalar
159
- // form; defer to the receiver so the predicate at least references a real
160
- // value (a richer chain would surface its own diagnostic at the top level).
161
- // Matches the pre-#2018-P5 `higherOrder` arm, which returned `emit(object)`
162
- // for every method.
163
- void method
161
+ // A nested callback method inside a filter predicate has no Kolon scalar
162
+ // form. The pre-#2038 behavior degraded it to its receiver, which silently
163
+ // changes predicate semantics (`!other.some(r => …)` collapses to
164
+ // `!other`), so surface BF101 instead. The receiver emit is kept only so
165
+ // the template stays syntactically valid while the build fails.
166
+ this.onUnsupported?.(
167
+ `Filter predicate contains a nested '.${method}(...)' callback, which has no Kolon scalar form`,
168
+ `Rewrite the predicate without a nested callback method, or add /* @client */ for client-only evaluation (no SSR).`,
169
+ )
164
170
  return emit(object)
165
171
  }
166
172
 
@@ -398,6 +404,18 @@ export class XslateTopLevelEmitter implements ParsedExprEmitter {
398
404
  return "''"
399
405
  }
400
406
 
407
+ // Value-producing `.map(cb)` (#2073): serialize the projection body +
408
+ // emit `$bf.map_eval`. (The JSX-returning `.map` is an IRLoop upstream.)
409
+ if (method === 'map') {
410
+ const evalForm = renderMapEval(recv, body, params[0], emit)
411
+ if (evalForm !== null) return evalForm
412
+ this.ctx._recordExprBF101(
413
+ `'.map(...)' projection is outside the Xslate adapter's evaluable surface`,
414
+ `Pre-compute the projected array, or move this position to a '/* @client */' boundary.`,
415
+ )
416
+ return "''"
417
+ }
418
+
401
419
  // Unknown callback method (should not arrive — CALLBACK_METHODS is closed).
402
420
  void object
403
421
  return recv
@@ -51,14 +51,3 @@ export function collectRootScopeNodes(node: IRNode): Set<IRNode> {
51
51
  visit(node)
52
52
  return out
53
53
  }
54
-
55
- /**
56
- * True when every `$var` the lowered Kolon expression references is already in
57
- * scope — guards in-template memo seeding against an out-of-scope binding. (#1297)
58
- */
59
- export function referencedVarsAreAvailable(expr: string, available: ReadonlySet<string>): boolean {
60
- for (const m of expr.matchAll(/\$([A-Za-z_]\w*)/g)) {
61
- if (!available.has(m[1])) return false
62
- }
63
- return true
64
- }