chore: add pnpm workspace configuration with package definitions and overrides

This commit is contained in:
snoozescript
2026-08-14 23:35:45 +05:30
parent ae2cb896a6
commit bcb302ac7d
389 changed files with 65206 additions and 0 deletions
@@ -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
}
]
}
+89
View File
@@ -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
```
+524
View File
@@ -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");
}