revert: remove local PR verification gate (#7031)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run

Reverts QwenLM/qwen-code#6873 and QwenLM/qwen-code#7025.
This commit is contained in:
callmeYe 2026-07-16 19:24:38 +08:00 committed by GitHub
parent 1fd2a6cca4
commit 3d4601489e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 33 additions and 3038 deletions

View file

@ -128,15 +128,15 @@ cd integration-tests && \
**Gotcha:** In interactive tests, always call `session.idle()` between sends —
ANSI output streams asynchronously.
### Linting, Formatting & Verification
### Linting & Formatting
```bash
npm run lint # ESLint check
npm run lint:fix # Auto-fix lint issues
npm run format # Prettier formatting
npm run typecheck # TypeScript type checking
npm run preflight # Legacy broad check; writes formatting and omits some PR CI checks
npm run verify:pr # Optional clean PR verification after committing
npm run preflight # Full check: clean → install → format → lint → build
# → typecheck → test
```
## Code Conventions
@ -211,10 +211,6 @@ npm run verify:pr # Optional clean PR verification after committing
Here, `/review` means the Codex code-review workflow, not Qwen Review or
the `qwen-review` plugin. Do not invoke Qwen Review unless the user
explicitly requests it by name.
6. **Optional PR verification** — after the final changes are committed and the
working tree is clean, ask whether to run `npm run verify:pr`. Run it only
when requested; it is not a required local step before pushing, and remote CI
remains authoritative.
### Feature development

View file

@ -41,7 +41,7 @@ If you'd like to get early feedback on your work, please use GitHub's **Draft Pu
#### 4. Ensure All Checks Pass
Before submitting your PR, run `npm run verify:pr` from a clean Node 22 checkout. This is the final local PR gate; `npm run preflight` remains useful during development but is not equivalent to CI.
Before submitting your PR, ensure that all automated checks are passing by running `npm run preflight`. This command runs all tests, linting, and other style checks.
#### 5. Update Documentation
@ -152,7 +152,7 @@ To execute the unit test suite for the project:
npm run test
```
This will run tests located in the `packages/core` and `packages/cli` directories. Ensure focused tests pass while developing, then run `npm run verify:pr` before submitting the PR.
This will run tests located in the `packages/core` and `packages/cli` directories. Ensure tests pass before submitting any changes. For a more comprehensive check, it is recommended to run `npm run preflight`.
#### Integration Tests
@ -166,21 +166,15 @@ npm run test:e2e
For more detailed information on the integration testing framework, please see the [Integration Tests documentation](./docs/developers/development/integration-tests.md).
### Linting and PR Verification
### Linting and Preflight Checks
During development, the legacy preflight command provides a broad check and may rewrite formatting:
To ensure code quality and formatting consistency, run the preflight check:
```bash
npm run preflight
```
Before pushing a PR, run the read-only final local gate from a clean worktree:
```bash
npm run verify:pr
```
The final gate covers the deterministic PR CI checks that `preflight` omits. See [`docs/design/local-pr-verification.md`](./docs/design/local-pr-verification.md) for profiles, supported hosts, and remote-only boundaries.
This command will run ESLint, Prettier, all tests, and other checks as defined in the project's `package.json`.
_ProTip_

View file

@ -1,121 +0,0 @@
# Local PR verification
## Motivation
The pre-commit hook is intentionally fast: it formats and lints only staged
files. The legacy `npm run preflight` command is broader, but it runs Prettier
in write mode and omits several PR CI checks. Neither is the final local PR
gate.
Use three levels of feedback:
1. During development, run focused tests from the affected package.
2. Keep the existing pre-commit hook for fast staged-file feedback.
3. After committing and before the first PR push or an update push, run
`npm run verify:pr`.
There is no repository pre-push hook. The full gate includes network access
and the full workspace test suite, so it remains an explicit developer action.
## Interface and guards
```bash
npm run verify:pr
npm run verify:pr -- --base origin/release/<branch>
npm run verify:pr -- --profile auto
```
The default base is `origin/main`, and the default profile is `full`. Full is
intentionally conservative. `--profile auto` is an opt-in optimization that
reuses the CI changed-file classifier.
Before validation starts, the command requires Node 22.x on macOS x64/ARM64 or
Linux x64. It also requires a caller working tree with no staged, unstaged, or
untracked files, a resolvable base commit and merge base, and at least one
committed change between that merge base and `HEAD`.
For profiles that run commands, the gate creates an owned detached temporary
worktree at the exact SHA captured during caller inspection, disables checkout
hooks, runs validation there, and removes it afterward.
Commands that install, build, or generate files therefore do not change the
caller's tracked checkout. Developers do not need to create a separate worktree
themselves. A docs-only auto profile finishes after the same caller guards
without creating the temporary worktree.
On POSIX systems, the owned container is created below the canonical `/tmp`
path. This avoids both macOS `/var` aliases and excessively long per-user
temporary paths. Build outputs, dependency caches, browser binaries, homes, and
verifier-owned temporary files stay inside the container. Git temporarily
registers the detached worktree in repository metadata until cleanup.
Installation forces lifecycle scripts to run so the build and bundle cannot
be skipped by caller npm settings, while disabling Husky setup to avoid
changing the linked worktrees' shared hook configuration.
## Profiles
- `full` runs the complete deterministic local gate and is always selected
unless `auto` is requested.
- `docs_only` is selected by `auto` only when every changed path is classified
as documentation. It performs the caller guards and skips validation
commands.
- `github_ci_only` is selected by `auto` only when every changed path belongs
to the CI safety-helper set. It sets up linters, runs actionlint and yamllint,
and runs the associated Node helper tests.
Mixed or unrecognized changes select `full`.
## Full validation
The full profile runs these categories in fail-fast order:
- clean installation in the fresh detached checkout: `npm ci` invokes the
repository `prepare` script, which runs the build and bundle;
- critical runtime dependency audit, lockfile validation, and desktop
workspace isolation;
- ESLint, actionlint, shellcheck, yamllint, and a read-only Prettier check of
regular files changed by the PR that still exist at `HEAD`;
- the GitHub CI profile-classifier and safety-helper tests;
- i18n validation, read-only settings-schema freshness plus a check that the
build left the committed schema unchanged, type checking, and the serve
fast-path bundle-closure check;
- all workspace unit tests plus script tests, with workspaces and test files
scheduled serially to keep the repository-wide run stable under local load;
the test contents and coverage settings stay unchanged;
- no-AK integration tests, an isolated Chromium installation, and the web-shell
Playwright smoke test on a dynamically allocated localhost port.
Every validation subprocess receives an isolated temporary home and a small
allowlist of transport-related caller variables such as `PATH`, proxy settings,
and certificate paths. Caller credentials, npm/pip modes, Qwen state paths,
test overrides, and Playwright redirects are not inherited. Python and npm use
owned configuration and cache paths.
When the PR changes `packages/sdk-python/` or its CI workflow, the full profile
also requires `uv` and creates isolated Python 3.10, 3.11, and 3.12
environments. Each version installs the SDK development dependencies and runs
Ruff lint and format checks, Mypy, and Pytest.
The existing sensitive-keyword no-op is intentionally not part of this gate.
## Failures and remote boundaries
The gate stops at the first failed step. Output includes the step and a safely
escaped command before execution; validation failures also report the exact
`HEAD`, selected base and profile, and direct developers to rerun the gate.
It preserves a child's numeric exit code. `SIGINT` and `SIGTERM` received by
the verifier are relayed after cleanup; other child-only signals use the
conventional `128 + signal` exit status. The gate
always attempts to remove the temporary worktree on success or failure.
`SIGINT` and `SIGTERM` received while a validation command is running are
forwarded to its process group; a second signal force-terminates that group,
and the gate cleans up before relaying the original signal.
Cleanup failures are reported, including separately when a validation failure
already exists.
This gate does not replace remote CI. Unsupported hosts, including Windows and
Linux ARM64, fail before repository inspection. The gate cannot reproduce ECS
runner load or cache behavior, Windows and macOS merge-queue jobs, integrations
that use real secrets, GitHub permissions and artifact handling, review bots,
or every residual test flake. Remote checks remain authoritative for those
surfaces.

View file

@ -41,7 +41,7 @@ If you'd like to get early feedback on your work, please use GitHub's **Draft Pu
#### 4. Ensure All Checks Pass
Before submitting your PR, run `npm run verify:pr` from a clean Node 22 checkout. This is the final local PR gate; `npm run preflight` remains useful during development but is not equivalent to CI.
Before submitting your PR, ensure that all automated checks are passing by running `npm run preflight`. This command runs all tests, linting, and other style checks.
#### 5. Update Documentation
@ -126,7 +126,7 @@ To execute the unit test suite for the project:
npm run test
```
This will run tests located in the `packages/core` and `packages/cli` directories. Ensure focused tests pass while developing, then run `npm run verify:pr` before submitting the PR.
This will run tests located in the `packages/core` and `packages/cli` directories. Ensure tests pass before submitting any changes. For a more comprehensive check, it is recommended to run `npm run preflight`.
#### Integration Tests
@ -140,21 +140,15 @@ npm run test:e2e
For more detailed information on the integration testing framework, please see the [Integration Tests documentation](./development/integration-tests.md).
### Linting and PR Verification
### Linting and Preflight Checks
During development, the legacy preflight command provides a broad check and may rewrite formatting:
To ensure code quality and formatting consistency, run the preflight check:
```bash
npm run preflight
```
Before pushing a PR, run the read-only final local gate from a clean worktree:
```bash
npm run verify:pr
```
The final gate covers the deterministic PR CI checks that `preflight` omits. See [`local-pr-verification.md`](../design/local-pr-verification.md) for profiles, supported hosts, and remote-only boundaries.
This command will run ESLint, Prettier, all tests, and other checks as defined in the project's `package.json`.
_ProTip_

View file

@ -31,7 +31,6 @@
"debug": "cross-env DEBUG=1 node --inspect-brk scripts/start.js",
"generate": "node scripts/generate-git-commit-info.js",
"generate:settings-schema": "node --import tsx/esm scripts/generate-settings-schema.ts",
"verify:pr": "node scripts/verify-pr.js",
"build": "cross-env NODE_OPTIONS=\"--max-old-space-size=3072\" node scripts/build.js",
"build-and-start": "npm run build && npm run start",
"build:vscode": "node scripts/build_vscode_companion.js",

View file

@ -255,49 +255,16 @@ function generateJsonSchema(
return jsonSchema;
}
const schema = getSettingsSchema();
const jsonSchema = generateJsonSchema(schema as unknown as SettingsSchema);
const outputDir = path.resolve(
__dirname,
'../packages/vscode-ide-companion/schemas',
);
const outputPath = path.join(outputDir, 'settings.schema.json');
export function runGenerateSettingsSchema(
args: string[],
schemaPath = outputPath,
): number {
const checkMode = args.length === 1 && args[0] === '--check';
if (args.length > 0 && !checkMode) {
console.error(
`Unknown argument${args.length === 1 ? '' : 's'}: ${args.join(' ')}. Usage: npm run generate:settings-schema -- [--check]`,
);
return 1;
}
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(outputPath, JSON.stringify(jsonSchema, null, 2) + '\n');
const schema = getSettingsSchema();
const jsonSchema = generateJsonSchema(schema as unknown as SettingsSchema);
const serializedSchema = JSON.stringify(jsonSchema, null, 2) + '\n';
if (checkMode) {
if (
!fs.existsSync(schemaPath) ||
fs.readFileSync(schemaPath, 'utf8') !== serializedSchema
) {
console.error(
'Settings JSON Schema is stale. Run "npm run generate:settings-schema" and commit the updated schema.',
);
return 1;
}
console.log(`Settings JSON Schema is current: ${schemaPath}`);
return 0;
}
fs.mkdirSync(path.dirname(schemaPath), { recursive: true });
fs.writeFileSync(schemaPath, serializedSchema);
console.log(`Generated settings JSON Schema at: ${schemaPath}`);
return 0;
}
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
process.exitCode = runGenerateSettingsSchema(process.argv.slice(2));
}
console.log(`Generated settings JSON Schema at: ${outputPath}`);

