Skip to content

Commit ee3595c

Browse files
os-warrenclaude
andauthored
fix(spec,core): startWatching refuses instead of reporting success; watchPatterns retired (#12571)
`HotReloadManager.startWatching` contained no watcher. Its whole body was a guard plus `logger.info('File watching started', { patterns })`, above an in-source note saying real watching "would require chokidar or similar". Where the sibling defect in this file announced its inert fallback at DEBUG, this claimed success at INFO: an operator who set `enabled: true` with `watchPatterns` and read that line had been told the opposite of the truth. `watchHandles` was only ever read, deleted, iterated and cleared and never set, so `stopWatching`'s cleanup branch and the teardown loop over its keys were structurally unreachable rather than merely untaken. The zero was pinned with a firing positive control before anything was removed: `reloadTimers.set` resolves a real writer in the same file and the same scan, while `watchHandles.set` resolves nothing anywhere in the tree. `watchPatterns` therefore had no reader that acted on it — its only two uses were log lines — so ADR-0049 enforce-or-remove applies. Neither of the other two states was available: no runtime composes `HotReloadManager`, so enforcing would build for a caller that does not exist, and a scan of every planning doc returned zero mentions of hot-reload file watching against 145 control hits in the same files, so there is no roadmap for `experimental` to point at. The key is tombstoned rather than deleted, and the build chose that: the plain deletion was tried first and `gen:schema` gate (a) refused it, because `HotReloadConfigSchema` is not `.strict()` and a bare deletion would be a silent strip (ADR-0104) — the very defect being retired, one layer down. `kernel/HotReloadConfig:watchPatterns` is registered in `RETIRED_KEYS_BY_MAJOR[18]` with the D3 semantic entry `hot-reload-watch-placeholder-retired`. No D2 conversion: `HotReloadConfig` is not an authorable surface, so a conversion would be a transform with no seam that ever runs. Because nothing in the tree parses `HotReloadConfigSchema` outside its own unit test, the tombstone alone reaches nobody, so the runtime doors carry the prescription: `startWatching` throws an ADR-0112 envelope pointing at `scheduleReload`, and `registerPlugin` refuses a leftover `watchPatterns` the same way, before the `enabled` check. `startWatching` is kept as a throwing door rather than deleted so that caller meets a prescription instead of a bare TypeError. Runtime reload behaviour is unchanged: nothing was ever watched, so nothing that used to happen stops happening. `stopWatching` keeps the half that always did something, and `shutdown` lost only a loop that iterated `watchHandles` and therefore ran zero times. Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3f41a21 commit ee3595c

11 files changed

Lines changed: 662 additions & 57 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/core": minor
4+
---
5+
6+
fix(spec,core): `HotReloadManager.startWatching` refuses instead of reporting success; `HotReloadConfig.watchPatterns` retired (#12428, ADR-0049)
7+
8+
<!-- adr-0087: registered hot-reload-watch-placeholder-retired -->
9+
10+
**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
11+
launch-window convention ships it as `minor`; the prescription is registered
12+
under protocol major 18 — `RETIRED_KEYS_BY_MAJOR[18]` + the D3 semantic entry
13+
`hot-reload-watch-placeholder-retired` — where `os migrate meta` users will
14+
look). Graded `minor` rather than `major` for the same reason #12340 was one
15+
day earlier, in this same module.
16+
17+
ADR-0049 applied one symbol over from #12340, in the same file and on the same
18+
per-key test. The #11825 keep still stands: `HotReloadConfigSchema` and
19+
`PluginStateSnapshotSchema` still export, and `HotReloadManager` /
20+
`PluginHealthMonitor` are untouched apart from the two doors below.
21+
22+
`HotReloadManager.startWatching` contained **no watcher**. Its whole body was a
23+
guard plus `logger.info('File watching started', { patterns })`, above an
24+
in-source note saying real watching "would require chokidar or similar". Where
25+
#12340's inert fallback at least announced itself at DEBUG, this claimed
26+
success at **INFO**: an operator who set `enabled: true` with `watchPatterns`
27+
and read that line had been told the opposite of the truth. `watchHandles` was
28+
only ever read, deleted, iterated and cleared and **never set**, so
29+
`stopWatching`'s cleanup branch and the teardown loop over its keys were
30+
structurally unreachable rather than merely untaken. `watchPatterns` therefore
31+
had no reader that acted on it — its only two uses were log lines.
32+
33+
FROM → TO:
34+
35+
- `watchPatterns: ['src/**/*.ts']`*(removed)* — delete the key. Declare your
36+
globs wherever your own watcher reads them.
37+
- `manager.startWatching(name)``manager.scheduleReload(name, reloadFn)`,
38+
called from your own watcher's change handler. That is the debounced
39+
integration point this class does implement, and it is unchanged.
40+
41+
One-line fix: delete `watchPatterns`, and call `scheduleReload` from your own
42+
file watcher instead of `startWatching` — nothing was ever watched, so nothing
43+
that used to happen stops happening. File watching is the host's job in this
44+
host-driven library; `chokidar` is already a dependency of
45+
`@objectstack/metadata`, `@objectstack/metadata-fs` and `@objectstack/cli`
46+
never of `@objectstack/core` — so a host has a working model to copy.
47+
48+
The retirement kit:
49+
50+
- **key tombstone**, and the build is what chose it: the plain deletion was
51+
tried first and `gen:schema` gate (a) refused it, because
52+
`HotReloadConfigSchema` is not `.strict()` and a bare deletion would be a
53+
silent strip (#3733, ADR-0104) — the very defect being retired, one layer
54+
down. #12340 could take route 3 because what left there was a whole *def*; a
55+
key leaving a *surviving* def has no such exit. So `watchPatterns` is
56+
`retiredKey()`-tombstoned, its surface line carries `[RETIRED]`, and
57+
`kernel/HotReloadConfig:watchPatterns` is registered by exact key in
58+
`RETIRED_KEYS_BY_MAJOR[18]`. A key tombstone on a surviving def moves
59+
`authorable-surface` only — the def still emits, so `api-surface` and
60+
`json-schema.manifest` do not.
61+
- **no D2 conversion**, deliberately: the chain walks a normalized stack, and
62+
`HotReloadConfig` is not an authorable surface — no metadata-type binding,
63+
stack collection or manifest embed ever carried it — so a conversion would be
64+
a transform with no seam that ever runs. For the same reason the prescription
65+
carries no `os migrate meta` sentence, exactly as its `stateStrategy` sibling
66+
in this module does not.
67+
- **runtime doors** in `@objectstack/core`, because nothing in the tree parses
68+
`HotReloadConfigSchema` outside its own unit test, so the tombstone alone
69+
reaches nobody: `startWatching` now throws an ADR-0112 envelope
70+
(`code: VALIDATION_ERROR`, `status: 400`) carrying the prescription, and
71+
`registerPlugin` refuses a leftover `watchPatterns` the same way — before the
72+
`enabled` check, so a disabled config cannot smuggle the false declaration
73+
through. `startWatching` is kept as a throwing door rather than deleted so
74+
that caller meets a prescription instead of a bare `TypeError`.
75+
- **dead code removed with a firing positive control**: `watchHandles` and both
76+
of its unreachable readers are gone. The zero was pinned first —
77+
`reloadTimers.set` resolves a real writer in the same file and the same scan,
78+
while `watchHandles.set` resolves nothing anywhere. `stopWatching` keeps the
79+
half that always did something (it cancels a pending debounced reload), and
80+
`shutdown` is unchanged in effect: the loop it lost iterated `watchHandles`
81+
and therefore ran zero times.
82+
- **ENFORCE and EXPERIMENTAL were both unavailable**, which is why this is a
83+
removal: no runtime composes `HotReloadManager`, so enforcing would build for
84+
a caller that does not exist; and a scan of every planning doc returned zero
85+
mentions of hot-reload file watching against 145 control hits in the same
86+
files, so there is no roadmap for `experimental` to point at.

content/docs/references/kernel/plugin-lifecycle-advanced.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const result = HotReloadConfigSchema.parse(data);
4141
| Property | Type | Required | Description |
4242
| :--- | :--- | :--- | :--- |
4343
| **enabled** | `boolean` | optional (default: `false`) | |
44-
| **watchPatterns** | `string[]` | optional | Glob patterns to watch for changes |
44+
| **watchPatterns** | `never` | optional | [REMOVED] `HotReloadConfig.watchPatterns` was removed in @objectstack/spec 18 (#12428, ADR-0049 enforce-or-remove) — nothing ever read it. Its only two uses were log lines in `HotReloadManager`, and one of them announced 'File watching started' at INFO level while no watcher was ever constructed: `startWatching` held a placeholder, and `watchHandles` was read, deleted, iterated and cleared but never set. So an author could declare a glob and no file change could ever trigger a reload. Delete the key. File watching is the HOST's job in this host-driven library: run your own watcher, declare your globs wherever that watcher reads them, and call `HotReloadManager.scheduleReload(pluginName, reloadFn)` when one matches — the debounced integration point this class does implement, and which is unchanged. |
4545
| **debounceDelay** | `integer` | optional (default: `1000`) | Wait time after change detection before reload |
4646
| **preserveState** | `boolean` | optional (default: `true`) | Keep plugin state across reloads |
4747
| **stateStrategy** | `Enum<'memory' \| 'none'>` | optional (default: `"memory"`) | How to preserve state during reload |

packages/core/examples/phase2-integration.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,6 @@ async function example() {
278278
// Hot reload
279279
hotReload: {
280280
enabled: true,
281-
watchPatterns: ['plugins/my-plugin/**/*.ts'],
282281
debounceDelay: 1000,
283282
preserveState: true,
284283
stateStrategy: 'memory',

packages/core/src/hot-reload.test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,3 +328,136 @@ describe('[#12340] stateStrategy refusal', () => {
328328
});
329329
}
330330
});
331+
332+
333+
// ── [#12428] The watching placeholder refuses instead of reporting success ───
334+
//
335+
// Before this card `startWatching` contained NO watcher: a guard plus
336+
// `logger.info('File watching started', { patterns })` above an in-source note
337+
// saying real watching "would require chokidar or similar". An operator who set
338+
// `enabled: true` with `watchPatterns` and read that INFO line had been told the
339+
// opposite of the truth. `watchHandles` was only ever read, deleted, iterated
340+
// and cleared and NEVER set, so `stopWatching`'s cleanup branch and the teardown
341+
// loop over its keys were structurally unreachable, not merely untaken.
342+
//
343+
// The last two tests are the behaviour-PRESERVATION pins for those removals:
344+
// what left was unreachable, and what stayed still works.
345+
describe('[#12428] startWatching refusal and the watch-handle removal', () => {
346+
const liveConfig = (overrides: Record<string, unknown> = {}): HotReloadConfigParsed =>
347+
({
348+
enabled: true,
349+
debounceDelay: 1000,
350+
preserveState: false,
351+
stateStrategy: 'memory',
352+
shutdownTimeout: 1000,
353+
...overrides,
354+
}) as unknown as HotReloadConfigParsed;
355+
356+
let mgr: HotReloadManager;
357+
beforeEach(() => {
358+
mgr = new HotReloadManager(createRecordingLogger([]));
359+
});
360+
361+
it('refuses startWatching with an ADR-0112 envelope and the prescription', () => {
362+
mgr.registerPlugin('p', liveConfig());
363+
364+
let caught: (Error & { code?: string; status?: number }) | undefined;
365+
try {
366+
mgr.startWatching('p');
367+
} catch (e) {
368+
caught = e as Error & { code?: string; status?: number };
369+
}
370+
371+
// The envelope, not merely "it threw": a bare toThrow() would stay green
372+
// against any unrelated failure on this path.
373+
expect(caught, 'startWatching must be refused').toBeDefined();
374+
expect(caught?.code).toBe('VALIDATION_ERROR');
375+
expect(caught?.status).toBe(400);
376+
377+
// The prescription's load-bearing facts, by CONTENT — this message is the
378+
// whole migration document for whoever hits it.
379+
const m = caught?.message ?? '';
380+
expect(m).toContain('#12428');
381+
expect(m).toContain('ADR-0049');
382+
expect(m).toContain('never watched');
383+
expect(m).toContain('scheduleReload');
384+
expect(m).toContain('p'); // locates the offending plugin
385+
});
386+
387+
it('refuses startWatching for an UNREGISTERED plugin too', () => {
388+
// The old body early-returned when the plugin was unknown or disabled, so
389+
// the lie was conditional. The refusal must not be: the method never
390+
// worked for anyone, in any state.
391+
expect(() => mgr.startWatching('never-registered')).toThrow(/#12428/);
392+
});
393+
394+
it('refuses a leftover watchPatterns at registration', () => {
395+
// The schema is not .strict(), so zod would STRIP this key on any parse
396+
// path — a clean parse and a setting that never takes effect. Route 3 left
397+
// no parse-time prescription (nothing parses the schema), so THIS is the
398+
// door that keeps the removal honest for the audience that exists.
399+
let caught: (Error & { code?: string; status?: number }) | undefined;
400+
try {
401+
mgr.registerPlugin('p', liveConfig({ watchPatterns: ['src/**/*.ts'] }));
402+
} catch (e) {
403+
caught = e as Error & { code?: string; status?: number };
404+
}
405+
expect(caught).toBeDefined();
406+
expect(caught?.code).toBe('VALIDATION_ERROR');
407+
expect(caught?.status).toBe(400);
408+
expect(caught?.message).toContain('watchPatterns');
409+
expect(caught?.message).toContain('#12428');
410+
expect(caught?.message).toContain('nothing ever read it');
411+
});
412+
413+
it('refuses watchPatterns even when hot reload is disabled', () => {
414+
// The door must not depend on `enabled` — a false declaration is false
415+
// whether or not the feature is switched on.
416+
expect(() =>
417+
mgr.registerPlugin('p', liveConfig({ enabled: false, watchPatterns: ['a/**'] }))
418+
).toThrow(/#12428/);
419+
});
420+
421+
it('still registers a config that does not carry the retired key', () => {
422+
// Anti-vacuity for the door: the refusals above must be about the key, not
423+
// about registration having broken.
424+
expect(() => mgr.registerPlugin('p', liveConfig())).not.toThrow();
425+
});
426+
427+
it('stopWatching still cancels a pending debounced reload', () => {
428+
// Behaviour preservation for the `watchHandles` removal. What left was the
429+
// unreachable cleanup branch; the half that always did something — clearing
430+
// the debounce timer armed by `scheduleReload` — is untouched.
431+
vi.useFakeTimers();
432+
try {
433+
mgr.registerPlugin('p', liveConfig());
434+
let ran = 0;
435+
mgr.scheduleReload('p', async () => { ran++; });
436+
437+
mgr.stopWatching('p');
438+
vi.advanceTimersByTime(5000);
439+
expect(ran, 'the scheduled reload must have been cancelled').toBe(0);
440+
} finally {
441+
vi.useRealTimers();
442+
}
443+
});
444+
445+
it('shutdown still clears pending timers without the dead teardown loop', () => {
446+
// The removed loop iterated `watchHandles.keys()` and therefore ran zero
447+
// times; every timer it could have reached is cleared by the
448+
// `reloadTimers` loop that follows it. This pins that equivalence.
449+
vi.useFakeTimers();
450+
try {
451+
mgr.registerPlugin('p', liveConfig());
452+
let ran = 0;
453+
mgr.scheduleReload('p', async () => { ran++; });
454+
455+
mgr.shutdown();
456+
vi.advanceTimersByTime(5000);
457+
expect(ran, 'shutdown must leave no pending reload behind').toBe(0);
458+
expect(vi.getTimerCount()).toBe(0);
459+
} finally {
460+
vi.useRealTimers();
461+
}
462+
});
463+
});

0 commit comments

Comments
 (0)