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
27 changes: 27 additions & 0 deletions lint/diff_filter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package lint

func normalizeChangedFilesSet(changedFiles []string) map[string]struct{} {
if changedFiles == nil {
return nil
}

set := make(map[string]struct{}, len(changedFiles))
for _, file := range changedFiles {
set[cleanPath(file)] = struct{}{}
}
return set
}

func filterInputFiles(inputFiles []string, changedFiles map[string]struct{}) []string {
if changedFiles == nil {
return inputFiles
}

filtered := make([]string, 0, len(inputFiles))
for _, inputFile := range inputFiles {
if _, ok := changedFiles[cleanPath(inputFile)]; ok {
filtered = append(filtered, inputFile)
}
}
return filtered
}
63 changes: 63 additions & 0 deletions lint/diff_filter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package lint

import (
"os"
"path/filepath"
"testing"
)

func TestFilterFilesUnderDirectory(t *testing.T) {
tempDir := t.TempDir()
modelDir := filepath.Join(tempDir, "modelsource")
nestedDir := filepath.Join(modelDir, "Module2")
if err := os.MkdirAll(nestedDir, 0755); err != nil {
t.Fatalf("failed to create directories: %v", err)
}

inside := filepath.Join(nestedDir, "DomainModels$DomainModel.yaml")
outside := filepath.Join(tempDir, "mxlint.yaml")
for _, path := range []string{inside, outside} {
if err := os.WriteFile(path, []byte("name: test\n"), 0644); err != nil {
t.Fatalf("failed to write file %q: %v", path, err)
}
}

filtered, err := FilterFilesUnderDirectory([]string{inside, outside}, modelDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(filtered) != 1 {
t.Fatalf("expected 1 file, got %d (%v)", len(filtered), filtered)
}
if filtered[0] != cleanPath(inside) {
t.Fatalf("expected %q, got %q", cleanPath(inside), filtered[0])
}
}

func TestFilterInputFiles(t *testing.T) {
tempDir := t.TempDir()
changed := filepath.Join(tempDir, "changed.yaml")
unchanged := filepath.Join(tempDir, "unchanged.yaml")
for _, path := range []string{changed, unchanged} {
if err := os.WriteFile(path, []byte("name: test\n"), 0644); err != nil {
t.Fatalf("failed to write file %q: %v", path, err)
}
}

changedSet := normalizeChangedFilesSet([]string{changed})
filtered := filterInputFiles([]string{changed, unchanged}, changedSet)
if len(filtered) != 1 {
t.Fatalf("expected 1 file, got %d (%v)", len(filtered), filtered)
}
if filtered[0] != changed {
t.Fatalf("expected %q, got %q", changed, filtered[0])
}
}

func TestFilterInputFilesNilSetReturnsAll(t *testing.T) {
inputFiles := []string{"a.yaml", "b.yaml"}
filtered := filterInputFiles(inputFiles, nil)
if len(filtered) != len(inputFiles) {
t.Fatalf("expected all files to be returned, got %v", filtered)
}
}
116 changes: 116 additions & 0 deletions lint/git.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package lint

import (
"errors"
"fmt"
"os/exec"
"path/filepath"
"strings"
)

var ErrNotGitRepository = errors.New("not a git repository")

// IsGitRepository reports whether dir is inside a git work tree.
func IsGitRepository(dir string) (bool, error) {
cmd := exec.Command("git", "rev-parse", "--is-inside-work-tree")
cmd.Dir = dir
output, err := cmd.Output()
if err != nil {
if isGitCommandNotFound(err) {
return false, fmt.Errorf("git is not installed or not available in PATH")
}
return false, nil
}
return strings.TrimSpace(string(output)) == "true", nil
}

// GitUnstagedChangedFiles returns absolute paths of files with unstaged changes.
func GitUnstagedChangedFiles(dir string) ([]string, error) {
isRepo, err := IsGitRepository(dir)
if err != nil {
return nil, err
}
if !isRepo {
return nil, ErrNotGitRepository
}

gitRoot, err := gitTopLevel(dir)
if err != nil {
return nil, err
}

cmd := exec.Command("git", "diff", "--name-only", "--diff-filter=ACMR")
cmd.Dir = dir
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to list unstaged changes: %w", err)
}

lines := strings.Split(strings.TrimSpace(string(output)), "\n")
changedFiles := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
changedFiles = append(changedFiles, cleanPath(filepath.Join(gitRoot, line)))
}
return changedFiles, nil
}

// FilterFilesUnderDirectory keeps only files located under directory.
func FilterFilesUnderDirectory(files []string, directory string) ([]string, error) {
absDir, err := filepath.Abs(directory)
if err != nil {
return nil, fmt.Errorf("failed to resolve directory %q: %w", directory, err)
}
absDir = cleanPath(absDir)

filtered := make([]string, 0, len(files))
for _, file := range files {
absFile, err := filepath.Abs(file)
if err != nil {
continue
}
absFile = cleanPath(absFile)
rel, err := filepath.Rel(absDir, absFile)
if err != nil || strings.HasPrefix(rel, "..") {
continue
}
filtered = append(filtered, absFile)
}
return filtered, nil
}

func gitTopLevel(dir string) (string, error) {
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
cmd.Dir = dir
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to resolve git root: %w", err)
}
return strings.TrimSpace(string(output)), nil
}