View file

@ -10,7 +10,7 @@ import { execSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, join } from 'node:path';
import { join } from 'node:path';
const ACTIONLINT_VERSION = '1.7.12';
const SHELLCHECK_VERSION = '0.11.0';
@ -87,7 +87,7 @@ const platformArch = getPlatformArch();
*/
const LINTERS = {
actionlint: {
check: `test "$(actionlint -version 2>/dev/null)" = "${ACTIONLINT_VERSION}"`,
check: 'command -v actionlint',
installer: `
mkdir -p "${TEMP_DIR}/actionlint"
curl -sSLo "${TEMP_DIR}/.actionlint.tgz" "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.tar.gz"
@ -106,7 +106,7 @@ const LINTERS = {
`,
},
shellcheck: {
check: `test "$(shellcheck --version 2>/dev/null | awk '/^version:/ { print $2 }')" = "${SHELLCHECK_VERSION}"`,
check: 'command -v shellcheck',
installer: `
mkdir -p "${TEMP_DIR}/shellcheck"
curl -sSLo "${TEMP_DIR}/.shellcheck.txz" "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.${platformArch.shellcheck}.tar.xz"
@ -125,42 +125,23 @@ const LINTERS = {
`,
},
yamllint: {
check: `test "$(yamllint --version 2>/dev/null)" = "yamllint ${YAMLLINT_VERSION}"`,
installer: `python3 -m pip install --target "${TEMP_DIR}/yamllint" "yamllint==${YAMLLINT_VERSION}"`,
check: 'command -v yamllint',
installer: `pip3 install --user "yamllint==${YAMLLINT_VERSION}"`,
run: "git ls-files | grep -E '\\.(yaml|yml)' | xargs yamllint --format github",
},
};
export function createLinterEnvironment({
cwd = process.cwd(),
env = process.env,
tempDir = TEMP_DIR,
} = {}) {
const yamllintTarget = join(tempDir, 'yamllint');
return {
...env,
PIP_CONFIG_FILE: '/dev/null',
PIP_REQUIRE_VIRTUALENV: 'false',
PIP_USER: 'false',
PATH: [
join(cwd, 'node_modules', '.bin'),
join(tempDir, 'actionlint'),
join(tempDir, 'shellcheck'),
join(yamllintTarget, 'bin'),
env.PATH,
]
.filter(Boolean)
.join(delimiter),
PYTHONPATH: [yamllintTarget, env.PYTHONPATH]
.filter(Boolean)
.join(delimiter),
PYTHONNOUSERSITE: '1',
};
}
function runCommand(command, stdio = 'inherit') {
try {
execSync(command, { stdio, env: createLinterEnvironment() });
const env = { ...process.env };
const nodeBin = join(process.cwd(), 'node_modules', '.bin');
env.PATH = `${nodeBin}:${TEMP_DIR}/actionlint:${TEMP_DIR}/shellcheck:${env.PATH}`;
if (process.platform === 'darwin') {
env.PATH = `${env.PATH}:${process.env.HOME}/Library/Python/3.12/bin`;
} else if (process.platform === 'linux') {
env.PATH = `${env.PATH}:${process.env.HOME}/.local/bin`;
}
execSync(command, { stdio, env });
return true;
} catch (_e) {
return false;

View file

@ -1,109 +0,0 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import {
mkdtempSync,
existsSync,
mkdirSync,
readFileSync,
rmSync,
statSync,
utimesSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { runGenerateSettingsSchema } from '../generate-settings-schema.js';
vi.unmock('node:fs');
const tempDirs: string[] = [];
function makeSchemaPath(): string {
const root = mkdtempSync(path.join(tmpdir(), 'qwen-settings-schema-'));
tempDirs.push(root);
return path.join(root, 'schemas', 'settings.schema.json');
}
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe('runGenerateSettingsSchema', () => {
it('runs the real entry point with the default path and exit status', () => {
const script = path.resolve('scripts/generate-settings-schema.ts');
const current = spawnSync('npx', ['tsx', script, '--check'], {
encoding: 'utf8',
});
const invalid = spawnSync('npx', ['tsx', script, '--unknown'], {
encoding: 'utf8',
});
expect(current.status, current.stderr).toBe(0);
expect(current.stdout).toContain('Settings JSON Schema is current');
expect(invalid.status).toBe(1);
expect(invalid.stderr).toContain('Unknown argument: --unknown');
});
it('returns success without rewriting a current schema', () => {
const schemaPath = makeSchemaPath();
expect(runGenerateSettingsSchema([], schemaPath)).toBe(0);
const content = readFileSync(schemaPath, 'utf8');
utimesSync(schemaPath, new Date(1_000_000_000), new Date(1_000_000_000));
const mtimeMs = statSync(schemaPath).mtimeMs;
expect(runGenerateSettingsSchema(['--check'], schemaPath)).toBe(0);
expect(readFileSync(schemaPath, 'utf8')).toBe(content);
expect(statSync(schemaPath).mtimeMs).toBe(mtimeMs);
});
it('reports a stale schema without rewriting it', () => {
const schemaPath = makeSchemaPath();
mkdirSync(path.dirname(schemaPath), { recursive: true });
writeFileSync(schemaPath, 'stale schema\n');
utimesSync(schemaPath, new Date(1_000_000_000), new Date(1_000_000_000));
const mtimeMs = statSync(schemaPath).mtimeMs;
expect(runGenerateSettingsSchema(['--check'], schemaPath)).toBe(1);
expect(readFileSync(schemaPath, 'utf8')).toBe('stale schema\n');
expect(statSync(schemaPath).mtimeMs).toBe(mtimeMs);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('npm run generate:settings-schema'),
);
});
it('reports a missing schema without creating it', () => {
const schemaPath = makeSchemaPath();
expect(runGenerateSettingsSchema(['--check'], schemaPath)).toBe(1);
expect(existsSync(schemaPath)).toBe(false);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('npm run generate:settings-schema'),
);
});
it('rejects unknown arguments without writing a schema', () => {
const schemaPath = makeSchemaPath();
expect(runGenerateSettingsSchema(['--unknown'], schemaPath)).toBe(1);
expect(existsSync(schemaPath)).toBe(false);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('Unknown argument: --unknown'),
);
});
});

