Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

tiny-scaffold

Run a template over a project as many times as you like. Nothing is duplicated, nothing you wrote is quietly overwritten, and the cases that genuinely cannot be decided are refused by name instead of guessed at.

import { scaffold } from 'tiny-scaffold';

scaffold('/path/to/project', [
  // The template owns the whole file.
  { path: 'tsconfig.json', content: tsconfigText, ownership: 'managed' },

  // The template owns only the marked spans. Everything else is yours.
  { path: 'src/routes.ts', content: routesText, ownership: 'regions' },
]);

A region owned file carries its marker in a comment, in whatever language the file happens to be:

import { health } from './handlers.js';

// scaffold:begin routes
app.get('/health', health);
// scaffold:end routes

Run it again after the template changes and after you have edited the file, and both survive. Run it again with nothing changed and it writes nothing at all.

The bug in the four line version

A generator re-run has three things it could look at: the text it wants to write, the text on disk, and the text it wrote last time. Almost every scaffolder has only the first two.

With two, a difference is unreadable. "You edited this" and "the template changed" produce exactly the same observation, and no policy built on an unreadable signal can be right. So every two way generator collapses into one of two constants:

  • Always overwrite. The template update lands. So does the deletion of every change anyone made, with no warning, because from two inputs there was never anything to warn about.
  • Always skip, or append only. Nothing is lost, and no template fix ever reaches a file anyone has touched. The scaffolder becomes a one time starter kit and the fixes rot in the template.

Both are discovered late, which is what makes them expensive.

This module keeps the third input. After every run it records the exact text it emitted, per file and per region, in a receipt at .tiny-scaffold.json. On the next run a difference from that recorded base on the disk side is your edit, and a difference on the template side is a template change. They are finally two different observations.

Concretely, here are two runs that hand the merge an identical file and an identical template:

on disk template wants recorded base result
A old new old new, the template change lands
B old new new old, you changed it back and it stays

A two way generator sees one situation in both rows and has to answer them the same way. Whichever constant it picks, it is wrong in one of them. Both rows are in the test suite.

The base is what the generator emitted, not what got written

There is a tempting shortcut once the merge works: record the merged result as the new base, since that is what is on disk now. It reads as correct for exactly one run.

Say the base is one, two, three, you change line two to EDITED, and the template adds a fourth line. The merge writes one, EDITED, three, four. If that becomes the base, then on the next run your edit is part of the base, the template still says two, and the template now looks like it removed your line. The merge dutifully reverts your own work, and no conflict is raised, because as far as the recorded state is concerned nothing was in dispute.

So the base records the template's emission, always. Your edits stay on the disk side where they belong. There is a test that runs a third time specifically to catch this, because the second run looks fine either way.

Granularity is the other half of the problem

Recording the base is not enough on its own.

Compare whole files or whole regions and one changed character collides with any template change anywhere in the same block. Every run then reports conflicts, nearly all of them spurious, and the team learns within a week to pass whatever flag makes them stop. That flag is always-overwrite under a new name. The region grained merge reintroduces exactly the bug it was built to prevent, and it does it through the front door with everyone's blessing.

So the merge here is diff3 over lines. A conflict is raised only when both sides rewrote the same lines. You edit line two, the template rewrites line eight, both land and nobody is asked anything.

Two cases that are not conflicts and are easy to get wrong:

  • Convergent edits. You already made the change the template is now making. Reporting it would ask you to choose between two identical texts, and applying it from both sides would write it twice.
  • A change one side made and the other did not touch. This is the ordinary case and it has to be silent, or the conflict list fills with noise and stops being read.

There is no force flag

The escape hatch is where these tools die, so this one does not have a general one.

Every refusal names a specific target, and a conflict is resolved by naming a specific conflict:

scaffold(root, template, {
  directives: {
    adopt: ['src/app.ts'],                   // keep the file as it is, start tracking it
    replace: ['README.md#header'],           // overwrite just that block
    restore: ['src/routes.ts#routes'],       // put back a block that was deleted
    release: ['.eslintrc.json'],             // stop generating it, for good
    remove: ['old/thing.ts'],                // delete it, edits included
    resolve: { 'src/app.ts@12+3:9f2a1c04bb71': 'ours' },
  },
});

