Repository navigation
feat(formula, plugin-email, lint)!: a mail template hole whose formatter the whitelist does not name is refused by name, never rendered raw - #27217
Conversation
…r the whitelist does not name is refused by name, never rendered raw Claude-Session: https://claude.ai/code/session_01WGufzgBzVZJqh8CQJLrZu8 Co-authored-by: Claude <noreply@anthropic.com>
… mail formatter refusal on every door Claude-Session: https://claude.ai/code/session_01WGufzgBzVZJqh8CQJLrZu8 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzgBzVZJqh8CQJLrZu8 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs read: card #26948 (body, triage 6455660116, claim 6465542682, dev report 6467173175), PR #27217 body and file list, the net diff ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgmentThe narrowing, read against the published spec on M1 — must-fix: the head is not landable and no gate has judged it. #26937 merged into
S1 — should-fix: file the non-word-formatter card. Nothing else found. Probes that held: prototype keys at every door (①1); the rule order against Implemented-by: VERDICT: FAIL |
Claude-Session: https://claude.ai/code/session_01WGufzgBzVZJqh8CQJLrZu8 Co-authored-by: Claude <noreply@anthropic.com>
… roster name the unknown-formatter refusal Claude-Session: https://claude.ai/code/session_01WGufzgBzVZJqh8CQJLrZu8 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 — 150 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 0d27374fe2a76e7c47b4de2e52ad458dbea96aca && git checkout 0d27374fe2a76e7c47b4de2e52ad458dbea96aca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ae16687ad430f4f9870ea5ba2bc9b63d6f41fc31 97613711a5d206bb091499645224c9c1aced6d14 && git checkout -B drift-repro ae16687ad430f4f9870ea5ba2bc9b63d6f41fc31 && git merge --no-ff 97613711a5d206bb091499645224c9c1aced6d14
node scripts/docs-audit/affected-docs.mjs --json ae16687ad430f4f9870ea5ba2bc9b63d6f41fc31
|
…nown-formatter clause Claude-Session: https://claude.ai/code/session_01WGufzgBzVZJqh8CQJLrZu8 Co-authored-by: Claude <noreply@anthropic.com>
席位核对 · ACCEPT ·
|
…-formatter refusal (#27217) beside the tenant default Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzizxYz7JyC5JLW3Gmv
Requested by Yibo Wang · project thread
Fixes #26948
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none —
skills/objectstack-formula/SKILL.md:444already teaches the formatter whitelist asTEMPLATE_FORMATTERS, which is exactly the rule this PR enforces, and noskills/**file teaches or shows a mail hole whose formatter is outside it (grep overskills/**forformatter,email templateandemailTemplates: none of the hits teaches a mail formatter name); the docs pagecontent/docs/automation/email-templates.mdxgains the refusal here.维护者速读
{{ amount | curency:EUR }})现在在os validate/os build/os lint和元数据发布门(422 INVALID_METADATA)被按名拒绝,并列出全部可用格式化器;已存的旧模板渲染时报TEMPLATE_INVALID,不再发出原始值。1234.5、日期处显示原始时间戳,作者毫无提示;应用内模板早就拒绝这种写法,两种方言不一致。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 通过(三个面、三条命令、发布门各被拒)。What changes
@objectstack/formula,judgeHoleFormatter(template-engine.ts): a third rule,known-formatter, judged afterone-formatterand before thecurrencyargument. A hole whose formatter is a single word thatTEMPLATE_FORMATTERSdoes not hold is refused, and the sentence names the hole and lists every known formatter.TemplateHoleGrammarRulegains'known-formatter'. The check reads the exported list, so an inherited object key (toString,constructor) is not a formatter either.mail-hole-grammar.ts): no code change.parseMailHolealready callsjudgeHoleFormatter, so it now carries the refusal andjudgeMailHoleGrammarreports the hole. Only its header names the new rule.@objectstack/plugin-emailrenderer (template-engine.ts): the// unknown formatter → rawfallback is gone. A refused hole throwsTEMPLATE_INVALIDfrom the grammar as the other refusals do, andEmailServiceprefixes the template and locale. IfformatValueever answeredundefinedfor a name the grammar passed, the render throwsTEMPLATE_INVALIDtoo, so a raw value is never printed in its place.@objectstack/lintemail-template-hole-grammar: no code change (it readsjudgeMailHoleGrammar). It now refuses the hole atos validate,os build,os lintand the runtime publish gate. Only its header comment names the new case.{{ x | toString }}) used to pass!FORMATTERS[name]and run as a formatter, printing[object Object]. Now the shared rule refuses it.content/docs/automation/email-templates.mdxgains a paragraph on the rule. TheTEMPLATE_INVALIDTSDoc onIEmailService.sendTemplate/renderTemplate(packages/spec/src/contracts/email-service.ts) names the third hole it refuses.minor(narrowed,(narrowing)arm, ADR-0087not-required (no-migration-prescription): a conversion cannot know which formatter was meant), specpatch(TSDoc only,none).@objectstack/metadata-protocolgets only a new test file, which is not in its publishedfiles, so it has no entry.Census (on
origin/main72629f3)I grepped every
{{ path | namehole 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-emailauth-templates.ts, the spec seeds, dogfood and downstream fixtures, the upgrade-harness SQL fixtures andskills/**. Examples 0, seeds 0, skills 0, apps 0. The only mail holes with a formatter are thedatetime:longholes inauth-templates.tsand 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 mergedmainonce #26937 landed (9b72c4e1, no rebase). The one conflict wasTemplateHoleGrammarRule. It now has five members:one-formatter,known-formatter,currency-code-literal,currency-code-path-mail-onlyandcurrency-code-path-undeclared.judgeHoleFormatterkeeps #26937'sdialectparameter, andknown-formattersits afterone-formatterand before the currency block.395c096eadds the unknown-formatter clause toMAIL_TEMPLATE_HOLESand its comment (theCURRENCY_ARGUMENT_RULEsentence stays verbatim), and to thevalidateEmailTemplateHoleGrammarroster comment inauthoring-rules.ts. It also widens the spec changeset and corrects theIEmailServiceTSDoc.97613711regeneratescontent/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 }}) failsFILTER_RE/WORD_RE, soparseMailHoleanswersnull. Every mail door then accepts it and the renderer prints the hole verbatim (pinned attext-field-html-outlets.pin.test.ts:284), while the in-app dialect refuses it. #27225 carries that.Tests
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/judgeMailHoleGrammarlocating triple-braced holes, the in-app sentence unchanged, and in-apptoStringnow refused.packages/plugins/plugin-email/src/template-unknown-formatter.render.test.ts(5 cases). It coversrenderTemplateon all three faces refused with the template, locale, hole and known list,sendTemplatesending nothing, and the corrected name rendering.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.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.packages/metadata-protocol/src/runtime-authoring-gate.email-template-unknown-formatter.test.ts. It checks the422 INVALID_METADATAenvelope (codeandstatus, plusissues), and that the corrected name publishes.Acceptance notes
|that is not a single word ({{ x | two words }},{{ x | }}) still names no formatter in the mail grammar and is left as written, astext-field-html-outlets.pin.test.tspins. 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.sendTemplatecase and the re-pinnedtemplate-engine.test.tscase stayed green. That is the renderer's ownformatted === undefinedguard: it still throwsTEMPLATE_INVALIDwhen 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/main72629f3 + 3 commits).Package tests and typecheck (under os-verify-lock)
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/spectypecheck, thenpnpm --filter @objectstack/spec check:generated: VERDICT command-exit 0.eslint --no-inline-configover the changed.tsfiles: exit 0.Ablation of the rule
node scripts/ablation-replace.mjsmade theknown-formatterbranch unreachable (anchor hit once,--hold), followed bypnpm --filter @objectstack/formula build.ablation-dist-preflightconfirmed the marker indist/index.jsanddist/index.mjs. Results, all red as expected:The restore was
--restore(blob equal to HEAD,git diff HEADempty), followed by a rebuild.ablation-dist-preflight --absentconfirmed the marker was gone from all 6 built files and the tree was clean.Derived gates
node scripts/pm/dispatch-gates.mjs --commands: 105 commands, run serially. 103 exit 0, includingcheck-adr-0087-registration --base origin/main,check-changeset-no-major --base origin/main,check:doc-authoringandcheck:docs. 2 exit 3, PREREQUISITE NOT MET (they need a full repo build, so nothing was measured):spec check:skill-examplesandcheck:dual-build-cjs-loads. CI runs both.--ranreconciliation: 105 derived, 103 run, 2 NOT-MEASURED, 0 UNRUN.Dogfood (owed by the narrowing arm)
pnpm --workspace-concurrency=2 --filter '@objectstack/dogfood^...' build && pnpm --filter @objectstack/dogfood teston head 857e696 (noOS_TEST_SHARD, no path): VERDICT command-exit 0. Test Files 348 passed, 3 skipped (351); Tests 2804 passed, 20 skipped (2824). No failures.Generated by Claude Code