Merged
KoalaHaus 2: the kh2 build engine, widgets and CLI #170
pavelmironchikAdd on 24 days ago
KoalaHaus 2: the kh2 build engine, widgets and CLI #170
Merged
pavelmironchikAdd on 24 days ago
Merged frompm/kh2-engineintomaster
Total: 181 files changed+42465-4
Mdocs/second-brain/40_Development_Workflow/CI_CD_Pipeline.md+24-0File content is under review, please try again later
@@ -8,6 +8,18 @@
8/demo/app/build/8/demo/app/build/
9/haus/lib9/haus/lib
10/haus/data10/haus/data
11+/haus2/engine/lib
12+/haus2/engine/lib-test
13+/haus2/engine/lib-example
14+/haus2/engine/lib-bench
15+/haus2/engine/.kh
16+/haus2/widgets/lib
17+/haus2/widgets/lib-test
18+/haus2/loader/lib
19+/haus2/loader/lib-test
20+/haus2/loader/example/touch/.kh
21+/haus2/loader/example/ninja/.kh
22+/haus2/loader/example/ninja/out
11/test/.recompile-root/23/test/.recompile-root/
12/test/.deps-root/24/test/.deps-root/
13/test/.multi-root/25/test/.multi-root/
@@ -42,7 +42,7 @@ target artifact is already built, and if so - avoid building of the node in D an
42```42```
43 43 
44 44 
45-## Running45+ ## Running
46 46 
47 Current version mostly focuses on incremental updates with fixed build scripts, and thus does not47 Current version mostly focuses on incremental updates with fixed build scripts, and thus does not
48 track changes in the build script itself, which is being compiled by regular build system.48 track changes in the build script itself, which is being compiled by regular build system.
@@ -54,4 +54,39 @@ target artifact is already built, and if so - avoid building of the node in D an
54 npm run run54 npm run run
55 ```55 ```
56 56 
57- will show the basics of tracking build system. No real rebuild happens.57+ will show the basics of tracking build system. No real rebuild happens.
58+ 
59+ ## haus2
60+ 
61+ The second-generation rewrite lives in `haus2/`, as three packages:
62+ `haus2/engine` (`@koalaui/haus2-engine`, the build graph engine and the memo
63+ widgets core), `haus2/widgets` (`@koalaui/haus2-widgets`, widgets over
64+ external tools) and `haus2/loader` (`@koalaui/haus2`, the `kh2` CLI and the
65+ build scripts' single entry point, re-exporting the engine and the widgets).
66+ User documentation: `kh2 help` (`node haus2/loader/bin/kh2.js help`) prints
67+ an overview and the index of the bundled documents (`haus2/*/docs/*.md`);
68+ each package's `README.md` is the guide for developing it.
69+ There the
70+ incremental runtime only constructs the jobs tree while the build graph engine
71+ executes it; only build outputs are persisted (in `<build-root>/.kh/build.db`)
72+ and the daemon cycle is wakeup-driven instead of polled.
73+ 
74+ ```bash
75+ npm run test:haus2 # engine, widgets and loader tests
76+ 
77+ # or directly on any build root containing kh.build.ts files:
78+ # (single run by default; exits 1 when some jobs are still waiting/failed)
79+ node haus2/loader/bin/kh2.js -C <build-root>
80+ node haus2/loader/bin/kh2.js -C <build-root> --watch # watch and run until Ctrl+C
81+ ```
82+ 
83+ An input change (e.g. `haus2/loader/example/touch/input.txt`) rebuilds `touched.txt` while
84+ the daemon runs with `--watch`; restarting reuses the recorded output from the
85+ database. Every rule build writes its job output (both stdout and stderr) to a
86+ per-rule log file under `<build-root>/.kh/log` instead of the console, named
87+ after the first declared output (e.g. `.kh/output/foo.o` maps to
88+ `.kh/log/output/foo.o.log`); a failing job prints the dump of that log. The
89+ console shows ninja-like events (`✔ done`, `↻ restarted`, `✘ failed`) and a
90+ progress line — `building [N/M][, failed F]` → `built [N/M]` — pinned at the
91+ bottom on a TTY and printed once per change otherwise. Other options:
92+ `--polling`, `--quiet`, `--verbose` (`kh2 --help` for the full list).
@@ -0,0 +1,70 @@
1+# Copyright (c) 2026 Huawei Device Co., Ltd.
2+# Licensed under the Apache License, Version 2.0 (the "License");
3+# you may not use this file except in compliance with the License.
4+# You may obtain a copy of the License at
5+#
6+# http://www.apache.org/licenses/LICENSE-2.0
7+#
8+# Unless required by applicable law or agreed to in writing, software
9+# distributed under the License is distributed on an "AS IS" BASIS,
10+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+# See the License for the specific language governing permissions and
12+# limitations under the License.
13+ 
14+# haus2 (kh2): the engine, the widgets and the loader. They belong to the
15+# isolated koala-haus npm workspace, which install:root does not install, so
16+# every job runs its own `npm install` in koala-haus (the npm cache makes the
17+# repeated installs cheap). One build job compiles the three packages and their
18+# tests; one test job runs the three harnesses in parallel through kh2 itself,
19+# with the build script koala-haus/haus2/kh.build.ts (a rule per package, so a
20+# failure dumps that package's log).
21+#
22+# The tests need no arkui3 artifact: the arkts suites stub the tool, the meson
23+# suites skip themselves when meson is not on PATH, and every watching test
24+# polls (the runner's workspace fs may not deliver inotify events).
25+ 
26+.haus2-job:
27+ extends: .npm-job
28+ rules:
29+ - if: $CI_PIPELINE_SOURCE == "pipeline"
30+ changes:
31+ - koala-haus/haus2/**/*
32+ - koala-haus/package.json
33+ needs: []
34+ variables:
35+ # better-sqlite3 (the engine's build database) compiles from source: its
36+ # prebuilds come from GitHub, which CI cannot reach, and the image has
37+ # clang++ but no g++ (see install:root).
38+ CXX: clang++
39+ 
40+build:haus2:
41+ extends: .haus2-job
42+ stage: build
43+ script:
44+ - cd koala-haus
45+ - npm install
46+ # the loader compile also merges the docs of the three packages into
47+ # haus2/loader/lib/docs
48+ - npm run compile:haus2
49+ - npm run compile:test -w haus2/engine -w haus2/widgets -w haus2/loader
50+ artifacts:
51+ paths:
52+ - koala-haus/haus2/*/lib/
53+ - koala-haus/haus2/*/lib-test/
54+ expire_in: 1 day
55+ 
56+test:haus2:
57+ extends: .haus2-job
58+ stage: test
59+ needs:
60+ - build:haus2
61+ script:
62+ - cd koala-haus
63+ - npm install
64+ - node haus2/loader/bin/kh2.js -C haus2
65+ artifacts:
66+ when: on_failure
67+ paths:
68+ # the per-rule logs (kh2 already dumps the failed ones)
69+ - koala-haus/haus2/.kh/log/
70+ expire_in: 3 days
@@ -0,0 +1,3 @@
1+{
2+ "spec": ["./lib-test/test/**/*.test.js"]
3+}
Akoala-haus/haus2/engine/CLAUDE.md+8-0File content is under review, please try again later
@@ -0,0 +1,85 @@
1+# @koalaui/haus2-engine
2+ 
3+The haus2 build engine: the reactive build tree (`BuildOwner`), the daemon
4+that drives it together with the memo runtime, the file API build scripts
5+declare with, and the engine-generic widgets. It knows no external tool and
6+no CLI. User documentation is in [`docs/`](docs) (merged into the `kh2 help`
7+bundle); this file is for developing the package.
8+ 
9+## Responsibilities
10+ 
11+| Path | Responsibility |
12+|---|---|
13+| `src/build/ReactiveBuildTree.ts` | `BuildOwner`: structural update, ordering, build step, goals, structural errors, garbage collection, the tree dump; the `RuleNode`/`FileNode`/`Fs`/storage/diagnostics interfaces |
14+| `src/build/hash-policy.ts` | the default content (hash) build policy (`hash_*`) |
15+| `src/build/explain.ts` | the build key layout and the `--why-build` explanation helpers |
16+| `src/build/DiagnosticsInterpreter.ts` | low-level structural and build diagnostics as text |
17+| `src/build/incremental.ts` | `buildOwner()` / `withBuildOwner` context |
18+| `src/build/arrays.ts` | shared array equality |
19+| `src/node.ts` | `BuildNode`, the base class of every rule widget |
20+| `src/fs.ts` | `file`/`outFile` descriptors and the watched states (`watchedFile`, `watchedBuildFile`, `watchedTree`, `watchedGlob`, `WatchedFiles`) |
21+| `src/context.ts` | `BuildContext`, `ScriptHost` and the memo-code accessors (`buildContext`, `scriptHost`, `buildParam`, ...) |
22+| `src/daemon.ts` | `BuildDaemon`: the public facade and the frame cycle |
23+| `src/session.ts` | `BuildSession`: owner, runner, storage, filesystem, reporter, logger, group and target registries |
24+| `src/scripts.ts`, `src/script.ts` | the memo root, the script entries and their hosts, live `setScript` |
25+| `src/inspect.ts` | the idle inspection: persistence, blocked inputs, structural error reports |
26+| `src/runner.ts`, `src/runner-api.ts` | `AsyncBuildRunner`: pools, the global job limit, the console pool, per-rule logs |
27+| `src/rebuild-check.ts` | `RebuildCheckRunner`: the "built twice in a one-shot" check |
28+| `src/reporter.ts` | `BuildReporter`: the readable output and the pinned progress block |
29+| `src/storage.ts` | `SqliteStorage`: the persisted build records |
30+| `src/watcher.ts`, `src/tree-watcher.ts`, `src/watch-limits.ts` | filesystem watching and the inotify limit report |
31+| `src/alias.ts`, `src/targets.ts` | `Alias`, the target registry, CLI target resolution |
32+| `src/goal.ts`, `src/pool.ts`, `src/report.ts`, `src/params.ts` | `Goal`, `Pool`, `Report`, build parameters |
33+| `src/group-registry.ts`, `src/widgets/group.ts` | `Group`/`addToGroup` and their per-daemon registry |
34+| `src/widgets/barrier.ts`, `phony.ts`, `touchfile.ts` | `Barrier`, `Phony`, `TouchFile` |
35+| `src/log.ts`, `src/rules.ts` | the `Logger` interface, readable rule names |
36+ 
37+Boundary: a widget that knows an external tool goes to `@koalaui/haus2-widgets`;
38+anything about the `kh2` command line goes to `@koalaui/haus2`.
39+ 
40+## Performance requirements
41+ 
42+Read [`docs/engine-performance.md`](docs/engine-performance.md) before
43+touching `src/build/` or the daemon cycle. In short:
44+ 
45+- The cost of a frame is proportional to what changed. No scan over all
46+ rules, files or errors outside the idle point; keep a reverse mirror or a
47+ counter instead.
48+- No per-rule or per-edge allocation in the structural update, the ordering
49+ or the build step. Reuse owner-held arrays (grow-only stacks, cleared
50+ slots), replace input/output arrays only when they differ.
51+- O(tree) work (dump, persistence, GC) runs once per idle point.
52+- Diagnostics hooks must cost nothing when not installed.
53+- Benchmark the ordering with `npm run bench:order`; add a bench for a new
54+ hot path.
55+ 
56+## Rules
57+ 
58+- The engine never names the CLI and never calls `console` outside
59+ `consoleLogger`; diagnostics go through `Logger`.
60+- `RuleNode.buildOwner` is written only by the owner.
61+- Never write the memo annotation token (`@` + `memo`) in prose comments: the
62+ transformer reads it in any JSDoc block.
63+- Sources compile with `@koalaui/ets-tsc` (the memo transform); stock `tsc`
64+ emits broken code.
65+- Apache header with the current year in every new file.
66+ 
67+## Commands
68+ 
69+```bash
70+npm run compile # ets-tsc -p tsconfig.json → lib/
71+npm run test # compile + harness (lib-test/test/**/*.test.js)
72+npm run bench:order # ordering benchmark
73+npm run example # the diagnostics example
74+```
75+ 
76+A renamed or removed source leaves its compiled file in `lib/` and `lib-test/`;
77+delete it there, or the harness keeps running the stale test.
78+ 
79+## Documentation
80+ 
81+`docs/*.md` are user documents with a yaml front matter (`name` equal to the
82+file name, `description`). They are merged with the other haus2 packages'
83+documents when `@koalaui/haus2` compiles, and indexed by `kh2 help`. Update
84+them together with the code; `koala-haus/CLAUDE.md` keeps the detailed
85+design notes.
@@ -0,0 +1,357 @@
1+# haus2 / haus2-loader — refactoring plan after the architecture review
2+ 
3+Status: done (2026-10-05). Baseline: 217 engine tests, 14 loader tests.
4+Result: haus2 216 + haus2-ninja 36 (moved from haus2) + haus2-loader 28, all passing;
5+`example:touch` and `example:ninja` work. Review follow-ups applied on top of the
6+tasks: readable overlap messages (`rule#N (inputs: …)`, T4), in-flight persistence
7+only of changed outputs instead of a full dump and removable executor exit
8+listeners (T10), `BuildDaemonOptions.reportBlockedInputs` instead of a loader
9+logger that dropped every warning (T11).
10+ 
11+Decisions taken in the review (not to be changed by these tasks):
12+- `RuleNode` keeps its public `b*` fields (object count), `BuildNode` stays the
13+ root class of every rule; runner, reporter and daemon may know `BuildNode`.
14+- The consumer decides which file parameter (hash, mtime, rule finish time)
15+ it reacts on; no producer-side file version.
16+- Pools are not ref-counted. Undeclared helper JS outputs stay. The
17+ `PositionalIdTracker.callCount` reset stays.
18+- The `BuildOwner` algorithm is changed only where a task says so (T3, T4, T6).
19+ 
20+## Global acceptance criteria (every task)
21+ 
22+- `npm run test -w haus2` and `npm run test -w haus2-loader` pass (and
23+ `npm run test -w haus2-ninja` once it exists); the test count never drops
24+ except for tests moved to another package.
25+- `npm run example:touch -w haus2-loader` and `npm run example:ninja -w haus2-loader` work.
26+- New/changed files keep the Apache header (years `2026`); no literal memo
27+ annotation token (`@` + `memo`) in prose comments.
28+- `koala-haus/CLAUDE.md` (haus2 / haus2-loader sections) describes the result.
29+- No commits, no `git add -A`, no `npm publish`.
30+ 
31+## Tasks
32+ 
33+### T1 — Common logging interface (review 15, part of 10)
34+- `haus2/src/log.ts`: `interface Logger { info(lines); warn(lines); error(lines) }`
35+ (`string | ReadonlyArray<string>`), `consoleLogger(prefix?)` default.
36+- `BuildDaemonOptions.logger`, passed down to `ChokidarFs`, the runner error
37+ callback and the blocked-input report; `BuildReporter` prints logger lines
38+ above the pinned bar (`BuildReporter` can act as a Logger).
39+- The engine never names the CLI: no `kh2:` literal and no `console.*` in
40+ `haus2/src` except inside `consoleLogger`.
41+- `LoaderLog` implements `Logger`; the executor passes a logger that adds the `kh2:` prefix.
42+- Accept: grep criteria above; a test injects a recording logger and sees the
43+ blocked-input lines and a watcher fallback warning.
44+ 
45+### T2 — Rename the daemon-facing control (review 9)
46+- `context.ts` `BuildControl` → `ScriptHost`, `buildControl()` → `scriptHost()`,
47+ `withBuildControl` → `withScriptHost`, `DaemonBuildControl` → `DaemonScriptHost`.
48+- `PooledBuildRunner` interface moves out of `runner.ts` (e.g. `runner-api.ts`
49+ or `context.ts`), so `context.ts` imports no implementation module.
50+- Accept: only one exported type is named `BuildControl` (the rule-facing one);
51+ `context.ts` imports interfaces only.
52+ 
53+### T3 — Persistent monotonic build stamp (review 5)
54+- Replace `performance.now()` in `BuildOwner` with a stamp source that is
55+ strictly increasing within a process and across restarts:
56+ `stamp = max(Date.now(), lastStamp + 1)`, `lastStamp` initialized from the
57+ maximum stored `buildRuleMtime` (`BuildPersistantStorage.maxBuildRuleMtime()`, optional
58+ method; 0 when absent).
59+- Accept: no `performance.now` in `haus2/src/build`; a test shows two builds
60+ of a rule across a daemon restart get increasing stamps, and the restored
61+ stamp of a cache match is kept.
62+ 
63+### T4 — Structural errors instead of exceptions (review 2)
64+- Cycle: `orderGraph` is iterative, never leaves `bInCycle` set, and reports the
65+ rules of the cycle instead of throwing. While the graph has a cycle the owner
66+ does not run `performBuild` (execution order is undefined); the error lives
67+ until a structural update removes the cycle.
68+- Overlapping outputs: a rule whose outputs intersect another rule's outputs
69+ becomes its sibling even when meta/inputs/outputs are not identical, and a
70+ structural error `overlapping-outputs` (both rules, the paths) is recorded.
71+ It is cleared when the declarations become identical or disjoint.
72+- `BuildOwner.structuralErrors()` lists the current errors; `BuildDiagnostics`
73+ gets `onStructuralErrors?(errors)`. While any error exists the tree is never
74+ "built": the daemon reports the errors through the logger/reporter, the
75+ progress line shows them, `hasUncompletedJobs` is true (one-shot exit 1).
76+- The remaining invariant exceptions get real messages (no `TODO` texts).
77+- Accept: existing "Cycle detected"/"overlapping outputs" tests are rewritten
78+ to the new behavior; new tests: cycle blocks the build and recovers after
79+ the cycle is broken; overlap → sleeping sibling + error, the other rules
80+ still build, error clears after the fix; daemon survives both.
81+ 
82+### T5 — No process-global state (review 6)
83+- `watchedFile`/`watchedBuildFile` caches move from module scope into the
84+ daemon-level `ScriptHost` (per daemon); subscriptions are held by memo
85+ nodes, ref-counted per path, and unwatched / listener-removed when the last
86+ node detaches.
87+- The loader's module-level `current` is replaced by a context-local value.
88+- Accept: a test with two daemons in one process watching the same path sees
89+ both updated, and closing one does not affect the other; detaching the last
90+ `watchedFile` user calls `unwatch`.
91+ 
92+### T6 — Resource lifetime in long watch sessions (review 7)
93+- `Fs.unwatch(path, fn)`; `ChokidarFs` closes a directory watcher without listeners.
94+- `BuildOwner` evicts, at the idle point, file nodes nothing references
95+ (no `dependencyOf`, `ghostDependencyOf`, `producedBy`, `goal`,
96+ `diagnosticsListeners`, not referenced from another file's
97+ `buildGhostInputFiles`) and unwatches them.
98+- `BuildDaemon` drops the script host of a removed script.
99+- Storage GC at the idle point: rows of paths that no attached rule produces
100+ any more are deleted (rows of rules outside goals stay: they are still produced).
101+- Accept: tests for eviction + unwatch, directory watcher close, storage GC,
102+ and that a re-declared file after eviction behaves like a fresh one.
103+ 
104+### T7 — Daemon decomposition and script entries (review 10, 11)
105+- Split `BuildDaemon` into a build session (owner, runner, storage, fs,
106+ reporter), a script host registry (memo root, scripts) and the idle
107+ inspection; the constructor has no side effects, `start()` creates
108+ directories, opens storage/watchers and renders the first frame; a failure
109+ there closes what was opened.
110+- Scripts are entries `{ key, inputDir, root }` instead of
111+ `path → root`; `setScript(key, entry | undefined)`. The loader drops the
112+ fake `kh2.loader` path.
113+- Accept: public API documented in CLAUDE.md; a test where the first frame
114+ throws leaves no open storage/watchers.
115+ 
116+### T8 — Ninja as a separate package (review 12)
117+- New workspace package `koala-haus/haus2-ninja` (`@koalahaus/haus2-ninja`)
118+ with `src/ninja/*`, its tests and `example/ninja` usage; the engine exports
119+ what it needs (`Pool`, `PhonyNode`, `manglePath`, `watchedFile`, …).
120+- The loader resolves `@koalahaus/haus2-ninja` to its own copy like the
121+ engine (type paths and runtime alias), so scripts need no node_modules.
122+- Accept: `haus2/src` has no ninja code; ninja tests pass in the new package;
123+ `example:ninja` works.
124+ 
125+### T9 — Compile node always succeeds (review 1, reworked)
126+- `CompileScripts` always resolves: its single output is the compile result
127+ (manifest v2: `status: "ok" | "error"`, diagnostics, roots/modules on
128+ success), and it always returns the ghost inputs (all local sources the
129+ program reached, plus the engine package.json), also on failure.
130+- The executor rule reads the result: on `error` it logs the diagnostics and
131+ fails (in watch mode the running scripts stay); on `ok` it loads.
132+- Accept: test "a new helper with an error, then fixing only the helper,
133+ recompiles and reloads" (the reproduced bug); one-shot compile error still
134+ exits 1.
135+ 
136+### T10 — Executor as an endless build (review 14)
137+- `ExecutorNode.build()` loads the manifest and then stays pending while the
138+ child runs: one-shot resolves/rejects with the child's exit code, watch
139+ rejects when the child crashes. Restart/stop are consequences of
140+ `BuildControl.cancel`: on cancel the build stops waiting; if the node left
141+ the tree (no owner after the cancel settles) the child is released, else the
142+ next build reuses the child with an IPC reload. No marker file, no session meta.
143+- The loader daemon persists its state while builds are in flight, so a watch
144+ run still stores the compile result (daemon change, not the owner algorithm).
145+- Accept: all cli tests pass; one-shot exit code comes from the build result;
146+ removing every root stops the child; a manifest change reloads in place.
147+ 
148+### T11 — No side effects in memo bodies (review 15)
149+- Loader reporting (missing roots, kh.yml errors) moves into report nodes
150+ that emit through the Logger in `incrementalUpdateDone`, once per change.
151+- Accept: the "missing root reported once" test passes; no logging calls in
152+ memo function bodies of the loader.
153+ 
154+### T12 — Closed reload set (review 18)
155+- On reload, the evicted set is closed: every root sharing an evicted module
156+ is reloaded too, so no two instances of a module coexist.
157+- Accept: test with two roots sharing a helper that keeps module state.
158+ 
159+### T13 — One options object (review 19)
160+- One `RunOptions` type for the CLI, loader and executor; the executor gets
161+ it in an IPC `init` message (or one JSON argument) instead of rebuilt flags.
162+- Accept: no duplicated option interfaces; cli tests pass.
163+ 
164+### T14 — Compilation off the main thread (review 20)
165+- `ScriptCompiler` runs in a `worker_threads` worker that keeps the builder
166+ program; the compiler is still loaded lazily on the first compilation.
167+- Accept: SIGINT during a long compilation stops kh2 promptly (test); the
168+ "compiler is not loaded when scripts are up to date" test still passes.
169+ 
170+## Follow-up: complexity and allocations (2026-10-05)
171+ 
172+Status: done. haus2 228, haus2-ninja 36, haus2-loader 28 passing. `orderGraph`
173+(30k x 300 ghosts): 14 ms per ordering, no per-node allocation (recursive original
174+16 ms, T4 version 97 ms); the cursor is kept in a local and stored only on a push.
175+GC keeps candidates held only by a cycle-blocked schedule for a later pass.
176+ 
177+Measured on a 30k-rule graph with 300 ghost inputs per rule: the T4 iterative
178+`orderGraph` takes 97 ms per ordering and 238 scavenges, against 19 ms / 29 for the
179+recursive original. An allocation-free iterative prototype (two reusable stacks)
180+takes 25 ms / 28. Every ordering triggered by a structural change pays this cost.
181+ 
182+Additional acceptance criterion for T15–T18: no per-node/per-edge heap allocation
183+on the paths named below; costs proportional to the changes, not to the graph size,
184+where the task says so.
185+ 
186+### T15 — Allocation-free orderGraph
187+- Iterative DFS over two grow-only stacks owned by `BuildOwner` (rules, child
188+ cursors), reused across orderings; dependencies walked by index (inputs, then
189+ ghosts) without copying; roots/goals visited one at a time.
190+- Same visit/assignment order as the pre-T4 recursive algorithm; the cycle is
191+ returned (the only allocation, on the error path) and no `bInCycle` stays set;
192+ the output-order invariant check is kept.
193+- Accept: equivalence test against a recursive reference on random DAGs (all
194+ `bOrder` equal); 20000-chain and cycle tests pass; `npm run bench:order -w haus2`
195+ prints the reference vs current timings.
196+ 
197+### T16 — ChokidarFs per-directory bookkeeping
198+- `Map<directory, Set<path>>` of watched paths: closing an unused directory
199+ watcher is O(1), `ready` re-notifies only the directory's paths, the polling
200+ fallback recreates from the map keys.
201+- Accept: unwatching 20k paths spread over 200 directories is linear (test with a
202+ generous time bound); existing watcher tests pass.
203+ 
204+### T17 — Incremental garbage collection and cheaper persistence
205+- `FileNode.ghostSnapshotRefs`: number of live ghost snapshots
206+ (`buildGhostInputFiles`) naming the node, maintained where snapshots are
207+ created, replaced and where a holder is evicted. Replaces the per-idle `Set`.
208+- GC candidates are collected when a file loses its last reference (edges,
209+ producer, goal, listener, snapshot) or is created; `collectGarbage` processes only
210+ the candidates (worklist: evicting a holder can release its ghosts).
211+- Storage GC: paths whose producer was detached are recorded by the owner and
212+ checked at the idle point; one full scan only at the first idle point of a run
213+ (rows left over by previous runs).
214+- `buildOutputsOf` / `setFileBuildInfo` stop copying `buildInputs` and mapping the
215+ ghost snapshot for unchanged rows; copies are made only for rows actually written.
216+- Accept: existing GC/storage tests pass; new tests for the snapshot counter
217+ (the identity-split scenario: producer detached, holder alive, ghost kept,
218+ producer re-attached → same FileNode), worklist release of a holder's ghosts in
219+ one pass, stale rows of a previous run removed once.
220+ 
221+### T18 — Structural error bookkeeping
222+- Overlapping error stored on the sleeping rule plus an insertion-ordered set of
223+ such rules; removing errors on detach walks only the detached rule's siblings.
224+- `structuralErrors()` returns a cached array rebuilt only after a change;
225+ `structuralErrorCount()` for the daemon's progress/`hasUncompletedJobs`.
226+- Accept: T4 tests pass; no array allocated per `build()` when errors do not change.
227+ 
228+### T19 — Keep build history: no storage GC (done: haus2 229, ninja 36, loader 28)
229+- Rows of outputs that left the tree stay in build.db: a re-declared output is
230+ validated by `areBuildOutputsActual`, so switching tree A -> B -> A is a cache
231+ hit for A (ninja-style history). In-memory FileNode eviction stays.
232+- Accept: storage GC code and its tests removed; A -> B -> A test (in one daemon
233+ and across restarts) shows A is not rebuilt; a modified output is rebuilt.
234+ 
235+### T20 — Revert the GC candidate worklist (done: haus2 228, ninja 36, loader 28; superseded by T21)
236+- Decision: GC runs only at the idle point, so the full pass over `files` is
237+ affordable there. The `gcCandidates` worklist added a Set insertion to the
238+ structural-update hot paths (`detachRuleInputFile`, `detachRuleGhostInputFile`,
239+ `detachRuleOutputFile`, `createFileNode`, `cancelGoal`, `removeDiagnosticsListener`,
240+ `releaseGhostSnapshotRef`) and risked forgetting to enqueue a node on a future
241+ change.
242+- `gcCandidates` and every `.add` to it are gone: no GC bookkeeping remains on
243+ the structural-update / build-step paths except the `ghostSnapshotRefs` counter
244+ (kept; it replaces the per-idle snapshot-reference set and is maintained only
245+ where a snapshot is assigned or its holder evicted). `addDiagnosticsListener` /
246+ `removeDiagnosticsListener` are plain list helpers again.
247+- `collectGarbage()` is a full pass over `files` with the same retention rules
248+ (`isReferenced`: producer, goal, `ghostSnapshotRefs > 0`, `dependencyOf` /
249+ `ghostDependencyOf` non-empty, diagnostics listeners, `bScheduledToBuild`),
250+ idle-only guard and unwatch. The T17-follow-up "scheduled-only candidates are
251+ kept for a later pass" logic is gone: a full pass at every idle point retries
252+ them naturally. A snapshot holder evicted in the pass appends its released
253+ ghosts to a local array that is re-checked after the pass (repeating until no
254+ new eviction), so a holder and its ghosts still go in one call and no state
255+ lives on the owner.
256+- Accept: the candidate-set test is removed; the identity-split,
257+ holder-and-ghosts-in-one-call and cycle-blocked-schedule tests still pass, as
258+ do all other GC tests and the two loader/ninja suites.
259+ 
260+### T21 — Mark-and-sweep file GC without reference counters (done: haus2 231, ninja 36, loader 28)
261+- Decision: replace the `FileNode.ghostSnapshotRefs` counter with a
262+ mark-and-sweep pass at the idle point. Three relations of the graph have exact
263+ reverse mirrors (`rule.bInputFiles` <-> `file.dependencyOf`,
264+ `rule.bOutputFiles` <-> `file.producedBy`,
265+ `rule.bGhostInputFiles` <-> `file.ghostDependencyOf`), so a file is a root
266+ exactly while one of these mirrors is non-empty, or it is a goal or carries a
267+ diagnostics listener. The only edge without a reverse mirror is a produced
268+ file's ghost snapshot (`holder.buildGhostInputFiles`): a ghost may be named
269+ only by the snapshot of a live holder whose producer is detached, so the
270+ traversal follows snapshots from the marked roots and keeps such a ghost
271+ exactly while its holder is kept.
272+- `FileNode.ghostSnapshotRefs`, `assignGhostSnapshot`, `releaseGhostSnapshotRef`,
273+ the local released-ghosts re-check and `isReferenced` are gone; snapshot
274+ assignment is a plain field assignment again (the `createFileNode` restore and
275+ the build-success branch). No GC bookkeeping remains on the structural-update,
276+ build-step or ordering paths.
277+- `collectGarbage()` returns 0 while a cycle blocks the build queue
278+ (`nodesNeedsBuild` non-empty while the owner counts as idle), then marks the
279+ roots with a `FileNode.gcMark` generation stamp (owner `gcGeneration` bumped
280+ per call), traverses the snapshots that need it (a holder whose producer is
281+ attached and points at the same array is skipped: its ghosts are held through
282+ `ghostDependencyOf`) with a grow-only stack reused across calls, and evicts
283+ every unmarked file. Cost O(F + traversed snapshot entries).
284+- Accept: `ghostSnapshotRefs` is gone; the identity-split, holder-and-ghosts,
285+ goal/listener retention and cycle-blocked tests pass (the last now asserts GC
286+ does nothing while the cycle blocks and collects after it breaks); new tests
287+ for the A -> B -> A boundary (held output, cache-hit re-attach on the same
288+ ghost node), a chained snapshot (O1 -> O2 -> G kept while O1 is held, all
289+ evicted in one call otherwise) and the skip rule (a held output sharing its
290+ producer's ghost array keeps the ghosts).
291+ 
292+### T22 — `kh2 --why-build`: the reason a job starts
293+- Engine: optional `RuleNode.explainBuild?(fs, context)` policy method
294+ (`hash_explainBuild` for `BuildNode`, `ninja_explainBuild` for
295+ `NinjaBuildNode`) comparing the new build key with the last successful build
296+ recorded on the outputs (`buildInputs`, `buildGhostInputs`, survives dirty
297+ resets and restarts), or with the previous key of a build restarted because
298+ its inputs changed while it ran. `BuildDiagnostics.onBuildStart?(rule,
299+ reasons)`; the owner calls `explainBuild` only when that hook is set (no cost
300+ otherwise) at the three `executeRule` sites with a `BuildStartContext`.
301+- Reporter: with `whyBuild`, each build start prints `started <name> because`
302+ (no `[N/M]` counter: it would not match the counter of the finish event)
303+ followed by indented reason lines; a restart prints the reasons under its
304+ `↻ … restarted (#k)` line.
305+- CLI: `--why-build` through `RunOptions`, the IPC `init` message and the
306+ executor daemon (`BuildDaemonOptions.whyBuild`).
307+- Accept: engine tests per reason and policy (hash and ninja), the hook-less
308+ owner never calls `explainBuild`, reporter and option tests; all suites pass.
309+ 
310+### T23 — Explicit input/output boundary in the build key
311+The build key (`RuleNode.bBuildInputs`, persisted as the outputs' `buildInputs`)
312+is `[p1, t1, …, pn, tn, o1, …, ok, meta]`: the boundary between the input pairs
313+and the output paths is known only from the rule's *current* outputs count, so
314+a key recorded with another outputs list can not be parsed (T22 then reports
315+only `changed outputs list`, and `DiagnosticsInterpreter.parseBuildKey` also
316+assumes an inputs count that the ninja policy, which leaves order-only and
317+validation inputs out of the key, does not match).
318+ 
319+- New layout: `[p1, t1, …, pn, tn, "|", o1, …, ok, meta]`. `"|"` is assumed
320+ never to be used as a file name (all paths are absolute anyway); a named
321+ constant (e.g. `BUILD_KEY_OUTPUTS_SEPARATOR`) is exported from
322+ `ReactiveBuildTree.ts` and used everywhere.
323+- Producers: `hash_updateBuildInputs` (including its incremental comparison
324+ with the old key) and `ninja_computeBuildInputs` (haus2-ninja).
325+- Parsers: `parseBuildKeyWithOutputs` loses its `declaredOutputs` parameter
326+ (input pairs up to the separator, output paths after it, the meta last) and
327+ the explanation reports `added outputs` / `removed outputs` together with the
328+ input changes; `DiagnosticsInterpreter.parseBuildKey` loses its inputs count
329+ and works for the ninja policy as well.
330+- Keys of the old layout (in `build.db` from earlier runs) no longer equal the
331+ new ones: the first run after the upgrade rebuilds every rule once. That is
332+ an accepted cache invalidation; parsers treat a key without the separator as
333+ unparsable (explanation falls back to `build key changed`).
334+- Accept: tests for the new layout of both policies, an explanation when the
335+ outputs list changed (`added outputs` / `removed outputs` plus `changed
336+ inputs` in one start), `--verbose` key diff for a ninja rule, an old-layout
337+ key handled without throwing; all suites pass; CLAUDE.md describes the key.
338+ 
339+### T24 — In-place manifest edits applied in watch (haus2-ninja bug, done: haus2-ninja 50)
340+- `Ninja()` declared pools, edges and phonies with `RepeatWithKey`, whose
341+ item callbacks closed over the `plan` of the render that created them: an
342+ item whose key and index did not change was reused and kept declaring the
343+ old data, so editing e.g. a `command` (same edge key) rebuilt nothing.
344+- Fix: the item data is the memo item parameter —
345+ `RepeatByArray(plan.pools / plan.edges / plan.phonies, key, declare)`
346+ with unchanged keys; the callbacks read only the item and the stable
347+ values of the `Ninja` callsite (manifest, baseDir, manifestPath). A
348+ re-parse makes new (structurally equal) objects, memo parameters compare
349+ by identity, so every item re-declares: an edited edge rebuilds (its meta
350+ changes), an unchanged one re-declares equal values, which the node
351+ setters skip (`setMeta`, `setInputFiles`, `setOutputFiles`), so it does
352+ not rebuild.
353+- Accept: watch tests (rewrite manifest + `fs.notify`) — a command edit
354+ rebuilds the edge with the new output content, a second untouched edge
355+ does not rebuild, a pool depth edit is applied (its jobs then run in
356+ parallel), a phony inputs edit builds the newly listed input; all four
357+ fail on the old code; all suites pass.
@@ -0,0 +1,18 @@
1+# TODO
2+ 
3+## Build engine
4+ 
5+- [ ] **Detach guard for unowned files (performance).** When a declared or
6+ ghost input edge is detached, `detachRuleInputFile` and
7+ `detachRuleGhostInputFile` (`src/build/ReactiveBuildTree.ts`) schedule the
8+ unowned file for re-observation unconditionally. When the file is still
9+ referenced by another declared or ghost edge, that visit is wasted.
10+ Reintroduce the "the file really left the build tree" condition — schedule
11+ only when `dependencyOf` and `ghostDependencyOf` are both empty — once
12+ measurements justify it.
13+ 
14+## Ninja interpreter
15+ 
16+The ninja interpreter now lives in the separate `@koalahaus/haus2-ninja`
17+package; see its `TODO.md`.
18+ 
@@ -0,0 +1,166 @@
1+/*
2+ * Copyright (c) 2026 Huawei Device Co., Ltd.
3+ * Licensed under the Apache License, Version 2.0 (the "License");
4+ * you may not use this file except in compliance with the License.
5+ * You may obtain a copy of the License at
6+ *
7+ * http://www.apache.org/licenses/LICENSE-2.0
8+ *
9+ * Unless required by applicable law or agreed to in writing, software
10+ * distributed under the License is distributed on an "AS IS" BASIS,
11+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+ * See the License for the specific language governing permissions and
13+ * limitations under the License.
14+ */
15+import { FileNode, GraphNodeKind, orderGraph, RuleNode } from "../src/build/ReactiveBuildTree"
16+ 
17+/**
18+ * Measures one ordering of the pre-T4 recursive algorithm against the
19+ * allocation-free iterative one. Each run uses a fresh generation, so no
20+ * per-run reset of the graph is needed and the timed region is the ordering
21+ * only. Run with `npm run bench:order -w haus2`.
22+ */
23+ 
24+function makeFile(path: string): FileNode {
25+ return {
26+ bKind: GraphNodeKind.File,
27+ path,
28+ bOrder: 0,
29+ bOrderGeneration: -1,
30+ producedBy: undefined,
31+ } as unknown as FileNode
32+}
33+ 
34+function makeRule(): RuleNode {
35+ return {
36+ bKind: GraphNodeKind.Rule,
37+ bOrder: 0,
38+ bOrderGeneration: -1,
39+ bInCycle: false,
40+ bNextRuleNode: undefined,
41+ } as unknown as RuleNode
42+}
43+ 
44+/** Flat graph mirroring the original benchmark harness: one source input per
45+ * rule, an edge to an earlier rule every tenth rule and a fixed ghost pool. */
46+function makeGraph(rules: number, ghostsPerRule: number, headerPool: number): RuleNode | undefined {
47+ const headers: Array<FileNode> = []
48+ for (let i = 0; i < headerPool; i++) {
49+ headers.push(makeFile(`h${i}`))
50+ }
51+ let first: RuleNode | undefined = undefined
52+ const all: Array<RuleNode> = []
53+ for (let r = 0; r < rules; r++) {
54+ const rule = makeRule()
55+ const output = makeFile(`o${r}`)
56+ output.producedBy = rule
57+ rule.bOutputFiles = [output]
58+ rule.bInputFiles = [makeFile(`s${r}`)]
59+ if (r > 0 && r % 10 === 0) {
60+ rule.bInputFiles.push(all[r - 1].bOutputFiles![0])
61+ }
62+ const ghosts: Array<FileNode> = []
63+ for (let g = 0; g < ghostsPerRule; g++) {
64+ ghosts.push(headers[(r * 7 + g * 13) % headerPool])
65+ }
66+ rule.bGhostInputFiles = ghosts
67+ all.push(rule)
68+ }
69+ for (let i = all.length - 1; i >= 0; i--) {
70+ all[i].bNextRuleNode = first
71+ first = all[i]
72+ }
73+ return first
74+}
75+ 
76+/** The pre-T4 recursive algorithm, used as the timing reference. */
77+function referenceOrder(
78+ firstNode: RuleNode | undefined,
79+ generation: number,
80+ visit: (node: RuleNode | FileNode) => void,
81+): void {
82+ let nextOrder = 0
83+ const orderFile = (file: FileNode): void => {
84+ if (file.bOrderGeneration !== generation) {
85+ if (file.producedBy) {
86+ orderNode(file.producedBy)
87+ } else {
88+ visit(file)
89+ file.bOrder = nextOrder++
90+ file.bOrderGeneration = generation
91+ }
92+ }
93+ }
94+ const orderNode = (node: RuleNode): void => {
95+ if (node.bOrderGeneration !== generation) {
96+ if (node.bInCycle) {
97+ throw new Error(`Cycle detected`)
98+ }
99+ visit(node)
100+ node.bInCycle = true
101+ if (node.bInputFiles) {
102+ for (const file of node.bInputFiles) {
103+ orderFile(file)
104+ }
105+ }
106+ if (node.bGhostInputFiles) {
107+ for (const file of node.bGhostInputFiles) {
108+ orderFile(file)
109+ }
110+ }
111+ node.bOrder = nextOrder++
112+ node.bOrderGeneration = generation
113+ if (node.bOutputFiles) {
114+ for (const file of node.bOutputFiles) {
115+ if (file.bOrderGeneration !== generation) {
116+ visit(file)
117+ file.bOrder = nextOrder++
118+ file.bOrderGeneration = generation
119+ }
120+ }
121+ }
122+ node.bInCycle = false
123+ }
124+ }
125+ let node = firstNode
126+ while (node) {
127+ orderNode(node)
128+ node = node.bNextRuleNode
129+ }
130+}
131+ 
132+const noVisit = (_: RuleNode | FileNode): void => {}
133+ 
134+function timePerOrdering(runs: number, run: (generation: number) => void, generation: number): number {
135+ run(generation++) // warm-up
136+ const start = performance.now()
137+ for (let i = 0; i < runs; i++) {
138+ run(generation++)
139+ }
140+ return (performance.now() - start) / runs
141+}
142+ 
143+function bench(rules: number, ghostsPerRule: number, runs: number): void {
144+ const first = makeGraph(rules, ghostsPerRule, 5000)
145+ const ruleStack: Array<RuleNode | undefined> = []
146+ const cursorStack: Array<number> = []
147+ 
148+ const reference = timePerOrdering(runs,
149+ generation => referenceOrder(first, generation, noVisit), 1)
150+ const current = timePerOrdering(runs,
151+ generation => orderGraph({ firstNode: first, generation, visitNodeBeforeOrder: noVisit }, ruleStack, cursorStack),
152+ 1 << 20)
153+ 
154+ console.log(`rules=${rules} ghosts/rule=${ghostsPerRule}: ` +
155+ `recursive ${reference.toFixed(1)} ms, current ${current.toFixed(1)} ms per ordering`)
156+}
157+ 
158+function main(): void {
159+ const runs = 5
160+ // keep the recursion depth reachable: every tenth rule extends a chain
161+ bench(30000, 0, runs)
162+ bench(30000, 300, runs)
163+ bench(100000, 300, runs)
164+}
165+ 
166+main()
@@ -0,0 +1,89 @@
1+---
2+name: barriers
3+description: When and how to use Barrier — business logic that depends on a build result (a generated manifest, an installed package graph, a discovery record), gating on watchedBuildFile, declaring the final result path, and the pitfalls (stuck barriers, flip-flops, rebuild-check failures).
4+---
5+ 
6+# Barriers: business logic over build results
7+ 
8+The engine has two halves. The memo runtime runs the scripts and declares the
9+jobs tree; the build owner executes it. Most scripts only read sources, so the
10+two never meet. Sometimes the declaration itself depends on something the
11+build produces:
12+ 
13+- the ninja manifest a `meson setup` rule writes decides the build edges;
14+- the package graph `arkts apm install` writes decides the source closure;
15+- a machine file a script generates decides the meson configuration;
16+- a request marker decides whether a test is declared at all (`groups`).
17+ 
18+The script can not wait for the build: a memo body runs synchronously and
19+must declare *something* in every frame. The answer is a barrier.
20+ 
21+## The pattern
22+ 
23+```ts
24+/** @memo */
25+function Configured(manifest: FileInfo, result: FileInfo): FileInfo {
26+ // 1. read the build state of the produced file (reactive: the body
27+ // re-runs when the producer finishes)
28+ if (watchedBuildFile(manifest.path).value.state !== GraphNodeState.Clean) {
29+ // 2. not settled yet: hold the consumers on the final result path
30+ return Barrier([manifest], result, { description: "waiting for the manifest" });
31+ }
32+ // 3. settled: read the file and declare the real rules producing `result`
33+ const plan = parse(readFileSync(manifest.path, "utf8"));
34+ return DeclareRules(plan, result);
35+}
36+```
37+ 
38+1. **Gate on `watchedBuildFile(path).value.state === Clean`.** The build
39+ record updates synchronously when the producing rule finishes, so every
40+ reader sees the new state in the same frame. Do not gate on a filesystem
41+ watch of the output (`watchedFile`): it fires asynchronously and on an
42+ output directory that may not exist yet.
43+2. **Declare the barrier at the final result path.** Consumers are declared
44+ over the same `FileInfo` before and after the switch; only its producer
45+ changes. A consumer then never restarts, and even one that froze the path
46+ in a cached literal stays correct.
47+3. **Keep the barrier's inputs on the producer.** The barrier orders its
48+ inputs, so a goal reaching the barrier pulls the producer: the producer
49+ runs, the state turns `Clean`, the body re-runs and replaces the barrier
50+ with the real rules.
51+4. **Parse once per change.** The body re-runs for unrelated transitions;
52+ cache the parse by the recorded `buildHash` or the mtime (`remember`).
53+ 
54+A warm start pays a few extra frames and nothing else: the consumers were
55+never started over the barrier, so the switch costs no restart and no cache
56+miss.
57+ 
58+## What a barrier does and does not do
59+ 
60+- It never builds: no job, no `-j` or pool slot, no reporter event.
61+- Its output stays dirty while it is attached, so no dependant starts.
62+- A barrier still attached at an idle point is reported (`<name> is waiting
63+ for <inputs>`) and keeps the run incomplete: a one-shot exits 1 instead of
64+ hanging.
65+- A detached barrier leaves nothing behind.
66+ 
67+## Pitfalls
68+ 
69+- **Gating on anything but `Clean`.** `Building`/`Dirty` transitions re-run
70+ the body; treat them all as "not settled".
71+- **A barrier whose inputs nobody produces** waits forever and is reported
72+ at the idle point: check that the producer is declared and reachable.
73+- **Flipping back.** If the real rules stop depending on the gated file, the
74+ producer may leave the goals tree, its output turns `Dirty` and the body
75+ declares the barrier again. Keep the gated file an input of the real rules
76+ (the request-marker pattern in `groups` relies on this).
77+- **Partial declarations.** Do not declare the real rule over a partial input
78+ set while waiting for the rest: it starts too early and restarts once the
79+ missing inputs appear, which the rebuild check reports as an error in a
80+ one-shot build. Wait for all of them behind one barrier.
81+- **Reading the file before the gate.** `readFileSync` of a produced file
82+ outside the `Clean` branch reads a stale or missing file.
83+ 
84+## In the widgets
85+ 
86+`Ninja` (a generated manifest), `Meson` (the setup's `build.ninja`),
87+`ArkTSBuild` (the apm copies and the discovery record) and arkui3's
88+`ArktsxNative` (the machine file) all use this pattern; their sources are the
89+reference implementations.
@@ -0,0 +1,147 @@
1+---
2+name: build-scripts
3+description: How a kh.build.ts is written — the memo model, the file API (file, outFile, watchedFile, watchedBuildFile, watchedTree, watchedGlob), the build context and parameters, and the memo rules that keep a script correct and cheap to re-run.
4+---
5+ 
6+# Writing build scripts
7+ 
8+A build script is a TypeScript module whose default export is a memo
9+function. It does not run commands: it **declares** the jobs tree. The memo
10+runtime re-runs the parts of the script whose observed state changed, the
11+script re-declares the affected rules, and the engine (`BuildOwner`) builds
12+what became dirty.
13+ 
14+```ts
15+import { Alias, Exec, ExecOptions, FileInfo, Goal, aliasFile, outFile, watchedTree } from "@koalaui/haus2";
16+ 
17+/** @memo */
18+export default function Build(): void {
19+ const inputs: Array<FileInfo> = [];
20+ for (const path of watchedTree("src", { include: ["**/*.c"] }).value.files) {
21+ inputs.push({ path: path });
22+ }
23+ const binary = outFile("app");
24+ // a typed local: the options capture `inputs`, see the memo rules below
25+ const options: ExecOptions = {
26+ command: ["cc", "-o", binary.path, ...inputs.map(input => input.path)],
27+ inputs: inputs,
28+ outputs: [binary],
29+ description: "link app",
30+ };
31+ const app = Exec(options)[0];
32+ Alias("app", [app], { description: "the application" });
33+ Goal([aliasFile("app")]);
34+}
35+```
36+ 
37+A script imports from `@koalaui/haus2` only: it re-exports the engine and
38+the tool widgets. Local helper modules are imported by relative path and are
39+compiled together with the scripts.
40+ 
41+## The two roles of a script
42+ 
43+- **Declaration.** Widget calls (`Exec`, `Alias`, `Phony`, ...) attach rule
44+ nodes. A call with the same arguments as in the previous frame changes
45+ nothing; a call that disappears detaches its rule.
46+- **Business logic.** Ordinary code decides *what* to declare: which
47+ packages exist, which files a directory holds, what a config says. It
48+ reads only reactive state (`watchedFile`, `watchedTree`, `watchedGlob`,
49+ `watchedBuildFile`, build parameters), so the script re-runs when that
50+ state changes and only then.
51+ 
52+A script never reads a file the build produces with plain `readFileSync`
53+while it is being produced, and never waits for a build: when the logic needs
54+a build result, it gates on `watchedBuildFile(path).value.state` and
55+declares a `Barrier` until then (see `barriers`).
56+ 
57+## File API
58+ 
59+| Call | Returns | Use |
60+|---|---|---|
61+| `file(path)` | `FileInfo` | a source input, resolved against the script's own directory |
62+| `outFile(path)` | `FileInfo` | a file under the daemon output directory (`<stateDir>/output`) |
63+| `aliasFile(name)` | `FileInfo` | the virtual marker of the alias `name` |
64+| `stamp(name)` | `FileInfo` | a virtual stamp output for commands without a known output (widgets) |
65+| `watchedFile(path)` | state of `{ path, mtime, unlinked }` | read a config or a source in business logic |
66+| `watchedBuildFile(path)` | state of `{ path, state, buildHash? }` | the build state of a produced file |
67+| `watchedTree(dir, { include?, exclude? })` | state of `{ dir, files }` | the sorted files under a directory |
68+| `watchedGlob(base, patterns)` | state of `{ base, matches }` | the entries a shallow glob matches (`arktsx/*/kh.build.ts`) |
69+ 
70+Declaring (`file`, `outFile`) allocates no state and never triggers a
71+rebuild. The `watched*` calls subscribe the calling memo scope: a change
72+re-runs it.
73+ 
74+- `watchedTree` changes only when the **list** changes; a content edit of a
75+ listed file produces no new value (the rules that take the file as an input
76+ see the edit).
77+- `watchedGlob` walks one path level per `/` separated segment and watches
78+ only the directories the patterns name; `**` is not recursive.
79+- A missing directory or file is watched and picked up when it appears.
80+ 
81+## Build context and parameters
82+ 
83+- `buildContext()` — `rootDir` (the script's own directory), `projectDir`
84+ (the daemon root, the same for every script), the output, generated and
85+ alias directories.
86+- `buildParam(name, default)`, `buildParamOrUndefined(name)`,
87+ `buildParams()` — the `kh2 -D key=value` values. They are constant for the
88+ daemon's lifetime: reading one creates no subscription. Put a parameter
89+ that should rebuild something into the rule's meta (its command, env or
90+ `meta` option). Parameters no script read are warned about once per run.
91+- `Report(lines, level?)` — emit lines through the daemon logger, once per
92+ change. Use it instead of logging from the memo body.
93+ 
94+## Memo rules
95+ 
96+These rules come from how the memo transformer rewrites the code. Breaking
97+them does not fail the compilation; it produces a script that silently stops
98+reacting.
99+ 
100+1. **Object literals passed to a memo call are cached.** The transformer
101+ wraps `Widget({ ... })` in a `compute` scope that re-evaluates only when a
102+ state read *inside* the literal changes. A literal capturing a local
103+ computed in the body (`inputs`, `env`) keeps its first value. Bind such
104+ literals to a typed local first:
105+ 
106+ ```ts
107+ const options: ExecOptions = { command, inputs, outputs }; // re-evaluated every run
108+ Exec(options);
109+ ```
110+ 
111+ A literal built from constants only may stay inline (it is even cheaper:
112+ the cached object keeps its identity). Array literals are not wrapped.
113+2. **Nested memo lambdas see only parameters.** The `NodeAttach` update
114+ callback and `Repeat*` item callbacks run in scopes of their own; a body
115+ local they capture is stale after the first run. Pass the value as a
116+ parameter of a separate memo function instead.
117+3. **Never write the literal memo annotation token in prose comments.** The
118+ transformer scans every JSDoc block for it, and the next function becomes
119+ memo. Write "memo" without the `@`.
120+4. **Keep re-runs cheap.** A state read anywhere inside a widget re-runs its
121+ ancestors, so a body re-runs for build-state transitions it does not care
122+ about. Pass stable parameter objects to child memo calls, cache expensive
123+ parses by the observed mtime or build hash (`remember`), never do uncached
124+ I/O in a body. A no-op rebuild must run zero widget bodies.
125+5. **Call memo helpers only from memo code.** `aliasFile`, `outFile` and the
126+ `watched*` calls are memo functions; calling them from a `.map` callback
127+ or a plain function is the `TS10001` compile error. Use a `for` loop, or
128+ mark the helper memo.
129+ 
130+## Identity and determinism
131+ 
132+- A rule's identity is its call site plus its declared outputs. Two rules
133+ declaring the same output are an overlapping-outputs structural error.
134+- Everything that decides a rebuild must be declared: inputs, outputs, meta
135+ (command, env, cwd). A one-shot `kh2` runs every rule at most once; a
136+ second run of the same rule is reported by the rebuild check (`kh2
137+ --rebuild-check`), usually a value that flips between frames.
138+- Files a command reads but cannot be named up front are **ghost inputs**
139+ (depfiles, `ghostInputs`): they are learned after the build and re-run the
140+ rule when they change, without being part of the build key.
141+ 
142+## See also
143+ 
144+- `engine-widgets` — the engine widgets (`Alias`, `Goal`, `Phony`, `Barrier`, `Group`, `Pool`, `Report`)
145+- `goals`, `barriers`, `groups` — the patterns
146+- `tool-widgets` — the widgets over external tools
147+- `kh2-cli` — running the scripts
@@ -0,0 +1,106 @@
1+---
2+name: custom-widgets
3+description: How to write a new widget — a BuildNode subclass attached with NodeAttach, the declaration setters, build() and its BuildControl, ghost inputs, pools, display names, and the rules that keep a widget correct (no locals in the update callback) and cheap (no work on re-declaration).
4+---
5+ 
6+# Writing a widget
7+ 
8+A widget is a memo function that attaches a rule node and returns the file(s)
9+dependants take. Prefer composing existing widgets (`Exec` covers most
10+commands, see `exec-and-run`); write a node class when the rule needs its own
11+build step, its own up-to-date policy or learned (ghost) inputs.
12+ 
13+## Skeleton
14+ 
15+```ts
16+import { mkdirSync, readFileSync, writeFileSync } from "fs";
17+import { dirname } from "path";
18+import { NodeAttach } from "@koalaui/runtime";
19+import { BuildControl, BuildNode, FileInfo, RuleNodeBuildResult, buildOwner } from "@koalaui/haus2";
20+ 
21+class ConcatNode extends BuildNode {
22+ build(control: BuildControl): RuleNodeBuildResult | Promise<RuleNodeBuildResult> {
23+ const output = this.outputFiles[0];
24+ const text = this.inputFiles.map(input => readFileSync(input.path, "utf8")).join("");
25+ mkdirSync(dirname(output.path), { recursive: true });
26+ writeFileSync(output.path, text);
27+ return undefined; // or { ghostInputFiles: [...] }
28+ }
29+}
30+ 
31+export interface ConcatOptions {
32+ inputs: Array<FileInfo>;
33+ output: FileInfo;
34+ separator?: string;
35+}
36+ 
37+/** @memo */
38+export function Concat(options: ConcatOptions): FileInfo {
39+ NodeAttach(() => new ConcatNode(), (node) => {
40+ node.setBuildOwner(buildOwner());
41+ node.setMeta(options.separator ?? "");
42+ node.setInputFiles(options.inputs);
43+ node.setOutputFiles([options.output]);
44+ });
45+ return options.output;
46+}
47+```
48+ 
49+## The declaration
50+ 
51+- `NodeAttach(create, update)` creates the node once per call site and runs
52+ `update` whenever a parameter it reads changes. **Use only the memo
53+ function's parameters inside `update`**: a body local captured there is
54+ frozen at its first value. When a value must be computed first, compute it
55+ in the body and pass it to a second memo function whose `update` reads
56+ only its parameter (the pattern of the tool widgets).
57+- `setBuildOwner(buildOwner())` first, then the setters.
58+- `setMeta(string)` is the rule's extra identity: everything that changes the
59+ result besides the input contents (command, flags, env, cwd). A changed
60+ meta rebuilds.
61+- `setInputFiles` / `setOutputFiles` sort and compare by path: re-declaring
62+ equal lists is a structural no-op.
63+- `displayName` is the name the reporter shows (`description` options).
64+- `pool` names a `Pool` (declare it with the `Pool` widget, or register it
65+ from the widget); undefined runs unpooled.
66+ 
67+## The build step
68+ 
69+- `build(control)` runs on the owner's schedule, synchronously or as a
70+ promise. Write every declared output; a build that does not create an
71+ output fails.
72+- `control.isCancelled` / `control.onCancel(fn)` — the owner cancels a build
73+ whose inputs became dirty, which left the goals, or on shutdown; kill the
74+ child process there (`runProcess` in the widgets package does it for a
75+ process group).
76+- `control.stdout` / `control.stderr` go to the per-rule log
77+ (`<stateDir>/log/...`), dumped by the reporter when the rule fails.
78+- Return `{ ghostInputFiles: paths }` to name files the build read but could
79+ not be declared (a depfile): they are watched and re-run the rule when they
80+ change, without being part of its build key.
81+- Throwing (or rejecting) fails the rule; it is not retried until an input
82+ changes.
83+ 
84+## The up-to-date policy
85+ 
86+`BuildNode` uses the content (hash) policy: the build key is the input paths
87+with their content tokens, the outputs and the meta. Override only when the
88+rule is not a pure function of that key:
89+ 
90+- `areBuildOutputsActual(fs)` — whether the recorded outputs are still valid
91+ (the `Run` widget: never, the program must start again);
92+- `isExcludedBuildInput(path)` — an order-only input: ordered, not part of the
93+ key;
94+- `explainBuild(fs, context)` — the `--why-build` lines.
95+ 
96+## Cheapness rules
97+ 
98+- The update callback runs again whenever an ancestor re-runs: keep it to
99+ setter calls, no I/O, no parsing.
100+- Pass stable parameter objects to child memo calls; a fresh literal per run
101+ re-runs the child.
102+- Cache an expensive read by the observed state (mtime, `buildHash`) with
103+ `remember`, and read it only when the observation changed.
104+- A widget that knows an external tool belongs in `@koalaui/haus2-widgets`
105+ (one `src/<tool>/` folder); an engine-generic one in the engine. A
106+ project-specific helper stays in the project (arkui3's `kh/`).
@@ -0,0 +1,131 @@
1+---
2+name: daemon-api
3+description: Using the haus2 daemon in your own program — embedding BuildDaemon through the API (options, script entries, lifecycle, one-shot vs watch, targets, parameters, logging, exit codes, live script reload) versus writing the program as kh.build.ts scripts run by kh2.
4+---
5+ 
6+# The daemon in your own program
7+ 
8+There are two ways to build a program on haus2:
9+ 
10+| | kh.build.ts + `kh2` | the API (`BuildDaemon`) |
11+|---|---|---|
12+| you write | build scripts | an application linking the engine |
13+| compilation of the memo code | done by the loader, cached | yours (`ets-tsc` + the koala transformer) |
14+| script edits | recompiled and reloaded live | `setScript` from your code |
15+| CLI, targets, `-D`, `--watch` | provided | your options |
16+| process model | loader + executor child | your process |
17+ 
18+Start with kh.build.ts unless the build must live inside another
19+application.
20+ 
21+## Programs as kh.build.ts
22+ 
23+A kh2 build is a long-running reactive program as much as a build:
24+ 
25+- `kh2 --watch` keeps the daemon alive: source edits rebuild, script edits
26+ recompile and reload the changed scripts in place.
27+- `Run(name, { command, deps })` declares a program that starts when its
28+ target is requested and is restarted when a dep output changes (`kh2 --watch
29+ run-demo` is an edit-and-relaunch loop). It runs in the console pool,
30+ attached to the terminal.
31+- `-D key=value` parameters (`buildParam`) configure the run; a parameter
32+ that should rebuild belongs in a rule's meta.
33+- `Report` prints from the scripts; `kh2 -t targets` lists what a user can
34+ select.
35+- Exit codes of a one-shot: 0 when everything built, 1 when jobs failed or are
36+ blocked (or the rebuild check failed), the program's own code for a `Run`
37+ target, 130 for a console job stopped with Ctrl+C, 2 for a usage error.
38+ 
39+See `kh2-cli` for the command line and `exec-and-run` for `Run`.
40+ 
41+## Embedding through the API
42+ 
43+```ts
44+import { BuildDaemon, ScriptEntry } from "@koalaui/haus2-engine";
45+import Build from "./build"; // a memo function, compiled with the transformer
46+ 
47+async function main(projectDir: string): Promise<void> {
48+ // inputDir is what the script's file() resolves against: the project,
49+ // not the directory of the compiled JavaScript
50+ const script: ScriptEntry = { key: "build", inputDir: projectDir, root: { content: Build } };
51+ const daemon = new BuildDaemon([script], {
52+ rootDir: projectDir,
53+ watch: false, // one-shot: no watchers, runUntilIdle returns
54+ readable: true, // job events and the progress line
55+ });
56+ try {
57+ await daemon.runUntilIdle();
58+ process.exitCode = daemon.fatalError !== undefined || daemon.hasUncompletedJobs ? 1 : 0;
59+ } finally {
60+ daemon.close();
61+ }
62+}
63+```
64+ 
65+### Requirements
66+ 
67+- **The memo transform.** The script functions must be compiled by
68+ `@koalaui/ets-tsc` with the transformer plugin in the tsconfig:
69+ `{ "transform": "@koalaui/compiler-plugin/build/lib/src/koala-transformer.js" }`.
70+ Stock `tsc` emits the calls un-rewritten, and they crash at runtime.
71+- **One memo runtime.** The application, the engine and the widgets must load
72+ the same `@koalaui/runtime` instance (the loader enforces it with a resolve
73+ hook; an embedder with hoisted `node_modules` gets it from npm).
74+ 
75+### Options (`BuildDaemonOptions`)
76+ 
77+| Option | Meaning |
78+|---|---|
79+| `rootDir` | anchors `<root>/.kh` (outputs, generated files, `build.db`, logs) |
80+| `stateDir` | another state directory; two daemons sharing a root need distinct ones |
81+| `watch` | `false` = one run; default `true` |
82+| `readable`, `verbose`, `whyBuild`, `reporterOutput` | the reporter |
83+| `logger` | the diagnostics sink, or `reporter => logger` to prefix lines |
84+| `targets`, `targetsCwd` | override the scripts' goals with alias names or paths |
85+| `params` | the build parameters (`buildParam`) |
86+| `jobs` | global job limit over all pools (`0` = none) |
87+| `rebuildCheck` | `"off" | "warn" | "error"`; default `"error"` one-shot, `"off"` watching |
88+| `polling`, `watchRoot` | the watcher (native per-directory tree by default) |
89+| `reportBlockedInputs` | report an idle tree blocked on missing inputs (default true) |
90+ 
91+### Lifecycle
92+ 
93+- The constructor has no side effects. `start()` opens the storage and the
94+ watchers and renders the first frame; `run()` and `runUntilIdle()` call it.
95+- `runUntilIdle()` runs cycles until both engines are idle and no build is in
96+ flight, then inspects the tree once (persist outputs, blocked inputs,
97+ unknown targets). With `watch: false` this is the whole run.
98+- `run()` runs until `stop()`, sleeping between changes, and closes the
99+ daemon at the end.
100+- `stop()` ends `run`/`runUntilIdle`; `close()` is idempotent, cancels the
101+ running builds (the process groups get SIGTERM) and releases everything.
102+- A script throwing while the first frame renders closes what was opened and
103+ rethrows from `start()`.
104+ 
105+### Results
106+ 
107+- `hasUncompletedJobs` — failed or blocked rules, structural errors, unknown
108+ targets or rebuild-check violations after the last idle point.
109+- `blockedInputs` — the inputs an idle tree waits for.
110+- `fatalError` — a native watcher that could not be installed (inotify limits);
111+ the daemon stopped itself.
112+- `targetNames()` — the registered aliases.
113+- `reporter.consoleExitCode` / `reporter.consoleJobStopped` — the outcome of a
114+ console (`Run`) job.
115+ 
116+### Scripts
117+ 
118+- A `ScriptEntry` is `{ key, inputDir, root }`: `key` keys the memo entry (add
119+ or remove scripts without recomputing the others), `inputDir` is what the
120+ script's `file()` resolves against, `root.content` is the memo function.
121+ `scriptEntry(path, root)` builds one from a source file path (key = the
122+ absolute path, inputDir = its directory).
123+- `setScript(key, content | undefined)` replaces or removes a script at
124+ runtime (from the application, never from inside a script); the old
125+ subtree is torn down and its rules detached.
126+ 
127+## See also
128+ 
129+- `engine-performance` — what the engine guarantees and what an embedder
130+ must keep
131+- `loader` — how kh2 compiles and runs the scripts (a reference embedder)
@@ -0,0 +1,110 @@
1+---
2+name: engine-performance
3+description: Performance model and requirements of the haus2 engine — the frame cycle, ReactiveBuildTree (BuildOwner) complexity per operation, allocation rules of the hot paths, idle-point work (dump, persistence, garbage collection), watchers and storage — and what build scripts and widgets must do to stay within it.
4+---
5+ 
6+# Engine performance
7+ 
8+The engine is built for large trees (thousands of rules and tens of thousands
9+of files) rebuilt many times in a watching session. Its contract: **the cost
10+of a frame is proportional to what changed, not to the size of the tree**, and
11+the hot paths allocate nothing per rule or edge. Everything proportional to
12+the whole tree runs only at an idle point.
13+ 
14+## The frame cycle
15+ 
16+```
17+callCallbacks → syncChanges → updateSnapshot → (scripts re-run) → owner.build()
18+```
19+ 
20+The daemon repeats the cycle while the memo runtime or the owner reports
21+pending work, then sleeps on a wakeup signal: no timers, no polling. Filesystem
22+events, watched-state changes and build completions request the signal.
23+ 
24+## BuildOwner (`src/build/ReactiveBuildTree.ts`)
25+ 
26+`build()` runs three phases, each over a schedule, never over the tree:
27+ 
28+1. **Structural update** — the rules attached, detached or rechecked since
29+ the last call (`rulesNeedsAttach/Detach/Recheck`). Cost: O(changed rules +
30+ their changed edges). Input and output arrays are replaced only when they
31+ differ (`lazyReplaceFiles`), so a re-declaration with equal lists allocates
32+ nothing.
33+2. **Ordering** — only when the structure changed (`needs-order`) or the
34+ goals changed. An iterative DFS over the goals' closure with two grow-only
35+ stacks owned by the owner (`orderRuleStack`, `orderCursorStack`), reused
36+ across orderings: one ordering allocates nothing per rule or edge, a deep
37+ chain never overflows. Cost: O(rules + edges in the goals' closure). A
38+ cycle is the only allocation, on the error path.
39+3. **Build** — a priority queue by order over the scheduled nodes
40+ (`nodesNeedsBuild`): only dirty nodes and their dependants are visited.
41+ 
42+Per-operation costs the code keeps:
43+ 
44+| Operation | Cost |
45+|---|---|
46+| `attachNode` / `detachNode` / `markRuleNodeDirty` | O(1), scheduled |
47+| re-declaring an unchanged rule | O(inputs) comparison, no allocation |
48+| `requestGoal` / `cancelGoal` | O(1) / O(goals) (goals are few, an array) |
49+| structural error record / removal | O(1) (`bOverlapError` per rule, an insertion-ordered set) |
50+| `structuralErrors()` | cached array, rebuilt only when the error version changed |
51+| `structuralErrorCount()` | O(1), no allocation |
52+| `dumpBuildTree()` | O(tree) — **idle point only** |
53+| `collectGarbage()` | O(files + traversed ghost snapshots) — **idle point only** |
54+| `explainBuild` | only while `onBuildStart` is installed (`--why-build`) |
55+ 
56+The diagnostics hooks (`onStructuralChanges`, `onBuildChanges`) receive
57+reused arrays; a listener must stay O(1) per node and allocate nothing.
58+ 
59+## Idle-point work
60+ 
61+At a fully idle point the daemon takes the build tree dump once and derives
62+everything from it: the outputs to persist (rows equal to the stored ones are
63+skipped), `hasUncompletedJobs`, the blocked inputs, then garbage collection
64+(a mark-and-sweep over `files` from the directly held roots). While builds are
65+in flight only the outputs whose records changed are persisted
66+(`collectPendingOutputs`), never a full dump.
67+ 
68+## Storage (`src/storage.ts`)
69+ 
70+SQLite at `<stateDir>/build.db`: the table is preloaded once, a batch is
71+written in a single transaction, only build outputs are stored (hashes of
72+inputs are recomputed from disk). A record references the node's live arrays;
73+rows are copied only when written.
74+ 
75+## Watchers (`src/watcher.ts`, `src/tree-watcher.ts`)
76+ 
77+Inside the daemon root: one native watch per **directory** (no per-file
78+watches; `node_modules` and `.git` are skipped). Outside it: one chokidar
79+watcher per parent directory, filtered to the registered paths. Unwatching is
80+O(1) per path. `watchedTree`/`watchedGlob` coalesce re-listings to one per
81+cycle.
82+ 
83+## What scripts and widgets must do
84+ 
85+The engine is cheap only if the memo side is:
86+ 
87+- **A no-op rebuild runs zero widget bodies, zero parses, zero rule
88+ re-declarations.** The recompute counters of the widgets package
89+ (`src/debug/counters.ts`, `test/recompute.test.ts`) measure it.
90+- **One change costs one parse.** Cache parses by the observed mtime or
91+ build hash; share a parse between readers through a content cache.
92+- **Stable parameters.** A fresh object per run re-runs the child memo
93+ function; an equal re-declaration is a no-op only if it reaches the setters
94+ with equal values.
95+- **No uncached I/O in a body** that re-runs for unrelated reasons (build
96+ state transitions of a watched output re-run every ancestor scope).
97+- **Diagnostics are opt-in.** Code paths for `--why-build` or verbose output
98+ must cost nothing when the flag is off.
99+ 
100+## Rules for engine changes
101+ 
102+- No per-rule or per-edge allocation in `build()`, the structural update or
103+ the ordering; reuse owner-held arrays and clear popped slots.
104+- No scan over all rules, files or errors outside the idle point; keep a
105+ reverse mirror or a counter instead (`_dirtyRules`, `overlappingRules`,
106+ `dependencyOf`/`ghostDependencyOf`).
107+- A new O(tree) step belongs to the idle inspection, and only if it runs once
108+ per idle point.
109+- Measure: `npm run bench:order -w haus2/engine` compares the ordering
110+ against the recursive reference; extend the bench for a new hot path.
@@ -0,0 +1,121 @@
1+---
2+name: engine-widgets
3+description: Reference of the engine widgets of @koalaui/haus2-engine — Alias, Goal, Phony, TouchFile, Barrier, Group/addToGroup, Pool and Report — with their signatures, semantics and when to use each.
4+---
5+ 
6+# Engine widgets
7+ 
8+The engine package ships the widgets that know no external tool: they order,
9+name and group the rules the tool widgets (`tool-widgets`) declare. All of
10+them are memo functions; call them from a build script or from another
11+widget.
12+ 
13+## Alias — a named target
14+ 
15+```ts
16+Alias(name: string, files: Array<FileInfo>, options?: { description?: string }): FileInfo
17+```
18+ 
19+A phony rule whose output is the virtual marker `aliasFile(name)`. It
20+registers `name` as a CLI target (`kh2 <name>`, listed by `kh2 -t targets`
21+with its description) and returns the marker, so aliases compose. Names are
22+non-empty, without path separators or whitespace. Two live aliases with the
23+same name are reported and keep the run incomplete.
24+ 
25+The marker pairs every input path with its recorded content hash: a
26+dependant of the marker is woken when an input's content changed. Use an
27+alias as the cross-script contract (a dependency is `aliasFile("<package>")`)
28+and as the target users type.
29+ 
30+## Goal — what the build is for
31+ 
32+```ts
33+Goal(files: FileInfo[], isScheduled: boolean = true): void
34+```
35+ 
36+Requests the listed files and their transitive dependencies as the build
37+tree; rules nothing requests are declared but never run. Inert while the
38+command line names targets (`kh2 <targets>`). Declare the default goal in
39+**one** place — see `goals`.
40+ 
41+## Phony — a marker over inputs
42+ 
43+```ts
44+Phony(inputs: Array<FileInfo>, output: FileInfo, options?: { meta?: string }): void
45+```
46+ 
47+Like `Alias` without the CLI name: the marker pairs input paths with their
48+content hashes, so a consumer re-runs when an input changed. Use it for
49+internal ordering points that should not clutter the target list (a
50+package's "prepared" marker, say). Note that it returns nothing; keep the
51+`output` descriptor yourself.
52+ 
53+## TouchFile — the smallest real rule
54+ 
55+```ts
56+TouchFile(inputs: Array<FileInfo>, output: FileInfo, options?: { meta?: string }): void
57+```
58+ 
59+Writes `output` when an input changed. Useful in examples and tests.
60+ 
61+## Barrier — hold the consumers
62+ 
63+```ts
64+Barrier(inputs: Array<FileInfo>, output: FileInfo, options?: { description?: string }): FileInfo
65+```
66+ 
67+Orders its inputs but never builds: its output stays dirty, so nothing
68+depending on it starts. The owner never executes a barrier (no job, no slot,
69+no reporter event). A barrier still attached at an idle point is reported as
70+`<name> is waiting for <inputs>` and keeps the run incomplete, so a one-shot
71+build exits non-zero instead of hanging. See `barriers`.
72+ 
73+## Group and addToGroup — inputs added from other scripts
74+ 
75+```ts
76+Group(inputs: Array<FileInfo>, output: FileInfo, options?: { meta?: string; description?: string }): FileInfo
77+addToGroup(group: FileInfo, inputs: FileInfo | Array<FileInfo>): void
78+```
79+ 
80+A group is a phony whose inputs are the declared ones plus everything any
81+script adds with `addToGroup(marker, ...)`, in any declaration order. Unlike
82+an alias, the producer of the group does not list its inputs: the
83+contributors do. The group's marker is always empty: a dependant is ordered
84+after every added input and cancelled while one rebuilds, but a finished
85+dependant is not re-run by a change of an input it does not declare itself.
86+See `groups`.
87+ 
88+## Pool — limit concurrency
89+ 
90+```ts
91+Pool(name: string, depth: number): void
92+```
93+ 
94+Registers a named pool with the runner: rules whose `pool` names it run at
95+most `depth` at a time (`0` = no limit). Pool names are global for the
96+daemon. The built-in `console` pool (`CONSOLE_POOL`, depth 1) owns the
97+terminal; rules in it run attached to stdio (see `exec-and-run`). On top of
98+the pools, `kh2 -j` caps all running jobs.
99+ 
100+Some widgets register their pools themselves: `NpmInstall` (`npm`, depth 1)
101+and the arkts widgets (`arkts:<workspace root>`, depth 1).
102+ 
103+## Report — messages from a script
104+ 
105+```ts
106+Report(lines: ReadonlyArray<string>, level: "info" | "warn" | "error" = "info", onReport?: (lines) => void): void
107+```
108+ 
109+Emits the lines through the daemon logger after the frame, once per change;
110+empty lines emit nothing and reset the memory. Declare a `Report` where a
111+script would otherwise log: logging from a memo body repeats on every re-run.
112+ 
113+## Lower level
114+ 
115+- `BuildNode` (`src/node.ts`) is the base class of every rule; a widget is a
116+ memo function that attaches one with `NodeAttach` — see `custom-widgets`.
117+- `PhonyNode`, `BarrierNode`, `GroupNode` are the node classes of the
118+ widgets above.
119+- `buildOwner()`, `scriptHost()`, `buildRunner()`, `scriptLogger()`,
120+ `goalOverride()` reach the daemon from memo code; widgets use them, scripts
121+ rarely need to.
@@ -0,0 +1,85 @@
1+---
2+name: goals
3+description: Best practices for Goal and named targets — one default goal declared in one place (the root script), aliases instead of goals inside widgets, how CLI targets override the goals, and the mistakes that make a build run too much or nothing.
4+---
5+ 
6+# Goals and targets
7+ 
8+A rule runs only when a goal reaches it: the engine builds the goals and
9+their transitive dependencies, nothing else. There are two sources of goals:
10+ 
11+- `Goal([...])` widgets in the scripts — the **default** build, what a plain
12+ `kh2` builds;
13+- the command line, `kh2 <target>...` — an alias name or the path of a
14+ produced file. While any target is given, every `Goal` widget is inert and
15+ the daemon requests the targets itself.
16+ 
17+## The rule: one default goal, in one file
18+ 
19+Declare the default goal once, in the root script, over aliases:
20+ 
21+```ts
22+// kh.build.ts (the root)
23+Alias("setup", [aliasFile("install"), aliasFile("compile")], { description: "..." });
24+Goal([aliasFile("setup")]);
25+```
26+ 
27+Why:
28+ 
29+- **Traceable.** Someone asking "what does `kh2` build?" reads one line. A
30+ default goal spread over many widgets and package scripts can only be
31+ reconstructed by running the build.
32+- **Selectable.** The same aliases are what users type (`kh2 compile`,
33+ `kh2 test-io`). A goal hidden inside a widget is reachable from nowhere
34+ else.
35+- **Composable.** A package script publishes aliases; the root decides what
36+ is built by default. Adding a package never changes the default build by
37+ accident.
38+ 
39+## Do not put Goal into widgets
40+ 
41+A widget (an `Exec` wrapper, a package helper such as `ArktsxPackage`)
42+**returns** its result and publishes aliases; it never requests itself:
43+ 
44+```ts
45+// wrong: every caller of the helper now builds it by default, and the
46+// default build can not be read anywhere
47+function Library(...): FileInfo {
48+ const result = Exec(options)[0];
49+ Goal([result]);
50+ return result;
51+}
52+ 
53+// right: the helper returns the result and names it; the root decides
54+function Library(name: string, ...): FileInfo {
55+ const result = Exec(options)[0];
56+ return Alias(name, [result]);
57+}
58+```
59+ 
60+The same holds for aggregate targets: a package adds itself to the root's
61+`compile` group alias (`groups`) instead of requesting itself.
62+ 
63+## Targets that do not run by default
64+ 
65+Run targets (`Run`), tests and deployment steps are declared as aliases and
66+left out of the default goal: they start only when named (`kh2 run-demo`).
67+Selected by nothing, such a rule is never ordered and costs nothing.
68+ 
69+## Checking the targets
70+ 
71+- `kh2 -t targets` lists every registered alias with its description,
72+ without building.
73+- An unknown target is reported once with the closest names, and a one-shot
74+ run exits 1.
75+- A target may name a file a rule declares in a later frame; it is requested
76+ optimistically and resolved when the rule appears.
77+ 
78+## Mistakes
79+ 
80+| Symptom | Cause |
81+|---|---|
82+| `kh2` builds nothing | no `Goal` in the scripts, or it names a file no rule produces |
83+| `kh2` builds too much | goals inside widgets, or a goal over a broad alias |
84+| a target is "unknown" | the alias is declared only after a barrier settles, or under a condition |
85+| a rule never runs in a one-shot | it is not reachable from a goal; check with `kh2 <its alias>` |
@@ -0,0 +1,117 @@
1+---
2+name: groups
3+description: How to use Group and addToGroup — aggregates other scripts contribute to (compile, test), group aliases, the empty-marker semantics, and the on-demand declaration pattern that combines a request marker, a Barrier and a Group (tests built only when requested, built serially and launched in parallel).
4+---
5+ 
6+# Groups: inputs added from other scripts
7+ 
8+An `Alias` lists its inputs where it is declared. That fails when the inputs
9+come from many scripts: the root would have to know every package (a
10+hard-coded list, or a directory glob duplicating `kh.yml`). A group turns it
11+around: the root declares the group, each contributor adds itself.
12+ 
13+```ts
14+// root kh.build.ts
15+const all = Group([], outFile("groups/compile"), { description: "group compile" });
16+Alias("compile", [all], { description: "every package" });
17+ 
18+// any package script, in any order
19+addToGroup(outFile("groups/compile"), aliasFile("libc"));
20+```
21+ 
22+- The registrations live in the daemon (`ScriptHost.groups`), so they cross
23+ scripts and survive the declaration order: adding to a group no script has
24+ declared yet is allowed, the group picks the input up when declared.
25+- Each `addToGroup` call site holds a lease: when the call site leaves the
26+ composition (the script is removed, a condition turns false) its inputs
27+ leave the group. A package joins and leaves the aggregate together with its
28+ script.
29+- Adding is O(1); the group merges its declared and added inputs once per
30+ recheck, however many additions a frame made.
31+ 
32+## The marker is empty
33+ 
34+A group's marker never carries content, unlike an alias or a phony marker.
35+A dependant of the group is
36+ 
37+- **ordered** after every added input,
38+- **cancelled** while one of them rebuilds (its input is dirty then),
39+- **not re-run** when an added input changed, or when the set changed.
40+ 
41+That is the point: one place every consumer waits on, without making the
42+consumers depend on each other's results. If a consumer needs to re-run on a
43+change of an input, it must declare that input itself.
44+ 
45+## Group aliases
46+ 
47+An aggregate target is an alias over a group. arkui3 wraps the pair:
48+ 
49+```ts
50+// kh/arktsx.ts
51+GroupAlias("compile", { description: "every package with a kh.build.ts" }); // root
52+addToGroupAlias("compile", packageAlias); // each package
53+```
54+ 
55+Use a group alias whenever an aggregate's members are discovered from the
56+scripts rather than listed in one place.
57+ 
58+## On-demand declarations: request marker + barrier + group
59+ 
60+Some rules should exist only when something asks for them: a package's tests
61+are expensive to build, and `kh2 test-io` should build io's tests only. The
62+declaration is made on demand:
63+ 
64+```ts
65+/** @memo */
66+function PackageTest(name: string, deps: Array<FileInfo>): FileInfo {
67+ // 1. the request marker: a phony without inputs; it builds (turns Clean)
68+ // exactly when a goal reaches it
69+ const marker = outFile(join("tests", name, "requested"));
70+ Phony([], marker);
71+ // 2. not requested: a barrier holds the result and pulls only the marker
72+ if (watchedBuildFile(marker.path).value.state !== GraphNodeState.Clean) {
73+ return Barrier([marker], outFile(join("tests", name, "pending")));
74+ }
75+ // 3. requested: the real rules. The build joins the shared group, the
76+ // launch waits for the whole group, so every requested build finishes
77+ // before the first suite starts
78+ const build = TestBuild(name, deps);
79+ addToGroup(outFile("tests/builds"), build);
80+ const launchDeps: Array<FileInfo> = [...deps, build, outFile("tests/builds"), marker];
81+ return TestLaunch(name, launchDeps);
82+}
83+ 
84+// root kh.build.ts: the group the builds are added to
85+Group([], outFile("tests/builds"), { description: "the requested test builds" });
86+```
87+ 
88+How it behaves:
89+ 
90+- **Not requested:** only the barrier and the empty marker are declared; no
91+ test is built.
92+- **Requested** (`kh2 test-io`): the goal reaches the barrier, the barrier
93+ pulls the marker, the marker turns `Clean`, the body re-runs and declares
94+ the build and the launch.
95+- **Many requested** (`kh2 test`): every build is added to the group; the
96+ builds run in their own pool one at a time, the launches out of the pool and
97+ in parallel, each waiting for the whole group.
98+- **The marker stays an input of the launch.** Otherwise the marker leaves
99+ the goals tree once the barrier is gone, turns `Dirty`, and the declaration
100+ flips back to the barrier on the next frame. (The owner schedules the
101+ outputs of a rule that left the goals tree, so a watched marker does see
102+ that it is not requested any more.)
103+- **The empty group marker matters:** a finished suite is not re-run because
104+ another package's test build changed.
105+ 
106+The arkui3 implementation is `PackageTest` in `kh/arktsx.ts`; the engine's
107+`test/group.test.ts` covers the cross-script order, the release on detach,
108+convergence under the one-shot rebuild check and goals leaving and returning.
109+ 
110+## Pitfalls
111+ 
112+- Declare each group **once** (two rules producing one marker are an
113+ overlapping-outputs error); contributors only call `addToGroup`.
114+- Use the same `FileInfo` path in the group and in every `addToGroup` (a
115+ shared helper or constant): a typo creates a second, empty group.
116+- Do not expect a group to wake its consumers on content changes; use an
117+ `Alias` when that is wanted.
Akoala-haus/haus2/engine/example/diagnostics.ts+105-0File content is under review, please try again later
@@ -0,0 +1,49 @@
1+{
2+ "name": "@koalaui/haus2-engine",
3+ "version": "0.0.1+devel",
4+ "description": "KoalaHaus 2.0 — reactive incremental build system: build engine (the kh2 CLI lives in @koalaui/haus2)",
5+ "license": "Apache-2.0",
6+ "main": "./lib/index.js",
7+ "types": "./lib/index.d.ts",
8+ "files": [
9+ "lib/**/*.js",
10+ "lib/**/*.d.ts",
11+ "docs/**/*",
12+ "src/**/*"
13+ ],
14+ "scripts": {
15+ "// --- CLEAN ---": "",
16+ "clean": "rimraf lib lib-test lib-example lib-bench",
17+ "// --- COMPILE ---": "",
18+ "compile": "ets-tsc -p tsconfig.json",
19+ "compile:test": "ets-tsc -p tsconfig-test.json",
20+ "// --- TEST ---": "",
21+ "run:test": "harness",
22+ "test:all": "npm run compile && npm run compile:test && npm run run:test",
23+ "// --- EXAMPLES ---": "",
24+ "compile:example": "ets-tsc -p tsconfig-example.json",
25+ "run:example": "node lib-example/example/diagnostics.js",
26+ "example": "npm run compile:example && npm run run:example",
27+ "// --- BENCH ---": "",
28+ "compile:bench": "ets-tsc -p tsconfig-bench.json",
29+ "bench:order": "npm run compile:bench && node lib-bench/bench/order-graph.js",
30+ "// --- CONVENIENCE ---": "",
31+ "all": "npm run test:all",
32+ "test": "npm run test:all"
33+ },
34+ "dependencies": {
35+ "@koalaui/runtime": "1.5.0-dev.78036-10034",
36+ "better-sqlite3": "^11.0.0",
37+ "chokidar": "^3.4.3",
38+ "fastpriorityqueue": "0.8.0",
39+ "minimatch": "10.0.1"
40+ },
41+ "devDependencies": {
42+ "@koalaui/compiler-plugin": "1.5.0-dev.78036-10034",
43+ "@koalaui/ets-tsc": "4.9.5-r6",
44+ "@koalaui/harness": "1.5.0-dev.78036-10034",
45+ "@types/better-sqlite3": "^7.6.13",
46+ "@types/node": "^18.0.0",
47+ "rimraf": "^3.0.2"
48+ }
49+}
Akoala-haus/haus2/engine/src/.gitignore+1-0File content is under review, please try again later
@@ -0,0 +1,131 @@
1+/*
2+ * Copyright (c) 2026 Huawei Device Co., Ltd.
3+ * Licensed under the Apache License, Version 2.0 (the "License");
4+ * you may not use this file except in compliance with the License.
5+ * You may obtain a copy of the License at
6+ *
7+ * http://www.apache.org/licenses/LICENSE-2.0
8+ *
9+ * Unless required by applicable law or agreed to in writing, software
10+ * distributed under the License is distributed on an "AS IS" BASIS,
11+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+ * See the License for the specific language governing permissions and
13+ * limitations under the License.
14+ */
15+import { IncrementalNode, NodeAttach } from "@koalaui/runtime";
16+import { resolve } from "path";
17+import { FileInfo, buildOwner } from "./build";
18+import { buildContext, scriptHost } from "./context";
19+import { TargetRegistry } from "./targets";
20+import { PhonyNode } from "./widgets/phony";
21+ 
22+/**
23+ * Declares the marker output of the alias `name`: a virtual file resolved
24+ * against the build context's alias directory (`<stateDir>/alias`). Names
25+ * must be non-empty and contain no path separators or whitespace, so one
26+ * name is one path and a target can name an alias without escaping it.
27+ */
28+/** @memo */
29+export function aliasFile(name: string): FileInfo {
30+ assertAliasName(name);
31+ return { path: resolve(buildContext().aliasDir, name) };
32+}
33+ 
34+function assertAliasName(name: string): void {
35+ if (name.length === 0 ||
36+ name === "." ||
37+ name === ".." ||
38+ name.includes("/") ||
39+ name.includes("\\") ||
40+ /\s/.test(name)) {
41+ throw new Error(
42+ `Invalid alias name ${JSON.stringify(name)}: expected a non-empty name` +
43+ ` without path separators or whitespace`,
44+ );
45+ }
46+}
47+ 
48+export interface AliasOptions {
49+ /**
50+ * Readable rule name shown in reporter events instead of the marker path
51+ * (the rule's displayName); also registered with the target name for
52+ * listings.
53+ */
54+ description?: string;
55+}
56+ 
57+/**
58+ * A named target over a set of files: a phony rule (see {@link PhonyNode})
59+ * whose single output is the marker {@link aliasFile}, so the target can be
60+ * selected by name (`BuildDaemonOptions.targets`) and aliases compose — an
61+ * alias of aliases declares another alias's marker as its input.
62+ *
63+ * The name is registered in the per-daemon {@link TargetRegistry} of the
64+ * script host while the widget is attached and released when it detaches or
65+ * is renamed, mirroring how the Goal widget holds its goals.
66+ */
67+class AliasNode extends PhonyNode {
68+ private registry: TargetRegistry | undefined = undefined;
69+ private aliasName: string | undefined = undefined;
70+ private aliasDescription: string | undefined = undefined;
71+ 
72+ private registeredName: string | undefined = undefined;
73+ private registeredDescription: string | undefined = undefined;
74+ 
75+ setAlias(registry: TargetRegistry, name: string, description: string | undefined): void {
76+ this.registry = registry;
77+ this.aliasName = name;
78+ this.aliasDescription = description;
79+ }
80+ 
81+ incrementalUpdateDone(parent?: IncrementalNode): void {
82+ super.incrementalUpdateDone(parent);
83+ if (this.registry !== undefined &&
84+ (this.registeredName !== this.aliasName ||
85+ this.registeredDescription !== this.aliasDescription)) {
86+ this.unregister();
87+ this.registeredName = this.aliasName;
88+ this.registeredDescription = this.aliasDescription;
89+ this.registry.register(this.aliasName!, this.aliasDescription);
90+ }
91+ }
92+ 
93+ detach(): void {
94+ this.unregister();
95+ super.detach();
96+ }
97+ 
98+ private unregister(): void {
99+ if (this.registry !== undefined && this.registeredName !== undefined) {
100+ const name = this.registeredName;
101+ const description = this.registeredDescription;
102+ this.registeredName = undefined;
103+ this.registeredDescription = undefined;
104+ this.registry.unregister(name, description);
105+ }
106+ }
107+}
108+ 
109+/**
110+ * Declares the named target `name` over `files` and returns its marker file
111+ * (see {@link aliasFile}), so aliases compose: an alias of aliases declares
112+ * another alias's marker as its input. The rule is phony — an input change
113+ * rewrites the marker but runs nothing.
114+ */
115+/** @memo */
116+export function Alias(
117+ name: string,
118+ files: Array<FileInfo>,
119+ options?: AliasOptions,
120+): FileInfo {
121+ // the update callback is a memo scope of its own: it reads only the
122+ // parameters (tracked), never locals of this body
123+ NodeAttach(() => new AliasNode(), (node) => {
124+ node.setBuildOwner(buildOwner());
125+ node.displayName = options?.description;
126+ node.setAlias(scriptHost().targets, name, options?.description);
127+ node.setInputFiles(files);
128+ node.setOutputFiles([aliasFile(name)]);
129+ });
130+ return aliasFile(name);
131+}
@@ -0,0 +1,368 @@
1+/*
2+ * Copyright (c) 2026 Huawei Device Co., Ltd.
3+ * Licensed under the Apache License, Version 2.0 (the "License");
4+ * you may not use this file except in compliance with the License.
5+ * You may obtain a copy of the License at
6+ *
7+ * http://www.apache.org/licenses/LICENSE-2.0
8+ *
9+ * Unless required by applicable law or agreed to in writing, software
10+ * distributed under the License is distributed on an "AS IS" BASIS,
11+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+ * See the License for the specific language governing permissions and
13+ * limitations under the License.
14+ */
15+import { BUILD_KEY_OUTPUTS_SEPARATOR } from "./explain"
16+import {
17+ BuildDiagnostics,
18+ FileNode,
19+ GraphNodeKind,
20+ GraphNodeState,
21+ RuleNode,
22+ RuleNodeBuildStatus,
23+} from "./ReactiveBuildTree"
24+ 
25+/** Options for the diagnostics interpreter. */
26+export interface DiagnosticsInterpreterOptions {
27+ /** Human readable name of a rule. Defaults to its outputs, then to its meta hash. */
28+ ruleName?: (rule: RuleNode) => string
29+ /** When set, rules and files whose recorded generation differs are stale and are skipped. */
30+ generation?: number
31+}
32+ 
33+function defaultRuleName(rule: RuleNode): string {
34+ const outputs = rule.outputFiles
35+ if (outputs.length > 0) {
36+ return outputs.map(file => file.path).join(", ")
37+ }
38+ return `meta:${rule.metaHash}`
39+}
40+ 
41+function quote(value: string): string {
42+ return `"${value}"`
43+}
44+ 
45+function stateName(state: GraphNodeState): string {
46+ return state === GraphNodeState.Clean ? "clean" : "dirty"
47+}
48+ 
49+function pathsOf(files: Array<FileNode> | undefined): Array<string> {
50+ return files === undefined ? [] : files.map(file => file.path)
51+}
52+ 
53+function describeFileListChange(
54+ kind: string,
55+ oldFiles: Array<FileNode> | undefined,
56+ newFiles: Array<FileNode> | undefined,
57+): string | undefined {
58+ if (oldFiles === undefined) {
59+ // no previous state to compare with (fresh attach)
60+ return undefined
61+ }
62+ const oldPaths = pathsOf(oldFiles)
63+ const newPaths = pathsOf(newFiles)
64+ const oldSet = new Set(oldPaths)
65+ const newSet = new Set(newPaths)
66+ const added = newPaths.filter(path => !oldSet.has(path))
67+ const removed = oldPaths.filter(path => !newSet.has(path))
68+ if (added.length === 0 && removed.length === 0) {
69+ return undefined
70+ }
71+ const parts: Array<string> = []
72+ if (added.length > 0) {
73+ parts.push(`+[${added.join(", ")}]`)
74+ }
75+ if (removed.length > 0) {
76+ parts.push(`-[${removed.join(", ")}]`)
77+ }
78+ return `${kind} changed: ${parts.join(" ")}`
79+}
80+ 
81+function describeMetaChange(rule: RuleNode): string | undefined {
82+ const oldMeta = rule.bDiagnosticsOldMetaHash
83+ if (oldMeta === undefined || oldMeta === rule.bMetaHash) {
84+ return undefined
85+ }
86+ return `meta changed: ${quote(oldMeta)} -> ${quote(rule.bMetaHash)}`
87+}
88+ 
89+function describeSiblingChange(rule: RuleNode, nameOf: (rule: RuleNode) => string): string | undefined {
90+ const oldPrev = rule.bDiagnosticsOldPrevSibling
91+ const newPrev = rule.bPrevSibling
92+ if (oldPrev === newPrev) {
93+ return undefined
94+ }
95+ if (oldPrev === undefined) {
96+ return `now a sibling after ${nameOf(newPrev!)}`
97+ }
98+ if (newPrev === undefined) {
99+ return `no longer a sibling (was after ${nameOf(oldPrev)})`
100+ }
101+ return `moved in the siblings list from after ${nameOf(oldPrev)} to after ${nameOf(newPrev)}`
102+}
103+ 
104+/**
105+ * Interprets the structural diagnostics of a rule: attach/detach, meta, input
106+ * and output lists, sibling list position.
107+ */
108+export function interpretStructuralDiagnostics(
109+ rule: RuleNode,
110+ options: DiagnosticsInterpreterOptions = {},
111+): Array<string> {
112+ if (options.generation !== undefined && rule.bStructuralDiagnosticsGeneration !== options.generation) {
113+ return []
114+ }
115+ const nameOf = options.ruleName ?? defaultRuleName
116+ const name = nameOf(rule)
117+ const lines: Array<string> = []
118+ const detached = rule.bDiagnosticsWasDetached === true
119+ const attached = rule.bDiagnosticsWasAttached === true
120+ 
121+ if (detached && attached) {
122+ lines.push(`${name}: reattached to the build graph`)
123+ } else if (detached) {
124+ // released edges and the sibling list are teardown artifacts, not changes
125+ lines.push(`${name}: detached from the build graph`)
126+ return lines
127+ } else if (attached) {
128+ lines.push(`${name}: attached to the build graph`)
129+ }
130+ 
131+ const freshAttach = attached && !detached
132+ if (!detached && !freshAttach) {
133+ // for a fresh attach the old fields are the initial empty state
134+ const metaChange = describeMetaChange(rule)
135+ if (metaChange !== undefined) {
136+ lines.push(`${name}: ${metaChange}`)
137+ }
138+ }
139+ 
140+ const inputsChange = describeFileListChange("inputs", rule.bDiagnosticsOldInputFiles, rule.bInputFiles)
141+ if (inputsChange !== undefined) {
142+ lines.push(`${name}: ${inputsChange}`)
143+ }
144+ const outputsChange = describeFileListChange("outputs", rule.bDiagnosticsOldOutputFiles, rule.bOutputFiles)
145+ if (outputsChange !== undefined) {
146+ lines.push(`${name}: ${outputsChange}`)
147+ }
148+ 
149+ const siblingChange = describeSiblingChange(rule, nameOf)
150+ if (siblingChange !== undefined) {
151+ lines.push(`${name}: ${siblingChange}`)
152+ }
153+ return lines
154+}
155+ 
156+/** Interprets structural diagnostics of several rules. */
157+export function interpretStructuralChanges(
158+ rules: ReadonlyArray<RuleNode>,
159+ options: DiagnosticsInterpreterOptions = {},
160+): Array<string> {
161+ const lines: Array<string> = []
162+ for (const rule of rules) {
163+ lines.push(...interpretStructuralDiagnostics(rule, options))
164+ }
165+ return lines
166+}
167+ 
168+/** Build keys are laid out as [input path, input token] * N, the outputs
169+ * separator, output path * M, meta. The separator is the explicit
170+ * input/output boundary, so a key parses no matter how many inputs the rule
171+ * declares now (the ninja policy leaves order-only and validation inputs out
172+ * of the key). */
173+interface BuildKey {
174+ inputs: Map<string, string>
175+ meta: string
176+}
177+ 
178+function parseBuildKey(key: Array<string> | undefined): BuildKey | undefined {
179+ if (key === undefined) {
180+ return undefined
181+ }
182+ // an even number of input tokens must sit before the separator, and at
183+ // least the separator and the meta after them
184+ const separatorIndex = key.indexOf(BUILD_KEY_OUTPUTS_SEPARATOR)
185+ if (separatorIndex < 0 || separatorIndex % 2 !== 0 || key.length < separatorIndex + 2) {
186+ return undefined
187+ }
188+ const inputs = new Map<string, string>()
189+ for (let i = 0; i < separatorIndex; i += 2) {
190+ inputs.set(key[i], key[i + 1])
191+ }
192+ return { inputs, meta: key[key.length - 1] }
193+}
194+ 
195+function describeBuildKeyChange(rule: RuleNode): string | undefined {
196+ const oldKey = parseBuildKey(rule.bDiagnosticsOldBuildInputs)
197+ const newKey = parseBuildKey(rule.bBuildInputs)
198+ if (oldKey === undefined || newKey === undefined) {
199+ return undefined
200+ }
201+ const parts: Array<string> = []
202+ if (oldKey.meta !== newKey.meta) {
203+ parts.push(`meta ${quote(oldKey.meta)} -> ${quote(newKey.meta)}`)
204+ }
205+ for (const [path, hash] of newKey.inputs) {
206+ const oldHash = oldKey.inputs.get(path)
207+ if (oldHash !== undefined && oldHash !== hash) {
208+ parts.push(`input ${quote(path)} content changed (hash ${oldHash} -> ${hash})`)
209+ }
210+ }
211+ return parts.length === 0 ? undefined : parts.join("; ")
212+}
213+ 
214+function describeBuildStatusChange(rule: RuleNode): string | undefined {
215+ const oldStatus = rule.bDiagnosticsOldBuildStatus
216+ const newStatus = rule.bBuildStatus
217+ if (oldStatus === undefined || oldStatus === newStatus || newStatus === RuleNodeBuildStatus.InProgress) {
218+ // starting a build is described by describeRebuildReason()
219+ return undefined
220+ }
221+ switch (newStatus) {
222+ case RuleNodeBuildStatus.Success:
223+ return "build succeeded"
224+ case RuleNodeBuildStatus.Failed:
225+ return "build failed"
226+ case RuleNodeBuildStatus.NotStarted:
227+ return rule.bDirtyInputs > 0 ? "build reset: inputs are dirty" : "build reset"
228+ }
229+}
230+ 
231+function describeRebuildReason(rule: RuleNode, keyChange: string | undefined): string | undefined {
232+ const oldStatus = rule.bDiagnosticsOldBuildStatus
233+ const newStatus = rule.bBuildStatus
234+ if (newStatus !== RuleNodeBuildStatus.InProgress || oldStatus === RuleNodeBuildStatus.InProgress) {
235+ return undefined
236+ }
237+ if (keyChange !== undefined) {
238+ return "build started"
239+ }
240+ if (oldStatus === undefined || oldStatus === RuleNodeBuildStatus.NotStarted) {
241+ return "build started"
242+ }
243+ if (oldStatus === RuleNodeBuildStatus.Failed) {
244+ return "build started: retrying a failed build"
245+ }
246+ return "build started: outputs changed externally or are missing (inputs are unchanged)"
247+}
248+ 
249+function interpretRuleBuildDiagnostics(rule: RuleNode, options: DiagnosticsInterpreterOptions): Array<string> {
250+ const name = (options.ruleName ?? defaultRuleName)(rule)
251+ const lines: Array<string> = []
252+ const keyChange = describeBuildKeyChange(rule)
253+ if (keyChange !== undefined) {
254+ lines.push(`${name}: build key changed: ${keyChange}`)
255+ }
256+ const cacheMatch = rule.bDiagnosticsCacheMatch === true
257+ const statusChange = describeBuildStatusChange(rule)
258+ if (statusChange !== undefined) {
259+ lines.push(`${name}: ${statusChange}${cacheMatch ? " (cache match)" : ""}`)
260+ }
261+ const reason = describeRebuildReason(rule, keyChange)
262+ if (reason !== undefined) {
263+ lines.push(`${name}: ${reason}`)
264+ }
265+ if (cacheMatch && statusChange === undefined) {
266+ lines.push(`${name}: cache match`)
267+ }
268+ return lines
269+}
270+ 
271+function interpretFileBuildDiagnostics(file: FileNode): Array<string> {
272+ const name = file.path
273+ const lines: Array<string> = []
274+ const oldHash = file.bDiagnosticsOldBuildHash
275+ if (file.producedBy === undefined && oldHash !== undefined && oldHash !== "" && oldHash !== file.buildHash) {
276+ lines.push(`${name}: content changed (hash ${oldHash} -> ${file.buildHash})`)
277+ }
278+ const cacheMatch = file.bDiagnosticsCacheMatch === true
279+ const oldState = file.bDiagnosticsOldState
280+ if (oldState !== undefined && oldState !== file.bState) {
281+ lines.push(`${name}: ${stateName(oldState)} -> ${stateName(file.bState)}${cacheMatch ? " (cache match)" : ""}`)
282+ } else if (cacheMatch) {
283+ lines.push(`${name}: cache match`)
284+ }
285+ return lines
286+}
287+ 
288+/**
289+ * Interprets the build diagnostics of a rule or a file: build status
290+ * transitions, rebuild reasons and file state/hash changes. Produced files are
291+ * driven by their owner state only, so their content hash is not reported.
292+ */
293+export function interpretBuildDiagnostics(
294+ node: RuleNode | FileNode,
295+ options: DiagnosticsInterpreterOptions = {},
296+): Array<string> {
297+ if (options.generation !== undefined && node.bBuildDiagnosticsGeneration !== options.generation) {
298+ return []
299+ }
300+ if (node.bKind === GraphNodeKind.File) {
301+ return interpretFileBuildDiagnostics(node)
302+ }
303+ return interpretRuleBuildDiagnostics(node, options)
304+}
305+ 
306+/** Interprets build diagnostics of several rules and files. */
307+export function interpretBuildChanges(
308+ nodes: ReadonlyArray<RuleNode | FileNode>,
309+ options: DiagnosticsInterpreterOptions = {},
310+): Array<string> {
311+ const lines: Array<string> = []
312+ for (const node of nodes) {
313+ lines.push(...interpretBuildDiagnostics(node, options))
314+ }
315+ return lines
316+}
317+ 
318+/** BuildDiagnostics implementation collecting interpreted structural and build lines. */
319+export class DiagnosticsInterpreter implements BuildDiagnostics {
320+ private readonly options: DiagnosticsInterpreterOptions
321+ private readonly structural: Array<string> = []
322+ private readonly build: Array<string> = []
323+ 
324+ constructor(options: DiagnosticsInterpreterOptions = {}) {
325+ this.options = options
326+ }
327+ 
328+ onStructuralChanges(nodes: Array<RuleNode>): void {
329+ this.structural.push(...interpretStructuralChanges(nodes, this.options))
330+ }
331+ 
332+ onBuildChanges(nodes: Array<RuleNode | FileNode>): void {
333+ this.build.push(...interpretBuildChanges(nodes, this.options))
334+ }
335+ 
336+ /** All structural lines collected so far. */
337+ get structuralMessages(): ReadonlyArray<string> {
338+ return this.structural
339+ }
340+ 
341+ /** All build lines collected so far. */
342+ get buildMessages(): ReadonlyArray<string> {
343+ return this.build
344+ }
345+ 
346+ /** Returns collected structural lines and clears that buffer. */
347+ takeStructural(): Array<string> {
348+ const messages = this.structural.slice()
349+ this.structural.length = 0
350+ return messages
351+ }
352+ 
353+ /** Returns collected build lines and clears that buffer. */
354+ takeBuild(): Array<string> {
355+ const messages = this.build.slice()
356+ this.build.length = 0
357+ return messages
358+ }
359+ 
360+ clear(): void {
361+ this.structural.length = 0
362+ this.build.length = 0
363+ }
364+ 
365+ toString(): string {
366+ return [...this.structural, ...this.build].join("\n")
367+ }
368+}
Akoala-haus/haus2/engine/src/build/arrays.ts+30-0File content is under review, please try again later
@@ -0,0 +1,253 @@
1+/*
2+ * Copyright (c) 2026 Huawei Device Co., Ltd.
3+ * Licensed under the Apache License, Version 2.0 (the "License");
4+ * you may not use this file except in compliance with the License.
5+ * You may obtain a copy of the License at
6+ *
7+ * http://www.apache.org/licenses/LICENSE-2.0
8+ *
9+ * Unless required by applicable law or agreed to in writing, software
10+ * distributed under the License is distributed on an "AS IS" BASIS,
11+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+ * See the License for the specific language governing permissions and
13+ * limitations under the License.
14+ */
15+ 
16+/**
17+ * The build key layout shared by the build policies and their explanation:
18+ * `[p1, t1, …, pn, tn, "|", o1, …, ok, meta]` — the input pairs (path, policy
19+ * token), the separator, the output paths, the meta hash last — plus the
20+ * helpers that turn two keys and a rule's recorded state into the reason lines
21+ * `--why-build` prints. The engine itself never parses a key: it only compares
22+ * keys for equality, so this module stays out of `ReactiveBuildTree.ts`.
23+ */
24+import { arraysEquals } from "./arrays";
25+import { BuildStartContext, BuildStartKind, FileNode, Fs, RuleNode } from "./ReactiveBuildTree";
26+ 
27+/** Marker between the input pairs and the output paths of a build key
28+ * (`RuleNode.bBuildInputs`): the key is `[p1, t1, …, pn, tn, separator,
29+ * o1, …, ok, meta]`, so the input/output boundary is explicit and a recorded
30+ * key stays parsable after the declarations changed. Assumed never to be used
31+ * as a file name (all paths are absolute anyway). */
32+export const BUILD_KEY_OUTPUTS_SEPARATOR = "|"
33+ 
34+/** One input token of a parsed build key: a path with the token the policy
35+ * recorded for it (a content hash, an mtime, ...). */
36+export interface BuildKeyToken {
37+ readonly path: string;
38+ readonly token: string;
39+}
40+ 
41+/** A build key parsed with the policy layout: input tokens, output paths and
42+ * the meta. */
43+export interface ParsedBuildKey {
44+ readonly inputs: ReadonlyArray<BuildKeyToken>;
45+ readonly outputs: ReadonlyArray<string>;
46+ readonly meta: string;
47+}
48+ 
49+/**
50+ * Parses a build key laid out as `path, token` input pairs, the
51+ * {@link BUILD_KEY_OUTPUTS_SEPARATOR}, the output paths and the meta. The
52+ * separator is the explicit input/output boundary, so a recorded key is
53+ * parsable no matter which outputs the rule declares now; `undefined` means
54+ * there is no key, or it is malformed or of the old layout (no separator), so
55+ * its inputs can not be told apart from its output paths.
56+ */
57+export function parseBuildKeyWithOutputs(
58+ key: ReadonlyArray<string> | undefined,
59+): ParsedBuildKey | undefined {
60+ if (key === undefined) {
61+ return undefined;
62+ }
63+ const separatorIndex = key.indexOf(BUILD_KEY_OUTPUTS_SEPARATOR);
64+ if (separatorIndex < 0 || separatorIndex % 2 !== 0 || key.length < separatorIndex + 2) {
65+ return undefined;
66+ }
67+ const inputs: Array<BuildKeyToken> = [];
68+ for (let i = 0; i < separatorIndex; i += 2) {
69+ inputs.push({ path: key[i], token: key[i + 1] });
70+ }
71+ return {
72+ inputs,
73+ outputs: key.slice(separatorIndex + 1, key.length - 1),
74+ meta: key[key.length - 1],
75+ };
76+}
77+ 
78+/** Pairs of a ghost snapshot (`path, token` flat array) as build key tokens. */
79+export function buildKeyTokensOfPairs(pairs: ReadonlyArray<string>): Array<BuildKeyToken> {
80+ const tokens: Array<BuildKeyToken> = [];
81+ for (let i = 0; i + 1 < pairs.length; i += 2) {
82+ tokens.push({ path: pairs[i], token: pairs[i + 1] });
83+ }
84+ return tokens;
85+}
86+ 
87+/** Which input paths changed their token, were added or removed between two
88+ * parsed keys, in the order of the actual (then baseline) key. */
89+export function diffBuildKeyInputs(
90+ baseline: ReadonlyArray<BuildKeyToken>,
91+ actual: ReadonlyArray<BuildKeyToken>,
92+): { changed: Array<string>; added: Array<string>; removed: Array<string> } {
93+ const baselineTokens = new Map<string, string>();
94+ for (const token of baseline) {
95+ baselineTokens.set(token.path, token.token);
96+ }
97+ const actualTokens = new Set<string>();
98+ const changed: Array<string> = [];
99+ const added: Array<string> = [];
100+ for (const token of actual) {
101+ actualTokens.add(token.path);
102+ const previous = baselineTokens.get(token.path);
103+ if (previous === undefined) {
104+ added.push(token.path);
105+ } else if (previous !== token.token) {
106+ changed.push(token.path);
107+ }
108+ }
109+ const removed: Array<string> = [];
110+ for (const token of baseline) {
111+ if (!actualTokens.has(token.path)) {
112+ removed.push(token.path);
113+ }
114+ }
115+ return { changed, added, removed };
116+}
117+ 
118+/** How many paths a reason line names before it summarizes the rest. */
119+const EXPLAINED_PATH_LIMIT = 5;
120+ 
121+/** Comma-separated paths, at most {@link EXPLAINED_PATH_LIMIT} of them, then
122+ * `… and N more`. Paths are printed as they appear in the build key: the
123+ * reporter makes them readable. */
124+export function formatExplainPaths(paths: ReadonlyArray<string>): string {
125+ if (paths.length <= EXPLAINED_PATH_LIMIT) {
126+ return paths.join(", ");
127+ }
128+ return `${paths.slice(0, EXPLAINED_PATH_LIMIT).join(", ")}, … and ${paths.length - EXPLAINED_PATH_LIMIT} more`;
129+}
130+ 
131+/**
132+ * Reason lines for the difference of two build keys: changed, added and
133+ * removed inputs, added and removed outputs, then the meta. When either key
134+ * is unparsable (malformed, or of the old layout without the separator) its
135+ * inputs can not be compared, so the single generic `build key changed` line
136+ * is returned. Empty when the keys do not differ.
137+ */
138+export function explainBuildKeyDiff(
139+ baselineKey: ReadonlyArray<string> | undefined,
140+ newKey: ReadonlyArray<string> | undefined,
141+ metaReason: string,
142+): Array<string> {
143+ if (baselineKey === undefined || newKey === undefined || arraysEquals(baselineKey, newKey)) {
144+ return [];
145+ }
146+ const baseline = parseBuildKeyWithOutputs(baselineKey);
147+ const actual = parseBuildKeyWithOutputs(newKey);
148+ if (baseline === undefined || actual === undefined) {
149+ // the layout of the baseline is unknown: the input pairs can not be
150+ // told apart from the output paths
151+ return ["build key changed"];
152+ }
153+ const lines: Array<string> = [];
154+ const diff = diffBuildKeyInputs(baseline.inputs, actual.inputs);
155+ if (diff.changed.length > 0) {
156+ lines.push(`changed inputs: ${formatExplainPaths(diff.changed)}`);
157+ }
158+ if (diff.added.length > 0) {
159+ lines.push(`added inputs: ${formatExplainPaths(diff.added)}`);
160+ }
161+ if (diff.removed.length > 0) {
162+ lines.push(`removed inputs: ${formatExplainPaths(diff.removed)}`);
163+ }
164+ const baselineOutputs = new Set(baseline.outputs);
165+ const actualOutputs = new Set(actual.outputs);
166+ const addedOutputs = actual.outputs.filter(path => !baselineOutputs.has(path));
167+ if (addedOutputs.length > 0) {
168+ lines.push(`added outputs: ${formatExplainPaths(addedOutputs)}`);
169+ }
170+ const removedOutputs = baseline.outputs.filter(path => !actualOutputs.has(path));
171+ if (removedOutputs.length > 0) {
172+ lines.push(`removed outputs: ${formatExplainPaths(removedOutputs)}`);
173+ }
174+ if (baseline.meta !== actual.meta) {
175+ lines.push(metaReason);
176+ }
177+ return lines;
178+}
179+ 
180+/** The policy parts of a build explanation: how the recorded ghost snapshot is
181+ * re-computed, the wording of a meta change and how a recorded output is
182+ * checked against the disk. */
183+export interface ExplainBuildPolicy {
184+ /** current ghost tokens of a recorded ghost snapshot */
185+ computeGhostInputs(ghostFiles: ReadonlyArray<FileNode>): Array<string>;
186+ /** reason line for a changed meta token */
187+ readonly metaReason: string;
188+ /** why a recorded output file is no longer actual (`missing` or
189+ * `modified externally`), or undefined while it is; cheap, it may observe
190+ * the file */
191+ outputNotActual(file: FileNode): "missing" | "modified externally" | undefined;
192+}
193+ 
194+/**
195+ * Explains why a rule build starts, comparing the new build key with the
196+ * baseline the {@link BuildStartContext.kind} names: the record of the last
197+ * successful build on the first output (which survives dirty resets and daemon
198+ * restarts), or `previousBuildInputs` for InputsChanged. One reason line per
199+ * finding; a single generic line when nothing specific is found.
200+ */
201+export function explainBuildWithPolicy(
202+ rule: RuleNode,
203+ fs: Fs,
204+ context: BuildStartContext,
205+ policy: ExplainBuildPolicy,
206+): Array<string> {
207+ const reasons: Array<string> = [];
208+ if (context.kind === BuildStartKind.RetryFailed) {
209+ reasons.push("retrying a failed build");
210+ }
211+ const outputFiles = rule.bOutputFiles ?? [];
212+ const recorded = outputFiles[0];
213+ if (context.kind === BuildStartKind.InputsChanged) {
214+ reasons.push(...explainBuildKeyDiff(context.previousBuildInputs, rule.bBuildInputs, policy.metaReason));
215+ } else {
216+ if (recorded === undefined || recorded.buildInputs === undefined) {
217+ // no outputs, or no record on the output: first build ever / no history
218+ reasons.push("no previous build recorded");
219+ return reasons;
220+ }
221+ reasons.push(...explainBuildKeyDiff(recorded.buildInputs, rule.bBuildInputs, policy.metaReason));
222+ for (const file of outputFiles) {
223+ if (file.buildInputs === undefined) {
224+ continue;
225+ }
226+ const problem = policy.outputNotActual(file);
227+ if (problem !== undefined) {
228+ reasons.push(`output ${problem}: ${file.path}`);
229+ }
230+ }
231+ }
232+ // the ghost inputs are not part of the build key: their snapshot lives on
233+ // the outputs (the last successful build), so they are compared for every
234+ // kind of start, a restart because a declared input changed included
235+ const ghostFiles = recorded?.buildGhostInputFiles;
236+ if (ghostFiles !== undefined) {
237+ const diff = diffBuildKeyInputs(
238+ buildKeyTokensOfPairs(recorded!.buildGhostInputs ?? []),
239+ buildKeyTokensOfPairs(policy.computeGhostInputs(ghostFiles)),
240+ );
241+ // a depfile names the source itself too: a declared input is already
242+ // explained by the build key, so it is not repeated here
243+ const declared = new Set(rule.inputFiles.map(file => file.path));
244+ const ghostPaths = [...diff.changed, ...diff.added, ...diff.removed].filter(path => !declared.has(path));
245+ if (ghostPaths.length > 0) {
246+ reasons.push(`changed ghost inputs (depfile): ${formatExplainPaths(ghostPaths)}`);
247+ }
248+ }
249+ if (reasons.length === 0) {
250+ reasons.push("build key changed");
251+ }
252+ return reasons;
253+}
@@ -0,0 +1,196 @@
1+/*
2+ * Copyright (c) 2026 Huawei Device Co., Ltd.
3+ * Licensed under the Apache License, Version 2.0 (the "License");
4+ * you may not use this file except in compliance with the License.
5+ * You may obtain a copy of the License at
6+ *
7+ * http://www.apache.org/licenses/LICENSE-2.0
8+ *
9+ * Unless required by applicable law or agreed to in writing, software
10+ * distributed under the License is distributed on an "AS IS" BASIS,
11+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+ * See the License for the specific language governing permissions and
13+ * limitations under the License.
14+ */
15+ 
16+/**
17+ * The default build policy of `BuildNode` (`hash_*`): a rule's build key
18+ * pairs every input path with its content hash, the outputs are actual while
19+ * their recorded hash and build key match. `BuildOwner` reaches a policy only
20+ * through the `RuleNode` methods, so the policies live outside the engine.
21+ */
22+import { arraysEquals } from "./arrays";
23+import { BUILD_KEY_OUTPUTS_SEPARATOR, explainBuildWithPolicy } from "./explain";
24+import { BuildStartContext, FileNode, Fs, GraphNodeState, RuleNode, RuleNodeBuildStatus, observeFileHash } from "./ReactiveBuildTree";
25+ 
26+export function hash_areBuildOutputsActual(rule: RuleNode, fs: Fs): boolean {
27+ if (rule.bBuildInputs === undefined) {
28+ throw new Error(`Can not verify correctness of outputs while inputs are not set`)
29+ }
30+ if (rule.bOutputFiles === undefined || rule.bOutputFiles.length === 0) {
31+ // if no build outputs defined, run job on every koala-haus launch. If during current launch
32+ // job was succeeded, don't re-run it (until inputs are dirty).
33+ return rule.bBuildStatus === RuleNodeBuildStatus.Success;
34+ }
35+ const expectedGhostBuildInputs = rule.bOutputFiles[0].buildGhostInputFiles
36+ ? hash_computeGhostBuildInputs(rule.bOutputFiles[0].buildGhostInputFiles, fs)
37+ : [];
38+ for (const outputFile of rule.bOutputFiles) {
39+ observeFileHash(outputFile, fs);
40+ if (outputFile.observedHash !== outputFile.buildHash) {
41+ return false;
42+ }
43+ if (outputFile.observedMtime === 0 && rule.bBuildStatus === RuleNodeBuildStatus.NotStarted) {
44+ // if build was already finished during current koala-haus run, don't react on unlinked
45+ // output files: that is result of current build. But if that is first job run, re-run it
46+ // repeating ninja behaviour.
47+ return false;
48+ }
49+ if (!outputFile.buildInputs || !arraysEquals(rule.bBuildInputs, outputFile.buildInputs)) {
50+ return false
51+ }
52+ if (!arraysEquals(expectedGhostBuildInputs, outputFile.buildGhostInputs ?? [])) {
53+ return false;
54+ }
55+ }
56+ return true;
57+}
58+ 
59+export function hash_areOutputsValidAfterBuild(rule: RuleNode, fs: Fs, ghostInputFiles: Array<FileNode> | undefined): boolean {
60+ for (const file of rule.bOutputFiles ?? []) {
61+ observeFileHash(file, fs);
62+ if (file.observedMtime === 0) {
63+ // deny unlinked files
64+ return false;
65+ }
66+ }
67+ if (ghostInputFiles !== undefined) {
68+ for (const file of ghostInputFiles) {
69+ if (file.producedBy === undefined) {
70+ // dependency that is not declared by another build tree node must be produced.
71+ observeFileHash(file, fs);
72+ if (file.observedMtime === 0) {
73+ // deny unlinked files
74+ return false;
75+ }
76+ } else {
77+ // allow unexisting ghost files if they are produced by someone.
78+ }
79+ }
80+ }
81+ return true;
82+}
83+ 
84+export function hash_updateBuildInputs(rule: RuleNode, fs: Fs): boolean {
85+ // bound: the method is called as a plain function below
86+ const isExcluded = rule.isExcludedBuildInput?.bind(rule)
87+ let inputsChanged = false;
88+ let i = 0;
89+ if (rule.bBuildInputs !== undefined) {
90+ const oldBuildInputs = rule.bBuildInputs;
91+ for (const file of rule.bInputFiles ?? []) {
92+ if (isExcluded !== undefined && isExcluded(file.path)) {
93+ // order-only input: ordered, but never part of the key
94+ continue;
95+ }
96+ if (inputsChanged || i + 2 >= oldBuildInputs.length) {
97+ inputsChanged = true;
98+ break;
99+ }
100+ inputsChanged ||=
101+ oldBuildInputs[i] !== file.path ||
102+ oldBuildInputs[i + 1] !== file.buildHash;
103+ i += 2;
104+ }
105+ if (!inputsChanged) {
106+ // the separator tells the input pairs from the output paths: a key
107+ // without it at that position (the layout of earlier runs) never
108+ // matches, so it is rebuilt once
109+ inputsChanged = oldBuildInputs[i] !== BUILD_KEY_OUTPUTS_SEPARATOR;
110+ }
111+ i += 1;
112+ for (const file of rule.bOutputFiles ?? []) {
113+ if (inputsChanged || i >= oldBuildInputs.length) {
114+ inputsChanged = true;
115+ break;
116+ }
117+ inputsChanged ||= oldBuildInputs[i] !== file.path;
118+ i += 1;
119+ }
120+ if (i + 1 !== oldBuildInputs.length ||
121+ oldBuildInputs[i] !== rule.bMetaHash) {
122+ inputsChanged = true;
123+ }
124+ } else {
125+ inputsChanged = true;
126+ }
127+ if (!inputsChanged) {
128+ return false;
129+ }
130+ const newBuildInputs = new Array<string>();
131+ for (const file of rule.bInputFiles ?? []) {
132+ if (isExcluded !== undefined && isExcluded(file.path)) {
133+ continue;
134+ }
135+ newBuildInputs.push(file.path);
136+ newBuildInputs.push(file.buildHash);
137+ }
138+ newBuildInputs.push(BUILD_KEY_OUTPUTS_SEPARATOR);
139+ for (const file of rule.bOutputFiles ?? []) {
140+ newBuildInputs.push(file.path);
141+ }
142+ newBuildInputs.push(rule.bMetaHash);
143+ rule.bBuildInputs = newBuildInputs;
144+ return true;
145+}
146+ 
147+export function hash_computeGhostBuildInputs(ghostInputFiles: ReadonlyArray<FileNode>, fs: Fs): string[] {
148+ const ghostBuildInputs = new Array<string>();
149+ for (const file of ghostInputFiles) {
150+ observeFileHash(file, fs);
151+ ghostBuildInputs.push(file.path);
152+ if (file.producedBy === undefined) {
153+ // when file does not have producer, that's possible to get ghost dependency on
154+ // new file that was not presented in graph and whose hash was not computed before.
155+ ghostBuildInputs.push(file.observedHash);
156+ } else {
157+ ghostBuildInputs.push(file.buildHash);
158+ }
159+ }
160+ return ghostBuildInputs;
161+}
162+ 
163+/**
164+ * The hash-policy build explanation of a rule: input tokens are content
165+ * hashes, a recorded output is actual while its observed hash matches the
166+ * recorded one ({@link hash_areBuildOutputsActual} reports the same findings
167+ * as build decisions, this only names them).
168+ */
169+export function hash_explainBuild(rule: RuleNode, fs: Fs, context: BuildStartContext): Array<string> {
170+ return explainBuildWithPolicy(rule, fs, context, {
171+ computeGhostInputs: ghostFiles => hash_computeGhostBuildInputs(ghostFiles, fs),
172+ metaReason: "changed meta",
173+ outputNotActual: file => {
174+ observeFileHash(file, fs);
175+ if (file.observedMtime === 0) {
176+ return "missing";
177+ }
178+ return file.observedHash !== file.buildHash ? "modified externally" : undefined;
179+ },
180+ });
181+}
182+ 
183+export function hash_buildOutputFile(rule: RuleNode, fs: Fs, file: FileNode): void {
184+ if (file.producedBy !== rule) {
185+ throw new Error(`Expected to have same file.producedBy as "rule" argument value`)
186+ }
187+ // nodes with owher: controlled by its state
188+ if (rule.bState === GraphNodeState.Clean) {
189+ observeFileHash(file, fs);
190+ file.buildRuleMtime = file.producedBy.bBuildSuccessMtime;
191+ file.buildInputs = file.producedBy.bBuildInputs;
192+ file.buildHashMtime = file.observedMtime;
193+ file.buildHash = file.observedHash;
194+ }
195+ file.bState = file.producedBy.bState;
196+}
@@ -0,0 +1,32 @@
1+/*
2+ * Copyright (c) 2026 Huawei Device Co., Ltd.
3+ * Licensed under the Apache License, Version 2.0 (the "License");
4+ * you may not use this file except in compliance with the License.
5+ * You may obtain a copy of the License at
6+ *
7+ * http://www.apache.org/licenses/LICENSE-2.0
8+ *
9+ * Unless required by applicable law or agreed to in writing, software
10+ * distributed under the License is distributed on an "AS IS" BASIS,
11+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+ * See the License for the specific language governing permissions and
13+ * limitations under the License.
14+ */
15+import { contextLocalScope, contextLocalValue } from "@koalaui/runtime";
16+import { BuildOwner } from "./ReactiveBuildTree";
17+ 
18+const BUILD_OWNER = "koala-haus-build-owner";
19+ 
20+/** @memo:intrinsic */
21+export function buildOwner(): BuildOwner {
22+ return contextLocalValue(BUILD_OWNER);
23+}
24+ 
25+/** @memo:intrinsic */
26+export function withBuildOwner(
27+ owner: BuildOwner,
28+ /** @memo */
29+ content: () => void,
30+) {
31+ return contextLocalScope(BUILD_OWNER, owner, content);
32+}
@@ -0,0 +1,19 @@
1+/*
2+ * Copyright (c) 2026 Huawei Device Co., Ltd.
3+ * Licensed under the Apache License, Version 2.0 (the "License");
4+ * you may not use this file except in compliance with the License.
5+ * You may obtain a copy of the License at
6+ *
7+ * http://www.apache.org/licenses/LICENSE-2.0
8+ *
9+ * Unless required by applicable law or agreed to in writing, software
10+ * distributed under the License is distributed on an "AS IS" BASIS,
11+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+ * See the License for the specific language governing permissions and
13+ * limitations under the License.
14+ */
15+export * from "./incremental"
16+export * from "./DiagnosticsInterpreter"
17+export * from "./ReactiveBuildTree"
18+export * from "./explain"
19+export * from "./hash-policy"
Akoala-haus/haus2/engine/src/index.ts+37-0File content is under review, please try again later
Akoala-haus/haus2/engine/src/inspect.ts+260-0File content is under review, please try again later
Akoala-haus/haus2/engine/src/log.ts+56-0File content is under review, please try again later
Akoala-haus/haus2/engine/src/scripts.ts+240-0File content is under review, please try again later
Akoala-haus/haus2/engine/src/targets.ts+245-0File content is under review, please try again later
Akoala-haus/haus2/engine/test/daemon-lifecycle.test.ts+149-0File content is under review, please try again later
Akoala-haus/haus2/engine/test/ghost.test.ts+301-0File content is under review, please try again later
Akoala-haus/haus2/engine/test/goal.test.ts+124-0File content is under review, please try again later
Akoala-haus/haus2/engine/test/rebuild-check.test.ts+217-0File content is under review, please try again later
Akoala-haus/haus2/engine/test/watched-files.test.ts+195-0File content is under review, please try again later
Akoala-haus/haus2/engine/test/watcher-directories.test.ts+149-0File content is under review, please try again later
Akoala-haus/haus2/engine/tsconfig-bench.json+19-0File content is under review, please try again later
Akoala-haus/haus2/engine/tsconfig-test.json+22-0File content is under review, please try again later
Akoala-haus/haus2/engine/tsconfig.json+24-0File content is under review, please try again later
Akoala-haus/haus2/loader/CLAUDE.md+8-0File content is under review, please try again later
Akoala-haus/haus2/loader/bin/kh2.js+34-0File content is under review, please try again later
Akoala-haus/haus2/loader/docs/loader.md+82-0File content is under review, please try again later
Akoala-haus/haus2/loader/package.json+51-0File content is under review, please try again later
Akoala-haus/haus2/loader/src/alias.ts+94-0File content is under review, please try again later
Akoala-haus/haus2/loader/src/child.ts+419-0File content is under review, please try again later
Akoala-haus/haus2/loader/src/discovery.ts+153-0File content is under review, please try again later
Akoala-haus/haus2/loader/src/engine.ts+115-0File content is under review, please try again later
Akoala-haus/haus2/loader/src/executor.ts+441-0File content is under review, please try again later
Akoala-haus/haus2/loader/src/help.ts+131-0File content is under review, please try again later
Akoala-haus/haus2/loader/src/help/overview.md+98-0File content is under review, please try again later
Akoala-haus/haus2/loader/src/index.ts+26-0File content is under review, please try again later
Akoala-haus/haus2/loader/src/loader.ts+256-0File content is under review, please try again later
Akoala-haus/haus2/loader/src/log.ts+77-0File content is under review, please try again later
Akoala-haus/haus2/loader/src/main.ts+101-0File content is under review, please try again later
Akoala-haus/haus2/loader/src/manifest.ts+90-0File content is under review, please try again later
Akoala-haus/haus2/loader/src/options.ts+275-0File content is under review, please try again later
Akoala-haus/haus2/loader/src/protocol.ts+57-0File content is under review, please try again later
Akoala-haus/haus2/loader/test/compiler.test.ts+175-0File content is under review, please try again later
Akoala-haus/haus2/loader/test/console.test.ts+265-0File content is under review, please try again later
Akoala-haus/haus2/loader/test/discovery.test.ts+67-0File content is under review, please try again later
Akoala-haus/haus2/loader/test/executor.test.ts+65-0File content is under review, please try again later
Akoala-haus/haus2/loader/test/help.test.ts+75-0File content is under review, please try again later
Akoala-haus/haus2/loader/test/meson.test.ts+145-0File content is under review, please try again later
Akoala-haus/haus2/loader/test/options.test.ts+224-0File content is under review, please try again later
Akoala-haus/haus2/loader/test/run.test.ts+332-0File content is under review, please try again later
Akoala-haus/haus2/loader/test/utils.ts+175-0File content is under review, please try again later
Akoala-haus/haus2/loader/tsconfig-test.json+23-0File content is under review, please try again later
Akoala-haus/haus2/loader/tsconfig.json+24-0File content is under review, please try again later
Akoala-haus/haus2/widgets/CLAUDE.md+8-0File content is under review, please try again later
Akoala-haus/haus2/widgets/README.md+75-0File content is under review, please try again later
Akoala-haus/haus2/widgets/TODO.md+23-0File content is under review, please try again later
Akoala-haus/haus2/widgets/docs/arkts-widgets.md+104-0File content is under review, please try again later
Akoala-haus/haus2/widgets/docs/exec-and-run.md+109-0File content is under review, please try again later
Akoala-haus/haus2/widgets/docs/ninja-and-meson.md+91-0File content is under review, please try again later
Akoala-haus/haus2/widgets/docs/tool-widgets.md+117-0File content is under review, please try again later
Akoala-haus/haus2/widgets/package.json+39-0File content is under review, please try again later
Akoala-haus/haus2/widgets/src/arkts/arkts.ts+1234-0File content is under review, please try again later
Akoala-haus/haus2/widgets/src/debug/counters.ts+103-0File content is under review, please try again later
Akoala-haus/haus2/widgets/src/exec/exec.ts+364-0File content is under review, please try again later
Akoala-haus/haus2/widgets/src/exec/process.ts+416-0File content is under review, please try again later
Akoala-haus/haus2/widgets/src/index.ts+31-0File content is under review, please try again later
Akoala-haus/haus2/widgets/src/meson/meson.ts+521-0File content is under review, please try again later
Akoala-haus/haus2/widgets/src/ninja/algorithm.ts+189-0File content is under review, please try again later
Akoala-haus/haus2/widgets/src/ninja/canonical.ts+148-0File content is under review, please try again later
Akoala-haus/haus2/widgets/src/ninja/depfile.ts+158-0File content is under review, please try again later
Akoala-haus/haus2/widgets/src/ninja/error.ts+30-0File content is under review, please try again later
Akoala-haus/haus2/widgets/src/ninja/lexer.ts+385-0File content is under review, please try again later
Akoala-haus/haus2/widgets/src/ninja/ninja.ts+653-0File content is under review, please try again later
Akoala-haus/haus2/widgets/src/ninja/parser.ts+485-0File content is under review, please try again later
Akoala-haus/haus2/widgets/src/ninja/plan.ts+465-0File content is under review, please try again later
Akoala-haus/haus2/widgets/src/npm/npm.ts+248-0File content is under review, please try again later
Akoala-haus/haus2/widgets/src/run/run.ts+246-0File content is under review, please try again later
Akoala-haus/haus2/widgets/src/tsc/tsc.ts+617-0File content is under review, please try again later
Akoala-haus/haus2/widgets/test/arkts.test.ts+529-0File content is under review, please try again later
Akoala-haus/haus2/widgets/test/exec.test.ts+688-0File content is under review, please try again later
Akoala-haus/haus2/widgets/test/meson-ninja.test.ts+173-0File content is under review, please try again later
Akoala-haus/haus2/widgets/test/meson.test.ts+446-0File content is under review, please try again later
Akoala-haus/haus2/widgets/test/ninja-build.test.ts+989-0File content is under review, please try again later
Akoala-haus/haus2/widgets/test/ninja-parser.test.ts+385-0File content is under review, please try again later
Akoala-haus/haus2/widgets/test/npm.test.ts+356-0File content is under review, please try again later
Akoala-haus/haus2/widgets/test/recompute.test.ts+613-0File content is under review, please try again later
Akoala-haus/haus2/widgets/test/run.test.ts+238-0File content is under review, please try again later
Akoala-haus/haus2/widgets/test/tsc.test.ts+296-0File content is under review, please try again later
Akoala-haus/haus2/widgets/test/utils.ts+342-0File content is under review, please try again later
Akoala-haus/haus2/widgets/tsconfig-test.json+22-0File content is under review, please try again later
Akoala-haus/haus2/widgets/tsconfig.json+24-0File content is under review, please try again later
Mkoala-haus/package.json+5-0File content is under review, please try again later
Mpackage.json+5-0File content is under review, please try again later