Repository navigation
feat(formula, plugin-email, lint)!: a mail template hole whose text after the | is not a formatter name is refused by name, and mail reads every formatter part as the in-app dialect does - #27270
Conversation
…matter-shape refusal Claude-Session: https://claude.ai/code/session_01WGufzhX8CnnYtA7h6jieDA Co-authored-by: Claude <noreply@anthropic.com>
… every door; docs and changesets Claude-Session: https://claude.ai/code/session_01WGufzhX8CnnYtA7h6jieDA Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzhX8CnnYtA7h6jieDA Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 148 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 090c5839486df9fa3d7f7f2b7a718fc6dc795fdd && git checkout 090c5839486df9fa3d7f7f2b7a718fc6dc795fdd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0d45c0e71f0a03bdcf2230901515c2a29716a92b 4bc8e16d39df2740f685b5aa0d82036ab7d2babb && git checkout -B drift-repro 0d45c0e71f0a03bdcf2230901515c2a29716a92b && git merge --no-ff 4bc8e16d39df2740f685b5aa0d82036ab7d2babb
node scripts/docs-audit/affected-docs.mjs --json 0d45c0e71f0a03bdcf2230901515c2a29716a92b
|
Contract reviewServed-tier: Ruling applied: claim ① Derived judgmentsEvery judgment below is read off the code at the head and at the base, not off the PR body.
② Semver level
③ Boundary flags
④ Design judgmentThe narrowing, against the published spec on The boundary at Tried to break it. (a) A non-word key on Should-fix.
Non-blocking observation: the header of Implemented-by: VERDICT: PASS Generated by Claude Code |
…rgument class in the changesets; drop the stale raw-fallback sentence Claude-Session: https://claude.ai/code/session_01WGufzhX8CnnYtA7h6jieDA Co-authored-by: Claude <noreply@anthropic.com>
席位核对 · ACCEPT ·
|
Requested by Yibo Wang · project thread
Fixes #27225
Claim: 6469619404
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none —
skills/objectstack-formula/SKILL.md:444teaches a template hole as{{ path | formatter[:arg] }}with theTEMPLATE_FORMATTERSwhitelist, which is exactly the shape this PR enforces, and noskills/**file teaches or shows a mail hole whose formatter part is not that shape (grep overskills/**forformatter,emailTemplatesandemail_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 pagecontent/docs/automation/email-templates.mdxgains 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 }}原文;应用内模板早就拒绝,两种方言不一致(feat(formula, plugin-email, lint)!: a mail template hole whose formatter the whitelist does not name is refused by name, never rendered raw #27217 合同复核 S1)。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 种写法。currencyhole with no argument formats in the organization's default currency, and with none the render refuses it #27249 串行,后落地者合入 main)。What changes
@objectstack/formula, one tokenizer for both dialects (template-engine.ts). NewsplitHoleFormatter(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-appparseHole's own code, moved out unchanged.parseHoleand the mail grammar'sparseMailHoleboth 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 afterone-formatterand beforeknown-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 listsTEMPLATE_FORMATTERS.known-formatterkeeps refusing a one-word unknown name, so{{ x | curency :EUR }}still readsknown-formatter.TemplateHoleGrammarRulegains'formatter-shape'.@objectstack/formula, the mail grammar (mail-hole-grammar.ts). Its ownFILTER_REis gone. A path followed by|now always parses as a hole; a formatter part the grammar refuses rides asrefusal, sojudgeMailHoleGrammarreports it.parseMailHolestill answersnullonly when the part before any|is not a path.@objectstack/plugin-email: no code change inrender(). It already throwsTEMPLATE_INVALIDfor a parsed hole that carries a refusal, so these holes are refused at render. Only the header comment names the new case.@objectstack/lintemail-template-hole-grammarand the publish door: no code change. They readjudgeMailHoleGrammar. The rule's header and theauthoring-rules.tsroster comment name the new case.@objectstack/spec:MAIL_TEMPLATE_HOLES(thesubject/bodyHtml/bodyTextdescriptions) states the shape, and theIEmailServiceTEMPLATE_INVALIDTSDoc names it among the refused holes.content/docs/references/system/email-template.mdxis regenerated.content/docs/automation/email-templates.mdxgains 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 feat(formula, plugin-email, lint)!: a mail template hole whose formatter the whitelist does not name is refused by name, never rendered raw #27217) is deleted from the same page.minor(narrowed,(narrowing)arm, ADR-0087not-required (no-migration-prescription): a conversion cannot know which formatter was meant). Specpatch(describe and TSDoc only,none). The formula entry carries a "Supersedes, in the same release" paragraph, because mail templates: an unknown hole formatter (e.g. a misspelledcurency) is refused nowhere and renders the raw value; the in-app dialect refuses it (found reviewing #26660) #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/main9ff8154c, which contains #27217)Every git-tracked file was scanned for a mail-grammar hole whose path parses, followed by
|and a formatter part the oldFILTER_REfailed. Shipped mail templates (plugin-emailauth-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/andscripts/;ui/cliff.toml(a Tera template, not mail); and a source-escapedcurrency: \'USD\'that parses at runtime. Two test lines pinned the old verbatim print: the outlets pin :284 andtemplate-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, formulatemplate-engine.ts,template-hole-grammar.test.ts,email-templates.mdxand theIEmailServiceTSDoc. This branch leavesrender()alone (its only plugin-email source edit is three lines in the header comment, clear of #27249's hunks). Whichever lands second mergesmain(no rebase).Tests
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-formatterstill winning on a chained hole;parseMailHolecarrying the refusal; the in-app readings (currency :EUR,default:it's,default:"n/a",currency :@currency_code); the literal set stayingnull;judgeMailHoleGrammarlocating 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"anddate:"long"among the accepted. The in-app sentence is unchanged.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".packages/plugins/plugin-email/src/template-formatter-shape.render.test.ts(5 cases):renderTemplaterefuses on all three faces with the template, locale, hole and shape;sendTemplatesends 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.packages/lint/src/validate-email-template-hole-grammar.formatter-shape.test.ts(9 cases): all three faces; a template usingcurrency :EUR,date :longanddefault:it'sdraws nothing; literal brace text draws nothing;os validate/build/lint; the runtime publish gate.packages/metadata-protocol/src/runtime-authoring-gate.email-template-formatter-shape.test.ts: the422 INVALID_METADATAenvelope (codeandstatus, plusissues), and the corrected template (withcurrency :EUR) publishing.Acceptance notes
$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.default:"n/a"supplied"n/a"and suppliesn/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 bycurrency-code-literalat 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-formatterandformatter-shapeoverlap by design. Without theWORD_REguard,known-formatterwould also catchdate-long, but its sentence ("names the formatter 'date-long'") reads as a misspelling.formatter-shapestates the shape instead.Verification
All readings below are on head
b0fe9278, except where a later line names4bc8e16d(branch offorigin/main9ff8154c,origin/main4d3a077fmerged in, which touched onlydriver-sqlite-wasm/vitest.config.ts).Before and after, the mail grammar and the in-app compile
Before (
origin/main9ff8154c):parseMailHole(' due | date-long '),' x | two words ',' x | ',' amount | currency :EUR 'and" note | default:it's "all answerednull.judgeMailHoleGrammarreported nothing, and the renderer printed each hole as written. In-app:date-long,two wordsand the empty part were refused at compile;currency :EURanddefault:it'scompiled.After (
b0fe9278): the first three carryrefusal.rule === 'formatter-shape'and the renderer throwsTEMPLATE_INVALID.currency :EURparses as{ formatter: 'currency', arg: 'EUR' }and renders€1,234.50;default:it'sparses witharg: "it's". In-app verdicts are unchanged on all 19 parts of the parity table.Tests and typecheck
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, pluscheck:test-typecheckfor the first three), VERDICT command-exit 0.4bc8e16d; changesets, one docs line andtemplate-formatter-shape.test.tsonly):pnpm --filter @objectstack/formula exec vitest run src/template-formatter-shape.test.ts10/10 passed, with the parity table at 21 parts. The dogfood battery onb0fe9278carries over (nosrc/**moved): 348 of 351 files and 2804 of 2824 tests passed, the rest skipped.pnpm --filter @objectstack/spec build && check:generated:check:docswas stale (content/docs/references/**),--fixregeneratedreferences/system/email-template.mdx, and the rerun reports all 14 artifacts up to date.Ablation
With the fix committed,
node scripts/ablation-replace.mjsaddedif (formatter === formatter) return null;at the top of theformatter-shapebranch ofjudgeHoleFormatter(anchor 1 → 0, blob6014dfef→7f1c5707). This simulates the old answer, where a non-word name passes the judge. Then it rantemplate-formatter-shape.test.tsandtemplate-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, andgit diff HEADwas empty. The formula tests import fromsrc, so that leg needed no rebuild. The plugin-email, lint and metadata-protocol legs read formula'sdistand were not ablated.Derived gates
node scripts/pm/dispatch-gates.mjs --commandsonb0fe9278derived 106 families. All were run serially, each line recorded asCMD :: exit N, and--ranreconciled: 106 derived, 104 run with exit 0, 2 NOT MEASURED, 0 unrun.pnpm buildin this tree):pnpm --filter @objectstack/spec run check:skill-examplesandpnpm check:dual-build-cjs-loads. Both read built output of packages this PR does not touch; CI's full build carries them.check-adr-0087-registration --base origin/main,check-changeset-no-major --base origin/main,check-empty-changeset --base origin/main,check:type-check-debtandcheck:undeclared-dep-imports.test:repowas not run locally (CI's Test Core runs it).Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01WGufzhX8CnnYtA7h6jieDA