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.
This commit is contained in:
Peter Steinberger 2026-09-30 06:35:55 -07:00
parent cf71789e6b
commit 192b90aaae
No known key found for this signature in database
10 changed files with 334 additions and 33 deletions

View file

@ -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`.

View file

@ -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<CoreTsgoGraph[]> {
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)

View file

@ -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";
}

View file

@ -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 (

View file

@ -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 &&

View file

@ -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 };
}

View file

@ -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);

View file

@ -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]],

View file

@ -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;

View file

@ -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",