Files
callstack__agent-device/scripts/__tests__/package-closure-audit.test.ts
Michał Pierzchała 80feff42d6 build: verify the published tarball instead of grepping the bundle (#1578)
* 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>
2026-08-04 12:28:05 +02:00

166 lines
7.0 KiB
TypeScript

/**
* Behavioral tests for the publishing gate's dependency-closure audit, run against fixture packages
* that stand in for packed output.
*
* The gate itself (scripts/check-package.ts) needs a real `npm pack` behind minutes of Swift and
* Android builds, so every check that runs it can only observe a *healthy* package passing. That
* leaves the interesting direction — does a malformed package actually fail? — untested, which is
* how the audit came to read 0 of the 99 dynamic imports in the built bundle while looking covered.
* These fixtures assert the failure direction, one resolution form at a time.
*
* Each fixture is spelled the way the minifier spells it: no-substitution template literals, short
* aliased `createRequire` bindings. That is what the packed files look like, so that is what the
* audit has to be able to read.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, test } from 'vitest';
import { auditDependencyClosure, type PackedManifest } from '../lib/shipped-imports.ts';
const tempRoots: string[] = [];
afterEach(() => {
for (const root of tempRoots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
});
/** Lays out a fake installed package: shipped files plus the `dependencies` the manifest declares. */
function fixturePackage(files: Record<string, string>, dependencies: string[] = []): () => void {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-closure-fixture-'));
tempRoots.push(root);
for (const [relative, source] of Object.entries(files)) {
const file = path.join(root, relative);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, source);
}
const manifest: PackedManifest = {
dependencies: Object.fromEntries(dependencies.map((name) => [name, '1.0.0'])),
};
return () => void auditDependencyClosure(root, manifest);
}
/** The workspace-private specifier whose published import broke 0.20.4 (#1577). */
const PRIVATE = '@agent-device/ad-script';
/** Every literal spelling of "resolve this specifier at runtime" that a shipped file can use. */
const LAZY_FORMS: Record<string, string> = {
'dynamic import, minified backtick spelling': 'await import(`SPECIFIER`);',
'dynamic import, quoted spelling': "await import('SPECIFIER');",
'bare require': 'const mod = require(`SPECIFIER`);',
'require.resolve': 'const at = require.resolve(`SPECIFIER`);',
'aliased createRequire result': [
"import { createRequire as t } from 'node:module';",
'var a = t(import.meta.url);',
'const mod = a(`SPECIFIER`);',
].join('\n'),
'immediately invoked createRequire': [
"import { createRequire } from 'node:module';",
'const mod = createRequire(import.meta.url)(`SPECIFIER`);',
].join('\n'),
'createRequire through a namespace import': [
"import * as M from 'node:module';",
'var r = M.createRequire(import.meta.url);',
'const mod = r(`SPECIFIER`);',
].join('\n'),
'require alias declared below its use': [
"import { createRequire as t } from 'node:module';",
'export function load() { return q(`SPECIFIER`); }',
'var q = t(import.meta.url);',
].join('\n'),
};
// The reviewable claim of the gate: a shipped file cannot reach a package the manifest does not
// declare. Without every form below, a command could lazily resolve a workspace-private specifier
// and publish green — the 0.20.4 failure class, reintroduced one resolution form at a time.
for (const [form, template] of Object.entries(LAZY_FORMS)) {
test(`an undeclared private specifier fails the closure audit via ${form}`, () => {
const audit = fixturePackage({ 'dist/cli.js': template.replaceAll('SPECIFIER', PRIVATE) });
assert.throws(audit, (error: Error) => {
assert.match(error.message, /Imported but not declared in "dependencies"/);
assert.match(error.message, /@agent-device\/ad-script in dist\/cli\.js/);
return true;
});
});
test(`a declared dependency satisfies the closure audit via ${form}`, () => {
const audit = fixturePackage({ 'dist/cli.js': template.replaceAll('SPECIFIER', 'yaml') }, [
'yaml',
]);
audit();
});
}
test('a subpath import is attributed to the package that must be declared', () => {
const audit = fixturePackage({ 'dist/cli.js': 'await import(`@limrun/api/client`);' }, [
'@limrun/api',
]);
audit();
});
test('the audit reads every shipped extension, not only the bundled .js files', () => {
for (const file of ['bin/agent-device.mjs', 'dist/legacy.cjs', 'dist/index.d.ts']) {
const audit = fixturePackage({ [file]: `const mod = require(\`${PRIVATE}\`);` });
assert.throws(
audit,
new RegExp(`${PRIVATE.replace('/', '\\/')} in ${file.replace('/', '\\/')}`),
);
}
});
test('builtins and relative specifiers are not dependencies', () => {
const audit = fixturePackage({
'dist/cli.js': [
"import fs from 'node:fs';",
"import { createRequire as t } from 'node:module';",
'var a = t(import.meta.url);',
'const z = a(`zlib`);',
'const u = a(`util`);',
'await import(`./sibling.js`);',
'await import(`../parent.js`);',
].join('\n'),
'dist/sibling.js': 'export const x = 1;',
});
audit();
});
// The other direction of the same audit: a dependency every user installs and no shipped file
// reaches. How `pngjs` stayed in `dependencies` after tsdown started inlining it.
test('a declared dependency nothing imports fails the closure audit', () => {
const audit = fixturePackage({ 'dist/cli.js': 'export const x = 1;' }, ['pngjs']);
assert.throws(audit, /Declared in "dependencies" but never imported[\s\S]*- pngjs/);
});
// Pinned limitations, so the gate's guarantee stays honest about where it stops. Both are covered
// by the runtime half instead: the gate imports every export and runs the CLI paths that load the
// lazy bundles, which resolves computed specifiers for real.
test('a computed specifier is out of scope for the static audit', () => {
const audit = fixturePackage({
'dist/cli.js': [
"import { createRequire as t } from 'node:module';",
'var a = t(import.meta.url);',
'export const load = (name) => a(name);',
'export const scoped = (v) => require(`@agent-device/ad-` + v);',
'export const lazy = (name) => import(name);',
].join('\n'),
});
audit();
});
test('a minified name collision is not mistaken for a require call', () => {
// The packed png-worker-contract.js really does contain `a(h[t],f,g,l,e,m)` in a scope where `a`
// is not the file's `createRequire` result. Attributing that to a dependency would fail the gate
// on a sound package, so only the one-string-argument shape counts.
const audit = fixturePackage({
'dist/cli.js': [
"import { createRequire as t } from 'node:module';",
'var a = t(import.meta.url);',
'export function draw(h, f, g, l, e, m) {',
' const a = (...parts) => parts.length;',
' return a(h[0], f, g, l, e, m);',
'}',
].join('\n'),
});
audit();
});