From 78770e6a4ed0ead4597202bd708f9287ac463da1 Mon Sep 17 00:00:00 2001 From: t1ktakdev Date: Sun, 13 Sep 2026 23:04:37 +0600 Subject: [PATCH] style: simplify generated reports --- README.md | 8 ++-- README.ru.md | 8 ++-- docs/reproshot.svg | 2 +- examples/generated/SHA256SUMS | 12 ++--- examples/generated/report.html | 4 +- examples/generated/reproshot.svg | 2 +- src/report.ts | 79 ++++++++++++++++++++++++++++++-- test/report.test.mjs | 40 +++++++++++++++- 8 files changed, 134 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 0060a5d..dc2c0f3 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,6 @@ npx reproshot -- npm test ``` -![ReproShot evidence card from an actual failing demo capture](docs/reproshot.svg) - ReproShot runs a command, shows its output as usual, and saves the useful debugging context in a local folder. There are no accounts, uploads, or hosted services. ## Try it @@ -44,6 +42,10 @@ SHA256SUMS The reports combine the command, exit status, runtime versions, Git state, redacted logs, and reproduction helpers. `changes.patch` is included when there are eligible tracked text changes. Source code and dependencies are not copied into the bundle. +Here is the compact summary from the generated demo capture: + +![ReproShot evidence summary from an actual failing demo capture](docs/reproshot.svg) + Open `report.html` for the full report or share `reproshot.svg` as a compact summary. To replay the command, restore the recorded source revision, install the project dependencies, and run the appropriate helper from the project directory. Review any patch before applying it. The Repro Score describes how much useful evidence was captured. It is not a promise that the bug will reproduce. @@ -73,6 +75,6 @@ npm run check npm run demo ``` -The SVG above is generated from the committed demo capture. The examples intentionally fail so the full capture path can be tested. +The SVG is generated from the committed demo capture. The examples intentionally fail so the full capture path can be tested. [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) · [Release procedure](docs/releasing.md) · [MIT license](LICENSE) diff --git a/README.ru.md b/README.ru.md index 34f951b..915b98d 100644 --- a/README.ru.md +++ b/README.ru.md @@ -8,8 +8,6 @@ npx reproshot -- npm test ``` -![Карточка ReproShot из настоящего демонстрационного запуска](docs/reproshot.svg) - ReproShot запускает команду, показывает её вывод в терминале и сохраняет полезный для отладки контекст в локальную папку. Никаких аккаунтов, загрузки данных или внешних сервисов. ## Попробовать @@ -44,6 +42,10 @@ SHA256SUMS В отчёт входят команда, код завершения, версии используемых инструментов, состояние Git, очищенные логи и скрипты для повторного запуска. `changes.patch` добавляется, если есть подходящие отслеживаемые текстовые изменения. Исходный код и зависимости в папку отчёта не копируются. +Ниже — краткая сводка из сгенерированного демонстрационного отчёта: + +![Сводка ReproShot из настоящего демонстрационного запуска](docs/reproshot.svg) + Полный отчёт можно открыть в `report.html`, а `reproshot.svg` удобно использовать как краткую сводку. Для повторного запуска восстановите указанную ревизию, установите зависимости проекта и запустите подходящий скрипт из папки проекта. Перед применением патча обязательно просмотрите его. Repro Score показывает, насколько полно собраны полезные данные. Он не гарантирует, что ошибку удастся воспроизвести. @@ -73,6 +75,6 @@ npm run check npm run demo ``` -SVG выше создан из сохранённого демонстрационного запуска ReproShot. Примеры намеренно завершаются с ошибкой, чтобы проверить весь процесс создания отчёта. +SVG создан из сохранённого демонстрационного запуска ReproShot. Примеры намеренно завершаются с ошибкой, чтобы проверить весь процесс создания отчёта. [Как внести вклад](CONTRIBUTING.md) · [Безопасность](SECURITY.md) · [Процедура релиза](docs/releasing.md) · [Лицензия MIT](LICENSE) diff --git a/docs/reproshot.svg b/docs/reproshot.svg index c0e3328..ecdaf0b 100644 --- a/docs/reproshot.svg +++ b/docs/reproshot.svg @@ -1 +1 @@ -ReproShot: 100/100 Repro Scorenpm test. failure. 2 detected secrets redacted.REPROSHOTFAILUREReproducibility evidence100 / 100Exact command✓ 20/20Exit result✓ 15/15Environment✓ 10/10Runtime versions✓ 10/10Git revision✓ 15/15Working tree✓ 10/10Logs✓ 10/10Reproduction scripts✓ 10/10Detected secrets redacted: 21 command to reproduceEvidence score, not a guarantee. Review before sharing. +ReproShot: 100/100 Repro Scorecapture failed · exit 1 · 0.28s. npm test. 2 detected secrets redacted.REPROSHOTcapture failed · exit 1 · 0.28s$npm testrepro score100/100gitab9e455 · main · cleanruntimenode 24.19.0 · npm 11.9.0 · +4logsstdout captured · stderr capturedreplayreproduce.sh · reproduce.ps1secrets redacted2Evidence completeness only. Review before sharing. diff --git a/examples/generated/SHA256SUMS b/examples/generated/SHA256SUMS index 61347c3..e3d20bc 100644 --- a/examples/generated/SHA256SUMS +++ b/examples/generated/SHA256SUMS @@ -1,8 +1,8 @@ -26fabe5d4a147b92878332536bbcffd4dfae94d83ed56d79c008cb0e3b735146 reproduce.ps1 -4d79b81c86d78d1ddcab4ec23206424c995376df7f5f5e2d8f4b7233fe4ecc91 report.html -576122ffa150f97223e22dd559ceb4c8fa3f3139e868f09a5daf4c6f88412142 report.md +06c5c16c797b0d8e75b8ca4ec88fef67efeb543924344c8c106d3009f2032004 reproduce.ps1 +2672549216e32aaf7a54a4a53f7eb9025257afedbce168ded690cbe6a5103473 stderr.log +29a415cbc780b0b2b81c4490fefb104057911e8f1bcf0815707c43230ec52e2c report.html 60580f03221e1e294ad4caff4859b1dec74e759b2508c8037ff4040e7cc2d09d reproduce.sh -7dc4e183eb6eb9b1818bfc897c744bd408f2161964dc515f4c8d9094319b95af reproshot.svg +aadcf480c8e6a4b589ddef44a269127bdb439ee9de6c5d0c4ff9b9bdbbb3ca74 reproshot.svg d27559dcd15a97fcee4a58493b88233a334e6f6c15c86a3ae280574eb9081326 stdout.log -eafd8fcfdc540fde17396f91e0da00be034fdb8e1883d767e5ee337e0b43ced6 manifest.json -ff8a569c89e7dad8a27d97bdd17b6915341dbd0c782576dfae0c2009360a8411 stderr.log +d9177d0ce3c9163d1494bced39780bd2099b2a02abca2ca6482555fbb0bc1249 report.md +fb9ab03fc1b6046a575ef08b6a476390209fbe7e887031bc7fc65325ce8262da manifest.json diff --git a/examples/generated/report.html b/examples/generated/report.html index ba6ca16..6feae68 100644 --- a/examples/generated/report.html +++ b/examples/generated/report.html @@ -1,7 +1,7 @@ ReproShot · failure
REPROSHOT / LOCAL CAPTURE

