Add a refactor verb, for what a change did to the published files

This repo owns xy/ and xyicons/ now, so nothing else writes there and asking
rsync what it would delete answers a question nobody has. The one worth asking
is whether an edit changed what we ship, which until now meant materializing a
deploy twice and diffing 300M of gifs.

Nothing has to be materialized. A finish already ends in an ActionQueue holding
one op per published file, and the bytes behind each are identified already: a
built artifact's CAS path spells its own digest, so only raw sources get read.
Digest those, keep the map in .build/ beside the rest of the build state, and a
comparison is a dictionary diff.

    node tools/deploy/index.ts refactor --record   # on what you are comparing to
    node tools/deploy/index.ts refactor            # after the change

prints + for a name that appeared, - for one that went, M for one whose bytes
moved, and exits non-zero if any did. Which is tup's refactor a layer out: it
checked that a Tupfile edit left the build graph alone, and this checks the
names and bytes the deploy blocks decide, where a lost alias or a rename
actually shows up.

Recording rather than failing when a file has no baseline yet, so the first run
in a checkout is useful instead of an error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christopher Monsanto
2026-08-23 02:01:02 -04:00
parent c076da9c0b
commit da25d70f78
2 changed files with 106 additions and 0 deletions

View File

@@ -68,6 +68,8 @@ $ pnpm deploy assets # run a named deploy
$ node tools/deploy/index.ts build smogon.build.ts # build one deploy's rules
$ node tools/deploy/index.ts run smogon.build.ts -o deploy/smogon
$ node tools/deploy/index.ts inspect src/minisprites/items/ileftovers.png -o /tmp/out
$ node tools/deploy/index.ts refactor --record # remember what the deploys publish
$ node tools/deploy/index.ts refactor # and what a change did to it
```
`run` materializes a deploy to a directory (`--link` hardlinks, `--tar`
@@ -75,6 +77,14 @@ writes a tar file) without uploading anything. `inspect` builds every rule
that consumes the given source paths and copies the outputs out under
readable names for eyeballing.
`refactor` answers "did that change anything we ship". It builds, runs the
deploy blocks, and digests the bytes landing at every published name, then
prints what was added, removed or modified since the last `--record` and exits
non-zero if anything was. A built artifact's CAS path already spells its
digest, so only raw sources are read; the baseline sits in `.build/` and is
per-checkout. Record on the commit you are comparing against, make the change,
run it again.
Useful flags: `-j <n>` parallelism, `-n` dry run, `-v` verbose,
`--fail-fast` stop after the first failure.

View File

@@ -1,5 +1,6 @@
import {spawn, type ChildProcess} from 'node:child_process';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as nodePath from 'node:path';
@@ -8,6 +9,7 @@ import {parseArgs} from 'node:util';
import * as artifact from '../build/artifact.ts';
import {casPath} from '../build/cas.ts';
import {hashFile} from '../build/hash.ts';
import {loadConfig} from '../build/config.ts';
import {build} from '../build/driver.ts';
import {BuildError} from '../build/errors.ts';
@@ -26,6 +28,7 @@ let DB_PATH = '.build/db.sqlite';
let LOCK_PATH = '.build/lock.sqlite';
let CAS_DIR = '.build/cas';
let TMP_DIR = '.build/tmp';
let BASELINE_PATH = '.build/outputs.json';
type CommonOpts = {
jobs: string,
@@ -39,6 +42,7 @@ type VerbOpts = CommonOpts & {
output?: string,
link?: boolean,
tar?: boolean,
record?: boolean,
};
let USAGE = `usage: node tools/deploy/index.ts <command> [options]
@@ -52,6 +56,8 @@ commands:
run <file> -o <dir> build, finish, and materialize to a directory (or tar file)
inspect <paths...> -o <dir> build every rule touching the given source paths and copy
the outputs out
refactor [files...] report how the published files differ from the last
--record (default: all *.build.ts)
options:
-j, --jobs <n> number of parallel jobs (default: all cores)
@@ -59,6 +65,7 @@ options:
--fail-fast stop scheduling new rules after the first failure
--config <file> config file (default: build.config)
-o, --output <dir> run/inspect/deploy: output directory (a file with --tar)
--record refactor: overwrite the baseline instead of checking it
--link run: hardlink instead of copying
--tar run: write a tar file
-v, --verbose print more detail
@@ -86,6 +93,9 @@ let VERB_OPTIONS = {
inspect: {
output: {type: 'string', short: 'o'},
},
refactor: {
record: {type: 'boolean'},
},
} as const;
function requireOutput(opts: VerbOpts): string {
@@ -309,6 +319,89 @@ async function cmdRun(file: string, opts: VerbOpts): Promise<void> {
});
}
// What a deploy publishes: the digest of the bytes landing at each name.
// tup's `refactor` checked that a Tupfile edit left the build graph alone;
// this checks the same thing one layer out, over the names and bytes the
// deploy blocks decide, which is where a rename or a lost alias shows up.
//
// node tools/deploy/index.ts refactor --record # before the change
// node tools/deploy/index.ts refactor # after it
//
// The baseline lives in .build/ beside the rest of the build state, so it is
// per-checkout and never committed.
async function outputs(aq: ActionQueue): Promise<Map<string, string>> {
let out = new Map<string, string>();
for (let e of aq.log) {
if (e.type !== 'Op') continue;
if (e.op.type === 'Write') {
out.set(e.dst, crypto.createHash('sha256').update(e.op.data).digest('hex'));
continue;
}
// A CAS path spells its own digest, so only raw sources are read.
let cas = new RegExp(`^${CAS_DIR}/[0-9a-f]{2}/([0-9a-f]{64})\\.`).exec(e.op.src);
out.set(e.dst, cas ? cas[1]! : (await hashFile(e.op.src)).toString('hex'));
}
return out;
}
function diffOutputs(was: Record<string, string>, now: Map<string, string>): string[] {
let lines = [];
for (let [dst, digest] of now) {
let before = was[dst];
if (before === undefined) {
lines.push(`+ ${dst}`);
} else if (before !== digest) {
lines.push(`M ${dst}`);
}
}
for (let dst of Object.keys(was)) {
if (!now.has(dst)) {
lines.push(`- ${dst}`);
}
}
return lines.sort((a, b) => a.slice(2) < b.slice(2) ? -1 : 1);
}
async function cmdRefactor(files: string[], opts: VerbOpts): Promise<void> {
setConfig(await loadConfig(opts.config));
let deployFiles = files.length > 0 ? files : await discoverDeployFiles();
let specs = await importDeploys(deployFiles);
let baseline: Record<string, Record<string, string>> = {};
try {
baseline = JSON.parse(await fs.readFile(BASELINE_PATH, 'utf8')) as typeof baseline;
} catch {
// No baseline yet; every file below records one.
}
process.exitCode = await buildThen(artifact.getDecls(), opts, false, async () => {
let changed = false;
for (let file of deployFiles) {
let aq = await runFinish(finishOf(specs, file), Boolean(opts.verbose));
if (aq === null) {
return 1;
}
let now = await outputs(aq);
let was = baseline[file];
if (opts.record || was === undefined) {
console.log(`${file}: recorded ${now.size} files${was === undefined && !opts.record ? ' (no baseline)' : ''}`);
} else {
let lines = diffOutputs(was, now);
console.log(`${file}: ${now.size} files, ${lines.length} changed`);
for (let line of lines) {
console.log(` ${line}`);
}
changed ||= lines.length > 0;
continue;
}
baseline[file] = Object.fromEntries([...now].sort((a, b) => a[0] < b[0] ? -1 : 1));
}
if (opts.record || Object.keys(baseline).length > 0) {
await fs.mkdir(nodePath.dirname(BASELINE_PATH), {recursive: true});
await fs.writeFile(BASELINE_PATH, JSON.stringify(baseline, null, 4) + '\n');
}
return changed ? 1 : 0;
});
}
function slugOf(decl: artifact.RuleDecl): string {
let template = decl.displayTemplate ?? decl.cmds[0] ?? '';
let slug = template.replace(/%[a-zA-Z0-9]+/g, ' ')
@@ -389,6 +482,7 @@ async function main(argv: string[]): Promise<void> {
output: v.output as string | undefined,
link: Boolean(v.link),
tar: Boolean(v.tar),
record: Boolean(v.record),
};
switch (verb) {
case 'build':
@@ -407,6 +501,8 @@ async function main(argv: string[]): Promise<void> {
throw new BuildError('inspect takes at least one source path');
}
return cmdInspect(positionals, opts);
case 'refactor':
return cmdRefactor(positionals, opts);
}
}