Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, + / −).
Expand Down
85 changes: 85 additions & 0 deletions src/core-open/scanners/go-scanner.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
46 changes: 31 additions & 15 deletions src/core-open/scanners/go-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,25 @@ const KNOWN_GO_FRAMEWORKS: Record<string, string> = {

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.
*
Expand Down Expand Up @@ -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;
Expand All @@ -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 };
Expand Down Expand Up @@ -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;
Expand Down
55 changes: 55 additions & 0 deletions src/core-open/scoring/dependency-drift-v3.test.ts
Original file line number Diff line number Diff line change
@@ -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<DependencyRow> & Pick<DependencyRow, 'package' | 'currentSpec' | 'resolvedVersion'>): 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);
});
});
18 changes: 17 additions & 1 deletion src/core-open/scoring/dependency-drift-v3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion src/core-open/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
54 changes: 54 additions & 0 deletions src/engine/manifests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }));
Expand Down
Loading
Loading