diff --git a/internal/mcov/byfile_test.go b/internal/mcov/byfile_test.go new file mode 100644 index 0000000..cdf8e8f --- /dev/null +++ b/internal/mcov/byfile_test.go @@ -0,0 +1,34 @@ +package mcov + +import "testing" + +// ByFile rolls a Result up per source file, preserving first-seen order, with +// Covered counting lines that ran at least once. +func TestByFile(t *testing.T) { + r := Result{Lines: []LineCov{ + {Path: "MATH.m", Hits: 1}, + {Path: "MATH.m", Hits: 0}, + {Path: "STR.m", Hits: 3}, + {Path: "MATH.m", Hits: 2}, // out-of-order line for an already-seen file + }} + got := ByFile(r) + if len(got) != 2 { + t.Fatalf("ByFile returned %d files, want 2: %+v", len(got), got) + } + // First-seen order: MATH.m before STR.m. + if got[0].Path != "MATH.m" || got[0].Total != 3 || got[0].Covered != 2 { + t.Errorf("got[0] = %+v, want MATH.m 2/3", got[0]) + } + if got[1].Path != "STR.m" || got[1].Total != 1 || got[1].Covered != 1 { + t.Errorf("got[1] = %+v, want STR.m 1/1", got[1]) + } + if p := got[0].Percent(); p < 66.6 || p > 66.7 { + t.Errorf("MATH.m percent = %.2f, want ~66.67", p) + } +} + +func TestByFileEmpty(t *testing.T) { + if got := ByFile(Result{}); len(got) != 0 { + t.Errorf("ByFile(empty) = %+v, want none", got) + } +} diff --git a/internal/mcov/coverage.go b/internal/mcov/coverage.go index 1cfce3d..9a1fd28 100644 --- a/internal/mcov/coverage.go +++ b/internal/mcov/coverage.go @@ -140,6 +140,41 @@ func Run(ctx context.Context, p *parse.Parser, eng engine.Engine, routinePaths, return Result{Lines: lines, Stdout: res.Stdout}, nil } +// FileCov is one source file's coverage rollup. +type FileCov struct { + Path string + Covered int + Total int +} + +// Percent is the file's line coverage (0 when it has no executable lines). +func (f FileCov) Percent() float64 { + if f.Total == 0 { + return 0 + } + return 100 * float64(f.Covered) / float64(f.Total) +} + +// ByFile rolls a Result up per source file in first-seen line order — the +// shared rollup behind `m coverage`'s per-file report and `m watch --coverage`. +func ByFile(r Result) []FileCov { + idx := map[string]int{} + var out []FileCov + for _, l := range r.Lines { + i, ok := idx[l.Path] + if !ok { + i = len(out) + idx[l.Path] = i + out = append(out, FileCov{Path: l.Path}) + } + out[i].Total++ + if l.Hits > 0 { + out[i].Covered++ + } + } + return out +} + // LCOV renders the result as an LCOV tracefile (one SF block per source file). func LCOV(r Result) string { byPath := map[string][]LineCov{} diff --git a/internal/mtest/affected_test.go b/internal/mtest/affected_test.go new file mode 100644 index 0000000..236806b --- /dev/null +++ b/internal/mtest/affected_test.go @@ -0,0 +1,100 @@ +package mtest_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/vista-cloud-dev/m-cli/internal/mtest" +) + +// ReferencedRoutines returns the external routines a suite calls — the basis +// for mapping a changed routine to the suites that exercise it. +func TestReferencedRoutines(t *testing.T) { + src := []byte(`MATHTST ; math suite + do start^STDASSERT(.pass,.fail) + do tAdd(.pass,.fail) + do report^STDASSERT(pass,fail) + quit +tAdd(pass,fail) ;@TEST "add" + do eq^STDASSERT(.pass,.fail,$$add^MATH(2,3),5,"2+3") + do helper + quit +helper ; + do log^STDLOG("done") + quit +`) + got, err := mtest.ReferencedRoutines(mustParser(t), src) + if err != nil { + t.Fatal(err) + } + // External targets only, sorted; local label calls (tAdd, helper) excluded. + want := []string{"MATH", "STDASSERT", "STDLOG"} + if len(got) != len(want) { + t.Fatalf("ReferencedRoutines = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("ReferencedRoutines = %v, want %v", got, want) + } + } +} + +// Affected picks the suites that exercise any changed routine: the suite's own +// routine changed, or it references a changed routine. +func TestAffected(t *testing.T) { + suites := []mtest.TestSuite{ + {Name: "MATHTST", Deps: []string{"MATH", "STDASSERT"}}, + {Name: "STRTST", Deps: []string{"STRING", "STDASSERT"}}, + } + names := func(ss []mtest.TestSuite) []string { + out := make([]string, len(ss)) + for i, s := range ss { + out[i] = s.Name + } + return out + } + cases := []struct { + desc string + changed map[string]bool + want []string + }{ + {"dep of one suite", map[string]bool{"MATH": true}, []string{"MATHTST"}}, + {"shared dep hits both", map[string]bool{"STDASSERT": true}, []string{"MATHTST", "STRTST"}}, + {"suite's own routine", map[string]bool{"STRTST": true}, []string{"STRTST"}}, + {"unrelated routine", map[string]bool{"NOPE": true}, nil}, + } + for _, c := range cases { + got := names(mtest.Affected(suites, c.changed)) + if len(got) != len(c.want) { + t.Errorf("%s: Affected = %v, want %v", c.desc, got, c.want) + continue + } + for i := range c.want { + if got[i] != c.want[i] { + t.Errorf("%s: Affected = %v, want %v", c.desc, got, c.want) + break + } + } + } +} + +// Discover records each suite's external dependencies so the watch loop can do +// affected-test selection without re-parsing on every save. +func TestDiscoverDeps(t *testing.T) { + dir := t.TempDir() + src, _ := os.ReadFile("testdata/SAMPLETST.m") + if err := os.WriteFile(filepath.Join(dir, "SAMPLETST.m"), src, 0o644); err != nil { + t.Fatal(err) + } + suites, err := mtest.Discover(mustParser(t), []string{dir}) + if err != nil { + t.Fatal(err) + } + if len(suites) != 1 { + t.Fatalf("got %d suites, want 1", len(suites)) + } + if len(suites[0].Deps) != 1 || suites[0].Deps[0] != "STDASSERT" { + t.Errorf("Deps = %v, want [STDASSERT]", suites[0].Deps) + } +} diff --git a/internal/mtest/discovery.go b/internal/mtest/discovery.go index 9470452..88e7f43 100644 --- a/internal/mtest/discovery.go +++ b/internal/mtest/discovery.go @@ -8,6 +8,7 @@ import ( "sort" "strings" + "github.com/vista-cloud-dev/m-cli/internal/workspace" "github.com/vista-cloud-dev/m-parse/parse" ) @@ -25,6 +26,7 @@ type TestSuite struct { Path string Protocol string // routine hosting start/report (STDASSERT, TESTRUN, …) Cases []TestCase + Deps []string // external routines the suite calls (upper, sorted) — for affected-test selection } var ( @@ -158,12 +160,61 @@ func Discover(p *parse.Parser, paths []string) ([]TestSuite, error) { if err != nil { return nil, err } - suites = append(suites, TestSuite{Name: name, Path: f, Protocol: DetectProtocol(src), Cases: cases}) + deps, err := ReferencedRoutines(p, src) + if err != nil { + return nil, err + } + suites = append(suites, TestSuite{Name: name, Path: f, Protocol: DetectProtocol(src), Cases: cases, Deps: deps}) } sort.Slice(suites, func(i, j int) bool { return suites[i].Name < suites[j].Name }) return suites, nil } +// ReferencedRoutines returns the external routines src calls out to — every +// LABEL^ROUTINE / ^ROUTINE / $$^ROUTINE site — upper-cased and sorted. Local +// (bare-label / intra-routine) calls are excluded: they add no cross-file +// dependency. This maps a changed routine to the suites that exercise it. +func ReferencedRoutines(p *parse.Parser, src []byte) ([]string, error) { + tree, err := p.Parse(context.Background(), src) + if err != nil { + return nil, err + } + defer tree.Close() + set := map[string]bool{} + for _, r := range workspace.References(tree.RootNode(), "") { + if r.TargetRoutine != "" { // "" == current routine (a local call) + set[r.TargetRoutine] = true + } + } + out := make([]string, 0, len(set)) + for r := range set { + out = append(out, r) + } + sort.Strings(out) + return out, nil +} + +// Affected returns the suites that exercise any of the changed routines: a +// suite matches when its own routine changed (the suite file itself) or it +// references a changed routine via its Deps. changed holds upper-cased routine +// names. Order is preserved from suites. +func Affected(suites []TestSuite, changed map[string]bool) []TestSuite { + var out []TestSuite + for _, s := range suites { + if changed[strings.ToUpper(s.Name)] { + out = append(out, s) + continue + } + for _, d := range s.Deps { + if changed[d] { + out = append(out, s) + break + } + } + } + return out +} + func childOfType(n parse.Node, typ string) (parse.Node, bool) { for i := uint32(0); i < n.ChildCount(); i++ { if c := n.Child(i); c.Type() == typ { diff --git a/main.go b/main.go index d4d9d0a..cf12899 100644 --- a/main.go +++ b/main.go @@ -640,27 +640,11 @@ func (c *coverageCmd) Run(cc *clikit.Context) error { } } - // Per-file rollup. - type acc struct{ cov, tot int } - byPath := map[string]*acc{} - var order []string - for _, l := range result.Lines { - a := byPath[l.Path] - if a == nil { - a = &acc{} - byPath[l.Path] = a - order = append(order, l.Path) - } - a.tot++ - if l.Hits > 0 { - a.cov++ - } - } report := coverageReport{ Engine: string(kind), Covered: result.Covered(), Total: result.Total(), Percent: result.Percent(), } - for _, path := range order { - report.Files = append(report.Files, fileCov{Path: path, Covered: byPath[path].cov, Total: byPath[path].tot}) + for _, fc := range mcov.ByFile(result) { + report.Files = append(report.Files, fileCov{Path: fc.Path, Covered: fc.Covered, Total: fc.Total}) } if err := cc.Result(report, func() { @@ -742,6 +726,7 @@ type watchCmd struct { // Run half (engine-bound): re-run *TST.m suites on each change. RunTests bool `name:"run" help:"Also run *TST.m suites on each change (the run half; needs an engine)."` + Coverage bool `help:"Also report line coverage for changed routines on each change (implies --run; needs an engine)."` Engine string `help:"Engine for --run: ydb or iris (else $M_ENGINE / heuristic; exit 4 if unresolved)."` Docker string `help:"Run --run suites inside this container via docker exec."` Routines []string `help:"Extra source dirs to stage for --run (e.g. m-stdlib/src). Repeatable."` @@ -752,6 +737,8 @@ func (c *watchCmd) Run(cc *clikit.Context) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() + c.RunTests = c.RunTests || c.Coverage // coverage-on-save needs the engine-bound run path + p, err := parse.New(ctx) if err != nil { return clikit.Fail(clikit.ExitRuntime, "PARSER_INIT", err.Error(), "") @@ -817,6 +804,9 @@ func (c *watchCmd) Run(cc *clikit.Context) error { if c.RunTests { mode = "lint+run" } + if c.Coverage { + mode = "lint+run+cov" + } fmt.Fprintln(cc.Stdout, cc.Faint(fmt.Sprintf("watching %s (%s, %s) — Ctrl+C to stop", strings.Join(paths, ", "), mode, filter))) @@ -828,7 +818,7 @@ func (c *watchCmd) Run(cc *clikit.Context) error { c.checkFile(ctx, cc, p, linter, f) // static half } if c.RunTests && len(ev.Changed) > 0 { - c.runHalf(ctx, cc, staged, suites, ev.Changed) // run half + c.runHalf(ctx, cc, p, staged, suites, ev.Changed) // run half } } @@ -840,17 +830,30 @@ func (c *watchCmd) Run(cc *clikit.Context) error { return err } -// runHalf re-stages the changed files and re-runs the suites through the engine, -// printing a compact pass/fail summary (the engine-bound half of m watch). -func (c *watchCmd) runHalf(ctx context.Context, cc *clikit.Context, staged *stagedEngine, suites []mtest.TestSuite, changed []string) { +// runHalf re-stages the changed files and re-runs the affected suites through +// the engine, printing a compact pass/fail summary (and, with --coverage, a +// per-routine coverage rollup) — the engine-bound half of m watch. +func (c *watchCmd) runHalf(ctx context.Context, cc *clikit.Context, p *parse.Parser, staged *stagedEngine, suites []mtest.TestSuite, changed []string) { if len(suites) == 0 { return } + // Affected-test selection: run only the suites that exercise a changed + // routine — the suite file itself changed, or it calls a changed routine — + // rather than re-running the whole set on every save (spec §3.1/§9). + changedRtns := map[string]bool{} + for _, f := range changed { + changedRtns[strings.ToUpper(strings.TrimSuffix(filepath.Base(f), filepath.Ext(f)))] = true + } + affected := mtest.Affected(suites, changedRtns) + if len(affected) == 0 { + fmt.Fprintln(cc.Stdout, " "+cc.Faint("tests: no suites affected")) + return + } if err := staged.restage(changed); err != nil { fmt.Fprintln(cc.Stdout, " "+cc.Failure("run: stage failed: "+err.Error())) return } - results, err := mtest.Run(ctx, staged.eng, suites) + results, err := mtest.Run(ctx, staged.eng, affected) if err != nil { fmt.Fprintln(cc.Stdout, " "+cc.Failure("run: "+err.Error())) return @@ -866,14 +869,46 @@ func (c *watchCmd) runHalf(ctx context.Context, cc *clikit.Context, staged *stag total := pass + fail if fail == 0 { fmt.Fprintln(cc.Stdout, " "+cc.Success(fmt.Sprintf("tests: %d/%d suites ok", pass, total))) - return + } else { + fmt.Fprintln(cc.Stdout, " "+cc.Failure(fmt.Sprintf("tests: %d/%d suites failed", fail, total))) + for _, r := range results { + if !r.OK { + fmt.Fprintf(cc.Stdout, " %s\n", cc.Failure(r.Suite)) + } + } } - fmt.Fprintln(cc.Stdout, " "+cc.Failure(fmt.Sprintf("tests: %d/%d suites failed", fail, total))) - for _, r := range results { - if !r.OK { - fmt.Fprintf(cc.Stdout, " %s\n", cc.Failure(r.Suite)) + if c.Coverage { + c.coverageHalf(ctx, cc, p, staged, affected, changed) // coverage-on-save + } +} + +// coverageHalf measures line coverage for the changed routines while driving the +// affected suites through the engine, printing a per-routine rollup. Coverage is +// scoped to what just changed (not the whole tree) so the inner loop stays fast. +func (c *watchCmd) coverageHalf(ctx context.Context, cc *clikit.Context, p *parse.Parser, staged *stagedEngine, affected []mtest.TestSuite, changed []string) { + var routinePaths []string + for _, f := range changed { + if isMFile(f) && !mtest.IsSuiteFile(f) { + routinePaths = append(routinePaths, f) } } + if len(routinePaths) == 0 { + return // only suites changed — no routine-under-test to measure + } + suiteEntries := make([]string, len(affected)) + for i, s := range affected { + suiteEntries[i] = s.Name + } + result, err := mcov.Run(ctx, p, staged.eng, routinePaths, suiteEntries) + if err != nil { + fmt.Fprintln(cc.Stdout, " "+cc.Failure("cov: "+err.Error())) + return + } + for _, fc := range mcov.ByFile(result) { + fmt.Fprintf(cc.Stdout, " %s %s %d/%d %s\n", + cc.Faint("cov:"), filepath.Base(fc.Path), fc.Covered, fc.Total, + cc.Faint(fmt.Sprintf("%.1f%%", fc.Percent()))) + } } // checkFile lints (and optionally fmt-checks) one file and prints the result.