mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
ci: bound PR extension lint to direct owners and smoke packages
Share the boundary selector's package policy: directly changed plugins, Telegram/Codex/Slack smoke for shared sources, and direct consumers of changed public SDK entries, including test imports. Defer transitive and ambient consumer fan-out to hourly main and release validation. Keep full lint for lint/type policy and directly changed loose extension sources so their own canonical lint coverage remains intact. Log selected roots and reasons in addition to the existing job summary. Twenty complete recent PR path scenarios reduce the median selection from 164 to 3 packages and aggregate selections from 2461 to 237; full cases fall from 15 to 1. The one confirmed own-diff historical lint failure retains all three failing roots, with no observed miss. This is limited historical proof. The existing OPENCLAW_CI_EXTENSION_LINT_FULL=true or 1 switch restores full PR coverage; unset keeps the aggressive default. No variable or setting changed. Full SDK preparation and typed lint programs remain. A scoped-preparation experiment did not improve the measured cold smoke stripes, so it is not part of this change. Existing preparation plus selected lint takes 133-144 seconds on a four-CPU, 16-GiB Linux Testbox; adding observed median setup models about 3m12s per cold job, not the two-minute target or measured Actions wall. Proof: whole tooling across 927 files, full test types, changed-file and boundary lint, architecture, source-contract checks, real preflight in both checkout shapes, and P2 review. Three unrelated upgrade-survivor fixture failures reproduce on the unmodified parent. The peer's declaration-fixture repair is preserved and passes the final owner replay. Selector tests cost 3.26 seconds in the local single-file diagnostic and also passed on Linux.
This commit is contained in:
parent
f138a1ea09
commit
5151a06b8d
5 changed files with 123 additions and 163 deletions
|
|
@ -94,7 +94,7 @@ The iOS, macOS, and both shared OpenClawKit Periphery scans use Xcode 27 on GitH
|
|||
- **Control UI source selection** retains the complete ordinary UI unit family when its area changes. Control UI E2E on PRs uses the existing PR-exempt owner policy: a fixed smoke cohort plus tests whose source owners, imported dependencies, harness inputs, or test files changed. Unmapped tests run on hourly main and in release validation; PRs still select them when their own file or an imported dependency changes. Only the core E2E harness, UI bundle configuration, explicit global app-shell owners, or unavailable changed-path information select the full Control UI E2E inventory. Shared components, styles, and feature modules follow explicit route/component owners and direct runtime imports instead; test and fixture imports retain transitive selection. Shared shell/store cycles do not make a leaf change select every route. Hourly and release validation own the full transitive composition. Owner selections share one browser row per 30 files, bounded by the existing row cap; full validation and the kill switch retain their existing row counts. The preflight job summary lists every selected file and its reasons. Set the repository variable `OPENCLAW_CI_UI_E2E_FULL` to `true` or `1` to restore full coverage for admitted Control UI E2E jobs on PRs. Hourly main and manual/release validation select the full Control UI E2E inventory, including the mapped compositions formerly restricted to release validation. Real-Gateway and browser-extension families retain their independent owners, so a browser-extension change need not select Control UI jobs. Node excludes files delegated to a selected UI job.
|
||||
- **Documentation beside code** keeps all `docs/` content, including navigation JSON, generated metadata, and assets, plus README files, `AGENTS.md`, and skill Markdown from widening the code's Node plan. Packaged workspace templates and test fixtures retain their runtime owners. Docs-only routing still selects no Node rows; unrelated runtime Markdown outside `docs/` retains its owner.
|
||||
- **PR dependency and helper selection** combines bounded owner areas, transitive test import/re-export reachability through workspace and public SDK aliases, explicit policy watches, and protected regressions. Protected tests also follow runtime imports across owner areas. One-hop proximity alone misses observed catches. Unrelated global inputs do not request the full runtime repository; owner and protected-test policy supplies the retained coverage.
|
||||
- **PR extension lint** reports complete affected plugin directories, selected from changed files and their transitive import consumers. The installed check planner records selected extensions and reasons in the job summary; no affected extensions means no extension lint work. The native typed lint program still resolves shared dependencies, and core, script, compiler, boundary, and policy checks keep their existing owners. Shared lint/type policy, ambient or unresolved inputs retain full extension lint. Set repository variable `OPENCLAW_CI_EXTENSION_LINT_FULL` to `true` or `1` to restore full extension lint on PRs. Hourly main, release validation, and historical targets retain full coverage. Broad PR check families use the existing planner for extension selection while preserving their full non-extension checks.
|
||||
- **PR extension lint** reports directly changed plugin directories, the Telegram/Codex/Slack smoke cohort for shared source changes, and direct consumers of changed public SDK entries (including test imports). It shares package-selection policy with the boundary checker; transitive and ambient-type fan-out runs on hourly main and release validation. The planner records selected extensions and reasons in its summary; no selected extensions means no extension lint work. Selected stripes retain the existing full declaration preparation, native typed lint program, and diagnostics. Lint/type policy changes, directly changed loose extension sources, and unavailable public-entry inventories retain full coverage. The existing `OPENCLAW_CI_EXTENSION_LINT_FULL=true|1` switch restores full PR coverage; unset keeps the aggressive default. Historical targets retain their full coverage, and non-extension checks keep their existing owners.
|
||||
- **Erased TypeScript modules** retain their owner tests, affected runtime test consumers, and declared source watches. Type-only changes still select every consuming compiler graph through the separate static-check owner. Ambiguous compiler ownership, ambient/declaration files, and missing history retain complete static checks without widening runtime tests.
|
||||
- **Filesystem source scanners** retain their declared watched inputs alongside ordinary importers. A policy watch supplements each changed source's tests and never supplies a missing ordinary owner.
|
||||
- **Global and hub Node inputs** select their bounded owner tests, transitive import consumers, and protected regressions plus the fixed PR smoke set. Shared test bootstrap/configuration, root compiler resolution, workspace/install settings, patches, dependency hubs, and packing policy no longer request the complete runtime plan. Static checks retain full fallback where ownership is ambiguous. Missing changed paths or an unavailable bounded selector fail preflight instead of admitting every runtime family. Global package, lockfile, compiler, and shared Vitest inputs do not select every platform/browser family merely because Node ownership is broad; related protected tests remain selected. UI and wizard locale catalogs and translation metadata retain their dedicated localization and compiler checks without PR Node or browser rows; localization generators and runtime modules retain tests.
|
||||
|
|
|
|||
|
|
@ -424,6 +424,11 @@ if (isDirectRunUrl(process.argv[1], import.meta.url)) {
|
|||
const plan = await createCiCheckPlan(
|
||||
parseInput(JSON.parse(process.env.OPENCLAW_CI_CHECK_PLAN_INPUT_JSON ?? "null")),
|
||||
);
|
||||
if (plan.extension_lint_selection_json) {
|
||||
console.log(
|
||||
`[ci-check-plan] extension lint selection: ${plan.extension_lint_selection_json}`,
|
||||
);
|
||||
}
|
||||
if (plan.extension_lint_selection_json && process.env.GITHUB_STEP_SUMMARY) {
|
||||
const selection: Awaited<
|
||||
ReturnType<
|
||||
|
|
|
|||
|
|
@ -1,31 +1,15 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as ts from "typescript/unstable/ast";
|
||||
import { resolveImportGraphDependents } from "../test-projects.test-support.mts";
|
||||
import { listAvailableExtensionIds } from "./changed-extensions.mts";
|
||||
import { createNativeTypeScriptParser } from "./native-typescript.mts";
|
||||
import { selectAffectedBoundaryPackages } from "./extension-boundary-selection.mts";
|
||||
|
||||
/** Select reported packages; the lint runner retains the complete typed program. */
|
||||
/** Share PR package ownership with boundary checks; transitive fan-out runs hourly. */
|
||||
export async function resolveCiExtensionLintSelection(
|
||||
changedFiles: readonly string[],
|
||||
cwd = process.cwd(),
|
||||
{ forceFull = false, baseRef }: { forceFull?: boolean; baseRef?: string } = {},
|
||||
) {
|
||||
const available = new Set(listAvailableExtensionIds(cwd).map((id) => `extensions/${id}`));
|
||||
const reasons: Record<string, string[]> = {};
|
||||
const available = listAvailableExtensionIds(cwd);
|
||||
const fullReasons: string[] = [];
|
||||
const rootFor = (file: string) => {
|
||||
const root = file.match(/^extensions\/[^/]+\//u)?.[0].slice(0, -1);
|
||||
return root && available.has(root) ? root : undefined;
|
||||
};
|
||||
const select = (root: string, reason: string) => {
|
||||
const entries = (reasons[root] ??= []);
|
||||
if (!entries.includes(reason)) {
|
||||
entries.push(reason);
|
||||
}
|
||||
};
|
||||
const sources: string[] = [];
|
||||
if (forceFull) {
|
||||
fullReasons.push("OPENCLAW_CI_EXTENSION_LINT_FULL");
|
||||
}
|
||||
|
|
@ -33,9 +17,12 @@ export async function resolveCiExtensionLintSelection(
|
|||
if (path.isAbsolute(file) || file.split("/").some((part) => part === ".." || !part)) {
|
||||
throw new Error(`Invalid changed extension lint path: ${file}`);
|
||||
}
|
||||
const root = rootFor(file);
|
||||
if (root) {
|
||||
select(root, `changed: ${file}`);
|
||||
if (
|
||||
file.startsWith("extensions/") &&
|
||||
/\.[cm]?[jt]sx?$/u.test(file) &&
|
||||
!available.includes(file.split("/")[1] ?? "")
|
||||
) {
|
||||
fullReasons.push(`direct shared extension source: ${file}`);
|
||||
}
|
||||
// These inputs can change type discovery or rules without a source import edge.
|
||||
if (
|
||||
|
|
@ -65,116 +52,30 @@ export async function resolveCiExtensionLintSelection(
|
|||
"scripts/lib/plugin-sdk-private-local-only-subpaths.json",
|
||||
"src/state/openclaw-state-schema.sql",
|
||||
"src/state/openclaw-agent-schema.sql",
|
||||
].includes(file) ||
|
||||
path.basename(file) === "package.json"
|
||||
].includes(file)
|
||||
) {
|
||||
fullReasons.push(`lint or type policy: ${file}`);
|
||||
continue;
|
||||
}
|
||||
if (file.startsWith("extensions/") && !root) {
|
||||
fullReasons.push(`shared extension input: ${file}`);
|
||||
continue;
|
||||
}
|
||||
if (/\.[cm]?[jt]sx?$/u.test(file)) {
|
||||
if (!existsSync(path.join(cwd, file))) {
|
||||
// Previous bytes may contain an ambient declaration; no safe graph-only projection.
|
||||
fullReasons.push(`removed source: ${file}`);
|
||||
} else {
|
||||
sources.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!fullReasons.length && sources.length) {
|
||||
const previousSources: { fileName: string; text: string }[] = [];
|
||||
let selection: ReturnType<typeof selectAffectedBoundaryPackages> | undefined;
|
||||
if (!fullReasons.length) {
|
||||
try {
|
||||
if (!baseRef || !/^[a-f0-9]{40}$/u.test(baseRef)) {
|
||||
throw new Error("Missing exact base revision");
|
||||
}
|
||||
const git = (args: string[]) =>
|
||||
execFileSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (git(["rev-parse", "--verify", `${baseRef}^{commit}`]).trim() !== baseRef) {
|
||||
throw new Error("Unavailable exact base revision");
|
||||
}
|
||||
const priorFiles = new Set(
|
||||
git(["ls-tree", "-r", "--name-only", "-z", baseRef, "--", ...sources]).split("\0"),
|
||||
selection = selectAffectedBoundaryPackages(
|
||||
cwd,
|
||||
available,
|
||||
[...new Set(changedFiles)].map((file) => ({ path: file, status: "M" })),
|
||||
baseRef,
|
||||
{ includeTestSources: true },
|
||||
);
|
||||
for (const fileName of sources.filter((file) => priorFiles.has(file))) {
|
||||
previousSources.push({ fileName, text: git(["show", `${baseRef}:${fileName}`]) });
|
||||
}
|
||||
} catch {
|
||||
fullReasons.push("previous source types unavailable at the exact diff base");
|
||||
}
|
||||
const consumers = resolveImportGraphDependents(sources, cwd, {
|
||||
tooling: true,
|
||||
resolveAliases: true,
|
||||
});
|
||||
const executionOwners = new Set([
|
||||
"scripts/run-oxlint.mts",
|
||||
"scripts/run-oxlint-shards.mts",
|
||||
"scripts/prepare-extension-package-boundary-artifacts.mts",
|
||||
"scripts/oxlint-boundary-guards.mjs",
|
||||
"scripts/ci-check-plan.mts",
|
||||
"scripts/ci-build-manifest.mjs",
|
||||
]);
|
||||
if (sources.some((file) => file.startsWith("scripts/"))) {
|
||||
for (const consumer of consumers.filter((file) => executionOwners.has(file))) {
|
||||
fullReasons.push(`lint execution consumer: ${consumer}`);
|
||||
}
|
||||
}
|
||||
const currentSources = [...sources, ...consumers].map((fileName) => ({
|
||||
fileName,
|
||||
text: readFileSync(path.join(cwd, fileName), "utf8"),
|
||||
}));
|
||||
// Class fields, module-local declarations and fixture strings are not global type inputs.
|
||||
using parser = createNativeTypeScriptParser({ cwd });
|
||||
// A broad SDK edit can reach thousands of files. Release each syntax batch
|
||||
// before decoding the next; a single global impact already requires full lint.
|
||||
ambientScan: for (const inputs of fullReasons.length ? [] : [previousSources, currentSources]) {
|
||||
for (let offset = 0; offset < inputs.length; offset += 32) {
|
||||
for (const source of parser.parseSourceFiles(inputs.slice(offset, offset + 32))) {
|
||||
let ambient = !source.externalModuleIndicator;
|
||||
const visit = (node: ts.Node) => {
|
||||
if (!ts.isModuleDeclaration(node)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
ts.isStringLiteralLikeNode(node.name) ||
|
||||
(ts.isIdentifier(node.name) && node.name.text === "global")
|
||||
) {
|
||||
ambient = true;
|
||||
}
|
||||
if (node.body) {
|
||||
if (ts.isModuleDeclaration(node.body)) {
|
||||
visit(node.body);
|
||||
} else {
|
||||
node.body.forEachChild(visit);
|
||||
}
|
||||
}
|
||||
};
|
||||
source.forEachChild(visit);
|
||||
if (ambient) {
|
||||
fullReasons.push(
|
||||
`ambient type impact: ${path.relative(cwd, source.fileName).split(path.sep).join("/")}`,
|
||||
);
|
||||
break ambientScan;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const file of consumers) {
|
||||
const root = rootFor(file);
|
||||
if (root && !reasons[root]) {
|
||||
select(root, `import consumer: ${file}`);
|
||||
} else if (file.startsWith("extensions/") && !root) {
|
||||
fullReasons.push(`shared extension consumer: ${file}`);
|
||||
}
|
||||
fullReasons.push("direct public-entry inventory unavailable");
|
||||
}
|
||||
}
|
||||
const reasons = Object.fromEntries(
|
||||
(selection?.selected ?? []).map(({ package: id, reason }) => [`extensions/${id}`, [reason]]),
|
||||
);
|
||||
|
||||
return {
|
||||
mode: fullReasons.length ? ("full" as const) : ("selected" as const),
|
||||
extensionRoots: Object.keys(reasons).toSorted(),
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export function selectAffectedBoundaryPackages(
|
|||
extensionIds: string[],
|
||||
changes: Change[],
|
||||
base?: string,
|
||||
{ includeTestSources = false }: { includeTestSources?: boolean } = {},
|
||||
): Selection {
|
||||
const reasons = new Map<string, string>();
|
||||
let sharedChange: string | undefined;
|
||||
|
|
@ -66,9 +67,12 @@ export function selectAffectedBoundaryPackages(
|
|||
changedEntries.add(`openclaw/plugin-sdk/${entry}`);
|
||||
}
|
||||
if (
|
||||
(/^(?:src|packages)\//u.test(file) &&
|
||||
((/^(?:src|packages)\//u.test(file) ||
|
||||
(includeTestSources &&
|
||||
(file.startsWith("scripts/") ||
|
||||
(file.startsWith("extensions/") && !extensionIds.includes(owner ?? ""))))) &&
|
||||
SOURCE.test(file) &&
|
||||
!/\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(file)) ||
|
||||
(includeTestSources || !/\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(file))) ||
|
||||
/^(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|tsconfig[^/]*\.json)$/u.test(file) ||
|
||||
/^scripts\/(?:lib\/plugin-sdk-|(?:prepare|check|compile)-extension.*boundary)/u.test(file)
|
||||
) {
|
||||
|
|
@ -89,7 +93,11 @@ export function selectAffectedBoundaryPackages(
|
|||
maxBuffer: 8 * 1024 * 1024,
|
||||
})
|
||||
.split("\0")
|
||||
.filter((file) => SOURCE.test(file) && !/\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(file));
|
||||
.filter(
|
||||
(file) =>
|
||||
SOURCE.test(file) &&
|
||||
(includeTestSources || !/\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(file)),
|
||||
);
|
||||
const sources: { fileName: string; text: string; owner: string }[] = [];
|
||||
for (const file of files) {
|
||||
const owner = file.split("/")[1]!;
|
||||
|
|
|
|||
|
|
@ -43,10 +43,12 @@ function workspace() {
|
|||
},
|
||||
}),
|
||||
);
|
||||
for (const id of ["alpha", "beta", "unrelated"]) {
|
||||
for (const id of ["alpha", "beta", "unrelated", "telegram", "codex", "slack"]) {
|
||||
write(`extensions/${id}/package.json`, '{"private":true}');
|
||||
write(`extensions/${id}/index.ts`, "export {};\n");
|
||||
}
|
||||
write("scripts/lib/plugin-sdk-entrypoints.json", '["api", "public"]');
|
||||
write("scripts/lib/plugin-sdk-private-local-only-subpaths.json", "[]");
|
||||
write("src/plugin-sdk/api.ts", "export type Result = Promise<void>;\n");
|
||||
write("src/plugin-sdk/public.ts", 'export type { Result } from "./api.js";\n');
|
||||
write(
|
||||
|
|
@ -68,32 +70,36 @@ function workspace() {
|
|||
return { cwd, write, baseRef, commitBase };
|
||||
}
|
||||
|
||||
const smokeRoots = ["extensions/codex", "extensions/slack", "extensions/telegram"];
|
||||
|
||||
describe("extension lint package selection", () => {
|
||||
it("selects whole packages for transitive aliased type consumers and directly changed metadata", async () => {
|
||||
it("selects direct public-entry consumers and changed metadata while deferring transitive consumers", async () => {
|
||||
const { cwd, baseRef } = workspace();
|
||||
const selected = await resolveCiExtensionLintSelection(
|
||||
["src/plugin-sdk/api.ts", "extensions/unrelated/openclaw.plugin.json"],
|
||||
["src/plugin-sdk/public.ts", "extensions/unrelated/openclaw.plugin.json"],
|
||||
cwd,
|
||||
{ baseRef },
|
||||
);
|
||||
expect(selected.mode).toBe("selected");
|
||||
expect(selected.extensionRoots).toEqual([
|
||||
"extensions/alpha",
|
||||
"extensions/beta",
|
||||
...smokeRoots,
|
||||
"extensions/unrelated",
|
||||
]);
|
||||
expect(selected.reasons["extensions/alpha"]).toEqual([
|
||||
"import consumer: extensions/alpha/index.ts",
|
||||
"direct import of changed public entry openclaw/plugin-sdk/public: extensions/alpha/index.ts",
|
||||
]);
|
||||
expect(selected.reasons["extensions/unrelated"]).toEqual([
|
||||
"changed: extensions/unrelated/openclaw.plugin.json",
|
||||
"PR changes extensions/unrelated/openclaw.plugin.json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits extension lint when a source has no extension consumers", async () => {
|
||||
it("omits extension lint for UI and documentation outside extension ownership", async () => {
|
||||
const { cwd, baseRef } = workspace();
|
||||
expect(
|
||||
await resolveCiExtensionLintSelection(["src/leaf.ts", "docs/example.md"], cwd, { baseRef }),
|
||||
await resolveCiExtensionLintSelection(["ui/src/leaf.ts", "docs/example.md"], cwd, {
|
||||
baseRef,
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "selected",
|
||||
extensionRoots: [],
|
||||
|
|
@ -110,12 +116,11 @@ describe("extension lint package selection", () => {
|
|||
);
|
||||
const selection = await resolveCiExtensionLintSelection(["src/leaf.ts"], cwd, { baseRef });
|
||||
expect(selection.mode).toBe("selected");
|
||||
expect(selection.extensionRoots).toEqual([]);
|
||||
expect(selection.extensionRoots).toEqual(smokeRoots);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"pnpm-lock.yaml",
|
||||
"extensions/alpha/package.json",
|
||||
"extensions/tsconfig.json",
|
||||
"config/oxlint/boundary-guards.json",
|
||||
"config/tsconfig/oxlint.json",
|
||||
|
|
@ -123,8 +128,7 @@ describe("extension lint package selection", () => {
|
|||
"src/state/openclaw-state-schema.sql",
|
||||
"scripts/lib/plugin-sdk-entrypoints.json",
|
||||
".github/workflows/ci.yml",
|
||||
"src/removed.ts",
|
||||
])("retains full coverage for unresolved or shared policy %s", async (file) => {
|
||||
])("retains full coverage for lint or type policy %s", async (file) => {
|
||||
const { cwd } = workspace();
|
||||
const selection = await resolveCiExtensionLintSelection([file], cwd);
|
||||
expect(selection.mode).toBe("full");
|
||||
|
|
@ -132,20 +136,23 @@ describe("extension lint package selection", () => {
|
|||
});
|
||||
|
||||
it.each([
|
||||
["extensions/shared.ts", "export const shared = true;\n", "shared extension input"],
|
||||
["src/global-types.ts", "interface GlobalResult { value: string }\n", "ambient type impact"],
|
||||
["extensions/shared.ts", "export const shared = true;\n", true],
|
||||
["src/global-types.ts", "interface GlobalResult { value: string }\n", false],
|
||||
])(
|
||||
"keeps full coverage for %s outside package import ownership",
|
||||
async (file, source, reason) => {
|
||||
"keeps direct lint ownership while sampling consumers of %s",
|
||||
async (file, source, directSharedSource) => {
|
||||
const { cwd, write, baseRef } = workspace();
|
||||
write(file, source);
|
||||
const selection = await resolveCiExtensionLintSelection([file], cwd, { baseRef });
|
||||
expect(selection.mode).toBe("full");
|
||||
expect(selection.fullReasons).toContain(`${reason}: ${file}`);
|
||||
expect(selection.mode).toBe(directSharedSource ? "full" : "selected");
|
||||
expect(selection.extensionRoots).toEqual(directSharedSource ? [] : smokeRoots);
|
||||
expect(selection.fullReasons).toEqual(
|
||||
directSharedSource ? [`direct shared extension source: ${file}`] : [],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("retains full lint when a changed script configures its execution owner", async () => {
|
||||
it("uses smoke coverage when a script indirectly configures lint", async () => {
|
||||
const { cwd, write, baseRef } = workspace();
|
||||
write("scripts/lib/settings.mts", "export const flag = true;\n");
|
||||
write(
|
||||
|
|
@ -155,11 +162,12 @@ describe("extension lint package selection", () => {
|
|||
const selection = await resolveCiExtensionLintSelection(["scripts/lib/settings.mts"], cwd, {
|
||||
baseRef,
|
||||
});
|
||||
expect(selection.mode).toBe("full");
|
||||
expect(selection.fullReasons).toContain("lint execution consumer: scripts/run-oxlint.mts");
|
||||
expect(selection.mode).toBe("selected");
|
||||
expect(selection.extensionRoots).toEqual(smokeRoots);
|
||||
expect(selection.fullReasons).toEqual([]);
|
||||
});
|
||||
|
||||
it("retains full coverage when a consumer augments globals outside import edges", async () => {
|
||||
it("defers transitive global-augmentation consumers to hourly coverage", async () => {
|
||||
const { cwd, write, baseRef } = workspace();
|
||||
write(
|
||||
"src/ambient.ts",
|
||||
|
|
@ -168,8 +176,9 @@ describe("extension lint package selection", () => {
|
|||
const selection = await resolveCiExtensionLintSelection(["src/plugin-sdk/api.ts"], cwd, {
|
||||
baseRef,
|
||||
});
|
||||
expect(selection.mode).toBe("full");
|
||||
expect(selection.fullReasons).toContain("ambient type impact: src/ambient.ts");
|
||||
expect(selection.mode).toBe("selected");
|
||||
expect(selection.extensionRoots).toEqual(smokeRoots);
|
||||
expect(selection.fullReasons).toEqual([]);
|
||||
});
|
||||
|
||||
it("restores full coverage with the kill switch even for an unrelated diff", async () => {
|
||||
|
|
@ -181,43 +190,80 @@ describe("extension lint package selection", () => {
|
|||
expect(selection.fullReasons).toEqual(["OPENCLAW_CI_EXTENSION_LINT_FULL"]);
|
||||
});
|
||||
|
||||
it("keeps loose extension consumers under full lint", async () => {
|
||||
it("uses smoke coverage for loose extension consumers", async () => {
|
||||
const { cwd, write, baseRef } = workspace();
|
||||
write(
|
||||
"extensions/shared.ts",
|
||||
'import { value } from "../src/leaf.js"; export const shared = value;\n',
|
||||
);
|
||||
const selection = await resolveCiExtensionLintSelection(["src/leaf.ts"], cwd, { baseRef });
|
||||
expect(selection.mode).toBe("full");
|
||||
expect(selection.fullReasons).toContain("shared extension consumer: extensions/shared.ts");
|
||||
expect(selection.mode).toBe("selected");
|
||||
expect(selection.extensionRoots).toEqual(smokeRoots);
|
||||
expect(selection.fullReasons).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps implicit consumers of an unchanged script-global import type covered", async () => {
|
||||
it("uses smoke coverage for script-global import types", async () => {
|
||||
const { cwd, write, baseRef } = workspace();
|
||||
write("src/global-alias.ts", 'type SharedResult = import("./plugin-sdk/api.js").Result;\n');
|
||||
const selection = await resolveCiExtensionLintSelection(["src/plugin-sdk/api.ts"], cwd, {
|
||||
baseRef,
|
||||
});
|
||||
expect(selection.mode).toBe("full");
|
||||
expect(selection.fullReasons).toContain("ambient type impact: src/global-alias.ts");
|
||||
expect(selection.mode).toBe("selected");
|
||||
expect(selection.extensionRoots).toEqual(smokeRoots);
|
||||
expect(selection.fullReasons).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps global consumers covered when an existing augmentation is removed", async () => {
|
||||
it("uses smoke coverage when an existing augmentation is removed", async () => {
|
||||
const { cwd, write, commitBase } = workspace();
|
||||
write("src/leaf.ts", "export {}; declare global { interface Window { work: string } }\n");
|
||||
const baseRef = commitBase();
|
||||
write("src/leaf.ts", "export {};\n");
|
||||
const selection = await resolveCiExtensionLintSelection(["src/leaf.ts"], cwd, { baseRef });
|
||||
expect(selection.mode).toBe("full");
|
||||
expect(selection.fullReasons).toContain("ambient type impact: src/leaf.ts");
|
||||
expect(selection.mode).toBe("selected");
|
||||
expect(selection.extensionRoots).toEqual(smokeRoots);
|
||||
expect(selection.fullReasons).toEqual([]);
|
||||
});
|
||||
|
||||
it("retains full coverage when previous source types cannot be inspected", async () => {
|
||||
it("does not require prior source bytes for an ordinary core change", async () => {
|
||||
const { cwd } = workspace();
|
||||
const selection = await resolveCiExtensionLintSelection(["src/leaf.ts"], cwd);
|
||||
expect(selection.mode).toBe("full");
|
||||
expect(selection.fullReasons).toContain(
|
||||
"previous source types unavailable at the exact diff base",
|
||||
expect(selection.mode).toBe("selected");
|
||||
expect(selection.extensionRoots).toEqual(smokeRoots);
|
||||
expect(selection.fullReasons).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["extensions/alpha/package.json", ["extensions/alpha"]],
|
||||
["src/removed.ts", smokeRoots],
|
||||
])("keeps %s bounded to its package or smoke owners", async (file, expected) => {
|
||||
const { cwd } = workspace();
|
||||
const selection = await resolveCiExtensionLintSelection([file], cwd);
|
||||
expect(selection.mode).toBe("selected");
|
||||
expect(selection.extensionRoots).toEqual(expected);
|
||||
});
|
||||
|
||||
it("includes direct public SDK imports from tests without selecting transitive packages", async () => {
|
||||
const { cwd, write, baseRef } = workspace();
|
||||
write(
|
||||
"extensions/unrelated/api.test.ts",
|
||||
'import type { Result } from "openclaw/plugin-sdk/api"; export type TestResult = Result;\n',
|
||||
);
|
||||
const selection = await resolveCiExtensionLintSelection(["src/plugin-sdk/api.ts"], cwd, {
|
||||
baseRef,
|
||||
});
|
||||
expect(selection.mode).toBe("selected");
|
||||
expect(selection.extensionRoots).toEqual([...smokeRoots, "extensions/unrelated"]);
|
||||
expect(selection.reasons["extensions/unrelated"]).toEqual([
|
||||
"direct import of changed public entry openclaw/plugin-sdk/api: extensions/unrelated/api.test.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains full coverage when the public SDK inventory cannot be read at the pinned base", async () => {
|
||||
const { cwd } = workspace();
|
||||
const selection = await resolveCiExtensionLintSelection(["src/plugin-sdk/public.ts"], cwd, {
|
||||
baseRef: "a".repeat(40),
|
||||
});
|
||||
expect(selection.mode).toBe("full");
|
||||
expect(selection.fullReasons).toEqual(["direct public-entry inventory unavailable"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue