diff --git a/DOCS.md b/DOCS.md index fbc09c5d..9a314a0e 100644 --- a/DOCS.md +++ b/DOCS.md @@ -459,46 +459,63 @@ Creates: ### vg report -Render a saved scan artifact. There is no HTML report. The formats are `md`, -`text`, and `json`. `text` is the default. `json` prints the artifact unchanged -and is the only stable machine-readable contract. `sarif` is not a `vg report` -format; write that with `vg scan --format sarif`. +Render a saved scan artifact. The formats are `md`, `text`, `json`, and `html`. +`text` is the default. `json` prints the artifact unchanged and is the only +stable machine-readable contract. `sarif` is not a `vg report` format; write +that with `vg scan --format sarif`. ```bash -vg report [--in ] [--format md|text|json] +vg report [--in ] [--format md|text|json|html] ``` -| Flag | Default | Description | -| ---------- | ---------------------------- | --------------------------------------------------------------------------- | -| `--in` | `.vibgrate/scan_result.json` | Input artifact file | -| `--format` | `text` | `md`, `text`, or `json`. Any other value is a usage error (exit `5`) | +| Flag | Default | Description | +| ---------- | ---------------------------- | -------------------------------------------------------------------------------- | +| `--in` | `.vibgrate/scan_result.json` | Input artifact file | +| `--format` | `text` | `md`, `text`, `json`, or `html`. Any other value is a usage error (exit `5`) | An unknown value is rejected before the file is read. The process exits `5`, writes nothing to stdout, and prints one stderr line that names the value and lists the valid ones: ```text -error: unknown --format "html" (expected md, text, json) +error: unknown --format "sarif" (expected md, text, json, html) ``` The comparison is exact. `HTML`, `sarif`, and `md ` are unknown. Omitting `--format` stays on `text`. +`md`, `text`, and `json` are unchanged. `html` writes one self-contained page +from that same artifact. It does not call the network and does not load +external files. The page opens with a summary: + +- DriftScore and the four component scores. A missing score is `n/a`. A measured `0` stays `0`. +- Counts by severity for vulnerability findings and for security findings. A check that did not run stays "Not scanned" rather than a row of zeros. A check that finished with nothing to report shows zeros. +- When a finding carries `details.fixedVersions`, how many of those findings name a fix. +- Links to the full tables under the summary: vulnerabilities, security findings, and the other findings. + +The same artifact always produces the same page. Save it with a shell redirect: + +```bash +vg report --format html > report.html +``` + #### Report format coverage `--format md` is `formatMarkdown` (`src/reporting/formatters/markdown.ts`). -`--format text` is `formatText` (`src/reporting/formatters/text.ts`). Both read -the same artifact. A section below is omitted when its guard is false. Lists -follow artifact order except where a sort is named. Colours in the text report -are terminal styling; under `NO_COLOR` the same words are plain. A null score -is `n/a` in both formats. A measured `0` stays `0`. +`--format text` is `formatText` (`src/reporting/formatters/text.ts`). +`--format html` is `formatHtmlReport` (`src/reporting/formatters/html.ts`). +All three read the same artifact. A section below is omitted when its guard is +false. Lists follow artifact order except where a sort is named. Colours in the +text report are terminal styling; under `NO_COLOR` the same words are plain. A +null score is `n/a` in the human formats. A measured `0` stays `0`. -The same artifact, three times: +The same artifact, four formats: ```bash vg report --in .vibgrate/scan_result.json --format md vg report --in .vibgrate/scan_result.json --format text vg report --in .vibgrate/scan_result.json --format json +vg report --in .vibgrate/scan_result.json --format html ``` ##### Markdown (`formatMarkdown`) @@ -566,7 +583,11 @@ When set: | Architecture Layers | `extended.architecture`. `archetype`, `archetypeConfidence`, `totalClassified`, `unclassified`. **Folders** — the first 12 of `folders`: `path`, `layer`, `confidence`, `fileCount`. **Coverage**, when set — `ratio`, `classified`, `unclassified`, and `bySource` counts sorted by count descending then source name. **Unclassified source** — `unclassifiedFiles.length` when that list is non-empty. **Unclassified by folder** — the first 8 of `unclassifiedFolders`: `count`, `path`. **Layers** with `fileCount` greater than 0: `layer`, `fileCount`, `driftScore`, `riskLevel`. **Boundary violations** — violations whose `rule` does not start with `graph-conflict:`, first 8: `fromFile`, `toFile`, `rule`. **Layer conflicts** — the `graph-conflict:` violations, first 8, same three fields. A capped violation list is marked when `violationsCapped` is set | | Solution Drift Summary | `solutions` is non-empty. `name`, `projectPaths.length`, and `drift.score` or `n/a` | -`relationshipDiagram` is not rendered. The text formatter leaves that diagram in the JSON artifact. +`relationshipDiagram` is not rendered. Markdown, text, and HTML leave that diagram in the JSON artifact. + +##### HTML (`formatHtmlReport`) + +`vg report --format html` writes one self-contained page from the same artifact. It does not call the network and does not load external files. The page opens with the summary listed above (DriftScore, severity counts, a fix-available count when a finding carries `details.fixedVersions`, and links to the tables), then the full vulnerability, security, and other-finding tables. A check that did not run stays "Not scanned". A missing score is `n/a`. The same artifact always produces the same page. ##### Differences @@ -579,10 +600,11 @@ When set: - DriftScore: Markdown puts the lower-is-better gloss in the opening cell. Text puts the score in **DriftScore Summary** without that gloss. - VCS: Markdown uses separate VCS, Branch, and Commit rows, and Commit requires `vcs.sha`. Text uses one line and can show `shortSha` without requiring `sha`. - Runtime wording: Markdown always says `major(s)`. Text says `major` or `majors`. +- HTML is the browser page: the summary above, then the vulnerability, security, and other-finding tables. It does not print Markdown's recommended-standards section or the text inventory. ##### JSON -`--format json` prints `JSON.stringify` of the loaded artifact with a two-space indent. Keys, values, and omissions are unchanged. Parse this when a script needs a field. Markdown and text drop fields, cap lists, and add derived lines; they are not a stable contract. +`--format json` prints `JSON.stringify` of the loaded artifact with a two-space indent. Keys, values, and omissions are unchanged. Parse this when a script needs a field. Markdown, text, and HTML drop fields, cap lists, and add derived lines; they are not a stable contract. --- diff --git a/plugins/claude/vg/skills/vg/SKILL.md b/plugins/claude/vg/skills/vg/SKILL.md index 029ee9e4..ada3b7d4 100644 --- a/plugins/claude/vg/skills/vg/SKILL.md +++ b/plugins/claude/vg/skills/vg/SKILL.md @@ -3,7 +3,7 @@ name: vg description: Query the local code graph (vg) for structure, impact, and navigation instead of grepping/reading many files. --- - + # vg — the code map @@ -39,7 +39,7 @@ measured and the tools improved): - **Upgrade drift:** `vg drift` lists what is outdated across dependencies (offline; `--online` for currency). `vg scan` scores upgrade drift (`text`, `json`, `sarif`, or `md`). `vg report` renders a saved scan - artifact as `text` (the default), `md`, or `json`. There is no HTML report. + artifact as `text` (the default), `md`, `json`, or `html` (a self-contained page). - **Version-correct docs:** `vg lib ` returns drift-annotated, version- specific usage docs for a library — inject these instead of guessing an API. diff --git a/skills/vg/SKILL.md b/skills/vg/SKILL.md index 4ad6401d..05fbb225 100644 --- a/skills/vg/SKILL.md +++ b/skills/vg/SKILL.md @@ -3,7 +3,7 @@ name: vg description: Query the local code graph (vg) for structure, impact, and navigation instead of grepping/reading many files. --- - + # vg — the code map @@ -39,7 +39,7 @@ measured and the tools improved): - **Upgrade drift:** `vg drift` lists what is outdated across dependencies (offline; `--online` for currency). `vg scan` scores upgrade drift (`text`, `json`, `sarif`, or `md`). `vg report` renders a saved scan - artifact as `text` (the default), `md`, or `json`. There is no HTML report. + artifact as `text` (the default), `md`, `json`, or `html` (a self-contained page). - **Version-correct docs:** `vg lib ` returns drift-annotated, version- specific usage docs for a library — inject these instead of guessing an API. diff --git a/src/install/content.ts b/src/install/content.ts index 76e05e34..632a7d3a 100644 --- a/src/install/content.ts +++ b/src/install/content.ts @@ -22,9 +22,10 @@ export const NUDGE_END = ''; * v3 = library-docs workflow resolves by package name only, and an * `entityCount` of 0 means read `node_modules` rather than retry; * v4 = `vg report` formats are text (default), md, and json. There is no - * HTML report, and sarif belongs to `vg scan`. + * HTML report, and sarif belongs to `vg scan`; + * v5 = `vg report` also accepts `html` (a self-contained page). */ -export const INSTALL_CONTENT_VERSION = 4; +export const INSTALL_CONTENT_VERSION = 5; const VERSION_MARKER_RE = //; @@ -94,7 +95,7 @@ measured and the tools improved): - **Upgrade drift:** \`vg drift\` lists what is outdated across dependencies (offline; \`--online\` for currency). \`vg scan\` scores upgrade drift (\`text\`, \`json\`, \`sarif\`, or \`md\`). \`vg report\` renders a saved scan - artifact as \`text\` (the default), \`md\`, or \`json\`. There is no HTML report. + artifact as \`text\` (the default), \`md\`, \`json\`, or \`html\` (a self-contained page). - **Version-correct docs:** \`vg lib \` returns drift-annotated, version- specific usage docs for a library — inject these instead of guessing an API. diff --git a/src/reporting/commands/report.test.ts b/src/reporting/commands/report.test.ts index ec118114..2fb793b7 100644 --- a/src/reporting/commands/report.test.ts +++ b/src/reporting/commands/report.test.ts @@ -4,6 +4,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { main } from '../../cli.js'; import { ExitCode } from '../../util/exit.js'; +import { REPORT_FORMATS, reportCommand } from './report.js'; const SENTINEL = 'report-format-sentinel'; @@ -91,14 +92,45 @@ async function runCli(args: string[]): Promise<{ code: number; stderr: string; s } describe('vg report --format', () => { + it('names html in help and in the accepted list', () => { + const option = reportCommand.options.find((item) => item.long === '--format'); + expect(option?.description).toBe('Output format (md|text|json|html)'); + expect(REPORT_FORMATS).toEqual(['md', 'text', 'json', 'html']); + }); + + it('renders html from the local artifact and leaves text, json, and md alone', async () => { + const file = writeArtifact(); + const html = await runCli(['report', '--in', file, '--format', 'html']); + expect(html.code).toBe(0); + expect(html.stdout).toContain(''); + expect(html.stdout).toContain('id="summary"'); + expect(html.stdout).toContain('id="drift-score">1'); + expect(html.stderr).toBe(''); + + const text = await runCli(['report', '--in', file]); + expect(text.code).toBe(0); + expect(text.stdout).toContain('Vibgrate Drift Report'); + expect(text.stdout).not.toContain(' { const file = writeArtifact(); - for (const format of ['html', 'HTML', 'sarif']) { + for (const format of ['HTML', 'sarif', 'html ']) { const result = await runCli(['report', '--in', file, '--format', format]); expect(result.code).toBe(ExitCode.USAGE_ERROR); expect(result.stdout).toBe(''); expect(result.stderr).toBe( - `error: unknown --format ${JSON.stringify(format)} (expected md, text, json)\n`, + `error: unknown --format ${JSON.stringify(format)} (expected md, text, json, html)\n`, ); expect(result.stderr).not.toContain(SENTINEL); } diff --git a/src/reporting/commands/report.ts b/src/reporting/commands/report.ts index 136175ab..bd984159 100644 --- a/src/reporting/commands/report.ts +++ b/src/reporting/commands/report.ts @@ -5,10 +5,11 @@ import { usageError } from '../../util/exit.js'; import { pathExists, readJsonFile } from '../utils/fs.js'; import { formatText } from '../formatters/text.js'; import { formatMarkdown } from '../formatters/markdown.js'; +import { formatHtmlReport } from '../formatters/html.js'; import type { ReportFormat, ScanArtifact } from '../types.js'; -/** Accepted by `vg report --format`. `text` is the commander default. */ -const REPORT_FORMATS = ['md', 'text', 'json'] as const satisfies readonly ReportFormat[]; +/** Formats `vg report` accepts. `text` is the default. */ +export const REPORT_FORMATS = ['md', 'text', 'json', 'html'] as const satisfies readonly ReportFormat[]; function assertReportFormat(format: string): asserts format is ReportFormat { if ((REPORT_FORMATS as readonly string[]).includes(format)) return; @@ -43,5 +44,8 @@ export const reportCommand = new Command('report') case 'text': console.log(formatText(artifact)); break; + case 'html': + console.log(formatHtmlReport(artifact)); + break; } }); diff --git a/src/reporting/formatters/html.test.ts b/src/reporting/formatters/html.test.ts new file mode 100644 index 00000000..96ab9deb --- /dev/null +++ b/src/reporting/formatters/html.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it } from 'vitest'; +import type { SecurityFinding } from '../../core-open/types.js'; +import type { ScanArtifact } from '../types.js'; +import { ADVISORY_CORPUS_FOOTER_ID, FIX_AVAILABLE_ATTR, formatHtmlReport } from './html.js'; + +const TOKEN = ['ghp', '0123456789abcdefghijklmnopqrstuvwxyzA'].join('_'); + +function artifact(overrides: Partial = {}): ScanArtifact { + return { + schemaVersion: '1.0', + timestamp: '2026-02-16T00:00:00.000Z', + vibgrateVersion: '0.0.0', + rootPath: '/work', + projects: [], + findings: [], + drift: { + score: null, + riskLevel: null, + components: { + runtimeScore: null, + frameworkScore: null, + dependencyScore: null, + eolScore: null, + }, + }, + ...overrides, + }; +} + +function security(overrides: Partial & Pick): SecurityFinding { + return { + pack: 'iac-cis-v1', + packVersion: '1', + rule: 'rule', + ...overrides, + }; +} + +function block(html: string, id: string): string { + const match = new RegExp(`<(p|ul|dd) id="${id}"[\\s\\S]*?`).exec(html); + if (!match) throw new Error(`missing #${id}`); + return match[0]; +} + +function section(html: string, id: string): string { + const start = html.indexOf(`
`); + const end = html.indexOf('
', start); + return html.slice(start, end); +} + +function sample(): ScanArtifact { + return artifact({ + drift: { + score: null, + riskLevel: null, + components: { + runtimeScore: 0, + frameworkScore: null, + dependencyScore: null, + eolScore: null, + }, + }, + findings: [ + { + ruleId: 'vibgrate/vulnerability', + level: 'note', + message: `low row token ${TOKEN}`, + location: 'left-pad', + details: { + severity: 'low', + package: 'left-pad', + advisoryId: 'GHSA-low', + cvss: 0, + }, + }, + { + ruleId: 'vibgrate/vulnerability', + level: 'error', + message: ``, + location: 'left-pad', + details: { package: 'left-pad', advisoryId: 'GHSA-miss' }, + }, + { + ruleId: 'vibgrate/vulnerability', + level: 'error', + message: 'critical row', + location: 'left-pad', + details: { severity: 'CRITICAL', package: 'left-pad', advisoryId: 'GHSA-crit', cvss: null }, + }, + { + ruleId: 'vibgrate/vulnerability', + level: 'warning', + message: 'moderate row', + location: 'left-pad', + details: { + severity: 'moderate', + package: 'left-pad', + advisoryId: 'GHSA-mod', + fixedVersions: ['4.17.21'], + }, + }, + { + ruleId: 'vibgrate/vulnerability', + level: 'error', + message: 'high row', + location: 'left-pad', + details: { severity: 'high', package: 'left-pad', advisoryId: 'GHSA-high', cvss: null, fixedVersions: [] }, + }, + { + ruleId: 'vibgrate/runtime-lag', + level: 'warning', + message: 'runtime is behind', + location: 'package.json', + details: { fixedVersions: ['9.9.9'] }, + }, + ], + extended: { + security: { + schema: 'vg.security.v1', + engine: 'engine/1', + packs: { 'iac-cis-v1': '1' }, + facts: { received: 2, evaluated: 2, rejected: 0 }, + findings: [ + security({ + id: 'b'.repeat(32), + path: 'b.tf', + line: 2, + severity: 'low', + rule: 'second', + message: 'later path', + }), + security({ + id: 'a'.repeat(32), + path: 'a.tf', + line: 1, + severity: 'critical', + rule: 'first', + message: `says & "quoted"`, + }), + ], + }, + }, + }); +} + +describe('formatHtmlReport', () => { + it('renders a summary with severity counts, an absent score, a fix rollup, and section anchors', () => { + const html = formatHtmlReport(sample()); + + expect(block(html, 'drift-score')).toBe('
n/a
'); + expect(block(html, 'risk-level')).toBe('
n/a
'); + expect(block(html, 'score-runtime')).toBe('
0
'); + expect(block(html, 'score-framework')).toBe('
n/a
'); + expect(html).not.toContain('id="drift-score">0'); + + const vulns = block(html, 'vuln-counts'); + expect(vulns).toContain('data-evaluated="yes"'); + expect(vulns).toContain('data-severity="critical">critical 1'); + expect(vulns).toContain('data-severity="high">high 1'); + expect(vulns).toContain('data-severity="moderate">moderate 1'); + expect(vulns).toContain('data-severity="low">low 1'); + expect(vulns).toContain('data-severity="unknown">unknown 0'); + expect(vulns).toContain('data-severity="unspecified">unspecified 1'); + + const securityCounts = block(html, 'security-counts'); + expect(securityCounts).toContain('data-severity="critical">critical 1'); + expect(securityCounts).toContain('data-severity="high">high 0'); + expect(securityCounts).toContain('data-severity="medium">medium 0'); + expect(securityCounts).toContain('data-severity="low">low 1'); + expect(securityCounts).toContain('data-severity="info">info 0'); + + expect(block(html, 'fix-available')).toBe('

Fix available: 2

'); + + expect(html).toContain('href="#vulnerabilities"'); + expect(html).toContain('href="#security"'); + expect(html).toContain('href="#findings"'); + expect(html).toContain('id="summary"'); + expect(html).toContain('id="vulnerabilities"'); + expect(html).toContain('id="security"'); + expect(html).toContain('id="findings"'); + }); + + it('keeps the full finding tables, in stable order, under the summary', () => { + const html = formatHtmlReport(sample()); + const vulns = section(html, 'vulnerabilities'); + const securitySection = section(html, 'security'); + const findings = section(html, 'findings'); + + const crit = vulns.indexOf('GHSA-crit'); + const high = vulns.indexOf('GHSA-high'); + const mod = vulns.indexOf('GHSA-mod'); + const low = vulns.indexOf('GHSA-low'); + const miss = vulns.indexOf('GHSA-miss'); + expect(crit).toBeGreaterThan(-1); + expect(crit).toBeLessThan(high); + expect(high).toBeLessThan(mod); + expect(mod).toBeLessThan(low); + expect(low).toBeLessThan(miss); + + expect(vulns).toContain('criticalGHSA-critleft-padn/a'); + expect(vulns).toContain('lowGHSA-lowleft-pad0'); + expect(vulns).toContain('fix available (4.17.21)'); + expect(vulns).toContain(`${FIX_AVAILABLE_ATTR}="yes"`); + expect(vulns).toContain(`${FIX_AVAILABLE_ATTR}="no"`); + + expect(securitySection.indexOf('a.tf:1')).toBeLessThan(securitySection.indexOf('b.tf:2')); + expect(securitySection).toContain('iac-cis-v1/first'); + expect(findings).toContain('vibgrate/runtime-lag'); + expect(html).not.toContain('engine/1'); + expect(findings).not.toContain('GHSA-crit'); + expect(html.indexOf('id="summary"')).toBeLessThan(html.indexOf('id="vulnerabilities"')); + }); + + it('escapes markup and redacts credential-shaped text', () => { + const html = formatHtmlReport(sample()); + expect(html).toContain('<script>alert("x") & 'y'</script>'); + expect(html).toContain('says <b> & "quoted"'); + expect(html).not.toContain(' { + const html = formatHtmlReport(artifact()); + expect(block(html, 'drift-score')).toBe('
n/a
'); + expect(block(html, 'vuln-counts')).toContain('Not scanned'); + expect(block(html, 'vuln-counts')).not.toContain('critical'); + expect(block(html, 'security-counts')).toContain('Not scanned'); + expect(html).not.toContain('id="fix-available"'); + expect(section(html, 'vulnerabilities')).toContain('Not scanned'); + expect(section(html, 'security')).toContain('Not scanned'); + expect(section(html, 'findings')).toContain('None'); + }); + + it('renders real zeros when a vulnerability check finished with nothing to report', () => { + const html = formatHtmlReport(artifact({ + extended: { + vulnerabilities: { + source: 'none', + packages: [], + totalAdvisories: 0, + severityCounts: { low: 0, moderate: 0, high: 0, critical: 0, unknown: 0 }, + }, + }, + })); + expect(block(html, 'vuln-counts')).toContain('data-evaluated="yes"'); + expect(block(html, 'vuln-counts')).toContain('critical 0'); + expect(section(html, 'vulnerabilities')).toContain('None'); + }); + + it('leaves an unfinished vulnerability check blank instead of zero', () => { + const html = formatHtmlReport(artifact({ + extended: { + vulnerabilities: { + source: 'unreachable', + packages: [], + totalAdvisories: 0, + severityCounts: { low: 0, moderate: 0, high: 0, critical: 0, unknown: 0 }, + }, + }, + })); + expect(block(html, 'vuln-counts')).toContain('Not scanned'); + expect(html).not.toContain('critical 0'); + }); + + it('shows a zero fix rollup when the field is present and no version is listed', () => { + const html = formatHtmlReport(artifact({ + findings: [ + { + ruleId: 'vibgrate/runtime-lag', + level: 'warning', + message: 'behind', + location: 'package.json', + details: { fixedVersions: [] }, + }, + ], + })); + expect(block(html, 'fix-available')).toBe('

Fix available: 0

'); + }); + + it('is deterministic and does not embed a timestamp, a script, or external assets', () => { + const first = sample(); + const again = formatHtmlReport(first); + expect(formatHtmlReport(first)).toBe(again); + + const shuffled = structuredClone(first); + shuffled.findings = [...first.findings].reverse(); + shuffled.extended!.security!.findings = [...first.extended!.security!.findings].reverse(); + expect(formatHtmlReport(shuffled)).toBe(again); + + expect(again).not.toContain('2026-02-16T00:00:00.000Z'); + expect(again).not.toMatch(/