Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/dual-source-export-ratchet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@objectstack/spec": patch
---

feat(spec): ratchet cross-entry dual-source exports — same name, different declaration, caught by symbol identity (#4446)

`api-surface.json` records every export per entry point, so a name appearing on
two entries was VISIBLE — but nothing distinguished a re-export (one
declaration, two import paths — fine) from a **dual-source** (each entry
resolving the shared name to its OWN declaration, so which type a consumer gets
depends on nothing but the import path). The dual-source case is the #4411
trap: spec carried two differently-shaped `MetadataWatchEvent`s plus ten more
pairs, and the copy that *looked* canonical was the dead one — an auto-import
or model completion picking by name compiled fine and failed later, at an edge
value.

New pure check `check:dual-source-exports` (lint.yml, after the build step):

- **Judged by symbol identity, not name.** Every export of all 16 public
entries is resolved through its alias chain to the original symbol; a name
whose entries resolve to ≥2 distinct symbols is dual-source. Name-based
counting would drown the signal — the real surface carries 148 legitimate
re-exported names.
- **Shrink-only baseline** (`dual-source-exports.baseline.json`): the 63
existing dual-source names are recorded (including the `MetadataFormat`
`./shared`≠`./system` enum divergence, the `./contracts` third-shape
interfaces, and two type-vs-const cases `ShareRecipientType` /
`TransformType`). A NEW dual-source fails the gate with the fix at the
declaration (converge + re-export, or rename); a resolved one fails until its
line is deleted. The baseline is hand-edited under review, deliberately not
generated — a `gen:` would admit new dual-sources via "run the fix command".
- **Self-tests first** (like `check:exported-any`): a fixture proves the
detector still flags a true dual-source (incl. type-vs-const) and still
passes re-exports, so a resolution failure can never read as "clean".

No runtime code changes; no export changes. The 63 baseline entries are
pre-existing debt, now visible and non-growing.
11 changes: 11 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,17 @@ jobs:
- name: Check no exported spec type resolves to `any`
run: pnpm --filter @objectstack/spec run check:exported-any

# Third axis on the same surface: api-surface.json shows a name on two
# entries but not whether the two are ONE declaration re-exported (fine)
# or TWO declarations sharing a name — the #4411 trap, where which type a
# consumer gets depends on nothing but the import path and the copy that
# LOOKS canonical can be the dead one. Judged by symbol identity against
# the built dist; existing dual-sources live in a shrink-only baseline
# (dual-source-exports.baseline.json), so only a NEW one fails (#4446).
# Self-tests first, like exported-any.
- name: Check no new same-name dual-source spec exports
run: pnpm --filter @objectstack/spec run check:dual-source-exports

# Anti-drift for the skill EXAMPLES, not just the skill reference indexes
# (#3094). The TypeScript in skills/ is the first thing an AI copies when
# authoring metadata, yet nothing type-checked it — so it rotted silently
Expand Down
13 changes: 9 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,10 +342,15 @@ removals" this way while writing this section; `check:generated` now prints this
inline when that gate is the one failing.)

