// Package modules parses dependency-manifest files and emits // KindModule nodes plus EdgeDependsOnModule edges so agents can // answer "what external packages does this repo depend on" or // "which files import lodash@4" with a single graph query. // // Scope (v1): Go's go.mod. Other ecosystems (package.json, pnpm- // lock, requirements.txt, Cargo.toml, etc.) are tracked as future // follow-ups; the scanner's API is shaped so they can land // alongside without changing the call sites. package modules import ( "bufio" "bytes" "encoding/json" "encoding/xml" "path/filepath" "regexp" "sort" "strings" "github.com/pelletier/go-toml/v2" "github.com/zzet/gortex/internal/graph" ) // Spec is one parsed dependency entry from a manifest file. // Indirect is true for entries marked `// indirect` in go.mod — // agents may want to scope queries to direct deps only, so the // flag rides along on the graph node's meta. type Spec struct { Ecosystem string // "go", "npm", "pypi", … — for v1 always "go" Path string // module path / package name Version string // version string, "" for unpinned Indirect bool Replace string // replacement path when go.mod has a `replace` directive, "" otherwise Line int // 1-based line in the manifest where the spec was found // Alias holds the real package name when an npm dependency is // declared as an alias — `"shared": "npm:@acme/shared-lib@1.4.0"` // makes Path="shared", Version the verbatim `npm:` string, and // Alias="@acme/shared-lib". Empty for every non-aliased entry. Alias string } // ParseGoMod walks go.mod source and returns one Spec per // dependency. Handles three shapes: // // require github.com/foo/bar v1.0.0 // single-line // require ( ... ) // grouped block // replace github.com/foo/bar => ./local/bar // local replacements // // `// indirect` markers attach to the relevant Spec. Comments and // blank lines are skipped. Errors silently produce a partial Spec // list — the indexer treats malformed go.mod as best-effort, not // fatal. func ParseGoMod(source []byte) []Spec { if len(source) == 0 { return nil } var specs []Spec scanner := bufio.NewScanner(bytes.NewReader(source)) scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) inRequire := false inReplace := false lineNum := 0 replaces := map[string]string{} for scanner.Scan() { lineNum++ raw := scanner.Text() line := strings.TrimSpace(raw) if line == "" || strings.HasPrefix(line, "//") { continue } // Block markers. switch { case strings.HasPrefix(line, "require ("): inRequire = true continue case strings.HasPrefix(line, "replace ("): inReplace = true continue case line == ")": inRequire = false inReplace = false continue } // Replace directives — collect first so we can stamp the // replacement onto the require Spec produced from the same // module path. Single-line and block forms both supported. if strings.HasPrefix(line, "replace ") || inReplace { from, to := parseReplace(line) if from != "" { replaces[from] = to } continue } // Require directives. var modulePath, version string var directiveLine = lineNum switch { case strings.HasPrefix(line, "require ") && !strings.Contains(line, "("): parts := strings.Fields(line) if len(parts) >= 3 { modulePath = parts[1] version = parts[2] } case inRequire: parts := strings.Fields(line) if len(parts) >= 2 && strings.Contains(parts[0], "/") { modulePath = parts[0] version = parts[1] } } if modulePath == "" { continue } spec := Spec{ Ecosystem: "go", Path: modulePath, Version: version, Indirect: strings.Contains(raw, "// indirect"), Line: directiveLine, } specs = append(specs, spec) } // Stamp replace targets onto the matching require Spec — done // after the full pass so block-form replaces declared after // requires still attach correctly. for i := range specs { if to, ok := replaces[specs[i].Path]; ok { specs[i].Replace = to } } return specs } // ParsePackageJSON walks an npm-style package.json file's // dependency blocks (`dependencies`, `devDependencies`, // `peerDependencies`, `optionalDependencies`) and returns one // Spec per declared package. The Indirect flag is repurposed for // devDependencies/peerDependencies/optionalDependencies — they // aren't "indirect" in the same Go-module sense, but the flag // lets agents scope to "production-only" deps without a separate // axis. The exact source block lives on Replace as a tag string // (not the most semantically clean home, but it avoids growing // the Spec struct for an ecosystem-specific field) — production // deps get an empty Replace. // // Version strings are kept verbatim — npm semver ranges // (`^1.2.0`, `~3.4.1`, `>=2 <3`) are not normalised, since // resolved-versions belong in the lockfile, not package.json. // A future package-lock.json / pnpm-lock.yaml extractor will // supersede the version string with the resolved one. func ParsePackageJSON(source []byte) []Spec { if len(source) == 0 { return nil } var manifest struct { Dependencies map[string]string `json:"dependencies"` DevDependencies map[string]string `json:"devDependencies"` PeerDependencies map[string]string `json:"peerDependencies"` OptionalDependencies map[string]string `json:"optionalDependencies"` } if err := json.Unmarshal(source, &manifest); err != nil { return nil } var specs []Spec specs = append(specs, packageJSONBlock(manifest.Dependencies, "")...) specs = append(specs, packageJSONBlock(manifest.DevDependencies, "dev")...) specs = append(specs, packageJSONBlock(manifest.PeerDependencies, "peer")...) specs = append(specs, packageJSONBlock(manifest.OptionalDependencies, "optional")...) return specs } // ParseNpmAlias extracts the real package name from an npm-alias // dependency value. npm lets a dependency be re-pointed at a // different package by giving its version range the `npm:` prefix: // // "shared": "npm:@acme/shared-lib@1.4.0" → "@acme/shared-lib" // "lodash4": "npm:lodash@4.17.21" → "lodash" // "left": "npm:left-pad" → "left-pad" (no version) // // The real name may itself be `@scope/name` (one slash) or a plain // `name`. ok is false for any value without the `npm:` prefix or // with an empty real name. func ParseNpmAlias(value string) (realName string, ok bool) { const prefix = "npm:" if !strings.HasPrefix(value, prefix) { return "", false } rest := value[len(prefix):] if rest == "" { return "", false } // Strip the trailing `@version`. A leading `@` belongs to the // scope (`@acme/shared-lib`), so search for the version `@` from // the second byte onward — index 0 is never a version separator. name := rest if at := strings.Index(rest[1:], "@"); at >= 0 { name = rest[:at+1] } if name == "" { return "", false } return name, true } func packageJSONBlock(deps map[string]string, kind string) []Spec { if len(deps) == 0 { return nil } out := make([]Spec, 0, len(deps)) for name, version := range deps { spec := Spec{ Ecosystem: "npm", Path: name, Version: version, Indirect: kind != "", Replace: kind, // dev/peer/optional, "" for production } if alias, ok := ParseNpmAlias(version); ok { spec.Alias = alias } out = append(out, spec) } // Stable order per block — JSON map iteration is randomised, and // downstream consumers (BuildGraphArtifacts dedup, prefix-sort // in LinkImports) tolerate any order, but tests want // determinism. sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) return out } // ParsePackageLockJSON walks an npm v2/v3 package-lock.json // manifest's `packages` map and returns one Spec per declared // package with its resolved version. The lockfile carries the // concrete versions npm picked at install time, which is far // more useful for "is lodash@4 still in use" queries than the // semver ranges in package.json. // // v1 lockfiles (with a top-level `dependencies` map and no // `packages` entry) are not handled — they're rare in the // modern npm ecosystem and a parallel parser would mostly // duplicate this logic. The lockfileVersion field on the // manifest tells the caller which shape they have; the v1 // shape can land as another switch case alongside this when // prioritised. // // The empty-string key in `packages` represents the root // project itself — skipped, the same way ParsePackageJSON // doesn't emit a self-reference. Production / dev classification // follows the same Replace-as-tag convention as the rest: // dev=true entries get Replace="dev"; everything else lands as // production. func ParsePackageLockJSON(source []byte) []Spec { if len(source) == 0 { return nil } var manifest struct { LockfileVersion int `json:"lockfileVersion"` Packages map[string]struct { Version string `json:"version"` Dev bool `json:"dev"` Optional bool `json:"optional"` } `json:"packages"` } if err := json.Unmarshal(source, &manifest); err != nil { return nil } if manifest.LockfileVersion < 2 { // v1 lockfiles use a different shape we don't handle yet. return nil } specs := make([]Spec, 0, len(manifest.Packages)) for key, entry := range manifest.Packages { if key == "" { // The root project's own manifest entry — skip. continue } // Keys are `node_modules/` (or // `node_modules//` for scoped, or nested // `node_modules/foo/node_modules/bar` for transitive). // We strip the leading `node_modules/` prefix and use the // remainder verbatim as the package name. Nested paths // preserve their full chain, which keeps duplicate-name // entries at different versions distinguishable. name := strings.TrimPrefix(key, "node_modules/") if name == "" || entry.Version == "" { continue } spec := Spec{ Ecosystem: "npm", Path: name, Version: entry.Version, } switch { case entry.Dev: spec.Indirect = true spec.Replace = "dev" case entry.Optional: spec.Indirect = true spec.Replace = "optional" } specs = append(specs, spec) } sort.Slice(specs, func(i, j int) bool { return specs[i].Path < specs[j].Path }) return specs } // ParseYarnLock walks a yarn classic (v1) lockfile and returns // one Spec per resolved dependency. Format is yarn's bespoke // pseudo-yaml (key blocks separated by blank lines, each starting // with one or more `@` keys followed by `version // "X.Y.Z"`). The parser is line-oriented and intentionally // minimal — it picks up the resolved version per dependency // block and ignores the rest (resolved/integrity/dependencies // fields aren't needed for graph identity). // // Berry (yarn 2+) lockfiles use real YAML and a different shape; // they're recognised as a separate dispatch row when needed. func ParseYarnLock(source []byte) []Spec { if len(source) == 0 { return nil } scanner := bufio.NewScanner(bytes.NewReader(source)) scanner.Buffer(make([]byte, 64*1024), 16*1024*1024) var ( specs []Spec pendingNames []string seen = make(map[string]struct{}) ) for scanner.Scan() { raw := scanner.Text() line := strings.TrimSpace(raw) if line == "" || strings.HasPrefix(line, "#") { pendingNames = nil continue } // Block header: a line that ends with `:` and contains // one or more `@` keys joined by `, `. if strings.HasSuffix(line, ":") && !strings.HasPrefix(raw, " ") && !strings.HasPrefix(raw, "\t") { pendingNames = parseYarnHeaderNames(strings.TrimSuffix(line, ":")) continue } // Inside a block — pick up the version line. if strings.HasPrefix(line, "version ") { version := strings.Trim(strings.TrimPrefix(line, "version "), `"`) if version == "" || len(pendingNames) == 0 { continue } for _, name := range pendingNames { key := name + "@" + version if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} specs = append(specs, Spec{ Ecosystem: "npm", Path: name, Version: version, }) } pendingNames = nil } } sort.Slice(specs, func(i, j int) bool { if specs[i].Path != specs[j].Path { return specs[i].Path < specs[j].Path } return specs[i].Version < specs[j].Version }) return specs } // parseYarnHeaderNames extracts the package names from a yarn- // classic lockfile block header. Each header consists of one or // more "@" entries separated by `, `. The names can // be quoted ("@scope/pkg@^1.0.0") or bare. We strip the @range // suffix and de-dup so a header like `lodash@^4.0.0, lodash@^4.17.0` // yields just one "lodash" name. func parseYarnHeaderNames(header string) []string { parts := strings.Split(header, ", ") out := make([]string, 0, len(parts)) seen := make(map[string]struct{}, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) p = strings.Trim(p, `"`) if p == "" { continue } // Find the @ that introduces the version range, NOT the // scope @ at position 0. Scoped packages start with @ — // we want the second @, or the first when the package // is unscoped. var atIdx int if strings.HasPrefix(p, "@") { // scoped: skip past the leading @ then find the next // one. atIdx = strings.Index(p[1:], "@") if atIdx >= 0 { atIdx++ } } else { atIdx = strings.Index(p, "@") } if atIdx < 0 { continue } name := p[:atIdx] if _, ok := seen[name]; ok { continue } seen[name] = struct{}{} out = append(out, name) } return out } // ParsePnpmLock walks a pnpm v6+ lockfile (real YAML, top-level // `packages` map keyed by `/@` paths). Each entry // resolves to one Spec with the canonical name+version. func ParsePnpmLock(source []byte) []Spec { if len(source) == 0 { return nil } // pnpm-lock.yaml has the shape: // packages: // /react@18.2.0: // resolution: {...} // /lodash@4.17.21: // ... // We only need the keys; the per-package metadata is large // (integrity hashes, transitive deps) and not needed for // graph identity. Parsing keys via line-oriented scan stays // independent of the YAML library's behaviour around large // nested structures. scanner := bufio.NewScanner(bytes.NewReader(source)) scanner.Buffer(make([]byte, 64*1024), 16*1024*1024) var ( specs []Spec inPackages bool seen = make(map[string]struct{}) ) for scanner.Scan() { raw := scanner.Text() trimmed := strings.TrimSpace(raw) if trimmed == "" || strings.HasPrefix(trimmed, "#") { continue } // The top-level `packages:` key marks where the per-pkg // blocks live. A subsequent unindented top-level key // ends the section. if !strings.HasPrefix(raw, " ") && !strings.HasPrefix(raw, "\t") { inPackages = trimmed == "packages:" continue } if !inPackages { continue } // Per-pkg block headers look like ` /@:` // at exactly two-space indent. Sub-keys are deeper. if !strings.HasPrefix(raw, " /") { continue } key := strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(raw), "/"), ":") key = strings.TrimSuffix(key, "(") // strip peer-suffix start when present // The version is the last `@` segment, accounting for // scoped packages (which start with `@scope/`). var atIdx int if strings.HasPrefix(key, "@") { atIdx = strings.LastIndex(key, "@") } else { atIdx = strings.Index(key, "@") } if atIdx <= 0 { continue } name := key[:atIdx] version := key[atIdx+1:] // Trim peer-dep suffix (`@types/node@20.11.5_typescript@5.3.3`). if i := strings.Index(version, "_"); i >= 0 { version = version[:i] } if name == "" || version == "" { continue } dedupKey := name + "@" + version if _, ok := seen[dedupKey]; ok { continue } seen[dedupKey] = struct{}{} specs = append(specs, Spec{ Ecosystem: "npm", Path: name, Version: version, }) } sort.Slice(specs, func(i, j int) bool { if specs[i].Path != specs[j].Path { return specs[i].Path < specs[j].Path } return specs[i].Version < specs[j].Version }) return specs } // ParsePyProject walks a Python pyproject.toml file's dependency // declarations and returns one Spec per declared package. Both // PEP 621 (`[project] dependencies = ["pkg>=1.0", ...]`) and // Poetry (`[tool.poetry.dependencies] pkg = "^1.0"`) shapes are // recognised; optional-dependency groups (`[project.optional- // dependencies]`) are surfaced with the group name in Replace // (analogous to how npm dev/peer/optional reuse the field). // // Version constraints are kept verbatim — PEP 440 specifiers and // Poetry's caret/tilde ranges aren't normalised; resolved // versions live in the lockfile (poetry.lock / uv.lock / // requirements.txt) which a future extractor will supersede. func ParsePyProject(source []byte) []Spec { if len(source) == 0 { return nil } var manifest struct { Project struct { Dependencies []string `toml:"dependencies"` OptionalDependencies map[string][]string `toml:"optional-dependencies"` } `toml:"project"` Tool struct { Poetry struct { Dependencies map[string]any `toml:"dependencies"` DevDependencies map[string]any `toml:"dev-dependencies"` } `toml:"poetry"` } `toml:"tool"` } if err := toml.Unmarshal(source, &manifest); err != nil { return nil } var specs []Spec // PEP 621 dependencies: list of `name` strings. for _, dep := range manifest.Project.Dependencies { name, version := splitPEP508(dep) if name == "" { continue } specs = append(specs, Spec{ Ecosystem: "pypi", Path: name, Version: version, }) } // PEP 621 optional groups: each group name lands on Replace so // agents can scope by group later. Sorted within each group for // determinism. groupNames := make([]string, 0, len(manifest.Project.OptionalDependencies)) for g := range manifest.Project.OptionalDependencies { groupNames = append(groupNames, g) } sort.Strings(groupNames) for _, group := range groupNames { entries := manifest.Project.OptionalDependencies[group] groupSpecs := make([]Spec, 0, len(entries)) for _, dep := range entries { name, version := splitPEP508(dep) if name == "" { continue } groupSpecs = append(groupSpecs, Spec{ Ecosystem: "pypi", Path: name, Version: version, Indirect: true, Replace: group, }) } sort.Slice(groupSpecs, func(i, j int) bool { return groupSpecs[i].Path < groupSpecs[j].Path }) specs = append(specs, groupSpecs...) } // Poetry shape: name → spec (string or table). When a table is // given we take its `version` field, when a bare string we keep // it verbatim. specs = append(specs, poetryBlock(manifest.Tool.Poetry.Dependencies, "")...) specs = append(specs, poetryBlock(manifest.Tool.Poetry.DevDependencies, "dev")...) return specs } // poetryBlock converts a Poetry `[tool.poetry.*-dependencies]` // table into Spec entries. Values can be either bare version // strings or tables containing a `version` key (for // extras-bearing entries like `requests = { version = "^2.0", // extras = ["socks"] }`). func poetryBlock(deps map[string]any, kind string) []Spec { if len(deps) == 0 { return nil } out := make([]Spec, 0, len(deps)) for name, raw := range deps { if name == "python" { // `python = "^3.10"` is a Python interpreter // constraint, not a dependency. Skip. continue } var version string switch v := raw.(type) { case string: version = v case map[string]any: if s, ok := v["version"].(string); ok { version = s } } out = append(out, Spec{ Ecosystem: "pypi", Path: name, Version: version, Indirect: kind != "", Replace: kind, }) } sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) return out } // pep508Re strips a PEP 508 dependency string into name + version // constraint. Examples: `requests>=2.0` → ("requests", ">=2.0"), // `flask[async]==2.0.0` → ("flask", "==2.0.0"), `numpy` → ("numpy", ""). // Environment markers (`pkg; python_version<'3.9'`) and URL // installs (`pkg @ https://...`) are dropped — they encode // install-context, not the dependency identity our graph cares // about. var pep508Re = regexp.MustCompile(`^([A-Za-z0-9_.\-]+)`) func splitPEP508(dep string) (name, version string) { dep = strings.TrimSpace(dep) // Drop environment markers and URL specs at the first ';' / ' @ '. if idx := strings.Index(dep, ";"); idx >= 0 { dep = strings.TrimSpace(dep[:idx]) } if idx := strings.Index(dep, " @ "); idx >= 0 { dep = strings.TrimSpace(dep[:idx]) } // Drop extras (the `[group]` suffix). if idx := strings.Index(dep, "["); idx >= 0 { end := strings.Index(dep, "]") if end < 0 { return "", "" } dep = strings.TrimSpace(dep[:idx]) + strings.TrimSpace(dep[end+1:]) } m := pep508Re.FindString(dep) if m == "" { return "", "" } name = m version = strings.TrimSpace(dep[len(m):]) return name, version } // ParseRequirementsTxt walks a pip requirements file and returns // one Spec per non-comment, non-blank entry. Each line is parsed // like a PEP 508 dependency; `-r other.txt` and `-e .` lines are // ignored — they're install-mode directives, not dependency // declarations. // // A future enhancement could chase `-r` includes recursively, but // the first-order coverage from a single file is the // 90th-percentile use case. func ParseRequirementsTxt(source []byte) []Spec { if len(source) == 0 { return nil } var specs []Spec scanner := bufio.NewScanner(bytes.NewReader(source)) scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } // Drop trailing inline comment. if i := strings.Index(line, "#"); i > 0 { line = strings.TrimSpace(line[:i]) } if strings.HasPrefix(line, "-") { // `-r`, `-e`, `--index-url`, etc. — skip. continue } name, version := splitPEP508(line) if name == "" { continue } specs = append(specs, Spec{ Ecosystem: "pypi", Path: name, Version: version, }) } return specs } // ParseCargoToml walks a Rust Cargo.toml manifest's dependency // tables and returns one Spec per declared crate. Three blocks // are recognised: [dependencies] for production deps, // [dev-dependencies] (and [dev_dependencies]) for test-only // deps, and [build-dependencies] for build-script deps. The // latter two stamp Replace = "dev" / "build" and Indirect = true, // matching the npm/pyproject convention. // // Each entry's value can be either a bare version string ("1.0", // "^1.0", "~1.0", "=1.0") or a table such as // `serde = { version = "1.0", features = [...] }`. Git/path/ // registry-only deps without a version field are skipped — they // resolve to a workspace-internal source rather than a versioned // crates.io release, and the graph cares about external versioned // identity. func ParseCargoToml(source []byte) []Spec { if len(source) == 0 { return nil } var manifest struct { Dependencies map[string]any `toml:"dependencies"` DevDependencies map[string]any `toml:"dev-dependencies"` DevDependenciesU map[string]any `toml:"dev_dependencies"` BuildDependencies map[string]any `toml:"build-dependencies"` } if err := toml.Unmarshal(source, &manifest); err != nil { return nil } var specs []Spec specs = append(specs, cargoBlock(manifest.Dependencies, "")...) // Cargo's canonical key is `dev-dependencies`, but the // underscore form is occasionally seen in older manifests. // Both fold into a single Replace="dev" set. specs = append(specs, cargoBlock(manifest.DevDependencies, "dev")...) specs = append(specs, cargoBlock(manifest.DevDependenciesU, "dev")...) specs = append(specs, cargoBlock(manifest.BuildDependencies, "build")...) return specs } func cargoBlock(deps map[string]any, kind string) []Spec { if len(deps) == 0 { return nil } out := make([]Spec, 0, len(deps)) for name, raw := range deps { var version string switch v := raw.(type) { case string: version = v case map[string]any: if s, ok := v["version"].(string); ok { version = s } // Git/path deps without a version field resolve to // repo-local sources; skip them. if version == "" { continue } default: continue } out = append(out, Spec{ Ecosystem: "cargo", Path: name, Version: version, Indirect: kind != "", Replace: kind, }) } sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) return out } // ParsePomXML walks a Maven pom.xml manifest and returns one Spec // per entry. Maven coordinates take the form // `:` for the dependency name with the // version a separate child element. The scope attribute // (compile/test/provided/runtime/system/import) maps to Spec // fields the same way npm dev/peer/optional do — `compile` is // the implicit production scope and lands without a Replace // tag; everything else stamps Replace = "" and // Indirect = true so cleanup queries can scope to production-only // deps. // // Maven property substitution (`${project.version}`, // `${spring.version}` etc.) is left verbatim in the version // string. A future enhancement could resolve properties from // and parent inheritance; the v1 keeps it simple // since the canonical source-of-truth for resolved versions is // the lockfile or `mvn dependency:tree` output rather than // pom.xml itself. func ParsePomXML(source []byte) []Spec { if len(source) == 0 { return nil } var manifest struct { Dependencies struct { Dependency []struct { GroupID string `xml:"groupId"` ArtifactID string `xml:"artifactId"` Version string `xml:"version"` Scope string `xml:"scope"` } `xml:"dependency"` } `xml:"dependencies"` } if err := xml.Unmarshal(source, &manifest); err != nil { return nil } specs := make([]Spec, 0, len(manifest.Dependencies.Dependency)) for _, d := range manifest.Dependencies.Dependency { if d.GroupID == "" || d.ArtifactID == "" { continue } scope := strings.TrimSpace(d.Scope) isProduction := scope == "" || scope == "compile" spec := Spec{ Ecosystem: "maven", Path: d.GroupID + ":" + d.ArtifactID, Version: strings.TrimSpace(d.Version), } if !isProduction { spec.Indirect = true spec.Replace = scope } specs = append(specs, spec) } // Stable order — XML parsing preserves source order, but the // downstream dedup + edge emission prefers alphabetical for // diff-able output across runs. sort.Slice(specs, func(i, j int) bool { return specs[i].Path < specs[j].Path }) return specs } // parseReplace extracts the from/to module paths from a replace // directive line. Returns ("", "") when the line doesn't have the // expected `from [version] => to [version]` shape. Replace // versions are dropped — they don't add graph signal beyond what // the require directive already carries. func parseReplace(line string) (from, to string) { line = strings.TrimPrefix(line, "replace ") idx := strings.Index(line, "=>") if idx < 0 { return "", "" } left := strings.TrimSpace(line[:idx]) right := strings.TrimSpace(line[idx+2:]) if left == "" || right == "" { return "", "" } // Drop optional version on the from side (`module v1.x => target`). if parts := strings.Fields(left); len(parts) > 0 { left = parts[0] } if parts := strings.Fields(right); len(parts) > 0 { right = parts[0] } return left, right } // ModuleNodeID returns the canonical ID for a module node. The // `module::` prefix is reserved for shared external-dependency // nodes; the version is included so two repos that depend on // `lodash@3` and `lodash@4` produce two distinct nodes that can be // joined for "version skew" queries. func ModuleNodeID(ecosystem, path, version string) string { id := "module::" + ecosystem + ":" + path if version != "" { id += "@" + version } return id } // BuildGraphArtifacts converts the parsed Spec list into // (modules, edges) pairs. Modules are de-duplicated within the // returned slice — graph.AddNode is idempotent on ID, so one node // per (ecosystem, path, version) tuple is guaranteed even when the // caller appends from multiple manifest files. // // filePath is the unprefixed manifest path (typically "go.mod"). // applyRepoPrefix downstream handles multi-repo namespacing for // the file→module edge, but module IDs themselves do not get // prefixed — the synthetic `module::` prefix matches the existing // `external::` / `annotation::` convention the exporter already // recognises. func BuildGraphArtifacts(filePath string, specs []Spec) ([]*graph.Node, []*graph.Edge) { if len(specs) == 0 { return nil, nil } filePath = filepath.ToSlash(filePath) seen := make(map[string]struct{}, len(specs)) nodes := make([]*graph.Node, 0, len(specs)) edges := make([]*graph.Edge, 0, len(specs)) for _, s := range specs { id := ModuleNodeID(s.Ecosystem, s.Path, s.Version) if _, ok := seen[id]; !ok { seen[id] = struct{}{} meta := map[string]any{ "ecosystem": s.Ecosystem, "path": s.Path, "version": s.Version, "indirect": s.Indirect, } if s.Replace != "" { meta["replace"] = s.Replace } nodes = append(nodes, &graph.Node{ ID: id, Kind: graph.KindModule, Name: shortName(s.Path), FilePath: filePath, Language: "go", Meta: meta, }) } edges = append(edges, &graph.Edge{ From: filePath, To: id, Kind: graph.EdgeDependsOnModule, FilePath: filePath, Line: s.Line, Origin: graph.OriginASTResolved, }) } return nodes, edges } // LinkImports walks every KindImport node in the graph and emits // an EdgeDependsOnModule edge to the matching module node from the // given Spec list. Matching is by longest path prefix — an import // of `github.com/foo/bar/sub` resolves to the spec for // `github.com/foo/bar` when no exact match exists. Returns the // number of edges emitted. // // Imports of repo-internal packages (the indexed module's own // path) are deliberately skipped — they aren't external // dependencies. Multi-version imports (Go's `module/v2` shape) // match the longest spec; a manifest declaring both `bar` and // `bar/v2` will resolve `import bar/v2/sub` to the v2 spec. func LinkImports(g graph.Store, specs []Spec, ownModulePath string) int { if g == nil { return 0 } return LinkImportsIn(g, g.AllNodes(), specs, ownModulePath) } // LinkImportsIn is LinkImports scoped to a caller-supplied import-node // slice. Walking g.AllNodes() inside a warmup loop is O(R · N) — every // repo's manifest pass touches every node in the whole graph. Callers // in multi-repo mode should pass the repo's own KindImport nodes (e.g. // from g.GetRepoNodes(repoPrefix) filtered by Kind) so each pass stays // O(repo size). func LinkImportsIn(g graph.Store, importNodes []*graph.Node, specs []Spec, ownModulePath string) int { if g == nil || len(specs) == 0 || len(importNodes) == 0 { return 0 } // Index specs by path for quick longest-prefix lookup. When two // specs share a path (shouldn't happen in a well-formed go.mod, // but guard against duplicates) the first wins — graph node // dedup later handles any concrete conflict. specByPath := make(map[string]Spec, len(specs)) paths := make([]string, 0, len(specs)) for _, s := range specs { if _, ok := specByPath[s.Path]; ok { continue } specByPath[s.Path] = s paths = append(paths, s.Path) } // Sort longest first so the prefix scan picks the most specific // match without an O(n²) probe. sort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) }) emitted := 0 for _, n := range importNodes { if n.Kind != graph.KindImport { continue } importPath, _ := n.Meta["path"].(string) if importPath == "" { continue } if ownModulePath != "" && (importPath == ownModulePath || strings.HasPrefix(importPath, ownModulePath+"/")) { continue } matched := matchLongestPrefix(importPath, paths) if matched == "" { continue } spec := specByPath[matched] moduleID := ModuleNodeID(spec.Ecosystem, spec.Path, spec.Version) g.AddEdge(&graph.Edge{ From: n.ID, To: moduleID, Kind: graph.EdgeDependsOnModule, FilePath: n.FilePath, Line: n.StartLine, Origin: graph.OriginASTResolved, }) emitted++ } return emitted } // matchLongestPrefix returns the longest path from candidates that // matches importPath as either an exact match or a directory // prefix. Candidates must already be sorted by descending length; // the first hit wins. func matchLongestPrefix(importPath string, candidates []string) string { for _, p := range candidates { if importPath == p { return p } if strings.HasPrefix(importPath, p+"/") { return p } } return "" } // shortName returns the last meaningful segment of a module path — // useful for the Name field surfaced by Brief listings. Strips the // `vN` major-version suffix when present (`github.com/foo/bar/v2` // → `bar`, not `v2`). func shortName(path string) string { if path == "" { return "" } parts := strings.Split(path, "/") last := parts[len(parts)-1] if isMajorVersionSegment(last) && len(parts) >= 2 { return parts[len(parts)-2] } return last } func isMajorVersionSegment(s string) bool { if len(s) < 2 || s[0] != 'v' { return false } for i := 1; i < len(s); i++ { if s[i] < '0' || s[i] > '9' { return false } } return true }