From 0d11fa3777b43fe33afa43ac6c1506ce57c7f7ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 17:33:44 +0000 Subject: [PATCH 1/4] =?UTF-8?q?test(layering):=20pin=20exact=20fa=C3=A7ade?= =?UTF-8?q?=20symbols=20for=20all=20workspace=20packages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1555 added the repo's first exact exported-symbol gate, pinning @agent-device/ad-replay's named export list. Every other workspace package was still covered only by the exports-subpath locks, which prove which files a package exposes but say nothing about what those files name — so any façade could grow a symbol silently. Pin all 29 exported subpaths across the remaining 8 packages: ad-script, contracts (14), kernel (8), maestro, provider-limrun, provider-webdriver, replay-test, and xml. The lists are the honest current surface, untrimmed — contracts/interaction alone names 140 symbols, and pinning the real number is what makes the next widening visible. The table is checked in both directions, so a new package or subpath that nobody pinned fails rather than being silently skipped. Pinning contracts needed the export-discovery helper widened: 13 of its 14 façades are bare `export * from '../x.ts'` barrels, and readNamedExports throws on those by design, because given only a source string the contributed set is genuinely unknowable. Given the FILE it is not, so readFacadeExports resolves the relative re-export chain and enumerates it. Resolution stays narrow — a package-specifier star still throws (that would mean re-entering another package's exports map, the unbounded widening the gate exists to refuse), cycles are visit-guarded, and a default export still throws through a barrel. Helper unit tests cover the shapes the merged AST scan handles but left unpinned: `export { default as x }` (the form between the two rejection rules — named, so reported, never `default`), a local `export { … }` list with no `from`, and multi-declarator `export const a = 1, b = 2`. Plant-verified per package rather than asserted: a stray export on ad-script, one two files deep behind contracts' `export *` chain, and an unpinned new subpath on xml each failed with a named diff; each reverted to green. Gates: check:layering (63 tests, up from 53) / typecheck / lint / format:check — green. --- scripts/layering/package-boundaries.test.ts | 988 +++++++++++++++++++- scripts/layering/package-boundaries.ts | 60 ++ 2 files changed, 1047 insertions(+), 1 deletion(-) diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 932833636..489a9fecd 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -10,6 +10,7 @@ import { checkPackageBoundaries, checkPackageInternalSites, checkRootSites, + readFacadeExports, readNamedExports, readWorkspacePackages, rootExternalDependencyRanges, @@ -18,6 +19,8 @@ import { type WorkspacePackage, } from './package-boundaries.ts'; +const repoRoot = path.resolve(import.meta.dirname, '../..'); + const kernel: WorkspacePackage = { dir: 'packages/kernel', name: '@agent-device/kernel', @@ -121,6 +124,990 @@ test('readNamedExports rejects a default export', () => { assert.throws(() => readNamedExports('export default 42;'), /export default/); }); +test('readNamedExports reports `export { default as x }` as the named symbol x', () => { + // The one form that sits between the two rejection rules above: the LOCAL + // name is `default`, but what it binds in this module — and the only thing + // a consumer can import — is `x`. Enumerable, so it must be reported, not + // thrown; and `default` must never appear in the list. + const names = readNamedExports("export { default as x, b } from './y.ts';"); + assert.deepEqual(names, ['b', 'x']); + assert.ok(!names.includes('default')); +}); + +test('readNamedExports collects a local `export { … }` list with no `from`', () => { + // The re-export tests above all carry a `from`; a façade that declares + // first and exports at the bottom is the same public surface. + assert.deepEqual( + readNamedExports('const a = 1;\ntype T = string;\nexport { a };\nexport type { T };'), + ['T', 'a'], + ); +}); + +test('readNamedExports collects every declarator of a multi-declarator export', () => { + // Documented in the helper's contract; the direct-declaration test above + // only exercises a single declarator, so the second name went unpinned. + assert.deepEqual(readNamedExports('export const a = 1, b = 2;'), ['a', 'b']); +}); + +// `readFacadeExports` is the same enumeration widened from one source string +// to the re-export CHAIN behind a file — the form every `contracts` façade +// is built from. These use the real tree's own barrels rather than fixtures: +// a fixture would pin the walker against a file this repo never ships. +test('readFacadeExports resolves a bare `export *` chain the source-only reader refuses', () => { + const barrel = path.join(repoRoot, 'packages/contracts/src/facades/session.ts'); + // Source-only: unknowable, so it throws (the merged contract, unchanged). + assert.throws(() => readNamedExports(fs.readFileSync(barrel, 'utf8')), /export \* from/); + // Given the FILE, the same barrel is fully enumerable. + assert.deepEqual(readFacadeExports(barrel), [ + 'SESSION_SURFACES', + 'SessionAction', + 'SessionSurface', + 'parseSessionSurface', + ]); +}); + +test('readFacadeExports refuses a bare `export *` across a package specifier', () => { + // A relative star names a module this gate can read; a package star means + // resolving node_modules into another package's exports map — unbounded + // widening, the exact thing the gate refuses. + const scratch = path.join(repoRoot, 'packages/contracts/src/facades/.export-star-probe.ts'); + fs.writeFileSync(scratch, "export * from '@agent-device/kernel/errors';\n"); + try { + assert.throws(() => readFacadeExports(scratch), /only a relative re-export/); + } finally { + fs.rmSync(scratch); + } +}); + +test('readFacadeExports still rejects a default export reached through the chain', () => { + // A default is no more enumerable behind a barrel than in front of one. + const dir = path.join(repoRoot, 'packages/contracts/src/facades'); + const leaf = path.join(dir, '.default-leaf-probe.ts'); + const barrel = path.join(dir, '.default-barrel-probe.ts'); + fs.writeFileSync(leaf, 'export default function leak() {}\n'); + fs.writeFileSync(barrel, "export * from './.default-leaf-probe.ts';\n"); + try { + assert.throws(() => readFacadeExports(barrel), /export default/); + } finally { + fs.rmSync(leaf); + fs.rmSync(barrel); + } +}); + +// Every workspace package façade, pinned to its exact exported-symbol list. +// +// #1555 established this gate for `@agent-device/ad-replay` (asserted +// separately below, beside the design rationale for that package's two-value +// façade). The exports-subpath checks in the R11 tree test prove only WHICH +// files a package exposes; the symbol lists here prove WHAT those files name. +// Without them a façade grows a symbol silently and only a diff review — of +// the package, not of the gate — would ever notice. +// +// The list is the honest current surface, deliberately untrimmed: several of +// these are wider than their owners would design today (`contracts/interaction` +// alone names 140 symbols), and pinning the real number is what makes the next +// widening visible. Narrowing a façade is a change to that package, with its +// own consumers to fix — not a silent edit to this table. +// +// Maintaining it is mechanical: run the gate, and the assertion prints the +// exact added/removed names. Adding a symbol to a façade means adding it here +// in the same commit — that coupling IS the gate. +const FACADE_SYMBOLS: readonly (readonly [string, readonly string[]])[] = [ + [ + '@agent-device/ad-script', + [ + 'LocalIdentity', + 'ParsedReplayScript', + 'REPLAY_VAR_KEY_RE', + 'ReplayScriptMetadata', + 'ReplayVarScope', + 'TARGET_ANNOTATION_MAX_ANCESTRY', + 'TARGET_ANNOTATION_MAX_FIELD_BYTES', + 'TARGET_ANNOTATION_MAX_PAYLOAD_BYTES', + 'TargetBindingClassification', + 'TargetBindingClassificationInput', + 'annotationLocalIdentity', + 'appendScriptSeriesFlags', + 'buildReplayVarScope', + 'classifyTargetBindingMatch', + 'collectReplayScrubbableVarValues', + 'collectReplayShellEnv', + 'firstAncestryMismatch', + 'formatDivergenceActionLabel', + 'formatPortableActionLine', + 'formatScriptArg', + 'formatScriptStringLiteral', + 'formatTargetAnnotationLines', + 'identityFieldMismatches', + 'isClickLikeCommand', + 'isTouchTargetCommand', + 'matchesAncestryPrefix', + 'matchesLocalIdentity', + 'normalizeIdentifierField', + 'normalizeLabelField', + 'normalizeRoleField', + 'parseReplayCliEnvEntries', + 'parseReplayScriptDetailed', + 'parseTargetAnnotationV1Payload', + 'readReplayCliEnvEntries', + 'readReplayScriptMetadata', + 'readReplayShellEnvSource', + 'resolveDeclaredScriptPlatform', + 'resolveReplayAction', + 'serializeTargetAnnotationV1', + 'stripRecordedRefGeneration', + 'truncateToUtf8Bytes', + 'utf8ByteLength', + ], + ], + [ + '@agent-device/contracts/client', + [ + 'AgentDeviceCapabilitiesResult', + 'AgentDeviceClientConfig', + 'AgentDeviceDaemonTransport', + 'AgentDeviceDaemonTransportContext', + 'AgentDeviceDevice', + 'AgentDeviceIdentifiers', + 'AgentDeviceRequestOverrides', + 'AgentDeviceSelectionOptions', + 'AgentDeviceSession', + 'AgentDeviceSessionDevice', + 'AlertCommandOptions', + 'AppCloseOptions', + 'AppCloseResult', + 'AppDeployOptions', + 'AppDeployResult', + 'AppInstallFromSourceOptions', + 'AppInstallFromSourceResult', + 'AppInstallOptions', + 'AppListOptions', + 'AppOpenOptions', + 'AppOpenResult', + 'AppPushOptions', + 'AppStateCommandOptions', + 'AppTriggerEventOptions', + 'AudioOptions', + 'BatchRunOptions', + 'BatchStep', + 'CaptureDiffOptions', + 'CaptureScreenshotOptions', + 'CaptureScreenshotResult', + 'CaptureSnapshotOptions', + 'CaptureSnapshotResult', + 'ClickOptions', + 'ClipboardCommandOptions', + 'CloudArtifactsOptions', + 'CommandExecutionOptions', + 'CommandRequestResult', + 'DeviceBootOptions', + 'DeviceCommandBaseOptions', + 'DeviceShutdownOptions', + 'DoctorCommandOptions', + 'ElementTarget', + 'EventsOptions', + 'FillOptions', + 'FindBaseOptions', + 'FindOptions', + 'FindSnapshotCommandOptions', + 'FlingOptions', + 'FocusOptions', + 'GetOptions', + 'InteractionTarget', + 'InternalRequestOptions', + 'IsOptions', + 'IsStatePredicateOptions', + 'IsTextPredicateOptions', + 'JsonObject', + 'JsonPrimitive', + 'JsonValue', + 'KeyboardCommandOptions', + 'Lease', + 'LeaseAllocateOptions', + 'LeaseOptions', + 'LeaseScopedOptions', + 'LogsOptions', + 'LongPressOptions', + 'MaterializationReleaseOptions', + 'MaterializationReleaseResult', + 'NetworkOptions', + 'PanOptions', + 'PerfOptions', + 'PermissionTarget', + 'PinchOptions', + 'PointTarget', + 'PrepareCommandOptions', + 'PressOptions', + 'ReactNativeCommandOptions', + 'RecordControlOptions', + 'RecordOptions', + 'RefTarget', + 'RepeatedPressOptions', + 'ReplayRunOptions', + 'ReplayTestOptions', + 'RotateGestureOptions', + 'ScrollOptions', + 'SelectorSnapshotCommandOptions', + 'SelectorTarget', + 'SessionCloseResult', + 'SessionSaveScriptOptions', + 'SessionSaveScriptResult', + 'SettingsUpdateOptions', + 'SettleCommandOptions', + 'StartupPerfSample', + 'SwipeGestureOptions', + 'SwipeOptions', + 'TraceOptions', + 'TransformGestureOptions', + 'TypeTextOptions', + 'ViewportCommandOptions', + 'WaitCommandOptions', + 'WaitCommandTarget', + 'isRecord', + ], + ], + [ + '@agent-device/contracts/command', + [ + 'CliFlags', + 'CommandFlags', + 'DEFAULT_BATCH_MAX_STEPS', + 'DaemonBatchStep', + 'DaemonExcludedCliFlag', + 'DispatchedCommand', + 'IOS_SAFARI_BUNDLE_ID', + 'MaestroRuntimeFlags', + 'PrepareCommandResult', + 'PrepareIosRunnerArtifactState', + 'PrepareIosRunnerCacheKind', + 'PrepareIosRunnerTiming', + 'PushCommandResult', + 'assertBatchStepCount', + 'isDeepLinkTarget', + 'isValidBatchMaxSteps', + 'isWebUrl', + 'parseBatchStepRuntime', + 'readBatchStepInputObject', + 'readBatchStepRecord', + 'readOptionalInteger', + 'resolveIosDeviceDeepLinkBundleId', + ], + ], + [ + '@agent-device/contracts/device', + [ + 'AppStateCommandResult', + 'AppsFilter', + 'BootCommandResult', + 'DEFAULT_APPS_FILTER', + 'DEVICE_ROTATIONS', + 'DEVICE_ROTATION_SURFACE_INDEX', + 'DeviceInventoryGroup', + 'DeviceInventoryGroupCounts', + 'DeviceInventoryProvider', + 'DeviceInventoryRequest', + 'DeviceLease', + 'DeviceRotation', + 'LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS', + 'LeaseLifecycleContext', + 'LeaseLifecycleProvider', + 'ProviderDeviceInstallOptions', + 'ProviderDeviceInstallResult', + 'ProviderDeviceRuntime', + 'ProviderExpiredLeaseRecovery', + 'ProviderPortReverseOptions', + 'ShutdownCommandResult', + 'TargetShutdownResult', + 'TriggerAppEventCommandResult', + 'WEB_DESKTOP_DEVICE', + 'assertResolvedAppsFilter', + 'countDeviceInventoryByGroup', + 'deviceRotationOrientation', + 'deviceRotationSurfaceDegrees', + 'parseDeviceRotation', + 'resolveAppsFilter', + 'shouldUseHostMacFastPath', + ], + ], + [ + '@agent-device/contracts/interaction', + [ + 'ALERT_ACTIONS', + 'ALERT_ACTION_RETRY_MS', + 'ALERT_POLL_INTERVAL_MS', + 'AlertAction', + 'AlertInfo', + 'AlertPlatform', + 'AlertSource', + 'AppSwitcherCommandResult', + 'AppleTvRemoteButton', + 'BACK_MODES', + 'BackCommandResult', + 'BackMode', + 'CLICK_BUTTONS', + 'ClickButton', + 'ClickCommandResponseData', + 'ClipboardCommandResult', + 'DEFAULT_ALERT_TIMEOUT_MS', + 'DisambiguationTiebreak', + 'ElementSelectorKey', + 'ElementSelectorTapOptions', + 'ElementTarget', + 'FillCommandResponseData', + 'FillCommandResult', + 'FindCommandResponseData', + 'FlingGesturePayload', + 'GESTURE_DURATION_MAX_MS', + 'GESTURE_DURATION_MIN_MS', + 'GESTURE_FLING_DURATION_MS', + 'GESTURE_INITIAL_ANGLE_DEGREES', + 'GESTURE_KINDS', + 'GESTURE_SAMPLE_INTERVAL_MS', + 'GestureExecutionProfile', + 'GestureIntent', + 'GesturePayload', + 'GesturePlan', + 'GesturePointerCount', + 'GestureReferenceFrame', + 'GestureSemanticInput', + 'GuaranteeEnforcement', + 'HomeCommandResult', + 'INTERACTION_DISPATCH_PATHS', + 'INTERACTION_GUARANTEES', + 'INTERACTION_PATH_IDS', + 'InPageSwipeGesturePlan', + 'InteractionEvidence', + 'InteractionGuarantee', + 'InteractionPathContract', + 'InteractionPathId', + 'InteractionTarget', + 'Interactor', + 'KeyboardCommandResult', + 'LongPressCommandResponseData', + 'LongPressCommandResult', + 'MAESTRO_NON_HITTABLE_FALLBACK_MESSAGE', + 'MultiTouchGesturePlan', + 'NormalizedPublicGesture', + 'OrientationCommandResult', + 'PanGesturePayload', + 'PinchGesturePayload', + 'PointTarget', + 'PointerTrajectory', + 'PointerTrajectorySample', + 'PressCommandResponseData', + 'PressCommandResult', + 'RecordingTargetOverride', + 'RefTarget', + 'ResolutionDiagnosticEntry', + 'ResolutionDisclosure', + 'ResolvedInteractionTarget', + 'ResolvedTarget', + 'RotateCommandResult', + 'RotateGesturePayload', + 'RunnerCallOptions', + 'RunnerContext', + 'SCROLL_DIRECTIONS', + 'SCROLL_DURATION_MAX_MS', + 'SCROLL_INPUT_DIRECTIONS', + 'SWIPE_PATTERNS', + 'SWIPE_PAUSE_MAX_MS', + 'SWIPE_PRESETS', + 'SWIPE_REPETITION_MAX', + 'SWIPE_SERIES_MAX_SCHEDULED_DURATION_MS', + 'ScreenshotOptions', + 'ScrollCommandOptions', + 'ScrollDirection', + 'ScrollDistanceOptions', + 'ScrollGestureOptions', + 'ScrollGesturePlan', + 'ScrollInputDirection', + 'ScrollTimingOptions', + 'SelectorTarget', + 'SettleDiffLine', + 'SettleObservation', + 'SettleParams', + 'SettleTailEntry', + 'SinglePointerGesturePlan', + 'SnapshotOptions', + 'SnapshotResult', + 'SwipeGesturePayload', + 'SwipePattern', + 'SwipePayload', + 'SwipePreset', + 'SwipePresetGesturePlan', + 'TV_REMOTE_BUTTONS', + 'TV_REMOTE_BUTTON_USAGE', + 'TransformGestureParams', + 'TransformGesturePayload', + 'TvRemoteButton', + 'TvRemoteCommandResult', + 'VegaTvRemoteKey', + 'WaitCommandResult', + 'assertExclusiveScrollDistanceInputs', + 'assertNoRemovedSwipeInput', + 'assertScrollGestureInput', + 'buildGesturePlan', + 'buildInPageSwipeGesturePlan', + 'buildScrollGesturePlan', + 'buildSwipePresetGesturePlan', + 'buttonTag', + 'clampGestureCoordinate', + 'describeReplayGestureArityError', + 'gestureDirectionDelta', + 'gesturePayloadFromPositionals', + 'gesturePayloadToPositionals', + 'getClickButtonValidationError', + 'honoredScrollDurationMs', + 'inferGestureReferenceFrame', + 'normalizePublicGesture', + 'normalizePublicSwipeMotion', + 'normalizeScrollDurationMs', + 'parseScrollDirection', + 'parseTvRemoteButton', + 'readGesturePayload', + 'resolveClickButton', + 'singlePointerPlanEndpoints', + 'swipePayloadFromPositionals', + 'toAndroidTvRemoteKeyevent', + 'toAppleTvRemoteButton', + 'toVegaTvRemoteKey', + 'tvRemoteDurationMode', + ], + ], + [ + '@agent-device/contracts/capture', + [ + 'AndroidSnapshotBackendMetadata', + 'BackendSnapshotOptions', + 'BackendSnapshotResult', + 'DiffSnapshotCommandResult', + 'FindLocator', + 'PublicSnapshotCaptureAnnotations', + 'SCREENSHOT_ACTION_FLAG_KEYS', + 'SCREENSHOT_COMMAND_FLAG_KEYS', + 'SCREENSHOT_SPECIFIC_FLAG_DEFINITIONS', + 'ScreenshotDispatchFlags', + 'ScreenshotPublicOptions', + 'ScreenshotRequestFlags', + 'ScreenshotResultData', + 'ScreenshotRuntimeFlags', + 'ScreenshotRuntimeOptions', + 'SnapshotCaptureAnalysis', + 'SnapshotCaptureAnnotations', + 'SnapshotCaptureFreshness', + 'SnapshotDiagnosticsState', + 'SnapshotDiagnosticsSummary', + 'SnapshotDiffLine', + 'SnapshotDiffSummary', + 'SnapshotTimingSample', + 'SnapshotTimingStats', + 'ViewportCommandResult', + 'appendScreenshotScriptFlags', + 'mergeSnapshotDiagnostics', + 'publicSnapshotCaptureAnnotations', + 'readScreenshotScriptFlag', + 'readSerializedSnapshotCaptureAnnotations', + 'readSnapshotDiagnosticsSummary', + 'recordSnapshotTiming', + 'screenshotFlagsFromOptions', + 'screenshotOptionsFromFlags', + 'snapshotCaptureAnnotationsFrom', + 'summarizeSnapshotDiagnostics', + 'summarizeSnapshotTimingSamples', + ], + ], + [ + '@agent-device/contracts/platform', + [ + 'ANDROID_SYSTEM_CHROME_PACKAGE', + 'AndroidInputOwner', + 'AndroidInputOwnership', + 'AndroidInputOwnershipSource', + 'AndroidSystemChromeProvenance', + 'AudioProbeResult', + 'AudioProbeSource', + 'EmptyAudioProbeResultOptions', + 'NormalizeAudioProbeRecordOptions', + 'PlatformGatedProviderResolverKey', + 'PlatformPlugin', + 'RunnerLogicalLeaseContext', + 'assertAppleMultiTouchSupported', + 'classifyAndroidInputOwner', + 'classifyAndroidInputOwnership', + 'emptyAudioProbeResult', + 'hasAndroidSystemChromeProvenance', + 'isAndroidInputMethodOwnedNode', + 'isAndroidSystemChromeWindowResourceId', + 'isAudioProbeSupportedDevice', + 'isFallbackAndroidInputMethodPackage', + 'isFallbackAndroidInputMethodResource', + 'isHostSystemAudioProbeDevice', + 'normalizeAudioProbeRecord', + 'parseAndroidInputMethodPackage', + 'readAndroidActiveInputMethodPackage', + 'stripAndroidSystemChromeProvenance', + 'stripAndroidSystemChromeProvenanceFromNode', + ], + ], + [ + '@agent-device/contracts/settings', + [ + 'PermissionAction', + 'PermissionTarget', + 'SETTINGS_INVALID_ARGS_MESSAGE', + 'SETTINGS_USAGE_OVERRIDE', + 'SettingOptions', + 'getUnsupportedMacOsSettingMessage', + 'isMacOsSettingSupported', + 'parsePermissionAction', + 'parsePermissionTarget', + ], + ], + [ + '@agent-device/contracts/session', + ['SESSION_SURFACES', 'SessionAction', 'SessionSurface', 'parseSessionSurface'], + ], + [ + '@agent-device/contracts/recording', + [ + 'DEFAULT_RECORDING_EXPORT_QUALITY', + 'RECORDING_EXPORT_QUALITIES', + 'RECORDING_SCOPE_VALUES', + 'RecordingAppIdentity', + 'RecordingBackendTag', + 'RecordingCommandResult', + 'RecordingExportQuality', + 'RecordingScope', + 'RecordingStartCommandResult', + 'RecordingStopCommandResult', + 'TraceCommandResult', + 'isRecordingExportQuality', + 'isWholeScreenRecordingScope', + 'recordingQualityInputToExportQuality', + ], + ], + [ + '@agent-device/contracts/observability', + [ + 'AgentArtifactsResult', + 'CloudArtifact', + 'CloudArtifactAvailability', + 'CloudArtifactKind', + 'CloudArtifactProvider', + 'CloudArtifactsQuery', + 'CloudArtifactsResult', + 'CloudArtifactsStatus', + 'CloudProviderSessionResult', + 'DaemonArtifactInventoryEntry', + 'DaemonArtifactsResult', + 'DebugSymbolsCrashFrame', + 'DebugSymbolsCrashSummary', + 'DebugSymbolsImage', + 'DebugSymbolsOptions', + 'DebugSymbolsResult', + 'DoctorCheck', + 'DoctorCommandResult', + 'DoctorKind', + 'DoctorStatus', + 'LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE', + 'LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE', + 'LOG_ACTION_VALUES', + 'LogAction', + 'LogBackend', + 'NetworkEntry', + 'PERF_ACTION_ERROR_MESSAGE', + 'PERF_ACTION_VALUES', + 'PERF_AREA_ERROR_MESSAGE', + 'PERF_AREA_VALUES', + 'PERF_KIND_ERROR_MESSAGE', + 'PERF_KIND_VALUES', + 'PERF_MEMORY_KIND_ERROR_MESSAGE', + 'PERF_SUBJECT_ERROR_MESSAGE', + 'PERF_SUBJECT_VALUES', + 'PerfAction', + 'PerfArea', + 'PerfKind', + 'PerfMetricsSamplerTag', + 'PerfSubject', + 'isPerfAction', + 'isPerfArea', + 'isPerfKind', + 'isPerfMemoryKind', + 'isPerfSubject', + ], + ], + [ + '@agent-device/contracts/remote', + [ + 'CloudProviderProfileFields', + 'CompanionTunnelScope', + 'MetroBridgeResult', + 'MetroBridgeScope', + 'MetroPrepareKind', + 'MetroPrepareOptions', + 'MetroPrepareResult', + 'MetroReloadOptions', + 'MetroReloadResult', + 'PROVIDER_DEVICE_ORIENTATIONS', + 'PrepareMetroRuntimeResult', + 'ProviderConnectionResource', + 'ProviderConnectionVerification', + 'ProviderDeviceOrientation', + 'ReloadMetroResult', + 'RemoteConfigMetroOptions', + 'RemoteConnectionProfileFields', + 'ResolvedMetroKind', + ], + ], + [ + '@agent-device/contracts/replay', + [ + 'RefFrameEffect', + 'ReplayCommandResult', + 'ReplaySuiteAttemptFailure', + 'ReplaySuiteResult', + 'ReplaySuiteTestFailed', + 'ReplaySuiteTestPassed', + 'ReplaySuiteTestResult', + 'ReplaySuiteTestSkipReason', + 'ReplaySuiteTestSkipped', + 'TargetAncestryEntry', + 'TargetAnnotationV1', + 'TargetRect', + 'TargetScrollRegion', + 'TargetVerification', + ], + ], + [ + '@agent-device/contracts/divergence', + [ + 'REPLAY_DIVERGENCE_DEFAULT_REF_LIMIT', + 'REPLAY_DIVERGENCE_DIGEST_REF_LIMIT', + 'REPLAY_DIVERGENCE_LEVEL_BYTE_LIMITS', + 'REPLAY_DIVERGENCE_SUGGESTION_LIMIT', + 'ReplayDivergence', + 'ReplayDivergenceCause', + 'ReplayDivergenceKind', + 'ReplayDivergenceOverflow', + 'ReplayDivergenceResume', + 'ReplayDivergenceScreen', + 'ReplayDivergenceScreenRef', + 'ReplayDivergenceStep', + 'ReplayDivergenceStepSource', + 'ReplayDivergenceSuggestion', + 'ReplayDivergenceSuggestionBasis', + 'ReplayDivergenceTargetBinding', + 'ReplayDivergenceTargetBindingKind', + 'ReplayDivergenceTargetCandidate', + 'ReplayDivergenceTargetIdentity', + 'ReplayRepairHint', + 'ReplayVarScrubEntry', + 'applyReplayDivergenceLevelCaps', + 'boundReplayDivergence', + 'createReplayDivergenceSanitizer', + 'formatReplayDivergenceReport', + 'measureReplayDivergenceBytes', + 'sanitizeReplayDivergenceField', + 'scrubReplayVarValues', + 'truncateUtf8Field', + ], + ], + [ + '@agent-device/contracts/progress', + [ + 'CommandProgressEvent', + 'ReplayTestProgressEvent', + 'ReplayTestSuiteProgressEvent', + 'RequestProgressEvent', + 'RequestProgressSink', + ], + ], + [ + '@agent-device/kernel/errors', + [ + 'AppError', + 'AppErrorCode', + 'AppErrorDetails', + 'DaemonError', + 'KNOWN_APP_ERROR_CODES', + 'KnownAppErrorCode', + 'NormalizedError', + 'asAppError', + 'defaultHintForCode', + 'isAgentDeviceError', + 'normalizeAgentDeviceError', + 'normalizeError', + 'retriableForErrorCode', + 'throwDaemonError', + 'toAppErrorCode', + ], + ], + [ + '@agent-device/kernel/device', + [ + 'AppleOS', + 'ApplePlatform', + 'DEVICE_TARGETS', + 'DeviceInfo', + 'DeviceKind', + 'DeviceSelector', + 'DeviceTarget', + 'PLATFORMS', + 'PLATFORM_SELECTORS', + 'PUBLIC_PLATFORMS', + 'Platform', + 'PlatformSelector', + 'PublicPlatform', + 'deviceFieldsFromPublicPlatform', + 'isAppleOs', + 'isApplePlatform', + 'isIosFamily', + 'isMacOs', + 'isMobilePlatform', + 'isPlatform', + 'isPublicPlatform', + 'isSerialAddressablePlatform', + 'isTvOsDevice', + 'matchesDeviceSelector', + 'matchesPlatformSelector', + 'publicPlatformString', + 'resolveApplePlatformName', + 'resolveAppleSimulatorSetPathForSelector', + 'resolveDevice', + 'resolveDeviceAppleOs', + 'sortAppleDevicesForSelection', + ], + ], + [ + '@agent-device/kernel/snapshot', + [ + 'HiddenContentHint', + 'Point', + 'REF_GRAMMAR_HINT', + 'RawSnapshotNode', + 'Rect', + 'ScreenshotOverlayRef', + 'SnapshotBackend', + 'SnapshotNode', + 'SnapshotOptions', + 'SnapshotPresentationFlagInput', + 'SnapshotQualityVerdict', + 'SnapshotState', + 'SnapshotUnchanged', + 'SnapshotVisibility', + 'SnapshotVisibilityReason', + 'SplitRef', + 'attachRefs', + 'buildSnapshotPresentationKey', + 'centerOfRect', + 'findNodeByRef', + 'isSnapshotBackend', + 'normalizeRef', + 'snapshotPresentationOptionsFromFlags', + 'splitRefGenerationSuffix', + 'usesMobileSnapshotPresentation', + ], + ], + [ + '@agent-device/kernel/contracts', + [ + 'AppErrorCode', + 'CommandRpcParams', + 'DaemonArtifact', + 'DaemonArtifactKnownType', + 'DaemonArtifactType', + 'DaemonInstallSource', + 'DaemonLockPolicy', + 'DaemonRequest', + 'DaemonRequestMeta', + 'DaemonResponse', + 'DaemonResponseData', + 'DaemonServerMode', + 'DaemonTransportPreference', + 'JsonRpcId', + 'JsonRpcRequestEnvelope', + 'LeaseBackend', + 'NETWORK_INCLUDE_MODES', + 'NetworkIncludeMode', + 'RESPONSE_LEVELS', + 'Rect', + 'ResponseCost', + 'ResponseLevel', + 'SessionIsolationMode', + 'SessionRuntimeHints', + 'SnapshotNode', + 'centerOfRect', + 'commandRpcParamsSchema', + 'daemonRuntimeSchema', + 'defaultHintForCode', + 'isNonDefaultResponseLevel', + 'jsonRpcRequestSchema', + 'normalizeError', + ], + ], + ['@agent-device/kernel/collections', ['uniqueStrings']], + ['@agent-device/kernel/rect', ['isPositiveFiniteRect', 'rectArea', 'rectContains']], + ['@agent-device/kernel/redaction', ['redactDiagnosticData']], + ['@agent-device/kernel/bounds', ['parseBounds']], + [ + '@agent-device/maestro', + [ + 'MAESTRO_COMPATIBILITY_ADR_URL', + 'MAESTRO_COMPATIBILITY_ISSUE_URL', + 'MAESTRO_COMPAT_LIMITATIONS', + 'MAESTRO_COMPAT_SUPPORTED_CAPABILITIES', + 'MAESTRO_RUNTIME_ADAPTER_POLICY', + 'MaestroActionEvent', + 'MaestroCompletedActionEvent', + 'MaestroDispatchSelector', + 'MaestroExecutionObserver', + 'MaestroExecutionOptions', + 'MaestroExecutionOutcome', + 'MaestroExportOptions', + 'MaestroExportResult', + 'MaestroExportWarning', + 'MaestroFailedAction', + 'MaestroFlow', + 'MaestroObservation', + 'MaestroObservationCondition', + 'MaestroObservationIdentity', + 'MaestroPlatform', + 'MaestroRuntimeCommand', + 'MaestroRuntimeMetrics', + 'MaestroRuntimeOperationContext', + 'MaestroRuntimeOperationResult', + 'MaestroRuntimeOperations', + 'MaestroRuntimePort', + 'MaestroRuntimePortLifecycle', + 'MaestroRuntimeReadContext', + 'MaestroSelector', + 'MaestroSinglePointerGestureInput', + 'MaestroSnapshotTargetQuery', + 'MaestroTargetMatch', + 'MaestroTargetQuery', + 'MaestroTargetResolution', + 'collectMaestroFailureSuggestions', + 'createMaestroRuntimePort', + 'executeMaestroFlow', + 'exportReplayActionsToMaestro', + 'formatMaestroCompatibilityReference', + 'inspectMaestroFlow', + 'literalFromMaestroRegex', + 'maestroObservationMatches', + 'maestroTestFailure', + 'resolveMaestroScrollableGesture', + 'resolveMaestroTargetFromSnapshot', + ], + ], + [ + '@agent-device/provider-limrun', + [ + 'LIMRUN_PROVIDER', + 'LimrunAndroidDeviceSession', + 'LimrunConnectionVerification', + 'LimrunConnectionVerificationOptions', + 'LimrunIosCommandExecution', + 'LimrunIosDeviceSession', + 'LimrunRuntime', + 'LimrunRuntimeDependencies', + 'LimrunRuntimeOptions', + 'createLimrunRuntime', + 'verifyLimrunConnection', + ], + ], + [ + '@agent-device/provider-webdriver', + [ + 'CLOUD_WEBDRIVER_PROVIDERS', + 'CloudWebDriverConnectionVerification', + 'CloudWebDriverConnectionVerificationOptions', + 'CloudWebDriverKnownProviderName', + 'DefaultCloudWebDriverArtifactEnv', + 'DefaultCloudWebDriverProviderRuntimeEnv', + 'ProviderWebDriver', + 'ProviderWebDriverDependencies', + 'RunHostCommand', + 'browserStackOnlyDeviceFeatureFlags', + 'createProviderWebDriver', + 'isCloudWebDriverProviderName', + 'readAwsDeviceFarmRegionFromArn', + 'rejectBrowserStackOnlyDeviceFeatures', + ], + ], + [ + '@agent-device/replay-test', + [ + 'ReplayTestAttemptCancellation', + 'ReplayTestAttemptError', + 'ReplayTestAttemptFailed', + 'ReplayTestAttemptOutcome', + 'ReplayTestAttemptPassed', + 'ReplayTestAttemptStep', + 'ReplayTestAttemptStepSink', + 'ReplayTestBindAttemptCancellation', + 'ReplayTestCleanupSession', + 'ReplayTestDiscoverSources', + 'ReplayTestEmitDiagnostic', + 'ReplayTestEmitProgress', + 'ReplayTestExecutionDependencies', + 'ReplayTestFinalizeAttempt', + 'ReplayTestIsCanceled', + 'ReplayTestManifest', + 'ReplayTestPlatform', + 'ReplayTestResolveShardTargets', + 'ReplayTestRunReplay', + 'ReplayTestRunReplayParams', + 'ReplayTestRuntimeDependencies', + 'ReplayTestShardContext', + 'ReplayTestShardMode', + 'ReplayTestShardTarget', + 'ReplayTestSource', + 'ReplayTestSuiteOutcome', + 'ReplayTestSuiteRequest', + 'ReplayTestTarget', + 'runReplayTestSuite', + ], + ], + [ + '@agent-device/xml', + [ + 'XmlNode', + 'XmlParseOptions', + 'decodeXmlCharacterReferences', + 'escapeXmlTextAndAttribute', + 'parseXmlDocumentSync', + ], + ], +]; + +test('every workspace package façade exports exactly its pinned symbol list', () => { + const packages = readWorkspacePackages(repoRoot); + const pinned = new Map(FACADE_SYMBOLS.map(([specifier, names]) => [specifier, names])); + // The table and the manifests must agree in BOTH directions: a new package + // (or a new subpath on an existing one) that nobody pinned is exactly the + // widening this gate exists to catch, so an unpinned façade fails here + // rather than being silently skipped. + const declared = packages + .filter((pkg) => pkg.name !== '@agent-device/ad-replay') + .flatMap((pkg) => [...pkg.exportTargets.keys()]); + assert.deepEqual( + declared.slice().sort(), + [...pinned.keys()].sort(), + 'every exports-map subpath needs a pinned symbol list (and vice versa)', + ); + for (const pkg of packages) { + for (const [specifier, target] of pkg.exportTargets) { + const expected = pinned.get(specifier); + if (!expected) continue; + assert.deepEqual( + readFacadeExports(path.join(repoRoot, target)), + [...expected], + `${specifier} exports exactly its pinned symbol list`, + ); + } + } +}); + test('double-quoted and re-export routes into packages are not invisible to R11', () => { // The scanner is the layering parser, so quote style and statement form // cannot carve out a bypass: a double-quoted import, a re-export, and a @@ -253,7 +1240,6 @@ test('root workspace specifiers need a root workspace:* entry and an exported su }); test('the real tree parses, declares, and passes R11', () => { - const repoRoot = path.resolve(import.meta.dirname, '../..'); const packages = readWorkspacePackages(repoRoot); assert.ok(packages.length >= 1, 'expected at least the kernel package'); const kernelPackage = packages.find((pkg) => pkg.name === '@agent-device/kernel'); diff --git a/scripts/layering/package-boundaries.ts b/scripts/layering/package-boundaries.ts index 5e060eade..9d56a806f 100644 --- a/scripts/layering/package-boundaries.ts +++ b/scripts/layering/package-boundaries.ts @@ -111,6 +111,66 @@ export function readNamedExports(source: string): string[] { return [...names].sort(); } +/** + * Every name a façade subpath exports, sorted — `readNamedExports` widened + * from one source string to the re-export CHAIN behind it, so a barrel + * façade is pinnable too. + * + * `readNamedExports` throws on bare `export * from './x.ts'` because, given + * only a source string, the set it contributes is genuinely unknowable. Given + * the FILE, it is not: the specifier names a sibling module the gate can read + * and enumerate in turn. That is the whole difference here — every + * `@agent-device/contracts` façade (`src/facades/*.ts`) is exactly such a + * barrel, 13 of the 14 subpaths being nothing but bare re-export lines, so + * without chain resolution the package with the largest and fastest-growing + * public surface in the workspace is the one package that cannot be pinned. + * + * Resolution is deliberately narrow — a RELATIVE specifier only, and the + * repo's explicit-`.ts`-extension convention means the specifier is already + * the path. A bare star across a PACKAGE specifier still throws: enumerating + * it means resolving `node_modules` and re-entering another package's + * `exports` map, and a façade that re-exports a whole other package wholesale + * is precisely the unbounded widening this gate exists to refuse. Cycles are + * visit-guarded (a barrel pair that re-exported each other would otherwise + * recurse forever), and `export default` still throws through the chain — a + * default reached via a barrel is no more enumerable than a direct one. + */ +export function readFacadeExports(entryFile: string): string[] { + const names = new Set(); + const visited = new Set(); + const walk = (file: string): void => { + const resolved = path.resolve(file); + if (visited.has(resolved)) return; + visited.add(resolved); + const parsed = parseSync(resolved, fs.readFileSync(resolved, 'utf8')); + for (const staticExport of parsed.module.staticExports) { + for (const entry of staticExport.entries) { + if (entry.exportName.kind === 'Default') { + throw new Error( + `readFacadeExports cannot enumerate 'export default …' as a named symbol (${resolved})` + + ' — a facade a caller pins to an exact named-export list must not carry one.', + ); + } + if (entry.exportName.kind === 'None') { + const specifier = entry.moduleRequest?.value; + if (!specifier || !specifier.startsWith('.')) { + throw new Error( + `readFacadeExports cannot enumerate 'export * from ${specifier ?? '…'}' ` + + `(${resolved}) — only a relative re-export names a module this gate can read ` + + 'and enumerate in turn. Name the re-exported symbols explicitly instead.', + ); + } + walk(path.resolve(path.dirname(resolved), specifier)); + continue; + } + if (entry.exportName.name) names.add(entry.exportName.name); + } + } + }; + walk(entryFile); + return [...names].sort(); +} + export function readWorkspacePackages(repoRoot: string): WorkspacePackage[] { const packagesDir = path.join(repoRoot, 'packages'); if (!fs.existsSync(packagesDir)) return []; From c1baea56b5c70790aae78fdc1ea1faa2030c3e89 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 18:08:38 +0000 Subject: [PATCH 2/4] fix(layering): model real `export *` semantics; split the pinned table out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses both P1 findings on #1574. P1 — `readFacadeExports` did not model `export *` façade semantics. It unioned every child name and threw on every child default. Both are wrong: - Per GetExportedNames, a star export excludes the child's `default`, so a private `export default` in a leaf is not reachable through the barrel and does not widen the façade. It is now passed over rather than rejected; the previous test codified that false positive and is replaced. A default on the ENTRY file is still a real default export of the façade and still throws. - Per ResolveExport, a name two star sources resolve differently is `ambiguous` — importing it is a SyntaxError, so it is not part of the surface at all. Unioning would pin a symbol no consumer can import; ambiguity now throws and names both origins. Origins are tracked by declaring module rather than by path taken, so a diamond (two barrels reaching one declaration) resolves normally, and an explicit export shadows a star-provided name of the same name as the spec's own precedence does. Both counterfactuals are tested alongside the two rejection cases. P1 — module size. The 885-line generated FACADE_SYMBOLS table moves to a focused sibling, scripts/layering/facade-symbols.ts, leaving the behavioral tests at 642 lines (from 1,455) so the test file stays one bounded read per AGENTS.md. Gates: check:layering (66 tests, up from 63) / typecheck / lint / format:check — green. Contracts plant re-verified under the corrected semantics: a stray two files deep behind the `export *` chain still fails with a named diff, and reverts to green. --- scripts/layering/facade-symbols.ts | 887 ++++++++++++++++++ scripts/layering/package-boundaries.test.ts | 975 ++------------------ scripts/layering/package-boundaries.ts | 106 ++- 3 files changed, 1058 insertions(+), 910 deletions(-) create mode 100644 scripts/layering/facade-symbols.ts diff --git a/scripts/layering/facade-symbols.ts b/scripts/layering/facade-symbols.ts new file mode 100644 index 000000000..c4d402f1a --- /dev/null +++ b/scripts/layering/facade-symbols.ts @@ -0,0 +1,887 @@ +// The exact exported-symbol list of every workspace package façade — the data +// half of the R11 gate, kept beside `package-boundaries.test.ts` so the +// behavioral tests there stay one bounded read (AGENTS.md module-size +// tripwires; a generated table is data, not behavior, and the two answer +// different questions). +// +// #1555 added the first such pin, for `@agent-device/ad-replay`; that one +// stays asserted inline beside the design rationale for its two-value façade. +// This table covers every other exported subpath. The exports-subpath checks +// prove WHICH files a package exposes; these lists prove WHAT those files +// name, so a façade cannot grow a symbol without editing the gate. +// +// The lists are the honest current surface, deliberately untrimmed: several +// are wider than their owners would design today (`contracts/interaction` +// alone names 140 symbols), and pinning the real number is what makes the +// next widening visible. Narrowing a façade is a change to that package, with +// its own consumers to fix — not a silent edit to this table. +// +// Maintaining it is mechanical: run `pnpm check:layering` and the assertion +// prints the exact added/removed names. +export const FACADE_SYMBOLS: readonly (readonly [string, readonly string[]])[] = [ + [ + '@agent-device/ad-script', + [ + 'LocalIdentity', + 'ParsedReplayScript', + 'REPLAY_VAR_KEY_RE', + 'ReplayScriptMetadata', + 'ReplayVarScope', + 'TARGET_ANNOTATION_MAX_ANCESTRY', + 'TARGET_ANNOTATION_MAX_FIELD_BYTES', + 'TARGET_ANNOTATION_MAX_PAYLOAD_BYTES', + 'TargetBindingClassification', + 'TargetBindingClassificationInput', + 'annotationLocalIdentity', + 'appendScriptSeriesFlags', + 'buildReplayVarScope', + 'classifyTargetBindingMatch', + 'collectReplayScrubbableVarValues', + 'collectReplayShellEnv', + 'firstAncestryMismatch', + 'formatDivergenceActionLabel', + 'formatPortableActionLine', + 'formatScriptArg', + 'formatScriptStringLiteral', + 'formatTargetAnnotationLines', + 'identityFieldMismatches', + 'isClickLikeCommand', + 'isTouchTargetCommand', + 'matchesAncestryPrefix', + 'matchesLocalIdentity', + 'normalizeIdentifierField', + 'normalizeLabelField', + 'normalizeRoleField', + 'parseReplayCliEnvEntries', + 'parseReplayScriptDetailed', + 'parseTargetAnnotationV1Payload', + 'readReplayCliEnvEntries', + 'readReplayScriptMetadata', + 'readReplayShellEnvSource', + 'resolveDeclaredScriptPlatform', + 'resolveReplayAction', + 'serializeTargetAnnotationV1', + 'stripRecordedRefGeneration', + 'truncateToUtf8Bytes', + 'utf8ByteLength', + ], + ], + [ + '@agent-device/contracts/client', + [ + 'AgentDeviceCapabilitiesResult', + 'AgentDeviceClientConfig', + 'AgentDeviceDaemonTransport', + 'AgentDeviceDaemonTransportContext', + 'AgentDeviceDevice', + 'AgentDeviceIdentifiers', + 'AgentDeviceRequestOverrides', + 'AgentDeviceSelectionOptions', + 'AgentDeviceSession', + 'AgentDeviceSessionDevice', + 'AlertCommandOptions', + 'AppCloseOptions', + 'AppCloseResult', + 'AppDeployOptions', + 'AppDeployResult', + 'AppInstallFromSourceOptions', + 'AppInstallFromSourceResult', + 'AppInstallOptions', + 'AppListOptions', + 'AppOpenOptions', + 'AppOpenResult', + 'AppPushOptions', + 'AppStateCommandOptions', + 'AppTriggerEventOptions', + 'AudioOptions', + 'BatchRunOptions', + 'BatchStep', + 'CaptureDiffOptions', + 'CaptureScreenshotOptions', + 'CaptureScreenshotResult', + 'CaptureSnapshotOptions', + 'CaptureSnapshotResult', + 'ClickOptions', + 'ClipboardCommandOptions', + 'CloudArtifactsOptions', + 'CommandExecutionOptions', + 'CommandRequestResult', + 'DeviceBootOptions', + 'DeviceCommandBaseOptions', + 'DeviceShutdownOptions', + 'DoctorCommandOptions', + 'ElementTarget', + 'EventsOptions', + 'FillOptions', + 'FindBaseOptions', + 'FindOptions', + 'FindSnapshotCommandOptions', + 'FlingOptions', + 'FocusOptions', + 'GetOptions', + 'InteractionTarget', + 'InternalRequestOptions', + 'IsOptions', + 'IsStatePredicateOptions', + 'IsTextPredicateOptions', + 'JsonObject', + 'JsonPrimitive', + 'JsonValue', + 'KeyboardCommandOptions', + 'Lease', + 'LeaseAllocateOptions', + 'LeaseOptions', + 'LeaseScopedOptions', + 'LogsOptions', + 'LongPressOptions', + 'MaterializationReleaseOptions', + 'MaterializationReleaseResult', + 'NetworkOptions', + 'PanOptions', + 'PerfOptions', + 'PermissionTarget', + 'PinchOptions', + 'PointTarget', + 'PrepareCommandOptions', + 'PressOptions', + 'ReactNativeCommandOptions', + 'RecordControlOptions', + 'RecordOptions', + 'RefTarget', + 'RepeatedPressOptions', + 'ReplayRunOptions', + 'ReplayTestOptions', + 'RotateGestureOptions', + 'ScrollOptions', + 'SelectorSnapshotCommandOptions', + 'SelectorTarget', + 'SessionCloseResult', + 'SessionSaveScriptOptions', + 'SessionSaveScriptResult', + 'SettingsUpdateOptions', + 'SettleCommandOptions', + 'StartupPerfSample', + 'SwipeGestureOptions', + 'SwipeOptions', + 'TraceOptions', + 'TransformGestureOptions', + 'TypeTextOptions', + 'ViewportCommandOptions', + 'WaitCommandOptions', + 'WaitCommandTarget', + 'isRecord', + ], + ], + [ + '@agent-device/contracts/command', + [ + 'CliFlags', + 'CommandFlags', + 'DEFAULT_BATCH_MAX_STEPS', + 'DaemonBatchStep', + 'DaemonExcludedCliFlag', + 'DispatchedCommand', + 'IOS_SAFARI_BUNDLE_ID', + 'MaestroRuntimeFlags', + 'PrepareCommandResult', + 'PrepareIosRunnerArtifactState', + 'PrepareIosRunnerCacheKind', + 'PrepareIosRunnerTiming', + 'PushCommandResult', + 'assertBatchStepCount', + 'isDeepLinkTarget', + 'isValidBatchMaxSteps', + 'isWebUrl', + 'parseBatchStepRuntime', + 'readBatchStepInputObject', + 'readBatchStepRecord', + 'readOptionalInteger', + 'resolveIosDeviceDeepLinkBundleId', + ], + ], + [ + '@agent-device/contracts/device', + [ + 'AppStateCommandResult', + 'AppsFilter', + 'BootCommandResult', + 'DEFAULT_APPS_FILTER', + 'DEVICE_ROTATIONS', + 'DEVICE_ROTATION_SURFACE_INDEX', + 'DeviceInventoryGroup', + 'DeviceInventoryGroupCounts', + 'DeviceInventoryProvider', + 'DeviceInventoryRequest', + 'DeviceLease', + 'DeviceRotation', + 'LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS', + 'LeaseLifecycleContext', + 'LeaseLifecycleProvider', + 'ProviderDeviceInstallOptions', + 'ProviderDeviceInstallResult', + 'ProviderDeviceRuntime', + 'ProviderExpiredLeaseRecovery', + 'ProviderPortReverseOptions', + 'ShutdownCommandResult', + 'TargetShutdownResult', + 'TriggerAppEventCommandResult', + 'WEB_DESKTOP_DEVICE', + 'assertResolvedAppsFilter', + 'countDeviceInventoryByGroup', + 'deviceRotationOrientation', + 'deviceRotationSurfaceDegrees', + 'parseDeviceRotation', + 'resolveAppsFilter', + 'shouldUseHostMacFastPath', + ], + ], + [ + '@agent-device/contracts/interaction', + [ + 'ALERT_ACTIONS', + 'ALERT_ACTION_RETRY_MS', + 'ALERT_POLL_INTERVAL_MS', + 'AlertAction', + 'AlertInfo', + 'AlertPlatform', + 'AlertSource', + 'AppSwitcherCommandResult', + 'AppleTvRemoteButton', + 'BACK_MODES', + 'BackCommandResult', + 'BackMode', + 'CLICK_BUTTONS', + 'ClickButton', + 'ClickCommandResponseData', + 'ClipboardCommandResult', + 'DEFAULT_ALERT_TIMEOUT_MS', + 'DisambiguationTiebreak', + 'ElementSelectorKey', + 'ElementSelectorTapOptions', + 'ElementTarget', + 'FillCommandResponseData', + 'FillCommandResult', + 'FindCommandResponseData', + 'FlingGesturePayload', + 'GESTURE_DURATION_MAX_MS', + 'GESTURE_DURATION_MIN_MS', + 'GESTURE_FLING_DURATION_MS', + 'GESTURE_INITIAL_ANGLE_DEGREES', + 'GESTURE_KINDS', + 'GESTURE_SAMPLE_INTERVAL_MS', + 'GestureExecutionProfile', + 'GestureIntent', + 'GesturePayload', + 'GesturePlan', + 'GesturePointerCount', + 'GestureReferenceFrame', + 'GestureSemanticInput', + 'GuaranteeEnforcement', + 'HomeCommandResult', + 'INTERACTION_DISPATCH_PATHS', + 'INTERACTION_GUARANTEES', + 'INTERACTION_PATH_IDS', + 'InPageSwipeGesturePlan', + 'InteractionEvidence', + 'InteractionGuarantee', + 'InteractionPathContract', + 'InteractionPathId', + 'InteractionTarget', + 'Interactor', + 'KeyboardCommandResult', + 'LongPressCommandResponseData', + 'LongPressCommandResult', + 'MAESTRO_NON_HITTABLE_FALLBACK_MESSAGE', + 'MultiTouchGesturePlan', + 'NormalizedPublicGesture', + 'OrientationCommandResult', + 'PanGesturePayload', + 'PinchGesturePayload', + 'PointTarget', + 'PointerTrajectory', + 'PointerTrajectorySample', + 'PressCommandResponseData', + 'PressCommandResult', + 'RecordingTargetOverride', + 'RefTarget', + 'ResolutionDiagnosticEntry', + 'ResolutionDisclosure', + 'ResolvedInteractionTarget', + 'ResolvedTarget', + 'RotateCommandResult', + 'RotateGesturePayload', + 'RunnerCallOptions', + 'RunnerContext', + 'SCROLL_DIRECTIONS', + 'SCROLL_DURATION_MAX_MS', + 'SCROLL_INPUT_DIRECTIONS', + 'SWIPE_PATTERNS', + 'SWIPE_PAUSE_MAX_MS', + 'SWIPE_PRESETS', + 'SWIPE_REPETITION_MAX', + 'SWIPE_SERIES_MAX_SCHEDULED_DURATION_MS', + 'ScreenshotOptions', + 'ScrollCommandOptions', + 'ScrollDirection', + 'ScrollDistanceOptions', + 'ScrollGestureOptions', + 'ScrollGesturePlan', + 'ScrollInputDirection', + 'ScrollTimingOptions', + 'SelectorTarget', + 'SettleDiffLine', + 'SettleObservation', + 'SettleParams', + 'SettleTailEntry', + 'SinglePointerGesturePlan', + 'SnapshotOptions', + 'SnapshotResult', + 'SwipeGesturePayload', + 'SwipePattern', + 'SwipePayload', + 'SwipePreset', + 'SwipePresetGesturePlan', + 'TV_REMOTE_BUTTONS', + 'TV_REMOTE_BUTTON_USAGE', + 'TransformGestureParams', + 'TransformGesturePayload', + 'TvRemoteButton', + 'TvRemoteCommandResult', + 'VegaTvRemoteKey', + 'WaitCommandResult', + 'assertExclusiveScrollDistanceInputs', + 'assertNoRemovedSwipeInput', + 'assertScrollGestureInput', + 'buildGesturePlan', + 'buildInPageSwipeGesturePlan', + 'buildScrollGesturePlan', + 'buildSwipePresetGesturePlan', + 'buttonTag', + 'clampGestureCoordinate', + 'describeReplayGestureArityError', + 'gestureDirectionDelta', + 'gesturePayloadFromPositionals', + 'gesturePayloadToPositionals', + 'getClickButtonValidationError', + 'honoredScrollDurationMs', + 'inferGestureReferenceFrame', + 'normalizePublicGesture', + 'normalizePublicSwipeMotion', + 'normalizeScrollDurationMs', + 'parseScrollDirection', + 'parseTvRemoteButton', + 'readGesturePayload', + 'resolveClickButton', + 'singlePointerPlanEndpoints', + 'swipePayloadFromPositionals', + 'toAndroidTvRemoteKeyevent', + 'toAppleTvRemoteButton', + 'toVegaTvRemoteKey', + 'tvRemoteDurationMode', + ], + ], + [ + '@agent-device/contracts/capture', + [ + 'AndroidSnapshotBackendMetadata', + 'BackendSnapshotOptions', + 'BackendSnapshotResult', + 'DiffSnapshotCommandResult', + 'FindLocator', + 'PublicSnapshotCaptureAnnotations', + 'SCREENSHOT_ACTION_FLAG_KEYS', + 'SCREENSHOT_COMMAND_FLAG_KEYS', + 'SCREENSHOT_SPECIFIC_FLAG_DEFINITIONS', + 'ScreenshotDispatchFlags', + 'ScreenshotPublicOptions', + 'ScreenshotRequestFlags', + 'ScreenshotResultData', + 'ScreenshotRuntimeFlags', + 'ScreenshotRuntimeOptions', + 'SnapshotCaptureAnalysis', + 'SnapshotCaptureAnnotations', + 'SnapshotCaptureFreshness', + 'SnapshotDiagnosticsState', + 'SnapshotDiagnosticsSummary', + 'SnapshotDiffLine', + 'SnapshotDiffSummary', + 'SnapshotTimingSample', + 'SnapshotTimingStats', + 'ViewportCommandResult', + 'appendScreenshotScriptFlags', + 'mergeSnapshotDiagnostics', + 'publicSnapshotCaptureAnnotations', + 'readScreenshotScriptFlag', + 'readSerializedSnapshotCaptureAnnotations', + 'readSnapshotDiagnosticsSummary', + 'recordSnapshotTiming', + 'screenshotFlagsFromOptions', + 'screenshotOptionsFromFlags', + 'snapshotCaptureAnnotationsFrom', + 'summarizeSnapshotDiagnostics', + 'summarizeSnapshotTimingSamples', + ], + ], + [ + '@agent-device/contracts/platform', + [ + 'ANDROID_SYSTEM_CHROME_PACKAGE', + 'AndroidInputOwner', + 'AndroidInputOwnership', + 'AndroidInputOwnershipSource', + 'AndroidSystemChromeProvenance', + 'AudioProbeResult', + 'AudioProbeSource', + 'EmptyAudioProbeResultOptions', + 'NormalizeAudioProbeRecordOptions', + 'PlatformGatedProviderResolverKey', + 'PlatformPlugin', + 'RunnerLogicalLeaseContext', + 'assertAppleMultiTouchSupported', + 'classifyAndroidInputOwner', + 'classifyAndroidInputOwnership', + 'emptyAudioProbeResult', + 'hasAndroidSystemChromeProvenance', + 'isAndroidInputMethodOwnedNode', + 'isAndroidSystemChromeWindowResourceId', + 'isAudioProbeSupportedDevice', + 'isFallbackAndroidInputMethodPackage', + 'isFallbackAndroidInputMethodResource', + 'isHostSystemAudioProbeDevice', + 'normalizeAudioProbeRecord', + 'parseAndroidInputMethodPackage', + 'readAndroidActiveInputMethodPackage', + 'stripAndroidSystemChromeProvenance', + 'stripAndroidSystemChromeProvenanceFromNode', + ], + ], + [ + '@agent-device/contracts/settings', + [ + 'PermissionAction', + 'PermissionTarget', + 'SETTINGS_INVALID_ARGS_MESSAGE', + 'SETTINGS_USAGE_OVERRIDE', + 'SettingOptions', + 'getUnsupportedMacOsSettingMessage', + 'isMacOsSettingSupported', + 'parsePermissionAction', + 'parsePermissionTarget', + ], + ], + [ + '@agent-device/contracts/session', + ['SESSION_SURFACES', 'SessionAction', 'SessionSurface', 'parseSessionSurface'], + ], + [ + '@agent-device/contracts/recording', + [ + 'DEFAULT_RECORDING_EXPORT_QUALITY', + 'RECORDING_EXPORT_QUALITIES', + 'RECORDING_SCOPE_VALUES', + 'RecordingAppIdentity', + 'RecordingBackendTag', + 'RecordingCommandResult', + 'RecordingExportQuality', + 'RecordingScope', + 'RecordingStartCommandResult', + 'RecordingStopCommandResult', + 'TraceCommandResult', + 'isRecordingExportQuality', + 'isWholeScreenRecordingScope', + 'recordingQualityInputToExportQuality', + ], + ], + [ + '@agent-device/contracts/observability', + [ + 'AgentArtifactsResult', + 'CloudArtifact', + 'CloudArtifactAvailability', + 'CloudArtifactKind', + 'CloudArtifactProvider', + 'CloudArtifactsQuery', + 'CloudArtifactsResult', + 'CloudArtifactsStatus', + 'CloudProviderSessionResult', + 'DaemonArtifactInventoryEntry', + 'DaemonArtifactsResult', + 'DebugSymbolsCrashFrame', + 'DebugSymbolsCrashSummary', + 'DebugSymbolsImage', + 'DebugSymbolsOptions', + 'DebugSymbolsResult', + 'DoctorCheck', + 'DoctorCommandResult', + 'DoctorKind', + 'DoctorStatus', + 'LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE', + 'LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE', + 'LOG_ACTION_VALUES', + 'LogAction', + 'LogBackend', + 'NetworkEntry', + 'PERF_ACTION_ERROR_MESSAGE', + 'PERF_ACTION_VALUES', + 'PERF_AREA_ERROR_MESSAGE', + 'PERF_AREA_VALUES', + 'PERF_KIND_ERROR_MESSAGE', + 'PERF_KIND_VALUES', + 'PERF_MEMORY_KIND_ERROR_MESSAGE', + 'PERF_SUBJECT_ERROR_MESSAGE', + 'PERF_SUBJECT_VALUES', + 'PerfAction', + 'PerfArea', + 'PerfKind', + 'PerfMetricsSamplerTag', + 'PerfSubject', + 'isPerfAction', + 'isPerfArea', + 'isPerfKind', + 'isPerfMemoryKind', + 'isPerfSubject', + ], + ], + [ + '@agent-device/contracts/remote', + [ + 'CloudProviderProfileFields', + 'CompanionTunnelScope', + 'MetroBridgeResult', + 'MetroBridgeScope', + 'MetroPrepareKind', + 'MetroPrepareOptions', + 'MetroPrepareResult', + 'MetroReloadOptions', + 'MetroReloadResult', + 'PROVIDER_DEVICE_ORIENTATIONS', + 'PrepareMetroRuntimeResult', + 'ProviderConnectionResource', + 'ProviderConnectionVerification', + 'ProviderDeviceOrientation', + 'ReloadMetroResult', + 'RemoteConfigMetroOptions', + 'RemoteConnectionProfileFields', + 'ResolvedMetroKind', + ], + ], + [ + '@agent-device/contracts/replay', + [ + 'RefFrameEffect', + 'ReplayCommandResult', + 'ReplaySuiteAttemptFailure', + 'ReplaySuiteResult', + 'ReplaySuiteTestFailed', + 'ReplaySuiteTestPassed', + 'ReplaySuiteTestResult', + 'ReplaySuiteTestSkipReason', + 'ReplaySuiteTestSkipped', + 'TargetAncestryEntry', + 'TargetAnnotationV1', + 'TargetRect', + 'TargetScrollRegion', + 'TargetVerification', + ], + ], + [ + '@agent-device/contracts/divergence', + [ + 'REPLAY_DIVERGENCE_DEFAULT_REF_LIMIT', + 'REPLAY_DIVERGENCE_DIGEST_REF_LIMIT', + 'REPLAY_DIVERGENCE_LEVEL_BYTE_LIMITS', + 'REPLAY_DIVERGENCE_SUGGESTION_LIMIT', + 'ReplayDivergence', + 'ReplayDivergenceCause', + 'ReplayDivergenceKind', + 'ReplayDivergenceOverflow', + 'ReplayDivergenceResume', + 'ReplayDivergenceScreen', + 'ReplayDivergenceScreenRef', + 'ReplayDivergenceStep', + 'ReplayDivergenceStepSource', + 'ReplayDivergenceSuggestion', + 'ReplayDivergenceSuggestionBasis', + 'ReplayDivergenceTargetBinding', + 'ReplayDivergenceTargetBindingKind', + 'ReplayDivergenceTargetCandidate', + 'ReplayDivergenceTargetIdentity', + 'ReplayRepairHint', + 'ReplayVarScrubEntry', + 'applyReplayDivergenceLevelCaps', + 'boundReplayDivergence', + 'createReplayDivergenceSanitizer', + 'formatReplayDivergenceReport', + 'measureReplayDivergenceBytes', + 'sanitizeReplayDivergenceField', + 'scrubReplayVarValues', + 'truncateUtf8Field', + ], + ], + [ + '@agent-device/contracts/progress', + [ + 'CommandProgressEvent', + 'ReplayTestProgressEvent', + 'ReplayTestSuiteProgressEvent', + 'RequestProgressEvent', + 'RequestProgressSink', + ], + ], + [ + '@agent-device/kernel/errors', + [ + 'AppError', + 'AppErrorCode', + 'AppErrorDetails', + 'DaemonError', + 'KNOWN_APP_ERROR_CODES', + 'KnownAppErrorCode', + 'NormalizedError', + 'asAppError', + 'defaultHintForCode', + 'isAgentDeviceError', + 'normalizeAgentDeviceError', + 'normalizeError', + 'retriableForErrorCode', + 'throwDaemonError', + 'toAppErrorCode', + ], + ], + [ + '@agent-device/kernel/device', + [ + 'AppleOS', + 'ApplePlatform', + 'DEVICE_TARGETS', + 'DeviceInfo', + 'DeviceKind', + 'DeviceSelector', + 'DeviceTarget', + 'PLATFORMS', + 'PLATFORM_SELECTORS', + 'PUBLIC_PLATFORMS', + 'Platform', + 'PlatformSelector', + 'PublicPlatform', + 'deviceFieldsFromPublicPlatform', + 'isAppleOs', + 'isApplePlatform', + 'isIosFamily', + 'isMacOs', + 'isMobilePlatform', + 'isPlatform', + 'isPublicPlatform', + 'isSerialAddressablePlatform', + 'isTvOsDevice', + 'matchesDeviceSelector', + 'matchesPlatformSelector', + 'publicPlatformString', + 'resolveApplePlatformName', + 'resolveAppleSimulatorSetPathForSelector', + 'resolveDevice', + 'resolveDeviceAppleOs', + 'sortAppleDevicesForSelection', + ], + ], + [ + '@agent-device/kernel/snapshot', + [ + 'HiddenContentHint', + 'Point', + 'REF_GRAMMAR_HINT', + 'RawSnapshotNode', + 'Rect', + 'ScreenshotOverlayRef', + 'SnapshotBackend', + 'SnapshotNode', + 'SnapshotOptions', + 'SnapshotPresentationFlagInput', + 'SnapshotQualityVerdict', + 'SnapshotState', + 'SnapshotUnchanged', + 'SnapshotVisibility', + 'SnapshotVisibilityReason', + 'SplitRef', + 'attachRefs', + 'buildSnapshotPresentationKey', + 'centerOfRect', + 'findNodeByRef', + 'isSnapshotBackend', + 'normalizeRef', + 'snapshotPresentationOptionsFromFlags', + 'splitRefGenerationSuffix', + 'usesMobileSnapshotPresentation', + ], + ], + [ + '@agent-device/kernel/contracts', + [ + 'AppErrorCode', + 'CommandRpcParams', + 'DaemonArtifact', + 'DaemonArtifactKnownType', + 'DaemonArtifactType', + 'DaemonInstallSource', + 'DaemonLockPolicy', + 'DaemonRequest', + 'DaemonRequestMeta', + 'DaemonResponse', + 'DaemonResponseData', + 'DaemonServerMode', + 'DaemonTransportPreference', + 'JsonRpcId', + 'JsonRpcRequestEnvelope', + 'LeaseBackend', + 'NETWORK_INCLUDE_MODES', + 'NetworkIncludeMode', + 'RESPONSE_LEVELS', + 'Rect', + 'ResponseCost', + 'ResponseLevel', + 'SessionIsolationMode', + 'SessionRuntimeHints', + 'SnapshotNode', + 'centerOfRect', + 'commandRpcParamsSchema', + 'daemonRuntimeSchema', + 'defaultHintForCode', + 'isNonDefaultResponseLevel', + 'jsonRpcRequestSchema', + 'normalizeError', + ], + ], + ['@agent-device/kernel/collections', ['uniqueStrings']], + ['@agent-device/kernel/rect', ['isPositiveFiniteRect', 'rectArea', 'rectContains']], + ['@agent-device/kernel/redaction', ['redactDiagnosticData']], + ['@agent-device/kernel/bounds', ['parseBounds']], + [ + '@agent-device/maestro', + [ + 'MAESTRO_COMPATIBILITY_ADR_URL', + 'MAESTRO_COMPATIBILITY_ISSUE_URL', + 'MAESTRO_COMPAT_LIMITATIONS', + 'MAESTRO_COMPAT_SUPPORTED_CAPABILITIES', + 'MAESTRO_RUNTIME_ADAPTER_POLICY', + 'MaestroActionEvent', + 'MaestroCompletedActionEvent', + 'MaestroDispatchSelector', + 'MaestroExecutionObserver', + 'MaestroExecutionOptions', + 'MaestroExecutionOutcome', + 'MaestroExportOptions', + 'MaestroExportResult', + 'MaestroExportWarning', + 'MaestroFailedAction', + 'MaestroFlow', + 'MaestroObservation', + 'MaestroObservationCondition', + 'MaestroObservationIdentity', + 'MaestroPlatform', + 'MaestroRuntimeCommand', + 'MaestroRuntimeMetrics', + 'MaestroRuntimeOperationContext', + 'MaestroRuntimeOperationResult', + 'MaestroRuntimeOperations', + 'MaestroRuntimePort', + 'MaestroRuntimePortLifecycle', + 'MaestroRuntimeReadContext', + 'MaestroSelector', + 'MaestroSinglePointerGestureInput', + 'MaestroSnapshotTargetQuery', + 'MaestroTargetMatch', + 'MaestroTargetQuery', + 'MaestroTargetResolution', + 'collectMaestroFailureSuggestions', + 'createMaestroRuntimePort', + 'executeMaestroFlow', + 'exportReplayActionsToMaestro', + 'formatMaestroCompatibilityReference', + 'inspectMaestroFlow', + 'literalFromMaestroRegex', + 'maestroObservationMatches', + 'maestroTestFailure', + 'resolveMaestroScrollableGesture', + 'resolveMaestroTargetFromSnapshot', + ], + ], + [ + '@agent-device/provider-limrun', + [ + 'LIMRUN_PROVIDER', + 'LimrunAndroidDeviceSession', + 'LimrunConnectionVerification', + 'LimrunConnectionVerificationOptions', + 'LimrunIosCommandExecution', + 'LimrunIosDeviceSession', + 'LimrunRuntime', + 'LimrunRuntimeDependencies', + 'LimrunRuntimeOptions', + 'createLimrunRuntime', + 'verifyLimrunConnection', + ], + ], + [ + '@agent-device/provider-webdriver', + [ + 'CLOUD_WEBDRIVER_PROVIDERS', + 'CloudWebDriverConnectionVerification', + 'CloudWebDriverConnectionVerificationOptions', + 'CloudWebDriverKnownProviderName', + 'DefaultCloudWebDriverArtifactEnv', + 'DefaultCloudWebDriverProviderRuntimeEnv', + 'ProviderWebDriver', + 'ProviderWebDriverDependencies', + 'RunHostCommand', + 'browserStackOnlyDeviceFeatureFlags', + 'createProviderWebDriver', + 'isCloudWebDriverProviderName', + 'readAwsDeviceFarmRegionFromArn', + 'rejectBrowserStackOnlyDeviceFeatures', + ], + ], + [ + '@agent-device/replay-test', + [ + 'ReplayTestAttemptCancellation', + 'ReplayTestAttemptError', + 'ReplayTestAttemptFailed', + 'ReplayTestAttemptOutcome', + 'ReplayTestAttemptPassed', + 'ReplayTestAttemptStep', + 'ReplayTestAttemptStepSink', + 'ReplayTestBindAttemptCancellation', + 'ReplayTestCleanupSession', + 'ReplayTestDiscoverSources', + 'ReplayTestEmitDiagnostic', + 'ReplayTestEmitProgress', + 'ReplayTestExecutionDependencies', + 'ReplayTestFinalizeAttempt', + 'ReplayTestIsCanceled', + 'ReplayTestManifest', + 'ReplayTestPlatform', + 'ReplayTestResolveShardTargets', + 'ReplayTestRunReplay', + 'ReplayTestRunReplayParams', + 'ReplayTestRuntimeDependencies', + 'ReplayTestShardContext', + 'ReplayTestShardMode', + 'ReplayTestShardTarget', + 'ReplayTestSource', + 'ReplayTestSuiteOutcome', + 'ReplayTestSuiteRequest', + 'ReplayTestTarget', + 'runReplayTestSuite', + ], + ], + [ + '@agent-device/xml', + [ + 'XmlNode', + 'XmlParseOptions', + 'decodeXmlCharacterReferences', + 'escapeXmlTextAndAttribute', + 'parseXmlDocumentSync', + ], + ], +]; diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 489a9fecd..4652e83b9 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -6,6 +6,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'node:test'; +import { FACADE_SYMBOLS } from './facade-symbols.ts'; import { checkPackageBoundaries, checkPackageInternalSites, @@ -179,906 +180,92 @@ test('readFacadeExports refuses a bare `export *` across a package specifier', ( } }); -test('readFacadeExports still rejects a default export reached through the chain', () => { - // A default is no more enumerable behind a barrel than in front of one. +/** Write throwaway modules next to a real façade; always clean them up. */ +function withProbeModules(files: Record, run: (dir: string) => void): void { const dir = path.join(repoRoot, 'packages/contracts/src/facades'); - const leaf = path.join(dir, '.default-leaf-probe.ts'); - const barrel = path.join(dir, '.default-barrel-probe.ts'); - fs.writeFileSync(leaf, 'export default function leak() {}\n'); - fs.writeFileSync(barrel, "export * from './.default-leaf-probe.ts';\n"); + const written = Object.entries(files).map(([name, source]) => { + const file = path.join(dir, name); + fs.writeFileSync(file, source); + return file; + }); try { - assert.throws(() => readFacadeExports(barrel), /export default/); + run(dir); } finally { - fs.rmSync(leaf); - fs.rmSync(barrel); + for (const file of written) fs.rmSync(file, { force: true }); } +} + +test('readFacadeExports excludes a default that a star export cannot reach', () => { + // #1574 review P1: `export *` skips the child's default per + // GetExportedNames, so a private default in a leaf is NOT part of the + // barrel's surface. Counterfactual: the named sibling still comes through, + // proving the leaf is genuinely being read and the default specifically — + // not the whole module — is what got dropped. + withProbeModules( + { + '.leaf-probe.ts': 'export default function hidden() {}\nexport const reachable = 1;\n', + '.barrel-probe.ts': "export * from './.leaf-probe.ts';\n", + }, + (dir) => { + assert.deepEqual(readFacadeExports(path.join(dir, '.barrel-probe.ts')), ['reachable']); + }, + ); }); -// Every workspace package façade, pinned to its exact exported-symbol list. -// -// #1555 established this gate for `@agent-device/ad-replay` (asserted -// separately below, beside the design rationale for that package's two-value -// façade). The exports-subpath checks in the R11 tree test prove only WHICH -// files a package exposes; the symbol lists here prove WHAT those files name. -// Without them a façade grows a symbol silently and only a diff review — of -// the package, not of the gate — would ever notice. -// -// The list is the honest current surface, deliberately untrimmed: several of -// these are wider than their owners would design today (`contracts/interaction` -// alone names 140 symbols), and pinning the real number is what makes the next -// widening visible. Narrowing a façade is a change to that package, with its -// own consumers to fix — not a silent edit to this table. -// -// Maintaining it is mechanical: run the gate, and the assertion prints the -// exact added/removed names. Adding a symbol to a façade means adding it here -// in the same commit — that coupling IS the gate. -const FACADE_SYMBOLS: readonly (readonly [string, readonly string[]])[] = [ - [ - '@agent-device/ad-script', - [ - 'LocalIdentity', - 'ParsedReplayScript', - 'REPLAY_VAR_KEY_RE', - 'ReplayScriptMetadata', - 'ReplayVarScope', - 'TARGET_ANNOTATION_MAX_ANCESTRY', - 'TARGET_ANNOTATION_MAX_FIELD_BYTES', - 'TARGET_ANNOTATION_MAX_PAYLOAD_BYTES', - 'TargetBindingClassification', - 'TargetBindingClassificationInput', - 'annotationLocalIdentity', - 'appendScriptSeriesFlags', - 'buildReplayVarScope', - 'classifyTargetBindingMatch', - 'collectReplayScrubbableVarValues', - 'collectReplayShellEnv', - 'firstAncestryMismatch', - 'formatDivergenceActionLabel', - 'formatPortableActionLine', - 'formatScriptArg', - 'formatScriptStringLiteral', - 'formatTargetAnnotationLines', - 'identityFieldMismatches', - 'isClickLikeCommand', - 'isTouchTargetCommand', - 'matchesAncestryPrefix', - 'matchesLocalIdentity', - 'normalizeIdentifierField', - 'normalizeLabelField', - 'normalizeRoleField', - 'parseReplayCliEnvEntries', - 'parseReplayScriptDetailed', - 'parseTargetAnnotationV1Payload', - 'readReplayCliEnvEntries', - 'readReplayScriptMetadata', - 'readReplayShellEnvSource', - 'resolveDeclaredScriptPlatform', - 'resolveReplayAction', - 'serializeTargetAnnotationV1', - 'stripRecordedRefGeneration', - 'truncateToUtf8Bytes', - 'utf8ByteLength', - ], - ], - [ - '@agent-device/contracts/client', - [ - 'AgentDeviceCapabilitiesResult', - 'AgentDeviceClientConfig', - 'AgentDeviceDaemonTransport', - 'AgentDeviceDaemonTransportContext', - 'AgentDeviceDevice', - 'AgentDeviceIdentifiers', - 'AgentDeviceRequestOverrides', - 'AgentDeviceSelectionOptions', - 'AgentDeviceSession', - 'AgentDeviceSessionDevice', - 'AlertCommandOptions', - 'AppCloseOptions', - 'AppCloseResult', - 'AppDeployOptions', - 'AppDeployResult', - 'AppInstallFromSourceOptions', - 'AppInstallFromSourceResult', - 'AppInstallOptions', - 'AppListOptions', - 'AppOpenOptions', - 'AppOpenResult', - 'AppPushOptions', - 'AppStateCommandOptions', - 'AppTriggerEventOptions', - 'AudioOptions', - 'BatchRunOptions', - 'BatchStep', - 'CaptureDiffOptions', - 'CaptureScreenshotOptions', - 'CaptureScreenshotResult', - 'CaptureSnapshotOptions', - 'CaptureSnapshotResult', - 'ClickOptions', - 'ClipboardCommandOptions', - 'CloudArtifactsOptions', - 'CommandExecutionOptions', - 'CommandRequestResult', - 'DeviceBootOptions', - 'DeviceCommandBaseOptions', - 'DeviceShutdownOptions', - 'DoctorCommandOptions', - 'ElementTarget', - 'EventsOptions', - 'FillOptions', - 'FindBaseOptions', - 'FindOptions', - 'FindSnapshotCommandOptions', - 'FlingOptions', - 'FocusOptions', - 'GetOptions', - 'InteractionTarget', - 'InternalRequestOptions', - 'IsOptions', - 'IsStatePredicateOptions', - 'IsTextPredicateOptions', - 'JsonObject', - 'JsonPrimitive', - 'JsonValue', - 'KeyboardCommandOptions', - 'Lease', - 'LeaseAllocateOptions', - 'LeaseOptions', - 'LeaseScopedOptions', - 'LogsOptions', - 'LongPressOptions', - 'MaterializationReleaseOptions', - 'MaterializationReleaseResult', - 'NetworkOptions', - 'PanOptions', - 'PerfOptions', - 'PermissionTarget', - 'PinchOptions', - 'PointTarget', - 'PrepareCommandOptions', - 'PressOptions', - 'ReactNativeCommandOptions', - 'RecordControlOptions', - 'RecordOptions', - 'RefTarget', - 'RepeatedPressOptions', - 'ReplayRunOptions', - 'ReplayTestOptions', - 'RotateGestureOptions', - 'ScrollOptions', - 'SelectorSnapshotCommandOptions', - 'SelectorTarget', - 'SessionCloseResult', - 'SessionSaveScriptOptions', - 'SessionSaveScriptResult', - 'SettingsUpdateOptions', - 'SettleCommandOptions', - 'StartupPerfSample', - 'SwipeGestureOptions', - 'SwipeOptions', - 'TraceOptions', - 'TransformGestureOptions', - 'TypeTextOptions', - 'ViewportCommandOptions', - 'WaitCommandOptions', - 'WaitCommandTarget', - 'isRecord', - ], - ], - [ - '@agent-device/contracts/command', - [ - 'CliFlags', - 'CommandFlags', - 'DEFAULT_BATCH_MAX_STEPS', - 'DaemonBatchStep', - 'DaemonExcludedCliFlag', - 'DispatchedCommand', - 'IOS_SAFARI_BUNDLE_ID', - 'MaestroRuntimeFlags', - 'PrepareCommandResult', - 'PrepareIosRunnerArtifactState', - 'PrepareIosRunnerCacheKind', - 'PrepareIosRunnerTiming', - 'PushCommandResult', - 'assertBatchStepCount', - 'isDeepLinkTarget', - 'isValidBatchMaxSteps', - 'isWebUrl', - 'parseBatchStepRuntime', - 'readBatchStepInputObject', - 'readBatchStepRecord', - 'readOptionalInteger', - 'resolveIosDeviceDeepLinkBundleId', - ], - ], - [ - '@agent-device/contracts/device', - [ - 'AppStateCommandResult', - 'AppsFilter', - 'BootCommandResult', - 'DEFAULT_APPS_FILTER', - 'DEVICE_ROTATIONS', - 'DEVICE_ROTATION_SURFACE_INDEX', - 'DeviceInventoryGroup', - 'DeviceInventoryGroupCounts', - 'DeviceInventoryProvider', - 'DeviceInventoryRequest', - 'DeviceLease', - 'DeviceRotation', - 'LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS', - 'LeaseLifecycleContext', - 'LeaseLifecycleProvider', - 'ProviderDeviceInstallOptions', - 'ProviderDeviceInstallResult', - 'ProviderDeviceRuntime', - 'ProviderExpiredLeaseRecovery', - 'ProviderPortReverseOptions', - 'ShutdownCommandResult', - 'TargetShutdownResult', - 'TriggerAppEventCommandResult', - 'WEB_DESKTOP_DEVICE', - 'assertResolvedAppsFilter', - 'countDeviceInventoryByGroup', - 'deviceRotationOrientation', - 'deviceRotationSurfaceDegrees', - 'parseDeviceRotation', - 'resolveAppsFilter', - 'shouldUseHostMacFastPath', - ], - ], - [ - '@agent-device/contracts/interaction', - [ - 'ALERT_ACTIONS', - 'ALERT_ACTION_RETRY_MS', - 'ALERT_POLL_INTERVAL_MS', - 'AlertAction', - 'AlertInfo', - 'AlertPlatform', - 'AlertSource', - 'AppSwitcherCommandResult', - 'AppleTvRemoteButton', - 'BACK_MODES', - 'BackCommandResult', - 'BackMode', - 'CLICK_BUTTONS', - 'ClickButton', - 'ClickCommandResponseData', - 'ClipboardCommandResult', - 'DEFAULT_ALERT_TIMEOUT_MS', - 'DisambiguationTiebreak', - 'ElementSelectorKey', - 'ElementSelectorTapOptions', - 'ElementTarget', - 'FillCommandResponseData', - 'FillCommandResult', - 'FindCommandResponseData', - 'FlingGesturePayload', - 'GESTURE_DURATION_MAX_MS', - 'GESTURE_DURATION_MIN_MS', - 'GESTURE_FLING_DURATION_MS', - 'GESTURE_INITIAL_ANGLE_DEGREES', - 'GESTURE_KINDS', - 'GESTURE_SAMPLE_INTERVAL_MS', - 'GestureExecutionProfile', - 'GestureIntent', - 'GesturePayload', - 'GesturePlan', - 'GesturePointerCount', - 'GestureReferenceFrame', - 'GestureSemanticInput', - 'GuaranteeEnforcement', - 'HomeCommandResult', - 'INTERACTION_DISPATCH_PATHS', - 'INTERACTION_GUARANTEES', - 'INTERACTION_PATH_IDS', - 'InPageSwipeGesturePlan', - 'InteractionEvidence', - 'InteractionGuarantee', - 'InteractionPathContract', - 'InteractionPathId', - 'InteractionTarget', - 'Interactor', - 'KeyboardCommandResult', - 'LongPressCommandResponseData', - 'LongPressCommandResult', - 'MAESTRO_NON_HITTABLE_FALLBACK_MESSAGE', - 'MultiTouchGesturePlan', - 'NormalizedPublicGesture', - 'OrientationCommandResult', - 'PanGesturePayload', - 'PinchGesturePayload', - 'PointTarget', - 'PointerTrajectory', - 'PointerTrajectorySample', - 'PressCommandResponseData', - 'PressCommandResult', - 'RecordingTargetOverride', - 'RefTarget', - 'ResolutionDiagnosticEntry', - 'ResolutionDisclosure', - 'ResolvedInteractionTarget', - 'ResolvedTarget', - 'RotateCommandResult', - 'RotateGesturePayload', - 'RunnerCallOptions', - 'RunnerContext', - 'SCROLL_DIRECTIONS', - 'SCROLL_DURATION_MAX_MS', - 'SCROLL_INPUT_DIRECTIONS', - 'SWIPE_PATTERNS', - 'SWIPE_PAUSE_MAX_MS', - 'SWIPE_PRESETS', - 'SWIPE_REPETITION_MAX', - 'SWIPE_SERIES_MAX_SCHEDULED_DURATION_MS', - 'ScreenshotOptions', - 'ScrollCommandOptions', - 'ScrollDirection', - 'ScrollDistanceOptions', - 'ScrollGestureOptions', - 'ScrollGesturePlan', - 'ScrollInputDirection', - 'ScrollTimingOptions', - 'SelectorTarget', - 'SettleDiffLine', - 'SettleObservation', - 'SettleParams', - 'SettleTailEntry', - 'SinglePointerGesturePlan', - 'SnapshotOptions', - 'SnapshotResult', - 'SwipeGesturePayload', - 'SwipePattern', - 'SwipePayload', - 'SwipePreset', - 'SwipePresetGesturePlan', - 'TV_REMOTE_BUTTONS', - 'TV_REMOTE_BUTTON_USAGE', - 'TransformGestureParams', - 'TransformGesturePayload', - 'TvRemoteButton', - 'TvRemoteCommandResult', - 'VegaTvRemoteKey', - 'WaitCommandResult', - 'assertExclusiveScrollDistanceInputs', - 'assertNoRemovedSwipeInput', - 'assertScrollGestureInput', - 'buildGesturePlan', - 'buildInPageSwipeGesturePlan', - 'buildScrollGesturePlan', - 'buildSwipePresetGesturePlan', - 'buttonTag', - 'clampGestureCoordinate', - 'describeReplayGestureArityError', - 'gestureDirectionDelta', - 'gesturePayloadFromPositionals', - 'gesturePayloadToPositionals', - 'getClickButtonValidationError', - 'honoredScrollDurationMs', - 'inferGestureReferenceFrame', - 'normalizePublicGesture', - 'normalizePublicSwipeMotion', - 'normalizeScrollDurationMs', - 'parseScrollDirection', - 'parseTvRemoteButton', - 'readGesturePayload', - 'resolveClickButton', - 'singlePointerPlanEndpoints', - 'swipePayloadFromPositionals', - 'toAndroidTvRemoteKeyevent', - 'toAppleTvRemoteButton', - 'toVegaTvRemoteKey', - 'tvRemoteDurationMode', - ], - ], - [ - '@agent-device/contracts/capture', - [ - 'AndroidSnapshotBackendMetadata', - 'BackendSnapshotOptions', - 'BackendSnapshotResult', - 'DiffSnapshotCommandResult', - 'FindLocator', - 'PublicSnapshotCaptureAnnotations', - 'SCREENSHOT_ACTION_FLAG_KEYS', - 'SCREENSHOT_COMMAND_FLAG_KEYS', - 'SCREENSHOT_SPECIFIC_FLAG_DEFINITIONS', - 'ScreenshotDispatchFlags', - 'ScreenshotPublicOptions', - 'ScreenshotRequestFlags', - 'ScreenshotResultData', - 'ScreenshotRuntimeFlags', - 'ScreenshotRuntimeOptions', - 'SnapshotCaptureAnalysis', - 'SnapshotCaptureAnnotations', - 'SnapshotCaptureFreshness', - 'SnapshotDiagnosticsState', - 'SnapshotDiagnosticsSummary', - 'SnapshotDiffLine', - 'SnapshotDiffSummary', - 'SnapshotTimingSample', - 'SnapshotTimingStats', - 'ViewportCommandResult', - 'appendScreenshotScriptFlags', - 'mergeSnapshotDiagnostics', - 'publicSnapshotCaptureAnnotations', - 'readScreenshotScriptFlag', - 'readSerializedSnapshotCaptureAnnotations', - 'readSnapshotDiagnosticsSummary', - 'recordSnapshotTiming', - 'screenshotFlagsFromOptions', - 'screenshotOptionsFromFlags', - 'snapshotCaptureAnnotationsFrom', - 'summarizeSnapshotDiagnostics', - 'summarizeSnapshotTimingSamples', - ], - ], - [ - '@agent-device/contracts/platform', - [ - 'ANDROID_SYSTEM_CHROME_PACKAGE', - 'AndroidInputOwner', - 'AndroidInputOwnership', - 'AndroidInputOwnershipSource', - 'AndroidSystemChromeProvenance', - 'AudioProbeResult', - 'AudioProbeSource', - 'EmptyAudioProbeResultOptions', - 'NormalizeAudioProbeRecordOptions', - 'PlatformGatedProviderResolverKey', - 'PlatformPlugin', - 'RunnerLogicalLeaseContext', - 'assertAppleMultiTouchSupported', - 'classifyAndroidInputOwner', - 'classifyAndroidInputOwnership', - 'emptyAudioProbeResult', - 'hasAndroidSystemChromeProvenance', - 'isAndroidInputMethodOwnedNode', - 'isAndroidSystemChromeWindowResourceId', - 'isAudioProbeSupportedDevice', - 'isFallbackAndroidInputMethodPackage', - 'isFallbackAndroidInputMethodResource', - 'isHostSystemAudioProbeDevice', - 'normalizeAudioProbeRecord', - 'parseAndroidInputMethodPackage', - 'readAndroidActiveInputMethodPackage', - 'stripAndroidSystemChromeProvenance', - 'stripAndroidSystemChromeProvenanceFromNode', - ], - ], - [ - '@agent-device/contracts/settings', - [ - 'PermissionAction', - 'PermissionTarget', - 'SETTINGS_INVALID_ARGS_MESSAGE', - 'SETTINGS_USAGE_OVERRIDE', - 'SettingOptions', - 'getUnsupportedMacOsSettingMessage', - 'isMacOsSettingSupported', - 'parsePermissionAction', - 'parsePermissionTarget', - ], - ], - [ - '@agent-device/contracts/session', - ['SESSION_SURFACES', 'SessionAction', 'SessionSurface', 'parseSessionSurface'], - ], - [ - '@agent-device/contracts/recording', - [ - 'DEFAULT_RECORDING_EXPORT_QUALITY', - 'RECORDING_EXPORT_QUALITIES', - 'RECORDING_SCOPE_VALUES', - 'RecordingAppIdentity', - 'RecordingBackendTag', - 'RecordingCommandResult', - 'RecordingExportQuality', - 'RecordingScope', - 'RecordingStartCommandResult', - 'RecordingStopCommandResult', - 'TraceCommandResult', - 'isRecordingExportQuality', - 'isWholeScreenRecordingScope', - 'recordingQualityInputToExportQuality', - ], - ], - [ - '@agent-device/contracts/observability', - [ - 'AgentArtifactsResult', - 'CloudArtifact', - 'CloudArtifactAvailability', - 'CloudArtifactKind', - 'CloudArtifactProvider', - 'CloudArtifactsQuery', - 'CloudArtifactsResult', - 'CloudArtifactsStatus', - 'CloudProviderSessionResult', - 'DaemonArtifactInventoryEntry', - 'DaemonArtifactsResult', - 'DebugSymbolsCrashFrame', - 'DebugSymbolsCrashSummary', - 'DebugSymbolsImage', - 'DebugSymbolsOptions', - 'DebugSymbolsResult', - 'DoctorCheck', - 'DoctorCommandResult', - 'DoctorKind', - 'DoctorStatus', - 'LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE', - 'LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE', - 'LOG_ACTION_VALUES', - 'LogAction', - 'LogBackend', - 'NetworkEntry', - 'PERF_ACTION_ERROR_MESSAGE', - 'PERF_ACTION_VALUES', - 'PERF_AREA_ERROR_MESSAGE', - 'PERF_AREA_VALUES', - 'PERF_KIND_ERROR_MESSAGE', - 'PERF_KIND_VALUES', - 'PERF_MEMORY_KIND_ERROR_MESSAGE', - 'PERF_SUBJECT_ERROR_MESSAGE', - 'PERF_SUBJECT_VALUES', - 'PerfAction', - 'PerfArea', - 'PerfKind', - 'PerfMetricsSamplerTag', - 'PerfSubject', - 'isPerfAction', - 'isPerfArea', - 'isPerfKind', - 'isPerfMemoryKind', - 'isPerfSubject', - ], - ], - [ - '@agent-device/contracts/remote', - [ - 'CloudProviderProfileFields', - 'CompanionTunnelScope', - 'MetroBridgeResult', - 'MetroBridgeScope', - 'MetroPrepareKind', - 'MetroPrepareOptions', - 'MetroPrepareResult', - 'MetroReloadOptions', - 'MetroReloadResult', - 'PROVIDER_DEVICE_ORIENTATIONS', - 'PrepareMetroRuntimeResult', - 'ProviderConnectionResource', - 'ProviderConnectionVerification', - 'ProviderDeviceOrientation', - 'ReloadMetroResult', - 'RemoteConfigMetroOptions', - 'RemoteConnectionProfileFields', - 'ResolvedMetroKind', - ], - ], - [ - '@agent-device/contracts/replay', - [ - 'RefFrameEffect', - 'ReplayCommandResult', - 'ReplaySuiteAttemptFailure', - 'ReplaySuiteResult', - 'ReplaySuiteTestFailed', - 'ReplaySuiteTestPassed', - 'ReplaySuiteTestResult', - 'ReplaySuiteTestSkipReason', - 'ReplaySuiteTestSkipped', - 'TargetAncestryEntry', - 'TargetAnnotationV1', - 'TargetRect', - 'TargetScrollRegion', - 'TargetVerification', - ], - ], - [ - '@agent-device/contracts/divergence', - [ - 'REPLAY_DIVERGENCE_DEFAULT_REF_LIMIT', - 'REPLAY_DIVERGENCE_DIGEST_REF_LIMIT', - 'REPLAY_DIVERGENCE_LEVEL_BYTE_LIMITS', - 'REPLAY_DIVERGENCE_SUGGESTION_LIMIT', - 'ReplayDivergence', - 'ReplayDivergenceCause', - 'ReplayDivergenceKind', - 'ReplayDivergenceOverflow', - 'ReplayDivergenceResume', - 'ReplayDivergenceScreen', - 'ReplayDivergenceScreenRef', - 'ReplayDivergenceStep', - 'ReplayDivergenceStepSource', - 'ReplayDivergenceSuggestion', - 'ReplayDivergenceSuggestionBasis', - 'ReplayDivergenceTargetBinding', - 'ReplayDivergenceTargetBindingKind', - 'ReplayDivergenceTargetCandidate', - 'ReplayDivergenceTargetIdentity', - 'ReplayRepairHint', - 'ReplayVarScrubEntry', - 'applyReplayDivergenceLevelCaps', - 'boundReplayDivergence', - 'createReplayDivergenceSanitizer', - 'formatReplayDivergenceReport', - 'measureReplayDivergenceBytes', - 'sanitizeReplayDivergenceField', - 'scrubReplayVarValues', - 'truncateUtf8Field', - ], - ], - [ - '@agent-device/contracts/progress', - [ - 'CommandProgressEvent', - 'ReplayTestProgressEvent', - 'ReplayTestSuiteProgressEvent', - 'RequestProgressEvent', - 'RequestProgressSink', - ], - ], - [ - '@agent-device/kernel/errors', - [ - 'AppError', - 'AppErrorCode', - 'AppErrorDetails', - 'DaemonError', - 'KNOWN_APP_ERROR_CODES', - 'KnownAppErrorCode', - 'NormalizedError', - 'asAppError', - 'defaultHintForCode', - 'isAgentDeviceError', - 'normalizeAgentDeviceError', - 'normalizeError', - 'retriableForErrorCode', - 'throwDaemonError', - 'toAppErrorCode', - ], - ], - [ - '@agent-device/kernel/device', - [ - 'AppleOS', - 'ApplePlatform', - 'DEVICE_TARGETS', - 'DeviceInfo', - 'DeviceKind', - 'DeviceSelector', - 'DeviceTarget', - 'PLATFORMS', - 'PLATFORM_SELECTORS', - 'PUBLIC_PLATFORMS', - 'Platform', - 'PlatformSelector', - 'PublicPlatform', - 'deviceFieldsFromPublicPlatform', - 'isAppleOs', - 'isApplePlatform', - 'isIosFamily', - 'isMacOs', - 'isMobilePlatform', - 'isPlatform', - 'isPublicPlatform', - 'isSerialAddressablePlatform', - 'isTvOsDevice', - 'matchesDeviceSelector', - 'matchesPlatformSelector', - 'publicPlatformString', - 'resolveApplePlatformName', - 'resolveAppleSimulatorSetPathForSelector', - 'resolveDevice', - 'resolveDeviceAppleOs', - 'sortAppleDevicesForSelection', - ], - ], - [ - '@agent-device/kernel/snapshot', - [ - 'HiddenContentHint', - 'Point', - 'REF_GRAMMAR_HINT', - 'RawSnapshotNode', - 'Rect', - 'ScreenshotOverlayRef', - 'SnapshotBackend', - 'SnapshotNode', - 'SnapshotOptions', - 'SnapshotPresentationFlagInput', - 'SnapshotQualityVerdict', - 'SnapshotState', - 'SnapshotUnchanged', - 'SnapshotVisibility', - 'SnapshotVisibilityReason', - 'SplitRef', - 'attachRefs', - 'buildSnapshotPresentationKey', - 'centerOfRect', - 'findNodeByRef', - 'isSnapshotBackend', - 'normalizeRef', - 'snapshotPresentationOptionsFromFlags', - 'splitRefGenerationSuffix', - 'usesMobileSnapshotPresentation', - ], - ], - [ - '@agent-device/kernel/contracts', - [ - 'AppErrorCode', - 'CommandRpcParams', - 'DaemonArtifact', - 'DaemonArtifactKnownType', - 'DaemonArtifactType', - 'DaemonInstallSource', - 'DaemonLockPolicy', - 'DaemonRequest', - 'DaemonRequestMeta', - 'DaemonResponse', - 'DaemonResponseData', - 'DaemonServerMode', - 'DaemonTransportPreference', - 'JsonRpcId', - 'JsonRpcRequestEnvelope', - 'LeaseBackend', - 'NETWORK_INCLUDE_MODES', - 'NetworkIncludeMode', - 'RESPONSE_LEVELS', - 'Rect', - 'ResponseCost', - 'ResponseLevel', - 'SessionIsolationMode', - 'SessionRuntimeHints', - 'SnapshotNode', - 'centerOfRect', - 'commandRpcParamsSchema', - 'daemonRuntimeSchema', - 'defaultHintForCode', - 'isNonDefaultResponseLevel', - 'jsonRpcRequestSchema', - 'normalizeError', - ], - ], - ['@agent-device/kernel/collections', ['uniqueStrings']], - ['@agent-device/kernel/rect', ['isPositiveFiniteRect', 'rectArea', 'rectContains']], - ['@agent-device/kernel/redaction', ['redactDiagnosticData']], - ['@agent-device/kernel/bounds', ['parseBounds']], - [ - '@agent-device/maestro', - [ - 'MAESTRO_COMPATIBILITY_ADR_URL', - 'MAESTRO_COMPATIBILITY_ISSUE_URL', - 'MAESTRO_COMPAT_LIMITATIONS', - 'MAESTRO_COMPAT_SUPPORTED_CAPABILITIES', - 'MAESTRO_RUNTIME_ADAPTER_POLICY', - 'MaestroActionEvent', - 'MaestroCompletedActionEvent', - 'MaestroDispatchSelector', - 'MaestroExecutionObserver', - 'MaestroExecutionOptions', - 'MaestroExecutionOutcome', - 'MaestroExportOptions', - 'MaestroExportResult', - 'MaestroExportWarning', - 'MaestroFailedAction', - 'MaestroFlow', - 'MaestroObservation', - 'MaestroObservationCondition', - 'MaestroObservationIdentity', - 'MaestroPlatform', - 'MaestroRuntimeCommand', - 'MaestroRuntimeMetrics', - 'MaestroRuntimeOperationContext', - 'MaestroRuntimeOperationResult', - 'MaestroRuntimeOperations', - 'MaestroRuntimePort', - 'MaestroRuntimePortLifecycle', - 'MaestroRuntimeReadContext', - 'MaestroSelector', - 'MaestroSinglePointerGestureInput', - 'MaestroSnapshotTargetQuery', - 'MaestroTargetMatch', - 'MaestroTargetQuery', - 'MaestroTargetResolution', - 'collectMaestroFailureSuggestions', - 'createMaestroRuntimePort', - 'executeMaestroFlow', - 'exportReplayActionsToMaestro', - 'formatMaestroCompatibilityReference', - 'inspectMaestroFlow', - 'literalFromMaestroRegex', - 'maestroObservationMatches', - 'maestroTestFailure', - 'resolveMaestroScrollableGesture', - 'resolveMaestroTargetFromSnapshot', - ], - ], - [ - '@agent-device/provider-limrun', - [ - 'LIMRUN_PROVIDER', - 'LimrunAndroidDeviceSession', - 'LimrunConnectionVerification', - 'LimrunConnectionVerificationOptions', - 'LimrunIosCommandExecution', - 'LimrunIosDeviceSession', - 'LimrunRuntime', - 'LimrunRuntimeDependencies', - 'LimrunRuntimeOptions', - 'createLimrunRuntime', - 'verifyLimrunConnection', - ], - ], - [ - '@agent-device/provider-webdriver', - [ - 'CLOUD_WEBDRIVER_PROVIDERS', - 'CloudWebDriverConnectionVerification', - 'CloudWebDriverConnectionVerificationOptions', - 'CloudWebDriverKnownProviderName', - 'DefaultCloudWebDriverArtifactEnv', - 'DefaultCloudWebDriverProviderRuntimeEnv', - 'ProviderWebDriver', - 'ProviderWebDriverDependencies', - 'RunHostCommand', - 'browserStackOnlyDeviceFeatureFlags', - 'createProviderWebDriver', - 'isCloudWebDriverProviderName', - 'readAwsDeviceFarmRegionFromArn', - 'rejectBrowserStackOnlyDeviceFeatures', - ], - ], - [ - '@agent-device/replay-test', - [ - 'ReplayTestAttemptCancellation', - 'ReplayTestAttemptError', - 'ReplayTestAttemptFailed', - 'ReplayTestAttemptOutcome', - 'ReplayTestAttemptPassed', - 'ReplayTestAttemptStep', - 'ReplayTestAttemptStepSink', - 'ReplayTestBindAttemptCancellation', - 'ReplayTestCleanupSession', - 'ReplayTestDiscoverSources', - 'ReplayTestEmitDiagnostic', - 'ReplayTestEmitProgress', - 'ReplayTestExecutionDependencies', - 'ReplayTestFinalizeAttempt', - 'ReplayTestIsCanceled', - 'ReplayTestManifest', - 'ReplayTestPlatform', - 'ReplayTestResolveShardTargets', - 'ReplayTestRunReplay', - 'ReplayTestRunReplayParams', - 'ReplayTestRuntimeDependencies', - 'ReplayTestShardContext', - 'ReplayTestShardMode', - 'ReplayTestShardTarget', - 'ReplayTestSource', - 'ReplayTestSuiteOutcome', - 'ReplayTestSuiteRequest', - 'ReplayTestTarget', - 'runReplayTestSuite', - ], - ], - [ - '@agent-device/xml', - [ - 'XmlNode', - 'XmlParseOptions', - 'decodeXmlCharacterReferences', - 'escapeXmlTextAndAttribute', - 'parseXmlDocumentSync', - ], - ], -]; +test('readFacadeExports still rejects a default on the façade entry itself', () => { + // The other side of the same rule: the entry's own default IS a default + // export of the façade, and a façade pinned to a named list must not carry + // one. Same source text as the leaf above — only its position changed. + withProbeModules({ '.entry-default-probe.ts': 'export default function leak() {}\n' }, (dir) => { + assert.throws( + () => readFacadeExports(path.join(dir, '.entry-default-probe.ts')), + /export default/, + ); + }); +}); + +test('readFacadeExports rejects a name two star sources resolve differently', () => { + // ESM resolves this to `ambiguous`, so `clash` is not importable at all; + // unioning would pin a symbol no consumer can reach. + withProbeModules( + { + '.clash-a-probe.ts': 'export const clash = 1;\nexport const onlyA = 1;\n', + '.clash-b-probe.ts': 'export const clash = 2;\n', + '.clash-barrel-probe.ts': + "export * from './.clash-a-probe.ts';\nexport * from './.clash-b-probe.ts';\n", + }, + (dir) => { + assert.throws(() => readFacadeExports(path.join(dir, '.clash-barrel-probe.ts')), /ambiguous/); + }, + ); +}); + +test('readFacadeExports resolves a diamond and lets an explicit export shadow a star', () => { + // The two counterfactuals to the ambiguity rule, both of which a naive + // "two paths reached this name" check would wrongly reject. One shared + // declaration reached by two barrels is ONE binding, not a clash; and an + // explicit re-export of a name a star also provides is the spec's own + // precedence, not ambiguity. + withProbeModules( + { + '.shared-probe.ts': 'export const shared = 1;\n', + '.mid-one-probe.ts': "export * from './.shared-probe.ts';\n", + '.mid-two-probe.ts': "export * from './.shared-probe.ts';\n", + '.diamond-probe.ts': + "export * from './.mid-one-probe.ts';\nexport * from './.mid-two-probe.ts';\n", + '.shadow-src-probe.ts': 'export const shadowed = 1;\nexport const other = 2;\n', + '.shadow-probe.ts': + "export * from './.shadow-src-probe.ts';\nexport { shadowed } from './.shared-probe.ts';\n", + }, + (dir) => { + assert.deepEqual(readFacadeExports(path.join(dir, '.diamond-probe.ts')), ['shared']); + assert.deepEqual(readFacadeExports(path.join(dir, '.shadow-probe.ts')), [ + 'other', + 'shadowed', + ]); + }, + ); +}); test('every workspace package façade exports exactly its pinned symbol list', () => { const packages = readWorkspacePackages(repoRoot); diff --git a/scripts/layering/package-boundaries.ts b/scripts/layering/package-boundaries.ts index 9d56a806f..555bf98a6 100644 --- a/scripts/layering/package-boundaries.ts +++ b/scripts/layering/package-boundaries.ts @@ -125,6 +125,26 @@ export function readNamedExports(source: string): string[] { * without chain resolution the package with the largest and fastest-growing * public surface in the workspace is the one package that cannot be pinned. * + * The walk models what `export *` ACTUALLY re-exports, which is narrower than + * "every name in the child" in two ways a naive union gets wrong (#1574 + * review P1): + * + * 1. **`default` is excluded.** Per `GetExportedNames`, a star export skips + * the child's default entirely. A private `export default` in a leaf is + * not reachable through the barrel and does not widen the façade, so it is + * passed over rather than rejected. A default on the ENTRY file is still a + * real default export of the façade itself, and still throws. + * 2. **A name from two different star sources is ambiguous, not exported.** + * `ResolveExport` returns `ambiguous` when star resolution finds two + * distinct bindings for one name, and importing it is then a `SyntaxError` + * — the name is not part of the surface at all. Unioning would silently + * pin a symbol no consumer can import, so ambiguity throws instead, naming + * both origins. A diamond (two barrels reaching the SAME declaration) is + * not ambiguous and resolves normally, which is why origins are tracked by + * declaring module rather than by path taken. An explicit export in a + * module shadows any star-provided name of the same name, exactly as the + * spec's own precedence does. + * * Resolution is deliberately narrow — a RELATIVE specifier only, and the * repo's explicit-`.ts`-extension convention means the specifier is already * the path. A bare star across a PACKAGE specifier still throws: enumerating @@ -132,27 +152,44 @@ export function readNamedExports(source: string): string[] { * `exports` map, and a façade that re-exports a whole other package wholesale * is precisely the unbounded widening this gate exists to refuse. Cycles are * visit-guarded (a barrel pair that re-exported each other would otherwise - * recurse forever), and `export default` still throws through the chain — a - * default reached via a barrel is no more enumerable than a direct one. + * recurse forever). */ export function readFacadeExports(entryFile: string): string[] { - const names = new Set(); - const visited = new Set(); - const walk = (file: string): void => { + // `${declaringFile}#${name}` — binding identity, so the same declaration + // reached by two different barrel paths is one origin, not two. + const cache = new Map>(); + const walking = new Set(); + + const exportedNames = (file: string): Map => { const resolved = path.resolve(file); - if (visited.has(resolved)) return; - visited.add(resolved); + const cached = cache.get(resolved); + if (cached) return cached; + // A cycle contributes nothing further; whatever it exports is reached by + // the path that entered it first. + if (walking.has(resolved)) return new Map(); + walking.add(resolved); + const parsed = parseSync(resolved, fs.readFileSync(resolved, 'utf8')); + const explicit = new Map(); + const starOrigins = new Map>(); + for (const staticExport of parsed.module.staticExports) { for (const entry of staticExport.entries) { + const specifier = entry.moduleRequest?.value; + if (entry.exportName.kind === 'Default') { - throw new Error( - `readFacadeExports cannot enumerate 'export default …' as a named symbol (${resolved})` + - ' — a facade a caller pins to an exact named-export list must not carry one.', - ); + // Only the façade's own default is a default export of the façade. + if (resolved === path.resolve(entryFile)) { + throw new Error( + `readFacadeExports cannot enumerate 'export default …' as a named symbol ` + + `(${resolved}) — a facade a caller pins to an exact named-export list must not ` + + 'carry one.', + ); + } + continue; } + if (entry.exportName.kind === 'None') { - const specifier = entry.moduleRequest?.value; if (!specifier || !specifier.startsWith('.')) { throw new Error( `readFacadeExports cannot enumerate 'export * from ${specifier ?? '…'}' ` + @@ -160,15 +197,52 @@ export function readFacadeExports(entryFile: string): string[] { 'and enumerate in turn. Name the re-exported symbols explicitly instead.', ); } - walk(path.resolve(path.dirname(resolved), specifier)); + const childPath = path.resolve(path.dirname(resolved), specifier); + for (const [name, origin] of exportedNames(childPath)) { + let origins = starOrigins.get(name); + if (!origins) starOrigins.set(name, (origins = new Map())); + origins.set(origin, specifier); + } continue; } - if (entry.exportName.name) names.add(entry.exportName.name); + + const name = entry.exportName.name; + if (!name) continue; + // A re-export's binding belongs to the module it came from, so two + // façades re-exporting one shared symbol agree on its identity. + explicit.set( + name, + specifier + ? `${ + specifier.startsWith('.') + ? path.resolve(path.dirname(resolved), specifier) + : specifier + }#${entry.importName?.name ?? name}` + : `${resolved}#${name}`, + ); } } + + const names = new Map(explicit); + for (const [name, origins] of starOrigins) { + if (explicit.has(name)) continue; // explicit export shadows the star + if (origins.size > 1) { + throw new Error( + `readFacadeExports found '${name}' re-exported by ${origins.size} different ` + + `'export *' sources in ${resolved} (${[...origins.values()].sort().join(', ')}). ` + + 'ESM resolves that to `ambiguous`, so the name is not importable at all and must ' + + 'not be pinned as part of the surface. Re-export it explicitly from one source.', + ); + } + names.set(name, [...origins.keys()][0]!); + } + + walking.delete(resolved); + cache.set(resolved, names); + return names; }; - walk(entryFile); - return [...names].sort(); + + return [...exportedNames(entryFile).keys()].sort(); } export function readWorkspacePackages(repoRoot: string): WorkspacePackage[] { From d17fd6de2944e0510007f6620608666c58ea93c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 19:07:36 +0000 Subject: [PATCH 3/4] fix(layering): resolve re-export identity transitively; extract facade-exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses both P1 findings on the second review round. P1 — named re-export identity stopped at the immediate source. Given `a` re-exporting `x` from `b`, `c` re-exporting `x` from `a`, and a façade starring both, ESM resolves ONE binding (b's `x`), but the walker identified the two paths as `b#x` and `a#x` and falsely rejected the façade as ambiguous. Reproduced before fixing. Origins now resolve through the chain to the binding a name ultimately names, by asking the child's own already-resolved map instead of synthesizing an identity from the specifier. A package specifier keeps a stable synthetic identity (it is not a file this gate reads), and a cycle in progress falls back to the immediate source. Two tests, counterfactual-verified against each other: the chain diamond now resolves to one name (confirmed failing with the old immediate-source identity, passing with the fix), and a same-depth chain whose branches bottom out in two genuinely distinct declarations still throws — so the fix cannot be satisfied by simply collapsing every duplicate. P1 — context-safety extraction was incomplete. Façade export enumeration moves to scripts/layering/facade-exports.ts (219 lines) with its own facade-exports.test.ts (245), registered in check:layering. package-boundaries.ts drops to 338 from 528 and its test file to 450 from 642: the boundary rules answer "may this file import that one?", this module answers "what does this façade name?". Every layering file is now under the 500-line tripwire except the generated symbol table, which the rule exempts. Gates: check:layering (68 tests, up from 66) / typecheck / lint / format:check — green. Contracts plant re-verified after the split. --- package.json | 2 +- scripts/layering/facade-exports.test.ts | 245 ++++++++++++++++++++ scripts/layering/facade-exports.ts | 219 +++++++++++++++++ scripts/layering/package-boundaries.test.ts | 194 +--------------- scripts/layering/package-boundaries.ts | 190 --------------- 5 files changed, 466 insertions(+), 384 deletions(-) create mode 100644 scripts/layering/facade-exports.test.ts create mode 100644 scripts/layering/facade-exports.ts diff --git a/package.json b/package.json index fc7210de4..c5d436aec 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,7 @@ "check:affected:test": "node --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/run.test.ts", "check:coverage-changed": "node --experimental-strip-types scripts/coverage-changed/run.ts", "check:coverage-changed:test": "node --experimental-strip-types --test scripts/coverage-changed/model.test.ts scripts/coverage-changed/run.test.ts", - "check:layering": "node --experimental-strip-types --test scripts/layering/model.test.ts scripts/layering/zone-policy.test.ts scripts/layering/daemon-modularity.test.ts scripts/layering/package-boundaries.test.ts && node --experimental-strip-types scripts/layering/check.ts", + "check:layering": "node --experimental-strip-types --test scripts/layering/model.test.ts scripts/layering/zone-policy.test.ts scripts/layering/daemon-modularity.test.ts scripts/layering/package-boundaries.test.ts scripts/layering/facade-exports.test.ts && node --experimental-strip-types scripts/layering/check.ts", "depgraph": "node --experimental-strip-types scripts/depgraph/build.ts", "depgraph:test": "node --experimental-strip-types --test scripts/depgraph/model.test.ts scripts/depgraph/affected.test.ts", "check:production-exports": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --fail-on-issues", diff --git a/scripts/layering/facade-exports.test.ts b/scripts/layering/facade-exports.test.ts new file mode 100644 index 000000000..a7cd130fc --- /dev/null +++ b/scripts/layering/facade-exports.test.ts @@ -0,0 +1,245 @@ +// Façade export enumeration, tested directly: what `readNamedExports` and +// `readFacadeExports` report for each export FORM, independently of the R11 +// boundary rules that consume them. +// +// The `readFacadeExports` cases write throwaway modules under a real package +// rather than using committed fixtures — a fixture would pin the walker +// against a file shape this repo never actually ships. + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'node:test'; +import { readFacadeExports, readNamedExports } from './facade-exports.ts'; + +const repoRoot = path.resolve(import.meta.dirname, '../..'); + +test('readNamedExports collects re-export and direct-declaration forms, resolving aliases', () => { + const source = [ + "export { a, b } from './x.ts';", + "export type { C, D } from './y.ts';", + "export { e as f } from './z.ts';", + "export type { g as h } from './z.ts';", + 'export function i() {}', + 'export const j = 1;', + 'export type K = string;', + 'export interface L {}', + "export {\n m,\n n,\n} from './multi.ts';", + ].join('\n'); + assert.deepEqual( + readNamedExports(source), + ['D', 'C', 'K', 'L', 'a', 'b', 'f', 'h', 'i', 'j', 'm', 'n'].sort(), + ); +}); + +test('readNamedExports never reports the original name behind an `as` alias', () => { + const source = "export { internalOnly as publicName } from './x.ts';"; + const names = readNamedExports(source); + assert.deepEqual(names, ['publicName']); + assert.ok(!names.includes('internalOnly')); +}); + +test('readNamedExports resolves `export * as ns` to its one real bound name', () => { + // Unlike bare `export *`, this binds exactly one importable name (`ns`) — + // enumerable, not a widening blind spot. + const source = "export * as ns from './x.ts';"; + assert.deepEqual(readNamedExports(source), ['ns']); +}); + +// #1555 review P1 (second pass, "the gate also ignores export-star +// declarations, so it can miss future widening"): a facade pinned to an +// exact named-export list must not silently accept a form that widens its +// real surface with no enumerable name at all. These two forms throw instead +// of contributing nothing to the list — plant-verified (temporarily reverted +// to a no-op, confirmed both tests failed, restored) rather than merely +// asserted. +test('readNamedExports rejects a bare `export *` re-export', () => { + const source = "export { runAdReplay } from './step-loop.ts';\nexport * from './leak.ts';\n"; + assert.throws(() => readNamedExports(source), /export \* from/); +}); + +test('readNamedExports rejects a default export', () => { + assert.throws(() => readNamedExports('export default function leak() {}'), /export default/); + assert.throws(() => readNamedExports('export default 42;'), /export default/); +}); + +test('readNamedExports reports `export { default as x }` as the named symbol x', () => { + // The one form that sits between the two rejection rules above: the LOCAL + // name is `default`, but what it binds in this module — and the only thing + // a consumer can import — is `x`. Enumerable, so it must be reported, not + // thrown; and `default` must never appear in the list. + const names = readNamedExports("export { default as x, b } from './y.ts';"); + assert.deepEqual(names, ['b', 'x']); + assert.ok(!names.includes('default')); +}); + +test('readNamedExports collects a local `export { … }` list with no `from`', () => { + // The re-export tests above all carry a `from`; a façade that declares + // first and exports at the bottom is the same public surface. + assert.deepEqual( + readNamedExports('const a = 1;\ntype T = string;\nexport { a };\nexport type { T };'), + ['T', 'a'], + ); +}); + +test('readNamedExports collects every declarator of a multi-declarator export', () => { + // Documented in the helper's contract; the direct-declaration test above + // only exercises a single declarator, so the second name went unpinned. + assert.deepEqual(readNamedExports('export const a = 1, b = 2;'), ['a', 'b']); +}); + +// `readFacadeExports` is the same enumeration widened from one source string +// to the re-export CHAIN behind a file — the form every `contracts` façade +// is built from. These use the real tree's own barrels rather than fixtures: +// a fixture would pin the walker against a file this repo never ships. +test('readFacadeExports resolves a bare `export *` chain the source-only reader refuses', () => { + const barrel = path.join(repoRoot, 'packages/contracts/src/facades/session.ts'); + // Source-only: unknowable, so it throws (the merged contract, unchanged). + assert.throws(() => readNamedExports(fs.readFileSync(barrel, 'utf8')), /export \* from/); + // Given the FILE, the same barrel is fully enumerable. + assert.deepEqual(readFacadeExports(barrel), [ + 'SESSION_SURFACES', + 'SessionAction', + 'SessionSurface', + 'parseSessionSurface', + ]); +}); + +test('readFacadeExports refuses a bare `export *` across a package specifier', () => { + // A relative star names a module this gate can read; a package star means + // resolving node_modules into another package's exports map — unbounded + // widening, the exact thing the gate refuses. + const scratch = path.join(repoRoot, 'packages/contracts/src/facades/.export-star-probe.ts'); + fs.writeFileSync(scratch, "export * from '@agent-device/kernel/errors';\n"); + try { + assert.throws(() => readFacadeExports(scratch), /only a relative re-export/); + } finally { + fs.rmSync(scratch); + } +}); + +/** Write throwaway modules next to a real façade; always clean them up. */ +function withProbeModules(files: Record, run: (dir: string) => void): void { + const dir = path.join(repoRoot, 'packages/contracts/src/facades'); + const written = Object.entries(files).map(([name, source]) => { + const file = path.join(dir, name); + fs.writeFileSync(file, source); + return file; + }); + try { + run(dir); + } finally { + for (const file of written) fs.rmSync(file, { force: true }); + } +} + +test('readFacadeExports excludes a default that a star export cannot reach', () => { + // #1574 review P1: `export *` skips the child's default per + // GetExportedNames, so a private default in a leaf is NOT part of the + // barrel's surface. Counterfactual: the named sibling still comes through, + // proving the leaf is genuinely being read and the default specifically — + // not the whole module — is what got dropped. + withProbeModules( + { + '.leaf-probe.ts': 'export default function hidden() {}\nexport const reachable = 1;\n', + '.barrel-probe.ts': "export * from './.leaf-probe.ts';\n", + }, + (dir) => { + assert.deepEqual(readFacadeExports(path.join(dir, '.barrel-probe.ts')), ['reachable']); + }, + ); +}); + +test('readFacadeExports still rejects a default on the façade entry itself', () => { + // The other side of the same rule: the entry's own default IS a default + // export of the façade, and a façade pinned to a named list must not carry + // one. Same source text as the leaf above — only its position changed. + withProbeModules({ '.entry-default-probe.ts': 'export default function leak() {}\n' }, (dir) => { + assert.throws( + () => readFacadeExports(path.join(dir, '.entry-default-probe.ts')), + /export default/, + ); + }); +}); + +test('readFacadeExports rejects a name two star sources resolve differently', () => { + // ESM resolves this to `ambiguous`, so `clash` is not importable at all; + // unioning would pin a symbol no consumer can reach. + withProbeModules( + { + '.clash-a-probe.ts': 'export const clash = 1;\nexport const onlyA = 1;\n', + '.clash-b-probe.ts': 'export const clash = 2;\n', + '.clash-barrel-probe.ts': + "export * from './.clash-a-probe.ts';\nexport * from './.clash-b-probe.ts';\n", + }, + (dir) => { + assert.throws(() => readFacadeExports(path.join(dir, '.clash-barrel-probe.ts')), /ambiguous/); + }, + ); +}); + +test('readFacadeExports resolves a diamond and lets an explicit export shadow a star', () => { + // The two counterfactuals to the ambiguity rule, both of which a naive + // "two paths reached this name" check would wrongly reject. One shared + // declaration reached by two barrels is ONE binding, not a clash; and an + // explicit re-export of a name a star also provides is the spec's own + // precedence, not ambiguity. + withProbeModules( + { + '.shared-probe.ts': 'export const shared = 1;\n', + '.mid-one-probe.ts': "export * from './.shared-probe.ts';\n", + '.mid-two-probe.ts': "export * from './.shared-probe.ts';\n", + '.diamond-probe.ts': + "export * from './.mid-one-probe.ts';\nexport * from './.mid-two-probe.ts';\n", + '.shadow-src-probe.ts': 'export const shadowed = 1;\nexport const other = 2;\n', + '.shadow-probe.ts': + "export * from './.shadow-src-probe.ts';\nexport { shadowed } from './.shared-probe.ts';\n", + }, + (dir) => { + assert.deepEqual(readFacadeExports(path.join(dir, '.diamond-probe.ts')), ['shared']); + assert.deepEqual(readFacadeExports(path.join(dir, '.shadow-probe.ts')), [ + 'other', + 'shadowed', + ]); + }, + ); +}); + +test('readFacadeExports follows a named re-export chain to its ultimate binding', () => { + // #1574 review P1: `a` re-exports `x` from `b`, `c` re-exports `x` from + // `a`, and the façade stars both. ESM resolves ONE binding (`b`'s `x`), so + // this is a diamond, not a clash. Identifying a re-export by its immediate + // source would see `b#x` vs `a#x` and falsely reject the façade — the + // counterfactual that fails without transitive origin resolution. + withProbeModules( + { + '.chain-b-probe.ts': 'export const x = 1;\n', + '.chain-a-probe.ts': "export { x } from './.chain-b-probe.ts';\n", + '.chain-c-probe.ts': "export { x } from './.chain-a-probe.ts';\n", + '.chain-facade-probe.ts': + "export * from './.chain-a-probe.ts';\nexport * from './.chain-c-probe.ts';\n", + }, + (dir) => { + assert.deepEqual(readFacadeExports(path.join(dir, '.chain-facade-probe.ts')), ['x']); + }, + ); +}); + +test('readFacadeExports keeps rejecting two genuinely distinct bindings behind a chain', () => { + // The guard against over-correcting the above: resolving through chains + // must not collapse two REAL declarations into one. Same chain depth as the + // diamond, but the two branches bottom out in different modules. + withProbeModules( + { + '.split-one-probe.ts': 'export const y = 1;\n', + '.split-two-probe.ts': 'export const y = 2;\n', + '.split-a-probe.ts': "export { y } from './.split-one-probe.ts';\n", + '.split-c-probe.ts': "export { y } from './.split-two-probe.ts';\n", + '.split-facade-probe.ts': + "export * from './.split-a-probe.ts';\nexport * from './.split-c-probe.ts';\n", + }, + (dir) => { + assert.throws(() => readFacadeExports(path.join(dir, '.split-facade-probe.ts')), /ambiguous/); + }, + ); +}); diff --git a/scripts/layering/facade-exports.ts b/scripts/layering/facade-exports.ts new file mode 100644 index 000000000..04d1c2aaf --- /dev/null +++ b/scripts/layering/facade-exports.ts @@ -0,0 +1,219 @@ +// Façade export enumeration: what names a module actually exposes to a +// consumer, for the R11 exact-symbol pins in `package-boundaries.test.ts`. +// +// Split out of `package-boundaries.ts` (#1574 review): the boundary rules +// answer "may this file import that one?", while this module answers "what +// does this façade name?" — different questions, so per AGENTS.md they are +// different bounded reads. + +import fs from 'node:fs'; +import path from 'node:path'; +import { parseSync } from 'oxc-parser'; + +/** + * Every name a façade module exports, value or type-only, sorted — the exact + * "named-export-list" a package-boundaries gate can pin (#1555 review P1, + * "add the reviewer-required exact exported-symbol gate"). Covers both + * re-export forms (`export { a, b } from './x.ts'`, + * `export type { a, b } from './x.ts'`, with or without `as` aliasing — the + * alias is reported, since that is the name a consumer actually imports), + * `export * as ns from './x.ts'` (one real name, `ns`), and direct + * declarations (`export function`/`const`/`class`/`type`/`interface`, + * including `export const a = 1, b = 2`'s multiple declarators). A stray + * export — intentional or not — changes this list, so a test that pins it + * exactly turns "the façade grew a symbol" into a loud failure instead of a + * silent widening only a PR diff review would catch. + * + * AST-based (`oxc-parser`, already a devDependency — `session-state.ts` is + * the existing precedent for using it in this gate), not a regex, for the + * SAME reason `session-state.ts` gives: a regex has to enumerate every + * export FORM by hand, and the one it forgets is exactly the one that slips + * through. That is precisely what happened here (#1555 review, second pass, + * "the gate also ignores export-star declarations, so it can miss future + * widening"): `export * from './x.ts'` re-exports an unbounded, statically + * unknowable set of names — the old regex scanner had no case for it at all, + * so it silently contributed NOTHING to the list instead of failing loudly. + * `parsed.module.staticExports` is oxc's own resolved export-entry table + * (built for exactly this purpose, not re-derived from a manual AST walk), + * and its `exportName.kind` already draws the line this function needs: + * `'None'` is bare `export *` (unenumerable — thrown), `'Default'` is + * `export default …` (also thrown — a facade pinned to an exact named-export + * list must not carry one), and `'Name'` is every enumerable form above, + * `export * as ns` included (oxc reports its one real bound name, `ns`). + */ +export function readNamedExports(source: string): string[] { + const parsed = parseSync('package-boundaries-export-scan.ts', source); + const names = new Set(); + for (const staticExport of parsed.module.staticExports) { + for (const entry of staticExport.entries) { + if (entry.exportName.kind === 'None') { + throw new Error( + "readNamedExports cannot enumerate 'export * from …' — it re-exports an unknown set " + + 'of names, exactly the widening an exact-export-list gate exists to catch. Name the ' + + 're-exported symbols explicitly instead of re-exporting the whole module.', + ); + } + if (entry.exportName.kind === 'Default') { + throw new Error( + "readNamedExports cannot enumerate 'export default …' as a named symbol — a facade a " + + 'caller pins to an exact named-export list must not carry a default export.', + ); + } + if (entry.exportName.name) names.add(entry.exportName.name); + } + } + return [...names].sort(); +} + +/** + * Every name a façade subpath exports, sorted — `readNamedExports` widened + * from one source string to the re-export CHAIN behind it, so a barrel + * façade is pinnable too. + * + * `readNamedExports` throws on bare `export * from './x.ts'` because, given + * only a source string, the set it contributes is genuinely unknowable. Given + * the FILE, it is not: the specifier names a sibling module the gate can read + * and enumerate in turn. That is the whole difference here — every + * `@agent-device/contracts` façade (`src/facades/*.ts`) is exactly such a + * barrel, 13 of the 14 subpaths being nothing but bare re-export lines, so + * without chain resolution the package with the largest and fastest-growing + * public surface in the workspace is the one package that cannot be pinned. + * + * The walk models what `export *` ACTUALLY re-exports, which is narrower than + * "every name in the child" in two ways a naive union gets wrong (#1574 + * review P1): + * + * 1. **`default` is excluded.** Per `GetExportedNames`, a star export skips + * the child's default entirely. A private `export default` in a leaf is + * not reachable through the barrel and does not widen the façade, so it is + * passed over rather than rejected. A default on the ENTRY file is still a + * real default export of the façade itself, and still throws. + * 2. **A name from two different star sources is ambiguous, not exported.** + * `ResolveExport` returns `ambiguous` when star resolution finds two + * distinct bindings for one name, and importing it is then a `SyntaxError` + * — the name is not part of the surface at all. Unioning would silently + * pin a symbol no consumer can import, so ambiguity throws instead, naming + * both origins. A diamond (two barrels reaching the SAME declaration) is + * not ambiguous and resolves normally, which is why origins are tracked by + * the binding a name ultimately resolves to — through a chain of named + * re-exports, not just the immediate source — rather than by path taken. + * An explicit export in a module shadows any star-provided name of the + * same name, exactly as the spec's own precedence does. + * + * Resolution is deliberately narrow — a RELATIVE specifier only, and the + * repo's explicit-`.ts`-extension convention means the specifier is already + * the path. A bare star across a PACKAGE specifier still throws: enumerating + * it means resolving `node_modules` and re-entering another package's + * `exports` map, and a façade that re-exports a whole other package wholesale + * is precisely the unbounded widening this gate exists to refuse. Cycles are + * visit-guarded (a barrel pair that re-exported each other would otherwise + * recurse forever). + */ +export function readFacadeExports(entryFile: string): string[] { + // `${declaringFile}#${name}` — binding identity, so the same declaration + // reached by two different barrel paths is one origin, not two. + const cache = new Map>(); + const walking = new Set(); + + /** + * The binding a named re-export ULTIMATELY names, not the module it was + * written against (#1574 review P1). `a` re-exports `x` from `b`, `c` + * re-exports `x` from `a`, and a façade stars both: ESM resolves one `b#x` + * binding, so that is a diamond, not a clash. Stopping at the immediate + * source would identify the two paths as `b#x` and `a#x` and falsely reject + * the façade as ambiguous. The child's own map already carries + * fully-resolved origins, so asking it is the whole fix. + */ + const reExportOrigin = ( + from: string, + specifier: string | undefined, + importedName: string, + localName: string, + ): string => { + if (!specifier) return `${from}#${localName}`; + // A package specifier is not a file this gate reads; its name is a stable + // identity of its own, so two façades re-exporting the same symbol from + // the same package still agree. + if (!specifier.startsWith('.')) return `${specifier}#${importedName}`; + const childPath = path.resolve(path.dirname(from), specifier); + // Falls back to the immediate source when the child does not name it — a + // cycle in progress, or a name oxc cannot attribute. + return exportedNames(childPath).get(importedName) ?? `${childPath}#${importedName}`; + }; + + const exportedNames = (file: string): Map => { + const resolved = path.resolve(file); + const cached = cache.get(resolved); + if (cached) return cached; + // A cycle contributes nothing further; whatever it exports is reached by + // the path that entered it first. + if (walking.has(resolved)) return new Map(); + walking.add(resolved); + + const parsed = parseSync(resolved, fs.readFileSync(resolved, 'utf8')); + const explicit = new Map(); + const starOrigins = new Map>(); + + for (const staticExport of parsed.module.staticExports) { + for (const entry of staticExport.entries) { + const specifier = entry.moduleRequest?.value; + + if (entry.exportName.kind === 'Default') { + // Only the façade's own default is a default export of the façade. + if (resolved === path.resolve(entryFile)) { + throw new Error( + `readFacadeExports cannot enumerate 'export default …' as a named symbol ` + + `(${resolved}) — a facade a caller pins to an exact named-export list must not ` + + 'carry one.', + ); + } + continue; + } + + if (entry.exportName.kind === 'None') { + if (!specifier || !specifier.startsWith('.')) { + throw new Error( + `readFacadeExports cannot enumerate 'export * from ${specifier ?? '…'}' ` + + `(${resolved}) — only a relative re-export names a module this gate can read ` + + 'and enumerate in turn. Name the re-exported symbols explicitly instead.', + ); + } + const childPath = path.resolve(path.dirname(resolved), specifier); + for (const [name, origin] of exportedNames(childPath)) { + let origins = starOrigins.get(name); + if (!origins) starOrigins.set(name, (origins = new Map())); + origins.set(origin, specifier); + } + continue; + } + + const name = entry.exportName.name; + if (!name) continue; + explicit.set( + name, + reExportOrigin(resolved, specifier, entry.importName?.name ?? name, name), + ); + } + } + + const names = new Map(explicit); + for (const [name, origins] of starOrigins) { + if (explicit.has(name)) continue; // explicit export shadows the star + if (origins.size > 1) { + throw new Error( + `readFacadeExports found '${name}' re-exported by ${origins.size} different ` + + `'export *' sources in ${resolved} (${[...origins.values()].sort().join(', ')}). ` + + 'ESM resolves that to `ambiguous`, so the name is not importable at all and must ' + + 'not be pinned as part of the surface. Re-export it explicitly from one source.', + ); + } + names.set(name, [...origins.keys()][0]!); + } + + walking.delete(resolved); + cache.set(resolved, names); + return names; + }; + + return [...exportedNames(entryFile).keys()].sort(); +} diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 4652e83b9..24f0e643d 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -6,13 +6,12 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'node:test'; +import { readFacadeExports, readNamedExports } from './facade-exports.ts'; import { FACADE_SYMBOLS } from './facade-symbols.ts'; import { checkPackageBoundaries, checkPackageInternalSites, checkRootSites, - readFacadeExports, - readNamedExports, readWorkspacePackages, rootExternalDependencyRanges, rootWorkspaceDependencyNames, @@ -76,197 +75,6 @@ test('specifier sites carry 1-based lines for static and dynamic imports', () => ); }); -test('readNamedExports collects re-export and direct-declaration forms, resolving aliases', () => { - const source = [ - "export { a, b } from './x.ts';", - "export type { C, D } from './y.ts';", - "export { e as f } from './z.ts';", - "export type { g as h } from './z.ts';", - 'export function i() {}', - 'export const j = 1;', - 'export type K = string;', - 'export interface L {}', - "export {\n m,\n n,\n} from './multi.ts';", - ].join('\n'); - assert.deepEqual( - readNamedExports(source), - ['D', 'C', 'K', 'L', 'a', 'b', 'f', 'h', 'i', 'j', 'm', 'n'].sort(), - ); -}); - -test('readNamedExports never reports the original name behind an `as` alias', () => { - const source = "export { internalOnly as publicName } from './x.ts';"; - const names = readNamedExports(source); - assert.deepEqual(names, ['publicName']); - assert.ok(!names.includes('internalOnly')); -}); - -test('readNamedExports resolves `export * as ns` to its one real bound name', () => { - // Unlike bare `export *`, this binds exactly one importable name (`ns`) — - // enumerable, not a widening blind spot. - const source = "export * as ns from './x.ts';"; - assert.deepEqual(readNamedExports(source), ['ns']); -}); - -// #1555 review P1 (second pass, "the gate also ignores export-star -// declarations, so it can miss future widening"): a facade pinned to an -// exact named-export list must not silently accept a form that widens its -// real surface with no enumerable name at all. These two forms throw instead -// of contributing nothing to the list — plant-verified (temporarily reverted -// to a no-op, confirmed both tests failed, restored) rather than merely -// asserted. -test('readNamedExports rejects a bare `export *` re-export', () => { - const source = "export { runAdReplay } from './step-loop.ts';\nexport * from './leak.ts';\n"; - assert.throws(() => readNamedExports(source), /export \* from/); -}); - -test('readNamedExports rejects a default export', () => { - assert.throws(() => readNamedExports('export default function leak() {}'), /export default/); - assert.throws(() => readNamedExports('export default 42;'), /export default/); -}); - -test('readNamedExports reports `export { default as x }` as the named symbol x', () => { - // The one form that sits between the two rejection rules above: the LOCAL - // name is `default`, but what it binds in this module — and the only thing - // a consumer can import — is `x`. Enumerable, so it must be reported, not - // thrown; and `default` must never appear in the list. - const names = readNamedExports("export { default as x, b } from './y.ts';"); - assert.deepEqual(names, ['b', 'x']); - assert.ok(!names.includes('default')); -}); - -test('readNamedExports collects a local `export { … }` list with no `from`', () => { - // The re-export tests above all carry a `from`; a façade that declares - // first and exports at the bottom is the same public surface. - assert.deepEqual( - readNamedExports('const a = 1;\ntype T = string;\nexport { a };\nexport type { T };'), - ['T', 'a'], - ); -}); - -test('readNamedExports collects every declarator of a multi-declarator export', () => { - // Documented in the helper's contract; the direct-declaration test above - // only exercises a single declarator, so the second name went unpinned. - assert.deepEqual(readNamedExports('export const a = 1, b = 2;'), ['a', 'b']); -}); - -// `readFacadeExports` is the same enumeration widened from one source string -// to the re-export CHAIN behind a file — the form every `contracts` façade -// is built from. These use the real tree's own barrels rather than fixtures: -// a fixture would pin the walker against a file this repo never ships. -test('readFacadeExports resolves a bare `export *` chain the source-only reader refuses', () => { - const barrel = path.join(repoRoot, 'packages/contracts/src/facades/session.ts'); - // Source-only: unknowable, so it throws (the merged contract, unchanged). - assert.throws(() => readNamedExports(fs.readFileSync(barrel, 'utf8')), /export \* from/); - // Given the FILE, the same barrel is fully enumerable. - assert.deepEqual(readFacadeExports(barrel), [ - 'SESSION_SURFACES', - 'SessionAction', - 'SessionSurface', - 'parseSessionSurface', - ]); -}); - -test('readFacadeExports refuses a bare `export *` across a package specifier', () => { - // A relative star names a module this gate can read; a package star means - // resolving node_modules into another package's exports map — unbounded - // widening, the exact thing the gate refuses. - const scratch = path.join(repoRoot, 'packages/contracts/src/facades/.export-star-probe.ts'); - fs.writeFileSync(scratch, "export * from '@agent-device/kernel/errors';\n"); - try { - assert.throws(() => readFacadeExports(scratch), /only a relative re-export/); - } finally { - fs.rmSync(scratch); - } -}); - -/** Write throwaway modules next to a real façade; always clean them up. */ -function withProbeModules(files: Record, run: (dir: string) => void): void { - const dir = path.join(repoRoot, 'packages/contracts/src/facades'); - const written = Object.entries(files).map(([name, source]) => { - const file = path.join(dir, name); - fs.writeFileSync(file, source); - return file; - }); - try { - run(dir); - } finally { - for (const file of written) fs.rmSync(file, { force: true }); - } -} - -test('readFacadeExports excludes a default that a star export cannot reach', () => { - // #1574 review P1: `export *` skips the child's default per - // GetExportedNames, so a private default in a leaf is NOT part of the - // barrel's surface. Counterfactual: the named sibling still comes through, - // proving the leaf is genuinely being read and the default specifically — - // not the whole module — is what got dropped. - withProbeModules( - { - '.leaf-probe.ts': 'export default function hidden() {}\nexport const reachable = 1;\n', - '.barrel-probe.ts': "export * from './.leaf-probe.ts';\n", - }, - (dir) => { - assert.deepEqual(readFacadeExports(path.join(dir, '.barrel-probe.ts')), ['reachable']); - }, - ); -}); - -test('readFacadeExports still rejects a default on the façade entry itself', () => { - // The other side of the same rule: the entry's own default IS a default - // export of the façade, and a façade pinned to a named list must not carry - // one. Same source text as the leaf above — only its position changed. - withProbeModules({ '.entry-default-probe.ts': 'export default function leak() {}\n' }, (dir) => { - assert.throws( - () => readFacadeExports(path.join(dir, '.entry-default-probe.ts')), - /export default/, - ); - }); -}); - -test('readFacadeExports rejects a name two star sources resolve differently', () => { - // ESM resolves this to `ambiguous`, so `clash` is not importable at all; - // unioning would pin a symbol no consumer can reach. - withProbeModules( - { - '.clash-a-probe.ts': 'export const clash = 1;\nexport const onlyA = 1;\n', - '.clash-b-probe.ts': 'export const clash = 2;\n', - '.clash-barrel-probe.ts': - "export * from './.clash-a-probe.ts';\nexport * from './.clash-b-probe.ts';\n", - }, - (dir) => { - assert.throws(() => readFacadeExports(path.join(dir, '.clash-barrel-probe.ts')), /ambiguous/); - }, - ); -}); - -test('readFacadeExports resolves a diamond and lets an explicit export shadow a star', () => { - // The two counterfactuals to the ambiguity rule, both of which a naive - // "two paths reached this name" check would wrongly reject. One shared - // declaration reached by two barrels is ONE binding, not a clash; and an - // explicit re-export of a name a star also provides is the spec's own - // precedence, not ambiguity. - withProbeModules( - { - '.shared-probe.ts': 'export const shared = 1;\n', - '.mid-one-probe.ts': "export * from './.shared-probe.ts';\n", - '.mid-two-probe.ts': "export * from './.shared-probe.ts';\n", - '.diamond-probe.ts': - "export * from './.mid-one-probe.ts';\nexport * from './.mid-two-probe.ts';\n", - '.shadow-src-probe.ts': 'export const shadowed = 1;\nexport const other = 2;\n', - '.shadow-probe.ts': - "export * from './.shadow-src-probe.ts';\nexport { shadowed } from './.shared-probe.ts';\n", - }, - (dir) => { - assert.deepEqual(readFacadeExports(path.join(dir, '.diamond-probe.ts')), ['shared']); - assert.deepEqual(readFacadeExports(path.join(dir, '.shadow-probe.ts')), [ - 'other', - 'shadowed', - ]); - }, - ); -}); - test('every workspace package façade exports exactly its pinned symbol list', () => { const packages = readWorkspacePackages(repoRoot); const pinned = new Map(FACADE_SYMBOLS.map(([specifier, names]) => [specifier, names])); diff --git a/scripts/layering/package-boundaries.ts b/scripts/layering/package-boundaries.ts index 555bf98a6..d7befbb91 100644 --- a/scripts/layering/package-boundaries.ts +++ b/scripts/layering/package-boundaries.ts @@ -18,7 +18,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { parseSync } from 'oxc-parser'; import { parseImports } from './model.ts'; export type PackageBoundaryViolation = { @@ -56,195 +55,6 @@ export function specifierSites(file: string, source: string): SpecifierSite[] { return parseImports(source).map((edge) => ({ file, line: edge.line, specifier: edge.spec })); } -/** - * Every name a façade module exports, value or type-only, sorted — the exact - * "named-export-list" a package-boundaries gate can pin (#1555 review P1, - * "add the reviewer-required exact exported-symbol gate"). Covers both - * re-export forms (`export { a, b } from './x.ts'`, - * `export type { a, b } from './x.ts'`, with or without `as` aliasing — the - * alias is reported, since that is the name a consumer actually imports), - * `export * as ns from './x.ts'` (one real name, `ns`), and direct - * declarations (`export function`/`const`/`class`/`type`/`interface`, - * including `export const a = 1, b = 2`'s multiple declarators). A stray - * export — intentional or not — changes this list, so a test that pins it - * exactly turns "the façade grew a symbol" into a loud failure instead of a - * silent widening only a PR diff review would catch. - * - * AST-based (`oxc-parser`, already a devDependency — `session-state.ts` is - * the existing precedent for using it in this gate), not a regex, for the - * SAME reason `session-state.ts` gives: a regex has to enumerate every - * export FORM by hand, and the one it forgets is exactly the one that slips - * through. That is precisely what happened here (#1555 review, second pass, - * "the gate also ignores export-star declarations, so it can miss future - * widening"): `export * from './x.ts'` re-exports an unbounded, statically - * unknowable set of names — the old regex scanner had no case for it at all, - * so it silently contributed NOTHING to the list instead of failing loudly. - * `parsed.module.staticExports` is oxc's own resolved export-entry table - * (built for exactly this purpose, not re-derived from a manual AST walk), - * and its `exportName.kind` already draws the line this function needs: - * `'None'` is bare `export *` (unenumerable — thrown), `'Default'` is - * `export default …` (also thrown — a facade pinned to an exact named-export - * list must not carry one), and `'Name'` is every enumerable form above, - * `export * as ns` included (oxc reports its one real bound name, `ns`). - */ -export function readNamedExports(source: string): string[] { - const parsed = parseSync('package-boundaries-export-scan.ts', source); - const names = new Set(); - for (const staticExport of parsed.module.staticExports) { - for (const entry of staticExport.entries) { - if (entry.exportName.kind === 'None') { - throw new Error( - "readNamedExports cannot enumerate 'export * from …' — it re-exports an unknown set " + - 'of names, exactly the widening an exact-export-list gate exists to catch. Name the ' + - 're-exported symbols explicitly instead of re-exporting the whole module.', - ); - } - if (entry.exportName.kind === 'Default') { - throw new Error( - "readNamedExports cannot enumerate 'export default …' as a named symbol — a facade a " + - 'caller pins to an exact named-export list must not carry a default export.', - ); - } - if (entry.exportName.name) names.add(entry.exportName.name); - } - } - return [...names].sort(); -} - -/** - * Every name a façade subpath exports, sorted — `readNamedExports` widened - * from one source string to the re-export CHAIN behind it, so a barrel - * façade is pinnable too. - * - * `readNamedExports` throws on bare `export * from './x.ts'` because, given - * only a source string, the set it contributes is genuinely unknowable. Given - * the FILE, it is not: the specifier names a sibling module the gate can read - * and enumerate in turn. That is the whole difference here — every - * `@agent-device/contracts` façade (`src/facades/*.ts`) is exactly such a - * barrel, 13 of the 14 subpaths being nothing but bare re-export lines, so - * without chain resolution the package with the largest and fastest-growing - * public surface in the workspace is the one package that cannot be pinned. - * - * The walk models what `export *` ACTUALLY re-exports, which is narrower than - * "every name in the child" in two ways a naive union gets wrong (#1574 - * review P1): - * - * 1. **`default` is excluded.** Per `GetExportedNames`, a star export skips - * the child's default entirely. A private `export default` in a leaf is - * not reachable through the barrel and does not widen the façade, so it is - * passed over rather than rejected. A default on the ENTRY file is still a - * real default export of the façade itself, and still throws. - * 2. **A name from two different star sources is ambiguous, not exported.** - * `ResolveExport` returns `ambiguous` when star resolution finds two - * distinct bindings for one name, and importing it is then a `SyntaxError` - * — the name is not part of the surface at all. Unioning would silently - * pin a symbol no consumer can import, so ambiguity throws instead, naming - * both origins. A diamond (two barrels reaching the SAME declaration) is - * not ambiguous and resolves normally, which is why origins are tracked by - * declaring module rather than by path taken. An explicit export in a - * module shadows any star-provided name of the same name, exactly as the - * spec's own precedence does. - * - * Resolution is deliberately narrow — a RELATIVE specifier only, and the - * repo's explicit-`.ts`-extension convention means the specifier is already - * the path. A bare star across a PACKAGE specifier still throws: enumerating - * it means resolving `node_modules` and re-entering another package's - * `exports` map, and a façade that re-exports a whole other package wholesale - * is precisely the unbounded widening this gate exists to refuse. Cycles are - * visit-guarded (a barrel pair that re-exported each other would otherwise - * recurse forever). - */ -export function readFacadeExports(entryFile: string): string[] { - // `${declaringFile}#${name}` — binding identity, so the same declaration - // reached by two different barrel paths is one origin, not two. - const cache = new Map>(); - const walking = new Set(); - - const exportedNames = (file: string): Map => { - const resolved = path.resolve(file); - const cached = cache.get(resolved); - if (cached) return cached; - // A cycle contributes nothing further; whatever it exports is reached by - // the path that entered it first. - if (walking.has(resolved)) return new Map(); - walking.add(resolved); - - const parsed = parseSync(resolved, fs.readFileSync(resolved, 'utf8')); - const explicit = new Map(); - const starOrigins = new Map>(); - - for (const staticExport of parsed.module.staticExports) { - for (const entry of staticExport.entries) { - const specifier = entry.moduleRequest?.value; - - if (entry.exportName.kind === 'Default') { - // Only the façade's own default is a default export of the façade. - if (resolved === path.resolve(entryFile)) { - throw new Error( - `readFacadeExports cannot enumerate 'export default …' as a named symbol ` + - `(${resolved}) — a facade a caller pins to an exact named-export list must not ` + - 'carry one.', - ); - } - continue; - } - - if (entry.exportName.kind === 'None') { - if (!specifier || !specifier.startsWith('.')) { - throw new Error( - `readFacadeExports cannot enumerate 'export * from ${specifier ?? '…'}' ` + - `(${resolved}) — only a relative re-export names a module this gate can read ` + - 'and enumerate in turn. Name the re-exported symbols explicitly instead.', - ); - } - const childPath = path.resolve(path.dirname(resolved), specifier); - for (const [name, origin] of exportedNames(childPath)) { - let origins = starOrigins.get(name); - if (!origins) starOrigins.set(name, (origins = new Map())); - origins.set(origin, specifier); - } - continue; - } - - const name = entry.exportName.name; - if (!name) continue; - // A re-export's binding belongs to the module it came from, so two - // façades re-exporting one shared symbol agree on its identity. - explicit.set( - name, - specifier - ? `${ - specifier.startsWith('.') - ? path.resolve(path.dirname(resolved), specifier) - : specifier - }#${entry.importName?.name ?? name}` - : `${resolved}#${name}`, - ); - } - } - - const names = new Map(explicit); - for (const [name, origins] of starOrigins) { - if (explicit.has(name)) continue; // explicit export shadows the star - if (origins.size > 1) { - throw new Error( - `readFacadeExports found '${name}' re-exported by ${origins.size} different ` + - `'export *' sources in ${resolved} (${[...origins.values()].sort().join(', ')}). ` + - 'ESM resolves that to `ambiguous`, so the name is not importable at all and must ' + - 'not be pinned as part of the surface. Re-export it explicitly from one source.', - ); - } - names.set(name, [...origins.keys()][0]!); - } - - walking.delete(resolved); - cache.set(resolved, names); - return names; - }; - - return [...exportedNames(entryFile).keys()].sort(); -} - export function readWorkspacePackages(repoRoot: string): WorkspacePackage[] { const packagesDir = path.join(repoRoot, 'packages'); if (!fs.existsSync(packagesDir)) return []; From fa949d4956c31605bfa214675108d87b3499d7ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 20:06:44 +0000 Subject: [PATCH 4/4] fix(layering): filter `default` at the star, not at its source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reported P1 does not reproduce: intermediate `export { default } from './x.ts'` links are reported by oxc as kind `Name` with the name `default`, not kind `Default`, so they already resolve transitively; and for a terminal `export default `, the fallback identity `${child}#default` is exactly the canonical binding, so both paths agree. The exact five-module scenario from the review returns ['x']. That behavior is now pinned by a test so it cannot silently regress. Investigating it did surface a real spec violation in the opposite direction. Because a re-exported `default` is a named entry, it landed in the module's map and was then copied wholesale by star enumeration, so `export * from './mid.ts'` reported `default` as part of the surface — a name `GetExportedNames` explicitly skips, and which oxc itself labels `AllButDefault` on the star's own import. `default` is now filtered at the star rather than at the source. That placement is the point: the name has to stay in the module's map so a later `export { default as x }` can resolve its binding, while never being reachable through a star. Filtering at the source would have broken identity resolution — the very thing the review round before this one fixed. A façade entry re-exporting a default under the name `default` is now rejected too. It carries a default export exactly as `export default …` does; only the parse shape differs, and only the declared form was being caught. Three tests: the star filter (counterfactual-verified — removing the filter fails it — with a sibling name proving the module is still read), entry-level rejection, and the two-paths-to-one-default-binding case from the review. Gates: check:layering (71 tests, up from 68) / typecheck / lint / format:check — green. Contracts plant re-verified. --- scripts/layering/facade-exports.test.ts | 62 +++++++++++++++++++++++++ scripts/layering/facade-exports.ts | 25 +++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/scripts/layering/facade-exports.test.ts b/scripts/layering/facade-exports.test.ts index a7cd130fc..cc5b2a78b 100644 --- a/scripts/layering/facade-exports.test.ts +++ b/scripts/layering/facade-exports.test.ts @@ -243,3 +243,65 @@ test('readFacadeExports keeps rejecting two genuinely distinct bindings behind a }, ); }); + +// #1574 review, third round. `export { default } from './x.ts'` is reported +// by oxc as kind `Name` with the name `default` — the same fact as +// `export default …` wearing a different parse shape. It has to stay in a +// module's map so a later `export { default as x }` can resolve its binding, +// while never being reachable through a star. These three pin that split. +test('a star export does not re-export a name called `default`', () => { + // Per GetExportedNames a star skips `default` — oxc names the star's own + // import `AllButDefault`. Counterfactual: the ordinary sibling name in the + // same module still comes through, so this is `default` being filtered and + // not the whole module being dropped. + withProbeModules( + { + '.dstar-leaf-probe.ts': 'export default function hidden() {}\nexport const kept = 1;\n', + '.dstar-mid-probe.ts': + "export { default } from './.dstar-leaf-probe.ts';\n" + + "export { kept } from './.dstar-leaf-probe.ts';\n", + '.dstar-facade-probe.ts': "export * from './.dstar-mid-probe.ts';\n", + }, + (dir) => { + assert.deepEqual(readFacadeExports(path.join(dir, '.dstar-facade-probe.ts')), ['kept']); + }, + ); +}); + +test('a façade re-exporting a default under the name `default` is rejected', () => { + // The entry carries a default export either way; only the parse shape + // differs from the `export default …` case above. + withProbeModules( + { + '.dentry-leaf-probe.ts': 'export default function leak() {}\n', + '.dentry-facade-probe.ts': "export { default } from './.dentry-leaf-probe.ts';\n", + }, + (dir) => { + assert.throws( + () => readFacadeExports(path.join(dir, '.dentry-facade-probe.ts')), + /must not carry one/, + ); + }, + ); +}); + +test('two paths to one default binding resolve to a single name, not ambiguity', () => { + // `leaf` declares a default; `a` re-exports it; `b` names a's default `x` + // while `c` names leaf's default `x`; a façade stars both. ESM resolves ONE + // `leaf#default` binding, so `x` is exported rather than ambiguous — the + // intermediate `export { default } from` link has to carry identity through + // for the two paths to agree. + withProbeModules( + { + '.dchain-leaf-probe.ts': 'export default function shared() {}\n', + '.dchain-a-probe.ts': "export { default } from './.dchain-leaf-probe.ts';\n", + '.dchain-b-probe.ts': "export { default as x } from './.dchain-a-probe.ts';\n", + '.dchain-c-probe.ts': "export { default as x } from './.dchain-leaf-probe.ts';\n", + '.dchain-facade-probe.ts': + "export * from './.dchain-b-probe.ts';\nexport * from './.dchain-c-probe.ts';\n", + }, + (dir) => { + assert.deepEqual(readFacadeExports(path.join(dir, '.dchain-facade-probe.ts')), ['x']); + }, + ); +}); diff --git a/scripts/layering/facade-exports.ts b/scripts/layering/facade-exports.ts index 04d1c2aaf..5f696682b 100644 --- a/scripts/layering/facade-exports.ts +++ b/scripts/layering/facade-exports.ts @@ -180,6 +180,17 @@ export function readFacadeExports(entryFile: string): string[] { } const childPath = path.resolve(path.dirname(resolved), specifier); for (const [name, origin] of exportedNames(childPath)) { + // `default` is filtered HERE, at the star, not at the source + // (#1574 review, third round). A child's `export { default } from + // './leaf.ts'` is a NAMED export whose name happens to be + // `default` — oxc reports it as kind `Name`, and it must stay in + // the child's map so a chain like `export { default as x } from + // './that-child.ts'` can resolve its binding. But a star must not + // re-export it: `GetExportedNames` skips `default`, and oxc names + // the star's own import `AllButDefault`. Filtering at the source + // would break identity resolution; filtering here is the spec's + // own split. + if (name === 'default') continue; let origins = starOrigins.get(name); if (!origins) starOrigins.set(name, (origins = new Map())); origins.set(origin, specifier); @@ -215,5 +226,17 @@ export function readFacadeExports(entryFile: string): string[] { return names; }; - return [...exportedNames(entryFile).keys()].sort(); + const names = exportedNames(entryFile); + // The entry's own default, in EITHER form. `export default …` throws above + // as it is parsed; `export { default } from './x.ts'` reaches here instead, + // because oxc reports it as a named export called `default` — a different + // parse shape for the same fact, that the façade carries a default export. + if (names.has('default')) { + throw new Error( + `readFacadeExports cannot enumerate a default export as a named symbol ` + + `(${path.resolve(entryFile)}) — a facade a caller pins to an exact named-export list ` + + 'must not carry one, whether declared or re-exported under the name `default`.', + ); + } + return [...names.keys()].sort(); }