Three properties keep this from decaying into a force flag:

A conflict id contains a hash of all three texts. Copy a resolution into a config file and it expires the moment your copy, the template, or the base moves. The run then says the resolution is stale and shows the current conflict, rather than applying last month's decision to a disagreement nobody has read.

A directive that turns out not to apply is reported, not ignored. A leftover adopt sitting in a script is a standing approval waiting for the situation to come up again. If nothing in the run consumed it, the run stops and says so.

A resolved conflict does not come back. The chosen side is folded into the recorded base, so the next run sees your text on the disk side and the template unchanged, and merges it cleanly. If resolutions had to be re-passed every run they would end up permanent, which is the thing being avoided.

Refusing is the interesting part

Each of these is a situation where two plausible readings exist and picking one silently is how work gets lost. All of them come back in a single error listing every problem in the run, and the run writes nothing.

Situation Why it is not decidable
The file exists, no recorded base Your file or a stale generated one. Overwriting loses work, skipping locks the template out forever.
A marked block exists, no recorded base You wrote that block by hand, or a previous version wrote it.
A tracked file is gone from disk You deleted it on purpose, or it never arrived.
A tracked block is gone from the file Writing it back is the duplicate block bug, one step removed: it would reappear on every run forever.
The template dropped a file you edited Deleting it takes your edits with it.
A file switched ownership between runs A whole file base and a set of region bases do not mean the same thing, and reinterpreting one as the other merges your prose against generated code.

release is recorded as a tombstone rather than by forgetting the entry. If forgetting were enough, the next run would see a file the template wants and no record of it, call it new, and write it. You would delete it, release it, and get it back, in a loop. A decision that does not survive to the next run is not a decision, it is a delay.

Markers, not positions

A generator that remembers "my block starts at line 40" appends a second copy the moment someone adds an import above it. A generator that searches for its own text stops finding the block the moment someone edits a character of it. Both produce the duplicate block that everyone who has used a scaffolder has deleted by hand.

The marker carries an id, so the block survives being moved, reindented, and rewritten. The marker token is matched anywhere in the line, so //, #, <!-- -->, and =begin all work without declaring a language, and the marker lines already in the file are preserved verbatim when the body is updated.

Markers that do not form well nested, uniquely named pairs are refused with the file name and line number. Two blocks with the same id is refused loudest of all, since that is the duplication itself.

Known limitations

A new region is appended at the end of the file. There is no cleverer placement rule, because the file belongs to you and anything smarter is the generator guessing at the structure of text it does not own. Move the block afterwards and it will be found there.

Line endings are content. A line carries its own \r, so a file that switches between CRLF and LF reads as every line having changed. Normalize before handing text in if that matters to you.

The receipt stores full text, not hashes. That is the point, since a hash cannot serve as the ancestor a merge aligns against, but it does mean the receipt is roughly the size of everything the template generates. Commit it. It is the only record of what the base was.

The merge table is capped at four million cells. Common prefixes and suffixes are stripped first, so this only bites when a couple of thousand lines genuinely differ, which in practice means generated or minified content in a managed file. It refuses rather than allocating.

Committing is not atomic across files. Each file is written to a temporary path and renamed, so no single file is ever half written, and the receipt is written last so a crash leaves untracked files rather than a base that never existed. But a crash in the middle of a multi file run leaves some files updated and some not. The next run resumes correctly for the files that were written and refuses the rest as untracked.

Renames are not tracked. Change a path in the template and the old file is a drop and the new one is a create. Your edits are in the old file, and the run refuses to delete it, which is safe but manual.

Test

npm install
npm test   # 137 tests: three way merge, region markers, directives, refusals, filesystem

The suite is written so that the naive implementations fail it. There are cases where always-overwrite loses, cases where always-skip loses, a case where recording the merged text as the base silently reverts an edit on the third run, and a case where the same disk and the same template must produce opposite results.

License

MIT

About

Idempotent scaffolding with a recorded base: re-run without duplicating blocks or clobbering edits

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages