Skip to content
Open
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
270 changes: 258 additions & 12 deletions detect/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -672,18 +672,7 @@ func (e *Engine) loadDeps() {
e.devDeps = make(map[string]bool)
e.allDeps = make(map[string]bool)

// Collect all manifest file paths, including workflow files
var manifestPaths []string
manifestPaths = append(manifestPaths, e.KB.ManifestFiles...)
// GitHub Actions workflow files
wfMatches, _ := filepath.Glob(filepath.Join(e.Root, ".github/workflows/*.yml"))
wfMatchesYAML, _ := filepath.Glob(filepath.Join(e.Root, ".github/workflows/*.yaml"))
for _, m := range append(wfMatches, wfMatchesYAML...) {
rel, err := filepath.Rel(e.Root, m)
if err == nil {
manifestPaths = append(manifestPaths, rel)
}
}
manifestPaths := e.manifestPaths()

for _, mf := range manifestPaths {
data, err := e.safeReadFile(mf)
Expand Down Expand Up @@ -734,6 +723,263 @@ func (e *Engine) loadDeps() {
}
}

// manifestPaths returns root manifest files plus workspace member manifests.
func (e *Engine) manifestPaths() []string {
var paths []string
seen := make(map[string]bool)
add := func(p string) {
p = filepath.ToSlash(filepath.Clean(p))
if p == "." || strings.HasPrefix(p, "../") || filepath.IsAbs(p) {
return
}
if seen[p] {
return
}
seen[p] = true
paths = append(paths, p)
}

for _, mf := range e.KB.ManifestFiles {
add(mf)
}

// GitHub Actions workflow files
wfMatches, _ := filepath.Glob(filepath.Join(e.Root, ".github/workflows/*.yml"))
wfMatchesYAML, _ := filepath.Glob(filepath.Join(e.Root, ".github/workflows/*.yaml"))
for _, m := range append(wfMatches, wfMatchesYAML...) {
rel, err := filepath.Rel(e.Root, m)
if err == nil {
add(rel)
}
}

e.addCargoWorkspaceManifests(add)
e.addGoWorkspaceManifests(add)
e.addPackageWorkspaceManifests(add)
e.addPnpmWorkspaceManifests(add)

return paths
}

func (e *Engine) addCargoWorkspaceManifests(add func(string)) {
data, err := e.safeReadFile("Cargo.toml")
if err != nil {
return
}

var root struct {
Workspace struct {
Members []string `toml:"members"`
Exclude []string `toml:"exclude"`
} `toml:"workspace"`
}
if err := toml.Unmarshal(data, &root); err != nil {
return
}

excluded := e.workspacePatternSet(root.Workspace.Exclude)
for _, member := range root.Workspace.Members {
for _, dir := range e.expandWorkspacePattern(member) {
if excluded[dir] {
continue
}
add(path.Join(dir, "Cargo.toml"))
}
}
}

func (e *Engine) addGoWorkspaceManifests(add func(string)) {
data, err := e.safeReadFile("go.work")
if err != nil {
return
}
for _, member := range parseGoWorkUsePaths(string(data)) {
add(path.Join(member, "go.mod"))
}
}

func (e *Engine) addPackageWorkspaceManifests(add func(string)) {
data, err := e.safeReadFile("package.json")
if err != nil {
return
}

var root struct {
Workspaces any `json:"workspaces"`
}
if err := json.Unmarshal(data, &root); err != nil {
return
}
for _, pattern := range packageWorkspacePatterns(root.Workspaces) {
for _, dir := range e.expandWorkspacePattern(pattern) {
add(path.Join(dir, "package.json"))
}
}
}

func (e *Engine) addPnpmWorkspaceManifests(add func(string)) {
data, err := e.safeReadFile("pnpm-workspace.yaml")
if err != nil {
return
}

var root struct {
Packages []string `yaml:"packages"`
}
if err := yaml.Unmarshal(data, &root); err != nil {
return
}

var includes []string
var excludes []string
for _, pattern := range root.Packages {
if strings.HasPrefix(pattern, "!") {
excludes = append(excludes, strings.TrimPrefix(pattern, "!"))
continue
}
includes = append(includes, pattern)
}
excluded := e.workspacePatternSet(excludes)
for _, pattern := range includes {
for _, dir := range e.expandWorkspacePattern(pattern) {
if excluded[dir] {
continue
}
add(path.Join(dir, "package.json"))
}
}
}

func packageWorkspacePatterns(workspaces any) []string {
switch v := workspaces.(type) {
case []any:
var patterns []string
for _, item := range v {
if s, ok := item.(string); ok {
patterns = append(patterns, s)
}
}
return patterns
case map[string]any:
packages, ok := v["packages"].([]any)
if !ok {
return nil
}
var patterns []string
for _, item := range packages {
if s, ok := item.(string); ok {
patterns = append(patterns, s)
}
}
return patterns
default:
return nil
}
}

func parseGoWorkUsePaths(content string) []string {
var paths []string
inUseBlock := false
for _, line := range strings.Split(content, "\n") {
line = stripGoWorkComment(line)
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}

if inUseBlock {
if fields[0] == ")" {
inUseBlock = false
continue
}
for _, field := range fields {
if field == ")" {
inUseBlock = false
break
}
paths = append(paths, cleanWorkspaceMember(field))
}
continue
}

if fields[0] != "use" {
continue
}
if len(fields) >= 2 && fields[1] == "(" {
for _, field := range fields[2:] {
if field == ")" {
break
}
paths = append(paths, cleanWorkspaceMember(field))
}
inUseBlock = true
if fields[len(fields)-1] == ")" {
inUseBlock = false
}
continue
}
for _, field := range fields[1:] {
if field == "(" || field == ")" {
continue
}
paths = append(paths, cleanWorkspaceMember(field))
}
}
return paths
}

