mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 15:51:35 +00:00
* fix(tooling): check changed exports for dead code
* fix(tooling): name mismatched PR wrapper components
* feat(tooling): repin PR review artifacts
* fix(tooling): scope dead-export detection to source files
Matching only the top-level tree let a docs file under src/ carrying `export`
in a code sample set the flag. That mattered because the flag also short-
circuited changedCheckRequiresRemote above its docsOnly guard, so a docs-only
change could be dragged onto the heavy remote route and made to run knip.
Scope detection to the source extensions knip actually reads, and drop the
routing branch entirely: any file that can now trigger detection already
enables a non-docs lane, which the existing final clause routes remotely. The
branch was dead for every real source change and wrong for the docs case.
* refactor(tooling): round-trip the repinned review JSON
Preserving byte-exact formatting required a hand-rolled JSON scanner: string
escape handling, container nesting, and span splicing, for a file that lives in
gitignored .local/ and is only ever read back through JSON.parse.
repin already parses the artifact to validate it, so assign the two identity
fields and re-serialize. JSON.parse/stringify keeps insertion order, which is
the only formatting property worth holding. Drops four helpers.
* fix(tooling): select the dead-export scan by path, not changed lines
Inspecting changed lines for an `export` token has two false negatives that
defeat the purpose. Barrels export through multiline `export { ... }` lists, so
removing a symbol changes a line carrying no `export` token. Worse, the failure
that motivated this scan was an import-only edit: it orphaned a re-export in a
barrel the diff never touched, which no changed-line rule can see.
Select by path instead: any changed production source file under src/,
extensions/, ui/, or packages/. This also removes the merge-base dependency, so
a shallow checkout or unrelated history can no longer silently skip the scan.
Those paths already enable a non-docs lane, so the run already routes remotely
and knip's cost lands on the box already doing the heavy work.
OPENCLAW_CHECK_CHANGED_SKIP_DEADCODE=1 still opts out.
* Revert "feat(tooling): repin PR review artifacts"
The repin subcommand defeats the property the identity pin exists to enforce.
It rewrites the pinned PR number and head SHA while keeping every
recommendation, finding, and Markdown conclusion, so a completed
"READY FOR /prepare-pr" verdict authored against one revision can be relabelled
as covering another and then pass validation. The test added alongside it
restamped PR #7 as PR #42 and asserted validation succeeded, which is exactly
the substitution the fail-closed gate was built to stop.
Re-authoring a review after the head moves is friction on purpose: the code the
verdict describes may no longer be the code being landed. A safe version has to
prove the reviewed content is equivalent across the move, which is a design
decision about what equivalence means, not a convenience wrapper.
Reverts 5d89a704d9 and its follow-up c6304eb539.
* docs(tooling): note why the wrapper diagnostic reads HEAD blobs
Two reviewers independently read the new component comparison as inspecting
committed state while the refusal was triggered by working-tree files. The
uncommitted-wrapper guard above already exits for any staged or unstaged edit to
these three paths, so HEAD matches the working tree by the time this runs. State
the invariant at the site instead of letting a third reader re-derive it.
* fix(tooling): cover jsx in the dead-export source selector
The hand-written extension alternation matched .tsx but not .jsx, so a JSX
source change would skip a scan that knip does apply to it. Use the
`[cm]?[jt]sx?` selector the lint lanes in check-changed.mjs already use, which
covers both and is shorter, and pin the extension coverage in tests.
660 lines
21 KiB
JavaScript
660 lines
21 KiB
JavaScript
import { execFileSync } from "node:child_process";
|
|
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs";
|
|
import { getChangedPathFacts, normalizeChangedPath } from "./lib/changed-path-facts.mjs";
|
|
import { isDirectRunUrl } from "./lib/direct-run.mjs";
|
|
import { resolveMergeHeadDiffBase } from "./lib/merge-head-diff-base.mjs";
|
|
|
|
const GIT_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024;
|
|
const IMPLAUSIBLE_NO_MERGE_BASE_DIFF_PATHS = 200;
|
|
const RAW_SYNC_CHANGED_LANES_ENV = "OPENCLAW_CHANGED_LANES_RAW_SYNC";
|
|
// Source files knip's production scan reads. Any edit to one of these can orphan
|
|
// an export -- including an import-only edit that drops a barrel re-export's last
|
|
// consumer -- so the scan is selected by path, not by inspecting changed lines.
|
|
const DEADCODE_SOURCE_PATH_RE = /^(?:src|extensions|ui|packages)\/.+\.[cm]?[jt]sx?$/u;
|
|
|
|
/** Returns whether any changed path is production source knip scans. */
|
|
export function hasDeadcodeScannedSource(changedPaths) {
|
|
return changedPaths.map(normalizeChangedPath).some((p) => DEADCODE_SOURCE_PATH_RE.test(p));
|
|
}
|
|
|
|
const SCRIPTS_TYPECHECK_PATH_RE =
|
|
/^(?:scripts\/.*\.(?:[cm]?ts|[cm]?tsx)|tsconfig\.scripts\.json)$/u;
|
|
const TEST_ROOT_TYPECHECK_PATH_RE =
|
|
/^(?:test\/(?!fixtures\/).*\.(?:[cm]?ts|[cm]?tsx)|test\/tsconfig\/tsconfig\.test\.root\.json)$/u;
|
|
/** @internal Shared repository-script contract. */
|
|
export const LIVE_DOCKER_AUTH_SHELL_TARGETS = [
|
|
"scripts/lib/live-docker-auth.sh",
|
|
"scripts/test-live-acp-bind-docker.sh",
|
|
"scripts/test-live-cli-backend-docker.sh",
|
|
"scripts/test-live-codex-harness-docker.sh",
|
|
"scripts/test-live-gateway-models-docker.sh",
|
|
"scripts/test-live-models-docker.sh",
|
|
"scripts/test-live-subagent-announce-docker.sh",
|
|
];
|
|
const LIVE_DOCKER_TOOLING_PATHS = new Set([
|
|
...LIVE_DOCKER_AUTH_SHELL_TARGETS,
|
|
"scripts/test-docker-all.mjs",
|
|
"src/gateway/gateway-acp-bind.live.test.ts",
|
|
"src/gateway/live-agent-probes.test.ts",
|
|
]);
|
|
const LIVE_DOCKER_PACKAGE_SCRIPT_RE = /^test:docker:live-[\w:-]+$/u;
|
|
const PUBLIC_EXTENSION_CONTRACT_RE =
|
|
/^(?:src\/plugin-sdk\/|src\/plugins\/contracts\/|src\/channels\/plugins\/|scripts\/lib\/plugin-sdk-entrypoints\.json$|scripts\/sync-plugin-sdk-exports\.mjs$|scripts\/generate-plugin-sdk-api-baseline\.ts$)/u;
|
|
const BUNDLED_CHANNEL_CONFIG_METADATA_PATH_RE =
|
|
/^(?:src\/config\/(?:bundled-channel-config-metadata\.generated|zod-schema\.[^/]+)\.ts|src\/channels\/plugins\/config-schema\.ts|src\/plugin-sdk\/(?:bundled-channel-config-schema|channel-config-schema)\.ts|src\/plugins\/(?:bundled-dir|public-surface-loader|public-surface-runtime|sdk-alias)\.ts|scripts\/(?:generate-bundled-channel-config-metadata\.ts|load-channel-config-surface\.ts|lib\/(?:bundled-plugin-source-utils|format-generated-module|generated-output-utils)\.mjs)|extensions\/[^/]+\/(?:openclaw\.plugin\.json|package\.json|(?:config|security-contract)-api\.[cm]?[jt]sx?|src\/config-(?:schema(?:-[^/]+)?|surface|ui-hints)\.[cm]?[jt]sx?))$/u;
|
|
/**
|
|
* Files whose changes are treated as release metadata only.
|
|
* @internal Shared repository-script contract.
|
|
*/
|
|
export const RELEASE_METADATA_PATHS = new Set([
|
|
"CHANGELOG.md",
|
|
"apps/android/CHANGELOG.md",
|
|
"apps/android/Config/Version.properties",
|
|
"apps/android/fastlane/metadata/android/en-US/release_notes.txt",
|
|
"apps/android/version.json",
|
|
"apps/ios/CHANGELOG.md",
|
|
"apps/macos/Sources/OpenClaw/Resources/Info.plist",
|
|
"docs/.generated/config-baseline.counts.json",
|
|
"docs/.generated/config-baseline.sha256",
|
|
"docs/install/updating.md",
|
|
"package.json",
|
|
]);
|
|
|
|
/** @typedef {"core" | "coreTests" | "ui" | "extensions" | "extensionTests" | "scripts" | "testRoot" | "apps" | "docs" | "tooling" | "liveDockerTooling" | "bundledChannelConfigMetadata" | "releaseMetadata" | "all"} ChangedLane */
|
|
|
|
/**
|
|
* @typedef {{
|
|
* paths: string[];
|
|
* lanes: Record<ChangedLane, boolean>;
|
|
* extensionImpactFromCore: boolean;
|
|
* docsOnly: boolean;
|
|
* reasons: string[];
|
|
* }} ChangedLaneResult
|
|
*/
|
|
|
|
/**
|
|
* Creates the default changed-lanes result object.
|
|
* @internal Directly tested script implementation detail.
|
|
*/
|
|
export function createEmptyChangedLanes() {
|
|
return {
|
|
core: false,
|
|
coreTests: false,
|
|
ui: false,
|
|
extensions: false,
|
|
extensionTests: false,
|
|
scripts: false,
|
|
testRoot: false,
|
|
apps: false,
|
|
docs: false,
|
|
tooling: false,
|
|
liveDockerTooling: false,
|
|
bundledChannelConfigMetadata: false,
|
|
releaseMetadata: false,
|
|
all: false,
|
|
};
|
|
}
|
|
|
|
export function isChangedLaneTestPath(changedPath) {
|
|
return getChangedPathFacts(normalizeChangedPath(changedPath)).isChangedLaneTest;
|
|
}
|
|
|
|
/**
|
|
* Classifies a list of changed paths into docs, app, extension, core, and tooling lanes.
|
|
* @internal Shared repository-script contract.
|
|
*/
|
|
export function detectChangedLanes(changedPaths, options = {}) {
|
|
const paths = [...new Set(changedPaths.map(normalizeChangedPath).filter(Boolean))]
|
|
.toSorted((left, right) => left.localeCompare(right))
|
|
.filter((changedPath) => changedPath !== "--");
|
|
const lanes = createEmptyChangedLanes();
|
|
const reasons = [];
|
|
let extensionImpactFromCore = false;
|
|
let hasNonDocs = false;
|
|
const packageJsonIsLiveDockerTooling =
|
|
paths.includes("package.json") && options.packageJsonChangeKind === "liveDockerTooling";
|
|
const packageJsonIsTooling =
|
|
paths.includes("package.json") && options.packageJsonChangeKind === "tooling";
|
|
|
|
if (paths.length === 0) {
|
|
reasons.push("no changed paths");
|
|
return { paths, lanes, extensionImpactFromCore: false, docsOnly: false, reasons };
|
|
}
|
|
|
|
if (
|
|
!packageJsonIsLiveDockerTooling &&
|
|
!packageJsonIsTooling &&
|
|
paths.some((changedPath) => RELEASE_METADATA_PATHS.has(changedPath)) &&
|
|
paths.every((changedPath) => RELEASE_METADATA_PATHS.has(changedPath))
|
|
) {
|
|
lanes.releaseMetadata = true;
|
|
lanes.docs = paths.some((changedPath) => getChangedPathFacts(changedPath).surface === "docs");
|
|
for (const changedPath of paths) {
|
|
reasons.push(`${changedPath}: release metadata`);
|
|
}
|
|
return { paths, lanes, extensionImpactFromCore: false, docsOnly: false, reasons };
|
|
}
|
|
|
|
for (const changedPath of paths) {
|
|
const facts = getChangedPathFacts(changedPath);
|
|
if (BUNDLED_CHANNEL_CONFIG_METADATA_PATH_RE.test(changedPath)) {
|
|
lanes.bundledChannelConfigMetadata = true;
|
|
reasons.push(`${changedPath}: bundled channel config metadata input`);
|
|
}
|
|
if (SCRIPTS_TYPECHECK_PATH_RE.test(changedPath)) {
|
|
lanes.scripts = true;
|
|
}
|
|
if (TEST_ROOT_TYPECHECK_PATH_RE.test(changedPath)) {
|
|
lanes.testRoot = true;
|
|
}
|
|
|
|
if (facts.surface === "docs") {
|
|
lanes.docs = true;
|
|
continue;
|
|
}
|
|
|
|
hasNonDocs = true;
|
|
|
|
if (changedPath === "package.json" && packageJsonIsLiveDockerTooling) {
|
|
lanes.liveDockerTooling = true;
|
|
reasons.push(`${changedPath}: live Docker package scripts`);
|
|
continue;
|
|
}
|
|
|
|
if (changedPath === "package.json" && packageJsonIsTooling) {
|
|
lanes.tooling = true;
|
|
reasons.push(`${changedPath}: package scripts`);
|
|
continue;
|
|
}
|
|
|
|
if (LIVE_DOCKER_TOOLING_PATHS.has(changedPath)) {
|
|
lanes.liveDockerTooling = true;
|
|
reasons.push(`${changedPath}: live Docker tooling surface`);
|
|
continue;
|
|
}
|
|
|
|
if (facts.surface === "rootGlobal") {
|
|
lanes.all = true;
|
|
extensionImpactFromCore = true;
|
|
reasons.push(`${changedPath}: root config/package surface`);
|
|
continue;
|
|
}
|
|
|
|
if (PUBLIC_EXTENSION_CONTRACT_RE.test(changedPath)) {
|
|
lanes.core = true;
|
|
lanes.coreTests = true;
|
|
lanes.extensions = true;
|
|
lanes.extensionTests = true;
|
|
extensionImpactFromCore = true;
|
|
reasons.push(`${changedPath}: public core/plugin contract affects extensions`);
|
|
continue;
|
|
}
|
|
|
|
if (facts.surface === "extension") {
|
|
if (facts.isChangedLaneTest) {
|
|
lanes.extensionTests = true;
|
|
reasons.push(`${changedPath}: extension test`);
|
|
} else {
|
|
lanes.extensions = true;
|
|
lanes.extensionTests = true;
|
|
reasons.push(`${changedPath}: extension production`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (facts.surface === "source" || facts.surface === "package") {
|
|
if (facts.isChangedLaneTest) {
|
|
lanes.coreTests = true;
|
|
reasons.push(`${changedPath}: core test`);
|
|
} else {
|
|
lanes.core = true;
|
|
lanes.coreTests = true;
|
|
reasons.push(`${changedPath}: core production`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (facts.surface === "ui") {
|
|
if (facts.isChangedLaneTest) {
|
|
lanes.coreTests = true;
|
|
reasons.push(`${changedPath}: UI test`);
|
|
} else {
|
|
lanes.ui = true;
|
|
lanes.coreTests = true;
|
|
reasons.push(`${changedPath}: UI production`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (facts.surface === "app") {
|
|
lanes.apps = true;
|
|
reasons.push(`${changedPath}: app surface`);
|
|
continue;
|
|
}
|
|
|
|
if (facts.surface === "rootTest" || facts.surface === "testFixture") {
|
|
lanes.tooling = true;
|
|
reasons.push(`${changedPath}: root test/support surface`);
|
|
continue;
|
|
}
|
|
|
|
if (facts.surface === "rootTooling") {
|
|
lanes.tooling = true;
|
|
reasons.push(`${changedPath}: tooling surface`);
|
|
continue;
|
|
}
|
|
|
|
if (facts.surface === "legacyRootAsset") {
|
|
lanes.tooling = true;
|
|
reasons.push(`${changedPath}: legacy root asset cleanup`);
|
|
continue;
|
|
}
|
|
|
|
lanes.all = true;
|
|
extensionImpactFromCore = true;
|
|
reasons.push(`${changedPath}: unknown surface; fail-safe all lanes`);
|
|
}
|
|
|
|
return {
|
|
paths,
|
|
lanes,
|
|
extensionImpactFromCore,
|
|
docsOnly: lanes.docs && !hasNonDocs,
|
|
reasons,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Classifies changed paths with optional package.json before/after contents.
|
|
* @internal Shared repository-script contract.
|
|
*/
|
|
export function detectChangedLanesForPaths(params) {
|
|
const base = params.staged
|
|
? params.base
|
|
: resolveMergeHeadDiffBase({
|
|
base: params.base,
|
|
head: params.head ?? "HEAD",
|
|
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
|
|
preferFirstParent: params.mergeHeadFirstParent === true,
|
|
});
|
|
const packageJsonChangeKind = params.paths.includes("package.json")
|
|
? classifyPackageJsonChangeFromGit({
|
|
base,
|
|
head: params.head,
|
|
staged: params.staged,
|
|
})
|
|
: null;
|
|
return detectChangedLanes(params.paths, { packageJsonChangeKind });
|
|
}
|
|
|
|
/**
|
|
* Lists changed paths from git for a base/head comparison.
|
|
*/
|
|
export function listChangedPathsFromGit(params) {
|
|
const head = params.head ?? "HEAD";
|
|
const cwd = params.cwd ?? process.cwd();
|
|
const base = resolveMergeHeadDiffBase({
|
|
base: params.base,
|
|
head,
|
|
cwd,
|
|
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
|
|
preferFirstParent: params.mergeHeadFirstParent === true,
|
|
});
|
|
if (!base) {
|
|
return [];
|
|
}
|
|
let rangePaths;
|
|
let noMergeBase = false;
|
|
try {
|
|
// oxlint-disable-next-line typescript/no-base-to-string, typescript/restrict-template-expressions -- resolveMergeHeadDiffBase returns a git ref string when present.
|
|
rangePaths = runGitNameOnlyDiff([`${base}...${head}`], cwd);
|
|
} catch (error) {
|
|
if (!isGitNoMergeBaseError(error)) {
|
|
throw error;
|
|
}
|
|
noMergeBase = true;
|
|
// oxlint-disable-next-line typescript/no-base-to-string, typescript/restrict-template-expressions -- resolveMergeHeadDiffBase returns a git ref string when present.
|
|
rangePaths = runGitNameOnlyDiff([`${base}..${head}`], cwd);
|
|
}
|
|
if (params.includeWorktree === false) {
|
|
return rangePaths;
|
|
}
|
|
const worktreePaths = [
|
|
...runGitNameOnlyDiff(["--cached", "--diff-filter=ACMRD"], cwd),
|
|
...runGitNameOnlyDiff(["--diff-filter=ACMRD"], cwd),
|
|
...runGitLsFiles(["--others", "--exclude-standard"], cwd),
|
|
];
|
|
// Raw Crabbox syncs can have unrelated synthetic refs; prefer the synced
|
|
// worktree delta instead of turning that into an accidental whole-repo gate.
|
|
if (
|
|
noMergeBase &&
|
|
process.env[RAW_SYNC_CHANGED_LANES_ENV] === "1" &&
|
|
worktreePaths.length > 0 &&
|
|
rangePaths.length > IMPLAUSIBLE_NO_MERGE_BASE_DIFF_PATHS
|
|
) {
|
|
rangePaths = [];
|
|
}
|
|
return [...new Set([...rangePaths, ...worktreePaths])].toSorted((left, right) =>
|
|
left.localeCompare(right),
|
|
);
|
|
}
|
|
|
|
function runGitNameOnlyDiff(extraArgs, cwd = process.cwd()) {
|
|
const output = execFileSync("git", ["diff", "--name-only", ...extraArgs], {
|
|
cwd,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
encoding: "utf8",
|
|
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
|
|
});
|
|
return output.split("\n").map(normalizeChangedPath).filter(Boolean);
|
|
}
|
|
|
|
function isGitNoMergeBaseError(error) {
|
|
const text = [
|
|
error?.message,
|
|
error?.stderr?.toString?.("utf8"),
|
|
Array.isArray(error?.output)
|
|
? error.output.map((value) => value?.toString?.("utf8")).join("\n")
|
|
: "",
|
|
].join("\n");
|
|
return text.includes("no merge base");
|
|
}
|
|
|
|
function runGitLsFiles(extraArgs, cwd = process.cwd()) {
|
|
const output = execFileSync("git", ["ls-files", ...extraArgs], {
|
|
cwd,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
encoding: "utf8",
|
|
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
|
|
});
|
|
return output.split("\n").map(normalizeChangedPath).filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Lists staged changed paths for pre-commit checks.
|
|
*/
|
|
export function listStagedChangedPaths(cwd = process.cwd()) {
|
|
const output = execFileSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMRD"], {
|
|
cwd,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
encoding: "utf8",
|
|
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
|
|
});
|
|
return output.split("\n").map(normalizeChangedPath).filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Classifies package.json script-only changes from git content.
|
|
*/
|
|
function classifyPackageJsonChangeFromGit(params) {
|
|
try {
|
|
const { before, after } = readPackageJsonBeforeAfter(params);
|
|
if (isLiveDockerPackageScriptOnlyChange(before, after)) {
|
|
return "liveDockerTooling";
|
|
}
|
|
return isPackageScriptOnlyChange(before, after) ? "tooling" : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks whether package scripts changed only live Docker script entries.
|
|
* @internal Directly tested script implementation detail.
|
|
*/
|
|
export function isLiveDockerPackageScriptOnlyChange(before, after) {
|
|
const beforePackage = JSON.parse(before);
|
|
const afterPackage = JSON.parse(after);
|
|
const beforeAllowed = extractLiveDockerPackageScripts(beforePackage);
|
|
const afterAllowed = extractLiveDockerPackageScripts(afterPackage);
|
|
const beforeStripped = stripLiveDockerPackageScripts(beforePackage);
|
|
const afterStripped = stripLiveDockerPackageScripts(afterPackage);
|
|
|
|
return (
|
|
stableJson(beforeStripped) === stableJson(afterStripped) &&
|
|
stableJson(beforeAllowed) !== stableJson(afterAllowed)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Checks whether package.json changes are limited to scripts.
|
|
* @internal Directly tested script implementation detail.
|
|
*/
|
|
export function isPackageScriptOnlyChange(before, after) {
|
|
const beforePackage = JSON.parse(before);
|
|
const afterPackage = JSON.parse(after);
|
|
const beforeScripts = extractPackageScripts(beforePackage);
|
|
const afterScripts = extractPackageScripts(afterPackage);
|
|
const beforeStripped = stripPackageScripts(beforePackage);
|
|
const afterStripped = stripPackageScripts(afterPackage);
|
|
|
|
return (
|
|
stableJson(beforeStripped) === stableJson(afterStripped) &&
|
|
stableJson(beforeScripts) !== stableJson(afterScripts)
|
|
);
|
|
}
|
|
|
|
function readPackageJsonBeforeAfter(params) {
|
|
const before = readGitText(params.staged ? "HEAD" : params.base, "package.json");
|
|
if (params.staged) {
|
|
return { before, after: readGitText("INDEX", "package.json") };
|
|
}
|
|
|
|
let after = readGitText(params.head ?? "HEAD", "package.json");
|
|
if (params.includeWorktree !== false && existsSync("package.json")) {
|
|
const worktree = readGitText("WORKTREE", "package.json");
|
|
if (worktree !== after) {
|
|
after = worktree;
|
|
}
|
|
}
|
|
return { before, after };
|
|
}
|
|
|
|
function readGitText(ref, filePath) {
|
|
if (ref === "WORKTREE") {
|
|
return readFileSync(filePath, "utf8");
|
|
}
|
|
const spec = ref === "INDEX" ? `:${filePath}` : `${ref}:${filePath}`;
|
|
return execFileSync("git", ["show", spec], {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
encoding: "utf8",
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
});
|
|
}
|
|
|
|
function extractLiveDockerPackageScripts(packageJson) {
|
|
const scripts = packageJson?.scripts;
|
|
if (!scripts || typeof scripts !== "object" || Array.isArray(scripts)) {
|
|
return {};
|
|
}
|
|
return Object.fromEntries(
|
|
Object.entries(scripts).filter(([name]) => LIVE_DOCKER_PACKAGE_SCRIPT_RE.test(name)),
|
|
);
|
|
}
|
|
|
|
function stripLiveDockerPackageScripts(packageJson) {
|
|
const clone = structuredClone(packageJson);
|
|
const scripts = clone.scripts;
|
|
if (!scripts || typeof scripts !== "object" || Array.isArray(scripts)) {
|
|
return clone;
|
|
}
|
|
for (const name of Object.keys(scripts)) {
|
|
if (LIVE_DOCKER_PACKAGE_SCRIPT_RE.test(name)) {
|
|
delete scripts[name];
|
|
}
|
|
}
|
|
return clone;
|
|
}
|
|
|
|
function extractPackageScripts(packageJson) {
|
|
const scripts = packageJson?.scripts;
|
|
return scripts && typeof scripts === "object" && !Array.isArray(scripts) ? scripts : {};
|
|
}
|
|
|
|
function stripPackageScripts(packageJson) {
|
|
const clone = structuredClone(packageJson);
|
|
delete clone.scripts;
|
|
return clone;
|
|
}
|
|
|
|
function stableJson(value) {
|
|
if (Array.isArray(value)) {
|
|
return `[${value.map(stableJson).join(",")}]`;
|
|
}
|
|
if (value && typeof value === "object") {
|
|
return `{${Object.keys(value)
|
|
.toSorted((left, right) => left.localeCompare(right))
|
|
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
|
|
.join(",")}}`;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
/**
|
|
* Writes changed-lane booleans to the GitHub Actions output file.
|
|
*/
|
|
function writeChangedLaneGitHubOutput(result, outputPath = process.env.GITHUB_OUTPUT) {
|
|
if (!outputPath) {
|
|
throw new Error("GITHUB_OUTPUT is required");
|
|
}
|
|
for (const [lane, enabled] of Object.entries(result.lanes)) {
|
|
appendFileSync(outputPath, `run_${toSnakeCase(lane)}=${String(enabled)}\n`, "utf8");
|
|
}
|
|
appendFileSync(outputPath, `docs_only=${result.docsOnly}\n`, "utf8");
|
|
appendFileSync(
|
|
outputPath,
|
|
`extension_impact_from_core=${result.extensionImpactFromCore}\n`,
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
function toSnakeCase(value) {
|
|
return value.replace(/[A-Z]/gu, (match) => `_${match.toLowerCase()}`);
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const separatorIndex = argv.indexOf("--");
|
|
const flagArgv = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex);
|
|
const explicitPaths = separatorIndex === -1 ? [] : argv.slice(separatorIndex + 1);
|
|
const args = {
|
|
base: "origin/main",
|
|
head: "HEAD",
|
|
staged: false,
|
|
mergeHeadFirstParent: false,
|
|
json: false,
|
|
githubOutput: false,
|
|
help: false,
|
|
paths: [],
|
|
};
|
|
const parsed = parseFlagArgs(
|
|
flagArgv,
|
|
args,
|
|
[
|
|
stringFlag("--base", "base"),
|
|
stringFlag("--head", "head"),
|
|
booleanFlag("--staged", "staged"),
|
|
booleanFlag("--merge-head-first-parent", "mergeHeadFirstParent"),
|
|
booleanFlag("--json", "json"),
|
|
booleanFlag("--github-output", "githubOutput"),
|
|
booleanFlag("--help", "help"),
|
|
booleanFlag("-h", "help"),
|
|
],
|
|
{
|
|
onUnhandledArg(arg, target) {
|
|
if (arg.startsWith("-")) {
|
|
throw new Error(`Unknown option: ${arg}`);
|
|
}
|
|
target.paths.push(arg);
|
|
return "handled";
|
|
},
|
|
},
|
|
);
|
|
parsed.paths.push(...explicitPaths);
|
|
return parsed;
|
|
}
|
|
|
|
function printUsage() {
|
|
console.log(
|
|
[
|
|
"Usage: node scripts/changed-lanes.mjs [options] [-- <paths...>]",
|
|
"",
|
|
"Options:",
|
|
" --base <ref> Base ref for changed paths (default: origin/main)",
|
|
" --head <ref> Head ref for changed paths (default: HEAD)",
|
|
" --staged Inspect staged changes",
|
|
" --json Print JSON result",
|
|
" --github-output Append GitHub output variables",
|
|
" -h, --help Show this help",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
|
|
function isDirectRun() {
|
|
return isDirectRunUrl(process.argv[1], import.meta.url);
|
|
}
|
|
|
|
function printHuman(result) {
|
|
const enabled = Object.entries(result.lanes)
|
|
.filter(([, value]) => value)
|
|
.map(([lane]) => lane);
|
|
console.log(`lanes: ${enabled.length > 0 ? enabled.join(", ") : "none"}`);
|
|
if (result.docsOnly) {
|
|
console.log("docs-only: true");
|
|
}
|
|
if (result.extensionImpactFromCore) {
|
|
console.log("extension-impact-from-core: true");
|
|
}
|
|
if (result.paths.length > 0) {
|
|
console.log("paths:");
|
|
for (const changedPath of result.paths) {
|
|
console.log(`- ${changedPath}`);
|
|
}
|
|
}
|
|
if (result.reasons.length > 0) {
|
|
console.log("reasons:");
|
|
for (const reason of result.reasons) {
|
|
console.log(`- ${reason}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isDirectRun()) {
|
|
let args;
|
|
try {
|
|
args = parseArgs(process.argv.slice(2));
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exit(1);
|
|
}
|
|
if (args.help) {
|
|
printUsage();
|
|
process.exit(0);
|
|
}
|
|
const paths =
|
|
args.paths.length > 0
|
|
? args.paths
|
|
: args.staged
|
|
? listStagedChangedPaths()
|
|
: listChangedPathsFromGit({
|
|
base: args.base,
|
|
head: args.head,
|
|
mergeHeadFirstParent: args.mergeHeadFirstParent,
|
|
});
|
|
const result = detectChangedLanesForPaths({
|
|
paths,
|
|
base: args.base,
|
|
head: args.head,
|
|
staged: args.staged,
|
|
mergeHeadFirstParent: args.mergeHeadFirstParent,
|
|
});
|
|
if (args.githubOutput) {
|
|
writeChangedLaneGitHubOutput(result);
|
|
}
|
|
if (args.json) {
|
|
console.log(JSON.stringify(result, null, 2));
|
|
} else if (!args.githubOutput) {
|
|
printHuman(result);
|
|
}
|
|
}
|