treelay 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aaron Moffatt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,296 @@
1
+ # treelay
2
+
3
+ An inheritance/composition system for directory trees — think `extends`/mixins
4
+ for the filesystem. A directory declares inheritance **parents** and **mixins**;
5
+ `treelay compile` resolves the whole graph and materializes a flat output
6
+ directory. The output stays **linked** to its template, so template changes can
7
+ be pulled into an already-created, already-edited project (`update`), and local
8
+ edits can be pushed back up into the layers they belong to (`promote`).
9
+
10
+ > **Status: early, working — the link is bidirectional and layers are
11
+ > distributable.** The architecture and full design are specified in
12
+ > [SPEC.md](./SPEC.md). Resolution (C3), value resolution, **`compile`**,
13
+ > **`update`** (pulls template changes *down*) and **`status`/`promote`/`extract`**
14
+ > (push local edits *up*) are implemented and tested, with rendering, deep-merge,
15
+ > append/prepend, tombstones, sidecar/suffix ops, **unified-diff patches with true
16
+ > 3-way merge**, `.treelay` state, **`explain`** for tracing where any file came
17
+ > from, and **git/npm layer refs pinned in `treelay.lock`** with vendored
18
+ > `mounts` and drift detection. Remaining per SPEC §12: `watch` and `eject`.
19
+
20
+ ## Why not just OverlayFS / copier / Kustomize?
21
+
22
+ - **OverlayFS** is a runtime union *mount* (Linux, root, ephemeral). treelay is a
23
+ build-time *compiler* producing plain, portable files.
24
+ - **copier** does the living-template update loop, but has no inheritance or
25
+ mixins — each template keeps an isolated answers file. treelay composes a graph
26
+ of parents + mixins (C3-linearized) with **one merged questionnaire**.
27
+ - **Kustomize** does structured patch composition, but only for Kubernetes YAML
28
+ and with throwaway output. treelay works on arbitrary file trees and keeps the
29
+ link alive.
30
+
31
+ See [SPEC.md §1](./SPEC.md) for the full comparison.
32
+
33
+ ## Core ideas
34
+
35
+ - **Composition graph** — `parents` (transitive, C3-linearized) and `mixins`,
36
+ with precedence `mounts < parents < mixins < self`.
37
+ - **Distributable layers** — a parent, mixin or mount can be a local path, a git
38
+ ref, or an npm package. Whatever a build resolves is pinned in `treelay.lock`,
39
+ so the next build reproduces it and an upstream that has moved is *reported*
40
+ rather than silently followed.
41
+ - **Per-file merge** — replace, deep-merge (JSON/YAML), 3-way text patch,
42
+ structured patch (RFC 7386/6902), append/prepend, tombstone delete.
43
+ - **`.treelay` sidecars** — the canonical operation format carrying strategy, the
44
+ recorded base (hash for drift detection, content for a true 3-way), and the
45
+ patch payload. A patch that can't be reconciled fails the build rather than
46
+ writing something half-merged.
47
+ - **Template variables** — declared across the graph and merged first, then
48
+ content is rendered (LiquidJS, sandboxed). Suffix opt-in (`*.tmpl`).
49
+ - **Two-way link** — `update` pulls template changes down; `promote`/`extract`
50
+ push instance edits up.
51
+ - **Living updates** — `update` merges the new template output against what it
52
+ produced last time and what you have since edited. Your changes survive; files
53
+ you never touched advance cleanly; genuine collisions surface as conflicts
54
+ rather than being guessed at.
55
+
56
+ ## CLI
57
+
58
+ ```
59
+ treelay compile <src> <dest> # materialize template → destination
60
+ treelay update <dest> # pull template changes down (3-way merge)
61
+ # --on-conflict markers|rej --dry-run
62
+ treelay status <dest> # list local changes vs baseline (--json)
63
+ treelay promote <dest> [files...] --to <layer> # push edits up into a layer
64
+ # --dry-run --no-verify
65
+ treelay extract <dest> [files...] --as <path> # capture edits as a new layer
66
+ # --mixin --name
67
+ treelay lock [dir] # resolve every layer ref and pin it
68
+ # --check --update --drift
69
+ treelay plan [dir] # print the linearized layer order
70
+ treelay explain <dir> [file] # trace file provenance (--json for machine output)
71
+ ```
72
+
73
+ `compile`, `update` and `plan` also take `--frozen-lockfile`.
74
+
75
+ ### Layers from git and npm, pinned
76
+
77
+ A parent, mixin or mount can live anywhere. The three forms are told apart by
78
+ shape, so nothing has to be declared twice:
79
+
80
+ ```jsonc
81
+ {
82
+ "parents": ["../core", "@acme/node-base@^2", "github:acme/base#v2"],
83
+ "mounts": { "packages": "git+https://host/acme/packages.git#v1.2.0" }
84
+ }
85
+ ```
86
+
87
+ Add `?path=core/_layer` to any non-local ref to use a subdirectory of the
88
+ fetched tree as the layer root.
89
+
90
+ **`mounts` vendor a whole tree into the output** at a fixed subpath. Mount paths
91
+ merge by ordinary layer precedence, which is the point: a leaf can hold
92
+ `packages/` back at an older pin while its parents float, using the same
93
+ override rule as everything else rather than a separate package mechanism.
94
+
95
+ ```console
96
+ $ treelay plan edo/soredi/_layer
97
+ Layers (lowest → highest precedence):
98
+ 1. mount:packages [mounted at packages/, pinned 64cf108ee311]
99
+ 2. core
100
+ 3. soredi
101
+ ```
102
+
103
+ Whatever gets materialized is pinned in `treelay.lock` beside the leaf manifest
104
+ — canonical ref → exact commit (or version) plus an integrity hash over the
105
+ tree. It is deterministically serialized, so re-resolving an unchanged tree
106
+ produces byte-identical output and never shows up in a diff.
107
+
108
+ ```console
109
+ $ treelay lock . --check # CI: is the lockfile complete and current?
110
+ $ treelay lock . --drift # has anything moved upstream? (exits 1 if so)
111
+ 1 ref(s) have moved upstream since treelay.lock was written:
112
+ git+https://host/acme/packages.git#v1.2.0
113
+ pinned 64cf108ee311
114
+ v1.2.0 is now 3991f53a3bf4 (packages/)
115
+ This build used the pinned revisions. Run `treelay lock --update` to advance them.
116
+ ```
117
+
118
+ **Drift is reported, never followed.** `compile` and `update` keep producing the
119
+ locked revision even after a branch advances — that is what pinning means, and
120
+ an update that quietly recomposed at a newer commit would make "pull my
121
+ template's changes down" mean something different depending on the day.
122
+ `treelay lock --update` is the only thing that advances a pin, and
123
+ `--frozen-lockfile` refuses to resolve anything the lock does not already pin.
124
+
125
+ Two asymmetries worth knowing:
126
+
127
+ - **Git pins are enforced; npm pins are recorded.** treelay materializes a git
128
+ commit from its own cache, so a build reproduces regardless of the branch. npm
129
+ layers resolve through the installed `node_modules` — treelay checks the
130
+ version satisfies the range and records it, but installation stays your
131
+ package manager's job. Rolling one back is `npm ci`'s job, not treelay's.
132
+ - **Offline means *unknown*, not *unchanged*.** A drift probe that could not
133
+ reach the remote says so rather than reporting in sync.
134
+
135
+ ### Pulling template changes into a project you've edited
136
+
137
+ This is the headline: a compiled project stays linked to its template, so the
138
+ template can keep evolving after the project exists.
139
+
140
+ ```console
141
+ $ treelay update ./my-service
142
+ New variables: region
143
+ U .github/workflows/ci.yml # you never touched it — updated cleanly
144
+ M pipeline.yml # both changed, merged
145
+ D legacy.cfg # template dropped it, you hadn't edited it
146
+ … 2 file(s) with local edits left as-is.
147
+ ```
148
+
149
+ Update reloads the answers it was built with and asks **only** about variables
150
+ the new template version introduced. Files you created yourself are never
151
+ touched. Genuine collisions are reported and written as conflicts rather than
152
+ guessed at — and `update` exits non-zero so CI notices:
153
+
154
+ ```console
155
+ $ treelay update ./my-service --on-conflict rej
156
+ C pipeline.yml ← conflict
157
+
158
+ 1 conflict(s). Your files are unchanged; incoming versions are in *.rej.
159
+ ```
160
+
161
+ `--on-conflict markers` (the default) writes diff3 markers in place, including
162
+ the base section so you can see what the template *used* to produce. `rej` keeps
163
+ the working file byte-identical and drops the incoming version at `<file>.rej` —
164
+ use it when a file has to stay parseable. `--dry-run` shows the plan and writes
165
+ nothing.
166
+
167
+ Run it twice and the second run is a no-op; the baseline advances to whatever
168
+ the template produced, so a conflict is never re-offered once you've dealt with
169
+ it.
170
+
171
+ ### Tracing where a file came from
172
+
173
+ `explain` is the debugging story for a system whose whole job is "this file came
174
+ from somewhere non-obvious." Point it at a source layer or a compiled
175
+ destination; omit the file to explain the entire composition.
176
+
177
+ ```console
178
+ $ treelay explain ./my-service src/config.json
179
+ Layers (lowest → highest precedence):
180
+ 1. @acme/node-base (parent)
181
+ 2. with-ci (mixin)
182
+ 3. my-service (self)
183
+
184
+ src/config.json ← with-ci (deep-merge)
185
+ 1. @acme/node-base parent create src/config.json
186
+ 2. with-ci mixin deep-merge src/config.json
187
+ 3. my-service self merge src/config.json.treelay [sidecar, base sha256:ab12cd3…]
188
+ folded in: @acme/node-base, sidecar
189
+ ```
190
+
191
+ Layers that cannot be written to — fetched git/npm layers, and mounts — are
192
+ tagged `[read-only]`, which is what filters them out as promotion targets (§8).
193
+ Fetched layers also show the revision in use, so `explain` answers "which
194
+ version of the base am I actually on?" without a second command.
195
+
196
+ A compiled destination explains itself — it reconstructs its own graph from the
197
+ lockfile lineage and re-renders with the answers it was built with:
198
+
199
+ ```console
200
+ $ treelay explain ./build --json | jq '.files["src/config.json"].winner'
201
+ ```
202
+
203
+ Notes on behaviour worth knowing:
204
+
205
+ - **Tombstoned files still appear**, marked not-present, so you can see *what*
206
+ deleted them rather than just finding them missing.
207
+ - **A patch that cannot be applied is described, not thrown on** — `explain`
208
+ stays usable precisely when `compile` is failing.
209
+ - `winner`/`strategy`/`patchedFrom` mirror what `compile` records, and a test
210
+ asserts the two agree so they cannot drift apart.
211
+
212
+ ### Pushing an edit back up (reflux)
213
+
214
+ The mirror image of `update`. You edited a file in the compiled project and
215
+ realised it belongs in a layer, so every sibling project gets it too. `status`
216
+ is `git status` plus blame — it tells you not just what changed but where it
217
+ could go:
218
+
219
+ ```console
220
+ $ treelay status ./out
221
+ A extra.ts ← local-only (no template origin)
222
+ M notes.txt ← produced by @acme/base
223
+
224
+ $ treelay promote ./out notes.txt --to @acme/base
225
+ Promoted into @acme/base:
226
+ rewrite notes.txt → notes.txt
227
+ Round-trip verified: the destination reproduces from the template.
228
+
229
+ @acme/base is consumed beyond this project — 1 other layer inherits it. This edit reaches all of them on their next update.
230
+ ```
231
+
232
+ After a verified promote the change flows down by inheritance, so it stops
233
+ showing up as local drift — `status` now lists only `extra.ts`.
234
+
235
+ **Three guards stand between you and a bad promotion:**
236
+
237
+ 1. **Shadowing** — promoting somewhere a higher layer would override is refused,
238
+ with the layer to use instead:
239
+ ```console
240
+ $ treelay promote ./out a.txt --to base
241
+ Promoting a.txt to base has no effect; with-ci overrides this file at a higher precedence.
242
+ Promote to with-ci or to the leaf instead (§8 guard 1).
243
+ ```
244
+ 2. **Round-trip verification** — after writing, treelay recompiles and asserts
245
+ the destination reproduces byte-for-byte. If it doesn't, the layer writes are
246
+ rolled back and nothing is left half-applied. Pass `--no-verify` to skip it.
247
+ 3. **Blast radius** — promoting reaches every sibling layer and every compiled
248
+ destination downstream of the target. That reach is reported (on stderr)
249
+ rather than left for someone else to discover on their next `update`.
250
+
251
+ Where the change lands is chosen for you: a layer that already produces the file
252
+ gets its **source rewritten**; a layer above the producer gets a **sidecar with
253
+ only the delta** (recording both `base` and `baseContent`, so it still merges
254
+ cleanly after the parent drifts); a locally-added file is **created**; a deletion
255
+ becomes a **tombstone**.
256
+
257
+ `extract` does the same thing into a brand-new layer:
258
+
259
+ ```console
260
+ $ treelay extract ./out a.txt --as ../house-style --mixin
261
+ Extracted 1 file(s) → /work/house-style
262
+ a.txt
263
+ Wired in as a mixin of the leaf; round-trip verified.
264
+ ```
265
+
266
+ Without `--mixin` the layer is created but left out of the graph — treelay says
267
+ so and deliberately skips verification and rebaselining, because the edits are
268
+ still local until you wire it in.
269
+
270
+ ### Composition rules worth knowing
271
+
272
+ - **`.git` is never composed**, in either form — a real directory *or* the
273
+ gitlink *file* that a git submodule checkout carries. Layers vendored as
274
+ submodules are safe to compose; `.gitignore`/`.gitmodules` are ordinary
275
+ content and compose normally. (SPEC §4)
276
+ - **`treelay.lock` never composes.** Like the manifest, it is layer metadata,
277
+ not content — otherwise a layer's pins would be published into every tree
278
+ built from it, and the leaf's own lockfile would show up as a generated file
279
+ that `update` reports as changed.
280
+ - **The destination may live inside the source tree.** Compiling into a
281
+ gitignored `build/` within the source repo is supported: the destination is
282
+ pruned from the layer walk, so recompiles never re-consume their own output.
283
+ A destination *equal to* a layer root is refused. (SPEC §7)
284
+
285
+ ## Development
286
+
287
+ ```bash
288
+ npm install
289
+ npm run typecheck # tsc --noEmit
290
+ npm test # vitest
291
+ npm run build # tsup → dist/
292
+ ```
293
+
294
+ ## License
295
+
296
+ MIT