@bugb-technologies/bravos 0.0.0-stage → 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 (85) hide show
  1. package/LICENSE +70 -0
  2. package/README.md +225 -2
  3. package/dist/account.js +140 -0
  4. package/dist/artifacts.js +450 -0
  5. package/dist/ci.js +52 -0
  6. package/dist/cli.js +963 -0
  7. package/dist/codegraph.js +268 -0
  8. package/dist/cursor.js +70 -0
  9. package/dist/detect.js +26 -0
  10. package/dist/doctor.js +99 -0
  11. package/dist/global.js +95 -0
  12. package/dist/graphbin.js +136 -0
  13. package/dist/guardlink.js +123 -0
  14. package/dist/guide.js +255 -0
  15. package/dist/help.js +334 -0
  16. package/dist/hooks.js +167 -0
  17. package/dist/install.js +209 -0
  18. package/dist/licence.js +41 -0
  19. package/dist/lock.js +50 -0
  20. package/dist/mcp.js +139 -0
  21. package/dist/migrate.js +275 -0
  22. package/dist/omp.js +46 -0
  23. package/dist/opencode.js +118 -0
  24. package/dist/paths.js +33 -0
  25. package/dist/plugin.js +101 -0
  26. package/dist/report/check.js +252 -0
  27. package/dist/report/data.js +401 -0
  28. package/dist/report/export.js +105 -0
  29. package/dist/report/markdown.js +310 -0
  30. package/dist/report/meta.js +48 -0
  31. package/dist/report/pdf.js +96 -0
  32. package/dist/review-server.js +7 -0
  33. package/dist/review.js +274 -0
  34. package/dist/setup.js +121 -0
  35. package/dist/skills.js +81 -0
  36. package/dist/types.js +9 -0
  37. package/dist/uninstall.js +192 -0
  38. package/dist/versions.js +8 -0
  39. package/dist/watch-frame.js +593 -0
  40. package/dist/watch.js +873 -0
  41. package/dist/workspace.js +316 -0
  42. package/docs/GUIDE.md +323 -0
  43. package/hooks/autopilot.mjs +45 -0
  44. package/hooks/lib/autopilot.mjs +191 -0
  45. package/hooks/lib/briefing.mjs +750 -0
  46. package/hooks/lib/licence.mjs +452 -0
  47. package/hooks/lib/pre-pr.mjs +209 -0
  48. package/hooks/lib/sast.mjs +207 -0
  49. package/hooks/lib/setup.mjs +107 -0
  50. package/hooks/lib/workspace.mjs +78 -0
  51. package/hooks/pre-pr.mjs +54 -0
  52. package/hooks/session-start.mjs +82 -0
  53. package/package.json +49 -4
  54. package/skills/bravos/SKILL.md +63 -0
  55. package/skills/bravos-annotate/SKILL.md +56 -0
  56. package/skills/bravos-annotate/scripts/lib/pick.mjs +127 -0
  57. package/skills/bravos-annotate/scripts/slice.mjs +141 -0
  58. package/skills/bravos-campaign/SKILL.md +49 -0
  59. package/skills/bravos-cicd/SKILL.md +48 -0
  60. package/skills/bravos-codegraph/SKILL.md +57 -0
  61. package/skills/bravos-context/SKILL.md +51 -0
  62. package/skills/bravos-context/intent.example.md +48 -0
  63. package/skills/bravos-env/SKILL.md +48 -0
  64. package/skills/bravos-env/scope.example.yaml +50 -0
  65. package/skills/bravos-prove/SKILL.md +52 -0
  66. package/skills/bravos-recheck/SKILL.md +47 -0
  67. package/skills/bravos-report/SKILL.md +53 -0
  68. package/skills/bravos-report/assessment.md +54 -0
  69. package/skills/bravos-report/brief.md +23 -0
  70. package/skills/bravos-report/finding.md +74 -0
  71. package/skills/bravos-report/retest.md +29 -0
  72. package/skills/bravos-report/style.md +44 -0
  73. package/skills/bravos-sast/SKILL.md +48 -0
  74. package/skills/bravos-schedule/SKILL.md +44 -0
  75. package/skills/bravos-schedule/loop.md +16 -0
  76. package/skills/bravos-ship/SKILL.md +58 -0
  77. package/skills/bravos-templatize/SKILL.md +48 -0
  78. package/skills/bravos-templatize/operator-guidance.md +20 -0
  79. package/templates/omp/bravos.js +62 -0
  80. package/templates/opencode/bravos.js +33 -0
  81. package/templates/report/report.html +88 -0
  82. package/templates/review/review.html +327 -0
  83. package/templates/workflows/bravos-nightly-claude.yml +173 -0
  84. package/templates/workflows/bravos-nightly-codex.yml +172 -0
  85. package/templates/workflows/bravos-pr-gate.yml +142 -0
