diff --git a/docs/specs/AST-110-swizzle-output/spec.md b/docs/specs/AST-110-swizzle-output/spec.md index ca5913fbd320a..b2397c3af9c57 100644 --- a/docs/specs/AST-110-swizzle-output/spec.md +++ b/docs/specs/AST-110-swizzle-output/spec.md @@ -145,6 +145,8 @@ and the repository proves it before a change that could break it merges. Every component swizzle can copy means Core's components as `swizzle --list` offers them (`spec:AST-035` FR12) and every component of each integration package in the repository, copied with `--package`. + A source check fails when a module that FR3 covers creates a module-local + Symbol, WeakMap or registry instead of a registered shared key. ### Platform support @@ -229,8 +231,9 @@ and the repository proves it before a change that could break it merges. | FR5 | Swizzle into a new folder, an empty one, a marked one, and one holding a product module; read the folder and the `swizzle.copy` result | Default output; `--output` to a product folder; a folder of earlier copies with no marker | A marker lands in a folder that holds a product module, a new, empty or marked folder is left unmarked, or the result omits what a skipped folder holds | | FR6 | The component's own interaction and accessibility checks run against the copy | Keyboard, pointer, screen-reader tree | A check that passes on the package fails on the copy | | FR7 | The required test lane's swizzle proof and the release-time proof; both cover Core's list and every integration package's components through `--package` | A change to component source; a change to the swizzle copy; an unrelated change; Core, Lab, Charts, RichText, Vega | An offered component missing from the proof, a failure that does not block, a new required context, the proof adding wall-clock time to the slowest required check, or the proof pushing its job past the pull-request time budget | +| FR7 | `scripts/shared-identity.test.mjs` over the source of the packages swizzle copies from, against its baseline | A module-local `Symbol()` brand in an FR3 module; the same brand under `sharedKey`; a module-scope registry an exported register function writes | The module-local brand or registry passes, the `sharedKey` brand fails, or a baseline entry the source no longer has stays listed | -Known verification gap: only FR3's shared-context checks (`sharedContexts.test.tsx`) exist; the other rows have no check on `main`. +Known verification gap: only FR3's shared-context checks (`sharedContexts.test.tsx`) and FR7's source check (`scripts/shared-identity.test.mjs`) exist; the other rows have no check on `main`. ## Decision log diff --git a/scripts/shared-identity.baseline.json b/scripts/shared-identity.baseline.json new file mode 100644 index 0000000000000..bf93636558757 --- /dev/null +++ b/scripts/shared-identity.baseline.json @@ -0,0 +1,1013 @@ +{ + "$comment": "Module-scope cross-module identity the FR3 check knows about (spec:AST-110 FR3, FR7). \"open\" names the fix group that removes the entry; \"allowed\" gives the approved reason. A new hit or a stale entry fails scripts/shared-identity.test.mjs.", + "entries": { + "packages/core/src/AppShell/useAppShellMainId.ts:claims": { + "status": "open", + "group": "triage" + }, + "packages/core/src/AppShell/useAppShellMainId.ts:listeners": { + "status": "open", + "group": "triage" + }, + "packages/core/src/BottomSheet/BottomSheetPanel.tsx:stopViewportResize": { + "status": "allowed", + "reason": "Each copy observes for its own subscribers; a second observer changes no result." + }, + "packages/core/src/BottomSheet/BottomSheetPanel.tsx:viewportHeight": { + "status": "allowed", + "reason": "Each copy observes for its own subscribers; a second observer changes no result." + }, + "packages/core/src/BottomSheet/BottomSheetPanel.tsx:viewportHeightListeners": { + "status": "allowed", + "reason": "Each copy observes for its own subscribers; a second observer changes no result." + }, + "packages/core/src/BottomSheet/bottomSheetStack.ts:showFocusesContainer": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/Chat/useChatDictation.ts:_sharedAudioCtx": { + "status": "allowed", + "reason": "One audio context per copy for dictation level metering; a second context costs memory and changes no result." + }, + "packages/core/src/Chat/useChatStreamScroll.ts:following": { + "status": "open", + "group": "triage" + }, + "packages/core/src/Chat/useSpeechRecognition.ts:_sharedAudioCtx": { + "status": "allowed", + "reason": "One audio context per copy for dictation level metering; a second context costs memory and changes no result." + }, + "packages/core/src/CodeBlock/highlightRanges.ts:dynamicStyleSheet": { + "status": "open", + "group": "triage" + }, + "packages/core/src/CodeBlock/highlightRanges.ts:registeredHighlightTypes": { + "status": "open", + "group": "triage" + }, + "packages/core/src/CodeBlock/highlightStyles.ts:inserted": { + "status": "allowed", + "reason": "Deduplicates an idempotent style insertion; a copy can at most insert the same rules again." + }, + "packages/core/src/CodeBlock/tokenizer.ts:langCache": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/CodeBlock/tokenizer.ts:valueCache": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/DateInput/monthGeometry.ts:GREGORIAN_MONTH_MODELS": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/DateInput/nativeDateSegments.ts:cachedEngine": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/Icon/globalIconRegistry.tsx:globalRegistry": { + "status": "open", + "group": "F8 Public registries" + }, + "packages/core/src/Layer/gestureCounter.ts:claimedPress": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/Layer/gestureCounter.ts:clickedGesture": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/Layer/gestureCounter.ts:contextMenuGesture": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/Layer/gestureCounter.ts:gesture": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/Layer/gestureCounter.ts:isListening": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/Layer/gestureCounter.ts:pressPointerId": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/Layer/gestureCounter.ts:unsubscribePress": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/Layer/layerExitCopy.ts:copies": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/Layer/layerStack.ts:composingDocuments": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/Layer/layerStack.ts:entries": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/Layer/layerStack.ts:layerCountByDocument": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/Layer/layerStack.ts:nextSeq": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/Layer/layerStack.ts:seqByToken": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/Layer/layerSurfacePress.ts:surfaces": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/Layer/modalOutlet.ts:listeners": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/Layer/modalOutlet.ts:outletGroups": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/Layer/modalOutlet.ts:outlets": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/Layer/outsidePress.ts:outsideDismissals": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/Layer/popoverOperations.ts:browserHidesByDocument": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/Layer/popoverOperations.ts:browserHidesInFlight": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/Layer/popoverOperations.ts:ownOperationsByDocument": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/Layer/popoverOperations.ts:settledCallbacksByDocument": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/List/listLayoutUpdates.ts:keptStyles": { + "status": "open", + "group": "triage" + }, + "packages/core/src/List/listLayoutUpdates.ts:reportedRows": { + "status": "open", + "group": "triage" + }, + "packages/core/src/List/listReorderEngine.tsx:sessionsByOptions": { + "status": "open", + "group": "F10 List reorder" + }, + "packages/core/src/Markdown/ast.ts:markdownAstLegacyCodeLanguage": { + "status": "open", + "group": "F11 Markdown protocol metadata" + }, + "packages/core/src/Markdown/characterReferences.ts:namedReferences": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/Markdown/parser.ts:imageAltDepth": { + "status": "allowed", + "reason": "Per-run state of one call (a parse, a transform or a render pass) that never outlives the call; a run never crosses copies." + }, + "packages/core/src/Markdown/parser.ts:incrementalCaches": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/Markdown/parser.ts:incrementalStateMode": { + "status": "open", + "group": "F11 Markdown protocol metadata" + }, + "packages/core/src/Markdown/parser.ts:listMarkerShapes": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/Markdown/plugins/protocol.ts:markdownSourceLinesDefinition": { + "status": "allowed", + "reason": "Deliberately unshared: a shared key would let any module forge trust or a parse claim." + }, + "packages/core/src/Markdown/plugins/protocol.ts:markdownTransformClaim": { + "status": "allowed", + "reason": "Deliberately unshared: a shared key would let any module forge trust or a parse claim." + }, + "packages/core/src/Markdown/plugins/protocol.ts:markdownTransformHasRenderer": { + "status": "open", + "group": "F11 Markdown protocol metadata" + }, + "packages/core/src/Markdown/plugins/protocol.ts:markdownTransformPluginName": { + "status": "open", + "group": "F11 Markdown protocol metadata" + }, + "packages/core/src/Markdown/plugins/protocol.ts:markdownTransformSourceUnchanged": { + "status": "open", + "group": "F11 Markdown protocol metadata" + }, + "packages/core/src/Markdown/plugins/protocol.ts:markdownTransformTrusted": { + "status": "allowed", + "reason": "Deliberately unshared: a shared key would let any module forge trust or a parse claim." + }, + "packages/core/src/Markdown/plugins/protocol.ts:preparedPluginLists": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/Markdown/plugins/protocol.ts:reportedProductionPluginFailures": { + "status": "allowed", + "reason": "Deduplicates development warnings; a copy can at most repeat a warning once." + }, + "packages/core/src/Markdown/plugins/protocol.ts:reusableScratch": { + "status": "allowed", + "reason": "Per-run state of one call (a parse, a transform or a render pass) that never outlives the call; a run never crosses copies." + }, + "packages/core/src/Markdown/plugins/protocol.ts:scratchInUse": { + "status": "allowed", + "reason": "Per-run state of one call (a parse, a transform or a render pass) that never outlives the call; a run never crosses copies." + }, + "packages/core/src/Markdown/plugins/protocol.ts:sourceHeadingMarker": { + "status": "open", + "group": "triage" + }, + "packages/core/src/Markdown/plugins/semanticFence.ts:markdownFenceProposal": { + "status": "open", + "group": "F11 Markdown protocol metadata" + }, + "packages/core/src/Markdown/plugins/textTransform.ts:adoptedReplacements": { + "status": "open", + "group": "triage" + }, + "packages/core/src/Markdown/remark.ts:REMARK_ORIGIN": { + "status": "open", + "group": "triage" + }, + "packages/core/src/Markdown/remark.ts:activeRun": { + "status": "allowed", + "reason": "Per-run state of one call (a parse, a transform or a render pass) that never outlives the call; a run never crosses copies." + }, + "packages/core/src/Markdown/remark.ts:adapterReasons": { + "status": "allowed", + "reason": "Per-run state of one call (a parse, a transform or a render pass) that never outlives the call; a run never crosses copies." + }, + "packages/core/src/NumberInput/numberParser.ts:symbolsByLocale": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/PowerSearch/formatTokenValue.ts:tokenNumeralLocales": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/Resizable/createResizable.ts:internals": { + "status": "open", + "group": "F9 Resizable" + }, + "packages/core/src/Resizable/resizablePane.ts:lent": { + "status": "open", + "group": "F9 Resizable" + }, + "packages/core/src/Resizable/resizablePane.ts:propsRegions": { + "status": "open", + "group": "F9 Resizable" + }, + "packages/core/src/Resizable/resizablePane.ts:resizeHandles": { + "status": "open", + "group": "F9 Resizable" + }, + "packages/core/src/Resizable/resizablePane.ts:watchers": { + "status": "open", + "group": "F9 Resizable" + }, + "packages/core/src/Resizable/useResizable.ts:collapseControls": { + "status": "open", + "group": "F9 Resizable" + }, + "packages/core/src/Resizable/useResizable.ts:effectiveMaxima": { + "status": "open", + "group": "F9 Resizable" + }, + "packages/core/src/Resizable/useResizable.ts:releaseEnds": { + "status": "open", + "group": "F9 Resizable" + }, + "packages/core/src/SegmentedControl/useSegmentedSelectionMotion.ts:ownSurface": { + "status": "open", + "group": "triage" + }, + "packages/core/src/SnapPaging/snapPagingValue.ts:attachments": { + "status": "allowed", + "reason": "The attach and the scroll calls run in the same copy; the paging value is shared." + }, + "packages/core/src/Spinner/spinnerRotor.ts:flushScheduled": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/Spinner/spinnerRotor.ts:pendingRotors": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/Table/glidingDivider.ts:inlineColorOf": { + "status": "open", + "group": "triage" + }, + "packages/core/src/Table/glidingDivider.ts:inlineTopOf": { + "status": "open", + "group": "triage" + }, + "packages/core/src/Table/glidingDivider.ts:markedBy": { + "status": "open", + "group": "triage" + }, + "packages/core/src/Table/glidingDivider.ts:sourcesOf": { + "status": "open", + "group": "triage" + }, + "packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.tsx:GROUP_HEADER": { + "status": "open", + "group": "triage" + }, + "packages/core/src/Table/plugins/virtualization/useTableRowsVirtualization.ts:capabilityBrand": { + "status": "open", + "group": "F1 Table capability and plugins" + }, + "packages/core/src/Table/plugins/virtualization/useTableWindow.ts:textContext": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/Table/tableColumnAdmission.ts:heightSafe": { + "status": "open", + "group": "F1 Table capability and plugins" + }, + "packages/core/src/Table/tableColumnAdmission.ts:logicalRenderers": { + "status": "open", + "group": "F1 Table capability and plugins" + }, + "packages/core/src/Table/tableRowContextUsage.ts:countIndependentPlugins": { + "status": "open", + "group": "F1 Table capability and plugins" + }, + "packages/core/src/Table/tableRowContextUsage.ts:countReadingPlugins": { + "status": "open", + "group": "F1 Table capability and plugins" + }, + "packages/core/src/Table/tableRowContextUsage.ts:indexReadingPlugins": { + "status": "open", + "group": "F1 Table capability and plugins" + }, + "packages/core/src/Table/tableRowContextUsage.ts:trackedContexts": { + "status": "open", + "group": "F1 Table capability and plugins" + }, + "packages/core/src/Table/tableRowContextUsage.ts:transformingPlugin": { + "status": "open", + "group": "F1 Table capability and plugins" + }, + "packages/core/src/Table/tableRuntime.ts:ownedScrollWrappers": { + "status": "open", + "group": "F1 Table capability and plugins" + }, + "packages/core/src/Timestamp/formatRelativeTime.ts:formatters": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/Timestamp/tooltipEntries.ts:warnedTimezoneIDs": { + "status": "allowed", + "reason": "Deduplicates development warnings; a copy can at most repeat a warning once." + }, + "packages/core/src/Toast/useToast.tsx:fallbackContext": { + "status": "open", + "group": "F2 Toast host" + }, + "packages/core/src/Toast/useToast.tsx:fallbackRoot": { + "status": "open", + "group": "F2 Toast host" + }, + "packages/core/src/Toast/useToast.tsx:toastIdCounter": { + "status": "open", + "group": "F13 Shared id sequences" + }, + "packages/core/src/TreeList/TreeList.tsx:clipGenerations": { + "status": "open", + "group": "triage" + }, + "packages/core/src/gestures/GestureRecognizer.ts:nextRecognizerId": { + "status": "open", + "group": "F13 Shared id sequences" + }, + "packages/core/src/gestures/fingerScroll.ts:watches": { + "status": "open", + "group": "triage" + }, + "packages/core/src/gestures/gestureSync.ts:syncingDepth": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/gestures/haptics.ts:nextLabelId": { + "status": "open", + "group": "F13 Shared id sequences" + }, + "packages/core/src/gestures/pageHold.ts:holdsByDocument": { + "status": "open", + "group": "F3 Scroll lock" + }, + "packages/core/src/gestures/touchActionRule.ts:markedRoots": { + "status": "allowed", + "reason": "Deduplicates an idempotent style insertion; a copy can at most insert the same rules again." + }, + "packages/core/src/gestures/touchActionRule.ts:sheets": { + "status": "allowed", + "reason": "Deduplicates an idempotent style insertion; a copy can at most insert the same rules again." + }, + "packages/core/src/hooks/modalAnnounce.ts:deferredByScope": { + "status": "open", + "group": "F5 Focus and announce" + }, + "packages/core/src/hooks/modalAnnounce.ts:held": { + "status": "open", + "group": "F5 Focus and announce" + }, + "packages/core/src/hooks/modalAnnounce.ts:presented": { + "status": "open", + "group": "F5 Focus and announce" + }, + "packages/core/src/hooks/rootScrollStyles.ts:claims": { + "status": "open", + "group": "F3 Scroll lock" + }, + "packages/core/src/hooks/rootScrollStyles.ts:held": { + "status": "open", + "group": "F3 Scroll lock" + }, + "packages/core/src/hooks/rootScrollStyles.ts:lockCount": { + "status": "open", + "group": "F3 Scroll lock" + }, + "packages/core/src/hooks/rootScrollStyles.ts:lockListeners": { + "status": "open", + "group": "F3 Scroll lock" + }, + "packages/core/src/hooks/rootScrollStyles.ts:nextClaimId": { + "status": "open", + "group": "F3 Scroll lock" + }, + "packages/core/src/hooks/scrollGeometrySubscriptions.ts:listeners": { + "status": "allowed", + "reason": "Each copy observes for its own subscribers; a second observer changes no result." + }, + "packages/core/src/hooks/scrollOwnerRegistry.ts:owners": { + "status": "open", + "group": "triage" + }, + "packages/core/src/hooks/useAnnounce.ts:clearTimers": { + "status": "open", + "group": "F5 Focus and announce" + }, + "packages/core/src/hooks/useAnnounce.ts:lastJoined": { + "status": "open", + "group": "F5 Focus and announce" + }, + "packages/core/src/hooks/useAnnounce.ts:modalRoute": { + "status": "open", + "group": "F5 Focus and announce" + }, + "packages/core/src/hooks/useAnnounce.ts:pendingByRegion": { + "status": "open", + "group": "F5 Focus and announce" + }, + "packages/core/src/hooks/useAnnounce.ts:pendingFrames": { + "status": "open", + "group": "F5 Focus and announce" + }, + "packages/core/src/hooks/useAnnounce.ts:pendingMessages": { + "status": "open", + "group": "F5 Focus and announce" + }, + "packages/core/src/hooks/useAnnounce.ts:regions": { + "status": "open", + "group": "F5 Focus and announce" + }, + "packages/core/src/hooks/useEntryAnimation.ts:initialPaintComplete": { + "status": "open", + "group": "triage" + }, + "packages/core/src/hooks/useFocusTrap.ts:activeEscapeTrapCount": { + "status": "open", + "group": "F5 Focus and announce" + }, + "packages/core/src/hooks/useFocusTrap.ts:activeRegionContainers": { + "status": "open", + "group": "F5 Focus and announce" + }, + "packages/core/src/hooks/useMediaQuery.ts:watches": { + "status": "allowed", + "reason": "Each copy observes for its own subscribers; a second observer changes no result." + }, + "packages/core/src/hooks/useMenuPress.ts:isModelActivation": { + "status": "open", + "group": "triage" + }, + "packages/core/src/hooks/useOverflow.ts:pendingMeasures": { + "status": "open", + "group": "triage" + }, + "packages/core/src/hooks/useScrollLock.ts:activeLock": { + "status": "open", + "group": "F3 Scroll lock" + }, + "packages/core/src/hooks/useScrollLock.ts:lockCount": { + "status": "open", + "group": "F3 Scroll lock" + }, + "packages/core/src/i18n/resolve.ts:formatterCache": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/keyboard/keyboardHeight.ts:focusTakeovers": { + "status": "open", + "group": "F6 Keyboard" + }, + "packages/core/src/keyboard/keyboardHeight.ts:stores": { + "status": "open", + "group": "F6 Keyboard" + }, + "packages/core/src/keyboard/keyboardHeight.ts:touchStartWatches": { + "status": "open", + "group": "F6 Keyboard" + }, + "packages/core/src/keyboard/keyboardLayoutGuide.ts:guides": { + "status": "open", + "group": "F6 Keyboard" + }, + "packages/core/src/keyboard/keyboardTarget.ts:currentRides": { + "status": "open", + "group": "F6 Keyboard" + }, + "packages/core/src/keyboard/keyboardTarget.ts:riddenAnimators": { + "status": "open", + "group": "F6 Keyboard" + }, + "packages/core/src/keyboard/keyboardTarget.ts:targets": { + "status": "open", + "group": "F6 Keyboard" + }, + "packages/core/src/keyboard/useMobileKeyboard.ts:revealsAtReport": { + "status": "open", + "group": "F6 Keyboard" + }, + "packages/core/src/motion/animatedLayout.ts:batches": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animatedLayout.ts:changeStarts": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animatedLayout.ts:edgeMotions": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animatedLayout.ts:endedReports": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animatedLayout.ts:exits": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animatedLayout.ts:glideReports": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animatedLayout.ts:glides": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animatedLayout.ts:heldSlides": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animatedLayout.ts:ours": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animatedLayout.ts:presentedGlides": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animatedLayout.ts:releaseGlides": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animatedLayout.ts:replaced": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animatedLayout.ts:resizeReveals": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animatedLayout.ts:slideTargets": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animatedLayout.ts:stoppedShown": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animator.ts:frameDepth": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animator.ts:frameRead": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animator.ts:openCall": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animator.ts:openTask": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animator.ts:shared": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animator.ts:sheetReads": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animator.ts:taskBakes": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animator.ts:waitingCalls": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/animator.ts:waitingRead": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/resolveMotion.ts:springsAtTempo": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/motion/roles.ts:pathOfGroup": { + "status": "allowed", + "reason": "Each copy registers and reads its own motion role tables; lookups never cross copies." + }, + "packages/core/src/motion/roles.ts:roleIdOfPath": { + "status": "allowed", + "reason": "Each copy registers and reads its own motion role tables; lookups never cross copies." + }, + "packages/core/src/motion/roles.ts:roleOfLeaf": { + "status": "allowed", + "reason": "Each copy registers and reads its own motion role tables; lookups never cross copies." + }, + "packages/core/src/motion/roles.ts:rolesById": { + "status": "allowed", + "reason": "Each copy registers and reads its own motion role tables; lookups never cross copies." + }, + "packages/core/src/motion/roles.ts:tablesById": { + "status": "allowed", + "reason": "Each copy registers and reads its own motion role tables; lookups never cross copies." + }, + "packages/core/src/motion/transientCopy.ts:carriedMotion": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/transientCopy.ts:inheritedValues": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/motion/transientCopy.ts:scrollOffsets": { + "status": "open", + "group": "F7 Motion frame" + }, + "packages/core/src/privateAttributes/travelling.stylex.ts:marks": { + "status": "open", + "group": "triage" + }, + "packages/core/src/reorderable/createReorderable.ts:reorderableBrand": { + "status": "open", + "group": "F10 List reorder" + }, + "packages/core/src/theme/Theme.tsx:injectedThemes": { + "status": "open", + "group": "triage" + }, + "packages/core/src/theme/declarationBoundary.ts:DECLARATION_ANALYSIS_CACHE": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/theme/dynamicType.ts:sharedSyncs": { + "status": "allowed", + "reason": "Each copy writes the same reader text-size variables to the document root; the duplicate sync is idempotent." + }, + "packages/core/src/theme/generateThemeRules.ts:REACHABLE_MATCH_SET_CACHE": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/theme/generateThemeRules.ts:STATUS_ROLE_GENERATION_CACHE": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/theme/generateThemeRules.ts:activeStatusRoleGeneration": { + "status": "allowed", + "reason": "Per-run state of one call (a parse, a transform or a render pass) that never outlives the call; a run never crosses copies." + }, + "packages/core/src/theme/statusRoleDerivation.ts:coreCache": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/theme/themeRegistry.ts:themeRegistry": { + "status": "open", + "group": "F8 Public registries" + }, + "packages/core/src/theme/useTheme.ts:rootModeAttrListeners": { + "status": "allowed", + "reason": "Each copy observes for its own subscribers; a second observer changes no result." + }, + "packages/core/src/theme/useTheme.ts:rootModeAttrObserver": { + "status": "allowed", + "reason": "Each copy observes for its own subscribers; a second observer changes no result." + }, + "packages/core/src/theme/useTheme.ts:rootNameAttrListeners": { + "status": "allowed", + "reason": "Each copy observes for its own subscribers; a second observer changes no result." + }, + "packages/core/src/theme/useTheme.ts:rootNameAttrObserver": { + "status": "allowed", + "reason": "Each copy observes for its own subscribers; a second observer changes no result." + }, + "packages/core/src/utils/boxHold.ts:waits": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/calendarDateParser.ts:displayOptionCache": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/utils/calendarDateParser.ts:eraChangeCache": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/utils/calendarDateParser.ts:lexicons": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/utils/calendarSystem.ts:supportedCalendars": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/utils/devWarning.ts:warnedKeys": { + "status": "allowed", + "reason": "Deduplicates development warnings; a copy can at most repeat a warning once." + }, + "packages/core/src/utils/focusReturn.ts:focusDetachedAtRemovalStart": { + "status": "open", + "group": "F5 Focus and announce" + }, + "packages/core/src/utils/focusReturn.ts:temporaryFocus": { + "status": "open", + "group": "F5 Focus and announce" + }, + "packages/core/src/utils/hitArea.ts:frame": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/hitArea.ts:interactive": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/hitArea.ts:interactiveDocument": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/hitArea.ts:isCoarse": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/hitArea.ts:observedSizes": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/hitArea.ts:pending": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/hitArea.ts:pointer": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/hitArea.ts:pressDocument": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/hitArea.ts:resize": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/hitArea.ts:scopeTimers": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/hitArea.ts:stopInteractiveWatch": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/hitArea.ts:view": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/hitArea.ts:visibility": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/hitArea.ts:watched": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/layoutViewport.ts:thicknesses": { + "status": "open", + "group": "F6 Keyboard" + }, + "packages/core/src/utils/plainDate.ts:dateTimeFormats": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/utils/plainDate.ts:dayNumberFormats": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/utils/pressClaim.ts:watchers": { + "status": "open", + "group": "F4 Layers and popovers" + }, + "packages/core/src/utils/pressFeedback.ts:boxHold": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/pressFeedback.ts:button": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/pressFeedback.ts:contact": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/pressFeedback.ts:finger": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/pressFeedback.ts:holders": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/pressFeedback.ts:keyHeld": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/pressFeedback.ts:lastScrollAt": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/pressFeedback.ts:lastScroller": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/pressFeedback.ts:lastTouchAt": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/pressFeedback.ts:lastTouchTarget": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/pressFeedback.ts:stopTouchCancel": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/pressFeedback.ts:stopWatchingPointer": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/pressFeedback.ts:unsubscribers": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/rowMotion.ts:motions": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/selectionLock.ts:locks": { + "status": "open", + "group": "F3 Scroll lock" + }, + "packages/core/src/utils/sharedMutationObserver.ts:observers": { + "status": "allowed", + "reason": "Each copy observes for its own subscribers; a second observer changes no result." + }, + "packages/core/src/utils/sharedResizeObserver.ts:callbacks": { + "status": "allowed", + "reason": "Each copy observes for its own subscribers; a second observer changes no result." + }, + "packages/core/src/utils/sharedResizeObserver.ts:observer": { + "status": "allowed", + "reason": "Each copy observes for its own subscribers; a second observer changes no result." + }, + "packages/core/src/utils/timeFormat.ts:symbolsByLocale": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/core/src/utils/touchHitArea.ts:heldWatches": { + "status": "open", + "group": "triage" + }, + "packages/core/src/utils/windowExtent.ts:stopObserving": { + "status": "allowed", + "reason": "Each copy observes for its own subscribers; a second observer changes no result." + }, + "packages/core/src/utils/windowExtent.ts:windowWidth": { + "status": "allowed", + "reason": "Each copy observes for its own subscribers; a second observer changes no result." + }, + "packages/core/src/utils/windowExtent.ts:windowWidthListeners": { + "status": "allowed", + "reason": "Each copy observes for its own subscribers; a second observer changes no result." + }, + "packages/lab/src/CodeEditor/CodeEditor.tsx:editorInstanceCounter": { + "status": "open", + "group": "F13 Shared id sequences" + }, + "packages/lab/src/EmojiPicker/EmojiPicker.tsx:bundledCategories": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/lab/src/EmojiPicker/EmojiPicker.tsx:bundledLabelKeys": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/lab/src/LineClamp/LineClamp.tsx:deferredSyncs": { + "status": "open", + "group": "triage" + }, + "packages/lab/src/LineClamp/LineClamp.tsx:isReleaseFlushing": { + "status": "open", + "group": "triage" + }, + "packages/lab/src/Schedule/Schedule.tsx:eventLoaderCache": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/lab/src/Schedule/useCurrentTime.ts:currentTime": { + "status": "allowed", + "reason": "Each copy observes for its own subscribers; a second observer changes no result." + }, + "packages/lab/src/Schedule/useCurrentTime.ts:interval": { + "status": "allowed", + "reason": "Each copy observes for its own subscribers; a second observer changes no result." + }, + "packages/lab/src/Schedule/useCurrentTime.ts:listeners": { + "status": "allowed", + "reason": "Each copy observes for its own subscribers; a second observer changes no result." + }, + "packages/richtext/src/CodeSyntaxPlugin.tsx:hasInjectedRules": { + "status": "allowed", + "reason": "Deduplicates an idempotent style insertion; a copy can at most insert the same rules again." + }, + "packages/richtext/src/markdownExtensionNode.ts:decorators": { + "status": "open", + "group": "F12 RichText extensions" + }, + "packages/richtext/src/markdownExtensions.ts:extensionPlugin": { + "status": "open", + "group": "F12 RichText extensions" + }, + "packages/richtext/src/markdownExtensions.ts:withExport": { + "status": "open", + "group": "triage" + }, + "packages/richtext/src/markdownSource.ts:canonicalCache": { + "status": "allowed", + "reason": "A memo cache; a second cache per copy changes no result." + }, + "packages/richtext/src/markdownSource.ts:importCount": { + "status": "open", + "group": "triage" + }, + "packages/vega/src/viewInputs.ts:OPAQUE": { + "status": "open", + "group": "triage" + } + } +} diff --git a/scripts/shared-identity.mjs b/scripts/shared-identity.mjs new file mode 100644 index 0000000000000..304eaa97a6f54 --- /dev/null +++ b/scripts/shared-identity.mjs @@ -0,0 +1,332 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file shared-identity.mjs + * @input The source of the packages swizzle copies components from + * @output The places where a module keeps cross-module identity in module + * scope instead of a registered shared key (spec:AST-110 FR3): a + * module-local symbol that keys an exported type or that an exported + * function reaches, and module-scope state (a `let`, or a collection or an + * object or array literal that changes) that an exported function reaches, directly or through the + * module's own functions, or that is keyed by a document or window + * @position Read by shared-identity.test.mjs; no Vitest import + */ + +import fs from 'node:fs'; +import {createRequire} from 'node:module'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +export const REPO = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const ts = createRequire(path.join(REPO, 'package.json'))('typescript'); + +/** The packages swizzle copies components from. */ +export const SOURCE_ROOTS = ['core', 'lab', 'charts', 'richtext', 'vega'].map( + name => `packages/${name}/src`, +); +const SOURCE_FILE = /\.tsx?$/; +const NOT_SHIPPED = + /\.(?:test|spec|stories|story|d|bench)\.tsx?$|(?:^|\/)__(?:tests|fixtures|mocks)__\//; +const COLLECTION = /^(?:Map|Set|WeakMap|WeakSet)$/; +const DOCUMENT_KEY = /^(?:Document|Window)$/; +const MUTATING_METHOD = + /^(?:push|pop|shift|unshift|splice|sort|reverse|fill|copyWithin|set|add|delete|clear)$/; + +/** Every shipped source file, repository-relative. */ +export function sourceFiles() { + return SOURCE_ROOTS.flatMap(root => + fs + .readdirSync(path.join(REPO, root), {recursive: true}) + .map(String) + .map(file => path.join(root, file)) + .filter(file => SOURCE_FILE.test(file) && !NOT_SHIPPED.test(file)), + ).sort(); +} + +/** + * The cross-module identity one module keeps in module scope. + * @param {string} file repository-relative + * @returns {Array<{file: string, name: string, kind: 'symbol'|'registry', why: string}>} + */ +export function findInFile(file) { + return findInSource(file, fs.readFileSync(path.join(REPO, file), 'utf8')); +} + +/** + * The cross-module identity one module's source keeps in module scope. + * @param {string} file the name to report + * @param {string} code the module's source + * @returns {Array<{file: string, name: string, kind: 'symbol'|'registry', why: string}>} + */ +export function findInSource(file, code) { + const source = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true); + /** @type {Map} */ + const candidates = new Map(); + /** Top-level functions, classes and types, by name. */ + /** @type {Map} */ + const scopes = new Map(); + /** Names the module exports, from modifiers, `export {…}` lists and defaults. */ + const exported = new Set(); + const hasExport = node => + (ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Export) !== 0; + /** Whether the module writes into the value `name` holds. */ + const isMutated = name => { + const rootOf = expression => { + let node = expression; + while ( + ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node) || + ts.isNonNullExpression(node) || + ts.isParenthesizedExpression(node) + ) { + node = node.expression; + } + return ts.isIdentifier(node) ? node.text : null; + }; + let mutated = false; + const visit = node => { + if (mutated) return; + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + node.operatorToken.kind <= ts.SyntaxKind.LastAssignment && + !ts.isIdentifier(node.left) && + rootOf(node.left) === name + ) { + mutated = true; + } else if ( + (ts.isPrefixUnaryExpression(node) || + ts.isPostfixUnaryExpression(node)) && + (node.operator === ts.SyntaxKind.PlusPlusToken || + node.operator === ts.SyntaxKind.MinusMinusToken) && + !ts.isIdentifier(node.operand) && + rootOf(node.operand) === name + ) { + mutated = true; + } else if ( + ts.isDeleteExpression(node) && + rootOf(node.expression) === name + ) { + mutated = true; + } else if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + MUTATING_METHOD.test(node.expression.name.text) && + rootOf(node.expression.expression) === name + ) { + mutated = true; + } + ts.forEachChild(node, visit); + }; + visit(source); + return mutated; + }; + + for (const statement of source.statements) { + if (ts.isVariableStatement(statement)) { + const isLet = (statement.declarationList.flags & ts.NodeFlags.Let) !== 0; + const isDeclare = statement.modifiers?.some( + m => m.kind === ts.SyntaxKind.DeclareKeyword, + ); + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name)) continue; + const name = declaration.name.text; + if (hasExport(statement)) exported.add(name); + const init = declaration.initializer; + const typeText = declaration.type?.getText(source) ?? ''; + if ( + (init && + ts.isCallExpression(init) && + init.expression.getText(source) === 'Symbol') || + (isDeclare && /unique symbol/.test(typeText)) + ) { + candidates.set(name, 'symbol'); + } else if (init && ts.isNewExpression(init)) { + const ctor = init.expression.getText(source); + // A collection with fixed contents that nothing changes, or one + // typed read-only, is a constant, not a registry. + const isConstant = + /^Readonly/.test(typeText) || + ((init.arguments?.length ?? 0) > 0 && !isMutated(name)); + if (COLLECTION.test(ctor) && !isConstant) { + const keyType = + init.typeArguments?.[0]?.getText(source) ?? + typeText.match(/^\w+<\s*(\w+)/)?.[1] ?? + ''; + candidates.set( + name, + DOCUMENT_KEY.test(keyType) ? 'document-registry' : 'registry', + ); + } + } else if (isLet) { + candidates.set(name, 'registry'); + } else if ( + init && + (ts.isObjectLiteralExpression(init) || + ts.isArrayLiteralExpression(init)) && + !/^Readonly/.test(typeText) && + isMutated(name) + ) { + // An object or array literal that the module changes holds state + // like a collection does. + candidates.set(name, 'registry'); + } + if ( + init && + (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) + ) { + scopes.set(name, {node: init, isType: false}); + } + } + } else if ( + (ts.isFunctionDeclaration(statement) || + ts.isClassDeclaration(statement)) && + statement.name + ) { + scopes.set(statement.name.text, {node: statement, isType: false}); + if (hasExport(statement)) exported.add(statement.name.text); + } else if ( + ts.isInterfaceDeclaration(statement) || + ts.isTypeAliasDeclaration(statement) + ) { + scopes.set(statement.name.text, {node: statement, isType: true}); + if (hasExport(statement)) exported.add(statement.name.text); + } else if ( + ts.isExportDeclaration(statement) && + !statement.moduleSpecifier && + statement.exportClause && + ts.isNamedExports(statement.exportClause) + ) { + for (const element of statement.exportClause.elements) { + exported.add((element.propertyName ?? element.name).text); + } + } else if ( + ts.isExportAssignment(statement) && + ts.isIdentifier(statement.expression) + ) { + exported.add(statement.expression.text); + } + } + if (candidates.size === 0) return []; + + /** + * The candidates a node uses (and whether each is a computed key in a + * type), and the top-level scopes it refers to. + */ + const usesIn = node => { + /** @type {Map} */ + const used = new Map(); + const refers = new Set(); + const visit = child => { + if (ts.isIdentifier(child)) { + if (candidates.has(child.text)) { + const parent = child.parent; + const asTypeKey = + parent && + ts.isComputedPropertyName(parent) && + (ts.isPropertySignature(parent.parent) || + ts.isMethodSignature(parent.parent)); + used.set(child.text, Boolean(used.get(child.text) || asTypeKey)); + } + if (scopes.has(child.text)) refers.add(child.text); + } + ts.forEachChild(child, visit); + }; + visit(node); + return {used, refers}; + }; + const uses = new Map( + [...scopes].map(([name, scope]) => [name, usesIn(scope.node)]), + ); + + // A function the module hands to another module at load, such as a + // callback it installs, acts for the module as an export does. + const handedOut = new Set(); + for (const statement of source.statements) { + if ( + ts.isExpressionStatement(statement) || + (ts.isVariableStatement(statement) && + statement.declarationList.declarations.some( + declaration => + declaration.initializer && + ts.isCallExpression(declaration.initializer), + )) + ) { + for (const name of usesIn(statement).refers) { + if (!exported.has(name) && !scopes.get(name).isType) { + handedOut.add(name); + } + } + } + } + + const rootName = root => + exported.has(root) + ? `the exported ${root}` + : `${root}, which the module hands out at load,`; + /** @type {Map} name -> why it crosses modules */ + const found = new Map(); + for (const root of [...[...exported].sort(), ...[...handedOut].sort()]) { + const scope = scopes.get(root); + if (!scope) continue; + if (scope.isType) { + for (const [name, asTypeKey] of uses.get(root).used) { + if ( + candidates.get(name) === 'symbol' && + asTypeKey && + !found.has(name) + ) { + found.set(name, `keys the exported type ${root}`); + } + } + continue; + } + // Everything the exported function reaches through the module's own + // functions acts for it. + const reached = new Set([root]); + const queue = [root]; + while (queue.length) { + const next = queue.pop(); + for (const name of uses.get(next)?.refers ?? []) { + if (!reached.has(name) && !scopes.get(name).isType) { + reached.add(name); + queue.push(name); + } + } + } + for (const name of reached) { + for (const used of uses.get(name).used.keys()) { + if (found.has(used)) continue; + found.set( + used, + candidates.get(used) === 'document-registry' + ? `a per-document store ${rootName(root)} uses` + : candidates.get(used) === 'symbol' + ? `read or written by ${rootName(root)}` + : `module state ${rootName(root)} uses`, + ); + } + } + } + // A per-document store counts even when no export reaches it: the + // components of the package and of a copy share the document. + for (const [name, kind] of candidates) { + if (kind === 'document-registry' && !found.has(name)) { + found.set(name, 'a per-document store in module scope'); + } + } + return [...found].map(([name, why]) => ({ + file, + name, + kind: candidates.get(name) === 'symbol' ? 'symbol' : 'registry', + why, + })); +} + +/** Every hit in the shipped source. */ +export function findAll() { + return sourceFiles().flatMap(findInFile); +} diff --git a/scripts/shared-identity.test.mjs b/scripts/shared-identity.test.mjs new file mode 100644 index 0000000000000..c6199f0817768 --- /dev/null +++ b/scripts/shared-identity.test.mjs @@ -0,0 +1,119 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file shared-identity.test.mjs + * @input The shipped source of the packages swizzle copies from, and + * scripts/shared-identity.baseline.json + * @output Fails when a module keeps cross-module identity in module scope + * (a module-local symbol or registry) that the baseline does not list, or + * when the baseline lists one the source no longer has + * (spec:AST-110 FR3, FR7) + * @position Node test over source; needs no build + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {describe, expect, it} from 'vitest'; + +import {REPO, findAll, findInSource} from './shared-identity.mjs'; + +const BASELINE = path.join(REPO, 'scripts/shared-identity.baseline.json'); +const FIX = + 'Keep it under a registered key: sharedKey or realmValue from ' + + 'packages/core/src/utils/sharedRealm.ts, createSharedContext for a ' + + 'context, and for a type brand one optional phantom member typed never ' + + '(spec:AST-110 FR3).'; + +describe('cross-module identity in module scope (spec:AST-110 FR3)', () => { + it('flags a module-local symbol brand and passes a shared key', () => { + const local = findInSource( + 'brand.ts', + [ + `const brand: unique symbol = Symbol('Brand');`, + `export interface Branded { readonly [brand]: true }`, + `export function make(): Branded { return {[brand]: true}; }`, + ].join('\n'), + ); + expect(local.map(hit => hit.name)).toEqual(['brand']); + const shared = findInSource( + 'brand.ts', + [ + `import {sharedKey} from './sharedRealm';`, + `const BRAND = sharedKey('brand', 1);`, + `export interface Branded { readonly '@astryxdesign/core/brand/v1'?: never }`, + `export function make(): Branded { return {[BRAND]: true} as Branded; }`, + ].join('\n'), + ); + expect(shared).toEqual([]); + }); + + it('flags a module-scope registry an exported function reads', () => { + const hits = findInSource( + 'host.ts', + [ + `let registered: unknown = null;`, + `export function registerHost(host: unknown) { registered = host; }`, + ].join('\n'), + ); + expect(hits.map(hit => hit.name)).toEqual(['registered']); + }); + + it('flags state an export reaches through a private helper or an export list, including a literal it changes', () => { + const hits = findInSource( + 'claims.ts', + [ + `const held = new Map();`, + `const regions: WeakMap = new WeakMap();`, + `const claims: Record = {overflow: []};`, + `function remember(id: number) { held.set(id, 'x'); claims.overflow.push(id); }`, + `function claim(id: number) { remember(id); }`, + `export {claim};`, + ].join('\n'), + ); + expect(hits.map(hit => [hit.name, hit.why])).toEqual([ + ['held', 'module state the exported claim uses'], + ['claims', 'module state the exported claim uses'], + ['regions', 'a per-document store in module scope'], + ]); + }); + + it('flags state a function the module hands out at load reaches', () => { + const hits = findInSource( + 'route.ts', + [ + `import {installRoute} from './other';`, + `const deferred = new WeakMap();`, + `function announce(scope: object) { deferred.set(scope, 'x'); }`, + `installRoute({announce});`, + ].join('\n'), + ); + expect(hits.map(hit => hit.name)).toEqual(['deferred']); + }); + + it('lists every hit in the baseline, and nothing the source no longer has', () => { + const baseline = JSON.parse(fs.readFileSync(BASELINE, 'utf8')).entries; + const hits = findAll(); + const found = new Set(hits.map(hit => `${hit.file}:${hit.name}`)); + const unlisted = hits + .filter(hit => !(`${hit.file}:${hit.name}` in baseline)) + .map(hit => `${hit.file}:${hit.name}: ${hit.why}. ${FIX}`); + const stale = Object.keys(baseline).filter(key => !found.has(key)); + expect(unlisted, 'new module-scope identity').toEqual([]); + expect( + stale, + 'baseline entries the source no longer has; delete them', + ).toEqual([]); + }); + + it('gives every allowed entry a reason and every open entry a fix group', () => { + const baseline = JSON.parse(fs.readFileSync(BASELINE, 'utf8')).entries; + for (const [key, entry] of Object.entries(baseline)) { + if (entry.status === 'allowed') { + expect(entry.reason, key).toMatch(/\S/); + } else { + expect(entry.status, key).toBe('open'); + expect(entry.group, key).toMatch(/\S/); + } + } + }); +});