func isGitCommandNotFound(err error) bool {
if err == nil {
return false
}
if errors.Is(err, exec.ErrNotFound) {
return true
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode() == 127
}
return false
}

func cleanPath(path string) string {
cleaned := filepath.Clean(path)
resolved, err := filepath.EvalSymlinks(cleaned)
if err != nil {
return cleaned
}
return resolved
}
66 changes: 66 additions & 0 deletions lint/git_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package lint

import (
"os"
"os/exec"
"path/filepath"
"testing"
)

func TestGitUnstagedChangedFilesRequiresRepository(t *testing.T) {
tempDir := t.TempDir()
_, err := GitUnstagedChangedFiles(tempDir)
if err == nil {
t.Fatal("expected error for non-git directory")
}
if err != ErrNotGitRepository {
t.Fatalf("expected ErrNotGitRepository, got %v", err)
}
}

func TestGitUnstagedChangedFilesDetectsUnstagedModelDocument(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is not available")
}

tempDir := t.TempDir()
runGit(t, tempDir, "init")
runGit(t, tempDir, "config", "user.email", "mxlint@test.local")
runGit(t, tempDir, "config", "user.name", "mxlint test")

modelDir := filepath.Join(tempDir, "modelsource")
if err := os.MkdirAll(modelDir, 0755); err != nil {
t.Fatalf("failed to create model directory: %v", err)
}

docPath := filepath.Join(modelDir, "Security$ProjectSecurity.yaml")
if err := os.WriteFile(docPath, []byte("name: initial\n"), 0644); err != nil {
t.Fatalf("failed to write model document: %v", err)
}
runGit(t, tempDir, "add", "modelsource/Security$ProjectSecurity.yaml")
runGit(t, tempDir, "commit", "-m", "initial")

if err := os.WriteFile(docPath, []byte("name: changed\n"), 0644); err != nil {
t.Fatalf("failed to update model document: %v", err)
}

changedFiles, err := GitUnstagedChangedFiles(tempDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(changedFiles) != 1 {
t.Fatalf("expected 1 changed file, got %d (%v)", len(changedFiles), changedFiles)
}
if changedFiles[0] != cleanPath(docPath) {
t.Fatalf("expected %q, got %q", cleanPath(docPath), changedFiles[0])
}
}

func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v failed: %v (%s)", args, err, string(output))
}
}
11 changes: 6 additions & 5 deletions lint/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func printTestsuite(ts Testsuite) {

// EvalAllWithResults evaluates all rules and returns the results
// This is similar to EvalAll but returns the results instead of just printing them
func EvalAllWithResults(rulesPath string, modelSourcePath string, xunitReport string, jsonFile string, ignoreNoqa bool, useCache bool) (interface{}, error) {
func EvalAllWithResults(rulesPath string, modelSourcePath string, xunitReport string, jsonFile string, ignoreNoqa bool, useCache bool, changedFiles []string) (interface{}, error) {
rules, err := ReadRulesMetadata(rulesPath)
if err != nil {
return nil, err
Expand Down Expand Up @@ -59,7 +59,7 @@ func EvalAllWithResults(rulesPath string, modelSourcePath string, xunitReport st
defer wg.Done()
defer func() { <-sem }()

testsuite, err := evalTestsuite(r, modelSourcePath, ignoreNoqa, useCache)
testsuite, err := evalTestsuite(r, modelSourcePath, ignoreNoqa, useCache, changedFiles)
if err != nil {
errChan <- err
return
Expand Down Expand Up @@ -143,7 +143,7 @@ func EvalAllWithResults(rulesPath string, modelSourcePath string, xunitReport st
return testsuitesContainer, nil
}

func EvalAll(rulesPath string, modelSourcePath string, xunitReport string, jsonFile string, ignoreNoqa bool, useCache bool) error {
func EvalAll(rulesPath string, modelSourcePath string, xunitReport string, jsonFile string, ignoreNoqa bool, useCache bool, changedFiles []string) error {
rules, err := ReadRulesMetadata(rulesPath)
if err != nil {
return err
Expand Down Expand Up @@ -172,7 +172,7 @@ func EvalAll(rulesPath string, modelSourcePath string, xunitReport string, jsonF
defer wg.Done()
defer func() { <-sem }()

testsuite, err := evalTestsuite(r, modelSourcePath, ignoreNoqa, useCache)
testsuite, err := evalTestsuite(r, modelSourcePath, ignoreNoqa, useCache, changedFiles)
if err != nil {
errChan <- err
return
Expand Down Expand Up @@ -269,7 +269,7 @@ func countTotalTestcases(testsuites []Testsuite) int {
return count
}

func evalTestsuite(rule Rule, modelSourcePath string, ignoreNoqa bool, useCache bool) (*Testsuite, error) {
func evalTestsuite(rule Rule, modelSourcePath string, ignoreNoqa bool, useCache bool, changedFiles []string) (*Testsuite, error) {

log.Debugf("evaluating rule %s", rule.Path)

Expand All @@ -282,6 +282,7 @@ func evalTestsuite(rule Rule, modelSourcePath string, ignoreNoqa bool, useCache
if err != nil {
return nil, err
}
inputFiles = filterInputFiles(inputFiles, normalizeChangedFilesSet(changedFiles))
testcase := &Testcase{}

for _, inputFile := range inputFiles {
Expand Down
Loading
Loading