From 228ccf8fa3e30c09a6bca0c17d6f4038c6f909f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 13:28:58 +0000 Subject: [PATCH 1/2] feat(spec): schema-free fine-grained /meta-spelling entry; spelling agreement moves to a build-time gate (#10096) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /meta URL-spelling contract (META_URL_TO_SINGULAR, canonicalMetaUrlType, metaUrlSpellingRefusal, unrecognisedMetaTypeRefusal) gets its own entry whose module graph is two pure modules — no zod, no registry closure. The map is materialized at build time by gen:meta-url-spelling into src/meta-spelling/meta-url-data.generated.ts (merge-driver registered, check:generated GATED); check:meta-url-spelling re-derives the three-limb union per CI lap and is the build-time enforcement home of the former module-load assertMetaUrlSpellingsAgree() (maintainer ruling 2026-08-20: moved, never dropped). /shared keeps all four symbols as re-exports of the one declaration. Standing principle written into README.md and the root module doc. Measured: the one fold costs 1110 B min / 530 B gz via the new entry vs 578.4 KB min / 136.8 KB gz via /shared on origin/main. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016gcKVsiywU9CcS96S5t9qD --- .gitattributes | 1 + .github/workflows/lint.yml | 12 + packages/spec/README.md | 26 ++ packages/spec/api-surface/meta-spelling.json | 10 + .../spec/export-origins/meta-spelling.json | 10 + packages/spec/export-origins/shared.json | 8 +- packages/spec/package.json | 12 + .../spec/scripts/build-meta-url-spelling.ts | 160 ++++++++ packages/spec/scripts/check-generated.ts | 10 + packages/spec/scripts/export-origins.test.ts | 1 + packages/spec/scripts/lib/category-title.ts | 4 + packages/spec/src/index.ts | 20 +- packages/spec/src/meta-spelling/index.ts | 33 ++ .../meta-spelling/meta-url-data.generated.ts | 89 +++++ .../meta-spelling/metadata-url-spelling.ts | 318 ++++++++++++++++ .../src/shared/metadata-url-spelling.test.ts | 31 +- .../spec/src/shared/metadata-url-spelling.ts | 344 ++---------------- packages/spec/tsup.config.ts | 7 +- scripts/regen-artifacts.mjs | 11 + 19 files changed, 777 insertions(+), 330 deletions(-) create mode 100644 packages/spec/api-surface/meta-spelling.json create mode 100644 packages/spec/export-origins/meta-spelling.json create mode 100644 packages/spec/scripts/build-meta-url-spelling.ts create mode 100644 packages/spec/src/meta-spelling/index.ts create mode 100644 packages/spec/src/meta-spelling/meta-url-data.generated.ts create mode 100644 packages/spec/src/meta-spelling/metadata-url-spelling.ts diff --git a/.gitattributes b/.gitattributes index 8ef510ce03..0652470e1d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -83,6 +83,7 @@ packages/spec/authorable-surface.base.json merge=os-regen packages/spec/authorable-defaults/** merge=os-regen packages/spec/json-schema.manifest/** merge=os-regen packages/spec/api-surface/** merge=os-regen +packages/spec/src/meta-spelling/meta-url-data.generated.ts merge=os-regen packages/spec/export-origins/** merge=os-regen packages/spec/api-surface-signatures.json merge=os-regen docs/protocol-upgrade-guide.md merge=os-regen diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 39721ebf65..a6c77da2c6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1695,6 +1695,18 @@ jobs: - name: Check skill docs are generated from SKILL.md frontmatter run: pnpm --filter @objectstack/spec check:skill-docs + # [#10096] The schema-free `/meta` URL-spelling data module + # (src/meta-spelling/meta-url-data.generated.ts). Re-derives the + # three-limb union from PLURAL_TO_SINGULAR + DEFAULT_METADATA_TYPE_REGISTRY + # and fails on staleness OR on a manifest/derived spelling disagreement — + # this step is the build-time enforcement home of what used to be a + # module-load `assertMetaUrlSpellingsAgree()` (maintainer ruling + # 2026-08-20: the assertion may move, never drop). Reads src via tsx, + # needs no build; belongs in this unfiltered required job for the same + # reason as its artifact-gate neighbours. + - name: Check the meta-url-spelling data module is current and spellings agree + run: pnpm --filter @objectstack/spec check:meta-url-spelling + - name: Check spec-changes.json is regenerated with the ADR-0087 registries run: pnpm --filter @objectstack/spec check:spec-changes diff --git a/packages/spec/README.md b/packages/spec/README.md index ebd9c7f86d..2d6cec585b 100644 --- a/packages/spec/README.md +++ b/packages/spec/README.md @@ -12,6 +12,32 @@ The **Source of Truth** for the ObjectStack Protocol. Contains strictly typed Zo - **Automation**: Flows, Workflows, Triggers. - **AI**: Agents, RAG Pipelines, Models, MCP Servers. +## Export surfaces + +The package publishes one entry per protocol domain (`@objectstack/spec/data`, +`/ui`, `/kernel`, …) plus fine-grained vocabulary entries +(`@objectstack/spec/meta-spelling` — the `/meta/:type` URL-spelling contract). +Each entry is a self-contained bundle: what an entry's module graph reaches is +what every consumer of that entry pays for. + +**Standing principle** (maintainer ruling 2026-08-20, recorded verbatim on +objectstack#10096): + +> **浏览器可达的 spec 导出面必须 schema-free。** A `@objectstack/spec` export +> surface that browser/client consumers reach must carry vocabulary — maps, +> folds, enums, pure predicates — without linking the zod schema/validation +> machinery. The schema graph is the server/publish side's dependency, never +> the price of spelling a URL segment or reading a posture predicate. + +Adding an export that browser/client code will import? Either place it on a +schema-free entry (`/meta-spelling` is the reference pattern: derivation from +the schema graph happens at build time via `gen:meta-url-spelling`, gated by +`check:meta-url-spelling`), or verify the entry it lands on keeps a +schema-free module graph. The package declares `sideEffects: false`, so +bundlers may drop what a consumer does not reach — module-scope side effects +in any published module are therefore also a defect (measured, not assumed; +see objectstack#10031). + ## Usage **Recommended: Use `ObjectSchema.create()` with `Field.*` helpers for strict TypeScript validation:** diff --git a/packages/spec/api-surface/meta-spelling.json b/packages/spec/api-surface/meta-spelling.json new file mode 100644 index 0000000000..36e5bffee9 --- /dev/null +++ b/packages/spec/api-surface/meta-spelling.json @@ -0,0 +1,10 @@ +{ + "description": "Every exported `name (kind)` of one published entry point of @objectstack/spec — the breadth half of the ADR-0059 backward-compatibility gate. Sharded by entry point (#5837) so two PRs touching different entry points never share a file. Reads the BUILT dist/*.d.ts: regenerate with `pnpm --filter @objectstack/spec gen:api-surface` after a real build.", + "entry": "./meta-spelling", + "exports": [ + "META_URL_TO_SINGULAR (const)", + "canonicalMetaUrlType (function)", + "metaUrlSpellingRefusal (function)", + "unrecognisedMetaTypeRefusal (function)" + ] +} diff --git a/packages/spec/export-origins/meta-spelling.json b/packages/spec/export-origins/meta-spelling.json new file mode 100644 index 0000000000..8605b44fdd --- /dev/null +++ b/packages/spec/export-origins/meta-spelling.json @@ -0,0 +1,10 @@ +{ + "description": "Which SOURCE DECLARATION each name exported by one public entry point of @objectstack/spec resolves to, after its alias chain is unwound: `# ()`. Two exports share an origin string iff they are the same declaration — so equal origins across two entries are a harmless re-export, and different origins under one name are the #4411 dual-source trap. Generated from src/ (no build needed) and read by the export-surface pin tests, which compare against it instead of each building their own ts.createProgram — that was ~55s of compilation per CI lap and a non-deterministic timeout that ejected unrelated PRs from the merge queue (#4796). Sharded by entry point (#5837) so two retirement PRs never share a file. Carries NO line numbers: the pins asserted the line as `\\d+`, and recording it would rewrite this artifact on every edit that shifts a line in any .zod.ts. Regenerate with `pnpm --filter @objectstack/spec gen:export-origins` and read the diff.", + "entry": "./meta-spelling", + "exports": { + "META_URL_TO_SINGULAR": "src/meta-spelling/meta-url-data.generated.ts#META_URL_TO_SINGULAR (const)", + "canonicalMetaUrlType": "src/meta-spelling/metadata-url-spelling.ts#canonicalMetaUrlType (function)", + "metaUrlSpellingRefusal": "src/meta-spelling/metadata-url-spelling.ts#metaUrlSpellingRefusal (function)", + "unrecognisedMetaTypeRefusal": "src/meta-spelling/metadata-url-spelling.ts#unrecognisedMetaTypeRefusal (function)" + } +} diff --git a/packages/spec/export-origins/shared.json b/packages/spec/export-origins/shared.json index 959948f700..1d7f175b07 100644 --- a/packages/spec/export-origins/shared.json +++ b/packages/spec/export-origins/shared.json @@ -48,7 +48,7 @@ "KeySetGuidance": "src/shared/suggestions.zod.ts#KeySetGuidance (interface)", "MAP_SUPPORTED_FIELDS": "src/shared/metadata-collection.zod.ts#MAP_SUPPORTED_FIELDS (const)", "METADATA_ALIASES": "src/shared/metadata-collection.zod.ts#METADATA_ALIASES (const)", - "META_URL_TO_SINGULAR": "src/shared/metadata-url-spelling.ts#META_URL_TO_SINGULAR (const)", + "META_URL_TO_SINGULAR": "src/meta-spelling/meta-url-data.generated.ts#META_URL_TO_SINGULAR (const)", "MapSupportedField": "src/shared/metadata-collection.zod.ts#MapSupportedField (type)", "MetadataCollectionInput": "src/shared/metadata-collection.zod.ts#MetadataCollectionInput (type)", "MetadataFormat": "src/shared/metadata-types.zod.ts#MetadataFormat (type)", @@ -97,7 +97,7 @@ "ViewNameParsed": "src/shared/branded-types.zod.ts#ViewNameParsed (type)", "ViewNameSchema": "src/shared/branded-types.zod.ts#ViewNameSchema (const)", "applyProtection": "src/shared/protection.zod.ts#applyProtection (function)", - "canonicalMetaUrlType": "src/shared/metadata-url-spelling.ts#canonicalMetaUrlType (function)", + "canonicalMetaUrlType": "src/meta-spelling/metadata-url-spelling.ts#canonicalMetaUrlType (function)", "cel": "src/shared/expression.zod.ts#cel (function)", "cron": "src/shared/expression.zod.ts#cron (function)", "expression": "src/shared/expression.zod.ts#expression (function)", @@ -108,7 +108,7 @@ "keySetMatches": "src/shared/suggestions.zod.ts#keySetMatches (function)", "lazySchema": "src/shared/lazy-schema.ts#lazySchema (function)", "levenshteinDistance": "src/shared/suggestions.zod.ts#levenshteinDistance (function)", - "metaUrlSpellingRefusal": "src/shared/metadata-url-spelling.ts#metaUrlSpellingRefusal (function)", + "metaUrlSpellingRefusal": "src/meta-spelling/metadata-url-spelling.ts#metaUrlSpellingRefusal (function)", "normalizeMetadataCollection": "src/shared/metadata-collection.zod.ts#normalizeMetadataCollection (function)", "normalizePluginMetadata": "src/shared/metadata-collection.zod.ts#normalizePluginMetadata (function)", "normalizeStackInput": "src/shared/metadata-collection.zod.ts#normalizeStackInput (function)", @@ -122,6 +122,6 @@ "strictUnknownKeyError": "src/shared/suggestions.zod.ts#strictUnknownKeyError (function)", "suggestFieldType": "src/shared/suggestions.zod.ts#suggestFieldType (function)", "tmpl": "src/shared/expression.zod.ts#tmpl (function)", - "unrecognisedMetaTypeRefusal": "src/shared/metadata-url-spelling.ts#unrecognisedMetaTypeRefusal (function)" + "unrecognisedMetaTypeRefusal": "src/meta-spelling/metadata-url-spelling.ts#unrecognisedMetaTypeRefusal (function)" } } diff --git a/packages/spec/package.json b/packages/spec/package.json index 5624106d3d..8f9ea6d9fa 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -166,6 +166,16 @@ "default": "./dist/shared/index.js" } }, + "./meta-spelling": { + "import": { + "types": "./dist/meta-spelling/index.d.mts", + "default": "./dist/meta-spelling/index.mjs" + }, + "require": { + "types": "./dist/meta-spelling/index.d.ts", + "default": "./dist/meta-spelling/index.js" + } + }, "./openapi.json": "./json-schema/openapi.json", "./package.json": "./package.json" }, @@ -188,6 +198,8 @@ "gen:schema": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts", "gen:authorable-surface-base": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts --update-base", "gen:openapi": "tsx scripts/build-openapi.ts", + "gen:meta-url-spelling": "tsx scripts/build-meta-url-spelling.ts", + "check:meta-url-spelling": "tsx scripts/build-meta-url-spelling.ts --check", "gen:docs": "tsx scripts/build-docs.ts", "check:docs": "tsx scripts/build-docs.ts --check", "check:generated": "tsx scripts/check-generated.ts", diff --git a/packages/spec/scripts/build-meta-url-spelling.ts b/packages/spec/scripts/build-meta-url-spelling.ts new file mode 100644 index 0000000000..005b5e61e7 --- /dev/null +++ b/packages/spec/scripts/build-meta-url-spelling.ts @@ -0,0 +1,160 @@ +#!/usr/bin/env tsx +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Generate (and gate) `src/meta-spelling/meta-url-data.generated.ts` — the + * schema-free materialization of the `/meta` URL-spelling contract (#10096). + * + * ## Why the map is generated instead of derived at module load + * + * The map's two sources are heavy on purpose-unrelated weight: + * `DEFAULT_METADATA_TYPE_REGISTRY` lives in the kernel zod graph, and + * `PLURAL_TO_SINGULAR` sits in a module that reaches the ADR-0087 conversion + * layer. Deriving at module load therefore made ONE string fold cost a + * browser consumer +60.1 KB gzipped (#10096's measurement). The 2026-08-20 + * maintainer ruling minted the standing principle 「浏览器可达的 spec 导出面必 + * 须 schema-free」 and approved moving the derivation to build time: this + * script derives the union HERE, writes it as a pure data module, and the + * `/meta-spelling` entry ships vocabulary with no schema closure. + * + * ## This check is the enforcement home of `assertMetaUrlSpellingsAgree()` + * + * The module-load assertion that pinned the derived limb against the manifest + * limb moved here, per the same ruling (⛔ dropping it to save bytes was + * explicitly forbidden — the agreement must keep an enforcement home). Both + * modes of this script run it, so a disagreement fails generation AND fails + * CI's `check:meta-url-spelling` — loudly, before any artifact is written. + * + * The pluralization/camelCase rules are IMPORTED from the runtime module + * (`restPluralOfMetaType` / `camelCaseOf`) rather than copied, so the checked + * refusal `hint`s and the checked-in map can never drift from the one rule. + * + * Usage: + * pnpm --filter @objectstack/spec gen:meta-url-spelling # rewrite the artifact + * pnpm --filter @objectstack/spec check:meta-url-spelling # verify it is current + * Exit: 0 = OK; 1 = spelling disagreement, or (--check) stale artifact. + */ + +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { DEFAULT_METADATA_TYPE_REGISTRY } from '../src/kernel/metadata-plugin.zod'; +import { PLURAL_TO_SINGULAR } from '../src/shared/metadata-collection.zod'; +import { restPluralOfMetaType, camelCaseOf } from '../src/meta-spelling/metadata-url-spelling'; + +const OUT = join( + dirname(fileURLToPath(import.meta.url)), + '..', + 'src', + 'meta-spelling', + 'meta-url-data.generated.ts', +); + +/** + * The three-limb union, exactly as `shared/metadata-url-spelling.ts` built it + * at module load before #10096 (limb 1 manifest spellings verbatim; limb 2 + * registry-derived REST plurals; limb 3 camelCase spellings for snake_case + * registry types). Order is load-bearing only for output stability. + */ +function buildMetaUrlMap(): Record { + const out: Record = {}; + for (const [plural, singular] of Object.entries(PLURAL_TO_SINGULAR)) out[plural] = singular; + for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { + out[restPluralOfMetaType(entry.type)] = entry.type; + const camel = camelCaseOf(entry.type); + if (camel !== entry.type) out[restPluralOfMetaType(camel)] = entry.type; + } + return out; +} + +/** + * Fail the build rather than serve two answers for one spelling. A + * disagreement here means the derived limb and the manifest limb have + * drifted, which is the same class of silent divergence #7894 is about. + * (Formerly asserted at module load in `shared/metadata-url-spelling.ts`; + * moved to this build-time home by the 2026-08-20 ruling on #10096.) + */ +function assertMetaUrlSpellingsAgree(map: Record): void { + for (const [plural, singular] of Object.entries(PLURAL_TO_SINGULAR)) { + const derived = map[plural]; + if (derived !== singular) { + console.error( + `✗ [metadata-url-spelling] '${plural}' resolves to '${derived}' in the URL map but ` + + `'${singular}' in PLURAL_TO_SINGULAR. One spelling may not name two types.`, + ); + process.exit(1); + } + } +} + +function render(map: Record, declared: string[]): string { + const mapLines = Object.entries(map) + .map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)},`) + .join('\n'); + const declaredLines = declared.map((t) => ` ${JSON.stringify(t)},`).join('\n'); + return `// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * AUTO-GENERATED by \`pnpm --filter @objectstack/spec gen:meta-url-spelling\` + * (scripts/build-meta-url-spelling.ts) — ❌ never edit by hand. + * + * Schema-free materialization of the \`/meta\` URL-spelling contract (#10096): + * the three-limb union of \`PLURAL_TO_SINGULAR\` and the REST plurals derived + * from \`DEFAULT_METADATA_TYPE_REGISTRY\`, frozen at build time so the + * \`@objectstack/spec/meta-spelling\` entry links no zod machinery. + * \`check:meta-url-spelling\` re-derives this union from the live sources on + * every CI lap — including the manifest/derived agreement assertion that used + * to run at module load — so this file cannot silently lag or drift. + * + * @module + */ + +/** Plural (and camelCase) URL spelling → canonical singular metadata type. */ +export const META_URL_TO_SINGULAR: Readonly> = Object.freeze({ +${mapLines} +}); + +/** + * Every metadata type with a STATIC \`DEFAULT_METADATA_TYPE_REGISTRY\` entry, + * in registry order. Read only by the refusal predicates one module over. + */ +export const REGISTRY_DECLARED_META_TYPES: ReadonlyArray = Object.freeze([ +${declaredLines} +]); +`; +} + +const map = buildMetaUrlMap(); +assertMetaUrlSpellingsAgree(map); +const declared = DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type); +const next = render(map, declared); + +const check = process.argv.includes('--check'); +const current = existsSync(OUT) ? readFileSync(OUT, 'utf8') : ''; + +if (check) { + if (current === next) { + console.log( + `✓ meta-url-spelling data is current (${Object.keys(map).length} spellings, ` + + `${declared.length} registry-declared types; manifest/derived agreement holds).`, + ); + process.exit(0); + } + console.error( + `✗ src/meta-spelling/meta-url-data.generated.ts is stale relative to its sources\n` + + ` (PLURAL_TO_SINGULAR and/or DEFAULT_METADATA_TYPE_REGISTRY changed).\n` + + ` Regenerate with: pnpm --filter @objectstack/spec gen:meta-url-spelling`, + ); + process.exit(1); +} + +if (current === next) { + console.log('✓ meta-url-spelling data already current — nothing written.'); +} else { + writeFileSync(OUT, next); + console.log( + `✓ wrote src/meta-spelling/meta-url-data.generated.ts ` + + `(${Object.keys(map).length} spellings, ${declared.length} registry-declared types).`, + ); +} diff --git a/packages/spec/scripts/check-generated.ts b/packages/spec/scripts/check-generated.ts index d83a1783ec..61027d61c3 100644 --- a/packages/spec/scripts/check-generated.ts +++ b/packages/spec/scripts/check-generated.ts @@ -88,6 +88,16 @@ const GATED: ReadonlyArray<{ }, { check: 'check:spec-changes', gen: 'gen:spec-changes', artifact: 'spec-changes.json' }, { check: 'check:upgrade-guide', gen: 'gen:upgrade-guide', artifact: 'docs/protocol-upgrade-guide.md' }, + // [#10096] The schema-free `/meta` URL-spelling data module. Cheap: tsx-loads + // the two source maps (lazySchema keeps the kernel module light), re-derives + // the three-limb union, and runs the manifest/derived agreement assertion + // that used to live at `shared/metadata-url-spelling.ts` module load — this + // gate is that assertion's build-time enforcement home (ruling 2026-08-20). + { + check: 'check:meta-url-spelling', + gen: 'gen:meta-url-spelling', + artifact: 'src/meta-spelling/meta-url-data.generated.ts', + }, { check: 'check:skill-docs', gen: 'gen:skill-docs', artifact: 'skill docs (from SKILL.md frontmatter)' }, { check: 'check:skill-refs', gen: 'gen:skill-refs', artifact: 'skill references' }, { check: 'check:react-blocks', gen: 'gen:react-blocks', artifact: 'react-blocks contract' }, diff --git a/packages/spec/scripts/export-origins.test.ts b/packages/spec/scripts/export-origins.test.ts index be087298d6..b340582336 100644 --- a/packages/spec/scripts/export-origins.test.ts +++ b/packages/spec/scripts/export-origins.test.ts @@ -56,6 +56,7 @@ const ENTRY_NAMESPACES: ReadonlyArray<[string, () => Promise]> = [ ['./identity', () => import('../src/identity/index')], ['./integration', () => import('../src/integration/index')], ['./kernel', () => import('../src/kernel/index')], + ['./meta-spelling', () => import('../src/meta-spelling/index')], ['./qa', () => import('../src/qa/index')], ['./security', () => import('../src/security/index')], ['./shared', () => import('../src/shared/index')], diff --git a/packages/spec/scripts/lib/category-title.ts b/packages/spec/scripts/lib/category-title.ts index 512cdb95e4..27f804e294 100644 --- a/packages/spec/scripts/lib/category-title.ts +++ b/packages/spec/scripts/lib/category-title.ts @@ -78,6 +78,10 @@ export const CATEGORY_TITLES: Readonly> = { identity: 'Identity Protocol', integration: 'Integration Protocol', kernel: 'Kernel Protocol', + // [#10096] The schema-free `/meta` URL-spelling entry. "Vocabulary", not + // "Protocol": the entry carries the spelling contract's data and folds + // without the schema machinery every Protocol category links. + 'meta-spelling': 'Meta-Spelling Vocabulary', migrations: 'Migrations Protocol', qa: 'QA Protocol', security: 'Security Protocol', diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index 2329a483e6..fdec3e9fc5 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -33,10 +33,28 @@ * ```typescript * import { Field, FieldType } from '@objectstack/spec/data'; * import { User, Session } from '@objectstack/spec/auth'; - * + * * const field: Field = { name: 'task_name', type: 'text' }; * const user: User = { id: 'u1', email: 'user@example.com' }; * ``` + * + * ## Standing principle for export surfaces (#10096, maintainer ruling 2026-08-20) + * + * > **浏览器可达的 spec 导出面必须 schema-free。** A `@objectstack/spec` export + * > surface that browser/client consumers reach must carry vocabulary — maps, + * > folds, enums, pure predicates — without linking the zod schema/validation + * > machinery. The schema graph is the server/publish side's dependency, never + * > the price of spelling a URL segment or reading a posture predicate. + * + * Every subpath entry is a self-contained bundle, so a vocabulary symbol that + * shares an entry with schema modules costs its consumers the whole schema + * closure (measured on #10096: one string fold through `/shared` cost + * +60.1 KB gzipped). When adding an export a browser consumer will reach, + * either put it on a schema-free entry (`/meta-spelling` is the reference: + * heavy derivation happens at BUILD time via a generator + check gate, the + * entry ships pure data and functions) or verify the entry's module graph + * stays schema-free. Mechanizing this principle as a gate is a welcome + * follow-up; until then it binds as a stated rule. */ // ============================================================================ diff --git a/packages/spec/src/meta-spelling/index.ts b/packages/spec/src/meta-spelling/index.ts new file mode 100644 index 0000000000..6868dd9503 --- /dev/null +++ b/packages/spec/src/meta-spelling/index.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `@objectstack/spec/meta-spelling` — the `/meta/:type` URL-spelling contract, + * schema-free (#10096, maintainer ruling 2026-08-20). + * + * This entry exists so a browser/client consumer who needs to spell (or fold, + * or refuse) a `/meta/:type` path segment pays a few hundred bytes instead of + * linking the zod schema graph — the measured cost of reaching the same four + * symbols through `@objectstack/spec/shared` was +60.1 KB gzipped on a graph + * that already carried `/ui` and `/kernel`. + * + * The standing principle this entry implements (recorded verbatim, + * untranslated): 「浏览器可达的 spec 导出面必须 schema-free」 — a + * browser-reachable export surface carries vocabulary (maps, folds, enums, + * pure predicates) without linking the zod schema/validation machinery. + * + * The published surface is the same four symbols `/shared` carries (#8424 — + * `/shared` keeps them; this entry is additive, one declaration re-exported): + * the map, the fold, and the two refusal verdicts. The map is derived at BUILD + * time from `PLURAL_TO_SINGULAR` and `DEFAULT_METADATA_TYPE_REGISTRY` + * (`gen:meta-url-spelling`), and `check:meta-url-spelling` enforces both its + * freshness and the manifest/derived spelling agreement on every CI lap. + * + * @module + */ + +export { + META_URL_TO_SINGULAR, + canonicalMetaUrlType, + metaUrlSpellingRefusal, + unrecognisedMetaTypeRefusal, +} from './metadata-url-spelling'; diff --git a/packages/spec/src/meta-spelling/meta-url-data.generated.ts b/packages/spec/src/meta-spelling/meta-url-data.generated.ts new file mode 100644 index 0000000000..c237b1a091 --- /dev/null +++ b/packages/spec/src/meta-spelling/meta-url-data.generated.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * AUTO-GENERATED by `pnpm --filter @objectstack/spec gen:meta-url-spelling` + * (scripts/build-meta-url-spelling.ts) — ❌ never edit by hand. + * + * Schema-free materialization of the `/meta` URL-spelling contract (#10096): + * the three-limb union of `PLURAL_TO_SINGULAR` and the REST plurals derived + * from `DEFAULT_METADATA_TYPE_REGISTRY`, frozen at build time so the + * `@objectstack/spec/meta-spelling` entry links no zod machinery. + * `check:meta-url-spelling` re-derives this union from the live sources on + * every CI lap — including the manifest/derived agreement assertion that used + * to run at module load — so this file cannot silently lag or drift. + * + * @module + */ + +/** Plural (and camelCase) URL spelling → canonical singular metadata type. */ +export const META_URL_TO_SINGULAR: Readonly> = Object.freeze({ + "objects": "object", + "apps": "app", + "pages": "page", + "dashboards": "dashboard", + "reports": "report", + "datasets": "dataset", + "actions": "action", + "themes": "theme", + "flows": "flow", + "jobs": "job", + "positions": "position", + "permissions": "permission", + "capabilities": "capability", + "sharingRules": "sharing_rule", + "apis": "api", + "webhooks": "webhook", + "agents": "agent", + "tools": "tool", + "skills": "skill", + "ragPipelines": "rag_pipeline", + "hooks": "hook", + "mappings": "mapping", + "analyticsCubes": "analytics_cube", + "connectors": "connector", + "datasources": "datasource", + "views": "view", + "emailTemplates": "email_template", + "docs": "doc", + "books": "book", + "fields": "field", + "seeds": "seed", + "external_catalogs": "external_catalog", + "externalCatalogs": "external_catalog", + "translations": "translation", + "email_templates": "email_template", +}); + +/** + * Every metadata type with a STATIC `DEFAULT_METADATA_TYPE_REGISTRY` entry, + * in registry order. Read only by the refusal predicates one module over. + */ +export const REGISTRY_DECLARED_META_TYPES: ReadonlyArray = Object.freeze([ + "object", + "field", + "hook", + "seed", + "mapping", + "view", + "page", + "dashboard", + "app", + "action", + "report", + "dataset", + "flow", + "job", + "datasource", + "external_catalog", + "api", + "translation", + "email_template", + "doc", + "book", + "permission", + "position", + "capability", + "agent", + "tool", + "skill", +]); diff --git a/packages/spec/src/meta-spelling/metadata-url-spelling.ts b/packages/spec/src/meta-spelling/metadata-url-spelling.ts new file mode 100644 index 0000000000..f06ce9a697 --- /dev/null +++ b/packages/spec/src/meta-spelling/metadata-url-spelling.ts @@ -0,0 +1,318 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * URL SPELLING of a metadata type — the `/meta/:type` half of #4432's canonical + * type key, split out of `PLURAL_TO_SINGULAR` (#7894). + * + * ## Why this module is SCHEMA-FREE (#10096, maintainer ruling 2026-08-20) + * + * This is the implementation behind the `@objectstack/spec/meta-spelling` + * entry, which exists so a browser/client consumer who needs to spell a + * `/meta/:type` URL segment does not link the zod schema graph to do it. The + * standing principle (recorded verbatim, untranslated): 「浏览器可达的 spec 导 + * 出面必须 schema-free」 — a browser-reachable export surface carries + * vocabulary (maps, folds, enums, pure predicates) without linking the zod + * schema/validation machinery. + * + * Consequently this module imports NOTHING from the schema graph. The two + * inputs the map used to be derived from at module load — + * `DEFAULT_METADATA_TYPE_REGISTRY` (kernel, zod) and `PLURAL_TO_SINGULAR` + * (shared, reaches the conversion layer) — are consumed at BUILD time by + * `scripts/build-meta-url-spelling.ts`, which materializes their union into + * `./meta-url-data.generated.ts`. The map is still derived, never hand-written + * (Prime Directive #8); the derivation just happens in the generator instead + * of at every consumer's module load. + * + * ## Why this is a separate map and not four more keys in `PLURAL_TO_SINGULAR` + * + * `PLURAL_TO_SINGULAR` is a MANIFEST-COLLECTION map: its keys are the + * properties an author writes in `defineStack()` (`objects: [...]`, + * `apps: [...]`), and `kernel/metadata-authoring-lint.ts` iterates it to decide + * WHICH COLLECTIONS EXIST at stack level — every key becomes a collection the + * lint walks and a "did you mean" hint it can emit. A URL spelling map is a + * different contract that merely overlaps: its keys are path segments a client + * may send to `/meta/:type`. The two agree for `objects`/`apps`/`views`, and + * that coincidence is exactly what hid the bug. + * + * Four registry types — `field`, `seed`, `external_catalog`, `translation` — + * had no entry in the manifest map, because none of them is a stack-level + * collection (fields live inside `ObjectSchema.fields`, seeds inside `data`). + * At the `/meta` boundary that absence did not read as "not a collection", it + * read as "unknown type", and an unknown type is treated as PLUGIN-REGISTERED, + * which every authorization gate is permissive toward by construction. So + * `PUT /meta/fields/showcase_task.title` answered 200 and minted a second + * namespace under `type='fields'` while `PUT /meta/field/...` answered + * 403 NOT_OVERRIDABLE — the plural URL was a door around the singular URL's + * lock. + * + * ⛔ The fix is NOT to add `fields:` to the manifest map. That would advertise a + * top-level `fields: [...]` stack collection which does not exist, and which + * collides conceptually with `ObjectSchema.fields`. + * + * ## How the map is built (Prime Directive #8 — derived, never hand-written) + * + * Three limbs, unioned, in this order (see the generator for the live code): + * + * 1. **Manifest spellings** — every key of `PLURAL_TO_SINGULAR`, verbatim. + * These are spellings that already worked at the URL boundary, including + * the camelCase ones and the six that name PLUGIN-registered kinds with no + * static registry entry at all (`themes`, `webhooks`, `connectors`, …). + * Keeping this limb whole is what makes the derivation non-breaking: no + * spelling that resolved before resolves differently now. + * 2. **Registry-derived spellings** — {@link restPluralOfMetaType} applied to + * every `DEFAULT_METADATA_TYPE_REGISTRY` entry. This is the limb that makes + * the defect non-recurring: a newly DECLARED type arrives with its URL + * spelling already mapped, so it can never again fall through to the + * plugin-type path. + * 3. **camelCase spellings for snake_case registry types** — `external_catalog` + * is addressable as `externalCatalogs` as well as `external_catalogs`, + * matching how the manifest map already spells every other multi-word type. + * + * Limb 2 cannot silently disagree with limb 1, and the checked-in data cannot + * silently lag its sources: `check:meta-url-spelling` re-derives the union from + * the live registry and manifest map on every CI lap and fails on any + * disagreement or staleness. That check is the BUILD-TIME home of what used to + * be a module-load `assertMetaUrlSpellingsAgree()` call here — moved by the + * 2026-08-20 ruling on #10096, which also forbade dropping the assertion: + * the agreement must keep an enforcement home, and this is it. + * + * ## What this map deliberately does NOT do + * + * It does not make the boundary tolerant. Folding happens at the boundary and + * only there (#4432, Prime Directive #12: one contract, not N dialects); the + * layers below keep reading the single canonical singular. Nothing here should + * ever be consulted by a predicate one layer down. + * + * ## The published surface is four symbols (#8424, extended by #8421) + * + * {@link META_URL_TO_SINGULAR} (the spelling contract) · + * {@link canonicalMetaUrlType} (the fold) · {@link metaUrlSpellingRefusal} + * (the misspelling verdict) · {@link unrecognisedMetaTypeRefusal} (the + * not-a-type-at-all verdict). The helpers behind them are module-internal to + * the PUBLISHED surface; see {@link metaUrlSpellingRefusal}'s doc for why the + * verdicts are exported and the parts are not. (`restPluralOfMetaType` and + * `camelCaseOf` carry module-level exports so the GENERATOR and the build-time + * check reuse the one rule instead of growing a drifting mirror — neither the + * `/meta-spelling` nor the `/shared` entry re-exports them.) The two verdicts + * answer different questions and are deliberately not merged: one says *you + * spelled a type we declare wrongly*, the other says *we have no such type*, + * and only the first can name a replacement spelling. + * + * @module + */ + +import { META_URL_TO_SINGULAR, REGISTRY_DECLARED_META_TYPES } from './meta-url-data.generated'; + +export { META_URL_TO_SINGULAR }; + +/** + * The ONE pluralization rule for a metadata type's REST path segment. + * + * Deliberately small and total: metadata type names are snake_case ASCII + * (Prime Directive #3), so the only irregularity that occurs in practice is a + * consonant + `y` (`capability` → `capabilities`). The `(s|x|z|ch|sh)` limb is + * carried for correctness of future types rather than for any type declared + * today. Anything more clever would be a spelling GUESSER, which is precisely + * what the boundary must not contain. + * + * Not on the published surface (#8424): exported at module level ONLY so + * `scripts/build-meta-url-spelling.ts` derives the checked-in map with this + * exact rule rather than a drifting copy. The `/meta-spelling` and `/shared` + * entries publish the VERDICTS, never the predicate parts. + */ +export function restPluralOfMetaType(type: string): string { + if (/[^aeiou]y$/.test(type)) return `${type.slice(0, -1)}ies`; + if (/(s|x|z|ch|sh)$/.test(type)) return `${type}es`; + return `${type}s`; +} + +/** + * `external_catalog` → `externalCatalog`. Identity for a type with no + * underscore. Module-level export for the generator only, like + * {@link restPluralOfMetaType} — not on any published entry. + */ +export function camelCaseOf(type: string): string { + return type.replace(/_([a-z])/g, (_m, c: string) => c.toUpperCase()); +} + +/** + * Every metadata type with a STATIC entry in `DEFAULT_METADATA_TYPE_REGISTRY`, + * as materialized into `meta-url-data.generated.ts` at build time. + * + * "Declared" is the load-bearing word: a type in this set is one the platform + * itself ships a contract for, so an unresolvable spelling of it is a caller + * error rather than a plugin the platform has not heard of. That distinction is + * the whole basis of {@link unmappedDeclaredTypeSpelling}. + * + * Module-internal (#8424) — deliberately so: this set LOOKS like a live + * registry of registered types and is not one (it is the static declared set), + * which is exactly the misreading a public export would invite. + */ +const DECLARED_META_TYPES: ReadonlySet = new Set(REGISTRY_DECLARED_META_TYPES); + +/** + * Fold a `/meta/:type` path segment to its canonical singular. Returns the + * input unchanged when it is already canonical (or is a plugin-registered type, + * which has no plural spelling of its own). + */ +export function canonicalMetaUrlType(type: string): string { + return META_URL_TO_SINGULAR[type] ?? type; +} + +/** Candidate singulars for a spelling, by inverting {@link restPluralOfMetaType}. */ +function singularCandidates(type: string): string[] { + const out: string[] = []; + if (type.endsWith('ies')) out.push(`${type.slice(0, -3)}y`); + if (type.endsWith('es')) out.push(type.slice(0, -2)); + if (type.endsWith('s')) out.push(type.slice(0, -1)); + return out; +} + +/** + * The boundary refusal (#7894, maintainer ruling 2026-08-12: *if the platform + * cannot honour a declaration, refuse it at the latest checkpoint that can see + * the whole picture, name the offending key path, and never answer 200*). + * + * Returns the DECLARED type a spelling was evidently reaching for, or `null` + * when the spelling is none of the platform's business. + * + * ## Why this is a STATIC rule and not a live-registry lookup + * + * The tempting version asks "is this type registered right now?" and refuses + * everything else. That version is a hazard: it would refuse a genuinely + * plugin-registered runtime type whenever the registration had not happened + * yet, turning an authorization fix into a plugin-registration outage — a worse + * defect than the one being closed. This rule instead refuses ONLY a spelling + * whose singular is a type the platform itself declares. A plugin kind can + * therefore never be refused by it, no matter what it is named or when it + * registers — the positive control holds BY CONSTRUCTION rather than by test + * coverage. (The test exists anyway; construction and coverage are not + * substitutes.) + * + * Note what this means for a plugin kind whose singular happens to end in `s` + * (`address`, `status`): `singularCandidates` produces `addre`/`addres` and + * `statu`/`statue`, none of which is declared, so it is permitted. Good. + * + * ## The residue this used to leave is now closed next door (#8421) + * + * A spelling that is not a plural of anything — `/meta/fieldz` — is + * indistinguishable from a plugin kind BY THIS PREDICATE, and still is: it has + * no declared singular to reach for, so this function keeps returning `null` + * for it and the POSITIVE CONTROL above keeps holding by construction. What + * changed is that the boundary no longer treats "this predicate is silent" as + * "forward it to the plugin path" on a WRITE: {@link unrecognisedMetaTypeRefusal} + * answers the other question — *is this a metadata type at all?* — which became + * answerable statically only once #8586 retired `additionalTypes` and left the + * platform with no declared-kind channel to be ignorant of. + * + * Module-internal (#8424): consumers get the composed verdict from + * {@link metaUrlSpellingRefusal}, never this predicate on its own. + */ +function unmappedDeclaredTypeSpelling(type: string): string | null { + if (type in META_URL_TO_SINGULAR) return null; + if (DECLARED_META_TYPES.has(type)) return null; + for (const candidate of singularCandidates(type)) { + if (DECLARED_META_TYPES.has(candidate)) return candidate; + } + return null; +} + +/** + * The refusal VERDICT for a `/meta/:type` path segment (#7894 · #8424). + * + * Returns `null` when the spelling is not the platform's to refuse — it is + * canonical, a mapped plural, or a possible plugin kind. Returns the verdict + * when the spelling is an unrecognised plural of a type the platform itself + * DECLARES: `declared` is that type, `hint` its canonical REST-plural spelling, + * so the refusing boundary can name both accepted spellings without owning any + * spelling logic of its own. + * + * ## Why the surface exports the verdict and not the parts (#8424) + * + * The predicate ({@link unmappedDeclaredTypeSpelling}), the pluralizer + * ({@link restPluralOfMetaType}) and the declared set (`DECLARED_META_TYPES`) + * are internal to the published surface on purpose. Every `@objectstack/spec` + * export is a compatibility commitment, and the one measured need outside this + * module — `metadata-protocol`'s 400 refusal at the request boundary — is "is + * this spelling refusable, and what does the refusal say". Exporting the parts + * would invite a consumer to recompose them in the wrong order (ask the + * declared set a live-registry question, derive a plural the map disagrees + * with); exporting the verdict makes the correct use the only expressible one. + * The spelling contract stays whole, at its producer (Prime Directive #8: + * derived, never re-derived downstream). + */ +export function metaUrlSpellingRefusal( + urlType: string, +): { declared: string; hint: string } | null { + const declared = unmappedDeclaredTypeSpelling(urlType); + if (declared === null) return null; + return { declared, hint: restPluralOfMetaType(declared) }; +} + +/** + * Every CANONICAL metadata type the static contract knows — the values of + * {@link META_URL_TO_SINGULAR} rather than its keys. + * + * Strictly larger than `DECLARED_META_TYPES`, and that difference is the whole + * reason this set exists: limb 1 carries six kinds that NO registry derivation + * could produce — `theme`, `webhook`, `connector`, `sharing_rule`, + * `analytics_cube`, `rag_pipeline` — which are legal, addressable metadata + * kinds with no static registry entry. A refusal quantified over the registry + * alone would refuse all six, i.e. break `PUT /meta/theme/dark`, which is the + * exact operation the plugin path exists to serve. + * + * Module-internal (#8424), for the same reason `DECLARED_META_TYPES` is: it + * LOOKS like a live registry of registered types and is not one. + */ +const CANONICAL_META_TYPES: ReadonlySet = new Set(Object.values(META_URL_TO_SINGULAR)); + +/** + * The verdict for a `/meta/:type` segment that is not a metadata type AT ALL + * (#8421, maintainer ruling 2026-08-14 「同意」, joint with #8586). + * + * Returns `null` when the segment is part of the platform's static spelling + * contract — a canonical type, or any spelling that folds to one. Returns the + * verdict when it is neither, i.e. when honouring it would mint a namespace + * for a metadata type that does not exist: `PUT /meta/fieldz/x` answering 200 + * and persisting a `sys_metadata` row under `type='fieldz'`. + * + * ## Why this became answerable statically, having not been before + * + * The version of this module that shipped with #7894 called this residue + * explicitly unclosable: `fieldz` is indistinguishable from a plugin kind by + * static means, and a LIVE-registry lookup (the obvious alternative) was + * measured on #8421 to be worse than the defect — `listLiveMetadataTypes()` is + * an ITEM-POPULATION set, so it omits a legitimate kind that has zero items, + * which is precisely the state every kind is in immediately before its first + * runtime create. + * + * What changed is not the boundary's information but the platform's: #8586 + * retired `MetadataPluginConfig.additionalTypes` (ADR-0049), and with it the + * last channel by which a plugin could DECLARE a metadata kind. There is now + * no declaration this predicate could be ignorant of, which is what makes + * refusing an unrecognised name safe by construction rather than by luck. + * + * ## What it is deliberately NOT + * + * ⛔ Not a spelling guesser. It offers no "did you mean" — {@link + * metaUrlSpellingRefusal} is the verdict that can name a replacement, because + * it is the only one holding evidence of what the caller was reaching for. + * ⛔ Not a claim about the LIVE type set. A running kernel legitimately holds + * type keys this set does not — `data`, `kind` and `package` all enter + * `SchemaRegistry` during a perfectly ordinary `registerApp` — which is why + * the boundary applies this verdict where a namespace is MINTED and nowhere + * else. See `refuseUnmintableMetaType` in `@objectstack/metadata-protocol` for + * that scoping and the measurement behind it. + * ⛔ Not the whole answer at the boundary either, and deliberately not: this + * predicate reads ONE path segment, while whether that segment is even making + * a claim about a metadata type depends on the request's arity (the compound + * form `/meta/lead/views/all_leads` carries an OBJECT name there) and whether + * the namespace already exists. Both are the consumer's to know — a predicate + * that guessed at them from a bare string is exactly the spelling GUESSER this + * module refuses to contain. + */ +export function unrecognisedMetaTypeRefusal(urlType: string): { type: string } | null { + if (urlType in META_URL_TO_SINGULAR) return null; + if (CANONICAL_META_TYPES.has(urlType)) return null; + return { type: urlType }; +} diff --git a/packages/spec/src/shared/metadata-url-spelling.test.ts b/packages/spec/src/shared/metadata-url-spelling.test.ts index 455d67e94f..475d77e67e 100644 --- a/packages/spec/src/shared/metadata-url-spelling.test.ts +++ b/packages/spec/src/shared/metadata-url-spelling.test.ts @@ -125,8 +125,11 @@ describe('#7894 INVARIANT 2 — no unmapped spelling of a DECLARED type may answ }); it('never lets one spelling name two types', () => { - // The module asserts this at load; assert it here too so the failure is - // attributable to a test rather than to an import side effect. + // [#10096] The agreement's enforcement home is the build-time + // `check:meta-url-spelling` gate (it moved OFF the module-load path with + // the schema-free split — maintainer ruling 2026-08-20). Assert it here + // too so a disagreement also fails as an attributable test, quantified + // over the live manifest map rather than the generated data. for (const [plural, singular] of Object.entries(PLURAL_TO_SINGULAR)) { expect(META_URL_TO_SINGULAR[plural]).toBe(singular); } @@ -296,3 +299,27 @@ describe('#7894 — the manifest map keeps its own job', () => { } }); }); + +describe('#10096 — the fine-grained entry is the SAME contract, re-exported', () => { + it('`meta-spelling` and `/shared` hand out identical bindings (one declaration, two entries)', async () => { + // The schema-free entry is additive: `/shared` keeps the four symbols, and + // both must resolve to the one declaration in + // `src/meta-spelling/metadata-url-spelling.ts`. Reference identity is the + // check that distinguishes a re-export from a fork — a faithful copy would + // pass every value comparison. + const entry = await import('../meta-spelling'); + expect(entry.META_URL_TO_SINGULAR).toBe(META_URL_TO_SINGULAR); + expect(entry.canonicalMetaUrlType).toBe(canonicalMetaUrlType); + expect(entry.metaUrlSpellingRefusal).toBe(metaUrlSpellingRefusal); + expect(entry.unrecognisedMetaTypeRefusal).toBe(unrecognisedMetaTypeRefusal); + }); + + it('the generated data module carries every registry type, so it cannot silently lag', () => { + // Freshness belt-and-braces: `check:meta-url-spelling` diffs the artifact + // byte-for-byte; this quantifies the same fact through the public map so a + // stale artifact also fails as an attributable test. + for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { + expect(META_URL_TO_SINGULAR[expectedRestPlural(entry.type)], `${entry.type} must be mapped`).toBe(entry.type); + } + }); +}); diff --git a/packages/spec/src/shared/metadata-url-spelling.ts b/packages/spec/src/shared/metadata-url-spelling.ts index e8dc40ec19..1bf6420358 100644 --- a/packages/spec/src/shared/metadata-url-spelling.ts +++ b/packages/spec/src/shared/metadata-url-spelling.ts @@ -2,329 +2,29 @@ /** * URL SPELLING of a metadata type — the `/meta/:type` half of #4432's canonical - * type key, split out of {@link PLURAL_TO_SINGULAR} (#7894). - * - * ## Why this is a separate map and not four more keys in the other one - * - * `PLURAL_TO_SINGULAR` is a MANIFEST-COLLECTION map: its keys are the - * properties an author writes in `defineStack()` (`objects: [...]`, - * `apps: [...]`), and `kernel/metadata-authoring-lint.ts` iterates it to decide - * WHICH COLLECTIONS EXIST at stack level — every key becomes a collection the - * lint walks and a "did you mean" hint it can emit. A URL spelling map is a - * different contract that merely overlaps: its keys are path segments a client - * may send to `/meta/:type`. The two agree for `objects`/`apps`/`views`, and - * that coincidence is exactly what hid the bug. - * - * Four registry types — `field`, `seed`, `external_catalog`, `translation` — - * had no entry in the manifest map, because none of them is a stack-level - * collection (fields live inside `ObjectSchema.fields`, seeds inside `data`). - * At the `/meta` boundary that absence did not read as "not a collection", it - * read as "unknown type", and an unknown type is treated as PLUGIN-REGISTERED, - * which every authorization gate is permissive toward by construction: - * `isRuntimeCreateAllowed` synthesises `allowRuntimeCreate: true`, - * `orgScopedWriteRefusal` returns `null` for anything with no static registry - * entry, and `SysMetadataRepository.assertAllowed` returns early. So - * `PUT /meta/fields/showcase_task.title` answered 200 and minted a second - * namespace under `type='fields'` while `PUT /meta/field/...` answered - * 403 NOT_OVERRIDABLE — the plural URL was a door around the singular URL's - * lock. - * - * ⛔ The fix is NOT to add `fields:` to the manifest map. That would advertise a - * top-level `fields: [...]` stack collection which does not exist, and which - * collides conceptually with `ObjectSchema.fields`. - * - * ## How this map is built (Prime Directive #8 — derived, never hand-written) - * - * Three limbs, unioned, in this order: - * - * 1. **Manifest spellings** — every key of `PLURAL_TO_SINGULAR`, verbatim. - * These are spellings that already worked at the URL boundary, including - * the camelCase ones (`emailTemplates`, `sharingRules`, `analyticsCubes`, - * `ragPipelines`) and the six that name PLUGIN-registered kinds with no - * static registry entry at all (`themes`, `webhooks`, `connectors`, …). - * Keeping this limb whole is what makes the change non-breaking: no - * spelling that resolved before resolves differently now. - * 2. **Registry-derived spellings** — {@link restPluralOfMetaType} applied to - * every `DEFAULT_METADATA_TYPE_REGISTRY` entry. This is the limb that makes - * the defect non-recurring: a newly DECLARED type arrives with its URL - * spelling already mapped, so it can never again fall through to the - * plugin-type path. Hand-adding the four missing keys would have fixed only - * today's four. - * 3. **camelCase spellings for snake_case registry types** — `external_catalog` - * is addressable as `externalCatalogs` as well as `external_catalogs`, - * matching how the manifest map already spells every other multi-word type. - * - * Limb 2 cannot silently disagree with limb 1: `assertMetaUrlSpellingsAgree` - * is called at module load and throws if any spelling would resolve to two - * different singulars. - * - * ## What this map deliberately does NOT do - * - * It does not make the boundary tolerant. Folding happens at the boundary and - * only there (#4432, Prime Directive #12: one contract, not N dialects); the - * layers below keep reading the single canonical singular. Nothing here should - * ever be consulted by a predicate one layer down. - * - * ## The published surface is four symbols (#8424, extended by #8421) - * - * {@link META_URL_TO_SINGULAR} (the spelling contract) · - * {@link canonicalMetaUrlType} (the fold) · {@link metaUrlSpellingRefusal} - * (the misspelling verdict) · {@link unrecognisedMetaTypeRefusal} (the - * not-a-type-at-all verdict). The helpers behind them are module-internal; - * see {@link metaUrlSpellingRefusal}'s doc for why the verdicts are exported - * and the parts are not. The two verdicts answer different questions and are - * deliberately not merged: one says *you spelled a type we declare wrongly*, - * the other says *we have no such type*, and only the first can name a - * replacement spelling. + * type key (#7894 · #8424 · #8421). + * + * The implementation moved to `src/meta-spelling/metadata-url-spelling.ts` + * (#10096, maintainer ruling 2026-08-20): the map is now materialized at BUILD + * time (`gen:meta-url-spelling`) so the contract is schema-free — importable + * without the kernel registry's zod closure — and published fine-grained as + * `@objectstack/spec/meta-spelling`. This file keeps the same four symbols on + * `/shared` (one declaration, re-exported; existing consumers are unaffected). + * + * Two things a reader used to find HERE and should look for THERE: + * - the module doc explaining the three-limb derivation and why this map is + * not `PLURAL_TO_SINGULAR`; + * - `assertMetaUrlSpellingsAgree()`, which no longer runs at module load — its + * enforcement home is the build-time `check:meta-url-spelling` gate + * (`scripts/build-meta-url-spelling.ts`), per the same ruling (⛔ dropping + * the assertion was explicitly forbidden; moving it is what was approved). * * @module */ -import { DEFAULT_METADATA_TYPE_REGISTRY } from '../kernel/metadata-plugin.zod'; -import { PLURAL_TO_SINGULAR } from './metadata-collection.zod'; - -/** - * The ONE pluralization rule for a metadata type's REST path segment. - * - * Deliberately small and total: metadata type names are snake_case ASCII - * (Prime Directive #3), so the only irregularity that occurs in practice is a - * consonant + `y` (`capability` → `capabilities`). The `(s|x|z|ch|sh)` limb is - * carried for correctness of future types rather than for any type declared - * today. Anything more clever would be a spelling GUESSER, which is precisely - * what the boundary must not contain. - * - * Module-internal (#8424): the published surface carries the VERDICT - * ({@link metaUrlSpellingRefusal}), never the predicate parts. - */ -function restPluralOfMetaType(type: string): string { - if (/[^aeiou]y$/.test(type)) return `${type.slice(0, -1)}ies`; - if (/(s|x|z|ch|sh)$/.test(type)) return `${type}es`; - return `${type}s`; -} - -/** `external_catalog` → `externalCatalog`. Identity for a type with no underscore. */ -function camelCaseOf(type: string): string { - return type.replace(/_([a-z])/g, (_m, c: string) => c.toUpperCase()); -} - -/** - * Every metadata type with a STATIC entry in `DEFAULT_METADATA_TYPE_REGISTRY`. - * - * "Declared" is the load-bearing word: a type in this set is one the platform - * itself ships a contract for, so an unresolvable spelling of it is a caller - * error rather than a plugin the platform has not heard of. That distinction is - * the whole basis of {@link unmappedDeclaredTypeSpelling}. - * - * Module-internal (#8424) — deliberately so: this set LOOKS like a live - * registry of registered types and is not one (it is the static declared set), - * which is exactly the misreading a public export would invite. - */ -const DECLARED_META_TYPES: ReadonlySet = new Set( - DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type), -); - -function buildMetaUrlMap(): Record { - const out: Record = {}; - // Limb 1 — every manifest spelling, verbatim. - for (const [plural, singular] of Object.entries(PLURAL_TO_SINGULAR)) out[plural] = singular; - // Limbs 2 and 3 — derived from the registry. - for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { - out[restPluralOfMetaType(entry.type)] = entry.type; - const camel = camelCaseOf(entry.type); - if (camel !== entry.type) out[restPluralOfMetaType(camel)] = entry.type; - } - return out; -} - -/** - * Plural (and camelCase) URL spelling → canonical singular metadata type. - * - * Read ONLY by the `/meta` boundary fold. See the module doc for why this is - * not `PLURAL_TO_SINGULAR`. - */ -export const META_URL_TO_SINGULAR: Readonly> = Object.freeze(buildMetaUrlMap()); - -/** - * Fail the build (well, the module load) rather than serve two answers for one - * spelling. A disagreement here would mean the derived limb and the manifest - * limb had drifted, which is the same class of silent divergence #7894 is about. - */ -function assertMetaUrlSpellingsAgree(): void { - for (const [plural, singular] of Object.entries(PLURAL_TO_SINGULAR)) { - const derived = META_URL_TO_SINGULAR[plural]; - if (derived !== singular) { - throw new Error( - `[metadata-url-spelling] '${plural}' resolves to '${derived}' in the URL map but '${singular}' in ` - + `PLURAL_TO_SINGULAR. One spelling may not name two types.`, - ); - } - } -} -assertMetaUrlSpellingsAgree(); - -/** - * Fold a `/meta/:type` path segment to its canonical singular. Returns the - * input unchanged when it is already canonical (or is a plugin-registered type, - * which has no plural spelling of its own). - */ -export function canonicalMetaUrlType(type: string): string { - return META_URL_TO_SINGULAR[type] ?? type; -} - -/** Candidate singulars for a spelling, by inverting {@link restPluralOfMetaType}. */ -function singularCandidates(type: string): string[] { - const out: string[] = []; - if (type.endsWith('ies')) out.push(`${type.slice(0, -3)}y`); - if (type.endsWith('es')) out.push(type.slice(0, -2)); - if (type.endsWith('s')) out.push(type.slice(0, -1)); - return out; -} - -/** - * The boundary refusal (#7894, maintainer ruling 2026-08-12: *if the platform - * cannot honour a declaration, refuse it at the latest checkpoint that can see - * the whole picture, name the offending key path, and never answer 200*). - * - * Returns the DECLARED type a spelling was evidently reaching for, or `null` - * when the spelling is none of the platform's business. - * - * ## Why this is a STATIC rule and not a live-registry lookup - * - * The tempting version asks "is this type registered right now?" and refuses - * everything else. That version is a hazard: it would refuse a genuinely - * plugin-registered runtime type whenever the registration had not happened - * yet, turning an authorization fix into a plugin-registration outage — a worse - * defect than the one being closed. This rule instead refuses ONLY a spelling - * whose singular is a type the platform itself declares. A plugin kind can - * therefore never be refused by it, no matter what it is named or when it - * registers — the positive control holds BY CONSTRUCTION rather than by test - * coverage. (The test exists anyway; construction and coverage are not - * substitutes.) - * - * Note what this means for a plugin kind whose singular happens to end in `s` - * (`address`, `status`): `singularCandidates` produces `addre`/`addres` and - * `statu`/`statue`, none of which is declared, so it is permitted. Good. - * - * ## The residue this used to leave is now closed next door (#8421) - * - * A spelling that is not a plural of anything — `/meta/fieldz` — is - * indistinguishable from a plugin kind BY THIS PREDICATE, and still is: it has - * no declared singular to reach for, so this function keeps returning `null` - * for it and the POSITIVE CONTROL above keeps holding by construction. What - * changed is that the boundary no longer treats "this predicate is silent" as - * "forward it to the plugin path" on a WRITE: {@link unrecognisedMetaTypeRefusal} - * answers the other question — *is this a metadata type at all?* — which became - * answerable statically only once #8586 retired `additionalTypes` and left the - * platform with no declared-kind channel to be ignorant of. - * - * Module-internal (#8424): consumers get the composed verdict from - * {@link metaUrlSpellingRefusal}, never this predicate on its own. - */ -function unmappedDeclaredTypeSpelling(type: string): string | null { - if (type in META_URL_TO_SINGULAR) return null; - if (DECLARED_META_TYPES.has(type)) return null; - for (const candidate of singularCandidates(type)) { - if (DECLARED_META_TYPES.has(candidate)) return candidate; - } - return null; -} - -/** - * The refusal VERDICT for a `/meta/:type` path segment (#7894 · #8424). - * - * Returns `null` when the spelling is not the platform's to refuse — it is - * canonical, a mapped plural, or a possible plugin kind. Returns the verdict - * when the spelling is an unrecognised plural of a type the platform itself - * DECLARES: `declared` is that type, `hint` its canonical REST-plural spelling, - * so the refusing boundary can name both accepted spellings without owning any - * spelling logic of its own. - * - * ## Why the surface exports the verdict and not the parts (#8424) - * - * The predicate ({@link unmappedDeclaredTypeSpelling}), the pluralizer - * ({@link restPluralOfMetaType}) and the declared set (`DECLARED_META_TYPES`) - * are module-internal on purpose. Every `@objectstack/spec` export is a - * compatibility commitment, and the one measured need outside this module — - * `metadata-protocol`'s 400 refusal at the request boundary — is "is this - * spelling refusable, and what does the refusal say". Exporting the parts - * would invite a consumer to recompose them in the wrong order (ask the - * declared set a live-registry question, derive a plural the map disagrees - * with); exporting the verdict makes the correct use the only expressible one. - * The spelling contract stays whole, at its producer (Prime Directive #8: - * derived, never re-derived downstream). - */ -export function metaUrlSpellingRefusal( - urlType: string, -): { declared: string; hint: string } | null { - const declared = unmappedDeclaredTypeSpelling(urlType); - if (declared === null) return null; - return { declared, hint: restPluralOfMetaType(declared) }; -} - -/** - * Every CANONICAL metadata type the static contract knows — the values of - * {@link META_URL_TO_SINGULAR} rather than its keys. - * - * Strictly larger than `DECLARED_META_TYPES`, and that difference is the whole - * reason this set exists: limb 1 carries six kinds that NO registry derivation - * could produce — `theme`, `webhook`, `connector`, `sharing_rule`, - * `analytics_cube`, `rag_pipeline` — which are legal, addressable metadata - * kinds with no static registry entry. A refusal quantified over the registry - * alone would refuse all six, i.e. break `PUT /meta/theme/dark`, which is the - * exact operation the plugin path exists to serve. - * - * Module-internal (#8424), for the same reason `DECLARED_META_TYPES` is: it - * LOOKS like a live registry of registered types and is not one. - */ -const CANONICAL_META_TYPES: ReadonlySet = new Set(Object.values(META_URL_TO_SINGULAR)); - -/** - * The verdict for a `/meta/:type` segment that is not a metadata type AT ALL - * (#8421, maintainer ruling 2026-08-14 「同意」, joint with #8586). - * - * Returns `null` when the segment is part of the platform's static spelling - * contract — a canonical type, or any spelling that folds to one. Returns the - * verdict when it is neither, i.e. when honouring it would mint a namespace - * for a metadata type that does not exist: `PUT /meta/fieldz/x` answering 200 - * and persisting a `sys_metadata` row under `type='fieldz'`. - * - * ## Why this became answerable statically, having not been before - * - * The version of this module that shipped with #7894 called this residue - * explicitly unclosable: `fieldz` is indistinguishable from a plugin kind by - * static means, and a LIVE-registry lookup (the obvious alternative) was - * measured on #8421 to be worse than the defect — `listLiveMetadataTypes()` is - * an ITEM-POPULATION set, so it omits a legitimate kind that has zero items, - * which is precisely the state every kind is in immediately before its first - * runtime create. - * - * What changed is not the boundary's information but the platform's: #8586 - * retired `MetadataPluginConfig.additionalTypes` (ADR-0049), and with it the - * last channel by which a plugin could DECLARE a metadata kind. There is now - * no declaration this predicate could be ignorant of, which is what makes - * refusing an unrecognised name safe by construction rather than by luck. - * - * ## What it is deliberately NOT - * - * ⛔ Not a spelling guesser. It offers no "did you mean" — {@link - * metaUrlSpellingRefusal} is the verdict that can name a replacement, because - * it is the only one holding evidence of what the caller was reaching for. - * ⛔ Not a claim about the LIVE type set. A running kernel legitimately holds - * type keys this set does not — `data`, `kind` and `package` all enter - * `SchemaRegistry` during a perfectly ordinary `registerApp` — which is why - * the boundary applies this verdict where a namespace is MINTED and nowhere - * else. See `refuseUnmintableMetaType` in `@objectstack/metadata-protocol` for - * that scoping and the measurement behind it. - * ⛔ Not the whole answer at the boundary either, and deliberately not: this - * predicate reads ONE path segment, while whether that segment is even making - * a claim about a metadata type depends on the request's arity (the compound - * form `/meta/lead/views/all_leads` carries an OBJECT name there) and whether - * the namespace already exists. Both are the consumer's to know — a predicate - * that guessed at them from a bare string is exactly the spelling GUESSER this - * module refuses to contain. - */ -export function unrecognisedMetaTypeRefusal(urlType: string): { type: string } | null { - if (urlType in META_URL_TO_SINGULAR) return null; - if (CANONICAL_META_TYPES.has(urlType)) return null; - return { type: urlType }; -} +export { + META_URL_TO_SINGULAR, + canonicalMetaUrlType, + metaUrlSpellingRefusal, + unrecognisedMetaTypeRefusal, +} from '../meta-spelling/metadata-url-spelling'; diff --git a/packages/spec/tsup.config.ts b/packages/spec/tsup.config.ts index 4173792c90..2a442e9da5 100644 --- a/packages/spec/tsup.config.ts +++ b/packages/spec/tsup.config.ts @@ -18,7 +18,12 @@ const entries = [ 'src/cloud/index.ts', 'src/qa/index.ts', 'src/identity/index.ts', - 'src/shared/index.ts' + 'src/shared/index.ts', + // [#10096] Schema-free fine-grained entry for the `/meta` URL-spelling + // contract — per-entry self-contained bundling is unchanged (#8133 stays on + // hold); this entry's whole graph is two pure modules, so "self-contained" + // costs a few hundred bytes here by construction. + 'src/meta-spelling/index.ts' ]; // Generate DTS separately to avoid memory issues diff --git a/scripts/regen-artifacts.mjs b/scripts/regen-artifacts.mjs index 3d4653a097..7605f19b20 100644 --- a/scripts/regen-artifacts.mjs +++ b/scripts/regen-artifacts.mjs @@ -95,6 +95,17 @@ export const REGEN_ARTIFACTS = Object.freeze([ // With that step gone, the freshness is asserted instead: every path that would // run either script refuses unless the tree is newer than the sources. { path: 'content/docs/references/**', gen: 'gen:docs', check: 'check:docs', readsSchemaTree: true }, + // [#10096] The schema-free `/meta` URL-spelling data module — the checked-in + // materialization of PLURAL_TO_SINGULAR ∪ registry-derived REST plurals. A + // pure projection of its two sources (no hand-written half), so a conflict is + // always resolved by regenerating; low-churn (moves only when a metadata type + // is declared or the manifest map changes). No `readsDist`: the generator + // tsx-loads `src/`, so a merge that moved sources is all it needs. + { + path: 'packages/spec/src/meta-spelling/meta-url-data.generated.ts', + gen: 'gen:meta-url-spelling', + check: 'check:meta-url-spelling', + }, // #5107. Unlike its neighbours this one is derived from the AST *plus* a // hand-written column (the ledger's `Class` verdicts feed the per-class // subtotals), which is exactly why it belongs here rather than in the ledger: From a9be45a86f421a2a59c7d391217c60e64c80cf44 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 13:29:15 +0000 Subject: [PATCH 2/2] perf(spec): declare sideEffects: false and annotate schema construction pure in emitted bundles (#10031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module-scope evaluation purity measured, not assumed: per-entry fresh-process probe of every built entry (globalThis / process.env / console / listeners) — the only observation is zod core's own globalThis-keyed __zod_globalConfig / __zod_globalRegistry init (zod itself ships sideEffects:false); no spec module writes a registry or mutates a global at module scope, and the one former module-scope effect (the metadata-url-spelling agreement assertion) moved to the build-time gate in the previous commit. A tsup esbuild onLoad transform injects PURE annotations before lazySchema/strictObject/defineForm call sites at parse time (esbuild preserves input annotations but does not re-emit 'pure'-option marks — measured), so consumer bundlers can drop unreached schema consts; no source file is edited, keeping the two files held by sibling claims untouched. Measured: the root + /security one-predicate marginal falls from +261.5 KB min / +82.4 KB gz to +177.8 KB / +56.6 KB, identical in both graph directions, reproducing the card's delta shrinking. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016gcKVsiywU9CcS96S5t9qD --- .changeset/schema-free-meta-spelling.md | 22 +++++++++ packages/spec/package.json | 1 + packages/spec/tsup.config.ts | 66 +++++++++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 .changeset/schema-free-meta-spelling.md diff --git a/.changeset/schema-free-meta-spelling.md b/.changeset/schema-free-meta-spelling.md new file mode 100644 index 0000000000..742b44a503 --- /dev/null +++ b/.changeset/schema-free-meta-spelling.md @@ -0,0 +1,22 @@ +--- +"@objectstack/spec": minor +--- + +Schema-free `/meta` spelling entry, and the package becomes tree-shakeable (#10096, #10031). + +- New fine-grained export `@objectstack/spec/meta-spelling`: the `/meta/:type` + URL-spelling contract — `META_URL_TO_SINGULAR`, `canonicalMetaUrlType`, + `metaUrlSpellingRefusal`, `unrecognisedMetaTypeRefusal` — importable for a few + hundred bytes instead of the schema graph the same symbols cost through + `/shared` (measured +246.9 KB minified / +69.7 KB gzipped marginal on a graph + already carrying `/ui` + `/kernel`). `/shared` keeps all four symbols + (re-exported from the one declaration); nothing moves or breaks. +- The map is now materialized at build time (`gen:meta-url-spelling`, gated by + `check:meta-url-spelling`). The module-load `assertMetaUrlSpellingsAgree()` + moved into that gate — same assertion, build-time enforcement home. +- `package.json` declares `sideEffects: false` (module-scope evaluation purity + measured per entry), and emitted bundles carry `/* @__PURE__ */` on deferred + schema construction, so consumer bundlers can drop schemas an entry never + reaches instead of retaining a subpath's whole module graph. +- Standing principle recorded in the package docs: a browser-reachable spec + export surface must be schema-free (maintainer ruling 2026-08-20, #10096). diff --git a/packages/spec/package.json b/packages/spec/package.json index 8f9ea6d9fa..5f9cf4f854 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -5,6 +5,7 @@ "license": "Apache-2.0", "main": "dist/index.js", "types": "dist/index.d.ts", + "sideEffects": false, "exports": { ".": { "import": { diff --git a/packages/spec/tsup.config.ts b/packages/spec/tsup.config.ts index 2a442e9da5..39e7218e8e 100644 --- a/packages/spec/tsup.config.ts +++ b/packages/spec/tsup.config.ts @@ -1,6 +1,71 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { readFile } from 'node:fs/promises'; import { defineConfig } from 'tsup'; +import type { Plugin } from 'esbuild'; + +/** + * [#10031] Annotate deferred schema construction as pure IN THE EMITTED + * BUNDLES, so a CONSUMER's bundler may drop the schema consts its entry never + * reaches (`sideEffects: false` in package.json is the other half — it lets a + * wholly-unreached module go; this lets an unreached top-level const go). + * + * Why a load-time transform and not source annotations: esbuild PRESERVES + * PURE-annotation comments (the at-double-underscore-PURE marker) from its + * input into the output but does NOT re-emit them for calls marked via the + * `pure` option (measured on esbuild 0.28.2 — `pure` only feeds its own + * tree-shaking), so the annotation has to exist at parse time. Injecting it + * here keeps the ~600 call sites out of the source diff and applies uniformly + * — including to files a source sweep could not touch while sibling claims + * hold them. + * + * Why the marked calls are really pure: `lazySchema(fn)` allocates a Proxy and + * defers `fn` to first property access — dropping an unused one loses nothing + * observable (per-entry runtime probe on #10031: no spec module mutates + * globals/env/registries at module scope; the one former module-scope effect, + * the metadata-url-spelling agreement assertion, is now the build-time + * `check:meta-url-spelling` gate). + * + * The word-boundary regex cannot hit the import specifier (`lazySchema,` / + * `lazySchema }` carry no paren), the declaration (its token is + * `lazySchema(`), or strings/comments in any way that survives bundling + * (non-annotation comments are dropped from the bundle output). + */ +const pureSchemaConstruction: Plugin = { + name: 'pure-schema-construction', + setup(build) { + // The three marked constructors, each with its purity argument: + // - `lazySchema(fn)` allocates a Proxy, defers `fn` to first use; + // - `strictObject(shape, …)` builds a closed zod object (construction + // only — the unknown-key error closure runs at parse time, not now); + // - `defineForm(cfg)` parses STATIC author-time data through + // `FormViewSchema` — pure computation whose only observable effect is a + // throw on invalid static input, which this package's own build + // (`gen:schema` under OS_EAGER_SCHEMAS) and tests still exercise. + const PURE_CALL = /\b(lazySchema|strictObject|defineForm)\(/g; + build.onLoad({ filter: /src[\\/].*\.(ts|mts)$/ }, async (args) => { + const source = await readFile(args.path, 'utf8'); + PURE_CALL.lastIndex = 0; + if (!PURE_CALL.test(source)) return undefined; + // Line-based on purpose: a marked name mentioned inside a JSDoc block + // must NOT receive an annotation — a comment injected inside a comment + // terminates the outer one and breaks the parse (measured on + // shared/strict-object.ts:48). Comment lines start with `*`, `//` or + // `/*` after indentation; every real call site in the tree starts with + // code (surveyed: 1731 code lines vs 9 comment mentions), and no code + // line carries a marked token in a trailing comment. + const contents = source + .split('\n') + .map((line) => { + const lead = line.trimStart(); + if (lead.startsWith('*') || lead.startsWith('//') || lead.startsWith('/*')) return line; + return line.replace(PURE_CALL, '/* @__PURE__ */ $1('); + }) + .join('\n'); + return { contents, loader: 'ts' }; + }); + }, +}; const entries = [ 'src/index.ts', @@ -38,4 +103,5 @@ export default defineConfig({ format: ['esm', 'cjs'], target: 'es2020', treeshake: true, + esbuildPlugins: [pureSchemaConstruction], });