qwen-code/scripts/check-lockfile.js
易良 8a21551599
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Lint & Static (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / web-shell E2E Smoke (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Integration Tests (no-AK, No Sandbox) (push) Blocked by required conditions
Qwen Code CI / Integration Tests (CLI, No Sandbox) (push) Blocked by required conditions
Qwen Code CI / Desktop Shell (ubuntu-22.04) (push) Blocked by required conditions
Qwen Code CI / Desktop Shell (windows-2022) (push) Blocked by required conditions
E2E Tests / Build for E2E (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/1 (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/1 (push) Blocked by required conditions
E2E Tests / E2E Test - macOS - shard 1/2 (push) Blocked by required conditions
E2E Tests / E2E Test - macOS - shard 2/2 (push) Blocked by required conditions
E2E Tests / E2E Interactive - OpenTUI renderer (bun) (push) Blocked by required conditions
E2E Tests / channel-plugin E2E (nightly) (push) Blocked by required conditions
E2E Tests / cron-interactive E2E (nightly) (push) Blocked by required conditions
E2E Tests / web-shell Browser Regression (push) Waiting to run
npm cache producer / Save npm cache (push) Waiting to run
pnpm Worktree Smoke / Install (macos-latest) (push) Waiting to run
pnpm Worktree Smoke / Install (ubuntu-latest) (push) Waiting to run
pnpm Worktree Smoke / Install (windows-latest) (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
chore: enforce Playwright version parity and close the deferred #11101 follow-ups (#11371)
* chore: enforce Playwright parity and close deferred #11101 follow-ups

Works the four items tracked in #11336:

- Pin Web Shell's @playwright/test to the same exact version as the root
  playwright dependency, and fail `npm run check:lockfile` when the two
  direct manifests drift apart or their resolved root trees stop matching
  the pin. The check deliberately tolerates the packages/mobile-mcp tree:
  mobilewright depends on playwright and playwright-core at an exact
  older revision, so no manifest edit in this repository can dedupe it.
- Correct the "Install evidence browser" rationale in qwen-triage.yml and
  its restatement in the workflow test. The two-trees premise no longer
  holds now that both manifests are pinned; the resolution behavior is
  unchanged, since resolving from the harness's own directory still keeps
  the binary tied to the package it imports.
- Cover the ENOENT fallback of the exported isMainModule helper. The
  existing runner.py assertion takes the realpath success path because
  that file exists, so the fallback stayed unpinned.
- Separate the CLI-only from the daemon-only followup suggestion gates in
  the documentation instead of pointing daemon clients at the blended
  CLI-oriented list, keeping the headless/SDK opt-out guidance.

* chore: pin the Playwright parity check and correct its scope notes

Follow-up to the round-1 review on this PR.

The parity block had no test in either direction: the only suite that runs the
script asserted the two pre-existing banners, so deleting the block, inverting a
comparison, or breaking the importer derivation left both `npm run test:scripts`
and CI's `check:lockfile` gate green. That is the defect class this PR exists to
remove — the same diff rewrites two rationale comments to lean on the check, so
an unpinned guard rots into a comment that lies. Add the third banner to the
existing suite and a fixture suite that runs the script against perturbed copies
of the real manifests and lockfiles, covering both drift arms and the two shapes
that must stay green.

Also correct three properties of the block the review found:

- The inventory comment claimed two Playwright declarations sit outside the
  invariant; `packages/mobile-mcp` declares `@playwright/test` as a range
  directly, in a workspace member, and was not named. It is the only in-workspace
  range on a name this check pins, so it is what the resolved-version message
  should point an operator at.
- The agreement check destructured exactly two entries while the comment invited
  maintainers to extend the list, so a third entry would have received the
  exactness check and silently skipped the comparison the block exists for.
  Compare every entry against the first and derive the resolved-tree locations
  from the same list instead of repeating them as a second literal.
- The pnpm arm prescribed "regenerate it" for every divergence, but
  `pnpm-workspace.yaml`'s `overrides:` and `.pnpmfile.mjs` both rewrite the
  recorded specifier, so regeneration is a no-op for those. Name the deciding
  layer in the messages and the importer list's source for a missing entry.

* docs: stop counting the followup suggestion code paths

Merging main brought in the OpenTUI port of ink's AppContainer effect
(packages/cli/src/ui/opentui/followup-generation.ts), which is a third
non-test `generatePromptSuggestion` call site. The section opener claimed
"Two code paths decide this", which a grep now contradicts even though the
substance still holds: the OpenTUI hook applies the same CLI gate set as
AppContainer — interactive, not SDK mode, the Responding to Idle edge, no
error in the last item, no waiting calls, approval mode not plan — and
collapses ink's five explicit dialog checks into `waitingCalls.length === 0`
because the composer is unmounted while a shell dialog is open.

Name the two decision-makers instead of counting implementations, so the
sentence stays true when a renderer is added or retired. The CLI/daemon
split below is unchanged and was re-verified against the merged tree: the
ink gate is byte-identical at AppContainer.tsx:3612-3627, the daemon gate
moved only by line number (Session.ts:5080-5095), and the shared
MIN_ASSISTANT_TURNS gate is still at suggestionGenerator.ts:83,103.

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
2026-09-12 11:40:46 +00:00

251 lines
10 KiB
JavaScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse as parseYaml } from 'yaml';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const lockfilePath = join(root, 'package-lock.json');
function readJsonFile(filePath) {
try {
const fileContent = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(fileContent);
} catch (error) {
console.error(`Error reading or parsing ${filePath}:`, error);
return null;
}
}
console.log('Checking lockfile...');
const lockfile = readJsonFile(lockfilePath);
if (lockfile === null) {
process.exit(1);
}
const packages = lockfile.packages || {};
const invalidPackages = [];
for (const [location, details] of Object.entries(packages)) {
// 1. Skip the root package itself.
if (location === '') {
continue;
}
// 2. Skip local workspace packages.
// They are identifiable in two ways:
// a) As a symlink within node_modules.
// b) As the source package definition, whose path is not in node_modules.
if (details.link === true || !location.includes('node_modules')) {
continue;
}
// 3. Any remaining package should be a third-party dependency.
// 1) Registry package with both "resolved" and "integrity" fields is valid.
if (details.resolved && details.integrity) {
continue;
}
// 2) Git and file dependencies only need a "resolved" field.
const isGitOrFileDep =
details.resolved?.startsWith('git') ||
details.resolved?.startsWith('file:');
if (isGitOrFileDep) {
continue;
}
// Mark the left dependency as invalid.
invalidPackages.push(location);
}
if (invalidPackages.length > 0) {
console.error(
'\nError: The following dependencies in package-lock.json are missing the "resolved" or "integrity" field:',
);
invalidPackages.forEach((pkg) => console.error(`- ${pkg}`));
process.exitCode = 1;
} else {
console.log('Lockfile check passed.');
process.exitCode = 0;
}
console.log('Checking pnpm lockfile...');
const pnpmLockfilePath = join(root, 'pnpm-lock.yaml');
let pnpmLockfile;
try {
pnpmLockfile = parseYaml(fs.readFileSync(pnpmLockfilePath, 'utf-8'));
} catch (error) {
console.error(`Error reading or parsing ${pnpmLockfilePath}:`, error);
process.exit(1);
}
const invalidPnpmPackages = [];
for (const [key, details] of Object.entries(pnpmLockfile?.packages ?? {})) {
const resolution = details?.resolution ?? {};
// Registry packages carry a sha512 integrity hash; git and tarball
// resolutions identify their source directly, mirroring the npm rules
// above.
const hasIntegrity =
typeof resolution.integrity === 'string' &&
resolution.integrity.startsWith('sha512-');
const isGitOrTarball =
resolution.type === 'git' || typeof resolution.tarball === 'string';
if (!hasIntegrity && !isGitOrTarball) {
invalidPnpmPackages.push(key);
}
}
if (invalidPnpmPackages.length > 0) {
console.error(
'\nError: The following dependencies in pnpm-lock.yaml are missing "resolution.integrity":',
);
invalidPnpmPackages.forEach((pkg) => console.error(`- ${pkg}`));
process.exitCode = 1;
} else {
console.log('pnpm lockfile check passed.');
}
console.log('Checking Playwright parity...');
// The root `playwright` and Web Shell's `@playwright/test` both drive the
// chromium revision the capture harness launches, so they must stay on one
// version. Both manifests carry an exact pin: a range on either side lets a
// regeneration resolve the pair apart and re-nest a second tree, which leaves
// the installed browser one the harness cannot launch.
//
// Three other Playwright declarations are deliberately outside this invariant:
// - `packages/mobile-mcp` declares `@playwright/test` as a range directly.
// npm satisfies it from the single hoisted copy at the pinned version
// today, and the harness never resolves into that workspace, so it stays
// out of scope here — but it is the only in-workspace range on a name this
// check pins, which makes it the first manifest to look at when the
// resolved-version assertion below fires. Do NOT append it to
// `playwrightManifests` as it stands: the exactness check rejects its range
// and would turn `check:lockfile` red immediately.
// - `packages/mobile-mcp` also depends on `mobilewright`, which pins
// `playwright` and `playwright-core` to an exact older revision no manifest
// edit here can dedupe. That tree hoists the older `playwright-core` to the
// root, so the root `playwright-core` bin is NOT part of this parity — only
// the `playwright` CLIs are.
// - `integration-tests/terminal-capture` declares a `playwright` range but is
// not a workspace member, so it enters neither lockfile. Bringing it inside
// the invariant takes three steps, not one: pin it exact, list the directory
// in BOTH the root `workspaces` and `pnpm-workspace.yaml`'s `packages:`
// (scripts/tests/package-scripts.test.js asserts the two lists are equal),
// then append it below and regenerate both lockfiles.
const EXACT_VERSION = /^\d+\.\d+\.\d+(-[\w.-]+)?$/;
const playwrightManifests = [
{ manifest: 'package.json', name: 'playwright' },
{ manifest: 'packages/web-shell/package.json', name: '@playwright/test' },
];
const playwrightSpecs = playwrightManifests.map(({ manifest, name }) => {
const pkg = readJsonFile(join(root, manifest));
if (pkg === null) {
process.exit(1);
}
const spec = pkg.devDependencies?.[name] ?? pkg.dependencies?.[name] ?? null;
return { manifest, name, spec, exact: EXACT_VERSION.test(spec ?? '') };
});
const parityErrors = [];
for (const { manifest, name, spec, exact } of playwrightSpecs) {
if (spec === null) {
parityErrors.push(`${manifest} does not declare ${name}`);
} else if (!exact) {
parityErrors.push(
`${manifest} declares ${name} as "${spec}"; expected an exact version so it cannot resolve apart from the others`,
);
}
}
// Every entry is compared against the first rather than the second against the
// first: `playwrightManifests` is a list a maintainer extends, and a positional
// destructure hands a third entry the exactness check while silently skipping
// the agreement check this block exists for.
const [pinEntry, ...restEntries] = playwrightSpecs;
const pinned = pinEntry.spec;
for (const { manifest, name, spec, exact } of restEntries) {
if (exact && pinEntry.exact && spec !== pinned) {
parityErrors.push(
`${pinEntry.name} ${pinned} (${pinEntry.manifest}) and ${name} ${spec} (${manifest}) must declare the same version`,
);
}
}
// Manifest agreement is not enough on its own: a stale or hand-edited lockfile
// can still resolve the set apart. Assert the resolved outcome too, deriving
// each location from the manifest list so an entry added above is covered by
// both halves rather than only by the exactness check.
if (pinEntry.exact) {
for (const { name } of playwrightSpecs) {
const location = `node_modules/${name}`;
const actual = packages[location]?.version;
if (actual === undefined) {
parityErrors.push(
`package-lock.json has no ${location} entry, so nothing hoists the package the harness imports; regenerate the lockfile`,
);
} else if (actual !== pinned) {
parityErrors.push(
`package-lock.json resolves ${location} to ${actual} instead of ${pinned}; regenerate the lockfile, and if that does not settle it then a manifest range is resolving above the pin — packages/mobile-mcp's @playwright/test is the only in-workspace one`,
);
}
}
// A nested copy at the pinned version is a redundant install, not a split
// revision, so only a differing one is an error.
const nested = 'node_modules/@playwright/test/node_modules/playwright';
if (packages[nested] && packages[nested].version !== pinned) {
parityErrors.push(
`package-lock.json nests ${nested} at ${packages[nested].version}, splitting the chromium revision; regenerate the lockfile`,
);
}
}
// Both lockfiles are committed together, so a specifier or a resolved version
// that lands in one and not the other is the same drift. pnpm's
// --frozen-lockfile validates specifiers only, so the version is asserted too.
// The recorded value is not always the manifest string: pnpm-workspace.yaml's
// `overrides:` and .pnpmfile.mjs's readPackage hook both rewrite it — web-shell
// declares `typescript: ^5.3.3` while the lockfile records `5.8.3`, because
// `overrides:` pins it. Neither layer touches Playwright today, so a divergence
// here is drift, but the messages name those layers because "regenerate it" is
// a no-op when one of them is what decides the value.
const pnpmImporters = pnpmLockfile?.importers ?? {};
for (const { manifest, name, spec, exact } of playwrightSpecs) {
const importer = dirname(manifest);
const entry =
pnpmImporters[importer]?.devDependencies?.[name] ??
pnpmImporters[importer]?.dependencies?.[name] ??
null;
if (entry === null) {
parityErrors.push(
`pnpm-lock.yaml has no ${name} entry for importer "${importer}"; importers come from pnpm-workspace.yaml's packages:, so check that ${importer} is listed there, then regenerate`,
);
continue;
}
if (entry.specifier !== spec) {
parityErrors.push(
`pnpm-lock.yaml records ${name} in "${importer}" as "${entry.specifier}" but ${manifest} declares "${spec}"; regenerate it — unless pnpm-workspace.yaml's overrides: or .pnpmfile.mjs rewrites this package, in which case that layer decides the value and the manifest is not the source of truth`,
);
}
if (exact && entry.version !== spec) {
parityErrors.push(
`pnpm-lock.yaml resolves ${name} in "${importer}" to ${entry.version} instead of ${spec}; regenerate it — unless an overrides: entry pins a different version`,
);
}
}
if (parityErrors.length > 0) {
console.error('\nError: Playwright version parity is broken:');
parityErrors.forEach((message) => console.error(`- ${message}`));
process.exitCode = 1;
} else {
console.log('Playwright parity check passed.');
}