mirror of
https://github.com/smogon/sprites.git
synced 2026-09-10 19:15:34 -05:00
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:
10
README.md
10
README.md
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user