mirror of
https://github.com/smogon/sprites.git
synced 2026-08-18 07:16:46 -05:00
- 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>
140 lines
4.5 KiB
TypeScript
140 lines
4.5 KiB
TypeScript
|
|
import {createHash} from 'crypto';
|
|
|
|
import type {RuleDecl} from './api.ts';
|
|
import type {StoredRule} from './db.ts';
|
|
|
|
export type DirtyReason = 'new' | 'failed-last-run' | 'input-changed'
|
|
| 'output-missing' | 'output-tampered';
|
|
|
|
export interface OutputStat {
|
|
size : bigint;
|
|
mtimeNs : bigint;
|
|
}
|
|
|
|
export interface Plan {
|
|
clean : RuleDecl[];
|
|
run : {decl : RuleDecl, reason : DirtyReason}[];
|
|
renames : {decl : RuleDecl, from : StoredRule}[];
|
|
stale : StoredRule[];
|
|
adopt : RuleDecl[];
|
|
}
|
|
|
|
export function inputSig(hashes : Buffer[]) : Buffer {
|
|
const h = createHash('sha256');
|
|
for (const hash of hashes) {
|
|
h.update(hash);
|
|
}
|
|
return h.digest();
|
|
}
|
|
|
|
export function ruleInputSig(decl : RuleDecl, hashes : Map<string, Buffer>) : Buffer {
|
|
return inputSig([...decl.inputs, ...decl.deps].map(p => hashes.get(p)!));
|
|
}
|
|
|
|
function renameKey(template : string, sig : Buffer) : string {
|
|
return template + '\0' + sig.toString('hex');
|
|
}
|
|
|
|
export function computePlan(opts : {
|
|
current : RuleDecl[],
|
|
stored : StoredRule[],
|
|
hashes : Map<string, Buffer>,
|
|
statOutput : (path : string) => OutputStat | null,
|
|
adopt : boolean,
|
|
}) : Plan {
|
|
const {current, stored, hashes, statOutput, adopt} = opts;
|
|
|
|
const storedByKey = new Map<string, StoredRule>();
|
|
const renameIndex = new Map<string, StoredRule[]>();
|
|
for (const s of stored) {
|
|
storedByKey.set(s.key, s);
|
|
// Rename detection is restricted to single-input, no-deps rules:
|
|
// sheet-style rules embed input *names* in their output bytes, so
|
|
// identical input content does not imply identical outputs there.
|
|
if (s.ok && s.inputs.length === 1 && !s.inputs[0]!.isDep) {
|
|
const key = renameKey(s.template, s.inputSig);
|
|
let list = renameIndex.get(key);
|
|
if (list === undefined) {
|
|
renameIndex.set(key, list = []);
|
|
}
|
|
list.push(s);
|
|
}
|
|
}
|
|
|
|
const currentKeys = new Set(current.map(r => r.key));
|
|
const plan : Plan = {
|
|
clean: [],
|
|
run: [],
|
|
renames: [],
|
|
stale: stored.filter(s => !currentKeys.has(s.key)),
|
|
adopt: [],
|
|
};
|
|
|
|
// A stored rule's outputs are intact iff every recorded output exists on
|
|
// disk with its recorded stat. Rename sources must pass this (never copy
|
|
// tampered or unverified bytes).
|
|
const outputsIntact = (s : StoredRule) : boolean =>
|
|
s.outputs.every(o => {
|
|
if (o.size === null || o.mtimeNs === null) {
|
|
return false;
|
|
}
|
|
const st = statOutput(o.path);
|
|
return st !== null && st.size === o.size && st.mtimeNs === o.mtimeNs;
|
|
});
|
|
|
|
const dirtyReason = (s : StoredRule) : DirtyReason | null => {
|
|
if (!s.ok) {
|
|
return 'failed-last-run';
|
|
}
|
|
for (const inp of s.inputs) {
|
|
if (!hashes.get(inp.path)?.equals(inp.hash)) {
|
|
return 'input-changed';
|
|
}
|
|
}
|
|
for (const o of s.outputs) {
|
|
const st = statOutput(o.path);
|
|
if (st === null) {
|
|
return 'output-missing';
|
|
}
|
|
if (st.size !== o.size || st.mtimeNs !== o.mtimeNs) {
|
|
return 'output-tampered';
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
for (const decl of current) {
|
|
const s = storedByKey.get(decl.key);
|
|
let reason : DirtyReason | null;
|
|
if (s !== undefined) {
|
|
reason = dirtyReason(s);
|
|
if (reason === null) {
|
|
plan.clean.push(decl);
|
|
continue;
|
|
}
|
|
} else {
|
|
reason = 'new';
|
|
if (decl.inputs.length === 1 && decl.deps.length === 0) {
|
|
const sig = ruleInputSig(decl, hashes);
|
|
const candidates = renameIndex.get(renameKey(decl.template, sig)) ?? [];
|
|
const from = candidates.find(c =>
|
|
c.outputs.length === decl.outputs.length && outputsIntact(c));
|
|
if (from !== undefined) {
|
|
plan.renames.push({decl, from});
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
// Adoption only covers rules we know nothing about; a known-dirty
|
|
// rule (input-changed, tampered, failed) must actually run.
|
|
if (adopt && reason === 'new' && decl.outputs.every(p => statOutput(p) !== null)) {
|
|
plan.adopt.push(decl);
|
|
} else {
|
|
plan.run.push({decl, reason});
|
|
}
|
|
}
|
|
|
|
return plan;
|
|
}
|