dw-kit 1.3.5 → 1.4.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/CLAUDE.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Workflow toolkit codebase. Rules live in `.claude/rules/` (auto-loaded).
4
4
 
5
5
  **v2.0 direction:** Context-First SDLC Governance Layer (5 pillars — see `.dw/core/PILLARS.md`)
6
- **Current:** v1.3.5 (released 2026-05-12) · ADR-0001 active · ADR-0005 Accepted (Supply-Chain Guard, sunset review 2026-08-12) · v1.4 cuts pending telemetry
6
+ **Current:** v1.3.6 (released 2026-05-14) · ADR-0001 active · ADR-0005 + ADR-0006 Accepted (Supply-Chain Guard 3-pillar AI-Native; sunset review 2026-08-12 per pillar) · v1.4 cuts pending telemetry
7
7
 
8
8
  ---
9
9
 
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > An AI development workflow toolkit for teams using agentic IDEs (Claude Code, Cursor) — from idea to review-ready commits.
4
4
 
5
- **v1.3.5** · `npm install -g dw-kit` · [Docs](docs/README.md) · [Get started](docs/get-started.md) · [Cheatsheet](docs/cheatsheet.md) · [Migration v1.3](MIGRATION-v1.3.md) · [Changelog](CHANGELOG.md)
5
+ **v1.3.6** · `npm install -g dw-kit` · [Docs](docs/README.md) · [Get started](docs/get-started.md) · [Cheatsheet](docs/cheatsheet.md) · [Migration v1.3](MIGRATION-v1.3.md) · [Changelog](CHANGELOG.md)
6
6
 
7
7
  ---
8
8
 
@@ -36,7 +36,9 @@ It’s designed for collaboration (Dev / Tech Lead / QA / PM) and keeps work aud
36
36
 
37
37
  ## Release notes
38
38
 
