From 57cdaf94a1f4786c76e318f681c0d92e2f838627 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 20:09:04 -0700 Subject: [PATCH 1/3] Add tests for affected-package resolution --- scripts/affected.test.ts | 128 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 scripts/affected.test.ts diff --git a/scripts/affected.test.ts b/scripts/affected.test.ts new file mode 100644 index 00000000..d70acbc1 --- /dev/null +++ b/scripts/affected.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "bun:test"; + +import { + affectedPackages, + directlyChanged, + isGlobalChange, + ownerOf, + withDependents, + type PackageManifest, +} from "./affected.ts"; + +const manifests: PackageManifest[] = [ + { name: "@corbits/events", dir: "packages/events", workspaceDeps: [] }, + { + name: "@corbits/chat", + dir: "packages/chat", + workspaceDeps: ["@corbits/events"], + }, + { + name: "@workbench/hub", + dir: "apps/hub", + workspaceDeps: ["@corbits/chat"], + }, + { name: "@corbits/slug", dir: "packages/slug", workspaceDeps: [] }, + { name: "@intx/db", dir: "vendor/intx/db", workspaceDeps: [] }, +]; + +describe("isGlobalChange", () => { + test("a root manifest or shared tsconfig forces a full run", () => { + expect(isGlobalChange(["package.json"])).toBe(true); + expect(isGlobalChange(["tsconfig.base.json"])).toBe(true); + }); + + test("anything under scripts/ forces a full run, since the gate itself moved", () => { + expect(isGlobalChange(["scripts/run-all.ts"])).toBe(true); + }); + + test("a package-local manifest is not global", () => { + expect(isGlobalChange(["packages/chat/package.json"])).toBe(false); + }); + + test("ordinary source changes are not global", () => { + expect(isGlobalChange(["packages/chat/src/index.ts"])).toBe(false); + }); +}); + +describe("ownerOf", () => { + test("attributes a file to its package", () => { + expect(ownerOf("packages/chat/src/index.ts", manifests)).toBe( + "@corbits/chat", + ); + }); + + test("prefers the longest matching root so a nested workspace is not shadowed", () => { + expect(ownerOf("vendor/intx/db/src/schema.ts", manifests)).toBe("@intx/db"); + }); + + test("returns undefined for a file no package owns", () => { + expect(ownerOf("README.md", manifests)).toBeUndefined(); + }); +}); + +describe("directlyChanged", () => { + test("collects each touched package once", () => { + const changed = directlyChanged( + [ + "packages/chat/src/a.ts", + "packages/chat/src/b.ts", + "packages/slug/src/c.ts", + ], + manifests, + ); + expect([...changed].sort()).toEqual(["@corbits/chat", "@corbits/slug"]); + }); +}); + +describe("withDependents", () => { + test("pulls in transitive dependents, not just direct ones", () => { + const affected = withDependents(new Set(["@corbits/events"]), manifests); + expect([...affected].sort()).toEqual([ + "@corbits/chat", + "@corbits/events", + "@workbench/hub", + ]); + }); + + test("a leaf package affects only itself", () => { + expect([...withDependents(new Set(["@corbits/slug"]), manifests)]).toEqual([ + "@corbits/slug", + ]); + }); + + test("terminates on a dependency cycle", () => { + const cyclic: PackageManifest[] = [ + { name: "a", dir: "packages/a", workspaceDeps: ["b"] }, + { name: "b", dir: "packages/b", workspaceDeps: ["a"] }, + ]; + expect([...withDependents(new Set(["a"]), cyclic)].sort()).toEqual([ + "a", + "b", + ]); + }); +}); + +describe("affectedPackages", () => { + test("a global change returns 'all' rather than a filtered set", () => { + expect(affectedPackages(["bun.lock"], manifests)).toBe("all"); + }); + + test("a package change returns that package and everything above it", () => { + const affected = affectedPackages( + ["packages/events/src/parse.ts"], + manifests, + ); + expect(affected).not.toBe("all"); + expect([...(affected as Set)].sort()).toEqual([ + "@corbits/chat", + "@corbits/events", + "@workbench/hub", + ]); + }); + + test("a change owned by no package checks nothing", () => { + expect([ + ...(affectedPackages(["docs/GLOSSARY.md"], manifests) as Set), + ]).toEqual([]); + }); +}); From 58f2e0abb211fe72171f96e3f2eb9a6eea53b558 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 20:09:04 -0700 Subject: [PATCH 2/3] check: run only the packages a change can break The gate ran all 109 workspace packages regardless of what moved, and tests run one at a time, so a one-line edit paid for the whole repo. WORKBENCH_CHECK_SINCE= narrows the job list to the packages the change touches plus everything that depends on them, transitively. A change to a root manifest, the shared tsconfig, or scripts/ has a blast radius no dependency edge records, so those still run everything. Opt-in: unset, every package runs, so CI keeps its current meaning. Also pins hono in overrides. Two resolved copies produce structurally identical but nominally distinct Hono types, and the mismatch surfaces as an unreadable TS2345 in whichever package bridges them. --- bun.lock | 19 +++++- package.json | 3 +- scripts/affected.ts | 141 ++++++++++++++++++++++++++++++++++++++++++++ scripts/run-all.ts | 66 ++++++++++++++++++++- 4 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 scripts/affected.ts diff --git a/bun.lock b/bun.lock index 8ec49346..e20232fb 100644 --- a/bun.lock +++ b/bun.lock @@ -1965,6 +1965,7 @@ "@intx/workflow-host": "0.3.0", "arktype": "^2.2.0", "better-auth": "1.6.29", + "hono": "4.13.3", }, "catalog": { "@modelcontextprotocol/sdk": "^1.30.0", @@ -3513,7 +3514,19 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/memory-hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + "@corbits/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#81049ed", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-81049ed", "sha512-oTE0iFDyQdz0ifG1epo39pwaCaYaw19YcKXwfaZqAEQ56a1g9YIozXwH9CG4NaUTwcJKUeYGuNls6oJsMPisCw=="], + + "@corbits/bench-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/chat-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/context-menu/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/plugins-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/settings-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/tasks-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], @@ -3537,7 +3550,9 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], - "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#81049ed", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-81049ed", "sha512-oTE0iFDyQdz0ifG1epo39pwaCaYaw19YcKXwfaZqAEQ56a1g9YIozXwH9CG4NaUTwcJKUeYGuNls6oJsMPisCw=="], + + "@workbench/web/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], diff --git a/package.json b/package.json index cee30a9c..ce22030b 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "@intx/workflow": "0.3.0", "@intx/workflow-deploy": "0.3.0", "@intx/workflow-host": "0.3.0", - "better-auth": "1.6.29" + "better-auth": "1.6.29", + "hono": "4.13.3" } } diff --git a/scripts/affected.ts b/scripts/affected.ts new file mode 100644 index 00000000..127a3e0e --- /dev/null +++ b/scripts/affected.ts @@ -0,0 +1,141 @@ +// Resolves which workspace packages a change actually affects, so a local +// gate can check those instead of all 109. +// +// A package is affected when the change touches its own files, or when it +// depends -- at any depth -- on a package that was touched. Dependents matter +// as much as the package itself: editing an exported type in +// `@corbits/agent-events` breaks its importers, not the package that changed. +// +// Some paths defeat the whole idea. A root manifest, the shared tsconfig, or +// the runner itself can change the result of every job, and no dependency edge +// records that. Those force a full run rather than a wrong-but-fast one -- +// a filtered gate that misses a break is worse than a slow one. +import { Glob } from "bun"; + +const WORKSPACE_ROOTS = [ + "apps", + "packages", + "tools", + "workflows", + "vendor/intx", +] as const; + +/** Changes whose blast radius no dependency edge can express. */ +const GLOBAL_PATHS = [ + "package.json", + "bun.lock", + "tsconfig.base.json", + "tsconfig.json", + "eslint.config.ts", + "scripts/", + ".github/", +] as const; + +export type PackageManifest = { + readonly name: string; + readonly dir: string; + readonly workspaceDeps: readonly string[]; +}; + +export function isGlobalChange(changedFiles: readonly string[]): boolean { + return changedFiles.some((file) => + GLOBAL_PATHS.some((global) => + global.endsWith("/") ? file.startsWith(global) : file === global, + ), + ); +} + +/** + * The package that owns a file, as the longest workspace directory prefixing + * it. Longest wins so a nested workspace root (`vendor/intx/db`) is not + * shadowed by a shorter one that happens to prefix it. + */ +export function ownerOf( + file: string, + manifests: readonly PackageManifest[], +): string | undefined { + let owner: PackageManifest | undefined; + for (const manifest of manifests) { + if (!file.startsWith(`${manifest.dir}/`)) continue; + if (owner === undefined || manifest.dir.length > owner.dir.length) { + owner = manifest; + } + } + return owner?.name; +} + +export function directlyChanged( + changedFiles: readonly string[], + manifests: readonly PackageManifest[], +): Set { + const changed = new Set(); + for (const file of changedFiles) { + const owner = ownerOf(file, manifests); + if (owner !== undefined) changed.add(owner); + } + return changed; +} + +/** + * Every package that reaches one of `seeds` through workspace dependencies, + * plus the seeds. Walks the reverse graph to a fixed point, so a cycle + * terminates instead of recursing forever. + */ +export function withDependents( + seeds: ReadonlySet, + manifests: readonly PackageManifest[], +): Set { + const dependentsOf = new Map(); + for (const manifest of manifests) { + for (const dep of manifest.workspaceDeps) { + const existing = dependentsOf.get(dep); + if (existing === undefined) dependentsOf.set(dep, [manifest.name]); + else existing.push(manifest.name); + } + } + + const affected = new Set(seeds); + const queue = [...seeds]; + while (queue.length > 0) { + const next = queue.pop(); + if (next === undefined) continue; + for (const dependent of dependentsOf.get(next) ?? []) { + if (affected.has(dependent)) continue; + affected.add(dependent); + queue.push(dependent); + } + } + return affected; +} + +export function affectedPackages( + changedFiles: readonly string[], + manifests: readonly PackageManifest[], +): Set | "all" { + if (isGlobalChange(changedFiles)) return "all"; + return withDependents(directlyChanged(changedFiles, manifests), manifests); +} + +export async function readManifests(): Promise { + const manifests: PackageManifest[] = []; + for (const root of WORKSPACE_ROOTS) { + const glob = new Glob(`${root}/*/package.json`); + for await (const manifestPath of glob.scan(".")) { + const raw = (await Bun.file(manifestPath).json()) as { + name?: string; + dependencies?: Record; + devDependencies?: Record; + }; + const dir = manifestPath.slice(0, -"/package.json".length); + const deps = { ...raw.dependencies, ...raw.devDependencies }; + manifests.push({ + name: raw.name ?? dir, + dir, + workspaceDeps: Object.entries(deps) + .filter(([, range]) => range.startsWith("workspace:")) + .map(([dep]) => dep), + }); + } + } + return manifests; +} diff --git a/scripts/run-all.ts b/scripts/run-all.ts index d6bfbaab..455f25e9 100644 --- a/scripts/run-all.ts +++ b/scripts/run-all.ts @@ -79,6 +79,63 @@ async function runJob(job: Job, script: string): Promise { return code; } +const SINCE_ENV = "WORKBENCH_CHECK_SINCE"; + +/** + * The files this change touches, relative to `ref`: everything committed since + * the merge base, plus whatever is still uncommitted. Uncommitted work counts + * because the gate runs before the commit exists. + */ +function changedFilesSince(ref: string): string[] { + const base = + Bun.spawnSync(["git", "merge-base", "HEAD", ref]) + .stdout.toString() + .trim() || ref; + const committed = Bun.spawnSync([ + "git", + "diff", + "--name-only", + `${base}...HEAD`, + ]); + const working = Bun.spawnSync(["git", "status", "--porcelain"]); + return [ + ...committed.stdout.toString().split("\n"), + ...working.stdout + .toString() + .split("\n") + .map((line) => line.slice(3)), + ] + .map((file) => file.trim()) + .filter((file) => file.length > 0); +} + +/** + * Narrows the job list to packages this change can actually break. Opt-in: + * without the env var every package runs, so CI and a bare `bun run check` + * keep their existing all-packages meaning. + */ +async function narrowToAffected(jobs: readonly Job[]): Promise { + const ref = process.env[SINCE_ENV]; + if (ref === undefined || ref === "") return [...jobs]; + + const { affectedPackages, readManifests } = await import("./affected.ts"); + const affected = affectedPackages( + changedFilesSince(ref), + await readManifests(), + ); + if (affected === "all") { + console.error( + `${SINCE_ENV}=${ref}: change is repo-wide, running every package`, + ); + return [...jobs]; + } + const narrowed = jobs.filter((job) => affected.has(job.name)); + console.error( + `${SINCE_ENV}=${ref}: ${narrowed.length} of ${jobs.length} package(s) affected`, + ); + return narrowed; +} + const script = process.argv[2]; if (!script) { console.error("usage: bun run scripts/run-all.ts "); @@ -93,9 +150,14 @@ try { process.exit(1); } -const jobs = await discover(script); +const discovered = await discover(script); +const jobs = await narrowToAffected(discovered); if (jobs.length === 0) { - console.log(`${script}: no workspace packages define it yet`); + const reason = + discovered.length === 0 + ? "no workspace packages define it yet" + : "nothing affected by this change"; + console.log(`${script}: ${reason}`); process.exit(0); } From 459e4db7a85e5461f016bf7fdae37d229062d4ec Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 20:35:58 -0700 Subject: [PATCH 3/3] check: cache prettier and eslint results Both re-lint every file on every run. Measured on this repo: prettier 19.5s, eslint 108s. Neither result changes for a file that has not changed, so both keep a cache keyed on content. Warm: prettier 7.1s, eslint 2.4s -- a full lint goes from ~127s to ~4.3s. Cold cost is unchanged, so CI (fresh checkout, no cache) is unaffected; this buys back the local edit-check loop. --- .gitignore | 3 +++ package.json | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 57a04f27..462aa908 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ tmp/ # Browser walkthrough harness screenshots + run summary (CL-6072) scripts/e2e/browser/shots/ + +# Lint caches — local only; CI installs fresh and rebuilds them. +.eslintcache diff --git a/package.json b/package.json index ce22030b..fd8f297d 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "check": "bun run typecheck && bun run lint && bun run test && bun run check:structural", "check:all": "bun run check && bun run check:packages", "typecheck": "bun run scripts/run-all.ts typecheck", - "lint": "prettier --check . && eslint .", + "lint": "prettier --check --cache . && eslint --cache .", "format": "prettier --write .", "build": "bun run scripts/run-all.ts build", "test": "bun test scripts/run-all.test.ts && bun run scripts/run-all.ts test",