fix: preserve every generated design variant in plan-design-review

This commit is contained in:
Matt Van Horn 2026-05-26 21:27:34 -07:00
parent 22f8c7f4e1
commit 0930beb099
No known key found for this signature in database
2 changed files with 345 additions and 7 deletions

View File

@ -32,6 +32,8 @@ import {
shutdownDaemon, shutdownDaemon,
} from "./daemon-client"; } from "./daemon-client";
import { spawn as nodeSpawn } from "child_process"; import { spawn as nodeSpawn } from "child_process";
import fs from "fs";
import path from "path";
function parseArgs(argv: string[]): { function parseArgs(argv: string[]): {
command: string; command: string;
@ -121,8 +123,8 @@ async function runSetup(): Promise<void> {
} }
} }
async function main(): Promise<void> { async function main(argv = process.argv): Promise<void> {
const { command, flags, positionals } = parseArgs(process.argv); const { command, flags, positionals } = parseArgs(argv);
if (!COMMANDS.has(command)) { if (!COMMANDS.has(command)) {
console.error(`Unknown command: ${command}`); console.error(`Unknown command: ${command}`);
@ -132,7 +134,7 @@ async function main(): Promise<void> {
switch (command) { switch (command) {
case "generate": case "generate":
await generate({ await generateWithRoundArtifacts({
brief: flags.brief as string, brief: flags.brief as string,
briefFile: flags["brief-file"] as string, briefFile: flags["brief-file"] as string,
output: (flags.output as string) || "/tmp/gstack-mockup.png", output: (flags.output as string) || "/tmp/gstack-mockup.png",
@ -206,7 +208,7 @@ async function main(): Promise<void> {
break; break;
case "iterate": case "iterate":
await iterate({ await iterateWithRoundArtifacts({
session: flags.session as string, session: flags.session as string,
feedback: flags.feedback as string, feedback: flags.feedback as string,
output: (flags.output as string) || "/tmp/gstack-iterate.png", output: (flags.output as string) || "/tmp/gstack-iterate.png",
@ -318,6 +320,129 @@ async function main(): Promise<void> {
} }
} }
const ROUND_MANIFEST = ".gstack-design-rounds.json";
interface RoundAttempt {
label: string;
path: string;
success: boolean;
error?: string;
}
type RoundManifest = Record<string, RoundAttempt[]>;
interface RoundArtifactPlan {
aliasOutput: string;
primaryOutput: string;
roundKey: string;
label: string;
}
function roundBaseName(outputPath: string): string | null {
const parsed = path.parse(outputPath);
if (parsed.ext !== ".png") return null;
if (parsed.name === "variant-recommended") return parsed.name;
if (/^variant-iteration-\d+$/.test(parsed.name)) return parsed.name;
return null;
}
function labelForIndex(index: number): string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
if (index < alphabet.length) return alphabet[index];
return `${index + 1}`;
}
function roundKey(outputPath: string, baseName: string): string {
return path.join(path.dirname(outputPath), baseName);
}
function readRoundManifest(dir: string): RoundManifest {
const manifestPath = path.join(dir, ROUND_MANIFEST);
if (!fs.existsSync(manifestPath)) return {};
try {
return JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as RoundManifest;
} catch {
return {};
}
}
function writeRoundManifest(dir: string, manifest: RoundManifest): void {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, ROUND_MANIFEST), JSON.stringify(manifest, null, 2));
}
function planRoundArtifacts(outputPath: string): RoundArtifactPlan | null {
const baseName = roundBaseName(outputPath);
if (!baseName) return null;
const dir = path.dirname(outputPath);
const manifest = readRoundManifest(dir);
const key = roundKey(outputPath, baseName);
const attempts = manifest[key] || [];
const label = labelForIndex(attempts.length);
return {
aliasOutput: outputPath,
primaryOutput: path.join(dir, `${baseName}-${label}.png`),
roundKey: key,
label,
};
}
function recordRoundAttempt(plan: RoundArtifactPlan, success: boolean, error?: string): void {
const dir = path.dirname(plan.aliasOutput);
const manifest = readRoundManifest(dir);
const attempts = manifest[plan.roundKey] || [];
const existing = attempts.find(attempt => attempt.label === plan.label);
const attempt = { label: plan.label, path: plan.primaryOutput, success, error };
if (existing) {
Object.assign(existing, attempt);
} else {
attempts.push(attempt);
}
manifest[plan.roundKey] = attempts;
writeRoundManifest(dir, manifest);
}
function copyRoundAlias(plan: RoundArtifactPlan): void {
if (plan.primaryOutput === plan.aliasOutput) return;
fs.copyFileSync(plan.primaryOutput, plan.aliasOutput);
}
async function generateWithRoundArtifacts(options: Parameters<typeof generate>[0]): Promise<void> {
const plan = planRoundArtifacts(options.output);
if (!plan) {
await generate(options);
return;
}
try {
await generate({ ...options, output: plan.primaryOutput });
recordRoundAttempt(plan, true);
copyRoundAlias(plan);
} catch (err: any) {
recordRoundAttempt(plan, false, err.message || String(err));
throw err;
}
}
async function iterateWithRoundArtifacts(options: Parameters<typeof iterate>[0]): Promise<void> {
const plan = planRoundArtifacts(options.output);
if (!plan) {
await iterate(options);
return;
}
try {
await iterate({ ...options, output: plan.primaryOutput });
recordRoundAttempt(plan, true);
copyRoundAlias(plan);
} catch (err: any) {
recordRoundAttempt(plan, false, err.message || String(err));
throw err;
}
}
/** /**
* Default `$D compare --serve` path: ensure the persistent daemon is up, * Default `$D compare --serve` path: ensure the persistent daemon is up,
* publish the board, open the browser to its URL, then exit. The daemon * publish the board, open the browser to its URL, then exit. The daemon
@ -400,7 +525,88 @@ async function resolveImagePaths(input: string): Promise<string[]> {
} }
// Comma-separated or single path // Comma-separated or single path
return input.split(",").map(p => p.trim()); const resolved: string[] = [];
const missing: string[] = [];
for (const imagePath of input.split(",").map(p => p.trim()).filter(Boolean)) {
const roundImages = resolveRoundImageAlias(imagePath);
if (roundImages) {
resolved.push(...roundImages.paths);
missing.push(...roundImages.missing);
} else {
resolved.push(imagePath);
if (!fs.existsSync(imagePath)) missing.push(path.basename(imagePath));
}
}
if (missing.length > 0) {
throw new Error(`Missing generated design variants: ${missing.join(", ")}`);
}
return resolved;
}
function resolveRoundImageAlias(imagePath: string): { paths: string[]; missing: string[] } | null {
const baseName = roundBaseName(imagePath);
if (!baseName) return null;
const dir = path.dirname(imagePath);
const manifest = readRoundManifest(dir);
const attempts = manifest[roundKey(imagePath, baseName)] || [];
if (attempts.length > 0) {
return {
paths: attempts.filter(attempt => attempt.success && fs.existsSync(attempt.path)).map(attempt => attempt.path),
missing: attempts
.filter(attempt => !attempt.success || !fs.existsSync(attempt.path))
.map(attempt => `${baseName}-${attempt.label}.png`),
};
}
const discovered = discoverRoundImages(dir, baseName);
if (discovered.paths.length > 0 || discovered.missing.length > 0) {
return discovered;
}
if (fs.existsSync(imagePath)) {
return { paths: [imagePath], missing: [] };
}
return { paths: [], missing: [path.basename(imagePath)] };
}
function discoverRoundImages(dir: string, baseName: string): { paths: string[]; missing: string[] } {
if (!fs.existsSync(dir)) return { paths: [], missing: [] };
const matches = fs.readdirSync(dir)
.map(name => {
const match = name.match(new RegExp(`^${escapeRegExp(baseName)}-([A-Z])\\.png$`));
return match ? { label: match[1], name } : null;
})
.filter((match): match is { label: string; name: string } => match !== null)
.sort((a, b) => a.label.localeCompare(b.label));
if (matches.length === 0) return { paths: [], missing: [] };
const highestIndex = matches[matches.length - 1].label.charCodeAt(0) - 65;
const byLabel = new Map(matches.map(match => [match.label, match.name]));
const paths: string[] = [];
const missing: string[] = [];
for (let i = 0; i <= highestIndex; i++) {
const label = labelForIndex(i);
const name = byLabel.get(label);
if (name) {
paths.push(path.join(dir, name));
} else {
missing.push(`${baseName}-${label}.png`);
}
}
return { paths, missing };
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
// Self-execution shortcut: when invoked with --daemon-mode, this same // Self-execution shortcut: when invoked with --daemon-mode, this same
@ -408,14 +614,21 @@ async function resolveImagePaths(input: string): Promise<string[]> {
// the production install to a single executable; daemon-client.ts spawns // the production install to a single executable; daemon-client.ts spawns
// `<this binary> --daemon-mode` (or `bun run cli.ts --daemon-mode` in dev) // `<this binary> --daemon-mode` (or `bun run cli.ts --daemon-mode` in dev)
// rather than relying on a separate daemon.ts file at a known path. // rather than relying on a separate daemon.ts file at a known path.
if (process.argv.includes("--daemon-mode")) { if (import.meta.main && process.argv.includes("--daemon-mode")) {
const { start } = await import("./daemon"); const { start } = await import("./daemon");
start(); start();
// start() binds Bun.serve and registers signal handlers; this branch // start() binds Bun.serve and registers signal handlers; this branch
// never falls through to main(). Process stays alive on the bound port. // never falls through to main(). Process stays alive on the bound port.
} else { } else if (import.meta.main) {
main().catch((err) => { main().catch((err) => {
console.error(err.message || err); console.error(err.message || err);
process.exit(1); process.exit(1);
}); });
} }
export {
main,
resolveImagePaths,
planRoundArtifacts,
resolveRoundImageAlias,
};

View File

@ -0,0 +1,125 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import fs from "fs";
import os from "os";
import path from "path";
import {
planRoundArtifacts,
resolveImagePaths,
} from "../src/cli";
const PNG_BYTES = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
"base64",
);
function writePng(filePath: string): void {
fs.writeFileSync(filePath, PNG_BYTES);
}
function writeManifest(tmpDir: string, entries: Record<string, any[]>): void {
fs.writeFileSync(
path.join(tmpDir, ".gstack-design-rounds.json"),
JSON.stringify(entries, null, 2),
);
}
describe("plan-design-review round variant preservation", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "variant-preservation-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test("recommended round allocates sequence-stable suffixed paths", () => {
const alias = path.join(tmpDir, "variant-recommended.png");
const first = planRoundArtifacts(alias);
expect(first?.primaryOutput).toBe(path.join(tmpDir, "variant-recommended-A.png"));
expect(first?.aliasOutput).toBe(alias);
writeManifest(tmpDir, {
[path.join(tmpDir, "variant-recommended")]: [
{ label: "A", path: first!.primaryOutput, success: true },
],
});
const second = planRoundArtifacts(alias);
expect(second?.primaryOutput).toBe(path.join(tmpDir, "variant-recommended-B.png"));
});
test("recommended round alias expands to every successful generated candidate", async () => {
const alias = path.join(tmpDir, "variant-recommended.png");
const variantA = path.join(tmpDir, "variant-recommended-A.png");
const variantB = path.join(tmpDir, "variant-recommended-B.png");
writePng(variantA);
writePng(variantB);
writePng(alias);
writeManifest(tmpDir, {
[path.join(tmpDir, "variant-recommended")]: [
{ label: "A", path: variantA, success: true },
{ label: "B", path: variantB, success: true },
],
});
await expect(resolveImagePaths(alias)).resolves.toEqual([variantA, variantB]);
});
test("iteration round discovers A/B/C candidates without collapsing onto the alias", async () => {
const alias = path.join(tmpDir, "variant-iteration-01.png");
const variantA = path.join(tmpDir, "variant-iteration-01-A.png");
const variantB = path.join(tmpDir, "variant-iteration-01-B.png");
const variantC = path.join(tmpDir, "variant-iteration-01-C.png");
writePng(variantA);
writePng(variantB);
writePng(variantC);
writePng(alias);
await expect(resolveImagePaths(alias)).resolves.toEqual([variantA, variantB, variantC]);
});
test("single recommended generation preserves the suffixed candidate and ignores alias copy", async () => {
const alias = path.join(tmpDir, "variant-recommended.png");
const variantA = path.join(tmpDir, "variant-recommended-A.png");
writePng(variantA);
writePng(alias);
writeManifest(tmpDir, {
[path.join(tmpDir, "variant-recommended")]: [
{ label: "A", path: variantA, success: true },
],
});
await expect(resolveImagePaths(alias)).resolves.toEqual([variantA]);
});
test("failed sibling leaves successful candidate in place and reports missing index", async () => {
const alias = path.join(tmpDir, "variant-recommended.png");
const variantA = path.join(tmpDir, "variant-recommended-A.png");
const variantB = path.join(tmpDir, "variant-recommended-B.png");
writePng(variantA);
writeManifest(tmpDir, {
[path.join(tmpDir, "variant-recommended")]: [
{ label: "A", path: variantA, success: true },
{ label: "B", path: variantB, success: false, error: "API error" },
],
});
await expect(resolveImagePaths(alias)).rejects.toThrow("variant-recommended-B.png");
expect(fs.existsSync(variantA)).toBe(true);
});
test("initial 3-option board paths are unchanged", async () => {
const variantA = path.join(tmpDir, "variant-A.png");
const variantB = path.join(tmpDir, "variant-B.png");
const variantC = path.join(tmpDir, "variant-C.png");
writePng(variantA);
writePng(variantB);
writePng(variantC);
const input = `${variantA},${variantB},${variantC}`;
await expect(resolveImagePaths(input)).resolves.toEqual([variantA, variantB, variantC]);
});
});