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
1062 lines
33 KiB
Go
1062 lines
33 KiB
Go
// 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/<name>` (or
|
|
// `node_modules/<scope>/<name>` 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 `<name>@<range>` 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 `<name>@<range>` 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 "<name>@<range>" 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 `/<name>@<version>` 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 ` /<name>@<version>:`
|
|
// 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<spec>` 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 <dependency> entry. Maven coordinates take the form
|
|
// `<groupId>:<artifactId>` 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 = "<scope>" 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
|
|
// <properties> 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
|
|
}
|