Files
zzet--gortex/internal/indexer/contract_import_resolve_test.go
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

505 lines
19 KiB
Go

package indexer
import (
"reflect"
"testing"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser/tsalias"
)
func TestTSFileCandidates(t *testing.T) {
got := tsFileCandidates("src/components/Widget.ts")
for _, want := range []string{
"src/components/Widget.ts",
"src/components/Widget.tsx",
"src/components/Widget/index.ts",
"src/components/Widget/index.tsx",
} {
require.Contains(t, got, want)
}
require.Nil(t, tsFileCandidates(""))
}
// TestTSFileCandidates_SingleFileComponents confirms `.svelte` /
// `.vue` module specifiers expand to a direct file candidate (the
// stem with the SFC extension), and never to a directory `index`
// barrel — single-file components have no `index.svelte` convention.
func TestTSFileCandidates_SingleFileComponents(t *testing.T) {
got := tsFileCandidates("src/lib/Button.svelte")
require.Contains(t, got, "src/lib/Button.svelte")
require.NotContains(t, got, "src/lib/Button/index.svelte")
gotVue := tsFileCandidates("src/lib/Card.vue")
require.Contains(t, gotVue, "src/lib/Card.vue")
require.NotContains(t, gotVue, "src/lib/Card/index.vue")
}
// TestParseTSReExports_DefaultAsSvelte covers the Svelte component
// barrel form `export { default as Button } from './Button.svelte'`:
// the module specifier resolves to the `.svelte` file (no `.ts`
// appended), and the local exported name `Button` maps to the
// module's `default` export.
func TestParseTSReExports_DefaultAsSvelte(t *testing.T) {
src := `
export { default as Button } from './Button.svelte';
export { default as Card } from './Card.vue';
`
res := parseTSReExports(src, "src/lib/index.ts", nil, "")
require.Len(t, res, 2)
byFile := map[string]tsReExport{}
for _, re := range res {
byFile[re.fromFile] = re
}
// The `.svelte` / `.vue` specifier keeps its extension verbatim.
require.Equal(t, "default", byFile["src/lib/Button.svelte"].names["Button"])
require.Equal(t, "default", byFile["src/lib/Card.vue"].names["Card"])
}
// TestFollowReExportChain_DefaultAsSvelte drives the full follower over
// a Svelte barrel: a consumer imports `Button`, the barrel re-exports
// it as the default export of a `.svelte` component, and the chain must
// reach that component file.
func TestFollowReExportChain_DefaultAsSvelte(t *testing.T) {
mi := &MultiIndexer{}
srcCache := map[string][]byte{
"src/lib/index.ts": []byte(`export { default as Button } from './Button.svelte';`),
"src/lib/Button.svelte": []byte(`<script lang="ts">export let label = "";</script>`),
}
reachable := mi.followReExportChain("src/lib/index.ts", "Button", srcCache)
require.True(t, reachable["src/lib/Button.svelte"],
"the `default as` re-export must reach the .svelte component file")
}
func TestParseTSReExports_StarNamedAndType(t *testing.T) {
src := `
export * from './widgets';
export { Button, Icon as Glyph } from './ui';
export type { Theme } from './theme';
export * as utils from './util';
export const local = 1;
`
res := parseTSReExports(src, "src/index.ts", nil, "")
// star + named + type re-exports — the `* as utils` namespace
// re-export and the local const are not forwarding statements.
require.Len(t, res, 3)
byFile := map[string]tsReExport{}
for _, re := range res {
byFile[re.fromFile] = re
}
require.True(t, byFile["src/widgets.ts"].star)
require.Equal(t, "Button", byFile["src/ui.ts"].names["Button"])
require.Equal(t, "Icon", byFile["src/ui.ts"].names["Glyph"]) // `as` rebind
require.Equal(t, "Theme", byFile["src/theme.ts"].names["Theme"])
}
func TestFollowReExportChain_BarrelToTerminal(t *testing.T) {
mi := &MultiIndexer{}
srcCache := map[string][]byte{
"src/index.ts": []byte(`export { Widget } from './components';`),
"src/components/index.ts": []byte(`export * from './Widget';`),
"src/components/Widget.ts": []byte(`export interface Widget { id: string }`),
}
reachable := mi.followReExportChain("src/index.ts", "Widget", srcCache)
require.True(t, reachable["src/components/Widget.ts"],
"re-export chain must reach the terminal module")
}
func TestFollowReExportChain_RenamedExport(t *testing.T) {
mi := &MultiIndexer{}
srcCache := map[string][]byte{
"pkg/index.ts": []byte(`export { Internal as Public } from './impl';`),
"pkg/impl.ts": []byte(`export class Internal {}`),
}
reachable := mi.followReExportChain("pkg/index.ts", "Public", srcCache)
require.True(t, reachable["pkg/impl.ts"], "must follow the `as` rename to the source module")
}
func TestFollowReExportChain_CircularTerminates(t *testing.T) {
mi := &MultiIndexer{}
srcCache := map[string][]byte{
"a.ts": []byte(`export * from './b';`),
"b.ts": []byte(`export * from './a';`),
}
reachable := mi.followReExportChain("a.ts", "X", srcCache)
require.True(t, reachable["a.ts"])
require.True(t, reachable["b.ts"])
}
// TestResolveTSModulePath_AliasResolvesWithPrefix wires an alias map
// (matching what tsalias.Load would produce for a Next.js-style
// `@/*` config) into resolveTSModulePath and verifies the result is
// re-prefixed with the repo prefix so the caller can match it
// against the graph node's FilePath.
func TestResolveTSModulePath_AliasResolvesWithPrefix(t *testing.T) {
m := &tsalias.Map{
Entries: []tsalias.Alias{
{AliasPrefix: "@/", TargetPrefix: "src/", HasWildcard: true},
},
}
got := resolveTSModulePath("@/lib/api", "myrepo/src/components", m, "myrepo")
want := "myrepo/src/lib/api.ts"
if got != want {
t.Errorf("resolveTSModulePath alias = %q, want %q", got, want)
}
}
// TestResolveTSModulePath_AliasNoMatchFallsThrough ensures that a bare
// specifier with no matching alias entry still returns "" so the
// caller leaves the bare-name reference in place.
func TestResolveTSModulePath_AliasNoMatchFallsThrough(t *testing.T) {
m := &tsalias.Map{
Entries: []tsalias.Alias{
{AliasPrefix: "@/", TargetPrefix: "src/", HasWildcard: true},
},
}
if got := resolveTSModulePath("react", "myrepo/src", m, "myrepo"); got != "" {
t.Errorf("unmatched bare specifier should resolve to \"\", got %q", got)
}
}
// TestParseTSImports_AliasResolvesNamedImport drives the full path
// from import-statement scanning through alias resolution.
func TestParseTSImports_AliasResolvesNamedImport(t *testing.T) {
src := `
import { Foo } from '@/lib/schema'
`
m := &tsalias.Map{
Entries: []tsalias.Alias{
{AliasPrefix: "@/", TargetPrefix: "src/", HasWildcard: true},
},
}
got := parseTSImports(src, "web/src/app/page.tsx", m, "web")
want := map[string]string{"Foo": "web/src/lib/schema.ts"}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v\nwant %+v", got, want)
}
}
// TestParseTSImports_ResolvesNamedImports covers the common shape:
// `import type { Foo, Bar as Baz } from './schema'`. Each named
// binding maps to the resolved file path; the alias rebind uses
// the local-binding name (`Baz`), not the source name (`Bar`).
func TestParseTSImports_ResolvesNamedImports(t *testing.T) {
src := `
import type { Foo, Bar as Baz } from './schema'
import { Quux } from '../shared/types'
`
got := parseTSImports(src, "web/src/lib/api.ts", nil, "")
want := map[string]string{
"Foo": "web/src/lib/schema.ts",
"Baz": "web/src/lib/schema.ts",
"Quux": "web/src/shared/types.ts",
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v\nwant %+v", got, want)
}
}
// TestParseTSImports_SkipsBareSpecifiers ensures third-party and
// path-aliased imports (`react`, `@/lib/foo`) don't leak into the
// resolution map — they don't correspond to a graph file in the
// local repo and would mislead the disambiguator if they did.
func TestParseTSImports_SkipsBareSpecifiers(t *testing.T) {
src := `
import React from 'react'
import { useState } from 'react'
import { foo } from '@/lib/foo'
`
got := parseTSImports(src, "web/src/lib/api.ts", nil, "")
if len(got) != 0 {
t.Errorf("expected empty map for bare specifiers, got %+v", got)
}
}
// TestParseTSImports_DefaultAndNamespace checks `import Foo from`
// (default) and `import * as NS from` bindings.
func TestParseTSImports_DefaultAndNamespace(t *testing.T) {
src := `
import Default from './a'
import * as NS from './b'
`
got := parseTSImports(src, "x/y.ts", nil, "")
if got["Default"] != "x/a.ts" {
t.Errorf("default import: got %q, want %q", got["Default"], "x/a.ts")
}
if got["NS"] != "x/b.ts" {
t.Errorf("namespace import: got %q, want %q", got["NS"], "x/b.ts")
}
}
// TestSplitTSImportClause_AliasAndType normalises the `type` keyword
// and `as <local>` rebind in named-import bodies.
func TestSplitTSImportClause_AliasAndType(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"Foo, Bar", []string{"Foo", "Bar"}},
{"type Foo, Bar as Baz", []string{"Foo", "Baz"}},
{" Foo , type Bar ", []string{"Foo", "Bar"}},
{"", nil},
}
for _, c := range cases {
got := splitTSImportClause(c.in)
if !reflect.DeepEqual(got, c.want) {
t.Errorf("splitTSImportClause(%q) = %v, want %v", c.in, got, c.want)
}
}
}
// TestResolveTSModulePath_AddsTSExtension confirms that a relative
// specifier without an extension picks up `.ts` so a candidate
// living in `<dir>/file.ts` is matchable. Specifiers with an
// explicit extension are left alone.
func TestResolveTSModulePath_AddsTSExtension(t *testing.T) {
cases := []struct {
mod, dir, want string
}{
{"./schema", "web/src/lib", "web/src/lib/schema.ts"},
{"./schema.ts", "web/src/lib", "web/src/lib/schema.ts"},
{"./schema.tsx", "web/src/lib", "web/src/lib/schema.tsx"},
{"../api", "web/src/lib/inner", "web/src/lib/api.ts"},
{"react", "web/src/lib", ""},
{"@/lib/foo", "web/src/lib", ""},
}
for _, c := range cases {
got := resolveTSModulePath(c.mod, c.dir, nil, "")
if got != c.want {
t.Errorf("resolveTSModulePath(%q, %q) = %q, want %q", c.mod, c.dir, got, c.want)
}
}
}
// TestIsImportResolvableLang only flags TS / JS family extensions —
// Go / Python / etc. are skipped because their import semantics
// differ enough that this resolver would mis-attribute candidates.
func TestIsImportResolvableLang(t *testing.T) {
pos := []string{"a.ts", "b.tsx", "c.js", "d.jsx", "e.mts", "f.cjs", "g.rs"}
neg := []string{"a.go", "b.py", "c.rb", "d.java", "e", "f.kt"}
for _, p := range pos {
if !isImportResolvableLang(p) {
t.Errorf("%q: want true, got false", p)
}
}
for _, p := range neg {
if isImportResolvableLang(p) {
t.Errorf("%q: want false, got true", p)
}
}
}
// TestFollowReExportChain_WildcardMultiHop drives a wildcard
// (`export *`) re-export chain two hops deep: the entry barrel stars
// a mid barrel, which stars the terminal module that actually defines
// the symbol. The follower must reach the terminal file.
func TestFollowReExportChain_WildcardMultiHop(t *testing.T) {
mi := &MultiIndexer{}
srcCache := map[string][]byte{
"pkg/index.ts": []byte(`export * from './mid';`),
"pkg/mid.ts": []byte(`export * from './terminal';`),
"pkg/terminal.ts": []byte(`export class Engine {}`),
"pkg/unrelated.ts": []byte(`export class Engine {}`),
}
reachable := mi.followReExportChain("pkg/index.ts", "Engine", srcCache)
require.True(t, reachable["pkg/terminal.ts"],
"multi-hop `export *` chain must reach the terminal module")
require.False(t, reachable["pkg/unrelated.ts"],
"a same-named symbol in an unrelated file must not be reached")
}
// TestFollowReExportChain_WildcardThroughNamed mixes the two TS forms:
// a named re-export of a symbol whose source module then `export *`s
// the terminal definition module.
func TestFollowReExportChain_WildcardThroughNamed(t *testing.T) {
mi := &MultiIndexer{}
srcCache := map[string][]byte{
"src/index.ts": []byte(`export { Theme } from './theme';`),
"src/theme.ts": []byte(`export * from './tokens';`),
"src/tokens.ts": []byte(`export interface Theme { name: string }`),
}
reachable := mi.followReExportChain("src/index.ts", "Theme", srcCache)
require.True(t, reachable["src/tokens.ts"],
"named → wildcard re-export chain must reach the definition module")
}
// TestParseRustReExports covers every `pub use` shape the re-export
// parser recognises, and asserts a plain (private) `use` is never
// treated as a re-export.
func TestParseRustReExports(t *testing.T) {
src := `
pub use crate::engine::Renderer;
pub use crate::shapes::{Circle, Square as Box};
pub use crate::prelude::*;
pub(crate) use crate::util::Helper;
use crate::internal::Secret;
`
res := parseRustReExports(src, "src/lib.rs")
byFile := map[string]reExportEdge{}
for _, re := range res {
byFile[re.fromFile] = re
}
// Single, list, glob, and pub(crate) forms forward; the private
// `use crate::internal::Secret` does not.
require.Equal(t, "Renderer", byFile["src/engine.rs"].names["Renderer"])
require.Equal(t, "Circle", byFile["src/shapes.rs"].names["Circle"])
require.Equal(t, "Square", byFile["src/shapes.rs"].names["Box"]) // `as` rebind
require.True(t, byFile["src/prelude.rs"].star)
require.Equal(t, "Helper", byFile["src/util.rs"].names["Helper"])
_, hasSecret := byFile["src/internal.rs"]
require.False(t, hasSecret, "a private `use` must not be parsed as a re-export")
}
// TestFollowReExportChain_RustPubUseMultiHop drives a `pub use`
// re-export chain two hops deep through three Rust modules: the crate
// root re-exports a name from the `api` module, which re-exports it
// from the `domain` module that actually defines it.
func TestFollowReExportChain_RustPubUseMultiHop(t *testing.T) {
mi := &MultiIndexer{}
srcCache := map[string][]byte{
"crate/src/lib.rs": []byte(`pub use crate::api::User;`),
"crate/src/api.rs": []byte(`pub use crate::domain::User;`),
"crate/src/domain.rs": []byte(`pub struct User { id: u64 }`),
"crate/src/unrelated.rs": []byte(`pub struct User;`),
}
reachable := mi.followReExportChain("crate/src/lib.rs", "User", srcCache)
require.True(t, reachable["crate/src/domain.rs"],
"multi-hop `pub use` chain must reach the defining module")
require.False(t, reachable["crate/src/unrelated.rs"],
"a same-named type in an unrelated module must not be reached")
}
// TestFollowReExportChain_RustGlobAndModDir exercises a `pub use
// mod::*` glob re-export and the `mod.rs` directory-module layout.
func TestFollowReExportChain_RustGlobAndModDir(t *testing.T) {
mi := &MultiIndexer{}
srcCache := map[string][]byte{
"src/lib.rs": []byte(`pub use crate::shapes::*;`),
"src/shapes/mod.rs": []byte(`pub use self::circle::Circle;`),
"src/shapes/circle.rs": []byte(`pub struct Circle { r: f64 }`),
}
reachable := mi.followReExportChain("src/lib.rs", "Circle", srcCache)
require.True(t, reachable["src/shapes/circle.rs"],
"glob re-export into a mod.rs directory module must reach the leaf")
}
// TestFollowReExportChain_RustPrivateUseNotForwarded is the negative
// case: a module that imports a symbol with a plain (non-`pub`) `use`
// does not re-export it, so the chain must not reach the symbol's
// real definition through that module.
func TestFollowReExportChain_RustPrivateUseNotForwarded(t *testing.T) {
mi := &MultiIndexer{}
srcCache := map[string][]byte{
// `api` only privately `use`s Token — it is not re-exported.
"src/lib.rs": []byte(`pub use crate::api::Token;`),
"src/api.rs": []byte(`use crate::secret::Token;`),
"src/secret.rs": []byte(`pub struct Token;`),
}
reachable := mi.followReExportChain("src/lib.rs", "Token", srcCache)
require.False(t, reachable["src/secret.rs"],
"a privately-`use`d symbol must not resolve through the re-export chain")
}
// TestResolveBareTypeViaImports_ThroughReExports is the end-to-end
// table: a consumer file imports a type name that collides with a
// same-named decoy elsewhere in the graph, and only the genuine
// definition is reachable through the wildcard / `pub use` re-export
// chain. resolveBareTypeViaImports must return the ID of that genuine
// definition node — or "" when the chain does not forward the name.
func TestResolveBareTypeViaImports_ThroughReExports(t *testing.T) {
cases := []struct {
name string
consumer string // consumer file path
typeName string // bare type name being resolved
files map[string][]byte // re-export fixture sources
nodes map[string]string // candidate node ID -> FilePath
wantID string // expected resolved node ID ("" = unresolved)
}{
{
name: "ts wildcard multi-hop resolves to original definition",
consumer: "web/src/app.ts",
typeName: "Widget",
files: map[string][]byte{
"web/src/app.ts": []byte(`import { Widget } from './ui';`),
"web/src/ui.ts": []byte(`export * from './ui/index';`),
"web/src/ui/index.ts": []byte(`export * from './widget';`),
"web/src/ui/widget.ts": []byte(`export interface Widget { id: string }`),
},
nodes: map[string]string{
"web/src/ui/widget.ts::Widget": "web/src/ui/widget.ts",
"web/src/legacy/old.ts::Widget": "web/src/legacy/old.ts",
},
wantID: "web/src/ui/widget.ts::Widget",
},
{
name: "js wildcard re-export resolves to original definition",
consumer: "app/src/main.js",
typeName: "Parser",
files: map[string][]byte{
"app/src/main.js": []byte(`import { Parser } from './lib';`),
"app/src/lib.js": []byte(`export * from './core';`),
"app/src/core.js": []byte(`export class Parser {}`),
},
nodes: map[string]string{
"app/src/core.js::Parser": "app/src/core.js",
"app/src/v1/legacy.js::Parser": "app/src/v1/legacy.js",
},
wantID: "app/src/core.js::Parser",
},
{
name: "rust pub use multi-hop resolves to original definition",
consumer: "crate/src/main.rs",
typeName: "Account",
files: map[string][]byte{
"crate/src/main.rs": []byte(`use crate::api::Account;`),
"crate/src/api.rs": []byte(`pub use crate::model::Account;`),
"crate/src/model.rs": []byte(`pub use crate::store::Account;`),
"crate/src/store.rs": []byte(`pub struct Account { id: u64 }`),
},
nodes: map[string]string{
"crate/src/store.rs::Account": "crate/src/store.rs",
"crate/src/other.rs::Account": "crate/src/other.rs",
},
wantID: "crate/src/store.rs::Account",
},
{
name: "rust private use does not resolve through",
consumer: "crate/src/main.rs",
typeName: "Hidden",
files: map[string][]byte{
"crate/src/main.rs": []byte(`use crate::api::Hidden;`),
"crate/src/api.rs": []byte(`use crate::internal::Hidden;`),
"crate/src/internal.rs": []byte(`pub struct Hidden;`),
},
nodes: map[string]string{
"crate/src/internal.rs::Hidden": "crate/src/internal.rs",
"crate/src/decoy.rs::Hidden": "crate/src/decoy.rs",
},
wantID: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
g := graph.New()
for id, fp := range tc.nodes {
g.AddNode(&graph.Node{
ID: id, Kind: graph.KindType, Name: tc.typeName,
FilePath: fp, StartLine: 1,
})
}
mi := &MultiIndexer{}
srcCache := map[string][]byte{}
for k, v := range tc.files {
srcCache[k] = v
}
importCache := map[string]map[string]string{}
got := mi.resolveBareTypeViaImports(tc.consumer, tc.typeName, g, srcCache, importCache)
require.Equal(t, tc.wantID, got)
})
}
}