Files
codegraph/__tests__/go-import-package-names.test.ts
Colby Mchenry d8a7f868b3 fix(go): an unaliased import is also known by the name goimports assumes for its package (#2410)
An unaliased Go import was named by its path's last element, so the
qualifier in yaml.Node (go.yaml.in/yaml/v3), klog.V (k8s.io/klog/v2) or
sqlite3.Error (github.com/mattn/go-sqlite3) matched none of the file's
imports. The reference then resolved by its bare name to whatever project
symbol shared it.

An unaliased import now also takes the name goimports'
ImportPathToAssumedName gives its path (the last element that isn't a
major version, without a go- prefix, cut at the first non-identifier
character), unless another import of the file is bound to that name or a
second import assumes it too. The last element stays registered:
k8s.io/api/core/v1 really is package v1.

The import scan now reads only the import section, without comments: the
last word of a comment had become the next import's alias, and imports
spelled in doc comments or string literals were read as real ones.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:15:52 +00:00

250 lines
7.7 KiB
TypeScript

/**
* A Go import without a name written before its path is bound to the name of
* the package it imports, and the resolver took that to be the path's last
* element. For a versioned path it isn't: `go.yaml.in/yaml/v3` and
* `gopkg.in/yaml.v3` are package `yaml`, `github.com/mattn/go-sqlite3` is
* `sqlite3`. So `yaml.Node`, `klog.V(2)` or `semver.Version` matched none of
* the file's imports, and resolved by the bare name to whatever project symbol
* shared it: kubernetes' 1,561 `klog.V(…)` calls went to an etcd3 logger
* wrapper's `V` method, etcd's `*semver.Version` parameters to client/v3's
* `Version` function. An unaliased import now also takes the name goimports
* assumes for its path, while keeping the last element:
* `k8s.io/api/core/v1` really is package `v1`.
*
* And a word in a comment is no import name: `"fmt" // for printing` used to
* name the next import `printing`.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { extractImportMappings } from '../src/resolution/import-resolver';
/** The names a Go file's imports are bound to, as `name <- path`. */
const names = (source: string): string[] =>
extractImportMappings('x.go', source, 'go').map((m) => `${m.localName} <- ${m.source}`).sort();
describe('Go import names', () => {
it('bind an unaliased versioned or go- import to its package name as well as its last element', () => {
expect(names(`package p
import (
"go.yaml.in/yaml/v3"
"github.com/mattn/go-sqlite3"
"k8s.io/api/core/v1"
"github.com/gin-gonic/gin"
"gopkg.in/natefinch/lumberjack.v2"
)
`)).toEqual([
'core <- k8s.io/api/core/v1',
'gin <- github.com/gin-gonic/gin',
'go-sqlite3 <- github.com/mattn/go-sqlite3',
'lumberjack <- gopkg.in/natefinch/lumberjack.v2',
'lumberjack.v2 <- gopkg.in/natefinch/lumberjack.v2',
'sqlite3 <- github.com/mattn/go-sqlite3',
'v1 <- k8s.io/api/core/v1',
'v3 <- go.yaml.in/yaml/v3',
'yaml <- go.yaml.in/yaml/v3',
]);
expect(names('package p\n\nimport "gopkg.in/yaml.v3"\n')).toEqual(['yaml <- gopkg.in/yaml.v3', 'yaml.v3 <- gopkg.in/yaml.v3']);
});
it('take no assumed name another import holds, or two imports assume', () => {
// `core` is pkg/apis/core's own name, not the one assumed for api/core/v1.
expect(names(`package p
import (
"k8s.io/api/core/v1"
"k8s.io/kubernetes/pkg/apis/core"
yaml "sigs.k8s.io/yaml"
"go.yaml.in/yaml/v3"
"github.com/a/semver/v3"
"github.com/b/go-semver"
)
`)).toEqual([
'core <- k8s.io/kubernetes/pkg/apis/core',
'go-semver <- github.com/b/go-semver',
'v1 <- k8s.io/api/core/v1',
'v3 <- github.com/a/semver/v3',
'v3 <- go.yaml.in/yaml/v3',
'yaml <- sigs.k8s.io/yaml',
]);
});
it('keep a written name, assume none for a dot import, and read none from a comment', () => {
expect(names(`package p
import (
"fmt" // for printing
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
_ "embed"
. "github.com/onsi/ginkgo/v2"
// yaml decoding
"gopkg.in/yaml.v3"
/* the store */ "example.com/app/store"
)
func run() {}
`)).toEqual([
'_ <- embed',
'fmt <- fmt',
'metav1 <- k8s.io/apimachinery/pkg/apis/meta/v1',
'store <- example.com/app/store',
'v2 <- github.com/onsi/ginkgo/v2',
'yaml <- gopkg.in/yaml.v3',
'yaml.v3 <- gopkg.in/yaml.v3',
]);
});
it('read only the import declarations, wherever a comment or a later string spells one', () => {
expect(names(`/*
Package p is a code generator.
func Example() {}
*/
package p
// import "example.com/commented"
import (
"text/template" // (for the body below)
"strings"
)
func run() string {
return strings.TrimSpace(\`
import "example.com/generated"
\`)
}
`)).toEqual(['strings <- strings', 'template <- text/template']);
});
});
describe('Go references through a versioned or go- import', () => {
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-import-names-'));
const files: Record<string, string> = {
'go.mod': 'module example.com/app\n\ngo 1.22\n',
// Project symbols named like what yaml, klog, semver and sqlite3 export.
'other/other.go': [
'package other',
'',
'type Node struct{}',
'',
'type Version struct{}',
'',
'type Error struct{}',
'',
'func Unmarshal(b []byte, v any) error { return nil }',
'',
'type klogWrapper struct{}',
'',
'func (k *klogWrapper) V(level int) bool { return true }',
'',
'func (k *klogWrapper) Infof(format string, args ...any) {}',
'',
].join('\n'),
'store/store.go': [
'package store',
'',
'import (',
'\t"github.com/Masterminds/semver/v3"',
'\t"github.com/mattn/go-sqlite3"',
'\t"go.yaml.in/yaml/v3"',
'\t"k8s.io/klog/v2"',
')',
'',
'func Load(b []byte, root *yaml.Node, min *semver.Version) error {',
'\tif klog.V(2) {',
'\t\tklog.Infof("loading %d bytes", len(b))',
'\t}',
'\tvar e sqlite3.Error',
'\t_ = e',
'\treturn yaml.Unmarshal(b, root)',
'}',
'',
].join('\n'),
// A project module whose path ends in a major version: package kit.
'kit/go.mod': 'module example.com/kit/v2\n\ngo 1.22\n',
'kit/kit.go': [
'package kit',
'',
'type Kit struct{}',
'',
'func New() *Kit { return &Kit{} }',
'',
].join('\n'),
'decoy/decoy.go': [
'package decoy',
'',
'type Kit struct{}',
'',
'func New() *Kit { return &Kit{} }',
'',
'func Find(id string) string { return id }',
'',
].join('\n'),
// k8s.io/api/core/v1 is package v1, beside a package named core.
'api/core/v1/types.go': 'package v1\n\ntype Pod struct{}\n',
'pkg/apis/core/types.go': 'package core\n\ntype Pod struct{}\n',
'repo/repo.go': 'package repo\n\nfunc Find(id string) string { return id }\n',
'app/app.go': [
'package app',
'',
'import (',
'\t"fmt" // for printing',
'\t"example.com/app/repo"',
'\t"example.com/app/api/core/v1"',
'\t"example.com/app/pkg/apis/core"',
'\t"example.com/kit/v2"',
')',
'',
'func Run(p *v1.Pod, q *core.Pod) *kit.Kit {',
'\tfmt.Println(repo.Find("x"))',
'\treturn kit.New()',
'}',
'',
].join('\n'),
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
}, 60_000);
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
const targetsFrom = (file: string) => {
const ids = cg.getNodesInFile(file).map((n) => n.id);
return cg.getOutgoingEdgesFrom(ids)
.filter((e) => e.kind !== 'contains')
.map((e) => {
const t = cg.getNode(e.target)!;
return `${e.kind} ${t.qualifiedName}@${t.filePath}`;
})
.sort();
};
it('never reach a project symbol that only shares the name', () => {
expect(targetsFrom('store/store.go').filter((t) => t.includes('@other/'))).toEqual([]);
});
it('reach the project package the import names', () => {
expect(targetsFrom('app/app.go')).toEqual([
'calls Find@repo/repo.go',
'calls New@kit/kit.go',
'references Kit@kit/kit.go',
'references Pod@api/core/v1/types.go',
'references Pod@pkg/apis/core/types.go',
]);
});
});