Skip to content

Commit 7631964

Browse files
os-zhuangclaude
andauthored
feat(spec): ratchet cross-entry dual-source exports — same name, different declaration, judged by symbol identity (#4446) (#4506)
* feat(spec): ratchet cross-entry dual-source exports — same name, different declaration, judged 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 the two ways that happens, and only one of them is fine: a re-export (one declaration, two import paths) versus 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: eleven names declared twice across ./kernel and ./system, where the copy that LOOKED canonical was the dead one — a pick by name compiled and failed later, at an edge value. New pure check `check:dual-source-exports`: - 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 legitimately re-exported names next to the 63 real findings. - Shrink-only baseline (dual-source-exports.baseline.json) records the 63 existing dual-sources — including the MetadataFormat ./shared≠./system enum divergence, the ./contracts third-shape interfaces, and two type-vs-const cases (ShareRecipientType, TransformType) the name-level scan could not even see. A NEW dual-source fails 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:` that rewrites it would admit new dual-sources via "run the fix command" instead of via a maintainer decision. - Self-tests first (the check-exported-any pattern), pinning both edges: a fixture dual-source (incl. type-vs-const) must be flagged, a re-export must not, and count assertions keep a resolution failure from reading as clean. - Wired everywhere a new check must be: package.json, the check:generated reconciliation ledger (NO_GENERATOR — it would fail the run unclassified), lint.yml's TypeScript Type Check job after the build step, and the AGENTS.md pure-checks paragraph. Also fixes a fresh flake this work kept tripping over: the #4491 parity tests spawn a tsx subprocess that loads the whole spec surface (~4.5s alone, 5-7s under turbo's parallel load) against vitest's 5s default timeout — three consecutive full-suite runs failed a DIFFERENT test of that file each time, every one a timeout, while the file alone stayed green. The six spawning tests now carry an explicit 60s timeout: a timeout there should mean "the script hung", not "the runner was busy". Closes #4446. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M9uWvoEp9CoLzYjNExj9sL * chore(spec): shrink the dual-source baseline by the 8 pairs #4500 resolved First contact with reality, one merge in: #4500 removed the connector "template" cluster, deleting the ./integration copies of ConsumerConfig, DatabaseProvider, MessageQueueProvider and MultipartUploadConfig (type + Schema each). Those 8 names are no longer dual-source, and the gate's stale-entry leg refused to pass until their baseline lines were deleted — the shrink-only ratchet ratcheting down exactly as designed. 63 → 55. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M9uWvoEp9CoLzYjNExj9sL * chore(spec): shrink the dual-source baseline by the 3 pairs #4503 resolved Second catch in one afternoon, this time inside the merge queue: the queue built this branch against a main that had just landed #4503 (trigger-registry Connector cluster removal), which deleted the ./automation copies of Connector, ConnectorSchema and ConnectorTriggerSchema — and the gate refused the queue build until their baseline lines were gone. 55 → 52. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M9uWvoEp9CoLzYjNExj9sL --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4f13be2 commit 7631964

8 files changed

Lines changed: 433 additions & 10 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
feat(spec): ratchet cross-entry dual-source exports — same name, different declaration, caught by symbol identity (#4446)
6+
7+
`api-surface.json` records every export per entry point, so a name appearing on
8+
two entries was VISIBLE — but nothing distinguished a re-export (one
9+
declaration, two import paths — fine) from a **dual-source** (each entry
10+
resolving the shared name to its OWN declaration, so which type a consumer gets
11+
depends on nothing but the import path). The dual-source case is the #4411
12+
trap: spec carried two differently-shaped `MetadataWatchEvent`s plus ten more
13+
pairs, and the copy that *looked* canonical was the dead one — an auto-import
14+
or model completion picking by name compiled fine and failed later, at an edge
15+
value.
16+
17+
New pure check `check:dual-source-exports` (lint.yml, after the build step):
18+
19+
- **Judged by symbol identity, not name.** Every export of all 16 public
20+
entries is resolved through its alias chain to the original symbol; a name
21+
whose entries resolve to ≥2 distinct symbols is dual-source. Name-based
22+
counting would drown the signal — the real surface carries 148 legitimate
23+
re-exported names.
24+
- **Shrink-only baseline** (`dual-source-exports.baseline.json`): the 63
25+
existing dual-source names are recorded (including the `MetadataFormat`
26+
`./shared``./system` enum divergence, the `./contracts` third-shape
27+
interfaces, and two type-vs-const cases `ShareRecipientType` /
28+
`TransformType`). A NEW dual-source fails the gate with the fix at the
29+
declaration (converge + re-export, or rename); a resolved one fails until its
30+
line is deleted. The baseline is hand-edited under review, deliberately not
31+
generated — a `gen:` would admit new dual-sources via "run the fix command".
32+
- **Self-tests first** (like `check:exported-any`): a fixture proves the
33+
detector still flags a true dual-source (incl. type-vs-const) and still
34+
passes re-exports, so a resolution failure can never read as "clean".
35+
36+
No runtime code changes; no export changes. The 63 baseline entries are
37+
pre-existing debt, now visible and non-growing.

.github/workflows/lint.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,17 @@ jobs:
440440
- name: Check no exported spec type resolves to `any`
441441
run: pnpm --filter @objectstack/spec run check:exported-any
442442

443+
# Third axis on the same surface: api-surface.json shows a name on two
444+
# entries but not whether the two are ONE declaration re-exported (fine)
445+
# or TWO declarations sharing a name — the #4411 trap, where which type a
446+
# consumer gets depends on nothing but the import path and the copy that
447+
# LOOKS canonical can be the dead one. Judged by symbol identity against
448+
# the built dist; existing dual-sources live in a shrink-only baseline
449+
# (dual-source-exports.baseline.json), so only a NEW one fails (#4446).
450+
# Self-tests first, like exported-any.
451+
- name: Check no new same-name dual-source spec exports
452+
run: pnpm --filter @objectstack/spec run check:dual-source-exports
453+
443454
# Anti-drift for the skill EXAMPLES, not just the skill reference indexes
444455
# (#3094). The TypeScript in skills/ is the first thing an AI copies when
445456
# authoring metadata, yet nothing type-checked it — so it rotted silently

AGENTS.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -342,10 +342,15 @@ removals" this way while writing this section; `check:generated` now prints this
342342
inline when that gate is the one failing.)
343343

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

350355
⚠️ **`check:react-declaration-parity` compares two DECLARATIONS, not a declaration against
351356
an implementation.** Left: the props a block's spec zod schema declares. Right: the inputs
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"_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).",
3+
"entries": [
4+
"ActionLocationSchema — [./studio (const)] ≠ [./ui (const)]",
5+
"ActivationEventSchema — [./kernel (const)] ≠ [./studio (const)]",
6+
"AnalyticsQuery — [./contracts (interface)] ≠ [./data (type)]",
7+
"CacheStrategy — [./shared (type)] ≠ [./system (type)]",
8+
"ConflictResolution — [./automation (type)] ≠ [./integration (type)] ≠ [./ui (type)]",
9+
"ConflictResolutionSchema — [./automation (const)] ≠ [./integration (const)] ≠ [./ui (const)]",
10+
"DataSyncConfig — [./automation (type)] ≠ [./integration (type)]",
11+
"DataSyncConfigSchema — [./automation (const)] ≠ [./integration (const)]",
12+
"DriverCapabilities — [./contracts (interface)] ≠ [./data (type)]",
13+
"EnvironmentArtifact — [./cloud (type)] ≠ [./system (type)]",
14+
"EnvironmentArtifactInput — [./cloud (type)] ≠ [./system (type)]",
15+
"EnvironmentArtifactSchema — [./cloud (const)] ≠ [./system (const)]",
16+
"EventSchema — [./automation (const)] ≠ [./kernel (const)]",
17+
"FieldMapping — [./data (type)] ≠ [./integration (type)] ≠ [./shared (type)]",
18+
"FieldMappingSchema — [./data (const)] ≠ [./integration (const)] ≠ [./shared (const)]",
19+
"HealthStatus — [./contracts (interface)] ≠ [./kernel (type)]",
20+
"HttpMethod — [./api, ./shared (type)] ≠ [./ui (type)]",
21+
"HttpRequest — [./shared (type)] ≠ [./ui (type)]",
22+
"JobExecution — [./contracts (interface)] ≠ [./system (type)]",
23+
"JobSchedule — [./contracts (interface)] ≠ [./system (type)]",
24+
"MetadataBulkRegisterRequestSchema — [./api (const)] ≠ [./kernel (const)]",
25+
"MetadataEvent — [./api (type)] ≠ [./kernel (type)]",
26+
"MetadataEventSchema — [./api (const)] ≠ [./kernel (const)]",
27+
"MetadataExportOptions — [./contracts (interface)] ≠ [./system (type)]",
28+
"MetadataFormat — [./shared (type)] ≠ [./system (type)]",
29+
"MetadataFormatSchema — [./shared (const)] ≠ [./system (const)]",
30+
"MetadataImportOptions — [./contracts (interface)] ≠ [./system (type)]",
31+
"Notification — [./api (type)] ≠ [./ui (type)]",
32+
"NotificationChannel — [./contracts (type)] ≠ [./system (type)]",
33+
"NotificationConfig — [./system (type)] ≠ [./ui (type)]",
34+
"NotificationConfigSchema — [./system (const)] ≠ [./ui (const)]",
35+
"NotificationSchema — [./api (const)] ≠ [./ui (const)]",
36+
"PackageDependency — [./cloud (type)] ≠ [./kernel (type)]",
37+
"PackageDependencySchema — [./cloud (const)] ≠ [./kernel (const)]",
38+
"PluginStartupResult — [./contracts (interface)] ≠ [./kernel (type)]",
39+
"RateLimitConfig — [./integration (type)] ≠ [./shared (type)]",
40+
"RateLimitConfigSchema — [./integration (const)] ≠ [./shared (const)]",
41+
"RetryPolicy — [./automation (type)] ≠ [./system (type)]",
42+
"RetryPolicySchema — [./automation (const)] ≠ [./system (const)]",
43+
"Session — [./api (type)] ≠ [./identity (type)]",
44+
"SessionSchema — [./api (const)] ≠ [./identity (const)]",
45+
"ShareRecipientType — [./contracts (type)] ≠ [./security (const)]",
46+
"StartupOptions — [./contracts (interface)] ≠ [./kernel (type)]",
47+
"TenantPlan — [./cloud (type)] ≠ [./system (type)]",
48+
"TenantPlanSchema — [./cloud (const)] ≠ [./system (const)]",
49+
"TransformType — [./data (const)] ≠ [./shared (type)]",
50+
"ValidationResult — [./contracts (interface)] ≠ [./kernel (type)]",
51+
"WebhookConfig — [./api (type)] ≠ [./integration (type)]",
52+
"WebhookConfigSchema — [./api (const)] ≠ [./integration (const)]",
53+
"WebhookEvent — [./api (type)] ≠ [./integration (type)]",
54+
"WebhookEventSchema — [./api (const)] ≠ [./integration (const)]",
55+
"suggestFieldType — [., ./shared (function)] ≠ [./data (function)]"
56+
]
57+
}

packages/spec/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@
199199
"gen:api-surface": "tsx scripts/build-api-surface.ts",
200200
"check:api-surface": "tsx scripts/build-api-surface.ts --check",
201201
"check:exported-any": "tsx scripts/check-exported-any.ts --self-test && tsx scripts/check-exported-any.ts",
202+
"check:dual-source-exports": "tsx scripts/check-dual-source-exports.ts --self-test && tsx scripts/check-dual-source-exports.ts",
202203
"check:authorable-surface": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts --check",
203204
"gen:spec-changes": "tsx scripts/build-spec-changes.ts",
204205
"check:spec-changes": "tsx scripts/build-spec-changes.ts --check",

0 commit comments

Comments
 (0)