@grainulation/mill 1.0.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 (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +76 -0
  4. package/bin/mill.js +320 -0
  5. package/lib/exporters/csv.js +83 -0
  6. package/lib/exporters/json-ld.js +44 -0
  7. package/lib/exporters/markdown.js +116 -0
  8. package/lib/exporters/pdf.js +104 -0
  9. package/lib/formats/bibtex.js +76 -0
  10. package/lib/formats/changelog.js +102 -0
  11. package/lib/formats/csv.js +92 -0
  12. package/lib/formats/dot.js +129 -0
  13. package/lib/formats/evidence-matrix.js +87 -0
  14. package/lib/formats/executive-summary.js +130 -0
  15. package/lib/formats/github-issues.js +89 -0
  16. package/lib/formats/graphml.js +118 -0
  17. package/lib/formats/html-report.js +181 -0
  18. package/lib/formats/jira-csv.js +89 -0
  19. package/lib/formats/json-ld.js +28 -0
  20. package/lib/formats/markdown.js +118 -0
  21. package/lib/formats/ndjson.js +25 -0
  22. package/lib/formats/obsidian.js +136 -0
  23. package/lib/formats/opml.js +108 -0
  24. package/lib/formats/ris.js +70 -0
  25. package/lib/formats/rss.js +100 -0
  26. package/lib/formats/sankey.js +72 -0
  27. package/lib/formats/slide-deck.js +200 -0
  28. package/lib/formats/sql.js +116 -0
  29. package/lib/formats/static-site.js +169 -0
  30. package/lib/formats/treemap.js +65 -0
  31. package/lib/formats/typescript-defs.js +147 -0
  32. package/lib/formats/yaml.js +144 -0
  33. package/lib/formats.js +60 -0
  34. package/lib/index.js +14 -0
  35. package/lib/json-ld-common.js +72 -0
  36. package/lib/publishers/clipboard.js +70 -0
  37. package/lib/publishers/static.js +152 -0
  38. package/lib/serve-mcp.js +340 -0
  39. package/lib/server.js +535 -0
  40. package/package.json +53 -0
  41. package/public/grainulation-tokens.css +321 -0
  42. package/public/index.html +891 -0
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+
3
+ // NOTE: PDF is the one export format that requires external tools (md-to-pdf
4
+ // for Markdown, puppeteer for HTML). These are fetched via npx on first run,
5
+ // which needs network access. All other mill export formats are zero-dep.
6
+
7
+ const path = require('node:path');
8
+ const { execFile } = require('node:child_process');
9
+
10
+ /**
11
+ * Export HTML or Markdown files to PDF.
12
+ * Uses npx md-to-pdf for Markdown, npx puppeteer for HTML.
13
+ * Zero installed deps -- delegates to npx at runtime.
14
+ */
15
+
16
+ function deriveOutputPath(inputPath, explicit) {
17
+ if (explicit) return explicit;
18
+ const dir = path.dirname(inputPath);
19
+ const base = path.basename(inputPath, path.extname(inputPath));
20
+ return path.join(dir, `${base}.pdf`);
21
+ }
22
+
23
+ function exec(cmd, args) {
24
+ return new Promise((resolve, reject) => {
25
+ execFile(cmd, args, { timeout: 120_000 }, (err, stdout, stderr) => {
26
+ if (err) {
27
+ reject(new Error(`${cmd} failed: ${stderr || err.message}`));
28
+ } else {
29
+ resolve(stdout);
30
+ }
31
+ });
32
+ });
33
+ }
34
+
35
+ function npxToolError(toolName, originalError) {
36
+ const msg = originalError.message || String(originalError);
37
+ const isNotFound = /not found|ENOENT|ERR_MODULE_NOT_FOUND|Cannot find module/i.test(msg);
38
+ const isNetwork = /ENETUNREACH|ENOTFOUND|fetch failed|EAI_AGAIN/i.test(msg);
39
+ const isTimeout = /timed out|ETIMEDOUT/i.test(msg);
40
+
41
+ let hint;
42
+ if (isNotFound || isNetwork || isTimeout) {
43
+ hint =
44
+ `PDF export requires "${toolName}" which is fetched via npx on first run.\n` +
45
+ ` To pre-install: npx ${toolName} --version\n` +
46
+ ` If you are offline or npx is unavailable, use: mill export --format markdown`;
47
+ } else {
48
+ hint =
49
+ `"${toolName}" exited with an error.\n` +
50
+ ` Verify it works standalone: npx ${toolName} --version\n` +
51
+ ` Or fall back to: mill export --format markdown`;
52
+ }
53
+
54
+ return new Error(`PDF export failed -- ${toolName} unavailable or broken.\n\n${hint}\n\nOriginal error: ${msg}`);
55
+ }
56
+
57
+ async function exportFromMarkdown(inputPath, outputPath) {
58
+ const out = deriveOutputPath(inputPath, outputPath);
59
+ // md-to-pdf reads from file, writes pdf alongside or to --dest
60
+ try {
61
+ await exec('npx', [
62
+ '--yes', 'md-to-pdf', inputPath,
63
+ '--dest', out,
64
+ ]);
65
+ } catch (err) {
66
+ throw npxToolError('md-to-pdf', err);
67
+ }
68
+ return { outputPath: out, message: `PDF written to ${out}` };
69
+ }
70
+
71
+ async function exportFromHtml(inputPath, outputPath) {
72
+ const out = deriveOutputPath(inputPath, outputPath);
73
+ // Use a small inline puppeteer script via npx
74
+ const script = `
75
+ const puppeteer = require('puppeteer');
76
+ (async () => {
77
+ const browser = await puppeteer.launch({ headless: 'new' });
78
+ const page = await browser.newPage();
79
+ await page.goto('file://${inputPath.replace(/'/g, "\\'")}', { waitUntil: 'networkidle0' });
80
+ await page.pdf({ path: '${out.replace(/'/g, "\\'")}', format: 'A4', printBackground: true });
81
+ await browser.close();
82
+ })();
83
+ `;
84
+ try {
85
+ await exec('node', ['-e', script]);
86
+ } catch (err) {
87
+ throw npxToolError('puppeteer', err);
88
+ }
89
+ return { outputPath: out, message: `PDF written to ${out}` };
90
+ }
91
+
92
+ async function exportPdf(inputPath, outputPath) {
93
+ const ext = path.extname(inputPath).toLowerCase();
94
+ if (ext === '.md' || ext === '.markdown') {
95
+ return exportFromMarkdown(inputPath, outputPath);
96
+ }
97
+ return exportFromHtml(inputPath, outputPath);
98
+ }
99
+
100
+ module.exports = {
101
+ name: 'pdf',
102
+ description: 'Export HTML or Markdown to PDF',
103
+ export: exportPdf,
104
+ };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * mill format: bibtex
3
+ *
4
+ * Converts compilation.json claims to BibTeX .bib entries.
5
+ * Each claim becomes an @misc entry with metadata in note/keywords fields.
6
+ * Zero dependencies — node built-in only.
7
+ */
8
+
9
+ export const name = 'bibtex';
10
+ export const extension = '.bib';
11
+ export const mimeType = 'application/x-bibtex; charset=utf-8';
12
+ export const description = 'Claims as BibTeX bibliography entries (@misc per claim)';
13
+
14
+ /**
15
+ * Convert a compilation object to BibTeX.
16
+ * @param {object} compilation - The compilation.json content
17
+ * @returns {string} BibTeX output
18
+ */
19
+ export function convert(compilation) {
20
+ const claims = compilation.claims || [];
21
+ const meta = compilation.meta || {};
22
+ const year = new Date().getFullYear().toString();
23
+ const author = meta.sprint ? `wheat sprint: ${meta.sprint}` : 'wheat sprint';
24
+
25
+ if (claims.length === 0) {
26
+ return `% No claims in compilation\n`;
27
+ }
28
+
29
+ const entries = claims.map(claim => claimToEntry(claim, author, year));
30
+ return entries.join('\n\n') + '\n';
31
+ }
32
+
33
+ function claimToEntry(claim, author, year) {
34
+ const id = String(claim.id || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_');
35
+ const title = escapeBibtex(claim.content || claim.text || '');
36
+ const type = claim.type || '';
37
+ const evidence = typeof claim.evidence === 'string'
38
+ ? claim.evidence
39
+ : (claim.evidence?.tier ?? claim.evidence_tier ?? '');
40
+ const status = claim.status || '';
41
+ const tags = Array.isArray(claim.tags) ? claim.tags : [];
42
+ const confidence = claim.confidence != null ? `, confidence: ${claim.confidence}` : '';
43
+
44
+ const noteParts = [
45
+ type ? `type: ${type}` : '',
46
+ evidence ? `evidence: ${evidence}` : '',
47
+ status ? `status: ${status}` : '',
48
+ ].filter(Boolean).join(', ') + confidence;
49
+
50
+ const keywordsLine = tags.length > 0
51
+ ? `\n keywords = {${tags.map(escapeBibtex).join(', ')}},`
52
+ : '';
53
+
54
+ return [
55
+ `@misc{claim_${id},`,
56
+ ` title = {${title}},`,
57
+ ` author = {${escapeBibtex(author)}},`,
58
+ ` year = {${year}},`,
59
+ ` note = {${escapeBibtex(noteParts)}},` + keywordsLine,
60
+ `}`,
61
+ ].join('\n');
62
+ }
63
+
64
+ function escapeBibtex(value) {
65
+ if (value == null) return '';
66
+ return String(value)
67
+ .replace(/\\/g, '\\textbackslash{}')
68
+ .replace(/&/g, '\\&')
69
+ .replace(/%/g, '\\%')
70
+ .replace(/#/g, '\\#')
71
+ .replace(/_/g, '\\_')
72
+ .replace(/\{/g, '\\{')
73
+ .replace(/\}/g, '\\}')
74
+ .replace(/~/g, '\\textasciitilde{}')
75
+ .replace(/\^/g, '\\textasciicircum{}');
76
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * mill format: changelog
3
+ *
4
+ * Converts compilation.json claims to a diff/changelog grouped by status.
5
+ * Shows active, resolved, and conflict sections with counts.
6
+ * Zero dependencies — node built-in only.
7
+ */
8
+
9
+ export const name = 'changelog';
10
+ export const extension = '.md';
11
+ export const mimeType = 'text/markdown; charset=utf-8';
12
+ export const description = 'Claims changelog grouped by status (active, resolved, conflicts)';
13
+
14
+ /**
15
+ * Convert a compilation object to changelog markdown.
16
+ * @param {object} compilation - The compilation.json content
17
+ * @returns {string} Markdown changelog output
18
+ */
19
+ export function convert(compilation) {
20
+ const claims = compilation.claims || [];
21
+ const conflicts = compilation.conflicts || [];
22
+ const meta = compilation.meta || {};
23
+ const sprintName = meta.sprint || 'unnamed';
24
+
25
+ const groups = {};
26
+ for (const claim of claims) {
27
+ const status = claim.status || 'unknown';
28
+ if (!groups[status]) groups[status] = [];
29
+ groups[status].push(claim);
30
+ }
31
+
32
+ const lines = [];
33
+ lines.push(`# Changelog — ${sprintName}`);
34
+ lines.push('');
35
+
36
+ // Active claims first
37
+ if (groups.active) {
38
+ lines.push(`## Active (${groups.active.length} claims)`);
39
+ lines.push('');
40
+ for (const claim of groups.active) {
41
+ lines.push(`- ${claim.id}: ${claimText(claim)}`);
42
+ }
43
+ lines.push('');
44
+ }
45
+
46
+ // Resolved claims
47
+ if (groups.resolved) {
48
+ lines.push(`## Resolved (${groups.resolved.length} claims)`);
49
+ lines.push('');
50
+ for (const claim of groups.resolved) {
51
+ lines.push(`- ${claim.id}: ${claimText(claim)}`);
52
+ }
53
+ lines.push('');
54
+ }
55
+
56
+ // All other statuses
57
+ const shown = new Set(['active', 'resolved']);
58
+ const otherStatuses = Object.keys(groups)
59
+ .filter(s => !shown.has(s))
60
+ .sort();
61
+
62
+ for (const status of otherStatuses) {
63
+ const group = groups[status];
64
+ const label = status.charAt(0).toUpperCase() + status.slice(1);
65
+ lines.push(`## ${label} (${group.length} claims)`);
66
+ lines.push('');
67
+ for (const claim of group) {
68
+ lines.push(`- ${claim.id}: ${claimText(claim)}`);
69
+ }
70
+ lines.push('');
71
+ }
72
+
73
+ // Conflicts section
74
+ if (conflicts.length > 0) {
75
+ lines.push(`## Conflicts (${conflicts.length})`);
76
+ lines.push('');
77
+ for (const conflict of conflicts) {
78
+ const ids = Array.isArray(conflict.claim_ids)
79
+ ? conflict.claim_ids.join(' vs ')
80
+ : (conflict.between || 'unknown');
81
+ const desc = conflict.description || conflict.reason || '';
82
+ lines.push(`- ${ids}: ${desc}`);
83
+ }
84
+ lines.push('');
85
+ } else {
86
+ lines.push(`## Conflicts (0)`);
87
+ lines.push('');
88
+ lines.push('No conflicts.');
89
+ lines.push('');
90
+ }
91
+
92
+ return lines.join('\n');
93
+ }
94
+
95
+ function claimText(claim) {
96
+ const text = claim.content || claim.text || '';
97
+ // Truncate long claims for readability
98
+ if (text.length > 120) {
99
+ return text.slice(0, 117) + '...';
100
+ }
101
+ return text;
102
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * mill format: csv
3
+ *
4
+ * Converts compilation.json claims to CSV.
5
+ * Columns: id, type, topic, content, evidence_tier, evidence_source, confidence, status
6
+ * Zero dependencies — node built-in only.
7
+ */
8
+
9
+ export const name = 'csv';
10
+ export const extension = '.csv';
11
+ export const mimeType = 'text/csv; charset=utf-8';
12
+ export const description = 'Claims as CSV spreadsheet (id, type, topic, content, evidence, status)';
13
+
14
+ const COLUMNS = [
15
+ 'id',
16
+ 'type',
17
+ 'topic',
18
+ 'content',
19
+ 'evidence_tier',
20
+ 'evidence_source',
21
+ 'confidence',
22
+ 'status',
23
+ 'tags',
24
+ 'created',
25
+ ];
26
+
27
+ /**
28
+ * Convert a compilation object to CSV.
29
+ * @param {object} compilation - The compilation.json content
30
+ * @returns {string} CSV output
31
+ */
32
+ export function convert(compilation) {
33
+ const claims = compilation.claims || [];
34
+
35
+ if (claims.length === 0) {
36
+ return COLUMNS.join(',') + '\n';
37
+ }
38
+
39
+ const header = COLUMNS.join(',');
40
+ const rows = claims.map(claimToRow);
41
+
42
+ return [header, ...rows].join('\n') + '\n';
43
+ }
44
+
45
+ function claimToRow(claim) {
46
+ return COLUMNS.map(col => {
47
+ switch (col) {
48
+ case 'content':
49
+ // Claims may use 'text' or 'content' for the main body
50
+ return escapeField(claim.content || claim.text || '');
51
+ case 'topic':
52
+ // Use claim.topic directly, or fall back to first tag
53
+ return escapeField(
54
+ claim.topic || (Array.isArray(claim.tags) ? claim.tags[0] || '' : '')
55
+ );
56
+ case 'evidence_tier':
57
+ // evidence may be a string (tier directly) or object { tier, source }
58
+ if (typeof claim.evidence === 'string') return escapeField(claim.evidence);
59
+ return escapeField(claim.evidence?.tier ?? claim.evidence_tier ?? '');
60
+ case 'evidence_source':
61
+ // source may be an object { origin, artifact } or a string
62
+ if (typeof claim.source === 'object' && claim.source !== null) {
63
+ return escapeField(claim.source.origin || claim.source.artifact || '');
64
+ }
65
+ if (typeof claim.evidence === 'object' && claim.evidence?.source) {
66
+ return escapeField(claim.evidence.source);
67
+ }
68
+ return escapeField(claim.source ?? '');
69
+ case 'tags':
70
+ return escapeField(
71
+ Array.isArray(claim.tags) ? claim.tags.join('; ') : ''
72
+ );
73
+ case 'confidence':
74
+ return claim.confidence != null ? String(claim.confidence) : '';
75
+ default:
76
+ return escapeField(claim[col]);
77
+ }
78
+ }).join(',');
79
+ }
80
+
81
+ function escapeField(value) {
82
+ if (value == null) return '';
83
+ let str = String(value);
84
+ // CWE-1236: Prevent CSV injection by prefixing formula-triggering characters
85
+ if (/^[=+\-@\t\r]/.test(str)) {
86
+ str = "'" + str;
87
+ }
88
+ if (str.includes(',') || str.includes('"') || str.includes('\n')) {
89
+ return `"${str.replace(/"/g, '""')}"`;
90
+ }
91
+ return str;
92
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * mill format: dot
3
+ *
4
+ * Converts compilation.json claims to Graphviz DOT format.
5
+ * Claims grouped into type-based subgraph clusters, tag edges as dashed lines.
6
+ * Zero dependencies — node built-in only.
7
+ */
8
+
9
+ export const name = 'dot';
10
+ export const extension = '.dot';
11
+ export const mimeType = 'text/vnd.graphviz; charset=utf-8';
12
+ export const description = 'Claims as Graphviz DOT graph (type clusters, tag edges)';
13
+
14
+ const TYPE_COLORS = {
15
+ constraint: { border: '#f87171', fill: '#2d1f1f', label: 'Constraints' },
16
+ factual: { border: '#60a5fa', fill: '#1f2937', label: 'Factual' },
17
+ estimate: { border: '#a78bfa', fill: '#1f1f2d', label: 'Estimates' },
18
+ risk: { border: '#fb923c', fill: '#2d2517', label: 'Risks' },
19
+ recommendation: { border: '#34d399', fill: '#172d1f', label: 'Recommendations' },
20
+ feedback: { border: '#fbbf24', fill: '#2d2a17', label: 'Feedback' },
21
+ };
22
+
23
+ const DEFAULT_COLOR = { border: '#9ca3af', fill: '#1f1f1f', label: 'Other' };
24
+
25
+ /**
26
+ * Convert a compilation object to DOT format.
27
+ * @param {object} compilation - The compilation.json content
28
+ * @returns {string} DOT output
29
+ */
30
+ export function convert(compilation) {
31
+ const claims = compilation.claims || [];
32
+ const sprintName = compilation.meta?.sprint || 'sprint';
33
+
34
+ const lines = [];
35
+ lines.push(`digraph ${escId(sprintName)} {`);
36
+ lines.push(' rankdir=LR;');
37
+ lines.push(' node [shape=box, style=filled, fontname="monospace", fontsize=10];');
38
+ lines.push(' edge [fontname="monospace", fontsize=8];');
39
+ lines.push('');
40
+
41
+ // Group claims by type
42
+ const groups = {};
43
+ for (const claim of claims) {
44
+ const t = claim.type || 'other';
45
+ if (!groups[t]) groups[t] = [];
46
+ groups[t].push(claim);
47
+ }
48
+
49
+ // Type clusters
50
+ for (const [type, group] of Object.entries(groups)) {
51
+ const colors = TYPE_COLORS[type] || DEFAULT_COLOR;
52
+ lines.push(` subgraph cluster_${escId(type)} {`);
53
+ lines.push(` label="${escDot(colors.label || type)}";`);
54
+ lines.push(` color="${colors.border}";`);
55
+ lines.push(` style=dashed;`);
56
+ lines.push('');
57
+
58
+ for (const claim of group) {
59
+ const id = escId(claim.id || '');
60
+ const content = claim.content || claim.text || '';
61
+ const label = truncate(content, 40);
62
+ lines.push(` ${id} [label="${escDot(claim.id + '\\n' + label)}" fillcolor="${colors.fill}"];`);
63
+ }
64
+
65
+ lines.push(' }');
66
+ lines.push('');
67
+ }
68
+
69
+ // Tag edges
70
+ const edges = buildTagEdges(claims);
71
+ if (edges.length > 0) {
72
+ lines.push(' // Tag edges');
73
+ for (const edge of edges) {
74
+ lines.push(` ${escId(edge.source)} -> ${escId(edge.target)} [label="${escDot('tag: ' + edge.tag)}" style=dashed];`);
75
+ }
76
+ lines.push('');
77
+ }
78
+
79
+ lines.push('}');
80
+
81
+ return lines.join('\n') + '\n';
82
+ }
83
+
84
+ function buildTagEdges(claims) {
85
+ const tagMap = {};
86
+
87
+ for (const claim of claims) {
88
+ const tags = Array.isArray(claim.tags) ? claim.tags : [];
89
+ for (const tag of tags) {
90
+ if (!tagMap[tag]) tagMap[tag] = [];
91
+ tagMap[tag].push(claim.id || '');
92
+ }
93
+ }
94
+
95
+ const edges = [];
96
+ const seen = new Set();
97
+
98
+ for (const [tag, ids] of Object.entries(tagMap)) {
99
+ for (let i = 0; i < ids.length; i++) {
100
+ for (let j = i + 1; j < ids.length; j++) {
101
+ const key = `${ids[i]}--${ids[j]}--${tag}`;
102
+ if (!seen.has(key)) {
103
+ seen.add(key);
104
+ edges.push({ source: ids[i], target: ids[j], tag });
105
+ }
106
+ }
107
+ }
108
+ }
109
+
110
+ return edges;
111
+ }
112
+
113
+ function truncate(str, max) {
114
+ if (!str) return '';
115
+ return str.length > max ? str.slice(0, max - 3) + '...' : str;
116
+ }
117
+
118
+ function escId(str) {
119
+ // DOT identifiers: replace non-alphanumeric with underscores
120
+ return String(str).replace(/[^a-zA-Z0-9_]/g, '_');
121
+ }
122
+
123
+ function escDot(str) {
124
+ if (str == null) return '';
125
+ return String(str)
126
+ .replace(/\\/g, '\\\\')
127
+ .replace(/"/g, '\\"')
128
+ .replace(/\n/g, '\\n');
129
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * mill format: evidence-matrix
3
+ *
4
+ * Generates a pivot CSV with rows=claim types, columns=evidence tiers, cells=counts.
5
+ * Useful for visualizing evidence coverage across claim categories.
6
+ * Zero dependencies — node built-in only.
7
+ */
8
+
9
+ export const name = 'evidence-matrix';
10
+ export const extension = '.csv';
11
+ export const mimeType = 'text/csv; charset=utf-8';
12
+ export const description = 'Pivot table CSV: claim types vs evidence tiers with counts';
13
+
14
+ const TIER_ORDER = ['stated', 'web', 'documented', 'tested', 'production'];
15
+
16
+ /**
17
+ * Convert a compilation object to an evidence matrix CSV.
18
+ * @param {object} compilation - The compilation.json content
19
+ * @returns {string} CSV pivot table output
20
+ */
21
+ export function convert(compilation) {
22
+ const claims = compilation.claims || [];
23
+
24
+ // Build the pivot: type -> tier -> count
25
+ const pivot = {};
26
+ const allTypes = new Set();
27
+ const allTiers = new Set();
28
+
29
+ for (const claim of claims) {
30
+ if (claim.status === 'reverted') continue;
31
+
32
+ const type = claim.type || 'unknown';
33
+ const tier = extractTier(claim) || 'unknown';
34
+
35
+ allTypes.add(type);
36
+ allTiers.add(tier);
37
+
38
+ if (!pivot[type]) pivot[type] = {};
39
+ pivot[type][tier] = (pivot[type][tier] || 0) + 1;
40
+ }
41
+
42
+ // Build column order: known tiers first (in canonical order), then any extras
43
+ const tierColumns = [];
44
+ for (const t of TIER_ORDER) {
45
+ if (allTiers.has(t)) tierColumns.push(t);
46
+ }
47
+ for (const t of [...allTiers].sort()) {
48
+ if (!tierColumns.includes(t)) tierColumns.push(t);
49
+ }
50
+
51
+ // Build row order: sort types alphabetically
52
+ const typeRows = [...allTypes].sort();
53
+
54
+ // Generate CSV
55
+ const lines = [];
56
+
57
+ // Header
58
+ lines.push(['type', ...tierColumns, 'total'].join(','));
59
+
60
+ // Data rows
61
+ for (const type of typeRows) {
62
+ const counts = tierColumns.map(tier => pivot[type]?.[tier] || 0);
63
+ const total = counts.reduce((sum, n) => sum + n, 0);
64
+ lines.push([type, ...counts, total].join(','));
65
+ }
66
+
67
+ // Totals row
68
+ const colTotals = tierColumns.map(tier => {
69
+ let sum = 0;
70
+ for (const type of typeRows) {
71
+ sum += pivot[type]?.[tier] || 0;
72
+ }
73
+ return sum;
74
+ });
75
+ const grandTotal = colTotals.reduce((sum, n) => sum + n, 0);
76
+ lines.push(['total', ...colTotals, grandTotal].join(','));
77
+
78
+ return lines.join('\n') + '\n';
79
+ }
80
+
81
+ function extractTier(claim) {
82
+ if (typeof claim.evidence === 'string') return claim.evidence;
83
+ if (typeof claim.evidence === 'object' && claim.evidence !== null) {
84
+ return claim.evidence.tier || null;
85
+ }
86
+ return claim.evidence_tier || null;
87
+ }