Failure, with context.

One command. A report you can share.
100 / 100REPRO SCORE
npm test
Exit1
Duration0.28s
Commitab9e455
Secrets2 redacted

Environment

System
linux 6.18.44 / x64
Directory
/tmp/reproshot-demo-GezJVt
node
24.19.0
npm
11.9.0
pnpm
11.19.0
python
3.12.14
pip
26.2.1
java
17.0.20

Source state

Commit
ab9e455f17d3754be6f3d65a26e7b5683358b801
Branch
main
Working tree
Clean
Patch
none
Captured
2026-09-13T13:20:27.722Z
Signal
None

Reproduce

Captured on linux x64. Versions: node 24.19.0, npm 11.9.0, pnpm 11.19.0, python 3.12.14, pip 26.2.1, java 17.0.20. +:root{color-scheme:dark light;--bg:#0d1117;--panel:#161b22;--line:#30363d;--text:#e6edf3;--muted:#8b949e;--accent:#58a6ff;--red:#f85149;--green:#3fb950;--amber:#d29922}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.6 system-ui,sans-serif}main{max-width:960px;margin:0 auto;padding:36px 24px}header{border-bottom:1px solid var(--line);padding-bottom:18px}.brand{font:600 14px ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--muted);letter-spacing:.08em;text-transform:uppercase}h1{font-size:28px;line-height:1.25;margin:6px 0 0}.status-failure,.status-spawn-error{color:var(--red)}.status-success{color:var(--green)}.status-interrupted{color:var(--amber)}h2{font-size:18px;margin:28px 0 12px}.muted,small{color:var(--muted)}.command,pre{white-space:pre-wrap;overflow-wrap:anywhere;background:var(--panel);border:1px solid var(--line);padding:16px;border-radius:5px;font:13px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace}.command{font-size:16px;border-left:3px solid var(--accent);margin:22px 0}.command::before{content:'$ ';color:var(--accent)}.facts{display:grid;grid-template-columns:repeat(5,1fr);border-block:1px solid var(--line)}.fact{padding:13px 14px;border-right:1px solid var(--line)}.fact:first-child{padding-left:0}.fact:last-child{border:0}.fact small{display:block}.fact strong{font:500 13px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace}.grid{display:grid;grid-template-columns:1fr 1fr;gap:32px}dl{display:grid;grid-template-columns:105px minmax(0,1fr);gap:8px 14px;font-size:14px}dt{color:var(--muted)}dd{margin:0;overflow-wrap:anywhere}a{color:var(--accent)}ul{padding-left:20px}.files{display:flex;flex-wrap:wrap;gap:8px;padding:0;list-style:none}.files a{display:block;padding:6px 10px;border:1px solid var(--line);border-radius:4px;text-decoration:none;font:12px ui-monospace,SFMono-Regular,Consolas,monospace}footer{border-top:1px solid var(--line);margin-top:30px;padding-top:18px;font-size:12px;color:var(--muted)}summary{cursor:pointer;font-weight:600}details{margin-top:22px}pre{max-height:520px;overflow:auto}.notice{color:var(--muted);font-size:13px}@media(max-width:720px){main{padding:28px 18px}.grid{grid-template-columns:1fr;gap:0}.facts{grid-template-columns:1fr 1fr}.fact,.fact:first-child{padding:12px;border-bottom:1px solid var(--line)}}@media(prefers-color-scheme:light){:root{--bg:#f6f8fa;--panel:#fff;--line:#d0d7de;--text:#1f2328;--muted:#59636e;--accent:#0969da;--red:#cf222e;--green:#1a7f37;--amber:#9a6700}} +

ReproShot

Capture failed

npm test
Exit1
Duration0.28s
Commitab9e455
Secrets2 redacted
Repro Score100/100

Environment

System
linux 6.18.44 / x64
Directory
/tmp/reproshot-demo-GezJVt
node
24.19.0
npm
11.9.0
pnpm
11.19.0
python
3.12.14
pip
26.2.1
java
17.0.20

Source state

Commit
ab9e455f17d3754be6f3d65a26e7b5683358b801
Branch
main
Working tree
Clean
Patch
none
Captured
2026-09-13T13:20:27.722Z
Signal
None

Reproduce

Captured on linux x64. Versions: node 24.19.0, npm 11.9.0, pnpm 11.19.0, python 3.12.14, pip 26.2.1, java 17.0.20. Use the project working directory (/tmp/reproshot-demo-GezJVt). Restore source revision ab9e455f17d3754be6f3d65a26e7b5683358b801, review any patch, and prepare dependencies yourself. Environment variable values were not collected. Review the command before running.

'npm' 'test'
 
diff --git a/examples/generated/reproshot.svg b/examples/generated/reproshot.svg
index c0e3328..ecdaf0b 100644
--- a/examples/generated/reproshot.svg
+++ b/examples/generated/reproshot.svg
@@ -1 +1 @@
-ReproShot: 100/100 Repro Scorenpm test. failure. 2 detected secrets redacted.REPROSHOTFAILUREReproducibility evidence100 / 100Exact command✓  20/20Exit result✓  15/15Environment✓  10/10Runtime versions✓  10/10Git revision✓  15/15Working tree✓  10/10Logs✓  10/10Reproduction scripts✓  10/10Detected secrets redacted: 21 command to reproduceEvidence score, not a guarantee. Review before sharing.
+ReproShot: 100/100 Repro Scorecapture failed · exit 1 · 0.28s. npm test. 2 detected secrets redacted.REPROSHOTcapture failed · exit 1 · 0.28s$npm testrepro score100/100gitab9e455 · main · cleanruntimenode 24.19.0 · npm 11.9.0 · +4logsstdout captured · stderr capturedreplayreproduce.sh · reproduce.ps1secrets redacted2Evidence completeness only. Review before sharing.
diff --git a/src/report.ts b/src/report.ts
index d51dc91..97fe268 100644
--- a/src/report.ts
+++ b/src/report.ts
@@ -98,6 +98,67 @@ export function issueBody(m: Manifest, stdout: string, stderr: string): string {
     '## Expected behavior\n\n\n\n## Additional context\n\n\n\nReproShot redacted detected secrets. Review this report before posting.\n',
   );
 }
