From 74b5af39e1734a58fb9c2bde0e284b4e368f8445 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 16:59:56 +0000 Subject: [PATCH 1/5] feat(spec): a refinement that never reaches the published JSON Schema now makes a noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `z.toJSONSchema()` has no arm for a `custom` check, so every rule written as a `.refine()` / `.superRefine()` is enforced by the runtime and absent from the `json-schema/` tree that ships inside `@objectstack/spec` — a published file WIDER than the Zod type it came from, in the direction where an author's (or an AI's) validator says yes and the platform then says no. Census, measured by the generator itself on zod 4.4.3: 688 refinement sites across 240 published schemas, none of which projected anything, growing from 126 refinement call sites in `packages/spec/src/**`. Nothing about what the schemas accept changes. Each affected file now carries an `x-dropped-refinements` annotation naming its own sites (`x-` keywords are ignored by every validator), the generator reports the population in full on every run, and `dropped-refinements.baseline.json` refuses to let it grow in silence. The detector MEASURES each drop per instance — project the node, project it again with its custom checks removed, compare bytes — rather than asserting it, so a zod release that learns to project refinements turns the ledger red instead of reading as current forever. Narrowing the published shape to match the Zod type is a public-contract change and is deliberately not done here. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude --- .changeset/spooky-poems-repeat.md | 22 + .../spec/dropped-refinements.baseline.json | 1660 +++++++++++++++++ packages/spec/scripts/build-schemas.ts | 188 ++ .../spec/scripts/dropped-refinements.test.ts | 269 +++ .../spec/scripts/lib/dropped-refinements.ts | 462 +++++ 5 files changed, 2601 insertions(+) create mode 100644 .changeset/spooky-poems-repeat.md create mode 100644 packages/spec/dropped-refinements.baseline.json create mode 100644 packages/spec/scripts/dropped-refinements.test.ts create mode 100644 packages/spec/scripts/lib/dropped-refinements.ts diff --git a/.changeset/spooky-poems-repeat.md b/.changeset/spooky-poems-repeat.md new file mode 100644 index 0000000000..ea154703ac --- /dev/null +++ b/.changeset/spooky-poems-repeat.md @@ -0,0 +1,22 @@ +--- +'@objectstack/spec': patch +--- + +Say it out loud when a `.refine()` never reaches the published JSON Schema. + +`z.toJSONSchema()` has no arm for a `custom` check, so every rule written as a +`.refine()` / `.superRefine()` is enforced by the runtime and absent from the +`json-schema/` tree that ships inside this package — a published file that is +WIDER than the Zod type it was generated from, in the direction where an +author's (or an AI's) validator says yes and the platform then says no. Measured +on zod 4.4.3: 688 refinement sites across 240 published schemas, none of which +projected anything. + +Nothing about what the schemas accept changes. Each affected file now carries an +`x-dropped-refinements` annotation naming the paths whose rules it does not +state — `x-` keywords are ignored by every validator, so the accepted document +set is byte-for-byte what it was — and the generator reports the population on +every run and refuses to grow it silently +(`packages/spec/dropped-refinements.baseline.json`). + +Clause-②: no diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json new file mode 100644 index 0000000000..f27aa875bb --- /dev/null +++ b/packages/spec/dropped-refinements.baseline.json @@ -0,0 +1,1660 @@ +{ + "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.", + "measured": { + "zod": "4.4.3", + "publishedSchemasWithDroppedRefinements": 240, + "droppedRefinementSites": 688, + "refinementSitesThatDidProject": 0, + "refinementSitesWithNoJsonFormToCompare": 3 + }, + "entries": { + "ai/BlueprintField": { + "sites": [ + "summaryOperations.filter.lazy" + ] + }, + "ai/BlueprintObject": { + "sites": [ + "fields.element.summaryOperations.filter.lazy" + ] + }, + "ai/BlueprintSummaryOperations": { + "sites": [ + "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" + ] + }, + "ai/SkillTriggerCondition": { + "sites": [ + "" + ] + }, + "ai/SolutionBlueprint": { + "sites": [ + "objects.element.fields.element.summaryOperations.filter.lazy" + ] + }, + "api/AnalyticsQueryRequest": { + "sites": [ + "where.lazy" + ] + }, + "api/AppDefinitionResponse": { + "sites": [ + "data.navigation.element.lazy.options[0]", + "data.navigation.element.lazy.options[0].visible.options[1]" + ] + }, + "api/AssembledInstalledPackage": { + "sites": [ + "manifest.actions.element.in", + "manifest.actions.element.in.params.element.in", + "manifest.connectors.element", + "manifest.dashboards.element.globalFilters.element", + "manifest.dashboards.element.widgets.element", + "manifest.datasets.element", + "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", + "manifest.objects.element.fieldGroups", + "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", + "manifest.permissions.element.rowLevelSecurity.element", + "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", + "manifest.views.element.form.sections.element.in", + "manifest.views.element.form.submitBehavior.options[1].url", + "manifest.views.element.list", + "manifest.views.element.list.bulkActionDefs.element", + "manifest.views.element.list.filter.element", + "manifest.views.element.list.grouping.fields.element.field" + ] + }, + "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" + ] + }, + "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" + ] + }, + "api/CreateImportJobRequest": { + "sites": [ + "" + ] + }, + "api/DisablePackageResponse": { + "sites": [ + "package.manifest.navigationContributions.element.items.element.lazy.options[0]", + "package.manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + ] + }, + "api/EnablePackageResponse": { + "sites": [ + "package.manifest.navigationContributions.element.items.element.lazy.options[0]", + "package.manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + ] + }, + "api/ExportRequest": { + "sites": [ + "left.where.lazy" + ] + }, + "api/FindDataRequest": { + "sites": [ + "query.where.lazy" + ] + }, + "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" + ] + }, + "api/GetInstalledPackageResponse": { + "sites": [ + "data.options[0].manifest.navigationContributions.element.items.element.lazy.options[0]", + "data.options[1].manifest.actions.element.in", + "data.options[1].manifest.actions.element.in.params.element.in", + "data.options[1].manifest.connectors.element", + "data.options[1].manifest.dashboards.element.globalFilters.element", + "data.options[1].manifest.dashboards.element.widgets.element", + "data.options[1].manifest.datasets.element", + "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", + "data.options[1].manifest.permissions.element.rowLevelSecurity.element", + "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", + "data.options[1].manifest.views.element.form.sections.element.in", + "data.options[1].manifest.views.element.form.submitBehavior.options[1].url", + "data.options[1].manifest.views.element.list", + "data.options[1].manifest.views.element.list.bulkActionDefs.element", + "data.options[1].manifest.views.element.list.filter.element", + "data.options[1].manifest.views.element.list.grouping.fields.element.field" + ] + }, + "api/GetObjectPermissionsResponse": { + "sites": [ + "permissions.out" + ] + }, + "api/GetPackageResponse": { + "sites": [ + "package.manifest.navigationContributions.element.items.element.lazy.options[0]", + "package.manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + ] + }, + "api/GetUiViewResponse": { + "sites": [ + "form", + "form.sections.element.in", + "form.submitBehavior.options[1].url", + "list", + "list.bulkActionDefs.element", + "list.conditionalFormatting.element.condition.options[1]", + "list.filter.element", + "list.grouping.fields.element.field" + ] + }, + "api/ImportRequest": { + "sites": [ + "" + ] + }, + "api/InstallPackageRequest": { + "sites": [ + "manifest.navigationContributions.element.items.element.lazy.options[0]", + "manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + ] + }, + "api/InstallPackageResponse": { + "sites": [ + "package.manifest.navigationContributions.element.items.element.lazy.options[0]", + "package.manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + ] + }, + "api/InstalledPackageAtEitherStage": { + "sites": [ + "options[0].manifest.navigationContributions.element.items.element.lazy.options[0]", + "options[1].manifest.actions.element.in", + "options[1].manifest.actions.element.in.params.element.in", + "options[1].manifest.connectors.element", + "options[1].manifest.dashboards.element.globalFilters.element", + "options[1].manifest.dashboards.element.widgets.element", + "options[1].manifest.datasets.element", + "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", + "options[1].manifest.permissions.element.rowLevelSecurity.element", + "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", + "options[1].manifest.views.element.form.sections.element.in", + "options[1].manifest.views.element.form.submitBehavior.options[1].url", + "options[1].manifest.views.element.list", + "options[1].manifest.views.element.list.bulkActionDefs.element", + "options[1].manifest.views.element.list.filter.element", + "options[1].manifest.views.element.list.grouping.fields.element.field" + ] + }, + "api/ListInstalledPackagesResponse": { + "sites": [ + "data.packages.element.options[0].manifest.navigationContributions.element.items.element.lazy.options[0]", + "data.packages.element.options[1].manifest.actions.element.in", + "data.packages.element.options[1].manifest.actions.element.in.params.element.in", + "data.packages.element.options[1].manifest.connectors.element", + "data.packages.element.options[1].manifest.dashboards.element.globalFilters.element", + "data.packages.element.options[1].manifest.dashboards.element.widgets.element", + "data.packages.element.options[1].manifest.datasets.element", + "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", + "data.packages.element.options[1].manifest.views.element.form.sections.element.in", + "data.packages.element.options[1].manifest.views.element.form.submitBehavior.options[1].url", + "data.packages.element.options[1].manifest.views.element.list", + "data.packages.element.options[1].manifest.views.element.list.bulkActionDefs.element", + "data.packages.element.options[1].manifest.views.element.list.filter.element" + ] + }, + "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]" + ] + }, + "api/MetadataTypeInfoResponse": { + "sites": [ + "data.actions.element.in", + "data.actions.element.in.params.element.in", + "data.actions.element.in.visible.options[1].options[1]" + ] + }, + "api/ObjectDefinitionResponse": { + "sites": [ + "data", + "data.actions.element.in", + "data.actions.element.in.params.element.in", + "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", + "data.listViews.valueType.bulkActionDefs.element", + "data.listViews.valueType.filter.element", + "data.listViews.valueType.grouping.fields.element.field", + "data.titleFormat.options[0].in", + "data.titleFormat.options[1]" + ] + }, + "api/PackageInstallRequest": { + "sites": [ + "manifest.navigationContributions.element.items.element.lazy.options[0]", + "manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + ] + }, + "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]" + ] + }, + "api/PackageUpgradeRequest": { + "sites": [ + "manifest.navigationContributions.element.items.element.lazy.options[0]", + "manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + ] + }, + "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": [ + "" + ] + }, + "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" + ] + }, + "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" + ] + }, + "automation/ApprovalNodeConfig": { + "sites": [ + "" + ] + }, + "automation/AssignmentConfig": { + "sites": [ + "assignments.valueType" + ] + }, + "automation/AssignmentExpressionValue": { + "sites": [ + "", + "source" + ] + }, + "automation/AssignmentValue": { + "sites": [ + "" + ] + }, + "automation/EndConfig": { + "sites": [ + "" + ] + }, + "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" + ] + }, + "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" + ] + }, + "automation/NotifyConfig": { + "sites": [ + "" + ] + }, + "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" + ] + }, + "automation/ScreenConfig": { + "sites": [ + "fields.element" + ] + }, + "automation/ScreenFieldConfig": { + "sites": [ + "" + ] + }, + "automation/TimeRelativeTrigger": { + "sites": [ + "" + ] + }, + "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" + ] + }, + "data/AggregationNode": { + "sites": [ + "filter.lazy" + ] + }, + "data/AnalyticsQuery": { + "sites": [ + "where.lazy" + ] + }, + "data/AutoPersistenceConfig": { + "sites": [ + "key", + "path" + ] + }, + "data/ConditionalValidation": { + "sites": [ + "when.options[1]" + ] + }, + "data/ContextToken": { + "sites": [ + "" + ] + }, + "data/ContextTokenPlaceholder": { + "sites": [ + "" + ] + }, + "data/CrossFieldValidation": { + "sites": [ + "condition.options[1]" + ] + }, + "data/CurrencyConfig": { + "sites": [ + "" + ] + }, + "data/DataEngineAggregateOptions": { + "sites": [ + "filter.options[1].lazy" + ] + }, + "data/DataEngineAggregateRequest": { + "sites": [ + "query.in.having.lazy" + ] + }, + "data/DataEngineCountOptions": { + "sites": [ + "filter.options[1].lazy" + ] + }, + "data/DataEngineCountRequest": { + "sites": [ + "query.in.where.options[1].lazy" + ] + }, + "data/DataEngineDeleteOptions": { + "sites": [ + "filter.options[1].lazy" + ] + }, + "data/DataEngineDeleteRequest": { + "sites": [ + "options.in.where.options[1].lazy" + ] + }, + "data/DataEngineFilter": { + "sites": [ + "options[1].lazy" + ] + }, + "data/DataEngineFindOneRequest": { + "sites": [ + "query.in.where.options[1].lazy" + ] + }, + "data/DataEngineFindRequest": { + "sites": [ + "query.in.where.options[1].lazy" + ] + }, + "data/DataEngineQueryOptions": { + "sites": [ + "filter.options[1].lazy" + ] + }, + "data/DataEngineRequest": { + "sites": [ + "options[8].where.options[1].lazy" + ] + }, + "data/DataEngineUpdateOptions": { + "sites": [ + "filter.options[1].lazy" + ] + }, + "data/DataEngineUpdateRequest": { + "sites": [ + "options.in.where.options[1].lazy" + ] + }, + "data/DataEngineVectorFindRequest": { + "sites": [ + "where.options[1].lazy" + ] + }, + "data/Datasource": { + "sites": [ + "" + ] + }, + "data/DateMacroPlaceholder": { + "sites": [ + "" + ] + }, + "data/DateMacroToken": { + "sites": [ + "" + ] + }, + "data/EngineAggregateOptions": { + "sites": [ + "having.lazy" + ] + }, + "data/EngineCountOptions": { + "sites": [ + "where.options[1].lazy" + ] + }, + "data/EngineDeleteOptions": { + "sites": [ + "where.options[1].lazy" + ] + }, + "data/EngineQueryOptions": { + "sites": [ + "where.options[1].lazy" + ] + }, + "data/EngineUpdateOptions": { + "sites": [ + "where.options[1].lazy" + ] + }, + "data/Field": { + "sites": [ + "", + "currencyConfig", + "expression.options[1]", + "relatedListFilter.lazy" + ] + }, + "data/FieldOperators": { + "sites": [ + "$in", + "$nin" + ] + }, + "data/FilePersistenceConfig": { + "sites": [ + "path" + ] + }, + "data/FilterArray": { + "sites": [ + "lazy.options[0].items[0]", + "lazy.options[0].items[1]", + "lazy.options[2].items[0]" + ] + }, + "data/FilterCondition": { + "sites": [ + "lazy" + ] + }, + "data/Hook": { + "sites": [ + "condition.options[1]", + "object" + ] + }, + "data/InlineGridColumn": { + "sites": [ + "readonlyWhen.options[1]" + ] + }, + "data/InstantValue": { + "sites": [ + "" + ] + }, + "data/Lifecycle": { + "sites": [ + "" + ] + }, + "data/LocalStoragePersistenceConfig": { + "sites": [ + "key" + ] + }, + "data/MongoConfig": { + "sites": [ + "", + "authSource", + "database", + "host", + "options", + "url", + "username" + ] + }, + "data/MysqlConfig": { + "sites": [ + "", + "database", + "host", + "url", + "username" + ] + }, + "data/NormalizedFilter": { + "sites": [ + "lazy.$not.options[0].valueType.$in", + "lazy.$not.options[0].valueType.$nin" + ] + }, + "data/Object": { + "sites": [ + "", + "actions.element.in", + "actions.element.in.params.element.in", + "fieldGroups", + "fields.valueType", + "fields.valueType.currencyConfig", + "fields.valueType.expression.options[1]", + "fields.valueType.relatedListFilter.lazy", + "lifecycle", + "listViews.valueType", + "listViews.valueType.bulkActionDefs.element", + "listViews.valueType.filter.element", + "listViews.valueType.grouping.fields.element.field", + "titleFormat.options[0].in", + "titleFormat.options[1]" + ] + }, + "data/ObjectExtension": { + "sites": [ + "", + "fields.valueType", + "fields.valueType.currencyConfig", + "fields.valueType.expression.options[1]", + "fields.valueType.relatedListFilter.lazy" + ] + }, + "data/ObjectFieldGroup": { + "sites": [ + "visibleWhen.options[1]" + ] + }, + "data/PostgresConfig": { + "sites": [ + "", + "applicationName", + "database", + "host", + "schema", + "url", + "username" + ] + }, + "data/Query": { + "sites": [ + "where.lazy" + ] + }, + "data/QueryFilter": { + "sites": [ + "where.lazy" + ] + }, + "data/ReferenceIdValue": { + "sites": [ + "" + ] + }, + "data/RowCrudActionOverride": { + "sites": [ + "visibleWhen.options[1]" + ] + }, + "data/SQLDriverConfig": { + "sites": [ + "", + "sslConfig" + ] + }, + "data/SSLConfig": { + "sites": [ + "" + ] + }, + "data/ScriptValidation": { + "sites": [ + "condition.options[1]" + ] + }, + "data/SelectOption": { + "sites": [ + "visibleWhen.options[1]" + ] + }, + "data/SetOperator": { + "sites": [ + "$in", + "$nin" + ] + }, + "data/SqliteConfig": { + "sites": [ + "filename" + ] + }, + "data/SqliteWasmConfig": { + "sites": [ + "filename" + ] + }, + "data/TursoConfig": { + "sites": [ + "", + "encryptionKey", + "syncUrl", + "url" + ] + }, + "data/ValidationRule": { + "sites": [ + "lazy.options[0].condition.options[1]" + ] + }, + "identity/SCIMError": { + "sites": [ + "schemas" + ] + }, + "identity/SCIMGroup": { + "sites": [ + "schemas" + ] + }, + "identity/SCIMListResponse": { + "sites": [ + "Resources.element.options[0]", + "Resources.element.options[0].schemas", + "Resources.element.options[1].schemas", + "schemas" + ] + }, + "identity/SCIMPatchRequest": { + "sites": [ + "schemas" + ] + }, + "identity/SCIMUser": { + "sites": [ + "", + "schemas" + ] + }, + "integration/DeclarativeConnectorEntry": { + "sites": [ + "" + ] + }, + "kernel/DisablePackageResponse": { + "sites": [ + "package.manifest.navigationContributions.element.items.element.lazy.options[0]", + "package.manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + ] + }, + "kernel/EnablePackageResponse": { + "sites": [ + "package.manifest.navigationContributions.element.items.element.lazy.options[0]", + "package.manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + ] + }, + "kernel/GetPackageResponse": { + "sites": [ + "package.manifest.navigationContributions.element.items.element.lazy.options[0]", + "package.manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + ] + }, + "kernel/InstallPackageRequest": { + "sites": [ + "manifest.navigationContributions.element.items.element.lazy.options[0]", + "manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + ] + }, + "kernel/InstallPackageResponse": { + "sites": [ + "package.manifest.navigationContributions.element.items.element.lazy.options[0]", + "package.manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + ] + }, + "kernel/InstalledPackage": { + "sites": [ + "manifest.navigationContributions.element.items.element.lazy.options[0]", + "manifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + ] + }, + "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]" + ] + }, + "kernel/Manifest": { + "sites": [ + "navigationContributions.element.items.element.lazy.options[0]", + "navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + ] + }, + "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]" + ] + }, + "kernel/OpsDomainModule": { + "sites": [ + "" + ] + }, + "kernel/OpsFilePath": { + "sites": [ + "" + ] + }, + "kernel/OpsPluginStructure": { + "sites": [ + "" + ] + }, + "kernel/Plugin": { + "sites": [ + "" + ] + }, + "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]" + ] + }, + "kernel/UpgradeSnapshot": { + "sites": [ + "previousManifest.navigationContributions.element.items.element.lazy.options[0]", + "previousManifest.navigationContributions.element.items.element.lazy.options[0].visible.options[1]" + ] + }, + "security/CriteriaSharingRule": { + "sites": [ + "condition.options[1]", + "sharedWith" + ] + }, + "security/ExplainRequest": { + "sites": [ + "" + ] + }, + "security/ObjectPermission": { + "sites": [ + "out" + ] + }, + "security/PermissionSet": { + "sites": [ + "objects.valueType.out", + "rowLevelSecurity.element" + ] + }, + "security/RowLevelSecurityPolicy": { + "sites": [ + "" + ] + }, + "security/SharingRule": { + "sites": [ + "condition.options[1]", + "sharedWith" + ] + }, + "security/TenantLayer0Verdict": { + "sites": [ + "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" + ] + }, + "system/AudienceConfig": { + "sites": [ + "" + ] + }, + "system/AuthConfig": { + "sites": [ + "audience" + ] + }, + "system/BucketConfig": { + "sites": [ + "lifecyclePolicy.rules.element" + ] + }, + "system/ChangeSet": { + "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", + "operations.element.options[3].object.actions.element.in.params.element.in", + "operations.element.options[3].object.fieldGroups", + "operations.element.options[3].object.fields.valueType", + "operations.element.options[3].object.lifecycle", + "operations.element.options[3].object.listViews.valueType", + "operations.element.options[3].object.listViews.valueType.bulkActionDefs.element", + "operations.element.options[3].object.listViews.valueType.filter.element", + "operations.element.options[3].object.listViews.valueType.grouping.fields.element.field", + "operations.element.options[3].object.titleFormat.options[0].in", + "operations.element.options[3].object.titleFormat.options[1]" + ] + }, + "system/CreateObjectOperation": { + "sites": [ + "object", + "object.actions.element.in", + "object.actions.element.in.params.element.in", + "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", + "object.listViews.valueType.bulkActionDefs.element", + "object.listViews.valueType.filter.element", + "object.listViews.valueType.grouping.fields.element.field", + "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]" + ] + }, + "system/LifecyclePolicyConfig": { + "sites": [ + "rules.element" + ] + }, + "system/LifecyclePolicyRule": { + "sites": [ + "" + ] + }, + "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", + "options[3].object.actions.element.in.params.element.in", + "options[3].object.fieldGroups", + "options[3].object.fields.valueType", + "options[3].object.lifecycle", + "options[3].object.listViews.valueType", + "options[3].object.listViews.valueType.bulkActionDefs.element", + "options[3].object.listViews.valueType.filter.element", + "options[3].object.listViews.valueType.grouping.fields.element.field", + "options[3].object.titleFormat.options[0].in", + "options[3].object.titleFormat.options[1]" + ] + }, + "system/ObjectStorageConfig": { + "sites": [ + "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]" + ] + }, + "system/SettingsNamespacePayload": { + "sites": [ + "manifest", + "manifest.specifiers.element", + "manifest.visible", + "manifest.visible.options[1]" + ] + }, + "system/Specifier": { + "sites": [ + "", + "visible", + "visible.options[1]" + ] + }, + "system/StackServerConfig": { + "sites": [ + "security.rateLimit" + ] + }, + "system/StackServerSecurity": { + "sites": [ + "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]" + ] + }, + "ui/ActionParam": { + "sites": [ + "in", + "in.visible.options[1]" + ] + }, + "ui/App": { + "sites": [ + "navigation.element.lazy.options[0]", + "navigation.element.lazy.options[0].visible.options[1]" + ] + }, + "ui/BulkActionDef": { + "sites": [ + "", + "visible.options[1]" + ] + }, + "ui/ChartAggregate": { + "sites": [ + "" + ] + }, + "ui/ComponentNavItem": { + "sites": [ + "visible.options[1]" + ] + }, + "ui/Dashboard": { + "sites": [ + "globalFilters.element", + "widgets.element", + "widgets.element.filter.lazy" + ] + }, + "ui/DashboardNavItem": { + "sites": [ + "visible.options[1]" + ] + }, + "ui/DashboardWidget": { + "sites": [ + "", + "filter.lazy" + ] + }, + "ui/Dataset": { + "sites": [ + "", + "filter.lazy", + "include.element" + ] + }, + "ui/DatasetMeasure": { + "sites": [ + "filter.lazy" + ] + }, + "ui/ElementButtonProps": { + "sites": [ + "action.out", + "action.out.params.element.in", + "action.out.params.element.in.visible.options[1]" + ] + }, + "ui/ElementDataSource": { + "sites": [ + "filter.element" + ] + }, + "ui/ElementNumberProps": { + "sites": [ + "filter.element" + ] + }, + "ui/ElementRecordPickerProps": { + "sites": [ + "filter.element" + ] + }, + "ui/FormField": { + "sites": [ + "in.publicPicker.filter.element", + "in.visibleWhen.options[1]" + ] + }, + "ui/FormFieldPublicPicker": { + "sites": [ + "filter.element" + ] + }, + "ui/FormSection": { + "sites": [ + "in", + "in.fields.element.options[1].in.publicPicker.filter.element", + "in.visibleWhen.options[1]" + ] + }, + "ui/FormSelectOption": { + "sites": [ + "visibleWhen.options[1]" + ] + }, + "ui/FormView": { + "sites": [ + "", + "sections.element.in", + "sections.element.in.fields.element.options[1].in.publicPicker.filter.element", + "sections.element.in.visibleWhen.options[1]", + "submitBehavior.options[1].url" + ] + }, + "ui/GlobalFilter": { + "sites": [ + "", + "optionsFrom.filter.lazy" + ] + }, + "ui/GlobalFilterOptionsFrom": { + "sites": [ + "filter.lazy" + ] + }, + "ui/GroupNavItem": { + "sites": [ + "visible.options[1]" + ] + }, + "ui/GroupingConfig": { + "sites": [ + "fields.element.field" + ] + }, + "ui/GroupingField": { + "sites": [ + "field" + ] + }, + "ui/InlineAction": { + "sites": [ + "out", + "out.params.element.in", + "out.params.element.in.visible.options[1]" + ] + }, + "ui/InterfacePageConfig": { + "sites": [ + "filterBy.element" + ] + }, + "ui/JoinedReportBlock": { + "sites": [ + "", + "runtimeFilter.lazy" + ] + }, + "ui/ListView": { + "sites": [ + "", + "bulkActionDefs.element", + "conditionalFormatting.element.condition.options[1]", + "filter.element", + "grouping.fields.element.field" + ] + }, + "ui/NavigationArea": { + "sites": [ + "navigation.element.lazy.options[0]", + "navigation.element.lazy.options[0].visible.options[1]" + ] + }, + "ui/NavigationContribution": { + "sites": [ + "items.element.lazy.options[0]", + "items.element.lazy.options[0].visible.options[1]" + ] + }, + "ui/NavigationItem": { + "sites": [ + "lazy.options[0]", + "lazy.options[0].visible.options[1]" + ] + }, + "ui/ObjectCalendarProps": { + "sites": [ + "filter.element" + ] + }, + "ui/ObjectGanttProps": { + "sites": [ + "filter.element" + ] + }, + "ui/ObjectGridProps": { + "sites": [ + "filter.element" + ] + }, + "ui/ObjectKanbanProps": { + "sites": [ + "filter.element" + ] + }, + "ui/ObjectListView": { + "sites": [ + "", + "bulkActionDefs.element", + "conditionalFormatting.element.condition.options[1]", + "filter.element", + "grouping.fields.element.field" + ] + }, + "ui/ObjectMapProps": { + "sites": [ + "filter.element" + ] + }, + "ui/ObjectMetricProps": { + "sites": [ + "filter.element" + ] + }, + "ui/ObjectNavItem": { + "sites": [ + "visible.options[1]" + ] + }, + "ui/ObjectTreeProps": { + "sites": [ + "filter.element" + ] + }, + "ui/Page": { + "sites": [ + "", + "interfaceConfig.filterBy.element", + "slots.header.options[0].in.type", + "slots.header.options[0].in.visibleWhen.options[1]" + ] + }, + "ui/PageComponent": { + "sites": [ + "in.dataSource.filter.element", + "in.type", + "in.visibleWhen.options[1]" + ] + }, + "ui/PageNavItem": { + "sites": [ + "visible.options[1]" + ] + }, + "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]" + ] + }, + "ui/RecordDetailsProps": { + "sites": [ + "sections.element" + ] + }, + "ui/RecordRelatedListProps": { + "sites": [ + "filter.element" + ] + }, + "ui/Report": { + "sites": [ + "", + "blocks.element", + "runtimeFilter.lazy" + ] + }, + "ui/ReportNavItem": { + "sites": [ + "visible.options[1]" + ] + }, + "ui/UrlNavItem": { + "sites": [ + "visible.options[1]" + ] + }, + "ui/UserFilters": { + "sites": [ + "tabs.element.filter.element" + ] + }, + "ui/View": { + "sites": [ + "form", + "form.sections.element.in", + "form.submitBehavior.options[1].url", + "list", + "list.bulkActionDefs.element", + "list.conditionalFormatting.element.condition.options[1]", + "list.filter.element", + "list.grouping.fields.element.field" + ] + }, + "ui/ViewFilterRule": { + "sites": [ + "" + ] + }, + "ui/ViewItem": { + "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.grouping.fields.element.field", + "options[1].config", + "options[1].config.sections.element.in", + "options[1].config.submitBehavior.options[1].url" + ] + }, + "ui/ViewItemWire": { + "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.grouping.fields.element.field", + "options[1].config", + "options[1].config.sections.element.in", + "options[1].config.submitBehavior.options[1].url" + ] + }, + "ui/ViewTab": { + "sites": [ + "filter.element" + ] + } + } +} diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index 4948f991ee..0803422391 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -45,6 +45,19 @@ import { projectByPruningUnionBranches, type PrunedBranch, } from './lib/union-branch-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 +// nothing guarded at all — a projection WIDER than it, which is the direction an +// author's validator says yes in and the runtime says no. +import { + DROPPED_REFINEMENTS_BASELINE_FILE, + checkDroppedRefinements, + collectDroppedRefinements, + hasDroppedRefinementProblems, + readDroppedRefinementsBaseline, + type RefinementCensusEntry, +} from './lib/dropped-refinements'; // Who owns what under json-schema/. This generator shares that directory with // gen:openapi, and used to clear it by deleting the directory itself (#5371). import { @@ -424,6 +437,13 @@ const branchPrunedProjections: Array<{ readonly pruned: readonly PrunedBranch[]; }> = []; +// Every published schema's refinement census (#18670) — the rules that reach +// the runtime and NOT the file. Collected inside the emit loop rather than +// re-derived afterwards because this loop is the only place that holds both the +// Zod value and the def key the artifact is written under, and a second walk +// keyed by something else is a second thing to keep in step. +const refinementCensus: RefinementCensusEntry[] = []; + // Error messages for schema types that inherently cannot be represented in JSON Schema. // These are expected warnings, not build-breaking errors. const KNOWN_UNSUPPORTED_PATTERNS = [ @@ -530,6 +550,25 @@ 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 + // 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. + const census = collectDroppedRefinements(`${categorySlug}/${schemaName}`, value); + refinementCensus.push(census); + if (census.dropped.length > 0) { + jsonSchema['x-dropped-refinements'] = census.dropped.map((site) => ({ + at: site.path, + type: site.nodeType, + count: site.count, + })); + } + const fileName = `${schemaName}.json`; const filePath = path.join(categoryDir, fileName); @@ -3356,6 +3395,155 @@ if (unemittedSkips.length > 0) { } } +// ─── The dropped-refinement ratchet (#18670) ───────────────────────── +// +// Runs after the never-published ratchet above, and the two populations are +// DISJOINT by construction: that one adjudicates exports this build published +// NOTHING for, this one adjudicates what it DID publish. So neither can mask +// the other, and an export that stops emitting still gets the remedy the +// ratchet above prescribes rather than this block's. +// +// What it holds closed: a rule written as `.refine()` reaches the runtime and +// not the file. `z.toJSONSchema()` has no arm for a `custom` check, so the +// published JSON Schema is WIDER than the Zod type it was generated from — the +// direction in which an author's validator says yes and the platform then says +// 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. +const droppedRefinementsBaseline = readDroppedRefinementsBaseline(PKG_DIR); +if (!droppedRefinementsBaseline) { + console.error(`\n❌ ${DROPPED_REFINEMENTS_BASELINE_FILE} is missing — it is a committed, hand-edited ledger (#18670).`); + console.error( + `\n Without it nothing holds the dropped-refinement population closed, and a rule that\n` + + ` reaches the runtime but not packages/spec/json-schema/** arrives in total silence —\n` + + ` the state #18670 measured. Restore packages/spec/${DROPPED_REFINEMENTS_BASELINE_FILE}\n` + + ` from git rather than regenerating it: it has no generator on purpose (see\n` + + ` scripts/lib/dropped-refinements.ts).`, + ); + process.exit(1); +} + +const droppedRefinementProblems = checkDroppedRefinements({ + census: refinementCensus, + publishedKeys: new Set(generatedSchemas.keys()), + baseline: droppedRefinementsBaseline, +}); + +if (hasDroppedRefinementProblems(droppedRefinementProblems)) { + const { undeclared, miscounted, repaired, vanished, unreasoned } = droppedRefinementProblems; + + if (undeclared.length > 0) { + console.error( + `\n❌ ${undeclared.length} published schema(s) drop a refinement and are not declared in ${DROPPED_REFINEMENTS_BASELINE_FILE}:`, + ); + for (const entry of undeclared) { + console.error(` + ${entry.defKey} (${entry.dropped.length} site(s))`); + for (const site of entry.dropped) { + console.error(` ${site.path || ''} (${site.nodeType}${site.aborting ? ', aborting' : ''})`); + } + } + console.error( + `\n The rule is enforced by the runtime and absent from the published file: a document\n` + + ` the file ACCEPTS can be refused at parse time, and the author — or the AI — that\n` + + ` validated against json-schema/** finds out a release later. The refinement itself is\n` + + ` correct; ⛔ do not delete or weaken it to make this line go away.\n\n` + + ` Declare it by adding to packages/spec/${DROPPED_REFINEMENTS_BASELINE_FILE}:\n\n` + + undeclared + .map( + (entry) => + ` "${entry.defKey}": {\n` + + ` "sites": [${entry.dropped.map((s) => `"${s.path}"`).join(', ')}]\n` + + ` },\n`, + ) + .join(''), + ); + } + + if (miscounted.length > 0) { + console.error(`\n❌ ${miscounted.length} ledger entry(ies) in ${DROPPED_REFINEMENTS_BASELINE_FILE} name a different set of sites:`); + for (const m of miscounted) { + console.error(` ~ ${m.defKey}:`); + for (const site of m.added) console.error(` + ${site || ''}`); + for (const site of m.removed) console.error(` - ${site || ''}`); + } + console.error( + `\n A \`+\` is a new gap: a rule that now reaches the runtime and not the file. A \`-\` is a\n` + + ` gap that closed or a path that moved — good news either way, and the line has to move\n` + + ` with it in the same PR. A ledger that keeps naming sites the build no longer sees has\n` + + ` stopped describing the tree and started covering for it, and the next gap then arrives\n` + + ` inside a list nobody re-read.\n\n` + + ` The corrected entries, in full:\n\n` + + miscounted + .map( + (m) => + ` "${m.defKey}": {\n` + + ` "sites": [${m.observedSites.map((s) => `"${s}"`).join(', ')}]\n` + + ` },\n`, + ) + .join(''), + ); + } + + if (repaired.length > 0) { + console.error(`\n❌ ${repaired.length} ledger entry(ies) in ${DROPPED_REFINEMENTS_BASELINE_FILE} drop NOTHING now:`); + for (const defKey of repaired) console.error(` - ${defKey}`); + console.error( + `\n Good news, and the line goes with it — in this same PR. Either the refinement was\n` + + ` removed, or the projection learned to emit what it constrains. Say which in the PR:\n` + + ` the second is the repair this ledger exists to become unnecessary for.`, + ); + } + + if (vanished.length > 0) { + console.error(`\n❌ ${vanished.length} ledger entry(ies) in ${DROPPED_REFINEMENTS_BASELINE_FILE} name no published schema:`); + for (const defKey of vanished) console.error(` - ${defKey}`); + console.error( + `\n The schema was removed, renamed, or stopped being published altogether. Delete the\n` + + ` line (a rename gets a new line under the new key), so the ledger keeps naming exactly\n` + + ` the population this build measures.`, + ); + } + + if (unreasoned.length > 0) { + console.error(`\n❌ ${unreasoned.length} ledger entry(ies) carry an empty \`sites\` list:`); + for (const defKey of unreasoned) console.error(` - ${defKey}`); + console.error( + `\n An entry that records only that a schema IS in the population is a count wearing a\n` + + ` ledger's shape. The site paths are the whole instrument: they are what makes a new\n` + + ` gap legible as a line in a diff instead of a number going up by one.`, + ); + } + + process.exit(1); +} + +// The accepted population, reported in full on every run — the same discipline +// as the never-published ledger above, and for the same reason: a population +// that passes in silence is the silence this ratchet was built to end. +const droppedSiteTotal = refinementCensus.reduce((sum, entry) => sum + entry.dropped.length, 0); +const droppedFiles = refinementCensus.filter((entry) => entry.dropped.length > 0); +const projectedSiteTotal = refinementCensus.reduce((sum, entry) => sum + entry.projected.length, 0); +const undecidableSiteTotal = refinementCensus.reduce((sum, entry) => sum + entry.undecidable.length, 0); +if (droppedSiteTotal > 0) { + console.log( + `\n🔇 ${droppedSiteTotal} refinement site(s) across ${droppedFiles.length} published schema(s) reach the ` + + `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.`, + ); + console.log( + ` Also measured this run: ${projectedSiteTotal} refinement site(s) DID reach the file, ` + + `${undecidableSiteTotal} had no JSON form on either side to compare.`, + ); +} + // ─── 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 new file mode 100644 index 0000000000..e15209ae29 --- /dev/null +++ b/packages/spec/scripts/dropped-refinements.test.ts @@ -0,0 +1,269 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pins the dropped-refinement detector and its ratchet (#18670). + * + * ── What is being pinned, and why a pin rather than a comment ───────────── + * The detector's whole claim is that a rule written as `.refine()` reaches the + * runtime and NOT the published JSON Schema. That claim rests on a property of + * zod — `toJSONSchema()` has no arm for a `custom` check — which is exactly the + * kind of thing a dependency bump repairs without telling anyone. So the + * premise is asserted here beside a LIT CONTROL that must keep hitting: a + * `.min(1)` moves the projected bytes. A run where BOTH are identical is an + * instrument that has stopped measuring, not a tree with no refinements, and + * only the control can tell those two apart. + * + * ── The detector must be able to FIRE and to STAY SILENT ───────────────── + * Both halves are asserted, on synthetic graphs and on the live one: a schema + * carrying a refinement produces a site at a named path, and a schema whose + * only constraint is projectable produces none. A detector that reports every + * node, or no node, passes neither half. + * + * ── The two false readings measured while building it ──────────────────── + * 1. `clone()` recomputes the constraint bag from the check list — which is + * what makes the differential meaningful — but it does NOT carry the + * `.describe()` text, which lives in `z.globalRegistry` keyed by instance. + * Without the meta copy, every described node reads as `projected` on a + * description that was never in question. + * 2. Walking a `lazy` node's `_cachedInner` memo reaches a SECOND instance of + * the same graph, whose recursive `$ref` layout differs from the first's. + * All 80 sites the first build of this walker called `projected` were that + * — a difference in the artifact, never in what the refinement constrains. + */ +import { describe, expect, it } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { z } from 'zod'; +import { + DROPPED_REFINEMENTS_BASELINE_FILE, + checkDroppedRefinements, + collectDroppedRefinements, + hasDroppedRefinementProblems, + readDroppedRefinementsBaseline, +} from './lib/dropped-refinements'; +import { ContextTokenSchema } from '../src/data/context-tokens.zod'; +import { AggregationFunction } from '../src/data/query.zod'; + +const PKG_DIR = path.resolve(__dirname, '..'); +const project = (schema: z.ZodType): string => + JSON.stringify(z.toJSONSchema(schema, { target: 'draft-2020-12' })); + +describe('the premise: zod projects no `custom` check', () => { + it('a plain record, a refined one and an ABORTING refined one are byte-identical', () => { + const plain = z.record(z.string(), z.unknown()); + const refined = z.record(z.string(), z.unknown()).refine((v) => !('dialect' in v), 'no dialect'); + const aborting = z.record(z.string(), z.unknown()).superRefine((v, ctx) => { + if ('dialect' in v) { + ctx.addIssue({ code: 'custom', message: 'no dialect', fatal: true }); + return z.NEVER; + } + return undefined; + }); + expect(project(refined)).toBe(project(plain)); + expect(project(aborting)).toBe(project(plain)); + }); + + it('LIT CONTROL — a constraint zod DOES project moves the bytes', () => { + // Without this, the assertion above passes just as well on a broken + // `toJSONSchema` that returns `{}` for everything. + expect(project(z.string().min(1))).not.toBe(project(z.string())); + expect(project(z.string().min(1))).toContain('"minLength":1'); + }); + + it('the runtime enforces the rule the projection dropped', () => { + const refined = z.record(z.string(), z.unknown()).refine((v) => !('dialect' in v), 'no dialect'); + expect(refined.safeParse({ dialect: 'cel' }).success).toBe(false); + expect(z.record(z.string(), z.unknown()).safeParse({ dialect: 'cel' }).success).toBe(true); + }); +}); + +describe('the detector FIRES', () => { + it('names the path of a refinement on a property', () => { + const entry = collectDroppedRefinements( + 't/Refined', + z.object({ a: z.string().refine((v) => v.trim().length > 0, 'non-blank') }), + ); + expect(entry.dropped.map((s) => s.path)).toEqual(['a']); + expect(entry.dropped[0].verdict).toBe('dropped'); + expect(entry.dropped[0].nodeType).toBe('string'); + expect(entry.projected).toHaveLength(0); + }); + + it('reports a refinement on the export itself at the empty path', () => { + const entry = collectDroppedRefinements('t/Root', z.object({ a: z.string() }).refine(() => true)); + expect(entry.dropped.map((s) => s.path)).toEqual(['']); + }); + + it('says when a refinement ABORTS the parse, and when it does not', () => { + const aborting = collectDroppedRefinements( + 't/Abort', + z.object({ a: z.string() }).refine(() => false, { abort: true }), + ); + expect(aborting.dropped).toHaveLength(1); + expect(aborting.dropped[0].aborting).toBe(true); + + const plain = collectDroppedRefinements('t/Plain', z.object({ a: z.string() }).refine(() => false)); + expect(plain.dropped[0].aborting).toBe(false); + }); + + it('⚠️ an `abort` spelled INSIDE the function body is invisible to the flag', () => { + // `ctx.addIssue({ fatal: true })` is a property of the issue the function + // raises at parse time, not of the check the graph carries — so the flag + // reads `false` for it. Pinned rather than left to be rediscovered: the + // flag is a report detail and never the verdict, and the site is still a + // DROP, which is the reading everything downstream turns on. + const entry = collectDroppedRefinements( + 't/FatalInBody', + z.object({ a: z.string() }).superRefine((_v, ctx) => { + ctx.addIssue({ code: 'custom', message: 'no', fatal: true }); + }), + ); + expect(entry.dropped).toHaveLength(1); + expect(entry.dropped[0].aborting).toBe(false); + expect(entry.dropped[0].verdict).toBe('dropped'); + }); + + it('fires on the LIVE graph — ContextTokenSchema carries a refinement no reader sees', () => { + const entry = collectDroppedRefinements('data/ContextToken', ContextTokenSchema); + expect(entry.dropped.length).toBeGreaterThanOrEqual(1); + expect(entry.projected).toHaveLength(0); + // And the rule really is enforced on the other side of the gap. + expect(ContextTokenSchema.safeParse('not-a-context-token').success).toBe(false); + }); +}); + +describe('the detector STAYS SILENT', () => { + it('a schema whose only constraints project reports nothing', () => { + const entry = collectDroppedRefinements('t/Clean', z.object({ a: z.string().min(1), b: z.number().int() })); + expect(entry.dropped).toHaveLength(0); + expect(entry.projected).toHaveLength(0); + expect(entry.undecidable).toHaveLength(0); + }); + + it('is silent on the LIVE graph for a schema with no refinement', () => { + const entry = collectDroppedRefinements('data/AggregationFunction', AggregationFunction); + expect(entry.dropped).toHaveLength(0); + expect(entry.projected).toHaveLength(0); + }); +}); + +describe('the differential isolates the refinement, not the node', () => { + it('a node carrying BOTH a projectable constraint and a refinement is still a drop', () => { + const entry = collectDroppedRefinements('t/Mixed', z.object({ a: z.string().min(3).refine(() => true) })); + expect(entry.dropped.map((s) => s.path)).toEqual(['a']); + // The projected half is untouched — the verdict is about the refinement. + expect(project(z.string().min(3).refine(() => true))).toContain('"minLength":3'); + }); + + it('a described node is a drop, not a projection (the meta-copy regression)', () => { + const entry = collectDroppedRefinements( + 't/Described', + z.object({ a: z.string().refine(() => true).describe('what this field means') }), + ); + expect(entry.projected).toHaveLength(0); + expect(entry.dropped.map((s) => s.path)).toEqual(['a']); + }); + + it('a recursive schema reports its refinement ONCE (the `_cachedInner` regression)', () => { + type Node = { name: string; child?: Node }; + const NodeSchema: z.ZodType = z.lazy(() => + z.object({ + name: z.string().refine((v) => v.trim().length > 0, 'non-blank'), + child: NodeSchema.optional(), + }), + ); + // Resolve the lazy the way the generator does, so `_cachedInner` is populated. + z.toJSONSchema(NodeSchema, { target: 'draft-2020-12' }); + const entry = collectDroppedRefinements('t/Recursive', NodeSchema); + expect(entry.dropped).toHaveLength(1); + expect(entry.projected).toHaveLength(0); + }); +}); + +describe('the ratchet adjudicates against the ledger', () => { + const site = (path: string) => ({ path, nodeType: 'string', count: 1, aborting: false, verdict: 'dropped' as const }); + const census = (defKey: string, paths: string[]) => ({ + defKey, + dropped: paths.map(site), + projected: [], + undecidable: [], + }); + + it('is green when the ledger names exactly what the build sees', () => { + const problems = checkDroppedRefinements({ + census: [census('a/One', ['x'])], + publishedKeys: new Set(['a/One']), + baseline: { entries: { 'a/One': { sites: ['x'] } } }, + }); + expect(hasDroppedRefinementProblems(problems)).toBe(false); + }); + + it('refuses a drop nobody declared', () => { + const problems = checkDroppedRefinements({ + census: [census('a/One', ['x'])], + publishedKeys: new Set(['a/One']), + baseline: { entries: {} }, + }); + expect(problems.undeclared.map((e) => e.defKey)).toEqual(['a/One']); + }); + + it('names a site that ARRIVED and one that LEFT, separately', () => { + const problems = checkDroppedRefinements({ + census: [census('a/One', ['x', 'z'])], + publishedKeys: new Set(['a/One']), + baseline: { entries: { 'a/One': { sites: ['x', 'y'] } } }, + }); + expect(problems.miscounted).toHaveLength(1); + expect(problems.miscounted[0].added).toEqual(['z']); + expect(problems.miscounted[0].removed).toEqual(['y']); + expect(problems.miscounted[0].observedSites).toEqual(['x', 'z']); + }); + + it('sees a second site at the SAME path — a set difference alone cannot', () => { + const problems = checkDroppedRefinements({ + census: [census('a/One', ['x', 'x'])], + publishedKeys: new Set(['a/One']), + baseline: { entries: { 'a/One': { sites: ['x'] } } }, + }); + expect(problems.miscounted).toHaveLength(1); + expect(problems.miscounted[0].observedSites).toEqual(['x', 'x']); + }); + + it('separates a REPAIRED schema from a VANISHED one', () => { + const problems = checkDroppedRefinements({ + census: [], + publishedKeys: new Set(['a/Repaired']), + baseline: { entries: { 'a/Repaired': { sites: ['x'] }, 'a/Gone': { sites: ['y'] } } }, + }); + expect(problems.repaired).toEqual(['a/Repaired']); + expect(problems.vanished).toEqual(['a/Gone']); + }); + + it('refuses an entry that records membership and no sites', () => { + const problems = checkDroppedRefinements({ + census: [census('a/One', ['x'])], + publishedKeys: new Set(['a/One']), + baseline: { entries: { 'a/One': { sites: [] } } }, + }); + expect(problems.unreasoned).toEqual(['a/One']); + }); +}); + +describe('the committed ledger', () => { + it('names at least one site per entry, and its header totals match its body', () => { + const baseline = readDroppedRefinementsBaseline(PKG_DIR); + expect(baseline).not.toBeNull(); + const entries = Object.entries(baseline!.entries); + expect(entries.length).toBeGreaterThan(0); + for (const [defKey, entry] of entries) { + expect(entry.sites.length, `${defKey} records no site`).toBeGreaterThan(0); + } + const raw = JSON.parse( + fs.readFileSync(path.join(PKG_DIR, DROPPED_REFINEMENTS_BASELINE_FILE), 'utf8'), + ) as { measured: { publishedSchemasWithDroppedRefinements: number; droppedRefinementSites: number } }; + expect(raw.measured.publishedSchemasWithDroppedRefinements).toBe(entries.length); + expect(raw.measured.droppedRefinementSites).toBe( + entries.reduce((sum, [, entry]) => sum + entry.sites.length, 0), + ); + }); +}); diff --git a/packages/spec/scripts/lib/dropped-refinements.ts b/packages/spec/scripts/lib/dropped-refinements.ts new file mode 100644 index 0000000000..1e9430d823 --- /dev/null +++ b/packages/spec/scripts/lib/dropped-refinements.ts @@ -0,0 +1,462 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The dropped-refinement ratchet (#18670) — a `.refine()` whose rule reaches the + * runtime and NOT the published JSON Schema, held to a declared population + * instead of nothing at all. + * + * ## The blind spot this closes, stated exactly + * + * `z.toJSONSchema()` has no projection for a `custom` check. Measured on zod + * 4.4.3, the version this package resolves: a plain record, the same record + * with a `.refine()`, and the same record with an ABORTING `.refine()` all + * produce byte-identical output. So every rule written as a refinement is + * enforced by the runtime and absent from `packages/spec/json-schema/**` — the + * tree that ships inside the `@objectstack/spec` tarball (`files[]`), that + * `content/docs/references/**` renders from, and that an author or an AI + * validates against. + * + * The direction of the gap is what makes it a trap rather than a nit. The + * published surface is **WIDER** than the runtime: a document the file accepts + * can be refused at parse time, and the author's validator says yes right up to + * the moment the platform says no. #16431 (a) already closed the mirror image — + * a projection NARROWER than the Zod type, recorded on the artifact as + * `x-unprojectable-branches` — and the sibling reasoning is quoted there: "a + * schema that is NARROWER than its Zod type with nothing saying so is the same + * silence this card was filed about". This is the other side of it, and the + * silent direction is this one. + * + * ## What this module is, and what it deliberately is NOT + * + * 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. + * + * 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. + * + * ## Why the verdict is MEASURED per instance, never assumed + * + * `collectDroppedRefinements` does not trust the sentence at the top of this + * docblock. For every node carrying a `custom` check it builds the same node + * WITHOUT those checks — `clone()` recomputes the constraint bag from the + * check list, which is what makes the comparison meaningful — and compares the + * two projections byte for byte. Identical means the rule reached no reader; + * different means zod projected something after all and the node is reported as + * `projected`, not as a gap. A detector that asserted the drop instead of + * measuring it would keep reading as current through the zod upgrade that fixes + * it, which is the failure mode it exists to prevent one level down. + * + * ## Shrink-only in BOTH directions + * + * Same discipline as `unemitted-schemas.baseline.json`: + * + * - a published schema with dropped refinements that is NOT recorded fails + * the build — a new gap has to be a reviewed line in a diff; + * - a recorded `count` the build does not observe ALSO fails, in either + * direction. A ledger that keeps saying 3 while the tree grew to 4 has + * stopped describing the tree, and the 4th arrives inside a number nobody + * re-read. + * + * ## Why the ledger is HAND-EDITED and has no `gen:` script + * + * Identical to `unemitted-schemas.baseline.json`: a generator would let a new + * gap be admitted by running a command instead of by a decision. Every entry + * carries a `reason` in prose and the gate requires it to be non-empty, because + * a baseline that records only a COUNT lets the next gap slip in behind a + * repaired one with nobody able to see which was replaced. + */ +import fs from 'fs'; +import path from 'path'; +import { z } from 'zod'; + +/** 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. + */ +export const CUSTOM_CHECK_KIND = 'custom'; + +/** How deep the graph walk goes before it stops descending. */ +const MAX_DEPTH = 14; + +/** One node that carries at least one refinement, and what became of it. */ +export interface RefinementSite { + /** + * Where the node sits under its export, in reading order — `''` for the + * export itself, else e.g. `condition.anyOf[0]` or `properties.rate`. + */ + readonly path: string; + /** The zod def type of the node, e.g. `record`, `object`, `string`. */ + readonly nodeType: string; + /** How many `custom` checks this node carries. */ + readonly count: number; + /** + * True when any of them carries the check-level `abort` flag — + * `.refine(fn, { abort: true })` — i.e. stops the parse outright. + * + * ⚠️ A `ctx.addIssue({ fatal: true })` written INSIDE a `superRefine` body is + * a property of the issue raised at parse time, not of the check the graph + * carries, so it reads `false` here. That is a report detail and never the + * verdict: such a site is still a DROP, which is what everything downstream + * turns on. + */ + readonly aborting: boolean; + /** + * `dropped` — removing the refinements leaves the projection byte-identical. + * `projected` — the projection changed, so the rule DID reach a reader. + * `undecidable` — the node has no JSON form in either io direction, so the + * comparison has no two sides. Reported, never counted as a gap. + */ + readonly verdict: 'dropped' | 'projected' | 'undecidable'; +} + +/** Every refinement site under one published schema. */ +export interface RefinementCensusEntry { + /** `category/SchemaName` — the published file, e.g. `system/TraceSamplingConfig`. */ + readonly defKey: string; + /** Sites whose rule reached no reader. The population this ratchet holds closed. */ + readonly dropped: readonly RefinementSite[]; + /** Sites zod projected something for. Reported so a zod upgrade is VISIBLE. */ + readonly projected: readonly RefinementSite[]; + /** Sites with no JSON form on either side of the comparison. */ + readonly undecidable: readonly RefinementSite[]; +} + +/** One recorded member of the accepted population. */ +export interface DroppedRefinementsEntry { + /** + * The paths, under this published schema, at which a refinement is dropped — + * the same strings the artifact's `x-dropped-refinements` names. Re-checked + * against every build, in both directions. + * + * ⚠️ The unit is the SITE and not a count, deliberately. A count cannot tell + * "a refinement moved" from "one arrived and one left", and a ledger of 237 + * numbers is a ledger nobody can read a diff of. The reason each site is + * here is the same for all of them and is written once, in this module's + * docblock and in the ledger's own `description`: zod has no projection for a + * `custom` check. A per-entry `reason` repeated 682 times would be prose that + * says nothing about the entry it sits on, which is the failure a required + * `reason` exists to prevent, arrived at from the other side. + */ + readonly sites: readonly string[]; +} + +/** The committed ledger's shape. */ +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. + * + * `clone()` re-runs the type's initialiser over the check list, so the + * constraint bag is rebuilt rather than copied — that is what makes the + * comparison in `verdictFor` mean anything (a clone that merely copied the bag + * would report every node as `dropped`, including `.min(1)`, and the lit + * control in the census would not catch it because the control asserts the + * opposite direction). The `.describe()` text lives in `z.globalRegistry`, + * keyed by instance, so it is carried across by hand: without it every + * described node reads as `projected` on a description that was never in + * question. + */ +function withoutCustomChecks(schema: z.ZodType): z.ZodType | null { + const def = defOf(schema); + if (!def || !Array.isArray(def.checks)) return null; + const kept = def.checks.filter((c) => checkKind(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 }); + const meta = z.globalRegistry.get(schema); + if (meta) z.globalRegistry.add(stripped, meta); + return stripped; +} + +/** `toJSONSchema` in the generator's own io ladder, or `null` when neither side has a JSON form. */ +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 })); + } catch { + // Try the other direction — the generator does the same, for the same reason. + } + } + return null; +} + +function verdictFor(schema: z.ZodType): RefinementSite['verdict'] { + const stripped = withoutCustomChecks(schema); + if (!stripped) return 'undecidable'; + const before = projectOrNull(schema); + const after = projectOrNull(stripped); + if (before === null || after === null) return 'undecidable'; + return before === after ? 'dropped' : 'projected'; +} + +/** + * Every schema a def references, each with the path segment that reaches it. + * + * Generic on purpose — it reads the def's own plain values rather than + * enumerating node kinds, so a kind zod adds later is walked rather than + * silently skipped (the reasoning `zodChildSchemas` records, #5317). + * + * ⛔ It deliberately does NOT follow `_zod.parent`. A check-clone's parent is + * the SAME node one refinement earlier, so following it would report + * `x.refine(a).refine(b)` as two sites, one of which no parent schema embeds. + */ +function labelledChildren(schema: z.ZodType): Array<{ label: string; schema: z.ZodType }> { + const out: Array<{ label: string; schema: z.ZodType }> = []; + const def = defOf(schema); + if (!def) return out; + const seen = new Set(); + const walk = (label: string, value: unknown): void => { + if (value == null) return; + if (value instanceof z.ZodType) { + out.push({ label, schema: value }); + return; + } + if (typeof value !== 'object') return; + if (seen.has(value)) return; + seen.add(value); + if (Array.isArray(value)) { + value.forEach((item, i) => walk(`${label}[${i}]`, item)); + return; + } + if (value instanceof Map) { + for (const [key, item] of value) walk(`${label}.${String(key)}`, item); + return; + } + const proto = Object.getPrototypeOf(value); + if (proto === Object.prototype || proto === null) { + for (const [key, item] of Object.entries(value)) walk(label ? `${label}.${key}` : key, item); + } + }; + for (const [key, value] of Object.entries(def)) { + // `checks` is the node's own rule list, not an edge to a child schema. + // A `_`-prefixed def key is zod's own memo — `_cachedInner` on a `lazy` + // node holds the resolved target, and descending into it walks a SECOND + // instance of a graph the `getter` edge below already reaches. Measured on + // the shipped tree: all 80 sites this walk classified `projected` were + // reached through `_cachedInner`, and every one of them was the recursive + // `$ref` layout differing between two instances of the same schema — a + // difference in the ARTIFACT, never in what the refinement constrains. + if (key === 'checks' || key.startsWith('_')) continue; + walk(key, value); + } + if (def.type === 'lazy' && typeof def.getter === 'function') { + try { + const inner = (def.getter as () => unknown)(); + if (inner instanceof z.ZodType) out.push({ label: 'lazy', schema: inner }); + } catch { + // An unresolvable lazy getter has no graph to traverse. + } + } + return out; +} + +/** + * Strip the two def-shape segments that carry no information for a reader — + * `shape` (an object's property bag) and `innerType` (an optional / default / + * nullable / readonly wrapper, which stacks, so this is a per-segment filter + * and not a regex: `.optional().default()` produces `innerType.innerType.` and + * a single global replace leaves the second one standing) — and keep every + * segment that does say something. `element`, `options[i]`, `valueType`, + * `in`/`out` and `left`/`right` all say WHERE under the published document the + * rule sits, which is the census question. + */ +function readablePath(raw: string): string { + return raw + .split('.') + .filter((segment) => segment !== '' && segment !== 'shape' && segment !== 'innerType') + .join('.'); +} + +/** + * Walk one published schema and classify every refinement under it. + * + * The visited set is per export and holds schema INSTANCES, so a shared + * sub-schema is reported once per published file that reaches it — which is the + * census question ("which published files does the gap land on"), not "how many + * distinct nodes exist". + */ +export function collectDroppedRefinements(defKey: string, root: z.ZodType): RefinementCensusEntry { + const dropped: RefinementSite[] = []; + const projected: RefinementSite[] = []; + const undecidable: RefinementSite[] = []; + const visited = new Set(); + + const queue: Array<{ schema: z.ZodType; path: string; depth: number }> = [ + { schema: root, path: '', depth: 0 }, + ]; + while (queue.length > 0) { + const { schema, path, depth } = queue.shift()!; + if (visited.has(schema)) continue; + visited.add(schema); + + const customs = customChecksOf(schema); + if (customs.length > 0) { + const site: RefinementSite = { + path: readablePath(path), + nodeType: String(defOf(schema)?.type ?? 'unknown'), + count: customs.length, + aborting: customs.some(checkAborts), + verdict: verdictFor(schema), + }; + if (site.verdict === 'dropped') dropped.push(site); + else if (site.verdict === 'projected') projected.push(site); + else undecidable.push(site); + } + + if (depth >= MAX_DEPTH) continue; + for (const child of labelledChildren(schema)) { + if (visited.has(child.schema)) continue; + queue.push({ + schema: child.schema, + path: path ? `${path}.${child.label}` : child.label, + depth: depth + 1, + }); + } + } + + const byPath = (a: RefinementSite, b: RefinementSite): number => a.path.localeCompare(b.path); + return { + defKey, + dropped: [...dropped].sort(byPath), + projected: [...projected].sort(byPath), + undecidable: [...undecidable].sort(byPath), + }; +} + +/** How many refinement sites one census entry drops. */ +export function droppedCount(entry: RefinementCensusEntry): number { + return entry.dropped.length; +} + +/** One ledger entry whose recorded site list is not the one this build observes. */ +export interface MiscountedEntry { + readonly defKey: string; + /** Sites this build sees that the ledger does not name — new gaps. */ + readonly added: readonly string[]; + /** Sites the ledger names that this build does not see — closed, or moved. */ + readonly removed: readonly string[]; + /** What the corrected entry should say, in full. */ + readonly observedSites: readonly string[]; +} + +/** Everything the gate found wrong with the ledger, in one pass. */ +export interface DroppedRefinementProblems { + /** Drops nobody declared — the growth this ratchet refuses. */ + readonly undeclared: readonly RefinementCensusEntry[]; + /** Declared with a site set this build does not observe, in either direction. */ + readonly miscounted: readonly MiscountedEntry[]; + /** Recorded, but the schema drops nothing now. Delete the line. */ + readonly repaired: readonly string[]; + /** Recorded, but no such schema is published any more. Delete the line. */ + readonly vanished: readonly string[]; + /** Recorded with an empty `sites` list — a membership bit pretending to be a ledger. */ + readonly unreasoned: readonly string[]; +} + +/** True when nothing above needs saying. */ +export function hasDroppedRefinementProblems(p: DroppedRefinementProblems): boolean { + return ( + p.undeclared.length > 0 || + p.miscounted.length > 0 || + p.repaired.length > 0 || + p.vanished.length > 0 || + p.unreasoned.length > 0 + ); +} + +/** + * Adjudicate one build's observed population against the committed ledger. + * + * `publishedKeys` is every def key this build emitted, which is what separates + * "this schema was repaired" from "this schema is no longer published" — two + * states whose remedy is the same line deletion but whose PR description is not. + */ +export function checkDroppedRefinements(args: { + readonly census: readonly RefinementCensusEntry[]; + readonly publishedKeys: ReadonlySet; + readonly baseline: DroppedRefinementsBaseline; +}): DroppedRefinementProblems { + const { census, publishedKeys, baseline } = args; + const observed = new Map(census.filter((e) => e.dropped.length > 0).map((e) => [e.defKey, e])); + + const undeclared = [...observed.values()].filter((e) => !(e.defKey in baseline.entries)); + const miscounted: MiscountedEntry[] = []; + const repaired: string[] = []; + const vanished: string[] = []; + const unreasoned: string[] = []; + + for (const [defKey, entry] of Object.entries(baseline.entries)) { + if (entry.sites.length === 0) unreasoned.push(defKey); + const seen = observed.get(defKey); + if (seen) { + const observedSites = seen.dropped.map((site) => site.path); + const recorded = new Set(entry.sites); + const added = observedSites.filter((site) => !recorded.has(site)); + const seenSet = new Set(observedSites); + const removed = entry.sites.filter((site) => !seenSet.has(site)); + // A repeated path is a real second site on the same node position, so the + // lengths are compared as well as the sets — two sites at one path differ + // from one, and a set difference alone cannot see it. + if (added.length > 0 || removed.length > 0 || observedSites.length !== entry.sites.length) { + miscounted.push({ defKey, added, removed, observedSites }); + } + continue; + } + if (publishedKeys.has(defKey)) repaired.push(defKey); + else vanished.push(defKey); + } + + return { undeclared, miscounted, repaired, vanished, unreasoned }; +} + +/** Read the committed ledger, or `null` when the file is absent. */ +export function readDroppedRefinementsBaseline(pkgDir: string): DroppedRefinementsBaseline | null { + const file = path.join(pkgDir, DROPPED_REFINEMENTS_BASELINE_FILE); + if (!fs.existsSync(file)) return null; + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as { entries?: unknown }; + const entries = parsed.entries; + if (typeof entries !== 'object' || entries === null || Array.isArray(entries)) { + throw new Error(`${DROPPED_REFINEMENTS_BASELINE_FILE}: "entries" must be an object of key -> { count, reason }`); + } + for (const [key, value] of Object.entries(entries as Record)) { + const entry = value as Partial; + if (!Array.isArray(entry?.sites) || entry.sites.some((site) => typeof site !== 'string')) { + throw new Error(`${DROPPED_REFINEMENTS_BASELINE_FILE}: entry "${key}" needs a \`sites\` array of path strings`); + } + } + return { entries: entries as Readonly> }; +} From a0127cfe26835b59d48313bb368685e8a504ee27 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 17:51:21 +0000 Subject: [PATCH 2/5] chore(spec): record the 49 groupByField refinement sites the sibling padding refusal added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging origin/main brings `fix(spec)!: refuse a padded groupByField on kanban, gantt and timeline` (12bb6727fd) into this branch. That commit put a `superRefine` on `KanbanConfigSchema.groupByField`, `GanttConfigSchema.groupByField` and `TimelineConfigSchema.groupByField`, and `z.toJSONSchema()` has no arm for a `custom` check — so three new published schemas enter the dropped-refinement population and sixteen existing entries grow a site wherever one of those three configs is reachable. This is the ratchet reporting a real new gap, not a false one: every added site is a `gantt|kanban|timeline.groupByField` path and traces to that one commit. Nothing shrinks, no refinement is touched, no published shape is narrowed. entries 240 -> 243, sites 688 -> 737 (+49, 0 removed) The ledger is hand-edited on purpose and has no `gen:` script — admitting a gap is a decision, not a command. The sites here are the ones `check:authorable-surface` printed as "the corrected entries, in full", applied under a script that refuses any delta that is not a pure addition of a groupByField site. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho --- .../spec/dropped-refinements.baseline.json | 83 ++++++++++++++++--- 1 file changed, 72 insertions(+), 11 deletions(-) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index f27aa875bb..682685d96a 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "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.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 240, - "droppedRefinementSites": 688, + "publishedSchemasWithDroppedRefinements": 243, + "droppedRefinementSites": 737, "refinementSitesThatDidProject": 0, "refinementSitesWithNoJsonFormToCompare": 3 }, @@ -116,7 +116,10 @@ "manifest.views.element.list", "manifest.views.element.list.bulkActionDefs.element", "manifest.views.element.list.filter.element", - "manifest.views.element.list.grouping.fields.element.field" + "manifest.views.element.list.gantt.groupByField", + "manifest.views.element.list.grouping.fields.element.field", + "manifest.views.element.list.kanban.groupByField", + "manifest.views.element.list.timeline.groupByField" ] }, "api/CreateFlowRequest": { @@ -219,7 +222,10 @@ "data.options[1].manifest.views.element.list", "data.options[1].manifest.views.element.list.bulkActionDefs.element", "data.options[1].manifest.views.element.list.filter.element", - "data.options[1].manifest.views.element.list.grouping.fields.element.field" + "data.options[1].manifest.views.element.list.gantt.groupByField", + "data.options[1].manifest.views.element.list.grouping.fields.element.field", + "data.options[1].manifest.views.element.list.kanban.groupByField", + "data.options[1].manifest.views.element.list.timeline.groupByField" ] }, "api/GetObjectPermissionsResponse": { @@ -242,7 +248,10 @@ "list.bulkActionDefs.element", "list.conditionalFormatting.element.condition.options[1]", "list.filter.element", - "list.grouping.fields.element.field" + "list.gantt.groupByField", + "list.grouping.fields.element.field", + "list.kanban.groupByField", + "list.timeline.groupByField" ] }, "api/ImportRequest": { @@ -305,7 +314,10 @@ "options[1].manifest.views.element.list", "options[1].manifest.views.element.list.bulkActionDefs.element", "options[1].manifest.views.element.list.filter.element", - "options[1].manifest.views.element.list.grouping.fields.element.field" + "options[1].manifest.views.element.list.gantt.groupByField", + "options[1].manifest.views.element.list.grouping.fields.element.field", + "options[1].manifest.views.element.list.kanban.groupByField", + "options[1].manifest.views.element.list.timeline.groupByField" ] }, "api/ListInstalledPackagesResponse": { @@ -349,7 +361,10 @@ "data.packages.element.options[1].manifest.views.element.form.submitBehavior.options[1].url", "data.packages.element.options[1].manifest.views.element.list", "data.packages.element.options[1].manifest.views.element.list.bulkActionDefs.element", - "data.packages.element.options[1].manifest.views.element.list.filter.element" + "data.packages.element.options[1].manifest.views.element.list.filter.element", + "data.packages.element.options[1].manifest.views.element.list.gantt.groupByField", + "data.packages.element.options[1].manifest.views.element.list.kanban.groupByField", + "data.packages.element.options[1].manifest.views.element.list.timeline.groupByField" ] }, "api/ListPackagesResponse": { @@ -379,7 +394,10 @@ "data.listViews.valueType", "data.listViews.valueType.bulkActionDefs.element", "data.listViews.valueType.filter.element", + "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]" ] @@ -799,7 +817,10 @@ "listViews.valueType", "listViews.valueType.bulkActionDefs.element", "listViews.valueType.filter.element", + "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]" ] @@ -1162,7 +1183,10 @@ "operations.element.options[3].object.listViews.valueType", "operations.element.options[3].object.listViews.valueType.bulkActionDefs.element", "operations.element.options[3].object.listViews.valueType.filter.element", + "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]" ] @@ -1181,7 +1205,10 @@ "object.listViews.valueType", "object.listViews.valueType.bulkActionDefs.element", "object.listViews.valueType.filter.element", + "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]" ] @@ -1228,7 +1255,10 @@ "options[3].object.listViews.valueType", "options[3].object.listViews.valueType.bulkActionDefs.element", "options[3].object.listViews.valueType.filter.element", + "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]" ] @@ -1421,6 +1451,11 @@ "submitBehavior.options[1].url" ] }, + "ui/GanttConfig": { + "sites": [ + "groupByField" + ] + }, "ui/GlobalFilter": { "sites": [ "", @@ -1465,13 +1500,21 @@ "runtimeFilter.lazy" ] }, + "ui/KanbanConfig": { + "sites": [ + "groupByField" + ] + }, "ui/ListView": { "sites": [ "", "bulkActionDefs.element", "conditionalFormatting.element.condition.options[1]", "filter.element", - "grouping.fields.element.field" + "gantt.groupByField", + "grouping.fields.element.field", + "kanban.groupByField", + "timeline.groupByField" ] }, "ui/NavigationArea": { @@ -1499,7 +1542,8 @@ }, "ui/ObjectGanttProps": { "sites": [ - "filter.element" + "filter.element", + "gantt.groupByField" ] }, "ui/ObjectGridProps": { @@ -1518,7 +1562,10 @@ "bulkActionDefs.element", "conditionalFormatting.element.condition.options[1]", "filter.element", - "grouping.fields.element.field" + "gantt.groupByField", + "grouping.fields.element.field", + "kanban.groupByField", + "timeline.groupByField" ] }, "ui/ObjectMapProps": { @@ -1600,6 +1647,11 @@ "visible.options[1]" ] }, + "ui/TimelineConfig": { + "sites": [ + "groupByField" + ] + }, "ui/UrlNavItem": { "sites": [ "visible.options[1]" @@ -1619,7 +1671,10 @@ "list.bulkActionDefs.element", "list.conditionalFormatting.element.condition.options[1]", "list.filter.element", - "list.grouping.fields.element.field" + "list.gantt.groupByField", + "list.grouping.fields.element.field", + "list.kanban.groupByField", + "list.timeline.groupByField" ] }, "ui/ViewFilterRule": { @@ -1633,7 +1688,10 @@ "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", + "options[0].config.kanban.groupByField", + "options[0].config.timeline.groupByField", "options[1].config", "options[1].config.sections.element.in", "options[1].config.submitBehavior.options[1].url" @@ -1645,7 +1703,10 @@ "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", + "options[0].config.kanban.groupByField", + "options[0].config.timeline.groupByField", "options[1].config", "options[1].config.sections.element.in", "options[1].config.submitBehavior.options[1].url" From d03b281fb5e33c3b7fefa408b0c23b96042475a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 19:08:15 +0000 Subject: [PATCH 3/5] fix(spec): the dropped-refinement census is one reading, not one per schema-evaluation mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects, both surfaced by `@objectstack/spec#test:repo` — the vitest project neither round of this card ran, and the one CI runs alongside `test`. 1. The walk's visited set was keyed on the schema INSTANCE. `lazySchema()` returns the real schema under `OS_EAGER_SCHEMAS=1` (how `gen:schema` and `check:authorable-surface` run) and a Proxy over it otherwise, so a sub-schema reached both directly and through a `lazySchema()` edge was one node to the eager walk and two to the lazy one. The census — and so the ledger comparison — then depended on which mode the generator was spawned in: `ui/View` measured 11 dropped sites eager, 13 lazy. Keyed on the zod `def` instead, which the Proxy's `_zod` facade prototype-delegates rather than copies, both modes read the same. The module already stated the intent this restores: once per published file that reaches it. 2. The check-mode fixtures mount the committed package-root ledgers into each sandbox, and the dropped-refinement ledger was never added to the five builders. Every fixture expecting exit 0 got a 1 about a missing artifact instead of about its own subject. The mount is now a list, so the next ledger is one entry rather than a sixth call to remember. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho --- .../scripts/build-schemas-check-mode.test.ts | 47 +++++++++++++------ .../spec/scripts/lib/dropped-refinements.ts | 36 +++++++++++--- 2 files changed, 62 insertions(+), 21 deletions(-) diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index bff29e5a45..d6e149d376 100644 --- a/packages/spec/scripts/build-schemas-check-mode.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.test.ts @@ -100,6 +100,7 @@ import { type UnemittedBaseline, type UnemittedEntry, } from './lib/unemitted-schemas'; +import { DROPPED_REFINEMENTS_BASELINE_FILE } from './lib/dropped-refinements'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const PKG = path.resolve(HERE, '..'); @@ -385,18 +386,34 @@ function mountSandbox(dir: string): void { } /** - * Copy the committed never-published ledger (#16431) into a fixture tree. + * The committed, package-root ledgers `build-schemas.ts` READS — every one of + * them, mounted into a fixture tree as a set. * - * `build-schemas.ts` resolves it from its own `__dirname/..`, so any tree that - * copies `scripts/` without it fails the #16431 gate on a MISSING ledger, - * before reaching whatever that fixture is about — which is how all four - * sandbox builders in this file came to need one line each. Copied rather than - * symlinked so a fixture may mutate it without writing to the real file; `src/` - * is the fixture's own, so the population a run observes is the repo's and the - * copied ledger is green without any seeding. + * A list rather than one constant because this is a population that grows: the + * generator resolves each of these from its own `__dirname/..`, so a tree that + * copies `scripts/` without one of them fails that ledger's gate on a MISSING + * artifact, before reaching whatever the fixture is about. That is not a + * hypothetical — it is how the never-published ledger (#16431) came to need one + * line in each of the five sandbox builders below, and how the + * dropped-refinement ledger (#18670) reddened 36 of them at once: every fixture + * that expects `status` 0 got a 1 that had nothing to do with its subject. + * + * ⇒ A new package-root ledger is ONE entry here, not a fifth mount call + * somebody has to remember at each builder. + */ +const COMMITTED_LEDGERS = [UNEMITTED_BASELINE_FILE, DROPPED_REFINEMENTS_BASELINE_FILE] as const; + +/** + * Copy every committed ledger into a fixture tree. + * + * Copied rather than symlinked so a fixture may mutate one without writing to + * the real file; `src/` is the fixture's own, so the population a run observes + * is the repo's and the copied ledgers are green without any seeding. */ -function mountUnemittedLedger(dir: string): void { - fs.cpSync(path.join(PKG, UNEMITTED_BASELINE_FILE), path.join(dir, UNEMITTED_BASELINE_FILE)); +function mountCommittedLedgers(dir: string): void { + for (const ledger of COMMITTED_LEDGERS) { + fs.cpSync(path.join(PKG, ledger), path.join(dir, ledger)); + } } /** @@ -458,7 +475,7 @@ function createSandbox(prefix: string): string { for (const entry of ['src', 'node_modules', 'package.json']) { fs.symlinkSync(path.join(PKG, entry), path.join(dir, entry)); } - mountUnemittedLedger(dir); + mountCommittedLedgers(dir); mountSandbox(dir); // The authorable-surface ratchet runs after the manifest one; give it the // committed snapshot so a check that gets that far judges the same contract. @@ -3356,7 +3373,7 @@ describe('build-schemas.ts — check (b) matches the exact retired key, not its for (const entry of ['node_modules', 'package.json']) { fs.symlinkSync(path.join(PKG, entry), path.join(box, entry)); } - mountUnemittedLedger(box); + mountCommittedLedgers(box); writeManifestShards(path.join(box, SCHEMA_MANIFEST_DIR_NAME), pristine); boxSurfaceDir = path.join(box, AUTHORABLE_SURFACE_DIR_NAME); writeSurfaceShards(boxSurfaceDir, pristineSurface); @@ -3655,7 +3672,7 @@ describe('build-schemas.ts — a deleted manifest key must prove itself (#4725)' for (const entry of ['node_modules', 'package.json']) { fs.symlinkSync(path.join(PKG, entry), path.join(box, entry)); } - mountUnemittedLedger(box); + mountCommittedLedgers(box); boxScript = path.join(box, 'scripts', 'build-schemas.ts'); boxManifestDir = path.join(box, SCHEMA_MANIFEST_DIR_NAME); boxSurfaceDir = path.join(box, AUTHORABLE_SURFACE_DIR_NAME); @@ -4021,7 +4038,7 @@ describe('build-schemas.ts — check (c) dates a tombstone by its exact key (#58 for (const entry of ['node_modules', 'package.json']) { fs.symlinkSync(path.join(PKG, entry), path.join(box, entry)); } - mountUnemittedLedger(box); + mountCommittedLedgers(box); writeManifestShards(path.join(box, SCHEMA_MANIFEST_DIR_NAME), pristine); boxSurfaceDir = path.join(box, AUTHORABLE_SURFACE_DIR_NAME); writeSurfaceShards(boxSurfaceDir, pristineSurface); @@ -4285,7 +4302,7 @@ describe('build-schemas.ts — a nested retirement row is judged, not ignored (# for (const entry of ['node_modules', 'package.json']) { fs.symlinkSync(path.join(PKG, entry), path.join(box, entry)); } - mountUnemittedLedger(box); + mountCommittedLedgers(box); writeManifestShards(path.join(box, SCHEMA_MANIFEST_DIR_NAME), pristine); writeSurfaceShards(path.join(box, AUTHORABLE_SURFACE_DIR_NAME), pristineSurface); // The #4666 default ratchet runs on every invocation, so every box needs its diff --git a/packages/spec/scripts/lib/dropped-refinements.ts b/packages/spec/scripts/lib/dropped-refinements.ts index 1e9430d823..1785d13e56 100644 --- a/packages/spec/scripts/lib/dropped-refinements.ts +++ b/packages/spec/scripts/lib/dropped-refinements.ts @@ -301,27 +301,51 @@ function readablePath(raw: string): string { .join('.'); } +/** + * The identity two references to the SAME schema share in both schema-evaluation + * modes — the node's zod `def` object, falling back to the instance when a node + * has none. + * + * ⛔ Not the instance itself, which is not mode-invariant. `lazySchema()` + * (`src/shared/lazy-schema.ts`) returns the real schema under + * `OS_EAGER_SCHEMAS=1` — how `gen:schema` and `check:authorable-surface` run — + * and a Proxy over it otherwise. Keyed on the instance, a sub-schema reached + * once directly and once through a `lazySchema()` edge is ONE node to the eager + * walk and TWO to the lazy one, so the census — and therefore the ledger it is + * compared against — differs between two runs of the same generator over the + * same tree. Measured on `ui/View`, whose `list`/`listViews.valueType` and + * `form`/`formViews.valueType` pairs each reach one schema by both routes: 11 + * dropped sites eager, 13 lazy. + * + * The `def` survives the Proxy because its `_zod` facade prototype-delegates + * every read it does not wrap, so `proxy._zod.def` IS `real._zod.def` — the + * same object, not a copy. + */ +function identityOf(schema: z.ZodType): unknown { + return defOf(schema) ?? schema; +} + /** * Walk one published schema and classify every refinement under it. * - * The visited set is per export and holds schema INSTANCES, so a shared + * The visited set is per export and holds node IDENTITIES, so a shared * sub-schema is reported once per published file that reaches it — which is the * census question ("which published files does the gap land on"), not "how many - * distinct nodes exist". + * distinct nodes exist", and not "by how many routes". */ export function collectDroppedRefinements(defKey: string, root: z.ZodType): RefinementCensusEntry { const dropped: RefinementSite[] = []; const projected: RefinementSite[] = []; const undecidable: RefinementSite[] = []; - const visited = new Set(); + const visited = new Set(); const queue: Array<{ schema: z.ZodType; path: string; depth: number }> = [ { schema: root, path: '', depth: 0 }, ]; while (queue.length > 0) { const { schema, path, depth } = queue.shift()!; - if (visited.has(schema)) continue; - visited.add(schema); + if (visited.has(identityOf(schema))) continue; + visited.add(identityOf(schema)); const customs = customChecksOf(schema); if (customs.length > 0) { @@ -339,7 +363,7 @@ export function collectDroppedRefinements(defKey: string, root: z.ZodType): Refi if (depth >= MAX_DEPTH) continue; for (const child of labelledChildren(schema)) { - if (visited.has(child.schema)) continue; + if (visited.has(identityOf(child.schema))) continue; queue.push({ schema: child.schema, path: path ? `${path}.${child.label}` : child.label, From ad091097c7e1251319b5e2f9712dc59a02a7a68b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 19:14:59 +0000 Subject: [PATCH 4/5] fix(spec): key the refinement walk on zod internals, not the instance or the def MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Narrows the previous commit's identity. `_zod` is per instance where the def is not: `clone()` with no argument hands a SECOND instance the FIRST's def, so keying on the def collapsed two nodes the accepted census counts separately and dropped `…options[3].object.fields.valueType` from `system/ChangeSet` and `system/MigrationOperation`. Keying on `_zod` — with `lazySchema()`'s Proxy facade resolved to the internals it prototype-delegates to — moves nothing in eager mode and makes the lazy walk agree with it. Measured tree-wide, both modes, ledger untouched: 737 sites across 243 published schemas, exit 0. Pinned with its lit control: a shared sub-schema reached by two routes is one site, the same holds across a `lazySchema()` edge, and two genuinely distinct nodes carrying the same rule are still two sites. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho --- .../spec/scripts/dropped-refinements.test.ts | 42 +++++++++++++++++++ .../spec/scripts/lib/dropped-refinements.ts | 41 +++++++++++++----- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/packages/spec/scripts/dropped-refinements.test.ts b/packages/spec/scripts/dropped-refinements.test.ts index e15209ae29..85461f0163 100644 --- a/packages/spec/scripts/dropped-refinements.test.ts +++ b/packages/spec/scripts/dropped-refinements.test.ts @@ -43,6 +43,7 @@ import { } from './lib/dropped-refinements'; import { ContextTokenSchema } from '../src/data/context-tokens.zod'; import { AggregationFunction } from '../src/data/query.zod'; +import { lazySchema } from '../src/shared/lazy-schema'; const PKG_DIR = path.resolve(__dirname, '..'); const project = (schema: z.ZodType): string => @@ -164,6 +165,47 @@ describe('the differential isolates the refinement, not the node', () => { expect(entry.dropped.map((s) => s.path)).toEqual(['a']); }); + it('one node reached by TWO routes is ONE site — a shared sub-schema, counted once', () => { + const Shared = z.object({ q: z.string().refine((v) => v !== '', 'non-empty') }); + const entry = collectDroppedRefinements('t/TwoRoutes', z.object({ first: Shared, second: Shared })); + // Reported at the route it was reached by first, and not again at the other + // — the census question is which published FILE the gap lands on. + expect(entry.dropped.map((s) => s.path)).toEqual(['first.q']); + }); + + it('a `lazySchema()` edge is the SAME node as the schema it stands for, in either mode', () => { + // The mode-dependence this pin exists for. `lazySchema()` returns the real + // schema under `OS_EAGER_SCHEMAS=1` — how `gen:schema` and + // `check:authorable-surface` run — and a Proxy over it otherwise. Keyed on + // the INSTANCE, the walk saw one node in the first case and two in the + // second, so the same generator over the same tree produced two different + // censuses and the ledger only held under one of them. `ui/View` measured + // 11 dropped sites eager and 13 lazy; `@objectstack/spec#test:repo` spawns + // the generator WITHOUT the flag, which is where it surfaced. + // + // This file runs in the `local` project, which does not set the flag, so + // the Proxy is the live shape here and the assertion is about it. + const Shared = z.object({ q: z.string().refine((v) => v !== '', 'non-empty') }); + const entry = collectDroppedRefinements( + 't/LazyEdge', + z.object({ direct: Shared, viaLazy: lazySchema(() => Shared) }), + ); + expect(entry.dropped.map((s) => s.path)).toEqual(['direct.q']); + }); + + it('LIT CONTROL — two DISTINCT nodes carrying the same rule are two sites', () => { + // Without it, the two assertions above pass just as well on a walk that + // dedupes everything structurally and reports one site per export. + const entry = collectDroppedRefinements( + 't/TwoNodes', + z.object({ + first: z.object({ q: z.string().refine((v) => v !== '', 'non-empty') }), + second: z.object({ q: z.string().refine((v) => v !== '', 'non-empty') }), + }), + ); + expect(entry.dropped.map((s) => s.path)).toEqual(['first.q', 'second.q']); + }); + it('a recursive schema reports its refinement ONCE (the `_cachedInner` regression)', () => { type Node = { name: string; child?: Node }; const NodeSchema: z.ZodType = z.lazy(() => diff --git a/packages/spec/scripts/lib/dropped-refinements.ts b/packages/spec/scripts/lib/dropped-refinements.ts index 1785d13e56..8459912373 100644 --- a/packages/spec/scripts/lib/dropped-refinements.ts +++ b/packages/spec/scripts/lib/dropped-refinements.ts @@ -302,27 +302,46 @@ function readablePath(raw: string): string { } /** - * The identity two references to the SAME schema share in both schema-evaluation - * modes — the node's zod `def` object, falling back to the instance when a node - * has none. + * The identity two references to the SAME schema node share in BOTH + * schema-evaluation modes — the node's zod internals (`_zod`), with a + * `lazySchema()` Proxy resolved to the internals it stands for. * - * ⛔ Not the instance itself, which is not mode-invariant. `lazySchema()` + * ⛔ Not the schema instance, which is not mode-invariant. `lazySchema()` * (`src/shared/lazy-schema.ts`) returns the real schema under * `OS_EAGER_SCHEMAS=1` — how `gen:schema` and `check:authorable-surface` run — * and a Proxy over it otherwise. Keyed on the instance, a sub-schema reached * once directly and once through a `lazySchema()` edge is ONE node to the eager - * walk and TWO to the lazy one, so the census — and therefore the ledger it is - * compared against — differs between two runs of the same generator over the - * same tree. Measured on `ui/View`, whose `list`/`listViews.valueType` and + * walk and TWO to the lazy one, so the census — and the ledger comparison built + * on it — differed between two runs of the same generator over the same tree. + * Measured on `ui/View`, whose `list`/`listViews.valueType` and * `form`/`formViews.valueType` pairs each reach one schema by both routes: 11 * dropped sites eager, 13 lazy. * - * The `def` survives the Proxy because its `_zod` facade prototype-delegates - * every read it does not wrap, so `proxy._zod.def` IS `real._zod.def` — the - * same object, not a copy. + * ⛔ And not the `def` either, which over-collapses in the other direction: + * `clone()` with no argument produces a SECOND instance carrying the FIRST's + * def object, so two nodes the eager walk counts separately become one. Keyed + * on the def, the shipped tree lost `…options[3].object.fields.valueType` from + * `system/ChangeSet` and `system/MigrationOperation` — a reading the accepted + * ledger does not make. `_zod` is per instance and the def is not, so `_zod` is + * the narrower key, and it moves nothing in eager mode: no Proxy exists there, + * and every other node maps to its own internals one-to-one. + * + * The Proxy is resolved by shape, not by asking it: its `_zod` facade is + * `Object.create(realInternals, { processJSONSchema })`, so the real internals + * ARE its prototype — and a real `_zod` descends from `Object.prototype`, which + * owns no `def`. Looped rather than unwrapped once, so a Proxy over a Proxy + * resolves the whole way down. */ function identityOf(schema: z.ZodType): unknown { - return defOf(schema) ?? schema; + let internals = (schema as unknown as { _zod?: object })._zod; + if (!internals || typeof internals !== 'object') return schema; + for (let hop = 0; hop < MAX_DEPTH; hop += 1) { + const proto = Object.getPrototypeOf(internals) as object | null; + if (!proto || proto === Object.prototype) break; + if (!Object.prototype.hasOwnProperty.call(proto, 'def')) break; + internals = proto; + } + return internals; } /** From 6eeebd726d4aaf0d2a0baa3f57067dfe5f730c22 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 19:16:46 +0000 Subject: [PATCH 5/5] test(spec): put the shared-node pin's rule ON the shared node, where identity decides it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first draft asserted nothing: with the refinement one level down, the property schema is the same INSTANCE by either route, so the walk deduped on that alone and the pin passed against the very defect it was written for — measured by ablating the fix and watching it stay green. Moving the rule onto the shared node makes identity the thing under test. Ablation now reads `[ 'direct', 'viaLazy' ]` where it expects `[ 'direct' ]`. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho --- .../spec/scripts/dropped-refinements.test.ts | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/packages/spec/scripts/dropped-refinements.test.ts b/packages/spec/scripts/dropped-refinements.test.ts index 85461f0163..5dd5b6d848 100644 --- a/packages/spec/scripts/dropped-refinements.test.ts +++ b/packages/spec/scripts/dropped-refinements.test.ts @@ -166,44 +166,45 @@ describe('the differential isolates the refinement, not the node', () => { }); it('one node reached by TWO routes is ONE site — a shared sub-schema, counted once', () => { - const Shared = z.object({ q: z.string().refine((v) => v !== '', 'non-empty') }); + // ⚠️ The rule sits on the SHARED node itself, not on a property under it. + // With it one level down, the property schema is the same instance by both + // routes and the walk dedupes on that alone — an assertion that holds + // whatever the identity is, which is no assertion at all (measured: the + // first draft of this pin passed against the defect it was written for). + const Shared = z.object({ q: z.string() }).refine((v) => v.q !== '', 'non-empty'); const entry = collectDroppedRefinements('t/TwoRoutes', z.object({ first: Shared, second: Shared })); // Reported at the route it was reached by first, and not again at the other // — the census question is which published FILE the gap lands on. - expect(entry.dropped.map((s) => s.path)).toEqual(['first.q']); + expect(entry.dropped.map((s) => s.path)).toEqual(['first']); }); - it('a `lazySchema()` edge is the SAME node as the schema it stands for, in either mode', () => { + it('a `lazySchema()` edge is the SAME node as the schema it stands for', () => { // The mode-dependence this pin exists for. `lazySchema()` returns the real // schema under `OS_EAGER_SCHEMAS=1` — how `gen:schema` and // `check:authorable-surface` run — and a Proxy over it otherwise. Keyed on // the INSTANCE, the walk saw one node in the first case and two in the // second, so the same generator over the same tree produced two different - // censuses and the ledger only held under one of them. `ui/View` measured - // 11 dropped sites eager and 13 lazy; `@objectstack/spec#test:repo` spawns - // the generator WITHOUT the flag, which is where it surfaced. + // censuses and the committed ledger only held under one of them. `ui/View` + // measured 11 dropped sites eager and 13 lazy; `@objectstack/spec#test:repo` + // spawns the generator WITHOUT the flag, which is where it surfaced. // // This file runs in the `local` project, which does not set the flag, so // the Proxy is the live shape here and the assertion is about it. - const Shared = z.object({ q: z.string().refine((v) => v !== '', 'non-empty') }); + const Shared = z.object({ q: z.string() }).refine((v) => v.q !== '', 'non-empty'); const entry = collectDroppedRefinements( 't/LazyEdge', z.object({ direct: Shared, viaLazy: lazySchema(() => Shared) }), ); - expect(entry.dropped.map((s) => s.path)).toEqual(['direct.q']); + expect(entry.dropped.map((s) => s.path)).toEqual(['direct']); }); it('LIT CONTROL — two DISTINCT nodes carrying the same rule are two sites', () => { // Without it, the two assertions above pass just as well on a walk that - // dedupes everything structurally and reports one site per export. - const entry = collectDroppedRefinements( - 't/TwoNodes', - z.object({ - first: z.object({ q: z.string().refine((v) => v !== '', 'non-empty') }), - second: z.object({ q: z.string().refine((v) => v !== '', 'non-empty') }), - }), - ); - expect(entry.dropped.map((s) => s.path)).toEqual(['first.q', 'second.q']); + // dedupes structurally and reports one site per export however many nodes + // carry the rule. + const mk = (): z.ZodType => z.object({ q: z.string() }).refine((v) => v.q !== '', 'non-empty'); + const entry = collectDroppedRefinements('t/TwoNodes', z.object({ first: mk(), second: mk() })); + expect(entry.dropped.map((s) => s.path)).toEqual(['first', 'second']); }); it('a recursive schema reports its refinement ONCE (the `_cachedInner` regression)', () => {