@immediately-run/grove 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ // @vitest-environment jsdom
2
+ // `useCatalogAnswered` — rendered in a real React tree, driven through the SDK's REAL
3
+ // catalog push channel against an emitting stub transport. The whole point of the hook is
4
+ // a timing property of that channel, so a mock of either would assert nothing.
5
+ //
6
+ // The property under test: the push channel has no value-equality check, so a host answer
7
+ // of `[]` — identical to the initial value — still notifies. That second notification is
8
+ // the signal. A test that only pushed a NON-empty catalog would pass against a naive
9
+ // `catalog.length > 0` implementation and prove nothing, which is why the empty-answer
10
+ // case is the one that carries this file.
11
+ import { describe, it, expect, vi, beforeAll } from 'vitest';
12
+ import { act } from 'react';
13
+ import { createRoot } from 'react-dom/client';
14
+
15
+ type Handler = (msg: Record<string, unknown>) => void;
16
+ const handlers = new Set<Handler>();
17
+ const emit = (msg: Record<string, unknown>): void => {
18
+ for (const h of handlers) h(msg);
19
+ };
20
+
21
+ // Installed BEFORE the SDK is imported: the channel resolves its transport lazily, and
22
+ // the hook module subscribes at import time.
23
+ (globalThis as { __immediatelyRun__?: unknown }).__immediatelyRun__ = {
24
+ transport: {
25
+ sendMessage: vi.fn(),
26
+ protocolRequest: vi.fn(async () => ({})),
27
+ onMessage: (handler: Handler) => {
28
+ handlers.add(handler);
29
+ return { dispose: () => handlers.delete(handler) };
30
+ },
31
+ },
32
+ };
33
+
34
+ let useCatalogAnswered: typeof import('./useCatalogAnswered').useCatalogAnswered;
35
+ let getCatalog: typeof import('@immediately-run/sdk').getCatalog;
36
+
37
+ beforeAll(async () => {
38
+ ({ useCatalogAnswered } = await import('./useCatalogAnswered'));
39
+ ({ getCatalog } = await import('@immediately-run/sdk'));
40
+ });
41
+
42
+ /** Render a probe recording the hook's value on every render. */
43
+ const renderProbe = async () => {
44
+ const seen: boolean[] = [];
45
+ const Probe = () => {
46
+ seen.push(useCatalogAnswered());
47
+ return null;
48
+ };
49
+ const root = createRoot(document.createElement('div'));
50
+ await act(async () => {
51
+ root.render(<Probe />);
52
+ });
53
+ return { seen, unmount: () => act(async () => root.unmount()) };
54
+ };
55
+
56
+ describe('useCatalogAnswered', () => {
57
+ it('is false before the host answers — the subscribe-time call is not an answer', async () => {
58
+ // `onCatalogChange` invokes its listener once, SYNCHRONOUSLY, with the current value
59
+ // when you subscribe. If that counted, the flag would be true from module load and
60
+ // the reach card would read an unanswered catalog as "not granted" — the exact defect
61
+ // this hook exists to prevent.
62
+ getCatalog(); // start the channel the way the app does
63
+ const { seen, unmount } = await renderProbe();
64
+ expect(seen[seen.length - 1]).toBe(false);
65
+ await unmount();
66
+ });
67
+
68
+ it('an EMPTY catalog answer flips it, and RE-RENDERS — an empty answer is still an answer', async () => {
69
+ const { seen, unmount } = await renderProbe();
70
+ expect(seen[seen.length - 1]).toBe(false);
71
+ const before = seen.length;
72
+
73
+ await act(async () => {
74
+ emit({ type: 'api-catalog', methods: [] });
75
+ });
76
+
77
+ // Both halves matter: a hook that returned the flag without subscribing would show
78
+ // the right value only on the NEXT unrelated render.
79
+ expect(seen.length).toBeGreaterThan(before);
80
+ expect(seen[seen.length - 1]).toBe(true);
81
+ await unmount();
82
+ });
83
+
84
+ it('stays true for a probe mounted after the answer', async () => {
85
+ const { seen, unmount } = await renderProbe();
86
+ expect(seen[seen.length - 1]).toBe(true);
87
+ await unmount();
88
+ });
89
+ });
@@ -0,0 +1,73 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import { onCatalogChange } from '@immediately-run/sdk';
3
+
4
+ // Has the host actually ANSWERED the grant-filtered method catalog?
5
+ //
6
+ // `useCatalog()` starts `[]` and an empty catalog is a legitimate answer (a frame granted
7
+ // nothing), so the value alone cannot distinguish "granted nothing" from "has not replied
8
+ // yet". The reach card needs that distinction: `!chatGranted` is true in both cases, and
9
+ // reading it as "not granted" before the host has spoken claims a consent state about a
10
+ // frame that may well hold the grant (grove#75 round 1).
11
+ //
12
+ // It is derivable here with no SDK change, because the push channel has **no
13
+ // value-equality check**: every host push notifies subscribers, including one whose value
14
+ // equals the initial `[]`. So a notification after the subscribe means the host replied.
15
+ //
16
+ // THE SUBSCRIBE-TIME CALL. `onCatalogChange` invokes the listener once, SYNCHRONOUSLY,
17
+ // with the current value before returning. That call is the subscribe, not an answer — but
18
+ // it is not worthless either, and an earlier version of this file threw it away with a
19
+ // blanket reset. A NON-EMPTY catalog at subscribe can only have come from the host, so it
20
+ // is proof the answer already landed. Reading it that way is what makes this correct when
21
+ // the module is evaluated LATE: grove is also published as a pinned library
22
+ // (`src/lib.ts`), so "imported before any render" is an assumption about one embedding,
23
+ // not a guarantee (grove#75 round 3).
24
+ //
25
+ // RESIDUALS, stated rather than implied:
26
+ // - a host that answers `[]` BEFORE this module is evaluated is indistinguishable from
27
+ // one that has not answered, and leaves the flag false for the realm's life. The Q&A
28
+ // row then stays neutral rather than naming a cause — the same honest "we have not been
29
+ // told" that the provider channel's `unknown` already renders, but it will not
30
+ // self-correct, because the host does not push again;
31
+ // - a host that never answers at all leaves it false forever, for the same reason;
32
+ // - importing this module is what STARTS the catalog channel and sends
33
+ // `REQUEST_API_CATALOG`. That used to be the first `useCatalog()` render. The channel
34
+ // starts at most once and the poll is idempotent, but the timing moved.
35
+ let answered = false;
36
+ let subscribing = true;
37
+ const waiters = new Set<() => void>();
38
+
39
+ onCatalogChange((catalog) => {
40
+ if (subscribing) {
41
+ // Synchronous, at subscribe: the current value, not an answer — except that a
42
+ // non-empty catalog could only have come from the host.
43
+ answered = catalog.length > 0;
44
+ return;
45
+ }
46
+ if (answered) return;
47
+ answered = true;
48
+ for (const w of waiters) w();
49
+ });
50
+ subscribing = false;
51
+
52
+ const subscribe = (onStoreChange: () => void): (() => void) => {
53
+ waiters.add(onStoreChange);
54
+ return () => {
55
+ waiters.delete(onStoreChange);
56
+ };
57
+ };
58
+ const getSnapshot = (): boolean => answered;
59
+
60
+ /**
61
+ * Whether the host has answered the method catalog. Re-renders when it flips.
62
+ *
63
+ * `useSyncExternalStore` rather than `useState` + `useEffect`, deliberately: the flag is
64
+ * read during render and written outside React, and a passive effect leaves a window
65
+ * between the two. An answer landing in that window used to be lost — the effect saw the
66
+ * flag already set, returned early without registering a waiter, and the component
67
+ * rendered a stale `false` with nothing left to wake it (grove#75 round 3, reproduced).
68
+ * `useSyncExternalStore` re-reads the snapshot after subscribing and re-renders if it
69
+ * moved, which is exactly that hole.
70
+ */
71
+ export function useCatalogAnswered(): boolean {
72
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
73
+ }
@@ -147,7 +147,7 @@ describe('linkKind — which hrefs may become a navigating <a>', () => {
147
147
  // dispatch declares CORPUS-relative (the host joins its chroot prefix — the corpus's
148
148
  // repo-side location is host knowledge this app cannot see).
149
149
  import { viewedDocumentForTarget } from './content';
150
- import { setContentRoot, resetContentRoot } from './contentRoot';
150
+ import { setContentRoot, resetContentRoot, isDispatched } from './contentRoot';
151
151
  import { afterEach } from 'vitest';
152
152
 
153
153
  describe('viewedDocumentForTarget — the R3-268 declaration path space', () => {
@@ -195,6 +195,25 @@ describe('viewedDocumentForTarget — the R3-268 declaration path space', () =>
195
195
  // answer "which PATH?" as well as "which entry?" — `[the handbook](handbook)` names
196
196
  // something real and must not render as a broken link.
197
197
  describe('hrefTargetKey — resolution without the entry-file requirement', () => {
198
+ afterEach(resetContentRoot);
199
+ it('R3-184 S2 — the $fs: clamp, ON THE RENDER PATH (the join: isDispatched → resolveLinkTarget)', () => {
200
+ // The clamp that is in force lives in hrefTargetKey's linkSpaceOpts: the
201
+ // decision (isDispatched) and the emission (resolveLinkTarget) join in ONE
202
+ // call, and THIS is the join — ways_of_working §4's rule that a decision and
203
+ // an emitter can both be tested while the line joining them is not.
204
+ // Fork (own repo): $fs: stays mount-absolute, as shipped (R3-273).
205
+ expect(hrefTargetKey('$fs:/mnt/aaa/x.mdx', HOME)).toBe('/mnt/aaa/x.mdx');
206
+ // Dispatched (a corpus mount): the shared resolver clamps $fs: onto the
207
+ // bundle root (R3-319's bundleChrooted), so the app-level mount point is
208
+ // not nameable — the resolved target is a CORPUS path, and only a real
209
+ // corpus entry there resolves downstream.
210
+ setContentRoot('/mnt/chroot1');
211
+ expect(hrefTargetKey('$fs:/mnt/aaa/x.mdx', '/mnt/chroot1/h.mdx')).toBe('/mnt/chroot1/mnt/aaa/x.mdx');
212
+ // And the same corpus path by its ordinary spelling resolves identically —
213
+ // the clamp makes $fs:/p and /p the same address, which is the invariant.
214
+ expect(hrefTargetKey('/mnt/aaa/x.mdx', '/mnt/chroot1/h.mdx')).toBe('/mnt/chroot1/mnt/aaa/x.mdx');
215
+ });
216
+
198
217
  it('resolves a folder href the entry-candidate list rejects', () => {
199
218
  expect(hrefKeyCandidates('handbook', HOME)).toEqual([]);
200
219
  expect(hrefTargetKey('handbook', HOME)).toBe('/app/content/handbook');
@@ -275,3 +294,30 @@ describe('link-space parity (LINK_SPACE_FIXTURE, R3-277b)', () => {
275
294
  );
276
295
  });
277
296
  });
297
+
298
+ describe('isDispatched — the R3-184 S2 `$fs:` clamp discriminator', () => {
299
+ afterEach(resetContentRoot);
300
+ // The clamp caller (GroveWiki's LinkSpaceContext) keys the SDK's
301
+ // `bundleChrooted` flag on this: a DISPATCHED corpus renders inside a
302
+ // host-minted chroot, so `$fs:` resolves within the corpus and a federated
303
+ // mount materialised beside it is not nameable from a corpus document; the
304
+ // fork's `$fs:` stays mount-absolute, as shipped. The flag derivation is
305
+ // one line over THIS module's state. The JOIN — this decision reaching the
306
+ // shared resolver on the render path — is pinned by the `$fs:` clamp case in
307
+ // the hrefTargetKey describe above (the two tests are the pair: the decision
308
+ // here, the emission there, and the wire between them asserted once).
309
+ it('fork (own repo): false — the corpus is the engine repo, not a chroot', () => {
310
+ expect(isDispatched()).toBe(false);
311
+ });
312
+
313
+ it('dispatch (a corpus mount): true', () => {
314
+ setContentRoot('/mnt/0a1b2c3d');
315
+ expect(isDispatched()).toBe(true);
316
+ });
317
+
318
+ it('the afterEach resets the root — a later reader sees the fork state', () => {
319
+ // The reset itself is the convention's job (afterEach, above); asserting it
320
+ // keeps the leak-visible property testable rather than assumed.
321
+ expect(isDispatched()).toBe(false);
322
+ });
323
+ });
@@ -186,6 +186,25 @@ export function hrefKeyCandidates(href: string, fromKey: string): string[] {
186
186
  * The two callers ask different questions of the same resolution — "which entry?" and
187
187
  * "which path?" — so the resolution lives here once.
188
188
  */
189
+ // R3-184 S2 (PERSISTENCE_SPEC §8.3) — the `$fs:` clamp ON the render path, the one
190
+ // the spec's dated note names as the missing piece. This file's hrefTargetKey is
191
+ // where a corpus document's link targets actually resolve (grove's own WikiLink
192
+ // override routes here), so the clamp lives HERE: `bundleChrooted: isDispatched()`
193
+ // makes the shared resolver treat `$fs:/p` exactly like `/p` under the bundle root
194
+ // (R3-319), so a dispatched corpus document's `$fs:/mnt/{hash}/…` cannot name a
195
+ // federated mount materialised beside it — the app-level mount point is not a
196
+ // corpus path. The fork (own repo, not a corpus mount) keeps `$fs:`
197
+ // mount-absolute, as shipped. The LinkSpaceContext field in GroveWiki carries the
198
+ // same flag for the SDK's generic component consumers, the day a release that
199
+ // forwards it is pinned.
200
+ const linkSpaceOpts = (fromKey: string) => ({
201
+ currentFile: fromKey,
202
+ // The canonical spelling (mdx-plugins reads bundleRoot-else-corpusRoot; the
203
+ // deprecated corpusRoot opts field stays for older consumers, not for new code).
204
+ bundleRoot: getContentRoot(),
205
+ bundleChrooted: isDispatched(),
206
+ });
207
+
189
208
  export function hrefTargetKey(href: string, fromKey: string): string | null {
190
209
  if (!href) return null;
191
210
  if (/^(https?:|mailto:|tel:|#)/i.test(href)) return null;
@@ -198,7 +217,7 @@ export function hrefTargetKey(href: string, fromKey: string): string | null {
198
217
  // targets resolve against the authoring file; `$fs:` targets resolve
199
218
  // mount-absolute (addressing, never reach — R3-273).
200
219
  if (!path.startsWith('/')) {
201
- const rel = resolveLinkTarget(path, { currentFile: fromKey, corpusRoot: getContentRoot() });
220
+ const rel = resolveLinkTarget(path, linkSpaceOpts(fromKey));
202
221
  if (rel.state !== 'resolved') return null;
203
222
  // Confinement, not tidiness: an href in foreign content is untrusted, and the
204
223
  // result flows into `fs` reads. In the default space anything that lands outside
@@ -221,7 +240,7 @@ export function hrefTargetKey(href: string, fromKey: string): string | null {
221
240
  const legacy = normalizeAbsolute(legacyAnchor + path);
222
241
  if (legacy.startsWith(contentDir())) return legacy;
223
242
  }
224
- const corpusAnchored = resolveLinkTarget(path, { currentFile: fromKey, corpusRoot: getContentRoot() });
243
+ const corpusAnchored = resolveLinkTarget(path, linkSpaceOpts(fromKey));
225
244
  if (corpusAnchored.state === 'resolved' && corpusAnchored.path.startsWith(contentDir())) {
226
245
  return corpusAnchored.path;
227
246
  }