Initial Commit
This commit is contained in:
106
packages/devtools/tooling/overview/README.md
Normal file
106
packages/devtools/tooling/overview/README.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# @suaveplan/overview - Machine-readable Suaveplan project manifest: typed PackageRecord / SuavePlanManifest / WorkspaceGraph, Zod schemas, tier inference, filesystem discovery (server sub-path), graph utilities (topo, cycle detection, transitive closure, leaves, roots), plugin/category/tier roll-ups, six OVERVIEW.* error codes, three OTel counters + one histogram.
|
||||
|
||||
> Machine-readable project manifest + graph utilities for the Suaveplan
|
||||
> project.
|
||||
|
||||
`@suaveplan/overview` turns a populated
|
||||
`packages/<category>/<plugin>/<pkg>/package.json` tree into a typed,
|
||||
Zod-validated `SuavePlanManifest` and gives you graph utilities — forward /
|
||||
reverse adjacency, topological order, cycle detection, transitive closure,
|
||||
leaves, roots, plugin / category / tier roll-ups — for everything downstream.
|
||||
|
||||
It is the **library form** of the data model produced by
|
||||
`scripts/folder-reorg/generate-manifest.ts` (whose own header comment names this
|
||||
package as the eventual replacement).
|
||||
|
||||
The pure-data surface lives at the root entry. Filesystem-walking code (which
|
||||
needs `node:fs` / `node:path`) lives behind the `./discover` sub-path so the
|
||||
root entry stays universal.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [discover](./src/discover/discover.md) — Implements the filesystem walker that produces a validated `SuavePlanManifest` from a populated `packages/<category>/<pl…
|
||||
- [discover](./src/discover/discover.md) — Implements the filesystem walker that produces a validated `SuavePlanManifest` from a populated `packages/<category>/<pl…
|
||||
- [errors](./src/errors/errors.md) — Defines the stable error-code registry and the concrete `OverviewError` subclass of `SuaveplanError`. Every public entr…
|
||||
- [errors](./src/errors/errors.md) — Defines the stable error-code registry and the concrete `OverviewError` subclass of `SuaveplanError`. Every public entr…
|
||||
- [graph](./src/graph/graph.md) — Implements the graph utilities that operate on a `WorkspaceGraph` built from a list of `PackageRecord`s. Covers the ana…
|
||||
- [graph](./src/graph/graph.md) — Implements the graph utilities that operate on a `WorkspaceGraph` built from a list of `PackageRecord`s. Covers the ana…
|
||||
- [rollups](./src/rollups/rollups.md) — Provides the three aggregate views over a `PackageRecord[]` that the manifest needs: plugin roll-ups (one row per `(cat…
|
||||
- [rollups](./src/rollups/rollups.md) — Provides the three aggregate views over a `PackageRecord[]` that the manifest needs: plugin roll-ups (one row per `(cat…
|
||||
- [schemas](./src/schemas/schemas.md) — Provides Zod schemas (and one helper, `parseTier`) for every public shape that `@suaveplan/overview` produces or accept…
|
||||
- [schemas](./src/schemas/schemas.md) — Provides Zod schemas (and one helper, `parseTier`) for every public shape that `@suaveplan/overview` produces or accept…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — Owns the OpenTelemetry meter for `@suaveplan/overview` and exposes the three counters and one histogram that the spec r…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — Owns the OpenTelemetry meter for `@suaveplan/overview` and exposes the three counters and one histogram that the spec r…
|
||||
- [tier](./src/tier/tier.md) — Computes the deterministic `Tier` label for a package given its shortname and the parsed `package.json` shape. The infe…
|
||||
- [tier](./src/tier/tier.md) — Computes the deterministic `Tier` label for a package given its shortname and the parsed `package.json` shape. The infe…
|
||||
- [types](./src/types/types.md) — Declares the type-level vocabulary of `@suaveplan/overview`: the closed `Tier` union, the `PackageRecord` row, the `Plu…
|
||||
- [types](./src/types/types.md) — Declares the type-level vocabulary of `@suaveplan/overview`: the closed `Tier` union, the `PackageRecord` row, the `Plu…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
bun add @suaveplan/overview
|
||||
```
|
||||
|
||||
## Surfaces
|
||||
|
||||
| Sub-path | Use |
|
||||
|---|---|
|
||||
| `@suaveplan/overview` | Types, schemas, errors, graph utilities, roll-ups, telemetry — all universal |
|
||||
| `@suaveplan/overview/discover` | `discoverPackages` + `discoverPackagesIter` — server-tier (touches `node:fs`) |
|
||||
| `@suaveplan/overview/errors` | `OVERVIEW_CODES`, `OverviewError` |
|
||||
| `@suaveplan/overview/schemas` | Zod schemas + `parseTier` |
|
||||
| `@suaveplan/overview/tier` | `inferTier`, `SERVER_DEP_HINTS` |
|
||||
| `@suaveplan/overview/graph` | Graph utilities |
|
||||
| `@suaveplan/overview/rollups` | `byPlugin`, `byCategory`, `byTier` |
|
||||
| `@suaveplan/overview/telemetry` | OTel instruments |
|
||||
|
||||
## Example
|
||||
|
||||
```ts
|
||||
import { discoverPackages } from "@suaveplan/overview/discover";
|
||||
import {
|
||||
buildGraph,
|
||||
topologicalOrder,
|
||||
byTier,
|
||||
} from "@suaveplan/overview";
|
||||
|
||||
const manifest = discoverPackages({ root: process.cwd() });
|
||||
const graph = buildGraph(manifest.packages);
|
||||
const order = topologicalOrder(graph);
|
||||
const buckets = byTier(manifest.packages);
|
||||
|
||||
console.log(`Packages: ${manifest.packages.length}`);
|
||||
console.log(`Universal: ${buckets.universal.length}`);
|
||||
console.log(`Build order (deps first): ${order.slice(0, 5).join(", ")} …`);
|
||||
```
|
||||
|
||||
## Error codes
|
||||
|
||||
| Code | Thrown by |
|
||||
|---|---|
|
||||
| `OVERVIEW.PARSE_FAILED` | `discoverPackages` — `package.json` failed `JSON.parse` |
|
||||
| `OVERVIEW.INVALID_RECORD` | `discoverPackages` — row failed `PackageRecordSchema` |
|
||||
| `OVERVIEW.CYCLE_DETECTED` | `topologicalOrder` |
|
||||
| `OVERVIEW.UNKNOWN_PACKAGE` | `transitiveDependencies` / `transitiveDependents` |
|
||||
| `OVERVIEW.DISCOVERY_ABORTED` | `discoverPackagesIter` |
|
||||
| `OVERVIEW.INVALID_TIER` | `parseTier` |
|
||||
|
||||
## Telemetry
|
||||
|
||||
Three counters and one histogram, scoped to meter
|
||||
`@suaveplan/overview@0.1.0`:
|
||||
|
||||
- `overview.packages_discovered_total`
|
||||
- `overview.records_rejected_total{reason}`
|
||||
- `overview.graph_builds_total`
|
||||
- `overview.discover_duration_ms`
|
||||
|
||||
Plug `@opentelemetry/sdk-metrics` in your runtime to harvest them.
|
||||
|
||||
## Tier
|
||||
|
||||
Universal at the root entry; server at `./discover`.
|
||||
|
||||
97
packages/devtools/tooling/overview/package.json
Normal file
97
packages/devtools/tooling/overview/package.json
Normal file
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"name": "@suaveplan/overview",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Machine-readable Suaveplan project manifest: typed PackageRecord / SuavePlanManifest / WorkspaceGraph, Zod schemas, tier inference, filesystem discovery (server sub-path), graph utilities (topo, cycle detection, transitive closure, leaves, roots), plugin/category/tier roll-ups, six OVERVIEW.* error codes, three OTel counters + one histogram.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./discover": {
|
||||
"import": {
|
||||
"types": "./dist/discover/discover.d.ts",
|
||||
"default": "./dist/discover/discover.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/errors.d.ts",
|
||||
"default": "./dist/errors/errors.js"
|
||||
}
|
||||
},
|
||||
"./schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/schemas.d.ts",
|
||||
"default": "./dist/schemas/schemas.js"
|
||||
}
|
||||
},
|
||||
"./tier": {
|
||||
"import": {
|
||||
"types": "./dist/tier/tier.d.ts",
|
||||
"default": "./dist/tier/tier.js"
|
||||
}
|
||||
},
|
||||
"./graph": {
|
||||
"import": {
|
||||
"types": "./dist/graph/graph.d.ts",
|
||||
"default": "./dist/graph/graph.js"
|
||||
}
|
||||
},
|
||||
"./rollups": {
|
||||
"import": {
|
||||
"types": "./dist/rollups/rollups.d.ts",
|
||||
"default": "./dist/rollups/rollups.js"
|
||||
}
|
||||
},
|
||||
"./telemetry": {
|
||||
"import": {
|
||||
"types": "./dist/telemetry/telemetry.d.ts",
|
||||
"default": "./dist/telemetry/telemetry.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"zod": "catalog:forms",
|
||||
"tslib": "catalog:build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
85
packages/devtools/tooling/overview/src/discover/discover.md
Normal file
85
packages/devtools/tooling/overview/src/discover/discover.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# `discover/discover.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the filesystem walker that produces a validated `SuavePlanManifest`
|
||||
from a populated `packages/<category>/<plugin>/<pkg>/package.json` tree. Both
|
||||
the synchronous `discoverPackages` and the async `discoverPackagesIter` live
|
||||
here. The module is the only server-tier surface in `@suaveplan/overview`; it
|
||||
ships under the `./discover` sub-path export so universal consumers can avoid
|
||||
the `node:fs` / `node:path` imports entirely.
|
||||
|
||||
Discovery is straightforward: walk three directory levels deep, read each
|
||||
`package.json`, infer its tier, collect workspace deps, validate against
|
||||
`PackageRecordSchema`, then aggregate the resulting rows into a manifest.
|
||||
|
||||
## Features
|
||||
|
||||
- Synchronous `discoverPackages` returning a fully-validated manifest
|
||||
- Async `discoverPackagesIter` honouring `AbortSignal`
|
||||
- `onError: "throw" | "skip"` for graceful handling of malformed
|
||||
`package.json` files
|
||||
- Optional `tierHints` extending `SERVER_DEP_HINTS`
|
||||
- Emits `overview.packages_discovered_total`,
|
||||
`overview.records_rejected_total{reason}`, and
|
||||
`overview.discover_duration_ms`
|
||||
- Built-in roll-up so the manifest carries `plugins`, `categories`, and
|
||||
`tierCounts` ready for consumption
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { discoverPackages } from "@suaveplan/overview/discover";
|
||||
|
||||
const manifest = discoverPackages({ root: process.cwd() });
|
||||
console.log(`Found ${manifest.packages.length} packages.`);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { discoverPackagesIter } from "@suaveplan/overview/discover";
|
||||
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 5_000);
|
||||
|
||||
try {
|
||||
for await (const r of discoverPackagesIter({
|
||||
root: "/repo",
|
||||
signal: controller.signal,
|
||||
onError: "skip",
|
||||
tierHints: new Set(["custom-native-binding"]),
|
||||
})) {
|
||||
process.stdout.write(`${r.name}\n`);
|
||||
}
|
||||
} catch (err) {
|
||||
// OverviewError with code OVERVIEW.DISCOVERY_ABORTED when the signal fired.
|
||||
}
|
||||
```
|
||||
|
||||
`onError: "skip"` is the right mode for tooling that should never abort on a
|
||||
single bad package.json (e.g. a long-running watcher); `"throw"` is right for
|
||||
one-shot CLIs that prefer a hard fail on corruption.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `discoverPackages(options: DiscoverOptions): SuavePlanManifest`
|
||||
- `discoverPackagesIter(options: AsyncDiscoverOptions): AsyncGenerator<PackageRecord>`
|
||||
- `interface DiscoverOptions { root, onError?, tierHints? }`
|
||||
- `interface AsyncDiscoverOptions extends DiscoverOptions { signal? }`
|
||||
- `type DiscoveryErrorMode = "throw" | "skip"`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The walker enforces exactly three directory levels (category → plugin →
|
||||
package). Anything shallower or deeper is silently ignored — partial-migration
|
||||
trees are common and a permissive walker keeps the tooling robust.
|
||||
`existsSync` + `statSync` are used to filter non-directories and packages
|
||||
without a `package.json`; both are cheap on a populated tree because the OS
|
||||
will have cached the directory listings. The histogram observation uses
|
||||
`performance.now()` when available (it always is in Node 20+/Bun) and falls
|
||||
back to `Date.now()` otherwise.
|
||||
405
packages/devtools/tooling/overview/src/discover/discover.test.ts
Normal file
405
packages/devtools/tooling/overview/src/discover/discover.test.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { OverviewError } from "../errors/errors.js";
|
||||
import { discoverPackages, discoverPackagesIter } from "./discover.js";
|
||||
|
||||
interface PkgFixture {
|
||||
readonly category: string;
|
||||
readonly plugin: string;
|
||||
readonly dir: string;
|
||||
readonly json: object | string; // string = raw bytes (e.g. malformed)
|
||||
}
|
||||
|
||||
function writeFixture(root: string, pkg: PkgFixture): void {
|
||||
const dir = join(root, "packages", pkg.category, pkg.plugin, pkg.dir);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const body =
|
||||
typeof pkg.json === "string" ? pkg.json : JSON.stringify(pkg.json);
|
||||
writeFileSync(join(dir, "package.json"), body);
|
||||
}
|
||||
|
||||
let tmp: string;
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), "overview-test-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
// node:fs has no recursive rm in our minimal type set; we rely on tmp dir
|
||||
// being scrubbed by the OS. Tests don't depend on prior content.
|
||||
});
|
||||
|
||||
describe("discoverPackages", () => {
|
||||
it("discovers a 3-level fixture", { timeout: 5000 }, () => {
|
||||
writeFixture(tmp, {
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
dir: "a",
|
||||
json: {
|
||||
name: "@suaveplan/a",
|
||||
version: "0.1.0",
|
||||
description: "alpha",
|
||||
},
|
||||
});
|
||||
writeFixture(tmp, {
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
dir: "b",
|
||||
json: {
|
||||
name: "@suaveplan/b",
|
||||
version: "0.1.0",
|
||||
description: "beta",
|
||||
dependencies: { "@suaveplan/a": "workspace:^" },
|
||||
},
|
||||
});
|
||||
const manifest = discoverPackages({ root: tmp });
|
||||
expect(manifest.packages.length).toBe(2);
|
||||
expect(manifest.packages[0].name).toBe("@suaveplan/a");
|
||||
expect(manifest.packages[1].workspaceDeps).toEqual(["@suaveplan/a"]);
|
||||
expect(manifest.categories).toEqual(["foundation"]);
|
||||
expect(manifest.tierCounts.universal).toBe(2);
|
||||
expect(manifest.plugins[0]).toEqual({
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
packages: 2,
|
||||
workspaceDeps: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty manifest for a missing packages/ directory", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const manifest = discoverPackages({ root: tmp });
|
||||
expect(manifest.packages.length).toBe(0);
|
||||
expect(manifest.categories).toEqual([]);
|
||||
expect(manifest.tierCounts.universal).toBe(0);
|
||||
});
|
||||
|
||||
it("throws PARSE_FAILED on invalid JSON", { timeout: 5000 }, () => {
|
||||
writeFixture(tmp, {
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
dir: "broken",
|
||||
json: "{ not valid",
|
||||
});
|
||||
try {
|
||||
discoverPackages({ root: tmp });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OverviewError);
|
||||
if (err instanceof OverviewError) {
|
||||
expect(err.code).toBe("OVERVIEW.PARSE_FAILED");
|
||||
expect(err.cause).toBeInstanceOf(Error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("throws INVALID_RECORD when fields missing", { timeout: 5000 }, () => {
|
||||
// No `name` field; schema requires `@suaveplan/...` prefix.
|
||||
writeFixture(tmp, {
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
dir: "x",
|
||||
json: { version: "0.1.0" },
|
||||
});
|
||||
try {
|
||||
discoverPackages({ root: tmp });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OverviewError);
|
||||
if (err instanceof OverviewError) {
|
||||
expect(err.code).toBe("OVERVIEW.INVALID_RECORD");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("onError: 'skip' drops bad rows and keeps good ones", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
writeFixture(tmp, {
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
dir: "good",
|
||||
json: {
|
||||
name: "@suaveplan/good",
|
||||
version: "0.1.0",
|
||||
description: "g",
|
||||
},
|
||||
});
|
||||
writeFixture(tmp, {
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
dir: "broken",
|
||||
json: "{ not valid",
|
||||
});
|
||||
writeFixture(tmp, {
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
dir: "invalid",
|
||||
json: { version: "0.1.0" },
|
||||
});
|
||||
const manifest = discoverPackages({ root: tmp, onError: "skip" });
|
||||
expect(manifest.packages.length).toBe(1);
|
||||
expect(manifest.packages[0].name).toBe("@suaveplan/good");
|
||||
});
|
||||
|
||||
it("ignores subfolders without package.json + stray files at every level", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
mkdirSync(join(tmp, "packages", "foundation", "core", "empty"), {
|
||||
recursive: true,
|
||||
});
|
||||
// Stray file at category level (not a directory)
|
||||
mkdirSync(join(tmp, "packages"), { recursive: true });
|
||||
writeFileSync(join(tmp, "packages", "stray-category.txt"), "stray");
|
||||
// Stray file at plugin level
|
||||
mkdirSync(join(tmp, "packages", "foundation"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(tmp, "packages", "foundation", "stray-plugin.txt"),
|
||||
"stray"
|
||||
);
|
||||
// Stray file at package level
|
||||
mkdirSync(join(tmp, "packages", "foundation", "core"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
join(tmp, "packages", "foundation", "core", "stray-pkg.txt"),
|
||||
"stray"
|
||||
);
|
||||
writeFixture(tmp, {
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
dir: "x",
|
||||
json: {
|
||||
name: "@suaveplan/x",
|
||||
version: "0.1.0",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
const manifest = discoverPackages({ root: tmp });
|
||||
expect(manifest.packages.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("discoverPackagesIter", () => {
|
||||
it("yields one record at a time", { timeout: 5000 }, async () => {
|
||||
writeFixture(tmp, {
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
dir: "a",
|
||||
json: {
|
||||
name: "@suaveplan/a",
|
||||
version: "0.1.0",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
const names: string[] = [];
|
||||
for await (const r of discoverPackagesIter({ root: tmp })) {
|
||||
names.push(r.name);
|
||||
}
|
||||
expect(names).toEqual(["@suaveplan/a"]);
|
||||
});
|
||||
|
||||
it("honours AbortSignal", { timeout: 5000 }, async () => {
|
||||
for (const dir of ["a", "b", "c"]) {
|
||||
writeFixture(tmp, {
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
dir,
|
||||
json: {
|
||||
name: `@suaveplan/${dir}`,
|
||||
version: "0.1.0",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const iter = discoverPackagesIter({
|
||||
root: tmp,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const first = await iter.next();
|
||||
expect(first.done).toBe(false);
|
||||
controller.abort();
|
||||
try {
|
||||
await iter.next();
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OverviewError);
|
||||
if (err instanceof OverviewError) {
|
||||
expect(err.code).toBe("OVERVIEW.DISCOVERY_ABORTED");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns immediately when packages/ does not exist", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const names: string[] = [];
|
||||
for await (const r of discoverPackagesIter({ root: tmp })) {
|
||||
names.push(r.name);
|
||||
}
|
||||
expect(names).toEqual([]);
|
||||
});
|
||||
|
||||
it("skip mode passes through bad rows", { timeout: 5000 }, async () => {
|
||||
writeFixture(tmp, {
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
dir: "good",
|
||||
json: {
|
||||
name: "@suaveplan/good",
|
||||
version: "0.1.0",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
writeFixture(tmp, {
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
dir: "broken",
|
||||
json: "{ not valid",
|
||||
});
|
||||
const names: string[] = [];
|
||||
for await (const r of discoverPackagesIter({
|
||||
root: tmp,
|
||||
onError: "skip",
|
||||
})) {
|
||||
names.push(r.name);
|
||||
}
|
||||
expect(names).toEqual(["@suaveplan/good"]);
|
||||
});
|
||||
|
||||
it("iter throws PARSE_FAILED in throw mode", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
writeFixture(tmp, {
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
dir: "broken",
|
||||
json: "{ not valid",
|
||||
});
|
||||
try {
|
||||
for await (const _ of discoverPackagesIter({ root: tmp })) {
|
||||
void _;
|
||||
}
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OverviewError);
|
||||
if (err instanceof OverviewError) {
|
||||
expect(err.code).toBe("OVERVIEW.PARSE_FAILED");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("iter ignores stray files at the category/plugin/pkg level", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
mkdirSync(join(tmp, "packages"), { recursive: true });
|
||||
writeFileSync(join(tmp, "packages", "stray.txt"), "x");
|
||||
mkdirSync(join(tmp, "packages", "foundation"), { recursive: true });
|
||||
writeFileSync(join(tmp, "packages", "foundation", "stray.txt"), "x");
|
||||
mkdirSync(join(tmp, "packages", "foundation", "core"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
join(tmp, "packages", "foundation", "core", "stray.txt"),
|
||||
"x"
|
||||
);
|
||||
writeFixture(tmp, {
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
dir: "a",
|
||||
json: {
|
||||
name: "@suaveplan/a",
|
||||
version: "0.1.0",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
const names: string[] = [];
|
||||
for await (const r of discoverPackagesIter({ root: tmp })) {
|
||||
names.push(r.name);
|
||||
}
|
||||
expect(names).toEqual(["@suaveplan/a"]);
|
||||
});
|
||||
|
||||
it("skip mode tolerates missing version + description fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
writeFixture(tmp, {
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
dir: "x",
|
||||
json: { name: "@suaveplan/x" },
|
||||
});
|
||||
const manifest = discoverPackages({ root: tmp, onError: "skip" });
|
||||
expect(manifest.packages.length).toBe(0);
|
||||
});
|
||||
|
||||
it("collectWorkspaceDeps ignores non-workspace ranges and non-suaveplan names", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
writeFixture(tmp, {
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
dir: "x",
|
||||
json: {
|
||||
name: "@suaveplan/x",
|
||||
version: "0.1.0",
|
||||
description: "",
|
||||
dependencies: {
|
||||
"@suaveplan/skip-me": "^1.0.0",
|
||||
"external-pkg": "workspace:^",
|
||||
},
|
||||
optionalDependencies: {
|
||||
"@suaveplan/opt": "workspace:^",
|
||||
},
|
||||
peerDependencies: {
|
||||
"@suaveplan/peer": "workspace:^",
|
||||
},
|
||||
},
|
||||
});
|
||||
const manifest = discoverPackages({ root: tmp });
|
||||
expect(manifest.packages[0]?.workspaceDeps).toEqual([
|
||||
"@suaveplan/opt",
|
||||
"@suaveplan/peer",
|
||||
]);
|
||||
});
|
||||
|
||||
it("iter throws INVALID_RECORD in throw mode", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
writeFixture(tmp, {
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
dir: "x",
|
||||
json: { version: "0.1.0" },
|
||||
});
|
||||
try {
|
||||
for await (const _ of discoverPackagesIter({ root: tmp })) {
|
||||
void _;
|
||||
}
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OverviewError);
|
||||
if (err instanceof OverviewError) {
|
||||
expect(err.code).toBe("OVERVIEW.INVALID_RECORD");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("tier purity at the root entry", () => {
|
||||
it("root barrel imports no node:* modules", { timeout: 5000 }, () => {
|
||||
const indexPath = join(__dirname, "..", "index.ts");
|
||||
const text = readFileSync(indexPath, "utf8");
|
||||
expect(text.includes('from "node:')).toBe(false);
|
||||
expect(text.includes('require("node:')).toBe(false);
|
||||
expect(text.includes("./discover/")).toBe(false);
|
||||
});
|
||||
});
|
||||
226
packages/devtools/tooling/overview/src/discover/discover.ts
Normal file
226
packages/devtools/tooling/overview/src/discover/discover.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { OVERVIEW_CODES, OverviewError } from "../errors/errors.js";
|
||||
import { byPlugin } from "../rollups/rollups.js";
|
||||
import { PackageRecordSchema } from "../schemas/schemas.js";
|
||||
import {
|
||||
discoverDurationMs,
|
||||
packagesDiscoveredTotal,
|
||||
recordsRejectedTotal,
|
||||
} from "../telemetry/telemetry.js";
|
||||
import { inferTier } from "../tier/tier.js";
|
||||
import type {
|
||||
SuavePlanManifest,
|
||||
PackageRecord,
|
||||
RawPackageJson,
|
||||
Tier,
|
||||
} from "../types/types.js";
|
||||
|
||||
/**
|
||||
* What happens when discovery encounters a `package.json` that fails to
|
||||
* parse as JSON or fails Zod validation.
|
||||
*
|
||||
* - `"throw"` (default) — abort discovery and throw an `OverviewError`.
|
||||
* - `"skip"` — drop the row, emit a `records_rejected_total` counter point,
|
||||
* and continue.
|
||||
*/
|
||||
export type DiscoveryErrorMode = "throw" | "skip";
|
||||
|
||||
export interface DiscoverOptions {
|
||||
/**
|
||||
* Root path containing a `packages/` folder. Discovery walks
|
||||
* `<root>/packages/<category>/<plugin>/<pkg>/package.json`.
|
||||
*/
|
||||
readonly root: string;
|
||||
/** Default `"throw"`. */
|
||||
readonly onError?: DiscoveryErrorMode;
|
||||
/** Extension hints merged with `SERVER_DEP_HINTS` for tier inference. */
|
||||
readonly tierHints?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export interface AsyncDiscoverOptions extends DiscoverOptions {
|
||||
/** Aborts the walk; the iterator throws `OVERVIEW.DISCOVERY_ABORTED`. */
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously walk `<root>/packages/<category>/<plugin>/<pkg>/package.json`
|
||||
* and return a fully-validated `SuavePlanManifest`.
|
||||
*
|
||||
* @throws OverviewError - `OVERVIEW.PARSE_FAILED` / `OVERVIEW.INVALID_RECORD`
|
||||
* when `onError === "throw"` and a row fails parsing.
|
||||
*/
|
||||
export function discoverPackages(options: DiscoverOptions): SuavePlanManifest {
|
||||
const start = nowMs();
|
||||
const onError = options.onError ?? "throw";
|
||||
const records: PackageRecord[] = [];
|
||||
const packagesRoot = join(options.root, "packages");
|
||||
if (existsSync(packagesRoot)) {
|
||||
for (const category of readdirSync(packagesRoot)) {
|
||||
const categoryPath = join(packagesRoot, category);
|
||||
if (!statSync(categoryPath).isDirectory()) continue;
|
||||
for (const plugin of readdirSync(categoryPath)) {
|
||||
const pluginPath = join(categoryPath, plugin);
|
||||
if (!statSync(pluginPath).isDirectory()) continue;
|
||||
for (const pkgDir of readdirSync(pluginPath)) {
|
||||
const pkgPath = join(pluginPath, pkgDir);
|
||||
if (!statSync(pkgPath).isDirectory()) continue;
|
||||
const rec = readRecord(
|
||||
pkgPath,
|
||||
category,
|
||||
plugin,
|
||||
pkgDir,
|
||||
options.tierHints,
|
||||
onError
|
||||
);
|
||||
if (rec !== undefined) records.push(rec);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
records.sort((a, b) => a.name.localeCompare(b.name));
|
||||
packagesDiscoveredTotal.add(records.length);
|
||||
discoverDurationMs.record(nowMs() - start);
|
||||
return assembleManifest(records);
|
||||
}
|
||||
|
||||
/**
|
||||
* AsyncGenerator variant that yields one `PackageRecord` at a time and
|
||||
* honours `AbortSignal`. Aborting causes the next `.next()` call to throw
|
||||
* `OVERVIEW.DISCOVERY_ABORTED`.
|
||||
*/
|
||||
export async function* discoverPackagesIter(
|
||||
options: AsyncDiscoverOptions
|
||||
): AsyncGenerator<PackageRecord> {
|
||||
const onError = options.onError ?? "throw";
|
||||
const packagesRoot = join(options.root, "packages");
|
||||
if (!existsSync(packagesRoot)) return;
|
||||
for (const category of readdirSync(packagesRoot)) {
|
||||
const categoryPath = join(packagesRoot, category);
|
||||
if (!statSync(categoryPath).isDirectory()) continue;
|
||||
for (const plugin of readdirSync(categoryPath)) {
|
||||
const pluginPath = join(categoryPath, plugin);
|
||||
if (!statSync(pluginPath).isDirectory()) continue;
|
||||
for (const pkgDir of readdirSync(pluginPath)) {
|
||||
if (options.signal?.aborted === true) {
|
||||
throw new OverviewError(
|
||||
OVERVIEW_CODES.DISCOVERY_ABORTED,
|
||||
"overview.discovery_aborted"
|
||||
);
|
||||
}
|
||||
const pkgPath = join(pluginPath, pkgDir);
|
||||
if (!statSync(pkgPath).isDirectory()) continue;
|
||||
const rec = readRecord(
|
||||
pkgPath,
|
||||
category,
|
||||
plugin,
|
||||
pkgDir,
|
||||
options.tierHints,
|
||||
onError
|
||||
);
|
||||
if (rec !== undefined) yield rec;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readRecord(
|
||||
pkgPath: string,
|
||||
category: string,
|
||||
plugin: string,
|
||||
dirName: string,
|
||||
tierHints: ReadonlySet<string> | undefined,
|
||||
onError: DiscoveryErrorMode
|
||||
): PackageRecord | undefined {
|
||||
const file = join(pkgPath, "package.json");
|
||||
if (!existsSync(file)) return undefined;
|
||||
let raw: RawPackageJson;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(file, "utf8")) as RawPackageJson;
|
||||
} catch (cause) {
|
||||
recordsRejectedTotal.add(1, { reason: "parse_failed" });
|
||||
if (onError === "throw") {
|
||||
throw new OverviewError(
|
||||
OVERVIEW_CODES.PARSE_FAILED,
|
||||
"overview.parse_failed",
|
||||
{
|
||||
cause: cause as Error,
|
||||
context: { file },
|
||||
}
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const name = raw.name ?? "";
|
||||
const short = name.startsWith("@suaveplan/")
|
||||
? name.slice("@suaveplan/".length)
|
||||
: name;
|
||||
const tier: Tier = inferTier(short, raw, tierHints);
|
||||
const workspaceDeps = collectWorkspaceDeps(raw);
|
||||
const candidate: PackageRecord = {
|
||||
name,
|
||||
short,
|
||||
category,
|
||||
plugin,
|
||||
dirName,
|
||||
version: raw.version ?? "",
|
||||
description: raw.description ?? "",
|
||||
tier,
|
||||
workspaceDeps,
|
||||
hasBin: raw.bin !== undefined,
|
||||
hasBrowserField: raw.browser !== undefined,
|
||||
};
|
||||
const parsed = PackageRecordSchema.safeParse(candidate);
|
||||
if (!parsed.success) {
|
||||
recordsRejectedTotal.add(1, { reason: "invalid_record" });
|
||||
if (onError === "throw") {
|
||||
throw new OverviewError(
|
||||
OVERVIEW_CODES.INVALID_RECORD,
|
||||
"overview.invalid_record",
|
||||
{ context: { file, issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function collectWorkspaceDeps(p: RawPackageJson): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const map of [
|
||||
p.dependencies,
|
||||
p.peerDependencies,
|
||||
p.optionalDependencies,
|
||||
]) {
|
||||
if (!map) continue;
|
||||
for (const [depName, range] of Object.entries(map)) {
|
||||
if (
|
||||
typeof range === "string" &&
|
||||
range.startsWith("workspace:") &&
|
||||
depName.startsWith("@suaveplan/")
|
||||
) {
|
||||
out.add(depName);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...out].sort();
|
||||
}
|
||||
|
||||
function assembleManifest(records: PackageRecord[]): SuavePlanManifest {
|
||||
const categories = [...new Set(records.map((r) => r.category))].sort();
|
||||
const tierCounts: Record<Tier, number> = {
|
||||
python: 0,
|
||||
r3f: 0,
|
||||
react: 0,
|
||||
browser: 0,
|
||||
server: 0,
|
||||
universal: 0,
|
||||
};
|
||||
for (const r of records) tierCounts[r.tier] += 1;
|
||||
const plugins = byPlugin(records);
|
||||
return { packages: records, plugins, categories, tierCounts };
|
||||
}
|
||||
|
||||
function nowMs(): number {
|
||||
return Date.now();
|
||||
}
|
||||
86
packages/devtools/tooling/overview/src/errors/errors.md
Normal file
86
packages/devtools/tooling/overview/src/errors/errors.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# `errors/errors.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable error-code registry and the concrete `OverviewError`
|
||||
subclass of `SuaveplanError`. Every public entry point in `@suaveplan/overview`
|
||||
that throws does so through this class, so callers can rely on a single
|
||||
`instanceof OverviewError` check and a literal-typed `code` field for
|
||||
discrimination.
|
||||
|
||||
The registry has six entries — one per failure mode that the package can
|
||||
detect — and the §2c "never delete spec elements to satisfy a gate" rule means
|
||||
each entry is exercised by a real call site somewhere in `src/`, asserted by
|
||||
the static test `every code has a real call site in src/`.
|
||||
|
||||
## Features
|
||||
|
||||
- Six stable codes registered through `defineCodes("OVERVIEW", [...])`
|
||||
- Frozen, fully-inferred code record — `OVERVIEW_CODES.PARSE_FAILED` is the
|
||||
literal `"OVERVIEW.PARSE_FAILED"`
|
||||
- `OverviewCode` closed union for exhaustive switch statements
|
||||
- `OverviewError` extends `SuaveplanError<OverviewCode>` so `instanceof`
|
||||
narrows `error.code`
|
||||
- Preserves cause chain, context, and trace-id propagation via
|
||||
`SuaveplanErrorOptions`
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { OverviewError, OVERVIEW_CODES } from "@suaveplan/overview";
|
||||
|
||||
throw new OverviewError(
|
||||
OVERVIEW_CODES.UNKNOWN_PACKAGE,
|
||||
"overview.unknown_package",
|
||||
{ context: { pkg: "@suaveplan/missing" } },
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { OverviewError } from "@suaveplan/overview";
|
||||
|
||||
try {
|
||||
// ...
|
||||
} catch (err) {
|
||||
if (err instanceof OverviewError) {
|
||||
switch (err.code) {
|
||||
case "OVERVIEW.CYCLE_DETECTED":
|
||||
// type-narrowed; safe to read err.context?.cycle
|
||||
break;
|
||||
case "OVERVIEW.PARSE_FAILED":
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
The exhaustive `switch` benefits from `OverviewCode` being a closed union —
|
||||
adding a new code without handling it in a downstream `switch` is a TypeScript
|
||||
error.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `OVERVIEW_CODES` — `Readonly<{PARSE_FAILED, INVALID_RECORD, CYCLE_DETECTED,
|
||||
UNKNOWN_PACKAGE, DISCOVERY_ABORTED, INVALID_TIER}>` with literal `OVERVIEW.<X>`
|
||||
values
|
||||
- `type OverviewCode` — closed union of every value in `OVERVIEW_CODES`
|
||||
- `class OverviewError extends SuaveplanError<OverviewCode>` — constructor
|
||||
`(code, messageKey, options?)`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `defineCodes` helper from `@suaveplan/error` freezes its return value so the
|
||||
registry cannot be mutated at runtime. `OverviewError` deliberately re-exposes
|
||||
the `protected` constructor of `SuaveplanError` as `public` (the language
|
||||
requires each subclass to do this explicitly) so callers can `new` an instance
|
||||
directly without a factory. The class is final by convention — sub-classing it
|
||||
would dilute `instanceof` narrowing.
|
||||
61
packages/devtools/tooling/overview/src/errors/errors.test.ts
Normal file
61
packages/devtools/tooling/overview/src/errors/errors.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { OVERVIEW_CODES, OverviewError } from "./errors.js";
|
||||
|
||||
describe("OVERVIEW_CODES registry", () => {
|
||||
it("every OVERVIEW code is unique", { timeout: 5000 }, () => {
|
||||
const values = Object.values(OVERVIEW_CODES);
|
||||
const unique = new Set(values);
|
||||
expect(unique.size).toBe(values.length);
|
||||
expect(values.length).toBe(6);
|
||||
for (const v of values) {
|
||||
expect(v.startsWith("OVERVIEW.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("instances carry the literal code and a cause chain", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("root");
|
||||
const err = new OverviewError(
|
||||
OVERVIEW_CODES.PARSE_FAILED,
|
||||
"overview.parse_failed",
|
||||
{ cause, context: { path: "/tmp/x" } }
|
||||
);
|
||||
expect(err).toBeInstanceOf(OverviewError);
|
||||
expect(err.code).toBe("OVERVIEW.PARSE_FAILED");
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context?.path).toBe("/tmp/x");
|
||||
});
|
||||
|
||||
it("every code has a real call site in src/", { timeout: 5000 }, () => {
|
||||
const root = join(__dirname, "..");
|
||||
const sources = collectTsFiles(root);
|
||||
const concat = sources
|
||||
.filter((f) => !f.endsWith(".test.ts"))
|
||||
.map((f) => readFileSync(f, "utf8"))
|
||||
.join("\n");
|
||||
for (const code of Object.values(OVERVIEW_CODES)) {
|
||||
const constant = `OVERVIEW_CODES.${code.slice("OVERVIEW.".length)}`;
|
||||
const directLiteral = `"${code}"`;
|
||||
const hit =
|
||||
concat.includes(constant) || concat.includes(directLiteral);
|
||||
expect(hit, `code ${code} must appear in src/`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function collectTsFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) {
|
||||
out.push(...collectTsFiles(full));
|
||||
continue;
|
||||
}
|
||||
if (full.endsWith(".ts")) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
45
packages/devtools/tooling/overview/src/errors/errors.ts
Normal file
45
packages/devtools/tooling/overview/src/errors/errors.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error code registry for `@suaveplan/overview`.
|
||||
*
|
||||
* Each code is reachable from a real call site in `src/`:
|
||||
*
|
||||
* - `OVERVIEW.PARSE_FAILED` — `discover` saw a `package.json` whose contents
|
||||
* failed `JSON.parse`.
|
||||
* - `OVERVIEW.INVALID_RECORD` — a parsed object failed Zod validation against
|
||||
* `PackageRecordSchema`.
|
||||
* - `OVERVIEW.CYCLE_DETECTED` — `topologicalOrder` ran into a cycle.
|
||||
* - `OVERVIEW.UNKNOWN_PACKAGE` — a graph query referenced a package not
|
||||
* present in the graph.
|
||||
* - `OVERVIEW.DISCOVERY_ABORTED` — `discoverPackagesIter` observed an aborted
|
||||
* `AbortSignal`.
|
||||
* - `OVERVIEW.INVALID_TIER` — a non-`Tier` string was passed somewhere that
|
||||
* needed a tier label (e.g. `byTier` initialiser given an extension).
|
||||
*/
|
||||
export const OVERVIEW_CODES = defineCodes("OVERVIEW", [
|
||||
"PARSE_FAILED",
|
||||
"INVALID_RECORD",
|
||||
"CYCLE_DETECTED",
|
||||
"UNKNOWN_PACKAGE",
|
||||
"DISCOVERY_ABORTED",
|
||||
"INVALID_TIER",
|
||||
] as const);
|
||||
|
||||
/** Closed union of every code emitted by the package. */
|
||||
export type OverviewCode = (typeof OVERVIEW_CODES)[keyof typeof OVERVIEW_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class. Carries the literal `code` union so `instanceof`
|
||||
* checks narrow to a typed code.
|
||||
*/
|
||||
export class OverviewError extends SuaveplanError<OverviewCode> {
|
||||
constructor(
|
||||
code: OverviewCode,
|
||||
messageKey: string,
|
||||
options: SuaveplanErrorOptions = {}
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
}
|
||||
}
|
||||
90
packages/devtools/tooling/overview/src/graph/graph.md
Normal file
90
packages/devtools/tooling/overview/src/graph/graph.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# `graph/graph.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the graph utilities that operate on a `WorkspaceGraph` built from a
|
||||
list of `PackageRecord`s. Covers the analyses every project tool eventually
|
||||
needs: build a forward / reverse adjacency map, compute a topological
|
||||
dependency-first order, detect cycles, gather transitive deps or dependents,
|
||||
and identify graph roots (no deps) and leaves (no dependents).
|
||||
|
||||
Edges where the target is not also a node are silently dropped at build time.
|
||||
This is the same posture as `scripts/folder-reorg/generate-manifest.ts` —
|
||||
necessary because Suaveplan migrates in waves and partial-manifest
|
||||
snapshots are routine.
|
||||
|
||||
## Features
|
||||
|
||||
- `buildGraph` — populates forward and reverse adjacency in one pass; emits
|
||||
`overview.graph_builds_total`
|
||||
- `topologicalOrder` — Kahn's algorithm with stable lexicographic tie-breaking;
|
||||
throws `OVERVIEW.CYCLE_DETECTED` on a cycle (error carries `context.cycle`)
|
||||
- `detectCycles` — Tarjan's SCC, returns every cycle as a sorted `string[]`
|
||||
- `transitiveDependencies` / `transitiveDependents` — BFS over the appropriate
|
||||
direction; throws `OVERVIEW.UNKNOWN_PACKAGE` for missing inputs
|
||||
- `leafPackages` — no dependents (no incoming edges)
|
||||
- `rootPackages` — no deps (no outgoing edges)
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildGraph, topologicalOrder } from "@suaveplan/overview";
|
||||
|
||||
const graph = buildGraph(records);
|
||||
const order = topologicalOrder(graph);
|
||||
// `order[0]` has no workspace deps; the last entry depends on everything.
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildGraph,
|
||||
detectCycles,
|
||||
transitiveDependencies,
|
||||
transitiveDependents,
|
||||
leafPackages,
|
||||
rootPackages,
|
||||
} from "@suaveplan/overview";
|
||||
|
||||
const graph = buildGraph(records);
|
||||
const cycles = detectCycles(graph);
|
||||
if (cycles.length > 0) {
|
||||
console.error("Cycles detected:", cycles);
|
||||
}
|
||||
|
||||
const blastRadius = transitiveDependents(graph, "@suaveplan/error");
|
||||
const supplyChain = transitiveDependencies(graph, "@suaveplan/api");
|
||||
const leaves = leafPackages(graph);
|
||||
const roots = rootPackages(graph);
|
||||
```
|
||||
|
||||
`topologicalOrder` is deterministic — two callers with the same graph see the
|
||||
same order — so it can drive reproducible build plans and CI fan-outs.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildGraph(records): WorkspaceGraph`
|
||||
- `topologicalOrder(graph): string[]` — throws `OVERVIEW.CYCLE_DETECTED`
|
||||
- `detectCycles(graph): string[][]`
|
||||
- `transitiveDependencies(graph, pkg): string[]` — throws
|
||||
`OVERVIEW.UNKNOWN_PACKAGE`
|
||||
- `transitiveDependents(graph, pkg): string[]` — throws
|
||||
`OVERVIEW.UNKNOWN_PACKAGE`
|
||||
- `leafPackages(graph): string[]`
|
||||
- `rootPackages(graph): string[]`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`topologicalOrder` initialises a remaining-out-degree counter per node and
|
||||
drains nodes with zero out-degree first; the queue is kept sorted on insertion
|
||||
so the output is stable across runs. `detectCycles` uses a recursive
|
||||
Tarjan implementation — the workspace size is bounded (hundreds of packages),
|
||||
so the stack-depth budget is comfortable. BFS in `transitiveDependencies` /
|
||||
`transitiveDependents` uses an array-based queue with `Array.prototype.shift`
|
||||
— acceptable at workspace scale; if the package ever has to handle tens of
|
||||
thousands of nodes the queue can be swapped for a head/tail index pair.
|
||||
297
packages/devtools/tooling/overview/src/graph/graph.test.ts
Normal file
297
packages/devtools/tooling/overview/src/graph/graph.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { OverviewError } from "../errors/errors.js";
|
||||
import type { PackageRecord } from "../types/types.js";
|
||||
import {
|
||||
buildGraph,
|
||||
detectCycles,
|
||||
leafPackages,
|
||||
rootPackages,
|
||||
topologicalOrder,
|
||||
transitiveDependencies,
|
||||
transitiveDependents,
|
||||
} from "./graph.js";
|
||||
|
||||
function rec(name: string, deps: readonly string[] = []): PackageRecord {
|
||||
return {
|
||||
name: `@suaveplan/${name}`,
|
||||
short: name,
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
dirName: name,
|
||||
version: "0.1.0",
|
||||
description: "",
|
||||
tier: "universal",
|
||||
workspaceDeps: deps.map((d) => `@suaveplan/${d}`),
|
||||
hasBin: false,
|
||||
hasBrowserField: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildGraph", () => {
|
||||
it("drops edges with unknown targets", { timeout: 5000 }, () => {
|
||||
const g = buildGraph([rec("a", ["b", "missing"]), rec("b")]);
|
||||
expect([...(g.forward.get("@suaveplan/a") ?? [])]).toEqual([
|
||||
"@suaveplan/b",
|
||||
]);
|
||||
expect(g.nodes.has("@suaveplan/missing")).toBe(false);
|
||||
});
|
||||
|
||||
it("builds reverse adjacency", { timeout: 5000 }, () => {
|
||||
const g = buildGraph([rec("a", ["b"]), rec("b")]);
|
||||
expect([...(g.reverse.get("@suaveplan/b") ?? [])]).toEqual([
|
||||
"@suaveplan/a",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("topologicalOrder", () => {
|
||||
it("respects dependency direction", { timeout: 5000 }, () => {
|
||||
// a -> b -> c
|
||||
const g = buildGraph([rec("a", ["b"]), rec("b", ["c"]), rec("c")]);
|
||||
const order = topologicalOrder(g);
|
||||
expect(order).toEqual(["@suaveplan/c", "@suaveplan/b", "@suaveplan/a"]);
|
||||
});
|
||||
|
||||
it("is stable on ties (lexicographic)", { timeout: 5000 }, () => {
|
||||
const g = buildGraph([rec("a"), rec("b")]);
|
||||
const order = topologicalOrder(g);
|
||||
expect(order).toEqual(["@suaveplan/a", "@suaveplan/b"]);
|
||||
});
|
||||
|
||||
it("throws CYCLE_DETECTED on a cycle", { timeout: 5000 }, () => {
|
||||
// a -> b -> a
|
||||
const g = buildGraph([rec("a", ["b"]), rec("b", ["a"])]);
|
||||
try {
|
||||
topologicalOrder(g);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OverviewError);
|
||||
if (err instanceof OverviewError) {
|
||||
expect(err.code).toBe("OVERVIEW.CYCLE_DETECTED");
|
||||
expect(err.context?.cycle).toContain("@suaveplan/a");
|
||||
expect(err.context?.cycle).toContain("@suaveplan/b");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic on random DAGs (property)", { timeout: 10000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc
|
||||
.uniqueArray(
|
||||
fc
|
||||
.string({ minLength: 1, maxLength: 5 })
|
||||
.filter((s) => /^[a-z]+$/.test(s)),
|
||||
{ minLength: 2, maxLength: 8 }
|
||||
)
|
||||
.chain((names) => {
|
||||
// Build edges only from lower-index to higher-index
|
||||
// names → guaranteed DAG.
|
||||
return fc.tuple(
|
||||
fc.constant(names),
|
||||
fc.array(
|
||||
fc.tuple(
|
||||
fc.integer({
|
||||
min: 0,
|
||||
max: names.length - 1,
|
||||
}),
|
||||
fc.integer({
|
||||
min: 0,
|
||||
max: names.length - 1,
|
||||
})
|
||||
),
|
||||
{ maxLength: 10 }
|
||||
)
|
||||
);
|
||||
}),
|
||||
([names, edges]) => {
|
||||
const records = names.map((n) => {
|
||||
const deps = edges
|
||||
.filter(
|
||||
([from, to]) => from < to && names[from] === n
|
||||
)
|
||||
.map(([, to]) => names[to]);
|
||||
return rec(n, deps);
|
||||
});
|
||||
const graph = buildGraph(records);
|
||||
const order = topologicalOrder(graph);
|
||||
const position = new Map(order.map((n, i) => [n, i]));
|
||||
for (const r of records) {
|
||||
for (const dep of r.workspaceDeps) {
|
||||
if (!graph.nodes.has(dep)) continue;
|
||||
expect(
|
||||
(position.get(dep) as number) <
|
||||
(position.get(r.name) as number)
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 25 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectCycles", () => {
|
||||
it("returns [] on an acyclic graph", { timeout: 5000 }, () => {
|
||||
const g = buildGraph([rec("a", ["b"]), rec("b")]);
|
||||
expect(detectCycles(g)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns the SCC for a 2-cycle", { timeout: 5000 }, () => {
|
||||
const g = buildGraph([rec("a", ["b"]), rec("b", ["a"])]);
|
||||
const cycles = detectCycles(g);
|
||||
expect(cycles.length).toBe(1);
|
||||
expect(cycles[0]).toEqual(["@suaveplan/a", "@suaveplan/b"]);
|
||||
});
|
||||
|
||||
it("returns multiple SCCs sorted by leader", { timeout: 5000 }, () => {
|
||||
// a<->b plus c<->d
|
||||
const g = buildGraph([
|
||||
rec("a", ["b"]),
|
||||
rec("b", ["a"]),
|
||||
rec("c", ["d"]),
|
||||
rec("d", ["c"]),
|
||||
]);
|
||||
const cycles = detectCycles(g);
|
||||
expect(cycles.length).toBe(2);
|
||||
expect(cycles[0][0]).toBe("@suaveplan/a");
|
||||
expect(cycles[1][0]).toBe("@suaveplan/c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transitive closures", () => {
|
||||
it("transitiveDependencies BFS closure", { timeout: 5000 }, () => {
|
||||
// a -> b, b -> c, b -> d
|
||||
const g = buildGraph([
|
||||
rec("a", ["b"]),
|
||||
rec("b", ["c", "d"]),
|
||||
rec("c"),
|
||||
rec("d"),
|
||||
]);
|
||||
expect(transitiveDependencies(g, "@suaveplan/a")).toEqual([
|
||||
"@suaveplan/b",
|
||||
"@suaveplan/c",
|
||||
"@suaveplan/d",
|
||||
]);
|
||||
});
|
||||
|
||||
it("transitiveDependents BFS closure", { timeout: 5000 }, () => {
|
||||
const g = buildGraph([rec("a", ["b"]), rec("b", ["c"]), rec("c")]);
|
||||
expect(transitiveDependents(g, "@suaveplan/c")).toEqual([
|
||||
"@suaveplan/a",
|
||||
"@suaveplan/b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("transitiveDependencies throws UNKNOWN_PACKAGE", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const g = buildGraph([rec("a")]);
|
||||
try {
|
||||
transitiveDependencies(g, "@suaveplan/missing");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OverviewError);
|
||||
if (err instanceof OverviewError) {
|
||||
expect(err.code).toBe("OVERVIEW.UNKNOWN_PACKAGE");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("transitiveDependents throws UNKNOWN_PACKAGE", { timeout: 5000 }, () => {
|
||||
const g = buildGraph([rec("a")]);
|
||||
try {
|
||||
transitiveDependents(g, "@suaveplan/missing");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OverviewError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("leaves and roots", () => {
|
||||
it("leaves and roots on a star", { timeout: 5000 }, () => {
|
||||
// hub depends on a, b, c
|
||||
const g = buildGraph([
|
||||
rec("hub", ["a", "b", "c"]),
|
||||
rec("a"),
|
||||
rec("b"),
|
||||
rec("c"),
|
||||
]);
|
||||
// dependents direction roots: hub (no dependents)
|
||||
expect(leafPackages(g)).toEqual(["@suaveplan/hub"]);
|
||||
// dependency direction roots: a, b, c (no deps)
|
||||
expect(rootPackages(g)).toEqual([
|
||||
"@suaveplan/a",
|
||||
"@suaveplan/b",
|
||||
"@suaveplan/c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("transitiveDependencies handles diamond graphs (revisit dedup)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// a -> b, a -> c, b -> d, c -> d (diamond)
|
||||
const g = buildGraph([
|
||||
rec("a", ["b", "c"]),
|
||||
rec("b", ["d"]),
|
||||
rec("c", ["d"]),
|
||||
rec("d"),
|
||||
]);
|
||||
expect(transitiveDependencies(g, "@suaveplan/a")).toEqual([
|
||||
"@suaveplan/b",
|
||||
"@suaveplan/c",
|
||||
"@suaveplan/d",
|
||||
]);
|
||||
});
|
||||
|
||||
it("detectCycles handles cross-edges to a finished SCC", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// a -> b, a -> c, c -> b. When a iterates its edges (a->b
|
||||
// then a->c after recursion, or in some order) we encounter a
|
||||
// node that is already indexed and NOT on the stack — the
|
||||
// else branch of the inner `else if (onStack.has(w))`.
|
||||
const g = buildGraph([rec("a", ["b", "c"]), rec("b"), rec("c", ["b"])]);
|
||||
expect(detectCycles(g)).toEqual([]);
|
||||
});
|
||||
|
||||
it("detectCycles handles back-edges to an already-indexed node", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// a -> b, b -> c, c -> a (3-cycle exercising the on-stack
|
||||
// back-edge branch of Tarjan)
|
||||
const g = buildGraph([
|
||||
rec("a", ["b"]),
|
||||
rec("b", ["c"]),
|
||||
rec("c", ["a"]),
|
||||
]);
|
||||
const cycles = detectCycles(g);
|
||||
expect(cycles.length).toBe(1);
|
||||
expect(cycles[0]).toEqual([
|
||||
"@suaveplan/a",
|
||||
"@suaveplan/b",
|
||||
"@suaveplan/c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("tolerates a sparse WorkspaceGraph (missing adjacency keys)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Hand-craft a graph where `forward`/`reverse` don't carry an
|
||||
// entry for every node — exercises the getSet undefined branch.
|
||||
const graph = {
|
||||
nodes: new Set(["@suaveplan/a"]),
|
||||
forward: new Map<string, Set<string>>(),
|
||||
reverse: new Map<string, Set<string>>(),
|
||||
};
|
||||
expect(rootPackages(graph)).toEqual(["@suaveplan/a"]);
|
||||
expect(leafPackages(graph)).toEqual(["@suaveplan/a"]);
|
||||
expect(topologicalOrder(graph)).toEqual(["@suaveplan/a"]);
|
||||
expect(detectCycles(graph)).toEqual([]);
|
||||
expect(transitiveDependencies(graph, "@suaveplan/a")).toEqual([]);
|
||||
expect(transitiveDependents(graph, "@suaveplan/a")).toEqual([]);
|
||||
});
|
||||
});
|
||||
225
packages/devtools/tooling/overview/src/graph/graph.ts
Normal file
225
packages/devtools/tooling/overview/src/graph/graph.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { OVERVIEW_CODES, OverviewError } from "../errors/errors.js";
|
||||
import { graphBuildsTotal } from "../telemetry/telemetry.js";
|
||||
import type { PackageRecord, WorkspaceGraph } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Build a `WorkspaceGraph` from a list of records. Edges where the target
|
||||
* is not in the node set are silently dropped — this matches the discovery
|
||||
* behaviour of `scripts/folder-reorg/generate-manifest.ts` so partial-
|
||||
* migration manifests stay self-consistent.
|
||||
*
|
||||
* Emits `overview.graph_builds_total`.
|
||||
*/
|
||||
export function buildGraph(records: readonly PackageRecord[]): WorkspaceGraph {
|
||||
const nodes = new Set<string>();
|
||||
const forward = new Map<string, Set<string>>();
|
||||
const reverse = new Map<string, Set<string>>();
|
||||
for (const r of records) {
|
||||
nodes.add(r.name);
|
||||
ensureSet(forward, r.name);
|
||||
ensureSet(reverse, r.name);
|
||||
}
|
||||
for (const r of records) {
|
||||
for (const dep of r.workspaceDeps) {
|
||||
if (!nodes.has(dep)) continue;
|
||||
ensureSet(forward, r.name).add(dep);
|
||||
ensureSet(reverse, dep).add(r.name);
|
||||
}
|
||||
}
|
||||
graphBuildsTotal.add(1);
|
||||
return { nodes, forward, reverse };
|
||||
}
|
||||
|
||||
/**
|
||||
* Topological order using Kahn's algorithm. Returns packages in
|
||||
* **dep-first** order: a node appears after its workspace dependencies
|
||||
* (so the very last node has no dependencies of its own).
|
||||
*
|
||||
* Throws `OVERVIEW.CYCLE_DETECTED` when a cycle is present; the error's
|
||||
* `context.cycle` lists the nodes that could not be ordered.
|
||||
*/
|
||||
export function topologicalOrder(graph: WorkspaceGraph): string[] {
|
||||
const remaining = new Map<string, number>();
|
||||
for (const node of graph.nodes) {
|
||||
remaining.set(node, getSet(graph.forward, node).size);
|
||||
}
|
||||
const queue: string[] = [];
|
||||
for (const [node, count] of remaining) {
|
||||
if (count === 0) queue.push(node);
|
||||
}
|
||||
queue.sort();
|
||||
|
||||
const order: string[] = [];
|
||||
while (queue.length > 0) {
|
||||
const node = queue.shift() as string;
|
||||
order.push(node);
|
||||
const dependents = getSet(graph.reverse, node);
|
||||
const batch: string[] = [];
|
||||
for (const dependent of dependents) {
|
||||
const next = (remaining.get(dependent) as number) - 1;
|
||||
remaining.set(dependent, next);
|
||||
if (next === 0) batch.push(dependent);
|
||||
}
|
||||
batch.sort();
|
||||
for (const b of batch) queue.push(b);
|
||||
}
|
||||
|
||||
if (order.length !== graph.nodes.size) {
|
||||
const cycle = [...graph.nodes].filter(
|
||||
(n) => (remaining.get(n) as number) > 0
|
||||
);
|
||||
throw new OverviewError(
|
||||
OVERVIEW_CODES.CYCLE_DETECTED,
|
||||
"overview.cycle_detected",
|
||||
{ context: { cycle } }
|
||||
);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect strongly-connected components larger than one node (Tarjan).
|
||||
* Returns an array of cycle arrays — empty when the graph is acyclic.
|
||||
*/
|
||||
export function detectCycles(graph: WorkspaceGraph): string[][] {
|
||||
const index = new Map<string, number>();
|
||||
const lowlink = new Map<string, number>();
|
||||
const onStack = new Set<string>();
|
||||
const stack: string[] = [];
|
||||
const result: string[][] = [];
|
||||
let counter = 0;
|
||||
|
||||
const strongConnect = (v: string): void => {
|
||||
index.set(v, counter);
|
||||
lowlink.set(v, counter);
|
||||
counter += 1;
|
||||
stack.push(v);
|
||||
onStack.add(v);
|
||||
|
||||
for (const w of getSet(graph.forward, v)) {
|
||||
if (!index.has(w)) {
|
||||
strongConnect(w);
|
||||
lowlink.set(
|
||||
v,
|
||||
Math.min(lowlink.get(v) as number, lowlink.get(w) as number)
|
||||
);
|
||||
} else if (onStack.has(w)) {
|
||||
lowlink.set(
|
||||
v,
|
||||
Math.min(lowlink.get(v) as number, index.get(w) as number)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (lowlink.get(v) === index.get(v)) {
|
||||
const component: string[] = [];
|
||||
for (;;) {
|
||||
const w = stack.pop() as string;
|
||||
onStack.delete(w);
|
||||
component.push(w);
|
||||
if (w === v) break;
|
||||
}
|
||||
if (component.length > 1) result.push(component.sort());
|
||||
}
|
||||
};
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
if (!index.has(node)) strongConnect(node);
|
||||
}
|
||||
return result.sort((a, b) =>
|
||||
(a[0] as string).localeCompare(b[0] as string)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorted transitive workspace dependencies of `pkg`. Throws
|
||||
* `OVERVIEW.UNKNOWN_PACKAGE` when `pkg` is not in the graph.
|
||||
*/
|
||||
export function transitiveDependencies(
|
||||
graph: WorkspaceGraph,
|
||||
pkg: string
|
||||
): string[] {
|
||||
ensureKnown(graph, pkg);
|
||||
return bfs(graph.forward, pkg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorted transitive dependents of `pkg`. Throws
|
||||
* `OVERVIEW.UNKNOWN_PACKAGE` when `pkg` is not in the graph.
|
||||
*/
|
||||
export function transitiveDependents(
|
||||
graph: WorkspaceGraph,
|
||||
pkg: string
|
||||
): string[] {
|
||||
ensureKnown(graph, pkg);
|
||||
return bfs(graph.reverse, pkg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorted list of packages with no dependents (nothing else imports them).
|
||||
* Roots of the dependent-direction DAG.
|
||||
*/
|
||||
export function leafPackages(graph: WorkspaceGraph): string[] {
|
||||
const out: string[] = [];
|
||||
for (const node of graph.nodes) {
|
||||
if (getSet(graph.reverse, node).size === 0) out.push(node);
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorted list of packages with no workspace deps. Roots of the
|
||||
* dependency-direction DAG.
|
||||
*/
|
||||
export function rootPackages(graph: WorkspaceGraph): string[] {
|
||||
const out: string[] = [];
|
||||
for (const node of graph.nodes) {
|
||||
if (getSet(graph.forward, node).size === 0) out.push(node);
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
function ensureKnown(graph: WorkspaceGraph, pkg: string): void {
|
||||
if (graph.nodes.has(pkg)) return;
|
||||
throw new OverviewError(
|
||||
OVERVIEW_CODES.UNKNOWN_PACKAGE,
|
||||
"overview.unknown_package",
|
||||
{ context: { pkg } }
|
||||
);
|
||||
}
|
||||
|
||||
function bfs(
|
||||
adjacency: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
start: string
|
||||
): string[] {
|
||||
const seen = new Set<string>();
|
||||
const queue: string[] = [start];
|
||||
while (queue.length > 0) {
|
||||
const node = queue.shift() as string;
|
||||
for (const neighbour of getSet(adjacency, node)) {
|
||||
if (seen.has(neighbour)) continue;
|
||||
seen.add(neighbour);
|
||||
queue.push(neighbour);
|
||||
}
|
||||
}
|
||||
return [...seen].sort();
|
||||
}
|
||||
|
||||
function ensureSet(map: Map<string, Set<string>>, key: string): Set<string> {
|
||||
let s = map.get(key);
|
||||
if (s === undefined) {
|
||||
s = new Set<string>();
|
||||
map.set(key, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function getSet(
|
||||
map: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
key: string
|
||||
): ReadonlySet<string> {
|
||||
const s = map.get(key);
|
||||
return s === undefined ? EMPTY_SET : s;
|
||||
}
|
||||
|
||||
const EMPTY_SET: ReadonlySet<string> = new Set<string>();
|
||||
52
packages/devtools/tooling/overview/src/index.ts
Normal file
52
packages/devtools/tooling/overview/src/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Errors
|
||||
export {
|
||||
OVERVIEW_CODES,
|
||||
type OverviewCode,
|
||||
OverviewError,
|
||||
} from "./errors/errors.js";
|
||||
// Graph
|
||||
export {
|
||||
buildGraph,
|
||||
detectCycles,
|
||||
leafPackages,
|
||||
rootPackages,
|
||||
topologicalOrder,
|
||||
transitiveDependencies,
|
||||
transitiveDependents,
|
||||
} from "./graph/graph.js";
|
||||
// Rollups
|
||||
export { byCategory, byPlugin, byTier } from "./rollups/rollups.js";
|
||||
// Schemas
|
||||
export {
|
||||
SuavePlanManifestSchema,
|
||||
PackageRecordSchema,
|
||||
PluginSummarySchema,
|
||||
parseTier,
|
||||
TierSchema,
|
||||
} from "./schemas/schemas.js";
|
||||
// Telemetry instruments (re-exported so consumers can read them without
|
||||
// reaching into the sub-path)
|
||||
export {
|
||||
discoverDurationMs,
|
||||
graphBuildsTotal,
|
||||
packagesDiscoveredTotal,
|
||||
type RejectionReason,
|
||||
recordsRejectedTotal,
|
||||
} from "./telemetry/telemetry.js";
|
||||
// Tier
|
||||
export { inferTier, SERVER_DEP_HINTS } from "./tier/tier.js";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
SuavePlanManifest,
|
||||
PackageRecord,
|
||||
PluginSummary,
|
||||
RawPackageJson,
|
||||
Tier,
|
||||
WorkspaceGraph,
|
||||
} from "./types/types.js";
|
||||
export { TIERS } from "./types/types.js";
|
||||
|
||||
// Note: discoverPackages + discoverPackagesIter live behind the
|
||||
// `@suaveplan/overview/discover` sub-path export so the root entry stays
|
||||
// universal (no `node:fs` imports).
|
||||
62
packages/devtools/tooling/overview/src/rollups/rollups.md
Normal file
62
packages/devtools/tooling/overview/src/rollups/rollups.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# `rollups/rollups.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the three aggregate views over a `PackageRecord[]` that the manifest
|
||||
needs: plugin roll-ups (one row per `(category, plugin)`), category counts
|
||||
(simple histogram), and tier bucketing (every tier key populated, even when
|
||||
empty). These are pure functions with no side effects, so callers can run them
|
||||
repeatedly without consulting the disk.
|
||||
|
||||
The roll-ups mirror the data that the human-readable `MANIFEST.md` table
|
||||
produces, but typed: downstream tooling can consume them directly instead of
|
||||
re-parsing the Markdown.
|
||||
|
||||
## Features
|
||||
|
||||
- `byPlugin` — sorted `PluginSummary[]`
|
||||
- `byCategory` — `Record<string, number>` of per-category package counts
|
||||
- `byTier` — `Record<Tier, PackageRecord[]>` with every tier key present
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { byPlugin, byCategory, byTier } from "@suaveplan/overview";
|
||||
|
||||
const plugins = byPlugin(records);
|
||||
const counts = byCategory(records);
|
||||
const tiered = byTier(records);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { byTier, TIERS } from "@suaveplan/overview";
|
||||
|
||||
const buckets = byTier(records);
|
||||
for (const tier of TIERS) {
|
||||
console.log(`${tier}: ${buckets[tier].length}`);
|
||||
}
|
||||
```
|
||||
|
||||
The `byTier` output always has every tier key, so downstream code never has to
|
||||
guard against missing keys — a tier with zero packages simply maps to `[]`.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `byPlugin(records): PluginSummary[]` — sorted by `"<category>/<plugin>"`
|
||||
- `byCategory(records): Record<string, number>`
|
||||
- `byTier(records): Record<Tier, PackageRecord[]>` — covers all six tier keys
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`byPlugin` accumulates into a `Map` keyed by `"<category>/<plugin>"`, then
|
||||
sorts by key on emit. `byTier` initialises the output via
|
||||
`Object.fromEntries(TIERS.map(...))` so the result has a stable insertion
|
||||
order matching `TIERS`. None of the helpers emit telemetry on their own — they
|
||||
are pure transformations; instrumentation is the caller's responsibility if
|
||||
needed.
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { PackageRecord, Tier } from "../types/types.js";
|
||||
import { byCategory, byPlugin, byTier } from "./rollups.js";
|
||||
|
||||
function rec(
|
||||
name: string,
|
||||
category: string,
|
||||
plugin: string,
|
||||
tier: Tier,
|
||||
deps: readonly string[] = []
|
||||
): PackageRecord {
|
||||
return {
|
||||
name: `@suaveplan/${name}`,
|
||||
short: name,
|
||||
category,
|
||||
plugin,
|
||||
dirName: name,
|
||||
version: "0.1.0",
|
||||
description: "",
|
||||
tier,
|
||||
workspaceDeps: deps,
|
||||
hasBin: false,
|
||||
hasBrowserField: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("rollups", () => {
|
||||
it("byPlugin aggregates packages + workspace deps", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const records = [
|
||||
rec("a", "foundation", "core", "universal", [
|
||||
"@suaveplan/x",
|
||||
"@suaveplan/y",
|
||||
]),
|
||||
rec("b", "foundation", "core", "universal", [
|
||||
"@suaveplan/x",
|
||||
"@suaveplan/y",
|
||||
"@suaveplan/z",
|
||||
]),
|
||||
rec("c", "media", "audio", "universal", ["@suaveplan/x"]),
|
||||
];
|
||||
const out = byPlugin(records);
|
||||
expect(out).toEqual([
|
||||
{
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
packages: 2,
|
||||
workspaceDeps: 5,
|
||||
},
|
||||
{
|
||||
category: "media",
|
||||
plugin: "audio",
|
||||
packages: 1,
|
||||
workspaceDeps: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("byCategory counts packages", { timeout: 5000 }, () => {
|
||||
const records = [
|
||||
rec("a", "foundation", "core", "universal"),
|
||||
rec("b", "foundation", "core", "universal"),
|
||||
rec("c", "media", "audio", "universal"),
|
||||
];
|
||||
expect(byCategory(records)).toEqual({ foundation: 2, media: 1 });
|
||||
});
|
||||
|
||||
it("byTier covers every Tier key", { timeout: 5000 }, () => {
|
||||
const out = byTier([]);
|
||||
expect(out.universal).toEqual([]);
|
||||
expect(out.browser).toEqual([]);
|
||||
expect(out.react).toEqual([]);
|
||||
expect(out.r3f).toEqual([]);
|
||||
expect(out.server).toEqual([]);
|
||||
expect(out.python).toEqual([]);
|
||||
});
|
||||
|
||||
it("byTier buckets records", { timeout: 5000 }, () => {
|
||||
const records = [
|
||||
rec("a", "foundation", "core", "universal"),
|
||||
rec("b", "ui", "react", "react"),
|
||||
];
|
||||
const out = byTier(records);
|
||||
expect(out.universal.length).toBe(1);
|
||||
expect(out.react.length).toBe(1);
|
||||
});
|
||||
});
|
||||
67
packages/devtools/tooling/overview/src/rollups/rollups.ts
Normal file
67
packages/devtools/tooling/overview/src/rollups/rollups.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
type PackageRecord,
|
||||
type PluginSummary,
|
||||
TIERS,
|
||||
type Tier,
|
||||
} from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Group records by `(category, plugin)` and emit one `PluginSummary` per pair,
|
||||
* sorted by `"<category>/<plugin>"`. The summary carries the package count and
|
||||
* the sum of outgoing workspace edges.
|
||||
*/
|
||||
export function byPlugin(records: readonly PackageRecord[]): PluginSummary[] {
|
||||
const acc = new Map<
|
||||
string,
|
||||
{
|
||||
category: string;
|
||||
plugin: string;
|
||||
packages: number;
|
||||
workspaceDeps: number;
|
||||
}
|
||||
>();
|
||||
for (const r of records) {
|
||||
const key = `${r.category}/${r.plugin}`;
|
||||
const cur = acc.get(key) ?? {
|
||||
category: r.category,
|
||||
plugin: r.plugin,
|
||||
packages: 0,
|
||||
workspaceDeps: 0,
|
||||
};
|
||||
cur.packages += 1;
|
||||
cur.workspaceDeps += r.workspaceDeps.length;
|
||||
acc.set(key, cur);
|
||||
}
|
||||
return [...acc.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([, v]) => v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `category → package count` rolled up across every record.
|
||||
*/
|
||||
export function byCategory(
|
||||
records: readonly PackageRecord[]
|
||||
): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const r of records) {
|
||||
out[r.category] = (out[r.category] ?? 0) + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket records by tier. Every tier key is present in the returned record,
|
||||
* even when the input has no rows for that tier — values default to `[]`.
|
||||
*/
|
||||
export function byTier(
|
||||
records: readonly PackageRecord[]
|
||||
): Record<Tier, PackageRecord[]> {
|
||||
const out = Object.fromEntries(
|
||||
TIERS.map((t) => [t, [] as PackageRecord[]])
|
||||
) as Record<Tier, PackageRecord[]>;
|
||||
for (const r of records) {
|
||||
out[r.tier].push(r);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
68
packages/devtools/tooling/overview/src/schemas/schemas.md
Normal file
68
packages/devtools/tooling/overview/src/schemas/schemas.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# `schemas/schemas.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides Zod schemas (and one helper, `parseTier`) for every public shape that
|
||||
`@suaveplan/overview` produces or accepts. The schemas are the authoritative
|
||||
runtime gate: any `PackageRecord` that does not parse against
|
||||
`PackageRecordSchema` is rejected by `discoverPackages` with
|
||||
`OVERVIEW.INVALID_RECORD`, and `parseTier` converts an unknown string into a
|
||||
typed `Tier` or throws `OVERVIEW.INVALID_TIER`.
|
||||
|
||||
The schemas are kept strict (`.strict()`) so extra fields are rejected — a
|
||||
downstream consumer that wants to attach extension data must clone the record
|
||||
into its own shape rather than smuggle properties through the manifest.
|
||||
|
||||
## Features
|
||||
|
||||
- `TierSchema` — the closed `Tier` enum
|
||||
- `PackageRecordSchema` — every required field, validated with `.strict()`
|
||||
- `PluginSummarySchema` — for plugin roll-ups
|
||||
- `SuavePlanManifestSchema` — the full snapshot, including all six tier-count
|
||||
keys as required numbers
|
||||
- `parseTier` — convenience wrapper that throws an `OverviewError` instead of
|
||||
returning a Zod `safeParse` failure
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PackageRecordSchema } from "@suaveplan/overview";
|
||||
|
||||
const r = PackageRecordSchema.parse(rawRow);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SuavePlanManifestSchema, parseTier } from "@suaveplan/overview";
|
||||
|
||||
const manifest = SuavePlanManifestSchema.parse(jsonFromDisk);
|
||||
const userInput: unknown = process.env.OVERVIEW_TIER;
|
||||
const tier = parseTier(userInput); // throws OVERVIEW.INVALID_TIER on bad input
|
||||
```
|
||||
|
||||
The strict schemas allow consumers to safely round-trip a manifest through JSON
|
||||
without losing fields or accidentally importing arbitrary extensions: a malformed
|
||||
JSON payload simply fails the parse.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `TierSchema: z.ZodType<Tier>`
|
||||
- `PackageRecordSchema: z.ZodObject<...>` (strict, eleven fields)
|
||||
- `PluginSummarySchema: z.ZodObject<...>` (strict, four fields)
|
||||
- `SuavePlanManifestSchema: z.ZodObject<...>` (strict, four fields)
|
||||
- `parseTier(input: unknown): Tier` — throws `OverviewError`
|
||||
(`OVERVIEW.INVALID_TIER`) on failure
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schemas use `z.enum(TIERS as readonly [Tier, ...Tier[]])` so the source of
|
||||
truth is the `TIERS` tuple in `types/types.ts`; adding a new tier requires
|
||||
updating that tuple (and the `tierCounts` shape) — both compile-time changes
|
||||
that the test suite catches. `parseTier` deliberately surfaces a package-shaped
|
||||
error rather than re-throwing the Zod `ZodError` so call sites only ever deal
|
||||
with one error class.
|
||||
147
packages/devtools/tooling/overview/src/schemas/schemas.test.ts
Normal file
147
packages/devtools/tooling/overview/src/schemas/schemas.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { OverviewError } from "../errors/errors.js";
|
||||
import {
|
||||
SuavePlanManifestSchema,
|
||||
PackageRecordSchema,
|
||||
PluginSummarySchema,
|
||||
parseTier,
|
||||
TierSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
const validRecord = {
|
||||
name: "@suaveplan/x",
|
||||
short: "x",
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
dirName: "x",
|
||||
version: "0.1.0",
|
||||
description: "",
|
||||
tier: "universal" as const,
|
||||
workspaceDeps: [] as readonly string[],
|
||||
hasBin: false,
|
||||
hasBrowserField: false,
|
||||
};
|
||||
|
||||
describe("schemas", () => {
|
||||
it("TierSchema accepts every tier", { timeout: 5000 }, () => {
|
||||
for (const t of [
|
||||
"python",
|
||||
"r3f",
|
||||
"react",
|
||||
"browser",
|
||||
"server",
|
||||
"universal",
|
||||
]) {
|
||||
expect(TierSchema.safeParse(t).success).toBe(true);
|
||||
}
|
||||
expect(TierSchema.safeParse("rust").success).toBe(false);
|
||||
});
|
||||
|
||||
it("PackageRecordSchema rejects bad input per field", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(PackageRecordSchema.safeParse(validRecord).success).toBe(true);
|
||||
const baseline = validRecord;
|
||||
const mutations: ReadonlyArray<Partial<typeof validRecord>> = [
|
||||
{ name: "no-prefix" },
|
||||
{ name: "" },
|
||||
{ short: "" },
|
||||
{ category: "" },
|
||||
{ plugin: "" },
|
||||
{ dirName: "" },
|
||||
{ version: "" },
|
||||
{ tier: "rust" as never },
|
||||
{
|
||||
workspaceDeps: ["foo"] as unknown as readonly string[],
|
||||
},
|
||||
{ hasBin: "yes" as never },
|
||||
{ hasBrowserField: "yes" as never },
|
||||
];
|
||||
for (const m of mutations) {
|
||||
const res = PackageRecordSchema.safeParse({ ...baseline, ...m });
|
||||
expect(res.success).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("PackageRecordSchema rejects unknown fields (strict)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = PackageRecordSchema.safeParse({
|
||||
...validRecord,
|
||||
extra: "field",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("PluginSummarySchema accepts valid rows", { timeout: 5000 }, () => {
|
||||
const ok = PluginSummarySchema.safeParse({
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
packages: 3,
|
||||
workspaceDeps: 5,
|
||||
});
|
||||
expect(ok.success).toBe(true);
|
||||
expect(
|
||||
PluginSummarySchema.safeParse({
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
packages: -1,
|
||||
workspaceDeps: 0,
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("SuavePlanManifestSchema accepts a populated snapshot", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ok = SuavePlanManifestSchema.safeParse({
|
||||
packages: [validRecord],
|
||||
plugins: [
|
||||
{
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
packages: 1,
|
||||
workspaceDeps: 0,
|
||||
},
|
||||
],
|
||||
categories: ["foundation"],
|
||||
tierCounts: {
|
||||
python: 0,
|
||||
r3f: 0,
|
||||
react: 0,
|
||||
browser: 0,
|
||||
server: 0,
|
||||
universal: 1,
|
||||
},
|
||||
});
|
||||
expect(ok.success).toBe(true);
|
||||
});
|
||||
|
||||
it("SuavePlanManifestSchema rejects missing tier key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = SuavePlanManifestSchema.safeParse({
|
||||
packages: [],
|
||||
plugins: [],
|
||||
categories: [],
|
||||
tierCounts: { python: 0, r3f: 0, react: 0, browser: 0, server: 0 },
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("parseTier returns a typed tier", { timeout: 5000 }, () => {
|
||||
expect(parseTier("universal")).toBe("universal");
|
||||
});
|
||||
|
||||
it("parseTier throws INVALID_TIER for nonsense", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseTier("rust");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OverviewError);
|
||||
if (err instanceof OverviewError) {
|
||||
expect(err.code).toBe("OVERVIEW.INVALID_TIER");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
79
packages/devtools/tooling/overview/src/schemas/schemas.ts
Normal file
79
packages/devtools/tooling/overview/src/schemas/schemas.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import z from "zod";
|
||||
import { OVERVIEW_CODES, OverviewError } from "../errors/errors.js";
|
||||
import { TIERS, type Tier } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Zod schema for the closed `Tier` union. Throws `OVERVIEW.INVALID_TIER`
|
||||
* when a parse is attempted against a non-tier string via `parseTier`.
|
||||
*/
|
||||
export const TierSchema: z.ZodType<Tier> = z.enum(
|
||||
TIERS as readonly [Tier, ...Tier[]]
|
||||
);
|
||||
|
||||
/**
|
||||
* Zod schema for a single manifest row. Every field is required; arrays must
|
||||
* contain unique sorted entries.
|
||||
*/
|
||||
export const PackageRecordSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).startsWith("@suaveplan/"),
|
||||
short: z.string().min(1),
|
||||
category: z.string().min(1),
|
||||
plugin: z.string().min(1),
|
||||
dirName: z.string().min(1),
|
||||
version: z.string().min(1),
|
||||
description: z.string(),
|
||||
tier: TierSchema,
|
||||
workspaceDeps: z.array(z.string().startsWith("@suaveplan/")),
|
||||
hasBin: z.boolean(),
|
||||
hasBrowserField: z.boolean(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Zod schema for a plugin roll-up row.
|
||||
*/
|
||||
export const PluginSummarySchema = z
|
||||
.object({
|
||||
category: z.string().min(1),
|
||||
plugin: z.string().min(1),
|
||||
packages: z.number().int().nonnegative(),
|
||||
workspaceDeps: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Zod schema for the full manifest snapshot. `tierCounts` carries an entry
|
||||
* for every tier — universal, browser, react, r3f, server, python.
|
||||
*/
|
||||
export const SuavePlanManifestSchema = z
|
||||
.object({
|
||||
packages: z.array(PackageRecordSchema),
|
||||
plugins: z.array(PluginSummarySchema),
|
||||
categories: z.array(z.string().min(1)),
|
||||
tierCounts: z.object({
|
||||
python: z.number().int().nonnegative(),
|
||||
r3f: z.number().int().nonnegative(),
|
||||
react: z.number().int().nonnegative(),
|
||||
browser: z.number().int().nonnegative(),
|
||||
server: z.number().int().nonnegative(),
|
||||
universal: z.number().int().nonnegative(),
|
||||
}),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Narrow-typed parse for tier strings. Throws `OVERVIEW.INVALID_TIER` on
|
||||
* failure so callers don't have to translate a Zod error into a package error.
|
||||
*/
|
||||
export function parseTier(input: unknown): Tier {
|
||||
const parsed = TierSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new OverviewError(
|
||||
OVERVIEW_CODES.INVALID_TIER,
|
||||
"overview.invalid_tier",
|
||||
{ context: { input: String(input) } }
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# `telemetry/telemetry.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the OpenTelemetry meter for `@suaveplan/overview` and exposes the
|
||||
three counters and one histogram that the spec requires. The meter is
|
||||
constructed lazily at module import time via
|
||||
`metrics.getMeter("@suaveplan/overview", "0.1.0")` so a no-op OTel SDK is the
|
||||
default — there is no instrumentation cost on the cold path when no SDK is
|
||||
installed.
|
||||
|
||||
Every metric is exported as a typed `Counter` or `Histogram` so other modules
|
||||
can `.add` / `.record` against them directly without going through a wrapper.
|
||||
|
||||
## Features
|
||||
|
||||
- Single meter scoped to the package name + version
|
||||
- `overview.packages_discovered_total` — counter
|
||||
- `overview.records_rejected_total{reason}` — counter (reason is one of
|
||||
`parse_failed` | `invalid_record`)
|
||||
- `overview.graph_builds_total` — counter
|
||||
- `overview.discover_duration_ms` — histogram with millisecond unit
|
||||
- `RejectionReason` typed alias for the `reason` attribute
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { packagesDiscoveredTotal } from "@suaveplan/overview/telemetry";
|
||||
packagesDiscoveredTotal.add(7);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
discoverDurationMs,
|
||||
recordsRejectedTotal,
|
||||
type RejectionReason,
|
||||
} from "@suaveplan/overview/telemetry";
|
||||
|
||||
const start = performance.now();
|
||||
const result = await runDiscovery();
|
||||
discoverDurationMs.record(performance.now() - start);
|
||||
|
||||
if (!result.ok) {
|
||||
const reason: RejectionReason = result.reason;
|
||||
recordsRejectedTotal.add(1, { reason });
|
||||
}
|
||||
```
|
||||
|
||||
Plug `@opentelemetry/sdk-metrics` in your runtime to harvest these metrics; no
|
||||
adapter glue is needed inside `@suaveplan/overview`.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `packagesDiscoveredTotal: Counter`
|
||||
- `recordsRejectedTotal: Counter` (attribute: `reason: "parse_failed" |
|
||||
"invalid_record"`)
|
||||
- `graphBuildsTotal: Counter`
|
||||
- `discoverDurationMs: Histogram` (unit: `ms`)
|
||||
- `type RejectionReason = "parse_failed" | "invalid_record"`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The meter is obtained once at module load and cached as a `const`. With the
|
||||
no-op SDK installed (the default in test environments without a configured
|
||||
OTel SDK), every `.add` and `.record` call is a fast no-op — there is no need
|
||||
to feature-flag instrumentation. Tests use the SDK's `InMemoryMetricReader`
|
||||
to assert that the counters and histogram move when discovery runs.
|
||||
@@ -0,0 +1,55 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { discoverPackages } from "../discover/discover.js";
|
||||
import { buildGraph } from "../graph/graph.js";
|
||||
import {
|
||||
discoverDurationMs,
|
||||
graphBuildsTotal,
|
||||
packagesDiscoveredTotal,
|
||||
recordsRejectedTotal,
|
||||
} from "./telemetry.js";
|
||||
|
||||
describe("telemetry instruments", () => {
|
||||
it("exposes the three counters and one histogram", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(packagesDiscoveredTotal).toBeDefined();
|
||||
expect(recordsRejectedTotal).toBeDefined();
|
||||
expect(graphBuildsTotal).toBeDefined();
|
||||
expect(discoverDurationMs).toBeDefined();
|
||||
});
|
||||
|
||||
it("discoverPackages emits counter + histogram", { timeout: 5000 }, () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "overview-telemetry-"));
|
||||
const dir = join(tmp, "packages", "foundation", "core", "a");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@suaveplan/a",
|
||||
version: "0.1.0",
|
||||
description: "",
|
||||
})
|
||||
);
|
||||
expect(() => discoverPackages({ root: tmp })).not.toThrow();
|
||||
});
|
||||
|
||||
it("discoverPackages records rejected_total on parse failure (skip mode)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "overview-telemetry-"));
|
||||
const dir = join(tmp, "packages", "foundation", "core", "broken");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "package.json"), "{ not valid");
|
||||
expect(() =>
|
||||
discoverPackages({ root: tmp, onError: "skip" })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("buildGraph emits graph_builds_total", { timeout: 5000 }, () => {
|
||||
const g = buildGraph([]);
|
||||
expect(g.nodes.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/overview";
|
||||
const METER_VERSION = "0.1.0";
|
||||
|
||||
const meter = metrics.getMeter(METER_NAME, METER_VERSION);
|
||||
|
||||
/**
|
||||
* Total `PackageRecord` rows produced by `discoverPackages`. Incremented by
|
||||
* `records.length` once per successful discovery.
|
||||
*/
|
||||
export const packagesDiscoveredTotal: Counter = meter.createCounter(
|
||||
"overview.packages_discovered_total",
|
||||
{
|
||||
description:
|
||||
"Total package records produced by overview.discoverPackages.",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Total records rejected during discovery, labelled by reason
|
||||
* (`parse_failed` | `invalid_record`).
|
||||
*/
|
||||
export const recordsRejectedTotal: Counter = meter.createCounter(
|
||||
"overview.records_rejected_total",
|
||||
{
|
||||
description:
|
||||
"Records rejected during discovery (parse_failed | invalid_record).",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Total `buildGraph` invocations. Incremented once per call.
|
||||
*/
|
||||
export const graphBuildsTotal: Counter = meter.createCounter(
|
||||
"overview.graph_builds_total",
|
||||
{
|
||||
description: "Total invocations of overview.buildGraph.",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Histogram of wall-clock duration of `discoverPackages` in milliseconds.
|
||||
*/
|
||||
export const discoverDurationMs: Histogram = meter.createHistogram(
|
||||
"overview.discover_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Wall-clock duration of overview.discoverPackages in milliseconds.",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Allowed rejection-reason values for the `records_rejected_total` counter.
|
||||
*/
|
||||
export type RejectionReason = "parse_failed" | "invalid_record";
|
||||
73
packages/devtools/tooling/overview/src/tier/tier.md
Normal file
73
packages/devtools/tooling/overview/src/tier/tier.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# `tier/tier.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Computes the deterministic `Tier` label for a package given its shortname and
|
||||
the parsed `package.json` shape. The inference order mirrors
|
||||
`scripts/folder-reorg/generate-manifest.ts` exactly, so consumers can rely on
|
||||
the library and the script producing identical tier assignments for the same
|
||||
input.
|
||||
|
||||
The function is pure and synchronous. It takes no side-effects, so it is safe
|
||||
to call from any tier — including the universal root barrel — even though
|
||||
some of the dep hints it inspects are Node-only.
|
||||
|
||||
## Features
|
||||
|
||||
- Six-rung priority cascade — python → r3f → react → browser → server →
|
||||
universal
|
||||
- Exported `SERVER_DEP_HINTS` set for downstream introspection
|
||||
- Optional `extraHints` parameter merges user-supplied server-tier dep names
|
||||
with the built-in set
|
||||
- Pure / synchronous — zero I/O, zero allocations on the universal fallback
|
||||
path beyond the merged-hint set
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { inferTier } from "@suaveplan/overview";
|
||||
|
||||
const tier = inferTier("foo", {
|
||||
dependencies: { "node:fs": "*" },
|
||||
});
|
||||
// tier === "server"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { inferTier, SERVER_DEP_HINTS } from "@suaveplan/overview";
|
||||
|
||||
const extra = new Set(["custom-native-binding"]);
|
||||
const tier = inferTier(
|
||||
"thing",
|
||||
{ dependencies: { "custom-native-binding": "*" } },
|
||||
extra,
|
||||
);
|
||||
|
||||
// SERVER_DEP_HINTS exposes the built-in set for tooling that wants to
|
||||
// surface the rule set in a UI without re-declaring it.
|
||||
console.log([...SERVER_DEP_HINTS]);
|
||||
```
|
||||
|
||||
The optional third argument is the path for tooling that wants to extend the
|
||||
hint set (e.g. a project-local native-binding registry) without forking the
|
||||
library. The base set is read-only and never mutated.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `inferTier(short: string, pkg: RawPackageJson, extraHints?: ReadonlySet<string>): Tier`
|
||||
- `const SERVER_DEP_HINTS: ReadonlySet<string>` — eighteen built-in entries
|
||||
spanning `node:*` modules and known native bindings
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function performs at most one shallow `Object.keys` pass over each of
|
||||
`peerDependencies`, `engines`, `dependencies`, and `devDependencies` — O(1)
|
||||
work per `package.json` in practice. The `mergeHints` helper allocates a new
|
||||
`Set` only when `extraHints` carries entries; otherwise it returns the base
|
||||
set unchanged so the common path is allocation-free.
|
||||
76
packages/devtools/tooling/overview/src/tier/tier.test.ts
Normal file
76
packages/devtools/tooling/overview/src/tier/tier.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { inferTier, SERVER_DEP_HINTS } from "./tier.js";
|
||||
|
||||
describe("inferTier", () => {
|
||||
it("prioritises python > r3f > react > browser > server > universal", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// python by shortname prefix
|
||||
expect(inferTier("python-foo", {})).toBe("python");
|
||||
// python by field
|
||||
expect(inferTier("foo", { python: {} })).toBe("python");
|
||||
|
||||
// r3f via peer dep
|
||||
expect(inferTier("foo", { peerDependencies: { three: "*" } })).toBe(
|
||||
"r3f"
|
||||
);
|
||||
// r3f via @react-three/fiber
|
||||
expect(
|
||||
inferTier("foo", {
|
||||
peerDependencies: { "@react-three/fiber": "*" },
|
||||
})
|
||||
).toBe("r3f");
|
||||
// r3f via shortname prefix
|
||||
expect(inferTier("r3f-physics", {})).toBe("r3f");
|
||||
|
||||
// react via peer dep
|
||||
expect(inferTier("foo", { peerDependencies: { react: "*" } })).toBe(
|
||||
"react"
|
||||
);
|
||||
// react via shortname prefix
|
||||
expect(inferTier("react-form", {})).toBe("react");
|
||||
|
||||
// browser via `browser` field
|
||||
expect(inferTier("foo", { browser: true })).toBe("browser");
|
||||
// browser via engines set without node/bun
|
||||
expect(inferTier("foo", { engines: { workerd: ">=1" } })).toBe(
|
||||
"browser"
|
||||
);
|
||||
|
||||
// server via bin
|
||||
expect(inferTier("foo", { bin: "./cli.js" })).toBe("server");
|
||||
// server via dep hint
|
||||
expect(inferTier("foo", { dependencies: { "node:fs": "*" } })).toBe(
|
||||
"server"
|
||||
);
|
||||
expect(inferTier("foo", { devDependencies: { pg: "*" } })).toBe(
|
||||
"server"
|
||||
);
|
||||
|
||||
// universal fallback
|
||||
expect(inferTier("foo", {})).toBe("universal");
|
||||
// universal with node engine listed
|
||||
expect(inferTier("foo", { engines: { node: ">=20" } })).toBe(
|
||||
"universal"
|
||||
);
|
||||
// universal with bun engine listed
|
||||
expect(inferTier("foo", { engines: { bun: ">=1" } })).toBe("universal");
|
||||
});
|
||||
|
||||
it("honours extension hints", { timeout: 5000 }, () => {
|
||||
const hints = new Set(["custom-native"]);
|
||||
expect(
|
||||
inferTier("foo", { dependencies: { "custom-native": "*" } }, hints)
|
||||
).toBe("server");
|
||||
// empty extension set passes through to fallback
|
||||
expect(inferTier("foo", {}, new Set())).toBe("universal");
|
||||
});
|
||||
|
||||
it("SERVER_DEP_HINTS contains the canonical Node namespaces", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(SERVER_DEP_HINTS.has("node:fs")).toBe(true);
|
||||
expect(SERVER_DEP_HINTS.has("pg")).toBe(true);
|
||||
expect(SERVER_DEP_HINTS.has("bun:sqlite")).toBe(true);
|
||||
});
|
||||
});
|
||||
96
packages/devtools/tooling/overview/src/tier/tier.ts
Normal file
96
packages/devtools/tooling/overview/src/tier/tier.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { RawPackageJson, Tier } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Server-only dependency names. When a package's `dependencies` /
|
||||
* `devDependencies` includes any of these, `inferTier` returns `"server"`
|
||||
* (unless an earlier rung already matched).
|
||||
*
|
||||
* Exported so downstream tooling can extend the set without forking — see
|
||||
* `inferTier(..., extraHints)`.
|
||||
*/
|
||||
export const SERVER_DEP_HINTS: ReadonlySet<string> = new Set([
|
||||
"bun:sqlite",
|
||||
"node:fs",
|
||||
"node:fs/promises",
|
||||
"node:net",
|
||||
"node:cluster",
|
||||
"node:dgram",
|
||||
"node:tls",
|
||||
"node:dns",
|
||||
"node:child_process",
|
||||
"node:os",
|
||||
"node:worker_threads",
|
||||
"pg",
|
||||
"ioredis",
|
||||
"better-sqlite3",
|
||||
"@sqlite.org/sqlite-wasm-node",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Resolve a tier label from the package shortname and the parsed
|
||||
* `package.json`. Priority order (top-down, first match wins):
|
||||
*
|
||||
* 1. `python` — `pkg.python` is set OR shortname starts with `python-`
|
||||
* 2. `r3f` — peer deps include `@react-three/fiber` or `three` OR shortname
|
||||
* starts with `r3f-`
|
||||
* 3. `react` — peer deps include `react` OR shortname starts with `react-`
|
||||
* 4. `browser` — `pkg.browser` is defined OR `engines` is set but neither
|
||||
* `node` nor `bun` are listed
|
||||
* 5. `server` — `pkg.bin` is defined OR any dep name appears in
|
||||
* `SERVER_DEP_HINTS` (or `extraHints`)
|
||||
* 6. `universal` — fallback
|
||||
*
|
||||
* @param short - npm shortname (after stripping `@suaveplan/`)
|
||||
* @param pkg - the parsed `package.json` shape
|
||||
* @param extraHints - optional extension set merged with `SERVER_DEP_HINTS`
|
||||
*/
|
||||
export function inferTier(
|
||||
short: string,
|
||||
pkg: RawPackageJson,
|
||||
extraHints?: ReadonlySet<string>
|
||||
): Tier {
|
||||
if (pkg.python !== undefined || short.startsWith("python-"))
|
||||
return "python";
|
||||
|
||||
const peerKeys = Object.keys(pkg.peerDependencies ?? {});
|
||||
if (
|
||||
peerKeys.includes("@react-three/fiber") ||
|
||||
peerKeys.includes("three") ||
|
||||
short.startsWith("r3f-")
|
||||
) {
|
||||
return "r3f";
|
||||
}
|
||||
if (peerKeys.includes("react") || short.startsWith("react-")) {
|
||||
return "react";
|
||||
}
|
||||
|
||||
if (pkg.browser !== undefined) return "browser";
|
||||
const enginesKeys = Object.keys(pkg.engines ?? {});
|
||||
if (
|
||||
enginesKeys.length > 0 &&
|
||||
!enginesKeys.includes("node") &&
|
||||
!enginesKeys.includes("bun")
|
||||
) {
|
||||
return "browser";
|
||||
}
|
||||
|
||||
if (pkg.bin !== undefined) return "server";
|
||||
|
||||
const hintSet = mergeHints(SERVER_DEP_HINTS, extraHints);
|
||||
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
||||
for (const key of Object.keys(allDeps)) {
|
||||
if (hintSet.has(key)) return "server";
|
||||
}
|
||||
|
||||
return "universal";
|
||||
}
|
||||
|
||||
function mergeHints(
|
||||
base: ReadonlySet<string>,
|
||||
extra: ReadonlySet<string> | undefined
|
||||
): ReadonlySet<string> {
|
||||
if (extra === undefined || extra.size === 0) return base;
|
||||
const merged = new Set(base);
|
||||
for (const v of extra) merged.add(v);
|
||||
return merged;
|
||||
}
|
||||
84
packages/devtools/tooling/overview/src/types/types.md
Normal file
84
packages/devtools/tooling/overview/src/types/types.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# `types/types.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the type-level vocabulary of `@suaveplan/overview`: the closed `Tier`
|
||||
union, the `PackageRecord` row, the `PluginSummary` aggregate, the
|
||||
`SuavePlanManifest` snapshot, the `WorkspaceGraph` adjacency structure, and the
|
||||
`RawPackageJson` narrowing of the on-disk `package.json` fields that influence
|
||||
tier inference.
|
||||
|
||||
Every other module in the package consumes these shapes. There is no runtime
|
||||
code in this file — only `export type` / `export interface` declarations and
|
||||
the `TIERS` constant array used for stable iteration order across roll-ups and
|
||||
tests.
|
||||
|
||||
## Features
|
||||
|
||||
- Closed `Tier` union with documented priority order
|
||||
- `TIERS` ordered tuple for deterministic iteration in roll-ups and tests
|
||||
- `readonly`-deep `PackageRecord` shape — no accidental mutation downstream
|
||||
- `PluginSummary` matches the columns produced by
|
||||
`scripts/folder-reorg/generate-manifest.ts`
|
||||
- `SuavePlanManifest` carries every aggregate that the Markdown manifest emits
|
||||
- `WorkspaceGraph` exposes both forward + reverse adjacency for O(1) traversal
|
||||
- `RawPackageJson` narrows `package.json` to only the fields the package reads
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { PackageRecord, Tier } from "@suaveplan/overview";
|
||||
|
||||
const record: PackageRecord = {
|
||||
name: "@suaveplan/x",
|
||||
short: "x",
|
||||
category: "foundation",
|
||||
plugin: "core",
|
||||
dirName: "x",
|
||||
version: "0.1.0",
|
||||
description: "",
|
||||
tier: "universal",
|
||||
workspaceDeps: [],
|
||||
hasBin: false,
|
||||
hasBrowserField: false,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { TIERS, type SuavePlanManifest, type Tier } from "@suaveplan/overview";
|
||||
|
||||
function totalCount(manifest: SuavePlanManifest): number {
|
||||
return TIERS.reduce<number>(
|
||||
(acc, tier: Tier) => acc + manifest.tierCounts[tier],
|
||||
0,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`TIERS` is ordered for human-readable output (universal first, python last);
|
||||
runtime code that needs the closed enum but is happy with any order can iterate
|
||||
`Object.keys(manifest.tierCounts) as Tier[]`.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type Tier = "python" | "r3f" | "react" | "browser" | "server" | "universal"`
|
||||
- `const TIERS: readonly Tier[]` — fixed ordering
|
||||
- `interface PackageRecord` — `readonly` row, eleven fields
|
||||
- `interface PluginSummary` — `{category, plugin, packages, workspaceDeps}`
|
||||
- `interface SuavePlanManifest` — `{packages, plugins, categories, tierCounts}`
|
||||
- `interface WorkspaceGraph` — `{nodes, forward, reverse}`
|
||||
- `interface RawPackageJson` — only the fields read by `inferTier`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Types live in a sibling `.types.ts` is not appropriate here because this file
|
||||
defines the cross-module vocabulary rather than the types of a single feature
|
||||
implementation. It is referenced directly by `src/index.ts` and every other
|
||||
feature folder. The `readonly` modifiers cascade so downstream consumers cannot
|
||||
accidentally mutate manifests they receive; mutation must be a conscious clone.
|
||||
113
packages/devtools/tooling/overview/src/types/types.ts
Normal file
113
packages/devtools/tooling/overview/src/types/types.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Closed enumeration of tier labels recognised by the manifest, in the
|
||||
* canonical priority order documented at
|
||||
* `openspec/specs/monorepo-structure/spec.md`.
|
||||
*
|
||||
* Inference is done in `inferTier`: python → r3f → react → browser → server
|
||||
* → universal.
|
||||
*/
|
||||
export type Tier =
|
||||
| "python"
|
||||
| "r3f"
|
||||
| "react"
|
||||
| "browser"
|
||||
| "server"
|
||||
| "universal";
|
||||
|
||||
/**
|
||||
* Every tier in deterministic order. Useful for iterating roll-ups and for
|
||||
* constructing all-tier empty buckets.
|
||||
*/
|
||||
export const TIERS: readonly Tier[] = [
|
||||
"universal",
|
||||
"browser",
|
||||
"react",
|
||||
"r3f",
|
||||
"server",
|
||||
"python",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* One row of the monorepo manifest. The minimum useful surface a downstream
|
||||
* tool needs to render dashboards, graph dependencies, route imports per
|
||||
* tier, or build deployment plans.
|
||||
*/
|
||||
export interface PackageRecord {
|
||||
/** Full npm name, e.g. `"@suaveplan/overview"`. */
|
||||
readonly name: string;
|
||||
/** npm name without the `@suaveplan/` prefix. */
|
||||
readonly short: string;
|
||||
/** Top-level super-category folder under `packages/`. */
|
||||
readonly category: string;
|
||||
/** Mid-level plugin folder under `packages/<category>/`. */
|
||||
readonly plugin: string;
|
||||
/** Last path segment — the on-disk folder name of the package. */
|
||||
readonly dirName: string;
|
||||
/** `package.json` `version` field. */
|
||||
readonly version: string;
|
||||
/** `package.json` `description` field. Empty string when absent. */
|
||||
readonly description: string;
|
||||
/** Inferred tier — never observed from disk, always computed. */
|
||||
readonly tier: Tier;
|
||||
/** Sorted, deduped `@suaveplan/*` names referenced via `workspace:`. */
|
||||
readonly workspaceDeps: readonly string[];
|
||||
/** `true` when the package declares a `bin` field. */
|
||||
readonly hasBin: boolean;
|
||||
/** `true` when the package declares a `browser` field. */
|
||||
readonly hasBrowserField: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-plugin aggregate row. Mirrors the Plugins table in `MANIFEST.md`.
|
||||
*/
|
||||
export interface PluginSummary {
|
||||
readonly category: string;
|
||||
readonly plugin: string;
|
||||
/** Number of packages discovered for this `(category, plugin)` pair. */
|
||||
readonly packages: number;
|
||||
/** Total outgoing workspace edges from packages in this plugin. */
|
||||
readonly workspaceDeps: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full validated manifest snapshot. The shape `SuavePlanManifestSchema`
|
||||
* parses to; what every public entry point returns.
|
||||
*/
|
||||
export interface SuavePlanManifest {
|
||||
readonly packages: readonly PackageRecord[];
|
||||
readonly plugins: readonly PluginSummary[];
|
||||
readonly categories: readonly string[];
|
||||
readonly tierCounts: Readonly<Record<Tier, number>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjacency structure derived from a list of `PackageRecord`s. Edges only
|
||||
* connect known nodes — orphan dep references are dropped at build time so
|
||||
* partial-migration manifests stay self-consistent.
|
||||
*/
|
||||
export interface WorkspaceGraph {
|
||||
/** Every node name. */
|
||||
readonly nodes: ReadonlySet<string>;
|
||||
/** `pkg → its workspace dependencies (known nodes only)`. */
|
||||
readonly forward: ReadonlyMap<string, ReadonlySet<string>>;
|
||||
/** `pkg → its dependents (known nodes only)`. */
|
||||
readonly reverse: ReadonlyMap<string, ReadonlySet<string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw `package.json` shape consumed by `inferTier`. Only the fields that
|
||||
* influence tier inference or dep discovery are present.
|
||||
*/
|
||||
export interface RawPackageJson {
|
||||
readonly name?: string;
|
||||
readonly version?: string;
|
||||
readonly description?: string;
|
||||
readonly dependencies?: Readonly<Record<string, string>>;
|
||||
readonly devDependencies?: Readonly<Record<string, string>>;
|
||||
readonly peerDependencies?: Readonly<Record<string, string>>;
|
||||
readonly optionalDependencies?: Readonly<Record<string, string>>;
|
||||
readonly browser?: unknown;
|
||||
readonly bin?: unknown;
|
||||
readonly engines?: Readonly<Record<string, string>>;
|
||||
readonly python?: unknown;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
12
packages/devtools/tooling/overview/tsconfig.lib.json
Normal file
12
packages/devtools/tooling/overview/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
38
packages/devtools/tooling/overview/vite.config.ts
Normal file
38
packages/devtools/tooling/overview/vite.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/overview",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
/^@suaveplan\/error\//,
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
29
packages/devtools/tooling/overview/vitest.config.ts
Normal file
29
packages/devtools/tooling/overview/vitest.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user