Skip to content

feat: implement mindev test cli command and rule loading by name#6551

Merged
evankanderson merged 10 commits into
mindersec:mainfrom
krrish175-byte:feat/cli-scaffolding
Jul 3, 2026
Merged

feat: implement mindev test cli command and rule loading by name#6551
evankanderson merged 10 commits into
mindersec:mainfrom
krrish175-byte:feat/cli-scaffolding

Conversation

@krrish175-byte

Copy link
Copy Markdown
Member

Summary

This PR implements the CLI Scaffolding for the mindev Rule Testing Framework, wiring up the underlying pkg/ruletest engine into an accessible CLI command and making the framework fully CI/CD ready.

Addressing deferred tech debt from Phase 2, this PR also introduces "Rule Loading by Name", eliminating the need to hardcode file paths in Starlark scripts by dynamically pre-loading all YAML rule definitions from the target directory into an in-memory registry.

Key Features:

  • New mindev test CLI Command: Added a test subcommand to mindev. Developers can now execute their Starlark test files across entire directories by running mindev test [directories...].
  • Rule Loading by Name: The eval() builtin has been updated to seamlessly look up and load rules directly by name from the pre-loaded YAML registry.
  • Graceful Fallback: If eval() cannot find the specified rule by name in the registry, it safely falls back to resolving it as a file path, ensuring backward compatibility.
  • CI/CD Readiness: The CLI aggregates the results of all Starlark tests executed. If any test fails, mindev test exits with a non-zero exit code (1), making it perfectly suited for automated CI environments.
  • Part of the rule testing framework implementation.

Fixes #6507

Testing

  • Built the developer tooling locally (make build) and verified the new test cobra command registers properly.
  • Ran the newly created command against the sample test data (./mindev test ./pkg/ruletest/testdata).
  • Verified that the CLI correctly parses and aggregates all test execution results.
  • Ensured that intentionally failing tests properly trigger the 1 exit code and bubble up the one or more tests failed error for CI environments.

@krrish175-byte krrish175-byte requested a review from a team as a code owner June 27, 2026 11:51
@coveralls

coveralls commented Jun 27, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 60.832% (+0.1%) from 60.724% — krrish175-byte:feat/cli-scaffolding into mindersec:main

@evankanderson evankanderson left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You've added a bunch of code in pkg/ruletest -- can we restructure the existing rule execution tests to cover that code as well?

Comment thread cmd/dev/app/test/test.go
Comment thread cmd/dev/app/test/test.go Outdated
runner := ruletest.NewRunner()
hasFailures := false

