Files
suaveplan/openspec/specs/monorepo-structure/spec.md
2026-05-27 23:29:53 +01:00

165 lines
10 KiB
Markdown

# monorepo-structure
## Purpose
The genesis monorepo is a Turborepo + Bun workspaces tree organised by domain rather than tier. Every package lives at `packages/<category>/<pkg>/`, where `<category>` is a one-word domain folder listed in [`FOLDERS.md`](../../FOLDERS.md) and `<pkg>` is the npm name without the `@suaveplan/` prefix. Tier (universal / browser / react / r3f / server / python) is inferred per package from `package.json` (`"browser"` field, peer deps, `engines`), never encoded in the directory path. Packages land in dependency-order waves at version `0.1.0` until a `1.0.0` graduation release, file history is preserved across the migration via `git mv`, and the canonical view of plugins → packages → tier-overlay → dependency graph lives in a generated `MANIFEST.md` at the repository root that supersedes the legacy `@suaveplan/overview` package.
## Requirements
### Requirement: Domain-first folder layout
The monorepo SHALL organise packages into domain folders defined in `FOLDERS.md`. The layout is `packages/<category>/<package>/` where `<category>` is the folder name from `FOLDERS.md` and `<package>` is the npm name without the `@suaveplan/` prefix. Tier (universal / browser / react / r3f / server / python) is NOT a directory — it is determined per-package by `package.json` deps, the `"browser"` field, peer deps, and `engines`.
#### Scenario: Every package lives under a plugin folder
- **WHEN** any package directory is inspected
- **THEN** its path is `packages/<category>/<package>/` AND `<category>` is listed in `FOLDERS.md`
#### Scenario: No tier-named top-level folders
- **WHEN** the repository root is listed
- **THEN** there are no `universal/`, `browser/`, `react/`, `r3f/`, `api/`, `server/`, `finance/`, `mcp/`, `python/`, or `gaming/` directories
#### Scenario: Cross-tier domain co-location
- **WHEN** a domain has packages spanning multiple tiers (e.g. `drive` with universal core, browser P2P plugin, react UI, and api plugin)
- **THEN** all of those packages live under the same category folder (`packages/drive/`)
### Requirement: Category → package assignment uniqueness
Every package SHALL appear in exactly one category folder. No package may be present in two categories.
#### Scenario: Assignment manifest has unique entries
- **WHEN** `scripts/folder-reorg/assignment.yml` is parsed
- **THEN** the union of all packages across all categories contains no duplicates
### Requirement: Generated MANIFEST.md replaces @suaveplan/overview
The repository SHALL contain a generated `MANIFEST.md` at the root that lists every plugin, every package, the inferred tier per package, the dependency graph, and a tier-overlay view. The manifest is regenerated by `bun scripts/folder-reorg/generate-manifest.ts` and a `--check` mode validates that the committed file is up-to-date.
#### Scenario: MANIFEST.md is up to date
- **WHEN** `bun scripts/folder-reorg/generate-manifest.ts --check` is run
- **THEN** it exits with status 0 AND the committed `MANIFEST.md` matches the regenerated output byte-for-byte
#### Scenario: MANIFEST.md reflects category moves
- **WHEN** `bun scripts/folder-reorg/generate-manifest.ts` runs after a package realignment
- **THEN** every moved package appears under its new category in `MANIFEST.md`
- **AND** no retired category folder appears in `MANIFEST.md`
**Verified by:** `scripts/folder-reorg/generate-manifest.ts --check`
#### Scenario: @suaveplan/overview is absent
- **WHEN** the repository is searched for `@suaveplan/overview`
- **THEN** no `package.json` declares it as a dep AND no source file imports it AND no folder named `overview/` exists under `packages/`
### Requirement: FOLDERS.yml is removed
`FOLDERS.yml` SHALL NOT exist at the repository root. The folder layout is self-describing via the `packages/` tree and the per-tier overlay lives in `MANIFEST.md`.
#### Scenario: FOLDERS.yml absent
- **WHEN** the repository root is listed
- **THEN** no file named `FOLDERS.yml` is present
### Requirement: Tier inference from package.json
`scripts/folder-reorg/generate-manifest.ts` SHALL infer each package's tier deterministically from its `package.json` and source layout, in this priority order: python → r3f → react → browser → server → universal.
- **python**: package directory name has prefix `python-` OR `package.json` has a top-level `"python"` field.
- **r3f**: `peerDependencies` includes `@react-three/fiber` OR `three`.
- **react**: `peerDependencies` includes `react` (and tier is not r3f).
- **browser**: `package.json` has a `"browser"` field OR `engines` lacks `node`.
- **server**: `bin` field is set OR direct dependencies include `bun:sqlite`, `node:fs`, `node:net`, `node:cluster`, `pg`, `ioredis`, or similar Node-only modules.
- **universal**: none of the above match.
#### Scenario: Tier inference is deterministic
- **WHEN** `scripts/folder-reorg/generate-manifest.ts --check` is run twice without source changes
- **THEN** both runs produce identical tier classifications for every package
### Requirement: Path references in CLAUDE.md and openspec/AGENTS.md updated
`CLAUDE.md` and `openspec/AGENTS.md` SHALL reference packages via `packages/<category>/<pkg>/` paths. References to legacy `universal/<domain>/<pkg>/` or `packages/<tier>/<pkg>/` paths SHALL NOT exist in either file.
#### Scenario: No legacy path references
- **WHEN** `CLAUDE.md` and `openspec/AGENTS.md` are searched for the regex `(^|[^a-z])(universal|browser|react|r3f|server|finance|mcp|python|gaming)/[a-z]`
- **THEN** zero matches are found in either file
**Verified by:** `scripts/check-fixture-resolves.ts` (path reference validation)
### Requirement: assignment.yml is the machine-readable mirror of FOLDERS.md
`scripts/folder-reorg/assignment.yml` SHALL list every on-disk package exactly once, keyed by its `FOLDERS.md` category. The file SHALL be parseable by the migration script and SHALL produce zero duplicate or orphan errors in `--check` mode.
#### Scenario: assignment.yml passes uniqueness check
- **WHEN** the migration script parses `assignment.yml`
- **THEN** no package name appears under more than one category key
- **AND** no package name appears under `__retired__` and also under a category
#### Scenario: assignment.yml covers every on-disk package
- **WHEN** the migration script discovers all `packages/*/*/package.json` files
- **THEN** every discovered package name appears in `assignment.yml`
- **AND** every package in `assignment.yml` that does not exist on disk is silently skipped (future package)
**Verified by:** `scripts/folder-reorg/realign-to-categories.ts --check`
### Requirement: No hard-coded retired paths in committed documentation
No committed file in the repository SHALL contain a hard-coded path to a retired category folder (e.g., `packages/codes/`, `packages/primitives/`, `packages/vault/`, etc.). Documentation, scripts, and workflow files SHALL reference the current category.
#### Scenario: Doc-reference sweep is clean
- **WHEN** a grep runs for each retired category folder name across the repo root
- **THEN** zero matches are found in any committed file
**Verified by:** `scripts/folder-reorg/realign-to-categories.ts --check` (orphan detection) AND manual grep verification in `STATUS-OPENSPEC-GAPS.md`
### Requirement: Migration is reversible until merged
The folder migration SHALL execute via `git mv` (preserving file history) in a single commit, allowing `git revert` to restore the legacy layout if a regression is found before merge. Filesystems where the rename syscall is blocked (e.g. WSL2/DrvFs with Windows-side handles) MAY use a `cp` + `git rm` fallback that copies tracked files only, preserving rename detection via content similarity in `git log --follow`.
#### Scenario: File history preserved
- **WHEN** `git log --follow packages/<any-category>/<any-existing-pkg>/src/<any-file>.ts` is run after migration
- **THEN** the log shows commits from before the migration (via `git mv` or via the cp-fallback's content-similarity rename detection)
### Requirement: Barrel export form
Per-feature module barrels (`src/<category>/<feature>/index.ts`) SHALL re-export their module surface using **explicit named re-exports**, not `export * from`. Package-root barrels (`src/index.ts`) MAY use `export * from` or `export * as <name> from` namespace exports until they are migrated under a separate change. Generated selector indexes (paths matching `*.select/index.ts`, plus per-impl-variant forwarders such as `*.native/index.ts` and `*.noble/index.ts` that contain only a single `export *` forwarder line) are exempt because transparent forwarding of the impl surface is the entire purpose of those files.
The reasons match `openspec/AGENTS.md` §6a:
- The barrel is the public contract; `export *` makes the contract whatever the source file happens to declare today.
- Adding a new private helper to `<feature>.ts` must not auto-leak through the barrel.
- Type-only re-exports remain clean (`export { type X }` per symbol).
- Tree-shaking is equivalent under `"sideEffects": false` with rollup or esbuild — there is no bundle-size argument for `export *`.
#### Scenario: Feature-module barrel uses explicit named exports
- **WHEN** `src/<category>/<feature>/index.ts` re-exports the contents of `<feature>.ts`
- **THEN** the barrel uses `export { Symbol1, type Type1, ... } from "./<feature>.js"`
- **AND** the barrel does NOT contain a line starting with `export * from "./<feature>` (with or without the `.js` suffix)
#### Scenario: Generated selector index exemption
- **WHEN** `src/<path>/<primitive>.select/index.ts` exists for a per-primitive selector
- **THEN** that file is permitted to contain `export * from "./<primitive>.select.js"`
- **AND** its parent directory ends in `.select`
#### Scenario: Namespace re-export at package root is permitted
- **WHEN** `src/index.ts` re-exports a sub-namespace
- **THEN** it MAY use `export * as <name> from "./<path>.js"` (namespace export)
#### Scenario: Repository scan finds no banned barrel forms
- **WHEN** `grep -rn '^\s*export\s\+\(type\s\+\)\?\*\s\+from' packages/ --include='index.ts' | grep -v '/dist/' | grep -v '/src/index.ts:' | grep -v '\.select/' | grep -v '\.native/index\.ts:' | grep -v '\.noble/index\.ts:' | grep -v 'export \* as'` is run
- **THEN** the output is empty