@rarusoft/dendrite-wiki 0.1.0-alpha.0

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 (74) hide show
  1. package/README.md +79 -0
  2. package/dist/api-extractor/extract.js +269 -0
  3. package/dist/api-extractor/language-extractor.js +15 -0
  4. package/dist/api-extractor/python-extractor.js +358 -0
  5. package/dist/api-extractor/render.js +195 -0
  6. package/dist/api-extractor/tree-sitter-extractor.js +1079 -0
  7. package/dist/api-extractor/types.js +11 -0
  8. package/dist/api-extractor/typescript-extractor.js +50 -0
  9. package/dist/api-extractor/walk.js +178 -0
  10. package/dist/api-reference.js +438 -0
  11. package/dist/benchmark-events.js +129 -0
  12. package/dist/benchmark.js +270 -0
  13. package/dist/binder-export.js +381 -0
  14. package/dist/canonical-target.js +168 -0
  15. package/dist/chart-insert.js +377 -0
  16. package/dist/chart-prompts.js +414 -0
  17. package/dist/context-cache.js +98 -0
  18. package/dist/contradicts-shipped-memory.js +232 -0
  19. package/dist/diff-context.js +142 -0
  20. package/dist/doctor.js +220 -0
  21. package/dist/generated-docs.js +219 -0
  22. package/dist/i18n.js +71 -0
  23. package/dist/index.js +49 -0
  24. package/dist/librarian.js +255 -0
  25. package/dist/maintenance-actions.js +244 -0
  26. package/dist/maintenance-inbox.js +842 -0
  27. package/dist/maintenance-runner.js +62 -0
  28. package/dist/page-drift.js +225 -0
  29. package/dist/page-inbox.js +168 -0
  30. package/dist/report-export.js +339 -0
  31. package/dist/review-bridge.js +1386 -0
  32. package/dist/search-index.js +199 -0
  33. package/dist/store.js +1617 -0
  34. package/dist/telemetry-defaults.js +44 -0
  35. package/dist/telemetry-report.js +263 -0
  36. package/dist/telemetry.js +544 -0
  37. package/dist/wiki-synthesis.js +901 -0
  38. package/package.json +35 -0
  39. package/src/api-extractor/extract.ts +333 -0
  40. package/src/api-extractor/language-extractor.ts +37 -0
  41. package/src/api-extractor/python-extractor.ts +380 -0
  42. package/src/api-extractor/render.ts +267 -0
  43. package/src/api-extractor/tree-sitter-extractor.ts +1210 -0
  44. package/src/api-extractor/types.ts +41 -0
  45. package/src/api-extractor/typescript-extractor.ts +56 -0
  46. package/src/api-extractor/walk.ts +209 -0
  47. package/src/api-reference.ts +552 -0
  48. package/src/benchmark-events.ts +216 -0
  49. package/src/benchmark.ts +376 -0
  50. package/src/binder-export.ts +437 -0
  51. package/src/canonical-target.ts +192 -0
  52. package/src/chart-insert.ts +478 -0
  53. package/src/chart-prompts.ts +417 -0
  54. package/src/context-cache.ts +129 -0
  55. package/src/contradicts-shipped-memory.ts +311 -0
  56. package/src/diff-context.ts +187 -0
  57. package/src/doctor.ts +260 -0
  58. package/src/generated-docs.ts +316 -0
  59. package/src/i18n.ts +106 -0
  60. package/src/index.ts +59 -0
  61. package/src/librarian.ts +331 -0
  62. package/src/maintenance-actions.ts +314 -0
  63. package/src/maintenance-inbox.ts +1132 -0
  64. package/src/maintenance-runner.ts +85 -0
  65. package/src/page-drift.ts +292 -0
  66. package/src/page-inbox.ts +254 -0
  67. package/src/report-export.ts +392 -0
  68. package/src/review-bridge.ts +1729 -0
  69. package/src/search-index.ts +266 -0
  70. package/src/store.ts +2171 -0
  71. package/src/telemetry-defaults.ts +50 -0
  72. package/src/telemetry-report.ts +365 -0
  73. package/src/telemetry.ts +757 -0
  74. package/src/wiki-synthesis.ts +1307 -0
