From a66ee80c2b2502e741e27c0050feb3a825020844 Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Fri, 17 Jul 2026 14:36:05 -0400 Subject: [PATCH 1/6] Expand cross-language design boundary coverage --- internal/codeguard/checks/design/design.go | 2 +- .../codeguard/checks/design/design_cpp.go | 379 ++++++++- internal/codeguard/checks/design/design_go.go | 15 + .../checks/design/design_graph_cpp.go | 23 +- .../checks/design/design_graph_typescript.go | 25 +- .../design_graph_typescript_resolver.go | 763 ++++++++++++++++++ .../codeguard/checks/design/design_python.go | 3 +- .../checks/design/design_python_structure.go | 271 +++++++ .../codeguard/checks/design/design_rust.go | 291 +++++++ .../codeguard/checks/quality/quality_go.go | 4 - .../checks/support/cpp_dependency_graph.go | 29 +- internal/codeguard/rules/catalog_design.go | 68 ++ .../design_architecture_boundaries_test.go | 82 ++ tests/checks/design_cpp_graph_test.go | 59 ++ .../design_encapsulation_boundaries_test.go | 44 + tests/checks/design_graph_languages_test.go | 95 +++ tests/checks/design_graph_policy_test.go | 56 ++ tests/checks/design_python_test.go | 68 ++ tests/checks/design_rust_test.go | 144 ++++ tests/checks/design_test.go | 23 + 20 files changed, 2409 insertions(+), 35 deletions(-) create mode 100644 internal/codeguard/checks/design/design_graph_typescript_resolver.go create mode 100644 internal/codeguard/checks/design/design_python_structure.go create mode 100644 internal/codeguard/checks/design/design_rust.go create mode 100644 tests/checks/design_rust_test.go diff --git a/internal/codeguard/checks/design/design.go b/internal/codeguard/checks/design/design.go index b5a422d..3353ca4 100644 --- a/internal/codeguard/checks/design/design.go +++ b/internal/codeguard/checks/design/design.go @@ -37,7 +37,7 @@ func targetLanguageFindings(ctx context.Context, env support.Context, target cor graph := buildPythonImportGraph(env, target) return pythonTargetFindings(env, target, graph), moduleGraphFromPython(graph) case "rust", "rs": - return nil, buildRustImportGraph(env, target) + return rustTargetFindings(env, target), buildRustImportGraph(env, target) case "java": return nil, buildJavaImportGraph(env, target) case "c++", "cpp", "cxx", "cc": diff --git a/internal/codeguard/checks/design/design_cpp.go b/internal/codeguard/checks/design/design_cpp.go index 24df993..10dd9a4 100644 --- a/internal/codeguard/checks/design/design_cpp.go +++ b/internal/codeguard/checks/design/design_cpp.go @@ -3,12 +3,20 @@ package design import ( "fmt" "path/filepath" + "regexp" + "sort" "strings" "github.com/devr-tools/codeguard/internal/codeguard/checks/support" "github.com/devr-tools/codeguard/internal/codeguard/core" ) +var ( + cppTypeDeclarationPattern = regexp.MustCompile(`^\s*(?:template[ \t]*<[^>\n]+>[ \t]*)*(class|struct)[ \t]+([A-Za-z_]\w*)\b`) + cppNamespaceDeclarationPattern = regexp.MustCompile(`^\s*(?:export[ \t]+)?namespace[ \t]+([A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)\b`) + cppAccessSpecifierPattern = regexp.MustCompile(`^\s*(public|protected|private)\s*:\s*$`) +) + func cppTargetFindings(env support.Context, target core.TargetConfig) []core.Finding { return support.ScanCPPFiles(env, target, "design", func(file string, data []byte) []core.Finding { return cppDesignFindingsForFile(env, file, data) @@ -18,26 +26,55 @@ func cppTargetFindings(env support.Context, target core.TargetConfig) []core.Fin func cppDesignFindingsForFile(env support.Context, file string, data []byte) []core.Finding { findings := cppGenericModuleNameFindings(env, file) parsed := support.ParseCLike(string(data), support.CLikeCPP) - counts := make(map[string]int) - for _, function := range parsed.Functions { - separator := strings.LastIndex(function.Name, "::") - if separator <= 0 { - continue + findings = append(findings, cppDeclFindings(env, file, parsed)...) + surfaces := cppTypeSurfaces(parsed.Source) + cppRecordOutOfLineMethods(surfaces, parsed.Functions) + for _, surface := range cppSortedTypeSurfaces(surfaces) { + if count := len(surface.methods); count > env.Config.Checks.DesignRules.MaxMethodsPerType { + findings = append(findings, env.NewFinding(support.FindingInput{ + RuleID: "design.cpp.max-methods-per-type", Level: "warn", Path: file, Line: surface.line, Column: 1, + Message: fmt.Sprintf("C++ type %s has %d methods in this file; max is %d", surface.name, count, env.Config.Checks.DesignRules.MaxMethodsPerType), + })) } - counts[function.Name[:separator]]++ - } - for typeName, count := range counts { - if count <= env.Config.Checks.DesignRules.MaxMethodsPerType { + if !isCPPContractPath(file) || len(surface.publicMethods) <= env.Config.Checks.DesignRules.MaxInterfaceMethods { continue } findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "design.cpp.max-methods-per-type", Level: "warn", Path: file, Line: 1, Column: 1, - Message: fmt.Sprintf("C++ type %s has %d out-of-line methods in this file; max is %d", typeName, count, env.Config.Checks.DesignRules.MaxMethodsPerType), + RuleID: "design.cpp.max-interface-methods", Level: "warn", Path: file, Line: surface.line, Column: 1, + Message: fmt.Sprintf("C++ type %s exposes %d public methods in this contract; max is %d", surface.name, len(surface.publicMethods), env.Config.Checks.DesignRules.MaxInterfaceMethods), })) } return findings } +func cppDeclFindings(env support.Context, file string, parsed *support.ParsedFile) []core.Finding { + count := cppDeclarationCount(parsed) + if count <= env.Config.Checks.DesignRules.MaxDeclsPerFile { + return nil + } + return []core.Finding{env.NewFinding(support.FindingInput{ + RuleID: "design.cpp.max-decls-per-file", + Level: "warn", + Path: file, + Line: 1, + Column: 1, + Message: fmt.Sprintf("C++ file has %d top-level declarations; max is %d", count, env.Config.Checks.DesignRules.MaxDeclsPerFile), + })} +} + +func cppDeclarationCount(parsed *support.ParsedFile) int { + if parsed == nil { + return 0 + } + count := len(parsed.Functions) + for _, line := range strings.Split(parsed.Masked, "\n") { + if cppTypeDeclarationPattern.MatchString(line) { + count++ + } + } + return count +} + func cppGenericModuleNameFindings(env support.Context, file string) []core.Finding { name := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)) for _, forbidden := range env.Config.Checks.DesignRules.ForbiddenPackageNames { @@ -50,3 +87,323 @@ func cppGenericModuleNameFindings(env support.Context, file string) []core.Findi } return nil } + +type cppTypeSurface struct { + name string + typeName string + line int + methods map[string]struct{} + publicMethods map[string]struct{} +} + +type cppTypeBlock struct { + surface *cppTypeSurface + waiting bool + bodyDepth int + defaultAccess string + access string +} + +type cppNamespaceBlock struct { + name string + waiting bool + bodyDepth int +} + +func cppTypeSurfaces(source string) map[string]*cppTypeSurface { + source = strings.ReplaceAll(source, "\r\n", "\n") + lines := strings.Split(support.MaskCLikeSource(source, support.CLikeCPP), "\n") + surfaces := make(map[string]*cppTypeSurface) + namespaces := make([]*cppNamespaceBlock, 0) + types := make([]*cppTypeBlock, 0) + depth := 0 + + for idx, line := range lines { + lineNo := idx + 1 + namespaces = append(namespaces, newCPPNamespaceBlock(line)) + if block := newCPPTypeBlock(line, lineNo, cppNamespacePrefix(namespaces), surfaces); block != nil { + types = append(types, block) + } + countCPPTypeMembers(types, depth, line) + depth += braceDelta(line) + openCPPNamespaceBlocks(namespaces, depth, line) + openCPPTypeBlocks(types, depth, line) + countCPPTypeMembers(types, depth, line) + namespaces = pruneCPPNamespaceBlocks(namespaces, depth, line) + types = pruneCPPTypeBlocks(types, depth, line) + } + + return surfaces +} + +func newCPPNamespaceBlock(line string) *cppNamespaceBlock { + match := cppNamespaceDeclarationPattern.FindStringSubmatch(line) + if len(match) != 2 { + return nil + } + return &cppNamespaceBlock{name: match[1], waiting: true} +} + +func newCPPTypeBlock(line string, lineNo int, namespacePrefix string, surfaces map[string]*cppTypeSurface) *cppTypeBlock { + match := cppTypeDeclarationPattern.FindStringSubmatch(line) + if len(match) != 3 { + return nil + } + defaultAccess := "private" + if match[1] == "struct" { + defaultAccess = "public" + } + typeName := match[2] + qualifiedName := typeName + if namespacePrefix != "" { + qualifiedName = namespacePrefix + "::" + qualifiedName + } + surface := surfaces[qualifiedName] + if surface == nil { + surface = &cppTypeSurface{ + name: qualifiedName, + typeName: typeName, + line: lineNo, + methods: make(map[string]struct{}), + publicMethods: make(map[string]struct{}), + } + surfaces[qualifiedName] = surface + } else if surface.line <= 0 { + surface.line = lineNo + } + return &cppTypeBlock{ + surface: surface, + waiting: true, + defaultAccess: defaultAccess, + access: defaultAccess, + } +} + +func cppNamespacePrefix(namespaces []*cppNamespaceBlock) string { + parts := make([]string, 0, len(namespaces)) + for _, block := range namespaces { + if block == nil || block.waiting || block.name == "" { + continue + } + parts = append(parts, block.name) + } + return strings.Join(parts, "::") +} + +func countCPPTypeMembers(types []*cppTypeBlock, depth int, line string) { + for _, block := range types { + if block == nil || block.waiting || depth != block.bodyDepth { + continue + } + if match := cppAccessSpecifierPattern.FindStringSubmatch(strings.TrimSpace(line)); len(match) == 2 { + block.access = match[1] + continue + } + key, ok := cppMethodKey(line, block.surface.typeName) + if !ok { + continue + } + block.surface.methods[key] = struct{}{} + if block.access == "public" { + block.surface.publicMethods[key] = struct{}{} + } + } +} + +func cppMethodKey(line string, typeName string) (string, bool) { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "using ") || + strings.HasPrefix(trimmed, "typedef ") || strings.HasPrefix(trimmed, "friend ") || + strings.HasPrefix(trimmed, "static_assert") || strings.HasPrefix(trimmed, "return ") { + return "", false + } + open := strings.Index(trimmed, "(") + if open < 0 { + return "", false + } + head := strings.TrimSpace(trimmed[:open]) + if head == "" || strings.Contains(head, "=") { + return "", false + } + name := cppTrailingIdentifier(head) + if name == "" || cppNonMethodName(name) { + return "", false + } + if name != typeName && name != "~"+typeName && strings.Contains(name, "::") { + name = name[strings.LastIndex(name, "::")+2:] + } + close := cppMatchingParen(trimmed, open) + if close < 0 { + return "", false + } + trailer := strings.TrimSpace(trimmed[close+1:]) + if strings.HasPrefix(trailer, "->") { + return "", false + } + params := cppSquashWhitespace(trimmed[open+1 : close]) + return name + "(" + params + ")", true +} + +func cppTrailingIdentifier(head string) string { + head = strings.TrimRight(head, " \t*&") + if head == "" { + return "" + } + start := len(head) + for start > 0 { + ch := head[start-1] + if ch == '_' || ch == '~' || ch == ':' || + (ch >= '0' && ch <= '9') || + (ch >= 'A' && ch <= 'Z') || + (ch >= 'a' && ch <= 'z') { + start-- + continue + } + break + } + return head[start:] +} + +func cppNonMethodName(name string) bool { + switch name { + case "if", "for", "while", "switch", "catch", "return", "requires": + return true + default: + return name == "" + } +} + +func cppMatchingParen(line string, open int) int { + depth := 0 + for i := open; i < len(line); i++ { + switch line[i] { + case '(': + depth++ + case ')': + depth-- + if depth == 0 { + return i + } + } + } + return -1 +} + +func openCPPNamespaceBlocks(blocks []*cppNamespaceBlock, depth int, line string) { + if !strings.Contains(line, "{") { + return + } + for _, block := range blocks { + if block == nil || !block.waiting { + continue + } + block.waiting = false + block.bodyDepth = depth + } +} + +func openCPPTypeBlocks(blocks []*cppTypeBlock, depth int, line string) { + if !strings.Contains(line, "{") { + return + } + for _, block := range blocks { + if block == nil || !block.waiting { + continue + } + block.waiting = false + block.bodyDepth = depth + block.access = block.defaultAccess + } +} + +func pruneCPPNamespaceBlocks(blocks []*cppNamespaceBlock, depth int, line string) []*cppNamespaceBlock { + kept := blocks[:0] + for _, block := range blocks { + if block == nil { + continue + } + if block.waiting && strings.Contains(line, ";") && !strings.Contains(line, "{") { + continue + } + if !block.waiting && depth < block.bodyDepth { + continue + } + kept = append(kept, block) + } + return kept +} + +func pruneCPPTypeBlocks(blocks []*cppTypeBlock, depth int, line string) []*cppTypeBlock { + kept := blocks[:0] + for _, block := range blocks { + if block == nil { + continue + } + if block.waiting && strings.Contains(line, ";") && !strings.Contains(line, "{") { + continue + } + if !block.waiting && depth < block.bodyDepth { + continue + } + kept = append(kept, block) + } + return kept +} + +func cppRecordOutOfLineMethods(surfaces map[string]*cppTypeSurface, functions []*support.ParsedFunction) { + for _, function := range functions { + if function == nil { + continue + } + separator := strings.LastIndex(function.Name, "::") + if separator <= 0 { + continue + } + typeName := function.Name[:separator] + methodName := function.Name[separator+2:] + surface := surfaces[typeName] + if surface == nil { + surface = &cppTypeSurface{ + name: typeName, + typeName: typeName[strings.LastIndex(typeName, "::")+2:], + line: function.StartLine, + methods: make(map[string]struct{}), + publicMethods: make(map[string]struct{}), + } + surfaces[typeName] = surface + } + surface.methods[methodName+"("+cppSquashWhitespace(function.Signature)+")"] = struct{}{} + } +} + +func cppSortedTypeSurfaces(surfaces map[string]*cppTypeSurface) []*cppTypeSurface { + result := make([]*cppTypeSurface, 0, len(surfaces)) + for _, surface := range surfaces { + result = append(result, surface) + } + sort.Slice(result, func(i, j int) bool { + if result[i].line != result[j].line { + return result[i].line < result[j].line + } + return result[i].name < result[j].name + }) + return result +} + +func isCPPContractPath(file string) bool { + rawExt := filepath.Ext(file) + if rawExt == ".C" { + return false + } + switch strings.ToLower(rawExt) { + case ".h", ".hh", ".hpp", ".hxx", ".h++", ".ipp", ".tpp", ".inl", ".txx", ".ixx", + ".cppm", ".cxxm", ".ccm", ".c++m", ".mpp", ".mxx", ".inc": + return true + default: + return false + } +} + +func cppSquashWhitespace(text string) string { + return strings.TrimSpace(strings.Join(strings.Fields(text), " ")) +} diff --git a/internal/codeguard/checks/design/design_go.go b/internal/codeguard/checks/design/design_go.go index 8cd7c09..6e250d3 100644 --- a/internal/codeguard/checks/design/design_go.go +++ b/internal/codeguard/checks/design/design_go.go @@ -27,6 +27,7 @@ func goFindingsForFile(env support.Context, file string, data []byte) []core.Fin findings := make([]core.Finding, 0) //nolint:prealloc // count not known up front; each rule appends a variable number findings = append(findings, forbiddenPackageFindings(env, file, parsed.Name.Name)...) + findings = append(findings, declFindings(env, file, parsed)...) methodCounts, interfaceFindings := typeFindings(env, file, fset, parsed) findings = append(findings, interfaceFindings...) findings = append(findings, methodFindings(env, file, methodCounts)...) @@ -34,6 +35,20 @@ func goFindingsForFile(env support.Context, file string, data []byte) []core.Fin return findings } +func declFindings(env support.Context, file string, parsed *ast.File) []core.Finding { + if len(parsed.Decls) <= env.Config.Checks.DesignRules.MaxDeclsPerFile { + return nil + } + return []core.Finding{env.NewFinding(support.FindingInput{ + RuleID: "design.max-decls-per-file", + Level: "warn", + Path: file, + Line: 1, + Column: 1, + Message: fmt.Sprintf("file has %d declarations; max is %d", len(parsed.Decls), env.Config.Checks.DesignRules.MaxDeclsPerFile), + })} +} + func forbiddenPackageFindings(env support.Context, file string, pkgName string) []core.Finding { for _, forbidden := range env.Config.Checks.DesignRules.ForbiddenPackageNames { if pkgName == forbidden { diff --git a/internal/codeguard/checks/design/design_graph_cpp.go b/internal/codeguard/checks/design/design_graph_cpp.go index 53754a1..d070790 100644 --- a/internal/codeguard/checks/design/design_graph_cpp.go +++ b/internal/codeguard/checks/design/design_graph_cpp.go @@ -9,8 +9,8 @@ import ( ) var ( - cppBoundaryModuleDeclarationPattern = regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?module[ \t]+([A-Za-z_]\w*(?::[A-Za-z_]\w*)*)[ \t]*;`) - cppBoundaryModuleImportPattern = regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?import[ \t]+([A-Za-z_]\w*(?::[A-Za-z_]\w*)*)[ \t]*;`) + cppBoundaryModuleDeclarationPattern = regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?module[ \t]+([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?::[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)?)[ \t]*;`) + cppBoundaryModuleImportPattern = regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?import[ \t]+((?:[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?::[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)?)|(?::[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*))[ \t]*;`) ) type cppBoundaryNamedImport struct { @@ -35,6 +35,7 @@ func buildCPPImportGraph(env support.Context, target core.TargetConfig) *moduleG } } namedModules := make(map[string]string) + declaredModules := make(map[string]string) namedImports := make([]cppBoundaryNamedImport, 0) env.VisitTargetFiles(target, func(rel string) bool { return support.IsCPPPath(rel, true) }, func(rel string, data []byte) { parsed := support.ParseCLike(string(data), support.CLikeCPP) @@ -46,6 +47,7 @@ func buildCPPImportGraph(env support.Context, target core.TargetConfig) *moduleG graph.addImport(rel, resolved, rel, imported.Module, imported.Line) } if match := cppBoundaryModuleDeclarationPattern.FindStringSubmatch(parsed.Masked); len(match) == 2 { + declaredModules[rel] = match[1] namedModules[match[1]] = rel } for _, match := range cppBoundaryModuleImportPattern.FindAllStringSubmatchIndex(parsed.Masked, -1) { @@ -54,11 +56,26 @@ func buildCPPImportGraph(env support.Context, target core.TargetConfig) *moduleG } }) for _, imported := range namedImports { - graph.addImport(imported.from, namedModules[imported.specifier], imported.from, imported.specifier, imported.line) + resolvedSpecifier := supportQualifyCPPModuleImport(imported.specifier, declaredModules[imported.from]) + graph.addImport(imported.from, namedModules[resolvedSpecifier], imported.from, imported.specifier, imported.line) } return graph } +func supportQualifyCPPModuleImport(specifier string, declaredModule string) string { + if !strings.HasPrefix(specifier, ":") { + return specifier + } + primary := declaredModule + if cut := strings.IndexByte(primary, ':'); cut >= 0 { + primary = primary[:cut] + } + if primary == "" { + return "" + } + return primary + specifier +} + func cppGraphEdgeAtLine(graph *moduleGraph, from string, line int) string { node := graph.modules[from] if node == nil { diff --git a/internal/codeguard/checks/design/design_graph_typescript.go b/internal/codeguard/checks/design/design_graph_typescript.go index 8aea3b5..ee25f80 100644 --- a/internal/codeguard/checks/design/design_graph_typescript.go +++ b/internal/codeguard/checks/design/design_graph_typescript.go @@ -43,8 +43,13 @@ func buildTypeScriptImportGraph(env support.Context, target core.TargetConfig) * pending = append(pending, pendingGraphEdge{from: module, to: imp.specifier, file: rel, line: imp.line}) } }) + resolver := newTypeScriptImportResolver(graph) + env.VisitTargetFiles(target, isTypeScriptResolverMetadataFile, func(rel string, data []byte) { + resolver.indexMetadata(rel, data) + }) + resolver.finalizeConfigs() for _, edge := range pending { - resolved := resolveTypeScriptImport(graph, edge.from, edge.to) + resolved := resolveTypeScriptImport(resolver, edge.from, edge.to) graph.addImport(edge.from, resolved, edge.file, edge.to, edge.line) } return graph @@ -78,18 +83,12 @@ func typeScriptModuleKey(rel string) string { return rel } -// resolveTypeScriptImport resolves a relative import specifier to a known -// module key; external package imports return an empty string. -func resolveTypeScriptImport(graph *moduleGraph, fromModule string, specifier string) string { - if !strings.HasPrefix(specifier, "./") && !strings.HasPrefix(specifier, "../") && specifier != "." && specifier != ".." { +// resolveTypeScriptImport resolves a script import specifier to a known local +// module key using relative imports, tsconfig aliases/baseUrl, and local +// workspace package manifests. External package imports return an empty string. +func resolveTypeScriptImport(resolver *typeScriptImportResolver, fromModule string, specifier string) string { + if resolver == nil { return "" } - joined := path.Clean(path.Join(path.Dir(fromModule), specifier)) - joined = typeScriptModuleKey(joined) - for _, candidate := range []string{joined, joined + "/index"} { - if _, ok := graph.modules[candidate]; ok { - return candidate - } - } - return "" + return resolver.resolve(fromModule, specifier) } diff --git a/internal/codeguard/checks/design/design_graph_typescript_resolver.go b/internal/codeguard/checks/design/design_graph_typescript_resolver.go new file mode 100644 index 0000000..8fe1892 --- /dev/null +++ b/internal/codeguard/checks/design/design_graph_typescript_resolver.go @@ -0,0 +1,763 @@ +package design + +import ( + "encoding/json" + "path" + "sort" + "strings" +) + +type typeScriptImportResolver struct { + graph *moduleGraph + configs []typeScriptGraphConfig + packages map[string]typeScriptWorkspacePackage + tsconfigs map[string]typeScriptConfigDocument + tsconfigPrimary map[string]string +} + +type typeScriptGraphConfig struct { + dir string + baseDir string + paths []typeScriptPathAlias +} + +type typeScriptPathAlias struct { + pattern string + targets []string +} + +type typeScriptWorkspacePackage struct { + name string + dir string + main string + module string + source string + types string + exports map[string][]string + imports map[string][]string +} + +type typeScriptPackageManifest struct { + Name string `json:"name"` + Main string `json:"main"` + Module string `json:"module"` + Source string `json:"source"` + Types string `json:"types"` + Exports json.RawMessage `json:"exports"` + Imports json.RawMessage `json:"imports"` +} + +type typeScriptConfigDocument struct { + Extends string `json:"extends"` + CompilerOptions typeScriptCompilerOptions `json:"compilerOptions"` +} + +type typeScriptCompilerOptions struct { + BaseURL string `json:"baseUrl"` + Paths map[string][]string `json:"paths"` +} + +func newTypeScriptImportResolver(graph *moduleGraph) *typeScriptImportResolver { + return &typeScriptImportResolver{ + graph: graph, + packages: make(map[string]typeScriptWorkspacePackage), + tsconfigs: make(map[string]typeScriptConfigDocument), + tsconfigPrimary: make(map[string]string), + } +} + +func isTypeScriptResolverMetadataFile(rel string) bool { + base := path.Base(rel) + if base == "package.json" { + return true + } + return strings.HasSuffix(base, ".json") +} + +func (resolver *typeScriptImportResolver) indexMetadata(rel string, data []byte) { + base := path.Base(rel) + switch { + case base == "package.json": + resolver.indexPackageManifest(path.Dir(rel), data) + case strings.HasSuffix(base, ".json"): + resolver.indexTSConfig(rel, data) + } +} + +func (resolver *typeScriptImportResolver) indexPackageManifest(dir string, data []byte) { + var manifest typeScriptPackageManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return + } + name := strings.TrimSpace(manifest.Name) + if name == "" { + return + } + pkg := typeScriptWorkspacePackage{ + name: name, + dir: normalizeTypeScriptPath(dir), + main: strings.TrimSpace(manifest.Main), + module: strings.TrimSpace(manifest.Module), + source: strings.TrimSpace(manifest.Source), + types: strings.TrimSpace(manifest.Types), + exports: parseTypeScriptPackageExports(manifest.Exports), + imports: parseTypeScriptPackageImports(manifest.Imports), + } + resolver.packages[name] = pkg +} + +func (resolver *typeScriptImportResolver) indexTSConfig(rel string, data []byte) { + var doc typeScriptConfigDocument + normalized, ok := normalizeJSONC(data) + if !ok || json.Unmarshal(normalized, &doc) != nil { + return + } + rel = normalizeTypeScriptPath(rel) + resolver.tsconfigs[rel] = doc + dir := normalizeTypeScriptPath(path.Dir(rel)) + primary, ok := resolver.tsconfigPrimary[dir] + if !ok || path.Base(rel) == "tsconfig.json" || path.Base(primary) != "tsconfig.json" { + resolver.tsconfigPrimary[dir] = rel + } +} + +func (resolver *typeScriptImportResolver) finalizeConfigs() { + resolver.configs = resolver.configs[:0] + dirs := make([]string, 0, len(resolver.tsconfigPrimary)) + for dir := range resolver.tsconfigPrimary { + dirs = append(dirs, dir) + } + sort.Slice(dirs, func(i, j int) bool { + return len(dirs[i]) > len(dirs[j]) + }) + cache := make(map[string]typeScriptGraphConfig, len(dirs)) + for _, dir := range dirs { + cfg, ok := resolver.effectiveTSConfig(resolver.tsconfigPrimary[dir], cache, make(map[string]bool)) + if !ok { + continue + } + resolver.configs = append(resolver.configs, cfg) + } +} + +func (resolver *typeScriptImportResolver) effectiveTSConfig(rel string, cache map[string]typeScriptGraphConfig, seen map[string]bool) (typeScriptGraphConfig, bool) { + if cfg, ok := cache[rel]; ok { + return cfg, true + } + if seen[rel] { + return typeScriptGraphConfig{}, false + } + doc, ok := resolver.tsconfigs[rel] + if !ok { + return typeScriptGraphConfig{}, false + } + seen[rel] = true + dir := normalizeTypeScriptPath(path.Dir(rel)) + effective := typeScriptCompilerOptions{} + if parentRel, ok := resolver.resolveTSConfigExtends(dir, doc.Extends); ok { + if parent, ok := resolver.effectiveTSConfig(parentRel, cache, seen); ok { + effective.BaseURL = parent.baseDir + effective.Paths = aliasesToPathMap(parent.paths) + } + } + effective = mergeTypeScriptCompilerOptions(dir, effective, doc.CompilerOptions) + cfg := typeScriptGraphConfig{ + dir: dir, + baseDir: effective.BaseURL, + paths: sortedTypeScriptAliases(effective.Paths), + } + if cfg.baseDir == "" { + cfg.baseDir = dir + } + cache[rel] = cfg + return cfg, true +} + +func aliasesToPathMap(aliases []typeScriptPathAlias) map[string][]string { + if len(aliases) == 0 { + return nil + } + out := make(map[string][]string, len(aliases)) + for _, alias := range aliases { + out[alias.pattern] = append([]string(nil), alias.targets...) + } + return out +} + +func mergeTypeScriptCompilerOptions(dir string, base typeScriptCompilerOptions, override typeScriptCompilerOptions) typeScriptCompilerOptions { + merged := typeScriptCompilerOptions{ + BaseURL: base.BaseURL, + Paths: cloneTypeScriptPathMap(base.Paths), + } + if strings.TrimSpace(override.BaseURL) != "" { + merged.BaseURL = normalizeTypeScriptPath(path.Join(dir, override.BaseURL)) + } + if len(override.Paths) > 0 { + merged.Paths = cloneTypeScriptPathMap(override.Paths) + } + if merged.BaseURL == "" { + merged.BaseURL = dir + } + return merged +} + +func cloneTypeScriptPathMap(paths map[string][]string) map[string][]string { + if len(paths) == 0 { + return nil + } + out := make(map[string][]string, len(paths)) + for key, values := range paths { + out[key] = append([]string(nil), values...) + } + return out +} + +func (resolver *typeScriptImportResolver) resolveTSConfigExtends(dir string, value string) (string, bool) { + value = strings.TrimSpace(value) + if value == "" { + return "", false + } + candidates := make([]string, 0, 3) + if strings.HasPrefix(value, ".") || strings.HasPrefix(value, "/") { + candidates = append(candidates, normalizeTypeScriptPath(path.Join(dir, value))) + } else { + candidates = append(candidates, normalizeTypeScriptPath(path.Join(dir, value))) + candidates = append(candidates, resolver.workspaceTSConfigCandidates(value)...) + } + if !strings.HasSuffix(value, ".json") { + candidates = append(candidates, normalizeTypeScriptPath(path.Join(dir, value+".json"))) + if !strings.HasPrefix(value, ".") && !strings.HasPrefix(value, "/") { + for _, candidate := range resolver.workspaceTSConfigCandidates(value + ".json") { + candidates = append(candidates, candidate) + } + } + } + candidates = append(candidates, normalizeTypeScriptPath(path.Join(dir, value, "tsconfig.json"))) + if !strings.HasPrefix(value, ".") && !strings.HasPrefix(value, "/") { + for _, candidate := range resolver.workspaceTSConfigCandidates(path.Join(value, "tsconfig.json")) { + candidates = append(candidates, candidate) + } + } + for _, candidate := range candidates { + if _, ok := resolver.tsconfigs[candidate]; ok { + return candidate, true + } + } + return "", false +} + +func (resolver *typeScriptImportResolver) workspaceTSConfigCandidates(specifier string) []string { + root := typeScriptPackageRoot(specifier) + pkg, ok := resolver.packages[root] + if !ok { + return nil + } + subpath := strings.TrimPrefix(specifier, root) + subpath = strings.TrimPrefix(subpath, "/") + candidates := make([]string, 0, 3) + if subpath == "" { + candidates = append(candidates, normalizeTypeScriptPath(path.Join(pkg.dir, "tsconfig.json"))) + } else { + candidates = append(candidates, normalizeTypeScriptPath(path.Join(pkg.dir, subpath))) + } + return candidates +} + +func sortedTypeScriptAliases(paths map[string][]string) []typeScriptPathAlias { + aliases := make([]typeScriptPathAlias, 0, len(paths)) + for pattern, targets := range paths { + cleanTargets := make([]string, 0, len(targets)) + for _, target := range targets { + target = strings.TrimSpace(target) + if target == "" { + continue + } + cleanTargets = append(cleanTargets, target) + } + if len(cleanTargets) == 0 { + continue + } + aliases = append(aliases, typeScriptPathAlias{ + pattern: strings.TrimSpace(pattern), + targets: cleanTargets, + }) + } + sort.Slice(aliases, func(i, j int) bool { + if len(aliases[i].pattern) != len(aliases[j].pattern) { + return len(aliases[i].pattern) > len(aliases[j].pattern) + } + return aliases[i].pattern < aliases[j].pattern + }) + return aliases +} + +func (resolver *typeScriptImportResolver) resolve(fromModule string, specifier string) string { + if strings.HasPrefix(specifier, "./") || strings.HasPrefix(specifier, "../") || specifier == "." || specifier == ".." { + return resolver.resolveRelative(fromModule, specifier) + } + if resolved := resolver.resolvePackageImport(fromModule, specifier); resolved != "" { + return resolved + } + if resolved := resolver.resolveTSConfigAlias(fromModule, specifier); resolved != "" { + return resolved + } + if resolved := resolver.resolveWorkspacePackage(specifier); resolved != "" { + return resolved + } + return "" +} + +func (resolver *typeScriptImportResolver) resolveRelative(fromModule string, specifier string) string { + joined := path.Clean(path.Join(path.Dir(fromModule), specifier)) + return resolver.resolveModulePath(joined) +} + +func (resolver *typeScriptImportResolver) resolvePackageImport(fromModule string, specifier string) string { + if !strings.HasPrefix(specifier, "#") { + return "" + } + pkg, ok := resolver.packageForModule(fromModule) + if !ok { + return "" + } + for _, candidate := range matchTypeScriptMapping(pkg.imports, specifier) { + if resolved := resolver.resolveModulePath(path.Join(pkg.dir, candidate)); resolved != "" { + return resolved + } + } + return "" +} + +func (resolver *typeScriptImportResolver) resolveTSConfigAlias(fromModule string, specifier string) string { + cfg := resolver.configForModule(fromModule) + if cfg == nil { + return "" + } + for _, alias := range cfg.paths { + wildcard, ok := matchTypeScriptAlias(alias.pattern, specifier) + if !ok { + continue + } + for _, target := range alias.targets { + applied := applyTypeScriptAliasTarget(target, wildcard) + if resolved := resolver.resolveModulePath(path.Join(cfg.baseDir, applied)); resolved != "" { + return resolved + } + } + } + if cfg.baseDir == "" { + return "" + } + return resolver.resolveModulePath(path.Join(cfg.baseDir, specifier)) +} + +func (resolver *typeScriptImportResolver) resolveWorkspacePackage(specifier string) string { + root := typeScriptPackageRoot(specifier) + pkg, ok := resolver.packages[root] + if !ok { + return "" + } + if specifier == root { + return resolver.resolveWorkspacePackageEntrypoint(pkg) + } + subpath := strings.TrimPrefix(specifier, root+"/") + if subpath == specifier || subpath == "" { + return "" + } + for _, candidate := range matchTypeScriptMapping(pkg.exports, "./"+subpath) { + if resolved := resolver.resolveModulePath(path.Join(pkg.dir, candidate)); resolved != "" { + return resolved + } + } + for _, candidate := range []string{ + path.Join(pkg.dir, subpath), + path.Join(pkg.dir, "src", subpath), + } { + if resolved := resolver.resolveModulePath(candidate); resolved != "" { + return resolved + } + } + return "" +} + +func (resolver *typeScriptImportResolver) resolveWorkspacePackageEntrypoint(pkg typeScriptWorkspacePackage) string { + for _, candidate := range matchTypeScriptMapping(pkg.exports, ".") { + if resolved := resolver.resolveModulePath(path.Join(pkg.dir, candidate)); resolved != "" { + return resolved + } + } + for _, candidate := range []string{pkg.types, pkg.source, pkg.module, pkg.main} { + if strings.TrimSpace(candidate) == "" { + continue + } + if resolved := resolver.resolveModulePath(path.Join(pkg.dir, candidate)); resolved != "" { + return resolved + } + } + for _, candidate := range []string{ + path.Join(pkg.dir, "index"), + path.Join(pkg.dir, "src", "index"), + } { + if resolved := resolver.resolveModulePath(candidate); resolved != "" { + return resolved + } + } + return "" +} + +func (resolver *typeScriptImportResolver) packageForModule(module string) (typeScriptWorkspacePackage, bool) { + node := resolver.graph.modules[module] + if node == nil { + return typeScriptWorkspacePackage{}, false + } + file := normalizeTypeScriptPath(node.file) + best := typeScriptWorkspacePackage{} + bestLen := -1 + for _, pkg := range resolver.packages { + if !typeScriptPathContains(pkg.dir, file) { + continue + } + if len(pkg.dir) > bestLen { + best = pkg + bestLen = len(pkg.dir) + } + } + if bestLen < 0 { + return typeScriptWorkspacePackage{}, false + } + return best, true +} + +func (resolver *typeScriptImportResolver) resolveModulePath(rel string) string { + rel = normalizeTypeScriptPath(rel) + rel = typeScriptModuleKey(rel) + for _, candidate := range []string{rel, rel + "/index"} { + if _, ok := resolver.graph.modules[candidate]; ok { + return candidate + } + } + return "" +} + +func (resolver *typeScriptImportResolver) configForModule(module string) *typeScriptGraphConfig { + node := resolver.graph.modules[module] + if node == nil { + return nil + } + dir := normalizeTypeScriptPath(path.Dir(node.file)) + for idx := range resolver.configs { + cfg := &resolver.configs[idx] + if typeScriptPathContains(cfg.dir, dir) { + return cfg + } + } + return nil +} + +func typeScriptPathContains(parent string, child string) bool { + parent = normalizeTypeScriptPath(parent) + child = normalizeTypeScriptPath(child) + if parent == "." { + return true + } + return child == parent || strings.HasPrefix(child, parent+"/") +} + +func normalizeTypeScriptPath(value string) string { + value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/")) + if value == "" { + return "." + } + value = path.Clean(value) + value = strings.TrimPrefix(value, "./") + if value == "" { + return "." + } + return value +} + +func typeScriptPackageRoot(specifier string) string { + if strings.HasPrefix(specifier, "@") { + parts := strings.Split(specifier, "/") + if len(parts) >= 2 { + return parts[0] + "/" + parts[1] + } + } + return firstTypeScriptSegment(specifier) +} + +func firstTypeScriptSegment(specifier string) string { + specifier = strings.TrimSpace(specifier) + if specifier == "" { + return "" + } + if cut := strings.IndexByte(specifier, '/'); cut >= 0 { + return specifier[:cut] + } + return specifier +} + +func matchTypeScriptAlias(pattern string, specifier string) (string, bool) { + if !strings.Contains(pattern, "*") { + return "", pattern == specifier + } + parts := strings.SplitN(pattern, "*", 2) + if !strings.HasPrefix(specifier, parts[0]) || !strings.HasSuffix(specifier, parts[1]) { + return "", false + } + return specifier[len(parts[0]) : len(specifier)-len(parts[1])], true +} + +func applyTypeScriptAliasTarget(target string, wildcard string) string { + if !strings.Contains(target, "*") { + return target + } + return strings.Replace(target, "*", wildcard, 1) +} + +func matchTypeScriptMapping(mappings map[string][]string, specifier string) []string { + if len(mappings) == 0 { + return nil + } + patterns := make([]string, 0, len(mappings)) + for pattern := range mappings { + patterns = append(patterns, pattern) + } + sort.Slice(patterns, func(i, j int) bool { + if len(patterns[i]) != len(patterns[j]) { + return len(patterns[i]) > len(patterns[j]) + } + return patterns[i] < patterns[j] + }) + for _, pattern := range patterns { + wildcard, ok := matchTypeScriptAlias(pattern, specifier) + if !ok { + continue + } + values := make([]string, 0, len(mappings[pattern])) + for _, target := range mappings[pattern] { + values = append(values, applyTypeScriptAliasTarget(target, wildcard)) + } + return values + } + return nil +} + +func parseTypeScriptPackageExports(raw json.RawMessage) map[string][]string { + return parseTypeScriptPackageMappings(raw, true) +} + +func parseTypeScriptPackageImports(raw json.RawMessage) map[string][]string { + return parseTypeScriptPackageMappings(raw, false) +} + +func parseTypeScriptPackageMappings(raw json.RawMessage, isExports bool) map[string][]string { + if len(raw) == 0 { + return nil + } + var node any + if err := json.Unmarshal(raw, &node); err != nil { + return nil + } + mappings := make(map[string][]string) + switch value := node.(type) { + case string: + mappings["."] = append(mappings["."], value) + case map[string]any: + for key, child := range value { + switch { + case isExports && key == ".": + mappings["."] = append(mappings["."], collectTypeScriptExportTargets(child)...) + case isExports && strings.HasPrefix(key, "./"): + mappings[key] = append(mappings[key], collectTypeScriptExportTargets(child)...) + case !isExports && strings.HasPrefix(key, "#"): + mappings[key] = append(mappings[key], collectTypeScriptExportTargets(child)...) + default: + mappings["."] = append(mappings["."], collectTypeScriptExportTargets(child)...) + } + } + } + for key, values := range mappings { + mappings[key] = uniqueNonEmptyStrings(values) + if len(mappings[key]) == 0 { + delete(mappings, key) + } + } + return mappings +} + +func collectTypeScriptExportTargets(node any) []string { + switch value := node.(type) { + case string: + return []string{value} + case []any: + out := make([]string, 0, len(value)) + for _, item := range value { + out = append(out, collectTypeScriptExportTargets(item)...) + } + return out + case map[string]any: + out := make([]string, 0, len(value)) + for _, key := range orderedTypeScriptConditionKeys(value) { + child := value[key] + out = append(out, collectTypeScriptExportTargets(child)...) + } + return out + default: + return nil + } +} + +func orderedTypeScriptConditionKeys(values map[string]any) []string { + preferred := []string{ + "types", "source", "import", "module", "browser", "development", + "production", "node", "default", "require", + } + keys := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, key := range preferred { + if _, ok := values[key]; ok { + keys = append(keys, key) + seen[key] = struct{}{} + } + } + extra := make([]string, 0, len(values)) + for key := range values { + if _, ok := seen[key]; ok { + continue + } + extra = append(extra, key) + } + sort.Strings(extra) + return append(keys, extra...) +} + +func uniqueNonEmptyStrings(values []string) []string { + out := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(strings.TrimPrefix(value, "./")) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func normalizeJSONC(data []byte) ([]byte, bool) { + withoutComments, ok := stripJSONCComments(string(data)) + if !ok { + return nil, false + } + return []byte(stripJSONCTrailingCommas(withoutComments)), true +} + +func stripJSONCComments(source string) (string, bool) { + var b strings.Builder + b.Grow(len(source)) + inString := false + escaped := false + for idx := 0; idx < len(source); idx++ { + ch := source[idx] + if inString { + b.WriteByte(ch) + if escaped { + escaped = false + continue + } + switch ch { + case '\\': + escaped = true + case '"': + inString = false + } + continue + } + if ch == '"' { + inString = true + b.WriteByte(ch) + continue + } + if ch == '/' && idx+1 < len(source) { + switch source[idx+1] { + case '/': + for idx+1 < len(source) && source[idx+1] != '\n' { + idx++ + } + continue + case '*': + idx += 2 + for idx < len(source) { + if idx+1 < len(source) && source[idx] == '*' && source[idx+1] == '/' { + idx++ + break + } + if source[idx] == '\n' { + b.WriteByte('\n') + } + idx++ + } + if idx >= len(source) { + return "", false + } + continue + } + } + b.WriteByte(ch) + } + return b.String(), !inString +} + +func stripJSONCTrailingCommas(source string) string { + var b strings.Builder + b.Grow(len(source)) + inString := false + escaped := false + for idx := 0; idx < len(source); idx++ { + ch := source[idx] + if inString { + b.WriteByte(ch) + if escaped { + escaped = false + continue + } + switch ch { + case '\\': + escaped = true + case '"': + inString = false + } + continue + } + if ch == '"' { + inString = true + b.WriteByte(ch) + continue + } + if ch == ',' { + next := idx + 1 + for next < len(source) && isJSONWhitespace(source[next]) { + next++ + } + if next < len(source) && (source[next] == '}' || source[next] == ']') { + continue + } + } + b.WriteByte(ch) + } + return b.String() +} + +func isJSONWhitespace(ch byte) bool { + switch ch { + case ' ', '\t', '\r', '\n': + return true + default: + return false + } +} diff --git a/internal/codeguard/checks/design/design_python.go b/internal/codeguard/checks/design/design_python.go index ba46ec6..fb34ea1 100644 --- a/internal/codeguard/checks/design/design_python.go +++ b/internal/codeguard/checks/design/design_python.go @@ -9,8 +9,9 @@ import ( "github.com/devr-tools/codeguard/internal/codeguard/core" ) -func pythonTargetFindings(env support.Context, _ core.TargetConfig, graph pythonImportGraph) []core.Finding { +func pythonTargetFindings(env support.Context, target core.TargetConfig, graph pythonImportGraph) []core.Finding { findings := make([]core.Finding, 0, len(graph.graph.Order)) + findings = append(findings, pythonStructuralFindings(env, target)...) for _, module := range graph.graph.Order { node := graph.graph.Nodes[module] findings = append(findings, genericPythonModuleNameFindings(env, node.Path)...) diff --git a/internal/codeguard/checks/design/design_python_structure.go b/internal/codeguard/checks/design/design_python_structure.go new file mode 100644 index 0000000..ecaf3c0 --- /dev/null +++ b/internal/codeguard/checks/design/design_python_structure.go @@ -0,0 +1,271 @@ +package design + +import ( + "fmt" + "regexp" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +var ( + pythonClassDeclPattern = regexp.MustCompile(`^class\s+([A-Za-z_]\w*)\s*(?:\((.*)\))?\s*:`) + pythonMethodDeclPattern = regexp.MustCompile(`^(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(`) + pythonProtocolAttrPattern = regexp.MustCompile(`^([A-Za-z_]\w*)\s*:`) +) + +type pythonTypeBlockKind string + +const ( + pythonTypeBlockClass pythonTypeBlockKind = "class" + pythonTypeBlockProtocol pythonTypeBlockKind = "protocol" +) + +type pythonTypeBlock struct { + kind pythonTypeBlockKind + name string + line int + headerIndent int + bodyIndent int + memberCount int +} + +type pythonTypeLogicalLine struct { + startLine int + indent int + text string +} + +func pythonStructuralFindings(env support.Context, target core.TargetConfig) []core.Finding { + return env.ScanTargetFiles(target, "design", func(rel string) bool { + return strings.EqualFold(".py", filepathExt(rel)) + }, func(file string, data []byte) []core.Finding { + return pythonStructuralFindingsForFile(env, file, data) + }) +} + +func pythonStructuralFindingsForFile(env support.Context, file string, data []byte) []core.Finding { + blocks := pythonTypeBlocks(string(data)) + findings := make([]core.Finding, 0, len(blocks)) + for _, block := range blocks { + switch block.kind { + case pythonTypeBlockClass: + if block.memberCount <= env.Config.Checks.DesignRules.MaxMethodsPerType { + continue + } + findings = append(findings, env.NewFinding(support.FindingInput{ + RuleID: "design.python.max-methods-per-type", + Level: "warn", + Path: file, + Line: block.line, + Column: 1, + Message: fmt.Sprintf("class %s has %d methods; max is %d", block.name, block.memberCount, env.Config.Checks.DesignRules.MaxMethodsPerType), + })) + case pythonTypeBlockProtocol: + if block.memberCount <= env.Config.Checks.DesignRules.MaxInterfaceMethods { + continue + } + findings = append(findings, env.NewFinding(support.FindingInput{ + RuleID: "design.python.max-protocol-members", + Level: "warn", + Path: file, + Line: block.line, + Column: 1, + Message: fmt.Sprintf("protocol %s has %d members; max is %d", block.name, block.memberCount, env.Config.Checks.DesignRules.MaxInterfaceMethods), + })) + } + } + return findings +} + +func pythonTypeBlocks(source string) []pythonTypeBlock { + masked := support.MaskPythonSource(strings.ReplaceAll(source, "\r\n", "\n")) + logical := pythonTypeLogicalLines(masked) + stack := make([]pythonTypeBlock, 0) + finished := make([]pythonTypeBlock, 0) + + for _, line := range logical { + for len(stack) > 0 && line.indent <= stack[len(stack)-1].headerIndent { + finished = append(finished, stack[len(stack)-1]) + stack = stack[:len(stack)-1] + } + + text := pythonCompactWhitespace(line.text) + if text == "" { + continue + } + if name, bases, ok := parsePythonClassDecl(text); ok { + stack = append(stack, pythonTypeBlock{ + kind: pythonTypeBlockForBases(bases), + name: name, + line: line.startLine, + headerIndent: line.indent, + bodyIndent: -1, + }) + continue + } + if len(stack) == 0 { + continue + } + + top := &stack[len(stack)-1] + if line.indent <= top.headerIndent { + continue + } + if top.bodyIndent < 0 { + top.bodyIndent = line.indent + } + if line.indent != top.bodyIndent { + continue + } + + if methodName, ok := parsePythonMethodDecl(text); ok { + if top.kind == pythonTypeBlockClass && methodName == "__init__" { + continue + } + top.memberCount++ + continue + } + if top.kind == pythonTypeBlockProtocol && isPythonProtocolAttribute(text) { + top.memberCount++ + } + } + + for len(stack) > 0 { + finished = append(finished, stack[len(stack)-1]) + stack = stack[:len(stack)-1] + } + return finished +} + +func pythonTypeLogicalLines(masked string) []pythonTypeLogicalLine { + lines := strings.Split(masked, "\n") + logical := make([]pythonTypeLogicalLine, 0, len(lines)) + current := pythonTypeLogicalLine{} + depth := 0 + open := false + + for idx, line := range lines { + if !open { + current = pythonTypeLogicalLine{startLine: idx + 1, indent: pythonIndentWidth(line)} + } else { + current.text += "\n" + } + current.text += line + depth += pythonBracketDelta(line) + open = depth > 0 || strings.HasSuffix(strings.TrimRight(line, " \t"), "\\") + if open { + continue + } + if strings.TrimSpace(current.text) != "" { + logical = append(logical, current) + } + } + + if open && strings.TrimSpace(current.text) != "" { + logical = append(logical, current) + } + return logical +} + +func parsePythonClassDecl(text string) (string, string, bool) { + match := pythonClassDeclPattern.FindStringSubmatch(text) + if len(match) != 3 { + return "", "", false + } + return match[1], match[2], true +} + +func parsePythonMethodDecl(text string) (string, bool) { + match := pythonMethodDeclPattern.FindStringSubmatch(text) + if len(match) != 2 { + return "", false + } + return match[1], true +} + +func pythonTypeBlockForBases(bases string) pythonTypeBlockKind { + for _, base := range pythonSplitTopLevelCSV(bases) { + base = strings.TrimSpace(base) + if cut := strings.IndexByte(base, '['); cut >= 0 { + base = base[:cut] + } + if base == "Protocol" || strings.HasSuffix(base, ".Protocol") { + return pythonTypeBlockProtocol + } + } + return pythonTypeBlockClass +} + +func isPythonProtocolAttribute(text string) bool { + if strings.HasPrefix(text, "@") { + return false + } + match := pythonProtocolAttrPattern.FindStringSubmatch(text) + return len(match) == 2 && match[1] != "" +} + +func pythonSplitTopLevelCSV(text string) []string { + parts := make([]string, 0, 2) + depth := 0 + start := 0 + for idx := 0; idx < len(text); idx++ { + switch text[idx] { + case '(', '[', '{': + depth++ + case ')', ']', '}': + if depth > 0 { + depth-- + } + case ',': + if depth == 0 { + parts = append(parts, text[start:idx]) + start = idx + 1 + } + } + } + if start <= len(text) { + parts = append(parts, text[start:]) + } + return parts +} + +func pythonCompactWhitespace(text string) string { + return strings.Join(strings.Fields(text), " ") +} + +func pythonBracketDelta(line string) int { + delta := 0 + for idx := 0; idx < len(line); idx++ { + switch line[idx] { + case '(', '[', '{': + delta++ + case ')', ']', '}': + delta-- + } + } + return delta +} + +func pythonIndentWidth(line string) int { + width := 0 + for _, ch := range line { + switch ch { + case ' ': + width++ + case '\t': + width += 4 + default: + return width + } + } + return width +} + +func filepathExt(path string) string { + if idx := strings.LastIndexByte(path, '.'); idx >= 0 { + return path[idx:] + } + return "" +} diff --git a/internal/codeguard/checks/design/design_rust.go b/internal/codeguard/checks/design/design_rust.go new file mode 100644 index 0000000..651bde0 --- /dev/null +++ b/internal/codeguard/checks/design/design_rust.go @@ -0,0 +1,291 @@ +package design + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +var ( + rustTraitPattern = regexp.MustCompile(`^\s*(?:pub(?:\([^)]*\))?\s+)?(?:unsafe\s+)?trait\s+([A-Za-z_]\w*)\b`) + rustImplPattern = regexp.MustCompile(`^\s*impl\b`) + rustMethodPattern = regexp.MustCompile(`^\s*(?:pub(?:\([^)]*\))?\s+)?(?:default\s+)?(?:async\s+)?(?:const\s+)?(?:unsafe\s+)?fn\s+([A-Za-z_]\w*)\b`) + rustTraitMemberPattern = regexp.MustCompile(`^\s*(?:(?:default\s+)?(?:async\s+)?(?:const\s+)?(?:unsafe\s+)?fn\s+[A-Za-z_]\w*\b|type\s+[A-Za-z_]\w*\b|const\s+[A-Za-z_]\w*\b)`) +) + +type rustBlockKind int + +const ( + rustBlockImpl rustBlockKind = iota + rustBlockTrait +) + +type rustBlock struct { + kind rustBlockKind + name string + header string + line int + bodyDepth int + waiting bool + count int +} + +type rustCountSummary struct { + line int + count int +} + +func rustTargetFindings(env support.Context, target core.TargetConfig) []core.Finding { + return env.ScanTargetFiles(target, "design", func(rel string) bool { + return strings.HasSuffix(rel, ".rs") + }, func(file string, data []byte) []core.Finding { + return rustFindingsForFile(env, file, data) + }) +} + +// RustFindingsForFile exposes the Rust-native design heuristics independently +// of shared dispatch so focused tests can exercise them directly. +func RustFindingsForFile(env support.Context, file string, data []byte) []core.Finding { + return rustFindingsForFile(env, file, data) +} + +func rustFindingsForFile(env support.Context, file string, data []byte) []core.Finding { + findings := rustGenericModuleNameFindings(env, file) + lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") + methodCounts := map[string]rustCountSummary{} + + depth := 0 + var active *rustBlock + + for idx, raw := range lines { + line := stripLineComment(raw) + active = nextRustBlock(active, depth, line, idx+1) + updateRustBlockHeader(active, line) + countRustBlockMember(active, depth, line) + depth += braceDelta(line) + openRustBlock(active, depth, line) + findings, active = closeRustBlock(findings, env, file, active, depth, methodCounts) + } + + if active != nil && !active.waiting { + findings = append(findings, finalizeRustBlock(env, file, *active, methodCounts)...) + } + + findings = append(findings, rustMethodFindings(env, file, methodCounts)...) + return findings +} + +func rustGenericModuleNameFindings(env support.Context, file string) []core.Finding { + moduleName := normalizedRustModuleName(file) + if moduleName == "" { + return nil + } + for _, forbidden := range env.Config.Checks.DesignRules.ForbiddenPackageNames { + if strings.EqualFold(moduleName, forbidden) { + return []core.Finding{env.NewFinding(support.FindingInput{ + RuleID: "design.rust.generic-module-name", + Level: "warn", + Path: file, + Line: 1, + Column: 1, + Message: fmt.Sprintf("module name %q is too generic", moduleName), + })} + } + } + return nil +} + +func normalizedRustModuleName(path string) string { + base := strings.TrimSuffix(strings.ToLower(filepath.Base(path)), filepath.Ext(path)) + switch base { + case "", "lib", "main": + return "" + case "mod": + parent := strings.ToLower(filepath.Base(filepath.Dir(path))) + if parent == "." || parent == "/" || parent == "src" { + return "" + } + return parent + default: + return base + } +} + +func nextRustBlock(active *rustBlock, depth int, line string, lineNo int) *rustBlock { + if active != nil || depth != 0 { + return active + } + if match := rustTraitPattern.FindStringSubmatch(line); len(match) == 2 { + return &rustBlock{kind: rustBlockTrait, name: match[1], line: lineNo, waiting: true} + } + if rustImplPattern.MatchString(line) { + return &rustBlock{kind: rustBlockImpl, line: lineNo, waiting: true} + } + return nil +} + +func updateRustBlockHeader(active *rustBlock, line string) { + if active == nil || !active.waiting { + return + } + trimmed := strings.TrimSpace(line) + if trimmed == "" { + return + } + if active.header != "" { + active.header += " " + } + active.header += trimmed +} + +func countRustBlockMember(active *rustBlock, depth int, line string) { + if active == nil || active.waiting || depth != active.bodyDepth { + return + } + trimmed := strings.TrimSpace(line) + if trimmed == "" || trimmed == "}" { + return + } + switch active.kind { + case rustBlockImpl: + if rustMethodPattern.MatchString(trimmed) { + active.count++ + } + case rustBlockTrait: + if rustTraitMemberPattern.MatchString(trimmed) { + active.count++ + } + } +} + +func openRustBlock(active *rustBlock, depth int, line string) { + if active == nil || !active.waiting || !strings.Contains(line, "{") { + return + } + if active.kind == rustBlockImpl { + active.name = rustImplTargetName(active.header) + } + active.waiting = false + active.bodyDepth = depth +} + +func closeRustBlock(findings []core.Finding, env support.Context, file string, active *rustBlock, depth int, methodCounts map[string]rustCountSummary) ([]core.Finding, *rustBlock) { + if active == nil || active.waiting || depth >= active.bodyDepth { + return findings, active + } + findings = append(findings, finalizeRustBlock(env, file, *active, methodCounts)...) + return findings, nil +} + +func finalizeRustBlock(env support.Context, file string, block rustBlock, methodCounts map[string]rustCountSummary) []core.Finding { + switch block.kind { + case rustBlockImpl: + if block.name == "" || block.count == 0 { + return nil + } + summary := methodCounts[block.name] + if summary.line == 0 { + summary.line = block.line + } + summary.count += block.count + methodCounts[block.name] = summary + case rustBlockTrait: + if block.name == "" || block.count <= env.Config.Checks.DesignRules.MaxInterfaceMethods { + return nil + } + return []core.Finding{env.NewFinding(support.FindingInput{ + RuleID: "design.rust.max-trait-members", + Level: "warn", + Path: file, + Line: block.line, + Column: 1, + Message: fmt.Sprintf("trait %s exposes %d members; max is %d", block.name, block.count, env.Config.Checks.DesignRules.MaxInterfaceMethods), + })} + } + return nil +} + +func rustMethodFindings(env support.Context, file string, methodCounts map[string]rustCountSummary) []core.Finding { + findings := make([]core.Finding, 0) + for typeName, summary := range methodCounts { + if summary.count <= env.Config.Checks.DesignRules.MaxMethodsPerType { + continue + } + line := summary.line + if line == 0 { + line = 1 + } + findings = append(findings, env.NewFinding(support.FindingInput{ + RuleID: "design.rust.max-methods-per-type", + Level: "warn", + Path: file, + Line: line, + Column: 1, + Message: fmt.Sprintf("type %s has %d impl methods in this file; max is %d", typeName, summary.count, env.Config.Checks.DesignRules.MaxMethodsPerType), + })) + } + return findings +} + +func rustImplTargetName(header string) string { + header = strings.Join(strings.Fields(strings.Split(header, "{")[0]), " ") + header = strings.TrimSpace(strings.TrimPrefix(header, "impl")) + header = strings.TrimSpace(trimRustGenericPrefix(header)) + if header == "" { + return "" + } + if idx := strings.LastIndex(header, " for "); idx >= 0 { + header = header[idx+5:] + } + if idx := strings.Index(header, " where "); idx >= 0 { + header = header[:idx] + } + return rustPrimaryTypeName(strings.TrimSpace(header)) +} + +func trimRustGenericPrefix(header string) string { + if !strings.HasPrefix(header, "<") { + return header + } + depth := 0 + for idx, char := range header { + switch char { + case '<': + depth++ + case '>': + depth-- + if depth == 0 { + return strings.TrimSpace(header[idx+1:]) + } + } + } + return header +} + +func rustPrimaryTypeName(target string) string { + target = strings.TrimSpace(target) + for { + target = strings.TrimSpace(strings.TrimPrefix(target, "&")) + target = strings.TrimSpace(strings.TrimPrefix(target, "mut ")) + target = strings.TrimSpace(strings.TrimPrefix(target, "dyn ")) + fields := strings.Fields(target) + if len(fields) > 0 && strings.HasPrefix(fields[0], "'") { + target = strings.TrimSpace(strings.Join(fields[1:], " ")) + continue + } + break + } + for _, sep := range []string{"<", " ", "(", "[", "{"} { + if idx := strings.Index(target, sep); idx >= 0 { + target = target[:idx] + } + } + if idx := strings.LastIndex(target, "::"); idx >= 0 { + target = target[idx+2:] + } + return strings.TrimSpace(target) +} diff --git a/internal/codeguard/checks/quality/quality_go.go b/internal/codeguard/checks/quality/quality_go.go index d52da8a..8b73ab2 100644 --- a/internal/codeguard/checks/quality/quality_go.go +++ b/internal/codeguard/checks/quality/quality_go.go @@ -50,10 +50,6 @@ func goFindingsForFile(env support.Context, file string, data []byte) []core.Fin })) return append(fileLengthFindingWithSignals(env, file, data, findings), findings...) } - if len(parsed.Decls) > env.Config.Checks.DesignRules.MaxDeclsPerFile { - findings = append(findings, warnFinding(env, "design.max-decls-per-file", file, 1, 1, - fmt.Sprintf("file has %d declarations; max is %d", len(parsed.Decls), env.Config.Checks.DesignRules.MaxDeclsPerFile))) - } findings = append(findings, importFindings(env, file, fset, parsed)...) findings = append(findings, goFunctionFindings(env, file, fset, parsed)...) findings = append(findings, goAIQualityFindings(env, file, fset, parsed, data)...) diff --git a/internal/codeguard/checks/support/cpp_dependency_graph.go b/internal/codeguard/checks/support/cpp_dependency_graph.go index afa8799..2b81b12 100644 --- a/internal/codeguard/checks/support/cpp_dependency_graph.go +++ b/internal/codeguard/checks/support/cpp_dependency_graph.go @@ -11,8 +11,8 @@ import ( ) var ( - cppModuleDeclarationPattern = regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?module[ \t]+([A-Za-z_]\w*(?::[A-Za-z_]\w*)*)[ \t]*;`) - cppModuleImportPattern = regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?import[ \t]+([A-Za-z_]\w*(?::[A-Za-z_]\w*)*)[ \t]*;`) + cppModuleDeclarationPattern = regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?module[ \t]+([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?::[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)?)[ \t]*;`) + cppModuleImportPattern = regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?import[ \t]+((?:[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?::[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)?)|(?::[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*))[ \t]*;`) ) // CPPDependencyGraph captures target-local #include and C++20 named-module @@ -36,6 +36,7 @@ type pendingCPPDependency struct { func BuildCPPDependencyGraph(env Context, target core.TargetConfig) *CPPDependencyGraph { nodes := make(map[string]DependencyNode) fileToModule := make(map[string]string) + declaredModules := make(map[string]string) moduleFiles := make(map[string]string) pending := make([]pendingCPPDependency, 0) env.VisitTargetFiles(target, func(rel string) bool { return IsCPPPath(rel, true) }, func(rel string, data []byte) { @@ -48,6 +49,7 @@ func BuildCPPDependencyGraph(env Context, target core.TargetConfig) *CPPDependen pending = append(pending, pendingCPPDependency{from: rel, target: imported.Module, line: imported.Line}) } if match := cppModuleDeclarationPattern.FindStringSubmatch(parsed.Masked); match != nil { + declaredModules[rel] = match[1] moduleFiles[match[1]] = rel } for _, match := range cppModuleImportPattern.FindAllStringSubmatchIndex(parsed.Masked, -1) { @@ -65,6 +67,7 @@ func BuildCPPDependencyGraph(env Context, target core.TargetConfig) *CPPDependen for _, dependency := range pending { to := "" if dependency.named { + dependency.target = qualifyCPPModuleImport(dependency.target, declaredModules[dependency.from]) to = moduleFiles[dependency.target] } else { to = resolveCPPInclude(nodes, dependency.from, dependency.target, includeRoots) @@ -86,6 +89,28 @@ func BuildCPPDependencyGraph(env Context, target core.TargetConfig) *CPPDependen return &CPPDependencyGraph{Graph: NewDependencyGraph(nodes), FileToModule: fileToModule} } +func qualifyCPPModuleImport(specifier string, declaredModule string) string { + if !strings.HasPrefix(specifier, ":") { + return specifier + } + primary := cppPrimaryModuleName(declaredModule) + if primary == "" { + return "" + } + return primary + specifier +} + +func cppPrimaryModuleName(module string) string { + module = strings.TrimSpace(module) + if module == "" { + return "" + } + if cut := strings.IndexByte(module, ':'); cut >= 0 { + return module[:cut] + } + return module +} + func resolveCPPInclude(nodes map[string]DependencyNode, from string, imported string, includeRoots []string) string { imported = filepath.ToSlash(imported) candidates := make([]string, 0, 2+len(includeRoots)) diff --git a/internal/codeguard/rules/catalog_design.go b/internal/codeguard/rules/catalog_design.go index 332b8c8..acb2dc6 100644 --- a/internal/codeguard/rules/catalog_design.go +++ b/internal/codeguard/rules/catalog_design.go @@ -113,6 +113,26 @@ var designCatalog = map[string]core.RuleMetadata{ Description: "Warns when one C++ file defines too many qualified methods for the same type.", HowToFix: "Split the type's responsibilities across smaller types or extracted collaborators.", }, + "design.cpp.max-decls-per-file": { + ID: "design.cpp.max-decls-per-file", + Section: "Design Patterns", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageCPP), + Title: "C++ declarations per file", + Description: "Warns when a C++ file accumulates too many top-level declarations.", + HowToFix: "Split unrelated types and functions into smaller translation units or headers with clearer ownership.", + }, + "design.cpp.max-interface-methods": { + ID: "design.cpp.max-interface-methods", + Section: "Design Patterns", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageCPP), + Title: "C++ contract surface size", + Description: "Warns when a C++ contract file exposes too many public methods on one type.", + HowToFix: "Break the public surface into smaller focused contracts or move implementation details behind non-public helpers.", + }, "design.typescript.max-methods-per-type": { ID: "design.typescript.max-methods-per-type", Section: "Design Patterns", @@ -176,4 +196,52 @@ var designCatalog = map[string]core.RuleMetadata{ Description: "Warns when a Python module name is too generic to communicate ownership or responsibility.", HowToFix: "Rename the module to something specific to its responsibility.", }, + "design.python.max-methods-per-type": { + ID: "design.python.max-methods-per-type", + Section: "Design Patterns", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + Title: "Python methods per type", + Description: "Warns when a Python class accumulates too many direct methods.", + HowToFix: "Split responsibilities across smaller classes or extract collaborators.", + }, + "design.python.max-protocol-members": { + ID: "design.python.max-protocol-members", + Section: "Design Patterns", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + Title: "Python protocol surface size", + Description: "Warns when a Python Protocol accumulates too many direct members.", + HowToFix: "Break the protocol into smaller focused contracts.", + }, + "design.rust.generic-module-name": { + ID: "design.rust.generic-module-name", + Section: "Design Patterns", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageRust), + Title: "Rust generic module name", + Description: "Warns when a Rust module name is too generic to communicate ownership or responsibility.", + HowToFix: "Rename the module to something specific to its responsibility.", + }, + "design.rust.max-methods-per-type": { + ID: "design.rust.max-methods-per-type", + Section: "Design Patterns", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageRust), + Title: "Rust methods per type", + Description: "Warns when a Rust type accumulates too many impl methods within one file.", + HowToFix: "Split responsibilities across smaller types, traits, or modules.", + }, + "design.rust.max-trait-members": { + ID: "design.rust.max-trait-members", + Section: "Design Patterns", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageRust), + Title: "Rust trait surface size", + Description: "Warns when a Rust trait accumulates too many members.", + HowToFix: "Break the trait into smaller focused contracts.", + }, } diff --git a/tests/checks/design_architecture_boundaries_test.go b/tests/checks/design_architecture_boundaries_test.go index 81ee009..c94bd74 100644 --- a/tests/checks/design_architecture_boundaries_test.go +++ b/tests/checks/design_architecture_boundaries_test.go @@ -85,6 +85,42 @@ func TestDesignTypeScriptDomainAndDataOwnershipBoundaries(t *testing.T) { } } +func TestDesignCPPDomainAndDataOwnershipBoundariesUseNamedModules(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "orders", "contracts.cppm"), "export module app.orders.contracts;\nexport struct Order { int id; };\n") + writeFile(t, filepath.Join(dir, "src", "orders", "data.cppm"), "export module app.orders.data;\nexport int orders_table();\n") + writeFile(t, filepath.Join(dir, "src", "billing", "allowed.cppm"), "export module app.billing.allowed;\nimport app.orders.contracts;\nexport int bill_order();\n") + writeFile(t, filepath.Join(dir, "src", "billing", "forbidden.cppm"), "export module app.billing.forbidden;\nimport app.orders.data;\nexport int source();\n") + + cfg := graphTestConfig("design-cpp-domains", dir, "cpp") + cfg.Checks.DesignRules.Domains = []codeguard.DesignDomainConfig{ + {Name: "billing", Paths: []string{"src/billing/**"}, MayDependOn: []string{"orders"}}, + {Name: "orders", Paths: []string{"src/orders/**"}, PublicPaths: []string{"src/orders/contracts.cppm"}, DataPaths: []string{"src/orders/data.cppm"}}, + } + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + for _, ruleID := range []string{"design.domain-boundary", "design.data-ownership"} { + finding := designFinding(t, report, ruleID) + if finding.Path != "src/billing/forbidden.cppm" || finding.Line != 2 { + t.Fatalf("%s location = %s:%d, want src/billing/forbidden.cppm:2", ruleID, finding.Path, finding.Line) + } + } + for _, section := range report.Sections { + if section.Name != "Design Patterns" { + continue + } + for _, finding := range section.Findings { + if finding.Path == "src/billing/allowed.cppm" && (finding.RuleID == "design.domain-boundary" || finding.RuleID == "design.data-ownership") { + t.Fatalf("allowed C++ contract import should not trigger %s: %+v", finding.RuleID, finding) + } + } + } +} + func TestDesignLayerDeniedExternalImport(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "go.mod"), "module example.com/external-layer\n\ngo 1.23.0\n") @@ -104,6 +140,30 @@ func TestDesignLayerDeniedExternalImport(t *testing.T) { } } +func TestDesignCPPLayerBoundarySupportsDottedNamedModules(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "domain.cppm"), "export module app.domain;\nimport app.adapters;\nexport int run();\n") + writeFile(t, filepath.Join(dir, "src", "adapters.cppm"), "export module app.adapters;\nexport int store();\n") + + cfg := graphTestConfig("design-cpp-layer-dotted-modules", dir, "cpp") + cfg.Checks.DesignRules.Layers = []codeguard.DesignLayerConfig{ + {Name: "domain", Paths: []string{"src/domain.cppm"}, DenyDependOn: []string{"adapters"}}, + {Name: "adapters", Paths: []string{"src/adapters.cppm"}}, + } + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + finding := designFinding(t, report, "design.layer-boundary") + if finding.Path != "src/domain.cppm" || finding.Line != 2 { + t.Fatalf("C++ layer finding location = %s:%d, want src/domain.cppm:2", finding.Path, finding.Line) + } + if !strings.Contains(finding.Message, "app.adapters") { + t.Fatalf("C++ layer finding should preserve named module specifier, got %q", finding.Message) + } +} + func TestDesignCapabilityBoundaryCoversSupportedLanguageImports(t *testing.T) { tests := []struct { name string @@ -145,6 +205,28 @@ func TestDesignCapabilityBoundaryCoversSupportedLanguageImports(t *testing.T) { } } +func TestDesignCPPCapabilityBoundarySupportsExternalNamedModules(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "domain.cppm"), "export module app.domain;\nimport vendor.aws.s3;\nexport int run();\n") + + cfg := graphTestConfig("design-cpp-capability-named-module", dir, "cpp") + cfg.Checks.DesignRules.Capabilities = []codeguard.DesignCapabilityConfig{{ + Name: "cloud-sdk", Imports: []string{"vendor.aws.**"}, AllowedPaths: []string{"src/adapters/**"}, + }} + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + finding := designFinding(t, report, "design.capability-boundary") + if finding.Path != "src/domain.cppm" || finding.Line != 2 { + t.Fatalf("C++ capability finding location = %s:%d, want src/domain.cppm:2", finding.Path, finding.Line) + } + if !strings.Contains(finding.Message, "vendor.aws.s3") { + t.Fatalf("C++ capability finding should preserve named module specifier, got %q", finding.Message) + } +} + func TestDesignRustUsesLanguageNeutralLayerPolicy(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "src", "lib.rs"), "mod domain;\nmod adapters;\n") diff --git a/tests/checks/design_cpp_graph_test.go b/tests/checks/design_cpp_graph_test.go index 1776a70..99078c9 100644 --- a/tests/checks/design_cpp_graph_test.go +++ b/tests/checks/design_cpp_graph_test.go @@ -34,6 +34,19 @@ func TestDesignCheckFailsForCPPNamedModuleCycle(t *testing.T) { assertFindingRulePresent(t, report, "Design Patterns", "design.cpp.import-cycle") } +func TestDesignCheckFailsForCPPModulePartitionCycle(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "alpha.cppm"), "export module alpha;\nimport :detail;\nexport int alpha_value();\n") + writeFile(t, filepath.Join(dir, "src", "alpha-detail.cppm"), "module alpha:detail;\nimport alpha;\nint detail_value();\n") + + report, err := codeguard.Run(context.Background(), graphTestConfig("design-cpp-partition-cycle", dir, "cpp")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRulePresent(t, report, "Design Patterns", "design.cpp.import-cycle") +} + func TestDesignCheckUsesCPPGraphForGodModules(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "include", "common.hpp"), "#pragma once\n") @@ -66,6 +79,52 @@ func TestDesignCheckWarnsForCPPGenericNameAndQualifiedMethodCount(t *testing.T) assertFindingRulePresent(t, report, "Design Patterns", "design.cpp.max-methods-per-type") } +func TestDesignCheckWarnsForCPPHeaderContractSurface(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "include", "service.hpp"), "#pragma once\nclass Service {\npublic:\n void one();\n void two();\n void three();\n};\n") + + cfg := graphTestConfig("design-cpp-header-contract", dir, "cpp") + cfg.Checks.DesignRules.MaxMethodsPerType = 2 + cfg.Checks.DesignRules.MaxInterfaceMethods = 2 + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRulePresent(t, report, "Design Patterns", "design.cpp.max-methods-per-type") + assertFindingRulePresent(t, report, "Design Patterns", "design.cpp.max-interface-methods") +} + +func TestDesignCheckWarnsForCPPDeclsPerFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "decls.cpp"), "struct Alpha {};\nstruct Beta {};\nint one() { return 1; }\nint two() { return 2; }\nint three() { return 3; }\n") + + cfg := graphTestConfig("design-cpp-decls", dir, "cpp") + cfg.Checks.DesignRules.MaxDeclsPerFile = 4 + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRulePresent(t, report, "Design Patterns", "design.cpp.max-decls-per-file") +} + +func TestDesignCheckCountsOnlyPublicCPPContractMethods(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "include", "service.hpp"), "#pragma once\nclass Service {\npublic:\n void one();\n void two();\nprivate:\n void helper();\n void cache();\n void reload();\n};\n") + + cfg := graphTestConfig("design-cpp-public-contract", dir, "cpp") + cfg.Checks.DesignRules.MaxMethodsPerType = 4 + cfg.Checks.DesignRules.MaxInterfaceMethods = 2 + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRulePresent(t, report, "Design Patterns", "design.cpp.max-methods-per-type") + assertFindingRuleAbsent(t, report, "Design Patterns", "design.cpp.max-interface-methods") +} + func TestDiffModeUsesCPPGraphForHighImpactChanges(t *testing.T) { dir := t.TempDir() runGit(t, dir, "init", "-b", "main") diff --git a/tests/checks/design_encapsulation_boundaries_test.go b/tests/checks/design_encapsulation_boundaries_test.go index 1158bdc..30b52e7 100644 --- a/tests/checks/design_encapsulation_boundaries_test.go +++ b/tests/checks/design_encapsulation_boundaries_test.go @@ -92,6 +92,50 @@ func TestDesignProductionTestPolicyCanBeDisabled(t *testing.T) { assertFindingRuleAbsent(t, report, "Design Patterns", "design.production-imports-test") } +func TestDesignPublicSurfaceRejectsCPPDeepInclude(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "app.cpp"), "#include \"../include/auth/internal/token.hpp\"\n") + writeFile(t, filepath.Join(dir, "include", "auth", "auth.hpp"), "#pragma once\n#include \"internal/token.hpp\"\n") + writeFile(t, filepath.Join(dir, "include", "auth", "internal", "token.hpp"), "#pragma once\nint parse_token();\n") + + cfg := graphTestConfig("design-cpp-public-surface", dir, "cpp") + cfg.Checks.DesignRules.PublicSurfaces = []codeguard.DesignPublicSurfaceConfig{{ + Name: "auth", + Paths: []string{"include/auth/**"}, + Entrypoints: []string{"include/auth/auth.hpp"}, + }} + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + finding := findFinding(t, report, "Design Patterns", "design.private-module-import") + if finding.Path != "src/app.cpp" || finding.Line != 1 { + t.Fatalf("finding location = %s:%d, want src/app.cpp:1", finding.Path, finding.Line) + } +} + +func TestDesignProductionCodeRejectsCPPTestInclude(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "app.cpp"), "#include \"../tests/support/fake_clock.hpp\"\n") + writeFile(t, filepath.Join(dir, "tests", "support", "fake_clock.hpp"), "#pragma once\nint fake_clock();\n") + + cfg := graphTestConfig("design-cpp-production-test", dir, "cpp") + cfg.Checks.DesignRules.ProductionTest = &codeguard.DesignProductionTestConfig{ + ProductionPaths: []string{"src/**"}, + TestPaths: []string{"tests/**"}, + } + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + finding := findFinding(t, report, "Design Patterns", "design.production-imports-test") + if finding.Path != "src/app.cpp" || finding.Line != 1 { + t.Fatalf("finding location = %s:%d, want src/app.cpp:1", finding.Path, finding.Line) + } +} + func TestDesignEncapsulationPoliciesUseGoImportSourceLocation(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "go.mod"), "module example.com/app\n\ngo 1.22\n") diff --git a/tests/checks/design_graph_languages_test.go b/tests/checks/design_graph_languages_test.go index c33a1eb..595bf5f 100644 --- a/tests/checks/design_graph_languages_test.go +++ b/tests/checks/design_graph_languages_test.go @@ -63,6 +63,101 @@ func TestDesignCheckPassesForAcyclicTypeScriptImports(t *testing.T) { assertFindingRuleAbsent(t, report, "Design Patterns", "design.typescript.import-cycle") } +func TestDesignCheckFailsForTypeScriptImportCycleThroughTSConfigPaths(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "tsconfig.json"), "{\n // comment to exercise JSONC parsing\n \"compilerOptions\": {\n \"baseUrl\": \".\",\n \"paths\": {\n \"@app/*\": [\"src/*\",],\n },\n },\n}\n") + writeFile(t, filepath.Join(dir, "src", "alpha.ts"), "import { beta } from \"@app/beta\";\n\nexport const alpha = () => beta();\n") + writeFile(t, filepath.Join(dir, "src", "beta.ts"), "import { alpha } from \"@app/alpha\";\n\nexport const beta = () => alpha();\n") + + report, err := codeguard.Run(context.Background(), graphTestConfig("design-ts-paths-cycle", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Design Patterns", "fail") + assertFindingRulePresent(t, report, "Design Patterns", "design.typescript.import-cycle") +} + +func TestDesignCheckFailsForTypeScriptImportCycleThroughExtendedTSConfigPaths(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "tsconfig.base.json"), "{\n \"compilerOptions\": {\n \"baseUrl\": \".\",\n \"paths\": {\n \"@app/*\": [\"app/src/*\"]\n }\n }\n}\n") + writeFile(t, filepath.Join(dir, "app", "tsconfig.json"), "{\n \"extends\": \"../tsconfig.base.json\"\n}\n") + writeFile(t, filepath.Join(dir, "app", "src", "alpha.ts"), "import { beta } from \"@app/beta\";\n\nexport const alpha = () => beta();\n") + writeFile(t, filepath.Join(dir, "app", "src", "beta.ts"), "import { alpha } from \"@app/alpha\";\n\nexport const beta = () => alpha();\n") + + report, err := codeguard.Run(context.Background(), graphTestConfig("design-ts-extended-paths-cycle", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Design Patterns", "fail") + assertFindingRulePresent(t, report, "Design Patterns", "design.typescript.import-cycle") +} + +func TestDesignCheckFailsForTypeScriptImportCycleThroughPackageExtendedTSConfigPaths(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "packages", "tsconfig", "package.json"), "{\n \"name\": \"@repo/tsconfig\"\n}\n") + writeFile(t, filepath.Join(dir, "packages", "tsconfig", "base.json"), "{\n \"compilerOptions\": {\n \"baseUrl\": \"../..\",\n \"paths\": {\n \"@app/*\": [\"app/src/*\"]\n }\n }\n}\n") + writeFile(t, filepath.Join(dir, "app", "tsconfig.json"), "{\n \"extends\": \"@repo/tsconfig/base.json\"\n}\n") + writeFile(t, filepath.Join(dir, "app", "src", "alpha.ts"), "import { beta } from \"@app/beta\";\n\nexport const alpha = () => beta();\n") + writeFile(t, filepath.Join(dir, "app", "src", "beta.ts"), "import { alpha } from \"@app/alpha\";\n\nexport const beta = () => alpha();\n") + + report, err := codeguard.Run(context.Background(), graphTestConfig("design-ts-package-extended-paths-cycle", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Design Patterns", "fail") + assertFindingRulePresent(t, report, "Design Patterns", "design.typescript.import-cycle") +} + +func TestDesignCheckFailsForTypeScriptWorkspacePackageCycle(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "packages", "alpha", "package.json"), "{\n \"name\": \"@repo/alpha\",\n \"main\": \"./src/index.ts\"\n}\n") + writeFile(t, filepath.Join(dir, "packages", "beta", "package.json"), "{\n \"name\": \"@repo/beta\",\n \"exports\": \"./src/index.ts\"\n}\n") + writeFile(t, filepath.Join(dir, "packages", "alpha", "src", "index.ts"), "import { beta } from \"@repo/beta\";\n\nexport const alpha = () => beta();\n") + writeFile(t, filepath.Join(dir, "packages", "beta", "src", "index.ts"), "import { alpha } from \"@repo/alpha\";\n\nexport const beta = () => alpha();\n") + + report, err := codeguard.Run(context.Background(), graphTestConfig("design-ts-workspace-cycle", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Design Patterns", "fail") + assertFindingRulePresent(t, report, "Design Patterns", "design.typescript.import-cycle") +} + +func TestDesignCheckPrefersSourceExportConditionsForWorkspacePackages(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "packages", "alpha", "package.json"), "{\n \"name\": \"@repo/alpha\",\n \"exports\": {\n \".\": {\n \"default\": \"./dist/index.js\",\n \"import\": \"./src/index.ts\"\n }\n }\n}\n") + writeFile(t, filepath.Join(dir, "packages", "beta", "package.json"), "{\n \"name\": \"@repo/beta\",\n \"main\": \"./src/index.ts\"\n}\n") + writeFile(t, filepath.Join(dir, "packages", "alpha", "src", "index.ts"), "import { beta } from \"@repo/beta\";\n\nexport const alpha = () => beta();\n") + writeFile(t, filepath.Join(dir, "packages", "beta", "src", "index.ts"), "import { alpha } from \"@repo/alpha\";\n\nexport const beta = () => alpha();\n") + + report, err := codeguard.Run(context.Background(), graphTestConfig("design-ts-conditional-exports-cycle", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Design Patterns", "fail") + assertFindingRulePresent(t, report, "Design Patterns", "design.typescript.import-cycle") +} + +func TestDesignCheckFailsForTypeScriptImportCycleThroughPackageImports(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "package.json"), "{\n \"name\": \"app\",\n \"imports\": {\n \"#core/*\": \"./src/*\"\n }\n}\n") + writeFile(t, filepath.Join(dir, "src", "alpha.ts"), "import { beta } from \"#core/beta\";\n\nexport const alpha = () => beta();\n") + writeFile(t, filepath.Join(dir, "src", "beta.ts"), "import { alpha } from \"#core/alpha\";\n\nexport const beta = () => alpha();\n") + + report, err := codeguard.Run(context.Background(), graphTestConfig("design-ts-package-imports-cycle", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Design Patterns", "fail") + assertFindingRulePresent(t, report, "Design Patterns", "design.typescript.import-cycle") +} + func TestDesignCheckFailsForRustModuleCycle(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "src", "lib.rs"), "mod reader;\nmod writer;\n") diff --git a/tests/checks/design_graph_policy_test.go b/tests/checks/design_graph_policy_test.go index fbddd9e..77192cb 100644 --- a/tests/checks/design_graph_policy_test.go +++ b/tests/checks/design_graph_policy_test.go @@ -58,6 +58,62 @@ func TestDesignReachabilityMatchesAnyFileInGoEntrypointPackage(t *testing.T) { } } +func TestDesignReachabilityFollowsWorkspacePackageExportSubpaths(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "main.ts"), `import "@repo/shared/token";`) + writeFile(t, filepath.Join(dir, "packages", "shared", "package.json"), "{\n \"name\": \"@repo/shared\",\n \"exports\": {\n \"./token\": \"./src/token.ts\"\n }\n}\n") + writeFile(t, filepath.Join(dir, "packages", "shared", "src", "token.ts"), `export const token = "ok";`) + writeFile(t, filepath.Join(dir, "packages", "shared", "src", "orphan.ts"), `export const orphan = true;`) + + cfg := designPolicyTestConfig(dir) + cfg.Targets[0].Entrypoints = []string{"src/main.ts"} + cfg.Checks.DesignRules.Reachability = &codeguard.DesignReachabilityConfig{} + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + finding := findFinding(t, report, "Design Patterns", "design.unreachable-module") + if finding.Path != "packages/shared/src/orphan.ts" { + t.Fatalf("unreachable finding path = %q, want packages/shared/src/orphan.ts", finding.Path) + } + for _, section := range report.Sections { + for _, candidate := range section.Findings { + if candidate.RuleID == "design.unreachable-module" && candidate.Path == "packages/shared/src/token.ts" { + t.Fatal("workspace export target imported from the entrypoint was reported unreachable") + } + } + } +} + +func TestDesignReachabilityFollowsWorkspacePackageWildcardExports(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "main.ts"), `import "@repo/shared/token";`) + writeFile(t, filepath.Join(dir, "packages", "shared", "package.json"), "{\n \"name\": \"@repo/shared\",\n \"exports\": {\n \"./*\": \"./src/*.ts\"\n }\n}\n") + writeFile(t, filepath.Join(dir, "packages", "shared", "src", "token.ts"), `export const token = "ok";`) + writeFile(t, filepath.Join(dir, "packages", "shared", "src", "orphan.ts"), `export const orphan = true;`) + + cfg := designPolicyTestConfig(dir) + cfg.Targets[0].Entrypoints = []string{"src/main.ts"} + cfg.Checks.DesignRules.Reachability = &codeguard.DesignReachabilityConfig{} + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + finding := findFinding(t, report, "Design Patterns", "design.unreachable-module") + if finding.Path != "packages/shared/src/orphan.ts" { + t.Fatalf("unreachable finding path = %q, want packages/shared/src/orphan.ts", finding.Path) + } + for _, section := range report.Sections { + for _, candidate := range section.Findings { + if candidate.RuleID == "design.unreachable-module" && candidate.Path == "packages/shared/src/token.ts" { + t.Fatal("workspace wildcard export target imported from the entrypoint was reported unreachable") + } + } + } +} + func TestDesignStabilityReportsDependencyTowardVolatileModule(t *testing.T) { dir := t.TempDir() for _, importer := range []string{"one", "two", "three"} { diff --git a/tests/checks/design_python_test.go b/tests/checks/design_python_test.go index 7f39784..d0b7a94 100644 --- a/tests/checks/design_python_test.go +++ b/tests/checks/design_python_test.go @@ -108,3 +108,71 @@ func TestDesignCheckPassesForLayeredPythonLayout(t *testing.T) { assertSectionStatus(t, report, "Design Patterns", "pass") } + +func TestDesignCheckWarnsForPythonClassWithTooManyMethods(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "service.py"), "class Service:\n def __init__(self):\n self.ready = True\n\n def a(self):\n return 1\n\n @property\n def b(self):\n return 2\n\n async def c(self):\n return 3\n") + + cfg := codeguard.ExampleConfig() + cfg.Name = "design-python-max-methods" + cfg.Targets = []codeguard.TargetConfig{{Name: "api", Path: dir, Language: "python"}} + cfg.Checks.Design = true + cfg.Checks.Quality = false + cfg.Checks.Security = false + cfg.Checks.Prompts = false + cfg.Checks.CI = false + cfg.Checks.DesignRules.MaxMethodsPerType = 2 + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Design Patterns", "warn") + assertFindingRulePresent(t, report, "Design Patterns", "design.python.max-methods-per-type") +} + +func TestDesignCheckWarnsForLargePythonProtocol(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "ports.py"), "from typing import Protocol\n\nclass Store(\n Protocol,\n):\n name: str\n enabled: bool\n\n def get(self) -> str:\n ...\n") + + cfg := codeguard.ExampleConfig() + cfg.Name = "design-python-max-protocol" + cfg.Targets = []codeguard.TargetConfig{{Name: "api", Path: dir, Language: "python"}} + cfg.Checks.Design = true + cfg.Checks.Quality = false + cfg.Checks.Security = false + cfg.Checks.Prompts = false + cfg.Checks.CI = false + cfg.Checks.DesignRules.MaxInterfaceMethods = 2 + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Design Patterns", "warn") + assertFindingRulePresent(t, report, "Design Patterns", "design.python.max-protocol-members") +} + +func TestDesignCheckIgnoresNestedPythonHelpersWhenCountingClassMethods(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "service.py"), "class Service:\n def a(self):\n def helper():\n return 1\n return helper()\n\n class Nested:\n def hidden(self):\n return 1\n\n def b(self):\n return 2\n") + + cfg := codeguard.ExampleConfig() + cfg.Name = "design-python-nested-methods" + cfg.Targets = []codeguard.TargetConfig{{Name: "api", Path: dir, Language: "python"}} + cfg.Checks.Design = true + cfg.Checks.Quality = false + cfg.Checks.Security = false + cfg.Checks.Prompts = false + cfg.Checks.CI = false + cfg.Checks.DesignRules.MaxMethodsPerType = 2 + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Design Patterns", "pass") +} diff --git a/tests/checks/design_rust_test.go b/tests/checks/design_rust_test.go new file mode 100644 index 0000000..f2be9aa --- /dev/null +++ b/tests/checks/design_rust_test.go @@ -0,0 +1,144 @@ +package checks_test + +import ( + "context" + "path/filepath" + "strings" + "testing" + + designpkg "github.com/devr-tools/codeguard/internal/codeguard/checks/design" + supportpkg "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func TestRustDesignFindingsWarnForGenericModuleName(t *testing.T) { + findings := designpkg.RustFindingsForFile(rustDesignTestEnv(4, 4), "src/utils.rs", []byte("pub fn answer() -> i32 { 42 }\n")) + + assertRustFindingRulePresent(t, findings, "design.rust.generic-module-name") +} + +func TestRustDesignFindingsWarnForTooManyImplMethodsAcrossBlocks(t *testing.T) { + source := "" + + "pub struct Service;\n" + + "\n" + + "impl Service {\n" + + " fn one(&self) {}\n" + + " fn two(&self) {}\n" + + "}\n" + + "\n" + + "impl Service {\n" + + " fn three(&self) {}\n" + + "}\n" + + findings := designpkg.RustFindingsForFile(rustDesignTestEnv(2, 4), "src/service.rs", []byte(source)) + + assertRustFindingRulePresent(t, findings, "design.rust.max-methods-per-type") + assertRustFindingMessageContains(t, findings, "Service", "3 impl methods") +} + +func TestRustDesignFindingsWarnForLargeTraitSurface(t *testing.T) { + source := "" + + "pub trait Client {\n" + + " type Item;\n" + + " const LIMIT: usize;\n" + + " fn run(&self);\n" + + "}\n" + + findings := designpkg.RustFindingsForFile(rustDesignTestEnv(4, 2), "src/client.rs", []byte(source)) + + assertRustFindingRulePresent(t, findings, "design.rust.max-trait-members") + assertRustFindingMessageContains(t, findings, "Client", "3 members") +} + +func TestRustDesignFindingsPassForCrateRootAndSmallSurfaces(t *testing.T) { + source := "" + + "pub trait Client {\n" + + " type Item;\n" + + " fn run(&self);\n" + + "}\n" + + "\n" + + "pub struct Service;\n" + + "\n" + + "impl Service {\n" + + " fn one(&self) {}\n" + + " fn two(&self) {}\n" + + "}\n" + + findings := designpkg.RustFindingsForFile(rustDesignTestEnv(2, 2), "src/lib.rs", []byte(source)) + + if len(findings) != 0 { + t.Fatalf("expected no findings, got %+v", findings) + } +} + +func TestDesignCheckWarnsForRustTypeSurfaceViaCodeguardRun(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "ports.rs"), "pub trait Client {\n type Item;\n const LIMIT: usize;\n fn run(&self);\n}\n") + writeFile(t, filepath.Join(dir, "src", "service.rs"), "pub struct Service;\n\nimpl Service {\n fn one(&self) {}\n fn two(&self) {}\n fn three(&self) {}\n}\n") + + cfg := graphTestConfig("design-rust-native-heuristics", dir, "rust") + cfg.Checks.DesignRules.MaxMethodsPerType = 2 + cfg.Checks.DesignRules.MaxInterfaceMethods = 2 + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Design Patterns", "warn") + assertFindingRulePresent(t, report, "Design Patterns", "design.rust.max-trait-members") + assertFindingRulePresent(t, report, "Design Patterns", "design.rust.max-methods-per-type") +} + +func rustDesignTestEnv(maxMethods int, maxTraitMembers int) supportpkg.Context { + return supportpkg.Context{ + Config: core.Config{ + Checks: core.CheckConfig{ + DesignRules: core.DesignRulesConfig{ + MaxMethodsPerType: maxMethods, + MaxInterfaceMethods: maxTraitMembers, + ForbiddenPackageNames: []string{"util", "utils", "common"}, + }, + }, + }, + NewFinding: func(input supportpkg.FindingInput) core.Finding { + return core.Finding{ + RuleID: input.RuleID, + Level: input.Level, + Message: input.Message, + Path: input.Path, + Line: input.Line, + Column: input.Column, + Confidence: input.Confidence, + } + }, + } +} + +func assertRustFindingRulePresent(t *testing.T, findings []core.Finding, ruleID string) { + t.Helper() + for _, finding := range findings { + if finding.RuleID == ruleID { + return + } + } + t.Fatalf("expected rule %q in findings %+v", ruleID, findings) +} + +func assertRustFindingMessageContains(t *testing.T, findings []core.Finding, needles ...string) { + t.Helper() + for _, finding := range findings { + matched := true + for _, needle := range needles { + if !strings.Contains(finding.Message, needle) { + matched = false + break + } + } + if matched { + return + } + } + t.Fatalf("expected finding message containing %q, got %+v", needles, findings) +} diff --git a/tests/checks/design_test.go b/tests/checks/design_test.go index 9346a1c..2e01b33 100644 --- a/tests/checks/design_test.go +++ b/tests/checks/design_test.go @@ -162,6 +162,29 @@ func TestDesignCheckWarnsForLargeInterface(t *testing.T) { assertSectionStatus(t, report, "Design Patterns", "warn") } +func TestDesignCheckWarnsForTooManyGoDeclsPerFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "pkg", "codeguard", "decls.go"), "package codeguard\n\nconst A = 1\nconst B = 2\nconst C = 3\nconst D = 4\n") + + cfg := codeguard.ExampleConfig() + cfg.Name = "design-go-decls" + cfg.Targets = []codeguard.TargetConfig{{Name: "repo", Path: dir, Language: "go"}} + cfg.Checks.Design = true + cfg.Checks.Quality = false + cfg.Checks.Security = false + cfg.Checks.Prompts = false + cfg.Checks.CI = false + cfg.Checks.DesignRules.MaxDeclsPerFile = 3 + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Design Patterns", "warn") + assertFindingRulePresent(t, report, "Design Patterns", "design.max-decls-per-file") +} + func TestDesignCheckFailsForConfiguredTypeScriptCommand(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "src", "index.ts"), "export const answer = 42;\n") From f25fdbc0401cb2de9bd8bcfd3e2a271f2b7136fd Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Fri, 17 Jul 2026 14:38:57 -0400 Subject: [PATCH 2/6] Add C++ design graph policy coverage --- tests/checks/design_graph_policy_test.go | 63 ++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/checks/design_graph_policy_test.go b/tests/checks/design_graph_policy_test.go index 77192cb..f6a1831 100644 --- a/tests/checks/design_graph_policy_test.go +++ b/tests/checks/design_graph_policy_test.go @@ -114,6 +114,40 @@ func TestDesignReachabilityFollowsWorkspacePackageWildcardExports(t *testing.T) } } +func TestDesignReachabilityFollowsCPPNamedModuleGraph(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "main.cpp"), "#include \"app.cppm\"\nint main() { return app_value(); }\n") + writeFile(t, filepath.Join(dir, "src", "app.cppm"), "export module app;\nimport app.shared;\nexport int app_value();\n") + writeFile(t, filepath.Join(dir, "src", "shared.cppm"), "export module app.shared;\nimport :detail;\nexport int shared_value();\n") + writeFile(t, filepath.Join(dir, "src", "shared-detail.cppm"), "module app.shared:detail;\nexport int detail_value();\n") + writeFile(t, filepath.Join(dir, "src", "orphan.cppm"), "export module app.orphan;\nexport int orphan_value();\n") + + cfg := designPolicyTestConfig(dir) + cfg.Targets[0].Language = "cpp" + cfg.Targets[0].Entrypoints = []string{"src/main.cpp"} + cfg.Checks.DesignRules.Reachability = &codeguard.DesignReachabilityConfig{} + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + finding := findFinding(t, report, "Design Patterns", "design.unreachable-module") + if finding.Path != "src/orphan.cppm" { + t.Fatalf("unreachable finding path = %q, want src/orphan.cppm", finding.Path) + } + for _, section := range report.Sections { + for _, candidate := range section.Findings { + if candidate.RuleID != "design.unreachable-module" { + continue + } + switch candidate.Path { + case "src/app.cppm", "src/shared.cppm", "src/shared-detail.cppm": + t.Fatalf("reachable C++ module was reported unreachable: %+v", candidate) + } + } + } +} + func TestDesignStabilityReportsDependencyTowardVolatileModule(t *testing.T) { dir := t.TempDir() for _, importer := range []string{"one", "two", "three"} { @@ -146,6 +180,35 @@ import "./leaf-four"; } } +func TestDesignStabilityReportsDependencyTowardVolatileCPPNamedModule(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "one.cppm"), "export module app.one;\nimport app.stable;\nexport int one();\n") + writeFile(t, filepath.Join(dir, "src", "two.cppm"), "export module app.two;\nimport app.stable;\nexport int two();\n") + writeFile(t, filepath.Join(dir, "src", "three.cppm"), "export module app.three;\nimport app.stable;\nexport int three();\n") + writeFile(t, filepath.Join(dir, "src", "stable.cppm"), "export module app.stable;\nimport app.volatile;\nexport int stable();\n") + writeFile(t, filepath.Join(dir, "src", "volatile.cppm"), "export module app.volatile;\nimport app.leaf_one;\nimport app.leaf_two;\nimport app.leaf_three;\nimport app.leaf_four;\nexport int volatile_value();\n") + writeFile(t, filepath.Join(dir, "src", "leaf_one.cppm"), "export module app.leaf_one;\nexport int leaf_one();\n") + writeFile(t, filepath.Join(dir, "src", "leaf_two.cppm"), "export module app.leaf_two;\nexport int leaf_two();\n") + writeFile(t, filepath.Join(dir, "src", "leaf_three.cppm"), "export module app.leaf_three;\nexport int leaf_three();\n") + writeFile(t, filepath.Join(dir, "src", "leaf_four.cppm"), "export module app.leaf_four;\nexport int leaf_four();\n") + + cfg := designPolicyTestConfig(dir) + cfg.Targets[0].Language = "cpp" + cfg.Checks.DesignRules.Stability = &codeguard.DesignStabilityConfig{ + MinimumFanIn: 3, + MaxInstabilityDelta: 0.30, + } + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + finding := findFinding(t, report, "Design Patterns", "design.stability-direction") + if finding.Path != "src/stable.cppm" || finding.Line != 2 { + t.Fatalf("stability finding location = %s:%d, want src/stable.cppm:2", finding.Path, finding.Line) + } +} + func TestDesignGraphPoliciesCanBeDisabled(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "src", "main.ts"), `export const main = true;`) From 965ddb4faab99833ed084437b3c8e819ba05421e Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Fri, 17 Jul 2026 14:51:19 -0400 Subject: [PATCH 3/6] Refresh production and checks documentation --- README.md | 18 +++++ docs/README.md | 1 + docs/checks.md | 34 ++++++-- docs/features.md | 7 +- docs/getting-started.md | 21 +++++ docs/production.md | 172 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 247 insertions(+), 6 deletions(-) create mode 100644 docs/production.md diff --git a/README.md b/README.md index 5407414..c7fbd97 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,23 @@ Text output includes ANSI color and emoji markers by default. Set `NO_COLOR=1` i If you want a JSON starting point instead, use [examples/codeguard.json](examples/codeguard.json). +## Production Use + +For production rollout, start in a narrow mode and expand deliberately: + +1. Run `codeguard doctor` and `codeguard validate` in CI first so config and toolchain issues fail early. +2. Start with `codeguard scan -mode diff` on pull requests so only changed lines and diff-aware checks gate merges. +3. Create a baseline for legacy findings with `codeguard baseline` before turning on full-repo enforcement. +4. Enable stricter families such as `design`, `security`, `contracts`, `performance`, and `supply_chain` incrementally per repository. +5. Use `codeguard rules` and `codeguard explain ` to document what a failure means before asking teams to act on it. + +When a scan fails: + +- `fail` findings should block merge or release until fixed, waived, or baselined intentionally. +- `warn` findings are non-blocking by default and are best used to drive cleanup, ownership, or gradual policy hardening. +- section names such as `Design Patterns`, `Security`, or `Code Quality` tell you what kind of action is expected. +- rule IDs are stable handles for waivers, baselines, dashboards, and agent workflows. + ## SDK Import the SDK from `github.com/devr-tools/codeguard/pkg/codeguard`. @@ -130,6 +147,7 @@ func main() { ## Docs - [Getting started](docs/getting-started.md) +- [Production rollout](docs/production.md) - [Features](docs/features.md) - [Security & OWASP](docs/security.md) - [AI-generated code quality](docs/ai-quality.md) diff --git a/docs/README.md b/docs/README.md index 8b4101b..208810b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,7 @@ # CodeGuard Docs - [Getting started](getting-started.md) +- [Production rollout](production.md) - [Features](features.md) - [AI-generated code quality](ai-quality.md) - [Agent-native features](agent-native.md) diff --git a/docs/checks.md b/docs/checks.md index 9a964f1..4ee8929 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -2,6 +2,26 @@ This file documents the current check categories in `codeguard` and the config keys that control them. +## How to interpret results + +When `codeguard` reports a finding, read it in this order: + +1. Section: tells you the policy family, such as `Security`, `Design Patterns`, or `Code Quality`. +2. Level: `fail` is intended to block; `warn` is intended to surface risk without blocking by default. +3. Rule ID: the stable identifier for waivers, baselines, dashboards, and `codeguard explain`. +4. Message: the repository-specific explanation for the concrete file and location. + +Useful commands: + +```bash +codeguard rules +codeguard explain design.layer-boundary +codeguard explain quality.ai.semantic-runtime +``` + +Use `codeguard rules` to discover what exists and `codeguard explain ` to +understand why a specific finding failed and what remediation path it expects. + ## Top-level shape ```json @@ -231,7 +251,7 @@ Current inference behavior: | Family | Go | C++ | Python | TypeScript | Rust | Java | C# | Ruby | | --- | --- | --- | --- | --- | --- | --- | --- | --- | | Quality | `gofmt`, parseability, maintainability thresholds | maintainability thresholds across sources, headers, templates, and modules; optional `clang-format` and sanitized `clang++` validation | maintainability thresholds | maintainability thresholds, `@ts-ignore`, `@ts-nocheck`, `@ts-expect-error`, `explicit any`, double assertions, non-null assertions, `debugger` statements | maintainability thresholds | maintainability thresholds | maintainability thresholds | maintainability thresholds | -| Design | boundary rules, generic package names, type/interface/file-size heuristics | include/module cycles, graph impact, generic filenames, qualified method counts | public-imports-private, public-imports-cli, generic module names | generic module names, max methods per class, max members per interface/object type | module cycles and graph impact | import cycles and graph impact | - | - | +| Design | package boundary rules, generic package names, declarations per file, methods per type, interface size, graph reachability/stability/impact | include and named-module cycles, reachability, stability, graph impact, generic filenames, declarations per file, method counts, contract surface checks, boundary-policy enforcement | public/private and entrypoint coupling, import cycles, generic module names, methods per type, protocol size | generic module names, max methods per class, max members per interface/object type, graph resolution through `tsconfig` paths, package `imports`, and workspace package exports | module cycles, graph impact, generic module names, methods per type, trait size | import cycles and graph impact | - | - | | Security | insecure TLS, shell execution review, optional `govulncheck` | insecure TLS, shell execution review, unsafe C string APIs, taint flow, SSRF | insecure TLS, shell execution review, dynamic code | insecure TLS, shell execution review, dynamic code, string timer execution, wildcard `postMessage`, Node `vm` execution, unsafe HTML sinks | insecure TLS, shell execution review | insecure TLS, shell execution review | insecure TLS, shell execution review | insecure TLS, shell execution review, dynamic code | | Commands | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | @@ -830,13 +850,17 @@ Current behavior: - fails on configured layer, domain, data-ownership, capability, public-surface, and production/test boundary violations - can require every module to belong to a configured layer and domain - warns on configured unreachable modules and stability-direction inversions -- Go targets keep the existing package, import-boundary, declaration-count, type-size, and interface-size heuristics -- Python targets fail on public-to-private imports, direct or transitive entrypoint coupling, and internal import cycles, and warn on overly generic module names -- TypeScript targets warn on overly generic module names, oversized classes, and oversized interfaces or object types using compiler-parsed AST analysis when the semantic runtime is available -- C++ targets build a target-local include/named-module graph for cycles, god modules, and diff impact; they also warn on generic filenames and excessive qualified out-of-line methods for one type +- Go targets keep package and import-boundary checks and warn on declarations per file, methods per type, and oversized interfaces +- Python targets fail on public-to-private imports, direct or transitive entrypoint coupling, and internal import cycles, and warn on overly generic module names, oversized classes, and oversized protocols +- TypeScript targets warn on overly generic module names, oversized classes, and oversized interfaces or object types, while the graph resolver follows relative imports, `tsconfig` aliases and extends chains, package `imports`, and workspace package exports +- Rust targets build a module graph for cycles and impact and warn on generic module names, oversized impls, and oversized traits +- C++ targets build a target-local include/named-module graph for cycles, god modules, reachability, stability, diff impact, and boundary policy enforcement; they also warn on generic filenames, declarations per file, excessive methods on one type, and oversized contract surfaces - can run language-specific design commands based on `targets[].language` - language command failures surface as `design.command-check` +For a production rollout sequence and guidance on when to use baselines, waivers, +or diff scans, see [Production rollout](production.md). + Language command example: ```json diff --git a/docs/features.md b/docs/features.md index 6d817bf..f9157f4 100644 --- a/docs/features.md +++ b/docs/features.md @@ -14,8 +14,13 @@ This page lists the current `codeguard` feature surface and the main config entr - `design` - layering and boundary rules - import cycle and god-module detection + - reachability and stability policy warnings over language graphs - high-impact-change analysis and dependency graph artifacts - - C++ target-local include and named-module graphs, generic filename checks, and qualified method-count limits + - Go package-boundary checks plus declarations-per-file, methods-per-type, and interface-size heuristics + - Python public/private and entrypoint coupling checks, import-cycle detection, generic module names, class-size heuristics, and protocol-size heuristics + - TypeScript generic-module, class-size, and interface-size heuristics plus graph resolution through relative imports, `tsconfig` paths, package `imports`, and workspace package exports + - Rust module graphs plus generic-module, methods-per-type, and trait-size heuristics + - C++ target-local include and named-module graphs for cycles, reachability, stability, change impact, and boundary policy enforcement, plus generic filename, declarations-per-file, method-count, and contract-surface heuristics - `security` - hardcoded secrets and private keys - Go, Python, TypeScript, and JavaScript taint-style flow checks diff --git a/docs/getting-started.md b/docs/getting-started.md index 3ed8c18..d83d4e7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -44,6 +44,26 @@ codeguard baseline -config codeguard.yaml -output codeguard-baseline.json If you prefer a JSON example, start from [examples/codeguard.json](/Users/alex/Documents/GitHub/codeguard/examples/codeguard.json:1). +## First production setup + +Use this sequence for a real repository: + +1. `codeguard init` +2. `codeguard validate -config codeguard.yaml` +3. `codeguard doctor -config codeguard.yaml` +4. `codeguard scan -mode diff -config codeguard.yaml` +5. `codeguard baseline -config codeguard.yaml -output codeguard-baseline.json` if the repo has pre-existing debt + +After that, add a full scan in scheduled CI and tighten check families +incrementally. The detailed rollout guidance lives in [Production rollout](production.md). + +## Understanding results + +- `fail` findings are intended to block until fixed, waived, or baselined. +- `warn` findings are advisory by default and are best used for gradual cleanup. +- `codeguard rules` lists every rule with section, level, execution model, and language coverage. +- `codeguard explain ` explains what a specific failed check means and how to fix it. + ## SDK import path The public SDK import path is: @@ -86,5 +106,6 @@ func main() { - `doctor` checks for config, Git, govulncheck, targets, and cache setup - `text`, `json`, `sarif`, and `github` report formats - Diff-mode filtering down to changed lines when Git history is available +- Production rollout tools such as baselines, waivers, diff scans, and rule metadata discovery Language-specific engines can be added later without changing the repo shape. diff --git a/docs/production.md b/docs/production.md new file mode 100644 index 0000000..098f38d --- /dev/null +++ b/docs/production.md @@ -0,0 +1,172 @@ +# Production Rollout + +This guide is for teams using `codeguard` as a real merge gate, scheduled audit, +or agent-facing policy service. + +## Goals + +In production, `codeguard` should do three things well: + +- block genuinely risky changes with low ambiguity +- keep historical debt from drowning out new regressions +- make findings understandable enough that humans and agents can act on them + +## Recommended rollout order + +1. Validate the environment. + + Run: + + ```bash + codeguard validate -config codeguard.yaml + codeguard doctor -config codeguard.yaml + ``` + + This catches broken config, missing binaries, target-path issues, and optional + command integrations before they fail inside CI. + +2. Start with pull-request diff scans. + + Run `codeguard scan -mode diff` in CI first. This keeps the initial gate tight + around changed code instead of failing the whole repository for historical debt. + +3. Capture legacy findings in a baseline. + + If the repository already has known issues, write a baseline and check it in: + + ```bash + codeguard baseline -config codeguard.yaml -output codeguard-baseline.json + ``` + + Then reference it from config so new regressions still fail while existing debt + stays visible but suppressed. + +4. Add full-repository scans. + + Keep diff scans as the merge gate, then add a scheduled or pre-release full scan + to catch cross-repo issues that are not visible from a small diff. + +5. Tighten by family, not all at once. + + A practical order is: + + - `security` + - `quality` + - `ci` + - `design` + - `contracts` + - `performance` + - `supply_chain` + + That ordering usually gives the fastest signal-to-noise improvement. + +## How to read a failed scan + +Every finding has four pieces that matter operationally: + +- section: the owning area, such as `Security`, `Design Patterns`, or `Code Quality` +- rule ID: the stable identifier, such as `design.layer-boundary` +- level: `fail` or `warn` +- message: the repository-specific explanation with path and location + +Use them this way: + +- `fail` means the finding is intended to block merge or release unless it is fixed, + waived, or suppressed through an explicit baseline. +- `warn` means the scan is surfacing risk or cleanup work without blocking by default. +- the rule ID is what you use in waivers, dashboards, automation, and `codeguard explain`. +- the message is optimized for the concrete instance, not the whole policy; read the + rule explanation when you need broader context. + +Helpful commands: + +```bash +codeguard rules +codeguard explain design.layer-boundary +codeguard explain security.hardcoded-credential +``` + +`codeguard rules` is best for catalog discovery. `codeguard explain` is best when a +team or agent needs the meaning of one specific failure. + +## Choosing what should block + +Use blocking failures for: + +- credential leaks +- contract breaks +- architecture violations with clear ownership boundaries +- unsafe prompt or MCP config patterns +- CI policy requirements + +Use warnings for: + +- maintainability drift +- cleanup-oriented design heuristics +- stability and reachability nudges +- performance smells that still need human review + +If a rule is too noisy, do not normalize ignoring it. Either tune the config, +baseline the current debt, or disable that rule family intentionally. + +## Baselines, waivers, and policy discipline + +Use each mechanism for a different purpose: + +- baseline: repository already has known findings; suppress them so only new + regressions gate progress +- waiver: a specific known exception with a reason and optional expiry +- config change: the rule truly does not fit the repository's architecture or risk model + +Good production hygiene: + +- keep waivers narrow by rule and path +- add reasons that another engineer can evaluate later +- expire temporary waivers +- review baselines periodically so they do not become permanent blind spots + +## Suggested CI pattern + +For most teams: + +- pull requests: `codeguard scan -mode diff` +- nightly or scheduled: `codeguard scan` +- release branches: `codeguard scan` plus contracts and supply-chain enforcement + +Prefer SARIF or GitHub output when you want code-host annotations, and JSON when +another system or agent will consume the report programmatically. + +## Human and agent workflows + +For humans: + +- treat the section as the routing signal +- treat the rule ID as the policy anchor +- treat the message as the local debugging hint + +For agents: + +- fetch rule metadata through `codeguard explain ` or the MCP `explain` tool +- preserve the rule ID in summaries and fix proposals +- avoid collapsing `warn` and `fail` into one severity bucket +- use file path and line as the patch target, but use the rule explanation to avoid + fixing the symptom while preserving the policy violation + +## Design policy in production + +Design checks are most effective when introduced in layers: + +1. Start with graph warnings such as cycles, reachability, and stability. +2. Add public-surface and production/test isolation policies. +3. Add layer, domain, capability, and data-ownership boundaries once the + repository's intended architecture is explicit. + +This is especially important in mixed-language repositories where Go, TypeScript, +Python, Rust, and C++ may have different maturity levels in their local module layout. + +## References + +- [Getting started](getting-started.md) +- [Checks reference](checks.md) +- [Features](features.md) +- [Integrations](integrations.md) From 80c92885055487fb9dd58d184fdbe0322896f916 Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Fri, 17 Jul 2026 15:13:15 -0400 Subject: [PATCH 4/6] Fix design lint regressions --- internal/codeguard/checks/design/design_cpp.go | 8 ++++---- .../checks/design/design_graph_typescript_resolver.go | 8 ++------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/internal/codeguard/checks/design/design_cpp.go b/internal/codeguard/checks/design/design_cpp.go index 10dd9a4..a6a8968 100644 --- a/internal/codeguard/checks/design/design_cpp.go +++ b/internal/codeguard/checks/design/design_cpp.go @@ -232,15 +232,15 @@ func cppMethodKey(line string, typeName string) (string, bool) { if name != typeName && name != "~"+typeName && strings.Contains(name, "::") { name = name[strings.LastIndex(name, "::")+2:] } - close := cppMatchingParen(trimmed, open) - if close < 0 { + closePos := cppMatchingParen(trimmed, open) + if closePos < 0 { return "", false } - trailer := strings.TrimSpace(trimmed[close+1:]) + trailer := strings.TrimSpace(trimmed[closePos+1:]) if strings.HasPrefix(trailer, "->") { return "", false } - params := cppSquashWhitespace(trimmed[open+1 : close]) + params := cppSquashWhitespace(trimmed[open+1 : closePos]) return name + "(" + params + ")", true } diff --git a/internal/codeguard/checks/design/design_graph_typescript_resolver.go b/internal/codeguard/checks/design/design_graph_typescript_resolver.go index 8fe1892..91d758e 100644 --- a/internal/codeguard/checks/design/design_graph_typescript_resolver.go +++ b/internal/codeguard/checks/design/design_graph_typescript_resolver.go @@ -227,16 +227,12 @@ func (resolver *typeScriptImportResolver) resolveTSConfigExtends(dir string, val if !strings.HasSuffix(value, ".json") { candidates = append(candidates, normalizeTypeScriptPath(path.Join(dir, value+".json"))) if !strings.HasPrefix(value, ".") && !strings.HasPrefix(value, "/") { - for _, candidate := range resolver.workspaceTSConfigCandidates(value + ".json") { - candidates = append(candidates, candidate) - } + candidates = append(candidates, resolver.workspaceTSConfigCandidates(value+".json")...) } } candidates = append(candidates, normalizeTypeScriptPath(path.Join(dir, value, "tsconfig.json"))) if !strings.HasPrefix(value, ".") && !strings.HasPrefix(value, "/") { - for _, candidate := range resolver.workspaceTSConfigCandidates(path.Join(value, "tsconfig.json")) { - candidates = append(candidates, candidate) - } + candidates = append(candidates, resolver.workspaceTSConfigCandidates(path.Join(value, "tsconfig.json"))...) } for _, candidate := range candidates { if _, ok := resolver.tsconfigs[candidate]; ok { From b746552e86f0b14abf1e3d977ce3f7af514b0e1f Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Fri, 17 Jul 2026 15:22:55 -0400 Subject: [PATCH 5/6] Add missing design fix templates --- internal/codeguard/rules/catalog_fix_templates_design.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/codeguard/rules/catalog_fix_templates_design.go b/internal/codeguard/rules/catalog_fix_templates_design.go index 93650a8..c6ae178 100644 --- a/internal/codeguard/rules/catalog_fix_templates_design.go +++ b/internal/codeguard/rules/catalog_fix_templates_design.go @@ -36,7 +36,14 @@ var designFixTemplates = map[string]core.FixTemplate{ "design.java.import-cycle": {Kind: guided, Text: "Break the cycle with an interface owned by the lower-level package.\n\nBefore:\n// billing.Invoice imports shipping.RateTable; shipping.RateTable imports billing.Invoice\n\nAfter:\n// billing defines interface RateSource; shipping implements it\n// billing no longer imports shipping, and shipping depends only on the interface"}, "design.cpp.import-cycle": {Kind: guided, Text: "Break the include/module cycle by moving shared declarations into a lower-level header or depending on an abstract interface.\n\nBefore:\n// order.hpp includes user.hpp; user.hpp includes order.hpp\n\nAfter:\n// use forward declarations where possible, or move shared declarations to model.hpp"}, "design.cpp.generic-module-name": {Kind: guided, Text: "Rename the C++ file after the component it owns.\n\nBefore:\n// src/utils.cpp\n\nAfter:\n// src/date_format.cpp"}, + "design.cpp.max-decls-per-file": {Kind: guided, Text: "Split unrelated C++ declarations into smaller translation units or headers with clearer ownership.\n\nBefore:\n// widget.cpp defines multiple types, helpers, and entrypoints together\n\nAfter:\n// widget_types.hpp, widget_parse.cpp, and widget_render.cpp each own one concern"}, + "design.cpp.max-interface-methods": {Kind: guided, Text: "Break the public C++ contract into smaller focused surfaces.\n\nBefore:\nclass Store {\npublic:\n User get_user(Id id);\n void save_user(const User& user);\n Order get_order(Id id);\n void save_order(const Order& order);\n // ...more\n};\n\nAfter:\nclass UserStore {\npublic:\n User get_user(Id id);\n void save_user(const User& user);\n};\n\nclass OrderStore {\npublic:\n Order get_order(Id id);\n void save_order(const Order& order);\n};"}, "design.cpp.max-methods-per-type": {Kind: guided, Text: "Split the C++ type's responsibilities across smaller collaborators.\n\nBefore:\nclass Service { /* methods for auth, billing, and reporting */ };\n\nAfter:\nclass AuthService;\nclass BillingService;\nclass ReportService;"}, "design.god-module": {Kind: guided, Text: "Split the module into focused pieces and route consumers through narrower interfaces.\n\nBefore:\n// core.ts: imported by 40 modules and importing 25 others\n\nAfter:\n// split into config.ts, events.ts, and store.ts\n// consumers import only the piece they actually use"}, "design.high-impact-change": {Kind: guided, Text: "Reduce the blast radius before merging a change to a widely depended-on module.\n\nBefore:\n// one commit rewrites shared/http.ts, which 60 modules transitively depend on\n\nAfter:\n// land a backwards-compatible refactor first, then migrate dependents in batches\n// add tests for the highest-traffic dependents before the breaking step"}, + "design.python.max-methods-per-type": {Kind: guided, Text: "Split the Python class into smaller collaborators with one responsibility each.\n\nBefore:\nclass ReportService:\n # methods for auth, loading, formatting, and delivery\n ...\n\nAfter:\nclass ReportLoader: ...\nclass ReportFormatter: ...\nclass ReportPublisher: ..."}, + "design.python.max-protocol-members": {Kind: guided, Text: "Break the Protocol into smaller focused contracts and depend on the narrowest one each caller needs.\n\nBefore:\nclass Store(Protocol):\n def get_user(self, id: str) -> User: ...\n def save_user(self, user: User) -> None: ...\n def get_order(self, id: str) -> Order: ...\n def save_order(self, order: Order) -> None: ...\n\nAfter:\nclass UserStore(Protocol):\n def get_user(self, id: str) -> User: ...\n def save_user(self, user: User) -> None: ...\n\nclass OrderStore(Protocol):\n def get_order(self, id: str) -> Order: ...\n def save_order(self, order: Order) -> None: ..."}, + "design.rust.generic-module-name": {Kind: guided, Text: "Rename the Rust module after the responsibility it owns.\n\nBefore:\n// src/utils.rs\n\nAfter:\n// src/date_format.rs"}, + "design.rust.max-methods-per-type": {Kind: guided, Text: "Split the Rust type across smaller collaborators, traits, or modules.\n\nBefore:\nstruct Service;\nimpl Service {\n // auth, billing, and reporting methods all live here\n}\n\nAfter:\nstruct AuthService;\nstruct BillingService;\nstruct ReportService;"}, + "design.rust.max-trait-members": {Kind: guided, Text: "Break the Rust trait into smaller focused contracts and accept only the capability each consumer needs.\n\nBefore:\ntrait Store {\n fn get_user(&self, id: Id) -> User;\n fn save_user(&self, user: User);\n fn get_order(&self, id: Id) -> Order;\n fn save_order(&self, order: Order);\n}\n\nAfter:\ntrait UserStore {\n fn get_user(&self, id: Id) -> User;\n fn save_user(&self, user: User);\n}\n\ntrait OrderStore {\n fn get_order(&self, id: Id) -> Order;\n fn save_order(&self, order: Order);\n}"}, } From cd9643b413922c9c1135b34716f3d2573aeef84c Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Fri, 17 Jul 2026 15:55:26 -0400 Subject: [PATCH 6/6] Refactor design checks and pin stable CI action --- .github/workflows/ci.yml | 9 +- README.md | 2 +- docs/getting-started.md | 2 +- docs/integrations.md | 4 +- .../codeguard/checks/design/design_cpp.go | 313 -------- .../checks/design/design_cpp_blocks.go | 162 +++++ .../checks/design/design_cpp_parse.go | 164 +++++ .../checks/design/design_graph_cpp.go | 26 +- .../checks/design/design_graph_typescript.go | 6 +- .../design_graph_typescript_resolver.go | 685 ------------------ ...design_graph_typescript_resolver_config.go | 147 ++++ .../design_graph_typescript_resolver_index.go | 73 ++ .../design_graph_typescript_resolver_jsonc.go | 124 ++++ ...sign_graph_typescript_resolver_packages.go | 119 +++ .../design_graph_typescript_resolver_paths.go | 95 +++ ...esign_graph_typescript_resolver_resolve.go | 164 +++++ .../checks/design/design_python_structure.go | 220 ------ .../design/design_python_structure_helpers.go | 100 +++ .../design_python_structure_patterns.go | 9 + .../design/design_python_structure_scan.go | 148 ++++ .../codeguard/checks/design/design_rust.go | 241 +----- .../checks/design/design_rust_blocks.go | 147 ++++ .../checks/design/design_rust_names.go | 62 ++ .../checks/design/design_rust_patterns.go | 10 + .../checks/support/cpp_dependency_graph.go | 34 +- .../codeguard/checks/support/cpp_modules.go | 33 + tests/checks/design_graph_helpers_test.go | 35 + .../design_graph_languages_native_test.go | 65 ++ tests/checks/design_graph_languages_test.go | 84 --- tests/checks/report_features_test.go | 184 +---- tests/checks/report_format_helpers_test.go | 86 +++ tests/checks/report_layout_fixtures_test.go | 107 +++ 32 files changed, 1892 insertions(+), 1768 deletions(-) create mode 100644 internal/codeguard/checks/design/design_cpp_blocks.go create mode 100644 internal/codeguard/checks/design/design_cpp_parse.go create mode 100644 internal/codeguard/checks/design/design_graph_typescript_resolver_config.go create mode 100644 internal/codeguard/checks/design/design_graph_typescript_resolver_index.go create mode 100644 internal/codeguard/checks/design/design_graph_typescript_resolver_jsonc.go create mode 100644 internal/codeguard/checks/design/design_graph_typescript_resolver_packages.go create mode 100644 internal/codeguard/checks/design/design_graph_typescript_resolver_paths.go create mode 100644 internal/codeguard/checks/design/design_graph_typescript_resolver_resolve.go create mode 100644 internal/codeguard/checks/design/design_python_structure_helpers.go create mode 100644 internal/codeguard/checks/design/design_python_structure_patterns.go create mode 100644 internal/codeguard/checks/design/design_python_structure_scan.go create mode 100644 internal/codeguard/checks/design/design_rust_blocks.go create mode 100644 internal/codeguard/checks/design/design_rust_names.go create mode 100644 internal/codeguard/checks/design/design_rust_patterns.go create mode 100644 internal/codeguard/checks/support/cpp_modules.go create mode 100644 tests/checks/design_graph_helpers_test.go create mode 100644 tests/checks/design_graph_languages_native_test.go create mode 100644 tests/checks/report_format_helpers_test.go create mode 100644 tests/checks/report_layout_fixtures_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96e0f38..145b196 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,13 +84,10 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + # make codeguard-ci: CI intentionally runs the stable Marketplace action instead of the in-repo binary. + - uses: devr-tools/codeguard@v1.1.1 with: - go-version-file: go.mod - cache: true - - - name: Run repository self-scan - run: make codeguard-ci + config: .codeguard/codeguard.yaml build: name: build diff --git a/README.md b/README.md index c7fbd97..193b37e 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ codeguard version ```yaml - name: Devr Codeguard - uses: devr-tools/codeguard@v0.8.1 + uses: devr-tools/codeguard@v1.1.1 ``` Or run in Docker: diff --git a/docs/getting-started.md b/docs/getting-started.md index d83d4e7..2b6bbe2 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -18,7 +18,7 @@ Or in GitHub Actions from GitHub Marketplace: ```yaml - name: Devr Codeguard - uses: devr-tools/codeguard@v0.2.0 + uses: devr-tools/codeguard@v1.1.1 ``` For SDK consumers: diff --git a/docs/integrations.md b/docs/integrations.md index b0196b9..aee1042 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -21,7 +21,7 @@ This repository also ships the GitHub Marketplace action `Devr Codeguard` from ` ```yaml - name: Devr Codeguard - uses: devr-tools/codeguard@v0.2.0 + uses: devr-tools/codeguard@v1.1.1 with: config: codeguard.yaml profile: strict @@ -36,7 +36,7 @@ For pull request workflows, the action can also publish a sticky fix-oriented co ```yaml - name: Devr Codeguard - uses: devr-tools/codeguard@v0.2.0 + uses: devr-tools/codeguard@v1.1.1 with: config: codeguard.yaml mode: diff diff --git a/internal/codeguard/checks/design/design_cpp.go b/internal/codeguard/checks/design/design_cpp.go index a6a8968..01dddf6 100644 --- a/internal/codeguard/checks/design/design_cpp.go +++ b/internal/codeguard/checks/design/design_cpp.go @@ -4,7 +4,6 @@ import ( "fmt" "path/filepath" "regexp" - "sort" "strings" "github.com/devr-tools/codeguard/internal/codeguard/checks/support" @@ -95,315 +94,3 @@ type cppTypeSurface struct { methods map[string]struct{} publicMethods map[string]struct{} } - -type cppTypeBlock struct { - surface *cppTypeSurface - waiting bool - bodyDepth int - defaultAccess string - access string -} - -type cppNamespaceBlock struct { - name string - waiting bool - bodyDepth int -} - -func cppTypeSurfaces(source string) map[string]*cppTypeSurface { - source = strings.ReplaceAll(source, "\r\n", "\n") - lines := strings.Split(support.MaskCLikeSource(source, support.CLikeCPP), "\n") - surfaces := make(map[string]*cppTypeSurface) - namespaces := make([]*cppNamespaceBlock, 0) - types := make([]*cppTypeBlock, 0) - depth := 0 - - for idx, line := range lines { - lineNo := idx + 1 - namespaces = append(namespaces, newCPPNamespaceBlock(line)) - if block := newCPPTypeBlock(line, lineNo, cppNamespacePrefix(namespaces), surfaces); block != nil { - types = append(types, block) - } - countCPPTypeMembers(types, depth, line) - depth += braceDelta(line) - openCPPNamespaceBlocks(namespaces, depth, line) - openCPPTypeBlocks(types, depth, line) - countCPPTypeMembers(types, depth, line) - namespaces = pruneCPPNamespaceBlocks(namespaces, depth, line) - types = pruneCPPTypeBlocks(types, depth, line) - } - - return surfaces -} - -func newCPPNamespaceBlock(line string) *cppNamespaceBlock { - match := cppNamespaceDeclarationPattern.FindStringSubmatch(line) - if len(match) != 2 { - return nil - } - return &cppNamespaceBlock{name: match[1], waiting: true} -} - -func newCPPTypeBlock(line string, lineNo int, namespacePrefix string, surfaces map[string]*cppTypeSurface) *cppTypeBlock { - match := cppTypeDeclarationPattern.FindStringSubmatch(line) - if len(match) != 3 { - return nil - } - defaultAccess := "private" - if match[1] == "struct" { - defaultAccess = "public" - } - typeName := match[2] - qualifiedName := typeName - if namespacePrefix != "" { - qualifiedName = namespacePrefix + "::" + qualifiedName - } - surface := surfaces[qualifiedName] - if surface == nil { - surface = &cppTypeSurface{ - name: qualifiedName, - typeName: typeName, - line: lineNo, - methods: make(map[string]struct{}), - publicMethods: make(map[string]struct{}), - } - surfaces[qualifiedName] = surface - } else if surface.line <= 0 { - surface.line = lineNo - } - return &cppTypeBlock{ - surface: surface, - waiting: true, - defaultAccess: defaultAccess, - access: defaultAccess, - } -} - -func cppNamespacePrefix(namespaces []*cppNamespaceBlock) string { - parts := make([]string, 0, len(namespaces)) - for _, block := range namespaces { - if block == nil || block.waiting || block.name == "" { - continue - } - parts = append(parts, block.name) - } - return strings.Join(parts, "::") -} - -func countCPPTypeMembers(types []*cppTypeBlock, depth int, line string) { - for _, block := range types { - if block == nil || block.waiting || depth != block.bodyDepth { - continue - } - if match := cppAccessSpecifierPattern.FindStringSubmatch(strings.TrimSpace(line)); len(match) == 2 { - block.access = match[1] - continue - } - key, ok := cppMethodKey(line, block.surface.typeName) - if !ok { - continue - } - block.surface.methods[key] = struct{}{} - if block.access == "public" { - block.surface.publicMethods[key] = struct{}{} - } - } -} - -func cppMethodKey(line string, typeName string) (string, bool) { - trimmed := strings.TrimSpace(line) - if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "using ") || - strings.HasPrefix(trimmed, "typedef ") || strings.HasPrefix(trimmed, "friend ") || - strings.HasPrefix(trimmed, "static_assert") || strings.HasPrefix(trimmed, "return ") { - return "", false - } - open := strings.Index(trimmed, "(") - if open < 0 { - return "", false - } - head := strings.TrimSpace(trimmed[:open]) - if head == "" || strings.Contains(head, "=") { - return "", false - } - name := cppTrailingIdentifier(head) - if name == "" || cppNonMethodName(name) { - return "", false - } - if name != typeName && name != "~"+typeName && strings.Contains(name, "::") { - name = name[strings.LastIndex(name, "::")+2:] - } - closePos := cppMatchingParen(trimmed, open) - if closePos < 0 { - return "", false - } - trailer := strings.TrimSpace(trimmed[closePos+1:]) - if strings.HasPrefix(trailer, "->") { - return "", false - } - params := cppSquashWhitespace(trimmed[open+1 : closePos]) - return name + "(" + params + ")", true -} - -func cppTrailingIdentifier(head string) string { - head = strings.TrimRight(head, " \t*&") - if head == "" { - return "" - } - start := len(head) - for start > 0 { - ch := head[start-1] - if ch == '_' || ch == '~' || ch == ':' || - (ch >= '0' && ch <= '9') || - (ch >= 'A' && ch <= 'Z') || - (ch >= 'a' && ch <= 'z') { - start-- - continue - } - break - } - return head[start:] -} - -func cppNonMethodName(name string) bool { - switch name { - case "if", "for", "while", "switch", "catch", "return", "requires": - return true - default: - return name == "" - } -} - -func cppMatchingParen(line string, open int) int { - depth := 0 - for i := open; i < len(line); i++ { - switch line[i] { - case '(': - depth++ - case ')': - depth-- - if depth == 0 { - return i - } - } - } - return -1 -} - -func openCPPNamespaceBlocks(blocks []*cppNamespaceBlock, depth int, line string) { - if !strings.Contains(line, "{") { - return - } - for _, block := range blocks { - if block == nil || !block.waiting { - continue - } - block.waiting = false - block.bodyDepth = depth - } -} - -func openCPPTypeBlocks(blocks []*cppTypeBlock, depth int, line string) { - if !strings.Contains(line, "{") { - return - } - for _, block := range blocks { - if block == nil || !block.waiting { - continue - } - block.waiting = false - block.bodyDepth = depth - block.access = block.defaultAccess - } -} - -func pruneCPPNamespaceBlocks(blocks []*cppNamespaceBlock, depth int, line string) []*cppNamespaceBlock { - kept := blocks[:0] - for _, block := range blocks { - if block == nil { - continue - } - if block.waiting && strings.Contains(line, ";") && !strings.Contains(line, "{") { - continue - } - if !block.waiting && depth < block.bodyDepth { - continue - } - kept = append(kept, block) - } - return kept -} - -func pruneCPPTypeBlocks(blocks []*cppTypeBlock, depth int, line string) []*cppTypeBlock { - kept := blocks[:0] - for _, block := range blocks { - if block == nil { - continue - } - if block.waiting && strings.Contains(line, ";") && !strings.Contains(line, "{") { - continue - } - if !block.waiting && depth < block.bodyDepth { - continue - } - kept = append(kept, block) - } - return kept -} - -func cppRecordOutOfLineMethods(surfaces map[string]*cppTypeSurface, functions []*support.ParsedFunction) { - for _, function := range functions { - if function == nil { - continue - } - separator := strings.LastIndex(function.Name, "::") - if separator <= 0 { - continue - } - typeName := function.Name[:separator] - methodName := function.Name[separator+2:] - surface := surfaces[typeName] - if surface == nil { - surface = &cppTypeSurface{ - name: typeName, - typeName: typeName[strings.LastIndex(typeName, "::")+2:], - line: function.StartLine, - methods: make(map[string]struct{}), - publicMethods: make(map[string]struct{}), - } - surfaces[typeName] = surface - } - surface.methods[methodName+"("+cppSquashWhitespace(function.Signature)+")"] = struct{}{} - } -} - -func cppSortedTypeSurfaces(surfaces map[string]*cppTypeSurface) []*cppTypeSurface { - result := make([]*cppTypeSurface, 0, len(surfaces)) - for _, surface := range surfaces { - result = append(result, surface) - } - sort.Slice(result, func(i, j int) bool { - if result[i].line != result[j].line { - return result[i].line < result[j].line - } - return result[i].name < result[j].name - }) - return result -} - -func isCPPContractPath(file string) bool { - rawExt := filepath.Ext(file) - if rawExt == ".C" { - return false - } - switch strings.ToLower(rawExt) { - case ".h", ".hh", ".hpp", ".hxx", ".h++", ".ipp", ".tpp", ".inl", ".txx", ".ixx", - ".cppm", ".cxxm", ".ccm", ".c++m", ".mpp", ".mxx", ".inc": - return true - default: - return false - } -} - -func cppSquashWhitespace(text string) string { - return strings.TrimSpace(strings.Join(strings.Fields(text), " ")) -} diff --git a/internal/codeguard/checks/design/design_cpp_blocks.go b/internal/codeguard/checks/design/design_cpp_blocks.go new file mode 100644 index 0000000..14aff8f --- /dev/null +++ b/internal/codeguard/checks/design/design_cpp_blocks.go @@ -0,0 +1,162 @@ +package design + +import ( + "sort" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" +) + +type cppTypeBlock struct { + surface *cppTypeSurface + waiting bool + bodyDepth int + defaultAccess string + access string +} + +type cppNamespaceBlock struct { + name string + waiting bool + bodyDepth int +} + +func cppTypeSurfaces(source string) map[string]*cppTypeSurface { + source = strings.ReplaceAll(source, "\r\n", "\n") + lines := strings.Split(support.MaskCLikeSource(source, support.CLikeCPP), "\n") + surfaces := make(map[string]*cppTypeSurface) + namespaces := make([]*cppNamespaceBlock, 0) + types := make([]*cppTypeBlock, 0) + depth := 0 + + for idx, line := range lines { + lineNo := idx + 1 + namespaces = append(namespaces, newCPPNamespaceBlock(line)) + if block := newCPPTypeBlock(line, lineNo, cppNamespacePrefix(namespaces), surfaces); block != nil { + types = append(types, block) + } + countCPPTypeMembers(types, depth, line) + depth += braceDelta(line) + openCPPNamespaceBlocks(namespaces, depth, line) + openCPPTypeBlocks(types, depth, line) + countCPPTypeMembers(types, depth, line) + namespaces = pruneCPPNamespaceBlocks(namespaces, depth, line) + types = pruneCPPTypeBlocks(types, depth, line) + } + + return surfaces +} + +func newCPPNamespaceBlock(line string) *cppNamespaceBlock { + match := cppNamespaceDeclarationPattern.FindStringSubmatch(line) + if len(match) != 2 { + return nil + } + return &cppNamespaceBlock{name: match[1], waiting: true} +} + +func newCPPTypeBlock(line string, lineNo int, namespacePrefix string, surfaces map[string]*cppTypeSurface) *cppTypeBlock { + match := cppTypeDeclarationPattern.FindStringSubmatch(line) + if len(match) != 3 { + return nil + } + defaultAccess := "private" + if match[1] == "struct" { + defaultAccess = "public" + } + typeName := match[2] + qualifiedName := typeName + if namespacePrefix != "" { + qualifiedName = namespacePrefix + "::" + qualifiedName + } + surface := surfaces[qualifiedName] + if surface == nil { + surface = &cppTypeSurface{ + name: qualifiedName, + typeName: typeName, + line: lineNo, + methods: make(map[string]struct{}), + publicMethods: make(map[string]struct{}), + } + surfaces[qualifiedName] = surface + } else if surface.line <= 0 { + surface.line = lineNo + } + return &cppTypeBlock{ + surface: surface, + waiting: true, + defaultAccess: defaultAccess, + access: defaultAccess, + } +} + +func cppNamespacePrefix(namespaces []*cppNamespaceBlock) string { + parts := make([]string, 0, len(namespaces)) + for _, block := range namespaces { + if block == nil || block.waiting || block.name == "" { + continue + } + parts = append(parts, block.name) + } + return strings.Join(parts, "::") +} + +func countCPPTypeMembers(types []*cppTypeBlock, depth int, line string) { + for _, block := range types { + if block == nil || block.waiting || depth != block.bodyDepth { + continue + } + if match := cppAccessSpecifierPattern.FindStringSubmatch(strings.TrimSpace(line)); len(match) == 2 { + block.access = match[1] + continue + } + key, ok := cppMethodKey(line, block.surface.typeName) + if !ok { + continue + } + block.surface.methods[key] = struct{}{} + if block.access == "public" { + block.surface.publicMethods[key] = struct{}{} + } + } +} + +func cppRecordOutOfLineMethods(surfaces map[string]*cppTypeSurface, functions []*support.ParsedFunction) { + for _, function := range functions { + if function == nil { + continue + } + separator := strings.LastIndex(function.Name, "::") + if separator <= 0 { + continue + } + typeName := function.Name[:separator] + methodName := function.Name[separator+2:] + surface := surfaces[typeName] + if surface == nil { + surface = &cppTypeSurface{ + name: typeName, + typeName: typeName[strings.LastIndex(typeName, "::")+2:], + line: function.StartLine, + methods: make(map[string]struct{}), + publicMethods: make(map[string]struct{}), + } + surfaces[typeName] = surface + } + surface.methods[methodName+"("+cppSquashWhitespace(function.Signature)+")"] = struct{}{} + } +} + +func cppSortedTypeSurfaces(surfaces map[string]*cppTypeSurface) []*cppTypeSurface { + result := make([]*cppTypeSurface, 0, len(surfaces)) + for _, surface := range surfaces { + result = append(result, surface) + } + sort.Slice(result, func(i, j int) bool { + if result[i].line != result[j].line { + return result[i].line < result[j].line + } + return result[i].name < result[j].name + }) + return result +} diff --git a/internal/codeguard/checks/design/design_cpp_parse.go b/internal/codeguard/checks/design/design_cpp_parse.go new file mode 100644 index 0000000..dff3db2 --- /dev/null +++ b/internal/codeguard/checks/design/design_cpp_parse.go @@ -0,0 +1,164 @@ +package design + +import ( + "path/filepath" + "strings" +) + +func cppMethodKey(line string, typeName string) (string, bool) { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "using ") || + strings.HasPrefix(trimmed, "typedef ") || strings.HasPrefix(trimmed, "friend ") || + strings.HasPrefix(trimmed, "static_assert") || strings.HasPrefix(trimmed, "return ") { + return "", false + } + open := strings.Index(trimmed, "(") + if open < 0 { + return "", false + } + head := strings.TrimSpace(trimmed[:open]) + if head == "" || strings.Contains(head, "=") { + return "", false + } + name := cppTrailingIdentifier(head) + if name == "" || cppNonMethodName(name) { + return "", false + } + if name != typeName && name != "~"+typeName && strings.Contains(name, "::") { + name = name[strings.LastIndex(name, "::")+2:] + } + closePos := cppMatchingParen(trimmed, open) + if closePos < 0 { + return "", false + } + trailer := strings.TrimSpace(trimmed[closePos+1:]) + if strings.HasPrefix(trailer, "->") { + return "", false + } + params := cppSquashWhitespace(trimmed[open+1 : closePos]) + return name + "(" + params + ")", true +} + +func cppTrailingIdentifier(head string) string { + head = strings.TrimRight(head, " \t*&") + if head == "" { + return "" + } + start := len(head) + for start > 0 { + ch := head[start-1] + if ch == '_' || ch == '~' || ch == ':' || + (ch >= '0' && ch <= '9') || + (ch >= 'A' && ch <= 'Z') || + (ch >= 'a' && ch <= 'z') { + start-- + continue + } + break + } + return head[start:] +} + +func cppNonMethodName(name string) bool { + switch name { + case "if", "for", "while", "switch", "catch", "return", "requires": + return true + default: + return name == "" + } +} + +func cppMatchingParen(line string, open int) int { + depth := 0 + for i := open; i < len(line); i++ { + switch line[i] { + case '(': + depth++ + case ')': + depth-- + if depth == 0 { + return i + } + } + } + return -1 +} + +func openCPPNamespaceBlocks(blocks []*cppNamespaceBlock, depth int, line string) { + if !strings.Contains(line, "{") { + return + } + for _, block := range blocks { + if block == nil || !block.waiting { + continue + } + block.waiting = false + block.bodyDepth = depth + } +} + +func openCPPTypeBlocks(blocks []*cppTypeBlock, depth int, line string) { + if !strings.Contains(line, "{") { + return + } + for _, block := range blocks { + if block == nil || !block.waiting { + continue + } + block.waiting = false + block.bodyDepth = depth + block.access = block.defaultAccess + } +} + +func pruneCPPNamespaceBlocks(blocks []*cppNamespaceBlock, depth int, line string) []*cppNamespaceBlock { + kept := blocks[:0] + for _, block := range blocks { + if block == nil { + continue + } + if block.waiting && strings.Contains(line, ";") && !strings.Contains(line, "{") { + continue + } + if !block.waiting && depth < block.bodyDepth { + continue + } + kept = append(kept, block) + } + return kept +} + +func pruneCPPTypeBlocks(blocks []*cppTypeBlock, depth int, line string) []*cppTypeBlock { + kept := blocks[:0] + for _, block := range blocks { + if block == nil { + continue + } + if block.waiting && strings.Contains(line, ";") && !strings.Contains(line, "{") { + continue + } + if !block.waiting && depth < block.bodyDepth { + continue + } + kept = append(kept, block) + } + return kept +} + +func isCPPContractPath(file string) bool { + rawExt := filepath.Ext(file) + if rawExt == ".C" { + return false + } + switch strings.ToLower(rawExt) { + case ".h", ".hh", ".hpp", ".hxx", ".h++", ".ipp", ".tpp", ".inl", ".txx", ".ixx", + ".cppm", ".cxxm", ".ccm", ".c++m", ".mpp", ".mxx", ".inc": + return true + default: + return false + } +} + +func cppSquashWhitespace(text string) string { + return strings.TrimSpace(strings.Join(strings.Fields(text), " ")) +} diff --git a/internal/codeguard/checks/design/design_graph_cpp.go b/internal/codeguard/checks/design/design_graph_cpp.go index d070790..cae6345 100644 --- a/internal/codeguard/checks/design/design_graph_cpp.go +++ b/internal/codeguard/checks/design/design_graph_cpp.go @@ -1,18 +1,12 @@ package design import ( - "regexp" "strings" "github.com/devr-tools/codeguard/internal/codeguard/checks/support" "github.com/devr-tools/codeguard/internal/codeguard/core" ) -var ( - cppBoundaryModuleDeclarationPattern = regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?module[ \t]+([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?::[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)?)[ \t]*;`) - cppBoundaryModuleImportPattern = regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?import[ \t]+((?:[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?::[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)?)|(?::[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*))[ \t]*;`) -) - type cppBoundaryNamedImport struct { from string specifier string @@ -46,36 +40,22 @@ func buildCPPImportGraph(env support.Context, target core.TargetConfig) *moduleG } graph.addImport(rel, resolved, rel, imported.Module, imported.Line) } - if match := cppBoundaryModuleDeclarationPattern.FindStringSubmatch(parsed.Masked); len(match) == 2 { + if match := support.CPPModuleDeclarationPattern.FindStringSubmatch(parsed.Masked); len(match) == 2 { declaredModules[rel] = match[1] namedModules[match[1]] = rel } - for _, match := range cppBoundaryModuleImportPattern.FindAllStringSubmatchIndex(parsed.Masked, -1) { + for _, match := range support.CPPModuleImportPattern.FindAllStringSubmatchIndex(parsed.Masked, -1) { specifier := strings.TrimSpace(parsed.Masked[match[2]:match[3]]) namedImports = append(namedImports, cppBoundaryNamedImport{from: rel, specifier: specifier, line: support.LineNumberForOffset(parsed.Masked, match[0])}) } }) for _, imported := range namedImports { - resolvedSpecifier := supportQualifyCPPModuleImport(imported.specifier, declaredModules[imported.from]) + resolvedSpecifier := support.QualifyCPPModuleImport(imported.specifier, declaredModules[imported.from]) graph.addImport(imported.from, namedModules[resolvedSpecifier], imported.from, imported.specifier, imported.line) } return graph } -func supportQualifyCPPModuleImport(specifier string, declaredModule string) string { - if !strings.HasPrefix(specifier, ":") { - return specifier - } - primary := declaredModule - if cut := strings.IndexByte(primary, ':'); cut >= 0 { - primary = primary[:cut] - } - if primary == "" { - return "" - } - return primary + specifier -} - func cppGraphEdgeAtLine(graph *moduleGraph, from string, line int) string { node := graph.modules[from] if node == nil { diff --git a/internal/codeguard/checks/design/design_graph_typescript.go b/internal/codeguard/checks/design/design_graph_typescript.go index ee25f80..fb5449b 100644 --- a/internal/codeguard/checks/design/design_graph_typescript.go +++ b/internal/codeguard/checks/design/design_graph_typescript.go @@ -45,9 +45,9 @@ func buildTypeScriptImportGraph(env support.Context, target core.TargetConfig) * }) resolver := newTypeScriptImportResolver(graph) env.VisitTargetFiles(target, isTypeScriptResolverMetadataFile, func(rel string, data []byte) { - resolver.indexMetadata(rel, data) + indexTypeScriptResolverMetadata(resolver, rel, data) }) - resolver.finalizeConfigs() + finalizeTypeScriptResolverConfigs(resolver) for _, edge := range pending { resolved := resolveTypeScriptImport(resolver, edge.from, edge.to) graph.addImport(edge.from, resolved, edge.file, edge.to, edge.line) @@ -90,5 +90,5 @@ func resolveTypeScriptImport(resolver *typeScriptImportResolver, fromModule stri if resolver == nil { return "" } - return resolver.resolve(fromModule, specifier) + return resolveTypeScriptModuleImport(resolver, fromModule, specifier) } diff --git a/internal/codeguard/checks/design/design_graph_typescript_resolver.go b/internal/codeguard/checks/design/design_graph_typescript_resolver.go index 91d758e..27ca0e5 100644 --- a/internal/codeguard/checks/design/design_graph_typescript_resolver.go +++ b/internal/codeguard/checks/design/design_graph_typescript_resolver.go @@ -3,7 +3,6 @@ package design import ( "encoding/json" "path" - "sort" "strings" ) @@ -73,687 +72,3 @@ func isTypeScriptResolverMetadataFile(rel string) bool { } return strings.HasSuffix(base, ".json") } - -func (resolver *typeScriptImportResolver) indexMetadata(rel string, data []byte) { - base := path.Base(rel) - switch { - case base == "package.json": - resolver.indexPackageManifest(path.Dir(rel), data) - case strings.HasSuffix(base, ".json"): - resolver.indexTSConfig(rel, data) - } -} - -func (resolver *typeScriptImportResolver) indexPackageManifest(dir string, data []byte) { - var manifest typeScriptPackageManifest - if err := json.Unmarshal(data, &manifest); err != nil { - return - } - name := strings.TrimSpace(manifest.Name) - if name == "" { - return - } - pkg := typeScriptWorkspacePackage{ - name: name, - dir: normalizeTypeScriptPath(dir), - main: strings.TrimSpace(manifest.Main), - module: strings.TrimSpace(manifest.Module), - source: strings.TrimSpace(manifest.Source), - types: strings.TrimSpace(manifest.Types), - exports: parseTypeScriptPackageExports(manifest.Exports), - imports: parseTypeScriptPackageImports(manifest.Imports), - } - resolver.packages[name] = pkg -} - -func (resolver *typeScriptImportResolver) indexTSConfig(rel string, data []byte) { - var doc typeScriptConfigDocument - normalized, ok := normalizeJSONC(data) - if !ok || json.Unmarshal(normalized, &doc) != nil { - return - } - rel = normalizeTypeScriptPath(rel) - resolver.tsconfigs[rel] = doc - dir := normalizeTypeScriptPath(path.Dir(rel)) - primary, ok := resolver.tsconfigPrimary[dir] - if !ok || path.Base(rel) == "tsconfig.json" || path.Base(primary) != "tsconfig.json" { - resolver.tsconfigPrimary[dir] = rel - } -} - -func (resolver *typeScriptImportResolver) finalizeConfigs() { - resolver.configs = resolver.configs[:0] - dirs := make([]string, 0, len(resolver.tsconfigPrimary)) - for dir := range resolver.tsconfigPrimary { - dirs = append(dirs, dir) - } - sort.Slice(dirs, func(i, j int) bool { - return len(dirs[i]) > len(dirs[j]) - }) - cache := make(map[string]typeScriptGraphConfig, len(dirs)) - for _, dir := range dirs { - cfg, ok := resolver.effectiveTSConfig(resolver.tsconfigPrimary[dir], cache, make(map[string]bool)) - if !ok { - continue - } - resolver.configs = append(resolver.configs, cfg) - } -} - -func (resolver *typeScriptImportResolver) effectiveTSConfig(rel string, cache map[string]typeScriptGraphConfig, seen map[string]bool) (typeScriptGraphConfig, bool) { - if cfg, ok := cache[rel]; ok { - return cfg, true - } - if seen[rel] { - return typeScriptGraphConfig{}, false - } - doc, ok := resolver.tsconfigs[rel] - if !ok { - return typeScriptGraphConfig{}, false - } - seen[rel] = true - dir := normalizeTypeScriptPath(path.Dir(rel)) - effective := typeScriptCompilerOptions{} - if parentRel, ok := resolver.resolveTSConfigExtends(dir, doc.Extends); ok { - if parent, ok := resolver.effectiveTSConfig(parentRel, cache, seen); ok { - effective.BaseURL = parent.baseDir - effective.Paths = aliasesToPathMap(parent.paths) - } - } - effective = mergeTypeScriptCompilerOptions(dir, effective, doc.CompilerOptions) - cfg := typeScriptGraphConfig{ - dir: dir, - baseDir: effective.BaseURL, - paths: sortedTypeScriptAliases(effective.Paths), - } - if cfg.baseDir == "" { - cfg.baseDir = dir - } - cache[rel] = cfg - return cfg, true -} - -func aliasesToPathMap(aliases []typeScriptPathAlias) map[string][]string { - if len(aliases) == 0 { - return nil - } - out := make(map[string][]string, len(aliases)) - for _, alias := range aliases { - out[alias.pattern] = append([]string(nil), alias.targets...) - } - return out -} - -func mergeTypeScriptCompilerOptions(dir string, base typeScriptCompilerOptions, override typeScriptCompilerOptions) typeScriptCompilerOptions { - merged := typeScriptCompilerOptions{ - BaseURL: base.BaseURL, - Paths: cloneTypeScriptPathMap(base.Paths), - } - if strings.TrimSpace(override.BaseURL) != "" { - merged.BaseURL = normalizeTypeScriptPath(path.Join(dir, override.BaseURL)) - } - if len(override.Paths) > 0 { - merged.Paths = cloneTypeScriptPathMap(override.Paths) - } - if merged.BaseURL == "" { - merged.BaseURL = dir - } - return merged -} - -func cloneTypeScriptPathMap(paths map[string][]string) map[string][]string { - if len(paths) == 0 { - return nil - } - out := make(map[string][]string, len(paths)) - for key, values := range paths { - out[key] = append([]string(nil), values...) - } - return out -} - -func (resolver *typeScriptImportResolver) resolveTSConfigExtends(dir string, value string) (string, bool) { - value = strings.TrimSpace(value) - if value == "" { - return "", false - } - candidates := make([]string, 0, 3) - if strings.HasPrefix(value, ".") || strings.HasPrefix(value, "/") { - candidates = append(candidates, normalizeTypeScriptPath(path.Join(dir, value))) - } else { - candidates = append(candidates, normalizeTypeScriptPath(path.Join(dir, value))) - candidates = append(candidates, resolver.workspaceTSConfigCandidates(value)...) - } - if !strings.HasSuffix(value, ".json") { - candidates = append(candidates, normalizeTypeScriptPath(path.Join(dir, value+".json"))) - if !strings.HasPrefix(value, ".") && !strings.HasPrefix(value, "/") { - candidates = append(candidates, resolver.workspaceTSConfigCandidates(value+".json")...) - } - } - candidates = append(candidates, normalizeTypeScriptPath(path.Join(dir, value, "tsconfig.json"))) - if !strings.HasPrefix(value, ".") && !strings.HasPrefix(value, "/") { - candidates = append(candidates, resolver.workspaceTSConfigCandidates(path.Join(value, "tsconfig.json"))...) - } - for _, candidate := range candidates { - if _, ok := resolver.tsconfigs[candidate]; ok { - return candidate, true - } - } - return "", false -} - -func (resolver *typeScriptImportResolver) workspaceTSConfigCandidates(specifier string) []string { - root := typeScriptPackageRoot(specifier) - pkg, ok := resolver.packages[root] - if !ok { - return nil - } - subpath := strings.TrimPrefix(specifier, root) - subpath = strings.TrimPrefix(subpath, "/") - candidates := make([]string, 0, 3) - if subpath == "" { - candidates = append(candidates, normalizeTypeScriptPath(path.Join(pkg.dir, "tsconfig.json"))) - } else { - candidates = append(candidates, normalizeTypeScriptPath(path.Join(pkg.dir, subpath))) - } - return candidates -} - -func sortedTypeScriptAliases(paths map[string][]string) []typeScriptPathAlias { - aliases := make([]typeScriptPathAlias, 0, len(paths)) - for pattern, targets := range paths { - cleanTargets := make([]string, 0, len(targets)) - for _, target := range targets { - target = strings.TrimSpace(target) - if target == "" { - continue - } - cleanTargets = append(cleanTargets, target) - } - if len(cleanTargets) == 0 { - continue - } - aliases = append(aliases, typeScriptPathAlias{ - pattern: strings.TrimSpace(pattern), - targets: cleanTargets, - }) - } - sort.Slice(aliases, func(i, j int) bool { - if len(aliases[i].pattern) != len(aliases[j].pattern) { - return len(aliases[i].pattern) > len(aliases[j].pattern) - } - return aliases[i].pattern < aliases[j].pattern - }) - return aliases -} - -func (resolver *typeScriptImportResolver) resolve(fromModule string, specifier string) string { - if strings.HasPrefix(specifier, "./") || strings.HasPrefix(specifier, "../") || specifier == "." || specifier == ".." { - return resolver.resolveRelative(fromModule, specifier) - } - if resolved := resolver.resolvePackageImport(fromModule, specifier); resolved != "" { - return resolved - } - if resolved := resolver.resolveTSConfigAlias(fromModule, specifier); resolved != "" { - return resolved - } - if resolved := resolver.resolveWorkspacePackage(specifier); resolved != "" { - return resolved - } - return "" -} - -func (resolver *typeScriptImportResolver) resolveRelative(fromModule string, specifier string) string { - joined := path.Clean(path.Join(path.Dir(fromModule), specifier)) - return resolver.resolveModulePath(joined) -} - -func (resolver *typeScriptImportResolver) resolvePackageImport(fromModule string, specifier string) string { - if !strings.HasPrefix(specifier, "#") { - return "" - } - pkg, ok := resolver.packageForModule(fromModule) - if !ok { - return "" - } - for _, candidate := range matchTypeScriptMapping(pkg.imports, specifier) { - if resolved := resolver.resolveModulePath(path.Join(pkg.dir, candidate)); resolved != "" { - return resolved - } - } - return "" -} - -func (resolver *typeScriptImportResolver) resolveTSConfigAlias(fromModule string, specifier string) string { - cfg := resolver.configForModule(fromModule) - if cfg == nil { - return "" - } - for _, alias := range cfg.paths { - wildcard, ok := matchTypeScriptAlias(alias.pattern, specifier) - if !ok { - continue - } - for _, target := range alias.targets { - applied := applyTypeScriptAliasTarget(target, wildcard) - if resolved := resolver.resolveModulePath(path.Join(cfg.baseDir, applied)); resolved != "" { - return resolved - } - } - } - if cfg.baseDir == "" { - return "" - } - return resolver.resolveModulePath(path.Join(cfg.baseDir, specifier)) -} - -func (resolver *typeScriptImportResolver) resolveWorkspacePackage(specifier string) string { - root := typeScriptPackageRoot(specifier) - pkg, ok := resolver.packages[root] - if !ok { - return "" - } - if specifier == root { - return resolver.resolveWorkspacePackageEntrypoint(pkg) - } - subpath := strings.TrimPrefix(specifier, root+"/") - if subpath == specifier || subpath == "" { - return "" - } - for _, candidate := range matchTypeScriptMapping(pkg.exports, "./"+subpath) { - if resolved := resolver.resolveModulePath(path.Join(pkg.dir, candidate)); resolved != "" { - return resolved - } - } - for _, candidate := range []string{ - path.Join(pkg.dir, subpath), - path.Join(pkg.dir, "src", subpath), - } { - if resolved := resolver.resolveModulePath(candidate); resolved != "" { - return resolved - } - } - return "" -} - -func (resolver *typeScriptImportResolver) resolveWorkspacePackageEntrypoint(pkg typeScriptWorkspacePackage) string { - for _, candidate := range matchTypeScriptMapping(pkg.exports, ".") { - if resolved := resolver.resolveModulePath(path.Join(pkg.dir, candidate)); resolved != "" { - return resolved - } - } - for _, candidate := range []string{pkg.types, pkg.source, pkg.module, pkg.main} { - if strings.TrimSpace(candidate) == "" { - continue - } - if resolved := resolver.resolveModulePath(path.Join(pkg.dir, candidate)); resolved != "" { - return resolved - } - } - for _, candidate := range []string{ - path.Join(pkg.dir, "index"), - path.Join(pkg.dir, "src", "index"), - } { - if resolved := resolver.resolveModulePath(candidate); resolved != "" { - return resolved - } - } - return "" -} - -func (resolver *typeScriptImportResolver) packageForModule(module string) (typeScriptWorkspacePackage, bool) { - node := resolver.graph.modules[module] - if node == nil { - return typeScriptWorkspacePackage{}, false - } - file := normalizeTypeScriptPath(node.file) - best := typeScriptWorkspacePackage{} - bestLen := -1 - for _, pkg := range resolver.packages { - if !typeScriptPathContains(pkg.dir, file) { - continue - } - if len(pkg.dir) > bestLen { - best = pkg - bestLen = len(pkg.dir) - } - } - if bestLen < 0 { - return typeScriptWorkspacePackage{}, false - } - return best, true -} - -func (resolver *typeScriptImportResolver) resolveModulePath(rel string) string { - rel = normalizeTypeScriptPath(rel) - rel = typeScriptModuleKey(rel) - for _, candidate := range []string{rel, rel + "/index"} { - if _, ok := resolver.graph.modules[candidate]; ok { - return candidate - } - } - return "" -} - -func (resolver *typeScriptImportResolver) configForModule(module string) *typeScriptGraphConfig { - node := resolver.graph.modules[module] - if node == nil { - return nil - } - dir := normalizeTypeScriptPath(path.Dir(node.file)) - for idx := range resolver.configs { - cfg := &resolver.configs[idx] - if typeScriptPathContains(cfg.dir, dir) { - return cfg - } - } - return nil -} - -func typeScriptPathContains(parent string, child string) bool { - parent = normalizeTypeScriptPath(parent) - child = normalizeTypeScriptPath(child) - if parent == "." { - return true - } - return child == parent || strings.HasPrefix(child, parent+"/") -} - -func normalizeTypeScriptPath(value string) string { - value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/")) - if value == "" { - return "." - } - value = path.Clean(value) - value = strings.TrimPrefix(value, "./") - if value == "" { - return "." - } - return value -} - -func typeScriptPackageRoot(specifier string) string { - if strings.HasPrefix(specifier, "@") { - parts := strings.Split(specifier, "/") - if len(parts) >= 2 { - return parts[0] + "/" + parts[1] - } - } - return firstTypeScriptSegment(specifier) -} - -func firstTypeScriptSegment(specifier string) string { - specifier = strings.TrimSpace(specifier) - if specifier == "" { - return "" - } - if cut := strings.IndexByte(specifier, '/'); cut >= 0 { - return specifier[:cut] - } - return specifier -} - -func matchTypeScriptAlias(pattern string, specifier string) (string, bool) { - if !strings.Contains(pattern, "*") { - return "", pattern == specifier - } - parts := strings.SplitN(pattern, "*", 2) - if !strings.HasPrefix(specifier, parts[0]) || !strings.HasSuffix(specifier, parts[1]) { - return "", false - } - return specifier[len(parts[0]) : len(specifier)-len(parts[1])], true -} - -func applyTypeScriptAliasTarget(target string, wildcard string) string { - if !strings.Contains(target, "*") { - return target - } - return strings.Replace(target, "*", wildcard, 1) -} - -func matchTypeScriptMapping(mappings map[string][]string, specifier string) []string { - if len(mappings) == 0 { - return nil - } - patterns := make([]string, 0, len(mappings)) - for pattern := range mappings { - patterns = append(patterns, pattern) - } - sort.Slice(patterns, func(i, j int) bool { - if len(patterns[i]) != len(patterns[j]) { - return len(patterns[i]) > len(patterns[j]) - } - return patterns[i] < patterns[j] - }) - for _, pattern := range patterns { - wildcard, ok := matchTypeScriptAlias(pattern, specifier) - if !ok { - continue - } - values := make([]string, 0, len(mappings[pattern])) - for _, target := range mappings[pattern] { - values = append(values, applyTypeScriptAliasTarget(target, wildcard)) - } - return values - } - return nil -} - -func parseTypeScriptPackageExports(raw json.RawMessage) map[string][]string { - return parseTypeScriptPackageMappings(raw, true) -} - -func parseTypeScriptPackageImports(raw json.RawMessage) map[string][]string { - return parseTypeScriptPackageMappings(raw, false) -} - -func parseTypeScriptPackageMappings(raw json.RawMessage, isExports bool) map[string][]string { - if len(raw) == 0 { - return nil - } - var node any - if err := json.Unmarshal(raw, &node); err != nil { - return nil - } - mappings := make(map[string][]string) - switch value := node.(type) { - case string: - mappings["."] = append(mappings["."], value) - case map[string]any: - for key, child := range value { - switch { - case isExports && key == ".": - mappings["."] = append(mappings["."], collectTypeScriptExportTargets(child)...) - case isExports && strings.HasPrefix(key, "./"): - mappings[key] = append(mappings[key], collectTypeScriptExportTargets(child)...) - case !isExports && strings.HasPrefix(key, "#"): - mappings[key] = append(mappings[key], collectTypeScriptExportTargets(child)...) - default: - mappings["."] = append(mappings["."], collectTypeScriptExportTargets(child)...) - } - } - } - for key, values := range mappings { - mappings[key] = uniqueNonEmptyStrings(values) - if len(mappings[key]) == 0 { - delete(mappings, key) - } - } - return mappings -} - -func collectTypeScriptExportTargets(node any) []string { - switch value := node.(type) { - case string: - return []string{value} - case []any: - out := make([]string, 0, len(value)) - for _, item := range value { - out = append(out, collectTypeScriptExportTargets(item)...) - } - return out - case map[string]any: - out := make([]string, 0, len(value)) - for _, key := range orderedTypeScriptConditionKeys(value) { - child := value[key] - out = append(out, collectTypeScriptExportTargets(child)...) - } - return out - default: - return nil - } -} - -func orderedTypeScriptConditionKeys(values map[string]any) []string { - preferred := []string{ - "types", "source", "import", "module", "browser", "development", - "production", "node", "default", "require", - } - keys := make([]string, 0, len(values)) - seen := make(map[string]struct{}, len(values)) - for _, key := range preferred { - if _, ok := values[key]; ok { - keys = append(keys, key) - seen[key] = struct{}{} - } - } - extra := make([]string, 0, len(values)) - for key := range values { - if _, ok := seen[key]; ok { - continue - } - extra = append(extra, key) - } - sort.Strings(extra) - return append(keys, extra...) -} - -func uniqueNonEmptyStrings(values []string) []string { - out := make([]string, 0, len(values)) - seen := make(map[string]struct{}, len(values)) - for _, value := range values { - value = strings.TrimSpace(strings.TrimPrefix(value, "./")) - if value == "" { - continue - } - if _, ok := seen[value]; ok { - continue - } - seen[value] = struct{}{} - out = append(out, value) - } - return out -} - -func normalizeJSONC(data []byte) ([]byte, bool) { - withoutComments, ok := stripJSONCComments(string(data)) - if !ok { - return nil, false - } - return []byte(stripJSONCTrailingCommas(withoutComments)), true -} - -func stripJSONCComments(source string) (string, bool) { - var b strings.Builder - b.Grow(len(source)) - inString := false - escaped := false - for idx := 0; idx < len(source); idx++ { - ch := source[idx] - if inString { - b.WriteByte(ch) - if escaped { - escaped = false - continue - } - switch ch { - case '\\': - escaped = true - case '"': - inString = false - } - continue - } - if ch == '"' { - inString = true - b.WriteByte(ch) - continue - } - if ch == '/' && idx+1 < len(source) { - switch source[idx+1] { - case '/': - for idx+1 < len(source) && source[idx+1] != '\n' { - idx++ - } - continue - case '*': - idx += 2 - for idx < len(source) { - if idx+1 < len(source) && source[idx] == '*' && source[idx+1] == '/' { - idx++ - break - } - if source[idx] == '\n' { - b.WriteByte('\n') - } - idx++ - } - if idx >= len(source) { - return "", false - } - continue - } - } - b.WriteByte(ch) - } - return b.String(), !inString -} - -func stripJSONCTrailingCommas(source string) string { - var b strings.Builder - b.Grow(len(source)) - inString := false - escaped := false - for idx := 0; idx < len(source); idx++ { - ch := source[idx] - if inString { - b.WriteByte(ch) - if escaped { - escaped = false - continue - } - switch ch { - case '\\': - escaped = true - case '"': - inString = false - } - continue - } - if ch == '"' { - inString = true - b.WriteByte(ch) - continue - } - if ch == ',' { - next := idx + 1 - for next < len(source) && isJSONWhitespace(source[next]) { - next++ - } - if next < len(source) && (source[next] == '}' || source[next] == ']') { - continue - } - } - b.WriteByte(ch) - } - return b.String() -} - -func isJSONWhitespace(ch byte) bool { - switch ch { - case ' ', '\t', '\r', '\n': - return true - default: - return false - } -} diff --git a/internal/codeguard/checks/design/design_graph_typescript_resolver_config.go b/internal/codeguard/checks/design/design_graph_typescript_resolver_config.go new file mode 100644 index 0000000..109839d --- /dev/null +++ b/internal/codeguard/checks/design/design_graph_typescript_resolver_config.go @@ -0,0 +1,147 @@ +package design + +import ( + "path" + "sort" + "strings" +) + +func effectiveTypeScriptConfig(resolver *typeScriptImportResolver, rel string, cache map[string]typeScriptGraphConfig, seen map[string]bool) (typeScriptGraphConfig, bool) { + if cfg, ok := cache[rel]; ok { + return cfg, true + } + if seen[rel] { + return typeScriptGraphConfig{}, false + } + doc, ok := resolver.tsconfigs[rel] + if !ok { + return typeScriptGraphConfig{}, false + } + seen[rel] = true + dir := normalizeTypeScriptPath(path.Dir(rel)) + effective := typeScriptCompilerOptions{} + if parentRel, ok := resolveTypeScriptConfigExtends(resolver, dir, doc.Extends); ok { + if parent, ok := effectiveTypeScriptConfig(resolver, parentRel, cache, seen); ok { + effective.BaseURL = parent.baseDir + effective.Paths = aliasesToPathMap(parent.paths) + } + } + effective = mergeTypeScriptCompilerOptions(dir, effective, doc.CompilerOptions) + cfg := typeScriptGraphConfig{ + dir: dir, + baseDir: effective.BaseURL, + paths: sortedTypeScriptAliases(effective.Paths), + } + if cfg.baseDir == "" { + cfg.baseDir = dir + } + cache[rel] = cfg + return cfg, true +} + +func aliasesToPathMap(aliases []typeScriptPathAlias) map[string][]string { + if len(aliases) == 0 { + return nil + } + out := make(map[string][]string, len(aliases)) + for _, alias := range aliases { + out[alias.pattern] = append([]string(nil), alias.targets...) + } + return out +} + +func mergeTypeScriptCompilerOptions(dir string, base typeScriptCompilerOptions, override typeScriptCompilerOptions) typeScriptCompilerOptions { + merged := typeScriptCompilerOptions{ + BaseURL: base.BaseURL, + Paths: cloneTypeScriptPathMap(base.Paths), + } + if strings.TrimSpace(override.BaseURL) != "" { + merged.BaseURL = normalizeTypeScriptPath(path.Join(dir, override.BaseURL)) + } + if len(override.Paths) > 0 { + merged.Paths = cloneTypeScriptPathMap(override.Paths) + } + if merged.BaseURL == "" { + merged.BaseURL = dir + } + return merged +} + +func cloneTypeScriptPathMap(paths map[string][]string) map[string][]string { + if len(paths) == 0 { + return nil + } + out := make(map[string][]string, len(paths)) + for key, values := range paths { + out[key] = append([]string(nil), values...) + } + return out +} + +func resolveTypeScriptConfigExtends(resolver *typeScriptImportResolver, dir string, value string) (string, bool) { + value = strings.TrimSpace(value) + if value == "" { + return "", false + } + candidates := []string{normalizeTypeScriptPath(path.Join(dir, value))} + if !strings.HasPrefix(value, ".") && !strings.HasPrefix(value, "/") { + candidates = append(candidates, workspaceTypeScriptConfigCandidates(resolver, value)...) + } + if !strings.HasSuffix(value, ".json") { + candidates = append(candidates, normalizeTypeScriptPath(path.Join(dir, value+".json"))) + if !strings.HasPrefix(value, ".") && !strings.HasPrefix(value, "/") { + candidates = append(candidates, workspaceTypeScriptConfigCandidates(resolver, value+".json")...) + } + } + candidates = append(candidates, normalizeTypeScriptPath(path.Join(dir, value, "tsconfig.json"))) + if !strings.HasPrefix(value, ".") && !strings.HasPrefix(value, "/") { + candidates = append(candidates, workspaceTypeScriptConfigCandidates(resolver, path.Join(value, "tsconfig.json"))...) + } + for _, candidate := range candidates { + if _, ok := resolver.tsconfigs[candidate]; ok { + return candidate, true + } + } + return "", false +} + +func workspaceTypeScriptConfigCandidates(resolver *typeScriptImportResolver, specifier string) []string { + root := typeScriptPackageRoot(specifier) + pkg, ok := resolver.packages[root] + if !ok { + return nil + } + subpath := strings.TrimPrefix(strings.TrimPrefix(specifier, root), "/") + if subpath == "" { + return []string{normalizeTypeScriptPath(path.Join(pkg.dir, "tsconfig.json"))} + } + return []string{normalizeTypeScriptPath(path.Join(pkg.dir, subpath))} +} + +func sortedTypeScriptAliases(paths map[string][]string) []typeScriptPathAlias { + aliases := make([]typeScriptPathAlias, 0, len(paths)) + for pattern, targets := range paths { + cleanTargets := make([]string, 0, len(targets)) + for _, target := range targets { + target = strings.TrimSpace(target) + if target == "" { + continue + } + cleanTargets = append(cleanTargets, target) + } + if len(cleanTargets) == 0 { + continue + } + aliases = append(aliases, typeScriptPathAlias{ + pattern: strings.TrimSpace(pattern), + targets: cleanTargets, + }) + } + sort.Slice(aliases, func(i, j int) bool { + if len(aliases[i].pattern) != len(aliases[j].pattern) { + return len(aliases[i].pattern) > len(aliases[j].pattern) + } + return aliases[i].pattern < aliases[j].pattern + }) + return aliases +} diff --git a/internal/codeguard/checks/design/design_graph_typescript_resolver_index.go b/internal/codeguard/checks/design/design_graph_typescript_resolver_index.go new file mode 100644 index 0000000..06def72 --- /dev/null +++ b/internal/codeguard/checks/design/design_graph_typescript_resolver_index.go @@ -0,0 +1,73 @@ +package design + +import ( + "encoding/json" + "path" + "sort" + "strings" +) + +func indexTypeScriptResolverMetadata(resolver *typeScriptImportResolver, rel string, data []byte) { + base := path.Base(rel) + switch { + case base == "package.json": + indexTypeScriptPackageManifest(resolver, path.Dir(rel), data) + case strings.HasSuffix(base, ".json"): + indexTypeScriptTSConfig(resolver, rel, data) + } +} + +func indexTypeScriptPackageManifest(resolver *typeScriptImportResolver, dir string, data []byte) { + var manifest typeScriptPackageManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return + } + name := strings.TrimSpace(manifest.Name) + if name == "" { + return + } + resolver.packages[name] = typeScriptWorkspacePackage{ + name: name, + dir: normalizeTypeScriptPath(dir), + main: strings.TrimSpace(manifest.Main), + module: strings.TrimSpace(manifest.Module), + source: strings.TrimSpace(manifest.Source), + types: strings.TrimSpace(manifest.Types), + exports: parseTypeScriptPackageExports(manifest.Exports), + imports: parseTypeScriptPackageImports(manifest.Imports), + } +} + +func indexTypeScriptTSConfig(resolver *typeScriptImportResolver, rel string, data []byte) { + var doc typeScriptConfigDocument + normalized, ok := normalizeJSONC(data) + if !ok || json.Unmarshal(normalized, &doc) != nil { + return + } + rel = normalizeTypeScriptPath(rel) + resolver.tsconfigs[rel] = doc + dir := normalizeTypeScriptPath(path.Dir(rel)) + primary, ok := resolver.tsconfigPrimary[dir] + if !ok || path.Base(rel) == "tsconfig.json" || path.Base(primary) != "tsconfig.json" { + resolver.tsconfigPrimary[dir] = rel + } +} + +func finalizeTypeScriptResolverConfigs(resolver *typeScriptImportResolver) { + resolver.configs = resolver.configs[:0] + dirs := make([]string, 0, len(resolver.tsconfigPrimary)) + for dir := range resolver.tsconfigPrimary { + dirs = append(dirs, dir) + } + sort.Slice(dirs, func(i, j int) bool { + return len(dirs[i]) > len(dirs[j]) + }) + cache := make(map[string]typeScriptGraphConfig, len(dirs)) + for _, dir := range dirs { + cfg, ok := effectiveTypeScriptConfig(resolver, resolver.tsconfigPrimary[dir], cache, make(map[string]bool)) + if !ok { + continue + } + resolver.configs = append(resolver.configs, cfg) + } +} diff --git a/internal/codeguard/checks/design/design_graph_typescript_resolver_jsonc.go b/internal/codeguard/checks/design/design_graph_typescript_resolver_jsonc.go new file mode 100644 index 0000000..cd02f64 --- /dev/null +++ b/internal/codeguard/checks/design/design_graph_typescript_resolver_jsonc.go @@ -0,0 +1,124 @@ +package design + +import "strings" + +func normalizeJSONC(data []byte) ([]byte, bool) { + withoutComments, ok := stripJSONCComments(string(data)) + if !ok { + return nil, false + } + return []byte(stripJSONCTrailingCommas(withoutComments)), true +} + +func stripJSONCComments(source string) (string, bool) { + var b strings.Builder + b.Grow(len(source)) + inString := false + escaped := false + for idx := 0; idx < len(source); idx++ { + ch := source[idx] + if inString { + escaped = appendJSONStringByte(&b, ch, escaped, &inString) + continue + } + if ch == '"' { + inString = true + b.WriteByte(ch) + continue + } + if next, handled := stripJSONCLineComment(source, idx); handled { + idx = next + continue + } + next, ok, handled := stripJSONCBlockComment(&b, source, idx) + if handled { + if !ok { + return "", false + } + idx = next + continue + } + b.WriteByte(ch) + } + return b.String(), !inString +} + +func appendJSONStringByte(b *strings.Builder, ch byte, escaped bool, inString *bool) bool { + b.WriteByte(ch) + if escaped { + return false + } + switch ch { + case '\\': + return true + case '"': + *inString = false + } + return false +} + +func stripJSONCLineComment(source string, idx int) (int, bool) { + if source[idx] != '/' || idx+1 >= len(source) || source[idx+1] != '/' { + return idx, false + } + for idx+1 < len(source) && source[idx+1] != '\n' { + idx++ + } + return idx, true +} + +func stripJSONCBlockComment(b *strings.Builder, source string, idx int) (int, bool, bool) { + if source[idx] != '/' || idx+1 >= len(source) || source[idx+1] != '*' { + return idx, true, false + } + idx += 2 + for idx < len(source) { + if idx+1 < len(source) && source[idx] == '*' && source[idx+1] == '/' { + return idx + 1, true, true + } + if source[idx] == '\n' { + b.WriteByte('\n') + } + idx++ + } + return idx, false, true +} + +func stripJSONCTrailingCommas(source string) string { + var b strings.Builder + b.Grow(len(source)) + inString := false + escaped := false + for idx := 0; idx < len(source); idx++ { + ch := source[idx] + if inString { + escaped = appendJSONStringByte(&b, ch, escaped, &inString) + continue + } + if ch == '"' { + inString = true + b.WriteByte(ch) + continue + } + if ch == ',' { + next := idx + 1 + for next < len(source) && isJSONWhitespace(source[next]) { + next++ + } + if next < len(source) && (source[next] == '}' || source[next] == ']') { + continue + } + } + b.WriteByte(ch) + } + return b.String() +} + +func isJSONWhitespace(ch byte) bool { + switch ch { + case ' ', '\t', '\r', '\n': + return true + default: + return false + } +} diff --git a/internal/codeguard/checks/design/design_graph_typescript_resolver_packages.go b/internal/codeguard/checks/design/design_graph_typescript_resolver_packages.go new file mode 100644 index 0000000..e0f1c01 --- /dev/null +++ b/internal/codeguard/checks/design/design_graph_typescript_resolver_packages.go @@ -0,0 +1,119 @@ +package design + +import ( + "encoding/json" + "sort" + "strings" +) + +func parseTypeScriptPackageExports(raw json.RawMessage) map[string][]string { + return parseTypeScriptPackageMappings(raw, true) +} + +func parseTypeScriptPackageImports(raw json.RawMessage) map[string][]string { + return parseTypeScriptPackageMappings(raw, false) +} + +func parseTypeScriptPackageMappings(raw json.RawMessage, isExports bool) map[string][]string { + if len(raw) == 0 { + return nil + } + var node any + if err := json.Unmarshal(raw, &node); err != nil { + return nil + } + mappings := make(map[string][]string) + switch value := node.(type) { + case string: + mappings["."] = append(mappings["."], value) + case map[string]any: + for key, child := range value { + appendTypeScriptPackageMapping(mappings, key, child, isExports) + } + } + for key, values := range mappings { + mappings[key] = uniqueNonEmptyStrings(values) + if len(mappings[key]) == 0 { + delete(mappings, key) + } + } + return mappings +} + +func appendTypeScriptPackageMapping(mappings map[string][]string, key string, child any, isExports bool) { + mappingKey := typeScriptPackageMappingKey(key, isExports) + mappings[mappingKey] = append(mappings[mappingKey], collectTypeScriptExportTargets(child)...) +} + +func typeScriptPackageMappingKey(key string, isExports bool) string { + switch { + case isExports && (key == "." || strings.HasPrefix(key, "./")): + return key + case !isExports && strings.HasPrefix(key, "#"): + return key + default: + return "." + } +} + +func collectTypeScriptExportTargets(node any) []string { + switch value := node.(type) { + case string: + return []string{value} + case []any: + out := make([]string, 0, len(value)) + for _, item := range value { + out = append(out, collectTypeScriptExportTargets(item)...) + } + return out + case map[string]any: + out := make([]string, 0, len(value)) + for _, key := range orderedTypeScriptConditionKeys(value) { + out = append(out, collectTypeScriptExportTargets(value[key])...) + } + return out + default: + return nil + } +} + +func orderedTypeScriptConditionKeys(values map[string]any) []string { + preferred := []string{ + "types", "source", "import", "module", "browser", "development", + "production", "node", "default", "require", + } + keys := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, key := range preferred { + if _, ok := values[key]; ok { + keys = append(keys, key) + seen[key] = struct{}{} + } + } + extra := make([]string, 0, len(values)) + for key := range values { + if _, ok := seen[key]; ok { + continue + } + extra = append(extra, key) + } + sort.Strings(extra) + return append(keys, extra...) +} + +func uniqueNonEmptyStrings(values []string) []string { + out := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(strings.TrimPrefix(value, "./")) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} diff --git a/internal/codeguard/checks/design/design_graph_typescript_resolver_paths.go b/internal/codeguard/checks/design/design_graph_typescript_resolver_paths.go new file mode 100644 index 0000000..157650c --- /dev/null +++ b/internal/codeguard/checks/design/design_graph_typescript_resolver_paths.go @@ -0,0 +1,95 @@ +package design + +import ( + "path" + "sort" + "strings" +) + +func typeScriptPathContains(parent string, child string) bool { + parent = normalizeTypeScriptPath(parent) + child = normalizeTypeScriptPath(child) + if parent == "." { + return true + } + return child == parent || strings.HasPrefix(child, parent+"/") +} + +func normalizeTypeScriptPath(value string) string { + value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/")) + if value == "" { + return "." + } + value = strings.TrimPrefix(path.Clean(value), "./") + if value == "" { + return "." + } + return value +} + +func typeScriptPackageRoot(specifier string) string { + if strings.HasPrefix(specifier, "@") { + parts := strings.Split(specifier, "/") + if len(parts) >= 2 { + return parts[0] + "/" + parts[1] + } + } + return firstTypeScriptSegment(specifier) +} + +func firstTypeScriptSegment(specifier string) string { + specifier = strings.TrimSpace(specifier) + if specifier == "" { + return "" + } + if cut := strings.IndexByte(specifier, '/'); cut >= 0 { + return specifier[:cut] + } + return specifier +} + +func matchTypeScriptAlias(pattern string, specifier string) (string, bool) { + if !strings.Contains(pattern, "*") { + return "", pattern == specifier + } + parts := strings.SplitN(pattern, "*", 2) + if !strings.HasPrefix(specifier, parts[0]) || !strings.HasSuffix(specifier, parts[1]) { + return "", false + } + return specifier[len(parts[0]) : len(specifier)-len(parts[1])], true +} + +func applyTypeScriptAliasTarget(target string, wildcard string) string { + if !strings.Contains(target, "*") { + return target + } + return strings.Replace(target, "*", wildcard, 1) +} + +func matchTypeScriptMapping(mappings map[string][]string, specifier string) []string { + if len(mappings) == 0 { + return nil + } + patterns := make([]string, 0, len(mappings)) + for pattern := range mappings { + patterns = append(patterns, pattern) + } + sort.Slice(patterns, func(i, j int) bool { + if len(patterns[i]) != len(patterns[j]) { + return len(patterns[i]) > len(patterns[j]) + } + return patterns[i] < patterns[j] + }) + for _, pattern := range patterns { + wildcard, ok := matchTypeScriptAlias(pattern, specifier) + if !ok { + continue + } + values := make([]string, 0, len(mappings[pattern])) + for _, target := range mappings[pattern] { + values = append(values, applyTypeScriptAliasTarget(target, wildcard)) + } + return values + } + return nil +} diff --git a/internal/codeguard/checks/design/design_graph_typescript_resolver_resolve.go b/internal/codeguard/checks/design/design_graph_typescript_resolver_resolve.go new file mode 100644 index 0000000..3235ab3 --- /dev/null +++ b/internal/codeguard/checks/design/design_graph_typescript_resolver_resolve.go @@ -0,0 +1,164 @@ +package design + +import ( + "path" + "strings" +) + +func resolveTypeScriptModuleImport(resolver *typeScriptImportResolver, fromModule string, specifier string) string { + if strings.HasPrefix(specifier, "./") || strings.HasPrefix(specifier, "../") || specifier == "." || specifier == ".." { + return resolveRelativeTypeScriptImport(resolver, fromModule, specifier) + } + if resolved := resolveTypeScriptPackageImport(resolver, fromModule, specifier); resolved != "" { + return resolved + } + if resolved := resolveTypeScriptConfigAlias(resolver, fromModule, specifier); resolved != "" { + return resolved + } + return resolveTypeScriptWorkspacePackage(resolver, specifier) +} + +func resolveRelativeTypeScriptImport(resolver *typeScriptImportResolver, fromModule string, specifier string) string { + return resolveTypeScriptModulePath(resolver, path.Clean(path.Join(path.Dir(fromModule), specifier))) +} + +func resolveTypeScriptPackageImport(resolver *typeScriptImportResolver, fromModule string, specifier string) string { + if !strings.HasPrefix(specifier, "#") { + return "" + } + pkg, ok := typeScriptPackageForModule(resolver, fromModule) + if !ok { + return "" + } + for _, candidate := range matchTypeScriptMapping(pkg.imports, specifier) { + if resolved := resolveTypeScriptModulePath(resolver, path.Join(pkg.dir, candidate)); resolved != "" { + return resolved + } + } + return "" +} + +func resolveTypeScriptConfigAlias(resolver *typeScriptImportResolver, fromModule string, specifier string) string { + cfg := typeScriptConfigForModule(resolver, fromModule) + if cfg == nil { + return "" + } + for _, alias := range cfg.paths { + wildcard, ok := matchTypeScriptAlias(alias.pattern, specifier) + if !ok { + continue + } + for _, target := range alias.targets { + applied := applyTypeScriptAliasTarget(target, wildcard) + if resolved := resolveTypeScriptModulePath(resolver, path.Join(cfg.baseDir, applied)); resolved != "" { + return resolved + } + } + } + if cfg.baseDir == "" { + return "" + } + return resolveTypeScriptModulePath(resolver, path.Join(cfg.baseDir, specifier)) +} + +func resolveTypeScriptWorkspacePackage(resolver *typeScriptImportResolver, specifier string) string { + root := typeScriptPackageRoot(specifier) + pkg, ok := resolver.packages[root] + if !ok { + return "" + } + if specifier == root { + return resolveTypeScriptWorkspacePackageEntrypoint(resolver, pkg) + } + subpath := strings.TrimPrefix(specifier, root+"/") + if subpath == specifier || subpath == "" { + return "" + } + for _, candidate := range matchTypeScriptMapping(pkg.exports, "./"+subpath) { + if resolved := resolveTypeScriptModulePath(resolver, path.Join(pkg.dir, candidate)); resolved != "" { + return resolved + } + } + for _, candidate := range []string{ + path.Join(pkg.dir, subpath), + path.Join(pkg.dir, "src", subpath), + } { + if resolved := resolveTypeScriptModulePath(resolver, candidate); resolved != "" { + return resolved + } + } + return "" +} + +func resolveTypeScriptWorkspacePackageEntrypoint(resolver *typeScriptImportResolver, pkg typeScriptWorkspacePackage) string { + for _, candidate := range matchTypeScriptMapping(pkg.exports, ".") { + if resolved := resolveTypeScriptModulePath(resolver, path.Join(pkg.dir, candidate)); resolved != "" { + return resolved + } + } + for _, candidate := range []string{pkg.types, pkg.source, pkg.module, pkg.main} { + if strings.TrimSpace(candidate) == "" { + continue + } + if resolved := resolveTypeScriptModulePath(resolver, path.Join(pkg.dir, candidate)); resolved != "" { + return resolved + } + } + for _, candidate := range []string{ + path.Join(pkg.dir, "index"), + path.Join(pkg.dir, "src", "index"), + } { + if resolved := resolveTypeScriptModulePath(resolver, candidate); resolved != "" { + return resolved + } + } + return "" +} + +func typeScriptPackageForModule(resolver *typeScriptImportResolver, module string) (typeScriptWorkspacePackage, bool) { + node := resolver.graph.modules[module] + if node == nil { + return typeScriptWorkspacePackage{}, false + } + file := normalizeTypeScriptPath(node.file) + best := typeScriptWorkspacePackage{} + bestLen := -1 + for _, pkg := range resolver.packages { + if !typeScriptPathContains(pkg.dir, file) { + continue + } + if len(pkg.dir) > bestLen { + best = pkg + bestLen = len(pkg.dir) + } + } + if bestLen < 0 { + return typeScriptWorkspacePackage{}, false + } + return best, true +} + +func resolveTypeScriptModulePath(resolver *typeScriptImportResolver, rel string) string { + rel = typeScriptModuleKey(normalizeTypeScriptPath(rel)) + for _, candidate := range []string{rel, rel + "/index"} { + if _, ok := resolver.graph.modules[candidate]; ok { + return candidate + } + } + return "" +} + +func typeScriptConfigForModule(resolver *typeScriptImportResolver, module string) *typeScriptGraphConfig { + node := resolver.graph.modules[module] + if node == nil { + return nil + } + dir := normalizeTypeScriptPath(path.Dir(node.file)) + for idx := range resolver.configs { + cfg := &resolver.configs[idx] + if typeScriptPathContains(cfg.dir, dir) { + return cfg + } + } + return nil +} diff --git a/internal/codeguard/checks/design/design_python_structure.go b/internal/codeguard/checks/design/design_python_structure.go index ecaf3c0..d3e0fee 100644 --- a/internal/codeguard/checks/design/design_python_structure.go +++ b/internal/codeguard/checks/design/design_python_structure.go @@ -2,41 +2,12 @@ package design import ( "fmt" - "regexp" "strings" "github.com/devr-tools/codeguard/internal/codeguard/checks/support" "github.com/devr-tools/codeguard/internal/codeguard/core" ) -var ( - pythonClassDeclPattern = regexp.MustCompile(`^class\s+([A-Za-z_]\w*)\s*(?:\((.*)\))?\s*:`) - pythonMethodDeclPattern = regexp.MustCompile(`^(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(`) - pythonProtocolAttrPattern = regexp.MustCompile(`^([A-Za-z_]\w*)\s*:`) -) - -type pythonTypeBlockKind string - -const ( - pythonTypeBlockClass pythonTypeBlockKind = "class" - pythonTypeBlockProtocol pythonTypeBlockKind = "protocol" -) - -type pythonTypeBlock struct { - kind pythonTypeBlockKind - name string - line int - headerIndent int - bodyIndent int - memberCount int -} - -type pythonTypeLogicalLine struct { - startLine int - indent int - text string -} - func pythonStructuralFindings(env support.Context, target core.TargetConfig) []core.Finding { return env.ScanTargetFiles(target, "design", func(rel string) bool { return strings.EqualFold(".py", filepathExt(rel)) @@ -78,194 +49,3 @@ func pythonStructuralFindingsForFile(env support.Context, file string, data []by } return findings } - -func pythonTypeBlocks(source string) []pythonTypeBlock { - masked := support.MaskPythonSource(strings.ReplaceAll(source, "\r\n", "\n")) - logical := pythonTypeLogicalLines(masked) - stack := make([]pythonTypeBlock, 0) - finished := make([]pythonTypeBlock, 0) - - for _, line := range logical { - for len(stack) > 0 && line.indent <= stack[len(stack)-1].headerIndent { - finished = append(finished, stack[len(stack)-1]) - stack = stack[:len(stack)-1] - } - - text := pythonCompactWhitespace(line.text) - if text == "" { - continue - } - if name, bases, ok := parsePythonClassDecl(text); ok { - stack = append(stack, pythonTypeBlock{ - kind: pythonTypeBlockForBases(bases), - name: name, - line: line.startLine, - headerIndent: line.indent, - bodyIndent: -1, - }) - continue - } - if len(stack) == 0 { - continue - } - - top := &stack[len(stack)-1] - if line.indent <= top.headerIndent { - continue - } - if top.bodyIndent < 0 { - top.bodyIndent = line.indent - } - if line.indent != top.bodyIndent { - continue - } - - if methodName, ok := parsePythonMethodDecl(text); ok { - if top.kind == pythonTypeBlockClass && methodName == "__init__" { - continue - } - top.memberCount++ - continue - } - if top.kind == pythonTypeBlockProtocol && isPythonProtocolAttribute(text) { - top.memberCount++ - } - } - - for len(stack) > 0 { - finished = append(finished, stack[len(stack)-1]) - stack = stack[:len(stack)-1] - } - return finished -} - -func pythonTypeLogicalLines(masked string) []pythonTypeLogicalLine { - lines := strings.Split(masked, "\n") - logical := make([]pythonTypeLogicalLine, 0, len(lines)) - current := pythonTypeLogicalLine{} - depth := 0 - open := false - - for idx, line := range lines { - if !open { - current = pythonTypeLogicalLine{startLine: idx + 1, indent: pythonIndentWidth(line)} - } else { - current.text += "\n" - } - current.text += line - depth += pythonBracketDelta(line) - open = depth > 0 || strings.HasSuffix(strings.TrimRight(line, " \t"), "\\") - if open { - continue - } - if strings.TrimSpace(current.text) != "" { - logical = append(logical, current) - } - } - - if open && strings.TrimSpace(current.text) != "" { - logical = append(logical, current) - } - return logical -} - -func parsePythonClassDecl(text string) (string, string, bool) { - match := pythonClassDeclPattern.FindStringSubmatch(text) - if len(match) != 3 { - return "", "", false - } - return match[1], match[2], true -} - -func parsePythonMethodDecl(text string) (string, bool) { - match := pythonMethodDeclPattern.FindStringSubmatch(text) - if len(match) != 2 { - return "", false - } - return match[1], true -} - -func pythonTypeBlockForBases(bases string) pythonTypeBlockKind { - for _, base := range pythonSplitTopLevelCSV(bases) { - base = strings.TrimSpace(base) - if cut := strings.IndexByte(base, '['); cut >= 0 { - base = base[:cut] - } - if base == "Protocol" || strings.HasSuffix(base, ".Protocol") { - return pythonTypeBlockProtocol - } - } - return pythonTypeBlockClass -} - -func isPythonProtocolAttribute(text string) bool { - if strings.HasPrefix(text, "@") { - return false - } - match := pythonProtocolAttrPattern.FindStringSubmatch(text) - return len(match) == 2 && match[1] != "" -} - -func pythonSplitTopLevelCSV(text string) []string { - parts := make([]string, 0, 2) - depth := 0 - start := 0 - for idx := 0; idx < len(text); idx++ { - switch text[idx] { - case '(', '[', '{': - depth++ - case ')', ']', '}': - if depth > 0 { - depth-- - } - case ',': - if depth == 0 { - parts = append(parts, text[start:idx]) - start = idx + 1 - } - } - } - if start <= len(text) { - parts = append(parts, text[start:]) - } - return parts -} - -func pythonCompactWhitespace(text string) string { - return strings.Join(strings.Fields(text), " ") -} - -func pythonBracketDelta(line string) int { - delta := 0 - for idx := 0; idx < len(line); idx++ { - switch line[idx] { - case '(', '[', '{': - delta++ - case ')', ']', '}': - delta-- - } - } - return delta -} - -func pythonIndentWidth(line string) int { - width := 0 - for _, ch := range line { - switch ch { - case ' ': - width++ - case '\t': - width += 4 - default: - return width - } - } - return width -} - -func filepathExt(path string) string { - if idx := strings.LastIndexByte(path, '.'); idx >= 0 { - return path[idx:] - } - return "" -} diff --git a/internal/codeguard/checks/design/design_python_structure_helpers.go b/internal/codeguard/checks/design/design_python_structure_helpers.go new file mode 100644 index 0000000..0e3ca72 --- /dev/null +++ b/internal/codeguard/checks/design/design_python_structure_helpers.go @@ -0,0 +1,100 @@ +package design + +import ( + "strings" +) + +type pythonTypeBlockKind string + +const ( + pythonTypeBlockClass pythonTypeBlockKind = "class" + pythonTypeBlockProtocol pythonTypeBlockKind = "protocol" +) + +type pythonTypeBlock struct { + kind pythonTypeBlockKind + name string + line int + headerIndent int + bodyIndent int + memberCount int +} + +type pythonTypeLogicalLine struct { + startLine int + indent int + text string +} + +func parsePythonClassDecl(text string) (string, string, bool) { + match := pythonClassDeclPattern.FindStringSubmatch(text) + if len(match) != 3 { + return "", "", false + } + return match[1], match[2], true +} + +func parsePythonMethodDecl(text string) (string, bool) { + match := pythonMethodDeclPattern.FindStringSubmatch(text) + if len(match) != 2 { + return "", false + } + return match[1], true +} + +func pythonTypeBlockForBases(bases string) pythonTypeBlockKind { + for _, base := range pythonSplitTopLevelCSV(bases) { + base = strings.TrimSpace(base) + if cut := strings.IndexByte(base, '['); cut >= 0 { + base = base[:cut] + } + if base == "Protocol" || strings.HasSuffix(base, ".Protocol") { + return pythonTypeBlockProtocol + } + } + return pythonTypeBlockClass +} + +func isPythonProtocolAttribute(text string) bool { + if strings.HasPrefix(text, "@") { + return false + } + match := pythonProtocolAttrPattern.FindStringSubmatch(text) + return len(match) == 2 && match[1] != "" +} + +func pythonSplitTopLevelCSV(text string) []string { + parts := make([]string, 0, 2) + depth := 0 + start := 0 + for idx := 0; idx < len(text); idx++ { + switch text[idx] { + case '(', '[', '{': + depth++ + case ')', ']', '}': + if depth > 0 { + depth-- + } + case ',': + if depth == 0 { + parts = append(parts, text[start:idx]) + start = idx + 1 + } + } + } + if start <= len(text) { + parts = append(parts, text[start:]) + } + return parts +} + +func pythonCompactWhitespace(text string) string { + return strings.Join(strings.Fields(text), " ") +} + +func filepathExt(path string) string { + if idx := strings.LastIndexByte(path, '.'); idx >= 0 { + return path[idx:] + } + return "" +} diff --git a/internal/codeguard/checks/design/design_python_structure_patterns.go b/internal/codeguard/checks/design/design_python_structure_patterns.go new file mode 100644 index 0000000..5d56850 --- /dev/null +++ b/internal/codeguard/checks/design/design_python_structure_patterns.go @@ -0,0 +1,9 @@ +package design + +import "regexp" + +var ( + pythonClassDeclPattern = regexp.MustCompile(`^class\s+([A-Za-z_]\w*)\s*(?:\((.*)\))?\s*:`) + pythonMethodDeclPattern = regexp.MustCompile(`^(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(`) + pythonProtocolAttrPattern = regexp.MustCompile(`^([A-Za-z_]\w*)\s*:`) +) diff --git a/internal/codeguard/checks/design/design_python_structure_scan.go b/internal/codeguard/checks/design/design_python_structure_scan.go new file mode 100644 index 0000000..51697fb --- /dev/null +++ b/internal/codeguard/checks/design/design_python_structure_scan.go @@ -0,0 +1,148 @@ +package design + +import ( + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" +) + +type pythonTypeScanner struct { + stack []pythonTypeBlock + finished []pythonTypeBlock +} + +func pythonTypeBlocks(source string) []pythonTypeBlock { + masked := support.MaskPythonSource(strings.ReplaceAll(source, "\r\n", "\n")) + scanner := pythonTypeScanner{} + for _, line := range pythonTypeLogicalLines(masked) { + scanner.consume(line) + } + return scanner.finish() +} + +func (scanner *pythonTypeScanner) consume(line pythonTypeLogicalLine) { + scanner.closeFinishedBlocks(line.indent) + + text := pythonCompactWhitespace(line.text) + if text == "" { + return + } + if scanner.openClassBlock(line, text) { + return + } + scanner.countTopLevelMember(line, text) +} + +func (scanner *pythonTypeScanner) closeFinishedBlocks(indent int) { + for len(scanner.stack) > 0 && indent <= scanner.stack[len(scanner.stack)-1].headerIndent { + scanner.finished = append(scanner.finished, scanner.stack[len(scanner.stack)-1]) + scanner.stack = scanner.stack[:len(scanner.stack)-1] + } +} + +func (scanner *pythonTypeScanner) openClassBlock(line pythonTypeLogicalLine, text string) bool { + name, bases, ok := parsePythonClassDecl(text) + if !ok { + return false + } + scanner.stack = append(scanner.stack, pythonTypeBlock{ + kind: pythonTypeBlockForBases(bases), + name: name, + line: line.startLine, + headerIndent: line.indent, + bodyIndent: -1, + }) + return true +} + +func (scanner *pythonTypeScanner) countTopLevelMember(line pythonTypeLogicalLine, text string) { + if len(scanner.stack) == 0 { + return + } + + top := &scanner.stack[len(scanner.stack)-1] + if line.indent <= top.headerIndent { + return + } + if top.bodyIndent < 0 { + top.bodyIndent = line.indent + } + if line.indent != top.bodyIndent { + return + } + if methodName, ok := parsePythonMethodDecl(text); ok { + if top.kind != pythonTypeBlockClass || methodName != "__init__" { + top.memberCount++ + } + return + } + if top.kind == pythonTypeBlockProtocol && isPythonProtocolAttribute(text) { + top.memberCount++ + } +} + +func (scanner *pythonTypeScanner) finish() []pythonTypeBlock { + for len(scanner.stack) > 0 { + scanner.finished = append(scanner.finished, scanner.stack[len(scanner.stack)-1]) + scanner.stack = scanner.stack[:len(scanner.stack)-1] + } + return scanner.finished +} + +func pythonTypeLogicalLines(masked string) []pythonTypeLogicalLine { + lines := strings.Split(masked, "\n") + logical := make([]pythonTypeLogicalLine, 0, len(lines)) + current := pythonTypeLogicalLine{} + depth := 0 + open := false + + for idx, line := range lines { + if !open { + current = pythonTypeLogicalLine{startLine: idx + 1, indent: pythonIndentWidth(line)} + } else { + current.text += "\n" + } + current.text += line + depth += pythonBracketDelta(line) + open = depth > 0 || strings.HasSuffix(strings.TrimRight(line, " \t"), "\\") + if open { + continue + } + if strings.TrimSpace(current.text) != "" { + logical = append(logical, current) + } + } + + if open && strings.TrimSpace(current.text) != "" { + logical = append(logical, current) + } + return logical +} + +func pythonBracketDelta(line string) int { + delta := 0 + for idx := 0; idx < len(line); idx++ { + switch line[idx] { + case '(', '[', '{': + delta++ + case ')', ']', '}': + delta-- + } + } + return delta +} + +func pythonIndentWidth(line string) int { + width := 0 + for _, ch := range line { + switch ch { + case ' ': + width++ + case '\t': + width += 4 + default: + return width + } + } + return width +} diff --git a/internal/codeguard/checks/design/design_rust.go b/internal/codeguard/checks/design/design_rust.go index 651bde0..1b6601f 100644 --- a/internal/codeguard/checks/design/design_rust.go +++ b/internal/codeguard/checks/design/design_rust.go @@ -3,40 +3,19 @@ package design import ( "fmt" "path/filepath" - "regexp" "strings" "github.com/devr-tools/codeguard/internal/codeguard/checks/support" "github.com/devr-tools/codeguard/internal/codeguard/core" ) -var ( - rustTraitPattern = regexp.MustCompile(`^\s*(?:pub(?:\([^)]*\))?\s+)?(?:unsafe\s+)?trait\s+([A-Za-z_]\w*)\b`) - rustImplPattern = regexp.MustCompile(`^\s*impl\b`) - rustMethodPattern = regexp.MustCompile(`^\s*(?:pub(?:\([^)]*\))?\s+)?(?:default\s+)?(?:async\s+)?(?:const\s+)?(?:unsafe\s+)?fn\s+([A-Za-z_]\w*)\b`) - rustTraitMemberPattern = regexp.MustCompile(`^\s*(?:(?:default\s+)?(?:async\s+)?(?:const\s+)?(?:unsafe\s+)?fn\s+[A-Za-z_]\w*\b|type\s+[A-Za-z_]\w*\b|const\s+[A-Za-z_]\w*\b)`) -) - -type rustBlockKind int - -const ( - rustBlockImpl rustBlockKind = iota - rustBlockTrait -) - -type rustBlock struct { - kind rustBlockKind - name string - header string - line int - bodyDepth int - waiting bool - count int -} - -type rustCountSummary struct { - line int - count int +type rustFileScan struct { + env support.Context + file string + findings []core.Finding + active *rustBlock + depth int + methodCounts map[string]rustCountSummary } func rustTargetFindings(env support.Context, target core.TargetConfig) []core.Finding { @@ -54,29 +33,30 @@ func RustFindingsForFile(env support.Context, file string, data []byte) []core.F } func rustFindingsForFile(env support.Context, file string, data []byte) []core.Finding { - findings := rustGenericModuleNameFindings(env, file) + scan := rustFileScan{ + env: env, + file: file, + findings: rustGenericModuleNameFindings(env, file), + methodCounts: map[string]rustCountSummary{}, + } lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") - methodCounts := map[string]rustCountSummary{} - - depth := 0 - var active *rustBlock for idx, raw := range lines { line := stripLineComment(raw) - active = nextRustBlock(active, depth, line, idx+1) - updateRustBlockHeader(active, line) - countRustBlockMember(active, depth, line) - depth += braceDelta(line) - openRustBlock(active, depth, line) - findings, active = closeRustBlock(findings, env, file, active, depth, methodCounts) + scan.active = nextRustBlock(scan.active, scan.depth, line, idx+1) + updateRustBlockHeader(scan.active, line) + countRustBlockMember(scan.active, scan.depth, line) + scan.depth += braceDelta(line) + openRustBlock(scan.active, scan.depth, line) + closeRustBlock(&scan) } - if active != nil && !active.waiting { - findings = append(findings, finalizeRustBlock(env, file, *active, methodCounts)...) + if scan.active != nil && !scan.active.waiting { + scan.findings = append(scan.findings, finalizeRustBlock(scan.env, scan.file, *scan.active, scan.methodCounts)...) } - findings = append(findings, rustMethodFindings(env, file, methodCounts)...) - return findings + scan.findings = append(scan.findings, rustMethodFindings(scan.env, scan.file, scan.methodCounts)...) + return scan.findings } func rustGenericModuleNameFindings(env support.Context, file string) []core.Finding { @@ -114,178 +94,3 @@ func normalizedRustModuleName(path string) string { return base } } - -func nextRustBlock(active *rustBlock, depth int, line string, lineNo int) *rustBlock { - if active != nil || depth != 0 { - return active - } - if match := rustTraitPattern.FindStringSubmatch(line); len(match) == 2 { - return &rustBlock{kind: rustBlockTrait, name: match[1], line: lineNo, waiting: true} - } - if rustImplPattern.MatchString(line) { - return &rustBlock{kind: rustBlockImpl, line: lineNo, waiting: true} - } - return nil -} - -func updateRustBlockHeader(active *rustBlock, line string) { - if active == nil || !active.waiting { - return - } - trimmed := strings.TrimSpace(line) - if trimmed == "" { - return - } - if active.header != "" { - active.header += " " - } - active.header += trimmed -} - -func countRustBlockMember(active *rustBlock, depth int, line string) { - if active == nil || active.waiting || depth != active.bodyDepth { - return - } - trimmed := strings.TrimSpace(line) - if trimmed == "" || trimmed == "}" { - return - } - switch active.kind { - case rustBlockImpl: - if rustMethodPattern.MatchString(trimmed) { - active.count++ - } - case rustBlockTrait: - if rustTraitMemberPattern.MatchString(trimmed) { - active.count++ - } - } -} - -func openRustBlock(active *rustBlock, depth int, line string) { - if active == nil || !active.waiting || !strings.Contains(line, "{") { - return - } - if active.kind == rustBlockImpl { - active.name = rustImplTargetName(active.header) - } - active.waiting = false - active.bodyDepth = depth -} - -func closeRustBlock(findings []core.Finding, env support.Context, file string, active *rustBlock, depth int, methodCounts map[string]rustCountSummary) ([]core.Finding, *rustBlock) { - if active == nil || active.waiting || depth >= active.bodyDepth { - return findings, active - } - findings = append(findings, finalizeRustBlock(env, file, *active, methodCounts)...) - return findings, nil -} - -func finalizeRustBlock(env support.Context, file string, block rustBlock, methodCounts map[string]rustCountSummary) []core.Finding { - switch block.kind { - case rustBlockImpl: - if block.name == "" || block.count == 0 { - return nil - } - summary := methodCounts[block.name] - if summary.line == 0 { - summary.line = block.line - } - summary.count += block.count - methodCounts[block.name] = summary - case rustBlockTrait: - if block.name == "" || block.count <= env.Config.Checks.DesignRules.MaxInterfaceMethods { - return nil - } - return []core.Finding{env.NewFinding(support.FindingInput{ - RuleID: "design.rust.max-trait-members", - Level: "warn", - Path: file, - Line: block.line, - Column: 1, - Message: fmt.Sprintf("trait %s exposes %d members; max is %d", block.name, block.count, env.Config.Checks.DesignRules.MaxInterfaceMethods), - })} - } - return nil -} - -func rustMethodFindings(env support.Context, file string, methodCounts map[string]rustCountSummary) []core.Finding { - findings := make([]core.Finding, 0) - for typeName, summary := range methodCounts { - if summary.count <= env.Config.Checks.DesignRules.MaxMethodsPerType { - continue - } - line := summary.line - if line == 0 { - line = 1 - } - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "design.rust.max-methods-per-type", - Level: "warn", - Path: file, - Line: line, - Column: 1, - Message: fmt.Sprintf("type %s has %d impl methods in this file; max is %d", typeName, summary.count, env.Config.Checks.DesignRules.MaxMethodsPerType), - })) - } - return findings -} - -func rustImplTargetName(header string) string { - header = strings.Join(strings.Fields(strings.Split(header, "{")[0]), " ") - header = strings.TrimSpace(strings.TrimPrefix(header, "impl")) - header = strings.TrimSpace(trimRustGenericPrefix(header)) - if header == "" { - return "" - } - if idx := strings.LastIndex(header, " for "); idx >= 0 { - header = header[idx+5:] - } - if idx := strings.Index(header, " where "); idx >= 0 { - header = header[:idx] - } - return rustPrimaryTypeName(strings.TrimSpace(header)) -} - -func trimRustGenericPrefix(header string) string { - if !strings.HasPrefix(header, "<") { - return header - } - depth := 0 - for idx, char := range header { - switch char { - case '<': - depth++ - case '>': - depth-- - if depth == 0 { - return strings.TrimSpace(header[idx+1:]) - } - } - } - return header -} - -func rustPrimaryTypeName(target string) string { - target = strings.TrimSpace(target) - for { - target = strings.TrimSpace(strings.TrimPrefix(target, "&")) - target = strings.TrimSpace(strings.TrimPrefix(target, "mut ")) - target = strings.TrimSpace(strings.TrimPrefix(target, "dyn ")) - fields := strings.Fields(target) - if len(fields) > 0 && strings.HasPrefix(fields[0], "'") { - target = strings.TrimSpace(strings.Join(fields[1:], " ")) - continue - } - break - } - for _, sep := range []string{"<", " ", "(", "[", "{"} { - if idx := strings.Index(target, sep); idx >= 0 { - target = target[:idx] - } - } - if idx := strings.LastIndex(target, "::"); idx >= 0 { - target = target[idx+2:] - } - return strings.TrimSpace(target) -} diff --git a/internal/codeguard/checks/design/design_rust_blocks.go b/internal/codeguard/checks/design/design_rust_blocks.go new file mode 100644 index 0000000..8e644fd --- /dev/null +++ b/internal/codeguard/checks/design/design_rust_blocks.go @@ -0,0 +1,147 @@ +package design + +import ( + "fmt" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +type rustBlockKind int + +const ( + rustBlockImpl rustBlockKind = iota + rustBlockTrait +) + +type rustBlock struct { + kind rustBlockKind + name string + header string + line int + bodyDepth int + waiting bool + count int +} + +type rustCountSummary struct { + line int + count int +} + +func nextRustBlock(active *rustBlock, depth int, line string, lineNo int) *rustBlock { + if active != nil || depth != 0 { + return active + } + if match := rustTraitPattern.FindStringSubmatch(line); len(match) == 2 { + return &rustBlock{kind: rustBlockTrait, name: match[1], line: lineNo, waiting: true} + } + if rustImplPattern.MatchString(line) { + return &rustBlock{kind: rustBlockImpl, line: lineNo, waiting: true} + } + return nil +} + +func updateRustBlockHeader(active *rustBlock, line string) { + if active == nil || !active.waiting { + return + } + trimmed := strings.TrimSpace(line) + if trimmed == "" { + return + } + if active.header != "" { + active.header += " " + } + active.header += trimmed +} + +func countRustBlockMember(active *rustBlock, depth int, line string) { + if active == nil || active.waiting || depth != active.bodyDepth { + return + } + trimmed := strings.TrimSpace(line) + if trimmed == "" || trimmed == "}" { + return + } + switch active.kind { + case rustBlockImpl: + if rustMethodPattern.MatchString(trimmed) { + active.count++ + } + case rustBlockTrait: + if rustTraitMemberPattern.MatchString(trimmed) { + active.count++ + } + } +} + +func openRustBlock(active *rustBlock, depth int, line string) { + if active == nil || !active.waiting || !strings.Contains(line, "{") { + return + } + if active.kind == rustBlockImpl { + active.name = rustImplTargetName(active.header) + } + active.waiting = false + active.bodyDepth = depth +} + +func closeRustBlock(scan *rustFileScan) { + if scan.active == nil || scan.active.waiting || scan.depth >= scan.active.bodyDepth { + return + } + scan.findings = append(scan.findings, finalizeRustBlock(scan.env, scan.file, *scan.active, scan.methodCounts)...) + scan.active = nil +} + +func finalizeRustBlock(env support.Context, file string, block rustBlock, methodCounts map[string]rustCountSummary) []core.Finding { + switch block.kind { + case rustBlockImpl: + if block.name == "" || block.count == 0 { + return nil + } + summary := methodCounts[block.name] + if summary.line == 0 { + summary.line = block.line + } + summary.count += block.count + methodCounts[block.name] = summary + case rustBlockTrait: + if block.name == "" || block.count <= env.Config.Checks.DesignRules.MaxInterfaceMethods { + return nil + } + return []core.Finding{env.NewFinding(support.FindingInput{ + RuleID: "design.rust.max-trait-members", + Level: "warn", + Path: file, + Line: block.line, + Column: 1, + Message: fmt.Sprintf("trait %s exposes %d members; max is %d", block.name, block.count, env.Config.Checks.DesignRules.MaxInterfaceMethods), + })} + } + return nil +} + +func rustMethodFindings(env support.Context, file string, methodCounts map[string]rustCountSummary) []core.Finding { + findings := make([]core.Finding, 0) + for typeName, summary := range methodCounts { + if summary.count <= env.Config.Checks.DesignRules.MaxMethodsPerType { + continue + } + line := summary.line + if line == 0 { + line = 1 + } + findings = append(findings, env.NewFinding(support.FindingInput{ + RuleID: "design.rust.max-methods-per-type", + Level: "warn", + Path: file, + Line: line, + Column: 1, + Message: fmt.Sprintf("type %s has %d impl methods in this file; max is %d", typeName, summary.count, env.Config.Checks.DesignRules.MaxMethodsPerType), + })) + } + return findings +} diff --git a/internal/codeguard/checks/design/design_rust_names.go b/internal/codeguard/checks/design/design_rust_names.go new file mode 100644 index 0000000..78f6588 --- /dev/null +++ b/internal/codeguard/checks/design/design_rust_names.go @@ -0,0 +1,62 @@ +package design + +import "strings" + +func rustImplTargetName(header string) string { + header = strings.Join(strings.Fields(strings.Split(header, "{")[0]), " ") + header = strings.TrimSpace(strings.TrimPrefix(header, "impl")) + header = strings.TrimSpace(trimRustGenericPrefix(header)) + if header == "" { + return "" + } + if idx := strings.LastIndex(header, " for "); idx >= 0 { + header = header[idx+5:] + } + if idx := strings.Index(header, " where "); idx >= 0 { + header = header[:idx] + } + return rustPrimaryTypeName(strings.TrimSpace(header)) +} + +func trimRustGenericPrefix(header string) string { + if !strings.HasPrefix(header, "<") { + return header + } + depth := 0 + for idx, char := range header { + switch char { + case '<': + depth++ + case '>': + depth-- + if depth == 0 { + return strings.TrimSpace(header[idx+1:]) + } + } + } + return header +} + +func rustPrimaryTypeName(target string) string { + target = strings.TrimSpace(target) + for { + target = strings.TrimSpace(strings.TrimPrefix(target, "&")) + target = strings.TrimSpace(strings.TrimPrefix(target, "mut ")) + target = strings.TrimSpace(strings.TrimPrefix(target, "dyn ")) + fields := strings.Fields(target) + if len(fields) > 0 && strings.HasPrefix(fields[0], "'") { + target = strings.TrimSpace(strings.Join(fields[1:], " ")) + continue + } + break + } + for _, sep := range []string{"<", " ", "(", "[", "{"} { + if idx := strings.Index(target, sep); idx >= 0 { + target = target[:idx] + } + } + if idx := strings.LastIndex(target, "::"); idx >= 0 { + target = target[idx+2:] + } + return strings.TrimSpace(target) +} diff --git a/internal/codeguard/checks/design/design_rust_patterns.go b/internal/codeguard/checks/design/design_rust_patterns.go new file mode 100644 index 0000000..1e7ce2f --- /dev/null +++ b/internal/codeguard/checks/design/design_rust_patterns.go @@ -0,0 +1,10 @@ +package design + +import "regexp" + +var ( + rustTraitPattern = regexp.MustCompile(`^\s*(?:pub(?:\([^)]*\))?\s+)?(?:unsafe\s+)?trait\s+([A-Za-z_]\w*)\b`) + rustImplPattern = regexp.MustCompile(`^\s*impl\b`) + rustMethodPattern = regexp.MustCompile(`^\s*(?:pub(?:\([^)]*\))?\s+)?(?:default\s+)?(?:async\s+)?(?:const\s+)?(?:unsafe\s+)?fn\s+([A-Za-z_]\w*)\b`) + rustTraitMemberPattern = regexp.MustCompile(`^\s*(?:(?:default\s+)?(?:async\s+)?(?:const\s+)?(?:unsafe\s+)?fn\s+[A-Za-z_]\w*\b|type\s+[A-Za-z_]\w*\b|const\s+[A-Za-z_]\w*\b)`) +) diff --git a/internal/codeguard/checks/support/cpp_dependency_graph.go b/internal/codeguard/checks/support/cpp_dependency_graph.go index 2b81b12..5170183 100644 --- a/internal/codeguard/checks/support/cpp_dependency_graph.go +++ b/internal/codeguard/checks/support/cpp_dependency_graph.go @@ -3,18 +3,12 @@ package support import ( "path" "path/filepath" - "regexp" "strings" "github.com/devr-tools/codeguard/internal/codeguard/core" "github.com/devr-tools/codeguard/internal/codeguard/cpp/compdb" ) -var ( - cppModuleDeclarationPattern = regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?module[ \t]+([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?::[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)?)[ \t]*;`) - cppModuleImportPattern = regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?import[ \t]+((?:[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?::[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)?)|(?::[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*))[ \t]*;`) -) - // CPPDependencyGraph captures target-local #include and C++20 named-module // dependencies. Nodes are files because headers, unlike Go packages, are the // unit whose edits fan out compilation work. @@ -48,11 +42,11 @@ func BuildCPPDependencyGraph(env Context, target core.TargetConfig) *CPPDependen for _, imported := range parsed.Imports { pending = append(pending, pendingCPPDependency{from: rel, target: imported.Module, line: imported.Line}) } - if match := cppModuleDeclarationPattern.FindStringSubmatch(parsed.Masked); match != nil { + if match := CPPModuleDeclarationPattern.FindStringSubmatch(parsed.Masked); match != nil { declaredModules[rel] = match[1] moduleFiles[match[1]] = rel } - for _, match := range cppModuleImportPattern.FindAllStringSubmatchIndex(parsed.Masked, -1) { + for _, match := range CPPModuleImportPattern.FindAllStringSubmatchIndex(parsed.Masked, -1) { pending = append(pending, pendingCPPDependency{ from: rel, target: parsed.Masked[match[2]:match[3]], line: LineNumberForOffset(parsed.Masked, match[0]), named: true, @@ -67,7 +61,7 @@ func BuildCPPDependencyGraph(env Context, target core.TargetConfig) *CPPDependen for _, dependency := range pending { to := "" if dependency.named { - dependency.target = qualifyCPPModuleImport(dependency.target, declaredModules[dependency.from]) + dependency.target = QualifyCPPModuleImport(dependency.target, declaredModules[dependency.from]) to = moduleFiles[dependency.target] } else { to = resolveCPPInclude(nodes, dependency.from, dependency.target, includeRoots) @@ -89,28 +83,6 @@ func BuildCPPDependencyGraph(env Context, target core.TargetConfig) *CPPDependen return &CPPDependencyGraph{Graph: NewDependencyGraph(nodes), FileToModule: fileToModule} } -func qualifyCPPModuleImport(specifier string, declaredModule string) string { - if !strings.HasPrefix(specifier, ":") { - return specifier - } - primary := cppPrimaryModuleName(declaredModule) - if primary == "" { - return "" - } - return primary + specifier -} - -func cppPrimaryModuleName(module string) string { - module = strings.TrimSpace(module) - if module == "" { - return "" - } - if cut := strings.IndexByte(module, ':'); cut >= 0 { - return module[:cut] - } - return module -} - func resolveCPPInclude(nodes map[string]DependencyNode, from string, imported string, includeRoots []string) string { imported = filepath.ToSlash(imported) candidates := make([]string, 0, 2+len(includeRoots)) diff --git a/internal/codeguard/checks/support/cpp_modules.go b/internal/codeguard/checks/support/cpp_modules.go new file mode 100644 index 0000000..4bc1018 --- /dev/null +++ b/internal/codeguard/checks/support/cpp_modules.go @@ -0,0 +1,33 @@ +package support + +import ( + "regexp" + "strings" +) + +var ( + CPPModuleDeclarationPattern = regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?module[ \t]+([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?::[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)?)[ \t]*;`) + CPPModuleImportPattern = regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?import[ \t]+((?:[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?::[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)?)|(?::[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*))[ \t]*;`) +) + +func QualifyCPPModuleImport(specifier string, declaredModule string) string { + if !strings.HasPrefix(specifier, ":") { + return specifier + } + primary := cppPrimaryModuleName(declaredModule) + if primary == "" { + return "" + } + return primary + specifier +} + +func cppPrimaryModuleName(module string) string { + module = strings.TrimSpace(module) + if module == "" { + return "" + } + if cut := strings.IndexByte(module, ':'); cut >= 0 { + return module[:cut] + } + return module +} diff --git a/tests/checks/design_graph_helpers_test.go b/tests/checks/design_graph_helpers_test.go new file mode 100644 index 0000000..d71b6fb --- /dev/null +++ b/tests/checks/design_graph_helpers_test.go @@ -0,0 +1,35 @@ +package checks_test + +import ( + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func graphTestConfig(name string, dir string, language string) codeguard.Config { + cfg := codeguard.ExampleConfig() + cfg.Name = name + cfg.Targets = []codeguard.TargetConfig{{Name: "repo", Path: dir, Language: language}} + cfg.Checks.Design = true + cfg.Checks.Quality = false + cfg.Checks.Security = false + cfg.Checks.Prompts = false + cfg.Checks.CI = false + return cfg +} + +func assertFindingRuleAbsent(t *testing.T, report codeguard.Report, section string, ruleID string) { + t.Helper() + for _, result := range report.Sections { + if result.Name != section { + continue + } + for _, finding := range result.Findings { + if finding.RuleID == ruleID { + t.Fatalf("section %q unexpectedly contains rule %q: %s", section, ruleID, finding.Message) + } + } + return + } + t.Fatalf("section %q not found", section) +} diff --git a/tests/checks/design_graph_languages_native_test.go b/tests/checks/design_graph_languages_native_test.go new file mode 100644 index 0000000..744ee8b --- /dev/null +++ b/tests/checks/design_graph_languages_native_test.go @@ -0,0 +1,65 @@ +package checks_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func TestDesignCheckFailsForRustModuleCycle(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "lib.rs"), "mod reader;\nmod writer;\n") + writeFile(t, filepath.Join(dir, "src", "reader.rs"), "use crate::writer::Writer;\n\npub struct Reader;\n") + writeFile(t, filepath.Join(dir, "src", "writer.rs"), "use crate::reader::Reader;\n\npub struct Writer;\n") + + report, err := codeguard.Run(context.Background(), graphTestConfig("design-rust-cycle", dir, "rust")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Design Patterns", "fail") + assertFindingRulePresent(t, report, "Design Patterns", "design.rust.import-cycle") +} + +func TestDesignCheckPassesForAcyclicRustModules(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "lib.rs"), "mod reader;\nmod writer;\n") + writeFile(t, filepath.Join(dir, "src", "reader.rs"), "use crate::writer::Writer;\n\npub struct Reader;\n") + writeFile(t, filepath.Join(dir, "src", "writer.rs"), "pub struct Writer;\n") + + report, err := codeguard.Run(context.Background(), graphTestConfig("design-rust-no-cycle", dir, "rust")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRuleAbsent(t, report, "Design Patterns", "design.rust.import-cycle") +} + +func TestDesignCheckFailsForJavaImportCycle(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "store", "Store.java"), "package store;\n\nimport web.Handler;\n\npublic class Store {}\n") + writeFile(t, filepath.Join(dir, "web", "Handler.java"), "package web;\n\nimport store.Store;\n\npublic class Handler {}\n") + + report, err := codeguard.Run(context.Background(), graphTestConfig("design-java-cycle", dir, "java")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Design Patterns", "fail") + assertFindingRulePresent(t, report, "Design Patterns", "design.java.import-cycle") +} + +func TestDesignCheckPassesForAcyclicJavaImports(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "store", "Store.java"), "package store;\n\npublic class Store {}\n") + writeFile(t, filepath.Join(dir, "web", "Handler.java"), "package web;\n\nimport store.Store;\n\npublic class Handler {}\n") + + report, err := codeguard.Run(context.Background(), graphTestConfig("design-java-no-cycle", dir, "java")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRuleAbsent(t, report, "Design Patterns", "design.java.import-cycle") +} diff --git a/tests/checks/design_graph_languages_test.go b/tests/checks/design_graph_languages_test.go index 595bf5f..6248a6f 100644 --- a/tests/checks/design_graph_languages_test.go +++ b/tests/checks/design_graph_languages_test.go @@ -8,34 +8,6 @@ import ( "github.com/devr-tools/codeguard/pkg/codeguard" ) -func graphTestConfig(name string, dir string, language string) codeguard.Config { - cfg := codeguard.ExampleConfig() - cfg.Name = name - cfg.Targets = []codeguard.TargetConfig{{Name: "repo", Path: dir, Language: language}} - cfg.Checks.Design = true - cfg.Checks.Quality = false - cfg.Checks.Security = false - cfg.Checks.Prompts = false - cfg.Checks.CI = false - return cfg -} - -func assertFindingRuleAbsent(t *testing.T, report codeguard.Report, section string, ruleID string) { - t.Helper() - for _, result := range report.Sections { - if result.Name != section { - continue - } - for _, finding := range result.Findings { - if finding.RuleID == ruleID { - t.Fatalf("section %q unexpectedly contains rule %q: %s", section, ruleID, finding.Message) - } - } - return - } - t.Fatalf("section %q not found", section) -} - func TestDesignCheckFailsForTypeScriptImportCycle(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "src", "alpha.ts"), "import { beta } from \"./beta\";\n\nexport const alpha = () => beta();\n") @@ -157,59 +129,3 @@ func TestDesignCheckFailsForTypeScriptImportCycleThroughPackageImports(t *testin assertSectionStatus(t, report, "Design Patterns", "fail") assertFindingRulePresent(t, report, "Design Patterns", "design.typescript.import-cycle") } - -func TestDesignCheckFailsForRustModuleCycle(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "src", "lib.rs"), "mod reader;\nmod writer;\n") - writeFile(t, filepath.Join(dir, "src", "reader.rs"), "use crate::writer::Writer;\n\npub struct Reader;\n") - writeFile(t, filepath.Join(dir, "src", "writer.rs"), "use crate::reader::Reader;\n\npub struct Writer;\n") - - report, err := codeguard.Run(context.Background(), graphTestConfig("design-rust-cycle", dir, "rust")) - if err != nil { - t.Fatalf("run: %v", err) - } - - assertSectionStatus(t, report, "Design Patterns", "fail") - assertFindingRulePresent(t, report, "Design Patterns", "design.rust.import-cycle") -} - -func TestDesignCheckPassesForAcyclicRustModules(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "src", "lib.rs"), "mod reader;\nmod writer;\n") - writeFile(t, filepath.Join(dir, "src", "reader.rs"), "use crate::writer::Writer;\n\npub struct Reader;\n") - writeFile(t, filepath.Join(dir, "src", "writer.rs"), "pub struct Writer;\n") - - report, err := codeguard.Run(context.Background(), graphTestConfig("design-rust-no-cycle", dir, "rust")) - if err != nil { - t.Fatalf("run: %v", err) - } - - assertFindingRuleAbsent(t, report, "Design Patterns", "design.rust.import-cycle") -} - -func TestDesignCheckFailsForJavaImportCycle(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "store", "Store.java"), "package store;\n\nimport web.Handler;\n\npublic class Store {}\n") - writeFile(t, filepath.Join(dir, "web", "Handler.java"), "package web;\n\nimport store.Store;\n\npublic class Handler {}\n") - - report, err := codeguard.Run(context.Background(), graphTestConfig("design-java-cycle", dir, "java")) - if err != nil { - t.Fatalf("run: %v", err) - } - - assertSectionStatus(t, report, "Design Patterns", "fail") - assertFindingRulePresent(t, report, "Design Patterns", "design.java.import-cycle") -} - -func TestDesignCheckPassesForAcyclicJavaImports(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "store", "Store.java"), "package store;\n\npublic class Store {}\n") - writeFile(t, filepath.Join(dir, "web", "Handler.java"), "package web;\n\nimport store.Store;\n\npublic class Handler {}\n") - - report, err := codeguard.Run(context.Background(), graphTestConfig("design-java-no-cycle", dir, "java")) - if err != nil { - t.Fatalf("run: %v", err) - } - - assertFindingRuleAbsent(t, report, "Design Patterns", "design.java.import-cycle") -} diff --git a/tests/checks/report_features_test.go b/tests/checks/report_features_test.go index a9e5cbf..e247d0f 100644 --- a/tests/checks/report_features_test.go +++ b/tests/checks/report_features_test.go @@ -2,16 +2,17 @@ package checks_test import ( "bytes" - "encoding/json" "strings" "testing" - "github.com/devr-tools/codeguard/internal/version" "github.com/devr-tools/codeguard/pkg/codeguard" ) func TestWriteReportSupportsSARIFAndGitHub(t *testing.T) { report := formatReport() + if report.Name != "format-test" { + t.Fatalf("report name = %q, want %q", report.Name, "format-test") + } testColoredTextReport(t, report) testPlainTextReport(t, report) @@ -21,81 +22,6 @@ func TestWriteReportSupportsSARIFAndGitHub(t *testing.T) { testGitHubCommentReport(t, report) } -func testColoredTextReport(t *testing.T, report codeguard.Report) { - t.Helper() - t.Setenv("NO_COLOR", "") - output := writeFormatReport(t, report, "text") - assertTextReportFormatting(t, &output) - assertReportVersion(t, "text", output.String(), "CodeGuard version: "+version.Number) -} - -func testPlainTextReport(t *testing.T, report codeguard.Report) { - t.Helper() - t.Setenv("NO_COLOR", "1") - output := writeFormatReport(t, report, "text") - assertPlainTextReportFormatting(t, &output) -} - -func testJSONReport(t *testing.T, report codeguard.Report) { - t.Helper() - output := writeFormatReport(t, report, "json") - var jsonPayload struct { - CodeGuardVersion string `json:"codeguard_version"` - } - if err := json.Unmarshal(output.Bytes(), &jsonPayload); err != nil { - t.Fatalf("parse json: %v", err) - } - if jsonPayload.CodeGuardVersion != version.Number { - t.Fatalf("json codeguard_version = %q, want %q", jsonPayload.CodeGuardVersion, version.Number) - } -} - -func testSARIFReport(t *testing.T, report codeguard.Report) { - t.Helper() - output := writeFormatReport(t, report, "sarif") - if !strings.Contains(output.String(), `"version": "2.1.0"`) { - t.Fatalf("expected SARIF payload, got: %s", output.String()) - } - assertReportVersion(t, "sarif", output.String(), `"version": "`+version.Number+`"`) -} - -func testGitHubReport(t *testing.T, report codeguard.Report) { - t.Helper() - output := writeFormatReport(t, report, "github") - if !strings.Contains(output.String(), "::warning file=tests/checks/test_helpers_test.go,line=58,col=1::") { - t.Fatalf("expected GitHub annotation, got: %s", output.String()) - } - assertReportVersion(t, "github", output.String(), "::notice title=CodeGuard::version "+version.Number) -} - -func testGitHubCommentReport(t *testing.T, report codeguard.Report) { - t.Helper() - output := writeFormatReport(t, report, "github-comment") - if !strings.Contains(output.String(), "## CodeGuard Fix Suggestions") { - t.Fatalf("expected GitHub comment heading, got: %s", output.String()) - } - assertReportVersion(t, "github-comment", output.String(), "CodeGuard version "+version.Number) - if !strings.Contains(output.String(), "Fix: Reduce branching in the function or refactor logic into smaller units.") { - t.Fatalf("expected concrete fix guidance, got: %s", output.String()) - } -} - -func writeFormatReport(t *testing.T, report codeguard.Report, format string) bytes.Buffer { - t.Helper() - var out bytes.Buffer - if err := codeguard.WriteReport(&out, report, format); err != nil { - t.Fatalf("write %s: %v", format, err) - } - return out -} - -func assertReportVersion(t *testing.T, format string, output string, want string) { - t.Helper() - if !strings.Contains(output, want) { - t.Fatalf("expected %s output to include version %q, got: %s", format, want, output) - } -} - func TestWriteReportUsesSameGroupedLayoutAcrossSections(t *testing.T) { var out bytes.Buffer t.Setenv("NO_COLOR", "1") @@ -110,107 +36,3 @@ func TestWriteReportUsesSameGroupedLayoutAcrossSections(t *testing.T) { } } } - -func formatReport() codeguard.Report { - return codeguard.Report{ - Name: "format-test", - Sections: []codeguard.SectionResult{{ - ID: "quality", - Name: "Code Quality", - Status: codeguard.StatusWarn, - Findings: []codeguard.Finding{ - { - RuleID: "quality.cyclomatic-complexity", - Level: "warn", - Title: "Cyclomatic complexity", - Message: "function assertTextReportFormatting has cyclomatic complexity 11; max is 10", - Why: "function assertTextReportFormatting has cyclomatic complexity 11; max is 10", - HowToFix: "Reduce branching in the function or refactor logic into smaller units.", - Path: "tests/checks/test_helpers_test.go", - Line: 58, - Column: 1, - Fingerprint: "abc123", - }, - { - RuleID: "quality.dependency-direction", - Level: "warn", - Title: "Dependency direction", - Message: "non-CLI package imports internal implementation detail", - Why: "non-CLI package imports internal implementation detail", - HowToFix: "Move shared logic into reusable packages and keep internal or CLI details out of library code.", - Path: "pkg/codeguard/sdk_types_state.go", - Line: 3, - Column: 1, - Fingerprint: "def456", - }, - }, - }}, - Summary: codeguard.ReportSummary{ - WarnedSections: 1, - TotalFindings: 2, - }, - } -} - -func groupedLayoutReport() codeguard.Report { - return codeguard.Report{ - Name: "layout-test", - Sections: []codeguard.SectionResult{ - groupedSection(sectionFixture{"quality", "Code Quality", "quality.cyclomatic-complexity", "Cyclomatic complexity", "quality why", "quality fix", "quality.go", 10, "quality-1"}), - groupedSection(sectionFixture{"design", "Design Patterns", "design.max-methods-per-type", "Methods per type", "design why", "design fix", "design.go", 20, "design-1"}), - groupedSection(sectionFixture{"security", "Security", "security.shell-execution", "Shell execution review", "security why", "security fix", "security.go", 30, "security-1"}), - groupedSection(sectionFixture{"prompts", "AI Prompts", "prompts.unsafe-instructions", "Unsafe instructions", "prompts why", "prompts fix", "prompts.md", 40, "prompts-1"}), - groupedSection(sectionFixture{"ci", "CI/CD", "ci.workflow-content", "Workflow content", "ci why", "ci fix", ".github/workflows/ci.yml", 50, "ci-1"}), - }, - Summary: codeguard.ReportSummary{ - WarnedSections: 5, - TotalFindings: 5, - }, - } -} - -type sectionFixture struct { - id string - name string - ruleID string - title string - why string - fix string - path string - line int - fingerprint string -} - -func groupedSection(fixture sectionFixture) codeguard.SectionResult { - return codeguard.SectionResult{ - ID: fixture.id, - Name: fixture.name, - Status: codeguard.StatusWarn, - Findings: []codeguard.Finding{{ - RuleID: fixture.ruleID, - Level: "warn", - Title: fixture.title, - Message: fixture.why, - Why: fixture.why, - HowToFix: fixture.fix, - Path: fixture.path, - Line: fixture.line, - Fingerprint: fixture.fingerprint, - }}, - } -} - -func groupedLayoutFragments() []string { - return []string{ - "[⚠️ WARN] Code Quality", - " Cyclomatic complexity\n 1. at: quality.go:10", - "[⚠️ WARN] Design Patterns", - " Methods per type\n 1. at: design.go:20", - "[⚠️ WARN] Security", - " Shell execution review\n 1. at: security.go:30", - "[⚠️ WARN] AI Prompts", - " Unsafe instructions\n 1. at: prompts.md:40", - "[⚠️ WARN] CI/CD", - " Workflow content\n 1. at: .github/workflows/ci.yml:50", - } -} diff --git a/tests/checks/report_format_helpers_test.go b/tests/checks/report_format_helpers_test.go new file mode 100644 index 0000000..cb4d469 --- /dev/null +++ b/tests/checks/report_format_helpers_test.go @@ -0,0 +1,86 @@ +package checks_test + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/devr-tools/codeguard/internal/version" + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func testColoredTextReport(t *testing.T, report codeguard.Report) { + t.Helper() + t.Setenv("NO_COLOR", "") + output := writeFormatReport(t, report, "text") + assertTextReportFormatting(t, &output) + assertReportVersion(t, "text", output.String(), "CodeGuard version: "+version.Number) +} + +func testPlainTextReport(t *testing.T, report codeguard.Report) { + t.Helper() + t.Setenv("NO_COLOR", "1") + output := writeFormatReport(t, report, "text") + assertPlainTextReportFormatting(t, &output) +} + +func testJSONReport(t *testing.T, report codeguard.Report) { + t.Helper() + output := writeFormatReport(t, report, "json") + var jsonPayload struct { + CodeGuardVersion string `json:"codeguard_version"` + } + if err := json.Unmarshal(output.Bytes(), &jsonPayload); err != nil { + t.Fatalf("parse json: %v", err) + } + if jsonPayload.CodeGuardVersion != version.Number { + t.Fatalf("json codeguard_version = %q, want %q", jsonPayload.CodeGuardVersion, version.Number) + } +} + +func testSARIFReport(t *testing.T, report codeguard.Report) { + t.Helper() + output := writeFormatReport(t, report, "sarif") + if !strings.Contains(output.String(), `"version": "2.1.0"`) { + t.Fatalf("expected SARIF payload, got: %s", output.String()) + } + assertReportVersion(t, "sarif", output.String(), `"version": "`+version.Number+`"`) +} + +func testGitHubReport(t *testing.T, report codeguard.Report) { + t.Helper() + output := writeFormatReport(t, report, "github") + if !strings.Contains(output.String(), "::warning file=tests/checks/test_helpers_test.go,line=58,col=1::") { + t.Fatalf("expected GitHub annotation, got: %s", output.String()) + } + assertReportVersion(t, "github", output.String(), "::notice title=CodeGuard::version "+version.Number) +} + +func testGitHubCommentReport(t *testing.T, report codeguard.Report) { + t.Helper() + output := writeFormatReport(t, report, "github-comment") + if !strings.Contains(output.String(), "## CodeGuard Fix Suggestions") { + t.Fatalf("expected GitHub comment heading, got: %s", output.String()) + } + assertReportVersion(t, "github-comment", output.String(), "CodeGuard version "+version.Number) + if !strings.Contains(output.String(), "Fix: Reduce branching in the function or refactor logic into smaller units.") { + t.Fatalf("expected concrete fix guidance, got: %s", output.String()) + } +} + +func writeFormatReport(t *testing.T, report codeguard.Report, format string) bytes.Buffer { + t.Helper() + var out bytes.Buffer + if err := codeguard.WriteReport(&out, report, format); err != nil { + t.Fatalf("write %s: %v", format, err) + } + return out +} + +func assertReportVersion(t *testing.T, format string, output string, want string) { + t.Helper() + if !strings.Contains(output, want) { + t.Fatalf("expected %s output to include version %q, got: %s", format, want, output) + } +} diff --git a/tests/checks/report_layout_fixtures_test.go b/tests/checks/report_layout_fixtures_test.go new file mode 100644 index 0000000..e6f2d8d --- /dev/null +++ b/tests/checks/report_layout_fixtures_test.go @@ -0,0 +1,107 @@ +package checks_test + +import "github.com/devr-tools/codeguard/pkg/codeguard" + +func formatReport() codeguard.Report { + return codeguard.Report{ + Name: "format-test", + Sections: []codeguard.SectionResult{{ + ID: "quality", + Name: "Code Quality", + Status: codeguard.StatusWarn, + Findings: []codeguard.Finding{ + { + RuleID: "quality.cyclomatic-complexity", + Level: "warn", + Title: "Cyclomatic complexity", + Message: "function assertTextReportFormatting has cyclomatic complexity 11; max is 10", + Why: "function assertTextReportFormatting has cyclomatic complexity 11; max is 10", + HowToFix: "Reduce branching in the function or refactor logic into smaller units.", + Path: "tests/checks/test_helpers_test.go", + Line: 58, + Column: 1, + Fingerprint: "abc123", + }, + { + RuleID: "quality.dependency-direction", + Level: "warn", + Title: "Dependency direction", + Message: "non-CLI package imports internal implementation detail", + Why: "non-CLI package imports internal implementation detail", + HowToFix: "Move shared logic into reusable packages and keep internal or CLI details out of library code.", + Path: "pkg/codeguard/sdk_types_state.go", + Line: 3, + Column: 1, + Fingerprint: "def456", + }, + }, + }}, + Summary: codeguard.ReportSummary{ + WarnedSections: 1, + TotalFindings: 2, + }, + } +} + +func groupedLayoutReport() codeguard.Report { + return codeguard.Report{ + Name: "layout-test", + Sections: []codeguard.SectionResult{ + groupedSection(sectionFixture{"quality", "Code Quality", "quality.cyclomatic-complexity", "Cyclomatic complexity", "quality why", "quality fix", "quality.go", 10, "quality-1"}), + groupedSection(sectionFixture{"design", "Design Patterns", "design.max-methods-per-type", "Methods per type", "design why", "design fix", "design.go", 20, "design-1"}), + groupedSection(sectionFixture{"security", "Security", "security.shell-execution", "Shell execution review", "security why", "security fix", "security.go", 30, "security-1"}), + groupedSection(sectionFixture{"prompts", "AI Prompts", "prompts.unsafe-instructions", "Unsafe instructions", "prompts why", "prompts fix", "prompts.md", 40, "prompts-1"}), + groupedSection(sectionFixture{"ci", "CI/CD", "ci.workflow-content", "Workflow content", "ci why", "ci fix", ".github/workflows/ci.yml", 50, "ci-1"}), + }, + Summary: codeguard.ReportSummary{ + WarnedSections: 5, + TotalFindings: 5, + }, + } +} + +type sectionFixture struct { + id string + name string + ruleID string + title string + why string + fix string + path string + line int + fingerprint string +} + +func groupedSection(fixture sectionFixture) codeguard.SectionResult { + return codeguard.SectionResult{ + ID: fixture.id, + Name: fixture.name, + Status: codeguard.StatusWarn, + Findings: []codeguard.Finding{{ + RuleID: fixture.ruleID, + Level: "warn", + Title: fixture.title, + Message: fixture.why, + Why: fixture.why, + HowToFix: fixture.fix, + Path: fixture.path, + Line: fixture.line, + Fingerprint: fixture.fingerprint, + }}, + } +} + +func groupedLayoutFragments() []string { + return []string{ + "[⚠️ WARN] Code Quality", + " Cyclomatic complexity\n 1. at: quality.go:10", + "[⚠️ WARN] Design Patterns", + " Methods per type\n 1. at: design.go:20", + "[⚠️ WARN] Security", + " Shell execution review\n 1. at: security.go:30", + "[⚠️ WARN] AI Prompts", + " Unsafe instructions\n 1. at: prompts.md:40", + "[⚠️ WARN] CI/CD", + " Workflow content\n 1. at: .github/workflows/ci.yml:50", + } +}