diff --git a/CHANGELOG.md b/CHANGELOG.md index b7ab0ed6..887e092f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,13 @@ backward compatible. ### Changed +- **Machine-readable output drops absolute home-directory paths (#282).** + JSON, SARIF, and reports rewrite `/Users//` and `/home//` + paths. A path inside the scan or build root is written relative to that + root. A path under the same home directory but outside the root is written + as `../…`. Any other path only loses that prefix. The result does not use + `~/`. The same tree produces the same paths. + - **`vg build` and `vg scan` do not follow symlinks, and they say so.** A directory symlink is not entered, so a link to its parent cannot hang the walk, and a file symlink is not read. When any are skipped, one stderr diff --git a/src/commands/export.ts b/src/commands/export.ts index b35ef068..b19bfb46 100644 --- a/src/commands/export.ts +++ b/src/commands/export.ts @@ -41,6 +41,7 @@ export function registerExport(program: Command): void { generatedAt: graph.generatedAt, compact: opts.compact === true ? true : opts.compact === false ? false : undefined, slim: opts.slim === true, + root, }); if (file === '-') { diff --git a/src/core-open/formatters/markdown.ts b/src/core-open/formatters/markdown.ts index a9e6820f..f976b1dd 100644 --- a/src/core-open/formatters/markdown.ts +++ b/src/core-open/formatters/markdown.ts @@ -3,6 +3,7 @@ // and re-run the vendor script. Apache-2.0. import type { ScanArtifact } from '../types.js'; import { baselineSuppressionLabel } from '../baseline-suppressions.js'; +import { portableValue } from '../utils/portable-path.js'; import { findingRuleLabel, formatDegradationLines } from '../warnings.js'; import { securityPacksLabel } from './text.js'; @@ -24,7 +25,8 @@ function markdownDriftCell(score: number | null): string { } /** Generate a Markdown report from scan artifact */ -export function formatMarkdown(artifact: ScanArtifact): string { +export function formatMarkdown(artifact: ScanArtifact, root?: string): string { + artifact = portableValue(artifact, root); const lines: string[] = []; // Billing (micro-project pricing) is a commercial signal attached by the full diff --git a/src/core-open/formatters/sarif.ts b/src/core-open/formatters/sarif.ts index e1723791..49cd7e19 100644 --- a/src/core-open/formatters/sarif.ts +++ b/src/core-open/formatters/sarif.ts @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto'; import { baselineSuppressionId } from '../baseline-suppressions.js'; import type { BaselineSuppression, ScanArtifact, Finding, SecurityFinding, SecuritySection, SecuritySeverity } from '../types.js'; +import { portableValue } from '../utils/portable-path.js'; /** * SARIF `partialFingerprints` key for every result `vg scan` emits. @@ -22,7 +23,8 @@ const FINDING_FINGERPRINT_KEY = 'vg/finding-id/v1'; * listed under `tool.extensions`. A scan that ran no pack is still one run, * and an empty pack section does not change the document. */ -export function formatSarif(artifact: ScanArtifact): object { +export function formatSarif(artifact: ScanArtifact, root?: string): object { + artifact = portableValue(artifact, root); const suppressed = suppressionIndex(artifact); const rules = buildRules(artifact.findings); const results = artifact.findings.map((f) => toSarifResult(f, suppressed.get(suppressionKey(f.ruleId, f.location)))); diff --git a/src/core-open/run-core-scan.ts b/src/core-open/run-core-scan.ts index 4ae6ee57..a6d6ebc5 100644 --- a/src/core-open/run-core-scan.ts +++ b/src/core-open/run-core-scan.ts @@ -37,6 +37,7 @@ import { formatSarif } from './formatters/sarif.js'; import { formatMarkdown } from './formatters/markdown.js'; import { loadConfig, appendExcludePatterns } from './config.js'; import { pathExists, readJsonFile, writeJsonFile, writeTextFile, ensureDir, FileCache, quickTreeCount } from './utils/fs.js'; +import { portableValue } from './utils/portable-path.js'; import { assertSafeWalkRoot } from './utils/root-safety.js'; import { detectVcs } from './utils/vcs.js'; import { isCiEnvironment, hasVibgrateWorkflow } from './utils/ci-env.js'; @@ -816,7 +817,7 @@ export async function runCoreScan( // categorisation, not pricing — the commercial rates live server-side. const billing = summarizeBilling(allProjects); - const artifact: ScanArtifact = { + let artifact: ScanArtifact = { schemaVersion: '1.0', timestamp: new Date().toISOString(), vibgrateVersion, @@ -853,8 +854,8 @@ export async function runCoreScan( // Matching findings stay in `findings`. This block is the audit record // in the same document — do not filter them out here. A baseline-only // row is not a current finding and is not listed. - const baselineFindings = Array.isArray(baseline.findings) ? baseline.findings : []; - artifact.baselineComparison = compareBaselineFindings(artifact.findings, baselineFindings); + const baselineFindings = portableValue(Array.isArray(baseline.findings) ? baseline.findings : [], rootDir); + artifact.baselineComparison = compareBaselineFindings(portableValue(artifact.findings, rootDir), baselineFindings); } catch { degradations.push( codedWarning( @@ -874,11 +875,15 @@ export async function runCoreScan( } } + // JSON, SARIF, and the report share this copy. Home-directory prefixes are + // gone; paths inside the scan root are relative to it. + artifact = portableValue(artifact, rootDir); + if (!opts.noLocalArtifacts && !maxPrivacyMode) { const vibgrateDir = path.join(rootDir, '.vibgrate'); await ensureDir(vibgrateDir); await writeJsonFile(path.join(vibgrateDir, 'scan_result.json'), artifact); - await writeJsonFile(path.join(vibgrateDir, 'solutions.json'), { + await writeJsonFile(path.join(vibgrateDir, 'solutions.json'), portableValue({ scannedAt: artifact.timestamp, solutions: solutions.map((solution) => ({ solutionId: solution.solutionId, @@ -887,7 +892,7 @@ export async function runCoreScan( type: solution.type, projectPaths: solution.projectPaths, })), - }); + }, rootDir)); } // scan_history.json is local ETA telemetry, not a result — honour the same @@ -905,7 +910,7 @@ export async function runCoreScan( if (!opts.noLocalArtifacts && !maxPrivacyMode) { const projectScores: Record = {}; - for (const project of allProjects) { + for (const project of artifact.projects) { if (project.drift && project.path) { projectScores[project.path] = { projectId: project.projectId, @@ -926,7 +931,7 @@ export async function runCoreScan( if (Object.keys(projectScores).length > 0) { const vibgrateDir = path.join(rootDir, '.vibgrate'); await ensureDir(vibgrateDir); - await writeJsonFile(path.join(vibgrateDir, 'project_scores.json'), projectScores); + await writeJsonFile(path.join(vibgrateDir, 'project_scores.json'), portableValue(projectScores, rootDir)); } } @@ -939,7 +944,7 @@ export async function runCoreScan( console.log(jsonStr); } } else if (opts.format === 'sarif') { - const sarif = formatSarif(artifact); + const sarif = formatSarif(artifact, rootDir); const sarifStr = JSON.stringify(sarif, null, 2); if (opts.out) { await writeTextFile(path.resolve(opts.out), sarifStr); @@ -948,7 +953,7 @@ export async function runCoreScan( console.log(sarifStr); } } else if (opts.format === 'md') { - const markdown = formatMarkdown(artifact); + const markdown = formatMarkdown(artifact, rootDir); console.log(markdown); if (opts.out) { await writeTextFile(path.resolve(opts.out), markdown); diff --git a/src/core-open/utils/portable-path.ts b/src/core-open/utils/portable-path.ts new file mode 100644 index 00000000..8308cc19 --- /dev/null +++ b/src/core-open/utils/portable-path.ts @@ -0,0 +1,97 @@ +// VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by +// scripts/vendor-core-open.mjs. Do not edit here — change the source package +// and re-run the vendor script. Apache-2.0. +/** + * Paths in machine-readable output. + * + * Default JSON, SARIF, and report output must not carry an absolute home + * directory (`/Users/…` or `/home/…`). A path inside the scan or build root + * becomes root-relative. The same inputs always produce the same relative + * path: no clock, no locale, and no dependence on which account ran the tool. + */ +import path from 'node:path'; + +/** Absolute home prefix, not a URL path segment (`docs/home/…` does not match). */ +const HOME_PATH = /(?|]+/g; + +const HOME_ROOT = /^\/(?:Users|home)\/[^/]+/; + +export function portableValue(value: T, root?: string): T { + if (!containsHomePath(value)) return value; + return rewrite(value, normalizeRoot(root)) as T; +} + +/** Rewrite one string. Exported for tests of a single path. */ +export function portablePath(value: string, root?: string): string { + return stripHomePrefixes(value, normalizeRoot(root)); +} + +function mentionsHome(text: string): boolean { + return text.includes('/Users/') || text.includes('/home/'); +} + +function containsHomePath(value: unknown): boolean { + if (typeof value === 'string') return mentionsHome(value); + if (Array.isArray(value)) return value.some((item) => containsHomePath(item)); + if (!value || typeof value !== 'object') return false; + for (const [key, child] of Object.entries(value as Record)) { + if (mentionsHome(key) || containsHomePath(child)) return true; + } + return false; +} + +function rewrite(value: unknown, root: string | undefined): unknown { + if (typeof value === 'string') return stripHomePrefixes(value, root); + if (Array.isArray(value)) return value.map((item) => rewrite(item, root)); + if (!value || typeof value !== 'object') return value; + const input = value as Record; + const out: Record = {}; + for (const key of Object.keys(input)) { + out[stripHomePrefixes(key, root)] = rewrite(input[key], root); + } + return out; +} + +function stripHomePrefixes(text: string, root: string | undefined): string { + if (!mentionsHome(text)) return text; + HOME_PATH.lastIndex = 0; + return text.replace(HOME_PATH, (match) => { + const { core, trail } = splitTrail(match); + if (!HOME_ROOT.test(core)) return match; + return toRelative(core, root) + trail; + }); +} + +function splitTrail(match: string): { core: string; trail: string } { + const trimmed = /^(.*?)([.,;:)\]}]+)$/.exec(match); + if (!trimmed || !HOME_ROOT.test(trimmed[1]!)) return { core: match, trail: '' }; + return { core: trimmed[1]!, trail: trimmed[2]! }; +} + +function normalizeRoot(root: string | undefined): string | undefined { + if (!root) return undefined; + let norm = root.replace(/\\/g, '/'); + if (norm.length > 1) norm = norm.replace(/\/+$/, ''); + return norm.startsWith('/') ? norm : undefined; +} + +function homeBase(abs: string): string | undefined { + return HOME_ROOT.exec(abs)?.[0]; +} + +function toRelative(absPath: string, root: string | undefined): string { + const abs = path.posix.normalize(absPath); + if (root) { + const base = path.posix.normalize(root); + if (abs === base) return '.'; + if (abs.startsWith(`${base}/`)) return abs.slice(base.length + 1); + if (homeBase(abs) && homeBase(abs) === homeBase(base)) { + const rel = path.posix.relative(base, abs); + if (rel && !/(^|\/)(?:Users|home)\//.test(rel)) return rel; + } + } + const base = homeBase(abs); + if (!base) return absPath; + const rest = abs.slice(base.length).replace(/^\//, ''); + return rest || '.'; +} diff --git a/src/engine/artifacts.ts b/src/engine/artifacts.ts index e6f3b558..95a73b37 100644 --- a/src/engine/artifacts.ts +++ b/src/engine/artifacts.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import type { HailePolicy } from './haile/types.js'; import * as path from 'node:path'; +import { portableValue } from '../core-open/utils/portable-path.js'; import { serializeGraph } from './serialize.js'; import { mapFileExists, snapshotPathFor, writeGraphSnapshot } from './snapshot.js'; import { renderReport } from './report.js'; @@ -228,6 +229,7 @@ export function companionArtifactDir(root: string, graphPath: string, env: NodeJ } export function writeArtifacts(graph: VgGraph, options: WriteOptions): WrittenArtifacts { + graph = portableValue(graph, options.root); const graphPath = options.graphPath ?? defaultGraphPath(options.root); const companions = companionArtifactDir(options.root, graphPath); const inRepo = isPathInside(companions, options.root) || isPathInside(graphPath, options.root); @@ -276,7 +278,7 @@ export function writeArtifacts(graph: VgGraph, options: WriteOptions): WrittenAr // never expose a half-written file to a concurrent reader. const tmp = `${graphPath}.${process.pid}.tmp`; try { - fs.writeFileSync(tmp, serializeGraph(graph)); + fs.writeFileSync(tmp, serializeGraph(graph, { root: options.root })); fs.renameSync(tmp, graphPath); } catch (err) { fs.rmSync(tmp, { force: true }); @@ -292,13 +294,13 @@ export function writeArtifacts(graph: VgGraph, options: WriteOptions): WrittenAr if (options.report !== false) { const reportPath = path.join(companions, 'GRAPH_REPORT.md'); - fs.writeFileSync(reportPath, renderReport(graph)); + fs.writeFileSync(reportPath, renderReport(graph, options.root)); written.reportPath = reportPath; } if (options.html !== false) { const htmlPath = path.join(companions, 'graph.html'); - fs.writeFileSync(htmlPath, renderHtml(graph)); + fs.writeFileSync(htmlPath, renderHtml(graph, options.root)); written.htmlPath = htmlPath; } diff --git a/src/engine/export.ts b/src/engine/export.ts index ac9cd0c2..b06dccf3 100644 --- a/src/engine/export.ts +++ b/src/engine/export.ts @@ -1,3 +1,4 @@ +import { portableValue } from '../core-open/utils/portable-path.js'; import { serializeGraph, slimGraphForExport } from './serialize.js'; import { renderReport } from './report.js'; import { renderHtml } from './html.js'; @@ -61,21 +62,23 @@ export interface ExportContext { compact?: boolean; /** Drop area members + grounding for smaller artifacts. */ slim?: boolean; + /** Scan/build root. Paths inside it are written relative to it. */ + root?: string; } export function exportGraph(format: ExportFormat, ctx: ExportContext): string { - const graph = ctx.slim ? slimGraphForExport(ctx.graph) : ctx.graph; + const graph = portableValue(ctx.slim ? slimGraphForExport(ctx.graph) : ctx.graph, ctx.root); const compact = ctx.compact === true || (ctx.compact !== false && format === 'json' && graph.meta.counts.nodes > COMPACT_JSON_NODES); switch (format) { case 'json': - return serializeGraph(graph, { compact }); + return serializeGraph(graph, { compact, root: ctx.root }); case 'md': - return renderReport(graph); + return renderReport(graph, ctx.root); case 'html': - return renderHtml(graph); + return renderHtml(graph, ctx.root); case 'ndjson': return ndjson(graph); case 'graphml': diff --git a/src/engine/html.ts b/src/engine/html.ts index 8847de2f..599f287e 100644 --- a/src/engine/html.ts +++ b/src/engine/html.ts @@ -1,3 +1,4 @@ +import { portableValue } from '../core-open/utils/portable-path.js'; import { stableStringify } from './serialize.js'; import type { VgGraph } from '../schema.js'; @@ -12,7 +13,8 @@ import type { VgGraph } from '../schema.js'; const EMBED_NODE_LIMIT = 5_000; -export function renderHtml(graph: VgGraph): string { +export function renderHtml(graph: VgGraph, root?: string): string { + graph = portableValue(graph, root); const top = graph.nodes .filter((n) => n.kind !== 'file' && n.kind !== 'external') .sort((a, b) => b.importance - a.importance || a.qualifiedName.localeCompare(b.qualifiedName)) diff --git a/src/engine/report.ts b/src/engine/report.ts index f757fd91..e7137c02 100644 --- a/src/engine/report.ts +++ b/src/engine/report.ts @@ -1,3 +1,4 @@ +import { portableValue } from '../core-open/utils/portable-path.js'; import type { VgGraph } from '../schema.js'; /** @@ -5,7 +6,8 @@ import type { VgGraph } from '../schema.js'; * `generatedAt`, the report is byte-stable. Numbers are derived purely from the * graph, so the report can be regenerated from a committed `graph.json`. */ -export function renderReport(graph: VgGraph): string { +export function renderReport(graph: VgGraph, root?: string): string { + graph = portableValue(graph, root); const { meta, provenance, nodes, edges } = graph; const lines: string[] = []; diff --git a/src/engine/serialize.ts b/src/engine/serialize.ts index 72e68dec..8acf3494 100644 --- a/src/engine/serialize.ts +++ b/src/engine/serialize.ts @@ -1,4 +1,5 @@ import { SUPPORTED_SCHEMA_VERSIONS, type SupportedSchemaVersion, type VgGraph } from '../schema.js'; +import { portableValue } from '../core-open/utils/portable-path.js'; import { CliError, ExitCode } from '../util/exit.js'; const REBUILD_HINT = 'Rebuild it with `vg build`.'; @@ -93,10 +94,10 @@ export function unreadableGraphError(): GraphLoadError { * Pretty-printed (2-space) and newline-terminated so the committed artifact is * human-diffable and plays well with the union merge driver. */ -export function serializeGraph(graph: VgGraph, opts?: { compact?: boolean }): string { +export function serializeGraph(graph: VgGraph, opts?: { compact?: boolean; root?: string }): string { // Compact (no pretty-print) for large maps / CI artifacts — still deterministic. const indent = opts?.compact ? 0 : 2; - return `${stableStringify(graph, indent)}\n`; + return `${stableStringify(portableValue(graph, opts?.root), indent)}\n`; } /** diff --git a/src/reporting/commands/report.ts b/src/reporting/commands/report.ts index bd984159..e353c3ec 100644 --- a/src/reporting/commands/report.ts +++ b/src/reporting/commands/report.ts @@ -6,6 +6,7 @@ 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 { portableValue } from '../../core-open/utils/portable-path.js'; import type { ReportFormat, ScanArtifact } from '../types.js'; /** Formats `vg report` accepts. `text` is the default. */ @@ -32,7 +33,7 @@ export const reportCommand = new Command('report') process.exit(1); } - const artifact = await readJsonFile(artifactPath); + const artifact = portableValue(await readJsonFile(artifactPath)); switch (opts.format) { case 'md': diff --git a/src/reporting/formatters/markdown.ts b/src/reporting/formatters/markdown.ts index d6d2d0aa..dcee7706 100644 --- a/src/reporting/formatters/markdown.ts +++ b/src/reporting/formatters/markdown.ts @@ -1,5 +1,6 @@ import type { ScanArtifact } from '../types.js'; import { baselineSuppressionLabel } from '../../core-open/baseline-suppressions.js'; +import { portableValue } from '../../core-open/utils/portable-path.js'; import { findingRuleLabel, formatDegradationLines } from '../../core-open/warnings.js'; /** A measured zero stays `0`. An unmeasured component is `n/a`, never `0`. */ @@ -8,7 +9,8 @@ function markdownDriftCell(score: number | null): string { } /** Generate a Markdown report from scan artifact */ -export function formatMarkdown(artifact: ScanArtifact): string { +export function formatMarkdown(artifact: ScanArtifact, root?: string): string { + artifact = portableValue(artifact, root); const lines: string[] = []; lines.push('# Vibgrate Drift Report'); diff --git a/test/portable-output-paths.test.ts b/test/portable-output-paths.test.ts new file mode 100644 index 00000000..48b33325 --- /dev/null +++ b/test/portable-output-paths.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import { formatMarkdown as formatScanMarkdown } from '../src/core-open/formatters/markdown.js'; +import { formatSarif } from '../src/core-open/formatters/sarif.js'; +import type { ScanArtifact } from '../src/core-open/types.js'; +import { portableValue } from '../src/core-open/utils/portable-path.js'; +import { renderReport } from '../src/engine/report.js'; +import { serializeGraph } from '../src/engine/serialize.js'; +import { formatMarkdown as formatReport } from '../src/reporting/formatters/markdown.js'; +import type { VgGraph } from '../src/schema.js'; + +/** + * Fixture paths use both home prefixes. Default machine-readable output must + * not repeat them: paths inside the scan/build root are relative to that root, + * and the same relative layout is byte-identical under either prefix. + */ +const ROOT = '/home/dev/fixture'; + +function scanFixture(): ScanArtifact { + return { + schemaVersion: '1.0', + timestamp: '2026-10-09T00:00:00.000Z', + vibgrateVersion: '0.0.0', + rootPath: 'fixture', + projects: [ + { + type: 'node', + path: '/home/dev/fixture/services/api', + name: 'api', + frameworks: [], + dependencies: [], + dependencyAgeBuckets: { current: 0, oneBehind: 0, twoPlusBehind: 0, unknown: 0 }, + }, + ], + drift: { + score: 10, + riskLevel: 'low', + components: { runtimeScore: 0, frameworkScore: 0, dependencyScore: 10, eolScore: 0 }, + }, + findings: [ + { + ruleId: 'vibgrate/dependency-rot', + level: 'warning', + message: 'see /home/dev/fixture/services/api/package.json and /Users/dev/fixture/pkg/lib.ts', + location: '/home/dev/fixture/services/api/package.json', + }, + ], + repository: { name: 'fixture', remoteUrl: 'https://example.com/home/docs' }, + }; +} + +function graphNode(file: string): VgGraph['nodes'][number] { + return { + id: 'fn-main', + kind: 'function', + name: 'main', + qualifiedName: 'main', + file, + span: { start: 1, end: 4 }, + lang: 'ts', + importance: 1, + centrality: { degree: 1, pagerank: 0, betweenness: 0, eigenvector: 0 }, + area: -1, + isHub: false, + tested: null, + }; +} + +function graphFixture(file: string): VgGraph { + return { + schemaVersion: 'vg-graph/1.1', + generatedAt: '2026-10-09T00:00:00.000Z', + provenance: { + tool: 'vg', + version: '0.0.0', + grammars: { ts: 'test' }, + resolver: ['heuristic'], + deep: false, + corpusHash: 'abc', + }, + meta: { + root: '.', + languages: ['ts'], + counts: { nodes: 1, edges: 0, areas: 0, tests: 0, untested: 0 }, + cluster: 'none', + edgeKinds: [], + }, + nodes: [graphNode(file)], + edges: [], + areas: [], + }; +} + +function assertNoHomePrefix(label: string, text: string): void { + expect(text, label).not.toContain('/home/dev/'); + expect(text, label).not.toContain('/home/ada/'); + expect(text, label).not.toContain('/Users/'); +} + +describe('default machine-readable output paths', () => { + it('omits home-prefixed fixture paths from json, sarif, and report', () => { + const artifact = scanFixture(); + const json = JSON.stringify(portableValue(artifact, ROOT), null, 2); + const sarif = JSON.stringify(formatSarif(artifact, ROOT), null, 2); + const scanReport = formatScanMarkdown(artifact, ROOT); + const report = formatReport(artifact, ROOT); + const graphJson = serializeGraph(graphFixture('/home/dev/fixture/src/main.ts'), { root: ROOT }); + const graphReport = renderReport(graphFixture('/Users/dev/fixture/src/main.ts'), '/Users/dev/fixture'); + + for (const [label, text] of [ + ['json', json], + ['sarif', sarif], + ['scan report', scanReport], + ['report', report], + ['graph json', graphJson], + ['graph report', graphReport], + ] as const) { + assertNoHomePrefix(label, text); + } + + expect(json).toContain('services/api/package.json'); + expect(json).toContain('https://example.com/home/docs'); + expect(sarif).toContain('"uri": "services/api/package.json"'); + expect(report).toContain('services/api/package.json'); + expect(graphJson).toContain('src/main.ts'); + expect(graphReport).toContain('src/main.ts'); + + expect(JSON.stringify(formatSarif(scanFixture(), ROOT), null, 2)).toBe(sarif); + expect(formatReport(scanFixture(), ROOT)).toBe(report); + expect(serializeGraph(graphFixture('/home/dev/fixture/src/main.ts'), { root: ROOT })).toBe(graphJson); + expect(serializeGraph(graphFixture('/home/ada/app/src/main.ts'), { root: '/home/ada/app' })).toBe( + serializeGraph(graphFixture('/Users/ada/app/src/main.ts'), { root: '/Users/ada/app' }), + ); + expect(portableValue(portableValue(artifact, ROOT), ROOT)).toEqual(portableValue(artifact, ROOT)); + }); +});