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
26 changes: 17 additions & 9 deletions scripts/check-typecheck-coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { fileURLToPath, URL } from "node:url";

export type TypecheckGap = { workspace: string; script: string };

export type WorkspaceWithTypecheck = { name: string; dir: string };

/** Every `npm run <name>` this script body invokes (the root package's own scripts). */
function referencedRootScripts(body: string): string[] {
// `npm run x`, `npm run x --silent`, `npm --silent run x` -- all forms used in this package.json.
Expand Down Expand Up @@ -51,13 +53,14 @@ function referencedWorkspaces(body: string): string[] {
/**
* PURE: workspaces that declare a `typecheck` script the root `typecheck` never reaches.
*
* `scripts` is the root package's script map; `workspacesWithTypecheck` is every workspace package name that
* declares one. Reachability follows `npm run` references transitively from `entry`, because a workspace is
* covered whether it is invoked directly or through an intermediate script.
* `scripts` is the root package's script map; `workspacesWithTypecheck` is every workspace that declares
* one (package name + directory). Reachability follows `npm run` references transitively from `entry`,
* because a workspace is covered whether it is invoked directly, through an intermediate script, or via
* `tsc -p <dir>/tsconfig.json`.
*/
export function findTypecheckGaps(
scripts: Readonly<Record<string, string>>,
workspacesWithTypecheck: readonly string[],
workspacesWithTypecheck: readonly WorkspaceWithTypecheck[],
entry = "typecheck",
): TypecheckGap[] {
const covered = new Set<string>();
Expand All @@ -79,13 +82,18 @@ export function findTypecheckGaps(
queue.push(...referencedRootScripts(body));
}
return workspacesWithTypecheck
.filter((workspace) => !covered.has(workspace) && !covered.has(workspace.replace(/^@[\w-]+\//, "")))
.map((workspace) => ({ workspace, script: "typecheck" }));
.filter(
(workspace) =>
!covered.has(workspace.name) &&
!covered.has(workspace.name.replace(/^@[\w-]+\//, "")) &&
!covered.has(workspace.dir),
)
.map((workspace) => ({ workspace: workspace.name, script: "typecheck" }));
}

/** Workspace package names (and their directories) that declare their own `typecheck` script. */
export function workspacesDeclaringTypecheck(root: string): { name: string; dir: string }[] {
const out: { name: string; dir: string }[] = [];
export function workspacesDeclaringTypecheck(root: string): WorkspaceWithTypecheck[] {
const out: WorkspaceWithTypecheck[] = [];
for (const group of ["apps", "packages"]) {
let dirs: string[];
try {
Expand All @@ -109,7 +117,7 @@ function main(): void {
const root = join(fileURLToPath(new URL(".", import.meta.url)), "..");
const rootManifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { scripts?: Record<string, string> };
const declared = workspacesDeclaringTypecheck(root);
const gaps = findTypecheckGaps(rootManifest.scripts ?? {}, declared.map((entry) => entry.name));
const gaps = findTypecheckGaps(rootManifest.scripts ?? {}, declared);

if (gaps.length > 0) {
console.error("`npm run typecheck` does not reach every workspace that declares one:\n");
Expand Down
58 changes: 51 additions & 7 deletions test/unit/check-typecheck-coverage-script.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ describe("findTypecheckGaps (#9860)", () => {
// Present in the repo, but reachable only from test:ci -- never from `typecheck`.
"ui:typecheck": "npm --workspace @loopover/ui run typecheck",
};
expect(findTypecheckGaps(scripts, ["@loopover/ui"])).toEqual([{ workspace: "@loopover/ui", script: "typecheck" }]);
expect(findTypecheckGaps(scripts, [{ name: "@loopover/ui", dir: "apps/loopover-ui" }])).toEqual([
{ workspace: "@loopover/ui", script: "typecheck" },
]);
});

it("counts a workspace reached THROUGH an intermediate script as covered", () => {
Expand All @@ -29,33 +31,73 @@ describe("findTypecheckGaps (#9860)", () => {
"ui:typecheck": "npm run ui:kit:build && npm --workspace @loopover/ui run typecheck",
"ui:kit:build": "npm --workspace @loopover/ui-kit run build",
};
expect(findTypecheckGaps(scripts, ["@loopover/ui"])).toEqual([]);
expect(findTypecheckGaps(scripts, [{ name: "@loopover/ui", dir: "apps/loopover-ui" }])).toEqual([]);
});

it("counts a project typechecked directly by path as covered, without its own script being invoked", () => {
// `typecheck:packages` runs tsc against the project file rather than calling the workspace's script.
// Inputs must match what main() produces: { name, dir }, not a bare directory string (#10044).
const scripts = {
typecheck: "npm run typecheck:packages",
"typecheck:packages": "tsc -p packages/loopover-contract/tsconfig.json --noEmit",
};
expect(findTypecheckGaps(scripts, ["packages/loopover-contract"])).toEqual([]);
expect(findTypecheckGaps(scripts, [{ name: "@loopover/contract", dir: "packages/loopover-contract" }])).toEqual([]);
});

it("REGRESSION (#10044): a workspace covered ONLY by tsc -p <dir> is not reported when identified by scoped package name", () => {
// Before the fix, covered held "packages/loopover-contract" while the filter only checked
// "@loopover/contract" / "contract" — so the -p branch was dead for every real main() call.
const scripts = {
typecheck: "npm run typecheck:packages",
"typecheck:packages": "tsc -p packages/loopover-contract/tsconfig.json --noEmit",
};
expect(findTypecheckGaps(scripts, [{ name: "@loopover/contract", dir: "packages/loopover-contract" }])).toEqual([]);
});

it("reports a gap when a different workspace's -p path does not cover this one", () => {
const scripts = {
typecheck: "npm run typecheck:packages",
"typecheck:packages": "tsc -p packages/loopover-contract/tsconfig.json --noEmit",
};
expect(findTypecheckGaps(scripts, [{ name: "@loopover/ui", dir: "apps/loopover-ui" }])).toEqual([
{ workspace: "@loopover/ui", script: "typecheck" },
]);
});

it("handles the reversed flag order, since both spellings appear in this package.json", () => {
const scripts = { typecheck: "npm run typecheck --workspace @loopover/ui" };
expect(findTypecheckGaps(scripts, ["@loopover/ui"])).toEqual([]);
expect(findTypecheckGaps(scripts, [{ name: "@loopover/ui", dir: "apps/loopover-ui" }])).toEqual([]);
});

it("does NOT count a workspace whose BUILD is run but whose typecheck is not", () => {
// A build may typecheck as a side effect, but that is a property of that script's current body, not a
// guarantee. Treating it as coverage would silently accept a build that later stops emitting types.
const scripts = { typecheck: "npm run ui:kit:build", "ui:kit:build": "npm --workspace @loopover/ui-kit run build" };
expect(findTypecheckGaps(scripts, ["@loopover/ui-kit"])).toEqual([{ workspace: "@loopover/ui-kit", script: "typecheck" }]);
expect(findTypecheckGaps(scripts, [{ name: "@loopover/ui-kit", dir: "packages/loopover-ui-kit" }])).toEqual([
{ workspace: "@loopover/ui-kit", script: "typecheck" },
]);
});

it("treats a workspace as covered when covered holds the unscoped name (scoped-name strip hit)", () => {
// Reversed-flag form with an unscoped workspace token puts "ui" in covered; the filter must still
// recognise @loopover/ui via name.replace(/^@[\w-]+\//, "").
const scripts = { typecheck: "npm run typecheck --workspace ui" };
expect(findTypecheckGaps(scripts, [{ name: "@loopover/ui", dir: "apps/loopover-ui" }])).toEqual([]);
});

it("does not treat a workspace as covered via scoped-name strip when the unscoped token is absent", () => {
// Body has no -p match and no workspace reference — strip fallback misses; gap is reported by name.
const scripts = { typecheck: "tsc --noEmit" };
expect(findTypecheckGaps(scripts, [{ name: "@loopover/ui", dir: "apps/loopover-ui" }])).toEqual([
{ workspace: "@loopover/ui", script: "typecheck" },
]);
});

it("terminates on a cyclic script graph instead of looping forever", () => {
const scripts = { typecheck: "npm run a", a: "npm run b", b: "npm run a" };
expect(findTypecheckGaps(scripts, ["@loopover/ui"])).toEqual([{ workspace: "@loopover/ui", script: "typecheck" }]);
expect(findTypecheckGaps(scripts, [{ name: "@loopover/ui", dir: "apps/loopover-ui" }])).toEqual([
{ workspace: "@loopover/ui", script: "typecheck" },
]);
});

it("reports nothing when no workspace declares a typecheck at all", () => {
Expand All @@ -64,6 +106,8 @@ describe("findTypecheckGaps (#9860)", () => {

it("tolerates a missing entry script rather than throwing", () => {
// A renamed root script must fail loudly as a REPORT, not as a crash mid-CI.
expect(findTypecheckGaps({}, ["@loopover/ui"])).toEqual([{ workspace: "@loopover/ui", script: "typecheck" }]);
expect(findTypecheckGaps({}, [{ name: "@loopover/ui", dir: "apps/loopover-ui" }])).toEqual([
{ workspace: "@loopover/ui", script: "typecheck" },
]);
});
});