Repository navigation
feat(formula,plugin-email,lint)!: a mail template hole takes one formatter and a literal currency code, refused at authoring and at render - #26799
Conversation
…s a literal ISO 4217 code, refused at authoring and at render instead of formatting in USD
`{{ x | currency:currency_code }}` passed every gate and rendered the amount
in US dollars: the argument is not read as a path, Intl refused it, and the
formatter substituted USD. The argument is now a literal code in three
capital letters, judged once by `judgeHoleFormatter` in @objectstack/formula
for both template dialects:
- formula: the in-app template compile refuses the hole (`invalid-template`
at os validate and the publish gate); `formatValue('currency', ...)` throws
a RangeError instead of substituting USD. The mail hole grammar is exported
once (`mailHolePattern`, `parseMailHole`, `judgeMailHoleGrammar`); the
placement judge drops its copy.
- lint: a gating rule `email-template-hole-grammar` judges subject, bodyText
and bodyHtml on every command and the email_template publish door
(422 INVALID_METADATA).
- plugin-email: the renderer parses with the shared grammar and a refused
hole fails the render with TEMPLATE_INVALID, naming the template and hole.
No argument still formats in USD.
Claude-Session: https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B
Co-authored-by: Claude <noreply@anthropic.com>
…; a chained hole is refused at authoring and at render instead of printing formatter syntax
`{{ x | default:0 | number:2 }}` passed every gate: the second `|` was read
as part of `default`'s argument, so the hole printed `0 | number:2` for an
empty value and the bare value otherwise, and `{{ x | upper | lower }}` was
left on the page as written. `judgeHoleFormatter` now refuses a second
formatter outside a quoted argument, for both template dialects:
- formula: the in-app compile refuses it (`invalid-template`); the mail
grammar recognises the chained hole even when its first formatter alone
would not parse, so it is located and named, never left verbatim.
- lint: `email-template-hole-grammar` refuses it on every command and the
email_template publish door (422 INVALID_METADATA), naming the
one-formatter rule and the caller remedy.
- plugin-email: the render fails with TEMPLATE_INVALID.
Claude-Session: https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B
Co-authored-by: Claude <noreply@anthropic.com>
…ate-hole-grammar registered `check:docs-transcript-drift` derives the count from `authoringRulesFor()`; the new gating rule moves it from 54 to 55 for build and validate. Claude-Session: https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 23 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 648b5552aeba2224a365c508effb98de0d3c34b2 && git checkout 648b5552aeba2224a365c508effb98de0d3c34b2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin aeb9cf0ddc3233ca0c2dbb2c05fc91534a6b13bc 898b6a1fe1cf57c1663fb88b4229f853616e4ec6 && git checkout -B drift-repro aeb9cf0ddc3233ca0c2dbb2c05fc91534a6b13bc && git merge --no-ff 898b6a1fe1cf57c1663fb88b4229f853616e4ec6
node scripts/docs-audit/affected-docs.mjs --json aeb9cf0ddc3233ca0c2dbb2c05fc91534a6b13bc
|
Claude-Session: https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B Co-authored-by: Claude <noreply@anthropic.com>
… and inbox channels The mail renderer now fails a stored template whose hole the grammar refuses with TEMPLATE_INVALID. Graded retryable, such a delivery spent its whole retry ladder before going dead; it now goes dead at once, like the other template failures. Pinned once per channel, each ablated. Claude-Session: https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Face: the file list carries no schema and no governed path, so the recogniser reads the face off the changesets' ① Derived judgmentsAccept-set changes the diff implies — each named RIGHT or WRONG against the tree.
Public-surface changes (additive): Exactly one mail hole grammar. ② Semver levelFour changesets, read against what the diff publishes:
Two should-fix findings on changeset prose (level unaffected):
③ Boundary flagsEvery dev flag, deviation and
④ Design judgmentPer floor file, against the code it cites and the scenario it is accepted against (cards #26660 item (c) and #26673: hotclm's
No must-fix. Should-fix: S1 and S2 (changeset wording, ②). Observation only: the renderer's Implemented-by: VERDICT: PASS Generated by Claude Code |
…, and name what is added instead of "nothing is widened" A lowercase code formatted in that currency before and is refused now; a path or empty argument fell back to US dollars. The narrowing changesets name their additive exports (plugin-email adds none). Claude-Session: https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B Co-authored-by: Claude <noreply@anthropic.com>
席位核对 · ACCEPT ·
|
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, thetemplaterow) already spells a hole as one optional formatter,{{ path | formatter[:arg] }}; the refusals name both rules at every door, andcontent/docs/automation/email-templates.mdxis 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 实测)。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/formulajudgeHoleFormatter(hole, formatterPart, formatter, arg)(intemplate-engine.ts, besideFORMATTERS) 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 thecurrencyargument 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.templateEngine.compile/evaluateanswer aparseerror naming the hole and the rule, sovalidateExpressionreportsinvalid-templateatos validateand the publish gate.currencyformatter no longer catches theIntlrefusal and substitutes USD: a non-code argument throws aRangeErrorbefore the value is read.mail-hole-grammar.ts(mailHolePattern,parseMailHole,judgeMailHoleGrammar).judgeMailHtmlHolesdrops 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), judgessubject,bodyTextandbodyHtmlof every template on all three commands and on the runtime publish surface foremail_templatewrites, refused there with the existing422 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 throwsTEMPLATE_INVALID, andresolveAndRenderTemplatenames 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'classifyErrorgradeTEMPLATE_INVALIDpermanent, besideTEMPLATE_NOT_FOUND,TEMPLATE_INACTIVE,MISSING_VARIABLESandTEMPLATE_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 changeset26660-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(theemail_templatepublish door is that registry's runtime surface), notemail-template-door.ts, which is the closedsys_email_templaterow door and judges no holes. The door pin lives inpackages/metadata-protocol(test only), beside the placement rule's.Same-day churn. Both symptoms re-measured on
origin/maina485b5a3through the surviving by-name render path,EmailService.renderTemplate(the module-level body renderer is retired from the entry).currency:currency_codewitheurrenderedUS$1,234.50(zh-CN),$1,234.50(en-US),1 234,50 $US(fr-FR); the chained hole rendered0 | number:2for null and71100otherwise, 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 JSXstyleobject).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 throughjudgeHoleFormatter, 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):renderTemplateandsendTemplaterefuse both shapes in each face withTEMPLATE_INVALIDnaming template, locale, hole and rule; nothing is sent; one formatter, a quoted|andcurrency:EURstill render.packages/lint/src/validate-email-template-hole-grammar.test.ts(new, 15): the rule per face, the registry entry (gating, all commands,email_templateruntime surface), eachoscommand aterror, the runtime gate.packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts(+2): each refusal at the publish door assertscodeINVALID_METADATA+status422 + the issue's rule and path, with a non-vacuous admit.Full suites and typecheck at 9fd3fde
@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 eslinton the 13 changed TypeScript files: exit 0.9d428aaechanges four docs transcripts only (no source); the suites above were not re-run there, the transcript gate was.Ablations (each through scripts/ablation-replace.mjs, WRAP mode; dist legs proven with ablation-dist-preflight)
formatter === 'currency'to a marker injudgeHoleFormatter; 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.chainsASecondFormatterbranch; rebuilt, marker in dist): formula 6, plugin-email 4, lint 6, metadata-protocol 1 failed — exactly the chain pins.Paid 0 | number:2, the original defect.git diff HEAD; after the last dist leg formula and lint were rebuilt and both markers proven absent fromdist/, tree clean.Acceptance notes
currency:ARGtakes only a literal ISO code, and a path argument silently renders USD #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.currencywith 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.currencyargument can no longer render, so the in-app compile must refuse it rather than letevaluatethrow. Same defect class, same file family; the in-app chain (default:0 | number:2printed0 | number:2there as well) is refused by the same function.TEMPLATE_INVALIDis a new render failure code. The messaging email and inbox channels grade it permanent (patch round 1). TheIEmailServicecontract's error lists inpackages/spec/src/contracts/email-service.tsstill name three codes; that documentation line is the spec lane's: filed by the seat as contracts: IEmailService's sendTemplate / renderTemplate Errors lists do not name TEMPLATE_INVALID, the render refusal plugin-email gains in PR #26799 #26803.number:twosilently formats without fixed digits). Not measured at a door here; noted only.origin/mainmerged at0f3d068c; validate/lint/build refuse a lookup or record_change trigger to an object provided by a package in manifest.dependencies — the publish gate already resolves that closure #25805 (also onpackages/lint) has not landed. It adds an authoring rule too, so whichever lands second moves the four transcript counts again (check:docs-transcript-driftnames them).Verification
Gate families (derived with dispatch-gates --commands, reconciled with --ran: 91 derived, 91 run)
9fd3fde3, pluspnpm check:error-code-casingandnode scripts/check-platform-checklist.mjs --changed-from origin/main(both exit 0).pnpm check:docs-transcript-drift: exit 1 at9fd3fde3(four transcripts printed 54 rules, the registry derives 55) — fixed in9d428aae, exit 0.pnpm check:cli-examples-parity: newly derived at9d428aae, exit 0.pnpm --filter @objectstack/spec run check:skill-examplesandpnpm check:dual-build-cjs-loads: exit 3 (prerequisite: unbuilt packages) at first; exit 0 at9d428aaeonce 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.Dogfood battery at 9d428aa
pnpm --filter @objectstack/dogfood testunder the verify lock (closure built first):VERDICT command-exit 0, Test Files 302 passed, 3 skipped (305), Tests 2507 passed, 20 skipped (2527).Patch round 1 at dfe8026 (origin/main merged at 1cd4b73, the PR was 15 behind)
@objectstack/service-messagingTest Files 61 passed (61), Tests 731 passed;@objectstack/formula56 files, 1484 tests;@objectstack/plugin-email40 files, 712 tests;@objectstack/lint169 files, 7445 tests;@objectstack/metadata-protocol269 passed and 3 skipped files, 21618 passed and 19 skipped tests.pnpm exec eslinton the 17 changed TypeScript files: exit 0.pnpm check:i18nandpnpm check:i18n-stale-fill). 92 exit 0 on the first run;pnpm check:dual-build-cjs-loadsexit 3 (six packages unbuilt in the fresh worktree), exit 0 once they were built. Extraspnpm check:error-code-casingandnode scripts/check-platform-checklist.mjs --changed-from origin/main: exit 0.--ranreconciliation with exit codes recorded: 93 derived, 93 run, 0 NOT-MEASURED.VERDICT command-exit 0, Test Files 313 passed, 3 skipped (316), Tests 2588 passed, 20 skipped (2608).Commits: one per member (
9e59d3dethe currency literal,44ccaf92one formatter per hole), merges oforigin/main(9fd3fde3,1cd4b739),9d428aaethe transcript counts, and patch round 1 (d6a898e3the messaging grade,dfe80262its changeset).Session: session_01QSeKYmQXM3rHJcPnrEPk4B
Generated by Claude Code