func stripGoWorkComment(line string) string {
if i := strings.Index(line, "//"); i >= 0 {
line = line[:i]
}
return strings.TrimSpace(line)
}

func cleanWorkspaceMember(member string) string {
member = strings.Trim(member, `"'`)
member = strings.TrimPrefix(member, "./")
return filepath.ToSlash(filepath.Clean(member))
}

func (e *Engine) workspacePatternSet(patterns []string) map[string]bool {
set := make(map[string]bool)
for _, pattern := range patterns {
for _, dir := range e.expandWorkspacePattern(pattern) {
set[dir] = true
}
}
return set
}

func (e *Engine) expandWorkspacePattern(pattern string) []string {
pattern = cleanWorkspaceMember(pattern)
if pattern == "." || pattern == "" || strings.HasPrefix(pattern, "../") || filepath.IsAbs(pattern) {
return nil
}
matches, err := filepath.Glob(filepath.Join(e.Root, filepath.FromSlash(pattern)))
if err != nil || len(matches) == 0 {
return []string{pattern}
}
var dirs []string
for _, match := range matches {
info, err := os.Stat(match)
if err != nil || !info.IsDir() {
continue
}
rel, err := filepath.Rel(e.Root, match)
if err != nil {
continue
}
rel = filepath.ToSlash(filepath.Clean(rel))
if rel == "." || strings.HasPrefix(rel, "../") {
continue
}
dirs = append(dirs, rel)
}
sort.Strings(dirs)
return dirs
}

// hasDependency checks if any of the tool's declared dependencies exist
// in the project's parsed manifests.
func (e *Engine) hasDependency(tool *kb.ToolDef) bool {
Expand Down
91 changes: 91 additions & 0 deletions detect/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,97 @@ func TestSQLiteDetection(t *testing.T) {
assertToolDetected(t, r, "database", "SQLite")
}

func TestCargoWorkspaceMemberDependencies(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "Cargo.toml", `[workspace]
members = ["crates/*"]
`)
writeFile(t, dir, "crates/app/Cargo.toml", `[package]
name = "app"
version = "0.1.0"
edition = "2021"

[dependencies]
reqwest = "0.12"
`)
writeFile(t, dir, "crates/app/src/lib.rs", "pub fn app() {}\n")

r, err := New(loadKB(t), dir).Run()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

assertToolDetected(t, r, "monorepo", "Cargo workspaces")
assertToolDetected(t, r, "library", "reqwest")
}

func TestGoWorkspaceMemberDependencies(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "go.work", `go 1.22

use (
./services/api
)
`)
writeFile(t, dir, "services/api/go.mod", `module example.com/api

go 1.22

require github.com/go-resty/resty/v2 v2.16.5
`)
writeFile(t, dir, "services/api/main.go", "package main\nfunc main() {}\n")

r, err := New(loadKB(t), dir).Run()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

assertToolDetected(t, r, "monorepo", "Go workspace")
assertToolDetected(t, r, "library", "Resty")
}

func TestPackageWorkspaceMemberDependencies(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "package.json", `{
"private": true,
"workspaces": ["packages/*"]
}`)
writeFile(t, dir, "packages/web/package.json", `{
"dependencies": {
"axios": "^1.7.0"
}
}`)
writeFile(t, dir, "packages/web/index.js", "import axios from 'axios'\n")

r, err := New(loadKB(t), dir).Run()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

assertToolDetected(t, r, "monorepo", "Yarn workspaces")
assertToolDetected(t, r, "library", "axios")
}

func TestPnpmWorkspaceMemberDependencies(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "package.json", `{"private": true}`)
writeFile(t, dir, "pnpm-workspace.yaml", "packages:\n - packages/*\n")
writeFile(t, dir, "packages/web/package.json", `{
"dependencies": {
"axios": "^1.7.0"
}
}`)
writeFile(t, dir, "packages/web/index.js", "import axios from 'axios'\n")

r, err := New(loadKB(t), dir).Run()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

assertToolDetected(t, r, "monorepo", "pnpm workspaces")
assertToolDetected(t, r, "library", "axios")
}

func TestNodeProject(t *testing.T) {
engine := New(loadKB(t), "../testdata/node-project")
r, err := engine.Run()
Expand Down