Files
suaveplan/openspec/archive/2026-05-25-enterprise-overview/design.md
2026-05-27 23:29:53 +01:00

7.7 KiB

Design — @suaveplan/overview

Non-normative design notes for the enterprise-overview change. Normative requirements live in ./specs/overview/spec.md.


1. What the library is

@suaveplan/overview is the typed, schema-validated, graph-aware library form of the monorepo manifest. The data model mirrors what scripts/folder-reorg/generate-manifest.ts produces ad-hoc today (and that script's own header comment names this package as the eventual replacement).

It is a leaf package. Direct workspace deps:

  • @suaveplan/error — SuaveplanError, defineCodes
  • @suaveplan/types — for option-bundle ergonomics (we re-use Clock etc. only if useful; not required by the core)

Plus catalog deps zod, tslib, @opentelemetry/api.

2. Tier model

Universal-tier for pure data + graph operations. The discovery surface (discoverPackages) touches node:fs and node:path and is therefore server-tier; it ships under the ./discover sub-path export so universal consumers (browser, edge, R3F) can import only the pure pieces. The package itself stays universal at the root entry — only the ./discover sub-path is server-flavoured. This matches the genesis pattern used by db-sqlite / packer-browser etc.

3. Data model (Zod-validated)

type Tier = "python" | "r3f" | "react" | "browser" | "server" | "universal";

interface PackageRecord {
  name: string;              // "@suaveplan/<short>"
  short: string;             // "<short>"
  category: string;          // top-level super-category folder
  plugin: string;            // mid-level plugin folder
  dirName: string;           // last path segment
  version: string;
  description: string;
  tier: Tier;
  workspaceDeps: string[];   // sorted, deduped @suaveplan/* names
  hasBin: boolean;
  hasBrowserField: boolean;
}

interface PluginSummary {
  category: string;
  plugin: string;
  packages: number;
  workspaceDeps: number;     // total edges out
}

interface SuavePlanManifest {
  packages: PackageRecord[];
  plugins: PluginSummary[];
  categories: string[];
  tierCounts: Record<Tier, number>;
}

interface WorkspaceGraph {
  nodes: Set<string>;
  forward: Map<string, Set<string>>;   // pkg → deps
  reverse: Map<string, Set<string>>;   // pkg → dependents
}

All four shapes have Zod schemas exported alongside the types.

4. Tier inference

Identical priority to generate-manifest.ts:

  1. python !== undefined or short.startsWith("python-") → python
  2. peerDependencies includes @react-three/fiber or three or short.startsWith("r3f-") → r3f
  3. peerDependencies includes react or short.startsWith("react-") → react
  4. browser !== undefined or engines set with neither node nor bun keys → browser
  5. bin !== undefined or dependencies/devDependencies hit a known server-only dep (bun:sqlite, pg, ioredis, better-sqlite3, node:* namespace) → server
  6. fallback → universal

The hint set is exported as SERVER_DEP_HINTS so downstream code can extend it via a tierHints option to discoverPackages.

5. Discovery semantics

discoverPackages(options) walks <root>/packages/<category>/<plugin>/<pkg>/package.json exactly three levels deep. Anything that fails JSON.parse is reported via the OVERVIEW.PARSE_FAILED error path (caller decides: throw or skip). Anything without a package.json is silently ignored — that matches the script behaviour and reflects partial-migration reality on genesis.

The walk is synchronous by default (cheap on a populated monorepo) with an optional async generator variant (discoverPackagesIter) that yields one PackageRecord at a time and honours AbortSignal.

6. Graph utilities

  • buildGraph(records) returns WorkspaceGraph. Only edges where the target is also in nodes are recorded (so a missing-yet-to-migrate dep is dropped rather than producing a phantom node — matches the script's behaviour).
  • topologicalOrder(graph) returns packages in dep-first order using Kahn's algorithm. On a cycle it throws OVERVIEW.CYCLE_DETECTED carrying the cycle nodes.
  • detectCycles(graph) returns string[][] of strongly-connected components larger than one node (Tarjan).
  • transitiveDependencies(graph, pkg) / transitiveDependents(graph, pkg) BFS in the respective direction. Returns a sorted string[].
  • leafPackages(graph) — packages with no incoming workspace edge (no dependents).
  • rootPackages(graph) — packages with no outgoing workspace edge (no deps).

7. Roll-ups

  • byPlugin(records) returns a sorted PluginSummary[].
  • byCategory(records) returns Record<string, number> (package count).
  • byTier(records) returns Record<Tier, PackageRecord[]>.

8. Error codes

defineCodes("OVERVIEW", [
  "PARSE_FAILED",        // package.json failed JSON.parse
  "INVALID_RECORD",      // a record failed Zod validation
  "CYCLE_DETECTED",      // topological sort hit a cycle
  "UNKNOWN_PACKAGE",     // queried a package not in the graph
  "DISCOVERY_ABORTED",   // AbortSignal fired mid-walk
  "INVALID_TIER",        // schema rejected an out-of-range tier string
] as const);

Every code is thrown from a real call site (no dead entries — §2c).

9. OpenTelemetry

Three counters and one histogram, namespaced overview.*:

  • overview.packages_discovered_total — counter incremented per discoverPackages call by records.length.
  • overview.records_rejected_total{reason} — counter (reason ∈ {parse_failed, invalid_record}).
  • overview.graph_builds_total — counter incremented per buildGraph.
  • overview.discover_duration_ms — histogram (boundaries 1, 5, 25, 100, 500, 2500) per successful discoverPackages.

The OTel meter is obtained via metrics.getMeter("@suaveplan/overview", "0.1.0") so a no-op OTel SDK is the default — no instrumentation cost on the cold path.

10. Why a sub-path for discovery

Tier purity: the package root MUST be safe to import from a universal context (browser, R3F, edge worker). Node-only node:fs / node:path imports live under ./discover which package.json exports lists distinctly. This matches the pattern used by db-sqlite.

11. Test plan

  • errors/errors.test.ts — every code constructable; cause chain preserved; instanceof OverviewError; code uniqueness.
  • schemas/schemas.test.ts — every shape parses a valid example + rejects each malformed shape (one negative case per field).
  • tier/tier.test.ts — one case per inference rung incl. server-hint walk and python early-out; tierHints extension.
  • discover/discover.test.ts — discovers a temp-dir fixture (real files, real package.jsons, no mocks — rule 2); async variant honours AbortSignal; invalid JSON triggers PARSE_FAILED; missing field triggers INVALID_RECORD.
  • graph/graph.test.ts — adjacency build; topo on linear + DAG; cycle case throws + reports; transitive closure correctness on a chain; leaves + roots on a star; missing-target edge is dropped.
  • rollups/rollups.test.ts — plugin / category / tier aggregates on a hand-crafted record array.
  • telemetry/telemetry.test.ts — installs an in-memory metric reader, observes counter + histogram values after discoverPackages + buildGraph.

Property-based tests via fast-check for topologicalOrder (random DAG → all edges respected) and transitiveDependencies (closure is reflexive on self-loops and matches multi-step BFS).

12. File-size discipline

The largest implementation file is expected to be graph/graph.ts (Tarjan

  • topo + closure) at ~180 lines. Everything else stays under 200. Test files are budget-aware; if graph.test.ts pushes 500, split into graph.spec.basics.test.ts + graph.spec.cycles.test.ts.