feat(compiler): --watch mode#215
Conversation
PR 4/4 of the #21 + #18 thread. Adds a target-agnostic WatchHost orchestrator and `--watch` flag to both `metano-typescript` and `metano-dart`. WatchHost.RunAsync(projectPath, runOnce, ct): - Single FileSystemWatcher rooted at the project directory, recursive, filtering .cs / .csproj / .props / .targets while excluding bin/ and obj/ artifact subtrees. - 250 ms quiet-period debounce coalesces an IDE's burst-of-events save into a single recompile; the post-debounce drain swallows every queued release so the next tick covers the next burst. - Ctrl+C from the CLI flows through Console.CancelKeyPress into a CancellationTokenSource — the loop exits cleanly with a stop message. - Any exception inside the runOnce delegate is logged with the full stack trace but does NOT stop the watcher; transient compile failures iterate, not abort. The CLI builds the runOnce delegate so target-specific post-emit work (the TypeScript target's PackageJsonWriter) runs once per tick. Combined with the incremental cache (ADR-0021), an editor save that does not actually change content costs only the load + extract phases — the cache short-circuit absorbs the rest. A real edit pays the parallel transform from ADR-0020. Tests: - 15 new test cases for WatchHost.IsRelevant (extension filter + bin/obj exclusion + mixed case + edge cases) - 1014 .NET tests pass (was 999) - Manual smoke on macOS: * --watch boots, prints initial transpile, watches project dir * Real .cs edit → cache miss → full recompile + package.json * touch (no content change) → cache hit, no work * Ctrl+C cleanly exits Roadmap (#21 + #18): - [x] PR 1 — type-level dependency graph (#211) - [x] PR 2 — parallel TypeTransformer (#213) - [x] PR 3a — incremental cache: whole-build short-circuit (#214) - [x] PR 4 — watch mode (this PR) - [ ] PR 3b — per-group skip via file-descriptor abstraction (deferred follow-up)
There was a problem hiding this comment.
Code Review
This pull request implements a --watch mode for the Dart and TypeScript transpilers, allowing for automatic re-transpilation upon source file changes. It introduces a target-agnostic WatchHost orchestrator that utilizes FileSystemWatcher with a 250ms debounce and integrates with the incremental cache. Review feedback highlights several critical improvements: the Renamed event should check both old and new paths to handle file removals; the FileSystemWatcher.Error event should be handled to ensure reliability; path filtering should be optimized with ReadOnlySpan to avoid allocations; and the Console.CancelKeyPress handler must be properly unregistered to prevent memory leaks and disposal errors.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9ef3953aab
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Pull request overview
This PR adds a cross-target --watch mode to the TypeScript and Dart CLIs, implemented via a shared WatchHost that monitors the C# project tree and re-runs the transpilation pipeline on relevant file changes (with debounce), with behavior documented in a new ADR.
Changes:
- Add
WatchHostorchestrator (FileSystemWatcher + relevance filter + debounce + Ctrl+C cancellation + resilient run loop). - Wire
--watchflag intometano-typescriptandmetano-dartby wrapping the existing run in aRunOncedelegate. - Add unit tests for
WatchHost.IsRelevantand document the design in ADR-0022 (indexed in ADR README).
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Metano.Tests/Watch/WatchHostTests.cs | Adds unit coverage for WatchHost.IsRelevant filtering behavior. |
| src/Metano.Compiler/Watch/WatchHost.cs | Introduces the watch-mode orchestrator used by all targets. |
| src/Metano.Compiler.TypeScript/Commands.cs | Adds --watch flag and delegates to WatchHost per tick (incl. package.json post-emit). |
| src/Metano.Compiler.Dart/Commands.cs | Adds --watch flag and delegates to WatchHost. |
| docs/adr/README.md | Indexes ADR-0022. |
| docs/adr/0022-watch-mode.md | Documents watch-mode design/constraints/alternatives. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
… Ctrl+C handler PR #215 review feedback. Three correctness wins plus one perf: - The Renamed handler now inspects both `OldFullPath` and `FullPath`, so renaming a relevant input (Foo.cs → Foo.txt) or moving it under bin/obj triggers a recompile. Without this, the watcher silently ignored the source-set delta until the user poked another file. - The watcher subscribes to `FileSystemWatcher.Error` and surfaces buffer-overflow drops in yellow on stderr, with guidance to save again. Silent event loss was the bug; the cache fingerprint catches up on the next save anyway. - Both target CLIs now subscribe `Console.CancelKeyPress` via a named handler and `-=` it in a `finally` block so the CancellationTokenSource is not pinned across CLI invocations and a Ctrl+C after watch exits does not deref a disposed CTS. - `ContainsBuildArtifactSegment` walks the path via `MemoryExtensions.SplitAny` over a `ReadOnlySpan<char>` instead of `string.Split` — no per-event array allocation, which matters because FileSystemWatcher is chatty. Tests: - 1014 .NET tests pass (no new assertions; `IsRelevant` already covered by 15 cases including bin/obj exclusion) - Manual: rename Foo.cs → Foo.txt fires recompile (errors surface for missing type), rename back recovers via cache hit; Ctrl+C still exits cleanly
PR #215 follow-up. Three remaining nits from Copilot's review: - Startup message now lists every watched extension (.cs / .csproj / .props / .targets) so it matches the actual IsRelevant filter. - lastEventAtUtc is now stored as a long behind Volatile.Read / Volatile.Write so the FileSystemWatcher callback thread and the consumer loop never tear or skip a write. DateTime is rebuilt from the ticks at the read site only. - WaitForQuietPeriod now sleeps for the *remaining* DebounceMs (DebounceMs minus elapsed since the last event). The previous fixed-slice loop could overshoot to nearly 2× DebounceMs when an event landed right after a delay started; the new shape settles in one slice once the burst stops. 1014 .NET tests pass. Manual smoke: startup banner, touch → cache hit chain stays correct.
|
Addressed in 2516c76 + 415760b. @gemini-code-assist @chatgpt-codex-connector @copilot — eight findings across two passes: Correctness:
Thread-safety:
Behavior:
Perf:
1014 .NET tests pass. Manual smoke on macOS: |
|
Thank you for the detailed update, @danfma. The implementation of |
|
To use Codex here, create an environment for this repo. |
All eight items are verified in the current branch (
All 1014 tests pass. |
Agent-Logs-Url: https://github.com/danfma/metano/sessions/027e9e67-8c93-45ca-a771-9e4ec6cd4abc Co-authored-by: danfma <101121+danfma@users.noreply.github.com>
This reverts commit 68b161e.
… Ctrl+C handler PR #215 review feedback. Three correctness wins plus one perf: - The Renamed handler now inspects both `OldFullPath` and `FullPath`, so renaming a relevant input (Foo.cs → Foo.txt) or moving it under bin/obj triggers a recompile. Without this, the watcher silently ignored the source-set delta until the user poked another file. - The watcher subscribes to `FileSystemWatcher.Error` and surfaces buffer-overflow drops in yellow on stderr, with guidance to save again. Silent event loss was the bug; the cache fingerprint catches up on the next save anyway. - Both target CLIs now subscribe `Console.CancelKeyPress` via a named handler and `-=` it in a `finally` block so the CancellationTokenSource is not pinned across CLI invocations and a Ctrl+C after watch exits does not deref a disposed CTS. - `ContainsBuildArtifactSegment` walks the path via `MemoryExtensions.SplitAny` over a `ReadOnlySpan<char>` instead of `string.Split` — no per-event array allocation, which matters because FileSystemWatcher is chatty. Tests: - 1014 .NET tests pass (no new assertions; `IsRelevant` already covered by 15 cases including bin/obj exclusion) - Manual: rename Foo.cs → Foo.txt fires recompile (errors surface for missing type), rename back recovers via cache hit; Ctrl+C still exits cleanly
PR #215 follow-up. Three remaining nits from Copilot's review: - Startup message now lists every watched extension (.cs / .csproj / .props / .targets) so it matches the actual IsRelevant filter. - lastEventAtUtc is now stored as a long behind Volatile.Read / Volatile.Write so the FileSystemWatcher callback thread and the consumer loop never tear or skip a write. DateTime is rebuilt from the ticks at the read site only. - WaitForQuietPeriod now sleeps for the *remaining* DebounceMs (DebounceMs minus elapsed since the last event). The previous fixed-slice loop could overshoot to nearly 2× DebounceMs when an event landed right after a delay started; the new shape settles in one slice once the burst stops. 1014 .NET tests pass. Manual smoke: startup banner, touch → cache hit chain stays correct.
Closes the per-group skip work from the #21 + #18 thread. PR 3a gave us the whole-build short-circuit; PR 3b gave us the per-type and closure hashers. This PR wires them into TypeTransformer so a single editor save regenerates only the affected groups while the rest reuse the disk content. Design (ADR-0024): stub TsSourceFile on cache hit, bypass the Printer for cached paths, skip TransformGroup entirely. Cache file is target-specific (<outputDir>/.metano-cache-groups-typescript.json) and managed independently from PR 3a's target-agnostic cache; --clean wipes both together. Flow inside TransformAll: 1. Compute per-group closure hashes (BuildSignatureHashIndex amortises per-type hashing across overlapping closures). 2. Load on-disk group cache. 3. Per group: hit → build stub via FileMetadataExtractor, capture the disk content (stripped of the host-applied file-prefix), increment skip counter, return. 4. Miss → run TransformGroup, extract metadata for the next run. 5. After loop: 'Per-group cache: X/Y group(s) reused' summary if any hits fired. 6. Write the fresh cache (every group, hits and misses). ITranspilerTarget.Transform extended with optional outputDir + filePrefix parameters so the host plumbs both through without a side-channel property. Backwards-compatible default keeps any third-party target compiling. Stub design (FileMetadataExtractor.BuildStub): TsImport records preserved fully (downstream stages walk every field); exports collapsed to (name, typeOnly) pairs and rebuilt as TsConstObject (value) / TsInterface (type-only) — the two TsTopLevel shapes BarrelFileGenerator's switch statement matches on. New downstream stages reading other AST nodes will need extractor updates. Tests: - 1027 .NET tests pass (was 1024; +3 for FileMetadataExtractor round-trip) - Manual smoke on SampleIssueTracker (18 groups): edit one type → 17/18 groups reused, byte-identical output for unchanged groups, regen for the touched group. - All samples build green. Roadmap (#21 + #18): - [x] PR 1 — type-level dependency graph (#211) - [x] PR 2 — parallel TypeTransformer (#213) - [x] PR 3a — incremental cache: whole-build short-circuit (#214) - [x] PR 4 — watch mode (#215) - [x] PR 3b — per-group skip foundation (#216) - [x] PR 3c — per-group skip integration (this PR)
## 2.3.0 _2026-05-12_ ### ✨ Features * **compiler:** --watch mode ([#18](#18)) ([8fff5a7](8fff5a7)), closes [#21](#21) [#21](#21) [#211](#211) [#213](#213) [#214](#214) * **compiler:** add [StrictUnionGuard] for shape-level union dispatch ([#154](#154)) ([f09fc40](f09fc40)), closes [#88](#88) * **compiler:** broaden MS0024 trigger to non-LINQ [Queryable] callsites ([#218](#218)) ([7c47a44](7c47a44)) * **compiler:** emit qualified type refs in queryable expression trees ([eae3507](eae3507)), closes [#203](#203) * **compiler:** extend queryable walker with new + nested lambda ([#206](#206)) ([e633d2f](e633d2f)) * **compiler:** fold static readonly + pure-arithmetic captures ([#208](#208)) ([0a7d251](0a7d251)) * **compiler:** fuse adjacent LINQ stages at build time ([#207](#207)) ([bf48474](bf48474)) * **compiler:** group closure hasher for PR 3b ([8b0c270](8b0c270)) * **compiler:** hoist pure repeated subtrees out of captured ExprTrees ([#209](#209)) ([46ad6da](46ad6da)) * **compiler:** incremental cache MVP — whole-build short-circuit (ADR-0021) ([9eef85a](9eef85a)), closes [#21](#21) [#18](#18) [#18](#18) [#21](#21) [#211](#211) [#213](#213) [#18](#18) * **compiler:** lower extension indexers via item$get / item$set helpers ([#156](#156)) ([7bfe7dd](7bfe7dd)) * **compiler:** lower extension property setters via $set helpers ([#156](#156)) ([6768eb5](6768eb5)) * **compiler:** lower static extension members to module helpers ([#156](#156)) ([457d2b5](457d2b5)) * **compiler:** MS0024 hard error for explicit queryable opt-in ([#205](#205)) ([95ca446](95ca446)) * **compiler:** parallelize TypeTransformer per file group (ADR-0020) ([b37c6d2](b37c6d2)), closes [#21](#21) [#18](#18) [#211](#211) [#21](#21) [#211](#211) [#21](#21) [#18](#18) * **compiler:** per-group skip integration (PR 3c) ([6dd58b6](6dd58b6)), closes [#21](#21) [#18](#18) [#21](#21) [#18](#18) [#211](#211) [#213](#213) [#214](#214) [#215](#215) [#216](#216) * **compiler:** per-type signature hasher for PR 3b ([590a5c6](590a5c6)) * **compiler:** type-level dependency graph backbone for [#18](#18) + [#21](#21) ([fcf999f](fcf999f)) * **runtime:** migrate array provider to getStages + document linq slot ([#200](#200)) ([b5f0101](b5f0101)) * **runtime:** publish ExprTree visitor API for queryable providers ([f77fa13](f77fa13)), closes [#198](#198) ### 🐛 Bug Fixes * address PR [#204](#204) review + add pre-push targets/ sync check ([6815397](6815397)), closes [C#-style](https://github.com/danfma/C/issues/-style) * **compiler,runtime:** record equals() routes value-wrapper fields through valueEquals ([#202](#202)) ([3fd0659](3fd0659)) * **compiler:** gate per-group cache writes on disk-touching runs ([9776c13](9776c13)), closes [#217](#217) * **compiler:** harden per-group cache against config drift, tampering, and error runs ([669755f](669755f)), closes [#217](#217) * **compiler:** make parallel TypeTransformer's shared sinks actually thread-safe ([4147f83](4147f83)), closes [#213](#213) * **compiler:** stabilise IrTypeSignatureHasher cache key (gemini review) ([e2a246c](e2a246c)), closes [#216](#216) * **compiler:** tighten incremental cache key + harden cache reads ([47baaed](47baaed)), closes [#214](#214) * **compiler:** tighten WatchHost debounce + sync per Copilot review ([6e89b62](6e89b62)), closes [#215](#215) * **compiler:** tighten WatchHost rename + error handling, unsubscribe Ctrl+C handler ([695002b](695002b)), closes [#215](#215) * **compiler:** verify WatchHost review findings already landed ([9212907](9212907)) * **dependency-graph:** address PR [#211](#211) review findings ([4fce313](4fce313)) * **reorg:** correct cref namespaces + harden pre-push gate against build races ([7b88091](7b88091)) ### ♻️ Refactor * **compiler:** route ExprTree member/method casing through target-aware policy ([#210](#210)) ([21f9713](21f9713)) * **compiler:** split Metano.Compiler folders by concern (ADR-0019) ([a16aafb](a16aafb)) * **namespaces:** nest Metano.{Dart,TypeScript}.* under Metano.Compiler.* ([6276672](6276672)) ### 📝 Documentation * ADR-0023 — per-group skip foundation (PR 3b setup) ([ecb81db](ecb81db)) * **dependency-graph:** scope statement to signature surface only ([ed921d5](ed921d5)) * **plans:** seed reorganization brief ([208cb95](208cb95)) * **roadmap:** editorial charter so docs/roadmap stays focused ([abf809d](abf809d))
Summary
PR 4/4 of the #21 + #18 thread. Closes #18.
Adds
--watchtometano-typescriptandmetano-dart, backed by a target-agnosticWatchHostorchestrator (src/Metano.Compiler/Watch/WatchHost.cs):FileSystemWatcherrooted at the project directory, filtering.cs / .csproj / .props / .targetsand excludingbin/+obj/subtreesConsole.CancelKeyPress→CancellationTokenSourcefor clean Ctrl+C exitRunOncedelegate so target-specific post-emit (TypeScript'sPackageJsonWriter) runs each tickCombined with the incremental cache (ADR-0021), editor saves that do not actually change content cost only the load + extract phases. Real edits pay the parallel transform from ADR-0020.
Roadmap (#21 + #18)
Test plan
WatchHost.IsRelevant(extension filter +bin/objexclusion + mixed case + edge cases).csedit triggers full recompile + package.json regenerated →touchtriggers cache hit → Ctrl+C exits cleanlytargets/byte-identicalRunOnceextract-method to a follow-upCloses #18
Part of #21