Skip to content

Commit 4fb0fcc

Browse files
fix(ci): make tsc -p dir coverage match package workspaces
Closes #10044 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent c877ed6 commit 4fb0fcc

2 files changed

Lines changed: 68 additions & 16 deletions

File tree

scripts/check-typecheck-coverage.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import { fileURLToPath, URL } from "node:url";
2323

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

26+
export type WorkspaceWithTypecheck = { name: string; dir: string };
27+
2628
/** Every `npm run <name>` this script body invokes (the root package's own scripts). */
2729
function referencedRootScripts(body: string): string[] {
2830
// `npm run x`, `npm run x --silent`, `npm --silent run x` -- all forms used in this package.json.
@@ -51,13 +53,14 @@ function referencedWorkspaces(body: string): string[] {
5153
/**
5254
* PURE: workspaces that declare a `typecheck` script the root `typecheck` never reaches.
5355
*
54-
* `scripts` is the root package's script map; `workspacesWithTypecheck` is every workspace package name that
55-
* declares one. Reachability follows `npm run` references transitively from `entry`, because a workspace is
56-
* covered whether it is invoked directly or through an intermediate script.
56+
* `scripts` is the root package's script map; `workspacesWithTypecheck` is every workspace that declares
57+
* one (package name + directory). Reachability follows `npm run` references transitively from `entry`,
58+
* because a workspace is covered whether it is invoked directly, through an intermediate script, or via
59+
* `tsc -p <dir>/tsconfig.json`.
5760
*/
5861
export function findTypecheckGaps(
5962
scripts: Readonly<Record<string, string>>,
60-
workspacesWithTypecheck: readonly string[],
63+
workspacesWithTypecheck: readonly WorkspaceWithTypecheck[],
6164
entry = "typecheck",
6265
): TypecheckGap[] {
6366
const covered = new Set<string>();
@@ -79,13 +82,18 @@ export function findTypecheckGaps(
7982
queue.push(...referencedRootScripts(body));
8083
}
8184
return workspacesWithTypecheck
82-
.filter((workspace) => !covered.has(workspace) && !covered.has(workspace.replace(/^@[\w-]+\//, "")))
83-
.map((workspace) => ({ workspace, script: "typecheck" }));
85+
.filter(
86+
(workspace) =>
87+
!covered.has(workspace.name) &&
88+
!covered.has(workspace.name.replace(/^@[\w-]+\//, "")) &&
89+
!covered.has(workspace.dir),
90+
)
91+
.map((workspace) => ({ workspace: workspace.name, script: "typecheck" }));
8492
}
8593

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

114122
if (gaps.length > 0) {
115123
console.error("`npm run typecheck` does not reach every workspace that declares one:\n");

test/unit/check-typecheck-coverage-script.test.ts

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ describe("findTypecheckGaps (#9860)", () => {
1818
// Present in the repo, but reachable only from test:ci -- never from `typecheck`.
1919
"ui:typecheck": "npm --workspace @loopover/ui run typecheck",
2020
};
21-
expect(findTypecheckGaps(scripts, ["@loopover/ui"])).toEqual([{ workspace: "@loopover/ui", script: "typecheck" }]);
21+
expect(findTypecheckGaps(scripts, [{ name: "@loopover/ui", dir: "apps/loopover-ui" }])).toEqual([
22+
{ workspace: "@loopover/ui", script: "typecheck" },
23+
]);
2224
});
2325

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

3537
it("counts a project typechecked directly by path as covered, without its own script being invoked", () => {
3638
// `typecheck:packages` runs tsc against the project file rather than calling the workspace's script.
39+
// Inputs must match what main() produces: { name, dir }, not a bare directory string (#10044).
3740
const scripts = {
3841
typecheck: "npm run typecheck:packages",
3942
"typecheck:packages": "tsc -p packages/loopover-contract/tsconfig.json --noEmit",
4043
};
41-
expect(findTypecheckGaps(scripts, ["packages/loopover-contract"])).toEqual([]);
44+
expect(findTypecheckGaps(scripts, [{ name: "@loopover/contract", dir: "packages/loopover-contract" }])).toEqual([]);
45+
});
46+
47+
it("REGRESSION (#10044): a workspace covered ONLY by tsc -p <dir> is not reported when identified by scoped package name", () => {
48+
// Before the fix, covered held "packages/loopover-contract" while the filter only checked
49+
// "@loopover/contract" / "contract" — so the -p branch was dead for every real main() call.
50+
const scripts = {
51+
typecheck: "npm run typecheck:packages",
52+
"typecheck:packages": "tsc -p packages/loopover-contract/tsconfig.json --noEmit",
53+
};
54+
expect(findTypecheckGaps(scripts, [{ name: "@loopover/contract", dir: "packages/loopover-contract" }])).toEqual([]);
55+
});
56+
57+
it("reports a gap when a different workspace's -p path does not cover this one", () => {
58+
const scripts = {
59+
typecheck: "npm run typecheck:packages",
60+
"typecheck:packages": "tsc -p packages/loopover-contract/tsconfig.json --noEmit",
61+
};
62+
expect(findTypecheckGaps(scripts, [{ name: "@loopover/ui", dir: "apps/loopover-ui" }])).toEqual([
63+
{ workspace: "@loopover/ui", script: "typecheck" },
64+
]);
4265
});
4366

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

4972
it("does NOT count a workspace whose BUILD is run but whose typecheck is not", () => {
5073
// A build may typecheck as a side effect, but that is a property of that script's current body, not a
5174
// guarantee. Treating it as coverage would silently accept a build that later stops emitting types.
5275
const scripts = { typecheck: "npm run ui:kit:build", "ui:kit:build": "npm --workspace @loopover/ui-kit run build" };
53-
expect(findTypecheckGaps(scripts, ["@loopover/ui-kit"])).toEqual([{ workspace: "@loopover/ui-kit", script: "typecheck" }]);
76+
expect(findTypecheckGaps(scripts, [{ name: "@loopover/ui-kit", dir: "packages/loopover-ui-kit" }])).toEqual([
77+
{ workspace: "@loopover/ui-kit", script: "typecheck" },
78+
]);
79+
});
80+
81+
it("treats a workspace as covered when covered holds the unscoped name (scoped-name strip hit)", () => {
82+
// Reversed-flag form with an unscoped workspace token puts "ui" in covered; the filter must still
83+
// recognise @loopover/ui via name.replace(/^@[\w-]+\//, "").
84+
const scripts = { typecheck: "npm run typecheck --workspace ui" };
85+
expect(findTypecheckGaps(scripts, [{ name: "@loopover/ui", dir: "apps/loopover-ui" }])).toEqual([]);
86+
});
87+
88+
it("does not treat a workspace as covered via scoped-name strip when the unscoped token is absent", () => {
89+
// Body has no -p match and no workspace reference — strip fallback misses; gap is reported by name.
90+
const scripts = { typecheck: "tsc --noEmit" };
91+
expect(findTypecheckGaps(scripts, [{ name: "@loopover/ui", dir: "apps/loopover-ui" }])).toEqual([
92+
{ workspace: "@loopover/ui", script: "typecheck" },
93+
]);
5494
});
5595

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

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

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

0 commit comments

Comments
 (0)