diff --git a/README.md b/README.md index 5b22bbf..fe2405c 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,22 @@ The policy is the shared `[MD0nn]` rule set from `rumdl/base.toml` in this repo. Optional `dirLimits` maps directory globs to `{limit: 50, reason: "record decisions together"}`. Limits must exceed 10 and reasons must contain at least three words. When multiple globs match, the smallest limit wins. +The gate also measures Go cognitive complexity, function lines and statements, and TypeScript cognitive complexity and function lines. The optional `functions` field uses the fixed Go limits 20, 60 and 40, and TypeScript limits 15 and 50. Existing configurations may omit it. Function collection requires `golangci-lint` and `biome` on PATH. The gate reports counted extensions without a collector on stderr. + +Function allowlist entries are keyed by metric, path and function ID. Go IDs use function and receiver names, with closure ordinals within their enclosing function. TypeScript IDs use enclosing functions, classes and test titles where available. Suppression directives and mismatched Biome rule options are findings. + +The gate runs Biome with its own generated configuration and the gate's limits. It copies the root Biome configuration's `javascript.parser` options. The repository's Biome rule settings are checked for consistency, but do not control measurement. The two measured rules may use `warn` or `error` levels in the repository configuration. + +Skips are printed on stderr. A Go file outside the default build that contains functions, or a file Biome cannot parse, is an unmeasured finding. If the TypeScript function collector cannot parse a file and Biome reports an over-limit function, the gate reports a file-level finding. Existing function allowlist entries for unmeasured files are preserved on `--write`. Skips that leave no functions unmeasured are notices only. Use the gate's `exclude` to opt out. + +Biome does not process files under `node_modules`. Exclude tracked paths in those directories from the gate. + +Findings use the format ` value= limit= fix=`. An `unmeasured` finding uses value and limit 0; its fix describes the cause. + +```cue +functions: "go-lines": "src/example.go": [{id: "Example", value: 70, reason: "split by concern"}] +``` + Add `tools/code-shape-gate/allowlist.cue` with `files` and `dirs` maps. Each entry has a measured `value` and a `reason` of at least three words: ```cue diff --git a/go/CHANGELOG.md b/go/CHANGELOG.md index 635bb4c..2406701 100644 --- a/go/CHANGELOG.md +++ b/go/CHANGELOG.md @@ -35,6 +35,10 @@ ### Added +- `code-shape-gate` measures Go and TypeScript functions, reports skipped files and unmeasured extensions, and detects suppression and Biome policy drift. +- Report unmeasured functions and Biome scope drift instead of silently passing them. + + - Added a Renovate hash refresh command for Go source and vendor hashes, including the Go tools package vendor hash. - Added `code-shape-gate`, which checks file length and directory width against a per-repo allowlist with a ratchet. - `code-shape-gate` supports per-glob directory limits and excludes matching files from directory width counts. diff --git a/go/cmd/code-shape-gate/main.go b/go/cmd/code-shape-gate/main.go index 13f0064..4a8b33f 100644 --- a/go/cmd/code-shape-gate/main.go +++ b/go/cmd/code-shape-gate/main.go @@ -7,9 +7,9 @@ import ( "context" "os" - "github.com/RigelBuild/meissa/go/internal/codeshape" + "github.com/RigelBuild/meissa/go/internal/codeshape/cli" ) func main() { - os.Exit(codeshape.Main(context.Background(), os.Args[1:], os.Getenv, os.Stdout, os.Stderr)) + os.Exit(cli.Main(context.Background(), os.Args[1:], os.Getenv, os.Stdout, os.Stderr)) } diff --git a/go/go.mod b/go/go.mod index ccb4451..0065035 100644 --- a/go/go.mod +++ b/go/go.mod @@ -14,18 +14,12 @@ require ( ) require ( - cuelabs.dev/go/oci/ociregistry v0.0.0-20260601085548-328ff8e2c943 // indirect github.com/cockroachdb/apd/v3 v3.2.3 // indirect github.com/emicklei/proto v1.14.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.1 // indirect github.com/protocolbuffers/txtpbfmt v0.0.0-20260420112717-c39628bde8b5 // indirect - github.com/rogpeppe/go-internal v1.15.0 // indirect golang.org/x/net v0.56.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.21.0 // indirect golang.org/x/text v0.39.0 // indirect google.golang.org/protobuf v1.33.0 // indirect ) diff --git a/go/go.sum b/go/go.sum index 05fef0a..62637e9 100644 --- a/go/go.sum +++ b/go/go.sum @@ -54,8 +54,6 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= diff --git a/go/internal/codeshape/cli/biome_scope_test.go b/go/internal/codeshape/cli/biome_scope_test.go new file mode 100644 index 0000000..ea01e20 --- /dev/null +++ b/go/internal/codeshape/cli/biome_scope_test.go @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package cli + +import ( + "strings" + "testing" +) + +func TestBiomeIgnoredCountedPath(t *testing.T) { + for _, tc := range []struct{ name, rootConfig, nested string }{ + {"includes", strings.TrimSuffix(tsBiomeConfig, "}") + `,"files":{"includes":["**", "!sub/**"]}}`, ""}, + {"override", strings.TrimSuffix(tsBiomeConfig, "}") + + `,"overrides":[{"includes":["sub/**"],"linter":{"enabled":false}}]}`, ""}, + {"nested", tsBiomeConfig, `{"root":false,"extends":"//","linter":{"enabled":false}}`}, + } { + t.Run(tc.name, func(t *testing.T) { + root := tsRepo(t, "function healthy() {}\n") + trackedFile(t, root, "biome.json", tc.rootConfig) + trackedFile(t, root, "sub/x.ts", "function large() {\n"+strings.Repeat("console.log(1);\n", 51)+"}\n") + if tc.nested != "" { + trackedFile(t, root, "sub/biome.json", tc.nested) + } + commitFiles(t, root) + code, out, stderr := runGoGate(t, root) + if code != 1 || !strings.Contains(out, "sub/x.ts") { + t.Fatalf("code=%d out=%q stderr=%q", code, out, stderr) + } + }) + } +} diff --git a/go/internal/codeshape/cli.go b/go/internal/codeshape/cli/cli.go similarity index 51% rename from go/internal/codeshape/cli.go rename to go/internal/codeshape/cli/cli.go index db7b09a..0974bcb 100644 --- a/go/internal/codeshape/cli.go +++ b/go/internal/codeshape/cli/cli.go @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -package codeshape +// Package cli implements the code shape gate command. +package cli import ( "context" @@ -10,10 +11,14 @@ import ( "maps" "os" "os/exec" + "path/filepath" "sort" "strings" - "cuelang.org/go/cue/cuecontext" + "github.com/RigelBuild/meissa/go/internal/codeshape" + shapeconfig "github.com/RigelBuild/meissa/go/internal/codeshape/config" + "github.com/RigelBuild/meissa/go/internal/codeshape/golang" + "github.com/RigelBuild/meissa/go/internal/codeshape/typescript" ) var errCannotRaiseAllowlist = errors.New("--write cannot add or raise allowlist entries") @@ -31,12 +36,21 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s writeOutput(stderr, "usage: code-shape-gate [--write] [--seed] \n") return 2 } - config, allow, err := Load(root) + root, err := filepath.Abs(root) if err != nil { writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 } - var base Allowlist + config, allow, err := shapeconfig.Load(root) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + err = reportUnmeasuredExtensions(stderr, config.Extensions) + if err != nil { + return 2 + } + var base codeshape.Allowlist if baseRef := env("GATE_BASE"); baseRef != "" { base, err = loadBaseAllowlist(ctx, root, baseRef) if err != nil { @@ -44,16 +58,32 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s return 2 } } - snapshot, err := Inventory(ctx, root, config) + snapshot, err := codeshape.Inventory(ctx, root, config) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + suppressions, unmeasured, err := collectMeasurements(ctx, root, config, &snapshot, stderr) if err != nil { writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 } + preserved := separateUnmeasured(&allow, unmeasured) + baseFindings := codeshape.JudgeBaseEntries(codeshape.Allowlist{ + Files: nil, Dirs: nil, Functions: preserved, Present: true, + }, base) if writeMode { - return writeUpdated(root, config, allow, base, snapshot, seed, stdout, stderr) + return writeUpdated(root, config, allow, base, snapshot, seed, stdout, stderr, + preserved, suppressions, baseFindings) } - findings := Judge(config, allow, base, snapshot) - err = writeFindings(stdout, findings) + findings := append(codeshape.Judge(config, allow, base, snapshot), suppressions...) + findings = append(findings, baseFindings...) + return printGateFindings(stdout, stderr, findings) +} + +func printGateFindings(stdout, stderr io.Writer, findings []codeshape.Finding) int { + codeshape.SortFindings(findings) + err := writeFindings(stdout, findings) if err != nil { writeOutput(stderr, "code-shape-gate: write output: %v\n", err) return 2 @@ -64,35 +94,89 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s return 0 } +func reportUnmeasuredExtensions(output io.Writer, extensions []string) error { + for _, extension := range extensions { + switch extension { + case ".go", ".ts", ".tsx", ".mts", ".cts": + continue + } + _, err := fmt.Fprintf(output, "code-shape-gate: no function collector for %s\n", extension) + if err != nil { + return fmt.Errorf("write unmeasured extension: %w", err) + } + } + return nil +} + +func collectMeasurements( + ctx context.Context, root string, config codeshape.Config, snapshot *codeshape.Snapshot, stderr io.Writer, +) ([]codeshape.Finding, []string, error) { + functions, skips, err := golang.GoMetrics(ctx, root, config) + if err != nil { + return nil, nil, fmt.Errorf("collect Go metrics: %w", err) + } + goFindings, goPaths, err := handleGoSkips(stderr, skips) + if err != nil { + return nil, nil, err + } + tsFunctions, tsSkips, err := typescript.TSMetrics(ctx, root, config) + if err != nil { + return nil, nil, fmt.Errorf("collect TypeScript metrics: %w", err) + } + unmeasuredFindings, unmeasuredPaths, err := handleTSSkips(stderr, tsSkips, config) + if err != nil { + return nil, nil, err + } + functions[codeshape.MetricTSCognitive] = tsFunctions[codeshape.MetricTSCognitive] + functions[codeshape.MetricTSLines] = tsFunctions[codeshape.MetricTSLines] + snapshot.Functions = functions + suppressions, err := golang.SuppressionFindings(ctx, root, config) + if err != nil { + return nil, nil, fmt.Errorf("collect Go suppressions: %w", err) + } + tsFindings, err := typescript.PolicyFindings(ctx, root, config) + if err != nil { + return nil, nil, fmt.Errorf("collect TypeScript policy: %w", err) + } + suppressions = append(suppressions, tsFindings...) + suppressions = append(suppressions, goFindings...) + suppressions = append(suppressions, unmeasuredFindings...) + codeshape.SortFindings(suppressions) + return suppressions, append(unmeasuredPaths, goPaths...), nil +} + func writeUpdated( root string, - config Config, - allow, base Allowlist, - snapshot Snapshot, + config codeshape.Config, + allow, base codeshape.Allowlist, + snapshot codeshape.Snapshot, seed bool, stdout, stderr io.Writer, + preserved codeshape.FunctionMeasurements, policy, baseFindings []codeshape.Finding, ) int { updated, changed, err := rewriteAllowlist(config, allow, snapshot, seed) if err != nil { writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 } - findings := Judge(config, updated, base, snapshot) + findings := codeshape.Judge(config, updated, base, snapshot) + ratchetFailures := len(findings) > 0 || len(baseFindings) > 0 + findings = append(findings, policy...) + findings = append(findings, baseFindings...) + codeshape.SortFindings(findings) err = writeFindings(stdout, findings) if err != nil { writeOutput(stderr, "code-shape-gate: write output: %v\n", err) return 2 } - if len(findings) > 0 { - return 1 - } - if changed { + restoreUnmeasured(&updated, preserved) + if changed && !ratchetFailures { repository, openErr := os.OpenRoot(root) if openErr != nil { writeOutput(stderr, "code-shape-gate: open repository: %v\n", openErr) return 2 } - err = repository.WriteFile("tools/code-shape-gate/allowlist.cue", Render(updated), 0o644) + err = repository.WriteFile("tools/code-shape-gate/allowlist.cue", shapeconfig.Render(updated), 0o644) closeErr := repository.Close() err = errors.Join(err, closeErr) if err != nil { @@ -100,6 +184,9 @@ func writeUpdated( return 2 } } + if len(findings) > 0 { + return 1 + } return 0 } @@ -128,33 +215,28 @@ func parseArgs(args []string) (bool, bool, string, bool) { return writeMode, seed, args[0], true } -func loadBaseAllowlist(ctx context.Context, root, ref string) (Allowlist, error) { +func loadBaseAllowlist(ctx context.Context, root, ref string) (codeshape.Allowlist, error) { // ls-tree validates the ref and returns no paths for a missing allowlist. //nolint:gosec // The ref is an argument to a fixed git command. command := exec.CommandContext(ctx, "git", "ls-tree", "--name-only", ref, "--", "tools/code-shape-gate/allowlist.cue") command.Dir = root data, err := command.Output() if err != nil { - return Allowlist{}, gitBaseError(ref, err) + return codeshape.Allowlist{}, gitBaseError(ref, err) } if len(data) == 0 { - return emptyAllowlist(), nil + return codeshape.EmptyAllowlist(), nil } //nolint:gosec // The ref is an argument to a fixed git command. command = exec.CommandContext(ctx, "git", "show", "--end-of-options", ref+":tools/code-shape-gate/allowlist.cue") command.Dir = root data, err = command.Output() if err != nil { - return Allowlist{}, gitBaseError(ref, err) - } - cueContext := cuecontext.New() - schema, err := schemaValue(cueContext) - if err != nil { - return Allowlist{}, err + return codeshape.Allowlist{}, gitBaseError(ref, err) } - allowlist, err := decodeAllowlistBytes(cueContext, schema, data, "base allowlist") + allowlist, err := shapeconfig.DecodeBaseAllowlist(data) if err != nil { - return Allowlist{}, err + return codeshape.Allowlist{}, fmt.Errorf("decode base allowlist: %w", err) } allowlist.Present = true return allowlist, nil @@ -168,20 +250,27 @@ func gitBaseError(ref string, err error) error { return fmt.Errorf("read base allowlist at %q: %w", ref, err) } -func rewriteAllowlist(cfg Config, allow Allowlist, snapshot Snapshot, seed bool) (Allowlist, bool, error) { - updated := Allowlist{ - Files: make(map[string]Entry, len(allow.Files)), - Dirs: make(map[string]Entry, len(allow.Dirs)), - Present: true, +func rewriteAllowlist( + cfg codeshape.Config, allow codeshape.Allowlist, snapshot codeshape.Snapshot, seed bool, +) (codeshape.Allowlist, bool, error) { + updated := codeshape.Allowlist{ + Files: make(map[string]codeshape.Entry, len(allow.Files)), + Dirs: make(map[string]codeshape.Entry, len(allow.Dirs)), + Functions: make(codeshape.FunctionMeasurements), + Present: true, } maps.Copy(updated.Files, allow.Files) maps.Copy(updated.Dirs, allow.Dirs) - changedFiles, fileErrors := rewriteEntries(updated.Files, snapshot.Files, cfg, MetricFiles, seed) - changedDirs, dirErrors := rewriteEntries(updated.Dirs, snapshot.Dirs, cfg, MetricDirs, seed) + changedFiles, fileErrors := rewriteEntries(updated.Files, snapshot.Files, cfg, codeshape.MetricFiles, seed) + changedDirs, dirErrors := rewriteEntries(updated.Dirs, snapshot.Dirs, cfg, codeshape.MetricDirs, seed) + changedFunctions, functionErrors := codeshape.RewriteFunctionEntries( + updated.Functions, allow.Functions, snapshot.Functions, cfg, seed, + ) errs := make([]string, 0, len(fileErrors)+len(dirErrors)) errs = append(errs, fileErrors...) errs = append(errs, dirErrors...) - changed := changedFiles || changedDirs + errs = append(errs, functionErrors...) + changed := changedFiles || changedDirs || changedFunctions sort.Strings(errs) if len(errs) > 0 { return allow, false, fmt.Errorf("%w: %s", errCannotRaiseAllowlist, strings.Join(errs, "; ")) @@ -190,15 +279,15 @@ func rewriteAllowlist(cfg Config, allow Allowlist, snapshot Snapshot, seed bool) } func rewriteEntries( - entries map[string]Entry, + entries map[string]codeshape.Entry, values map[string]int, - cfg Config, - metric Metric, + cfg codeshape.Config, + metric codeshape.Metric, seed bool, ) (bool, []string) { changed := false for path, entry := range entries { - limit := cfg.limitFor(metric, path) + limit := cfg.LimitFor(metric, path) value, exists := values[path] if !exists || value <= limit { delete(entries, path) @@ -211,7 +300,7 @@ func rewriteEntries( } var errs []string for path, value := range values { - limit := cfg.limitFor(metric, path) + limit := cfg.LimitFor(metric, path) if value <= limit { continue } @@ -229,7 +318,7 @@ func rewriteEntries( } else { errMessage := fmt.Sprintf( "%s %s value %d needs --seed to add or raise the allowlist", - displayPath(metric, path), + codeshape.DisplayPath(metric, path), metric, value, ) @@ -244,7 +333,7 @@ func writeOutput(output io.Writer, format string, args ...any) bool { return err == nil } -func writeFindings(output io.Writer, findings []Finding) error { +func writeFindings(output io.Writer, findings []codeshape.Finding) error { for _, finding := range findings { _, err := fmt.Fprintf( output, diff --git a/go/internal/codeshape/cli/cli_test.go b/go/internal/codeshape/cli/cli_test.go new file mode 100644 index 0000000..f56e72d --- /dev/null +++ b/go/internal/codeshape/cli/cli_test.go @@ -0,0 +1,345 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package cli + +import ( + "fmt" + "github.com/RigelBuild/meissa/go/internal/codeshape" + shapeconfig "github.com/RigelBuild/meissa/go/internal/codeshape/config" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestUnmeasuredExtensions(t *testing.T) { + var output strings.Builder + err := reportUnmeasuredExtensions(&output, []string{".go", ".ts", ".js", ".cue"}) + if err != nil || output.String() != "code-shape-gate: no function collector for .js\n"+ + "code-shape-gate: no function collector for .cue\n" { + t.Fatalf("output=%q err=%v", output.String(), err) + } +} + +func TestMainBaseModes(t *testing.T) { + for _, tc := range []struct { + name, base, head, ref string + want int + }{ + {name: "absent at base", base: "", head: "files: {}\n", ref: "HEAD", want: 0}, + {name: "raised", base: `files: "large.go": {value: 501, reason: "split by concern"}`, + head: `files: "large.go": {value: 502, reason: "split by concern"}`, ref: "HEAD", want: 1}, + {name: "lowered", base: `files: "large.go": {value: 503, reason: "split by concern"}`, + head: `files: "large.go": {value: 502, reason: "split by concern"}`, ref: "HEAD", want: 0}, + {name: "invalid ref", base: "files: {}\n", head: "files: {}\n", ref: "missing-ref", want: 2}, + } { + t.Run(tc.name, func(t *testing.T) { + root := newFixtureRepo(t) + writeConfigFiles(t, root, validConfig, tc.base) + if tc.base == "" { + err := os.Remove(filepath.Join(root, "tools/code-shape-gate/allowlist.cue")) + if err != nil { + t.Fatal(err) + } + } + if tc.name == "raised" || tc.name == "lowered" { + trackedFile(t, root, "large.go", strings.Repeat("line\n", 502)) + } + commitFiles(t, root) + writeConfigFiles(t, root, validConfig, tc.head) + var stdout, stderr strings.Builder + code := Main(t.Context(), []string{root}, func(string) string { return tc.ref }, &stdout, &stderr) + if code != tc.want { + t.Fatalf("code=%d stdout=%q stderr=%q want=%d", code, stdout.String(), stderr.String(), tc.want) + } + if tc.name == "invalid ref" && !strings.Contains(stderr.String(), "missing-ref") { + t.Fatalf("git diagnostic missing: %q", stderr.String()) + } + }) + } +} + +func TestMainRelativeRoot(t *testing.T) { + var source strings.Builder + source.WriteString("package p\nfunc Large() {\n") + for range 41 { + source.WriteString("println(1)\n") + } + source.WriteString("}\n") + root := goTestRepo(t, source.String()) + t.Chdir(root) + var stdout, stderr strings.Builder + code := Main(t.Context(), []string{"."}, func(string) string { return "" }, &stdout, &stderr) + if code != 1 || !strings.Contains(stdout.String(), "x.go") { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } +} + +func TestMainRejectsEscapingAllowlist(t *testing.T) { + root := newFixtureRepo(t) + writeConfigFiles(t, root, validConfig, "files: {}\n") + outside := filepath.Join(t.TempDir(), "allowlist.cue") + content := []byte(`files: "gone.go": {value: 501, reason: "split by concern"}`) + err := os.WriteFile(outside, content, 0o600) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "tools/code-shape-gate/allowlist.cue") + err = os.Remove(path) + if err != nil { + t.Fatal(err) + } + err = os.Symlink(outside, path) + if err != nil { + t.Fatal(err) + } + commitFiles(t, root) + var stdout, stderr strings.Builder + code := Main(t.Context(), []string{"--write", root}, func(string) string { return "" }, &stdout, &stderr) + if code != 2 { + t.Fatalf("code=%d stderr=%q", code, stderr.String()) + } + data, err := os.ReadFile(outside) + if err != nil || !strings.Contains(string(data), "gone.go") { + t.Fatalf("outside changed: %q %v", data, err) + } +} + +func TestWriteUpdatedRejectsEscapingAllowlist(t *testing.T) { + root := newFixtureRepo(t) + writeConfigFiles(t, root, validConfig, "files: {}\n") + outside := filepath.Join(t.TempDir(), "allowlist.cue") + err := os.WriteFile(outside, []byte("unchanged"), 0o600) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "tools/code-shape-gate/allowlist.cue") + err = os.Remove(path) + if err != nil { + t.Fatal(err) + } + err = os.Symlink(outside, path) + if err != nil { + t.Fatal(err) + } + allow := codeshape.EmptyAllowlist() + allow.Files["gone.go"] = codeshape.Entry{ID: "", Value: 501, Reason: "split by concern"} + snapshot := codeshape.Snapshot{Files: map[string]int{}, Dirs: map[string]int{}, Functions: nil} + var stdout, stderr strings.Builder + code := writeUpdated(root, testConfig(), allow, codeshape.EmptyAllowlist(), snapshot, false, + &stdout, &stderr, nil, nil, nil) + data, err := os.ReadFile(outside) + if code != 2 || err != nil || string(data) != "unchanged" { + t.Fatalf("code=%d outside=%q error=%v stderr=%q", code, data, err, stderr.String()) + } +} + +func TestParseArgs(t *testing.T) { + for _, args := range [][]string{ + {"--bad", "."}, {"--write", "--write", "."}, {"--write", "--seed", "--seed", "."}, + {"--seed", "."}, {".", "extra"}, + } { + if _, _, _, ok := parseArgs(args); ok { + t.Fatalf("accepted %q", args) + } + } + var out, errout strings.Builder + code := Main(t.Context(), []string{"--help"}, func(string) string { return "" }, &out, &errout) + if code != 0 || !strings.Contains(out.String(), "usage:") { + t.Fatalf("help: %d %q", code, out.String()) + } +} + +func TestMainPrintsBaseAndMeasuredFindings(t *testing.T) { + root := newFixtureRepo(t) + writeConfigFiles(t, root, validConfig, "files: {}\n") + commitFiles(t, root) + writeConfigFiles(t, root, validConfig, `files: "listed.go": {value: 501, reason: "split by concern"}`) + trackedFile(t, root, "listed.go", strings.Repeat("line\n", 501)) + trackedFile(t, root, "unlisted.go", strings.Repeat("line\n", 501)) + cmd := exec.CommandContext(t.Context(), "git", "-C", root, "add", "--all") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git add: %s %v", output, err) + } + var stdout, stderr strings.Builder + code := Main(t.Context(), []string{root}, func(string) string { return "HEAD" }, &stdout, &stderr) + if code != 1 || !strings.Contains(stdout.String(), "unlisted.go") || + !strings.Contains(stdout.String(), "do not add") { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } +} + +func TestSeedPreservesReason(t *testing.T) { + entries := map[string]codeshape.Entry{"large.go": {ID: "", Value: 501, Reason: "keep curated reason"}} + _, errs := rewriteEntries(entries, map[string]int{"large.go": 502}, testConfig(), codeshape.MetricFiles, true) + if len(errs) != 0 || entries["large.go"].Reason != "keep curated reason" || entries["large.go"].Value != 502 { + t.Fatalf("entries=%#v errors=%v", entries, errs) + } +} +func TestConfiguredDirectoryWidths(t *testing.T) { + for _, tc := range []struct { + name, config, want string + count int + }{ + { + name: "within limit", config: `dirLimits: "wide": {limit: 50, reason: "record decisions together"}`, + count: 30, want: "", + }, + { + name: "over limit", config: `dirLimits: "wide": {limit: 50, reason: "record decisions together"}`, + count: 51, want: "wide dirs value=51 limit=50 fix=reorganize the record's decisions\n", + }, + { + name: "overlapping limits", + config: `dirLimits: {"wide": {limit: 50, reason: "record decisions together"}, ` + + `"w*": {limit: 20, reason: "record decisions together"}}`, + count: 21, want: "wide dirs value=21 limit=20 fix=reorganize the record's decisions\n", + }, + {name: "excluded files", config: `exclude: ["wide/**"]`, count: 300, want: ""}, + } { + t.Run(tc.name, func(t *testing.T) { + root := newFixtureRepo(t) + config := strings.Replace(validConfig, "exclude: []", tc.config, 1) + if !strings.Contains(tc.config, "exclude:") { + config += "\nexclude: []\n" + } + writeConfigFiles(t, root, config, "files: {}\ndirs: {}\n") + for index := range tc.count { + trackedFile(t, root, fmt.Sprintf("wide/file-%03d.txt", index), "text\n") + } + commitFiles(t, root) + var stdout, stderr strings.Builder + status := Main(t.Context(), []string{root}, func(string) string { return "" }, &stdout, &stderr) + wantStatus := 0 + if tc.want != "" { + wantStatus = 1 + } + if status != wantStatus || stdout.String() != tc.want { + t.Fatalf("status=%d stdout=%q stderr=%q, want status=%d stdout=%q", + status, stdout.String(), stderr.String(), wantStatus, tc.want) + } + }) + } +} +func TestMainModes(t *testing.T) { + t.Run("write refuses to add without seed", func(t *testing.T) { + root := newFixtureRepo(t) + writeConfigFiles(t, root, validConfig, "files: {}\ndirs: {}\n") + trackedFile(t, root, "large.go", strings.Repeat("line\n", 501)) + commitFiles(t, root) + var stdout, stderr strings.Builder + code := Main(t.Context(), []string{"--write", root}, func(string) string { return "" }, &stdout, &stderr) + if code != 2 || !strings.Contains(stderr.String(), "needs --seed") { + t.Fatalf("Main = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + }) + t.Run("seed writes raised rollout entries", func(t *testing.T) { + root := newFixtureRepo(t) + writeConfigFiles(t, root, validConfig, "files: {}\ndirs: {}\n") + trackedFile(t, root, "large.go", strings.Repeat("line\n", 501)) + commitFiles(t, root) + var stdout, stderr strings.Builder + code := Main(t.Context(), []string{"--write", "--seed", root}, func(string) string { return "" }, &stdout, &stderr) + if code != 0 { + t.Fatalf("Main = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + _, allowlist, err := shapeconfig.Load(root) + if err != nil { + t.Fatal(err) + } + entry, exists := allowlist.Files["large.go"] + if !exists || entry.Value != 501 || entry.Reason != "seeded at rollout, burn down" { + t.Fatalf("seeded entry = %#v, exists=%v", entry, exists) + } + }) +} +func TestMainWriteUpdates(t *testing.T) { + t.Run("write lowers entries", func(t *testing.T) { + root := newFixtureRepo(t) + writeConfigFiles(t, root, validConfig, "files: \"large.go\": {value: 502, reason: \"split by concern\"}\n") + trackedFile(t, root, "large.go", strings.Repeat("line\n", 501)) + commitFiles(t, root) + var stdout, stderr strings.Builder + code := Main(t.Context(), []string{"--write", root}, func(string) string { return "" }, &stdout, &stderr) + if code != 0 { + t.Fatalf("Main = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + _, allowlist, err := shapeconfig.Load(root) + if err != nil { + t.Fatal(err) + } + if got := allowlist.Files["large.go"].Value; got != 501 { + t.Fatalf("lowered value = %d, want 501", got) + } + }) + t.Run("write removes entries at limit", func(t *testing.T) { + root := newFixtureRepo(t) + writeConfigFiles(t, root, validConfig, "files: \"small.go\": {value: 501, reason: \"split by concern\"}\n") + trackedFile(t, root, "small.go", strings.Repeat("line\n", 500)) + commitFiles(t, root) + var stdout, stderr strings.Builder + code := Main(t.Context(), []string{"--write", root}, func(string) string { return "" }, &stdout, &stderr) + if code != 0 { + t.Fatalf("Main = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + _, allowlist, err := shapeconfig.Load(root) + if err != nil { + t.Fatal(err) + } + if _, exists := allowlist.Files["small.go"]; exists { + t.Fatal("--write retained an entry now within the limit") + } + }) + t.Run("base rejects a seeded added entry", func(t *testing.T) { + root := newFixtureRepo(t) + writeConfigFiles(t, root, validConfig, "files: {}\ndirs: {}\n") + trackedFile(t, root, "large.go", strings.Repeat("line\n", 501)) + commitFiles(t, root) + var stdout, stderr strings.Builder + code := Main(t.Context(), []string{"--write", "--seed", root}, func(key string) string { + if key == "GATE_BASE" { + return "HEAD" + } + return "" + }, &stdout, &stderr) + if code != 1 || !strings.Contains(stdout.String(), "do not add or raise") { + t.Fatalf("Main = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + }) +} +func TestRewriteFunctionEntries(t *testing.T) { + cfg := testConfig() + cfg.Functions.Go.Lines = 60 + allow := codeshape.Allowlist{Files: nil, Dirs: nil, Present: false, + Functions: codeshape.FunctionMeasurements{codeshape.MetricGoLines: {"f.go": { + {ID: "lower", Value: 80, Reason: "split by concern"}, + {ID: "remove", Value: 70, Reason: "split by concern"}, + }}}} + values := codeshape.FunctionMeasurements{codeshape.MetricGoLines: {"f.go": { + {ID: "lower", Value: 70, Reason: ""}, {ID: "new", Value: 61, Reason: ""}, + }}} + _, _, err := rewriteAllowlist(cfg, allow, codeshape.Snapshot{Files: nil, Dirs: nil, Functions: values}, false) + if err == nil { + t.Fatal("write accepted a new offender") + } + updated, changed, err := rewriteAllowlist( + cfg, allow, codeshape.Snapshot{Files: nil, Dirs: nil, Functions: values}, true, + ) + if err != nil || !changed { + t.Fatalf("seed: changed=%v err=%v", changed, err) + } + got := updated.Functions[codeshape.MetricGoLines]["f.go"] + if len(got) != 2 { + t.Fatalf("seed entries: %#v", got) + } + lower, ok := functionEntry(got, "lower") + if !ok || lower.Value != 70 || lower.Reason != "split by concern" { + t.Fatalf("lowered entry: %#v", lower) + } + added, ok := functionEntry(got, "new") + if !ok || added.Value != 61 || added.Reason == "" { + t.Fatalf("seeded entry: %#v", added) + } +} diff --git a/go/internal/codeshape/cli/collectors_test.go b/go/internal/codeshape/cli/collectors_test.go new file mode 100644 index 0000000..afbcf02 --- /dev/null +++ b/go/internal/codeshape/cli/collectors_test.go @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func goTestRepo(t *testing.T, source string) string { + t.Helper() + root := newFixtureRepo(t) + writeConfigFiles(t, root, validConfig, "files: {}\n") + trackedFile(t, root, "go.mod", "module example.com/fixture\n\ngo 1.25\n") + trackedFile(t, root, "x.go", source) + commitFiles(t, root) + return root +} +func runGoGate(t *testing.T, root string) (int, string, string) { + t.Helper() + var out, errout strings.Builder + code := Main(t.Context(), []string{root}, func(string) string { return "" }, &out, &errout) + return code, out.String(), errout.String() +} +func TestSkipOnlyModuleIsValid(t *testing.T) { + root := goTestRepo(t, "//go:build integration\n\npackage p\nconst X = 1\n") + code, out, stderr := runGoGate(t, root) + if code != 0 || out != "" || !strings.Contains(stderr, "code-shape-gate: skip x.go: not in default Go build\n") { + t.Fatalf("code=%d out=%q stderr=%q", code, out, stderr) + } +} + +func TestSkippedGoFunctionsAreFindings(t *testing.T) { + root := goTestRepo(t, "//go:build integration\n\npackage p\nfunc F() {}\n") + code, out, stderr := runGoGate(t, root) + if code != 1 || !strings.Contains(out, "x.go") || !strings.Contains(stderr, "not in default Go build") { + t.Fatalf("code=%d out=%q stderr=%q", code, out, stderr) + } +} + +func TestBiomeDashPrefixedFilename(t *testing.T) { + root := tsRepo(t, "function healthy() {}\n") + trackedFile(t, root, "--only=x.ts", "function large() {\n"+strings.Repeat("console.log(1);\n", 51)+"}\n") + commitFiles(t, root) + code, out, stderr := runGoGate(t, root) + if code != 1 || !strings.Contains(out, "--only=x.ts#large ts-lines") { + t.Fatalf("code=%d out=%q stderr=%q", code, out, stderr) + } +} + +func TestGoLimitsCLI(t *testing.T) { + var source strings.Builder + source.WriteString("package p\nfunc Cognitive(x int) {\n") + for range 21 { + source.WriteString("if x > 0 { println(x) }\n") + } + source.WriteString("}\nfunc Lines() {\n") + for range 60 { + source.WriteString("\n") + } + source.WriteString("println(1)\n}\nfunc Statements() {\n") + for range 41 { + source.WriteString("println(1)\n") + } + source.WriteString("}\n") + root := goTestRepo(t, source.String()) + code, out, errout := runGoGate(t, root) + if code != 1 || !strings.Contains(out, "go-cognitive value=21") || + !strings.Contains(out, "go-lines value=61") || !strings.Contains(out, "go-statements value=41") { + t.Fatalf("code=%d out=%s stderr=%s", code, out, errout) + } +} +func TestGoMissingBinary(t *testing.T) { + root := goTestRepo(t, "package p\nfunc F() {}\n") + dir := t.TempDir() + git, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + err = os.Symlink(git, filepath.Join(dir, "git")) + if err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + code, _, errout := runGoGate(t, root) + if code != 2 || !strings.Contains(errout, "golangci-lint") { + t.Fatalf("code=%d stderr=%s", code, errout) + } +} +func fakeGoLint(t *testing.T, output string) { + t.Helper() + dir := t.TempDir() + script := "#!/bin/sh\nif [ \"$1\" = --version ]; then echo 'golangci-lint has version 2.15.0'; exit 0; fi\n" + + "printf '%s' '" + output + "'\nexit 1\n" + err := os.WriteFile(filepath.Join(dir, "golangci-lint"), []byte(script), 0o755) + if err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} +func TestGoTruncatedOutput(t *testing.T) { + root := goTestRepo(t, "package p\nfunc F() {}\n") + fakeGoLint(t, `{"Issues":[`) + code, _, errout := runGoGate(t, root) + if code != 2 || !strings.Contains(errout, "parse golangci-lint") { + t.Fatalf("code=%d stderr=%s", code, errout) + } +} +func TestGoUnparseableIssue(t *testing.T) { + root := goTestRepo(t, "package p\nfunc F() {}\n") + fakeGoLint(t, `{"Issues":[{"FromLinter":"gocognit","Text":"unexpected",`+ + `"Pos":{"Filename":"x.go","Line":2,"Column":1}}],`+ + `"Report":{"Linters":[{"Name":"gocognit","Enabled":true}]}}`) + code, _, errout := runGoGate(t, root) + if code != 2 || !strings.Contains(errout, "cannot parse") { + t.Fatalf("code=%d stderr=%s", code, errout) + } +} + +const tsBiomeConfig = `{"linter":{"rules":{"complexity":{"noExcessiveCognitiveComplexity":` + + `{"level":"warn","options":{"maxAllowedComplexity":15}},` + + `"noExcessiveLinesPerFunction":{"level":"warn","options":{"maxLines":50}}}}}}` + +func tsRepo(t *testing.T, source string) string { + t.Helper() + root := newFixtureRepo(t) + writeConfigFiles(t, root, strings.Replace(validConfig, `".go", ".cue"`, `".go", ".cue", ".ts"`, 1), "files: {}\n") + trackedFile(t, root, "biome.json", tsBiomeConfig) + trackedFile(t, root, "x.ts", source) + commitFiles(t, root) + return root +} + +func TestSuppressionCoexistsWithJudgeAndSeed(t *testing.T) { + root := tsRepo(t, "// biome-ignore lint: reason\nfunction f() {}\n") + trackedFile(t, root, "large.go", strings.Repeat("line\n", 501)) + commitFiles(t, root) + code, out, stderr := runGoGate(t, root) + if code != 1 || !strings.Contains(out, "x.ts:1") || !strings.Contains(out, "large.go") { + t.Fatalf("code=%d out=%q stderr=%q", code, out, stderr) + } + var stdout, errout strings.Builder + code = Main(t.Context(), []string{"--write", "--seed", root}, func(string) string { return "" }, &stdout, &errout) + allow, err := os.ReadFile(filepath.Join(root, "tools/code-shape-gate/allowlist.cue")) + if code != 1 || err != nil || !strings.Contains(string(allow), "large.go") || + !strings.Contains(stdout.String(), "x.ts:1") { + t.Fatalf("code=%d out=%q stderr=%q allow=%q err=%v", code, stdout.String(), errout.String(), allow, err) + } +} +func TestTSCognitiveLimit(t *testing.T) { + root := tsRepo(t, "function f(x: boolean) {\n"+strings.Repeat("if (x) { console.log(x); }\n", 16)+"}\n") + code, out, errout := runGoGate(t, root) + if code != 1 || !strings.Contains(out, "ts-cognitive value=16") { + t.Fatalf("code=%d out=%s err=%s", code, out, errout) + } +} +func TestTSLinesLimit(t *testing.T) { + root := tsRepo(t, "function f() {\n"+strings.Repeat("console.log(1);\n", 51)+"}\n") + code, out, errout := runGoGate(t, root) + if code != 1 || !strings.Contains(out, "ts-lines value=51") { + t.Fatalf("code=%d out=%s err=%s", code, out, errout) + } +} + +func TestBiomeMeasuredErrors(t *testing.T) { + source := "function f(x: boolean) {\n" + strings.Repeat("if (x) { console.log(x); }\n", 51) + "}\n" + root := tsRepo(t, source) + trackedFile(t, root, "biome.json", strings.ReplaceAll(tsBiomeConfig, `"warn"`, `"error"`)) + commitFiles(t, root) + code, out, stderr := runGoGate(t, root) + if code != 1 || !strings.Contains(out, "ts-lines") || !strings.Contains(out, "ts-cognitive") { + t.Fatalf("code=%d out=%q stderr=%q", code, out, stderr) + } +} + +func TestBiomeAstralColumnAttribution(t *testing.T) { + source := "function outer() { const x = '" + strings.Repeat("😀", 20) + "'; return () => {\n" + + strings.Repeat("console.log(1);\n", 51) + "}; }\n" + root := tsRepo(t, source) + code, out, stderr := runGoGate(t, root) + if code != 1 || !strings.Contains(out, "x.ts#outer/anonymous") { + t.Fatalf("code=%d out=%q stderr=%q", code, out, stderr) + } +} +func TestTSDrift(t *testing.T) { + root := tsRepo(t, "function f() {}\n") + err := os.WriteFile(filepath.Join(root, "biome.json"), + []byte(strings.Replace(tsBiomeConfig, `"maxLines":50`, `"maxLines":49`, 1)), 0600) + if err != nil { + t.Fatal(err) + } + code, out, errout := runGoGate(t, root) + if code != 1 || !strings.Contains(out, "biome.json") { + t.Fatalf("code=%d out=%s err=%s", code, out, errout) + } +} +func TestTSSuppression(t *testing.T) { + root := tsRepo(t, "// biome-ignore lint/complexity/noExcessiveLinesPerFunction: reason\nfunction f() {}\n") + code, out, errout := runGoGate(t, root) + if code != 1 || !strings.Contains(out, "x.ts:1") { + t.Fatalf("code=%d out=%s err=%s", code, out, errout) + } +} +func TestTSMissingBinary(t *testing.T) { + root := tsRepo(t, "function f() {}\n") + dir := t.TempDir() + git, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + err = os.Symlink(git, filepath.Join(dir, "git")) + if err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + code, _, errout := runGoGate(t, root) + if code != 2 || !strings.Contains(errout, "biome") { + t.Fatalf("code=%d err=%s", code, errout) + } +} +func TestTSTruncatedJSON(t *testing.T) { + root := tsRepo(t, "function f() {}\n") + dir := t.TempDir() + script := "#!/bin/sh\nif [ \"$1\" = --version ]; then echo 'Version: 2.5.15'; exit 0; fi\n" + + "printf '{\"summary\":'\n" + err := os.WriteFile(filepath.Join(dir, "biome"), []byte(script), 0755) + if err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + code, _, errout := runGoGate(t, root) + if code != 2 || !strings.Contains(errout, "parse biome") { + t.Fatalf("code=%d err=%s", code, errout) + } +} diff --git a/go/internal/codeshape/cli/failclosed_test.go b/go/internal/codeshape/cli/failclosed_test.go new file mode 100644 index 0000000..1fd1534 --- /dev/null +++ b/go/internal/codeshape/cli/failclosed_test.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package cli + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func fakeCollector(t *testing.T, name, version, output string, status int) { + t.Helper() + dir := t.TempDir() + script := fmt.Sprintf("#!/bin/sh\nif [ \"$1\" = --version ]; then echo '%s'; exit 0; fi\n"+ + "printf '%%s' '%s'\nexit %d\n", version, output, status) + err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0700) + if err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func TestBiomeFailClosed(t *testing.T) { + for _, tc := range []struct { + name, output string + status int + }{ + {"truncated", `{"summary":{"changed":1,"diagnosticsNotPrinted":1,"errors":0},"diagnostics":[]}`, 0}, + {"errors", `{"summary":{"changed":1,"diagnosticsNotPrinted":0,"errors":1},"diagnostics":[]}`, 0}, + {"unprocessed", `{"summary":{"changed":0,"unchanged":0,"diagnosticsNotPrinted":0,"errors":0},"diagnostics":[]}`, 0}, + {"exit", `{"summary":{"changed":1,"diagnosticsNotPrinted":0,"errors":0},"diagnostics":[]}`, 2}, + } { + t.Run(tc.name, func(t *testing.T) { + root := tsRepo(t, "function f() {}\n") + fakeCollector(t, "biome", "Version: 2.5.15", tc.output, tc.status) + code, _, stderr := runGoGate(t, root) + if code != 2 { + t.Fatalf("code=%d stderr=%q", code, stderr) + } + if tc.name == "unprocessed" && !strings.Contains(stderr, "did not process all requested paths") { + t.Fatalf("stderr=%q, want unprocessed-path error", stderr) + } + }) + } +} + +func TestBiomeParseFailureIsFinding(t *testing.T) { + root := tsRepo(t, "function broken( {\n") + code, out, stderr := runGoGate(t, root) + if code != 1 || !strings.Contains(out, "unmeasured value=0 limit=0") || !strings.Contains(out, "Biome parse error") { + t.Fatalf("code=%d out=%q stderr=%q", code, out, stderr) + } +} + +func TestGoLintFailClosed(t *testing.T) { + for _, tc := range []struct { + name, output string + status int + }{ + {"report error", `{"Issues":[],"Report":{"Error":"broken","Linters":[{"Name":"gocognit","Enabled":true}]}}`, 0}, + {"exit", `{}`, 2}, + } { + t.Run(tc.name, func(t *testing.T) { + root := goTestRepo(t, "package p\nfunc F() {}\n") + fakeCollector(t, "golangci-lint", "golangci-lint has version 2.15.0", tc.output, tc.status) + code, _, stderr := runGoGate(t, root) + if code != 2 { + t.Fatalf("code=%d stderr=%q", code, stderr) + } + }) + } +} + +func TestGoTypecheckIssue(t *testing.T) { + root := goTestRepo(t, "package p\nfunc F() {}\n") + output := `{"Issues":[{"FromLinter":"typecheck","Text":"bad type","Pos":{"Filename":"x.go","Line":2}}],` + + `"Report":{"Linters":[{"Name":"gocognit","Enabled":true}]}}` + fakeCollector(t, "golangci-lint", "golangci-lint has version 2.15.0", output, 1) + code, _, stderr := runGoGate(t, root) + if code != 2 || !strings.Contains(stderr, "typecheck issue: bad type") { + t.Fatalf("code=%d stderr=%q", code, stderr) + } +} + +func TestTwoLargeInitFunctions(t *testing.T) { + body := strings.Repeat("println(1)\n", 41) + root := goTestRepo(t, "package p\nfunc init() {\n"+body+"}\nfunc init() {\n"+body+"}\n") + code, stdout, stderr := runGoGate(t, root) + if code != 1 || !strings.Contains(stdout, "init#1") || !strings.Contains(stdout, "init#2") { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout, stderr) + } +} diff --git a/go/internal/codeshape/cli/fixtures_test.go b/go/internal/codeshape/cli/fixtures_test.go new file mode 100644 index 0000000..4f64903 --- /dev/null +++ b/go/internal/codeshape/cli/fixtures_test.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package cli + +import ( + "github.com/RigelBuild/meissa/go/internal/codeshape" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func newFixtureRepo(t *testing.T) string { + t.Helper() + root := t.TempDir() + command := exec.CommandContext(t.Context(), "git", "init", "-q") + command.Dir = root + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git init: %v: %s", err, output) + } + command = exec.CommandContext(t.Context(), "git", "-C", root, "config", "user.email", "test@example.invalid") + output, err = command.CombinedOutput() + if err != nil { + t.Fatalf("git config: %v: %s", err, output) + } + command = exec.CommandContext(t.Context(), "git", "-C", root, "config", "user.name", "test") + output, err = command.CombinedOutput() + if err != nil { + t.Fatalf("git config: %v: %s", err, output) + } + return root +} + +func trackedFile(t *testing.T, root, path, content string) { + t.Helper() + fullPath := filepath.Join(root, filepath.FromSlash(path)) + err := os.MkdirAll(filepath.Dir(fullPath), 0o755) + if err != nil { + t.Fatal(err) + } + err = os.WriteFile(fullPath, []byte(content), 0o644) + if err != nil { + t.Fatal(err) + } +} + +func commitFiles(t *testing.T, root string) { + t.Helper() + command := exec.CommandContext(t.Context(), "git", "-C", root, "add", "--all") + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git add: %v: %s", err, output) + } + command = exec.CommandContext(t.Context(), "git", "-C", root, "commit", "-qm", "fixture") + output, err = command.CombinedOutput() + if err != nil { + t.Fatalf("git commit: %v: %s", err, output) + } +} +func testConfig() codeshape.Config { + return codeshape.Config{ + FileLimit: 500, DirLimit: 10, Extensions: []string{".go", ".cue"}, + Exclude: []string{}, DirLimits: map[string]codeshape.DirLimit{}, + Functions: codeshape.FunctionLimits{Go: codeshape.GoFunctionLimits{Cognitive: 0, Lines: 0, Statements: 0}, + TS: codeshape.TSFunctionLimits{Cognitive: 0, Lines: 0}}, + } +} + +const validConfig = "fileLimit: 500\ndirLimit: 10\nextensions: [\".go\", \".cue\"]\nexclude: []\n" + + "functions: {go: {cognitive: 20, lines: 60, statements: 40}, ts: {cognitive: 15, lines: 50}}\n" + +func writeConfigFiles(t *testing.T, root, config, allowlist string) { + t.Helper() + dir := filepath.Join(root, "tools", "code-shape-gate") + err := os.MkdirAll(dir, 0o755) + if err != nil { + t.Fatal(err) + } + for name, content := range map[string]string{"config.cue": config, "allowlist.cue": allowlist} { + err = os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644) + if err != nil { + t.Fatal(err) + } + } +} +func functionEntry(entries []codeshape.Entry, id string) (codeshape.Entry, bool) { + for _, entry := range entries { + if entry.ID == id { + return entry, true + } + } + return codeshape.Entry{ID: "", Value: 0, Reason: ""}, false +} diff --git a/go/internal/codeshape/cli/testmain_test.go b/go/internal/codeshape/cli/testmain_test.go new file mode 100644 index 0000000..6e857a2 --- /dev/null +++ b/go/internal/codeshape/cli/testmain_test.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package cli + +import ( + "fmt" + "os" + "testing" +) + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "codeshape-cli-test-") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + err = os.Setenv("TMPDIR", dir) + if err != nil { + fmt.Fprintln(os.Stderr, err) + fmt.Fprintln(os.Stderr, os.RemoveAll(dir)) + os.Exit(1) + } + code := m.Run() + err = os.RemoveAll(dir) + if err != nil { + fmt.Fprintln(os.Stderr, err) + code = 1 + } + os.Exit(code) +} diff --git a/go/internal/codeshape/cli/unmeasured.go b/go/internal/codeshape/cli/unmeasured.go new file mode 100644 index 0000000..9459716 --- /dev/null +++ b/go/internal/codeshape/cli/unmeasured.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package cli + +import ( + "fmt" + "io" + "maps" + + "github.com/RigelBuild/meissa/go/internal/codeshape" +) + +func handleTSSkips(output io.Writer, skips []codeshape.Skip, cfg codeshape.Config) ( + []codeshape.Finding, []string, error, +) { + findings := []codeshape.Finding{} + paths := []string{} + for _, skip := range skips { + if skip.Unmeasured != nil { + paths = append(paths, skip.Path) + if skip.Unmeasured.BiomeParseFailed { + findings = append(findings, codeshape.Finding{Path: skip.Path, Metric: codeshape.MetricUnmeasured, + Value: 0, Limit: 0, Fix: skip.Reason + "; exclude it or fix the parse"}) + } + for metric, value := range skip.Unmeasured.Values { + findings = append(findings, codeshape.Finding{Path: skip.Path, Metric: metric, Value: value, + Limit: cfg.Functions.LimitFor(metric), Fix: "unparseable by the function collector; exclude it or fix the parse"}) + } + } + _, err := fmt.Fprintf(output, "code-shape-gate: skip %s: %s\n", skip.Path, skip.Reason) + if err != nil { + return nil, nil, fmt.Errorf("write skip: %w", err) + } + } + return findings, paths, nil +} + +func separateUnmeasured(allow *codeshape.Allowlist, paths []string) codeshape.FunctionMeasurements { + preserved := make(codeshape.FunctionMeasurements) + for metric, entries := range allow.Functions { + copyEntries := make(map[string][]codeshape.Entry, len(entries)) + maps.Copy(copyEntries, entries) + allow.Functions[metric] = copyEntries + for _, path := range paths { + if functions, ok := copyEntries[path]; ok { + if preserved[metric] == nil { + preserved[metric] = map[string][]codeshape.Entry{} + } + preserved[metric][path] = functions + delete(copyEntries, path) + } + } + } + return preserved +} + +func restoreUnmeasured(allow *codeshape.Allowlist, preserved codeshape.FunctionMeasurements) { + for metric, paths := range preserved { + if allow.Functions[metric] == nil { + allow.Functions[metric] = map[string][]codeshape.Entry{} + } + maps.Copy(allow.Functions[metric], paths) + } +} + +func handleGoSkips(output io.Writer, skips []codeshape.Skip) ([]codeshape.Finding, []string, error) { + var findings []codeshape.Finding + var paths []string + for _, skip := range skips { + _, err := fmt.Fprintf(output, "code-shape-gate: skip %s: %s\n", skip.Path, skip.Reason) + if err != nil { + return nil, nil, fmt.Errorf("write skip: %w", err) + } + if skip.Unmeasured != nil { + paths = append(paths, skip.Path) + findings = append(findings, codeshape.Finding{Path: skip.Path, Metric: codeshape.MetricUnmeasured, + Value: 0, Limit: 0, Fix: skip.Reason + "; exclude it or include it in the default Go build"}) + } + } + return findings, paths, nil +} diff --git a/go/internal/codeshape/cli/unmeasured_test.go b/go/internal/codeshape/cli/unmeasured_test.go new file mode 100644 index 0000000..54c9b74 --- /dev/null +++ b/go/internal/codeshape/cli/unmeasured_test.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/RigelBuild/meissa/go/internal/codeshape" +) + +func TestUnmeasuredSkipFinding(t *testing.T) { + skips := []codeshape.Skip{ + {Path: "plain.ts", Reason: "no functions", Unmeasured: nil}, + {Path: "bad.ts", Reason: "parse error", Unmeasured: &codeshape.UnmeasuredFunctions{ + Values: map[codeshape.Metric]int{codeshape.MetricTSLines: 70}, BiomeParseFailed: false}}, + } + var output strings.Builder + findings, paths, err := handleTSSkips(&output, skips, testConfig()) + if err != nil || len(findings) != 1 || findings[0].Path != "bad.ts" || findings[0].Value != 70 || + len(paths) != 1 || paths[0] != "bad.ts" { + t.Fatalf("findings=%v paths=%v err=%v", findings, paths, err) + } + if !strings.Contains(output.String(), "skip plain.ts: no functions") { + t.Fatal(output.String()) + } +} + +func TestUnparseableTypeScriptKeepsAllowlist(t *testing.T) { + root := tsRepo(t, "export type * from './other';\nfunction f() {\n"+strings.Repeat("console.log(1);\n", 51)+"}\n") + allow := "functions: {\"ts-lines\": {\"x.ts\": [{id: \"f\", value: 80, reason: \"split this function\"}]}}\n" + trackedFile(t, root, "tools/code-shape-gate/allowlist.cue", allow) + var out, stderr strings.Builder + code := Main(t.Context(), []string{"--write", root}, func(string) string { return "" }, &out, &stderr) + data, err := os.ReadFile(filepath.Join(root, "tools/code-shape-gate/allowlist.cue")) + if err != nil || code != 1 || !strings.Contains(out.String(), "unparseable by the function collector") || + string(data) != allow { + t.Fatalf("code=%d out=%q stderr=%q allow=%q err=%v", code, out.String(), stderr.String(), data, err) + } +} + +func TestUnparseableFileCannotRaiseBaseEntry(t *testing.T) { + root := tsRepo(t, "export type * from './other';\n") + base := "functions: {\"ts-lines\": {\"x.ts\": [{id: \"f\", value: 60, reason: \"split this function\"}]}}\n" + trackedFile(t, root, "tools/code-shape-gate/allowlist.cue", base) + commitFiles(t, root) + trackedFile(t, root, "tools/code-shape-gate/allowlist.cue", strings.Replace(base, "60", "80", 1)) + for _, args := range [][]string{{root}, {"--write", root}} { + var out, stderr strings.Builder + code := Main(t.Context(), args, func(string) string { return "HEAD" }, &out, &stderr) + if code != 1 || !strings.Contains(out.String(), "do not add or raise allowlist entries") { + t.Fatalf("args=%v code=%d out=%q stderr=%q", args, code, out.String(), stderr.String()) + } + } +} diff --git a/go/internal/codeshape/cli_test.go b/go/internal/codeshape/cli_test.go deleted file mode 100644 index 6218025..0000000 --- a/go/internal/codeshape/cli_test.go +++ /dev/null @@ -1,151 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package codeshape - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func TestMainBaseModes(t *testing.T) { - for _, tc := range []struct { - name, base, head, ref string - want int - }{ - {name: "absent at base", base: "", head: "files: {}\n", ref: "HEAD", want: 0}, - {name: "raised", base: `files: "large.go": {value: 501, reason: "split by concern"}`, - head: `files: "large.go": {value: 502, reason: "split by concern"}`, ref: "HEAD", want: 1}, - {name: "lowered", base: `files: "large.go": {value: 503, reason: "split by concern"}`, - head: `files: "large.go": {value: 502, reason: "split by concern"}`, ref: "HEAD", want: 0}, - {name: "invalid ref", base: "files: {}\n", head: "files: {}\n", ref: "missing-ref", want: 2}, - } { - t.Run(tc.name, func(t *testing.T) { - root := newFixtureRepo(t) - writeConfigFiles(t, root, validConfig, tc.base) - if tc.base == "" { - err := os.Remove(filepath.Join(root, "tools/code-shape-gate/allowlist.cue")) - if err != nil { - t.Fatal(err) - } - } - if tc.name == "raised" || tc.name == "lowered" { - trackedFile(t, root, "large.go", strings.Repeat("line\n", 502)) - } - commitFiles(t, root) - writeConfigFiles(t, root, validConfig, tc.head) - var stdout, stderr strings.Builder - code := Main(t.Context(), []string{root}, func(string) string { return tc.ref }, &stdout, &stderr) - if code != tc.want { - t.Fatalf("code=%d stdout=%q stderr=%q want=%d", code, stdout.String(), stderr.String(), tc.want) - } - if tc.name == "invalid ref" && !strings.Contains(stderr.String(), "missing-ref") { - t.Fatalf("git diagnostic missing: %q", stderr.String()) - } - }) - } -} - -func TestMainRejectsEscapingAllowlist(t *testing.T) { - root := newFixtureRepo(t) - writeConfigFiles(t, root, validConfig, "files: {}\n") - outside := filepath.Join(t.TempDir(), "allowlist.cue") - content := []byte(`files: "gone.go": {value: 501, reason: "split by concern"}`) - err := os.WriteFile(outside, content, 0o600) - if err != nil { - t.Fatal(err) - } - path := filepath.Join(root, "tools/code-shape-gate/allowlist.cue") - err = os.Remove(path) - if err != nil { - t.Fatal(err) - } - err = os.Symlink(outside, path) - if err != nil { - t.Fatal(err) - } - commitFiles(t, root) - var stdout, stderr strings.Builder - code := Main(t.Context(), []string{"--write", root}, func(string) string { return "" }, &stdout, &stderr) - if code != 2 { - t.Fatalf("code=%d stderr=%q", code, stderr.String()) - } - data, err := os.ReadFile(outside) - if err != nil || !strings.Contains(string(data), "gone.go") { - t.Fatalf("outside changed: %q %v", data, err) - } -} - -func TestWriteUpdatedRejectsEscapingAllowlist(t *testing.T) { - root := newFixtureRepo(t) - writeConfigFiles(t, root, validConfig, "files: {}\n") - outside := filepath.Join(t.TempDir(), "allowlist.cue") - err := os.WriteFile(outside, []byte("unchanged"), 0o600) - if err != nil { - t.Fatal(err) - } - path := filepath.Join(root, "tools/code-shape-gate/allowlist.cue") - err = os.Remove(path) - if err != nil { - t.Fatal(err) - } - err = os.Symlink(outside, path) - if err != nil { - t.Fatal(err) - } - allow := emptyAllowlist() - allow.Files["gone.go"] = Entry{Value: 501, Reason: "split by concern"} - snapshot := Snapshot{Files: map[string]int{}, Dirs: map[string]int{}} - var stdout, stderr strings.Builder - code := writeUpdated(root, testConfig(), allow, emptyAllowlist(), snapshot, false, &stdout, &stderr) - data, err := os.ReadFile(outside) - if code != 2 || err != nil || string(data) != "unchanged" { - t.Fatalf("code=%d outside=%q error=%v stderr=%q", code, data, err, stderr.String()) - } -} - -func TestParseArgs(t *testing.T) { - for _, args := range [][]string{ - {"--bad", "."}, {"--write", "--write", "."}, {"--write", "--seed", "--seed", "."}, - {"--seed", "."}, {".", "extra"}, - } { - if _, _, _, ok := parseArgs(args); ok { - t.Fatalf("accepted %q", args) - } - } - var out, errout strings.Builder - code := Main(t.Context(), []string{"--help"}, func(string) string { return "" }, &out, &errout) - if code != 0 || !strings.Contains(out.String(), "usage:") { - t.Fatalf("help: %d %q", code, out.String()) - } -} - -func TestMainPrintsBaseAndMeasuredFindings(t *testing.T) { - root := newFixtureRepo(t) - writeConfigFiles(t, root, validConfig, "files: {}\n") - commitFiles(t, root) - writeConfigFiles(t, root, validConfig, `files: "listed.go": {value: 501, reason: "split by concern"}`) - trackedFile(t, root, "listed.go", strings.Repeat("line\n", 501)) - trackedFile(t, root, "unlisted.go", strings.Repeat("line\n", 501)) - cmd := exec.CommandContext(t.Context(), "git", "-C", root, "add", "--all") - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git add: %s %v", output, err) - } - var stdout, stderr strings.Builder - code := Main(t.Context(), []string{root}, func(string) string { return "HEAD" }, &stdout, &stderr) - if code != 1 || !strings.Contains(stdout.String(), "unlisted.go") || - !strings.Contains(stdout.String(), "do not add") { - t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) - } -} - -func TestSeedPreservesReason(t *testing.T) { - entries := map[string]Entry{"large.go": {Value: 501, Reason: "keep curated reason"}} - _, errs := rewriteEntries(entries, map[string]int{"large.go": 502}, testConfig(), MetricFiles, true) - if len(errs) != 0 || entries["large.go"].Reason != "keep curated reason" || entries["large.go"].Value != 502 { - t.Fatalf("entries=%#v errors=%v", entries, errs) - } -} diff --git a/go/internal/codeshape/collector.go b/go/internal/codeshape/collector.go new file mode 100644 index 0000000..56c92fa --- /dev/null +++ b/go/internal/codeshape/collector.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +// Package codeshape defines code-shape measurements and judgments. +package codeshape + +import ( + "errors" + "fmt" + "os" +) + +// ReadRepositoryFile reads a path confined to the repository root. +func ReadRepositoryFile(root, path string) ([]byte, error) { + repository, err := os.OpenRoot(root) + if err != nil { + return nil, fmt.Errorf("open repository root: %w", err) + } + data, readErr := repository.ReadFile(path) + closeErr := repository.Close() + return data, errors.Join(readErr, closeErr) +} diff --git a/go/internal/codeshape/config/fixtures_test.go b/go/internal/codeshape/config/fixtures_test.go new file mode 100644 index 0000000..4cc6448 --- /dev/null +++ b/go/internal/codeshape/config/fixtures_test.go @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package config + +import ( + "os/exec" + "testing" +) + +func newFixtureRepo(t *testing.T) string { + t.Helper() + root := t.TempDir() + cmd := exec.CommandContext(t.Context(), "git", "init", "-q") + cmd.Dir = root + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git init: %v: %s", err, output) + } + return root +} diff --git a/go/internal/codeshape/load.go b/go/internal/codeshape/config/load.go similarity index 59% rename from go/internal/codeshape/load.go rename to go/internal/codeshape/config/load.go index 54eeb69..9e1e61d 100644 --- a/go/internal/codeshape/load.go +++ b/go/internal/codeshape/config/load.go @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -// Package codeshape measures tracked source and directory sizes. -package codeshape +// Package config loads and renders code shape configuration. +package config import ( "embed" @@ -15,6 +15,8 @@ import ( "cuelang.org/go/cue/ast" "cuelang.org/go/cue/cuecontext" "cuelang.org/go/cue/parser" + + "github.com/RigelBuild/meissa/go/internal/codeshape" ) var ( @@ -22,73 +24,13 @@ var ( errInvalidExtension = errors.New("invalid source extension") errImportsForbidden = errors.New("imports are not permitted") errDuplicateRoot = errors.New("duplicate root directory entries") + errUnsupportedMetric = errors.New("unsupported function metric") + errFunctionLimits = errors.New("function limits must be positive") ) //go:embed schema.cue var schemaFiles embed.FS -// Metric identifies one measured code-shape dimension. -type Metric string - -const ( - // MetricFiles identifies file-size measurements. - MetricFiles Metric = "files" - // MetricDirs identifies directory-width measurements. - MetricDirs Metric = "dirs" -) - -// Config contains the repository's file and directory thresholds. -type Config struct { - FileLimit int `json:"fileLimit"` - DirLimit int `json:"dirLimit"` - Extensions []string `json:"extensions"` - Exclude []string `json:"exclude"` - DirLimits map[string]DirLimit `json:"dirLimits"` -} - -// DirLimit records a directory width exception and its reason. -type DirLimit struct { - Limit int `json:"limit"` - Reason string `json:"reason"` -} - -// Entry records a measured value and its burn-down reason. -type Entry struct { - Value int `json:"value"` - Reason string `json:"reason"` -} - -// Allowlist contains file and directory ratchet entries. -// Present distinguishes a loaded base allowlist from head-only mode. -type Allowlist struct { - Files map[string]Entry `json:"files,omitempty"` - Dirs map[string]Entry `json:"dirs,omitempty"` - Present bool `json:"-"` -} - -func emptyAllowlist() Allowlist { - return Allowlist{ - Files: map[string]Entry{}, - Dirs: map[string]Entry{}, - Present: false, - } -} - -// Snapshot contains current measurements. -type Snapshot struct { - Files map[string]int - Dirs map[string]int -} - -// Finding describes one enforced size or ratchet violation. -type Finding struct { - Path string - Metric Metric - Value int - Limit int - Fix string -} - func schemaValue(ctx *cue.Context) (cue.Value, error) { data, err := schemaFiles.ReadFile("schema.cue") if err != nil { @@ -102,11 +44,21 @@ func schemaValue(ctx *cue.Context) (cue.Value, error) { return value, nil } +// DecodeBaseAllowlist decodes an allowlist from a base revision. +func DecodeBaseAllowlist(data []byte) (codeshape.Allowlist, error) { + ctx := cuecontext.New() + schema, err := schemaValue(ctx) + if err != nil { + return codeshape.Allowlist{}, err + } + return decodeAllowlistBytes(ctx, schema, data, "base allowlist") +} + // Load reads and validates the repository's CUE configuration and allowlist. -func Load(root string) (Config, Allowlist, error) { +func Load(root string) (codeshape.Config, codeshape.Allowlist, error) { repository, err := os.OpenRoot(root) if err != nil { - return Config{}, Allowlist{}, fmt.Errorf("open repository root: %w", err) + return codeshape.Config{}, codeshape.Allowlist{}, fmt.Errorf("open repository root: %w", err) } config, allowlist, loadErr := loadFromRoot(repository) closeErr := repository.Close() @@ -116,40 +68,40 @@ func Load(root string) (Config, Allowlist, error) { return config, allowlist, errors.Join(loadErr, closeErr) } -func loadFromRoot(repository *os.Root) (Config, Allowlist, error) { +func loadFromRoot(repository *os.Root) (codeshape.Config, codeshape.Allowlist, error) { ctx := cuecontext.New() schema, err := schemaValue(ctx) if err != nil { - return Config{}, Allowlist{}, err + return codeshape.Config{}, codeshape.Allowlist{}, err } configValue, err := loadCUEFile(ctx, repository, "tools/code-shape-gate/config.cue") if err != nil { - return Config{}, Allowlist{}, err + return codeshape.Config{}, codeshape.Allowlist{}, err } configSchema := schema.LookupPath(cue.MakePath(cue.Def("Config"))) configValue = configValue.Unify(configSchema) err = configValue.Err() if err != nil { - return Config{}, Allowlist{}, fmt.Errorf("validate config.cue: %w", err) + return codeshape.Config{}, codeshape.Allowlist{}, fmt.Errorf("validate config.cue: %w", err) } - var config Config + var config codeshape.Config err = configValue.Decode(&config) if err != nil { - return Config{}, Allowlist{}, fmt.Errorf("decode config.cue: %w", err) + return codeshape.Config{}, codeshape.Allowlist{}, fmt.Errorf("decode config.cue: %w", err) } err = validateConfig(config) if err != nil { - return Config{}, Allowlist{}, err + return codeshape.Config{}, codeshape.Allowlist{}, err } allowValue, err := loadCUEFile(ctx, repository, "tools/code-shape-gate/allowlist.cue") if err != nil { - return Config{}, Allowlist{}, err + return codeshape.Config{}, codeshape.Allowlist{}, err } allowlist, err := decodeAllowlist(schema, allowValue, "allowlist.cue") if err != nil { - return Config{}, Allowlist{}, err + return codeshape.Config{}, codeshape.Allowlist{}, err } allowlist.Present = true return config, allowlist, nil @@ -181,35 +133,47 @@ func compileCUEFile(ctx *cue.Context, data []byte, name string) (cue.Value, erro return value, nil } -func decodeAllowlistBytes(ctx *cue.Context, schema cue.Value, data []byte, name string) (Allowlist, error) { +func decodeAllowlistBytes(ctx *cue.Context, schema cue.Value, data []byte, name string) (codeshape.Allowlist, error) { value, err := compileCUEFile(ctx, data, name) if err != nil { - return Allowlist{}, err + return codeshape.Allowlist{}, err } return decodeAllowlist(schema, value, name) } -func decodeAllowlist(schema, value cue.Value, name string) (Allowlist, error) { +func decodeAllowlist(schema, value cue.Value, name string) (codeshape.Allowlist, error) { allowSchema := schema.LookupPath(cue.MakePath(cue.Def("Allowlist"))) value = value.Unify(allowSchema) err := value.Err() if err != nil { - return Allowlist{}, fmt.Errorf("validate %s: %w", name, err) + return codeshape.Allowlist{}, fmt.Errorf("validate %s: %w", name, err) } - var allowlist Allowlist + var allowlist codeshape.Allowlist err = value.Decode(&allowlist) if err != nil { - return Allowlist{}, fmt.Errorf("decode %s: %w", name, err) + return codeshape.Allowlist{}, fmt.Errorf("decode %s: %w", name, err) } if allowlist.Files == nil { - allowlist.Files = map[string]Entry{} + allowlist.Files = map[string]codeshape.Entry{} } if allowlist.Dirs == nil { - allowlist.Dirs = map[string]Entry{} + allowlist.Dirs = map[string]codeshape.Entry{} + } + if allowlist.Functions == nil { + allowlist.Functions = map[codeshape.Metric]map[string][]codeshape.Entry{} + } + for metric, paths := range allowlist.Functions { + if !codeshape.IsFunctionMetric(metric) { + return codeshape.Allowlist{}, fmt.Errorf("%s: %w %q", name, errUnsupportedMetric, metric) + } + err := codeshape.ValidateFunctionEntryIDs(paths) + if err != nil { + return codeshape.Allowlist{}, fmt.Errorf("%s: %w", name, err) + } } if entry, exists := allowlist.Dirs["."]; exists { if _, duplicate := allowlist.Dirs[""]; duplicate { - return Allowlist{}, fmt.Errorf("%s: %w", name, errDuplicateRoot) + return codeshape.Allowlist{}, fmt.Errorf("%s: %w", name, errDuplicateRoot) } allowlist.Dirs[""] = entry delete(allowlist.Dirs, ".") @@ -217,10 +181,14 @@ func decodeAllowlist(schema, value cue.Value, name string) (Allowlist, error) { return allowlist, nil } -func validateConfig(config Config) error { +func validateConfig(config codeshape.Config) error { if config.FileLimit != 500 || config.DirLimit != 10 { return errInvalidThresholds } + if config.Functions.Go.Cognitive <= 0 || config.Functions.Go.Lines <= 0 || config.Functions.Go.Statements <= 0 || + config.Functions.TS.Cognitive <= 0 || config.Functions.TS.Lines <= 0 { + return errFunctionLimits + } for _, extension := range config.Extensions { if !strings.HasPrefix(extension, ".") || strings.ContainsAny(extension, "/\\") { return fmt.Errorf("%w %q", errInvalidExtension, extension) diff --git a/go/internal/codeshape/load_test.go b/go/internal/codeshape/config/load_test.go similarity index 52% rename from go/internal/codeshape/load_test.go rename to go/internal/codeshape/config/load_test.go index 40125f1..0102279 100644 --- a/go/internal/codeshape/load_test.go +++ b/go/internal/codeshape/config/load_test.go @@ -1,14 +1,29 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -package codeshape +package config import ( "os" "path/filepath" "strings" "testing" + + "github.com/RigelBuild/meissa/go/internal/codeshape" ) +func TestLoadLegacyConfig(t *testing.T) { + root := newFixtureRepo(t) + writeConfigFiles(t, root, "fileLimit: 500\ndirLimit: 10\nextensions: [\".go\"]\nexclude: []\n", "files: {}\n") + cfg, _, err := Load(root) + if err != nil { + t.Fatal(err) + } + if cfg.Functions.Go.Cognitive != 20 || cfg.Functions.Go.Lines != 60 || cfg.Functions.Go.Statements != 40 || + cfg.Functions.TS.Cognitive != 15 || cfg.Functions.TS.Lines != 50 { + t.Fatalf("unexpected defaults: %#v", cfg.Functions) + } +} + func TestLoad(t *testing.T) { t.Run("valid CUE configuration and allowlist", func(t *testing.T) { root := newFixtureRepo(t) @@ -44,6 +59,26 @@ func TestLoad(t *testing.T) { }) } +func TestLoadFunctionEntries(t *testing.T) { + for _, tc := range []struct { + name, reason string + valid bool + }{{"valid", "split by concern", true}, {"short", "two words", false}} { + t.Run(tc.name, func(t *testing.T) { + root := newFixtureRepo(t) + allow := `functions: "go-lines": "f.go": [{id: "work", value: 70, reason: "` + tc.reason + `"}]` + writeConfigFiles(t, root, validConfig, allow) + cfg, entries, err := Load(root) + if (err == nil) != tc.valid { + t.Fatalf("Load error = %v", err) + } + if tc.valid && (cfg.Functions.Go.Lines != 60 || entries.Functions[codeshape.MetricGoLines]["f.go"][0].ID != "work") { + t.Fatalf("decoded config=%#v entries=%#v", cfg, entries) + } + }) + } +} + func TestLoadDirectoryLimits(t *testing.T) { for _, config := range []string{ `dirLimits: "wide": {limit: 10, reason: "record decisions together"}`, @@ -115,95 +150,8 @@ func TestLoadMissingFiles(t *testing.T) { } } -func TestMainModes(t *testing.T) { - t.Run("write refuses to add without seed", func(t *testing.T) { - root := newFixtureRepo(t) - writeConfigFiles(t, root, validConfig, "files: {}\ndirs: {}\n") - trackedFile(t, root, "large.go", strings.Repeat("line\n", 501)) - commitFiles(t, root) - var stdout, stderr strings.Builder - code := Main(t.Context(), []string{"--write", root}, func(string) string { return "" }, &stdout, &stderr) - if code != 2 || !strings.Contains(stderr.String(), "needs --seed") { - t.Fatalf("Main = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) - } - }) - t.Run("seed writes raised rollout entries", func(t *testing.T) { - root := newFixtureRepo(t) - writeConfigFiles(t, root, validConfig, "files: {}\ndirs: {}\n") - trackedFile(t, root, "large.go", strings.Repeat("line\n", 501)) - commitFiles(t, root) - var stdout, stderr strings.Builder - code := Main(t.Context(), []string{"--write", "--seed", root}, func(string) string { return "" }, &stdout, &stderr) - if code != 0 { - t.Fatalf("Main = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) - } - _, allowlist, err := Load(root) - if err != nil { - t.Fatal(err) - } - entry, exists := allowlist.Files["large.go"] - if !exists || entry.Value != 501 || entry.Reason != "seeded at rollout, burn down" { - t.Fatalf("seeded entry = %#v, exists=%v", entry, exists) - } - }) -} - -func TestMainWriteUpdates(t *testing.T) { - t.Run("write lowers entries", func(t *testing.T) { - root := newFixtureRepo(t) - writeConfigFiles(t, root, validConfig, "files: \"large.go\": {value: 502, reason: \"split by concern\"}\n") - trackedFile(t, root, "large.go", strings.Repeat("line\n", 501)) - commitFiles(t, root) - var stdout, stderr strings.Builder - code := Main(t.Context(), []string{"--write", root}, func(string) string { return "" }, &stdout, &stderr) - if code != 0 { - t.Fatalf("Main = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) - } - _, allowlist, err := Load(root) - if err != nil { - t.Fatal(err) - } - if got := allowlist.Files["large.go"].Value; got != 501 { - t.Fatalf("lowered value = %d, want 501", got) - } - }) - t.Run("write removes entries at limit", func(t *testing.T) { - root := newFixtureRepo(t) - writeConfigFiles(t, root, validConfig, "files: \"small.go\": {value: 501, reason: \"split by concern\"}\n") - trackedFile(t, root, "small.go", strings.Repeat("line\n", 500)) - commitFiles(t, root) - var stdout, stderr strings.Builder - code := Main(t.Context(), []string{"--write", root}, func(string) string { return "" }, &stdout, &stderr) - if code != 0 { - t.Fatalf("Main = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) - } - _, allowlist, err := Load(root) - if err != nil { - t.Fatal(err) - } - if _, exists := allowlist.Files["small.go"]; exists { - t.Fatal("--write retained an entry now within the limit") - } - }) - t.Run("base rejects a seeded added entry", func(t *testing.T) { - root := newFixtureRepo(t) - writeConfigFiles(t, root, validConfig, "files: {}\ndirs: {}\n") - trackedFile(t, root, "large.go", strings.Repeat("line\n", 501)) - commitFiles(t, root) - var stdout, stderr strings.Builder - code := Main(t.Context(), []string{"--write", "--seed", root}, func(key string) string { - if key == "GATE_BASE" { - return "HEAD" - } - return "" - }, &stdout, &stderr) - if code != 1 || !strings.Contains(stdout.String(), "do not add or raise") { - t.Fatalf("Main = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) - } - }) -} - -const validConfig = "fileLimit: 500\ndirLimit: 10\nextensions: [\".go\", \".cue\"]\nexclude: []\n" +const validConfig = "fileLimit: 500\ndirLimit: 10\nextensions: [\".go\", \".cue\"]\nexclude: []\n" + + "functions: {go: {cognitive: 20, lines: 60, statements: 40}, ts: {cognitive: 15, lines: 50}}\n" func writeConfigFiles(t *testing.T, root, config, allowlist string) { t.Helper() diff --git a/go/internal/codeshape/config/render.go b/go/internal/codeshape/config/render.go new file mode 100644 index 0000000..f971ec6 --- /dev/null +++ b/go/internal/codeshape/config/render.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package config + +import ( + "sort" + "strconv" + "strings" + + "github.com/RigelBuild/meissa/go/internal/codeshape" +) + +// Render serializes the T1 file and directory entries in stable key order. +func Render(allow codeshape.Allowlist) []byte { + var output strings.Builder + writeEntries(&output, "files", allow.Files) + writeEntries(&output, "dirs", allow.Dirs) + writeFunctionEntries(&output, allow.Functions) + if len(allow.Files) == 0 && len(allow.Dirs) == 0 && len(allow.Functions) == 0 { + output.WriteString("files: {}\n") + } + return []byte(output.String()) +} + +func writeEntries(output *strings.Builder, name string, entries map[string]codeshape.Entry) { + if len(entries) == 0 { + return + } + keys := make([]string, 0, len(entries)) + for key := range entries { + keys = append(keys, key) + } + sort.Strings(keys) + output.WriteString(name + ": {\n") + for _, key := range keys { + entry := entries[key] + output.WriteString("\t" + strconv.Quote(key) + ": {value: " + strconv.Itoa(entry.Value) + + ", reason: " + strconv.Quote(entry.Reason) + "},\n") + } + output.WriteString("}\n") +} + +func writeFunctionEntries(output *strings.Builder, functions map[codeshape.Metric]map[string][]codeshape.Entry) { + for _, metric := range codeshape.FunctionMetrics() { + paths := functions[metric] + if len(paths) == 0 { + continue + } + output.WriteString("functions: " + strconv.Quote(string(metric)) + ": {\n") + for _, path := range codeshape.SortedEntryPaths(paths) { + output.WriteString("\t" + strconv.Quote(path) + ": [\n") + for _, entry := range codeshape.SortedFunctionEntries(paths[path]) { + output.WriteString("\t\t{id: " + strconv.Quote(entry.ID) + ", value: " + strconv.Itoa(entry.Value) + + ", reason: " + strconv.Quote(entry.Reason) + "},\n") + } + output.WriteString("\t],\n") + } + output.WriteString("}\n") + } +} diff --git a/go/internal/codeshape/render_test.go b/go/internal/codeshape/config/render_test.go similarity index 60% rename from go/internal/codeshape/render_test.go rename to go/internal/codeshape/config/render_test.go index 500a0fa..f562d44 100644 --- a/go/internal/codeshape/render_test.go +++ b/go/internal/codeshape/config/render_test.go @@ -1,21 +1,24 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -package codeshape +package config import ( "bytes" "reflect" "testing" + + "github.com/RigelBuild/meissa/go/internal/codeshape" ) func TestRender(t *testing.T) { - allow := Allowlist{ - Files: map[string]Entry{ - "z.go": {Value: 501, Reason: "split by concern"}, - "a.go": {Value: 502, Reason: "generated by codegen"}, + allow := codeshape.Allowlist{ + Files: map[string]codeshape.Entry{ + "z.go": {ID: "", Value: 501, Reason: "split by concern"}, + "a.go": {ID: "", Value: 502, Reason: "generated by codegen"}, }, - Dirs: map[string]Entry{"pkg": {Value: 11, Reason: "split package tests"}}, - Present: false, + Dirs: map[string]codeshape.Entry{"pkg": {ID: "", Value: 11, Reason: "split package tests"}}, + Functions: nil, + Present: false, } first := Render(allow) second := Render(allow) @@ -33,12 +36,13 @@ func TestRender(t *testing.T) { } func TestRenderRoundTrip(t *testing.T) { - allow := Allowlist{ - Files: map[string]Entry{ - `large"file.go`: {Value: 501, Reason: `split "quoted" concern`}, + allow := codeshape.Allowlist{ + Files: map[string]codeshape.Entry{ + `large"file.go`: {ID: "", Value: 501, Reason: `split "quoted" concern`}, }, - Dirs: map[string]Entry{"pkg": {Value: 11, Reason: "split package tests"}}, - Present: false, + Dirs: map[string]codeshape.Entry{"pkg": {ID: "", Value: 11, Reason: "split package tests"}}, + Functions: nil, + Present: false, } root := newFixtureRepo(t) writeConfigFiles(t, root, validConfig, string(Render(allow))) diff --git a/go/internal/codeshape/config/schema.cue b/go/internal/codeshape/config/schema.cue new file mode 100644 index 0000000..162baa5 --- /dev/null +++ b/go/internal/codeshape/config/schema.cue @@ -0,0 +1,45 @@ +#FunctionEntry: close({ + id: string & !="" + value: int & >=0 + reason: string & =~"^\\S+(\\s+\\S+){2,}$" +}) + +#FunctionLimits: close({ + go: close({ + cognitive: int & >0 + lines: int & >0 + statements: int & >0 + }) + ts: close({ + cognitive: int & >0 + lines: int & >0 + }) +}) + +#Metric: "go-cognitive" | "go-lines" | "go-statements" | "ts-cognitive" | "ts-lines" + +#Allowlist: close({ + files?: [string]: #Entry + dirs?: [string]: #Entry + functions?: [#Metric]: [string]: [...#FunctionEntry] +}) + +#Entry: close({ + value: int & >=0 + reason: string & =~"^\\S+(\\s+\\S+){2,}$" +}) + +#Config: close({ + fileLimit: 500 + dirLimit: 10 + extensions: [...string] + exclude: [...string] + // When multiple globs match, the smallest limit wins. + dirLimits?: [string]: {limit: int & >10, reason: string & =~"^\\S+(\\s+\\S+){2,}$"} + functions: *#FunctionLimits | #FunctionLimits + #FunctionLimits: close({ + go: close({cognitive: 20, lines: 60, statements: 40}) + ts: close({cognitive: 15, lines: 50}) + }) +}) + diff --git a/go/internal/codeshape/functions.go b/go/internal/codeshape/functions.go new file mode 100644 index 0000000..ca7df3d --- /dev/null +++ b/go/internal/codeshape/functions.go @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +import ( + "errors" + "fmt" + "slices" + "sort" +) + +// Skip records a source file that could not be measured. +type Skip struct { + Path string + Reason string + Unmeasured *UnmeasuredFunctions +} + +// UnmeasuredFunctions holds diagnostic maxima for an unparseable source file. +type UnmeasuredFunctions struct { + Values map[Metric]int + BiomeParseFailed bool +} + +var errFunctionID = errors.New("invalid function allowlist id") + +// FunctionLimits holds per-language function thresholds. +type FunctionLimits struct { + Go GoFunctionLimits `json:"go"` + TS TSFunctionLimits `json:"ts"` +} + +// GoFunctionLimits holds Go function thresholds. +type GoFunctionLimits struct { + Cognitive int `json:"cognitive"` + Lines int `json:"lines"` + Statements int `json:"statements"` +} + +// TSFunctionLimits holds TypeScript function thresholds. +type TSFunctionLimits struct { + Cognitive int `json:"cognitive"` + Lines int `json:"lines"` +} + +// LimitFor returns the threshold for a function metric. +func (limits FunctionLimits) LimitFor(metric Metric) int { + switch metric { + case MetricGoCognitive: + return limits.Go.Cognitive + case MetricGoLines: + return limits.Go.Lines + case MetricGoStatements: + return limits.Go.Statements + case MetricTSCognitive: + return limits.TS.Cognitive + case MetricTSLines: + return limits.TS.Lines + case MetricFiles, MetricDirs, MetricUnmeasured: + return 0 + default: + return 0 + } +} + +// IsFunctionMetric reports whether a metric measures functions. +func IsFunctionMetric(metric Metric) bool { + return slices.Contains(FunctionMetrics(), metric) +} + +// FunctionMetrics lists supported function metrics. +func FunctionMetrics() []Metric { + return []Metric{MetricGoCognitive, MetricGoLines, MetricGoStatements, MetricTSCognitive, MetricTSLines} +} + +// NewFunctionMeasurements initializes the function metric maps. +func NewFunctionMeasurements() map[Metric]map[string][]Entry { + values := make(map[Metric]map[string][]Entry) + for _, metric := range FunctionMetrics() { + values[metric] = make(map[string][]Entry) + } + return values +} + +// AppendFunctionValue records a function measurement. +func AppendFunctionValue(values map[Metric]map[string][]Entry, metric Metric, path, id string, value int) { + entries := values[metric][path] + for index, entry := range entries { + if entry.ID == id { + if value > entry.Value { + entries[index].Value = value + } + values[metric][path] = entries + return + } + } + values[metric][path] = append(entries, Entry{ID: id, Value: value, Reason: ""}) +} + +// SortedFunctionEntries returns entries ordered by identifier. +func SortedFunctionEntries(entries []Entry) []Entry { + sorted := append([]Entry(nil), entries...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID }) + return sorted +} + +// ValidateFunctionEntryIDs checks function entry identifiers. +func ValidateFunctionEntryIDs(entries map[string][]Entry) error { + for path, functions := range entries { + seen := make(map[string]bool, len(functions)) + for _, entry := range functions { + if entry.ID == "" { + return fmt.Errorf("%w: function allowlist entry for %q has an empty id", errFunctionID, path) + } + if seen[entry.ID] { + return fmt.Errorf("%w: function allowlist entry for %q repeats id %q", errFunctionID, path, entry.ID) + } + seen[entry.ID] = true + } + } + return nil +} + +// RewriteFunctionEntries updates function ratchet entries. +func RewriteFunctionEntries(updated, allow, values FunctionMeasurements, cfg Config, seed bool) (bool, []string) { + changed := false + errs := []string{} + for _, metric := range FunctionMetrics() { + paths, dropped := retainFunctionEntries(allow[metric], values[metric], cfg.Functions.LimitFor(metric)) + added, metricErrors := seedFunctionEntries(paths, values[metric], metric, cfg.Functions.LimitFor(metric), seed) + changed = changed || dropped || added + errs = append(errs, metricErrors...) + if len(paths) > 0 { + updated[metric] = paths + } + } + return changed, errs +} + +func retainFunctionEntries(allow, values map[string][]Entry, limit int) (map[string][]Entry, bool) { + paths := map[string][]Entry{} + changed := false + for path, entries := range allow { + for _, entry := range entries { + current, exists := functionEntry(values[path], entry.ID) + if !exists || current.Value <= limit { + changed = true + continue + } + if current.Value < entry.Value { + entry.Value = current.Value + changed = true + } + paths[path] = append(paths[path], entry) + } + } + return paths, changed +} + +func seedFunctionEntries(paths, values map[string][]Entry, metric Metric, limit int, seed bool) (bool, []string) { + changed := false + errs := []string{} + for path, entries := range values { + for _, current := range entries { + if current.Value <= limit { + continue + } + entry, exists := functionEntry(paths[path], current.ID) + if exists && current.Value <= entry.Value { + continue + } + if !seed { + errs = append(errs, fmt.Sprintf( + "%s#%s %s value %d needs --seed to add or raise the allowlist", path, current.ID, metric, current.Value, + )) + continue + } + if !exists { + entry = Entry{ID: current.ID, Value: 0, Reason: "seeded at rollout, burn down"} + } + entry.Value = current.Value + paths[path] = replaceFunctionEntry(paths[path], entry, exists) + changed = true + } + } + return changed, errs +} + +func replaceFunctionEntry(entries []Entry, entry Entry, exists bool) []Entry { + if !exists { + return append(entries, entry) + } + for i := range entries { + if entries[i].ID == entry.ID { + entries[i] = entry + } + } + return entries +} diff --git a/go/internal/codeshape/golang/fixtures_test.go b/go/internal/codeshape/golang/fixtures_test.go new file mode 100644 index 0000000..dec4656 --- /dev/null +++ b/go/internal/codeshape/golang/fixtures_test.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package golang + +import ( + "github.com/RigelBuild/meissa/go/internal/codeshape" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func newFixtureRepo(t *testing.T) string { + t.Helper() + root := t.TempDir() + for _, args := range [][]string{ + {"init", "-q"}, {"config", "user.email", "test@example.invalid"}, {"config", "user.name", "test"}, + } { + cmd := exec.CommandContext(t.Context(), "git", args...) + cmd.Dir = root + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git: %v: %s", err, out) + } + } + return root +} +func trackedFile(t *testing.T, root, path, content string) { + t.Helper() + path = filepath.Join(root, filepath.FromSlash(path)) + err := os.MkdirAll(filepath.Dir(path), 0755) + if err != nil { + t.Fatal(err) + } + err = os.WriteFile(path, []byte(content), 0644) + if err != nil { + t.Fatal(err) + } +} +func commitFiles(t *testing.T, root string) { + t.Helper() + for _, args := range [][]string{{"add", "--all"}, {"commit", "-qm", "fixture"}} { + cmd := exec.CommandContext(t.Context(), "git", args...) + cmd.Dir = root + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git: %v: %s", err, out) + } + } +} +func testConfig() codeshape.Config { + return codeshape.Config{FileLimit: 500, DirLimit: 10, Extensions: []string{".go", ".cue"}, + Exclude: []string{}, DirLimits: map[string]codeshape.DirLimit{}, + Functions: codeshape.FunctionLimits{Go: codeshape.GoFunctionLimits{Cognitive: 0, Lines: 0, Statements: 0}, + TS: codeshape.TSFunctionLimits{Cognitive: 0, Lines: 0}}} +} + +const validConfig = "fileLimit: 500\ndirLimit: 10\nextensions: [\".go\", \".cue\"]\nexclude: []\n" + + "functions: {go: {cognitive: 20, lines: 60, statements: 40}, ts: {cognitive: 15, lines: 50}}\n" + +func writeConfigFiles(t *testing.T, root, config, allowlist string) { + t.Helper() + dir := filepath.Join(root, "tools", "code-shape-gate") + err := os.MkdirAll(dir, 0755) + if err != nil { + t.Fatal(err) + } + for name, content := range map[string]string{"config.cue": config, "allowlist.cue": allowlist} { + err = os.WriteFile(filepath.Join(dir, name), []byte(content), 0644) + if err != nil { + t.Fatal(err) + } + } +} diff --git a/go/internal/codeshape/golang/golangci.go b/go/internal/codeshape/golang/golangci.go new file mode 100644 index 0000000..3c18b35 --- /dev/null +++ b/go/internal/codeshape/golang/golangci.go @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package golang + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/RigelBuild/meissa/go/internal/codeshape" +) + +var ( + gocognitValue = regexp.MustCompile(`cognitive complexity ([0-9]+) of func`) + funlenValue = regexp.MustCompile(`\(([0-9]+) > [0-9]+\)`) + errGoLintMissing = errors.New("measurement omitted") + errGoMetricParse = errors.New("cannot parse value") + errGoMetricInvalid = errors.New("invalid value") +) + +//nolint:tagliatelle // external wire format +type golangciReport struct { + Issues []struct { + FromLinter string `json:"FromLinter"` + Text string `json:"Text"` + Pos struct { + Filename string `json:"Filename"` + Line int `json:"Line"` + Column int `json:"Column"` + } `json:"Pos"` + } `json:"Issues"` + Report struct { + Error string `json:"Error"` + Linters []struct { + Name string `json:"Name"` + Enabled bool `json:"Enabled"` + } `json:"Linters"` + } `json:"Report"` +} + +func runGolangci( + ctx context.Context, + binary, root, module, linter string, + lines, statements int, + cfg codeshape.Config, +) (golangciReport, error) { + configuration := fmt.Sprintf( + "version: '2'\nlinters:\n default: none\n enable:\n - %s\n"+ + " settings:\n gocognit:\n min-complexity: %d\n funlen:\n"+ + " lines: %d\n statements: %d\n ignore-comments: true\n"+ + "issues:\n max-issues-per-linter: 0\n max-same-issues: 0\n", + linter, + cfg.Functions.Go.Cognitive, + lines, + statements, + ) + temporary, err := os.CreateTemp("", "code-shape-golangci-*.yml") + if err != nil { + return golangciReport{}, fmt.Errorf("create golangci-lint config: %w", err) + } + report, runErr := runGolangciWithConfig(ctx, binary, root, module, linter, configuration, temporary) + removeErr := os.Remove(temporary.Name()) + if removeErr != nil { + removeErr = fmt.Errorf("remove golangci-lint config: %w", removeErr) + } + return report, errors.Join(runErr, removeErr) +} + +func runGolangciWithConfig( + ctx context.Context, binary, root, module, linter, configuration string, temporary *os.File, +) (golangciReport, error) { + var err error + _, err = temporary.WriteString(configuration) + if err != nil { + closeErr := temporary.Close() + if closeErr != nil { + err = errors.Join(err, fmt.Errorf("close golangci-lint config: %w", closeErr)) + } + return golangciReport{}, fmt.Errorf("write golangci-lint config: %w", err) + } + err = temporary.Close() + if err != nil { + return golangciReport{}, fmt.Errorf("close golangci-lint config: %w", err) + } + //nolint:gosec // G204: execute the configured golangci-lint binary + command := exec.CommandContext(ctx, binary, "run", "--config", temporary.Name(), "--enable-only", linter, + "--output.json.path", "stdout", "--path-mode", "abs", "--show-stats=false", "--uniq-by-line=false", + "--max-issues-per-linter", "0", "--max-same-issues", "0", "./...") + command.Dir = filepath.Join(root, filepath.FromSlash(module)) + command.Env = append(os.Environ(), "CGO_ENABLED=0") + var stdout, stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + runErr := command.Run() + if runErr != nil { + var exitError *exec.ExitError + if !errors.As(runErr, &exitError) || exitError.ExitCode() != 1 { + return golangciReport{}, fmt.Errorf("golangci-lint %s failed in %q: %w: %s", + linter, module, runErr, strings.TrimSpace(stderr.String())) + } + } + var report golangciReport + err = json.Unmarshal(stdout.Bytes(), &report) + if err != nil { + return golangciReport{}, fmt.Errorf("parse golangci-lint %s output in %q: %w", linter, module, err) + } + if report.Report.Error != "" { + return golangciReport{}, fmt.Errorf("golangci-lint %s %w: %s", linter, errGoLintReport, report.Report.Error) + } + if runErr != nil && len(report.Issues) == 0 { + return golangciReport{}, fmt.Errorf("golangci-lint %s failed without issues: %w", linter, runErr) + } + enabled := false + for _, entry := range report.Report.Linters { + if entry.Name == linter && entry.Enabled { + enabled = true + } + } + if !enabled { + return golangciReport{}, fmt.Errorf("golangci-lint output omitted enabled %s measurement: %w", + linter, errGoLintMissing) + } + return report, nil +} + +func metricValue(text string, metric codeshape.Metric) (int, error) { + pattern := funlenValue + if metric == codeshape.MetricGoCognitive { + pattern = gocognitValue + } + matches := pattern.FindStringSubmatch(text) + if len(matches) < 2 { + return 0, fmt.Errorf("cannot parse %s value from %q: %w", metric, text, errGoMetricParse) + } + value, err := strconv.Atoi(matches[1]) + if err != nil || value <= 0 { + return 0, fmt.Errorf("invalid %s value in %q: %w", metric, text, errGoMetricInvalid) + } + return value, nil +} diff --git a/go/internal/codeshape/golang/gometrics.go b/go/internal/codeshape/golang/gometrics.go new file mode 100644 index 0000000..77065cc --- /dev/null +++ b/go/internal/codeshape/golang/gometrics.go @@ -0,0 +1,396 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +// Package golang collects Go function measurements. +package golang + +import ( + "bytes" + "context" + "errors" + "fmt" + "go/ast" + "go/build" + "go/format" + "go/parser" + "go/token" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/RigelBuild/meissa/go/internal/codeshape" +) + +const highFunlenLimit = 1_000_000_000 + +var ( + errGoLintReport = errors.New("reported an error") + errGoSourceUnknown = errors.New("unknown Go diagnostic source") + errGoSourceNoFunctions = errors.New("go diagnostic source has no functions") + errGoPosition = errors.New("invalid Go diagnostic position") + errGoOutsideFunction = errors.New("go diagnostic outside any function") +) + +type goSourceFile struct { + set *token.FileSet + functions []goFunction +} + +type goFunction struct { + node ast.Node + path string + base string + id string +} + +// GoMetrics measures function complexity and length in every included module. +func GoMetrics(ctx context.Context, root string, cfg codeshape.Config) ( + map[codeshape.Metric]map[string][]codeshape.Entry, []codeshape.Skip, error, +) { + paths, err := codeshape.TrackedPaths(ctx, root) + if err != nil { + return nil, nil, fmt.Errorf("collect Go paths: %w", err) + } + modules := goModules(paths, cfg) + if len(modules) == 0 { + return codeshape.NewFunctionMeasurements(), nil, nil + } + binary, err := exec.LookPath("golangci-lint") + if err != nil { + return nil, nil, fmt.Errorf("find golangci-lint: %w", err) + } + err = codeshape.CheckToolVersion(ctx, binary, codeshape.GolangciVersion) + if err != nil { + return nil, nil, fmt.Errorf("check golangci-lint: %w", err) + } + measurements := codeshape.NewFunctionMeasurements() + skips := []codeshape.Skip{} + for _, module := range modules { + files, skipped, err := parseGoSources(root, module, paths, cfg) + if err != nil { + return nil, nil, err + } + skips = append(skips, skipped...) + if len(files) == 0 { + continue + } + err = measureGoModule(ctx, binary, root, module, cfg, files, measurements) + if err != nil { + return nil, nil, err + } + } + return measurements, skips, nil +} + +func measureGoModule(ctx context.Context, binary, root, module string, cfg codeshape.Config, + files map[string]goSourceFile, measurements map[codeshape.Metric]map[string][]codeshape.Entry, +) error { + limits := []struct { + metric codeshape.Metric + linter string + lines, statements int + }{ + {metric: codeshape.MetricGoCognitive, linter: "gocognit", lines: 0, statements: 0}, + {metric: codeshape.MetricGoLines, linter: "funlen", lines: cfg.Functions.Go.Lines, statements: highFunlenLimit}, + {metric: codeshape.MetricGoStatements, linter: "funlen", lines: highFunlenLimit, + statements: cfg.Functions.Go.Statements}, + } + for _, file := range files { + for _, function := range file.functions { + for _, metric := range []codeshape.Metric{ + codeshape.MetricGoCognitive, codeshape.MetricGoLines, codeshape.MetricGoStatements, + } { + codeshape.AppendFunctionValue(measurements, metric, function.path, function.id, 0) + } + } + } + for _, limit := range limits { + report, err := runGolangci(ctx, binary, root, module, limit.linter, limit.lines, limit.statements, cfg) + if err != nil { + return err + } + err = applyGoIssues(root, module, files, cfg, limit.metric, limit.linter, report, measurements) + if err != nil { + return err + } + } + return nil +} + +func applyGoIssues(root, module string, files map[string]goSourceFile, cfg codeshape.Config, + metric codeshape.Metric, linter string, report golangciReport, + measurements map[codeshape.Metric]map[string][]codeshape.Entry, +) error { + for _, issue := range report.Issues { + if issue.FromLinter != linter { + return fmt.Errorf("golangci-lint %s reported %s issue: %s: %w", linter, issue.FromLinter, issue.Text, + errGoLintReport) + } + path := issue.Pos.Filename + if filepath.IsAbs(path) { + var err error + path, err = filepath.Rel(root, path) + if err != nil { + return fmt.Errorf("relativize Go diagnostic path: %w", err) + } + } else { + path = filepath.Join(module, path) + } + path = filepath.ToSlash(path) + if excludedFunctionPath(path, cfg) || !codeshape.CountedPath(path, cfg) { + continue + } + value, err := metricValue(issue.Text, metric) + if err != nil { + return err + } + path, function, err := findGoFunction(root, module, files, issue.Pos.Filename, issue.Pos.Line, issue.Pos.Column) + if err != nil { + return err + } + codeshape.AppendFunctionValue(measurements, metric, path, function, value) + } + return nil +} + +func goModules(paths []string, cfg codeshape.Config) []string { + modules := make([]string, 0) + for _, path := range paths { + if filepath.Base(path) != "go.mod" || excludedFunctionPath(path, cfg) { + continue + } + modules = append(modules, filepath.ToSlash(filepath.Dir(path))) + } + sort.Strings(modules) + return modules +} + +func excludedFunctionPath(path string, cfg codeshape.Config) bool { + for segment := range strings.SplitSeq(filepath.ToSlash(path), "/") { + if segment == "testdata" || segment == "fixtures" { + return true + } + } + return codeshape.MatchesAny(filepath.ToSlash(path), cfg.Exclude) +} + +func parseGoSources(root, module string, paths []string, cfg codeshape.Config) ( + map[string]goSourceFile, []codeshape.Skip, error, +) { + files := make(map[string]goSourceFile) + skips := []codeshape.Skip{} + buildContext := build.Default + buildContext.CgoEnabled = false + modules := make([]string, 0) + for _, path := range paths { + if filepath.Base(path) == "go.mod" { + modules = append(modules, filepath.ToSlash(filepath.Dir(path))) + } + } + sort.Strings(modules) + for _, path := range paths { + if !eligibleGoSource(path, module, modules, cfg) { + continue + } + fullPath := filepath.Join(root, filepath.FromSlash(path)) + matches, err := buildContext.MatchFile(filepath.Dir(fullPath), filepath.Base(fullPath)) + if err != nil { + return nil, nil, fmt.Errorf("check Go build constraints %q: %w", path, err) + } + source, err := codeshape.ReadRepositoryFile(root, filepath.FromSlash(path)) + if err != nil { + return nil, nil, fmt.Errorf("read Go source %q: %w", path, err) + } + set := token.NewFileSet() + file, err := parser.ParseFile(set, path, source, parser.ParseComments|parser.AllErrors) + if err != nil { + return nil, nil, fmt.Errorf("parse Go source %q: %w", path, err) + } + if !matches { + var unmeasured *codeshape.UnmeasuredFunctions + if len(goFunctions(file, set)) > 0 { + unmeasured = &codeshape.UnmeasuredFunctions{Values: nil, BiomeParseFailed: false} + } + skips = append(skips, codeshape.Skip{Path: path, Reason: "not in default Go build", Unmeasured: unmeasured}) + continue + } + functions := goFunctions(file, set) + for index := range functions { + functions[index].path = path + } + files[path] = goSourceFile{set: set, functions: functions} + } + return files, skips, nil +} + +func eligibleGoSource(path, module string, modules []string, cfg codeshape.Config) bool { + if filepath.Ext(path) != ".go" || excludedFunctionPath(path, cfg) { + return false + } + if module != "." && !strings.HasPrefix(filepath.ToSlash(path), strings.TrimSuffix(module, "/")+"/") { + return false + } + return !nestedModulePath(path, module, modules) && codeshape.CountedPath(filepath.ToSlash(path), cfg) +} + +func nestedModulePath(path, module string, modules []string) bool { + for _, dir := range modules { + if dir != "." && dir != module && (module == "." || strings.HasPrefix(dir, module+"/")) && + strings.HasPrefix(filepath.ToSlash(path), dir+"/") { + return true + } + } + return false +} + +func goFunctions(file *ast.File, set *token.FileSet) []goFunction { + functions := make([]goFunction, 0) + topLevel := topLevelLiterals(file) + ast.Inspect(file, func(node ast.Node) bool { + switch function := node.(type) { + case *ast.FuncDecl: + name := function.Name.Name + if function.Recv != nil && len(function.Recv.List) > 0 { + var receiver bytes.Buffer + err := format.Node(&receiver, set, function.Recv.List[0].Type) + if err == nil { + name = "(" + receiver.String() + ")." + name + } + } + functions = append(functions, goFunction{node: function, path: "", base: name, id: ""}) + case *ast.FuncLit: + functions = append(functions, goFunction{node: function, path: "", base: topLevel[function], id: ""}) + } + return true + }) + sort.Slice(functions, func(i, j int) bool { return functions[i].node.Pos() < functions[j].node.Pos() }) + assignGoFunctionIDs(functions, goFunctionParents(functions)) + return functions +} + +func topLevelLiterals(file *ast.File) map[*ast.FuncLit]string { + topLevel := make(map[*ast.FuncLit]string) + for _, decl := range file.Decls { + group, ok := decl.(*ast.GenDecl) + if !ok { + continue + } + for _, spec := range group.Specs { + value, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + for i, expr := range value.Values { + if literal, ok := expr.(*ast.FuncLit); ok && i < len(value.Names) { + topLevel[literal] = value.Names[i].Name + } + } + } + } + return topLevel +} + +func goFunctionParents(functions []goFunction) []int { + parent := make([]int, len(functions)) + for index := range parent { + parent[index] = -1 + } + for index, function := range functions { + if function.base != "" { + continue + } + parentSpan := token.Pos(0) + for candidate, other := range functions { + if candidate == index || other.node.Pos() > function.node.Pos() || other.node.End() < function.node.End() { + continue + } + span := other.node.End() - other.node.Pos() + if parent[index] == -1 || span < parentSpan { + parent[index] = candidate + parentSpan = span + } + } + } + return parent +} + +func assignGoFunctionIDs(functions []goFunction, parent []int) { + closureCounts := make(map[int]int) + duplicates := make(map[string]int) + totals := make(map[string]int) + for _, function := range functions { + if function.base != "" { + totals[function.base]++ + } + } + for index := range functions { + if functions[index].base != "" { + name := functions[index].base + if totals[name] > 1 { + duplicates[name]++ + name += "#" + strconv.Itoa(duplicates[name]) + } + functions[index].id = name + continue + } + owner := parent[index] + closureCounts[owner]++ + name := "closure-" + strconv.Itoa(closureCounts[owner]) + if owner >= 0 { + functions[index].id = functions[owner].id + "#" + name + } else { + functions[index].id = name + } + } +} + +func findGoFunction( + root, module string, files map[string]goSourceFile, reportedPath string, line, column int, +) (string, string, error) { + path := filepath.ToSlash(reportedPath) + if filepath.IsAbs(reportedPath) { + relative, err := filepath.Rel(root, reportedPath) + if err != nil { + return "", "", fmt.Errorf("relativize Go diagnostic path: %w", err) + } + path = filepath.ToSlash(relative) + } else if module != "." { + candidate := filepath.ToSlash(filepath.Join(module, path)) + if _, exists := files[candidate]; exists { + path = candidate + } + } + source, exists := files[path] + if !exists { + return "", "", fmt.Errorf("go diagnostic references unknown source %q: %w", reportedPath, errGoSourceUnknown) + } + if len(source.functions) == 0 { + return "", "", fmt.Errorf("go diagnostic references %q, which has no functions: %w", path, errGoSourceNoFunctions) + } + tokenFile := source.set.File(source.functions[0].node.Pos()) + if line < 1 || line > tokenFile.LineCount() || column < 1 { + return "", "", fmt.Errorf("go diagnostic has invalid position %s:%d:%d: %w", path, line, column, errGoPosition) + } + position := tokenFile.LineStart(line) + token.Pos(column-1) + if position > token.Pos(tokenFile.Base()+tokenFile.Size()) { + return "", "", fmt.Errorf("go diagnostic has invalid position %s:%d:%d: %w", path, line, column, errGoPosition) + } + var match *goFunction + var smallest token.Pos + for index := range source.functions { + function := &source.functions[index] + if function.node.Pos() <= position && position <= function.node.End() { + span := function.node.End() - function.node.Pos() + if match == nil || span < smallest { + match = function + smallest = span + } + } + } + if match == nil { + return "", "", fmt.Errorf("go diagnostic at %s:%d:%d is outside any function: %w", + path, line, column, errGoOutsideFunction) + } + return path, match.id, nil +} diff --git a/go/internal/codeshape/golang/gometrics_test.go b/go/internal/codeshape/golang/gometrics_test.go new file mode 100644 index 0000000..a189fd3 --- /dev/null +++ b/go/internal/codeshape/golang/gometrics_test.go @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package golang + +import ( + "fmt" + "go/parser" + "go/token" + "os/exec" + "strings" + "testing" + + "github.com/RigelBuild/meissa/go/internal/codeshape" +) + +func TestGoCollectorFixture(t *testing.T) { + root := newFixtureRepo(t) + trackedFile(t, root, "go.mod", "module example.com/fixture\n\ngo 1.25\n") + var source strings.Builder + source.WriteString("package fixture\n") + for i := range 25 { + fmt.Fprintf(&source, "func F%d() {\n", i) + for range 41 { + source.WriteString("println(1)\n") + } + source.WriteString("}\n") + } + trackedFile(t, root, "fixture.go", source.String()) + commitFiles(t, root) + cfg := testConfig() + cfg.Functions = codeshape.FunctionLimits{Go: codeshape.GoFunctionLimits{Cognitive: 20, Lines: 60, Statements: 40}, + TS: codeshape.TSFunctionLimits{Cognitive: 0, Lines: 0}} + values, _, err := GoMetrics(t.Context(), root, cfg) + if err != nil { + t.Fatal(err) + } + count := 0 + for _, entry := range values[codeshape.MetricGoStatements]["fixture.go"] { + if entry.Value > 40 { + count++ + } + } + if count != 25 { + t.Fatalf("got %d offenders, want 25: %#v", count, values) + } + binary, err := exec.LookPath("golangci-lint") + if err != nil { + t.Fatal(err) + } + report, err := runGolangci(t.Context(), binary, root, ".", "funlen", 10, 10, cfg) + if err != nil { + t.Fatal(err) + } + if len(report.Issues) != 25 { + t.Fatalf("one funlen run reported %d issues for 25 functions exceeding both limits", len(report.Issues)) + } +} + +func TestGoInitIDsStableWhenDifferentFunctionAdded(t *testing.T) { + ids := func(source string) []string { + set := token.NewFileSet() + file, err := parser.ParseFile(set, "x.go", source, 0) + if err != nil { + t.Fatal(err) + } + functions := goFunctions(file, set) + result := []string{} + for _, fn := range functions { + if strings.HasPrefix(fn.id, "init") { + result = append(result, fn.id) + } + } + return result + } + a := ids("package p\nfunc init() {}\nfunc init() {}") + b := ids("package p\nfunc other() {}\nfunc init() {}\nfunc init() {}") + if len(a) != 2 || a[0] == a[1] || strings.Join(a, ",") != strings.Join(b, ",") { + t.Fatalf("unstable ids: %v %v", a, b) + } +} + +func TestGoTopLevelLiteralIdentity(t *testing.T) { + ids := func(source string) []goFunction { + set := token.NewFileSet() + file, err := parser.ParseFile(set, "x.go", source, 0) + if err != nil { + t.Fatal(err) + } + return goFunctions(file, set) + } + a := ids("package p\nvar target = func() {}") + b := ids("package p\nvar other = func() {}\nvar target = func() {}") + if a[0].id != "target" || b[1].id != a[0].id { + t.Fatalf("ids %v %v", a, b) + } +} + +func TestGoSuppressionFinding(t *testing.T) { + root := newFixtureRepo(t) + trackedFile(t, root, "go.mod", "module example.com/x\n\ngo 1.25\n") + trackedFile(t, root, "x.go", "package p\n//nolint:gocognit,funlen\n//nolint:all\n"+ + "//nolint\n//nolint:gocyclo\n//nolint:unused\nfunc x() {}\n") + commitFiles(t, root) + findings, err := SuppressionFindings(t.Context(), root, testConfig()) + if err != nil || len(findings) != 7 || findings[0].Path != "x.go:2" { + t.Fatalf("findings=%v err=%v", findings, err) + } +} +func goTestRepo(t *testing.T, source string) string { + t.Helper() + root := newFixtureRepo(t) + writeConfigFiles(t, root, validConfig, "files: {}\n") + trackedFile(t, root, "go.mod", "module example.com/fixture\n\ngo 1.25\n") + trackedFile(t, root, "x.go", source) + commitFiles(t, root) + return root +} +func TestGoNestedModules(t *testing.T) { + root := goTestRepo(t, "package p\nfunc Parent() {\n"+strings.Repeat("println(1)\n", 41)+"}\n") + trackedFile(t, root, "child/go.mod", "module example.com/child\n\ngo 1.25\n") + trackedFile(t, root, "child/child.go", "package child\nfunc Child() {\n"+strings.Repeat("println(1)\n", 41)+"}\n") + commitFiles(t, root) + values, _, err := GoMetrics(t.Context(), root, testConfig()) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{"x.go", "child/child.go"} { + entries := values[codeshape.MetricGoStatements][path] + if len(entries) != 1 || entries[0].Value != 41 { + t.Fatalf("%s: %#v", path, entries) + } + } + if len(values[codeshape.MetricGoStatements]) != 2 { + t.Fatalf("unexpected paths: %#v", values[codeshape.MetricGoStatements]) + } +} + +func TestGoExcludedAndUnbuiltFiles(t *testing.T) { + root := goTestRepo(t, "package p\nfunc Parent() {}\n") + offender := "package fixture\nfunc Large() {\n" + strings.Repeat("println(1)\n", 41) + "}\n" + trackedFile(t, root, "fixtures/x.go", offender) + trackedFile(t, root, "excluded/x.go", offender) + trackedFile(t, root, "tagged.go", "//go:build integration\n\n"+offender) + trackedFile(t, root, "platform_windows.go", offender) + commitFiles(t, root) + cfg := testConfig() + cfg.Exclude = []string{"excluded/**"} + values, skips, err := GoMetrics(t.Context(), root, cfg) + if err != nil { + t.Fatal(err) + } + if len(skips) != 2 || len(values[codeshape.MetricGoStatements]) != 1 { + t.Fatalf("skips=%v measurements=%v", skips, values[codeshape.MetricGoStatements]) + } +} +func TestGoExcludedModule(t *testing.T) { + root := goTestRepo(t, "package p\nfunc Parent() {}\n") + trackedFile(t, root, "excluded/go.mod", "module example.com/excluded\n\ngo 1.25\n") + trackedFile(t, root, "excluded/x.go", "not valid go") + commitFiles(t, root) + cfg := testConfig() + cfg.Exclude = []string{"excluded/**"} + _, _, err := GoMetrics(t.Context(), root, cfg) + if err != nil { + t.Fatal(err) + } + _, err = SuppressionFindings(t.Context(), root, cfg) + if err != nil { + t.Fatal(err) + } +} +func TestGoBareAndAllSuppressions(t *testing.T) { + root := goTestRepo(t, "package p\n//nolint\n//nolint:all\nfunc F() {}\n") + findings, err := SuppressionFindings(t.Context(), root, testConfig()) + if err != nil { + t.Fatal(err) + } + for _, line := range []int{2, 3} { + for _, metric := range []codeshape.Metric{codeshape.MetricGoCognitive, codeshape.MetricGoLines} { + found := false + for _, finding := range findings { + if finding.Path == fmt.Sprintf("x.go:%d", line) && finding.Value == 1 && finding.Metric == metric { + found = true + } + } + if !found { + t.Fatalf("missing %s line %d: %#v", metric, line, findings) + } + } + } +} diff --git a/go/internal/codeshape/golang/gosuppressions.go b/go/internal/codeshape/golang/gosuppressions.go new file mode 100644 index 0000000..18adb25 --- /dev/null +++ b/go/internal/codeshape/golang/gosuppressions.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package golang + +import ( + "context" + "fmt" + "go/ast" + "go/parser" + "go/token" + "strconv" + "strings" + + "github.com/RigelBuild/meissa/go/internal/codeshape" +) + +// SuppressionFindings reports Go complexity suppressions. +func SuppressionFindings(ctx context.Context, root string, cfg codeshape.Config) ([]codeshape.Finding, error) { + paths, err := codeshape.TrackedPaths(ctx, root) + if err != nil { + return nil, fmt.Errorf("collect Go suppression paths: %w", err) + } + modules := goModules(paths, cfg) + eligible := make(map[string]bool) + for _, module := range modules { + files, _, err := parseGoSources(root, module, paths, cfg) + if err != nil { + return nil, err + } + for path := range files { + eligible[path] = true + } + } + findings := []codeshape.Finding{} + for _, path := range paths { + if !eligible[path] { + continue + } + source, err := codeshape.ReadRepositoryFile(root, path) + if err != nil { + return nil, fmt.Errorf("read Go source %q: %w", path, err) + } + set := token.NewFileSet() + file, err := parser.ParseFile(set, path, source, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parse Go source %q: %w", path, err) + } + findings = append(findings, goCommentFindings(path, file.Comments, set)...) + } + codeshape.SortFindings(findings) + return findings, nil +} + +func goCommentFindings(path string, groups []*ast.CommentGroup, set *token.FileSet) []codeshape.Finding { + findings := []codeshape.Finding{} + for _, group := range groups { + for _, comment := range group.List { + for metric := range goDirectiveMetrics(comment.Text) { + line := set.Position(comment.Pos()).Line + findings = append(findings, codeshape.Finding{ + Path: path + ":" + strconv.Itoa(line), Metric: metric, Value: 1, Limit: 0, + Fix: "remove complexity nolint suppression", + }) + } + } + } + return findings +} + +func goDirectiveMetrics(comment string) map[codeshape.Metric]bool { + metrics := make(map[codeshape.Metric]bool) + if !strings.HasPrefix(comment, "//") { + return metrics + } + fields := strings.Fields(strings.TrimSpace(strings.TrimPrefix(comment, "//"))) + if len(fields) == 0 { + return metrics + } + directive := fields[0] + if directive == "nolint" || directive == "nolint:all" { + metrics[codeshape.MetricGoCognitive] = true + metrics[codeshape.MetricGoLines] = true + } else if after, ok := strings.CutPrefix(directive, "nolint:"); ok { + for name := range strings.SplitSeq(after, ",") { + switch name { + case "all": + metrics[codeshape.MetricGoCognitive] = true + metrics[codeshape.MetricGoLines] = true + case "gocognit", "gocyclo": + metrics[codeshape.MetricGoCognitive] = true + case "funlen": + metrics[codeshape.MetricGoLines] = true + } + } + } + return metrics +} diff --git a/go/internal/codeshape/golang/testmain_test.go b/go/internal/codeshape/golang/testmain_test.go new file mode 100644 index 0000000..148ad09 --- /dev/null +++ b/go/internal/codeshape/golang/testmain_test.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package golang + +import ( + "fmt" + "os" + "testing" +) + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "codeshape-go-test-") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + err = os.Setenv("TMPDIR", dir) + if err != nil { + fmt.Fprintln(os.Stderr, err) + fmt.Fprintln(os.Stderr, os.RemoveAll(dir)) + os.Exit(1) + } + code := m.Run() + err = os.RemoveAll(dir) + if err != nil { + fmt.Fprintln(os.Stderr, err) + code = 1 + } + os.Exit(code) +} diff --git a/go/internal/codeshape/inventory.go b/go/internal/codeshape/inventory.go index e53be9a..4467e36 100644 --- a/go/internal/codeshape/inventory.go +++ b/go/internal/codeshape/inventory.go @@ -47,7 +47,7 @@ func inventoryPaths(repository *os.Root, output []byte, cfg Config) (Snapshot, e continue } trackedPath := filepath.ToSlash(string(rawPath)) - if matchesAny(trackedPath, cfg.Exclude) { + if MatchesAny(trackedPath, cfg.Exclude) { continue } parent := filepath.ToSlash(filepath.Dir(filepath.FromSlash(trackedPath))) @@ -55,7 +55,7 @@ func inventoryPaths(repository *os.Root, output []byte, cfg Config) (Snapshot, e parent = "" } dirs[parent]++ - if !countedPath(trackedPath, cfg) { + if !CountedPath(trackedPath, cfg) { continue } file, err := repository.Open(filepath.FromSlash(trackedPath)) @@ -77,10 +77,11 @@ func inventoryPaths(repository *os.Root, output []byte, cfg Config) (Snapshot, e files[trackedPath] = lines } } - return Snapshot{Files: files, Dirs: dirs}, nil + return Snapshot{Files: files, Dirs: dirs, Functions: nil}, nil } -func countedPath(path string, cfg Config) bool { +// CountedPath reports whether the configuration includes a path. +func CountedPath(path string, cfg Config) bool { for segment := range strings.SplitSeq(path, "/") { if segment == "testdata" || segment == "fixtures" { return false @@ -90,10 +91,11 @@ func countedPath(path string, cfg Config) bool { if !slices.Contains(cfg.Extensions, extension) { return false } - return !matchesAny(path, cfg.Exclude) + return !MatchesAny(path, cfg.Exclude) } -func matchesAny(path string, patterns []string) bool { +// MatchesAny reports whether a path matches a pattern. +func MatchesAny(path string, patterns []string) bool { for _, pattern := range patterns { if matchGlob(pattern, path) { return true @@ -155,26 +157,14 @@ func physicalLines(source io.Reader) (int, bool, error) { for { chunk, readErr := reader.ReadSlice('\n') lineHasBytes = lineHasBytes || len(chunk) > 0 - body := chunk - if readErr == nil { - body = body[:len(body)-1] - } - if (readErr == nil || errors.Is(readErr, io.EOF)) && len(body) > 0 && body[len(body)-1] == '\r' { - body = body[:len(body)-1] - } - observe(body) - if readErr == nil || errors.Is(readErr, io.EOF) { - if lineHasBytes || readErr == nil { - count++ - if count <= 5 && (hasGeneratedMarker || hasCodeGenerated && hasDoNotEdit) { - generated = true - } - } - carryLen = 0 - lineHasBytes = false - hasGeneratedMarker = false - hasCodeGenerated = false - hasDoNotEdit = false + observe(trimPhysicalLine(chunk, readErr)) + complete := readErr == nil || errors.Is(readErr, io.EOF) + if complete { + increment := lineHasBytes || readErr == nil + count, generated = completePhysicalLine(count, generated, increment, + hasGeneratedMarker || hasCodeGenerated && hasDoNotEdit) + carryLen, lineHasBytes = 0, false + hasGeneratedMarker, hasCodeGenerated, hasDoNotEdit = false, false, false } if errors.Is(readErr, io.EOF) { break @@ -185,3 +175,37 @@ func physicalLines(source io.Reader) (int, bool, error) { } return count, generated, nil } + +func trimPhysicalLine(chunk []byte, readErr error) []byte { + body := chunk + if readErr == nil { + body = body[:len(body)-1] + } + if (readErr == nil || errors.Is(readErr, io.EOF)) && len(body) > 0 && body[len(body)-1] == '\r' { + body = body[:len(body)-1] + } + return body +} + +func completePhysicalLine(count int, generated, increment, marker bool) (int, bool) { + if increment { + count++ + generated = generated || count <= 5 && marker + } + return count, generated +} + +// TrackedPaths lists tracked repository paths. +func TrackedPaths(ctx context.Context, root string) ([]string, error) { + command := exec.CommandContext(ctx, "git", "ls-files", "-z") + command.Dir = root + output, err := command.Output() + if err != nil { + return nil, fmt.Errorf("list tracked files: %w", err) + } + paths := strings.Split(string(output), "\x00") + if len(paths) > 0 && paths[len(paths)-1] == "" { + paths = paths[:len(paths)-1] + } + return paths, nil +} diff --git a/go/internal/codeshape/inventory_test.go b/go/internal/codeshape/inventory_test.go index f03fa65..91c6670 100644 --- a/go/internal/codeshape/inventory_test.go +++ b/go/internal/codeshape/inventory_test.go @@ -12,6 +12,23 @@ import ( "testing" ) +func TestReadRepositoryFileRejectsEscapingSymlink(t *testing.T) { + root := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside") + err := os.WriteFile(outside, []byte("outside"), 0600) + if err != nil { + t.Fatal(err) + } + err = os.Symlink(outside, filepath.Join(root, "escape")) + if err != nil { + t.Fatal(err) + } + _, err = ReadRepositoryFile(root, "escape") + if err == nil { + t.Fatal("read escaped repository root") + } +} + func newFixtureRepo(t *testing.T) string { t.Helper() root := t.TempDir() @@ -60,7 +77,6 @@ func commitFiles(t *testing.T, root string) { t.Fatalf("git commit: %v: %s", err, output) } } - func TestInventory(t *testing.T) { t.Run("file one line over limit is measured", func(t *testing.T) { root := newFixtureRepo(t) @@ -73,7 +89,7 @@ func TestInventory(t *testing.T) { if snapshot.Files["large.go"] != 501 { t.Fatalf("file lines = %d, want 501", snapshot.Files["large.go"]) } - findings := Judge(testConfig(), emptyAllowlist(), emptyAllowlist(), snapshot) + findings := Judge(testConfig(), EmptyAllowlist(), EmptyAllowlist(), snapshot) if !hasFinding(findings, "large.go", MetricFiles, 501, 500, "split this file") { t.Fatalf("missing one-over finding: %#v", findings) } @@ -176,7 +192,7 @@ func TestInventoryDirectoryWidth(t *testing.T) { if snapshot.Dirs["wide"] != 11 { t.Fatalf("directory files = %d, want 11", snapshot.Dirs["wide"]) } - findings := Judge(testConfig(), emptyAllowlist(), emptyAllowlist(), snapshot) + findings := Judge(testConfig(), EmptyAllowlist(), EmptyAllowlist(), snapshot) if !hasFinding(findings, "wide", MetricDirs, 11, 10, "split the directory") { t.Fatalf("missing 11-file directory finding: %#v", findings) } @@ -191,61 +207,28 @@ func TestInventoryDirectoryWidth(t *testing.T) { if err != nil { t.Fatal(err) } - if findings := Judge(testConfig(), emptyAllowlist(), emptyAllowlist(), snapshot); len(findings) != 0 { + if findings := Judge(testConfig(), EmptyAllowlist(), EmptyAllowlist(), snapshot); len(findings) != 0 { t.Fatalf("10-file directory findings = %#v", findings) } }) } -func TestConfiguredDirectoryWidths(t *testing.T) { - for _, tc := range []struct { - name, config, want string - count int - }{ - { - name: "within limit", config: `dirLimits: "wide": {limit: 50, reason: "record decisions together"}`, - count: 30, want: "", - }, - { - name: "over limit", config: `dirLimits: "wide": {limit: 50, reason: "record decisions together"}`, - count: 51, want: "wide dirs value=51 limit=50 fix=reorganize the record's decisions\n", - }, - { - name: "overlapping limits", - config: `dirLimits: {"wide": {limit: 50, reason: "record decisions together"}, ` + - `"w*": {limit: 20, reason: "record decisions together"}}`, - count: 21, want: "wide dirs value=21 limit=20 fix=reorganize the record's decisions\n", - }, - {name: "excluded files", config: `exclude: ["wide/**"]`, count: 300, want: ""}, - } { - t.Run(tc.name, func(t *testing.T) { - root := newFixtureRepo(t) - config := strings.Replace(validConfig, "exclude: []", tc.config, 1) - if !strings.Contains(tc.config, "exclude:") { - config += "\nexclude: []\n" - } - writeConfigFiles(t, root, config, "files: {}\ndirs: {}\n") - for index := range tc.count { - trackedFile(t, root, fmt.Sprintf("wide/file-%03d.txt", index), "text\n") - } - commitFiles(t, root) - var stdout, stderr strings.Builder - status := Main(t.Context(), []string{root}, func(string) string { return "" }, &stdout, &stderr) - wantStatus := 0 - if tc.want != "" { - wantStatus = 1 - } - if status != wantStatus || stdout.String() != tc.want { - t.Fatalf("status=%d stdout=%q stderr=%q, want status=%d stdout=%q", - status, stdout.String(), stderr.String(), wantStatus, tc.want) - } - }) - } -} - func testConfig() Config { return Config{ FileLimit: 500, DirLimit: 10, Extensions: []string{".go", ".cue"}, Exclude: []string{}, DirLimits: map[string]DirLimit{}, + Functions: FunctionLimits{Go: GoFunctionLimits{Cognitive: 0, Lines: 0, Statements: 0}, + TS: TSFunctionLimits{Cognitive: 0, Lines: 0}}, + } +} +func TestToolVersionMismatch(t *testing.T) { + path := filepath.Join(t.TempDir(), "tool") + err := os.WriteFile(path, []byte("#!/bin/sh\necho 'Version: 1.0.0'\n"), 0700) + if err != nil { + t.Fatal(err) + } + err = CheckToolVersion(t.Context(), path, BiomeVersion) + if err == nil || !strings.Contains(err.Error(), "unsupported tool version") { + t.Fatalf("error = %v", err) } } diff --git a/go/internal/codeshape/judge.go b/go/internal/codeshape/judge.go index bf04db7..70463cd 100644 --- a/go/internal/codeshape/judge.go +++ b/go/internal/codeshape/judge.go @@ -11,8 +11,9 @@ func Judge(cfg Config, allow, baseAllow Allowlist, snap Snapshot) []Finding { findings := make([]Finding, 0) findings = append(findings, judgeMetric(cfg, snap.Files, allow.Files, MetricFiles)...) findings = append(findings, judgeMetric(cfg, snap.Dirs, allow.Dirs, MetricDirs)...) - findings = append(findings, judgeBaseEntries(allow, baseAllow)...) - for _, path := range sortedEntryPaths(allow.Files) { + findings = append(findings, judgeFunctions(cfg, snap.Functions, allow.Functions)...) + findings = append(findings, JudgeBaseEntries(allow, baseAllow)...) + for _, path := range SortedEntryPaths(allow.Files) { if _, exists := snap.Files[path]; !exists { findings = append(findings, Finding{ Path: path, @@ -23,32 +24,44 @@ func Judge(cfg Config, allow, baseAllow Allowlist, snap Snapshot) []Finding { }) } } - for _, path := range sortedEntryPaths(allow.Dirs) { + for _, path := range SortedEntryPaths(allow.Dirs) { if _, exists := snap.Dirs[path]; !exists { findings = append(findings, Finding{ Path: displayDir(path), Metric: MetricDirs, Value: 0, - Limit: cfg.limitFor(MetricDirs, path), + Limit: cfg.LimitFor(MetricDirs, path), Fix: "remove the stale allowlist entry", }) } } - sortFindings(findings) + for metric, paths := range allow.Functions { + for path, entries := range paths { + for _, entry := range entries { + if !functionExists(snap.Functions[metric][path], entry.ID) { + findings = append(findings, Finding{ + Path: path + "#" + entry.ID, Metric: metric, Value: 0, + Limit: cfg.Functions.LimitFor(metric), Fix: "remove the stale allowlist entry", + }) + } + } + } + } + SortFindings(findings) return findings } func judgeMetric(cfg Config, values map[string]int, entries map[string]Entry, metric Metric) []Finding { - paths := sortedEntryPaths(values) + paths := SortedEntryPaths(values) findings := make([]Finding, 0) for _, path := range paths { value := values[path] - limit := cfg.limitFor(metric, path) + limit := cfg.LimitFor(metric, path) entry, listed := entries[path] if !listed { if value > limit { findings = append(findings, Finding{ - Path: displayPath(metric, path), + Path: DisplayPath(metric, path), Metric: metric, Value: value, Limit: limit, @@ -59,7 +72,7 @@ func judgeMetric(cfg Config, values map[string]int, entries map[string]Entry, me } if value <= limit { findings = append(findings, Finding{ - Path: displayPath(metric, path), + Path: DisplayPath(metric, path), Metric: metric, Value: value, Limit: limit, @@ -69,7 +82,7 @@ func judgeMetric(cfg Config, values map[string]int, entries map[string]Entry, me } if value > entry.Value { findings = append(findings, Finding{ - Path: displayPath(metric, path), + Path: DisplayPath(metric, path), Metric: metric, Value: value, Limit: entry.Value, @@ -80,30 +93,43 @@ func judgeMetric(cfg Config, values map[string]int, entries map[string]Entry, me return findings } -func (cfg Config) limitFor(metric Metric, path string) int { - if metric != MetricDirs { - return cfg.FileLimit - } - limit := 0 - for pattern, entry := range cfg.DirLimits { - if matchGlob(pattern, path) && (limit == 0 || entry.Limit < limit) { - limit = entry.Limit +func judgeFunctions(cfg Config, values, entries map[Metric]map[string][]Entry) []Finding { + findings := make([]Finding, 0) + for _, metric := range FunctionMetrics() { + paths := SortedEntryPaths(values[metric]) + for _, path := range paths { + for _, current := range values[metric][path] { + limit := cfg.Functions.LimitFor(metric) + previous, listed := functionEntry(entries[metric][path], current.ID) + findingPath := path + "#" + current.ID + if !listed { + if current.Value > limit { + findings = append(findings, Finding{ + Path: findingPath, Metric: metric, Value: current.Value, + Limit: limit, Fix: "split this function by concern", + }) + } + continue + } + if current.Value <= limit { + findings = append(findings, Finding{ + Path: findingPath, Metric: metric, Value: current.Value, + Limit: limit, Fix: "remove the allowlist entry after splitting or shrinking", + }) + } else if current.Value > previous.Value { + findings = append(findings, Finding{ + Path: findingPath, Metric: metric, Value: current.Value, + Limit: previous.Value, Fix: "grew: split the offender instead of growing its recorded value", + }) + } + } } } - if limit == 0 { - return cfg.DirLimit - } - return limit -} - -func fixForPath(cfg Config, metric Metric, path string) string { - if metric == MetricDirs && cfg.limitFor(metric, path) != cfg.DirLimit { - return "reorganize the record's decisions" - } - return fixFor(metric) + return findings } -func judgeBaseEntries(allow, base Allowlist) []Finding { +// JudgeBaseEntries rejects new or raised allowlist entries against the base. +func JudgeBaseEntries(allow, base Allowlist) []Finding { if !base.Present { return nil } @@ -116,19 +142,69 @@ func judgeBaseEntries(allow, base Allowlist) []Finding { previous, exists := group.base[path] if !exists || entry.Value > previous.Value { findings = append(findings, Finding{ - Path: displayPath(group.metric, path), - Metric: group.metric, - Value: entry.Value, - Limit: previous.Value, - Fix: "do not add or raise allowlist entries in pull requests", + Path: DisplayPath(group.metric, path), Metric: group.metric, + Value: entry.Value, Limit: previous.Value, + Fix: "do not add or raise allowlist entries in pull requests", }) } } } + for metric, paths := range allow.Functions { + for path, entries := range paths { + for _, entry := range entries { + previous, exists := functionEntry(base.Functions[metric][path], entry.ID) + if !exists || entry.Value > previous.Value { + findings = append(findings, Finding{ + Path: path + "#" + entry.ID, Metric: metric, + Value: entry.Value, Limit: previous.Value, + Fix: "do not add or raise allowlist entries in pull requests", + }) + } + } + } + } return findings } -func sortedEntryPaths[V any](entries map[string]V) []string { +func functionEntry(entries []Entry, id string) (Entry, bool) { + for _, entry := range entries { + if entry.ID == id { + return entry, true + } + } + return Entry{ID: "", Value: 0, Reason: ""}, false +} +func functionExists(entries []Entry, id string) bool { + _, exists := functionEntry(entries, id) + return exists +} + +// LimitFor returns the limit for a path and metric. +func (cfg Config) LimitFor(metric Metric, path string) int { + if metric != MetricDirs { + return cfg.FileLimit + } + limit := 0 + for pattern, entry := range cfg.DirLimits { + if matchGlob(pattern, path) && (limit == 0 || entry.Limit < limit) { + limit = entry.Limit + } + } + if limit == 0 { + return cfg.DirLimit + } + return limit +} + +func fixForPath(cfg Config, metric Metric, path string) string { + if metric == MetricDirs && cfg.LimitFor(metric, path) != cfg.DirLimit { + return "reorganize the record's decisions" + } + return fixFor(metric) +} + +// SortedEntryPaths returns sorted path map keys. +func SortedEntryPaths[V any](entries map[string]V) []string { paths := make([]string, 0, len(entries)) for path := range entries { paths = append(paths, path) @@ -137,7 +213,8 @@ func sortedEntryPaths[V any](entries map[string]V) []string { return paths } -func sortFindings(findings []Finding) { +// SortFindings orders findings by path and metric. +func SortFindings(findings []Finding) { sort.Slice(findings, func(i, j int) bool { if findings[i].Path != findings[j].Path { return findings[i].Path < findings[j].Path @@ -146,7 +223,8 @@ func sortFindings(findings []Finding) { }) } -func displayPath(metric Metric, path string) string { +// DisplayPath formats a path for a finding. +func DisplayPath(metric Metric, path string) string { if metric == MetricDirs { return displayDir(path) } diff --git a/go/internal/codeshape/judge_test.go b/go/internal/codeshape/judge_test.go index 87ba284..4bb7e7c 100644 --- a/go/internal/codeshape/judge_test.go +++ b/go/internal/codeshape/judge_test.go @@ -11,43 +11,43 @@ func TestJudge(t *testing.T) { config := testConfig() t.Run("growth fails with grew", func(t *testing.T) { allow := Allowlist{ - Files: map[string]Entry{"large.go": {Value: 501, Reason: "split by concern"}}, - Dirs: map[string]Entry{}, - Present: false, + Files: map[string]Entry{"large.go": {ID: "", Value: 501, Reason: "split by concern"}}, + Dirs: map[string]Entry{}, Functions: nil, Present: false, } snapshot := Snapshot{ - Files: map[string]int{"large.go": 502}, - Dirs: map[string]int{}, + Files: map[string]int{"large.go": 502}, + Dirs: map[string]int{}, + Functions: nil, } - findings := Judge(config, allow, emptyAllowlist(), snapshot) + findings := Judge(config, allow, EmptyAllowlist(), snapshot) if !hasFinding(findings, "large.go", MetricFiles, 502, 501, "grew") { t.Fatalf("missing grew finding: %#v", findings) } }) t.Run("entry at limit fails with remove", func(t *testing.T) { allow := Allowlist{ - Files: map[string]Entry{"small.go": {Value: 501, Reason: "split by concern"}}, - Dirs: map[string]Entry{}, - Present: false, + Files: map[string]Entry{"small.go": {ID: "", Value: 501, Reason: "split by concern"}}, + Dirs: map[string]Entry{}, Functions: nil, Present: false, } snapshot := Snapshot{ - Files: map[string]int{"small.go": 500}, - Dirs: map[string]int{}, + Files: map[string]int{"small.go": 500}, + Dirs: map[string]int{}, + Functions: nil, } - findings := Judge(config, allow, emptyAllowlist(), snapshot) + findings := Judge(config, allow, EmptyAllowlist(), snapshot) if !hasFinding(findings, "small.go", MetricFiles, 500, 500, "remove") { t.Fatalf("missing remove finding: %#v", findings) } }) t.Run("missing entry fails with remove", func(t *testing.T) { allow := Allowlist{ - Files: map[string]Entry{"gone.go": {Value: 501, Reason: "split by concern"}}, - Dirs: map[string]Entry{}, - Present: false, + Files: map[string]Entry{"gone.go": {ID: "", Value: 501, Reason: "split by concern"}}, + Dirs: map[string]Entry{}, Functions: nil, Present: false, } - findings := Judge(config, allow, emptyAllowlist(), Snapshot{ - Files: map[string]int{}, - Dirs: map[string]int{}, + findings := Judge(config, allow, EmptyAllowlist(), Snapshot{ + Files: map[string]int{}, + Dirs: map[string]int{}, + Functions: nil, }) if !hasFinding(findings, "gone.go", MetricFiles, 0, 500, "remove") { t.Fatalf("missing stale entry finding: %#v", findings) @@ -59,15 +59,15 @@ func TestJudgeBase(t *testing.T) { config := testConfig() t.Run("base rejects an added entry", func(t *testing.T) { allow := Allowlist{ - Files: map[string]Entry{"large.go": {Value: 501, Reason: "split by concern"}}, - Dirs: map[string]Entry{}, - Present: false, + Files: map[string]Entry{"large.go": {ID: "", Value: 501, Reason: "split by concern"}}, + Dirs: map[string]Entry{}, Functions: nil, Present: false, } - base := emptyAllowlist() + base := EmptyAllowlist() base.Present = true findings := Judge(config, allow, base, Snapshot{ - Files: map[string]int{"large.go": 501}, - Dirs: map[string]int{}, + Files: map[string]int{"large.go": 501}, + Dirs: map[string]int{}, + Functions: nil, }) if !hasFinding(findings, "large.go", MetricFiles, 501, 0, "do not add") { t.Fatalf("missing added-entry finding: %#v", findings) @@ -75,18 +75,17 @@ func TestJudgeBase(t *testing.T) { }) t.Run("base rejects raised entry", func(t *testing.T) { allow := Allowlist{ - Files: map[string]Entry{"large.go": {Value: 502, Reason: "split by concern"}}, - Dirs: map[string]Entry{}, - Present: false, + Files: map[string]Entry{"large.go": {ID: "", Value: 502, Reason: "split by concern"}}, + Dirs: map[string]Entry{}, Functions: nil, Present: false, } base := Allowlist{ - Files: map[string]Entry{"large.go": {Value: 501, Reason: "split by concern"}}, - Dirs: map[string]Entry{}, - Present: true, + Files: map[string]Entry{"large.go": {ID: "", Value: 501, Reason: "split by concern"}}, + Dirs: map[string]Entry{}, Functions: nil, Present: true, } findings := Judge(config, allow, base, Snapshot{ - Files: map[string]int{"large.go": 502}, - Dirs: map[string]int{}, + Files: map[string]int{"large.go": 502}, + Dirs: map[string]int{}, + Functions: nil, }) if !hasFinding(findings, "large.go", MetricFiles, 502, 501, "do not add") { t.Fatalf("missing raised-entry finding: %#v", findings) @@ -94,29 +93,29 @@ func TestJudgeBase(t *testing.T) { }) t.Run("base absence is head only", func(t *testing.T) { allow := Allowlist{ - Files: map[string]Entry{"large.go": {Value: 501, Reason: "split by concern"}}, - Dirs: map[string]Entry{}, - Present: false, + Files: map[string]Entry{"large.go": {ID: "", Value: 501, Reason: "split by concern"}}, + Dirs: map[string]Entry{}, Functions: nil, Present: false, } snapshot := Snapshot{ - Files: map[string]int{"large.go": 501}, - Dirs: map[string]int{}, + Files: map[string]int{"large.go": 501}, + Dirs: map[string]int{}, + Functions: nil, } - if findings := Judge(config, allow, emptyAllowlist(), snapshot); len(findings) != 0 { + if findings := Judge(config, allow, EmptyAllowlist(), snapshot); len(findings) != 0 { t.Fatalf("head-only findings = %#v", findings) } }) t.Run("base rejects entries for missing subjects", func(t *testing.T) { allow := Allowlist{ - Files: map[string]Entry{"gone.go": {Value: 501, Reason: "split by concern"}}, - Dirs: map[string]Entry{}, - Present: false, + Files: map[string]Entry{"gone.go": {ID: "", Value: 501, Reason: "split by concern"}}, + Dirs: map[string]Entry{}, Functions: nil, Present: false, } - base := emptyAllowlist() + base := EmptyAllowlist() base.Present = true findings := Judge(config, allow, base, Snapshot{ - Files: map[string]int{}, - Dirs: map[string]int{}, + Files: map[string]int{}, + Dirs: map[string]int{}, + Functions: nil, }) if !hasFinding(findings, "gone.go", MetricFiles, 501, 0, "do not add") { t.Fatalf("missing base finding: %#v", findings) @@ -136,3 +135,39 @@ func hasFinding(findings []Finding, path string, metric Metric, value, limit int } return false } + +func TestJudgeFunctionRatchets(t *testing.T) { + cfg := testConfig() + cfg.Functions.Go.Cognitive = 20 + entries := []Entry{{ID: "first", Value: 30, Reason: ""}, {ID: "second", Value: 30, Reason: ""}} + allow := Allowlist{Files: nil, Dirs: nil, + Functions: FunctionMeasurements{MetricGoCognitive: {"f.go": entries}}, Present: false} + values := FunctionMeasurements{MetricGoCognitive: {"f.go": { + {ID: "first", Value: 31, Reason: ""}, {ID: "second", Value: 29, Reason: ""}, + }}} + findings := Judge(cfg, allow, EmptyAllowlist(), Snapshot{Files: nil, Dirs: nil, Functions: values}) + if len(findings) != 1 || !hasFinding(findings, "f.go#first", MetricGoCognitive, 31, 30, "grew") { + t.Fatalf("independent ratchets: %#v", findings) + } + values[MetricGoCognitive]["f.go"][0].Value = 30 + findings = Judge(cfg, allow, EmptyAllowlist(), Snapshot{Files: nil, Dirs: nil, Functions: values}) + if len(findings) != 0 { + t.Fatalf("unchanged and shrunk functions: %#v", findings) + } +} + +func TestJudgeFunctionRenameAndStale(t *testing.T) { + cfg := testConfig() + cfg.Functions.Go.Lines = 60 + base := Allowlist{Files: nil, Dirs: nil, Present: true, + Functions: FunctionMeasurements{MetricGoLines: {"f.go": {{ID: "old", Value: 70, Reason: ""}}}}} + head := Allowlist{Files: nil, Dirs: nil, Present: false, Functions: FunctionMeasurements{MetricGoLines: {"f.go": { + {ID: "new", Value: 70, Reason: ""}, {ID: "old", Value: 70, Reason: ""}, + }}}} + values := FunctionMeasurements{MetricGoLines: {"f.go": {{ID: "new", Value: 70, Reason: ""}}}} + findings := Judge(cfg, head, base, Snapshot{Files: nil, Dirs: nil, Functions: values}) + if !hasFinding(findings, "f.go#old", MetricGoLines, 0, 60, "remove") || + !hasFinding(findings, "f.go#new", MetricGoLines, 70, 0, "do not add") { + t.Fatalf("rename findings: %#v", findings) + } +} diff --git a/go/internal/codeshape/metrics.go b/go/internal/codeshape/metrics.go new file mode 100644 index 0000000..23e9547 --- /dev/null +++ b/go/internal/codeshape/metrics.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +// Metric identifies one measured code-shape dimension. +type Metric string + +// Metric constants identify the supported code-shape dimensions. +const ( + MetricFiles Metric = "files" + MetricDirs Metric = "dirs" + MetricGoCognitive Metric = "go-cognitive" + MetricGoLines Metric = "go-lines" + MetricGoStatements Metric = "go-statements" + MetricTSCognitive Metric = "ts-cognitive" + MetricTSLines Metric = "ts-lines" + MetricUnmeasured Metric = "unmeasured" +) + +// FunctionMeasurements maps each metric and source path to individual functions. +type FunctionMeasurements = map[Metric]map[string][]Entry diff --git a/go/internal/codeshape/model.go b/go/internal/codeshape/model.go new file mode 100644 index 0000000..f1e3c30 --- /dev/null +++ b/go/internal/codeshape/model.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +// Config contains the repository's file, directory, and function thresholds. +type Config struct { + FileLimit int `json:"fileLimit"` + DirLimit int `json:"dirLimit"` + Extensions []string `json:"extensions"` + Exclude []string `json:"exclude"` + DirLimits map[string]DirLimit `json:"dirLimits"` + Functions FunctionLimits `json:"functions"` +} + +// DirLimit records a directory width exception and its reason. +type DirLimit struct { + Limit int `json:"limit"` + Reason string `json:"reason"` +} + +// Entry records a measured value and its burn-down reason. +type Entry struct { + ID string `json:"id,omitempty"` + Value int `json:"value"` + Reason string `json:"reason"` +} + +// Allowlist contains file, directory, and function ratchet entries. +type Allowlist struct { + Files map[string]Entry `json:"files,omitempty"` + Dirs map[string]Entry `json:"dirs,omitempty"` + Functions map[Metric]map[string][]Entry `json:"functions,omitempty"` + Present bool `json:"-"` +} + +// EmptyAllowlist creates an empty allowlist. +func EmptyAllowlist() Allowlist { + return Allowlist{Files: map[string]Entry{}, Dirs: map[string]Entry{}, + Functions: map[Metric]map[string][]Entry{}, Present: false} +} + +// Snapshot contains current measurements. +type Snapshot struct { + Files map[string]int + Dirs map[string]int + Functions map[Metric]map[string][]Entry +} + +// Finding describes one enforced size or ratchet violation. +type Finding struct { + Path string + Metric Metric + Value int + Limit int + Fix string +} diff --git a/go/internal/codeshape/render.go b/go/internal/codeshape/render.go deleted file mode 100644 index 2370d26..0000000 --- a/go/internal/codeshape/render.go +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package codeshape - -import ( - "sort" - "strconv" - "strings" -) - -// Render serializes the T1 file and directory entries in stable key order. -func Render(allow Allowlist) []byte { - var output strings.Builder - writeEntries(&output, "files", allow.Files) - writeEntries(&output, "dirs", allow.Dirs) - if len(allow.Files) == 0 && len(allow.Dirs) == 0 { - output.WriteString("files: {}\n") - } - return []byte(output.String()) -} - -func writeEntries(output *strings.Builder, name string, entries map[string]Entry) { - if len(entries) == 0 { - return - } - keys := make([]string, 0, len(entries)) - for key := range entries { - keys = append(keys, key) - } - sort.Strings(keys) - output.WriteString(name + ": {\n") - for _, key := range keys { - entry := entries[key] - output.WriteString("\t" + strconv.Quote(key) + ": {value: " + strconv.Itoa(entry.Value) + - ", reason: " + strconv.Quote(entry.Reason) + "},\n") - } - output.WriteString("}\n") -} diff --git a/go/internal/codeshape/schema.cue b/go/internal/codeshape/schema.cue deleted file mode 100644 index 26fa000..0000000 --- a/go/internal/codeshape/schema.cue +++ /dev/null @@ -1,18 +0,0 @@ -#Entry: close({ - value: int & >=0 - reason: string & =~"^\\S+(\\s+\\S+){2,}$" -}) - -#Config: close({ - fileLimit: 500 - dirLimit: 10 - extensions: [...string] - exclude: [...string] - // When multiple globs match, the smallest limit wins. - dirLimits?: [string]: {limit: int & >10, reason: string & =~"^\\S+(\\s+\\S+){2,}$"} -}) - -#Allowlist: close({ - files?: [string]: #Entry - dirs?: [string]: #Entry -}) diff --git a/go/internal/codeshape/testmain_test.go b/go/internal/codeshape/testmain_test.go new file mode 100644 index 0000000..9ece698 --- /dev/null +++ b/go/internal/codeshape/testmain_test.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +import ( + "fmt" + "os" + "testing" +) + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "codeshape-test-") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + err = os.Setenv("TMPDIR", dir) + if err != nil { + fmt.Fprintln(os.Stderr, err) + fmt.Fprintln(os.Stderr, os.RemoveAll(dir)) + os.Exit(1) + } + code := m.Run() + err = os.RemoveAll(dir) + if err != nil { + fmt.Fprintln(os.Stderr, err) + code = 1 + } + os.Exit(code) +} diff --git a/go/internal/codeshape/toolversion.go b/go/internal/codeshape/toolversion.go new file mode 100644 index 0000000..078c610 --- /dev/null +++ b/go/internal/codeshape/toolversion.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +import ( + "context" + "errors" + "fmt" + "os/exec" + "slices" + "strings" +) + +const ( + // GolangciVersion is the supported golangci-lint release. + GolangciVersion = "2.15.0" + // BiomeVersion is the supported Biome release. + BiomeVersion = "2.5.15" +) + +var errToolVersion = errors.New("unsupported tool version") + +// CheckToolVersion verifies the linter release before parsing its output. +func CheckToolVersion(ctx context.Context, binary, version string) error { + command := exec.CommandContext(ctx, binary, "--version") + output, err := command.Output() + if err != nil { + return fmt.Errorf("read tool version: %w", err) + } + if slices.Contains(strings.Fields(string(output)), version) { + return nil + } + return fmt.Errorf("%s: expected %s, got %q: %w", binary, version, strings.TrimSpace(string(output)), errToolVersion) +} diff --git a/go/internal/codeshape/typescript/biome_config.go b/go/internal/codeshape/typescript/biome_config.go new file mode 100644 index 0000000..a185c90 --- /dev/null +++ b/go/internal/codeshape/typescript/biome_config.go @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package typescript + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "github.com/RigelBuild/meissa/go/internal/codeshape" +) + +func biomeConfigNames() []string { + return []string{"biome.json", "biome.jsonc", ".biome.json", ".biome.jsonc"} +} + +func writeGateBiomeConfig(dir string, limits codeshape.FunctionLimits, parser json.RawMessage, maxSize int) ( + string, error, +) { + config := map[string]any{ + "vcs": map[string]any{"enabled": false}, + "files": map[string]any{"includes": []string{"**"}, "maxSize": maxSize}, + "linter": map[string]any{"rules": map[string]any{"recommended": false, "complexity": map[string]any{ + "noExcessiveLinesPerFunction": map[string]any{ + "level": "warn", "options": map[string]int{"maxLines": limits.TS.Lines}}, + "noExcessiveCognitiveComplexity": map[string]any{ + "level": "warn", "options": map[string]int{"maxAllowedComplexity": limits.TS.Cognitive}}, + }}}, + } + if len(parser) > 0 { + config["javascript"] = map[string]any{"parser": parser} + } + data, err := json.Marshal(config) + if err != nil { + return "", fmt.Errorf("encode Biome config: %w", err) + } + name := filepath.Join(dir, "biome.json") + err = os.WriteFile(name, data, 0o600) + if err != nil { + return "", fmt.Errorf("write Biome config: %w", err) + } + return name, nil +} + +func rootBiomeParser(root string) (json.RawMessage, error) { + for _, name := range biomeConfigNames() { + config, err := readBiomeConfig(root, name) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return nil, err + } + javascript, ok := config["javascript"].(map[string]any) + if !ok || javascript["parser"] == nil { + return nil, nil + } + data, err := json.Marshal(javascript["parser"]) + if err != nil { + return nil, fmt.Errorf("encode Biome parser options: %w", err) + } + return data, nil + } + return nil, nil +} +func runBiome(ctx context.Context, binary, root, configDir string, paths []string) (biomeReport, error) { + args := make([]string, 0, 5+len(paths)) + args = append(args, "lint", "--reporter=json", "--max-diagnostics="+strconv.Itoa(maxBiomeDiagnostics), + "--only=complexity/noExcessiveCognitiveComplexity", "--only=complexity/noExcessiveLinesPerFunction", + "--no-errors-on-unmatched", "--config-path", configDir, "--") + args = append(args, paths...) + command := exec.CommandContext(ctx, binary, args...) //nolint:gosec // G204: execute the biome linter + command.Dir = root + var stdout, stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + runErr := command.Run() + var exitErr *exec.ExitError + if runErr != nil && (!errors.As(runErr, &exitErr) || exitErr.ExitCode() != 1) { + return biomeReport{}, fmt.Errorf("biome lint failed: %w: %s", runErr, strings.TrimSpace(stderr.String())) + } + var report biomeReport + err := json.Unmarshal(stdout.Bytes(), &report) + if err != nil { + return biomeReport{}, fmt.Errorf("parse biome output: %w: %s", err, strings.TrimSpace(stderr.String())) + } + if report.Summary == nil || report.Diagnostics == nil { + return biomeReport{}, errBiomeMissingOutput + } + err = validateBiomeErrors(report) + if err != nil { + return biomeReport{}, err + } + return report, nil +} diff --git a/go/internal/codeshape/typescript/biome_config_test.go b/go/internal/codeshape/typescript/biome_config_test.go new file mode 100644 index 0000000..5b4bdd1 --- /dev/null +++ b/go/internal/codeshape/typescript/biome_config_test.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package typescript + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/RigelBuild/meissa/go/internal/codeshape" + shapeconfig "github.com/RigelBuild/meissa/go/internal/codeshape/config" +) + +func TestGateBiomeConfig(t *testing.T) { + limits := codeshape.FunctionLimits{Go: codeshape.GoFunctionLimits{Cognitive: 20, Lines: 60, Statements: 40}, + TS: codeshape.TSFunctionLimits{Cognitive: 15, Lines: 50}} + name, err := writeGateBiomeConfig(t.TempDir(), limits, + json.RawMessage(`{"unsafeParameterDecoratorsEnabled":true}`), 1048576) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + var config map[string]any + err = json.Unmarshal(data, &config) + if err != nil { + t.Fatal(err) + } + rules := nestedMap(config, "linter", "rules", "complexity") + lines := nestedMap(rules, "noExcessiveLinesPerFunction", "options") + cognitive := nestedMap(rules, "noExcessiveCognitiveComplexity", "options") + if lines["maxLines"] != float64(50) || cognitive["maxAllowedComplexity"] != float64(15) || + nestedMap(config, "javascript", "parser")["unsafeParameterDecoratorsEnabled"] != true { + t.Fatalf("config=%s", data) + } +} + +func TestGateBiomeIgnoresRepositoryLimit(t *testing.T) { + root := tsRepo(t, "function f() {\n"+strings.Repeat("console.log(1);\n", 51)+"}\n") + data, err := os.ReadFile(filepath.Join(root, "biome.json")) + if err != nil { + t.Fatal(err) + } + trackedFile(t, root, "biome.json", strings.Replace(string(data), `"maxLines":50`, `"maxLines":1000`, 1)) + commitFiles(t, root) + cfg, _, err := shapeconfig.Load(root) + if err != nil { + t.Fatal(err) + } + values, _, err := TSMetrics(t.Context(), root, cfg) + if err != nil { + t.Fatal(err) + } + entries := values[codeshape.MetricTSLines]["x.ts"] + if len(entries) != 1 || entries[0].Value <= 50 { + t.Fatalf("entries=%v", entries) + } +} + +func TestGateBiomeMeasuresLargeFile(t *testing.T) { + root := tsRepo(t, "function large() {\n"+strings.Repeat(" ", 1100000)+"\n}\n") + cfg, _, err := shapeconfig.Load(root) + if err != nil { + t.Fatal(err) + } + values, _, err := TSMetrics(t.Context(), root, cfg) + if err != nil || len(values[codeshape.MetricTSLines]["x.ts"]) != 1 { + t.Fatalf("values=%v err=%v", values, err) + } +} diff --git a/go/internal/codeshape/typescript/drift_test.go b/go/internal/codeshape/typescript/drift_test.go new file mode 100644 index 0000000..83fbd07 --- /dev/null +++ b/go/internal/codeshape/typescript/drift_test.go @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package typescript + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/RigelBuild/meissa/go/internal/codeshape" +) + +func TestBiomeDriftMissingOptions(t *testing.T) { + cfg := codeshape.Config{FileLimit: 500, DirLimit: 10, Extensions: []string{}, Exclude: []string{}, + DirLimits: map[string]codeshape.DirLimit{}, Functions: codeshape.FunctionLimits{ + Go: codeshape.GoFunctionLimits{Cognitive: 20, Lines: 60, Statements: 40}, + TS: codeshape.TSFunctionLimits{Cognitive: 15, Lines: 50}}} + findings := biomeDriftFindings("biome.json", map[string]any{}, cfg, true) + if len(findings) != 2 { + t.Fatalf("findings = %#v", findings) + } + config := map[string]any{"linter": map[string]any{"rules": map[string]any{"complexity": map[string]any{ + "noExcessiveCognitiveComplexity": map[string]any{ + "level": "off", "options": map[string]any{"maxAllowedComplexity": 15}}, + "noExcessiveLinesPerFunction": map[string]any{"level": "off", "options": map[string]any{"maxLines": 50}}, + }}}} + findings = biomeDriftFindings("biome.json", config, cfg, true) + if len(findings) != 2 { + t.Fatalf("disabled rules: %#v", findings) + } +} + +func TestBiomeJSONCAndMissingConfig(t *testing.T) { + root := t.TempDir() + _, err := readBiomeConfig(root, "biome.json") + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("expected missing error: %v", err) + } + err = os.WriteFile(filepath.Join(root, "biome.jsonc"), []byte("{/* comment */}"), 0600) + if err != nil { + t.Fatal(err) + } + _, err = readBiomeConfig(root, "biome.jsonc") + if err != nil { + t.Fatal(err) + } +} + +func TestTrackedBiomeDrift(t *testing.T) { + root := newFixtureRepo(t) + trackedFile(t, root, "src/x.ts", "function f() {}\n") + trackedFile(t, root, "nested/biome.jsonc", `{"linter":{"rules":{"complexity":`+ + `{"noExcessiveLinesPerFunction":{"level":"off","options":{"maxLines":50}}}}}}`) + trackedFile(t, root, "biome.json", `{"linter":{"rules":{"complexity":{`+ + `"noExcessiveCognitiveComplexity":{"level":"warn","options":{"maxAllowedComplexity":15}},`+ + `"noExcessiveLinesPerFunction":{"level":"warn","options":{"maxLines":50}}}}},`+ + `"overrides":[{"linter":{"rules":{"complexity":`+ + `{"noExcessiveLinesPerFunction":{"level":"off","options":{"maxLines":50}}}}}}]}`) + commitFiles(t, root) + cfg := codeshape.Config{FileLimit: 500, DirLimit: 10, Extensions: []string{".ts"}, Exclude: []string{}, + DirLimits: map[string]codeshape.DirLimit{}, Functions: codeshape.FunctionLimits{ + Go: codeshape.GoFunctionLimits{Cognitive: 20, Lines: 60, Statements: 40}, + TS: codeshape.TSFunctionLimits{Cognitive: 15, Lines: 50}}} + findings, err := PolicyFindings(t.Context(), root, cfg) + if err != nil || len(findings) != 2 { + t.Fatalf("findings=%#v err=%v", findings, err) + } + paths := map[string]bool{} + for _, finding := range findings { + paths[finding.Path] = true + } + if !paths["biome.json#overrides[0]"] || !paths["nested/biome.jsonc"] { + t.Fatalf("wrong drift paths: %#v", findings) + } +} + +func TestDotBiomeConfigDrift(t *testing.T) { + root := newFixtureRepo(t) + trackedFile(t, root, "x.ts", "function f() {}\n") + trackedFile(t, root, ".biome.json", `{"linter":{"enabled":false}}`) + commitFiles(t, root) + cfg := codeshape.Config{FileLimit: 500, DirLimit: 10, Extensions: []string{".ts"}, Exclude: []string{}, + DirLimits: map[string]codeshape.DirLimit{}, Functions: codeshape.FunctionLimits{ + Go: codeshape.GoFunctionLimits{Cognitive: 20, Lines: 60, Statements: 40}, + TS: codeshape.TSFunctionLimits{Cognitive: 15, Lines: 50}}} + findings, err := PolicyFindings(t.Context(), root, cfg) + if err != nil || len(findings) == 0 || findings[0].Path != ".biome.json" { + t.Fatalf("findings=%v err=%v", findings, err) + } +} diff --git a/go/internal/codeshape/typescript/fixtures_test.go b/go/internal/codeshape/typescript/fixtures_test.go new file mode 100644 index 0000000..d5473f7 --- /dev/null +++ b/go/internal/codeshape/typescript/fixtures_test.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package typescript + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func newFixtureRepo(t *testing.T) string { + t.Helper() + root := t.TempDir() + for _, args := range [][]string{ + {"init", "-q"}, {"config", "user.email", "test@example.invalid"}, {"config", "user.name", "test"}, + } { + cmd := exec.CommandContext(t.Context(), "git", args...) + cmd.Dir = root + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git: %v: %s", err, out) + } + } + return root +} +func trackedFile(t *testing.T, root, path, content string) { + t.Helper() + path = filepath.Join(root, filepath.FromSlash(path)) + err := os.MkdirAll(filepath.Dir(path), 0755) + if err != nil { + t.Fatal(err) + } + err = os.WriteFile(path, []byte(content), 0644) + if err != nil { + t.Fatal(err) + } +} +func commitFiles(t *testing.T, root string) { + t.Helper() + for _, args := range [][]string{{"add", "--all"}, {"commit", "-qm", "fixture"}} { + cmd := exec.CommandContext(t.Context(), "git", args...) + cmd.Dir = root + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git: %v: %s", err, out) + } + } +} + +const validConfig = "fileLimit: 500\ndirLimit: 10\nextensions: [\".go\", \".cue\"]\nexclude: []\n" + + "functions: {go: {cognitive: 20, lines: 60, statements: 40}, ts: {cognitive: 15, lines: 50}}\n" + +func writeConfigFiles(t *testing.T, root, config, allowlist string) { + t.Helper() + dir := filepath.Join(root, "tools", "code-shape-gate") + err := os.MkdirAll(dir, 0755) + if err != nil { + t.Fatal(err) + } + for name, content := range map[string]string{"config.cue": config, "allowlist.cue": allowlist} { + err = os.WriteFile(filepath.Join(dir, name), []byte(content), 0644) + if err != nil { + t.Fatal(err) + } + } +} diff --git a/go/internal/codeshape/typescript/tsmetrics.go b/go/internal/codeshape/typescript/tsmetrics.go new file mode 100644 index 0000000..f4c48c0 --- /dev/null +++ b/go/internal/codeshape/typescript/tsmetrics.go @@ -0,0 +1,501 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +// Package typescript collects TypeScript function measurements. +package typescript + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "github.com/RigelBuild/meissa/go/internal/codeshape" + "github.com/odvcencio/gotreesitter" + "github.com/odvcencio/gotreesitter/grammars/tsx" + "github.com/odvcencio/gotreesitter/grammars/typescript" + "github.com/titanous/json5" +) + +const maxBiomeDiagnostics = 1_000_000 + +func biomeRuleMetrics() map[string]codeshape.Metric { + return map[string]codeshape.Metric{ + "lint/complexity/noExcessiveCognitiveComplexity": codeshape.MetricTSCognitive, + "lint/complexity/noExcessiveLinesPerFunction": codeshape.MetricTSLines, + } +} + +var ( + errBiomeTruncated = errors.New("biome truncated diagnostics") + errBiomeUnknownSource = errors.New("biome diagnostic references unknown source") + errBiomeMissingOutput = errors.New("parse biome output: missing summary or diagnostics") + errBiomeReported = errors.New("biome reported errors") + errBiomeValue = errors.New("cannot parse biome value") + errTSSyntax = errors.New("syntax errors") + errBiomePosition = errors.New("invalid biome source position") + errBiomeOutsideSource = errors.New("biome position outside source") + errBiomeOutsideFunction = errors.New("biome position outside any function") + errBiomeUnprocessed = errors.New("biome did not process all requested paths; add unprocessed paths to exclude") +) + +type biomeReport struct { + Summary *struct { + Changed int `json:"changed"` + Unchanged int `json:"unchanged"` + DiagnosticsNotPrinted int `json:"diagnosticsNotPrinted"` + Errors int `json:"errors"` + } `json:"summary"` + Diagnostics []struct { + Category string `json:"category"` + Severity string `json:"severity"` + Message string `json:"message"` + Location struct { + Path string `json:"path"` + Start struct { + Line int `json:"line"` + Column int `json:"column"` + } `json:"start"` + } `json:"location"` + } `json:"diagnostics"` +} + +type tsFunction struct { + node *gotreesitter.Node + id string +} + +// TSMetrics measures Biome's function complexity and line count rules. +func TSMetrics(ctx context.Context, root string, cfg codeshape.Config) ( + map[codeshape.Metric]map[string][]codeshape.Entry, []codeshape.Skip, error, +) { + paths, err := codeshape.TrackedPaths(ctx, root) + if err != nil { + return nil, nil, fmt.Errorf("collect TypeScript paths: %w", err) + } + sources, err := readTSSources(root, paths, cfg) + if err != nil { + return nil, nil, err + } + if len(sources) == 0 { + return codeshape.NewFunctionMeasurements(), nil, nil + } + report, err := prepareBiome(ctx, root, cfg, sources) + if err != nil { + return nil, nil, err + } + skips, err := biomeParseSkips(report, sources) + if err != nil { + return nil, nil, err + } + values := codeshape.NewFunctionMeasurements() + files := make(map[string][]tsFunction, len(sources)) + for path, source := range sources { + functions, tree, err := parseTSFunctions(path, source) + if err != nil { + skips = append(skips, codeshape.Skip{Path: path, Reason: err.Error(), + Unmeasured: &codeshape.UnmeasuredFunctions{Values: map[codeshape.Metric]int{}, BiomeParseFailed: false}}) + delete(sources, path) + continue + } + defer tree.Release() + files[path] = functions + for _, function := range functions { + codeshape.AppendFunctionValue(values, codeshape.MetricTSCognitive, path, function.id, 0) + codeshape.AppendFunctionValue(values, codeshape.MetricTSLines, path, function.id, 0) + } + } + err = applyBiomeIssues(report, files, sources, skips, values) + if err != nil { + return nil, nil, err + } + return values, skips, nil +} + +func prepareBiome(ctx context.Context, root string, cfg codeshape.Config, sources map[string][]byte) ( + biomeReport, error, +) { + binary, err := exec.LookPath("biome") + if err != nil { + return biomeReport{}, fmt.Errorf("find biome: %w", err) + } + err = codeshape.CheckToolVersion(ctx, binary, codeshape.BiomeVersion) + if err != nil { + return biomeReport{}, fmt.Errorf("check biome: %w", err) + } + var paths []string + for path := range sources { + paths = append(paths, path) + } + sort.Strings(paths) + dir, err := os.MkdirTemp("", "code-shape-biome-*") + if err != nil { + return biomeReport{}, fmt.Errorf("create Biome config directory: %w", err) + } + maxSize := 1024 * 1024 + for _, source := range sources { + maxSize = max(maxSize, len(source)+1024) + } + report, runErr := runGateBiome(ctx, binary, root, dir, paths, cfg.Functions, maxSize) + removeErr := os.RemoveAll(dir) + err = errors.Join(runErr, removeErr) + if err != nil { + return biomeReport{}, err + } + return report, nil +} + +func runGateBiome(ctx context.Context, binary, root, dir string, paths []string, + limits codeshape.FunctionLimits, maxSize int, +) ( + biomeReport, error, +) { + parser, err := rootBiomeParser(root) + if err != nil { + return biomeReport{}, err + } + _, err = writeGateBiomeConfig(dir, limits, parser, maxSize) + if err != nil { + return biomeReport{}, err + } + report, err := runBiome(ctx, binary, root, dir, paths) + if err != nil { + return biomeReport{}, err + } + if report.Summary.DiagnosticsNotPrinted != 0 { + return biomeReport{}, fmt.Errorf("%w: %d were not printed", + errBiomeTruncated, report.Summary.DiagnosticsNotPrinted) + } + processed := report.Summary.Changed + report.Summary.Unchanged + if processed != len(paths) { + return biomeReport{}, fmt.Errorf("processed %d of %d requested paths: %w", + processed, len(paths), errBiomeUnprocessed) + } + return report, nil +} + +func readTSSources(root string, paths []string, cfg codeshape.Config) (map[string][]byte, error) { + sources := make(map[string][]byte) + for _, path := range paths { + if !isTypeScriptPath(path) || !codeshape.CountedPath(path, cfg) { + continue + } + source, err := codeshape.ReadRepositoryFile(root, filepath.FromSlash(path)) + if err != nil { + return nil, fmt.Errorf("read TypeScript source %q: %w", path, err) + } + sources[path] = source + } + return sources, nil +} + +func applyBiomeIssues(report biomeReport, files map[string][]tsFunction, sources map[string][]byte, + skips []codeshape.Skip, values map[codeshape.Metric]map[string][]codeshape.Entry, +) error { + metrics := biomeRuleMetrics() + for _, diagnostic := range report.Diagnostics { + metric, relevant := metrics[diagnostic.Category] + if !relevant { + continue + } + path := filepath.ToSlash(diagnostic.Location.Path) + functions, exists := files[path] + if !exists { + recorded, err := recordUnparseableIssue(skips, path, metric, diagnostic.Message) + if err != nil { + return err + } + if recorded { + continue + } + } + if _, included := sources[path]; !included && slices.ContainsFunc(skips, func(skip codeshape.Skip) bool { + return skip.Path == path + }) { + continue + } + if !exists { + return fmt.Errorf("%w %q", errBiomeUnknownSource, path) + } + function, err := findTSFunction( + functions, diagnostic.Location.Start.Line, diagnostic.Location.Start.Column, sources[path], + ) + if err != nil { + return err + } + value, err := biomeValue(diagnostic.Message, metric) + if err != nil { + return err + } + codeshape.AppendFunctionValue(values, metric, path, function.id, value) + } + return nil +} + +func readBiomeConfig(root, path string) (map[string]any, error) { + data, err := codeshape.ReadRepositoryFile(root, path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + var config map[string]any + err = json5.Unmarshal(data, &config) + if err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return config, nil +} + +func nestedMap(value map[string]any, keys ...string) map[string]any { + for _, key := range keys { + nested, ok := value[key].(map[string]any) + if !ok { + return nil + } + value = nested + } + return value +} + +func numericValue(value any) int { + switch number := value.(type) { + case int: + return number + case int64: + return int(number) + case float64: + return int(number) + default: + return 0 + } +} + +func biomeValue(message string, metric codeshape.Metric) (int, error) { + fields := strings.Fields(message) + for index, field := range fields { + if index+1 < len(fields) && (metric == codeshape.MetricTSCognitive && field == "of" || + metric == codeshape.MetricTSLines && field == "lines") { + value, err := strconv.Atoi(strings.Trim(fields[index+1], ".()")) + if err != nil { + return 0, fmt.Errorf("parse biome value: %w", err) + } + return value, nil + } + } + return 0, fmt.Errorf("cannot parse %s value from %q: %w", metric, message, errBiomeValue) +} + +func parseTSFunctions(path string, source []byte) ([]tsFunction, *gotreesitter.Tree, error) { + lang := typescript.Language() + if filepath.Ext(path) == ".tsx" { + lang = tsx.Language() + } + tree, err := gotreesitter.NewParser(lang).Parse(source) + if err != nil { + return nil, nil, fmt.Errorf("parse TypeScript %q: %w", path, err) + } + root := tree.RootNode() + if root.HasErrorOrMissing() { + tree.Release() + return nil, nil, fmt.Errorf("parse TypeScript %q: %w", path, errTSSyntax) + } + functions := make([]tsFunction, 0) + collectTSFunctions(root, lang, nil, nil, &functions, source) + assignTSFunctionIDs(functions, lang, source) + return functions, tree, nil +} + +func assignTSFunctionIDs(functions []tsFunction, lang *gotreesitter.Language, source []byte) { + counts := make(map[string]int) + for i := range functions { + parentID := "" + var parentNode *gotreesitter.Node + for j := i - 1; j >= 0; j-- { + if functions[j].node.StartByte() <= functions[i].node.StartByte() && + functions[j].node.EndByte() >= functions[i].node.EndByte() { + parentID = functions[j].id + "/" + parentNode = functions[j].node + break + } + } + classID := tsClassPrefix(functions[i].node, parentNode, lang, source) + base := parentID + classID + functions[i].id + counts[base]++ + functions[i].id = base + if counts[base] > 1 { + functions[i].id += fmt.Sprintf("#%d", counts[base]) + } + } +} + +func tsClassPrefix(node, parentFunction *gotreesitter.Node, lang *gotreesitter.Language, source []byte) string { + class := enclosingTSClass(node, lang) + if class == nil || parentFunction != nil && enclosingTSClass(parentFunction, lang) == class { + return "" + } + name := class.ChildByFieldName("name", lang) + if name == nil { + return "" + } + return name.Text(source) + "/" +} + +func enclosingTSClass(node *gotreesitter.Node, lang *gotreesitter.Language) *gotreesitter.Node { + for parent := node.Parent(); parent != nil; parent = parent.Parent() { + if parent.Type(lang) == "class_declaration" || parent.Type(lang) == "class" { + return parent + } + } + return nil +} + +func collectTSFunctions( + node *gotreesitter.Node, + lang *gotreesitter.Language, + titlePath []string, + parent *tsFunction, + functions *[]tsFunction, + source []byte, +) { + if node == nil { + return + } + if node.Type(lang) == "call_expression" { + callable := node.ChildByFieldName("function", lang) + if callable != nil && (callable.Text(source) == "describe" || callable.Text(source) == "test" || + callable.Text(source) == "it" || strings.HasPrefix(callable.Text(source), "describe.") || + strings.HasPrefix(callable.Text(source), "test.") || strings.HasPrefix(callable.Text(source), "it.")) { + arguments := node.ChildByFieldName("arguments", lang) + title := firstStringArgument(arguments, lang, source) + callback := lastFunctionArgument(arguments, lang) + if title != "" && callback != nil { + nextPath := append(append([]string(nil), titlePath...), title) + collectTSFunctions(callback, lang, nextPath, parent, functions, source) + return + } + } + } + if isTSFunctionNode(node, lang) { + function := tsFunction{node: node, id: ""} + function.id = tsFunctionID(node, lang, titlePath, source) + *functions = append(*functions, function) + parent = &(*functions)[len(*functions)-1] + } + for index := range node.ChildCount() { + collectTSFunctions(node.Child(index), lang, titlePath, parent, functions, source) + } +} + +func isTSFunctionNode(node *gotreesitter.Node, lang *gotreesitter.Language) bool { + switch node.Type(lang) { + case "function_declaration", "function_expression", "arrow_function", "method_definition", + "generator_function_declaration", "generator_function": + return true + default: + return false + } +} + +func firstStringArgument(arguments *gotreesitter.Node, lang *gotreesitter.Language, source []byte) string { + if arguments == nil { + return "" + } + if arguments.NamedChildCount() == 0 { + return "" + } + child := arguments.NamedChild(0) + if child.Type(lang) != "string" && + (child.Type(lang) != "template_string" || strings.Contains(child.Text(source), "${")) { + return "" + } + return strings.Trim(strings.TrimSpace(child.Text(source)), "`\"'") +} + +func lastFunctionArgument(arguments *gotreesitter.Node, lang *gotreesitter.Language) *gotreesitter.Node { + if arguments == nil { + return nil + } + for index := arguments.NamedChildCount() - 1; index >= 0; index-- { + child := arguments.NamedChild(index) + if isTSFunctionNode(child, lang) { + return child + } + } + return nil +} + +func tsFunctionID(node *gotreesitter.Node, lang *gotreesitter.Language, titlePath []string, source []byte) string { + name := node.ChildByFieldName("name", lang) + functionName := "anonymous" + if name != nil { + functionName = name.Text(source) + } else if parent := node.Parent(); parent != nil && (parent.Type(lang) == "variable_declarator" || + parent.Type(lang) == "public_field_definition" || parent.Type(lang) == "pair") { + if variable := parent.ChildByFieldName("name", lang); variable != nil { + functionName = variable.Text(source) + } + if key := parent.ChildByFieldName("key", lang); key != nil { + functionName = key.Text(source) + } + } + if len(titlePath) > 0 { + functionName = strings.Join(titlePath, "/") + "/" + functionName + } + return functionName +} + +func findTSFunction(functions []tsFunction, line, column int, source []byte) (*tsFunction, error) { + if line < 1 || column < 1 { + return nil, errBiomePosition + } + offset, err := tsOffset(line, column, source) + if err != nil { + return nil, err + } + var match *tsFunction + var smallest uint32 + for index := range functions { + function := &functions[index] + if int64(function.node.StartByte()) <= int64(offset) && int64(offset) <= int64(function.node.EndByte()) { + span := function.node.EndByte() - function.node.StartByte() + if match == nil || span < smallest { + match = function + smallest = span + } + } + } + if match == nil { + return nil, fmt.Errorf("biome position %d:%d is outside any function: %w", line, column, errBiomeOutsideFunction) + } + return match, nil +} + +func tsOffset(line, column int, source []byte) (int, error) { + lineStart := 0 + for current := 1; current < line; current++ { + next := bytes.IndexByte(source[lineStart:], '\n') + if next < 0 { + return 0, fmt.Errorf("biome position line %d is outside source: %w", line, errBiomeOutsideSource) + } + lineStart += next + 1 + } + offset := lineStart + for units := 1; units < column; { + if offset >= len(source) || source[offset] == '\n' { + return 0, fmt.Errorf("biome position %d:%d is outside source: %w", line, column, errBiomeOutsideSource) + } + _, size := utf8.DecodeRune(source[offset:]) + units++ + offset += size + } + if offset > len(source) { + return 0, fmt.Errorf("biome position %d:%d is outside source: %w", line, column, errBiomeOutsideSource) + } + return offset, nil +} diff --git a/go/internal/codeshape/typescript/tsmetrics_test.go b/go/internal/codeshape/typescript/tsmetrics_test.go new file mode 100644 index 0000000..e11c564 --- /dev/null +++ b/go/internal/codeshape/typescript/tsmetrics_test.go @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package typescript + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/RigelBuild/meissa/go/internal/codeshape" + shapeconfig "github.com/RigelBuild/meissa/go/internal/codeshape/config" +) + +const tsBiomeConfig = `{"linter":{"rules":{"complexity":{"noExcessiveCognitiveComplexity":` + + `{"level":"off","options":{"maxAllowedComplexity":15}},` + + `"noExcessiveLinesPerFunction":{"level":"off","options":{"maxLines":50}}}}}}` + +func tsRepo(t *testing.T, source string) string { + t.Helper() + root := newFixtureRepo(t) + writeConfigFiles(t, root, strings.Replace(validConfig, `".go", ".cue"`, `".go", ".cue", ".ts"`, 1), "files: {}\n") + trackedFile(t, root, "biome.json", tsBiomeConfig) + trackedFile(t, root, "x.ts", source) + commitFiles(t, root) + return root +} +func TestTSOnlyOffUsesOptions(t *testing.T) { + root := tsRepo(t, "function f(x: boolean) {\nif (x) { console.log(x); }\n"+ + "if (x) { console.log(x); }\nconsole.log(x);\nconsole.warn(x);\n}\n") + config := strings.ReplaceAll(strings.ReplaceAll(tsBiomeConfig, `"maxAllowedComplexity":15`, + `"maxAllowedComplexity":1`), `"maxLines":50`, `"maxLines":3`) + err := os.WriteFile(filepath.Join(root, "biome.json"), []byte(config), 0o600) + if err != nil { + t.Fatal(err) + } + binary, err := exec.LookPath("biome") + if err != nil { + t.Fatal(err) + } + report, err := runBiome(t.Context(), binary, root, root, []string{"x.ts"}) + if err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + for _, d := range report.Diagnostics { + seen[d.Category] = true + if d.Category == "lint/complexity/noExcessiveLinesPerFunction" && + !strings.Contains(d.Message, "Maximum allowed is 3.") { + t.Fatalf("missing configured limit: %q", d.Message) + } + } + if len(seen) != 2 { + t.Fatalf("disabled rules did not both report: %#v", report.Diagnostics) + } +} + +func TestTSRepositoryIgnoredFileIsMeasured(t *testing.T) { + root := tsRepo(t, "function f() {}\n") + config := strings.Replace(tsBiomeConfig, `{`, `{"files":{"includes":["**", "!ignored.ts"]},`, 1) + err := os.WriteFile(filepath.Join(root, "biome.json"), []byte(config), 0600) + if err != nil { + t.Fatal(err) + } + trackedFile(t, root, "ignored.ts", "function ignored() {}\n") + commitFiles(t, root) + cfg, _, err := shapeconfig.Load(root) + if err != nil { + t.Fatal(err) + } + values, skips, err := TSMetrics(t.Context(), root, cfg) + if err != nil || len(skips) != 0 || len(values[codeshape.MetricTSLines]["ignored.ts"]) != 1 { + t.Fatalf("values=%v skips=%v err=%v", values, skips, err) + } +} + +func TestTSGeneratorAndUnicodePosition(t *testing.T) { + source := []byte("const s = 'é😀'; function* generate() { yield 1; }") + funcs, tree, err := parseTSFunctions("x.ts", source) + if err != nil { + t.Fatal(err) + } + defer tree.Release() + fn, err := findTSFunction(funcs, 1, 18, source) + if err != nil || fn.id != "generate" { + t.Fatalf("function=%v err=%v", fn, err) + } +} + +func TestTSStructuralIdentity(t *testing.T) { + ids := func(source string) []string { + functions, tree, err := parseTSFunctions("x.ts", []byte(source)) + if err != nil { + t.Fatal(err) + } + defer tree.Release() + result := []string{} + for _, fn := range functions { + if strings.Contains(fn.id, "big/") || strings.HasPrefix(fn.id, "A/") { + result = append(result, fn.id) + } + } + return result + } + a := ids(`function big() { xs.map(() => 1) } class A { run() {} }`) + b := ids(`ys.map(() => 0); class B { run() {} } function big() { xs.map(() => 1) } class A { run() {} }`) + if len(a) != 2 || strings.Join(a, ",") != strings.Join(b, ",") { + t.Fatalf("ids %v %v", a, b) + } +} + +func TestClassChildIdentity(t *testing.T) { + functions, tree, err := parseTSFunctions("x.ts", []byte(`class A { run() { xs.map(() => 1) } }`)) + if err != nil { + t.Fatal(err) + } + defer tree.Release() + if len(functions) != 2 || functions[1].id != "A/run/anonymous" { + t.Fatalf("functions=%v", functions) + } +} + +func TestTSManyOffenders(t *testing.T) { + var source strings.Builder + for i := range 25 { + fmt.Fprintf(&source, "function f%d() {\n%s}\n", i, strings.Repeat("console.log(1);\n", 51)) + } + root := tsRepo(t, source.String()) + cfg, _, err := shapeconfig.Load(root) + if err != nil { + t.Fatal(err) + } + values, _, err := TSMetrics(t.Context(), root, cfg) + if err != nil { + t.Fatal(err) + } + count := 0 + for _, e := range values[codeshape.MetricTSLines]["x.ts"] { + if e.Value > 50 { + count++ + } + } + if count != 25 { + t.Fatalf("got %d offenders", count) + } +} +func TestTSCallbackIdentity(t *testing.T) { + ids := func(source string) []string { + funcs, tree, err := parseTSFunctions("x.ts", []byte(source)) + if err != nil { + t.Fatal(err) + } + defer tree.Release() + var ids []string + for _, f := range funcs { + if strings.Contains(f.id, "suite/anonymous") { + ids = append(ids, f.id) + } + } + return ids + } + a := ids(`describe("suite", () => { helper(() => {}); helper(() => {}); });`) + b := ids(`function other() {} describe("suite", () => { helper(() => {}); helper(() => {}); });`) + if len(a) < 2 || strings.Join(a, ",") != strings.Join(b, ",") || a[len(a)-1] == a[len(a)-2] { + t.Fatalf("unstable ids %v %v", a, b) + } +} +func TestTSNestedDescribeIdentity(t *testing.T) { + funcs, tree, err := parseTSFunctions("x.ts", []byte( + `describe("outer", () => { describe("inner", () => { test("works", () => {}); }); });`, + )) + if err != nil { + t.Fatal(err) + } + defer tree.Release() + for _, f := range funcs { + if strings.Contains(f.id, "outer/inner/works/") { + return + } + } + t.Fatalf("missing nested title: %#v", funcs) +} + +func TestClassNameMatchesOuterFunction(t *testing.T) { + functions, tree, err := parseTSFunctions("x.ts", []byte(`function A() { class A { m() {} } }`)) + if err != nil { + t.Fatal(err) + } + defer tree.Release() + if len(functions) != 2 || functions[1].id != "A/A/m" { + t.Fatalf("functions=%v", functions) + } +} diff --git a/go/internal/codeshape/typescript/tssuppressions.go b/go/internal/codeshape/typescript/tssuppressions.go new file mode 100644 index 0000000..0684ac7 --- /dev/null +++ b/go/internal/codeshape/typescript/tssuppressions.go @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package typescript + +import ( + "context" + "fmt" + "path/filepath" + "slices" + "strconv" + "strings" + + "github.com/RigelBuild/meissa/go/internal/codeshape" +) + +// PolicyFindings reports TypeScript suppressions and policy drift. +func PolicyFindings(ctx context.Context, root string, cfg codeshape.Config) ([]codeshape.Finding, error) { + paths, err := codeshape.TrackedPaths(ctx, root) + if err != nil { + return nil, fmt.Errorf("collect TypeScript policy paths: %w", err) + } + findings := []codeshape.Finding{} + hasTS := false + for _, path := range paths { + if !isTypeScriptPath(path) || !codeshape.CountedPath(path, cfg) { + continue + } + hasTS = true + source, err := codeshape.ReadRepositoryFile(root, path) + if err != nil { + return nil, fmt.Errorf("read TypeScript source %q: %w", path, err) + } + findings = append(findings, tsDirectiveFindings(path, source)...) + } + if !hasTS { + return findings, nil + } + drift, err := trackedBiomeDrift(root, paths, cfg) + if err != nil { + return nil, err + } + return append(findings, drift...), nil +} + +func trackedBiomeDrift(root string, paths []string, cfg codeshape.Config) ([]codeshape.Finding, error) { + findings := []codeshape.Finding{} + for _, path := range paths { + base := filepath.Base(path) + if !slices.Contains(biomeConfigNames(), base) { + continue + } + config, err := readBiomeConfig(root, path) + if err != nil { + return nil, err + } + require := path == base + findings = append(findings, biomeDriftFindings(path, config, cfg, require)...) + findings = append(findings, biomeOverrideDrift(path, config, cfg)...) + } + return findings, nil +} + +func biomeOverrideDrift(path string, config map[string]any, cfg codeshape.Config) []codeshape.Finding { + findings := []codeshape.Finding{} + overrides, _ := config["overrides"].([]any) + for index, raw := range overrides { + override, ok := raw.(map[string]any) + if ok { + overridePath := fmt.Sprintf("%s#overrides[%d]", path, index) + findings = append(findings, biomeDriftFindings(overridePath, override, cfg, false)...) + } + } + return findings +} + +func biomeDriftFindings(path string, config map[string]any, cfg codeshape.Config, require bool) []codeshape.Finding { + findings := []codeshape.Finding{} + linter, _ := config["linter"].(map[string]any) + ruleGroups, _ := linter["rules"].(map[string]any) + if linter["enabled"] == false || ruleGroups["complexity"] == "off" { + findings = append(findings, codeshape.Finding{Path: path, Metric: codeshape.MetricTSLines, + Value: 0, Limit: 1, Fix: "enable Biome complexity rules"}) + } + rules := nestedMap(config, "linter", "rules", "complexity") + for _, check := range []struct { + name, option string + metric codeshape.Metric + }{ + {"noExcessiveCognitiveComplexity", "maxAllowedComplexity", codeshape.MetricTSCognitive}, + {"noExcessiveLinesPerFunction", "maxLines", codeshape.MetricTSLines}, + } { + value, present := rules[check.name] + if !require && !present { + continue + } + rule, _ := value.(map[string]any) + options, _ := rule["options"].(map[string]any) + option, exists := options[check.option] + level, _ := rule["level"].(string) + if !exists || level == "off" || numericValue(option) != cfg.Functions.LimitFor(check.metric) { + findings = append(findings, codeshape.Finding{Path: path, Metric: check.metric, + Value: numericValue(option), Limit: cfg.Functions.LimitFor(check.metric), + Fix: "match biome rule option to gate config"}) + } + } + return findings +} + +func tsDirectiveFindings(path string, source []byte) []codeshape.Finding { + findings := []codeshape.Finding{} + for i, line := range strings.Split(string(source), "\n") { + pos := strings.Index(line, "biome-ignore") + if pos < 0 { + continue + } + directive, _, _ := strings.Cut(line[pos:], ":") + fields := strings.Fields(directive) + if len(fields) < 2 || (fields[0] != "biome-ignore" && fields[0] != "biome-ignore-all" && + fields[0] != "biome-ignore-start") { + continue + } + for name, metric := range biomeRuleMetrics() { + for _, category := range fields[1:] { + category = strings.Trim(category, ",") + if category == "lint" || category == "lint/complexity" || category == name { + findings = append(findings, codeshape.Finding{ + Path: path + ":" + strconv.Itoa(i+1), Metric: metric, Value: 1, Limit: 0, + Fix: "remove biome-ignore suppression", + }) + break + } + } + } + } + return findings +} diff --git a/go/internal/codeshape/typescript/tssuppressions_test.go b/go/internal/codeshape/typescript/tssuppressions_test.go new file mode 100644 index 0000000..00aca7e --- /dev/null +++ b/go/internal/codeshape/typescript/tssuppressions_test.go @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package typescript + +import "testing" + +func TestBiomeSuppressionGroups(t *testing.T) { + for _, directive := range []string{ + "biome-ignore lint: reason", "biome-ignore lint/complexity: reason", + "biome-ignore-all lint: reason", "biome-ignore-start lint/complexity: reason", + } { + t.Run(directive, func(t *testing.T) { + findings := tsDirectiveFindings("x.ts", []byte("// "+directive)) + if len(findings) != 2 || findings[0].Value != 1 || findings[1].Value != 1 { + t.Fatalf("findings = %#v", findings) + } + }) + } +} diff --git a/go/internal/codeshape/typescript/unmeasured.go b/go/internal/codeshape/typescript/unmeasured.go new file mode 100644 index 0000000..fbed2a2 --- /dev/null +++ b/go/internal/codeshape/typescript/unmeasured.go @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package typescript + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/RigelBuild/meissa/go/internal/codeshape" +) + +func recordUnparseableIssue(skips []codeshape.Skip, path string, metric codeshape.Metric, message string) ( + bool, error, +) { + for _, skip := range skips { + if skip.Path != path || skip.Unmeasured == nil { + continue + } + value, err := biomeValue(message, metric) + if err != nil { + return true, err + } + skip.Unmeasured.Values[metric] = max(skip.Unmeasured.Values[metric], value) + return true, nil + } + return false, nil +} +func isTypeScriptPath(path string) bool { + switch filepath.Ext(path) { + case ".ts", ".tsx", ".mts", ".cts": + return true + default: + return false + } +} + +func validateBiomeErrors(report biomeReport) error { + count := 0 + metrics := biomeRuleMetrics() + for _, diagnostic := range report.Diagnostics { + if diagnostic.Severity != "error" { + continue + } + if _, ok := metrics[diagnostic.Category]; !ok && diagnostic.Category != "parse" && + !strings.HasPrefix(diagnostic.Category, "parse/") { + return fmt.Errorf("biome reported %s: %w", diagnostic.Category, errBiomeReported) + } + count++ + } + if count != report.Summary.Errors { + return fmt.Errorf("biome reported %d errors with %d diagnostics: %w", report.Summary.Errors, count, errBiomeReported) + } + return nil +} + +func biomeParseSkips(report biomeReport, sources map[string][]byte) ([]codeshape.Skip, error) { + var skips []codeshape.Skip + seen := map[string]bool{} + for _, diagnostic := range report.Diagnostics { + if diagnostic.Category != "parse" && !strings.HasPrefix(diagnostic.Category, "parse/") { + continue + } + path := filepath.ToSlash(diagnostic.Location.Path) + if seen[path] { + continue + } + seen[path] = true + if _, ok := sources[path]; !ok { + return nil, fmt.Errorf("%w %q", errBiomeUnknownSource, path) + } + delete(sources, path) + skips = append(skips, codeshape.Skip{Path: path, Reason: "Biome parse error: " + diagnostic.Message, + Unmeasured: &codeshape.UnmeasuredFunctions{Values: map[codeshape.Metric]int{}, BiomeParseFailed: true}}) + } + return skips, nil +} diff --git a/go/package.nix b/go/package.nix index 5dd6b6f..36e35c9 100644 --- a/go/package.nix +++ b/go/package.nix @@ -3,7 +3,7 @@ buildGo127Module { pname = "meissa-tools"; version = (lib.importJSON ../.release-please-manifest.json).go or "0.0.0"; src = lib.fileset.toSource { root = ./.; fileset = ./.; }; - vendorHash = "sha256-gXUE5nvRY4WOvIP0ZM5WscVD6BCXFgjD0OQ+gx/XBFs="; + vendorHash = "sha256-hwYLM/VsoAPukwf35tvFSZnTzsuim3VV36Sm1O+x4fk="; subPackages = [ "cmd/code-shape-gate" "cmd/ref-gate" "cmd/refresh-nix-hashes" ]; env.CGO_ENABLED = "0"; ldflags = [ "-s" "-w" ];