From 9a70971b8fd885df0985460b82cdc0731e2ee9c9 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 19 Aug 2026 10:17:42 -0400 Subject: [PATCH 1/7] Add a BXL card-authoring skill with a drift guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card authors get no guard rails from the engine: it tolerates missing values, catches Excel error sentinels, and compiles a readable spreadsheet dialect into jq, so several ways of getting an expression wrong produce a plausible number instead of an error. The skill ships through the boxel-cli plugin, next to the glossary whose bxl entries name it. A BXL suite pins each claim twice — the snippet still appears in the skill, and it still behaves as described — and asserts the repo paths the skill cites resolve. The bxl CI filter now covers the skill directory so an edit there runs that suite. Also corrects two syntax-modes examples that handed an iterating path straight to SUM, and points its import line at the platform module. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yaml | 4 + .../plugin/skills/bxl-authoring/SKILL.md | 341 +++++++++++ packages/bxl/docs/syntax-modes.md | 21 +- packages/bxl/tests/boxel/README.md | 1 + .../bxl/tests/boxel/authoring-skill-claims.ts | 565 ++++++++++++++++++ 5 files changed, 927 insertions(+), 5 deletions(-) create mode 100644 packages/boxel-cli/plugin/skills/bxl-authoring/SKILL.md create mode 100644 packages/bxl/tests/boxel/authoring-skill-claims.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9cc60358a02..99b3d5cc812 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -137,6 +137,10 @@ jobs: # shared CI-boot paths trigger it. - *shared - 'packages/bxl/**' + # The card-authoring skill's claims are pinned by a BXL suite + # that reads the skill file, so an edit to the skill has to run + # that suite too. + - 'packages/boxel-cli/plugin/skills/bxl-authoring/**' bench-amd: # The AMD transpiler is a runtime-common module with no # cross-workspace deps that affect its wall-time, so the diff --git a/packages/boxel-cli/plugin/skills/bxl-authoring/SKILL.md b/packages/boxel-cli/plugin/skills/bxl-authoring/SKILL.md new file mode 100644 index 00000000000..172a1f9f9fd --- /dev/null +++ b/packages/boxel-cli/plugin/skills/bxl-authoring/SKILL.md @@ -0,0 +1,341 @@ +--- +name: bxl-authoring +description: 'Use when writing or reviewing a BXL expression in a Boxel card — a computeVia built from expression(), the fx / jq tags, spreadsheet-formula fields, aggregations over linked or query-backed collections. Covers which tag to reach for, what the derive profile refuses outright, the silent traps (a stream where an aggregate was meant, jq interpolation in a plain string, & on a blank field, dates and "today"), and why an indexed computed can differ from the one the viewer sees. Activates on expression(, fx`…`, jq`…`, "BXL", "formula field", "computed field with Excel functions", "sum the linked cards".' +--- + +# Authoring BXL in a card + +BXL is the workspace's expression language: readable spreadsheet syntax and Excel +formula libraries on top of a jq engine. In a card it drives `computeVia`. + +```ts +import { expression, fx, jq } from '@cardstack/bxl'; + +export class Claim extends CardDef { + @field paidAmount = contains(NumberField); + @field reserveAmount = contains(NumberField); + @field incurredAmount = contains(NumberField, { + computeVia: expression(fx`ROUND((PaidAmount + ReserveAmount) * 100) / 100`), + }); +} +``` + +`@cardstack/bxl` is a platform module — the host serves it to card code, so the +bare specifier is the import. Only the package root is card-facing. `expression` +is the factory (`bxl` and `expr` are aliases); it compiles the source once when +the class body runs, then evaluates it against the card instance on each read. + +The rest of this skill is the decision layer and the trap list. For the full +syntax surface — labels, row selectors, predicates, the Excel function matrix — +read [bxl.boxel.site](https://bxl.boxel.site). + +## 1. Which tag + +| Source contains | Reach for | Why | +| ------------------------------------------- | --------------- | ------------------------------------------------------ | +| `\(…)` jq interpolation | `` jq`…` `` | A plain string drops the backslash — see trap 4 | +| Excel functions (`ROUND`, `IFS`, `SUM`) | `` fx`…` `` | Readable-syntax compilation, explicit at the call site | +| Bare PascalCase field labels (`PaidAmount`) | `` fx`…` `` | The compiler resolves them to `.paidAmount` | +| Pure jq (`.claims \| length`) | `` jq`…` `` | Skips the readable-syntax compile step | +| Quoted multi-word labels (`"Line Item"`) | `fx` + `schema` | Label resolution needs the schema — see trap 3 | + +A plain string compiles exactly like `` fx`…` ``. Prefer a tag: it tells the next +reader which dialect they are in, and it is the only form that survives `\(…)`. + +Mixing dialects inside one source is fine — `` fx`IF(.status == "Open", 1, 0)` `` +and `` fx`if Status == "Open" then 1 else 0 end` `` both work. Case is the +dispatch: `IF(cond, t, f)` is the Excel function, `if cond then … end` is the jq +construct. + +## 2. The `derive` profile — what a computed may not do + +`expression()` validates against the `derive` profile **when the field is +defined**, so a violation throws while the card module loads rather than +producing a wrong value. The diagnostic names the rule: + +```text +computeVia expression violates the derive profile: +derive-call-banned: Profile.derive is for deterministic write/index-time +computation and cannot use call TODAY: volatile calls are not stable write-time +derivations. +``` + +Refused: volatile calls (`TODAY`, `NOW`, `RAND`, `RANDBETWEEN`) · request, +actor and mutation context (`@User`, `@Env`, `$new`, `$old`) · user-defined +`def` helpers · jq `try` / `catch` · `error` · `label` / `break` · assignment +(`=`, `|=`) · recursive descent (`..`) · format filters (`@csv`) · control and +side-effect calls (`debug`, `env`, `input`, `stderr`, `halt`) · runtime metadata +(`builtins`, `modulemeta`). + +Allowed and useful: `IFERROR` / `IFNA` · optional access (`.a?`) · aggregates +(`SUM`, `AVERAGE`, `COUNT`, `NPV`) · validator helpers (`isEmail`) · `LET` · +bindings (`. as $x | …`) · explicit folds (`reduce`, `foreach`) · structural ops +(`keys`, `to_entries`, `group_by`, `unique`, `tojson`). + +The boundary is determinism: a derived value comes from the record snapshot, not +from the clock, the viewer, or the request. It is computed once server-side and +stored in the search doc, so anything ambient would bake one viewer's answer in +for everyone. + +## 3. An aggregate needs a collected array, not a stream + +**The single most expensive trap.** jq function arguments are streams, so +navigating into an array field and handing that straight to an aggregate calls +the aggregate once *per element* — and the field receives an array of per-element +results instead of one number. Nothing errors. + +```ts +// WRONG — compiles to SUM(.claims[].paid); with two claims the field gets [10, 5] +computeVia: expression(fx`SUM(Claims[].Paid)`); + +// RIGHT — collect first, then aggregate: 15 +computeVia: expression(fx`SUM([Claims[].Paid])`); + +// RIGHT — the jq spelling, with a fallback for the empty case +computeVia: expression(jq`[.claims[] | .paidAmount] | add // 0`); +``` + +Same for `AVERAGE`, `COUNT`, `MAX`, `MIN`, `SUMIF`. The rule: if the expression +contains `[]` or an iterating path, the aggregate's argument must be wrapped in +`[…]`. + +Passing a `schema` is the other way to get this right — with field metadata, the +compiler collects implicitly, so `SUM("Line Item"."Line Total")` compiles to +`SUM([.lineItems[].lineTotal])`. Without a schema, quoted multi-word labels fail +loudly (`Cannot index string with string`) and bare PascalCase falls back to a +single-word camelCase path. A card gets no schema unless the expression passes +one. + +Check the compiled jq when in doubt — the factory exposes it: + +```ts +expression(fx`SUM([Claims[].Paid])`).bxl; +// { source, compiledSource: 'SUM([.claims[].paid])', warnings, deps, memoize } +``` + +## 4. `\(…)` in a plain string is silently inert + +A JS string literal and an untagged template both drop the backslash before `(`, +so the runtime never sees the interpolation and the field renders the literal +text `(.bpSystolic)/(.bpDiastolic)`. No lint pass in a realm flags this. + +```ts +// WRONG — yields "(.bpSystolic)/(.bpDiastolic)" +computeVia: expression('"\(.bpSystolic)/\(.bpDiastolic)"'); + +// RIGHT — the tag passes the raw source through +computeVia: expression(jq`"\(.bpSystolic)/\(.bpDiastolic)"`); +``` + +## 5. Blank inputs: what propagates, what absorbs + +Missing and null operands are tolerated rather than fatal, which means a wrong +answer is quiet. The model, for a card whose numeric fields are unset: + +| Expression | Result | Reason | +| ------------------------------------- | ------- | ------------------------------------------- | +| `` fx`Paid + Reserve` `` | `null` | null propagates through arithmetic | +| `` fx`Paid + 5` `` | `5` | a null addend contributes nothing | +| `` fx`SUM(Paid, Reserve)` `` | `0` | aggregates skip blanks, Excel-style | +| `` fx`ROUND(Paid + Reserve)` `` | `0` | `ROUND` absorbs null (and error sentinels) | +| `` fx`Premium / 0` `` | `null` | division by zero yields null, not `#DIV/0!` | +| `` jq`[.claims[] \| .paid] \| add` `` | `null` | `add` over an empty array is null | +| `` jq`.name \| startswith("a")` `` | `false` | string predicates on null are false | + +Guard with `//`, the jq alternative operator: `(Paid // 0) + (Reserve // 0)`, +`add // 0`. Because division by zero produces null rather than an error, +`IFERROR` does **not** rescue it — guard the divisor instead. + +**`&` renders a blank operand as the text `null`.** `` fx`Name & " (" & Tier & +")"` `` on a card with no tier yields `Acme (null)`. Write +`` fx`Name & " (" & (Tier // "") & ")"` ``, or use `CONCAT` / `TEXTJOIN`, which +drop blanks. + +## 6. Excel error sentinels never crash the card + +Sentinels (`#N/A`, `#DIV/0!`, `#VALUE!`, `#REF!`, `#NAME?`, `#NUM!`) are raised +as values inside evaluation and caught at the factory boundary, which returns +`null`. A failing formula leaves one blank field; it does not fail the card or +the realm's index pass. + +Catch them deliberately when a fallback reads better than a blank: +`` fx`IFERROR(VLOOKUP(Sku, Rows, 2, FALSE), "unlisted")` ``, +`` fx`IFNA(NA(), "none")` ``. An `AVERAGE` over an empty collection raises +`#DIV/0!` and therefore lands as null. + +## 7. Linked cards, query-backed inverses, and staleness + +Paths traverse links, including several hops: `` jq`.policy.customer.name` `` +reads across two `linksTo` edges, and a missing hop anywhere yields null. + +A query-backed `linksToMany` — the inverse side, derived from a filter rather +than stored on the card — behaves differently from a stored link, and this is +the part worth understanding before you aggregate over one: + +- It resolves against the **live index at visit time**. On a realm's first index + pass the index is still empty, so aggregates over the inverse bake in their + empty-set values; the next visit of the aggregating card converges them. +- Only stored edges drive invalidation. Writing a `Claim` reindexes that claim; + the `Policy` whose inverse contains it keeps the aggregate from its last visit + until something revisits the policy. Aggregates over an inverse are eventually + consistent by design. +- The browser resolves the inverse live during render, so the number a viewer + sees can be the converged one while the indexed value — the one search filters + and sorts on — is still from the last visit. + +Guidance: aggregate over query-backed inverses for display and reporting; do not +treat such a field as a promptly-correct index-time fact, and do not build a +filter or sort that depends on it being current. When the aggregate must be +index-accurate, put the edge on the aggregating card (a stored `linksToMany`) +so a write to either side invalidates it. + +## 8. Cyclic card graphs are safe but clipped + +Card graphs are legitimately cyclic (a claim links to its policy, the policy's +inverse contains the claim); jq's data model is not. Re-entering a card already +on the traversal path yields a bounded `{ id }` reference — the same clip a +search doc applies — so `` jq`[.claims[] | .policy.id]` `` reads one id per +claim, and the policy's other fields read null from that direction. Structural operations +(`unique`, `tojson`, `==`) terminate and stay field-aware, comparing cards by +their materialized values. + +Two consequences for data modeling: + +- Read a value from the near side of a cycle, not by walking back across it. A + claim reaching `.policy.annualPremium` is fine; a policy reaching + `.claims[].policy.annualPremium` gets null. +- A computed whose program enumerates its own card (`tojson`, `keys`, `unique` + over `.`) re-enters the field it is producing. That in-flight read is blank — + the spreadsheet circular-reference surface — so the value comes out as if the + field were empty rather than recursing. + +## 9. Dates: serials are safe, "today" is not available + +Indexing evaluates computeds server-side; a browser evaluates them in the +viewer's zone. Date functions are anchored so that they answer the same in +either place: `DATE`, `EDATE`, `EOMONTH`, `WEEKDAY`, `DATEVALUE`, `YEARFRAC`, +`DAYS`, `NETWORKDAYS` and friends give one answer across host zones. Serial +arithmetic and explicit Y/M/D construction are the safe idioms. + +`TODAY` and `NOW` are not available in a computed at all — the `derive` profile +refuses them, because an indexed value computed once from the clock is wrong for +every later read. So: + +- Compute the **fact**: a due-date serial, a span between two stored dates, a + boolean over stored dates. +- Render the **relative phrase** in the component, where the viewer's clock and + zone are the right ones. A computed that yields "3 days overdue" is a trap; one + that yields the due-date serial and lets the template phrase it is not. +- If a card genuinely needs a local-time value, it belongs in the rendering + layer. Anything indexed is computed once, server-side, for all viewers. + +## 10. Memoization is per-instance and microtask-scoped + +`expression()` caches its result per card instance until the current microtask +ends, which collapses the repeated synchronous reads a serialization or search +pass makes. Glimmer flushes re-renders synchronously at the end of an action, so +an action that **reads a formula and then writes one of that formula's inputs in +the same burst** paints once with the cached value. It heals on the next change +to that card. Write-only actions never see it. + +Pass `memoize: false` for a formula an action reads before writing its inputs: + +```ts +@field statusPanel = contains(PanelField, { + computeVia: expression(jq`{ label: .status }`, { + as: PanelField, + memoize: false, + }), +}); +``` + +## 11. `{ as: FieldDef }` for structured output + +An expression yields plain JSON. When the field's type is a `FieldDef`, pass +`as` so the value is rebuilt as an instance the serializer can identify — object +keys map to the field def's `@field` names, nested `contains` values materialize +as their own field-def instances, and each element of an array output gets the +same treatment. Scalars and null pass through untouched. + +```ts +export class RiskBandField extends FieldDef { + @field label = contains(StringField); + @field score = contains(NumberField); + @field flags = containsMany(StringField); +} + +@field riskBand = contains(RiskBandField, { + computeVia: expression( + jq`{ + label: (if .lossRatio >= 0.8 then "High" else "Low" end), + score: ((.lossRatio * 100) | round), + flags: (if .lossRatio >= 0.8 then ["review"] else [] end) + }`, + { as: RiskBandField }, + ), +}); + +// containsMany — one materialized instance per element +@field claimBands = containsMany(RiskBandField, { + computeVia: expression( + jq`[.claims[] | { label: .severityBand, score: .paidAmount }]`, + { as: RiskBandField }, + ), +}); +``` + +Without `as`, a structured value reaches the serializer as an anonymous object +and fails to identify. + +## Reviewing a card's BXL + +1. Every aggregate's argument is wrapped in `[…]`. +2. Every `\(…)` source is `` jq`…` ``-tagged. +3. Every divisor and every `&` operand that can be blank is guarded. +4. No formula reads the clock; date output is a serial or a span, not a phrase. +5. Aggregates over query-backed inverses are display values, not filter or sort + keys. +6. Structured output has `{ as: … }`. +7. A field whose indexed value is deliberately allowed to lag says so in a + comment at the field. + +## Where these rules are pinned + +In `cardstack/boxel`, the behavior above is locked down by tests, which are the +place to check a detail or add a case: + +- `packages/bxl/tests/boxel/authoring-skill-claims.ts` — this skill's own drift + guard: for each claim above it asserts the snippet still appears in this file + and still behaves as described. Editing an example here means editing that + suite too. +- `packages/host/tests/helpers/cards/bxl-tracking.ts` — the worked example this + skill draws on: an insurance domain exercising all three tags, linked and + query-backed traversal, null tolerance, sentinels, and `{ as: … }`. +- `packages/host/tests/integration/bxl-expression-test.gts` — the factory on + real cards, including the memoization contract. +- `packages/host/tests/integration/bxl-platform-module-test.gts` — the platform + module end to end, and the query-backed first-pass/converge contract. +- `packages/host/tests/integration/bxl-cyclic-graph-test.gts` — the `{ id }` + clip and structural operations across a cycle. +- `packages/bxl/tests/boxel/` — null tolerance, tag dispatch, the `\(…)` + preservation rule, sentinel catching, and `as` materialization over plain + objects. +- `packages/bxl/docs/` — `syntax-modes.md` (call-site modes), `profiles.md` + (the `derive` contract), `formulas.md` (the Excel matrix), + `realm-composition.md` (threading inputs into child field defs). + +## Adjacent skills + +- Query-backed `linksToMany` and inbound-reference lookups — `boxel-patterns`, + pattern `automate-linked-to-me-lookup`. +- Field types, formats, and templates — `boxel`; silent-failure traps outside + BXL — `boxel-workspace-cardinal-rules`. +- Why a card failed to index or holds broken links — `indexing-errors`. + +The engine itself — the compiler, the jq runtime, the formula libraries, the +mutation and authorization profiles — is documented in `packages/bxl/docs/` and +is not this skill's subject. + +The glossary's **bxl** and `computeVia: expression(...)` entries name +`library-bxl` and `extension-libs/bxl/` as their reference targets; this skill is +that reference. diff --git a/packages/bxl/docs/syntax-modes.md b/packages/bxl/docs/syntax-modes.md index 1a4d399bffe..399d79874a2 100644 --- a/packages/bxl/docs/syntax-modes.md +++ b/packages/bxl/docs/syntax-modes.md @@ -16,13 +16,18 @@ locked down by a case in [`../tests/boxel/`](../tests/boxel/). ## Using BXL Inside Boxel -In a Boxel realm, import from the uploaded realm bundle using the -relative path to that bundle: +In a Boxel realm, import the platform module by its bare specifier — +the host serves it to card code, and the package root is the +card-facing entry: ```ts -import { expression, fx, jq } from '../bxl'; +import { expression, fx, jq } from '@cardstack/bxl'; ``` +A realm that carries its own uploaded bundle imports that bundle by +relative path instead (`from '../bxl'`); everything below applies the +same either way. + `expression` is the same compute factory as `bxl` / `expr`; it returns a function shaped for `computeVia`. The factory validates the source against the `derive` execution profile when it is constructed, so non-deterministic @@ -30,7 +35,7 @@ or request-scoped expressions are rejected before Boxel runs the field. ```ts @field subtotal = contains(NumberField, { - computeVia: expression(fx`SUM("Line Item".Amount)`), + computeVia: expression(fx`SUM([LineItems[].Amount])`), }); @field slug = contains(StringField, { @@ -158,7 +163,7 @@ Watch for: ```ts expression(fx`ROUND(Salary / 2080, 2)`); expression(fx`PatientId & " — " & FirstName & " " & LastName`); -expression(fx`SUM(Patients[].Billing.RoomCharge)`); +expression(fx`SUM([Patients[].Billing.RoomCharge])`); ``` The compiler treats `` fx`…` `` exactly like a plain string — @@ -187,6 +192,12 @@ These apply regardless of the tag: - **Null-tolerant arithmetic.** `null - 5`, `5 / 0`, `null * x`, `null | startswith("a")` all return `null` / `false` instead of throwing. +- **Aggregates take a collected array.** Function arguments are jq + streams, so `SUM(Claims[].Paid)` calls `SUM` once per claim and the + field receives one value per element. Collect first — + `SUM([Claims[].Paid])` — or supply a `schema`, which makes implicit + iteration collect on its own (`SUM("Line Item"."Line Total")` compiles + to `SUM([.lineItems[].lineTotal])`). ## Mixed-syntax expressions diff --git a/packages/bxl/tests/boxel/README.md b/packages/bxl/tests/boxel/README.md index e064e23e4ec..e0ebe97dc85 100644 --- a/packages/bxl/tests/boxel/README.md +++ b/packages/bxl/tests/boxel/README.md @@ -28,6 +28,7 @@ rules, so a failing case name points straight at the one that broke. | `fielddef-threading.ts` | Multi-stage `{ as: Cls }` threading — the insurance pipeline pattern | | `card-source-mutation.ts` | The card-source mutation adapter: schema derivation, computed-field skips, relationship serialization, stale-plan safety | | `update-via-bxl.ts` | The `updateViaBxl` adapter | +| `authoring-skill-claims.ts` | Drift guard for the `bxl-authoring` agent skill: every behavior it teaches, plus the repo paths it cites | ## Running diff --git a/packages/bxl/tests/boxel/authoring-skill-claims.ts b/packages/bxl/tests/boxel/authoring-skill-claims.ts new file mode 100644 index 00000000000..f53c94dca2f --- /dev/null +++ b/packages/bxl/tests/boxel/authoring-skill-claims.ts @@ -0,0 +1,565 @@ +// Drift guard for the card-authoring skill. +// +// The `bxl-authoring` agent skill +// (packages/boxel-cli/plugin/skills/bxl-authoring/SKILL.md) teaches card +// authors a set of concrete behaviors — which tag preserves `\(…)`, what the +// derive profile refuses, how an aggregate reads a collection, what a blank +// input produces. It ships to authors who cannot run the engine to check, so +// each claim is pinned here twice: +// +// 1. The snippet the skill shows must still appear in the skill text, so a +// rewrite that changes an example has to come through this file. +// 2. The behavior that snippet claims must still hold against the engine. +// +// Claims that need a live card runtime — query-backed inverse staleness, the +// `{ id }` clip across a cycle, the memoized-then-written paint — are pinned by +// the host integration suites the skill names; the last case here asserts those +// pointers still resolve. + +import { existsSync } from 'node:fs'; +import { readFileSync } from 'node:fs'; +import { deepStrictEqual, ok, strictEqual } from 'node:assert'; +import { join } from 'node:path'; +import { evaluateBxl, expression, fx, jq } from '../../src/index.ts'; + +const REPO_ROOT = join(import.meta.dirname, '..', '..', '..', '..'); +const SKILL_PATH = join( + REPO_ROOT, + 'packages/boxel-cli/plugin/skills/bxl-authoring/SKILL.md', +); + +let pass = 0; +let fail = 0; +const failures: string[] = []; + +function check(name: string, fn: () => void) { + try { + fn(); + pass++; + } catch (error) { + fail++; + failures.push(` ${name}\n ${(error as Error).message.split('\n')[0]}`); + } +} + +if (!existsSync(SKILL_PATH)) { + console.log(`FAIL: the authoring skill is not at ${SKILL_PATH}`); + process.exit(1); +} +const skill = readFileSync(SKILL_PATH, 'utf8'); +// Runs of whitespace collapse, so rewrapping a paragraph or realigning a table +// column is not a failure — only changing what an example says is. +const flatten = (text: string) => text.replace(/\s+/g, ' '); +const skillFlat = flatten(skill); + +/** Asserts the skill still shows this snippet. */ +function shows(snippet: string) { + ok( + skillFlat.includes(flatten(snippet)), + `the skill no longer shows \`${snippet}\` — update this case with it`, + ); +} + +// ------------------------------------------------- tag dispatch + +check('a plain string drops the backslash before `(`', () => { + shows('expression(\'"\\(.bpSystolic)/\\(.bpDiastolic)"\')'); + // The escape is the subject of the case, so the source is spelled the way an + // author spells it and the assertion below shows what JS hands on. + // eslint-disable-next-line no-useless-escape -- the useless escape IS the trap + const asAuthorTyped = '"\(.bpSystolic)/\(.bpDiastolic)"'; + strictEqual( + asAuthorTyped, + '"(.bpSystolic)/(.bpDiastolic)"', + 'a JS string literal drops the backslash before `(`', + ); + strictEqual( + evaluateBxl(asAuthorTyped, { bpSystolic: 120, bpDiastolic: 80 }).value, + '(.bpSystolic)/(.bpDiastolic)', + 'so the interpolation is inert, and nothing throws to say so', + ); +}); + +check('the jq tag preserves the interpolation', () => { + shows('expression(jq`"\\(.bpSystolic)/\\(.bpDiastolic)"`)'); + strictEqual( + expression(jq`"\(.bpSystolic)/\(.bpDiastolic)"`).call({ + bpSystolic: 120, + bpDiastolic: 80, + }), + '120/80', + ); +}); + +check('fx resolves a bare PascalCase label to a camelCase path', () => { + shows('The compiler resolves them to `.paidAmount`'); + strictEqual(expression(fx`PaidAmount`).bxl.compiledSource, '.paidAmount'); +}); + +check('IF is the Excel function, if/then/end is the jq construct', () => { + shows( + '`IF(cond, t, f)` is the Excel function, `if cond then … end` is the jq', + ); + strictEqual( + expression(fx`IF(.status == "Open", 1, 0)`).call({ status: 'Open' }), + 1, + ); + strictEqual( + expression(fx`if Status == "Open" then 1 else 0 end`).call({ + status: 'Open', + }), + 1, + ); +}); + +// ------------------------------------------------- the derive profile + +/** The diagnostic code `expression()` throws for a rejected source. */ +function rejectionCode(make: () => unknown): string { + try { + make(); + } catch (error) { + const match = /derive-[a-z-]+/.exec((error as Error).message); + if (match) return match[0]; + return `no derive code in: ${(error as Error).message.split('\n')[0]}`; + } + return 'accepted'; +} + +check('the diagnostic the skill quotes is the one the factory throws', () => { + shows( + 'derive-call-banned: Profile.derive is for deterministic write/index-time', + ); + shows('cannot use call TODAY: volatile calls are not stable write-time'); + let message = ''; + try { + expression(fx`TODAY()`); + } catch (error) { + message = (error as Error).message; + } + ok( + message.startsWith('computeVia expression violates the derive profile:'), + `unexpected preamble: ${message.split('\n')[0]}`, + ); + ok( + message.includes( + 'derive-call-banned: Profile.derive is for deterministic write/index-time computation and cannot use call TODAY: volatile calls are not stable write-time derivations.', + ), + 'the quoted diagnostic no longer matches', + ); +}); + +check('every call the skill lists as refused is refused', () => { + shows( + 'Refused: volatile calls (`TODAY`, `NOW`, `RAND`, `RANDBETWEEN`) · request,', + ); + const refused: Array<[string, () => unknown, string]> = [ + ['TODAY()', () => expression(fx`TODAY()`), 'derive-call-banned'], + ['NOW()', () => expression(fx`NOW()`), 'derive-call-banned'], + ['RAND()', () => expression(fx`RAND()`), 'derive-call-banned'], + [ + 'RANDBETWEEN(1, 6)', + () => expression(fx`RANDBETWEEN(1, 6)`), + 'derive-call-banned', + ], + ['@User.id', () => expression(fx`@User.id`), 'derive-context-banned'], + ['$new.total', () => expression(fx`$new.total`), 'derive-context-banned'], + ['def', () => expression(jq`def f: . + 1; f`), 'derive-def-banned'], + ['try/catch', () => expression(jq`try .a catch "x"`), 'derive-try-banned'], + ['error', () => expression(jq`error("boom")`), 'derive-call-banned'], + [ + 'label/break', + () => expression(jq`label $out | .a, break $out`), + 'derive-control-flow-banned', + ], + [ + 'assignment =', + () => expression(jq`.total = 5`), + 'derive-assignment-banned', + ], + [ + 'assignment |=', + () => expression(jq`.total |= . + 1`), + 'derive-assignment-banned', + ], + [ + 'recursive descent', + () => expression(jq`.. | numbers`), + 'derive-recursive-descent-banned', + ], + ['@csv', () => expression(jq`[.a, .b] | @csv`), 'derive-format-banned'], + ['debug', () => expression(jq`debug`), 'derive-call-banned'], + ['env', () => expression(jq`env`), 'derive-call-banned'], + ['input', () => expression(jq`input`), 'derive-call-banned'], + ['builtins', () => expression(jq`builtins | length`), 'derive-call-banned'], + ]; + for (const [label, make, code] of refused) { + strictEqual(rejectionCode(make), code, `${label} should be ${code}`); + } +}); + +check('every form the skill lists as allowed constructs', () => { + shows('Allowed and useful: `IFERROR` / `IFNA` · optional access (`.a?`)'); + const allowed: Array<[string, () => unknown]> = [ + ['IFERROR', () => expression(fx`IFERROR(Amount, 0)`)], + ['IFNA', () => expression(fx`IFNA(Amount, 0)`)], + ['optional access', () => expression(jq`.a?`)], + ['SUM', () => expression(fx`SUM([Claims[].Paid])`)], + ['AVERAGE', () => expression(fx`AVERAGE([Claims[].Paid])`)], + ['COUNT', () => expression(fx`COUNT([Claims[].Paid])`)], + ['NPV', () => expression(fx`NPV(0.1, CashFlows)`)], + ['isEmail', () => expression(fx`isEmail(Email)`)], + ['LET', () => expression(fx`LET(t, SUM([Claims[].Paid]), t > 100)`)], + ['binding', () => expression(jq`. as $x | $x.a`)], + ['reduce', () => expression(jq`reduce .items[] as $i (0; . + $i)`)], + ['keys', () => expression(jq`keys`)], + ['to_entries', () => expression(jq`to_entries | map(.key)`)], + ['group_by', () => expression(jq`group_by(.status) | length`)], + ['unique', () => expression(jq`[.claims[]] | unique | length`)], + ['tojson', () => expression(jq`tojson | length`)], + ]; + for (const [label, make] of allowed) { + strictEqual(rejectionCode(make), 'accepted', `${label} should construct`); + } +}); + +// ------------------------------------------------- aggregates over collections + +const twoClaims = { claims: [{ paid: 10 }, { paid: 5 }] }; + +check('an aggregate over a stream runs per element', () => { + shows( + '// WRONG — compiles to SUM(.claims[].paid); with two claims the field gets [10, 5]', + ); + const compute = expression(fx`SUM(Claims[].Paid)`); + strictEqual(compute.bxl.compiledSource, 'SUM(.claims[].paid)'); + deepStrictEqual(compute.call(twoClaims), [10, 5]); +}); + +check('collecting first aggregates once', () => { + shows('computeVia: expression(fx`SUM([Claims[].Paid])`);'); + const compute = expression(fx`SUM([Claims[].Paid])`); + strictEqual(compute.bxl.compiledSource, 'SUM([.claims[].paid])'); + strictEqual(compute.call(twoClaims), 15); +}); + +check('the jq spelling with an empty-case fallback', () => { + shows('computeVia: expression(jq`[.claims[] | .paidAmount] | add // 0`);'); + const compute = expression(jq`[.claims[] | .paidAmount] | add // 0`); + strictEqual(compute.call({ claims: [] }), 0); + strictEqual( + compute.call({ claims: [{ paidAmount: 10 }, { paidAmount: 5 }] }), + 15, + ); +}); + +check('a schema makes implicit iteration collect on its own', () => { + shows('`SUM("Line Item"."Line Total")` compiles to'); + shows('`SUM([.lineItems[].lineTotal])`'); + const schema = { + fields: [ + { + key: 'lineItems', + label: 'Line Item', + kind: 'array' as const, + item: { fields: [{ key: 'lineTotal', label: 'Line Total' }] }, + }, + ], + }; + const run = evaluateBxl( + 'SUM("Line Item"."Line Total")', + { lineItems: [{ lineTotal: 10 }, { lineTotal: 5 }] }, + { schema }, + ); + strictEqual(run.compiledSource, 'SUM([.lineItems[].lineTotal])'); + strictEqual(run.value, 15); +}); + +check('a quoted label with no schema fails loudly', () => { + shows('loudly (`Cannot index string with string`)'); + let message = ''; + try { + evaluateBxl('SUM("Line Item"."Line Total")', { lineItems: [] }); + } catch (error) { + message = (error as Error).message; + } + ok( + message.includes('Cannot index string with string'), + `unexpected message: ${message.split('\n')[0]}`, + ); +}); + +check('the factory exposes the compiled jq and the dependency list', () => { + shows( + "// { source, compiledSource: 'SUM([.claims[].paid])', warnings, deps, memoize }", + ); + const meta = expression(fx`SUM([Claims[].Paid])`).bxl; + strictEqual(meta.source, 'SUM([Claims[].Paid])'); + strictEqual(meta.compiledSource, 'SUM([.claims[].paid])'); + deepStrictEqual([...meta.deps], ['claims']); + strictEqual(meta.memoize, 'microtask'); +}); + +// ------------------------------------------------- blank inputs + +const noAmounts = {}; + +check('the blank-input table still reads true', () => { + const rows: Array<[string, () => unknown, unknown]> = [ + [ + '| `` fx`Paid + Reserve` `` | `null` |', + () => expression(fx`Paid + Reserve`).call(noAmounts), + null, + ], + [ + '| `` fx`Paid + 5` `` | `5` |', + () => expression(fx`Paid + 5`).call(noAmounts), + 5, + ], + [ + '| `` fx`SUM(Paid, Reserve)` `` | `0` |', + () => expression(fx`SUM(Paid, Reserve)`).call(noAmounts), + 0, + ], + [ + '| `` fx`ROUND(Paid + Reserve)` `` | `0` |', + () => expression(fx`ROUND(Paid + Reserve)`).call(noAmounts), + 0, + ], + [ + '| `` fx`Premium / 0` `` | `null` |', + () => expression(fx`Premium / 0`).call({ premium: 100 }), + null, + ], + [ + '| `` jq`[.claims[] \\| .paid] \\| add` `` | `null` |', + () => expression(jq`[.claims[] | .paid] | add`).call({ claims: [] }), + null, + ], + [ + '| `` jq`.name \\| startswith("a")` `` | `false` |', + () => expression(jq`.name | startswith("a")`).call(noAmounts), + false, + ], + ]; + for (const [row, run, expected] of rows) { + shows(row); + strictEqual(run(), expected, `row changed: ${row}`); + } +}); + +check('`//` guards a blank operand', () => { + shows('`(Paid // 0) + (Reserve // 0)`'); + strictEqual(expression(fx`(Paid // 0) + (Reserve // 0)`).call(noAmounts), 0); +}); + +check('IFERROR does not rescue a division by zero', () => { + shows('`IFERROR` does **not** rescue it — guard the divisor instead'); + strictEqual( + expression(fx`IFERROR(Premium / 0, 0)`).call({ premium: 100 }), + null, + ); +}); + +check('`&` renders a blank operand as the text null', () => { + shows('yields `Acme (null)`'); + strictEqual( + expression(fx`Name & " (" & Tier & ")"`).call({ name: 'Acme' }), + 'Acme (null)', + ); + shows('fx`Name & " (" & (Tier // "") & ")"`'); + strictEqual( + expression(fx`Name & " (" & (Tier // "") & ")"`).call({ name: 'Acme' }), + 'Acme ()', + ); + shows('use `CONCAT` / `TEXTJOIN`, which'); + strictEqual( + expression(fx`CONCAT(Name, "-", Tier)`).call({ name: 'Acme' }), + 'Acme-', + ); + strictEqual( + expression(fx`TEXTJOIN("-", TRUE, Name, Tier)`).call({ name: 'Acme' }), + 'Acme', + ); +}); + +// ------------------------------------------------- Excel error sentinels + +check('an uncaught sentinel lands as null, not a throw', () => { + shows('caught at the factory boundary, which returns\n`null`'); + strictEqual(expression(fx`NA()`).call(noAmounts), null); +}); + +check('the catch-it-deliberately examples still work', () => { + shows('fx`IFERROR(VLOOKUP(Sku, Rows, 2, FALSE), "unlisted")`'); + strictEqual( + expression(fx`IFERROR(VLOOKUP(Sku, Rows, 2, FALSE), "unlisted")`).call({ + sku: 'x', + rows: [], + }), + 'unlisted', + ); + shows('fx`IFNA(NA(), "none")`'); + strictEqual(expression(fx`IFNA(NA(), "none")`).call(noAmounts), 'none'); + shows('An `AVERAGE` over an empty collection raises'); + strictEqual( + expression(fx`AVERAGE([Claims[].Paid])`).call({ claims: [] }), + null, + ); +}); + +// ------------------------------------------------- dates + +check('the date functions the skill calls safe agree across host zones', () => { + shows('`DATE`, `EDATE`, `EOMONTH`, `WEEKDAY`, `DATEVALUE`, `YEARFRAC`,'); + shows('`DAYS`, `NETWORKDAYS` and friends give one answer across host zones'); + const sources = [ + 'DATE(2026, 2, 5)', + 'EDATE(DATE(2026, 2, 5), 1)', + 'EOMONTH(DATE(2026, 2, 5), 1)', + 'WEEKDAY(DATE(2026, 4, 30))', + 'DATEVALUE("April 30, 2026 23:30")', + 'YEARFRAC(DATE(2026, 1, 31), DATE(2026, 3, 31), 0)', + 'DAYS(DATE(2026, 4, 30), DATE(2026, 4, 22))', + 'NETWORKDAYS(DATE(2026, 4, 1), DATE(2026, 4, 30))', + ]; + // The whole-suite sweep in tests/unit/fixtures/function-coverage runs every + // registry case under six zones; these two are the pair that moved a + // calendar day apart when the anchoring was wrong. + const zones = ['UTC', 'Pacific/Kiritimati']; + const original = process.env.TZ; + try { + const perZone = zones.map((zone) => { + process.env.TZ = zone; + return sources.map((source) => evaluateBxl(source, null).value); + }); + deepStrictEqual( + perZone[0], + perZone[1], + `a date function answers differently under ${zones[1]}`, + ); + } finally { + if (original === undefined) delete process.env.TZ; + else process.env.TZ = original; + } +}); + +check('TODAY and NOW are unavailable in a computed', () => { + shows('`TODAY` and `NOW` are not available in a computed at all'); + strictEqual( + rejectionCode(() => expression(fx`TODAY()`)), + 'derive-call-banned', + ); + strictEqual( + rejectionCode(() => expression(fx`NOW()`)), + 'derive-call-banned', + ); +}); + +// ------------------------------------------------- memoization + +check('memoize: false re-runs on every read', () => { + shows('memoize: false,'); + const card = { status: 'Open' }; + const memoized = expression(jq`{ label: .status }`); + strictEqual( + memoized.call(card), + memoized.call(card), + 'the default caches within the microtask', + ); + const fresh = expression(jq`{ label: .status }`, { memoize: false }); + ok( + fresh.call(card) !== fresh.call(card), + 'memoize: false should hand back a new value each read', + ); +}); + +// ------------------------------------------------- { as: FieldDef } + +check( + '{ as: … } materializes objects and array elements, passes scalars', + () => { + shows('Scalars and null pass through untouched'); + class RiskBand { + label?: string; + score?: number; + } + const single = expression(jq`{ label: "High", score: 8 }`, { + as: RiskBand, + }); + const band = single.call({}) as RiskBand; + ok(band instanceof RiskBand, 'an object output becomes an instance'); + strictEqual(band.label, 'High'); + + const many = expression(jq`[{ label: "a" }, { label: "b" }]`, { + as: RiskBand, + }); + const bands = many.call({}) as RiskBand[]; + strictEqual(bands.length, 2); + ok( + bands.every((entry) => entry instanceof RiskBand), + 'every element is materialized', + ); + + strictEqual( + expression(jq`.subtotal`, { as: RiskBand }).call({ subtotal: 3 }), + 3, + 'a scalar passes through', + ); + strictEqual( + expression(jq`.missing`, { as: RiskBand }).call({}), + null, + 'null passes through', + ); + }, +); + +// ------------------------------------------------- self-referential compute + +check( + 'a compute that enumerates its own record reads that field as blank', + () => { + shows('That in-flight read is blank —'); + const selfJson = expression(jq`tojson`); + const record = { + a: 1, + get serialized() { + return selfJson.call(this); + }, + }; + strictEqual( + record.serialized, + '{"a":1,"serialized":null}', + 'the in-flight field serializes as null instead of recursing', + ); + }, +); + +// ------------------------------------------------- the skill's own pointers + +check('every repo path the skill cites exists', () => { + const cited = [ + ...new Set( + Array.from( + skill.matchAll(/`(packages\/[A-Za-z0-9._/-]+)`/g), + (match) => match[1], + ), + ), + ]; + ok( + cited.length >= 6, + `expected the pinned-rules list, found ${cited.length}`, + ); + for (const path of cited) { + ok(existsSync(join(REPO_ROOT, path)), `the skill cites a missing ${path}`); + } +}); + +// ---------------------------------------------------------------- + +console.log(`BXL authoring-skill claims: ${pass}/${pass + fail} cases passed`); +if (fail > 0) { + console.log('Failures:'); + for (const f of failures) console.log(f); + process.exit(1); +} From 763d77954d7af8a05ce6cfce8ecb89be91ae8190 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 19 Aug 2026 11:01:27 -0400 Subject: [PATCH 2/7] Correct three skill claims and widen what the guard pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An independent review found three claims that were wrong and two rules stated more broadly than they hold: - ROUND absorbs a null operand but not an Excel sentinel: a sentinel propagates out and blanks the whole expression. - Only sentinels blank a field. A misspelled function name or a structural op on the wrong shape throws a non-sentinel error and the instance indexes as an error, so a formula typo is a broken card. - Of the self-enumeration ops, only value-reading ones re-enter. keys reads field names and never re-enters; unique over an object throws. - The collect rule applies to the argument that iterates. A comma list is collected by the compiler, an array-valued field is already one value, and wrapping a scalar parameter blanks the field. - Both tags preserve the raw source, so the requirement is a tag, not the jq tag specifically. A PascalCase label inside an interpolation is read as a function call and throws on first read. The checklist restates those narrower rules, #NAME? leaves the sentinel list (nothing raises it), and modulemeta leaves the refusal list (it does not parse, so it has no derive diagnostic). The guard grew to match. It now evaluates every allowed form with an expected value instead of only constructing it — construction proved nothing, since a name the registry has never heard of constructs too, and NPV and isEmail live in lazy chunks that were never loaded. List membership, the WRONG/RIGHT pairing, the tag and blank-input reason columns, and the doc filenames are pinned, and the header states what the guard cannot reach rather than implying it covers the prose. Co-Authored-By: Claude Opus 5 (1M context) --- .../plugin/skills/bxl-authoring/SKILL.md | 102 +++- packages/bxl/docs/syntax-modes.md | 16 +- .../bxl/tests/boxel/authoring-skill-claims.ts | 523 +++++++++++++++--- 3 files changed, 525 insertions(+), 116 deletions(-) diff --git a/packages/boxel-cli/plugin/skills/bxl-authoring/SKILL.md b/packages/boxel-cli/plugin/skills/bxl-authoring/SKILL.md index 172a1f9f9fd..44eb9ab899a 100644 --- a/packages/boxel-cli/plugin/skills/bxl-authoring/SKILL.md +++ b/packages/boxel-cli/plugin/skills/bxl-authoring/SKILL.md @@ -65,7 +65,7 @@ actor and mutation context (`@User`, `@Env`, `$new`, `$old`) · user-defined `def` helpers · jq `try` / `catch` · `error` · `label` / `break` · assignment (`=`, `|=`) · recursive descent (`..`) · format filters (`@csv`) · control and side-effect calls (`debug`, `env`, `input`, `stderr`, `halt`) · runtime metadata -(`builtins`, `modulemeta`). +(`builtins`). Allowed and useful: `IFERROR` / `IFNA` · optional access (`.a?`) · aggregates (`SUM`, `AVERAGE`, `COUNT`, `NPV`) · validator helpers (`isEmail`) · `LET` · @@ -95,16 +95,29 @@ computeVia: expression(fx`SUM([Claims[].Paid])`); computeVia: expression(jq`[.claims[] | .paidAmount] | add // 0`); ``` -Same for `AVERAGE`, `COUNT`, `MAX`, `MIN`, `SUMIF`. The rule: if the expression -contains `[]` or an iterating path, the aggregate's argument must be wrapped in -`[…]`. +Same for `AVERAGE`, `COUNT`, `MAX`, `MIN`, `SUMIF`. Collect **the argument that +iterates**, and only that one — the rule is narrower than "wrap everything", and +wrapping the wrong thing is its own bug: + +- An iterating path is what needs it: `SUM([Claims[].Paid])`, + `SUMIF([Claims[].Paid], ">6")`. +- A field that already holds an array is a single value. `SUM(Amounts)` over a + `containsMany(NumberField)` is correct as it stands. +- A comma list is collected by the compiler, which is why the multi-argument + spelling has never shown the trap: `SUM(Paid, Reserve)` compiles to + `SUM([.paid, .reserve])`, and even `SUM(Claims[].Paid, 0)` becomes + `SUM([.claims[].paid, 0])`. Write the `[…]` anyway — the single-argument form + is what an author reaches for next. +- **Never wrap a scalar parameter.** `ROUND([1.234], 2)` and + `NPV([0.1], CashFlows)` hand an array to a function that wants a number; each + returns null. Passing a `schema` is the other way to get this right — with field metadata, the compiler collects implicitly, so `SUM("Line Item"."Line Total")` compiles to `SUM([.lineItems[].lineTotal])`. Without a schema, quoted multi-word labels fail -loudly (`Cannot index string with string`) and bare PascalCase falls back to a -single-word camelCase path. A card gets no schema unless the expression passes -one. +loudly (`Cannot index string with string`) and a bare PascalCase label resolves +to a single camelCase path segment. A card gets no schema unless the expression +passes one. Check the compiled jq when in doubt — the factory exposes it: @@ -127,6 +140,14 @@ computeVia: expression('"\(.bpSystolic)/\(.bpDiastolic)"'); computeVia: expression(jq`"\(.bpSystolic)/\(.bpDiastolic)"`); ``` +Either tag preserves it — both read the raw strings, so `` fx`"\(.bpSystolic)"` `` +interpolates too. The plain string is the one broken form. + +Inside `\(…)` you are in jq, and the readable-syntax pass does not reach in: +a PascalCase label there is read as a function call, and +`` fx`"\(PaidAmount) paid"` `` throws `'PaidAmount/0' is not defined` the first +time the field is read. Write the path — `` fx`"\(.paidAmount) paid"` ``. + ## 5. Blank inputs: what propagates, what absorbs Missing and null operands are tolerated rather than fatal, which means a wrong @@ -137,7 +158,7 @@ answer is quiet. The model, for a card whose numeric fields are unset: | `` fx`Paid + Reserve` `` | `null` | null propagates through arithmetic | | `` fx`Paid + 5` `` | `5` | a null addend contributes nothing | | `` fx`SUM(Paid, Reserve)` `` | `0` | aggregates skip blanks, Excel-style | -| `` fx`ROUND(Paid + Reserve)` `` | `0` | `ROUND` absorbs null (and error sentinels) | +| `` fx`ROUND(Paid + Reserve)` `` | `0` | `ROUND` absorbs a null operand | | `` fx`Premium / 0` `` | `null` | division by zero yields null, not `#DIV/0!` | | `` jq`[.claims[] \| .paid] \| add` `` | `null` | `add` over an empty array is null | | `` jq`.name \| startswith("a")` `` | `false` | string predicates on null are false | @@ -151,18 +172,28 @@ Guard with `//`, the jq alternative operator: `(Paid // 0) + (Reserve // 0)`, `` fx`Name & " (" & (Tier // "") & ")"` ``, or use `CONCAT` / `TEXTJOIN`, which drop blanks. -## 6. Excel error sentinels never crash the card +## 6. Excel error sentinels blank the field; other failures fail the card -Sentinels (`#N/A`, `#DIV/0!`, `#VALUE!`, `#REF!`, `#NAME?`, `#NUM!`) are raised -as values inside evaluation and caught at the factory boundary, which returns -`null`. A failing formula leaves one blank field; it does not fail the card or -the realm's index pass. +Sentinels (`#N/A`, `#DIV/0!`, `#VALUE!`, `#REF!`, `#NUM!`) are raised as values +inside evaluation and caught at the factory boundary, which returns `null`. That +much is safe: `` fx`INDEX(Rows, 99)` `` leaves one blank field rather than +failing the card. -Catch them deliberately when a fallback reads better than a blank: +A sentinel blanks the **whole** expression, not the sub-call that raised it — it +propagates out through every enclosing call until the boundary catches it. +`` fx`ROUND(NA()) + 5` `` is null, not 5, even though `ROUND` absorbs a null +operand. Catch it where you want the fallback: `` fx`IFERROR(VLOOKUP(Sku, Rows, 2, FALSE), "unlisted")` ``, `` fx`IFNA(NA(), "none")` ``. An `AVERAGE` over an empty collection raises `#DIV/0!` and therefore lands as null. +Anything that is **not** a sentinel does fail the card. A misspelled function +name compiles happily and throws on the first read — `` fx`SUMM([Amounts])` `` +gives `'SUMM/1' is not defined` — and so does a structural operation applied to +the wrong shape (`` jq`unique` `` over an object rather than an array). Those +surface as an indexing error on the instance, not as a blank field, so a typo in +a formula is a broken card and not a quiet null. + ## 7. Linked cards, query-backed inverses, and staleness Paths traverse links, including several hops: `` jq`.policy.customer.name` `` @@ -187,7 +218,10 @@ Guidance: aggregate over query-backed inverses for display and reporting; do not treat such a field as a promptly-correct index-time fact, and do not build a filter or sort that depends on it being current. When the aggregate must be index-accurate, put the edge on the aggregating card (a stored `linksToMany`) -so a write to either side invalidates it. +so a write to either side invalidates it. Where you accept the lag, say so in a +comment at the field — the indexed value is server-computed and may differ from +the one on screen, and the next reader has no other way to tell that was a +choice. ## 8. Cyclic card graphs are safe but clipped @@ -204,10 +238,14 @@ Two consequences for data modeling: - Read a value from the near side of a cycle, not by walking back across it. A claim reaching `.policy.annualPremium` is fine; a policy reaching `.claims[].policy.annualPremium` gets null. -- A computed whose program enumerates its own card (`tojson`, `keys`, `unique` - over `.`) re-enters the field it is producing. That in-flight read is blank — - the spreadsheet circular-reference surface — so the value comes out as if the - field were empty rather than recursing. +- A computed whose program reads the **values** of its own card — `tojson`, + `to_entries`, `. == .` over `.` — re-enters the field it is producing. That + in-flight read is blank, the spreadsheet circular-reference surface, so + `` jq`tojson` `` on a card yields `{"a":1,"derived":null}` rather than + recursing. `` jq`keys` `` is safe for the opposite reason: it reads the field + names, never their values, so nothing re-enters. `` jq`unique` `` over `.` + is not a self-reference problem at all — it throws, because `unique` wants an + array and a card is an object. ## 9. Dates: serials are safe, "today" is not available @@ -289,15 +327,18 @@ and fails to identify. ## Reviewing a card's BXL -1. Every aggregate's argument is wrapped in `[…]`. -2. Every `\(…)` source is `` jq`…` ``-tagged. -3. Every divisor and every `&` operand that can be blank is guarded. -4. No formula reads the clock; date output is a serial or a span, not a phrase. +1. Every aggregate argument that iterates is collected — `SUM([Claims[].Paid])`, + never `SUM(Claims[].Paid)` — and no scalar parameter is. +2. Every `\(…)` source is tagged, and every label inside `\(…)` is a jq path. +3. Every divisor that can be blank is guarded; every `&` on a blank-able operand + is guarded or rewritten as `CONCAT` / `TEXTJOIN`. +4. Date output is a serial or a span, not a rendered phrase. 5. Aggregates over query-backed inverses are display values, not filter or sort - keys. -6. Structured output has `{ as: … }`. -7. A field whose indexed value is deliberately allowed to lag says so in a - comment at the field. + keys — and a field left to lag says so in a comment. +6. Output shaped as an object or an array of objects for a `FieldDef`-typed field + has `{ as: … }`. +7. Every function name is spelled the way the catalog spells it — a typo indexes + as an error, not as a blank. ## Where these rules are pinned @@ -336,6 +377,7 @@ The engine itself — the compiler, the jq runtime, the formula libraries, the mutation and authorization profiles — is documented in `packages/bxl/docs/` and is not this skill's subject. -The glossary's **bxl** and `computeVia: expression(...)` entries name -`library-bxl` and `extension-libs/bxl/` as their reference targets; this skill is -that reference. +The glossary's **bxl** entry points at `library-bxl` and `extension-libs/bxl/`, +and its `computeVia: expression(...)` entry at `library-bxl` and +`bxl-computevia-fields`. Those are reserved names rather than files that exist — +what they describe is this skill. diff --git a/packages/bxl/docs/syntax-modes.md b/packages/bxl/docs/syntax-modes.md index 399d79874a2..7b555ff6a08 100644 --- a/packages/bxl/docs/syntax-modes.md +++ b/packages/bxl/docs/syntax-modes.md @@ -192,12 +192,16 @@ These apply regardless of the tag: - **Null-tolerant arithmetic.** `null - 5`, `5 / 0`, `null * x`, `null | startswith("a")` all return `null` / `false` instead of throwing. -- **Aggregates take a collected array.** Function arguments are jq - streams, so `SUM(Claims[].Paid)` calls `SUM` once per claim and the - field receives one value per element. Collect first — - `SUM([Claims[].Paid])` — or supply a `schema`, which makes implicit - iteration collect on its own (`SUM("Line Item"."Line Total")` compiles - to `SUM([.lineItems[].lineTotal])`). +- **A single-argument aggregate takes a collected array.** Function + arguments are jq streams, so `SUM(Claims[].Paid)` calls `SUM` once per + claim and yields one output per claim rather than a total. Collect the + iterating argument — `SUM([Claims[].Paid])` — or supply a `schema`, + which makes implicit iteration collect on its own + (`SUM("Line Item"."Line Total")` compiles to + `SUM([.lineItems[].lineTotal])`). A comma list is already collected by + the compiler (`SUM(Paid, Reserve)` → `SUM([.paid, .reserve])`), and a + scalar parameter must not be wrapped: `ROUND([1.234], 2)` hands an + array to a function that wants a number. ## Mixed-syntax expressions diff --git a/packages/bxl/tests/boxel/authoring-skill-claims.ts b/packages/bxl/tests/boxel/authoring-skill-claims.ts index f53c94dca2f..ac03b78047c 100644 --- a/packages/bxl/tests/boxel/authoring-skill-claims.ts +++ b/packages/bxl/tests/boxel/authoring-skill-claims.ts @@ -5,22 +5,46 @@ // authors a set of concrete behaviors — which tag preserves `\(…)`, what the // derive profile refuses, how an aggregate reads a collection, what a blank // input produces. It ships to authors who cannot run the engine to check, so -// each claim is pinned here twice: +// each behavioral claim is pinned here twice: // // 1. The snippet the skill shows must still appear in the skill text, so a // rewrite that changes an example has to come through this file. // 2. The behavior that snippet claims must still hold against the engine. // -// Claims that need a live card runtime — query-backed inverse staleness, the -// `{ id }` clip across a cycle, the memoized-then-written paint — are pinned by -// the host integration suites the skill names; the last case here asserts those -// pointers still resolve. - -import { existsSync } from 'node:fs'; -import { readFileSync } from 'node:fs'; +// What that does and does not cover is worth stating plainly, because a guard +// trusted past its reach is worse than none. It covers the engine behaviors +// reachable from plain Node: tag dispatch, every derive refusal and every +// allowed form, the aggregate-collect rules, the blank-input table, sentinel +// versus non-sentinel failure, the date-function zone sweep, memoization modes, +// `{ as: … }` shapes, and self-enumeration. Two things it cannot check: +// +// - Prose. A `shows()` snippet proves the example is still on the page, not +// that the sentence around it still says the right thing. The reason +// columns, the guidance paragraphs, and §7/§10's architectural claims are +// read by people, not by this file. +// - Anything needing a live card runtime. Query-backed inverse staleness and +// the `{ id }` clip across a cycle are pinned by the host integration +// suites the skill names, and the last case here asserts those pointers +// resolve. §10's stale-paint interaction with Glimmer's render flush is NOT +// pinned anywhere — it follows from runloop ordering, and the mitigation +// the skill gives (`memoize: false`) is what is pinned. + +import { existsSync, readFileSync } from 'node:fs'; import { deepStrictEqual, ok, strictEqual } from 'node:assert'; import { join } from 'node:path'; -import { evaluateBxl, expression, fx, jq } from '../../src/index.ts'; +import { + evaluateBxl, + expression, + fx, + jq, + loadAllFormulaExtensions, +} from '../../src/index.ts'; + +// The host folds every lazy formula family into the default library set before +// serving `@cardstack/bxl` to card code, so a card reaches `NPV` and `isEmail` +// as readily as `ROUND`. Do the same here or those cases would assert against a +// narrower library than the skill's audience has. +await loadAllFormulaExtensions(); const REPO_ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const SKILL_PATH = join( @@ -60,6 +84,32 @@ function shows(snippet: string) { ); } +/** + * The bullet paragraph introduced by `lead`, up to the blank line that ends it. + * Used to pin which list a name is in — a name that moves between the refused + * and allowed lists has to move in this file too. + */ +function paragraph(lead: string): string { + const start = skill.indexOf(lead); + ok(start !== -1, `the skill no longer has a "${lead}" paragraph`); + const end = skill.indexOf('\n\n', start); + return flatten(skill.slice(start, end === -1 ? undefined : end)); +} + +/** Asserts `name` is listed in `lead`'s paragraph and not in `otherLead`'s. */ +function listedUnder(name: string, lead: string, otherLead: string) { + const mine = paragraph(lead); + const theirs = paragraph(otherLead); + ok(mine.includes(name), `"${name}" is no longer listed under "${lead}"`); + ok( + !theirs.includes(name), + `"${name}" has moved into "${otherLead}" — this case says otherwise`, + ); +} + +const REFUSED_LEAD = 'Refused: volatile calls'; +const ALLOWED_LEAD = 'Allowed and useful:'; + // ------------------------------------------------- tag dispatch check('a plain string drops the backslash before `(`', () => { @@ -68,32 +118,69 @@ check('a plain string drops the backslash before `(`', () => { // author spells it and the assertion below shows what JS hands on. // eslint-disable-next-line no-useless-escape -- the useless escape IS the trap const asAuthorTyped = '"\(.bpSystolic)/\(.bpDiastolic)"'; - strictEqual( - asAuthorTyped, - '"(.bpSystolic)/(.bpDiastolic)"', - 'a JS string literal drops the backslash before `(`', - ); strictEqual( evaluateBxl(asAuthorTyped, { bpSystolic: 120, bpDiastolic: 80 }).value, '(.bpSystolic)/(.bpDiastolic)', - 'so the interpolation is inert, and nothing throws to say so', + 'the interpolation is inert, and nothing throws to say so', ); }); -check('the jq tag preserves the interpolation', () => { +check('either tag preserves the interpolation', () => { shows('expression(jq`"\\(.bpSystolic)/\\(.bpDiastolic)"`)'); + shows('Either tag preserves it — both read the raw strings'); + const bp = { bpSystolic: 120, bpDiastolic: 80 }; strictEqual( - expression(jq`"\(.bpSystolic)/\(.bpDiastolic)"`).call({ - bpSystolic: 120, - bpDiastolic: 80, - }), + expression(jq`"\(.bpSystolic)/\(.bpDiastolic)"`).call(bp), '120/80', ); + strictEqual( + expression(fx`"\(.bpSystolic)/\(.bpDiastolic)"`).call(bp), + '120/80', + 'fx reads String.raw too, so the checklist must say tagged, not jq-tagged', + ); }); -check('fx resolves a bare PascalCase label to a camelCase path', () => { - shows('The compiler resolves them to `.paidAmount`'); +check('a PascalCase label inside an interpolation is not resolved', () => { + shows("throws `'PaidAmount/0' is not defined` the first"); + shows('Write the path — `` fx`"\\(.paidAmount) paid"` ``'); + const card = { paidAmount: 10 }; + // Construction is fine either way; the failure waits for the first read. + const unresolved = expression(fx`"\(PaidAmount) paid"`); + let message = ''; + try { + unresolved.call(card); + } catch (error) { + message = (error as Error).message; + } + ok( + message.includes(`'PaidAmount/0' is not defined`), + `expected an undefined-function throw, got: ${message.split('\n')[0]}`, + ); + strictEqual( + expression(fx`"\(.paidAmount) paid"`).call(card), + '10 paid', + 'the path spelling is what works', + ); +}); + +check('the tag table rows still read true', () => { + // The "Why" column is the reason an author picks a tag, so it is pinned with + // the row rather than left to prose. + shows( + '| `\\(…)` jq interpolation | `` jq`…` `` | A plain string drops the backslash — see trap 4 |', + ); + shows( + '| Bare PascalCase field labels (`PaidAmount`) | `` fx`…` `` | The compiler resolves them to `.paidAmount` |', + ); + shows( + '| Quoted multi-word labels (`"Line Item"`) | `fx` + `schema` | Label resolution needs the schema — see trap 3 |', + ); strictEqual(expression(fx`PaidAmount`).bxl.compiledSource, '.paidAmount'); + strictEqual( + expression('PaidAmount').bxl.compiledSource, + '.paidAmount', + 'a plain string compiles like fx, which the row below the table states', + ); }); check('IF is the Excel function, if/then/end is the jq construct', () => { @@ -153,6 +240,29 @@ check('every call the skill lists as refused is refused', () => { shows( 'Refused: volatile calls (`TODAY`, `NOW`, `RAND`, `RANDBETWEEN`) · request,', ); + for (const name of [ + 'TODAY', + 'NOW', + 'RAND', + 'RANDBETWEEN', + '@User', + '$new', + 'def', + 'try', + 'catch', + 'error', + 'label', + 'break', + '|=', + '..', + '@csv', + 'debug', + 'env', + 'input', + 'builtins', + ]) { + listedUnder(name, REFUSED_LEAD, ALLOWED_LEAD); + } const refused: Array<[string, () => unknown, string]> = [ ['TODAY()', () => expression(fx`TODAY()`), 'derive-call-banned'], ['NOW()', () => expression(fx`NOW()`), 'derive-call-banned'], @@ -198,30 +308,115 @@ check('every call the skill lists as refused is refused', () => { } }); -check('every form the skill lists as allowed constructs', () => { - shows('Allowed and useful: `IFERROR` / `IFNA` · optional access (`.a?`)'); - const allowed: Array<[string, () => unknown]> = [ - ['IFERROR', () => expression(fx`IFERROR(Amount, 0)`)], - ['IFNA', () => expression(fx`IFNA(Amount, 0)`)], - ['optional access', () => expression(jq`.a?`)], - ['SUM', () => expression(fx`SUM([Claims[].Paid])`)], - ['AVERAGE', () => expression(fx`AVERAGE([Claims[].Paid])`)], - ['COUNT', () => expression(fx`COUNT([Claims[].Paid])`)], - ['NPV', () => expression(fx`NPV(0.1, CashFlows)`)], - ['isEmail', () => expression(fx`isEmail(Email)`)], - ['LET', () => expression(fx`LET(t, SUM([Claims[].Paid]), t > 100)`)], - ['binding', () => expression(jq`. as $x | $x.a`)], - ['reduce', () => expression(jq`reduce .items[] as $i (0; . + $i)`)], - ['keys', () => expression(jq`keys`)], - ['to_entries', () => expression(jq`to_entries | map(.key)`)], - ['group_by', () => expression(jq`group_by(.status) | length`)], - ['unique', () => expression(jq`[.claims[]] | unique | length`)], - ['tojson', () => expression(jq`tojson | length`)], - ]; - for (const [label, make] of allowed) { - strictEqual(rejectionCode(make), 'accepted', `${label} should construct`); - } -}); +check( + 'every form the skill lists as allowed runs and produces its value', + () => { + shows('Allowed and useful: `IFERROR` / `IFNA` · optional access (`.a?`)'); + for (const name of [ + 'IFERROR', + 'IFNA', + '.a?', + 'SUM', + 'AVERAGE', + 'COUNT', + 'NPV', + 'isEmail', + 'LET', + 'reduce', + 'foreach', + 'keys', + 'to_entries', + 'group_by', + 'unique', + 'tojson', + ]) { + listedUnder(name, ALLOWED_LEAD, REFUSED_LEAD); + } + + // Constructing proves nothing on its own: the derive profile only screens the + // names it bans, so a name the registry has never heard of constructs too. + // Each of these therefore evaluates, and an unknown name is the control. + const card = { + amount: 4, + claims: [{ paid: 10 }, { paid: 5 }], + cashFlows: [-100, 60, 60], + email: 'ops@example.com', + items: [1, 2, 3], + status: 'Open', + a: 7, + }; + const allowed: Array<[string, () => unknown, unknown]> = [ + ['IFERROR', () => expression(fx`IFERROR(Amount, 0)`).call(card), 4], + ['IFNA', () => expression(fx`IFNA(Amount, 0)`).call(card), 4], + ['optional access', () => expression(jq`.a?`).call(card), 7], + ['SUM', () => expression(fx`SUM([Claims[].Paid])`).call(card), 15], + [ + 'AVERAGE', + () => expression(fx`AVERAGE([Claims[].Paid])`).call(card), + 7.5, + ], + ['COUNT', () => expression(fx`COUNT([Claims[].Paid])`).call(card), 2], + [ + 'NPV', + () => expression(fx`ROUND(NPV(0.1, CashFlows), 4)`).call(card), + 3.7566, + ], + ['isEmail', () => expression(fx`isEmail(Email)`).call(card), true], + [ + 'LET', + () => expression(fx`LET(t, SUM([Claims[].Paid]), t > 100)`).call(card), + false, + ], + ['binding', () => expression(jq`. as $x | $x.a`).call(card), 7], + [ + 'reduce', + () => expression(jq`reduce .items[] as $i (0; . + $i)`).call(card), + 6, + ], + [ + 'foreach', + () => + expression(jq`[foreach .items[] as $i (0; . + $i)] | last`).call( + card, + ), + 6, + ], + [ + 'group_by', + () => expression(jq`[.claims[]] | group_by(.paid) | length`).call(card), + 2, + ], + [ + 'unique', + () => expression(jq`[.claims[] | .paid] | unique | length`).call(card), + 2, + ], + ['to_entries', () => expression(jq`to_entries | length`).call(card), 7], + ['keys', () => expression(jq`keys | length`).call(card), 7], + ['tojson', () => expression(jq`tojson | length > 0`).call(card), true], + ]; + for (const [label, run, expected] of allowed) { + strictEqual( + rejectionCode(() => run()), + 'accepted', + `${label} threw`, + ); + strictEqual(run(), expected, `${label} produced the wrong value`); + } + + const unknown = expression(fx`TOTALLYFAKE(1)`); + let controlMessage = ''; + try { + unknown.call(card); + } catch (error) { + controlMessage = (error as Error).message; + } + ok( + controlMessage.includes('is not defined'), + 'control: an unknown function name must fail on read, or the cases above prove nothing', + ); + }, +); // ------------------------------------------------- aggregates over collections @@ -243,8 +438,19 @@ check('collecting first aggregates once', () => { strictEqual(compute.call(twoClaims), 15); }); -check('the jq spelling with an empty-case fallback', () => { - shows('computeVia: expression(jq`[.claims[] | .paidAmount] | add // 0`);'); +check('the WRONG example is the uncollected one', () => { + // Pinning the two code lines against their comments, not just the sources: + // swapping them would otherwise leave both snippets on the page and teach the + // exact inverse of the trap. + shows( + '// WRONG — compiles to SUM(.claims[].paid); with two claims the field gets [10, 5]\ncomputeVia: expression(fx`SUM(Claims[].Paid)`);', + ); + shows( + '// RIGHT — collect first, then aggregate: 15\ncomputeVia: expression(fx`SUM([Claims[].Paid])`);', + ); + shows( + '// RIGHT — the jq spelling, with a fallback for the empty case\ncomputeVia: expression(jq`[.claims[] | .paidAmount] | add // 0`);', + ); const compute = expression(jq`[.claims[] | .paidAmount] | add // 0`); strictEqual(compute.call({ claims: [] }), 0); strictEqual( @@ -253,6 +459,54 @@ check('the jq spelling with an empty-case fallback', () => { ); }); +check('the collect rule is only for the argument that iterates', () => { + shows('Collect **the argument that iterates**, and only that one'); + shows( + '`SUM(Amounts)` over a `containsMany(NumberField)` is correct as it stands', + ); + strictEqual( + expression(fx`SUM(Amounts)`).call({ amounts: [10, 5] }), + 15, + 'an array-valued field is one value already', + ); + + shows('`SUM(Paid, Reserve)` compiles to'); + shows('`SUM([.paid, .reserve])`, and even `SUM(Claims[].Paid, 0)` becomes'); + shows('`SUM([.claims[].paid, 0])`'); + strictEqual( + expression(fx`SUM(Paid, Reserve)`).bxl.compiledSource, + 'SUM([.paid, .reserve])', + ); + const multiArg = expression(fx`SUM(Claims[].Paid, 0)`); + strictEqual(multiArg.bxl.compiledSource, 'SUM([.claims[].paid, 0])'); + strictEqual( + multiArg.call(twoClaims), + 15, + 'a comma list collects, so the multi-argument spelling never showed the trap', + ); + + shows('**Never wrap a scalar parameter.** `ROUND([1.234], 2)` and'); + shows( + '`NPV([0.1], CashFlows)` hand an array to a function that wants a number', + ); + strictEqual( + expression(fx`ROUND([1.234], 2)`).call({}), + null, + 'a wrapped scalar blanks the field', + ); + strictEqual( + expression(fx`NPV([0.1], CashFlows)`).call({ cashFlows: [-100, 60, 60] }), + null, + ); + strictEqual( + expression(fx`ROUND(NPV(0.1, CashFlows), 4)`).call({ + cashFlows: [-100, 60, 60], + }), + 3.7566, + 'unwrapped, the same call works', + ); +}); + check('a schema makes implicit iteration collect on its own', () => { shows('`SUM("Line Item"."Line Total")` compiles to'); shows('`SUM([.lineItems[].lineTotal])`'); @@ -305,39 +559,41 @@ check('the factory exposes the compiled jq and the dependency list', () => { const noAmounts = {}; check('the blank-input table still reads true', () => { + // Whole rows, reason column included: the stated mechanism is as much a claim + // as the value, and a wrong mechanism is what sends an author down a wrong fix. const rows: Array<[string, () => unknown, unknown]> = [ [ - '| `` fx`Paid + Reserve` `` | `null` |', + '| `` fx`Paid + Reserve` `` | `null` | null propagates through arithmetic |', () => expression(fx`Paid + Reserve`).call(noAmounts), null, ], [ - '| `` fx`Paid + 5` `` | `5` |', + '| `` fx`Paid + 5` `` | `5` | a null addend contributes nothing |', () => expression(fx`Paid + 5`).call(noAmounts), 5, ], [ - '| `` fx`SUM(Paid, Reserve)` `` | `0` |', + '| `` fx`SUM(Paid, Reserve)` `` | `0` | aggregates skip blanks, Excel-style |', () => expression(fx`SUM(Paid, Reserve)`).call(noAmounts), 0, ], [ - '| `` fx`ROUND(Paid + Reserve)` `` | `0` |', + '| `` fx`ROUND(Paid + Reserve)` `` | `0` | `ROUND` absorbs a null operand |', () => expression(fx`ROUND(Paid + Reserve)`).call(noAmounts), 0, ], [ - '| `` fx`Premium / 0` `` | `null` |', + '| `` fx`Premium / 0` `` | `null` | division by zero yields null, not `#DIV/0!` |', () => expression(fx`Premium / 0`).call({ premium: 100 }), null, ], [ - '| `` jq`[.claims[] \\| .paid] \\| add` `` | `null` |', + '| `` jq`[.claims[] \\| .paid] \\| add` `` | `null` | `add` over an empty array is null |', () => expression(jq`[.claims[] | .paid] | add`).call({ claims: [] }), null, ], [ - '| `` jq`.name \\| startswith("a")` `` | `false` |', + '| `` jq`.name \\| startswith("a")` `` | `false` | string predicates on null are false |', () => expression(jq`.name | startswith("a")`).call(noAmounts), false, ], @@ -386,10 +642,71 @@ check('`&` renders a blank operand as the text null', () => { // ------------------------------------------------- Excel error sentinels check('an uncaught sentinel lands as null, not a throw', () => { - shows('caught at the factory boundary, which returns\n`null`'); + shows('caught at the factory boundary, which returns `null`'); + shows('`` fx`INDEX(Rows, 99)` `` leaves one blank field'); strictEqual(expression(fx`NA()`).call(noAmounts), null); + strictEqual(expression(fx`INDEX(Rows, 99)`).call({ rows: [1, 2, 3] }), null); + // Every sentinel the skill names has to be one this engine actually raises, + // or an author waits for an error that never comes. #NAME? is deliberately + // absent from that list: nothing throws it, and the failure it stands for in + // a spreadsheet — an unknown function — is the non-sentinel case below. + for (const sentinel of ['#N/A', '#DIV/0!', '#VALUE!', '#REF!', '#NUM!']) { + shows(sentinel); + } + ok( + !skillFlat.includes('#NAME?'), + 'the skill lists #NAME?, which this engine never raises', + ); }); +check('a sentinel blanks the whole expression, not just its own call', () => { + shows('`` fx`ROUND(NA()) + 5` `` is null, not 5'); + shows('even though `ROUND` absorbs a null operand'); + strictEqual( + expression(fx`ROUND(Paid) + 5`).call(noAmounts), + 5, + 'a null operand is absorbed by ROUND, so the addition still runs', + ); + strictEqual( + expression(fx`ROUND(NA()) + 5`).call(noAmounts), + null, + 'a sentinel propagates out of ROUND and blanks the field', + ); +}); + +check( + 'a non-sentinel failure fails the card instead of blanking a field', + () => { + shows("gives `'SUMM/1' is not defined`"); + shows('surface as an indexing error on the instance, not as a blank field'); + // A misspelled name passes the profile check — nothing validates that a name + // exists — and throws on first read, which is what reaches the indexer. + const misspelled = expression(fx`SUMM([Amounts])`); + let message = ''; + try { + misspelled.call({ amounts: [1, 2] }); + } catch (error) { + message = (error as Error).message; + } + ok( + message.includes(`'SUMM/1' is not defined`), + `expected an undefined-function throw, got: ${message.split('\n')[0] || '(no throw)'}`, + ); + + shows('(`` jq`unique` `` over an object rather than an array)'); + let structuralMessage = ''; + try { + expression(jq`unique`).call({ a: 1 }); + } catch (error) { + structuralMessage = (error as Error).message; + } + ok( + structuralMessage.length > 0, + 'a structural op on the wrong shape must throw rather than blank the field', + ); + }, +); + check('the catch-it-deliberately examples still work', () => { shows('fx`IFERROR(VLOOKUP(Sku, Rows, 2, FALSE), "unlisted")`'); strictEqual( @@ -516,43 +833,89 @@ check( // ------------------------------------------------- self-referential compute +/** + * Installs `source` as a computed getter on a record and reads it, counting how + * many times the getter is entered — one entry is the read itself, a second is + * the program re-entering the field it is producing. + */ +function selfRead(source: unknown) { + const compute = expression(source as never); + let entries = 0; + const record: Record = { + a: 1, + get derived() { + entries += 1; + return compute.call(this); + }, + }; + try { + return { value: record.derived, entries, threw: false }; + } catch (error) { + return { value: (error as Error).message, entries, threw: true }; + } +} + check( - 'a compute that enumerates its own record reads that field as blank', + 'reading a record’s own values re-enters and reads the field blank', () => { - shows('That in-flight read is blank —'); - const selfJson = expression(jq`tojson`); - const record = { - a: 1, - get serialized() { - return selfJson.call(this); - }, - }; - strictEqual( - record.serialized, - '{"a":1,"serialized":null}', - 'the in-flight field serializes as null instead of recursing', - ); + shows('`` jq`tojson` `` on a card yields `{"a":1,"derived":null}`'); + const serialized = selfRead(jq`tojson`); + strictEqual(serialized.threw, false); + strictEqual(serialized.value, '{"a":1,"derived":null}'); + strictEqual(serialized.entries, 2, 'the program re-entered the field'); }, ); +check('`keys` reads field names, so nothing re-enters', () => { + shows('it reads the field names, never their values, so nothing re-enters'); + const named = selfRead(jq`keys`); + strictEqual(named.threw, false); + deepStrictEqual(named.value, ['a', 'derived']); + strictEqual(named.entries, 1, 'no re-entry — the values were never read'); +}); + +check('`unique` over a record throws rather than reading blank', () => { + shows('is not a self-reference problem at all — it throws, because `unique`'); + const deduped = selfRead(jq`unique`); + strictEqual(deduped.threw, true, 'unique over an object must throw'); +}); + // ------------------------------------------------- the skill's own pointers check('every repo path the skill cites exists', () => { - const cited = [ - ...new Set( - Array.from( - skill.matchAll(/`(packages\/[A-Za-z0-9._/-]+)`/g), - (match) => match[1], - ), + const cited = new Set( + Array.from( + skill.matchAll(/`(packages\/[A-Za-z0-9._/-]+)`/g), + (match) => match[1], ), - ]; - ok( - cited.length >= 6, - `expected the pinned-rules list, found ${cited.length}`, ); + // A floor on the count would let a citation be dropped silently, so the + // pointers the skill's pinned-rules list is built on are named here. + for (const required of [ + 'packages/bxl/tests/boxel/authoring-skill-claims.ts', + 'packages/host/tests/helpers/cards/bxl-tracking.ts', + 'packages/host/tests/integration/bxl-expression-test.gts', + 'packages/host/tests/integration/bxl-platform-module-test.gts', + 'packages/host/tests/integration/bxl-cyclic-graph-test.gts', + 'packages/bxl/tests/boxel/', + 'packages/bxl/docs/', + ]) { + ok(cited.has(required), `the skill no longer cites ${required}`); + } for (const path of cited) { ok(existsSync(join(REPO_ROOT, path)), `the skill cites a missing ${path}`); } + // The docs are cited by bare filename, relative to the `packages/bxl/docs/` + // entry above, so a rename there would otherwise slip through. + for (const doc of Array.from( + skill.matchAll(/`([a-z-]+\.md)`/g), + (match) => match[1], + )) { + ok( + existsSync(join(REPO_ROOT, 'packages/bxl/docs', doc)), + `the skill cites a missing packages/bxl/docs/${doc}`, + ); + } }); // ---------------------------------------------------------------- From 53fc9beb3d639d1cf80e4a0412bd6bd0eed44918 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 19 Aug 2026 11:08:28 -0400 Subject: [PATCH 3/7] Pin the refusal and allowance lists exactly, by token The two lists are read as their backticked spans and compared as sets against the names the cases cover, so an entry added without a case, dropped, or moved between the lists fails here. Exact tokens rather than substrings: a short name like `env` cannot be satisfied by a longer word containing it, or by appearing inside a neighbouring code span. That brings @Env, $old, stderr, halt and bare `=` under evaluation alongside the rest, and each allowed form now evaluates once per case rather than twice, so a throw and a wrong value are told apart by where the failure lands. Co-Authored-By: Claude Opus 5 (1M context) --- .../bxl/tests/boxel/authoring-skill-claims.ts | 153 ++++++++---------- 1 file changed, 64 insertions(+), 89 deletions(-) diff --git a/packages/bxl/tests/boxel/authoring-skill-claims.ts b/packages/bxl/tests/boxel/authoring-skill-claims.ts index ac03b78047c..7a87a83643e 100644 --- a/packages/bxl/tests/boxel/authoring-skill-claims.ts +++ b/packages/bxl/tests/boxel/authoring-skill-claims.ts @@ -85,25 +85,29 @@ function shows(snippet: string) { } /** - * The bullet paragraph introduced by `lead`, up to the blank line that ends it. - * Used to pin which list a name is in — a name that moves between the refused - * and allowed lists has to move in this file too. + * The names the paragraph introduced by `lead` lists, read as its backticked + * spans. Exact tokens rather than substrings: `env` must not be satisfied by + * some longer word that happens to contain it, and a name must not count as + * listed because it appears inside a neighbouring code span. */ -function paragraph(lead: string): string { +function listedNames(lead: string): string[] { const start = skill.indexOf(lead); ok(start !== -1, `the skill no longer has a "${lead}" paragraph`); const end = skill.indexOf('\n\n', start); - return flatten(skill.slice(start, end === -1 ? undefined : end)); + const body = flatten(skill.slice(start, end === -1 ? undefined : end)); + return Array.from(body.matchAll(/`([^`]+)`/g), (match) => match[1].trim()); } -/** Asserts `name` is listed in `lead`'s paragraph and not in `otherLead`'s. */ -function listedUnder(name: string, lead: string, otherLead: string) { - const mine = paragraph(lead); - const theirs = paragraph(otherLead); - ok(mine.includes(name), `"${name}" is no longer listed under "${lead}"`); - ok( - !theirs.includes(name), - `"${name}" has moved into "${otherLead}" — this case says otherwise`, +/** + * Asserts the paragraph lists exactly `covered` — every name the cases below + * exercise, and nothing else. An entry added to the skill without a case, moved + * between the two lists, or dropped from either fails here. + */ +function listsExactly(lead: string, covered: Array) { + deepStrictEqual( + listedNames(lead).sort(), + covered.flat().sort(), + `the "${lead}" list and this suite's cases have diverged`, ); } @@ -240,71 +244,54 @@ check('every call the skill lists as refused is refused', () => { shows( 'Refused: volatile calls (`TODAY`, `NOW`, `RAND`, `RANDBETWEEN`) · request,', ); - for (const name of [ - 'TODAY', - 'NOW', - 'RAND', - 'RANDBETWEEN', - '@User', - '$new', - 'def', - 'try', - 'catch', - 'error', - 'label', - 'break', - '|=', - '..', - '@csv', - 'debug', - 'env', - 'input', - 'builtins', - ]) { - listedUnder(name, REFUSED_LEAD, ALLOWED_LEAD); - } - const refused: Array<[string, () => unknown, string]> = [ - ['TODAY()', () => expression(fx`TODAY()`), 'derive-call-banned'], - ['NOW()', () => expression(fx`NOW()`), 'derive-call-banned'], - ['RAND()', () => expression(fx`RAND()`), 'derive-call-banned'], + // Each entry: the names it covers in the skill's list, a source that reaches + // them, and the diagnostic code the factory must answer with. + const refused: Array<[string | string[], () => unknown, string]> = [ + ['TODAY', () => expression(fx`TODAY()`), 'derive-call-banned'], + ['NOW', () => expression(fx`NOW()`), 'derive-call-banned'], + ['RAND', () => expression(fx`RAND()`), 'derive-call-banned'], [ - 'RANDBETWEEN(1, 6)', + 'RANDBETWEEN', () => expression(fx`RANDBETWEEN(1, 6)`), 'derive-call-banned', ], - ['@User.id', () => expression(fx`@User.id`), 'derive-context-banned'], - ['$new.total', () => expression(fx`$new.total`), 'derive-context-banned'], + ['@User', () => expression(fx`@User.id`), 'derive-context-banned'], + ['@Env', () => expression(fx`@Env.region`), 'derive-context-banned'], + ['$new', () => expression(fx`$new.total`), 'derive-context-banned'], + ['$old', () => expression(fx`$old.total`), 'derive-context-banned'], ['def', () => expression(jq`def f: . + 1; f`), 'derive-def-banned'], - ['try/catch', () => expression(jq`try .a catch "x"`), 'derive-try-banned'], + [ + ['try', 'catch'], + () => expression(jq`try .a catch "x"`), + 'derive-try-banned', + ], ['error', () => expression(jq`error("boom")`), 'derive-call-banned'], [ - 'label/break', + ['label', 'break'], () => expression(jq`label $out | .a, break $out`), 'derive-control-flow-banned', ], + ['=', () => expression(jq`.total = 5`), 'derive-assignment-banned'], + ['|=', () => expression(jq`.total |= . + 1`), 'derive-assignment-banned'], [ - 'assignment =', - () => expression(jq`.total = 5`), - 'derive-assignment-banned', - ], - [ - 'assignment |=', - () => expression(jq`.total |= . + 1`), - 'derive-assignment-banned', - ], - [ - 'recursive descent', + '..', () => expression(jq`.. | numbers`), 'derive-recursive-descent-banned', ], ['@csv', () => expression(jq`[.a, .b] | @csv`), 'derive-format-banned'], ['debug', () => expression(jq`debug`), 'derive-call-banned'], ['env', () => expression(jq`env`), 'derive-call-banned'], + ['stderr', () => expression(jq`stderr`), 'derive-call-banned'], + ['halt', () => expression(jq`halt`), 'derive-call-banned'], ['input', () => expression(jq`input`), 'derive-call-banned'], ['builtins', () => expression(jq`builtins | length`), 'derive-call-banned'], ]; - for (const [label, make, code] of refused) { - strictEqual(rejectionCode(make), code, `${label} should be ${code}`); + listsExactly( + REFUSED_LEAD, + refused.map(([names]) => names), + ); + for (const [names, make, code] of refused) { + strictEqual(rejectionCode(make), code, `${names} should be ${code}`); } }); @@ -312,27 +299,6 @@ check( 'every form the skill lists as allowed runs and produces its value', () => { shows('Allowed and useful: `IFERROR` / `IFNA` · optional access (`.a?`)'); - for (const name of [ - 'IFERROR', - 'IFNA', - '.a?', - 'SUM', - 'AVERAGE', - 'COUNT', - 'NPV', - 'isEmail', - 'LET', - 'reduce', - 'foreach', - 'keys', - 'to_entries', - 'group_by', - 'unique', - 'tojson', - ]) { - listedUnder(name, ALLOWED_LEAD, REFUSED_LEAD); - } - // Constructing proves nothing on its own: the derive profile only screens the // names it bans, so a name the registry has never heard of constructs too. // Each of these therefore evaluates, and an unknown name is the control. @@ -345,10 +311,10 @@ check( status: 'Open', a: 7, }; - const allowed: Array<[string, () => unknown, unknown]> = [ + const allowed: Array<[string | string[], () => unknown, unknown]> = [ ['IFERROR', () => expression(fx`IFERROR(Amount, 0)`).call(card), 4], ['IFNA', () => expression(fx`IFNA(Amount, 0)`).call(card), 4], - ['optional access', () => expression(jq`.a?`).call(card), 7], + ['.a?', () => expression(jq`.a?`).call(card), 7], ['SUM', () => expression(fx`SUM([Claims[].Paid])`).call(card), 15], [ 'AVERAGE', @@ -367,7 +333,7 @@ check( () => expression(fx`LET(t, SUM([Claims[].Paid]), t > 100)`).call(card), false, ], - ['binding', () => expression(jq`. as $x | $x.a`).call(card), 7], + ['. as $x | …', () => expression(jq`. as $x | $x.a`).call(card), 7], [ 'reduce', () => expression(jq`reduce .items[] as $i (0; . + $i)`).call(card), @@ -395,13 +361,22 @@ check( ['keys', () => expression(jq`keys | length`).call(card), 7], ['tojson', () => expression(jq`tojson | length > 0`).call(card), true], ]; - for (const [label, run, expected] of allowed) { - strictEqual( - rejectionCode(() => run()), - 'accepted', - `${label} threw`, - ); - strictEqual(run(), expected, `${label} produced the wrong value`); + listsExactly( + ALLOWED_LEAD, + allowed.map(([names]) => names), + ); + for (const [names, run, expected] of allowed) { + // One evaluation per case: a throw and a wrong value are distinguished by + // where this lands, not by running the source twice. + let value: unknown; + try { + value = run(); + } catch (error) { + throw new Error( + `${names} threw instead of evaluating: ${(error as Error).message.split('\n')[0]}`, + ); + } + strictEqual(value, expected, `${names} produced the wrong value`); } const unknown = expression(fx`TOTALLYFAKE(1)`); From f485ee8a348efcdee8b71a747c7395da4cdb3769 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 19 Aug 2026 11:48:08 -0400 Subject: [PATCH 4/7] Move the skill to the skills repo, keep its drift guard here MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A card-authoring skill has to reach both agent harnesses, and the monorepo's plugin tree reaches only one. cardstack/boxel-skills is the single source: build:skills copies skills//SKILL.md from a pinned tag into the boxel-cli plugin, and the same repo syncs to the skills realm, where a Skill card makes the file loadable in the in-app AI assistant. The nine hand-authored skills in plugin/skills/ are CLI-command docs and none carry the boxel.kind marker. What stays here is the half the monorepo owns: the suite that pins the skill's claims against the engine, the syntax-modes.md corrections, and the change filter. The suite reads the copy build:skills brings in, so bumping BOXEL_SKILLS_VERSION to a tag carrying the skill is what turns it green — verified locally by staging the upstream file at the copy's path, 31/31. Co-Authored-By: Claude Opus 5 (1M context) --- .../plugin/skills/bxl-authoring/SKILL.md | 383 ------------------ 1 file changed, 383 deletions(-) delete mode 100644 packages/boxel-cli/plugin/skills/bxl-authoring/SKILL.md diff --git a/packages/boxel-cli/plugin/skills/bxl-authoring/SKILL.md b/packages/boxel-cli/plugin/skills/bxl-authoring/SKILL.md deleted file mode 100644 index 44eb9ab899a..00000000000 --- a/packages/boxel-cli/plugin/skills/bxl-authoring/SKILL.md +++ /dev/null @@ -1,383 +0,0 @@ ---- -name: bxl-authoring -description: 'Use when writing or reviewing a BXL expression in a Boxel card — a computeVia built from expression(), the fx / jq tags, spreadsheet-formula fields, aggregations over linked or query-backed collections. Covers which tag to reach for, what the derive profile refuses outright, the silent traps (a stream where an aggregate was meant, jq interpolation in a plain string, & on a blank field, dates and "today"), and why an indexed computed can differ from the one the viewer sees. Activates on expression(, fx`…`, jq`…`, "BXL", "formula field", "computed field with Excel functions", "sum the linked cards".' ---- - -# Authoring BXL in a card - -BXL is the workspace's expression language: readable spreadsheet syntax and Excel -formula libraries on top of a jq engine. In a card it drives `computeVia`. - -```ts -import { expression, fx, jq } from '@cardstack/bxl'; - -export class Claim extends CardDef { - @field paidAmount = contains(NumberField); - @field reserveAmount = contains(NumberField); - @field incurredAmount = contains(NumberField, { - computeVia: expression(fx`ROUND((PaidAmount + ReserveAmount) * 100) / 100`), - }); -} -``` - -`@cardstack/bxl` is a platform module — the host serves it to card code, so the -bare specifier is the import. Only the package root is card-facing. `expression` -is the factory (`bxl` and `expr` are aliases); it compiles the source once when -the class body runs, then evaluates it against the card instance on each read. - -The rest of this skill is the decision layer and the trap list. For the full -syntax surface — labels, row selectors, predicates, the Excel function matrix — -read [bxl.boxel.site](https://bxl.boxel.site). - -## 1. Which tag - -| Source contains | Reach for | Why | -| ------------------------------------------- | --------------- | ------------------------------------------------------ | -| `\(…)` jq interpolation | `` jq`…` `` | A plain string drops the backslash — see trap 4 | -| Excel functions (`ROUND`, `IFS`, `SUM`) | `` fx`…` `` | Readable-syntax compilation, explicit at the call site | -| Bare PascalCase field labels (`PaidAmount`) | `` fx`…` `` | The compiler resolves them to `.paidAmount` | -| Pure jq (`.claims \| length`) | `` jq`…` `` | Skips the readable-syntax compile step | -| Quoted multi-word labels (`"Line Item"`) | `fx` + `schema` | Label resolution needs the schema — see trap 3 | - -A plain string compiles exactly like `` fx`…` ``. Prefer a tag: it tells the next -reader which dialect they are in, and it is the only form that survives `\(…)`. - -Mixing dialects inside one source is fine — `` fx`IF(.status == "Open", 1, 0)` `` -and `` fx`if Status == "Open" then 1 else 0 end` `` both work. Case is the -dispatch: `IF(cond, t, f)` is the Excel function, `if cond then … end` is the jq -construct. - -## 2. The `derive` profile — what a computed may not do - -`expression()` validates against the `derive` profile **when the field is -defined**, so a violation throws while the card module loads rather than -producing a wrong value. The diagnostic names the rule: - -```text -computeVia expression violates the derive profile: -derive-call-banned: Profile.derive is for deterministic write/index-time -computation and cannot use call TODAY: volatile calls are not stable write-time -derivations. -``` - -Refused: volatile calls (`TODAY`, `NOW`, `RAND`, `RANDBETWEEN`) · request, -actor and mutation context (`@User`, `@Env`, `$new`, `$old`) · user-defined -`def` helpers · jq `try` / `catch` · `error` · `label` / `break` · assignment -(`=`, `|=`) · recursive descent (`..`) · format filters (`@csv`) · control and -side-effect calls (`debug`, `env`, `input`, `stderr`, `halt`) · runtime metadata -(`builtins`). - -Allowed and useful: `IFERROR` / `IFNA` · optional access (`.a?`) · aggregates -(`SUM`, `AVERAGE`, `COUNT`, `NPV`) · validator helpers (`isEmail`) · `LET` · -bindings (`. as $x | …`) · explicit folds (`reduce`, `foreach`) · structural ops -(`keys`, `to_entries`, `group_by`, `unique`, `tojson`). - -The boundary is determinism: a derived value comes from the record snapshot, not -from the clock, the viewer, or the request. It is computed once server-side and -stored in the search doc, so anything ambient would bake one viewer's answer in -for everyone. - -## 3. An aggregate needs a collected array, not a stream - -**The single most expensive trap.** jq function arguments are streams, so -navigating into an array field and handing that straight to an aggregate calls -the aggregate once *per element* — and the field receives an array of per-element -results instead of one number. Nothing errors. - -```ts -// WRONG — compiles to SUM(.claims[].paid); with two claims the field gets [10, 5] -computeVia: expression(fx`SUM(Claims[].Paid)`); - -// RIGHT — collect first, then aggregate: 15 -computeVia: expression(fx`SUM([Claims[].Paid])`); - -// RIGHT — the jq spelling, with a fallback for the empty case -computeVia: expression(jq`[.claims[] | .paidAmount] | add // 0`); -``` - -Same for `AVERAGE`, `COUNT`, `MAX`, `MIN`, `SUMIF`. Collect **the argument that -iterates**, and only that one — the rule is narrower than "wrap everything", and -wrapping the wrong thing is its own bug: - -- An iterating path is what needs it: `SUM([Claims[].Paid])`, - `SUMIF([Claims[].Paid], ">6")`. -- A field that already holds an array is a single value. `SUM(Amounts)` over a - `containsMany(NumberField)` is correct as it stands. -- A comma list is collected by the compiler, which is why the multi-argument - spelling has never shown the trap: `SUM(Paid, Reserve)` compiles to - `SUM([.paid, .reserve])`, and even `SUM(Claims[].Paid, 0)` becomes - `SUM([.claims[].paid, 0])`. Write the `[…]` anyway — the single-argument form - is what an author reaches for next. -- **Never wrap a scalar parameter.** `ROUND([1.234], 2)` and - `NPV([0.1], CashFlows)` hand an array to a function that wants a number; each - returns null. - -Passing a `schema` is the other way to get this right — with field metadata, the -compiler collects implicitly, so `SUM("Line Item"."Line Total")` compiles to -`SUM([.lineItems[].lineTotal])`. Without a schema, quoted multi-word labels fail -loudly (`Cannot index string with string`) and a bare PascalCase label resolves -to a single camelCase path segment. A card gets no schema unless the expression -passes one. - -Check the compiled jq when in doubt — the factory exposes it: - -```ts -expression(fx`SUM([Claims[].Paid])`).bxl; -// { source, compiledSource: 'SUM([.claims[].paid])', warnings, deps, memoize } -``` - -## 4. `\(…)` in a plain string is silently inert - -A JS string literal and an untagged template both drop the backslash before `(`, -so the runtime never sees the interpolation and the field renders the literal -text `(.bpSystolic)/(.bpDiastolic)`. No lint pass in a realm flags this. - -```ts -// WRONG — yields "(.bpSystolic)/(.bpDiastolic)" -computeVia: expression('"\(.bpSystolic)/\(.bpDiastolic)"'); - -// RIGHT — the tag passes the raw source through -computeVia: expression(jq`"\(.bpSystolic)/\(.bpDiastolic)"`); -``` - -Either tag preserves it — both read the raw strings, so `` fx`"\(.bpSystolic)"` `` -interpolates too. The plain string is the one broken form. - -Inside `\(…)` you are in jq, and the readable-syntax pass does not reach in: -a PascalCase label there is read as a function call, and -`` fx`"\(PaidAmount) paid"` `` throws `'PaidAmount/0' is not defined` the first -time the field is read. Write the path — `` fx`"\(.paidAmount) paid"` ``. - -## 5. Blank inputs: what propagates, what absorbs - -Missing and null operands are tolerated rather than fatal, which means a wrong -answer is quiet. The model, for a card whose numeric fields are unset: - -| Expression | Result | Reason | -| ------------------------------------- | ------- | ------------------------------------------- | -| `` fx`Paid + Reserve` `` | `null` | null propagates through arithmetic | -| `` fx`Paid + 5` `` | `5` | a null addend contributes nothing | -| `` fx`SUM(Paid, Reserve)` `` | `0` | aggregates skip blanks, Excel-style | -| `` fx`ROUND(Paid + Reserve)` `` | `0` | `ROUND` absorbs a null operand | -| `` fx`Premium / 0` `` | `null` | division by zero yields null, not `#DIV/0!` | -| `` jq`[.claims[] \| .paid] \| add` `` | `null` | `add` over an empty array is null | -| `` jq`.name \| startswith("a")` `` | `false` | string predicates on null are false | - -Guard with `//`, the jq alternative operator: `(Paid // 0) + (Reserve // 0)`, -`add // 0`. Because division by zero produces null rather than an error, -`IFERROR` does **not** rescue it — guard the divisor instead. - -**`&` renders a blank operand as the text `null`.** `` fx`Name & " (" & Tier & -")"` `` on a card with no tier yields `Acme (null)`. Write -`` fx`Name & " (" & (Tier // "") & ")"` ``, or use `CONCAT` / `TEXTJOIN`, which -drop blanks. - -## 6. Excel error sentinels blank the field; other failures fail the card - -Sentinels (`#N/A`, `#DIV/0!`, `#VALUE!`, `#REF!`, `#NUM!`) are raised as values -inside evaluation and caught at the factory boundary, which returns `null`. That -much is safe: `` fx`INDEX(Rows, 99)` `` leaves one blank field rather than -failing the card. - -A sentinel blanks the **whole** expression, not the sub-call that raised it — it -propagates out through every enclosing call until the boundary catches it. -`` fx`ROUND(NA()) + 5` `` is null, not 5, even though `ROUND` absorbs a null -operand. Catch it where you want the fallback: -`` fx`IFERROR(VLOOKUP(Sku, Rows, 2, FALSE), "unlisted")` ``, -`` fx`IFNA(NA(), "none")` ``. An `AVERAGE` over an empty collection raises -`#DIV/0!` and therefore lands as null. - -Anything that is **not** a sentinel does fail the card. A misspelled function -name compiles happily and throws on the first read — `` fx`SUMM([Amounts])` `` -gives `'SUMM/1' is not defined` — and so does a structural operation applied to -the wrong shape (`` jq`unique` `` over an object rather than an array). Those -surface as an indexing error on the instance, not as a blank field, so a typo in -a formula is a broken card and not a quiet null. - -## 7. Linked cards, query-backed inverses, and staleness - -Paths traverse links, including several hops: `` jq`.policy.customer.name` `` -reads across two `linksTo` edges, and a missing hop anywhere yields null. - -A query-backed `linksToMany` — the inverse side, derived from a filter rather -than stored on the card — behaves differently from a stored link, and this is -the part worth understanding before you aggregate over one: - -- It resolves against the **live index at visit time**. On a realm's first index - pass the index is still empty, so aggregates over the inverse bake in their - empty-set values; the next visit of the aggregating card converges them. -- Only stored edges drive invalidation. Writing a `Claim` reindexes that claim; - the `Policy` whose inverse contains it keeps the aggregate from its last visit - until something revisits the policy. Aggregates over an inverse are eventually - consistent by design. -- The browser resolves the inverse live during render, so the number a viewer - sees can be the converged one while the indexed value — the one search filters - and sorts on — is still from the last visit. - -Guidance: aggregate over query-backed inverses for display and reporting; do not -treat such a field as a promptly-correct index-time fact, and do not build a -filter or sort that depends on it being current. When the aggregate must be -index-accurate, put the edge on the aggregating card (a stored `linksToMany`) -so a write to either side invalidates it. Where you accept the lag, say so in a -comment at the field — the indexed value is server-computed and may differ from -the one on screen, and the next reader has no other way to tell that was a -choice. - -## 8. Cyclic card graphs are safe but clipped - -Card graphs are legitimately cyclic (a claim links to its policy, the policy's -inverse contains the claim); jq's data model is not. Re-entering a card already -on the traversal path yields a bounded `{ id }` reference — the same clip a -search doc applies — so `` jq`[.claims[] | .policy.id]` `` reads one id per -claim, and the policy's other fields read null from that direction. Structural operations -(`unique`, `tojson`, `==`) terminate and stay field-aware, comparing cards by -their materialized values. - -Two consequences for data modeling: - -- Read a value from the near side of a cycle, not by walking back across it. A - claim reaching `.policy.annualPremium` is fine; a policy reaching - `.claims[].policy.annualPremium` gets null. -- A computed whose program reads the **values** of its own card — `tojson`, - `to_entries`, `. == .` over `.` — re-enters the field it is producing. That - in-flight read is blank, the spreadsheet circular-reference surface, so - `` jq`tojson` `` on a card yields `{"a":1,"derived":null}` rather than - recursing. `` jq`keys` `` is safe for the opposite reason: it reads the field - names, never their values, so nothing re-enters. `` jq`unique` `` over `.` - is not a self-reference problem at all — it throws, because `unique` wants an - array and a card is an object. - -## 9. Dates: serials are safe, "today" is not available - -Indexing evaluates computeds server-side; a browser evaluates them in the -viewer's zone. Date functions are anchored so that they answer the same in -either place: `DATE`, `EDATE`, `EOMONTH`, `WEEKDAY`, `DATEVALUE`, `YEARFRAC`, -`DAYS`, `NETWORKDAYS` and friends give one answer across host zones. Serial -arithmetic and explicit Y/M/D construction are the safe idioms. - -`TODAY` and `NOW` are not available in a computed at all — the `derive` profile -refuses them, because an indexed value computed once from the clock is wrong for -every later read. So: - -- Compute the **fact**: a due-date serial, a span between two stored dates, a - boolean over stored dates. -- Render the **relative phrase** in the component, where the viewer's clock and - zone are the right ones. A computed that yields "3 days overdue" is a trap; one - that yields the due-date serial and lets the template phrase it is not. -- If a card genuinely needs a local-time value, it belongs in the rendering - layer. Anything indexed is computed once, server-side, for all viewers. - -## 10. Memoization is per-instance and microtask-scoped - -`expression()` caches its result per card instance until the current microtask -ends, which collapses the repeated synchronous reads a serialization or search -pass makes. Glimmer flushes re-renders synchronously at the end of an action, so -an action that **reads a formula and then writes one of that formula's inputs in -the same burst** paints once with the cached value. It heals on the next change -to that card. Write-only actions never see it. - -Pass `memoize: false` for a formula an action reads before writing its inputs: - -```ts -@field statusPanel = contains(PanelField, { - computeVia: expression(jq`{ label: .status }`, { - as: PanelField, - memoize: false, - }), -}); -``` - -## 11. `{ as: FieldDef }` for structured output - -An expression yields plain JSON. When the field's type is a `FieldDef`, pass -`as` so the value is rebuilt as an instance the serializer can identify — object -keys map to the field def's `@field` names, nested `contains` values materialize -as their own field-def instances, and each element of an array output gets the -same treatment. Scalars and null pass through untouched. - -```ts -export class RiskBandField extends FieldDef { - @field label = contains(StringField); - @field score = contains(NumberField); - @field flags = containsMany(StringField); -} - -@field riskBand = contains(RiskBandField, { - computeVia: expression( - jq`{ - label: (if .lossRatio >= 0.8 then "High" else "Low" end), - score: ((.lossRatio * 100) | round), - flags: (if .lossRatio >= 0.8 then ["review"] else [] end) - }`, - { as: RiskBandField }, - ), -}); - -// containsMany — one materialized instance per element -@field claimBands = containsMany(RiskBandField, { - computeVia: expression( - jq`[.claims[] | { label: .severityBand, score: .paidAmount }]`, - { as: RiskBandField }, - ), -}); -``` - -Without `as`, a structured value reaches the serializer as an anonymous object -and fails to identify. - -## Reviewing a card's BXL - -1. Every aggregate argument that iterates is collected — `SUM([Claims[].Paid])`, - never `SUM(Claims[].Paid)` — and no scalar parameter is. -2. Every `\(…)` source is tagged, and every label inside `\(…)` is a jq path. -3. Every divisor that can be blank is guarded; every `&` on a blank-able operand - is guarded or rewritten as `CONCAT` / `TEXTJOIN`. -4. Date output is a serial or a span, not a rendered phrase. -5. Aggregates over query-backed inverses are display values, not filter or sort - keys — and a field left to lag says so in a comment. -6. Output shaped as an object or an array of objects for a `FieldDef`-typed field - has `{ as: … }`. -7. Every function name is spelled the way the catalog spells it — a typo indexes - as an error, not as a blank. - -## Where these rules are pinned - -In `cardstack/boxel`, the behavior above is locked down by tests, which are the -place to check a detail or add a case: - -- `packages/bxl/tests/boxel/authoring-skill-claims.ts` — this skill's own drift - guard: for each claim above it asserts the snippet still appears in this file - and still behaves as described. Editing an example here means editing that - suite too. -- `packages/host/tests/helpers/cards/bxl-tracking.ts` — the worked example this - skill draws on: an insurance domain exercising all three tags, linked and - query-backed traversal, null tolerance, sentinels, and `{ as: … }`. -- `packages/host/tests/integration/bxl-expression-test.gts` — the factory on - real cards, including the memoization contract. -- `packages/host/tests/integration/bxl-platform-module-test.gts` — the platform - module end to end, and the query-backed first-pass/converge contract. -- `packages/host/tests/integration/bxl-cyclic-graph-test.gts` — the `{ id }` - clip and structural operations across a cycle. -- `packages/bxl/tests/boxel/` — null tolerance, tag dispatch, the `\(…)` - preservation rule, sentinel catching, and `as` materialization over plain - objects. -- `packages/bxl/docs/` — `syntax-modes.md` (call-site modes), `profiles.md` - (the `derive` contract), `formulas.md` (the Excel matrix), - `realm-composition.md` (threading inputs into child field defs). - -## Adjacent skills - -- Query-backed `linksToMany` and inbound-reference lookups — `boxel-patterns`, - pattern `automate-linked-to-me-lookup`. -- Field types, formats, and templates — `boxel`; silent-failure traps outside - BXL — `boxel-workspace-cardinal-rules`. -- Why a card failed to index or holds broken links — `indexing-errors`. - -The engine itself — the compiler, the jq runtime, the formula libraries, the -mutation and authorization profiles — is documented in `packages/bxl/docs/` and -is not this skill's subject. - -The glossary's **bxl** entry points at `library-bxl` and `extension-libs/bxl/`, -and its `computeVia: expression(...)` entry at `library-bxl` and -`bxl-computevia-fields`. Those are reserved names rather than files that exist — -what they describe is this skill. From 48d3f73040055aab7efc87b60721f9dca108fdb3 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 19 Aug 2026 11:57:53 -0400 Subject: [PATCH 5/7] Fix the README's authoring section and widen the bxl change filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package's front-page "Using BXL inside Boxel" section taught the relative-bundle import as the only form and an uncollected SUM("Line Item".Amount) — which, with no schema, is the form that throws "Cannot index string with string" on the first read. It now names the platform module and collects the iterating argument, matching the syntax-modes guidance. The bxl filter gains the host paths the drift guard asserts exist. bxl-test runs unconditionally on main, so a host-only rename of a cited suite would otherwise merge green and redden main on a commit whose own CI never ran the failing suite. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yaml | 6 ++++++ packages/bxl/README.md | 13 +++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 99b3d5cc812..0e7b4013a50 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -141,6 +141,12 @@ jobs: # that reads the skill file, so an edit to the skill has to run # that suite too. - 'packages/boxel-cli/plugin/skills/bxl-authoring/**' + # That suite also asserts the host suites the skill cites still + # exist. `bxl-test` runs unconditionally on main, so without + # these a host-only rename would merge green and redden main on + # a commit whose own CI never ran the failing suite. + - 'packages/host/tests/integration/bxl-*' + - 'packages/host/tests/helpers/cards/bxl-*' bench-amd: # The AMD transpiler is a runtime-common module with no # cross-workspace deps that affect its wall-time, so the diff --git a/packages/bxl/README.md b/packages/bxl/README.md index 046f337598a..df1c833f026 100644 --- a/packages/bxl/README.md +++ b/packages/bxl/README.md @@ -331,14 +331,19 @@ Same string language everywhere. Each slot in the object is a plain string; the ### Using BXL inside Boxel -In Boxel realms, import the compute factory and syntax tags from the uploaded -bundle, then assign the returned function to `computeVia`: +In Boxel realms, import the compute factory and syntax tags from the +platform module — the host serves `@cardstack/bxl` to card code, and a realm +that carries its own uploaded bundle imports that bundle by relative path +instead — then assign the returned function to `computeVia`: ```ts -import { expression, fx, jq } from '../bxl'; +import { expression, fx, jq } from '@cardstack/bxl'; +// An aggregate's iterating argument has to be collected: function arguments +// are jq streams, so the uncollected `SUM(LineItems[].Amount)` would run once +// per line item and hand the field one value per element. @field subtotal = contains(NumberField, { - computeVia: expression(fx`SUM("Line Item".Amount)`), + computeVia: expression(fx`SUM([LineItems[].Amount])`), }); @field slug = contains(StringField, { From c83b657002e5e20be1266ac2968270aec77cc17b Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 19 Aug 2026 12:31:36 -0400 Subject: [PATCH 6/7] Anchor the refusal check to the engine's ban tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comparing the skill's refusal list against this suite's own cases is a closed loop: a ban neither enumerated is invisible to both. That is how isAfter and isBefore stayed missing from a page that blesses the validator helpers they sit among — the derive profile's volatile set is six calls, not the four the skill named, because VOLATILE_VALIDATION_FUNCTIONS folds those two in. The new case walks BXL_DERIVE_DENIED_CALLS and requires every denied call to be either named in the skill's list or waived here with the category the skill describes it by, and it checks each waiver against categoryForBxlFunction so a waiver cannot outlive its reason. A ban the engine gains now fails until someone decides which it is — verified by injecting one. isAfter, isBefore and isDate gain cases of their own. Co-Authored-By: Claude Opus 5 (1M context) --- .../bxl/tests/boxel/authoring-skill-claims.ts | 72 ++++++++++++++++++- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/packages/bxl/tests/boxel/authoring-skill-claims.ts b/packages/bxl/tests/boxel/authoring-skill-claims.ts index 7a87a83643e..9303575b73d 100644 --- a/packages/bxl/tests/boxel/authoring-skill-claims.ts +++ b/packages/bxl/tests/boxel/authoring-skill-claims.ts @@ -33,12 +33,14 @@ import { existsSync, readFileSync } from 'node:fs'; import { deepStrictEqual, ok, strictEqual } from 'node:assert'; import { join } from 'node:path'; import { + BXL_DERIVE_DENIED_CALLS, evaluateBxl, expression, fx, jq, loadAllFormulaExtensions, } from '../../src/index.ts'; +import { categoryForBxlFunction } from '../../src/bxl/profiles/function-safety.ts'; // The host folds every lazy formula family into the default library set before // serving `@cardstack/bxl` to card code, so a card reaches `NPV` and `isEmail` @@ -242,8 +244,9 @@ check('the diagnostic the skill quotes is the one the factory throws', () => { check('every call the skill lists as refused is refused', () => { shows( - 'Refused: volatile calls (`TODAY`, `NOW`, `RAND`, `RANDBETWEEN`) · request,', + 'Refused: volatile calls (`TODAY`, `NOW`, `RAND`, `RANDBETWEEN`, and the two clock-reading validator helpers `isAfter` / `isBefore`)', ); + shows('Compare two stored dates with the operators instead'); // Each entry: the names it covers in the skill's list, a source that reaches // them, and the diagnostic code the factory must answer with. const refused: Array<[string | string[], () => unknown, string]> = [ @@ -285,6 +288,16 @@ check('every call the skill lists as refused is refused', () => { ['halt', () => expression(jq`halt`), 'derive-call-banned'], ['input', () => expression(jq`input`), 'derive-call-banned'], ['builtins', () => expression(jq`builtins | length`), 'derive-call-banned'], + [ + 'isAfter', + () => expression(fx`isAfter(StartDate, EndDate)`), + 'derive-call-banned', + ], + [ + 'isBefore', + () => expression(fx`isBefore(StartDate, EndDate)`), + 'derive-call-banned', + ], ]; listsExactly( REFUSED_LEAD, @@ -295,6 +308,57 @@ check('every call the skill lists as refused is refused', () => { } }); +/** + * Call names the `derive` profile bans that the skill deliberately does not + * name, each with the category its refusal falls under. The skill states those + * categories — "control and side-effect calls", "runtime metadata" — and names + * the members an author might plausibly reach for; these are the rest. + * + * This list exists so the case below can be anchored to the engine instead of + * to this suite. Comparing the skill's list against the cases above alone is a + * closed loop: a ban neither of them enumerated is invisible to both, which is + * how `isAfter` / `isBefore` stayed missing from a page that blessed the + * validator helpers they sit among. + */ +const REFUSALS_NOT_WORTH_NAMING = new Map([ + ['HALT_ERROR', 'controlOrSideEffect'], + ['INPUT_FILENAME', 'controlOrSideEffect'], + ['INPUT_LINE_NUMBER', 'controlOrSideEffect'], + ['GET_JQ_ORIGIN', 'metadata'], + ['GET_PROG_ORIGIN', 'metadata'], + ['GET_SEARCH_LIST', 'metadata'], + // Not a name a card author can even reach: the parser rejects it as an + // unexpected keyword before the profile gets a say. + ['MODULEMETA', 'metadata'], +]); + +check('the skill accounts for every call the derive profile bans', () => { + const listed = new Set( + listedNames(REFUSED_LEAD).map((name) => name.toUpperCase()), + ); + const unaccounted: string[] = []; + for (const denied of BXL_DERIVE_DENIED_CALLS) { + if (listed.has(denied)) continue; + const waived = REFUSALS_NOT_WORTH_NAMING.get(denied); + if (waived) { + // A waiver is only honest while the ban really falls under the category + // the skill describes it by. + strictEqual( + categoryForBxlFunction(denied), + waived, + `${denied} is waived as ${waived} but the engine classes it otherwise`, + ); + continue; + } + unaccounted.push(denied); + } + deepStrictEqual( + unaccounted, + [], + `the derive profile bans these and the skill neither names nor waives them: ${unaccounted.join(', ')}`, + ); +}); + check( 'every form the skill lists as allowed runs and produces its value', () => { @@ -308,6 +372,7 @@ check( cashFlows: [-100, 60, 60], email: 'ops@example.com', items: [1, 2, 3], + startDate: '2026-04-30', status: 'Open', a: 7, }; @@ -328,6 +393,7 @@ check( 3.7566, ], ['isEmail', () => expression(fx`isEmail(Email)`).call(card), true], + ['isDate', () => expression(fx`isDate(StartDate)`).call(card), true], [ 'LET', () => expression(fx`LET(t, SUM([Claims[].Paid]), t > 100)`).call(card), @@ -357,8 +423,8 @@ check( () => expression(jq`[.claims[] | .paid] | unique | length`).call(card), 2, ], - ['to_entries', () => expression(jq`to_entries | length`).call(card), 7], - ['keys', () => expression(jq`keys | length`).call(card), 7], + ['to_entries', () => expression(jq`to_entries | length`).call(card), 8], + ['keys', () => expression(jq`keys | length`).call(card), 8], ['tojson', () => expression(jq`tojson | length > 0`).call(card), true], ]; listsExactly( From ca1e7ee236d4722aaa8c6c657119a9bc3ad29aca Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 20 Aug 2026 14:40:23 -0400 Subject: [PATCH 7/7] Move the skills pin to the tag carrying the authoring skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `build:skills` copies `skills//` from cardstack/boxel-skills at `BOXEL_SKILLS_VERSION` into the boxel-cli plugin, and v0.1.1 is the first tag carrying `bxl-authoring`. Moving the pin there ships the skill in the plugin and gives the drift guard the file it reads, so the suite asserts against the copy authors actually get rather than refusing to run. The regenerated copies also carry the other skill edits made upstream since v0.1.0 — the linked catalog entries, SEARCH/REPLACE-only file editing, the two instance-authoring cardinal rules, and the `@cardstack/base` import corrections. Co-Authored-By: Claude Opus 5 (1M context) --- packages/boxel-cli/plugin/README.md | 41 +- .../plugin/skills/boxel-design/SKILL.md | 2 +- .../plugin/skills/boxel-environment/SKILL.md | 39 +- .../references/host-commands-reference.md | 8 +- .../references/indexing-operations.md | 2 +- .../references/user-environment-awareness.md | 6 +- .../boxel-file-def/references/import-paths.md | 38 +- .../markdowndef-vs-markdownfield.md | 2 +- .../references/no-inline-binary.md | 4 +- .../references/using-filedef-in-cards.md | 14 +- .../shape-of-a-static-markdown-template.md | 2 +- .../the-markdown-helpers-toolkit.md | 4 +- ...-example-note-card-with-custom-markdown.md | 8 +- .../plugin/skills/boxel-patterns/SKILL.md | 2 +- .../app-card-home-with-search/README.md | 6 +- .../patterns/attach-remote-image/README.md | 10 +- .../automate-image-steering/example.gts | 6 +- .../automate-linked-to-me-lookup/README.md | 8 +- .../automate-linked-to-me-lookup/example.gts | 4 +- .../automate-run-command-cli/README.md | 2 +- .../automate-run-command-cli/example.gts | 6 +- .../build-planning-cards-trio/README.md | 2 +- .../build-site-config-with-theme/example.gts | 12 +- .../cardinfo-override-title/example.gts | 6 +- .../command-atomic-install/example.gts | 2 +- .../patterns/command-data-resource/README.md | 6 +- .../command-data-resource/example.gts | 6 +- .../command-optimistic-pipeline/example.gts | 8 +- .../command-typed-with-progress/example.gts | 2 +- .../command-with-skill-card-ref/example.gts | 2 +- .../containsmany-sorted-render/example.gts | 6 +- .../format-morph-shared-component/README.md | 2 +- .../integrate-chess-js-via-cdn/example.gts | 4 +- .../example.gts | 2 +- .../integrate-leaflet-via-cdn/example.gts | 6 +- .../integrate-one-shot-llm/example.gts | 2 +- .../example.gts | 6 +- .../example.gts | 6 +- .../example.gts | 4 +- .../integrate-three-js-via-cdn/example.gts | 4 +- .../integrate-thumbnail-card-ai/README.md | 4 +- .../integrate-thumbnail-card-ai/example.gts | 6 +- .../integrate-tone-js-via-cdn/example.gts | 4 +- .../integrate-web-audio-synthesis/example.gts | 4 +- .../layout-3d-card-carousel/example.gts | 4 +- .../patterns/layout-design-board/example.gts | 2 +- .../layout-kanban-drag-drop/example.gts | 10 +- .../README.md | 10 +- .../example.gts | 6 +- .../patterns/link-command-menu-item/README.md | 2 +- .../link-command-menu-item/example.gts | 6 +- .../example.gts | 2 +- .../patterns/link-flip-card/example.gts | 4 +- .../patterns/link-host-mode-paths/README.md | 4 +- .../patterns/link-host-mode-paths/example.gts | 8 +- .../patterns/link-view-transition/example.gts | 2 +- .../organize-atomic-field-factory/example.gts | 2 +- .../organize-base-class-taxonomy/example.gts | 2 +- .../organize-recursive-fielddef/example.gts | 8 +- .../example.gts | 2 +- .../organize-sensitive-stub-pair/README.md | 4 +- .../organize-sensitive-stub-pair/example.gts | 6 +- .../organize-typed-activity-feed/README.md | 10 +- .../organize-typed-activity-feed/example.gts | 12 +- .../example.gts | 2 +- .../patterns/pick-rating/example.gts | 8 +- .../polymorphic-field-subclass/README.md | 2 +- .../show-card-list-with-views/example.gts | 2 +- .../show-count-tiles-from-query/example.gts | 4 +- .../show-filedef-audio-player/example.gts | 10 +- .../show-list-prefer-prerendered/example.gts | 2 +- .../show-pdf-annotations-filedef/example.gts | 8 +- .../show-runtime-markdown-html/README.md | 6 +- .../show-runtime-markdown-html/example.gts | 8 +- .../show-table-from-query/example.gts | 2 +- .../patterns/show-wiki-links/example.gts | 6 +- .../patterns/theme-first-workflow/README.md | 12 +- .../patterns/theme-first-workflow/example.gts | 6 +- .../references/integration-surfaces.md | 6 +- .../boxel-patterns/references/libraries.md | 46 +- .../skills/boxel-skill-authoring/SKILL.md | 2 +- .../skills/boxel-theme-development/SKILL.md | 2 +- .../boxel-ui-component-discovery/SKILL.md | 2 +- .../boxel-workspace-cardinal-rules/SKILL.md | 48 ++- .../boxel-cli/plugin/skills/boxel/SKILL.md | 7 +- .../boxel/references/base-field-catalog.md | 106 ++--- .../boxel/references/command-development.md | 4 +- .../skills/boxel/references/common-imports.md | 26 +- .../skills/boxel/references/core-concept.md | 17 +- .../skills/boxel/references/core-patterns.md | 10 +- .../boxel/references/data-management.md | 2 +- .../references/defensive-link-traversal.md | 2 +- .../boxel/references/design-playbook.md | 2 +- .../skills/boxel/references/enumerations.md | 16 +- .../boxel/references/external-libraries.md | 4 +- .../skills/boxel/references/formatters.md | 2 +- .../skills/boxel/references/imagedef.md | 12 +- .../skills/boxel/references/query-systems.md | 2 +- .../boxel/references/quick-reference.md | 70 ++-- .../references/relationship-loading-state.md | 2 +- .../skills/boxel/references/spec-usage.md | 16 +- .../skills/boxel/references/styling-design.md | 2 +- .../boxel/references/template-syntax.md | 6 +- .../boxel/references/theme-design-system.md | 24 +- .../plugin/skills/bxl-authoring/SKILL.md | 392 ++++++++++++++++++ packages/boxel-cli/plugin/skills/glossary.md | 22 +- .../skills/source-code-editing/SKILL.md | 16 +- .../scripts/.boxel-skills-manifest.json | 3 +- packages/boxel-cli/scripts/build-skills.ts | 2 +- 109 files changed, 909 insertions(+), 468 deletions(-) create mode 100644 packages/boxel-cli/plugin/skills/bxl-authoring/SKILL.md diff --git a/packages/boxel-cli/plugin/README.md b/packages/boxel-cli/plugin/README.md index db087d002ef..6c79179aa1c 100644 --- a/packages/boxel-cli/plugin/README.md +++ b/packages/boxel-cli/plugin/README.md @@ -83,26 +83,27 @@ Authored upstream in [`cardstack/boxel-skills`](https://github.com/cardstack/box -_Copied from [`cardstack/boxel-skills@v0.1.0`](https://github.com/cardstack/boxel-skills/tree/v0.1.0) by_ `pnpm build:skills`. _Edit upstream, not here._ - -| Skill | Use it for | -| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/boxel-cli:boxel` | Use whenever creating, reading, or editing Boxel cards (.gts files), card instances (.json), fields, templates, queries, or anything in a Boxel realm. Required for any Boxel coding work — covers CardDef, FieldDef, contains/linksTo, templates, formats, queries, and core patterns. Companion skills - boxel-design (visual decisions), boxel-ui-guidelines (template UI), source-code-editing (SEARCH/REPLACE), boxel-environment (running the Boxel app). | -| `/boxel-cli:boxel-create-edit-cards` | Use when choosing the right Boxel host command combination to create new cards or edit existing instances from the AI assistant. | -| `/boxel-cli:boxel-design` | Use when DECIDING a Boxel card's visual language — mood, palette, typography direction, asset direction, one visual signature, the design-playbook process. This is the taste/decision layer. NOT for implementing tokens or CSS inside templates (that's boxel-ui-guidelines) and NOT for creating/editing Theme, StyleReference, or BrandGuide card artifacts (that's boxel-theme-development). | -| `/boxel-cli:boxel-environment` | Use when running, navigating, or orchestrating tasks inside the live Boxel application — switching between Code Mode and Interact Mode, calling host commands (search-cards, switch-submode, show-card, patch-fields, apply-markdown-edit, reindex, etc.), or any operation that drives the Boxel UI. Activates for Boxel-app runtime work, not for writing card definitions (see boxel for that). | -| `/boxel-cli:boxel-file-def` | Use when adding or working with file-typed fields (FileDef, ImageDef, MarkdownDef, PngDef, CsvFileDef). Activates when a card needs to reference an image, document, or other file asset. | -| `/boxel-cli:boxel-flavored-markdown` | Use when authoring or editing Boxel Flavored Markdown (BFM) content — content fields rendered as rich markdown with :card/::card directives, mermaid diagrams, etc. | -| `/boxel-cli:boxel-markdown-format` | Use when authoring a `markdown` template (static markdown format) on a CardDef or FieldDef — defaults, markdownEscape, and markdown helpers. | -| `/boxel-cli:boxel-patterns` | Use when the user names an outcome ("show a chart", "let users pick a color", "build a dashboard", "summarize comments", "embed AI image generation", "lay out a moodboard") and you need a working code example to start from. This skill is the bridge between user intent and the existing patterns in Boxel realms. Index your search by what the user wants to DO, not by which CardDef/FieldDef class to extend. Activates when the user asks "do we have a pattern for…", "how is X typically done", or names a feature outcome that isn't in core syntax. | -| `/boxel-cli:boxel-skill-authoring` | Use when creating or editing a user-authored Boxel skill — a markdown file whose `boxel.kind: skill` frontmatter makes it loadable by AI assistant rooms. Covers the SKILL.md format contract, the frontmatter schema, tool declarations (codeRef forms, requiresApproval), placement conventions, and how to verify the skill indexed correctly. Activates for "write me a skill", "add a tool to my skill", or a skill that isn't showing up in the skill chooser. | -| `/boxel-cli:boxel-theme-development` | Use when the deliverable is a theme ARTIFACT — creating, converting, auditing, or patching Theme, StructuredTheme, StyleReference, DetailedStyleReference, or BrandGuide cards; importing/exporting Google DESIGN.md design-system briefs; logo/mark usage and functional palettes. NOT for deciding a card's visual language (boxel-design) and NOT for applying tokens inside card templates (boxel-ui-guidelines). | -| `/boxel-cli:boxel-ui-component-discovery` | MANDATORY before writing any UI in a `.gts` template. Search the catalog for a boxel-ui component Spec and reuse it. Fall back to raw HTML only when no matching spec exists, and surface the gap when you do. | -| `/boxel-cli:boxel-ui-guidelines` | Use when IMPLEMENTING UI in Boxel templates — applying var(--\*) theme tokens in