mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
80feff42d6
* build: verify the published tarball instead of grepping the bundle Replaces the bundle-dependency grep with one gate that packs the tarball npm would publish and proves it sound from a clean consumer install: publint and attw on the tarball, a two-way dependency-closure audit, an import of every `exports` subpath, and the CLI smoke run — all from outside the workspace, where no pnpm link can mask an unresolvable specifier. Also stops the build from emitting a publishable bundle in the first place: a missing workspace link now fails `pnpm build` instead of warning and exiting 0, which is how 0.20.4 shipped an unresolvable `@agent-device/ad-script` import. publint found 12 real defects in the current package — every `exports` entry listed `types` after `import`, so TypeScript resolved declarations by accident rather than by condition. The dependency audit found `pngjs` declared as a runtime dependency while tsdown inlines it, an install every user paid for and no shipped code reached; it moves to devDependencies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NamFJUgn9DGHrT2za11JbD * fix(ci): run the package gate without pnpm on the Node floor pnpm 11.17 requires Node >= 22.13, so `pnpm check:package` could not start on the 22.12 floor the Packaged CLI job exists to cover. The gate needs only `node` and `npm`, so the job invokes the script directly. Splits the dependency-closure audit into a collector and a message builder to clear Fallow's complexity threshold, and classifies both packaging linters in ignoreDependencies: they are subprocess CLIs with no importable API here, which dependency analysis cannot follow to an import. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NamFJUgn9DGHrT2za11JbD * fix(publishing): read every literal resolution form in the closure audit The dependency-closure audit derived shipped imports from the ESM module record alone, so it could not see a package resolved through `require` or a `createRequire` result: neither produces a module-record entry. A lazy `createRequire('@agent-device/…')` would therefore clear the audit, the all-export probe and the exercised CLI paths, reintroducing the 0.20.4 published-install failure class for another command. Measuring the built bundle turned up a second, larger hole in the same reader. The shipped files are minified, and the minifier rewrites every string literal to a no-substitution template literal, so the dynamic-import extraction — which accepted quoted strings only — matched 0 of the 99 dynamic imports the bundle contains. The lazy `import()` path that broke 0.20.4 was reported as covered while checking nothing. Specifiers now come from the module record plus an AST walk over every literal runtime-resolution form: `import()`, `require()`, `require.resolve()`, an immediately-invoked `createRequire(...)`, and calls through a `createRequire` result under any import or minified alias. Both spellings of a string literal count everywhere, and `.cjs` joins the scanned extensions. Computed specifiers stay explicitly out of scope, and are pinned as such. Rejecting them is not available: minifiers reuse short identifiers across scopes, and the packed bundle really does contain an unrelated `a(h[t],f,g,l,e,m)` that no name-based match can distinguish from a require call. Those are covered by the gate's runtime half instead, which resolves them for real. Bare-identifier calls need the one-string-argument shape for the same reason. The audit moves to scripts/lib/shipped-imports.ts so fixture packages can exercise it. The gate needs a real `npm pack` behind minutes of Swift and Android builds, so every check that runs it can only watch a healthy package pass — which is how a reader that matched nothing looked covered. The new fixtures assert the failure direction per resolution form: 16 of the 22 fail against the previous reader, and the 6 that pass are the quoted-spelling and pinned-limitation cases. A wiring assertion keeps the audit and both runtime probes attached to the gate, since fixtures alone would stay green if the call were deleted. Verified against the real built bundle: the closure resolves to exactly the two declared dependencies, so the stricter reader adds no false positives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NamFJUgn9DGHrT2za11JbD --------- Co-authored-by: Claude <noreply@anthropic.com>
262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
/**
|
|
* The static half of the publishing gate (scripts/check-package.ts step 3): which packages the
|
|
* files inside the packed tarball actually resolve at runtime, and whether `dependencies` matches.
|
|
*
|
|
* Extracted from the gate so this half can be exercised against fixture output. The gate itself
|
|
* only runs against a real `npm pack` — minutes of Swift and Android builds — so nothing there can
|
|
* prove that a malformed package *fails*; every test against it can only confirm the healthy path.
|
|
*
|
|
* Two properties of the shipped files decide what a reader here has to understand. Both were
|
|
* measured against the built `dist/`, not assumed:
|
|
*
|
|
* - **The shipped files are minified, and the minifier rewrites every string literal to a
|
|
* no-substitution template literal.** `` import(`./cli-help.js`) `` is the normal spelling in
|
|
* the bundle; `import('./cli-help.js')` never appears. A reader that accepts only quoted strings
|
|
* sees 0 of the 99 dynamic imports the bundle contains — so the lazy-import half of the closure
|
|
* audit reads as covered while checking nothing, on exactly the lazy `import()` path that
|
|
* published 0.20.4 broke on (#1577).
|
|
*
|
|
* - **Bundled dependencies resolve through `createRequire`, under minified aliases.** The packed
|
|
* `png-worker-contract.js` carries `` var a = t(import.meta.url); a(`zlib`) `` from inlined
|
|
* `pngjs`. A `require` call produces no entry in an ESM module record — not a static import, not
|
|
* a dynamic import — so a reader built on the record alone cannot see a required package at all,
|
|
* and a lazy `createRequire('@agent-device/…')` would slip past this audit the same way.
|
|
*
|
|
* So specifiers come from the module record (static `import`/`export`) *and* from an AST walk over
|
|
* every literal runtime-resolution form: `import()`, `require()`, `require.resolve()`, and calls
|
|
* through a `createRequire` result. Both spellings of a string literal count everywhere.
|
|
*
|
|
* Deliberately **not** covered: a specifier computed at runtime — `a(name)`, `require('pkg/' + x)`.
|
|
* Resolving those needs constant propagation this gate does not attempt, and rejecting them
|
|
* outright is not available either: minifiers reuse short identifiers across scopes, so the packed
|
|
* bundle contains unrelated calls like `a(h[t],f,g,l,e,m)` that no name-based match can tell from a
|
|
* real `require`. Computed resolution is covered by the runtime half of the gate instead — step 4
|
|
* imports every export and runs the CLI paths that load the lazy bundles. Keep both halves.
|
|
*/
|
|
import fs from 'node:fs';
|
|
import { builtinModules } from 'node:module';
|
|
import path from 'node:path';
|
|
import {
|
|
type CallExpression,
|
|
type Expression,
|
|
type Node,
|
|
parseSync,
|
|
type StringLiteral,
|
|
type TemplateLiteral,
|
|
Visitor,
|
|
} from 'oxc-parser';
|
|
import { walkFiles } from './walk-files.ts';
|
|
|
|
export type PackedManifest = {
|
|
dependencies?: Record<string, string>;
|
|
};
|
|
|
|
/** Every file extension the package ships that Node can resolve a specifier from. */
|
|
const SHIPPED_SOURCE = /\.(?:m?js|cjs|d\.ts)$/;
|
|
|
|
/** The module both spellings of `createRequire` are imported from. */
|
|
const MODULE_BUILTIN = new Set(['module', 'node:module']);
|
|
|
|
const builtins = new Set(builtinModules.flatMap((name) => [name, `node:${name}`]));
|
|
|
|
/**
|
|
* `'pkg'` and `` `pkg` `` are the same specifier. The second is the only spelling the minified
|
|
* shipped files use, so treating it as non-literal blinds this reader to the whole bundle.
|
|
*/
|
|
function literalSpecifier(node: Node | Expression | null | undefined): string | undefined {
|
|
if (!node) return undefined;
|
|
if (node.type === 'Literal') {
|
|
const { value } = node as StringLiteral;
|
|
return typeof value === 'string' ? value : undefined;
|
|
}
|
|
if (node.type === 'TemplateLiteral') {
|
|
const template = node as TemplateLiteral;
|
|
if (template.expressions.length > 0 || template.quasis.length !== 1) return undefined;
|
|
return template.quasis[0]?.value.cooked ?? undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** The local names `createRequire` is reachable through in one file, under any import alias. */
|
|
type CreateRequireBindings = {
|
|
/** `import { createRequire } from 'node:module'` — including `as` aliases the minifier invents. */
|
|
locals: Set<string>;
|
|
/** `import * as mod from 'node:module'`, reached as `mod.createRequire`. */
|
|
namespaces: Set<string>;
|
|
};
|
|
|
|
function isCreateRequire(callee: Expression, bindings: CreateRequireBindings): boolean {
|
|
if (callee.type === 'Identifier') return bindings.locals.has(callee.name);
|
|
return (
|
|
callee.type === 'MemberExpression' &&
|
|
!callee.computed &&
|
|
callee.object.type === 'Identifier' &&
|
|
bindings.namespaces.has(callee.object.name) &&
|
|
callee.property.type === 'Identifier' &&
|
|
callee.property.name === 'createRequire'
|
|
);
|
|
}
|
|
|
|
/** A resolution call found in one pass, pending the alias set the same pass is still collecting. */
|
|
type PendingCall =
|
|
| { form: 'callee'; name: string; specifier: string; single: boolean }
|
|
| { form: 'resolve'; name: string; specifier: string }
|
|
| { form: 'resolved'; specifier: string };
|
|
|
|
/** The first argument, when it is a literal specifier rather than a computed expression. */
|
|
function firstArgumentSpecifier(call: CallExpression): string | undefined {
|
|
const [first] = call.arguments;
|
|
if (!first || first.type === 'SpreadElement') return undefined;
|
|
return literalSpecifier(first);
|
|
}
|
|
|
|
/** `<name>.resolve` — the object name, for the `require.resolve('pkg')` spelling. */
|
|
function resolveTarget(callee: Expression): string | undefined {
|
|
if (callee.type !== 'MemberExpression' || callee.computed) return undefined;
|
|
if (callee.object.type !== 'Identifier') return undefined;
|
|
if (callee.property.type !== 'Identifier' || callee.property.name !== 'resolve') return undefined;
|
|
return callee.object.name;
|
|
}
|
|
|
|
function pendingCall(
|
|
call: CallExpression,
|
|
bindings: CreateRequireBindings,
|
|
): PendingCall | undefined {
|
|
const specifier = firstArgumentSpecifier(call);
|
|
if (specifier === undefined) return undefined;
|
|
const { callee } = call;
|
|
// `createRequire(import.meta.url)('pkg')` — the alias is inline, so nothing needs resolving.
|
|
if (callee.type === 'CallExpression' && isCreateRequire(callee.callee, bindings)) {
|
|
return { form: 'resolved', specifier };
|
|
}
|
|
if (callee.type === 'Identifier') {
|
|
return { form: 'callee', name: callee.name, specifier, single: call.arguments.length === 1 };
|
|
}
|
|
// `require.resolve('pkg')`, which also takes an options bag as a second argument.
|
|
const target = resolveTarget(callee);
|
|
return target === undefined ? undefined : { form: 'resolve', name: target, specifier };
|
|
}
|
|
|
|
/**
|
|
* Every literal specifier one shipped file resolves, from the module record and from the runtime
|
|
* resolution forms the record cannot represent.
|
|
*/
|
|
function moduleSpecifiers(file: string, source: string): string[] {
|
|
const parsed = parseSync(file, source);
|
|
const record = parsed.module;
|
|
|
|
const bindings: CreateRequireBindings = { locals: new Set(), namespaces: new Set() };
|
|
for (const staticImport of record.staticImports) {
|
|
if (!MODULE_BUILTIN.has(staticImport.moduleRequest.value)) continue;
|
|
for (const entry of staticImport.entries) {
|
|
if (entry.importName.kind === 'Name' && entry.importName.name === 'createRequire') {
|
|
bindings.locals.add(entry.localName.value);
|
|
}
|
|
if (entry.importName.kind === 'NamespaceObject') {
|
|
bindings.namespaces.add(entry.localName.value);
|
|
}
|
|
}
|
|
}
|
|
|
|
// A single pass: `require` aliases and the calls through them are collected together and matched
|
|
// afterwards, so a call does not have to appear below the declaration that names it.
|
|
const requireAliases = new Set(['require']);
|
|
const pending: PendingCall[] = [];
|
|
const dynamic: string[] = [];
|
|
new Visitor({
|
|
VariableDeclarator(node) {
|
|
if (node.id.type !== 'Identifier') return;
|
|
if (node.init?.type !== 'CallExpression') return;
|
|
if (isCreateRequire(node.init.callee, bindings)) requireAliases.add(node.id.name);
|
|
},
|
|
ImportExpression(node) {
|
|
const specifier = literalSpecifier(node.source);
|
|
if (specifier !== undefined) dynamic.push(specifier);
|
|
},
|
|
CallExpression(node) {
|
|
const call = pendingCall(node, bindings);
|
|
if (call) pending.push(call);
|
|
},
|
|
}).visit(parsed.program);
|
|
|
|
const required = pending.flatMap((call) => {
|
|
if (call.form === 'resolved') return [call.specifier];
|
|
if (!requireAliases.has(call.name)) return [];
|
|
// A bare `name('pkg')` only reads as a require call in the one-string-argument shape. Minified
|
|
// scopes reuse short names, and the packed bundle really does contain a six-argument `a(…)`
|
|
// whose `a` is not the file's `createRequire` result.
|
|
if (call.form === 'callee' && !call.single) return [];
|
|
return [call.specifier];
|
|
});
|
|
|
|
return [
|
|
...record.staticImports.map((entry) => entry.moduleRequest.value),
|
|
...record.staticExports.flatMap((entry) =>
|
|
entry.entries.flatMap((exported) =>
|
|
exported.moduleRequest?.value ? [exported.moduleRequest.value] : [],
|
|
),
|
|
),
|
|
...dynamic,
|
|
...required,
|
|
];
|
|
}
|
|
|
|
function packageNameOf(specifier: string): string {
|
|
const segments = specifier.split('/');
|
|
return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]!;
|
|
}
|
|
|
|
function isExternalPackage(specifier: string): boolean {
|
|
return !specifier.startsWith('.') && !specifier.startsWith('/') && !builtins.has(specifier);
|
|
}
|
|
|
|
/** Maps each package the shipped files import to the files importing it. */
|
|
function shippedImports(installedRoot: string): Map<string, string[]> {
|
|
const importedBy = new Map<string, string[]>();
|
|
for (const file of walkFiles(installedRoot, (file) => SHIPPED_SOURCE.test(file))) {
|
|
const relative = path.relative(installedRoot, file);
|
|
const specifiers = moduleSpecifiers(file, fs.readFileSync(file, 'utf8'));
|
|
for (const specifier of specifiers.filter(isExternalPackage)) {
|
|
const name = packageNameOf(specifier);
|
|
importedBy.set(name, [...(importedBy.get(name) ?? []), `${specifier} in ${relative}`]);
|
|
}
|
|
}
|
|
return importedBy;
|
|
}
|
|
|
|
function mismatch(heading: string, entries: readonly string[], fix: string): string[] {
|
|
return entries.length === 0 ? [] : [heading, ...entries.map((entry) => ` - ${entry}`), fix];
|
|
}
|
|
|
|
/**
|
|
* Both directions matter. An import the manifest does not declare breaks the install; a declared
|
|
* dependency nothing imports is an install every user pays for and no code reaches — how `pngjs`
|
|
* stayed in `dependencies` after `tsdown.config.ts` started inlining it.
|
|
*/
|
|
export function auditDependencyClosure(
|
|
installedRoot: string,
|
|
manifest: PackedManifest,
|
|
): Map<string, string[]> {
|
|
const declared = new Set(Object.keys(manifest.dependencies ?? {}));
|
|
const importedBy = shippedImports(installedRoot);
|
|
const problems = [
|
|
...mismatch(
|
|
'Imported but not declared in "dependencies" (a published install cannot resolve these):',
|
|
[...importedBy].filter(([name]) => !declared.has(name)).flatMap(([, sites]) => sites),
|
|
'Declare the package, or add it to `deps.alwaysBundle` in tsdown.config.ts.',
|
|
),
|
|
...mismatch(
|
|
'Declared in "dependencies" but never imported (every user installs these for nothing):',
|
|
[...declared].filter((name) => !importedBy.has(name)),
|
|
'Remove the dependency, or stop bundling it in tsdown.config.ts.',
|
|
),
|
|
];
|
|
|
|
if (problems.length > 0) {
|
|
throw new Error(
|
|
`The published dependency closure does not match what the package imports.\n${problems.join('\n')}`,
|
|
);
|
|
}
|
|
return importedBy;
|
|
}
|