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
2 changes: 1 addition & 1 deletion sast-engine/cmd/ci.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ Examples:
})
logger.FinishProgress()
if len(codeGraph.Nodes) == 0 {
logger.Progress("No source files found in project")
reportEmptyProject(logger, codeGraph.ProjectStats)
} else {
logger.Statistic("Code graph built: %d nodes", len(codeGraph.Nodes))
}
Expand Down
56 changes: 56 additions & 0 deletions sast-engine/cmd/empty_project.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package cmd

import (
"fmt"
"strings"

"github.com/shivasurya/code-pathfinder/sast-engine/graph"
"github.com/shivasurya/code-pathfinder/sast-engine/output"
)

// emptyProjectTopLanguages caps the "Detected:" line so a polyglot repo with
// twenty extensions doesn't produce an unreadable scroll of language counts.
const emptyProjectTopLanguages = 5

// reportEmptyProject prints a user-facing explanation of why the scan ended
// up with zero analyzable files. Always visible (verbosity-independent) so a
// user pointed at the wrong directory or running pathfinder on a repo we
// don't support yet sees something concrete instead of a successful but
// silent run.
func reportEmptyProject(logger *output.Logger, stats graph.ProjectStats) {
logger.Info(emptyProjectMessage(stats))
}

// emptyProjectMessage formats the multi-line explanation. Split out from
// reportEmptyProject so unit tests can assert the exact output without
// piping a logger through a buffer.
func emptyProjectMessage(stats graph.ProjectStats) string {
var sb strings.Builder
unsupported := stats.UnsupportedFileCount()

switch {
case stats.TotalFiles == 0:
// Walk found nothing: empty directory, or everything was filtered
// out by skip-dirs (vendor/, node_modules/, ...) and --exclude.
sb.WriteString("No files to analyze. Project directory is empty (after applying skip and exclude rules).")

case unsupported > 0:
// Mixed or all-unsupported: this is the pathfinder-api case.
// "Scanned 0 of 47 files (47 unsupported)."
fmt.Fprintf(&sb, "Scanned 0 of %d files (%d unsupported).\n", stats.TotalFiles, unsupported)
if summary := stats.UnsupportedSummary(emptyProjectTopLanguages); summary != "" {
fmt.Fprintf(&sb, "Detected: %s\n", summary)
}
fmt.Fprintf(&sb, "Supported: %s\n", strings.Join(graph.SupportedLanguages(), ", "))
sb.WriteString("\nNo files to analyze. (Pathfinder doesn't analyze these languages yet.)")

default:
// Files existed and were all in supported languages, but the
// parsers still produced zero graph nodes (e.g. every file
// failed to parse, or the only files were tests excluded via
// --skip-tests). Surface the count so the user knows the walk
// did happen.
fmt.Fprintf(&sb, "Scanned %d file(s) but no analyzable content was produced.", stats.TotalFiles)
}
return sb.String()
}
103 changes: 103 additions & 0 deletions sast-engine/cmd/empty_project_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package cmd

import (
"bytes"
"strings"
"testing"

"github.com/stretchr/testify/assert"

"github.com/shivasurya/code-pathfinder/sast-engine/graph"
"github.com/shivasurya/code-pathfinder/sast-engine/output"
)

func TestEmptyProjectMessage_TotallyEmptyDir(t *testing.T) {
msg := emptyProjectMessage(graph.ProjectStats{})
assert.Equal(t, "No files to analyze. Project directory is empty (after applying skip and exclude rules).", msg)
}

func TestEmptyProjectMessage_OnlySupportedFilesButZeroNodes(t *testing.T) {
// Files existed, all in supported languages, but graph ended empty
// (e.g. every file failed to parse). Headline should reflect that
// the walk did happen, distinct from the "empty directory" case.
stats := graph.ProjectStats{
TotalFiles: 3,
ScannedFiles: 3,
ByLanguage: map[string]int{"Java": 3},
}
msg := emptyProjectMessage(stats)
assert.Equal(t, "Scanned 3 file(s) but no analyzable content was produced.", msg)
}

