mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-25 00:26:31 +00:00
* fix(review): harden the pipeline against four live-run failures
Measured on three parallel PR reviews (qwen3.8-max, 2026-08-13, PRs
#9013/#9014/#9045) run via `qwen review run`:
- run.ts: pin the composed-verdict and report scans to the run's own
target. The generic newest-composed scan captured a concurrent run's
artifact — two of the three runs republished a neighbour PR's verdict
(one reported REQUEST_CHANGES for a review whose own report said
Comment). Also keep re-reading while the child runs: a coverage
re-check legitimately recomposed a verdict 12 minutes after the first
write, and the first-snapshot capture would republish the superseded
one.
- budget.ts: drop placeholder gaps whose completion word carries a
trailing budget adverbial. Three "none — all checks … completed
within budget" non-answers reached two posted bodies because the
completion idiom required the completion word to end the text.
- coverage.ts: label a non-chunk agent by the brief codename found
anywhere in its launch prompt. Launchers prepend context lines, so the
first-line label gave twelve finders one shared PR-summary sentence,
and every budget-gap disclosure rendered as the same truncated PR
quote instead of a name.
- copy_bundle_assets.js: emit dist/cli.js with a shebang and the execute
bit. shellContextEnv blanks a QWEN_CODE_CLI a POSIX shell cannot exec,
so every review subcommand issued from a session launched off the
bundle silently fell back to the PATH's global install — all three
runs executed the machine's auto-updated release instead of the tree
they were launched from.
* test(review): follow the codename label into compose-review's fixtures
The backtick-collapse fixture's first line was itself the brief codename
shape, so the new codename extraction labels it `agent security` and the
first-line assertion no longer holds. Keep the sanitization intent on a
prose-only first line, and pin the codename behaviour — a prepended
context line must not reintroduce the shared-PR-quote label — as its own
case.
* fix(review): classify the run target with the child's own parser
Review feedback on the target pin: prNumberFromTarget re-derived PR
classification with a narrower regex than parse-args — /pull/<n>/files
URLs went unpinned, 0042 pinned pr-0042- while the child writes pr-42-,
and docs/pull/42 pinned a file target as a PR — so a completed (and
posted) review could be reported as one that produced no verdict.
Delegate to parseReviewArgs, whose verdict is what the child names its
artifacts from, and pin the divergent shapes as tests.
Also gate the bundle's shebang/exec-bit block with a package-assets
case (mode asserted off-win32, double-run must not stack shebangs), and
document the accepted same-target residual race on composedPatternFor.
* fix(review): pin run artifacts by exact target identity, share the identity-line parser
Round-2 review feedback, all six findings:
- run.ts: replace name-shape pins with the exact composed filename each
target class produces (pr-<n> / file basename / the fixed 'local' token,
per the skill's --out template). The (?!pr-\d+-) lookahead rejected a
file run's own artifact whenever the reviewed file was named
pr-<digits>-…, the PR branch's .* wildcard claimed that same artifact,
and the pooled null class let concurrent file and no-target runs
cross-capture each other's verdicts. Target classification now comes
from classifyRunTarget (parse-args' verdict, basename for files).
- run.ts: newestArtifactSince returns {path, mtime}, so the capture poll
reuses the scan's own stat instead of re-statting the path — the
scan-vs-sweep window (and its untestable catch branch) is gone
structurally.
- lib/agent-identity.ts: one parser for the identity line agent-prompt
bakes into every launch, shared by cost-ledger's row labels and
coverage's disclosure labels — the two hand-rolled copies could drift,
and coverage's copy dropped the (round N) and owned-file suffixes,
folding reverse-audit rounds into indistinguishable disclosure lines.
cost-ledger still feeds it only the first line (quoted identity lines
below must never be credited); coverage scans for the first
line-anchored identity line (launchers prepend context lines).
- lib/budget.ts: one vocabulary for the budget-idiom family — 'below'
joins the completion tail's position words, and the stayed idiom takes
the same qualifiers ('stayed inside the tool-call budget').
- run.test.ts: handler-level assertion that the report scan is pinned
(a strictly newer neighbour report must not become this run's
reportPath), alongside the pattern-level cases for every shape the
review probed.
* fix(review): round-3 review polish — CRLF identity lines, producer-side no-gap rule, edge-case pins
- agent-identity: tolerate a trailing CR (CRLF-recorded prompts fed
through \n-splitting callers failed every parse and fell back to
first-line prose); scan the launch prompt with one multiline match
instead of materializing a line array per agent record; pin the
round-over-file precedence with a both-suffixes test.
- agent-prompt: state the no-gap rule at the producer — write NO
'Budget gap:' line when nothing was cut short — instead of leaving
each agent to improvise a 'none' phrasing the consumer-side
placeholder filter must chase forever.
- run.ts: strip trailing path separators before taking a file target's
basename (a tab-completed 'src/' pinned 'qwen-review--composed.json',
which no child artifact carries — fail-closed exit 1 on a completed
review); pin file-run reports by their filename slot so a file named
'pr-1234.md' claims its own report; document the two collision
classes the basename-keyed pin defines (same-basename files,
basenames spelling 'local'/'pr-<n>').
- budget.test: pin the parenthesis-form exception keep case beside the
dash form.
* fix(review): round-4 polish — one budget vocabulary, named pin expectation, honest prompt claim
- budget.ts: spell the budget-position vocabulary once (BUDGET_QUALIFIED /
COMPLETION_TAIL) and build PLACEHOLDER_GAP_RE from it — the literal
carried three hand-copies that had already drifted twice in two review
rounds; the space-separated 'tool call' form is pinned in both branches.
- agent-prompt.ts: the no-gap rule now states what actually happens to a
'none' disclosure — at best filtered, and any unrecognized wording is
published as a phantom coverage gap — instead of claiming the parser
treats it as a gap, which was the negation of the filter shipped beside
it.
- run.ts: derive the composed pin from composedNameFor and name the
expected filename in both the no-verdict prose and the JSON result
(expectedComposedName) — a naming drift between the pin and the skill's
template was undiagnosable once Step 9 swept the near-miss.
- compose-review.ts: publicAgentSubject's provenance note now describes
the codename labels coverage prefers, with first-line prose as the
fallback.
* test(review): guard the no-verdict diagnostic and the pinned capture; align the chunk-role grammar
- run.test.ts: assert the no-verdict report names the artifact it waited
for, in prose and as expectedComposedName — mutation-verified: dropping
the suffix now fails.
- run.test.ts: force the neighbour's composed artifact strictly NEWER in
the concurrent-run fixture. With it older, an unpinned newest-composed
scan landed on the right file anyway and the regression passed;
mutation-verified: reverting composedPatternFor to the generic scan now
fails the handler test, not only the pattern units.
- agent-identity.ts: CHUNK_ROLE_RE takes coverage's CHUNK_RE shape
(whitespace-tolerant, case-insensitive) so a hand-edited 'Chunk 3 of 7'
cannot resolve as a chunk owner in the posted body and a role agent in
the ledger row.
* fix(review): keep the bundle's write time across the shebang rewrite; hold the pins to the skill
Round-6 review feedback, all five findings:
- copy_bundle_assets.js: preserve dist/cli.js's atime/mtime across the
shebang rewrite. stampReviewSourceDigest reads that mtime as the build
time, so a bumped one certifies a bundle as newer than review sources
edited before it and the staleness warning the skill's Step 0 stops on
never fires. A full bundle stamps before reaching here, but a
standalone run of this script — a flow the gate's own comment
contemplates — was exposed.
- package-assets.test.js: pin both halves the block owes. The mtime is
asserted against a fixture built 60s in the past, and the second run
now arrives at mode 0644 so the exec bit must be re-set — demoting the
chmod inside the shebang guard previously stayed green.
- run-skill-parity.test.ts: new. composedNameFor and reportPatternFor
encode the bundled skill's Step 6 --out template and Step 8 report
stems, and were pinned only against self-referential literals. This
reads the templates out of SKILL.md and renders them per target class,
so a skill-side edit fails next to the code that must follow it
instead of silently in a later review.
- cost-ledger.test.ts: pin the first-line-only invariant — a launch
whose prepended context sits above the identity line keeps the
transcript's own id, never a label lifted from below. Consolidating
both callers on labelFromLaunchPrompt now fails.
- agent-identity.test.ts: assert the two entry points genuinely differ
on that prompt, so neither caller's policy can be collapsed into the
other unnoticed.
* fix(review): stop the mtime assertion from pinning libuv's timespec truncation
The assertion compared the recorded mtime against the Date handed to
utimesSync, so it also pinned libuv's double-seconds → timespec
conversion: about half of all millisecond values read back 1 ns low
(X - 0.001), and builtAt is a fresh Date.now() - 60_000 every run — a
~50% coin flip that would have landed intermittent reds on unrelated
PRs through test:ci.
Capture what the filesystem actually recorded after the setup and
compare against that; the invariant under test is only whether the
shebang rewrite moves the stored time. 10/10 green through the CI entry
point, and it keeps its teeth: removing the production
fs.utimesSync(cliEntry, atime, mtime) restore fails it 3/3.
489 lines
18 KiB
JavaScript
489 lines
18 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',
|
|
},
|
|
{
|
|
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);
|
|
}
|