< Summary - Repometa Coverage

Line coverage
92%
Covered lines: 418
Uncovered lines: 36
Coverable lines: 454
Total lines: 1220
Line coverage: 92%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/repometa/repometa/detect_dotnet.go

#LineLine coverage
 1package repometa
 2
 3import (
 4  "path/filepath"
 5  "regexp"
 6  "strings"
 7)
 8
 9type dotnetDetector struct{}
 10
 11// slnProjectLineRE matches the Project(...) header lines that .sln files
 12// use to declare each member project. Format:
 13//
 14//  Project("{TYPE-GUID}") = "Name", "relative/path/to/Name.csproj", "{PROJECT-GUID}"
 15//
 16// Only the second field (the path) is captured; the type GUID (which
 17// distinguishes projects from solution folders) is inspected downstream
 18// by the extension of the path itself.
 19var slnProjectLineRE = regexp.MustCompile(`(?i)^Project\([^)]+\)\s*=\s*"[^"]*",\s*"([^"]+)"`)
 20
 21func (dotnetDetector) detect(dv dirVisit, cfg options) []finding {
 22  var out []finding
 23
 24  for _, f := range dv.files {
 25    if f.IsDir() {
 26      continue
 27    }
 28    name := f.Name()
 29    ext := strings.ToLower(filepath.Ext(name))
 30
 31    switch ext {
 32    case ".sln":
 33      // A .sln is the Visual Studio solution format used across
 34      // every MSBuild language (C#, F#, VB, and C++). We only
 35      // emit dotnet-solution when the .sln references at least
 36      // one .NET project file — a pure-C++ solution or one whose
 37      // projects are all solution folders is not a .NET workspace
 38      // and should not be labeled as such. The C++ projects it
 39      // does contain still surface as cpp-project components at
 40      // their own directories.
 41      members := parseSlnMembers(filepath.Join(dv.abs, name), cfg)
 42      if len(members) == 0 {
 43        continue
 44      }
 45      out = append(out, finding{
 46        Kind:       KindDotNetSolution,
 47        Confidence: 1.0,
 48        Evidence: []Evidence{{
 49          Path:   relJoin(dv.rel, name),
 50          Reason: ".sln at directory root",
 51        }},
 52        Workspaces: []Workspace{{
 53          Kind:    WorkspaceDotNetSolution,
 54          Members: expandMembers(dv.abs, members, dv.rel),
 55        }},
 56      })
 57
 58    case ".csproj", ".fsproj", ".vbproj":
 59      out = append(out, finding{
 60        Kind:       KindDotNetProject,
 61        Confidence: 1.0,
 62        Evidence: []Evidence{{
 63          Path:   relJoin(dv.rel, name),
 64          Reason: ext + " project file",
 65        }},
 66        Attributes: map[string]string{
 67          "dotnet.language": dotnetLanguageFor(ext),
 68        },
 69      })
 70
 71    case ".vcxproj":
 72      // Visual Studio C++ project. Shares the MSBuild machinery
 73      // with .csproj but targets native C/C++ code, so it maps to
 74      // LanguageC in the polyglot classifier and suppresses loose
 75      // C-source detection inside the same directory (see
 76      // isCBuildKind in scan.go).
 77      out = append(out, finding{
 78        Kind:       KindCppProject,
 79        Confidence: 1.0,
 80        Evidence: []Evidence{{
 81          Path:   relJoin(dv.rel, name),
 82          Reason: ".vcxproj project file",
 83        }},
 84      })
 85    }
 86  }
 87  return out
 88}
 89
 90// dotnetLanguageFor maps a project-file extension to the language label
 91// exposed on the dotnet.language attribute.
 592func dotnetLanguageFor(ext string) string {
 593  switch ext {
 294  case ".csproj":
 295    return "csharp"
 296  case ".fsproj":
 297    return "fsharp"
 198  case ".vbproj":
 199    return "vb"
 100  }
 0101  return ""
 102}
 103
 104// parseSlnMembers walks the Project(...) lines of a .sln file and returns
 105// the directory-relative paths of every C#/F#/VB project it references.
 106// Solution folders (identified by their type GUID) reuse the Project(...)
 107// prefix but point at a virtual name rather than a real project file —
 108// they are filtered out by extension so members line up with the
 109// dotnet-project components emitted by the walker.
 110//
 111// .sln paths are Windows-style with backslashes; they are normalized to
 112// forward slashes for cross-platform stability.
 4113func parseSlnMembers(path string, cfg options) []string {
 4114  data := readManifestOrNil(path, cfg)
 1115  if data == nil {
 1116    return nil
 1117  }
 3118  var members []string
 3119  seen := make(map[string]bool)
 3120  for line := range strings.SplitSeq(string(data), "\n") {
 21121    trim := strings.TrimSpace(line)
 17122    if !strings.HasPrefix(trim, "Project(") {
 17123      continue
 124    }
 4125    m := slnProjectLineRE.FindStringSubmatch(trim)
 0126    if len(m) != 2 {
 0127      continue
 128    }
 4129    p := strings.ReplaceAll(m[1], `\`, "/")
 4130    ext := strings.ToLower(filepath.Ext(p))
 2131    if ext != ".csproj" && ext != ".fsproj" && ext != ".vbproj" {
 2132      continue
 133    }
 2134    dir := filepath.ToSlash(filepath.Dir(p))
 0135    if dir == "" || dir == "." {
 0136      continue
 137    }
 2138    if !seen[dir] {
 2139      seen[dir] = true
 2140      members = append(members, dir)
 2141    }
 142  }
 3143  return members
 144}

/home/runner/work/repometa/repometa/detect_go.go

#LineLine coverage
 1package repometa
 2
 3import (
 4  "path/filepath"
 5  "strings"
 6)
 7
 8type goDetector struct{}
 9
 10func (goDetector) detect(dv dirVisit, cfg options) []finding {
 11  var out []finding
 12
 13  // go.work — the whole directory is a Go workspace. Members are the
 14  // module paths listed in the `use` directive.
 15  if hasFile(dv.files, "go.work") {
 16    members := parseGoWorkUse(filepath.Join(dv.abs, "go.work"), cfg)
 17    expanded := make([]string, 0, len(members))
 18    for _, m := range members {
 19      expanded = append(expanded, joinRel(dv.rel, m))
 20    }
 21    out = append(out, finding{
 22      Kind:       KindGoModule,
 23      Confidence: 1.0,
 24      Evidence: []Evidence{
 25        {Path: relJoin(dv.rel, "go.work"), Reason: "go.work at directory root"},
 26      },
 27      Workspaces: []Workspace{{Kind: WorkspaceGo, Members: expanded}},
 28    })
 29    return out
 30  }
 31
 32  if hasFile(dv.files, "go.mod") {
 33    out = append(out, finding{
 34      Kind:       KindGoModule,
 35      Confidence: 1.0,
 36      Evidence: []Evidence{
 37        {Path: relJoin(dv.rel, "go.mod"), Reason: "go.mod at directory root"},
 38      },
 39    })
 40  }
 41  return out
 42}
 43
 44// parseGoWorkUse returns the module directories listed in a go.work file.
 45// It handles both `use ./mod` and `use ( ... )` block forms. On any read
 46// error it returns nil.
 247func parseGoWorkUse(path string, cfg options) []string {
 248  data := readManifestOrNil(path, cfg)
 049  if data == nil {
 050    return nil
 051  }
 252  var members []string
 253  inBlock := false
 254  for line := range strings.SplitSeq(string(data), "\n") {
 1755    trim := strings.TrimSpace(line)
 656    if trim == "" || strings.HasPrefix(trim, "//") {
 657      continue
 58    }
 159    if idx := strings.Index(trim, "//"); idx >= 0 {
 160      trim = strings.TrimSpace(trim[:idx])
 161    }
 1162    switch {
 263    case strings.HasPrefix(trim, "use ("):
 264      inBlock = true
 265    case inBlock && trim == ")":
 266      inBlock = false
 467    case inBlock:
 468      m := strings.Trim(trim, "\"'")
 469      m = strings.TrimPrefix(m, "./")
 470      if m != "" {
 471        members = append(members, m)
 472      }
 173    case strings.HasPrefix(trim, "use "):
 174      m := strings.TrimSpace(strings.TrimPrefix(trim, "use "))
 175      m = strings.Trim(m, "\"'")
 176      m = strings.TrimPrefix(m, "./")
 177      if m != "" {
 178        members = append(members, m)
 179      }
 80    }
 81  }
 282  return members
 83}

/home/runner/work/repometa/repometa/detect_java.go

#LineLine coverage
 1package repometa
 2
 3import (
 4  "encoding/xml"
 5  "path/filepath"
 6  "regexp"
 7  "strings"
 8)
 9
 10type javaDetector struct{}
 11
 12// mavenPom captures the two shapes we care about from a pom.xml: the
 13// packaging discriminator (unused for now, kept to record its position)
 14// and the <modules> block that turns a POM into a multi-module root.
 15type mavenPom struct {
 16  XMLName xml.Name `xml:"project"`
 17  Modules struct {
 18    Module []string `xml:"module"`
 19  } `xml:"modules"`
 20}
 21
 22// gradleIncludeRE matches Gradle settings-file `include` statements in
 23// both Groovy and Kotlin DSLs — parentheses are optional in Groovy,
 24// mandatory in Kotlin. Only the first argument is captured; multi-arg
 25// `include('a', 'b')` calls are handled by matching each quoted literal
 26// independently via gradleQuotedLiteralRE.
 27var gradleIncludeRE = regexp.MustCompile(`(?m)^\s*include\b`)
 28
 29// gradleQuotedLiteralRE extracts every single- or double-quoted string
 30// from a Gradle settings-file line. Combined with gradleIncludeRE we
 31// capture every module argument regardless of arity.
 32var gradleQuotedLiteralRE = regexp.MustCompile(`['"]([^'"]+)['"]`)
 33
 34func (javaDetector) detect(dv dirVisit, cfg options) []finding {
 35  var out []finding
 36
 37  if hasFile(dv.files, "pom.xml") {
 38    out = append(out, detectMaven(dv, cfg))
 39  }
 40
 41  if hasGradleMarker(dv) {
 42    out = append(out, detectGradle(dv, cfg))
 43  }
 44
 45  // Ant. build.xml with a <project> root is the canonical Ant marker;
 46  // Ivy adjuncts (ivy.xml) count only as supporting evidence.
 47  if hasFile(dv.files, "build.xml") {
 48    if f := detectAnt(dv); f != nil {
 49      out = append(out, *f)
 50    }
 51  }
 52
 53  return out
 54}
 55
 56// detectMaven emits a java-project finding for a directory containing
 57// pom.xml. When the POM declares <modules>, a maven-multi-module
 58// workspace is attached with the listed members.
 559func detectMaven(dv dirVisit, cfg options) finding {
 560  attrs := map[string]string{"java.build": "maven"}
 561  evidence := []Evidence{{
 562    Path:   relJoin(dv.rel, "pom.xml"),
 563    Reason: "pom.xml at directory root",
 564  }}
 565  var workspaces []Workspace
 566  confidence := 1.0
 567
 568  data, err := readManifest(filepath.Join(dv.abs, "pom.xml"), cfg)
 569  switch {
 070  case err != nil:
 071    confidence = confidenceUnreadable
 072    evidence = append(evidence, evidenceUnreadable(dv.rel, "pom.xml", err))
 573  default:
 574    var pom mavenPom
 175    if uerr := xml.Unmarshal(data, &pom); uerr != nil {
 176      confidence = confidenceUnparsable
 177      evidence = append(evidence, evidenceUnparsable(dv.rel, "pom.xml", uerr))
 178    } else if len(pom.Modules.Module) > 0 {
 179      workspaces = append(workspaces, Workspace{
 180        Kind:    WorkspaceMavenMultiModule,
 181        Members: expandMembers(dv.abs, pom.Modules.Module, dv.rel),
 182      })
 183      evidence = append(evidence, Evidence{
 184        Path:   relJoin(dv.rel, "pom.xml"),
 185        Reason: "pom.xml declares <modules>",
 186      })
 187    }
 88  }
 589  return finding{
 590    Kind:       KindJavaProject,
 591    Confidence: confidence,
 592    Evidence:   evidence,
 593    Attributes: attrs,
 594    Workspaces: workspaces,
 595  }
 96}
 97
 98// hasGradleMarker reports whether the directory has any Gradle build or
 99// settings file — either DSL.
 84100func hasGradleMarker(dv dirVisit) bool {
 84101  return hasFile(dv.files, "build.gradle") ||
 84102    hasFile(dv.files, "build.gradle.kts") ||
 84103    hasFile(dv.files, "settings.gradle") ||
 84104    hasFile(dv.files, "settings.gradle.kts")
 84105}
 106
 107// detectGradle emits a java-project finding for a directory containing
 108// any Gradle marker. The Kotlin DSL takes precedence over Groovy when
 109// both are present in a directory (rare but legal during a migration).
 110// A settings file attaches a gradle-multi-project workspace with members
 111// parsed from `include` statements.
 9112func detectGradle(dv dirVisit, cfg options) finding {
 9113  attrs := map[string]string{"java.build": "gradle"}
 9114  var evidence []Evidence
 9115  var workspaces []Workspace
 9116
 9117  buildFile := ""
 9118  switch {
 1119  case hasFile(dv.files, "build.gradle.kts"):
 1120    buildFile = "build.gradle.kts"
 7121  case hasFile(dv.files, "build.gradle"):
 7122    buildFile = "build.gradle"
 123  }
 8124  if buildFile != "" {
 8125    evidence = append(evidence, Evidence{
 8126      Path:   relJoin(dv.rel, buildFile),
 8127      Reason: buildFile + " at directory root",
 8128    })
 1129    if strings.HasSuffix(buildFile, ".kts") {
 1130      attrs["java.gradle.dsl"] = "kotlin"
 1131    } else {
 7132      attrs["java.gradle.dsl"] = "groovy"
 7133    }
 134  }
 135
 9136  settingsFile := ""
 9137  switch {
 0138  case hasFile(dv.files, "settings.gradle.kts"):
 0139    settingsFile = "settings.gradle.kts"
 2140  case hasFile(dv.files, "settings.gradle"):
 2141    settingsFile = "settings.gradle"
 142  }
 2143  if settingsFile != "" {
 2144    evidence = append(evidence, Evidence{
 2145      Path:   relJoin(dv.rel, settingsFile),
 2146      Reason: settingsFile + " at directory root",
 2147    })
 2148    members := parseGradleIncludes(filepath.Join(dv.abs, settingsFile), cfg)
 1149    if len(members) > 0 {
 1150      workspaces = append(workspaces, Workspace{
 1151        Kind:    WorkspaceGradleMultiProject,
 1152        Members: expandMembers(dv.abs, members, dv.rel),
 1153      })
 1154    } else {
 1155      workspaces = append(workspaces, Workspace{Kind: WorkspaceGradleMultiProject})
 1156    }
 157  }
 158
 9159  return finding{
 9160    Kind:       KindJavaProject,
 9161    Confidence: 1.0,
 9162    Evidence:   evidence,
 9163    Attributes: attrs,
 9164    Workspaces: workspaces,
 9165  }
 166}
 167
 168// detectAnt emits a java-project finding for a directory whose build.xml
 169// looks like an Ant project (root element is <project>). Returns nil if
 170// the file is unreadable or doesn't have the Ant shape — build.xml is
 171// used by other tools too and a bare presence check would over-detect.
 2172func detectAnt(dv dirVisit) *finding {
 2173  attrs := map[string]string{"java.build": "ant"}
 2174  evidence := []Evidence{{
 2175    Path:   relJoin(dv.rel, "build.xml"),
 2176    Reason: "build.xml at directory root",
 2177  }}
 2178
 1179  if hasFile(dv.files, "ivy.xml") {
 1180    evidence = append(evidence, Evidence{
 1181      Path:   relJoin(dv.rel, "ivy.xml"),
 1182      Reason: "ivy.xml present (Ivy dependency descriptor)",
 1183    })
 1184  }
 185
 2186  return &finding{
 2187    Kind:       KindJavaProject,
 2188    Confidence: 1.0,
 2189    Evidence:   evidence,
 2190    Attributes: attrs,
 2191  }
 192}
 193
 194// parseGradleIncludes returns the module paths declared by `include`
 195// statements in a Gradle settings file. Gradle uses ":a:b" to denote
 196// nested paths in the project tree; these are converted to "a/b" so the
 197// result is directly usable as a filesystem-relative member path.
 3198func parseGradleIncludes(path string, cfg options) []string {
 3199  data := readManifestOrNil(path, cfg)
 1200  if data == nil {
 1201    return nil
 1202  }
 2203  var members []string
 2204  seen := make(map[string]bool)
 2205  for line := range strings.SplitSeq(string(data), "\n") {
 3206    if !gradleIncludeRE.MatchString(line) {
 3207      continue
 208    }
 3209    for _, m := range gradleQuotedLiteralRE.FindAllStringSubmatch(line, -1) {
 4210      p := strings.ReplaceAll(strings.TrimPrefix(m[1], ":"), ":", "/")
 0211      if p == "" || seen[p] {
 0212        continue
 213      }
 4214      seen[p] = true
 4215      members = append(members, p)
 216    }
 217  }
 2218  return members
 219}

/home/runner/work/repometa/repometa/detect_node.go

#LineLine coverage
 1package repometa
 2
 3import (
 4  "encoding/json"
 5  "path/filepath"
 6
 7  "gopkg.in/yaml.v3"
 8)
 9
 10type jsDetector struct{}
 11
 12type packageJSON struct {
 13  Name            string            `json:"name"`
 14  Workspaces      json.RawMessage   `json:"workspaces"`
 15  Dependencies    map[string]string `json:"dependencies"`
 16  DevDependencies map[string]string `json:"devDependencies"`
 17}
 18
 19type pnpmWorkspaceYAML struct {
 20  Packages []string `yaml:"packages"`
 21}
 22
 23func (jsDetector) detect(dv dirVisit, cfg options) []finding {
 24  if !hasFile(dv.files, "package.json") {
 25    return nil
 26  }
 27
 28  attrs := map[string]string{}
 29  evidence := []Evidence{{Path: relJoin(dv.rel, "package.json"), Reason: "package.json at directory root"}}
 30  var workspaces []Workspace
 31  confidence := 1.0
 32
 33  data, err := readManifest(filepath.Join(dv.abs, "package.json"), cfg)
 34  switch {
 35  case err != nil:
 36    confidence = confidenceUnreadable
 37    evidence = append(evidence, evidenceUnreadable(dv.rel, "package.json", err))
 38  default:
 39    var pkg packageJSON
 40    if uerr := json.Unmarshal(data, &pkg); uerr != nil {
 41      confidence = confidenceUnparsable
 42      evidence = append(evidence, evidenceUnparsable(dv.rel, "package.json", uerr))
 43      break
 44    }
 45    if fw := detectJSFramework(pkg); fw != "" {
 46      attrs["js.framework"] = fw
 47    }
 48    if members, ok := parseNpmYarnWorkspaces(pkg.Workspaces); ok {
 49      expanded := expandMembers(dv.abs, members, dv.rel)
 50      workspaces = append(workspaces, Workspace{Kind: WorkspaceNpmYarn, Members: expanded})
 51      evidence = append(evidence, Evidence{
 52        Path: relJoin(dv.rel, "package.json"), Reason: `package.json "workspaces" declared`,
 53      })
 54    }
 55  }
 56
 57  if hasFile(dv.files, "pnpm-workspace.yaml") {
 58    members := parsePnpmWorkspace(filepath.Join(dv.abs, "pnpm-workspace.yaml"), cfg)
 59    expanded := expandMembers(dv.abs, members, dv.rel)
 60    workspaces = append(workspaces, Workspace{Kind: WorkspacePnpm, Members: expanded})
 61    evidence = append(evidence, Evidence{Path: relJoin(dv.rel, "pnpm-workspace.yaml"), Reason: "pnpm-workspace.yaml pres
 62  }
 63  if hasFile(dv.files, "nx.json") {
 64    workspaces = append(workspaces, Workspace{Kind: WorkspaceNx})
 65    evidence = append(evidence, Evidence{Path: relJoin(dv.rel, "nx.json"), Reason: "nx.json present"})
 66  }
 67  if hasFile(dv.files, "turbo.json") {
 68    workspaces = append(workspaces, Workspace{Kind: WorkspaceTurborepo})
 69    evidence = append(evidence, Evidence{Path: relJoin(dv.rel, "turbo.json"), Reason: "turbo.json present"})
 70  }
 71
 72  // Angular's canonical marker is angular.json, which may exist even
 73  // without @angular/core being an obvious dependency line.
 74  if hasFile(dv.files, "angular.json") {
 75    attrs["js.framework"] = "angular"
 76    evidence = append(evidence, Evidence{Path: relJoin(dv.rel, "angular.json"), Reason: "angular.json present"})
 77  }
 78
 79  return []finding{{
 80    Kind:       KindNodePackage,
 81    Confidence: confidence,
 82    Evidence:   evidence,
 83    Workspaces: workspaces,
 84    Attributes: attrs,
 85  }}
 86}
 87
 1388func detectJSFramework(pkg packageJSON) string {
 389  if _, ok := pkg.Dependencies["next"]; ok {
 390    return "nextjs"
 391  }
 192  if _, ok := pkg.DevDependencies["next"]; ok {
 193    return "nextjs"
 194  }
 195  if _, ok := pkg.Dependencies["@angular/core"]; ok {
 196    return "angular"
 197  }
 198  if _, ok := pkg.DevDependencies["@angular/core"]; ok {
 199    return "angular"
 1100  }
 7101  return ""
 102}
 103
 104// parseNpmYarnWorkspaces handles both shapes:
 105//
 106//  "workspaces": ["packages/*"]
 107//  "workspaces": {"packages": ["packages/*"], "nohoist": [...]}
 11108func parseNpmYarnWorkspaces(raw json.RawMessage) ([]string, bool) {
 8109  if len(raw) == 0 {
 8110    return nil, false
 8111  }
 3112  var arr []string
 1113  if err := json.Unmarshal(raw, &arr); err == nil {
 1114    return arr, true
 1115  }
 2116  var obj struct {
 2117    Packages []string `json:"packages"`
 2118  }
 1119  if err := json.Unmarshal(raw, &obj); err == nil {
 1120    return obj.Packages, true
 1121  }
 1122  return nil, false
 123}
 124
 1125func parsePnpmWorkspace(path string, cfg options) []string {
 1126  data := readManifestOrNil(path, cfg)
 0127  if data == nil {
 0128    return nil
 0129  }
 1130  var ws pnpmWorkspaceYAML
 0131  if err := yaml.Unmarshal(data, &ws); err != nil {
 0132    return nil
 0133  }
 1134  return ws.Packages
 135}

/home/runner/work/repometa/repometa/detect_python.go

#LineLine coverage
 1package repometa
 2
 3import (
 4  "io/fs"
 5  "path/filepath"
 6
 7  "github.com/BurntSushi/toml"
 8)
 9
 10type pythonDetector struct{}
 11
 12type pyprojectTOML struct {
 13  Tool struct {
 14    Uv *struct {
 15      Workspace *struct {
 16        Members []string `toml:"members"`
 17      } `toml:"workspace"`
 18    } `toml:"uv"`
 19    Poetry *struct{} `toml:"poetry"`
 20  } `toml:"tool"`
 21  Project *struct {
 22    Name string `toml:"name"`
 23  } `toml:"project"`
 24}
 25
 26func (pythonDetector) detect(dv dirVisit, cfg options) []finding {
 27  // Any of these markers makes the directory a Python package for our
 28  // purposes. pyproject.toml wins the "primary" evidence slot when
 29  // present because it is the modern canonical form.
 30  markers := []string{"pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"}
 31  primary, ok := firstFile(dv.files, markers...)
 32  if !ok {
 33    return nil
 34  }
 35
 36  attrs := detectPythonPM(dv.files)
 37  if attrs == nil {
 38    attrs = map[string]string{}
 39  }
 40  evidence := []Evidence{{Path: relJoin(dv.rel, primary), Reason: "Python packaging marker"}}
 41  var workspaces []Workspace
 42
 43  confidence := 1.0
 44  if primary == "pyproject.toml" {
 45    data, err := readManifest(filepath.Join(dv.abs, "pyproject.toml"), cfg)
 46    switch {
 47    case err != nil:
 48      confidence = confidenceUnreadable
 49      evidence = append(evidence, evidenceUnreadable(dv.rel, "pyproject.toml", err))
 50    default:
 51      var pp pyprojectTOML
 52      if uerr := toml.Unmarshal(data, &pp); uerr != nil {
 53        confidence = confidenceUnparsable
 54        evidence = append(evidence, evidenceUnparsable(dv.rel, "pyproject.toml", uerr))
 55        break
 56      }
 57      if pp.Tool.Uv != nil && pp.Tool.Uv.Workspace != nil {
 58        members := expandMembers(dv.abs, pp.Tool.Uv.Workspace.Members, dv.rel)
 59        workspaces = append(workspaces, Workspace{Kind: WorkspaceUv, Members: members})
 60        evidence = append(evidence, Evidence{
 61          Path:   relJoin(dv.rel, "pyproject.toml"),
 62          Reason: "pyproject.toml declares [tool.uv.workspace]",
 63        })
 64      }
 65      if pp.Tool.Poetry != nil {
 66        // Poetry is not a workspace tool in v0 — recorded as
 67        // package-manager attribute only.
 68        attrs["python.pm"] = "poetry"
 69      }
 70    }
 71  }
 72
 73  return []finding{{
 74    Kind:       KindPythonPackage,
 75    Confidence: confidence,
 76    Evidence:   evidence,
 77    Workspaces: workspaces,
 78    Attributes: attrs,
 79  }}
 80}
 81
 82// detectPythonPM returns the most specific package-manager attribution
 83// available from lockfiles / marker files in the directory. Order reflects
 84// modern preference: uv > poetry > pipenv > pip. The result may be empty
 85// when no lockfile or requirements.txt is present.
 1086func detectPythonPM(files []fs.DirEntry) map[string]string {
 1087  attrs := map[string]string{}
 1088  switch {
 289  case hasFile(files, "uv.lock"):
 290    attrs["python.pm"] = "uv"
 191  case hasFile(files, "poetry.lock"):
 192    attrs["python.pm"] = "poetry"
 193  case hasFile(files, "Pipfile.lock") || hasFile(files, "Pipfile"):
 194    attrs["python.pm"] = "pipenv"
 195  case hasFile(files, "requirements.txt"):
 196    attrs["python.pm"] = "pip"
 97  }
 1098  return attrs
 99}

/home/runner/work/repometa/repometa/detectors.go

#LineLine coverage
 1package repometa
 2
 3import (
 4  "fmt"
 5  "io/fs"
 6  "os"
 7  "path/filepath"
 8  "strings"
 9)
 10
 11// finding is the per-directory result a detector produces. Scan attaches
 12// dv.rel as the Component's Root — detectors do not need to fill it in.
 13type finding struct {
 14  Kind       Kind
 15  Evidence   []Evidence
 16  Confidence float64
 17  Workspaces []Workspace
 18  Attributes map[string]string
 19}
 20
 21// detector inspects a single directory. Detectors do NOT recurse; the
 22// walker handles traversal so bounds and skip lists are respected in one
 23// place.
 24type detector interface {
 25  detect(dv dirVisit, cfg options) []finding
 26}
 27
 3428func allDetectors() []detector {
 3429  return []detector{
 3430    goDetector{},
 3431    rustDetector{},
 3432    pythonDetector{},
 3433    jsDetector{},
 3434    dotnetDetector{},
 3435    javaDetector{},
 3436    cmakeDetector{},
 3437    makeDetector{},
 3438    cDetector{},
 3439    asmDetector{},
 3440  }
 3441}
 42
 43// ---- shared helpers ----
 44
 45// hasFile reports whether the directory contains a regular file with
 46// exactly the given name (case-sensitive).
 101347func hasFile(files []fs.DirEntry, name string) bool {
 101348  for _, f := range files {
 6249    if !f.IsDir() && f.Name() == name {
 6250      return true
 6251    }
 52  }
 95153  return false
 54}
 55
 56// firstFile returns the first name from names present in files, and true.
 57// If none are present it returns "", false.
 16858func firstFile(files []fs.DirEntry, names ...string) (string, bool) {
 16859  present := make(map[string]bool, len(files))
 16860  for _, f := range files {
 17061    if !f.IsDir() {
 17062      present[f.Name()] = true
 17063    }
 64  }
 16865  for _, n := range names {
 1266    if present[n] {
 1267      return n, true
 1268    }
 69  }
 15670  return "", false
 71}
 72
 73// countByExt counts files ending in any of the given extensions.
 25274func countByExt(files []fs.DirEntry, exts ...string) int {
 25275  n := 0
 25276  for _, f := range files {
 077    if f.IsDir() {
 078      continue
 79    }
 25580    name := f.Name()
 25581    for _, ext := range exts {
 882      if strings.HasSuffix(name, ext) {
 883        n++
 884        break
 85      }
 86    }
 87  }
 25288  return n
 89}
 90
 91// relJoin joins a directory's relative path with a filename, using "/"
 92// for evidence paths so they are stable across platforms.
 7593func relJoin(dir, name string) string {
 4194  if dir == "." || dir == "" {
 4195    return name
 4196  }
 3497  return dir + "/" + name
 98}
 99
 100// Confidence values used when a manifest file is present but its
 101// contents could not be fully interpreted. The gradation reflects how
 102// much the detector still knows: an unreadable file (stat succeeded but
 103// open/read failed, or the size cap fired) means we saw the file existed
 104// but couldn't inspect its contents at all; an unparsable file means we
 105// did read the bytes but the unmarshaler rejected them. The component
 106// is still reported in both cases — its presence is a real signal — but
 107// downstream consumers get a hint that the shape data is missing.
 108const (
 109  confidenceUnreadable = 0.8
 110  confidenceUnparsable = 0.7
 111)
 112
 113// evidenceUnreadable formats the Evidence entry used when readManifest
 114// returns an error. The filename is repeated in Path and Reason so a
 115// consumer scanning Evidence text for a specific manifest can match on
 116// either field.
 1117func evidenceUnreadable(rel, filename string, err error) Evidence {
 1118  return Evidence{
 1119    Path:   relJoin(rel, filename),
 1120    Reason: filename + " unreadable: " + err.Error(),
 1121  }
 1122}
 123
 124// evidenceUnparsable formats the Evidence entry used when a detector's
 125// unmarshaler rejects the read bytes. Mirrors [evidenceUnreadable]'s
 126// shape so the two cases are visually parallel in Evidence output.
 3127func evidenceUnparsable(rel, filename string, err error) Evidence {
 3128  return Evidence{
 3129    Path:   relJoin(rel, filename),
 3130    Reason: filename + " parse error: " + err.Error(),
 3131  }
 3132}
 133
 134// readManifestOrNil returns the manifest bytes at path, or nil if
 135// readManifest fails for any reason (stat failure, size cap exceeded,
 136// read failure). Use it in the workspace-member parsers whose contract
 137// is "best-effort: on any read failure I return an empty member list,
 138// no error." Callers that need to distinguish read failures from empty
 139// results should use [readManifest] directly.
 10140func readManifestOrNil(path string, cfg options) []byte {
 10141  data, err := readManifest(path, cfg)
 2142  if err != nil {
 2143    return nil
 2144  }
 8145  return data
 146}
 147
 148// readManifest reads a file at path, capped at cfg.maxFileSize. On
 149// failure it returns nil and an error explaining the reason (stat
 150// failure, size cap exceeded, or read failure) so callers can attribute
 151// the specific cause in Evidence rather than reporting a generic
 152// "unparsed" state.
 38153func readManifest(path string, cfg options) ([]byte, error) {
 38154  info, err := os.Stat(path)
 3155  if err != nil {
 3156    return nil, fmt.Errorf("stat: %w", err)
 3157  }
 2158  if info.Size() > cfg.maxFileSize {
 2159    return nil, fmt.Errorf("size %d bytes exceeds cap of %d", info.Size(), cfg.maxFileSize)
 2160  }
 33161  data, err := os.ReadFile(path)
 0162  if err != nil {
 0163    return nil, fmt.Errorf("read: %w", err)
 0164  }
 33165  return data, nil
 166}
 167
 168// expandMembers takes a base directory (absolute), a list of glob patterns
 169// (as they appear in a workspace manifest), and the Manifest-relative root
 170// of the workspace. It returns the resolved member paths as Manifest-
 171// relative paths, sorted and de-duplicated.
 172//
 173// Only single-star globs are supported. Patterns containing "**" are
 174// dropped rather than returned as literal paths — a broken entry
 175// (a directory a consumer would try to stat but that does not exist)
 176// is worse than a missing entry. Support for "**" is tracked in TODO.md.
 8177func expandMembers(baseAbs string, patterns []string, wsRel string) []string {
 8178  seen := make(map[string]struct{})
 8179  var out []string
 8180  for _, p := range patterns {
 16181    p = strings.TrimSpace(p)
 1182    if p == "" {
 1183      continue
 184    }
 1185    if strings.Contains(p, "**") {
 1186      continue
 187    }
 14188    matches, err := filepath.Glob(filepath.Join(baseAbs, p))
 1189    if err != nil || len(matches) == 0 {
 1190      rel := joinRel(wsRel, p)
 1191      if _, ok := seen[rel]; !ok {
 1192        seen[rel] = struct{}{}
 1193        out = append(out, rel)
 1194      }
 1195      continue
 196    }
 13197    for _, m := range matches {
 16198      info, err := os.Stat(m)
 0199      if err != nil || !info.IsDir() {
 0200        continue
 201      }
 16202      rel, err := filepath.Rel(baseAbs, m)
 0203      if err != nil {
 0204        continue
 205      }
 16206      joined := joinRel(wsRel, filepath.ToSlash(rel))
 16207      if _, ok := seen[joined]; !ok {
 16208        seen[joined] = struct{}{}
 16209        out = append(out, joined)
 16210      }
 211    }
 212  }
 8213  return out
 214}
 215
 216// joinRel joins two Manifest-relative paths using "/" separators.
 22217func joinRel(base, sub string) string {
 22218  sub = strings.TrimPrefix(sub, "./")
 15219  if base == "." || base == "" {
 15220    return sub
 15221  }
 7222  return base + "/" + sub
 223}

/home/runner/work/repometa/repometa/options.go

#LineLine coverage
 1package repometa
 2
 3// Option configures a [Scan] call. Options are applied in order; when
 4// two options set the same field, the later one wins.
 5type Option func(*options)
 6
 7type options struct {
 8  maxDepth    int
 9  maxDirs     int
 10  maxFileSize int64
 11}
 12
 13// Bounded-traversal caps — every recursive walk on user-supplied input
 14// must reference a named constant so termination is provable and the
 15// bound is grep-discoverable.
 16const (
 17  defaultMaxDepth    = 20
 18  defaultMaxDirs     = 50_000
 19  defaultMaxFileSize = 4 << 20 // 4 MiB per manifest file parsed
 20)
 21
 3822func defaultOptions() options {
 3823  return options{
 3824    maxDepth:    defaultMaxDepth,
 3825    maxDirs:     defaultMaxDirs,
 3826    maxFileSize: defaultMaxFileSize,
 3827  }
 3828}
 29
 30// WithMaxDepth caps directory recursion depth. The scan root is depth 0.
 31// A value of 0 or less is treated as "no descent below root".
 132func WithMaxDepth(n int) Option { return func(o *options) { o.maxDepth = n } }
 33
 34// WithMaxDirs caps the total number of directories visited during the
 35// scan. The walk aborts silently when this cap is hit;
 36// [ScanStats.DirCapHits] on the returned Manifest reports how many times
 37// the cap fired so callers can decide whether to widen the scan.
 138func WithMaxDirs(n int) Option { return func(o *options) { o.maxDirs = n } }
 39
 40// WithMaxFileSize caps how many bytes any single manifest file may be
 41// read into memory. Files above this cap are skipped for content parsing
 42// but their presence is still recorded as [Evidence]. The cap protects
 43// against pathologically large manifest files (generated lockfiles,
 44// vendored artifacts) exhausting memory during a scan.
 145func WithMaxFileSize(n int64) Option { return func(o *options) { o.maxFileSize = n } }

/home/runner/work/repometa/repometa/scan.go

#LineLine coverage
 1// Package repometa scans a source repository and reports the components
 2// (buildable units) it contains, plus any monorepo workspace layouts it
 3// recognizes. It is intended to be imported by downstream tools that
 4// need to reason about arbitrary repositories without re-implementing
 5// discovery logic.
 6//
 7// The API is unstable. See the repo README for scope and non-goals.
 8package repometa
 9
 10import (
 11  "errors"
 12  "os"
 13  "path/filepath"
 14  "sort"
 15  "strings"
 16)
 17
 18// Scan walks root and returns a [Manifest] describing every detected
 19// component. The returned Manifest is non-nil on success.
 20//
 21// The walk is bounded by the caps documented on [WithMaxDepth],
 22// [WithMaxDirs], and [WithMaxFileSize]; a caller who overrides none of
 23// them accepts the package defaults. Symlinks and a hardcoded skip list
 24// (.git, node_modules, vendor, target, dist, build, .next, .angular,
 25// .venv) are never traversed.
 26//
 27// Scan returns an error if root is empty, does not exist, or is not a
 28// directory. Errors surfaced by the underlying filesystem walk are
 29// wrapped and returned as-is; there are no exported sentinel errors.
 30//
 31// Scan is safe for concurrent use: it holds no package-level state and
 32// mutates only the Manifest it returns.
 3733func Scan(root string, opts ...Option) (*Manifest, error) {
 134  if root == "" {
 135    return nil, errors.New("repometa: root path is empty")
 136  }
 3637  abs, err := filepath.Abs(root)
 038  if err != nil {
 039    return nil, err
 040  }
 3641  info, err := os.Stat(abs)
 142  if err != nil {
 143    return nil, err
 144  }
 145  if !info.IsDir() {
 146    return nil, errors.New("repometa: root is not a directory")
 147  }
 48
 3449  cfg := defaultOptions()
 350  for _, opt := range opts {
 351    opt(&cfg)
 352  }
 53
 3454  w := newWalker(abs, cfg)
 3455  detectors := allDetectors()
 3456
 3457  var components []Component
 3458  if err := w.walk(func(dv dirVisit) {
 8459    for _, d := range detectors {
 6760      for _, f := range d.detect(dv, cfg) {
 6761        components = append(components, Component{
 6762          Kind:       f.Kind,
 6763          Root:       dv.rel,
 6764          Evidence:   f.Evidence,
 6765          Confidence: f.Confidence,
 6766          Workspaces: f.Workspaces,
 6767          Attributes: f.Attributes,
 6768        })
 6769      }
 70    }
 071  }); err != nil {
 072    return nil, err
 073  }
 74
 3475  components = suppressLooseSourceInsideStructured(components)
 3476  sortComponents(components)
 3477
 3478  return &Manifest{
 3479    Root:       abs,
 3480    Components: components,
 3481    Stats:      w.stats,
 3482  }, nil
 83}
 84
 85// suppressLooseSourceInsideStructured drops KindCSource / KindAsmSource
 86// findings that live at, or below, a directory already reported as a
 87// Make or CMake project. Coverage from other ecosystems (Go, Rust, Node,
 88// Python) does not suppress loose C/asm — those languages don't own
 89// C/asm source files.
 3490func suppressLooseSourceInsideStructured(cs []Component) []Component {
 3491  var cBuildRoots []string
 3492  for _, c := range cs {
 693    if isCBuildKind(c.Kind) {
 694      cBuildRoots = append(cBuildRoots, c.Root)
 695    }
 96  }
 3497  kept := cs[:0]
 3498  for _, c := range cs {
 399    if isLooseSource(c.Kind) && coveredBy(c.Root, cBuildRoots) {
 3100      continue
 101    }
 64102    kept = append(kept, c)
 103  }
 34104  return kept
 105}
 106
 67107func isLooseSource(k Kind) bool {
 67108  return k == KindCSource || k == KindAsmSource
 67109}
 110
 67111func isCBuildKind(k Kind) bool {
 67112  return k == KindCMakeProject || k == KindMakeProject || k == KindCppProject
 67113}
 114
 115// coveredBy reports whether path equals, or is a proper descendant of,
 116// any of roots. Uses forward-slash separator because Component.Root uses
 117// slash-separated paths regardless of platform. A root of "." matches
 118// every path because Component.Root is canonicalized to strip any
 119// leading "./" — the HasPrefix check would otherwise miss all children.
 5120func coveredBy(path string, roots []string) bool {
 5121  for _, r := range roots {
 1122    if r == "." {
 1123      return true
 1124    }
 1125    if r == path {
 1126      return true
 1127    }
 1128    if strings.HasPrefix(path, r+"/") {
 1129      return true
 1130    }
 131  }
 2132  return false
 133}
 134
 135// sortComponents orders components deterministically: by root path, then
 136// by kind. This makes serialized output diff-friendly.
 35137func sortComponents(cs []Component) {
 35138  sort.Slice(cs, func(i, j int) bool {
 36139    if cs[i].Root != cs[j].Root {
 36140      return cs[i].Root < cs[j].Root
 36141    }
 1142    return string(cs[i].Kind) < string(cs[j].Kind)
 143  })
 144}

/home/runner/work/repometa/repometa/walker.go

#LineLine coverage
 1package repometa
 2
 3import (
 4  "io/fs"
 5  "os"
 6  "path/filepath"
 7  "sort"
 8)
 9
 10// dirVisit is the payload the walker hands each detector for a single
 11// directory. Files contains only non-directory entries (regular files
 12// and other special entries), sorted lexicographically by name.
 13type dirVisit struct {
 14  abs   string
 15  rel   string
 16  files []fs.DirEntry
 17}
 18
 19type walker struct {
 20  root  string
 21  cfg   options
 22  stats ScanStats
 23}
 24
 3425func newWalker(root string, cfg options) *walker {
 3426  return &walker{root: root, cfg: cfg}
 3427}
 28
 29type visitFunc func(dirVisit)
 30
 31func (w *walker) walk(visit visitFunc) error {
 32  return w.walkDir(w.root, ".", 0, visit)
 33}
 34
 35func (w *walker) walkDir(abs, rel string, depth int, visit visitFunc) error {
 36  if depth > w.cfg.maxDepth {
 37    w.stats.DepthCapHits++
 38    return nil
 39  }
 40  if w.stats.DirsVisited >= w.cfg.maxDirs {
 41    w.stats.DirCapHits++
 42    return nil
 43  }
 44
 45  entries, err := os.ReadDir(abs)
 46  if err != nil {
 47    // A permission error on a subdirectory should not abort the
 48    // whole scan; skip it and continue. Any other error at the root
 49    // bubbles up because Scan already stat'd the root.
 50    if rel == "." {
 51      return err
 52    }
 53    return nil
 54  }
 55
 56  files := make([]fs.DirEntry, 0, len(entries))
 57  subdirs := make([]fs.DirEntry, 0)
 58  for _, e := range entries {
 59    name := e.Name()
 60    if skipDirs[name] {
 61      continue
 62    }
 63    if e.IsDir() {
 64      subdirs = append(subdirs, e)
 65      continue
 66    }
 67    if e.Type()&fs.ModeSymlink != 0 {
 68      w.stats.SymlinksSkipped++
 69      continue
 70    }
 71    files = append(files, e)
 72  }
 73  sort.Slice(files, func(i, j int) bool { return files[i].Name() < files[j].Name() })
 74  sort.Slice(subdirs, func(i, j int) bool { return subdirs[i].Name() < subdirs[j].Name() })
 75
 76  w.stats.DirsVisited++
 77  w.stats.FilesSeen += len(files)
 78
 79  visit(dirVisit{abs: abs, rel: rel, files: files})
 80
 81  for _, sd := range subdirs {
 82    if sd.Type()&fs.ModeSymlink != 0 {
 83      w.stats.SymlinksSkipped++
 84      continue
 85    }
 86    childAbs := filepath.Join(abs, sd.Name())
 87    childRel := sd.Name()
 88    if rel != "." {
 89      childRel = filepath.Join(rel, sd.Name())
 90    }
 91    if err := w.walkDir(childAbs, childRel, depth+1, visit); err != nil {
 92      return err
 93    }
 94  }
 95  return nil
 96}
 97
 98// skipDirs is the hardcoded set of directory names never descended into.
 99// These are unambiguous ecosystem artifacts / caches / tool state that
 100// would explode the walk time without adding component information.
 101// Names that are commonly legitimate source directories in some
 102// codebases (bin, obj, out, env) are intentionally NOT listed here.
 103var skipDirs = map[string]bool{
 104  ".git":          true,
 105  ".hg":           true,
 106  ".svn":          true,
 107  ".idea":         true,
 108  ".vscode":       true,
 109  ".gradle":       true,
 110  ".mvn":          true,
 111  ".next":         true,
 112  ".nuxt":         true,
 113  ".angular":      true,
 114  ".pytest_cache": true,
 115  ".mypy_cache":   true,
 116  ".ruff_cache":   true,
 117  ".tox":          true,
 118  ".terraform":    true,
 119  ".direnv":       true,
 120  "__pycache__":   true,
 121  "node_modules":  true,
 122  ".venv":         true,
 123  "venv":          true,
 124  "vendor":        true,
 125  "target":        true,
 126  "dist":          true,
 127  "build":         true,
 128}