From 192b90aaaee6fa3824bb487d3164f4fbac97db72 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 06:35:55 -0700 Subject: [PATCH] ci: plan extension types without serial core discovery Retain complete core graph validation in the existing required parallel boundary row for canonical extension-only changes. Discover all four noncore compiler consumers while preserving full fallback for aliases, missing inputs, mixed changes and incomplete inventories. --- docs/ci/pipeline.md | 2 +- scripts/check-tsgo-core-boundary.mts | 6 +- scripts/ci-build-manifest.mjs | 3 +- scripts/ci-check-plan.mts | 6 +- scripts/lib/tsgo-core-test-shards.mts | 10 +- scripts/run-tsgo-core-test-shards.mts | 43 +++++- test/scripts/ci-changed-type-graphs.test.ts | 148 ++++++++++++++++++-- test/scripts/ci-check-plan.test.ts | 47 ++++++- test/scripts/ci-workflow-planning.test.ts | 24 +++- test/scripts/tsgo-core-test-shards.test.ts | 78 +++++++++++ 10 files changed, 334 insertions(+), 33 deletions(-) diff --git a/docs/ci/pipeline.md b/docs/ci/pipeline.md index 9473fb332ac8..c3052fdecf61 100644 --- a/docs/ci/pipeline.md +++ b/docs/ci/pipeline.md @@ -704,7 +704,7 @@ automation account, and SecOps-owned-path cases before declaring enforcement act ## Fail-fast order 1. `preflight` decides which lanes exist at all. The `docs-scope` and `changed-scope` logic are steps inside this job, not standalone jobs. Canonical `main` starts immediately in one of two parity slots; each slot admits one complete run and coalesces later pushes into its newest pending tip. Downstream jobs wait for the manifest, then eligible Blacksmith jobs restore exact dependencies from the trusted warmer or fall back to the ordinary pnpm-store cache on a miss. Pushes, pull requests, and manual runs targeting the workflow revision run preflight with native Node and skip dependency setup. Manual runs targeting a different revision install dependencies and retain that target's `tsx` tooling. -2. `security-fast`, `check-*`, `check-additional-*`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs. Additional checks start directly after preflight. Narrow PRs with additional checks also place the existing `check-dependencies` row there: its complete dependency, unused-file, and export scans do not consume the installed compiler/lint plan. Full selections and runs without that family retain the central row, and the aggregate requires the selected owner. Compiler narrowing retains complete discovery for eligible source changes; known full selections skip discovery and retain boundary proof in an already selected additional boundary row or the required planner. No extra boundary row is admitted for that optimization. The production dependency audit sends one complete graph with up to four attempts and a four-minute total request budget, including retries and response reading. Timeouts, native fetch failures, HTTP 429, and 5xx responses retry with exponential backoff; retryable HTTP responses honor `Retry-After`. Attempts and recovery are logged. Persistent unavailability, vulnerability findings, invalid inputs, malformed advisory data, oversized responses, and permanent HTTP failures block CI. An unavailable audit is incomplete coverage, not a clean result. CI dispatched by Full Release Validation or release publication records a failing audit as a warning instead, because advisories never block a release. Local pre-commit and release dependency audits use the same bounded request owner and fail on unavailability; release dependency evidence blocks only on known malware. +2. `security-fast`, `check-*`, `check-additional-*`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs. Additional checks start directly after preflight. Narrow PRs with additional checks also place the existing `check-dependencies` row there: its complete dependency, unused-file, and export scans do not consume the installed compiler/lint plan. Full selections and runs without that family retain the central row, and the aggregate requires the selected owner. Extension-only compiler inputs can be planned from the four noncore graphs when an already selected additional boundary row owns the full core graph check. This requires existing regular source files without symlink aliases; mixed source changes retain complete discovery, and missing or uncertain inputs retain full checking. Known full selections skip discovery and retain boundary proof in an already selected additional boundary row or the required planner. No extra boundary row is admitted for that optimization. The production dependency audit sends one complete graph with up to four attempts and a four-minute total request budget, including retries and response reading. Timeouts, native fetch failures, HTTP 429, and 5xx responses retry with exponential backoff; retryable HTTP responses honor `Retry-After`. Attempts and recovery are logged. Persistent unavailability, vulnerability findings, invalid inputs, malformed advisory data, oversized responses, and permanent HTTP failures block CI. An unavailable audit is incomplete coverage, not a clean result. CI dispatched by Full Release Validation or release publication records a failing audit as a warning instead, because advisories never block a release. Local pre-commit and release dependency audits use the same bounded request owner and fail on unavailability; release dependency evidence blocks only on known malware. 3. `build-artifacts` and the locale checks overlap with the fast Linux lanes. Control UI and native app source PRs exclude generated locale snapshots/resources; their serialized refresh workflows repair and auto-merge isolated generated PRs in the background. Source CI still blocks stale source inventories and unsafe localization calls. Generated PRs, manual CI, and release prep enforce full translated/platform-generated parity. Canonical `release/YYYY.M.PATCH` branches may include release-prep locale repairs with the other generated release output. 4. Baseline ratchets and selected Node test shards start independently after preflight. Node rows consume the manifest, not ratchet outputs. `ci-gate` still requires every selected ratchet to pass, and the PR failure monitor still cancels remaining work after a ratchet failure. Frozen targets retain their existing ratchet selection. 5. Current plans with guards run `check:coercion-helpers` there once; fast-only plans retain its standalone row. Other platform and runtime lanes fan out independently: `checks-fast-core` (including startup corpus), `checks-fast-contracts-plugins`, `checks-fast-contracts-channels`, `checks-windows`, `macos-node`, `macos-swift`, `ios-build`, the screenshot shards, and `android`. diff --git a/scripts/check-tsgo-core-boundary.mts b/scripts/check-tsgo-core-boundary.mts index 913937ab633e..e000f79b5621 100644 --- a/scripts/check-tsgo-core-boundary.mts +++ b/scripts/check-tsgo-core-boundary.mts @@ -209,12 +209,12 @@ export async function checkCoreTsgoGraphBoundary( return graphs; } -/** Reuse the core boundary admission before inspecting the remaining CI compilers. */ +/** Noncore discovery requires the caller to retain a separate blocking core boundary. */ export async function inspectCiTsgoCheckGraphs( - options: { cwd?: string } = {}, + options: { cwd?: string; scope?: "all" | "noncore" } = {}, ): Promise { const cwd = realpathSync(options.cwd ?? repoRoot); - const graphs = await checkCoreTsgoGraphBoundary({ cwd }); + const graphs = options.scope === "noncore" ? [] : await checkCoreTsgoGraphBoundary({ cwd }); for (const graph of TSGO_CI_ADDITIONAL_GRAPHS) { const files = ( await runTsgoQuery(graph.config, "--listFilesOnly", `${graph.name} file listing`, cwd) diff --git a/scripts/ci-build-manifest.mjs b/scripts/ci-build-manifest.mjs index 559c02e9b7cb..906f78e270b7 100644 --- a/scripts/ci-build-manifest.mjs +++ b/scripts/ci-build-manifest.mjs @@ -728,11 +728,12 @@ if (runCheckPlan && narrowCheckScope.types) { const { resolveChangedCiTsgoInputs } = await import( fromTarget("./scripts/lib/tsgo-core-test-shards.mts") ); + const compilerPaths = resolveChangedCiTsgoInputs(changedPaths, existsSync); typeGraphBoundaryOwner = runNodeFull && !releaseFastLane && narrowCheckScope.additionalGroups.includes("boundaries") && - !resolveChangedCiTsgoInputs(changedPaths, existsSync) + (!compilerPaths || compilerPaths.every((file) => file.startsWith("extensions/"))) ? "additional-checks" : "check-plan"; } diff --git a/scripts/ci-check-plan.mts b/scripts/ci-check-plan.mts index 50cb31a2f9f1..751c84080127 100644 --- a/scripts/ci-check-plan.mts +++ b/scripts/ci-check-plan.mts @@ -52,7 +52,11 @@ export async function createCiCheckPlan(input: CiCheckPlanInput) { runs("prod-types") || runs("test-types") ? await ( await import("./run-tsgo-core-test-shards.mts") - ).createChangedCiTypeCheckPlan(input.changedPaths, { cwd: process.cwd() }) + ).createChangedCiTypeCheckPlan(input.changedPaths, { + cwd: process.cwd(), + coreBoundaryOwner: + input.typeGraphBoundaryOwner === "additional-checks" ? "additional-checks" : undefined, + }) : null; // Full selection needs no discovery, but a boundary without another admitted owner stays here. if ( diff --git a/scripts/lib/tsgo-core-test-shards.mts b/scripts/lib/tsgo-core-test-shards.mts index f8002295dd60..76b89b26b702 100644 --- a/scripts/lib/tsgo-core-test-shards.mts +++ b/scripts/lib/tsgo-core-test-shards.mts @@ -203,19 +203,23 @@ export function resolveChangedCiTsgoInputs( export function selectChangedCiTsgoGraphs( paths: readonly string[], graphs: readonly { config: string; files: readonly string[] }[], + options: { scope?: "all" | "noncore" } = {}, ): readonly { name: string; config: string }[] | undefined { const compilerPaths = resolveChangedCiTsgoInputs(paths); + const candidates = options.scope === "noncore" ? TSGO_CI_ADDITIONAL_GRAPHS : TSGO_CI_GRAPHS; if ( !compilerPaths || - graphs.length !== TSGO_CI_GRAPHS.length || - TSGO_CI_GRAPHS.some( + (options.scope === "noncore" && + !compilerPaths.every((file) => file.startsWith("extensions/"))) || + graphs.length !== candidates.length || + candidates.some( (expected) => graphs.filter((graph) => graph.config === expected.config).length !== 1, ) || compilerPaths.some((file) => !graphs.some((graph) => graph.files.includes(file))) ) { return undefined; } - return TSGO_CI_GRAPHS.filter((expected) => + return candidates.filter((expected) => graphs.some( (graph) => graph.config === expected.config && diff --git a/scripts/run-tsgo-core-test-shards.mts b/scripts/run-tsgo-core-test-shards.mts index 6c1bb571da88..0de0fc165ca6 100644 --- a/scripts/run-tsgo-core-test-shards.mts +++ b/scripts/run-tsgo-core-test-shards.mts @@ -3,7 +3,7 @@ // Run bounded test graphs in fresh processes so one shard's checker heap cannot // accumulate while the next shard loads. import { randomUUID } from "node:crypto"; -import { existsSync, realpathSync } from "node:fs"; +import { existsSync, lstatSync, realpathSync } from "node:fs"; import path from "node:path"; import type { CoreTsgoGraph } from "./check-tsgo-core-boundary.mts"; import { isDirectRunUrl } from "./lib/direct-run.mjs"; @@ -170,17 +170,46 @@ export function createChangedCoreTestCheck( /** Preflight selects compiler consumers once; executing rows retain their existing owners. */ export async function createChangedCiTypeCheckPlan( paths: readonly string[], - options: { cwd?: string } = {}, + options: { cwd?: string; coreBoundaryOwner?: "additional-checks" } = {}, ) { const cwd = realpathSync(options.cwd ?? repoRoot); - if (!resolveChangedCiTsgoInputs(paths, (file) => existsSync(path.resolve(cwd, file)))) { + const compilerPaths = resolveChangedCiTsgoInputs(paths, (file) => + existsSync(path.resolve(cwd, file)), + ); + if (!compilerPaths) { + return { mode: "full", graphs: TSGO_CI_GRAPHS }; + } + const scope = + options.coreBoundaryOwner === "additional-checks" && + compilerPaths.every((file) => file.startsWith("extensions/")) + ? "noncore" + : "all"; + // Only ordinary extension paths can rely on the parallel core boundary. + // Aliases can have different names in compiler inventories; retain all graphs. + const physicalExtensionInputs = () => { + try { + return compilerPaths.every((file) => { + const absolute = path.resolve(cwd, file); + return ( + path.relative(cwd, absolute).split(path.sep).join("/") === file && + lstatSync(absolute).isFile() && + realpathSync(absolute) === absolute + ); + }); + } catch { + return false; + } + }; + if (scope === "noncore" && !physicalExtensionInputs()) { return { mode: "full", graphs: TSGO_CI_GRAPHS }; } const { inspectCiTsgoCheckGraphs } = await import("./check-tsgo-core-boundary.mts"); - const inspected = await inspectCiTsgoCheckGraphs({ cwd }); - const selected = paths.every((file) => existsSync(path.resolve(cwd, file))) - ? selectChangedCiTsgoGraphs(paths, inspected) - : undefined; + const inspected = await inspectCiTsgoCheckGraphs({ cwd, scope }); + const selected = + paths.every((file) => existsSync(path.resolve(cwd, file))) && + (scope !== "noncore" || physicalExtensionInputs()) + ? selectChangedCiTsgoGraphs(paths, inspected, { scope }) + : undefined; return { mode: selected ? "changed" : "full", graphs: selected ?? TSGO_CI_GRAPHS }; } diff --git a/test/scripts/ci-changed-type-graphs.test.ts b/test/scripts/ci-changed-type-graphs.test.ts index acc87e4ed38c..fb9ce8e9c5a9 100644 --- a/test/scripts/ci-changed-type-graphs.test.ts +++ b/test/scripts/ci-changed-type-graphs.test.ts @@ -1,11 +1,12 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { mkdirSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { CoreTsgoGraph } from "../../scripts/check-tsgo-core-boundary.mts"; import { selectChangedCiTsgoGraphs, resolveCiTsgoGraphs, TSGO_CI_GRAPHS, + TSGO_CI_ADDITIONAL_GRAPHS, } from "../../scripts/lib/tsgo-core-test-shards.mts"; import { createChangedCiTypeCheckPlan } from "../../scripts/run-tsgo-core-test-shards.mts"; import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; @@ -79,6 +80,8 @@ describe("changed CI compiler graph selection", () => { ["unclassified/module.ts"], ["docs/plugins/sdk-subpaths.md", "ui/src/styles/chat.css"], [sharedType, "src/config/catalog.json"], + ["extensions/example/value.ts", "extensions/example/types.d.ts"], + ["extensions/example/value.ts", "extensions/example/tsconfig.json"], ])("retains all compilers without discovery for uncertain input %j", async (paths) => { expect(selectChangedCiTsgoGraphs(paths, graphs())).toBeUndefined(); inspectGraphs.mockRejectedValue(new Error("Full plans must not enumerate compiler inputs")); @@ -103,25 +106,142 @@ describe("changed CI compiler graph selection", () => { expect(inspectGraphs).not.toHaveBeenCalled(); }); - it("still discovers compiler consumers for existing source-only changes", async () => { - const cwd = tempDirs.make("ci-type-source-"); - mkdirSync(join(cwd, "src")); - writeFileSync(join(cwd, "src/value.ts"), "export type Value = number;\n"); + it.each([undefined, "additional-checks"] as const)( + "still discovers core consumers when the parallel boundary owner is %s", + async (coreBoundaryOwner) => { + const cwd = tempDirs.make("ci-type-source-"); + mkdirSync(join(cwd, "src")); + writeFileSync(join(cwd, "src/value.ts"), "export type Value = number;\n"); + inspectGraphs.mockResolvedValue( + graphs().map(({ name, config }) => ({ + name, + config, + roots: [], + files: name === "ui" ? ["src/value.ts"] : [], + })), + ); + expect( + await createChangedCiTypeCheckPlan(["src/value.ts"], { cwd, coreBoundaryOwner }), + ).toEqual({ mode: "changed", graphs: [{ name: "ui", config: "tsconfig.ui.json" }] }); + expect(inspectGraphs).toHaveBeenCalledOnce(); + }, + ); + + it("keeps every noncore consumer when a parallel owner checks the core boundary", async () => { + const cwd = tempDirs.make("ci-type-extension-"); + const paths = ["extensions/example/value.ts", "docs/value.md", "ui/styles/value.css"]; + for (const file of paths) { + mkdirSync(dirname(join(cwd, file)), { recursive: true }); + writeFileSync(join(cwd, file), "export type Value = number;\n"); + } inspectGraphs.mockResolvedValue( - graphs().map(({ name, config }) => ({ - name, - config, + TSGO_CI_ADDITIONAL_GRAPHS.map((graph) => ({ + ...graph, roots: [], - files: name === "ui" ? ["src/value.ts"] : [], + files: [paths[0]!], })), ); - expect(await createChangedCiTypeCheckPlan(["src/value.ts"], { cwd })).toEqual({ - mode: "changed", - graphs: [{ name: "ui", config: "tsconfig.ui.json" }], + expect( + await createChangedCiTypeCheckPlan(paths, { cwd, coreBoundaryOwner: "additional-checks" }), + ).toEqual({ mode: "changed", graphs: TSGO_CI_ADDITIONAL_GRAPHS }); + // Without a separate owner, a partial inventory cannot authorize narrowing. + expect(await createChangedCiTypeCheckPlan(paths, { cwd })).toEqual({ + mode: "full", + graphs: TSGO_CI_GRAPHS, }); - expect(inspectGraphs).toHaveBeenCalledOnce(); }); + it.each(["leaf", "directory", "extension-alias", "broken", "traversal", "directory-file"])( + "retains every compiler for a nonphysical extension input (%s)", + async (kind) => { + const cwd = tempDirs.make("ci-type-extension-alias-"); + mkdirSync(join(cwd, "src")); + mkdirSync(join(cwd, "extensions", "example"), { recursive: true }); + writeFileSync(join(cwd, "src/value.ts"), "export type Value = number;\n"); + writeFileSync(join(cwd, "extensions/example/other.ts"), "export type Value = number;\n"); + const file = + kind === "traversal" + ? "extensions/example/../../src/value.ts" + : kind === "directory" + ? "extensions/alias/value.ts" + : "extensions/example/value.ts"; + if (kind === "directory") { + symlinkSync(join(cwd, "src"), join(cwd, "extensions/alias"), "dir"); + } else if (kind === "directory-file") { + mkdirSync(join(cwd, file)); + } else if (kind !== "traversal") { + symlinkSync( + join( + cwd, + kind === "broken" + ? "src/missing.ts" + : kind === "extension-alias" + ? "extensions/example/other.ts" + : "src/value.ts", + ), + join(cwd, file), + "file", + ); + } + inspectGraphs.mockRejectedValue( + new Error("Aliases must retain all graphs without discovery"), + ); + expect( + await createChangedCiTypeCheckPlan([file], { cwd, coreBoundaryOwner: "additional-checks" }), + ).toEqual({ mode: "full", graphs: TSGO_CI_GRAPHS }); + expect(inspectGraphs).not.toHaveBeenCalled(); + }, + ); + + it.each(["deleted", "symlink"])( + "retains every compiler when an extension input becomes %s during discovery", + async (change) => { + const cwd = tempDirs.make("ci-type-extension-replaced-"); + const file = "extensions/example/value.ts"; + mkdirSync(join(cwd, "extensions/example"), { recursive: true }); + writeFileSync(join(cwd, file), "export type Value = number;\n"); + const target = join(cwd, "extensions/example/other.ts"); + writeFileSync(target, "export type Value = number;\n"); + inspectGraphs.mockImplementation(async () => { + unlinkSync(join(cwd, file)); + if (change === "symlink") { + symlinkSync(target, join(cwd, file), "file"); + } + return TSGO_CI_ADDITIONAL_GRAPHS.map((graph) => ({ ...graph, roots: [], files: [file] })); + }); + expect( + await createChangedCiTypeCheckPlan([file], { cwd, coreBoundaryOwner: "additional-checks" }), + ).toEqual({ mode: "full", graphs: TSGO_CI_GRAPHS }); + }, + ); + + it.each(["missing", "duplicate", "unexpected", "unmatched", "mixed", "ambient", "config"])( + "refuses incomplete or inapplicable noncore discovery (%s)", + (kind) => { + const file = "extensions/example/value.ts"; + const inventory: { config: string; files: string[] }[] = TSGO_CI_ADDITIONAL_GRAPHS.map( + ({ config }) => ({ config, files: [file] }), + ); + const paths = [file]; + if (kind === "missing") { + inventory.pop(); + } else if (kind === "duplicate") { + inventory[inventory.length - 1] = inventory[0]!; + } else if (kind === "unexpected") { + inventory[0] = { config: "tsconfig.core.json", files: [file] }; + } else if (kind === "unmatched") { + paths.push("extensions/example/other.ts"); + } else if (kind === "mixed") { + paths.push("src/value.ts"); + } else if (kind === "ambient") { + paths.push("extensions/example/types.d.ts"); + } else if (kind === "config") { + paths.push("extensions/example/package.json"); + } + expect(selectChangedCiTsgoGraphs(paths, inventory, { scope: "noncore" })).toBeUndefined(); + }, + ); + it.each(["missing", "duplicate"])("refuses an incomplete %s compiler inventory", (kind) => { const inventory = graphs(); inventory[0]!.files.push(sharedType); diff --git a/test/scripts/ci-check-plan.test.ts b/test/scripts/ci-check-plan.test.ts index 4cac6b9ab698..eb3040e472d9 100644 --- a/test/scripts/ci-check-plan.test.ts +++ b/test/scripts/ci-check-plan.test.ts @@ -6,6 +6,7 @@ import { createExtensionOxlintShards, selectExtensionOxlintStripe, } from "../../scripts/run-oxlint-shards.mts"; +import { createChangedCiTypeCheckPlan } from "../../scripts/run-tsgo-core-test-shards.mts"; import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; import { evaluateWorkflowExpression, @@ -21,7 +22,7 @@ const typeSelection = vi.hoisted(() => ({ })); vi.mock("../../scripts/run-tsgo-core-test-shards.mts", () => ({ - createChangedCiTypeCheckPlan: async () => ({ + createChangedCiTypeCheckPlan: vi.fn(async () => ({ mode: "targeted", graphs: typeSelection.graphs ?? [ { @@ -30,7 +31,7 @@ vi.mock("../../scripts/run-tsgo-core-test-shards.mts", () => ({ }, { name: "scripts", config: "tsconfig.scripts.json" }, ], - }), + })), })); const checkJobs = [ @@ -93,6 +94,48 @@ function materializePlan(runnerProfile: string, rows: number) { } describe("CI check-plan completion count", () => { + it.each(["", "check-plan", "additional-checks"] as const)( + "passes only an admitted parallel boundary owner without adding compiler rows (%s)", + async (typeGraphBoundaryOwner) => { + typeSelection.graphs = [ + { name: "extensions", config: "tsconfig.extensions.json" }, + { name: "extensions-test", config: "test/tsconfig/tsconfig.extensions.test.json" }, + { name: "test-root", config: "test/tsconfig/tsconfig.test.root.json" }, + ]; + vi.mocked(createChangedCiTypeCheckPlan).mockClear(); + try { + const paths = ["extensions/example/value.ts"]; + const plan = await createCiCheckPlan({ + typeGraphBoundaryOwner, + changedPaths: paths, + changedCoreTestPaths: null, + runnerProfile: "hybrid", + checkMatrix: { + include: [ + { check_name: "check-prod-types", task: "prod-types", runner: "unused" }, + { check_name: "check-test-types", task: "test-types", runner: "unused" }, + ], + }, + coreTypeMatrix: { include: [1, 2, 3, 4, 5].map((stripe) => ({ stripe })) }, + lintCoreMatrix: { include: [] }, + lintExtensionMatrix: { include: [] }, + }); + expect(createChangedCiTypeCheckPlan).toHaveBeenCalledExactlyOnceWith(paths, { + cwd: process.cwd(), + coreBoundaryOwner: + typeGraphBoundaryOwner === "additional-checks" ? "additional-checks" : undefined, + }); + expect(plan.core_type_matrix.include).toEqual([]); + expect(plan.check_job_count).toBe(2); + expect( + plan.check_matrix.include.map((row) => JSON.parse(row.type_graph_names_json!)), + ).toEqual([["extensions"], ["extensions-test", "test-root"]]); + } finally { + typeSelection.graphs = null; + } + }, + ); + it.each([ ["hybrid", [1, 2, 4, 5]], ["github", [1, 2, 3, 4, 5]], diff --git a/test/scripts/ci-workflow-planning.test.ts b/test/scripts/ci-workflow-planning.test.ts index 06f56b1cb3f4..265b949ddeb7 100644 --- a/test/scripts/ci-workflow-planning.test.ts +++ b/test/scripts/ci-workflow-planning.test.ts @@ -1254,6 +1254,7 @@ describe("ci workflow guards", () => { }, { path: "extensions/telegram/src/send.ts", + boundaryOwner: "additional-checks", tasks: ["guards", "prod-types", "lint", "dependencies", "test-types"], fastTasks: [], baselineRatchets: true, @@ -1295,6 +1296,7 @@ describe("ci workflow guards", () => { lintExtensionStripes, lintCentralScripts, graphs, + boundaryOwner, nodeDataOnly = false, }) => { const paths = [changedPath]; @@ -1422,7 +1424,7 @@ describe("ci workflow guards", () => { ); expect( evaluateWorkflowExpression(boundaryStep.env.TYPE_GRAPH_BOUNDARY_OWNER, context), - ).toBe(tasks.includes("test-types") ? "check-plan" : ""); + ).toBe(boundaryOwner ?? (tasks.includes("test-types") ? "check-plan" : "")); expect( evaluateWorkflowExpression( workflow.jobs["check-test-types-hosted-core-shard"].if, @@ -1504,6 +1506,16 @@ describe("ci workflow guards", () => { it.each([ { paths: ["src/config/settings.json"], owner: "check-plan", boundaryRow: false }, + { + paths: ["extensions/telegram/src/send.ts"], + owner: "additional-checks", + boundaryRow: true, + }, + { + paths: ["extensions/telegram/src/send.ts", "docs/plugins/example.md", "ui/styles/chat.css"], + owner: "additional-checks", + boundaryRow: true, + }, { paths: ["src/shared/runtime.ts", "src/config/settings.json"], owner: "additional-checks", @@ -1518,10 +1530,20 @@ describe("ci workflow guards", () => { eventName: "pull_request", runnerProfile: "hybrid", changedPaths: paths, + ciTypeGraphNames: paths[0]?.startsWith("extensions/") + ? ["extensions", "extensions-test", "test-root"] + : undefined, changedPlannerSource: changedPlannerSource(), }); expect(manifest.status, manifest.output).toBe(0); expect(manifest.outputs.type_graph_boundary_owner).toBe(owner); + if (paths[0]?.startsWith("extensions/")) { + expect(JSON.parse(manifest.checkPlanOutputs.core_type_matrix!).include).toEqual([]); + } + expect( + JSON.parse(expectDefined(manifest.outputs.check_plan_input_json, "check plan input")) + .typeGraphBoundaryOwner, + ).toBe(owner); const rows = JSON.parse( expectDefined(manifest.outputs.check_additional_matrix, "additional matrix"), ).include; diff --git a/test/scripts/tsgo-core-test-shards.test.ts b/test/scripts/tsgo-core-test-shards.test.ts index 1771b3ebe784..83b17a757324 100644 --- a/test/scripts/tsgo-core-test-shards.test.ts +++ b/test/scripts/tsgo-core-test-shards.test.ts @@ -14,6 +14,7 @@ import { findTsgoCoreTestShardViolations, selectChangedTsgoCoreTestShards, TSGO_CORE_GRAPHS, + TSGO_CI_ADDITIONAL_GRAPHS, selectTsgoCoreTestShards, selectTsgoCoreTestStripe, TSGO_CORE_TEST_SHARDS, @@ -635,6 +636,83 @@ process.exit(result.status??1); const initial = await check([leaf], "1/5"); expect(initial.result.status, initial.result.stderr).toBe(0); expect(initial.builds).toEqual([]); + const extension = "extensions/example/value.ts"; + write(extension, "export type ExtensionValue = number;\n"); + const noncoreConsumer = "test/noncore-consumer.ts"; + write( + noncoreConsumer, + "export type { ExtensionValue } from '../extensions/example/value.js';\n", + ); + for (const graph of TSGO_CI_ADDITIONAL_GRAPHS) { + write( + graph.config, + JSON.stringify({ + compilerOptions: { + noEmit: true, + strict: true, + types: [], + lib: ["es5"], + module: "nodenext", + target: "es2022", + }, + files: [path.join(root, noncoreConsumer)], + }), + ); + } + const plannerDriver = write( + "scripts/extension-plan-fixture.mts", + `import { createChangedCiTypeCheckPlan } from "./run-tsgo-core-test-shards.mts"; +import { checkCoreTsgoGraphBoundary } from "./check-tsgo-core-boundary.mts"; +if (process.argv[2] === "boundary") { + await checkCoreTsgoGraphBoundary(); +} else { + const plan = await createChangedCiTypeCheckPlan([${JSON.stringify(extension)}], { + cwd: process.cwd(), coreBoundaryOwner: "additional-checks", + }); + console.log(JSON.stringify({ mode: plan.mode, names: plan.graphs.map(({ name }) => name) })); +} +`, + ); + const inspectExtension = async (mode: "plan" | "boundary") => { + write("compiler-events.jsonl", ""); + return await lifetime.track( + runNodeScript( + [ + "--import", + pathToFileURL(path.join(sourceRoot, "scripts/tsx.mjs")).href, + plannerDriver, + mode, + ], + env, + undefined, + { cwd: root, signal, requireProcessTreeExit: true }, + ), + ); + }; + const extensionPlan = await inspectExtension("plan"); + expect(extensionPlan.status, extensionPlan.stderr).toBe(0); + expect(JSON.parse(extensionPlan.stdout.trim())).toEqual({ + mode: "changed", + names: ["extensions", "extensions-test", "scripts", "test-root"], + }); + const discovery = fs + .readFileSync(path.join(root, "compiler-events.jsonl"), "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as string[]); + expect(discovery).toHaveLength(4); + expect(discovery.every((args) => args.includes("--listFilesOnly"))).toBe(true); + // Its parallel owner must still reject a type-only edge into an extension. + write( + consumer, + "export type { ExtensionValue } from '../../../extensions/example/value.js';\n", + ); + const extensionBoundary = await inspectExtension("boundary"); + expect(extensionBoundary.status).not.toBe(0); + expect(extensionBoundary.stderr).toContain( + "Core tsgo graphs include bundled extension files", + ); + expect(extensionBoundary.stderr).toContain(extension); write( consumer, "import type {Value} from '../nested/leaf.test.js';\nconst value: Value = 1;\n",