docgrity 0.1.2
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/.github/workflows/ci.yml +54 -0
- package/.vscodeignore +13 -0
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/action/LICENSE +21 -0
- package/action/README.md +104 -0
- package/action/action.yml +30 -0
- package/action/bin/action.js +71 -0
- package/action/bin/docgrity.js +191 -0
- package/action/examples/docgrity.yml +61 -0
- package/action/package.json +38 -0
- package/action/src/corpus.js +110 -0
- package/action/src/issues.js +91 -0
- package/action/src/llm.js +216 -0
- package/action/src/prompts.js +65 -0
- package/action/src/report.js +281 -0
- package/action/src/scan.js +142 -0
- package/action/test/corpus.test.mjs +59 -0
- package/action/test/issues.test.mjs +89 -0
- package/action/test/report.test.mjs +76 -0
- package/docgrity_logo.png +0 -0
- package/image.png +0 -0
- package/media/icon.png +0 -0
- package/media/icon.svg +5 -0
- package/package.json +171 -0
- package/samples/api-limits.md +23 -0
- package/samples/architecture-notes.md +28 -0
- package/samples/deployment-guide.md +23 -0
- package/samples/integration-guide.md +21 -0
- package/samples/release-process.md +23 -0
- package/src/agents/assess.ts +187 -0
- package/src/agents/prompts.ts +94 -0
- package/src/agents/selectModel.ts +50 -0
- package/src/core/json.ts +58 -0
- package/src/core/prefilter.ts +56 -0
- package/src/core/slug.ts +10 -0
- package/src/core/verify.ts +15 -0
- package/src/extension.ts +142 -0
- package/src/findings/diagnostics.ts +78 -0
- package/src/findings/report.ts +68 -0
- package/src/findings/store.ts +60 -0
- package/src/findings/tree.ts +93 -0
- package/src/github/issues.ts +90 -0
- package/src/github/owners.ts +60 -0
- package/src/log.ts +22 -0
- package/src/scanner/candidates.ts +62 -0
- package/src/scanner/corpus.ts +75 -0
- package/src/scanner/scan.ts +215 -0
- package/test/candidates.test.ts +63 -0
- package/test/json.test.ts +87 -0
- package/test/prefilter.test.ts +65 -0
- package/test/slug.test.ts +31 -0
- package/test/verify.test.ts +47 -0
- package/tsconfig.json +15 -0
- package/vitest.config.mts +9 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static HTML report — the read-only dashboard. Shows findings, evidence,
|
|
3
|
+
* doc links and potential owners. Deliberately no action buttons: local and
|
|
4
|
+
* published reports are for observing and routing, not acting.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const esc = (s) =>
|
|
8
|
+
String(s ?? '')
|
|
9
|
+
.replace(/&/g, '&')
|
|
10
|
+
.replace(/</g, '<')
|
|
11
|
+
.replace(/>/g, '>')
|
|
12
|
+
.replace(/"/g, '"');
|
|
13
|
+
|
|
14
|
+
const TYPE_LABELS = {
|
|
15
|
+
contradiction: 'Contradictions',
|
|
16
|
+
duplicate: 'Duplicates',
|
|
17
|
+
open_question: 'Open questions',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/* Inline SVG icons in the Atlassian Design System style (24px grid, 2px
|
|
21
|
+
* stroke, rounded caps) — inlined so the report has zero dependencies and
|
|
22
|
+
* works offline / as a CI artifact. */
|
|
23
|
+
const ICONS = {
|
|
24
|
+
findings:
|
|
25
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M9 13h6M9 17h4"/></svg>',
|
|
26
|
+
duplicate:
|
|
27
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg>',
|
|
28
|
+
contradiction:
|
|
29
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/></svg>',
|
|
30
|
+
open_question:
|
|
31
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3M12 17h.01"/></svg>',
|
|
32
|
+
docs:
|
|
33
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
|
|
34
|
+
owner:
|
|
35
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
|
|
36
|
+
check:
|
|
37
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m8.5 12.5 2.5 2.5 5-6"/></svg>',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const icon = (name, cls = '') => `<span class="ic ${cls}" aria-hidden="true">${ICONS[name] ?? ''}</span>`;
|
|
41
|
+
|
|
42
|
+
const SEV_ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
|
|
43
|
+
|
|
44
|
+
export function renderReport({ findings, stats, repoSlug, branch }) {
|
|
45
|
+
const docUrl = (relPath) =>
|
|
46
|
+
repoSlug ? `https://github.com/${repoSlug}/blob/${branch || 'main'}/${relPath}` : null;
|
|
47
|
+
|
|
48
|
+
const counts = {};
|
|
49
|
+
for (const f of findings) counts[f.type] = (counts[f.type] ?? 0) + 1;
|
|
50
|
+
|
|
51
|
+
const sections = Object.entries(TYPE_LABELS)
|
|
52
|
+
.map(([type, label]) => {
|
|
53
|
+
const items = findings
|
|
54
|
+
.filter((f) => f.type === type)
|
|
55
|
+
.sort((a, b) => (SEV_ORDER[a.severity] ?? 9) - (SEV_ORDER[b.severity] ?? 9) || b.confidence - a.confidence);
|
|
56
|
+
return `
|
|
57
|
+
<section class="type-section" id="section-${type}" ${items.length === 0 ? 'hidden' : ''}>
|
|
58
|
+
<h2>${icon(type)} ${esc(label)} <span class="count">${items.length}</span></h2>
|
|
59
|
+
<div class="filters">Filter severity:
|
|
60
|
+
${['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
|
|
61
|
+
.map((s) => `<button class="sevbtn" data-type="${type}" data-sev="${s}">${s}</button>`)
|
|
62
|
+
.join(' ')}
|
|
63
|
+
</div>
|
|
64
|
+
<div class="tablewrap">
|
|
65
|
+
<table>
|
|
66
|
+
<thead><tr>
|
|
67
|
+
<th>Finding</th><th>Severity</th><th>Confidence</th><th>Docs</th><th>Potential owner</th><th>Action</th>
|
|
68
|
+
</tr></thead>
|
|
69
|
+
<tbody>
|
|
70
|
+
${items.map((f, i) => findingRows(f, type, i, docUrl)).join('\n')}
|
|
71
|
+
</tbody>
|
|
72
|
+
</table>
|
|
73
|
+
</div>
|
|
74
|
+
</section>`;
|
|
75
|
+
})
|
|
76
|
+
.join('\n');
|
|
77
|
+
|
|
78
|
+
return `<!doctype html>
|
|
79
|
+
<html lang="en">
|
|
80
|
+
<head>
|
|
81
|
+
<meta charset="utf-8">
|
|
82
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
83
|
+
<title>Docgrity report</title>
|
|
84
|
+
<style>
|
|
85
|
+
:root { --blue:#1558bc; --blue-dark:#0d3d8a; --teal:#16a394; --muted:#5b6470; --bg:#f4f6fa; --card:#fff; --border:#e3e8ef;
|
|
86
|
+
--crit:#c62828; --high:#e65100; --med:#a07d00; --low:#5b6470;
|
|
87
|
+
--good:#1d7a3e; --good-bg:#e8f6ed; --warn-bg:#fdecea; --dup-bg:#fff4e0; --oq-bg:#eef3fd; }
|
|
88
|
+
* { box-sizing: border-box; }
|
|
89
|
+
body { margin:0; font:15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
90
|
+
background:var(--bg); color:#1a2230; }
|
|
91
|
+
.wrap { max-width: 1080px; margin: 0 auto; padding: 0 20px 60px; }
|
|
92
|
+
.hero { background:linear-gradient(120deg, var(--blue-dark), var(--blue) 60%, var(--teal)); color:#fff;
|
|
93
|
+
margin: 0 -20px; padding: 30px 28px 26px; }
|
|
94
|
+
.hero h1 { font-size: 1.5rem; margin: 0 0 4px; font-weight: 700; }
|
|
95
|
+
.hero .meta { color: rgba(255,255,255,.85); font-size: .87rem; }
|
|
96
|
+
.ic { display:inline-flex; width:20px; height:20px; vertical-align:-4px; }
|
|
97
|
+
.ic svg { width:100%; height:100%; }
|
|
98
|
+
.stats { display:grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap:14px; margin: -22px 0 26px; }
|
|
99
|
+
.stat { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:14px 16px;
|
|
100
|
+
box-shadow: 0 2px 8px rgba(16,30,54,.06); display:flex; align-items:flex-start; justify-content:space-between;
|
|
101
|
+
cursor:pointer; text-align:left; font:inherit; color:inherit; transition: box-shadow .15s, transform .15s; }
|
|
102
|
+
.stat:hover { box-shadow: 0 4px 14px rgba(16,30,54,.14); transform: translateY(-1px); }
|
|
103
|
+
.stat.active { outline: 2px solid var(--blue); }
|
|
104
|
+
.stat .label { color:var(--muted); font-size:.82rem; font-weight:600; }
|
|
105
|
+
.stat .num { font-size:1.75rem; font-weight:800; line-height:1.15; }
|
|
106
|
+
.stat .sub { color:var(--blue); font-size:.76rem; font-weight:600; }
|
|
107
|
+
.badge { width:38px; height:38px; border-radius:10px; display:flex; align-items:center; justify-content:center; flex:none; }
|
|
108
|
+
.badge .ic { width:22px; height:22px; }
|
|
109
|
+
.badge.findings { background:#e7eefb; color:var(--blue); }
|
|
110
|
+
.badge.duplicate { background:var(--dup-bg); color:var(--high); }
|
|
111
|
+
.badge.contradiction { background:var(--warn-bg); color:var(--crit); }
|
|
112
|
+
.badge.open_question { background:var(--oq-bg); color:var(--blue); }
|
|
113
|
+
.badge.ok { background:var(--good-bg); color:var(--good); }
|
|
114
|
+
h2 { font-size: 1.12rem; margin: 32px 0 10px; display:flex; align-items:center; gap:8px; }
|
|
115
|
+
h2 .ic { color:var(--muted); }
|
|
116
|
+
.count { background:#e7eefb; color:var(--blue); font-size:.78rem; font-weight:700; border-radius:999px; padding:1px 10px; }
|
|
117
|
+
.filters { color:var(--muted); font-size:.85rem; margin-bottom:10px; }
|
|
118
|
+
.sevbtn { font:inherit; font-size:.75rem; font-weight:700; letter-spacing:.03em; padding:3px 12px; margin-left:4px;
|
|
119
|
+
border:1px solid var(--border); border-radius:8px; background:var(--card); color:#1a2230; cursor:pointer; }
|
|
120
|
+
.sevbtn.on { border-color: var(--blue); background:#e7eefb; color: var(--blue); }
|
|
121
|
+
.tablewrap { background:var(--card); border:1px solid var(--border); border-radius:12px; overflow:hidden;
|
|
122
|
+
box-shadow: 0 1px 4px rgba(16,30,54,.05); }
|
|
123
|
+
table { width:100%; border-collapse: collapse; font-size:.9rem; }
|
|
124
|
+
th { text-align:left; color:var(--muted); font-size:.78rem; font-weight:600; padding:10px 14px;
|
|
125
|
+
border-bottom:1px solid var(--border); }
|
|
126
|
+
td { padding:12px 14px; border-bottom:1px solid var(--border); vertical-align:top; }
|
|
127
|
+
tr.frow { cursor:pointer; }
|
|
128
|
+
tr.frow:hover { background:#f7f9fc; }
|
|
129
|
+
tr:last-child td { border-bottom:none; }
|
|
130
|
+
.sev { display:inline-block; font-size:.72rem; font-weight:700; letter-spacing:.04em; padding:2px 8px;
|
|
131
|
+
border-radius:999px; color:#fff; }
|
|
132
|
+
.sev.CRITICAL{background:var(--crit)} .sev.HIGH{background:var(--high)} .sev.MEDIUM{background:var(--med)} .sev.LOW{background:var(--low)}
|
|
133
|
+
.docpath { font-family: ui-monospace, monospace; font-size:.8rem; display:block; color:var(--blue); text-decoration:none; }
|
|
134
|
+
span.docpath { color:#1a2230; }
|
|
135
|
+
.owner { display:inline-flex; align-items:center; gap:5px; background:#eef3fd; color:var(--blue-dark);
|
|
136
|
+
border-radius:999px; padding:2px 10px 2px 4px; font-size:.8rem; font-weight:600; margin:1px 2px 1px 0; }
|
|
137
|
+
.avatar { width:20px; height:20px; border-radius:50%; background:var(--blue); color:#fff; display:inline-flex;
|
|
138
|
+
align-items:center; justify-content:center; font-size:.66rem; font-weight:700; flex:none; }
|
|
139
|
+
.action { font-size:.78rem; font-weight:700; color:var(--muted); letter-spacing:.03em; }
|
|
140
|
+
tr.detail { display:none; background:#fafbfd; }
|
|
141
|
+
tr.detail.open { display:table-row; }
|
|
142
|
+
blockquote { margin: 8px 0; padding: 8px 12px; border-left: 3px solid var(--teal); background:#f0faf8;
|
|
143
|
+
font-size:.88rem; border-radius: 0 8px 8px 0; }
|
|
144
|
+
blockquote .src { display:block; color:var(--muted); font-size:.78rem; font-family: ui-monospace, monospace; margin-top:4px; }
|
|
145
|
+
.fine { color:var(--muted); font-size:.75rem; margin-top:6px; }
|
|
146
|
+
.empty { background:var(--good-bg); border:1px solid #cdebd8; color:var(--good); border-radius:12px;
|
|
147
|
+
padding:18px 20px; display:flex; align-items:center; gap:10px; font-weight:600; }
|
|
148
|
+
.chev { color:var(--muted); font-size:.75rem; margin-right:6px; display:inline-block; transition: transform .15s; }
|
|
149
|
+
tr.frow.open .chev { transform: rotate(90deg); }
|
|
150
|
+
footer { margin-top:40px; color:var(--muted); font-size:.8rem; }
|
|
151
|
+
</style>
|
|
152
|
+
</head>
|
|
153
|
+
<body>
|
|
154
|
+
<div class="wrap">
|
|
155
|
+
<div class="hero">
|
|
156
|
+
<h1>Docgrity — documentation-integrity report</h1>
|
|
157
|
+
<div class="meta">${esc(repoSlug ?? 'local scan')} · scanned ${esc(stats.scannedAt)} · ${stats.docs} markdown docs · ${stats.pairs} pairs assessed</div>
|
|
158
|
+
</div>
|
|
159
|
+
<div class="stats">
|
|
160
|
+
<button class="stat" data-target="all"><div><div class="label">Open findings</div><div class="num">${findings.length}</div><div class="sub">view all</div></div><div class="badge ${findings.length ? 'findings' : 'ok'}">${icon(findings.length ? 'findings' : 'check')}</div></button>
|
|
161
|
+
<button class="stat" data-target="duplicate"><div><div class="label">Duplicates</div><div class="num">${counts.duplicate ?? 0}</div><div class="sub">view duplicates</div></div><div class="badge duplicate">${icon('duplicate')}</div></button>
|
|
162
|
+
<button class="stat" data-target="contradiction"><div><div class="label">Contradictions</div><div class="num">${counts.contradiction ?? 0}</div><div class="sub">view contradictions</div></div><div class="badge contradiction">${icon('contradiction')}</div></button>
|
|
163
|
+
<button class="stat" data-target="open_question"><div><div class="label">Open questions</div><div class="num">${counts.open_question ?? 0}</div><div class="sub">view questions</div></div><div class="badge open_question">${icon('open_question')}</div></button>
|
|
164
|
+
</div>
|
|
165
|
+
${findings.length === 0 ? `<div class="empty">${icon('check')} No findings — your docs agree with themselves.</div>` : sections}
|
|
166
|
+
<footer>Read-only report. Ownership is inferred from git history and is always <em>potential</em>, never asserted.
|
|
167
|
+
Generated by Docgrity.</footer>
|
|
168
|
+
</div>
|
|
169
|
+
<script>
|
|
170
|
+
(function () {
|
|
171
|
+
// Widget click -> show that type's rows (or all), scroll to it.
|
|
172
|
+
var stats = document.querySelectorAll('.stat');
|
|
173
|
+
stats.forEach(function (btn) {
|
|
174
|
+
btn.addEventListener('click', function () {
|
|
175
|
+
var target = btn.getAttribute('data-target');
|
|
176
|
+
stats.forEach(function (b) { b.classList.toggle('active', b === btn); });
|
|
177
|
+
document.querySelectorAll('.type-section').forEach(function (sec) {
|
|
178
|
+
var type = sec.id.replace('section-', '');
|
|
179
|
+
var has = sec.querySelector('tbody tr');
|
|
180
|
+
sec.hidden = !has || (target !== 'all' && type !== target);
|
|
181
|
+
});
|
|
182
|
+
var first = document.querySelector('.type-section:not([hidden])');
|
|
183
|
+
if (first) first.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Row click -> expand/collapse evidence detail.
|
|
188
|
+
document.querySelectorAll('tr.frow').forEach(function (row) {
|
|
189
|
+
row.addEventListener('click', function (e) {
|
|
190
|
+
if (e.target.closest('a')) return; // let links work
|
|
191
|
+
row.classList.toggle('open');
|
|
192
|
+
var detail = document.getElementById(row.getAttribute('data-detail'));
|
|
193
|
+
if (detail) detail.classList.toggle('open');
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Severity filter buttons (toggle; none active = show all).
|
|
198
|
+
document.querySelectorAll('.sevbtn').forEach(function (btn) {
|
|
199
|
+
btn.addEventListener('click', function () {
|
|
200
|
+
btn.classList.toggle('on');
|
|
201
|
+
var type = btn.getAttribute('data-type');
|
|
202
|
+
var active = Array.prototype.map.call(
|
|
203
|
+
document.querySelectorAll('.sevbtn.on[data-type="' + type + '"]'),
|
|
204
|
+
function (b) { return b.getAttribute('data-sev'); }
|
|
205
|
+
);
|
|
206
|
+
document.querySelectorAll('#section-' + type + ' tr.frow').forEach(function (row) {
|
|
207
|
+
var show = active.length === 0 || active.indexOf(row.getAttribute('data-sev')) !== -1;
|
|
208
|
+
row.style.display = show ? '' : 'none';
|
|
209
|
+
if (!show) {
|
|
210
|
+
row.classList.remove('open');
|
|
211
|
+
var d = document.getElementById(row.getAttribute('data-detail'));
|
|
212
|
+
if (d) d.classList.remove('open');
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
})();
|
|
218
|
+
</script>
|
|
219
|
+
</body>
|
|
220
|
+
</html>`;
|
|
221
|
+
|
|
222
|
+
function findingRows(f, type, i, docUrlFn) {
|
|
223
|
+
const rowId = `${type}-${i}`;
|
|
224
|
+
const fileLinks = f.files
|
|
225
|
+
.map((p) => {
|
|
226
|
+
const url = docUrlFn(p);
|
|
227
|
+
return url
|
|
228
|
+
? `<a class="docpath" href="${esc(url)}" rel="noopener">${esc(p)}</a>`
|
|
229
|
+
: `<span class="docpath">${esc(p)}</span>`;
|
|
230
|
+
})
|
|
231
|
+
.join('');
|
|
232
|
+
const owners = f.potentialOwners.length
|
|
233
|
+
? f.potentialOwners
|
|
234
|
+
.map((o) => {
|
|
235
|
+
const initials = o.trim().split(/\s+/).map((w) => w[0]).join('').slice(0, 2).toUpperCase();
|
|
236
|
+
return `<span class="owner"><span class="avatar">${esc(initials)}</span>${esc(o)}</span>`;
|
|
237
|
+
})
|
|
238
|
+
.join(' ')
|
|
239
|
+
: '<span class="owner"><span class="avatar">?</span>unknown</span>';
|
|
240
|
+
const action = esc(f.detail?.recommended_action ?? 'REVIEW');
|
|
241
|
+
const claims = Array.isArray(f.detail?.conflicting_claims) && f.detail.conflicting_claims.length
|
|
242
|
+
? `<ul>${f.detail.conflicting_claims.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>`
|
|
243
|
+
: '';
|
|
244
|
+
return `<tr class="frow" data-sev="${esc(f.severity)}" data-detail="detail-${rowId}">
|
|
245
|
+
<td><span class="chev">▶</span>${esc(f.summary)}</td>
|
|
246
|
+
<td><span class="sev ${esc(f.severity)}">${esc(f.severity)}</span></td>
|
|
247
|
+
<td>${(f.confidence * 100).toFixed(0)}%</td>
|
|
248
|
+
<td>${fileLinks}</td>
|
|
249
|
+
<td>${owners}</td>
|
|
250
|
+
<td><span class="action">${action}</span></td>
|
|
251
|
+
</tr>
|
|
252
|
+
<tr class="detail" id="detail-${rowId}"><td colspan="6">
|
|
253
|
+
${claims}
|
|
254
|
+
${f.evidence
|
|
255
|
+
.map((e) => `<blockquote>${esc(e.excerpt)}<span class="src">${esc(e.sourceLabel)}</span></blockquote>`)
|
|
256
|
+
.join('')}
|
|
257
|
+
${f.issueUrl ? `<div class="fine"><a href="${esc(f.issueUrl)}" rel="noopener">View raised issue</a></div>` : ''}
|
|
258
|
+
<div class="fine">fingerprint ${esc(f.fingerprint)} · model ${esc(f.model)} · prompt ${esc(f.promptVersion)}</div>
|
|
259
|
+
</td></tr>`;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function renderSummaryMarkdown({ findings, stats }) {
|
|
264
|
+
const lines = [
|
|
265
|
+
'## Docgrity doc-integrity scan',
|
|
266
|
+
'',
|
|
267
|
+
`Scanned **${stats.docs}** markdown docs (${stats.pairs} candidate pairs) at ${stats.scannedAt}.`,
|
|
268
|
+
'',
|
|
269
|
+
];
|
|
270
|
+
if (findings.length === 0) {
|
|
271
|
+
lines.push('No findings — docs agree with themselves.');
|
|
272
|
+
return lines.join('\n');
|
|
273
|
+
}
|
|
274
|
+
lines.push('| Type | Severity | Confidence | Summary | Files |', '|---|---|---|---|---|');
|
|
275
|
+
for (const f of findings) {
|
|
276
|
+
lines.push(
|
|
277
|
+
`| ${f.type} | ${f.severity} | ${(f.confidence * 100).toFixed(0)}% | ${f.summary.replace(/\|/g, '\\|').slice(0, 120)} | ${f.files.join('<br>')} |`
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return lines.join('\n');
|
|
281
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scan orchestrator — deterministic loop, LLM only for semantic assessment.
|
|
3
|
+
* Evidence excerpts are verified verbatim against sources (hallucination guard).
|
|
4
|
+
* Each finding gets a stable fingerprint for issue deduplication and trends.
|
|
5
|
+
*/
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
import { collectCorpus, selectCandidatePairs, potentialOwner } from './corpus.js';
|
|
8
|
+
import { assessContradiction, assessDuplicate, assessOpenQuestions } from './llm.js';
|
|
9
|
+
|
|
10
|
+
const norm = (s) => s.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
11
|
+
|
|
12
|
+
function verifyExcerpts(excerpts, docs) {
|
|
13
|
+
const haystacks = docs.map((d) => norm(d.text));
|
|
14
|
+
return excerpts.every((e) => {
|
|
15
|
+
const needle = norm(e.excerpt).slice(0, 200);
|
|
16
|
+
return needle.length > 0 && haystacks.some((h) => h.includes(needle));
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Stable across runs: type + files + first evidence excerpt (normalized). */
|
|
21
|
+
function fingerprint(type, files, firstExcerpt) {
|
|
22
|
+
return crypto
|
|
23
|
+
.createHash('sha256')
|
|
24
|
+
.update(`${type}|${[...files].sort().join('|')}|${norm(firstExcerpt).slice(0, 120)}`)
|
|
25
|
+
.digest('hex')
|
|
26
|
+
.slice(0, 16);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function runScan(root, opts = {}) {
|
|
30
|
+
const {
|
|
31
|
+
client,
|
|
32
|
+
maxFiles = 200,
|
|
33
|
+
maxPairs = 25,
|
|
34
|
+
thresholds = { duplicate: 0.75, contradiction: 0.7, openQuestion: 0.6 },
|
|
35
|
+
checks = { duplicates: true, contradictions: true, openQuestions: true },
|
|
36
|
+
log = () => {},
|
|
37
|
+
} = opts;
|
|
38
|
+
|
|
39
|
+
const docs = await collectCorpus(root, { maxFiles });
|
|
40
|
+
// Pair selection is only needed for the pairwise checks.
|
|
41
|
+
const pairs = checks.duplicates || checks.contradictions ? selectCandidatePairs(docs, maxPairs) : [];
|
|
42
|
+
log(`Corpus: ${docs.length} markdown docs; assessing ${pairs.length} candidate pairs`);
|
|
43
|
+
const findings = [];
|
|
44
|
+
const now = new Date().toISOString();
|
|
45
|
+
|
|
46
|
+
const owners = async (ds) => {
|
|
47
|
+
const set = new Set();
|
|
48
|
+
for (const d of ds) {
|
|
49
|
+
const o = await potentialOwner(root, d.relPath);
|
|
50
|
+
if (o) set.add(o);
|
|
51
|
+
}
|
|
52
|
+
return [...set];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
let i = 0;
|
|
56
|
+
for (const { a, b } of pairs) {
|
|
57
|
+
i++;
|
|
58
|
+
log(`Pair ${i}/${pairs.length}: ${a.relPath} <-> ${b.relPath}`);
|
|
59
|
+
const dup = checks.duplicates ? await assessDuplicate(client, a, b) : null;
|
|
60
|
+
if (
|
|
61
|
+
dup &&
|
|
62
|
+
dup.output.is_duplicate &&
|
|
63
|
+
dup.output.confidence >= thresholds.duplicate &&
|
|
64
|
+
verifyExcerpts(dup.output.evidence, [a, b])
|
|
65
|
+
) {
|
|
66
|
+
const files = [a.relPath, b.relPath];
|
|
67
|
+
findings.push({
|
|
68
|
+
fingerprint: fingerprint('duplicate', files, dup.output.evidence[0]?.excerpt ?? dup.output.summary),
|
|
69
|
+
type: 'duplicate',
|
|
70
|
+
severity: 'MEDIUM',
|
|
71
|
+
confidence: dup.output.confidence,
|
|
72
|
+
summary: dup.output.summary,
|
|
73
|
+
detail: { recommended_action: dup.output.recommended_action },
|
|
74
|
+
evidence: dup.output.evidence.map((e) => ({
|
|
75
|
+
sourceLabel: e.page === 'A' ? a.relPath : b.relPath,
|
|
76
|
+
excerpt: e.excerpt,
|
|
77
|
+
})),
|
|
78
|
+
files,
|
|
79
|
+
potentialOwners: await owners([a, b]),
|
|
80
|
+
model: dup.model,
|
|
81
|
+
promptVersion: dup.promptVersion,
|
|
82
|
+
createdAt: now,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const con = checks.contradictions ? await assessContradiction(client, a, b) : null;
|
|
87
|
+
if (
|
|
88
|
+
con &&
|
|
89
|
+
con.output.is_contradiction &&
|
|
90
|
+
con.output.confidence >= thresholds.contradiction &&
|
|
91
|
+
verifyExcerpts(con.output.evidence, [a, b])
|
|
92
|
+
) {
|
|
93
|
+
const files = [a.relPath, b.relPath];
|
|
94
|
+
findings.push({
|
|
95
|
+
fingerprint: fingerprint('contradiction', files, con.output.evidence[0]?.excerpt ?? con.output.summary),
|
|
96
|
+
type: 'contradiction',
|
|
97
|
+
severity: con.output.severity,
|
|
98
|
+
confidence: con.output.confidence,
|
|
99
|
+
summary: con.output.summary,
|
|
100
|
+
detail: { conflicting_claims: con.output.conflicting_claims },
|
|
101
|
+
evidence: con.output.evidence.map((e) => ({
|
|
102
|
+
sourceLabel: e.page === 'A' ? a.relPath : b.relPath,
|
|
103
|
+
excerpt: e.excerpt,
|
|
104
|
+
})),
|
|
105
|
+
files,
|
|
106
|
+
potentialOwners: await owners([a, b]),
|
|
107
|
+
model: con.model,
|
|
108
|
+
promptVersion: con.promptVersion,
|
|
109
|
+
createdAt: now,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let j = 0;
|
|
115
|
+
const oqDocs = checks.openQuestions ? docs : [];
|
|
116
|
+
for (const doc of oqDocs) {
|
|
117
|
+
j++;
|
|
118
|
+
log(`Open questions ${j}/${oqDocs.length}: ${doc.relPath}`);
|
|
119
|
+
const oq = await assessOpenQuestions(client, doc);
|
|
120
|
+
const kept = oq.output.questions.filter(
|
|
121
|
+
(q) => q.confidence >= thresholds.openQuestion && verifyExcerpts([q], [doc])
|
|
122
|
+
);
|
|
123
|
+
if (kept.length > 0) {
|
|
124
|
+
findings.push({
|
|
125
|
+
fingerprint: fingerprint('open_question', [doc.relPath], kept[0].excerpt),
|
|
126
|
+
type: 'open_question',
|
|
127
|
+
severity: kept.some((q) => q.severity === 'HIGH') ? 'HIGH' : 'MEDIUM',
|
|
128
|
+
confidence: Math.max(...kept.map((q) => q.confidence)),
|
|
129
|
+
summary: `${kept.length} unresolved question(s) in ${doc.relPath}`,
|
|
130
|
+
detail: { questions: kept },
|
|
131
|
+
evidence: kept.map((q) => ({ sourceLabel: doc.relPath, excerpt: q.excerpt })),
|
|
132
|
+
files: [doc.relPath],
|
|
133
|
+
potentialOwners: await owners([doc]),
|
|
134
|
+
model: oq.model,
|
|
135
|
+
promptVersion: oq.promptVersion,
|
|
136
|
+
createdAt: now,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { findings, stats: { docs: docs.length, pairs: pairs.length, scannedAt: now } };
|
|
142
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** corpus.js tests — md-only collection, TF-IDF pairing, owner inference. */
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { collectCorpus, selectCandidatePairs, githubRepoSlug } from '../src/corpus.js';
|
|
8
|
+
|
|
9
|
+
const doc = (relPath, text) => ({ relPath, text, hash: relPath });
|
|
10
|
+
|
|
11
|
+
test('selectCandidatePairs pairs similar docs, ignores unrelated ones', () => {
|
|
12
|
+
const a = doc('a.md', 'deployment guide for the payments service using canary rollout and grafana dashboards '.repeat(4));
|
|
13
|
+
const b = doc('b.md', 'release process for the payments service with canary rollout watched in grafana '.repeat(4));
|
|
14
|
+
const c = doc('c.md', 'chocolate cake recipe flour sugar eggs butter vanilla oven baking whisk frosting '.repeat(4));
|
|
15
|
+
const pairs = selectCandidatePairs([a, b, c]);
|
|
16
|
+
assert.ok(pairs.some((p) => p.a.relPath === 'a.md' && p.b.relPath === 'b.md'), 'similar docs must pair');
|
|
17
|
+
assert.ok(!pairs.some((p) => p.a.relPath === 'c.md' || p.b.relPath === 'c.md'), 'unrelated doc must not pair');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('selectCandidatePairs strips code blocks before comparing', () => {
|
|
21
|
+
const code = '```\nconst deploy = canary(grafana, rollout, payments, service);\n```';
|
|
22
|
+
const a = doc('a.md', `${code} completely unrelated prose about gardening tulips soil watering sunlight`.repeat(3));
|
|
23
|
+
const b = doc('b.md', `${code} astronomy telescope galaxy nebula orbit planets observation stars`.repeat(3));
|
|
24
|
+
const pairs = selectCandidatePairs([a, b]);
|
|
25
|
+
assert.equal(pairs.length, 0, 'shared code blocks alone must not create a pair');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('selectCandidatePairs respects maxPairs cap', () => {
|
|
29
|
+
const docs = Array.from({ length: 6 }, (_, i) =>
|
|
30
|
+
doc(`d${i}.md`, 'payments service deployment canary rollout grafana monitoring alerts '.repeat(4))
|
|
31
|
+
);
|
|
32
|
+
assert.ok(selectCandidatePairs(docs, 3).length <= 3);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('collectCorpus: markdown only, skips ignored dirs and trivial files', async () => {
|
|
36
|
+
const root = await mkdtemp(path.join(tmpdir(), 'docgrity-'));
|
|
37
|
+
try {
|
|
38
|
+
const big = 'This document is long enough to be included in the corpus. '.repeat(3);
|
|
39
|
+
await writeFile(path.join(root, 'keep.md'), big);
|
|
40
|
+
await writeFile(path.join(root, 'skip.txt'), big);
|
|
41
|
+
await writeFile(path.join(root, 'tiny.md'), 'too short');
|
|
42
|
+
await mkdir(path.join(root, 'node_modules'), { recursive: true });
|
|
43
|
+
await writeFile(path.join(root, 'node_modules', 'dep.md'), big);
|
|
44
|
+
const docs = await collectCorpus(root);
|
|
45
|
+
assert.deepEqual(docs.map((d) => d.relPath), ['keep.md']);
|
|
46
|
+
assert.match(docs[0].hash, /^[0-9a-f]{64}$/);
|
|
47
|
+
} finally {
|
|
48
|
+
await rm(root, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('githubRepoSlug returns undefined outside a git repo', async () => {
|
|
53
|
+
const root = await mkdtemp(path.join(tmpdir(), 'docgrity-'));
|
|
54
|
+
try {
|
|
55
|
+
assert.equal(await githubRepoSlug(root), undefined);
|
|
56
|
+
} finally {
|
|
57
|
+
await rm(root, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** issues.js tests — dedupe by fingerprint, close resolved, cap creation.
|
|
2
|
+
* GitHub API is mocked via global fetch; the LLM client is a stub. */
|
|
3
|
+
import { test, beforeEach } from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { syncIssues } from '../src/issues.js';
|
|
6
|
+
|
|
7
|
+
const SLUG = 'me/repo';
|
|
8
|
+
const MARKER = (fp) => `<!-- docgrity:fingerprint:${fp} -->`;
|
|
9
|
+
|
|
10
|
+
const stubClient = {
|
|
11
|
+
model: 'stub',
|
|
12
|
+
async complete() {
|
|
13
|
+
return JSON.stringify({ title: 'Drafted title', body: 'Drafted body' });
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const finding = (fp, over = {}) => ({
|
|
18
|
+
fingerprint: fp,
|
|
19
|
+
type: 'duplicate',
|
|
20
|
+
severity: 'MEDIUM',
|
|
21
|
+
confidence: 0.8,
|
|
22
|
+
summary: 'dup',
|
|
23
|
+
evidence: [{ sourceLabel: 'a.md', excerpt: 'x' }],
|
|
24
|
+
files: ['a.md'],
|
|
25
|
+
potentialOwners: [],
|
|
26
|
+
model: 'stub',
|
|
27
|
+
promptVersion: 'v1',
|
|
28
|
+
...over,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
let calls;
|
|
32
|
+
let openIssues;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
calls = [];
|
|
36
|
+
openIssues = [];
|
|
37
|
+
let nextNumber = 100;
|
|
38
|
+
globalThis.fetch = async (url, init = {}) => {
|
|
39
|
+
const method = init.method ?? 'GET';
|
|
40
|
+
const u = new URL(url);
|
|
41
|
+
calls.push({ method, path: u.pathname, body: init.body ? JSON.parse(init.body) : null });
|
|
42
|
+
assert.match(init.headers.Authorization, /^Bearer /, 'token must be a bearer header');
|
|
43
|
+
if (method === 'GET') return { ok: true, status: 200, json: async () => openIssues };
|
|
44
|
+
if (method === 'POST' && u.pathname.endsWith('/issues')) {
|
|
45
|
+
const n = nextNumber++;
|
|
46
|
+
return { ok: true, status: 201, json: async () => ({ number: n, html_url: `https://github.com/${SLUG}/issues/${n}` }) };
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, status: 200, json: async () => ({}) };
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('creates issues for new findings with fingerprint marker and labels', async () => {
|
|
53
|
+
const f = finding('aaaaaaaaaaaaaaaa');
|
|
54
|
+
const result = await syncIssues({ client: stubClient, token: 't', slug: SLUG, findings: [f] });
|
|
55
|
+
assert.equal(result.created.length, 1);
|
|
56
|
+
const create = calls.find((c) => c.method === 'POST' && c.path === `/repos/${SLUG}/issues`);
|
|
57
|
+
assert.ok(create.body.body.includes(MARKER('aaaaaaaaaaaaaaaa')), 'marker must be embedded');
|
|
58
|
+
assert.deepEqual(create.body.labels, ['docgrity', 'docgrity:duplicate']);
|
|
59
|
+
assert.ok(f.issueUrl, 'finding gets its issue url');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('dedupes: existing fingerprint means no new issue', async () => {
|
|
63
|
+
openIssues = [{ number: 7, html_url: 'u', body: `text ${MARKER('bbbbbbbbbbbbbbbb')}` }];
|
|
64
|
+
const result = await syncIssues({
|
|
65
|
+
client: stubClient,
|
|
66
|
+
token: 't',
|
|
67
|
+
slug: SLUG,
|
|
68
|
+
findings: [finding('bbbbbbbbbbbbbbbb')],
|
|
69
|
+
});
|
|
70
|
+
assert.equal(result.created.length, 0);
|
|
71
|
+
assert.equal(result.unchanged, 1);
|
|
72
|
+
assert.ok(!calls.some((c) => c.method === 'POST' && c.path === `/repos/${SLUG}/issues`));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('closes issues whose finding is resolved', async () => {
|
|
76
|
+
openIssues = [{ number: 9, html_url: 'u', body: MARKER('cccccccccccccccc') }];
|
|
77
|
+
const result = await syncIssues({ client: stubClient, token: 't', slug: SLUG, findings: [] });
|
|
78
|
+
assert.deepEqual(result.closed, [9]);
|
|
79
|
+
const patch = calls.find((c) => c.method === 'PATCH');
|
|
80
|
+
assert.deepEqual(patch.body, { state: 'closed' });
|
|
81
|
+
assert.ok(calls.some((c) => c.path.endsWith('/9/comments')), 'closing comment posted');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('caps new issues per run', async () => {
|
|
85
|
+
const findings = ['1111111111111111', '2222222222222222', '3333333333333333'].map(finding);
|
|
86
|
+
const result = await syncIssues({ client: stubClient, token: 't', slug: SLUG, findings, maxNewIssues: 2 });
|
|
87
|
+
assert.equal(result.created.length, 2);
|
|
88
|
+
assert.equal(result.skipped, 1);
|
|
89
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** report.js tests — dashboard rendering, XSS escaping, empty state. */
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { renderReport, renderSummaryMarkdown } from '../src/report.js';
|
|
5
|
+
|
|
6
|
+
const stats = { docs: 5, pairs: 9, scannedAt: '2026-09-05T00:00:00Z' };
|
|
7
|
+
|
|
8
|
+
const finding = (over = {}) => ({
|
|
9
|
+
fingerprint: 'abcd1234abcd1234',
|
|
10
|
+
type: 'contradiction',
|
|
11
|
+
severity: 'HIGH',
|
|
12
|
+
confidence: 0.91,
|
|
13
|
+
summary: 'Docs disagree on rate limits',
|
|
14
|
+
detail: { conflicting_claims: ['100 rpm', '1000 rpm'] },
|
|
15
|
+
evidence: [{ sourceLabel: 'a.md', excerpt: 'limited to 100 requests' }],
|
|
16
|
+
files: ['a.md', 'b.md'],
|
|
17
|
+
potentialOwners: ['alice'],
|
|
18
|
+
model: 'gemini:test',
|
|
19
|
+
promptVersion: 'v1',
|
|
20
|
+
createdAt: '2026-09-05T00:00:00Z',
|
|
21
|
+
...over,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('renderReport shows stat cards with correct counts', () => {
|
|
25
|
+
const html = renderReport({
|
|
26
|
+
findings: [finding(), finding({ type: 'duplicate', fingerprint: 'ffff0000ffff0000' })],
|
|
27
|
+
stats,
|
|
28
|
+
});
|
|
29
|
+
assert.match(html, /Open findings/);
|
|
30
|
+
assert.match(html, /<div class="num">2<\/div>/); // total
|
|
31
|
+
assert.match(html, /Duplicates/);
|
|
32
|
+
assert.match(html, /Contradictions/);
|
|
33
|
+
assert.match(html, /Open questions/);
|
|
34
|
+
assert.match(html, /class="ic/); // icons present
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('renderReport escapes HTML everywhere (XSS guard)', () => {
|
|
38
|
+
const html = renderReport({
|
|
39
|
+
findings: [
|
|
40
|
+
finding({
|
|
41
|
+
summary: '<script>alert(1)</script>',
|
|
42
|
+
files: ['<img src=x onerror=1>.md'],
|
|
43
|
+
evidence: [{ sourceLabel: '"><svg>', excerpt: '<iframe>' }],
|
|
44
|
+
potentialOwners: ['<b>owner</b>'],
|
|
45
|
+
detail: { conflicting_claims: ['<script>x</script>'] },
|
|
46
|
+
}),
|
|
47
|
+
],
|
|
48
|
+
stats,
|
|
49
|
+
});
|
|
50
|
+
assert.ok(!html.includes('<script>alert(1)</script>'));
|
|
51
|
+
assert.ok(!html.includes('<img src=x'));
|
|
52
|
+
assert.ok(!html.includes('<iframe>'));
|
|
53
|
+
assert.ok(!html.includes('<b>owner</b>'));
|
|
54
|
+
assert.ok(html.includes('<script>alert(1)</script>'));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('renderReport links files only when repoSlug present', () => {
|
|
58
|
+
const withSlug = renderReport({ findings: [finding()], stats, repoSlug: 'me/repo', branch: 'main' });
|
|
59
|
+
assert.match(withSlug, /https:\/\/github\.com\/me\/repo\/blob\/main\/a\.md/);
|
|
60
|
+
const noSlug = renderReport({ findings: [finding()], stats });
|
|
61
|
+
assert.ok(!noSlug.includes('github.com/me/repo'));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('renderReport shows friendly empty state', () => {
|
|
65
|
+
const html = renderReport({ findings: [], stats });
|
|
66
|
+
assert.match(html, /No findings/);
|
|
67
|
+
assert.match(html, /class="empty"/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('renderSummaryMarkdown escapes pipes and lists findings', () => {
|
|
71
|
+
const md = renderSummaryMarkdown({ findings: [finding({ summary: 'a | b' })], stats });
|
|
72
|
+
assert.match(md, /a \\\| b/);
|
|
73
|
+
assert.match(md, /\| contradiction \| HIGH \| 91% \|/);
|
|
74
|
+
const empty = renderSummaryMarkdown({ findings: [], stats });
|
|
75
|
+
assert.match(empty, /No findings/);
|
|
76
|
+
});
|
|
Binary file
|
package/image.png
ADDED
|
Binary file
|
package/media/icon.png
ADDED
|
Binary file
|
package/media/icon.svg
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
3
|
+
<path d="M14 2v6h6"/>
|
|
4
|
+
<path d="M9 15l2 2 4-4"/>
|
|
5
|
+
</svg>
|