for _, dir := range args {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we accept files or directories?

Also, this feels like it might be a reasonable function in runner.go (where you could test it more easily).

Comment thread cmd/dev/app/test/test.go Outdated
hasFailures := false

for _, dir := range args {
fmt.Printf("Running tests in %s...\n", dir)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use cmd.Printf() so that we can override the cmd.Output and get this output from the command.

Comment thread cmd/dev/app/test/test.go Outdated
Comment on lines +31 to +33
if err != nil {
return fmt.Errorf("error running tests in %s: %w", dir, err)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure -- do we want to exit-early here, or collect errors from remaining directories?

Comment thread pkg/ruletest/runner.go Outdated
Comment on lines +99 to +101
func (r *Runner) RunFile(filename string, src any) ([]TestResult, error) {
return r.runFileWithRules(filename, src, nil)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just add the ruleTypes argument to RunFile?

Comment thread pkg/ruletest/eval_test.go
Comment thread pkg/ruletest/runner.go Outdated
return nil, fmt.Errorf("error opening file: %s", yf)
}
rt, err := fileconvert.ReadResourceTyped[*minderv1.RuleType](decoder)
closer.Close()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's okay to not handle errors from Close in this case, but the linter does not, so you'll need to do something to make it okay with that.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I'd prefer the defer Close() pattern, just in case extra logic or returns get added in later.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Yes, we could be deferring a bunch of filehandle closes, but those should be fairly cheap)

Comment thread pkg/ruletest/runner.go Outdated

func (r *Runner) runOneTest(name string, fn *starlark.Function, fileSystem fs.FS) TestResult {
tr := r.newTestCaseRunner(name, fileSystem)
func (r *Runner) runOneTest(name string, fn *starlark.Function, fileSystem fs.FS, ruleTypes map[string]*minderv1.RuleType) TestResult {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The linter wants you to fold the line at 130 characters.

Comment thread pkg/ruletest/runner.go Outdated

var allResults []TestResult
for _, file := range files {
results, err := r.runFileWithRules(file, nil, ruleTypes)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than carrying around ruleTypes, I'd look to see if there's an object it might naturally live with that could encompass "test files in a directory".

Comment thread pkg/ruletest/runner.go Outdated
Comment on lines 236 to 241
ruleTypes, err := loadRulesFromDir(dir)
if err != nil {
t.Fatalf("loading rules: %v", err)
}

files, err := DiscoverFiles(dir)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like RunDir. Why different?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, why not simply call RunPaths, and then assert on the errors and results?

@krrish175-byte

Copy link
Copy Markdown
Member Author

Key Updates:

  • Added mindev test CLI command: Implemented a new test subcommand in mindev (via cmd/dev/app/test) that accepts multiple file or directory paths.
  • Enhanced Test Runner (pkg/ruletest/runner.go):
    • Added the RunPaths function to allow executing tests across a list of directories and files seamlessly.
    • Implemented automatic rule loading (loadRulesFromDir). The test runner now dynamically loads rule definitions alongside the tests, resolving panics that occurred when tests referenced rule names that were missing from the context.
  • CLI Output Formatting: mindev test now aggregates test results and outputs a clean, structured list of PASS/FAIL results with nested failure messages for easy debugging.
  • Test Coverage: Updated runner_test.go to maintain full test coverage and explicitly test execution behavior without panicking on uninitialized framework pointers.

(Note: The filesystem mocking features are staged locally and will be pushed as a separate commit once this foundational work is settled.)

@evankanderson evankanderson left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few comments -- I feel like your structure is close to optimal, but you can probably remove some near-duplicate code and get better test coverage with a few more tweaks.

Comment thread pkg/ruletest/runner.go Outdated
Comment on lines 18 to 22
minderv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1"
"github.com/mindersec/minder/pkg/fileconvert"
"go.starlark.net/starlark"
"go.starlark.net/starlarktest"
"go.starlark.net/syntax"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GCI wants the minder imports after the other third-party imports, in a separate section.

Suggested change
minderv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1"
"github.com/mindersec/minder/pkg/fileconvert"
"go.starlark.net/starlark"
"go.starlark.net/starlarktest"
"go.starlark.net/syntax"
"go.starlark.net/starlark"
"go.starlark.net/starlarktest"
"go.starlark.net/syntax"
minderv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1"
"github.com/mindersec/minder/pkg/fileconvert"

Comment thread pkg/ruletest/runner.go Outdated
globals, err := tr.runFile(filename, src)
if err != nil {
if evalErr, ok := errors.AsType[*starlark.EvalError](err); ok {
if evalErr, ok := err.(*starlark.EvalError); ok {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the change from errors.AsType?

Comment thread pkg/ruletest/runner.go Outdated
// directory, reporting results through t.
func (r *Runner) RunDir(t *testing.T, dir string) {
// directory. It also discovers and loads any *.yaml rule files in the directory.
func (r *Runner) RunDir(dir string) ([]TestResult, error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if you want to make this function private or roll it into RunPaths

Comment thread pkg/ruletest/runner.go Outdated
Comment on lines 236 to 241
ruleTypes, err := loadRulesFromDir(dir)
if err != nil {
t.Fatalf("loading rules: %v", err)
}

files, err := DiscoverFiles(dir)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, why not simply call RunPaths, and then assert on the errors and results?

Comment on lines +130 to +142
func TestTestDir(t *testing.T) {
t.Parallel()
r := NewRunner()
dir := t.TempDir()
content := []byte(`
def test_pass():
assert.eq(1, 1)
`)
if err := os.WriteFile(filepath.Join(dir, "pass.star"), content, 0644); err != nil {
t.Fatal(err)
}
r.TestDir(t, dir)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would it take to change all the above tests that use RunFile to have a single invocation of TestDir (or RunPaths) and then have specific tests which check for specific results?

I suspect that results might want to become something more sophisticated than a string (e.g. a file name + test name + output), but I think you might find that gives you benefits when it comes to creating e.g. XML output.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still hoping that this becomes:

r := NewRunner()
r.TestDir(t, "testdata")

And that we can get good coverage from just that and the file contents in testdata.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(We might even be able to delete some of the other tests!)

@krrish175-byte krrish175-byte force-pushed the feat/cli-scaffolding branch 2 times, most recently from 8242a0e to eefe8c0 Compare June 29, 2026 19:55
- Fix GCI import ordering in runner.go (minder imports in separate section)
- Restore errors.AsType in RunFile for proper error-wrapping support
- Make RunDir private; TestDir now delegates to RunPaths to remove duplication
- Rename ruleNameOrPath to ruleName in builtinEval for clarity
- Update mindev test Use field to accept files or directories

@evankanderson evankanderson left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the delay; I got sniped by #6558 and several life things (like getting a bike repaired).

This is looking better, but I'm going to push for even more integration testing in the pkg/ruletest to make sure we don't break the CLI by accident.

Comment thread pkg/ruletest/eval.go Outdated
}

rulePath := ruleName
if !filepath.IsAbs(rulePath) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this check? What would happen if the rulename started with / on Unix?

Comment thread pkg/ruletest/eval.go Outdated
Comment on lines +144 to +163
rulePath := ruleName
if !filepath.IsAbs(rulePath) {
callerFrame := thread.CallFrame(1)
if callerFile := callerFrame.Pos.Filename(); callerFile != "" {
rulePath = filepath.Join(filepath.Dir(callerFile), rulePath)
}
}

decoder, closer := fileconvert.DecoderForFile(rulePath)
if decoder == nil {
return nil, fmt.Errorf("rule %q not found in loaded rule types and no file found at path %s", ruleName, rulePath)
}
defer closer.Close()

rt, err := fileconvert.ReadResourceTyped[*minderv1.RuleType](decoder)
if err != nil {
return nil, fmt.Errorf("failed to parse rule type: %w", err)
}

return rt, nil

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm worried about this code being hit unexpectedly, and things "working" when the primary path is broken.

Alternatively, I'm worried that we have no coverage for this path, and that it's code that seems like it does something but doesn't.

Is there a good reason to keep this code vs removing it and updating the test cases?

Comment thread pkg/ruletest/eval_test.go
Comment thread pkg/ruletest/runner.go Outdated
Comment on lines +99 to +100
// RunFile executes a single Starlark test file. If src is non-nil, it is
// used as the file contents.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old comment was better. (It explained the testing action more clearly, and better explained the values that src can take.)

Comment thread pkg/ruletest/runner.go
// into a map of RuleTypes keyed by rule name.
func loadRulesFromDir(dir string) (map[string]*minderv1.RuleType, error) {
ruleTypes := make(map[string]*minderv1.RuleType)
yamlFiles, err := filepath.Glob(filepath.Join(dir, "*.yaml"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#6558 will mean you'll need to expand this a little bit. Feel free to adjust e.g. ResourcesFromPaths to have a non-recursive version, or a recursion boolean (actually, it looks like it is ExpandFileArgs that would need adjustment).

Comment on lines +130 to +142
func TestTestDir(t *testing.T) {
t.Parallel()
r := NewRunner()
dir := t.TempDir()
content := []byte(`
def test_pass():
assert.eq(1, 1)
`)
if err := os.WriteFile(filepath.Join(dir, "pass.star"), content, 0644); err != nil {
t.Fatal(err)
}
r.TestDir(t, dir)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(We might even be able to delete some of the other tests!)

Comment thread pkg/testkit/v1/testkit.go
Comment on lines +43 to +46
return func(tk *TestKit) {
tk.mockFS = fs
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you adjust testkit as follows (rather than having separate paths for the WithGitDir and this? Also, I think "WithGitFiles" might be a better name here, to parallel the "Dir" option -- I don't feel strongly, but both are providing a mock FS?

The way I'd do this would be:

  1. rename tk.gitDir to tk.gitFS, and change the type to billy.Filesystem
  2. WithGitDir would more-eagerly create the osfs.New(dir)
  3. This method would create a memfs, populate it, and assign it to tk.gitFS. Note that we only need to return a billy.Filesystem here, we don't need a full Git storage by using the Ingester interface.

Comment thread cmd/dev/app/test/test.go Outdated
for _, res := range results {
if len(res.Failures) > 0 {
hasFailures = true
cmd.Printf("FAIL: %s\n", res.Name)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you'll want the filename here as well as the test name, particularly if the same test name could occur in multiple *.star files.

Comment thread cmd/dev/app/test/test.go Outdated

if len(results) == 0 {
cmd.Printf("No tests found\n")
return err

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

err is nil here, so you could just return nil.

Comment thread cmd/dev/app/test/test.go Outdated
return fmt.Errorf("one or more tests failed")
}

return err

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

err is also non-nil here (was last assigned on 28, then checked on 29)

- Remove t.Parallel() from TestDir to prevent panic when caller already called it
- Add Filename field to TestResult; TestDir groups sub-tests by file/name
- Restore more descriptive RunFile doc comment explaining src parameter
- Remove path fallback from rule lookup: eval() now only accepts rule names,
  not file paths, resolving the dual-typed argument complexity
- Update testdata/eval.star to reference rule by name (branch_protection_reviews)
  instead of file path (rule_type_sample.yaml)
- Load ruleTypes in TestRunEvalFile so name-based lookup works in tests
- Remove nil guard from mapToProto: empty entity maps now correctly flow
  through to the engine instead of silently returning nil
- Addressed Evan's feedback on testkit mocking (refactored to gitFS/WithGitFiles)
- Fixed 'err' return values in cmd/dev/app/test/test.go
- Added filename prefix to CLI test output
- Fixed defer Close() pattern in ruletest/runner.go
- Updated RunFile godoc
- Add test_fail_ prefix support to TestDir

- Simplify runner_test.go to use TestDir

- Rename intentionally failing tests in sample.star to use test_fail_ prefix

@evankanderson evankanderson left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a few lint errors hanging around, but this is looking close.

I'd still like to see runner_test.go call r.TestDir(t, "testdata") if that works -- it feels like it would make adding new test cases really simple.

Comment thread pkg/ruletest/eval.go Outdated
Comment on lines +137 to +139
if ruleType, ok := tr.ruleTypes[ruleName]; ok {
return ruleType, nil
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that you're storing *minderv1.RuleType, you don't need the ,ok = formulation, since a nil pointer could just be the error case below:

Suggested change
if ruleType, ok := tr.ruleTypes[ruleName]; ok {
return ruleType, nil
}
if ruleType := tr.ruleTypes[ruleName]; ruleType != nil {
return ruleType, nil
}

Comment thread pkg/ruletest/eval_test.go Outdated
},
{
name: "invalid mock_fs type",
args: starlark.Tuple{starlark.String("rule.yaml")},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I know we don't really use this here, but this should be "fs_check", right?)

Comment thread pkg/testkit/v1/testkit_provider.go Outdated
Comment on lines +89 to +130
// Clone Implements the Git trait. If gitFS is set, it initializes an
// in-memory Git repository backed by that filesystem.
func (tk *TestKit) Clone(_ context.Context, _ string, _ string) (*git.Repository, error) {
if tk.gitFS == nil {
return nil, ErrNotIngesterOverridden
}

storer := memory.NewStorage()

repo, err := git.Init(storer, tk.gitFS)
if err != nil {
return nil, err
}

w, err := repo.Worktree()
if err != nil {
return nil, err
}

// Add all files from the worktree
status, err := w.Status()
if err != nil {
return nil, err
}
for path := range status {
if _, err := w.Add(path); err != nil {
continue
}
}

_, err = w.Commit("Initial commit", &git.CommitOptions{
Author: &object.Signature{
Name: "TestKit",
Email: "testkit@minder.test",
When: time.Now(),
},
})
if err != nil {
return nil, err
}

return repo, nil

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still need this? I don't think we do.

@evankanderson evankanderson left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good except for the lint errors. I'm particularly pleased at how runner_test.go was able to be simplified but still have good coverage.

Comment thread pkg/ruletest/runner.go
if !d.IsDir() && strings.HasSuffix(d.Name(), ".star") {
files = append(files, path)
if rt != nil && rt.Name != "" {
ruleTypes[rt.Name] = rt

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(for a subsequent PR)

We should return an error if ruletypes[rt.Name] already exists.

Comment thread pkg/ruletest/runner.go Outdated
Comment on lines +235 to +239
var dirs []string
for dir := range filesByDir {
dirs = append(dirs, dir)
}
sort.Strings(dirs)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a use-case for the Go generic map functions introduced a year or so ago:

Suggested change
var dirs []string
for dir := range filesByDir {
dirs = append(dirs, dir)
}
sort.Strings(dirs)
dirs = slices.Sorted(maps.Keys(filesByDir))

Comment thread pkg/ruletest/runner.go Outdated
Comment on lines +263 to +266
// TestDir discovers and executes all *.star test files under the given
// directory, reporting results through t. It also loads *.yaml rules.
//nolint:tparallel // TestDir acts as a test runner helper, caller is responsible for t.Parallel
func (r *Runner) TestDir(t *testing.T, dir string) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The linter doesn't like the //nolint: directive right after the other docs. You might just move this into runner_test, not export it, or export with a name that doesn't start with "Test*"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This diff is beautiful, IMO.

Comment thread pkg/ruletest/runner.go Outdated
result := result
name := result.Filename + "/" + result.Name
t.Run(name, func(t *testing.T) {
if strings.HasPrefix(result.Name, "test_fail_") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GCI wants this anonymous function to call t.Parallel() after calling t.Run() to produce a subtest.

@evankanderson evankanderson merged commit 860fb8f into mindersec:main Jul 3, 2026
34 of 35 checks passed
@krrish175-byte krrish175-byte deleted the feat/cli-scaffolding branch July 3, 2026 19:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[LFX] Rule Testing Framework: CLI scaffolding + test discovery (mindev test)

3 participants