Repository navigation
Commit dabe771
feat(formula,plugin-email,lint)!: a mail template hole takes one formatter and a literal currency code, refused at authoring and at render (#26799)
Fixes #26673
Part of #26660 (item c, the silent USD; item b stays open on the card,
see Acceptance notes)
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — the published skill
(`skills/objectstack-formula/SKILL.md`, the `template` row) already
spells a hole as one optional formatter, `{{ path | formatter[:arg] }}`;
the refusals name both rules at every door, and
`content/docs/automation/email-templates.mdx` is updated here.
## 维护者速读
- 改了什么:邮件模板占位符只允许一个格式化器,`currency` 的参数只能是三位大写 ISO 4217 字面代码;两条规则在 `os
validate` / `os build` / `os lint` 与元数据发布门(`422
INVALID_METADATA`)拒收,渲染时同样拒收(`TEMPLATE_INVALID`),应用内模板方言共用同一判定。
- 为什么改:`{{ x | default:0 | number:2 }}` 会把 `0 | number:2` 原样发给收件人,`{{ x
| currency:currency_code }}` 会静默按美元显示金额,所有门禁都放行(hotclm 实测)。
-
风险与回滚:收紧(BREAKING),写了链式格式化器或路径/小写币种参数的模板会被拒;本仓库零命中。已存储的旧模板在下次发布前发送会失败而不是发错。回滚即还原本
PR。
- 先看:`packages/formula/src/template-engine.ts`(`judgeHoleFormatter` 与
`currency`
格式化器)、`packages/plugins/plugin-email/src/template-engine.ts`(渲染时抛
`TEMPLATE_INVALID`)、`packages/lint/src/validate-email-template-hole-grammar.ts`。
- 两分钟验证:`pnpm --filter @objectstack/plugin-email exec vitest run
--maxWorkers=2 src/template-hole-grammar.render.test.ts`(先 build
formula),预期 10 passed:链式与路径参数都以 `TEMPLATE_INVALID` 拒收,`currency:EUR` 渲染为
€1,234.50。
- 需要你做的:无,席位落地。
## What changes
One hole grammar, one judgement, every reader:
- **`@objectstack/formula`**
- `judgeHoleFormatter(hole, formatterPart, formatter, arg)` (in
`template-engine.ts`, beside `FORMATTERS`) is the one judgement of a
hole's formatter part, used by both template dialects. Two rules: **one
formatter per hole** (a second `|` outside a closed quoted argument is
refused), and **the `currency` argument is a literal ISO 4217 code in
three capitals** (a path, a lowercase or an empty argument is refused).
No argument still formats in USD.
- The in-app dialect: `templateEngine.compile` / `evaluate` answer a
`parse` error naming the hole and the rule, so `validateExpression`
reports `invalid-template` at `os validate` and the publish gate.
- The `currency` formatter no longer catches the `Intl` refusal and
substitutes USD: a non-code argument throws a `RangeError` before the
value is read.
- The mail hole grammar now lives once, in `mail-hole-grammar.ts`
(`mailHolePattern`, `parseMailHole`, `judgeMailHoleGrammar`).
`judgeMailHtmlHoles` drops its copy and imports it; so does the renderer
below. A chained hole is recognised as a hole even when its first
formatter alone would not parse (`{{ x | upper | lower }}` used to be
left on the page verbatim).
- **`@objectstack/lint`**: a new gating rule,
`email-template-hole-grammar` (`validateEmailTemplateHoleGrammar`),
judges `subject`, `bodyText` and `bodyHtml` of every template on all
three commands and on the runtime publish surface for `email_template`
writes, refused there with the existing `422 INVALID_METADATA`. The four
CLI transcripts that print the rule count (54 to 55) are updated.
- **`@objectstack/plugin-email`**: the renderer parses with the shared
grammar; a refused hole throws `TEMPLATE_INVALID`, and
`resolveAndRenderTemplate` names the template and locale in front of it.
It is reached only by a template stored before the rule existed, until
its next publish. It is now the one way a render throws.
- **`@objectstack/service-messaging`** (patch round 1): the email and
inbox channels' `classifyError` grade `TEMPLATE_INVALID` permanent,
beside `TEMPLATE_NOT_FOUND`, `TEMPLATE_INACTIVE`, `MISSING_VARIABLES`
and `TEMPLATE_UNSUPPORTED`. A pre-rule stored template that fails this
way now goes dead at once with the code on its delivery row, instead of
spending its retry ladder. Patch changeset
`26660-mail-hole-grammar-messaging.md` (`Clause-②: no`,
`Metadata-protocol: none`).
**Landing outside the named files, and why.** The save-time verdict's
producer is the authoring-rule registry in `packages/lint` (the
`email_template` publish door is that registry's runtime surface), not
`email-template-door.ts`, which is the closed `sys_email_template` row
door and judges no holes. The door pin lives in
`packages/metadata-protocol` (test only), beside the placement rule's.
**Same-day churn.** Both symptoms re-measured on `origin/main`
`a485b5a3` through the surviving by-name render path,
`EmailService.renderTemplate` (the module-level body renderer is retired
from the entry). `currency:currency_code` with `eur` rendered
`US$1,234.50` (zh-CN), `$1,234.50` (en-US), `1 234,50 $US` (fr-FR); the
chained hole rendered `0 | number:2` for null and `71100` otherwise, in
subject, text and html.
**Sweep (one pass, all trees: `packages`, `examples`, `skills`,
`content`, `apps`, `ui`, `docs`).** Chained holes: zero hits (the one
regex hit is a JSX `style` object). `currency:` arguments: all are
uppercase literals (`EUR`, `USD`, one quoted `'USD'`), all still
admitted. No in-repo template needed a change.
## Tests
- `packages/formula/src/template-hole-grammar.test.ts` (new, 24): both
rules through `judgeHoleFormatter`, the in-app compile / evaluate /
`validateExpression`, the formatter backstop, and the mail grammar's
parse and per-face judge.
-
`packages/plugins/plugin-email/src/template-hole-grammar.render.test.ts`
(new, 10): `renderTemplate` and `sendTemplate` refuse both shapes in
each face with `TEMPLATE_INVALID` naming template, locale, hole and
rule; nothing is sent; one formatter, a quoted `|` and `currency:EUR`
still render.
- `packages/lint/src/validate-email-template-hole-grammar.test.ts` (new,
15): the rule per face, the registry entry (gating, all commands,
`email_template` runtime surface), each `os` command at `error`, the
runtime gate.
-
`packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts`
(+2): each refusal at the publish door asserts `code` `INVALID_METADATA`
+ `status` 422 + the issue's rule and path, with a non-vacuous admit.
<details><summary>Full suites and typecheck at 9fd3fde</summary>
- `@objectstack/formula`: Test Files 55 passed (55), Tests 1476 passed
(1476); typecheck exit 0.
- `@objectstack/plugin-email`: Test Files 40 passed (40), Tests 712
passed (712); typecheck exit 0.
- `@objectstack/lint`: Test Files 169 passed (169), Tests 7445 passed
(7445); typecheck exit 0.
- `@objectstack/metadata-protocol`: Test Files 269 passed, 3 skipped
(272), Tests 21618 passed, 19 skipped; typecheck exit 0.
- `pnpm exec eslint` on the 13 changed TypeScript files: exit 0.
- `9d428aae` changes four docs transcripts only (no source); the suites
above were not re-run there, the transcript gate was.
</details>
<details><summary>Ablations (each through scripts/ablation-replace.mjs,
WRAP mode; dist legs proven with ablation-dist-preflight)</summary>
- **A, currency rule off** (`formatter === 'currency'` to a marker in
`judgeHoleFormatter`; formula + lint rebuilt, marker present in 2 dist
files): formula 11 failed, plugin-email 4 failed, lint 9 failed,
metadata-protocol 1 failed — exactly the currency pins; every chain pin
stayed green. A first attempt was refused by the tool before running
anything (the replacement contained the anchor, so the count could not
drop); re-anchored.
- **B, one-formatter rule off** (marker on the `chainsASecondFormatter`
branch; rebuilt, marker in dist): formula 6, plugin-email 4, lint 6,
metadata-protocol 1 failed — exactly the chain pins.
- **C, renderer guard off** (plugin-email source): plugin-email 8
failed, others green; the chained template rendered `Paid 0 | number:2`,
the original defect.
- **D, the formatter's USD substitution put back** (formula source): 1
failed, the formatter-throws pin.
- Every leg restored to the HEAD blob with an empty `git diff HEAD`;
after the last dist leg formula and lint were rebuilt and both markers
proven absent from `dist/`, tree clean.
</details>
- `packages/services/service-messaging/src/email-channel.test.ts` and
`inbox-channel.test.ts` (+1 each): a `TEMPLATE_INVALID: …` failure from
`sendTemplate` / `renderTemplate` reaches the delivery row with the code
at its front and is graded `permanent`. Ablated per channel (the word
removed from the regex): each went red on exactly that pin, `expected
'retryable' to be 'permanent'`, and was restored to the HEAD blob.
## Acceptance notes
- **Remaining half of #26660 (item b): format an amount in the record's
own currency.** Not built. Reading the code from the data
(`currency:record.currency_code`, or a per-hole value the formatter
knows the code of) widens the template grammar, which triage sent back
to triage. Until then the refusal says to format the amount in the
caller and pass the text.
- **`currency` with no argument still formats in USD.** Unchanged by
these cards (a present non-code argument is what is refused). It is the
same reader-sees-a-currency-nobody-chose shape for a record in another
currency; noted for the item-b decision, not changed here.
- **The in-app template dialect narrows too.** The two rules are judged
once for both dialects because the formatter is shared: a non-code
`currency` argument can no longer render, so the in-app compile must
refuse it rather than let `evaluate` throw. Same defect class, same file
family; the in-app chain (`default:0 | number:2` printed `0 | number:2`
there as well) is refused by the same function.
- **`TEMPLATE_INVALID` is a new render failure code.** The messaging
email and inbox channels grade it permanent (patch round 1). The
`IEmailService` contract's error lists in
`packages/spec/src/contracts/email-service.ts` still name three codes;
that documentation line is the spec lane's: filed by the seat as #26803.
- **Other formatter arguments are still unjudged** (`number:two`
silently formats without fixed digits). Not measured at a door here;
noted only.
- **Serial constraint.** `origin/main` merged at `0f3d068c`; #25805
(also on `packages/lint`) has not landed. It adds an authoring rule too,
so whichever lands second moves the four transcript counts again
(`check:docs-transcript-drift` names them).
## Verification
<details><summary>Gate families (derived with dispatch-gates --commands,
reconciled with --ran: 91 derived, 91 run)</summary>
- 86 derived commands exit 0 at `9fd3fde3`, plus `pnpm
check:error-code-casing` and `node scripts/check-platform-checklist.mjs
--changed-from origin/main` (both exit 0).
- `pnpm check:docs-transcript-drift`: exit 1 at `9fd3fde3` (four
transcripts printed 54 rules, the registry derives 55) — fixed in
`9d428aae`, exit 0.
- `pnpm check:cli-examples-parity`: newly derived at `9d428aae`, exit 0.
- `pnpm --filter @objectstack/spec run check:skill-examples` and `pnpm
check:dual-build-cjs-loads`: exit 3 (prerequisite: unbuilt packages) at
first; exit 0 at `9d428aae` once the dogfood closure was built.
- `node scripts/check-plugin-teardown-shape.mjs --self-test`: exit 3,
NOT MEASURED — its positive control is pinned to a commit this shallow
checkout cannot reach; checker health only, CI runs it.
</details>
<details><summary>Dogfood battery at 9d428aa</summary>
`pnpm --filter @objectstack/dogfood test` under the verify lock (closure
built first): `VERDICT command-exit 0`, Test Files 302 passed, 3 skipped
(305), Tests 2507 passed, 20 skipped (2527).
</details>
<details><summary>Patch round 1 at dfe8026 (origin/main merged at
1cd4b73, the PR was 15 behind)</summary>
- Full suites and typecheck exit 0: `@objectstack/service-messaging`
Test Files 61 passed (61), Tests 731 passed; `@objectstack/formula` 56
files, 1484 tests; `@objectstack/plugin-email` 40 files, 712 tests;
`@objectstack/lint` 169 files, 7445 tests;
`@objectstack/metadata-protocol` 269 passed and 3 skipped files, 21618
passed and 19 skipped tests. `pnpm exec eslint` on the 17 changed
TypeScript files: exit 0.
- Gates re-derived with dispatch-gates --commands (no paths) at
dfe8026: 93 commands (adds `pnpm check:i18n` and `pnpm
check:i18n-stale-fill`). 92 exit 0 on the first run; `pnpm
check:dual-build-cjs-loads` exit 3 (six packages unbuilt in the fresh
worktree), exit 0 once they were built. Extras `pnpm
check:error-code-casing` and `node scripts/check-platform-checklist.mjs
--changed-from origin/main`: exit 0. `--ran` reconciliation with exit
codes recorded: 93 derived, 93 run, 0 NOT-MEASURED.
- Dogfood battery at dfe8026: `VERDICT command-exit 0`, Test Files 313
passed, 3 skipped (316), Tests 2588 passed, 20 skipped (2608).
</details>
Commits: one per member (`9e59d3de` the currency literal, `44ccaf92` one
formatter per hole), merges of `origin/main` (`9fd3fde3`, `1cd4b739`),
`9d428aae` the transcript counts, and patch round 1 (`d6a898e3` the
messaging grade, `dfe80262` its changeset).
Session: session_01QSeKYmQXM3rHJcPnrEPk4B
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 95f818d commit dabe771
26 files changed
Lines changed: 962 additions & 104 deletions
File tree
- .changeset
- content/docs
- automation
- deployment
- getting-started
- ui
- packages
- formula/src
- lint/src
- metadata-protocol/src
- plugins/plugin-email/src
- services/service-messaging/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
82 | 82 | | |
83 | 83 | | |
84 | 84 | | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
85 | 100 | | |
86 | 101 | | |
87 | 102 | | |
| |||
94 | 109 | | |
95 | 110 | | |
96 | 111 | | |
97 | | - | |
| 112 | + | |
98 | 113 | | |
99 | | - | |
100 | | - | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
101 | 120 | | |
102 | 121 | | |
103 | 122 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
758 | 758 | | |
759 | 759 | | |
760 | 760 | | |
761 | | - | |
| 761 | + | |
762 | 762 | | |
763 | 763 | | |
764 | 764 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
752 | 752 | | |
753 | 753 | | |
754 | 754 | | |
755 | | - | |
| 755 | + | |
756 | 756 | | |
757 | 757 | | |
758 | 758 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
305 | 305 | | |
306 | 306 | | |
307 | 307 | | |
308 | | - | |
| 308 | + | |
309 | 309 | | |
310 | 310 | | |
311 | 311 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
397 | 397 | | |
398 | 398 | | |
399 | 399 | | |
400 | | - | |
| 400 | + | |
401 | 401 | | |
402 | 402 | | |
403 | 403 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
63 | 63 | | |
64 | 64 | | |
65 | 65 | | |
66 | | - | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
67 | 72 | | |
68 | 73 | | |
69 | 74 | | |
| |||
0 commit comments