Repository navigation
Commit b445807
feat(formula, plugin-email, lint)!: a mail template hole whose text after the
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBJMi61dBZ6D1yX5agd3kpSa)_
Fixes #27225
Claim: 6469619404
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — `skills/objectstack-formula/SKILL.md:444` teaches a
template hole as `{{ path | formatter[:arg] }}` with the
`TEMPLATE_FORMATTERS` whitelist, which is exactly the shape this PR
enforces, and no `skills/**` file teaches or shows a mail hole whose
formatter part is not that shape (grep over `skills/**` for `formatter`,
`emailTemplates` and `email_template`: the hits are that row, the
automation skill's "a variable path, optional formatter, no logic"
sentence, and two unrelated uses of the word). The docs page
`content/docs/automation/email-templates.mdx` gains the rule here.
## 维护者速读
- **改了什么:** 邮件模板里 `|` 后面写的不是一个格式化器名(如 `{{ due | date-long }}`、`{{ x |
two words }}`、`{{ x | }}`)时,现在在 `os validate` / `os build` / `os lint`
和元数据发布门(`422 INVALID_METADATA`)被按名拒绝,说明正确写法(一个词,可带
`:参数`)并列出全部格式化器;已存的旧模板渲染时报
`TEMPLATE_INVALID`,不再把原文发给收件人。同时邮件与应用内模板改用同一个切分函数,`{{ amount | currency
:EUR }}`、`default:it's` 这类应用内本来就接受的写法,邮件里也照应用内的读法渲染。
- **为什么改:** 原来这种写法通过所有门禁,邮件正文里直接出现 `{{ due | date-long }}`
原文;应用内模板早就拒绝,两种方言不一致(#27217 合同复核 S1)。
- **风险与回滚:**
收窄(BREAKING),只影响格式化器写成非单词的邮件模板;仓内出厂模板、示例、种子、技能零命中。另有一类读法变化:邮件里每个双引号参数现在都去掉首尾引号,与应用内一致(`default:"n/a"`、`date:"long"`、`number:"2"`、`truncate:"80"`、`default:""`);其中
`currency:"EUR"` 以前被 `currency-code-literal` 拒收,现在可发布、按 EUR
渲染(一处放宽,级别仍由收窄决定)。回滚即 revert 本 PR。
- **先看:** `packages/formula/src/template-engine.ts`(新增
`splitHoleFormatter` 与 `formatter-shape`
规则)、`packages/formula/src/mail-hole-grammar.ts`(去掉
`FILTER_RE`,改读共享切分)、`packages/plugins/plugin-email/src/text-field-html-outlets.pin.test.ts`(原样输出的边界重钉)。
- **两分钟验证:** `pnpm --filter @objectstack/formula exec vitest run
src/template-formatter-shape.test.ts`,预期 10
个用例全绿,其中「两种方言接受完全相同的格式化器写法」逐条比对 21 种写法。
- **需要你做的:** 无,席位落地(与 #27249 串行,后落地者合入 main)。
## What changes
- **`@objectstack/formula`, one tokenizer for both dialects**
(`template-engine.ts`). New `splitHoleFormatter(formatterPart)` splits
the text after a hole's first `|` at its first `:`, trims both sides and
removes one enclosing quote mark at each end of the argument. This is
the in-app `parseHole`'s own code, moved out unchanged. `parseHole` and
the mail grammar's `parseMailHole` both read it, so the two dialects now
accept exactly the same formatter parts (the seat's ruling on the
claim). The function is exported from the module for the mail grammar;
the package index exports nothing new.
- **`@objectstack/formula`, a fourth rule, `formatter-shape`**
(`judgeHoleFormatter`). It is judged after `one-formatter` and before
`known-formatter`: a formatter name that is not one word is refused. The
sentence names the hole, says what it wrote where the formatter name
goes (or that it wrote none), states the shape `{{ path |
formatter[:arg] }}` and lists `TEMPLATE_FORMATTERS`. `known-formatter`
keeps refusing a one-word unknown name, so `{{ x | curency :EUR }}`
still reads `known-formatter`. `TemplateHoleGrammarRule` gains
`'formatter-shape'`.
- **`@objectstack/formula`, the mail grammar** (`mail-hole-grammar.ts`).
Its own `FILTER_RE` is gone. A path followed by `|` now always parses as
a hole; a formatter part the grammar refuses rides as `refusal`, so
`judgeMailHoleGrammar` reports it. `parseMailHole` still answers `null`
only when the part before any `|` is not a path.
- **The in-app dialect: no accept set moves.** Its compile refused every
formatter part this PR refuses, before and after, and keeps its own
"invalid template hole" sentence (pinned).
- **`@objectstack/plugin-email`**: no code change in `render()`. It
already throws `TEMPLATE_INVALID` for a parsed hole that carries a
refusal, so these holes are refused at render. Only the header comment
names the new case.
- **`@objectstack/lint` `email-template-hole-grammar` and the publish
door**: no code change. They read `judgeMailHoleGrammar`. The rule's
header and the `authoring-rules.ts` roster comment name the new case.
- **`@objectstack/spec`**: `MAIL_TEMPLATE_HOLES` (the `subject` /
`bodyHtml` / `bodyText` descriptions) states the shape, and the
`IEmailService` `TEMPLATE_INVALID` TSDoc names it among the refused
holes. `content/docs/references/system/email-template.mdx` is
regenerated.
- **Docs**: `content/docs/automation/email-templates.mdx` gains a
paragraph on the rule, the shared reading and the verbatim boundary. The
stale sentence "An unknown formatter falls back to the raw value rather
than failing the render." (false since #27217) is deleted from the same
page.
- **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`
(describe and TSDoc only, `none`). The formula entry carries a
"Supersedes, in the same release" paragraph, because #26948's unreleased
entry says non-word formatter text "is left as written", which is no
longer true.
### The verbatim boundary (`text-field-html-outlets.pin.test.ts:284`)
Decided and pinned: **brace text whose part before any `|` is not a path
is the template's own text and stays verbatim** (`{{ not a path }}`,
`{{}}`, `{{ | upper }}`, `{{ a b | upper }}`, `{{ | date-long }}`). **A
path followed by `|` is a formatter use and is judged.** The corpus line
at :284 drops `{{ x | two words }}` and gains `{{ a b | upper }}`. A new
case in the same pin renders the literal set unchanged through the real
path and refuses `{{ x | two words }}`, `{{ due | date-long }}`, `{{ x |
}}` and the triple-braced form in both entry points.
### Census (on `origin/main` `9ff8154c`, which contains #27217)
Every git-tracked file was scanned for a mail-grammar hole whose path
parses, followed by `|` and a formatter part the old `FILTER_RE` failed.
Shipped mail templates (plugin-email `auth-templates.ts`, examples,
apps, seeds, dogfood and downstream fixtures, `skills/**`) have **0**
hits. The 79 raw hits are grammar prose in TSDoc, docs, CHANGELOGs and
changesets; chained holes the one-formatter rule already refuses; GitHub
Actions `||` expressions in `.github/` and `scripts/`; `ui/cliff.toml`
(a Tera template, not mail); and a source-escaped `currency: \'USD\'`
that parses at runtime. Two test lines pinned the old verbatim print:
the outlets pin :284 and `template-unknown-formatter.test.ts:45`. Both
are re-pinned here. No mail template in the repo writes a double-quoted
argument, so the double-quoted-argument reading change (below) touches
none.
### Merge order with #27249
**Serial with #27249 (#26939).** That PR edits the plugin-email
`render()` hunk, formula `template-engine.ts`,
`template-hole-grammar.test.ts`, `email-templates.mdx` and the
`IEmailService` TSDoc. This branch leaves `render()` alone (its only
plugin-email source edit is three lines in the header comment, clear of
#27249's hunks). Whichever lands second merges `main` (no rebase).
## Tests
- New `packages/formula/src/template-formatter-shape.test.ts` (10
cases): the tokenizer; the refusal and its sentence (named hole, shape,
known list, and the empty case); `one-formatter` still winning on a
chained hole; `parseMailHole` carrying the refusal; the in-app readings
(`currency :EUR`, `default:it's`, `default:"n/a"`, `currency
:@currency_code`); the literal set staying `null`;
`judgeMailHoleGrammar` locating refused holes, triple-braced included.
**⭐ Dialect parity:** 21 formatter parts, each compiled in-app and
parsed in mail, with the same verdict asserted on both sides (12
accepted, 9 refused), `currency:"EUR"` and `date:"long"` among the
accepted. The in-app sentence is unchanged.
- Re-pinned `packages/formula/src/template-unknown-formatter.test.ts`:
"text after the `|` that is not a word names no formatter, and the mail
grammar leaves it verbatim" is now "…is refused by the formatter-shape
rule, never left verbatim".
- New
`packages/plugins/plugin-email/src/template-formatter-shape.render.test.ts`
(5 cases): `renderTemplate` refuses on all three faces with the
template, locale, hole and shape; `sendTemplate` sends nothing; the
in-app readings render (`€1,234.50`, `it's`, `2026-10-28`, `n/a`).
-
`packages/plugins/plugin-email/src/text-field-html-outlets.pin.test.ts`:
the boundary case above, plus the corpus edit.
- New
`packages/lint/src/validate-email-template-hole-grammar.formatter-shape.test.ts`
(9 cases): all three faces; a template using `currency :EUR`, `date
:long` and `default:it's` draws nothing; literal brace text draws
nothing; `os validate` / `build` / `lint`; the runtime publish gate.
- New
`packages/metadata-protocol/src/runtime-authoring-gate.email-template-formatter-shape.test.ts`:
the `422 INVALID_METADATA` envelope (`code` and `status`, plus
`issues`), and the corrected template (with `currency :EUR`) publishing.
## Acceptance notes
- **The two dialects still differ in the PATH part, not here.** The
in-app path admits `$` and `[i]` (`PATH_ONLY_RE`), the mail path does
not (`PATH_RE`), so `{{ $error.message | upper }}` is a hole in-app and
literal text in mail. That is the path grammar, outside this card, and
it is unchanged.
- **Every double-quoted argument changes reading, and one spelling
widens.** The mail grammar kept double quotes as part of the argument;
the shared tokenizer removes one enclosing quote mark at each end, as
in-app always did. So `default:"n/a"` supplied `"n/a"` and supplies
`n/a`; `date:"long"` fell back to the short style and is long;
`number:"2"` had no fixed digits and has two; `truncate:"80"` did not
truncate and cuts at 80; `default:""` supplied `""` and supplies
nothing. `currency:"EUR"` was refused by `currency-code-literal` at
every door and at render, and now publishes and formats in EUR: that one
spelling is a widening. The narrowing is the BREAKING arm, so the
declarations and the levels do not move. The formula and lint changesets
say so (their earlier "nothing refused is accepted" / "no accept set is
widened" sentences were false and are corrected). The census found no
mail template with a double-quoted argument.
- **`known-formatter` and `formatter-shape` overlap by design.** Without
the `WORD_RE` guard, `known-formatter` would also catch `date-long`, but
its sentence ("names the formatter 'date-long'") reads as a misspelling.
`formatter-shape` states the shape instead.
## Verification
All readings below are on head `b0fe9278`, except where a later line
names `4bc8e16d` (branch off `origin/main` `9ff8154c`, `origin/main`
`4d3a077f` merged in, which touched only
`driver-sqlite-wasm/vitest.config.ts`).
<details><summary>Before and after, the mail grammar and the in-app
compile</summary>
Before (`origin/main` `9ff8154c`): `parseMailHole(' due | date-long ')`,
`' x | two words '`, `' x | '`, `' amount | currency :EUR '` and `" note
| default:it's "` all answered `null`. `judgeMailHoleGrammar` reported
nothing, and the renderer printed each hole as written. In-app:
`date-long`, `two words` and the empty part were refused at compile;
`currency :EUR` and `default:it's` compiled.
After (`b0fe9278`): the first three carry `refusal.rule ===
'formatter-shape'` and the renderer throws `TEMPLATE_INVALID`. `currency
:EUR` parses as `{ formatter: 'currency', arg: 'EUR' }` and renders
`€1,234.50`; `default:it's` parses with `arg: "it's"`. In-app verdicts
are unchanged on all 19 parts of the parity table.
</details>
<details><summary>Tests and typecheck</summary>
- `pnpm --filter @objectstack/formula exec vitest run --maxWorkers=2`:
59 files, 1541 tests. One failure on the first run was the new test's
own offset (expected 62, actual 60), corrected; then the three grammar
files 43/43.
- `pnpm --filter @objectstack/plugin-email exec vitest run
--maxWorkers=2` (formula dist rebuilt first): 43 files, 735 tests, all
passed.
- `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2`: 174
files, 7552 tests, all passed.
- `pnpm --filter @objectstack/metadata-protocol exec vitest run
src/runtime-authoring-gate.email-template` (closure rebuilt first): 3
files, 8 tests, all passed.
- `pnpm --filter @objectstack/formula --filter @objectstack/plugin-email
--filter @objectstack/lint --filter @objectstack/metadata-protocol
typecheck`: all four ran (`tsc --noEmit`, plus `check:test-typecheck`
for the first three), VERDICT command-exit 0.
- Round 1 (`4bc8e16d`; changesets, one docs line and
`template-formatter-shape.test.ts` only): `pnpm --filter
@objectstack/formula exec vitest run
src/template-formatter-shape.test.ts` 10/10 passed, with the parity
table at 21 parts. The dogfood battery on `b0fe9278` carries over (no
`src/**` moved): 348 of 351 files and 2804 of 2824 tests passed, the
rest skipped.
- `pnpm --filter @objectstack/spec build && check:generated`:
`check:docs` was stale (`content/docs/references/**`), `--fix`
regenerated `references/system/email-template.mdx`, and the rerun
reports all 14 artifacts up to date.
</details>
<details><summary>Ablation</summary>
With the fix committed, `node scripts/ablation-replace.mjs` added `if
(formatter === formatter) return null;` at the top of the
`formatter-shape` branch of `judgeHoleFormatter` (anchor 1 → 0, blob
`6014dfef` → `7f1c5707`). This simulates the old answer, where a
non-word name passes the judge. Then it ran
`template-formatter-shape.test.ts` and
`template-unknown-formatter.test.ts`: **6 failed | 13 passed**, the
refusal, sentence, empty-part, mail-parse, face-location and
dialect-parity cases. The restore confirmed the blob equals HEAD, and
`git diff HEAD` was empty. The formula tests import from `src`, so that
leg needed no rebuild. The plugin-email, lint and metadata-protocol legs
read formula's `dist` and were not ablated.
</details>
<details><summary>Derived gates</summary>
`node scripts/pm/dispatch-gates.mjs --commands` on `b0fe9278` derived
**106** families. All were run serially, each line recorded as `CMD ::
exit N`, and `--ran` reconciled: **106 derived, 104 run with exit 0, 2
NOT MEASURED, 0 unrun.**
- NOT MEASURED (exit 3, PREREQUISITE NOT MET, no full `pnpm build` in
this tree): `pnpm --filter @objectstack/spec run check:skill-examples`
and `pnpm check:dual-build-cjs-loads`. Both read built output of
packages this PR does not touch; CI's full build carries them.
- Green families that read this diff include
`check-adr-0087-registration --base origin/main`,
`check-changeset-no-major --base origin/main`, `check-empty-changeset
--base origin/main`, `check:type-check-debt` and
`check:undeclared-dep-imports`.
- `test:repo` was not run locally (CI's Test Core runs it).
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WGufzhX8CnnYtA7h6jieDA)_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01WGufzhX8CnnYtA7h6jieDA
---------
Co-authored-by: Claude <noreply@anthropic.com>| is not a formatter name is refused by name, and mail reads every formatter part as the in-app dialect does (#27270)1 parent bf01226 commit b445807
19 files changed
Lines changed: 544 additions & 52 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 | + | |
| 20 | + | |
| 21 | + | |
| 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 | + | |
| 19 | + | |
| 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 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
85 | 96 | | |
86 | 97 | | |
87 | 98 | | |
| |||
152 | 163 | | |
153 | 164 | | |
154 | 165 | | |
155 | | - | |
156 | | - | |
157 | 166 | | |
158 | 167 | | |
159 | 168 | | |
| |||
| 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 | | |
| |||
0 commit comments