@@ -0,0 +1,105 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { templatesDir, version } from '../paths.js';
4
+ import { BravosError } from '../types.js';
5
+ import { lib } from '../watch.js';
6
+ import { checkReport } from './check.js';
7
+ import { readEvidence, readMeta, reportDir, writeMeta } from './data.js';
8
+ import { renderReportHtml, renderReportText } from './markdown.js';
9
+ import { printPdf } from './pdf.js';
10
+ // @comment -- "bravos report export: renders .bravos/reports/<id>/report.md, after it passes bravos report check, to md, txt, html and pdf beside it, and records each in meta.json. HTML is the branded self-contained page (no script, strict CSP, no external request); PDF prints that page with an installed Chromium-family browser."
11
+ export const FORMATS = ['md', 'txt', 'html', 'pdf'];
12
+ const escHtml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
13
+ export function readReportMd(root, id) {
14
+ const file = join(reportDir(root, id), 'report.md');
15
+ if (!existsSync(file))
16
+ throw new BravosError(`no report.md in .bravos/reports/${id}: write it first (/bravos-report)`);
17
+ return readFileSync(file, 'utf8');
18
+ }
19
+ // @comment -- "Runs the check on a report and stores its verdict and finding counts in meta.json, so bravos watch can show them without re-reading the markdown."
20
+ export function checkAndRecord(root, id) {
21
+ const bundle = readEvidence(root, id);
22
+ const result = checkReport(readReportMd(root, id), bundle, bundle.kind);
23
+ const meta = readMeta(root, id) ?? { schema: 'bravos.report-meta/v1', id, kind: bundle.kind, createdAt: bundle.generatedAt, sha: bundle.git.sha, title: null, check: null, exports: {} };
24
+ const byLevel = {};
25
+ const bySeverity = {};
26
+ for (const f of result.findings) {
27
+ if (f.level)
28
+ byLevel[f.level] = (byLevel[f.level] ?? 0) + 1;
29
+ if (f.severity)
30
+ bySeverity[f.severity] = (bySeverity[f.severity] ?? 0) + 1;
31
+ }
32
+ meta.title = result.title;
33
+ meta.check = {
34
+ ok: result.ok,
35
+ errors: result.errors.length,
36
+ warnings: result.warnings.length,
37
+ at: new Date().toISOString(),
38
+ findings: result.findings.length,
39
+ byLevel,
40
+ bySeverity,
41
+ index: result.findings.map((f) => ({ id: f.id, title: f.title, level: f.level, severity: f.severity, priority: f.priority })),
42
+ };
43
+ writeMeta(root, meta);
44
+ return { result, bundle, meta };
45
+ }
46
+ // @comment -- "The branded HTML page for a report: the rendered body, a contents list from the sections and findings, the mascot, and a header and footer naming the repository, commit, kind, check verdict and bravos version."
47
+ export async function renderHtmlPage(md, bundle, check) {
48
+ const template = readFileSync(join(templatesDir(), 'report', 'report.html'), 'utf8');
49
+ const { html, toc } = renderReportHtml(md);
50
+ const briefing = await lib('briefing.mjs');
51
+ const mascot = briefing.MASCOT ?? [];
52
+ const title = check?.title ?? md.match(/^#\s+(.+)$/m)?.[1] ?? `${bundle.repo} report`;
53
+ const meta = [
54
+ `<b>${escHtml(title)}</b>`,
55
+ `${escHtml(bundle.repo)} · ${escHtml(bundle.kind)} · commit ${escHtml(bundle.git.short ?? '?')}${bundle.git.branch ? ` (${escHtml(bundle.git.branch)})` : ''}`,
56
+ `evidence gathered ${escHtml(bundle.generatedAt.slice(0, 16).replace('T', ' '))} UTC · guardlink ${escHtml(bundle.tools.guardlink ?? '?')} · ${escHtml(bundle.tools.cxg ?? 'cxg ?')}`,
57
+ ].join('<br>');
58
+ const tocHtml = toc.map((t) => `<a class="l${t.level}" href="#${escHtml(t.id)}">${escHtml(t.text)}</a>`).join('');
59
+ const footer = `Written by bravos ${escHtml(version())} from report.md and evidence.json (${escHtml(bundle.id)}). ${check ? (check.ok ? 'bravos report check: passes.' : `bravos report check: ${check.errors.length} problem${check.errors.length === 1 ? '' : 's'}.`) : ''} This report quotes evidence from the assessment; share it deliberately.`;
60
+ return template
61
+ .replace('__TITLE__', () => escHtml(title))
62
+ .replace('__ART__', () => escHtml(mascot.join('\n')))
63
+ .replace('__META__', () => meta)
64
+ .replace('__TOC__', () => tocHtml)
65
+ .replace('__BODY__', () => html)
66
+ .replace('__FOOTER__', () => footer);
67
+ }
68
+ // @comment -- "Writes the requested formats beside report.md. Refuses while the report fails its check unless force is set; pdf also writes the html it prints; a missing browser skips pdf with the reason and keeps the rest."
69
+ export async function exportReport(root, id, formats, opts = {}) {
70
+ const { result, bundle, meta } = checkAndRecord(root, id);
71
+ if (!result.ok && !opts.force) {
72
+ throw new BravosError(`report ${id} fails bravos report check with ${result.errors.length} problem${result.errors.length === 1 ? '' : 's'}; fix them (bravos report check ${id}) or pass --force`);
73
+ }
74
+ const dir = reportDir(root, id);
75
+ const md = readReportMd(root, id);
76
+ const written = [];
77
+ const skipped = [];
78
+ const now = new Date().toISOString();
79
+ const want = new Set(formats);
80
+ if (want.has('md'))
81
+ written.push({ format: 'md', path: join(dir, 'report.md') });
82
+ if (want.has('txt')) {
83
+ const head = `${bundle.repo} · ${bundle.kind} · commit ${bundle.git.short ?? '?'} · bravos ${version()}`;
84
+ writeFileSync(join(dir, 'report.txt'), `${head}\n\n${renderReportText(md, 80)}`);
85
+ written.push({ format: 'txt', path: join(dir, 'report.txt') });
86
+ }
87
+ if (want.has('html') || want.has('pdf')) {
88
+ writeFileSync(join(dir, 'report.html'), await renderHtmlPage(md, bundle, result));
89
+ if (want.has('html'))
90
+ written.push({ format: 'html', path: join(dir, 'report.html') });
91
+ }
92
+ if (want.has('pdf')) {
93
+ const r = printPdf(join(dir, 'report.html'), join(dir, 'report.pdf'), opts.env ?? process.env);
94
+ if (r.ok)
95
+ written.push({ format: 'pdf', path: join(dir, 'report.pdf') });
96
+ else
97
+ skipped.push({ format: 'pdf', reason: r.reason });
98
+ if (!want.has('html'))
99
+ written.push({ format: 'html', path: join(dir, 'report.html') });
100
+ }
101
+ for (const w of written)
102
+ meta.exports[w.format] = { path: w.path, at: now };
103
+ writeMeta(root, meta);
104
+ return { written, skipped, check: result };
105
+ }
@@ -0,0 +1,310 @@
1
+ import { LEVEL_LABEL } from './data.js';
2
+ // @comment -- "Renders a bravos report's markdown to HTML and to plain text, with no dependency. The report is model-written, so it is untrusted: every character is HTML-escaped first and only the constructs below become markup again; raw HTML shows as text; links keep only http(s), mailto, in-page # and relative targets. Ported from guardlink's dashboard renderer, plus heading anchors, field lines, evidence and severity chips, and a section per finding."
3
+ const escHtml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
4
+ function safeHref(raw) {
5
+ const href = raw.trim();
6
+ if (/^(https?:|mailto:)/i.test(href) || href.startsWith('#') || /^[./\w-][^:]*$/.test(href))
7
+ return href;
8
+ return null;
9
+ }
10
+ const LEVEL_CLASS = { reproduced: 'lv-repro', held: 'lv-held', 'code-review': 'lv-review', suspected: 'lv-suspect' };
11
+ const LEVEL_TAG = { reproduced: 'REPRODUCED', held: 'HELD', 'code-review': 'CODE REVIEW', suspected: 'SUSPECTED' };
12
+ /** Inline constructs over ESCAPED text; code spans are cut out first so nothing inside them changes. */
13
+ function inline(text) {
14
+ const codes = [];
15
+ let s = text.replace(//g, '').replace(/`([^`]+)`/g, (_, c) => {
16
+ codes.push(c);
17
+ return `${codes.length - 1}`;
18
+ });
19
+ s = escHtml(s);
20
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_w, label, url) => {
21
+ const href = safeHref(url.replace(/&amp;/g, '&'));
22
+ return href ? `<a href="${escHtml(href)}"${/^https?:/i.test(href) ? ' rel="noopener"' : ''}>${label}</a>` : label;
23
+ });
24
+ s = s.replace(/\*\*([^*]+)\*\*|__([^_]+)__/g, (_, a, b) => `<strong>${a ?? b}</strong>`);
25
+ s = s.replace(/(^|[^*\w])\*([^*\s][^*]*)\*(?!\w)|(^|[^_\w])_([^_\s][^_]*)_(?!\w)/g, (_, p1, a, p3, b) => `${p1 ?? p3 ?? ''}<em>${a ?? b}</em>`);
26
+ for (const [k, label] of Object.entries(LEVEL_LABEL)) {
27
+ s = s.replace(new RegExp(`(^|[\\s(>])(${label})`, 'gi'), (_, pre, l) => `${pre}<span class="chip ${LEVEL_CLASS[k]}">${l}</span>`);
28
+ }
29
+ // Evidence ids become links into the appendix index when the page has one.
30
+ s = s.replace(/(?<![\w:-])((?:[a-z0-9][a-z0-9._-]{0,40}:)?(?:RUN-\d+(?:\/[FXHN]-\d+)?|(?:EXP|SAST|CTL|BND|ACC|EDGE|UNR)-\d+|LED-[a-f0-9]{8}))\b/g, '<span class="eid">$1</span>');
31
+ return s.replace(/(\d+)/g, (_, i) => `<code>${escHtml(codes[Number(i)])}</code>`);
32
+ }
33
+ const isTableSep = (l) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(l);
34
+ const cells = (l) => l.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
35
+ const listItem = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;
36
+ const fieldLine = /^\s*\*\*[^*]{1,40}?(?::\*\*|\*\*:)/;
37
+ const slug = (s) => s.toLowerCase().replace(/<[^>]+>/g, '').replace(/&[a-z]+;/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60);
38
+ // @comment -- "Markdown to HTML for a report body: headings with anchors (F-nn headings anchor as f-nn), each **Label:** line as its own field paragraph, severity values as chips, and every finding wrapped in <section class=\"finding\"> so print keeps it together."
39
+ export function renderReportHtml(src) {
40
+ const lines = src.replace(/\r\n?/g, '\n').split('\n');
41
+ const out = [];
42
+ const toc = [];
43
+ const used = new Set();
44
+ let i = 0;
45
+ let inFinding = false;
46
+ const para = [];
47
+ const flush = () => {
48
+ if (para.length) {
49
+ out.push(`<p>${inline(para.join(' '))}</p>`);
50
+ para.length = 0;
51
+ }
52
+ };
53
+ const list = () => {
54
+ const base = (listItem.exec(lines[i])[1] ?? '').length;
55
+ const ordered = /\d/.test(listItem.exec(lines[i])[2]);
56
+ const items = [];
57
+ while (i < lines.length) {
58
+ const m = listItem.exec(lines[i]);
59
+ if (!m || m[1].length < base)
60
+ break;
61
+ if (m[1].length > base) {
62
+ items[items.length - 1] = (items[items.length - 1] ?? '') + list();
63
+ continue;
64
+ }
65
+ let body = inline(m[3]);
66
+ i++;
67
+ while (i < lines.length && lines[i].trim() && !listItem.test(lines[i]) && /^\s+/.test(lines[i])) {
68
+ body += ` ${inline(lines[i].trim())}`;
69
+ i++;
70
+ }
71
+ items.push(body);
72
+ }
73
+ return `<${ordered ? 'ol' : 'ul'}>${items.map((b) => `<li>${b}</li>`).join('')}</${ordered ? 'ol' : 'ul'}>`;
74
+ };
75
+ while (i < lines.length) {
76
+ const line = lines[i];
77
+ const fence = /^\s*(```|~~~)\s*([\w-]*)\s*$/.exec(line);
78
+ if (fence) {
79
+ flush();
80
+ const body = [];
81
+ i++;
82
+ while (i < lines.length && !lines[i].trim().startsWith(fence[1]))
83
+ body.push(lines[i++]);
84
+ i++;
85
+ out.push(`<pre${fence[2] ? ` data-lang="${escHtml(fence[2])}"` : ''}><code>${escHtml(body.join('\n'))}</code></pre>`);
86
+ continue;
87
+ }
88
+ if (!line.trim()) {
89
+ flush();
90
+ i++;
91
+ continue;
92
+ }
93
+ const h = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
94
+ if (h) {
95
+ flush();
96
+ const level = h[1].length;
97
+ const f = /^(F-\d{2,3})\b/.exec(h[2]);
98
+ if (inFinding && (level <= 3 || f)) {
99
+ out.push('</section>');
100
+ inFinding = false;
101
+ }
102
+ let id = f ? f[1].toLowerCase() : slug(h[2]) || 'section';
103
+ while (used.has(id))
104
+ id += '-x';
105
+ used.add(id);
106
+ if (f) {
107
+ out.push('<section class="finding">');
108
+ inFinding = true;
109
+ }
110
+ out.push(`<h${level} id="${id}">${inline(h[2])}</h${level}>`);
111
+ if (level === 2 || f)
112
+ toc.push({ id, text: h[2].replace(/[*_`]/g, ''), level: f ? 3 : level });
113
+ i++;
114
+ continue;
115
+ }
116
+ if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
117
+ flush();
118
+ out.push('<hr>');
119
+ i++;
120
+ continue;
121
+ }
122
+ if (/^\s*>/.test(line)) {
123
+ flush();
124
+ const q = [];
125
+ while (i < lines.length && /^\s*>/.test(lines[i]))
126
+ q.push(lines[i++].replace(/^\s*>\s?/, ''));
127
+ out.push(`<blockquote>${renderReportHtml(q.join('\n')).html}</blockquote>`);
128
+ continue;
129
+ }
130
+ if (line.includes('|') && i + 1 < lines.length && isTableSep(lines[i + 1])) {
131
+ flush();
132
+ const head = cells(line);
133
+ i += 2;
134
+ const rows = [];
135
+ while (i < lines.length && lines[i].includes('|') && lines[i].trim())
136
+ rows.push(cells(lines[i++]));
137
+ out.push(`<div class="table-wrap"><table><thead><tr>${head.map((c) => `<th>${inline(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr>${head.map((_, k) => `<td>${inline(r[k] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`);
138
+ continue;
139
+ }
140
+ if (listItem.test(line) && !fieldLine.test(line)) {
141
+ flush();
142
+ out.push(list());
143
+ continue;
144
+ }
145
+ if (fieldLine.test(line)) {
146
+ flush();
147
+ let html = inline(line.trim());
148
+ // A severity value becomes a chip.
149
+ html = html.replace(/^(<strong>Severity:?<\/strong>:?\s*)(critical|high|medium|low|informational|info)\b/i, (_, lead, sev) => `${lead}<span class="chip sev-${sev.toLowerCase()}">${sev}</span>`);
150
+ out.push(`<p class="field">${html}</p>`);
151
+ i++;
152
+ continue;
153
+ }
154
+ para.push(line.trim());
155
+ i++;
156
+ }
157
+ flush();
158
+ if (inFinding)
159
+ out.push('</section>');
160
+ return { html: out.join('\n'), toc };
161
+ }
162
+ /* ------------------------------------------------------------------ text */
163
+ /** Word-wraps one paragraph to width with a first-line prefix and a hanging indent. */
164
+ function wrap(text, width, first = '', rest = '') {
165
+ const words = text.split(/\s+/).filter(Boolean);
166
+ const out = [];
167
+ let line = first;
168
+ let lead = first;
169
+ for (const w0 of words) {
170
+ let w = w0;
171
+ // A single word longer than a line is broken hard.
172
+ while ([...w].length > width - rest.length) {
173
+ if (line.trim() !== lead.trim())
174
+ out.push(line.trimEnd());
175
+ const room = width - rest.length;
176
+ out.push(rest + [...w].slice(0, room).join(''));
177
+ w = [...w].slice(room).join('');
178
+ line = rest;
179
+ lead = rest;
180
+ }
181
+ if (!w)
182
+ continue;
183
+ const next = line === lead ? line + w : `${line} ${w}`;
184
+ if ([...next].length > width && line !== lead) {
185
+ out.push(line.trimEnd());
186
+ line = rest + w;
187
+ lead = rest;
188
+ }
189
+ else
190
+ line = next;
191
+ }
192
+ if (line.trim())
193
+ out.push(line.trimEnd());
194
+ return out.length ? out : [first.trimEnd()];
195
+ }
196
+ const plainInline = (s, tags = false) => {
197
+ let t = s.replace(/`([^`]+)`/g, '$1').replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, l, u) => (u.startsWith('#') ? l : `${l} <${u}>`));
198
+ t = t.replace(/\*\*([^*]+)\*\*|__([^_]+)__/g, (_, a, b) => a ?? b ?? '');
199
+ t = t.replace(/(^|[^*\w])\*([^*\s][^*]*)\*(?!\w)|(^|[^_\w])_([^_\s][^_]*)_(?!\w)/g, (_, p1, a, p3, b) => `${p1 ?? p3 ?? ''}${a ?? b}`);
200
+ // Evidence labels are tagged on field lines (Evidence, Result), where a reader scans for them; prose keeps the plain words.
201
+ if (tags)
202
+ for (const [k, label] of Object.entries(LEVEL_LABEL))
203
+ t = t.replace(new RegExp(`(^|[\\s(])(${label})`, 'gi'), (_, pre, l) => `${pre}[${LEVEL_TAG[k]}] ${l}`);
204
+ return t;
205
+ };
206
+ // @comment -- "Markdown to plain text at a fixed width (default 80): underlined headings, wrapped paragraphs with hanging indents for lists and fields, tables as aligned columns when they fit and as label: value blocks when they do not, code indented and hard-wrapped, evidence labels tagged [REPRODUCED] and the like. Every output line is at most width columns."
207
+ export function renderReportText(src, width = 80) {
208
+ const lines = src.replace(/\r\n?/g, '\n').split('\n');
209
+ const out = [];
210
+ const para = [];
211
+ const flush = () => {
212
+ if (para.length) {
213
+ out.push(...wrap(plainInline(para.join(' ')), width), '');
214
+ para.length = 0;
215
+ }
216
+ };
217
+ let i = 0;
218
+ while (i < lines.length) {
219
+ const line = lines[i];
220
+ const fence = /^\s*(```|~~~)/.exec(line);
221
+ if (fence) {
222
+ flush();
223
+ i++;
224
+ while (i < lines.length && !lines[i].trim().startsWith(fence[1])) {
225
+ const l = ' ' + lines[i++];
226
+ for (let k = 0; k < Math.max(1, [...l].length); k += width)
227
+ out.push([...l].slice(k, k + width).join('').trimEnd());
228
+ }
229
+ i++;
230
+ out.push('');
231
+ continue;
232
+ }
233
+ if (!line.trim()) {
234
+ flush();
235
+ i++;
236
+ continue;
237
+ }
238
+ const h = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
239
+ if (h) {
240
+ flush();
241
+ const text = plainInline(h[2]);
242
+ const wrapped = wrap(h[1].length === 1 ? text.toUpperCase() : text, width);
243
+ out.push(...wrapped);
244
+ if (h[1].length <= 2)
245
+ out.push((h[1].length === 1 ? '=' : '-').repeat(Math.min(width, Math.max(...wrapped.map((w) => [...w].length)))));
246
+ out.push('');
247
+ i++;
248
+ continue;
249
+ }
250
+ if (line.includes('|') && i + 1 < lines.length && isTableSep(lines[i + 1])) {
251
+ flush();
252
+ const head = cells(line).map((c) => plainInline(c));
253
+ i += 2;
254
+ const rows = [];
255
+ while (i < lines.length && lines[i].includes('|') && lines[i].trim())
256
+ rows.push(cells(lines[i++]).map((c) => plainInline(c)));
257
+ const widths = head.map((c, k) => Math.max([...c].length, ...rows.map((r) => [...(r[k] ?? '')].length)));
258
+ const total = widths.reduce((a, b) => a + b, 0) + 2 * (widths.length - 1);
259
+ if (total <= width) {
260
+ const row = (r) => r.map((c, k) => (c ?? '').padEnd(widths[k])).join(' ').trimEnd();
261
+ out.push(row(head), widths.map((w) => '-'.repeat(w)).join(' '), ...rows.map(row), '');
262
+ }
263
+ else {
264
+ for (const r of rows) {
265
+ head.forEach((hd, k) => out.push(...wrap(r[k] ?? '', width, `${hd}: `, ' ')));
266
+ out.push('');
267
+ }
268
+ }
269
+ continue;
270
+ }
271
+ const li = listItem.exec(line);
272
+ if (li && !fieldLine.test(line)) {
273
+ flush();
274
+ const indent = ' '.repeat(Math.min(8, li[1].length));
275
+ const marker = /\d/.test(li[2]) ? `${li[2]} ` : '- ';
276
+ let body = li[3];
277
+ i++;
278
+ while (i < lines.length && lines[i].trim() && !listItem.test(lines[i]) && /^\s+/.test(lines[i]))
279
+ body += ` ${lines[i++].trim()}`;
280
+ out.push(...wrap(plainInline(body), width, indent + marker, indent + ' '.repeat(marker.length)));
281
+ if (!(i < lines.length && listItem.test(lines[i])))
282
+ out.push('');
283
+ continue;
284
+ }
285
+ if (/^\s*>/.test(line)) {
286
+ flush();
287
+ const q = [];
288
+ while (i < lines.length && /^\s*>/.test(lines[i]))
289
+ q.push(lines[i++].replace(/^\s*>\s?/, ''));
290
+ out.push(...renderReportText(q.join('\n'), width - 2).split('\n').map((l) => (l ? `| ${l}` : '|')));
291
+ continue;
292
+ }
293
+ if (fieldLine.test(line)) {
294
+ flush();
295
+ out.push(...wrap(plainInline(line.trim().replace(/^[-*]\s+/, ''), true), width, '', ' '));
296
+ i++;
297
+ continue;
298
+ }
299
+ if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
300
+ flush();
301
+ out.push('-'.repeat(Math.min(width, 40)), '');
302
+ i++;
303
+ continue;
304
+ }
305
+ para.push(line.trim());
306
+ i++;
307
+ }
308
+ flush();
309
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
310
+ }
@@ -0,0 +1,48 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { BravosError } from '../types.js';
4
+ // @comment -- "The report folders under .bravos/reports and their meta.json (kind, title, check verdict with finding counts, exported formats): the small read/write layer bravos report and bravos watch share. No dependency on the evidence gathering, so watch can read reports cheaply."
5
+ export const META_SCHEMA = 'bravos.report-meta/v1';
6
+ export const REPORTS_DIR = join('.bravos', 'reports');
7
+ // @comment -- "The folder of report id under root; ids are refused unless they are a plain name, so a report id can never point outside .bravos/reports."
8
+ export const reportDir = (root, id) => {
9
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/.test(id))
10
+ throw new BravosError(`not a report id: ${id}`);
11
+ return join(root, REPORTS_DIR, id);
12
+ };
13
+ // @comment -- "Reads a report's meta.json; a missing, unreadable or foreign file reads as null."
14
+ export function readMeta(root, id) {
15
+ try {
16
+ const m = JSON.parse(readFileSync(join(reportDir(root, id), 'meta.json'), 'utf8'));
17
+ return m?.schema === META_SCHEMA ? m : null;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ export function writeMeta(root, meta) {
24
+ writeFileSync(join(reportDir(root, meta.id), 'meta.json'), JSON.stringify(meta, null, 2) + '\n');
25
+ }
26
+ // @comment -- "Lists report folders newest first by their meta.json creation time (falling back to the folder's mtime); 'latest' resolves to the first."
27
+ export function listReports(root) {
28
+ const base = join(root, REPORTS_DIR);
29
+ if (!existsSync(base))
30
+ return [];
31
+ return readdirSync(base, { withFileTypes: true })
32
+ .filter((e) => e.isDirectory() && existsSync(join(base, e.name, 'evidence.json')))
33
+ .map((e) => ({ id: e.name, at: readMeta(root, e.name)?.createdAt ?? statSync(join(base, e.name)).mtime.toISOString() }))
34
+ .sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : a.id < b.id ? 1 : -1))
35
+ .map((r) => r.id);
36
+ }
37
+ export function resolveReport(root, ref) {
38
+ if (!ref || ref === 'latest') {
39
+ const first = listReports(root)[0];
40
+ if (!first)
41
+ throw new BravosError('no reports yet: run bravos report data, then write report.md (/bravos-report)');
42
+ return first;
43
+ }
44
+ reportDir(root, ref);
45
+ if (!existsSync(join(root, REPORTS_DIR, ref)))
46
+ throw new BravosError(`no report ${ref} under ${REPORTS_DIR}`);
47
+ return ref;
48
+ }
@@ -0,0 +1,96 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { accessSync, constants, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { delimiter, join } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ // @comment -- "PDF without a dependency: finds an installed Chromium-family browser (BRAVOS_PDF_BROWSER, then Chrome, Edge, Brave or Chromium at their usual install paths or on PATH, then Playwright's Chromium) and prints the report's own HTML file with --headless --print-to-pdf. argv, no shell, a throwaway profile directory so the person's browser profile is never touched, and a timeout."
7
+ const executable = (p) => {
8
+ try {
9
+ accessSync(p, constants.X_OK);
10
+ return statSync(p).isFile();
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ };
16
+ function onPath(names, env) {
17
+ for (const dir of (env.PATH ?? '').split(delimiter)) {
18
+ if (!dir)
19
+ continue;
20
+ for (const n of names)
21
+ if (executable(join(dir, n)))
22
+ return join(dir, n);
23
+ }
24
+ return null;
25
+ }
26
+ /** Playwright's downloaded Chromium builds, newest first. */
27
+ function playwrightChromium(env) {
28
+ const home = env.HOME ?? homedir();
29
+ for (const cache of [env.PLAYWRIGHT_BROWSERS_PATH, join(home, 'Library', 'Caches', 'ms-playwright'), join(home, '.cache', 'ms-playwright')].filter((d) => !!d)) {
30
+ let dirs = [];
31
+ try {
32
+ dirs = readdirSync(cache).filter((d) => /^chromium(_headless_shell)?-\d+$/.test(d)).sort((a, b) => Number(b.split('-').pop()) - Number(a.split('-').pop()));
33
+ }
34
+ catch {
35
+ continue;
36
+ }
37
+ for (const d of dirs) {
38
+ for (const rel of [
39
+ 'chrome-mac/Chromium.app/Contents/MacOS/Chromium',
40
+ 'chrome-mac-arm64/Chromium.app/Contents/MacOS/Chromium',
41
+ 'chrome-mac/headless_shell',
42
+ 'chrome-mac-arm64/headless_shell',
43
+ 'chrome-headless-shell-mac-arm64/chrome-headless-shell',
44
+ 'chrome-headless-shell-mac-x64/chrome-headless-shell',
45
+ 'chrome-linux/chrome',
46
+ 'chrome-linux/headless_shell',
47
+ 'chrome-headless-shell-linux64/chrome-headless-shell',
48
+ ]) {
49
+ const p = join(cache, d, rel);
50
+ if (executable(p))
51
+ return p;
52
+ }
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+ // @comment -- "The browser that can print a PDF here, or null: BRAVOS_PDF_BROWSER when it is executable, then the usual macOS and Windows install paths, then Chrome/Chromium/Edge/Brave names on PATH, then Playwright's Chromium."
58
+ export function findBrowser(env = process.env) {
59
+ if (env.BRAVOS_PDF_BROWSER)
60
+ return executable(env.BRAVOS_PDF_BROWSER) ? env.BRAVOS_PDF_BROWSER : null;
61
+ const home = env.HOME ?? homedir();
62
+ const mac = ['Google Chrome.app/Contents/MacOS/Google Chrome', 'Chromium.app/Contents/MacOS/Chromium', 'Microsoft Edge.app/Contents/MacOS/Microsoft Edge', 'Brave Browser.app/Contents/MacOS/Brave Browser'];
63
+ const win = [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA]
64
+ .filter((d) => !!d)
65
+ .flatMap((d) => [join(d, 'Google', 'Chrome', 'Application', 'chrome.exe'), join(d, 'Microsoft', 'Edge', 'Application', 'msedge.exe'), join(d, 'BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe')]);
66
+ const candidates = [
67
+ ...(process.platform === 'darwin' && !env.BRAVOS_PDF_NO_SYSTEM ? mac.flatMap((m) => [join('/Applications', m), join(home, 'Applications', m)]) : []),
68
+ ...(process.platform === 'win32' ? win : []),
69
+ ];
70
+ for (const c of candidates)
71
+ if (executable(c))
72
+ return c;
73
+ return onPath(['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge', 'msedge', 'brave-browser', 'chrome'], env) ?? playwrightChromium(env);
74
+ }
75
+ // @comment -- "Prints htmlFile to pdfFile with the browser in headless mode and checks the result starts with %PDF; a fresh profile directory is created and removed around the run."
76
+ export function printPdf(htmlFile, pdfFile, env = process.env, timeoutMs = 90000) {
77
+ const browser = findBrowser(env);
78
+ if (!browser)
79
+ return { ok: false, reason: 'no Chrome, Edge, Brave or Chromium found (set BRAVOS_PDF_BROWSER to one, or print the HTML to PDF from a browser)' };
80
+ const profile = mkdtempSync(join(tmpdir(), 'bravos-pdf-'));
81
+ try {
82
+ rmSync(pdfFile, { force: true });
83
+ const r = spawnSync(browser, ['--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--disable-extensions', `--user-data-dir=${profile}`, '--no-pdf-header-footer', `--print-to-pdf=${pdfFile}`, pathToFileURL(htmlFile).href], { env, stdio: ['ignore', 'ignore', 'pipe'], encoding: 'utf8', timeout: timeoutMs });
84
+ if (r.error)
85
+ return { ok: false, reason: `${browser} did not run: ${r.error.message}` };
86
+ if (!existsSync(pdfFile))
87
+ return { ok: false, reason: `${browser} wrote no PDF${r.stderr ? `: ${String(r.stderr).split('\n').find((l) => l.trim()) ?? ''}` : ''}` };
88
+ const head = readFileSync(pdfFile).subarray(0, 5).toString('latin1');
89
+ if (head !== '%PDF-')
90
+ return { ok: false, reason: `${browser} wrote a file that is not a PDF` };
91
+ return { ok: true, browser };
92
+ }
93
+ finally {
94
+ rmSync(profile, { recursive: true, force: true });
95
+ }
96
+ }
@@ -0,0 +1,7 @@
1
+ // @comment -- "Detached child that serves one review: argv root, kind, token, port. Started by bravos review so the command can return at once in any harness; it exits on Done or after 30 minutes idle."
2
+ import { readDraft, renderReview, startReviewServer } from './review.js';
3
+ const [root, kind, token, port] = process.argv.slice(2);
4
+ const draft = readDraft(root, kind);
5
+ const page = await renderReview(draft, { kind: 'serve', token });
6
+ const { server } = await startReviewServer(root, draft, page, token, Number(port));
7
+ server.on('close', () => process.exit(0));