treelay 0.1.0 → 0.2.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/README.md +27 -4
- package/SPEC.md +40 -6
- package/dist/{chunk-QKQUPZVD.js → chunk-CP7GIVGA.js} +356 -16
- package/dist/chunk-CP7GIVGA.js.map +1 -0
- package/dist/cli.js +122 -41
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +174 -3
- package/dist/index.js +17 -1
- package/package.json +4 -2
- package/dist/chunk-QKQUPZVD.js.map +0 -1
package/README.md
CHANGED
|
@@ -15,7 +15,10 @@ edits can be pushed back up into the layers they belong to (`promote`).
|
|
|
15
15
|
> append/prepend, tombstones, sidecar/suffix ops, **unified-diff patches with true
|
|
16
16
|
> 3-way merge**, `.treelay` state, **`explain`** for tracing where any file came
|
|
17
17
|
> from, and **git/npm layer refs pinned in `treelay.lock`** with vendored
|
|
18
|
-
> `mounts` and drift detection.
|
|
18
|
+
> `mounts` and drift detection. `validate`, `watch` and `eject` complete the
|
|
19
|
+
> SPEC §12 build order — every documented command is implemented. What remains
|
|
20
|
+
> is the **[open]** v2 design work: hunk-level reflux, `by-key` array merging,
|
|
21
|
+
> and the reflux/variables interaction (§8).
|
|
19
22
|
|
|
20
23
|
## Why not just OverlayFS / copier / Kustomize?
|
|
21
24
|
|
|
@@ -33,7 +36,10 @@ See [SPEC.md §1](./SPEC.md) for the full comparison.
|
|
|
33
36
|
## Core ideas
|
|
34
37
|
|
|
35
38
|
- **Composition graph** — `parents` (transitive, C3-linearized) and `mixins`,
|
|
36
|
-
with precedence `mounts < parents < mixins < self`.
|
|
39
|
+
with precedence `mounts < parents < mixins < self`. Mixins carry their own
|
|
40
|
+
parents in with them. Note the two lists run opposite ways: the *first*
|
|
41
|
+
parent declared outranks the rest (Python's C3), while the *last* mixin
|
|
42
|
+
declared wins.
|
|
37
43
|
- **Distributable layers** — a parent, mixin or mount can be a local path, a git
|
|
38
44
|
ref, or an npm package. Whatever a build resolves is pinned in `treelay.lock`,
|
|
39
45
|
so the next build reproduces it and an upstream that has moved is *reported*
|
|
@@ -68,10 +74,25 @@ treelay lock [dir] # resolve every layer ref and pin it
|
|
|
68
74
|
# --check --update --drift
|
|
69
75
|
treelay plan [dir] # print the linearized layer order
|
|
70
76
|
treelay explain <dir> [file] # trace file provenance (--json for machine output)
|
|
77
|
+
treelay validate [dir] # cycles, failing patches, conflicts, stale lock
|
|
78
|
+
# --drift (network) --json
|
|
79
|
+
treelay watch <src> <dest> # recompile on change
|
|
80
|
+
# --debounce ms --poll
|
|
81
|
+
treelay eject <dest> # drop .treelay state, keep the files (--dry-run)
|
|
71
82
|
```
|
|
72
83
|
|
|
73
84
|
`compile`, `update` and `plan` also take `--frozen-lockfile`.
|
|
74
85
|
|
|
86
|
+
`validate` is built to be a merge gate: it reports every problem it can find in
|
|
87
|
+
one pass instead of stopping at the first, exits non-zero only on real errors
|
|
88
|
+
(stale pins and missing answers are warnings), and always lists the checks it
|
|
89
|
+
could *not* run — a clean report that quietly skipped half of them would be
|
|
90
|
+
worse than a noisy one.
|
|
91
|
+
|
|
92
|
+
`eject` is one-way. It deletes the baseline that makes `update` a three-way
|
|
93
|
+
merge rather than a guess, and nothing in the output can reconstruct it, so
|
|
94
|
+
`--dry-run` shows what the link was tracking before you cut it.
|
|
95
|
+
|
|
75
96
|
### Layers from git and npm, pinned
|
|
76
97
|
|
|
77
98
|
A parent, mixin or mount can live anywhere. The three forms are told apart by
|
|
@@ -93,11 +114,11 @@ merge by ordinary layer precedence, which is the point: a leaf can hold
|
|
|
93
114
|
override rule as everything else rather than a separate package mechanism.
|
|
94
115
|
|
|
95
116
|
```console
|
|
96
|
-
$ treelay plan
|
|
117
|
+
$ treelay plan klamath/project/_layer
|
|
97
118
|
Layers (lowest → highest precedence):
|
|
98
119
|
1. mount:packages [mounted at packages/, pinned 64cf108ee311]
|
|
99
120
|
2. core
|
|
100
|
-
3.
|
|
121
|
+
3. project
|
|
101
122
|
```
|
|
102
123
|
|
|
103
124
|
Whatever gets materialized is pinned in `treelay.lock` beside the leaf manifest
|
|
@@ -284,6 +305,8 @@ still local until you wire it in.
|
|
|
284
305
|
|
|
285
306
|
## Development
|
|
286
307
|
|
|
308
|
+
Requires Node 20.19 or newer.
|
|
309
|
+
|
|
287
310
|
```bash
|
|
288
311
|
npm install
|
|
289
312
|
npm run typecheck # tsc --noEmit
|
package/SPEC.md
CHANGED
|
@@ -96,7 +96,7 @@ Three origins, told apart by shape alone so nothing has to be declared twice:
|
|
|
96
96
|
|
|
97
97
|
Any non-local ref may carry **`?path=<subdir>`** to use a subdirectory of the
|
|
98
98
|
fetched tree as the layer root — the monorepo-of-layers case
|
|
99
|
-
(`git+https://…/
|
|
99
|
+
(`git+https://…/klamath.git?path=core/_layer#v3`). Order follows URL convention:
|
|
100
100
|
query before fragment.
|
|
101
101
|
|
|
102
102
|
**Git refs are enforced; npm refs are delegated.** A git ref names a commit, and
|
|
@@ -166,6 +166,24 @@ Final stack, lowest → highest:
|
|
|
166
166
|
2. `mixins` in declaration order (each strictly above all parents)
|
|
167
167
|
3. the directory's own files (always win)
|
|
168
168
|
|
|
169
|
+
The two lists run in opposite directions, which is worth stating plainly:
|
|
170
|
+
`parents` follows Python, so the **first**-declared base is the most derived and
|
|
171
|
+
outranks the ones after it; `mixins` are layered on in order, so the **last**
|
|
172
|
+
one declared wins. Parents answer "what am I a specialization of" (earlier =
|
|
173
|
+
closer to you); mixins answer "what is layered on top" (later = on top).
|
|
174
|
+
|
|
175
|
+
### Mixin ancestry **[decided]** ✅ *implemented*
|
|
176
|
+
|
|
177
|
+
A mixin may declare `parents` of its own. Each mixin's ancestry is C3-linearized
|
|
178
|
+
and placed **directly beneath that mixin**, while the whole group stays above
|
|
179
|
+
every one of the leaf's parents — so `parents < mixins < self` still holds when
|
|
180
|
+
read as groups rather than individual layers.
|
|
181
|
+
|
|
182
|
+
A layer that already appears lower in the stack keeps its position rather than
|
|
183
|
+
being re-inserted beneath its mixin. This is the diamond rule again: a shared
|
|
184
|
+
ancestor applies once, at its lowest position, because promoting it upward would
|
|
185
|
+
let it override the very layers it is supposed to sit beneath.
|
|
186
|
+
|
|
169
187
|
### Guards **[decided]**
|
|
170
188
|
|
|
171
189
|
- **Cycle detection** — `A → B → A` fails loud with the full path shown.
|
|
@@ -190,7 +208,7 @@ in a diff.
|
|
|
190
208
|
"resolved": "3f2a1c9e…", // the immutable thing it became
|
|
191
209
|
"integrity": "sha256:…", // over the materialized tree
|
|
192
210
|
"path": "core/_layer", // ?path=, when present
|
|
193
|
-
"requestedBy": ["
|
|
211
|
+
"requestedBy": ["klamath/project/_layer"] // relative to the lockfile
|
|
194
212
|
}
|
|
195
213
|
}
|
|
196
214
|
}
|
|
@@ -810,9 +828,12 @@ treelay explain <dir> [file] [--set k=v] [--answers f] [--json]
|
|
|
810
828
|
# trace which layers touched a file, in order, with patches
|
|
811
829
|
# <dir> = a source layer, or a compiled destination
|
|
812
830
|
# omit [file] to explain every path in the composition
|
|
813
|
-
treelay validate [dir]
|
|
814
|
-
|
|
815
|
-
treelay
|
|
831
|
+
treelay validate [dir] [--set k=v] [--answers f] [--drift] [--json]
|
|
832
|
+
# cycles? patches apply? unresolved conflicts? drift vs lock?
|
|
833
|
+
treelay watch <src> <dest> [--set k=v] [--debounce ms] [--poll]
|
|
834
|
+
# recompile on change
|
|
835
|
+
treelay eject <dest> [--dry-run]
|
|
836
|
+
# flatten + drop .treelay state (sever the template link)
|
|
816
837
|
```
|
|
817
838
|
|
|
818
839
|
`promote` and `extract` always end with the §8 round-trip recompile-and-verify,
|
|
@@ -820,6 +841,15 @@ and both refuse read-only or shadowed targets with a clear explanation.
|
|
|
820
841
|
|
|
821
842
|
`compile`, `update` and `plan` all accept `--frozen-lockfile` (§3).
|
|
822
843
|
|
|
844
|
+
`validate` **collects** rather than stopping at the first problem: it is
|
|
845
|
+
producing a report, not an artifact, so a finding only suppresses the checks
|
|
846
|
+
that genuinely cannot run without it, and whatever went unchecked is always
|
|
847
|
+
stated. Errors exit non-zero; warnings (stale pins, missing answers) do not, so
|
|
848
|
+
it can be a merge gate. `watch` re-resolves the whole graph on every pass —
|
|
849
|
+
editing a manifest can reshape the layer stack, so nothing from the previous
|
|
850
|
+
pass can be assumed still valid. `eject` is one-way: the baseline it removes is
|
|
851
|
+
the merge base `update` needs, and no part of the output can reconstruct it.
|
|
852
|
+
|
|
823
853
|
`plan` and `explain` are not nice-to-haves — they are the debugging story for a
|
|
824
854
|
system whose entire job is "this file came from somewhere non-obvious." Build
|
|
825
855
|
`plan` before `compile`.
|
|
@@ -913,8 +943,12 @@ De-risk by building resolution first, then output, then the bidirectional loops:
|
|
|
913
943
|
7. ✅ `explain` — per-file provenance (source layers or a compiled destination; `--json`)
|
|
914
944
|
8. ✅ `status` + file-level `promote` / `extract` — reflux (pushes changes *up*)
|
|
915
945
|
9. ✅ npm/git layer resolution + `mounts` + `treelay.lock` (pins, drift, `--frozen-lockfile`)
|
|
916
|
-
10. `watch` / `eject`
|
|
946
|
+
10. ✅ `validate` / `watch` / `eject` — the remaining §9 surface
|
|
917
947
|
|
|
918
948
|
Demoable and trustworthy after step 3; templated scaffolding works at step 4;
|
|
919
949
|
the headline pull-down lands at step 6, and the bidirectional link closes at
|
|
920
950
|
step 8.
|
|
951
|
+
|
|
952
|
+
Every command in §9 is now implemented. What remains is the **[open]** design
|
|
953
|
+
work rather than build order: hunk-level reflux, `by-key` array merging, and the
|
|
954
|
+
reflux/variables interaction (§8) — all deferred to v2 on purpose.
|
|
@@ -8,14 +8,14 @@ var CycleError = class extends Error {
|
|
|
8
8
|
path;
|
|
9
9
|
};
|
|
10
10
|
var InconsistentHierarchyError = class extends Error {
|
|
11
|
-
constructor(
|
|
12
|
-
super(`Inconsistent hierarchy: ${
|
|
11
|
+
constructor(message2) {
|
|
12
|
+
super(`Inconsistent hierarchy: ${message2}`);
|
|
13
13
|
this.name = "InconsistentHierarchyError";
|
|
14
14
|
}
|
|
15
15
|
};
|
|
16
16
|
var MergeConflictError = class extends Error {
|
|
17
|
-
constructor(file,
|
|
18
|
-
super(`Merge conflict in ${file}: ${
|
|
17
|
+
constructor(file, message2) {
|
|
18
|
+
super(`Merge conflict in ${file}: ${message2}`);
|
|
19
19
|
this.file = file;
|
|
20
20
|
this.name = "MergeConflictError";
|
|
21
21
|
}
|
|
@@ -135,7 +135,20 @@ function templateTarget(path, suffix = DEFAULT_TEMPLATE_SUFFIX) {
|
|
|
135
135
|
|
|
136
136
|
// src/variables.ts
|
|
137
137
|
import { createInterface } from "readline/promises";
|
|
138
|
+
import { Writable } from "stream";
|
|
138
139
|
import { parse as parseYaml2 } from "yaml";
|
|
140
|
+
var MaskableOutput = class extends Writable {
|
|
141
|
+
constructor(target = process.stdout) {
|
|
142
|
+
super();
|
|
143
|
+
this.target = target;
|
|
144
|
+
}
|
|
145
|
+
target;
|
|
146
|
+
muted = false;
|
|
147
|
+
_write(chunk, _encoding, done) {
|
|
148
|
+
if (!this.muted) this.target.write(chunk);
|
|
149
|
+
done();
|
|
150
|
+
}
|
|
151
|
+
};
|
|
139
152
|
function mergeVariableDecls(layers) {
|
|
140
153
|
const merged = {};
|
|
141
154
|
for (const layer of layers) {
|
|
@@ -196,11 +209,28 @@ async function resolveValues(graph, options = {}) {
|
|
|
196
209
|
const resolved = new Set(Object.keys(provided).filter((n) => decls[n]));
|
|
197
210
|
let pending = Object.keys(decls).filter((n) => !resolved.has(n));
|
|
198
211
|
let rl;
|
|
212
|
+
let out;
|
|
199
213
|
const promptVar = async (name, decl, fallback) => {
|
|
200
|
-
rl
|
|
214
|
+
if (!rl) {
|
|
215
|
+
out = new MaskableOutput();
|
|
216
|
+
rl = createInterface({
|
|
217
|
+
input: process.stdin,
|
|
218
|
+
output: out,
|
|
219
|
+
// readline infers `terminal` from `output.isTTY`, which a proxy stream
|
|
220
|
+
// does not have. Without this it would disable echo and line editing
|
|
221
|
+
// even in a real terminal.
|
|
222
|
+
terminal: process.stdout.isTTY === true
|
|
223
|
+
});
|
|
224
|
+
}
|
|
201
225
|
const hint = decl.choices ? ` ${JSON.stringify(decl.choices)}` : "";
|
|
202
226
|
const def = fallback !== void 0 ? ` [${String(fallback)}]` : "";
|
|
203
|
-
const
|
|
227
|
+
const pendingAnswer = rl.question(`${decl.prompt}${hint}${def} `);
|
|
228
|
+
if (decl.secret && out) out.muted = true;
|
|
229
|
+
const answer = (await pendingAnswer).trim();
|
|
230
|
+
if (decl.secret && out) {
|
|
231
|
+
out.muted = false;
|
|
232
|
+
out.write("\n");
|
|
233
|
+
}
|
|
204
234
|
return answer === "" ? fallback : coerce(decl.type, answer);
|
|
205
235
|
};
|
|
206
236
|
try {
|
|
@@ -466,8 +496,8 @@ import { execFileSync } from "child_process";
|
|
|
466
496
|
import { existsSync as existsSync3, rmSync as rmSync2 } from "fs";
|
|
467
497
|
import { join as join4 } from "path";
|
|
468
498
|
var GitFetchError = class extends Error {
|
|
469
|
-
constructor(url,
|
|
470
|
-
super(`git layer ${url}: ${
|
|
499
|
+
constructor(url, message2) {
|
|
500
|
+
super(`git layer ${url}: ${message2}`);
|
|
471
501
|
this.url = url;
|
|
472
502
|
this.name = "GitFetchError";
|
|
473
503
|
}
|
|
@@ -580,8 +610,8 @@ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
|
|
|
580
610
|
import { dirname, join as join5 } from "path";
|
|
581
611
|
import { satisfies, validRange } from "semver";
|
|
582
612
|
var NpmResolveError = class extends Error {
|
|
583
|
-
constructor(packageName,
|
|
584
|
-
super(`npm layer ${packageName}: ${
|
|
613
|
+
constructor(packageName, message2) {
|
|
614
|
+
super(`npm layer ${packageName}: ${message2}`);
|
|
585
615
|
this.packageName = packageName;
|
|
586
616
|
this.name = "NpmResolveError";
|
|
587
617
|
}
|
|
@@ -775,7 +805,17 @@ function resolve(srcDir, options = {}) {
|
|
|
775
805
|
const parentsOf = (layer) => (layer.manifest.parents ?? []).map((ref) => loadRef(ctx, ref, layer));
|
|
776
806
|
const mro = c3Linearize(self, parentsOf, (l) => l.id);
|
|
777
807
|
const parentsLowToHigh = mro.slice(1).reverse();
|
|
778
|
-
const
|
|
808
|
+
const mixinGroups = (self.manifest.mixins ?? []).flatMap((ref) => {
|
|
809
|
+
const mixin = loadRef(ctx, ref, self);
|
|
810
|
+
return c3Linearize(mixin, parentsOf, (l) => l.id).reverse();
|
|
811
|
+
});
|
|
812
|
+
const placed = /* @__PURE__ */ new Set([self.id, ...parentsLowToHigh.map((l) => l.id)]);
|
|
813
|
+
const mixins = [];
|
|
814
|
+
for (const layer of mixinGroups) {
|
|
815
|
+
if (placed.has(layer.id)) continue;
|
|
816
|
+
placed.add(layer.id);
|
|
817
|
+
mixins.push(layer);
|
|
818
|
+
}
|
|
779
819
|
const declared = [...parentsLowToHigh, ...mixins, self];
|
|
780
820
|
const mounts = resolveMounts(ctx, declared);
|
|
781
821
|
const layers = [...mounts, ...declared];
|
|
@@ -944,11 +984,11 @@ function assertParseable(file, patch) {
|
|
|
944
984
|
try {
|
|
945
985
|
hunks = parsePatch(patch).reduce((n, p) => n + p.hunks.length, 0);
|
|
946
986
|
} catch (err) {
|
|
947
|
-
const
|
|
948
|
-
const hint = /invalid line/i.test(
|
|
987
|
+
const message2 = err instanceof Error ? err.message : String(err);
|
|
988
|
+
const hint = /invalid line/i.test(message2) ? "\nCheck the `@@ -old,COUNT +new,COUNT @@` header: the counts must match the number of lines in the hunk body (context + removals for the first, context + additions for the second)." : "";
|
|
949
989
|
throw new MergeConflictError(
|
|
950
990
|
file,
|
|
951
|
-
`patch payload is not a valid unified diff \u2014 ${
|
|
991
|
+
`patch payload is not a valid unified diff \u2014 ${message2}${hint}`
|
|
952
992
|
);
|
|
953
993
|
}
|
|
954
994
|
if (hunks === 0) {
|
|
@@ -2569,6 +2609,298 @@ var LayerTransaction = class {
|
|
|
2569
2609
|
}
|
|
2570
2610
|
};
|
|
2571
2611
|
|
|
2612
|
+
// src/eject.ts
|
|
2613
|
+
import { rmSync as rmSync6 } from "fs";
|
|
2614
|
+
var NotEjectableError = class extends Error {
|
|
2615
|
+
constructor(destDir) {
|
|
2616
|
+
super(
|
|
2617
|
+
`${destDir} has no .treelay state \u2014 it is not a compiled destination, so there is no template link to sever.`
|
|
2618
|
+
);
|
|
2619
|
+
this.name = "NotEjectableError";
|
|
2620
|
+
}
|
|
2621
|
+
};
|
|
2622
|
+
function eject(destDir, options = {}) {
|
|
2623
|
+
if (!hasState(destDir)) throw new NotEjectableError(destDir);
|
|
2624
|
+
const state = readState(destDir);
|
|
2625
|
+
const entries = Object.entries(state.manifest);
|
|
2626
|
+
const source = sourceOf(state);
|
|
2627
|
+
const result = {
|
|
2628
|
+
...source !== void 0 ? { source } : {},
|
|
2629
|
+
lineage: state.lock.lineage,
|
|
2630
|
+
// `owned` marks a *user*-created file, so the template's own files are the
|
|
2631
|
+
// ones where it is false (§7).
|
|
2632
|
+
tracked: entries.filter(([, m]) => !m.owned).map(([path]) => path).sort(),
|
|
2633
|
+
userOwned: entries.filter(([, m]) => m.owned).map(([path]) => path).sort(),
|
|
2634
|
+
removed: false
|
|
2635
|
+
};
|
|
2636
|
+
if (options.dryRun) return result;
|
|
2637
|
+
rmSync6(statePaths(destDir).dir, { recursive: true, force: true });
|
|
2638
|
+
return { ...result, removed: true };
|
|
2639
|
+
}
|
|
2640
|
+
function formatEject(result, destDir) {
|
|
2641
|
+
const lines = [];
|
|
2642
|
+
const verb = result.removed ? "Ejected" : "Would eject";
|
|
2643
|
+
lines.push(`${verb} ${destDir}.`);
|
|
2644
|
+
if (result.source) lines.push(` was tracking: ${result.source}`);
|
|
2645
|
+
if (result.lineage.length) {
|
|
2646
|
+
lines.push(` composed from ${result.lineage.length} layer(s)`);
|
|
2647
|
+
}
|
|
2648
|
+
lines.push(` ${result.tracked.length} template-owned file(s) left in place`);
|
|
2649
|
+
if (result.userOwned.length) {
|
|
2650
|
+
lines.push(` ${result.userOwned.length} file(s) you created, untouched`);
|
|
2651
|
+
}
|
|
2652
|
+
lines.push(
|
|
2653
|
+
result.removed ? `
|
|
2654
|
+
\`treelay update\` no longer works here. Re-linking means compiling the template into a fresh directory and copying your changes across.` : `
|
|
2655
|
+
Nothing was removed. Re-run without --dry-run to sever the link.`
|
|
2656
|
+
);
|
|
2657
|
+
return lines.join("\n");
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
// src/validate.ts
|
|
2661
|
+
async function validate(srcDir, options = {}) {
|
|
2662
|
+
const issues = [];
|
|
2663
|
+
const skipped = [];
|
|
2664
|
+
const { values: given, drift: probeDrift, ...resolveOptions } = options;
|
|
2665
|
+
let graph;
|
|
2666
|
+
try {
|
|
2667
|
+
graph = resolve(srcDir, resolveOptions);
|
|
2668
|
+
} catch (err) {
|
|
2669
|
+
issues.push(classifyResolveError(err));
|
|
2670
|
+
skipped.push("patches and conflicts (the graph did not resolve)");
|
|
2671
|
+
skipped.push("lockfile freshness (the graph did not resolve)");
|
|
2672
|
+
return { ok: false, issues, skipped };
|
|
2673
|
+
}
|
|
2674
|
+
if (graph.lockDirty) {
|
|
2675
|
+
issues.push({
|
|
2676
|
+
severity: "warning",
|
|
2677
|
+
code: "lock-stale",
|
|
2678
|
+
message: `${lockfilePath(srcDir)} does not match what this tree resolves to.`,
|
|
2679
|
+
remedy: `Run \`treelay lock ${srcDir}\` and commit the result.`
|
|
2680
|
+
});
|
|
2681
|
+
}
|
|
2682
|
+
let values;
|
|
2683
|
+
try {
|
|
2684
|
+
values = await resolveValues(graph, { ...given ? { set: given } : {}, prompt: false });
|
|
2685
|
+
} catch (err) {
|
|
2686
|
+
issues.push({
|
|
2687
|
+
severity: "warning",
|
|
2688
|
+
code: "variables-unresolved",
|
|
2689
|
+
message: message(err),
|
|
2690
|
+
remedy: "Pass --answers or --set so validate can render and compose the tree; without values it cannot check patches or conflicts."
|
|
2691
|
+
});
|
|
2692
|
+
skipped.push("patches and conflicts (no values to render with)");
|
|
2693
|
+
}
|
|
2694
|
+
if (values) {
|
|
2695
|
+
try {
|
|
2696
|
+
const files = await composeToMemory(graph, values);
|
|
2697
|
+
return finish(issues, skipped, graph, files.size, probeDrift, srcDir);
|
|
2698
|
+
} catch (err) {
|
|
2699
|
+
issues.push(classifyComposeError(err));
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
return finish(issues, skipped, graph, void 0, probeDrift, srcDir);
|
|
2703
|
+
}
|
|
2704
|
+
function finish(issues, skipped, graph, fileCount, probeDrift, srcDir) {
|
|
2705
|
+
if (probeDrift) {
|
|
2706
|
+
const reports = checkDrift(graph);
|
|
2707
|
+
if (hasDrift(reports)) {
|
|
2708
|
+
issues.push({
|
|
2709
|
+
severity: "warning",
|
|
2710
|
+
code: "drift",
|
|
2711
|
+
message: formatDrift(reports).trim(),
|
|
2712
|
+
remedy: `Run \`treelay lock ${srcDir} --update\` to advance the pins.`
|
|
2713
|
+
});
|
|
2714
|
+
}
|
|
2715
|
+
} else {
|
|
2716
|
+
skipped.push("upstream drift (needs the network; pass --drift)");
|
|
2717
|
+
}
|
|
2718
|
+
return {
|
|
2719
|
+
ok: !issues.some((i) => i.severity === "error"),
|
|
2720
|
+
issues,
|
|
2721
|
+
layerCount: graph.layers.length,
|
|
2722
|
+
...fileCount !== void 0 ? { fileCount } : {},
|
|
2723
|
+
skipped
|
|
2724
|
+
};
|
|
2725
|
+
}
|
|
2726
|
+
var message = (err) => err instanceof Error ? err.message : String(err);
|
|
2727
|
+
function classifyResolveError(err) {
|
|
2728
|
+
const name = err instanceof Error ? err.name : "";
|
|
2729
|
+
if (name === "CycleError") {
|
|
2730
|
+
return {
|
|
2731
|
+
severity: "error",
|
|
2732
|
+
code: "cycle",
|
|
2733
|
+
message: message(err),
|
|
2734
|
+
remedy: "Break the loop: a layer cannot be its own ancestor."
|
|
2735
|
+
};
|
|
2736
|
+
}
|
|
2737
|
+
if (name === "InconsistentHierarchyError") {
|
|
2738
|
+
return {
|
|
2739
|
+
severity: "error",
|
|
2740
|
+
code: "hierarchy",
|
|
2741
|
+
message: message(err),
|
|
2742
|
+
remedy: "Two layers demand incompatible orderings of the same ancestors. Reorder the conflicting `parents` lists so they agree."
|
|
2743
|
+
};
|
|
2744
|
+
}
|
|
2745
|
+
return { severity: "error", code: "resolve-failed", message: message(err) };
|
|
2746
|
+
}
|
|
2747
|
+
function classifyComposeError(err) {
|
|
2748
|
+
if (err instanceof Error && err.name === "MergeConflictError") {
|
|
2749
|
+
return {
|
|
2750
|
+
severity: "error",
|
|
2751
|
+
code: "merge-conflict",
|
|
2752
|
+
message: message(err),
|
|
2753
|
+
remedy: "Either the patch no longer applies to the inherited file, or two layers edit the same lines. `treelay explain <dir> <file>` shows who touched it."
|
|
2754
|
+
};
|
|
2755
|
+
}
|
|
2756
|
+
return { severity: "error", code: "compose-failed", message: message(err) };
|
|
2757
|
+
}
|
|
2758
|
+
function formatValidation(report, srcDir) {
|
|
2759
|
+
const lines = [];
|
|
2760
|
+
const errors = report.issues.filter((i) => i.severity === "error");
|
|
2761
|
+
const warnings = report.issues.filter((i) => i.severity === "warning");
|
|
2762
|
+
for (const issue of [...errors, ...warnings]) {
|
|
2763
|
+
lines.push(`${issue.severity === "error" ? "\u2717" : "!"} ${issue.code}: ${issue.message}`);
|
|
2764
|
+
if (issue.remedy) lines.push(` ${issue.remedy}`);
|
|
2765
|
+
}
|
|
2766
|
+
if (lines.length) lines.push("");
|
|
2767
|
+
const scope = report.layerCount === void 0 ? "" : ` (${report.layerCount} layer(s)` + (report.fileCount === void 0 ? "" : `, ${report.fileCount} file(s)`) + ")";
|
|
2768
|
+
lines.push(
|
|
2769
|
+
errors.length ? `${errors.length} error(s), ${warnings.length} warning(s) in ${srcDir}${scope}.` : warnings.length ? `No errors, ${warnings.length} warning(s) in ${srcDir}${scope}.` : `${srcDir} is valid${scope}.`
|
|
2770
|
+
);
|
|
2771
|
+
for (const s of report.skipped) lines.push(` not checked: ${s}`);
|
|
2772
|
+
return lines.join("\n");
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
// src/watch.ts
|
|
2776
|
+
import { watch as chokidarWatch } from "chokidar";
|
|
2777
|
+
import { relative as relative4, resolve as resolvePath6, isAbsolute as isAbsolute3, sep as sep2 } from "path";
|
|
2778
|
+
var WatchTargetError = class extends Error {
|
|
2779
|
+
constructor(destDir, existing, incoming) {
|
|
2780
|
+
super(
|
|
2781
|
+
`${destDir} was compiled from ${existing}, not ${incoming}. Watching it would overwrite that project's files. Pick an empty destination.`
|
|
2782
|
+
);
|
|
2783
|
+
this.name = "WatchTargetError";
|
|
2784
|
+
}
|
|
2785
|
+
};
|
|
2786
|
+
function isInside(parent, child) {
|
|
2787
|
+
const rel = relative4(parent, child);
|
|
2788
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute3(rel);
|
|
2789
|
+
}
|
|
2790
|
+
function watchRoots(layerDirs, destDir) {
|
|
2791
|
+
const roots = [];
|
|
2792
|
+
for (const dir of layerDirs) {
|
|
2793
|
+
if (isInside(dir, destDir) && dir === destDir) continue;
|
|
2794
|
+
if (!roots.some((r) => isInside(r, dir))) {
|
|
2795
|
+
for (let i = roots.length - 1; i >= 0; i--) {
|
|
2796
|
+
if (isInside(dir, roots[i])) roots.splice(i, 1);
|
|
2797
|
+
}
|
|
2798
|
+
roots.push(dir);
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
return roots.sort();
|
|
2802
|
+
}
|
|
2803
|
+
async function watch(srcDir, destDir, options = {}) {
|
|
2804
|
+
const {
|
|
2805
|
+
values,
|
|
2806
|
+
debounceMs = 120,
|
|
2807
|
+
usePolling,
|
|
2808
|
+
pollIntervalMs,
|
|
2809
|
+
onEvent,
|
|
2810
|
+
...resolveOptions
|
|
2811
|
+
} = options;
|
|
2812
|
+
const src = resolvePath6(srcDir);
|
|
2813
|
+
const dest = resolvePath6(destDir);
|
|
2814
|
+
if (hasState(dest)) {
|
|
2815
|
+
const existing = sourceOf(readState(dest));
|
|
2816
|
+
if (existing && resolvePath6(existing) !== src) {
|
|
2817
|
+
throw new WatchTargetError(dest, existing, src);
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
let watcher;
|
|
2821
|
+
let roots = [src];
|
|
2822
|
+
const syncRoots = (dirs) => {
|
|
2823
|
+
const next = watchRoots([src, ...dirs], dest);
|
|
2824
|
+
const added = next.filter((r) => !roots.includes(r));
|
|
2825
|
+
roots = next;
|
|
2826
|
+
if (added.length && watcher) watcher.add(added);
|
|
2827
|
+
};
|
|
2828
|
+
const build = async (trigger) => {
|
|
2829
|
+
const started = Date.now();
|
|
2830
|
+
try {
|
|
2831
|
+
const graph = resolve(src, resolveOptions);
|
|
2832
|
+
const composed = await resolveValues(graph, {
|
|
2833
|
+
...values ? { set: values } : {},
|
|
2834
|
+
prompt: false
|
|
2835
|
+
});
|
|
2836
|
+
const result = await compile(graph, { destDir: dest, values: composed });
|
|
2837
|
+
syncRoots(
|
|
2838
|
+
graph.layers.filter((l) => !l.origin || l.origin.kind === "local").map((l) => l.dir)
|
|
2839
|
+
);
|
|
2840
|
+
onEvent?.({
|
|
2841
|
+
kind: "compiled",
|
|
2842
|
+
files: Object.keys(result.files).length,
|
|
2843
|
+
ms: Date.now() - started,
|
|
2844
|
+
...trigger ? { trigger } : {}
|
|
2845
|
+
});
|
|
2846
|
+
} catch (err) {
|
|
2847
|
+
onEvent?.({
|
|
2848
|
+
kind: "failed",
|
|
2849
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
2850
|
+
...trigger ? { trigger } : {}
|
|
2851
|
+
});
|
|
2852
|
+
}
|
|
2853
|
+
};
|
|
2854
|
+
await build();
|
|
2855
|
+
let timer;
|
|
2856
|
+
let pending;
|
|
2857
|
+
let closed = false;
|
|
2858
|
+
watcher = chokidarWatch(roots, {
|
|
2859
|
+
ignoreInitial: true,
|
|
2860
|
+
...usePolling ? { usePolling: true } : {},
|
|
2861
|
+
...pollIntervalMs !== void 0 ? { interval: pollIntervalMs } : {},
|
|
2862
|
+
// The output must never feed the loop that produces it, and neither should
|
|
2863
|
+
// the state directory compile itself writes on every pass.
|
|
2864
|
+
ignored: (path) => isInside(dest, path) || path.split(sep2).includes(STATE_DIR) || path.split(sep2).includes("node_modules") || path.split(sep2).includes(".git")
|
|
2865
|
+
});
|
|
2866
|
+
watcher.on("all", (_event, path) => {
|
|
2867
|
+
if (closed) return;
|
|
2868
|
+
pending ??= path;
|
|
2869
|
+
if (timer) clearTimeout(timer);
|
|
2870
|
+
timer = setTimeout(() => {
|
|
2871
|
+
const trigger = pending;
|
|
2872
|
+
pending = void 0;
|
|
2873
|
+
timer = void 0;
|
|
2874
|
+
void build(trigger);
|
|
2875
|
+
}, debounceMs);
|
|
2876
|
+
});
|
|
2877
|
+
await new Promise((res) => watcher.once("ready", () => res()));
|
|
2878
|
+
onEvent?.({ kind: "ready", roots });
|
|
2879
|
+
return {
|
|
2880
|
+
// A getter, not a snapshot: `syncRoots` replaces the array when the graph
|
|
2881
|
+
// gains a layer, and a stale copy would misreport what is being watched.
|
|
2882
|
+
get roots() {
|
|
2883
|
+
return roots;
|
|
2884
|
+
},
|
|
2885
|
+
trigger: () => build(),
|
|
2886
|
+
close: async () => {
|
|
2887
|
+
closed = true;
|
|
2888
|
+
if (timer) clearTimeout(timer);
|
|
2889
|
+
await watcher.close();
|
|
2890
|
+
}
|
|
2891
|
+
};
|
|
2892
|
+
}
|
|
2893
|
+
function formatWatchEvent(event) {
|
|
2894
|
+
switch (event.kind) {
|
|
2895
|
+
case "ready":
|
|
2896
|
+
return `Watching ${event.roots.length} layer(s). Ctrl-C to stop.`;
|
|
2897
|
+
case "compiled":
|
|
2898
|
+
return `Compiled ${event.files} file(s) in ${event.ms}ms` + (event.trigger ? ` \u2190 ${event.trigger}` : "");
|
|
2899
|
+
case "failed":
|
|
2900
|
+
return `Build failed: ${event.error.message}`;
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2572
2904
|
export {
|
|
2573
2905
|
CycleError,
|
|
2574
2906
|
InconsistentHierarchyError,
|
|
@@ -2660,6 +2992,14 @@ export {
|
|
|
2660
2992
|
status,
|
|
2661
2993
|
formatStatus,
|
|
2662
2994
|
promote,
|
|
2663
|
-
extract
|
|
2995
|
+
extract,
|
|
2996
|
+
NotEjectableError,
|
|
2997
|
+
eject,
|
|
2998
|
+
formatEject,
|
|
2999
|
+
validate,
|
|
3000
|
+
formatValidation,
|
|
3001
|
+
WatchTargetError,
|
|
3002
|
+
watch,
|
|
3003
|
+
formatWatchEvent
|
|
2664
3004
|
};
|
|
2665
|
-
//# sourceMappingURL=chunk-
|
|
3005
|
+
//# sourceMappingURL=chunk-CP7GIVGA.js.map
|