@@ -0,0 +1,478 @@
1
+ /**
2
+ * Chart insertion + validation module — M2 of the AI-mermaid-charts roadmap.
3
+ *
4
+ * Single source of truth for adding/updating Mermaid diagrams in wiki pages.
5
+ * Both insertion paths converge here:
6
+ * - Operator-side: the editor's Insert Chart wizard (M5) calls
7
+ * `insertChartIntoPage` via the embedded review-bridge endpoint.
8
+ * - Agent-side: the `wiki_insert_chart` MCP tool (M3) calls the same
9
+ * function directly. Same validation, same anchoring, same write path.
10
+ *
11
+ * Design contracts:
12
+ * - VALIDATE first. Both paths parse `mermaidSource` with `mermaid.parse`
13
+ * before any disk write. If the source is malformed, the call fails with
14
+ * a structured error. Never silently corrupt a page.
15
+ * - ANCHOR by heading, not by line number. Line numbers shift with any
16
+ * edit; headings are stable identifiers. `after-heading` / `before-heading`
17
+ * find the matching `## ...` line, then anchor relative to its section
18
+ * boundary (next sibling heading at the same or higher level).
19
+ * - IDEMPOTENT via stable chart-id markers. Each inserted chart is
20
+ * prefixed by `<!-- chart:<kind>-<hash7> -->` where hash7 is sha256 of
21
+ * the mermaid source truncated to 7 hex chars. Calling `insertChartIntoPage`
22
+ * twice with identical (slug, source, anchor) is a no-op on the second
23
+ * call.
24
+ * - WRITE via writeWikiPage so the same lint, cache invalidation,
25
+ * project-log entry, and benchmark event side-effects fire as any other
26
+ * wiki edit. The benchmark trigger is `wiki_insert_chart` (added to the
27
+ * DendriteBenchmarkEventTrigger union when M3 wires the MCP tool).
28
+ */
29
+ import { createHash } from 'node:crypto';
30
+ import { appendProjectLog, readWikiPage, writeWikiPage } from './store.js';
31
+
32
+ // NOTE on validation strategy (M2): we deliberately do NOT call
33
+ // `mermaid.parse()` here. Mermaid's Node-mode parser depends on DOMPurify
34
+ // in a way that requires a DOM; without one (which is the normal MCP
35
+ // environment), `mermaid.parse({suppressErrors: true})` returns `false` for
36
+ // the vast majority of non-trivial diagrams — including ones that render
37
+ // perfectly in the browser. Calling it would false-reject valid content,
38
+ // which is worse than letting some malformed content through to the
39
+ // browser's render step where errors are visible.
40
+ //
41
+ // Instead, we run a heuristic validator that catches the failure modes we
42
+ // actually see in practice from LLM output:
43
+ // - empty source
44
+ // - prose text (no diagram-type keyword on the first non-empty line)
45
+ // - truncated source (unbalanced brackets/parens/curly braces)
46
+ // - no connections (no arrow operators — almost always a sign the LLM
47
+ // gave up partway through)
48
+ // - script/iframe injection attempts
49
+ //
50
+ // If we ever need stricter parser-level validation, the path forward is
51
+ // to install jsdom and polyfill `global.window` before importing mermaid.
52
+ // That's deferred — the heuristic catches the common failures and the
53
+ // browser renderer catches the rare ones at display time.
54
+
55
+ export type ChartKind = 'flowchart' | 'sequence' | 'state' | 'class' | 'er' | 'gantt' | 'diagram';
56
+
57
+ export type ChartAnchor =
58
+ | { kind: 'after-heading'; heading: string }
59
+ | { kind: 'before-heading'; heading: string }
60
+ | { kind: 'end-of-page' }
61
+ | { kind: 'after-line'; line: number };
62
+
63
+ export interface ChartInsertInput {
64
+ slug: string;
65
+ mermaidSource: string;
66
+ anchor: ChartAnchor;
67
+ chartKind?: ChartKind;
68
+ /** Optional figure caption rendered as italic text under the chart. */
69
+ caption?: string;
70
+ /** When true, validates + computes the new content but does NOT write to disk. */
71
+ dryRun?: boolean;
72
+ /** When set, project-log entry uses this trigger label. Defaults to "agent" */
73
+ authorTag?: 'agent' | 'operator';
74
+ }
75
+
76
+ export interface ChartReplaceInput {
77
+ slug: string;
78
+ chartId: string;
79
+ newSource: string;
80
+ caption?: string;
81
+ dryRun?: boolean;
82
+ authorTag?: 'agent' | 'operator';
83
+ }
84
+
85
+ export interface ChartInsertResult {
86
+ ok: true;
87
+ /** Stable chart ID, e.g. "auto-flowchart-7e9f1a3". Used by replaceChartInPage. */
88
+ chartId: string;
89
+ /** True when the page already contained an identical chart at the requested
90
+ * anchor — no write happened, the existing chart was left in place. */
91
+ noop: boolean;
92
+ /** The full updated page content. Returned even on dryRun. */
93
+ content: string;
94
+ /** The byte offset in the new content where the inserted block begins. */
95
+ insertedAt: number;
96
+ }
97
+
98
+ export interface ValidationOk { ok: true; diagramType: string; }
99
+ export interface ValidationFail { ok: false; error: { message: string; source: string; }; }
100
+ export type ValidationResult = ValidationOk | ValidationFail;
101
+
102
+ const MARKER_PREFIX = 'chart:';
103
+ // Matches the marker comment line we insert before each chart. Captures the
104
+ // chart ID. Single-line; the marker always sits on its own line.
105
+ const MARKER_RE = /^<!--\s*chart:([a-z0-9][a-z0-9-]*)\s*-->\s*$/m;
106
+ // The full chart block: marker line + ```mermaid fence + body + closing fence
107
+ // + optional caption. Captures groups for replacement.
108
+ const CHART_BLOCK_RE = /<!--\s*chart:([a-z0-9][a-z0-9-]*)\s*-->\r?\n```mermaid\r?\n([\s\S]*?)\r?\n```\r?\n?(?:\r?\n\*Figure:\s*([^\n]+)\*\r?\n)?/g;
109
+
110
+ // Diagram-type keywords Mermaid recognizes. Match is case-insensitive on the
111
+ // first non-empty line. List kept in sync with current Mermaid docs as of
112
+ // the package version pinned in package.json.
113
+ const DIAGRAM_KEYWORDS = [
114
+ 'flowchart', 'graph',
115
+ 'sequenceDiagram',
116
+ 'stateDiagram-v2', 'stateDiagram',
117
+ 'classDiagram',
118
+ 'erDiagram',
119
+ 'gantt', 'pie', 'journey',
120
+ 'gitGraph',
121
+ 'mindmap', 'timeline',
122
+ 'quadrantChart',
123
+ 'xychart-beta', 'sankey-beta',
124
+ 'c4Context', 'c4Container', 'c4Component', 'c4Dynamic', 'c4',
125
+ 'requirementDiagram',
126
+ 'block-beta',
127
+ 'packet-beta'
128
+ ];
129
+
130
+ // Arrow-like operators across the diagram types. The check just needs ANY of
131
+ // them present — a chart with zero connections is almost always a truncated
132
+ // LLM output ("flowchart TD" with no body) rather than a deliberate empty
133
+ // diagram. We accept false-negatives on edge cases (e.g., a single-node
134
+ // flowchart) — the cost of a false-negative is the operator regenerating;
135
+ // the cost of accepting truncation is a useless chart in the wiki.
136
+ const CONNECTION_OPERATORS = /-->|---|-\.->|==>|->>|-->>|<<--|--?>|->|<->|~~~|\.\.>|::|<-/;
137
+
138
+ export async function validateMermaidSource(source: string): Promise<ValidationResult> {
139
+ const trimmed = source.trim();
140
+ if (!trimmed) {
141
+ return { ok: false, error: { message: 'Mermaid source is empty.', source } };
142
+ }
143
+
144
+ // 1. Defense-in-depth on top of mermaid's `securityLevel: 'strict'`. If
145
+ // somehow `<script>` or `<iframe>` survives the LLM and our prompt, refuse
146
+ // to write it to a wiki page. The browser renderer would strip them at
147
+ // display time but the source would still be on disk.
148
+ if (/<\s*(script|iframe|object|embed)\b/i.test(trimmed)) {
149
+ return {
150
+ ok: false,
151
+ error: {
152
+ message: 'Mermaid source contains a forbidden HTML tag (<script>, <iframe>, <object>, <embed>).',
153
+ source
154
+ }
155
+ };
156
+ }
157
+
158
+ // 2. First-line diagram-type keyword. Catches "LLM returned a prose
159
+ // paragraph instead of mermaid" — by far the most common failure mode.
160
+ const firstNonEmpty = trimmed.split(/\r?\n/).find((line) => line.trim().length > 0) ?? '';
161
+ const matchedKeyword = DIAGRAM_KEYWORDS.find((k) => new RegExp(`^${k.replace('-', '\\-')}\\b`, 'i').test(firstNonEmpty));
162
+ if (!matchedKeyword) {
163
+ return {
164
+ ok: false,
165
+ error: {
166
+ message: `Mermaid source must start with a diagram-type keyword (flowchart, sequenceDiagram, stateDiagram, classDiagram, erDiagram, gantt, etc.). Got: ${firstNonEmpty.slice(0, 60)}`,
167
+ source
168
+ }
169
+ };
170
+ }
171
+
172
+ // 3. Bracket balance — catches truncated output. Counts `[`, `]`, `{`, `}`,
173
+ // `(`, `)` and requires each opener to match its closer. Strings and code
174
+ // blocks INSIDE Mermaid source are rare enough that we don't try to skip
175
+ // their contents — if a legitimate diagram fails this check it's because
176
+ // it contains a literal unbalanced bracket, which is itself a Mermaid
177
+ // syntax error.
178
+ const balance = countBracketBalance(trimmed);
179
+ if (balance.square !== 0 || balance.curly !== 0 || balance.paren !== 0) {
180
+ return {
181
+ ok: false,
182
+ error: {
183
+ message: `Mermaid source has unbalanced brackets (square: ${balance.square}, curly: ${balance.curly}, paren: ${balance.paren}). Likely truncated.`,
184
+ source
185
+ }
186
+ };
187
+ }
188
+
189
+ // 4. Connection check — must have at least one arrow operator UNLESS the
190
+ // diagram type is one that legitimately has no connections (gantt, pie,
191
+ // journey, timeline, quadrantChart, xychart, sankey, mindmap, gitGraph).
192
+ // For flowchart / sequence / state / class / er, no connections almost
193
+ // certainly means truncated output.
194
+ const NEEDS_CONNECTIONS = ['flowchart', 'graph', 'sequenceDiagram', 'stateDiagram-v2', 'stateDiagram', 'classDiagram', 'erDiagram'];
195
+ if (NEEDS_CONNECTIONS.includes(matchedKeyword) && !CONNECTION_OPERATORS.test(trimmed)) {
196
+ return {
197
+ ok: false,
198
+ error: {
199
+ message: `Mermaid ${matchedKeyword} has no arrow connections. Likely truncated output.`,
200
+ source
201
+ }
202
+ };
203
+ }
204
+
205
+ return { ok: true, diagramType: keywordToDiagramType(matchedKeyword) };
206
+ }
207
+
208
+ interface BracketBalance { square: number; curly: number; paren: number; }
209
+ function countBracketBalance(source: string): BracketBalance {
210
+ let square = 0;
211
+ let curly = 0;
212
+ let paren = 0;
213
+ for (const ch of source) {
214
+ if (ch === '[') square++;
215
+ else if (ch === ']') square--;
216
+ else if (ch === '{') curly++;
217
+ else if (ch === '}') curly--;
218
+ else if (ch === '(') paren++;
219
+ else if (ch === ')') paren--;
220
+ }
221
+ return { square, curly, paren };
222
+ }
223
+
224
+ function keywordToDiagramType(keyword: string): string {
225
+ // Normalize the keyword to a stable diagram-type label for downstream
226
+ // consumers (matches the names mermaid.parse() returns where possible).
227
+ if (keyword === 'flowchart' || keyword === 'graph') return 'flowchart-v2';
228
+ if (keyword === 'stateDiagram' || keyword === 'stateDiagram-v2') return 'stateDiagram-v2';
229
+ return keyword;
230
+ }
231
+
232
+ /**
233
+ * Pure string-transformation core: given the existing page content and the
234
+ * insertion request, returns the new content + chart ID + noop flag. No
235
+ * file IO, no logging, no benchmark events. Used by both the file-system
236
+ * wrapper below AND directly by tests (skips the fixture-cwd dance).
237
+ */
238
+ export async function computeChartInsertion(
239
+ existingContent: string,
240
+ input: Omit<ChartInsertInput, 'slug' | 'dryRun' | 'authorTag'>
241
+ ): Promise<{ chartId: string; chartKind: ChartKind; content: string; insertedAt: number; noop: boolean }> {
242
+ const validation = await validateMermaidSource(input.mermaidSource);
243
+ if (!validation.ok) {
244
+ throw new ChartValidationError(validation.error.message, validation.error.source);
245
+ }
246
+ const chartKind: ChartKind = input.chartKind ?? inferChartKindFromDiagramType(validation.diagramType);
247
+ const chartId = computeChartId(chartKind, input.mermaidSource);
248
+
249
+ // Idempotency: if the same chartId is already in the page, no-op.
250
+ const existingIdMatch = new RegExp(`<!--\\s*chart:${chartId}\\s*-->`).test(existingContent);
251
+ if (existingIdMatch) {
252
+ return { chartId, chartKind, content: existingContent, insertedAt: existingContent.indexOf(`<!-- chart:${chartId}`), noop: true };
253
+ }
254
+
255
+ const block = renderChartBlock(chartId, input.mermaidSource, input.caption);
256
+ const { content, insertedAt } = applyAnchor(existingContent, block, input.anchor);
257
+ return { chartId, chartKind, content, insertedAt, noop: false };
258
+ }
259
+
260
+ /**
261
+ * Pure replacement core. Same shape as computeChartInsertion.
262
+ */
263
+ export async function computeChartReplacement(
264
+ existingContent: string,
265
+ input: Omit<ChartReplaceInput, 'slug' | 'dryRun' | 'authorTag'>
266
+ ): Promise<{ chartId: string; content: string; insertedAt: number; noop: boolean }> {
267
+ const validation = await validateMermaidSource(input.newSource);
268
+ if (!validation.ok) {
269
+ throw new ChartValidationError(validation.error.message, validation.error.source);
270
+ }
271
+ const found = findChartBlockById(existingContent, input.chartId);
272
+ if (!found) {
273
+ throw new ChartNotFoundError(`No chart with id "${input.chartId}" in page.`);
274
+ }
275
+ const originalKind = parseKindFromChartId(input.chartId);
276
+ const newChartId = computeChartId(originalKind, input.newSource);
277
+ if (newChartId === input.chartId) {
278
+ return { chartId: input.chartId, content: existingContent, insertedAt: found.start, noop: true };
279
+ }
280
+ const block = renderChartBlock(newChartId, input.newSource, input.caption);
281
+ const content = existingContent.slice(0, found.start) + block + existingContent.slice(found.end);
282
+ return { chartId: newChartId, content, insertedAt: found.start, noop: false };
283
+ }
284
+
285
+ /**
286
+ * File-system wrapper around computeChartInsertion. Reads the page, computes
287
+ * the new content, writes it back (unless dryRun), and appends a project-log
288
+ * entry. The MCP tool (M3) and the editor wizard (M5) both call this.
289
+ */
290
+ export async function insertChartIntoPage(input: ChartInsertInput): Promise<ChartInsertResult> {
291
+ const existing = await readWikiPage(input.slug);
292
+ const result = await computeChartInsertion(existing, input);
293
+ if (!result.noop && !input.dryRun) {
294
+ await writeWikiPage(input.slug, result.content);
295
+ const author = input.authorTag === 'operator' ? 'in-browser editor' : 'agent';
296
+ await appendProjectLog(`Inserted ${result.chartKind} chart \`${result.chartId}\` into \`${input.slug}\` via ${author} (chart-insert).`);
297
+ }
298
+ return { ok: true, chartId: result.chartId, noop: result.noop, content: result.content, insertedAt: result.insertedAt };
299
+ }
300
+
301
+ export async function replaceChartInPage(input: ChartReplaceInput): Promise<ChartInsertResult> {
302
+ const existing = await readWikiPage(input.slug);
303
+ const result = await computeChartReplacement(existing, input);
304
+ if (!result.noop && !input.dryRun) {
305
+ await writeWikiPage(input.slug, result.content);
306
+ const author = input.authorTag === 'operator' ? 'in-browser editor' : 'agent';
307
+ await appendProjectLog(`Replaced chart \`${input.chartId}\` → \`${result.chartId}\` in \`${input.slug}\` via ${author} (chart-insert).`);
308
+ }
309
+ return { ok: true, chartId: result.chartId, noop: result.noop, content: result.content, insertedAt: result.insertedAt };
310
+ }
311
+
312
+ // -------------------------- helpers --------------------------------------
313
+
314
+ export function computeChartId(kind: ChartKind, source: string): string {
315
+ const hash = createHash('sha256').update(source.trim()).digest('hex').slice(0, 7);
316
+ return `auto-${kind}-${hash}`;
317
+ }
318
+
319
+ function parseKindFromChartId(chartId: string): ChartKind {
320
+ // chartId is `auto-<kind>-<hash>` — pull the kind from the middle segment.
321
+ const match = chartId.match(/^auto-([a-z]+)-[0-9a-f]+$/i);
322
+ const candidate = match?.[1] as ChartKind | undefined;
323
+ const valid: ChartKind[] = ['flowchart', 'sequence', 'state', 'class', 'er', 'gantt', 'diagram'];
324
+ return candidate && valid.includes(candidate) ? candidate : 'diagram';
325
+ }
326
+
327
+ function inferChartKindFromDiagramType(diagramType: string): ChartKind {
328
+ // Mermaid's parse() returns names like 'flowchart-v2', 'sequence', 'stateDiagram', etc.
329
+ if (/^flowchart/i.test(diagramType) || /^graph/i.test(diagramType)) return 'flowchart';
330
+ if (/^sequence/i.test(diagramType)) return 'sequence';
331
+ if (/^state/i.test(diagramType)) return 'state';
332
+ if (/^class/i.test(diagramType)) return 'class';
333
+ if (/^er/i.test(diagramType)) return 'er';
334
+ if (/^gantt/i.test(diagramType)) return 'gantt';
335
+ return 'diagram';
336
+ }
337
+
338
+ function renderChartBlock(chartId: string, source: string, caption?: string): string {
339
+ const trimmed = source.trim();
340
+ const captionLine = caption?.trim() ? `\n*Figure: ${caption.trim()}*\n` : '';
341
+ return `<!-- chart:${chartId} -->\n\`\`\`mermaid\n${trimmed}\n\`\`\`\n${captionLine}`;
342
+ }
343
+
344
+ interface ChartBlockMatch {
345
+ chartId: string;
346
+ start: number;
347
+ end: number;
348
+ source: string;
349
+ caption?: string;
350
+ }
351
+
352
+ function findChartBlockById(content: string, chartId: string): ChartBlockMatch | null {
353
+ CHART_BLOCK_RE.lastIndex = 0;
354
+ let match: RegExpExecArray | null;
355
+ while ((match = CHART_BLOCK_RE.exec(content)) !== null) {
356
+ if (match[1] === chartId) {
357
+ return {
358
+ chartId: match[1],
359
+ start: match.index,
360
+ end: match.index + match[0].length,
361
+ source: match[2],
362
+ caption: match[3]
363
+ };
364
+ }
365
+ }
366
+ return null;
367
+ }
368
+
369
+ function applyAnchor(content: string, block: string, anchor: ChartAnchor): { content: string; insertedAt: number } {
370
+ const lines = content.split(/\r?\n/);
371
+ const eol = content.includes('\r\n') ? '\r\n' : '\n';
372
+
373
+ switch (anchor.kind) {
374
+ case 'after-heading': {
375
+ const idx = findHeadingLine(lines, anchor.heading);
376
+ if (idx === -1) {
377
+ throw new AnchorNotFoundError(`Heading "${anchor.heading}" not found in page.`);
378
+ }
379
+ const sectionEnd = findSectionEnd(lines, idx);
380
+ const insertLineIdx = sectionEnd;
381
+ return spliceAtLine(lines, insertLineIdx, block, eol);
382
+ }
383
+ case 'before-heading': {
384
+ const idx = findHeadingLine(lines, anchor.heading);
385
+ if (idx === -1) {
386
+ throw new AnchorNotFoundError(`Heading "${anchor.heading}" not found in page.`);
387
+ }
388
+ return spliceAtLine(lines, idx, block, eol);
389
+ }
390
+ case 'end-of-page': {
391
+ return spliceAtLine(lines, lines.length, block, eol);
392
+ }
393
+ case 'after-line': {
394
+ if (anchor.line < 1 || anchor.line > lines.length) {
395
+ throw new AnchorNotFoundError(`Line ${anchor.line} is out of range (page has ${lines.length} lines).`);
396
+ }
397
+ return spliceAtLine(lines, anchor.line, block, eol);
398
+ }
399
+ }
400
+ }
401
+
402
+ function findHeadingLine(lines: string[], heading: string): number {
403
+ const target = heading.trim().toLowerCase();
404
+ // Match `^#{1,6}\s+<heading>\s*$` case-insensitively. Anchored to the whole
405
+ // line so we don't false-match a heading mention inside a code block — that
406
+ // would be a bug worth catching, but for now we accept it as a known
407
+ // limitation and call it out in the roadmap's open questions.
408
+ for (let i = 0; i < lines.length; i++) {
409
+ const match = lines[i].match(/^(#{1,6})\s+(.+?)\s*$/);
410
+ if (match && match[2].trim().toLowerCase() === target) {
411
+ return i;
412
+ }
413
+ }
414
+ return -1;
415
+ }
416
+
417
+ function headingLevel(line: string): number {
418
+ const match = line.match(/^(#{1,6})\s/);
419
+ return match ? match[1].length : 0;
420
+ }
421
+
422
+ function findSectionEnd(lines: string[], headingIdx: number): number {
423
+ // A section ends just before the next heading at the SAME OR HIGHER level
424
+ // (lower number = higher level). H2's section ends before the next H1 or H2.
425
+ // Returns the line index where to insert (== first line AFTER the section).
426
+ const startLevel = headingLevel(lines[headingIdx]);
427
+ if (startLevel === 0) return lines.length;
428
+ for (let i = headingIdx + 1; i < lines.length; i++) {
429
+ const lvl = headingLevel(lines[i]);
430
+ if (lvl > 0 && lvl <= startLevel) {
431
+ return i;
432
+ }
433
+ }
434
+ return lines.length;
435
+ }
436
+
437
+ function spliceAtLine(lines: string[], lineIdx: number, block: string, eol: '\n' | '\r\n'): { content: string; insertedAt: number } {
438
+ const before = lines.slice(0, lineIdx).join(eol);
439
+ const after = lines.slice(lineIdx).join(eol);
440
+ // Always sandwich the block with blank lines so it doesn't fuse into
441
+ // surrounding content (especially next to a heading).
442
+ const parts: string[] = [];
443
+ if (before.length > 0) parts.push(before);
444
+ parts.push(''); // blank line separator
445
+ parts.push(block.trimEnd());
446
+ parts.push(''); // blank line separator
447
+ if (after.length > 0) parts.push(after);
448
+ const content = parts.join(eol);
449
+ // The block starts just after the leading separator(s). Compute by finding
450
+ // the marker comment in the new content.
451
+ const insertedAt = content.indexOf(`<!-- chart:`);
452
+ return { content, insertedAt };
453
+ }
454
+
455
+ // -------------------------- typed errors ---------------------------------
456
+
457
+ export class ChartValidationError extends Error {
458
+ readonly source: string;
459
+ constructor(message: string, source: string) {
460
+ super(message);
461
+ this.name = 'ChartValidationError';
462
+ this.source = source;
463
+ }
464
+ }
465
+
466
+ export class AnchorNotFoundError extends Error {
467
+ constructor(message: string) {
468
+ super(message);
469
+ this.name = 'AnchorNotFoundError';
470
+ }
471
+ }
472
+
473
+ export class ChartNotFoundError extends Error {
474
+ constructor(message: string) {
475
+ super(message);
476
+ this.name = 'ChartNotFoundError';
477
+ }
478
+ }