diff --git a/.changeset/18670-project-expressible-refinements.md b/.changeset/18670-project-expressible-refinements.md new file mode 100644 index 00000000000..975e2a05387 --- /dev/null +++ b/.changeset/18670-project-expressible-refinements.md @@ -0,0 +1,22 @@ +--- +"@objectstack/spec": minor +--- + +**BREAKING (published artifact narrows)** — `packages/spec/json-schema/**` now states two of the rules it used to leave entirely to the runtime, so a validator reading the published files stops answering PASS on metadata the platform then refuses (#18670 item 2). + +Clause-②: yes (narrowing) + +`z.toJSONSchema()` has no arm for a `custom` check: on zod 4.4.3 a plain record, the same record with a `.refine()`, and the same record with an **aborting** `.refine()` all project byte-identically. Every rule written as a refinement was therefore enforced by the runtime and absent from the published file — the direction in which an author's, or an AI's, validator says yes right up to the moment the platform says no. + +Two named patterns now project, and only those two: + +- **at least one of these keys is present** — emitted as `anyOf` of one `required` per key. `shared/Expression.json` states the source-or-ast rule, so `{ "dialect": "cel" }` is refused by the published file exactly as the runtime already refused it. +- **a string with at least one non-whitespace character** — emitted as `minLength: 1` plus the pattern `\S`. Every evaluated and typed expression slot states it, so a whitespace-only `source` is refused at the door. + +**⛔ Not a behaviour change, and no document the runtime accepts becomes refused.** Both patterns are EXACT rather than approximate: a key absent from a JSON object is the only way for its value to read `undefined`, and `String.prototype.trim` removes exactly the ECMA-262 whitespace set that `\S` is the complement of. Both equalities are pinned over their whole input space in `packages/spec/scripts/refinement-projection.test.ts`, including every ECMA-262 WhiteSpace and LineTerminator code point. No refinement was weakened, removed or added; the runtime accepts and refuses exactly what it did before. + +**The list is CLOSED.** `packages/spec/src/shared/refinement-projection.ts` declares the vocabulary and builds each predicate from its own declaration, so the rule the runtime enforces and the keywords the file publishes cannot name different things. A refinement outside that list stays unprojected and keeps its `x-dropped-refinements` annotation. Adding an arm is a public-contract decision with its own measurement, never a refactor — and ⛔ never an open-ended zod-to-JSON-Schema translator over the whole population. + +**Proof of work, in the shrink-only ledger.** `packages/spec/dropped-refinements.baseline.json` reads 201 published schemas / 553 dropped sites, from 246 / 750: 45 rows deleted, 75 rows shrunk, 197 sites closed, zero sites added anywhere. The generator now prints the closed population per pattern on every run (137 `required-one-of`, 60 `non-blank-string`), and reports a site that projects with no declared pattern on its own line. + + diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 916e598cb16..6622c962124 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -1,10 +1,10 @@ { - "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent. Narrowing the published shape to match the Zod type is a public contract change and is NOT what this ledger does.", + "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 246, - "droppedRefinementSites": 750, - "refinementSitesThatDidProject": 0, + "publishedSchemasWithDroppedRefinements": 201, + "droppedRefinementSites": 553, + "refinementSitesThatDidProject": 197, "refinementSitesWithNoJsonFormToCompare": 3 }, "entries": { @@ -23,30 +23,6 @@ "filter.lazy" ] }, - "ai/KnowledgeRefreshPolicy": { - "sites": [ - "cron.options[0].in", - "cron.options[1]" - ] - }, - "ai/KnowledgeSource": { - "sites": [ - "refresh.cron.options[0].in", - "refresh.cron.options[1]" - ] - }, - "ai/ModelRegistry": { - "sites": [ - "promptTemplates.valueType.user.options[0].in", - "promptTemplates.valueType.user.options[1]" - ] - }, - "ai/PromptTemplate": { - "sites": [ - "user.options[0].in", - "user.options[1]" - ] - }, "ai/Skill": { "sites": [ "triggerConditions.element" @@ -69,8 +45,7 @@ }, "api/AppDefinitionResponse": { "sites": [ - "data.navigation.element.lazy.options[0]", - "data.navigation.element.lazy.options[0].visible.options[1]" + "data.navigation.element.lazy.options[0]" ] }, "api/AssembledInstalledPackage": { @@ -84,13 +59,8 @@ "manifest.datasets.element.include.element", "manifest.datasources.element", "manifest.flows.element", - "manifest.flows.element.edges.element.condition.options[0].in", - "manifest.flows.element.edges.element.condition.options[1]", - "manifest.flows.element.edges.element.condition.options[1].source", "manifest.flows.element.errorHandling", "manifest.flows.element.nodes.element.in.waitEventConfig", - "manifest.jobs.element.schedule.options[0].expression.options[0].in", - "manifest.jobs.element.schedule.options[0].expression.options[1]", "manifest.navigationContributions.element.items.element.lazy.options[0]", "manifest.objectExtensions.element", "manifest.objects.element", @@ -98,8 +68,6 @@ "manifest.objects.element.fields.valueType", "manifest.objects.element.fields.valueType.currencyConfig", "manifest.objects.element.lifecycle", - "manifest.objects.element.titleFormat.options[0].in", - "manifest.objects.element.titleFormat.options[1]", "manifest.pages.element", "manifest.pages.element.slots.header.options[0].in.type", "manifest.permissions.element.objects.valueType.out", @@ -107,7 +75,6 @@ "manifest.reports.element", "manifest.reports.element.blocks.element", "manifest.reports.element.runtimeFilter.lazy", - "manifest.sharingRules.element.condition.options[1]", "manifest.sharingRules.element.sharedWith", "manifest.skills.element.triggerConditions.element", "manifest.views.element.form", @@ -125,9 +92,6 @@ "api/CreateFlowRequest": { "sites": [ "", - "edges.element.condition.options[0].in", - "edges.element.condition.options[1]", - "edges.element.condition.options[1].source", "errorHandling", "nodes.element.in.waitEventConfig" ] @@ -135,9 +99,6 @@ "api/CreateFlowResponse": { "sites": [ "data", - "data.edges.element.condition.options[0].in", - "data.edges.element.condition.options[1]", - "data.edges.element.condition.options[1].source", "data.errorHandling", "data.nodes.element.in.waitEventConfig" ] @@ -149,14 +110,12 @@ }, "api/DisablePackageResponse": { "sites": [ - "package.manifest.navigationContributions.element.items.element.lazy.options[0]", - "package.manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + "package.manifest.navigationContributions.element.items.element.lazy.options[0]" ] }, "api/EnablePackageResponse": { "sites": [ - "package.manifest.navigationContributions.element.items.element.lazy.options[0]", - "package.manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + "package.manifest.navigationContributions.element.items.element.lazy.options[0]" ] }, "api/ExportRequest": { @@ -175,9 +134,6 @@ "api/GetFlowResponse": { "sites": [ "data", - "data.edges.element.condition.options[0].in", - "data.edges.element.condition.options[1]", - "data.edges.element.condition.options[1].source", "data.errorHandling", "data.nodes.element.in.waitEventConfig" ] @@ -194,21 +150,14 @@ "data.options[1].manifest.datasets.element.include.element", "data.options[1].manifest.datasources.element", "data.options[1].manifest.flows.element", - "data.options[1].manifest.flows.element.edges.element.condition.options[0].in", - "data.options[1].manifest.flows.element.edges.element.condition.options[1]", - "data.options[1].manifest.flows.element.edges.element.condition.options[1].source", "data.options[1].manifest.flows.element.errorHandling", "data.options[1].manifest.flows.element.nodes.element.in.waitEventConfig", - "data.options[1].manifest.jobs.element.schedule.options[0].expression.options[0].in", - "data.options[1].manifest.jobs.element.schedule.options[0].expression.options[1]", "data.options[1].manifest.objectExtensions.element", "data.options[1].manifest.objects.element", "data.options[1].manifest.objects.element.fieldGroups", "data.options[1].manifest.objects.element.fields.valueType", "data.options[1].manifest.objects.element.fields.valueType.currencyConfig", "data.options[1].manifest.objects.element.lifecycle", - "data.options[1].manifest.objects.element.titleFormat.options[0].in", - "data.options[1].manifest.objects.element.titleFormat.options[1]", "data.options[1].manifest.pages.element", "data.options[1].manifest.pages.element.slots.header.options[0].in.type", "data.options[1].manifest.permissions.element.objects.valueType.out", @@ -216,7 +165,6 @@ "data.options[1].manifest.reports.element", "data.options[1].manifest.reports.element.blocks.element", "data.options[1].manifest.reports.element.runtimeFilter.lazy", - "data.options[1].manifest.sharingRules.element.condition.options[1]", "data.options[1].manifest.sharingRules.element.sharedWith", "data.options[1].manifest.skills.element.triggerConditions.element", "data.options[1].manifest.views.element.form", @@ -238,8 +186,7 @@ }, "api/GetPackageResponse": { "sites": [ - "package.manifest.navigationContributions.element.items.element.lazy.options[0]", - "package.manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + "package.manifest.navigationContributions.element.items.element.lazy.options[0]" ] }, "api/GetUiViewResponse": { @@ -249,7 +196,6 @@ "form.submitBehavior.options[1].url", "list", "list.bulkActionDefs.element", - "list.conditionalFormatting.element.condition.options[1]", "list.filter.element", "list.gantt.groupByField", "list.grouping.fields.element.field", @@ -264,14 +210,12 @@ }, "api/InstallPackageRequest": { "sites": [ - "manifest.navigationContributions.element.items.element.lazy.options[0]", - "manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + "manifest.navigationContributions.element.items.element.lazy.options[0]" ] }, "api/InstallPackageResponse": { "sites": [ - "package.manifest.navigationContributions.element.items.element.lazy.options[0]", - "package.manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + "package.manifest.navigationContributions.element.items.element.lazy.options[0]" ] }, "api/InstalledPackageAtEitherStage": { @@ -286,21 +230,14 @@ "options[1].manifest.datasets.element.include.element", "options[1].manifest.datasources.element", "options[1].manifest.flows.element", - "options[1].manifest.flows.element.edges.element.condition.options[0].in", - "options[1].manifest.flows.element.edges.element.condition.options[1]", - "options[1].manifest.flows.element.edges.element.condition.options[1].source", "options[1].manifest.flows.element.errorHandling", "options[1].manifest.flows.element.nodes.element.in.waitEventConfig", - "options[1].manifest.jobs.element.schedule.options[0].expression.options[0].in", - "options[1].manifest.jobs.element.schedule.options[0].expression.options[1]", "options[1].manifest.objectExtensions.element", "options[1].manifest.objects.element", "options[1].manifest.objects.element.fieldGroups", "options[1].manifest.objects.element.fields.valueType", "options[1].manifest.objects.element.fields.valueType.currencyConfig", "options[1].manifest.objects.element.lifecycle", - "options[1].manifest.objects.element.titleFormat.options[0].in", - "options[1].manifest.objects.element.titleFormat.options[1]", "options[1].manifest.pages.element", "options[1].manifest.pages.element.slots.header.options[0].in.type", "options[1].manifest.permissions.element.objects.valueType.out", @@ -308,7 +245,6 @@ "options[1].manifest.reports.element", "options[1].manifest.reports.element.blocks.element", "options[1].manifest.reports.element.runtimeFilter.lazy", - "options[1].manifest.sharingRules.element.condition.options[1]", "options[1].manifest.sharingRules.element.sharedWith", "options[1].manifest.skills.element.triggerConditions.element", "options[1].manifest.views.element.form", @@ -335,28 +271,20 @@ "data.packages.element.options[1].manifest.datasets.element.include.element", "data.packages.element.options[1].manifest.datasources.element", "data.packages.element.options[1].manifest.flows.element", - "data.packages.element.options[1].manifest.flows.element.edges.element.condition.options[0].in", - "data.packages.element.options[1].manifest.flows.element.edges.element.condition.options[1]", - "data.packages.element.options[1].manifest.flows.element.edges.element.condition.options[1].source", "data.packages.element.options[1].manifest.flows.element.errorHandling", "data.packages.element.options[1].manifest.flows.element.nodes.element.in.waitEventConfig", - "data.packages.element.options[1].manifest.jobs.element.schedule.options[0].expression.options[0].in", - "data.packages.element.options[1].manifest.jobs.element.schedule.options[0].expression.options[1]", "data.packages.element.options[1].manifest.objectExtensions.element", "data.packages.element.options[1].manifest.objects.element", "data.packages.element.options[1].manifest.objects.element.fieldGroups", "data.packages.element.options[1].manifest.objects.element.fields.valueType", "data.packages.element.options[1].manifest.objects.element.fields.valueType.currencyConfig", "data.packages.element.options[1].manifest.objects.element.lifecycle", - "data.packages.element.options[1].manifest.objects.element.titleFormat.options[0].in", - "data.packages.element.options[1].manifest.objects.element.titleFormat.options[1]", "data.packages.element.options[1].manifest.pages.element", "data.packages.element.options[1].manifest.permissions.element.objects.valueType.out", "data.packages.element.options[1].manifest.permissions.element.rowLevelSecurity.element", "data.packages.element.options[1].manifest.reports.element", "data.packages.element.options[1].manifest.reports.element.blocks.element", "data.packages.element.options[1].manifest.reports.element.runtimeFilter.lazy", - "data.packages.element.options[1].manifest.sharingRules.element.condition.options[1]", "data.packages.element.options[1].manifest.sharingRules.element.sharedWith", "data.packages.element.options[1].manifest.skills.element.triggerConditions.element", "data.packages.element.options[1].manifest.views.element.form", @@ -372,15 +300,13 @@ }, "api/ListPackagesResponse": { "sites": [ - "packages.element.manifest.navigationContributions.element.items.element.lazy.options[0]", - "packages.element.manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + "packages.element.manifest.navigationContributions.element.items.element.lazy.options[0]" ] }, "api/MetadataTypeInfoResponse": { "sites": [ "data.actions.element.in", - "data.actions.element.in.params.element.in", - "data.actions.element.in.visible.options[1].options[1]" + "data.actions.element.in.params.element.in" ] }, "api/ObjectDefinitionResponse": { @@ -391,7 +317,6 @@ "data.fieldGroups", "data.fields.valueType", "data.fields.valueType.currencyConfig", - "data.fields.valueType.expression.options[1]", "data.fields.valueType.relatedListFilter.lazy", "data.lifecycle", "data.listViews.valueType", @@ -400,52 +325,37 @@ "data.listViews.valueType.gantt.groupByField", "data.listViews.valueType.grouping.fields.element.field", "data.listViews.valueType.kanban.groupByField", - "data.listViews.valueType.timeline.groupByField", - "data.titleFormat.options[0].in", - "data.titleFormat.options[1]" + "data.listViews.valueType.timeline.groupByField" ] }, "api/PackageInstallBody": { "sites": [ - "options[1].navigationContributions.element.items.element.lazy.options[0]", - "options[1].navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + "options[1].navigationContributions.element.items.element.lazy.options[0]" ] }, "api/PackageInstallRequest": { "sites": [ - "manifest.navigationContributions.element.items.element.lazy.options[0]", - "manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + "manifest.navigationContributions.element.items.element.lazy.options[0]" ] }, "api/PackageInstallResponse": { "sites": [ - "data.package.manifest.navigationContributions.element.items.element.lazy.options[0]", - "data.package.manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + "data.package.manifest.navigationContributions.element.items.element.lazy.options[0]" ] }, "api/PackageUpgradeRequest": { "sites": [ - "manifest.navigationContributions.element.items.element.lazy.options[0]", - "manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + "manifest.navigationContributions.element.items.element.lazy.options[0]" ] }, "api/ResolveDependenciesRequest": { "sites": [ - "manifest.navigationContributions.element.items.element.lazy.options[0]", - "manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" - ] - }, - "api/UpdateAiConversationRequest": { - "sites": [ - "" + "manifest.navigationContributions.element.items.element.lazy.options[0]" ] }, "api/UpdateFlowRequest": { "sites": [ "definition", - "definition.edges.element.condition.options[0].in", - "definition.edges.element.condition.options[1]", - "definition.edges.element.condition.options[1].source", "definition.errorHandling", "definition.nodes.element.in.waitEventConfig" ] @@ -453,9 +363,6 @@ "api/UpdateFlowResponse": { "sites": [ "data", - "data.edges.element.condition.options[0].in", - "data.edges.element.condition.options[1]", - "data.edges.element.condition.options[1].source", "data.errorHandling", "data.nodes.element.in.waitEventConfig" ] @@ -470,12 +377,6 @@ "assignments.valueType" ] }, - "automation/AssignmentExpressionValue": { - "sites": [ - "", - "source" - ] - }, "automation/AssignmentValue": { "sites": [ "" @@ -489,20 +390,10 @@ "automation/Flow": { "sites": [ "", - "edges.element.condition.options[0].in", - "edges.element.condition.options[1]", - "edges.element.condition.options[1].source", "errorHandling", "nodes.element.in.waitEventConfig" ] }, - "automation/FlowEdge": { - "sites": [ - "condition.options[0].in", - "condition.options[1]", - "condition.options[1].source" - ] - }, "automation/FlowNode": { "sites": [ "in.waitEventConfig" @@ -510,27 +401,18 @@ }, "automation/FlowRegion": { "sites": [ - "edges.element.lazy.condition.options[0].in", - "edges.element.lazy.condition.options[1]", - "edges.element.lazy.condition.options[1].source", "nodes.element.lazy.in.waitEventConfig" ] }, "automation/FlowVersionHistory": { "sites": [ "definition", - "definition.edges.element.condition.options[0].in", - "definition.edges.element.condition.options[1]", - "definition.edges.element.condition.options[1].source", "definition.errorHandling", "definition.nodes.element.in.waitEventConfig" ] }, "automation/LoopConfig": { "sites": [ - "body.edges.element.lazy.condition.options[0].in", - "body.edges.element.lazy.condition.options[1]", - "body.edges.element.lazy.condition.options[1].source", "body.nodes.element.lazy.in.waitEventConfig" ] }, @@ -541,17 +423,11 @@ }, "automation/ParallelBranch": { "sites": [ - "edges.element.lazy.condition.options[0].in", - "edges.element.lazy.condition.options[1]", - "edges.element.lazy.condition.options[1].source", "nodes.element.lazy.in.waitEventConfig" ] }, "automation/ParallelConfig": { "sites": [ - "branches.element.edges.element.lazy.condition.options[0].in", - "branches.element.edges.element.lazy.condition.options[1]", - "branches.element.edges.element.lazy.condition.options[1].source", "branches.element.nodes.element.lazy.in.waitEventConfig" ] }, @@ -572,9 +448,6 @@ }, "automation/TryCatchConfig": { "sites": [ - "try.edges.element.lazy.condition.options[0].in", - "try.edges.element.lazy.condition.options[1]", - "try.edges.element.lazy.condition.options[1].source", "try.nodes.element.lazy.in.waitEventConfig" ] }, @@ -594,11 +467,6 @@ "path" ] }, - "data/ConditionalValidation": { - "sites": [ - "when.options[1]" - ] - }, "data/ContextToken": { "sites": [ "" @@ -609,11 +477,6 @@ "" ] }, - "data/CrossFieldValidation": { - "sites": [ - "condition.options[1]" - ] - }, "data/CurrencyConfig": { "sites": [ "" @@ -733,7 +596,6 @@ "sites": [ "", "currencyConfig", - "expression.options[1]", "relatedListFilter.lazy" ] }, @@ -762,15 +624,9 @@ }, "data/Hook": { "sites": [ - "condition.options[1]", "object" ] }, - "data/InlineGridColumn": { - "sites": [ - "readonlyWhen.options[1]" - ] - }, "data/InstantValue": { "sites": [ "" @@ -820,7 +676,6 @@ "fieldGroups", "fields.valueType", "fields.valueType.currencyConfig", - "fields.valueType.expression.options[1]", "fields.valueType.relatedListFilter.lazy", "lifecycle", "listViews.valueType", @@ -829,9 +684,7 @@ "listViews.valueType.gantt.groupByField", "listViews.valueType.grouping.fields.element.field", "listViews.valueType.kanban.groupByField", - "listViews.valueType.timeline.groupByField", - "titleFormat.options[0].in", - "titleFormat.options[1]" + "listViews.valueType.timeline.groupByField" ] }, "data/ObjectExtension": { @@ -839,15 +692,9 @@ "", "fields.valueType", "fields.valueType.currencyConfig", - "fields.valueType.expression.options[1]", "fields.valueType.relatedListFilter.lazy" ] }, - "data/ObjectFieldGroup": { - "sites": [ - "visibleWhen.options[1]" - ] - }, "data/PostgresConfig": { "sites": [ "", @@ -890,11 +737,6 @@ "" ] }, - "data/RowCrudActionOverride": { - "sites": [ - "visibleWhen.options[1]" - ] - }, "data/SQLDriverConfig": { "sites": [ "", @@ -906,16 +748,6 @@ "" ] }, - "data/ScriptValidation": { - "sites": [ - "condition.options[1]" - ] - }, - "data/SelectOption": { - "sites": [ - "visibleWhen.options[1]" - ] - }, "data/SetOperator": { "sites": [ "$in", @@ -940,11 +772,6 @@ "url" ] }, - "data/ValidationRule": { - "sites": [ - "lazy.options[0].condition.options[1]" - ] - }, "identity/SCIMError": { "sites": [ "schemas" @@ -981,63 +808,49 @@ }, "kernel/DisablePackageResponse": { "sites": [ - "package.manifest.navigationContributions.element.items.element.lazy.options[0]", - "package.manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + "package.manifest.navigationContributions.element.items.element.lazy.options[0]" ] }, "kernel/EnablePackageResponse": { "sites": [ - "package.manifest.navigationContributions.element.items.element.lazy.options[0]", - "package.manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + "package.manifest.navigationContributions.element.items.element.lazy.options[0]" ] }, "kernel/GetPackageResponse": { "sites": [ - "package.manifest.navigationContributions.element.items.element.lazy.options[0]", - "package.manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + "package.manifest.navigationContributions.element.items.element.lazy.options[0]" ] }, "kernel/InstallPackageRequest": { "sites": [ - "manifest.navigationContributions.element.items.element.lazy.options[0]", - "manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + "manifest.navigationContributions.element.items.element.lazy.options[0]" ] }, "kernel/InstallPackageResponse": { "sites": [ - "package.manifest.navigationContributions.element.items.element.lazy.options[0]", - "package.manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + "package.manifest.navigationContributions.element.items.element.lazy.options[0]" ] }, "kernel/InstalledPackage": { "sites": [ - "manifest.navigationContributions.element.items.element.lazy.options[0]", - "manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + "manifest.navigationContributions.element.items.element.lazy.options[0]" ] }, "kernel/ListPackagesResponse": { "sites": [ - "packages.element.manifest.navigationContributions.element.items.element.lazy.options[0]", - "packages.element.manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + "packages.element.manifest.navigationContributions.element.items.element.lazy.options[0]" ] }, "kernel/Manifest": { "sites": [ - "navigationContributions.element.items.element.lazy.options[0]", - "navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + "navigationContributions.element.items.element.lazy.options[0]" ] }, "kernel/MetadataTypeRegistryEntry": { "sites": [ "", "actions.element.in", - "actions.element.in.params.element.in", - "actions.element.in.visible.options[1].options[1]" - ] - }, - "kernel/MultiVersionSupport": { - "sites": [ - "routing.element.condition.options[1]" + "actions.element.in.params.element.in" ] }, "kernel/OpsDomainModule": { @@ -1060,36 +873,18 @@ "" ] }, - "kernel/PluginPermission": { - "sites": [ - "filter.condition.options[1]" - ] - }, - "kernel/PluginPermissionSet": { - "sites": [ - "permissions.element.filter.condition.options[1]" - ] - }, - "kernel/PluginSecurityManifest": { - "sites": [ - "permissions.permissions.element.filter.condition.options[1]" - ] - }, "kernel/UpgradePackageRequest": { "sites": [ - "manifest.navigationContributions.element.items.element.lazy.options[0]", - "manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + "manifest.navigationContributions.element.items.element.lazy.options[0]" ] }, "kernel/UpgradeSnapshot": { "sites": [ - "previousManifest.navigationContributions.element.items.element.lazy.options[0]", - "previousManifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + "previousManifest.navigationContributions.element.items.element.lazy.options[0]" ] }, "security/CriteriaSharingRule": { "sites": [ - "condition.options[1]", "sharedWith" ] }, @@ -1116,7 +911,6 @@ }, "security/SharingRule": { "sites": [ - "condition.options[1]", "sharedWith" ] }, @@ -1125,56 +919,10 @@ "options[2].organizationIds" ] }, - "shared/CronExpressionInput": { - "sites": [ - "options[0].in", - "options[1]" - ] - }, - "shared/EvaluatedExpression": { - "sites": [ - "", - "source" - ] - }, - "shared/EvaluatedExpressionInput": { - "sites": [ - "options[0].in", - "options[1]", - "options[1].source" - ] - }, - "shared/Expression": { - "sites": [ - "" - ] - }, - "shared/ExpressionInput": { - "sites": [ - "options[1]" - ] - }, - "shared/Predicate": { - "sites": [ - "" - ] - }, - "shared/PredicateInput": { - "sites": [ - "options[1]" - ] - }, - "shared/TemplateExpressionInput": { - "sites": [ - "options[0].in", - "options[1]" - ] - }, "system/AddFieldOperation": { "sites": [ "field", "field.currencyConfig", - "field.expression.options[1]", "field.relatedListFilter.lazy" ] }, @@ -1197,7 +945,6 @@ "sites": [ "operations.element.options[0].field", "operations.element.options[0].field.currencyConfig", - "operations.element.options[0].field.expression.options[1]", "operations.element.options[0].field.relatedListFilter.lazy", "operations.element.options[3].object", "operations.element.options[3].object.actions.element.in", @@ -1211,9 +958,7 @@ "operations.element.options[3].object.listViews.valueType.gantt.groupByField", "operations.element.options[3].object.listViews.valueType.grouping.fields.element.field", "operations.element.options[3].object.listViews.valueType.kanban.groupByField", - "operations.element.options[3].object.listViews.valueType.timeline.groupByField", - "operations.element.options[3].object.titleFormat.options[0].in", - "operations.element.options[3].object.titleFormat.options[1]" + "operations.element.options[3].object.listViews.valueType.timeline.groupByField" ] }, "system/CreateObjectOperation": { @@ -1224,7 +969,6 @@ "object.fieldGroups", "object.fields.valueType", "object.fields.valueType.currencyConfig", - "object.fields.valueType.expression.options[1]", "object.fields.valueType.relatedListFilter.lazy", "object.lifecycle", "object.listViews.valueType", @@ -1233,21 +977,7 @@ "object.listViews.valueType.gantt.groupByField", "object.listViews.valueType.grouping.fields.element.field", "object.listViews.valueType.kanban.groupByField", - "object.listViews.valueType.timeline.groupByField", - "object.titleFormat.options[0].in", - "object.titleFormat.options[1]" - ] - }, - "system/CronSchedule": { - "sites": [ - "expression.options[0].in", - "expression.options[1]" - ] - }, - "system/Job": { - "sites": [ - "schedule.options[0].expression.options[0].in", - "schedule.options[0].expression.options[1]" + "object.listViews.valueType.timeline.groupByField" ] }, "system/LifecyclePolicyConfig": { @@ -1260,16 +990,10 @@ "" ] }, - "system/MetricsConfig": { - "sites": [ - "slis.element.successCriteria.options[1].options[1]" - ] - }, "system/MigrationOperation": { "sites": [ "options[0].field", "options[0].field.currencyConfig", - "options[0].field.expression.options[1]", "options[0].field.relatedListFilter.lazy", "options[3].object", "options[3].object.actions.element.in", @@ -1283,9 +1007,7 @@ "options[3].object.listViews.valueType.gantt.groupByField", "options[3].object.listViews.valueType.grouping.fields.element.field", "options[3].object.listViews.valueType.kanban.groupByField", - "options[3].object.listViews.valueType.timeline.groupByField", - "options[3].object.titleFormat.options[0].in", - "options[3].object.titleFormat.options[1]" + "options[3].object.listViews.valueType.timeline.groupByField" ] }, "system/ObjectStorageConfig": { @@ -1293,43 +1015,29 @@ "buckets.element.lifecyclePolicy.rules.element" ] }, - "system/Schedule": { - "sites": [ - "options[0].expression.options[0].in", - "options[0].expression.options[1]" - ] - }, "system/ServerRateLimitConfig": { "sites": [ "" ] }, - "system/ServiceLevelIndicator": { - "sites": [ - "successCriteria.options[1].options[1]" - ] - }, "system/SettingsManifest": { "sites": [ "", "specifiers.element", - "visible", - "visible.options[1]" + "visible" ] }, "system/SettingsNamespacePayload": { "sites": [ "manifest", "manifest.specifiers.element", - "manifest.visible", - "manifest.visible.options[1]" + "manifest.visible" ] }, "system/Specifier": { "sites": [ "", - "visible", - "visible.options[1]" + "visible" ] }, "system/StackServerConfig": { @@ -1342,44 +1050,25 @@ "rateLimit" ] }, - "system/TraceSamplingConfig": { - "sites": [ - "composite.element.condition.options[1].options[1]" - ] - }, - "system/TracingConfig": { - "sites": [ - "sampling.composite.element.condition.options[1].options[1]" - ] - }, "ui/Action": { "sites": [ "in", - "in.params.element.in", - "in.visible.options[1].options[1]" - ] - }, - "ui/ActionNavItem": { - "sites": [ - "visible.options[1]" + "in.params.element.in" ] }, "ui/ActionParam": { "sites": [ - "in", - "in.visible.options[1]" + "in" ] }, "ui/App": { "sites": [ - "navigation.element.lazy.options[0]", - "navigation.element.lazy.options[0].visible.options[1]" + "navigation.element.lazy.options[0]" ] }, "ui/BulkActionDef": { "sites": [ - "", - "visible.options[1]" + "" ] }, "ui/ChartAggregate": { @@ -1387,11 +1076,6 @@ "" ] }, - "ui/ComponentNavItem": { - "sites": [ - "visible.options[1]" - ] - }, "ui/Dashboard": { "sites": [ "globalFilters.element", @@ -1399,11 +1083,6 @@ "widgets.element.filter.lazy" ] }, - "ui/DashboardNavItem": { - "sites": [ - "visible.options[1]" - ] - }, "ui/DashboardWidget": { "sites": [ "", @@ -1425,8 +1104,7 @@ "ui/ElementButtonProps": { "sites": [ "action.out", - "action.out.params.element.in", - "action.out.params.element.in.visible.options[1]" + "action.out.params.element.in" ] }, "ui/ElementDataSource": { @@ -1446,8 +1124,7 @@ }, "ui/FormField": { "sites": [ - "in.publicPicker.filter.element", - "in.visibleWhen.options[1]" + "in.publicPicker.filter.element" ] }, "ui/FormFieldPublicPicker": { @@ -1458,13 +1135,7 @@ "ui/FormSection": { "sites": [ "in", - "in.fields.element.options[1].in.publicPicker.filter.element", - "in.visibleWhen.options[1]" - ] - }, - "ui/FormSelectOption": { - "sites": [ - "visibleWhen.options[1]" + "in.fields.element.options[1].in.publicPicker.filter.element" ] }, "ui/FormView": { @@ -1472,7 +1143,6 @@ "", "sections.element.in", "sections.element.in.fields.element.options[1].in.publicPicker.filter.element", - "sections.element.in.visibleWhen.options[1]", "submitBehavior.options[1].url" ] }, @@ -1492,11 +1162,6 @@ "filter.lazy" ] }, - "ui/GroupNavItem": { - "sites": [ - "visible.options[1]" - ] - }, "ui/GroupingConfig": { "sites": [ "fields.element.field" @@ -1510,8 +1175,7 @@ "ui/InlineAction": { "sites": [ "out", - "out.params.element.in", - "out.params.element.in.visible.options[1]" + "out.params.element.in" ] }, "ui/InterfacePageConfig": { @@ -1534,7 +1198,6 @@ "sites": [ "", "bulkActionDefs.element", - "conditionalFormatting.element.condition.options[1]", "filter.element", "gantt.groupByField", "grouping.fields.element.field", @@ -1544,20 +1207,17 @@ }, "ui/NavigationArea": { "sites": [ - "navigation.element.lazy.options[0]", - "navigation.element.lazy.options[0].visible.options[1]" + "navigation.element.lazy.options[0]" ] }, "ui/NavigationContribution": { "sites": [ - "items.element.lazy.options[0]", - "items.element.lazy.options[0].visible.options[1]" + "items.element.lazy.options[0]" ] }, "ui/NavigationItem": { "sites": [ - "lazy.options[0]", - "lazy.options[0].visible.options[1]" + "lazy.options[0]" ] }, "ui/ObjectCalendarProps": { @@ -1585,7 +1245,6 @@ "sites": [ "", "bulkActionDefs.element", - "conditionalFormatting.element.condition.options[1]", "filter.element", "gantt.groupByField", "grouping.fields.element.field", @@ -1603,11 +1262,6 @@ "filter.element" ] }, - "ui/ObjectNavItem": { - "sites": [ - "visible.options[1]" - ] - }, "ui/ObjectTreeProps": { "sites": [ "filter.element" @@ -1617,37 +1271,19 @@ "sites": [ "", "interfaceConfig.filterBy.element", - "slots.header.options[0].in.type", - "slots.header.options[0].in.visibleWhen.options[1]" + "slots.header.options[0].in.type" ] }, "ui/PageComponent": { "sites": [ "in.dataSource.filter.element", - "in.type", - "in.visibleWhen.options[1]" - ] - }, - "ui/PageNavItem": { - "sites": [ - "visible.options[1]" + "in.type" ] }, "ui/PageRegion": { "sites": [ "components.element.lazy.in.dataSource.filter.element", - "components.element.lazy.in.type", - "components.element.lazy.in.visibleWhen.options[1]" - ] - }, - "ui/PageTabsProps": { - "sites": [ - "items.element.visibleWhen.options[1]" - ] - }, - "ui/RecordAlertProps": { - "sites": [ - "visible.options[1].options[1]" + "components.element.lazy.in.type" ] }, "ui/RecordDetailsProps": { @@ -1667,21 +1303,11 @@ "runtimeFilter.lazy" ] }, - "ui/ReportNavItem": { - "sites": [ - "visible.options[1]" - ] - }, "ui/TimelineConfig": { "sites": [ "groupByField" ] }, - "ui/UrlNavItem": { - "sites": [ - "visible.options[1]" - ] - }, "ui/UserFilters": { "sites": [ "tabs.element.filter.element" @@ -1694,7 +1320,6 @@ "form.submitBehavior.options[1].url", "list", "list.bulkActionDefs.element", - "list.conditionalFormatting.element.condition.options[1]", "list.filter.element", "list.gantt.groupByField", "list.grouping.fields.element.field", @@ -1711,7 +1336,6 @@ "sites": [ "options[0].config", "options[0].config.bulkActionDefs.element", - "options[0].config.conditionalFormatting.element.condition.options[1]", "options[0].config.filter.element", "options[0].config.gantt.groupByField", "options[0].config.grouping.fields.element.field", @@ -1726,7 +1350,6 @@ "sites": [ "options[0].config", "options[0].config.bulkActionDefs.element", - "options[0].config.conditionalFormatting.element.condition.options[1]", "options[0].config.filter.element", "options[0].config.gantt.groupByField", "options[0].config.grouping.fields.element.field", diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index 3233aeceaf0..135a8b8e1ab 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -45,6 +45,11 @@ import { projectByPruningUnionBranches, type PrunedBranch, } from './lib/union-branch-projection'; +// The closed list of refinements this generator DOES publish (#18670 item 2). +// The ratchet below measures against this same override, so a rule the list +// emits leaves the ledger and a rule it does not emit stays in it — see the +// module header for why the two halves must not be read against each other. +import { refinementProjectionOverride } from './lib/refinement-projection'; // The dropped-refinement ratchet (#18670). The mirror image of the branch // pruning above, and deliberately its own module for the same reason: the // pruner guards a projection NARROWER than the Zod type, this one the direction @@ -495,6 +500,7 @@ for (const [namespaceName, namespaceExports] of Object.entries(Protocol)) { try { jsonSchema = z.toJSONSchema(value, { target: 'draft-2020-12', + override: refinementProjectionOverride, }) as Record; } catch (outputError) { if (!isKnownUnsupported(outputError)) throw outputError; @@ -503,6 +509,7 @@ for (const [namespaceName, namespaceExports] of Object.entries(Protocol)) { jsonSchema = z.toJSONSchema(value, { target: 'draft-2020-12', io: 'input', + override: refinementProjectionOverride, }) as Record; } catch (inputError) { if (!isKnownUnsupported(inputError)) throw inputError; @@ -519,7 +526,10 @@ for (const [namespaceName, namespaceExports] of Object.entries(Protocol)) { // then re-thrown with the message Zod produced, so this attempt // can never change WHY an export is skipped, and so never the // `cause` recorded for it in unemitted-schemas.baseline.json. - const projected = projectByPruningUnionBranches(value, { target: 'draft-2020-12' }); + const projected = projectByPruningUnionBranches(value, { + target: 'draft-2020-12', + override: refinementProjectionOverride, + }); if (!projected) throw inputError; jsonSchema = projected.schema; io = projected.io; @@ -550,15 +560,21 @@ for (const [namespaceName, namespaceExports] of Object.entries(Protocol)) { branchPrunedProjections.push({ namespace: namespaceName, exportKey: key, pruned: prunedBranches }); } - // The refinements this projection DROPPED (#18670), named on the + // The refinements this projection STILL drops (#18670), named on the // artifact for the same reason `x-unprojectable-branches` is: a reader // of this file — an author, a reference page, an AI validating a // document against it — can otherwise not tell that the contract // underneath carries rules this file does not state. It is an // annotation and nothing more: `x-` keywords are ignored by every // validator, so the set of documents this schema ACCEPTS is unchanged - // by it. Narrowing the published shape to match the Zod type is a - // public-contract change and is deliberately NOT done here. + // by it. + // + // What DID narrow (#18670 item 2) is the closed list in + // `src/shared/refinement-projection.ts`, applied by the `override` + // above: a refinement declared through it is emitted as real + // keywords, so it never reaches `census.dropped` and never reaches + // this annotation. ⛔ The two are exclusive by construction — a site + // cannot be both stated and annotated as unstated. const census = collectDroppedRefinements(`${categorySlug}/${schemaName}`, value); refinementCensus.push(census); if (census.dropped.length > 0) { @@ -3421,9 +3437,16 @@ if (unemittedSkips.length > 0) { // no. Measured on this tree at the change that added this block: 682 refinement // sites across 237 published schemas, zero of which projected anything. // -// ⛔ It does NOT narrow any published shape and does not touch the refinements -// themselves — the runtime rule is correct. It makes the population declared, -// so the next one arrives as a line in a diff instead of as nothing at all. +// ⛔ This ratchet still narrows nothing by itself and touches no refinement — +// the runtime rule is correct. It makes the remaining population declared, so +// the next gap arrives as a line in a diff instead of as nothing at all. +// +// The narrowing is the CLOSED list in `src/shared/refinement-projection.ts` +// (#18670 item 2), emitted by the `override` this generator passes to every +// projection. It and this ratchet compose in one direction: a site the list +// emits is `projected` and its ledger row is deleted in the same PR; every +// other site is `dropped` and stays declared. So the ledger is shrink-only in +// the strong sense — a repair is the only thing that shortens it. const droppedRefinementsBaseline = readDroppedRefinementsBaseline(PKG_DIR); if (!droppedRefinementsBaseline) { console.error(`\n❌ ${DROPPED_REFINEMENTS_BASELINE_FILE} is missing — it is a committed, hand-edited ledger (#18670).`); @@ -3544,10 +3567,11 @@ if (droppedSiteTotal > 0) { `RUNTIME and not the published JSON Schema — all declared in ${DROPPED_REFINEMENTS_BASELINE_FILE} (#18670).`, ); console.log( - ` The published files are therefore WIDER than the Zod types they are generated from:\n` + - ` a document one of them accepts can still be refused at parse time. Each affected file\n` + - ` names its own sites as \`x-dropped-refinements\`. Narrowing the published shape to match\n` + - ` is a public-contract change and is NOT what this ratchet does.`, + ` Those files are therefore still WIDER than the Zod types they are generated from:\n` + + ` a document one of them accepts can be refused at parse time. Each affected file names\n` + + ` its own remaining sites as \`x-dropped-refinements\`. Closing one means teaching the\n` + + ` CLOSED list in src/shared/refinement-projection.ts a NAMED pattern — ⛔ never deleting\n` + + ` the refinement, and ⛔ never an open-ended translator over the whole population.`, ); console.log( ` Also measured this run: ${projectedSiteTotal} refinement site(s) DID reach the file, ` + @@ -3555,6 +3579,30 @@ if (droppedSiteTotal > 0) { ); } +// Which projected sites got there through which arm of the closed list (#18670 +// item 2). Printed per pattern rather than as one total, for the reason the +// ledger records sites rather than a count: a total cannot tell "one arm stopped +// emitting" from "somebody deleted a refinement", and the two have opposite +// remedies. A site that projects with NO declared pattern is reported on its own +// line — it means zod started emitting something by itself, which is news. +if (projectedSiteTotal > 0) { + const byPattern = new Map(); + for (const entry of refinementCensus) { + for (const site of entry.projected) { + const key = site.declaredPatterns.length > 0 + ? site.declaredPatterns.join('+') + : 'UNDECLARED — zod projected this on its own'; + byPattern.set(key, (byPattern.get(key) ?? 0) + 1); + } + } + console.log( + `\n📣 ${projectedSiteTotal} refinement site(s) DO reach the published JSON Schema, by declared pattern:`, + ); + for (const [pattern, n] of [...byPattern].sort((a, b) => b[1] - a[1])) { + console.log(` ${String(n).padStart(4)} ${pattern}`); + } +} + // ─── Generate Bundled Schema ───────────────────────────────────────── // Single-file bundled schema containing all generated schemas for IDE autocomplete diff --git a/packages/spec/scripts/dropped-refinements.test.ts b/packages/spec/scripts/dropped-refinements.test.ts index 18c6a5c9a8d..5a413f27a04 100644 --- a/packages/spec/scripts/dropped-refinements.test.ts +++ b/packages/spec/scripts/dropped-refinements.test.ts @@ -225,7 +225,7 @@ describe('the differential isolates the refinement, not the node', () => { }); describe('the ratchet adjudicates against the ledger', () => { - const site = (path: string) => ({ path, nodeType: 'string', count: 1, aborting: false, verdict: 'dropped' as const }); + const site = (path: string) => ({ path, nodeType: 'string', count: 1, aborting: false, verdict: 'dropped' as const, declaredPatterns: [] }); const census = (defKey: string, paths: string[]) => ({ defKey, dropped: paths.map(site), diff --git a/packages/spec/scripts/lib/dropped-refinements.ts b/packages/spec/scripts/lib/dropped-refinements.ts index 9615f4e0c02..8586ed4d970 100644 --- a/packages/spec/scripts/lib/dropped-refinements.ts +++ b/packages/spec/scripts/lib/dropped-refinements.ts @@ -30,13 +30,17 @@ * * It is a **visibility ratchet**, exactly like `unemitted-schemas.ts`: it * reports what is already true and refuses GROWTH of the population. It - * narrows no published shape, removes no refinement, and changes nothing about - * what the runtime accepts — the baseline is anchored to the tree as it stands, - * so it is green the moment it lands. + * narrows nothing itself and removes no refinement — the baseline is anchored + * to the tree as it stands. * - * It is **not** a fix. Teaching the projection to emit what a refinement - * constrains, or declaring the published artifact a floor, both change the - * published contract and are a maintainer's decision, not a generator's. + * The **fix** is a separate module and a separate decision, taken for #18670 + * item 2: `refinement-projection.ts` publishes a CLOSED, named list of + * refinements, and this module now measures against that same projection (see + * `projectOrNull`). So the two halves compose in one direction only — a rule the + * closed list emits reads `projected` here and its ledger row goes; every other + * rule reads `dropped` and stays declared. ⛔ Neither half may be used to talk + * the other out of its reading: a site is `dropped` because THIS build's file + * says nothing about it, not because a PR body says the projection handles it. * * ## Why the verdict is MEASURED per instance, never assumed * @@ -77,18 +81,30 @@ import fs from 'fs'; import path from 'path'; import { z } from 'zod'; +// The closed list of refinements that DO reach the published file (#18670 item +// 2), and the zod-check primitives both halves turn on. Imported rather than +// re-derived because the differential below is only a statement about the real +// published artifact if it runs the generator's own projection — see that +// module's header. +import { + CUSTOM_CHECK_KIND, + checkKindOf, + customChecksOf, + projectableRefinementsOf, + refinementProjectionOverride, + zodDefOf, +} from './refinement-projection'; /** File name of the committed ledger, resolved against the package root. */ export const DROPPED_REFINEMENTS_BASELINE_FILE = 'dropped-refinements.baseline.json'; /** - * The check kind `.refine()`, `.superRefine()` and a bare `.check(fn)` all - * compile to in zod 4. Named once here because it is the ONE string this whole - * instrument turns on: a zod release that renames it must fail as "no - * refinements found anywhere" against the lit control in the census, never as a - * quiet zero. + * Re-exported so this module stays the one import for the census vocabulary. + * Its declaration — and the argument for why it is the ONE string this whole + * instrument turns on — lives in `refinement-projection.ts`, beside the two + * other readers of a zod check. */ -export const CUSTOM_CHECK_KIND = 'custom'; +export { CUSTOM_CHECK_KIND }; /** How deep the graph walk goes before it stops descending. */ const MAX_DEPTH = 14; @@ -122,6 +138,17 @@ export interface RefinementSite { * comparison has no two sides. Reported, never counted as a gap. */ readonly verdict: 'dropped' | 'projected' | 'undecidable'; + /** + * Which arms of the closed projectable list (#18670 item 2) this node's + * refinements were DECLARED as, in declaration order — empty for every rule + * outside that list, which is what keeps it `dropped`. + * + * Reported so the generator can say which PATTERN closed a site rather than + * only that the count moved: a projection arm that silently stops emitting + * shows up here as a site that went back to `dropped` with its pattern still + * named, which reads differently from a refinement somebody deleted. + */ + readonly declaredPatterns: readonly string[]; } /** Every refinement site under one published schema. */ @@ -160,28 +187,11 @@ export interface DroppedRefinementsBaseline { readonly entries: Readonly>; } -function defOf(schema: z.ZodType): Record | null { - const def = (schema as unknown as { _zod?: { def?: unknown } })._zod?.def; - return def && typeof def === 'object' ? (def as Record) : null; -} - -function checkKind(check: unknown): string | null { - const kind = (check as { _zod?: { def?: { check?: unknown } } } | null)?._zod?.def?.check; - return typeof kind === 'string' ? kind : null; -} - function checkAborts(check: unknown): boolean { const def = (check as { _zod?: { def?: Record } } | null)?._zod?.def; return def?.abort === true || def?.fatal === true; } -function customChecksOf(schema: z.ZodType): unknown[] { - const def = defOf(schema); - const checks = def?.checks; - if (!Array.isArray(checks)) return []; - return checks.filter((c) => checkKind(c) === CUSTOM_CHECK_KIND); -} - /** * The same node with its `custom` checks removed. * @@ -196,9 +206,9 @@ function customChecksOf(schema: z.ZodType): unknown[] { * question. */ function withoutCustomChecks(schema: z.ZodType): z.ZodType | null { - const def = defOf(schema); + const def = zodDefOf(schema); if (!def || !Array.isArray(def.checks)) return null; - const kept = def.checks.filter((c) => checkKind(c) !== CUSTOM_CHECK_KIND); + const kept = def.checks.filter((c) => checkKindOf(c) !== CUSTOM_CHECK_KIND); const cloneable = schema as unknown as { clone?: (d: unknown) => z.ZodType }; if (typeof cloneable.clone !== 'function') return null; const stripped = cloneable.clone({ ...def, checks: kept }); @@ -207,11 +217,24 @@ function withoutCustomChecks(schema: z.ZodType): z.ZodType | null { return stripped; } -/** `toJSONSchema` in the generator's own io ladder, or `null` when neither side has a JSON form. */ +/** + * `toJSONSchema` in the generator's own io ladder, or `null` when neither side + * has a JSON form. + * + * ⭐ It passes the generator's `override` (#18670 item 2). Without it this + * function would measure a projection nothing publishes: a node whose rule the + * closed list DOES emit would read byte-identical on both sides of the + * differential and stay in the ledger for ever, and the shrink-only ledger's + * whole use — a row deletion is the observable proof a site closed — would be + * unreachable. With it, `dropped` means "this build's own published file states + * nothing about this rule". + */ function projectOrNull(schema: z.ZodType): string | null { for (const io of ['output', 'input'] as const) { try { - return JSON.stringify(z.toJSONSchema(schema, { target: 'draft-2020-12', io })); + return JSON.stringify( + z.toJSONSchema(schema, { target: 'draft-2020-12', io, override: refinementProjectionOverride }), + ); } catch { // Try the other direction — the generator does the same, for the same reason. } @@ -241,7 +264,7 @@ function verdictFor(schema: z.ZodType): RefinementSite['verdict'] { */ function labelledChildren(schema: z.ZodType): Array<{ label: string; schema: z.ZodType }> { const out: Array<{ label: string; schema: z.ZodType }> = []; - const def = defOf(schema); + const def = zodDefOf(schema); if (!def) return out; const seen = new Set(); const walk = (label: string, value: unknown): void => { @@ -375,10 +398,11 @@ export function collectDroppedRefinements(defKey: string, root: z.ZodType): Refi if (customs.length > 0) { const site: RefinementSite = { path: readablePath(path), - nodeType: String(defOf(schema)?.type ?? 'unknown'), + nodeType: String(zodDefOf(schema)?.type ?? 'unknown'), count: customs.length, aborting: customs.some(checkAborts), verdict: verdictFor(schema), + declaredPatterns: projectableRefinementsOf(schema).map((declared) => declared.pattern), }; if (site.verdict === 'dropped') dropped.push(site); else if (site.verdict === 'projected') projected.push(site); diff --git a/packages/spec/scripts/lib/refinement-projection.ts b/packages/spec/scripts/lib/refinement-projection.ts new file mode 100644 index 00000000000..da567e13bd4 --- /dev/null +++ b/packages/spec/scripts/lib/refinement-projection.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Emits the closed list of projectable refinements into the published JSON + * Schema (#18670 item 2) — the generator half of + * `src/shared/refinement-projection.ts`. + * + * ## What it does + * + * `z.toJSONSchema()` calls its `override` hook once per node with that node's + * emitted object. {@link refinementProjectionOverride} reads the node's `custom` + * checks, asks `projectableRefinementOf` what each one was DECLARED to mean, and + * writes the keywords for the ones inside the closed list. A refinement nobody + * declared gets nothing — it stays dropped, and `dropped-refinements.ts` keeps + * naming it on the artifact as `x-dropped-refinements`. + * + * ## Why the detector runs the same override + * + * `dropped-refinements.ts` decides `dropped` vs `projected` by projecting a node + * twice — once as it is, once with its `custom` checks removed — and comparing + * bytes. That differential is only about the real published file if BOTH sides + * go through the generator's own projection, so the detector passes this same + * override. The consequence is the property the ledger is read for: a site whose + * rule this module emits flips to `projected` and its ledger row has to go, in + * the same PR, and a site it does not emit stays `dropped` no matter what the + * PR says about it. ⛔ There is no third state a PR can put a site into. + * + * ## The direction, stated once + * + * Every arm's keywords accept exactly the JSON documents its runtime rule + * accepts (`ProjectableRefinement`'s own docblock argues each equality). So the + * published file NARROWS toward what the runtime already refuses and no document + * the runtime accepts becomes refused. ⛔ An arm that could only approximate + * its rule would be a behaviour change wearing a correction's clothes. + */ +import type { z } from 'zod'; +import { + NON_BLANK_PATTERN, + projectableRefinementOf, + type ProjectableRefinement, +} from '../../src/shared/refinement-projection'; + +/** + * The check kind `.refine()`, `.superRefine()` and a bare `.check(fn)` all + * compile to in zod 4. Named once here because it is the ONE string this whole + * instrument turns on: a zod release that renames it must fail as "no + * refinements found anywhere" against the lit control in the census, never as a + * quiet zero. + */ +export const CUSTOM_CHECK_KIND = 'custom'; + +/** A node's own `_zod.def`, or `null` for anything that is not a zod node. */ +export function zodDefOf(schema: z.ZodType): Record | null { + const def = (schema as unknown as { _zod?: { def?: unknown } })._zod?.def; + return def && typeof def === 'object' ? (def as Record) : null; +} + +/** One check's `check` tag, e.g. `custom`, `min_length`, `string_format`. */ +export function checkKindOf(check: unknown): string | null { + const kind = (check as { _zod?: { def?: { check?: unknown } } } | null)?._zod?.def?.check; + return typeof kind === 'string' ? kind : null; +} + +/** Every `custom` check this node carries, in declaration order. */ +export function customChecksOf(schema: z.ZodType): unknown[] { + const def = zodDefOf(schema); + const checks = def?.checks; + if (!Array.isArray(checks)) return []; + return checks.filter((c) => checkKindOf(c) === CUSTOM_CHECK_KIND); +} + +/** The predicate a `.refine()` check holds, or `undefined` for `.superRefine()` / `.check()`. */ +function ruleOf(check: unknown): unknown { + return (check as { _zod?: { def?: { fn?: unknown } } } | null)?._zod?.def?.fn; +} + +/** + * What the closed list says about this node, in declaration order — empty for + * every node whose refinements are outside it, which is the common case. + */ +export function projectableRefinementsOf(schema: z.ZodType): ProjectableRefinement[] { + const out: ProjectableRefinement[] = []; + for (const check of customChecksOf(schema)) { + const declared = projectableRefinementOf(ruleOf(check)); + if (declared) out.push(declared); + } + return out; +} + +type JsonObject = Record; + +/** + * `anyOf` of one `required` per key, conjoined onto the node through `allOf`. + * + * ⭐ The nesting is MEASURED, not stylistic. Written as a TOP-LEVEL `anyOf` + * beside the node's own `type: 'object'` and `properties`, the pattern is valid + * JSON Schema and reads correctly to a validator — and it breaks the one real + * reader this repository has. `scripts/lib/format-type.ts` tests `anyOf` BEFORE + * `properties`, so the node stopped rendering as its object shape and started + * rendering as its `anyOf` branches, which carry no `type` at all: measured on + * `content/docs/references/system/tracing.mdx`, the `condition` cell went from + * `Record | string | { dialect: …; source?: string; ast?: any }` to + * `Record | string | any | any`, and 26 reference pages moved the + * same way. The reference tables are the authoritative input for AI authors + * (ADR-0033), so a cell that loses a shape it used to state is a second lie + * traded for the first one this change exists to remove. + * + * `allOf` is the conjunction JSON Schema provides for exactly this — a + * constraint added BESIDE a node's own keywords rather than instead of them — + * so a validator reads the same rule, the reference table keeps the shape it + * always stated, and two arms can land on one node without either replacing + * the other. + */ +function emitRequiredOneOf(jsonSchema: JsonObject, keys: readonly string[]): void { + if (keys.length === 0) return; + const anyOf = keys.map((key) => ({ required: [key] })); + const allOf = Array.isArray(jsonSchema.allOf) ? (jsonSchema.allOf as unknown[]) : []; + jsonSchema.allOf = [...allOf, { anyOf }]; +} + +/** + * `minLength: 1` plus the non-blank pattern. + * + * `minLength` never LOWERS an existing one — a slot that also carries + * `.min(8)` keeps its 8 — and an existing `pattern` is conjoined through + * `allOf` rather than replaced, for the same reason `anyOf` is above. + */ +function emitNonBlankString(jsonSchema: JsonObject): void { + const existing = typeof jsonSchema.minLength === 'number' ? jsonSchema.minLength : 0; + jsonSchema.minLength = Math.max(existing, 1); + if (!('pattern' in jsonSchema)) { + jsonSchema.pattern = NON_BLANK_PATTERN; + return; + } + if (jsonSchema.pattern === NON_BLANK_PATTERN) return; + const allOf = Array.isArray(jsonSchema.allOf) ? (jsonSchema.allOf as unknown[]) : []; + jsonSchema.allOf = [...allOf, { pattern: NON_BLANK_PATTERN }]; +} + +/** Write one declared arm's keywords onto one emitted node. */ +export function emitProjectableRefinement(jsonSchema: JsonObject, declared: ProjectableRefinement): void { + switch (declared.pattern) { + case 'required-one-of': + emitRequiredOneOf(jsonSchema, declared.keys); + return; + case 'non-blank-string': + emitNonBlankString(jsonSchema); + return; + } +} + +/** + * The `override` callback `z.toJSONSchema()` takes. Safe to pass for every + * projection of every schema: a node with no declared refinement is left byte + * for byte as zod emitted it. + */ +export function refinementProjectionOverride(ctx: { + readonly zodSchema: unknown; + readonly jsonSchema: unknown; +}): void { + const jsonSchema = ctx.jsonSchema as JsonObject | null; + if (!jsonSchema || typeof jsonSchema !== 'object') return; + for (const declared of projectableRefinementsOf(ctx.zodSchema as z.ZodType)) { + emitProjectableRefinement(jsonSchema, declared); + } +} + +/** + * Run `first`, then `second`, on every node — for a projection that already + * owns the single `override` slot (`union-branch-projection.ts` marks nodes with + * it) and must also carry this one. + */ +export function composeOverrides(first: (ctx: C) => void, second: (ctx: C) => void): (ctx: C) => void { + return (ctx: C): void => { + first(ctx); + second(ctx); + }; +} diff --git a/packages/spec/scripts/lib/union-branch-projection.ts b/packages/spec/scripts/lib/union-branch-projection.ts index 1b23049dcf3..571dadf88f9 100644 --- a/packages/spec/scripts/lib/union-branch-projection.ts +++ b/packages/spec/scripts/lib/union-branch-projection.ts @@ -269,17 +269,36 @@ export function findSurvivingMark(node: unknown, at = '#'): string | null { */ export function projectByPruningUnionBranches( value: z.ZodType, - options: { readonly target: 'draft-2020-12' }, + options: { + readonly target: 'draft-2020-12'; + /** + * An extra `override` to run after this module's own marker pass — the + * generator's refinement projection (#18670 item 2). This function owns the + * single `override` slot `toJSONSchema` provides, so a caller that also + * needs one hands it here rather than losing one of the two silently: an + * export that reaches its published file through THIS path would otherwise + * be the one artifact missing a narrowing the ledger already recorded as + * closed (`data/Hook` is the live case). + */ + readonly override?: (ctx: { zodSchema: unknown; jsonSchema: unknown; path: (string | number)[] }) => void; + }, ): BranchProjection | null { const candidates: BranchProjection[] = []; for (const io of ['output', 'input'] as const) { let schema: JsonObject; try { + const mark = markUnprojectableNodes(io); + const extra = options.override; schema = z.toJSONSchema(value, { target: options.target, unrepresentable: 'any', - override: markUnprojectableNodes(io), + override: extra + ? (ctx): void => { + mark(ctx); + extra(ctx); + } + : mark, ...(io === 'input' ? { io } : {}), }) as JsonObject; } catch { diff --git a/packages/spec/scripts/refinement-projection.test.ts b/packages/spec/scripts/refinement-projection.test.ts new file mode 100644 index 00000000000..0f892bdb064 --- /dev/null +++ b/packages/spec/scripts/refinement-projection.test.ts @@ -0,0 +1,337 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pins the CLOSED refinement projection (#18670 item 2) — the change that makes + * `packages/spec/json-schema/**` state rules it used to leave to the runtime. + * + * ## The one claim every case here serves + * + * The published file NARROWS toward what the runtime already refuses, and ⛔ no + * document the runtime ACCEPTS becomes refused. That is not a direction to + * assert — for each arm the two sides are EXACTLY equal, and the equality is + * what is measured: + * + * - `required-one-of` — over the whole key-presence lattice, and with a + * present-but-`null` value, which is the one JSON shape where "present" and + * "not undefined" could have come apart. + * - `non-blank-string` — over every ECMA-262 WhiteSpace and LineTerminator + * code point, each alone (both sides refuse) and each embedded beside a + * letter (both sides accept). `String.prototype.trim` removes exactly that + * set and `\S` is its complement, so the pin is the whole argument; JSON + * Schema specifies `pattern` as an ECMA-262 regex, which is the same engine + * this assertion runs on. + * + * ## Why an equality pin and not a comment + * + * `src/shared/refinement-projection.ts` builds each predicate FROM its + * declaration wherever it can (`requiredOneOf` reads one key array twice), so + * that arm cannot drift. `non-blank-string` cannot be derived that way — a + * `.trim()` and a regex are two spellings of one set, not one spelling used + * twice — so the equivalence is pinned here instead, and an edit to either side + * fails rather than publishes a contract nothing enforces. + * + * ## And why the detector is pinned beside it + * + * The ledger's use is that a row deletion is the observable proof a site + * closed. That only holds while `dropped-refinements.ts` measures the + * GENERATOR's projection rather than a bare one, so both halves of that + * coupling are asserted: a declared refinement reads `projected`, an undeclared + * one on the same shape still reads `dropped`. + */ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { + NON_BLANK_PATTERN, + NON_BLANK_STRING, + PROJECTABLE_REFINEMENT_PATTERNS, + projectableRefinementOf, + requiredOneOf, +} from '../src/shared/refinement-projection'; +import { + emitProjectableRefinement, + projectableRefinementsOf, + refinementProjectionOverride, +} from './lib/refinement-projection'; +import { collectDroppedRefinements } from './lib/dropped-refinements'; +import { + EvaluatedExpressionInputSchema, + ExpressionSchema, +} from '../src/shared/expression.zod'; + +/** Exactly the call shape `build-schemas.ts` publishes with. */ +const publish = (schema: z.ZodType, io: 'input' | 'output' = 'output'): Record => + z.toJSONSchema(schema, { + target: 'draft-2020-12', + io, + override: refinementProjectionOverride, + }) as Record; + +/** + * The node's `allOf[].anyOf[].required` rule — evaluated the way a validator + * would, and refusing to report anything when the node carries no such rule, so + * a projection that stopped emitting fails rather than passing vacuously. + */ +const requiredOneOfSatisfied = (node: Record, doc: Record): boolean => { + const allOf = node.allOf as Array<{ anyOf?: Array<{ required: string[] }> }> | undefined; + const branches = allOf?.flatMap((clause) => clause.anyOf ?? []); + if (!branches || branches.length === 0) { + throw new Error('the node carries no allOf[].anyOf[].required — nothing to evaluate'); + } + return branches.some((branch) => branch.required.every((key) => Object.prototype.hasOwnProperty.call(doc, key))); +}; + +/** + * ECMA-262 WhiteSpace ∪ LineTerminator, by code point so no control byte is + * ever written into this file (`scripts/check-nul-bytes.mjs` is the authority + * on why a raw one is a defect rather than a spelling). + */ +const BLANK_CODE_POINTS = [ + 0x09, 0x0b, 0x0c, 0x20, 0xa0, 0xfeff, // WhiteSpace: TAB VT FF SP NBSP ZWNBSP + 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, + 0x2009, 0x200a, 0x202f, 0x205f, 0x3000, // WhiteSpace: the Zs category + 0x0a, 0x0d, 0x2028, 0x2029, // LineTerminator: LF CR LS PS +]; + +describe('the list of projectable patterns is CLOSED', () => { + it('names exactly the two arms this change landed', () => { + // ⛔ Growing this is a public-contract decision: every arm narrows a + // published artifact. A new arm updates this line in the same PR, which is + // what makes it a reviewed diff rather than a quiet widening of the + // narrowing. + expect([...PROJECTABLE_REFINEMENT_PATTERNS]).toEqual(['required-one-of', 'non-blank-string']); + }); + + it('a refinement nobody declared gets NO keyword', () => { + const undeclared = z.string().refine((s) => s.startsWith('x'), 'must start with x'); + expect(projectableRefinementsOf(undeclared)).toEqual([]); + expect(publish(undeclared)).toEqual(publish(z.string())); + }); + + it('LIT CONTROL — the same node with a DECLARED rule does get one', () => { + const declared = z.string().refine(NON_BLANK_STRING, 'must not be blank'); + expect(projectableRefinementsOf(declared).map((p) => p.pattern)).toEqual(['non-blank-string']); + expect(publish(declared)).not.toEqual(publish(z.string())); + }); + + it('a `.superRefine()` carries no readable rule, so it can never be declared', () => { + // The measurement behind "the declaration cannot be read back out of the + // predicate": `.superRefine()`'s check def holds only `{ check: 'custom' }`. + const sup = z.string().superRefine((s, ctx) => { + if (s.length === 0) ctx.addIssue({ code: 'custom', message: 'empty' }); + }); + expect(projectableRefinementsOf(sup)).toEqual([]); + }); +}); + +describe('required-one-of: one key list, read twice', () => { + it('declares the keys it was given', () => { + const rule = requiredOneOf(['source', 'ast']); + expect(projectableRefinementOf(rule)).toEqual({ pattern: 'required-one-of', keys: ['source', 'ast'] }); + }); + + it('emits an `anyOf` of one `required` per key, conjoined through `allOf`', () => { + const node: Record = { type: 'object' }; + emitProjectableRefinement(node, { pattern: 'required-one-of', keys: ['a', 'b'] }); + expect(node).toEqual({ + type: 'object', + allOf: [{ anyOf: [{ required: ['a'] }, { required: ['b'] }] }], + }); + }); + + it('⛔ never writes a TOP-LEVEL `anyOf` — the reference renderer reads that as the node\'s TYPE', () => { + // Measured: `format-type.ts` tests `anyOf` before `properties`, so a + // top-level `anyOf` here makes 26 reference pages print `any | any` in + // place of an object shape they used to state. Pinned as an absence + // because the regression is silent in every gate. + const node: Record = { type: 'object', properties: { a: { type: 'string' } } }; + emitProjectableRefinement(node, { pattern: 'required-one-of', keys: ['a', 'b'] }); + expect(node.anyOf).toBeUndefined(); + expect(node.properties).toEqual({ a: { type: 'string' } }); + }); + + it('the predicate and the keywords agree over the whole presence lattice', () => { + const rule = requiredOneOf(['a', 'b']); + const node = publish(z.object({ a: z.string().optional(), b: z.string().optional(), c: z.string().optional() }).refine(rule)); + const keys = ['a', 'b', 'c'] as const; + for (let mask = 0; mask < 8; mask += 1) { + const doc: Record = {}; + keys.forEach((key, i) => { + if (mask & (1 << i)) doc[key] = 'v'; + }); + // A JSON object round-trip is what makes "absent" and "undefined" the + // same fact — the equality this arm rests on. + const asJson = JSON.parse(JSON.stringify(doc)) as Record; + expect( + rule(asJson as never), + `runtime vs keywords disagree for ${JSON.stringify(asJson)}`, + ).toBe(requiredOneOfSatisfied(node, asJson)); + } + }); + + it('a key present with a `null` value satisfies BOTH sides', () => { + const rule = requiredOneOf(['a', 'b']); + const node = publish(z.object({ a: z.unknown().optional(), b: z.unknown().optional() }).refine(rule)); + const doc = { a: null }; + expect(rule(doc as never)).toBe(true); + expect(requiredOneOfSatisfied(node, doc)).toBe(true); + }); + + it('leaves a union `anyOf` the node already has completely alone', () => { + const node: Record = { anyOf: [{ type: 'string' }, { type: 'number' }] }; + emitProjectableRefinement(node, { pattern: 'required-one-of', keys: ['a'] }); + expect(node.anyOf).toEqual([{ type: 'string' }, { type: 'number' }]); + expect(node.allOf).toEqual([{ anyOf: [{ required: ['a'] }] }]); + }); + + it('two arms on one node both land, neither replacing the other', () => { + const node: Record = { type: 'object' }; + emitProjectableRefinement(node, { pattern: 'required-one-of', keys: ['a'] }); + emitProjectableRefinement(node, { pattern: 'required-one-of', keys: ['b', 'c'] }); + expect(node.allOf).toEqual([ + { anyOf: [{ required: ['a'] }] }, + { anyOf: [{ required: ['b'] }, { required: ['c'] }] }, + ]); + }); +}); + +describe('non-blank-string: the trim and the regex are ONE set', () => { + const blankRe = (): RegExp => new RegExp(NON_BLANK_PATTERN); + + it('agrees on every ECMA-262 WhiteSpace and LineTerminator code point, alone', () => { + for (const cp of BLANK_CODE_POINTS) { + const s = String.fromCodePoint(cp); + expect(NON_BLANK_STRING(s), `U+${cp.toString(16)} alone`).toBe(false); + expect(blankRe().test(s), `U+${cp.toString(16)} alone, via the pattern`).toBe(false); + } + }); + + it('agrees on every one of them EMBEDDED beside a letter', () => { + for (const cp of BLANK_CODE_POINTS) { + const s = `${String.fromCodePoint(cp)}a${String.fromCodePoint(cp)}`; + expect(NON_BLANK_STRING(s), `U+${cp.toString(16)} embedded`).toBe(true); + expect(blankRe().test(s), `U+${cp.toString(16)} embedded, via the pattern`).toBe(true); + } + }); + + it('agrees on a corpus, including every run of blanks', () => { + const corpus = ['', 'a', 'ab', ' a', 'a ', ' a ', ' ', ' ', 'a b', '0', '{{record.name}}']; + for (const s of corpus) { + expect(NON_BLANK_STRING(s), JSON.stringify(s)).toBe(blankRe().test(s)); + } + }); + + it('emits `minLength: 1` and the pattern', () => { + const node: Record = { type: 'string' }; + emitProjectableRefinement(node, { pattern: 'non-blank-string' }); + expect(node).toEqual({ type: 'string', minLength: 1, pattern: NON_BLANK_PATTERN }); + }); + + it('never LOWERS an existing minLength', () => { + const node: Record = { type: 'string', minLength: 8 }; + emitProjectableRefinement(node, { pattern: 'non-blank-string' }); + expect(node.minLength).toBe(8); + }); + + it('conjoins through `allOf` rather than replacing a pattern the node already has', () => { + const node: Record = { type: 'string', pattern: '^[a-z_]+$' }; + emitProjectableRefinement(node, { pattern: 'non-blank-string' }); + expect(node.pattern).toBe('^[a-z_]+$'); + expect(node.allOf).toEqual([{ pattern: NON_BLANK_PATTERN }]); + }); + + it('is idempotent — the same arm twice writes one pattern, not an `allOf`', () => { + const node: Record = { type: 'string' }; + emitProjectableRefinement(node, { pattern: 'non-blank-string' }); + emitProjectableRefinement(node, { pattern: 'non-blank-string' }); + expect(node).toEqual({ type: 'string', minLength: 1, pattern: NON_BLANK_PATTERN }); + }); +}); + +describe('the LIVE seam: the published file now states the rule it used to drop', () => { + it('`Expression` publishes the source-or-ast rule, and keeps its object shape', () => { + const node = publish(ExpressionSchema); + expect(node.allOf).toEqual([{ anyOf: [{ required: ['source'] }, { required: ['ast'] }] }]); + expect(node.type).toBe('object'); + expect(Object.keys(node.properties as Record)).toEqual(['dialect', 'source', 'ast', 'meta']); + }); + + it('the card\'s own specimen — `{ dialect: \'cel\' }` — is refused by BOTH sides now', () => { + const doc = { dialect: 'cel' }; + expect(ExpressionSchema.safeParse(doc).success).toBe(false); + expect(requiredOneOfSatisfied(publish(ExpressionSchema), doc)).toBe(false); + }); + + it('⛔ no envelope the runtime ACCEPTS is refused by the emitted keywords', () => { + const node = publish(ExpressionSchema); + const corpus: Array> = [ + { dialect: 'cel', source: 'a == 1' }, + { dialect: 'cel', ast: { kind: 'eq' } }, + { dialect: 'cel', source: 'a == 1', ast: { kind: 'eq' } }, + { dialect: 'cron', source: '0 9 * * 1-5' }, + { dialect: 'template', source: '{{record.name}}', meta: { rationale: 'why' } }, + { dialect: 'cel' }, + { dialect: 'cel', meta: { generatedBy: 'agent' } }, + ]; + for (const doc of corpus) { + const runtimeAccepts = ExpressionSchema.safeParse(doc).success; + const keywordsAccept = requiredOneOfSatisfied(node, doc); + // Equality, not implication: this arm is exact, so a one-sided pin would + // pass a projection that had stopped narrowing at all. + expect(keywordsAccept, `disagreement on ${JSON.stringify(doc)}`).toBe(runtimeAccepts); + } + }); + + it('an evaluated input slot publishes the non-blank rule on BOTH of its arms', () => { + const node = publish(EvaluatedExpressionInputSchema, 'input'); + const [stringArm, objectArm] = node.anyOf as Array>; + expect(stringArm).toMatchObject({ type: 'string', minLength: 1, pattern: NON_BLANK_PATTERN }); + expect((objectArm.properties as Record).source) + .toMatchObject({ type: 'string', minLength: 1, pattern: NON_BLANK_PATTERN }); + }); + + it('a whitespace-only source is refused by BOTH sides, on both arms', () => { + const node = publish(EvaluatedExpressionInputSchema, 'input'); + const [stringArm, objectArm] = node.anyOf as Array>; + const blank = ' '; + expect(EvaluatedExpressionInputSchema.safeParse(blank).success).toBe(false); + expect(new RegExp(stringArm.pattern as string).test(blank)).toBe(false); + expect(EvaluatedExpressionInputSchema.safeParse({ dialect: 'cel', source: blank }).success).toBe(false); + const sourceNode = (objectArm.properties as Record>).source; + expect(new RegExp(sourceNode.pattern as string).test(blank)).toBe(false); + }); + + it('LIT CONTROL — a non-blank source is accepted by both', () => { + const node = publish(EvaluatedExpressionInputSchema, 'input'); + const [stringArm] = node.anyOf as Array>; + expect(EvaluatedExpressionInputSchema.safeParse('a == 1').success).toBe(true); + expect(new RegExp(stringArm.pattern as string).test('a == 1')).toBe(true); + }); +}); + +describe('the ledger measures THIS projection', () => { + it('a DECLARED refinement reads `projected`, and names its pattern', () => { + const schema = z.object({ a: z.string().optional(), b: z.string().optional() }) + .refine(requiredOneOf(['a', 'b']), 'one of a or b'); + const census = collectDroppedRefinements('test/Declared', schema); + expect(census.dropped).toEqual([]); + expect(census.projected).toHaveLength(1); + expect(census.projected[0].declaredPatterns).toEqual(['required-one-of']); + }); + + it('LIT CONTROL — the same shape with an UNDECLARED rule still reads `dropped`', () => { + const schema = z.object({ a: z.string().optional(), b: z.string().optional() }) + .refine((v) => v.a !== undefined || v.b !== undefined, 'one of a or b'); + const census = collectDroppedRefinements('test/Undeclared', schema); + expect(census.projected).toEqual([]); + expect(census.dropped).toHaveLength(1); + expect(census.dropped[0].declaredPatterns).toEqual([]); + }); + + it('a declared refinement one level down is `projected` at its own path', () => { + const schema = z.object({ slot: z.string().refine(NON_BLANK_STRING, 'non-blank') }); + const census = collectDroppedRefinements('test/Nested', schema); + expect(census.dropped).toEqual([]); + expect(census.projected.map((s) => s.path)).toEqual(['slot']); + expect(census.projected[0].declaredPatterns).toEqual(['non-blank-string']); + }); +}); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index d65fb56a116..5db152247ae 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -12,6 +12,10 @@ import { import { MetadataCacheRequestSchema, MetadataCacheResponseSchema } from './http-cache.zod'; import { QUERY_DISTINCT_REMOVED } from '../data/query.zod'; import { retiredKey } from '../shared/retired-key'; +// The closed list of refinements that reach the published JSON Schema (#18670 +// item 2) — `UpdateAiConversationRequest`'s at-least-one rule is declared +// through it, so `api/UpdateAiConversationRequest.json` states it. +import { requiredOneOf } from '../shared/refinement-projection'; import { MetadataItemNameSchema } from '../shared/identifiers.zod'; import { DroppedFieldsEventSchema, QueryWithTransportSchema } from '../data/data-engine.zod'; import { @@ -2962,7 +2966,7 @@ export const AiStreamChunkSchema = lazySchema(() => z.object({ export const UpdateAiConversationRequestSchema = lazySchema(() => z.object({ title: z.string().optional().describe('New title'), metadata: z.record(z.string(), z.unknown()).optional().describe('New metadata'), -}).refine((p) => p.title !== undefined || p.metadata !== undefined, { +}).refine(requiredOneOf(['title', 'metadata']), { message: 'at least one of title or metadata is required', })); diff --git a/packages/spec/src/shared/expression.zod.ts b/packages/spec/src/shared/expression.zod.ts index b92012cbb60..966c718403d 100644 --- a/packages/spec/src/shared/expression.zod.ts +++ b/packages/spec/src/shared/expression.zod.ts @@ -1,6 +1,10 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; +// The closed list of refinements that reach the published JSON Schema (#18670 +// item 2). Both rules below are DECLARED through it, so `json-schema/**` states +// them instead of being silently wider than this file. +import { NON_BLANK_STRING, requiredOneOf } from './refinement-projection'; /** * # Expression Protocol @@ -98,7 +102,7 @@ export const ExpressionSchema = z.object({ ast: z.unknown().optional(), /** Optional authorship metadata. */ meta: ExpressionMetaSchema.optional(), -}).refine(e => e.source !== undefined || e.ast !== undefined, { +}).refine(requiredOneOf(['source', 'ast']), { message: 'Expression requires at least one of `source` or `ast`', }); export type Expression = z.input; @@ -165,7 +169,7 @@ export const EvaluatedExpressionSchema = ExpressionSchema.safeExtend({ * the engine evaluates, and `ast` alone cannot be run. */ source: z.string({ error: () => EVALUATED_EXPRESSION_SOURCE_REQUIRED }) - .refine((source) => source.trim().length > 0, { message: EVALUATED_EXPRESSION_SOURCE_REQUIRED }), + .refine(NON_BLANK_STRING, { message: EVALUATED_EXPRESSION_SOURCE_REQUIRED }), }); export type EvaluatedExpression = z.input; export type EvaluatedExpressionParsed = z.infer; @@ -243,7 +247,7 @@ function evaluatedExpressionInputRefusal(input: unknown): string | undefined { */ export const EvaluatedExpressionInputSchema = z.union([ z.string() - .refine((source) => source.trim().length > 0, { message: EVALUATED_EXPRESSION_SOURCE_REQUIRED }) + .refine(NON_BLANK_STRING, { message: EVALUATED_EXPRESSION_SOURCE_REQUIRED }) .transform((source): EvaluatedExpression => ({ dialect: 'cel', source })), EvaluatedExpressionSchema, ], { error: (issue) => evaluatedExpressionInputRefusal(issue.input) }); @@ -318,7 +322,7 @@ export const TYPED_EXPRESSION_DIALECT_ONLY: Readonly(dialect: D) { return z.string() - .refine((source) => source.trim().length > 0, { message: TYPED_EXPRESSION_SOURCE_REQUIRED[dialect] }) + .refine(NON_BLANK_STRING, { message: TYPED_EXPRESSION_SOURCE_REQUIRED[dialect] }) .transform((source) => ({ dialect, source })); } diff --git a/packages/spec/src/shared/refinement-projection.ts b/packages/spec/src/shared/refinement-projection.ts new file mode 100644 index 00000000000..73ffdead837 --- /dev/null +++ b/packages/spec/src/shared/refinement-projection.ts @@ -0,0 +1,159 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The CLOSED list of refinements that reach the published JSON Schema (#18670 + * item 2) — declared here, beside the rule, so the generator never has to guess + * what a `.refine()` means. + * + * ## The gap this closes, and the only direction it may move + * + * `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same + * record with a `.refine()`, and the same record with an ABORTING `.refine()` + * project byte-identically (measured on zod 4.4.3, the version this package + * resolves). So every rule written as a refinement is enforced by the runtime + * and absent from `packages/spec/json-schema/**` — the tree that ships in the + * `@objectstack/spec` tarball, that `content/docs/references/**` renders from, + * and that an author or an AI validates a document against. The published file + * is therefore WIDER than the contract, which is the silent direction: the + * validator says yes right up to the moment the platform says no. + * + * Each arm below makes the published file state one of those rules. It is a + * correction of the machine-readable declaration and ⛔ NOT a behaviour change: + * every arm's emitted keywords accept EXACTLY the JSON documents its runtime + * rule accepts, so no document the runtime accepts becomes refused. An arm that + * can only approximate its rule does not belong here — it stays dropped and + * annotated as `x-dropped-refinements`, which is what the ledger + * (`dropped-refinements.baseline.json`) holds closed. + * + * ## Why the PREDICATE is built from the DECLARATION, and not the other way + * + * The rule the runtime enforces and the keywords the file publishes have to be + * the same rule, and the ways they can drift are silent in both directions: a + * predicate edited without its declaration publishes a stale contract, a + * declaration edited without its predicate publishes a contract nothing + * enforces. Reading the declaration back OUT of the predicate is not available + * — `.superRefine()` and `.check()` carry no readable function at all (measured: + * their check def holds only `{ check: 'custom' }`, where `.refine()`'s holds + * `{ type, check, fn }`), and a projection that turned on `fn.toString()` would + * be a source-text parser. + * + * So each arm here is a FACTORY: it takes the declaration and returns the + * predicate built from it. {@link requiredOneOf} is the exact case — the keys it + * publishes are the keys its predicate reads, one array, read twice. Where an + * arm cannot derive its predicate (a regex and a `.trim()` are two spellings of + * one set, not one spelling used twice) the equivalence is a PIN rather than a + * comment: `refinement-projection.test.ts` asserts the two agree on every + * ECMA-262 whitespace code point plus a corpus, so a future edit to either side + * fails rather than drifts. + * + * ## What lives here and what does not + * + * This module is declaration only — the vocabulary, the factories, and the + * constants a reader of the emitted file would see. Turning a declaration into + * JSON Schema keywords is the generator's job and lives in + * `scripts/lib/refinement-projection.ts`; nothing in `src/` emits a keyword. + */ + +/** + * One member of the closed list, as declared at the refinement's own call site. + * + * ⛔ Growing this union is a public-contract decision, not a refactor: every arm + * narrows a published artifact. A new arm owes the same three things the two + * below have — a factory or a pinned equivalence, an exact-equality argument in + * its own docblock, and the generator-side keywords that emit it. + */ +export type ProjectableRefinement = + /** + * "at least one of these keys is present" — published as `anyOf` of one + * `required` per key. + * + * Exact in the JSON domain: a key absent from a JSON object is the only way + * for its value to read `undefined`, so `required` and `!== undefined` name + * the same set of documents. A key present with any JSON value — `null` + * included — satisfies both. + */ + | { readonly pattern: 'required-one-of'; readonly keys: readonly string[] } + /** + * "a string with at least one non-whitespace character" — published as + * `minLength: 1` plus {@link NON_BLANK_PATTERN}. + * + * Exact: `String.prototype.trim` removes exactly ECMA-262 WhiteSpace ∪ + * LineTerminator, and `\S` is the complement of that same set, so + * `s.trim().length > 0` and a `\S` search agree on every string. `minLength` + * is redundant beside the pattern and is emitted anyway, because it is the + * keyword a form generator and a reference table read. + */ + | { readonly pattern: 'non-blank-string' }; + +/** Every arm's `pattern` tag, for a reader that needs the list itself. */ +export const PROJECTABLE_REFINEMENT_PATTERNS = ['required-one-of', 'non-blank-string'] as const; + +/** + * The ECMA-262 pattern accepting exactly the strings {@link NON_BLANK_STRING} + * accepts. Unanchored on purpose: a JSON Schema `pattern` is a SEARCH, so this + * reads "somewhere in the string there is a non-whitespace character". + */ +export const NON_BLANK_PATTERN = '\\S'; + +/** + * Declared rule per predicate. Keyed on the function the call site hands to + * `.refine()`, which is the one object that survives everything between the + * declaration and the projection: `clone()` rebuilds a node's constraint bag + * from the same check objects, and a `lazySchema()` Proxy delegates to the real + * internals, so both reach this same function. + */ +const DECLARED = new WeakMap(); + +/** Register `rule` as the predicate for `declared`, and hand `rule` back. */ +function declare boolean>(rule: F, declared: ProjectableRefinement): F { + DECLARED.set(rule, Object.freeze(declared)); + return rule; +} + +/** + * What `rule` was declared to mean, or `undefined` for a rule nobody declared — + * which is every refinement outside the closed list, and is the reading that + * keeps it dropped and annotated. + */ +export function projectableRefinementOf(rule: unknown): ProjectableRefinement | undefined { + return typeof rule === 'function' ? DECLARED.get(rule as object) : undefined; +} + +/** + * "at least one of `keys` is present", as a `.refine()` predicate that also + * declares itself. + * + * The keys are read once into the declaration and the predicate reads them from + * there, so the published `anyOf` and the enforced rule cannot name different + * keys. Spell the slot's own keys at the call site: + * + * ```ts + * z.object({ source: …, ast: … }).refine(requiredOneOf(['source', 'ast']), { + * message: 'Expression requires at least one of `source` or `ast`', + * }) + * ``` + */ +export function requiredOneOf( + keys: readonly [K, ...K[]], +): (value: Readonly>>) => boolean { + const declared: ProjectableRefinement = { pattern: 'required-one-of', keys: Object.freeze([...keys]) }; + const rule = (value: Readonly>>): boolean => + (declared as { keys: readonly string[] }).keys.some( + (key) => (value as Record)[key] !== undefined, + ); + return declare(rule, declared); +} + +/** + * "non-blank after trimming", as a `.refine()` predicate that declares itself — + * the notion of blank the engines' own helpers apply (`source.trim()`), not a + * second one. + * + * One shared function rather than a factory: there is nothing per-call-site to + * declare, and one predicate means one registry entry covering every slot that + * composes it. + */ +export const NON_BLANK_STRING: (source: string) => boolean = declare( + (source: string): boolean => source.trim().length > 0, + { pattern: 'non-blank-string' }, +);