qwen-code/scripts/copy_bundle_assets.js
Shaojin Wen 133cf8bfcf
refactor(cli): consolidate shared helpers ahead of the legacy audit skill (#9345)
* refactor(cli): consolidate shared helpers ahead of the legacy audit skill

Move the pieces the upcoming /audit skill needs out of command-group
ownership so no skill imports across command groups:

- the findings schema moves from commands/review/ to cli/src/utils/ as-is;
  every review consumer imports it from the new home, and the stale-bundle
  digest, bundle-asset list, and artifact comment track the move
- safeTarget (traversal-safe slug) and tokenizeArgs (quoted argument
  splitting) lift to cli/src/utils/paths.ts and shell-args.ts, with
  review's copies re-exporting/redirecting
- the two private git check-ignore copies (review test-plan, team memory)
  consolidate into one fresh-by-default helper in core utils; the memo
  stays caller-side so a remedy re-check observes the flip

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): restore review ledger machinery dropped by the consolidation

The shared-helper consolidation silently reverted three behaviors PR #9175
added to compose-review, against the PR's stated "no behavior change"
intent: the unreviewed-dimension anchor exemption (scopeUnproven /
dimensionGapsAreDepthOnly / isNonDiffDimensionGap), the LEDGER_MAX_ROUND
stamp clamp, and the bilingual budget-stop phrase splice. Restore them with
the tests that pin them; SKILL.md, ledger.ts, and deadline.ts still
document all three.

Also harden the new helper tests:
- safeTarget: the deep-path fixtures now share a flattened prefix longer
  than the kept window, so a truncation-only slug (no digest) collides
  instead of shipping green.
- isGitIgnored: each GIT_* scrub arm now carries a discriminating fixture
  (three arms previously passed with their scrub line deleted), and every
  foreign git init scrubs ambient repo-placement selectors.

* fix(core): scrub git config-injection channels from the ignore probe

Ambient GIT_CONFIG_COUNT (inline KEY/VALUE injection) and
GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM (config-file redirects) can aim
core.excludesFile at a foreign rules file and flip the probe's verdict
for the -C worktree — the same leak class the existing selector scrubs
close. Measured on the pristine probe: both channels turn a
not-ignored path into an ignored one.

Also pin the guards the review found unpinned: the two config
channels, the `--` separator for dash-leading paths, the timeoutMs
wiring, safeTarget's hash-of-original-target property, and
tree-existence of the lifted review helpers.

* fix(core): scrub pathspec-magic env channels from the ignore probe

* fix(core): drop the whole GIT_* env family from the ignore probe

Two more leak channels surfaced on the probe's per-variable scrub list:
GIT_ICASE_PATHSPECS (the fourth pathspec modifier — ambient, makes
check-ignore reject every pathspec with exit 128, which the catch reads
as not-ignored) and GIT_CONFIG_PARAMETERS (the inline -c channel git
itself uses to propagate config to children — ambient, can aim
core.excludesFile at a foreign rules file). Measured through the real
function: the first flips a genuinely ignored path to not-ignored, the
second flips a not-ignored path to ignored.

Since the channel list grew by one leak per review round, drop the
whole GIT_* family instead of enumerating, and close the system config
tier explicitly (GIT_CONFIG_NOSYSTEM=1) so host policy in
/etc/gitconfig can no longer answer for the -C worktree — that ambient
dependency also made the config-redirect arm red on any host whose
system config matches the probe path. Pin the icase member, the
PARAMETERS channel, the default 5 s deadline (previously unpinned), and
add a lower timing bound to the caller-deadline arm so it cannot pass
vacuously when the shim is not executable.

* fix(cli): make the safeTarget slug space prefix-free at the dash boundary

Review's cleanup sweeps .qwen/tmp/ by qwen-review-<slug>- prefix. A slug
that itself carried '-' — natively (pr-6771 vs pr) or via the truncation
join — could extend a shorter slug, letting one target's cleanup delete a
DISTINCT target's artifacts (R8-7). The truncation branch this PR carries
newly lands deep targets inside the cap instead of dying ENAMETOOLONG,
which turned the latent collision live.

Drop '-' from the slug alphabet entirely (dashes flatten like separators)
and join the truncation digest with '_': with '-' out of every slug, the
qwen-review-<slug>- boundary is unambiguous by construction — no slug can
start with another slug plus '-'. prev-ledger side files keep their
hardcoded dashed name on both writer and reader, untouched by the slug.

Tests pin the prefix-free property (short-vs-short, short-vs-truncated)
and the fixture names follow the new slugs.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* revert(cli): restore safeTarget byte-identical to the pre-lift behavior

