@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,118 @@
1
+ /**
2
+ * mill format: markdown
3
+ *
4
+ * Converts compilation.json (wheat compiler output) to clean Markdown.
5
+ * Used by the mill server for live preview and export.
6
+ * Zero dependencies — node built-in only.
7
+ */
8
+
9
+ export const name = 'markdown';
10
+ export const extension = '.md';
11
+ export const mimeType = 'text/markdown; charset=utf-8';
12
+ export const description = 'Clean Markdown document from compilation data';
13
+
14
+ /**
15
+ * Convert a compilation object to Markdown.
16
+ * @param {object} compilation - The compilation.json content
17
+ * @returns {string} Markdown output
18
+ */
19
+ export function convert(compilation) {
20
+ const lines = [];
21
+ const meta = compilation.meta || {};
22
+ const claims = compilation.claims || [];
23
+ const conflicts = compilation.conflicts || [];
24
+ const certificate = compilation.certificate || {};
25
+
26
+ // Title
27
+ const title = meta.sprint || meta.question || 'Sprint Export';
28
+ lines.push(`# ${title}`);
29
+ lines.push('');
30
+
31
+ // Meta block
32
+ if (meta.question) {
33
+ lines.push(`> **Question:** ${meta.question}`);
34
+ lines.push('');
35
+ }
36
+ if (meta.audience) {
37
+ lines.push(`**Audience:** ${meta.audience}`);
38
+ lines.push('');
39
+ }
40
+ if (certificate.compiled_at) {
41
+ lines.push(`*Compiled: ${certificate.compiled_at}*`);
42
+ lines.push('');
43
+ }
44
+
45
+ // Summary stats
46
+ const active = claims.filter(c => c.status === 'active').length;
47
+ const superseded = claims.filter(c => c.status === 'superseded').length;
48
+ const reverted = claims.filter(c => c.status === 'reverted').length;
49
+ lines.push('## Summary');
50
+ lines.push('');
51
+ lines.push(`- **Total claims:** ${claims.length}`);
52
+ lines.push(`- **Active:** ${active}`);
53
+ if (superseded) lines.push(`- **Superseded:** ${superseded}`);
54
+ if (reverted) lines.push(`- **Reverted:** ${reverted}`);
55
+ if (conflicts.length) lines.push(`- **Conflicts:** ${conflicts.length}`);
56
+ lines.push('');
57
+
58
+ // Group claims by type
59
+ const byType = {};
60
+ for (const claim of claims) {
61
+ if (claim.status === 'reverted') continue;
62
+ const type = claim.type || 'unknown';
63
+ if (!byType[type]) byType[type] = [];
64
+ byType[type].push(claim);
65
+ }
66
+
67
+ const typeOrder = ['constraint', 'factual', 'recommendation', 'risk', 'estimate', 'feedback'];
68
+ const sortedTypes = typeOrder.filter(t => byType[t]);
69
+ for (const t of Object.keys(byType)) {
70
+ if (!sortedTypes.includes(t)) sortedTypes.push(t);
71
+ }
72
+
73
+ for (const type of sortedTypes) {
74
+ const group = byType[type];
75
+ if (!group || group.length === 0) continue;
76
+
77
+ lines.push(`## ${capitalize(type)}s (${group.length})`);
78
+ lines.push('');
79
+
80
+ for (const claim of group) {
81
+ const conf = claim.confidence != null ? ` [${Math.round(claim.confidence * 100)}%]` : '';
82
+ const evidenceStr = typeof claim.evidence === 'string' ? claim.evidence : claim.evidence?.tier;
83
+ const tier = evidenceStr ? ` (${evidenceStr})` : '';
84
+ const status = claim.status === 'superseded' ? ' ~~superseded~~' : '';
85
+ const body = claim.content || claim.text || '';
86
+ lines.push(`- **${claim.id}**${conf}${tier}${status}: ${body}`);
87
+ }
88
+ lines.push('');
89
+ }
90
+
91
+ // Conflicts
92
+ if (conflicts.length > 0) {
93
+ lines.push('## Conflicts');
94
+ lines.push('');
95
+ for (const conflict of conflicts) {
96
+ const resolved = conflict.resolution ? ' (resolved)' : '';
97
+ lines.push(`- **${conflict.ids?.join(' vs ') || 'unknown'}**${resolved}: ${conflict.description || conflict.reason || 'No description'}`);
98
+ if (conflict.resolution) {
99
+ lines.push(` - *Resolution:* ${conflict.resolution}`);
100
+ }
101
+ }
102
+ lines.push('');
103
+ }
104
+
105
+ // Certificate
106
+ if (certificate.sha256 || certificate.claim_count) {
107
+ lines.push('---');
108
+ lines.push('');
109
+ lines.push(`*Certificate: ${certificate.claim_count || claims.length} claims, sha256:${(certificate.sha256 || 'unknown').slice(0, 12)}*`);
110
+ lines.push('');
111
+ }
112
+
113
+ return lines.join('\n');
114
+ }
115
+
116
+ function capitalize(str) {
117
+ return str.charAt(0).toUpperCase() + str.slice(1);
118
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * mill format: ndjson
3
+ *
4
+ * One JSON object per line (Newline Delimited JSON).
5
+ * Each line is a claim with all fields preserved.
6
+ * Zero dependencies — node built-in only.
7
+ */
8
+
9
+ export const name = 'ndjson';
10
+ export const extension = '.ndjson';
11
+ export const mimeType = 'application/x-ndjson';
12
+ export const description = 'Newline-delimited JSON — one claim object per line';
13
+
14
+ /**
15
+ * Convert a compilation object to NDJSON.
16
+ * @param {object} compilation - The compilation.json content
17
+ * @returns {string} NDJSON output
18
+ */
19
+ export function convert(compilation) {
20
+ const claims = compilation.claims || [];
21
+
22
+ if (claims.length === 0) return '';
23
+
24
+ return claims.map(c => JSON.stringify(c)).join('\n') + '\n';
25
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * mill format: obsidian
3
+ *
4
+ * Converts compilation.json claims to Obsidian-flavored markdown
5
+ * with YAML front matter, wikilinks, and backlinks.
6
+ * Zero dependencies — node built-in only.
7
+ */
8
+
9
+ export const name = 'obsidian';
10
+ export const extension = '.md';
11
+ export const mimeType = 'text/markdown; charset=utf-8';
12
+ export const description = 'Claims as Obsidian vault page (YAML front matter, wikilinks, backlinks)';
13
+
14
+ /**
15
+ * Convert a compilation object to Obsidian markdown.
16
+ * @param {object} compilation - The compilation.json content
17
+ * @returns {string} Markdown output
18
+ */
19
+ export function convert(compilation) {
20
+ const meta = compilation.meta || {};
21
+ const claims = compilation.claims || [];
22
+ const sprint = meta.sprint || 'unknown';
23
+ const question = meta.question || '';
24
+ const audience = meta.audience || '';
25
+
26
+ const grouped = groupByType(claims);
27
+ const lines = [];
28
+
29
+ // YAML front matter
30
+ lines.push('---');
31
+ lines.push(`sprint: ${sprint}`);
32
+ if (question) lines.push(`question: "${escapeFrontMatter(question)}"`);
33
+ if (audience) lines.push(`audience: "${escapeFrontMatter(audience)}"`);
34
+ lines.push(`claim_count: ${claims.length}`);
35
+ lines.push('tags: [wheat, sprint, export]');
36
+ lines.push('---');
37
+ lines.push('');
38
+
39
+ // Title
40
+ lines.push(`# Sprint: ${sprint}`);
41
+ lines.push('');
42
+
43
+ if (question) {
44
+ lines.push(`> ${question}`);
45
+ lines.push('');
46
+ }
47
+
48
+ // Claims grouped by type
49
+ lines.push('## Claims');
50
+ lines.push('');
51
+
52
+ const typeOrder = ['constraint', 'factual', 'estimate', 'risk', 'recommendation', 'feedback'];
53
+ const seen = new Set();
54
+
55
+ for (const type of typeOrder) {
56
+ if (grouped[type]) {
57
+ lines.push(...renderGroup(type, grouped[type]));
58
+ seen.add(type);
59
+ }
60
+ }
61
+
62
+ for (const type of Object.keys(grouped)) {
63
+ if (!seen.has(type)) {
64
+ lines.push(...renderGroup(type, grouped[type]));
65
+ }
66
+ }
67
+
68
+ // Connections section
69
+ lines.push('## Connections');
70
+ lines.push('');
71
+ lines.push('- Related: [[compilation]] [[claims]]');
72
+
73
+ if (compilation.certificate) {
74
+ lines.push('- Certificate: [[certificate]]');
75
+ }
76
+
77
+ lines.push('');
78
+ return lines.join('\n') + '\n';
79
+ }
80
+
81
+ function groupByType(claims) {
82
+ const groups = {};
83
+ for (const claim of claims) {
84
+ const type = claim.type || 'other';
85
+ if (!groups[type]) groups[type] = [];
86
+ groups[type].push(claim);
87
+ }
88
+ return groups;
89
+ }
90
+
91
+ function renderGroup(type, claims) {
92
+ const label = capitalize(type) + 's';
93
+ const lines = [];
94
+
95
+ lines.push(`### ${label}`);
96
+
97
+ for (const claim of claims) {
98
+ const id = claim.id || '???';
99
+ const content = claim.content || claim.text || '';
100
+ const summary = truncate(content, 120);
101
+ const evidence = getEvidence(claim);
102
+ const tags = formatTags(claim);
103
+
104
+ lines.push(`- [[${id}]] ${summary} #${type} #${evidence}${tags}`);
105
+ }
106
+
107
+ lines.push('');
108
+ return lines;
109
+ }
110
+
111
+ function getEvidence(claim) {
112
+ if (typeof claim.evidence === 'string') return claim.evidence;
113
+ if (typeof claim.evidence === 'object' && claim.evidence !== null) {
114
+ return claim.evidence.tier || claim.evidence_tier || 'stated';
115
+ }
116
+ return claim.evidence_tier || 'stated';
117
+ }
118
+
119
+ function formatTags(claim) {
120
+ const tags = Array.isArray(claim.tags) ? claim.tags : [];
121
+ if (tags.length === 0) return '';
122
+ return ' ' + tags.map(t => `#${t.replace(/\s+/g, '-')}`).join(' ');
123
+ }
124
+
125
+ function capitalize(str) {
126
+ return str.charAt(0).toUpperCase() + str.slice(1);
127
+ }
128
+
129
+ function truncate(str, max) {
130
+ if (str.length <= max) return str;
131
+ return str.slice(0, max - 3) + '...';
132
+ }
133
+
134
+ function escapeFrontMatter(str) {
135
+ return String(str).replace(/"/g, '\\"');
136
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * mill format: opml
3
+ *
4
+ * Converts compilation.json claims to OPML 2.0 outline,
5
+ * grouped by claim type.
6
+ * Zero dependencies — node built-in only.
7
+ */
8
+
9
+ export const name = 'opml';
10
+ export const extension = '.opml';
11
+ export const mimeType = 'text/x-opml; charset=utf-8';
12
+ export const description = 'Claims as OPML 2.0 outline grouped by type (for RSS readers and outliners)';
13
+
14
+ /**
15
+ * Convert a compilation object to OPML XML.
16
+ * @param {object} compilation - The compilation.json content
17
+ * @returns {string} OPML XML output
18
+ */
19
+ export function convert(compilation) {
20
+ const meta = compilation.meta || {};
21
+ const claims = compilation.claims || [];
22
+ const sprint = meta.sprint || 'unknown';
23
+
24
+ const grouped = groupByType(claims);
25
+ const lines = [];
26
+
27
+ lines.push('<?xml version="1.0" encoding="utf-8"?>');
28
+ lines.push('<opml version="2.0">');
29
+ lines.push(` <head><title>${esc(sprint)}</title></head>`);
30
+ lines.push(' <body>');
31
+
32
+ const typeOrder = ['constraint', 'factual', 'estimate', 'risk', 'recommendation', 'feedback'];
33
+ const seen = new Set();
34
+
35
+ for (const type of typeOrder) {
36
+ if (grouped[type]) {
37
+ lines.push(...renderGroup(type, grouped[type]));
38
+ seen.add(type);
39
+ }
40
+ }
41
+
42
+ // Remaining types not in the predefined order
43
+ for (const type of Object.keys(grouped)) {
44
+ if (!seen.has(type)) {
45
+ lines.push(...renderGroup(type, grouped[type]));
46
+ }
47
+ }
48
+
49
+ lines.push(' </body>');
50
+ lines.push('</opml>');
51
+
52
+ return lines.join('\n') + '\n';
53
+ }
54
+
55
+ function groupByType(claims) {
56
+ const groups = {};
57
+ for (const claim of claims) {
58
+ const type = claim.type || 'other';
59
+ if (!groups[type]) groups[type] = [];
60
+ groups[type].push(claim);
61
+ }
62
+ return groups;
63
+ }
64
+
65
+ function renderGroup(type, claims) {
66
+ const label = capitalize(type) + 's';
67
+ const lines = [];
68
+
69
+ lines.push(` <outline text="${esc(label)} (${claims.length})">`);
70
+
71
+ for (const claim of claims) {
72
+ const id = claim.id || '???';
73
+ const content = claim.content || claim.text || '';
74
+ const summary = truncate(content, 100);
75
+ const evidence = getEvidence(claim);
76
+ lines.push(` <outline text="${esc(id)}: ${esc(summary)}" _note="evidence: ${esc(evidence)}"/>`);
77
+ }
78
+
79
+ lines.push(' </outline>');
80
+ return lines;
81
+ }
82
+
83
+ function getEvidence(claim) {
84
+ if (typeof claim.evidence === 'string') return claim.evidence;
85
+ if (typeof claim.evidence === 'object' && claim.evidence !== null) {
86
+ return claim.evidence.tier || claim.evidence_tier || 'stated';
87
+ }
88
+ return claim.evidence_tier || 'stated';
89
+ }
90
+
91
+ function capitalize(str) {
92
+ return str.charAt(0).toUpperCase() + str.slice(1);
93
+ }
94
+
95
+ function truncate(str, max) {
96
+ if (str.length <= max) return str;
97
+ return str.slice(0, max - 3) + '...';
98
+ }
99
+
100
+ function esc(str) {
101
+ if (str == null) return '';
102
+ return String(str)
103
+ .replace(/&/g, '&amp;')
104
+ .replace(/</g, '&lt;')
105
+ .replace(/>/g, '&gt;')
106
+ .replace(/"/g, '&quot;')
107
+ .replace(/'/g, '&apos;');
108
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * mill format: ris
3
+ *
4
+ * Converts compilation.json claims to RIS tagged citation format.
5
+ * Each claim becomes a TY-ER record block.
6
+ * Zero dependencies — node built-in only.
7
+ */
8
+
9
+ export const name = 'ris';
10
+ export const extension = '.ris';
11
+ export const mimeType = 'application/x-research-info-systems; charset=utf-8';
12
+ export const description = 'Claims as RIS citation records (one TY-ER block per claim)';
13
+
14
+ /**
15
+ * Convert a compilation object to RIS.
16
+ * @param {object} compilation - The compilation.json content
17
+ * @returns {string} RIS output
18
+ */
19
+ export function convert(compilation) {
20
+ const claims = compilation.claims || [];
21
+ const meta = compilation.meta || {};
22
+ const author = meta.sprint ? `wheat sprint: ${meta.sprint}` : 'wheat sprint';
23
+ const year = new Date().getFullYear().toString();
24
+
25
+ if (claims.length === 0) {
26
+ return '';
27
+ }
28
+
29
+ const records = claims.map(claim => claimToRecord(claim, author, year));
30
+ return records.join('\n') + '\n';
31
+ }
32
+
33
+ function claimToRecord(claim, author, year) {
34
+ const id = String(claim.id || 'unknown');
35
+ const title = 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 lines = [
51
+ `TY - GEN`,
52
+ `ID - ${id}`,
53
+ `TI - ${title}`,
54
+ `AU - ${author}`,
55
+ `PY - ${year}`,
56
+ ];
57
+
58
+ for (const tag of tags) {
59
+ lines.push(`KW - ${tag}`);
60
+ }
61
+
62
+ if (noteParts) {
63
+ lines.push(`N1 - ${noteParts}`);
64
+ }
65
+
66
+ lines.push(`ER - `);
67
+ lines.push('');
68
+
69
+ return lines.join('\n');
70
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * mill format: rss
3
+ *
4
+ * Converts compilation.json claims to an Atom XML feed.
5
+ * Each claim becomes a feed entry with category and content.
6
+ * Zero dependencies — node built-in only.
7
+ */
8
+
9
+ export const name = 'rss';
10
+ export const extension = '.xml';
11
+ export const mimeType = 'application/atom+xml; charset=utf-8';
12
+ export const description = 'Claims as Atom XML feed (one entry per claim)';
13
+
14
+ /**
15
+ * Convert a compilation object to Atom XML.
16
+ * @param {object} compilation - The compilation.json content
17
+ * @returns {string} Atom XML output
18
+ */
19
+ export function convert(compilation) {
20
+ const claims = compilation.claims || [];
21
+ const meta = compilation.meta || {};
22
+ const sprintName = meta.sprint || 'unnamed';
23
+ const updated = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
24
+
25
+ const lines = [];
26
+ lines.push(`<?xml version="1.0" encoding="utf-8"?>`);
27
+ lines.push(`<feed xmlns="http://www.w3.org/2005/Atom">`);
28
+ lines.push(` <title>${escapeXml(`Sprint: ${sprintName}`)}</title>`);
29
+ lines.push(` <id>urn:wheat:sprint:${escapeXml(sprintName)}</id>`);
30
+ lines.push(` <updated>${updated}</updated>`);
31
+
32
+ if (meta.question) {
33
+ lines.push(` <subtitle>${escapeXml(meta.question)}</subtitle>`);
34
+ }
35
+
36
+ for (const claim of claims) {
37
+ lines.push(claimToEntry(claim));
38
+ }
39
+
40
+ lines.push(`</feed>`);
41
+ lines.push('');
42
+
43
+ return lines.join('\n');
44
+ }
45
+
46
+ function claimToEntry(claim) {
47
+ const id = String(claim.id || 'unknown');
48
+ const text = claim.content || claim.text || '';
49
+ const type = claim.type || '';
50
+ const status = claim.status || '';
51
+ const evidence = typeof claim.evidence === 'string'
52
+ ? claim.evidence
53
+ : (claim.evidence?.tier ?? claim.evidence_tier ?? '');
54
+ const tags = Array.isArray(claim.tags) ? claim.tags : [];
55
+ const updated = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
56
+
57
+ const summaryParts = [
58
+ type ? `type: ${type}` : '',
59
+ evidence ? `evidence: ${evidence}` : '',
60
+ status ? `status: ${status}` : '',
61
+ ].filter(Boolean).join(', ');
62
+
63
+ const lines = [];
64
+ lines.push(` <entry>`);
65
+ lines.push(` <title>${escapeXml(`${id}: ${truncate(text, 80)}`)}</title>`);
66
+ lines.push(` <id>urn:wheat:${escapeXml(id)}</id>`);
67
+ lines.push(` <updated>${updated}</updated>`);
68
+ lines.push(` <content type="text">${escapeXml(text)}</content>`);
69
+
70
+ if (summaryParts) {
71
+ lines.push(` <summary>${escapeXml(summaryParts)}</summary>`);
72
+ }
73
+
74
+ if (type) {
75
+ lines.push(` <category term="${escapeXml(type)}"/>`);
76
+ }
77
+
78
+ for (const tag of tags) {
79
+ lines.push(` <category term="${escapeXml(tag)}"/>`);
80
+ }
81
+
82
+ lines.push(` </entry>`);
83
+
84
+ return lines.join('\n');
85
+ }
86
+
87
+ function escapeXml(value) {
88
+ if (value == null) return '';
89
+ return String(value)
90
+ .replace(/&/g, '&amp;')
91
+ .replace(/</g, '&lt;')
92
+ .replace(/>/g, '&gt;')
93
+ .replace(/"/g, '&quot;')
94
+ .replace(/'/g, '&apos;');
95
+ }
96
+
97
+ function truncate(str, max) {
98
+ if (str.length <= max) return str;
99
+ return str.slice(0, max - 3) + '...';
100
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * mill format: sankey
3
+ *
4
+ * Converts compilation.json claims to Sankey JSON for D3-sankey.
5
+ * Three-column flow: type -> evidence_tier -> status.
6
+ * Zero dependencies — node built-in only.
7
+ */
8
+
9
+ export const name = 'sankey';
10
+ export const extension = '.json';
11
+ export const mimeType = 'application/json; charset=utf-8';
12
+ export const description = 'Claims as Sankey flow JSON (type -> evidence tier -> status)';
13
+
14
+ /**
15
+ * Convert a compilation object to Sankey JSON.
16
+ * @param {object} compilation - The compilation.json content
17
+ * @returns {string} JSON output
18
+ */
19
+ export function convert(compilation) {
20
+ const claims = compilation.claims || [];
21
+
22
+ // Count flows: type -> evidence, evidence -> status
23
+ const typeToEvidence = {};
24
+ const evidenceToStatus = {};
25
+ const nodeSet = new Set();
26
+
27
+ for (const claim of claims) {
28
+ const type = 'type:' + (claim.type || 'other');
29
+ const evidence = 'evidence:' + getEvidence(claim) || 'evidence:unknown';
30
+ const status = 'status:' + (claim.status || 'unknown');
31
+
32
+ nodeSet.add(type);
33
+ nodeSet.add(evidence);
34
+ nodeSet.add(status);
35
+
36
+ // type -> evidence link
37
+ const teKey = `${type}|||${evidence}`;
38
+ typeToEvidence[teKey] = (typeToEvidence[teKey] || 0) + 1;
39
+
40
+ // evidence -> status link
41
+ const esKey = `${evidence}|||${status}`;
42
+ evidenceToStatus[esKey] = (evidenceToStatus[esKey] || 0) + 1;
43
+ }
44
+
45
+ const nodes = Array.from(nodeSet)
46
+ .sort()
47
+ .map(id => ({ id }));
48
+
49
+ const links = [];
50
+
51
+ for (const [key, value] of Object.entries(typeToEvidence)) {
52
+ const [source, target] = key.split('|||');
53
+ links.push({ source, target, value });
54
+ }
55
+
56
+ for (const [key, value] of Object.entries(evidenceToStatus)) {
57
+ const [source, target] = key.split('|||');
58
+ links.push({ source, target, value });
59
+ }
60
+
61
+ const result = { nodes, links };
62
+
63
+ return JSON.stringify(result, null, 2) + '\n';
64
+ }
65
+
66
+ function getEvidence(claim) {
67
+ if (typeof claim.evidence === 'string') return claim.evidence;
68
+ if (typeof claim.evidence === 'object' && claim.evidence !== null) {
69
+ return claim.evidence.tier || claim.evidence_tier || '';
70
+ }
71
+ return claim.evidence_tier || '';
72
+ }