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.
Potentially problematic release.
This version of treelay might be problematic. Click here for more details.
- package/LICENSE +21 -0
- package/README.md +296 -0
- package/SPEC.md +920 -0
- package/dist/chunk-QKQUPZVD.js +2665 -0
- package/dist/chunk-QKQUPZVD.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +293 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +1399 -0
- package/dist/index.js +187 -0
- package/dist/index.js.map +1 -0
- package/package.json +70 -0
package/SPEC.md
ADDED
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
# treelay — design spec
|
|
2
|
+
|
|
3
|
+
An inheritance/composition system for directory trees. A directory can declare
|
|
4
|
+
inheritance **parents** and **mixins**; "compiling" it resolves the whole graph
|
|
5
|
+
and materializes a flat output directory. Crucially, the output stays **linked**
|
|
6
|
+
to its template, so template changes can be pulled into an already-created,
|
|
7
|
+
already-edited project via three-way merge.
|
|
8
|
+
|
|
9
|
+
> Status: design draft. Decisions marked **[decided]** are locked; **[open]**
|
|
10
|
+
> needs a call before implementation.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 1. Mental model
|
|
15
|
+
|
|
16
|
+
A directory is a **class**; compiling it produces an **instance** (a plain,
|
|
17
|
+
flat directory). Everything else is borrowed from language semantics:
|
|
18
|
+
|
|
19
|
+
| Filesystem concept | Language analogue |
|
|
20
|
+
|-------------------------------|------------------------------|
|
|
21
|
+
| `parents` | base classes (*is-a*) |
|
|
22
|
+
| `mixins` | traits/mixins (*with-a*) |
|
|
23
|
+
| compile | instantiation / flattening |
|
|
24
|
+
| same file in multiple layers | method override / `super()` |
|
|
25
|
+
| a `.patch` on an inherited file | calling `super()` then editing the result |
|
|
26
|
+
| a tombstone | deleting an inherited member |
|
|
27
|
+
|
|
28
|
+
If a "what should happen here?" question comes up, the honest answer is usually
|
|
29
|
+
"what would the class-inheritance version do?"
|
|
30
|
+
|
|
31
|
+
### The two relationships, and why both exist
|
|
32
|
+
|
|
33
|
+
- **parent** — a full base you are a specialization of. Transitively resolved:
|
|
34
|
+
a parent contributes *its own* parents into your lineage. Use for "this
|
|
35
|
+
project *is a* TypeScript service."
|
|
36
|
+
- **mixin** — a focused, reusable fragment layered on top. Not necessarily
|
|
37
|
+
buildable standalone. Use for "...also *with* Docker, *with* CI."
|
|
38
|
+
|
|
39
|
+
Precedence, lowest → highest:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
parents (C3-linearized) < mixins (declaration order) < self
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`self > mixins > parents`. Write this on the box; every user needs it in their head.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 2. Manifest
|
|
50
|
+
|
|
51
|
+
Each overlay directory carries `treelay.json` (or a `"treelay"` key in
|
|
52
|
+
`package.json`, so an overlay *is* a normal npm package).
|
|
53
|
+
|
|
54
|
+
```jsonc
|
|
55
|
+
{
|
|
56
|
+
"name": "@acme/service-base",
|
|
57
|
+
"abstract": false, // true = inherit-only, not compilable standalone
|
|
58
|
+
"parents": ["@acme/node-base@^2", "../shared-eslint"],
|
|
59
|
+
"mixins": ["@acme/with-docker", "@acme/with-ci"],
|
|
60
|
+
"mounts": { "packages": "git+https://host/acme/packages.git#v1.2.0" },
|
|
61
|
+
"ignore": ["node_modules", ".git", ".treelay"],
|
|
62
|
+
"merge": {
|
|
63
|
+
"*.json": "deep-merge",
|
|
64
|
+
"*.yaml": "deep-merge",
|
|
65
|
+
"package.json": "deep-merge",
|
|
66
|
+
"**/*.png": "replace"
|
|
67
|
+
},
|
|
68
|
+
"arrays": "replace", // deep-merge array policy: replace | concat | by-key
|
|
69
|
+
|
|
70
|
+
"templateSuffix": ".tmpl", // only files ending in this are rendered (see §6)
|
|
71
|
+
"variables": {
|
|
72
|
+
"serviceName": { "type": "string", "prompt": "Service name?" },
|
|
73
|
+
"port": { "type": "number", "default": 3000 },
|
|
74
|
+
"useDocker": { "type": "boolean", "default": true },
|
|
75
|
+
"registry": { "type": "string", "default": "{{ org }}.registry.io", "computed": true },
|
|
76
|
+
"license": { "type": "string", "choices": ["MIT", "Apache-2.0"], "default": "MIT" }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Variable *declarations* merge across the inheritance graph by the same layer
|
|
82
|
+
precedence as files (§3) — a parent declares, a mixin or the leaf overrides the
|
|
83
|
+
default — producing **one merged questionnaire** for the whole composition
|
|
84
|
+
(detailed in §6). This is the thing copier structurally cannot do (its templates
|
|
85
|
+
each keep an isolated answers file).
|
|
86
|
+
|
|
87
|
+
### Referencing layers (all npm-native) **[decided]** ✅ *implemented*
|
|
88
|
+
|
|
89
|
+
Three origins, told apart by shape alone so nothing has to be declared twice:
|
|
90
|
+
|
|
91
|
+
| Form | Example | Resolution |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| **local path** | `../base`, `/abs/base`, `file:./base` | as written (monorepo / dev) |
|
|
94
|
+
| **git** | `git+https://host/o/r.git#v1.2.0`, `git+ssh://git@host/o/r.git#main`, `git+file:///srv/r.git#main`, `github:acme/base#v2` | cloned and pinned to a commit |
|
|
95
|
+
| **npm** | `@acme/base@^2`, `pkg@1.2.3`, `npm:@acme/base@^2` | through the installed `node_modules` |
|
|
96
|
+
|
|
97
|
+
Any non-local ref may carry **`?path=<subdir>`** to use a subdirectory of the
|
|
98
|
+
fetched tree as the layer root — the monorepo-of-layers case
|
|
99
|
+
(`git+https://…/lake.git?path=core/_layer#v3`). Order follows URL convention:
|
|
100
|
+
query before fragment.
|
|
101
|
+
|
|
102
|
+
**Git refs are enforced; npm refs are delegated.** A git ref names a commit, and
|
|
103
|
+
treelay materializes exactly that commit from its own cache, so a build
|
|
104
|
+
reproduces regardless of what has happened to the branch. An npm ref names a
|
|
105
|
+
*range*, and the package on disk was put there by a package manager that already
|
|
106
|
+
owns installation, integrity and an offline cache. treelay reads what is
|
|
107
|
+
installed, verifies it satisfies the declared range, and records the exact
|
|
108
|
+
version — it does not install, and it cannot roll a package back. Shipping a
|
|
109
|
+
second, worse package manager inside a composition engine would only produce two
|
|
110
|
+
lockfiles disagreeing about one tree. Reproducing an old npm pin is `npm ci`'s
|
|
111
|
+
job; that asymmetry is deliberate and is the one place where "pinned" means
|
|
112
|
+
*recorded* rather than *enforced*.
|
|
113
|
+
|
|
114
|
+
### Vendored trees: `mounts` **[decided]** ✅ *implemented*
|
|
115
|
+
|
|
116
|
+
A layer can pull an entire external tree into the composed output at a fixed
|
|
117
|
+
subpath:
|
|
118
|
+
|
|
119
|
+
```jsonc
|
|
120
|
+
{ "mounts": { "packages": "git+https://host/acme/packages.git#v1.2.0" } }
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Every compiled tree then carries its own `packages/` — a self-contained
|
|
124
|
+
mini-monorepo where `file:`/workspace references resolve at stable internal
|
|
125
|
+
paths, with no registry and no path rewriting.
|
|
126
|
+
|
|
127
|
+
Mount **paths** merge across the graph by ordinary layer precedence: the highest
|
|
128
|
+
layer naming `packages` decides the ref. That is the whole feature — a leaf
|
|
129
|
+
holds a vendored tree back at an older pin while its parents float, using the
|
|
130
|
+
same override rule as everything else rather than a bespoke package mechanism.
|
|
131
|
+
|
|
132
|
+
Mounted layers sit at the **bottom** of the stack, below every declared layer.
|
|
133
|
+
Vendored content is substrate: any layer must be able to patch or tombstone a
|
|
134
|
+
file inside it, and that only works if the mount is beneath them all. The
|
|
135
|
+
alternative — placing a mount beside its declaring layer — would make an
|
|
136
|
+
intermediate layer's patch on `packages/**` apply or not depending on which
|
|
137
|
+
ancestor happened to win the ref, which is exactly the unexplainable-bug-report
|
|
138
|
+
outcome C3 exists to avoid. Overlapping mount paths are refused; they have no
|
|
139
|
+
unambiguous owner. Mounted layers are read-only for reflux in v1: promotion maps
|
|
140
|
+
an output path back to a layer path, and a mount's output paths carry a prefix
|
|
141
|
+
its sources do not (§8).
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## 3. Resolution — the crux
|
|
146
|
+
|
|
147
|
+
A graph (with possible diamonds) flattened to a deterministic, ordered layer
|
|
148
|
+
list, lowest precedence → highest.
|
|
149
|
+
|
|
150
|
+
### Linearization: C3 **[decided]**
|
|
151
|
+
|
|
152
|
+
Use C3 linearization (Python's MRO) over the `parents` graph. It is the only
|
|
153
|
+
approach that simultaneously:
|
|
154
|
+
|
|
155
|
+
1. handles diamonds — a shared grandparent applies **once**, before both children;
|
|
156
|
+
2. respects local order — your declared parent order is honored;
|
|
157
|
+
3. is monotonic — a parent's ancestry is never reshuffled by a child.
|
|
158
|
+
|
|
159
|
+
The naive alternative (depth-first + last-wins dedupe) is simpler but produces
|
|
160
|
+
surprising orders in diamonds and generates unexplainable bug reports.
|
|
161
|
+
|
|
162
|
+
Final stack, lowest → highest:
|
|
163
|
+
|
|
164
|
+
0. `mounts` (§2), sorted by mount path — vendored substrate
|
|
165
|
+
1. linearized `parents` (C3 order)
|
|
166
|
+
2. `mixins` in declaration order (each strictly above all parents)
|
|
167
|
+
3. the directory's own files (always win)
|
|
168
|
+
|
|
169
|
+
### Guards **[decided]**
|
|
170
|
+
|
|
171
|
+
- **Cycle detection** — `A → B → A` fails loud with the full path shown.
|
|
172
|
+
- **Lockfile** — `treelay.lock` records what every ref resolved to, for
|
|
173
|
+
reproducible builds and upstream-drift detection (below).
|
|
174
|
+
|
|
175
|
+
### `treelay.lock` **[decided]** ✅ *implemented*
|
|
176
|
+
|
|
177
|
+
Committed beside the **leaf layer's manifest**. Deterministically serialized —
|
|
178
|
+
sorted keys, fixed field order, two-space indent, trailing newline — so
|
|
179
|
+
re-resolving an unchanged tree produces byte-identical output and never appears
|
|
180
|
+
in a diff.
|
|
181
|
+
|
|
182
|
+
```jsonc
|
|
183
|
+
{
|
|
184
|
+
"lockfileVersion": 1,
|
|
185
|
+
"refs": {
|
|
186
|
+
"git+https://host/acme/packages.git#v1.2.0": { // canonical ref = the key
|
|
187
|
+
"kind": "git",
|
|
188
|
+
"source": "https://host/acme/packages.git",
|
|
189
|
+
"requested": "v1.2.0", // the mutable thing asked for
|
|
190
|
+
"resolved": "3f2a1c9e…", // the immutable thing it became
|
|
191
|
+
"integrity": "sha256:…", // over the materialized tree
|
|
192
|
+
"path": "core/_layer", // ?path=, when present
|
|
193
|
+
"requestedBy": ["edo/soredi/_layer"] // relative to the lockfile
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Four properties worth stating outright:
|
|
200
|
+
|
|
201
|
+
- **The key is canonical, not textual.** `github:acme/base#v2` and
|
|
202
|
+
`git+https://github.com/acme/base.git#v2` are the same layer and share one
|
|
203
|
+
entry; the key is derived from the parsed ref, so two spellings cannot pin
|
|
204
|
+
independently.
|
|
205
|
+
- **`requested` and `resolved` are both recorded.** A lock that stored only the
|
|
206
|
+
commit could say *that* something changed but never *what was asked for* — and
|
|
207
|
+
"v1.2.0 now points somewhere else" is the sentence a drift report needs.
|
|
208
|
+
- **`integrity` covers content, not history.** It hashes the materialized tree
|
|
209
|
+
(`.git` and `node_modules` pruned), so a cache entry edited in place is caught
|
|
210
|
+
even though its commit id is unchanged. It is enforced only when both sides
|
|
211
|
+
claim the same revision — a *different* revision is drift, which for npm is
|
|
212
|
+
legitimate and is reported rather than mistaken for corruption.
|
|
213
|
+
- **Only refs actually materialized are pinned.** A mount ref that lost the
|
|
214
|
+
precedence contest is never fetched and never appears; the lock is a record of
|
|
215
|
+
what was built, not of everything mentioned.
|
|
216
|
+
|
|
217
|
+
Distinct from `<dest>/.treelay/lock.json` (§7): that file is regenerated on
|
|
218
|
+
every compile and says what one *destination* was built from. `treelay.lock` is
|
|
219
|
+
the pin a repository commits and reviews.
|
|
220
|
+
|
|
221
|
+
**Fetching and the cache.** Fetched trees land at a path derived from what they
|
|
222
|
+
are — `<cache>/git/<repo-key>/rev/<commit>/` — never from who asked. Two layers
|
|
223
|
+
pinning the same commit share one checkout; a held-back pin coexists with a
|
|
224
|
+
floating one instead of fighting over a working directory; and the cache is
|
|
225
|
+
disposable, since the worst case is a re-fetch. Git trees are extracted with
|
|
226
|
+
`git archive`, so a checkout carries no `.git` at all and cannot leak a gitlink
|
|
227
|
+
into a composed tree (§4 guards the same hazard from the other side).
|
|
228
|
+
`TREELAY_CACHE_DIR` relocates it.
|
|
229
|
+
|
|
230
|
+
**Resolution is synchronous**, even though it may clone. Every caller in the
|
|
231
|
+
library and CLI treats `resolve` as a plain function, and a build cannot proceed
|
|
232
|
+
without its layers, so there is nothing to overlap with — going async would tax
|
|
233
|
+
every consumer for no gain.
|
|
234
|
+
|
|
235
|
+
### Drift: ref moved vs lock **[decided]** ✅ *implemented*
|
|
236
|
+
|
|
237
|
+
Drift is **reported, never acted on**. `compile` and `update` materialize the
|
|
238
|
+
locked revision even after `main` advances; that is what pinning means. An
|
|
239
|
+
update that silently recomposed at a newer revision would make "pull my
|
|
240
|
+
template's changes down" mean something different depending on the day.
|
|
241
|
+
|
|
242
|
+
- `treelay lock --drift` probes each pinned ref and exits non-zero if any moved.
|
|
243
|
+
- `update` prints moved refs on stderr before merging, then composes from the pins.
|
|
244
|
+
- `explain` / `plan` annotate each fetched layer with the revision in use.
|
|
245
|
+
- `treelay lock --update` is the *only* thing that advances a pin.
|
|
246
|
+
- `--frozen-lockfile` refuses to resolve anything the lock does not already
|
|
247
|
+
pin — the CI posture: a build reproduces from what was committed, or it fails.
|
|
248
|
+
|
|
249
|
+
The probe is best-effort by construction. Offline, unauthenticated, or
|
|
250
|
+
uninstalled makes the current revision **unknown**, which is a distinct answer
|
|
251
|
+
from "unchanged" and is presented as one — an advisory check that reported
|
|
252
|
+
in-sync when it could not look would be worse than saying nothing. Annotated
|
|
253
|
+
tags are peeled before comparison, or every one of them would report drift
|
|
254
|
+
forever (a tag object's id is not the commit's).
|
|
255
|
+
|
|
256
|
+
Absent a lock entry, the first build resolves the ref, materializes it, and
|
|
257
|
+
records the pin — the package-manager convention, so day one needs no separate
|
|
258
|
+
step. That write touches the *source* tree, so it is always announced rather
|
|
259
|
+
than done silently.
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## 4. Per-file merge semantics
|
|
264
|
+
|
|
265
|
+
When the same relative path exists in N layers:
|
|
266
|
+
|
|
267
|
+
| Strategy | Default for | Mechanism |
|
|
268
|
+
|---------------------|-------------------------|--------------------------------------------|
|
|
269
|
+
| **replace** | binary / unknown | higher layer wins wholesale |
|
|
270
|
+
| **deep-merge** | JSON / YAML / TOML | recursive merge (array policy configurable)|
|
|
271
|
+
| **patch** | text | apply unified diff onto inherited file |
|
|
272
|
+
| **append / prepend**| `.gitignore`, logs | concatenate |
|
|
273
|
+
| **delete (tombstone)** | — | whiteout an inherited file |
|
|
274
|
+
|
|
275
|
+
Strategy is chosen three ways, in increasing power:
|
|
276
|
+
|
|
277
|
+
- **manifest globs** (`"merge"` block) — broad defaults.
|
|
278
|
+
- **filename suffix conventions** — discoverable one-off *sugar*:
|
|
279
|
+
- `config.json.patch` → apply diff onto inherited `config.json`
|
|
280
|
+
- `.gitignore.append` → append to inherited file
|
|
281
|
+
- `config.json.delete` → tombstone the inherited file
|
|
282
|
+
- **`.treelay` sidecar** — the canonical, full-power form (below). Suffixes
|
|
283
|
+
desugar to a sidecar op; anything a suffix can express, a sidecar can too.
|
|
284
|
+
|
|
285
|
+
Array merge policy (`"arrays"`) defaults to **replace** — concat surprises people.
|
|
286
|
+
|
|
287
|
+
### The `.treelay` sidecar — canonical operation format
|
|
288
|
+
|
|
289
|
+
A `<targetPath>.treelay` file sitting beside where a file would land describes an
|
|
290
|
+
**operation on the inherited file**. It exists because filename suffixes can't
|
|
291
|
+
carry metadata — most importantly the **base** that §5's true 3-way merge
|
|
292
|
+
needs. YAML, so unified-diff payloads read cleanly as block scalars:
|
|
293
|
+
|
|
294
|
+
```yaml
|
|
295
|
+
# config.json.treelay → operates on the inherited config.json
|
|
296
|
+
op: patch # patch | merge | append | prepend | delete | replace
|
|
297
|
+
base: sha256:ab12… # hash of the parent text this was authored against → drift detection
|
|
298
|
+
baseContent: | # the parent text itself → enables true 3-way once it drifts (§5)
|
|
299
|
+
{
|
|
300
|
+
"name": "svc",
|
|
301
|
+
}
|
|
302
|
+
when: "{{ useDocker }}" # optional conditional (skip the op when false)
|
|
303
|
+
render: true # render the payload/result with variables (§6)
|
|
304
|
+
patch: |
|
|
305
|
+
@@ -2,3 +2,4 @@
|
|
306
|
+
"name": "svc",
|
|
307
|
+
+ "port": 3000,
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
```yaml
|
|
311
|
+
# package.json.treelay → structured merge, no line-drift
|
|
312
|
+
op: merge
|
|
313
|
+
merge: { scripts: { build: "tsc" } } # RFC 7386 JSON Merge Patch (or `jsonPatch:` for RFC 6902)
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
```yaml
|
|
317
|
+
# README.md.treelay → remove an inherited file
|
|
318
|
+
op: delete
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Division of power: a bare `*.patch` (no recorded base) is **best-effort apply**;
|
|
322
|
+
a sidecar `op: patch` carrying `baseContent` gets a **true 3-way merge**, and one
|
|
323
|
+
carrying only `base` gets honest drift detection in between (§5 spells out the
|
|
324
|
+
four cases). **Reflux (§8) auto-generates sidecars** with both fields baked in,
|
|
325
|
+
so hand-authoring is the exception, not the rule. (Sidecars are distinct from the
|
|
326
|
+
`.treelay/` *state directory*, which only ever exists in compiled destinations,
|
|
327
|
+
never in layers.)
|
|
328
|
+
|
|
329
|
+
### Never composed — built-in exclusions **[decided]**
|
|
330
|
+
|
|
331
|
+
Some paths are excluded from every layer's walk before any strategy applies —
|
|
332
|
+
an implicit tombstone that no manifest has to declare:
|
|
333
|
+
|
|
334
|
+
| Excluded | Why |
|
|
335
|
+
|---|---|
|
|
336
|
+
| `.git` (**file or directory**) | VCS metadata; see below |
|
|
337
|
+
| `node_modules/` | dependency install output, never template content |
|
|
338
|
+
| `.treelay/` | destination *state* dir (§7); only valid in an output |
|
|
339
|
+
| `treelay.{json,yaml,yml}` | the layer's own manifest, not payload |
|
|
340
|
+
|
|
341
|
+
**`.git` is excluded in both of its forms, and this matters.** A layer vendored
|
|
342
|
+
as a **git submodule** carries a `.git` *gitlink file* (`gitdir: …`) where a
|
|
343
|
+
normal clone has a directory. Composing either one publishes VCS metadata into
|
|
344
|
+
the output; the gitlink case is worse, because the compiled tree then looks to
|
|
345
|
+
git like a broken submodule pointing at a path that does not exist there. Only
|
|
346
|
+
the exact name `.git` is matched — `.gitignore` and `.gitmodules` are ordinary
|
|
347
|
+
content and compose normally.
|
|
348
|
+
|
|
349
|
+
This is deliberately *not* configurable. A layer that wants to ship VCS-adjacent
|
|
350
|
+
content can name it something else; there is no legitimate case for a compiled
|
|
351
|
+
instance inheriting its template's `.git`.
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
## 5. Patches & three-way merge **[decided]** ✅ *implemented*
|
|
356
|
+
|
|
357
|
+
Patches make this powerful *and* fragile: if a parent file changes, a child's
|
|
358
|
+
line-diff may no longer apply.
|
|
359
|
+
|
|
360
|
+
- **3-way merge with a recorded base.** A patch records the parent text it was
|
|
361
|
+
authored against, so compile can reconstruct the author's intent
|
|
362
|
+
(base → patched) and reconcile it against the drift the parent actually took
|
|
363
|
+
(base → parent-now). Resolves cleanly far more often than a flat apply, and
|
|
364
|
+
produces honest conflicts when it can't.
|
|
365
|
+
- **Structured patches for structured files.** JSON Merge Patch (RFC 7386) for
|
|
366
|
+
simple cases, JSON Patch (RFC 6902) for precise array ops. No line-drift at
|
|
367
|
+
all — prefer these for config.
|
|
368
|
+
- **Fail loud, never silent.** A patch that won't apply stops the build. Compile
|
|
369
|
+
is all-or-nothing: nothing is materialized until every layer has merged, so a
|
|
370
|
+
conflict leaves the destination untouched rather than half-written.
|
|
371
|
+
|
|
372
|
+
### `base` vs `baseContent` — why a hash is not enough
|
|
373
|
+
|
|
374
|
+
An earlier draft said the `base:` **hash** alone enabled a real 3-way merge. It
|
|
375
|
+
doesn't: a hash can prove the parent drifted, but it cannot reconstruct the
|
|
376
|
+
original text that diff3 needs as its merge base. The two fields split that job:
|
|
377
|
+
|
|
378
|
+
| Field | Carries | Buys you |
|
|
379
|
+
|---|---|---|
|
|
380
|
+
| `base:` | `sha256:…` of the authored-against parent | **drift detection** — cheap, and proves a clean apply when it still matches |
|
|
381
|
+
| `baseContent:` | the parent text itself | **true diff3** once the parent has moved on |
|
|
382
|
+
|
|
383
|
+
Compile picks its path accordingly:
|
|
384
|
+
|
|
385
|
+
1. **`baseContent` present** → true diff3. When `base` is also recorded it is
|
|
386
|
+
verified against it; a mismatch means the sidecar contradicts itself and is
|
|
387
|
+
rejected rather than trusted.
|
|
388
|
+
2. **`base` matches the inherited file** → no drift, so the file *is* the base;
|
|
389
|
+
the patch is guaranteed to apply exactly.
|
|
390
|
+
3. **`base` no longer matches** → drift, with no way to reconstruct the original.
|
|
391
|
+
Compile does *not* blindly apply a patch it knows was authored elsewhere: it
|
|
392
|
+
relocates hunks that merely moved and fails loud on the rest.
|
|
393
|
+
4. **No `base` at all** (a bare `*.patch`) → best-effort apply, same as (3).
|
|
394
|
+
|
|
395
|
+
Reflux (§8) has the base text in hand when it generates a sidecar, so it records
|
|
396
|
+
both fields and case (1) is the norm for tool-authored patches; hand-authored
|
|
397
|
+
sidecars can record just the hash and still get honest drift detection.
|
|
398
|
+
|
|
399
|
+
### What "best-effort" actually recovers
|
|
400
|
+
|
|
401
|
+
Worth being precise, since it sets expectations for hand-authored patches:
|
|
402
|
+
|
|
403
|
+
- **Moved hunks** — recovered. The matcher searches for each hunk's location, so
|
|
404
|
+
content added or removed *elsewhere* in the file doesn't break the patch.
|
|
405
|
+
- **Changed context** — rejected. If a line inside the hunk's context window
|
|
406
|
+
changed, there is no safe way to guess, so it becomes a conflict. (A `fuzz`
|
|
407
|
+
setting does not rescue this case; it only tolerates truncated leading/trailing
|
|
408
|
+
context.) This is exactly the case `baseContent` upgrades to a clean merge:
|
|
409
|
+
diff3 sees the two edits are separated by unchanged lines and takes both.
|
|
410
|
+
- **Overlapping edits** — conflict, including edits on *immediately adjacent*
|
|
411
|
+
lines, which diff3 cannot order. Same rule git applies.
|
|
412
|
+
|
|
413
|
+
### Patch meets tombstone
|
|
414
|
+
|
|
415
|
+
A patch edits an inherited file, so it needs something to inherit — the `super()`
|
|
416
|
+
analogy of §1 holds:
|
|
417
|
+
|
|
418
|
+
- **tombstone above a patch** — the delete wins; the file is gone. (The patch ran
|
|
419
|
+
at a lower layer; a later layer removing the file is a normal override.)
|
|
420
|
+
- **patch above a tombstone** — the patch has nothing to apply to and **fails
|
|
421
|
+
loud**. It is not treated as a file-creating operation, because a patch that
|
|
422
|
+
silently becomes "write this whole file" hides a real authoring mistake.
|
|
423
|
+
- **patch on a file no layer produces** — same failure, same reason.
|
|
424
|
+
|
|
425
|
+
---
|
|
426
|
+
|
|
427
|
+
## 6. Template variables **[decided]**
|
|
428
|
+
|
|
429
|
+
Layers declare variables; values are **merged and evaluated first**, then file
|
|
430
|
+
content is rendered with them. Variables compose across the inheritance graph,
|
|
431
|
+
which is what makes treelay's questionnaire fundamentally different from copier's
|
|
432
|
+
per-template, non-shared answers.
|
|
433
|
+
|
|
434
|
+
### The compile pipeline (where variables sit)
|
|
435
|
+
|
|
436
|
+
Ordering is the whole design. A compile runs:
|
|
437
|
+
|
|
438
|
+
1. **Resolve graph** (C3) → ordered layers (§3).
|
|
439
|
+
2. **Merge variable declarations** across the linearized stack → one schema.
|
|
440
|
+
Declarations deep-merge per-key (parents C3 < mixins < self), so a child can
|
|
441
|
+
override just a parent's `default` while keeping its `prompt`/`type`.
|
|
442
|
+
3. **Resolve values**, lowest → highest precedence:
|
|
443
|
+
declared `default` → answers/values file(s) → interactive prompts → CLI
|
|
444
|
+
`--set k=v` / env overrides.
|
|
445
|
+
4. **Evaluate computed variables** (`"computed": true`) in topological order;
|
|
446
|
+
cycles are detected and fail loud.
|
|
447
|
+
5. **Render** each layer's file *names* and *contents* with the final value set.
|
|
448
|
+
6. **Merge** the rendered layers per-file (§4/§5) — i.e. **render-then-merge**.
|
|
449
|
+
7. **Drop conditional files** whose rendered name is empty.
|
|
450
|
+
8. **Materialize** to dest + persist answers and baseline (§7).
|
|
451
|
+
|
|
452
|
+
**Render-then-merge, not merge-then-render** — because a child's `.patch` is
|
|
453
|
+
authored against the parent's *rendered* output, and structured deep-merge needs
|
|
454
|
+
valid JSON/YAML on both sides, not template-y source. "Variables first, then
|
|
455
|
+
content" is exactly this ordering.
|
|
456
|
+
|
|
457
|
+
### Variable declaration
|
|
458
|
+
|
|
459
|
+
Mirrors copier's proven question set, but composed across layers:
|
|
460
|
+
|
|
461
|
+
```jsonc
|
|
462
|
+
"serviceName": {
|
|
463
|
+
"type": "string", // string | number | boolean | json | yaml | path
|
|
464
|
+
"prompt": "Service name?", // omit → never prompted (pure default/computed)
|
|
465
|
+
"default": "svc", // templatable: "{{ org }}-svc"
|
|
466
|
+
"choices": ["a", "b"], // optional enum (can be Jinja-dynamic)
|
|
467
|
+
"when": "{{ useDocker }}", // skip the question + value when false
|
|
468
|
+
"validate": "...", // renders to "" if valid, else the error message
|
|
469
|
+
"secret": true, // masked input; excluded from the persisted answers
|
|
470
|
+
"computed": true // derived from other vars; never prompted
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
### What gets rendered — suffix opt-in **[decided]**
|
|
475
|
+
|
|
476
|
+
**Only files ending in `templateSuffix` (default `.tmpl`) are rendered**;
|
|
477
|
+
everything else is copied byte-for-byte. `config.json.tmpl` → renders →
|
|
478
|
+
`config.json`. This is copier's hard-won default, and the reason is collisions:
|
|
479
|
+
`{{ }}` is also GitHub Actions (`${{ }}`), Vue, Handlebars, Go templates… a
|
|
480
|
+
render-by-default tool would corrupt those files. Opt-in is the safe baseline; a
|
|
481
|
+
layer can set `"render": "all-text"` if it really wants render-by-default.
|
|
482
|
+
|
|
483
|
+
Suffix order: the template suffix is outermost — strip-and-render first, then the
|
|
484
|
+
inner merge suffix applies (`config.json.patch.tmpl` → render → treat as a
|
|
485
|
+
`.patch` against the inherited `config.json`).
|
|
486
|
+
|
|
487
|
+
### Engine & trust
|
|
488
|
+
|
|
489
|
+
Engine: **LiquidJS [decided]** — safe by design, very active, no arbitrary code
|
|
490
|
+
execution. Chosen over Nunjucks (most Jinja-familiar but a weaker sandbox) and
|
|
491
|
+
Eta (tiny/fast) because layers arrive as **third-party npm packages**: a template
|
|
492
|
+
that can run arbitrary code is a supply-chain hazard. Rendering runs with
|
|
493
|
+
filesystem/network access disabled — a template can only read the resolved
|
|
494
|
+
variable values, nothing else.
|
|
495
|
+
|
|
496
|
+
---
|
|
497
|
+
|
|
498
|
+
## 7. The living template — compile & update **[decided]**
|
|
499
|
+
|
|
500
|
+
This is the headline feature: a compiled project stays linked to its template
|
|
501
|
+
and can absorb template updates *after* it has been created and locally edited.
|
|
502
|
+
|
|
503
|
+
### Compile (template dir → destination dir)
|
|
504
|
+
|
|
505
|
+
```
|
|
506
|
+
treelay compile <srcDir> <destDir>
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
`srcDir` is the leaf overlay (the project template). It resolves parents/mixins
|
|
510
|
+
and writes the flattened result to `destDir`. The destination gets a state dir:
|
|
511
|
+
|
|
512
|
+
```
|
|
513
|
+
<destDir>/.treelay/
|
|
514
|
+
lock.json # resolved lineage + version refs at last compile
|
|
515
|
+
answers.json # resolved variable values (§6); secrets excluded
|
|
516
|
+
baseline.json # relative path → content hash of every generated file
|
|
517
|
+
baseline/ # the generated content itself = the diff3 merge base
|
|
518
|
+
manifest.json # per file: generated-by-template | user-owned, + producing layer
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
**Why both a hash index and a content snapshot.** They answer different
|
|
522
|
+
questions. The hash cheaply decides *whether* a file changed, which is all the
|
|
523
|
+
first two merge cases below need. But a hash cannot reconstruct the text diff3
|
|
524
|
+
requires as its merge base, so "both sides changed" — the case the whole feature
|
|
525
|
+
exists for — needs the content itself. (Same lesson as `base` vs `baseContent`
|
|
526
|
+
in §5; a digest detects drift, it never reverses it.) The snapshot is replaced
|
|
527
|
+
wholesale on each write so files the template no longer produces cannot linger
|
|
528
|
+
as stale merge bases, and reads are verified against the recorded hash: a
|
|
529
|
+
snapshot that has fallen out of sync degrades to "no base available", which
|
|
530
|
+
surfaces as a conflict rather than a silently wrong merge.
|
|
531
|
+
|
|
532
|
+
`lock.json`'s lineage doubles as the pointer home — its last entry is the leaf,
|
|
533
|
+
which is how `treelay update <dest>` rediscovers the template it came from
|
|
534
|
+
without being told.
|
|
535
|
+
|
|
536
|
+
First compile = fresh instantiation. The baseline records "this is exactly what
|
|
537
|
+
the template produced *with these answers*," which is what later updates merge
|
|
538
|
+
against. Persisting `answers.json` is what makes re-rendering on update
|
|
539
|
+
deterministic (copier's `.copier-answers.yml`, but one file for the whole
|
|
540
|
+
composition rather than one per template).
|
|
541
|
+
|
|
542
|
+
#### Destinations inside the source tree **[decided]**
|
|
543
|
+
|
|
544
|
+
`destDir` may be **nested inside a layer** — compiling into a gitignored
|
|
545
|
+
`build/` within the source repo is a first-class, supported layout, not an
|
|
546
|
+
accident to be worked around. A destination that is a strict descendant of a
|
|
547
|
+
layer is pruned from that layer's walk, so a recompile never re-consumes its own
|
|
548
|
+
prior output. (The first compile is safe by construction — enumeration precedes
|
|
549
|
+
materialization — but the second would otherwise fold `build/` back in, and
|
|
550
|
+
again on the third, compounding each time.)
|
|
551
|
+
|
|
552
|
+
The degenerate case fails loud rather than silently eating its own sources:
|
|
553
|
+
`destDir` **equal to** a layer root is refused, because materializing over the
|
|
554
|
+
directory being read has no correct interpretation.
|
|
555
|
+
|
|
556
|
+
### Update (re-pull template changes into an edited project) ✅ *implemented*
|
|
557
|
+
|
|
558
|
+
```
|
|
559
|
+
treelay update <destDir> [--set k=v] [--on-conflict markers|rej] [--dry-run]
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
Update first reloads `answers.json`, prompts **only for variables the new
|
|
563
|
+
template version newly introduced** (existing answers are reused, not
|
|
564
|
+
re-asked), then recompiles. Three inputs, per file:
|
|
565
|
+
|
|
566
|
+
- **base** = `.treelay/baseline` (template output at last compile)
|
|
567
|
+
- **ours** = current working copy in `destDir` (user's edits)
|
|
568
|
+
- **theirs**= freshly recompiled template at the new version, same answers
|
|
569
|
+
|
|
570
|
+
Crucially, `theirs` is composed **in memory**. Recompiling onto the destination
|
|
571
|
+
would destroy the very edits the merge exists to preserve.
|
|
572
|
+
|
|
573
|
+
Per-file three-way merge:
|
|
574
|
+
|
|
575
|
+
- base == ours → take theirs (user never touched it; accept update cleanly)
|
|
576
|
+
- base == theirs→ keep ours (template unchanged; preserve user edits)
|
|
577
|
+
- both changed, mergeable → merge (structured merge for JSON/YAML; 3-way text merge otherwise)
|
|
578
|
+
- both changed, conflicting → surface for resolution (see below)
|
|
579
|
+
- file gone in theirs, unchanged in ours → delete it
|
|
580
|
+
- file gone in theirs, edited in ours → conflict (don't silently discard user work)
|
|
581
|
+
- file gone in ours, unchanged in theirs → stay deleted (a deletion is an edit too)
|
|
582
|
+
- file gone in ours, changed in theirs → conflict (don't silently resurrect it)
|
|
583
|
+
- file new in theirs, already present in ours → conflict (the user got there first)
|
|
584
|
+
|
|
585
|
+
**Text first, then structure.** For JSON/YAML the line merge is tried *before*
|
|
586
|
+
the structured one, because it preserves formatting and comments. Only when it
|
|
587
|
+
conflicts do we retry as merge patches, where two sides adding unrelated keys
|
|
588
|
+
compose cleanly however adjacent those keys happen to be — the `package.json`
|
|
589
|
+
case. The cost is reserialization, which is why it is the fallback and not the
|
|
590
|
+
default.
|
|
591
|
+
|
|
592
|
+
**Conflicts are written, not thrown.** Unlike compile (§5), which can safely
|
|
593
|
+
refuse to produce anything, `update` is editing a project that already exists —
|
|
594
|
+
doing nothing leaves the user stuck. So conflicts are reported in the plan and
|
|
595
|
+
materialized one of two ways:
|
|
596
|
+
|
|
597
|
+
- **`markers`** (default) — the merged file carries diff3 markers, *including the
|
|
598
|
+
base section*, so you can see what the template previously produced rather than
|
|
599
|
+
guessing why the two sides disagree.
|
|
600
|
+
- **`rej`** — the working file is left byte-identical and the incoming version
|
|
601
|
+
lands at `<file>.rej`. For projects where a file must stay parseable (a
|
|
602
|
+
committed lockfile, anything a pre-commit hook reads) markers are worse than
|
|
603
|
+
useless.
|
|
604
|
+
|
|
605
|
+
Either way the *whole plan is computed before anything is written*, so a failure
|
|
606
|
+
partway through leaves the project exactly as it was — the same all-or-nothing
|
|
607
|
+
guarantee compile makes, applied to a tree that already has work in it.
|
|
608
|
+
|
|
609
|
+
The baseline is then rewritten to the new template output **unconditionally**,
|
|
610
|
+
including for conflicted files: "what the template last produced" is factually
|
|
611
|
+
`theirs` regardless of how each merge landed. This is what makes a repeated
|
|
612
|
+
update a no-op, and stops a conflict from being re-offered on every future run.
|
|
613
|
+
|
|
614
|
+
### Generated vs owned files
|
|
615
|
+
|
|
616
|
+
`.treelay/manifest.json` tracks which files the template is responsible for vs.
|
|
617
|
+
files the user added themselves. Update only governs generated files; user-owned
|
|
618
|
+
files are never touched. This is what stops `update` from clobbering the project.
|
|
619
|
+
|
|
620
|
+
---
|
|
621
|
+
|
|
622
|
+
## 8. Reflux / promotion — pushing instance edits back up **[decided]**
|
|
623
|
+
|
|
624
|
+
The mirror image of §7. `update` pulls template changes *down* into a project;
|
|
625
|
+
**reflux** pushes a project's local edits *up* into the inheritance graph. The
|
|
626
|
+
OOP analogue is exact: `compile` is instantiation, reflux is **"pull member up"**
|
|
627
|
+
— deciding an edit you made on the instance actually belongs on a superclass.
|
|
628
|
+
Together they make the template↔project link **bidirectional**.
|
|
629
|
+
|
|
630
|
+
treelay is unusually suited to this because per-file **provenance** is already
|
|
631
|
+
first-class (§10): the tool knows which layer produced each file, so it can
|
|
632
|
+
*suggest* where an edit belongs instead of making you pick blind — the
|
|
633
|
+
`git absorb` experience.
|
|
634
|
+
|
|
635
|
+
### Listing changes
|
|
636
|
+
|
|
637
|
+
Everything keys off `.treelay/baseline` (exactly what the template produced last
|
|
638
|
+
compile). The working copy diverges three ways: **modified** (hunks vs baseline),
|
|
639
|
+
**added** (user-owned, no template origin), **deleted** (tombstone candidate).
|
|
640
|
+
|
|
641
|
+
`treelay status` is `git status` *plus blame* — it annotates each change with the
|
|
642
|
+
layer that currently produces the file, because the useful question is "where
|
|
643
|
+
could this go," not just "what changed":
|
|
644
|
+
|
|
645
|
+
```
|
|
646
|
+
treelay status <dest>
|
|
647
|
+
M src/config.json ← produced by @acme/node-base (+ patched by with-ci)
|
|
648
|
+
M .eslintrc ← produced by ../shared-eslint
|
|
649
|
+
A src/custom/thing.ts ← local-only (no template origin)
|
|
650
|
+
D README.md ← produced by @acme/service-base
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
### Dispositions — the menu per change
|
|
654
|
+
|
|
655
|
+
| Disposition | Meaning | OOP analogue |
|
|
656
|
+
|---|---|---|
|
|
657
|
+
| **Keep local** | project-specific; never promote | instance field |
|
|
658
|
+
| **Extract to new layer** | factor into a brand-new overlay (optionally a mixin) | extract superclass/trait |
|
|
659
|
+
| **Promote into parent/mixin** | push up into a *chosen existing* layer so siblings inherit it | pull member up |
|
|
660
|
+
| **Promote into self** | bake into the leaf template itself | edit the class directly |
|
|
661
|
+
|
|
662
|
+
The interactive flow is `git add -p` where the staging question is *"where does
|
|
663
|
+
this belong?"* rather than yes/no.
|
|
664
|
+
|
|
665
|
+
### Granularity
|
|
666
|
+
|
|
667
|
+
- **File-level** — promote whole files. Predictable; the v1 target.
|
|
668
|
+
- **Hunk-level** — a single file's edits split across targets (the `git absorb`
|
|
669
|
+
power-move). Real jump in complexity; **v2**. **[open]**
|
|
670
|
+
|
|
671
|
+
### How a promoted change lands in the target layer
|
|
672
|
+
|
|
673
|
+
Chosen automatically by what the target layer already does with the file:
|
|
674
|
+
|
|
675
|
+
| Situation at target L | Mode | What lands |
|
|
676
|
+
|---|---|---|
|
|
677
|
+
| L already produces the file | **rewrite** | L's *own source file* is rewritten in place |
|
|
678
|
+
| a *lower* layer produces it, L does not | **patch** | a sidecar in L carrying only the delta |
|
|
679
|
+
| no layer produces it (locally added) | **create** | the file, dropped into L |
|
|
680
|
+
| the change is a deletion | **tombstone** | L's source removed if it is the sole producer, else an `op: delete` sidecar |
|
|
681
|
+
|
|
682
|
+
Two details matter more than they look:
|
|
683
|
+
|
|
684
|
+
- **Rewrite reuses the layer's existing source path.** If L produces `config.json`
|
|
685
|
+
from `config.json.tmpl`, the rewrite goes back into the `.tmpl` — writing a
|
|
686
|
+
plain `config.json` beside it would leave the layer producing the same path
|
|
687
|
+
twice. The consequence is the one §8 already documents under
|
|
688
|
+
"reflux meets variables": the promoted file stops being parametric. Round-trip
|
|
689
|
+
verification is what makes that safe to do by default — if baking the rendered
|
|
690
|
+
values in changes the output, the promotion fails rather than quietly
|
|
691
|
+
de-templating the layer.
|
|
692
|
+
- **Patch mode records both `base` and `baseContent` (§5).** Reflux composes the
|
|
693
|
+
sub-stack *below* L to obtain the exact inherited text, so it always has the
|
|
694
|
+
base in hand and never has to emit the weaker hash-only form. Structured files
|
|
695
|
+
get an RFC 7386 merge patch instead of a line diff.
|
|
696
|
+
|
|
697
|
+
### Why guard 1 refuses so little
|
|
698
|
+
|
|
699
|
+
Guard 1 (shadowing) fires only on a **wholesale** override above the target — a
|
|
700
|
+
higher layer that creates, replaces, or deletes the same path. Partial actions
|
|
701
|
+
(deep-merge, append, prepend, patch) are deliberately *not* treated as shadowing,
|
|
702
|
+
even though they can also stop a promotion from reproducing.
|
|
703
|
+
|
|
704
|
+
The split is about the quality of the answer. A wholesale override is provably
|
|
705
|
+
futile and can be named precisely: *"with-ci overrides this file; promote there
|
|
706
|
+
instead."* Whether a *partial* transform preserves the promoted bytes depends on
|
|
707
|
+
merge order, array policy, and re-rendering — questions a static check can only
|
|
708
|
+
guess at. Rather than guess, those fall to guard 2, which recompiles and simply
|
|
709
|
+
looks. The result is that guard 1 never produces a false refusal, and guard 2
|
|
710
|
+
never lets a bad promotion through; a failure there rolls the layer writes back
|
|
711
|
+
through an undo log, because a half-written layer would be picked up by the very
|
|
712
|
+
next compile.
|
|
713
|
+
|
|
714
|
+
**Layer writability — three tiers, not two:**
|
|
715
|
+
|
|
716
|
+
- **Writable in place** — local paths and monorepo packages. Edited directly.
|
|
717
|
+
- **Writable via a clone** — **git layers** (below). A git ref is a real repo
|
|
718
|
+
with a push target, so reflux *can* land there — it just commits instead of
|
|
719
|
+
rewriting a file on disk.
|
|
720
|
+
- **Truly read-only** — npm-package layers resolved as tarballs in
|
|
721
|
+
`node_modules`. No upstream working tree to commit to; the tool falls back to
|
|
722
|
+
"capture as a patch in a writable layer above it" and says so rather than
|
|
723
|
+
failing silently.
|
|
724
|
+
|
|
725
|
+
### Committing reflux back to a git layer **[decided]**
|
|
726
|
+
|
|
727
|
+
A git-referenced layer (`github:acme/base#…`) is a first-class promotion target,
|
|
728
|
+
not a read-only one. The mechanics differ from a local path only at the end:
|
|
729
|
+
|
|
730
|
+
- **Mutable clone, not the resolved snapshot.** The snapshot pulled for *compile*
|
|
731
|
+
is detached and unsuitable for writing. Promotion operates on a real working
|
|
732
|
+
clone in a treelay cache (`~/.treelay/git/<repo>/`), shelling out to the user's
|
|
733
|
+
configured `git` so SSH/credential-helper auth is respected — treelay never
|
|
734
|
+
handles tokens itself.
|
|
735
|
+
- **Promote onto a branch, never a pinned ref.** A layer pinned to a tag or SHA
|
|
736
|
+
is immovable by definition, so git promotion *requires* a target branch and
|
|
737
|
+
refuses a detached/tag ref with an explanation. On success the project's own
|
|
738
|
+
reference and `treelay.lock` are rewritten to the new commit — otherwise the
|
|
739
|
+
project stays pinned to the old ref and would never see the change it just
|
|
740
|
+
promoted.
|
|
741
|
+
- **Landing mode is the user's call, per promote.** The interactive flow offers,
|
|
742
|
+
for each git promotion: **commit on a branch** (nothing leaves the machine),
|
|
743
|
+
**commit + push** (branch only, never the pinned/default ref), or **commit +
|
|
744
|
+
open a PR** (via `gh`/`glab` when available). Non-interactive runs
|
|
745
|
+
(`--no-prompt`) default to the most conservative — local commit on a branch —
|
|
746
|
+
and require an explicit flag to push or PR.
|
|
747
|
+
- **Round-trip verification still gates the commit (§8 guard 2).** After
|
|
748
|
+
committing, re-resolve the layer at the new commit, recompile, and assert
|
|
749
|
+
byte-identity. If it doesn't reproduce, the commit is rolled back
|
|
750
|
+
(`git reset --hard`) — never left dangling.
|
|
751
|
+
- **Blast radius is maximal (§8 guard 3).** A pushed git layer reaches *every*
|
|
752
|
+
consumer everywhere, and a push is far harder to walk back than editing a
|
|
753
|
+
sibling directory. The warning is louder, and push/PR is always a deliberate,
|
|
754
|
+
separately-confirmed step.
|
|
755
|
+
|
|
756
|
+
### The three guards (where reflux earns its keep)
|
|
757
|
+
|
|
758
|
+
1. **Precedence shadowing.** If you promote to `parent X` but `mixin Y` overrides
|
|
759
|
+
the same file higher up, the change vanishes on recompile. The tool detects
|
|
760
|
+
this from the resolved stack and refuses: *"Promoting to node-base has no
|
|
761
|
+
effect; with-ci overrides this file. Promote to with-ci or self instead."*
|
|
762
|
+
2. **Round-trip verification.** After any promote/extract, **recompile and assert
|
|
763
|
+
the working copy is byte-identical.** If the change doesn't reproduce (merge
|
|
764
|
+
order interactions), fail loud. This is what makes reflux trustworthy.
|
|
765
|
+
3. **Blast radius.** Promoting up reaches *every sibling* inheriting that layer —
|
|
766
|
+
the intent, but a footgun. Warn: *"node-base is consumed by 6 projects; this
|
|
767
|
+
edit reaches all of them on their next update."*
|
|
768
|
+
|
|
769
|
+
After a verified promote, `.treelay/baseline` is rewritten so the change counts
|
|
770
|
+
as "from template" and drops off the local-changes list — it now flows down by
|
|
771
|
+
inheritance instead of being a local override.
|
|
772
|
+
|
|
773
|
+
### Reflux meets variables (the hard interaction) **[open]**
|
|
774
|
+
|
|
775
|
+
The working copy lives in **rendered** space; layers live in **template** space.
|
|
776
|
+
Promoting a rendered edit up therefore has a representation problem: the literal
|
|
777
|
+
text `port: 3000` in the project may have come from `port: {{ port }}` in a
|
|
778
|
+
layer. Default behavior: **store the promoted content literally** (rendered
|
|
779
|
+
values baked in) — correct and predictable, but the promoted file stops being
|
|
780
|
+
parametric in that layer. Optional **assisted re-templatization** can substitute
|
|
781
|
+
known variable *values* back to `{{ var }}` references, gated behind explicit
|
|
782
|
+
review (fragile when a value is a short/common string like `"1"` or `"true"`).
|
|
783
|
+
Round-trip verification (§8 guard 2) still applies — it re-renders the target
|
|
784
|
+
layer with the persisted answers and asserts byte-identity. Granularity of the
|
|
785
|
+
re-templatization assist is **[open]**.
|
|
786
|
+
|
|
787
|
+
---
|
|
788
|
+
|
|
789
|
+
## 9. CLI surface
|
|
790
|
+
|
|
791
|
+
```
|
|
792
|
+
treelay compile <src> <dest> [--set k=v] [--answers f] [--no-prompt]
|
|
793
|
+
# materialize template → destination (first run = instantiate)
|
|
794
|
+
treelay update <dest> [--set k=v] # re-render with saved answers (prompt only new vars) + 3-way merge
|
|
795
|
+
treelay status <dest> [--json] # list changes vs baseline, annotated with producing layer
|
|
796
|
+
treelay diff <dest|a> [b] # working-vs-baseline hunks, or layer-vs-layer
|
|
797
|
+
treelay promote <dest> [files...] [--to <layer>] [--dry-run] [--no-verify]
|
|
798
|
+
# push edits up; auto-suggests --to from provenance
|
|
799
|
+
# git targets: [--branch <name>] [--push] [--pr]
|
|
800
|
+
# (commit-on-branch only unless --push/--pr given)
|
|
801
|
+
treelay extract <dest> [files...] --as <path> [--mixin] [--name <n>]
|
|
802
|
+
# capture edits as a NEW overlay layer
|
|
803
|
+
treelay lock [dir] [--check] [--update] [--drift]
|
|
804
|
+
# resolve every layer ref and pin it in treelay.lock
|
|
805
|
+
# --check verify it is complete + current (CI), write nothing
|
|
806
|
+
# --update advance moving refs to their current revision
|
|
807
|
+
# --drift report refs whose upstream has moved (network)
|
|
808
|
+
treelay plan [dir] # print linearized layer order + per-file resolution; write nothing
|
|
809
|
+
treelay explain <dir> [file] [--set k=v] [--answers f] [--json]
|
|
810
|
+
# trace which layers touched a file, in order, with patches
|
|
811
|
+
# <dir> = a source layer, or a compiled destination
|
|
812
|
+
# omit [file] to explain every path in the composition
|
|
813
|
+
treelay validate [dir] # cycles? patches apply? unresolved conflicts? drift vs lock?
|
|
814
|
+
treelay watch <src> <dest> # recompile on change
|
|
815
|
+
treelay eject <dest> # flatten + drop .treelay state (sever the template link)
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
`promote` and `extract` always end with the §8 round-trip recompile-and-verify,
|
|
819
|
+
and both refuse read-only or shadowed targets with a clear explanation.
|
|
820
|
+
|
|
821
|
+
`compile`, `update` and `plan` all accept `--frozen-lockfile` (§3).
|
|
822
|
+
|
|
823
|
+
`plan` and `explain` are not nice-to-haves — they are the debugging story for a
|
|
824
|
+
system whose entire job is "this file came from somewhere non-obvious." Build
|
|
825
|
+
`plan` before `compile`.
|
|
826
|
+
|
|
827
|
+
---
|
|
828
|
+
|
|
829
|
+
## 10. Programmatic API
|
|
830
|
+
|
|
831
|
+
The CLI is a thin shell over a library (people will want this in build tools):
|
|
832
|
+
|
|
833
|
+
```ts
|
|
834
|
+
const graph = await resolve(srcDir, { frozen, updateRefs, noLock, cacheDir });
|
|
835
|
+
// linearized layers + provenance, no output I/O
|
|
836
|
+
// graph.variables = merged declaration schema across all layers (§6)
|
|
837
|
+
// graph.lock / lockDirty / lockDir = pins resolved in memory (§3). Resolution
|
|
838
|
+
// never writes: `explain` must not have a lockfile as a side effect, so the
|
|
839
|
+
// caller that owns the source tree persists it.
|
|
840
|
+
writeLock(graph.lockDir, graph.lock); // what `treelay lock` does
|
|
841
|
+
const drift = checkDrift(graph); // [{ ref, requested, locked, current, status }]
|
|
842
|
+
|
|
843
|
+
const values = await resolveValues(graph, { answers, set, prompt }); // §6 steps 3–4
|
|
844
|
+
const result = await compile(graph, { destDir, values });
|
|
845
|
+
// result.files[path] = { fromLayer, strategy, patchedFrom, owned } ← powers `explain`
|
|
846
|
+
|
|
847
|
+
const why = await explain(graph, { values }); // no output I/O; read-only provenance
|
|
848
|
+
// why.layers = [{ id, name, role: parent|mixin|self, position, writable }]
|
|
849
|
+
// why.files[p] = { contributions[], present, winner, strategy, patchedFrom }
|
|
850
|
+
// winner/strategy/patchedFrom mirror result.files[p] exactly (asserted by test)
|
|
851
|
+
const dest = await explainDest(destDir); // re-explains from lockfile lineage + saved answers
|
|
852
|
+
|
|
853
|
+
const plan = await planUpdate(destDir); // dry-run 3-way, returns clean/conflict per file
|
|
854
|
+
await update(destDir, { onConflict: "markers" }); // reuses saved answers, prompts only new vars
|
|
855
|
+
|
|
856
|
+
const changes = await status(destDir); // per file: kind (M/A/D) + producing layer + targets
|
|
857
|
+
// changes[i].targets already excludes layers a higher layer would shadow
|
|
858
|
+
|
|
859
|
+
const promoted = await promote(destDir, changes, { to: layerRef, verify: true });
|
|
860
|
+
// throws on a read-only or shadowed target, or a failed round-trip (writes rolled back)
|
|
861
|
+
// promoted.landed[i] = { path, mode: rewrite|patch|create|tombstone, wrote }
|
|
862
|
+
// promoted.blastRadius = { dependents[], destinations[] } ← §8 guard 3
|
|
863
|
+
|
|
864
|
+
const created = await extract(destDir, changes, { as: path, asMixin: true });
|
|
865
|
+
// created.wired === false ⇒ not in the graph yet, so nothing was verified or rebaselined
|
|
866
|
+
|
|
867
|
+
// The guards are reusable on their own:
|
|
868
|
+
const check = await roundTripVerify(destDir, graph, values); // { ok, mismatches, composed }
|
|
869
|
+
const reach = blastRadius(layerDir, { searchRoot }); // who else consumes this layer
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
---
|
|
873
|
+
|
|
874
|
+
## 11. Open decisions
|
|
875
|
+
|
|
876
|
+
- **Array merge default** — `replace` proposed; revisit if config use cases want `by-key`. **[open]**
|
|
877
|
+
- **What's tracked** — contents always; modes + symlinks proposed yes; empty dirs only via `.keep`. **[open]**
|
|
878
|
+
- **Conflict UX** — **[decided]**, split by direction. For **compile**: fail the
|
|
879
|
+
build, write nothing (§5) — a template that can't compose has no partial output
|
|
880
|
+
worth keeping. For **update**: write the conflict, since refusing to act on a
|
|
881
|
+
project that already exists just leaves the user stuck. Both presentations ship
|
|
882
|
+
because neither dominates — inline `markers` (diff3 style, base section shown)
|
|
883
|
+
by default, `rej` sidecars when a file has to stay parseable (§7). An
|
|
884
|
+
interactive resolver is deferred; it composes on top of either mode rather
|
|
885
|
+
than replacing them.
|
|
886
|
+
- **Reflux granularity** — file-level for v1; hunk-level splitting + auto-`absorb` routing deferred to v2. **[open]**
|
|
887
|
+
- **Template engine** — **LiquidJS [decided]** (safe, sandboxed; over Nunjucks/Eta) — see §6.
|
|
888
|
+
- **Reflux re-templatization** — store promoted edits literally vs assisted value→`{{ var }}` substitution (§8). **[open]**
|
|
889
|
+
- **Git layer write-back** — git layers are writable via a working clone; reflux commits onto a branch (never a pinned ref), landing mode (commit / push / PR) chosen per promote, lockfile + project ref advanced on success. **[decided]** (§8)
|
|
890
|
+
- **Template variables** — interpolate values, merged across the graph, suffix opt-in rendering. **[decided]** (§6)
|
|
891
|
+
- **Layer refs & pinning** — three origins by shape, `?path=` subdirs, mounts at the
|
|
892
|
+
bottom of the stack, deterministic `treelay.lock`, drift reported not followed.
|
|
893
|
+
**[decided]** (§2, §3)
|
|
894
|
+
- **npm pins are recorded, not enforced** — installation stays the package
|
|
895
|
+
manager's job; git pins *are* enforced from treelay's own cache. **[decided]** (§3)
|
|
896
|
+
- **Reflux into mounted layers** — a mount's output paths carry a prefix its
|
|
897
|
+
sources do not, so promotion into one needs a path mapping that does not exist
|
|
898
|
+
yet; read-only for now. **[open]**
|
|
899
|
+
- **Virtual/FUSE mode** — deferred; materialize-first is **[decided]**. Revisit later for dev loops.
|
|
900
|
+
|
|
901
|
+
---
|
|
902
|
+
|
|
903
|
+
## 12. MVP build order
|
|
904
|
+
|
|
905
|
+
De-risk by building resolution first, then output, then the bidirectional loops:
|
|
906
|
+
|
|
907
|
+
1. ✅ Manifest parsing + C3 resolution + `plan` ← the risky core, visible early
|
|
908
|
+
2. ✅ `replace` / `deep-merge` / tombstone strategies (+ append/prepend, sidecar `merge`)
|
|
909
|
+
3. ✅ `compile` to a destination (fresh instantiation + `.treelay` state)
|
|
910
|
+
4. ✅ Variable schema merge + value resolution + suffix-opt-in rendering (§6)
|
|
911
|
+
5. ✅ Unified-diff patches with 3-way merge (`.patch` suffix, sidecar `op: patch`, `patch` merge glob)
|
|
912
|
+
6. ✅ `update` — the living-template three-way loop, reusing saved answers (pulls *down*)
|
|
913
|
+
7. ✅ `explain` — per-file provenance (source layers or a compiled destination; `--json`)
|
|
914
|
+
8. ✅ `status` + file-level `promote` / `extract` — reflux (pushes changes *up*)
|
|
915
|
+
9. ✅ npm/git layer resolution + `mounts` + `treelay.lock` (pins, drift, `--frozen-lockfile`)
|
|
916
|
+
10. `watch` / `eject` ← next
|
|
917
|
+
|
|
918
|
+
Demoable and trustworthy after step 3; templated scaffolding works at step 4;
|
|
919
|
+
the headline pull-down lands at step 6, and the bidirectional link closes at
|
|
920
|
+
step 8.
|