View file

@ -5,21 +5,9 @@
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { spawnSync } from 'node:child_process';
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
describe('getLinterTempDir', () => {
const originalArgv = process.argv;
const tempDirs = [];
beforeEach(() => {
process.argv = ['node', 'scripts/lint.js', '--test-import'];
@ -27,9 +15,6 @@ describe('getLinterTempDir', () => {
afterEach(() => {
process.argv = originalArgv;
for (const directory of tempDirs.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
it('isolates GitHub Actions linter installs by run and job', async () => {
@ -79,91 +64,4 @@ describe('getLinterTempDir', () => {
expect(second).toMatch(/\/qwen-code-linters\/local-[a-f0-9]{16}$/);
expect(first).not.toBe(second);
});
it('uses the owned yamllint target without a version-specific user path', async () => {
const { createLinterEnvironment } = await import('../lint.js');
const environment = createLinterEnvironment({
cwd: '/workspace',
env: {
HOME: '/caller/home',
PATH: '/usr/bin',
PIP_CONFIG_FILE: '/caller/pip.conf',
PIP_REQUIRE_VIRTUALENV: 'true',
PIP_USER: 'true',
PYTHONPATH: '/caller/python',
},
tempDir: '/owned/linters',
});
expect(environment.PATH.split(path.delimiter)).toEqual([
'/workspace/node_modules/.bin',
'/owned/linters/actionlint',
'/owned/linters/shellcheck',
'/owned/linters/yamllint/bin',
'/usr/bin',
]);
expect(environment.PYTHONPATH).toBe(
['/owned/linters/yamllint', '/caller/python'].join(path.delimiter),
);
expect(environment).toMatchObject({
PIP_CONFIG_FILE: '/dev/null',
PIP_REQUIRE_VIRTUALENV: 'false',
PIP_USER: 'false',
PYTHONNOUSERSITE: '1',
});
expect(Object.values(environment).join(path.delimiter)).not.toContain(
'Python/3.12',
);
});
it.skipIf(process.platform === 'win32')(
'recognizes the pinned linter version output',
() => {
const root = mkdtempSync(path.join(tmpdir(), 'lint-version-test-'));
tempDirs.push(root);
const bin = path.join(root, 'bin');
mkdirSync(bin);
const log = path.join(root, 'versions.log');
const executables = {
actionlint:
'#!/bin/sh\necho actionlint >> "$LINTER_VERSION_LOG"\necho 1.7.12\n',
curl: '#!/bin/sh\nexit 99\n',
python3: '#!/bin/sh\nexit 99\n',
shellcheck:
'#!/bin/sh\necho shellcheck >> "$LINTER_VERSION_LOG"\necho "ShellCheck - shell script analysis tool"\necho "version: 0.11.0"\n',
tar: '#!/bin/sh\nexit 99\n',
yamllint:
'#!/bin/sh\necho yamllint >> "$LINTER_VERSION_LOG"\necho "yamllint 1.35.1"\n',
};
for (const [name, contents] of Object.entries(executables)) {
const executable = path.join(bin, name);
writeFileSync(executable, contents);
chmodSync(executable, 0o755);
}
const result = spawnSync(
process.execPath,
['scripts/lint.js', '--setup'],
{
cwd: path.resolve('.'),
encoding: 'utf8',
env: {
...process.env,
LINTER_VERSION_LOG: log,
PATH: [bin, process.env.PATH].filter(Boolean).join(path.delimiter),
RUNNER_TEMP: path.join(root, 'runner'),
},
},
);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).not.toContain('Installing ');
expect(readFileSync(log, 'utf8').trim().split('\n')).toEqual([
'actionlint',
'shellcheck',
'yamllint',
]);
},
);
});

View file

@ -57,12 +57,6 @@ function getWorkflowStep(job, stepName) {
}
describe('package scripts', () => {
it('exposes the local PR verification runner', () => {
expect(readPackageJson().scripts['verify:pr']).toBe(
'node scripts/verify-pr.js',
);
});
it('keeps the serve fast-path bundle check outside unit test scripts', () => {
const packageJson = readPackageJson();

File diff suppressed because it is too large Load diff

View file

@ -1,982 +0,0 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn, spawnSync } from 'node:child_process';
import {
lstatSync,
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
} from 'node:fs';
import { createServer } from 'node:net';
import { constants as osConstants, tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { classifyChangedFiles } from '../.github/scripts/ci/classify-profile.mjs';
const SETTINGS_SCHEMA_PATH =
'packages/vscode-ide-companion/schemas/settings.schema.json';
const SUPPORTED_HOSTS = new Set(['darwin/arm64', 'darwin/x64', 'linux/x64']);
const PASSTHROUGH_ENV_KEYS = new Set([
'ALL_PROXY',
'HTTP_PROXY',
'HTTPS_PROXY',
'NODE_EXTRA_CA_CERTS',
'NO_PROXY',
'PATH',
'QWEN_CI_REAL_GIT',
'RUNNER_TEMP',
'SSL_CERT_DIR',
'SSL_CERT_FILE',
'TEMP',
'TMP',
'TMPDIR',
'all_proxy',
'http_proxy',
'https_proxy',
'no_proxy',
]);
export function parseArgs(argv) {
const options = {
base: 'origin/main',
help: false,
profile: 'full',
};
for (let index = 0; index < argv.length; index += 1) {
const option = argv[index];
if (option === '--help') {
options.help = true;
continue;
}
if (option !== '--base' && option !== '--profile') {
throw new Error(`Unknown option or positional argument: ${option}`);
}
const value = argv[++index];
if (!value || value.startsWith('--')) {
throw new Error(`Missing value for ${option}`);
}
if (option === '--base') options.base = value;
if (option === '--profile') {
if (value !== 'full' && value !== 'auto') {
throw new Error(`Invalid profile: ${value}`);
}
options.profile = value;
}
}
return options;
}
export function assertNode22(version) {
if (Number.parseInt(version, 10) !== 22) {
throw new Error(`Node 22 is required; found ${version}`);
}
}
export function assertSupportedHost(
platform = process.platform,
arch = process.arch,
) {
if (!SUPPORTED_HOSTS.has(`${platform}/${arch}`)) {
throw new Error(
`npm run verify:pr supports macOS x64/ARM64 and Linux x64; found ${platform}/${arch}. Use GitHub CI for unsupported hosts.`,
);
}
}
function createBaseEnvironment(baseEnv) {
const env = {};
for (const [key, value] of Object.entries(baseEnv)) {
if (PASSTHROUGH_ENV_KEYS.has(key) && value !== undefined) env[key] = value;
}
return { ...env, LANG: 'C', LC_ALL: 'C', TZ: 'UTC' };
}
export function createGitEnvironment(baseEnv, { home, hooksPath } = {}) {
const env = createBaseEnvironment(baseEnv);
Object.assign(env, {
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_SYSTEM: '/dev/null',
});
if (home) {
env.HOME = home;
env.USERPROFILE = home;
}
if (hooksPath) {
Object.assign(env, {
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'core.hooksPath',
GIT_CONFIG_VALUE_0: hooksPath,
});
}
return env;
}
function runGit(cwd, args, baseEnv) {
const result = spawnSync('git', args, {
cwd,
encoding: 'utf8',
env: createGitEnvironment(baseEnv),
});
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(
result.stderr.trim() || `git ${args.join(' ')} exited with failure`,
);
}
return result.stdout;
}
export function inspectRepository({ base, cwd, env = process.env }) {
const status = runGit(
cwd,
['status', '--porcelain=v1', '-z', '--untracked-files=all'],
env,
);
if (status) {
throw new Error(
'The caller working tree must have no staged, unstaged, or untracked changes.',
);
}
const head = runGit(cwd, ['rev-parse', 'HEAD'], env).trim();
let baseSha;
try {
baseSha = runGit(
cwd,
['rev-parse', '--verify', '--end-of-options', `${base}^{commit}`],
env,
).trim();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Unable to resolve base ref ${base}: ${message}`);
}
const mergeBase = runGit(cwd, ['merge-base', baseSha, head], env).trim();
const changedFiles = runGit(
cwd,
['diff', '--name-only', '--no-renames', '-z', mergeBase, head, '--'],
env,
)
.split('\0')
.filter(Boolean);
if (changedFiles.length === 0) {
throw new Error(`No committed changes found against ${base}.`);
}
return { baseSha, changedFiles, head, mergeBase };
}
export function selectProfile({
changedFiles,
classify = classifyChangedFiles,
requestedProfile,
}) {
return requestedProfile === 'auto' ? classify(changedFiles) : 'full';
}
function step(name, ...command) {
return { command, name };
}
const GITHUB_HELPER_TEST = [
'Run GitHub CI helper tests',
'node',
'--test',
'.github/scripts/pr-safety-precheck.test.mjs',
'.github/scripts/ci/classify-profile.test.mjs',
'.github/scripts/resolve-sandbox-image.test.mjs',
];
function isRegularWorktreeFile(worktree, file) {
let currentPath = worktree;
let stats;
const segments = file.split('/');
for (const [index, segment] of segments.entries()) {
currentPath = join(currentPath, segment);
stats = lstatSync(currentPath, { throwIfNoEntry: false });
if (!stats || stats.isSymbolicLink()) return false;
if (index < segments.length - 1 && !stats.isDirectory()) return false;
}
return stats?.isFile() === true;
}
export function createValidationSteps({ prettierFiles = [], profile }) {
if (profile === 'docs_only') return [];
const commands =
profile === 'github_ci_only'
? [
['Set up linters', 'node', 'scripts/lint.js', '--setup'],
['Run actionlint', 'node', 'scripts/lint.js', '--actionlint'],
['Run yamllint', 'node', 'scripts/lint.js', '--yamllint'],
GITHUB_HELPER_TEST,
]
: [
[
'Install dependencies',
'npm',
'ci',
'--prefer-offline',
'--no-audit',
'--progress=false',
'--ignore-scripts=false',
],
[
'Audit critical runtime dependencies',
'npm',
'run',
'audit:runtime:critical',
],
['Check lockfile', 'npm', 'run', 'check:lockfile'],
[
'Check desktop workspace isolation',
'npm',
'run',
'check:desktop-isolation',
],
['Set up linters', 'node', 'scripts/lint.js', '--setup'],
['Run ESLint', 'node', 'scripts/lint.js', '--eslint'],
['Run actionlint', 'node', 'scripts/lint.js', '--actionlint'],
['Run shellcheck', 'node', 'scripts/lint.js', '--shellcheck'],
['Run yamllint', 'node', 'scripts/lint.js', '--yamllint'],
GITHUB_HELPER_TEST,
...(prettierFiles.length > 0
? [
[
'Run Prettier',
'npx',
'prettier',
'--experimental-cli',
'--check',
'--ignore-unknown',
'--',
...prettierFiles,
],
]
: []),
['Run i18n check', 'npm', 'run', 'check-i18n'],
[
'Check settings schema',
'npm',
'run',
'generate:settings-schema',
'--',
'--check',
],
[
'Ensure settings schema is committed',
'git',
'cat-file',
'-e',
`HEAD:${SETTINGS_SCHEMA_PATH}`,
],
[
'Check committed settings schema',
'git',
'diff',
'--exit-code',
'HEAD',
'--',
SETTINGS_SCHEMA_PATH,
],
['Run typecheck', 'npm', 'run', 'typecheck'],
[
'Check serve fast-path bundle closure',
'npm',
'run',
'check:serve-fast-path-bundle',
],
[
'Run unit tests',
'npx',
'cross-env',
'NODE_OPTIONS=--max-old-space-size=3072',
'npm',
'run',
'test:ci',
'--workspaces',
'--if-present',
'--',
'--no-file-parallelism',
],
[
'Run script tests',
'npm',
'run',
'test:scripts',
'--',
'--no-file-parallelism',
],
[
'Run no-AK integration tests',
'npm',
'run',
'test:integration:no-ak:sandbox:none',
],
[
'Install Playwright Chromium',
'npx',
'playwright',
'install',
'chromium',
],
[
'Run web shell smoke tests',
'npm',
'run',
'test:e2e:smoke',
'--workspace=packages/web-shell',
],
];
const steps = commands.map(([name, ...command]) => step(name, ...command));
if (profile === 'full') {
steps.find(
({ name }) => name === 'Install dependencies',
).installEnvironment = true;
for (const name of [
'Install Playwright Chromium',
'Run web shell smoke tests',
]) {
steps.find((candidate) => candidate.name === name).playwrightEnvironment =
true;
}
steps.find(({ name }) => name === 'Run web shell smoke tests').playwright =
true;
}
return steps;
}
export function needsPythonChecks(changedFiles) {
return changedFiles.some((file) => {
const normalized = file.replace(/\\/g, '/');
return (
normalized.startsWith('packages/sdk-python/') ||
normalized === '.github/workflows/sdk-python.yml'
);
});
}
export function getVenvPythonPath(venv, platform = process.platform) {
return platform === 'win32'
? join(venv, 'Scripts', 'python.exe')
: join(venv, 'bin', 'python');
}
export function createPythonSteps({ platform = process.platform, pythonRoot }) {
const steps = [
{
...step('Require uv', 'uv', '--version'),
uvRequirement: true,
},
];
for (const version of ['3.10', '3.11', '3.12']) {
const venv = join(pythonRoot, version);
const python = getVenvPythonPath(venv, platform);
const commands = [
['Create virtualenv', 'uv', 'venv', '--python', version, '--seed', venv],
['Upgrade pip', python, '-m', 'pip', 'install', '--upgrade', 'pip'],
[
'Install SDK test dependencies',
python,
'-m',
'pip',
'install',
'-e',
'packages/sdk-python[dev]',
],
[
'Run Ruff',
python,
'-m',
'ruff',
'check',
'--config',
'packages/sdk-python/pyproject.toml',
'packages/sdk-python',
],
[
'Run Ruff format',
python,
'-m',
'ruff',
'format',
'--check',
'--config',
'packages/sdk-python/pyproject.toml',
'packages/sdk-python',
],
[
'Run Mypy',
python,
'-m',
'mypy',
'--config-file',
'packages/sdk-python/pyproject.toml',
'packages/sdk-python/src',
],
[
'Run Pytest',
python,
'-m',
'pytest',
'-c',
'packages/sdk-python/pyproject.toml',
'packages/sdk-python/tests',
'-q',
],
];
steps.push(
...commands.map(([name, ...command]) =>
step(`${name} (Python ${version})`, ...command),
),
);
}
for (const pythonStep of steps) {
pythonStep.pythonEnvironment = true;
}
return steps;
}
export function createStepEnvironment({
baseEnv,
home,
playwrightPort,
step: currentStep,
}) {
const env = createBaseEnvironment(baseEnv);
Object.assign(env, {
CI: 'true',
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_SYSTEM: '/dev/null',
HOME: home,
NPM_CONFIG_CACHE: join(home, '.npm'),
NPM_CONFIG_GLOBAL: 'false',
NPM_CONFIG_GLOBALCONFIG: '/dev/null',
NPM_CONFIG_PREFIX: join(home, '.npm-prefix'),
NPM_CONFIG_UPDATE_NOTIFIER: 'false',
NPM_CONFIG_USERCONFIG: join(home, '.npmrc'),
NO_COLOR: 'true',
USERPROFILE: home,
});
if (currentStep.installEnvironment) {
Object.assign(env, {
HUSKY: '0',
NPM_CONFIG_FETCH_RETRIES: '5',
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: '120000',
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: '20000',
NPM_CONFIG_FETCH_TIMEOUT: '300000',
NPM_CONFIG_IGNORE_SCRIPTS: 'false',
});
}
if (currentStep.pythonEnvironment) {
Object.assign(env, {
PIP_CONFIG_FILE: '/dev/null',
PIP_DISABLE_PIP_VERSION_CHECK: '1',
PIP_NO_INPUT: '1',
PIP_REQUIRE_VIRTUALENV: 'false',
PIP_USER: 'false',
PYTHONNOUSERSITE: '1',
PYTHONUTF8: '1',
UV_NO_CONFIG: '1',
});
}
if (currentStep.playwrightEnvironment) {
env.PLAYWRIGHT_BROWSERS_PATH = join(home, 'playwright');
}
if (currentStep.playwright) env.PLAYWRIGHT_PORT = String(playwrightPort);
return env;
}
function quoteArgument(argument) {
const display = argument.replace(
/[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/gu,
(character) => `\\u{${character.codePointAt(0).toString(16)}}`,
);
return /^[A-Za-z0-9_./:=@%+,-]+$/.test(display)
? display
: `'${display.replaceAll("'", `'"'"'`)}'`;
}
function formatCommand(command) {
return command.map(quoteArgument).join(' ');
}
function signalChild(child, signal) {
try {
if (process.platform !== 'win32' && child.pid) {
process.kill(-child.pid, signal);
} else {
child.kill(signal);
}
} catch (error) {
if (error?.code !== 'ESRCH') throw error;
}
}
export function executeChild({ command, cwd, env }) {
return new Promise((resolveResult) => {
const child = spawn(command[0], command.slice(1), {
cwd,
detached: process.platform !== 'win32',
env,
stdio: 'inherit',
});
let forwardedSignal;
let settled = false;
const signalHandlers = new Map();
const finish = (result) => {
if (settled) return;
settled = true;
for (const [signal, handler] of signalHandlers) {
process.off(signal, handler);
}
resolveResult(result);
};
for (const signal of ['SIGINT', 'SIGTERM']) {
const handler = () => {
if (forwardedSignal) {
signalChild(child, 'SIGKILL');
return;
}
forwardedSignal = signal;
signalChild(child, signal);
};
signalHandlers.set(signal, handler);
process.on(signal, handler);
}
child.once('error', (error) => {
finish({
error,
relaySignal: forwardedSignal,
status: null,
});
});
child.once('exit', (status, signal) => {
finish({
relaySignal: forwardedSignal,
signal,
status,
});
});
});
}
class ValidationFailure extends Error {
constructor({ command, detail, exitCode, relaySignal, signal, stage }) {
super(`${stage}: ${detail}`);
this.command = command;
this.detail = detail;
this.exitCode = exitCode;
this.relaySignal = relaySignal;
this.signal = signal;
this.stage = stage;
}
}
function commandFailure(stage, command, result) {
if (
!result.error &&
!result.relaySignal &&
!result.signal &&
result.status === 0
) {
return undefined;
}
const failureSignal = result.signal ?? result.relaySignal;
const signalNumber = failureSignal
? osConstants.signals[failureSignal]
: undefined;
const exitCode =
typeof result.status === 'number' && result.status !== 0
? result.status
: typeof signalNumber === 'number'
? 128 + signalNumber
: 1;
const detail = result.error
? `spawn error: ${result.error.message}`
: result.signal
? `terminated by signal ${result.signal}`
: result.relaySignal
? `interrupted after forwarding signal ${result.relaySignal}`
: `exited with status ${exitCode}`;
return new ValidationFailure({
command,
detail,
exitCode,
relaySignal: result.relaySignal,
signal: result.signal ?? undefined,
stage,
});
}
export async function runSteps({
allocatePort,
baseEnv,
cwd,
execute = executeChild,
home,
log = console.log,
now = Date.now,
steps,
}) {
for (const [index, currentStep] of steps.entries()) {
log(`[${index + 1}/${steps.length}] ${currentStep.name}`);
log(`Command: ${formatCommand(currentStep.command)}`);
const startedAt = now();
let playwrightPort;
if (currentStep.playwright) {
try {
playwrightPort = await allocatePort();
} catch (error) {
throw new ValidationFailure({
command: currentStep.command,
detail: `unable to allocate Playwright port: ${error instanceof Error ? error.message : String(error)}`,
exitCode: 1,
stage: currentStep.name,
});
}
}
const result = await execute({
command: currentStep.command,
cwd,
env: createStepEnvironment({
baseEnv,
home,
playwrightPort,
step: currentStep,
}),
});
log(`Elapsed: ${((now() - startedAt) / 1000).toFixed(1)}s`);
const failure = commandFailure(
currentStep.name,
currentStep.command,
result,
);
if (!failure) continue;
if (currentStep.uvRequirement) {
failure.detail = `uv is required for Python SDK verification. Install uv and ensure it is on PATH. ${failure.detail}`;
failure.message = `${failure.stage}: ${failure.detail}`;
}
throw failure;
}
}
function runWorktreeGit({ args, cwd, env }) {
return spawnSync('git', args, { cwd, env, stdio: 'inherit' });
}
export async function withTemporaryWorktree({
baseEnv = process.env,
cwd,
gitCommand = runWorktreeGit,
head,
makeContainer = () =>
mkdtempSync(
join(
realpathSync(process.platform === 'win32' ? tmpdir() : '/tmp'),
'qwen-verify-pr-',
),
),
removeContainer = (container) =>
rmSync(container, { recursive: true, force: true }),
reportCleanup = console.error,
validate,
}) {
if (!head) throw new Error('Temporary worktree requires an inspected HEAD.');
const createdContainer = makeContainer();
let container = createdContainer;
let gitBaseEnv;
let paths;
let added = false;
const cleanupFailures = [];
let primaryError;
let validationResult;
try {
container = realpathSync(createdContainer);
paths = {
container,
home: join(container, 'home'),
hooks: join(container, 'hooks'),
pythonRoot: join(container, 'python'),
temp: join(container, 'tmp'),
worktree: join(container, 'worktree'),
};
for (const directory of [
paths.home,
paths.hooks,
paths.pythonRoot,
paths.temp,
]) {
mkdirSync(directory, { recursive: true });
}
gitBaseEnv = {
...baseEnv,
RUNNER_TEMP: container,
TEMP: paths.temp,
TMP: paths.temp,
TMPDIR: paths.temp,
};
const gitEnv = createGitEnvironment(gitBaseEnv, {
home: paths.home,
hooksPath: paths.hooks,
});
const addCommand = ['worktree', 'add', '--detach', paths.worktree, head];
const addFailure = commandFailure(
'Create temporary worktree',
['git', ...addCommand],
gitCommand({ args: addCommand, cwd, env: gitEnv }),
);
if (addFailure) throw addFailure;
added = true;
validationResult = await validate(paths);
} catch (error) {
primaryError = error;
} finally {
if (added && paths) {
const removeCommand = [
'worktree',
'remove',
'--force',
'--force',
paths.worktree,
];
const cleanupFailure = commandFailure(
'Clean up temporary worktree',
['git', ...removeCommand],
gitCommand({
args: removeCommand,
cwd,
env: createGitEnvironment(gitBaseEnv, {
home: paths.home,
hooksPath: paths.hooks,
}),
}),
);
if (cleanupFailure) cleanupFailures.push(cleanupFailure);
}
try {
removeContainer(container);
} catch (error) {
cleanupFailures.push(
new ValidationFailure({
detail: error instanceof Error ? error.message : String(error),
exitCode: 1,
stage: 'Clean up temporary container',
}),
);
}
}
if (primaryError) {
for (const failure of cleanupFailures) {
reportCleanup(`Cleanup also failed: ${failure.message}`);
}
throw primaryError;
}
if (cleanupFailures.length > 0) {
for (const failure of cleanupFailures.slice(1)) {
reportCleanup(`Cleanup also failed: ${failure.message}`);
}
throw cleanupFailures[0];
}
return validationResult;
}
function allocateFreePort() {
return new Promise((resolve, reject) => {
const server = createServer();
server.unref();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
server.close((error) => {
if (error) reject(error);
else if (typeof address === 'object' && address) resolve(address.port);
else reject(new Error('Unable to allocate a localhost port.'));
});
});
});
}
export async function verifyPullRequest(
{ base, cwd, requestedProfile },
{
allocatePort = allocateFreePort,
arch = process.arch,
baseEnv = process.env,
error = console.error,
inspect = inspectRepository,
log = console.log,
nodeVersion = process.versions.node,
now = Date.now,
platform = process.platform,
runValidationSteps = runSteps,
temporaryWorktree = withTemporaryWorktree,
} = {},
) {
const startedAt = now();
assertNode22(nodeVersion);
assertSupportedHost(platform, arch);
const repository = inspect({ base, cwd, env: baseEnv });
const profile = selectProfile({
changedFiles: repository.changedFiles,
requestedProfile,
});
log(`Base: ${base}`);
log(`HEAD: ${repository.head}`);
log(`Changed files: ${repository.changedFiles.length}`);
log(`Profile: ${profile}`);
if (profile === 'docs_only') {
log('Docs-only change; full CI skipped.');
log(`Total elapsed: ${((now() - startedAt) / 1000).toFixed(1)}s`);
return { ...repository, profile };
}
try {
await temporaryWorktree({
baseEnv,
cwd,
head: repository.head,
reportCleanup: error,
validate: async ({ container, home, pythonRoot, temp, worktree }) => {
const prettierFiles =
profile === 'full'
? repository.changedFiles.filter((file) =>
isRegularWorktreeFile(worktree, file),
)
: [];
const steps = createValidationSteps({ prettierFiles, profile });
if (profile === 'full' && needsPythonChecks(repository.changedFiles)) {
steps.push(...createPythonSteps({ pythonRoot }));
}
await runValidationSteps({
allocatePort,
baseEnv: {
...baseEnv,
RUNNER_TEMP: container,
TEMP: temp,
TMP: temp,
TMPDIR: temp,
},
cwd: worktree,
home,
log,
now,
steps,
});
},
});
} catch (failure) {
if (failure && typeof failure === 'object') {
failure.base = base;
failure.head = repository.head;
failure.profile = profile;
}
throw failure;
}
log(`Total elapsed: ${((now() - startedAt) / 1000).toFixed(1)}s`);
return { ...repository, profile };
}
const USAGE = `Usage: npm run verify:pr -- [options]
Options:
--base <ref> Base ref (default: origin/main)
--profile <full|auto> Validation profile (default: full)
--help Show this help`;
export async function runCli(
argv,
{
cwd = process.cwd(),
error = console.error,
log = console.log,
relaySignal = (signal) => process.kill(process.pid, signal),
verify = verifyPullRequest,
} = {},
) {
let options;
try {
options = parseArgs(argv);
} catch (parseError) {
error(
parseError instanceof Error ? parseError.message : String(parseError),
);
error(USAGE);
return 1;
}
if (options.help) {
log(USAGE);
return 0;
}
try {
await verify({
base: options.base,
cwd,
requestedProfile: options.profile,
});
return 0;
} catch (failure) {
const context = failure && typeof failure === 'object' ? failure : {};
const command = Array.isArray(context.command)
? formatCommand(context.command)
: 'not started';
error('PR verification failed.');
error(`Stage: ${context.stage ?? 'Caller guards'}`);
error(`Command: ${command}`);
error(`HEAD: ${context.head ?? 'unresolved'}`);
error(`Base: ${context.base ?? options.base}`);
error(`Profile: ${context.profile ?? options.profile}`);
error(`Error: ${context.detail ?? context.message ?? String(failure)}`);
error(
`Rerun: npm run verify:pr -- --base ${quoteArgument(options.base)} --profile ${options.profile}`,
);
if (typeof context.relaySignal === 'string') {
relaySignal(context.relaySignal);
const signalNumber = osConstants.signals[context.relaySignal];
return typeof signalNumber === 'number' ? 128 + signalNumber : 1;
}
return Number.isInteger(context.exitCode) && context.exitCode > 0
? context.exitCode
: 1;
}
}
if (
process.argv[1] &&
resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))
) {
process.exitCode = await runCli(process.argv.slice(2));
}