Files
qwen-code/packages/web-shell/scripts/verify-publish-artifacts.mjs
T
ytahdnand钉萁 df5470e430 feat(web-shell): Add workspace file browser and previews (#13878)
* feat(web-shell): Add workspace file browser and previews

* fix(web-shell): Restore source preview CI fixtures and notices

* fix(web-shell): Address file pane review blockers

* fix(web-shell): Resolve review regressions and strengthen file checks

---------

Co-authored-by: 钉萁 <[email protected]>
2026-10-13 09:38:47 +00:00

171 lines
7.2 KiB
JavaScript

// Runs from `prepublishOnly`: a published version cannot be replaced, so refuse
// to pack artifacts that consumers could not resolve.
import { execSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import pkg from '../package.json' with { type: 'json' };
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const problems = [];
// Existing on disk says nothing about shipping: `files` publishes `dist/*.js`,
// and an npm glob does not cross a `/`, so anything the build emits below
// `dist/` is left out. Ask npm which paths it would actually pack.
// npm pack does not run `prepublishOnly`; `--ignore-scripts` prevents future
// pack-time hooks from re-entering this verifier.
let packed;
try {
packed = new Set(
JSON.parse(
execSync('npm pack --dry-run --json --ignore-scripts', {
cwd: root,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
}),
)[0].files.map((file) => file.path),
);
} catch (error) {
// No list means no membership check, and a published version cannot be
// replaced: report it and let the run below refuse the publish.
problems.push(`could not determine the packed file list: ${error.message}`);
}
// npm lists packed paths relative to the package root, posix-separated and
// without the leading `./` that `exports` targets carry, so both sides of a
// membership check have to be reduced to that form first.
const packPath = (target) => relative(root, target).split(sep).join('/');
if (packed && !packed.has('NOTICES.txt')) {
problems.push(
'bundled dependency NOTICES.txt is missing from the npm package',
);
}
// A `.`/`..` path segment (also percent-encoded, which Node rejects the same
// way) anywhere in a declared `exports` target. Deliberately not `_`: Node
// resolves `./dist/_/*.js` normally.
const invalidTargetSegment = /(^|\/)(\.|%2e)(\.|%2e)?(\/|$)/i;
const entryPoints = Object.entries(pkg.exports).flatMap(([key, entry]) =>
typeof entry === 'string'
? [[key, entry]]
: Object.values(entry).map((value) => [key, value]),
);
const seen = new Set();
for (const [key, entry] of entryPoints) {
// `[key, entry]` pairs are fresh arrays, so identity dedup (`new Set` over
// the pairs) would never fire. The key is part of the dedup text on
// purpose: which branch a pair takes depends on the key, so two keys
// sharing one target must both be checked.
if (seen.has(key + '\0' + entry)) continue;
seen.add(key + '\0' + entry);
// Node resolves an `exports` target only when it is a `./`-relative path
// whose segments are all real names. A bare (`dist/*`), rooted (`/dist/*`)
// or dot-segment (`./dist/../dist/*.js`) target throws
// `ERR_INVALID_PACKAGE_TARGET` for every specifier through that key, so the
// family it names is unresolvable no matter what the tarball ships. Check
// the DECLARED string: `join` below normalizes dot segments away, and
// matching the normalized path would certify a target Node refuses to
// resolve. The segment test runs after the `./` prefix is stripped, or
// `(^|\/)` would match that prefix itself and reject every valid target.
if (!entry.startsWith('./') || invalidTargetSegment.test(entry.slice(2))) {
problems.push(`${entry} is not a valid "exports" target`);
continue;
}
// A subpath pattern (`"./*": "./dist/*"`) names a family of files, not a
// path: statting it literally would report a false `missing`. Hold the
// family against the packed list instead — at least one packed file must
// match, or the manifest advertises subpaths the tarball does not ship.
// Node gives `*` pattern meaning only when the KEY carries it, so gate on
// both sides: a `*` target under a literal key is a literal path and keeps
// the checks below, and a pattern key with a literal target still needs the
// relative-import chunk scan the `continue` would skip. A pattern key also
// has to be a `./`-prefixed subpath key — `"dist/*"` and `"*"` are not, and
// mixing them with `"."` makes Node reject the whole manifest with
// `ERR_INVALID_PACKAGE_CONFIG`, root specifier included — and Node honours
// it only when it carries exactly one `*`, substituting that one capture
// into every `*` in the target. So the gate requires the prefix and counts
// stars on the key, and the regex captures on the first `*` and
// back-references that capture for every later `*` instead of matching each
// star independently. That back-reference has to be named: `\1` followed
// immediately by a digit (`./dist/*/*1.js`) compiles as the Annex B octal
// escape U+0009 rather than as a reference, leaving a pattern no packed
// path can satisfy — which would refuse a manifest Node resolves fine.
if (
key.startsWith('./') &&
key.split('*').length === 2 &&
entry.includes('*')
) {
if (packed) {
const escaped = packPath(join(root, entry)).replace(
/[.+?^${}()|[\]\\]/g,
'\\$&',
);
const [first, ...rest] = escaped.split('*');
const pattern =
first +
rest
.map((part, i) => (i === 0 ? '(?<s>.*)' : '\\k<s>') + part)
.join('');
if (![...packed].some((file) => new RegExp(`^${pattern}$`).test(file))) {
problems.push(`${entry} matches no file in the npm package`);
}
}
continue;
}
const target = join(root, entry);
if (!existsSync(target)) {
problems.push(`missing ${entry}`);
continue;
}
if (packed && !packed.has(packPath(target))) {
problems.push(`${entry} was built but is not included in the npm package`);
continue;
}
// The bundles share chunks by relative path, and `files` publishes them by
// globbing `dist/*.js`. A chunk emitted into a subdirectory would be
// announced by an entry point but never packed.
if (!entry.endsWith('.js')) continue;
for (const [, specifier] of readFileSync(target, 'utf8').matchAll(
/(?:from|import\()\s*['"](\.[^'"]+)['"]/g,
)) {
const imported = resolve(dirname(target), specifier);
if (!existsSync(imported)) {
problems.push(`${entry} imports ${specifier}, which was not built`);
} else if (packed && !packed.has(packPath(imported))) {
problems.push(
`${entry} imports ${specifier}, which is not included in the npm package`,
);
}
}
}
// Declarations ship verbatim, so they must not import through the alias that
// only this repository resolves.
const typesDir = join(root, 'dist/types');
if (existsSync(typesDir)) {
for (const name of readdirSync(typesDir, { recursive: true })) {
if (!name.endsWith('.d.ts')) continue;
const source = readFileSync(join(typesDir, name), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '');
for (const [, specifier] of source.matchAll(
/(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g,
)) {
if (specifier.startsWith('@/')) {
problems.push(`dist/types/${name} imports the repo-only ${specifier}`);
}
}
}
}
if (problems.length > 0) {
console.error(
`Refusing to publish @qwen-code/web-shell:\n${problems
.map((problem) => ` - ${problem}`)
.join('\n')}`,
);
process.exit(1);
}