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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ go get github.com/git-pkgs/provides

`Surface` maps a versioned PURL to its provided source names. `Binding` connects a PURL to the imported and local names used by one project. An aliased binding may also retain the package-side target name. Both retain the evidence used to produce the mapping.

`ProvidedName.Name` is the exact source-visible spelling. Package-manager normalisation does not apply to it, and matching is case-sensitive. `flask` therefore does not match `Flask`.
`ProvidedName.Name` is the exact source-visible spelling. Package-manager normalisation does not apply to it, and matching is case-sensitive by default. `flask` therefore does not match `Flask`. Set `CaseInsensitive` for languages whose module or namespace lookup folds case, for example PHP, where `use GuzzleHttp\Client` and `use guzzlehttp\client` resolve identically.

## Matching names

Expand Down Expand Up @@ -116,6 +116,20 @@ result, err := provides.ResolveProjectSurfaces(

This path reads no files, runs no package-manager commands, and makes no network requests. PyPI distribution names are normalised for catalog lookup, while each returned `Surface.PURL` retains the caller's spelling and version. Unknown packages are omitted without producing a diagnostic.

## Heuristic surfaces

The `heuristic` package derives conventional source names from a package's PURL type and name alone: an npm package `ws` provides module `ws` and any `ws/...` subpath, PyPI `Engine-IO-Parser` provides `engine_io_parser`, gem `active_support` provides both feature `active_support` and constant `ActiveSupport`, Cargo `tokio-util` provides crate `tokio_util`. It covers `npm`, `pypi`, `golang`, `gem`, `cargo`, `composer`, `hex`, and `maven`; other PURL types resolve to an empty surface. Every returned name carries `EvidenceHeuristic` so callers can distinguish a naming-convention guess from a verified mapping.

Packages whose importable name is not a mechanical transform of their registry name (PyYAML → `yaml`, Pillow → `PIL`, most Composer PSR-4 roots) need curated data or an artifact resolver. `Chain` runs several resolvers over the same package and merges their results, so an authoritative source can be tried first and the naming convention fills whatever it does not cover:

```go
resolver := provides.Chain(curated.Python(), heuristic.Resolver())

project, err := provides.ResolveProjectSurfaces(ctx, resolver, packages, provides.SurfaceOptions{})
```

For PyYAML this returns both `yaml` (curated) and `pyyaml` (heuristic) with their respective evidence; `MatchImport("python", "yaml", project)` matches on the curated entry while a package the catalog does not list still resolves via the heuristic.

## Resolving an import

`ResolveImport` combines project-surface resolution with a reverse lookup. Every matching dependency is returned when an import is ambiguous:
Expand Down
148 changes: 148 additions & 0 deletions heuristic/heuristic.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Package heuristic provides a SurfaceResolver that maps a package identity
// to its conventional source-level name using per-ecosystem naming rules
// alone. It reads no files, runs no commands, and makes no network requests.
//
// The mappings cover the common case where a package's importable name is a
// mechanical transform of its registry name: an npm package `ws` provides
// module `ws`, a PyPI distribution `Engine-IO-Parser` provides module
// `engine_io_parser`, a Ruby gem `active_support` provides constant
// `ActiveSupport`. Packages whose importable name is unrelated to their
// registry name (PyYAML → yaml, Pillow → PIL) need curated data or an
// artifact resolver; chain this resolver after one of those so the
// convention fills gaps the authoritative source did not cover.
//
// Every returned ProvidedName carries EvidenceHeuristic so downstream code
// can distinguish a naming-convention guess from a verified mapping.
package heuristic

import (
"context"
"strings"

"github.com/git-pkgs/provides"
"github.com/git-pkgs/purl"
)

const source = "heuristic"

// Resolver returns a SurfaceResolver that derives conventional source names
// from a package's PURL type and name. Ecosystems without a registered
// convention resolve to an empty surface with no diagnostic.
func Resolver() provides.SurfaceResolverFunc {
return resolve
}

func resolve(ctx context.Context, pkg provides.Package, _ provides.SurfaceOptions) (provides.SurfaceResult, error) {
if err := ctx.Err(); err != nil {
return provides.SurfaceResult{}, err
}
p, err := purl.Parse(pkg.PURL)
if err != nil {
return provides.SurfaceResult{
Diagnostics: []provides.Diagnostic{{Source: source, Message: err.Error()}},
}, nil
}
fn, ok := conventions[p.Type]
if !ok {
return provides.SurfaceResult{Surface: provides.Surface{PURL: pkg.PURL}}, nil
}
return provides.SurfaceResult{
Surface: provides.Surface{PURL: pkg.PURL, Provides: fn(p)},
}, nil
}

// conventions maps a PURL type to the source names its packages
// conventionally provide.
var conventions = map[string]func(*purl.PURL) []provides.ProvidedName{
"npm": func(p *purl.PURL) []provides.ProvidedName {
// Bare specifier and any subpath under it: `ws`, `ws/lib/sender`.
name := p.Name
if p.Namespace != "" {
name = p.Namespace + "/" + p.Name
}
return []provides.ProvidedName{prefix("javascript", name, "module", "/", false)}
},
"pypi": func(p *purl.PURL) []provides.ProvidedName {
// Distribution names are case-insensitive; the purl spec
// lowercases and replaces _ with - so p.Name arrives normalised.
// Hyphens are not valid in Python identifiers, so a hyphenated
// distribution conventionally installs an underscored module. A
// dot is preserved because dotted distribution names
// (`zope.interface`, `ruamel.yaml`) conventionally install as
// namespace packages with the same dotted import path.
return []provides.ProvidedName{
prefix("python", strings.ToLower(underscore(p.Name)), "module", ".", false),
}
},
"golang": func(p *purl.PURL) []provides.ProvidedName {
// Import paths are the module path or a package under it.
module := p.Name
if p.Namespace != "" {
module = p.Namespace + "/" + p.Name
}
return []provides.ProvidedName{prefix("go", module, "package", "/", false)}
},
"gem": func(p *purl.PURL) []provides.ProvidedName {
return []provides.ProvidedName{
// require 'gem' or 'gem/sub'
prefix("ruby", p.Name, "feature", "/", false),
// Bundler-autoloaded top-level constant.
prefix("ruby", camelize(p.Name), "constant", "::", false),
}
},
"cargo": func(p *purl.PURL) []provides.ProvidedName {
// Crate identifiers replace hyphens with underscores.
return []provides.ProvidedName{prefix("rust", underscore(p.Name), "crate", "::", false)}
},
"composer": func(p *purl.PURL) []provides.ProvidedName {
// PSR-4 root is conventionally the vendor segment titlecased. PHP
// namespace resolution is case-insensitive so the guess need only
// match after case folding.
vendor := p.Namespace
if vendor == "" {
vendor = p.Name
}
return []provides.ProvidedName{prefix("php", camelize(vendor), "namespace", `\`, true)}
},
"hex": func(p *purl.PURL) []provides.ProvidedName {
return []provides.ProvidedName{prefix("elixir", camelize(p.Name), "module", ".", false)}
},
"maven": func(p *purl.PURL) []provides.ProvidedName {
// Java packages conventionally follow the reversed-domain group ID.
return []provides.ProvidedName{prefix("java", p.Namespace, "package", ".", false)}
},
}

func prefix(lang, name, kind, sep string, ci bool) provides.ProvidedName {
return provides.ProvidedName{
Language: lang,
Name: name,
Kind: kind,
Match: provides.MatchPrefix,
Separator: sep,
CaseInsensitive: ci,
Evidence: []provides.Evidence{{Method: provides.EvidenceHeuristic, Source: source}},
}
}

// underscore replaces hyphens with underscores.
func underscore(s string) string { return strings.ReplaceAll(s, "-", "_") }
Comment thread
andrew marked this conversation as resolved.

// camelize turns a hyphen/underscore-separated name into UpperCamelCase.
func camelize(s string) string {
var b strings.Builder
up := true
for i := 0; i < len(s); i++ {
c := s[i]
if c == '_' || c == '-' {
up = true
continue
}
if up && 'a' <= c && c <= 'z' {
c -= 'a' - 'A'
}
up = false
b.WriteByte(c)
}
return b.String()
}
146 changes: 146 additions & 0 deletions heuristic/heuristic_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package heuristic

import (
"context"
"testing"

"github.com/git-pkgs/provides"
)

func resolveOne(t *testing.T, purl string) provides.Surface {
t.Helper()
res, err := Resolver().ResolveSurface(context.Background(), provides.Package{PURL: purl}, provides.SurfaceOptions{})
if err != nil {
t.Fatalf("resolve %s: %v", purl, err)
}
return res.Surface
}

func firstName(t *testing.T, s provides.Surface) provides.ProvidedName {
t.Helper()
if len(s.Provides) == 0 {
t.Fatalf("no provided names: %+v", s)
}
return s.Provides[0]
}

func TestNPM(t *testing.T) {
n := firstName(t, resolveOne(t, "pkg:npm/ws@8.17.1"))
if n.Language != "javascript" || n.Name != "ws" || !n.Matches("ws/lib/sender") {
t.Errorf("ws: %+v", n)
}
scoped := firstName(t, resolveOne(t, "pkg:npm/%40babel/core"))
if scoped.Name != "@babel/core" || !scoped.Matches("@babel/core/lib/parse") {
t.Errorf("scoped: %+v", scoped)
}
if scoped.Matches("@babel/core-utils") {
t.Error("prefix over-match")
}
}

func TestPyPI(t *testing.T) {
n := firstName(t, resolveOne(t, "pkg:pypi/Engine-IO-Parser@1.0"))
if n.Name != "engine_io_parser" || !n.Matches("engine_io_parser.decode") {
t.Errorf("pypi normalisation: %+v", n)
}
f := firstName(t, resolveOne(t, "pkg:pypi/Flask"))
if !f.Matches("flask") || f.Matches("Flask") {
t.Errorf("pypi case: %+v", f)
}
// Dotted distributions install as namespace packages with the dot
// retained in the import path.
z := firstName(t, resolveOne(t, "pkg:pypi/zope.interface"))
if z.Name != "zope.interface" || !z.Matches("zope.interface.declarations") {
t.Errorf("dotted distribution should keep dot: %+v", z)
}
}

func TestGo(t *testing.T) {
n := firstName(t, resolveOne(t, "pkg:golang/github.com/gin-contrib/sse"))
if n.Name != "github.com/gin-contrib/sse" || !n.Matches("github.com/gin-contrib/sse/v2") {
t.Errorf("go module: %+v", n)
}
if n.Matches("github.com/gin-contrib/sse-other") {
t.Error("prefix over-match")
}
}

func TestGem(t *testing.T) {
s := resolveOne(t, "pkg:gem/active_support")
if len(s.Provides) != 2 {
t.Fatalf("gem should provide feature + constant: %+v", s.Provides)
}
var feat, konst provides.ProvidedName
for _, n := range s.Provides {
switch n.Kind {
case "feature":
feat = n
case "constant":
konst = n
}
}
if !feat.Matches("active_support/core_ext") {
t.Errorf("feature subpath: %+v", feat)
}
if konst.Name != "ActiveSupport" || !konst.Matches("ActiveSupport::Duration") {
t.Errorf("constant: %+v", konst)
}
}

func TestCargo(t *testing.T) {
n := firstName(t, resolveOne(t, "pkg:cargo/tokio-util"))
if n.Name != "tokio_util" || !n.Matches("tokio_util::codec") {
t.Errorf("cargo hyphen→underscore: %+v", n)
}
}

func TestComposer(t *testing.T) {
n := firstName(t, resolveOne(t, "pkg:composer/guzzlehttp/guzzle"))
if n.Language != "php" || !n.CaseInsensitive {
t.Errorf("composer should be case-insensitive: %+v", n)
}
if !n.Matches(`GuzzleHttp\Client`) {
t.Errorf("case-folded PSR-4 root: %+v", n)
}
if n.Matches(`App\Models\User`) {
t.Error("unrelated namespace matched")
}
}

func TestHex(t *testing.T) {
n := firstName(t, resolveOne(t, "pkg:hex/phoenix_html"))
if n.Name != "PhoenixHtml" || !n.Matches("PhoenixHtml.Safe") {
t.Errorf("hex: %+v", n)
}
}

func TestMaven(t *testing.T) {
n := firstName(t, resolveOne(t, "pkg:maven/com.google.guava/guava"))
if n.Name != "com.google.guava" || !n.Matches("com.google.guava.collect") {
t.Errorf("maven group: %+v", n)
}
}

func TestUnknownEcosystem(t *testing.T) {
s := resolveOne(t, "pkg:conan/zlib@1.3.1")
if len(s.Provides) != 0 {
t.Errorf("unknown ecosystem should be empty: %+v", s)
}
}

func TestInvalidPURL(t *testing.T) {
res, err := Resolver().ResolveSurface(context.Background(), provides.Package{PURL: "not a purl"}, provides.SurfaceOptions{})
if err != nil {
t.Fatalf("invalid purl should be a diagnostic, not an error: %v", err)
}
if len(res.Diagnostics) == 0 {
t.Error("expected diagnostic for invalid purl")
}
}

func TestEvidence(t *testing.T) {
n := firstName(t, resolveOne(t, "pkg:npm/ws"))
if len(n.Evidence) == 0 || n.Evidence[0].Method != provides.EvidenceHeuristic {
t.Errorf("heuristic evidence: %+v", n.Evidence)
}
}
26 changes: 14 additions & 12 deletions import.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,13 @@ func MatchImport(language, name string, project ProjectSurfaceResult) ImportResu
provided.Separator = ""
}
key := importMatchKey{
purl: surface.PURL,
language: provided.Language,
name: provided.Name,
kind: provided.Kind,
match: provided.Match,
separator: provided.Separator,
purl: surface.PURL,
language: provided.Language,
name: provided.Name,
kind: provided.Kind,
match: provided.Match,
separator: provided.Separator,
caseInsensitive: provided.CaseInsensitive,
}
if existing, ok := matches[key]; ok {
provided.Evidence = mergeEvidence(existing.Provided.Evidence, provided.Evidence)
Expand Down Expand Up @@ -112,10 +113,11 @@ func MatchImport(language, name string, project ProjectSurfaceResult) ImportResu
}

type importMatchKey struct {
purl string
language string
name string
kind string
match MatchMode
separator string
purl string
language string
name string
kind string
match MatchMode
separator string
caseInsensitive bool
}
12 changes: 9 additions & 3 deletions match.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@ package provides
import "strings"

// Matches reports whether imported is covered by the provided name. Matching
// is case-sensitive because Name retains its exact source-visible spelling.
// is case-sensitive by default because Name retains its exact source-visible
// spelling; CaseInsensitive folds ASCII case for languages whose lookup does.
func (name ProvidedName) Matches(imported string) bool {
if imported == name.Name {
target, candidate := name.Name, imported
if name.CaseInsensitive {
target = strings.ToLower(target)
candidate = strings.ToLower(candidate)
}
if candidate == target {
return true
}
return normalizedMatchMode(name.Match) == MatchPrefix &&
name.Separator != "" &&
strings.HasPrefix(imported, name.Name+name.Separator)
strings.HasPrefix(candidate, target+name.Separator)
}

// Matches reports whether imported is covered by the project binding. Prefix
Expand Down
Loading