func TestEmptyProjectMessage_OnlyUnsupportedFiles_MatchesDesignSpec(t *testing.T) {
// Matches the example from the brainstorm:
// Scanned 0 of 47 files (47 unsupported).
// Detected: TypeScript (32), JavaScript (8), JSON (5), Markdown (2)
// Supported: Java, Python, Go, C/C++, Dockerfile, docker-compose
//
// No files to analyze. (Pathfinder doesn't analyze these languages yet.)
stats := graph.ProjectStats{
TotalFiles: 47,
ScannedFiles: 0,
ByLanguage: map[string]int{
"TypeScript": 32,
"JavaScript": 8,
"JSON": 5,
"Markdown": 2,
},
}
msg := emptyProjectMessage(stats)
want := "Scanned 0 of 47 files (47 unsupported).\n" +
"Detected: TypeScript (32), JavaScript (8), JSON (5), Markdown (2)\n" +
"Supported: Java, Python, Go, C/C++, Dockerfile, docker-compose\n" +
"\nNo files to analyze. (Pathfinder doesn't analyze these languages yet.)"
assert.Equal(t, want, msg)
}

func TestEmptyProjectMessage_MixedButGraphEmpty(t *testing.T) {
// Walk found 50 files: 5 supported (parsed but produced 0 nodes) and 45
// unsupported. Treated as "files unsupported > 0" branch since the user
// still has a noisy unsupported population to know about.
stats := graph.ProjectStats{
TotalFiles: 50,
ScannedFiles: 5,
ByLanguage: map[string]int{
"Java": 5,
"TypeScript": 45,
},
}
msg := emptyProjectMessage(stats)
assert.Contains(t, msg, "Scanned 0 of 50 files (45 unsupported).")
assert.Contains(t, msg, "Detected: TypeScript (45)")
assert.Contains(t, msg, "Supported: Java, Python, Go, C/C++, Dockerfile, docker-compose")
}

func TestEmptyProjectMessage_FilesExistButNoneCategorised(t *testing.T) {
// Walk saw N files but every one was a binary blob or had an
// unrecognised extension (languageOf returns ""). ScannedFiles is 0
// and UnsupportedFileCount is also 0 because nothing got bucketed.
// Falls through to the default branch, which still surfaces that the
// walk happened.
stats := graph.ProjectStats{TotalFiles: 3}
msg := emptyProjectMessage(stats)
assert.Equal(t, "Scanned 3 file(s) but no analyzable content was produced.", msg)
}

func TestReportEmptyProject_AlwaysVisibleAtDefaultVerbosity(t *testing.T) {
// Logger.Info must print regardless of verbosity (unlike Progress
// which is gated to verbose+). Verify by piping a logger through a
// buffer at the lowest verbosity and checking the output is non-empty.
var buf bytes.Buffer
logger := output.NewLoggerWithWriter(output.VerbosityDefault, &buf)
stats := graph.ProjectStats{
TotalFiles: 1,
ScannedFiles: 0,
ByLanguage: map[string]int{"TypeScript": 1},
}
reportEmptyProject(logger, stats)
got := buf.String()
assert.True(t, strings.Contains(got, "Scanned 0 of 1 files"),
"reportEmptyProject must write at default verbosity, got: %q", got)
assert.True(t, strings.Contains(got, "Supported: Java"),
"reportEmptyProject must include Supported line, got: %q", got)
}
15 changes: 9 additions & 6 deletions sast-engine/cmd/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,13 +193,16 @@ Examples:
})
logger.FinishProgress()
if len(codeGraph.Nodes) == 0 {
analytics.ReportEventWithProperties(analytics.ScanFailed, map[string]any{
"error_type": "empty_project",
"phase": "graph_building",
})
return fmt.Errorf("no source files found in project")
// No supported source under projectPath. Fall through to the
// formatter step so a valid (empty) output document is still
// written: downstream consumers like cpf-executor read the
// JSON regardless of finding count, and treating this as a
// hard error misclassifies "repo we don't analyze yet" as a
// scanner failure.
reportEmptyProject(logger, codeGraph.ProjectStats)
} else {
logger.Statistic("Code graph built: %d nodes", len(codeGraph.Nodes))
}
logger.Statistic("Code graph built: %d nodes", len(codeGraph.Nodes))

