Skip to content

feat(compiler): --watch mode#215

Merged
danfma merged 5 commits into
mainfrom
feat/watch-mode
May 7, 2026
Merged

feat(compiler): --watch mode#215
danfma merged 5 commits into
mainfrom
feat/watch-mode

Conversation

@danfma

@danfma danfma commented May 7, 2026

Copy link
Copy Markdown
Owner

Summary

PR 4/4 of the #21 + #18 thread. Closes #18.

Adds --watch to metano-typescript and metano-dart, backed by a target-agnostic WatchHost orchestrator (src/Metano.Compiler/Watch/WatchHost.cs):

  • Single recursive FileSystemWatcher rooted at the project directory, filtering .cs / .csproj / .props / .targets and excluding bin/ + obj/ subtrees
  • 250 ms quiet-period debounce + post-burst drain coalesces editor save bursts into a single recompile
  • Console.CancelKeyPressCancellationTokenSource for clean Ctrl+C exit
  • Exceptions in the runOnce delegate log the full stack trace but do NOT stop the watcher — transient compile failures iterate, not abort
  • The CLI builds a RunOnce delegate so target-specific post-emit (TypeScript's PackageJsonWriter) runs each tick

Combined 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

  • 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: boot → initial transpile → real .cs edit triggers full recompile + package.json regenerated → touch triggers cache hit → Ctrl+C exits cleanly
  • Generated TS under targets/ byte-identical
  • Dual review (compiler-man + bob) — addressed bin/obj exclusion, exception-stack logging, cancellation-flow simplification, debounce/drain extraction, naming improvements; deferred TS Commands RunOnce extract-method to a follow-up

Closes #18
Part of #21

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)
Copilot AI review requested due to automatic review settings May 7, 2026 21:41

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/Metano.Compiler/Watch/WatchHost.cs Outdated
Comment thread src/Metano.Compiler/Watch/WatchHost.cs
Comment thread src/Metano.Compiler/Watch/WatchHost.cs Outdated
Comment thread src/Metano.Compiler.TypeScript/Commands.cs Outdated
Comment thread src/Metano.Compiler.Dart/Commands.cs Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/Metano.Compiler/Watch/WatchHost.cs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 WatchHost orchestrator (FileSystemWatcher + relevance filter + debounce + Ctrl+C cancellation + resilient run loop).
  • Wire --watch flag into metano-typescript and metano-dart by wrapping the existing run in a RunOnce delegate.
  • Add unit tests for WatchHost.IsRelevant and 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.

Comment thread src/Metano.Compiler/Watch/WatchHost.cs Outdated
Comment thread src/Metano.Compiler/Watch/WatchHost.cs
Comment thread src/Metano.Compiler/Watch/WatchHost.cs Outdated
Comment thread src/Metano.Compiler/Watch/WatchHost.cs Outdated
Comment thread src/Metano.Compiler/Watch/WatchHost.cs Outdated
danfma added 2 commits May 7, 2026 18:46
… 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.
@danfma

danfma commented May 7, 2026

Copy link
Copy Markdown
Owner Author

Addressed in 2516c76 + 415760b.

@gemini-code-assist @chatgpt-codex-connector @copilot — eight findings across two passes:

Correctness:

  • Renamed handler now inspects both paths (gemini high, codex P1, copilot): renaming a relevant input (Foo.csFoo.txt) or moving it under bin/obj triggers a recompile. Before, the rename was silently dropped.
  • FileSystemWatcher.Error handler (gemini, copilot): buffer overflows surface as a yellow stderr warning telling the user to save again. The cache fingerprint catches up on the next save.
  • Console.CancelKeyPress is -= in finally (gemini, both targets): the handler no longer leaks across CLI invocations and a Ctrl+C after watch exits cannot deref a disposed CancellationTokenSource.
  • Startup banner now lists every watched extension (copilot): .cs / .csproj / .props / .targets instead of just .cs / .csproj.

Thread-safety:

  • lastEventAtUtc is now long ticks behind Volatile.Read/Write (copilot): the watcher's background-thread writes never tear or get reordered against the consumer-loop reads.

Behavior:

  • Debounce sleeps for the remaining quiet period (copilot): WaitForQuietPeriod now delays DebounceMs - elapsed instead of a fixed slice. Effective debounce is ~250 ms instead of drifting up to ~500 ms when an event lands right after a delay starts.

Perf:

  • ContainsBuildArtifactSegment walks the path via MemoryExtensions.SplitAny over a ReadOnlySpan<char> (gemini): no per-event array allocation. FileSystemWatcher is chatty enough that string.Split showed up on the GC profile.

1014 .NET tests pass. Manual smoke on macOS: --watch boots with the updated banner; rename Foo.csFoo.txt triggers a recompile (errors surface for the missing type), rename back recovers via cache hit; touch (no content change) still hits the cache; Ctrl+C exits cleanly.

@gemini-code-assist

Copy link
Copy Markdown

Thank you for the detailed update, @danfma. The implementation of WatchHost looks robust, and the performance optimizations—particularly the use of MemoryExtensions to avoid allocations—are excellent. The handling of FileSystemWatcher edge cases and the refined debounce logic significantly improve the developer experience. The changes effectively address the identified concerns.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

Copilot AI commented May 7, 2026

Copy link
Copy Markdown
Contributor

Addressed in 2516c76 + 415760b.

@gemini-code-assist @chatgpt-codex-connector @copilot — eight findings across two passes:...

All eight items are verified in the current branch (415760b):

  • Renamed handler checks both OldFullPath and FullPath
  • Error handler surfaces buffer overflow as a yellow stderr warning
  • CancelKeyPress -= handler in finally in both CLI targets
  • Startup banner lists all four extensions (.cs / .csproj / .props / .targets)
  • lastEventAtUtcTicks protected by Volatile.Read/Write
  • WaitForQuietPeriod sleeps only the remaining quiet period
  • ContainsBuildArtifactSegment uses MemoryExtensions.SplitAny over a ReadOnlySpan<char>

All 1014 tests pass.

Copilot finished work on behalf of danfma May 7, 2026 21:56
@danfma
danfma merged commit 13d8ab0 into main May 7, 2026
2 checks passed
danfma added a commit that referenced this pull request May 7, 2026
… 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
danfma added a commit that referenced this pull request May 7, 2026
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.
@danfma
danfma deleted the feat/watch-mode branch May 7, 2026 23:05
danfma added a commit that referenced this pull request May 11, 2026
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)
github-actions Bot pushed a commit that referenced this pull request May 12, 2026
## 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))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Watch mode (--watch) with FileSystemWatcher

3 participants