Reverts the slug alphabet change (539495226d) and drops the deep-target
truncation branch, per review round findings R12-1 and R8-7:

- R12-1: flattening dashes renamed only one side of review's two-sided
  artifact-naming contract — bundled-skill templates, composed names,
  prev-ledger and brief/report producers hardcode the dash spelling, so
  the bypass-audit tripwire would silently skip and the cleanup sweep
  would leak ~15 dash-form artifacts per review.
- R8-7: the prefix-sweep hazard only exists because of the truncation
  branch this lift carried; main's safeTarget has no truncation, so a
  behavior-preserving lift must not add it. Deep-target support belongs
  in a follow-up paired with the sweep-side structural fix it needs.

safeTarget is now byte-identical to the pre-lift implementation (moved,
not modified); tests pin the dash spelling and the leading-strip rule as
they behave on main. Full review suite green (5022).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci@service.alibaba.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-19 14:53:44 +00:00

504 lines
19 KiB
JavaScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
statSync,
writeFileSync,
} from 'node:fs';
import {
dirname,
join,
basename,
resolve,
relative,
sep,
extname,
} from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { glob } from 'glob';
import fs from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const defaultRoot = join(__dirname, '..');
// Exported for `scripts/tests/review-source-digest.test.ts`, which holds the
// skill root's digest allowlist up to everything this rule lets the copier
// ship — a file the copier would carry but the digest cannot see is a
// staleness check with a blind spot.
export const BUNDLED_SKILL_TEST_FILE_RE =
/\.(?:test|spec)\.(?:d\.)?[cm]?[jt]sx?(?:\.map)?$/;
/**
* The digest of every review source this bundle was built from.
*
* A staleness hint, not an integrity control: an unsigned file beside the
* bundle, which anyone who can write `dist/` can write.
*
* Kept in step with `stale-bundle.ts`, which re-derives it the same way — and
* duplicated rather than shared, because this script runs before the package
* it would import has been built. `scripts/tests/review-source-digest.test.ts`
* is what holds the two equal; nothing here is imported from there.
*
* Tests and fixtures are excluded on both sides: esbuild follows imports from
* the CLI entry, neither is reachable that way, and a warning fired by an
* edit to a file the bundle cannot contain is the false positive this check
* exists not to produce. DESIGN.md is excluded for the same reason: the
* copier below deliberately does not ship it.
*/
// Mirrors NOT_BUNDLED_RE / NOT_BUNDLED_DIR / NOT_BUNDLED_FILE /
// NOT_BUNDLED_SKILL_FILE / DIGESTED_EXTENSIONS in stale-bundle.ts; the
// parity test keeps them equal.
const NOT_BUNDLED_RE = /\.(?:test|spec)\.(?:d\.)?[cm]?[jt]sx?$/;
const NOT_BUNDLED_DIR = new Set(['__fixtures__', '__snapshots__']);
const NOT_BUNDLED_FILE = new Set(['test-utils.ts']);
const NOT_BUNDLED_SKILL_FILE = new Set(['DESIGN.md']);
const DIGESTED_EXTENSIONS = {
code: new Set([
'.ts',
'.tsx',
'.mts',
'.cts',
'.js',
'.mjs',
'.json',
'.jsx',
]),
skill: new Set(['.md']),
};
function isDigestedFile(kind, name) {
if (!DIGESTED_EXTENSIONS[kind].has(extname(name))) return false;
if (kind !== 'code') return !NOT_BUNDLED_SKILL_FILE.has(name);
return !NOT_BUNDLED_RE.test(name) && !NOT_BUNDLED_FILE.has(name);
}
export function reviewSourceDigestForBuild(root) {
const cliCommands = join(root, 'packages', 'cli', 'src', 'commands');
const roots = [
{ path: join(cliCommands, 'review'), kind: 'code' },
{ path: join(cliCommands, 'review.ts'), kind: 'code' },
// Mirrors the lease root `reviewSourceRoots` (stale-bundle.ts) adds; the
// repo-tree case in review-source-digest.test.ts holds the two equal.
{
path: join(
root,
'packages',
'cli',
'src',
'services',
'review-worktree-lease.ts',
),
kind: 'code',
},
// Mirrors the lifted-helpers roots `reviewSourceRoots` (stale-bundle.ts)
// adds; the repo-tree case in review-source-digest.test.ts holds the two
// equal.
{
path: join(root, 'packages', 'cli', 'src', 'utils', 'findings.ts'),
kind: 'code',
},
{
path: join(root, 'packages', 'cli', 'src', 'utils', 'shell-args.ts'),
kind: 'code',
},
{
path: join(root, 'packages', 'cli', 'src', 'utils', 'paths.ts'),
kind: 'code',
},
{
path: join(
root,
'packages',
'core',
'src',
'skills',
'bundled',
'review',
),
kind: 'skill',
},
];
const files = [];
// `listed` is true when the parent walk saw this path as a directory a
// moment ago: if it cannot be listed now, the tree changed underneath the
// walk and the measurement is incomplete, where a root that was never there
// has simply nothing to say.
const walk = (dir, kind, listed) => {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
// `readdirSync` failing says only "this path did not list" — the reason
// decides everything, and `statSync` states it directly, where inferring
// "this is a file" from `ENOTDIR` assumes every platform's libuv maps
// the case the same way, and a one-sided divergence would drop
// `review.ts` from one digest and not the other — a bundle that is
// byte-for-byte correct warning on every review, forever, on that
// platform alone. A directory that could not be listed (EACCES, or
// vanished after the parent listed it) throws instead of being skipped:
// a digest over the survivors would be stamped as the truth, and every
// review from then on would report stale against it.
let stats;
try {
stats = fs.statSync(dir);
} catch (error) {
if (listed) {
throw new Error(`${dir} vanished while being walked`);
}
// A root that was never there is silent only on ENOENT — EACCES/EPERM
// is a tree that IS there but cannot be measured, and skipping it
// would stamp a digest over the surviving roots, which the runtime
// twin (`sourceFilesUnder` in stale-bundle.ts) marks incomplete: a
// parity gap that would accuse a byte-for-byte correct bundle once
// the tree becomes readable again.
if (error?.code !== 'ENOENT') {
throw new Error(`${dir} could not be measured`);
}
return;
}
if (stats.isDirectory()) {
throw new Error(`${dir} could not be listed`);
}
if (stats.isFile() && isDigestedFile(kind, basename(dir))) {
files.push(dir);
}
return;
}
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) {
if (kind !== 'code' || !NOT_BUNDLED_DIR.has(e.name))
walk(full, kind, true);
} else if (e.isFile() && isDigestedFile(kind, e.name)) {
files.push(full);
}
}
};
for (const r of roots) walk(r.path, r.kind, false);
if (files.length === 0)
return { digest: undefined, count: 0, newest: undefined };
const hash = createHash('sha256');
let newest;
for (const file of files.sort()) {
const { mtimeMs } = fs.statSync(file);
if (!newest || mtimeMs > newest.mtimeMs) newest = { file, mtimeMs };
hash.update(relative(root, file).split(sep).join('/'));
hash.update('\0');
hash.update(fs.readFileSync(file));
hash.update('\0');
}
return { digest: hash.digest('hex'), count: files.length, newest };
}
function stampReviewSourceDigest(root, distDir) {
const stampPath = join(distDir, 'review-sources.sha256');
// Every refusal below removes an existing stamp first. Leaving an older
// stamp beside a newer bundle is a weaker form of the misdescription the
// refusal exists to avoid, and `unmeasured` is the state each refusal means.
const refuse = (why) => {
try {
fs.rmSync(stampPath, { recursive: true, force: true });
} catch (error) {
// `force` swallows only ENOENT; a permission or lock error on the old
// stamp must not kill the bundle step after every asset is in place.
// Leaving it is the lesser outcome: at worst a later "stale" notice
// whose rebuild advice is still correct.
console.warn(
`Could not remove the stale source digest at ${stampPath}: ` +
(error instanceof Error ? error.message : error),
);
}
// `warn`, not `log`: the runtime message sends its reader back to this
// build's output, and a refusal hidden among plain logs is not what they
// are being sent to find.
console.warn(why);
};
// Never fatal. This is the last step of the copier, so a file vanishing
// mid-walk would fail the bundle after every asset was already in place —
// and a missing stamp is only `unmeasured`, which the runtime check already
// treats as an acceptable answer.
let digest, count, newest;
try {
({ digest, count, newest } = reviewSourceDigestForBuild(root));
} catch (error) {
refuse(
`Could not read the review sources; skipped the source digest: ${
error instanceof Error ? error.message : error
}`,
);
return;
}
if (!digest) {
refuse('No review sources found; skipped the source digest.');
return;
}
// The digest describes the tree as the COPIER sees it, and the copier runs
// after esbuild — so a source edited after the bundle was written, or this
// script run on its own, would stamp a `cli.js` built from something else.
// That is the only direction where silence is affirmatively wrong instead
// of merely uninformative: every other gap here degrades to `unmeasured`.
// Timestamps are the wrong tool for judging staleness and the right one for
// judging whether this stamp can be honest at all, so refuse rather than
// stamp. The anchor is the bundle's WRITE time, not the build's start:
// esbuild reads the sources during the graph walk, before it writes, so an
// edit landing while the build is in flight is older than this anchor,
// passes the gate, and is what the copier digests — a stamp honest about
// the tree and not about the bundle. That window is not covered here.
const bundlePath = join(distDir, 'cli.js');
let builtAt;
try {
builtAt = fs.statSync(bundlePath).mtimeMs;
} catch {
refuse('No bundle found to stamp; skipped the source digest.');
return;
}
const newestSource = newest.mtimeMs;
if (newestSource > builtAt) {
refuse(
`A review source is newer than ${bundlePath}; skipped the source digest ` +
`rather than stamp a bundle it may not describe. Run \`npm run bundle\` ` +
`to rebuild the bundle and the stamp together.`,
);
return;
}
try {
fs.writeFileSync(stampPath, digest);
} catch (error) {
refuse(
`Could not write the source digest; skipped the stamp: ${
error instanceof Error ? error.message : error
}`,
);
return;
}
console.log(`Stamped the review source digest over ${count} files.`);
}
export function copyBundleAssets({ root = defaultRoot } = {}) {
const distDir = join(root, 'dist');
const coreVendorDir = join(root, 'packages', 'core', 'vendor');
// Create the dist directory if it doesn't exist
if (!existsSync(distDir)) {
mkdirSync(distDir);
}
// Find and copy all .sb files from packages to the root of the dist directory
const sbFiles = glob.sync('packages/**/*.sb', { cwd: root });
for (const file of sbFiles) {
copyFileSync(join(root, file), join(distDir, basename(file)));
}
console.log('Copied sandbox profiles to dist/');
// Copy vendor directory (contains ripgrep binaries)
console.log('Copying vendor directory...');
if (existsSync(coreVendorDir)) {
const destVendorDir = join(distDir, 'vendor');
copyRecursiveSync(coreVendorDir, destVendorDir);
console.log('Copied vendor directory to dist/');
} else {
console.warn(`Warning: Vendor directory not found at ${coreVendorDir}`);
}
// Copy bundled skills (e.g. /review) so they are available at runtime.
// In the esbuild bundle, import.meta.url resolves to dist/cli.js, so
// SkillManager looks for bundled skills at dist/bundled/.
const bundledSkillsDir = join(
root,
'packages',
'core',
'src',
'skills',
'bundled',
);
if (existsSync(bundledSkillsDir)) {
const destBundledDir = join(distDir, 'bundled');
fs.rmSync(destBundledDir, { recursive: true, force: true });
copyRecursiveSync(bundledSkillsDir, destBundledDir, {
// DESIGN.md files are maintainer design narratives, not runtime inputs;
// shipping one would hand a review a ~125 KB read_file target that
// outweighs the context the slimmed skill saves.
skipEntry: (entry) =>
isBundledSkillTestFile(entry) || entry === 'DESIGN.md',
});
console.log('Copied bundled skills to dist/bundled/');
} else {
console.warn(
`Warning: Bundled skills directory not found at ${bundledSkillsDir}`,
);
}
// Copy user docs into qc-helper bundled skill so it can reference them at runtime.
// The qc-helper skill reads docs from a `docs/` subdirectory relative to its own
// directory. In the esbuild bundle this becomes dist/bundled/qc-helper/docs/.
const userDocsDir = join(root, 'docs', 'users');
if (existsSync(userDocsDir)) {
const destDocsDir = join(distDir, 'bundled', 'qc-helper', 'docs');
copyRecursiveSync(userDocsDir, destDocsDir);
console.log('Copied docs/users/ to dist/bundled/qc-helper/docs/');
} else {
console.warn(`Warning: User docs directory not found at ${userDocsDir}`);
}
// Copy builtin locales so bundled dist/cli.js can load UI translations at runtime.
// Published packages already include these via prepare-package.js; bundle output
// should mirror that behavior for local `node dist/cli.js` runs.
const localesDir = join(root, 'packages', 'cli', 'src', 'i18n', 'locales');
if (existsSync(localesDir)) {
const destLocalesDir = join(distDir, 'locales');
copyRecursiveSync(localesDir, destLocalesDir);
console.log('Copied builtin locales to dist/locales/');
} else {
console.warn(`Warning: Locales directory not found at ${localesDir}`);
}
// Copy extension templates so bundled dist/cli.js can scaffold
// `/extensions new` from the runtime examples directory.
const extensionExamplesDir = join(
root,
'packages',
'cli',
'src',
'commands',
'extensions',
'examples',
);
if (existsSync(extensionExamplesDir)) {
const destExtensionExamplesDir = join(distDir, 'examples');
copyRecursiveSync(extensionExamplesDir, destExtensionExamplesDir);
console.log('Copied extension examples to dist/examples/');
} else {
console.warn(
`Warning: Extension examples directory not found at ${extensionExamplesDir}`,
);
}
// Copy the built Web Shell SPA (index.html + assets/) so the bundled
// `qwen serve` can serve the browser UI at its root path. The library
// build outputs (dist/index.js, dist/types) are for npm consumers and are
// intentionally NOT copied. Source only exists after the web-shell
// workspace is built (npm run build); when absent (e.g. a --cli-only
// build, or bundling without a prior full build) we warn and skip so the
// bundle step never fails — the daemon then runs API-only at runtime.
const webShellDistDir = join(root, 'packages', 'web-shell', 'dist');
const webShellIndexHtml = join(webShellDistDir, 'index.html');
const webShellAssetsDir = join(webShellDistDir, 'assets');
if (existsSync(webShellIndexHtml) && existsSync(webShellAssetsDir)) {
const destWebShellDir = join(distDir, 'web-shell');
mkdirSync(destWebShellDir, { recursive: true });
copyFileSync(webShellIndexHtml, join(destWebShellDir, 'index.html'));
copyRecursiveSync(webShellAssetsDir, join(destWebShellDir, 'assets'));
console.log('Copied Web Shell UI to dist/web-shell/');
} else {
console.warn(
`Warning: Web Shell assets not found at ${webShellDistDir}; ` +
'dist/web-shell/ will be absent and `qwen serve` runs API-only. ' +
'Run a full `npm run build` before bundling to include the UI.',
);
}
// Stamp what the review sources looked like at build time. `/review` drives
// the bundle, not the working tree, so a review command edited after this
// point takes no effect — and without a record of what was built, the run
// cannot tell and neither can its reader. Compared, not trusted: the check
// reads this and re-derives the digest from the tree.
stampReviewSourceDigest(root, distDir);
// Make dist/cli.js directly executable: shellContextEnv blanks a
// QWEN_CODE_CLI whose entry a POSIX shell cannot exec (no shebang, or no
// execute bit), and the `"${QWEN_CODE_CLI:-qwen}"` fallback then silently
// runs whatever `qwen` the PATH resolves — a different install for every
// `review` subcommand of a session launched from this bundle. Measured on
// three live `review run`s: every agent-issued subcommand ran the machine's
// global install instead of the freshly bundled tree.
const cliEntry = join(distDir, 'cli.js');
if (existsSync(cliEntry)) {
const source = readFileSync(cliEntry, 'utf8');
if (!source.startsWith('#!')) {
// Preserve the bundle's write time across the rewrite. The digest stamp
// above reads this mtime as "when the bundle was built", and a bumped
// one certifies a bundle as newer than review sources edited before it
// — the staleness warning the skill's Step 0 stops on then never fires.
// (Only a standalone run of this script is exposed, since a full bundle
// stamps before reaching here, but that is a flow the gate contemplates.)
const { atime, mtime } = statSync(cliEntry);
writeFileSync(cliEntry, `#!/usr/bin/env node\n${source}`);
fs.utimesSync(cliEntry, atime, mtime);
}
// chmod does not touch mtime, so it stays outside the guard: a bundle
// that already carries a shebang may still arrive without the exec bit.
fs.chmodSync(cliEntry, 0o755);
}
console.log('\n✅ All bundle assets copied to dist/');
}
if (isDirectRun()) {
copyBundleAssets();
}
function isDirectRun() {
return process.argv[1]
? fileURLToPath(import.meta.url) === resolve(process.argv[1])
: false;
}
/**
* Recursively copy directory
*/
function copyRecursiveSync(src, dest, options = {}) {
if (!existsSync(src)) {
return;
}
const stats = statSync(src);
if (stats.isDirectory()) {
if (!existsSync(dest)) {
mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src);
for (const entry of entries) {
if (entry === '.DS_Store' || options.skipEntry?.(entry)) {
continue;
}
const srcPath = join(src, entry);
const destPath = join(dest, entry);
copyRecursiveSync(srcPath, destPath, options);
}
} else {
copyFileSync(src, dest);
// Preserve execute permissions for binaries
const srcStats = statSync(src);
if (srcStats.mode & 0o111) {
fs.chmodSync(dest, srcStats.mode);
}
}
}
function isBundledSkillTestFile(fileName) {
return BUNDLED_SKILL_TEST_FILE_RE.test(fileName);
}