@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.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/bin/mill.js +320 -0
- package/lib/exporters/csv.js +83 -0
- package/lib/exporters/json-ld.js +44 -0
- package/lib/exporters/markdown.js +116 -0
- package/lib/exporters/pdf.js +104 -0
- package/lib/formats/bibtex.js +76 -0
- package/lib/formats/changelog.js +102 -0
- package/lib/formats/csv.js +92 -0
- package/lib/formats/dot.js +129 -0
- package/lib/formats/evidence-matrix.js +87 -0
- package/lib/formats/executive-summary.js +130 -0
- package/lib/formats/github-issues.js +89 -0
- package/lib/formats/graphml.js +118 -0
- package/lib/formats/html-report.js +181 -0
- package/lib/formats/jira-csv.js +89 -0
- package/lib/formats/json-ld.js +28 -0
- package/lib/formats/markdown.js +118 -0
- package/lib/formats/ndjson.js +25 -0
- package/lib/formats/obsidian.js +136 -0
- package/lib/formats/opml.js +108 -0
- package/lib/formats/ris.js +70 -0
- package/lib/formats/rss.js +100 -0
- package/lib/formats/sankey.js +72 -0
- package/lib/formats/slide-deck.js +200 -0
- package/lib/formats/sql.js +116 -0
- package/lib/formats/static-site.js +169 -0
- package/lib/formats/treemap.js +65 -0
- package/lib/formats/typescript-defs.js +147 -0
- package/lib/formats/yaml.js +144 -0
- package/lib/formats.js +60 -0
- package/lib/index.js +14 -0
- package/lib/json-ld-common.js +72 -0
- package/lib/publishers/clipboard.js +70 -0
- package/lib/publishers/static.js +152 -0
- package/lib/serve-mcp.js +340 -0
- package/lib/server.js +535 -0
- package/package.json +53 -0
- package/public/grainulation-tokens.css +321 -0
- 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
|
+
}
|