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
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/lib | 9 | /haus/lib |
| 10 | /haus/data | 10 | /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 | -## Running | 45 | + ## Running |
| 46 | 46 | ||
| 47 | Current version mostly focuses on incremental updates with fixed build scripts, and thus does not | 47 | 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 run | 54 | 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 | +} | ||
| @@ -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 | +} | ||
| @@ -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/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/loader/src/help/overview.md+98-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/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/tsconfig-test.json+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/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/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/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/tsconfig-test.json+22-0File content is under review, please try again later