Files
sprites/tools/build/test/plan.test.ts
Christopher Monsanto 2d83f4a16f Fix review findings in tools/build
- Verify rename sources against recorded stats at copy time; colliding
  rename destinations/sources fall back to running the rule
- Restrict rename detection to single-input, no-deps rules (sheet-style
  rules embed input names in output bytes)
- Include input extensions in the rename template (magick picks output
  format from extensions)
- Adopt only rules with no stored record, never known-dirty ones
- Skip dotfiles in glob (tup parity)
- Complete sheet dep closures (data/lib, lib/root)
- Validate --jobs; reject non-numeric values instead of silently no-oping
- Worker pool drains all workers before rethrowing; per-rule internal
  errors abort scheduling instead of racing db.close()
- SIGKILL all live process groups on second Ctrl-C
- Transactional loadStoredRules snapshot; dry run without existing state
  uses an in-memory db; idempotent DDL
- rule() rejects % placeholders in literal outputs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 22:32:59 -04:00

218 lines
8.3 KiB
TypeScript

import assert from 'node:assert/strict';
import {test} from 'node:test';
import {forEachRule, getRules, resetRules, rule, type RuleDecl} from '../api.ts';
import type {StoredRule, StoredRuleOutput} from '../db.ts';
import {computePlan, type OutputStat, ruleInputSig} from '../plan.ts';
function hashOf(content : string) : Buffer {
return Buffer.from(content.padEnd(32, '\0'));
}
function makeForeach(input : string, outputDir : string) : RuleDecl {
resetRules();
forEachRule(input, {cmds: ['convert %f %o']}, `${outputDir}/%b`);
const decl = getRules()[0]!;
assert(decl !== undefined);
return decl;
}
function stored(decl : RuleDecl, hashes : Map<string, Buffer>,
opts : {ok? : boolean, outputs? : StoredRuleOutput[]} = {}) : StoredRule {
return {
id: 1n,
key: decl.key,
command: decl.command,
display: decl.display,
template: decl.template,
inputSig: ruleInputSig(decl, hashes),
ok: opts.ok ?? true,
inputs: [
...decl.inputs.map(path => ({path, isDep: false, hash: hashes.get(path)!})),
...decl.deps.map(path => ({path, isDep: true, hash: hashes.get(path)!})),
],
outputs: opts.outputs ?? decl.outputs.map(path => ({path, size: 10n, mtimeNs: 100n})),
};
}
function statFrom(entries : Record<string, OutputStat | null>) {
return (path : string) : OutputStat | null => entries[path] ?? null;
}
const GOOD : OutputStat = {size: 10n, mtimeNs: 100n};
test('unchanged rule is clean', () => {
const decl = makeForeach('src/a.png', 'build/out');
const hashes = new Map([['src/a.png', hashOf('A')]]);
const plan = computePlan({
current: [decl],
stored: [stored(decl, hashes)],
hashes,
statOutput: statFrom({'build/out/a.png': GOOD}),
adopt: false,
});
assert.deepEqual(plan.clean, [decl]);
assert.equal(plan.run.length, 0);
});
test('dirty reasons', () => {
const decl = makeForeach('src/a.png', 'build/out');
const hashes = new Map([['src/a.png', hashOf('A')]]);
const cases : [StoredRule, (p : string) => OutputStat | null, string][] = [
[stored(decl, new Map([['src/a.png', hashOf('OLD')]])),
statFrom({'build/out/a.png': GOOD}), 'input-changed'],
[stored(decl, hashes, {ok: false}),
statFrom({'build/out/a.png': GOOD}), 'failed-last-run'],
[stored(decl, hashes), statFrom({}), 'output-missing'],
[stored(decl, hashes),
statFrom({'build/out/a.png': {size: 11n, mtimeNs: 100n}}), 'output-tampered'],
];
for (const [s, statOutput, reason] of cases) {
const plan = computePlan({current: [decl], stored: [s], hashes, statOutput, adopt: false});
assert.deepEqual(plan.run.map(r => r.reason), [reason]);
}
});
test('unknown rule runs as new; removed rule is stale', () => {
const decl = makeForeach('src/a.png', 'build/out');
const gone = makeForeach('src/z.png', 'build/out');
const hashes = new Map([['src/a.png', hashOf('A')], ['src/z.png', hashOf('Z')]]);
const plan = computePlan({
current: [decl],
stored: [stored(gone, hashes)],
hashes,
statOutput: statFrom({}),
adopt: false,
});
assert.deepEqual(plan.run.map(r => r.reason), ['new']);
assert.deepEqual(plan.stale.map(s => s.key), [gone.key]);
});
test('renamed input with identical content matches instead of running', () => {
const oldDecl = makeForeach('src/a.png', 'build/out');
const newDecl = makeForeach('src/b.png', 'build/out');
const hashes = new Map([['src/a.png', hashOf('SAME')], ['src/b.png', hashOf('SAME')]]);
const plan = computePlan({
current: [newDecl],
stored: [stored(oldDecl, hashes)],
hashes,
statOutput: statFrom({'build/out/a.png': GOOD}),
adopt: false,
});
assert.equal(plan.run.length, 0);
assert.deepEqual(plan.renames.map(r => [r.from.outputs[0]!.path, r.decl.outputs[0]]),
[['build/out/a.png', 'build/out/b.png']]);
// the source rule is still deleted afterward
assert.deepEqual(plan.stale.map(s => s.key), [oldDecl.key]);
});
test('rename does not match differing content or tampered source outputs', () => {
const oldDecl = makeForeach('src/a.png', 'build/out');
const newDecl = makeForeach('src/b.png', 'build/out');
const differing = new Map([['src/a.png', hashOf('X')], ['src/b.png', hashOf('Y')]]);
let plan = computePlan({
current: [newDecl],
stored: [stored(oldDecl, differing)],
hashes: differing,
statOutput: statFrom({'build/out/a.png': GOOD}),
adopt: false,
});
assert.deepEqual(plan.run.map(r => r.reason), ['new']);
const same = new Map([['src/a.png', hashOf('SAME')], ['src/b.png', hashOf('SAME')]]);
plan = computePlan({
current: [newDecl],
stored: [stored(oldDecl, same)],
hashes: same,
statOutput: statFrom({'build/out/a.png': {size: 99n, mtimeNs: 100n}}),
adopt: false,
});
assert.deepEqual(plan.run.map(r => r.reason), ['new']);
});
test('rename is restricted to single-input, no-deps rules', () => {
// multi-input: same output basename, same content, but never rename-matched
resetRules();
rule(['src/a.png', 'src/b.png'], {cmds: ['c %f %o']}, 'out1/x.png');
rule(['src/a2.png', 'src/b2.png'], {cmds: ['c %f %o']}, 'out2/x.png');
const [multiOld, multiNew] = getRules() as [RuleDecl, RuleDecl];
const multiHashes = new Map([
['src/a.png', hashOf('A')], ['src/b.png', hashOf('B')],
['src/a2.png', hashOf('A')], ['src/b2.png', hashOf('B')],
]);
let plan = computePlan({
current: [multiNew],
stored: [stored(multiOld, multiHashes)],
hashes: multiHashes,
statOutput: statFrom({'out1/x.png': GOOD}),
adopt: false,
});
assert.deepEqual(plan.run.map(r => r.reason), ['new']);
// dep-bearing: same template and sig, but never rename-matched
resetRules();
forEachRule('src/a.png', {deps: 'src/d.json', cmds: ['c %f %o']}, 'out/%b');
forEachRule('src/b.png', {deps: 'src/d.json', cmds: ['c %f %o']}, 'out/%b');
const [depOld, depNew] = getRules() as [RuleDecl, RuleDecl];
const depHashes = new Map([
['src/a.png', hashOf('SAME')], ['src/b.png', hashOf('SAME')],
['src/d.json', hashOf('D')],
]);
plan = computePlan({
current: [depNew],
stored: [stored(depOld, depHashes)],
hashes: depHashes,
statOutput: statFrom({'out/a.png': GOOD}),
adopt: false,
});
assert.deepEqual(plan.run.map(r => r.reason), ['new']);
});
test('rename does not match across input extension changes', () => {
const oldDecl = makeForeach('src/a.png', 'build/out');
resetRules();
forEachRule('src/a.gif', {cmds: ['convert %f %o']}, 'build/out/%b');
const newDecl = getRules()[0]!;
const hashes = new Map([['src/a.png', hashOf('SAME')], ['src/a.gif', hashOf('SAME')]]);
const plan = computePlan({
current: [newDecl],
stored: [stored(oldDecl, hashes)],
hashes,
statOutput: statFrom({'build/out/a.png': GOOD}),
adopt: false,
});
assert.deepEqual(plan.run.map(r => r.reason), ['new']);
});
test('adopt does not bless known-dirty rules', () => {
const decl = makeForeach('src/a.png', 'build/out');
const plan = computePlan({
current: [decl],
stored: [stored(decl, new Map([['src/a.png', hashOf('OLD')]]))],
hashes: new Map([['src/a.png', hashOf('NEW')]]),
statOutput: statFrom({'build/out/a.png': GOOD}),
adopt: true,
});
assert.equal(plan.adopt.length, 0);
assert.deepEqual(plan.run.map(r => r.reason), ['input-changed']);
});
test('adopt records existing outputs instead of running', () => {
resetRules();
rule('src/a.png', {cmds: ['convert %f %o']}, 'build/present.png');
rule('src/b.png', {cmds: ['convert %f %o']}, 'build/absent.png');
const [present, absent] = getRules() as [RuleDecl, RuleDecl];
const hashes = new Map([['src/a.png', hashOf('A')], ['src/b.png', hashOf('B')]]);
const plan = computePlan({
current: [present, absent],
stored: [],
hashes,
statOutput: statFrom({'build/present.png': GOOD}),
adopt: true,
});
assert.deepEqual(plan.adopt, [present]);
assert.deepEqual(plan.run.map(r => r.decl), [absent]);
});