From 3087e566ed22f48e54ac743236190e66dc16fe6c Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 15:01:37 -0700 Subject: [PATCH 1/2] Delete the owned-tables-fk structural check --- scripts/checks/owned-tables-fk.ts | 174 -------------------- scripts/checks/test/owned-tables-fk.test.ts | 93 ----------- scripts/checks/test/run.test.ts | 1 - 3 files changed, 268 deletions(-) delete mode 100644 scripts/checks/owned-tables-fk.ts delete mode 100644 scripts/checks/test/owned-tables-fk.test.ts diff --git a/scripts/checks/owned-tables-fk.ts b/scripts/checks/owned-tables-fk.ts deleted file mode 100644 index 356dc2b32b..0000000000 --- a/scripts/checks/owned-tables-fk.ts +++ /dev/null @@ -1,174 +0,0 @@ -// check:owned-tables-fk — CL-8210's ruling: every custom (non-Interchange) -// Postgres table Workbench owns lives on its own named `pgSchema(...)`, -// never `public`, and every `tenant_id`/`principal_id` column on it is a -// real foreign key (a drizzle `.references(...)` call) back to -// Interchange's tenant/principal tables with `ON DELETE CASCADE`. This -// check enforces the mechanical half of that ruling over every -// `pgTable(...)`/`.table(...)` declaration outside `vendor/` — -// it cannot verify the FK target is actually Interchange's table, or that -// `onDelete: "cascade"` is set, only that `.references(` appears at all; -// a reviewer still reads the diff for those. -// -// A bare `pgTable("tenant", { id: text("id").primaryKey() })` — the -// "declared, never migrated" stub every FK-carrying package uses to name -// Interchange's own table for the FK to point at (see -// packages/notify/src/schema.ts's `hostTenant`/`hostPrincipal`) — is not a -// table this package owns, so it is exempted by HOST_STUB_TABLES below -// rather than by a generic "few columns" heuristic. -import { Glob } from "bun"; -import path from "node:path"; -import { emptyReport, reportAndExit, rootFromArgs, type CheckReport } from "./lib/repo"; - -const SCAN_DIRS = ["apps", "packages", "workflows"]; - -// Interchange's own control-plane tables, re-declared locally (never -// migrated) purely to carry a drizzle `.references()` FK target. These -// names are exempt from both rules: they're not owned tables. -const HOST_STUB_TABLES = new Set(["tenant", "principal"]); - -// Pre-existing tenant_id/principal_id debt this check does not (yet) fail -// on. `@corbits/chat` is owned by another workstream — its schema already -// lives on its own `chat` pgSchema (satisfying the schema half of -// CL-8210) but its tenant_id/principal_id columns predate the FK ruling -// and are out of scope for CL-8210's PR, which is explicitly barred from -// touching packages/chat/**. Tracked as its own follow-up rather than -// silently exempted forever: a shrinking ledger, not a blanket allowlist. -const FK_DEBT_FILES = new Set(["packages/chat/src/schema.ts"]); - -const TABLE_CALL_PATTERN = - /(?:(\b[A-Za-z_][A-Za-z0-9_]*)\.table|(\bpgTable))\(\s*"([a-zA-Z0-9_]+)"/g; - -export interface TableDeclaration { - readonly relPath: string; - readonly tableName: string; - readonly hasOwnSchema: boolean; - readonly body: string; -} - -/** Finds the matching closing brace for the `{` at `openIndex` in `text`. */ -function matchBrace(text: string, openIndex: number): number { - let depth = 0; - for (let i = openIndex; i < text.length; i++) { - if (text[i] === "{") depth++; - else if (text[i] === "}") { - depth--; - if (depth === 0) return i; - } - } - return -1; -} - -/** Splits a `{...}` object body into its top-level `key: value` entries. */ -function splitTopLevelEntries(body: string): string[] { - const entries: string[] = []; - let depth = 0; - let start = 0; - for (let i = 0; i < body.length; i++) { - const ch = body[i]; - if (ch === "{" || ch === "(" || ch === "[") depth++; - else if (ch === "}" || ch === ")" || ch === "]") depth--; - else if (ch === "," && depth === 0) { - entries.push(body.slice(start, i)); - start = i + 1; - } - } - entries.push(body.slice(start)); - return entries.map((e) => e.trim()).filter((e) => e.length > 0); -} - -export function findTableDeclarations(relPath: string, contents: string): TableDeclaration[] { - const declarations: TableDeclaration[] = []; - for (const match of contents.matchAll(TABLE_CALL_PATTERN)) { - const schemaVar = match[1]; - const tableName = match[3] ?? ""; - const callEnd = (match.index ?? 0) + match[0].length; - const braceStart = contents.indexOf("{", callEnd); - if (braceStart === -1) continue; - // A non-object second argument (e.g. a bare `pgTable("x", columns)` - // referencing a variable) has no literal body to inspect; skip it — - // nothing in this repo declares a table that way today. - const betweenCommaAndBrace = contents.slice(callEnd, braceStart).trim(); - if (betweenCommaAndBrace !== "," && betweenCommaAndBrace !== "") continue; - const braceEnd = matchBrace(contents, braceStart); - if (braceEnd === -1) continue; - declarations.push({ - relPath, - tableName, - hasOwnSchema: schemaVar !== undefined, - body: contents.slice(braceStart + 1, braceEnd), - }); - } - return declarations; -} - -const ID_COLUMN_PATTERN = /^(?:(\w+):\s*)?text\(\s*"(tenant_id|principal_id)"\s*\)/; - -export function auditOwnedTablesFk( - files: readonly { relPath: string; contents: string }[], -): CheckReport { - const report = emptyReport(); - for (const { relPath, contents } of files) { - if (FK_DEBT_FILES.has(relPath)) { - report.notes.push(`${relPath}: skipped — tracked FK debt, see FK_DEBT_FILES`); - continue; - } - const declarations = findTableDeclarations(relPath, contents); - for (const decl of declarations) { - if (HOST_STUB_TABLES.has(decl.tableName)) continue; - - if (!decl.hasOwnSchema) { - report.violations.push( - `${relPath}: table "${decl.tableName}" is declared with a bare pgTable(...) ` + - `call, not on a named pgSchema(...) — every table Workbench owns must live ` + - `on its own schema, never public (CL-8210).`, - ); - } - - for (const entry of splitTopLevelEntries(decl.body)) { - const idMatch = entry.match(ID_COLUMN_PATTERN); - if (idMatch === undefined || idMatch === null) continue; - if (!entry.includes(".references(")) { - report.violations.push( - `${relPath}: table "${decl.tableName}" column "${idMatch[2]}" has no ` + - `.references(...) — every tenant_id/principal_id column must be a real ` + - `foreign key to Interchange's tenant/principal table, ON DELETE CASCADE ` + - `(CL-8210).`, - ); - } - } - } - } - return report; -} - -async function scanFiles(root: string, dirs: readonly string[]): Promise { - const files: string[] = []; - for (const dir of dirs) { - const glob = new Glob(`${dir}/**/*.{ts,tsx}`); - for await (const file of glob.scan({ cwd: root, dot: false })) { - if (file.includes("node_modules/")) continue; - if (file.includes("/dist/") || file.startsWith("dist/")) continue; - if (file.includes("/vendor/") || file.startsWith("vendor/")) continue; - if (file.endsWith(".test.ts") || file.endsWith(".test.tsx")) continue; - files.push(file); - } - } - return files; -} - -async function main(): Promise { - const args = Bun.argv.slice(2); - const root = rootFromArgs(args); - const relPaths = await scanFiles(root, SCAN_DIRS); - const files = await Promise.all( - relPaths.map(async (relPath) => ({ - relPath, - contents: await Bun.file(path.join(root, relPath)).text(), - })), - ); - const report = auditOwnedTablesFk(files); - report.notes.push(`scanned ${files.length} file(s) under ${SCAN_DIRS.join(", ")}`); - reportAndExit("check:owned-tables-fk", report); -} - -if (import.meta.main) await main(); diff --git a/scripts/checks/test/owned-tables-fk.test.ts b/scripts/checks/test/owned-tables-fk.test.ts deleted file mode 100644 index 5b42316ca6..0000000000 --- a/scripts/checks/test/owned-tables-fk.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { expect, test } from "bun:test"; -import { auditOwnedTablesFk } from "../owned-tables-fk"; - -test("a schema-scoped table with FK'd tenant_id/principal_id passes clean", () => { - const report = auditOwnedTablesFk([ - { - relPath: "packages/notify/src/schema.ts", - contents: ` - const hostTenant = pgTable("tenant", { id: text("id").primaryKey() }); - const hostPrincipal = pgTable("principal", { id: text("id").primaryKey() }); - export const notifyDispatch = notifySchema.table("notify_dispatch", { - id: text("id").primaryKey(), - tenantId: text("tenant_id").notNull().references(() => hostTenant.id, { onDelete: "cascade" }), - principalId: text("principal_id").notNull().references(() => hostPrincipal.id, { onDelete: "cascade" }), - }); - `, - }, - ]); - expect(report.violations).toEqual([]); -}); - -test("a bare pgTable(...) call outside a named schema is a violation", () => { - const report = auditOwnedTablesFk([ - { - relPath: "packages/widget/src/schema.ts", - contents: ` - export const widget = pgTable("widget", { - id: text("id").primaryKey(), - tenantId: text("tenant_id").notNull().references(() => hostTenant.id, { onDelete: "cascade" }), - }); - `, - }, - ]); - expect(report.violations).toHaveLength(1); - expect(report.violations[0]).toContain("pgTable"); - expect(report.violations[0]).toContain("widget"); -}); - -test("a tenant_id/principal_id column with no .references() is a violation", () => { - const report = auditOwnedTablesFk([ - { - relPath: "packages/widget/src/schema.ts", - contents: ` - export const widget = widgetSchema.table("widget", { - id: text("id").primaryKey(), - tenantId: text("tenant_id").notNull(), - principalId: text("principal_id").notNull(), - }); - `, - }, - ]); - expect(report.violations).toHaveLength(2); - expect(report.violations.some((v) => v.includes("tenant_id"))).toBe(true); - expect(report.violations.some((v) => v.includes("principal_id"))).toBe(true); -}); - -test("the tenant/principal host stub tables are exempt from both rules", () => { - const report = auditOwnedTablesFk([ - { - relPath: "packages/widget/src/schema.ts", - contents: ` - const hostTenant = pgTable("tenant", { id: text("id").primaryKey() }); - const hostPrincipal = pgTable("principal", { id: text("id").primaryKey() }); - `, - }, - ]); - expect(report.violations).toEqual([]); -}); - -test("packages/chat/src/schema.ts is skipped as tracked FK debt", () => { - const report = auditOwnedTablesFk([ - { - relPath: "packages/chat/src/schema.ts", - contents: ` - export const t = chatSchema.table("workbench_settings", { - tenantId: text("tenant_id").notNull(), - }); - `, - }, - ]); - expect(report.violations).toEqual([]); - expect(report.notes.some((n) => n.includes("packages/chat/src/schema.ts"))).toBe(true); -}); - -test("a column merely named tenant_id in an unrelated file with no table call is ignored", () => { - const report = auditOwnedTablesFk([ - { - relPath: "packages/widget/src/queries.ts", - contents: `const tenantId = row.tenant_id;`, - }, - ]); - expect(report.violations).toEqual([]); -}); diff --git a/scripts/checks/test/run.test.ts b/scripts/checks/test/run.test.ts index 2744c531be..b04e2ea948 100644 --- a/scripts/checks/test/run.test.ts +++ b/scripts/checks/test/run.test.ts @@ -21,7 +21,6 @@ const EXPECTED_CHECK_NAMES = [ "killdates", "licenses", "no-product-tenancy", - "owned-tables-fk", "packages", "publishable-workflows", "react-ui-drift", From d7c4f1f8361359519b88d655d3fb77d6f91a7616 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 15:07:11 -0700 Subject: [PATCH 2/2] Delete scripts/checks and the structural CI job; hard-remove tables on cutover --- .github/workflows/ci.yml | 10 - AGENTS.md | 31 +- IMPLEMENTATION.md | 2 +- package.json | 3 +- scripts/checks/browser-safe-subpaths.ts | 342 ----------- scripts/checks/catalog-pins.ts | 151 ----- scripts/checks/db-gate.ts | 64 --- scripts/checks/deletion.ts | 64 --- scripts/checks/error-envelope.ts | 88 --- scripts/checks/hub-git-safety.ts | 120 ---- scripts/checks/hub-workbench-routes.ts | 122 ---- scripts/checks/kill-dates.txt | 32 -- scripts/checks/killdates.ts | 191 ------- scripts/checks/lib/exports.ts | 34 -- scripts/checks/lib/repo.ts | 55 -- scripts/checks/lib/tree-hash.ts | 28 - scripts/checks/licenses.ts | 116 ---- scripts/checks/no-product-tenancy.ts | 224 -------- scripts/checks/packages.ts | 247 -------- scripts/checks/publishable-workflows.ts | 126 ----- scripts/checks/react-ui-drift-allowlist.ts | 44 -- scripts/checks/react-ui-drift.ts | 245 -------- scripts/checks/react-ui-pin.ts | 85 --- scripts/checks/replaced-paths.txt | 34 -- scripts/checks/report-error-baseline.txt | 138 ----- scripts/checks/report-error.ts | 529 ------------------ scripts/checks/routine-target-inference.ts | 127 ----- scripts/checks/run.ts | 122 ---- scripts/checks/tailwind-source.ts | 71 --- .../checks/test/browser-safe-subpaths.test.ts | 309 ---------- scripts/checks/test/catalog-pins.test.ts | 109 ---- scripts/checks/test/db-gate.test.ts | 50 -- scripts/checks/test/deletion.test.ts | 66 --- scripts/checks/test/error-envelope.test.ts | 114 ---- scripts/checks/test/hub-git-safety.test.ts | 84 --- .../checks/test/hub-workbench-routes.test.ts | 97 ---- scripts/checks/test/killdates.test.ts | 225 -------- scripts/checks/test/licenses.test.ts | 81 --- .../checks/test/no-product-tenancy.test.ts | 108 ---- scripts/checks/test/packages.test.ts | 69 --- scripts/checks/test/react-ui-drift.test.ts | 156 ------ scripts/checks/test/react-ui-pin.test.ts | 61 -- scripts/checks/test/report-error.test.ts | 479 ---------------- .../test/routine-target-inference.test.ts | 74 --- scripts/checks/test/run.test.ts | 141 ----- scripts/checks/test/tailwind-source.test.ts | 41 -- .../test/tool-package-freshness.test.ts | 181 ------ scripts/checks/test/tool-package-pins.test.ts | 102 ---- scripts/checks/test/ui-vocabulary.test.ts | 410 -------------- scripts/checks/tool-package-freshness.ts | 188 ------- scripts/checks/tool-package-pins.ts | 228 -------- scripts/checks/ui-vocabulary.ts | 259 --------- scripts/checks/web-tailwind-utilities.ts | 143 ----- scripts/ci-jobs.test.ts | 12 - scripts/e2e/db-gate.ts | 12 +- scripts/hooks-install.test.ts | 3 +- 56 files changed, 19 insertions(+), 7228 deletions(-) delete mode 100644 scripts/checks/browser-safe-subpaths.ts delete mode 100644 scripts/checks/catalog-pins.ts delete mode 100644 scripts/checks/db-gate.ts delete mode 100644 scripts/checks/deletion.ts delete mode 100644 scripts/checks/error-envelope.ts delete mode 100644 scripts/checks/hub-git-safety.ts delete mode 100644 scripts/checks/hub-workbench-routes.ts delete mode 100644 scripts/checks/kill-dates.txt delete mode 100644 scripts/checks/killdates.ts delete mode 100644 scripts/checks/lib/exports.ts delete mode 100644 scripts/checks/lib/repo.ts delete mode 100644 scripts/checks/lib/tree-hash.ts delete mode 100644 scripts/checks/licenses.ts delete mode 100644 scripts/checks/no-product-tenancy.ts delete mode 100644 scripts/checks/packages.ts delete mode 100644 scripts/checks/publishable-workflows.ts delete mode 100644 scripts/checks/react-ui-drift-allowlist.ts delete mode 100644 scripts/checks/react-ui-drift.ts delete mode 100644 scripts/checks/react-ui-pin.ts delete mode 100644 scripts/checks/replaced-paths.txt delete mode 100644 scripts/checks/report-error-baseline.txt delete mode 100644 scripts/checks/report-error.ts delete mode 100644 scripts/checks/routine-target-inference.ts delete mode 100644 scripts/checks/run.ts delete mode 100644 scripts/checks/tailwind-source.ts delete mode 100644 scripts/checks/test/browser-safe-subpaths.test.ts delete mode 100644 scripts/checks/test/catalog-pins.test.ts delete mode 100644 scripts/checks/test/db-gate.test.ts delete mode 100644 scripts/checks/test/deletion.test.ts delete mode 100644 scripts/checks/test/error-envelope.test.ts delete mode 100644 scripts/checks/test/hub-git-safety.test.ts delete mode 100644 scripts/checks/test/hub-workbench-routes.test.ts delete mode 100644 scripts/checks/test/killdates.test.ts delete mode 100644 scripts/checks/test/licenses.test.ts delete mode 100644 scripts/checks/test/no-product-tenancy.test.ts delete mode 100644 scripts/checks/test/packages.test.ts delete mode 100644 scripts/checks/test/react-ui-drift.test.ts delete mode 100644 scripts/checks/test/react-ui-pin.test.ts delete mode 100644 scripts/checks/test/report-error.test.ts delete mode 100644 scripts/checks/test/routine-target-inference.test.ts delete mode 100644 scripts/checks/test/run.test.ts delete mode 100644 scripts/checks/test/tailwind-source.test.ts delete mode 100644 scripts/checks/test/tool-package-freshness.test.ts delete mode 100644 scripts/checks/test/tool-package-pins.test.ts delete mode 100644 scripts/checks/test/ui-vocabulary.test.ts delete mode 100644 scripts/checks/tool-package-freshness.ts delete mode 100644 scripts/checks/tool-package-pins.ts delete mode 100644 scripts/checks/ui-vocabulary.ts delete mode 100644 scripts/checks/web-tailwind-utilities.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33ea2ccc1f..97cfbc021b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,16 +35,6 @@ jobs: - run: bun run lint - run: bun run fmt:check - structural: - needs: setup - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: ./.github/actions/setup-workbench - - run: bun run check:structural - # A single fast pass over every workspace's tests. DB-gated suites # (scripts/e2e/* included) skip themselves when DATABASE_URL is unset — # see scripts/e2e/db-gate.ts — so this job needs no Postgres service. diff --git a/AGENTS.md b/AGENTS.md index b004219ab0..f032ed37fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,12 +42,9 @@ grants); the hub never seeds data on a client's behalf. ## Working conventions -- `bun run check` (typecheck, lint, test, structural checks) must pass - before every commit. `check:structural` (`scripts/checks/run.ts`) - discovers and runs every `scripts/checks/*.ts` file with an - `import.meta.main` entry point — run one on its own with `bun run -check:structural ` (e.g. `bun run check:structural report-error`), - forwarding flags after it. +- `bun run check` (typecheck, lint, fmt, test) must pass before every + commit. There are no custom structural check scripts; a rule lives in + the code or schema itself or it does not exist. - Worktrees live in `.worktrees/`; branch = `cl--`. - Commit sequence per change: tests first ("Add tests for X"), then implementation ("X: what changed"), then docs ("Update docs: X"). One @@ -79,24 +76,18 @@ Tests are meaningful red/green tests only — no coverage theater. An outdated test is deleted in the same PR that breaks it, not adapted to keep passing; rebuilding coverage for the area it covered happens under CL-8150. -## Conventions a check enforces - -Each is backed by a `scripts/checks/*.ts` file that `check:structural` -discovers and runs, so a violation fails CI rather than waiting for review. +## Conventions - Report every caught error through `reportError` from - `@corbits/error-sink` — never a bare `catch {}`. Pre-existing violations - are tracked in `scripts/checks/report-error-baseline.txt`, a shrinking - debt ledger, not an allowlist. -- A package's `browser-safe` subpath may never import a server-only + `@corbits/error-sink` — never a bare `catch {}`. +- A package's `browser-safe` subpath never imports a server-only dependency (`postgres`, `drizzle-orm`, `hono`, any `@intx/*`). -- A tool package's `{ name, version }` pin must match its own - `package.json` version, and its `src/` changing requires a version bump. - Every package needs a `LICENSE` file (LGPL-2.1-or-later). -- A dependency declared in the root `catalog` must be consumed as - `catalog:` everywhere, never a literal range. -- `exactOptionalPropertyTypes: true` in `tsconfig.base.json` — omit an - optional key rather than assigning it `undefined`. +- A dependency declared in the root `catalog` is consumed as `catalog:`. +- `exactOptionalPropertyTypes: true` — omit an optional key rather than + assigning it `undefined`. +- A custom table that gets deleted is hard-removed: delete the schema code, + no drop migration. Cutovers are breaking; reset the database. ## Docs map diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 03e7a4fa2f..c7cc84034e 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -69,7 +69,7 @@ recorded per-package in each vendored package's own `VENDORED-FROM` file. | `bun run dev` | Validates `.env`, verifies the database, applies pending migrations, builds the web UI if needed, starts the hub and one sidecar | | `bun run setup` | Applies pending database migrations (`scripts/db-setup.ts`) | | `bun run reset` | Drops the platform database schema and clears on-disk asset directories (local `DATABASE_URL` only, unrecoverable) | -| `bun run check` | The full gate: `typecheck && lint && fmt:check && check:structural && test` — must pass before every commit | +| `bun run check` | The full gate: `typecheck && lint && fmt:check && test` — must pass before every commit | | `bun run test` | Workspace unit/integration tests | | `bun run test:e2e` | End-to-end smoke tests (`scripts/e2e/*.test.ts`) | | `bun run fmt` | `oxfmt` write | diff --git a/package.json b/package.json index 7dc57a25e2..6605c615ca 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "workflows/*" ], "scripts": { - "check": "bun run typecheck && bun run lint && bun run fmt:check && bun run check:structural && bun run test", + "check": "bun run typecheck && bun run lint && bun run fmt:check && bun run test", "typecheck": "bun run scripts/typecheck.ts", "lint": "oxlint apps packages tools agents skills workflows scripts test", "fmt": "oxfmt apps packages tools agents skills workflows scripts test", @@ -28,7 +28,6 @@ "setup": "bun scripts/db-setup.ts", "setup:memory": "bun run scripts/setup-memory.ts", "reset": "bun scripts/reset.ts", - "check:structural": "bun run scripts/checks/run.ts", "build:sidecar-image": "docker build -f apps/sidecar/Dockerfile -t corbits-sidecar:dev .", "eval": "bun run scripts/evals-run.ts" }, diff --git a/scripts/checks/browser-safe-subpaths.ts b/scripts/checks/browser-safe-subpaths.ts deleted file mode 100644 index 04b444fa5e..0000000000 --- a/scripts/checks/browser-safe-subpaths.ts +++ /dev/null @@ -1,342 +0,0 @@ -// check:browser-safe-subpaths — a package that declares a "browser-safe" -// subpath (`@corbits/workflows/client`, `@corbits/agent-directory/client`, -// …) is making a promise: nothing reachable from that one entry point -// pulls in a server-only dependency. Comments have said as much for a -// while; this check is what actually makes it true, by walking the real -// transitive import graph — relative imports and `@corbits/*` workspace -// subpath imports — starting at each declared entry, and failing if it -// ever reaches `postgres`, `drizzle-orm`, `hono`, or any `@intx/*` import. -// -// `import type` / `export type` statements are skipped: a type-only -// import is erased at compile time and carries no runtime dependency. A -// package this repo doesn't own (an external git dependency -// like `@corbits/mailbox` or `@corbits/react-ui`) is an opaque leaf — its -// own source isn't in this tree to walk, so it can't be statically -// checked here and is trusted the same way a `node_modules` import -// always is. -// -// New browser-safe subpaths need an explicit ruling in ENTRIES below — -// this check only walks what it's told to. A package.json declaring the -// conventional `./client` subpath with no matching ENTRIES ruling is -// itself a violation, so a new client can't be silently forgotten; a -// non-conventional browser-safe subpath (like api-query's `./envelope`) -// still needs to be added to ENTRIES by hand. -import { Glob } from "bun"; -import path from "node:path"; -import { emptyReport, reportAndExit, rootFromArgs, type CheckReport } from "./lib/repo"; - -const SCAN_GLOB = "packages/**/*.{ts,tsx}"; - -export interface PackageManifest { - readonly name: string; - readonly exports: Readonly>; -} - -export interface BrowserSafeEntry { - readonly package: string; - /** An `exports` key, e.g. `"./client"` or `"."`. */ - readonly subpath: string; -} - -/** - * The declared browser-safe subpaths this check enforces. Each of these - * packages carries a header comment / README claim that nothing server- - * only reaches this entry point — this is what backs that claim. - */ -export const ENTRIES: readonly BrowserSafeEntry[] = [ - { package: "@corbits/agent-directory", subpath: "./client" }, - { package: "@corbits/api-query", subpath: "." }, - // The envelope alone (UnauthenticatedError, ApiQueryError, toAPIQuery) has - // no React/JSX dependency, so packages without `jsx` configured import - // this subpath rather than the root, which drags in query-view.tsx. - { package: "@corbits/api-query", subpath: "./envelope" }, - // CL-6099: the Inference settings section (packages/inference-settings) - // imports this for its known-provider base URL seeds — plain data, no - // HTTP, so it is safe alongside the other browser-facing subpaths above. - // @corbits/approvals' root reaches @intx/authz for the grant-allowance - // gate; `headlineFor` is pure string work over a tool snapshot, so the - // browser composes an approval's headline through this subpath. - { package: "@corbits/approvals", subpath: "./headline" }, - // CL-7373: the workflow source-tree constants, the definition-detail - // wire schema, and the pure lifecycle derivation — the same read - // `apps/web`'s workflow detail page (`workflow-detail-api.ts`) needs. - { package: "@corbits/workflows", subpath: "./client" }, - // The loopback OAuth descriptors in templates/connectors.ts source - // their client ids, redirect URIs, and scopes from these pure-data - // constant files so the descriptor identity and the adapter refresh - // path cannot drift. Plain data, zero imports — enforced here. - { package: "@corbits/codex-provider", subpath: "./constants" }, - { package: "@corbits/xai-provider", subpath: "./constants" }, -]; - -const DENYLIST_PATTERNS: readonly RegExp[] = [ - /^@intx\//, - /^hono(\/|$)/, - /^postgres(\/|$)/, - /^drizzle-orm(\/|$)/, - /^node:/, -]; - -function isDenylisted(specifier: string): boolean { - return DENYLIST_PATTERNS.some((pattern) => pattern.test(specifier)); -} - -function entryLabel(entry: BrowserSafeEntry): string { - if (entry.subpath === ".") return entry.package; - return `${entry.package}/${entry.subpath.replace(/^\.\//, "")}`; -} - -interface ImportSpecifier { - readonly specifier: string; - readonly typeOnly: boolean; -} - -/** - * Blanks out line and block comments, preserving offsets and newlines so - * the patterns below cannot match prose. Without this, a comment - * mentioning the word `import` before a real import swallows the lines - * between them — `[^;]*?` spans newlines — and reports the file's genuine - * type-only import as a value import. - */ -export function stripComments(contents: string): string { - return contents - .replace(/\/\*[\s\S]*?\*\//g, (match) => match.replace(/[^\n]/g, " ")) - .replace(/\/\/[^\n]*/g, (match) => " ".repeat(match.length)); -} - -/** - * Extracts every static `from "..."` import/export specifier from a - * source file, plus dynamic `import("...")` and bare side-effect - * `import "...";` forms. A statement is `typeOnly` only when the whole - * statement is `import type` / `export type` — a mixed statement like - * `import { type X, y } from "z"` is conservatively treated as a value - * import, since `y` really is one. - */ -export function parseImportSpecifiers(source: string): ImportSpecifier[] { - const contents = stripComments(source); - const results: ImportSpecifier[] = []; - - const fromPattern = /\b(import|export)\s+(type\s+)?[^;]*?\bfrom\s+["']([^"']+)["']/g; - for (const match of contents.matchAll(fromPattern)) { - const specifier = match[3]; - if (specifier === undefined) continue; - results.push({ specifier, typeOnly: match[2] !== undefined }); - } - - const dynamicPattern = /\bimport\s*\(\s*["']([^"']+)["']/g; - for (const match of contents.matchAll(dynamicPattern)) { - const specifier = match[1]; - if (specifier === undefined) continue; - results.push({ specifier, typeOnly: false }); - } - - const sideEffectPattern = /\bimport\s+["']([^"']+)["']\s*;/g; - for (const match of contents.matchAll(sideEffectPattern)) { - const specifier = match[1]; - if (specifier === undefined) continue; - results.push({ specifier, typeOnly: false }); - } - - return results; -} - -function resolveRelative( - fromRelPath: string, - specifier: string, - files: ReadonlyMap, -): string | undefined { - const dir = path.posix.dirname(fromRelPath); - const base = path.posix.normalize(path.posix.join(dir, specifier)); - const candidates = [base, `${base}.ts`, `${base}.tsx`, `${base}/index.ts`, `${base}/index.tsx`]; - return candidates.find((candidate) => files.has(candidate)); -} - -function splitPackageSpecifier(specifier: string): { - packageName: string; - rest: string; -} { - const segments = specifier.split("/"); - const packageName = segments.slice(0, 2).join("/"); - const rest = segments.slice(2).join("/"); - return { packageName, rest }; -} - -/** - * `./client` is this repo's naming convention for a browser-safe subpath - * (inbox, agent-directory, presence, bench, - * preferences all use it) — a package that declares one is making the same - * "nothing server-only reaches here" promise ENTRIES exists to check, so a - * declared `./client` with no ENTRIES ruling is itself a violation, not a - * silent skip. - */ -const CONVENTIONAL_SUBPATH = "./client"; - -function findUnruledClientExports( - entries: readonly BrowserSafeEntry[], - packages: readonly PackageManifest[], -): string[] { - const ruled = new Set(entries.map((entry) => `${entry.package}${entry.subpath}`)); - const violations: string[] = []; - for (const pkg of packages) { - if (pkg.exports[CONVENTIONAL_SUBPATH] === undefined) continue; - if (ruled.has(`${pkg.name}${CONVENTIONAL_SUBPATH}`)) continue; - violations.push( - `${pkg.name} declares a "${CONVENTIONAL_SUBPATH}" export with no ` + - `ENTRIES ruling in scripts/checks/browser-safe-subpaths.ts — add ` + - `{ package: "${pkg.name}", subpath: "${CONVENTIONAL_SUBPATH}" } to ENTRIES.`, - ); - } - return violations; -} - -/** - * Walks the transitive import graph of every declared entry and reports - * a violation for each denylisted import reached, and for any import - * this check cannot resolve (a broken relative import, or a workspace - * package subpath not declared in that package's own `exports`) — an - * unresolvable import is exactly the kind of drift this check exists to - * catch, not something to silently skip. - */ -export function auditBrowserSafeSubpaths( - entries: readonly BrowserSafeEntry[], - packages: readonly PackageManifest[], - files: ReadonlyMap, -): CheckReport { - const report = emptyReport(); - - report.violations.push(...findUnruledClientExports(entries, packages)); - - for (const entry of entries) { - const label = entryLabel(entry); - const manifest = packages.find((pkg) => pkg.name === entry.package); - if (manifest === undefined) { - report.violations.push(`${label}: no package named "${entry.package}" found.`); - continue; - } - const entryRelPath = manifest.exports[entry.subpath]; - if (entryRelPath === undefined) { - report.violations.push(`${label}: "${entry.package}" declares no "${entry.subpath}" export.`); - continue; - } - - const visited = new Set(); - const stack: { relPath: string; chain: readonly string[] }[] = [ - { relPath: entryRelPath, chain: [entryRelPath] }, - ]; - let reached = 0; - - while (stack.length > 0) { - const current = stack.pop(); - if (current === undefined) break; - const { relPath, chain } = current; - if (visited.has(relPath)) continue; - visited.add(relPath); - reached += 1; - - const contents = files.get(relPath); - if (contents === undefined) { - report.violations.push( - `${label}: cannot read "${relPath}" (chain: ${chain.join(" -> ")}).`, - ); - continue; - } - - for (const { specifier, typeOnly } of parseImportSpecifiers(contents)) { - if (typeOnly) continue; - - if (isDenylisted(specifier)) { - report.violations.push( - `${label}: reaches server-only import "${specifier}" via ` + - `${relPath} (chain: ${chain.join(" -> ")} -> ${specifier}).`, - ); - continue; - } - - if (specifier.startsWith(".")) { - const resolved = resolveRelative(relPath, specifier, files); - if (resolved === undefined) { - report.violations.push( - `${label}: cannot resolve "${specifier}" imported from "${relPath}".`, - ); - continue; - } - stack.push({ relPath: resolved, chain: [...chain, resolved] }); - continue; - } - - if (specifier.startsWith("@corbits/")) { - const { packageName, rest } = splitPackageSpecifier(specifier); - const depManifest = packages.find((pkg) => pkg.name === packageName); - if (depManifest === undefined) continue; // external dependency, opaque leaf - const key = rest === "" ? "." : `./${rest}`; - const target = depManifest.exports[key]; - if (target === undefined) { - report.violations.push( - `${label}: "${specifier}" imported from "${relPath}" has no ` + - `declared "${key}" export in ${packageName}'s package.json.`, - ); - continue; - } - stack.push({ relPath: target, chain: [...chain, target] }); - continue; - } - - // A bare external module (arktype, react, lucide-react, …) that - // isn't denylisted: a leaf, nothing further to resolve. - } - } - - report.notes.push(`${label}: ${reached} file(s) in its import graph, clean.`); - } - - return report; -} - -async function scanPackageManifests(root: string): Promise { - const manifests: PackageManifest[] = []; - const glob = new Glob("packages/*/package.json"); - for await (const relPath of glob.scan({ cwd: root, dot: false })) { - const raw: unknown = await Bun.file(path.join(root, relPath)).json(); - if ( - typeof raw !== "object" || - raw === null || - !("name" in raw) || - typeof raw.name !== "string" - ) { - continue; - } - const exportsField = - "exports" in raw && typeof raw.exports === "object" && raw.exports !== null - ? (raw.exports as Record) - : {}; - const packageDir = path.posix.dirname(relPath); - const exportsMap: Record = {}; - for (const [key, value] of Object.entries(exportsField)) { - if (typeof value !== "string") continue; - exportsMap[key] = path.posix.normalize(path.posix.join(packageDir, value)); - } - manifests.push({ name: raw.name, exports: exportsMap }); - } - return manifests; -} - -async function scanSourceFiles(root: string): Promise> { - const files = new Map(); - const glob = new Glob(SCAN_GLOB); - for await (const relPath of glob.scan({ cwd: root, dot: false })) { - if (relPath.includes("node_modules/")) continue; - if (relPath.includes("/dist/") || relPath.startsWith("dist/")) continue; - files.set(relPath, await Bun.file(path.join(root, relPath)).text()); - } - return files; -} - -async function main(): Promise { - const args = Bun.argv.slice(2); - const root = rootFromArgs(args); - const [packages, files] = await Promise.all([scanPackageManifests(root), scanSourceFiles(root)]); - const report = auditBrowserSafeSubpaths(ENTRIES, packages, files); - reportAndExit("check:browser-safe-subpaths", report); -} - -if (import.meta.main) await main(); diff --git a/scripts/checks/catalog-pins.ts b/scripts/checks/catalog-pins.ts deleted file mode 100644 index e0f511ec79..0000000000 --- a/scripts/checks/catalog-pins.ts +++ /dev/null @@ -1,151 +0,0 @@ -// check:catalog-pins — a dependency declared in the root `catalog` (or a -// named catalog under `catalogs`) must be consumed as `catalog:` (or -// `catalog:`) everywhere. A literal range for a catalogued -// dependency is how the same package ends up pinned two ways (CL-7442 -// found drizzle-orm, hono, postgres, and @types/react-dom drifted this -// way) — one workspace bumps its own literal, the rest don't, and the -// version actually installed depends on hoisting order. -// -// vendor/intx/*/package.json is excluded: those manifests are governed -// by the vendoring ledger (VENDORED.md), not by this repo's catalog. -import { existsSync, readFileSync } from "node:fs"; -import path from "node:path"; -import { Glob } from "bun"; -import { type } from "arktype"; -import { emptyReport, reportAndExit, rootFromArgs, type CheckReport } from "./lib/repo"; - -const DEPENDENCY_FIELDS = ["dependencies", "devDependencies", "peerDependencies"] as const; - -const PackageJson = type({ - "dependencies?": "Record", - "devDependencies?": "Record", - "peerDependencies?": "Record", - "catalog?": "Record", - "catalogs?": "Record>", - "workspaces?": "unknown", -}); - -type PackageJson = typeof PackageJson.infer; - -export const WORKSPACE_GLOBS = [ - "apps/*/package.json", - "packages/*/package.json", - "tools/*/package.json", - "templates/package.json", - "workflows/*/package.json", -]; - -function parsePackageJson(raw: string, source: string): PackageJson { - const parsed = PackageJson(JSON.parse(raw)); - if (parsed instanceof type.errors) { - throw new Error(`${source}: invalid package.json — ${parsed.summary}`); - } - return parsed; -} - -/** Resolves the root's default catalog plus any named catalogs, either - * from a top-level `catalog`/`catalogs` or from `workspaces.catalog` / - * `workspaces.catalogs` (bun accepts both shapes). */ -export function resolveCatalogs(rootPackageJson: PackageJson): { - readonly default: Readonly>; - readonly named: Readonly>>>; -} { - const workspaces = rootPackageJson.workspaces; - const workspacesObject = - workspaces && typeof workspaces === "object" && !Array.isArray(workspaces) - ? (workspaces as { - catalog?: Record; - catalogs?: Record>; - }) - : undefined; - - return { - default: rootPackageJson.catalog ?? workspacesObject?.catalog ?? {}, - named: rootPackageJson.catalogs ?? workspacesObject?.catalogs ?? {}, - }; -} - -export function auditCatalogPins( - catalog: Readonly>, - workspaces: readonly { dir: string; packageJson: PackageJson }[], - namedCatalogs: Readonly>>> = {}, -): CheckReport { - const report = emptyReport(); - const catalogued = new Set(Object.keys(catalog)); - const cataloguedByName = new Map( - Object.entries(namedCatalogs).map(([catalogName, deps]) => [ - catalogName, - new Set(Object.keys(deps)), - ]), - ); - - for (const { dir, packageJson } of workspaces) { - for (const field of DEPENDENCY_FIELDS) { - const deps = packageJson[field]; - if (!deps) continue; - for (const [name, range] of Object.entries(deps)) { - if (range.startsWith("catalog:")) continue; - if (catalogued.has(name)) { - report.violations.push( - `${dir}/package.json: "${name}" is declared in the root ` + - `catalog but pinned here as a literal ("${range}") instead ` + - `of "catalog:" — either use "catalog:" or drop the entry ` + - `from the root catalog if this package intentionally needs ` + - `a different version.`, - ); - continue; - } - for (const [catalogName, names] of cataloguedByName) { - if (!names.has(name)) continue; - report.violations.push( - `${dir}/package.json: "${name}" is declared in the ` + - `"${catalogName}" catalog but pinned here as a literal ` + - `("${range}") instead of "catalog:${catalogName}" — either ` + - `use "catalog:${catalogName}" or drop the entry from that ` + - `catalog if this package intentionally needs a different ` + - `version.`, - ); - break; - } - } - } - } - return report; -} - -async function listWorkspaces(root: string): Promise<{ dir: string; packageJson: PackageJson }[]> { - const workspaces: { dir: string; packageJson: PackageJson }[] = []; - for (const pattern of WORKSPACE_GLOBS) { - const glob = new Glob(pattern); - for await (const manifestPath of glob.scan(root)) { - const dir = path.dirname(manifestPath); - const packageJson = parsePackageJson( - readFileSync(path.join(root, manifestPath), "utf8"), - manifestPath, - ); - workspaces.push({ dir, packageJson }); - } - } - return workspaces; -} - -async function main(): Promise { - const root = rootFromArgs(Bun.argv.slice(2)); - const rootPackageJsonPath = path.join(root, "package.json"); - const rootPackageJson = existsSync(rootPackageJsonPath) - ? parsePackageJson(readFileSync(rootPackageJsonPath, "utf8"), "package.json") - : ({} as PackageJson); - const { default: catalog, named: namedCatalogs } = resolveCatalogs(rootPackageJson); - const workspaces = await listWorkspaces(root); - const report = auditCatalogPins(catalog, workspaces, namedCatalogs); - if (workspaces.length === 0) { - report.notes.push("no workspace packages yet."); - } - report.notes.push( - "vendor/intx/*/package.json is excluded — vendored manifests are " + - "governed by VENDORED.md, not this repo's catalog.", - ); - reportAndExit("check:catalog-pins", report); -} - -if (import.meta.main) await main(); diff --git a/scripts/checks/db-gate.ts b/scripts/checks/db-gate.ts deleted file mode 100644 index a8d426b0ca..0000000000 --- a/scripts/checks/db-gate.ts +++ /dev/null @@ -1,64 +0,0 @@ -// check:db-gate — every DB-gated suite must skip through dbGate -// (scripts/e2e/db-gate.ts), never a hand-rolled -// `databaseUrl ? describe.skip : describe` ternary. A hand-rolled gate -// skips in total silence and never honors CI=true — exactly -// the gap CL-7279 closed, one file at a time, until the next merge -// added a new hand-rolled copy the sweep never saw. This check makes -// the invariant self-enforcing instead of a one-time sweep. -// -// vendor/intx is excluded: editing inside a vendored tree carries -// re-pin tax, so its (small, pre-existing) hand-rolled gates are left -// alone — see VENDORED.md. -import { Glob } from "bun"; -import path from "node:path"; -import { emptyReport, reportAndExit, rootFromArgs, type CheckReport } from "./lib/repo"; - -const SCAN_DIRS = ["apps", "packages", "scripts"]; -const HAND_ROLLED_GATE_PATTERN = - /const\s+describeIfDb\s*=\s*\w+\s*===\s*(?:undefined|"")\s*\?\s*describe\.skip\s*:\s*describe;/; - -export async function scanFiles(root: string, dirs: readonly string[]): Promise { - const files: string[] = []; - for (const dir of dirs) { - const glob = new Glob(`${dir}/**/*.test.ts`); - for await (const file of glob.scan({ cwd: root, dot: false })) { - if (file.includes("node_modules/")) continue; - // This check's own test fixtures deliberately contain the - // hand-rolled pattern as string literals, not real gates. - if (file.startsWith("scripts/checks/")) continue; - files.push(file); - } - } - return files; -} - -export function auditDbGate(files: readonly { relPath: string; contents: string }[]): CheckReport { - const report = emptyReport(); - for (const { relPath, contents } of files) { - if (!HAND_ROLLED_GATE_PATTERN.test(contents)) continue; - report.violations.push( - `${relPath}: hand-rolls a describeIfDb gate instead of using dbGate ` + - `from scripts/e2e/db-gate.ts. A hand-rolled gate skips silently ` + - `and never honors CI=true — replace it with ` + - `\`const describeIfDb = dbGate(databaseUrl, import.meta.path);\`.`, - ); - } - return report; -} - -async function main(): Promise { - const args = Bun.argv.slice(2); - const root = rootFromArgs(args); - const relPaths = await scanFiles(root, SCAN_DIRS); - const files = await Promise.all( - relPaths.map(async (relPath) => ({ - relPath, - contents: await Bun.file(path.join(root, relPath)).text(), - })), - ); - const report = auditDbGate(files); - report.notes.push(`scanned ${files.length} *.test.ts file(s) under ${SCAN_DIRS.join(", ")}`); - reportAndExit("check:db-gate", report); -} - -if (import.meta.main) await main(); diff --git a/scripts/checks/deletion.ts b/scripts/checks/deletion.ts deleted file mode 100644 index 4de7994b85..0000000000 --- a/scripts/checks/deletion.ts +++ /dev/null @@ -1,64 +0,0 @@ -// check:deletion — donor deletion, the mechanical half of hard cutover. -// -// scripts/checks/replaced-paths.txt is the ledger of in-repo paths that -// ported code claims to have replaced. A change that replaces a path -// adds it to the ledger in the same commit that deletes it; this check -// fails while any ledgered path still exists, so a replacement can -// never quietly leave the old path alive beside the new one. Extra -// paths may be passed as arguments for ad hoc use. -import { existsSync, readFileSync } from "node:fs"; -import path from "node:path"; -import { - emptyReport, - positionalArgs, - reportAndExit, - rootFromArgs, - type CheckReport, -} from "./lib/repo"; - -export const LEDGER_PATH = "scripts/checks/replaced-paths.txt"; - -/** One path per line; blank lines and `#` comments ignored. */ -export function parseLedger(text: string): string[] { - const paths: string[] = []; - for (const rawLine of text.split("\n")) { - const line = rawLine.trim(); - if (line.length === 0 || line.startsWith("#")) continue; - paths.push(line); - } - return paths; -} - -export function auditReplacedPaths( - paths: readonly string[], - exists: (repoRelativePath: string) => boolean, -): CheckReport { - const report = emptyReport(); - for (const replaced of paths) { - if (!exists(replaced)) continue; - report.violations.push( - `${replaced}: a change claimed to replace this path, but it still ` + - `exists. Hard cutover means the replaced path is deleted in the ` + - `same change that replaces it — delete it now, or remove its ` + - `row from ${LEDGER_PATH} if the replacement claim was wrong.`, - ); - } - return report; -} - -function main(): void { - const args = Bun.argv.slice(2); - const root = rootFromArgs(args); - const ledgerFile = path.join(root, LEDGER_PATH); - const ledgered = existsSync(ledgerFile) ? parseLedger(readFileSync(ledgerFile, "utf8")) : []; - const paths = [...ledgered, ...positionalArgs(args)]; - const report = auditReplacedPaths(paths, (repoRelativePath) => - existsSync(path.join(root, repoRelativePath)), - ); - if (paths.length === 0) { - report.notes.push("the replaced-paths ledger is empty."); - } - reportAndExit("check:deletion", report); -} - -if (import.meta.main) main(); diff --git a/scripts/checks/error-envelope.ts b/scripts/checks/error-envelope.ts deleted file mode 100644 index 691c519710..0000000000 --- a/scripts/checks/error-envelope.ts +++ /dev/null @@ -1,88 +0,0 @@ -// check:error-envelope — a structural invariant: hub routes answer a -// failure with `@corbits/error-sink`'s `makeErrorEnvelope` (`code`, -// `userMessage`, `refId`). A locally-defined `{ error: { code, message } }` -// factory is a second envelope, and a second envelope is how `refId` -// support silently goes missing. This check greps for those factories — -// never for a comment or an arktype parser that happens to be named -// `ErrorEnvelope`. -// -// Documented exceptions live in ALLOWLIST below. Each entry is an -// explicit ruling that the named file may keep a helper because it -// already wraps the canonical `makeErrorEnvelope` (onboarding, -// connections, the hub's own template routes) or *is* the canonical -// helper (error-sink). -import { Glob } from "bun"; -import path from "node:path"; -import { emptyReport, reportAndExit, rootFromArgs, type CheckReport } from "./lib/repo"; - -const SCAN_DIRS = ["apps", "packages", "workflows"]; - -const ALLOWLIST = new Set([ - "packages/error-sink/src/error-envelope.ts", - "packages/onboarding/src/routes.ts", - "packages/connections/src/connect-github-routes.ts", - "apps/hub/src/templates/template-block-routes.ts", -]); - -// Arrow: `const ErrorEnvelope = (code: string, message: string) => ({ -// error: { code, message }, -// });` -// Function: `function errorEnvelope(code: string, message: string) { -// return { error: { code, message } }; -// }` -const ARROW_FACTORY = - /(?:export\s+)?(?:const|let|var)\s+(ErrorEnvelope|errorEnvelope)\s*=\s*\(\s*code(?:\s*:\s*string)?\s*,\s*message(?:\s*:\s*string)?\s*\)\s*=>\s*\(?\s*\{\s*error:\s*\{\s*code\s*,\s*message\s*\}\s*,?\s*\}\s*\)?/s; -const FUNCTION_FACTORY = - /(?:export\s+)?function\s+(ErrorEnvelope|errorEnvelope)\s*\(\s*code(?:\s*:\s*string)?\s*,\s*message(?:\s*:\s*string)?\s*\)\s*\{\s*return\s*\{\s*error:\s*\{\s*code\s*,\s*message\s*\}\s*,?\s*\}\s*;?\s*\}/s; - -export async function scanFiles(root: string, dirs: readonly string[]): Promise { - const files: string[] = []; - for (const dir of dirs) { - const glob = new Glob(`${dir}/**/*.{ts,tsx}`); - for await (const file of glob.scan({ cwd: root, dot: false })) { - if (file.includes("node_modules/")) continue; - if (file.includes("/dist/") || file.startsWith("dist/")) continue; - if (file.includes(".test.") || file.includes("/test/")) continue; - files.push(file); - } - } - return files; -} - -export function auditLocalErrorEnvelopeFactories( - files: readonly { relPath: string; contents: string }[], -): CheckReport { - const report = emptyReport(); - for (const { relPath, contents } of files) { - if (ALLOWLIST.has(relPath)) { - report.notes.push(`${relPath}: allowlisted (canonical makeErrorEnvelope helper)`); - continue; - } - const arrow = ARROW_FACTORY.test(contents); - const fn = FUNCTION_FACTORY.test(contents); - if (!arrow && !fn) continue; - report.violations.push( - `${relPath}: defines a local { error: { code, message } } factory. ` + - `Hub routes must use makeErrorEnvelope from @corbits/error-sink ` + - `so every failure carries code, userMessage, and refId.`, - ); - } - return report; -} - -async function main(): Promise { - const args = Bun.argv.slice(2); - const root = rootFromArgs(args); - const relPaths = await scanFiles(root, SCAN_DIRS); - const files = await Promise.all( - relPaths.map(async (relPath) => ({ - relPath, - contents: await Bun.file(path.join(root, relPath)).text(), - })), - ); - const report = auditLocalErrorEnvelopeFactories(files); - report.notes.push(`scanned ${files.length} file(s) under ${SCAN_DIRS.join(", ")}`); - reportAndExit("check:error-envelope", report); -} - -if (import.meta.main) await main(); diff --git a/scripts/checks/hub-git-safety.ts b/scripts/checks/hub-git-safety.ts deleted file mode 100644 index f46a171359..0000000000 --- a/scripts/checks/hub-git-safety.ts +++ /dev/null @@ -1,120 +0,0 @@ -// check:hub-git-safety — fail when this branch carries a commit authored -// by the hub's own git-on-disk machinery (or a seed/deploy test that -// inherited the working tree's GIT_DIR / HUB_DATA_DIR). Those commits -// have landed on the working branch and wiped checkouts; catching them -// in CI is cheaper than reconstructing a deleted worktree. -import { spawnSync } from "node:child_process"; -import { emptyReport, reportAndExit, rootFromArgs, type CheckReport } from "./lib/repo"; - -export const JUNK_COMMIT_SUBJECTS = [ - "initial @corbits/fake-tools@0.0.1", - "seed v1", - "Deploy the default workflow definition", -] as const; - -export const JUNK_AUTHOR_EMAILS = ["hub@interchange.local", "seed@workbench.localhost"] as const; - -/** A commit touching this many paths with no source-tree file is hub dump. */ -export const LARGE_COMMIT_FILE_THRESHOLD = 50; - -const SOURCE_PATH = /^(apps|packages|scripts|workflows|docs|test|tools|\.github)\//; - -export type InspectedCommit = { - readonly hash: string; - readonly subject: string; - readonly authorEmail: string; - readonly files: readonly string[]; -}; - -export function isSourcePath(file: string): boolean { - return SOURCE_PATH.test(file) && !file.includes(".data/"); -} - -export function auditHubJunkCommits(commits: readonly InspectedCommit[]): CheckReport { - const report = emptyReport(); - for (const commit of commits) { - const short = commit.hash.slice(0, 8); - if ((JUNK_COMMIT_SUBJECTS as readonly string[]).includes(commit.subject)) { - report.violations.push( - `${short}: subject ${JSON.stringify(commit.subject)} is hub/seed ` + - `git-on-disk machinery, not a workbench change. Reset that commit ` + - `off the branch; tests must use a disposable HUB_DATA_DIR outside ` + - `this work tree (test/disposable-hub-data-dir.ts).`, - ); - } - if ((JUNK_AUTHOR_EMAILS as readonly string[]).includes(commit.authorEmail)) { - report.violations.push( - `${short}: author ${commit.authorEmail} is hub/seed git identity ` + - `(subject ${JSON.stringify(commit.subject)}). That commit is ` + - `on-disk hub state, not a workbench change.`, - ); - } - const dataFiles = commit.files.filter((file) => file === ".data" || file.startsWith(".data/")); - if (dataFiles.length > 0) { - report.violations.push( - `${short}: commits hub data paths (${dataFiles.slice(0, 3).join(", ")}` + - `${dataFiles.length > 3 ? `, … +${dataFiles.length - 3}` : ""}). ` + - `HUB_DATA_DIR must not live inside this work tree.`, - ); - } - if (commit.files.length > LARGE_COMMIT_FILE_THRESHOLD && !commit.files.some(isSourcePath)) { - report.violations.push( - `${short}: touches ${commit.files.length} paths with no corresponding ` + - `source-tree change — the shape of a hub git-on-disk dump onto the ` + - `working branch.`, - ); - } - } - return report; -} - -function git(root: string, args: readonly string[]): string | undefined { - const result = spawnSync("git", [...args], { cwd: root, encoding: "utf8" }); - return result.status === 0 ? result.stdout.trim() : undefined; -} - -export function resolveBaseRef(root: string, explicit: string | undefined): string | undefined { - if (explicit !== undefined && explicit.length > 0) return explicit; - return git(root, ["merge-base", "HEAD", "origin/main"]); -} - -export function readCommitsSince(root: string, baseRef: string): InspectedCommit[] { - const hashes = git(root, ["log", "--format=%H", `${baseRef}..HEAD`]); - if (hashes === undefined || hashes === "") return []; - const commits: InspectedCommit[] = []; - for (const hash of hashes.split("\n")) { - if (hash === "") continue; - const meta = git(root, ["log", "-1", "--format=%s%n%ae", hash]); - if (meta === undefined) continue; - const newline = meta.indexOf("\n"); - const subject = newline === -1 ? meta : meta.slice(0, newline); - const authorEmail = newline === -1 ? "" : meta.slice(newline + 1); - const names = git(root, ["diff-tree", "--no-commit-id", "--name-only", "-r", hash]); - commits.push({ - hash, - subject, - authorEmail, - files: names === undefined || names === "" ? [] : names.split("\n"), - }); - } - return commits; -} - -function main(): void { - const root = rootFromArgs(Bun.argv.slice(2)); - const baseRef = resolveBaseRef(root, process.env["CHECK_BASE_REF"]); - if (baseRef === undefined) { - const report = emptyReport(); - report.notes.push( - "no base ref (no origin/main, no CHECK_BASE_REF); skipping — CI " + - "supplies the base ref for the authoritative run", - ); - reportAndExit("check:hub-git-safety", report); - } - const commits = readCommitsSince(root, baseRef); - const report = auditHubJunkCommits(commits); - report.notes.push(`${commits.length} commit(s) since ${baseRef.slice(0, 8)}`); - reportAndExit("check:hub-git-safety", report); -} - -if (import.meta.main) main(); diff --git a/scripts/checks/hub-workbench-routes.ts b/scripts/checks/hub-workbench-routes.ts deleted file mode 100644 index b798e88a26..0000000000 --- a/scripts/checks/hub-workbench-routes.ts +++ /dev/null @@ -1,122 +0,0 @@ -// Hub-zero T3 (CL-8114) deleted the hub's workbench-scoped GitHub-connect -// mount; hub-zero T4 (CL-8126) cut the scheduled delivery join and the last -// `@workbench/templates` import. `@corbits/connections` keeps owning the -// `createConnectGithubRoutes` factory (its own package tests still cover -// it). GitHub connect/disconnect goes through the native tenant-scoped -// `/api/tenants/:tenantId/connections/*` routes only; the room card's -// state/start-reviewing rebind is a connections follow-up (the card renders -// its existing no-port disabled framing until then). -// -// This check locks both done-whens: no `createConnectGithubRoutes` -// reference and no bare `${TENANT_PREFIX}/workbenches` mount in -// `apps/hub/src`, the brief's `workbench-settings|/workbenches.*github| -// github.*workbench` rg empty over `apps/hub/src`, no `@workbench/templates` -// import anywhere in `apps/hub/src`, and no `listWorkbenchSettings` at all. -// -// Get/update variants (the T3 critic note): `getWorkbenchSettings` survives -// exactly once, inside `createCommandRoutes`' membership check — the -// T5-owned command guard a file carries the `COMMAND_GUARD_MARKER` for. -// `updateWorkbenchSettings` has no such exception. Any get/update call site -// outside a marker-carrying file is a settings-row reader the cutover -// missed. List-only was never an option: the surviving call site is a get, -// and the check treats each variant on its own terms. -import { readFileSync } from "node:fs"; -import { emptyReport, reportAndExit, rootFromArgs, type CheckReport } from "./lib/repo"; - -export type SourceFile = { - readonly relPath: string; - readonly contents: string; -}; - -/** The T5-owned command guard allowed to hold the surviving get-variant - * read: `createCommandRoutes`' membership check. Any settings-row get/update - * call site outside a file containing this marker is a reader the hub-zero - * cutover missed. */ -const COMMAND_GUARD_MARKER = "workbenchBelongsToTenant"; - -/** The brief's done-when rg, per line, case-insensitive. */ -const DONE_WHEN_PATTERN = /workbench-settings|\/workbenches.*github|github.*workbench/i; - -export function auditHubWorkbenchRoutes(files: readonly SourceFile[]): CheckReport { - const report = emptyReport(); - for (const file of files) { - if (file.relPath !== "apps/hub/src/index.ts") continue; - if (file.contents.includes("createConnectGithubRoutes")) { - report.violations.push( - `${file.relPath}: still references createConnectGithubRoutes — ` + - "the hub's /workbenches GitHub-connect mount must be deleted " + - "(connect/disconnect is native /connections/* now)", - ); - } - if (file.contents.includes("${TENANT_PREFIX}/workbenches`")) { - report.violations.push( - `${file.relPath}: still mounts \`\${TENANT_PREFIX}/workbenches\` — ` + - "the workbench-scoped GitHub-connect mount must be deleted", - ); - } - } - for (const file of files) { - let offset = 0; - for (const line of file.contents.split("\n")) { - const lineNumber = file.contents.slice(0, offset).split("\n").length; - offset += line.length + 1; - if (DONE_WHEN_PATTERN.test(line)) { - report.violations.push( - `${file.relPath}:${lineNumber}: ` + `matches the hub-zero done-when rg: ${line.trim()}`, - ); - } - } - } - for (const file of files) { - if (file.contents.includes("@workbench/templates")) { - report.violations.push( - `${file.relPath}: still imports @workbench/templates — ` + - "the hub's connector surface is native now " + - "(./native-connector-registry.ts); the legacy dep must stay deleted", - ); - } - } - for (const file of files) { - if (!file.contents.includes("listWorkbenchSettings")) continue; - report.violations.push( - `${file.relPath}: listWorkbenchSettings survives — ` + - "T4 cut the scheduled-delivery seam that owned the only list read", - ); - } - for (const file of files) { - const readsSettingsRow = - file.contents.includes("getWorkbenchSettings") || - file.contents.includes("updateWorkbenchSettings"); - if (!readsSettingsRow) continue; - if (file.contents.includes(COMMAND_GUARD_MARKER)) { - report.notes.push( - `${file.relPath}: a settings-row get/update read survives inside ` + - "the T5-owned command guard only — documented, not a violation", - ); - continue; - } - report.violations.push( - `${file.relPath}: a settings-row get/update read outside the ` + - "T5-owned command guard — a workbench-settings reader hub-zero missed", - ); - } - return report; -} - -async function main(): Promise { - const root = rootFromArgs(process.argv); - const files: SourceFile[] = []; - for (const dir of ["apps/hub/src"]) { - const glob = new Bun.Glob(`${dir}/**/*.{ts,tsx}`); - for await (const relPath of glob.scan({ cwd: root, dot: false })) { - if (relPath.includes("node_modules")) continue; - files.push({ - relPath, - contents: readFileSync(`${root}/${relPath}`, "utf8"), - }); - } - } - reportAndExit("check:hub-workbench-routes", auditHubWorkbenchRoutes(files)); -} - -if (import.meta.main) await main(); diff --git a/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt deleted file mode 100644 index 73f984fe5f..0000000000 --- a/scripts/checks/kill-dates.txt +++ /dev/null @@ -1,32 +0,0 @@ -# Kill-date registry, read by check:killdates. -# -# Every temporary path registers here in the change that introduces it: -# -# | | [| ] -# -# check:killdates fails once the date has passed while the path still -# exists, and fails on stale rows whose path is gone. Vendored paths -# (see VENDORED.md) and short-lived bridges are the intended customers. -# -# Vendored rows also record a sha256 content hash of their tree -# (sorted relative paths, each hashed as path + NUL + bytes, -# node_modules excluded). check:killdates recomputes it and fails on -# drift: editing a vendored tree means updating this hash and the -# package's VENDORED-FROM delta line in the same change. -apps/sidecar | sawyer | 2026-11-03 -vendor/intx/agent | sawyer | 2026-11-03 | 6aa9a129b7bd9fad2071889dbcbe9d4693f77d2ede90199fefb08bd5e2abd52f -vendor/intx/db | sawyer | 2026-11-03 | 056628aa03bda8c05171a616759649e35afcb11494cc081c90e18972f74eaa94 -vendor/intx/harness | sawyer | 2026-11-03 | 399b4e8b3b18e8969e815427625e1bfc6746f31e791fb92cdbc2aaf2c5080dbf -vendor/intx/hub-agent | sawyer | 2026-11-03 | f0d61f41ff5502df3aea0207b794ed84aa8f941c7914a9cbfd3ebdcf6b0fdcf4 -vendor/intx/hub-api | sawyer | 2026-11-03 | b8d78972b2f86a97aeba0c9392b1122356ede77a638c53502d3e37c15f8d0bca -vendor/intx/hub-common | sawyer | 2026-11-03 | 2fe74684593083f422023b2d6b337e414b5b319c6bfe02c7fa157130d7162e41 -vendor/intx/hub-sessions | sawyer | 2026-11-03 | 903a55c58293e40111daadb58223dfd9bfbf0dfa2c0d5ebef2ef1ee73f21f305 -vendor/intx/inference | sawyer | 2026-11-03 | 4227655a064d1aaa953e31d352e9e2382fe0e5172d4fe267bf14a8bbe8a3a1b1 -vendor/intx/mail-memory | sawyer | 2026-11-03 | e281498352adbc2face631744ce9a02cb838c60a6c318961022904f0fd05d192 -vendor/intx/mailbox | sawyer | 2026-11-03 | f0525d9facb17e68556f1032cac42c0bf51b403c0b6a31b2bc3320dbb11b8e41 -vendor/intx/mime | sawyer | 2026-11-03 | e8986e3a315bc7a202c2dae1affd4af46e2a76075c55e9cb123ac9a84fda61f7 -vendor/intx/types | sawyer | 2026-11-03 | 1fdd7cccab3b434fb77c4252a243b4634ee189f6ae63e239b61b0bfcd9c64f50 -vendor/intx/workflow | sawyer | 2026-11-03 | e158bcea0a934edeee47104c20e22e09795f80ee1fd1444702058381ff4c0a70 -vendor/intx/workflow-deploy | sawyer | 2026-11-03 | d1a47505548d4e551ea92296bf7d9f2fa8d2d0b495871686aafcbf687ba7e5dc -vendor/intx/workflow-host | sawyer | 2026-11-03 | 47bac48fd2fc078f1bca73adb144dcf3c713f7a1f1021e3ea00a11916feb650d - diff --git a/scripts/checks/killdates.ts b/scripts/checks/killdates.ts deleted file mode 100644 index 77f7534a4a..0000000000 --- a/scripts/checks/killdates.ts +++ /dev/null @@ -1,191 +0,0 @@ -// check:killdates — kill dates for temporary paths. -// -// scripts/checks/kill-dates.txt registers every temporary path with an -// owner and a kill date; this check fails once the date has passed -// while the path still exists, and fails on stale rows whose path is -// already gone. Vendored code (see VENDORED.md) and short-lived -// bridges register here in the same change that introduces them. -import { existsSync, readFileSync, readdirSync } from "node:fs"; -import path from "node:path"; -import { emptyReport, reportAndExit, rootFromArgs, type CheckReport } from "./lib/repo"; -import { hashDirectory } from "./lib/tree-hash"; - -export const REGISTRY_PATH = "scripts/checks/kill-dates.txt"; - -export interface KillDateEntry { - /** Repo-relative path of the temporary code. */ - path: string; - owner: string; - /** ISO date (YYYY-MM-DD) after which the path may no longer exist. */ - killDate: string; - /** sha256 of the directory tree as recorded (vendored rows only). */ - hash?: string; -} - -export interface ParsedKillDates { - entries: KillDateEntry[]; - problems: string[]; -} - -/** - * Rows are `path | owner | YYYY-MM-DD` with an optional trailing - * `| sha256` column; `#` comments and blanks ignored. - */ -export function parseKillDates(text: string): ParsedKillDates { - const parsed: ParsedKillDates = { entries: [], problems: [] }; - for (const rawLine of text.split("\n")) { - const line = rawLine.trim(); - if (line.length === 0 || line.startsWith("#")) continue; - const columns = line.split("|").map((column) => column.trim()); - const [entryPath, owner, killDate, hash] = columns; - if ( - columns.length < 3 || - columns.length > 4 || - entryPath === undefined || - entryPath.length === 0 || - owner === undefined || - owner.length === 0 || - killDate === undefined - ) { - parsed.problems.push(`malformed row "${line}" — expected "path | owner | YYYY-MM-DD".`); - continue; - } - if (!/^\d{4}-\d{2}-\d{2}$/.test(killDate)) { - parsed.problems.push(`row "${line}" has kill date "${killDate}" — expected YYYY-MM-DD.`); - continue; - } - const entry: KillDateEntry = { path: entryPath, owner, killDate }; - if (hash !== undefined) entry.hash = hash; - parsed.entries.push(entry); - } - return parsed; -} - -/** Repo-relative paths of every directory two levels under vendor/. */ -export function listVendoredPaths(root: string): string[] { - const vendorRoot = path.join(root, "vendor"); - if (!existsSync(vendorRoot)) return []; - const vendored: string[] = []; - for (const bucket of readdirSync(vendorRoot, { withFileTypes: true })) { - if (!bucket.isDirectory()) continue; - const bucketPath = path.join(vendorRoot, bucket.name); - for (const entry of readdirSync(bucketPath, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - // `vendor/intx/*` is a workspace glob, so `bun install` materializes a - // directory holding nothing but `node_modules` for every linked - // package — an install artifact, not a vendored tree. Only a - // directory carrying its own source is something the ledger owes a - // kill date. - if (!hasVendoredSource(path.join(bucketPath, entry.name))) continue; - vendored.push(path.join("vendor", bucket.name, entry.name)); - } - } - return vendored.sort(); -} - -/** - * A vendored tree carries hand-copied source. A directory holding only - * `node_modules` is what a workspace glob leaves behind after an install. - */ -export function hasVendoredSource(directory: string): boolean { - return readdirSync(directory, { withFileTypes: true }).some( - (entry) => entry.name !== "node_modules", - ); -} - -/** Every vendored directory must carry a kill-date registry row. */ -export function auditVendorCoverage( - vendoredPaths: readonly string[], - entries: readonly KillDateEntry[], -): CheckReport { - const report = emptyReport(); - const registered = new Set(entries.map((entry) => entry.path)); - for (const vendoredPath of vendoredPaths) { - if (registered.has(vendoredPath)) continue; - report.violations.push( - `${vendoredPath}: vendored directory with no row in ` + - `${REGISTRY_PATH} — every vendored path registers a kill date ` + - `in the same change that introduces it (see VENDORED.md).`, - ); - } - return report; -} - -/** Every existing vendored row's recorded hash must match its tree. */ -export function auditVendorDrift(entries: readonly KillDateEntry[], root: string): CheckReport { - const report = emptyReport(); - for (const entry of entries) { - if (!entry.path.startsWith("vendor/")) continue; - const dir = path.join(root, entry.path); - if (!existsSync(dir)) continue; - if (entry.hash === undefined || !/^[0-9a-f]{64}$/.test(entry.hash)) { - report.violations.push( - `${entry.path}: registry row is missing a valid sha256 content ` + - `hash column — record hashDirectory() of the vendored tree in ` + - `${REGISTRY_PATH}.`, - ); - continue; - } - if (hashDirectory(dir) !== entry.hash) { - report.violations.push( - `${entry.path}: vendored tree edited without recording it — ` + - `update the hash in ${REGISTRY_PATH} and the package's ` + - `VENDORED-FROM delta line.`, - ); - } - } - return report; -} - -export function auditKillDates( - entries: readonly KillDateEntry[], - today: string, - exists: (repoRelativePath: string) => boolean, -): CheckReport { - const report = emptyReport(); - for (const entry of entries) { - if (!exists(entry.path)) { - report.violations.push( - `${entry.path}: registered in ${REGISTRY_PATH} but no longer ` + - `exists — remove the stale row (owner: ${entry.owner}).`, - ); - continue; - } - if (today > entry.killDate) { - report.violations.push( - `${entry.path}: kill date ${entry.killDate} has passed and the ` + - `path still exists (owner: ${entry.owner}). Replace it with ` + - `the permanent implementation and delete it, or deliberately ` + - `renew the date through review — and update VENDORED.md if ` + - `the path is vendored.`, - ); - continue; - } - report.notes.push( - `${entry.path} is temporary until ${entry.killDate} ` + `(owner: ${entry.owner}).`, - ); - } - return report; -} - -function main(): void { - const root = rootFromArgs(Bun.argv.slice(2)); - const registryFile = path.join(root, REGISTRY_PATH); - const parsed = existsSync(registryFile) - ? parseKillDates(readFileSync(registryFile, "utf8")) - : { entries: [], problems: [] }; - const today = new Date().toISOString().slice(0, 10); - const report = auditKillDates(parsed.entries, today, (repoRelativePath) => - existsSync(path.join(root, repoRelativePath)), - ); - report.violations.unshift(...parsed.problems.map((problem) => `${REGISTRY_PATH}: ${problem}`)); - const coverage = auditVendorCoverage(listVendoredPaths(root), parsed.entries); - report.violations.push(...coverage.violations); - report.violations.push(...auditVendorDrift(parsed.entries, root).violations); - if (parsed.entries.length === 0 && parsed.problems.length === 0) { - report.notes.push("no temporary paths are registered."); - } - reportAndExit("check:killdates", report); -} - -if (import.meta.main) main(); diff --git a/scripts/checks/lib/exports.ts b/scripts/checks/lib/exports.ts deleted file mode 100644 index f0b759864d..0000000000 --- a/scripts/checks/lib/exports.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Pure helpers over a package.json "exports" field, used by the -// package-hygiene check. - -import { type } from "arktype"; - -/** - * Every relative file path an "exports" field promises, across all - * subpaths and conditions. These are the files a published artifact - * must actually contain. - */ -export function collectExportTargets(exportsField: unknown): string[] { - const targets = new Set(); - const walk = (value: unknown): void => { - if (typeof value === "string") { - if (value.startsWith("./")) targets.add(value); - return; - } - if (typeof value === "object" && value !== null) { - for (const child of Object.values(value)) walk(child); - } - }; - walk(exportsField); - return [...targets]; -} - -// The one slice of a package.json this checker reads. -const DependencyBearer = type({ "dependencies?": "Record" }); - -/** Names in "dependencies" — the packages a consumer install provides. */ -export function declaredDependencyNames(packageJson: unknown): string[] { - const parsed = DependencyBearer(packageJson); - if (parsed instanceof type.errors) return []; - return Object.keys(parsed.dependencies ?? {}); -} diff --git a/scripts/checks/lib/repo.ts b/scripts/checks/lib/repo.ts deleted file mode 100644 index bdb056031c..0000000000 --- a/scripts/checks/lib/repo.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Shared plumbing for the structural checks. Every check builds a -// CheckReport of violations (blocking) and notes (informational) and -// exits through reportAndExit, so output shape and exit codes are -// uniform across checks. -import path from "node:path"; - -/** Absolute path of the repository root this script tree lives in. */ -export const REPO_ROOT = path.resolve(import.meta.dir, "..", "..", ".."); - -/** - * Resolves the tree a check runs against. Defaults to the repository - * root; `--root=` points the check at another tree, which is how - * the checks are exercised against deliberately broken fixtures. - */ -export function rootFromArgs(argv: readonly string[]): string { - for (const arg of argv) { - if (arg.startsWith("--root=")) { - return path.resolve(arg.slice("--root=".length)); - } - } - return REPO_ROOT; -} - -/** Positional arguments, with `--root=` and other flags stripped. */ -export function positionalArgs(argv: readonly string[]): string[] { - return argv.filter((arg) => !arg.startsWith("--")); -} - -export interface CheckReport { - violations: string[]; - notes: string[]; -} - -export function emptyReport(): CheckReport { - return { violations: [], notes: [] }; -} - -/** - * Prints a report and exits: 0 when there are no violations, 1 - * otherwise. Notes never affect the exit code. - */ -export function reportAndExit(name: string, report: CheckReport): never { - for (const note of report.notes) { - console.log(`${name}: note: ${note}`); - } - if (report.violations.length > 0) { - for (const violation of report.violations) { - console.error(`${name}: ${violation}`); - } - console.error(`${name}: FAIL — ${report.violations.length} violation(s) above`); - process.exit(1); - } - console.log(`${name}: ok`); - process.exit(0); -} diff --git a/scripts/checks/lib/tree-hash.ts b/scripts/checks/lib/tree-hash.ts deleted file mode 100644 index 06fbfdb424..0000000000 --- a/scripts/checks/lib/tree-hash.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Deterministic content hash of a directory tree: sha256 over the -// sorted relative file paths, each followed by NUL and the file bytes. -// node_modules trees, dist/ build output, and *.tsbuildinfo files are -// install/build artifacts, never content, and are excluded so the hash -// is stable across machines and unaffected by whether a typecheck ran -// first — a composite package's tsc --build run writes declaration -// output to dist/ as a side effect, and that output must never be -// mistaken for a vendored source edit. -import { createHash } from "node:crypto"; -import { readdirSync, readFileSync, statSync } from "node:fs"; -import path from "node:path"; - -export function hashDirectory(dir: string): string { - const relativeFiles = readdirSync(dir, { recursive: true, encoding: "utf8" }) - .map((entry) => entry.split(path.sep).join("/")) - .filter((entry) => !entry.split("/").includes("node_modules")) - .filter((entry) => !entry.split("/").includes("dist")) - .filter((entry) => !entry.endsWith(".tsbuildinfo")) - .filter((entry) => statSync(path.join(dir, entry)).isFile()) - .sort(); - const hash = createHash("sha256"); - for (const relativeFile of relativeFiles) { - hash.update(relativeFile); - hash.update("\0"); - hash.update(readFileSync(path.join(dir, relativeFile))); - } - return hash.digest("hex"); -} diff --git a/scripts/checks/licenses.ts b/scripts/checks/licenses.ts deleted file mode 100644 index 376a00defd..0000000000 --- a/scripts/checks/licenses.ts +++ /dev/null @@ -1,116 +0,0 @@ -// check:licenses — the dual-license scheme. -// -// Every packages/* and workflows/* library is LGPL-2.1-or-later with a -// LICENSE file carrying the canonical text; every apps/* application -// points at the root GPLv2-with-AI-Exception license instead. This -// check fails when a package.json's "license" field or a library's -// LICENSE file drifts from that scheme. -import { existsSync, readFileSync } from "node:fs"; -import path from "node:path"; -import { Glob } from "bun"; -import { emptyReport, reportAndExit, rootFromArgs, type CheckReport } from "./lib/repo"; - -export const LIBRARY_LICENSE = "LGPL-2.1-or-later"; -export const APP_LICENSE = "SEE LICENSE IN ../../LICENSE.md"; -export const CANONICAL_LICENSE_TEXT_PATH = "vendor/intx/LICENSE"; - -interface PackageJson { - license?: string; -} - -export interface WorkspacePackage { - /** Directory relative to the root, e.g. packages/chat. */ - dir: string; - packageJson: PackageJson; -} - -async function listPackages(root: string, pattern: string): Promise { - const glob = new Glob(pattern); - const packages: WorkspacePackage[] = []; - for await (const manifestPath of glob.scan(root)) { - packages.push({ - dir: path.dirname(manifestPath), - packageJson: (await Bun.file(path.join(root, manifestPath)).json()) as PackageJson, - }); - } - return packages.sort((a, b) => a.dir.localeCompare(b.dir)); -} - -export function listLibraryPackages(root: string): Promise { - return listPackages(root, "{packages,workflows}/*/package.json"); -} - -export function listAppPackages(root: string): Promise { - return listPackages(root, "apps/*/package.json"); -} - -/** - * Every library declares LGPL-2.1-or-later and carries a LICENSE file - * matching the canonical text byte-for-byte. - */ -export function auditLibraryLicenses( - libraries: readonly WorkspacePackage[], - canonicalText: string, - readLicenseFile: (dir: string) => string | null, -): CheckReport { - const report = emptyReport(); - for (const library of libraries) { - if (library.packageJson.license !== LIBRARY_LICENSE) { - report.violations.push( - `${library.dir}/package.json: "license" is ` + - `${JSON.stringify(library.packageJson.license)} — libraries under ` + - `packages/ and workflows/ must declare "${LIBRARY_LICENSE}".`, - ); - } - const licenseText = readLicenseFile(library.dir); - if (licenseText === null) { - report.violations.push( - `${library.dir}: missing a LICENSE file — every library carries the ` + - `canonical LGPL-2.1 text (see ${CANONICAL_LICENSE_TEXT_PATH}).`, - ); - } else if (licenseText !== canonicalText) { - report.violations.push( - `${library.dir}/LICENSE: does not match the canonical LGPL-2.1 text ` + - `in ${CANONICAL_LICENSE_TEXT_PATH}.`, - ); - } - } - return report; -} - -/** Every application defers to the root GPLv2-with-AI-Exception license. */ -export function auditAppLicenses(apps: readonly WorkspacePackage[]): CheckReport { - const report = emptyReport(); - for (const app of apps) { - if (app.packageJson.license !== APP_LICENSE) { - report.violations.push( - `${app.dir}/package.json: "license" is ` + - `${JSON.stringify(app.packageJson.license)} — apps must declare ` + - `"${APP_LICENSE}".`, - ); - } - } - return report; -} - -async function main(): Promise { - const root = rootFromArgs(Bun.argv.slice(2)); - const canonicalTextFile = path.join(root, CANONICAL_LICENSE_TEXT_PATH); - const canonicalText = existsSync(canonicalTextFile) - ? readFileSync(canonicalTextFile, "utf8") - : ""; - const [libraries, apps] = await Promise.all([listLibraryPackages(root), listAppPackages(root)]); - const report = emptyReport(); - const libraryReport = auditLibraryLicenses(libraries, canonicalText, (dir) => { - const licenseFile = path.join(root, dir, "LICENSE"); - return existsSync(licenseFile) ? readFileSync(licenseFile, "utf8") : null; - }); - report.violations.push(...libraryReport.violations); - report.violations.push(...auditAppLicenses(apps).violations); - if (libraries.length === 0 && apps.length === 0) { - report.notes.push("no workspace packages yet."); - } - reportAndExit("check:licenses", report); -} - -if (import.meta.main) await main(); diff --git a/scripts/checks/no-product-tenancy.ts b/scripts/checks/no-product-tenancy.ts deleted file mode 100644 index 367fb6192e..0000000000 --- a/scripts/checks/no-product-tenancy.ts +++ /dev/null @@ -1,224 +0,0 @@ -// check:no-product-tenancy — a structural invariant, not a spelling -// taboo: every table Interchange needs for tenants, principals, roles, -// grants, and invites already exists as native schema under -// `vendor/intx/db`. Nothing in `apps/`, `packages/`, or `workflows/` -// should ever declare its own `pgTable(...)` — any drizzle table -// exported from product code would be exactly the kind of duplicate -// the platform already gives us for free, tenancy or otherwise. This -// check greps for the actual drizzle API call, `pgTable(`, never for a -// naming convention or a string that merely mentions tenancy — a -// comment or variable name that happens to say "tenant" is never a -// violation, only a real call is. -// -// Documented product-domain exceptions live in ALLOWLIST below. Each -// entry is an explicit ruling: the named file may declare up to -// `maxOccurrences` product tables because they hold package-owned -// state the platform deliberately does not own (chat settings, cron -// schedules, webhook bindings, etc.). Tenancy, principals, and grants -// still stay native. Any other `pgTable(` occurrence fails, and an -// allowlisted file fails if it grows past its max. -import { Glob } from "bun"; -import path from "node:path"; -import { emptyReport, reportAndExit, rootFromArgs, type CheckReport } from "./lib/repo"; - -const SCAN_DIRS = ["apps", "packages", "workflows"]; -// Matches the plain `pgTable(...)` builder and `xyzSchema.table(...)` — -// the form every package now uses to declare tables inside its own named -// Postgres schema (see docs/package-migrations.md). Both are drizzle table -// declarations and both count toward a file's allowlisted occurrences; -// only the declaration style differs. -const PGTABLE_CALL_PATTERN = /\bpgTable\s*\(|\b\w+Schema\.table\s*\(/g; - -const ALLOWLIST: readonly { - relPath: string; - maxOccurrences: number; - tables: readonly string[]; -}[] = [ - { - relPath: "packages/chat/src/schema.ts", - maxOccurrences: 16, - tables: [ - // Created as channel_settings et al.; renamed to workbench_* by - // 0018_rename_channel_to_workbench (CL-6260) — see migrations.ts. - "workbench_settings", - "chat_bench_settings", - "workbench_read_state", - "workbench_launch", - "workbench_threads", - "workbench_thread_messages", - "workbench_share", - "workbench_share_member", - "block_responses", - "message_reactions", - "pinned_messages", - // Durable redelivery-dedup claim for the finalized-turn write - // surfaces (CL-6039) — see finalizedTurnWriteClaim's doc comment - // in schema.ts. - "finalized_turn_write_claim", - "message_client_ids", - // The room's own messages (CL-6327): a workbench message is - // workbench data, held here rather than read back out of the - // anchor run's mailbox — see room-messages.ts. - "workbench_messages", - // The turn projection (CL-6329): one row per agent turn, so a room - // answers "which run produced this reply, and how did that turn - // end" from its own rows — see agentTurns in schema.ts. - "agent_turns", - // Which workbench message a dispatch mail answers (CL-6314): the - // reply path threads under that source. Chat-owned correlation, - // not tenancy — see turnMailCorrelation in schema.ts. - "turn_mail_correlation", - ], - }, - { - relPath: "packages/webhook-triggers/src/schema.ts", - maxOccurrences: 4, - tables: [ - // CL-8210: `tenant`/`principal` are Interchange's own tables, - // re-declared here (id column only, never migrated) just far - // enough to carry the FK from webhook_trigger/repo_review_lease — - // same pattern as @corbits/artifacts's src/schema.ts. - "tenant", - "principal", - "webhook_trigger", - // CL-7242: a short-lived lease serializing the GitHub connect - // card's former start-reviewing step (workbench-scoped mount - // deleted in hub-zero T3, CL-8114; the lease still backs the - // native `startReviewingRepos` helper), entirely workbench-owned - // state with no relationship to Interchange's own `grant` table — - // see repo-review-lease.ts's doc comment for why the concurrency - // fix lives here rather than as any change to Interchange's - // schema. - "repo_review_lease", - ], - }, - { - relPath: "packages/notify/src/schema.ts", - maxOccurrences: 3, - tables: [ - // CL-8210: Interchange's own `tenant`/`principal`, re-declared - // (id column only, never migrated) to carry the FK from - // notify_dispatch — same pattern as @corbits/artifacts. - "tenant", - "principal", - "notify_dispatch", - ], - }, - { - relPath: "packages/bench/src/schema.ts", - maxOccurrences: 1, - tables: ["bench_settings"], - }, - { - // A bench's model policy: its allow/deny selectors, price ceilings - // and provider preference. The platform's catalog owns what a bench - // can reach; what a bench is willing to spend on it is a product - // decision with nowhere native to live. Everything else this package - // answers is derived at read time from model_offering and - // model_pricing — see docs/inference-concepts.md. - relPath: "packages/inference-catalog/src/schema.ts", - maxOccurrences: 1, - tables: ["bench_model_policy"], - }, - { - relPath: "packages/onboarding/src/schema.ts", - maxOccurrences: 2, - tables: [ - // CL-8210: Interchange's own `tenant`, re-declared (id column - // only, never migrated) to carry the FK from pending_seed. - "tenant", - "pending_seed", - ], - }, - { - // Eval-run history (CL-6143): one row per (eval, config) scored - // run, product-owned scoring data, never tenancy. - relPath: "packages/evals/src/store/schema.ts", - maxOccurrences: 1, - tables: ["evals.run"], - }, - { - // CL-8183: the vendored Interchange workflow-trigger grammar has no - // `schedule` trigger, so `@corbits/cron` fires a tenant's saved cron - // expressions as plain mail instead. Product-owned schedule state, - // never tenancy — FK'd back to the native `tenant` table. - relPath: "packages/cron/src/schema.ts", - maxOccurrences: 2, - // CL-8210: Interchange's `tenant` re-declared (id only, never migrated) - // to carry the FK from cron.schedule. - tables: ["tenant", "cron.schedule"], - }, -]; - -export async function scanFiles(root: string, dirs: readonly string[]): Promise { - const files: string[] = []; - for (const dir of dirs) { - const glob = new Glob(`${dir}/**/*.{ts,tsx}`); - for await (const file of glob.scan({ cwd: root, dot: false })) { - // vendor/intx and node_modules never appear under apps/packages/ - // workflows globs directly, but a package can still vendor its - // own node_modules under packages/*/node_modules — exclude it. - if (file.includes("node_modules/")) continue; - if (file.includes("/dist/") || file.startsWith("dist/")) continue; - files.push(file); - } - } - return files; -} - -function countPgTableCalls(contents: string): number { - return [...contents.matchAll(PGTABLE_CALL_PATTERN)].length; -} - -export function auditProductTenancy( - files: readonly { relPath: string; contents: string }[], -): CheckReport { - const report = emptyReport(); - for (const { relPath, contents } of files) { - const occurrences = countPgTableCalls(contents); - if (occurrences === 0) continue; - - const allowed = ALLOWLIST.find((entry) => entry.relPath === relPath); - if (allowed === undefined) { - report.violations.push( - `${relPath}: calls pgTable(...). All persistent state is native ` + - `Interchange schema under vendor/intx/db — a drizzle table ` + - `declared in apps/, packages/, or workflows/ is a product-owned ` + - `duplicate of platform schema, never a design choice. Product ` + - `domain tables need an explicit ALLOWLIST ruling in ` + - `scripts/checks/no-product-tenancy.ts.`, - ); - continue; - } - if (occurrences > allowed.maxOccurrences) { - report.violations.push( - `${relPath}: declares ${occurrences} pgTable(...) call(s), more than ` + - `the ${allowed.maxOccurrences} allowed for this file ` + - `(${allowed.tables.join(", ")}). Any new product table needs its ` + - `own explicit ALLOWLIST ruling, not a quiet addition to this file.`, - ); - continue; - } - report.notes.push( - `${relPath}: ${occurrences} pgTable(...) call(s) allowed ` + `(${allowed.tables.join(", ")})`, - ); - } - return report; -} - -async function main(): Promise { - const args = Bun.argv.slice(2); - const root = rootFromArgs(args); - const relPaths = await scanFiles(root, SCAN_DIRS); - const files = await Promise.all( - relPaths.map(async (relPath) => ({ - relPath, - contents: await Bun.file(path.join(root, relPath)).text(), - })), - ); - const report = auditProductTenancy(files); - report.notes.push(`scanned ${files.length} file(s) under ${SCAN_DIRS.join(", ")}`); - reportAndExit("check:no-product-tenancy", report); -} - -if (import.meta.main) await main(); diff --git a/scripts/checks/packages.ts b/scripts/checks/packages.ts deleted file mode 100644 index ad5d8a737f..0000000000 --- a/scripts/checks/packages.ts +++ /dev/null @@ -1,247 +0,0 @@ -// check:packages — package hygiene. -// -// Every workspace package must build, pass its tests run from its own -// directory, and — when it declares an exports field — survive -// consumption from outside the workspace: the packed artifact (bun pm -// pack) must contain every file its exports promise, and its entry -// must import by package name from a staging directory where only its -// declared dependencies are present. "Works in the repo" has to mean -// "works when published". -import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { Glob } from "bun"; -import { readFileSync } from "node:fs"; -import { collectExportTargets, declaredDependencyNames } from "./lib/exports"; -import { listVendoredPaths } from "./killdates"; -import { emptyReport, reportAndExit, rootFromArgs, type CheckReport } from "./lib/repo"; - -/** The tarball filename `bun pm pack` produces for a package. */ -export function tarballNameFor(name: string, version: string): string { - return `${name.replace(/^@/, "").replace("/", "-")}-${version}.tgz`; -} - -interface PackageJson { - name?: string; - version?: string; - scripts?: Record; - exports?: unknown; - dependencies?: Record; -} - -interface WorkspacePackage { - /** Directory relative to the root, e.g. packages/module. */ - dir: string; - packageJson: PackageJson; -} - -async function listWorkspacePackages(root: string): Promise { - const glob = new Glob("{apps,packages,tools,workflows}/*/package.json"); - const packages: WorkspacePackage[] = []; - for await (const manifestPath of glob.scan(root)) { - packages.push({ - dir: path.dirname(manifestPath), - packageJson: (await Bun.file(path.join(root, manifestPath)).json()) as PackageJson, - }); - } - return packages.sort((a, b) => a.dir.localeCompare(b.dir)); -} - -function run(command: readonly string[], cwd: string): { ok: boolean; output: string } { - const result = Bun.spawnSync([...command], { cwd, stderr: "pipe" }); - return { - ok: result.exitCode === 0, - output: result.stderr.toString().trim(), - }; -} - -function runScript(pkg: WorkspacePackage, script: string, root: string, report: CheckReport): void { - if (pkg.packageJson.scripts?.[script] === undefined) { - report.notes.push(`${pkg.dir}: no ${script} script.`); - return; - } - const result = run(["bun", "run", script], path.join(root, pkg.dir)); - if (!result.ok) { - report.violations.push( - `${pkg.dir}: "${script}" fails when run from the package ` + - `directory. A package must ${script === "build" ? "build" : "pass"} ` + - `in isolation, not only through the workspace fan-out.\n${result.output}`, - ); - } -} - -function linkDependency( - dependency: string, - pkg: WorkspacePackage, - root: string, - workspaceDirs: ReadonlyMap, - stageModules: string, -): string | null { - const candidates = [ - path.join(root, pkg.dir, "node_modules", dependency), - path.join(root, "node_modules", dependency), - workspaceDirs.get(dependency) ?? "", - ]; - const source = candidates.find((candidate) => candidate.length > 0 && existsSync(candidate)); - if (source === undefined) { - return ( - `${pkg.dir}: declared dependency "${dependency}" is not ` + - `installed anywhere in the workspace.` - ); - } - const target = path.join(stageModules, dependency); - mkdirSync(path.dirname(target), { recursive: true }); - symlinkSync(source, target, "dir"); - return null; -} - -function checkPackedConsumption( - pkg: WorkspacePackage, - root: string, - workspaceDirs: ReadonlyMap, - report: CheckReport, -): void { - const name = pkg.packageJson.name; - const version = pkg.packageJson.version; - if (name === undefined || version === undefined) { - report.violations.push( - `${pkg.dir}: package.json needs both name and version to be ` + - `consumable as a published package.`, - ); - return; - } - const stage = mkdtempSync(path.join(tmpdir(), "check-packages-")); - try { - const packResult = run(["bun", "pm", "pack", "--destination", stage], path.join(root, pkg.dir)); - if (!packResult.ok) { - report.violations.push(`${pkg.dir}: bun pm pack failed.\n${packResult.output}`); - return; - } - const tarball = path.join(stage, tarballNameFor(name, version)); - const extractResult = run(["tar", "-xzf", tarball, "-C", stage], stage); - if (!extractResult.ok) { - report.violations.push( - `${pkg.dir}: packed tarball could not be extracted.\n` + extractResult.output, - ); - return; - } - const installedDir = path.join(stage, "node_modules", name); - mkdirSync(path.dirname(installedDir), { recursive: true }); - renameSync(path.join(stage, "package"), installedDir); - - let broken = false; - for (const target of collectExportTargets(pkg.packageJson.exports)) { - if (existsSync(path.join(installedDir, target))) continue; - report.violations.push( - `${pkg.dir}: exports promises "${target}" but the packed ` + - `artifact does not contain it — a published install would ` + - `not resolve it. Fix the exports map or the files field.`, - ); - broken = true; - } - for (const dependency of declaredDependencyNames(pkg.packageJson)) { - const problem = linkDependency( - dependency, - pkg, - root, - workspaceDirs, - path.join(stage, "node_modules"), - ); - if (problem !== null) { - report.violations.push(problem); - broken = true; - } - } - if (broken) return; - - const importResult = run(["bun", "-e", `await import(${JSON.stringify(name)});`], stage); - if (!importResult.ok) { - report.violations.push( - `${pkg.dir}: the packed artifact does not import by name from ` + - `outside the workspace with only its declared dependencies ` + - `present — an undeclared dependency or a file missing from ` + - `the pack.\n${importResult.output}`, - ); - } - } finally { - rmSync(stage, { recursive: true, force: true }); - } -} - -export const LEDGER_PATH = "VENDORED.md"; - -/** Vendored paths named in the first column of the VENDORED.md ledger. */ -export function vendoredLedgerPaths(markdown: string): string[] { - const paths: string[] = []; - for (const line of markdown.split("\n")) { - const match = /^\|\s*`([^`]+)`\s*\|/.exec(line.trim()); - if (match?.[1] !== undefined) paths.push(match[1]); - } - return paths; -} - -/** - * Every vendored directory must have a VENDORED.md ledger row, and - * every ledger row must point at a path that still exists. A ledger - * row may name a path outside vendor/ (a living fork such as - * apps/sidecar), so row existence is checked against the tree itself, - * not against the vendor/ listing. - */ -export function auditVendoredLedger( - vendoredDirs: readonly string[], - ledgerPaths: readonly string[], - ledgerPathExists: (ledgerPath: string) => boolean, -): CheckReport { - const report = emptyReport(); - const inLedger = new Set(ledgerPaths); - for (const dir of vendoredDirs) { - if (inLedger.has(dir)) continue; - report.violations.push( - `${dir}: vendored directory with no ${LEDGER_PATH} ledger row — ` + - `code copied into the tree without a ledger row is a bug.`, - ); - } - for (const ledgerPath of ledgerPaths) { - if (ledgerPathExists(ledgerPath)) continue; - report.violations.push( - `${LEDGER_PATH}: row for "${ledgerPath}" but the path no longer ` + - `exists — retiring a vendored copy deletes the row with the files.`, - ); - } - return report; -} - -async function main(): Promise { - const root = rootFromArgs(Bun.argv.slice(2)); - const report = emptyReport(); - const packages = await listWorkspacePackages(root); - const workspaceDirs = new Map(); - for (const pkg of packages) { - if (pkg.packageJson.name !== undefined) { - workspaceDirs.set(pkg.packageJson.name, path.join(root, pkg.dir)); - } - } - for (const pkg of packages) { - runScript(pkg, "build", root, report); - runScript(pkg, "test", root, report); - if (pkg.packageJson.exports === undefined) { - report.notes.push(`${pkg.dir}: no exports field; skipping packed-consumption check.`); - continue; - } - checkPackedConsumption(pkg, root, workspaceDirs, report); - } - if (packages.length === 0) { - report.notes.push("no workspace packages yet."); - } - const ledgerFile = path.join(root, LEDGER_PATH); - const ledgerPaths = existsSync(ledgerFile) - ? vendoredLedgerPaths(readFileSync(ledgerFile, "utf8")) - : []; - const ledgerReport = auditVendoredLedger(listVendoredPaths(root), ledgerPaths, (ledgerPath) => - existsSync(path.join(root, ledgerPath)), - ); - report.violations.push(...ledgerReport.violations); - reportAndExit("check:packages", report); -} - -if (import.meta.main) await main(); diff --git a/scripts/checks/publishable-workflows.ts b/scripts/checks/publishable-workflows.ts deleted file mode 100644 index 758022ff26..0000000000 --- a/scripts/checks/publishable-workflows.ts +++ /dev/null @@ -1,126 +0,0 @@ -// check:publishable-workflows — every workflows/*, tools/*, agents/*, and -// skills/* package (plus any remaining packages/*-tools or -// packages/tools-* holdout) must stay an individually publishable npm -// package: a standalone tarball with no dependency that only resolves -// inside this monorepo (CL-8157, CL-8217). -// -// A package opts out of this check entirely by staying `private: true` — -// that's the escape hatch for a package with a runtime dependency this -// repo cannot yet publish around (see the CL-8157 PR description for the -// current list). Once a package drops `private`, this check holds it to -// the full bar: it must declare `exports`, `files`, and `license`, and -// none of its runtime `dependencies` or `peerDependencies` may be a -// `workspace:*` range or point at a package that is itself still -// `private: true`. -import { readFileSync } from "node:fs"; -import path from "node:path"; -import { Glob } from "bun"; -import { type } from "arktype"; -import { emptyReport, reportAndExit, rootFromArgs, type CheckReport } from "./lib/repo"; - -const PackageJson = type({ - name: "string", - "private?": "boolean", - "license?": "string", - "exports?": "unknown", - "files?": "unknown", - "dependencies?": "Record", - "peerDependencies?": "Record", -}); -type PackageJson = typeof PackageJson.infer; - -const PUBLISHABLE_GLOBS = [ - "workflows/*/package.json", - "tools/*/package.json", - "agents/*/package.json", - "skills/*/package.json", - "packages/*/package.json", -]; - -function isInScope(dir: string): boolean { - if (dir.startsWith("workflows/")) return true; - if (dir.startsWith("tools/") || dir.startsWith("agents/") || dir.startsWith("skills/")) { - return true; - } - const base = path.basename(dir); - return base.endsWith("-tools") || base.startsWith("tools-"); -} - -interface Workspace { - readonly dir: string; - readonly packageJson: PackageJson; -} - -async function listWorkspaces(root: string): Promise { - const workspaces: Workspace[] = []; - for (const pattern of PUBLISHABLE_GLOBS) { - const glob = new Glob(pattern); - for await (const manifestPath of glob.scan(root)) { - const dir = path.dirname(manifestPath); - if (!isInScope(dir)) continue; - const raw = JSON.parse(readFileSync(path.join(root, manifestPath), "utf8")); - const parsed = PackageJson(raw); - if (parsed instanceof type.errors) { - throw new Error(`${manifestPath}: invalid package.json — ${parsed.summary}`); - } - workspaces.push({ dir, packageJson: parsed }); - } - } - return workspaces.sort((a, b) => a.dir.localeCompare(b.dir)); -} - -export function auditPublishableWorkflows(workspaces: readonly Workspace[]): CheckReport { - const report = emptyReport(); - const privateNames = new Set( - workspaces.filter((w) => w.packageJson.private === true).map((w) => w.packageJson.name), - ); - - for (const { dir, packageJson } of workspaces) { - if (packageJson.private === true) continue; - - if (packageJson.license === undefined) { - report.violations.push(`${dir}/package.json: missing "license".`); - } - if (packageJson.exports === undefined) { - report.violations.push(`${dir}/package.json: missing "exports".`); - } - if (packageJson.files === undefined) { - report.violations.push(`${dir}/package.json: missing "files".`); - } - - for (const field of ["dependencies", "peerDependencies"] as const) { - const deps = packageJson[field]; - if (!deps) continue; - for (const [name, range] of Object.entries(deps)) { - if (range === "workspace:*" || range.startsWith("workspace:")) { - report.violations.push( - `${dir}/package.json: "${field}.${name}" is "${range}" — a ` + - `workspace: range never survives publish; pin a real version.`, - ); - continue; - } - if (privateNames.has(name)) { - report.violations.push( - `${dir}/package.json: "${field}.${name}" points at "${name}", ` + - `which is still "private: true" — a published tarball of ` + - `this package can never install it. Either publish "${name}" ` + - `too, or keep this package private until it can.`, - ); - } - } - } - } - return report; -} - -async function main(): Promise { - const root = rootFromArgs(Bun.argv.slice(2)); - const workspaces = await listWorkspaces(root); - const report = auditPublishableWorkflows(workspaces); - if (workspaces.length === 0) { - report.notes.push("no workflows/* or *-tools packages found."); - } - reportAndExit("check:publishable-workflows", report); -} - -if (import.meta.main) await main(); diff --git a/scripts/checks/react-ui-drift-allowlist.ts b/scripts/checks/react-ui-drift-allowlist.ts deleted file mode 100644 index be400f22c1..0000000000 --- a/scripts/checks/react-ui-drift-allowlist.ts +++ /dev/null @@ -1,44 +0,0 @@ -// The known-drift ledger for check:react-ui-drift — every file listed here -// is excluded from the (a) raw-form-control and (d) raw-button counts -// because its raw DOM is tracked work for a lane other than the one that -// last touched this file, not an oversight. A file only belongs here once -// it actually trips one of those two classes; grep before adding a row. -// -// A row also stops belonging here the moment its file stops tripping a -// class — `packages/chat-ui/src/timeline.tsx` was carried long after it had -// nothing left to exclude, since it imports react-ui's `Button` and that -// takes it out of the raw-button class outright. A stale row reads as -// accepted debt that isn't there, and every row here once cited a ticket -// that had closed (or, for one, no ticket at all), which hid that the ledger -// had no live work behind it. `ticket` must therefore name an OPEN issue: -// if the work is done, delete the row instead of repointing it. Run the -// check after removing a row — the checker's classes are narrower than a -// grep, so "this file looks clean" is not the same as it counting zero. -// -// `REACT_UI_DRIFT_SNAPSHOT` is the total ratcheted violation count (raw -// form controls outside this list + every raw `` + raw buttons -// outside this list) recorded the last time this ledger was updated. -// check:react-ui-drift fails only when the live count exceeds this number, -// so lowering it is how a cleanup lane proves it actually reduced drift — -// bump it back up only alongside a change that both adds new raw DOM and -// justifies why (which should be rare; most new UI should use react-ui -// primitives directly and never need this file touched at all). -export interface DriftAllowlistEntry { - readonly relPath: string; - /** Why this file still carries raw DOM instead of a react-ui primitive. */ - readonly ticket: string; -} - -export const REACT_UI_DRIFT_ALLOWLIST: readonly DriftAllowlistEntry[] = [ - { - relPath: "apps/web/src/pages/insights-page.tsx", - ticket: "CL-6320", - }, -]; - -// CL-6628 adds the prompt box's `