diff --git a/.changeset/spooky-poems-repeat.md b/.changeset/spooky-poems-repeat.md new file mode 100644 index 00000000000..ea154703ac4 --- /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 00000000000..682685d96ad --- /dev/null +++ b/packages/spec/dropped-refinements.baseline.json @@ -0,0 +1,1721 @@ +{ + "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": 243, + "droppedRefinementSites": 737, + "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.gantt.groupByField", + "manifest.views.element.list.grouping.fields.element.field", + "manifest.views.element.list.kanban.groupByField", + "manifest.views.element.list.timeline.groupByField" + ] + }, + "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.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": { + "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.gantt.groupByField", + "list.grouping.fields.element.field", + "list.kanban.groupByField", + "list.timeline.groupByField" + ] + }, + "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.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": { + "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", + "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": { + "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.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]" + ] + }, + "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.gantt.groupByField", + "listViews.valueType.grouping.fields.element.field", + "listViews.valueType.kanban.groupByField", + "listViews.valueType.timeline.groupByField", + "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.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]" + ] + }, + "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.gantt.groupByField", + "object.listViews.valueType.grouping.fields.element.field", + "object.listViews.valueType.kanban.groupByField", + "object.listViews.valueType.timeline.groupByField", + "object.titleFormat.options[0].in", + "object.titleFormat.options[1]" + ] + }, + "system/CronSchedule": { + "sites": [ + "expression.options[0].in", + "expression.options[1]" + ] + }, + "system/Job": { + "sites": [ + "schedule.options[0].expression.options[0].in", + "schedule.options[0].expression.options[1]" + ] + }, + "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.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]" + ] + }, + "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/GanttConfig": { + "sites": [ + "groupByField" + ] + }, + "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/KanbanConfig": { + "sites": [ + "groupByField" + ] + }, + "ui/ListView": { + "sites": [ + "", + "bulkActionDefs.element", + "conditionalFormatting.element.condition.options[1]", + "filter.element", + "gantt.groupByField", + "grouping.fields.element.field", + "kanban.groupByField", + "timeline.groupByField" + ] + }, + "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", + "gantt.groupByField" + ] + }, + "ui/ObjectGridProps": { + "sites": [ + "filter.element" + ] + }, + "ui/ObjectKanbanProps": { + "sites": [ + "filter.element" + ] + }, + "ui/ObjectListView": { + "sites": [ + "", + "bulkActionDefs.element", + "conditionalFormatting.element.condition.options[1]", + "filter.element", + "gantt.groupByField", + "grouping.fields.element.field", + "kanban.groupByField", + "timeline.groupByField" + ] + }, + "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/TimelineConfig": { + "sites": [ + "groupByField" + ] + }, + "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.gantt.groupByField", + "list.grouping.fields.element.field", + "list.kanban.groupByField", + "list.timeline.groupByField" + ] + }, + "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.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" + ] + }, + "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.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" + ] + }, + "ui/ViewTab": { + "sites": [ + "filter.element" + ] + } + } +} diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index bff29e5a45d..d6e149d3764 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/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index 4948f991ee7..08034223917 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 00000000000..5dd5b6d8488 --- /dev/null +++ b/packages/spec/scripts/dropped-refinements.test.ts @@ -0,0 +1,312 @@ +// 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'; +import { lazySchema } from '../src/shared/lazy-schema'; + +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('one node reached by TWO routes is ONE site — a shared sub-schema, counted once', () => { + // ⚠️ 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']); + }); + + 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 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.q !== '', 'non-empty'); + const entry = collectDroppedRefinements( + 't/LazyEdge', + z.object({ direct: Shared, viaLazy: lazySchema(() => Shared) }), + ); + 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 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)', () => { + 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 00000000000..84599123730 --- /dev/null +++ b/packages/spec/scripts/lib/dropped-refinements.ts @@ -0,0 +1,505 @@ +// 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('.'); +} + +/** + * 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 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 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. + * + * ⛔ 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 { + 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; +} + +/** + * Walk one published schema and classify every refinement under it. + * + * 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", 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 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(identityOf(schema))) continue; + visited.add(identityOf(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(identityOf(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> }; +}