From 7946e8d95d18a4aa0dbcca70d9f9d49725b350ac Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 9 Nov 2026 22:32:58 -0500 Subject: [PATCH 01/54] feat(go): Go and TypeScript function metrics in code-shape-gate (RIG-8435) Co-authored-by: Matt Wilkinson --- go/internal/codeshape/cli.go | 46 ++- go/internal/codeshape/cli_test.go | 6 +- go/internal/codeshape/functions.go | 158 +++++++++ go/internal/codeshape/gometrics.go | 451 ++++++++++++++++++++++++ go/internal/codeshape/gometrics_test.go | 229 ++++++++++++ go/internal/codeshape/gosuppressions.go | 86 +++++ go/internal/codeshape/inventory.go | 2 +- go/internal/codeshape/inventory_test.go | 2 + go/internal/codeshape/judge.go | 122 +++++-- go/internal/codeshape/judge_test.go | 143 ++++++-- go/internal/codeshape/load.go | 53 +-- go/internal/codeshape/load_test.go | 23 +- go/internal/codeshape/metrics.go | 20 ++ go/internal/codeshape/render.go | 22 +- go/internal/codeshape/render_test.go | 16 +- go/internal/codeshape/schema.cue | 34 +- go/internal/codeshape/tsmetrics.go | 387 ++++++++++++++++++++ go/internal/codeshape/tsmetrics_test.go | 170 +++++++++ go/internal/codeshape/tssuppressions.go | 74 ++++ 19 files changed, 1941 insertions(+), 103 deletions(-) create mode 100644 go/internal/codeshape/functions.go create mode 100644 go/internal/codeshape/gometrics.go create mode 100644 go/internal/codeshape/gometrics_test.go create mode 100644 go/internal/codeshape/gosuppressions.go create mode 100644 go/internal/codeshape/metrics.go create mode 100644 go/internal/codeshape/tsmetrics.go create mode 100644 go/internal/codeshape/tsmetrics_test.go create mode 100644 go/internal/codeshape/tssuppressions.go diff --git a/go/internal/codeshape/cli.go b/go/internal/codeshape/cli.go index db7b09a..ca46f08 100644 --- a/go/internal/codeshape/cli.go +++ b/go/internal/codeshape/cli.go @@ -49,6 +49,39 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 } + functions, err := GoMetrics(ctx, root, config) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + tsFunctions, err := TSMetrics(ctx, root, config) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + functions[MetricTSCognitive] = tsFunctions[MetricTSCognitive] + functions[MetricTSLines] = tsFunctions[MetricTSLines] + snapshot.Functions = functions + suppressions, err := goSuppressionFindings(ctx, root, config) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + tsFindings, err := tsPolicyFindings(ctx, root, config) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + suppressions = append(suppressions, tsFindings...) + sortFindings(suppressions) + if len(suppressions) > 0 { + err := writeFindings(stdout, suppressions) + if err != nil { + writeOutput(stderr, "code-shape-gate: write output: %v\n", err) + return 2 + } + return 1 + } if writeMode { return writeUpdated(root, config, allow, base, snapshot, seed, stdout, stderr) } @@ -170,18 +203,23 @@ func gitBaseError(ref string, err error) error { 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, + Files: make(map[string]Entry, len(allow.Files)), + Dirs: make(map[string]Entry, len(allow.Dirs)), + Functions: make(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) + changedFunctions, functionErrors := 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, "; ")) diff --git a/go/internal/codeshape/cli_test.go b/go/internal/codeshape/cli_test.go index 6218025..7fd9103 100644 --- a/go/internal/codeshape/cli_test.go +++ b/go/internal/codeshape/cli_test.go @@ -96,8 +96,8 @@ func TestWriteUpdatedRejectsEscapingAllowlist(t *testing.T) { 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{}} + allow.Files["gone.go"] = Entry{ID: "", Value: 501, Reason: "split by concern"} + snapshot := Snapshot{Files: map[string]int{}, Dirs: map[string]int{}, Functions: nil} var stdout, stderr strings.Builder code := writeUpdated(root, testConfig(), allow, emptyAllowlist(), snapshot, false, &stdout, &stderr) data, err := os.ReadFile(outside) @@ -143,7 +143,7 @@ func TestMainPrintsBaseAndMeasuredFindings(t *testing.T) { } func TestSeedPreservesReason(t *testing.T) { - entries := map[string]Entry{"large.go": {Value: 501, Reason: "keep curated reason"}} + entries := map[string]Entry{"large.go": {ID: "", 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/functions.go b/go/internal/codeshape/functions.go new file mode 100644 index 0000000..9d54c2c --- /dev/null +++ b/go/internal/codeshape/functions.go @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +import ( + "errors" + "fmt" + "slices" + "sort" +) + +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"` +} + +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: + return 0 + default: + return 0 + } +} + +func isFunctionMetric(metric Metric) bool { + return slices.Contains(functionMetrics(), metric) +} + +func functionMetrics() []Metric { + return []Metric{MetricGoCognitive, MetricGoLines, MetricGoStatements, MetricTSCognitive, MetricTSLines} +} +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 +} + +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: ""}) +} + +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 +} + +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 +} + +func rewriteFunctionEntries(updated, allow, values FunctionMeasurements, cfg Config, seed bool) (bool, []string) { + changed := false + errs := []string{} + for _, metric := range functionMetrics() { + paths := map[string][]Entry{} + for path, entries := range allow[metric] { + for _, entry := range entries { + current, exists := functionEntry(values[metric][path], entry.ID) + if !exists || current.Value <= cfg.Functions.limitFor(metric) { + changed = true + continue + } + if current.Value < entry.Value { + entry.Value = current.Value + changed = true + } + paths[path] = append(paths[path], entry) + } + } + for path, entries := range values[metric] { + for _, current := range entries { + if current.Value <= cfg.Functions.limitFor(metric) { + 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 + if exists { + for i := range paths[path] { + if paths[path][i].ID == current.ID { + paths[path][i] = entry + } + } + } else { + paths[path] = append(paths[path], entry) + } + changed = true + } + } + if len(paths) > 0 { + updated[metric] = paths + } + } + return changed, errs +} diff --git a/go/internal/codeshape/gometrics.go b/go/internal/codeshape/gometrics.go new file mode 100644 index 0000000..e238c02 --- /dev/null +++ b/go/internal/codeshape/gometrics.go @@ -0,0 +1,451 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "go/ast" + "go/format" + "go/parser" + "go/token" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +const highFunlenLimit = 1_000_000_000 + +var ( + gocognitValue = regexp.MustCompile(`cognitive complexity ([0-9]+) of func`) + funlenValue = regexp.MustCompile(`\(([0-9]+) > [0-9]+\)`) + errEmptyGoModule = errors.New("has no included source files") + errEmptyGoMeasurement = errors.New("go function measurement is empty") + errGoLintReport = errors.New("reported an error") + errGoLintMissing = errors.New("measurement omitted") + errGoMetricParse = errors.New("cannot parse value") + errGoMetricInvalid = errors.New("invalid value") + 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") +) + +//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"` +} + +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 Config) (map[Metric]map[string][]Entry, error) { + paths, err := trackedPaths(ctx, root) + if err != nil { + return nil, err + } + modules := goModules(paths, cfg) + if len(modules) == 0 { + return newFunctionMeasurements(), nil + } + binary, err := exec.LookPath("golangci-lint") + if err != nil { + return nil, fmt.Errorf("find golangci-lint: %w", err) + } + measurements := newFunctionMeasurements() + for _, module := range modules { + files, err := parseGoSources(root, module, paths, cfg) + if err != nil { + return nil, err + } + if len(files) == 0 { + return nil, fmt.Errorf("go module %q: %w", module, errEmptyGoModule) + } + limits := []struct { + metric Metric + linter string + lines int + statements int + }{ + {metric: MetricGoCognitive, linter: "gocognit", lines: 0, statements: 0}, + {metric: MetricGoLines, linter: "funlen", lines: cfg.Functions.Go.Lines, statements: highFunlenLimit}, + {metric: MetricGoStatements, linter: "funlen", lines: highFunlenLimit, statements: cfg.Functions.Go.Statements}, + } + for _, file := range files { + for _, function := range file.functions { + for _, metric := range []Metric{MetricGoCognitive, MetricGoLines, MetricGoStatements} { + 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 nil, err + } + for _, issue := range report.Issues { + value, err := metricValue(issue.Text, limit.metric) + if err != nil { + return nil, err + } + path, function, err := findGoFunction(root, module, files, issue.Pos.Filename, issue.Pos.Line, issue.Pos.Column) + if err != nil { + return nil, err + } + appendFunctionValue(measurements, limit.metric, path, function, value) + } + } + } + if !hasAnyFunctionMeasurement(measurements) { + return nil, errEmptyGoMeasurement + } + return measurements, nil +} + +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 +} + +func goModules(paths []string, cfg 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 Config) bool { + for segment := range strings.SplitSeq(filepath.ToSlash(path), "/") { + if segment == "testdata" || segment == "fixtures" { + return true + } + } + return matchesAny(filepath.ToSlash(path), cfg.Exclude) +} + +func parseGoSources(root, module string, paths []string, cfg Config) (map[string]goSourceFile, error) { + files := make(map[string]goSourceFile) + 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 filepath.Ext(path) != ".go" || excludedFunctionPath(path, cfg) { + continue + } + if module != "." && !strings.HasPrefix(filepath.ToSlash(path), strings.TrimSuffix(module, "/")+"/") { + continue + } + nested := false + for _, dir := range modules { + if dir != "." && dir != module && (module == "." || strings.HasPrefix(dir, module+"/")) && + strings.HasPrefix(filepath.ToSlash(path), dir+"/") { + nested = true + break + } + } + if nested { + continue + } + if !countedPath(filepath.ToSlash(path), cfg) { + continue + } + //nolint:gosec // G304: path from the repo's tracked file list + source, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(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|parser.AllErrors) + if err != nil { + return nil, fmt.Errorf("parse Go source %q: %w", path, err) + } + functions := goFunctions(file, set) + for index := range functions { + functions[index].path = path + } + files[path] = goSourceFile{set: set, functions: functions} + } + return files, nil +} + +func goFunctions(file *ast.File, set *token.FileSet) []goFunction { + functions := make([]goFunction, 0) + 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: "", id: ""}) + } + return true + }) + sort.Slice(functions, func(i, j int) bool { return functions[i].node.Pos() < functions[j].node.Pos() }) + 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 + } + } + } + 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 + } + } + return functions +} + +func runGolangci( + ctx context.Context, + binary, root, module, linter string, + lines, statements int, + cfg 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 Metric) (int, error) { + pattern := funlenValue + if metric == 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 +} + +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 +} + +func hasAnyFunctionMeasurement(values map[Metric]map[string][]Entry) bool { + for _, paths := range values { + for _, entries := range paths { + if len(entries) > 0 { + return true + } + } + } + return false +} diff --git a/go/internal/codeshape/gometrics_test.go b/go/internal/codeshape/gometrics_test.go new file mode 100644 index 0000000..37390e5 --- /dev/null +++ b/go/internal/codeshape/gometrics_test.go @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +import ( + "fmt" + "go/parser" + "go/token" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +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 = FunctionLimits{Go: GoFunctionLimits{Cognitive: 20, Lines: 60, Statements: 40}, + TS: TSFunctionLimits{Cognitive: 0, Lines: 0}} + values, err := GoMetrics(t.Context(), root, cfg) + if err != nil { + t.Fatal(err) + } + count := 0 + for _, entry := range values[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 TestGoFunctionIdentity(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 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 := goSuppressionFindings(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 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 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 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[MetricGoStatements][path] + if len(entries) != 1 || entries[0].Value != 41 { + t.Fatalf("%s: %#v", path, entries) + } + } + if len(values[MetricGoStatements]) != 2 { + t.Fatalf("unexpected paths: %#v", values[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 = goSuppressionFindings(t.Context(), root, cfg) + if err != nil { + t.Fatal(err) + } +} +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\nprintf '%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) + } +} +func TestGoBareAndAllSuppressions(t *testing.T) { + root := goTestRepo(t, "package p\n//nolint\n//nolint:all\nfunc F() {}\n") + findings, err := goSuppressionFindings(t.Context(), root, testConfig()) + if err != nil { + t.Fatal(err) + } + for _, line := range []int{2, 3} { + for _, metric := range []Metric{MetricGoCognitive, MetricGoLines} { + found := false + for _, finding := range findings { + if finding.Path == fmt.Sprintf("x.go:%d", line) && finding.Value == line && finding.Metric == metric { + found = true + } + } + if !found { + t.Fatalf("missing %s line %d: %#v", metric, line, findings) + } + } + } +} diff --git a/go/internal/codeshape/gosuppressions.go b/go/internal/codeshape/gosuppressions.go new file mode 100644 index 0000000..7488018 --- /dev/null +++ b/go/internal/codeshape/gosuppressions.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +import ( + "context" + "fmt" + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" +) + +func goSuppressionFindings(ctx context.Context, root string, cfg Config) ([]Finding, error) { + paths, err := trackedPaths(ctx, root) + if err != nil { + return nil, 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 := []Finding{} + for _, path := range paths { + if !eligible[path] { + continue + } + source, err := os.ReadFile(filepath.Join(root, path)) //nolint:gosec // G304: path from the repo's tracked file list + 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) + } + for _, group := range file.Comments { + for _, comment := range group.List { + if !strings.HasPrefix(comment.Text, "//") { + continue + } + text := strings.TrimSpace(strings.TrimPrefix(comment.Text, "//")) + fields := strings.Fields(text) + if len(fields) == 0 { + continue + } + directive := fields[0] + metrics := make(map[Metric]bool) + if directive == "nolint" || directive == "nolint:all" { + metrics[MetricGoCognitive] = true + metrics[MetricGoLines] = true + } else if after, ok := strings.CutPrefix(directive, "nolint:"); ok { + for name := range strings.SplitSeq(after, ",") { + switch name { + case "all": + metrics[MetricGoCognitive] = true + metrics[MetricGoLines] = true + case "gocognit", "gocyclo": + metrics[MetricGoCognitive] = true + case "funlen": + metrics[MetricGoLines] = true + } + } + } + for metric := range metrics { + line := set.Position(comment.Pos()).Line + findings = append(findings, Finding{ + Path: path + ":" + strconv.Itoa(line), Metric: metric, Value: line, Limit: 0, + Fix: "remove complexity nolint suppression", + }) + } + } + } + } + sortFindings(findings) + return findings, nil +} diff --git a/go/internal/codeshape/inventory.go b/go/internal/codeshape/inventory.go index e53be9a..476bd61 100644 --- a/go/internal/codeshape/inventory.go +++ b/go/internal/codeshape/inventory.go @@ -77,7 +77,7 @@ 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 { diff --git a/go/internal/codeshape/inventory_test.go b/go/internal/codeshape/inventory_test.go index f03fa65..8d2b4c4 100644 --- a/go/internal/codeshape/inventory_test.go +++ b/go/internal/codeshape/inventory_test.go @@ -247,5 +247,7 @@ 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}}, } } diff --git a/go/internal/codeshape/judge.go b/go/internal/codeshape/judge.go index bf04db7..17ed349 100644 --- a/go/internal/codeshape/judge.go +++ b/go/internal/codeshape/judge.go @@ -11,6 +11,7 @@ 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, judgeFunctions(cfg, snap.Functions, allow.Functions)...) findings = append(findings, judgeBaseEntries(allow, baseAllow)...) for _, path := range sortedEntryPaths(allow.Files) { if _, exists := snap.Files[path]; !exists { @@ -34,6 +35,18 @@ func Judge(cfg Config, allow, baseAllow Allowlist, snap Snapshot) []Finding { }) } } + 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 } @@ -80,27 +93,39 @@ 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 { @@ -116,18 +141,67 @@ 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 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 +} + +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) +} + func sortedEntryPaths[V any](entries map[string]V) []string { paths := make([]string, 0, len(entries)) for path := range entries { diff --git a/go/internal/codeshape/judge_test.go b/go/internal/codeshape/judge_test.go index 87ba284..7786299 100644 --- a/go/internal/codeshape/judge_test.go +++ b/go/internal/codeshape/judge_test.go @@ -11,13 +11,13 @@ 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) if !hasFinding(findings, "large.go", MetricFiles, 502, 501, "grew") { @@ -26,13 +26,13 @@ func TestJudge(t *testing.T) { }) 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) if !hasFinding(findings, "small.go", MetricFiles, 500, 500, "remove") { @@ -41,13 +41,13 @@ func TestJudge(t *testing.T) { }) 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{}, + 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.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,13 +93,13 @@ 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 { t.Fatalf("head-only findings = %#v", findings) @@ -108,15 +107,15 @@ func TestJudgeBase(t *testing.T) { }) 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.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,71 @@ 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) + } +} + +func TestRewriteFunctionEntries(t *testing.T) { + cfg := testConfig() + cfg.Functions.Go.Lines = 60 + allow := Allowlist{Files: nil, Dirs: nil, Present: false, Functions: FunctionMeasurements{MetricGoLines: {"f.go": { + {ID: "lower", Value: 80, Reason: "split by concern"}, + {ID: "remove", Value: 70, Reason: "split by concern"}, + }}}} + values := FunctionMeasurements{MetricGoLines: {"f.go": { + {ID: "lower", Value: 70, Reason: ""}, {ID: "new", Value: 61, Reason: ""}, + }}} + _, _, err := rewriteAllowlist(cfg, allow, Snapshot{Files: nil, Dirs: nil, Functions: values}, false) + if err == nil { + t.Fatal("write accepted a new offender") + } + updated, changed, err := rewriteAllowlist(cfg, allow, Snapshot{Files: nil, Dirs: nil, Functions: values}, true) + if err != nil || !changed { + t.Fatalf("seed: changed=%v err=%v", changed, err) + } + got := updated.Functions[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/load.go b/go/internal/codeshape/load.go index 54eeb69..6891f43 100644 --- a/go/internal/codeshape/load.go +++ b/go/internal/codeshape/load.go @@ -22,28 +22,21 @@ 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. +// 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. @@ -54,30 +47,34 @@ type DirLimit struct { // 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 and directory ratchet entries. +// Allowlist contains file, directory, and function 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:"-"` + 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:"-"` } func emptyAllowlist() Allowlist { return Allowlist{ - Files: map[string]Entry{}, - Dirs: map[string]Entry{}, - Present: false, + 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 + Files map[string]int + Dirs map[string]int + Functions map[Metric]map[string][]Entry } // Finding describes one enforced size or ratchet violation. @@ -207,6 +204,18 @@ func decodeAllowlist(schema, value cue.Value, name string) (Allowlist, error) { if allowlist.Dirs == nil { allowlist.Dirs = map[string]Entry{} } + if allowlist.Functions == nil { + allowlist.Functions = map[Metric]map[string][]Entry{} + } + for metric, paths := range allowlist.Functions { + if !isFunctionMetric(metric) { + return Allowlist{}, fmt.Errorf("%s: %w %q", name, errUnsupportedMetric, metric) + } + err := validateFunctionEntryIDs(paths) + if err != nil { + return 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) @@ -221,6 +230,10 @@ func validateConfig(config 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/load_test.go index 40125f1..f9c8b15 100644 --- a/go/internal/codeshape/load_test.go +++ b/go/internal/codeshape/load_test.go @@ -44,6 +44,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[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"}`, @@ -203,7 +223,8 @@ func TestMainWriteUpdates(t *testing.T) { }) } -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/metrics.go b/go/internal/codeshape/metrics.go new file mode 100644 index 0000000..bef0052 --- /dev/null +++ b/go/internal/codeshape/metrics.go @@ -0,0 +1,20 @@ +// 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" +) + +// FunctionMeasurements maps each metric and source path to individual functions. +type FunctionMeasurements = map[Metric]map[string][]Entry diff --git a/go/internal/codeshape/render.go b/go/internal/codeshape/render.go index 2370d26..1c2f758 100644 --- a/go/internal/codeshape/render.go +++ b/go/internal/codeshape/render.go @@ -13,7 +13,8 @@ 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 { + 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()) @@ -36,3 +37,22 @@ func writeEntries(output *strings.Builder, name string, entries map[string]Entry } output.WriteString("}\n") } + +func writeFunctionEntries(output *strings.Builder, functions map[Metric]map[string][]Entry) { + for _, metric := range functionMetrics() { + paths := functions[metric] + if len(paths) == 0 { + continue + } + output.WriteString("functions: " + strconv.Quote(string(metric)) + ": {\n") + for _, path := range sortedEntryPaths(paths) { + output.WriteString("\t" + strconv.Quote(path) + ": [\n") + for _, entry := range 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/render_test.go index 500a0fa..dc0b48a 100644 --- a/go/internal/codeshape/render_test.go +++ b/go/internal/codeshape/render_test.go @@ -11,11 +11,12 @@ import ( 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"}, + "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]Entry{"pkg": {ID: "", Value: 11, Reason: "split package tests"}}, + Functions: nil, + Present: false, } first := Render(allow) second := Render(allow) @@ -35,10 +36,11 @@ 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`}, + `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]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/schema.cue b/go/internal/codeshape/schema.cue index 26fa000..5e67e8a 100644 --- a/go/internal/codeshape/schema.cue +++ b/go/internal/codeshape/schema.cue @@ -1,3 +1,29 @@ +#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,}$" @@ -10,9 +36,9 @@ exclude: [...string] // When multiple globs match, the smallest limit wins. dirLimits?: [string]: {limit: int & >10, reason: string & =~"^\\S+(\\s+\\S+){2,}$"} + functions: close({ + go: close({cognitive: 20, lines: 60, statements: 40}) + ts: close({cognitive: 15, lines: 50}) + }) }) -#Allowlist: close({ - files?: [string]: #Entry - dirs?: [string]: #Entry -}) diff --git a/go/internal/codeshape/tsmetrics.go b/go/internal/codeshape/tsmetrics.go new file mode 100644 index 0000000..dc98775 --- /dev/null +++ b/go/internal/codeshape/tsmetrics.go @@ -0,0 +1,387 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + + "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]Metric { + return map[string]Metric{ + "lint/complexity/noExcessiveCognitiveComplexity": MetricTSCognitive, + "lint/complexity/noExcessiveLinesPerFunction": 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") +) + +type biomeReport struct { + Summary *struct { + 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 + parent *tsFunction +} + +// TSMetrics measures Biome's function complexity and line count rules. +func TSMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[string][]Entry, error) { + paths, err := trackedPaths(ctx, root) + if err != nil { + return nil, err + } + sources := make(map[string][]byte) + for _, path := range paths { + if !isTypeScriptPath(path) || !countedPath(path, cfg) { + continue + } + //nolint:gosec // G304: path from the repo's tracked file list + source, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) + if err != nil { + return nil, fmt.Errorf("read TypeScript source %q: %w", path, err) + } + sources[path] = source + } + if len(sources) == 0 { + return newFunctionMeasurements(), nil + } + binary, err := exec.LookPath("biome") + if err != nil { + return nil, fmt.Errorf("find biome: %w", err) + } + var sourcePaths []string + for path := range sources { + sourcePaths = append(sourcePaths, path) + } + sort.Strings(sourcePaths) + report, err := runBiome(ctx, binary, root, sourcePaths) + if err != nil { + return nil, err + } + if report.Summary.DiagnosticsNotPrinted != 0 { + return nil, fmt.Errorf("%w: %d were not printed", errBiomeTruncated, report.Summary.DiagnosticsNotPrinted) + } + values := newFunctionMeasurements() + files := make(map[string][]tsFunction, len(sources)) + for path, source := range sources { + functions, tree, err := parseTSFunctions(path, source) + if err != nil { + return nil, err + } + defer tree.Release() + files[path] = functions + for _, function := range functions { + appendFunctionValue(values, MetricTSCognitive, path, function.id, 0) + appendFunctionValue(values, MetricTSLines, path, function.id, 0) + } + } + 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 { + return nil, fmt.Errorf("%w %q", errBiomeUnknownSource, path) + } + function, err := findTSFunction( + functions, diagnostic.Location.Start.Line, diagnostic.Location.Start.Column, sources[path], + ) + if err != nil { + return nil, err + } + value, err := biomeValue(diagnostic.Message, metric) + if err != nil { + return nil, err + } + appendFunctionValue(values, metric, path, function.id, value) + } + return values, nil +} + +func isTypeScriptPath(path string) bool { + switch filepath.Ext(path) { + case ".ts", ".tsx", ".mts", ".cts": + return true + default: + return false + } +} + +func runBiome(ctx context.Context, binary, root 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") + 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() + if runErr != nil { + 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", err) + } + if report.Summary == nil || report.Diagnostics == nil { + return biomeReport{}, errBiomeMissingOutput + } + if report.Summary.Errors > 0 { + return biomeReport{}, fmt.Errorf("biome reported %d errors: %w", report.Summary.Errors, errBiomeReported) + } + return report, nil +} + +func readBiomeConfig(root string) (map[string]any, error) { + data, err := os.ReadFile(filepath.Join(root, "biome.json")) //nolint:gosec // G304: read repo config + if err != nil { + return nil, fmt.Errorf("read biome.json: %w", err) + } + var config map[string]any + err = json5.Unmarshal(data, &config) + if err != nil { + return nil, fmt.Errorf("parse biome.json: %w", 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 Metric) (int, error) { + fields := strings.Fields(message) + for index, field := range fields { + if index+1 < len(fields) && (metric == MetricTSCognitive && field == "of" || + metric == 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) + counts := make(map[string]int) + for _, function := range functions { + counts[function.id]++ + } + seen := make(map[string]int) + for i := range functions { + id := functions[i].id + if counts[id] > 1 { + seen[id]++ + functions[i].id = fmt.Sprintf("%s#%d", id, seen[id]) + } + } + return functions, tree, 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") { + 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: "", parent: parent} + 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": + 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" { + 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" { + if variable := parent.ChildByFieldName("name", lang); variable != nil { + functionName = variable.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 + } + lineStart := 0 + for current := 1; current < line; current++ { + next := bytes.IndexByte(source[lineStart:], '\n') + if next < 0 { + return nil, fmt.Errorf("biome position line %d is outside source: %w", line, errBiomeOutsideSource) + } + lineStart += next + 1 + } + offset := lineStart + column - 1 + if offset > len(source) { + return nil, fmt.Errorf("biome position %d:%d is outside source: %w", line, column, errBiomeOutsideSource) + } + 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 +} diff --git a/go/internal/codeshape/tsmetrics_test.go b/go/internal/codeshape/tsmetrics_test.go new file mode 100644 index 0000000..fabc9f7 --- /dev/null +++ b/go/internal/codeshape/tsmetrics_test.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +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) {\n"+strings.Repeat("if (x) { console.log(x); }\n", 16)+ + strings.Repeat("console.log(x);\n", 40)+"}\n") + binary, err := exec.LookPath("biome") + if err != nil { + t.Fatal(err) + } + report, err := runBiome(t.Context(), binary, root, []string{"x.ts"}) + if err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + for _, d := range report.Diagnostics { + seen[d.Category] = true + } + if len(seen) != 2 { + t.Fatalf("disabled rules did not both report: %#v", report.Diagnostics) + } +} + +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 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 := 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[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 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() + err := os.WriteFile(filepath.Join(dir, "biome"), []byte("#!/bin/sh\nprintf '{\"summary\":'\n"), 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/tssuppressions.go b/go/internal/codeshape/tssuppressions.go new file mode 100644 index 0000000..8621add --- /dev/null +++ b/go/internal/codeshape/tssuppressions.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +func tsPolicyFindings(ctx context.Context, root string, cfg Config) ([]Finding, error) { + paths, err := trackedPaths(ctx, root) + if err != nil { + return nil, err + } + findings := []Finding{} + hasTS := false + for _, path := range paths { + if !isTypeScriptPath(path) || !countedPath(path, cfg) { + continue + } + hasTS = true + source, err := os.ReadFile(filepath.Join(root, path)) //nolint:gosec // G304: path from the repo's tracked file list + if err != nil { + return nil, fmt.Errorf("read TypeScript source %q: %w", path, err) + } + for i, line := range strings.Split(string(source), "\n") { + pos := strings.Index(line, "biome-ignore") + if pos < 0 { + continue + } + directive, _, _ := strings.Cut(line[pos:], ":") + for name, metric := range biomeRuleMetrics() { + if strings.Contains(directive, strings.TrimPrefix(name, "lint/")) { + findings = append(findings, Finding{ + Path: path + ":" + strconv.Itoa(i+1), Metric: metric, Value: i + 1, Limit: 0, + Fix: "remove biome-ignore suppression", + }) + } + } + } + } + if !hasTS { + return findings, nil + } + config, err := readBiomeConfig(root) + if os.IsNotExist(err) { + return findings, nil + } + if err != nil { + return nil, err + } + rules := nestedMap(config, "linter", "rules", "complexity") + for _, check := range []struct { + name, option string + metric Metric + }{ + {"noExcessiveCognitiveComplexity", "maxAllowedComplexity", MetricTSCognitive}, + {"noExcessiveLinesPerFunction", "maxLines", MetricTSLines}, + } { + rule, _ := rules[check.name].(map[string]any) + options, _ := rule["options"].(map[string]any) + if value, ok := options[check.option]; ok && numericValue(value) != cfg.Functions.limitFor(check.metric) { + findings = append(findings, Finding{ + Path: "biome.json", Metric: check.metric, Value: numericValue(value), + Limit: cfg.Functions.limitFor(check.metric), Fix: "match biome rule option to gate config", + }) + } + } + return findings, nil +} From d77fb4cd07169fefbc33add73389d4fb28e98172 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 04:46:37 -0500 Subject: [PATCH 02/54] wip(go): T2 review abs root (RIG-8435) --- go/internal/codeshape/cli.go | 6 ++++++ go/internal/codeshape/cli_test.go | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/go/internal/codeshape/cli.go b/go/internal/codeshape/cli.go index ca46f08..86005b2 100644 --- a/go/internal/codeshape/cli.go +++ b/go/internal/codeshape/cli.go @@ -10,6 +10,7 @@ import ( "maps" "os" "os/exec" + "path/filepath" "sort" "strings" @@ -31,6 +32,11 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s writeOutput(stderr, "usage: code-shape-gate [--write] [--seed] \n") return 2 } + root, err := filepath.Abs(root) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } config, allow, err := Load(root) if err != nil { writeOutput(stderr, "code-shape-gate: %v\n", err) diff --git a/go/internal/codeshape/cli_test.go b/go/internal/codeshape/cli_test.go index 7fd9103..6519328 100644 --- a/go/internal/codeshape/cli_test.go +++ b/go/internal/codeshape/cli_test.go @@ -48,6 +48,22 @@ func TestMainBaseModes(t *testing.T) { } } +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") From cf0d7c48b0be31b5646538129970d61535f4bc60 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 04:49:35 -0500 Subject: [PATCH 03/54] wip(go): T2 review go scope (RIG-8435) --- go/internal/codeshape/cli.go | 7 ++- go/internal/codeshape/gometrics.go | 67 ++++++++++++++++++------- go/internal/codeshape/gometrics_test.go | 25 +++++++-- go/internal/codeshape/gosuppressions.go | 2 +- 4 files changed, 78 insertions(+), 23 deletions(-) diff --git a/go/internal/codeshape/cli.go b/go/internal/codeshape/cli.go index 86005b2..6f01db2 100644 --- a/go/internal/codeshape/cli.go +++ b/go/internal/codeshape/cli.go @@ -55,11 +55,16 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 } - functions, err := GoMetrics(ctx, root, config) + functions, skips, err := GoMetrics(ctx, root, config) if err != nil { writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 } + for _, skip := range skips { + if !writeOutput(stderr, "code-shape-gate: skip %s: %s\n", skip.Path, skip.Reason) { + return 2 + } + } tsFunctions, err := TSMetrics(ctx, root, config) if err != nil { writeOutput(stderr, "code-shape-gate: %v\n", err) diff --git a/go/internal/codeshape/gometrics.go b/go/internal/codeshape/gometrics.go index e238c02..2bf4d6e 100644 --- a/go/internal/codeshape/gometrics.go +++ b/go/internal/codeshape/gometrics.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "go/ast" + "go/build" "go/format" "go/parser" "go/token" @@ -70,28 +71,36 @@ type goFunction struct { id string } +// Skip records a source file that could not be measured. +type Skip struct { + Path string + Reason string +} + // GoMetrics measures function complexity and length in every included module. -func GoMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[string][]Entry, error) { +func GoMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[string][]Entry, []Skip, error) { paths, err := trackedPaths(ctx, root) if err != nil { - return nil, err + return nil, nil, err } modules := goModules(paths, cfg) if len(modules) == 0 { - return newFunctionMeasurements(), nil + return newFunctionMeasurements(), nil, nil } binary, err := exec.LookPath("golangci-lint") if err != nil { - return nil, fmt.Errorf("find golangci-lint: %w", err) + return nil, nil, fmt.Errorf("find golangci-lint: %w", err) } measurements := newFunctionMeasurements() + skips := []Skip{} for _, module := range modules { - files, err := parseGoSources(root, module, paths, cfg) + files, skipped, err := parseGoSources(root, module, paths, cfg) if err != nil { - return nil, err + return nil, nil, err } + skips = append(skips, skipped...) if len(files) == 0 { - return nil, fmt.Errorf("go module %q: %w", module, errEmptyGoModule) + continue } limits := []struct { metric Metric @@ -113,25 +122,35 @@ func GoMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[str for _, limit := range limits { report, err := runGolangci(ctx, binary, root, module, limit.linter, limit.lines, limit.statements, cfg) if err != nil { - return nil, err + return nil, nil, err } for _, issue := range report.Issues { + path := issue.Pos.Filename + if filepath.IsAbs(path) { + path, err = filepath.Rel(root, path) + if err != nil { + return nil, nil, fmt.Errorf("relativize Go diagnostic path: %w", err) + } + } else { + path = filepath.Join(module, path) + } + path = filepath.ToSlash(path) + if excludedFunctionPath(path, cfg) || !countedPath(path, cfg) { + continue + } value, err := metricValue(issue.Text, limit.metric) if err != nil { - return nil, err + return nil, nil, err } path, function, err := findGoFunction(root, module, files, issue.Pos.Filename, issue.Pos.Line, issue.Pos.Column) if err != nil { - return nil, err + return nil, nil, err } appendFunctionValue(measurements, limit.metric, path, function, value) } } } - if !hasAnyFunctionMeasurement(measurements) { - return nil, errEmptyGoMeasurement - } - return measurements, nil + return measurements, skips, nil } func trackedPaths(ctx context.Context, root string) ([]string, error) { @@ -169,8 +188,11 @@ func excludedFunctionPath(path string, cfg Config) bool { return matchesAny(filepath.ToSlash(path), cfg.Exclude) } -func parseGoSources(root, module string, paths []string, cfg Config) (map[string]goSourceFile, error) { +func parseGoSources(root, module string, paths []string, cfg Config) (map[string]goSourceFile, []Skip, error) { files := make(map[string]goSourceFile) + skips := []Skip{} + buildContext := build.Default + buildContext.CgoEnabled = false modules := make([]string, 0) for _, path := range paths { if filepath.Base(path) == "go.mod" { @@ -199,15 +221,24 @@ func parseGoSources(root, module string, paths []string, cfg Config) (map[string if !countedPath(filepath.ToSlash(path), 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) + } + if !matches { + skips = append(skips, Skip{Path: path, Reason: "not in default Go build"}) + continue + } //nolint:gosec // G304: path from the repo's tracked file list source, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) if err != nil { - return nil, fmt.Errorf("read Go source %q: %w", path, err) + 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, fmt.Errorf("parse Go source %q: %w", path, err) + return nil, nil, fmt.Errorf("parse Go source %q: %w", path, err) } functions := goFunctions(file, set) for index := range functions { @@ -215,7 +246,7 @@ func parseGoSources(root, module string, paths []string, cfg Config) (map[string } files[path] = goSourceFile{set: set, functions: functions} } - return files, nil + return files, skips, nil } func goFunctions(file *ast.File, set *token.FileSet) []goFunction { diff --git a/go/internal/codeshape/gometrics_test.go b/go/internal/codeshape/gometrics_test.go index 37390e5..31cdacd 100644 --- a/go/internal/codeshape/gometrics_test.go +++ b/go/internal/codeshape/gometrics_test.go @@ -30,7 +30,7 @@ func TestGoCollectorFixture(t *testing.T) { cfg := testConfig() cfg.Functions = FunctionLimits{Go: GoFunctionLimits{Cognitive: 20, Lines: 60, Statements: 40}, TS: TSFunctionLimits{Cognitive: 0, Lines: 0}} - values, err := GoMetrics(t.Context(), root, cfg) + values, _, err := GoMetrics(t.Context(), root, cfg) if err != nil { t.Fatal(err) } @@ -132,7 +132,7 @@ func TestGoNestedModules(t *testing.T) { 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()) + values, _, err := GoMetrics(t.Context(), root, testConfig()) if err != nil { t.Fatal(err) } @@ -146,6 +146,25 @@ func TestGoNestedModules(t *testing.T) { t.Fatalf("unexpected paths: %#v", values[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[MetricGoStatements]) != 1 { + t.Fatalf("skips=%v measurements=%v", skips, values[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") @@ -153,7 +172,7 @@ func TestGoExcludedModule(t *testing.T) { commitFiles(t, root) cfg := testConfig() cfg.Exclude = []string{"excluded/**"} - _, err := GoMetrics(t.Context(), root, cfg) + _, _, err := GoMetrics(t.Context(), root, cfg) if err != nil { t.Fatal(err) } diff --git a/go/internal/codeshape/gosuppressions.go b/go/internal/codeshape/gosuppressions.go index 7488018..c2da015 100644 --- a/go/internal/codeshape/gosuppressions.go +++ b/go/internal/codeshape/gosuppressions.go @@ -21,7 +21,7 @@ func goSuppressionFindings(ctx context.Context, root string, cfg Config) ([]Find modules := goModules(paths, cfg) eligible := make(map[string]bool) for _, module := range modules { - files, err := parseGoSources(root, module, paths, cfg) + files, _, err := parseGoSources(root, module, paths, cfg) if err != nil { return nil, err } From 1b6e2a7a12d95c5023cbdc85262262052b1e8696 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 04:52:18 -0500 Subject: [PATCH 04/54] wip(go): T2 review ts scope (RIG-8435) --- go/internal/codeshape/cli.go | 7 ++- go/internal/codeshape/tsmetrics.go | 63 +++++++++++++++++++------ go/internal/codeshape/tsmetrics_test.go | 33 ++++++++++++- 3 files changed, 86 insertions(+), 17 deletions(-) diff --git a/go/internal/codeshape/cli.go b/go/internal/codeshape/cli.go index 6f01db2..eeb9988 100644 --- a/go/internal/codeshape/cli.go +++ b/go/internal/codeshape/cli.go @@ -65,11 +65,16 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s return 2 } } - tsFunctions, err := TSMetrics(ctx, root, config) + tsFunctions, tsSkips, err := TSMetrics(ctx, root, config) if err != nil { writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 } + for _, skip := range tsSkips { + if !writeOutput(stderr, "code-shape-gate: skip %s: %s\n", skip.Path, skip.Reason) { + return 2 + } + } functions[MetricTSCognitive] = tsFunctions[MetricTSCognitive] functions[MetricTSLines] = tsFunctions[MetricTSLines] snapshot.Functions = functions diff --git a/go/internal/codeshape/tsmetrics.go b/go/internal/codeshape/tsmetrics.go index dc98775..5fe91b0 100644 --- a/go/internal/codeshape/tsmetrics.go +++ b/go/internal/codeshape/tsmetrics.go @@ -11,9 +11,11 @@ import ( "os" "os/exec" "path/filepath" + "slices" "sort" "strconv" "strings" + "unicode/utf8" "github.com/odvcencio/gotreesitter" "github.com/odvcencio/gotreesitter/grammars/tsx" @@ -44,6 +46,8 @@ var ( type biomeReport struct { Summary *struct { + Changed int `json:"changed"` + Unchanged int `json:"unchanged"` DiagnosticsNotPrinted int `json:"diagnosticsNotPrinted"` Errors int `json:"errors"` } `json:"summary"` @@ -68,10 +72,10 @@ type tsFunction struct { } // TSMetrics measures Biome's function complexity and line count rules. -func TSMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[string][]Entry, error) { +func TSMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[string][]Entry, []Skip, error) { paths, err := trackedPaths(ctx, root) if err != nil { - return nil, err + return nil, nil, err } sources := make(map[string][]byte) for _, path := range paths { @@ -81,16 +85,16 @@ func TSMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[str //nolint:gosec // G304: path from the repo's tracked file list source, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) if err != nil { - return nil, fmt.Errorf("read TypeScript source %q: %w", path, err) + return nil, nil, fmt.Errorf("read TypeScript source %q: %w", path, err) } sources[path] = source } if len(sources) == 0 { - return newFunctionMeasurements(), nil + return newFunctionMeasurements(), nil, nil } binary, err := exec.LookPath("biome") if err != nil { - return nil, fmt.Errorf("find biome: %w", err) + return nil, nil, fmt.Errorf("find biome: %w", err) } var sourcePaths []string for path := range sources { @@ -99,17 +103,32 @@ func TSMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[str sort.Strings(sourcePaths) report, err := runBiome(ctx, binary, root, sourcePaths) if err != nil { - return nil, err + return nil, nil, err } if report.Summary.DiagnosticsNotPrinted != 0 { - return nil, fmt.Errorf("%w: %d were not printed", errBiomeTruncated, report.Summary.DiagnosticsNotPrinted) + return nil, nil, fmt.Errorf("%w: %d were not printed", errBiomeTruncated, report.Summary.DiagnosticsNotPrinted) + } + skips := []Skip{} + if report.Summary.Changed+report.Summary.Unchanged != len(sourcePaths) { + for _, path := range sourcePaths { + single, singleErr := runBiome(ctx, binary, root, []string{path}) + if singleErr != nil { + return nil, nil, singleErr + } + if single.Summary.Changed+single.Summary.Unchanged == 0 { + delete(sources, path) + skips = append(skips, Skip{Path: path, Reason: "ignored by Biome"}) + } + } } values := newFunctionMeasurements() files := make(map[string][]tsFunction, len(sources)) for path, source := range sources { functions, tree, err := parseTSFunctions(path, source) if err != nil { - return nil, err + skips = append(skips, Skip{Path: path, Reason: err.Error()}) + delete(sources, path) + continue } defer tree.Release() files[path] = functions @@ -126,22 +145,25 @@ func TSMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[str } path := filepath.ToSlash(diagnostic.Location.Path) functions, exists := files[path] + if _, included := sources[path]; !included && slices.ContainsFunc(skips, func(skip Skip) bool { return skip.Path == path }) { + continue + } if !exists { - return nil, fmt.Errorf("%w %q", errBiomeUnknownSource, path) + return nil, nil, fmt.Errorf("%w %q", errBiomeUnknownSource, path) } function, err := findTSFunction( functions, diagnostic.Location.Start.Line, diagnostic.Location.Start.Column, sources[path], ) if err != nil { - return nil, err + return nil, nil, err } value, err := biomeValue(diagnostic.Message, metric) if err != nil { - return nil, err + return nil, nil, err } appendFunctionValue(values, metric, path, function.id, value) } - return values, nil + return values, skips, nil } func isTypeScriptPath(path string) bool { @@ -156,7 +178,7 @@ func isTypeScriptPath(path string) bool { func runBiome(ctx context.Context, binary, root 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") + "--only=complexity/noExcessiveCognitiveComplexity", "--only=complexity/noExcessiveLinesPerFunction", "--no-errors-on-unmatched") args = append(args, paths...) command := exec.CommandContext(ctx, binary, args...) //nolint:gosec // G204: execute the biome linter command.Dir = root @@ -302,7 +324,7 @@ func collectTSFunctions( func isTSFunctionNode(node *gotreesitter.Node, lang *gotreesitter.Language) bool { switch node.Type(lang) { - case "function_declaration", "function_expression", "arrow_function", "method_definition": + case "function_declaration", "function_expression", "arrow_function", "method_definition", "generator_function_declaration", "generator_function": return true default: return false @@ -364,7 +386,18 @@ func findTSFunction(functions []tsFunction, line, column int, source []byte) (*t } lineStart += next + 1 } - offset := lineStart + column - 1 + offset := lineStart + for units := 1; units < column; { + if offset >= len(source) || source[offset] == '\n' { + return nil, fmt.Errorf("biome position %d:%d is outside source: %w", line, column, errBiomeOutsideSource) + } + r, size := utf8.DecodeRune(source[offset:]) + units++ + if r > 0xffff { + units++ + } + offset += size + } if offset > len(source) { return nil, fmt.Errorf("biome position %d:%d is outside source: %w", line, column, errBiomeOutsideSource) } diff --git a/go/internal/codeshape/tsmetrics_test.go b/go/internal/codeshape/tsmetrics_test.go index fabc9f7..3814905 100644 --- a/go/internal/codeshape/tsmetrics_test.go +++ b/go/internal/codeshape/tsmetrics_test.go @@ -45,6 +45,37 @@ func TestTSOnlyOffUsesOptions(t *testing.T) { } } +func TestTSIgnoredFileIsSkipped(t *testing.T) { + root := tsRepo(t, "function f() {}\n") + config := strings.Replace(tsBiomeConfig, `{`, `{"files":{"includes":["**", "!ignored.ts"]},`, 1) + if err := os.WriteFile(filepath.Join(root, "biome.json"), []byte(config), 0600); err != nil { + t.Fatal(err) + } + trackedFile(t, root, "ignored.ts", "function ignored() {}\n") + commitFiles(t, root) + cfg, _, err := Load(root) + if err != nil { + t.Fatal(err) + } + values, skips, err := TSMetrics(t.Context(), root, cfg) + if err != nil || len(skips) != 1 || skips[0].Path != "ignored.ts" || len(values[MetricTSLines]["ignored.ts"]) != 0 { + 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, 19, source) + if err != nil || fn.id != "generate" { + t.Fatalf("function=%v err=%v", fn, 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) @@ -69,7 +100,7 @@ func TestTSManyOffenders(t *testing.T) { if err != nil { t.Fatal(err) } - values, err := TSMetrics(t.Context(), root, cfg) + values, _, err := TSMetrics(t.Context(), root, cfg) if err != nil { t.Fatal(err) } From 73194cbf3f1e14d16d9c56c0cae98b82131a2d92 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 04:54:02 -0500 Subject: [PATCH 05/54] wip(go): T2 review identity (RIG-8435) --- go/internal/codeshape/gometrics.go | 20 ++++++++++- go/internal/codeshape/gometrics_test.go | 16 +++++++++ go/internal/codeshape/tsmetrics.go | 45 ++++++++++++++++--------- go/internal/codeshape/tsmetrics_test.go | 22 ++++++++++++ 4 files changed, 87 insertions(+), 16 deletions(-) diff --git a/go/internal/codeshape/gometrics.go b/go/internal/codeshape/gometrics.go index 2bf4d6e..87732b3 100644 --- a/go/internal/codeshape/gometrics.go +++ b/go/internal/codeshape/gometrics.go @@ -251,6 +251,24 @@ func parseGoSources(root, module string, paths []string, cfg Config) (map[string func goFunctions(file *ast.File, set *token.FileSet) []goFunction { functions := make([]goFunction, 0) + 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 + } + } + } + } ast.Inspect(file, func(node ast.Node) bool { switch function := node.(type) { case *ast.FuncDecl: @@ -264,7 +282,7 @@ func goFunctions(file *ast.File, set *token.FileSet) []goFunction { } functions = append(functions, goFunction{node: function, path: "", base: name, id: ""}) case *ast.FuncLit: - functions = append(functions, goFunction{node: function, path: "", base: "", id: ""}) + functions = append(functions, goFunction{node: function, path: "", base: topLevel[function], id: ""}) } return true }) diff --git a/go/internal/codeshape/gometrics_test.go b/go/internal/codeshape/gometrics_test.go index 31cdacd..e8f19c6 100644 --- a/go/internal/codeshape/gometrics_test.go +++ b/go/internal/codeshape/gometrics_test.go @@ -79,6 +79,22 @@ func TestGoFunctionIdentity(t *testing.T) { } } +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") diff --git a/go/internal/codeshape/tsmetrics.go b/go/internal/codeshape/tsmetrics.go index 5fe91b0..4d53046 100644 --- a/go/internal/codeshape/tsmetrics.go +++ b/go/internal/codeshape/tsmetrics.go @@ -66,9 +66,8 @@ type biomeReport struct { } type tsFunction struct { - node *gotreesitter.Node - id string - parent *tsFunction + node *gotreesitter.Node + id string } // TSMetrics measures Biome's function complexity and line count rules. @@ -272,15 +271,28 @@ func parseTSFunctions(path string, source []byte) ([]tsFunction, *gotreesitter.T functions := make([]tsFunction, 0) collectTSFunctions(root, lang, nil, nil, &functions, source) counts := make(map[string]int) - for _, function := range functions { - counts[function.id]++ - } - seen := make(map[string]int) for i := range functions { - id := functions[i].id - if counts[id] > 1 { - seen[id]++ - functions[i].id = fmt.Sprintf("%s#%d", id, seen[id]) + parentID := "" + 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 + "/" + break + } + } + classID := "" + for parent := functions[i].node.Parent(); parent != nil; parent = parent.Parent() { + if parent.Type(lang) == "class_declaration" || parent.Type(lang) == "class" { + if name := parent.ChildByFieldName("name", lang); name != nil { + classID = name.Text(source) + "/" + } + break + } + } + base := parentID + classID + functions[i].id + counts[base]++ + functions[i].id = base + if counts[base] > 1 { + functions[i].id += fmt.Sprintf("#%d", counts[base]) } } return functions, tree, nil @@ -300,7 +312,7 @@ func collectTSFunctions( 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") { + 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) @@ -312,7 +324,7 @@ func collectTSFunctions( } } if isTSFunctionNode(node, lang) { - function := tsFunction{node: node, id: "", parent: parent} + function := tsFunction{node: node, id: ""} function.id = tsFunctionID(node, lang, titlePath, source) *functions = append(*functions, function) parent = &(*functions)[len(*functions)-1] @@ -339,7 +351,7 @@ func firstStringArgument(arguments *gotreesitter.Node, lang *gotreesitter.Langua return "" } child := arguments.NamedChild(0) - if child.Type(lang) != "string" { + if child.Type(lang) != "string" && (child.Type(lang) != "template_string" || strings.Contains(child.Text(source), "${")) { return "" } return strings.Trim(strings.TrimSpace(child.Text(source)), "`\"'") @@ -363,10 +375,13 @@ func tsFunctionID(node *gotreesitter.Node, lang *gotreesitter.Language, titlePat functionName := "anonymous" if name != nil { functionName = name.Text(source) - } else if parent := node.Parent(); parent != nil && parent.Type(lang) == "variable_declarator" { + } 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 diff --git a/go/internal/codeshape/tsmetrics_test.go b/go/internal/codeshape/tsmetrics_test.go index 3814905..626b531 100644 --- a/go/internal/codeshape/tsmetrics_test.go +++ b/go/internal/codeshape/tsmetrics_test.go @@ -76,6 +76,28 @@ func TestTSGeneratorAndUnicodePosition(t *testing.T) { } } +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 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) From 41f2ee92113ad0a0026e7ef044bcc084e1dc34c4 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 09:41:43 -0500 Subject: [PATCH 06/54] wip(go): T2 review package split, does not build (RIG-8435) --- go/internal/codeshape/allowlist/api.go | 180 ++++++++ go/internal/codeshape/allowlist/api_test.go | 47 ++ go/internal/codeshape/allowlist/base.go | 36 ++ go/internal/codeshape/allowlist/load.go | 0 .../codeshape/{ => allowlist}/render.go | 27 +- .../codeshape/{ => allowlist}/render_test.go | 0 .../codeshape/{ => allowlist}/schema.cue | 8 +- go/internal/codeshape/cli.go | 317 ------------- go/internal/codeshape/cli/main.go | 354 ++++++++++++++ go/internal/codeshape/cli_test.go | 167 ------- go/internal/codeshape/functions.go | 158 ------- go/internal/codeshape/golang/api.go | 106 +++++ .../{gometrics.go => golang/metrics.go} | 431 ++++++++--------- .../metrics_test.go} | 19 +- .../suppressions.go} | 0 go/internal/codeshape/inventory.go | 187 -------- go/internal/codeshape/inventory/inventory.go | 156 +++++++ .../codeshape/inventory/inventory_test.go | 65 +++ go/internal/codeshape/inventory/paths.go | 25 + go/internal/codeshape/inventory_test.go | 253 ---------- go/internal/codeshape/judge.go | 242 ---------- go/internal/codeshape/judge/judge.go | 196 ++++++++ go/internal/codeshape/judge/judge_test.go | 39 ++ go/internal/codeshape/judge_test.go | 205 --------- go/internal/codeshape/load.go | 254 ---------- go/internal/codeshape/load_test.go | 242 ---------- go/internal/codeshape/metrics.go | 20 - go/internal/codeshape/model/config.go | 146 ++++++ go/internal/codeshape/model/model.go | 200 ++++++++ go/internal/codeshape/testsupport/fixtures.go | 86 ++++ go/internal/codeshape/tsmetrics.go | 435 ------------------ go/internal/codeshape/typescript/api.go | 184 ++++++++ go/internal/codeshape/typescript/api_test.go | 98 ++++ go/internal/codeshape/typescript/metrics.go | 420 +++++++++++++++++ .../metrics_test.go} | 0 go/internal/codeshape/typescript/report.go | 32 ++ .../suppressions.go} | 0 37 files changed, 2621 insertions(+), 2714 deletions(-) create mode 100644 go/internal/codeshape/allowlist/api.go create mode 100644 go/internal/codeshape/allowlist/api_test.go create mode 100644 go/internal/codeshape/allowlist/base.go create mode 100644 go/internal/codeshape/allowlist/load.go rename go/internal/codeshape/{ => allowlist}/render.go (67%) rename go/internal/codeshape/{ => allowlist}/render_test.go (100%) rename go/internal/codeshape/{ => allowlist}/schema.cue (82%) delete mode 100644 go/internal/codeshape/cli.go create mode 100644 go/internal/codeshape/cli/main.go delete mode 100644 go/internal/codeshape/cli_test.go delete mode 100644 go/internal/codeshape/functions.go create mode 100644 go/internal/codeshape/golang/api.go rename go/internal/codeshape/{gometrics.go => golang/metrics.go} (50%) rename go/internal/codeshape/{gometrics_test.go => golang/metrics_test.go} (93%) rename go/internal/codeshape/{gosuppressions.go => golang/suppressions.go} (100%) delete mode 100644 go/internal/codeshape/inventory.go create mode 100644 go/internal/codeshape/inventory/inventory.go create mode 100644 go/internal/codeshape/inventory/inventory_test.go create mode 100644 go/internal/codeshape/inventory/paths.go delete mode 100644 go/internal/codeshape/inventory_test.go delete mode 100644 go/internal/codeshape/judge.go create mode 100644 go/internal/codeshape/judge/judge.go create mode 100644 go/internal/codeshape/judge/judge_test.go delete mode 100644 go/internal/codeshape/judge_test.go delete mode 100644 go/internal/codeshape/load.go delete mode 100644 go/internal/codeshape/load_test.go delete mode 100644 go/internal/codeshape/metrics.go create mode 100644 go/internal/codeshape/model/config.go create mode 100644 go/internal/codeshape/model/model.go create mode 100644 go/internal/codeshape/testsupport/fixtures.go delete mode 100644 go/internal/codeshape/tsmetrics.go create mode 100644 go/internal/codeshape/typescript/api.go create mode 100644 go/internal/codeshape/typescript/api_test.go create mode 100644 go/internal/codeshape/typescript/metrics.go rename go/internal/codeshape/{tsmetrics_test.go => typescript/metrics_test.go} (100%) create mode 100644 go/internal/codeshape/typescript/report.go rename go/internal/codeshape/{tssuppressions.go => typescript/suppressions.go} (100%) diff --git a/go/internal/codeshape/allowlist/api.go b/go/internal/codeshape/allowlist/api.go new file mode 100644 index 0000000..3e115d8 --- /dev/null +++ b/go/internal/codeshape/allowlist/api.go @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +// Package allowlist loads and renders the code-shape gate configuration. +package allowlist + +import ( + "embed" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/ast" + "cuelang.org/go/cue/cuecontext" + "cuelang.org/go/cue/parser" + + "github.com/RigelBuild/meissa/go/internal/codeshape/model" +) + +var ( + errInvalidThresholds = errors.New("config thresholds must be fileLimit 500 and dirLimit 10") + 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 + +// Load reads and validates the repository's CUE configuration and allowlist. +func Load(root string) (model.Config, model.Allowlist, error) { + repository, err := os.OpenRoot(root) + if err != nil { + return model.Config{}, model.Allowlist{}, fmt.Errorf("open repository root: %w", err) + } + config, allowlist, loadErr := loadFromRoot(repository) + closeErr := repository.Close() + if closeErr != nil { + closeErr = fmt.Errorf("close repository root: %w", closeErr) + } + return config, allowlist, errors.Join(loadErr, closeErr) +} + +func loadFromRoot(repository *os.Root) (model.Config, model.Allowlist, error) { + ctx := cuecontext.New() + schema, err := schemaValue(ctx) + if err != nil { + return model.Config{}, model.Allowlist{}, err + } + configValue, err := loadCUEFile(ctx, repository, "tools/code-shape-gate/config.cue") + if err != nil { + return model.Config{}, model.Allowlist{}, err + } + configValue = configValue.Unify(schema.LookupPath(cue.MakePath(cue.Def("Config")))) + if err = configValue.Err(); err != nil { + return model.Config{}, model.Allowlist{}, fmt.Errorf("validate config.cue: %w", err) + } + var config model.Config + if err = configValue.Decode(&config); err != nil { + return model.Config{}, model.Allowlist{}, fmt.Errorf("decode config.cue: %w", err) + } + if config.Functions == (model.FunctionLimits{}) { + config.Functions = model.FunctionLimits{ + Go: model.GoFunctionLimits{Cognitive: 20, Lines: 60, Statements: 40}, + TS: model.TSFunctionLimits{Cognitive: 15, Lines: 50}, + } + } + if err = validateConfig(config); err != nil { + return model.Config{}, model.Allowlist{}, err + } + allowValue, err := loadCUEFile(ctx, repository, "tools/code-shape-gate/allowlist.cue") + if err != nil { + return model.Config{}, model.Allowlist{}, err + } + allowlist, err := decodeAllowlist(schema, allowValue, "allowlist.cue") + if err != nil { + return model.Config{}, model.Allowlist{}, err + } + allowlist.Present = true + return config, allowlist, nil +} + +func schemaValue(ctx *cue.Context) (cue.Value, error) { + data, err := schemaFiles.ReadFile("schema.cue") + if err != nil { + return cue.Value{}, fmt.Errorf("read embedded schema: %w", err) + } + value := ctx.CompileBytes(data) + if err = value.Err(); err != nil { + return cue.Value{}, fmt.Errorf("compile embedded schema: %w", err) + } + return value, nil +} + +func loadCUEFile(ctx *cue.Context, repository *os.Root, name string) (cue.Value, error) { + data, err := repository.ReadFile(name) + if err != nil { + return cue.Value{}, fmt.Errorf("read %s: %w", name, err) + } + file, err := parser.ParseFile(name, data) + if err != nil { + return cue.Value{}, fmt.Errorf("parse %s: %w", name, err) + } + for _, declaration := range file.Decls { + if _, ok := declaration.(*ast.ImportDecl); ok { + return cue.Value{}, fmt.Errorf("%s: %w", name, errImportsForbidden) + } + } + value := ctx.BuildFile(file) + if err = value.Err(); err != nil { + return cue.Value{}, fmt.Errorf("evaluate %s: %w", name, err) + } + return value, nil +} + +func decodeAllowlist(schema, value cue.Value, name string) (model.Allowlist, error) { + value = value.Unify(schema.LookupPath(cue.MakePath(cue.Def("Allowlist")))) + if err := value.Err(); err != nil { + return model.Allowlist{}, fmt.Errorf("validate %s: %w", name, err) + } + var allowlist model.Allowlist + if err := value.Decode(&allowlist); err != nil { + return model.Allowlist{}, fmt.Errorf("decode %s: %w", name, err) + } + if allowlist.Files == nil { + allowlist.Files = map[string]model.Entry{} + } + if allowlist.Dirs == nil { + allowlist.Dirs = map[string]model.Entry{} + } + if allowlist.Functions == nil { + allowlist.Functions = model.NewFunctionMeasurements() + } + for metric, paths := range allowlist.Functions { + if !model.IsFunctionMetric(metric) { + return model.Allowlist{}, fmt.Errorf("%s: %w %q", name, errUnsupportedMetric, metric) + } + if err := model.ValidateFunctionEntryIDs(paths); err != nil { + return model.Allowlist{}, fmt.Errorf("%s: %w", name, err) + } + } + if entry, exists := allowlist.Dirs["."]; exists { + if _, duplicate := allowlist.Dirs[""]; duplicate { + return model.Allowlist{}, fmt.Errorf("%s: %w", name, errDuplicateRoot) + } + allowlist.Dirs[""] = entry + delete(allowlist.Dirs, ".") + } + return allowlist, nil +} + +func validateConfig(config model.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) + } + } + for _, pattern := range config.Exclude { + for segment := range strings.SplitSeq(pattern, "/") { + if segment == "**" { + continue + } + if _, err := filepath.Match(segment, ""); err != nil { + return fmt.Errorf("invalid exclude glob %q: %w", pattern, err) + } + } + } + return nil +} diff --git a/go/internal/codeshape/allowlist/api_test.go b/go/internal/codeshape/allowlist/api_test.go new file mode 100644 index 0000000..bfd72fe --- /dev/null +++ b/go/internal/codeshape/allowlist/api_test.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package allowlist_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/RigelBuild/meissa/go/internal/codeshape/allowlist" + "github.com/RigelBuild/meissa/go/internal/codeshape/model" + "github.com/RigelBuild/meissa/go/internal/codeshape/testsupport" +) + +func TestLoadAllowsT1ConfigWithoutFunctionLimits(t *testing.T) { + root := testsupport.NewFixtureRepo(t) + testsupport.WriteConfigFiles(t, root, "fileLimit: 500\ndirLimit: 10\nextensions: [\".go\"]\nexclude: []\n", "files: {}\n") + cfg, _, err := allowlist.Load(root) + if err != nil { + t.Fatal(err) + } + if cfg.FileLimit != 500 || cfg.Functions.Go.Lines != 0 { + t.Fatalf("config = %#v", cfg) + } +} + +func TestLoadBiomeConfigSupportsJSONCNameAndRootedRead(t *testing.T) { + root := testsupport.NewFixtureRepo(t) + outside := filepath.Join(t.TempDir(), "outside") + if err := os.WriteFile(outside, []byte("outside"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil { + t.Fatal(err) + } + repository, err := os.OpenRoot(root) + if err != nil { + t.Fatal(err) + } + defer repository.Close() + if _, err := repository.ReadFile("escape"); err == nil { + t.Fatal("repository root followed a symlink outside its boundary") + } + if model.MetricTSLines != "ts-lines" { + t.Fatalf("metric = %q", model.MetricTSLines) + } +} diff --git a/go/internal/codeshape/allowlist/base.go b/go/internal/codeshape/allowlist/base.go new file mode 100644 index 0000000..4e2fa20 --- /dev/null +++ b/go/internal/codeshape/allowlist/base.go @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package allowlist + +import ( + "fmt" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/ast" + "cuelang.org/go/cue/parser" + + "github.com/RigelBuild/meissa/go/internal/codeshape/model" +) + +// Schema returns the embedded allowlist schema in a CUE context. +func Schema(ctx *cue.Context) (cue.Value, error) { + return schemaValue(ctx) +} + +// ParseAllowlist decodes an allowlist from CUE bytes. +func ParseAllowlist(ctx *cue.Context, schema cue.Value, data []byte) (model.Allowlist, error) { + file, err := parser.ParseFile("base allowlist", data) + if err != nil { + return model.Allowlist{}, fmt.Errorf("parse base allowlist: %w", err) + } + for _, declaration := range file.Decls { + if _, ok := declaration.(*ast.ImportDecl); ok { + return model.Allowlist{}, fmt.Errorf("base allowlist: %w", errImportsForbidden) + } + } + value := ctx.BuildFile(file) + if err := value.Err(); err != nil { + return model.Allowlist{}, fmt.Errorf("evaluate base allowlist: %w", err) + } + return decodeAllowlist(schema, value, "base allowlist") +} diff --git a/go/internal/codeshape/allowlist/load.go b/go/internal/codeshape/allowlist/load.go new file mode 100644 index 0000000..e69de29 diff --git a/go/internal/codeshape/render.go b/go/internal/codeshape/allowlist/render.go similarity index 67% rename from go/internal/codeshape/render.go rename to go/internal/codeshape/allowlist/render.go index 1c2f758..e40ef0f 100644 --- a/go/internal/codeshape/render.go +++ b/go/internal/codeshape/allowlist/render.go @@ -1,15 +1,17 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -package codeshape +package allowlist import ( "sort" "strconv" "strings" + + "github.com/RigelBuild/meissa/go/internal/codeshape/model" ) -// Render serializes the T1 file and directory entries in stable key order. -func Render(allow Allowlist) []byte { +// Render serializes the allowlist in stable key order. +func Render(allow model.Allowlist) []byte { var output strings.Builder writeEntries(&output, "files", allow.Files) writeEntries(&output, "dirs", allow.Dirs) @@ -20,7 +22,7 @@ func Render(allow Allowlist) []byte { return []byte(output.String()) } -func writeEntries(output *strings.Builder, name string, entries map[string]Entry) { +func writeEntries(output *strings.Builder, name string, entries map[string]model.Entry) { if len(entries) == 0 { return } @@ -38,16 +40,16 @@ func writeEntries(output *strings.Builder, name string, entries map[string]Entry output.WriteString("}\n") } -func writeFunctionEntries(output *strings.Builder, functions map[Metric]map[string][]Entry) { - for _, metric := range functionMetrics() { +func writeFunctionEntries(output *strings.Builder, functions model.FunctionMeasurements) { + for _, metric := range model.FunctionMetrics() { paths := functions[metric] if len(paths) == 0 { continue } output.WriteString("functions: " + strconv.Quote(string(metric)) + ": {\n") - for _, path := range sortedEntryPaths(paths) { + for _, path := range sortedPaths(paths) { output.WriteString("\t" + strconv.Quote(path) + ": [\n") - for _, entry := range sortedFunctionEntries(paths[path]) { + for _, entry := range model.SortedFunctionEntries(paths[path]) { output.WriteString("\t\t{id: " + strconv.Quote(entry.ID) + ", value: " + strconv.Itoa(entry.Value) + ", reason: " + strconv.Quote(entry.Reason) + "},\n") } @@ -56,3 +58,12 @@ func writeFunctionEntries(output *strings.Builder, functions map[Metric]map[stri output.WriteString("}\n") } } + +func sortedPaths[V any](entries map[string]V) []string { + paths := make([]string, 0, len(entries)) + for path := range entries { + paths = append(paths, path) + } + sort.Strings(paths) + return paths +} diff --git a/go/internal/codeshape/render_test.go b/go/internal/codeshape/allowlist/render_test.go similarity index 100% rename from go/internal/codeshape/render_test.go rename to go/internal/codeshape/allowlist/render_test.go diff --git a/go/internal/codeshape/schema.cue b/go/internal/codeshape/allowlist/schema.cue similarity index 82% rename from go/internal/codeshape/schema.cue rename to go/internal/codeshape/allowlist/schema.cue index 5e67e8a..75b59b3 100644 --- a/go/internal/codeshape/schema.cue +++ b/go/internal/codeshape/allowlist/schema.cue @@ -36,9 +36,9 @@ exclude: [...string] // When multiple globs match, the smallest limit wins. dirLimits?: [string]: {limit: int & >10, reason: string & =~"^\\S+(\\s+\\S+){2,}$"} - functions: close({ - go: close({cognitive: 20, lines: 60, statements: 40}) - ts: close({cognitive: 15, lines: 50}) - }) + functions?: close({ + go: close({cognitive: int & >0, lines: int & >0, statements: int & >0}) + ts: close({cognitive: int & >0, lines: int & >0}) + }) }) diff --git a/go/internal/codeshape/cli.go b/go/internal/codeshape/cli.go deleted file mode 100644 index eeb9988..0000000 --- a/go/internal/codeshape/cli.go +++ /dev/null @@ -1,317 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package codeshape - -import ( - "context" - "errors" - "fmt" - "io" - "maps" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - - "cuelang.org/go/cue/cuecontext" -) - -var errCannotRaiseAllowlist = errors.New("--write cannot add or raise allowlist entries") - -// Main runs the code-shape-gate CLI contract without exiting the process. -func Main(ctx context.Context, args []string, env func(string) string, stdout, stderr io.Writer) int { - if len(args) == 1 && (args[0] == "--help" || args[0] == "-h") { - if !writeOutput(stdout, "usage: code-shape-gate [--write] [--seed] \n") { - return 2 - } - return 0 - } - writeMode, seed, root, ok := parseArgs(args) - if !ok { - writeOutput(stderr, "usage: code-shape-gate [--write] [--seed] \n") - return 2 - } - root, err := filepath.Abs(root) - if err != nil { - writeOutput(stderr, "code-shape-gate: %v\n", err) - return 2 - } - config, allow, err := Load(root) - if err != nil { - writeOutput(stderr, "code-shape-gate: %v\n", err) - return 2 - } - var base Allowlist - if baseRef := env("GATE_BASE"); baseRef != "" { - base, err = loadBaseAllowlist(ctx, root, baseRef) - if err != nil { - writeOutput(stderr, "code-shape-gate: %v\n", err) - return 2 - } - } - snapshot, err := Inventory(ctx, root, config) - if err != nil { - writeOutput(stderr, "code-shape-gate: %v\n", err) - return 2 - } - functions, skips, err := GoMetrics(ctx, root, config) - if err != nil { - writeOutput(stderr, "code-shape-gate: %v\n", err) - return 2 - } - for _, skip := range skips { - if !writeOutput(stderr, "code-shape-gate: skip %s: %s\n", skip.Path, skip.Reason) { - return 2 - } - } - tsFunctions, tsSkips, err := TSMetrics(ctx, root, config) - if err != nil { - writeOutput(stderr, "code-shape-gate: %v\n", err) - return 2 - } - for _, skip := range tsSkips { - if !writeOutput(stderr, "code-shape-gate: skip %s: %s\n", skip.Path, skip.Reason) { - return 2 - } - } - functions[MetricTSCognitive] = tsFunctions[MetricTSCognitive] - functions[MetricTSLines] = tsFunctions[MetricTSLines] - snapshot.Functions = functions - suppressions, err := goSuppressionFindings(ctx, root, config) - if err != nil { - writeOutput(stderr, "code-shape-gate: %v\n", err) - return 2 - } - tsFindings, err := tsPolicyFindings(ctx, root, config) - if err != nil { - writeOutput(stderr, "code-shape-gate: %v\n", err) - return 2 - } - suppressions = append(suppressions, tsFindings...) - sortFindings(suppressions) - if len(suppressions) > 0 { - err := writeFindings(stdout, suppressions) - if err != nil { - writeOutput(stderr, "code-shape-gate: write output: %v\n", err) - return 2 - } - return 1 - } - if writeMode { - return writeUpdated(root, config, allow, base, snapshot, seed, stdout, stderr) - } - findings := Judge(config, allow, base, snapshot) - 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 - } - return 0 -} - -func writeUpdated( - root string, - config Config, - allow, base Allowlist, - snapshot Snapshot, - seed bool, - stdout, stderr io.Writer, -) 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) - 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 { - 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) - closeErr := repository.Close() - err = errors.Join(err, closeErr) - if err != nil { - writeOutput(stderr, "code-shape-gate: write allowlist: %v\n", err) - return 2 - } - } - return 0 -} - -func parseArgs(args []string) (bool, bool, string, bool) { - var writeMode, seed bool - for len(args) > 0 && strings.HasPrefix(args[0], "--") { - switch args[0] { - case "--write": - if writeMode { - return false, false, "", false - } - writeMode = true - case "--seed": - if seed { - return false, false, "", false - } - seed = true - default: - return false, false, "", false - } - args = args[1:] - } - if len(args) != 1 || args[0] == "" || seed && !writeMode { - return false, false, "", false - } - return writeMode, seed, args[0], true -} - -func loadBaseAllowlist(ctx context.Context, root, ref string) (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) - } - if len(data) == 0 { - return 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 - } - allowlist, err := decodeAllowlistBytes(cueContext, schema, data, "base allowlist") - if err != nil { - return Allowlist{}, err - } - allowlist.Present = true - return allowlist, nil -} - -func gitBaseError(ref string, err error) error { - exitError, ok := errors.AsType[*exec.ExitError](err) - if ok { - return fmt.Errorf("read base allowlist at %q: %w: %s", ref, err, strings.TrimSpace(string(exitError.Stderr))) - } - 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)), - Functions: make(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) - changedFunctions, functionErrors := 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...) - 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, "; ")) - } - return updated, changed, nil -} - -func rewriteEntries( - entries map[string]Entry, - values map[string]int, - cfg Config, - metric Metric, - seed bool, -) (bool, []string) { - changed := false - for path, entry := range entries { - limit := cfg.limitFor(metric, path) - value, exists := values[path] - if !exists || value <= limit { - delete(entries, path) - changed = true - } else if value < entry.Value { - entry.Value = value - entries[path] = entry - changed = true - } - } - var errs []string - for path, value := range values { - limit := cfg.limitFor(metric, path) - if value <= limit { - continue - } - entry, exists := entries[path] - if exists && value <= entry.Value { - continue - } - if seed { - if !exists { - entry.Reason = "seeded at rollout, burn down" - } - entry.Value = value - entries[path] = entry - changed = true - } else { - errMessage := fmt.Sprintf( - "%s %s value %d needs --seed to add or raise the allowlist", - displayPath(metric, path), - metric, - value, - ) - errs = append(errs, errMessage) - } - } - return changed, errs -} - -func writeOutput(output io.Writer, format string, args ...any) bool { - _, err := fmt.Fprintf(output, format, args...) - return err == nil -} - -func writeFindings(output io.Writer, findings []Finding) error { - for _, finding := range findings { - _, err := fmt.Fprintf( - output, - "%s %s value=%d limit=%d fix=%s\n", - finding.Path, - finding.Metric, - finding.Value, - finding.Limit, - finding.Fix, - ) - if err != nil { - return fmt.Errorf("write finding: %w", err) - } - } - return nil -} diff --git a/go/internal/codeshape/cli/main.go b/go/internal/codeshape/cli/main.go new file mode 100644 index 0000000..e38de92 --- /dev/null +++ b/go/internal/codeshape/cli/main.go @@ -0,0 +1,354 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +// Package cli exposes the code-shape gate command orchestration. +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "maps" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "cuelang.org/go/cue/cuecontext" + + "github.com/RigelBuild/meissa/go/internal/codeshape/allowlist" + "github.com/RigelBuild/meissa/go/internal/codeshape/golang" + "github.com/RigelBuild/meissa/go/internal/codeshape/inventory" + "github.com/RigelBuild/meissa/go/internal/codeshape/judge" + "github.com/RigelBuild/meissa/go/internal/codeshape/model" + "github.com/RigelBuild/meissa/go/internal/codeshape/typescript" +) + +var errCannotRaiseAllowlist = errors.New("--write cannot add or raise allowlist entries") + +// Main runs the code-shape gate command without exiting the process. +func Main(ctx context.Context, args []string, env func(string) string, stdout, stderr io.Writer) int { + if helpRequested(args) { + return printHelp(stdout) + } + writeMode, seed, root, ok := parseArgs(args) + if !ok { + writeOutput(stderr, "usage: code-shape-gate [--write] [--seed] \n") + return 2 + } + root, err := filepath.Abs(root) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + config, allow, err := allowlist.Load(root) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + base, err := loadRequestedBase(ctx, root, env) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + paths, err := inventory.TrackedPaths(ctx, root) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + repository, err := os.OpenRoot(root) + if err != nil { + writeOutput(stderr, "code-shape-gate: open repository: %v\n", err) + return 2 + } + defer repository.Close() + snapshot, err := inventory.Inventory(paths, config, repository) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + functions, skips, err := collectFunctionMeasurements(ctx, root, paths, config, repository) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + for _, skip := range skips { + if !writeOutput(stderr, "code-shape-gate: skip %s: %s\n", skip.Path, skip.Reason) { + return 2 + } + } + snapshot.Functions = functions + findings, err := collectPolicyFindings(paths, config, repository) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + if writeMode { + return writeUpdated(root, config, allow, base, snapshot, seed, findings, stdout, stderr) + } + findings = append(judge.Judge(config, allow, base, snapshot), findings...) + sortFindings(findings) + return printFindings(stdout, stderr, findings) +} + +func collectFunctionMeasurements(ctx context.Context, root string, paths []string, cfg model.Config, repository *os.Root) (model.FunctionMeasurements, []model.Skip, error) { + goValues, goSkips, err := golang.Metrics(ctx, root, paths, cfg, repository) + if err != nil { + return nil, nil, err + } + tsValues, tsSkips, err := typescript.Metrics(ctx, root, paths, cfg, repository) + if err != nil { + return nil, nil, err + } + for _, metric := range []model.Metric{model.MetricTSCognitive, model.MetricTSLines} { + goValues[metric] = tsValues[metric] + } + return goValues, append(goSkips, tsSkips...), nil +} + +func collectPolicyFindings(paths []string, cfg model.Config, repository *os.Root) ([]model.Finding, error) { + goFindings, err := golang.SuppressionFindings(paths, cfg, repository) + if err != nil { + return nil, err + } + tsFindings, err := typescript.SuppressionFindings(paths, cfg, repository) + if err != nil { + return nil, err + } + return append(goFindings, tsFindings...), nil +} + +func helpRequested(args []string) bool { + return len(args) == 1 && (args[0] == "--help" || args[0] == "-h") +} + +func printHelp(stdout io.Writer) int { + if !writeOutput(stdout, "usage: code-shape-gate [--write] [--seed] \n") { + return 2 + } + return 0 +} + +func loadRequestedBase(ctx context.Context, root string, env func(string) string) (model.Allowlist, error) { + ref := env("GATE_BASE") + if ref == "" { + return model.Allowlist{}, nil + } + return loadBaseAllowlist(ctx, root, ref) +} + +func printFindings(stdout, stderr io.Writer, findings []model.Finding) int { + if err := writeFindings(stdout, findings); err != nil { + writeOutput(stderr, "code-shape-gate: write output: %v\n", err) + return 2 + } + if len(findings) > 0 { + return 1 + } + return 0 +} + +func writeUpdated(root string, config model.Config, allow, base model.Allowlist, snapshot model.Snapshot, seed bool, + suppressions []model.Finding, stdout, stderr io.Writer) int { + updated, changed, err := rewriteAllowlist(config, allow, snapshot, seed) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + findings := append(judge.Judge(config, updated, base, snapshot), suppressions...) + sortFindings(findings) + if len(findings) > 0 { + return printFindings(stdout, stderr, findings) + } + if changed { + if err := writeAllowlist(root, updated); err != nil { + writeOutput(stderr, "code-shape-gate: write allowlist: %v\n", err) + return 2 + } + } + return 0 +} + +func writeAllowlist(root string, updated model.Allowlist) error { + repository, err := os.OpenRoot(root) + if err != nil { + return fmt.Errorf("open repository: %w", err) + } + writeErr := repository.WriteFile("tools/code-shape-gate/allowlist.cue", allowlist.Render(updated), 0o644) + closeErr := repository.Close() + return errors.Join(writeErr, closeErr) +} + +func parseArgs(args []string) (bool, bool, string, bool) { + var writeMode, seed bool + for len(args) > 0 && strings.HasPrefix(args[0], "--") { + switch args[0] { + case "--write": + if writeMode { + return false, false, "", false + } + writeMode = true + case "--seed": + if seed { + return false, false, "", false + } + seed = true + default: + return false, false, "", false + } + args = args[1:] + } + if len(args) != 1 || args[0] == "" || seed && !writeMode { + return false, false, "", false + } + return writeMode, seed, args[0], true +} + +func loadBaseAllowlist(ctx context.Context, root, ref string) (model.Allowlist, error) { + 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 model.Allowlist{}, gitBaseError(ref, err) + } + if len(data) == 0 { + return emptyAllowlist(), nil + } + 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 model.Allowlist{}, gitBaseError(ref, err) + } + cueContext := cuecontext.New() + schema, err := allowlistSchema(cueContext) + if err != nil { + return model.Allowlist{}, err + } + parsed, err := parseBaseAllowlist(cueContext, schema, data) + if err != nil { + return model.Allowlist{}, err + } + parsed.Present = true + return parsed, nil +} + +func gitBaseError(ref string, err error) error { + exitError, ok := errors.AsType[*exec.ExitError](err) + if ok { + return fmt.Errorf("read base allowlist at %q: %w: %s", ref, err, strings.TrimSpace(string(exitError.Stderr))) + } + return fmt.Errorf("read base allowlist at %q: %w", ref, err) +} + +func rewriteAllowlist(config model.Config, allow, snapshot model.Allowlist, values model.Snapshot, seed bool) (model.Allowlist, bool, error) { + _ = snapshot + updated := model.Allowlist{Files: make(map[string]model.Entry, len(allow.Files)), + Dirs: make(map[string]model.Entry, len(allow.Dirs)), Functions: model.NewFunctionMeasurements(), Present: true} + maps.Copy(updated.Files, allow.Files) + maps.Copy(updated.Dirs, allow.Dirs) + changedFiles, fileErrors := rewriteEntries(updated.Files, values.Files, config, model.MetricFiles, seed) + changedDirs, dirErrors := rewriteEntries(updated.Dirs, values.Dirs, config, model.MetricDirs, seed) + changedFunctions, functionErrors := model.RewriteFunctionEntries(updated.Functions, allow.Functions, values.Functions, config, seed) + errs := append(fileErrors, dirErrors...) + errs = append(errs, functionErrors...) + sort.Strings(errs) + if len(errs) > 0 { + return allow, false, fmt.Errorf("%w: %s", errCannotRaiseAllowlist, strings.Join(errs, "; ")) + } + return updated, changedFiles || changedDirs || changedFunctions, nil +} + +func rewriteEntries(entries map[string]model.Entry, values map[string]int, cfg model.Config, metric model.Metric, seed bool) (bool, []string) { + changed := dropClearedEntries(entries, values, cfg, metric) + added, errs := addFunctionEntries(entries, values, cfg, metric, seed) + return changed || added, errs +} + +func dropClearedEntries(entries map[string]model.Entry, values map[string]int, cfg model.Config, metric model.Metric) bool { + changed := false + for path, entry := range entries { + value, exists := values[path] + if !exists || value <= limitFor(cfg, metric, path) { + delete(entries, path) + changed = true + } else if value < entry.Value { + entry.Value = value + entries[path] = entry + changed = true + } + } + return changed +} + +func addFunctionEntries(entries map[string]model.Entry, values map[string]int, cfg model.Config, metric model.Metric, seed bool) (bool, []string) { + changed := false + var errs []string + for path, value := range values { + limit := limitFor(cfg, metric, path) + if value <= limit { + continue + } + entry, exists := entries[path] + if exists && value <= entry.Value { + continue + } + if !seed { + errs = append(errs, fmt.Sprintf("%s %s value %d needs --seed to add or raise the allowlist", path, metric, value)) + continue + } + if !exists { + entry.Reason = "seeded at rollout, burn down" + } + entry.Value = value + entries[path] = entry + changed = true + } + return changed, errs +} + +func limitFor(cfg model.Config, metric model.Metric, path string) int { + if metric != model.MetricDirs { + return cfg.FileLimit + } + limit := 0 + for pattern, entry := range cfg.DirLimits { + if inventory.MatchGlob(pattern, path) && (limit == 0 || entry.Limit < limit) { + limit = entry.Limit + } + } + if limit == 0 { + return cfg.DirLimit + } + return limit +} + +func emptyAllowlist() model.Allowlist { + return model.Allowlist{Files: map[string]model.Entry{}, Dirs: map[string]model.Entry{}, + Functions: model.NewFunctionMeasurements()} +} + +func writeOutput(output io.Writer, format string, args ...any) bool { + _, err := fmt.Fprintf(output, format, args...) + return err == nil +} + +func writeFindings(output io.Writer, findings []model.Finding) error { + for _, finding := range findings { + if _, err := fmt.Fprintf(output, "%s %s value=%d limit=%d fix=%s\n", finding.Path, finding.Metric, + finding.Value, finding.Limit, finding.Fix); err != nil { + return fmt.Errorf("write finding: %w", err) + } + } + return nil +} + +func sortFindings(findings []model.Finding) { + sort.Slice(findings, func(i, j int) bool { + if findings[i].Path != findings[j].Path { + return findings[i].Path < findings[j].Path + } + return findings[i].Metric < findings[j].Metric + }) +} diff --git a/go/internal/codeshape/cli_test.go b/go/internal/codeshape/cli_test.go deleted file mode 100644 index 6519328..0000000 --- a/go/internal/codeshape/cli_test.go +++ /dev/null @@ -1,167 +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 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 := emptyAllowlist() - allow.Files["gone.go"] = Entry{ID: "", Value: 501, Reason: "split by concern"} - snapshot := Snapshot{Files: map[string]int{}, Dirs: map[string]int{}, Functions: nil} - 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": {ID: "", 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/functions.go b/go/internal/codeshape/functions.go deleted file mode 100644 index 9d54c2c..0000000 --- a/go/internal/codeshape/functions.go +++ /dev/null @@ -1,158 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package codeshape - -import ( - "errors" - "fmt" - "slices" - "sort" -) - -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"` -} - -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: - return 0 - default: - return 0 - } -} - -func isFunctionMetric(metric Metric) bool { - return slices.Contains(functionMetrics(), metric) -} - -func functionMetrics() []Metric { - return []Metric{MetricGoCognitive, MetricGoLines, MetricGoStatements, MetricTSCognitive, MetricTSLines} -} -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 -} - -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: ""}) -} - -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 -} - -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 -} - -func rewriteFunctionEntries(updated, allow, values FunctionMeasurements, cfg Config, seed bool) (bool, []string) { - changed := false - errs := []string{} - for _, metric := range functionMetrics() { - paths := map[string][]Entry{} - for path, entries := range allow[metric] { - for _, entry := range entries { - current, exists := functionEntry(values[metric][path], entry.ID) - if !exists || current.Value <= cfg.Functions.limitFor(metric) { - changed = true - continue - } - if current.Value < entry.Value { - entry.Value = current.Value - changed = true - } - paths[path] = append(paths[path], entry) - } - } - for path, entries := range values[metric] { - for _, current := range entries { - if current.Value <= cfg.Functions.limitFor(metric) { - 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 - if exists { - for i := range paths[path] { - if paths[path][i].ID == current.ID { - paths[path][i] = entry - } - } - } else { - paths[path] = append(paths[path], entry) - } - changed = true - } - } - if len(paths) > 0 { - updated[metric] = paths - } - } - return changed, errs -} diff --git a/go/internal/codeshape/golang/api.go b/go/internal/codeshape/golang/api.go new file mode 100644 index 0000000..237a278 --- /dev/null +++ b/go/internal/codeshape/golang/api.go @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +// Package golang collects per-function Go metrics and suppression findings. +package golang + +import ( + "context" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "strconv" + "strings" + + "github.com/RigelBuild/meissa/go/internal/codeshape/inventory" + "github.com/RigelBuild/meissa/go/internal/codeshape/model" +) + +// SuppressionFindings finds Go lint suppressions on included Go source. +func SuppressionFindings(paths []string, cfg model.Config, repository *os.Root) ([]model.Finding, error) { + var findings []model.Finding + for _, path := range paths { + if !goSourceIncluded(path, cfg) { + continue + } + source, err := repository.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read Go source %q: %w", path, err) + } + file, set, err := parseGoFile(path, source) + if err != nil { + return nil, err + } + findings = append(findings, suppressionFindings(path, file, set)...) + } + return findings, nil +} + +func goSourceIncluded(path string, cfg model.Config) bool { + return strings.HasSuffix(path, ".go") && inventory.CountedPath(path, cfg) +} + +func parseGoFile(path string, source []byte) (*ast.File, *token.FileSet, error) { + set := token.NewFileSet() + file, err := parser.ParseFile(set, path, source, parser.ParseComments) + if err != nil { + return nil, nil, fmt.Errorf("parse Go source %q: %w", path, err) + } + return file, set, nil +} + +func suppressionFindings(path string, file *ast.File, set *token.FileSet) []model.Finding { + var findings []model.Finding + for _, group := range file.Comments { + for _, comment := range group.List { + metrics := suppressedGoMetrics(comment.Text) + line := set.Position(comment.Pos()).Line + for _, metric := range metrics { + findings = append(findings, model.Finding{Path: path + ":" + strconv.Itoa(line), + Metric: metric, Value: 1, Fix: "remove complexity nolint suppression"}) + } + } + } + return findings +} + +func suppressedGoMetrics(text string) []model.Metric { + if !strings.HasPrefix(text, "//") { + return nil + } + fields := strings.Fields(strings.TrimSpace(strings.TrimPrefix(text, "//"))) + if len(fields) == 0 { + return nil + } + switch fields[0] { + case "nolint", "nolint:all": + return []model.Metric{model.MetricGoCognitive, model.MetricGoLines} + } + if names, ok := strings.CutPrefix(fields[0], "nolint:"); ok { + return selectedGoMetrics(names) + } + return nil +} + +func selectedGoMetrics(names string) []model.Metric { + complexity, lines := false, false + for name := range strings.SplitSeq(names, ",") { + switch name { + case "all": + complexity, lines = true, true + case "gocognit", "gocyclo": + complexity = true + case "funlen": + lines = true + } + } + metrics := make([]model.Metric, 0, 2) + if complexity { + metrics = append(metrics, model.MetricGoCognitive) + } + if lines { + metrics = append(metrics, model.MetricGoLines) + } + return metrics +} diff --git a/go/internal/codeshape/gometrics.go b/go/internal/codeshape/golang/metrics.go similarity index 50% rename from go/internal/codeshape/gometrics.go rename to go/internal/codeshape/golang/metrics.go index 87732b3..c0737bc 100644 --- a/go/internal/codeshape/gometrics.go +++ b/go/internal/codeshape/golang/metrics.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -package codeshape +package golang import ( "bytes" @@ -20,6 +20,9 @@ import ( "sort" "strconv" "strings" + + "github.com/RigelBuild/meissa/go/internal/codeshape/inventory" + "github.com/RigelBuild/meissa/go/internal/codeshape/model" ) const highFunlenLimit = 1_000_000_000 @@ -27,8 +30,6 @@ const highFunlenLimit = 1_000_000_000 var ( gocognitValue = regexp.MustCompile(`cognitive complexity ([0-9]+) of func`) funlenValue = regexp.MustCompile(`\(([0-9]+) > [0-9]+\)`) - errEmptyGoModule = errors.New("has no included source files") - errEmptyGoMeasurement = errors.New("go function measurement is empty") errGoLintReport = errors.New("reported an error") errGoLintMissing = errors.New("measurement omitted") errGoMetricParse = errors.New("cannot parse value") @@ -71,30 +72,27 @@ type goFunction struct { id string } -// Skip records a source file that could not be measured. -type Skip struct { - Path string - Reason string +type goLimits struct { + metric model.Metric + linter string + lines int + statements int } -// GoMetrics measures function complexity and length in every included module. -func GoMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[string][]Entry, []Skip, error) { - paths, err := trackedPaths(ctx, root) - if err != nil { - return nil, nil, err - } +// Metrics measures functions in every included Go module. +func Metrics(ctx context.Context, root string, paths []string, cfg model.Config, repository *os.Root) (model.FunctionMeasurements, []model.Skip, error) { modules := goModules(paths, cfg) if len(modules) == 0 { - return newFunctionMeasurements(), nil, nil + return model.NewFunctionMeasurements(), nil, nil } binary, err := exec.LookPath("golangci-lint") if err != nil { return nil, nil, fmt.Errorf("find golangci-lint: %w", err) } - measurements := newFunctionMeasurements() - skips := []Skip{} + measurements := model.NewFunctionMeasurements() + var skips []model.Skip for _, module := range modules { - files, skipped, err := parseGoSources(root, module, paths, cfg) + files, skipped, err := parseGoSources(root, module, paths, cfg, repository) if err != nil { return nil, nil, err } @@ -102,55 +100,88 @@ func GoMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[str if len(files) == 0 { continue } - limits := []struct { - metric Metric - linter string - lines int - statements int - }{ - {metric: MetricGoCognitive, linter: "gocognit", lines: 0, statements: 0}, - {metric: MetricGoLines, linter: "funlen", lines: cfg.Functions.Go.Lines, statements: highFunlenLimit}, - {metric: MetricGoStatements, linter: "funlen", lines: highFunlenLimit, statements: cfg.Functions.Go.Statements}, - } - for _, file := range files { - for _, function := range file.functions { - for _, metric := range []Metric{MetricGoCognitive, MetricGoLines, MetricGoStatements} { - appendFunctionValue(measurements, metric, function.path, function.id, 0) - } - } + if err := initializeMeasurements(measurements, files); err != nil { + return nil, nil, err } - for _, limit := range limits { - report, err := runGolangci(ctx, binary, root, module, limit.linter, limit.lines, limit.statements, cfg) - if err != nil { + for _, limits := range goLimits(cfg) { + if err := measureModule(ctx, root, module, binary, cfg, files, measurements, limits); err != nil { return nil, nil, err } - for _, issue := range report.Issues { - path := issue.Pos.Filename - if filepath.IsAbs(path) { - path, err = filepath.Rel(root, path) - if err != nil { - return nil, nil, fmt.Errorf("relativize Go diagnostic path: %w", err) - } - } else { - path = filepath.Join(module, path) - } - path = filepath.ToSlash(path) - if excludedFunctionPath(path, cfg) || !countedPath(path, cfg) { - continue - } - value, err := metricValue(issue.Text, limit.metric) - if err != nil { - return nil, nil, err - } - path, function, err := findGoFunction(root, module, files, issue.Pos.Filename, issue.Pos.Line, issue.Pos.Column) - if err != nil { - return nil, nil, err + } + } + return measurements, skips, nil +} + +func initializeMeasurements(measurements model.FunctionMeasurements, files map[string]goSourceFile) error { + for _, file := range files { + for _, function := range file.functions { + for _, metric := range []model.Metric{model.MetricGoCognitive, model.MetricGoLines, model.MetricGoStatements} { + if err := model.AppendFunctionValue(measurements, metric, function.path, function.id, 0); err != nil { + return err } - appendFunctionValue(measurements, limit.metric, path, function, value) } } } - return measurements, skips, nil + return nil +} + +func goLimits(cfg model.Config) []goLimits { + return []goLimits{ + {metric: model.MetricGoCognitive, linter: "gocognit"}, + {metric: model.MetricGoLines, linter: "funlen", lines: cfg.Functions.Go.Lines, statements: highFunlenLimit}, + {metric: model.MetricGoStatements, linter: "funlen", lines: highFunlenLimit, statements: cfg.Functions.Go.Statements}, + } +} + +func measureModule(ctx context.Context, root, module, binary string, cfg model.Config, + files map[string]goSourceFile, measurements model.FunctionMeasurements, limits goLimits) error { + report, err := runGolangci(ctx, binary, root, module, limits.linter, limits.lines, limits.statements, cfg) + if err != nil { + return err + } + for _, issue := range report.Issues { + if issue.FromLinter != limits.linter { + return fmt.Errorf("golangci-lint typecheck issue in %q: %s", issue.Pos.Filename, issue.Text) + } + path := resolveReportedPath(root, module, issue.Pos.Filename) + if !goSourceIncluded(path, cfg) { + continue + } + value, err := metricValue(issue.Text, limits.metric) + if err != nil { + return err + } + path, function, err := findGoFunction(root, module, files, issue.Pos.Filename, issue.Pos.Line, issue.Pos.Column) + if errors.Is(err, errGoSourceUnknown) && !includedSourcePath(path, files, cfg) { + continue + } + if err != nil { + return err + } + if err := model.SetFunctionDiagnostic(measurements, limits.metric, path, function, value); err != nil { + return err + } + } + return nil +} + +func resolveReportedPath(root, module, reported string) string { + if filepath.IsAbs(reported) { + relative, err := filepath.Rel(root, reported) + if err == nil { + return filepath.ToSlash(relative) + } + return filepath.ToSlash(reported) + } + path := filepath.ToSlash(reported) + if module != "." { + return filepath.ToSlash(filepath.Join(module, path)) + } + return path +} + +func includedSourcePath(path string, files map[string]goSourceFile, cfg model.Config) bool { + return goSourceIncluded(path, cfg) && files[path].set != nil } func trackedPaths(ctx context.Context, root string) ([]string, error) { @@ -167,71 +198,46 @@ func trackedPaths(ctx context.Context, root string) ([]string, error) { return paths, nil } -func goModules(paths []string, cfg Config) []string { +func goModules(paths []string, cfg model.Config) []string { modules := make([]string, 0) for _, path := range paths { - if filepath.Base(path) != "go.mod" || excludedFunctionPath(path, cfg) { - continue + if filepath.Base(path) == "go.mod" && !excludedFunctionPath(path, cfg) { + modules = append(modules, filepath.ToSlash(filepath.Dir(path))) } - modules = append(modules, filepath.ToSlash(filepath.Dir(path))) } sort.Strings(modules) return modules } -func excludedFunctionPath(path string, cfg Config) bool { +func excludedFunctionPath(path string, cfg model.Config) bool { for segment := range strings.SplitSeq(filepath.ToSlash(path), "/") { if segment == "testdata" || segment == "fixtures" { return true } } - return matchesAny(filepath.ToSlash(path), cfg.Exclude) + return inventory.MatchesAny(filepath.ToSlash(path), cfg.Exclude) } -func parseGoSources(root, module string, paths []string, cfg Config) (map[string]goSourceFile, []Skip, error) { +func parseGoSources(root, module string, paths []string, cfg model.Config, repository *os.Root) (map[string]goSourceFile, []model.Skip, error) { files := make(map[string]goSourceFile) - skips := []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) + var skips []model.Skip + modules := goModules(paths, cfg) for _, path := range paths { - if filepath.Ext(path) != ".go" || excludedFunctionPath(path, cfg) { - continue - } - if module != "." && !strings.HasPrefix(filepath.ToSlash(path), strings.TrimSuffix(module, "/")+"/") { - continue - } - nested := false - for _, dir := range modules { - if dir != "." && dir != module && (module == "." || strings.HasPrefix(dir, module+"/")) && - strings.HasPrefix(filepath.ToSlash(path), dir+"/") { - nested = true - break - } - } - if nested { - continue - } - if !countedPath(filepath.ToSlash(path), cfg) { + if !goSourceIncluded(path, cfg) || !belongsToModule(path, module, modules) { continue } + buildContext := build.Default + buildContext.CgoEnabled = false 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) } if !matches { - skips = append(skips, Skip{Path: path, Reason: "not in default Go build"}) + skips = append(skips, model.Skip{Path: path, Reason: "not in default Go build"}) continue } - //nolint:gosec // G304: path from the repo's tracked file list - source, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) + source, err := repository.ReadFile(filepath.FromSlash(path)) if err != nil { return nil, nil, fmt.Errorf("read Go source %q: %w", path, err) } @@ -249,8 +255,48 @@ func parseGoSources(root, module string, paths []string, cfg Config) (map[string return files, skips, nil } +func belongsToModule(path, module string, modules []string) bool { + slashPath := filepath.ToSlash(path) + if module != "." && !strings.HasPrefix(slashPath, strings.TrimSuffix(module, "/")+"/") { + return false + } + for _, dir := range modules { + if dir != "." && dir != module && (module == "." || strings.HasPrefix(dir, module+"/")) && + strings.HasPrefix(slashPath, dir+"/") { + return false + } + } + return true +} + +func goSourceIncluded(path string, cfg model.Config) bool { + return filepath.Ext(path) == ".go" && !excludedFunctionPath(path, cfg) && inventory.CountedPath(filepath.ToSlash(path), cfg) +} + func goFunctions(file *ast.File, set *token.FileSet) []goFunction { - functions := make([]goFunction, 0) + functions := collectGoFunctions(file, set) + sort.Slice(functions, func(i, j int) bool { return functions[i].node.Pos() < functions[j].node.Pos() }) + parents := goFunctionParents(functions) + assignGoFunctionIDs(functions, parents) + return functions +} + +func collectGoFunctions(file *ast.File, set *token.FileSet) []goFunction { + topLevel := topLevelFunctionNames(file) + var functions []goFunction + ast.Inspect(file, func(node ast.Node) bool { + switch function := node.(type) { + case *ast.FuncDecl: + functions = append(functions, goFunction{node: function, base: functionName(function, set)}) + case *ast.FuncLit: + functions = append(functions, goFunction{node: function, base: topLevel[function]}) + } + return true + }) + return functions +} + +func topLevelFunctionNames(file *ast.File) map[*ast.FuncLit]string { topLevel := make(map[*ast.FuncLit]string) for _, decl := range file.Decls { group, ok := decl.(*ast.GenDecl) @@ -262,54 +308,51 @@ func goFunctions(file *ast.File, set *token.FileSet) []goFunction { 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 + for index, expr := range value.Values { + if literal, ok := expr.(*ast.FuncLit); ok && index < len(value.Names) { + topLevel[literal] = value.Names[index].Name } } } } - 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() }) - parent := make([]int, len(functions)) - for index := range parent { - parent[index] = -1 + return topLevel +} + +func functionName(function *ast.FuncDecl, set *token.FileSet) string { + name := function.Name.Name + if function.Recv == nil || len(function.Recv.List) == 0 { + return name + } + var receiver bytes.Buffer + if err := format.Node(&receiver, set, function.Recv.List[0].Type); err != nil { + return name + } + return "(" + receiver.String() + ")." + name +} + +func goFunctionParents(functions []goFunction) []int { + parents := make([]int, len(functions)) + for index := range parents { + parents[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 + if parents[index] < 0 || other.node.End()-other.node.Pos() < functions[parents[index]].node.End()-functions[parents[index]].node.Pos() { + parents[index] = candidate } } } - closureCounts := make(map[int]int) - duplicates := make(map[string]int) - totals := make(map[string]int) + return parents +} + +func assignGoFunctionIDs(functions []goFunction, parents []int) { + closureCounts, duplicates, totals := map[int]int{}, map[string]int{}, map[string]int{} for _, function := range functions { if function.base != "" { totals[function.base]++ @@ -317,15 +360,10 @@ func goFunctions(file *ast.File, set *token.FileSet) []goFunction { } 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 + functions[index].id = numberedGoName(functions[index].base, totals, duplicates) continue } - owner := parent[index] + owner := parents[index] closureCounts[owner]++ name := "closure-" + strconv.Itoa(closureCounts[owner]) if owner >= 0 { @@ -334,25 +372,22 @@ func goFunctions(file *ast.File, set *token.FileSet) []goFunction { functions[index].id = name } } - return functions } -func runGolangci( - ctx context.Context, - binary, root, module, linter string, - lines, statements int, - cfg 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, - ) +func numberedGoName(name string, totals, duplicates map[string]int) string { + if totals[name] <= 1 { + return name + } + duplicates[name]++ + return name + "#" + strconv.Itoa(duplicates[name]) +} + +func runGolangci(ctx context.Context, binary, root, module, linter string, lines, statements int, cfg model.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) @@ -365,20 +400,12 @@ func runGolangci( 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 { +func runGolangciWithConfig(ctx context.Context, binary, root, module, linter, configuration string, temporary *os.File) (golangciReport, error) { + if _, err := temporary.WriteString(configuration); 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) + return golangciReport{}, fmt.Errorf("write golangci-lint config: %w", errors.Join(err, closeErr)) } - err = temporary.Close() - if err != nil { + if err := temporary.Close(); err != nil { return golangciReport{}, fmt.Errorf("close golangci-lint config: %w", err) } //nolint:gosec // G204: execute the configured golangci-lint binary @@ -388,19 +415,16 @@ func runGolangciWithConfig( 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 + command.Stdout, command.Stderr = &stdout, &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())) + 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 { + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { return golangciReport{}, fmt.Errorf("parse golangci-lint %s output in %q: %w", linter, module, err) } if report.Report.Error != "" { @@ -409,22 +433,24 @@ func runGolangciWithConfig( if runErr != nil && len(report.Issues) == 0 { return golangciReport{}, fmt.Errorf("golangci-lint %s failed without issues: %w", linter, runErr) } - enabled := false + if !hasEnabledLinter(report, linter) { + return golangciReport{}, fmt.Errorf("golangci-lint output omitted enabled %s measurement: %w", linter, errGoLintMissing) + } + return report, nil +} + +func hasEnabledLinter(report golangciReport, name string) bool { for _, entry := range report.Report.Linters { - if entry.Name == linter && entry.Enabled { - enabled = true + if entry.Name == name && entry.Enabled { + return true } } - if !enabled { - return golangciReport{}, fmt.Errorf("golangci-lint output omitted enabled %s measurement: %w", - linter, errGoLintMissing) - } - return report, nil + return false } -func metricValue(text string, metric Metric) (int, error) { +func metricValue(text string, metric model.Metric) (int, error) { pattern := funlenValue - if metric == MetricGoCognitive { + if metric == model.MetricGoCognitive { pattern = gocognitValue } matches := pattern.FindStringSubmatch(text) @@ -438,25 +464,11 @@ func metricValue(text string, metric Metric) (int, error) { return value, nil } -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 - } - } +func findGoFunction(root, module string, files map[string]goSourceFile, reportedPath string, line, column int) (string, string, error) { + path := resolveReportedPath(root, module, reportedPath) source, exists := files[path] if !exists { - return "", "", fmt.Errorf("go diagnostic references unknown source %q: %w", reportedPath, errGoSourceUnknown) + return path, "", 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) @@ -469,6 +481,10 @@ func findGoFunction( if position > token.Pos(tokenFile.Base()+tokenFile.Size()) { return "", "", fmt.Errorf("go diagnostic has invalid position %s:%d:%d: %w", path, line, column, errGoPosition) } + return functionAtPosition(path, source, position, line, column) +} + +func functionAtPosition(path string, source goSourceFile, position token.Pos, line, column int) (string, string, error) { var match *goFunction var smallest token.Pos for index := range source.functions { @@ -476,25 +492,12 @@ func findGoFunction( 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 + match, smallest = function, span } } } if match == nil { - return "", "", fmt.Errorf("go diagnostic at %s:%d:%d is outside any function: %w", - path, line, column, errGoOutsideFunction) + return "", "", fmt.Errorf("go diagnostic at %s:%d:%d is outside any function: %w", path, line, column, errGoOutsideFunction) } return path, match.id, nil } - -func hasAnyFunctionMeasurement(values map[Metric]map[string][]Entry) bool { - for _, paths := range values { - for _, entries := range paths { - if len(entries) > 0 { - return true - } - } - } - return false -} diff --git a/go/internal/codeshape/gometrics_test.go b/go/internal/codeshape/golang/metrics_test.go similarity index 93% rename from go/internal/codeshape/gometrics_test.go rename to go/internal/codeshape/golang/metrics_test.go index e8f19c6..cdb7a15 100644 --- a/go/internal/codeshape/gometrics_test.go +++ b/go/internal/codeshape/golang/metrics_test.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -package codeshape +package golang import ( "fmt" @@ -11,11 +11,14 @@ import ( "path/filepath" "strings" "testing" + + "github.com/RigelBuild/meissa/go/internal/codeshape/model" + "github.com/RigelBuild/meissa/go/internal/codeshape/testsupport" ) func TestGoCollectorFixture(t *testing.T) { - root := newFixtureRepo(t) - trackedFile(t, root, "go.mod", "module example.com/fixture\n\ngo 1.25\n") + root := testsupport.NewFixtureRepo(t) + testsupport.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 { @@ -25,11 +28,11 @@ func TestGoCollectorFixture(t *testing.T) { } source.WriteString("}\n") } - trackedFile(t, root, "fixture.go", source.String()) - commitFiles(t, root) - cfg := testConfig() - cfg.Functions = FunctionLimits{Go: GoFunctionLimits{Cognitive: 20, Lines: 60, Statements: 40}, - TS: TSFunctionLimits{Cognitive: 0, Lines: 0}} + testsupport.TrackedFile(t, root, "fixture.go", source.String()) + testsupport.CommitFiles(t, root) + cfg := testsupport.Config() + cfg.Functions = model.FunctionLimits{Go: model.GoFunctionLimits{Cognitive: 20, Lines: 60, Statements: 40}, + TS: model.TSFunctionLimits{Cognitive: 0, Lines: 0}} values, _, err := GoMetrics(t.Context(), root, cfg) if err != nil { t.Fatal(err) diff --git a/go/internal/codeshape/gosuppressions.go b/go/internal/codeshape/golang/suppressions.go similarity index 100% rename from go/internal/codeshape/gosuppressions.go rename to go/internal/codeshape/golang/suppressions.go diff --git a/go/internal/codeshape/inventory.go b/go/internal/codeshape/inventory.go deleted file mode 100644 index 476bd61..0000000 --- a/go/internal/codeshape/inventory.go +++ /dev/null @@ -1,187 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package codeshape - -import ( - "bufio" - "bytes" - "context" - "errors" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "slices" - "strings" -) - -// Inventory measures tracked source files and direct tracked files per directory. -func Inventory(ctx context.Context, root string, cfg Config) (Snapshot, error) { - command := exec.CommandContext(ctx, "git", "ls-files", "-z") - command.Dir = root - output, err := command.Output() - if err != nil { - return Snapshot{}, fmt.Errorf("git ls-files: %w", err) - } - repository, err := os.OpenRoot(root) - if err != nil { - return Snapshot{}, fmt.Errorf("open repository root: %w", err) - } - snapshot, inventoryErr := inventoryPaths(repository, output, cfg) - closeErr := repository.Close() - if closeErr != nil { - closeErr = fmt.Errorf("close repository root: %w", closeErr) - } - if inventoryErr != nil || closeErr != nil { - return Snapshot{}, errors.Join(inventoryErr, closeErr) - } - return snapshot, nil -} - -func inventoryPaths(repository *os.Root, output []byte, cfg Config) (Snapshot, error) { - files := map[string]int{} - dirs := map[string]int{} - for rawPath := range bytes.SplitSeq(output, []byte{0}) { - if len(rawPath) == 0 { - continue - } - trackedPath := filepath.ToSlash(string(rawPath)) - if matchesAny(trackedPath, cfg.Exclude) { - continue - } - parent := filepath.ToSlash(filepath.Dir(filepath.FromSlash(trackedPath))) - if parent == "." { - parent = "" - } - dirs[parent]++ - if !countedPath(trackedPath, cfg) { - continue - } - file, err := repository.Open(filepath.FromSlash(trackedPath)) - if err != nil { - return Snapshot{}, fmt.Errorf("open tracked file %q: %w", trackedPath, err) - } - lines, generated, countErr := physicalLines(file) - closeErr := file.Close() - if countErr != nil { - countErr = fmt.Errorf("count lines in %q: %w", trackedPath, countErr) - } - if closeErr != nil { - closeErr = fmt.Errorf("close tracked file %q: %w", trackedPath, closeErr) - } - if countErr != nil || closeErr != nil { - return Snapshot{}, errors.Join(countErr, closeErr) - } - if !generated { - files[trackedPath] = lines - } - } - return Snapshot{Files: files, Dirs: dirs, Functions: nil}, nil -} - -func countedPath(path string, cfg Config) bool { - for segment := range strings.SplitSeq(path, "/") { - if segment == "testdata" || segment == "fixtures" { - return false - } - } - extension := filepath.Ext(path) - if !slices.Contains(cfg.Extensions, extension) { - return false - } - return !matchesAny(path, cfg.Exclude) -} - -func matchesAny(path string, patterns []string) bool { - for _, pattern := range patterns { - if matchGlob(pattern, path) { - return true - } - } - return false -} - -func matchGlob(pattern, path string) bool { - patternParts := strings.Split(strings.Trim(pattern, "/"), "/") - pathParts := strings.Split(strings.Trim(path, "/"), "/") - return matchGlobParts(patternParts, pathParts) -} - -func matchGlobParts(pattern, path []string) bool { - if len(pattern) == 0 { - return len(path) == 0 - } - if pattern[0] == "**" { - if matchGlobParts(pattern[1:], path) { - return true - } - return len(path) > 0 && matchGlobParts(pattern, path[1:]) - } - if len(path) == 0 { - return false - } - matched, err := filepath.Match(pattern[0], path[0]) - if err != nil || !matched { - return false - } - return matchGlobParts(pattern[1:], path[1:]) -} - -func physicalLines(source io.Reader) (int, bool, error) { - const bufferSize = 4096 - const markerOverlap = len("Code generated") - 1 - reader := bufio.NewReaderSize(source, bufferSize) - var window [bufferSize + markerOverlap]byte - var carry [markerOverlap]byte - var carryLen int - var hasGeneratedMarker, hasCodeGenerated, hasDoNotEdit bool - lineHasBytes := false - count := 0 - generated := false - observe := func(chunk []byte) { - if count >= 5 { - return - } - combined := copy(window[:], carry[:carryLen]) - combined += copy(window[combined:], chunk) - line := window[:combined] - hasGeneratedMarker = hasGeneratedMarker || bytes.Contains(line, []byte("@generated")) - hasCodeGenerated = hasCodeGenerated || bytes.Contains(line, []byte("Code generated")) - hasDoNotEdit = hasDoNotEdit || bytes.Contains(line, []byte("DO NOT EDIT.")) - carryLen = min(len(line), len(carry)) - copy(carry[:], line[len(line)-carryLen:]) - } - 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 - } - if errors.Is(readErr, io.EOF) { - break - } - if readErr != nil && !errors.Is(readErr, bufio.ErrBufferFull) { - return 0, false, fmt.Errorf("scan source lines: %w", readErr) - } - } - return count, generated, nil -} diff --git a/go/internal/codeshape/inventory/inventory.go b/go/internal/codeshape/inventory/inventory.go new file mode 100644 index 0000000..9f2cedf --- /dev/null +++ b/go/internal/codeshape/inventory/inventory.go @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package inventory + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/RigelBuild/meissa/go/internal/codeshape/model" +) + +// Inventory measures tracked source files and direct tracked files per directory. +func Inventory(paths []string, cfg model.Config, repository *os.Root) (model.Snapshot, error) { + files := map[string]int{} + dirs := map[string]int{} + for _, path := range paths { + if err := inventoryPath(path, cfg, repository, files, dirs); err != nil { + return model.Snapshot{}, err + } + } + return model.Snapshot{Files: files, Dirs: dirs}, nil +} + +func inventoryPath(path string, cfg model.Config, repository *os.Root, files, dirs map[string]int) error { + trackedPath := filepath.ToSlash(path) + if MatchesAny(trackedPath, cfg.Exclude) { + return nil + } + parent := filepath.ToSlash(filepath.Dir(filepath.FromSlash(trackedPath))) + if parent == "." { + parent = "" + } + dirs[parent]++ + if !CountedPath(trackedPath, cfg) { + return nil + } + file, err := repository.Open(filepath.FromSlash(trackedPath)) + if err != nil { + return fmt.Errorf("open tracked file %q: %w", trackedPath, err) + } + lines, generated, countErr := physicalLines(file) + closeErr := file.Close() + if countErr != nil { + countErr = fmt.Errorf("count lines in %q: %w", trackedPath, countErr) + } + if closeErr != nil { + closeErr = fmt.Errorf("close tracked file %q: %w", trackedPath, closeErr) + } + if countErr != nil || closeErr != nil { + return errors.Join(countErr, closeErr) + } + if !generated { + files[trackedPath] = lines + } + return nil +} + +// CountedPath reports whether a path participates in configured source measurements. +func CountedPath(path string, cfg model.Config) bool { + for segment := range strings.SplitSeq(path, "/") { + if segment == "testdata" || segment == "fixtures" { + return false + } + } + return slices.Contains(cfg.Extensions, filepath.Ext(path)) && !MatchesAny(path, cfg.Exclude) +} + +// MatchesAny reports whether a repository path matches one configured glob. +func MatchesAny(path string, patterns []string) bool { + for _, pattern := range patterns { + if MatchGlob(pattern, path) { + return true + } + } + return false +} + +// MatchGlob matches a slash-separated repository path against a glob. +func MatchGlob(pattern, path string) bool { + return matchGlobParts(strings.Split(strings.Trim(pattern, "/"), "/"), strings.Split(strings.Trim(path, "/"), "/")) +} + +func matchGlobParts(pattern, path []string) bool { + if len(pattern) == 0 { + return len(path) == 0 + } + if pattern[0] == "**" { + return matchGlobParts(pattern[1:], path) || len(path) > 0 && matchGlobParts(pattern, path[1:]) + } + if len(path) == 0 { + return false + } + matched, err := filepath.Match(pattern[0], path[0]) + return err == nil && matched && matchGlobParts(pattern[1:], path[1:]) +} + +func physicalLines(source io.Reader) (int, bool, error) { + const bufferSize = 4096 + const markerOverlap = len("Code generated") - 1 + reader := bufio.NewReaderSize(source, bufferSize) + var window [bufferSize + markerOverlap]byte + var carry [markerOverlap]byte + var carryLen int + var hasGeneratedMarker, hasCodeGenerated, hasDoNotEdit bool + lineHasBytes, generated := false, false + count := 0 + observe := func(chunk []byte) { + if count >= 5 { + return + } + combined := copy(window[:], carry[:carryLen]) + combined += copy(window[combined:], chunk) + line := window[:combined] + hasGeneratedMarker = hasGeneratedMarker || bytes.Contains(line, []byte("@generated")) + hasCodeGenerated = hasCodeGenerated || bytes.Contains(line, []byte("Code generated")) + hasDoNotEdit = hasDoNotEdit || bytes.Contains(line, []byte("DO NOT EDIT.")) + carryLen = min(len(line), len(carry)) + copy(carry[:], line[len(line)-carryLen:]) + } + 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, lineHasBytes = 0, false + hasGeneratedMarker, hasCodeGenerated, hasDoNotEdit = false, false, false + } + if errors.Is(readErr, io.EOF) { + break + } + if readErr != nil && !errors.Is(readErr, bufio.ErrBufferFull) { + return 0, false, fmt.Errorf("scan source lines: %w", readErr) + } + } + return count, generated, nil +} diff --git a/go/internal/codeshape/inventory/inventory_test.go b/go/internal/codeshape/inventory/inventory_test.go new file mode 100644 index 0000000..69cb440 --- /dev/null +++ b/go/internal/codeshape/inventory/inventory_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package inventory_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/RigelBuild/meissa/go/internal/codeshape/inventory" + "github.com/RigelBuild/meissa/go/internal/codeshape/judge" + "github.com/RigelBuild/meissa/go/internal/codeshape/model" + "github.com/RigelBuild/meissa/go/internal/codeshape/testsupport" +) + +func TestInventory(t *testing.T) { + root := testsupport.NewFixtureRepo(t) + testsupport.TrackedFile(t, root, "large.go", strings.Repeat("line\n", 501)) + testsupport.TrackedFile(t, root, "generated.go", "// Code generated by tool. DO NOT EDIT.\nsource\n") + testsupport.CommitFiles(t, root) + paths, err := inventory.TrackedPaths(t.Context(), root) + if err != nil { + t.Fatal(err) + } + repository, err := os.OpenRoot(root) + if err != nil { + t.Fatal(err) + } + defer repository.Close() + snapshot, err := inventory.Inventory(paths, testsupport.Config(), repository) + if err != nil { + t.Fatal(err) + } + if snapshot.Files["large.go"] != 501 { + t.Fatalf("files = %#v", snapshot.Files) + } + if _, exists := snapshot.Files["generated.go"]; exists { + t.Fatalf("generated source counted: %#v", snapshot.Files) + } + findings := judge.Judge(testsupport.Config(), model.Allowlist{}, model.Allowlist{}, snapshot) + if len(findings) != 1 || findings[0].Metric != model.MetricFiles { + t.Fatalf("findings = %#v", findings) + } +} + +func TestInventoryRejectsSymlinkEscape(t *testing.T) { + root := testsupport.NewFixtureRepo(t) + outside := filepath.Join(t.TempDir(), "outside.go") + if err := os.WriteFile(outside, []byte(strings.Repeat("line\n", 600)), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(root, "escape.go")); err != nil { + t.Fatal(err) + } + paths := []string{"escape.go"} + repository, err := os.OpenRoot(root) + if err != nil { + t.Fatal(err) + } + defer repository.Close() + if _, err := inventory.Inventory(paths, testsupport.Config(), repository); err == nil { + t.Fatal("inventory followed a source symlink outside repository") + } +} diff --git a/go/internal/codeshape/inventory/paths.go b/go/internal/codeshape/inventory/paths.go new file mode 100644 index 0000000..a599435 --- /dev/null +++ b/go/internal/codeshape/inventory/paths.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package inventory + +import ( + "context" + "fmt" + "os/exec" + "strings" +) + +// TrackedPaths returns tracked paths as repository-relative slash-separated names. +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 deleted file mode 100644 index 8d2b4c4..0000000 --- a/go/internal/codeshape/inventory_test.go +++ /dev/null @@ -1,253 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package codeshape - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "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 TestInventory(t *testing.T) { - t.Run("file one line over limit is measured", func(t *testing.T) { - root := newFixtureRepo(t) - trackedFile(t, root, "large.go", strings.Repeat("line\n", 501)) - commitFiles(t, root) - snapshot, err := Inventory(context.Background(), root, testConfig()) - if err != nil { - t.Fatal(err) - } - if snapshot.Files["large.go"] != 501 { - t.Fatalf("file lines = %d, want 501", snapshot.Files["large.go"]) - } - 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) - } - }) - t.Run("testdata files skip file length", func(t *testing.T) { - root := newFixtureRepo(t) - trackedFile(t, root, "pkg/testdata/large.go", strings.Repeat("line\n", 501)) - commitFiles(t, root) - snapshot, err := Inventory(context.Background(), root, testConfig()) - if err != nil { - t.Fatal(err) - } - if _, exists := snapshot.Files["pkg/testdata/large.go"]; exists { - t.Fatal("testdata file counted toward file length") - } - if snapshot.Dirs["pkg/testdata"] != 1 { - t.Fatalf("testdata direct files = %d, want 1", snapshot.Dirs["pkg/testdata"]) - } - }) - t.Run("fixtures files skip file length", func(t *testing.T) { - root := newFixtureRepo(t) - trackedFile(t, root, "pkg/fixtures/large.go", strings.Repeat("line\n", 501)) - commitFiles(t, root) - snapshot, err := Inventory(context.Background(), root, testConfig()) - if err != nil { - t.Fatal(err) - } - if _, exists := snapshot.Files["pkg/fixtures/large.go"]; exists { - t.Fatal("fixtures file counted toward file length") - } - }) -} - -func TestInventoryGeneratedAndExcluded(t *testing.T) { - t.Run("generated header in first five lines skips file length", func(t *testing.T) { - root := newFixtureRepo(t) - trackedFile(t, root, "generated.go", "// Code generated by tool. DO NOT EDIT.\n"+strings.Repeat("line\n", 501)) - commitFiles(t, root) - snapshot, err := Inventory(context.Background(), root, testConfig()) - if err != nil { - t.Fatal(err) - } - if _, exists := snapshot.Files["generated.go"]; exists { - t.Fatal("generated file counted toward file length") - } - }) - t.Run("at generated marker skips file length", func(t *testing.T) { - lines, generated, err := physicalLines(strings.NewReader("one\ntwo\nthree\nfour\n// @generated\nsix\n")) - if err != nil || !generated || lines != 6 { - t.Fatalf("lines=%d generated=%v error=%v", lines, generated, err) - } - }) - t.Run("generated marker after fifth line is counted", func(t *testing.T) { - _, generated, err := physicalLines(strings.NewReader("one\ntwo\nthree\nfour\nfive\n// @generated\n")) - if err != nil || generated { - t.Fatalf("generated=%v error=%v", generated, err) - } - }) - t.Run("exclude glob skips file length and directory width", func(t *testing.T) { - root := newFixtureRepo(t) - trackedFile(t, root, "pkg/large.go", strings.Repeat("line\n", 501)) - commitFiles(t, root) - config := testConfig() - config.Exclude = []string{"pkg/**"} - snapshot, err := Inventory(context.Background(), root, config) - if err != nil { - t.Fatal(err) - } - if _, exists := snapshot.Files["pkg/large.go"]; exists || snapshot.Dirs["pkg"] != 0 { - t.Fatalf("exclude applied incorrectly: %#v", snapshot) - } - }) -} - -func TestPhysicalLinesLongLine(t *testing.T) { - content := strings.Repeat("x", 65_530) + "@generated\nsecond\n" - lines, generated, err := physicalLines(strings.NewReader(content)) - if err != nil { - t.Fatal(err) - } - if lines != 2 || !generated { - t.Fatalf("lines=%d generated=%v, want 2 lines and generated", lines, generated) - } - lines, generated, err = physicalLines(strings.NewReader(strings.Repeat("x", 70_000))) - if err != nil || lines != 1 || generated { - t.Fatalf("unterminated line: lines=%d generated=%v error=%v", lines, generated, err) - } -} -func TestInventoryDirectoryWidth(t *testing.T) { - t.Run("directory width counts all tracked file types at eleven", func(t *testing.T) { - root := newFixtureRepo(t) - for index := range 11 { - trackedFile(t, root, fmt.Sprintf("wide/file-%02d.txt", index), "text\n") - } - commitFiles(t, root) - snapshot, err := Inventory(context.Background(), root, testConfig()) - if err != nil { - t.Fatal(err) - } - if snapshot.Dirs["wide"] != 11 { - t.Fatalf("directory files = %d, want 11", snapshot.Dirs["wide"]) - } - 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) - } - }) - t.Run("directory width ten passes", func(t *testing.T) { - root := newFixtureRepo(t) - for index := range 10 { - trackedFile(t, root, fmt.Sprintf("wide/file-%02d.txt", index), "text\n") - } - commitFiles(t, root) - snapshot, err := Inventory(context.Background(), root, testConfig()) - if err != nil { - t.Fatal(err) - } - 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}}, - } -} diff --git a/go/internal/codeshape/judge.go b/go/internal/codeshape/judge.go deleted file mode 100644 index 17ed349..0000000 --- a/go/internal/codeshape/judge.go +++ /dev/null @@ -1,242 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package codeshape - -import ( - "sort" -) - -// Judge compares current measurements with thresholds and allowlists. -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, 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, - Metric: MetricFiles, - Value: 0, - Limit: cfg.FileLimit, - Fix: "remove the stale allowlist entry", - }) - } - } - 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), - Fix: "remove the stale allowlist entry", - }) - } - } - 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) - findings := make([]Finding, 0) - for _, path := range paths { - value := values[path] - limit := cfg.limitFor(metric, path) - entry, listed := entries[path] - if !listed { - if value > limit { - findings = append(findings, Finding{ - Path: displayPath(metric, path), - Metric: metric, - Value: value, - Limit: limit, - Fix: fixForPath(cfg, metric, path), - }) - } - continue - } - if value <= limit { - findings = append(findings, Finding{ - Path: displayPath(metric, path), - Metric: metric, - Value: value, - Limit: limit, - Fix: "remove the allowlist entry after splitting or shrinking", - }) - continue - } - if value > entry.Value { - findings = append(findings, Finding{ - Path: displayPath(metric, path), - Metric: metric, - Value: value, - Limit: entry.Value, - Fix: "grew: split the offender instead of growing its recorded value", - }) - } - } - return findings -} - -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", - }) - } - } - } - } - return findings -} - -func judgeBaseEntries(allow, base Allowlist) []Finding { - if !base.Present { - return nil - } - findings := []Finding{} - for _, group := range []struct { - metric Metric - head, base map[string]Entry - }{{MetricFiles, allow.Files, base.Files}, {MetricDirs, allow.Dirs, base.Dirs}} { - for path, entry := range group.head { - 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", - }) - } - } - } - 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 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 -} - -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) -} - -func sortedEntryPaths[V any](entries map[string]V) []string { - paths := make([]string, 0, len(entries)) - for path := range entries { - paths = append(paths, path) - } - sort.Strings(paths) - return paths -} - -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 - } - return findings[i].Metric < findings[j].Metric - }) -} - -func displayPath(metric Metric, path string) string { - if metric == MetricDirs { - return displayDir(path) - } - return path -} - -func displayDir(path string) string { - if path == "" { - return "." - } - return path -} - -func fixFor(metric Metric) string { - if metric == MetricDirs { - return "split the directory by concern" - } - return "split this file by concern" -} diff --git a/go/internal/codeshape/judge/judge.go b/go/internal/codeshape/judge/judge.go new file mode 100644 index 0000000..57b9c92 --- /dev/null +++ b/go/internal/codeshape/judge/judge.go @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package judge + +import ( + "sort" + + "github.com/RigelBuild/meissa/go/internal/codeshape/inventory" + "github.com/RigelBuild/meissa/go/internal/codeshape/model" +) + +// Judge compares current measurements with thresholds and allowlists. +func Judge(cfg model.Config, allow, baseAllow model.Allowlist, snap model.Snapshot) []model.Finding { + findings := append(judgeMetric(cfg, snap.Files, allow.Files, model.MetricFiles), + judgeMetric(cfg, snap.Dirs, allow.Dirs, model.MetricDirs)...) + findings = append(findings, judgeFunctions(cfg, snap.Functions, allow.Functions)...) + findings = append(findings, judgeBaseEntries(allow, baseAllow)...) + findings = append(findings, staleEntries(cfg, allow, snap)...) + sortFindings(findings) + return findings +} + +func staleEntries(cfg model.Config, allow model.Allowlist, snap model.Snapshot) []model.Finding { + var findings []model.Finding + for path := range allow.Files { + if _, exists := snap.Files[path]; !exists { + findings = append(findings, model.Finding{Path: path, Metric: model.MetricFiles, + Limit: cfg.FileLimit, Fix: "remove the stale allowlist entry"}) + } + } + for path := range allow.Dirs { + if _, exists := snap.Dirs[path]; !exists { + findings = append(findings, model.Finding{Path: displayDir(path), Metric: model.MetricDirs, + Limit: cfgLimit(cfg, model.MetricDirs, path), Fix: "remove the stale allowlist entry"}) + } + } + for metric, paths := range allow.Functions { + for path, entries := range paths { + for _, entry := range entries { + if _, exists := model.FunctionEntry(snap.Functions[metric][path], entry.ID); !exists { + findings = append(findings, model.Finding{Path: path + "#" + entry.ID, Metric: metric, + Limit: cfg.Functions.LimitFor(metric), Fix: "remove the stale allowlist entry"}) + } + } + } + } + return findings +} + +func judgeMetric(cfg model.Config, values map[string]int, entries map[string]model.Entry, metric model.Metric) []model.Finding { + paths := sortedPaths(values) + var findings []model.Finding + for _, path := range paths { + value, limit := values[path], cfgLimit(cfg, metric, path) + entry, listed := entries[path] + if !listed && value > limit { + findings = append(findings, model.Finding{Path: displayPath(metric, path), Metric: metric, + Value: value, Limit: limit, Fix: fixForPath(cfg, metric, path)}) + continue + } + if !listed { + continue + } + if value <= limit { + findings = append(findings, model.Finding{Path: displayPath(metric, path), Metric: metric, + Value: value, Limit: limit, Fix: "remove the allowlist entry after splitting or shrinking"}) + } else if value > entry.Value { + findings = append(findings, model.Finding{Path: displayPath(metric, path), Metric: metric, + Value: value, Limit: entry.Value, Fix: "grew: split the offender instead of growing its recorded value"}) + } + } + return findings +} + +func judgeFunctions(cfg model.Config, values, entries model.FunctionMeasurements) []model.Finding { + var findings []model.Finding + for _, metric := range model.FunctionMetrics() { + for _, path := range sortedPaths(values[metric]) { + for _, current := range values[metric][path] { + previous, listed := model.FunctionEntry(entries[metric][path], current.ID) + findings = append(findings, judgeFunction(cfg, metric, path, current, previous, listed)...) + } + } + } + return findings +} + +func judgeFunction(cfg model.Config, metric model.Metric, path string, current, previous model.Entry, listed bool) []model.Finding { + limit, findingPath := cfg.Functions.LimitFor(metric), path+"#"+current.ID + if !listed && current.Value > limit { + return []model.Finding{{Path: findingPath, Metric: metric, Value: current.Value, Limit: limit, + Fix: "split this function by concern"}} + } + if !listed { + return nil + } + if current.Value <= limit { + return []model.Finding{{Path: findingPath, Metric: metric, Value: current.Value, Limit: limit, + Fix: "remove the allowlist entry after splitting or shrinking"}} + } + if current.Value > previous.Value { + return []model.Finding{{Path: findingPath, Metric: metric, Value: current.Value, Limit: previous.Value, + Fix: "grew: split the offender instead of growing its recorded value"}} + } + return nil +} + +func judgeBaseEntries(allow, base model.Allowlist) []model.Finding { + if !base.Present { + return nil + } + var findings []model.Finding + for _, group := range []struct { + metric model.Metric + head map[string]model.Entry + base map[string]model.Entry + }{{model.MetricFiles, allow.Files, base.Files}, {model.MetricDirs, allow.Dirs, base.Dirs}} { + for path, entry := range group.head { + previous, exists := group.base[path] + if !exists || entry.Value > previous.Value { + findings = append(findings, model.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"}) + } + } + } + for metric, paths := range allow.Functions { + for path, entries := range paths { + for _, entry := range entries { + previous, exists := model.FunctionEntry(base.Functions[metric][path], entry.ID) + if !exists || entry.Value > previous.Value { + findings = append(findings, model.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 cfgLimit(cfg model.Config, metric model.Metric, path string) int { + if metric != model.MetricDirs { + return cfg.FileLimit + } + limit := 0 + for pattern, entry := range cfg.DirLimits { + if inventory.MatchGlob(pattern, path) && (limit == 0 || entry.Limit < limit) { + limit = entry.Limit + } + } + if limit == 0 { + return cfg.DirLimit + } + return limit +} + +func fixForPath(cfg model.Config, metric model.Metric, path string) string { + if metric == model.MetricDirs && cfgLimit(cfg, metric, path) != cfg.DirLimit { + return "reorganize the record's decisions" + } + if metric == model.MetricDirs { + return "split the directory by concern" + } + return "split this file by concern" +} + +func displayPath(metric model.Metric, path string) string { + if metric == model.MetricDirs { + return displayDir(path) + } + return path +} + +func displayDir(path string) string { + if path == "" { + return "." + } + return path +} + +func sortedPaths[V any](entries map[string]V) []string { + paths := make([]string, 0, len(entries)) + for path := range entries { + paths = append(paths, path) + } + sort.Strings(paths) + return paths +} + +func sortFindings(findings []model.Finding) { + sort.Slice(findings, func(i, j int) bool { + if findings[i].Path != findings[j].Path { + return findings[i].Path < findings[j].Path + } + return findings[i].Metric < findings[j].Metric + }) +} diff --git a/go/internal/codeshape/judge/judge_test.go b/go/internal/codeshape/judge/judge_test.go new file mode 100644 index 0000000..9504bd4 --- /dev/null +++ b/go/internal/codeshape/judge/judge_test.go @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package judge_test + +import ( + "testing" + + "github.com/RigelBuild/meissa/go/internal/codeshape/judge" + "github.com/RigelBuild/meissa/go/internal/codeshape/model" + "github.com/RigelBuild/meissa/go/internal/codeshape/testsupport" +) + +func TestJudgeReportsFileAndFunctionFindings(t *testing.T) { + cfg := testsupport.Config() + allow := model.Allowlist{Files: map[string]model.Entry{}, Dirs: map[string]model.Entry{}} + snapshot := model.Snapshot{ + Files: map[string]int{"large.go": 501}, Dirs: map[string]int{}, + Functions: model.FunctionMeasurements{model.MetricGoCognitive: {"large.go": {{ID: "work", Value: 21}}}}, + } + findings := judge.Judge(cfg, allow, model.Allowlist{}, snapshot) + if len(findings) != 2 || findings[0].Metric != model.MetricFiles || findings[1].Metric != model.MetricGoCognitive { + t.Fatalf("findings = %#v", findings) + } +} + +func TestJudgeEnforcesBaseFunctionRatchets(t *testing.T) { + cfg := testsupport.Config() + base := model.Allowlist{Present: true, Functions: model.FunctionMeasurements{ + model.MetricGoLines: {"large.go": {{ID: "work", Value: 70}}}, + }} + head := model.Allowlist{Functions: model.FunctionMeasurements{ + model.MetricGoLines: {"large.go": {{ID: "work", Value: 71}}}, + }} + snapshot := model.Snapshot{Files: map[string]int{}, Dirs: map[string]int{}, Functions: head.Functions} + findings := judge.Judge(cfg, head, base, snapshot) + if len(findings) != 1 || findings[0].Limit != 70 { + t.Fatalf("findings = %#v", findings) + } +} diff --git a/go/internal/codeshape/judge_test.go b/go/internal/codeshape/judge_test.go deleted file mode 100644 index 7786299..0000000 --- a/go/internal/codeshape/judge_test.go +++ /dev/null @@ -1,205 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package codeshape - -import ( - "strings" - "testing" -) - -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": {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{}, - Functions: nil, - } - 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": {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{}, - Functions: nil, - } - 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": {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{}, - Functions: nil, - }) - if !hasFinding(findings, "gone.go", MetricFiles, 0, 500, "remove") { - t.Fatalf("missing stale entry finding: %#v", findings) - } - }) -} - -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": {ID: "", Value: 501, Reason: "split by concern"}}, - Dirs: map[string]Entry{}, Functions: nil, Present: false, - } - base := emptyAllowlist() - base.Present = true - findings := Judge(config, allow, base, Snapshot{ - 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) - } - }) - t.Run("base rejects raised entry", func(t *testing.T) { - allow := Allowlist{ - 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": {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{}, - Functions: nil, - }) - if !hasFinding(findings, "large.go", MetricFiles, 502, 501, "do not add") { - t.Fatalf("missing raised-entry finding: %#v", findings) - } - }) - t.Run("base absence is head only", func(t *testing.T) { - allow := Allowlist{ - 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{}, - Functions: nil, - } - 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": {ID: "", Value: 501, Reason: "split by concern"}}, - Dirs: map[string]Entry{}, Functions: nil, Present: false, - } - base := emptyAllowlist() - base.Present = true - findings := Judge(config, allow, base, Snapshot{ - 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) - } - }) -} - -func hasFinding(findings []Finding, path string, metric Metric, value, limit int, fragment string) bool { - for _, finding := range findings { - if finding.Path == path && - finding.Metric == metric && - finding.Value == value && - finding.Limit == limit && - strings.Contains(finding.Fix, fragment) { - return true - } - } - 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) - } -} - -func TestRewriteFunctionEntries(t *testing.T) { - cfg := testConfig() - cfg.Functions.Go.Lines = 60 - allow := Allowlist{Files: nil, Dirs: nil, Present: false, Functions: FunctionMeasurements{MetricGoLines: {"f.go": { - {ID: "lower", Value: 80, Reason: "split by concern"}, - {ID: "remove", Value: 70, Reason: "split by concern"}, - }}}} - values := FunctionMeasurements{MetricGoLines: {"f.go": { - {ID: "lower", Value: 70, Reason: ""}, {ID: "new", Value: 61, Reason: ""}, - }}} - _, _, err := rewriteAllowlist(cfg, allow, Snapshot{Files: nil, Dirs: nil, Functions: values}, false) - if err == nil { - t.Fatal("write accepted a new offender") - } - updated, changed, err := rewriteAllowlist(cfg, allow, Snapshot{Files: nil, Dirs: nil, Functions: values}, true) - if err != nil || !changed { - t.Fatalf("seed: changed=%v err=%v", changed, err) - } - got := updated.Functions[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/load.go b/go/internal/codeshape/load.go deleted file mode 100644 index 6891f43..0000000 --- a/go/internal/codeshape/load.go +++ /dev/null @@ -1,254 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -// Package codeshape measures tracked source and directory sizes. -package codeshape - -import ( - "embed" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - - "cuelang.org/go/cue" - "cuelang.org/go/cue/ast" - "cuelang.org/go/cue/cuecontext" - "cuelang.org/go/cue/parser" -) - -var ( - errInvalidThresholds = errors.New("config thresholds must be fileLimit 500 and dirLimit 10") - 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 - -// 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. -// 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"` - Functions map[Metric]map[string][]Entry `json:"functions,omitempty"` - Present bool `json:"-"` -} - -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 -} - -func schemaValue(ctx *cue.Context) (cue.Value, error) { - data, err := schemaFiles.ReadFile("schema.cue") - if err != nil { - return cue.Value{}, fmt.Errorf("read embedded schema: %w", err) - } - value := ctx.CompileBytes(data) - err = value.Err() - if err != nil { - return cue.Value{}, fmt.Errorf("compile embedded schema: %w", err) - } - return value, nil -} - -// Load reads and validates the repository's CUE configuration and allowlist. -func Load(root string) (Config, Allowlist, error) { - repository, err := os.OpenRoot(root) - if err != nil { - return Config{}, Allowlist{}, fmt.Errorf("open repository root: %w", err) - } - config, allowlist, loadErr := loadFromRoot(repository) - closeErr := repository.Close() - if closeErr != nil { - closeErr = fmt.Errorf("close repository root: %w", closeErr) - } - return config, allowlist, errors.Join(loadErr, closeErr) -} - -func loadFromRoot(repository *os.Root) (Config, Allowlist, error) { - ctx := cuecontext.New() - schema, err := schemaValue(ctx) - if err != nil { - return Config{}, Allowlist{}, err - } - - configValue, err := loadCUEFile(ctx, repository, "tools/code-shape-gate/config.cue") - if err != nil { - return Config{}, 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) - } - var config Config - err = configValue.Decode(&config) - if err != nil { - return Config{}, Allowlist{}, fmt.Errorf("decode config.cue: %w", err) - } - err = validateConfig(config) - if err != nil { - return Config{}, Allowlist{}, err - } - - allowValue, err := loadCUEFile(ctx, repository, "tools/code-shape-gate/allowlist.cue") - if err != nil { - return Config{}, Allowlist{}, err - } - allowlist, err := decodeAllowlist(schema, allowValue, "allowlist.cue") - if err != nil { - return Config{}, Allowlist{}, err - } - allowlist.Present = true - return config, allowlist, nil -} - -func loadCUEFile(ctx *cue.Context, repository *os.Root, name string) (cue.Value, error) { - data, err := repository.ReadFile(name) - if err != nil { - return cue.Value{}, fmt.Errorf("read %s: %w", name, err) - } - return compileCUEFile(ctx, data, name) -} - -func compileCUEFile(ctx *cue.Context, data []byte, name string) (cue.Value, error) { - file, err := parser.ParseFile(name, data) - if err != nil { - return cue.Value{}, fmt.Errorf("parse %s: %w", name, err) - } - for _, declaration := range file.Decls { - if _, ok := declaration.(*ast.ImportDecl); ok { - return cue.Value{}, fmt.Errorf("%s: %w", name, errImportsForbidden) - } - } - value := ctx.BuildFile(file) - err = value.Err() - if err != nil { - return cue.Value{}, fmt.Errorf("evaluate %s: %w", name, err) - } - return value, nil -} - -func decodeAllowlistBytes(ctx *cue.Context, schema cue.Value, data []byte, name string) (Allowlist, error) { - value, err := compileCUEFile(ctx, data, name) - if err != nil { - return Allowlist{}, err - } - return decodeAllowlist(schema, value, name) -} - -func decodeAllowlist(schema, value cue.Value, name string) (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) - } - var allowlist Allowlist - err = value.Decode(&allowlist) - if err != nil { - return Allowlist{}, fmt.Errorf("decode %s: %w", name, err) - } - if allowlist.Files == nil { - allowlist.Files = map[string]Entry{} - } - if allowlist.Dirs == nil { - allowlist.Dirs = map[string]Entry{} - } - if allowlist.Functions == nil { - allowlist.Functions = map[Metric]map[string][]Entry{} - } - for metric, paths := range allowlist.Functions { - if !isFunctionMetric(metric) { - return Allowlist{}, fmt.Errorf("%s: %w %q", name, errUnsupportedMetric, metric) - } - err := validateFunctionEntryIDs(paths) - if err != nil { - return 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) - } - allowlist.Dirs[""] = entry - delete(allowlist.Dirs, ".") - } - return allowlist, nil -} - -func validateConfig(config 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) - } - } - for _, pattern := range config.Exclude { - for segment := range strings.SplitSeq(pattern, "/") { - if segment == "**" { - continue - } - _, err := filepath.Match(segment, "") - if err != nil { - return fmt.Errorf("invalid exclude glob %q: %w", pattern, err) - } - } - } - return nil -} diff --git a/go/internal/codeshape/load_test.go b/go/internal/codeshape/load_test.go deleted file mode 100644 index f9c8b15..0000000 --- a/go/internal/codeshape/load_test.go +++ /dev/null @@ -1,242 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package codeshape - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestLoad(t *testing.T) { - t.Run("valid CUE configuration and allowlist", func(t *testing.T) { - root := newFixtureRepo(t) - writeConfigFiles(t, root, validConfig, "files: {}\ndirs: {}\n") - _, _, err := Load(root) - if err != nil { - t.Fatal(err) - } - }) - t.Run("invalid config fails", func(t *testing.T) { - root := newFixtureRepo(t) - writeConfigFiles(t, root, "fileLimit: 500\ndirLimit: 10\nextensions: [\".go\"]\nexclude: [\"[\"]\n", "files: {}\n") - _, _, err := Load(root) - if err == nil { - t.Fatal("Load accepted an invalid config") - } - }) - t.Run("missing reason fails", func(t *testing.T) { - root := newFixtureRepo(t) - writeConfigFiles(t, root, validConfig, "files: \"large.go\": {value: 501}\n") - _, _, err := Load(root) - if err == nil { - t.Fatal("Load accepted an entry without a reason") - } - }) - t.Run("short reason fails", func(t *testing.T) { - root := newFixtureRepo(t) - writeConfigFiles(t, root, validConfig, "files: \"large.go\": {value: 501, reason: \"split files\"}\n") - _, _, err := Load(root) - if err == nil { - t.Fatal("Load accepted a reason with fewer than three words") - } - }) -} - -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[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"}`, - `dirLimits: "wide": {limit: 0, reason: "record decisions together"}`, - `dirLimits: "wide": {limit: 50, reason: "two words"}`, - } { - t.Run(config, func(t *testing.T) { - root := newFixtureRepo(t) - writeConfigFiles(t, root, validConfig+config+"\n", "files: {}\n") - _, _, err := Load(root) - if err == nil { - t.Fatalf("Load accepted invalid config %s", config) - } - }) - } -} - -func TestLoadRejectsImports(t *testing.T) { - root := newFixtureRepo(t) - writeConfigFiles(t, root, "import \"strings\"\n"+validConfig+"\nx: strings.ToUpper(\"x\")\n", "files: {}\n") - _, _, err := Load(root) - if err == nil || !strings.Contains(err.Error(), "imports are not permitted") { - t.Fatalf("Load import error = %v", err) - } -} - -func TestLoadRejectsEscapingConfig(t *testing.T) { - root := newFixtureRepo(t) - writeConfigFiles(t, root, validConfig, "files: {}\n") - outside := filepath.Join(t.TempDir(), "config.cue") - err := os.WriteFile(outside, []byte(validConfig), 0o600) - if err != nil { - t.Fatal(err) - } - path := filepath.Join(root, "tools/code-shape-gate/config.cue") - err = os.Remove(path) - if err != nil { - t.Fatal(err) - } - err = os.Symlink(outside, path) - if err != nil { - t.Fatal(err) - } - _, _, err = Load(root) - if err == nil { - t.Fatal("Load accepted a config symlink outside the repository") - } -} - -func TestLoadRootDirectoryEntry(t *testing.T) { - root := newFixtureRepo(t) - writeConfigFiles(t, root, validConfig, `dirs: ".": {value: 11, reason: "split root files"}`) - _, allow, err := Load(root) - if err != nil || allow.Dirs[""].Value != 11 { - t.Fatalf("root entry = %#v, error = %v", allow.Dirs, err) - } -} - -func TestLoadMissingFiles(t *testing.T) { - root := newFixtureRepo(t) - writeConfigFiles(t, root, validConfig, "files: {}\n") - err := os.Remove(filepath.Join(root, "tools", "code-shape-gate", "allowlist.cue")) - if err != nil { - t.Fatal(err) - } - _, _, err = Load(root) - if err == nil || !strings.Contains(err.Error(), "allowlist.cue") { - t.Fatalf("Load error = %v, want missing allowlist", err) - } -} - -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" + - "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) - } - } -} diff --git a/go/internal/codeshape/metrics.go b/go/internal/codeshape/metrics.go deleted file mode 100644 index bef0052..0000000 --- a/go/internal/codeshape/metrics.go +++ /dev/null @@ -1,20 +0,0 @@ -// 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" -) - -// FunctionMeasurements maps each metric and source path to individual functions. -type FunctionMeasurements = map[Metric]map[string][]Entry diff --git a/go/internal/codeshape/model/config.go b/go/internal/codeshape/model/config.go new file mode 100644 index 0000000..b195641 --- /dev/null +++ b/go/internal/codeshape/model/config.go @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package model + +import ( + "path/filepath" + "strings" +) +// FunctionLimits contains per-language function thresholds. +type FunctionLimits struct { + Go GoFunctionLimits `json:"go"` + TS TSFunctionLimits `json:"ts"` +} + +// GoFunctionLimits contains Go function thresholds. +type GoFunctionLimits struct { + Cognitive int `json:"cognitive"` + Lines int `json:"lines"` + Statements int `json:"statements"` +} + +// TSFunctionLimits contains TypeScript function thresholds. +type TSFunctionLimits struct { + Cognitive int `json:"cognitive"` + Lines int `json:"lines"` +} + +// LimitFor returns a 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 + default: + return 0 + } +} +// Config contains file, directory, exclusion, 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. +// 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"` + Functions FunctionMeasurements `json:"functions,omitempty"` + Present bool `json:"-"` +} + +// Snapshot contains current measurements. +type Snapshot struct { + Files map[string]int + Dirs map[string]int + Functions FunctionMeasurements +} + +// Finding describes one enforced size or ratchet violation. +type Finding struct { + Path string + Metric Metric + Value int + Limit int + Fix string +} + +// Skip records a source file that could not be measured. +type Skip struct { + Path string + Reason string +} +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 matchGlob(pattern, path string) bool { + patternParts := strings.Split(strings.Trim(pattern, "/"), "/") + pathParts := strings.Split(strings.Trim(path, "/"), "/") + return matchGlobParts(patternParts, pathParts) +} + +func matchGlobParts(pattern, path []string) bool { + if len(pattern) == 0 { + return len(path) == 0 + } + if pattern[0] == "**" { + return matchGlobParts(pattern[1:], path) || len(path) > 0 && matchGlobParts(pattern, path[1:]) + } + if len(path) == 0 { + return false + } + matched, err := filepath.Match(pattern[0], path[0]) + return err == nil && matched && matchGlobParts(pattern[1:], path[1:]) +} +// StandardConfig returns default gate thresholds for tools and tests. +func StandardConfig() Config { + return Config{ + FileLimit: 500, + DirLimit: 10, + Extensions: []string{".go", ".cue"}, + Exclude: []string{}, + DirLimits: map[string]DirLimit{}, + Functions: FunctionLimits{ + Go: GoFunctionLimits{Cognitive: 20, Lines: 60, Statements: 40}, + TS: TSFunctionLimits{Cognitive: 15, Lines: 50}, + }, + } +} diff --git a/go/internal/codeshape/model/model.go b/go/internal/codeshape/model/model.go new file mode 100644 index 0000000..44e76e8 --- /dev/null +++ b/go/internal/codeshape/model/model.go @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +// Package model holds the data types used by the code-shape gate. +package model + +import ( + "fmt" + "sort" +) + +// 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" +) + +// FunctionMeasurements maps each metric and source path to individual functions. +type FunctionMeasurements map[Metric]map[string][]Entry + +type functionIDError struct{} + +func (functionIDError) Error() string { return "invalid function allowlist id" } + +var errFunctionID error = functionIDError{} + +// FunctionMetrics returns the supported function metrics in stable order. +func FunctionMetrics() []Metric { + return []Metric{MetricGoCognitive, MetricGoLines, MetricGoStatements, MetricTSCognitive, MetricTSLines} +} + +// IsFunctionMetric reports whether metric has a function collector. +func IsFunctionMetric(metric Metric) bool { + for _, candidate := range FunctionMetrics() { + if metric == candidate { + return true + } + } + return false +} + +// NewFunctionMeasurements creates initialized maps for every function metric. +func NewFunctionMeasurements() FunctionMeasurements { + values := make(FunctionMeasurements, len(FunctionMetrics())) + for _, metric := range FunctionMetrics() { + values[metric] = make(map[string][]Entry) + } + return values +} + +// AppendFunctionValue appends one function value and rejects duplicate ids. +func AppendFunctionValue(values FunctionMeasurements, metric Metric, path, id string, value int) error { + entries := values[metric][path] + for _, entry := range entries { + if entry.ID == id { + return fmt.Errorf("duplicate %s measurement for %s#%s", metric, path, id) + } + } + values[metric][path] = append(entries, Entry{ID: id, Value: value}) + return nil +} + +// SetFunctionDiagnostic sets one diagnostic on an initialized function. +func SetFunctionDiagnostic(values FunctionMeasurements, metric Metric, path, id string, value int) error { + entries := values[metric][path] + for index := range entries { + if entries[index].ID != id { + continue + } + if entries[index].Value != 0 { + return fmt.Errorf("duplicate %s diagnostic for %s#%s", metric, path, id) + } + entries[index].Value = value + values[metric][path] = entries + return nil + } + return fmt.Errorf("missing %s function measurement for %s#%s", metric, path, id) +} + +// FunctionEntry finds one function entry by id. +func FunctionEntry(entries []Entry, id string) (Entry, bool) { + for _, entry := range entries { + if entry.ID == id { + return entry, true + } + } + return Entry{}, false +} + +// SortedFunctionEntries returns function entries sorted by id. +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 that allowlist function ids are non-empty and unique. +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 == "" || seen[entry.ID] { + return fmt.Errorf("%w: function allowlist entry for %q has invalid or repeated id %q", errFunctionID, path, entry.ID) + } + seen[entry.ID] = true + } + } + return nil +} + +// RewriteFunctionEntries applies the allowlist ratchet and optional seeding. +func RewriteFunctionEntries(updated, allow, values FunctionMeasurements, cfg Config, seed bool) (bool, []string) { + changed := false + var errs []string + for _, metric := range FunctionMetrics() { + paths, dropped := retainedFunctionEntries(allow[metric], values[metric], cfg, metric) + changed = changed || dropped + added, addErrors := seedFunctionEntries(paths, values[metric], cfg, metric, seed) + changed = changed || added + errs = append(errs, addErrors...) + if len(paths) > 0 { + updated[metric] = paths + } + } + return changed, errs +} + +func retainedFunctionEntries(allow, values map[string][]Entry, cfg Config, metric Metric) (map[string][]Entry, bool) { + paths := make(map[string][]Entry, len(allow)) + changed := false + for path, entries := range allow { + for _, entry := range entries { + current, exists := FunctionEntry(values[path], entry.ID) + if !exists || current.Value <= cfg.Functions.LimitFor(metric) { + 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, cfg Config, metric Metric, seed bool) (bool, []string) { + changed := false + var errs []string + for path, entries := range values { + for _, current := range entries { + if current.Value <= cfg.Functions.LimitFor(metric) { + 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 + } + entry = seededFunctionEntry(entry, current, exists) + replaceFunctionEntry(paths, path, entry, exists) + changed = true + } + } + return changed, errs +} + +func seededFunctionEntry(entry, current Entry, exists bool) Entry { + if !exists { + entry = Entry{ID: current.ID, Reason: "seeded at rollout, burn down"} + } + entry.Value = current.Value + return entry +} + +func replaceFunctionEntry(paths map[string][]Entry, path string, entry Entry, exists bool) { + if !exists { + paths[path] = append(paths[path], entry) + return + } + for index := range paths[path] { + if paths[path][index].ID == entry.ID { + paths[path][index] = entry + return + } + } +} + diff --git a/go/internal/codeshape/testsupport/fixtures.go b/go/internal/codeshape/testsupport/fixtures.go new file mode 100644 index 0000000..64b2111 --- /dev/null +++ b/go/internal/codeshape/testsupport/fixtures.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +// Package testsupport provides isolated repositories for code-shape tests. +package testsupport + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/RigelBuild/meissa/go/internal/codeshape/model" +) + +// NewFixtureRepo creates an initialized git repository in the test temp directory. +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) + } + for _, args := range [][]string{{"config", "user.email", "test@example.invalid"}, {"config", "user.name", "test"}} { + command = exec.CommandContext(t.Context(), "git", append([]string{"-C", root}, args...)...) + output, err = command.CombinedOutput() + if err != nil { + t.Fatalf("git config: %v: %s", err, output) + } + } + return root +} + +// TrackedFile writes a fixture file inside a test repository. +func TrackedFile(t *testing.T, root, path, content string) { + t.Helper() + fullPath := filepath.Join(root, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// CommitFiles adds all fixture files to the repository. +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) + } +} + +// Config returns the standard valid code-shape test configuration. +func Config() model.Config { + return model.Config{ + FileLimit: 500, DirLimit: 10, Extensions: []string{".go", ".cue"}, Exclude: []string{}, + DirLimits: map[string]model.DirLimit{}, + Functions: model.FunctionLimits{ + Go: model.GoFunctionLimits{Cognitive: 20, Lines: 60, Statements: 40}, + TS: model.TSFunctionLimits{Cognitive: 15, Lines: 50}, + }, + } +} + +// WriteConfigFiles writes a gate config and allowlist. +func WriteConfigFiles(t *testing.T, root, config, allowlist string) { + t.Helper() + dir := filepath.Join(root, "tools", "code-shape-gate") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for path, content := range map[string]string{"config.cue": config, "allowlist.cue": allowlist} { + if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } +} diff --git a/go/internal/codeshape/tsmetrics.go b/go/internal/codeshape/tsmetrics.go deleted file mode 100644 index 4d53046..0000000 --- a/go/internal/codeshape/tsmetrics.go +++ /dev/null @@ -1,435 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package codeshape - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "slices" - "sort" - "strconv" - "strings" - "unicode/utf8" - - "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]Metric { - return map[string]Metric{ - "lint/complexity/noExcessiveCognitiveComplexity": MetricTSCognitive, - "lint/complexity/noExcessiveLinesPerFunction": 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") -) - -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 Config) (map[Metric]map[string][]Entry, []Skip, error) { - paths, err := trackedPaths(ctx, root) - if err != nil { - return nil, nil, err - } - sources := make(map[string][]byte) - for _, path := range paths { - if !isTypeScriptPath(path) || !countedPath(path, cfg) { - continue - } - //nolint:gosec // G304: path from the repo's tracked file list - source, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) - if err != nil { - return nil, nil, fmt.Errorf("read TypeScript source %q: %w", path, err) - } - sources[path] = source - } - if len(sources) == 0 { - return newFunctionMeasurements(), nil, nil - } - binary, err := exec.LookPath("biome") - if err != nil { - return nil, nil, fmt.Errorf("find biome: %w", err) - } - var sourcePaths []string - for path := range sources { - sourcePaths = append(sourcePaths, path) - } - sort.Strings(sourcePaths) - report, err := runBiome(ctx, binary, root, sourcePaths) - if err != nil { - return nil, nil, err - } - if report.Summary.DiagnosticsNotPrinted != 0 { - return nil, nil, fmt.Errorf("%w: %d were not printed", errBiomeTruncated, report.Summary.DiagnosticsNotPrinted) - } - skips := []Skip{} - if report.Summary.Changed+report.Summary.Unchanged != len(sourcePaths) { - for _, path := range sourcePaths { - single, singleErr := runBiome(ctx, binary, root, []string{path}) - if singleErr != nil { - return nil, nil, singleErr - } - if single.Summary.Changed+single.Summary.Unchanged == 0 { - delete(sources, path) - skips = append(skips, Skip{Path: path, Reason: "ignored by Biome"}) - } - } - } - values := 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, Skip{Path: path, Reason: err.Error()}) - delete(sources, path) - continue - } - defer tree.Release() - files[path] = functions - for _, function := range functions { - appendFunctionValue(values, MetricTSCognitive, path, function.id, 0) - appendFunctionValue(values, MetricTSLines, path, function.id, 0) - } - } - 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 _, included := sources[path]; !included && slices.ContainsFunc(skips, func(skip Skip) bool { return skip.Path == path }) { - continue - } - if !exists { - return nil, nil, fmt.Errorf("%w %q", errBiomeUnknownSource, path) - } - function, err := findTSFunction( - functions, diagnostic.Location.Start.Line, diagnostic.Location.Start.Column, sources[path], - ) - if err != nil { - return nil, nil, err - } - value, err := biomeValue(diagnostic.Message, metric) - if err != nil { - return nil, nil, err - } - appendFunctionValue(values, metric, path, function.id, value) - } - return values, skips, nil -} - -func isTypeScriptPath(path string) bool { - switch filepath.Ext(path) { - case ".ts", ".tsx", ".mts", ".cts": - return true - default: - return false - } -} - -func runBiome(ctx context.Context, binary, root 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") - 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() - if runErr != nil { - 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", err) - } - if report.Summary == nil || report.Diagnostics == nil { - return biomeReport{}, errBiomeMissingOutput - } - if report.Summary.Errors > 0 { - return biomeReport{}, fmt.Errorf("biome reported %d errors: %w", report.Summary.Errors, errBiomeReported) - } - return report, nil -} - -func readBiomeConfig(root string) (map[string]any, error) { - data, err := os.ReadFile(filepath.Join(root, "biome.json")) //nolint:gosec // G304: read repo config - if err != nil { - return nil, fmt.Errorf("read biome.json: %w", err) - } - var config map[string]any - err = json5.Unmarshal(data, &config) - if err != nil { - return nil, fmt.Errorf("parse biome.json: %w", 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 Metric) (int, error) { - fields := strings.Fields(message) - for index, field := range fields { - if index+1 < len(fields) && (metric == MetricTSCognitive && field == "of" || - metric == 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) - counts := make(map[string]int) - for i := range functions { - parentID := "" - 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 + "/" - break - } - } - classID := "" - for parent := functions[i].node.Parent(); parent != nil; parent = parent.Parent() { - if parent.Type(lang) == "class_declaration" || parent.Type(lang) == "class" { - if name := parent.ChildByFieldName("name", lang); name != nil { - classID = name.Text(source) + "/" - } - break - } - } - base := parentID + classID + functions[i].id - counts[base]++ - functions[i].id = base - if counts[base] > 1 { - functions[i].id += fmt.Sprintf("#%d", counts[base]) - } - } - return functions, tree, 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 - } - lineStart := 0 - for current := 1; current < line; current++ { - next := bytes.IndexByte(source[lineStart:], '\n') - if next < 0 { - return nil, 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 nil, fmt.Errorf("biome position %d:%d is outside source: %w", line, column, errBiomeOutsideSource) - } - r, size := utf8.DecodeRune(source[offset:]) - units++ - if r > 0xffff { - units++ - } - offset += size - } - if offset > len(source) { - return nil, fmt.Errorf("biome position %d:%d is outside source: %w", line, column, errBiomeOutsideSource) - } - 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 -} diff --git a/go/internal/codeshape/typescript/api.go b/go/internal/codeshape/typescript/api.go new file mode 100644 index 0000000..fc5703c --- /dev/null +++ b/go/internal/codeshape/typescript/api.go @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +// Package typescript collects Biome function metrics and suppression findings. +package typescript + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/RigelBuild/meissa/go/internal/codeshape/inventory" + "github.com/RigelBuild/meissa/go/internal/codeshape/model" + "github.com/titanous/json5" +) +CUT 168*=219 + "github.com/RigelBuild/meissa/go/internal/codeshape/model" + "github.com/titanous/json5" +) + +// SuppressionFindings reports Biome suppression directives and policy drift. +func SuppressionFindings(paths []string, cfg model.Config, repository *os.Root) ([]model.Finding, error) { + var findings []model.Finding + hasTS := false + for _, path := range paths { + if !isTypeScriptPath(path) || !inventory.CountedPath(path, cfg) { + continue + } + hasTS = true + source, err := repository.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read TypeScript source %q: %w", path, err) + } + findings = append(findings, suppressionLineFindings(path, source)...) + } + if !hasTS { + return findings, nil + } + configs, err := biomeConfigs(paths, repository) + if err != nil { + return nil, err + } + for path, config := range configs { + findings = append(findings, driftFindings(path, config, cfg)...) + } + return findings, nil +} + +// ParseSuppressionsForTest parses Biome suppression directives from one source file. +func ParseSuppressionsForTest(path string, source []byte) []model.Finding { + var findings []model.Finding + for lineNumber, line := range strings.Split(string(source), "\n") { + start := strings.Index(line, "biome-ignore") + if start < 0 { + continue + } + directive, _, _ := strings.Cut(line[start:], ":") + if !isBiomeComplexitySuppression(directive) { + continue + } + for _, metric := range []model.Metric{model.MetricTSCognitive, model.MetricTSLines} { + findings = append(findings, model.Finding{Path: path + ":" + strconv.Itoa(lineNumber+1), Metric: metric, Value: 1}) + } + } + return findings +} +func isBiomeComplexitySuppression(directive string) bool { + fields := strings.Fields(directive) + if len(fields) < 2 || (fields[0] != "biome-ignore" && fields[0] != "biome-ignore-all" && fields[0] != "biome-ignore-start") { + return false + } + for _, category := range strings.Split(strings.Join(fields[1:], " "), ",") { + switch strings.TrimSpace(category) { + case "lint", "lint/complexity", "lint/complexity/noExcessiveCognitiveComplexity", "lint/complexity/noExcessiveLinesPerFunction": + return true + } + } + return false +} + +func biomeConfigs(paths []string, repository *os.Root) (map[string]map[string]any, error) { + configs := make(map[string]map[string]any) + for _, path := range paths { + if filepath.Base(path) != "biome.json" && filepath.Base(path) != "biome.jsonc" { + continue + } + config, err := readBiomeConfig(repository, path) + if err != nil { + return nil, err + } + configs[path] = config + } + return configs, nil +} + +func readBiomeConfig(repository *os.Root, path string) (map[string]any, error) { + data, err := repository.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + var config map[string]any + if err := json5.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return config, nil +} + +func driftFindings(path string, config map[string]any, cfg model.Config) []model.Finding { + var findings []model.Finding + for _, override := range biomeComplexityRules(config) { + for _, check := range []struct { + name, option string + metric model.Metric + }{ + {"noExcessiveCognitiveComplexity", "maxAllowedComplexity", model.MetricTSCognitive}, + {"noExcessiveLinesPerFunction", "maxLines", model.MetricTSLines}, + } { + rule, exists := override[check.name].(map[string]any) + option, optionExists := ruleOption(rule, check.option) + if !exists || !optionExists || numericValue(option) != cfg.Functions.LimitFor(check.metric) || ruleLevel(rule) != "off" { + value := numericValue(option) + findings = append(findings, model.Finding{Path: path, Metric: check.metric, + Value: value, Limit: cfg.Functions.LimitFor(check.metric), Fix: "match biome rule option to gate config"}) + } + } + } + return findings +} + +func biomeComplexityRules(config map[string]any) []map[string]any { + rules := []map[string]any{nestedMap(config, "linter", "rules", "complexity")} + if overrides, ok := config["overrides"].([]any); ok { + for _, value := range overrides { + if override, ok := value.(map[string]any); ok { + rules = append(rules, nestedMap(override, "linter", "rules", "complexity")) + } + } + } + return rules +} + +func ruleOption(rule map[string]any, key string) (any, bool) { + options, ok := rule["options"].(map[string]any) + if !ok { + return nil, false + } + value, exists := options[key] + return value, exists +} + +func ruleLevel(rule map[string]any) string { + level, ok := rule["level"].(string) + if !ok { + return "" + } + return level +} + +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 + } +} + diff --git a/go/internal/codeshape/typescript/api_test.go b/go/internal/codeshape/typescript/api_test.go new file mode 100644 index 0000000..87f62fd --- /dev/null +++ b/go/internal/codeshape/typescript/api_test.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package typescript_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/RigelBuild/meissa/go/internal/codeshape/model" + "github.com/RigelBuild/meissa/go/internal/codeshape/testsupport" + "github.com/RigelBuild/meissa/go/internal/codeshape/typescript" +) + +func TestSuppressionFindingsCoverBiomeGroups(t *testing.T) { + for _, tc := range []struct { + name, directive string + }{ + {"bare lint", "// biome-ignore lint: reason"}, + {"complexity group", "// biome-ignore lint/complexity: reason"}, + {"all directive", "// biome-ignore-all lint/complexity: reason"}, + {"start directive", "// biome-ignore-start lint/complexity: reason"}, + {"single rule", "// biome-ignore lint/complexity/noExcessiveLinesPerFunction: reason"}, + } { + t.Run(tc.name, func(t *testing.T) { + findings := typescriptSuppressionLineFindings("src/a.ts", []byte(tc.directive+"\n")) + if len(findings) != 2 || findings[0].Value != 1 || findings[0].Path != "src/a.ts:1" { + t.Fatalf("findings = %#v", findings) + } + }) + } +} + +func TestBiomeDriftChecksJSONCAndOverrides(t *testing.T) { + root := testsupport.NewFixtureRepo(t) + testsupport.TrackedFile(t, root, "src/a.ts", "function f() {}\n") + testsupport.TrackedFile(t, root, "biome.jsonc", `{"linter":{"rules":{"complexity":{"noExcessiveCognitiveComplexity":{"level":"off","options":{"maxAllowedComplexity":15}},"noExcessiveLinesPerFunction":{"level":"off","options":{"maxLines":50}}}}},"overrides":[{"linter":{"rules":{"complexity":{"noExcessiveLinesPerFunction":{"level":"off","options":{"maxLines":3}}}}}}]}`) + testsupport.CommitFiles(t, root) + paths, err := trackedPaths(t.Context(), root) + if err != nil { + t.Fatal(err) + } + repository, err := os.OpenRoot(root) + if err != nil { + t.Fatal(err) + } + defer repository.Close() + findings, err := typescript.SuppressionFindings(paths, model.Config{Extensions: []string{".ts"}, Functions: model.FunctionLimits{TS: model.TSFunctionLimits{Cognitive: 15, Lines: 50}}}, repository) + if err != nil { + t.Fatal(err) + } + if len(findings) != 1 || findings[0].Path != "biome.jsonc" || findings[0].Limit != 50 { + t.Fatalf("findings = %#v", findings) + } +} + +func TestBiomeSuppressionFindingsDoNotReadOutsideRoot(t *testing.T) { + root := testsupport.NewFixtureRepo(t) + outside := filepath.Join(t.TempDir(), "outside.ts") + if err := os.WriteFile(outside, []byte("// biome-ignore lint: reason\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(root, "escape.ts")); err != nil { + t.Fatal(err) + } + repository, err := os.OpenRoot(root) + if err != nil { + t.Fatal(err) + } + defer repository.Close() + if _, err := typescript.SuppressionFindings([]string{"escape.ts"}, model.Config{Extensions: []string{".ts"}}, repository); err == nil { + t.Fatal("suppression scan followed symlink outside repository") + } +} + +func typescriptSuppressionLineFindings(path string, source []byte) []model.Finding { + return typescript.ParseSuppressionsForTest(path, source) +} + +func TestTSIdentityHandlesSameNamedMethodsAndNestedCallbacks(t *testing.T) { + first := `function big() { xs.map(() => 1) } class A { run() {} } class B { run() {} }` + second := `ys.map(() => 0); function big() { xs.map(() => 1) } class A { run() {} } class B { run() {} }` + a, err := typescript.FunctionIDs("x.ts", []byte(first)) + if err != nil { + t.Fatal(err) + } + b, err := typescript.FunctionIDs("x.ts", []byte(second)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(a, ","), "A/run") || !strings.Contains(strings.Join(a, ","), "B/run") || !strings.Contains(strings.Join(a, ","), "big/closure-1") { + t.Fatalf("ids = %v", a) + } + if !strings.Contains(strings.Join(b, ","), "big/closure-1") { + t.Fatalf("sibling insertion changed ids = %v", b) + } +} diff --git a/go/internal/codeshape/typescript/metrics.go b/go/internal/codeshape/typescript/metrics.go new file mode 100644 index 0000000..99ecd34 --- /dev/null +++ b/go/internal/codeshape/typescript/metrics.go @@ -0,0 +1,420 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package typescript + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "github.com/RigelBuild/meissa/go/internal/codeshape/inventory" + "github.com/RigelBuild/meissa/go/internal/codeshape/model" + "github.com/odvcencio/gotreesitter" + "github.com/odvcencio/gotreesitter/grammars/tsx" + "github.com/odvcencio/gotreesitter/grammars/typescript" +) + +const maxBiomeDiagnostics = 1_000_000 + +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") + errBiomePosition = errors.New("invalid biome source position") + errBiomeOutsideSource = errors.New("biome position outside source") + errBiomeOutsideFunction = errors.New("biome position outside any function") +) + +func biomeRuleMetrics() map[string]model.Metric { + return map[string]model.Metric{ + "lint/complexity/noExcessiveCognitiveComplexity": model.MetricTSCognitive, + "lint/complexity/noExcessiveLinesPerFunction": model.MetricTSLines, + } +} + +// Metrics measures Biome's function complexity and line count rules. +func Metrics(ctx context.Context, root string, paths []string, cfg model.Config, repository *os.Root) (model.FunctionMeasurements, []model.Skip, error) { + sources, sourcePaths, err := readTypeScriptSources(paths, cfg, repository) + if err != nil || len(sources) == 0 { + return model.NewFunctionMeasurements(), nil, err + } + binary, err := exec.LookPath("biome") + if err != nil { + return nil, nil, fmt.Errorf("find biome: %w", err) + } + report, err := runBiome(ctx, binary, root, sourcePaths) + if err != nil { + return nil, nil, err + } + if report.Summary.DiagnosticsNotPrinted != 0 { + return nil, nil, fmt.Errorf("%w: %d were not printed", errBiomeTruncated, report.Summary.DiagnosticsNotPrinted) + } + skipped, err := skippedBiomeSources(ctx, binary, root, sourcePaths, report) + if err != nil { + return nil, nil, err + } + for _, path := range skipped { + delete(sources, path) + } + return collectTypeScriptMetrics(report, sources, skipped) +} + +func readTypeScriptSources(paths []string, cfg model.Config, repository *os.Root) (map[string][]byte, []string, error) { + sources := make(map[string][]byte) + for _, path := range paths { + if !isTypeScriptPath(path) || !inventory.CountedPath(path, cfg) { + continue + } + source, err := repository.ReadFile(filepath.FromSlash(path)) + if err != nil { + return nil, nil, fmt.Errorf("read TypeScript source %q: %w", path, err) + } + sources[path] = source + } + sourcePaths := make([]string, 0, len(sources)) + for path := range sources { + sourcePaths = append(sourcePaths, path) + } + sort.Strings(sourcePaths) + return sources, sourcePaths, nil +} + +func skippedBiomeSources(ctx context.Context, binary, root string, paths []string, report biomeReport) ([]model.Skip, error) { + if report.Summary.Changed+report.Summary.Unchanged == len(paths) { + return nil, nil + } + var skipped []model.Skip + for _, path := range paths { + single, err := runBiome(ctx, binary, root, []string{path}) + if err != nil { + return nil, err + } + if single.Summary.Changed+single.Summary.Unchanged == 0 { + skipped = append(skipped, model.Skip{Path: path, Reason: "ignored by Biome"}) + } + } + return skipped, nil +} + +func collectTypeScriptMetrics(report biomeReport, sources map[string][]byte, skips []model.Skip) (model.FunctionMeasurements, []model.Skip, error) { + values := model.NewFunctionMeasurements() + files := make(map[string][]tsFunction, len(sources)) + trees := make([]*gotreesitter.Tree, 0, len(sources)) + defer func() { + for _, tree := range trees { + tree.Release() + } + }() + for path, source := range sources { + functions, tree, err := parseTSFunctions(path, source) + if err != nil { + skips = append(skips, model.Skip{Path: path, Reason: err.Error()}) + delete(sources, path) + continue + } + trees = append(trees, tree) + files[path] = functions + for _, function := range functions { + if err := model.AppendFunctionValue(values, model.MetricTSCognitive, path, function.id, 0); err != nil { + return nil, nil, err + } + if err := model.AppendFunctionValue(values, model.MetricTSLines, path, function.id, 0); err != nil { + return nil, nil, err + } + } + } + if err := applyBiomeDiagnostics(report, files, sources, values, skips); err != nil { + return nil, nil, err + } + return values, skips, nil +} + +func applyBiomeDiagnostics(report biomeReport, files map[string][]tsFunction, sources map[string][]byte, + values model.FunctionMeasurements, skips []model.Skip) 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 isSkippedTSPath(path, skips) { + 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 + } + if err := model.SetFunctionDiagnostic(values, metric, path, function.id, value); err != nil { + return err + } + } + return nil +} + +func isSkippedTSPath(path string, skips []model.Skip) bool { + for _, skip := range skips { + if skip.Path == path { + return true + } + } + return false +} + +func isTypeScriptPath(path string) bool { + switch filepath.Ext(path) { + case ".ts", ".tsx", ".mts", ".cts": + return true + default: + return false + } +} + +func runBiome(ctx context.Context, binary, root string, paths []string) (biomeReport, error) { + args := []string{"lint", "--reporter=json", "--max-diagnostics=" + strconv.Itoa(maxBiomeDiagnostics), + "--only=complexity/noExcessiveCognitiveComplexity", "--only=complexity/noExcessiveLinesPerFunction", "--no-errors-on-unmatched"} + args = append(args, paths...) + //nolint:gosec // G204: execute the configured biome linter + command := exec.CommandContext(ctx, binary, args...) + command.Dir = root + var stdout, stderr bytes.Buffer + command.Stdout, command.Stderr = &stdout, &stderr + if err := command.Run(); err != nil { + return biomeReport{}, fmt.Errorf("biome lint failed: %w: %s", err, strings.TrimSpace(stderr.String())) + } + var report biomeReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + return biomeReport{}, fmt.Errorf("parse biome output: %w", err) + } + if report.Summary == nil || report.Diagnostics == nil { + return biomeReport{}, errBiomeMissingOutput + } + if report.Summary.Errors > 0 { + return biomeReport{}, fmt.Errorf("biome reported %d errors: %w", report.Summary.Errors, errBiomeReported) + } + return report, nil +} + +func biomeValue(message string, metric model.Metric) (int, error) { + fields := strings.Fields(message) + for index, field := range fields { + if index+1 < len(fields) && (metric == model.MetricTSCognitive && field == "of" || metric == model.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: syntax errors", path) + } + functions := make([]tsFunction, 0) + collectTSFunctions(root, lang, 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 index := range functions { + parent := enclosingTSFunction(functions, index) + class := enclosingTSClass(functions[index].node, lang, source) + base := parent + class + functions[index].id + counts[base]++ + functions[index].id = base + if counts[base] > 1 { + functions[index].id += fmt.Sprintf("#%d", counts[base]) + } + } +} + +func enclosingTSFunction(functions []tsFunction, index int) string { + for candidate := index - 1; candidate >= 0; candidate-- { + if functions[candidate].node.StartByte() <= functions[index].node.StartByte() && + functions[candidate].node.EndByte() >= functions[index].node.EndByte() { + return functions[candidate].id + "/" + } + } + return "" +} + +func enclosingTSClass(node *gotreesitter.Node, lang *gotreesitter.Language, source []byte) string { + for parent := node.Parent(); parent != nil; parent = parent.Parent() { + if parent.Type(lang) != "class_declaration" && parent.Type(lang) != "class" { + continue + } + if name := parent.ChildByFieldName("name", lang); name != nil { + return name.Text(source) + "/" + } + } + return "" +} + +func collectTSFunctions(node *gotreesitter.Node, lang *gotreesitter.Language, titlePath []string, + functions *[]tsFunction, source []byte) { + if node == nil { + return + } + if isTestTitleCall(node, lang, source) { + arguments := node.ChildByFieldName("arguments", lang) + title, callback := firstStringArgument(arguments, lang, source), lastFunctionArgument(arguments, lang) + if title != "" && callback != nil { + collectTSFunctions(callback, lang, append(append([]string(nil), titlePath...), title), functions, source) + return + } + } + if isTSFunctionNode(node, lang) { + *functions = append(*functions, tsFunction{node: node, id: tsFunctionID(node, lang, titlePath, source)}) + } + for index := range node.ChildCount() { + collectTSFunctions(node.Child(index), lang, titlePath, functions, source) + } +} + +func isTestTitleCall(node *gotreesitter.Node, lang *gotreesitter.Language, source []byte) bool { + if node.Type(lang) != "call_expression" { + return false + } + callable := node.ChildByFieldName("function", lang) + if callable == nil { + return false + } + text := callable.Text(source) + return text == "describe" || text == "test" || text == "it" || + strings.HasPrefix(text, "describe.") || strings.HasPrefix(text, "test.") || strings.HasPrefix(text, "it.") +} + +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 || 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 { + if property := parent.ChildByFieldName("name", lang); property != nil { + functionName = property.Text(source) + } else 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) { + offset, err := biomeOffset(source, line, column) + 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, smallest = function, span + } + } + } + if match == nil { + return nil, fmt.Errorf("biome position %d:%d is outside any function: %w", line, column, errBiomeOutsideFunction) + } + return match, nil +} + +func biomeOffset(source []byte, line, column int) (int, error) { + if line < 1 || column < 1 { + return 0, errBiomePosition + } + 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) + } + r, size := utf8.DecodeRune(source[offset:]) + units++ + if r > 0xffff { + units++ + } + offset += size + } + return offset, nil +} diff --git a/go/internal/codeshape/tsmetrics_test.go b/go/internal/codeshape/typescript/metrics_test.go similarity index 100% rename from go/internal/codeshape/tsmetrics_test.go rename to go/internal/codeshape/typescript/metrics_test.go diff --git a/go/internal/codeshape/typescript/report.go b/go/internal/codeshape/typescript/report.go new file mode 100644 index 0000000..32f1787 --- /dev/null +++ b/go/internal/codeshape/typescript/report.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package typescript + +import "github.com/odvcencio/gotreesitter" + +// biomeReport is the JSON schema for Biome's lint output. +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 +} diff --git a/go/internal/codeshape/tssuppressions.go b/go/internal/codeshape/typescript/suppressions.go similarity index 100% rename from go/internal/codeshape/tssuppressions.go rename to go/internal/codeshape/typescript/suppressions.go From b30b84b9dcd9c5aa279f2e06926de4e117091ca0 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 10:24:57 -0500 Subject: [PATCH 07/54] wip(go): T2 split cli package (RIG-8435) --- go/cmd/code-shape-gate/main.go | 4 +- go/internal/codeshape/allowlist/api.go | 180 -------- go/internal/codeshape/allowlist/api_test.go | 47 -- go/internal/codeshape/allowlist/base.go | 36 -- go/internal/codeshape/allowlist/load.go | 0 go/internal/codeshape/bridge.go | 36 ++ go/internal/codeshape/cli/cli.go | 312 +++++++++++++ go/internal/codeshape/cli/cli_test.go | 331 +++++++++++++ go/internal/codeshape/cli/collectors_test.go | 171 +++++++ go/internal/codeshape/cli/fixtures_test.go | 94 ++++ go/internal/codeshape/cli/main.go | 354 -------------- go/internal/codeshape/cli/testmain_test.go | 28 ++ go/internal/codeshape/functions.go | 158 +++++++ go/internal/codeshape/golang/api.go | 106 ----- .../{golang/metrics.go => gometrics.go} | 431 +++++++++-------- .../metrics_test.go => gometrics_test.go} | 94 +--- .../suppressions.go => gosuppressions.go} | 0 go/internal/codeshape/inventory.go | 187 ++++++++ go/internal/codeshape/inventory/inventory.go | 156 ------- .../codeshape/inventory/inventory_test.go | 65 --- go/internal/codeshape/inventory/paths.go | 25 - go/internal/codeshape/inventory_test.go | 206 +++++++++ go/internal/codeshape/judge.go | 241 ++++++++++ go/internal/codeshape/judge/judge.go | 196 -------- go/internal/codeshape/judge/judge_test.go | 39 -- go/internal/codeshape/judge_test.go | 173 +++++++ go/internal/codeshape/load.go | 254 ++++++++++ go/internal/codeshape/load_test.go | 154 +++++++ go/internal/codeshape/metrics.go | 20 + go/internal/codeshape/model/config.go | 146 ------ go/internal/codeshape/model/model.go | 200 -------- .../codeshape/{allowlist => }/render.go | 27 +- .../codeshape/{allowlist => }/render_test.go | 0 .../codeshape/{allowlist => }/schema.cue | 8 +- go/internal/codeshape/testmain_test.go | 28 ++ go/internal/codeshape/testsupport/fixtures.go | 86 ---- go/internal/codeshape/tsmetrics.go | 435 ++++++++++++++++++ .../metrics_test.go => tsmetrics_test.go} | 64 --- .../suppressions.go => tssuppressions.go} | 0 go/internal/codeshape/typescript/api.go | 184 -------- go/internal/codeshape/typescript/api_test.go | 98 ---- go/internal/codeshape/typescript/metrics.go | 420 ----------------- go/internal/codeshape/typescript/report.go | 32 -- 43 files changed, 3064 insertions(+), 2762 deletions(-) delete mode 100644 go/internal/codeshape/allowlist/api.go delete mode 100644 go/internal/codeshape/allowlist/api_test.go delete mode 100644 go/internal/codeshape/allowlist/base.go delete mode 100644 go/internal/codeshape/allowlist/load.go create mode 100644 go/internal/codeshape/bridge.go create mode 100644 go/internal/codeshape/cli/cli.go create mode 100644 go/internal/codeshape/cli/cli_test.go create mode 100644 go/internal/codeshape/cli/collectors_test.go create mode 100644 go/internal/codeshape/cli/fixtures_test.go delete mode 100644 go/internal/codeshape/cli/main.go create mode 100644 go/internal/codeshape/cli/testmain_test.go create mode 100644 go/internal/codeshape/functions.go delete mode 100644 go/internal/codeshape/golang/api.go rename go/internal/codeshape/{golang/metrics.go => gometrics.go} (50%) rename go/internal/codeshape/{golang/metrics_test.go => gometrics_test.go} (64%) rename go/internal/codeshape/{golang/suppressions.go => gosuppressions.go} (100%) create mode 100644 go/internal/codeshape/inventory.go delete mode 100644 go/internal/codeshape/inventory/inventory.go delete mode 100644 go/internal/codeshape/inventory/inventory_test.go delete mode 100644 go/internal/codeshape/inventory/paths.go create mode 100644 go/internal/codeshape/inventory_test.go create mode 100644 go/internal/codeshape/judge.go delete mode 100644 go/internal/codeshape/judge/judge.go delete mode 100644 go/internal/codeshape/judge/judge_test.go create mode 100644 go/internal/codeshape/judge_test.go create mode 100644 go/internal/codeshape/load.go create mode 100644 go/internal/codeshape/load_test.go create mode 100644 go/internal/codeshape/metrics.go delete mode 100644 go/internal/codeshape/model/config.go delete mode 100644 go/internal/codeshape/model/model.go rename go/internal/codeshape/{allowlist => }/render.go (67%) rename go/internal/codeshape/{allowlist => }/render_test.go (100%) rename go/internal/codeshape/{allowlist => }/schema.cue (82%) create mode 100644 go/internal/codeshape/testmain_test.go delete mode 100644 go/internal/codeshape/testsupport/fixtures.go create mode 100644 go/internal/codeshape/tsmetrics.go rename go/internal/codeshape/{typescript/metrics_test.go => tsmetrics_test.go} (67%) rename go/internal/codeshape/{typescript/suppressions.go => tssuppressions.go} (100%) delete mode 100644 go/internal/codeshape/typescript/api.go delete mode 100644 go/internal/codeshape/typescript/api_test.go delete mode 100644 go/internal/codeshape/typescript/metrics.go delete mode 100644 go/internal/codeshape/typescript/report.go 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/internal/codeshape/allowlist/api.go b/go/internal/codeshape/allowlist/api.go deleted file mode 100644 index 3e115d8..0000000 --- a/go/internal/codeshape/allowlist/api.go +++ /dev/null @@ -1,180 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -// Package allowlist loads and renders the code-shape gate configuration. -package allowlist - -import ( - "embed" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - - "cuelang.org/go/cue" - "cuelang.org/go/cue/ast" - "cuelang.org/go/cue/cuecontext" - "cuelang.org/go/cue/parser" - - "github.com/RigelBuild/meissa/go/internal/codeshape/model" -) - -var ( - errInvalidThresholds = errors.New("config thresholds must be fileLimit 500 and dirLimit 10") - 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 - -// Load reads and validates the repository's CUE configuration and allowlist. -func Load(root string) (model.Config, model.Allowlist, error) { - repository, err := os.OpenRoot(root) - if err != nil { - return model.Config{}, model.Allowlist{}, fmt.Errorf("open repository root: %w", err) - } - config, allowlist, loadErr := loadFromRoot(repository) - closeErr := repository.Close() - if closeErr != nil { - closeErr = fmt.Errorf("close repository root: %w", closeErr) - } - return config, allowlist, errors.Join(loadErr, closeErr) -} - -func loadFromRoot(repository *os.Root) (model.Config, model.Allowlist, error) { - ctx := cuecontext.New() - schema, err := schemaValue(ctx) - if err != nil { - return model.Config{}, model.Allowlist{}, err - } - configValue, err := loadCUEFile(ctx, repository, "tools/code-shape-gate/config.cue") - if err != nil { - return model.Config{}, model.Allowlist{}, err - } - configValue = configValue.Unify(schema.LookupPath(cue.MakePath(cue.Def("Config")))) - if err = configValue.Err(); err != nil { - return model.Config{}, model.Allowlist{}, fmt.Errorf("validate config.cue: %w", err) - } - var config model.Config - if err = configValue.Decode(&config); err != nil { - return model.Config{}, model.Allowlist{}, fmt.Errorf("decode config.cue: %w", err) - } - if config.Functions == (model.FunctionLimits{}) { - config.Functions = model.FunctionLimits{ - Go: model.GoFunctionLimits{Cognitive: 20, Lines: 60, Statements: 40}, - TS: model.TSFunctionLimits{Cognitive: 15, Lines: 50}, - } - } - if err = validateConfig(config); err != nil { - return model.Config{}, model.Allowlist{}, err - } - allowValue, err := loadCUEFile(ctx, repository, "tools/code-shape-gate/allowlist.cue") - if err != nil { - return model.Config{}, model.Allowlist{}, err - } - allowlist, err := decodeAllowlist(schema, allowValue, "allowlist.cue") - if err != nil { - return model.Config{}, model.Allowlist{}, err - } - allowlist.Present = true - return config, allowlist, nil -} - -func schemaValue(ctx *cue.Context) (cue.Value, error) { - data, err := schemaFiles.ReadFile("schema.cue") - if err != nil { - return cue.Value{}, fmt.Errorf("read embedded schema: %w", err) - } - value := ctx.CompileBytes(data) - if err = value.Err(); err != nil { - return cue.Value{}, fmt.Errorf("compile embedded schema: %w", err) - } - return value, nil -} - -func loadCUEFile(ctx *cue.Context, repository *os.Root, name string) (cue.Value, error) { - data, err := repository.ReadFile(name) - if err != nil { - return cue.Value{}, fmt.Errorf("read %s: %w", name, err) - } - file, err := parser.ParseFile(name, data) - if err != nil { - return cue.Value{}, fmt.Errorf("parse %s: %w", name, err) - } - for _, declaration := range file.Decls { - if _, ok := declaration.(*ast.ImportDecl); ok { - return cue.Value{}, fmt.Errorf("%s: %w", name, errImportsForbidden) - } - } - value := ctx.BuildFile(file) - if err = value.Err(); err != nil { - return cue.Value{}, fmt.Errorf("evaluate %s: %w", name, err) - } - return value, nil -} - -func decodeAllowlist(schema, value cue.Value, name string) (model.Allowlist, error) { - value = value.Unify(schema.LookupPath(cue.MakePath(cue.Def("Allowlist")))) - if err := value.Err(); err != nil { - return model.Allowlist{}, fmt.Errorf("validate %s: %w", name, err) - } - var allowlist model.Allowlist - if err := value.Decode(&allowlist); err != nil { - return model.Allowlist{}, fmt.Errorf("decode %s: %w", name, err) - } - if allowlist.Files == nil { - allowlist.Files = map[string]model.Entry{} - } - if allowlist.Dirs == nil { - allowlist.Dirs = map[string]model.Entry{} - } - if allowlist.Functions == nil { - allowlist.Functions = model.NewFunctionMeasurements() - } - for metric, paths := range allowlist.Functions { - if !model.IsFunctionMetric(metric) { - return model.Allowlist{}, fmt.Errorf("%s: %w %q", name, errUnsupportedMetric, metric) - } - if err := model.ValidateFunctionEntryIDs(paths); err != nil { - return model.Allowlist{}, fmt.Errorf("%s: %w", name, err) - } - } - if entry, exists := allowlist.Dirs["."]; exists { - if _, duplicate := allowlist.Dirs[""]; duplicate { - return model.Allowlist{}, fmt.Errorf("%s: %w", name, errDuplicateRoot) - } - allowlist.Dirs[""] = entry - delete(allowlist.Dirs, ".") - } - return allowlist, nil -} - -func validateConfig(config model.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) - } - } - for _, pattern := range config.Exclude { - for segment := range strings.SplitSeq(pattern, "/") { - if segment == "**" { - continue - } - if _, err := filepath.Match(segment, ""); err != nil { - return fmt.Errorf("invalid exclude glob %q: %w", pattern, err) - } - } - } - return nil -} diff --git a/go/internal/codeshape/allowlist/api_test.go b/go/internal/codeshape/allowlist/api_test.go deleted file mode 100644 index bfd72fe..0000000 --- a/go/internal/codeshape/allowlist/api_test.go +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package allowlist_test - -import ( - "os" - "path/filepath" - "testing" - - "github.com/RigelBuild/meissa/go/internal/codeshape/allowlist" - "github.com/RigelBuild/meissa/go/internal/codeshape/model" - "github.com/RigelBuild/meissa/go/internal/codeshape/testsupport" -) - -func TestLoadAllowsT1ConfigWithoutFunctionLimits(t *testing.T) { - root := testsupport.NewFixtureRepo(t) - testsupport.WriteConfigFiles(t, root, "fileLimit: 500\ndirLimit: 10\nextensions: [\".go\"]\nexclude: []\n", "files: {}\n") - cfg, _, err := allowlist.Load(root) - if err != nil { - t.Fatal(err) - } - if cfg.FileLimit != 500 || cfg.Functions.Go.Lines != 0 { - t.Fatalf("config = %#v", cfg) - } -} - -func TestLoadBiomeConfigSupportsJSONCNameAndRootedRead(t *testing.T) { - root := testsupport.NewFixtureRepo(t) - outside := filepath.Join(t.TempDir(), "outside") - if err := os.WriteFile(outside, []byte("outside"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil { - t.Fatal(err) - } - repository, err := os.OpenRoot(root) - if err != nil { - t.Fatal(err) - } - defer repository.Close() - if _, err := repository.ReadFile("escape"); err == nil { - t.Fatal("repository root followed a symlink outside its boundary") - } - if model.MetricTSLines != "ts-lines" { - t.Fatalf("metric = %q", model.MetricTSLines) - } -} diff --git a/go/internal/codeshape/allowlist/base.go b/go/internal/codeshape/allowlist/base.go deleted file mode 100644 index 4e2fa20..0000000 --- a/go/internal/codeshape/allowlist/base.go +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package allowlist - -import ( - "fmt" - - "cuelang.org/go/cue" - "cuelang.org/go/cue/ast" - "cuelang.org/go/cue/parser" - - "github.com/RigelBuild/meissa/go/internal/codeshape/model" -) - -// Schema returns the embedded allowlist schema in a CUE context. -func Schema(ctx *cue.Context) (cue.Value, error) { - return schemaValue(ctx) -} - -// ParseAllowlist decodes an allowlist from CUE bytes. -func ParseAllowlist(ctx *cue.Context, schema cue.Value, data []byte) (model.Allowlist, error) { - file, err := parser.ParseFile("base allowlist", data) - if err != nil { - return model.Allowlist{}, fmt.Errorf("parse base allowlist: %w", err) - } - for _, declaration := range file.Decls { - if _, ok := declaration.(*ast.ImportDecl); ok { - return model.Allowlist{}, fmt.Errorf("base allowlist: %w", errImportsForbidden) - } - } - value := ctx.BuildFile(file) - if err := value.Err(); err != nil { - return model.Allowlist{}, fmt.Errorf("evaluate base allowlist: %w", err) - } - return decodeAllowlist(schema, value, "base allowlist") -} diff --git a/go/internal/codeshape/allowlist/load.go b/go/internal/codeshape/allowlist/load.go deleted file mode 100644 index e69de29..0000000 diff --git a/go/internal/codeshape/bridge.go b/go/internal/codeshape/bridge.go new file mode 100644 index 0000000..943830d --- /dev/null +++ b/go/internal/codeshape/bridge.go @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +import ( + "context" + + "cuelang.org/go/cue/cuecontext" +) + +func GoSuppressionFindings(ctx context.Context, root string, cfg Config) ([]Finding, error) { + return goSuppressionFindings(ctx, root, cfg) +} + +func TSPolicyFindings(ctx context.Context, root string, cfg Config) ([]Finding, error) { + return tsPolicyFindings(ctx, root, cfg) +} + +func EmptyAllowlist() Allowlist { return emptyAllowlist() } + +func DecodeBaseAllowlist(data []byte) (Allowlist, error) { + ctx := cuecontext.New() + schema, err := schemaValue(ctx) + if err != nil { + return Allowlist{}, err + } + return decodeAllowlistBytes(ctx, schema, data, "base allowlist") +} + +func RewriteFunctionEntries(updated, allow, values FunctionMeasurements, cfg Config, seed bool) (bool, []string) { + return rewriteFunctionEntries(updated, allow, values, cfg, seed) +} + +func (cfg Config) LimitFor(metric Metric, path string) int { return cfg.limitFor(metric, path) } +func DisplayPath(metric Metric, path string) string { return displayPath(metric, path) } +func SortFindings(findings []Finding) { sortFindings(findings) } diff --git a/go/internal/codeshape/cli/cli.go b/go/internal/codeshape/cli/cli.go new file mode 100644 index 0000000..e3f61b5 --- /dev/null +++ b/go/internal/codeshape/cli/cli.go @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "maps" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + . "github.com/RigelBuild/meissa/go/internal/codeshape" +) + +var errCannotRaiseAllowlist = errors.New("--write cannot add or raise allowlist entries") + +// Main runs the code-shape-gate CLI contract without exiting the process. +func Main(ctx context.Context, args []string, env func(string) string, stdout, stderr io.Writer) int { + if len(args) == 1 && (args[0] == "--help" || args[0] == "-h") { + if !writeOutput(stdout, "usage: code-shape-gate [--write] [--seed] \n") { + return 2 + } + return 0 + } + writeMode, seed, root, ok := parseArgs(args) + if !ok { + writeOutput(stderr, "usage: code-shape-gate [--write] [--seed] \n") + return 2 + } + root, err := filepath.Abs(root) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + config, allow, err := Load(root) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + var base Allowlist + if baseRef := env("GATE_BASE"); baseRef != "" { + base, err = loadBaseAllowlist(ctx, root, baseRef) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + } + snapshot, err := Inventory(ctx, root, config) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + functions, skips, err := GoMetrics(ctx, root, config) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + for _, skip := range skips { + if !writeOutput(stderr, "code-shape-gate: skip %s: %s\n", skip.Path, skip.Reason) { + return 2 + } + } + tsFunctions, tsSkips, err := TSMetrics(ctx, root, config) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + for _, skip := range tsSkips { + if !writeOutput(stderr, "code-shape-gate: skip %s: %s\n", skip.Path, skip.Reason) { + return 2 + } + } + functions[MetricTSCognitive] = tsFunctions[MetricTSCognitive] + functions[MetricTSLines] = tsFunctions[MetricTSLines] + snapshot.Functions = functions + suppressions, err := GoSuppressionFindings(ctx, root, config) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + tsFindings, err := TSPolicyFindings(ctx, root, config) + if err != nil { + writeOutput(stderr, "code-shape-gate: %v\n", err) + return 2 + } + suppressions = append(suppressions, tsFindings...) + SortFindings(suppressions) + if len(suppressions) > 0 { + err := writeFindings(stdout, suppressions) + if err != nil { + writeOutput(stderr, "code-shape-gate: write output: %v\n", err) + return 2 + } + return 1 + } + if writeMode { + return writeUpdated(root, config, allow, base, snapshot, seed, stdout, stderr) + } + findings := Judge(config, allow, base, snapshot) + 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 + } + return 0 +} + +func writeUpdated( + root string, + config Config, + allow, base Allowlist, + snapshot Snapshot, + seed bool, + stdout, stderr io.Writer, +) 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) + 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 { + 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) + closeErr := repository.Close() + err = errors.Join(err, closeErr) + if err != nil { + writeOutput(stderr, "code-shape-gate: write allowlist: %v\n", err) + return 2 + } + } + return 0 +} + +func parseArgs(args []string) (bool, bool, string, bool) { + var writeMode, seed bool + for len(args) > 0 && strings.HasPrefix(args[0], "--") { + switch args[0] { + case "--write": + if writeMode { + return false, false, "", false + } + writeMode = true + case "--seed": + if seed { + return false, false, "", false + } + seed = true + default: + return false, false, "", false + } + args = args[1:] + } + if len(args) != 1 || args[0] == "" || seed && !writeMode { + return false, false, "", false + } + return writeMode, seed, args[0], true +} + +func loadBaseAllowlist(ctx context.Context, root, ref string) (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) + } + if len(data) == 0 { + return 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) + } + allowlist, err := DecodeBaseAllowlist(data) + if err != nil { + return Allowlist{}, err + } + allowlist.Present = true + return allowlist, nil +} + +func gitBaseError(ref string, err error) error { + exitError, ok := errors.AsType[*exec.ExitError](err) + if ok { + return fmt.Errorf("read base allowlist at %q: %w: %s", ref, err, strings.TrimSpace(string(exitError.Stderr))) + } + 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)), + Functions: make(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) + changedFunctions, functionErrors := 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...) + 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, "; ")) + } + return updated, changed, nil +} + +func rewriteEntries( + entries map[string]Entry, + values map[string]int, + cfg Config, + metric Metric, + seed bool, +) (bool, []string) { + changed := false + for path, entry := range entries { + limit := cfg.LimitFor(metric, path) + value, exists := values[path] + if !exists || value <= limit { + delete(entries, path) + changed = true + } else if value < entry.Value { + entry.Value = value + entries[path] = entry + changed = true + } + } + var errs []string + for path, value := range values { + limit := cfg.LimitFor(metric, path) + if value <= limit { + continue + } + entry, exists := entries[path] + if exists && value <= entry.Value { + continue + } + if seed { + if !exists { + entry.Reason = "seeded at rollout, burn down" + } + entry.Value = value + entries[path] = entry + changed = true + } else { + errMessage := fmt.Sprintf( + "%s %s value %d needs --seed to add or raise the allowlist", + DisplayPath(metric, path), + metric, + value, + ) + errs = append(errs, errMessage) + } + } + return changed, errs +} + +func writeOutput(output io.Writer, format string, args ...any) bool { + _, err := fmt.Fprintf(output, format, args...) + return err == nil +} + +func writeFindings(output io.Writer, findings []Finding) error { + for _, finding := range findings { + _, err := fmt.Fprintf( + output, + "%s %s value=%d limit=%d fix=%s\n", + finding.Path, + finding.Metric, + finding.Value, + finding.Limit, + finding.Fix, + ) + if err != nil { + return fmt.Errorf("write finding: %w", err) + } + } + return nil +} diff --git a/go/internal/codeshape/cli/cli_test.go b/go/internal/codeshape/cli/cli_test.go new file mode 100644 index 0000000..4f04a75 --- /dev/null +++ b/go/internal/codeshape/cli/cli_test.go @@ -0,0 +1,331 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package cli + +import ( + "fmt" + . "github.com/RigelBuild/meissa/go/internal/codeshape" + "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 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 := EmptyAllowlist() + allow.Files["gone.go"] = Entry{ID: "", Value: 501, Reason: "split by concern"} + snapshot := Snapshot{Files: map[string]int{}, Dirs: map[string]int{}, Functions: nil} + 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": {ID: "", 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) + } +} +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 := 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()) + } + }) +} +func TestRewriteFunctionEntries(t *testing.T) { + cfg := testConfig() + cfg.Functions.Go.Lines = 60 + allow := Allowlist{Files: nil, Dirs: nil, Present: false, Functions: FunctionMeasurements{MetricGoLines: {"f.go": { + {ID: "lower", Value: 80, Reason: "split by concern"}, + {ID: "remove", Value: 70, Reason: "split by concern"}, + }}}} + values := FunctionMeasurements{MetricGoLines: {"f.go": { + {ID: "lower", Value: 70, Reason: ""}, {ID: "new", Value: 61, Reason: ""}, + }}} + _, _, err := rewriteAllowlist(cfg, allow, Snapshot{Files: nil, Dirs: nil, Functions: values}, false) + if err == nil { + t.Fatal("write accepted a new offender") + } + updated, changed, err := rewriteAllowlist(cfg, allow, Snapshot{Files: nil, Dirs: nil, Functions: values}, true) + if err != nil || !changed { + t.Fatalf("seed: changed=%v err=%v", changed, err) + } + got := updated.Functions[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..ddffdc8 --- /dev/null +++ b/go/internal/codeshape/cli/collectors_test.go @@ -0,0 +1,171 @@ +// 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 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\nprintf '%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":"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 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 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() + err := os.WriteFile(filepath.Join(dir, "biome"), []byte("#!/bin/sh\nprintf '{\"summary\":'\n"), 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/fixtures_test.go b/go/internal/codeshape/cli/fixtures_test.go new file mode 100644 index 0000000..bc5960b --- /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() 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}}, + } +} + +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 []Entry, id string) (Entry, bool) { + for _, entry := range entries { + if entry.ID == id { + return entry, true + } + } + return Entry{ID: "", Value: 0, Reason: ""}, false +} diff --git a/go/internal/codeshape/cli/main.go b/go/internal/codeshape/cli/main.go deleted file mode 100644 index e38de92..0000000 --- a/go/internal/codeshape/cli/main.go +++ /dev/null @@ -1,354 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -// Package cli exposes the code-shape gate command orchestration. -package cli - -import ( - "context" - "errors" - "fmt" - "io" - "maps" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - - "cuelang.org/go/cue/cuecontext" - - "github.com/RigelBuild/meissa/go/internal/codeshape/allowlist" - "github.com/RigelBuild/meissa/go/internal/codeshape/golang" - "github.com/RigelBuild/meissa/go/internal/codeshape/inventory" - "github.com/RigelBuild/meissa/go/internal/codeshape/judge" - "github.com/RigelBuild/meissa/go/internal/codeshape/model" - "github.com/RigelBuild/meissa/go/internal/codeshape/typescript" -) - -var errCannotRaiseAllowlist = errors.New("--write cannot add or raise allowlist entries") - -// Main runs the code-shape gate command without exiting the process. -func Main(ctx context.Context, args []string, env func(string) string, stdout, stderr io.Writer) int { - if helpRequested(args) { - return printHelp(stdout) - } - writeMode, seed, root, ok := parseArgs(args) - if !ok { - writeOutput(stderr, "usage: code-shape-gate [--write] [--seed] \n") - return 2 - } - root, err := filepath.Abs(root) - if err != nil { - writeOutput(stderr, "code-shape-gate: %v\n", err) - return 2 - } - config, allow, err := allowlist.Load(root) - if err != nil { - writeOutput(stderr, "code-shape-gate: %v\n", err) - return 2 - } - base, err := loadRequestedBase(ctx, root, env) - if err != nil { - writeOutput(stderr, "code-shape-gate: %v\n", err) - return 2 - } - paths, err := inventory.TrackedPaths(ctx, root) - if err != nil { - writeOutput(stderr, "code-shape-gate: %v\n", err) - return 2 - } - repository, err := os.OpenRoot(root) - if err != nil { - writeOutput(stderr, "code-shape-gate: open repository: %v\n", err) - return 2 - } - defer repository.Close() - snapshot, err := inventory.Inventory(paths, config, repository) - if err != nil { - writeOutput(stderr, "code-shape-gate: %v\n", err) - return 2 - } - functions, skips, err := collectFunctionMeasurements(ctx, root, paths, config, repository) - if err != nil { - writeOutput(stderr, "code-shape-gate: %v\n", err) - return 2 - } - for _, skip := range skips { - if !writeOutput(stderr, "code-shape-gate: skip %s: %s\n", skip.Path, skip.Reason) { - return 2 - } - } - snapshot.Functions = functions - findings, err := collectPolicyFindings(paths, config, repository) - if err != nil { - writeOutput(stderr, "code-shape-gate: %v\n", err) - return 2 - } - if writeMode { - return writeUpdated(root, config, allow, base, snapshot, seed, findings, stdout, stderr) - } - findings = append(judge.Judge(config, allow, base, snapshot), findings...) - sortFindings(findings) - return printFindings(stdout, stderr, findings) -} - -func collectFunctionMeasurements(ctx context.Context, root string, paths []string, cfg model.Config, repository *os.Root) (model.FunctionMeasurements, []model.Skip, error) { - goValues, goSkips, err := golang.Metrics(ctx, root, paths, cfg, repository) - if err != nil { - return nil, nil, err - } - tsValues, tsSkips, err := typescript.Metrics(ctx, root, paths, cfg, repository) - if err != nil { - return nil, nil, err - } - for _, metric := range []model.Metric{model.MetricTSCognitive, model.MetricTSLines} { - goValues[metric] = tsValues[metric] - } - return goValues, append(goSkips, tsSkips...), nil -} - -func collectPolicyFindings(paths []string, cfg model.Config, repository *os.Root) ([]model.Finding, error) { - goFindings, err := golang.SuppressionFindings(paths, cfg, repository) - if err != nil { - return nil, err - } - tsFindings, err := typescript.SuppressionFindings(paths, cfg, repository) - if err != nil { - return nil, err - } - return append(goFindings, tsFindings...), nil -} - -func helpRequested(args []string) bool { - return len(args) == 1 && (args[0] == "--help" || args[0] == "-h") -} - -func printHelp(stdout io.Writer) int { - if !writeOutput(stdout, "usage: code-shape-gate [--write] [--seed] \n") { - return 2 - } - return 0 -} - -func loadRequestedBase(ctx context.Context, root string, env func(string) string) (model.Allowlist, error) { - ref := env("GATE_BASE") - if ref == "" { - return model.Allowlist{}, nil - } - return loadBaseAllowlist(ctx, root, ref) -} - -func printFindings(stdout, stderr io.Writer, findings []model.Finding) int { - if err := writeFindings(stdout, findings); err != nil { - writeOutput(stderr, "code-shape-gate: write output: %v\n", err) - return 2 - } - if len(findings) > 0 { - return 1 - } - return 0 -} - -func writeUpdated(root string, config model.Config, allow, base model.Allowlist, snapshot model.Snapshot, seed bool, - suppressions []model.Finding, stdout, stderr io.Writer) int { - updated, changed, err := rewriteAllowlist(config, allow, snapshot, seed) - if err != nil { - writeOutput(stderr, "code-shape-gate: %v\n", err) - return 2 - } - findings := append(judge.Judge(config, updated, base, snapshot), suppressions...) - sortFindings(findings) - if len(findings) > 0 { - return printFindings(stdout, stderr, findings) - } - if changed { - if err := writeAllowlist(root, updated); err != nil { - writeOutput(stderr, "code-shape-gate: write allowlist: %v\n", err) - return 2 - } - } - return 0 -} - -func writeAllowlist(root string, updated model.Allowlist) error { - repository, err := os.OpenRoot(root) - if err != nil { - return fmt.Errorf("open repository: %w", err) - } - writeErr := repository.WriteFile("tools/code-shape-gate/allowlist.cue", allowlist.Render(updated), 0o644) - closeErr := repository.Close() - return errors.Join(writeErr, closeErr) -} - -func parseArgs(args []string) (bool, bool, string, bool) { - var writeMode, seed bool - for len(args) > 0 && strings.HasPrefix(args[0], "--") { - switch args[0] { - case "--write": - if writeMode { - return false, false, "", false - } - writeMode = true - case "--seed": - if seed { - return false, false, "", false - } - seed = true - default: - return false, false, "", false - } - args = args[1:] - } - if len(args) != 1 || args[0] == "" || seed && !writeMode { - return false, false, "", false - } - return writeMode, seed, args[0], true -} - -func loadBaseAllowlist(ctx context.Context, root, ref string) (model.Allowlist, error) { - 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 model.Allowlist{}, gitBaseError(ref, err) - } - if len(data) == 0 { - return emptyAllowlist(), nil - } - 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 model.Allowlist{}, gitBaseError(ref, err) - } - cueContext := cuecontext.New() - schema, err := allowlistSchema(cueContext) - if err != nil { - return model.Allowlist{}, err - } - parsed, err := parseBaseAllowlist(cueContext, schema, data) - if err != nil { - return model.Allowlist{}, err - } - parsed.Present = true - return parsed, nil -} - -func gitBaseError(ref string, err error) error { - exitError, ok := errors.AsType[*exec.ExitError](err) - if ok { - return fmt.Errorf("read base allowlist at %q: %w: %s", ref, err, strings.TrimSpace(string(exitError.Stderr))) - } - return fmt.Errorf("read base allowlist at %q: %w", ref, err) -} - -func rewriteAllowlist(config model.Config, allow, snapshot model.Allowlist, values model.Snapshot, seed bool) (model.Allowlist, bool, error) { - _ = snapshot - updated := model.Allowlist{Files: make(map[string]model.Entry, len(allow.Files)), - Dirs: make(map[string]model.Entry, len(allow.Dirs)), Functions: model.NewFunctionMeasurements(), Present: true} - maps.Copy(updated.Files, allow.Files) - maps.Copy(updated.Dirs, allow.Dirs) - changedFiles, fileErrors := rewriteEntries(updated.Files, values.Files, config, model.MetricFiles, seed) - changedDirs, dirErrors := rewriteEntries(updated.Dirs, values.Dirs, config, model.MetricDirs, seed) - changedFunctions, functionErrors := model.RewriteFunctionEntries(updated.Functions, allow.Functions, values.Functions, config, seed) - errs := append(fileErrors, dirErrors...) - errs = append(errs, functionErrors...) - sort.Strings(errs) - if len(errs) > 0 { - return allow, false, fmt.Errorf("%w: %s", errCannotRaiseAllowlist, strings.Join(errs, "; ")) - } - return updated, changedFiles || changedDirs || changedFunctions, nil -} - -func rewriteEntries(entries map[string]model.Entry, values map[string]int, cfg model.Config, metric model.Metric, seed bool) (bool, []string) { - changed := dropClearedEntries(entries, values, cfg, metric) - added, errs := addFunctionEntries(entries, values, cfg, metric, seed) - return changed || added, errs -} - -func dropClearedEntries(entries map[string]model.Entry, values map[string]int, cfg model.Config, metric model.Metric) bool { - changed := false - for path, entry := range entries { - value, exists := values[path] - if !exists || value <= limitFor(cfg, metric, path) { - delete(entries, path) - changed = true - } else if value < entry.Value { - entry.Value = value - entries[path] = entry - changed = true - } - } - return changed -} - -func addFunctionEntries(entries map[string]model.Entry, values map[string]int, cfg model.Config, metric model.Metric, seed bool) (bool, []string) { - changed := false - var errs []string - for path, value := range values { - limit := limitFor(cfg, metric, path) - if value <= limit { - continue - } - entry, exists := entries[path] - if exists && value <= entry.Value { - continue - } - if !seed { - errs = append(errs, fmt.Sprintf("%s %s value %d needs --seed to add or raise the allowlist", path, metric, value)) - continue - } - if !exists { - entry.Reason = "seeded at rollout, burn down" - } - entry.Value = value - entries[path] = entry - changed = true - } - return changed, errs -} - -func limitFor(cfg model.Config, metric model.Metric, path string) int { - if metric != model.MetricDirs { - return cfg.FileLimit - } - limit := 0 - for pattern, entry := range cfg.DirLimits { - if inventory.MatchGlob(pattern, path) && (limit == 0 || entry.Limit < limit) { - limit = entry.Limit - } - } - if limit == 0 { - return cfg.DirLimit - } - return limit -} - -func emptyAllowlist() model.Allowlist { - return model.Allowlist{Files: map[string]model.Entry{}, Dirs: map[string]model.Entry{}, - Functions: model.NewFunctionMeasurements()} -} - -func writeOutput(output io.Writer, format string, args ...any) bool { - _, err := fmt.Fprintf(output, format, args...) - return err == nil -} - -func writeFindings(output io.Writer, findings []model.Finding) error { - for _, finding := range findings { - if _, err := fmt.Fprintf(output, "%s %s value=%d limit=%d fix=%s\n", finding.Path, finding.Metric, - finding.Value, finding.Limit, finding.Fix); err != nil { - return fmt.Errorf("write finding: %w", err) - } - } - return nil -} - -func sortFindings(findings []model.Finding) { - sort.Slice(findings, func(i, j int) bool { - if findings[i].Path != findings[j].Path { - return findings[i].Path < findings[j].Path - } - return findings[i].Metric < findings[j].Metric - }) -} diff --git a/go/internal/codeshape/cli/testmain_test.go b/go/internal/codeshape/cli/testmain_test.go new file mode 100644 index 0000000..494d4d4 --- /dev/null +++ b/go/internal/codeshape/cli/testmain_test.go @@ -0,0 +1,28 @@ +// 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) + } + if err = os.Setenv("TMPDIR", dir); err != nil { + fmt.Fprintln(os.Stderr, err) + os.RemoveAll(dir) + os.Exit(1) + } + code := m.Run() + if err = os.RemoveAll(dir); err != nil { + fmt.Fprintln(os.Stderr, err) + code = 1 + } + os.Exit(code) +} diff --git a/go/internal/codeshape/functions.go b/go/internal/codeshape/functions.go new file mode 100644 index 0000000..9d54c2c --- /dev/null +++ b/go/internal/codeshape/functions.go @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +import ( + "errors" + "fmt" + "slices" + "sort" +) + +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"` +} + +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: + return 0 + default: + return 0 + } +} + +func isFunctionMetric(metric Metric) bool { + return slices.Contains(functionMetrics(), metric) +} + +func functionMetrics() []Metric { + return []Metric{MetricGoCognitive, MetricGoLines, MetricGoStatements, MetricTSCognitive, MetricTSLines} +} +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 +} + +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: ""}) +} + +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 +} + +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 +} + +func rewriteFunctionEntries(updated, allow, values FunctionMeasurements, cfg Config, seed bool) (bool, []string) { + changed := false + errs := []string{} + for _, metric := range functionMetrics() { + paths := map[string][]Entry{} + for path, entries := range allow[metric] { + for _, entry := range entries { + current, exists := functionEntry(values[metric][path], entry.ID) + if !exists || current.Value <= cfg.Functions.limitFor(metric) { + changed = true + continue + } + if current.Value < entry.Value { + entry.Value = current.Value + changed = true + } + paths[path] = append(paths[path], entry) + } + } + for path, entries := range values[metric] { + for _, current := range entries { + if current.Value <= cfg.Functions.limitFor(metric) { + 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 + if exists { + for i := range paths[path] { + if paths[path][i].ID == current.ID { + paths[path][i] = entry + } + } + } else { + paths[path] = append(paths[path], entry) + } + changed = true + } + } + if len(paths) > 0 { + updated[metric] = paths + } + } + return changed, errs +} diff --git a/go/internal/codeshape/golang/api.go b/go/internal/codeshape/golang/api.go deleted file mode 100644 index 237a278..0000000 --- a/go/internal/codeshape/golang/api.go +++ /dev/null @@ -1,106 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -// Package golang collects per-function Go metrics and suppression findings. -package golang - -import ( - "context" - "fmt" - "go/ast" - "go/parser" - "go/token" - "os" - "strconv" - "strings" - - "github.com/RigelBuild/meissa/go/internal/codeshape/inventory" - "github.com/RigelBuild/meissa/go/internal/codeshape/model" -) - -// SuppressionFindings finds Go lint suppressions on included Go source. -func SuppressionFindings(paths []string, cfg model.Config, repository *os.Root) ([]model.Finding, error) { - var findings []model.Finding - for _, path := range paths { - if !goSourceIncluded(path, cfg) { - continue - } - source, err := repository.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read Go source %q: %w", path, err) - } - file, set, err := parseGoFile(path, source) - if err != nil { - return nil, err - } - findings = append(findings, suppressionFindings(path, file, set)...) - } - return findings, nil -} - -func goSourceIncluded(path string, cfg model.Config) bool { - return strings.HasSuffix(path, ".go") && inventory.CountedPath(path, cfg) -} - -func parseGoFile(path string, source []byte) (*ast.File, *token.FileSet, error) { - set := token.NewFileSet() - file, err := parser.ParseFile(set, path, source, parser.ParseComments) - if err != nil { - return nil, nil, fmt.Errorf("parse Go source %q: %w", path, err) - } - return file, set, nil -} - -func suppressionFindings(path string, file *ast.File, set *token.FileSet) []model.Finding { - var findings []model.Finding - for _, group := range file.Comments { - for _, comment := range group.List { - metrics := suppressedGoMetrics(comment.Text) - line := set.Position(comment.Pos()).Line - for _, metric := range metrics { - findings = append(findings, model.Finding{Path: path + ":" + strconv.Itoa(line), - Metric: metric, Value: 1, Fix: "remove complexity nolint suppression"}) - } - } - } - return findings -} - -func suppressedGoMetrics(text string) []model.Metric { - if !strings.HasPrefix(text, "//") { - return nil - } - fields := strings.Fields(strings.TrimSpace(strings.TrimPrefix(text, "//"))) - if len(fields) == 0 { - return nil - } - switch fields[0] { - case "nolint", "nolint:all": - return []model.Metric{model.MetricGoCognitive, model.MetricGoLines} - } - if names, ok := strings.CutPrefix(fields[0], "nolint:"); ok { - return selectedGoMetrics(names) - } - return nil -} - -func selectedGoMetrics(names string) []model.Metric { - complexity, lines := false, false - for name := range strings.SplitSeq(names, ",") { - switch name { - case "all": - complexity, lines = true, true - case "gocognit", "gocyclo": - complexity = true - case "funlen": - lines = true - } - } - metrics := make([]model.Metric, 0, 2) - if complexity { - metrics = append(metrics, model.MetricGoCognitive) - } - if lines { - metrics = append(metrics, model.MetricGoLines) - } - return metrics -} diff --git a/go/internal/codeshape/golang/metrics.go b/go/internal/codeshape/gometrics.go similarity index 50% rename from go/internal/codeshape/golang/metrics.go rename to go/internal/codeshape/gometrics.go index c0737bc..87732b3 100644 --- a/go/internal/codeshape/golang/metrics.go +++ b/go/internal/codeshape/gometrics.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -package golang +package codeshape import ( "bytes" @@ -20,9 +20,6 @@ import ( "sort" "strconv" "strings" - - "github.com/RigelBuild/meissa/go/internal/codeshape/inventory" - "github.com/RigelBuild/meissa/go/internal/codeshape/model" ) const highFunlenLimit = 1_000_000_000 @@ -30,6 +27,8 @@ const highFunlenLimit = 1_000_000_000 var ( gocognitValue = regexp.MustCompile(`cognitive complexity ([0-9]+) of func`) funlenValue = regexp.MustCompile(`\(([0-9]+) > [0-9]+\)`) + errEmptyGoModule = errors.New("has no included source files") + errEmptyGoMeasurement = errors.New("go function measurement is empty") errGoLintReport = errors.New("reported an error") errGoLintMissing = errors.New("measurement omitted") errGoMetricParse = errors.New("cannot parse value") @@ -72,27 +71,30 @@ type goFunction struct { id string } -type goLimits struct { - metric model.Metric - linter string - lines int - statements int +// Skip records a source file that could not be measured. +type Skip struct { + Path string + Reason string } -// Metrics measures functions in every included Go module. -func Metrics(ctx context.Context, root string, paths []string, cfg model.Config, repository *os.Root) (model.FunctionMeasurements, []model.Skip, error) { +// GoMetrics measures function complexity and length in every included module. +func GoMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[string][]Entry, []Skip, error) { + paths, err := trackedPaths(ctx, root) + if err != nil { + return nil, nil, err + } modules := goModules(paths, cfg) if len(modules) == 0 { - return model.NewFunctionMeasurements(), nil, nil + return newFunctionMeasurements(), nil, nil } binary, err := exec.LookPath("golangci-lint") if err != nil { return nil, nil, fmt.Errorf("find golangci-lint: %w", err) } - measurements := model.NewFunctionMeasurements() - var skips []model.Skip + measurements := newFunctionMeasurements() + skips := []Skip{} for _, module := range modules { - files, skipped, err := parseGoSources(root, module, paths, cfg, repository) + files, skipped, err := parseGoSources(root, module, paths, cfg) if err != nil { return nil, nil, err } @@ -100,88 +102,55 @@ func Metrics(ctx context.Context, root string, paths []string, cfg model.Config, if len(files) == 0 { continue } - if err := initializeMeasurements(measurements, files); err != nil { - return nil, nil, err + limits := []struct { + metric Metric + linter string + lines int + statements int + }{ + {metric: MetricGoCognitive, linter: "gocognit", lines: 0, statements: 0}, + {metric: MetricGoLines, linter: "funlen", lines: cfg.Functions.Go.Lines, statements: highFunlenLimit}, + {metric: MetricGoStatements, linter: "funlen", lines: highFunlenLimit, statements: cfg.Functions.Go.Statements}, + } + for _, file := range files { + for _, function := range file.functions { + for _, metric := range []Metric{MetricGoCognitive, MetricGoLines, MetricGoStatements} { + appendFunctionValue(measurements, metric, function.path, function.id, 0) + } + } } - for _, limits := range goLimits(cfg) { - if err := measureModule(ctx, root, module, binary, cfg, files, measurements, limits); err != nil { + for _, limit := range limits { + report, err := runGolangci(ctx, binary, root, module, limit.linter, limit.lines, limit.statements, cfg) + if err != nil { return nil, nil, err } - } - } - return measurements, skips, nil -} - -func initializeMeasurements(measurements model.FunctionMeasurements, files map[string]goSourceFile) error { - for _, file := range files { - for _, function := range file.functions { - for _, metric := range []model.Metric{model.MetricGoCognitive, model.MetricGoLines, model.MetricGoStatements} { - if err := model.AppendFunctionValue(measurements, metric, function.path, function.id, 0); err != nil { - return err + for _, issue := range report.Issues { + path := issue.Pos.Filename + if filepath.IsAbs(path) { + path, err = filepath.Rel(root, path) + if err != nil { + return nil, nil, fmt.Errorf("relativize Go diagnostic path: %w", err) + } + } else { + path = filepath.Join(module, path) + } + path = filepath.ToSlash(path) + if excludedFunctionPath(path, cfg) || !countedPath(path, cfg) { + continue } + value, err := metricValue(issue.Text, limit.metric) + if err != nil { + return nil, nil, err + } + path, function, err := findGoFunction(root, module, files, issue.Pos.Filename, issue.Pos.Line, issue.Pos.Column) + if err != nil { + return nil, nil, err + } + appendFunctionValue(measurements, limit.metric, path, function, value) } } } - return nil -} - -func goLimits(cfg model.Config) []goLimits { - return []goLimits{ - {metric: model.MetricGoCognitive, linter: "gocognit"}, - {metric: model.MetricGoLines, linter: "funlen", lines: cfg.Functions.Go.Lines, statements: highFunlenLimit}, - {metric: model.MetricGoStatements, linter: "funlen", lines: highFunlenLimit, statements: cfg.Functions.Go.Statements}, - } -} - -func measureModule(ctx context.Context, root, module, binary string, cfg model.Config, - files map[string]goSourceFile, measurements model.FunctionMeasurements, limits goLimits) error { - report, err := runGolangci(ctx, binary, root, module, limits.linter, limits.lines, limits.statements, cfg) - if err != nil { - return err - } - for _, issue := range report.Issues { - if issue.FromLinter != limits.linter { - return fmt.Errorf("golangci-lint typecheck issue in %q: %s", issue.Pos.Filename, issue.Text) - } - path := resolveReportedPath(root, module, issue.Pos.Filename) - if !goSourceIncluded(path, cfg) { - continue - } - value, err := metricValue(issue.Text, limits.metric) - if err != nil { - return err - } - path, function, err := findGoFunction(root, module, files, issue.Pos.Filename, issue.Pos.Line, issue.Pos.Column) - if errors.Is(err, errGoSourceUnknown) && !includedSourcePath(path, files, cfg) { - continue - } - if err != nil { - return err - } - if err := model.SetFunctionDiagnostic(measurements, limits.metric, path, function, value); err != nil { - return err - } - } - return nil -} - -func resolveReportedPath(root, module, reported string) string { - if filepath.IsAbs(reported) { - relative, err := filepath.Rel(root, reported) - if err == nil { - return filepath.ToSlash(relative) - } - return filepath.ToSlash(reported) - } - path := filepath.ToSlash(reported) - if module != "." { - return filepath.ToSlash(filepath.Join(module, path)) - } - return path -} - -func includedSourcePath(path string, files map[string]goSourceFile, cfg model.Config) bool { - return goSourceIncluded(path, cfg) && files[path].set != nil + return measurements, skips, nil } func trackedPaths(ctx context.Context, root string) ([]string, error) { @@ -198,46 +167,71 @@ func trackedPaths(ctx context.Context, root string) ([]string, error) { return paths, nil } -func goModules(paths []string, cfg model.Config) []string { +func goModules(paths []string, cfg Config) []string { modules := make([]string, 0) for _, path := range paths { - if filepath.Base(path) == "go.mod" && !excludedFunctionPath(path, cfg) { - modules = append(modules, filepath.ToSlash(filepath.Dir(path))) + 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 model.Config) bool { +func excludedFunctionPath(path string, cfg Config) bool { for segment := range strings.SplitSeq(filepath.ToSlash(path), "/") { if segment == "testdata" || segment == "fixtures" { return true } } - return inventory.MatchesAny(filepath.ToSlash(path), cfg.Exclude) + return matchesAny(filepath.ToSlash(path), cfg.Exclude) } -func parseGoSources(root, module string, paths []string, cfg model.Config, repository *os.Root) (map[string]goSourceFile, []model.Skip, error) { +func parseGoSources(root, module string, paths []string, cfg Config) (map[string]goSourceFile, []Skip, error) { files := make(map[string]goSourceFile) - var skips []model.Skip - modules := goModules(paths, cfg) + skips := []Skip{} + buildContext := build.Default + buildContext.CgoEnabled = false + modules := make([]string, 0) for _, path := range paths { - if !goSourceIncluded(path, cfg) || !belongsToModule(path, module, modules) { + if filepath.Base(path) == "go.mod" { + modules = append(modules, filepath.ToSlash(filepath.Dir(path))) + } + } + sort.Strings(modules) + for _, path := range paths { + if filepath.Ext(path) != ".go" || excludedFunctionPath(path, cfg) { + continue + } + if module != "." && !strings.HasPrefix(filepath.ToSlash(path), strings.TrimSuffix(module, "/")+"/") { + continue + } + nested := false + for _, dir := range modules { + if dir != "." && dir != module && (module == "." || strings.HasPrefix(dir, module+"/")) && + strings.HasPrefix(filepath.ToSlash(path), dir+"/") { + nested = true + break + } + } + if nested { + continue + } + if !countedPath(filepath.ToSlash(path), cfg) { continue } - buildContext := build.Default - buildContext.CgoEnabled = false 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) } if !matches { - skips = append(skips, model.Skip{Path: path, Reason: "not in default Go build"}) + skips = append(skips, Skip{Path: path, Reason: "not in default Go build"}) continue } - source, err := repository.ReadFile(filepath.FromSlash(path)) + //nolint:gosec // G304: path from the repo's tracked file list + source, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) if err != nil { return nil, nil, fmt.Errorf("read Go source %q: %w", path, err) } @@ -255,48 +249,8 @@ func parseGoSources(root, module string, paths []string, cfg model.Config, repos return files, skips, nil } -func belongsToModule(path, module string, modules []string) bool { - slashPath := filepath.ToSlash(path) - if module != "." && !strings.HasPrefix(slashPath, strings.TrimSuffix(module, "/")+"/") { - return false - } - for _, dir := range modules { - if dir != "." && dir != module && (module == "." || strings.HasPrefix(dir, module+"/")) && - strings.HasPrefix(slashPath, dir+"/") { - return false - } - } - return true -} - -func goSourceIncluded(path string, cfg model.Config) bool { - return filepath.Ext(path) == ".go" && !excludedFunctionPath(path, cfg) && inventory.CountedPath(filepath.ToSlash(path), cfg) -} - func goFunctions(file *ast.File, set *token.FileSet) []goFunction { - functions := collectGoFunctions(file, set) - sort.Slice(functions, func(i, j int) bool { return functions[i].node.Pos() < functions[j].node.Pos() }) - parents := goFunctionParents(functions) - assignGoFunctionIDs(functions, parents) - return functions -} - -func collectGoFunctions(file *ast.File, set *token.FileSet) []goFunction { - topLevel := topLevelFunctionNames(file) - var functions []goFunction - ast.Inspect(file, func(node ast.Node) bool { - switch function := node.(type) { - case *ast.FuncDecl: - functions = append(functions, goFunction{node: function, base: functionName(function, set)}) - case *ast.FuncLit: - functions = append(functions, goFunction{node: function, base: topLevel[function]}) - } - return true - }) - return functions -} - -func topLevelFunctionNames(file *ast.File) map[*ast.FuncLit]string { + functions := make([]goFunction, 0) topLevel := make(map[*ast.FuncLit]string) for _, decl := range file.Decls { group, ok := decl.(*ast.GenDecl) @@ -308,51 +262,54 @@ func topLevelFunctionNames(file *ast.File) map[*ast.FuncLit]string { if !ok { continue } - for index, expr := range value.Values { - if literal, ok := expr.(*ast.FuncLit); ok && index < len(value.Names) { - topLevel[literal] = value.Names[index].Name + 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 functionName(function *ast.FuncDecl, set *token.FileSet) string { - name := function.Name.Name - if function.Recv == nil || len(function.Recv.List) == 0 { - return name - } - var receiver bytes.Buffer - if err := format.Node(&receiver, set, function.Recv.List[0].Type); err != nil { - return name - } - return "(" + receiver.String() + ")." + name -} - -func goFunctionParents(functions []goFunction) []int { - parents := make([]int, len(functions)) - for index := range parents { - parents[index] = -1 + 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() }) + 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 } - if parents[index] < 0 || other.node.End()-other.node.Pos() < functions[parents[index]].node.End()-functions[parents[index]].node.Pos() { - parents[index] = candidate + span := other.node.End() - other.node.Pos() + if parent[index] == -1 || span < parentSpan { + parent[index] = candidate + parentSpan = span } } } - return parents -} - -func assignGoFunctionIDs(functions []goFunction, parents []int) { - closureCounts, duplicates, totals := map[int]int{}, map[string]int{}, map[string]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]++ @@ -360,10 +317,15 @@ func assignGoFunctionIDs(functions []goFunction, parents []int) { } for index := range functions { if functions[index].base != "" { - functions[index].id = numberedGoName(functions[index].base, totals, duplicates) + name := functions[index].base + if totals[name] > 1 { + duplicates[name]++ + name += "#" + strconv.Itoa(duplicates[name]) + } + functions[index].id = name continue } - owner := parents[index] + owner := parent[index] closureCounts[owner]++ name := "closure-" + strconv.Itoa(closureCounts[owner]) if owner >= 0 { @@ -372,22 +334,25 @@ func assignGoFunctionIDs(functions []goFunction, parents []int) { functions[index].id = name } } + return functions } -func numberedGoName(name string, totals, duplicates map[string]int) string { - if totals[name] <= 1 { - return name - } - duplicates[name]++ - return name + "#" + strconv.Itoa(duplicates[name]) -} - -func runGolangci(ctx context.Context, binary, root, module, linter string, lines, statements int, cfg model.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) +func runGolangci( + ctx context.Context, + binary, root, module, linter string, + lines, statements int, + cfg 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) @@ -400,12 +365,20 @@ func runGolangci(ctx context.Context, binary, root, module, linter string, lines return report, errors.Join(runErr, removeErr) } -func runGolangciWithConfig(ctx context.Context, binary, root, module, linter, configuration string, temporary *os.File) (golangciReport, error) { - if _, err := temporary.WriteString(configuration); err != nil { +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() - return golangciReport{}, fmt.Errorf("write golangci-lint config: %w", errors.Join(err, closeErr)) + 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) } - if err := temporary.Close(); err != nil { + 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 @@ -415,16 +388,19 @@ func runGolangciWithConfig(ctx context.Context, binary, root, module, linter, co command.Dir = filepath.Join(root, filepath.FromSlash(module)) command.Env = append(os.Environ(), "CGO_ENABLED=0") var stdout, stderr bytes.Buffer - command.Stdout, command.Stderr = &stdout, &stderr + 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())) + return golangciReport{}, fmt.Errorf("golangci-lint %s failed in %q: %w: %s", + linter, module, runErr, strings.TrimSpace(stderr.String())) } } var report golangciReport - if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + 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 != "" { @@ -433,24 +409,22 @@ func runGolangciWithConfig(ctx context.Context, binary, root, module, linter, co if runErr != nil && len(report.Issues) == 0 { return golangciReport{}, fmt.Errorf("golangci-lint %s failed without issues: %w", linter, runErr) } - if !hasEnabledLinter(report, linter) { - return golangciReport{}, fmt.Errorf("golangci-lint output omitted enabled %s measurement: %w", linter, errGoLintMissing) - } - return report, nil -} - -func hasEnabledLinter(report golangciReport, name string) bool { + enabled := false for _, entry := range report.Report.Linters { - if entry.Name == name && entry.Enabled { - return true + if entry.Name == linter && entry.Enabled { + enabled = true } } - return false + if !enabled { + return golangciReport{}, fmt.Errorf("golangci-lint output omitted enabled %s measurement: %w", + linter, errGoLintMissing) + } + return report, nil } -func metricValue(text string, metric model.Metric) (int, error) { +func metricValue(text string, metric Metric) (int, error) { pattern := funlenValue - if metric == model.MetricGoCognitive { + if metric == MetricGoCognitive { pattern = gocognitValue } matches := pattern.FindStringSubmatch(text) @@ -464,11 +438,25 @@ func metricValue(text string, metric model.Metric) (int, error) { return value, nil } -func findGoFunction(root, module string, files map[string]goSourceFile, reportedPath string, line, column int) (string, string, error) { - path := resolveReportedPath(root, module, reportedPath) +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 path, "", fmt.Errorf("go diagnostic references unknown source %q: %w", reportedPath, errGoSourceUnknown) + 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) @@ -481,10 +469,6 @@ func findGoFunction(root, module string, files map[string]goSourceFile, reported if position > token.Pos(tokenFile.Base()+tokenFile.Size()) { return "", "", fmt.Errorf("go diagnostic has invalid position %s:%d:%d: %w", path, line, column, errGoPosition) } - return functionAtPosition(path, source, position, line, column) -} - -func functionAtPosition(path string, source goSourceFile, position token.Pos, line, column int) (string, string, error) { var match *goFunction var smallest token.Pos for index := range source.functions { @@ -492,12 +476,25 @@ func functionAtPosition(path string, source goSourceFile, position token.Pos, li if function.node.Pos() <= position && position <= function.node.End() { span := function.node.End() - function.node.Pos() if match == nil || span < smallest { - match, smallest = function, span + 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 "", "", fmt.Errorf("go diagnostic at %s:%d:%d is outside any function: %w", + path, line, column, errGoOutsideFunction) } return path, match.id, nil } + +func hasAnyFunctionMeasurement(values map[Metric]map[string][]Entry) bool { + for _, paths := range values { + for _, entries := range paths { + if len(entries) > 0 { + return true + } + } + } + return false +} diff --git a/go/internal/codeshape/golang/metrics_test.go b/go/internal/codeshape/gometrics_test.go similarity index 64% rename from go/internal/codeshape/golang/metrics_test.go rename to go/internal/codeshape/gometrics_test.go index cdb7a15..d82f876 100644 --- a/go/internal/codeshape/golang/metrics_test.go +++ b/go/internal/codeshape/gometrics_test.go @@ -1,24 +1,19 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -package golang +package codeshape import ( "fmt" "go/parser" "go/token" - "os" "os/exec" - "path/filepath" "strings" "testing" - - "github.com/RigelBuild/meissa/go/internal/codeshape/model" - "github.com/RigelBuild/meissa/go/internal/codeshape/testsupport" ) func TestGoCollectorFixture(t *testing.T) { - root := testsupport.NewFixtureRepo(t) - testsupport.TrackedFile(t, root, "go.mod", "module example.com/fixture\n\ngo 1.25\n") + 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 { @@ -28,11 +23,11 @@ func TestGoCollectorFixture(t *testing.T) { } source.WriteString("}\n") } - testsupport.TrackedFile(t, root, "fixture.go", source.String()) - testsupport.CommitFiles(t, root) - cfg := testsupport.Config() - cfg.Functions = model.FunctionLimits{Go: model.GoFunctionLimits{Cognitive: 20, Lines: 60, Statements: 40}, - TS: model.TSFunctionLimits{Cognitive: 0, Lines: 0}} + trackedFile(t, root, "fixture.go", source.String()) + commitFiles(t, root) + cfg := testConfig() + cfg.Functions = FunctionLimits{Go: GoFunctionLimits{Cognitive: 20, Lines: 60, Statements: 40}, + TS: TSFunctionLimits{Cognitive: 0, Lines: 0}} values, _, err := GoMetrics(t.Context(), root, cfg) if err != nil { t.Fatal(err) @@ -118,34 +113,6 @@ func goTestRepo(t *testing.T, source string) string { 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 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 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") @@ -200,51 +167,6 @@ func TestGoExcludedModule(t *testing.T) { t.Fatal(err) } } -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\nprintf '%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) - } -} func TestGoBareAndAllSuppressions(t *testing.T) { root := goTestRepo(t, "package p\n//nolint\n//nolint:all\nfunc F() {}\n") findings, err := goSuppressionFindings(t.Context(), root, testConfig()) diff --git a/go/internal/codeshape/golang/suppressions.go b/go/internal/codeshape/gosuppressions.go similarity index 100% rename from go/internal/codeshape/golang/suppressions.go rename to go/internal/codeshape/gosuppressions.go diff --git a/go/internal/codeshape/inventory.go b/go/internal/codeshape/inventory.go new file mode 100644 index 0000000..476bd61 --- /dev/null +++ b/go/internal/codeshape/inventory.go @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" +) + +// Inventory measures tracked source files and direct tracked files per directory. +func Inventory(ctx context.Context, root string, cfg Config) (Snapshot, error) { + command := exec.CommandContext(ctx, "git", "ls-files", "-z") + command.Dir = root + output, err := command.Output() + if err != nil { + return Snapshot{}, fmt.Errorf("git ls-files: %w", err) + } + repository, err := os.OpenRoot(root) + if err != nil { + return Snapshot{}, fmt.Errorf("open repository root: %w", err) + } + snapshot, inventoryErr := inventoryPaths(repository, output, cfg) + closeErr := repository.Close() + if closeErr != nil { + closeErr = fmt.Errorf("close repository root: %w", closeErr) + } + if inventoryErr != nil || closeErr != nil { + return Snapshot{}, errors.Join(inventoryErr, closeErr) + } + return snapshot, nil +} + +func inventoryPaths(repository *os.Root, output []byte, cfg Config) (Snapshot, error) { + files := map[string]int{} + dirs := map[string]int{} + for rawPath := range bytes.SplitSeq(output, []byte{0}) { + if len(rawPath) == 0 { + continue + } + trackedPath := filepath.ToSlash(string(rawPath)) + if matchesAny(trackedPath, cfg.Exclude) { + continue + } + parent := filepath.ToSlash(filepath.Dir(filepath.FromSlash(trackedPath))) + if parent == "." { + parent = "" + } + dirs[parent]++ + if !countedPath(trackedPath, cfg) { + continue + } + file, err := repository.Open(filepath.FromSlash(trackedPath)) + if err != nil { + return Snapshot{}, fmt.Errorf("open tracked file %q: %w", trackedPath, err) + } + lines, generated, countErr := physicalLines(file) + closeErr := file.Close() + if countErr != nil { + countErr = fmt.Errorf("count lines in %q: %w", trackedPath, countErr) + } + if closeErr != nil { + closeErr = fmt.Errorf("close tracked file %q: %w", trackedPath, closeErr) + } + if countErr != nil || closeErr != nil { + return Snapshot{}, errors.Join(countErr, closeErr) + } + if !generated { + files[trackedPath] = lines + } + } + return Snapshot{Files: files, Dirs: dirs, Functions: nil}, nil +} + +func countedPath(path string, cfg Config) bool { + for segment := range strings.SplitSeq(path, "/") { + if segment == "testdata" || segment == "fixtures" { + return false + } + } + extension := filepath.Ext(path) + if !slices.Contains(cfg.Extensions, extension) { + return false + } + return !matchesAny(path, cfg.Exclude) +} + +func matchesAny(path string, patterns []string) bool { + for _, pattern := range patterns { + if matchGlob(pattern, path) { + return true + } + } + return false +} + +func matchGlob(pattern, path string) bool { + patternParts := strings.Split(strings.Trim(pattern, "/"), "/") + pathParts := strings.Split(strings.Trim(path, "/"), "/") + return matchGlobParts(patternParts, pathParts) +} + +func matchGlobParts(pattern, path []string) bool { + if len(pattern) == 0 { + return len(path) == 0 + } + if pattern[0] == "**" { + if matchGlobParts(pattern[1:], path) { + return true + } + return len(path) > 0 && matchGlobParts(pattern, path[1:]) + } + if len(path) == 0 { + return false + } + matched, err := filepath.Match(pattern[0], path[0]) + if err != nil || !matched { + return false + } + return matchGlobParts(pattern[1:], path[1:]) +} + +func physicalLines(source io.Reader) (int, bool, error) { + const bufferSize = 4096 + const markerOverlap = len("Code generated") - 1 + reader := bufio.NewReaderSize(source, bufferSize) + var window [bufferSize + markerOverlap]byte + var carry [markerOverlap]byte + var carryLen int + var hasGeneratedMarker, hasCodeGenerated, hasDoNotEdit bool + lineHasBytes := false + count := 0 + generated := false + observe := func(chunk []byte) { + if count >= 5 { + return + } + combined := copy(window[:], carry[:carryLen]) + combined += copy(window[combined:], chunk) + line := window[:combined] + hasGeneratedMarker = hasGeneratedMarker || bytes.Contains(line, []byte("@generated")) + hasCodeGenerated = hasCodeGenerated || bytes.Contains(line, []byte("Code generated")) + hasDoNotEdit = hasDoNotEdit || bytes.Contains(line, []byte("DO NOT EDIT.")) + carryLen = min(len(line), len(carry)) + copy(carry[:], line[len(line)-carryLen:]) + } + 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 + } + if errors.Is(readErr, io.EOF) { + break + } + if readErr != nil && !errors.Is(readErr, bufio.ErrBufferFull) { + return 0, false, fmt.Errorf("scan source lines: %w", readErr) + } + } + return count, generated, nil +} diff --git a/go/internal/codeshape/inventory/inventory.go b/go/internal/codeshape/inventory/inventory.go deleted file mode 100644 index 9f2cedf..0000000 --- a/go/internal/codeshape/inventory/inventory.go +++ /dev/null @@ -1,156 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package inventory - -import ( - "bufio" - "bytes" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "slices" - "strings" - - "github.com/RigelBuild/meissa/go/internal/codeshape/model" -) - -// Inventory measures tracked source files and direct tracked files per directory. -func Inventory(paths []string, cfg model.Config, repository *os.Root) (model.Snapshot, error) { - files := map[string]int{} - dirs := map[string]int{} - for _, path := range paths { - if err := inventoryPath(path, cfg, repository, files, dirs); err != nil { - return model.Snapshot{}, err - } - } - return model.Snapshot{Files: files, Dirs: dirs}, nil -} - -func inventoryPath(path string, cfg model.Config, repository *os.Root, files, dirs map[string]int) error { - trackedPath := filepath.ToSlash(path) - if MatchesAny(trackedPath, cfg.Exclude) { - return nil - } - parent := filepath.ToSlash(filepath.Dir(filepath.FromSlash(trackedPath))) - if parent == "." { - parent = "" - } - dirs[parent]++ - if !CountedPath(trackedPath, cfg) { - return nil - } - file, err := repository.Open(filepath.FromSlash(trackedPath)) - if err != nil { - return fmt.Errorf("open tracked file %q: %w", trackedPath, err) - } - lines, generated, countErr := physicalLines(file) - closeErr := file.Close() - if countErr != nil { - countErr = fmt.Errorf("count lines in %q: %w", trackedPath, countErr) - } - if closeErr != nil { - closeErr = fmt.Errorf("close tracked file %q: %w", trackedPath, closeErr) - } - if countErr != nil || closeErr != nil { - return errors.Join(countErr, closeErr) - } - if !generated { - files[trackedPath] = lines - } - return nil -} - -// CountedPath reports whether a path participates in configured source measurements. -func CountedPath(path string, cfg model.Config) bool { - for segment := range strings.SplitSeq(path, "/") { - if segment == "testdata" || segment == "fixtures" { - return false - } - } - return slices.Contains(cfg.Extensions, filepath.Ext(path)) && !MatchesAny(path, cfg.Exclude) -} - -// MatchesAny reports whether a repository path matches one configured glob. -func MatchesAny(path string, patterns []string) bool { - for _, pattern := range patterns { - if MatchGlob(pattern, path) { - return true - } - } - return false -} - -// MatchGlob matches a slash-separated repository path against a glob. -func MatchGlob(pattern, path string) bool { - return matchGlobParts(strings.Split(strings.Trim(pattern, "/"), "/"), strings.Split(strings.Trim(path, "/"), "/")) -} - -func matchGlobParts(pattern, path []string) bool { - if len(pattern) == 0 { - return len(path) == 0 - } - if pattern[0] == "**" { - return matchGlobParts(pattern[1:], path) || len(path) > 0 && matchGlobParts(pattern, path[1:]) - } - if len(path) == 0 { - return false - } - matched, err := filepath.Match(pattern[0], path[0]) - return err == nil && matched && matchGlobParts(pattern[1:], path[1:]) -} - -func physicalLines(source io.Reader) (int, bool, error) { - const bufferSize = 4096 - const markerOverlap = len("Code generated") - 1 - reader := bufio.NewReaderSize(source, bufferSize) - var window [bufferSize + markerOverlap]byte - var carry [markerOverlap]byte - var carryLen int - var hasGeneratedMarker, hasCodeGenerated, hasDoNotEdit bool - lineHasBytes, generated := false, false - count := 0 - observe := func(chunk []byte) { - if count >= 5 { - return - } - combined := copy(window[:], carry[:carryLen]) - combined += copy(window[combined:], chunk) - line := window[:combined] - hasGeneratedMarker = hasGeneratedMarker || bytes.Contains(line, []byte("@generated")) - hasCodeGenerated = hasCodeGenerated || bytes.Contains(line, []byte("Code generated")) - hasDoNotEdit = hasDoNotEdit || bytes.Contains(line, []byte("DO NOT EDIT.")) - carryLen = min(len(line), len(carry)) - copy(carry[:], line[len(line)-carryLen:]) - } - 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, lineHasBytes = 0, false - hasGeneratedMarker, hasCodeGenerated, hasDoNotEdit = false, false, false - } - if errors.Is(readErr, io.EOF) { - break - } - if readErr != nil && !errors.Is(readErr, bufio.ErrBufferFull) { - return 0, false, fmt.Errorf("scan source lines: %w", readErr) - } - } - return count, generated, nil -} diff --git a/go/internal/codeshape/inventory/inventory_test.go b/go/internal/codeshape/inventory/inventory_test.go deleted file mode 100644 index 69cb440..0000000 --- a/go/internal/codeshape/inventory/inventory_test.go +++ /dev/null @@ -1,65 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package inventory_test - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/RigelBuild/meissa/go/internal/codeshape/inventory" - "github.com/RigelBuild/meissa/go/internal/codeshape/judge" - "github.com/RigelBuild/meissa/go/internal/codeshape/model" - "github.com/RigelBuild/meissa/go/internal/codeshape/testsupport" -) - -func TestInventory(t *testing.T) { - root := testsupport.NewFixtureRepo(t) - testsupport.TrackedFile(t, root, "large.go", strings.Repeat("line\n", 501)) - testsupport.TrackedFile(t, root, "generated.go", "// Code generated by tool. DO NOT EDIT.\nsource\n") - testsupport.CommitFiles(t, root) - paths, err := inventory.TrackedPaths(t.Context(), root) - if err != nil { - t.Fatal(err) - } - repository, err := os.OpenRoot(root) - if err != nil { - t.Fatal(err) - } - defer repository.Close() - snapshot, err := inventory.Inventory(paths, testsupport.Config(), repository) - if err != nil { - t.Fatal(err) - } - if snapshot.Files["large.go"] != 501 { - t.Fatalf("files = %#v", snapshot.Files) - } - if _, exists := snapshot.Files["generated.go"]; exists { - t.Fatalf("generated source counted: %#v", snapshot.Files) - } - findings := judge.Judge(testsupport.Config(), model.Allowlist{}, model.Allowlist{}, snapshot) - if len(findings) != 1 || findings[0].Metric != model.MetricFiles { - t.Fatalf("findings = %#v", findings) - } -} - -func TestInventoryRejectsSymlinkEscape(t *testing.T) { - root := testsupport.NewFixtureRepo(t) - outside := filepath.Join(t.TempDir(), "outside.go") - if err := os.WriteFile(outside, []byte(strings.Repeat("line\n", 600)), 0o600); err != nil { - t.Fatal(err) - } - if err := os.Symlink(outside, filepath.Join(root, "escape.go")); err != nil { - t.Fatal(err) - } - paths := []string{"escape.go"} - repository, err := os.OpenRoot(root) - if err != nil { - t.Fatal(err) - } - defer repository.Close() - if _, err := inventory.Inventory(paths, testsupport.Config(), repository); err == nil { - t.Fatal("inventory followed a source symlink outside repository") - } -} diff --git a/go/internal/codeshape/inventory/paths.go b/go/internal/codeshape/inventory/paths.go deleted file mode 100644 index a599435..0000000 --- a/go/internal/codeshape/inventory/paths.go +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package inventory - -import ( - "context" - "fmt" - "os/exec" - "strings" -) - -// TrackedPaths returns tracked paths as repository-relative slash-separated names. -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 new file mode 100644 index 0000000..67e61d9 --- /dev/null +++ b/go/internal/codeshape/inventory_test.go @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "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 TestInventory(t *testing.T) { + t.Run("file one line over limit is measured", func(t *testing.T) { + root := newFixtureRepo(t) + trackedFile(t, root, "large.go", strings.Repeat("line\n", 501)) + commitFiles(t, root) + snapshot, err := Inventory(context.Background(), root, testConfig()) + if err != nil { + t.Fatal(err) + } + if snapshot.Files["large.go"] != 501 { + t.Fatalf("file lines = %d, want 501", snapshot.Files["large.go"]) + } + 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) + } + }) + t.Run("testdata files skip file length", func(t *testing.T) { + root := newFixtureRepo(t) + trackedFile(t, root, "pkg/testdata/large.go", strings.Repeat("line\n", 501)) + commitFiles(t, root) + snapshot, err := Inventory(context.Background(), root, testConfig()) + if err != nil { + t.Fatal(err) + } + if _, exists := snapshot.Files["pkg/testdata/large.go"]; exists { + t.Fatal("testdata file counted toward file length") + } + if snapshot.Dirs["pkg/testdata"] != 1 { + t.Fatalf("testdata direct files = %d, want 1", snapshot.Dirs["pkg/testdata"]) + } + }) + t.Run("fixtures files skip file length", func(t *testing.T) { + root := newFixtureRepo(t) + trackedFile(t, root, "pkg/fixtures/large.go", strings.Repeat("line\n", 501)) + commitFiles(t, root) + snapshot, err := Inventory(context.Background(), root, testConfig()) + if err != nil { + t.Fatal(err) + } + if _, exists := snapshot.Files["pkg/fixtures/large.go"]; exists { + t.Fatal("fixtures file counted toward file length") + } + }) +} + +func TestInventoryGeneratedAndExcluded(t *testing.T) { + t.Run("generated header in first five lines skips file length", func(t *testing.T) { + root := newFixtureRepo(t) + trackedFile(t, root, "generated.go", "// Code generated by tool. DO NOT EDIT.\n"+strings.Repeat("line\n", 501)) + commitFiles(t, root) + snapshot, err := Inventory(context.Background(), root, testConfig()) + if err != nil { + t.Fatal(err) + } + if _, exists := snapshot.Files["generated.go"]; exists { + t.Fatal("generated file counted toward file length") + } + }) + t.Run("at generated marker skips file length", func(t *testing.T) { + lines, generated, err := physicalLines(strings.NewReader("one\ntwo\nthree\nfour\n// @generated\nsix\n")) + if err != nil || !generated || lines != 6 { + t.Fatalf("lines=%d generated=%v error=%v", lines, generated, err) + } + }) + t.Run("generated marker after fifth line is counted", func(t *testing.T) { + _, generated, err := physicalLines(strings.NewReader("one\ntwo\nthree\nfour\nfive\n// @generated\n")) + if err != nil || generated { + t.Fatalf("generated=%v error=%v", generated, err) + } + }) + t.Run("exclude glob skips file length and directory width", func(t *testing.T) { + root := newFixtureRepo(t) + trackedFile(t, root, "pkg/large.go", strings.Repeat("line\n", 501)) + commitFiles(t, root) + config := testConfig() + config.Exclude = []string{"pkg/**"} + snapshot, err := Inventory(context.Background(), root, config) + if err != nil { + t.Fatal(err) + } + if _, exists := snapshot.Files["pkg/large.go"]; exists || snapshot.Dirs["pkg"] != 0 { + t.Fatalf("exclude applied incorrectly: %#v", snapshot) + } + }) +} + +func TestPhysicalLinesLongLine(t *testing.T) { + content := strings.Repeat("x", 65_530) + "@generated\nsecond\n" + lines, generated, err := physicalLines(strings.NewReader(content)) + if err != nil { + t.Fatal(err) + } + if lines != 2 || !generated { + t.Fatalf("lines=%d generated=%v, want 2 lines and generated", lines, generated) + } + lines, generated, err = physicalLines(strings.NewReader(strings.Repeat("x", 70_000))) + if err != nil || lines != 1 || generated { + t.Fatalf("unterminated line: lines=%d generated=%v error=%v", lines, generated, err) + } +} +func TestInventoryDirectoryWidth(t *testing.T) { + t.Run("directory width counts all tracked file types at eleven", func(t *testing.T) { + root := newFixtureRepo(t) + for index := range 11 { + trackedFile(t, root, fmt.Sprintf("wide/file-%02d.txt", index), "text\n") + } + commitFiles(t, root) + snapshot, err := Inventory(context.Background(), root, testConfig()) + if err != nil { + t.Fatal(err) + } + if snapshot.Dirs["wide"] != 11 { + t.Fatalf("directory files = %d, want 11", snapshot.Dirs["wide"]) + } + 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) + } + }) + t.Run("directory width ten passes", func(t *testing.T) { + root := newFixtureRepo(t) + for index := range 10 { + trackedFile(t, root, fmt.Sprintf("wide/file-%02d.txt", index), "text\n") + } + commitFiles(t, root) + snapshot, err := Inventory(context.Background(), root, testConfig()) + if err != nil { + t.Fatal(err) + } + if findings := Judge(testConfig(), emptyAllowlist(), emptyAllowlist(), snapshot); len(findings) != 0 { + t.Fatalf("10-file directory findings = %#v", findings) + } + }) +} + +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}}, + } +} diff --git a/go/internal/codeshape/judge.go b/go/internal/codeshape/judge.go new file mode 100644 index 0000000..b2c249b --- /dev/null +++ b/go/internal/codeshape/judge.go @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +import ( + "sort" +) + +// Judge compares current measurements with thresholds and allowlists. +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, 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, + Metric: MetricFiles, + Value: 0, + Limit: cfg.FileLimit, + Fix: "remove the stale allowlist entry", + }) + } + } + 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), + Fix: "remove the stale allowlist entry", + }) + } + } + 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) + findings := make([]Finding, 0) + for _, path := range paths { + value := values[path] + limit := cfg.limitFor(metric, path) + entry, listed := entries[path] + if !listed { + if value > limit { + findings = append(findings, Finding{ + Path: displayPath(metric, path), + Metric: metric, + Value: value, + Limit: limit, + Fix: fixForPath(cfg, metric, path), + }) + } + continue + } + if value <= limit { + findings = append(findings, Finding{ + Path: displayPath(metric, path), + Metric: metric, + Value: value, + Limit: limit, + Fix: "remove the allowlist entry after splitting or shrinking", + }) + continue + } + if value > entry.Value { + findings = append(findings, Finding{ + Path: displayPath(metric, path), + Metric: metric, + Value: value, + Limit: entry.Value, + Fix: "grew: split the offender instead of growing its recorded value", + }) + } + } + return findings +} + +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", + }) + } + } + } + } + return findings +} + +func judgeBaseEntries(allow, base Allowlist) []Finding { + if !base.Present { + return nil + } + findings := []Finding{} + for _, group := range []struct { + metric Metric + head, base map[string]Entry + }{{MetricFiles, allow.Files, base.Files}, {MetricDirs, allow.Dirs, base.Dirs}} { + for path, entry := range group.head { + 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", + }) + } + } + } + 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 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 +} + +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) +} + +func sortedEntryPaths[V any](entries map[string]V) []string { + paths := make([]string, 0, len(entries)) + for path := range entries { + paths = append(paths, path) + } + sort.Strings(paths) + return paths +} + +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 + } + return findings[i].Metric < findings[j].Metric + }) +} + +func displayPath(metric Metric, path string) string { + if metric == MetricDirs { + return displayDir(path) + } + return path +} + +func displayDir(path string) string { + if path == "" { + return "." + } + return path +} + +func fixFor(metric Metric) string { + if metric == MetricDirs { + return "split the directory by concern" + } + return "split this file by concern" +} diff --git a/go/internal/codeshape/judge/judge.go b/go/internal/codeshape/judge/judge.go deleted file mode 100644 index 57b9c92..0000000 --- a/go/internal/codeshape/judge/judge.go +++ /dev/null @@ -1,196 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package judge - -import ( - "sort" - - "github.com/RigelBuild/meissa/go/internal/codeshape/inventory" - "github.com/RigelBuild/meissa/go/internal/codeshape/model" -) - -// Judge compares current measurements with thresholds and allowlists. -func Judge(cfg model.Config, allow, baseAllow model.Allowlist, snap model.Snapshot) []model.Finding { - findings := append(judgeMetric(cfg, snap.Files, allow.Files, model.MetricFiles), - judgeMetric(cfg, snap.Dirs, allow.Dirs, model.MetricDirs)...) - findings = append(findings, judgeFunctions(cfg, snap.Functions, allow.Functions)...) - findings = append(findings, judgeBaseEntries(allow, baseAllow)...) - findings = append(findings, staleEntries(cfg, allow, snap)...) - sortFindings(findings) - return findings -} - -func staleEntries(cfg model.Config, allow model.Allowlist, snap model.Snapshot) []model.Finding { - var findings []model.Finding - for path := range allow.Files { - if _, exists := snap.Files[path]; !exists { - findings = append(findings, model.Finding{Path: path, Metric: model.MetricFiles, - Limit: cfg.FileLimit, Fix: "remove the stale allowlist entry"}) - } - } - for path := range allow.Dirs { - if _, exists := snap.Dirs[path]; !exists { - findings = append(findings, model.Finding{Path: displayDir(path), Metric: model.MetricDirs, - Limit: cfgLimit(cfg, model.MetricDirs, path), Fix: "remove the stale allowlist entry"}) - } - } - for metric, paths := range allow.Functions { - for path, entries := range paths { - for _, entry := range entries { - if _, exists := model.FunctionEntry(snap.Functions[metric][path], entry.ID); !exists { - findings = append(findings, model.Finding{Path: path + "#" + entry.ID, Metric: metric, - Limit: cfg.Functions.LimitFor(metric), Fix: "remove the stale allowlist entry"}) - } - } - } - } - return findings -} - -func judgeMetric(cfg model.Config, values map[string]int, entries map[string]model.Entry, metric model.Metric) []model.Finding { - paths := sortedPaths(values) - var findings []model.Finding - for _, path := range paths { - value, limit := values[path], cfgLimit(cfg, metric, path) - entry, listed := entries[path] - if !listed && value > limit { - findings = append(findings, model.Finding{Path: displayPath(metric, path), Metric: metric, - Value: value, Limit: limit, Fix: fixForPath(cfg, metric, path)}) - continue - } - if !listed { - continue - } - if value <= limit { - findings = append(findings, model.Finding{Path: displayPath(metric, path), Metric: metric, - Value: value, Limit: limit, Fix: "remove the allowlist entry after splitting or shrinking"}) - } else if value > entry.Value { - findings = append(findings, model.Finding{Path: displayPath(metric, path), Metric: metric, - Value: value, Limit: entry.Value, Fix: "grew: split the offender instead of growing its recorded value"}) - } - } - return findings -} - -func judgeFunctions(cfg model.Config, values, entries model.FunctionMeasurements) []model.Finding { - var findings []model.Finding - for _, metric := range model.FunctionMetrics() { - for _, path := range sortedPaths(values[metric]) { - for _, current := range values[metric][path] { - previous, listed := model.FunctionEntry(entries[metric][path], current.ID) - findings = append(findings, judgeFunction(cfg, metric, path, current, previous, listed)...) - } - } - } - return findings -} - -func judgeFunction(cfg model.Config, metric model.Metric, path string, current, previous model.Entry, listed bool) []model.Finding { - limit, findingPath := cfg.Functions.LimitFor(metric), path+"#"+current.ID - if !listed && current.Value > limit { - return []model.Finding{{Path: findingPath, Metric: metric, Value: current.Value, Limit: limit, - Fix: "split this function by concern"}} - } - if !listed { - return nil - } - if current.Value <= limit { - return []model.Finding{{Path: findingPath, Metric: metric, Value: current.Value, Limit: limit, - Fix: "remove the allowlist entry after splitting or shrinking"}} - } - if current.Value > previous.Value { - return []model.Finding{{Path: findingPath, Metric: metric, Value: current.Value, Limit: previous.Value, - Fix: "grew: split the offender instead of growing its recorded value"}} - } - return nil -} - -func judgeBaseEntries(allow, base model.Allowlist) []model.Finding { - if !base.Present { - return nil - } - var findings []model.Finding - for _, group := range []struct { - metric model.Metric - head map[string]model.Entry - base map[string]model.Entry - }{{model.MetricFiles, allow.Files, base.Files}, {model.MetricDirs, allow.Dirs, base.Dirs}} { - for path, entry := range group.head { - previous, exists := group.base[path] - if !exists || entry.Value > previous.Value { - findings = append(findings, model.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"}) - } - } - } - for metric, paths := range allow.Functions { - for path, entries := range paths { - for _, entry := range entries { - previous, exists := model.FunctionEntry(base.Functions[metric][path], entry.ID) - if !exists || entry.Value > previous.Value { - findings = append(findings, model.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 cfgLimit(cfg model.Config, metric model.Metric, path string) int { - if metric != model.MetricDirs { - return cfg.FileLimit - } - limit := 0 - for pattern, entry := range cfg.DirLimits { - if inventory.MatchGlob(pattern, path) && (limit == 0 || entry.Limit < limit) { - limit = entry.Limit - } - } - if limit == 0 { - return cfg.DirLimit - } - return limit -} - -func fixForPath(cfg model.Config, metric model.Metric, path string) string { - if metric == model.MetricDirs && cfgLimit(cfg, metric, path) != cfg.DirLimit { - return "reorganize the record's decisions" - } - if metric == model.MetricDirs { - return "split the directory by concern" - } - return "split this file by concern" -} - -func displayPath(metric model.Metric, path string) string { - if metric == model.MetricDirs { - return displayDir(path) - } - return path -} - -func displayDir(path string) string { - if path == "" { - return "." - } - return path -} - -func sortedPaths[V any](entries map[string]V) []string { - paths := make([]string, 0, len(entries)) - for path := range entries { - paths = append(paths, path) - } - sort.Strings(paths) - return paths -} - -func sortFindings(findings []model.Finding) { - sort.Slice(findings, func(i, j int) bool { - if findings[i].Path != findings[j].Path { - return findings[i].Path < findings[j].Path - } - return findings[i].Metric < findings[j].Metric - }) -} diff --git a/go/internal/codeshape/judge/judge_test.go b/go/internal/codeshape/judge/judge_test.go deleted file mode 100644 index 9504bd4..0000000 --- a/go/internal/codeshape/judge/judge_test.go +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package judge_test - -import ( - "testing" - - "github.com/RigelBuild/meissa/go/internal/codeshape/judge" - "github.com/RigelBuild/meissa/go/internal/codeshape/model" - "github.com/RigelBuild/meissa/go/internal/codeshape/testsupport" -) - -func TestJudgeReportsFileAndFunctionFindings(t *testing.T) { - cfg := testsupport.Config() - allow := model.Allowlist{Files: map[string]model.Entry{}, Dirs: map[string]model.Entry{}} - snapshot := model.Snapshot{ - Files: map[string]int{"large.go": 501}, Dirs: map[string]int{}, - Functions: model.FunctionMeasurements{model.MetricGoCognitive: {"large.go": {{ID: "work", Value: 21}}}}, - } - findings := judge.Judge(cfg, allow, model.Allowlist{}, snapshot) - if len(findings) != 2 || findings[0].Metric != model.MetricFiles || findings[1].Metric != model.MetricGoCognitive { - t.Fatalf("findings = %#v", findings) - } -} - -func TestJudgeEnforcesBaseFunctionRatchets(t *testing.T) { - cfg := testsupport.Config() - base := model.Allowlist{Present: true, Functions: model.FunctionMeasurements{ - model.MetricGoLines: {"large.go": {{ID: "work", Value: 70}}}, - }} - head := model.Allowlist{Functions: model.FunctionMeasurements{ - model.MetricGoLines: {"large.go": {{ID: "work", Value: 71}}}, - }} - snapshot := model.Snapshot{Files: map[string]int{}, Dirs: map[string]int{}, Functions: head.Functions} - findings := judge.Judge(cfg, head, base, snapshot) - if len(findings) != 1 || findings[0].Limit != 70 { - t.Fatalf("findings = %#v", findings) - } -} diff --git a/go/internal/codeshape/judge_test.go b/go/internal/codeshape/judge_test.go new file mode 100644 index 0000000..d6ba80a --- /dev/null +++ b/go/internal/codeshape/judge_test.go @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +import ( + "strings" + "testing" +) + +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": {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{}, + Functions: nil, + } + 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": {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{}, + Functions: nil, + } + 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": {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{}, + Functions: nil, + }) + if !hasFinding(findings, "gone.go", MetricFiles, 0, 500, "remove") { + t.Fatalf("missing stale entry finding: %#v", findings) + } + }) +} + +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": {ID: "", Value: 501, Reason: "split by concern"}}, + Dirs: map[string]Entry{}, Functions: nil, Present: false, + } + base := emptyAllowlist() + base.Present = true + findings := Judge(config, allow, base, Snapshot{ + 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) + } + }) + t.Run("base rejects raised entry", func(t *testing.T) { + allow := Allowlist{ + 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": {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{}, + Functions: nil, + }) + if !hasFinding(findings, "large.go", MetricFiles, 502, 501, "do not add") { + t.Fatalf("missing raised-entry finding: %#v", findings) + } + }) + t.Run("base absence is head only", func(t *testing.T) { + allow := Allowlist{ + 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{}, + Functions: nil, + } + 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": {ID: "", Value: 501, Reason: "split by concern"}}, + Dirs: map[string]Entry{}, Functions: nil, Present: false, + } + base := emptyAllowlist() + base.Present = true + findings := Judge(config, allow, base, Snapshot{ + 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) + } + }) +} + +func hasFinding(findings []Finding, path string, metric Metric, value, limit int, fragment string) bool { + for _, finding := range findings { + if finding.Path == path && + finding.Metric == metric && + finding.Value == value && + finding.Limit == limit && + strings.Contains(finding.Fix, fragment) { + return true + } + } + 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/load.go b/go/internal/codeshape/load.go new file mode 100644 index 0000000..6891f43 --- /dev/null +++ b/go/internal/codeshape/load.go @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +// Package codeshape measures tracked source and directory sizes. +package codeshape + +import ( + "embed" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/ast" + "cuelang.org/go/cue/cuecontext" + "cuelang.org/go/cue/parser" +) + +var ( + errInvalidThresholds = errors.New("config thresholds must be fileLimit 500 and dirLimit 10") + 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 + +// 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. +// 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"` + Functions map[Metric]map[string][]Entry `json:"functions,omitempty"` + Present bool `json:"-"` +} + +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 +} + +func schemaValue(ctx *cue.Context) (cue.Value, error) { + data, err := schemaFiles.ReadFile("schema.cue") + if err != nil { + return cue.Value{}, fmt.Errorf("read embedded schema: %w", err) + } + value := ctx.CompileBytes(data) + err = value.Err() + if err != nil { + return cue.Value{}, fmt.Errorf("compile embedded schema: %w", err) + } + return value, nil +} + +// Load reads and validates the repository's CUE configuration and allowlist. +func Load(root string) (Config, Allowlist, error) { + repository, err := os.OpenRoot(root) + if err != nil { + return Config{}, Allowlist{}, fmt.Errorf("open repository root: %w", err) + } + config, allowlist, loadErr := loadFromRoot(repository) + closeErr := repository.Close() + if closeErr != nil { + closeErr = fmt.Errorf("close repository root: %w", closeErr) + } + return config, allowlist, errors.Join(loadErr, closeErr) +} + +func loadFromRoot(repository *os.Root) (Config, Allowlist, error) { + ctx := cuecontext.New() + schema, err := schemaValue(ctx) + if err != nil { + return Config{}, Allowlist{}, err + } + + configValue, err := loadCUEFile(ctx, repository, "tools/code-shape-gate/config.cue") + if err != nil { + return Config{}, 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) + } + var config Config + err = configValue.Decode(&config) + if err != nil { + return Config{}, Allowlist{}, fmt.Errorf("decode config.cue: %w", err) + } + err = validateConfig(config) + if err != nil { + return Config{}, Allowlist{}, err + } + + allowValue, err := loadCUEFile(ctx, repository, "tools/code-shape-gate/allowlist.cue") + if err != nil { + return Config{}, Allowlist{}, err + } + allowlist, err := decodeAllowlist(schema, allowValue, "allowlist.cue") + if err != nil { + return Config{}, Allowlist{}, err + } + allowlist.Present = true + return config, allowlist, nil +} + +func loadCUEFile(ctx *cue.Context, repository *os.Root, name string) (cue.Value, error) { + data, err := repository.ReadFile(name) + if err != nil { + return cue.Value{}, fmt.Errorf("read %s: %w", name, err) + } + return compileCUEFile(ctx, data, name) +} + +func compileCUEFile(ctx *cue.Context, data []byte, name string) (cue.Value, error) { + file, err := parser.ParseFile(name, data) + if err != nil { + return cue.Value{}, fmt.Errorf("parse %s: %w", name, err) + } + for _, declaration := range file.Decls { + if _, ok := declaration.(*ast.ImportDecl); ok { + return cue.Value{}, fmt.Errorf("%s: %w", name, errImportsForbidden) + } + } + value := ctx.BuildFile(file) + err = value.Err() + if err != nil { + return cue.Value{}, fmt.Errorf("evaluate %s: %w", name, err) + } + return value, nil +} + +func decodeAllowlistBytes(ctx *cue.Context, schema cue.Value, data []byte, name string) (Allowlist, error) { + value, err := compileCUEFile(ctx, data, name) + if err != nil { + return Allowlist{}, err + } + return decodeAllowlist(schema, value, name) +} + +func decodeAllowlist(schema, value cue.Value, name string) (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) + } + var allowlist Allowlist + err = value.Decode(&allowlist) + if err != nil { + return Allowlist{}, fmt.Errorf("decode %s: %w", name, err) + } + if allowlist.Files == nil { + allowlist.Files = map[string]Entry{} + } + if allowlist.Dirs == nil { + allowlist.Dirs = map[string]Entry{} + } + if allowlist.Functions == nil { + allowlist.Functions = map[Metric]map[string][]Entry{} + } + for metric, paths := range allowlist.Functions { + if !isFunctionMetric(metric) { + return Allowlist{}, fmt.Errorf("%s: %w %q", name, errUnsupportedMetric, metric) + } + err := validateFunctionEntryIDs(paths) + if err != nil { + return 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) + } + allowlist.Dirs[""] = entry + delete(allowlist.Dirs, ".") + } + return allowlist, nil +} + +func validateConfig(config 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) + } + } + for _, pattern := range config.Exclude { + for segment := range strings.SplitSeq(pattern, "/") { + if segment == "**" { + continue + } + _, err := filepath.Match(segment, "") + if err != nil { + return fmt.Errorf("invalid exclude glob %q: %w", pattern, err) + } + } + } + return nil +} diff --git a/go/internal/codeshape/load_test.go b/go/internal/codeshape/load_test.go new file mode 100644 index 0000000..db72dd7 --- /dev/null +++ b/go/internal/codeshape/load_test.go @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoad(t *testing.T) { + t.Run("valid CUE configuration and allowlist", func(t *testing.T) { + root := newFixtureRepo(t) + writeConfigFiles(t, root, validConfig, "files: {}\ndirs: {}\n") + _, _, err := Load(root) + if err != nil { + t.Fatal(err) + } + }) + t.Run("invalid config fails", func(t *testing.T) { + root := newFixtureRepo(t) + writeConfigFiles(t, root, "fileLimit: 500\ndirLimit: 10\nextensions: [\".go\"]\nexclude: [\"[\"]\n", "files: {}\n") + _, _, err := Load(root) + if err == nil { + t.Fatal("Load accepted an invalid config") + } + }) + t.Run("missing reason fails", func(t *testing.T) { + root := newFixtureRepo(t) + writeConfigFiles(t, root, validConfig, "files: \"large.go\": {value: 501}\n") + _, _, err := Load(root) + if err == nil { + t.Fatal("Load accepted an entry without a reason") + } + }) + t.Run("short reason fails", func(t *testing.T) { + root := newFixtureRepo(t) + writeConfigFiles(t, root, validConfig, "files: \"large.go\": {value: 501, reason: \"split files\"}\n") + _, _, err := Load(root) + if err == nil { + t.Fatal("Load accepted a reason with fewer than three words") + } + }) +} + +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[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"}`, + `dirLimits: "wide": {limit: 0, reason: "record decisions together"}`, + `dirLimits: "wide": {limit: 50, reason: "two words"}`, + } { + t.Run(config, func(t *testing.T) { + root := newFixtureRepo(t) + writeConfigFiles(t, root, validConfig+config+"\n", "files: {}\n") + _, _, err := Load(root) + if err == nil { + t.Fatalf("Load accepted invalid config %s", config) + } + }) + } +} + +func TestLoadRejectsImports(t *testing.T) { + root := newFixtureRepo(t) + writeConfigFiles(t, root, "import \"strings\"\n"+validConfig+"\nx: strings.ToUpper(\"x\")\n", "files: {}\n") + _, _, err := Load(root) + if err == nil || !strings.Contains(err.Error(), "imports are not permitted") { + t.Fatalf("Load import error = %v", err) + } +} + +func TestLoadRejectsEscapingConfig(t *testing.T) { + root := newFixtureRepo(t) + writeConfigFiles(t, root, validConfig, "files: {}\n") + outside := filepath.Join(t.TempDir(), "config.cue") + err := os.WriteFile(outside, []byte(validConfig), 0o600) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "tools/code-shape-gate/config.cue") + err = os.Remove(path) + if err != nil { + t.Fatal(err) + } + err = os.Symlink(outside, path) + if err != nil { + t.Fatal(err) + } + _, _, err = Load(root) + if err == nil { + t.Fatal("Load accepted a config symlink outside the repository") + } +} + +func TestLoadRootDirectoryEntry(t *testing.T) { + root := newFixtureRepo(t) + writeConfigFiles(t, root, validConfig, `dirs: ".": {value: 11, reason: "split root files"}`) + _, allow, err := Load(root) + if err != nil || allow.Dirs[""].Value != 11 { + t.Fatalf("root entry = %#v, error = %v", allow.Dirs, err) + } +} + +func TestLoadMissingFiles(t *testing.T) { + root := newFixtureRepo(t) + writeConfigFiles(t, root, validConfig, "files: {}\n") + err := os.Remove(filepath.Join(root, "tools", "code-shape-gate", "allowlist.cue")) + if err != nil { + t.Fatal(err) + } + _, _, err = Load(root) + if err == nil || !strings.Contains(err.Error(), "allowlist.cue") { + t.Fatalf("Load error = %v, want missing allowlist", err) + } +} + +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) + } + } +} diff --git a/go/internal/codeshape/metrics.go b/go/internal/codeshape/metrics.go new file mode 100644 index 0000000..bef0052 --- /dev/null +++ b/go/internal/codeshape/metrics.go @@ -0,0 +1,20 @@ +// 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" +) + +// FunctionMeasurements maps each metric and source path to individual functions. +type FunctionMeasurements = map[Metric]map[string][]Entry diff --git a/go/internal/codeshape/model/config.go b/go/internal/codeshape/model/config.go deleted file mode 100644 index b195641..0000000 --- a/go/internal/codeshape/model/config.go +++ /dev/null @@ -1,146 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package model - -import ( - "path/filepath" - "strings" -) -// FunctionLimits contains per-language function thresholds. -type FunctionLimits struct { - Go GoFunctionLimits `json:"go"` - TS TSFunctionLimits `json:"ts"` -} - -// GoFunctionLimits contains Go function thresholds. -type GoFunctionLimits struct { - Cognitive int `json:"cognitive"` - Lines int `json:"lines"` - Statements int `json:"statements"` -} - -// TSFunctionLimits contains TypeScript function thresholds. -type TSFunctionLimits struct { - Cognitive int `json:"cognitive"` - Lines int `json:"lines"` -} - -// LimitFor returns a 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 - default: - return 0 - } -} -// Config contains file, directory, exclusion, 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. -// 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"` - Functions FunctionMeasurements `json:"functions,omitempty"` - Present bool `json:"-"` -} - -// Snapshot contains current measurements. -type Snapshot struct { - Files map[string]int - Dirs map[string]int - Functions FunctionMeasurements -} - -// Finding describes one enforced size or ratchet violation. -type Finding struct { - Path string - Metric Metric - Value int - Limit int - Fix string -} - -// Skip records a source file that could not be measured. -type Skip struct { - Path string - Reason string -} -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 matchGlob(pattern, path string) bool { - patternParts := strings.Split(strings.Trim(pattern, "/"), "/") - pathParts := strings.Split(strings.Trim(path, "/"), "/") - return matchGlobParts(patternParts, pathParts) -} - -func matchGlobParts(pattern, path []string) bool { - if len(pattern) == 0 { - return len(path) == 0 - } - if pattern[0] == "**" { - return matchGlobParts(pattern[1:], path) || len(path) > 0 && matchGlobParts(pattern, path[1:]) - } - if len(path) == 0 { - return false - } - matched, err := filepath.Match(pattern[0], path[0]) - return err == nil && matched && matchGlobParts(pattern[1:], path[1:]) -} -// StandardConfig returns default gate thresholds for tools and tests. -func StandardConfig() Config { - return Config{ - FileLimit: 500, - DirLimit: 10, - Extensions: []string{".go", ".cue"}, - Exclude: []string{}, - DirLimits: map[string]DirLimit{}, - Functions: FunctionLimits{ - Go: GoFunctionLimits{Cognitive: 20, Lines: 60, Statements: 40}, - TS: TSFunctionLimits{Cognitive: 15, Lines: 50}, - }, - } -} diff --git a/go/internal/codeshape/model/model.go b/go/internal/codeshape/model/model.go deleted file mode 100644 index 44e76e8..0000000 --- a/go/internal/codeshape/model/model.go +++ /dev/null @@ -1,200 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -// Package model holds the data types used by the code-shape gate. -package model - -import ( - "fmt" - "sort" -) - -// 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" -) - -// FunctionMeasurements maps each metric and source path to individual functions. -type FunctionMeasurements map[Metric]map[string][]Entry - -type functionIDError struct{} - -func (functionIDError) Error() string { return "invalid function allowlist id" } - -var errFunctionID error = functionIDError{} - -// FunctionMetrics returns the supported function metrics in stable order. -func FunctionMetrics() []Metric { - return []Metric{MetricGoCognitive, MetricGoLines, MetricGoStatements, MetricTSCognitive, MetricTSLines} -} - -// IsFunctionMetric reports whether metric has a function collector. -func IsFunctionMetric(metric Metric) bool { - for _, candidate := range FunctionMetrics() { - if metric == candidate { - return true - } - } - return false -} - -// NewFunctionMeasurements creates initialized maps for every function metric. -func NewFunctionMeasurements() FunctionMeasurements { - values := make(FunctionMeasurements, len(FunctionMetrics())) - for _, metric := range FunctionMetrics() { - values[metric] = make(map[string][]Entry) - } - return values -} - -// AppendFunctionValue appends one function value and rejects duplicate ids. -func AppendFunctionValue(values FunctionMeasurements, metric Metric, path, id string, value int) error { - entries := values[metric][path] - for _, entry := range entries { - if entry.ID == id { - return fmt.Errorf("duplicate %s measurement for %s#%s", metric, path, id) - } - } - values[metric][path] = append(entries, Entry{ID: id, Value: value}) - return nil -} - -// SetFunctionDiagnostic sets one diagnostic on an initialized function. -func SetFunctionDiagnostic(values FunctionMeasurements, metric Metric, path, id string, value int) error { - entries := values[metric][path] - for index := range entries { - if entries[index].ID != id { - continue - } - if entries[index].Value != 0 { - return fmt.Errorf("duplicate %s diagnostic for %s#%s", metric, path, id) - } - entries[index].Value = value - values[metric][path] = entries - return nil - } - return fmt.Errorf("missing %s function measurement for %s#%s", metric, path, id) -} - -// FunctionEntry finds one function entry by id. -func FunctionEntry(entries []Entry, id string) (Entry, bool) { - for _, entry := range entries { - if entry.ID == id { - return entry, true - } - } - return Entry{}, false -} - -// SortedFunctionEntries returns function entries sorted by id. -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 that allowlist function ids are non-empty and unique. -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 == "" || seen[entry.ID] { - return fmt.Errorf("%w: function allowlist entry for %q has invalid or repeated id %q", errFunctionID, path, entry.ID) - } - seen[entry.ID] = true - } - } - return nil -} - -// RewriteFunctionEntries applies the allowlist ratchet and optional seeding. -func RewriteFunctionEntries(updated, allow, values FunctionMeasurements, cfg Config, seed bool) (bool, []string) { - changed := false - var errs []string - for _, metric := range FunctionMetrics() { - paths, dropped := retainedFunctionEntries(allow[metric], values[metric], cfg, metric) - changed = changed || dropped - added, addErrors := seedFunctionEntries(paths, values[metric], cfg, metric, seed) - changed = changed || added - errs = append(errs, addErrors...) - if len(paths) > 0 { - updated[metric] = paths - } - } - return changed, errs -} - -func retainedFunctionEntries(allow, values map[string][]Entry, cfg Config, metric Metric) (map[string][]Entry, bool) { - paths := make(map[string][]Entry, len(allow)) - changed := false - for path, entries := range allow { - for _, entry := range entries { - current, exists := FunctionEntry(values[path], entry.ID) - if !exists || current.Value <= cfg.Functions.LimitFor(metric) { - 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, cfg Config, metric Metric, seed bool) (bool, []string) { - changed := false - var errs []string - for path, entries := range values { - for _, current := range entries { - if current.Value <= cfg.Functions.LimitFor(metric) { - 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 - } - entry = seededFunctionEntry(entry, current, exists) - replaceFunctionEntry(paths, path, entry, exists) - changed = true - } - } - return changed, errs -} - -func seededFunctionEntry(entry, current Entry, exists bool) Entry { - if !exists { - entry = Entry{ID: current.ID, Reason: "seeded at rollout, burn down"} - } - entry.Value = current.Value - return entry -} - -func replaceFunctionEntry(paths map[string][]Entry, path string, entry Entry, exists bool) { - if !exists { - paths[path] = append(paths[path], entry) - return - } - for index := range paths[path] { - if paths[path][index].ID == entry.ID { - paths[path][index] = entry - return - } - } -} - diff --git a/go/internal/codeshape/allowlist/render.go b/go/internal/codeshape/render.go similarity index 67% rename from go/internal/codeshape/allowlist/render.go rename to go/internal/codeshape/render.go index e40ef0f..1c2f758 100644 --- a/go/internal/codeshape/allowlist/render.go +++ b/go/internal/codeshape/render.go @@ -1,17 +1,15 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -package allowlist +package codeshape import ( "sort" "strconv" "strings" - - "github.com/RigelBuild/meissa/go/internal/codeshape/model" ) -// Render serializes the allowlist in stable key order. -func Render(allow model.Allowlist) []byte { +// 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) @@ -22,7 +20,7 @@ func Render(allow model.Allowlist) []byte { return []byte(output.String()) } -func writeEntries(output *strings.Builder, name string, entries map[string]model.Entry) { +func writeEntries(output *strings.Builder, name string, entries map[string]Entry) { if len(entries) == 0 { return } @@ -40,16 +38,16 @@ func writeEntries(output *strings.Builder, name string, entries map[string]model output.WriteString("}\n") } -func writeFunctionEntries(output *strings.Builder, functions model.FunctionMeasurements) { - for _, metric := range model.FunctionMetrics() { +func writeFunctionEntries(output *strings.Builder, functions map[Metric]map[string][]Entry) { + for _, metric := range functionMetrics() { paths := functions[metric] if len(paths) == 0 { continue } output.WriteString("functions: " + strconv.Quote(string(metric)) + ": {\n") - for _, path := range sortedPaths(paths) { + for _, path := range sortedEntryPaths(paths) { output.WriteString("\t" + strconv.Quote(path) + ": [\n") - for _, entry := range model.SortedFunctionEntries(paths[path]) { + for _, entry := range sortedFunctionEntries(paths[path]) { output.WriteString("\t\t{id: " + strconv.Quote(entry.ID) + ", value: " + strconv.Itoa(entry.Value) + ", reason: " + strconv.Quote(entry.Reason) + "},\n") } @@ -58,12 +56,3 @@ func writeFunctionEntries(output *strings.Builder, functions model.FunctionMeasu output.WriteString("}\n") } } - -func sortedPaths[V any](entries map[string]V) []string { - paths := make([]string, 0, len(entries)) - for path := range entries { - paths = append(paths, path) - } - sort.Strings(paths) - return paths -} diff --git a/go/internal/codeshape/allowlist/render_test.go b/go/internal/codeshape/render_test.go similarity index 100% rename from go/internal/codeshape/allowlist/render_test.go rename to go/internal/codeshape/render_test.go diff --git a/go/internal/codeshape/allowlist/schema.cue b/go/internal/codeshape/schema.cue similarity index 82% rename from go/internal/codeshape/allowlist/schema.cue rename to go/internal/codeshape/schema.cue index 75b59b3..5e67e8a 100644 --- a/go/internal/codeshape/allowlist/schema.cue +++ b/go/internal/codeshape/schema.cue @@ -36,9 +36,9 @@ exclude: [...string] // When multiple globs match, the smallest limit wins. dirLimits?: [string]: {limit: int & >10, reason: string & =~"^\\S+(\\s+\\S+){2,}$"} - functions?: close({ - go: close({cognitive: int & >0, lines: int & >0, statements: int & >0}) - ts: close({cognitive: int & >0, lines: int & >0}) - }) + functions: close({ + go: close({cognitive: 20, lines: 60, statements: 40}) + ts: close({cognitive: 15, lines: 50}) + }) }) diff --git a/go/internal/codeshape/testmain_test.go b/go/internal/codeshape/testmain_test.go new file mode 100644 index 0000000..683a578 --- /dev/null +++ b/go/internal/codeshape/testmain_test.go @@ -0,0 +1,28 @@ +// 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) + } + if err = os.Setenv("TMPDIR", dir); err != nil { + fmt.Fprintln(os.Stderr, err) + os.RemoveAll(dir) + os.Exit(1) + } + code := m.Run() + if err = os.RemoveAll(dir); err != nil { + fmt.Fprintln(os.Stderr, err) + code = 1 + } + os.Exit(code) +} diff --git a/go/internal/codeshape/testsupport/fixtures.go b/go/internal/codeshape/testsupport/fixtures.go deleted file mode 100644 index 64b2111..0000000 --- a/go/internal/codeshape/testsupport/fixtures.go +++ /dev/null @@ -1,86 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -// Package testsupport provides isolated repositories for code-shape tests. -package testsupport - -import ( - "os" - "os/exec" - "path/filepath" - "testing" - - "github.com/RigelBuild/meissa/go/internal/codeshape/model" -) - -// NewFixtureRepo creates an initialized git repository in the test temp directory. -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) - } - for _, args := range [][]string{{"config", "user.email", "test@example.invalid"}, {"config", "user.name", "test"}} { - command = exec.CommandContext(t.Context(), "git", append([]string{"-C", root}, args...)...) - output, err = command.CombinedOutput() - if err != nil { - t.Fatalf("git config: %v: %s", err, output) - } - } - return root -} - -// TrackedFile writes a fixture file inside a test repository. -func TrackedFile(t *testing.T, root, path, content string) { - t.Helper() - fullPath := filepath.Join(root, filepath.FromSlash(path)) - if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil { - t.Fatal(err) - } -} - -// CommitFiles adds all fixture files to the repository. -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) - } -} - -// Config returns the standard valid code-shape test configuration. -func Config() model.Config { - return model.Config{ - FileLimit: 500, DirLimit: 10, Extensions: []string{".go", ".cue"}, Exclude: []string{}, - DirLimits: map[string]model.DirLimit{}, - Functions: model.FunctionLimits{ - Go: model.GoFunctionLimits{Cognitive: 20, Lines: 60, Statements: 40}, - TS: model.TSFunctionLimits{Cognitive: 15, Lines: 50}, - }, - } -} - -// WriteConfigFiles writes a gate config and allowlist. -func WriteConfigFiles(t *testing.T, root, config, allowlist string) { - t.Helper() - dir := filepath.Join(root, "tools", "code-shape-gate") - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatal(err) - } - for path, content := range map[string]string{"config.cue": config, "allowlist.cue": allowlist} { - if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - } -} diff --git a/go/internal/codeshape/tsmetrics.go b/go/internal/codeshape/tsmetrics.go new file mode 100644 index 0000000..4d53046 --- /dev/null +++ b/go/internal/codeshape/tsmetrics.go @@ -0,0 +1,435 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "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]Metric { + return map[string]Metric{ + "lint/complexity/noExcessiveCognitiveComplexity": MetricTSCognitive, + "lint/complexity/noExcessiveLinesPerFunction": 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") +) + +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 Config) (map[Metric]map[string][]Entry, []Skip, error) { + paths, err := trackedPaths(ctx, root) + if err != nil { + return nil, nil, err + } + sources := make(map[string][]byte) + for _, path := range paths { + if !isTypeScriptPath(path) || !countedPath(path, cfg) { + continue + } + //nolint:gosec // G304: path from the repo's tracked file list + source, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) + if err != nil { + return nil, nil, fmt.Errorf("read TypeScript source %q: %w", path, err) + } + sources[path] = source + } + if len(sources) == 0 { + return newFunctionMeasurements(), nil, nil + } + binary, err := exec.LookPath("biome") + if err != nil { + return nil, nil, fmt.Errorf("find biome: %w", err) + } + var sourcePaths []string + for path := range sources { + sourcePaths = append(sourcePaths, path) + } + sort.Strings(sourcePaths) + report, err := runBiome(ctx, binary, root, sourcePaths) + if err != nil { + return nil, nil, err + } + if report.Summary.DiagnosticsNotPrinted != 0 { + return nil, nil, fmt.Errorf("%w: %d were not printed", errBiomeTruncated, report.Summary.DiagnosticsNotPrinted) + } + skips := []Skip{} + if report.Summary.Changed+report.Summary.Unchanged != len(sourcePaths) { + for _, path := range sourcePaths { + single, singleErr := runBiome(ctx, binary, root, []string{path}) + if singleErr != nil { + return nil, nil, singleErr + } + if single.Summary.Changed+single.Summary.Unchanged == 0 { + delete(sources, path) + skips = append(skips, Skip{Path: path, Reason: "ignored by Biome"}) + } + } + } + values := 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, Skip{Path: path, Reason: err.Error()}) + delete(sources, path) + continue + } + defer tree.Release() + files[path] = functions + for _, function := range functions { + appendFunctionValue(values, MetricTSCognitive, path, function.id, 0) + appendFunctionValue(values, MetricTSLines, path, function.id, 0) + } + } + 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 _, included := sources[path]; !included && slices.ContainsFunc(skips, func(skip Skip) bool { return skip.Path == path }) { + continue + } + if !exists { + return nil, nil, fmt.Errorf("%w %q", errBiomeUnknownSource, path) + } + function, err := findTSFunction( + functions, diagnostic.Location.Start.Line, diagnostic.Location.Start.Column, sources[path], + ) + if err != nil { + return nil, nil, err + } + value, err := biomeValue(diagnostic.Message, metric) + if err != nil { + return nil, nil, err + } + appendFunctionValue(values, metric, path, function.id, value) + } + return values, skips, nil +} + +func isTypeScriptPath(path string) bool { + switch filepath.Ext(path) { + case ".ts", ".tsx", ".mts", ".cts": + return true + default: + return false + } +} + +func runBiome(ctx context.Context, binary, root 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") + 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() + if runErr != nil { + 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", err) + } + if report.Summary == nil || report.Diagnostics == nil { + return biomeReport{}, errBiomeMissingOutput + } + if report.Summary.Errors > 0 { + return biomeReport{}, fmt.Errorf("biome reported %d errors: %w", report.Summary.Errors, errBiomeReported) + } + return report, nil +} + +func readBiomeConfig(root string) (map[string]any, error) { + data, err := os.ReadFile(filepath.Join(root, "biome.json")) //nolint:gosec // G304: read repo config + if err != nil { + return nil, fmt.Errorf("read biome.json: %w", err) + } + var config map[string]any + err = json5.Unmarshal(data, &config) + if err != nil { + return nil, fmt.Errorf("parse biome.json: %w", 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 Metric) (int, error) { + fields := strings.Fields(message) + for index, field := range fields { + if index+1 < len(fields) && (metric == MetricTSCognitive && field == "of" || + metric == 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) + counts := make(map[string]int) + for i := range functions { + parentID := "" + 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 + "/" + break + } + } + classID := "" + for parent := functions[i].node.Parent(); parent != nil; parent = parent.Parent() { + if parent.Type(lang) == "class_declaration" || parent.Type(lang) == "class" { + if name := parent.ChildByFieldName("name", lang); name != nil { + classID = name.Text(source) + "/" + } + break + } + } + base := parentID + classID + functions[i].id + counts[base]++ + functions[i].id = base + if counts[base] > 1 { + functions[i].id += fmt.Sprintf("#%d", counts[base]) + } + } + return functions, tree, 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 + } + lineStart := 0 + for current := 1; current < line; current++ { + next := bytes.IndexByte(source[lineStart:], '\n') + if next < 0 { + return nil, 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 nil, fmt.Errorf("biome position %d:%d is outside source: %w", line, column, errBiomeOutsideSource) + } + r, size := utf8.DecodeRune(source[offset:]) + units++ + if r > 0xffff { + units++ + } + offset += size + } + if offset > len(source) { + return nil, fmt.Errorf("biome position %d:%d is outside source: %w", line, column, errBiomeOutsideSource) + } + 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 +} diff --git a/go/internal/codeshape/typescript/metrics_test.go b/go/internal/codeshape/tsmetrics_test.go similarity index 67% rename from go/internal/codeshape/typescript/metrics_test.go rename to go/internal/codeshape/tsmetrics_test.go index 626b531..b482aec 100644 --- a/go/internal/codeshape/typescript/metrics_test.go +++ b/go/internal/codeshape/tsmetrics_test.go @@ -24,7 +24,6 @@ func tsRepo(t *testing.T, source string) string { commitFiles(t, root) return root } - func TestTSOnlyOffUsesOptions(t *testing.T) { root := tsRepo(t, "function f(x: boolean) {\n"+strings.Repeat("if (x) { console.log(x); }\n", 16)+ strings.Repeat("console.log(x);\n", 40)+"}\n") @@ -98,20 +97,6 @@ func TestTSStructuralIdentity(t *testing.T) { } } -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 TestTSManyOffenders(t *testing.T) { var source strings.Builder for i := range 25 { @@ -172,52 +157,3 @@ func TestTSNestedDescribeIdentity(t *testing.T) { } t.Fatalf("missing nested title: %#v", funcs) } -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() - err := os.WriteFile(filepath.Join(dir, "biome"), []byte("#!/bin/sh\nprintf '{\"summary\":'\n"), 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/typescript/suppressions.go b/go/internal/codeshape/tssuppressions.go similarity index 100% rename from go/internal/codeshape/typescript/suppressions.go rename to go/internal/codeshape/tssuppressions.go diff --git a/go/internal/codeshape/typescript/api.go b/go/internal/codeshape/typescript/api.go deleted file mode 100644 index fc5703c..0000000 --- a/go/internal/codeshape/typescript/api.go +++ /dev/null @@ -1,184 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -// Package typescript collects Biome function metrics and suppression findings. -package typescript - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - - "github.com/RigelBuild/meissa/go/internal/codeshape/inventory" - "github.com/RigelBuild/meissa/go/internal/codeshape/model" - "github.com/titanous/json5" -) -CUT 168*=219 - "github.com/RigelBuild/meissa/go/internal/codeshape/model" - "github.com/titanous/json5" -) - -// SuppressionFindings reports Biome suppression directives and policy drift. -func SuppressionFindings(paths []string, cfg model.Config, repository *os.Root) ([]model.Finding, error) { - var findings []model.Finding - hasTS := false - for _, path := range paths { - if !isTypeScriptPath(path) || !inventory.CountedPath(path, cfg) { - continue - } - hasTS = true - source, err := repository.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read TypeScript source %q: %w", path, err) - } - findings = append(findings, suppressionLineFindings(path, source)...) - } - if !hasTS { - return findings, nil - } - configs, err := biomeConfigs(paths, repository) - if err != nil { - return nil, err - } - for path, config := range configs { - findings = append(findings, driftFindings(path, config, cfg)...) - } - return findings, nil -} - -// ParseSuppressionsForTest parses Biome suppression directives from one source file. -func ParseSuppressionsForTest(path string, source []byte) []model.Finding { - var findings []model.Finding - for lineNumber, line := range strings.Split(string(source), "\n") { - start := strings.Index(line, "biome-ignore") - if start < 0 { - continue - } - directive, _, _ := strings.Cut(line[start:], ":") - if !isBiomeComplexitySuppression(directive) { - continue - } - for _, metric := range []model.Metric{model.MetricTSCognitive, model.MetricTSLines} { - findings = append(findings, model.Finding{Path: path + ":" + strconv.Itoa(lineNumber+1), Metric: metric, Value: 1}) - } - } - return findings -} -func isBiomeComplexitySuppression(directive string) bool { - fields := strings.Fields(directive) - if len(fields) < 2 || (fields[0] != "biome-ignore" && fields[0] != "biome-ignore-all" && fields[0] != "biome-ignore-start") { - return false - } - for _, category := range strings.Split(strings.Join(fields[1:], " "), ",") { - switch strings.TrimSpace(category) { - case "lint", "lint/complexity", "lint/complexity/noExcessiveCognitiveComplexity", "lint/complexity/noExcessiveLinesPerFunction": - return true - } - } - return false -} - -func biomeConfigs(paths []string, repository *os.Root) (map[string]map[string]any, error) { - configs := make(map[string]map[string]any) - for _, path := range paths { - if filepath.Base(path) != "biome.json" && filepath.Base(path) != "biome.jsonc" { - continue - } - config, err := readBiomeConfig(repository, path) - if err != nil { - return nil, err - } - configs[path] = config - } - return configs, nil -} - -func readBiomeConfig(repository *os.Root, path string) (map[string]any, error) { - data, err := repository.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read %s: %w", path, err) - } - var config map[string]any - if err := json5.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("parse %s: %w", path, err) - } - return config, nil -} - -func driftFindings(path string, config map[string]any, cfg model.Config) []model.Finding { - var findings []model.Finding - for _, override := range biomeComplexityRules(config) { - for _, check := range []struct { - name, option string - metric model.Metric - }{ - {"noExcessiveCognitiveComplexity", "maxAllowedComplexity", model.MetricTSCognitive}, - {"noExcessiveLinesPerFunction", "maxLines", model.MetricTSLines}, - } { - rule, exists := override[check.name].(map[string]any) - option, optionExists := ruleOption(rule, check.option) - if !exists || !optionExists || numericValue(option) != cfg.Functions.LimitFor(check.metric) || ruleLevel(rule) != "off" { - value := numericValue(option) - findings = append(findings, model.Finding{Path: path, Metric: check.metric, - Value: value, Limit: cfg.Functions.LimitFor(check.metric), Fix: "match biome rule option to gate config"}) - } - } - } - return findings -} - -func biomeComplexityRules(config map[string]any) []map[string]any { - rules := []map[string]any{nestedMap(config, "linter", "rules", "complexity")} - if overrides, ok := config["overrides"].([]any); ok { - for _, value := range overrides { - if override, ok := value.(map[string]any); ok { - rules = append(rules, nestedMap(override, "linter", "rules", "complexity")) - } - } - } - return rules -} - -func ruleOption(rule map[string]any, key string) (any, bool) { - options, ok := rule["options"].(map[string]any) - if !ok { - return nil, false - } - value, exists := options[key] - return value, exists -} - -func ruleLevel(rule map[string]any) string { - level, ok := rule["level"].(string) - if !ok { - return "" - } - return level -} - -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 - } -} - diff --git a/go/internal/codeshape/typescript/api_test.go b/go/internal/codeshape/typescript/api_test.go deleted file mode 100644 index 87f62fd..0000000 --- a/go/internal/codeshape/typescript/api_test.go +++ /dev/null @@ -1,98 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package typescript_test - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/RigelBuild/meissa/go/internal/codeshape/model" - "github.com/RigelBuild/meissa/go/internal/codeshape/testsupport" - "github.com/RigelBuild/meissa/go/internal/codeshape/typescript" -) - -func TestSuppressionFindingsCoverBiomeGroups(t *testing.T) { - for _, tc := range []struct { - name, directive string - }{ - {"bare lint", "// biome-ignore lint: reason"}, - {"complexity group", "// biome-ignore lint/complexity: reason"}, - {"all directive", "// biome-ignore-all lint/complexity: reason"}, - {"start directive", "// biome-ignore-start lint/complexity: reason"}, - {"single rule", "// biome-ignore lint/complexity/noExcessiveLinesPerFunction: reason"}, - } { - t.Run(tc.name, func(t *testing.T) { - findings := typescriptSuppressionLineFindings("src/a.ts", []byte(tc.directive+"\n")) - if len(findings) != 2 || findings[0].Value != 1 || findings[0].Path != "src/a.ts:1" { - t.Fatalf("findings = %#v", findings) - } - }) - } -} - -func TestBiomeDriftChecksJSONCAndOverrides(t *testing.T) { - root := testsupport.NewFixtureRepo(t) - testsupport.TrackedFile(t, root, "src/a.ts", "function f() {}\n") - testsupport.TrackedFile(t, root, "biome.jsonc", `{"linter":{"rules":{"complexity":{"noExcessiveCognitiveComplexity":{"level":"off","options":{"maxAllowedComplexity":15}},"noExcessiveLinesPerFunction":{"level":"off","options":{"maxLines":50}}}}},"overrides":[{"linter":{"rules":{"complexity":{"noExcessiveLinesPerFunction":{"level":"off","options":{"maxLines":3}}}}}}]}`) - testsupport.CommitFiles(t, root) - paths, err := trackedPaths(t.Context(), root) - if err != nil { - t.Fatal(err) - } - repository, err := os.OpenRoot(root) - if err != nil { - t.Fatal(err) - } - defer repository.Close() - findings, err := typescript.SuppressionFindings(paths, model.Config{Extensions: []string{".ts"}, Functions: model.FunctionLimits{TS: model.TSFunctionLimits{Cognitive: 15, Lines: 50}}}, repository) - if err != nil { - t.Fatal(err) - } - if len(findings) != 1 || findings[0].Path != "biome.jsonc" || findings[0].Limit != 50 { - t.Fatalf("findings = %#v", findings) - } -} - -func TestBiomeSuppressionFindingsDoNotReadOutsideRoot(t *testing.T) { - root := testsupport.NewFixtureRepo(t) - outside := filepath.Join(t.TempDir(), "outside.ts") - if err := os.WriteFile(outside, []byte("// biome-ignore lint: reason\n"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.Symlink(outside, filepath.Join(root, "escape.ts")); err != nil { - t.Fatal(err) - } - repository, err := os.OpenRoot(root) - if err != nil { - t.Fatal(err) - } - defer repository.Close() - if _, err := typescript.SuppressionFindings([]string{"escape.ts"}, model.Config{Extensions: []string{".ts"}}, repository); err == nil { - t.Fatal("suppression scan followed symlink outside repository") - } -} - -func typescriptSuppressionLineFindings(path string, source []byte) []model.Finding { - return typescript.ParseSuppressionsForTest(path, source) -} - -func TestTSIdentityHandlesSameNamedMethodsAndNestedCallbacks(t *testing.T) { - first := `function big() { xs.map(() => 1) } class A { run() {} } class B { run() {} }` - second := `ys.map(() => 0); function big() { xs.map(() => 1) } class A { run() {} } class B { run() {} }` - a, err := typescript.FunctionIDs("x.ts", []byte(first)) - if err != nil { - t.Fatal(err) - } - b, err := typescript.FunctionIDs("x.ts", []byte(second)) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(strings.Join(a, ","), "A/run") || !strings.Contains(strings.Join(a, ","), "B/run") || !strings.Contains(strings.Join(a, ","), "big/closure-1") { - t.Fatalf("ids = %v", a) - } - if !strings.Contains(strings.Join(b, ","), "big/closure-1") { - t.Fatalf("sibling insertion changed ids = %v", b) - } -} diff --git a/go/internal/codeshape/typescript/metrics.go b/go/internal/codeshape/typescript/metrics.go deleted file mode 100644 index 99ecd34..0000000 --- a/go/internal/codeshape/typescript/metrics.go +++ /dev/null @@ -1,420 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package typescript - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "sort" - "strconv" - "strings" - "unicode/utf8" - - "github.com/RigelBuild/meissa/go/internal/codeshape/inventory" - "github.com/RigelBuild/meissa/go/internal/codeshape/model" - "github.com/odvcencio/gotreesitter" - "github.com/odvcencio/gotreesitter/grammars/tsx" - "github.com/odvcencio/gotreesitter/grammars/typescript" -) - -const maxBiomeDiagnostics = 1_000_000 - -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") - errBiomePosition = errors.New("invalid biome source position") - errBiomeOutsideSource = errors.New("biome position outside source") - errBiomeOutsideFunction = errors.New("biome position outside any function") -) - -func biomeRuleMetrics() map[string]model.Metric { - return map[string]model.Metric{ - "lint/complexity/noExcessiveCognitiveComplexity": model.MetricTSCognitive, - "lint/complexity/noExcessiveLinesPerFunction": model.MetricTSLines, - } -} - -// Metrics measures Biome's function complexity and line count rules. -func Metrics(ctx context.Context, root string, paths []string, cfg model.Config, repository *os.Root) (model.FunctionMeasurements, []model.Skip, error) { - sources, sourcePaths, err := readTypeScriptSources(paths, cfg, repository) - if err != nil || len(sources) == 0 { - return model.NewFunctionMeasurements(), nil, err - } - binary, err := exec.LookPath("biome") - if err != nil { - return nil, nil, fmt.Errorf("find biome: %w", err) - } - report, err := runBiome(ctx, binary, root, sourcePaths) - if err != nil { - return nil, nil, err - } - if report.Summary.DiagnosticsNotPrinted != 0 { - return nil, nil, fmt.Errorf("%w: %d were not printed", errBiomeTruncated, report.Summary.DiagnosticsNotPrinted) - } - skipped, err := skippedBiomeSources(ctx, binary, root, sourcePaths, report) - if err != nil { - return nil, nil, err - } - for _, path := range skipped { - delete(sources, path) - } - return collectTypeScriptMetrics(report, sources, skipped) -} - -func readTypeScriptSources(paths []string, cfg model.Config, repository *os.Root) (map[string][]byte, []string, error) { - sources := make(map[string][]byte) - for _, path := range paths { - if !isTypeScriptPath(path) || !inventory.CountedPath(path, cfg) { - continue - } - source, err := repository.ReadFile(filepath.FromSlash(path)) - if err != nil { - return nil, nil, fmt.Errorf("read TypeScript source %q: %w", path, err) - } - sources[path] = source - } - sourcePaths := make([]string, 0, len(sources)) - for path := range sources { - sourcePaths = append(sourcePaths, path) - } - sort.Strings(sourcePaths) - return sources, sourcePaths, nil -} - -func skippedBiomeSources(ctx context.Context, binary, root string, paths []string, report biomeReport) ([]model.Skip, error) { - if report.Summary.Changed+report.Summary.Unchanged == len(paths) { - return nil, nil - } - var skipped []model.Skip - for _, path := range paths { - single, err := runBiome(ctx, binary, root, []string{path}) - if err != nil { - return nil, err - } - if single.Summary.Changed+single.Summary.Unchanged == 0 { - skipped = append(skipped, model.Skip{Path: path, Reason: "ignored by Biome"}) - } - } - return skipped, nil -} - -func collectTypeScriptMetrics(report biomeReport, sources map[string][]byte, skips []model.Skip) (model.FunctionMeasurements, []model.Skip, error) { - values := model.NewFunctionMeasurements() - files := make(map[string][]tsFunction, len(sources)) - trees := make([]*gotreesitter.Tree, 0, len(sources)) - defer func() { - for _, tree := range trees { - tree.Release() - } - }() - for path, source := range sources { - functions, tree, err := parseTSFunctions(path, source) - if err != nil { - skips = append(skips, model.Skip{Path: path, Reason: err.Error()}) - delete(sources, path) - continue - } - trees = append(trees, tree) - files[path] = functions - for _, function := range functions { - if err := model.AppendFunctionValue(values, model.MetricTSCognitive, path, function.id, 0); err != nil { - return nil, nil, err - } - if err := model.AppendFunctionValue(values, model.MetricTSLines, path, function.id, 0); err != nil { - return nil, nil, err - } - } - } - if err := applyBiomeDiagnostics(report, files, sources, values, skips); err != nil { - return nil, nil, err - } - return values, skips, nil -} - -func applyBiomeDiagnostics(report biomeReport, files map[string][]tsFunction, sources map[string][]byte, - values model.FunctionMeasurements, skips []model.Skip) 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 isSkippedTSPath(path, skips) { - 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 - } - if err := model.SetFunctionDiagnostic(values, metric, path, function.id, value); err != nil { - return err - } - } - return nil -} - -func isSkippedTSPath(path string, skips []model.Skip) bool { - for _, skip := range skips { - if skip.Path == path { - return true - } - } - return false -} - -func isTypeScriptPath(path string) bool { - switch filepath.Ext(path) { - case ".ts", ".tsx", ".mts", ".cts": - return true - default: - return false - } -} - -func runBiome(ctx context.Context, binary, root string, paths []string) (biomeReport, error) { - args := []string{"lint", "--reporter=json", "--max-diagnostics=" + strconv.Itoa(maxBiomeDiagnostics), - "--only=complexity/noExcessiveCognitiveComplexity", "--only=complexity/noExcessiveLinesPerFunction", "--no-errors-on-unmatched"} - args = append(args, paths...) - //nolint:gosec // G204: execute the configured biome linter - command := exec.CommandContext(ctx, binary, args...) - command.Dir = root - var stdout, stderr bytes.Buffer - command.Stdout, command.Stderr = &stdout, &stderr - if err := command.Run(); err != nil { - return biomeReport{}, fmt.Errorf("biome lint failed: %w: %s", err, strings.TrimSpace(stderr.String())) - } - var report biomeReport - if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { - return biomeReport{}, fmt.Errorf("parse biome output: %w", err) - } - if report.Summary == nil || report.Diagnostics == nil { - return biomeReport{}, errBiomeMissingOutput - } - if report.Summary.Errors > 0 { - return biomeReport{}, fmt.Errorf("biome reported %d errors: %w", report.Summary.Errors, errBiomeReported) - } - return report, nil -} - -func biomeValue(message string, metric model.Metric) (int, error) { - fields := strings.Fields(message) - for index, field := range fields { - if index+1 < len(fields) && (metric == model.MetricTSCognitive && field == "of" || metric == model.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: syntax errors", path) - } - functions := make([]tsFunction, 0) - collectTSFunctions(root, lang, 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 index := range functions { - parent := enclosingTSFunction(functions, index) - class := enclosingTSClass(functions[index].node, lang, source) - base := parent + class + functions[index].id - counts[base]++ - functions[index].id = base - if counts[base] > 1 { - functions[index].id += fmt.Sprintf("#%d", counts[base]) - } - } -} - -func enclosingTSFunction(functions []tsFunction, index int) string { - for candidate := index - 1; candidate >= 0; candidate-- { - if functions[candidate].node.StartByte() <= functions[index].node.StartByte() && - functions[candidate].node.EndByte() >= functions[index].node.EndByte() { - return functions[candidate].id + "/" - } - } - return "" -} - -func enclosingTSClass(node *gotreesitter.Node, lang *gotreesitter.Language, source []byte) string { - for parent := node.Parent(); parent != nil; parent = parent.Parent() { - if parent.Type(lang) != "class_declaration" && parent.Type(lang) != "class" { - continue - } - if name := parent.ChildByFieldName("name", lang); name != nil { - return name.Text(source) + "/" - } - } - return "" -} - -func collectTSFunctions(node *gotreesitter.Node, lang *gotreesitter.Language, titlePath []string, - functions *[]tsFunction, source []byte) { - if node == nil { - return - } - if isTestTitleCall(node, lang, source) { - arguments := node.ChildByFieldName("arguments", lang) - title, callback := firstStringArgument(arguments, lang, source), lastFunctionArgument(arguments, lang) - if title != "" && callback != nil { - collectTSFunctions(callback, lang, append(append([]string(nil), titlePath...), title), functions, source) - return - } - } - if isTSFunctionNode(node, lang) { - *functions = append(*functions, tsFunction{node: node, id: tsFunctionID(node, lang, titlePath, source)}) - } - for index := range node.ChildCount() { - collectTSFunctions(node.Child(index), lang, titlePath, functions, source) - } -} - -func isTestTitleCall(node *gotreesitter.Node, lang *gotreesitter.Language, source []byte) bool { - if node.Type(lang) != "call_expression" { - return false - } - callable := node.ChildByFieldName("function", lang) - if callable == nil { - return false - } - text := callable.Text(source) - return text == "describe" || text == "test" || text == "it" || - strings.HasPrefix(text, "describe.") || strings.HasPrefix(text, "test.") || strings.HasPrefix(text, "it.") -} - -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 || 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 { - if property := parent.ChildByFieldName("name", lang); property != nil { - functionName = property.Text(source) - } else 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) { - offset, err := biomeOffset(source, line, column) - 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, smallest = function, span - } - } - } - if match == nil { - return nil, fmt.Errorf("biome position %d:%d is outside any function: %w", line, column, errBiomeOutsideFunction) - } - return match, nil -} - -func biomeOffset(source []byte, line, column int) (int, error) { - if line < 1 || column < 1 { - return 0, errBiomePosition - } - 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) - } - r, size := utf8.DecodeRune(source[offset:]) - units++ - if r > 0xffff { - units++ - } - offset += size - } - return offset, nil -} diff --git a/go/internal/codeshape/typescript/report.go b/go/internal/codeshape/typescript/report.go deleted file mode 100644 index 32f1787..0000000 --- a/go/internal/codeshape/typescript/report.go +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package typescript - -import "github.com/odvcencio/gotreesitter" - -// biomeReport is the JSON schema for Biome's lint output. -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 -} From d6650987fab5832455777a028eca0c9a6cb644b0 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 10:42:05 -0500 Subject: [PATCH 08/54] wip(go): T2 qualify cli imports (RIG-8435) --- go/internal/codeshape/cli/cli.go | 74 +++++++++++----------- go/internal/codeshape/cli/cli_test.go | 30 ++++----- go/internal/codeshape/cli/fixtures_test.go | 16 ++--- 3 files changed, 59 insertions(+), 61 deletions(-) diff --git a/go/internal/codeshape/cli/cli.go b/go/internal/codeshape/cli/cli.go index e3f61b5..586a6c9 100644 --- a/go/internal/codeshape/cli/cli.go +++ b/go/internal/codeshape/cli/cli.go @@ -14,7 +14,7 @@ import ( "sort" "strings" - . "github.com/RigelBuild/meissa/go/internal/codeshape" + "github.com/RigelBuild/meissa/go/internal/codeshape" ) var errCannotRaiseAllowlist = errors.New("--write cannot add or raise allowlist entries") @@ -37,12 +37,12 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 } - config, allow, err := Load(root) + config, allow, err := codeshape.Load(root) if err != nil { writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 } - var base Allowlist + var base codeshape.Allowlist if baseRef := env("GATE_BASE"); baseRef != "" { base, err = loadBaseAllowlist(ctx, root, baseRef) if err != nil { @@ -50,12 +50,12 @@ 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 } - functions, skips, err := GoMetrics(ctx, root, config) + functions, skips, err := codeshape.GoMetrics(ctx, root, config) if err != nil { writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 @@ -65,7 +65,7 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s return 2 } } - tsFunctions, tsSkips, err := TSMetrics(ctx, root, config) + tsFunctions, tsSkips, err := codeshape.TSMetrics(ctx, root, config) if err != nil { writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 @@ -75,21 +75,21 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s return 2 } } - functions[MetricTSCognitive] = tsFunctions[MetricTSCognitive] - functions[MetricTSLines] = tsFunctions[MetricTSLines] + functions[codeshape.MetricTSCognitive] = tsFunctions[codeshape.MetricTSCognitive] + functions[codeshape.MetricTSLines] = tsFunctions[codeshape.MetricTSLines] snapshot.Functions = functions - suppressions, err := GoSuppressionFindings(ctx, root, config) + suppressions, err := codeshape.GoSuppressionFindings(ctx, root, config) if err != nil { writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 } - tsFindings, err := TSPolicyFindings(ctx, root, config) + tsFindings, err := codeshape.TSPolicyFindings(ctx, root, config) if err != nil { writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 } suppressions = append(suppressions, tsFindings...) - SortFindings(suppressions) + codeshape.SortFindings(suppressions) if len(suppressions) > 0 { err := writeFindings(stdout, suppressions) if err != nil { @@ -101,7 +101,7 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s if writeMode { return writeUpdated(root, config, allow, base, snapshot, seed, stdout, stderr) } - findings := Judge(config, allow, base, snapshot) + findings := codeshape.Judge(config, allow, base, snapshot) err = writeFindings(stdout, findings) if err != nil { writeOutput(stderr, "code-shape-gate: write output: %v\n", err) @@ -115,9 +115,9 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s 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, ) int { @@ -126,7 +126,7 @@ func writeUpdated( writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 } - findings := Judge(config, updated, base, snapshot) + findings := codeshape.Judge(config, updated, base, snapshot) err = writeFindings(stdout, findings) if err != nil { writeOutput(stderr, "code-shape-gate: write output: %v\n", err) @@ -141,7 +141,7 @@ func writeUpdated( 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", codeshape.Render(updated), 0o644) closeErr := repository.Close() err = errors.Join(err, closeErr) if err != nil { @@ -177,28 +177,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) + return codeshape.Allowlist{}, gitBaseError(ref, err) } - allowlist, err := DecodeBaseAllowlist(data) + allowlist, err := codeshape.DecodeBaseAllowlist(data) if err != nil { - return Allowlist{}, err + return codeshape.Allowlist{}, err } allowlist.Present = true return allowlist, nil @@ -212,20 +212,18 @@ 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)), - Functions: make(FunctionMeasurements), +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) - changedFunctions, functionErrors := RewriteFunctionEntries( - updated.Functions, allow.Functions, snapshot.Functions, cfg, 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...) @@ -239,10 +237,10 @@ 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 @@ -278,7 +276,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, ) @@ -293,7 +291,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 index 4f04a75..e5267f8 100644 --- a/go/internal/codeshape/cli/cli_test.go +++ b/go/internal/codeshape/cli/cli_test.go @@ -4,7 +4,7 @@ package cli import ( "fmt" - . "github.com/RigelBuild/meissa/go/internal/codeshape" + "github.com/RigelBuild/meissa/go/internal/codeshape" "os" "os/exec" "path/filepath" @@ -113,11 +113,11 @@ func TestWriteUpdatedRejectsEscapingAllowlist(t *testing.T) { if err != nil { t.Fatal(err) } - allow := EmptyAllowlist() - allow.Files["gone.go"] = Entry{ID: "", Value: 501, Reason: "split by concern"} - snapshot := Snapshot{Files: map[string]int{}, Dirs: map[string]int{}, Functions: nil} + 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, EmptyAllowlist(), snapshot, false, &stdout, &stderr) + code := writeUpdated(root, testConfig(), allow, codeshape.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()) @@ -161,8 +161,8 @@ func TestMainPrintsBaseAndMeasuredFindings(t *testing.T) { } func TestSeedPreservesReason(t *testing.T) { - entries := map[string]Entry{"large.go": {ID: "", Value: 501, Reason: "keep curated reason"}} - _, errs := rewriteEntries(entries, map[string]int{"large.go": 502}, testConfig(), MetricFiles, true) + 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) } @@ -234,7 +234,7 @@ func TestMainModes(t *testing.T) { if code != 0 { t.Fatalf("Main = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } - _, allowlist, err := Load(root) + _, allowlist, err := codeshape.Load(root) if err != nil { t.Fatal(err) } @@ -255,7 +255,7 @@ func TestMainWriteUpdates(t *testing.T) { if code != 0 { t.Fatalf("Main = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } - _, allowlist, err := Load(root) + _, allowlist, err := codeshape.Load(root) if err != nil { t.Fatal(err) } @@ -273,7 +273,7 @@ func TestMainWriteUpdates(t *testing.T) { if code != 0 { t.Fatalf("Main = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } - _, allowlist, err := Load(root) + _, allowlist, err := codeshape.Load(root) if err != nil { t.Fatal(err) } @@ -301,22 +301,22 @@ func TestMainWriteUpdates(t *testing.T) { func TestRewriteFunctionEntries(t *testing.T) { cfg := testConfig() cfg.Functions.Go.Lines = 60 - allow := Allowlist{Files: nil, Dirs: nil, Present: false, Functions: FunctionMeasurements{MetricGoLines: {"f.go": { + 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 := FunctionMeasurements{MetricGoLines: {"f.go": { + values := codeshape.FunctionMeasurements{codeshape.MetricGoLines: {"f.go": { {ID: "lower", Value: 70, Reason: ""}, {ID: "new", Value: 61, Reason: ""}, }}} - _, _, err := rewriteAllowlist(cfg, allow, Snapshot{Files: nil, Dirs: nil, Functions: values}, false) + _, _, 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, Snapshot{Files: nil, Dirs: nil, Functions: values}, true) + 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[MetricGoLines]["f.go"] + got := updated.Functions[codeshape.MetricGoLines]["f.go"] if len(got) != 2 { t.Fatalf("seed entries: %#v", got) } diff --git a/go/internal/codeshape/cli/fixtures_test.go b/go/internal/codeshape/cli/fixtures_test.go index bc5960b..4f64903 100644 --- a/go/internal/codeshape/cli/fixtures_test.go +++ b/go/internal/codeshape/cli/fixtures_test.go @@ -3,7 +3,7 @@ package cli import ( - . "github.com/RigelBuild/meissa/go/internal/codeshape" + "github.com/RigelBuild/meissa/go/internal/codeshape" "os" "os/exec" "path/filepath" @@ -58,12 +58,12 @@ func commitFiles(t *testing.T, root string) { t.Fatalf("git commit: %v: %s", err, output) } } -func testConfig() Config { - return Config{ +func testConfig() codeshape.Config { + return codeshape.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}}, + 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}}, } } @@ -84,11 +84,11 @@ func writeConfigFiles(t *testing.T, root, config, allowlist string) { } } } -func functionEntry(entries []Entry, id string) (Entry, bool) { +func functionEntry(entries []codeshape.Entry, id string) (codeshape.Entry, bool) { for _, entry := range entries { if entry.ID == id { return entry, true } } - return Entry{ID: "", Value: 0, Reason: ""}, false + return codeshape.Entry{ID: "", Value: 0, Reason: ""}, false } From 49f525c878e2fbbf98f9eb200d102a1f95e61d0a Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 10:47:18 -0500 Subject: [PATCH 09/54] wip(go): T2 split Go collector (RIG-8435) --- go/internal/codeshape/bridge.go | 4 -- go/internal/codeshape/cli/cli.go | 5 +- go/internal/codeshape/collector.go | 13 ++++ go/internal/codeshape/functions.go | 6 ++ go/internal/codeshape/golang/fixtures_test.go | 63 ++++++++++++++++ .../codeshape/{ => golang}/gometrics.go | 72 +++++++------------ .../codeshape/{ => golang}/gometrics_test.go | 28 ++++---- .../codeshape/{ => golang}/gosuppressions.go | 28 ++++---- go/internal/codeshape/golang/testmain_test.go | 28 ++++++++ go/internal/codeshape/inventory.go | 13 ++++ 10 files changed, 183 insertions(+), 77 deletions(-) create mode 100644 go/internal/codeshape/collector.go create mode 100644 go/internal/codeshape/golang/fixtures_test.go rename go/internal/codeshape/{ => golang}/gometrics.go (86%) rename go/internal/codeshape/{ => golang}/gometrics_test.go (84%) rename go/internal/codeshape/{ => golang}/gosuppressions.go (72%) create mode 100644 go/internal/codeshape/golang/testmain_test.go diff --git a/go/internal/codeshape/bridge.go b/go/internal/codeshape/bridge.go index 943830d..76184c3 100644 --- a/go/internal/codeshape/bridge.go +++ b/go/internal/codeshape/bridge.go @@ -8,10 +8,6 @@ import ( "cuelang.org/go/cue/cuecontext" ) -func GoSuppressionFindings(ctx context.Context, root string, cfg Config) ([]Finding, error) { - return goSuppressionFindings(ctx, root, cfg) -} - func TSPolicyFindings(ctx context.Context, root string, cfg Config) ([]Finding, error) { return tsPolicyFindings(ctx, root, cfg) } diff --git a/go/internal/codeshape/cli/cli.go b/go/internal/codeshape/cli/cli.go index 586a6c9..b5793bf 100644 --- a/go/internal/codeshape/cli/cli.go +++ b/go/internal/codeshape/cli/cli.go @@ -15,6 +15,7 @@ import ( "strings" "github.com/RigelBuild/meissa/go/internal/codeshape" + "github.com/RigelBuild/meissa/go/internal/codeshape/golang" ) var errCannotRaiseAllowlist = errors.New("--write cannot add or raise allowlist entries") @@ -55,7 +56,7 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 } - functions, skips, err := codeshape.GoMetrics(ctx, root, config) + functions, skips, err := golang.GoMetrics(ctx, root, config) if err != nil { writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 @@ -78,7 +79,7 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s functions[codeshape.MetricTSCognitive] = tsFunctions[codeshape.MetricTSCognitive] functions[codeshape.MetricTSLines] = tsFunctions[codeshape.MetricTSLines] snapshot.Functions = functions - suppressions, err := codeshape.GoSuppressionFindings(ctx, root, config) + suppressions, err := golang.SuppressionFindings(ctx, root, config) if err != nil { writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 diff --git a/go/internal/codeshape/collector.go b/go/internal/codeshape/collector.go new file mode 100644 index 0000000..9c6ee3d --- /dev/null +++ b/go/internal/codeshape/collector.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +import "context" + +func TrackedPaths(ctx context.Context, root string) ([]string, error) { return trackedPaths(ctx, root) } +func CountedPath(path string, cfg Config) bool { return countedPath(path, cfg) } +func MatchesAny(path string, patterns []string) bool { return matchesAny(path, patterns) } +func NewFunctionMeasurements() map[Metric]map[string][]Entry { return newFunctionMeasurements() } +func AppendFunctionValue(values map[Metric]map[string][]Entry, metric Metric, path, id string, value int) { + appendFunctionValue(values, metric, path, id, value) +} diff --git a/go/internal/codeshape/functions.go b/go/internal/codeshape/functions.go index 9d54c2c..2383de1 100644 --- a/go/internal/codeshape/functions.go +++ b/go/internal/codeshape/functions.go @@ -9,6 +9,12 @@ import ( "sort" ) +// Skip records a source file that could not be measured. +type Skip struct { + Path string + Reason string +} + var errFunctionID = errors.New("invalid function allowlist id") // FunctionLimits holds per-language function thresholds. diff --git a/go/internal/codeshape/golang/fixtures_test.go b/go/internal/codeshape/golang/fixtures_test.go new file mode 100644 index 0000000..bab6e2d --- /dev/null +++ b/go/internal/codeshape/golang/fixtures_test.go @@ -0,0 +1,63 @@ +// 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 + if out, err := cmd.CombinedOutput(); 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)) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0644); 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 + if out, err := cmd.CombinedOutput(); 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{}} +} + +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") + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + for name, content := range map[string]string{"config.cue": config, "allowlist.cue": allowlist} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + t.Fatal(err) + } + } +} diff --git a/go/internal/codeshape/gometrics.go b/go/internal/codeshape/golang/gometrics.go similarity index 86% rename from go/internal/codeshape/gometrics.go rename to go/internal/codeshape/golang/gometrics.go index 87732b3..a335a54 100644 --- a/go/internal/codeshape/gometrics.go +++ b/go/internal/codeshape/golang/gometrics.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -package codeshape +package golang import ( "bytes" @@ -20,6 +20,8 @@ import ( "sort" "strconv" "strings" + + "github.com/RigelBuild/meissa/go/internal/codeshape" ) const highFunlenLimit = 1_000_000_000 @@ -71,28 +73,22 @@ type goFunction struct { id string } -// Skip records a source file that could not be measured. -type Skip struct { - Path string - Reason string -} - // GoMetrics measures function complexity and length in every included module. -func GoMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[string][]Entry, []Skip, error) { - paths, err := trackedPaths(ctx, root) +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, err } modules := goModules(paths, cfg) if len(modules) == 0 { - return newFunctionMeasurements(), nil, nil + return codeshape.NewFunctionMeasurements(), nil, nil } binary, err := exec.LookPath("golangci-lint") if err != nil { return nil, nil, fmt.Errorf("find golangci-lint: %w", err) } - measurements := newFunctionMeasurements() - skips := []Skip{} + measurements := codeshape.NewFunctionMeasurements() + skips := []codeshape.Skip{} for _, module := range modules { files, skipped, err := parseGoSources(root, module, paths, cfg) if err != nil { @@ -103,19 +99,19 @@ func GoMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[str continue } limits := []struct { - metric Metric + metric codeshape.Metric linter string lines int statements int }{ - {metric: MetricGoCognitive, linter: "gocognit", lines: 0, statements: 0}, - {metric: MetricGoLines, linter: "funlen", lines: cfg.Functions.Go.Lines, statements: highFunlenLimit}, - {metric: MetricGoStatements, linter: "funlen", lines: highFunlenLimit, statements: cfg.Functions.Go.Statements}, + {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 []Metric{MetricGoCognitive, MetricGoLines, MetricGoStatements} { - appendFunctionValue(measurements, metric, function.path, function.id, 0) + for _, metric := range []codeshape.Metric{codeshape.MetricGoCognitive, codeshape.MetricGoLines, codeshape.MetricGoStatements} { + codeshape.AppendFunctionValue(measurements, metric, function.path, function.id, 0) } } } @@ -135,7 +131,7 @@ func GoMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[str path = filepath.Join(module, path) } path = filepath.ToSlash(path) - if excludedFunctionPath(path, cfg) || !countedPath(path, cfg) { + if excludedFunctionPath(path, cfg) || !codeshape.CountedPath(path, cfg) { continue } value, err := metricValue(issue.Text, limit.metric) @@ -146,28 +142,14 @@ func GoMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[str if err != nil { return nil, nil, err } - appendFunctionValue(measurements, limit.metric, path, function, value) + codeshape.AppendFunctionValue(measurements, limit.metric, path, function, value) } } } return measurements, skips, nil } -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 -} - -func goModules(paths []string, cfg Config) []string { +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) { @@ -179,18 +161,18 @@ func goModules(paths []string, cfg Config) []string { return modules } -func excludedFunctionPath(path string, cfg Config) bool { +func excludedFunctionPath(path string, cfg codeshape.Config) bool { for segment := range strings.SplitSeq(filepath.ToSlash(path), "/") { if segment == "testdata" || segment == "fixtures" { return true } } - return matchesAny(filepath.ToSlash(path), cfg.Exclude) + return codeshape.MatchesAny(filepath.ToSlash(path), cfg.Exclude) } -func parseGoSources(root, module string, paths []string, cfg Config) (map[string]goSourceFile, []Skip, error) { +func parseGoSources(root, module string, paths []string, cfg codeshape.Config) (map[string]goSourceFile, []codeshape.Skip, error) { files := make(map[string]goSourceFile) - skips := []Skip{} + skips := []codeshape.Skip{} buildContext := build.Default buildContext.CgoEnabled = false modules := make([]string, 0) @@ -218,7 +200,7 @@ func parseGoSources(root, module string, paths []string, cfg Config) (map[string if nested { continue } - if !countedPath(filepath.ToSlash(path), cfg) { + if !codeshape.CountedPath(filepath.ToSlash(path), cfg) { continue } fullPath := filepath.Join(root, filepath.FromSlash(path)) @@ -227,7 +209,7 @@ func parseGoSources(root, module string, paths []string, cfg Config) (map[string return nil, nil, fmt.Errorf("check Go build constraints %q: %w", path, err) } if !matches { - skips = append(skips, Skip{Path: path, Reason: "not in default Go build"}) + skips = append(skips, codeshape.Skip{Path: path, Reason: "not in default Go build"}) continue } //nolint:gosec // G304: path from the repo's tracked file list @@ -341,7 +323,7 @@ func runGolangci( ctx context.Context, binary, root, module, linter string, lines, statements int, - cfg Config, + cfg codeshape.Config, ) (golangciReport, error) { configuration := fmt.Sprintf( "version: '2'\nlinters:\n default: none\n enable:\n - %s\n"+ @@ -422,9 +404,9 @@ func runGolangciWithConfig( return report, nil } -func metricValue(text string, metric Metric) (int, error) { +func metricValue(text string, metric codeshape.Metric) (int, error) { pattern := funlenValue - if metric == MetricGoCognitive { + if metric == codeshape.MetricGoCognitive { pattern = gocognitValue } matches := pattern.FindStringSubmatch(text) @@ -488,7 +470,7 @@ func findGoFunction( return path, match.id, nil } -func hasAnyFunctionMeasurement(values map[Metric]map[string][]Entry) bool { +func hasAnyFunctionMeasurement(values map[codeshape.Metric]map[string][]codeshape.Entry) bool { for _, paths := range values { for _, entries := range paths { if len(entries) > 0 { diff --git a/go/internal/codeshape/gometrics_test.go b/go/internal/codeshape/golang/gometrics_test.go similarity index 84% rename from go/internal/codeshape/gometrics_test.go rename to go/internal/codeshape/golang/gometrics_test.go index d82f876..1612121 100644 --- a/go/internal/codeshape/gometrics_test.go +++ b/go/internal/codeshape/golang/gometrics_test.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -package codeshape +package golang import ( "fmt" @@ -9,6 +9,8 @@ import ( "os/exec" "strings" "testing" + + "github.com/RigelBuild/meissa/go/internal/codeshape" ) func TestGoCollectorFixture(t *testing.T) { @@ -26,14 +28,14 @@ func TestGoCollectorFixture(t *testing.T) { trackedFile(t, root, "fixture.go", source.String()) commitFiles(t, root) cfg := testConfig() - cfg.Functions = FunctionLimits{Go: GoFunctionLimits{Cognitive: 20, Lines: 60, Statements: 40}, - TS: TSFunctionLimits{Cognitive: 0, Lines: 0}} + 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[MetricGoStatements]["fixture.go"] { + for _, entry := range values[codeshape.MetricGoStatements]["fixture.go"] { if entry.Value > 40 { count++ } @@ -99,7 +101,7 @@ func TestGoSuppressionFinding(t *testing.T) { 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 := goSuppressionFindings(t.Context(), root, testConfig()) + 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) } @@ -123,13 +125,13 @@ func TestGoNestedModules(t *testing.T) { t.Fatal(err) } for _, path := range []string{"x.go", "child/child.go"} { - entries := values[MetricGoStatements][path] + entries := values[codeshape.MetricGoStatements][path] if len(entries) != 1 || entries[0].Value != 41 { t.Fatalf("%s: %#v", path, entries) } } - if len(values[MetricGoStatements]) != 2 { - t.Fatalf("unexpected paths: %#v", values[MetricGoStatements]) + if len(values[codeshape.MetricGoStatements]) != 2 { + t.Fatalf("unexpected paths: %#v", values[codeshape.MetricGoStatements]) } } @@ -147,8 +149,8 @@ func TestGoExcludedAndUnbuiltFiles(t *testing.T) { if err != nil { t.Fatal(err) } - if len(skips) != 2 || len(values[MetricGoStatements]) != 1 { - t.Fatalf("skips=%v measurements=%v", skips, values[MetricGoStatements]) + if len(skips) != 2 || len(values[codeshape.MetricGoStatements]) != 1 { + t.Fatalf("skips=%v measurements=%v", skips, values[codeshape.MetricGoStatements]) } } func TestGoExcludedModule(t *testing.T) { @@ -162,19 +164,19 @@ func TestGoExcludedModule(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = goSuppressionFindings(t.Context(), root, cfg) + _, 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 := goSuppressionFindings(t.Context(), root, testConfig()) + findings, err := SuppressionFindings(t.Context(), root, testConfig()) if err != nil { t.Fatal(err) } for _, line := range []int{2, 3} { - for _, metric := range []Metric{MetricGoCognitive, MetricGoLines} { + 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 == line && finding.Metric == metric { diff --git a/go/internal/codeshape/gosuppressions.go b/go/internal/codeshape/golang/gosuppressions.go similarity index 72% rename from go/internal/codeshape/gosuppressions.go rename to go/internal/codeshape/golang/gosuppressions.go index c2da015..644551a 100644 --- a/go/internal/codeshape/gosuppressions.go +++ b/go/internal/codeshape/golang/gosuppressions.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -package codeshape +package golang import ( "context" @@ -11,10 +11,12 @@ import ( "path/filepath" "strconv" "strings" + + "github.com/RigelBuild/meissa/go/internal/codeshape" ) -func goSuppressionFindings(ctx context.Context, root string, cfg Config) ([]Finding, error) { - paths, err := trackedPaths(ctx, root) +func SuppressionFindings(ctx context.Context, root string, cfg codeshape.Config) ([]codeshape.Finding, error) { + paths, err := codeshape.TrackedPaths(ctx, root) if err != nil { return nil, err } @@ -29,7 +31,7 @@ func goSuppressionFindings(ctx context.Context, root string, cfg Config) ([]Find eligible[path] = true } } - findings := []Finding{} + findings := []codeshape.Finding{} for _, path := range paths { if !eligible[path] { continue @@ -54,26 +56,26 @@ func goSuppressionFindings(ctx context.Context, root string, cfg Config) ([]Find continue } directive := fields[0] - metrics := make(map[Metric]bool) + metrics := make(map[codeshape.Metric]bool) if directive == "nolint" || directive == "nolint:all" { - metrics[MetricGoCognitive] = true - metrics[MetricGoLines] = true + 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[MetricGoCognitive] = true - metrics[MetricGoLines] = true + metrics[codeshape.MetricGoCognitive] = true + metrics[codeshape.MetricGoLines] = true case "gocognit", "gocyclo": - metrics[MetricGoCognitive] = true + metrics[codeshape.MetricGoCognitive] = true case "funlen": - metrics[MetricGoLines] = true + metrics[codeshape.MetricGoLines] = true } } } for metric := range metrics { line := set.Position(comment.Pos()).Line - findings = append(findings, Finding{ + findings = append(findings, codeshape.Finding{ Path: path + ":" + strconv.Itoa(line), Metric: metric, Value: line, Limit: 0, Fix: "remove complexity nolint suppression", }) @@ -81,6 +83,6 @@ func goSuppressionFindings(ctx context.Context, root string, cfg Config) ([]Find } } } - sortFindings(findings) + codeshape.SortFindings(findings) return findings, nil } diff --git a/go/internal/codeshape/golang/testmain_test.go b/go/internal/codeshape/golang/testmain_test.go new file mode 100644 index 0000000..cf84a42 --- /dev/null +++ b/go/internal/codeshape/golang/testmain_test.go @@ -0,0 +1,28 @@ +// 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) + } + if err = os.Setenv("TMPDIR", dir); err != nil { + fmt.Fprintln(os.Stderr, err) + os.RemoveAll(dir) + os.Exit(1) + } + code := m.Run() + if err = os.RemoveAll(dir); 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 476bd61..c870fc6 100644 --- a/go/internal/codeshape/inventory.go +++ b/go/internal/codeshape/inventory.go @@ -185,3 +185,16 @@ func physicalLines(source io.Reader) (int, bool, error) { } return count, generated, nil } +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 +} From ca09622c890b7a6fc531916edde4be0dc4ac45ec Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 10:51:38 -0500 Subject: [PATCH 10/54] wip(go): T2 split TypeScript collector (RIG-8435) --- go/internal/codeshape/bridge.go | 6 -- go/internal/codeshape/cli/cli.go | 5 +- go/internal/codeshape/collector_limits.go | 5 ++ .../codeshape/typescript/fixtures_test.go | 63 +++++++++++++++++++ .../codeshape/{ => typescript}/tsmetrics.go | 41 ++++++------ .../{ => typescript}/tsmetrics_test.go | 12 ++-- .../{ => typescript}/tssuppressions.go | 26 ++++---- 7 files changed, 113 insertions(+), 45 deletions(-) create mode 100644 go/internal/codeshape/collector_limits.go create mode 100644 go/internal/codeshape/typescript/fixtures_test.go rename go/internal/codeshape/{ => typescript}/tsmetrics.go (89%) rename go/internal/codeshape/{ => typescript}/tsmetrics_test.go (94%) rename go/internal/codeshape/{ => typescript}/tssuppressions.go (67%) diff --git a/go/internal/codeshape/bridge.go b/go/internal/codeshape/bridge.go index 76184c3..e375650 100644 --- a/go/internal/codeshape/bridge.go +++ b/go/internal/codeshape/bridge.go @@ -3,15 +3,9 @@ package codeshape import ( - "context" - "cuelang.org/go/cue/cuecontext" ) -func TSPolicyFindings(ctx context.Context, root string, cfg Config) ([]Finding, error) { - return tsPolicyFindings(ctx, root, cfg) -} - func EmptyAllowlist() Allowlist { return emptyAllowlist() } func DecodeBaseAllowlist(data []byte) (Allowlist, error) { diff --git a/go/internal/codeshape/cli/cli.go b/go/internal/codeshape/cli/cli.go index b5793bf..81235da 100644 --- a/go/internal/codeshape/cli/cli.go +++ b/go/internal/codeshape/cli/cli.go @@ -16,6 +16,7 @@ import ( "github.com/RigelBuild/meissa/go/internal/codeshape" "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") @@ -66,7 +67,7 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s return 2 } } - tsFunctions, tsSkips, err := codeshape.TSMetrics(ctx, root, config) + tsFunctions, tsSkips, err := typescript.TSMetrics(ctx, root, config) if err != nil { writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 @@ -84,7 +85,7 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 } - tsFindings, err := codeshape.TSPolicyFindings(ctx, root, config) + tsFindings, err := typescript.PolicyFindings(ctx, root, config) if err != nil { writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 diff --git a/go/internal/codeshape/collector_limits.go b/go/internal/codeshape/collector_limits.go new file mode 100644 index 0000000..041606c --- /dev/null +++ b/go/internal/codeshape/collector_limits.go @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package codeshape + +func (limits FunctionLimits) LimitFor(metric Metric) int { return limits.limitFor(metric) } diff --git a/go/internal/codeshape/typescript/fixtures_test.go b/go/internal/codeshape/typescript/fixtures_test.go new file mode 100644 index 0000000..74e2519 --- /dev/null +++ b/go/internal/codeshape/typescript/fixtures_test.go @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package typescript + +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 + if out, err := cmd.CombinedOutput(); 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)) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0644); 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 + if out, err := cmd.CombinedOutput(); 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{}} +} + +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") + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + for name, content := range map[string]string{"config.cue": config, "allowlist.cue": allowlist} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + t.Fatal(err) + } + } +} diff --git a/go/internal/codeshape/tsmetrics.go b/go/internal/codeshape/typescript/tsmetrics.go similarity index 89% rename from go/internal/codeshape/tsmetrics.go rename to go/internal/codeshape/typescript/tsmetrics.go index 4d53046..a17605e 100644 --- a/go/internal/codeshape/tsmetrics.go +++ b/go/internal/codeshape/typescript/tsmetrics.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -package codeshape +package typescript import ( "bytes" @@ -17,6 +17,7 @@ import ( "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" @@ -25,10 +26,10 @@ import ( const maxBiomeDiagnostics = 1_000_000 -func biomeRuleMetrics() map[string]Metric { - return map[string]Metric{ - "lint/complexity/noExcessiveCognitiveComplexity": MetricTSCognitive, - "lint/complexity/noExcessiveLinesPerFunction": MetricTSLines, +func biomeRuleMetrics() map[string]codeshape.Metric { + return map[string]codeshape.Metric{ + "lint/complexity/noExcessiveCognitiveComplexity": codeshape.MetricTSCognitive, + "lint/complexity/noExcessiveLinesPerFunction": codeshape.MetricTSLines, } } @@ -71,14 +72,14 @@ type tsFunction struct { } // TSMetrics measures Biome's function complexity and line count rules. -func TSMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[string][]Entry, []Skip, error) { - paths, err := trackedPaths(ctx, root) +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, err } sources := make(map[string][]byte) for _, path := range paths { - if !isTypeScriptPath(path) || !countedPath(path, cfg) { + if !isTypeScriptPath(path) || !codeshape.CountedPath(path, cfg) { continue } //nolint:gosec // G304: path from the repo's tracked file list @@ -89,7 +90,7 @@ func TSMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[str sources[path] = source } if len(sources) == 0 { - return newFunctionMeasurements(), nil, nil + return codeshape.NewFunctionMeasurements(), nil, nil } binary, err := exec.LookPath("biome") if err != nil { @@ -107,7 +108,7 @@ func TSMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[str if report.Summary.DiagnosticsNotPrinted != 0 { return nil, nil, fmt.Errorf("%w: %d were not printed", errBiomeTruncated, report.Summary.DiagnosticsNotPrinted) } - skips := []Skip{} + skips := []codeshape.Skip{} if report.Summary.Changed+report.Summary.Unchanged != len(sourcePaths) { for _, path := range sourcePaths { single, singleErr := runBiome(ctx, binary, root, []string{path}) @@ -116,24 +117,24 @@ func TSMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[str } if single.Summary.Changed+single.Summary.Unchanged == 0 { delete(sources, path) - skips = append(skips, Skip{Path: path, Reason: "ignored by Biome"}) + skips = append(skips, codeshape.Skip{Path: path, Reason: "ignored by Biome"}) } } } - values := newFunctionMeasurements() + 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, Skip{Path: path, Reason: err.Error()}) + skips = append(skips, codeshape.Skip{Path: path, Reason: err.Error()}) delete(sources, path) continue } defer tree.Release() files[path] = functions for _, function := range functions { - appendFunctionValue(values, MetricTSCognitive, path, function.id, 0) - appendFunctionValue(values, MetricTSLines, path, function.id, 0) + codeshape.AppendFunctionValue(values, codeshape.MetricTSCognitive, path, function.id, 0) + codeshape.AppendFunctionValue(values, codeshape.MetricTSLines, path, function.id, 0) } } metrics := biomeRuleMetrics() @@ -144,7 +145,7 @@ func TSMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[str } path := filepath.ToSlash(diagnostic.Location.Path) functions, exists := files[path] - if _, included := sources[path]; !included && slices.ContainsFunc(skips, func(skip Skip) bool { return skip.Path == path }) { + if _, included := sources[path]; !included && slices.ContainsFunc(skips, func(skip codeshape.Skip) bool { return skip.Path == path }) { continue } if !exists { @@ -160,7 +161,7 @@ func TSMetrics(ctx context.Context, root string, cfg Config) (map[Metric]map[str if err != nil { return nil, nil, err } - appendFunctionValue(values, metric, path, function.id, value) + codeshape.AppendFunctionValue(values, metric, path, function.id, value) } return values, skips, nil } @@ -239,11 +240,11 @@ func numericValue(value any) int { } } -func biomeValue(message string, metric Metric) (int, error) { +func biomeValue(message string, metric codeshape.Metric) (int, error) { fields := strings.Fields(message) for index, field := range fields { - if index+1 < len(fields) && (metric == MetricTSCognitive && field == "of" || - metric == MetricTSLines && field == "lines") { + 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) diff --git a/go/internal/codeshape/tsmetrics_test.go b/go/internal/codeshape/typescript/tsmetrics_test.go similarity index 94% rename from go/internal/codeshape/tsmetrics_test.go rename to go/internal/codeshape/typescript/tsmetrics_test.go index b482aec..8b52c9b 100644 --- a/go/internal/codeshape/tsmetrics_test.go +++ b/go/internal/codeshape/typescript/tsmetrics_test.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -package codeshape +package typescript import ( "fmt" @@ -9,6 +9,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/RigelBuild/meissa/go/internal/codeshape" ) const tsBiomeConfig = `{"linter":{"rules":{"complexity":{"noExcessiveCognitiveComplexity":` + @@ -52,12 +54,12 @@ func TestTSIgnoredFileIsSkipped(t *testing.T) { } trackedFile(t, root, "ignored.ts", "function ignored() {}\n") commitFiles(t, root) - cfg, _, err := Load(root) + cfg, _, err := codeshape.Load(root) if err != nil { t.Fatal(err) } values, skips, err := TSMetrics(t.Context(), root, cfg) - if err != nil || len(skips) != 1 || skips[0].Path != "ignored.ts" || len(values[MetricTSLines]["ignored.ts"]) != 0 { + if err != nil || len(skips) != 1 || skips[0].Path != "ignored.ts" || len(values[codeshape.MetricTSLines]["ignored.ts"]) != 0 { t.Fatalf("values=%v skips=%v err=%v", values, skips, err) } } @@ -103,7 +105,7 @@ func TestTSManyOffenders(t *testing.T) { fmt.Fprintf(&source, "function f%d() {\n%s}\n", i, strings.Repeat("console.log(1);\n", 51)) } root := tsRepo(t, source.String()) - cfg, _, err := Load(root) + cfg, _, err := codeshape.Load(root) if err != nil { t.Fatal(err) } @@ -112,7 +114,7 @@ func TestTSManyOffenders(t *testing.T) { t.Fatal(err) } count := 0 - for _, e := range values[MetricTSLines]["x.ts"] { + for _, e := range values[codeshape.MetricTSLines]["x.ts"] { if e.Value > 50 { count++ } diff --git a/go/internal/codeshape/tssuppressions.go b/go/internal/codeshape/typescript/tssuppressions.go similarity index 67% rename from go/internal/codeshape/tssuppressions.go rename to go/internal/codeshape/typescript/tssuppressions.go index 8621add..70279ee 100644 --- a/go/internal/codeshape/tssuppressions.go +++ b/go/internal/codeshape/typescript/tssuppressions.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -package codeshape +package typescript import ( "context" @@ -9,17 +9,19 @@ import ( "path/filepath" "strconv" "strings" + + "github.com/RigelBuild/meissa/go/internal/codeshape" ) -func tsPolicyFindings(ctx context.Context, root string, cfg Config) ([]Finding, error) { - paths, err := trackedPaths(ctx, root) +func PolicyFindings(ctx context.Context, root string, cfg codeshape.Config) ([]codeshape.Finding, error) { + paths, err := codeshape.TrackedPaths(ctx, root) if err != nil { return nil, err } - findings := []Finding{} + findings := []codeshape.Finding{} hasTS := false for _, path := range paths { - if !isTypeScriptPath(path) || !countedPath(path, cfg) { + if !isTypeScriptPath(path) || !codeshape.CountedPath(path, cfg) { continue } hasTS = true @@ -35,7 +37,7 @@ func tsPolicyFindings(ctx context.Context, root string, cfg Config) ([]Finding, directive, _, _ := strings.Cut(line[pos:], ":") for name, metric := range biomeRuleMetrics() { if strings.Contains(directive, strings.TrimPrefix(name, "lint/")) { - findings = append(findings, Finding{ + findings = append(findings, codeshape.Finding{ Path: path + ":" + strconv.Itoa(i+1), Metric: metric, Value: i + 1, Limit: 0, Fix: "remove biome-ignore suppression", }) @@ -56,17 +58,17 @@ func tsPolicyFindings(ctx context.Context, root string, cfg Config) ([]Finding, rules := nestedMap(config, "linter", "rules", "complexity") for _, check := range []struct { name, option string - metric Metric + metric codeshape.Metric }{ - {"noExcessiveCognitiveComplexity", "maxAllowedComplexity", MetricTSCognitive}, - {"noExcessiveLinesPerFunction", "maxLines", MetricTSLines}, + {"noExcessiveCognitiveComplexity", "maxAllowedComplexity", codeshape.MetricTSCognitive}, + {"noExcessiveLinesPerFunction", "maxLines", codeshape.MetricTSLines}, } { rule, _ := rules[check.name].(map[string]any) options, _ := rule["options"].(map[string]any) - if value, ok := options[check.option]; ok && numericValue(value) != cfg.Functions.limitFor(check.metric) { - findings = append(findings, Finding{ + if value, ok := options[check.option]; ok && numericValue(value) != cfg.Functions.LimitFor(check.metric) { + findings = append(findings, codeshape.Finding{ Path: "biome.json", Metric: check.metric, Value: numericValue(value), - Limit: cfg.Functions.limitFor(check.metric), Fix: "match biome rule option to gate config", + Limit: cfg.Functions.LimitFor(check.metric), Fix: "match biome rule option to gate config", }) } } From 7bd8334e444484cc076d5867ca3271e019335fd8 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 10:57:40 -0500 Subject: [PATCH 11/54] wip(go): T2 split config package (RIG-8435) --- go/internal/codeshape/bridge.go | 26 ---- go/internal/codeshape/cli/cli.go | 7 +- go/internal/codeshape/cli/cli_test.go | 7 +- go/internal/codeshape/collector.go | 17 +++ go/internal/codeshape/collector_limits.go | 5 - go/internal/codeshape/config/fixtures_test.go | 19 +++ go/internal/codeshape/{ => config}/load.go | 124 ++++++------------ .../codeshape/{ => config}/load_test.go | 6 +- go/internal/codeshape/{ => config}/render.go | 16 ++- .../codeshape/{ => config}/render_test.go | 16 ++- go/internal/codeshape/{ => config}/schema.cue | 0 go/internal/codeshape/model.go | 54 ++++++++ .../codeshape/typescript/tsmetrics_test.go | 5 +- 13 files changed, 162 insertions(+), 140 deletions(-) delete mode 100644 go/internal/codeshape/bridge.go delete mode 100644 go/internal/codeshape/collector_limits.go create mode 100644 go/internal/codeshape/config/fixtures_test.go rename go/internal/codeshape/{ => config}/load.go (59%) rename go/internal/codeshape/{ => config}/load_test.go (97%) rename go/internal/codeshape/{ => config}/render.go (79%) rename go/internal/codeshape/{ => config}/render_test.go (76%) rename go/internal/codeshape/{ => config}/schema.cue (100%) create mode 100644 go/internal/codeshape/model.go diff --git a/go/internal/codeshape/bridge.go b/go/internal/codeshape/bridge.go deleted file mode 100644 index e375650..0000000 --- a/go/internal/codeshape/bridge.go +++ /dev/null @@ -1,26 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package codeshape - -import ( - "cuelang.org/go/cue/cuecontext" -) - -func EmptyAllowlist() Allowlist { return emptyAllowlist() } - -func DecodeBaseAllowlist(data []byte) (Allowlist, error) { - ctx := cuecontext.New() - schema, err := schemaValue(ctx) - if err != nil { - return Allowlist{}, err - } - return decodeAllowlistBytes(ctx, schema, data, "base allowlist") -} - -func RewriteFunctionEntries(updated, allow, values FunctionMeasurements, cfg Config, seed bool) (bool, []string) { - return rewriteFunctionEntries(updated, allow, values, cfg, seed) -} - -func (cfg Config) LimitFor(metric Metric, path string) int { return cfg.limitFor(metric, path) } -func DisplayPath(metric Metric, path string) string { return displayPath(metric, path) } -func SortFindings(findings []Finding) { sortFindings(findings) } diff --git a/go/internal/codeshape/cli/cli.go b/go/internal/codeshape/cli/cli.go index 81235da..2c21fe6 100644 --- a/go/internal/codeshape/cli/cli.go +++ b/go/internal/codeshape/cli/cli.go @@ -15,6 +15,7 @@ import ( "strings" "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" ) @@ -39,7 +40,7 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 } - config, allow, err := codeshape.Load(root) + config, allow, err := shapeconfig.Load(root) if err != nil { writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 @@ -143,7 +144,7 @@ func writeUpdated( writeOutput(stderr, "code-shape-gate: open repository: %v\n", openErr) return 2 } - err = repository.WriteFile("tools/code-shape-gate/allowlist.cue", codeshape.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 { @@ -198,7 +199,7 @@ func loadBaseAllowlist(ctx context.Context, root, ref string) (codeshape.Allowli if err != nil { return codeshape.Allowlist{}, gitBaseError(ref, err) } - allowlist, err := codeshape.DecodeBaseAllowlist(data) + allowlist, err := shapeconfig.DecodeBaseAllowlist(data) if err != nil { return codeshape.Allowlist{}, err } diff --git a/go/internal/codeshape/cli/cli_test.go b/go/internal/codeshape/cli/cli_test.go index e5267f8..21eb3b5 100644 --- a/go/internal/codeshape/cli/cli_test.go +++ b/go/internal/codeshape/cli/cli_test.go @@ -5,6 +5,7 @@ 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" @@ -234,7 +235,7 @@ func TestMainModes(t *testing.T) { if code != 0 { t.Fatalf("Main = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } - _, allowlist, err := codeshape.Load(root) + _, allowlist, err := shapeconfig.Load(root) if err != nil { t.Fatal(err) } @@ -255,7 +256,7 @@ func TestMainWriteUpdates(t *testing.T) { if code != 0 { t.Fatalf("Main = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } - _, allowlist, err := codeshape.Load(root) + _, allowlist, err := shapeconfig.Load(root) if err != nil { t.Fatal(err) } @@ -273,7 +274,7 @@ func TestMainWriteUpdates(t *testing.T) { if code != 0 { t.Fatalf("Main = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } - _, allowlist, err := codeshape.Load(root) + _, allowlist, err := shapeconfig.Load(root) if err != nil { t.Fatal(err) } diff --git a/go/internal/codeshape/collector.go b/go/internal/codeshape/collector.go index 9c6ee3d..3fcebfe 100644 --- a/go/internal/codeshape/collector.go +++ b/go/internal/codeshape/collector.go @@ -11,3 +11,20 @@ func NewFunctionMeasurements() map[Metric]map[string][]Entry { return n func AppendFunctionValue(values map[Metric]map[string][]Entry, metric Metric, path, id string, value int) { appendFunctionValue(values, metric, path, id, value) } +func EmptyAllowlist() Allowlist { return emptyAllowlist() } + +func RewriteFunctionEntries(updated, allow, values FunctionMeasurements, cfg Config, seed bool) (bool, []string) { + return rewriteFunctionEntries(updated, allow, values, cfg, seed) +} + +func (cfg Config) LimitFor(metric Metric, path string) int { return cfg.limitFor(metric, path) } +func DisplayPath(metric Metric, path string) string { return displayPath(metric, path) } +func SortFindings(findings []Finding) { sortFindings(findings) } +func IsFunctionMetric(metric Metric) bool { return isFunctionMetric(metric) } +func ValidateFunctionEntryIDs(entries map[string][]Entry) error { + return validateFunctionEntryIDs(entries) +} +func FunctionMetrics() []Metric { return functionMetrics() } +func SortedFunctionEntries(entries []Entry) []Entry { return sortedFunctionEntries(entries) } +func SortedEntryPaths[V any](entries map[string]V) []string { return sortedEntryPaths(entries) } +func (limits FunctionLimits) LimitFor(metric Metric) int { return limits.limitFor(metric) } diff --git a/go/internal/codeshape/collector_limits.go b/go/internal/codeshape/collector_limits.go deleted file mode 100644 index 041606c..0000000 --- a/go/internal/codeshape/collector_limits.go +++ /dev/null @@ -1,5 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package codeshape - -func (limits FunctionLimits) LimitFor(metric Metric) int { return limits.limitFor(metric) } diff --git a/go/internal/codeshape/config/fixtures_test.go b/go/internal/codeshape/config/fixtures_test.go new file mode 100644 index 0000000..4011aa8 --- /dev/null +++ b/go/internal/codeshape/config/fixtures_test.go @@ -0,0 +1,19 @@ +// 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 + if output, err := cmd.CombinedOutput(); 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 6891f43..9dfe9c8 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 ( @@ -29,63 +31,6 @@ var ( //go:embed schema.cue var schemaFiles embed.FS -// 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. -// 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"` - Functions map[Metric]map[string][]Entry `json:"functions,omitempty"` - Present bool `json:"-"` -} - -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 -} - func schemaValue(ctx *cue.Context) (cue.Value, error) { data, err := schemaFiles.ReadFile("schema.cue") if err != nil { @@ -99,11 +44,20 @@ func schemaValue(ctx *cue.Context) (cue.Value, error) { return value, nil } +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() @@ -113,40 +67,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 @@ -178,47 +132,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[Metric]map[string][]Entry{} + allowlist.Functions = map[codeshape.Metric]map[string][]codeshape.Entry{} } for metric, paths := range allowlist.Functions { - if !isFunctionMetric(metric) { - return Allowlist{}, fmt.Errorf("%s: %w %q", name, errUnsupportedMetric, metric) + if !codeshape.IsFunctionMetric(metric) { + return codeshape.Allowlist{}, fmt.Errorf("%s: %w %q", name, errUnsupportedMetric, metric) } - err := validateFunctionEntryIDs(paths) + err := codeshape.ValidateFunctionEntryIDs(paths) if err != nil { - return Allowlist{}, fmt.Errorf("%s: %w", name, err) + 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, ".") @@ -226,7 +180,7 @@ 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 } diff --git a/go/internal/codeshape/load_test.go b/go/internal/codeshape/config/load_test.go similarity index 97% rename from go/internal/codeshape/load_test.go rename to go/internal/codeshape/config/load_test.go index db72dd7..c4b36c8 100644 --- a/go/internal/codeshape/load_test.go +++ b/go/internal/codeshape/config/load_test.go @@ -1,12 +1,14 @@ // 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 TestLoad(t *testing.T) { @@ -57,7 +59,7 @@ func TestLoadFunctionEntries(t *testing.T) { if (err == nil) != tc.valid { t.Fatalf("Load error = %v", err) } - if tc.valid && (cfg.Functions.Go.Lines != 60 || entries.Functions[MetricGoLines]["f.go"][0].ID != "work") { + 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) } }) diff --git a/go/internal/codeshape/render.go b/go/internal/codeshape/config/render.go similarity index 79% rename from go/internal/codeshape/render.go rename to go/internal/codeshape/config/render.go index 1c2f758..f971ec6 100644 --- a/go/internal/codeshape/render.go +++ b/go/internal/codeshape/config/render.go @@ -1,15 +1,17 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -package codeshape +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 Allowlist) []byte { +func Render(allow codeshape.Allowlist) []byte { var output strings.Builder writeEntries(&output, "files", allow.Files) writeEntries(&output, "dirs", allow.Dirs) @@ -20,7 +22,7 @@ func Render(allow Allowlist) []byte { return []byte(output.String()) } -func writeEntries(output *strings.Builder, name string, entries map[string]Entry) { +func writeEntries(output *strings.Builder, name string, entries map[string]codeshape.Entry) { if len(entries) == 0 { return } @@ -38,16 +40,16 @@ func writeEntries(output *strings.Builder, name string, entries map[string]Entry output.WriteString("}\n") } -func writeFunctionEntries(output *strings.Builder, functions map[Metric]map[string][]Entry) { - for _, metric := range functionMetrics() { +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 sortedEntryPaths(paths) { + for _, path := range codeshape.SortedEntryPaths(paths) { output.WriteString("\t" + strconv.Quote(path) + ": [\n") - for _, entry := range sortedFunctionEntries(paths[path]) { + 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") } diff --git a/go/internal/codeshape/render_test.go b/go/internal/codeshape/config/render_test.go similarity index 76% rename from go/internal/codeshape/render_test.go rename to go/internal/codeshape/config/render_test.go index dc0b48a..f562d44 100644 --- a/go/internal/codeshape/render_test.go +++ b/go/internal/codeshape/config/render_test.go @@ -1,20 +1,22 @@ // 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{ + 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": {ID: "", Value: 11, Reason: "split package tests"}}, + Dirs: map[string]codeshape.Entry{"pkg": {ID: "", Value: 11, Reason: "split package tests"}}, Functions: nil, Present: false, } @@ -34,11 +36,11 @@ func TestRender(t *testing.T) { } func TestRenderRoundTrip(t *testing.T) { - allow := Allowlist{ - Files: map[string]Entry{ + allow := codeshape.Allowlist{ + Files: map[string]codeshape.Entry{ `large"file.go`: {ID: "", Value: 501, Reason: `split "quoted" concern`}, }, - Dirs: map[string]Entry{"pkg": {ID: "", Value: 11, Reason: "split package tests"}}, + Dirs: map[string]codeshape.Entry{"pkg": {ID: "", Value: 11, Reason: "split package tests"}}, Functions: nil, Present: false, } diff --git a/go/internal/codeshape/schema.cue b/go/internal/codeshape/config/schema.cue similarity index 100% rename from go/internal/codeshape/schema.cue rename to go/internal/codeshape/config/schema.cue diff --git a/go/internal/codeshape/model.go b/go/internal/codeshape/model.go new file mode 100644 index 0000000..5ef05fb --- /dev/null +++ b/go/internal/codeshape/model.go @@ -0,0 +1,54 @@ +// 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:"-"` +} + +func emptyAllowlist() Allowlist { + return Allowlist{Files: map[string]Entry{}, Dirs: map[string]Entry{}, Functions: map[Metric]map[string][]Entry{}} +} + +// 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/typescript/tsmetrics_test.go b/go/internal/codeshape/typescript/tsmetrics_test.go index 8b52c9b..59e5c03 100644 --- a/go/internal/codeshape/typescript/tsmetrics_test.go +++ b/go/internal/codeshape/typescript/tsmetrics_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/RigelBuild/meissa/go/internal/codeshape" + shapeconfig "github.com/RigelBuild/meissa/go/internal/codeshape/config" ) const tsBiomeConfig = `{"linter":{"rules":{"complexity":{"noExcessiveCognitiveComplexity":` + @@ -54,7 +55,7 @@ func TestTSIgnoredFileIsSkipped(t *testing.T) { } trackedFile(t, root, "ignored.ts", "function ignored() {}\n") commitFiles(t, root) - cfg, _, err := codeshape.Load(root) + cfg, _, err := shapeconfig.Load(root) if err != nil { t.Fatal(err) } @@ -105,7 +106,7 @@ func TestTSManyOffenders(t *testing.T) { fmt.Fprintf(&source, "function f%d() {\n%s}\n", i, strings.Repeat("console.log(1);\n", 51)) } root := tsRepo(t, source.String()) - cfg, _, err := codeshape.Load(root) + cfg, _, err := shapeconfig.Load(root) if err != nil { t.Fatal(err) } From 9e65ed5822db280ea200fdaee172bdc19fb83762 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 11:05:45 -0500 Subject: [PATCH 12/54] wip(go): T2 lint clean after split (RIG-8435) --- go/internal/codeshape/cli/cli.go | 11 +++-- go/internal/codeshape/cli/cli_test.go | 13 +++-- go/internal/codeshape/cli/testmain_test.go | 8 ++-- go/internal/codeshape/collector.go | 48 +++++++++++++++---- go/internal/codeshape/config/fixtures_test.go | 3 +- go/internal/codeshape/config/load.go | 1 + go/internal/codeshape/golang/fixtures_test.go | 27 +++++++---- go/internal/codeshape/golang/gometrics.go | 31 +++++------- .../codeshape/golang/gosuppressions.go | 3 +- go/internal/codeshape/golang/testmain_test.go | 8 ++-- go/internal/codeshape/model.go | 3 +- go/internal/codeshape/testmain_test.go | 8 ++-- .../codeshape/typescript/fixtures_test.go | 26 +++++----- go/internal/codeshape/typescript/tsmetrics.go | 29 +++++++---- .../codeshape/typescript/tsmetrics_test.go | 6 ++- .../codeshape/typescript/tssuppressions.go | 3 +- 16 files changed, 150 insertions(+), 78 deletions(-) diff --git a/go/internal/codeshape/cli/cli.go b/go/internal/codeshape/cli/cli.go index 2c21fe6..780ae8b 100644 --- a/go/internal/codeshape/cli/cli.go +++ b/go/internal/codeshape/cli/cli.go @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +// Package cli implements the code shape gate command. package cli import ( @@ -201,7 +202,7 @@ func loadBaseAllowlist(ctx context.Context, root, ref string) (codeshape.Allowli } allowlist, err := shapeconfig.DecodeBaseAllowlist(data) if err != nil { - return codeshape.Allowlist{}, err + return codeshape.Allowlist{}, fmt.Errorf("decode base allowlist: %w", err) } allowlist.Present = true return allowlist, nil @@ -215,7 +216,9 @@ func gitBaseError(ref string, err error) error { return fmt.Errorf("read base allowlist at %q: %w", ref, err) } -func rewriteAllowlist(cfg codeshape.Config, allow codeshape.Allowlist, snapshot codeshape.Snapshot, seed bool) (codeshape.Allowlist, bool, error) { +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)), @@ -226,7 +229,9 @@ func rewriteAllowlist(cfg codeshape.Config, allow codeshape.Allowlist, snapshot maps.Copy(updated.Dirs, allow.Dirs) 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) + 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...) diff --git a/go/internal/codeshape/cli/cli_test.go b/go/internal/codeshape/cli/cli_test.go index 21eb3b5..0555556 100644 --- a/go/internal/codeshape/cli/cli_test.go +++ b/go/internal/codeshape/cli/cli_test.go @@ -302,10 +302,11 @@ func TestMainWriteUpdates(t *testing.T) { 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"}, - }}}} + 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: ""}, }}} @@ -313,7 +314,9 @@ func TestRewriteFunctionEntries(t *testing.T) { 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) + 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) } diff --git a/go/internal/codeshape/cli/testmain_test.go b/go/internal/codeshape/cli/testmain_test.go index 494d4d4..6e857a2 100644 --- a/go/internal/codeshape/cli/testmain_test.go +++ b/go/internal/codeshape/cli/testmain_test.go @@ -14,13 +14,15 @@ func TestMain(m *testing.M) { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - if err = os.Setenv("TMPDIR", dir); err != nil { + err = os.Setenv("TMPDIR", dir) + if err != nil { fmt.Fprintln(os.Stderr, err) - os.RemoveAll(dir) + fmt.Fprintln(os.Stderr, os.RemoveAll(dir)) os.Exit(1) } code := m.Run() - if err = os.RemoveAll(dir); err != nil { + err = os.RemoveAll(dir) + if err != nil { fmt.Fprintln(os.Stderr, err) code = 1 } diff --git a/go/internal/codeshape/collector.go b/go/internal/codeshape/collector.go index 3fcebfe..c87a6ca 100644 --- a/go/internal/codeshape/collector.go +++ b/go/internal/codeshape/collector.go @@ -1,30 +1,60 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +// Package codeshape defines code-shape measurements and judgments. package codeshape import "context" +// TrackedPaths lists tracked repository paths. func TrackedPaths(ctx context.Context, root string) ([]string, error) { return trackedPaths(ctx, root) } -func CountedPath(path string, cfg Config) bool { return countedPath(path, cfg) } -func MatchesAny(path string, patterns []string) bool { return matchesAny(path, patterns) } -func NewFunctionMeasurements() map[Metric]map[string][]Entry { return newFunctionMeasurements() } + +// CountedPath reports whether the configuration includes a path. +func CountedPath(path string, cfg Config) bool { return countedPath(path, cfg) } + +// MatchesAny reports whether a path matches a pattern. +func MatchesAny(path string, patterns []string) bool { return matchesAny(path, patterns) } + +// NewFunctionMeasurements initializes the function metric maps. +func NewFunctionMeasurements() map[Metric]map[string][]Entry { return newFunctionMeasurements() } + +// AppendFunctionValue records a function measurement. func AppendFunctionValue(values map[Metric]map[string][]Entry, metric Metric, path, id string, value int) { appendFunctionValue(values, metric, path, id, value) } + +// EmptyAllowlist creates an empty allowlist. func EmptyAllowlist() Allowlist { return emptyAllowlist() } +// RewriteFunctionEntries updates the function ratchet entries. func RewriteFunctionEntries(updated, allow, values FunctionMeasurements, cfg Config, seed bool) (bool, []string) { return rewriteFunctionEntries(updated, allow, values, cfg, seed) } +// LimitFor returns the limit for a path and metric. func (cfg Config) LimitFor(metric Metric, path string) int { return cfg.limitFor(metric, path) } -func DisplayPath(metric Metric, path string) string { return displayPath(metric, path) } -func SortFindings(findings []Finding) { sortFindings(findings) } -func IsFunctionMetric(metric Metric) bool { return isFunctionMetric(metric) } + +// DisplayPath formats a path for a finding. +func DisplayPath(metric Metric, path string) string { return displayPath(metric, path) } + +// SortFindings orders findings by path and metric. +func SortFindings(findings []Finding) { sortFindings(findings) } + +// IsFunctionMetric reports whether a metric measures functions. +func IsFunctionMetric(metric Metric) bool { return isFunctionMetric(metric) } + +// ValidateFunctionEntryIDs checks function entry identifiers. func ValidateFunctionEntryIDs(entries map[string][]Entry) error { return validateFunctionEntryIDs(entries) } -func FunctionMetrics() []Metric { return functionMetrics() } -func SortedFunctionEntries(entries []Entry) []Entry { return sortedFunctionEntries(entries) } + +// FunctionMetrics lists supported function metrics. +func FunctionMetrics() []Metric { return functionMetrics() } + +// SortedFunctionEntries returns function entries ordered by identifier. +func SortedFunctionEntries(entries []Entry) []Entry { return sortedFunctionEntries(entries) } + +// SortedEntryPaths returns the sorted keys of a path map. func SortedEntryPaths[V any](entries map[string]V) []string { return sortedEntryPaths(entries) } -func (limits FunctionLimits) LimitFor(metric Metric) int { return limits.limitFor(metric) } + +// LimitFor returns the threshold for a function metric. +func (limits FunctionLimits) LimitFor(metric Metric) int { return limits.limitFor(metric) } diff --git a/go/internal/codeshape/config/fixtures_test.go b/go/internal/codeshape/config/fixtures_test.go index 4011aa8..4cc6448 100644 --- a/go/internal/codeshape/config/fixtures_test.go +++ b/go/internal/codeshape/config/fixtures_test.go @@ -12,7 +12,8 @@ func newFixtureRepo(t *testing.T) string { root := t.TempDir() cmd := exec.CommandContext(t.Context(), "git", "init", "-q") cmd.Dir = root - if output, err := cmd.CombinedOutput(); err != nil { + output, err := cmd.CombinedOutput() + if err != nil { t.Fatalf("git init: %v: %s", err, output) } return root diff --git a/go/internal/codeshape/config/load.go b/go/internal/codeshape/config/load.go index 9dfe9c8..9e1e61d 100644 --- a/go/internal/codeshape/config/load.go +++ b/go/internal/codeshape/config/load.go @@ -44,6 +44,7 @@ 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) diff --git a/go/internal/codeshape/golang/fixtures_test.go b/go/internal/codeshape/golang/fixtures_test.go index bab6e2d..dec4656 100644 --- a/go/internal/codeshape/golang/fixtures_test.go +++ b/go/internal/codeshape/golang/fixtures_test.go @@ -13,10 +13,13 @@ import ( 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"}} { + 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 - if out, err := cmd.CombinedOutput(); err != nil { + out, err := cmd.CombinedOutput() + if err != nil { t.Fatalf("git: %v: %s", err, out) } } @@ -25,10 +28,12 @@ func newFixtureRepo(t *testing.T) string { func trackedFile(t *testing.T, root, path, content string) { t.Helper() path = filepath.Join(root, filepath.FromSlash(path)) - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + err := os.MkdirAll(filepath.Dir(path), 0755) + if err != nil { t.Fatal(err) } - if err := os.WriteFile(path, []byte(content), 0644); err != nil { + err = os.WriteFile(path, []byte(content), 0644) + if err != nil { t.Fatal(err) } } @@ -37,13 +42,17 @@ func commitFiles(t *testing.T, root string) { for _, args := range [][]string{{"add", "--all"}, {"commit", "-qm", "fixture"}} { cmd := exec.CommandContext(t.Context(), "git", args...) cmd.Dir = root - if out, err := cmd.CombinedOutput(); err != nil { + 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{}} + 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" + @@ -52,11 +61,13 @@ const validConfig = "fileLimit: 500\ndirLimit: 10\nextensions: [\".go\", \".cue\ func writeConfigFiles(t *testing.T, root, config, allowlist string) { t.Helper() dir := filepath.Join(root, "tools", "code-shape-gate") - if err := os.MkdirAll(dir, 0755); err != nil { + err := os.MkdirAll(dir, 0755) + if err != nil { t.Fatal(err) } for name, content := range map[string]string{"config.cue": config, "allowlist.cue": allowlist} { - if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + err = os.WriteFile(filepath.Join(dir, name), []byte(content), 0644) + if err != nil { t.Fatal(err) } } diff --git a/go/internal/codeshape/golang/gometrics.go b/go/internal/codeshape/golang/gometrics.go index a335a54..cd2afe8 100644 --- a/go/internal/codeshape/golang/gometrics.go +++ b/go/internal/codeshape/golang/gometrics.go @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +// Package golang collects Go function measurements. package golang import ( @@ -29,8 +30,6 @@ const highFunlenLimit = 1_000_000_000 var ( gocognitValue = regexp.MustCompile(`cognitive complexity ([0-9]+) of func`) funlenValue = regexp.MustCompile(`\(([0-9]+) > [0-9]+\)`) - errEmptyGoModule = errors.New("has no included source files") - errEmptyGoMeasurement = errors.New("go function measurement is empty") errGoLintReport = errors.New("reported an error") errGoLintMissing = errors.New("measurement omitted") errGoMetricParse = errors.New("cannot parse value") @@ -74,10 +73,12 @@ type goFunction struct { } // 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) { +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, err + return nil, nil, fmt.Errorf("collect Go paths: %w", err) } modules := goModules(paths, cfg) if len(modules) == 0 { @@ -106,11 +107,14 @@ func GoMetrics(ctx context.Context, root string, cfg codeshape.Config) (map[code }{ {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}, + {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} { + for _, metric := range []codeshape.Metric{ + codeshape.MetricGoCognitive, codeshape.MetricGoLines, codeshape.MetricGoStatements, + } { codeshape.AppendFunctionValue(measurements, metric, function.path, function.id, 0) } } @@ -170,7 +174,9 @@ func excludedFunctionPath(path string, cfg codeshape.Config) bool { return codeshape.MatchesAny(filepath.ToSlash(path), cfg.Exclude) } -func parseGoSources(root, module string, paths []string, cfg codeshape.Config) (map[string]goSourceFile, []codeshape.Skip, error) { +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 @@ -469,14 +475,3 @@ func findGoFunction( } return path, match.id, nil } - -func hasAnyFunctionMeasurement(values map[codeshape.Metric]map[string][]codeshape.Entry) bool { - for _, paths := range values { - for _, entries := range paths { - if len(entries) > 0 { - return true - } - } - } - return false -} diff --git a/go/internal/codeshape/golang/gosuppressions.go b/go/internal/codeshape/golang/gosuppressions.go index 644551a..47bf7cd 100644 --- a/go/internal/codeshape/golang/gosuppressions.go +++ b/go/internal/codeshape/golang/gosuppressions.go @@ -15,10 +15,11 @@ import ( "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, err + return nil, fmt.Errorf("collect Go suppression paths: %w", err) } modules := goModules(paths, cfg) eligible := make(map[string]bool) diff --git a/go/internal/codeshape/golang/testmain_test.go b/go/internal/codeshape/golang/testmain_test.go index cf84a42..148ad09 100644 --- a/go/internal/codeshape/golang/testmain_test.go +++ b/go/internal/codeshape/golang/testmain_test.go @@ -14,13 +14,15 @@ func TestMain(m *testing.M) { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - if err = os.Setenv("TMPDIR", dir); err != nil { + err = os.Setenv("TMPDIR", dir) + if err != nil { fmt.Fprintln(os.Stderr, err) - os.RemoveAll(dir) + fmt.Fprintln(os.Stderr, os.RemoveAll(dir)) os.Exit(1) } code := m.Run() - if err = os.RemoveAll(dir); err != nil { + err = os.RemoveAll(dir) + if err != nil { fmt.Fprintln(os.Stderr, err) code = 1 } diff --git a/go/internal/codeshape/model.go b/go/internal/codeshape/model.go index 5ef05fb..09d5757 100644 --- a/go/internal/codeshape/model.go +++ b/go/internal/codeshape/model.go @@ -34,7 +34,8 @@ type Allowlist struct { } func emptyAllowlist() Allowlist { - return Allowlist{Files: map[string]Entry{}, Dirs: map[string]Entry{}, Functions: map[Metric]map[string][]Entry{}} + return Allowlist{Files: map[string]Entry{}, Dirs: map[string]Entry{}, + Functions: map[Metric]map[string][]Entry{}, Present: false} } // Snapshot contains current measurements. diff --git a/go/internal/codeshape/testmain_test.go b/go/internal/codeshape/testmain_test.go index 683a578..9ece698 100644 --- a/go/internal/codeshape/testmain_test.go +++ b/go/internal/codeshape/testmain_test.go @@ -14,13 +14,15 @@ func TestMain(m *testing.M) { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - if err = os.Setenv("TMPDIR", dir); err != nil { + err = os.Setenv("TMPDIR", dir) + if err != nil { fmt.Fprintln(os.Stderr, err) - os.RemoveAll(dir) + fmt.Fprintln(os.Stderr, os.RemoveAll(dir)) os.Exit(1) } code := m.Run() - if err = os.RemoveAll(dir); err != nil { + err = os.RemoveAll(dir) + if err != nil { fmt.Fprintln(os.Stderr, err) code = 1 } diff --git a/go/internal/codeshape/typescript/fixtures_test.go b/go/internal/codeshape/typescript/fixtures_test.go index 74e2519..d5473f7 100644 --- a/go/internal/codeshape/typescript/fixtures_test.go +++ b/go/internal/codeshape/typescript/fixtures_test.go @@ -3,7 +3,6 @@ package typescript import ( - "github.com/RigelBuild/meissa/go/internal/codeshape" "os" "os/exec" "path/filepath" @@ -13,10 +12,13 @@ import ( 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"}} { + 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 - if out, err := cmd.CombinedOutput(); err != nil { + out, err := cmd.CombinedOutput() + if err != nil { t.Fatalf("git: %v: %s", err, out) } } @@ -25,10 +27,12 @@ func newFixtureRepo(t *testing.T) string { func trackedFile(t *testing.T, root, path, content string) { t.Helper() path = filepath.Join(root, filepath.FromSlash(path)) - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + err := os.MkdirAll(filepath.Dir(path), 0755) + if err != nil { t.Fatal(err) } - if err := os.WriteFile(path, []byte(content), 0644); err != nil { + err = os.WriteFile(path, []byte(content), 0644) + if err != nil { t.Fatal(err) } } @@ -37,14 +41,12 @@ func commitFiles(t *testing.T, root string) { for _, args := range [][]string{{"add", "--all"}, {"commit", "-qm", "fixture"}} { cmd := exec.CommandContext(t.Context(), "git", args...) cmd.Dir = root - if out, err := cmd.CombinedOutput(); err != nil { + 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{}} -} 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" @@ -52,11 +54,13 @@ const validConfig = "fileLimit: 500\ndirLimit: 10\nextensions: [\".go\", \".cue\ func writeConfigFiles(t *testing.T, root, config, allowlist string) { t.Helper() dir := filepath.Join(root, "tools", "code-shape-gate") - if err := os.MkdirAll(dir, 0755); err != nil { + err := os.MkdirAll(dir, 0755) + if err != nil { t.Fatal(err) } for name, content := range map[string]string{"config.cue": config, "allowlist.cue": allowlist} { - if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + 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 index a17605e..2ae451f 100644 --- a/go/internal/codeshape/typescript/tsmetrics.go +++ b/go/internal/codeshape/typescript/tsmetrics.go @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +// Package typescript collects TypeScript function measurements. package typescript import ( @@ -72,10 +73,12 @@ type tsFunction struct { } // 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) { +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, err + return nil, nil, fmt.Errorf("collect TypeScript paths: %w", err) } sources := make(map[string][]byte) for _, path := range paths { @@ -145,7 +148,9 @@ func TSMetrics(ctx context.Context, root string, cfg codeshape.Config) (map[code } path := filepath.ToSlash(diagnostic.Location.Path) functions, exists := files[path] - if _, included := sources[path]; !included && slices.ContainsFunc(skips, func(skip codeshape.Skip) bool { return skip.Path == path }) { + if _, included := sources[path]; !included && slices.ContainsFunc(skips, func(skip codeshape.Skip) bool { + return skip.Path == path + }) { continue } if !exists { @@ -178,7 +183,8 @@ func isTypeScriptPath(path string) bool { func runBiome(ctx context.Context, binary, root 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") + "--only=complexity/noExcessiveCognitiveComplexity", "--only=complexity/noExcessiveLinesPerFunction", + "--no-errors-on-unmatched") args = append(args, paths...) command := exec.CommandContext(ctx, binary, args...) //nolint:gosec // G204: execute the biome linter command.Dir = root @@ -275,7 +281,8 @@ func parseTSFunctions(path string, source []byte) ([]tsFunction, *gotreesitter.T for i := range functions { parentID := "" for j := i - 1; j >= 0; j-- { - if functions[j].node.StartByte() <= functions[i].node.StartByte() && functions[j].node.EndByte() >= functions[i].node.EndByte() { + if functions[j].node.StartByte() <= functions[i].node.StartByte() && + functions[j].node.EndByte() >= functions[i].node.EndByte() { parentID = functions[j].id + "/" break } @@ -313,7 +320,8 @@ func collectTSFunctions( 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.")) { + 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) @@ -337,7 +345,8 @@ func collectTSFunctions( 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": + case "function_declaration", "function_expression", "arrow_function", "method_definition", + "generator_function_declaration", "generator_function": return true default: return false @@ -352,7 +361,8 @@ func firstStringArgument(arguments *gotreesitter.Node, lang *gotreesitter.Langua return "" } child := arguments.NamedChild(0) - if child.Type(lang) != "string" && (child.Type(lang) != "template_string" || strings.Contains(child.Text(source), "${")) { + if child.Type(lang) != "string" && + (child.Type(lang) != "template_string" || strings.Contains(child.Text(source), "${")) { return "" } return strings.Trim(strings.TrimSpace(child.Text(source)), "`\"'") @@ -376,7 +386,8 @@ func tsFunctionID(node *gotreesitter.Node, lang *gotreesitter.Language, titlePat 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") { + } 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) } diff --git a/go/internal/codeshape/typescript/tsmetrics_test.go b/go/internal/codeshape/typescript/tsmetrics_test.go index 59e5c03..607bae3 100644 --- a/go/internal/codeshape/typescript/tsmetrics_test.go +++ b/go/internal/codeshape/typescript/tsmetrics_test.go @@ -50,7 +50,8 @@ func TestTSOnlyOffUsesOptions(t *testing.T) { func TestTSIgnoredFileIsSkipped(t *testing.T) { root := tsRepo(t, "function f() {}\n") config := strings.Replace(tsBiomeConfig, `{`, `{"files":{"includes":["**", "!ignored.ts"]},`, 1) - if err := os.WriteFile(filepath.Join(root, "biome.json"), []byte(config), 0600); err != nil { + 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") @@ -60,7 +61,8 @@ func TestTSIgnoredFileIsSkipped(t *testing.T) { t.Fatal(err) } values, skips, err := TSMetrics(t.Context(), root, cfg) - if err != nil || len(skips) != 1 || skips[0].Path != "ignored.ts" || len(values[codeshape.MetricTSLines]["ignored.ts"]) != 0 { + if err != nil || len(skips) != 1 || skips[0].Path != "ignored.ts" || + len(values[codeshape.MetricTSLines]["ignored.ts"]) != 0 { t.Fatalf("values=%v skips=%v err=%v", values, skips, err) } } diff --git a/go/internal/codeshape/typescript/tssuppressions.go b/go/internal/codeshape/typescript/tssuppressions.go index 70279ee..8c377db 100644 --- a/go/internal/codeshape/typescript/tssuppressions.go +++ b/go/internal/codeshape/typescript/tssuppressions.go @@ -13,10 +13,11 @@ import ( "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, err + return nil, fmt.Errorf("collect TypeScript policy paths: %w", err) } findings := []codeshape.Finding{} hasTS := false From aa6467d6c875d57b57053774344c556a39324f25 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 11:15:17 -0500 Subject: [PATCH 13/54] wip(go): T2 cognitive complexity (RIG-8435) --- go/internal/codeshape/cli/cli.go | 71 +++--- go/internal/codeshape/functions.go | 105 +++++---- go/internal/codeshape/golang/gometrics.go | 202 +++++++++++------- .../codeshape/golang/gosuppressions.go | 84 ++++---- go/internal/codeshape/inventory.go | 47 ++-- go/internal/codeshape/typescript/tsmetrics.go | 105 +++++---- .../codeshape/typescript/tssuppressions.go | 36 ++-- 7 files changed, 385 insertions(+), 265 deletions(-) diff --git a/go/internal/codeshape/cli/cli.go b/go/internal/codeshape/cli/cli.go index 780ae8b..7552b93 100644 --- a/go/internal/codeshape/cli/cli.go +++ b/go/internal/codeshape/cli/cli.go @@ -59,41 +59,11 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 } - functions, skips, err := golang.GoMetrics(ctx, root, config) - if err != nil { - writeOutput(stderr, "code-shape-gate: %v\n", err) - return 2 - } - for _, skip := range skips { - if !writeOutput(stderr, "code-shape-gate: skip %s: %s\n", skip.Path, skip.Reason) { - return 2 - } - } - tsFunctions, tsSkips, err := typescript.TSMetrics(ctx, root, config) - if err != nil { - writeOutput(stderr, "code-shape-gate: %v\n", err) - return 2 - } - for _, skip := range tsSkips { - if !writeOutput(stderr, "code-shape-gate: skip %s: %s\n", skip.Path, skip.Reason) { - return 2 - } - } - functions[codeshape.MetricTSCognitive] = tsFunctions[codeshape.MetricTSCognitive] - functions[codeshape.MetricTSLines] = tsFunctions[codeshape.MetricTSLines] - snapshot.Functions = functions - suppressions, err := golang.SuppressionFindings(ctx, root, config) + suppressions, err := collectMeasurements(ctx, root, config, &snapshot, stderr) if err != nil { writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 } - tsFindings, err := typescript.PolicyFindings(ctx, root, config) - if err != nil { - writeOutput(stderr, "code-shape-gate: %v\n", err) - return 2 - } - suppressions = append(suppressions, tsFindings...) - codeshape.SortFindings(suppressions) if len(suppressions) > 0 { err := writeFindings(stdout, suppressions) if err != nil { @@ -117,6 +87,45 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s return 0 } +func collectMeasurements( + ctx context.Context, root string, config codeshape.Config, snapshot *codeshape.Snapshot, stderr io.Writer, +) ([]codeshape.Finding, error) { + functions, skips, err := golang.GoMetrics(ctx, root, config) + if err != nil { + return nil, fmt.Errorf("collect Go metrics: %w", err) + } + for _, skip := range skips { + _, err := fmt.Fprintf(stderr, "code-shape-gate: skip %s: %s\n", skip.Path, skip.Reason) + if err != nil { + return nil, fmt.Errorf("write skip: %w", err) + } + } + tsFunctions, tsSkips, err := typescript.TSMetrics(ctx, root, config) + if err != nil { + return nil, fmt.Errorf("collect TypeScript metrics: %w", err) + } + for _, skip := range tsSkips { + _, err := fmt.Fprintf(stderr, "code-shape-gate: skip %s: %s\n", skip.Path, skip.Reason) + if err != nil { + return nil, fmt.Errorf("write skip: %w", 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, fmt.Errorf("collect Go suppressions: %w", err) + } + tsFindings, err := typescript.PolicyFindings(ctx, root, config) + if err != nil { + return nil, fmt.Errorf("collect TypeScript policy: %w", err) + } + suppressions = append(suppressions, tsFindings...) + codeshape.SortFindings(suppressions) + return suppressions, nil +} + func writeUpdated( root string, config codeshape.Config, diff --git a/go/internal/codeshape/functions.go b/go/internal/codeshape/functions.go index 2383de1..faf3c0b 100644 --- a/go/internal/codeshape/functions.go +++ b/go/internal/codeshape/functions.go @@ -110,55 +110,74 @@ func rewriteFunctionEntries(updated, allow, values FunctionMeasurements, cfg Con changed := false errs := []string{} for _, metric := range functionMetrics() { - paths := map[string][]Entry{} - for path, entries := range allow[metric] { - for _, entry := range entries { - current, exists := functionEntry(values[metric][path], entry.ID) - if !exists || current.Value <= cfg.Functions.limitFor(metric) { - changed = true - continue - } - if current.Value < entry.Value { - entry.Value = current.Value - changed = true - } - paths[path] = append(paths[path], entry) - } + 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 } - for path, entries := range values[metric] { - for _, current := range entries { - if current.Value <= cfg.Functions.limitFor(metric) { - 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"} - } + } + 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 - if exists { - for i := range paths[path] { - if paths[path][i].ID == current.ID { - paths[path][i] = entry - } - } - } else { - paths[path] = append(paths[path], entry) - } changed = true } + paths[path] = append(paths[path], entry) } - if len(paths) > 0 { - updated[metric] = paths + } + 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/gometrics.go b/go/internal/codeshape/golang/gometrics.go index cd2afe8..649bf0b 100644 --- a/go/internal/codeshape/golang/gometrics.go +++ b/go/internal/codeshape/golang/gometrics.go @@ -99,58 +99,78 @@ func GoMetrics(ctx context.Context, root string, cfg codeshape.Config) ( if len(files) == 0 { continue } - limits := []struct { - metric codeshape.Metric - linter string - lines int - 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) - } + 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) + } + 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, report, measurements) + if err != nil { + return err + } + } + return nil +} + +func applyGoIssues(root, module string, files map[string]goSourceFile, cfg codeshape.Config, + metric codeshape.Metric, report golangciReport, measurements map[codeshape.Metric]map[string][]codeshape.Entry, +) error { + for _, issue := range report.Issues { + path := issue.Pos.Filename + if filepath.IsAbs(path) { + var err error + path, err = filepath.Rel(root, path) if err != nil { - return nil, nil, err - } - for _, issue := range report.Issues { - path := issue.Pos.Filename - if filepath.IsAbs(path) { - path, err = filepath.Rel(root, path) - if err != nil { - return nil, nil, 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, limit.metric) - if err != nil { - return nil, nil, err - } - path, function, err := findGoFunction(root, module, files, issue.Pos.Filename, issue.Pos.Line, issue.Pos.Column) - if err != nil { - return nil, nil, err - } - codeshape.AppendFunctionValue(measurements, limit.metric, path, function, value) + 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 measurements, skips, nil + return nil } func goModules(paths []string, cfg codeshape.Config) []string { @@ -189,24 +209,7 @@ func parseGoSources(root, module string, paths []string, cfg codeshape.Config) ( } sort.Strings(modules) for _, path := range paths { - if filepath.Ext(path) != ".go" || excludedFunctionPath(path, cfg) { - continue - } - if module != "." && !strings.HasPrefix(filepath.ToSlash(path), strings.TrimSuffix(module, "/")+"/") { - continue - } - nested := false - for _, dir := range modules { - if dir != "." && dir != module && (module == "." || strings.HasPrefix(dir, module+"/")) && - strings.HasPrefix(filepath.ToSlash(path), dir+"/") { - nested = true - break - } - } - if nested { - continue - } - if !codeshape.CountedPath(filepath.ToSlash(path), cfg) { + if !eligibleGoSource(path, module, modules, cfg) { continue } fullPath := filepath.Join(root, filepath.FromSlash(path)) @@ -237,26 +240,29 @@ func parseGoSources(root, module string, paths []string, cfg codeshape.Config) ( return files, skips, nil } -func goFunctions(file *ast.File, set *token.FileSet) []goFunction { - functions := make([]goFunction, 0) - 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 - } - } +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: @@ -275,6 +281,33 @@ func goFunctions(file *ast.File, set *token.FileSet) []goFunction { 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 @@ -295,6 +328,10 @@ func goFunctions(file *ast.File, set *token.FileSet) []goFunction { } } } + return parent +} + +func assignGoFunctionIDs(functions []goFunction, parent []int) { closureCounts := make(map[int]int) duplicates := make(map[string]int) totals := make(map[string]int) @@ -322,7 +359,6 @@ func goFunctions(file *ast.File, set *token.FileSet) []goFunction { functions[index].id = name } } - return functions } func runGolangci( diff --git a/go/internal/codeshape/golang/gosuppressions.go b/go/internal/codeshape/golang/gosuppressions.go index 47bf7cd..55c3319 100644 --- a/go/internal/codeshape/golang/gosuppressions.go +++ b/go/internal/codeshape/golang/gosuppressions.go @@ -5,6 +5,7 @@ package golang import ( "context" "fmt" + "go/ast" "go/parser" "go/token" "os" @@ -46,44 +47,53 @@ func SuppressionFindings(ctx context.Context, root string, cfg codeshape.Config) if err != nil { return nil, fmt.Errorf("parse Go source %q: %w", path, err) } - for _, group := range file.Comments { - for _, comment := range group.List { - if !strings.HasPrefix(comment.Text, "//") { - continue - } - text := strings.TrimSpace(strings.TrimPrefix(comment.Text, "//")) - fields := strings.Fields(text) - if len(fields) == 0 { - continue - } - directive := fields[0] - metrics := make(map[codeshape.Metric]bool) - 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 - } - } - } - for metric := range metrics { - line := set.Position(comment.Pos()).Line - findings = append(findings, codeshape.Finding{ - Path: path + ":" + strconv.Itoa(line), Metric: metric, Value: line, Limit: 0, - Fix: "remove complexity nolint suppression", - }) - } - } - } + 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: line, 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/inventory.go b/go/internal/codeshape/inventory.go index c870fc6..27312cc 100644 --- a/go/internal/codeshape/inventory.go +++ b/go/internal/codeshape/inventory.go @@ -155,26 +155,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,6 +173,25 @@ 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 +} func trackedPaths(ctx context.Context, root string) ([]string, error) { command := exec.CommandContext(ctx, "git", "ls-files", "-z") command.Dir = root diff --git a/go/internal/codeshape/typescript/tsmetrics.go b/go/internal/codeshape/typescript/tsmetrics.go index 2ae451f..760984c 100644 --- a/go/internal/codeshape/typescript/tsmetrics.go +++ b/go/internal/codeshape/typescript/tsmetrics.go @@ -111,18 +111,9 @@ func TSMetrics(ctx context.Context, root string, cfg codeshape.Config) ( if report.Summary.DiagnosticsNotPrinted != 0 { return nil, nil, fmt.Errorf("%w: %d were not printed", errBiomeTruncated, report.Summary.DiagnosticsNotPrinted) } - skips := []codeshape.Skip{} - if report.Summary.Changed+report.Summary.Unchanged != len(sourcePaths) { - for _, path := range sourcePaths { - single, singleErr := runBiome(ctx, binary, root, []string{path}) - if singleErr != nil { - return nil, nil, singleErr - } - if single.Summary.Changed+single.Summary.Unchanged == 0 { - delete(sources, path) - skips = append(skips, codeshape.Skip{Path: path, Reason: "ignored by Biome"}) - } - } + skips, err := biomeSkippedPaths(ctx, binary, root, sourcePaths, report, sources) + if err != nil { + return nil, nil, err } values := codeshape.NewFunctionMeasurements() files := make(map[string][]tsFunction, len(sources)) @@ -140,6 +131,36 @@ func TSMetrics(ctx context.Context, root string, cfg codeshape.Config) ( 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 biomeSkippedPaths(ctx context.Context, binary, root string, paths []string, report biomeReport, + sources map[string][]byte, +) ([]codeshape.Skip, error) { + skips := []codeshape.Skip{} + if report.Summary.Changed+report.Summary.Unchanged == len(paths) { + return skips, nil + } + for _, path := range paths { + single, err := runBiome(ctx, binary, root, []string{path}) + if err != nil { + return nil, err + } + if single.Summary.Changed+single.Summary.Unchanged == 0 { + delete(sources, path) + skips = append(skips, codeshape.Skip{Path: path, Reason: "ignored by Biome"}) + } + } + return skips, 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] @@ -154,21 +175,21 @@ func TSMetrics(ctx context.Context, root string, cfg codeshape.Config) ( continue } if !exists { - return nil, nil, fmt.Errorf("%w %q", errBiomeUnknownSource, path) + 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 nil, nil, err + return err } value, err := biomeValue(diagnostic.Message, metric) if err != nil { - return nil, nil, err + return err } codeshape.AppendFunctionValue(values, metric, path, function.id, value) } - return values, skips, nil + return nil } func isTypeScriptPath(path string) bool { @@ -277,6 +298,11 @@ func parseTSFunctions(path string, source []byte) ([]tsFunction, *gotreesitter.T } 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 := "" @@ -303,7 +329,6 @@ func parseTSFunctions(path string, source []byte) ([]tsFunction, *gotreesitter.T functions[i].id += fmt.Sprintf("#%d", counts[base]) } } - return functions, tree, nil } func collectTSFunctions( @@ -405,18 +430,41 @@ func findTSFunction(functions []tsFunction, line, column int, source []byte) (*t 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 nil, fmt.Errorf("biome position line %d is outside source: %w", line, errBiomeOutsideSource) + 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 nil, fmt.Errorf("biome position %d:%d is outside source: %w", line, column, errBiomeOutsideSource) + return 0, fmt.Errorf("biome position %d:%d is outside source: %w", line, column, errBiomeOutsideSource) } r, size := utf8.DecodeRune(source[offset:]) units++ @@ -426,22 +474,7 @@ func findTSFunction(functions []tsFunction, line, column int, source []byte) (*t offset += size } if offset > len(source) { - return nil, fmt.Errorf("biome position %d:%d is outside source: %w", line, column, errBiomeOutsideSource) + return 0, fmt.Errorf("biome position %d:%d is outside source: %w", line, column, errBiomeOutsideSource) } - 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 + return offset, nil } diff --git a/go/internal/codeshape/typescript/tssuppressions.go b/go/internal/codeshape/typescript/tssuppressions.go index 8c377db..e876107 100644 --- a/go/internal/codeshape/typescript/tssuppressions.go +++ b/go/internal/codeshape/typescript/tssuppressions.go @@ -30,21 +30,7 @@ func PolicyFindings(ctx context.Context, root string, cfg codeshape.Config) ([]c if err != nil { return nil, fmt.Errorf("read TypeScript source %q: %w", path, err) } - for i, line := range strings.Split(string(source), "\n") { - pos := strings.Index(line, "biome-ignore") - if pos < 0 { - continue - } - directive, _, _ := strings.Cut(line[pos:], ":") - for name, metric := range biomeRuleMetrics() { - if strings.Contains(directive, strings.TrimPrefix(name, "lint/")) { - findings = append(findings, codeshape.Finding{ - Path: path + ":" + strconv.Itoa(i+1), Metric: metric, Value: i + 1, Limit: 0, - Fix: "remove biome-ignore suppression", - }) - } - } - } + findings = append(findings, tsDirectiveFindings(path, source)...) } if !hasTS { return findings, nil @@ -75,3 +61,23 @@ func PolicyFindings(ctx context.Context, root string, cfg codeshape.Config) ([]c } return findings, nil } + +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:], ":") + for name, metric := range biomeRuleMetrics() { + if strings.Contains(directive, strings.TrimPrefix(name, "lint/")) { + findings = append(findings, codeshape.Finding{ + Path: path + ":" + strconv.Itoa(i+1), Metric: metric, Value: i + 1, Limit: 0, + Fix: "remove biome-ignore suppression", + }) + } + } + } + return findings +} From a94e38a2a51e4e1f6fba4066f757599b36ebd50a Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 11:19:45 -0500 Subject: [PATCH 14/54] wip(go): T2 biome suppression findings (RIG-8435) --- go/internal/codeshape/cli/cli.go | 27 +++++++++---------- go/internal/codeshape/cli/collectors_test.go | 17 ++++++++++++ .../codeshape/typescript/tssuppressions.go | 19 +++++++++---- .../typescript/tssuppressions_test.go | 19 +++++++++++++ 4 files changed, 63 insertions(+), 19 deletions(-) create mode 100644 go/internal/codeshape/typescript/tssuppressions_test.go diff --git a/go/internal/codeshape/cli/cli.go b/go/internal/codeshape/cli/cli.go index 7552b93..52aa386 100644 --- a/go/internal/codeshape/cli/cli.go +++ b/go/internal/codeshape/cli/cli.go @@ -64,18 +64,11 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 } - if len(suppressions) > 0 { - err := writeFindings(stdout, suppressions) - if err != nil { - writeOutput(stderr, "code-shape-gate: write output: %v\n", err) - return 2 - } - return 1 - } if writeMode { - return writeUpdated(root, config, allow, base, snapshot, seed, stdout, stderr) + return writeUpdated(root, config, allow, base, snapshot, seed, stdout, stderr, suppressions) } - findings := codeshape.Judge(config, allow, base, snapshot) + findings := append(codeshape.Judge(config, allow, base, snapshot), suppressions...) + codeshape.SortFindings(findings) err = writeFindings(stdout, findings) if err != nil { writeOutput(stderr, "code-shape-gate: write output: %v\n", err) @@ -133,6 +126,7 @@ func writeUpdated( snapshot codeshape.Snapshot, seed bool, stdout, stderr io.Writer, + policy ...[]codeshape.Finding, ) int { updated, changed, err := rewriteAllowlist(config, allow, snapshot, seed) if err != nil { @@ -140,15 +134,17 @@ func writeUpdated( return 2 } findings := codeshape.Judge(config, updated, base, snapshot) + ratchetFailures := len(findings) > 0 + if len(policy) > 0 { + findings = append(findings, policy[0]...) + } + 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 { + if changed && !ratchetFailures { repository, openErr := os.OpenRoot(root) if openErr != nil { writeOutput(stderr, "code-shape-gate: open repository: %v\n", openErr) @@ -162,6 +158,9 @@ func writeUpdated( return 2 } } + if len(findings) > 0 { + return 1 + } return 0 } diff --git a/go/internal/codeshape/cli/collectors_test.go b/go/internal/codeshape/cli/collectors_test.go index ddffdc8..c5d64f4 100644 --- a/go/internal/codeshape/cli/collectors_test.go +++ b/go/internal/codeshape/cli/collectors_test.go @@ -106,6 +106,23 @@ func tsRepo(t *testing.T, source string) string { 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) diff --git a/go/internal/codeshape/typescript/tssuppressions.go b/go/internal/codeshape/typescript/tssuppressions.go index e876107..b2839b2 100644 --- a/go/internal/codeshape/typescript/tssuppressions.go +++ b/go/internal/codeshape/typescript/tssuppressions.go @@ -70,12 +70,21 @@ func tsDirectiveFindings(path string, source []byte) []codeshape.Finding { 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() { - if strings.Contains(directive, strings.TrimPrefix(name, "lint/")) { - findings = append(findings, codeshape.Finding{ - Path: path + ":" + strconv.Itoa(i+1), Metric: metric, Value: i + 1, Limit: 0, - Fix: "remove biome-ignore suppression", - }) + 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 + } } } } 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) + } + }) + } +} From 69975720f62cf1923df65a2ac77ac2ac481969c3 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 11:21:54 -0500 Subject: [PATCH 15/54] wip(go): T2 biome drift options (RIG-8435) --- .../codeshape/typescript/drift_test.go | 40 +++++++++++++++++++ go/internal/codeshape/typescript/tsmetrics.go | 8 ++-- .../codeshape/typescript/tssuppressions.go | 36 ++++++++++++----- 3 files changed, 71 insertions(+), 13 deletions(-) create mode 100644 go/internal/codeshape/typescript/drift_test.go diff --git a/go/internal/codeshape/typescript/drift_test.go b/go/internal/codeshape/typescript/drift_test.go new file mode 100644 index 0000000..8dc0dba --- /dev/null +++ b/go/internal/codeshape/typescript/drift_test.go @@ -0,0 +1,40 @@ +// 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) + } +} + +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) + } +} diff --git a/go/internal/codeshape/typescript/tsmetrics.go b/go/internal/codeshape/typescript/tsmetrics.go index 760984c..3ebcfd8 100644 --- a/go/internal/codeshape/typescript/tsmetrics.go +++ b/go/internal/codeshape/typescript/tsmetrics.go @@ -230,15 +230,15 @@ func runBiome(ctx context.Context, binary, root string, paths []string) (biomeRe return report, nil } -func readBiomeConfig(root string) (map[string]any, error) { - data, err := os.ReadFile(filepath.Join(root, "biome.json")) //nolint:gosec // G304: read repo config +func readBiomeConfig(root, path string) (map[string]any, error) { + data, err := os.ReadFile(filepath.Join(root, path)) //nolint:gosec // G304: read repo config if err != nil { - return nil, fmt.Errorf("read biome.json: %w", err) + 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 biome.json: %w", err) + return nil, fmt.Errorf("parse %s: %w", path, err) } return config, nil } diff --git a/go/internal/codeshape/typescript/tssuppressions.go b/go/internal/codeshape/typescript/tssuppressions.go index b2839b2..fccc9d0 100644 --- a/go/internal/codeshape/typescript/tssuppressions.go +++ b/go/internal/codeshape/typescript/tssuppressions.go @@ -4,7 +4,9 @@ package typescript import ( "context" + "errors" "fmt" + "io/fs" "os" "path/filepath" "strconv" @@ -35,13 +37,24 @@ func PolicyFindings(ctx context.Context, root string, cfg codeshape.Config) ([]c if !hasTS { return findings, nil } - config, err := readBiomeConfig(root) - if os.IsNotExist(err) { + path := "biome.json" + config, err := readBiomeConfig(root, path) + if errors.Is(err, fs.ErrNotExist) { + path = "biome.jsonc" + config, err = readBiomeConfig(root, path) + } + if errors.Is(err, fs.ErrNotExist) { return findings, nil } if err != nil { return nil, err } + findings = append(findings, biomeDriftFindings(path, config, cfg, true)...) + return findings, nil +} + +func biomeDriftFindings(path string, config map[string]any, cfg codeshape.Config, require bool) []codeshape.Finding { + findings := []codeshape.Finding{} rules := nestedMap(config, "linter", "rules", "complexity") for _, check := range []struct { name, option string @@ -50,16 +63,21 @@ func PolicyFindings(ctx context.Context, root string, cfg codeshape.Config) ([]c {"noExcessiveCognitiveComplexity", "maxAllowedComplexity", codeshape.MetricTSCognitive}, {"noExcessiveLinesPerFunction", "maxLines", codeshape.MetricTSLines}, } { - rule, _ := rules[check.name].(map[string]any) + value, present := rules[check.name] + if !require && !present { + continue + } + rule, _ := value.(map[string]any) options, _ := rule["options"].(map[string]any) - if value, ok := options[check.option]; ok && numericValue(value) != cfg.Functions.LimitFor(check.metric) { - findings = append(findings, codeshape.Finding{ - Path: "biome.json", Metric: check.metric, Value: numericValue(value), - Limit: cfg.Functions.LimitFor(check.metric), Fix: "match biome rule option to gate config", - }) + 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, nil + return findings } func tsDirectiveFindings(path string, source []byte) []codeshape.Finding { From 73896ac987725d8664adea9448a3954e9d4580ed Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 11:23:04 -0500 Subject: [PATCH 16/54] wip(go): T2 optional function config (RIG-8435) --- go/internal/codeshape/config/load.go | 6 ++++++ go/internal/codeshape/config/load_test.go | 13 +++++++++++++ go/internal/codeshape/config/schema.cue | 2 +- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/go/internal/codeshape/config/load.go b/go/internal/codeshape/config/load.go index 9e1e61d..0dc3989 100644 --- a/go/internal/codeshape/config/load.go +++ b/go/internal/codeshape/config/load.go @@ -90,6 +90,12 @@ func loadFromRoot(repository *os.Root) (codeshape.Config, codeshape.Allowlist, e if err != nil { return codeshape.Config{}, codeshape.Allowlist{}, fmt.Errorf("decode config.cue: %w", err) } + if !configValue.LookupPath(cue.ParsePath("functions")).Exists() { + config.Functions = codeshape.FunctionLimits{ + Go: codeshape.GoFunctionLimits{Cognitive: 20, Lines: 60, Statements: 40}, + TS: codeshape.TSFunctionLimits{Cognitive: 15, Lines: 50}, + } + } err = validateConfig(config) if err != nil { return codeshape.Config{}, codeshape.Allowlist{}, err diff --git a/go/internal/codeshape/config/load_test.go b/go/internal/codeshape/config/load_test.go index c4b36c8..0102279 100644 --- a/go/internal/codeshape/config/load_test.go +++ b/go/internal/codeshape/config/load_test.go @@ -11,6 +11,19 @@ import ( "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) diff --git a/go/internal/codeshape/config/schema.cue b/go/internal/codeshape/config/schema.cue index 5e67e8a..cae30f5 100644 --- a/go/internal/codeshape/config/schema.cue +++ b/go/internal/codeshape/config/schema.cue @@ -36,7 +36,7 @@ exclude: [...string] // When multiple globs match, the smallest limit wins. dirLimits?: [string]: {limit: int & >10, reason: string & =~"^\\S+(\\s+\\S+){2,}$"} - functions: close({ + functions?: close({ go: close({cognitive: 20, lines: 60, statements: 40}) ts: close({cognitive: 15, lines: 50}) }) From dd1002c69e852544d06e76d71a1dfe2274e19b6f Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 11:24:33 -0500 Subject: [PATCH 17/54] wip(go): T2 report unmeasured extensions (RIG-8435) --- go/internal/codeshape/cli/cli.go | 18 ++++++++++++++++++ go/internal/codeshape/cli/cli_test.go | 9 +++++++++ 2 files changed, 27 insertions(+) diff --git a/go/internal/codeshape/cli/cli.go b/go/internal/codeshape/cli/cli.go index 52aa386..c72f1ca 100644 --- a/go/internal/codeshape/cli/cli.go +++ b/go/internal/codeshape/cli/cli.go @@ -46,6 +46,10 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s 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) @@ -80,6 +84,20 @@ 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, error) { diff --git a/go/internal/codeshape/cli/cli_test.go b/go/internal/codeshape/cli/cli_test.go index 0555556..b6ad301 100644 --- a/go/internal/codeshape/cli/cli_test.go +++ b/go/internal/codeshape/cli/cli_test.go @@ -13,6 +13,15 @@ import ( "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 From e1a4139019227208caf2612628e08aa88218f833 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 11:27:06 -0500 Subject: [PATCH 18/54] wip(go): T2 confine collector file reads (RIG-8435) --- go/internal/codeshape/collector.go | 18 +++++++++++++++++- go/internal/codeshape/golang/gometrics.go | 3 +-- go/internal/codeshape/golang/gosuppressions.go | 4 +--- go/internal/codeshape/inventory_test.go | 17 +++++++++++++++++ go/internal/codeshape/typescript/tsmetrics.go | 6 ++---- .../codeshape/typescript/tssuppressions.go | 4 +--- 6 files changed, 39 insertions(+), 13 deletions(-) diff --git a/go/internal/codeshape/collector.go b/go/internal/codeshape/collector.go index c87a6ca..f8318a3 100644 --- a/go/internal/codeshape/collector.go +++ b/go/internal/codeshape/collector.go @@ -3,11 +3,27 @@ // Package codeshape defines code-shape measurements and judgments. package codeshape -import "context" +import ( + "context" + "errors" + "fmt" + "os" +) // TrackedPaths lists tracked repository paths. func TrackedPaths(ctx context.Context, root string) ([]string, error) { return trackedPaths(ctx, root) } +// 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) +} + // CountedPath reports whether the configuration includes a path. func CountedPath(path string, cfg Config) bool { return countedPath(path, cfg) } diff --git a/go/internal/codeshape/golang/gometrics.go b/go/internal/codeshape/golang/gometrics.go index 649bf0b..79d8597 100644 --- a/go/internal/codeshape/golang/gometrics.go +++ b/go/internal/codeshape/golang/gometrics.go @@ -221,8 +221,7 @@ func parseGoSources(root, module string, paths []string, cfg codeshape.Config) ( skips = append(skips, codeshape.Skip{Path: path, Reason: "not in default Go build"}) continue } - //nolint:gosec // G304: path from the repo's tracked file list - source, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) + source, err := codeshape.ReadRepositoryFile(root, filepath.FromSlash(path)) if err != nil { return nil, nil, fmt.Errorf("read Go source %q: %w", path, err) } diff --git a/go/internal/codeshape/golang/gosuppressions.go b/go/internal/codeshape/golang/gosuppressions.go index 55c3319..ec56fae 100644 --- a/go/internal/codeshape/golang/gosuppressions.go +++ b/go/internal/codeshape/golang/gosuppressions.go @@ -8,8 +8,6 @@ import ( "go/ast" "go/parser" "go/token" - "os" - "path/filepath" "strconv" "strings" @@ -38,7 +36,7 @@ func SuppressionFindings(ctx context.Context, root string, cfg codeshape.Config) if !eligible[path] { continue } - source, err := os.ReadFile(filepath.Join(root, path)) //nolint:gosec // G304: path from the repo's tracked file list + source, err := codeshape.ReadRepositoryFile(root, path) if err != nil { return nil, fmt.Errorf("read Go source %q: %w", path, err) } diff --git a/go/internal/codeshape/inventory_test.go b/go/internal/codeshape/inventory_test.go index 67e61d9..dcbf63a 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() diff --git a/go/internal/codeshape/typescript/tsmetrics.go b/go/internal/codeshape/typescript/tsmetrics.go index 3ebcfd8..6a0d6fe 100644 --- a/go/internal/codeshape/typescript/tsmetrics.go +++ b/go/internal/codeshape/typescript/tsmetrics.go @@ -9,7 +9,6 @@ import ( "encoding/json" "errors" "fmt" - "os" "os/exec" "path/filepath" "slices" @@ -85,8 +84,7 @@ func TSMetrics(ctx context.Context, root string, cfg codeshape.Config) ( if !isTypeScriptPath(path) || !codeshape.CountedPath(path, cfg) { continue } - //nolint:gosec // G304: path from the repo's tracked file list - source, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) + source, err := codeshape.ReadRepositoryFile(root, filepath.FromSlash(path)) if err != nil { return nil, nil, fmt.Errorf("read TypeScript source %q: %w", path, err) } @@ -231,7 +229,7 @@ func runBiome(ctx context.Context, binary, root string, paths []string) (biomeRe } func readBiomeConfig(root, path string) (map[string]any, error) { - data, err := os.ReadFile(filepath.Join(root, path)) //nolint:gosec // G304: read repo config + data, err := codeshape.ReadRepositoryFile(root, path) if err != nil { return nil, fmt.Errorf("read %s: %w", path, err) } diff --git a/go/internal/codeshape/typescript/tssuppressions.go b/go/internal/codeshape/typescript/tssuppressions.go index fccc9d0..62e8af7 100644 --- a/go/internal/codeshape/typescript/tssuppressions.go +++ b/go/internal/codeshape/typescript/tssuppressions.go @@ -7,8 +7,6 @@ import ( "errors" "fmt" "io/fs" - "os" - "path/filepath" "strconv" "strings" @@ -28,7 +26,7 @@ func PolicyFindings(ctx context.Context, root string, cfg codeshape.Config) ([]c continue } hasTS = true - source, err := os.ReadFile(filepath.Join(root, path)) //nolint:gosec // G304: path from the repo's tracked file list + source, err := codeshape.ReadRepositoryFile(root, path) if err != nil { return nil, fmt.Errorf("read TypeScript source %q: %w", path, err) } From f38b2b8699a4f8f447b85a37105a38b5c5c8ed71 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 11:28:05 -0500 Subject: [PATCH 19/54] wip(go): T2 cover skip-only modules (RIG-8435) --- go/internal/codeshape/cli/collectors_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/go/internal/codeshape/cli/collectors_test.go b/go/internal/codeshape/cli/collectors_test.go index c5d64f4..d8894b2 100644 --- a/go/internal/codeshape/cli/collectors_test.go +++ b/go/internal/codeshape/cli/collectors_test.go @@ -25,6 +25,14 @@ func runGoGate(t *testing.T, root string) (int, string, string) { 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\nfunc F() {}\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 TestGoLimitsCLI(t *testing.T) { var source strings.Builder source.WriteString("package p\nfunc Cognitive(x int) {\n") From 17a51306b509a2deea53887187c6f70306a6e3d4 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 11:29:18 -0500 Subject: [PATCH 20/54] wip(go): T2 fix suppression finding values (RIG-8435) --- go/internal/codeshape/golang/gometrics_test.go | 2 +- go/internal/codeshape/golang/gosuppressions.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/go/internal/codeshape/golang/gometrics_test.go b/go/internal/codeshape/golang/gometrics_test.go index 1612121..9ba1766 100644 --- a/go/internal/codeshape/golang/gometrics_test.go +++ b/go/internal/codeshape/golang/gometrics_test.go @@ -179,7 +179,7 @@ func TestGoBareAndAllSuppressions(t *testing.T) { 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 == line && finding.Metric == metric { + if finding.Path == fmt.Sprintf("x.go:%d", line) && finding.Value == 1 && finding.Metric == metric { found = true } } diff --git a/go/internal/codeshape/golang/gosuppressions.go b/go/internal/codeshape/golang/gosuppressions.go index ec56fae..18adb25 100644 --- a/go/internal/codeshape/golang/gosuppressions.go +++ b/go/internal/codeshape/golang/gosuppressions.go @@ -58,7 +58,7 @@ func goCommentFindings(path string, groups []*ast.CommentGroup, set *token.FileS 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: line, Limit: 0, + Path: path + ":" + strconv.Itoa(line), Metric: metric, Value: 1, Limit: 0, Fix: "remove complexity nolint suppression", }) } From 7c04f9a1565c8b214716f971fff6924c6edc9531 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 11:30:26 -0500 Subject: [PATCH 21/54] wip(go): T2 document function gate (RIG-8435) --- README.md | 8 ++++++++ go/CHANGELOG.md | 3 +++ 2 files changed, 11 insertions(+) diff --git a/README.md b/README.md index 5b22bbf..2bb4b0a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,14 @@ The policy is the shared `[MD0nn]` rule set from `rumdl/base.toml` in this repo. `code-shape-gate` checks tracked source-file line counts and direct tracked-file counts per directory. Add `tools/code-shape-gate/config.cue` with `fileLimit`, `dirLimit`, counted `extensions`, and path `exclude` globs. The fixed limits are 500 physical lines per file and 10 direct files per directory, unless a directory matches a `dirLimits` exception. File counts omit tracked files under `testdata` or `fixtures`, generated headers in the first five lines, and configured excludes. Directory counts include every tracked file except configured excludes. 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. Files that cannot be measured are reported as skips on stderr; skips do not change the exit code. + +```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: diff --git a/go/CHANGELOG.md b/go/CHANGELOG.md index 635bb4c..40a8fda 100644 --- a/go/CHANGELOG.md +++ b/go/CHANGELOG.md @@ -35,6 +35,9 @@ ### Added +- `code-shape-gate` measures Go and TypeScript functions, reports skipped files and unmeasured extensions, and detects suppression and Biome policy drift. + + - 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. From 3402f68092cc809cf06a1dc759913e99b518653c Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 11:32:00 -0500 Subject: [PATCH 22/54] wip(go): T2 fix README spacing (RIG-8435) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2bb4b0a..dc1e0dc 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ The policy is the shared `[MD0nn]` rule set from `rumdl/base.toml` in this repo. `code-shape-gate` checks tracked source-file line counts and direct tracked-file counts per directory. Add `tools/code-shape-gate/config.cue` with `fileLimit`, `dirLimit`, counted `extensions`, and path `exclude` globs. The fixed limits are 500 physical lines per file and 10 direct files per directory, unless a directory matches a `dirLimits` exception. File counts omit tracked files under `testdata` or `fixtures`, generated headers in the first five lines, and configured excludes. Directory counts include every tracked file except configured excludes. 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. Files that cannot be measured are reported as skips on stderr; skips do not change the exit code. @@ -56,7 +57,6 @@ Function allowlist entries are keyed by metric, path and function ID. Go IDs use 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 From 8494a4021735334c9fc454d4756f0c6ac9676d9f Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 11:34:21 -0500 Subject: [PATCH 23/54] wip(go): T2 check nested biome drift (RIG-8435) --- .../codeshape/typescript/drift_test.go | 21 +++++++++ .../codeshape/typescript/tssuppressions.go | 44 ++++++++++++++----- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/go/internal/codeshape/typescript/drift_test.go b/go/internal/codeshape/typescript/drift_test.go index 8dc0dba..4837514 100644 --- a/go/internal/codeshape/typescript/drift_test.go +++ b/go/internal/codeshape/typescript/drift_test.go @@ -38,3 +38,24 @@ func TestBiomeJSONCAndMissingConfig(t *testing.T) { 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":500}}}}}}`) + trackedFile(t, root, "biome.json", `{"linter":{"rules":{"complexity":{`+ + `"noExcessiveCognitiveComplexity":{"level":"off","options":{"maxAllowedComplexity":15}},`+ + `"noExcessiveLinesPerFunction":{"level":"off","options":{"maxLines":50}}}}},`+ + `"overrides":[{"linter":{"rules":{"complexity":`+ + `{"noExcessiveLinesPerFunction":{"level":"off","options":{"maxLines":500}}}}}}]}`) + 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) + } +} diff --git a/go/internal/codeshape/typescript/tssuppressions.go b/go/internal/codeshape/typescript/tssuppressions.go index 62e8af7..b51c6e1 100644 --- a/go/internal/codeshape/typescript/tssuppressions.go +++ b/go/internal/codeshape/typescript/tssuppressions.go @@ -4,9 +4,8 @@ package typescript import ( "context" - "errors" "fmt" - "io/fs" + "path/filepath" "strconv" "strings" @@ -35,22 +34,43 @@ func PolicyFindings(ctx context.Context, root string, cfg codeshape.Config) ([]c if !hasTS { return findings, nil } - path := "biome.json" - config, err := readBiomeConfig(root, path) - if errors.Is(err, fs.ErrNotExist) { - path = "biome.jsonc" - config, err = readBiomeConfig(root, path) - } - if errors.Is(err, fs.ErrNotExist) { - return findings, nil - } + drift, err := trackedBiomeDrift(root, paths, cfg) if err != nil { return nil, err } - findings = append(findings, biomeDriftFindings(path, config, cfg, true)...) + 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 base != "biome.json" && base != "biome.jsonc" { + continue + } + config, err := readBiomeConfig(root, path) + if err != nil { + return nil, err + } + require := path == "biome.json" || path == "biome.jsonc" + 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 _, raw := range overrides { + override, ok := raw.(map[string]any) + if ok { + findings = append(findings, biomeDriftFindings(path, override, cfg, false)...) + } + } + return findings +} + func biomeDriftFindings(path string, config map[string]any, cfg codeshape.Config, require bool) []codeshape.Finding { findings := []codeshape.Finding{} rules := nestedMap(config, "linter", "rules", "complexity") From c7df5c9fae416b44dbfa9522313363446e8c6e71 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 11:37:47 -0500 Subject: [PATCH 24/54] wip(go): T2 check linter versions (RIG-8435) --- go/internal/codeshape/cli/collectors_test.go | 7 ++-- go/internal/codeshape/golang/gometrics.go | 4 +++ go/internal/codeshape/inventory_test.go | 11 ++++++ go/internal/codeshape/toolversion.go | 34 +++++++++++++++++++ go/internal/codeshape/typescript/tsmetrics.go | 4 +++ 5 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 go/internal/codeshape/toolversion.go diff --git a/go/internal/codeshape/cli/collectors_test.go b/go/internal/codeshape/cli/collectors_test.go index d8894b2..762eb00 100644 --- a/go/internal/codeshape/cli/collectors_test.go +++ b/go/internal/codeshape/cli/collectors_test.go @@ -75,7 +75,8 @@ func TestGoMissingBinary(t *testing.T) { func fakeGoLint(t *testing.T, output string) { t.Helper() dir := t.TempDir() - script := "#!/bin/sh\nprintf '%s' '" + output + "'\nexit 1\n" + 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) @@ -184,7 +185,9 @@ func TestTSMissingBinary(t *testing.T) { func TestTSTruncatedJSON(t *testing.T) { root := tsRepo(t, "function f() {}\n") dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "biome"), []byte("#!/bin/sh\nprintf '{\"summary\":'\n"), 0755) + 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) } diff --git a/go/internal/codeshape/golang/gometrics.go b/go/internal/codeshape/golang/gometrics.go index 79d8597..7d21ef0 100644 --- a/go/internal/codeshape/golang/gometrics.go +++ b/go/internal/codeshape/golang/gometrics.go @@ -88,6 +88,10 @@ func GoMetrics(ctx context.Context, root string, cfg codeshape.Config) ( 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 { diff --git a/go/internal/codeshape/inventory_test.go b/go/internal/codeshape/inventory_test.go index dcbf63a..5cce83e 100644 --- a/go/internal/codeshape/inventory_test.go +++ b/go/internal/codeshape/inventory_test.go @@ -221,3 +221,14 @@ func testConfig() Config { 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/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/tsmetrics.go b/go/internal/codeshape/typescript/tsmetrics.go index 6a0d6fe..4e28876 100644 --- a/go/internal/codeshape/typescript/tsmetrics.go +++ b/go/internal/codeshape/typescript/tsmetrics.go @@ -97,6 +97,10 @@ func TSMetrics(ctx context.Context, root string, cfg codeshape.Config) ( if err != nil { return nil, nil, fmt.Errorf("find biome: %w", err) } + err = codeshape.CheckToolVersion(ctx, binary, codeshape.BiomeVersion) + if err != nil { + return nil, nil, fmt.Errorf("check biome: %w", err) + } var sourcePaths []string for path := range sources { sourcePaths = append(sourcePaths, path) From 2f30b7879dcd1f410dda210d0b3992f564d17b4c Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 11:39:23 -0500 Subject: [PATCH 25/54] wip(go): T2 cover collector failures (RIG-8435) --- go/internal/codeshape/cli/failclosed_test.go | 71 ++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 go/internal/codeshape/cli/failclosed_test.go diff --git a/go/internal/codeshape/cli/failclosed_test.go b/go/internal/codeshape/cli/failclosed_test.go new file mode 100644 index 0000000..3803beb --- /dev/null +++ b/go/internal/codeshape/cli/failclosed_test.go @@ -0,0 +1,71 @@ +// 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}, + {"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) + } + }) + } +} + +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 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) + } +} From 0ce515c21c0a22b7687fd87583a578cd2506cbf0 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 11:40:37 -0500 Subject: [PATCH 26/54] wip(go): T2 validate lint issue attribution (RIG-8435) --- go/internal/codeshape/cli/failclosed_test.go | 11 +++++++++++ go/internal/codeshape/golang/gometrics.go | 9 +++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/go/internal/codeshape/cli/failclosed_test.go b/go/internal/codeshape/cli/failclosed_test.go index 3803beb..9d7e2cf 100644 --- a/go/internal/codeshape/cli/failclosed_test.go +++ b/go/internal/codeshape/cli/failclosed_test.go @@ -61,6 +61,17 @@ func TestGoLintFailClosed(t *testing.T) { } } +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") diff --git a/go/internal/codeshape/golang/gometrics.go b/go/internal/codeshape/golang/gometrics.go index 7d21ef0..8f2b395 100644 --- a/go/internal/codeshape/golang/gometrics.go +++ b/go/internal/codeshape/golang/gometrics.go @@ -138,7 +138,7 @@ func measureGoModule(ctx context.Context, binary, root, module string, cfg codes if err != nil { return err } - err = applyGoIssues(root, module, files, cfg, limit.metric, report, measurements) + err = applyGoIssues(root, module, files, cfg, limit.metric, limit.linter, report, measurements) if err != nil { return err } @@ -147,9 +147,14 @@ func measureGoModule(ctx context.Context, binary, root, module string, cfg codes } func applyGoIssues(root, module string, files map[string]goSourceFile, cfg codeshape.Config, - metric codeshape.Metric, report golangciReport, measurements map[codeshape.Metric]map[string][]codeshape.Entry, + 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 From 0c327302405a5072ada3976344decd9fb408078d Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 11:43:18 -0500 Subject: [PATCH 27/54] wip(go): T2 flag disabled biome rules (RIG-8435) --- go/internal/codeshape/cli/collectors_test.go | 4 ++-- go/internal/codeshape/typescript/drift_test.go | 17 +++++++++++++---- .../codeshape/typescript/tssuppressions.go | 2 +- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/go/internal/codeshape/cli/collectors_test.go b/go/internal/codeshape/cli/collectors_test.go index 762eb00..f6ddbca 100644 --- a/go/internal/codeshape/cli/collectors_test.go +++ b/go/internal/codeshape/cli/collectors_test.go @@ -103,8 +103,8 @@ func TestGoUnparseableIssue(t *testing.T) { } const tsBiomeConfig = `{"linter":{"rules":{"complexity":{"noExcessiveCognitiveComplexity":` + - `{"level":"off","options":{"maxAllowedComplexity":15}},` + - `"noExcessiveLinesPerFunction":{"level":"off","options":{"maxLines":50}}}}}}` + `{"level":"warn","options":{"maxAllowedComplexity":15}},` + + `"noExcessiveLinesPerFunction":{"level":"warn","options":{"maxLines":50}}}}}}` func tsRepo(t *testing.T, source string) string { t.Helper() diff --git a/go/internal/codeshape/typescript/drift_test.go b/go/internal/codeshape/typescript/drift_test.go index 4837514..22206ef 100644 --- a/go/internal/codeshape/typescript/drift_test.go +++ b/go/internal/codeshape/typescript/drift_test.go @@ -21,6 +21,15 @@ func TestBiomeDriftMissingOptions(t *testing.T) { 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) { @@ -43,12 +52,12 @@ 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":500}}}}}}`) + `{"noExcessiveLinesPerFunction":{"level":"off","options":{"maxLines":50}}}}}}`) trackedFile(t, root, "biome.json", `{"linter":{"rules":{"complexity":{`+ - `"noExcessiveCognitiveComplexity":{"level":"off","options":{"maxAllowedComplexity":15}},`+ - `"noExcessiveLinesPerFunction":{"level":"off","options":{"maxLines":50}}}}},`+ + `"noExcessiveCognitiveComplexity":{"level":"warn","options":{"maxAllowedComplexity":15}},`+ + `"noExcessiveLinesPerFunction":{"level":"warn","options":{"maxLines":50}}}}},`+ `"overrides":[{"linter":{"rules":{"complexity":`+ - `{"noExcessiveLinesPerFunction":{"level":"off","options":{"maxLines":500}}}}}}]}`) + `{"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{ diff --git a/go/internal/codeshape/typescript/tssuppressions.go b/go/internal/codeshape/typescript/tssuppressions.go index b51c6e1..55c5728 100644 --- a/go/internal/codeshape/typescript/tssuppressions.go +++ b/go/internal/codeshape/typescript/tssuppressions.go @@ -89,7 +89,7 @@ func biomeDriftFindings(path string, config map[string]any, cfg codeshape.Config 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) { + 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"}) From ebd2a0afca02a7fca9b2f941e081e654829b931a Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 12:13:37 -0500 Subject: [PATCH 28/54] wip(go): T2 restore shape limits (RIG-8435) --- go/internal/codeshape/cli/cli.go | 6 +- go/internal/codeshape/golang/golangci.go | 148 ++++++++++++++++++ go/internal/codeshape/golang/gometrics.go | 129 --------------- go/internal/codeshape/typescript/tsmetrics.go | 68 ++++---- 4 files changed, 194 insertions(+), 157 deletions(-) create mode 100644 go/internal/codeshape/golang/golangci.go diff --git a/go/internal/codeshape/cli/cli.go b/go/internal/codeshape/cli/cli.go index c72f1ca..d591d04 100644 --- a/go/internal/codeshape/cli/cli.go +++ b/go/internal/codeshape/cli/cli.go @@ -72,8 +72,12 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s return writeUpdated(root, config, allow, base, snapshot, seed, stdout, stderr, suppressions) } findings := append(codeshape.Judge(config, allow, base, snapshot), suppressions...) + return printGateFindings(stdout, stderr, findings) +} + +func printGateFindings(stdout, stderr io.Writer, findings []codeshape.Finding) int { codeshape.SortFindings(findings) - err = writeFindings(stdout, findings) + err := writeFindings(stdout, findings) if err != nil { writeOutput(stderr, "code-shape-gate: write output: %v\n", err) return 2 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 index 8f2b395..668d4d0 100644 --- a/go/internal/codeshape/golang/gometrics.go +++ b/go/internal/codeshape/golang/gometrics.go @@ -6,7 +6,6 @@ package golang import ( "bytes" "context" - "encoding/json" "errors" "fmt" "go/ast" @@ -14,10 +13,8 @@ import ( "go/format" "go/parser" "go/token" - "os" "os/exec" "path/filepath" - "regexp" "sort" "strconv" "strings" @@ -28,38 +25,13 @@ import ( const highFunlenLimit = 1_000_000_000 var ( - gocognitValue = regexp.MustCompile(`cognitive complexity ([0-9]+) of func`) - funlenValue = regexp.MustCompile(`\(([0-9]+) > [0-9]+\)`) errGoLintReport = errors.New("reported an error") - errGoLintMissing = errors.New("measurement omitted") - errGoMetricParse = errors.New("cannot parse value") - errGoMetricInvalid = errors.New("invalid value") 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") ) -//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"` -} - type goSourceFile struct { set *token.FileSet functions []goFunction @@ -369,107 +341,6 @@ func assignGoFunctionIDs(functions []goFunction, parent []int) { } } -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 -} - func findGoFunction( root, module string, files map[string]goSourceFile, reportedPath string, line, column int, ) (string, string, error) { diff --git a/go/internal/codeshape/typescript/tsmetrics.go b/go/internal/codeshape/typescript/tsmetrics.go index 4e28876..6416cd3 100644 --- a/go/internal/codeshape/typescript/tsmetrics.go +++ b/go/internal/codeshape/typescript/tsmetrics.go @@ -79,40 +79,17 @@ func TSMetrics(ctx context.Context, root string, cfg codeshape.Config) ( if err != nil { return nil, nil, fmt.Errorf("collect TypeScript paths: %w", err) } - 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, nil, fmt.Errorf("read TypeScript source %q: %w", path, err) - } - sources[path] = source + sources, err := readTSSources(root, paths, cfg) + if err != nil { + return nil, nil, err } if len(sources) == 0 { return codeshape.NewFunctionMeasurements(), nil, nil } - binary, err := exec.LookPath("biome") - if err != nil { - return nil, nil, fmt.Errorf("find biome: %w", err) - } - err = codeshape.CheckToolVersion(ctx, binary, codeshape.BiomeVersion) - if err != nil { - return nil, nil, fmt.Errorf("check biome: %w", err) - } - var sourcePaths []string - for path := range sources { - sourcePaths = append(sourcePaths, path) - } - sort.Strings(sourcePaths) - report, err := runBiome(ctx, binary, root, sourcePaths) + binary, report, sourcePaths, err := prepareBiome(ctx, root, sources) if err != nil { return nil, nil, err } - if report.Summary.DiagnosticsNotPrinted != 0 { - return nil, nil, fmt.Errorf("%w: %d were not printed", errBiomeTruncated, report.Summary.DiagnosticsNotPrinted) - } skips, err := biomeSkippedPaths(ctx, binary, root, sourcePaths, report, sources) if err != nil { return nil, nil, err @@ -140,6 +117,43 @@ func TSMetrics(ctx context.Context, root string, cfg codeshape.Config) ( return values, skips, nil } +func prepareBiome(ctx context.Context, root string, sources map[string][]byte) (string, biomeReport, []string, error) { + binary, err := exec.LookPath("biome") + if err != nil { + return "", biomeReport{}, nil, fmt.Errorf("find biome: %w", err) + } + err = codeshape.CheckToolVersion(ctx, binary, codeshape.BiomeVersion) + if err != nil { + return "", biomeReport{}, nil, fmt.Errorf("check biome: %w", err) + } + var paths []string + for path := range sources { + paths = append(paths, path) + } + sort.Strings(paths) + report, err := runBiome(ctx, binary, root, paths) + if err == nil && report.Summary.DiagnosticsNotPrinted != 0 { + return "", biomeReport{}, nil, fmt.Errorf("%w: %d were not printed", + errBiomeTruncated, report.Summary.DiagnosticsNotPrinted) + } + return binary, report, paths, err +} + +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 biomeSkippedPaths(ctx context.Context, binary, root string, paths []string, report biomeReport, sources map[string][]byte, ) ([]codeshape.Skip, error) { From 35fd8e8856ca6e72817d7467b3d3f389bac72012 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 12:20:31 -0500 Subject: [PATCH 29/54] wip(go): T2 report unparseable functions (RIG-8435) --- README.md | 2 +- go/internal/codeshape/cli/cli.go | 31 +++++----- go/internal/codeshape/cli/cli_test.go | 2 +- go/internal/codeshape/cli/unmeasured.go | 60 +++++++++++++++++++ go/internal/codeshape/cli/unmeasured_test.go | 42 +++++++++++++ go/internal/codeshape/functions.go | 10 +++- go/internal/codeshape/golang/gometrics.go | 2 +- go/internal/codeshape/typescript/tsmetrics.go | 23 +++---- .../codeshape/typescript/unmeasured.go | 34 +++++++++++ 9 files changed, 175 insertions(+), 31 deletions(-) create mode 100644 go/internal/codeshape/cli/unmeasured.go create mode 100644 go/internal/codeshape/cli/unmeasured_test.go create mode 100644 go/internal/codeshape/typescript/unmeasured.go diff --git a/README.md b/README.md index dc1e0dc..bc8f1a7 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Optional `dirLimits` maps directory globs to `{limit: 50, reason: "record decisi 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. Files that cannot be measured are reported as skips on stderr; skips do not change the exit code. +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. Skips are printed on stderr. If Biome reports a measured rule in a TypeScript file the function collector cannot parse, the gate reports a file-level finding and preserves its existing function allowlist entries on `--write`. ```cue functions: "go-lines": "src/example.go": [{id: "Example", value: 70, reason: "split by concern"}] diff --git a/go/internal/codeshape/cli/cli.go b/go/internal/codeshape/cli/cli.go index d591d04..558261b 100644 --- a/go/internal/codeshape/cli/cli.go +++ b/go/internal/codeshape/cli/cli.go @@ -63,13 +63,14 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s writeOutput(stderr, "code-shape-gate: %v\n", err) return 2 } - suppressions, err := collectMeasurements(ctx, root, config, &snapshot, stderr) + 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) if writeMode { - return writeUpdated(root, config, allow, base, snapshot, seed, stdout, stderr, suppressions) + return writeUpdated(root, config, allow, base, snapshot, seed, stdout, stderr, preserved, suppressions) } findings := append(codeshape.Judge(config, allow, base, snapshot), suppressions...) return printGateFindings(stdout, stderr, findings) @@ -104,41 +105,40 @@ func reportUnmeasuredExtensions(output io.Writer, extensions []string) error { func collectMeasurements( ctx context.Context, root string, config codeshape.Config, snapshot *codeshape.Snapshot, stderr io.Writer, -) ([]codeshape.Finding, error) { +) ([]codeshape.Finding, []string, error) { functions, skips, err := golang.GoMetrics(ctx, root, config) if err != nil { - return nil, fmt.Errorf("collect Go metrics: %w", err) + return nil, nil, fmt.Errorf("collect Go metrics: %w", err) } for _, skip := range skips { _, err := fmt.Fprintf(stderr, "code-shape-gate: skip %s: %s\n", skip.Path, skip.Reason) if err != nil { - return nil, fmt.Errorf("write skip: %w", err) + return nil, nil, fmt.Errorf("write skip: %w", err) } } tsFunctions, tsSkips, err := typescript.TSMetrics(ctx, root, config) if err != nil { - return nil, fmt.Errorf("collect TypeScript metrics: %w", err) + return nil, nil, fmt.Errorf("collect TypeScript metrics: %w", err) } - for _, skip := range tsSkips { - _, err := fmt.Fprintf(stderr, "code-shape-gate: skip %s: %s\n", skip.Path, skip.Reason) - if err != nil { - return nil, fmt.Errorf("write skip: %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, fmt.Errorf("collect Go suppressions: %w", err) + return nil, nil, fmt.Errorf("collect Go suppressions: %w", err) } tsFindings, err := typescript.PolicyFindings(ctx, root, config) if err != nil { - return nil, fmt.Errorf("collect TypeScript policy: %w", err) + return nil, nil, fmt.Errorf("collect TypeScript policy: %w", err) } suppressions = append(suppressions, tsFindings...) + suppressions = append(suppressions, unmeasuredFindings...) codeshape.SortFindings(suppressions) - return suppressions, nil + return suppressions, unmeasuredPaths, nil } func writeUpdated( @@ -148,7 +148,7 @@ func writeUpdated( snapshot codeshape.Snapshot, seed bool, stdout, stderr io.Writer, - policy ...[]codeshape.Finding, + preserved codeshape.FunctionMeasurements, policy ...[]codeshape.Finding, ) int { updated, changed, err := rewriteAllowlist(config, allow, snapshot, seed) if err != nil { @@ -166,6 +166,7 @@ func writeUpdated( writeOutput(stderr, "code-shape-gate: write output: %v\n", err) return 2 } + restoreUnmeasured(&updated, preserved) if changed && !ratchetFailures { repository, openErr := os.OpenRoot(root) if openErr != nil { diff --git a/go/internal/codeshape/cli/cli_test.go b/go/internal/codeshape/cli/cli_test.go index b6ad301..580a3f0 100644 --- a/go/internal/codeshape/cli/cli_test.go +++ b/go/internal/codeshape/cli/cli_test.go @@ -127,7 +127,7 @@ func TestWriteUpdatedRejectsEscapingAllowlist(t *testing.T) { 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) + code := writeUpdated(root, testConfig(), allow, codeshape.EmptyAllowlist(), snapshot, false, &stdout, &stderr, 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()) diff --git a/go/internal/codeshape/cli/unmeasured.go b/go/internal/codeshape/cli/unmeasured.go new file mode 100644 index 0000000..aaea287 --- /dev/null +++ b/go/internal/codeshape/cli/unmeasured.go @@ -0,0 +1,60 @@ +// 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) + 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) + } +} diff --git a/go/internal/codeshape/cli/unmeasured_test.go b/go/internal/codeshape/cli/unmeasured_test.go new file mode 100644 index 0000000..51e385f --- /dev/null +++ b/go/internal/codeshape/cli/unmeasured_test.go @@ -0,0 +1,42 @@ +// 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}}}, + } + 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) + } +} diff --git a/go/internal/codeshape/functions.go b/go/internal/codeshape/functions.go index faf3c0b..343c7ff 100644 --- a/go/internal/codeshape/functions.go +++ b/go/internal/codeshape/functions.go @@ -11,8 +11,14 @@ import ( // Skip records a source file that could not be measured. type Skip struct { - Path string - Reason string + Path string + Reason string + Unmeasured *UnmeasuredFunctions +} + +// UnmeasuredFunctions holds diagnostic maxima for an unparseable source file. +type UnmeasuredFunctions struct { + Values map[Metric]int } var errFunctionID = errors.New("invalid function allowlist id") diff --git a/go/internal/codeshape/golang/gometrics.go b/go/internal/codeshape/golang/gometrics.go index 668d4d0..59603e2 100644 --- a/go/internal/codeshape/golang/gometrics.go +++ b/go/internal/codeshape/golang/gometrics.go @@ -199,7 +199,7 @@ func parseGoSources(root, module string, paths []string, cfg codeshape.Config) ( return nil, nil, fmt.Errorf("check Go build constraints %q: %w", path, err) } if !matches { - skips = append(skips, codeshape.Skip{Path: path, Reason: "not in default Go build"}) + skips = append(skips, codeshape.Skip{Path: path, Reason: "not in default Go build", Unmeasured: nil}) continue } source, err := codeshape.ReadRepositoryFile(root, filepath.FromSlash(path)) diff --git a/go/internal/codeshape/typescript/tsmetrics.go b/go/internal/codeshape/typescript/tsmetrics.go index 6416cd3..ddc723c 100644 --- a/go/internal/codeshape/typescript/tsmetrics.go +++ b/go/internal/codeshape/typescript/tsmetrics.go @@ -99,7 +99,8 @@ func TSMetrics(ctx context.Context, root string, cfg codeshape.Config) ( for path, source := range sources { functions, tree, err := parseTSFunctions(path, source) if err != nil { - skips = append(skips, codeshape.Skip{Path: path, Reason: err.Error()}) + skips = append(skips, codeshape.Skip{Path: path, Reason: err.Error(), + Unmeasured: &codeshape.UnmeasuredFunctions{Values: map[codeshape.Metric]int{}}}) delete(sources, path) continue } @@ -168,7 +169,7 @@ func biomeSkippedPaths(ctx context.Context, binary, root string, paths []string, } if single.Summary.Changed+single.Summary.Unchanged == 0 { delete(sources, path) - skips = append(skips, codeshape.Skip{Path: path, Reason: "ignored by Biome"}) + skips = append(skips, codeshape.Skip{Path: path, Reason: "ignored by Biome", Unmeasured: nil}) } } return skips, nil @@ -185,6 +186,15 @@ func applyBiomeIssues(report biomeReport, files map[string][]tsFunction, sources } 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 }) { @@ -208,15 +218,6 @@ func applyBiomeIssues(report biomeReport, files map[string][]tsFunction, sources return nil } -func isTypeScriptPath(path string) bool { - switch filepath.Ext(path) { - case ".ts", ".tsx", ".mts", ".cts": - return true - default: - return false - } -} - func runBiome(ctx context.Context, binary, root string, paths []string) (biomeReport, error) { args := make([]string, 0, 5+len(paths)) args = append(args, "lint", "--reporter=json", "--max-diagnostics="+strconv.Itoa(maxBiomeDiagnostics), diff --git a/go/internal/codeshape/typescript/unmeasured.go b/go/internal/codeshape/typescript/unmeasured.go new file mode 100644 index 0000000..3e8576f --- /dev/null +++ b/go/internal/codeshape/typescript/unmeasured.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package typescript + +import ( + "path/filepath" + + "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 + } +} From 8646d6fb068ee1898927edb98a4cf839d1d364b7 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 12:23:56 -0500 Subject: [PATCH 30/54] wip(go): T2 enforce Biome scope (RIG-8435) --- go/go.mod | 1 + go/go.sum | 2 + go/internal/codeshape/cli/biome_scope_test.go | 31 ++++ go/internal/codeshape/cli/unmeasured.go | 4 + go/internal/codeshape/cli/unmeasured_test.go | 2 +- go/internal/codeshape/functions.go | 3 +- .../codeshape/typescript/biome_scope.go | 134 ++++++++++++++++++ go/internal/codeshape/typescript/tsmetrics.go | 26 +--- .../codeshape/typescript/tssuppressions.go | 6 + 9 files changed, 184 insertions(+), 25 deletions(-) create mode 100644 go/internal/codeshape/cli/biome_scope_test.go create mode 100644 go/internal/codeshape/typescript/biome_scope.go diff --git a/go/go.mod b/go/go.mod index ccb4451..958fa51 100644 --- a/go/go.mod +++ b/go/go.mod @@ -15,6 +15,7 @@ require ( require ( cuelabs.dev/go/oci/ociregistry v0.0.0-20260601085548-328ff8e2c943 // indirect + github.com/bmatcuk/doublestar/v4 v4.9.1 // 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 diff --git a/go/go.sum b/go/go.sum index 05fef0a..9648901 100644 --- a/go/go.sum +++ b/go/go.sum @@ -2,6 +2,8 @@ cuelabs.dev/go/oci/ociregistry v0.0.0-20260601085548-328ff8e2c943 h1:XUtzi/yWlmu cuelabs.dev/go/oci/ociregistry v0.0.0-20260601085548-328ff8e2c943/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8= cuelang.org/go v0.17.1 h1:liOkxZDqTHrzq0USJX+6bMYOZ5PSf+wzvQr15AHpDCQ= cuelang.org/go v0.17.1/go.mod h1:xlly/o1wSLvxOsi5vkQGieU0rLOt7TvUIizOFtnxHRU= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/cockroachdb/apd/v3 v3.2.3 h1:4Zx+I3R35bFXMnltzmjP79i2cravE4jTRL6ps9Aux80= github.com/cockroachdb/apd/v3 v3.2.3/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= github.com/dlclark/regexp2/v2 v2.12.0 h1:GsCgDZZeHluUDHASr2yqQonZk4Tma5LFmLH9CSsFFEE= 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/unmeasured.go b/go/internal/codeshape/cli/unmeasured.go index aaea287..bda4a0f 100644 --- a/go/internal/codeshape/cli/unmeasured.go +++ b/go/internal/codeshape/cli/unmeasured.go @@ -18,6 +18,10 @@ func handleTSSkips(output io.Writer, skips []codeshape.Skip, cfg codeshape.Confi for _, skip := range skips { if skip.Unmeasured != nil { paths = append(paths, skip.Path) + if skip.Unmeasured.Ignored { + findings = append(findings, codeshape.Finding{Path: skip.Path, Metric: codeshape.MetricTSLines, + Value: 1, Limit: 0, Fix: "add to code-shape exclude or let Biome lint it"}) + } 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"}) diff --git a/go/internal/codeshape/cli/unmeasured_test.go b/go/internal/codeshape/cli/unmeasured_test.go index 51e385f..2e4692f 100644 --- a/go/internal/codeshape/cli/unmeasured_test.go +++ b/go/internal/codeshape/cli/unmeasured_test.go @@ -15,7 +15,7 @@ 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}}}, + Values: map[codeshape.Metric]int{codeshape.MetricTSLines: 70}, Ignored: false}}, } var output strings.Builder findings, paths, err := handleTSSkips(&output, skips, testConfig()) diff --git a/go/internal/codeshape/functions.go b/go/internal/codeshape/functions.go index 343c7ff..2e67802 100644 --- a/go/internal/codeshape/functions.go +++ b/go/internal/codeshape/functions.go @@ -18,7 +18,8 @@ type Skip struct { // UnmeasuredFunctions holds diagnostic maxima for an unparseable source file. type UnmeasuredFunctions struct { - Values map[Metric]int + Values map[Metric]int + Ignored bool } var errFunctionID = errors.New("invalid function allowlist id") diff --git a/go/internal/codeshape/typescript/biome_scope.go b/go/internal/codeshape/typescript/biome_scope.go new file mode 100644 index 0000000..659ae22 --- /dev/null +++ b/go/internal/codeshape/typescript/biome_scope.go @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package typescript + +import ( + "errors" + "fmt" + "path" + "strings" + + "github.com/RigelBuild/meissa/go/internal/codeshape" + "github.com/bmatcuk/doublestar/v4" +) + +var errBiomeScope = errors.New("invalid or inconsistent Biome scope") + +type biomeScope struct { + dir string + config map[string]any +} + +func biomeScopes(root string, paths []string) ([]biomeScope, error) { + var scopes []biomeScope + for _, name := range paths { + if path.Base(name) != "biome.json" && path.Base(name) != "biome.jsonc" { + continue + } + config, err := readBiomeConfig(root, name) + if err != nil { + return nil, err + } + scopes = append(scopes, biomeScope{dir: path.Dir(name), config: config}) + } + return scopes, nil +} + +func biomeIncludes(raw any, name string) (bool, error) { + patterns, ok := raw.([]any) + if !ok { + return true, nil + } + included := false + for _, item := range patterns { + pattern, ok := item.(string) + if !ok { + return false, fmt.Errorf("include pattern: %w", errBiomeScope) + } + negated := strings.HasPrefix(pattern, "!") + pattern = strings.TrimPrefix(pattern, "!") + matched, err := doublestar.Match(pattern, name) + if err != nil { + return false, fmt.Errorf("invalid Biome glob %q: %w", pattern, err) + } + if matched { + included = !negated + } + } + return included, nil +} + +func biomeExcluded(name string, scopes []biomeScope) (bool, error) { + enabled := true + for _, scope := range scopes { + relative := name + if scope.dir != "." { + prefix := scope.dir + "/" + if !strings.HasPrefix(name, prefix) { + continue + } + relative = strings.TrimPrefix(name, prefix) + } + files, _ := scope.config["files"].(map[string]any) + included, err := biomeIncludes(files["includes"], relative) + if err != nil { + return false, err + } + if !included { + return true, nil + } + linter, _ := scope.config["linter"].(map[string]any) + if value, ok := linter["enabled"].(bool); ok { + enabled = value + } + enabled, err = biomeOverrideEnabled(scope.config, relative, enabled) + if err != nil { + return false, err + } + } + return !enabled, nil +} + +func biomeOverrideEnabled(config map[string]any, name string, enabled bool) (bool, error) { + overrides, _ := config["overrides"].([]any) + for _, raw := range overrides { + override, ok := raw.(map[string]any) + if !ok { + continue + } + applies, err := biomeIncludes(override["includes"], name) + if err != nil { + return false, err + } + linter, _ := override["linter"].(map[string]any) + if value, ok := linter["enabled"].(bool); applies && ok { + enabled = value + } + } + return enabled, nil +} + +func biomeSkippedPaths(root string, tracked, paths []string, report biomeReport, sources map[string][]byte) ( + []codeshape.Skip, error, +) { + scopes, err := biomeScopes(root, tracked) + if err != nil { + return nil, err + } + var skips []codeshape.Skip + for _, name := range paths { + excluded, err := biomeExcluded(name, scopes) + if err != nil { + return nil, err + } + if excluded { + delete(sources, name) + skips = append(skips, codeshape.Skip{Path: name, Reason: "ignored by Biome", + Unmeasured: &codeshape.UnmeasuredFunctions{Values: map[codeshape.Metric]int{}, Ignored: true}}) + } + } + if report.Summary.Changed+report.Summary.Unchanged != len(paths)-len(skips) { + return nil, fmt.Errorf("processed count differs from configuration: %w", errBiomeScope) + } + return skips, nil +} diff --git a/go/internal/codeshape/typescript/tsmetrics.go b/go/internal/codeshape/typescript/tsmetrics.go index ddc723c..e95eaa1 100644 --- a/go/internal/codeshape/typescript/tsmetrics.go +++ b/go/internal/codeshape/typescript/tsmetrics.go @@ -86,11 +86,11 @@ func TSMetrics(ctx context.Context, root string, cfg codeshape.Config) ( if len(sources) == 0 { return codeshape.NewFunctionMeasurements(), nil, nil } - binary, report, sourcePaths, err := prepareBiome(ctx, root, sources) + _, report, sourcePaths, err := prepareBiome(ctx, root, sources) if err != nil { return nil, nil, err } - skips, err := biomeSkippedPaths(ctx, binary, root, sourcePaths, report, sources) + skips, err := biomeSkippedPaths(root, paths, sourcePaths, report, sources) if err != nil { return nil, nil, err } @@ -100,7 +100,7 @@ func TSMetrics(ctx context.Context, root string, cfg codeshape.Config) ( 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{}}}) + Unmeasured: &codeshape.UnmeasuredFunctions{Values: map[codeshape.Metric]int{}, Ignored: false}}) delete(sources, path) continue } @@ -155,26 +155,6 @@ func readTSSources(root string, paths []string, cfg codeshape.Config) (map[strin return sources, nil } -func biomeSkippedPaths(ctx context.Context, binary, root string, paths []string, report biomeReport, - sources map[string][]byte, -) ([]codeshape.Skip, error) { - skips := []codeshape.Skip{} - if report.Summary.Changed+report.Summary.Unchanged == len(paths) { - return skips, nil - } - for _, path := range paths { - single, err := runBiome(ctx, binary, root, []string{path}) - if err != nil { - return nil, err - } - if single.Summary.Changed+single.Summary.Unchanged == 0 { - delete(sources, path) - skips = append(skips, codeshape.Skip{Path: path, Reason: "ignored by Biome", Unmeasured: nil}) - } - } - return skips, 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 { diff --git a/go/internal/codeshape/typescript/tssuppressions.go b/go/internal/codeshape/typescript/tssuppressions.go index 55c5728..8ed6996 100644 --- a/go/internal/codeshape/typescript/tssuppressions.go +++ b/go/internal/codeshape/typescript/tssuppressions.go @@ -73,6 +73,12 @@ func biomeOverrideDrift(path string, config map[string]any, cfg codeshape.Config 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 From 46677d7a53e200b2185c0dd0a8ffa2d6ba7a574a Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 12:25:02 -0500 Subject: [PATCH 31/54] wip(go): T2 accept measured Biome errors (RIG-8435) --- README.md | 2 ++ go/internal/codeshape/cli/collectors_test.go | 11 +++++++++++ go/internal/codeshape/typescript/tsmetrics.go | 8 +++++--- .../codeshape/typescript/unmeasured.go | 19 +++++++++++++++++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bc8f1a7..cd5594c 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ The gate also measures Go cognitive complexity, function lines and statements, a 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. Skips are printed on stderr. If Biome reports a measured rule in a TypeScript file the function collector cannot parse, the gate reports a file-level finding and preserves its existing function allowlist entries on `--write`. +The two measured Biome rules may use `warn` or `error` levels. Both report gate findings when a function exceeds its limit. + ```cue functions: "go-lines": "src/example.go": [{id: "Example", value: 70, reason: "split by concern"}] ``` diff --git a/go/internal/codeshape/cli/collectors_test.go b/go/internal/codeshape/cli/collectors_test.go index f6ddbca..61c4db0 100644 --- a/go/internal/codeshape/cli/collectors_test.go +++ b/go/internal/codeshape/cli/collectors_test.go @@ -146,6 +146,17 @@ func TestTSLinesLimit(t *testing.T) { 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 TestTSDrift(t *testing.T) { root := tsRepo(t, "function f() {}\n") err := os.WriteFile(filepath.Join(root, "biome.json"), diff --git a/go/internal/codeshape/typescript/tsmetrics.go b/go/internal/codeshape/typescript/tsmetrics.go index e95eaa1..3ff0778 100644 --- a/go/internal/codeshape/typescript/tsmetrics.go +++ b/go/internal/codeshape/typescript/tsmetrics.go @@ -210,7 +210,8 @@ func runBiome(ctx context.Context, binary, root string, paths []string) (biomeRe command.Stdout = &stdout command.Stderr = &stderr runErr := command.Run() - if runErr != nil { + 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 @@ -221,8 +222,9 @@ func runBiome(ctx context.Context, binary, root string, paths []string) (biomeRe if report.Summary == nil || report.Diagnostics == nil { return biomeReport{}, errBiomeMissingOutput } - if report.Summary.Errors > 0 { - return biomeReport{}, fmt.Errorf("biome reported %d errors: %w", report.Summary.Errors, errBiomeReported) + err = validateBiomeErrors(report) + if err != nil { + return biomeReport{}, err } return report, nil } diff --git a/go/internal/codeshape/typescript/unmeasured.go b/go/internal/codeshape/typescript/unmeasured.go index 3e8576f..d7cff97 100644 --- a/go/internal/codeshape/typescript/unmeasured.go +++ b/go/internal/codeshape/typescript/unmeasured.go @@ -3,6 +3,7 @@ package typescript import ( + "fmt" "path/filepath" "github.com/RigelBuild/meissa/go/internal/codeshape" @@ -32,3 +33,21 @@ func isTypeScriptPath(path string) bool { 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 { + 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 +} From 8f2c966a31b329e60e17ff4ec5219a7132ac8f70 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 12:25:45 -0500 Subject: [PATCH 32/54] wip(go): T2 count Biome columns as runes (RIG-8435) --- go/internal/codeshape/cli/collectors_test.go | 10 ++++++++++ go/internal/codeshape/typescript/tsmetrics.go | 5 +---- go/internal/codeshape/typescript/tsmetrics_test.go | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/go/internal/codeshape/cli/collectors_test.go b/go/internal/codeshape/cli/collectors_test.go index 61c4db0..b225855 100644 --- a/go/internal/codeshape/cli/collectors_test.go +++ b/go/internal/codeshape/cli/collectors_test.go @@ -157,6 +157,16 @@ func TestBiomeMeasuredErrors(t *testing.T) { 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"), diff --git a/go/internal/codeshape/typescript/tsmetrics.go b/go/internal/codeshape/typescript/tsmetrics.go index 3ff0778..1c45998 100644 --- a/go/internal/codeshape/typescript/tsmetrics.go +++ b/go/internal/codeshape/typescript/tsmetrics.go @@ -465,11 +465,8 @@ func tsOffset(line, column int, source []byte) (int, error) { if offset >= len(source) || source[offset] == '\n' { return 0, fmt.Errorf("biome position %d:%d is outside source: %w", line, column, errBiomeOutsideSource) } - r, size := utf8.DecodeRune(source[offset:]) + _, size := utf8.DecodeRune(source[offset:]) units++ - if r > 0xffff { - units++ - } offset += size } if offset > len(source) { diff --git a/go/internal/codeshape/typescript/tsmetrics_test.go b/go/internal/codeshape/typescript/tsmetrics_test.go index 607bae3..fe30c17 100644 --- a/go/internal/codeshape/typescript/tsmetrics_test.go +++ b/go/internal/codeshape/typescript/tsmetrics_test.go @@ -74,7 +74,7 @@ func TestTSGeneratorAndUnicodePosition(t *testing.T) { t.Fatal(err) } defer tree.Release() - fn, err := findTSFunction(funcs, 1, 19, source) + fn, err := findTSFunction(funcs, 1, 18, source) if err != nil || fn.id != "generate" { t.Fatalf("function=%v err=%v", fn, err) } From a70453ad857bef1541bd99e0a93b0101356bff92 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 12:27:05 -0500 Subject: [PATCH 33/54] wip(go): T2 strengthen collector fixtures (RIG-8435) --- go/internal/codeshape/golang/gometrics_test.go | 2 +- go/internal/codeshape/typescript/tsmetrics_test.go | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/go/internal/codeshape/golang/gometrics_test.go b/go/internal/codeshape/golang/gometrics_test.go index 9ba1766..a189fd3 100644 --- a/go/internal/codeshape/golang/gometrics_test.go +++ b/go/internal/codeshape/golang/gometrics_test.go @@ -56,7 +56,7 @@ func TestGoCollectorFixture(t *testing.T) { } } -func TestGoFunctionIdentity(t *testing.T) { +func TestGoInitIDsStableWhenDifferentFunctionAdded(t *testing.T) { ids := func(source string) []string { set := token.NewFileSet() file, err := parser.ParseFile(set, "x.go", source, 0) diff --git a/go/internal/codeshape/typescript/tsmetrics_test.go b/go/internal/codeshape/typescript/tsmetrics_test.go index fe30c17..8781fd7 100644 --- a/go/internal/codeshape/typescript/tsmetrics_test.go +++ b/go/internal/codeshape/typescript/tsmetrics_test.go @@ -28,8 +28,14 @@ func tsRepo(t *testing.T, source string) string { return root } func TestTSOnlyOffUsesOptions(t *testing.T) { - root := tsRepo(t, "function f(x: boolean) {\n"+strings.Repeat("if (x) { console.log(x); }\n", 16)+ - strings.Repeat("console.log(x);\n", 40)+"}\n") + 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) @@ -41,6 +47,9 @@ func TestTSOnlyOffUsesOptions(t *testing.T) { seen := map[string]bool{} for _, d := range report.Diagnostics { seen[d.Category] = true + if d.Category == "lint/complexity/noExcessiveLinesPerFunction" && !strings.Contains(d.Message, "3") { + t.Fatalf("missing configured limit: %q", d.Message) + } } if len(seen) != 2 { t.Fatalf("disabled rules did not both report: %#v", report.Diagnostics) From 61fad5b915517ce1f4d4c7e794e6e3049012b7c8 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 12:27:59 -0500 Subject: [PATCH 34/54] wip(go): T2 flag unbuilt Go functions (RIG-8435) --- README.md | 2 ++ go/internal/codeshape/cli/cli.go | 11 +++++------ go/internal/codeshape/cli/collectors_test.go | 10 +++++++++- go/internal/codeshape/cli/unmeasured.go | 17 +++++++++++++++++ go/internal/codeshape/golang/gometrics.go | 12 ++++++++---- 5 files changed, 41 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index cd5594c..5afeaa6 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ The gate also measures Go cognitive complexity, function lines and statements, a 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. Skips are printed on stderr. If Biome reports a measured rule in a TypeScript file the function collector cannot parse, the gate reports a file-level finding and preserves its existing function allowlist entries on `--write`. +Skips that leave no functions unmeasured are notices only. A counted Go file outside the default build that contains functions is a finding. Counted TypeScript files Biome ignores are also findings. Use the gate's `exclude` to opt out. + The two measured Biome rules may use `warn` or `error` levels. Both report gate findings when a function exceeds its limit. ```cue diff --git a/go/internal/codeshape/cli/cli.go b/go/internal/codeshape/cli/cli.go index 558261b..146c61b 100644 --- a/go/internal/codeshape/cli/cli.go +++ b/go/internal/codeshape/cli/cli.go @@ -110,11 +110,9 @@ func collectMeasurements( if err != nil { return nil, nil, fmt.Errorf("collect Go metrics: %w", err) } - for _, skip := range skips { - _, err := fmt.Fprintf(stderr, "code-shape-gate: skip %s: %s\n", skip.Path, skip.Reason) - if err != nil { - return nil, nil, fmt.Errorf("write skip: %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 { @@ -136,9 +134,10 @@ func collectMeasurements( 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, unmeasuredPaths, nil + return suppressions, append(unmeasuredPaths, goPaths...), nil } func writeUpdated( diff --git a/go/internal/codeshape/cli/collectors_test.go b/go/internal/codeshape/cli/collectors_test.go index b225855..40e701f 100644 --- a/go/internal/codeshape/cli/collectors_test.go +++ b/go/internal/codeshape/cli/collectors_test.go @@ -26,13 +26,21 @@ func runGoGate(t *testing.T, root string) (int, string, string) { return code, out.String(), errout.String() } func TestSkipOnlyModuleIsValid(t *testing.T) { - root := goTestRepo(t, "//go:build integration\n\npackage p\nfunc F() {}\n") + 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 TestGoLimitsCLI(t *testing.T) { var source strings.Builder source.WriteString("package p\nfunc Cognitive(x int) {\n") diff --git a/go/internal/codeshape/cli/unmeasured.go b/go/internal/codeshape/cli/unmeasured.go index bda4a0f..e6fdc53 100644 --- a/go/internal/codeshape/cli/unmeasured.go +++ b/go/internal/codeshape/cli/unmeasured.go @@ -62,3 +62,20 @@ func restoreUnmeasured(allow *codeshape.Allowlist, preserved codeshape.FunctionM 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.MetricGoLines, + Value: 1, Limit: 0, Fix: "add to code-shape exclude or include it in the default Go build"}) + } + } + return findings, paths, nil +} diff --git a/go/internal/codeshape/golang/gometrics.go b/go/internal/codeshape/golang/gometrics.go index 59603e2..d1db4de 100644 --- a/go/internal/codeshape/golang/gometrics.go +++ b/go/internal/codeshape/golang/gometrics.go @@ -198,10 +198,6 @@ func parseGoSources(root, module string, paths []string, cfg codeshape.Config) ( if err != nil { return nil, nil, fmt.Errorf("check Go build constraints %q: %w", path, err) } - if !matches { - skips = append(skips, codeshape.Skip{Path: path, Reason: "not in default Go build", Unmeasured: nil}) - continue - } source, err := codeshape.ReadRepositoryFile(root, filepath.FromSlash(path)) if err != nil { return nil, nil, fmt.Errorf("read Go source %q: %w", path, err) @@ -211,6 +207,14 @@ func parseGoSources(root, module string, paths []string, cfg codeshape.Config) ( 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, Ignored: true} + } + 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 From 39c1121e3c69a33db0254ae7dc2fc83505a2b159 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 12:28:36 -0500 Subject: [PATCH 35/54] wip(go): T2 identify Biome override drift (RIG-8435) --- go/internal/codeshape/typescript/drift_test.go | 7 +++++++ go/internal/codeshape/typescript/tssuppressions.go | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/go/internal/codeshape/typescript/drift_test.go b/go/internal/codeshape/typescript/drift_test.go index 22206ef..7cdffd0 100644 --- a/go/internal/codeshape/typescript/drift_test.go +++ b/go/internal/codeshape/typescript/drift_test.go @@ -67,4 +67,11 @@ func TestTrackedBiomeDrift(t *testing.T) { 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) + } } diff --git a/go/internal/codeshape/typescript/tssuppressions.go b/go/internal/codeshape/typescript/tssuppressions.go index 8ed6996..efbcaef 100644 --- a/go/internal/codeshape/typescript/tssuppressions.go +++ b/go/internal/codeshape/typescript/tssuppressions.go @@ -62,10 +62,11 @@ func trackedBiomeDrift(root string, paths []string, cfg codeshape.Config) ([]cod func biomeOverrideDrift(path string, config map[string]any, cfg codeshape.Config) []codeshape.Finding { findings := []codeshape.Finding{} overrides, _ := config["overrides"].([]any) - for _, raw := range overrides { + for index, raw := range overrides { override, ok := raw.(map[string]any) if ok { - findings = append(findings, biomeDriftFindings(path, override, cfg, false)...) + overridePath := fmt.Sprintf("%s#overrides[%d]", path, index) + findings = append(findings, biomeDriftFindings(overridePath, override, cfg, false)...) } } return findings From 9b5b46eff3db300111019fc856e40f15294c7722 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 12:29:14 -0500 Subject: [PATCH 36/54] wip(go): T2 simplify policy argument (RIG-8435) --- go/internal/codeshape/cli/cli.go | 6 ++---- go/internal/codeshape/cli/cli_test.go | 3 ++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/go/internal/codeshape/cli/cli.go b/go/internal/codeshape/cli/cli.go index 146c61b..7cede4b 100644 --- a/go/internal/codeshape/cli/cli.go +++ b/go/internal/codeshape/cli/cli.go @@ -147,7 +147,7 @@ func writeUpdated( snapshot codeshape.Snapshot, seed bool, stdout, stderr io.Writer, - preserved codeshape.FunctionMeasurements, policy ...[]codeshape.Finding, + preserved codeshape.FunctionMeasurements, policy []codeshape.Finding, ) int { updated, changed, err := rewriteAllowlist(config, allow, snapshot, seed) if err != nil { @@ -156,9 +156,7 @@ func writeUpdated( } findings := codeshape.Judge(config, updated, base, snapshot) ratchetFailures := len(findings) > 0 - if len(policy) > 0 { - findings = append(findings, policy[0]...) - } + findings = append(findings, policy...) codeshape.SortFindings(findings) err = writeFindings(stdout, findings) if err != nil { diff --git a/go/internal/codeshape/cli/cli_test.go b/go/internal/codeshape/cli/cli_test.go index 580a3f0..b3670a0 100644 --- a/go/internal/codeshape/cli/cli_test.go +++ b/go/internal/codeshape/cli/cli_test.go @@ -127,7 +127,8 @@ func TestWriteUpdatedRejectsEscapingAllowlist(t *testing.T) { 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) + code := writeUpdated(root, testConfig(), allow, codeshape.EmptyAllowlist(), snapshot, false, + &stdout, &stderr, 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()) From ce982194267414dda5d880991f66184274e75eed Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 12:29:51 -0500 Subject: [PATCH 37/54] wip(go): T2 centralize function limits (RIG-8435) --- go/internal/codeshape/config/load.go | 6 ------ go/internal/codeshape/config/schema.cue | 3 ++- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/go/internal/codeshape/config/load.go b/go/internal/codeshape/config/load.go index 0dc3989..9e1e61d 100644 --- a/go/internal/codeshape/config/load.go +++ b/go/internal/codeshape/config/load.go @@ -90,12 +90,6 @@ func loadFromRoot(repository *os.Root) (codeshape.Config, codeshape.Allowlist, e if err != nil { return codeshape.Config{}, codeshape.Allowlist{}, fmt.Errorf("decode config.cue: %w", err) } - if !configValue.LookupPath(cue.ParsePath("functions")).Exists() { - config.Functions = codeshape.FunctionLimits{ - Go: codeshape.GoFunctionLimits{Cognitive: 20, Lines: 60, Statements: 40}, - TS: codeshape.TSFunctionLimits{Cognitive: 15, Lines: 50}, - } - } err = validateConfig(config) if err != nil { return codeshape.Config{}, codeshape.Allowlist{}, err diff --git a/go/internal/codeshape/config/schema.cue b/go/internal/codeshape/config/schema.cue index cae30f5..162baa5 100644 --- a/go/internal/codeshape/config/schema.cue +++ b/go/internal/codeshape/config/schema.cue @@ -36,7 +36,8 @@ exclude: [...string] // When multiple globs match, the smallest limit wins. dirLimits?: [string]: {limit: int & >10, reason: string & =~"^\\S+(\\s+\\S+){2,}$"} - functions?: close({ + functions: *#FunctionLimits | #FunctionLimits + #FunctionLimits: close({ go: close({cognitive: 20, lines: 60, statements: 40}) ts: close({cognitive: 15, lines: 50}) }) From dbe53e54663a1b0f8523a430a71086c868e600b4 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 12:30:50 -0500 Subject: [PATCH 38/54] wip(go): T2 remove repeated class IDs (RIG-8435) --- go/internal/codeshape/typescript/tsmetrics.go | 27 ++++++++++++------- .../codeshape/typescript/tsmetrics_test.go | 11 ++++++++ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/go/internal/codeshape/typescript/tsmetrics.go b/go/internal/codeshape/typescript/tsmetrics.go index 1c45998..a04957c 100644 --- a/go/internal/codeshape/typescript/tsmetrics.go +++ b/go/internal/codeshape/typescript/tsmetrics.go @@ -312,15 +312,7 @@ func assignTSFunctionIDs(functions []tsFunction, lang *gotreesitter.Language, so break } } - classID := "" - for parent := functions[i].node.Parent(); parent != nil; parent = parent.Parent() { - if parent.Type(lang) == "class_declaration" || parent.Type(lang) == "class" { - if name := parent.ChildByFieldName("name", lang); name != nil { - classID = name.Text(source) + "/" - } - break - } - } + classID := tsClassPrefix(functions[i].node, lang, source, parentID) base := parentID + classID + functions[i].id counts[base]++ functions[i].id = base @@ -330,6 +322,23 @@ func assignTSFunctionIDs(functions []tsFunction, lang *gotreesitter.Language, so } } +func tsClassPrefix(node *gotreesitter.Node, lang *gotreesitter.Language, source []byte, parentID string) string { + for parent := node.Parent(); parent != nil; parent = parent.Parent() { + if parent.Type(lang) != "class_declaration" && parent.Type(lang) != "class" { + continue + } + name := parent.ChildByFieldName("name", lang) + if name != nil { + classID := name.Text(source) + "/" + if !strings.Contains("/"+parentID, "/"+classID) { + return classID + } + } + break + } + return "" +} + func collectTSFunctions( node *gotreesitter.Node, lang *gotreesitter.Language, diff --git a/go/internal/codeshape/typescript/tsmetrics_test.go b/go/internal/codeshape/typescript/tsmetrics_test.go index 8781fd7..2db1e44 100644 --- a/go/internal/codeshape/typescript/tsmetrics_test.go +++ b/go/internal/codeshape/typescript/tsmetrics_test.go @@ -111,6 +111,17 @@ func TestTSStructuralIdentity(t *testing.T) { } } +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 { From 0969138ab537f1fe43d1eb1e9f6dabd13a738c13 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 12:32:21 -0500 Subject: [PATCH 39/54] wip(go): T2 remove collector wrappers (RIG-8435) --- go/internal/codeshape/collector.go | 55 ------------------------- go/internal/codeshape/functions.go | 35 ++++++++++------ go/internal/codeshape/inventory.go | 16 ++++--- go/internal/codeshape/inventory_test.go | 6 +-- go/internal/codeshape/judge.go | 42 ++++++++++--------- go/internal/codeshape/judge_test.go | 16 +++---- go/internal/codeshape/model.go | 3 +- 7 files changed, 68 insertions(+), 105 deletions(-) diff --git a/go/internal/codeshape/collector.go b/go/internal/codeshape/collector.go index f8318a3..56c92fa 100644 --- a/go/internal/codeshape/collector.go +++ b/go/internal/codeshape/collector.go @@ -4,15 +4,11 @@ package codeshape import ( - "context" "errors" "fmt" "os" ) -// TrackedPaths lists tracked repository paths. -func TrackedPaths(ctx context.Context, root string) ([]string, error) { return trackedPaths(ctx, root) } - // ReadRepositoryFile reads a path confined to the repository root. func ReadRepositoryFile(root, path string) ([]byte, error) { repository, err := os.OpenRoot(root) @@ -23,54 +19,3 @@ func ReadRepositoryFile(root, path string) ([]byte, error) { closeErr := repository.Close() return data, errors.Join(readErr, closeErr) } - -// CountedPath reports whether the configuration includes a path. -func CountedPath(path string, cfg Config) bool { return countedPath(path, cfg) } - -// MatchesAny reports whether a path matches a pattern. -func MatchesAny(path string, patterns []string) bool { return matchesAny(path, patterns) } - -// NewFunctionMeasurements initializes the function metric maps. -func NewFunctionMeasurements() map[Metric]map[string][]Entry { return newFunctionMeasurements() } - -// AppendFunctionValue records a function measurement. -func AppendFunctionValue(values map[Metric]map[string][]Entry, metric Metric, path, id string, value int) { - appendFunctionValue(values, metric, path, id, value) -} - -// EmptyAllowlist creates an empty allowlist. -func EmptyAllowlist() Allowlist { return emptyAllowlist() } - -// RewriteFunctionEntries updates the function ratchet entries. -func RewriteFunctionEntries(updated, allow, values FunctionMeasurements, cfg Config, seed bool) (bool, []string) { - return rewriteFunctionEntries(updated, allow, values, cfg, seed) -} - -// LimitFor returns the limit for a path and metric. -func (cfg Config) LimitFor(metric Metric, path string) int { return cfg.limitFor(metric, path) } - -// DisplayPath formats a path for a finding. -func DisplayPath(metric Metric, path string) string { return displayPath(metric, path) } - -// SortFindings orders findings by path and metric. -func SortFindings(findings []Finding) { sortFindings(findings) } - -// IsFunctionMetric reports whether a metric measures functions. -func IsFunctionMetric(metric Metric) bool { return isFunctionMetric(metric) } - -// ValidateFunctionEntryIDs checks function entry identifiers. -func ValidateFunctionEntryIDs(entries map[string][]Entry) error { - return validateFunctionEntryIDs(entries) -} - -// FunctionMetrics lists supported function metrics. -func FunctionMetrics() []Metric { return functionMetrics() } - -// SortedFunctionEntries returns function entries ordered by identifier. -func SortedFunctionEntries(entries []Entry) []Entry { return sortedFunctionEntries(entries) } - -// SortedEntryPaths returns the sorted keys of a path map. -func SortedEntryPaths[V any](entries map[string]V) []string { return sortedEntryPaths(entries) } - -// LimitFor returns the threshold for a function metric. -func (limits FunctionLimits) LimitFor(metric Metric) int { return limits.limitFor(metric) } diff --git a/go/internal/codeshape/functions.go b/go/internal/codeshape/functions.go index 2e67802..8edffc9 100644 --- a/go/internal/codeshape/functions.go +++ b/go/internal/codeshape/functions.go @@ -43,7 +43,8 @@ type TSFunctionLimits struct { Lines int `json:"lines"` } -func (limits FunctionLimits) limitFor(metric Metric) int { +// LimitFor returns the threshold for a function metric. +func (limits FunctionLimits) LimitFor(metric Metric) int { switch metric { case MetricGoCognitive: return limits.Go.Cognitive @@ -62,22 +63,27 @@ func (limits FunctionLimits) limitFor(metric Metric) int { } } -func isFunctionMetric(metric Metric) bool { - return slices.Contains(functionMetrics(), metric) +// IsFunctionMetric reports whether a metric measures functions. +func IsFunctionMetric(metric Metric) bool { + return slices.Contains(FunctionMetrics(), metric) } -func functionMetrics() []Metric { +// FunctionMetrics lists supported function metrics. +func FunctionMetrics() []Metric { return []Metric{MetricGoCognitive, MetricGoLines, MetricGoStatements, MetricTSCognitive, MetricTSLines} } -func newFunctionMeasurements() map[Metric]map[string][]Entry { + +// NewFunctionMeasurements initializes the function metric maps. +func NewFunctionMeasurements() map[Metric]map[string][]Entry { values := make(map[Metric]map[string][]Entry) - for _, metric := range functionMetrics() { + for _, metric := range FunctionMetrics() { values[metric] = make(map[string][]Entry) } return values } -func appendFunctionValue(values map[Metric]map[string][]Entry, metric Metric, path, id string, value int) { +// 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 { @@ -91,13 +97,15 @@ func appendFunctionValue(values map[Metric]map[string][]Entry, metric Metric, pa values[metric][path] = append(entries, Entry{ID: id, Value: value, Reason: ""}) } -func sortedFunctionEntries(entries []Entry) []Entry { +// 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 } -func validateFunctionEntryIDs(entries map[string][]Entry) error { +// 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 { @@ -113,12 +121,13 @@ func validateFunctionEntryIDs(entries map[string][]Entry) error { return nil } -func rewriteFunctionEntries(updated, allow, values FunctionMeasurements, cfg Config, seed bool) (bool, []string) { +// 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) + 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 { diff --git a/go/internal/codeshape/inventory.go b/go/internal/codeshape/inventory.go index 27312cc..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)) @@ -80,7 +80,8 @@ func inventoryPaths(repository *os.Root, output []byte, cfg Config) (Snapshot, e 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 @@ -192,7 +194,9 @@ func completePhysicalLine(count int, generated, increment, marker bool) (int, bo } return count, generated } -func trackedPaths(ctx context.Context, root string) ([]string, error) { + +// 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() diff --git a/go/internal/codeshape/inventory_test.go b/go/internal/codeshape/inventory_test.go index 5cce83e..91c6670 100644 --- a/go/internal/codeshape/inventory_test.go +++ b/go/internal/codeshape/inventory_test.go @@ -89,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) } @@ -192,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) } @@ -207,7 +207,7 @@ 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) } }) diff --git a/go/internal/codeshape/judge.go b/go/internal/codeshape/judge.go index b2c249b..dab7889 100644 --- a/go/internal/codeshape/judge.go +++ b/go/internal/codeshape/judge.go @@ -13,7 +13,7 @@ func Judge(cfg Config, allow, baseAllow Allowlist, snap Snapshot) []Finding { findings = append(findings, judgeMetric(cfg, snap.Dirs, allow.Dirs, MetricDirs)...) findings = append(findings, judgeFunctions(cfg, snap.Functions, allow.Functions)...) findings = append(findings, judgeBaseEntries(allow, baseAllow)...) - for _, path := range sortedEntryPaths(allow.Files) { + for _, path := range SortedEntryPaths(allow.Files) { if _, exists := snap.Files[path]; !exists { findings = append(findings, Finding{ Path: path, @@ -24,13 +24,13 @@ 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", }) } @@ -41,27 +41,27 @@ func Judge(cfg Config, allow, baseAllow Allowlist, snap Snapshot) []Finding { 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", + Limit: cfg.Functions.LimitFor(metric), Fix: "remove the stale allowlist entry", }) } } } } - sortFindings(findings) + 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, @@ -72,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, @@ -82,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, @@ -95,11 +95,11 @@ func judgeMetric(cfg Config, values map[string]int, entries map[string]Entry, me 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 _, metric := range FunctionMetrics() { + paths := SortedEntryPaths(values[metric]) for _, path := range paths { for _, current := range values[metric][path] { - limit := cfg.Functions.limitFor(metric) + limit := cfg.Functions.LimitFor(metric) previous, listed := functionEntry(entries[metric][path], current.ID) findingPath := path + "#" + current.ID if !listed { @@ -141,7 +141,7 @@ 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, + 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", }) @@ -178,7 +178,8 @@ func functionExists(entries []Entry, id string) bool { return exists } -func (cfg Config) limitFor(metric Metric, path string) int { +// LimitFor returns the limit for a path and metric. +func (cfg Config) LimitFor(metric Metric, path string) int { if metric != MetricDirs { return cfg.FileLimit } @@ -195,13 +196,14 @@ func (cfg Config) limitFor(metric Metric, path string) int { } func fixForPath(cfg Config, metric Metric, path string) string { - if metric == MetricDirs && cfg.limitFor(metric, path) != cfg.DirLimit { + if metric == MetricDirs && cfg.LimitFor(metric, path) != cfg.DirLimit { return "reorganize the record's decisions" } return fixFor(metric) } -func sortedEntryPaths[V any](entries map[string]V) []string { +// 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) @@ -210,7 +212,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 @@ -219,7 +222,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 d6ba80a..4bb7e7c 100644 --- a/go/internal/codeshape/judge_test.go +++ b/go/internal/codeshape/judge_test.go @@ -19,7 +19,7 @@ func TestJudge(t *testing.T) { 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) } @@ -34,7 +34,7 @@ func TestJudge(t *testing.T) { 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) } @@ -44,7 +44,7 @@ func TestJudge(t *testing.T) { 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{ + findings := Judge(config, allow, EmptyAllowlist(), Snapshot{ Files: map[string]int{}, Dirs: map[string]int{}, Functions: nil, @@ -62,7 +62,7 @@ func TestJudgeBase(t *testing.T) { 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}, @@ -101,7 +101,7 @@ func TestJudgeBase(t *testing.T) { 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) } }) @@ -110,7 +110,7 @@ func TestJudgeBase(t *testing.T) { 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{}, @@ -145,12 +145,12 @@ func TestJudgeFunctionRatchets(t *testing.T) { 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}) + 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}) + findings = Judge(cfg, allow, EmptyAllowlist(), Snapshot{Files: nil, Dirs: nil, Functions: values}) if len(findings) != 0 { t.Fatalf("unchanged and shrunk functions: %#v", findings) } diff --git a/go/internal/codeshape/model.go b/go/internal/codeshape/model.go index 09d5757..f1e3c30 100644 --- a/go/internal/codeshape/model.go +++ b/go/internal/codeshape/model.go @@ -33,7 +33,8 @@ type Allowlist struct { Present bool `json:"-"` } -func emptyAllowlist() Allowlist { +// 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} } From b5076955594c078b9be5ad70f54bb15861c33d30 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 12:32:56 -0500 Subject: [PATCH 40/54] wip(go): T2 document review fixes (RIG-8435) --- go/CHANGELOG.md | 5 +++++ go/go.mod | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/go/CHANGELOG.md b/go/CHANGELOG.md index 40a8fda..13b40d9 100644 --- a/go/CHANGELOG.md +++ b/go/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- Measure Go and TypeScript function complexity and size, with ratcheted allowlists. +- Report unmeasured functions and Biome scope drift instead of silently passing them. + ## [0.2.0](https://github.com/RigelBuild/meissa/compare/go/v0.1.0...go/v0.2.0) (2026-11-10) diff --git a/go/go.mod b/go/go.mod index 958fa51..ef2556a 100644 --- a/go/go.mod +++ b/go/go.mod @@ -4,6 +4,7 @@ go 1.26.0 require ( cuelang.org/go v0.17.1 + github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/dlclark/regexp2/v2 v2.12.0 github.com/odvcencio/gotreesitter v0.56.0 github.com/pelletier/go-toml/v2 v2.3.1 @@ -15,7 +16,6 @@ require ( require ( cuelabs.dev/go/oci/ociregistry v0.0.0-20260601085548-328ff8e2c943 // indirect - github.com/bmatcuk/doublestar/v4 v4.9.1 // 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 From 80b34cc4d2c00e537598c6bfc5f7c8b0833dd273 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 12:33:24 -0500 Subject: [PATCH 41/54] wip(go): T2 consolidate release notes (RIG-8435) --- go/CHANGELOG.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/go/CHANGELOG.md b/go/CHANGELOG.md index 13b40d9..2406701 100644 --- a/go/CHANGELOG.md +++ b/go/CHANGELOG.md @@ -1,10 +1,5 @@ # Changelog -## Unreleased - -- Measure Go and TypeScript function complexity and size, with ratcheted allowlists. -- Report unmeasured functions and Biome scope drift instead of silently passing them. - ## [0.2.0](https://github.com/RigelBuild/meissa/compare/go/v0.1.0...go/v0.2.0) (2026-11-10) @@ -41,6 +36,7 @@ ### 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. From aacda682c2b2ffeb92f9bb6189626fe104d6d58f Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 12:35:09 -0500 Subject: [PATCH 42/54] wip(go): T2 verify Biome scope partitions (RIG-8435) --- .../codeshape/typescript/biome_scope.go | 33 +++++++++++++++---- go/internal/codeshape/typescript/tsmetrics.go | 33 +++++++++++-------- .../codeshape/typescript/tsmetrics_test.go | 27 +++++++++++++++ 3 files changed, 73 insertions(+), 20 deletions(-) diff --git a/go/internal/codeshape/typescript/biome_scope.go b/go/internal/codeshape/typescript/biome_scope.go index 659ae22..dc27bbd 100644 --- a/go/internal/codeshape/typescript/biome_scope.go +++ b/go/internal/codeshape/typescript/biome_scope.go @@ -3,6 +3,7 @@ package typescript import ( + "context" "errors" "fmt" "path" @@ -108,27 +109,45 @@ func biomeOverrideEnabled(config map[string]any, name string, enabled bool) (boo return enabled, nil } -func biomeSkippedPaths(root string, tracked, paths []string, report biomeReport, sources map[string][]byte) ( - []codeshape.Skip, error, +func biomeSkippedPaths(root string, tracked, paths []string, sources map[string][]byte) ( + []codeshape.Skip, []string, []string, error, ) { scopes, err := biomeScopes(root, tracked) if err != nil { - return nil, err + return nil, nil, nil, err } var skips []codeshape.Skip + var included, ignored []string for _, name := range paths { excluded, err := biomeExcluded(name, scopes) if err != nil { - return nil, err + return nil, nil, nil, err } if excluded { + ignored = append(ignored, name) delete(sources, name) skips = append(skips, codeshape.Skip{Path: name, Reason: "ignored by Biome", Unmeasured: &codeshape.UnmeasuredFunctions{Values: map[codeshape.Metric]int{}, Ignored: true}}) + } else { + included = append(included, name) } } - if report.Summary.Changed+report.Summary.Unchanged != len(paths)-len(skips) { - return nil, fmt.Errorf("processed count differs from configuration: %w", errBiomeScope) + return skips, included, ignored, nil +} + +func runBiomePartition(ctx context.Context, binary, root string, paths []string, expected int) (biomeReport, error) { + if len(paths) == 0 { + return biomeReport{Summary: nil, Diagnostics: nil}, nil + } + report, err := runBiome(ctx, binary, root, 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) + } + if report.Summary.Changed+report.Summary.Unchanged != expected { + return biomeReport{}, fmt.Errorf("processed count differs from configuration: %w", errBiomeScope) } - return skips, nil + return report, nil } diff --git a/go/internal/codeshape/typescript/tsmetrics.go b/go/internal/codeshape/typescript/tsmetrics.go index a04957c..f6cf652 100644 --- a/go/internal/codeshape/typescript/tsmetrics.go +++ b/go/internal/codeshape/typescript/tsmetrics.go @@ -86,11 +86,7 @@ func TSMetrics(ctx context.Context, root string, cfg codeshape.Config) ( if len(sources) == 0 { return codeshape.NewFunctionMeasurements(), nil, nil } - _, report, sourcePaths, err := prepareBiome(ctx, root, sources) - if err != nil { - return nil, nil, err - } - skips, err := biomeSkippedPaths(root, paths, sourcePaths, report, sources) + report, skips, err := prepareBiome(ctx, root, paths, sources) if err != nil { return nil, nil, err } @@ -118,26 +114,37 @@ func TSMetrics(ctx context.Context, root string, cfg codeshape.Config) ( return values, skips, nil } -func prepareBiome(ctx context.Context, root string, sources map[string][]byte) (string, biomeReport, []string, error) { +func prepareBiome(ctx context.Context, root string, tracked []string, sources map[string][]byte) ( + biomeReport, []codeshape.Skip, error, +) { binary, err := exec.LookPath("biome") if err != nil { - return "", biomeReport{}, nil, fmt.Errorf("find biome: %w", err) + return biomeReport{}, nil, fmt.Errorf("find biome: %w", err) } err = codeshape.CheckToolVersion(ctx, binary, codeshape.BiomeVersion) if err != nil { - return "", biomeReport{}, nil, fmt.Errorf("check biome: %w", err) + return biomeReport{}, nil, fmt.Errorf("check biome: %w", err) } var paths []string for path := range sources { paths = append(paths, path) } sort.Strings(paths) - report, err := runBiome(ctx, binary, root, paths) - if err == nil && report.Summary.DiagnosticsNotPrinted != 0 { - return "", biomeReport{}, nil, fmt.Errorf("%w: %d were not printed", - errBiomeTruncated, report.Summary.DiagnosticsNotPrinted) + skips, included, ignored, err := biomeSkippedPaths(root, tracked, paths, sources) + if err != nil { + return biomeReport{}, nil, err + } + report, err := runBiomePartition(ctx, binary, root, included, len(included)) + if err != nil { + return biomeReport{}, nil, err + } + if len(ignored) > 0 { + _, err = runBiomePartition(ctx, binary, root, ignored, 0) + if err != nil { + return biomeReport{}, nil, err + } } - return binary, report, paths, err + return report, skips, nil } func readTSSources(root string, paths []string, cfg codeshape.Config) (map[string][]byte, error) { diff --git a/go/internal/codeshape/typescript/tsmetrics_test.go b/go/internal/codeshape/typescript/tsmetrics_test.go index 2db1e44..e82b836 100644 --- a/go/internal/codeshape/typescript/tsmetrics_test.go +++ b/go/internal/codeshape/typescript/tsmetrics_test.go @@ -76,6 +76,33 @@ func TestTSIgnoredFileIsSkipped(t *testing.T) { } } +func TestBiomeOppositeScopeMismatchesFailClosed(t *testing.T) { + root := tsRepo(t, "function f() {}\n") + config := strings.Replace(tsBiomeConfig, `{`, `{"files":{"includes":["**", "!ignored.ts"]},`, 1) + trackedFile(t, root, "biome.json", config) + trackedFile(t, root, "ignored.ts", "function ignored() {}\n") + commitFiles(t, root) + binary, err := exec.LookPath("biome") + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + paths []string + expected int + }{ + {"predicted processed", []string{"ignored.ts"}, 1}, + {"predicted ignored", []string{"x.ts"}, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := runBiomePartition(t.Context(), binary, root, tc.paths, tc.expected) + if err == nil { + t.Fatal("scope mismatch passed") + } + }) + } +} + func TestTSGeneratorAndUnicodePosition(t *testing.T) { source := []byte("const s = 'é😀'; function* generate() { yield 1; }") funcs, tree, err := parseTSFunctions("x.ts", source) From d4e0d21aed7dbabc09d3b0e61a2cdff5213dd276 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 13:01:46 -0500 Subject: [PATCH 43/54] wip(go): T2 force Biome measurement config (RIG-8435) --- go/go.mod | 7 - go/go.sum | 4 - .../codeshape/typescript/biome_config.go | 97 +++++++++++ .../codeshape/typescript/biome_config_test.go | 61 +++++++ .../codeshape/typescript/biome_scope.go | 153 ------------------ go/internal/codeshape/typescript/tsmetrics.go | 85 +++++----- .../codeshape/typescript/tsmetrics_test.go | 34 +--- 7 files changed, 199 insertions(+), 242 deletions(-) create mode 100644 go/internal/codeshape/typescript/biome_config.go create mode 100644 go/internal/codeshape/typescript/biome_config_test.go delete mode 100644 go/internal/codeshape/typescript/biome_scope.go diff --git a/go/go.mod b/go/go.mod index ef2556a..0065035 100644 --- a/go/go.mod +++ b/go/go.mod @@ -4,7 +4,6 @@ go 1.26.0 require ( cuelang.org/go v0.17.1 - github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/dlclark/regexp2/v2 v2.12.0 github.com/odvcencio/gotreesitter v0.56.0 github.com/pelletier/go-toml/v2 v2.3.1 @@ -15,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 9648901..62637e9 100644 --- a/go/go.sum +++ b/go/go.sum @@ -2,8 +2,6 @@ cuelabs.dev/go/oci/ociregistry v0.0.0-20260601085548-328ff8e2c943 h1:XUtzi/yWlmu cuelabs.dev/go/oci/ociregistry v0.0.0-20260601085548-328ff8e2c943/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8= cuelang.org/go v0.17.1 h1:liOkxZDqTHrzq0USJX+6bMYOZ5PSf+wzvQr15AHpDCQ= cuelang.org/go v0.17.1/go.mod h1:xlly/o1wSLvxOsi5vkQGieU0rLOt7TvUIizOFtnxHRU= -github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= -github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/cockroachdb/apd/v3 v3.2.3 h1:4Zx+I3R35bFXMnltzmjP79i2cravE4jTRL6ps9Aux80= github.com/cockroachdb/apd/v3 v3.2.3/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= github.com/dlclark/regexp2/v2 v2.12.0 h1:GsCgDZZeHluUDHASr2yqQonZk4Tma5LFmLH9CSsFFEE= @@ -56,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/typescript/biome_config.go b/go/internal/codeshape/typescript/biome_config.go new file mode 100644 index 0000000..3714d7c --- /dev/null +++ b/go/internal/codeshape/typescript/biome_config.go @@ -0,0 +1,97 @@ +// 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 writeGateBiomeConfig(dir string, limits codeshape.FunctionLimits, parser json.RawMessage) (string, error) { + config := map[string]any{ + "vcs": map[string]any{"enabled": false}, + "files": map[string]any{"includes": []string{"**"}}, + "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 []string{"biome.json", "biome.jsonc", ".biome.json", ".biome.jsonc"} { + 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", err) + } + 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..157e485 --- /dev/null +++ b/go/internal/codeshape/typescript/biome_config_test.go @@ -0,0 +1,61 @@ +// 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}`)) + 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) + } +} diff --git a/go/internal/codeshape/typescript/biome_scope.go b/go/internal/codeshape/typescript/biome_scope.go deleted file mode 100644 index dc27bbd..0000000 --- a/go/internal/codeshape/typescript/biome_scope.go +++ /dev/null @@ -1,153 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package typescript - -import ( - "context" - "errors" - "fmt" - "path" - "strings" - - "github.com/RigelBuild/meissa/go/internal/codeshape" - "github.com/bmatcuk/doublestar/v4" -) - -var errBiomeScope = errors.New("invalid or inconsistent Biome scope") - -type biomeScope struct { - dir string - config map[string]any -} - -func biomeScopes(root string, paths []string) ([]biomeScope, error) { - var scopes []biomeScope - for _, name := range paths { - if path.Base(name) != "biome.json" && path.Base(name) != "biome.jsonc" { - continue - } - config, err := readBiomeConfig(root, name) - if err != nil { - return nil, err - } - scopes = append(scopes, biomeScope{dir: path.Dir(name), config: config}) - } - return scopes, nil -} - -func biomeIncludes(raw any, name string) (bool, error) { - patterns, ok := raw.([]any) - if !ok { - return true, nil - } - included := false - for _, item := range patterns { - pattern, ok := item.(string) - if !ok { - return false, fmt.Errorf("include pattern: %w", errBiomeScope) - } - negated := strings.HasPrefix(pattern, "!") - pattern = strings.TrimPrefix(pattern, "!") - matched, err := doublestar.Match(pattern, name) - if err != nil { - return false, fmt.Errorf("invalid Biome glob %q: %w", pattern, err) - } - if matched { - included = !negated - } - } - return included, nil -} - -func biomeExcluded(name string, scopes []biomeScope) (bool, error) { - enabled := true - for _, scope := range scopes { - relative := name - if scope.dir != "." { - prefix := scope.dir + "/" - if !strings.HasPrefix(name, prefix) { - continue - } - relative = strings.TrimPrefix(name, prefix) - } - files, _ := scope.config["files"].(map[string]any) - included, err := biomeIncludes(files["includes"], relative) - if err != nil { - return false, err - } - if !included { - return true, nil - } - linter, _ := scope.config["linter"].(map[string]any) - if value, ok := linter["enabled"].(bool); ok { - enabled = value - } - enabled, err = biomeOverrideEnabled(scope.config, relative, enabled) - if err != nil { - return false, err - } - } - return !enabled, nil -} - -func biomeOverrideEnabled(config map[string]any, name string, enabled bool) (bool, error) { - overrides, _ := config["overrides"].([]any) - for _, raw := range overrides { - override, ok := raw.(map[string]any) - if !ok { - continue - } - applies, err := biomeIncludes(override["includes"], name) - if err != nil { - return false, err - } - linter, _ := override["linter"].(map[string]any) - if value, ok := linter["enabled"].(bool); applies && ok { - enabled = value - } - } - return enabled, nil -} - -func biomeSkippedPaths(root string, tracked, paths []string, sources map[string][]byte) ( - []codeshape.Skip, []string, []string, error, -) { - scopes, err := biomeScopes(root, tracked) - if err != nil { - return nil, nil, nil, err - } - var skips []codeshape.Skip - var included, ignored []string - for _, name := range paths { - excluded, err := biomeExcluded(name, scopes) - if err != nil { - return nil, nil, nil, err - } - if excluded { - ignored = append(ignored, name) - delete(sources, name) - skips = append(skips, codeshape.Skip{Path: name, Reason: "ignored by Biome", - Unmeasured: &codeshape.UnmeasuredFunctions{Values: map[codeshape.Metric]int{}, Ignored: true}}) - } else { - included = append(included, name) - } - } - return skips, included, ignored, nil -} - -func runBiomePartition(ctx context.Context, binary, root string, paths []string, expected int) (biomeReport, error) { - if len(paths) == 0 { - return biomeReport{Summary: nil, Diagnostics: nil}, nil - } - report, err := runBiome(ctx, binary, root, 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) - } - if report.Summary.Changed+report.Summary.Unchanged != expected { - return biomeReport{}, fmt.Errorf("processed count differs from configuration: %w", errBiomeScope) - } - return report, nil -} diff --git a/go/internal/codeshape/typescript/tsmetrics.go b/go/internal/codeshape/typescript/tsmetrics.go index f6cf652..28cda04 100644 --- a/go/internal/codeshape/typescript/tsmetrics.go +++ b/go/internal/codeshape/typescript/tsmetrics.go @@ -6,9 +6,9 @@ package typescript import ( "bytes" "context" - "encoding/json" "errors" "fmt" + "os" "os/exec" "path/filepath" "slices" @@ -86,10 +86,11 @@ func TSMetrics(ctx context.Context, root string, cfg codeshape.Config) ( if len(sources) == 0 { return codeshape.NewFunctionMeasurements(), nil, nil } - report, skips, err := prepareBiome(ctx, root, paths, sources) + report, err := prepareBiome(ctx, root, cfg, sources) if err != nil { return nil, nil, err } + var skips []codeshape.Skip values := codeshape.NewFunctionMeasurements() files := make(map[string][]tsFunction, len(sources)) for path, source := range sources { @@ -114,37 +115,58 @@ func TSMetrics(ctx context.Context, root string, cfg codeshape.Config) ( return values, skips, nil } -func prepareBiome(ctx context.Context, root string, tracked []string, sources map[string][]byte) ( - biomeReport, []codeshape.Skip, error, +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{}, nil, fmt.Errorf("find biome: %w", err) + return biomeReport{}, fmt.Errorf("find biome: %w", err) } err = codeshape.CheckToolVersion(ctx, binary, codeshape.BiomeVersion) if err != nil { - return biomeReport{}, nil, fmt.Errorf("check biome: %w", err) + return biomeReport{}, fmt.Errorf("check biome: %w", err) } var paths []string for path := range sources { paths = append(paths, path) } sort.Strings(paths) - skips, included, ignored, err := biomeSkippedPaths(root, tracked, paths, sources) + dir, err := os.MkdirTemp("", "code-shape-biome-*") if err != nil { - return biomeReport{}, nil, err + return biomeReport{}, fmt.Errorf("create Biome config directory: %w", err) } - report, err := runBiomePartition(ctx, binary, root, included, len(included)) + report, runErr := runGateBiome(ctx, binary, root, dir, paths, cfg.Functions) + removeErr := os.RemoveAll(dir) + err = errors.Join(runErr, removeErr) if err != nil { - return biomeReport{}, nil, err + return biomeReport{}, err } - if len(ignored) > 0 { - _, err = runBiomePartition(ctx, binary, root, ignored, 0) - if err != nil { - return biomeReport{}, nil, err - } + return report, nil +} + +func runGateBiome(ctx context.Context, binary, root, dir string, paths []string, limits codeshape.FunctionLimits) ( + biomeReport, error, +) { + parser, err := rootBiomeParser(root) + if err != nil { + return biomeReport{}, err } - return report, skips, nil + _, err = writeGateBiomeConfig(dir, limits, parser) + 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) + } + if report.Summary.Changed+report.Summary.Unchanged != len(paths) { + return biomeReport{}, fmt.Errorf("processed count differs from requested paths: %w", errBiomeMissingOutput) + } + return report, nil } func readTSSources(root string, paths []string, cfg codeshape.Config) (map[string][]byte, error) { @@ -205,37 +227,6 @@ func applyBiomeIssues(report biomeReport, files map[string][]tsFunction, sources return nil } -func runBiome(ctx context.Context, binary, root 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") - 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", err) - } - if report.Summary == nil || report.Diagnostics == nil { - return biomeReport{}, errBiomeMissingOutput - } - err = validateBiomeErrors(report) - if err != nil { - return biomeReport{}, err - } - return report, nil -} - func readBiomeConfig(root, path string) (map[string]any, error) { data, err := codeshape.ReadRepositoryFile(root, path) if err != nil { diff --git a/go/internal/codeshape/typescript/tsmetrics_test.go b/go/internal/codeshape/typescript/tsmetrics_test.go index e82b836..0c32b48 100644 --- a/go/internal/codeshape/typescript/tsmetrics_test.go +++ b/go/internal/codeshape/typescript/tsmetrics_test.go @@ -40,7 +40,7 @@ func TestTSOnlyOffUsesOptions(t *testing.T) { if err != nil { t.Fatal(err) } - report, err := runBiome(t.Context(), binary, root, []string{"x.ts"}) + report, err := runBiome(t.Context(), binary, root, root, []string{"x.ts"}) if err != nil { t.Fatal(err) } @@ -56,7 +56,7 @@ func TestTSOnlyOffUsesOptions(t *testing.T) { } } -func TestTSIgnoredFileIsSkipped(t *testing.T) { +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) @@ -70,39 +70,11 @@ func TestTSIgnoredFileIsSkipped(t *testing.T) { t.Fatal(err) } values, skips, err := TSMetrics(t.Context(), root, cfg) - if err != nil || len(skips) != 1 || skips[0].Path != "ignored.ts" || - len(values[codeshape.MetricTSLines]["ignored.ts"]) != 0 { + 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 TestBiomeOppositeScopeMismatchesFailClosed(t *testing.T) { - root := tsRepo(t, "function f() {}\n") - config := strings.Replace(tsBiomeConfig, `{`, `{"files":{"includes":["**", "!ignored.ts"]},`, 1) - trackedFile(t, root, "biome.json", config) - trackedFile(t, root, "ignored.ts", "function ignored() {}\n") - commitFiles(t, root) - binary, err := exec.LookPath("biome") - if err != nil { - t.Fatal(err) - } - for _, tc := range []struct { - name string - paths []string - expected int - }{ - {"predicted processed", []string{"ignored.ts"}, 1}, - {"predicted ignored", []string{"x.ts"}, 0}, - } { - t.Run(tc.name, func(t *testing.T) { - _, err := runBiomePartition(t.Context(), binary, root, tc.paths, tc.expected) - if err == nil { - t.Fatal("scope mismatch passed") - } - }) - } -} - func TestTSGeneratorAndUnicodePosition(t *testing.T) { source := []byte("const s = 'é😀'; function* generate() { yield 1; }") funcs, tree, err := parseTSFunctions("x.ts", source) From 925508d0fadd64015bd270d8ebe5cc1e51f60b68 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 13:02:45 -0500 Subject: [PATCH 44/54] wip(go): T2 refresh tools vendor hash (RIG-8435) --- go/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" ]; From 10c187d53d0a8aa1168ce016f1c47699a5efd44c Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 13:04:57 -0500 Subject: [PATCH 45/54] wip(go): T2 report Biome parse failures (RIG-8435) --- go/internal/codeshape/cli/failclosed_test.go | 8 ++++++ go/internal/codeshape/cli/unmeasured.go | 2 +- go/internal/codeshape/typescript/tsmetrics.go | 5 +++- .../codeshape/typescript/unmeasured.go | 26 ++++++++++++++++++- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/go/internal/codeshape/cli/failclosed_test.go b/go/internal/codeshape/cli/failclosed_test.go index 9d7e2cf..912251b 100644 --- a/go/internal/codeshape/cli/failclosed_test.go +++ b/go/internal/codeshape/cli/failclosed_test.go @@ -42,6 +42,14 @@ func TestBiomeFailClosed(t *testing.T) { } } +func TestBiomeParseFailureIsFinding(t *testing.T) { + root := tsRepo(t, "function broken( {\n") + code, out, stderr := runGoGate(t, root) + if code != 1 || !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 diff --git a/go/internal/codeshape/cli/unmeasured.go b/go/internal/codeshape/cli/unmeasured.go index e6fdc53..d26863c 100644 --- a/go/internal/codeshape/cli/unmeasured.go +++ b/go/internal/codeshape/cli/unmeasured.go @@ -20,7 +20,7 @@ func handleTSSkips(output io.Writer, skips []codeshape.Skip, cfg codeshape.Confi paths = append(paths, skip.Path) if skip.Unmeasured.Ignored { findings = append(findings, codeshape.Finding{Path: skip.Path, Metric: codeshape.MetricTSLines, - Value: 1, Limit: 0, Fix: "add to code-shape exclude or let Biome lint it"}) + Value: 1, 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, diff --git a/go/internal/codeshape/typescript/tsmetrics.go b/go/internal/codeshape/typescript/tsmetrics.go index 28cda04..cd08f99 100644 --- a/go/internal/codeshape/typescript/tsmetrics.go +++ b/go/internal/codeshape/typescript/tsmetrics.go @@ -90,7 +90,10 @@ func TSMetrics(ctx context.Context, root string, cfg codeshape.Config) ( if err != nil { return nil, nil, err } - var skips []codeshape.Skip + 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 { diff --git a/go/internal/codeshape/typescript/unmeasured.go b/go/internal/codeshape/typescript/unmeasured.go index d7cff97..5edc06b 100644 --- a/go/internal/codeshape/typescript/unmeasured.go +++ b/go/internal/codeshape/typescript/unmeasured.go @@ -5,6 +5,7 @@ package typescript import ( "fmt" "path/filepath" + "strings" "github.com/RigelBuild/meissa/go/internal/codeshape" ) @@ -41,7 +42,8 @@ func validateBiomeErrors(report biomeReport) error { if diagnostic.Severity != "error" { continue } - if _, ok := metrics[diagnostic.Category]; !ok { + 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++ @@ -51,3 +53,25 @@ func validateBiomeErrors(report biomeReport) error { } 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{}, Ignored: true}}) + } + return skips, nil +} From 1496307179f63e5547a74a5ecee5210fa286079b Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 13:06:55 -0500 Subject: [PATCH 46/54] wip(go): T2 enforce unmeasured base ratchet (RIG-8435) --- go/internal/codeshape/cli/cli.go | 12 +++++++++--- go/internal/codeshape/cli/cli_test.go | 2 +- go/internal/codeshape/cli/unmeasured_test.go | 15 +++++++++++++++ go/internal/codeshape/judge.go | 5 +++-- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/go/internal/codeshape/cli/cli.go b/go/internal/codeshape/cli/cli.go index 7cede4b..0974bcb 100644 --- a/go/internal/codeshape/cli/cli.go +++ b/go/internal/codeshape/cli/cli.go @@ -69,10 +69,15 @@ func Main(ctx context.Context, args []string, env func(string) string, stdout, s 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, preserved, suppressions) + return writeUpdated(root, config, allow, base, snapshot, seed, stdout, stderr, + preserved, suppressions, baseFindings) } findings := append(codeshape.Judge(config, allow, base, snapshot), suppressions...) + findings = append(findings, baseFindings...) return printGateFindings(stdout, stderr, findings) } @@ -147,7 +152,7 @@ func writeUpdated( snapshot codeshape.Snapshot, seed bool, stdout, stderr io.Writer, - preserved codeshape.FunctionMeasurements, policy []codeshape.Finding, + preserved codeshape.FunctionMeasurements, policy, baseFindings []codeshape.Finding, ) int { updated, changed, err := rewriteAllowlist(config, allow, snapshot, seed) if err != nil { @@ -155,8 +160,9 @@ func writeUpdated( return 2 } findings := codeshape.Judge(config, updated, base, snapshot) - ratchetFailures := len(findings) > 0 + 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 { diff --git a/go/internal/codeshape/cli/cli_test.go b/go/internal/codeshape/cli/cli_test.go index b3670a0..f56e72d 100644 --- a/go/internal/codeshape/cli/cli_test.go +++ b/go/internal/codeshape/cli/cli_test.go @@ -128,7 +128,7 @@ func TestWriteUpdatedRejectsEscapingAllowlist(t *testing.T) { 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) + &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()) diff --git a/go/internal/codeshape/cli/unmeasured_test.go b/go/internal/codeshape/cli/unmeasured_test.go index 2e4692f..003f5b6 100644 --- a/go/internal/codeshape/cli/unmeasured_test.go +++ b/go/internal/codeshape/cli/unmeasured_test.go @@ -40,3 +40,18 @@ func TestUnparseableTypeScriptKeepsAllowlist(t *testing.T) { 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/judge.go b/go/internal/codeshape/judge.go index dab7889..70463cd 100644 --- a/go/internal/codeshape/judge.go +++ b/go/internal/codeshape/judge.go @@ -12,7 +12,7 @@ func Judge(cfg Config, allow, baseAllow Allowlist, snap Snapshot) []Finding { findings = append(findings, judgeMetric(cfg, snap.Files, allow.Files, MetricFiles)...) findings = append(findings, judgeMetric(cfg, snap.Dirs, allow.Dirs, MetricDirs)...) findings = append(findings, judgeFunctions(cfg, snap.Functions, allow.Functions)...) - findings = append(findings, judgeBaseEntries(allow, baseAllow)...) + findings = append(findings, JudgeBaseEntries(allow, baseAllow)...) for _, path := range SortedEntryPaths(allow.Files) { if _, exists := snap.Files[path]; !exists { findings = append(findings, Finding{ @@ -128,7 +128,8 @@ func judgeFunctions(cfg Config, values, entries map[Metric]map[string][]Entry) [ 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 } From 9ff85e2daec34646a3e96f1235cf06a14839e8dc Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 13:07:40 -0500 Subject: [PATCH 47/54] wip(go): T2 label unmeasured findings (RIG-8435) --- go/internal/codeshape/cli/failclosed_test.go | 2 +- go/internal/codeshape/cli/unmeasured.go | 8 ++++---- go/internal/codeshape/functions.go | 2 +- go/internal/codeshape/metrics.go | 1 + 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/go/internal/codeshape/cli/failclosed_test.go b/go/internal/codeshape/cli/failclosed_test.go index 912251b..614ffb9 100644 --- a/go/internal/codeshape/cli/failclosed_test.go +++ b/go/internal/codeshape/cli/failclosed_test.go @@ -45,7 +45,7 @@ func TestBiomeFailClosed(t *testing.T) { func TestBiomeParseFailureIsFinding(t *testing.T) { root := tsRepo(t, "function broken( {\n") code, out, stderr := runGoGate(t, root) - if code != 1 || !strings.Contains(out, "Biome parse error") { + 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) } } diff --git a/go/internal/codeshape/cli/unmeasured.go b/go/internal/codeshape/cli/unmeasured.go index d26863c..0680a5b 100644 --- a/go/internal/codeshape/cli/unmeasured.go +++ b/go/internal/codeshape/cli/unmeasured.go @@ -19,8 +19,8 @@ func handleTSSkips(output io.Writer, skips []codeshape.Skip, cfg codeshape.Confi if skip.Unmeasured != nil { paths = append(paths, skip.Path) if skip.Unmeasured.Ignored { - findings = append(findings, codeshape.Finding{Path: skip.Path, Metric: codeshape.MetricTSLines, - Value: 1, Limit: 0, Fix: skip.Reason + "; exclude it or fix the parse"}) + 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, @@ -73,8 +73,8 @@ func handleGoSkips(output io.Writer, skips []codeshape.Skip) ([]codeshape.Findin } if skip.Unmeasured != nil { paths = append(paths, skip.Path) - findings = append(findings, codeshape.Finding{Path: skip.Path, Metric: codeshape.MetricGoLines, - Value: 1, Limit: 0, Fix: "add to code-shape exclude or include it in the default Go build"}) + 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/functions.go b/go/internal/codeshape/functions.go index 8edffc9..6f5cbb5 100644 --- a/go/internal/codeshape/functions.go +++ b/go/internal/codeshape/functions.go @@ -56,7 +56,7 @@ func (limits FunctionLimits) LimitFor(metric Metric) int { return limits.TS.Cognitive case MetricTSLines: return limits.TS.Lines - case MetricFiles, MetricDirs: + case MetricFiles, MetricDirs, MetricUnmeasured: return 0 default: return 0 diff --git a/go/internal/codeshape/metrics.go b/go/internal/codeshape/metrics.go index bef0052..23e9547 100644 --- a/go/internal/codeshape/metrics.go +++ b/go/internal/codeshape/metrics.go @@ -14,6 +14,7 @@ const ( 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. From 6b779d2176ede2eb962f726b2d5b065e47a033ce Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 13:08:12 -0500 Subject: [PATCH 48/54] wip(go): T2 document forced measurement (RIG-8435) --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5afeaa6..0d3f1e5 100644 --- a/README.md +++ b/README.md @@ -51,11 +51,13 @@ Optional `dirLimits` maps directory globs to `{limit: 50, reason: "record decisi 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. Skips are printed on stderr. If Biome reports a measured rule in a TypeScript file the function collector cannot parse, the gate reports a file-level finding and preserves its existing function allowlist entries on `--write`. +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. -Skips that leave no functions unmeasured are notices only. A counted Go file outside the default build that contains functions is a finding. Counted TypeScript files Biome ignores are also findings. Use the gate's `exclude` to opt out. +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. -The two measured Biome rules may use `warn` or `error` levels. Both report gate findings when a function exceeds its limit. +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. + +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"}] From 1202fe2ec5ef4e3cc93ab2e9d5917dab48be9483 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 13:09:28 -0500 Subject: [PATCH 49/54] wip(go): T2 fix Biome review mechanics (RIG-8435) --- .../codeshape/typescript/biome_config.go | 2 +- go/internal/codeshape/typescript/tsmetrics.go | 32 +++++++++++-------- .../codeshape/typescript/tsmetrics_test.go | 14 +++++++- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/go/internal/codeshape/typescript/biome_config.go b/go/internal/codeshape/typescript/biome_config.go index 3714d7c..937b1e2 100644 --- a/go/internal/codeshape/typescript/biome_config.go +++ b/go/internal/codeshape/typescript/biome_config.go @@ -84,7 +84,7 @@ func runBiome(ctx context.Context, binary, root, configDir string, paths []strin var report biomeReport err := json.Unmarshal(stdout.Bytes(), &report) if err != nil { - return biomeReport{}, fmt.Errorf("parse biome output: %w", err) + return biomeReport{}, fmt.Errorf("parse biome output: %w: %s", err, strings.TrimSpace(stderr.String())) } if report.Summary == nil || report.Diagnostics == nil { return biomeReport{}, errBiomeMissingOutput diff --git a/go/internal/codeshape/typescript/tsmetrics.go b/go/internal/codeshape/typescript/tsmetrics.go index cd08f99..297be65 100644 --- a/go/internal/codeshape/typescript/tsmetrics.go +++ b/go/internal/codeshape/typescript/tsmetrics.go @@ -306,14 +306,16 @@ func assignTSFunctionIDs(functions []tsFunction, lang *gotreesitter.Language, so 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, lang, source, parentID) + classID := tsClassPrefix(functions[i].node, parentNode, lang, source) base := parentID + classID + functions[i].id counts[base]++ functions[i].id = base @@ -323,21 +325,25 @@ func assignTSFunctionIDs(functions []tsFunction, lang *gotreesitter.Language, so } } -func tsClassPrefix(node *gotreesitter.Node, lang *gotreesitter.Language, source []byte, parentID string) string { +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" { - continue + if parent.Type(lang) == "class_declaration" || parent.Type(lang) == "class" { + return parent } - name := parent.ChildByFieldName("name", lang) - if name != nil { - classID := name.Text(source) + "/" - if !strings.Contains("/"+parentID, "/"+classID) { - return classID - } - } - break } - return "" + return nil } func collectTSFunctions( diff --git a/go/internal/codeshape/typescript/tsmetrics_test.go b/go/internal/codeshape/typescript/tsmetrics_test.go index 0c32b48..e11c564 100644 --- a/go/internal/codeshape/typescript/tsmetrics_test.go +++ b/go/internal/codeshape/typescript/tsmetrics_test.go @@ -47,7 +47,8 @@ func TestTSOnlyOffUsesOptions(t *testing.T) { seen := map[string]bool{} for _, d := range report.Diagnostics { seen[d.Category] = true - if d.Category == "lint/complexity/noExcessiveLinesPerFunction" && !strings.Contains(d.Message, "3") { + if d.Category == "lint/complexity/noExcessiveLinesPerFunction" && + !strings.Contains(d.Message, "Maximum allowed is 3.") { t.Fatalf("missing configured limit: %q", d.Message) } } @@ -181,3 +182,14 @@ func TestTSNestedDescribeIdentity(t *testing.T) { } 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) + } +} From 3ac30d05089ec30cc8c420acb9c27b02b4b0a704 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 13:41:23 -0500 Subject: [PATCH 50/54] wip(go): T2 delimit Biome path arguments (RIG-8435) --- go/internal/codeshape/cli/collectors_test.go | 10 ++++++++++ go/internal/codeshape/typescript/biome_config.go | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/go/internal/codeshape/cli/collectors_test.go b/go/internal/codeshape/cli/collectors_test.go index 40e701f..afbcf02 100644 --- a/go/internal/codeshape/cli/collectors_test.go +++ b/go/internal/codeshape/cli/collectors_test.go @@ -41,6 +41,16 @@ func TestSkippedGoFunctionsAreFindings(t *testing.T) { } } +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") diff --git a/go/internal/codeshape/typescript/biome_config.go b/go/internal/codeshape/typescript/biome_config.go index 937b1e2..cd11750 100644 --- a/go/internal/codeshape/typescript/biome_config.go +++ b/go/internal/codeshape/typescript/biome_config.go @@ -69,7 +69,7 @@ func runBiome(ctx context.Context, binary, root, configDir string, paths []strin 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) + "--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 From 928bb1e55f531cf644cdcf04d817d3e73eaa6ca1 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 13:42:56 -0500 Subject: [PATCH 51/54] wip(go): T2 size Biome inputs safely (RIG-8435) --- README.md | 2 ++ go/internal/codeshape/typescript/biome_config.go | 6 ++++-- .../codeshape/typescript/biome_config_test.go | 15 ++++++++++++++- go/internal/codeshape/typescript/tsmetrics.go | 15 +++++++++++---- 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0d3f1e5..fe2405c 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ The gate runs Biome with its own generated configuration and the gate's limits. 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 diff --git a/go/internal/codeshape/typescript/biome_config.go b/go/internal/codeshape/typescript/biome_config.go index cd11750..a674cb5 100644 --- a/go/internal/codeshape/typescript/biome_config.go +++ b/go/internal/codeshape/typescript/biome_config.go @@ -18,10 +18,12 @@ import ( "github.com/RigelBuild/meissa/go/internal/codeshape" ) -func writeGateBiomeConfig(dir string, limits codeshape.FunctionLimits, parser json.RawMessage) (string, error) { +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{"**"}}, + "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}}, diff --git a/go/internal/codeshape/typescript/biome_config_test.go b/go/internal/codeshape/typescript/biome_config_test.go index 157e485..5b4bdd1 100644 --- a/go/internal/codeshape/typescript/biome_config_test.go +++ b/go/internal/codeshape/typescript/biome_config_test.go @@ -16,7 +16,8 @@ import ( 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}`)) + name, err := writeGateBiomeConfig(t.TempDir(), limits, + json.RawMessage(`{"unsafeParameterDecoratorsEnabled":true}`), 1048576) if err != nil { t.Fatal(err) } @@ -59,3 +60,15 @@ func TestGateBiomeIgnoresRepositoryLimit(t *testing.T) { 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/tsmetrics.go b/go/internal/codeshape/typescript/tsmetrics.go index 297be65..11ced26 100644 --- a/go/internal/codeshape/typescript/tsmetrics.go +++ b/go/internal/codeshape/typescript/tsmetrics.go @@ -43,6 +43,7 @@ var ( 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 { @@ -138,7 +139,11 @@ func prepareBiome(ctx context.Context, root string, cfg codeshape.Config, source if err != nil { return biomeReport{}, fmt.Errorf("create Biome config directory: %w", err) } - report, runErr := runGateBiome(ctx, binary, root, dir, paths, cfg.Functions) + 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 { @@ -147,14 +152,16 @@ func prepareBiome(ctx context.Context, root string, cfg codeshape.Config, source return report, nil } -func runGateBiome(ctx context.Context, binary, root, dir string, paths []string, limits codeshape.FunctionLimits) ( +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) + _, err = writeGateBiomeConfig(dir, limits, parser, maxSize) if err != nil { return biomeReport{}, err } @@ -167,7 +174,7 @@ func runGateBiome(ctx context.Context, binary, root, dir string, paths []string, errBiomeTruncated, report.Summary.DiagnosticsNotPrinted) } if report.Summary.Changed+report.Summary.Unchanged != len(paths) { - return biomeReport{}, fmt.Errorf("processed count differs from requested paths: %w", errBiomeMissingOutput) + return biomeReport{}, fmt.Errorf("requested paths %v: %w", paths, errBiomeUnprocessed) } return report, nil } From 8f4d9ee2e4d96836b12495c4e37d047797ef950f Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 13:43:26 -0500 Subject: [PATCH 52/54] wip(go): T2 test Biome count guard (RIG-8435) --- go/internal/codeshape/cli/failclosed_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/go/internal/codeshape/cli/failclosed_test.go b/go/internal/codeshape/cli/failclosed_test.go index 614ffb9..26d04a9 100644 --- a/go/internal/codeshape/cli/failclosed_test.go +++ b/go/internal/codeshape/cli/failclosed_test.go @@ -29,6 +29,7 @@ func TestBiomeFailClosed(t *testing.T) { }{ {"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) { From 0f6d95ce20c0c6baec04f741793a3d907dd6d4e1 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 13:44:57 -0500 Subject: [PATCH 53/54] wip(go): T2 align Biome config discovery (RIG-8435) --- go/internal/codeshape/cli/unmeasured.go | 2 +- go/internal/codeshape/cli/unmeasured_test.go | 2 +- go/internal/codeshape/functions.go | 4 ++-- go/internal/codeshape/golang/gometrics.go | 2 +- go/internal/codeshape/typescript/biome_config.go | 6 +++++- go/internal/codeshape/typescript/drift_test.go | 15 +++++++++++++++ go/internal/codeshape/typescript/tsmetrics.go | 2 +- .../codeshape/typescript/tssuppressions.go | 5 +++-- go/internal/codeshape/typescript/unmeasured.go | 2 +- 9 files changed, 30 insertions(+), 10 deletions(-) diff --git a/go/internal/codeshape/cli/unmeasured.go b/go/internal/codeshape/cli/unmeasured.go index 0680a5b..9459716 100644 --- a/go/internal/codeshape/cli/unmeasured.go +++ b/go/internal/codeshape/cli/unmeasured.go @@ -18,7 +18,7 @@ func handleTSSkips(output io.Writer, skips []codeshape.Skip, cfg codeshape.Confi for _, skip := range skips { if skip.Unmeasured != nil { paths = append(paths, skip.Path) - if skip.Unmeasured.Ignored { + 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"}) } diff --git a/go/internal/codeshape/cli/unmeasured_test.go b/go/internal/codeshape/cli/unmeasured_test.go index 003f5b6..54c9b74 100644 --- a/go/internal/codeshape/cli/unmeasured_test.go +++ b/go/internal/codeshape/cli/unmeasured_test.go @@ -15,7 +15,7 @@ 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}, Ignored: false}}, + Values: map[codeshape.Metric]int{codeshape.MetricTSLines: 70}, BiomeParseFailed: false}}, } var output strings.Builder findings, paths, err := handleTSSkips(&output, skips, testConfig()) diff --git a/go/internal/codeshape/functions.go b/go/internal/codeshape/functions.go index 6f5cbb5..ca7df3d 100644 --- a/go/internal/codeshape/functions.go +++ b/go/internal/codeshape/functions.go @@ -18,8 +18,8 @@ type Skip struct { // UnmeasuredFunctions holds diagnostic maxima for an unparseable source file. type UnmeasuredFunctions struct { - Values map[Metric]int - Ignored bool + Values map[Metric]int + BiomeParseFailed bool } var errFunctionID = errors.New("invalid function allowlist id") diff --git a/go/internal/codeshape/golang/gometrics.go b/go/internal/codeshape/golang/gometrics.go index d1db4de..77065cc 100644 --- a/go/internal/codeshape/golang/gometrics.go +++ b/go/internal/codeshape/golang/gometrics.go @@ -210,7 +210,7 @@ func parseGoSources(root, module string, paths []string, cfg codeshape.Config) ( if !matches { var unmeasured *codeshape.UnmeasuredFunctions if len(goFunctions(file, set)) > 0 { - unmeasured = &codeshape.UnmeasuredFunctions{Values: nil, Ignored: true} + unmeasured = &codeshape.UnmeasuredFunctions{Values: nil, BiomeParseFailed: false} } skips = append(skips, codeshape.Skip{Path: path, Reason: "not in default Go build", Unmeasured: unmeasured}) continue diff --git a/go/internal/codeshape/typescript/biome_config.go b/go/internal/codeshape/typescript/biome_config.go index a674cb5..a185c90 100644 --- a/go/internal/codeshape/typescript/biome_config.go +++ b/go/internal/codeshape/typescript/biome_config.go @@ -18,6 +18,10 @@ import ( "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, ) { @@ -47,7 +51,7 @@ func writeGateBiomeConfig(dir string, limits codeshape.FunctionLimits, parser js } func rootBiomeParser(root string) (json.RawMessage, error) { - for _, name := range []string{"biome.json", "biome.jsonc", ".biome.json", ".biome.jsonc"} { + for _, name := range biomeConfigNames() { config, err := readBiomeConfig(root, name) if errors.Is(err, fs.ErrNotExist) { continue diff --git a/go/internal/codeshape/typescript/drift_test.go b/go/internal/codeshape/typescript/drift_test.go index 7cdffd0..83fbd07 100644 --- a/go/internal/codeshape/typescript/drift_test.go +++ b/go/internal/codeshape/typescript/drift_test.go @@ -75,3 +75,18 @@ func TestTrackedBiomeDrift(t *testing.T) { 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/tsmetrics.go b/go/internal/codeshape/typescript/tsmetrics.go index 11ced26..bcaf08c 100644 --- a/go/internal/codeshape/typescript/tsmetrics.go +++ b/go/internal/codeshape/typescript/tsmetrics.go @@ -101,7 +101,7 @@ func TSMetrics(ctx context.Context, root string, cfg codeshape.Config) ( 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{}, Ignored: false}}) + Unmeasured: &codeshape.UnmeasuredFunctions{Values: map[codeshape.Metric]int{}, BiomeParseFailed: false}}) delete(sources, path) continue } diff --git a/go/internal/codeshape/typescript/tssuppressions.go b/go/internal/codeshape/typescript/tssuppressions.go index efbcaef..0684ac7 100644 --- a/go/internal/codeshape/typescript/tssuppressions.go +++ b/go/internal/codeshape/typescript/tssuppressions.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "path/filepath" + "slices" "strconv" "strings" @@ -45,14 +46,14 @@ func trackedBiomeDrift(root string, paths []string, cfg codeshape.Config) ([]cod findings := []codeshape.Finding{} for _, path := range paths { base := filepath.Base(path) - if base != "biome.json" && base != "biome.jsonc" { + if !slices.Contains(biomeConfigNames(), base) { continue } config, err := readBiomeConfig(root, path) if err != nil { return nil, err } - require := path == "biome.json" || path == "biome.jsonc" + require := path == base findings = append(findings, biomeDriftFindings(path, config, cfg, require)...) findings = append(findings, biomeOverrideDrift(path, config, cfg)...) } diff --git a/go/internal/codeshape/typescript/unmeasured.go b/go/internal/codeshape/typescript/unmeasured.go index 5edc06b..fbed2a2 100644 --- a/go/internal/codeshape/typescript/unmeasured.go +++ b/go/internal/codeshape/typescript/unmeasured.go @@ -71,7 +71,7 @@ func biomeParseSkips(report biomeReport, sources map[string][]byte) ([]codeshape } delete(sources, path) skips = append(skips, codeshape.Skip{Path: path, Reason: "Biome parse error: " + diagnostic.Message, - Unmeasured: &codeshape.UnmeasuredFunctions{Values: map[codeshape.Metric]int{}, Ignored: true}}) + Unmeasured: &codeshape.UnmeasuredFunctions{Values: map[codeshape.Metric]int{}, BiomeParseFailed: true}}) } return skips, nil } From 44546883e5644ac1b9f935074d1cc356f3e32602 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 10 Nov 2026 14:30:25 -0500 Subject: [PATCH 54/54] wip(go): T2 report Biome count mismatch (RIG-8435) --- go/internal/codeshape/cli/failclosed_test.go | 3 +++ go/internal/codeshape/typescript/tsmetrics.go | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/go/internal/codeshape/cli/failclosed_test.go b/go/internal/codeshape/cli/failclosed_test.go index 26d04a9..1fd1534 100644 --- a/go/internal/codeshape/cli/failclosed_test.go +++ b/go/internal/codeshape/cli/failclosed_test.go @@ -39,6 +39,9 @@ func TestBiomeFailClosed(t *testing.T) { 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) + } }) } } diff --git a/go/internal/codeshape/typescript/tsmetrics.go b/go/internal/codeshape/typescript/tsmetrics.go index bcaf08c..f4c48c0 100644 --- a/go/internal/codeshape/typescript/tsmetrics.go +++ b/go/internal/codeshape/typescript/tsmetrics.go @@ -173,8 +173,10 @@ func runGateBiome(ctx context.Context, binary, root, dir string, paths []string, return biomeReport{}, fmt.Errorf("%w: %d were not printed", errBiomeTruncated, report.Summary.DiagnosticsNotPrinted) } - if report.Summary.Changed+report.Summary.Unchanged != len(paths) { - return biomeReport{}, fmt.Errorf("requested paths %v: %w", paths, errBiomeUnprocessed) + 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 }