Repository navigation
Commit 40320f1
feat(formula, plugin-email, lint)!: a mail template hole whose formatter the whitelist does not name is refused by name, never rendered raw (#27217)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBPZ1A9daZqRh23Ponr9G4xe)_
Fixes #26948
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — `skills/objectstack-formula/SKILL.md:444` already
teaches the formatter whitelist as `TEMPLATE_FORMATTERS`, which is
exactly the rule this PR enforces, and no `skills/**` file teaches or
shows a mail hole whose formatter is outside it (grep over `skills/**`
for `formatter`, `email template` and `emailTemplates`: none of the hits
teaches a mail formatter name); the docs page
`content/docs/automation/email-templates.mdx` gains the refusal here.
## 维护者速读
- 改了什么:邮件模板里写错或不存在的格式化器(如 `{{ amount | curency:EUR }}`)现在在 `os validate`
/ `os build` / `os lint` 和元数据发布门(`422
INVALID_METADATA`)被按名拒绝,并列出全部可用格式化器;已存的旧模板渲染时报
`TEMPLATE_INVALID`,不再发出原始值。
- 为什么改:原来邮件照常发出,金额处显示 `1234.5`、日期处显示原始时间戳,作者毫无提示;应用内模板早就拒绝这种写法,两种方言不一致。
- 风险与回滚:收窄(BREAKING),只影响拼错格式化器的模板;仓内出厂模板、示例、种子、技能零命中。回滚即 revert 本 PR。
- 先看:`packages/formula/src/template-engine.ts`(`judgeHoleFormatter` 新增的
`known-formatter`
规则)、`packages/plugins/plugin-email/src/template-engine.ts`(去掉原始值回退)、`content/docs/automation/email-templates.mdx`。
- 两分钟验证:`pnpm --filter @objectstack/lint exec vitest run
src/validate-email-template-hole-grammar.unknown-formatter.test.ts`,预期
8/8 通过(三个面、三条命令、发布门各被拒)。
- 需要你做的:无,席位落地(须在 #26937 之后落地,届时合入 main)。
## What changes
- **`@objectstack/formula`, `judgeHoleFormatter`**
(`template-engine.ts`): a third rule, `known-formatter`, judged after
`one-formatter` and before the `currency` argument. A hole whose
formatter is a single word that `TEMPLATE_FORMATTERS` does not hold is
refused, and the sentence names the hole and lists every known
formatter. `TemplateHoleGrammarRule` gains `'known-formatter'`. The
check reads the exported list, so an inherited object key (`toString`,
`constructor`) is not a formatter either.
- **The mail grammar** (`mail-hole-grammar.ts`): no code change.
`parseMailHole` already calls `judgeHoleFormatter`, so it now carries
the refusal and `judgeMailHoleGrammar` reports the hole. Only its header
names the new rule.
- **`@objectstack/plugin-email` renderer** (`template-engine.ts`): the
`// unknown formatter → raw` fallback is gone. A refused hole throws
`TEMPLATE_INVALID` from the grammar as the other refusals do, and
`EmailService` prefixes the template and locale. If `formatValue` ever
answered `undefined` for a name the grammar passed, the render throws
`TEMPLATE_INVALID` too, so a raw value is never printed in its place.
- **`@objectstack/lint` `email-template-hole-grammar`**: no code change
(it reads `judgeMailHoleGrammar`). It now refuses the hole at `os
validate`, `os build`, `os lint` and the runtime publish gate. Only its
header comment names the new case.
- **The in-app dialect**: its own parse already refused an unknown
formatter and keeps its own sentence. One side effect: an inherited
object key (`{{ x | toString }}`) used to pass `!FORMATTERS[name]` and
run as a formatter, printing `[object Object]`. Now the shared rule
refuses it.
- **Docs**: `content/docs/automation/email-templates.mdx` gains a
paragraph on the rule. The `TEMPLATE_INVALID` TSDoc on
`IEmailService.sendTemplate` / `renderTemplate`
(`packages/spec/src/contracts/email-service.ts`) names the third hole it
refuses.
- **Changesets**: formula, plugin-email and lint `minor` (narrowed,
`(narrowing)` arm, ADR-0087 `not-required (no-migration-prescription)`:
a conversion cannot know which formatter was meant), spec `patch` (TSDoc
only, `none`). `@objectstack/metadata-protocol` gets only a new test
file, which is not in its published `files`, so it has no entry.
### Census (on `origin/main` 72629f3)
I grepped every `{{ path | name` hole in the repo, excluding CHANGELOGs,
release notes and generated translations. Then I checked each mail
template carrier by hand: the showcase and app emails, `plugin-email`
`auth-templates.ts`, the spec seeds, dogfood and downstream fixtures,
the upgrade-harness SQL fixtures and `skills/**`. Examples 0, seeds 0,
skills 0, apps 0. The only mail holes with a formatter are the
`datetime:long` holes in `auth-templates.ts` and the docs examples, and
all of them use known formatters. The unknown names the grep found
(`bogus`, `nope`, `shout`, `no_such_formatter`) are in-app dialect
tests. They are refused there already and their results do not change.
### Merge order with #26937 and #26939
**Serial with #26937 (merged at `22058635`).** This branch kept its
hunks out of #26937's, and merged `main` once #26937 landed (`9b72c4e1`,
no rebase). The one conflict was `TemplateHoleGrammarRule`. It now has
five members: `one-formatter`, `known-formatter`,
`currency-code-literal`, `currency-code-path-mail-only` and
`currency-code-path-undeclared`. `judgeHoleFormatter` keeps #26937's
`dialect` parameter, and `known-formatter` sits after `one-formatter`
and before the currency block. `395c096e` adds the unknown-formatter
clause to `MAIL_TEMPLATE_HOLES` and its comment (the
`CURRENCY_ARGUMENT_RULE` sentence stays verbatim), and to the
`validateEmailTemplateHoleGrammar` roster comment in
`authoring-rules.ts`. It also widens the spec changeset and corrects the
`IEmailService` TSDoc. `97613711` regenerates
`content/docs/references/system/email-template.mdx`.
**Residual (follow-up #27225).** This PR refuses an unknown one-word
formatter. Formatter text that is not one word (`{{ x | date-long }}`,
`{{ x | currency :EUR }}`, `{{ x | two words }}`) fails `FILTER_RE` /
`WORD_RE`, so `parseMailHole` answers `null`. Every mail door then
accepts it and the renderer prints the hole verbatim (pinned at
`text-field-html-outlets.pin.test.ts:284`), while the in-app dialect
refuses it. #27225 carries that.
## Tests
- New `packages/formula/src/template-unknown-formatter.test.ts` (9
cases). It covers the refusal and its sentence, every whitelisted name
passing, inherited keys refused, the one-formatter rule winning on a
chained hole, non-word text left verbatim, `parseMailHole` /
`judgeMailHoleGrammar` locating triple-braced holes, the in-app sentence
unchanged, and in-app `toString` now refused.
- New
`packages/plugins/plugin-email/src/template-unknown-formatter.render.test.ts`
(5 cases). It covers `renderTemplate` on all three faces refused with
the template, locale, hole and known list, `sendTemplate` sending
nothing, and the corrected name rendering.
- Re-pinned `packages/plugins/plugin-email/src/template-engine.test.ts`:
"falls back to the raw value for an unknown formatter (no throw)" is now
"refuses an unknown formatter with TEMPLATE_INVALID, never the raw
value", for both entry points.
- New
`packages/lint/src/validate-email-template-hole-grammar.unknown-formatter.test.ts`
(8 cases). It covers all three faces, `os validate` / `build` / `lint`,
and the runtime publish gate.
- New
`packages/metadata-protocol/src/runtime-authoring-gate.email-template-unknown-formatter.test.ts`.
It checks the `422 INVALID_METADATA` envelope (`code` and `status`, plus
`issues`), and that the corrected name publishes.
## Acceptance notes
- Text after the `|` that is not a single word (`{{ x | two words }}`,
`{{ x | }}`) still names no formatter in the mail grammar and is left as
written, as `text-field-html-outlets.pin.test.ts` pins. The in-app
dialect refuses the same text as an invalid hole. This PR leaves that
difference alone, because the card is about formatter names.
- In the ablation, the `sendTemplate` case and the re-pinned
`template-engine.test.ts` case stayed green. That is the renderer's own
`formatted === undefined` guard: it still throws `TEMPLATE_INVALID` when
the grammar rule is removed. That is the intended second line, and the
face cases, which assert the grammar's sentence, went red.
## Verification
Run on head 857e696 (`origin/main` 72629f3 + 3 commits).
<details><summary>Package tests and typecheck (under
os-verify-lock)</summary>
`pnpm --filter @objectstack/formula --filter @objectstack/plugin-email
--filter @objectstack/lint --filter @objectstack/metadata-protocol
--filter @objectstack/service-messaging test`, then the same filters
plus `@objectstack/spec` `typecheck`, then `pnpm --filter
@objectstack/spec check:generated`: VERDICT command-exit 0.
- formula: Test Files 57 passed (57), Tests 1493 passed.
- plugin-email: Test Files 41 passed (41), Tests 717 passed.
- lint: Test Files 172 passed (172), Tests 7530 passed.
- metadata-protocol: Test Files 278 passed, 4 skipped (282), Tests 21750
passed, 26 skipped.
- service-messaging: Test Files 61 passed (61), Tests 734 passed.
- typecheck: formula, plugin-email, lint, metadata-protocol and spec all
Done, with check:test-typecheck OK.
- spec check:generated: "All 14 generated artifacts are up to date".
- `eslint --no-inline-config` over the changed `.ts` files: exit 0.
</details>
<details><summary>Ablation of the rule</summary>
`node scripts/ablation-replace.mjs` made the `known-formatter` branch
unreachable (anchor hit once, `--hold`), followed by `pnpm --filter
@objectstack/formula build`. `ablation-dist-preflight` confirmed the
marker in `dist/index.js` and `dist/index.mjs`. Results, all red as
expected:
- formula new file: 5 of 9 failed.
- plugin-email new file plus re-pin: 3 of 36 failed (the three face
cases).
- lint new file: 7 of 8 failed.
- metadata-protocol new file: 1 of 1 failed.
The restore was `--restore` (blob equal to HEAD, `git diff HEAD` empty),
followed by a rebuild. `ablation-dist-preflight --absent` confirmed the
marker was gone from all 6 built files and the tree was clean.
</details>
<details><summary>Derived gates</summary>
`node scripts/pm/dispatch-gates.mjs --commands`: 105 commands, run
serially. 103 exit 0, including `check-adr-0087-registration --base
origin/main`, `check-changeset-no-major --base origin/main`,
`check:doc-authoring` and `check:docs`. 2 exit 3, PREREQUISITE NOT MET
(they need a full repo build, so nothing was measured): `spec
check:skill-examples` and `check:dual-build-cjs-loads`. CI runs both.
`--ran` reconciliation: 105 derived, 103 run, 2 NOT-MEASURED, 0 UNRUN.
</details>
<details><summary>Dogfood (owed by the narrowing arm)</summary>
`pnpm --workspace-concurrency=2 --filter '@objectstack/dogfood^...'
build && pnpm --filter @objectstack/dogfood test` on head 857e696 (no
`OS_TEST_SHARD`, no path): VERDICT command-exit 0. Test Files 348
passed, 3 skipped (351); Tests 2804 passed, 20 skipped (2824). No
failures.
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WGufzgBzVZJqh8CQJLrZu8)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 295699d commit 40320f1
18 files changed
Lines changed: 423 additions & 22 deletions
File tree
- .changeset
- content/docs
- automation
- references/system
- packages
- formula/src
- lint/src
- metadata-protocol/src
- plugins/plugin-email/src
- spec/src
- contracts
- system
| 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 | + | |
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
82 | 82 | | |
83 | 83 | | |
84 | 84 | | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
85 | 93 | | |
86 | 94 | | |
87 | 95 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
52 | | - | |
53 | | - | |
54 | | - | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
55 | 55 | | |
56 | 56 | | |
57 | 57 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
23 | | - | |
24 | | - | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
25 | 26 | | |
26 | 27 | | |
27 | 28 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
166 | 166 | | |
167 | 167 | | |
168 | 168 | | |
| 169 | + | |
169 | 170 | | |
170 | 171 | | |
171 | 172 | | |
| |||
187 | 188 | | |
188 | 189 | | |
189 | 190 | | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
190 | 194 | | |
191 | 195 | | |
192 | 196 | | |
| |||
284 | 288 | | |
285 | 289 | | |
286 | 290 | | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
287 | 297 | | |
288 | 298 | | |
289 | 299 | | |
| |||
309 | 319 | | |
310 | 320 | | |
311 | 321 | | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
312 | 331 | | |
313 | 332 | | |
314 | 333 | | |
| |||
0 commit comments