// Step 1.5: Execute container rules if Docker/Compose files are present
var containerDetections []*dsl.EnrichedDetection
Expand Down
18 changes: 9 additions & 9 deletions sast-engine/graph/graph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ func TestGetFiles(t *testing.T) {
}

// Run getFiles
files, err := getFiles(tempDir, nil)
files, _, err := getFiles(tempDir, nil)
if err != nil {
t.Fatalf("getFiles returned an error: %v", err)
}
Expand Down Expand Up @@ -258,7 +258,7 @@ func TestGetFilesEmptyDirectory(t *testing.T) {
}
defer os.RemoveAll(tempDir)

files, err := getFiles(tempDir, nil)
files, _, err := getFiles(tempDir, nil)
if err != nil {
t.Fatalf("getFiles returned an error: %v", err)
}
Expand All @@ -270,7 +270,7 @@ func TestGetFilesEmptyDirectory(t *testing.T) {

func TestGetFilesNonExistentDirectory(t *testing.T) {
nonExistentDir := "/path/to/non/existent/directory"
_, err := getFiles(nonExistentDir, nil)
_, _, err := getFiles(nonExistentDir, nil)
if err == nil {
t.Error("Expected an error for non-existent directory, but got nil")
}
Expand All @@ -297,7 +297,7 @@ func TestGetFilesWithSymlinks(t *testing.T) {
t.Fatalf("Failed to create symlink: %v", err)
}

files, err := getFiles(tempDir, nil)
files, _, err := getFiles(tempDir, nil)
if err != nil {
t.Fatalf("getFiles returned an error: %v", err)
}
Expand Down Expand Up @@ -992,7 +992,7 @@ func TestGetFilesMixedLanguages(t *testing.T) {
}

// Run getFiles
files, err := getFiles(tempDir, nil)
files, _, err := getFiles(tempDir, nil)
if err != nil {
t.Fatalf("getFiles returned an error: %v", err)
}
Expand Down Expand Up @@ -1412,7 +1412,7 @@ func TestGetFilesIncludesGo(t *testing.T) {
}
}

files, err := getFiles(tempDir, nil)
files, _, err := getFiles(tempDir, nil)
if err != nil {
t.Fatalf("getFiles returned an error: %v", err)
}
Expand Down Expand Up @@ -1468,7 +1468,7 @@ func TestGetFilesSkipsVendor(t *testing.T) {
t.Fatalf("Failed to create vendor file: %v", err)
}

files, err := getFiles(tempDir, nil)
files, _, err := getFiles(tempDir, nil)
if err != nil {
t.Fatalf("getFiles returned an error: %v", err)
}
Expand Down Expand Up @@ -1499,7 +1499,7 @@ func TestGetFilesSkipsTestdata(t *testing.T) {
t.Fatalf("Failed to create testdata file: %v", err)
}

files, err := getFiles(tempDir, nil)
files, _, err := getFiles(tempDir, nil)
if err != nil {
t.Fatalf("getFiles returned an error: %v", err)
}
Expand Down Expand Up @@ -1530,7 +1530,7 @@ func TestGetFilesSkipsUnderscoreDirs(t *testing.T) {
t.Fatalf("Failed to create underscore dir file: %v", err)
}

files, err := getFiles(tempDir, nil)
files, _, err := getFiles(tempDir, nil)
if err != nil {
t.Fatalf("getFiles returned an error: %v", err)
}
Expand Down
3 changes: 2 additions & 1 deletion sast-engine/graph/initialize.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ func Initialize(directory string, callbacks *ProgressCallbacks) *CodeGraph {
if callbacks != nil {
excludePatterns = callbacks.ExcludePatterns
}
files, err := getFiles(directory, excludePatterns)
files, stats, err := getFiles(directory, excludePatterns)
codeGraph.ProjectStats = stats
if err != nil {
//nolint:all
Log("Directory not found:", err)
Expand Down
Loading
Loading