From 6e8ef240e655ec26269a21067fc44fde34254576 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 12:06:20 +0000 Subject: [PATCH] fix(report): reject unknown vg report --format values Unknown values, including html, rendered the text report and exited 0. Reject them with a usage error that names the value and lists md, text, and json. Document what the Markdown and text formatters emit, and that json is the unchanged artifact. There is no HTML report. Signed-off-by: Cursor Agent Co-authored-by: Crowers --- CHANGELOG.md | 6 ++ DOCS.md | 125 +++++++++++++++++++++++- README.md | 2 +- plugins/claude/vg/skills/vg/SKILL.md | 7 +- skills/vg/SKILL.md | 7 +- src/install/content.ts | 11 ++- src/reporting/commands/report.test.ts | 131 ++++++++++++++++++++++++++ src/reporting/commands/report.ts | 17 +++- 8 files changed, 287 insertions(+), 19 deletions(-) create mode 100644 src/reporting/commands/report.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 72af8f05..ec0672cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ backward compatible. ### Fixed +- **`vg report --format` rejects values other than `md`, `text`, and `json`.** + An unknown value, including `html`, exits `5` with a usage error that names + the value and lists the valid ones. Stdout is empty. `text` stays the + default. There is no HTML report. Markdown, text, and JSON field coverage + is documented on `vg report`. + - **`vg sbom export` merges multi-project lockfiles by ecosystem, name, and version.** The same package from two projects stays one component, and `vibgrate:projects` lists every project that contributed it. A sub-project diff --git a/DOCS.md b/DOCS.md index 5b1d5626..bcee3b79 100644 --- a/DOCS.md +++ b/DOCS.md @@ -18,6 +18,7 @@ For a quick overview, see the [README](./README.md). This document covers everyt - [vg fix](#vg-fix) - [vg init](#vg-init) - [vg report](#vg-report) + - [Report format coverage](#report-format-coverage) - [vg review](#vg-review) - [vg sbom](#vg-sbom) - [Choosing CycloneDX or SPDX](#choosing-cyclonedx-or-spdx) @@ -450,16 +451,130 @@ Creates: ### vg report -Generate a human-readable report from a scan artifact. +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`. ```bash vg report [--in ] [--format md|text|json] ``` -| Flag | Default | Description | -| ---------- | ---------------------------- | -------------------------------------- | -| `--in` | `.vibgrate/scan_result.json` | Input artifact file | -| `--format` | `text` | Output format: `md`, `text`, or `json` | +| 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`) | + +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) +``` + +The comparison is exact. `HTML`, `sarif`, and `md ` are unknown. Omitting +`--format` stays on `text`. + +#### 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`. + +The same artifact, three times: + +```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 +``` + +##### Markdown (`formatMarkdown`) + +Always: + +| Section | Fields | +| --- | --- | +| Title | `# Vibgrate Drift Report` | +| Metrics table | **DriftScore** — `n/a`, or `{drift.score}/100` plus `_(lower is better; 0 = no drift)_`. **Risk Level** — `drift.riskLevel` uppercased, or `n/a` when it is missing. **Projects** — `projects.length`. **Scanned** — `timestamp`; when set, `durationMs` as seconds with one decimal, `filesScanned` as `{n} files`, and `treeSummary` as `{totalFiles} workspace files · {totalDirs} dirs`. **VCS** — `vcs.type`, only when `vcs` is set. **Branch** — `vcs.branch`, when set. **Commit** — `` `vcs.shortSha` ``, only when `vcs.sha` is set | +| Score Breakdown | `drift.components.runtimeScore`, `frameworkScore`, `dependencyScore`, `eolScore` | +| Projects | One `### {name} ({type})` per project. **Runtime**, when `runtime` is set: the runtime, then ` — {runtimeMajorsBehind} major(s) behind` when that count is greater than 0, otherwise ` — current`. **Frameworks**, when the list is non-empty: `name`, `currentVersion` or `?`, `latestVersion` or `?`, and `majorsBehind` as `current` (`0`), `{n} behind`, or `unknown` (`null`). **Dependencies**, when `current + oneBehind + twoPlusBehind + unknown` is greater than 0: those four `dependencyAgeBuckets` counts | + +When the artifact carries the data: + +| Section | Guard and fields | +| --- | --- | +| Product Purpose Signals | `extended.uiPurpose`. **Frameworks** — `detectedFrameworks`, or `unknown`. **Evidence Items** — `topEvidence.length`, plus `(capped from {evidenceCount})` when `capped` is set. **Top Evidence** — the first 10 of `topEvidence`: `kind`, `value`, `file`. **Unknowns** — the first 5 of `unknownSignals` | +| Recommended Standards | `extended.standards.recommended` is non-empty. **Detected purpose** — `projectPurposes` as `{project} → {category}`, when that list is non-empty. **Compliance framework coverage** — `frameworks`: `name`, `recommendedMembers`, `totalMembers`. **Top standards to consider** — the first 10 of `recommended`: `name`, `reason`, and `_(compliance)_` when `complianceRelevant` is set | +| Findings | `findings` is non-empty. Table of `level` (error, warning, or anything else), `ruleId`, the stored `message`, and `location` | +| Drift Delta | `delta` is set. The signed number, `vs baseline`, and `_(worsened)_` when positive, `_(improved)_` when negative, or neither when zero | +| Baseline suppressions | `baselineComparison.compared` is true. `{n} finding suppressed by baseline` or `{n} findings suppressed by baseline`, where `n` is `baselineComparison.suppressed.length` | + +##### Text (`formatText`) + +Always, including an artifact with no projects: + +| Section | Fields | +| --- | --- | +| Banner | The word `vibgrate` and `Code Intelligence Engine v` plus the running CLI version. This version is not a field of the artifact | +| Title | `Vibgrate Drift Report` | +| DriftScore Summary | **DriftScore** — `n/a` or `{drift.score}/100`. **Risk Level** — `LOW`, `MODERATE`, or `HIGH` for `low`, `moderate`, and `high`; the raw string for any other non-empty level; `n/a` when missing. **Projects** — `projects.length`. **VCS**, when `vcs` is set — `vcs.type`, then `vcs.branch` and `vcs.shortSha` when those are set | +| Score Breakdown | The same four component scores as Markdown, each drawn as a bar when measured | +| Footer | `Scanned at {timestamp}`; when set, `durationMs` as seconds with one decimal, `{n} file scanned` or `{n} files scanned`, `{treeSummary.totalFiles} workspace files`, and `{treeSummary.totalDirs} dirs` | + +Per project, in artifact order: + +| Line | Guard and fields | +| --- | --- | +| Heading | `name`, `type`, `path` | +| Runtime | `runtime` when set, and `runtimeMajorsBehind` as `{n} major` or `majors behind` when greater than 0, otherwise `current` | +| Target | `targetFramework` when set | +| Frameworks | Same version fields as Markdown (`name`, `currentVersion`, `latestVersion`, `majorsBehind`) | +| Dependencies | The four `dependencyAgeBuckets` counts when their sum is greater than 0 | + +When set: + +| Section | Guard and fields | +| --- | --- | +| Drift Delta | `delta` is set. The signed number and `(vs baseline)`. Zero prints `0`. No worsened or improved word | +| Baseline suppressions | Same sentence as Markdown, when `baselineComparison.compared` is true | +| Tech Stack | `extended.toolingInventory` has a category with items. Category label, then each item's `name`. Labels: Frontend, Meta-frameworks, Bundlers, CSS / UI, Backend, ORM / Database, Testing, Lint & Format, API & Messaging, Observability, Payment, Auth, Email, Cloud, Databases, Messaging, CRM & Comms, Storage, Search & AI. An unlisted category key is printed as stored | +| Services & Integrations | `extended.serviceDependencies`, same category labels. Each item's `name`, and `version` when set | +| Breaking Change Exposure | `extended.breakingChangeExposure` has a deprecated package or a legacy polyfill. `exposureScore`, `deprecatedPackages`, `legacyPolyfills`, a peer-conflict line when `peerConflictsDetected` is set, `overallRecommendation`, and up to 3 `projectIntelligence` rows that have packages (project `project` and `recommendation`; up to 2 packages: `package`, `currentVersion`, `targetVersion`, `usage.touchedPercent`, `automatable`) | +| TypeScript | `extended.tsModernity.typescriptVersion` is set. The version, `strict` (`strict` is omitted when it is neither true nor false), `moduleType`, and `target` | +| Build & Deploy | `extended.buildDeploy` has CI entries, a Dockerfile, or a package manager. `ci`, Docker `dockerfileCount` and `baseImages`, `packageManagers`, `monorepoTools`, `iac` — each line only when that list or count is non-empty | +| Product Purpose Signals | `extended.uiPurpose`. `detectedFrameworks` or `unknown`. Evidence count is `topEvidence.length`, and `of {evidenceCount} (capped)` when `capped` is set. **Top Signals** — the first 8 of `topEvidence`: `kind`, `value`, `file`. **Unknowns** — the first 4 of `unknownSignals` | +| Security Posture | `extended.securityPosture`. `lockfilePresent`, `gitignoreCoversEnv`, `gitignoreCoversNodeModules`. `multipleLockfileTypes` and `envFilesTracked` only when true | +| Platform | `extended.platformMatrix` has a native module or a Docker base image. `nativeModules` when non-empty. `osAssumptions` when non-empty. `dockerBaseImages` only decides whether the section exists; it is not its own line | +| Code Quality | `extended.codeQuality`. `filesAnalyzed`, `functionsAnalyzed`, `avgCyclomaticComplexity`, `avgFunctionLength`, `maxNestingDepth`, `circularDependencies`, `deadCodePercent`. **God files** — the first 3 of `godFiles`: `path`, `lines` | +| Database Schema | `extended.databaseSchema`. `providers`, model count, enum count. **Sources** when models use more than one `source`, sorted by source name. **Models** — the first 5 `name`s, then a remaining count | +| Dependency Graph | `extended.dependencyGraph.lockfileType` is set. `lockfileType`, `totalUnique`, `totalInstalled`. Duplicated count when `duplicatedPackages` is non-empty. Phantom count when `phantomDependencies` is non-empty | +| Findings | `findings` is non-empty. Counts of `error`, `warning`, and `note`. Each row is the message from `humanFindingText`, then `ruleId` and `location`, then a hint when that helper returns one. The helper drops a stored ` — no fix available` and adds `fix available (...)` from `details.fixedVersions` only when the message does not already contain `fix available` | +| Top Priority Actions | Up to five derived lines, highest severity first. Each line has a title and an explanation; **Impact** is printed only for the runtime, framework, and dependency-rot actions. The triggers are: a runtime `runtimeMajorsBehind` of 3 or more; a framework `majorsBehind` of 3 or more; a project whose `twoPlusBehind / (current + oneBehind + twoPlusBehind)` is at least 40 percent; a framework exactly 2 majors behind; any deprecated package or legacy polyfill on `breakingChangeExposure`; 10 or more `phantomDependencies`; `securityPosture.envFilesTracked` or a missing lockfile; 3 or more `duplicatedPackages` that each have 3 or more versions. Explanations quote project `path`, runtime `runtime` / `runtimeLatest`, framework versions, dependency `package` / `resolvedVersion` or `currentSpec` / `latestStable` / `majorsBehind`, and the matching posture or graph fields | +| 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. + +##### Differences + +- Markdown is a short table. Text is the terminal report: banner and CLI version, project `path` and `targetFramework`, the extended sections above, findings with a fix hint, derived priority actions, architecture, solutions, score bars, and the scanned-at footer. +- Markdown prints **Recommended Standards**. Text does not. +- Text prints Tech Stack, Services, Breaking Change Exposure, TypeScript, Build & Deploy, Security Posture, Platform, Code Quality, Database Schema, and Dependency Graph. Markdown does not. +- Product purpose: Markdown shows 10 evidence rows and 5 unknowns (`Evidence Items`, `Top Evidence`). Text shows 8 and 4 (`Evidence`, `Top Signals`). +- Findings: Markdown prints the stored `message` in a table. Text prints counts plus `humanFindingText` (the stored message, a removed ` — no fix available`, and the fix hint). +- Drift delta: Markdown adds `_(worsened)_` or `_(improved)_`. Text prints the signed number only. +- 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`. + +##### 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. --- diff --git a/README.md b/README.md index df01df52..2a910a16 100644 --- a/README.md +++ b/README.md @@ -826,7 +826,7 @@ All HCS computation runs in an optional, separately-licensed engine module that | `vg evidence` | Signed, reproducible regulatory evidence — jurisdiction-neutral regimes (EU CRA first, DORA incident reporting too): `init`, `product`, `release`, `exposure`, `readiness`, `drill`, `watch`, `pack`, `verify`, `push`, `export` | | `vg fix` | Ranked, risk-tiered upgrade plans from the hosted planner — then apply the one you choose | | `vg init [path]` | Initialise config and `.vibgrate/` | -| `vg report` | Generate a report from a scan artifact | +| `vg report` | Render a saved scan artifact as text (default), Markdown, or JSON. There is no HTML report. Field coverage is in [DOCS.md](./DOCS.md#report-format-coverage) | | `vg review` | **Vibgrate Review** — architecture + security-control review of the current change, locally (`--in-place`, `--local`, `--loop`). Deterministic blast-radius findings from the code graph via `vg review findings-from-diff`; `vg review propose ` attaches a PatchIR dry-run (same `--base` / `--in-place` / `--diff`, `--findings`, or `.vibgrate/review-propose-handoff.json`). One decision (`pass` / `needs_review` / `fail` / `undetermined`) in a signed receipt (Ed25519 over the receipt digest; `vg review verify ` checks it offline); protected findings cannot be blessed into a pass. Reports change integrity, not a proof of security. Builds or refreshes the code map itself when it is missing or stale (`--no-auto-build` opts out) | | `vg sbom export` / `delta` / `vex` | Export CycloneDX/SPDX SBOM, diff two artifacts, or emit an OpenVEX document | | `vg scan [path]` | Scan for upgrade drift | diff --git a/plugins/claude/vg/skills/vg/SKILL.md b/plugins/claude/vg/skills/vg/SKILL.md index d98a152a..029ee9e4 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 @@ -37,8 +37,9 @@ measured and the tools improved): ## Dependencies & library docs - **Upgrade drift:** `vg drift` lists what is outdated across dependencies - (offline; `--online` for currency). `vg scan` scores upgrade drift and - `vg report` renders it (text | json | sarif | md). + (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. - **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 9a7036ee..4ad6401d 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 @@ -37,8 +37,9 @@ measured and the tools improved): ## Dependencies & library docs - **Upgrade drift:** `vg drift` lists what is outdated across dependencies - (offline; `--online` for currency). `vg scan` scores upgrade drift and - `vg report` renders it (text | json | sarif | md). + (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. - **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 4f7cb6ab..76e05e34 100644 --- a/src/install/content.ts +++ b/src/install/content.ts @@ -20,9 +20,11 @@ export const NUDGE_END = ''; * History: v1 = the unversioned originals (detected by their headings); * v2 = versioned markers + the strong MCP-first recommendation; * v3 = library-docs workflow resolves by package name only, and an - * `entityCount` of 0 means read `node_modules` rather than retry. + * `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`. */ -export const INSTALL_CONTENT_VERSION = 3; +export const INSTALL_CONTENT_VERSION = 4; const VERSION_MARKER_RE = //; @@ -90,8 +92,9 @@ measured and the tools improved): ## Dependencies & library docs - **Upgrade drift:** \`vg drift\` lists what is outdated across dependencies - (offline; \`--online\` for currency). \`vg scan\` scores upgrade drift and - \`vg report\` renders it (text | json | sarif | md). + (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. - **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 new file mode 100644 index 00000000..ec118114 --- /dev/null +++ b/src/reporting/commands/report.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { main } from '../../cli.js'; +import { ExitCode } from '../../util/exit.js'; + +const SENTINEL = 'report-format-sentinel'; + +const artifact = { + schemaVersion: '1.0', + timestamp: '2026-01-01T00:00:00.000Z', + vibgrateVersion: '0.0.0', + rootPath: '/fixture', + projects: [ + { + type: 'node', + path: 'app', + name: SENTINEL, + frameworks: [], + dependencies: [], + dependencyAgeBuckets: { current: 0, oneBehind: 0, twoPlusBehind: 0, unknown: 0 }, + }, + ], + drift: { + score: 1, + riskLevel: 'low', + components: { runtimeScore: 0, frameworkScore: 0, dependencyScore: 0, eolScore: 0 }, + }, + findings: [], +} as const; + +const roots: string[] = []; + +function writeArtifact(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-report-format-')); + roots.push(root); + const file = path.join(root, 'scan_result.json'); + fs.writeFileSync(file, JSON.stringify(artifact)); + return file; +} + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +async function runCli(args: string[]): Promise<{ code: number; stderr: string; stdout: string }> { + vi.stubEnv('NO_COLOR', '1'); + vi.stubEnv('VIBGRATE_NO_KERNEL', '1'); + const stderr: string[] = []; + const stdout: string[] = []; + const write = (bucket: string[]) => (chunk: unknown, encodingOrCb?: unknown, cb?: unknown) => { + bucket.push(String(chunk)); + const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb; + if (typeof callback === 'function') callback(); + return true; + }; + const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation(write(stderr) as never); + const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation(write(stdout) as never); + // Vitest's console capture does not go through the stdout spy. Record it too, + // so a formatter that prints cannot hide from the empty-stdout assertion. + const logSpy = vi.spyOn(console, 'log').mockImplementation((...parts: unknown[]) => { + stdout.push(`${parts.map((part) => String(part)).join(' ')}\n`); + }); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`EXIT:${code ?? 0}`); + }) as never); + + const captured = (): { code: number; stderr: string; stdout: string } => ({ + code: 0, + stderr: stderr.join(''), + stdout: stdout.join(''), + }); + + try { + await main(['node', 'vg', ...args]); + return captured(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const match = /^EXIT:(\d+)$/.exec(message); + if (!match) throw err; + return { ...captured(), code: Number(match[1]) }; + } finally { + errSpy.mockRestore(); + outSpy.mockRestore(); + logSpy.mockRestore(); + exitSpy.mockRestore(); + } +} + +describe('vg report --format', () => { + it('rejects an unknown format with exit 5, the value, the valid list, and empty stdout', async () => { + const file = writeArtifact(); + for (const format of ['html', 'HTML', 'sarif']) { + 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`, + ); + expect(result.stderr).not.toContain(SENTINEL); + } + }); + + it('defaults to the text report', async () => { + const file = writeArtifact(); + const result = await runCli(['report', '--in', file]); + expect(result.code).toBe(0); + expect(result.stdout).toContain('Code Intelligence Engine'); + expect(result.stdout).toContain('Vibgrate Drift Report'); + expect(result.stdout).toContain(SENTINEL); + expect(result.stdout).not.toContain('# Vibgrate Drift Report'); + }); + + it('prints markdown for md and the artifact unchanged for json', async () => { + const file = writeArtifact(); + const md = await runCli(['report', '--in', file, '--format', 'md']); + expect(md.code).toBe(0); + expect(md.stdout).toContain('# Vibgrate Drift Report'); + expect(md.stdout).toContain('## Score Breakdown'); + expect(md.stdout).toContain(SENTINEL); + expect(md.stdout).not.toContain('Code Intelligence Engine'); + + const json = await runCli(['report', '--in', file, '--format', 'json']); + expect(json.code).toBe(0); + expect(JSON.parse(json.stdout)).toEqual(artifact); + expect(json.stdout).not.toContain('Code Intelligence Engine'); + }); +}); diff --git a/src/reporting/commands/report.ts b/src/reporting/commands/report.ts index 25a40b80..136175ab 100644 --- a/src/reporting/commands/report.ts +++ b/src/reporting/commands/report.ts @@ -1,16 +1,28 @@ import * as path from 'node:path'; import { Command } from 'commander'; import chalk from 'chalk'; +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 type { ScanArtifact } from '../types.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[]; + +function assertReportFormat(format: string): asserts format is ReportFormat { + if ((REPORT_FORMATS as readonly string[]).includes(format)) return; + throw usageError(`unknown --format ${JSON.stringify(format)} (expected ${REPORT_FORMATS.join(', ')})`); +} export const reportCommand = new Command('report') .description('Generate a drift report from a scan artifact') .option('--in ', 'Input artifact file', '.vibgrate/scan_result.json') - .option('--format ', 'Output format (md|text|json)', 'text') + .option('--format ', `Output format (${REPORT_FORMATS.join('|')})`, 'text') .action(async (opts: { in: string; format: string }) => { + // Before the artifact is read, so an unknown value never renders as text. + assertReportFormat(opts.format); + const artifactPath = path.resolve(opts.in); if (!(await pathExists(artifactPath))) { @@ -29,7 +41,6 @@ export const reportCommand = new Command('report') console.log(JSON.stringify(artifact, null, 2)); break; case 'text': - default: console.log(formatText(artifact)); break; }