Blog
Scaffolding without surprises
A scaffold is useful when it makes the first steps repeatable. I become cautious when convenience blurs the difference between creating missing files and replacing someone's existing choices.
Inspect before generating
I first list the target files, read the project's instructions, and identify anything already present. My heuristic is to treat an existing file as owned work, even if it looks like a default template. A preview or dry run should show intended paths and changes. It adds a step but gives the reviewer a chance to spot a collision before content is touched.
Before generating anything, I keep a short inventory: which paths the tool intends to create, which it intends to change, and which it merely reads. The list is cheap to produce and cheap to check. A path I did not expect usually means the template's assumptions and the project's assumptions have diverged.
Existing files deserve a second look rather than an automatic replacement. A file may be a default copy, a deliberate local change, or something in between that nobody remembers. I cannot always tell from the content, so I treat the safer reading as the working one and ask. A question costs a message; an overwrite can cost a reconstruction.
interface PlannedFile {
path: string;
action: "create" | "skip";
reason?: string;
}
// An existing file is skipped, never replaced by default.
export function planFiles(
requested: readonly string[],
existing: ReadonlySet<string>,
): PlannedFile[] {
return requested.map((path) =>
existing.has(path)
? { path, action: "skip", reason: "already present" }
: { path, action: "create" },
);
}Make mutation a separate choice
Generating a missing configuration and overwriting a tailored one are different operations. I prefer a tool that refuses collisions by default and asks for explicit intent before replacing anything. If that option is unavailable, I prepare the proposed content separately and review a diff. Silent overwrite is a poor trade for a faster setup, especially when the original intent cannot be reconstructed.
It helps when the tool treats skip and replace as separate outcomes rather than one action with a flag. A skipped path leaves the file untouched and says so; a replacement names what it will overwrite. When both outcomes are visible in the preview, I can review them with the same attention I would give a diff, before any write happens.
When replacement is genuinely wanted, I still separate preparation from the write. I generate the proposed content somewhere harmless, compare it with what exists, and merge by hand where the differences carry meaning. A diff I reviewed is worth more than a success message I did not see.
{
"create": ["src/config.ts", "tests/config.test.ts"],
"skip": [{ "path": "README.md", "reason": "already present" }],
"replace": false
}Verify the boundary, not just the output
When generation fails halfway, I inspect what exists before retrying; a second run should not compound an incomplete first run. Afterward I compare the expected file list with the actual diff and run the declared checks. Success means both that the new files work and that unrelated files stayed intact. I report any manual merge rather than presenting it as automatic scaffolding.
Partial writes are the reason I prefer idempotent steps. A step that can run twice without changing the second result is easy to retry; a step that appends or increments is not. When the tool cannot promise that, I treat the retry as a new decision and inspect the state first. That caution costs one look at the directory and prevents a duplicate.
Finally, I compare what was promised with what happened. The intended path list and the actual diff should agree; any file outside the list deserves an explanation. Then I run the declared checks. New files working is only half the claim; the other half is that nothing unrelated moved, and only the diff can show that.
# Show the intended paths before writing anything.
scaffold --plan scaffold-plan.json --dry-run
# create: src/config.ts
# create: tests/config.test.ts
# skip: README.md (already present)
# A second run still refuses a collision without an explicit option.
scaffold --plan scaffold-plan.json
# error: refusing to replace README.md without --replace