Skip to content

spec(config): watch directory config for extra watcher dirs (#530)#576

Merged
zeroedin merged 6 commits into
mainfrom
spec/issue-530-watch-config
May 12, 2026
Merged

spec(config): watch directory config for extra watcher dirs (#530)#576
zeroedin merged 6 commits into
mainfrom
spec/issue-530-watch-config

Conversation

@zeroedin

Copy link
Copy Markdown
Owner

Summary

  • Adds WatchMapping struct stub and Watch field to Config for compilation
  • 7 config tests: YAML parsing, validation (empty from, invalid/empty type, array index), acceptance (all types, no entries)
  • 10 watcher tests: WatchDirs inclusion (basic, mixed, glob), ClassifyChange mapping (content/layout/data + standard dirs), RebuildPipeline scope (all three types)
  • Updates PLAN.md with Watch Directories section and IMPLEMENTATION.md with implementation guidance

Design

Watch dirs reuse existing ContentChange/LayoutChange/DataChange — no new ChangeType constants needed. All three trigger RebuildPipeline, unlike passthrough which triggers RebuildRecopy. Watch dirs are checked before passthrough in ClassifyChange.

Test plan

  • go test ./internal/config/... --ginkgo.focus="issue #530" — 4 FAIL red (validation not implemented), 3 PASS green
  • go test ./internal/server/... --ginkgo.focus="issue #530" — 5 FAIL red (WatchDirs/ClassifyChange not implemented), 5 PASS green
  • go test ./internal/config/... --ginkgo.skip="issue #530" — all existing tests pass
  • go test ./internal/server/... --ginkgo.skip="issue #530" — all existing tests pass
  • go vet ./internal/config/... ./internal/server/... — clean

🤖 Generated with Claude Code

Add spec tests for the `watch:` config key that registers extra
directories for pipeline-triggering file watching during serve mode.
Unlike passthrough (RebuildRecopy), watch dirs trigger RebuildPipeline
by reusing existing ContentChange/LayoutChange/DataChange types.

Config tests: parsing, validation (empty from, invalid type, array
index in error), acceptance (all three types, no entries).
Watcher tests: WatchDirs inclusion, glob root extraction,
ClassifyChange mapping, RebuildPipeline scope confirmation.

WatchMapping struct stub added to config.go for compilation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Copilot AI 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.

Pull request overview

Adds the initial configuration surface area and spec/tests for “extra watch directories” (issue #530) so alloy serve can rebuild when plugin-read files change outside the standard content/ tree.

Changes:

  • Introduces Config.Watch []WatchMapping plus YAML testdata to parse watch: entries.
  • Adds new Ginkgo tests specifying expected validation behavior (watch[i].from/type) and watcher behavior (WatchDirs, ClassifyChange, rebuild scope).
  • Updates planning docs (PLAN.md, IMPLEMENTATION.md) with the feature spec and implementation notes.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
plans/PLAN.md Documents the new watch: config key behavior/validation.
plans/IMPLEMENTATION.md Adds implementation guidance for watch mappings in config + watcher.
internal/server/watcher_test.go Adds watcher specs for including/classifying watch: dirs.
internal/config/testdata/valid.yaml Adds watch: examples used by config parsing tests.
internal/config/config.go Adds Watch field to Config and defines WatchMapping.
internal/config/config_test.go Adds parsing + validation specs for watch: entries.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/config/config.go
Comment on lines 30 to 35
Permalinks map[string]string `yaml:"permalinks" toml:"permalinks" json:"permalinks"`
Pagination PaginationConfig `yaml:"pagination" toml:"pagination" json:"pagination"`
Passthrough []PassthroughMapping `yaml:"passthrough" toml:"passthrough" json:"passthrough"`
Watch []WatchMapping `yaml:"watch" toml:"watch" json:"watch"`
Sources map[string]*SourceConfig `yaml:"sources" toml:"sources" json:"sources"`
Sitemap SitemapConfig `yaml:"sitemap" toml:"sitemap" json:"sitemap"`

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This is a spec-first PR — tests are written to fail red and drive the implementation in a follow-up development PR. The validation logic, WatchDirs, and ClassifyChange changes are intentionally omitted. The WatchMapping struct stub is included only so the test file compiles.

Comment on lines +123 to +128
Describe("Watch directory watching (issue #530)", func() {
It("WatchDirs includes watch from: directories", func() {
cfg := &config.Config{
Title: "Watch Site",
Watch: []config.WatchMapping{
{From: "elements", Type: "content"},

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Same as above — spec-first workflow. Tests intentionally fail red to define the contract. The WatchDirs and ClassifyChange implementation is the developer's responsibility in the follow-up PR.

Comment on lines +641 to +717
Context("validation", func() {
It("rejects watch entry with empty from", func() {
cfg := &config.Config{
Title: "Test",
BaseURL: "https://example.com",
Watch: []config.WatchMapping{{From: "", Type: "content"}},
}
err := config.Validate(cfg)
Expect(err).To(HaveOccurred(),
"watch entry with empty from must fail validation (issue #530)")
Expect(err.Error()).To(ContainSubstring("from"),
"error must mention the invalid field (issue #530)")
})

It("rejects watch entry with invalid type", func() {
cfg := &config.Config{
Title: "Test",
BaseURL: "https://example.com",
Watch: []config.WatchMapping{{From: "elements", Type: "invalid"}},
}
err := config.Validate(cfg)
Expect(err).To(HaveOccurred(),
"watch entry with invalid type must fail validation (issue #530)")
Expect(err.Error()).To(ContainSubstring("type"),
"error must mention the invalid field (issue #530)")
})

It("rejects watch entry with empty type", func() {
cfg := &config.Config{
Title: "Test",
BaseURL: "https://example.com",
Watch: []config.WatchMapping{{From: "elements", Type: ""}},
}
err := config.Validate(cfg)
Expect(err).To(HaveOccurred(),
"empty type does not match content/layout/data (issue #530)")
})

It("accepts valid watch entries for all three types", func() {
cfg := &config.Config{
Title: "Test",
BaseURL: "https://example.com",
Watch: []config.WatchMapping{
{From: "elements", Type: "content"},
{From: "shared-layouts", Type: "layout"},
{From: "external-data", Type: "data"},
},
}
err := config.Validate(cfg)
Expect(err).NotTo(HaveOccurred(),
"all three watch types must be valid (issue #530)")
})

It("accepts config with no watch entries", func() {
cfg := &config.Config{
Title: "Test",
BaseURL: "https://example.com",
}
err := config.Validate(cfg)
Expect(err).NotTo(HaveOccurred(),
"omitting watch entirely must be valid (issue #530)")
})

It("includes index in validation error for second entry", func() {
cfg := &config.Config{
Title: "Test",
BaseURL: "https://example.com",
Watch: []config.WatchMapping{
{From: "elements", Type: "content"},
{From: "", Type: "data"},
},
}
err := config.Validate(cfg)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("watch[1]"),
"validation error must include array index (issue #530)")
})

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Spec-first workflow — validation tests define the contract for the developer. The 4 red validation tests (empty from, invalid type, empty type, array index) will pass once Validate() is extended in the implementation PR.

@zeroedin zeroedin left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review #1 — Ready to merge

Verdict: Ready to merge — 4 reviewers dispatched (correctness, testing, maintainability, project-standards).

Findings

# File Issue Reviewer Conf Route
1 config_test.go:~85 Empty type validation test has no ContainSubstring("type") assertion — peer tests ("empty from", "invalid type") both assert on error message content; this one only checks HaveOccurred() testing 100 advisory → human
2 watcher_test.go Watch/passthrough precedence untested — PLAN.md states "watch directories are checked before passthrough directories in ClassifyChange" but no test enforces this rule; two independent reviewers flagged this gap testing, correctness 75 advisory → human

Both findings are observations for the architect per project test immutability rules.

Testing Gaps

  1. Empty type test missing ContainSubstring("type") — inconsistent with peer validation tests
  2. No test for uppercase/mixed case watch type to verify case sensitivity
  3. Watch/passthrough precedence not tested (documented in PLAN.md but unenforced)
  4. No test for duplicate watch entries (same from: with different types)
  5. No test for watch from: overlapping standard dirs (e.g., from: "content" type: "layout")
  6. No test for glob pattern watch dirs in ClassifyChange (only WatchDirs tests glob root extraction)

Correctness Verification

  • Red/green split confirmed: config 4 FAIL / 3 PASS, watcher 5 FAIL / 5 PASS
  • WatchMapping struct tags consistent with PassthroughMapping pattern
  • Watch []WatchMapping placement on Config correct (after Passthrough, before Sources)
  • valid.yaml fixture matches config_test.go expectations
  • Validate() confirmed: no Watch validation yet (4 red tests drive this)
  • WatchDirs() confirmed: no Watch dirs yet (3 red tests drive this)
  • ClassifyChange() confirmed: no Watch handling yet (2 red tests drive this)
  • RebuildScopeForChangeType() confirmed: Content/Layout/DataChange already map to RebuildPipeline
  • ClassifyChange content test passes via fallback return ContentChange (watcher.go:119) — correct result, coincidental path

Verdict: Ready to merge. Well-structured spec PR with 17 tests, clear red/green expectations, minimal struct stub, and thorough plan documentation.

Run artifact: /tmp/compound-engineering/ce-code-review/20260512-aae07245/

…e test (#530)

Add ContainSubstring("type") assertion to empty-type validation test
for consistency with peer tests. Add ClassifyChange precedence test
verifying watch dirs take priority over passthrough when both match
the same directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@zeroedin

Copy link
Copy Markdown
Owner Author

Addressing review findings in 61e9289:

Finding #1 — empty type test missing ContainSubstring("type"): Fixed. Added the assertion to match peer tests.

Finding #2 — watch/passthrough precedence untested: Fixed. Added ClassifyChange prefers watch over passthrough for same directory test — config has elements in both watch: (type: content) and passthrough: (to: assets/elements), asserts ContentChange not PassthroughChange. Fails red as expected (returns 7/PassthroughChange, wants 1/ContentChange).

Remaining testing gaps (case sensitivity, duplicates, standard dir overlap, glob in ClassifyChange) are valid observations but lower priority — the core contract is covered by the 12 tests. Happy to add any of these if you want them.

Copilot AI 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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

@zeroedin zeroedin left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review #2 — Ready to merge

Verdict: Ready to merge — 5 reviewers dispatched (correctness, testing, maintainability, project-standards, previous-comments).

Review #1 Findings Status

# Finding Status
1 Empty type test missing ContainSubstring("type") Addressed — commit 61e9289 added the assertion, now consistent with peer tests
2 Watch/passthrough precedence untested Addressed — commit 61e9289 added "ClassifyChange prefers watch over passthrough for same directory" test

Findings

# Sev File Issue Route
1 P3 watcher_test.go:271-288 Three RebuildScopeForChangeType assertions omit (issue #530) while all 8 other assertions in the block include it — cosmetic inconsistency advisory → human

Red/Green Verification (18 tests)

  • Config: 4 FAIL red (validation), 3 PASS green (parsing + acceptance)
  • Watcher: 6 FAIL red (WatchDirs ×3, ClassifyChange layout/data/precedence), 5 PASS green (ClassifyChange content via fallback, standard dirs, scope ×3)
  • Precedence test fails red correctly: ClassifyChange checks passthrough first → returns PassthroughChange, not expected ContentChange

Remaining Testing Gaps

  1. Nonexistent watch directory behavior (validation vs runtime error)
  2. Duplicate watch entries (same from: with different types)
  3. Watch dir overlapping base directories (from: "content" type: "layout")
  4. Trailing slash in from: path matching
  5. File extension mismatch under watch directory

All are edge cases that don't block this spec PR.

Verdict: Ready to merge. Both review #1 findings addressed. Test count 17 → 18. Red/green split correct (10 red, 8 green). 1 P3 advisory (missing issue refs in scope assertions).

Run artifact: /tmp/compound-engineering/ce-code-review/20260512-b7f3c901/

Add tests for duplicate from: paths (ambiguous classification),
from: overlapping base structure dirs (conflicting ChangeType),
and trailing slash normalization (from: "elements/" must work).
Add ClassifyChange trailing slash test in watcher.

Update PLAN.md and IMPLEMENTATION.md with new validation rules.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@zeroedin

Copy link
Copy Markdown
Owner Author

Added edge case validation tests in 4f600dc:

Duplicate from: pathsfrom: "elements" type: content + from: "elements" type: layout must error. Ambiguous classification with no right answer.

Base dir overlapfrom: "content" type: layout must error. Standard dirs already have fixed ChangeType; a watch entry would create conflicting classification.

Trailing slash normalizationfrom: "elements/" must be accepted and normalized. Config test verifies validation passes; watcher test verifies ClassifyChange still matches files under the directory. Both pass green (config via no-validation-yet, watcher via default fallthrough).

Updated red/green totals: config 6 FAIL / 4 PASS, watcher 6 FAIL / 6 PASS.

Copilot AI 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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Comment thread internal/server/watcher_test.go Outdated
Comment on lines +229 to +233
changeType := server.ClassifyChange("elements/rh-button/docs/overview.md", cfg)
Expect(changeType).To(Equal(server.ContentChange),
"trailing slash in from: must not break path matching — "+
"the slash should be normalized before ClassifyChange "+
"compares prefixes (issue #530)")
Comment thread plans/PLAN.md Outdated

**Behavior:** `WatchDirs()` includes all watch `from:` directories (or glob roots) in the watch list. `ClassifyChange()` maps files under watch directories to `ContentChange`, `LayoutChange`, or `DataChange` based on their declared `type`. All three types map to `RebuildPipeline` via `RebuildScopeForChangeType()` — this is the key difference from passthrough (which triggers `RebuildRecopy`). Watch directories are checked before passthrough directories in `ClassifyChange` — a directory in both `watch:` and `passthrough:` uses the watch classification.

**Validation:** `from` must not be empty. `type` must be one of: `content`, `layout`, `data`. Invalid entries produce a validation error with the array index (e.g., `watch[1].type must be content, layout, or data`). Duplicate `from` paths are rejected — ambiguous classification (which type wins?). `from` paths matching base structure directories (`content`, `layouts`, `data`, `assets`, `static`) are rejected — those already have fixed `ChangeType` classification. Trailing slashes in `from` are normalized (stripped), not rejected.
Comment thread plans/IMPLEMENTATION.md Outdated
- HTTP server with mode-aware behavior (dev/preview)
- File watcher with debouncing and change classification
- **Passthrough watching (issue #275)**: `WatchDirs(cfg)` must iterate `cfg.Passthrough` and append each `from:` path. `ClassifyChange(path, cfg)` must add a `PassthroughChange` type for files matching passthrough source directories. The serve rebuild handler should recopy only the changed file to `_site/<to>/<relative-path>` on `PassthroughChange` instead of triggering a full pipeline rebuild. In `alloy dev`, passthrough changes only trigger a browser reload (files are served from source). `addRecursiveWatch` must be called on each passthrough `from:` directory.
- **Watch directory config (issue #530)**: `WatchMapping` struct in `internal/config/config.go` — `From string`, `Type string` with yaml/toml/json tags. `Watch []WatchMapping` field on `Config` after `Passthrough`. `Validate()`: reject empty `from`, reject `type` not in {content, layout, data}, include array index in error. Reject duplicate `from` paths (build a seen-set, error on collision). Reject `from` matching base structure dirs (content, layouts, data, assets, static). Normalize trailing slashes in `from` (strip before storing/comparing). `WatchDirs()`: loop after passthrough — append `w.From` (or `static.GlobRoot(w.From)` for globs). `ClassifyChange()`: in `default:` branch, check watch dirs before passthrough loop — switch on `w.Type` to return `ContentChange`/`LayoutChange`/`DataChange`. No new `ChangeType` constant needed — reuses existing types. No changes to `cmd/serve.go`, `cmd/watcher.go`, or `RebuildScopeForChangeType` — existing dispatch handles all three types correctly.
Comment thread internal/config/config.go
Comment on lines +140 to +145
// WatchMapping registers an external directory for pipeline-triggering
// file watching during serve mode.
type WatchMapping struct {
From string `yaml:"from" toml:"from" json:"from"`
Type string `yaml:"type" toml:"type" json:"type"`
}

@zeroedin zeroedin left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review #3 — Ready to merge

Verdict: Ready to merge — 5 reviewers dispatched (correctness, testing, maintainability, project-standards, previous-comments). 0 actionable findings.

New in commit 4f600dc

4 new tests (22 total, up from 18):

  • "rejects duplicate watch entries with same from" — addresses review #2 gap #2
  • "rejects watch from: that overlaps a base structure directory" — addresses review #2 gap #3
  • "normalizes trailing slash in watch from: path" + "ClassifyChange matches watch dir with trailing slash in from" — addresses review #2 gap #4

Plan docs (PLAN.md, IMPLEMENTATION.md) updated with duplicate/overlap/trailing slash rules.

Prior Review Findings

Review Finding Status
#1 Empty type test missing ContainSubstring("type") Addressed61e9289
#1 Watch/passthrough precedence untested Addressed61e9289
#2 Scope assertions lack "(issue #530)" False positive — verified all 3 assertions always included the reference (lines 239, 245, 251)

Red/Green Verification (22 tests)

  • Config: 6 FAIL red (empty from, invalid type, empty type, array index, duplicate from, base dir overlap), 4 PASS green (parsing, valid types, no entries, trailing slash)
  • Watcher: 6 FAIL red (WatchDirs ×3, ClassifyChange layout/data/precedence), 6 PASS green (content via fallback, standard dirs, trailing slash via fallback, scope ×3)

Remaining Testing Gaps

  1. Nonexistent watch directory behavior (runtime vs validation error)
  2. File extension mismatch under watch directory (e.g., .css under type: content)
  3. ClassifyChange with glob-pattern watch from: (WatchDirs tests glob root but ClassifyChange doesn't)
  4. Trailing slash ClassifyChange test passes green via fallback, not via watch-aware normalization — cannot distinguish correct handling from coincidental pass

All are edge cases. None block this spec PR.

Verdict: Ready to merge. All prior findings addressed. Test count 17 → 22 across 3 commits. 0 actionable findings. Clean.

Run artifact: /tmp/compound-engineering/ce-code-review/20260512-58746cbc/

zeroedin and others added 3 commits May 12, 2026 14:06
Validate that watch from: pointing to a nonexistent directory fails
at config validation time — fail fast with a clear error instead of
silently watching nothing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tal pass (#530)

The trailing slash ClassifyChange test passed via the default
ContentChange fallback, not via watch-aware normalization. Switch
to type: layout so the test can only pass if ClassifyChange
actually recognizes the watch directory after normalizing the
trailing slash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
WatchDirs tests glob root extraction but ClassifyChange had no
corresponding test. Add one with type: data to avoid the default
ContentChange fallback coincidental pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@zeroedin

Copy link
Copy Markdown
Owner Author

Final test counts after review feedback:

Config (11 tests): 7 FAIL red (empty from, invalid type, empty type, array index, duplicate from, base dir overlap, nonexistent dir) / 4 PASS green (parsing, valid types, no entries, trailing slash normalization)

Watcher (13 tests): 8 FAIL red (WatchDirs x3, ClassifyChange layout/data/glob/precedence/trailing-slash) / 5 PASS green (ClassifyChange content [default fallthrough], standard dirs, RebuildPipeline x3)

All coincidental green-via-fallback tests have been eliminated or use non-content types to distinguish real handling from default ContentChange return. File extension mismatch skipped — by-design behavior already covered by existing ClassifyChange tests.

@zeroedin
zeroedin merged commit 83ab922 into main May 12, 2026
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.

2 participants