chore: add pnpm workspace configuration with package definitions and overrides
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
{
|
||||
"summary": {
|
||||
"filesScanned": 120,
|
||||
"elementsParsed": 10219,
|
||||
"vstackUses": 1425,
|
||||
"hstackUses": 853
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"title": "Layout idiom census — exact VStack/HStack/Card/Grid/Section configs (≥ 3 uses, ≥ 2 files)",
|
||||
"rows": [
|
||||
{
|
||||
"signature": "VStack(gap=1)",
|
||||
"count": 464,
|
||||
"files": 34,
|
||||
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/auth/pages/ProfileSettingsPage.tsx, src/features/dispatch/ProjectDispatch.tsx, src/features/organizations/containers/InvitationContainer.tsx, src/features/organizations/containers/OrganizationSettingsContainer.tsx … (+29 more)",
|
||||
"example": "src/features/artifacts/ArtifactViewer.tsx:194"
|
||||
},
|
||||
{
|
||||
"signature": "VStack(gap=0)",
|
||||
"count": 360,
|
||||
"files": 34,
|
||||
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/auth/containers/UserProfileMenu.tsx, src/features/auth/pages/ProfileSettingsPage.tsx, src/features/dispatch/IntegrationCard.tsx, src/features/organizations/components/MemberTable.tsx … (+29 more)",
|
||||
"example": "src/features/artifacts/ArtifactViewer.tsx:174"
|
||||
},
|
||||
{
|
||||
"signature": "HStack(gap=2,vAlign=center)",
|
||||
"count": 204,
|
||||
"files": 26,
|
||||
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/conversations/WorkspaceToolsPanel.tsx, src/features/playbooks/PlaybookDiagram.tsx, src/features/tasks/TaskKanbanBoard.tsx, src/pages/AgentDetails.tsx … (+21 more)",
|
||||
"example": "src/features/artifacts/ArtifactViewer.tsx:166"
|
||||
},
|
||||
{
|
||||
"signature": "Card(padding=4)",
|
||||
"count": 160,
|
||||
"files": 16,
|
||||
"filesList": "src/features/dispatch/IntegrationCard.tsx, src/pages/AgentPluginDetails.tsx, src/pages/AgentPlugins.tsx, src/pages/ConnectorDetails.tsx, src/pages/Connectors.tsx … (+11 more)",
|
||||
"example": "src/features/dispatch/IntegrationCard.tsx:40"
|
||||
},
|
||||
{
|
||||
"signature": "StackItem(size=fill)",
|
||||
"count": 150,
|
||||
"files": 20,
|
||||
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/conversations/WorkspaceToolsPanel.tsx, src/features/dispatch/IntegrationCard.tsx, src/features/playbooks/PlaybookDiagram.tsx, src/features/playbooks/PlaybookFlow.tsx … (+15 more)",
|
||||
"example": "src/features/artifacts/ArtifactViewer.tsx:173"
|
||||
},
|
||||
{
|
||||
"signature": "HStack(gap=3,vAlign=center)",
|
||||
"count": 138,
|
||||
"files": 33,
|
||||
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/auth/components/AuthLayout.tsx, src/features/dispatch/IntegrationCard.tsx, src/features/organizations/components/MemberTable.tsx, src/features/organizations/containers/OrganizationInvitationsContainer.tsx … (+28 more)",
|
||||
"example": "src/features/artifacts/ArtifactViewer.tsx:370"
|
||||
},
|
||||
{
|
||||
"signature": "VStack(gap=3)",
|
||||
"count": 128,
|
||||
"files": 30,
|
||||
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/dispatch/IntegrationCard.tsx, src/features/organizations/pages/OrganizationCreatePage.tsx, src/features/playbooks/PlaybookDiagram.tsx, src/features/playbooks/PlaybookFlow.tsx … (+25 more)",
|
||||
"example": "src/features/artifacts/ArtifactViewer.tsx:193"
|
||||
},
|
||||
{
|
||||
"signature": "VStack(gap=4)",
|
||||
"count": 114,
|
||||
"files": 19,
|
||||
"filesList": "src/features/auth/pages/LoginPage.tsx, src/features/auth/pages/SignupPage.tsx, src/features/organizations/components/OrganizationForm.tsx, src/pages/AgentDetails.tsx, src/pages/AgentPlugins.tsx … (+14 more)",
|
||||
"example": "src/features/auth/pages/LoginPage.tsx:115"
|
||||
},
|
||||
{
|
||||
"signature": "VStack(gap=2)",
|
||||
"count": 96,
|
||||
"files": 20,
|
||||
"filesList": "src/features/agent-backends/AcpLaunchInstructions.tsx, src/features/artifacts/ArtifactViewer.tsx, src/features/auth/components/AuthLayout.tsx, src/features/auth/containers/UserProfileMenu.tsx, src/features/dispatch/IntegrationCard.tsx … (+15 more)",
|
||||
"example": "src/features/agent-backends/AcpLaunchInstructions.tsx:31"
|
||||
},
|
||||
{
|
||||
"signature": "Grid(columns=expr,gap=4)",
|
||||
"count": 93,
|
||||
"files": 22,
|
||||
"filesList": "src/features/auth/pages/ProfileSettingsPage.tsx, src/features/auth/pages/SignupPage.tsx, src/pages/AgentDetails.tsx, src/pages/AgentPluginDetails.tsx, src/pages/AgentPlugins.tsx … (+17 more)",
|
||||
"example": "src/features/auth/pages/ProfileSettingsPage.tsx:129"
|
||||
}
|
||||
],
|
||||
"total": 81
|
||||
},
|
||||
{
|
||||
"title": "Repeated JSX structure fingerprints (≥ 3 occurrences, ≥ 2 files)",
|
||||
"rows": [
|
||||
{
|
||||
"signature": "Text(color:s,type:s)[#t1]",
|
||||
"count": 1255,
|
||||
"files": 47,
|
||||
"filesList": "src/features/agent-backends/AcpLaunchInstructions.tsx, src/features/artifacts/ArtifactViewer.tsx, src/features/auth/components/AuthLayout.tsx, src/features/auth/pages/ProfileSettingsPage.tsx, src/features/conversations/WorkspaceToolsPanel.tsx … (+42 more)",
|
||||
"example": "src/features/agent-backends/AcpLaunchInstructions.tsx:24"
|
||||
},
|
||||
{
|
||||
"signature": "Heading(level:s)[#t1]",
|
||||
"count": 837,
|
||||
"files": 42,
|
||||
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/auth/pages/ProfileSettingsPage.tsx, src/features/dispatch/ProjectDispatch.tsx, src/features/organizations/containers/InvitationContainer.tsx, src/features/organizations/containers/OrganizationInvitationsContainer.tsx … (+37 more)",
|
||||
"example": "src/features/artifacts/ArtifactViewer.tsx:156"
|
||||
},
|
||||
{
|
||||
"signature": "Icon(color:s,icon:s,size:s)[]",
|
||||
"count": 347,
|
||||
"files": 40,
|
||||
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/auth/containers/UserProfileMenu.tsx, src/features/conversations/WorkspaceToolsPanel.tsx, src/features/dispatch/IntegrationCard.tsx, src/features/dispatch/ProjectDispatch.tsx … (+35 more)",
|
||||
"example": "src/features/artifacts/ArtifactViewer.tsx:126"
|
||||
},
|
||||
{
|
||||
"signature": "Text(type:s,weight:s)[#t1]",
|
||||
"count": 314,
|
||||
"files": 31,
|
||||
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/auth/pages/ProfileSettingsPage.tsx, src/features/conversations/WorkspaceToolsPanel.tsx, src/features/dispatch/IntegrationCard.tsx, src/features/organizations/containers/OrganizationSettingsContainer.tsx … (+26 more)",
|
||||
"example": "src/features/artifacts/ArtifactViewer.tsx:175"
|
||||
},
|
||||
{
|
||||
"signature": "Badge(label:s,variant:s)[]",
|
||||
"count": 256,
|
||||
"files": 37,
|
||||
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/dispatch/IntegrationCard.tsx, src/features/dispatch/ProjectDispatch.tsx, src/features/git-providers/GitProviderSelector.tsx, src/features/organizations/components/InvitationTable.tsx … (+32 more)",
|
||||
"example": "src/features/artifacts/ArtifactViewer.tsx:387"
|
||||
},
|
||||
{
|
||||
"signature": "VStack(gap:s)[Heading(level:s)[#t1];Text(color:s,type:s)[#t1]]",
|
||||
"count": 218,
|
||||
"files": 28,
|
||||
"filesList": "src/features/auth/pages/ProfileSettingsPage.tsx, src/features/dispatch/ProjectDispatch.tsx, src/features/organizations/containers/OrganizationSettingsContainer.tsx, src/pages/AgentDetails.tsx, src/pages/AgentPluginDetails.tsx … (+23 more)",
|
||||
"example": "src/features/auth/pages/ProfileSettingsPage.tsx:93"
|
||||
},
|
||||
{
|
||||
"signature": "Text(type:s)[#t1]",
|
||||
"count": 182,
|
||||
"files": 25,
|
||||
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/auth/containers/UserProfileMenu.tsx, src/features/dispatch/IntegrationCard.tsx, src/features/organizations/pages/OrganizationCreatePage.tsx, src/features/playbooks/PlaybookFlow.tsx … (+20 more)",
|
||||
"example": "src/features/artifacts/ArtifactViewer.tsx:267"
|
||||
},
|
||||
{
|
||||
"signature": "Icon(icon:s,size:s)[]",
|
||||
"count": 170,
|
||||
"files": 47,
|
||||
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/auth/containers/UserProfileMenu.tsx, src/features/auth/pages/LoginPage.tsx, src/features/auth/pages/SignupPage.tsx, src/features/connectors/EnvironmentLabels.tsx … (+42 more)",
|
||||
"example": "src/features/artifacts/ArtifactViewer.tsx:244"
|
||||
},
|
||||
{
|
||||
"signature": "Button(label:s,onClick:s,variant:s)[]",
|
||||
"count": 149,
|
||||
"files": 28,
|
||||
"filesList": "src/features/conversations/WorkspaceToolsPanel.tsx, src/features/dispatch/ProjectDispatch.tsx, src/features/organizations/containers/InvitationContainer.tsx, src/features/organizations/containers/OrganizationInvitationsContainer.tsx, src/features/organizations/containers/OrganizationPeopleContainer.tsx … (+23 more)",
|
||||
"example": "src/features/conversations/WorkspaceToolsPanel.tsx:224"
|
||||
},
|
||||
{
|
||||
"signature": "VStack(gap:s)[Text(color:s,type:s)[#t1];Heading(level:s)[#t1];Text(color:s,type:s)[#t1]]",
|
||||
"count": 136,
|
||||
"files": 13,
|
||||
"filesList": "src/pages/AgentPluginDetails.tsx, src/pages/AgentPlugins.tsx, src/pages/ConnectorDetails.tsx, src/pages/Connectors.tsx, src/pages/GitProviderDetails.tsx … (+8 more)",
|
||||
"example": "src/pages/AgentPluginDetails.tsx:235"
|
||||
}
|
||||
],
|
||||
"total": 220
|
||||
},
|
||||
{
|
||||
"title": "Known hand-rolled idioms — extraction candidates",
|
||||
"rows": [
|
||||
{
|
||||
"idiom": "Label+description pair (VStack gap=0: Text + supporting Text)",
|
||||
"key": "labelPair",
|
||||
"count": 64,
|
||||
"files": 18,
|
||||
"examples": [
|
||||
"src/features/artifacts/ArtifactViewer.tsx:174",
|
||||
"src/features/auth/containers/UserProfileMenu.tsx:88",
|
||||
"src/features/auth/pages/ProfileSettingsPage.tsx:108"
|
||||
]
|
||||
},
|
||||
{
|
||||
"idiom": "Section header (VStack gap=0: Heading + supporting Text)",
|
||||
"key": "sectionHeader",
|
||||
"count": 163,
|
||||
"files": 25,
|
||||
"examples": [
|
||||
"src/features/organizations/containers/InvitationContainer.tsx:184",
|
||||
"src/features/playbooks/PlaybookDiagram.tsx:105",
|
||||
"src/features/playbooks/PlaybookFlow.tsx:35"
|
||||
]
|
||||
},
|
||||
{
|
||||
"idiom": "Stat card (Card padding>=3 > VStack gap=1: 2 supporting Texts + Heading)",
|
||||
"key": "statCard",
|
||||
"count": 138,
|
||||
"files": 13,
|
||||
"examples": [
|
||||
"src/pages/AgentPluginDetails.tsx:234",
|
||||
"src/pages/AgentPlugins.tsx:240",
|
||||
"src/pages/ConnectorDetails.tsx:175"
|
||||
]
|
||||
},
|
||||
{
|
||||
"idiom": "Icon + text row (HStack vAlign=center: Icon + VStack)",
|
||||
"key": "iconTextRow",
|
||||
"count": 54,
|
||||
"files": 14,
|
||||
"examples": [
|
||||
"src/pages/AgentPluginDetails.tsx:123",
|
||||
"src/pages/AgentPlugins.tsx:124",
|
||||
"src/pages/GitProviderDetails.tsx:111"
|
||||
]
|
||||
},
|
||||
{
|
||||
"idiom": "Form action row (HStack gap=2: 2+ Buttons)",
|
||||
"key": "formActions",
|
||||
"count": 43,
|
||||
"files": 13,
|
||||
"examples": [
|
||||
"src/features/artifacts/ArtifactViewer.tsx:216",
|
||||
"src/pages/AgentDetails.tsx:806",
|
||||
"src/pages/Agents.tsx:783"
|
||||
]
|
||||
},
|
||||
{
|
||||
"idiom": "Wizard progress (HStack wrap=wrap of Badge+Text steps)",
|
||||
"key": "wizardProgress",
|
||||
"count": 0,
|
||||
"files": 0,
|
||||
"examples": []
|
||||
}
|
||||
],
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"title": "Most similar page pairs (Jaccard over structure fingerprints)",
|
||||
"rows": [
|
||||
{
|
||||
"pair": "src/pages/GitProviders.tsx ↔ src/pages/Integrations.tsx",
|
||||
"jaccard": "0.76",
|
||||
"shared": 22
|
||||
},
|
||||
{
|
||||
"pair": "src/pages/NewIntegration.tsx ↔ src/pages/NewIssueProvider.tsx",
|
||||
"jaccard": "0.58",
|
||||
"shared": 38
|
||||
},
|
||||
{
|
||||
"pair": "src/pages/NewGitProvider.tsx ↔ src/pages/NewIssueProvider.tsx",
|
||||
"jaccard": "0.57",
|
||||
"shared": 37
|
||||
},
|
||||
{
|
||||
"pair": "src/pages/GitProviderDetails.tsx ↔ src/pages/IntegrationDetails.tsx",
|
||||
"jaccard": "0.54",
|
||||
"shared": 40
|
||||
},
|
||||
{
|
||||
"pair": "src/pages/NewGitProvider.tsx ↔ src/pages/NewIntegration.tsx",
|
||||
"jaccard": "0.54",
|
||||
"shared": 36
|
||||
},
|
||||
{
|
||||
"pair": "src/pages/Integrations.tsx ↔ src/pages/IssueProviders.tsx",
|
||||
"jaccard": "0.47",
|
||||
"shared": 18
|
||||
},
|
||||
{
|
||||
"pair": "src/pages/Integrations.tsx ↔ src/pages/Playbooks.tsx",
|
||||
"jaccard": "0.47",
|
||||
"shared": 18
|
||||
},
|
||||
{
|
||||
"pair": "src/pages/GitProviders.tsx ↔ src/pages/IssueProviders.tsx",
|
||||
"jaccard": "0.46",
|
||||
"shared": 18
|
||||
},
|
||||
{
|
||||
"pair": "src/features/organizations/containers/OrganizationInvitationsContainer.tsx ↔ src/features/organizations/containers/OrganizationPeopleContainer.tsx",
|
||||
"jaccard": "0.43",
|
||||
"shared": 12
|
||||
},
|
||||
{
|
||||
"pair": "src/pages/GitProviders.tsx ↔ src/pages/Playbooks.tsx",
|
||||
"jaccard": "0.42",
|
||||
"shared": 17
|
||||
}
|
||||
],
|
||||
"total": 22
|
||||
},
|
||||
{
|
||||
"title": "Most co-imported @astryxdesign/core modules (signals composite components to extract)",
|
||||
"rows": [
|
||||
{
|
||||
"modules": "/Layout|@astryxdesign/core/Text",
|
||||
"count": 46,
|
||||
"files": 46
|
||||
},
|
||||
{
|
||||
"modules": "/Icon|@astryxdesign/core/Layout",
|
||||
"count": 45,
|
||||
"files": 45
|
||||
},
|
||||
{
|
||||
"modules": "/Button|@astryxdesign/core/Layout",
|
||||
"count": 43,
|
||||
"files": 43
|
||||
},
|
||||
{
|
||||
"modules": "/Icon|@astryxdesign/core/Text",
|
||||
"count": 42,
|
||||
"files": 42
|
||||
},
|
||||
{
|
||||
"modules": "/Button|@astryxdesign/core/Text",
|
||||
"count": 39,
|
||||
"files": 39
|
||||
},
|
||||
{
|
||||
"modules": "/Button|@astryxdesign/core/Icon",
|
||||
"count": 38,
|
||||
"files": 38
|
||||
},
|
||||
{
|
||||
"modules": "/Badge|@astryxdesign/core/Icon",
|
||||
"count": 33,
|
||||
"files": 33
|
||||
},
|
||||
{
|
||||
"modules": "/Badge|@astryxdesign/core/Layout",
|
||||
"count": 33,
|
||||
"files": 33
|
||||
},
|
||||
{
|
||||
"modules": "/Badge|@astryxdesign/core/Text",
|
||||
"count": 32,
|
||||
"files": 32
|
||||
},
|
||||
{
|
||||
"modules": "/Card|@astryxdesign/core/Layout",
|
||||
"count": 30,
|
||||
"files": 30
|
||||
}
|
||||
],
|
||||
"total": 211
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
# Component Extraction Report — Attendance web-app
|
||||
|
||||
Generated by `scripts/find-duplicates.mjs` (computed duplication scan) + 5 parallel
|
||||
code-reading agents (file-level evidence). Web app: React 19 + `@astryxdesign/core`
|
||||
design system + StyleX. Raw machine-readable data: `scripts/duplication-report.json`.
|
||||
|
||||
## Headline numbers
|
||||
|
||||
| Metric | Value |
|
||||
| ------------------------------------------- | ---------------------------------------- |
|
||||
| Files / JSX elements scanned | 120 / 10,219 |
|
||||
| `VStack` uses | 1,425 (incl. 464× `gap=1`, 360× `gap=0`) |
|
||||
| `HStack` uses | 853 (incl. 204× `gap=2 vAlign=center`) |
|
||||
| Files importing `@astryxdesign/core/Layout` | 68 |
|
||||
|
||||
Top file pairs by structural similarity (Jaccard):
|
||||
`GitProviders ↔ Integrations` **76%**, `NewIntegration ↔ NewIssueProvider` **58%**,
|
||||
`NewGitProvider ↔ NewIssueProvider` **57%**, `GitProviderDetails ↔ IntegrationDetails`
|
||||
**54%**, `NewGitProvider ↔ NewIntegration` **54%**.
|
||||
|
||||
## Tier 1 — byte-identical patterns, extract first
|
||||
|
||||
| Candidate | Occurrences | Evidence | API sketch |
|
||||
| -------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| **`StatCard` / `StatCardGrid`** | ~22 list + ~30 detail cards across 13 files | `GitProviders.tsx:176-185`, `LLMProviderDetails.tsx:274-283`, `AgentPlugins.tsx:240-249`, `Usage.tsx:612-675` (icon/trend variant) | `{ label, value, hint?, icon?, footnote? }` + `StatCardGrid({ cards, columns })` |
|
||||
| **`SectionHeader`** | 163 CLI hits / 25 files | `GitProviderDetails.tsx:205-210`, `IntegrationDetails.tsx:259-264`, `AgentDetails.tsx:423-428` | `{ title, description?, endAction?, level? }` |
|
||||
| **`WizardProgress`** | 3 byte-identical + 2 variants | `NewGitProvider.tsx:28-53`, `NewIssueProvider.tsx:47-72`, `NewIntegration.tsx:28-53` (only `STEP_LABELS` differs); sidebar variant `NewLLMProvider.tsx:134-158` | `{ steps: string[], current: number, variant? }` — slot into `<PageHero actions={…}>` |
|
||||
| **`StatusCell`** | 7 list + 3 detail + dispatch | `GitProviders.tsx:95-105`, `Integrations.tsx:90-105`, `ChatSessions.tsx:114-122` | `{ variant, label, isPulsing?, detail? }` (wraps `StatusDot` + `Text`) |
|
||||
| **`LeadingCell` / `EntityRow`** | ~9 list + 7 feature sites | `GitProviders.tsx:66-76`, `PlaybookDiagram.tsx:102-112`, `ArtifactViewer.tsx:166-187` | `{ leading?, title, subtitle?, meta?/trailing? }` |
|
||||
| **`FormFooter` / `FormActions`** | 7 instances / 6 files | `GitProviderDetails.tsx:296-308`, `LLMProviderDetails.tsx:425-433`, `AgentDetails.tsx:802-815` (Reset/dirty variant) | `{ saved?, primaryLabel, onPrimary, secondaryLabel?, onSecondary?, isDirty? }` |
|
||||
| **`BackButton`** | 12+ sites | `NewGitProvider.tsx:104-109`, `GitProviderDetails.tsx:59-64`, `NewPlaybook.tsx:757-764` | `{ label, href, iconOnly? }` (ghost + `IconArrowLeft` + navigate) |
|
||||
|
||||
## Tier 2 — near-identical composites (bigger wins)
|
||||
|
||||
## Tier 2 — near-identical composites (bigger wins)
|
||||
|
||||
> ⚠️ The codebase is being actively rewritten while this report was generated (e.g. `DetailHero` below landed mid-analysis). Line references are as-of-scan-time and may be slightly stale.
|
||||
|
||||
| Candidate | Occurrences | Evidence |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **`DetailHero`** — ✅ **already exists** in `src/layouts/DetailHero.tsx`, already adopted by 8 detail pages (`AgentDetails`, `AgentPluginDetails`, `ConnectorDetails`, `GitProviderDetails`, `IntegrationDetails`, `IssueProviderDetails`, `LLMProviderDetails`, `PlaybookDetails`). **Remaining adopters:** `TaskDetails` (LayoutHeader variant), `DispatchIntegrations`, `Projects` (ProjectDetailsPage), `ArtifactViewer` | ~11 total | API `{ breadcrumbs?, leading?, status?, title, metadata?, description?, actions? }` — exactly the shape recommended by the infra analysis; replaces the hand-rolled hero block |
|
||||
| **`WizardPageShell`** | 3 full + 2 partial | `NewGitProvider.tsx:101-350`, `NewIssueProvider.tsx:139-393`, `NewIntegration.tsx:92-320` — back button + hero + step Card + footer |
|
||||
| **`ProviderPicker`** | 5 | `NewGitProvider.tsx:130-155`, `NewLLMProvider.tsx:173-191` — `SelectableCard`/`ClickableCard` grid over `Object.keys(definitions)` |
|
||||
| **`ReviewSummary` + `SummaryGrid`** | 3 | `NewGitProvider.tsx:272-319`, `NewIntegration.tsx:241-288` — muted Section + icon/name + label-value grid |
|
||||
| **`TableSection`** | 11 | `GitProviders.tsx:211-230`, `Usage.tsx:828-851` — section header + `Card padding={0}` + `Table` (fixed 7-prop set `density dividers verticalAlign textOverflow hasHover`) |
|
||||
| **`FilterToolbar`** | 6 | `Playbooks.tsx:196-221`, `Projects.tsx:415-450`, `ChatSessions.tsx:190-204` — hidden-label search + `SegmentedControl`/`Selector` + count |
|
||||
| **`DeleteResourceDialog` + `DangerZone`** | 3 detail + 4 org containers | `GitProviderDetails.tsx:314-347`, `OrganizationSettingsContainer.tsx:218-240` |
|
||||
| **`EmptyStateCard`** | 5 | `ProjectDispatch.tsx:79-92`, `OrganizationPeopleContainer.tsx:186-200` |
|
||||
| **`IconActionMenu`** | 4 | tasks + org tables + conversations (`DropdownMenu` ghost icon button) |
|
||||
| **`ValidatedField`** (auth) | ~15 fields / 5 forms | `LoginPage.tsx:116-171`, `SignupPage.tsx:164-355`, `ProfileSettingsPage.tsx:130-223`, `OrganizationForm`, `InviteMemberDialog` |
|
||||
|
||||
## Tier 3 — hooks & data/logic extraction
|
||||
|
||||
| Candidate | Occurrences | Evidence |
|
||||
| ---------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **`useRouteSection`** | 7 | `AgentDetails.tsx:820-830`, `GitProviderDetails.tsx:41-51`, `LLMProviderDetails.tsx:225-228` — normalizes `:section` param + `goToSection` (also fixes the `overview → ""` suffix inconsistency) |
|
||||
| **`useSeededList`** | 4 | `GitProviders.tsx:30-33`, `Integrations.tsx:28-33` — `location.state?.createdX` seeding |
|
||||
| **`useFilteredList`** | 6 | AgentPlugins, Playbooks, Projects, Agents, ChatSessions, Tasks |
|
||||
| **`useDetailEntity`** | 5 | `GitProviderDetails.tsx:46`, `IntegrationDetails.tsx:91` — state-entity-or-lookup + patch + didSave |
|
||||
| **`createKindRegistry` / `createStatusMap` / `createSeedStore`** | 8 data modules | `git-provider-data.ts:17-50`, `connector-data.ts:79-89`, `dispatch-data.ts:195-241` — collapse the per-feature `Record<Kind, Definition>` + `getX(id)` + label/variant map trio; UI status maps (`TaskKanbanBoard.tsx:93-114`, `ArtifactViewer.tsx:64-69`) fold in |
|
||||
| **Shared contracts package (`packages/`)** | web-app ↔ worker | `auth-client.ts` vs `auth-options.ts`: user fields, password min 8, username 3-30 regex, auth base URL, `APP_NAME`, invitation roles duplicated across apps — `pnpm-workspace.yaml` currently only includes `apps/*` |
|
||||
|
||||
## Tier 4 — consistency fixes to fold into the rewrite
|
||||
|
||||
- **`PageHero` bypasses** (inconsistent hero styling: `gap={1}` + plain `Heading level={1}` vs `PageHero`'s `gap={2}` + `display-2`): `Agents.tsx:582-595`, `LLMProviders.tsx:28-41`, `NewLLMProvider.tsx:111-121`, `OrganizationPeopleContainer.tsx:138-166`.
|
||||
- **`HeroTabs` bypass**: `Projects.tsx:1497-1532` re-implements it inline with raw `Toolbar`+`TabList` — drop-in swap.
|
||||
- **`FormLayout` unused**: the design system ships it, but every form hand-rolls field sets in `VStack`/`Grid` — worth centralizing with `ValidatedField`.
|
||||
- Feature-local duplicates (only extract within the feature): org `Skeleton` blocks, org confirm `AlertDialog` flows (`useConfirmAction`), playbooks `conditionLabels`/`edgeColors` maps (`PlaybookDiagram.tsx:74-86` vs `PlaybookFlow.tsx:10-22`), `WorkspaceToolsPanel`'s three terminal/browser/vnc panels.
|
||||
|
||||
## Deliberately NOT worth extracting
|
||||
|
||||
- `LLMProviders` item-list (deliberate non-table presentation), `TasksView` (already shared), Usage charts (analytics-specific), `NewPlaybook` diagram inspector (single consumer), kanban DnD engine and dagre/ReactFlow layouts (single-use), per-feature info `Banner` copy, `goToSection` standalone (absorbed by hook), per-feature status label _maps_ (keep local; extract only the pill rendering).
|
||||
|
||||
## Suggested landing order (if the team proceeds)
|
||||
|
||||
1. Create `apps/web-app/src/components/` (currently none exists — everything lives in `features/`).
|
||||
2. Land `TitleBlock`/`SectionHeader` + `EntityRow` first (together ~24 call sites across 8 features).
|
||||
3. Land `StatCard`/`StatCardGrid`, `StatusCell`, `FormFooter`, `BackButton`.
|
||||
4. Then composites: `useRouteSection` (routing hook for the 7 tabbed detail pages — `DetailHero` is done, the routing layer is the remaining shared piece), `WizardPageShell` + `WizardProgress` + `ProviderPicker`, `TableSection` + `FilterToolbar`.
|
||||
5. Then data layer: kind-registry/status-map factories, `useSeededList`/`useFilteredList`, and the `packages/` contracts member for web-app ↔ worker auth constants.
|
||||
|
||||
## Rerunning the scanner
|
||||
|
||||
```sh
|
||||
cd apps/web-app
|
||||
node scripts/find-duplicates.mjs # terminal report
|
||||
node scripts/find-duplicates.mjs --json # machine-readable
|
||||
node scripts/find-duplicates.mjs --limit 20 --min-count 5
|
||||
```
|
||||
@@ -0,0 +1,524 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* find-duplicates — component duplication scanner for the web-app source.
|
||||
*
|
||||
* Parses every TSX file under `src` with the TypeScript compiler API and
|
||||
* computes four things:
|
||||
*
|
||||
* 1. Layout idiom census — exact VStack/HStack/Card/Section/Grid prop
|
||||
* configurations repeated across the codebase (e.g. `VStack(gap=0)`).
|
||||
* 2. Structural fingerprints — normalized JSX subtree shapes (text and
|
||||
* non-layout prop values stripped) that recur in ≥ N places. These are
|
||||
* the raw material for extracting shared components.
|
||||
* 3. Known idioms — hand-tuned recognizers for patterns that keep showing
|
||||
* up by hand: label+description pairs, stat cards, section headers,
|
||||
* icon+text rows, form action footers, wizard progress steps.
|
||||
* 4. File similarity — pairwise Jaccard over each file's structural
|
||||
* fingerprints, surfacing pages that are near-copies of each other.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/find-duplicates.mjs # run from apps/web-app
|
||||
* node scripts/find-duplicates.mjs --root src --min-count 3
|
||||
* node scripts/find-duplicates.mjs --json > report.json
|
||||
* node scripts/find-duplicates.mjs --limit 8 # rows per section
|
||||
*
|
||||
* Zero runtime dependencies: only `typescript` (already a devDependency).
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const ts = require("typescript");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI args
|
||||
// ---------------------------------------------------------------------------
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name, fallback) => {
|
||||
const i = args.indexOf(name);
|
||||
return i === -1 ? fallback : args[i + 1];
|
||||
};
|
||||
const has = (name) => args.includes(name);
|
||||
|
||||
const ROOT = flag("--root", "src");
|
||||
const MIN_COUNT = Number(flag("--min-count", "3"));
|
||||
const LIMIT = Number(flag("--limit", "10"));
|
||||
const AS_JSON = has("--json");
|
||||
|
||||
const LAYOUT_TAGS = new Set([
|
||||
"VStack",
|
||||
"HStack",
|
||||
"StackItem",
|
||||
"Section",
|
||||
"Card",
|
||||
"Grid",
|
||||
"Center",
|
||||
"Layout",
|
||||
"LayoutHeader",
|
||||
"LayoutContent",
|
||||
"LayoutFooter",
|
||||
"LayoutPanel",
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source collection
|
||||
// ---------------------------------------------------------------------------
|
||||
const SRC_DIR = resolve(process.cwd(), ROOT);
|
||||
if (!statSync(SRC_DIR, { throwIfNoParent: false })?.isDirectory?.()) {
|
||||
console.error(`Cannot find source directory: ${SRC_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Collect all .tsx files under a directory, skipping build/scratch dirs. */
|
||||
function collectTsx(dir, out = []) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (entry === "node_modules" || entry === "dist" || entry === ".astryx-tmp") continue;
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) collectTsx(full, out);
|
||||
else if (full.endsWith(".tsx") || full.endsWith(".ts")) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const files = collectTsx(SRC_DIR).sort();
|
||||
const parsed = [];
|
||||
|
||||
for (const file of files) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
parsed.push({ file, text, sf });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AST helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Normalized attributes of a JSX element: [{key, value}] with literals resolved. */
|
||||
function attrsOf(node) {
|
||||
const out = [];
|
||||
for (const prop of node.attributes.properties) {
|
||||
if (!ts.isJsxAttribute(prop)) {
|
||||
out.push({ key: "...", value: "..." }); // spread
|
||||
continue;
|
||||
}
|
||||
const key = prop.name.getText();
|
||||
const init = prop.initializer;
|
||||
if (!init) {
|
||||
out.push({ key, value: "true" }); // boolean shorthand
|
||||
} else if (ts.isStringLiteral(init)) {
|
||||
out.push({ key, value: init.text });
|
||||
} else if (ts.isJsxExpression(init)) {
|
||||
const e = init.expression;
|
||||
if (!e) out.push({ key, value: "expr" });
|
||||
else if (ts.isStringLiteral(e) || ts.isNumericLiteral(e)) out.push({ key, value: e.text });
|
||||
else if (e.kind === ts.SyntaxKind.TrueKeyword) out.push({ key, value: "true" });
|
||||
else if (e.kind === ts.SyntaxKind.FalseKeyword) out.push({ key, value: "false" });
|
||||
else out.push({ key, value: "expr" });
|
||||
} else {
|
||||
out.push({ key, value: "?" });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a TSX subtree into element nodes:
|
||||
* { tag, attrs, elemChildren, textCount, line, parent }
|
||||
*/
|
||||
function flattenJsx(node, sf, parent = null, out = []) {
|
||||
if (ts.isJsxElement(node)) {
|
||||
const el = {
|
||||
tag: node.openingElement.tagName.getText(),
|
||||
attrs: attrsOf(node.openingElement),
|
||||
elemChildren: [],
|
||||
textCount: 0,
|
||||
line: node.getStart(sf).then ? 0 : sf.getLineAndCharacterOfPosition(node.getStart()).line + 1,
|
||||
parent,
|
||||
};
|
||||
for (const child of node.children) {
|
||||
if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) {
|
||||
el.elemChildren.push(flattenJsx(child, sf, el, out));
|
||||
} else if (ts.isJsxText(child) && child.getText().trim().length > 0) {
|
||||
el.textCount += 1;
|
||||
} else if (ts.isJsxExpression(child)) {
|
||||
el.textCount += 1; // {expression} counts as dynamic text
|
||||
}
|
||||
}
|
||||
out.push(el);
|
||||
return el;
|
||||
}
|
||||
if (ts.isJsxSelfClosingElement(node)) {
|
||||
const el = {
|
||||
tag: node.tagName.getText(),
|
||||
attrs: attrsOf(node),
|
||||
elemChildren: [],
|
||||
textCount: 0,
|
||||
line: sf.getLineAndCharacterOfPosition(node.getStart()).line + 1,
|
||||
parent,
|
||||
};
|
||||
out.push(el);
|
||||
return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const p of parsed) {
|
||||
p.elements = [];
|
||||
const visit = (node) => {
|
||||
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node))
|
||||
flattenJsx(node, p.sf, null, p.elements);
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(p.sf);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Layout idiom census — exact prop configs for layout primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
const layoutCounts = new Map(); // sig -> {count, files:Set, examples:[{file,line}]}
|
||||
for (const p of parsed) {
|
||||
for (const el of p.elements) {
|
||||
if (!LAYOUT_TAGS.has(el.tag)) continue;
|
||||
const props = el.attrs
|
||||
.filter((a) => a.key !== "...")
|
||||
.map((a) => `${a.key}=${a.value}`)
|
||||
.sort()
|
||||
.join(",");
|
||||
const sig = `${el.tag}(${props})`;
|
||||
const entry = layoutCounts.get(sig) ?? { count: 0, files: new Set(), examples: [] };
|
||||
entry.count += 1;
|
||||
entry.files.add(p.file);
|
||||
if (entry.examples.length < 3) entry.examples.push({ file: p.file, line: el.line });
|
||||
layoutCounts.set(sig, entry);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Structural fingerprints — shape only, text/prop values stripped
|
||||
// ---------------------------------------------------------------------------
|
||||
const MAX_DEPTH = 4;
|
||||
|
||||
function shapeSig(el, depth) {
|
||||
const attrs = el.attrs
|
||||
.map((a) => `${a.key}:${typeof a.value === "string" ? "s" : a.value}`)
|
||||
.sort()
|
||||
.join(",");
|
||||
const kids = depth > 0 ? el.elemChildren.map((c) => shapeSig(c, depth - 1)).join(";") : "";
|
||||
const text = el.textCount > 0 ? `#t${el.textCount}` : "";
|
||||
return `${el.tag}(${attrs})[${kids}${text}]`;
|
||||
}
|
||||
|
||||
const shapeCounts = new Map(); // sig -> {count, files:Set, examples:[{file,line}]}
|
||||
for (const p of parsed) {
|
||||
for (const el of p.elements) {
|
||||
const sig = shapeSig(el, MAX_DEPTH);
|
||||
const entry = shapeCounts.get(sig) ?? { count: 0, files: new Set(), examples: [] };
|
||||
entry.count += 1;
|
||||
entry.files.add(p.file);
|
||||
if (entry.examples.length < 3) entry.examples.push({ file: p.file, line: el.line });
|
||||
shapeCounts.set(sig, entry);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Known idioms
|
||||
// ---------------------------------------------------------------------------
|
||||
const getAttr = (el, key) => el.attrs.find((a) => a.key === key)?.value;
|
||||
|
||||
/** Text element with supporting typography + secondary color (the "subtitle"). */
|
||||
const isSupporting = (el) =>
|
||||
el.tag === "Text" &&
|
||||
(getAttr(el, "type") === "supporting" || getAttr(el, "type") === undefined) &&
|
||||
(getAttr(el, "color") === "secondary" || getAttr(el, "color") === undefined);
|
||||
|
||||
function detectIdioms() {
|
||||
const results = {
|
||||
labelPair: [], // VStack gap=0: [Text(label), Text(supporting/secondary)]
|
||||
sectionHeader: [], // VStack gap=0: [Heading, Text(supporting/secondary)]
|
||||
statCard: [], // Card padding>=3: VStack gap=1: [supporting, Heading, supporting]
|
||||
iconTextRow: [], // HStack gap 1-3 vAlign=center: [Icon, VStack]
|
||||
formActions: [], // HStack gap=2: [Button, Button, ...]
|
||||
wizardProgress: [], // HStack wrap=wrap: several [Badge, Text] HStacks
|
||||
};
|
||||
const push = (key, p, el, label) =>
|
||||
results[key].push({ file: p.file, line: el.line, detail: label });
|
||||
|
||||
for (const p of parsed) {
|
||||
for (const el of p.elements) {
|
||||
const kids = el.elemChildren;
|
||||
|
||||
// labelPair / sectionHeader
|
||||
if (el.tag === "VStack" && getAttr(el, "gap") === "0" && kids.length === 2) {
|
||||
const [a, b] = kids;
|
||||
if (isSupporting(b) && a.tag === "Text") push("labelPair", p, el, "label+description");
|
||||
if (isSupporting(b) && a.tag === "Heading")
|
||||
push("sectionHeader", p, el, "heading+description");
|
||||
}
|
||||
|
||||
// statCard
|
||||
if (el.tag === "Card" && kids.length === 1) {
|
||||
const inner = kids[0];
|
||||
const pad = Number(getAttr(el, "padding") ?? "0");
|
||||
if (inner.tag === "VStack" && getAttr(inner, "gap") === "1" && pad >= 3) {
|
||||
const [t1, h, t2] = inner.elemChildren;
|
||||
if (
|
||||
t1?.tag === "Text" &&
|
||||
isSupporting(t1) &&
|
||||
h?.tag === "Heading" &&
|
||||
t2?.tag === "Text" &&
|
||||
isSupporting(t2)
|
||||
) {
|
||||
push("statCard", p, el, `Card padding=${pad} + VStack gap=1`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// iconTextRow
|
||||
if (el.tag === "HStack" && getAttr(el, "vAlign") === "center") {
|
||||
const gap = Number(getAttr(el, "gap") ?? "0");
|
||||
if (gap >= 1 && gap <= 3 && kids[0]?.tag === "Icon" && kids[1]?.tag === "VStack") {
|
||||
push("iconTextRow", p, el, `Icon + VStack, gap=${gap}`);
|
||||
}
|
||||
}
|
||||
|
||||
// formActions
|
||||
if (
|
||||
el.tag === "HStack" &&
|
||||
getAttr(el, "gap") === "2" &&
|
||||
kids.length >= 2 &&
|
||||
kids.every((k) => k.tag === "Button")
|
||||
) {
|
||||
push("formActions", p, el, `${kids.length} buttons`);
|
||||
}
|
||||
|
||||
// wizardProgress
|
||||
if (el.tag === "HStack" && getAttr(el, "wrap") === "wrap" && kids.length >= 2) {
|
||||
const stepy = kids.every(
|
||||
(k) =>
|
||||
k.tag === "HStack" &&
|
||||
k.elemChildren.some((c) => c.tag === "Badge") &&
|
||||
k.elemChildren.some((c) => c.tag === "Text"),
|
||||
);
|
||||
if (stepy) push("wizardProgress", p, el, `${kids.length} steps`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const idioms = detectIdioms();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. File similarity — Jaccard over shape fingerprints (depth 2..3)
|
||||
// ---------------------------------------------------------------------------
|
||||
function fileSigSet(p) {
|
||||
const set = new Set();
|
||||
for (const el of p.elements) {
|
||||
set.add(shapeSig(el, 3));
|
||||
}
|
||||
return set;
|
||||
}
|
||||
const sigSets = new Map(parsed.map((p) => [p.file, fileSigSet(p)]));
|
||||
|
||||
const pairs = [];
|
||||
const fileList = [...sigSets.keys()];
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
const a = sigSets.get(fileList[i]);
|
||||
for (let j = i + 1; j < fileList.length; j++) {
|
||||
const b = sigSets.get(fileList[j]);
|
||||
if (a.size < 15 || b.size < 15) continue;
|
||||
let inter = 0;
|
||||
for (const s of a) if (b.has(s)) inter += 1;
|
||||
const union = a.size + b.size - inter;
|
||||
const jac = inter / union;
|
||||
if (jac >= 0.3 && inter >= 8) {
|
||||
pairs.push({
|
||||
a: fileList[i],
|
||||
b: fileList[j],
|
||||
jaccard: jac,
|
||||
shared: inter,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
pairs.sort((x, y) => y.jaccard - x.jaccard || y.shared - x.shared);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Import cluster census
|
||||
// ---------------------------------------------------------------------------
|
||||
const importModuleCount = new Map(); // module -> files
|
||||
const coImportCount = new Map(); // "mod1|mod2" -> files
|
||||
for (const p of parsed) {
|
||||
const mods = new Set();
|
||||
const visit = (node) => {
|
||||
if (
|
||||
ts.isImportDeclaration(node) &&
|
||||
node.moduleSpecifier &&
|
||||
ts.isStringLiteral(node.moduleSpecifier)
|
||||
) {
|
||||
const mod = node.moduleSpecifier.text;
|
||||
if (mod.startsWith("@astryxdesign/core")) mods.add(mod);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(p.sf);
|
||||
const list = [...mods].sort();
|
||||
for (const m of list) {
|
||||
const e = importModuleCount.get(m) ?? { count: 0, files: new Set() };
|
||||
e.count += 1;
|
||||
e.files.add(p.file);
|
||||
importModuleCount.set(m, e);
|
||||
}
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
for (let j = i + 1; j < list.length; j++) {
|
||||
const key = `${list[i]}|${list[j]}`;
|
||||
const e = coImportCount.get(key) ?? { count: 0, files: new Set() };
|
||||
e.count += 1;
|
||||
e.files.add(p.file);
|
||||
coImportCount.set(key, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reporting
|
||||
// ---------------------------------------------------------------------------
|
||||
const rel = (f) => relative(process.cwd(), f);
|
||||
const fmtList = (s) => {
|
||||
const arr = [...s].map(rel).sort();
|
||||
if (arr.length <= 5) return arr.join(", ");
|
||||
return `${arr.slice(0, 5).join(", ")} … (+${arr.length - 5} more)`;
|
||||
};
|
||||
|
||||
const sections = [];
|
||||
|
||||
// --- layout census ---
|
||||
const layoutRows = [...layoutCounts.entries()]
|
||||
.filter(([, e]) => e.count >= MIN_COUNT && e.files.size >= 2)
|
||||
.sort((a, b) => b[1].count - a[1].count);
|
||||
sections.push({
|
||||
title: `Layout idiom census — exact VStack/HStack/Card/Grid/Section configs (≥ ${MIN_COUNT} uses, ≥ 2 files)`,
|
||||
rows: layoutRows.slice(0, LIMIT).map(([sig, e]) => ({
|
||||
signature: sig,
|
||||
count: e.count,
|
||||
files: e.files.size,
|
||||
filesList: fmtList(e.files),
|
||||
example: `${rel(e.examples[0].file)}:${e.examples[0].line}`,
|
||||
})),
|
||||
total: layoutRows.length,
|
||||
});
|
||||
|
||||
// --- shape fingerprints ---
|
||||
const shapeRows = [...shapeCounts.entries()]
|
||||
.filter(([, e]) => e.count >= MIN_COUNT && e.files.size >= 2)
|
||||
.sort((a, b) => b[1].count - a[1].count);
|
||||
sections.push({
|
||||
title: `Repeated JSX structure fingerprints (≥ ${MIN_COUNT} occurrences, ≥ 2 files)`,
|
||||
rows: shapeRows.slice(0, LIMIT).map(([sig, e]) => ({
|
||||
signature: sig.length > 220 ? `${sig.slice(0, 220)}…` : sig,
|
||||
count: e.count,
|
||||
files: e.files.size,
|
||||
filesList: fmtList(e.files),
|
||||
example: `${rel(e.examples[0].file)}:${e.examples[0].line}`,
|
||||
})),
|
||||
total: shapeRows.length,
|
||||
});
|
||||
|
||||
// --- idioms ---
|
||||
const idiomNames = {
|
||||
labelPair: "Label+description pair (VStack gap=0: Text + supporting Text)",
|
||||
sectionHeader: "Section header (VStack gap=0: Heading + supporting Text)",
|
||||
statCard: "Stat card (Card padding>=3 > VStack gap=1: 2 supporting Texts + Heading)",
|
||||
iconTextRow: "Icon + text row (HStack vAlign=center: Icon + VStack)",
|
||||
formActions: "Form action row (HStack gap=2: 2+ Buttons)",
|
||||
wizardProgress: "Wizard progress (HStack wrap=wrap of Badge+Text steps)",
|
||||
};
|
||||
const idiomRows = Object.entries(idioms).map(([key, hits]) => {
|
||||
const byFile = new Map();
|
||||
for (const h of hits) {
|
||||
if (!byFile.has(h.file)) byFile.set(h.file, h.line);
|
||||
if (byFile.size >= 3) break;
|
||||
}
|
||||
return {
|
||||
idiom: idiomNames[key],
|
||||
key,
|
||||
count: hits.length,
|
||||
files: new Set(hits.map((h) => h.file)).size,
|
||||
examples: [...byFile.entries()].map(([f, l]) => `${rel(f)}:${l}`),
|
||||
};
|
||||
});
|
||||
sections.push({
|
||||
title: "Known hand-rolled idioms — extraction candidates",
|
||||
rows: idiomRows,
|
||||
total: idiomRows.length,
|
||||
});
|
||||
|
||||
// --- file similarity ---
|
||||
sections.push({
|
||||
title: "Most similar page pairs (Jaccard over structure fingerprints)",
|
||||
rows: pairs.slice(0, LIMIT).map((p) => ({
|
||||
pair: `${rel(p.a)} ↔ ${rel(p.b)}`,
|
||||
jaccard: p.jaccard.toFixed(2),
|
||||
shared: p.shared,
|
||||
})),
|
||||
total: pairs.length,
|
||||
});
|
||||
|
||||
// --- import clusters ---
|
||||
const coRows = [...coImportCount.entries()]
|
||||
.filter(([, e]) => e.count >= 5)
|
||||
.sort((a, b) => b[1].count - a[1].count);
|
||||
sections.push({
|
||||
title: "Most co-imported @astryxdesign/core modules (signals composite components to extract)",
|
||||
rows: coRows.slice(0, LIMIT).map(([key, e]) => ({
|
||||
modules: key.replace("@astryxdesign/core", ""),
|
||||
count: e.count,
|
||||
files: e.files.size,
|
||||
})),
|
||||
total: coRows.length,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output
|
||||
// ---------------------------------------------------------------------------
|
||||
const summary = {
|
||||
filesScanned: files.length,
|
||||
elementsParsed: parsed.reduce((n, p) => n + p.elements.length, 0),
|
||||
vstackUses: [...layoutCounts.entries()]
|
||||
.filter(([s]) => s.startsWith("VStack"))
|
||||
.reduce((n, [, e]) => n + e.count, 0),
|
||||
hstackUses: [...layoutCounts.entries()]
|
||||
.filter(([s]) => s.startsWith("HStack"))
|
||||
.reduce((n, [, e]) => n + e.count, 0),
|
||||
};
|
||||
|
||||
if (AS_JSON) {
|
||||
console.log(JSON.stringify({ summary, sections }, null, 2));
|
||||
} else {
|
||||
console.log(`\n=== Component duplication scanner ===`);
|
||||
console.log(
|
||||
`Scanned ${summary.filesScanned} files, ${summary.elementsParsed} JSX elements ` +
|
||||
`(${summary.vstackUses} VStack, ${summary.hstackUses} HStack uses).\n`,
|
||||
);
|
||||
for (const s of sections) {
|
||||
console.log(`\n## ${s.title} (${s.total} matches)\n`);
|
||||
for (const r of s.rows) {
|
||||
if (r.signature !== undefined) {
|
||||
console.log(` ${r.count.toString().padStart(4)}× ${r.signature}`);
|
||||
console.log(` in ${r.files} files · e.g. ${r.example}`);
|
||||
} else if (r.idiom !== undefined) {
|
||||
console.log(` ${r.count.toString().padStart(4)}× ${r.idiom}`);
|
||||
console.log(` in ${r.files} files · e.g. ${r.examples.join(", ")}`);
|
||||
} else if (r.pair !== undefined) {
|
||||
console.log(` ${(r.jaccard * 100).toString().padStart(2)}% ${r.pair}`);
|
||||
console.log(` ${r.shared} shared structure fingerprints`);
|
||||
} else if (r.modules !== undefined) {
|
||||
console.log(` ${r.count.toString().padStart(4)}× ${r.modules}`);
|
||||
console.log(` in ${r.files} files`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log("\n");
|
||||
}
|
||||
Reference in New Issue
Block a user