@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,377 @@
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
+ const MARKER_PREFIX = 'chart:';
32
+ // Matches the marker comment line we insert before each chart. Captures the
33
+ // chart ID. Single-line; the marker always sits on its own line.
34
+ const MARKER_RE = /^<!--\s*chart:([a-z0-9][a-z0-9-]*)\s*-->\s*$/m;
35
+ // The full chart block: marker line + ```mermaid fence + body + closing fence
36
+ // + optional caption. Captures groups for replacement.
37
+ 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;
38
+ // Diagram-type keywords Mermaid recognizes. Match is case-insensitive on the
39
+ // first non-empty line. List kept in sync with current Mermaid docs as of
40
+ // the package version pinned in package.json.
41
+ const DIAGRAM_KEYWORDS = [
42
+ 'flowchart', 'graph',
43
+ 'sequenceDiagram',
44
+ 'stateDiagram-v2', 'stateDiagram',
45
+ 'classDiagram',
46
+ 'erDiagram',
47
+ 'gantt', 'pie', 'journey',
48
+ 'gitGraph',
49
+ 'mindmap', 'timeline',
50
+ 'quadrantChart',
51
+ 'xychart-beta', 'sankey-beta',
52
+ 'c4Context', 'c4Container', 'c4Component', 'c4Dynamic', 'c4',
53
+ 'requirementDiagram',
54
+ 'block-beta',
55
+ 'packet-beta'
56
+ ];
57
+ // Arrow-like operators across the diagram types. The check just needs ANY of
58
+ // them present — a chart with zero connections is almost always a truncated
59
+ // LLM output ("flowchart TD" with no body) rather than a deliberate empty
60
+ // diagram. We accept false-negatives on edge cases (e.g., a single-node
61
+ // flowchart) — the cost of a false-negative is the operator regenerating;
62
+ // the cost of accepting truncation is a useless chart in the wiki.
63
+ const CONNECTION_OPERATORS = /-->|---|-\.->|==>|->>|-->>|<<--|--?>|->|<->|~~~|\.\.>|::|<-/;
64
+ export async function validateMermaidSource(source) {
65
+ const trimmed = source.trim();
66
+ if (!trimmed) {
67
+ return { ok: false, error: { message: 'Mermaid source is empty.', source } };
68
+ }
69
+ // 1. Defense-in-depth on top of mermaid's `securityLevel: 'strict'`. If
70
+ // somehow `<script>` or `<iframe>` survives the LLM and our prompt, refuse
71
+ // to write it to a wiki page. The browser renderer would strip them at
72
+ // display time but the source would still be on disk.
73
+ if (/<\s*(script|iframe|object|embed)\b/i.test(trimmed)) {
74
+ return {
75
+ ok: false,
76
+ error: {
77
+ message: 'Mermaid source contains a forbidden HTML tag (<script>, <iframe>, <object>, <embed>).',
78
+ source
79
+ }
80
+ };
81
+ }
82
+ // 2. First-line diagram-type keyword. Catches "LLM returned a prose
83
+ // paragraph instead of mermaid" — by far the most common failure mode.
84
+ const firstNonEmpty = trimmed.split(/\r?\n/).find((line) => line.trim().length > 0) ?? '';
85
+ const matchedKeyword = DIAGRAM_KEYWORDS.find((k) => new RegExp(`^${k.replace('-', '\\-')}\\b`, 'i').test(firstNonEmpty));
86
+ if (!matchedKeyword) {
87
+ return {
88
+ ok: false,
89
+ error: {
90
+ message: `Mermaid source must start with a diagram-type keyword (flowchart, sequenceDiagram, stateDiagram, classDiagram, erDiagram, gantt, etc.). Got: ${firstNonEmpty.slice(0, 60)}`,
91
+ source
92
+ }
93
+ };
94
+ }
95
+ // 3. Bracket balance — catches truncated output. Counts `[`, `]`, `{`, `}`,
96
+ // `(`, `)` and requires each opener to match its closer. Strings and code
97
+ // blocks INSIDE Mermaid source are rare enough that we don't try to skip
98
+ // their contents — if a legitimate diagram fails this check it's because
99
+ // it contains a literal unbalanced bracket, which is itself a Mermaid
100
+ // syntax error.
101
+ const balance = countBracketBalance(trimmed);
102
+ if (balance.square !== 0 || balance.curly !== 0 || balance.paren !== 0) {
103
+ return {
104
+ ok: false,
105
+ error: {
106
+ message: `Mermaid source has unbalanced brackets (square: ${balance.square}, curly: ${balance.curly}, paren: ${balance.paren}). Likely truncated.`,
107
+ source
108
+ }
109
+ };
110
+ }
111
+ // 4. Connection check — must have at least one arrow operator UNLESS the
112
+ // diagram type is one that legitimately has no connections (gantt, pie,
113
+ // journey, timeline, quadrantChart, xychart, sankey, mindmap, gitGraph).
114
+ // For flowchart / sequence / state / class / er, no connections almost
115
+ // certainly means truncated output.
116
+ const NEEDS_CONNECTIONS = ['flowchart', 'graph', 'sequenceDiagram', 'stateDiagram-v2', 'stateDiagram', 'classDiagram', 'erDiagram'];
117
+ if (NEEDS_CONNECTIONS.includes(matchedKeyword) && !CONNECTION_OPERATORS.test(trimmed)) {
118
+ return {
119
+ ok: false,
120
+ error: {
121
+ message: `Mermaid ${matchedKeyword} has no arrow connections. Likely truncated output.`,
122
+ source
123
+ }
124
+ };
125
+ }
126
+ return { ok: true, diagramType: keywordToDiagramType(matchedKeyword) };
127
+ }
128
+ function countBracketBalance(source) {
129
+ let square = 0;
130
+ let curly = 0;
131
+ let paren = 0;
132
+ for (const ch of source) {
133
+ if (ch === '[')
134
+ square++;
135
+ else if (ch === ']')
136
+ square--;
137
+ else if (ch === '{')
138
+ curly++;
139
+ else if (ch === '}')
140
+ curly--;
141
+ else if (ch === '(')
142
+ paren++;
143
+ else if (ch === ')')
144
+ paren--;
145
+ }
146
+ return { square, curly, paren };
147
+ }
148
+ function keywordToDiagramType(keyword) {
149
+ // Normalize the keyword to a stable diagram-type label for downstream
150
+ // consumers (matches the names mermaid.parse() returns where possible).
151
+ if (keyword === 'flowchart' || keyword === 'graph')
152
+ return 'flowchart-v2';
153
+ if (keyword === 'stateDiagram' || keyword === 'stateDiagram-v2')
154
+ return 'stateDiagram-v2';
155
+ return keyword;
156
+ }
157
+ /**
158
+ * Pure string-transformation core: given the existing page content and the
159
+ * insertion request, returns the new content + chart ID + noop flag. No
160
+ * file IO, no logging, no benchmark events. Used by both the file-system
161
+ * wrapper below AND directly by tests (skips the fixture-cwd dance).
162
+ */
163
+ export async function computeChartInsertion(existingContent, input) {
164
+ const validation = await validateMermaidSource(input.mermaidSource);
165
+ if (!validation.ok) {
166
+ throw new ChartValidationError(validation.error.message, validation.error.source);
167
+ }
168
+ const chartKind = input.chartKind ?? inferChartKindFromDiagramType(validation.diagramType);
169
+ const chartId = computeChartId(chartKind, input.mermaidSource);
170
+ // Idempotency: if the same chartId is already in the page, no-op.
171
+ const existingIdMatch = new RegExp(`<!--\\s*chart:${chartId}\\s*-->`).test(existingContent);
172
+ if (existingIdMatch) {
173
+ return { chartId, chartKind, content: existingContent, insertedAt: existingContent.indexOf(`<!-- chart:${chartId}`), noop: true };
174
+ }
175
+ const block = renderChartBlock(chartId, input.mermaidSource, input.caption);
176
+ const { content, insertedAt } = applyAnchor(existingContent, block, input.anchor);
177
+ return { chartId, chartKind, content, insertedAt, noop: false };
178
+ }
179
+ /**
180
+ * Pure replacement core. Same shape as computeChartInsertion.
181
+ */
182
+ export async function computeChartReplacement(existingContent, input) {
183
+ const validation = await validateMermaidSource(input.newSource);
184
+ if (!validation.ok) {
185
+ throw new ChartValidationError(validation.error.message, validation.error.source);
186
+ }
187
+ const found = findChartBlockById(existingContent, input.chartId);
188
+ if (!found) {
189
+ throw new ChartNotFoundError(`No chart with id "${input.chartId}" in page.`);
190
+ }
191
+ const originalKind = parseKindFromChartId(input.chartId);
192
+ const newChartId = computeChartId(originalKind, input.newSource);
193
+ if (newChartId === input.chartId) {
194
+ return { chartId: input.chartId, content: existingContent, insertedAt: found.start, noop: true };
195
+ }
196
+ const block = renderChartBlock(newChartId, input.newSource, input.caption);
197
+ const content = existingContent.slice(0, found.start) + block + existingContent.slice(found.end);
198
+ return { chartId: newChartId, content, insertedAt: found.start, noop: false };
199
+ }
200
+ /**
201
+ * File-system wrapper around computeChartInsertion. Reads the page, computes
202
+ * the new content, writes it back (unless dryRun), and appends a project-log
203
+ * entry. The MCP tool (M3) and the editor wizard (M5) both call this.
204
+ */
205
+ export async function insertChartIntoPage(input) {
206
+ const existing = await readWikiPage(input.slug);
207
+ const result = await computeChartInsertion(existing, input);
208
+ if (!result.noop && !input.dryRun) {
209
+ await writeWikiPage(input.slug, result.content);
210
+ const author = input.authorTag === 'operator' ? 'in-browser editor' : 'agent';
211
+ await appendProjectLog(`Inserted ${result.chartKind} chart \`${result.chartId}\` into \`${input.slug}\` via ${author} (chart-insert).`);
212
+ }
213
+ return { ok: true, chartId: result.chartId, noop: result.noop, content: result.content, insertedAt: result.insertedAt };
214
+ }
215
+ export async function replaceChartInPage(input) {
216
+ const existing = await readWikiPage(input.slug);
217
+ const result = await computeChartReplacement(existing, input);
218
+ if (!result.noop && !input.dryRun) {
219
+ await writeWikiPage(input.slug, result.content);
220
+ const author = input.authorTag === 'operator' ? 'in-browser editor' : 'agent';
221
+ await appendProjectLog(`Replaced chart \`${input.chartId}\` → \`${result.chartId}\` in \`${input.slug}\` via ${author} (chart-insert).`);
222
+ }
223
+ return { ok: true, chartId: result.chartId, noop: result.noop, content: result.content, insertedAt: result.insertedAt };
224
+ }
225
+ // -------------------------- helpers --------------------------------------
226
+ export function computeChartId(kind, source) {
227
+ const hash = createHash('sha256').update(source.trim()).digest('hex').slice(0, 7);
228
+ return `auto-${kind}-${hash}`;
229
+ }
230
+ function parseKindFromChartId(chartId) {
231
+ // chartId is `auto-<kind>-<hash>` — pull the kind from the middle segment.
232
+ const match = chartId.match(/^auto-([a-z]+)-[0-9a-f]+$/i);
233
+ const candidate = match?.[1];
234
+ const valid = ['flowchart', 'sequence', 'state', 'class', 'er', 'gantt', 'diagram'];
235
+ return candidate && valid.includes(candidate) ? candidate : 'diagram';
236
+ }
237
+ function inferChartKindFromDiagramType(diagramType) {
238
+ // Mermaid's parse() returns names like 'flowchart-v2', 'sequence', 'stateDiagram', etc.
239
+ if (/^flowchart/i.test(diagramType) || /^graph/i.test(diagramType))
240
+ return 'flowchart';
241
+ if (/^sequence/i.test(diagramType))
242
+ return 'sequence';
243
+ if (/^state/i.test(diagramType))
244
+ return 'state';
245
+ if (/^class/i.test(diagramType))
246
+ return 'class';
247
+ if (/^er/i.test(diagramType))
248
+ return 'er';
249
+ if (/^gantt/i.test(diagramType))
250
+ return 'gantt';
251
+ return 'diagram';
252
+ }
253
+ function renderChartBlock(chartId, source, caption) {
254
+ const trimmed = source.trim();
255
+ const captionLine = caption?.trim() ? `\n*Figure: ${caption.trim()}*\n` : '';
256
+ return `<!-- chart:${chartId} -->\n\`\`\`mermaid\n${trimmed}\n\`\`\`\n${captionLine}`;
257
+ }
258
+ function findChartBlockById(content, chartId) {
259
+ CHART_BLOCK_RE.lastIndex = 0;
260
+ let match;
261
+ while ((match = CHART_BLOCK_RE.exec(content)) !== null) {
262
+ if (match[1] === chartId) {
263
+ return {
264
+ chartId: match[1],
265
+ start: match.index,
266
+ end: match.index + match[0].length,
267
+ source: match[2],
268
+ caption: match[3]
269
+ };
270
+ }
271
+ }
272
+ return null;
273
+ }
274
+ function applyAnchor(content, block, anchor) {
275
+ const lines = content.split(/\r?\n/);
276
+ const eol = content.includes('\r\n') ? '\r\n' : '\n';
277
+ switch (anchor.kind) {
278
+ case 'after-heading': {
279
+ const idx = findHeadingLine(lines, anchor.heading);
280
+ if (idx === -1) {
281
+ throw new AnchorNotFoundError(`Heading "${anchor.heading}" not found in page.`);
282
+ }
283
+ const sectionEnd = findSectionEnd(lines, idx);
284
+ const insertLineIdx = sectionEnd;
285
+ return spliceAtLine(lines, insertLineIdx, block, eol);
286
+ }
287
+ case 'before-heading': {
288
+ const idx = findHeadingLine(lines, anchor.heading);
289
+ if (idx === -1) {
290
+ throw new AnchorNotFoundError(`Heading "${anchor.heading}" not found in page.`);
291
+ }
292
+ return spliceAtLine(lines, idx, block, eol);
293
+ }
294
+ case 'end-of-page': {
295
+ return spliceAtLine(lines, lines.length, block, eol);
296
+ }
297
+ case 'after-line': {
298
+ if (anchor.line < 1 || anchor.line > lines.length) {
299
+ throw new AnchorNotFoundError(`Line ${anchor.line} is out of range (page has ${lines.length} lines).`);
300
+ }
301
+ return spliceAtLine(lines, anchor.line, block, eol);
302
+ }
303
+ }
304
+ }
305
+ function findHeadingLine(lines, heading) {
306
+ const target = heading.trim().toLowerCase();
307
+ // Match `^#{1,6}\s+<heading>\s*$` case-insensitively. Anchored to the whole
308
+ // line so we don't false-match a heading mention inside a code block — that
309
+ // would be a bug worth catching, but for now we accept it as a known
310
+ // limitation and call it out in the roadmap's open questions.
311
+ for (let i = 0; i < lines.length; i++) {
312
+ const match = lines[i].match(/^(#{1,6})\s+(.+?)\s*$/);
313
+ if (match && match[2].trim().toLowerCase() === target) {
314
+ return i;
315
+ }
316
+ }
317
+ return -1;
318
+ }
319
+ function headingLevel(line) {
320
+ const match = line.match(/^(#{1,6})\s/);
321
+ return match ? match[1].length : 0;
322
+ }
323
+ function findSectionEnd(lines, headingIdx) {
324
+ // A section ends just before the next heading at the SAME OR HIGHER level
325
+ // (lower number = higher level). H2's section ends before the next H1 or H2.
326
+ // Returns the line index where to insert (== first line AFTER the section).
327
+ const startLevel = headingLevel(lines[headingIdx]);
328
+ if (startLevel === 0)
329
+ return lines.length;
330
+ for (let i = headingIdx + 1; i < lines.length; i++) {
331
+ const lvl = headingLevel(lines[i]);
332
+ if (lvl > 0 && lvl <= startLevel) {
333
+ return i;
334
+ }
335
+ }
336
+ return lines.length;
337
+ }
338
+ function spliceAtLine(lines, lineIdx, block, eol) {
339
+ const before = lines.slice(0, lineIdx).join(eol);
340
+ const after = lines.slice(lineIdx).join(eol);
341
+ // Always sandwich the block with blank lines so it doesn't fuse into
342
+ // surrounding content (especially next to a heading).
343
+ const parts = [];
344
+ if (before.length > 0)
345
+ parts.push(before);
346
+ parts.push(''); // blank line separator
347
+ parts.push(block.trimEnd());
348
+ parts.push(''); // blank line separator
349
+ if (after.length > 0)
350
+ parts.push(after);
351
+ const content = parts.join(eol);
352
+ // The block starts just after the leading separator(s). Compute by finding
353
+ // the marker comment in the new content.
354
+ const insertedAt = content.indexOf(`<!-- chart:`);
355
+ return { content, insertedAt };
356
+ }
357
+ // -------------------------- typed errors ---------------------------------
358
+ export class ChartValidationError extends Error {
359
+ source;
360
+ constructor(message, source) {
361
+ super(message);
362
+ this.name = 'ChartValidationError';
363
+ this.source = source;
364
+ }
365
+ }
366
+ export class AnchorNotFoundError extends Error {
367
+ constructor(message) {
368
+ super(message);
369
+ this.name = 'AnchorNotFoundError';
370
+ }
371
+ }
372
+ export class ChartNotFoundError extends Error {
373
+ constructor(message) {
374
+ super(message);
375
+ this.name = 'ChartNotFoundError';
376
+ }
377
+ }