Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 17 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
128 changes: 128 additions & 0 deletions scripts/affected.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>)].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<string>),
]).toEqual([]);
});
});
141 changes: 141 additions & 0 deletions scripts/affected.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const changed = new Set<string>();
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<string>,
manifests: readonly PackageManifest[],
): Set<string> {
const dependentsOf = new Map<string, string[]>();
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<string> | "all" {
if (isGlobalChange(changedFiles)) return "all";
return withDependents(directlyChanged(changedFiles, manifests), manifests);
}

export async function readManifests(): Promise<PackageManifest[]> {
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<string, string>;
devDependencies?: Record<string, string>;
};
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;
}
Loading
Loading