+
+const statusText = (status: Manifest['status']): string =>
+  ({
+    success: 'capture succeeded',
+    failure: 'capture failed',
+    interrupted: 'capture interrupted',
+    'spawn-error': 'spawn error',
+  })[status];
+
+const statusHeading = (status: Manifest['status']): string => {
+  const value = statusText(status);
+  return value[0]!.toUpperCase() + value.slice(1);
+};
+
+const compactLine = (value: unknown, limit: number): string => {
+  const chars = [...String(value).replace(/\s+/gu, ' ').trim()];
+  return chars.length <= limit ? chars.join('') : `${chars.slice(0, limit - 1).join('')}…`;
+};
+
+const resultSummary = (m: Manifest): string => {
+  const result =
+    m.status === 'spawn-error'
+      ? 'not started'
+      : m.result.signal
+        ? `signal ${compactLine(m.result.signal, 18)}`
+        : m.result.exitCode === null
+          ? 'exit unavailable'
+          : `exit ${m.result.exitCode}`;
+  return `${statusText(m.status)} · ${result} · ${(m.result.durationMs / 1000).toFixed(2)}s`;
+};
+
+const runtimeSummary = (m: Manifest): string => {
+  const priority = (name: string) => (name === 'node' ? 0 : name === 'npm' ? 1 : 2);
+  const entries = Object.entries(m.environment.runtimes).sort(([a], [b]) => {
+    const rank = priority(a) - priority(b);
+    return rank || (a < b ? -1 : a > b ? 1 : 0);
+  });
+  if (!entries.length) return 'not detected';
+  const visible = entries
+    .slice(0, 2)
+    .map(([name, version]) => `${compactLine(name, 10)} ${compactLine(version, 16)}`);
+  if (entries.length > visible.length) visible.push(`+${entries.length - visible.length}`);
+  return visible.join(' · ');
+};
+
+const gitSummary = (m: Manifest): string => {
+  if (!m.git.available) return 'unavailable';
+  const commit = m.git.commit ? compactLine(m.git.commit.slice(0, 7), 7) : 'no commit';
+  const branch = m.git.branch ? compactLine(m.git.branch, 24) : 'detached';
+  const tree = m.git.dirty === null ? 'unknown' : m.git.dirty ? 'modified' : 'clean';
+  return `${commit} · ${branch} · ${tree}`;
+};
+
+const logsSummary = (m: Manifest): string =>
+  `stdout ${m.logs.stdout.truncated ? 'truncated' : 'captured'} · stderr ${m.logs.stderr.truncated ? 'truncated' : 'captured'}`;
+
+const replaySummary = (m: Manifest): string => {
+  if (!m.reproduction.scripts) return 'not available';
+  return `reproduce.sh · reproduce.ps1${m.reproduction.requiresEditing ? ' · edit required' : ''}`;
+};
+
 export function html(m: Manifest, stdout: string, stderr: string): string {
   const e = escapeHtml;
   const facts = [
@@ -105,11 +166,12 @@ export function html(m: Manifest, stdout: string, stderr: string): string {
     ['Duration', `${(m.result.durationMs / 1000).toFixed(2)}s`],
     ['Commit', m.git.commit?.slice(0, 7) ?? 'unavailable'],
     ['Secrets', `${m.redaction.detectedSecrets} redacted`],
+    ['Repro Score', `${m.score.total}/100`],
   ];
   return `
 ReproShot · ${e(m.status)}
REPROSHOT / LOCAL CAPTURE

${m.status === 'failure' ? 'Failure, with context.' : m.status === 'success' ? 'Command captured.' : 'Capture ' + e(m.status) + '.'}

One command. A report you can share.
${m.score.total} / 100REPRO SCORE
${e(m.command.display)}
${facts.map(([k, v]) => `
${k}${e(String(v))}
`).join('')}

Environment

System
${e(m.environment.os + ' ' + m.environment.release + ' / ' + m.environment.arch)}
Directory
${e(m.environment.cwd)}
${Object.entries( +:root{color-scheme:dark light;--bg:#0d1117;--panel:#161b22;--line:#30363d;--text:#e6edf3;--muted:#8b949e;--accent:#58a6ff;--red:#f85149;--green:#3fb950;--amber:#d29922}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.6 system-ui,sans-serif}main{max-width:960px;margin:0 auto;padding:36px 24px}header{border-bottom:1px solid var(--line);padding-bottom:18px}.brand{font:600 14px ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--muted);letter-spacing:.08em;text-transform:uppercase}h1{font-size:28px;line-height:1.25;margin:6px 0 0}.status-failure,.status-spawn-error{color:var(--red)}.status-success{color:var(--green)}.status-interrupted{color:var(--amber)}h2{font-size:18px;margin:28px 0 12px}.muted,small{color:var(--muted)}.command,pre{white-space:pre-wrap;overflow-wrap:anywhere;background:var(--panel);border:1px solid var(--line);padding:16px;border-radius:5px;font:13px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace}.command{font-size:16px;border-left:3px solid var(--accent);margin:22px 0}.command::before{content:'$ ';color:var(--accent)}.facts{display:grid;grid-template-columns:repeat(5,1fr);border-block:1px solid var(--line)}.fact{padding:13px 14px;border-right:1px solid var(--line)}.fact:first-child{padding-left:0}.fact:last-child{border:0}.fact small{display:block}.fact strong{font:500 13px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace}.grid{display:grid;grid-template-columns:1fr 1fr;gap:32px}dl{display:grid;grid-template-columns:105px minmax(0,1fr);gap:8px 14px;font-size:14px}dt{color:var(--muted)}dd{margin:0;overflow-wrap:anywhere}a{color:var(--accent)}ul{padding-left:20px}.files{display:flex;flex-wrap:wrap;gap:8px;padding:0;list-style:none}.files a{display:block;padding:6px 10px;border:1px solid var(--line);border-radius:4px;text-decoration:none;font:12px ui-monospace,SFMono-Regular,Consolas,monospace}footer{border-top:1px solid var(--line);margin-top:30px;padding-top:18px;font-size:12px;color:var(--muted)}summary{cursor:pointer;font-weight:600}details{margin-top:22px}pre{max-height:520px;overflow:auto}.notice{color:var(--muted);font-size:13px}@media(max-width:720px){main{padding:28px 18px}.grid{grid-template-columns:1fr;gap:0}.facts{grid-template-columns:1fr 1fr}.fact,.fact:first-child{padding:12px;border-bottom:1px solid var(--line)}}@media(prefers-color-scheme:light){:root{--bg:#f6f8fa;--panel:#fff;--line:#d0d7de;--text:#1f2328;--muted:#59636e;--accent:#0969da;--red:#cf222e;--green:#1a7f37;--amber:#9a6700}} +
ReproShot

${e(statusHeading(m.status))}

${e(m.command.display)}
${facts.map(([k, v]) => `
${k}${e(String(v))}
`).join('')}

Environment

System
${e(m.environment.os + ' ' + m.environment.release + ' / ' + m.environment.arch)}
Directory
${e(m.environment.cwd)}
${Object.entries( m.environment.runtimes, ) .map(([k, v]) => `
${e(k)}
${e(v)}
`) @@ -118,6 +180,15 @@ export function html(m: Manifest, stdout: string, stderr: string): string { )}

Source state

Commit
${e(m.git.commit ?? 'Not recorded')}
Branch
${e(m.git.branch ?? 'Detached or unavailable')}
Working tree
${m.git.dirty === null ? 'Unknown' : m.git.dirty ? 'Modified' : 'Clean'}
Patch
${e(m.git.patch)}
Captured
${e(m.timestamp)}
Signal
${e(m.result.signal ?? 'None')}

Reproduce

${e(requirements(m))}

${m.reproduction.requiresEditing ? '

Helpers stop until sanitized or shortened arguments are restored.

' : ''}
${e(renderSh(m.command.argv))}\n\n# PowerShell\n${e(renderPowerShell(m.command.argv))}
stderr ${m.logs.stderr.truncated ? '· truncated' : ''}
${e(preview(stderr) || '(empty)')}
stdout ${m.logs.stdout.truncated ? '· truncated' : ''}
${e(preview(stdout) || '(empty)')}

Bundle files

    ${m.files.map((f) => `
  • ${e(f)}
  • `).join('')}
Evidence behind the score
    ${m.score.items.map((i) => `
  • ${e(i.name)}: ${i.points}/${i.max} — ${e(i.detail)}
  • `).join('')}

Capture notes

    ${[m.redaction.notice, ...m.warnings, ...m.git.notes].map((x) => `
  • ${e(x)}
  • `).join('')}
ReproShot ${e(m.tool.version)} · Static report, no network requests. Score measures evidence completeness, not reproducibility or security guarantees. Review before sharing.
\n`; } export function svg(m: Manifest): string { - const rows = m.score.items; - return `ReproShot: ${m.score.total}/100 Repro Score${escapeHtml(m.command.display)}. ${escapeHtml(m.status)}. ${m.redaction.detectedSecrets} detected secrets redacted.REPROSHOT${escapeHtml(m.status.toUpperCase())}Reproducibility evidence${m.score.total} / 100${rows.map((item, i) => `${escapeHtml(item.name)}${item.points ? '✓' : '—'} ${item.points}/${item.max}`).join('')}Detected secrets redacted: ${m.redaction.detectedSecrets}${m.reproduction.requiresEditing ? 'Restore placeholders before reproducing' : '1 command to reproduce'}Evidence score, not a guarantee. Review before sharing.\n`; + const accent = + m.status === 'success' ? '#3fb950' : m.status === 'interrupted' ? '#d29922' : '#f85149'; + const lines = [ + ['repro score', `${m.score.total}/100`], + ['git', gitSummary(m)], + ['runtime', runtimeSummary(m)], + ['logs', logsSummary(m)], + ['replay', replaySummary(m)], + ['secrets redacted', m.redaction.detectedSecrets], + ]; + return `ReproShot: ${m.score.total}/100 Repro Score${escapeHtml(resultSummary(m))}. ${escapeHtml(compactLine(m.command.display, 74))}. ${m.redaction.detectedSecrets} detected secrets redacted.REPROSHOT${escapeHtml(resultSummary(m))}$${escapeHtml(compactLine(m.command.display, 74))}${lines.map(([label, value], i) => `${escapeHtml(String(label))}${escapeHtml(compactLine(value, 60))}`).join('')}Evidence completeness only. Review before sharing.\n`; } diff --git a/test/report.test.mjs b/test/report.test.mjs index d8b81ec..cc6d4ba 100644 --- a/test/report.test.mjs +++ b/test/report.test.mjs @@ -108,6 +108,11 @@ test('reports are deterministic and HTML escapes hostile log and metadata', () = assert.doesNotMatch(html(m, log, ''), / npm test'; + m.git.branch = '
'; + m.environment.runtimes = { + python: '3.12.0', + npm: '10.0.0', + cargo: '1.80.0', + node: '22.0.0', + }; + const image = svg(m); + assert.doesNotMatch(image, /