diff --git a/docs/ci/scope-and-routing/selection.md b/docs/ci/scope-and-routing/selection.md index d1f95c3eabd2..dbcd7d4c8ec9 100644 --- a/docs/ci/scope-and-routing/selection.md +++ b/docs/ci/scope-and-routing/selection.md @@ -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. diff --git a/scripts/ci-check-plan.mts b/scripts/ci-check-plan.mts index ff77bd76b5f2..45ae90ca0ea4 100644 --- a/scripts/ci-check-plan.mts +++ b/scripts/ci-check-plan.mts @@ -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< diff --git a/scripts/lib/ci-extension-lint-plan.mts b/scripts/lib/ci-extension-lint-plan.mts index bbd5bb0c26f1..4169b21192c3 100644 --- a/scripts/lib/ci-extension-lint-plan.mts +++ b/scripts/lib/ci-extension-lint-plan.mts @@ -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 = {}; + 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 | 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(), diff --git a/scripts/lib/extension-boundary-selection.mts b/scripts/lib/extension-boundary-selection.mts index 4bbba92d795c..4dc4d7ba69f5 100644 --- a/scripts/lib/extension-boundary-selection.mts +++ b/scripts/lib/extension-boundary-selection.mts @@ -49,6 +49,7 @@ export function selectAffectedBoundaryPackages( extensionIds: string[], changes: Change[], base?: string, + { includeTestSources = false }: { includeTestSources?: boolean } = {}, ): Selection { const reasons = new Map(); 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]!; diff --git a/test/scripts/ci-extension-lint-plan.test.ts b/test/scripts/ci-extension-lint-plan.test.ts index effa059ca928..89db22674da5 100644 --- a/test/scripts/ci-extension-lint-plan.test.ts +++ b/test/scripts/ci-extension-lint-plan.test.ts @@ -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;\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"]); }); });