@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,339 @@
1
+ /**
2
+ * Self-contained HTML benchmark report exporter.
3
+ *
4
+ * Reads the benchmark history JSON and renders a single self-contained HTML file with
5
+ * inlined trend charts (top-1 hit rate, MRR, page counts, lint findings, etc.). No
6
+ * external assets or runtime dependencies — the file opens cleanly in any browser and is
7
+ * the natural artifact to attach to a PR, share with a stakeholder, or print to PDF.
8
+ *
9
+ * Driven by `dendrite-wiki report:export [--output path] [--title text]`. When no
10
+ * benchmark history exists yet, the exporter still produces a valid empty-state report
11
+ * with instructions for capturing the first snapshot.
12
+ */
13
+ import { promises as fs } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { readBenchmarkHistory } from './benchmark.js';
16
+ const DEFAULT_TITLE = 'Dendrite Wiki MCP — Project Report';
17
+ export async function writeBenchmarkReportHtml(options = {}) {
18
+ const root = path.resolve(options.root ?? process.cwd());
19
+ const outputPath = path.resolve(options.outputPath ?? path.join(root, 'docs', 'public', 'benchmark-report.html'));
20
+ const title = options.reportTitle ?? DEFAULT_TITLE;
21
+ const history = await readBenchmarkHistory(root);
22
+ const html = renderReportHtml(history, { title });
23
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
24
+ await fs.writeFile(outputPath, html, 'utf8');
25
+ const stat = await fs.stat(outputPath);
26
+ return {
27
+ outputPath,
28
+ snapshotCount: history.snapshots.length,
29
+ bytesWritten: stat.size,
30
+ hasData: history.snapshots.length > 0
31
+ };
32
+ }
33
+ export function renderReportHtml(history, context) {
34
+ const baseline = history.snapshots[0] ?? null;
35
+ const latest = history.latest && history.latest.timestamp ? history.latest : history.snapshots.at(-1) ?? null;
36
+ const generatedAt = new Date().toISOString();
37
+ const summary = buildHeroSummary(baseline, latest);
38
+ const metricCards = renderMetricCards(baseline, latest);
39
+ const trendChart = renderTrendChart(history.snapshots);
40
+ const recallPanel = renderRecallPanel(baseline, latest);
41
+ const snapshotsTable = renderSnapshotsTable(history.snapshots);
42
+ const emptyState = history.snapshots.length === 0
43
+ ? '<section class="empty-state"><p>No benchmark snapshots yet. Run <code>npx dendrite-wiki benchmark:snapshot</code> to capture one, then re-export.</p></section>'
44
+ : '';
45
+ return [
46
+ '<!doctype html>',
47
+ '<html lang="en">',
48
+ '<head>',
49
+ '<meta charset="utf-8">',
50
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
51
+ `<title>${escapeHtml(context.title)}</title>`,
52
+ `<style>${REPORT_CSS}</style>`,
53
+ '</head>',
54
+ '<body>',
55
+ '<main class="report">',
56
+ '<header class="hero">',
57
+ `<h1>${escapeHtml(context.title)}</h1>`,
58
+ `<p class="meta">Generated ${escapeHtml(generatedAt)}${latest?.git?.commit ? ` · Commit <code>${escapeHtml(latest.git.commit)}</code>` : ''}${latest?.git?.branch ? ` · Branch <code>${escapeHtml(latest.git.branch)}</code>` : ''}</p>`,
59
+ `<p class="summary">${escapeHtml(summary)}</p>`,
60
+ '</header>',
61
+ emptyState,
62
+ metricCards,
63
+ trendChart,
64
+ recallPanel,
65
+ snapshotsTable,
66
+ '<footer class="footer">',
67
+ '<p>Generated by <a href="https://github.com/mfillalan/dendrite-wiki-mcp">Dendrite Wiki MCP</a>. Local-first project memory and living wiki for AI coding agents.</p>',
68
+ `<p class="footer-meta">Snapshots in this report: ${history.snapshots.length}</p>`,
69
+ '</footer>',
70
+ '</main>',
71
+ '</body>',
72
+ '</html>',
73
+ ''
74
+ ].join('\n');
75
+ }
76
+ function buildHeroSummary(baseline, latest) {
77
+ if (!baseline || !latest) {
78
+ return 'No benchmark history yet — capture a snapshot to begin tracking project documentation health.';
79
+ }
80
+ if (baseline.timestamp === latest.timestamp) {
81
+ return 'A baseline snapshot has been captured. Run more snapshots after meaningful sessions to start a trend.';
82
+ }
83
+ const baselineDebt = baseline.metrics.lintFindingCount + baseline.metrics.proposalCount + baseline.metrics.staleClaimCount;
84
+ const latestDebt = latest.metrics.lintFindingCount + latest.metrics.proposalCount + latest.metrics.staleClaimCount;
85
+ const coverageDelta = latest.metrics.metadataCoverage - baseline.metrics.metadataCoverage;
86
+ if (latestDebt < baselineDebt && coverageDelta >= 0) {
87
+ const debtDrop = baselineDebt - latestDebt;
88
+ return `Documentation debt dropped by ${debtDrop} item${debtDrop === 1 ? '' : 's'} since the baseline. Wiki is healthier.`;
89
+ }
90
+ if (latestDebt > baselineDebt) {
91
+ const debtRise = latestDebt - baselineDebt;
92
+ return `Documentation debt grew by ${debtRise} item${debtRise === 1 ? '' : 's'} since the baseline. Review stale claims, lint findings, and proposals.`;
93
+ }
94
+ return 'The wiki is stable versus the baseline. Capture more snapshots after meaningful sessions to reveal a stronger trend.';
95
+ }
96
+ function renderMetricCards(baseline, latest) {
97
+ if (!baseline || !latest) {
98
+ return '';
99
+ }
100
+ const cards = [
101
+ metricCard('Wiki pages', latest.metrics.pageCount, baseline.metrics.pageCount, 'up'),
102
+ metricCard('Source-backed claims', latest.metrics.claimCount, baseline.metrics.claimCount, 'up'),
103
+ metricCard('Lint findings', latest.metrics.lintFindingCount, baseline.metrics.lintFindingCount, 'down'),
104
+ metricCard('Open proposals', latest.metrics.proposalCount, baseline.metrics.proposalCount, 'down'),
105
+ metricCard('Stale claims', latest.metrics.staleClaimCount, baseline.metrics.staleClaimCount, 'down'),
106
+ metricCard('Metadata coverage', formatPercent(latest.metrics.metadataCoverage), formatPercent(baseline.metrics.metadataCoverage), 'up', latest.metrics.metadataCoverage - baseline.metrics.metadataCoverage)
107
+ ];
108
+ return ['<section class="metrics">', '<h2>Key Metrics</h2>', '<div class="metric-grid">', ...cards, '</div>', '</section>'].join('\n');
109
+ }
110
+ function metricCard(label, latestValue, baselineValue, betterDirection, explicitDelta) {
111
+ const numericLatest = typeof latestValue === 'number' ? latestValue : parseFloat(String(latestValue));
112
+ const numericBaseline = typeof baselineValue === 'number' ? baselineValue : parseFloat(String(baselineValue));
113
+ const delta = explicitDelta ?? numericLatest - numericBaseline;
114
+ const deltaText = renderDelta(delta, typeof latestValue === 'string' && latestValue.endsWith('%'));
115
+ const tone = deltaTone(delta, betterDirection);
116
+ return [
117
+ `<div class="metric-card metric-${tone}">`,
118
+ `<span class="metric-label">${escapeHtml(label)}</span>`,
119
+ `<span class="metric-value">${escapeHtml(String(latestValue))}</span>`,
120
+ `<span class="metric-delta">${escapeHtml(deltaText)}</span>`,
121
+ `<span class="metric-baseline">baseline ${escapeHtml(String(baselineValue))}</span>`,
122
+ '</div>'
123
+ ].join('');
124
+ }
125
+ function renderDelta(delta, isPercent) {
126
+ if (delta === 0) {
127
+ return 'no change';
128
+ }
129
+ const sign = delta > 0 ? '+' : '';
130
+ if (isPercent) {
131
+ return `${sign}${(delta * 100).toFixed(1)}%`;
132
+ }
133
+ return `${sign}${delta}`;
134
+ }
135
+ function deltaTone(delta, betterDirection) {
136
+ if (delta === 0)
137
+ return 'flat';
138
+ const isUp = delta > 0;
139
+ if (betterDirection === 'up')
140
+ return isUp ? 'good' : 'bad';
141
+ return isUp ? 'bad' : 'good';
142
+ }
143
+ function renderTrendChart(snapshots) {
144
+ if (snapshots.length < 2) {
145
+ return '';
146
+ }
147
+ const debtSeries = snapshots.map((s) => s.metrics.lintFindingCount + s.metrics.proposalCount + s.metrics.staleClaimCount);
148
+ const claimSeries = snapshots.map((s) => s.metrics.claimCount);
149
+ return [
150
+ '<section class="trends">',
151
+ '<h2>Health Trends</h2>',
152
+ '<div class="trend-row">',
153
+ trendBlock('Documentation debt (lint + proposals + stale claims)', debtSeries, 'bad'),
154
+ trendBlock('Source-backed claims', claimSeries, 'good'),
155
+ '</div>',
156
+ '</section>'
157
+ ].join('\n');
158
+ }
159
+ function trendBlock(label, series, goodTone) {
160
+ return [
161
+ '<div class="trend-block">',
162
+ `<h3>${escapeHtml(label)}</h3>`,
163
+ inlineSparkline(series, goodTone),
164
+ `<p class="trend-caption">${escapeHtml(`Range ${Math.min(...series)} → ${Math.max(...series)} across ${series.length} snapshots. Latest: ${series.at(-1) ?? 0}.`)}</p>`,
165
+ '</div>'
166
+ ].join('\n');
167
+ }
168
+ function inlineSparkline(series, tone) {
169
+ const width = 360;
170
+ const height = 80;
171
+ const padding = 6;
172
+ if (series.length === 0) {
173
+ return `<svg width="${width}" height="${height}" role="img" aria-label="No data"></svg>`;
174
+ }
175
+ const max = Math.max(...series, 1);
176
+ const min = Math.min(...series, 0);
177
+ const range = max - min || 1;
178
+ const stepX = (width - padding * 2) / Math.max(series.length - 1, 1);
179
+ const points = series
180
+ .map((value, i) => {
181
+ const x = padding + i * stepX;
182
+ const y = height - padding - ((value - min) / range) * (height - padding * 2);
183
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
184
+ })
185
+ .join(' ');
186
+ const stroke = tone === 'good' ? '#247a4d' : '#b54728';
187
+ const fill = tone === 'good' ? '#247a4d22' : '#b5472822';
188
+ return [
189
+ `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img">`,
190
+ `<polyline fill="none" stroke="${stroke}" stroke-width="2" points="${points}" />`,
191
+ `<polyline fill="${fill}" stroke="none" points="${points} ${(width - padding).toFixed(1)},${(height - padding).toFixed(1)} ${padding.toFixed(1)},${(height - padding).toFixed(1)}" />`,
192
+ '</svg>'
193
+ ].join('');
194
+ }
195
+ function renderRecallPanel(baseline, latest) {
196
+ if (!latest || !latest.recall || latest.recall.evaluatedProbeCount === 0) {
197
+ return '';
198
+ }
199
+ const recall = latest.recall;
200
+ const baselineRecall = baseline?.recall;
201
+ const top1Pct = recall.evaluatedProbeCount > 0 ? (recall.top1HitCount / recall.evaluatedProbeCount) * 100 : 0;
202
+ const top5Pct = recall.evaluatedProbeCount > 0 ? (recall.top5HitCount / recall.evaluatedProbeCount) * 100 : 0;
203
+ return [
204
+ '<section class="recall-panel">',
205
+ '<h2>Recall Quality</h2>',
206
+ `<p class="recall-source">Probe source: <code>${escapeHtml(recall.probesSource)}</code>${recall.probesPath ? ` · ${escapeHtml(recall.probesPath)}` : ''}</p>`,
207
+ '<div class="recall-grid">',
208
+ recallStat('Top-1 hit rate', `${top1Pct.toFixed(0)}%`, baselineRecall ? `${((baselineRecall.top1HitCount / Math.max(baselineRecall.evaluatedProbeCount, 1)) * 100).toFixed(0)}%` : '—'),
209
+ recallStat('Top-5 hit rate', `${top5Pct.toFixed(0)}%`, baselineRecall ? `${((baselineRecall.top5HitCount / Math.max(baselineRecall.evaluatedProbeCount, 1)) * 100).toFixed(0)}%` : '—'),
210
+ recallStat('Mean reciprocal rank', recall.meanReciprocalRank.toFixed(3), baselineRecall ? baselineRecall.meanReciprocalRank.toFixed(3) : '—'),
211
+ recallStat('Misses', String(recall.missCount), baselineRecall ? String(baselineRecall.missCount) : '—'),
212
+ recallStat('Probes evaluated', String(recall.evaluatedProbeCount), baselineRecall ? String(baselineRecall.evaluatedProbeCount) : '—'),
213
+ '</div>',
214
+ '</section>'
215
+ ].join('\n');
216
+ }
217
+ function recallStat(label, value, baselineValue) {
218
+ return [
219
+ '<div class="recall-stat">',
220
+ `<span class="metric-label">${escapeHtml(label)}</span>`,
221
+ `<span class="metric-value">${escapeHtml(value)}</span>`,
222
+ `<span class="metric-baseline">baseline ${escapeHtml(baselineValue)}</span>`,
223
+ '</div>'
224
+ ].join('');
225
+ }
226
+ function renderSnapshotsTable(snapshots) {
227
+ if (snapshots.length === 0) {
228
+ return '';
229
+ }
230
+ const rows = snapshots
231
+ .map((s) => `<tr>
232
+ <td>${escapeHtml(formatTimestamp(s.timestamp))}</td>
233
+ <td>${escapeHtml(s.label)}</td>
234
+ <td class="num">${s.metrics.pageCount}</td>
235
+ <td class="num">${s.metrics.claimCount}</td>
236
+ <td class="num">${s.metrics.lintFindingCount}</td>
237
+ <td class="num">${s.metrics.proposalCount}</td>
238
+ <td>${escapeHtml(s.git?.commit ?? '')}</td>
239
+ </tr>`)
240
+ .join('\n');
241
+ return [
242
+ '<section class="snapshots">',
243
+ '<h2>All Snapshots</h2>',
244
+ '<table>',
245
+ '<thead><tr><th>Timestamp</th><th>Label</th><th>Pages</th><th>Claims</th><th>Lint</th><th>Proposals</th><th>Commit</th></tr></thead>',
246
+ `<tbody>${rows}</tbody>`,
247
+ '</table>',
248
+ '</section>'
249
+ ].join('\n');
250
+ }
251
+ function formatTimestamp(timestamp) {
252
+ if (!timestamp)
253
+ return '—';
254
+ return timestamp.replace('T', ' ').replace(/\.\d+Z$/, 'Z');
255
+ }
256
+ function formatPercent(value) {
257
+ return `${(value * 100).toFixed(0)}%`;
258
+ }
259
+ function escapeHtml(value) {
260
+ return value
261
+ .replace(/&/g, '&amp;')
262
+ .replace(/</g, '&lt;')
263
+ .replace(/>/g, '&gt;')
264
+ .replace(/"/g, '&quot;')
265
+ .replace(/'/g, '&#39;');
266
+ }
267
+ const REPORT_CSS = `
268
+ :root {
269
+ color-scheme: light;
270
+ --ink: #172026;
271
+ --muted: #5b6670;
272
+ --line: #d7dee6;
273
+ --panel: #ffffff;
274
+ --bg: #f6f8fb;
275
+ --good: #247a4d;
276
+ --bad: #b54728;
277
+ --flat: #5b6670;
278
+ --accent: #2367d1;
279
+ }
280
+ * { box-sizing: border-box; }
281
+ body {
282
+ margin: 0;
283
+ background: var(--bg);
284
+ color: var(--ink);
285
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
286
+ line-height: 1.5;
287
+ }
288
+ .report { max-width: 1080px; margin: 0 auto; padding: 32px 24px 64px; }
289
+ .hero {
290
+ background: linear-gradient(135deg, #ffffff 0%, #f0f6ff 100%);
291
+ border: 1px solid var(--line);
292
+ border-radius: 12px;
293
+ padding: 32px;
294
+ margin-bottom: 24px;
295
+ }
296
+ .hero h1 { margin: 0 0 8px; font-size: 28px; }
297
+ .hero .meta { margin: 0 0 16px; color: var(--muted); font-size: 13px; }
298
+ .hero .summary { margin: 0; font-size: 18px; font-weight: 500; color: var(--ink); }
299
+ .empty-state { padding: 32px; text-align: center; color: var(--muted); border: 1px dashed var(--line); border-radius: 12px; margin-bottom: 24px; }
300
+ section { margin-bottom: 32px; }
301
+ section h2 { font-size: 18px; margin: 0 0 12px; color: var(--ink); }
302
+ .metric-grid {
303
+ display: grid;
304
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
305
+ gap: 12px;
306
+ }
307
+ .metric-card, .recall-stat {
308
+ background: var(--panel);
309
+ border: 1px solid var(--line);
310
+ border-radius: 10px;
311
+ padding: 14px 16px;
312
+ display: flex;
313
+ flex-direction: column;
314
+ gap: 4px;
315
+ }
316
+ .metric-label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
317
+ .metric-value { font-size: 24px; font-weight: 700; color: var(--ink); }
318
+ .metric-delta { font-size: 13px; font-weight: 600; }
319
+ .metric-baseline { font-size: 12px; color: var(--muted); }
320
+ .metric-good .metric-delta { color: var(--good); }
321
+ .metric-bad .metric-delta { color: var(--bad); }
322
+ .metric-flat .metric-delta { color: var(--flat); }
323
+ .trend-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
324
+ .trend-block { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px; }
325
+ .trend-block h3 { margin: 0 0 8px; font-size: 14px; color: var(--ink); }
326
+ .trend-block svg { display: block; max-width: 100%; height: auto; }
327
+ .trend-caption { margin: 8px 0 0; color: var(--muted); font-size: 12px; }
328
+ .recall-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
329
+ .recall-source { color: var(--muted); font-size: 13px; margin: 0 0 12px; }
330
+ table { width: 100%; border-collapse: collapse; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
331
+ th, td { padding: 10px 12px; text-align: left; font-size: 13px; border-bottom: 1px solid var(--line); }
332
+ th { background: #fafbfd; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; font-size: 11px; }
333
+ tr:last-child td { border-bottom: none; }
334
+ td.num { text-align: right; font-variant-numeric: tabular-nums; }
335
+ code { font-family: 'SF Mono', Menlo, monospace; font-size: 12px; background: #eef2f7; padding: 1px 6px; border-radius: 4px; }
336
+ .footer { margin-top: 48px; padding-top: 16px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; }
337
+ .footer a { color: var(--accent); text-decoration: none; }
338
+ .footer-meta { margin: 4px 0 0; }
339
+ `;