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 f28a74b0..fbc09c5d 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) @@ -458,16 +459,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; }