`check:liveness`, `check:empty-state`, `check:skill-examples`,
`check:react-declaration-parity` and `check:exported-any` are pure checks with no
generator — a failure there is a real finding to fix, not an artifact to regenerate.
`check:generated` names them as deliberately not run, so its "all up to date" never reads
as "everything passed".
`check:react-declaration-parity`, `check:exported-any` and `check:dual-source-exports` are
pure checks with no generator — a failure there is a real finding to fix, not an artifact
to regenerate. `check:generated` names them as deliberately not run, so its "all up to
date" never reads as "everything passed". The last one asks the third question about the
export surface (#4446): `api-surface.json` shows a name on two entries but not whether
that is one declaration re-exported (fine) or two declarations sharing a name — the #4411
trap, judged by symbol identity against the built dist, with the accepted cases in the
shrink-only `dual-source-exports.baseline.json` (hand-edited under review, never
generated: a `gen:` would admit a new dual-source via "run the fix command").

⚠️ **`check:react-declaration-parity` compares two DECLARATIONS, not a declaration against
an implementation.** Left: the props a block's spec zod schema declares. Right: the inputs
Expand Down
57 changes: 57 additions & 0 deletions packages/spec/dual-source-exports.baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"_comment": "Accepted cross-entry DUAL-SOURCE exports of @objectstack/spec (#4446): names that two or more public entry points export for DIFFERENT declarations, so which type a consumer gets depends on the import path — the #4411 trap. Shrink-only ratchet, judged by symbol identity (a re-export of one declaration from many entries is fine and not listed). A NEW name here fails check:dual-source-exports: converge on one declaration and re-export it, or rename one side — growing this list needs maintainer sign-off and shows up as this file in the diff. An entry that stops being dual-source fails until its line is deleted. Regenerate with: tsx scripts/check-dual-source-exports.ts --update (after pnpm build).",
"entries": [
"ActionLocationSchema — [./studio (const)] ≠ [./ui (const)]",
"ActivationEventSchema — [./kernel (const)] ≠ [./studio (const)]",
"AnalyticsQuery — [./contracts (interface)] ≠ [./data (type)]",
"CacheStrategy — [./shared (type)] ≠ [./system (type)]",
"ConflictResolution — [./automation (type)] ≠ [./integration (type)] ≠ [./ui (type)]",
"ConflictResolutionSchema — [./automation (const)] ≠ [./integration (const)] ≠ [./ui (const)]",
"DataSyncConfig — [./automation (type)] ≠ [./integration (type)]",
"DataSyncConfigSchema — [./automation (const)] ≠ [./integration (const)]",
"DriverCapabilities — [./contracts (interface)] ≠ [./data (type)]",
"EnvironmentArtifact — [./cloud (type)] ≠ [./system (type)]",
"EnvironmentArtifactInput — [./cloud (type)] ≠ [./system (type)]",
"EnvironmentArtifactSchema — [./cloud (const)] ≠ [./system (const)]",
"EventSchema — [./automation (const)] ≠ [./kernel (const)]",
"FieldMapping — [./data (type)] ≠ [./integration (type)] ≠ [./shared (type)]",
"FieldMappingSchema — [./data (const)] ≠ [./integration (const)] ≠ [./shared (const)]",
"HealthStatus — [./contracts (interface)] ≠ [./kernel (type)]",
"HttpMethod — [./api, ./shared (type)] ≠ [./ui (type)]",
"HttpRequest — [./shared (type)] ≠ [./ui (type)]",
"JobExecution — [./contracts (interface)] ≠ [./system (type)]",
"JobSchedule — [./contracts (interface)] ≠ [./system (type)]",
"MetadataBulkRegisterRequestSchema — [./api (const)] ≠ [./kernel (const)]",
"MetadataEvent — [./api (type)] ≠ [./kernel (type)]",
"MetadataEventSchema — [./api (const)] ≠ [./kernel (const)]",
"MetadataExportOptions — [./contracts (interface)] ≠ [./system (type)]",
"MetadataFormat — [./shared (type)] ≠ [./system (type)]",
"MetadataFormatSchema — [./shared (const)] ≠ [./system (const)]",
"MetadataImportOptions — [./contracts (interface)] ≠ [./system (type)]",
"Notification — [./api (type)] ≠ [./ui (type)]",
"NotificationChannel — [./contracts (type)] ≠ [./system (type)]",
"NotificationConfig — [./system (type)] ≠ [./ui (type)]",
"NotificationConfigSchema — [./system (const)] ≠ [./ui (const)]",
"NotificationSchema — [./api (const)] ≠ [./ui (const)]",
"PackageDependency — [./cloud (type)] ≠ [./kernel (type)]",
"PackageDependencySchema — [./cloud (const)] ≠ [./kernel (const)]",
"PluginStartupResult — [./contracts (interface)] ≠ [./kernel (type)]",
"RateLimitConfig — [./integration (type)] ≠ [./shared (type)]",
"RateLimitConfigSchema — [./integration (const)] ≠ [./shared (const)]",
"RetryPolicy — [./automation (type)] ≠ [./system (type)]",
"RetryPolicySchema — [./automation (const)] ≠ [./system (const)]",
"Session — [./api (type)] ≠ [./identity (type)]",
"SessionSchema — [./api (const)] ≠ [./identity (const)]",
"ShareRecipientType — [./contracts (type)] ≠ [./security (const)]",
"StartupOptions — [./contracts (interface)] ≠ [./kernel (type)]",
"TenantPlan — [./cloud (type)] ≠ [./system (type)]",
"TenantPlanSchema — [./cloud (const)] ≠ [./system (const)]",
"TransformType — [./data (const)] ≠ [./shared (type)]",
"ValidationResult — [./contracts (interface)] ≠ [./kernel (type)]",
"WebhookConfig — [./api (type)] ≠ [./integration (type)]",
"WebhookConfigSchema — [./api (const)] ≠ [./integration (const)]",
"WebhookEvent — [./api (type)] ≠ [./integration (type)]",
"WebhookEventSchema — [./api (const)] ≠ [./integration (const)]",
"suggestFieldType — [., ./shared (function)] ≠ [./data (function)]"
]
}
1 change: 1 addition & 0 deletions packages/spec/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@
"gen:api-surface": "tsx scripts/build-api-surface.ts",
"check:api-surface": "tsx scripts/build-api-surface.ts --check",
"check:exported-any": "tsx scripts/check-exported-any.ts --self-test && tsx scripts/check-exported-any.ts",
"check:dual-source-exports": "tsx scripts/check-dual-source-exports.ts --self-test && tsx scripts/check-dual-source-exports.ts",
"check:authorable-surface": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts --check",
"gen:spec-changes": "tsx scripts/build-spec-changes.ts",
"check:spec-changes": "tsx scripts/build-spec-changes.ts --check",
Expand Down
Loading
Loading