39
- - **v1.3.5** (2026-05-12) — AI-Native Supply-Chain Guard: `dw security-scan` CLI + OSV.dev auto-sync + Edit-lockfile hook + scoped `.gitignore` for end-user projects. See [`CHANGELOG.md#v135--2026-05-12`](CHANGELOG.md#v135--2026-05-12) and [ADR-0005](.dw/decisions/0005-supply-chain-guard.md). Public 90-day sunset review committed for 2026-08-12.
39
+ - **v1.4 (in progress)**Optional **Review Render Pipeline** ([ADR-0007](.dw/decisions/0007-decoupled-review-render-pipeline.md)): `/dw:review --visual` plus a separate `dw-kit-render` package turn findings into SVG + PNG cards for PR comments / Slack / stakeholders. Pure JS + WASM, universal `npm install`, no system deps. See [`docs/review-renderer.md`](docs/review-renderer.md).
40
+ - **v1.3.6** (2026-05-14) — Supply-Chain Guard upgraded to 3-pillar architecture: OSV snapshot + curated IoC fixture (version-aware, wired into default scan) + **AI-Native NEW-package heuristic** that catches zero-day-ish risk at the AI-edit boundary. See [`CHANGELOG.md#v136--2026-05-14`](CHANGELOG.md#v136--2026-05-14) and [ADR-0006](.dw/decisions/0006-supply-chain-guard-heuristic.md).
41
+ - v1.3.5 (2026-05-12) — AI-Native Supply-Chain Guard: `dw security-scan` CLI + OSV.dev auto-sync + Edit-lockfile hook + scoped `.gitignore` for end-user projects. See [ADR-0005](.dw/decisions/0005-supply-chain-guard.md). Public 90-day sunset review committed for 2026-08-12.
40
42
  - v1.3.4 (2026-04-21) — `/dw:plan` Quick Debate (red/blue self-critique), depth-driven activation
41
43
  - v1.3.3 (2026-04-21) — Writer skills v1/v2 compatibility fix
42
44
  - v1.3.0 (2026-04-21) — 5-pillar governance layer + telemetry foundation + ADRs + v2 task docs ([ADR-0001](.dw/decisions/0001-v2-pragmatic-lean.md))
@@ -44,6 +46,17 @@ It’s designed for collaboration (Dev / Tech Lead / QA / PM) and keeps work aud
44
46
  - Full changelog: `CHANGELOG.md`
45
47
  - Latest release notes: [GitHub Releases](https://github.com/dv-workflow/dv-workflow/releases)
46
48
 
49
+ ### What's in v1.3.6 for your team
50
+
51
+ Reaction time when a supply-chain incident drops goes from 24-72 hours (wait for OSV index + npm publish cycle) to **~1 hour** (AI edits lockfile → hook fires → heuristic flags BEFORE anyone knows).
52
+
53
+ - **3-pillar default scan** — `dw security-scan` now runs OSV snapshot + curated IoC fixture + AI-Native heuristic in one go. Heuristic only probes NEW/bumped packages from `git show HEAD:package-lock.json` diff — typical edit = 1-5 packages probed, not 1000+.
54
+ - **npm registry metadata heuristic** — composite scoring on `recent_publish` (<72h), `popular_package` (≥10k weekly DL), `maintainer_change_recent`, `major_version_jump`, `typo_squat`. Per-package metadata cached 1h. Tunable threshold via `.dw/config/dw.config.yml`.
55
+ - **Version-aware IoC fixture** — `affected_range` per entry. Concrete versions out-of-range are skipped (no false positives). Range specs (`^1.169.0`) emit ambiguity warnings with severity downgrade.
56
+ - **Hook fires `dw security-scan --heuristic-only`** on Claude Code lockfile edit — fast diff-only check.
57
+ - **Telemetry per pillar** — `source: 'osv' | 'fixture' | 'heuristic'` tracked separately so the 2026-08-12 sunset review attributes catches to the right pillar.
58
+ - **`>1000 packages`** crash bug from v1.3.5 fixed (chunked OSV batches).
59
+
47
60
  ### What's in v1.3.5 for your team
48
61
 
49
62
  - **`dw security-scan`** — scan for known supply-chain advisories against your project's `package-lock.json` (full match) or `package.json` (pre-install approximate). Uses [OSV.dev](https://osv.dev/) as data source (multi-maintainer upstream feed; no solo-curated bundle to go stale).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dw-kit",
3
- "version": "1.3.5",
3
+ "version": "1.4.0",
4
4
  "description": "AI development workflow toolkit — structured, quality-assured, team-ready. From requirements to dashboard.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -56,6 +56,7 @@
56
56
  },
57
57
  "scripts": {
58
58
  "test": "node src/smoke-test.mjs",
59
+ "test:renderer": "cd packages/dw-kit-render && npm test",
59
60
  "link": "npm link",
60
61
  "test:e2e-local": "bash scripts/e2e-local-check.sh"
61
62
  },
package/src/cli.mjs CHANGED
@@ -103,11 +103,14 @@ export function run(argv) {
103
103
 
104
104
  program
105
105
  .command('security-scan')
106
- .description('Scan project lockfile against advisory snapshot (OSV.dev). Supply-chain guard (ADR-0005, opt-in).')
106
+ .alias('scan')
107
+ .description('Scan project: 3-pillar supply-chain guard (OSV + fixture + AI-Native heuristic). Auto-syncs OSV snapshot if missing or stale.')
107
108
  .option('--quick', 'Offline mode — use existing snapshot only (default behavior)')
108
109
  .option('--update-db', 'Fetch fresh advisory snapshot from OSV.dev before scanning')
109
110
  .option('--pre-install', 'Scan package.json without lockfile (OSV name-only + namespace fixture)')
110
- .option('--offline', 'Skip network in --pre-install mode (fixture-only)')
111
+ .option('--offline', 'Skip network in --pre-install mode + skip pillar 3 heuristic')
112
+ .option('--no-heuristic', 'Skip pillar 3 (AI-Native NEW-package heuristic). Default: enabled on lockfile diff.')
113
+ .option('--heuristic-only', 'Run ONLY pillar 3 (used by hook). Skips OSV + fixture pillars.')
111
114
  .option('--json', 'Output machine-readable JSON')
112
115
  .option('--install-hook', 'Wire supply-chain-scan.sh into .claude/settings.json PostToolUse (idempotent)')
113
116
  .option('--uninstall-hook', 'Remove supply-chain-scan.sh entry from .claude/settings.json')
@@ -128,6 +131,21 @@ export function run(argv) {
128
131
  await securityScanCommand(opts);
129
132
  });
130
133
 
134
+ const reviewCmd = program
135
+ .command('review')
136
+ .description('Review subcommands (ADR-0007)');
137
+
138
+ reviewCmd
139
+ .command('render <manifest>')
140
+ .description('Render a /dw:review --visual manifest into SVG/PNG (requires dw-kit-render) or markdown summary fallback')
141
+ .option('-f, --format <kind>', 'Override output formats: svg | png | both', null)
142
+ .option('-s, --strategy <name>', 'Override strategy: auto | plugin | markdown-only', null)
143
+ .option('-q, --quiet', 'Suppress info logs (still exits non-zero on hard errors)')
144
+ .action(async (manifest, opts) => {
145
+ const { reviewRenderCommand } = await import('./commands/review-render.mjs');
146
+ await reviewRenderCommand(manifest, opts);
147
+ });
148
+
131
149
  program
132
150
  .command('claude-vn-fix')
133
151
  .description('Patch Claude CLI to fix Vietnamese IME (local, with backup/restore)')
@@ -1,12 +1,29 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
2
3
  import { join, resolve } from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import { header, ok, warn, err, info, log } from '../lib/ui.mjs';
5
- import { loadConfig, getToolkitVersions } from '../lib/config.mjs';
6
+ import { loadConfig, getToolkitVersions, getReviewRendererConfig } from '../lib/config.mjs';
6
7
  import { detectPlatform, platformLabel } from '../lib/platform.mjs';
7
8
  import { snapshotInfo } from '../lib/sc-sync.mjs';
8
9
 
9
10
  const TOOLKIT_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..', '..');
11
+ const RENDER_PACKAGE = 'dw-kit-render';
12
+
13
+ function tryResolveRenderer(projectDir) {
14
+ if (process.env.DW_REVIEW_NO_RENDERER === '1') return { ok: false, reason: 'disabled by DW_REVIEW_NO_RENDERER=1' };
15
+ const reqProject = createRequire(join(projectDir, 'package.json'));
16
+ for (const req of [reqProject, createRequire(import.meta.url)]) {
17
+ try {
18
+ const pkgPath = req.resolve(`${RENDER_PACKAGE}/package.json`);
19
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
20
+ return { ok: true, version: pkg.version };
21
+ } catch {
22
+ // try next
23
+ }
24
+ }
25
+ return { ok: false };
26
+ }
10
27
 
11
28
  const CORE_FILES = [
12
29
  '.dw/core/WORKFLOW.md',
@@ -151,6 +168,29 @@ export async function doctorCommand() {
151
168
  }
152
169
  }
153
170
 
171
+ info('Review Render Pipeline (ADR-0007, opt-in)');
172
+ const rendererCfg = existsSync(configPath) ? getReviewRendererConfig(loadConfig(configPath) || {}) : getReviewRendererConfig({});
173
+ log(` Strategy : ${rendererCfg.strategy}`);
174
+ log(` Formats : ${rendererCfg.formats.join(', ')}`);
175
+ log(` Theme/Font : ${rendererCfg.theme} / ${rendererCfg.font}`);
176
+
177
+ if (rendererCfg.strategy === 'markdown-only') {
178
+ log(' Renderer : skipped (strategy=markdown-only)');
179
+ } else {
180
+ const r = tryResolveRenderer(projectDir);
181
+ if (r.ok) {
182
+ ok(`Renderer : dw-kit-render v${r.version || '?'} resolvable`);
183
+ } else if (rendererCfg.strategy === 'plugin') {
184
+ err("Renderer : 'dw-kit-render' NOT installed but strategy='plugin'");
185
+ log(` Install: npm install -g dw-kit-render`);
186
+ issues++;
187
+ } else {
188
+ warn(`Renderer : 'dw-kit-render' not installed — /dw:review --visual will fall back to markdown`);
189
+ log(` Install (optional): npm install -g dw-kit-render`);
190
+ warnings++;
191
+ }
192
+ }
193
+
154
194
  info('Supply-Chain Guard (ADR-0005, opt-in)');
155
195
  const sc = snapshotInfo(projectDir);
156
196
  if (!sc.exists) {
@@ -193,7 +193,51 @@ async function maybeInstallSupplyChainHook(projectDir, presetKey) {
193
193
  }
194
194
  if (result.action === 'added') {
195
195
  ok('Supply-chain guard hook wired (ADR-0005 — opt-in flag enabled)');
196
- log(' First scan: `dw security-scan --update-db`');
196
+ }
197
+
198
+ // UX: offer one-time OSV snapshot sync so end-user doesn't need to know
199
+ // `--update-db` exists. Lazy auto-refresh in `dw scan` covers the case
200
+ // when user declines, but offering during init gets snapshot ready upfront.
201
+ await maybeBootstrapOsvSnapshot(projectDir);
202
+ }
203
+
204
+ async function maybeBootstrapOsvSnapshot(projectDir) {
205
+ // Non-TTY (CI / scripted): default yes so the snapshot exists for next scan.
206
+ // TTY: prompt user, default yes.
207
+ let shouldSync = true;
208
+ if (process.stdin.isTTY && !process.env.DW_INIT_NO_PROMPT) {
209
+ try {
210
+ const { prompt } = await import('enquirer');
211
+ const answer = await prompt({
212
+ type: 'confirm',
213
+ name: 'syncNow',
214
+ message: 'Sync OSV advisory snapshot now? (recommended first-time; ~10-30s)',
215
+ initial: true,
216
+ });
217
+ shouldSync = !!answer.syncNow;
218
+ } catch {
219
+ shouldSync = true;
220
+ }
221
+ }
222
+ if (!shouldSync) {
223
+ log(' Skipped. First `dw scan` will auto-sync if needed.');
224
+ return;
225
+ }
226
+
227
+ const { findLockfile } = await import('../lib/sc-scanner.mjs');
228
+ if (!findLockfile(projectDir)) {
229
+ log(' No lockfile yet — first `dw scan` will sync after `npm install` runs.');
230
+ return;
231
+ }
232
+ log(' Syncing OSV snapshot...');
233
+ try {
234
+ const { syncSnapshotForProject } = await import('../lib/sc-sync.mjs');
235
+ const start = Date.now();
236
+ const res = await syncSnapshotForProject(projectDir);
237
+ const partialNote = res.partial ? ` (PARTIAL ${res.chunks.failed}/${res.chunks.total})` : '';
238
+ ok(`OSV snapshot ready — ${res.advisoryCount} advisories for ${res.packageCount} packages (${Date.now() - start}ms)${partialNote}`);
239
+ } catch (e) {
240
+ warn(`OSV sync skipped: ${e.message}. Will retry on first \`dw scan\`.`);
197
241
  }
198
242
  }
199
243
 
@@ -0,0 +1,255 @@
1
+ import { createRequire } from 'node:module';
2
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, resolve, isAbsolute } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { performance } from 'node:perf_hooks';
6
+ import chalk from 'chalk';
7
+ import { header, ok, info, log, warn, err } from '../lib/ui.mjs';
8
+ import { loadConfigWithLocal, getReviewRendererConfig } from '../lib/config.mjs';
9
+ import { readManifest } from '../lib/review/manifest-validator.mjs';
10
+ import { logEvent } from '../lib/telemetry.mjs';
11
+
12
+ const RENDER_PACKAGE = 'dw-kit-render';
13
+ const INSTALL_HINT = ` Install renderer with: ${chalk.cyan('npm install -g dw-kit-render')}\n Or run: ${chalk.cyan('dw doctor')} for environment check.`;
14
+
15
+ /**
16
+ * `dw review render <manifest>` — invoked by /dw:review --visual after writing manifest.
17
+ * See ADR-0007 for architecture.
18
+ *
19
+ * @param {string} manifestPath - path to manifest.json (relative or absolute)
20
+ * @param {{format?: string, strategy?: string, quiet?: boolean}} opts
21
+ */
22
+ export async function reviewRenderCommand(manifestPath, opts = {}) {
23
+ const projectDir = process.cwd();
24
+ const absManifest = isAbsolute(manifestPath) ? manifestPath : resolve(projectDir, manifestPath);
25
+ const startedAt = performance.now();
26
+
27
+ if (!opts.quiet) header('dw review render');
28
+
29
+ // 1. Load + validate manifest.
30
+ const parseResult = readManifest(absManifest);
31
+ if (!parseResult.ok) {
32
+ err(`Manifest invalid: ${absManifest}`);
33
+ for (const e of parseResult.errors.slice(0, 10)) {
34
+ log(` ${chalk.dim(e.path || '/')} — ${e.message}`);
35
+ }
36
+ logEvent({ event: 'review_render', action: 'fail', fallback_reason: 'invalid-manifest' }, projectDir);
37
+ process.exit(1);
38
+ }
39
+ const manifest = parseResult.manifest;
40
+
41
+ // 2. Resolve config + strategy.
42
+ const config = loadConfigWithLocal(join(projectDir, '.dw', 'config')) || {};
43
+ const rendererCfg = getReviewRendererConfig(config);
44
+ const strategy = opts.strategy || rendererCfg.strategy;
45
+ const formats = parseFormats(opts.format) || rendererCfg.formats;
46
+
47
+ // 3. Resolve output directory.
48
+ const outDir = resolveOutputDir(rendererCfg.output_dir, manifest, projectDir);
49
+ if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
50
+
51
+ if (!opts.quiet) {
52
+ info('Render context');
53
+ log(` Manifest : ${absManifest}`);
54
+ log(` Scope : ${manifest.scope} (slug: ${manifest.scope_slug || '—'})`);
55
+ log(` Findings : ${manifest.findings.length}`);
56
+ log(` Strategy : ${strategy}`);
57
+ log(` Formats : ${formats.join(', ')}`);
58
+ log(` Output dir : ${outDir}`);
59
+ }
60
+
61
+ // 4. Resolve renderer (dw-kit-render package).
62
+ const rendererResolved = strategy === 'markdown-only' ? null : await tryResolveRenderer(projectDir);
63
+ const finalStrategy = strategy === 'markdown-only'
64
+ ? 'markdown-only'
65
+ : (rendererResolved ? 'plugin' : (strategy === 'plugin' ? 'plugin-missing' : 'fallback-markdown'));
66
+
67
+ // Plugin strategy was requested but package is missing — fail loudly.
68
+ if (strategy === 'plugin' && !rendererResolved) {
69
+ err(`Strategy 'plugin' requested but '${RENDER_PACKAGE}' is not installed.`);
70
+ log(INSTALL_HINT);
71
+ logEvent({ event: 'review_render', action: 'fail', fallback_reason: 'no-renderer', strategy, formats }, projectDir);
72
+ process.exit(1);
73
+ }
74
+
75
+ let artifacts = { svg: [], png: [] };
76
+ let renderErrors = [];
77
+
78
+ if (rendererResolved) {
79
+ try {
80
+ if (!opts.quiet) info('Rendering with dw-kit-render');
81
+ const result = await rendererResolved.render({
82
+ manifest,
83
+ outDir,
84
+ formats,
85
+ theme: rendererCfg.theme,
86
+ font: rendererCfg.font,
87
+ });
88
+ artifacts = {
89
+ svg: Array.isArray(result?.svgPaths) ? result.svgPaths : [],
90
+ png: Array.isArray(result?.pngPaths) ? result.pngPaths : [],
91
+ };
92
+ } catch (e) {
93
+ renderErrors.push(e.message || String(e));
94
+ warn(`Renderer error: ${e.message || e}`);
95
+ warn('Falling back to markdown-only summary.');
96
+ }
97
+ } else if (strategy !== 'markdown-only') {
98
+ if (!opts.quiet) {
99
+ warn(`'${RENDER_PACKAGE}' not found — emitting markdown summary only.`);
100
+ log(INSTALL_HINT);
101
+ }
102
+ }
103
+
104
+ // 5. Write summary.md (always — works without renderer too).
105
+ const summaryPath = join(outDir, 'summary.md');
106
+ writeFileSync(summaryPath, buildSummaryMarkdown(manifest, artifacts, { outDir, projectDir }), 'utf-8');
107
+
108
+ const durationMs = Math.round(performance.now() - startedAt);
109
+
110
+ if (!opts.quiet) {
111
+ console.log();
112
+ info('Artifacts');
113
+ log(` Summary : ${summaryPath}`);
114
+ if (artifacts.svg.length) log(` SVG : ${artifacts.svg.length} file(s)`);
115
+ if (artifacts.png.length) log(` PNG : ${artifacts.png.length} file(s)`);
116
+ if (!artifacts.svg.length && !artifacts.png.length) log(` ${chalk.dim('(markdown only — install dw-kit-render for images)')}`);
117
+ console.log();
118
+ if (renderErrors.length) {
119
+ warn(`${renderErrors.length} render error(s) — see above`);
120
+ } else {
121
+ ok(`Done in ${durationMs}ms`);
122
+ }
123
+ }
124
+
125
+ logEvent({
126
+ event: 'review_render',
127
+ action: renderErrors.length ? 'partial' : 'success',
128
+ strategy: finalStrategy,
129
+ formats,
130
+ findings: manifest.findings.length,
131
+ duration_ms: durationMs,
132
+ fallback_reason: rendererResolved ? null : (strategy === 'markdown-only' ? 'config-markdown-only' : 'no-renderer'),
133
+ }, projectDir);
134
+
135
+ process.exit(0);
136
+ }
137
+
138
+ function parseFormats(value) {
139
+ if (!value) return null;
140
+ if (value === 'both') return ['svg', 'png'];
141
+ if (value === 'svg' || value === 'png') return [value];
142
+ return null;
143
+ }
144
+
145
+ function resolveOutputDir(configDir, manifest, projectDir) {
146
+ const baseDir = isAbsolute(configDir) ? configDir : join(projectDir, configDir);
147
+ const slug = manifest.scope_slug || manifest.scope || 'review';
148
+ return join(baseDir, slug);
149
+ }
150
+
151
+ async function tryResolveRenderer(projectDir) {
152
+ // Test-only escape hatch: tests use this to force the markdown fallback even
153
+ // when a local renderer is dev-linked from a sibling packages/ dir.
154
+ if (process.env.DW_REVIEW_NO_RENDERER === '1') return null;
155
+
156
+ // Resolution order: project node_modules → CLI's own node_modules (global -g case).
157
+ // We use createRequire to get a *resolution* path, then dynamic-import that URL
158
+ // so the renderer can be ESM-only (Phase 1 ships ESM only).
159
+ for (const anchor of [join(projectDir, 'package.json'), import.meta.url]) {
160
+ try {
161
+ const req = createRequire(anchor);
162
+ const entry = req.resolve(RENDER_PACKAGE);
163
+ const mod = await import(pathToFileURL(entry).href);
164
+ const render = mod?.render || mod?.default?.render;
165
+ if (typeof render !== 'function') {
166
+ throw new Error("dw-kit-render module did not export a 'render' function");
167
+ }
168
+ return { render };
169
+ } catch {
170
+ // try next anchor
171
+ }
172
+ }
173
+ return null;
174
+ }
175
+
176
+ function buildSummaryMarkdown(manifest, artifacts, { outDir, projectDir }) {
177
+ const lines = [];
178
+ const counts = countBySeverity(manifest.findings);
179
+ const slug = manifest.scope_slug || manifest.scope;
180
+
181
+ lines.push(`# Review summary — ${manifest.scope}`);
182
+ lines.push('');
183
+ lines.push(`- Generated: ${manifest.generated_at}`);
184
+ if (manifest.review_meta?.diff_base) lines.push(`- Diff base: ${manifest.review_meta.diff_base}`);
185
+ if (manifest.review_meta?.files_reviewed != null) lines.push(`- Files reviewed: ${manifest.review_meta.files_reviewed}`);
186
+ if (manifest.task_id) lines.push(`- Task: \`${manifest.task_id}\``);
187
+ lines.push('');
188
+ lines.push(`**Severity counts:** critical=${counts.critical}, warning=${counts.warning}, suggestion=${counts.suggestion}`);
189
+ lines.push('');
190
+
191
+ if (!manifest.findings.length) {
192
+ lines.push('No findings.');
193
+ lines.push('');
194
+ return lines.join('\n');
195
+ }
196
+
197
+ lines.push('## Findings');
198
+ lines.push('');
199
+ for (const f of manifest.findings) {
200
+ const loc = formatLocation(f.location);
201
+ lines.push(`### [${f.severity.toUpperCase()}] ${f.title}`);
202
+ lines.push('');
203
+ lines.push(`- File: \`${loc}\``);
204
+ if (f.rule_ref) lines.push(`- Rule: ${f.rule_ref}`);
205
+
206
+ const svg = artifacts.svg.find((p) => p.endsWith(`finding-${f.id}.svg`));
207
+ const png = artifacts.png.find((p) => p.endsWith(`finding-${f.id}.png`));
208
+ if (svg) lines.push(`- SVG: \`${relativeTo(svg, outDir)}\``);
209
+ if (png) lines.push(`- PNG: \`${relativeTo(png, outDir)}\``);
210
+ lines.push('');
211
+ lines.push(f.body);
212
+ lines.push('');
213
+ if (f.code_snippet) {
214
+ const lang = f.language || '';
215
+ lines.push('```' + lang);
216
+ lines.push(f.code_snippet);
217
+ lines.push('```');
218
+ lines.push('');
219
+ }
220
+ if (f.fix) {
221
+ lines.push(`**Fix:** ${f.fix}`);
222
+ lines.push('');
223
+ }
224
+ lines.push('---');
225
+ lines.push('');
226
+ }
227
+
228
+ lines.push(`*Manifest: \`${relativeTo(join(outDir, 'manifest.json'), projectDir)}\`*`);
229
+ lines.push('');
230
+ return lines.join('\n');
231
+ }
232
+
233
+ function countBySeverity(findings) {
234
+ const out = { critical: 0, warning: 0, suggestion: 0 };
235
+ for (const f of findings) {
236
+ if (out[f.severity] != null) out[f.severity]++;
237
+ }
238
+ return out;
239
+ }
240
+
241
+ function formatLocation(loc) {
242
+ if (!loc) return '?';
243
+ if (loc.line_start && loc.line_end && loc.line_start !== loc.line_end) {
244
+ return `${loc.file}:${loc.line_start}-${loc.line_end}`;
245
+ }
246
+ if (loc.line_start) return `${loc.file}:${loc.line_start}`;
247
+ return loc.file;
248
+ }
249
+
250
+ function relativeTo(target, base) {
251
+ const t = target.replace(/\\/g, '/');
252
+ const b = base.replace(/\\/g, '/').replace(/\/$/, '');
253
+ if (t.startsWith(b + '/')) return t.slice(b.length + 1);
254
+ return t;
255
+ }