Files
callstack__agent-device/scripts/check-packaged-runner-swift.ts
Michał Pierzchała df0a0f7fd2 perf(package): strip comments from the Apple runner source the npm package ships (#2467)
* perf(package): strip comments from the Apple runner source the npm package ships

The packager copies apple/runner/** into dist/ as Swift source, removing only
its AGENT_DEVICE_RUNNER_UNIT_TESTS blocks, so doc comments and design notes were
downloaded on every install: 71.9 kB of 441.2 kB of packaged runner Swift.

Add a lexical scanner for the removal. A regex cannot do this: `//` and `/*`
open a comment only in code position, raw literals move their own delimiter and
escape with the `#` count, interpolation segments hold code and further
literals, and Swift block comments nest. A construct the scanner cannot account
for throws at packaging time instead of shipping Swift that does not compile.

* fix(package): keep Swift regex literals out of the comment scanner

`#/foo//bar/#` is a valid extended regex literal with no comment in it, but the
scanner only knew the `#"` raw-string family, so it read the literal's `//` as a
line comment and shipped `let pattern = #/foo` — Swift that does not compile.
Add `#/…/#` and `##/…/##` as a literal context: matching `#` counts, the
single- and multi-line forms, Swift's own-line rule for a multi-line closing
delimiter, and the `\/` escape that keeps one from closing early.

Bare `/…/` literals stay unresolvable, because the same `/` opens a comment,
divides, and starts a regex literal, and only the parse separates them. Where
one could begin — an expression position whose `/` is not followed by a space,
a tab or `)` — packaging throws by file and line instead of rewriting bytes it
cannot prove are code. Divisions (`width/2`, `Double(3)/Double(4)`), the
recording scripts' shebang and `(/)` keep flowing through.

* fix(package): keep the packaged runner source on the checkout's line numbers

`dist/apple/runner/**` is the Swift a user's `xcodebuild` and the runner name a
file and line in (it lands in runner.log), so those numbers are only worth
reading if they point at the same line of `apple/runner/**`. Both rewriting
passes now empty the lines they remove instead of deleting them: comment removal
(889 lines, 889 B) and the pre-existing unit-test `#if` block strip, which was
moving everything below a block by up to 883 lines (3,737 lines, 3,737 B).

`dist/apple/runner/` 555,907 B -> 488,635 B (-67,272 B, -12.1%); its Swift alone
441,196 B -> 373,924 B (-15.2%). Parity costs 4,626 B of the 71,898 B the
previous head saved.

Nothing in the repo compiles the packaged source, so a mis-lex that failed to
throw would ship Swift that does not build and no gate would see it. Add
`pnpm check:packaged-runner-swift`: it packages into a throwaway root and asserts
line-count parity plus the line of every declaration each packaged file still
carries, then runs `swiftc -parse` over all 44 files. The parse half reports
itself skipped where no Swift toolchain exists, so the gate is declared on the
macOS lane, where both halves run.
2026-09-11 12:00:02 +02:00

182 lines
7.9 KiB
TypeScript

// `pnpm check:packaged-runner-swift` — hold the two properties of the Apple runner source the npm
// package ships that nothing else in this repo can observe.
//
// `apple/runner/**` is rewritten on its way into `dist/apple/runner/**`: unit-test `#if` blocks
// come out, and `scripts/strip-swift-comments.mjs` takes the comments out (#2461). Nothing in the
// repo compiles, imports or reads the result — the first consumer is a user's `xcodebuild` — so
// both the Swift staying valid and its line numbers staying meaningful are claims only a check
// makes. This is that check:
//
// 1. Line parity. Every rewritten line is emptied, never deleted, so packaged line N is
// checkout line N. An `xcodebuild`/runner failure names the packaged path (it lands in
// `runner.log`), and the number it prints is only worth reading if it points at the same
// line of `apple/runner/**`. Asserted two ways per file: the line count, and the line every
// declaration the packaged file still carries sits on.
// 2. It parses. `xcrun swiftc -parse` over every packaged Swift file. The scanner throws on a
// construct it cannot lex rather than shipping, but a future mis-lex that fails to throw
// would ship Swift that does not compile, and no other gate would notice.
//
// Packaging runs into a throwaway root over a symlinked `apple/`, so the gate reads the bytes the
// packager really writes without touching `dist/`.
//
// Property 1 needs no toolchain and runs everywhere. Property 2 needs Swift: on a host without it
// (Linux CI) the parse is reported as skipped rather than failing, which is why the gate is
// declared on the macOS lane, where it is the half that matters.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { runCmdSync } from '@agent-device/host-kit/command';
const repoRoot = path.resolve(import.meta.dirname, '..');
const packageAppleRunnerScript = path.join(repoRoot, 'scripts/package-apple-runner-source.mjs');
const SOURCE_DIR = path.join('apple', 'runner');
const PACKAGED_DIR = path.join('dist', 'apple', 'runner');
/**
* A Swift declaration and its name. Matched per line and used as a line-number anchor, so it is
* deliberately independent of the rewrite: it reads the name out of whatever the line still holds
* rather than comparing the two lines, which a removed trailing comment would break.
*/
const DECLARATION = /\b(actor|class|enum|extension|func|protocol|struct)\s+([A-Za-z_]\w*)/;
/** The declaration each line declares, keyed by 1-based line number. */
export function declarationsByLine(text: string): Map<number, string> {
const declarations = new Map<number, string>();
for (const [index, line] of text.split('\n').entries()) {
const match = DECLARATION.exec(line);
if (match) declarations.set(index + 1, `${match[1]} ${match[2]}`);
}
return declarations;
}
/** Every way the packaged file's line numbering can disagree with the checkout's. */
export function parityFailures(
relativePath: string,
sourceText: string,
packagedText: string,
): string[] {
const sourceLines = sourceText.split('\n').length;
const packagedLines = packagedText.split('\n').length;
if (sourceLines !== packagedLines) {
return [
`${relativePath}: packaged source has ${packagedLines} lines, checkout has ${sourceLines}. ` +
'Packaging must empty a removed line, not delete it.',
];
}
const sourceDeclarations = declarationsByLine(sourceText);
return [...declarationsByLine(packagedText)]
.filter(([line, declaration]) => sourceDeclarations.get(line) !== declaration)
.map(
([line, declaration]) =>
`${relativePath}:${line}: packaged \`${declaration}\` is \`` +
`${sourceDeclarations.get(line) ?? '(blank)'}\` at the same line of the checkout.`,
);
}
function swiftFilesUnder(root: string, relativeDir = ''): string[] {
const entries = fs.readdirSync(path.join(root, relativeDir), { withFileTypes: true });
return entries.flatMap((entry) => {
const relativePath = path.join(relativeDir, entry.name);
if (entry.isDirectory()) return swiftFilesUnder(root, relativePath);
return entry.isFile() && entry.name.endsWith('.swift') ? [relativePath] : [];
});
}
/**
* Packages the runner into a disposable root. `apple/` is symlinked rather than copied: the
* packager only reads it, and the output path it derives (`<root>/dist/apple/runner`) is what has
* to land outside the checkout.
*/
function packageIntoScratchRoot(scratchRoot: string): string {
fs.symlinkSync(path.join(repoRoot, 'apple'), path.join(scratchRoot, 'apple'), 'dir');
runCmdSync(process.execPath, [packageAppleRunnerScript, '--root', scratchRoot, '--quiet']);
return path.join(scratchRoot, PACKAGED_DIR);
}
function lineParityFailures(packagedRoot: string, relativePaths: readonly string[]): string[] {
return relativePaths.flatMap((relativePath) => {
const sourcePath = path.join(repoRoot, SOURCE_DIR, relativePath);
if (!fs.existsSync(sourcePath)) {
return [`${path.join(PACKAGED_DIR, relativePath)} has no counterpart in ${SOURCE_DIR}.`];
}
return parityFailures(
path.join(PACKAGED_DIR, relativePath),
fs.readFileSync(sourcePath, 'utf8'),
fs.readFileSync(path.join(packagedRoot, relativePath), 'utf8'),
);
});
}
/** The Swift compiler, or undefined on a host that has none. */
function findSwiftc(): string | undefined {
if (process.platform !== 'darwin') return undefined;
try {
const found = runCmdSync('xcrun', ['--find', 'swiftc'], { allowFailure: true });
return found.exitCode === 0 && found.stdout.trim() !== '' ? found.stdout.trim() : undefined;
} catch {
// No `xcrun` on PATH at all: a macOS host without the command line tools.
return undefined;
}
}
/**
* Parses each packaged file on its own, so a diagnostic names the file that caused it and one
* file's top-level code cannot change how another is read.
*/
function swiftParseFailures(
swiftc: string,
packagedRoot: string,
relativePaths: readonly string[],
tmpDir: string,
): string[] {
return relativePaths.flatMap((relativePath) => {
const result = runCmdSync(swiftc, ['-parse', path.join(packagedRoot, relativePath)], {
allowFailure: true,
env: { ...process.env, TMPDIR: tmpDir },
});
if (result.exitCode === 0) return [];
// Swift names the scratch copy it was handed; say where that file comes from instead.
const detail = (result.stderr || result.stdout)
.replaceAll(`${packagedRoot}${path.sep}`, `${PACKAGED_DIR}${path.sep}`)
.trim();
return [`${path.join(PACKAGED_DIR, relativePath)} does not parse after packaging:\n${detail}`];
});
}
function main(): number {
const scratchRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-packaged-swift-'));
try {
const packagedRoot = packageIntoScratchRoot(scratchRoot);
const relativePaths = swiftFilesUnder(packagedRoot);
const swiftc = findSwiftc();
const failures = [
...lineParityFailures(packagedRoot, relativePaths),
...(swiftc === undefined
? []
: swiftParseFailures(swiftc, packagedRoot, relativePaths, scratchRoot)),
];
if (failures.length > 0) {
process.stderr.write(`${failures.join('\n')}\n`);
process.stdout.write(
`packaged runner Swift: ${failures.length} failure(s) over ${relativePaths.length} files.\n`,
);
return 1;
}
const parse =
swiftc === undefined
? 'parse skipped, no Swift toolchain on this host'
: `all ${relativePaths.length} parse under swiftc -parse`;
process.stdout.write(
`packaged runner Swift: ok — ${relativePaths.length} files keep the checkout's line ` +
`numbering; ${parse}.\n`,
);
return 0;
} finally {
fs.rmSync(scratchRoot, { recursive: true, force: true });
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) process.exit(main());