diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2ec89..ecb6e30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,15 @@ backward compatible. ### Fixed +- **Unpinned Go `require` lines stay in `vg build` and `vg scan`.** A direct + module with no version token (or a version that does not start with `v`) was + dropped, so impact and drift could not see it. The module is now a graph + edge and a scan dependency. The version stays null when the manifest omits + a pin — not `""`, `0`, or a guessed version — and that absence is left out + of the drift mean rather than scored as current. SBOM export still keeps the + row, with the existing `unknown` version sentinel and no `@version` on the + purl. + - **`vg show arch` clipped the map to a fixed viewport.** Columns that ran off the bottom of the window could not be scrolled or zoomed; the canvas is now a pannable, zoomable map (scroll or drag, pinch / Ctrl-scroll, + / −). diff --git a/src/core-open/scanners/go-scanner.test.ts b/src/core-open/scanners/go-scanner.test.ts new file mode 100644 index 0000000..aaa6628 --- /dev/null +++ b/src/core-open/scanners/go-scanner.test.ts @@ -0,0 +1,85 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { Semaphore } from '../utils/semaphore.js'; +import { GoCache } from './go-cache.js'; +import { scanGoProjects } from './go-scanner.js'; + +let dir: string | undefined; + +afterEach(() => { + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + dir = undefined; +}); + +const GOMOD = [ + 'module example.com/svc', + '', + 'go 1.22', + '', + 'require (', + '\tgithub.com/gin-gonic/gin v1.9.1', + '\texample.com/unpinned', + '\texample.com/noprefix 1.4.0', + '\tgithub.com/stretchr/testify v1.8.4 // indirect', + '\texample.com/indirect-unpinned // indirect', + ')', + '', + 'require example.com/single', + '', + 'exclude example.com/excluded v1.0.0', + '', + 'replace example.com/local => ../local', + '', +].join('\n'); + +describe('scanGoProjects unpinned requires', () => { + it('keeps a direct require that has no version and does not invent a pin', async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-go-scan-')); + fs.writeFileSync(path.join(dir, 'go.mod'), GOMOD); + const cache = new GoCache(new Semaphore(1), undefined, true); + const scan = () => scanGoProjects(dir!, cache); + const [first, second] = await Promise.all([scan(), scan()]); + expect(second[0]?.dependencies).toEqual(first[0]?.dependencies); + + const deps = first[0]?.dependencies ?? []; + const byName = Object.fromEntries(deps.map((d) => [d.package, d])); + expect(Object.keys(byName).sort()).toEqual([ + 'example.com/noprefix', + 'example.com/single', + 'example.com/unpinned', + 'github.com/gin-gonic/gin', + ]); + + expect(byName['example.com/unpinned']).toMatchObject({ + currentSpec: null, + resolvedVersion: null, + majorsBehind: null, + drift: 'unknown', + }); + expect(byName['example.com/single']).toMatchObject({ + currentSpec: null, + resolvedVersion: null, + majorsBehind: null, + }); + expect(byName['example.com/noprefix']).toMatchObject({ + currentSpec: '1.4.0', + resolvedVersion: '1.4.0', + }); + expect(byName['github.com/gin-gonic/gin']).toMatchObject({ + currentSpec: 'v1.9.1', + resolvedVersion: '1.9.1', + }); + expect(byName['example.com/excluded']).toBeUndefined(); + expect(byName['example.com/local']).toBeUndefined(); + expect(byName['example.com/indirect-unpinned']).toBeUndefined(); + expect(byName['github.com/stretchr/testify']).toBeUndefined(); + + for (const dep of deps) { + expect(dep.currentSpec).not.toBe(''); + expect(dep.resolvedVersion).not.toBe(''); + if (dep.currentSpec == null) expect(dep.majorsBehind).toBeNull(); + } + }); +}); diff --git a/src/core-open/scanners/go-scanner.ts b/src/core-open/scanners/go-scanner.ts index 18b1bc7..f632a56 100644 --- a/src/core-open/scanners/go-scanner.ts +++ b/src/core-open/scanners/go-scanner.ts @@ -85,10 +85,25 @@ const KNOWN_GO_FRAMEWORKS: Record = { interface GoDependency { path: string; - version: string; + /** Null when the require names a module and omits a version token. */ + version: string | null; indirect: boolean; } +/** go.mod directive keywords — never module paths. */ +const GO_MOD_DIRECTIVES = new Set([ + 'module', + 'go', + 'toolchain', + 'tool', + 'godebug', + 'ignore', + 'require', + 'exclude', + 'replace', + 'retract', +]); + /** * Parse go.mod to extract dependencies and Go version. * @@ -126,9 +141,12 @@ function parseGoMod(content: string): { goVersion?: string; deps: GoDependency[] continue; } - // Parse dependency lines + // Parse dependency lines. A version token is optional: a bare module path + // is still a direct require (omitted pin / workspace inheritance). The + // version stays null — never "", 0, or a guessed pin. // Format: github.com/gin-gonic/gin v1.9.1 // Format: github.com/gin-gonic/gin v1.9.1 // indirect + // Format: example.com/mod let depLine = trimmed; if (inRequireBlock) { depLine = trimmed; @@ -140,18 +158,16 @@ function parseGoMod(content: string): { goVersion?: string; deps: GoDependency[] const indirect = depLine.includes('// indirect'); depLine = depLine.replace(/\/\/.*$/, '').trim(); - - const parts = depLine.split(/\s+/); - if (parts.length >= 2) { - const [modulePath, version] = parts; - if (modulePath && version) { - deps.push({ - path: modulePath, - version, - indirect, - }); - } - } + if (!depLine || depLine === '(' || depLine === ')' || depLine.includes('=>')) continue; + + const parts = depLine.split(/\s+/).filter((part) => part.length > 0); + const modulePath = parts[0]; + if (!modulePath || modulePath === '(' || modulePath === ')' || GO_MOD_DIRECTIVES.has(modulePath)) continue; + deps.push({ + path: modulePath, + version: parts[1] ?? null, + indirect, + }); } return { goVersion, deps }; @@ -267,7 +283,7 @@ async function scanOneGoProject( const resolved = await Promise.all(metaPromises); for (const { dep, meta } of resolved) { - const resolvedVersion = semver.valid(semver.clean(dep.version)); + const resolvedVersion = dep.version ? semver.valid(semver.clean(dep.version)) : null; const latestStable = meta.latestStableOverall; let majorsBehind: number | null = null; diff --git a/src/core-open/scoring/dependency-drift-v3.test.ts b/src/core-open/scoring/dependency-drift-v3.test.ts new file mode 100644 index 0000000..e182056 --- /dev/null +++ b/src/core-open/scoring/dependency-drift-v3.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import type { DependencyRow } from '../types.js'; +import { aggregateDependencyDrift, perDependencyDrift } from './dependency-drift-v3.js'; + +function row(partial: Partial & Pick): DependencyRow { + return { + section: 'dependencies', + latestStable: null, + majorsBehind: null, + drift: 'unknown', + ...partial, + }; +} + +describe('version absence vs zero', () => { + it('excludes a dependency with no version instead of scoring it as zero drift', () => { + const unpinned = row({ + package: 'example.com/unpinned', + currentSpec: null, + resolvedVersion: null, + latestStable: '2.0.0', + }); + const scored = perDependencyDrift(unpinned); + expect(scored.excluded).toBe(true); + expect(scored.flags).toContain('version-absent'); + expect(aggregateDependencyDrift([unpinned])).toBeNull(); + + const pinned = row({ + package: 'example.com/pinned', + currentSpec: 'v1.0.0', + resolvedVersion: '1.0.0', + latestStable: '2.0.0', + majorsBehind: 1, + drift: 'major-behind', + }); + const agg = aggregateDependencyDrift([unpinned, pinned]); + expect(agg?.scored).toBe(1); + expect(agg?.excluded).toBe(1); + expect(agg?.drift).toBeGreaterThan(0); + }); + + it('does not treat a concrete 0.0.0 pin or a workspace spec as missing', () => { + const zero = row({ + package: 'example.com/zero', + currentSpec: 'v0.0.0', + resolvedVersion: '0.0.0', + latestStable: '0.0.0', + majorsBehind: 0, + drift: 'current', + }); + expect(perDependencyDrift(zero).excluded).toBe(false); + expect(perDependencyDrift(row({ package: 'home', currentSpec: 'workspace:*', resolvedVersion: null })).excluded).toBe(false); + expect(perDependencyDrift(row({ package: 'blank', currentSpec: '', resolvedVersion: null })).excluded).toBe(true); + }); +}); diff --git a/src/core-open/scoring/dependency-drift-v3.ts b/src/core-open/scoring/dependency-drift-v3.ts index 79acae6..b539b41 100644 --- a/src/core-open/scoring/dependency-drift-v3.ts +++ b/src/core-open/scoring/dependency-drift-v3.ts @@ -111,10 +111,15 @@ export interface DependencyDriftResult { weight: number; /** Guards that fired, for explainability. */ flags: string[]; - /** Excluded from scoring entirely (placeholder stub). */ + /** Excluded from scoring entirely (placeholder stub, or no version to score). */ excluded: boolean; } +/** Null and "" are both "no version". A real pin, including "0.0.0", is not. */ +function versionMissing(value: string | null | undefined): boolean { + return value == null || value === ''; +} + function clamp(v: number, min: number, max: number): number { return Math.min(max, Math.max(min, v)); } @@ -177,6 +182,17 @@ export function perDependencyDrift( }; } + // No declared or resolved version. The dependency stays visible on the scan + // row, but there is no number to score — excluding it keeps absence out of + // the mean (absent ≠ 0). The `drift: 0` here is unused; `excluded` is the + // signal, same as a placeholder stub. + if (versionMissing(dep.resolvedVersion) && versionMissing(dep.currentSpec)) { + return { + package: dep.package, drift: 0, mode: 'estimated', unsupported: false, + weight: 0, flags: ['version-absent'], excluded: true, + }; + } + // Guard 1 — canary "latest" (e.g. react-native 1000.0.0): the version signal // is meaningless; discard it and let time carry. const latestMajor = majorOf(dep.latestStable); diff --git a/src/core-open/types.ts b/src/core-open/types.ts index 07f0335..3f6e830 100644 --- a/src/core-open/types.ts +++ b/src/core-open/types.ts @@ -123,7 +123,11 @@ export interface DependencyLicense { export interface DependencyRow { package: string; section: DepSection; - currentSpec: string; + /** + * Declared version requirement from the manifest. Null when the manifest + * names the dependency and does not pin a version — absent, not `""` or `0`. + */ + currentSpec: string | null; resolvedVersion: string | null; latestStable: string | null; /** diff --git a/src/engine/manifests.test.ts b/src/engine/manifests.test.ts index 202cd9f..b41aa60 100644 --- a/src/engine/manifests.test.ts +++ b/src/engine/manifests.test.ts @@ -117,6 +117,60 @@ serde = "1" expect(out.deps).toBe(1); }); + it('keeps an unpinned go.mod require as a direct edge and does not invent a version', () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-manifest-')); + const gomod = [ + 'module example.com/svc', + '', + 'go 1.22', + '', + 'require (', + '\tgithub.com/gin-gonic/gin v1.9.1', + '\texample.com/unpinned', + '\texample.com/noprefix 1.4.0', + '\tgithub.com/stretchr/testify v1.8.4 // indirect', + '\texample.com/indirect-unpinned // indirect', + '\t// example.com/commented v9.9.9', + ')', + '', + 'require example.com/single', + '', + 'exclude example.com/excluded v1.0.0', + '', + 'replace example.com/local => ../local', + '', + ].join('\n'); + write(dir, 'go.mod', gomod); + const out = extractManifests(dir); + const again = extractManifests(dir); + expect(again).toEqual(out); + + const extNames = out.nodes.filter((n) => n.kind === 'external').map((n) => n.name).sort(); + expect(extNames).toEqual([ + 'example.com/indirect-unpinned', + 'example.com/noprefix', + 'example.com/single', + 'example.com/unpinned', + 'github.com/gin-gonic/gin', + 'github.com/stretchr/testify', + ]); + expect(out.deps).toBe(extNames.length); + expect(extNames).not.toContain('example.com/excluded'); + expect(extNames).not.toContain('example.com/local'); + expect(extNames).not.toContain('example.com/commented'); + + const pkg = out.nodes.find((n) => n.kind === 'package'); + expect(pkg?.qualifiedName).toBe('example.com/svc'); + for (const name of ['example.com/unpinned', 'example.com/single', 'example.com/indirect-unpinned']) { + const ext = out.nodes.find((n) => n.kind === 'external' && n.name === name); + expect(ext).toBeDefined(); + expect(ext).not.toHaveProperty('version'); + expect(out.edges.some((e) => e.kind === 'import' && e.src === pkg?.id && e.dst === ext?.id)).toBe(true); + } + expect(JSON.stringify(out)).not.toContain('"version":""'); + expect(JSON.stringify(out)).not.toContain('"version":0'); + }); + it('gives each ecosystem its own package node in a mixed-ecosystem tree', () => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-manifest-')); write(dir, 'services/api/package.json', JSON.stringify({ name: 'api' })); diff --git a/src/engine/manifests.ts b/src/engine/manifests.ts index 9e79583..a8bbef5 100644 --- a/src/engine/manifests.ts +++ b/src/engine/manifests.ts @@ -206,18 +206,24 @@ function ingestGoMod( }); // require blocks and single-line requires (ignore replace/exclude). + // A version token is optional. `example.com/mod v1.2.3` and `example.com/mod` + // are both direct dependencies; the graph stores the module path only, so an + // omitted pin stays absent instead of becoming "" or a made-up version. const reqNames = new Set(); const block = /require\s*\(([\s\S]*?)\)/g; let bm: RegExpExecArray | null; while ((bm = block.exec(text)) !== null) { for (const line of bm[1].split('\n')) { - const m = /^\s*(\S+)\s+v\S+/.exec(line); - if (m && !line.trim().startsWith('//')) reqNames.add(m[1]); + const name = goRequireModulePath(line); + if (name) reqNames.add(name); } } - const single = /^\s*require\s+(\S+)\s+v\S+/gm; + const single = /^\s*require\s+(.+)$/gm; let sm: RegExpExecArray | null; - while ((sm = single.exec(text)) !== null) reqNames.add(sm[1]); + while ((sm = single.exec(text)) !== null) { + const name = goRequireModulePath(sm[1] ?? ''); + if (name) reqNames.add(name); + } let n = 0; for (const name of [...reqNames].sort()) { @@ -399,6 +405,34 @@ function ingestCargoToml( return n; } +/** + * Module path from one `require` spec. The version token is optional: + * `github.com/foo/bar v1.2.3` and a bare `github.com/foo/bar` both name a + * dependency. Comments, block punctuation, and `replace` (`=>`) lines are not + * modules. The returned path is the dependency identity; this parser does not + * invent a version when the spec omits one. + */ +function goRequireModulePath(line: string): string | null { + const stripped = line.replace(/\/\/.*$/, '').trim(); + if (!stripped || stripped === '(' || stripped === ')' || stripped.includes('=>')) return null; + const name = stripped.split(/\s+/)[0]; + if (!name || name === '(' || name === ')' || GO_MOD_DIRECTIVES.has(name)) return null; + return name; +} + +const GO_MOD_DIRECTIVES = new Set([ + 'module', + 'go', + 'toolchain', + 'tool', + 'godebug', + 'ignore', + 'require', + 'exclude', + 'replace', + 'retract', +]); + function isRecognizedManifest(base: string): boolean { return ( base === 'package.json' || diff --git a/src/lsp/server.ts b/src/lsp/server.ts index f59d95f..a8f77ea 100644 --- a/src/lsp/server.ts +++ b/src/lsp/server.ts @@ -522,7 +522,8 @@ export interface BreakdownItem { drift: number; /** Band for `drift` — the client maps it to a colour; it never re-derives it. */ band: Band; - currentSpec: string; + /** Null when the manifest names the dependency and does not pin a version. */ + currentSpec: string | null; resolvedVersion: string | null; latestStable: string | null; majorsBehind: number | null; @@ -2346,7 +2347,7 @@ function buildBreakdown(rootDir: string, projects: ProjectScan[]): BreakdownItem ecosystem, drift: scored.drift, band: bandForScore(scored.drift), - currentSpec: row.currentSpec ?? '', + currentSpec: row.currentSpec, resolvedVersion: row.resolvedVersion ?? null, latestStable: row.latestStable ?? null, majorsBehind: row.majorsBehind ?? null, @@ -2607,7 +2608,7 @@ function inlineLabel(dep: DependencyRow, state?: { ignored?: boolean; isNew?: bo } else if (dep.drift === 'minor-behind') { bits.push('minor behind'); } - if (dep.latestStable) bits.push(dep.latestStable); + if (dep.latestStable && (dep.resolvedVersion || dep.currentSpec)) bits.push(dep.latestStable); // ageDays is fractional off the wire — round it. "1,896.836d stale" in the // margin of someone's package.json is the kind of detail that reads as sloppy. if (dep.ageDays != null && dep.ageDays >= 1) { @@ -2743,7 +2744,7 @@ function hoverMarkdown( const yours = dep.resolvedVersion ?? dep.currentSpec; const drifted = dep.drift === 'major-behind' || dep.drift === 'minor-behind'; - if (dep.latestStable && dep.latestStable !== yours) { + if (yours && dep.latestStable && dep.latestStable !== yours) { lines.push('**Currency**'); lines.push(`| | |`); lines.push(`|---|---|`); diff --git a/src/reporting/commands/evidence/release.ts b/src/reporting/commands/evidence/release.ts index a395892..72528c1 100644 --- a/src/reporting/commands/evidence/release.ts +++ b/src/reporting/commands/evidence/release.ts @@ -74,6 +74,9 @@ export function componentsFromArtifact(artifact: ScanArtifact): FrozenComponent[ const ecosystem = ecosystemForProjectType(project.type); for (const dep of project.dependencies) { const version = dep.resolvedVersion ?? dep.currentSpec; + // A require with no pin has no shipped version to freeze. Skip it rather + // than writing "" or a made-up pin into the component manifest. + if (!version) continue; const key = `${ecosystem ?? ''}|${dep.package}|${version}`; if (seen.has(key)) continue; seen.add(key); diff --git a/src/reporting/commands/sbom.test.ts b/src/reporting/commands/sbom.test.ts index 4625967..2a184c3 100644 --- a/src/reporting/commands/sbom.test.ts +++ b/src/reporting/commands/sbom.test.ts @@ -226,6 +226,34 @@ describe('sbom helpers', () => { } }); + it('keeps an unpinned Go require and does not write the missing pin into version', () => { + const artifact = makeArtifact('v1.2.3', 90); + artifact.projects[0]!.type = 'go'; + const dep = artifact.projects[0]!.dependencies[0]!; + dep.package = 'example.com/unpinned'; + dep.currentSpec = null; + dep.resolvedVersion = null; + dep.majorsBehind = null; + dep.drift = 'unknown'; + const sbom = toCycloneDx(artifact) as { + components: Array<{ + name: string; + version: string; + purl: string; + properties: Array<{ name: string; value: string }>; + }>; + }; + expect(sbom.components).toHaveLength(1); + expect(sbom.components[0]!.name).toBe('example.com/unpinned'); + expect(sbom.components[0]!.version).toBe('unknown'); + expect(sbom.components[0]!.purl).toBe('pkg:golang/example.com/unpinned'); + const props = sbom.components[0]!.properties; + expect(props.some((p) => p.name === 'vibgrate:currentSpec')).toBe(false); + expect(props.find((p) => p.name === 'vibgrate:majorsBehind')?.value).toBe('unknown'); + const spdx = toSpdx(artifact) as { packages: Array<{ versionInfo: string }> }; + expect(spdx.packages[0]!.versionInfo).toBe('unknown'); + }); + it('still reports a concrete pinned version normally (no false positives from the range guard)', () => { const sbom = toCycloneDx(makeArtifact('5.3.0', 90)) as { components: Array<{ version: string; purl: string }> }; expect(sbom.components[0]!.version).toBe('5.3.0'); diff --git a/src/reporting/commands/sbom.ts b/src/reporting/commands/sbom.ts index c560d2f..cf7cc25 100644 --- a/src/reporting/commands/sbom.ts +++ b/src/reporting/commands/sbom.ts @@ -13,7 +13,8 @@ interface FlattenedDependency { project: string; package: string; version: string; - currentSpec: string; + /** Null when the manifest named the dependency and did not pin a version. */ + currentSpec: string | null; drift: DependencyRow['drift']; majorsBehind: number | null; /** 'direct' comes from a scanned manifest; 'transitive' is lockfile-only. */ @@ -100,7 +101,7 @@ const UNKNOWN_VERSION = 'unknown'; * reads as "this exact version is installed" — that's not a smaller version * of the truth, it's a different claim. */ -function isConcreteVersion(spec: string): boolean { +function isConcreteVersion(spec: string | null | undefined): spec is string { if (!spec || spec === '*' || spec === 'latest') return false; if (/[\^~*<>|]/.test(spec)) return false; if (/^(npm|workspace|patch|file|link|git|github|https?):/i.test(spec)) return false; @@ -193,7 +194,7 @@ function sbomSerialSeed(format: string, artifact: ScanArtifact, deps: FlattenedD artifact.rootPath ?? '', artifact.timestamp ?? '', artifact.vibgrateVersion ?? '', - ...deps.map((d) => `${d.package}|${d.version}|${d.currentSpec}|${d.project}|${d.drift}|${d.majorsBehind ?? ''}|${d.scope}`), + ...deps.map((d) => `${d.package}|${d.version}|${d.currentSpec ?? ''}|${d.project}|${d.drift}|${d.majorsBehind ?? ''}|${d.scope}`), ...(graph?.rootDependsOn.length ? [`root>${uniqSorted(graph.rootDependsOn).join(',')}`] : []), ...edgeLines, ].join('\n'); @@ -266,12 +267,14 @@ export function flattenDependencies( for (const project of artifact.projects) { const ecosystem = projectEcosystem(project.type); for (const dep of project.dependencies) { - // Go always pins an exact version in go.mod, but the scanner's - // `resolvedVersion` runs it through `semver.clean` (for semver math - // elsewhere) and drops the `v` prefix go.sum's transitive entries keep - // — matching on `currentSpec` instead is what lets a direct Go - // dependency dedupe against its own go.sum-derived component instead - // of appearing as two, differently-versioned components. + // When go.mod pins a version, the scanner's `resolvedVersion` runs it + // through `semver.clean` (for semver math elsewhere) and drops the `v` + // prefix go.sum's transitive entries keep — matching on `currentSpec` + // instead is what lets a direct Go dependency dedupe against its own + // go.sum-derived component instead of appearing as two, differently- + // versioned components. A require with no version token has a null spec; + // that is not a concrete version, and the sentinel below must not be + // replaced with a fabricated pin. const rawVersion = ecosystem === 'go' ? dep.currentSpec : (dep.resolvedVersion ?? dep.currentSpec); // A dependency with no lockfile/installed-tree resolution falls back // to its declared spec, which for npm/yarn/pnpm can be a semver range, @@ -377,7 +380,9 @@ export function toCycloneDx(artifact: ScanArtifact, graph?: LockfileGraph): Reco purl: purlFor(dep.ecosystem, dep.package, dep.version), properties: [ { name: 'vibgrate:project', value: dep.project }, - { name: 'vibgrate:currentSpec', value: dep.currentSpec }, + // Omit when the manifest did not declare a spec. Do not write "" or a + // guessed pin; `version` above is already the unknown sentinel. + ...(dep.currentSpec ? [{ name: 'vibgrate:currentSpec', value: dep.currentSpec }] : []), { name: 'vibgrate:drift', value: dep.drift }, { name: 'vibgrate:majorsBehind', value: String(dep.majorsBehind ?? 'unknown') }, { name: 'vibgrate:scope', value: dep.scope }, @@ -426,6 +431,11 @@ export function toSpdx(artifact: ScanArtifact, graph?: LockfileGraph): Record): string { + return dep.resolvedVersion ?? dep.currentSpec ?? 'unpinned'; +} + function projectDependencyMap(artifact: ScanArtifact): Map { const map = new Map(); for (const project of artifact.projects) { @@ -446,12 +456,12 @@ export function formatDeltaText(base: ScanArtifact, current: ScanArtifact): stri for (const [key, dep] of currentMap.entries()) { if (!baseMap.has(key)) { - added.push(`${key} @ ${dep.resolvedVersion ?? dep.currentSpec}`); + added.push(`${key} @ ${formatDeclaredVersion(dep)}`); continue; } const prev = baseMap.get(key)!; - const prevVersion = prev.resolvedVersion ?? prev.currentSpec; - const nowVersion = dep.resolvedVersion ?? dep.currentSpec; + const prevVersion = formatDeclaredVersion(prev); + const nowVersion = formatDeclaredVersion(dep); if (prevVersion !== nowVersion || prev.majorsBehind !== dep.majorsBehind) { changed.push(`${key} ${prevVersion} -> ${nowVersion} (majorsBehind ${prev.majorsBehind ?? 'unknown'} -> ${dep.majorsBehind ?? 'unknown'})`); } @@ -459,7 +469,7 @@ export function formatDeltaText(base: ScanArtifact, current: ScanArtifact): stri for (const [key, dep] of baseMap.entries()) { if (!currentMap.has(key)) { - removed.push(`${key} @ ${dep.resolvedVersion ?? dep.currentSpec}`); + removed.push(`${key} @ ${formatDeclaredVersion(dep)}`); } } diff --git a/src/reporting/reachability.ts b/src/reporting/reachability.ts index 0d3bbcb..4065e96 100644 --- a/src/reporting/reachability.ts +++ b/src/reporting/reachability.ts @@ -400,7 +400,7 @@ export async function analyzeReachability( export function collectPreflightDependencies( projects: Array<{ type: string; - dependencies: Array<{ package: string; resolvedVersion: string | null; currentSpec: string }>; + dependencies: Array<{ package: string; resolvedVersion: string | null; currentSpec: string | null }>; }>, projectTypeToEcosystem: Partial>, ): SymbolsPreflightDependency[] { @@ -409,7 +409,7 @@ export function collectPreflightDependencies( const ecosystem = projectTypeToEcosystem[project.type]; if (!ecosystem) continue; for (const dep of project.dependencies) { - const version = dep.resolvedVersion || normalizeVersionSpec(dep.currentSpec); + const version = dep.resolvedVersion || (dep.currentSpec ? normalizeVersionSpec(dep.currentSpec) : null); if (!version) continue; const key = `${ecosystem}:${dep.package}:${version}`; if (!deduped.has(key)) { diff --git a/src/reporting/scanners/breaking-change.ts b/src/reporting/scanners/breaking-change.ts index 2f9432f..d79d8fc 100644 --- a/src/reporting/scanners/breaking-change.ts +++ b/src/reporting/scanners/breaking-change.ts @@ -112,8 +112,9 @@ function normalizeMajor(version: string | null | undefined): number | null { return parsed?.major ?? null; } -function resolveCurrentVersion(dep: { resolvedVersion: string | null; currentSpec: string }): string | null { +function resolveCurrentVersion(dep: { resolvedVersion: string | null; currentSpec: string | null }): string | null { if (dep.resolvedVersion && semver.valid(semver.coerce(dep.resolvedVersion))) return semver.coerce(dep.resolvedVersion)?.version ?? null; + if (!dep.currentSpec) return null; const min = semver.minVersion(dep.currentSpec); return min?.version ?? null; } diff --git a/src/reporting/types.ts b/src/reporting/types.ts index 0ac11bf..629ecd7 100644 --- a/src/reporting/types.ts +++ b/src/reporting/types.ts @@ -71,7 +71,11 @@ export interface NpmMeta { export interface DependencyRow { package: string; section: DepSection; - currentSpec: string; + /** + * Declared version requirement from the manifest. Null when the manifest + * names the dependency and does not pin a version — absent, not `""` or `0`. + */ + currentSpec: string | null; resolvedVersion: string | null; latestStable: string | null; majorsBehind: number | null; diff --git a/test/manifests-export.test.ts b/test/manifests-export.test.ts index e647879..36bbc9a 100644 --- a/test/manifests-export.test.ts +++ b/test/manifests-export.test.ts @@ -36,6 +36,40 @@ describe('extractManifests', () => { expect(m.edges.some((e) => e.kind === 'import' && e.epistemic === 'declared')).toBe(true); }); + it('folds an unpinned go.mod require into a full build', async () => { + const root = project({ + 'go.mod': [ + 'module example.com/svc', + '', + 'go 1.22', + '', + 'require (', + ' example.com/unpinned', + ' github.com/gin-gonic/gin v1.9.1', + ')', + '', + ].join('\n'), + }); + const build = () => + buildGraph({ + root, + generatedAt: '2020-01-01T00:00:00.000Z', + noGround: true, + inline: true, + noTsc: true, + }); + const { graph } = await build(); + const again = await build(); + const external = (nodes: typeof graph.nodes) => + nodes.filter((n) => n.kind === 'external').map((n) => ({ id: n.id, name: n.name })).sort((a, b) => a.name.localeCompare(b.name)); + expect(external(graph.nodes)).toEqual(external(again.graph.nodes)); + const unpinned = graph.nodes.find((n) => n.kind === 'external' && n.name === 'example.com/unpinned'); + const pkg = graph.nodes.find((n) => n.kind === 'package' && n.qualifiedName === 'example.com/svc'); + expect(unpinned).toBeDefined(); + expect(unpinned).not.toHaveProperty('version'); + expect(graph.edges.some((e) => e.kind === 'import' && e.src === pkg?.id && e.dst === unpinned?.id)).toBe(true); + }); + it('reads go.mod require lines', () => { const root = project({ 'go.mod': [