├── internal ├── models │ ├── opr_error.go │ ├── common │ │ ├── project_info.go │ │ ├── reference_referable.go │ │ ├── reference.go │ │ └── reference_test.go │ ├── opr_schema.go │ ├── opr_version.go │ ├── global.go │ ├── flags.go │ ├── opr_self_inspect.go │ ├── opr_graph.go │ ├── opr_mapping.go │ ├── errors.go │ ├── resolved_file.go │ ├── utils_path.go │ ├── arch │ │ └── spec.go │ └── opr_check.go ├── view │ ├── view_schema.gohtml │ ├── view_error.gohtml │ ├── view_graph.gohtml │ ├── view_version.gohtml │ ├── view_self_inspect.gohtml │ ├── view_mapping.gohtml │ ├── templates.go │ └── view_check.gohtml ├── services │ ├── render │ │ ├── code │ │ │ ├── types.go │ │ │ ├── line_count.go │ │ │ ├── line_count_test.go │ │ │ ├── render_test.go │ │ │ ├── reader.go │ │ │ └── render.go │ │ ├── types.go │ │ ├── ascii_processor.go │ │ ├── printer │ │ │ └── color_printer.go │ │ ├── colorize.go │ │ ├── ascii_functions.go │ │ └── renderer.go │ ├── spec │ │ ├── decoder │ │ │ ├── decoder_doc.go │ │ │ ├── types.go │ │ │ ├── decoder_utils.go │ │ │ ├── decoder_yaml.go │ │ │ ├── json_scheme_test.go │ │ │ ├── json_scheme.go │ │ │ ├── decoder.go │ │ │ ├── decoder_doc_v1.go │ │ │ ├── decoder_doc_v2.go │ │ │ └── decoder_doc_v3.go │ │ ├── validator │ │ │ ├── types.go │ │ │ ├── validator_vendors.go │ │ │ ├── validator_common_vendors.go │ │ │ ├── validator_exclude_files.go │ │ │ ├── validator_common_components.go │ │ │ ├── validator_version.go │ │ │ ├── validator_workdir.go │ │ │ ├── validator.go │ │ │ ├── validator_deps_vendors.go │ │ │ ├── validator_components.go │ │ │ ├── validator_deps_components.go │ │ │ ├── utils.go │ │ │ └── validator_deps.go │ │ ├── assembler │ │ │ ├── assembler_ac_workdir.go │ │ │ ├── types.go │ │ │ ├── referable.go │ │ │ ├── assembler_ac_allow.go │ │ │ ├── assembler_composite.go │ │ │ ├── assembler_ac_exclude_file_matcher.go │ │ │ ├── assembler_ac_exclude.go │ │ │ ├── allowed_vendor_imports.go │ │ │ ├── resolver.go │ │ │ ├── allowed_project_imports.go │ │ │ ├── assembler.go │ │ │ └── assembler_ac_components.go │ │ └── document.go │ ├── checker │ │ ├── deepscan │ │ │ ├── utils.go │ │ │ ├── models.go │ │ │ ├── ast_utils.go │ │ │ ├── searcher.go │ │ │ ├── criteria.go │ │ │ └── searcher_parse_methods.go │ │ ├── types.go │ │ ├── checker_composite.go │ │ ├── results.go │ │ └── checker_imports.go │ ├── common │ │ ├── ast │ │ │ └── utils.go │ │ ├── path │ │ │ ├── resolver.go │ │ │ └── glob.go │ │ └── yaml │ │ │ └── reference │ │ │ └── resolver.go │ ├── schema │ │ ├── provider.go │ │ └── v1.json │ └── project │ │ ├── resolver │ │ ├── types.go │ │ ├── transformer.go │ │ └── resolver.go │ │ ├── info │ │ └── assembler.go │ │ ├── scanner │ │ └── scanner.go │ │ └── holder │ │ └── holder.go ├── operations │ ├── schema │ │ ├── types.go │ │ └── operation.go │ ├── graph │ │ └── types.go │ ├── selfInspect │ │ ├── types.go │ │ └── operation.go │ ├── mapping │ │ ├── types.go │ │ └── operation.go │ ├── check │ │ ├── types.go │ │ └── operation.go │ └── version │ │ └── operation.go └── app │ ├── build_consts.go │ ├── internal │ └── container │ │ ├── container.go │ │ ├── container_cmd_version.go │ │ ├── cnt_utils.go │ │ ├── container_cmd_schema.go │ │ ├── container_cmd_self_inspect.go │ │ ├── container_cmd_check.go │ │ ├── container_cmd_mapping.go │ │ ├── container_cmd_graph.go │ │ ├── cnt_glue.go │ │ └── container_cmd.go │ └── cli.go ├── tools.go ├── main.go ├── .pre-commit-hooks.yaml ├── LICENSE ├── .github └── workflows │ └── release.yml ├── go.mod ├── .golangci.yml └── README.md /internal/models/opr_error.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type CmdErrorOut struct { 4 | Error string `json:"Error"` 5 | } 6 | -------------------------------------------------------------------------------- /internal/view/view_schema.gohtml: -------------------------------------------------------------------------------- 1 | {{- /*gotype: github.com/fe3dback/go-arch-lint/internal/models.CmdSchemaOut*/ -}} 2 | 3 | {{.JSONSchema}} -------------------------------------------------------------------------------- /internal/services/render/code/types.go: -------------------------------------------------------------------------------- 1 | package code 2 | 3 | type ( 4 | colorPrinter interface { 5 | Gray(in string) string 6 | } 7 | ) 8 | -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | // +build tools 3 | 4 | package tools 5 | 6 | import ( 7 | _ "github.com/stretchr/testify/assert" 8 | ) 9 | -------------------------------------------------------------------------------- /internal/view/view_error.gohtml: -------------------------------------------------------------------------------- 1 | {{- /*gotype: github.com/fe3dback/go-arch-lint/internal/models.CmdErrorOut*/ -}} 2 | 3 | Error: {{.Error | colorize "red"}} -------------------------------------------------------------------------------- /internal/operations/schema/types.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | type ( 4 | jsonSchemaProvider interface { 5 | Provide(version int) ([]byte, error) 6 | } 7 | ) 8 | -------------------------------------------------------------------------------- /internal/services/spec/decoder/decoder_doc.go: -------------------------------------------------------------------------------- 1 | package decoder 2 | 3 | import "github.com/fe3dback/go-arch-lint/internal/services/spec" 4 | 5 | type doc interface { 6 | spec.Document 7 | 8 | postSetup() 9 | } 10 | -------------------------------------------------------------------------------- /internal/models/common/project_info.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | type ( 4 | Project struct { 5 | Directory string 6 | GoArchFilePath string 7 | GoModFilePath string 8 | ModuleName string 9 | } 10 | ) 11 | -------------------------------------------------------------------------------- /internal/app/build_consts.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import "github.com/fe3dback/go-arch-lint/internal/models" 4 | 5 | var ( 6 | Version = models.UnknownVersion 7 | BuildTime = "unknown" 8 | CommitHash = "unknown" 9 | ) 10 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/fe3dback/go-arch-lint/internal/app" 7 | ) 8 | 9 | func main() { 10 | os.Exit(run()) 11 | } 12 | 13 | func run() int { 14 | return app.Execute() 15 | } 16 | -------------------------------------------------------------------------------- /internal/models/opr_schema.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type ( 4 | CmdSchemaIn struct { 5 | Version int 6 | } 7 | 8 | CmdSchemaOut struct { 9 | Version int `json:"Version"` 10 | JSONSchema string `json:"JsonSchema"` 11 | } 12 | ) 13 | -------------------------------------------------------------------------------- /internal/view/view_graph.gohtml: -------------------------------------------------------------------------------- 1 | {{- /*gotype: github.com/fe3dback/go-arch-lint/internal/models.CmdGraphOut*/ -}} 2 | 3 | {{ if .ExportD2 -}} 4 | {{ .D2Definitions -}} 5 | {{ else -}} 6 | Graph outputted to: 7 | {{ .OutFile | colorize "blue" }} 8 | {{ end -}} 9 | -------------------------------------------------------------------------------- /internal/models/opr_version.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type CmdVersionOut struct { 4 | LinterVersion string `json:"LinterVersion"` 5 | GoArchFileSupported string `json:"GoArchFileSupported"` 6 | BuildTime string `json:"BuildTime"` 7 | CommitHash string `json:"CommitHash"` 8 | } 9 | -------------------------------------------------------------------------------- /internal/models/global.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | const UnknownVersion = "dev" 4 | 5 | const ( 6 | DefaultProjectPath = "./" 7 | DefaultArchFileName = ".go-arch-lint.yml" 8 | DefaultGoModFileName = "go.mod" 9 | ) 10 | 11 | const ( 12 | SupportedVersionMin = 1 13 | SupportedVersionMax = 3 14 | ) 15 | -------------------------------------------------------------------------------- /internal/view/view_version.gohtml: -------------------------------------------------------------------------------- 1 | {{- /*gotype: github.com/fe3dback/go-arch-lint/internal/models.CmdVersionOut*/ -}} 2 | 3 | Linter version: {{.LinterVersion | colorize "yellow" }} 4 | Supported go arch file versions: {{.GoArchFileSupported | colorize "yellow" }} 5 | Build time: {{.BuildTime | colorize "yellow" }} 6 | Commit hash: {{.CommitHash | colorize "yellow" }} -------------------------------------------------------------------------------- /internal/services/spec/decoder/types.go: -------------------------------------------------------------------------------- 1 | package decoder 2 | 3 | import ( 4 | "github.com/fe3dback/go-arch-lint/internal/models/common" 5 | ) 6 | 7 | type ( 8 | yamlSourceCodeReferenceResolver interface { 9 | Resolve(filePath string, yamlPath string) common.Reference 10 | } 11 | 12 | jsonSchemaProvider interface { 13 | Provide(version int) ([]byte, error) 14 | } 15 | ) 16 | -------------------------------------------------------------------------------- /internal/models/common/reference_referable.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | type Referable[T any] struct { 4 | Value T 5 | Reference Reference 6 | } 7 | 8 | func NewReferable[T any](value T, ref Reference) Referable[T] { 9 | return Referable[T]{Value: value, Reference: ref} 10 | } 11 | 12 | func NewEmptyReferable[T any](value T) Referable[T] { 13 | return Referable[T]{Value: value, Reference: NewEmptyReference()} 14 | } 15 | -------------------------------------------------------------------------------- /internal/services/spec/validator/types.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 5 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 6 | ) 7 | 8 | type ( 9 | validator interface { 10 | Validate(doc spec.Document) []arch.Notice 11 | } 12 | 13 | pathResolver interface { 14 | Resolve(absPath string) (resolvePaths []string, err error) 15 | } 16 | ) 17 | -------------------------------------------------------------------------------- /internal/operations/graph/types.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 5 | "github.com/fe3dback/go-arch-lint/internal/models/common" 6 | ) 7 | 8 | type ( 9 | specAssembler interface { 10 | Assemble(prj common.Project) (arch.Spec, error) 11 | } 12 | 13 | projectInfoAssembler interface { 14 | ProjectInfo(rootDirectory string, archFilePath string) (common.Project, error) 15 | } 16 | ) 17 | -------------------------------------------------------------------------------- /internal/operations/selfInspect/types.go: -------------------------------------------------------------------------------- 1 | package selfInspect 2 | 3 | import ( 4 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 5 | "github.com/fe3dback/go-arch-lint/internal/models/common" 6 | ) 7 | 8 | type ( 9 | specAssembler interface { 10 | Assemble(prj common.Project) (arch.Spec, error) 11 | } 12 | 13 | projectInfoAssembler interface { 14 | ProjectInfo(rootDirectory string, archFilePath string) (common.Project, error) 15 | } 16 | ) 17 | -------------------------------------------------------------------------------- /internal/services/spec/decoder/decoder_utils.go: -------------------------------------------------------------------------------- 1 | package decoder 2 | 3 | import "github.com/fe3dback/go-arch-lint/internal/models/common" 4 | 5 | func castRef[T any](r ref[T]) common.Referable[T] { 6 | return r.ref 7 | } 8 | 9 | func castRefList[T any](r []ref[T]) []common.Referable[T] { 10 | casted := make([]common.Referable[T], 0, len(r)) 11 | 12 | for _, ref := range r { 13 | casted = append(casted, castRef(ref)) 14 | } 15 | 16 | return casted 17 | } 18 | -------------------------------------------------------------------------------- /internal/models/flags.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | const ( 4 | OutputTypeDefault OutputType = "default" 5 | OutputTypeASCII OutputType = "ascii" 6 | OutputTypeJSON OutputType = "json" 7 | ) 8 | 9 | var OutputTypeValues = []string{ 10 | OutputTypeASCII, 11 | OutputTypeJSON, 12 | } 13 | 14 | type ( 15 | OutputType = string 16 | 17 | FlagsRoot struct { 18 | UseColors bool 19 | OutputType OutputType 20 | OutputJsonOneLine bool 21 | } 22 | ) 23 | -------------------------------------------------------------------------------- /internal/services/checker/deepscan/utils.go: -------------------------------------------------------------------------------- 1 | package deepscan 2 | 3 | func mapStrToSlice(src map[string]struct{}) []string { 4 | r := make([]string, 0, len(src)) 5 | 6 | for value := range src { 7 | r = append(r, value) 8 | } 9 | 10 | return r 11 | } 12 | 13 | func sliceStrToMap(src []string) map[string]struct{} { 14 | r := make(map[string]struct{}, len(src)) 15 | 16 | for _, value := range src { 17 | r[value] = struct{}{} 18 | } 19 | 20 | return r 21 | } 22 | -------------------------------------------------------------------------------- /internal/services/common/ast/utils.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | import ( 4 | "go/token" 5 | 6 | "github.com/fe3dback/go-arch-lint/internal/models/common" 7 | ) 8 | 9 | func PositionFromToken(pos token.Position) common.Reference { 10 | ref := common.NewReferenceSingleLine( 11 | pos.Filename, 12 | pos.Line, 13 | pos.Column, 14 | ) 15 | 16 | if pos.Line == 0 { 17 | ref.Valid = false 18 | ref.Line = 0 19 | 20 | return ref 21 | } 22 | 23 | return ref 24 | } 25 | -------------------------------------------------------------------------------- /internal/services/spec/validator/validator_vendors.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 5 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 6 | ) 7 | 8 | type validatorVendors struct{} 9 | 10 | func newValidatorVendors() *validatorVendors { 11 | return &validatorVendors{} 12 | } 13 | 14 | func (v *validatorVendors) Validate(_ spec.Document) []arch.Notice { 15 | return make([]arch.Notice, 0) 16 | } 17 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: go-arch-lint-check 2 | name: Check Go repo architecture 3 | description: Check Go repo architecture using go-arch-lint 4 | entry: go-arch-lint check 5 | language: golang 6 | pass_filenames: false 7 | 8 | - id: go-arch-lint-graph 9 | name: Generate graph for Go repo architecture 10 | description: Generate graph for Go repo architecture using go-arch-lint 11 | entry: go-arch-lint graph 12 | language: golang 13 | pass_filenames: false 14 | -------------------------------------------------------------------------------- /internal/services/render/code/line_count.go: -------------------------------------------------------------------------------- 1 | package code 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | ) 7 | 8 | func lineCounter(r io.Reader) (int, error) { 9 | buf := make([]byte, 32*1024) 10 | count := 1 11 | lineSep := []byte{'\n'} 12 | 13 | for { 14 | c, err := r.Read(buf) 15 | count += bytes.Count(buf[:c], lineSep) 16 | 17 | switch { 18 | case err == io.EOF: 19 | return count, nil 20 | 21 | case err != nil: 22 | return count, err 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /internal/view/view_self_inspect.gohtml: -------------------------------------------------------------------------------- 1 | {{- /*gotype: github.com/fe3dback/go-arch-lint/internal/models.CmdSelfInspectOut*/ -}} 2 | 3 | Usage: 4 | {{ print "$" | colorize "gray" }} {{ print "go-arch-lint" | colorize "blue" }} self-inspect {{ print "--json" | colorize "magenta" }} 5 | {{ " " }} 6 | Note: 7 | this command created for integration with dev-tools and IDE's 8 | and not have any ascii output support for inspection results. 9 | please use this command with flag {{ print "--json" | colorize "magenta" }} -------------------------------------------------------------------------------- /internal/app/internal/container/container.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "github.com/fe3dback/go-arch-lint/internal/models" 5 | ) 6 | 7 | type Container struct { 8 | version string 9 | buildTime string 10 | commitHash string 11 | 12 | flags models.FlagsRoot 13 | } 14 | 15 | func NewContainer( 16 | version string, 17 | buildTime string, 18 | commitHash string, 19 | ) *Container { 20 | return &Container{ 21 | version: version, 22 | buildTime: buildTime, 23 | commitHash: commitHash, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /internal/services/spec/assembler/assembler_ac_workdir.go: -------------------------------------------------------------------------------- 1 | package assembler 2 | 3 | import ( 4 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 5 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 6 | ) 7 | 8 | type workdirAssembler struct{} 9 | 10 | func newWorkdirAssembler() *workdirAssembler { 11 | return &workdirAssembler{} 12 | } 13 | 14 | func (efa *workdirAssembler) assemble(spec *arch.Spec, document spec.Document) error { 15 | spec.WorkingDirectory = document.WorkingDirectory() 16 | 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /internal/services/spec/assembler/types.go: -------------------------------------------------------------------------------- 1 | package assembler 2 | 3 | import ( 4 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 5 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 6 | ) 7 | 8 | type ( 9 | archDecoder interface { 10 | Decode(archFile string) (spec.Document, []arch.Notice, error) 11 | } 12 | 13 | archValidator interface { 14 | Validate(doc spec.Document, projectDir string) []arch.Notice 15 | } 16 | 17 | pathResolver interface { 18 | Resolve(absPath string) (resolvePaths []string, err error) 19 | } 20 | ) 21 | -------------------------------------------------------------------------------- /internal/services/render/types.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "github.com/fe3dback/go-arch-lint/internal/models/common" 5 | ) 6 | 7 | type ( 8 | referenceRender interface { 9 | SourceCode(ref common.Reference, highlight bool, showPointer bool) []byte 10 | } 11 | 12 | colorPrinter interface { 13 | Red(in string) (out string) 14 | Green(in string) (out string) 15 | Yellow(in string) (out string) 16 | Blue(in string) (out string) 17 | Magenta(in string) (out string) 18 | Cyan(in string) (out string) 19 | Gray(in string) (out string) 20 | } 21 | ) 22 | -------------------------------------------------------------------------------- /internal/services/spec/assembler/referable.go: -------------------------------------------------------------------------------- 1 | package assembler 2 | 3 | import ( 4 | "github.com/fe3dback/go-arch-lint/internal/models/common" 5 | ) 6 | 7 | func wrap[T any](ref common.Reference, list []T) []common.Referable[T] { 8 | res := make([]common.Referable[T], len(list)) 9 | 10 | for ind, path := range list { 11 | res[ind] = common.NewReferable(path, ref) 12 | } 13 | 14 | return res 15 | } 16 | 17 | func unwrap[T any](refList []common.Referable[T]) []T { 18 | res := make([]T, len(refList)) 19 | 20 | for ind, r := range refList { 21 | res[ind] = r.Value 22 | } 23 | 24 | return res 25 | } 26 | -------------------------------------------------------------------------------- /internal/services/render/ascii_processor.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | func preprocessRawASCIITemplate(tpl string) string { 8 | lines := strings.Split(tpl, "\n") 9 | buffer := make([]string, 0, len(lines)) 10 | 11 | for _, line := range lines { 12 | processedLine := preprocessTemplateLine(line) 13 | if strings.TrimSpace(processedLine) == "" { 14 | continue 15 | } 16 | 17 | buffer = append(buffer, processedLine) 18 | } 19 | 20 | return strings.Join(buffer, "\n") 21 | } 22 | 23 | func preprocessTemplateLine(row string) string { 24 | return strings.TrimLeft(row, "\t") 25 | } 26 | -------------------------------------------------------------------------------- /internal/services/schema/provider.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | ) 7 | 8 | //go:embed v1.json 9 | var v1 []byte 10 | 11 | //go:embed v2.json 12 | var v2 []byte 13 | 14 | //go:embed v3.json 15 | var v3 []byte 16 | 17 | type Provider struct{} 18 | 19 | func NewProvider() *Provider { 20 | return &Provider{} 21 | } 22 | 23 | func (p *Provider) Provide(version int) ([]byte, error) { 24 | switch version { 25 | case 3: 26 | return v3, nil 27 | case 2: 28 | return v2, nil 29 | case 1: 30 | return v1, nil 31 | default: 32 | return nil, fmt.Errorf("unknown version: %d", version) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/operations/mapping/types.go: -------------------------------------------------------------------------------- 1 | package mapping 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/fe3dback/go-arch-lint/internal/models" 7 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 8 | "github.com/fe3dback/go-arch-lint/internal/models/common" 9 | ) 10 | 11 | type ( 12 | specAssembler interface { 13 | Assemble(prj common.Project) (arch.Spec, error) 14 | } 15 | 16 | projectFilesResolver interface { 17 | ProjectFiles(ctx context.Context, spec arch.Spec) ([]models.FileHold, error) 18 | } 19 | 20 | projectInfoAssembler interface { 21 | ProjectInfo(rootDirectory string, archFilePath string) (common.Project, error) 22 | } 23 | ) 24 | -------------------------------------------------------------------------------- /internal/services/checker/types.go: -------------------------------------------------------------------------------- 1 | package checker 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/fe3dback/go-arch-lint/internal/models" 7 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 8 | "github.com/fe3dback/go-arch-lint/internal/models/common" 9 | ) 10 | 11 | type ( 12 | projectFilesResolver interface { 13 | ProjectFiles(ctx context.Context, spec arch.Spec) ([]models.FileHold, error) 14 | } 15 | 16 | checker interface { 17 | Check(ctx context.Context, spec arch.Spec) (models.CheckResult, error) 18 | } 19 | 20 | sourceCodeRenderer interface { 21 | SourceCode(ref common.Reference, highlight bool, showPointer bool) []byte 22 | } 23 | ) 24 | -------------------------------------------------------------------------------- /internal/services/project/resolver/types.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import ( 4 | "context" 5 | "regexp" 6 | 7 | "github.com/fe3dback/go-arch-lint/internal/models" 8 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 9 | ) 10 | 11 | type ( 12 | projectFilesResolver interface { 13 | Scan( 14 | ctx context.Context, 15 | projectDirectory string, 16 | moduleName string, 17 | excludePaths []models.ResolvedPath, 18 | excludeFileMatchers []*regexp.Regexp, 19 | ) ([]models.ProjectFile, error) 20 | } 21 | 22 | projectFilesHolder interface { 23 | HoldProjectFiles(files []models.ProjectFile, components []arch.Component) []models.FileHold 24 | } 25 | ) 26 | -------------------------------------------------------------------------------- /internal/services/spec/assembler/assembler_ac_allow.go: -------------------------------------------------------------------------------- 1 | package assembler 2 | 3 | import ( 4 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 5 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 6 | ) 7 | 8 | type allowAssembler struct{} 9 | 10 | func newAllowAssembler() *allowAssembler { 11 | return &allowAssembler{} 12 | } 13 | 14 | func (efa *allowAssembler) assemble(spec *arch.Spec, document spec.Document) error { 15 | spec.Allow = arch.Allow{ 16 | DepOnAnyVendor: document.Options().IsDependOnAnyVendor(), 17 | DeepScan: document.Options().DeepScan(), 18 | IgnoreNotFoundComponents: document.Options().IgnoreNotFoundComponents(), 19 | } 20 | 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /internal/services/project/resolver/transformer.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/fe3dback/go-arch-lint/internal/models" 7 | "github.com/fe3dback/go-arch-lint/internal/models/common" 8 | ) 9 | 10 | func refPathToList(list []common.Referable[models.ResolvedPath]) []models.ResolvedPath { 11 | result := make([]models.ResolvedPath, 0) 12 | 13 | for _, path := range list { 14 | result = append(result, path.Value) 15 | } 16 | 17 | return result 18 | } 19 | 20 | func refRegExpToList(list []common.Referable[*regexp.Regexp]) []*regexp.Regexp { 21 | result := make([]*regexp.Regexp, 0) 22 | 23 | for _, path := range list { 24 | result = append(result, path.Value) 25 | } 26 | 27 | return result 28 | } 29 | -------------------------------------------------------------------------------- /internal/app/internal/container/container_cmd_version.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/fe3dback/go-arch-lint/internal/operations/version" 7 | ) 8 | 9 | func (c *Container) commandVersion() (*cobra.Command, runner) { 10 | cmd := &cobra.Command{ 11 | Use: "version", 12 | Short: "Print go arch linter version", 13 | Long: "show version, build time and commit hash of current build", 14 | } 15 | 16 | return cmd, func(_ *cobra.Command) (any, error) { 17 | return c.commandVersionOperation().Behave() 18 | } 19 | } 20 | 21 | func (c *Container) commandVersionOperation() *version.Operation { 22 | return version.NewOperation( 23 | c.version, 24 | c.buildTime, 25 | c.commitHash, 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /internal/operations/check/types.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/fe3dback/go-arch-lint/internal/models" 7 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 8 | "github.com/fe3dback/go-arch-lint/internal/models/common" 9 | ) 10 | 11 | type ( 12 | projectInfoAssembler interface { 13 | ProjectInfo(rootDirectory string, archFilePath string) (common.Project, error) 14 | } 15 | 16 | specAssembler interface { 17 | Assemble(prj common.Project) (arch.Spec, error) 18 | } 19 | 20 | referenceRender interface { 21 | SourceCode(ref common.Reference, highlight bool, showPointer bool) []byte 22 | } 23 | 24 | specChecker interface { 25 | Check(ctx context.Context, spec arch.Spec) (models.CheckResult, error) 26 | } 27 | ) 28 | -------------------------------------------------------------------------------- /internal/models/opr_self_inspect.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "github.com/fe3dback/go-arch-lint/internal/models/common" 4 | 5 | type ( 6 | CmdSelfInspectIn struct { 7 | ProjectPath string 8 | ArchFile string 9 | } 10 | 11 | CmdSelfInspectOut struct { 12 | ModuleName string `json:"ModuleName"` 13 | RootDirectory string `json:"RootDirectory"` 14 | LinterVersion string `json:"LinterVersion"` 15 | Notices []CmdSelfInspectOutAnnotation `json:"Notices"` 16 | Suggestions []CmdSelfInspectOutAnnotation `json:"Suggestions"` 17 | } 18 | 19 | CmdSelfInspectOutAnnotation struct { 20 | Text string `json:"Text"` 21 | Reference common.Reference `json:"Reference"` 22 | } 23 | ) 24 | -------------------------------------------------------------------------------- /internal/models/opr_graph.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | const ( 4 | GraphTypeFlow GraphType = "flow" 5 | GraphTypeDI GraphType = "di" 6 | ) 7 | 8 | var GraphTypesValues = []string{ 9 | GraphTypeFlow, 10 | GraphTypeDI, 11 | } 12 | 13 | type ( 14 | GraphType = string 15 | 16 | CmdGraphIn struct { 17 | ProjectPath string 18 | ArchFile string 19 | Type GraphType 20 | OutFile string 21 | Focus string 22 | IncludeVendors bool 23 | ExportD2 bool 24 | OutputType OutputType 25 | } 26 | 27 | CmdGraphOut struct { 28 | ProjectDirectory string `json:"ProjectDirectory"` 29 | ModuleName string `json:"ModuleName"` 30 | OutFile string `json:"OutFile"` 31 | D2Definitions string `json:"D2Definitions"` 32 | ExportD2 bool `json:"-"` 33 | } 34 | ) 35 | -------------------------------------------------------------------------------- /internal/app/internal/container/cnt_utils.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "github.com/logrusorgru/aurora/v3" 5 | 6 | "github.com/fe3dback/go-arch-lint/internal/services/render" 7 | "github.com/fe3dback/go-arch-lint/internal/services/render/printer" 8 | "github.com/fe3dback/go-arch-lint/internal/view" 9 | ) 10 | 11 | func (c *Container) provideColorPrinter() *printer.ColorPrinter { 12 | return printer.NewColorPrinter( 13 | c.provideAurora(), 14 | ) 15 | } 16 | 17 | func (c *Container) provideAurora() aurora.Aurora { 18 | return aurora.NewAurora( 19 | c.flags.UseColors, 20 | ) 21 | } 22 | 23 | func (c *Container) ProvideRenderer() *render.Renderer { 24 | return render.NewRenderer( 25 | c.provideColorPrinter(), 26 | c.provideReferenceRender(), 27 | c.flags.OutputType, 28 | c.flags.OutputJsonOneLine, 29 | view.Templates, 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /internal/services/spec/validator/validator_common_vendors.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 5 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 6 | ) 7 | 8 | type validatorCommonVendors struct { 9 | utils *utils 10 | } 11 | 12 | func newValidatorCommonVendors( 13 | utils *utils, 14 | ) *validatorCommonVendors { 15 | return &validatorCommonVendors{ 16 | utils: utils, 17 | } 18 | } 19 | 20 | func (v *validatorCommonVendors) Validate(doc spec.Document) []arch.Notice { 21 | notices := make([]arch.Notice, 0) 22 | 23 | for _, vendorName := range doc.CommonVendors() { 24 | if err := v.utils.assertKnownVendor(vendorName.Value); err != nil { 25 | notices = append(notices, arch.Notice{ 26 | Notice: err, 27 | Ref: vendorName.Reference, 28 | }) 29 | } 30 | } 31 | 32 | return notices 33 | } 34 | -------------------------------------------------------------------------------- /internal/services/spec/validator/validator_exclude_files.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | 7 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 8 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 9 | ) 10 | 11 | type validatorExcludeFiles struct{} 12 | 13 | func newValidatorExcludeFiles() *validatorExcludeFiles { 14 | return &validatorExcludeFiles{} 15 | } 16 | 17 | func (v *validatorExcludeFiles) Validate(doc spec.Document) []arch.Notice { 18 | notices := make([]arch.Notice, 0) 19 | 20 | for index, regExp := range doc.ExcludedFilesRegExp() { 21 | if _, err := regexp.Compile(regExp.Value); err != nil { 22 | notices = append(notices, arch.Notice{ 23 | Notice: fmt.Errorf("invalid regexp '%s' at %d: %w", regExp.Value, index, err), 24 | Ref: regExp.Reference, 25 | }) 26 | } 27 | } 28 | 29 | return notices 30 | } 31 | -------------------------------------------------------------------------------- /internal/services/common/path/resolver.go: -------------------------------------------------------------------------------- 1 | package path 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | type ( 10 | Resolver struct{} 11 | ) 12 | 13 | func NewResolver() *Resolver { 14 | return &Resolver{} 15 | } 16 | 17 | func (r Resolver) Resolve(absPath string) (resolvePaths []string, err error) { 18 | absPath = strings.TrimSuffix(absPath, ".") 19 | 20 | matches, err := glob(absPath) 21 | if err != nil { 22 | return nil, fmt.Errorf("can`t match path mask '%s': %w", absPath, err) 23 | } 24 | 25 | dirs := make([]string, 0) 26 | for _, match := range matches { 27 | fileInfo, err := os.Stat(match) 28 | if err != nil { 29 | return nil, fmt.Errorf("nostat '%s': %w", match, err) 30 | } 31 | 32 | switch mode := fileInfo.Mode(); { 33 | case mode.IsDir(): 34 | dirs = append(dirs, match) 35 | default: 36 | continue 37 | } 38 | } 39 | 40 | return dirs, nil 41 | } 42 | -------------------------------------------------------------------------------- /internal/services/spec/validator/validator_common_components.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 5 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 6 | ) 7 | 8 | type validatorCommonComponents struct { 9 | utils *utils 10 | } 11 | 12 | func newValidatorCommonComponents( 13 | utils *utils, 14 | ) *validatorCommonComponents { 15 | return &validatorCommonComponents{ 16 | utils: utils, 17 | } 18 | } 19 | 20 | func (v *validatorCommonComponents) Validate(doc spec.Document) []arch.Notice { 21 | notices := make([]arch.Notice, 0) 22 | 23 | for _, componentName := range doc.CommonComponents() { 24 | if err := v.utils.assertKnownComponent(componentName.Value); err != nil { 25 | notices = append(notices, arch.Notice{ 26 | Notice: err, 27 | Ref: componentName.Reference, 28 | }) 29 | } 30 | } 31 | 32 | return notices 33 | } 34 | -------------------------------------------------------------------------------- /internal/services/render/code/line_count_test.go: -------------------------------------------------------------------------------- 1 | package code 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "testing" 7 | ) 8 | 9 | func Test_lineCounter(t *testing.T) { 10 | type args struct { 11 | r io.Reader 12 | } 13 | tests := []struct { 14 | name string 15 | args args 16 | want int 17 | wantErr bool 18 | }{ 19 | { 20 | name: "simple", 21 | args: args{ 22 | r: bytes.NewReader([]byte("Hello world\nThis buffer has three lines\n")), 23 | }, 24 | want: 3, 25 | wantErr: false, 26 | }, 27 | } 28 | for _, tt := range tests { 29 | t.Run(tt.name, func(t *testing.T) { 30 | got, err := lineCounter(tt.args.r) 31 | if (err != nil) != tt.wantErr { 32 | t.Errorf("lineCounter() error = %v, wantErr %v", err, tt.wantErr) 33 | return 34 | } 35 | if got != tt.want { 36 | t.Errorf("lineCounter() got = %v, want %v", got, tt.want) 37 | } 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /internal/services/render/code/render_test.go: -------------------------------------------------------------------------------- 1 | package code 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "testing" 7 | 8 | "github.com/fe3dback/go-arch-lint/internal/models/common" 9 | ) 10 | 11 | func Test_readLines(t *testing.T) { 12 | type args struct { 13 | r io.Reader 14 | ref common.Reference 15 | } 16 | tests := []struct { 17 | name string 18 | args args 19 | want []byte 20 | }{ 21 | { 22 | name: "simple", 23 | args: args{ 24 | r: bytes.NewReader([]byte("Line1\nLine2\nLine3\nLine4\nLine5\nLine6")), 25 | ref: common.NewReferenceRange("/", 2, 3, 4), 26 | }, 27 | want: []byte("Line2\nLine3\nLine4"), 28 | }, 29 | } 30 | for _, tt := range tests { 31 | t.Run(tt.name, func(t *testing.T) { 32 | if got := readLines(tt.args.r, tt.args.ref); string(got) != string(tt.want) { 33 | t.Errorf("readLines() = %v, want %v", string(got), string(tt.want)) 34 | } 35 | }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /internal/services/spec/assembler/assembler_composite.go: -------------------------------------------------------------------------------- 1 | package assembler 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 7 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 8 | ) 9 | 10 | type ( 11 | assembler interface { 12 | assemble(spec *arch.Spec, doc spec.Document) error 13 | } 14 | 15 | specCompositeModifier struct { 16 | modifiers []assembler 17 | } 18 | ) 19 | 20 | func newSpecCompositeAssembler(modifiers []assembler) *specCompositeModifier { 21 | return &specCompositeModifier{ 22 | modifiers: modifiers, 23 | } 24 | } 25 | 26 | func (s *specCompositeModifier) assemble(spec *arch.Spec, doc spec.Document) error { 27 | for _, modifier := range s.modifiers { 28 | err := modifier.assemble(spec, doc) 29 | if err != nil { 30 | return fmt.Errorf("failed to assemble spec with '%T' assembler: %w", modifier, err) 31 | } 32 | } 33 | 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /internal/services/spec/assembler/assembler_ac_exclude_file_matcher.go: -------------------------------------------------------------------------------- 1 | package assembler 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 7 | "github.com/fe3dback/go-arch-lint/internal/models/common" 8 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 9 | ) 10 | 11 | type excludeFilesMatcherAssembler struct{} 12 | 13 | func newExcludeFilesMatcherAssembler() *excludeFilesMatcherAssembler { 14 | return &excludeFilesMatcherAssembler{} 15 | } 16 | 17 | func (efa *excludeFilesMatcherAssembler) assemble(spec *arch.Spec, yamlSpec spec.Document) error { 18 | for _, regString := range yamlSpec.ExcludedFilesRegExp() { 19 | matcher, err := regexp.Compile(regString.Value) 20 | if err != nil { 21 | continue 22 | } 23 | 24 | spec.ExcludeFilesMatcher = append(spec.ExcludeFilesMatcher, common.NewReferable( 25 | matcher, 26 | regString.Reference, 27 | )) 28 | } 29 | 30 | return nil 31 | } 32 | -------------------------------------------------------------------------------- /internal/app/cli.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/fe3dback/go-arch-lint/internal/app/internal/container" 10 | "github.com/fe3dback/go-arch-lint/internal/models" 11 | ) 12 | 13 | func Execute() int { 14 | mainCtx, cancel := context.WithCancel(context.Background()) 15 | defer cancel() 16 | 17 | // -- build DI 18 | di := container.NewContainer( 19 | Version, 20 | BuildTime, 21 | CommitHash, 22 | ) 23 | 24 | // -- process 25 | err := di.CommandRoot().ExecuteContext(mainCtx) 26 | // -- handle errors 27 | if err != nil { 28 | if errors.Is(err, models.UserSpaceError{}) { 29 | // do not display user space errors (usually explain will be in ascii/json output) 30 | return 1 31 | } 32 | 33 | // system error, not possible to output this in json, so just dump to stdout 34 | _, _ = fmt.Fprintf(os.Stderr, "%s\n", err.Error()) 35 | return 1 36 | } 37 | 38 | return 0 39 | } 40 | -------------------------------------------------------------------------------- /internal/services/checker/checker_composite.go: -------------------------------------------------------------------------------- 1 | package checker 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/fe3dback/go-arch-lint/internal/models" 8 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 9 | ) 10 | 11 | type CompositeChecker struct { 12 | checkers []checker 13 | } 14 | 15 | func NewCompositeChecker(checkers ...checker) *CompositeChecker { 16 | return &CompositeChecker{checkers: checkers} 17 | } 18 | 19 | func (c *CompositeChecker) Check(ctx context.Context, spec arch.Spec) (models.CheckResult, error) { 20 | overallResults := models.CheckResult{} 21 | 22 | for ind, checker := range c.checkers { 23 | results, err := checker.Check(ctx, spec) 24 | if err != nil { 25 | return models.CheckResult{}, fmt.Errorf("checker failed '%T': %w", checker, err) 26 | } 27 | 28 | overallResults.Append(results) 29 | 30 | if results.HasNotices() && ind < len(c.checkers)-1 { 31 | break 32 | } 33 | } 34 | 35 | return overallResults, nil 36 | } 37 | -------------------------------------------------------------------------------- /internal/services/spec/validator/validator_version.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fe3dback/go-arch-lint/internal/models" 7 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 8 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 9 | ) 10 | 11 | type validatorVersion struct{} 12 | 13 | func newValidatorVersion() *validatorVersion { 14 | return &validatorVersion{} 15 | } 16 | 17 | func (v *validatorVersion) Validate(doc spec.Document) []arch.Notice { 18 | notices := make([]arch.Notice, 0) 19 | 20 | if doc.Version().Value <= models.SupportedVersionMax && doc.Version().Value >= models.SupportedVersionMin { 21 | return notices 22 | } 23 | 24 | notices = append(notices, arch.Notice{ 25 | Notice: fmt.Errorf("version '%d' is not supported, supported: [%d-%d]", 26 | doc.Version().Value, 27 | models.SupportedVersionMin, 28 | models.SupportedVersionMax, 29 | ), 30 | Ref: doc.Version().Reference, 31 | }) 32 | 33 | return notices 34 | } 35 | -------------------------------------------------------------------------------- /internal/models/opr_mapping.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | const ( 4 | MappingSchemeGrouped MappingScheme = "grouped" 5 | MappingSchemeList MappingScheme = "list" 6 | ) 7 | 8 | var MappingSchemesValues = []string{ 9 | MappingSchemeList, 10 | MappingSchemeGrouped, 11 | } 12 | 13 | type ( 14 | MappingScheme = string 15 | 16 | CmdMappingIn struct { 17 | ProjectPath string 18 | ArchFile string 19 | Scheme MappingScheme 20 | } 21 | 22 | CmdMappingOut struct { 23 | ProjectDirectory string `json:"ProjectDirectory"` 24 | ModuleName string `json:"ModuleName"` 25 | MappingGrouped []CmdMappingOutGrouped `json:"MappingGrouped"` 26 | MappingList []CmdMappingOutList `json:"MappingList"` 27 | Scheme MappingScheme `json:"-"` 28 | } 29 | 30 | CmdMappingOutGrouped struct { 31 | ComponentName string 32 | FileNames []string 33 | } 34 | 35 | CmdMappingOutList struct { 36 | FileName string 37 | ComponentName string 38 | } 39 | ) 40 | -------------------------------------------------------------------------------- /internal/view/view_mapping.gohtml: -------------------------------------------------------------------------------- 1 | {{- /* gotype: github.com/fe3dback/go-arch-lint/internal/models.CmdMappingOut*/ -}} 2 | {{ $root := .ProjectDirectory -}} 3 | 4 | module: {{ .ModuleName | colorize "green" }} 5 | Project Packages: 6 | {{ if eq .Scheme "list" -}} 7 | {{ $prev := "" -}} 8 | {{ range .MappingList -}} 9 | {{ $packageName := (.FileName | trimPrefix $root | dir | def "/") -}} 10 | 11 | {{ if ne $prev $packageName -}} 12 | {{ " " }} {{ .ComponentName | padRight 20 " " -}} 13 | {{ $packageName | colorize "cyan" }} 14 | {{ end -}} 15 | 16 | {{ $prev = $packageName -}} 17 | {{ end -}} 18 | {{ else -}} 19 | {{ range .MappingGrouped -}} 20 | {{ " " }} {{ .ComponentName }}: 21 | {{ $prev := "" -}} 22 | {{ range .FileNames -}} 23 | {{ $packageName := (. | trimPrefix $root | dir | def "/") -}} 24 | 25 | {{ if ne $prev $packageName -}} 26 | {{ " " }} {{ $packageName | colorize "cyan" }} 27 | {{ end -}} 28 | 29 | {{ $prev = $packageName -}} 30 | {{ end -}} 31 | {{ end -}} 32 | {{ end -}} 33 | -------------------------------------------------------------------------------- /internal/services/spec/validator/validator_workdir.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | "path/filepath" 7 | 8 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 9 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 10 | ) 11 | 12 | type validatorWorkDir struct { 13 | utils *utils 14 | } 15 | 16 | func newValidatorWorkDir(utils *utils) *validatorWorkDir { 17 | return &validatorWorkDir{ 18 | utils: utils, 19 | } 20 | } 21 | 22 | func (v *validatorWorkDir) Validate(doc spec.Document) []arch.Notice { 23 | notices := make([]arch.Notice, 0) 24 | 25 | absPath := filepath.Join(v.utils.projectDir, doc.WorkingDirectory().Value) 26 | absPath = path.Clean(absPath) 27 | 28 | err := v.utils.assertDirectoriesValid(absPath) 29 | if err != nil { 30 | notices = append(notices, arch.Notice{ 31 | Notice: fmt.Errorf("invalid workdir '%s' (%s), directory not exist", 32 | doc.WorkingDirectory().Value, 33 | absPath, 34 | ), 35 | Ref: doc.WorkingDirectory().Reference, 36 | }) 37 | } 38 | 39 | return notices 40 | } 41 | -------------------------------------------------------------------------------- /internal/app/internal/container/container_cmd_schema.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/fe3dback/go-arch-lint/internal/models" 9 | "github.com/fe3dback/go-arch-lint/internal/operations/schema" 10 | ) 11 | 12 | func (c *Container) commandSchema() (*cobra.Command, runner) { 13 | cmd := &cobra.Command{ 14 | Use: "schema", 15 | Short: "json schema for arch file inspection", 16 | Long: "useful for integrations with ide's and editor plugins", 17 | } 18 | 19 | in := models.CmdSchemaIn{ 20 | Version: 0, 21 | } 22 | 23 | cmd.PersistentFlags().IntVar(&in.Version, "version", in.Version, fmt.Sprintf("json schema version to output (min: %d, max: %d)", 24 | models.SupportedVersionMin, 25 | models.SupportedVersionMax, 26 | )) 27 | 28 | return cmd, func(act *cobra.Command) (any, error) { 29 | return c.commandSchemaOperation().Behave(in) 30 | } 31 | } 32 | 33 | func (c *Container) commandSchemaOperation() *schema.Operation { 34 | return schema.NewOperation( 35 | c.provideJsonSchemaProvider(), 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 fe3dback@pm.me 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/services/spec/assembler/assembler_ac_exclude.go: -------------------------------------------------------------------------------- 1 | package assembler 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | 7 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 8 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 9 | ) 10 | 11 | type excludeAssembler struct { 12 | resolver *resolver 13 | } 14 | 15 | func newExcludeAssembler( 16 | resolver *resolver, 17 | ) *excludeAssembler { 18 | return &excludeAssembler{ 19 | resolver: resolver, 20 | } 21 | } 22 | 23 | func (ea *excludeAssembler) assemble(spec *arch.Spec, document spec.Document) error { 24 | for _, yamlRelativePath := range document.ExcludedDirectories() { 25 | tmpResolvedPath, err := ea.resolver.resolveLocalGlobPath( 26 | path.Clean(fmt.Sprintf("%s/%s", 27 | document.WorkingDirectory().Value, 28 | yamlRelativePath.Value, 29 | )), 30 | ) 31 | if err != nil { 32 | return fmt.Errorf("failed to assemble exclude '%s' path's: %w", yamlRelativePath.Value, err) 33 | } 34 | 35 | resolvedPath := wrap(yamlRelativePath.Reference, tmpResolvedPath) 36 | spec.Exclude = append(spec.Exclude, resolvedPath...) 37 | } 38 | 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /internal/view/templates.go: -------------------------------------------------------------------------------- 1 | package view 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | 7 | "github.com/fe3dback/go-arch-lint/internal/models" 8 | ) 9 | 10 | //go:embed view_check.gohtml 11 | var viewCheck []byte 12 | 13 | //go:embed view_error.gohtml 14 | var viewError []byte 15 | 16 | //go:embed view_graph.gohtml 17 | var viewGraph []byte 18 | 19 | //go:embed view_mapping.gohtml 20 | var viewMapping []byte 21 | 22 | //go:embed view_schema.gohtml 23 | var viewSchema []byte 24 | 25 | //go:embed view_self_inspect.gohtml 26 | var viewSelfInspect []byte 27 | 28 | //go:embed view_version.gohtml 29 | var viewVersion []byte 30 | 31 | var Templates = map[string]string{ 32 | tpl(models.CmdCheckOut{}): string(viewCheck), 33 | tpl(models.CmdErrorOut{}): string(viewError), 34 | tpl(models.CmdGraphOut{}): string(viewGraph), 35 | tpl(models.CmdMappingOut{}): string(viewMapping), 36 | tpl(models.CmdSchemaOut{}): string(viewSchema), 37 | tpl(models.CmdSelfInspectOut{}): string(viewSelfInspect), 38 | tpl(models.CmdVersionOut{}): string(viewVersion), 39 | } 40 | 41 | func tpl(model interface{}) string { 42 | return fmt.Sprintf("%T", model) 43 | } 44 | -------------------------------------------------------------------------------- /internal/services/spec/validator/validator.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 5 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 6 | ) 7 | 8 | type Validator struct { 9 | pathResolver pathResolver 10 | } 11 | 12 | func NewValidator( 13 | pathResolver pathResolver, 14 | ) *Validator { 15 | return &Validator{ 16 | pathResolver: pathResolver, 17 | } 18 | } 19 | 20 | func (v *Validator) Validate(doc spec.Document, projectDir string) []arch.Notice { 21 | notices := make([]arch.Notice, 0) 22 | 23 | utils := newUtils(v.pathResolver, doc, projectDir) 24 | validators := []validator{ 25 | newValidatorCommonComponents(utils), 26 | newValidatorCommonVendors(utils), 27 | newValidatorComponents(utils), 28 | newValidatorDeps(utils), 29 | newValidatorDepsComponents(utils), 30 | newValidatorDepsVendors(utils), 31 | newValidatorExcludeFiles(), 32 | newValidatorVendors(), 33 | newValidatorVersion(), 34 | newValidatorWorkDir(utils), 35 | } 36 | 37 | for _, specValidator := range validators { 38 | notices = append(notices, specValidator.Validate(doc)...) 39 | } 40 | 41 | return notices 42 | } 43 | -------------------------------------------------------------------------------- /internal/services/render/printer/color_printer.go: -------------------------------------------------------------------------------- 1 | package printer 2 | 3 | import ( 4 | "github.com/logrusorgru/aurora/v3" 5 | ) 6 | 7 | type ColorPrinter struct { 8 | au aurora.Aurora 9 | } 10 | 11 | func NewColorPrinter(au aurora.Aurora) *ColorPrinter { 12 | return &ColorPrinter{ 13 | au: au, 14 | } 15 | } 16 | 17 | func (cp *ColorPrinter) Red(in string) (out string) { 18 | return cp.au.Red(in).String() 19 | } 20 | 21 | func (cp *ColorPrinter) Green(in string) (out string) { 22 | return cp.au.Green(in).String() 23 | } 24 | 25 | func (cp *ColorPrinter) Yellow(in string) (out string) { 26 | return cp.au.Yellow(in).String() 27 | } 28 | 29 | func (cp *ColorPrinter) Blue(in string) (out string) { 30 | return cp.au.Blue(in).String() 31 | } 32 | 33 | func (cp *ColorPrinter) Magenta(in string) (out string) { 34 | return cp.au.Magenta(in).String() 35 | } 36 | 37 | func (cp *ColorPrinter) Cyan(in string) (out string) { 38 | return cp.au.Cyan(in).String() 39 | } 40 | 41 | func (cp *ColorPrinter) White(in string) (out string) { 42 | return cp.au.White(in).String() 43 | } 44 | 45 | func (cp *ColorPrinter) Gray(in string) (out string) { 46 | return cp.au.BrightBlack(in).String() 47 | } 48 | -------------------------------------------------------------------------------- /internal/operations/schema/operation.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/fe3dback/go-arch-lint/internal/models" 8 | ) 9 | 10 | type Operation struct { 11 | jsonSchemaProvider jsonSchemaProvider 12 | } 13 | 14 | func NewOperation(jsonSchemaProvider jsonSchemaProvider) *Operation { 15 | return &Operation{ 16 | jsonSchemaProvider: jsonSchemaProvider, 17 | } 18 | } 19 | 20 | func (o *Operation) Behave(in models.CmdSchemaIn) (models.CmdSchemaOut, error) { 21 | jsonSchema, err := o.jsonSchemaProvider.Provide(in.Version) 22 | if err != nil { 23 | return models.CmdSchemaOut{}, fmt.Errorf("failed to provide json schema: %w", err) 24 | } 25 | 26 | // reformat json to system one line string 27 | var data interface{} 28 | err = json.Unmarshal(jsonSchema, &data) 29 | if err != nil { 30 | return models.CmdSchemaOut{}, fmt.Errorf("failed to unmarshal json: %w", err) 31 | } 32 | 33 | formatted, err := json.Marshal(data) 34 | if err != nil { 35 | return models.CmdSchemaOut{}, fmt.Errorf("failed to marshal json: %w", err) 36 | } 37 | 38 | return models.CmdSchemaOut{ 39 | Version: in.Version, 40 | JSONSchema: string(formatted), 41 | }, nil 42 | } 43 | -------------------------------------------------------------------------------- /internal/services/render/colorize.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | const ( 8 | colorRed colorName = "red" 9 | colorGreen colorName = "green" 10 | colorYellow colorName = "yellow" 11 | colorBlue colorName = "blue" 12 | colorMagenta colorName = "magenta" 13 | colorCyan colorName = "cyan" 14 | colorGray colorName = "gray" 15 | ) 16 | 17 | type ( 18 | colorizer struct { 19 | printer colorPrinter 20 | } 21 | 22 | colorName = string 23 | ) 24 | 25 | func newColorizer(printer colorPrinter) *colorizer { 26 | return &colorizer{ 27 | printer: printer, 28 | } 29 | } 30 | 31 | func (c *colorizer) colorize(color colorName, input string) (string, error) { 32 | switch color { 33 | case colorRed: 34 | return c.printer.Red(input), nil 35 | case colorGreen: 36 | return c.printer.Green(input), nil 37 | case colorYellow: 38 | return c.printer.Yellow(input), nil 39 | case colorBlue: 40 | return c.printer.Blue(input), nil 41 | case colorMagenta: 42 | return c.printer.Magenta(input), nil 43 | case colorCyan: 44 | return c.printer.Cyan(input), nil 45 | case colorGray: 46 | return c.printer.Gray(input), nil 47 | default: 48 | return "", fmt.Errorf("invalid color '%s'", color) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /internal/app/internal/container/container_cmd_self_inspect.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/fe3dback/go-arch-lint/internal/models" 7 | "github.com/fe3dback/go-arch-lint/internal/operations/selfInspect" 8 | ) 9 | 10 | func (c *Container) commandSelfInspect() (*cobra.Command, runner) { 11 | cmd := &cobra.Command{ 12 | Use: "self-inspect", 13 | Short: "will validate arch config and arch setup", 14 | Long: "this useful for IDE plugins and other tool integration", 15 | } 16 | 17 | in := models.CmdSelfInspectIn{ 18 | ProjectPath: models.DefaultProjectPath, 19 | ArchFile: models.DefaultArchFileName, 20 | } 21 | 22 | cmd.PersistentFlags().StringVar(&in.ProjectPath, "project-path", in.ProjectPath, "absolute path to project directory") 23 | cmd.PersistentFlags().StringVar(&in.ArchFile, "arch-file", in.ArchFile, "arch file path") 24 | 25 | return cmd, func(_ *cobra.Command) (any, error) { 26 | return c.commandSelfInspectOperation().Behave(in) 27 | } 28 | } 29 | 30 | func (c *Container) commandSelfInspectOperation() *selfInspect.Operation { 31 | return selfInspect.NewOperation( 32 | c.provideSpecAssembler(), 33 | c.provideProjectInfoAssembler(), 34 | c.version, 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /internal/models/errors.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "github.com/fe3dback/go-arch-lint/internal/models/common" 4 | 5 | type ( 6 | UserSpaceError struct { 7 | msg string 8 | } 9 | 10 | ReferableError struct { 11 | original error 12 | reference common.Reference 13 | } 14 | ) 15 | 16 | func (u UserSpaceError) Error() string { 17 | return u.msg 18 | } 19 | 20 | func (r ReferableError) Error() string { 21 | return r.original.Error() 22 | } 23 | 24 | func (r ReferableError) Reference() common.Reference { 25 | return r.reference 26 | } 27 | 28 | func (u UserSpaceError) Is(err error) bool { 29 | if err == nil { 30 | return false 31 | } 32 | 33 | if _, ok := err.(UserSpaceError); ok { 34 | return true 35 | } 36 | 37 | return false 38 | } 39 | 40 | func (r ReferableError) Is(err error) bool { 41 | if err == nil { 42 | return false 43 | } 44 | 45 | if _, ok := err.(ReferableError); ok { 46 | return true 47 | } 48 | 49 | return false 50 | } 51 | 52 | func NewUserSpaceError(msg string) UserSpaceError { 53 | return UserSpaceError{ 54 | msg: msg, 55 | } 56 | } 57 | 58 | func NewReferableErr(err error, ref common.Reference) ReferableError { 59 | return ReferableError{ 60 | original: err, 61 | reference: ref, 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /internal/services/checker/results.go: -------------------------------------------------------------------------------- 1 | package checker 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/fe3dback/go-arch-lint/internal/models" 7 | ) 8 | 9 | type ( 10 | results models.CheckResult 11 | ) 12 | 13 | func newResults() results { 14 | return results{ 15 | DependencyWarnings: []models.CheckArchWarningDependency{}, 16 | MatchWarnings: []models.CheckArchWarningMatch{}, 17 | } 18 | } 19 | 20 | func (res *results) addNotMatchedWarning(warn models.CheckArchWarningMatch) { 21 | res.MatchWarnings = append(res.MatchWarnings, warn) 22 | } 23 | 24 | func (res *results) addDependencyWarning(warn models.CheckArchWarningDependency) { 25 | res.DependencyWarnings = append(res.DependencyWarnings, warn) 26 | } 27 | 28 | func (res *results) assembleSortedResults() models.CheckResult { 29 | sort.Slice(res.DependencyWarnings, func(i, j int) bool { 30 | return res.DependencyWarnings[i].FileRelativePath < res.DependencyWarnings[j].FileRelativePath 31 | }) 32 | 33 | sort.Slice(res.MatchWarnings, func(i, j int) bool { 34 | return res.MatchWarnings[i].FileRelativePath < res.MatchWarnings[j].FileRelativePath 35 | }) 36 | 37 | return models.CheckResult{ 38 | DependencyWarnings: res.DependencyWarnings, 39 | MatchWarnings: res.MatchWarnings, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/services/spec/assembler/allowed_vendor_imports.go: -------------------------------------------------------------------------------- 1 | package assembler 2 | 3 | import ( 4 | "github.com/fe3dback/go-arch-lint/internal/models" 5 | "github.com/fe3dback/go-arch-lint/internal/models/common" 6 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 7 | ) 8 | 9 | type allowedVendorImportsAssembler struct{} 10 | 11 | func newAllowedVendorImportsAssembler() *allowedVendorImportsAssembler { 12 | return &allowedVendorImportsAssembler{} 13 | } 14 | 15 | func (aia *allowedVendorImportsAssembler) assemble( 16 | yamlDocument spec.Document, 17 | vendorNames []string, 18 | ) ([]common.Referable[models.Glob], error) { 19 | list := make([]common.Referable[models.Glob], 0) 20 | 21 | allowedVendors := make([]string, 0) 22 | allowedVendors = append(allowedVendors, vendorNames...) 23 | for _, vendorName := range yamlDocument.CommonVendors() { 24 | allowedVendors = append(allowedVendors, vendorName.Value) 25 | } 26 | 27 | for _, name := range allowedVendors { 28 | yamlVendor, ok := yamlDocument.Vendors()[name] 29 | if !ok { 30 | continue 31 | } 32 | 33 | for _, vendorIn := range yamlVendor.Value.ImportPaths() { 34 | list = append(list, common.NewReferable(vendorIn, yamlVendor.Reference)) 35 | } 36 | } 37 | 38 | return list, nil 39 | } 40 | -------------------------------------------------------------------------------- /internal/models/resolved_file.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/fe3dback/go-arch-lint/internal/models/common" 7 | ) 8 | 9 | const ( 10 | ImportTypeStdLib ImportType = iota 11 | ImportTypeProject 12 | ImportTypeVendor 13 | ) 14 | 15 | type ( 16 | ImportType uint8 17 | 18 | FileHold struct { 19 | File ProjectFile 20 | ComponentID *string 21 | } 22 | 23 | ProjectFile struct { 24 | Path string 25 | Imports []ResolvedImport 26 | } 27 | 28 | ResolvedImport struct { 29 | Name string 30 | ImportType ImportType 31 | Reference common.Reference 32 | } 33 | ) 34 | 35 | // GetImportType classifies an import path as std, project, or vendor 36 | func GetImportType(importPath string, moduleName string, stdPackages map[string]struct{}) ImportType { 37 | if _, ok := stdPackages[importPath]; ok { 38 | return ImportTypeStdLib 39 | } 40 | 41 | // We can't use a straight prefix match here because the module name could be a substring of the import path. 42 | // For example, if the module name is "example.com/foo/bar", we do not want to match "example.com/foo/bar-utils" 43 | if importPath == moduleName || strings.HasPrefix(importPath, moduleName+"/") { 44 | return ImportTypeProject 45 | } 46 | 47 | return ImportTypeVendor 48 | } 49 | -------------------------------------------------------------------------------- /internal/services/spec/validator/validator_deps_vendors.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 7 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 8 | ) 9 | 10 | type validatorDepsVendors struct { 11 | utils *utils 12 | } 13 | 14 | func newValidatorDepsVendors( 15 | utils *utils, 16 | ) *validatorDepsVendors { 17 | return &validatorDepsVendors{ 18 | utils: utils, 19 | } 20 | } 21 | 22 | func (v *validatorDepsVendors) Validate(doc spec.Document) []arch.Notice { 23 | notices := make([]arch.Notice, 0) 24 | 25 | for name, rule := range doc.Dependencies() { 26 | existVendors := make(map[string]bool) 27 | 28 | for _, vendorName := range rule.Value.CanUse() { 29 | if _, ok := existVendors[vendorName.Value]; ok { 30 | notices = append(notices, arch.Notice{ 31 | Notice: fmt.Errorf("vendor '%s' dublicated in '%s' deps", vendorName.Value, name), 32 | Ref: vendorName.Reference, 33 | }) 34 | } 35 | 36 | if err := v.utils.assertKnownVendor(vendorName.Value); err != nil { 37 | notices = append(notices, arch.Notice{ 38 | Notice: err, 39 | Ref: vendorName.Reference, 40 | }) 41 | } 42 | 43 | existVendors[vendorName.Value] = true 44 | } 45 | } 46 | 47 | return notices 48 | } 49 | -------------------------------------------------------------------------------- /internal/services/spec/validator/validator_components.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | 7 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 8 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 9 | ) 10 | 11 | type validatorComponents struct { 12 | utils *utils 13 | } 14 | 15 | func newValidatorComponents( 16 | utils *utils, 17 | ) *validatorComponents { 18 | return &validatorComponents{ 19 | utils: utils, 20 | } 21 | } 22 | 23 | func (v *validatorComponents) Validate(doc spec.Document) []arch.Notice { 24 | notices := make([]arch.Notice, 0) 25 | 26 | if len(doc.Components()) == 0 { 27 | notices = append(notices, arch.Notice{ 28 | Notice: fmt.Errorf("at least one component should by defined"), 29 | Ref: doc.Version().Reference, 30 | }) 31 | } 32 | 33 | for _, component := range doc.Components() { 34 | for _, componentIn := range component.Value.RelativePaths() { 35 | localPath := path.Clean(fmt.Sprintf("%s/%s", 36 | doc.WorkingDirectory().Value, 37 | string(componentIn), 38 | )) 39 | 40 | if err := v.utils.assertGlobPathValid(localPath); err != nil { 41 | notices = append(notices, arch.Notice{ 42 | Notice: err, 43 | Ref: component.Reference, 44 | }) 45 | } 46 | } 47 | } 48 | 49 | return notices 50 | } 51 | -------------------------------------------------------------------------------- /internal/services/project/resolver/resolver.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path" 7 | 8 | "github.com/fe3dback/go-arch-lint/internal/models" 9 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 10 | ) 11 | 12 | type Resolver struct { 13 | projectFilesResolver projectFilesResolver 14 | projectFilesHolder projectFilesHolder 15 | } 16 | 17 | func NewResolver( 18 | projectFilesResolver projectFilesResolver, 19 | projectFilesHolder projectFilesHolder, 20 | ) *Resolver { 21 | return &Resolver{ 22 | projectFilesResolver: projectFilesResolver, 23 | projectFilesHolder: projectFilesHolder, 24 | } 25 | } 26 | 27 | func (r *Resolver) ProjectFiles(ctx context.Context, spec arch.Spec) ([]models.FileHold, error) { 28 | scanDirectory := path.Clean(fmt.Sprintf("%s/%s", 29 | spec.RootDirectory.Value, 30 | spec.WorkingDirectory.Value, 31 | )) 32 | 33 | projectFiles, err := r.projectFilesResolver.Scan( 34 | ctx, 35 | scanDirectory, 36 | spec.ModuleName.Value, 37 | refPathToList(spec.Exclude), 38 | refRegExpToList(spec.ExcludeFilesMatcher), 39 | ) 40 | if err != nil { 41 | return nil, fmt.Errorf("failed to resolve project files: %w", err) 42 | } 43 | 44 | holdFiles := r.projectFilesHolder.HoldProjectFiles(projectFiles, spec.Components) 45 | return holdFiles, nil 46 | } 47 | -------------------------------------------------------------------------------- /internal/services/spec/validator/validator_deps_components.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 7 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 8 | ) 9 | 10 | type validatorDepsComponents struct { 11 | utils *utils 12 | } 13 | 14 | func newValidatorDepsComponents( 15 | utils *utils, 16 | ) *validatorDepsComponents { 17 | return &validatorDepsComponents{ 18 | utils: utils, 19 | } 20 | } 21 | 22 | func (v *validatorDepsComponents) Validate(doc spec.Document) []arch.Notice { 23 | notices := make([]arch.Notice, 0) 24 | 25 | for name, rule := range doc.Dependencies() { 26 | existComponents := make(map[string]bool) 27 | 28 | for _, componentName := range rule.Value.MayDependOn() { 29 | if _, ok := existComponents[componentName.Value]; ok { 30 | notices = append(notices, arch.Notice{ 31 | Notice: fmt.Errorf("component '%s' dublicated in '%s' deps", componentName.Value, name), 32 | Ref: componentName.Reference, 33 | }) 34 | } 35 | 36 | if err := v.utils.assertKnownComponent(componentName.Value); err != nil { 37 | notices = append(notices, arch.Notice{ 38 | Notice: err, 39 | Ref: componentName.Reference, 40 | }) 41 | } 42 | 43 | existComponents[componentName.Value] = true 44 | } 45 | } 46 | 47 | return notices 48 | } 49 | -------------------------------------------------------------------------------- /internal/models/utils_path.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | type ( 10 | Glob string 11 | 12 | ResolvedPath struct { 13 | ImportPath string 14 | LocalPath string 15 | AbsPath string 16 | } 17 | ) 18 | 19 | // Match check if path is subset of glob, for example: 20 | // - github.com/**/library/*/abc 21 | // 22 | // will match: 23 | // - github.com/a/b/c/library/any/abc 24 | // - github.com/test/library/another/awesome 25 | // 26 | // and not match: 27 | // - github.com/a/b/c/library/any 28 | // - github.com/library/another/awesome 29 | func (glob Glob) Match(testedPath string) (bool, error) { 30 | regGlob := string(glob) 31 | regGlob = strings.ReplaceAll(regGlob, ".", "\\.") // safe dots 32 | regGlob = strings.ReplaceAll(regGlob, "/", "\\/") // safe slash 33 | 34 | regGlob = strings.ReplaceAll(regGlob, "**", "") // super glob tmp 35 | regGlob = strings.ReplaceAll(regGlob, "*", "[^\\/]+") // single glob 36 | regGlob = strings.ReplaceAll(regGlob, "", ".*") // super glob 37 | regGlob = fmt.Sprintf("^%s$", regGlob) 38 | 39 | matcher, err := regexp.Compile(regGlob) 40 | if err != nil { 41 | return false, fmt.Errorf("failed compile glob '%s' as regexp '%s': %w", 42 | glob, 43 | regGlob, 44 | err, 45 | ) 46 | } 47 | 48 | return matcher.MatchString(testedPath), nil 49 | } 50 | -------------------------------------------------------------------------------- /internal/services/spec/decoder/decoder_yaml.go: -------------------------------------------------------------------------------- 1 | package decoder 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/fe3dback/go-yaml/ast" 8 | 9 | "github.com/fe3dback/go-arch-lint/internal/models/common" 10 | ) 11 | 12 | type ref[T any] struct { 13 | defined bool 14 | ref common.Referable[T] 15 | } 16 | 17 | type ( 18 | stringList []string 19 | yamlParentFileCtx struct{} 20 | ) 21 | 22 | func (r *ref[T]) UnmarshalYAML(ctx context.Context, node ast.Node, decode func(interface{}) error) error { 23 | filePath := "" 24 | if ref, ok := ctx.Value(yamlParentFileCtx{}).(string); ok { 25 | filePath = ref 26 | } 27 | 28 | r.defined = true 29 | r.ref.Reference = common.NewReferenceSingleLine( 30 | filePath, 31 | node.GetToken().Position.Line, 32 | node.GetToken().Position.Column, 33 | ) 34 | 35 | return decode(&r.ref.Value) 36 | } 37 | 38 | func (s *stringList) UnmarshalYAML(unmarshal func(interface{}) error) error { 39 | var list []string 40 | var lastErr error 41 | 42 | if err := unmarshal(&list); err == nil { 43 | *s = list 44 | return nil 45 | } else { 46 | lastErr = err 47 | } 48 | 49 | var value string 50 | if err := unmarshal(&value); err == nil { 51 | *s = []string{value} 52 | return nil 53 | } else { 54 | lastErr = fmt.Errorf("%s: %w", lastErr.Error(), err) 55 | } 56 | 57 | return fmt.Errorf("failed decode yaml stringsList: %w", lastErr) 58 | } 59 | -------------------------------------------------------------------------------- /internal/services/spec/assembler/resolver.go: -------------------------------------------------------------------------------- 1 | package assembler 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/fe3dback/go-arch-lint/internal/models" 9 | ) 10 | 11 | type resolver struct { 12 | pathResolver pathResolver 13 | rootDirectory string 14 | moduleName string 15 | } 16 | 17 | func newResolver( 18 | pathResolver pathResolver, 19 | rootDirectory string, 20 | moduleName string, 21 | ) *resolver { 22 | return &resolver{ 23 | pathResolver: pathResolver, 24 | rootDirectory: rootDirectory, 25 | moduleName: moduleName, 26 | } 27 | } 28 | 29 | func (r *resolver) resolveLocalGlobPath(localGlobPath string) ([]models.ResolvedPath, error) { 30 | list := make([]models.ResolvedPath, 0) 31 | 32 | absPath := fmt.Sprintf("%s/%s", r.rootDirectory, localGlobPath) 33 | resolved, err := r.pathResolver.Resolve(absPath) 34 | if err != nil { 35 | return nil, fmt.Errorf("failed to resolve path '%s'", absPath) 36 | } 37 | 38 | for _, absResolvedPath := range resolved { 39 | localPath := strings.TrimPrefix(absResolvedPath, fmt.Sprintf("%s/", r.rootDirectory)) 40 | localPath = strings.TrimRight(localPath, "/") 41 | importPath := fmt.Sprintf("%s/%s", r.moduleName, localPath) 42 | 43 | list = append(list, models.ResolvedPath{ 44 | ImportPath: strings.TrimRight(importPath, "/"), 45 | LocalPath: strings.TrimRight(localPath, "/"), 46 | AbsPath: filepath.Clean(strings.TrimRight(absResolvedPath, "/")), 47 | }) 48 | } 49 | 50 | return list, nil 51 | } 52 | -------------------------------------------------------------------------------- /internal/services/common/yaml/reference/resolver.go: -------------------------------------------------------------------------------- 1 | package reference 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/fe3dback/go-yaml" 8 | "github.com/fe3dback/go-yaml/parser" 9 | 10 | "github.com/fe3dback/go-arch-lint/internal/models/common" 11 | ) 12 | 13 | type Resolver struct { 14 | cache map[string][]byte 15 | } 16 | 17 | func NewResolver() *Resolver { 18 | return &Resolver{ 19 | cache: map[string][]byte{}, 20 | } 21 | } 22 | 23 | func (r *Resolver) Resolve(filePath string, yamlPath string) (ref common.Reference) { 24 | defer func() { 25 | if data := recover(); data != nil { 26 | ref = common.NewEmptyReference() 27 | return 28 | } 29 | }() 30 | 31 | sourceCode := r.fileSource(filePath) 32 | 33 | path, err := yaml.PathString(yamlPath) 34 | if err != nil { 35 | return common.NewEmptyReference() 36 | } 37 | 38 | file, err := parser.ParseBytes(sourceCode, 0) 39 | if err != nil { 40 | return common.NewEmptyReference() 41 | } 42 | 43 | node, err := path.FilterFile(file) 44 | if err != nil { 45 | return common.NewEmptyReference() 46 | } 47 | 48 | pos := node.GetToken().Position 49 | 50 | return common.NewReferenceSingleLine( 51 | filePath, 52 | pos.Line, 53 | pos.Column, 54 | ) 55 | } 56 | 57 | func (r *Resolver) fileSource(filePath string) []byte { 58 | if content, exist := r.cache[filePath]; exist { 59 | return content 60 | } 61 | 62 | content, err := os.ReadFile(filePath) 63 | if err != nil { 64 | panic(fmt.Sprintf("failed to provide source code of archfile: %v", err)) 65 | } 66 | 67 | r.cache[filePath] = content 68 | return content 69 | } 70 | -------------------------------------------------------------------------------- /internal/models/arch/spec.go: -------------------------------------------------------------------------------- 1 | package arch 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/fe3dback/go-arch-lint/internal/models" 7 | "github.com/fe3dback/go-arch-lint/internal/models/common" 8 | ) 9 | 10 | type ( 11 | Spec struct { 12 | RootDirectory common.Referable[string] 13 | WorkingDirectory common.Referable[string] 14 | ModuleName common.Referable[string] 15 | Allow Allow 16 | Components []Component 17 | Exclude []common.Referable[models.ResolvedPath] 18 | ExcludeFilesMatcher []common.Referable[*regexp.Regexp] 19 | Integrity Integrity 20 | } 21 | 22 | Allow struct { 23 | DepOnAnyVendor common.Referable[bool] 24 | DeepScan common.Referable[bool] 25 | IgnoreNotFoundComponents common.Referable[bool] 26 | } 27 | 28 | Component struct { 29 | Name common.Referable[string] 30 | DeepScan common.Referable[bool] 31 | ResolvedPaths []common.Referable[models.ResolvedPath] 32 | AllowedProjectImports []common.Referable[models.ResolvedPath] 33 | AllowedVendorGlobs []common.Referable[models.Glob] 34 | MayDependOn []common.Referable[string] 35 | CanUse []common.Referable[string] 36 | SpecialFlags SpecialFlags 37 | } 38 | 39 | SpecialFlags struct { 40 | AllowAllProjectDeps common.Referable[bool] 41 | AllowAllVendorDeps common.Referable[bool] 42 | } 43 | 44 | Integrity struct { 45 | DocumentNotices []Notice 46 | Suggestions []Notice 47 | } 48 | 49 | Notice struct { 50 | Notice error 51 | Ref common.Reference 52 | } 53 | ) 54 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - 13 | name: Check Tag 14 | id: check-tag 15 | run: | # 16 | if [[ ${{ github.event.ref }} =~ ^refs\/tags\/v[0-9]+\.[0-9]+\.[0-9]+(\-rc[0-9]*)?$ ]]; then 17 | echo "match=true" >> $GITHUB_OUTPUT 18 | fi 19 | - 20 | name: Checkout 21 | if: steps.check-tag.outputs.match == 'true' 22 | uses: actions/checkout@v2 23 | with: 24 | fetch-depth: 0 25 | - 26 | name: Set up Go 27 | if: steps.check-tag.outputs.match == 'true' 28 | uses: actions/setup-go@v2 29 | with: 30 | go-version: 1.24 31 | - 32 | name: Docker Login 33 | if: success() && steps.check-tag.outputs.match == 'true' 34 | env: 35 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} 36 | DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} 37 | run: | 38 | echo "${DOCKER_PASSWORD}" | docker login --username "${DOCKER_USERNAME}" --password-stdin 39 | - 40 | name: Release 41 | if: steps.check-tag.outputs.match == 'true' 42 | uses: goreleaser/goreleaser-action@v2 43 | with: 44 | version: latest 45 | args: release --clean 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.RELEASE_GH_TOKEN }} 48 | - 49 | name: Clear 50 | if: always() && steps.check-tag.outputs.match == 'true' 51 | run: | 52 | rm -f ${HOME}/.docker/config.json 53 | -------------------------------------------------------------------------------- /internal/services/render/code/reader.go: -------------------------------------------------------------------------------- 1 | package code 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io" 7 | "os" 8 | 9 | "github.com/alecthomas/chroma/formatters" 10 | "github.com/alecthomas/chroma/lexers" 11 | "github.com/alecthomas/chroma/styles" 12 | 13 | "github.com/fe3dback/go-arch-lint/internal/models/common" 14 | ) 15 | 16 | func readFile(fileName string) (content io.Reader, linesCount int) { 17 | file, err := os.Open(fileName) 18 | if err != nil { 19 | return nil, 0 20 | } 21 | 22 | linesCount, err = lineCounter(file) 23 | if err != nil { 24 | return nil, 0 25 | } 26 | 27 | _, err = file.Seek(0, io.SeekStart) 28 | if err != nil { 29 | return nil, 0 30 | } 31 | 32 | return file, linesCount 33 | } 34 | 35 | func readLines(r io.Reader, ref common.Reference) []byte { 36 | sc := bufio.NewScanner(r) 37 | currentLine := 0 38 | var buffer bytes.Buffer 39 | 40 | for sc.Scan() { 41 | currentLine++ 42 | 43 | if currentLine >= ref.LineFrom && currentLine <= ref.LineTo { 44 | buffer.Write(sc.Bytes()) 45 | 46 | if currentLine != ref.LineTo { 47 | buffer.WriteByte('\n') 48 | } 49 | } 50 | } 51 | 52 | return buffer.Bytes() 53 | } 54 | 55 | func highlightContent(filePath string, code []byte) []byte { 56 | lexer := lexers.Match(filePath) 57 | if lexer == nil { 58 | lexer = lexers.Fallback 59 | } 60 | 61 | style := styles.Trac 62 | formatter := formatters.TTY8 63 | 64 | iterator, err := lexer.Tokenise(nil, string(code)) 65 | if err != nil { 66 | return []byte{} 67 | } 68 | 69 | var buf bytes.Buffer 70 | err = formatter.Format(&buf, style, iterator) 71 | if err != nil { 72 | return []byte{} 73 | } 74 | 75 | return buf.Bytes() 76 | } 77 | -------------------------------------------------------------------------------- /internal/services/spec/assembler/allowed_project_imports.go: -------------------------------------------------------------------------------- 1 | package assembler 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | 7 | "github.com/fe3dback/go-arch-lint/internal/models" 8 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 9 | ) 10 | 11 | type allowedProjectImportsAssembler struct { 12 | resolver *resolver 13 | } 14 | 15 | func newAllowedProjectImportsAssembler( 16 | resolver *resolver, 17 | ) *allowedProjectImportsAssembler { 18 | return &allowedProjectImportsAssembler{ 19 | resolver: resolver, 20 | } 21 | } 22 | 23 | func (aia *allowedProjectImportsAssembler) assemble( 24 | yamlDocument spec.Document, 25 | componentNames []string, 26 | ) ([]models.ResolvedPath, error) { 27 | list := make([]models.ResolvedPath, 0) 28 | 29 | allowedComponents := make([]string, 0) 30 | allowedComponents = append(allowedComponents, componentNames...) 31 | for _, componentName := range yamlDocument.CommonComponents() { 32 | allowedComponents = append(allowedComponents, componentName.Value) 33 | } 34 | 35 | for _, name := range allowedComponents { 36 | yamlComponent, ok := yamlDocument.Components()[name] 37 | if !ok { 38 | continue 39 | } 40 | 41 | for _, componentIn := range yamlComponent.Value.RelativePaths() { 42 | relativeGlobPath := componentIn 43 | 44 | resolved, err := aia.resolver.resolveLocalGlobPath( 45 | path.Clean(fmt.Sprintf("%s/%s", 46 | yamlDocument.WorkingDirectory().Value, 47 | string(relativeGlobPath), 48 | )), 49 | ) 50 | if err != nil { 51 | return nil, fmt.Errorf("failed to resolve component path '%s'", relativeGlobPath) 52 | } 53 | 54 | list = append(list, resolved...) 55 | } 56 | } 57 | 58 | return list, nil 59 | } 60 | -------------------------------------------------------------------------------- /internal/services/spec/decoder/json_scheme_test.go: -------------------------------------------------------------------------------- 1 | package decoder 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func Test_jsonSchemeTransformJsonPathToYamlPath(t *testing.T) { 8 | type args struct { 9 | path string 10 | } 11 | tests := []struct { 12 | name string 13 | args args 14 | want string 15 | }{ 16 | { 17 | name: "s1", 18 | args: args{ 19 | path: "(root)", 20 | }, 21 | want: "$", 22 | }, 23 | { 24 | name: "s2", 25 | args: args{ 26 | path: "(root).simple", 27 | }, 28 | want: "$.simple", 29 | }, 30 | { 31 | name: "s3", 32 | args: args{ 33 | path: "(root).tree.path", 34 | }, 35 | want: "$.tree.path", 36 | }, 37 | { 38 | name: "with index", 39 | args: args{ 40 | path: "(root).tree.path.5", 41 | }, 42 | want: "$.tree.path[5]", 43 | }, 44 | { 45 | name: "with index 2", 46 | args: args{ 47 | path: "(root).tree.path.100", 48 | }, 49 | want: "$.tree.path[100]", 50 | }, 51 | { 52 | name: "with index tree", 53 | args: args{ 54 | path: "(root).tree.path.100.anotherList.5.anotherItem", 55 | }, 56 | want: "$.tree.path[100].anotherList[5].anotherItem", 57 | }, 58 | { 59 | name: "inv 1", 60 | args: args{ 61 | path: "(root).123-hello.b", 62 | }, 63 | want: "$.123-hello.b", 64 | }, 65 | { 66 | name: "inv 1", 67 | args: args{ 68 | path: "(root).3rd-hello.13.13b", 69 | }, 70 | want: "$.3rd-hello[13].13b", 71 | }, 72 | } 73 | for _, tt := range tests { 74 | t.Run(tt.name, func(t *testing.T) { 75 | if got := jsonSchemeTransformJSONPathToYamlPath(tt.args.path); got != tt.want { 76 | t.Errorf("jsonSchemeTransformJsonPathToYamlPath() = %v, want %v", got, tt.want) 77 | } 78 | }) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /internal/app/internal/container/container_cmd_check.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/fe3dback/go-arch-lint/internal/models" 9 | "github.com/fe3dback/go-arch-lint/internal/operations/check" 10 | ) 11 | 12 | func (c *Container) commandCheck() (*cobra.Command, runner) { 13 | cmd := &cobra.Command{ 14 | Use: "check", 15 | Aliases: []string{"c"}, 16 | Short: "check project architecture by yaml file", 17 | Long: "compare project *.go files with arch defined in spec file", 18 | } 19 | 20 | in := models.CmdCheckIn{ 21 | ProjectPath: models.DefaultProjectPath, 22 | ArchFile: models.DefaultArchFileName, 23 | MaxWarnings: 100, 24 | } 25 | 26 | cmd.PersistentFlags().StringVar(&in.ProjectPath, "project-path", in.ProjectPath, "absolute path to project directory") 27 | cmd.PersistentFlags().StringVar(&in.ArchFile, "arch-file", in.ArchFile, "arch file path") 28 | cmd.PersistentFlags().IntVar(&in.MaxWarnings, "max-warnings", in.MaxWarnings, "max number of warnings to output") 29 | 30 | return cmd, func(act *cobra.Command) (any, error) { 31 | const warningsRangeMin = 1 32 | const warningsRangeMax = 32768 33 | 34 | if in.MaxWarnings < warningsRangeMin || in.MaxWarnings > warningsRangeMax { 35 | return nil, fmt.Errorf( 36 | "flag '%s' should by in range [%d .. %d]", 37 | "max-warnings", 38 | warningsRangeMin, 39 | warningsRangeMax, 40 | ) 41 | } 42 | 43 | return c.commandCheckOperation().Behave(act.Context(), in) 44 | } 45 | } 46 | 47 | func (c *Container) commandCheckOperation() *check.Operation { 48 | return check.NewOperation( 49 | c.provideProjectInfoAssembler(), 50 | c.provideSpecAssembler(), 51 | c.provideSpecChecker(), 52 | c.provideReferenceRender(), 53 | c.flags.UseColors, 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /internal/services/spec/validator/utils.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 9 | ) 10 | 11 | type ( 12 | utils struct { 13 | pathResolver pathResolver 14 | document spec.Document 15 | projectDir string 16 | } 17 | ) 18 | 19 | func newUtils( 20 | pathResolver pathResolver, 21 | document spec.Document, 22 | projectDir string, 23 | ) *utils { 24 | return &utils{ 25 | projectDir: projectDir, 26 | pathResolver: pathResolver, 27 | document: document, 28 | } 29 | } 30 | 31 | func (u *utils) assertGlobPathValid(localGlobPath string) error { 32 | absPath := filepath.Join(u.projectDir, localGlobPath) 33 | resolved, err := u.pathResolver.Resolve(absPath) 34 | if err != nil { 35 | return fmt.Errorf("failed to resolv path: %w", err) 36 | } 37 | 38 | if len(resolved) == 0 && !u.document.Options().IgnoreNotFoundComponents().Value { 39 | return fmt.Errorf("not found directories for '%s' in '%s'", localGlobPath, absPath) 40 | } 41 | 42 | return u.assertDirectoriesValid(resolved...) 43 | } 44 | 45 | func (u *utils) assertDirectoriesValid(paths ...string) error { 46 | for _, path := range paths { 47 | if _, err := os.Stat(path); os.IsNotExist(err) { 48 | return fmt.Errorf("directory '%s' not exist", path) 49 | } 50 | } 51 | 52 | return nil 53 | } 54 | 55 | func (u *utils) assertKnownComponent(name string) error { 56 | for knownName := range u.document.Components() { 57 | if name == knownName { 58 | return nil 59 | } 60 | } 61 | 62 | return fmt.Errorf("unknown component '%s'", name) 63 | } 64 | 65 | func (u *utils) assertKnownVendor(name string) error { 66 | for knownName := range u.document.Vendors() { 67 | if name == knownName { 68 | return nil 69 | } 70 | } 71 | 72 | return fmt.Errorf("unknown vendor '%s'", name) 73 | } 74 | -------------------------------------------------------------------------------- /internal/services/spec/validator/validator_deps.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 7 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 8 | ) 9 | 10 | type validatorDeps struct { 11 | utils *utils 12 | } 13 | 14 | func newValidatorDeps( 15 | utils *utils, 16 | ) *validatorDeps { 17 | return &validatorDeps{ 18 | utils: utils, 19 | } 20 | } 21 | 22 | func (v *validatorDeps) Validate(doc spec.Document) []arch.Notice { 23 | notices := make([]arch.Notice, 0) 24 | 25 | for name, rule := range doc.Dependencies() { 26 | if err := v.utils.assertKnownComponent(name); err != nil { 27 | notices = append(notices, arch.Notice{ 28 | Notice: err, 29 | Ref: rule.Reference, 30 | }) 31 | } 32 | 33 | if len(rule.Value.MayDependOn()) > 0 && rule.Value.AnyProjectDeps().Value { 34 | notices = append(notices, arch.Notice{ 35 | Notice: fmt.Errorf("'anyProjectDeps=true' used with not empty 'MayDependOn' list (likely this is miss configuration)"), 36 | Ref: rule.Value.AnyProjectDeps().Reference, 37 | }) 38 | } 39 | 40 | if len(rule.Value.CanUse()) > 0 && rule.Value.AnyVendorDeps().Value { 41 | notices = append(notices, arch.Notice{ 42 | Notice: fmt.Errorf("'AnyVendorDeps=true' used with not empty 'CanUse' list (likely this is miss configuration)"), 43 | Ref: rule.Value.AnyVendorDeps().Reference, 44 | }) 45 | } 46 | 47 | if len(rule.Value.MayDependOn()) == 0 && len(rule.Value.CanUse()) == 0 { 48 | if rule.Value.AnyProjectDeps().Value { 49 | continue 50 | } 51 | 52 | if rule.Value.AnyVendorDeps().Value { 53 | continue 54 | } 55 | 56 | notices = append(notices, arch.Notice{ 57 | Notice: fmt.Errorf("should have ref in 'mayDependOn'/'canUse' or at least one flag of ['anyProjectDeps', 'anyVendorDeps']"), 58 | Ref: rule.Reference, 59 | }) 60 | } 61 | } 62 | 63 | return notices 64 | } 65 | -------------------------------------------------------------------------------- /internal/app/internal/container/container_cmd_mapping.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/fe3dback/go-arch-lint/internal/models" 10 | "github.com/fe3dback/go-arch-lint/internal/operations/mapping" 11 | ) 12 | 13 | func (c *Container) commandMapping() (*cobra.Command, runner) { 14 | cmd := &cobra.Command{ 15 | Use: "mapping", 16 | Aliases: []string{"ps", "ls"}, 17 | Short: "mapping table between files and components", 18 | Long: "display mapping table between project files and arch components", 19 | } 20 | 21 | in := models.CmdMappingIn{ 22 | ProjectPath: models.DefaultProjectPath, 23 | ArchFile: models.DefaultArchFileName, 24 | Scheme: models.MappingSchemeList, 25 | } 26 | 27 | cmd.PersistentFlags().StringVar(&in.ProjectPath, "project-path", in.ProjectPath, "absolute path to project directory") 28 | cmd.PersistentFlags().StringVar(&in.ArchFile, "arch-file", in.ArchFile, "arch file path") 29 | cmd.PersistentFlags().StringVarP(&in.Scheme, "scheme", "s", in.Scheme, fmt.Sprintf( 30 | "display scheme [%s]", 31 | strings.Join(models.MappingSchemesValues, ","), 32 | )) 33 | 34 | return cmd, func(act *cobra.Command) (any, error) { 35 | hasValidScheme := false 36 | for _, validScheme := range models.MappingSchemesValues { 37 | if in.Scheme == validScheme { 38 | hasValidScheme = true 39 | break 40 | } 41 | } 42 | 43 | if !hasValidScheme { 44 | return "", fmt.Errorf( 45 | "invalid scheme '%s', available: [%s]", 46 | in.Scheme, 47 | strings.Join(models.MappingSchemesValues, ", "), 48 | ) 49 | } 50 | 51 | return c.commandMappingOperation().Behave(act.Context(), in) 52 | } 53 | } 54 | 55 | func (c *Container) commandMappingOperation() *mapping.Operation { 56 | return mapping.NewOperation( 57 | c.provideSpecAssembler(), 58 | c.provideProjectFilesResolver(), 59 | c.provideProjectInfoAssembler(), 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /internal/services/common/path/glob.go: -------------------------------------------------------------------------------- 1 | package path 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | // COPYRIGHT: https://github.com/yargevad 10 | // SOURCE: https://github.com/yargevad/filepathx/blob/907099cb5a626c26c0b04e01adca2798e63d030e/filepathx.go 11 | 12 | // globs represents one filepath glob, with its elements joined by "**". 13 | type globs []string 14 | 15 | // Glob adds double-star support to the core path/filepath Glob function. 16 | // It's useful when your globs might have double-stars, but you're not sure. 17 | func glob(pattern string) ([]string, error) { 18 | if !strings.Contains(pattern, "**") { 19 | // passthru to core package if no double-star 20 | return filepath.Glob(pattern) 21 | } 22 | split := strings.Split(pattern, "**") 23 | for i, s := range split { 24 | split[i] = strings.TrimRight(s, "/") 25 | } 26 | 27 | return globs(split).expand() 28 | } 29 | 30 | // expand finds matches for the provided globs. 31 | func (globs globs) expand() ([]string, error) { 32 | matches := []string{""} // accumulate here 33 | for _, glob := range globs { 34 | var hits []string 35 | hitMap := map[string]bool{} 36 | for _, match := range matches { 37 | paths, err := filepath.Glob(match + glob) 38 | if err != nil { 39 | return nil, err 40 | } 41 | for _, path := range paths { 42 | err = filepath.Walk(path, func(path string, info os.FileInfo, err error) error { 43 | if err != nil { 44 | return err 45 | } 46 | // save deduped match from current iteration 47 | if _, ok := hitMap[path]; !ok { 48 | hits = append(hits, path) 49 | hitMap[path] = true 50 | } 51 | return nil 52 | }) 53 | if err != nil { 54 | return nil, err 55 | } 56 | } 57 | } 58 | matches = hits 59 | } 60 | 61 | // fix up return value for nil input 62 | if globs == nil && len(matches) > 0 && matches[0] == "" { 63 | matches = matches[1:] 64 | } 65 | 66 | return matches, nil 67 | } 68 | -------------------------------------------------------------------------------- /internal/operations/version/operation.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "runtime/debug" 6 | 7 | "github.com/fe3dback/go-arch-lint/internal/models" 8 | ) 9 | 10 | type Operation struct { 11 | version string 12 | buildTime string 13 | commitHash string 14 | } 15 | 16 | func NewOperation( 17 | version string, 18 | buildTime string, 19 | commitHash string, 20 | ) *Operation { 21 | return &Operation{ 22 | version: version, 23 | buildTime: buildTime, 24 | commitHash: commitHash, 25 | } 26 | } 27 | 28 | func (o *Operation) Behave() (models.CmdVersionOut, error) { 29 | if o.version == models.UnknownVersion { 30 | if data, err := o.fromCompiledMeta(); err == nil { 31 | return data, nil 32 | } 33 | } 34 | 35 | return o.fromLdFlags() 36 | } 37 | 38 | func (o *Operation) fromCompiledMeta() (models.CmdVersionOut, error) { 39 | bi, ok := debug.ReadBuildInfo() 40 | if !ok { 41 | return models.CmdVersionOut{}, fmt.Errorf("nothing to read") 42 | } 43 | 44 | if bi.Main.Version == "" { 45 | return models.CmdVersionOut{}, fmt.Errorf("nothing to read") 46 | } 47 | 48 | vcsHash := "unknown" 49 | vcsTime := "unknown" 50 | 51 | for _, setting := range bi.Settings { 52 | if setting.Key == "vcs.revision" { 53 | vcsHash = setting.Value 54 | continue 55 | } 56 | 57 | if setting.Key == "vcs.time" { 58 | vcsTime = setting.Value 59 | continue 60 | } 61 | } 62 | 63 | return models.CmdVersionOut{ 64 | GoArchFileSupported: o.supportedSchemas(), 65 | LinterVersion: bi.Main.Version, 66 | BuildTime: vcsTime, 67 | CommitHash: vcsHash, 68 | }, nil 69 | } 70 | 71 | func (o *Operation) fromLdFlags() (models.CmdVersionOut, error) { 72 | return models.CmdVersionOut{ 73 | GoArchFileSupported: o.supportedSchemas(), 74 | LinterVersion: o.version, 75 | BuildTime: o.buildTime, 76 | CommitHash: o.commitHash, 77 | }, nil 78 | } 79 | 80 | func (o *Operation) supportedSchemas() string { 81 | return fmt.Sprintf("%d .. %d", models.SupportedVersionMin, models.SupportedVersionMax) 82 | } 83 | -------------------------------------------------------------------------------- /internal/app/internal/container/container_cmd_graph.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/fe3dback/go-arch-lint/internal/models" 10 | "github.com/fe3dback/go-arch-lint/internal/operations/graph" 11 | ) 12 | 13 | func (c *Container) commandGraph() (*cobra.Command, runner) { 14 | cmd := &cobra.Command{ 15 | Use: "graph", 16 | Aliases: []string{"g"}, 17 | Short: "output dependencies graph as svg file", 18 | Long: "display mapping table between project files and arch components", 19 | } 20 | 21 | in := models.CmdGraphIn{ 22 | ProjectPath: models.DefaultProjectPath, 23 | ArchFile: models.DefaultArchFileName, 24 | Type: models.GraphTypeFlow, 25 | OutFile: "./go-arch-lint-graph.svg", 26 | Focus: "", 27 | IncludeVendors: false, 28 | ExportD2: false, 29 | } 30 | 31 | cmd.PersistentFlags().StringVar(&in.ProjectPath, "project-path", in.ProjectPath, "absolute path to project directory") 32 | cmd.PersistentFlags().StringVar(&in.ArchFile, "arch-file", in.ArchFile, "arch file path") 33 | cmd.PersistentFlags().StringVarP(&in.Type, "type", "t", in.Type, fmt.Sprintf("render graph type [%s]", strings.Join(models.GraphTypesValues, ","))) 34 | cmd.PersistentFlags().StringVar(&in.OutFile, "out", in.OutFile, "svg graph output file") 35 | cmd.PersistentFlags().StringVar(&in.Focus, "focus", in.Focus, "render only specified component (should match component name exactly)") 36 | cmd.PersistentFlags().BoolVarP(&in.IncludeVendors, "include-vendors", "r", in.IncludeVendors, "include vendor dependencies (from \"canUse\" block)?") 37 | cmd.PersistentFlags().BoolVar(&in.ExportD2, "d2", in.ExportD2, "output raw d2 definitions to stdout (from which svg is generated)") 38 | 39 | return cmd, func(act *cobra.Command) (any, error) { 40 | in.OutputType = c.flags.OutputType 41 | 42 | return c.commandGraphOperation().Behave(act.Context(), in) 43 | } 44 | } 45 | 46 | func (c *Container) commandGraphOperation() *graph.Operation { 47 | return graph.NewOperation( 48 | c.provideSpecAssembler(), 49 | c.provideProjectInfoAssembler(), 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /internal/view/view_check.gohtml: -------------------------------------------------------------------------------- 1 | {{- /*gotype: github.com/fe3dback/go-arch-lint/internal/models.CmdCheckOut*/ -}} 2 | 3 | module: {{.ModuleName | colorize "green"}} 4 | linters: 5 | {{ range .Qualities }} 6 | {{- if .Used -}} 7 | {{" On" | colorize "green" -}} 8 | {{- else -}} 9 | {{" Off" | colorize "yellow" -}} 10 | {{- end -}} 11 | {{" | " | colorize "gray" -}} {{ .Name -}} 12 | {{ concat " # " .Hint | colorize "gray" }} 13 | {{ end }} 14 | 15 | {{ range .DocumentNotices -}} 16 | {{.Text}} 17 | {{ if .SourceCodePreview -}} 18 | {{ .SourceCodePreview | printf "%s" -}} 19 | {{ end -}} 20 | {{ else -}} 21 | {{ if .ArchHasWarnings -}} 22 | {{ $warnCount := (plus (plus (len .ArchWarningsDependency) (len .ArchWarningsMatch)) (len .ArchWarningsDeepScan) ) -}} 23 | {{ range .ArchWarningsDependency -}} 24 | Component {{.ComponentName | colorize "magenta"}} shouldn't depend on {{ .ResolvedImportName | colorize "blue"}} in {{ .Reference | colorize "gray"}} 25 | {{ end -}} 26 | {{ range .ArchWarningsMatch -}} 27 | File {{.FileRelativePath | colorize "cyan"}} not attached to any component in archfile 28 | {{ end }} 29 | {{ range .ArchWarningsDeepScan }} 30 | Dependency {{.Dependency.ComponentName | colorize "magenta"}} -\-> {{.Gate.ComponentName | colorize "magenta"}} not allowed 31 | ├─ {{.Dependency.ComponentName | colorize "magenta"}} {{.Dependency.Name | colorize "blue"}} in {{ .Target.RelativePath | colorize "gray" }} 32 | └─ {{.Gate.ComponentName | colorize "magenta"}} {{.Gate.MethodName | colorize "blue"}} in {{ .Gate.RelativePath | colorize "gray" }} 33 | {{ " " }} 34 | {{ concat " " .Dependency.Injection.File ":" .Dependency.Injection.Line | colorize "gray" }} 35 | {{ if .Dependency.SourceCodePreview -}} 36 | {{ .Dependency.SourceCodePreview | printf "%s" | linePrefix " " -}} 37 | {{ end }} 38 | {{ end }} 39 | 40 | -- 41 | total notices: {{ plus $warnCount .OmittedCount | printf "%d" | colorize "yellow" }} 42 | {{ if gt .OmittedCount 0 -}} 43 | omitted: {{.OmittedCount | printf "%d" | colorize "yellow" }} (too big to display) 44 | {{ end }} 45 | {{ else -}} 46 | {{"OK - No warnings found" | colorize "green" -}} 47 | {{ end -}} 48 | {{ end -}} -------------------------------------------------------------------------------- /internal/services/checker/deepscan/models.go: -------------------------------------------------------------------------------- 1 | package deepscan 2 | 3 | import ( 4 | "github.com/fe3dback/go-arch-lint/internal/models" 5 | "github.com/fe3dback/go-arch-lint/internal/models/common" 6 | ) 7 | 8 | type ( 9 | InjectionMethod struct { 10 | Name string // method name (example: `NewProcessor`) 11 | Definition Source // where method is defined 12 | Gates []Gate // method params with interface type 13 | } 14 | 15 | Gate struct { 16 | MethodName string // function name (func Hello(a,b int), name="Hello") 17 | ParamName string // function param name (func (_a_,b int), name="a") 18 | Index int // function param index (func (a,b bool, c int), for c index=2) 19 | MethodDefinition Source // where method is defined 20 | ArgumentDefinition Source // where method param type defined (func (a,b,c _int_)) 21 | Interface Interface // used interface for injection 22 | Implementations []Implementation // all code links to this param 23 | IsVariadic bool // function param is variadic (func (a bool, nums ...int)) 24 | } 25 | 26 | Interface struct { 27 | Name string // interface name 28 | Definition Source // where interface defined 29 | GoType string // interface go type 30 | } 31 | 32 | Implementation struct { 33 | Injector Injector // who inject Target to Gate.FunctionName 34 | Target Target // what is injected into Gate.FunctionName 35 | } 36 | 37 | Injector struct { 38 | CodeName string // code expression (not unique) 39 | MethodDefinition Source // where method is called 40 | ParamDefinition Source // where param is passed to method (injection occurs) 41 | } 42 | 43 | Target struct { 44 | StructName string // interface implementation type name 45 | Definition Source // where this type defined 46 | } 47 | 48 | Source struct { 49 | Pkg string // package name (example: "a") 50 | Import models.ResolvedImport // package full import path with type 51 | Path string // package full abs path (example: "/home/user/go/src/myProject/internal/a") 52 | Place common.Reference // exactly place in source code 53 | } 54 | ) 55 | -------------------------------------------------------------------------------- /internal/services/spec/assembler/assembler.go: -------------------------------------------------------------------------------- 1 | package assembler 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 7 | "github.com/fe3dback/go-arch-lint/internal/models/common" 8 | ) 9 | 10 | type ( 11 | Assembler struct { 12 | decoder archDecoder 13 | validator archValidator 14 | pathResolver pathResolver 15 | } 16 | ) 17 | 18 | func NewAssembler( 19 | decoder archDecoder, 20 | validator archValidator, 21 | pathResolver pathResolver, 22 | ) *Assembler { 23 | return &Assembler{ 24 | decoder: decoder, 25 | validator: validator, 26 | pathResolver: pathResolver, 27 | } 28 | } 29 | 30 | func (sa *Assembler) Assemble(prj common.Project) (arch.Spec, error) { 31 | spec := arch.Spec{ 32 | RootDirectory: common.NewEmptyReferable(prj.Directory), 33 | ModuleName: common.NewEmptyReferable(prj.ModuleName), 34 | Integrity: arch.Integrity{ 35 | DocumentNotices: []arch.Notice{}, 36 | Suggestions: []arch.Notice{}, 37 | }, 38 | } 39 | 40 | document, schemeNotices, err := sa.decoder.Decode(prj.GoArchFilePath) 41 | if err != nil { 42 | return spec, fmt.Errorf("failed to decode document '%s': %w", prj.GoArchFilePath, err) 43 | } 44 | 45 | if len(schemeNotices) > 0 { 46 | // only simple scheme validation errors 47 | spec.Integrity.DocumentNotices = append(spec.Integrity.DocumentNotices, schemeNotices...) 48 | } else { 49 | // if scheme is ok, need check arch errors 50 | advancedErrors := sa.validator.Validate(document, prj.Directory) 51 | spec.Integrity.DocumentNotices = append(spec.Integrity.DocumentNotices, advancedErrors...) 52 | } 53 | 54 | if document == nil { 55 | return spec, nil 56 | } 57 | 58 | resolver := newResolver( 59 | sa.pathResolver, 60 | prj.Directory, 61 | prj.ModuleName, 62 | ) 63 | 64 | assembler := newSpecCompositeAssembler([]assembler{ 65 | newComponentsAssembler( 66 | resolver, 67 | newAllowedProjectImportsAssembler( 68 | resolver, 69 | ), 70 | newAllowedVendorImportsAssembler(), 71 | ), 72 | newExcludeAssembler(resolver), 73 | newExcludeFilesMatcherAssembler(), 74 | newAllowAssembler(), 75 | newWorkdirAssembler(), 76 | }) 77 | 78 | err = assembler.assemble(&spec, document) 79 | if err != nil { 80 | return spec, fmt.Errorf("failed to assemble document: %w", err) 81 | } 82 | 83 | return spec, nil 84 | } 85 | -------------------------------------------------------------------------------- /internal/operations/selfInspect/operation.go: -------------------------------------------------------------------------------- 1 | package selfInspect 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fe3dback/go-arch-lint/internal/models" 7 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 8 | ) 9 | 10 | type Operation struct { 11 | specAssembler specAssembler 12 | projectInfoAssembler projectInfoAssembler 13 | version string 14 | } 15 | 16 | func NewOperation( 17 | specAssembler specAssembler, 18 | projectInfoAssembler projectInfoAssembler, 19 | version string, 20 | ) *Operation { 21 | return &Operation{ 22 | specAssembler: specAssembler, 23 | projectInfoAssembler: projectInfoAssembler, 24 | version: version, 25 | } 26 | } 27 | 28 | func (o *Operation) Behave(in models.CmdSelfInspectIn) (models.CmdSelfInspectOut, error) { 29 | projectInfo, err := o.projectInfoAssembler.ProjectInfo( 30 | in.ProjectPath, 31 | in.ArchFile, 32 | ) 33 | if err != nil { 34 | return models.CmdSelfInspectOut{}, fmt.Errorf("failed to assemble project info: %w", err) 35 | } 36 | 37 | spec, err := o.specAssembler.Assemble(projectInfo) 38 | if err != nil { 39 | return models.CmdSelfInspectOut{}, fmt.Errorf("failed assemble spec: %w", err) 40 | } 41 | 42 | return models.CmdSelfInspectOut{ 43 | ModuleName: projectInfo.ModuleName, 44 | RootDirectory: projectInfo.Directory, 45 | LinterVersion: o.version, 46 | Notices: o.extractNotices(&spec), 47 | Suggestions: o.extractSuggestions(&spec), 48 | }, nil 49 | } 50 | 51 | func (o *Operation) extractNotices(spec *arch.Spec) []models.CmdSelfInspectOutAnnotation { 52 | return o.asAnnotations(spec.Integrity.DocumentNotices) 53 | } 54 | 55 | func (o *Operation) extractSuggestions(spec *arch.Spec) []models.CmdSelfInspectOutAnnotation { 56 | return o.asAnnotations(spec.Integrity.Suggestions) 57 | } 58 | 59 | func (o *Operation) asAnnotations(list []arch.Notice) []models.CmdSelfInspectOutAnnotation { 60 | annotations := make([]models.CmdSelfInspectOutAnnotation, 0, len(list)) 61 | 62 | for _, notice := range list { 63 | annotations = append(annotations, o.asAnnotation(notice)) 64 | } 65 | 66 | return annotations 67 | } 68 | 69 | func (o *Operation) asAnnotation(notice arch.Notice) models.CmdSelfInspectOutAnnotation { 70 | return models.CmdSelfInspectOutAnnotation{ 71 | Text: notice.Notice.Error(), 72 | Reference: notice.Ref, 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /internal/services/render/code/render.go: -------------------------------------------------------------------------------- 1 | package code 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "math" 8 | "strings" 9 | 10 | "github.com/fe3dback/go-arch-lint/internal/models/common" 11 | ) 12 | 13 | type Render struct { 14 | printer colorPrinter 15 | } 16 | 17 | type annotateOpts struct { 18 | code []byte 19 | ref common.Reference 20 | showColumnPointer bool 21 | } 22 | 23 | func NewRender(printer colorPrinter) *Render { 24 | return &Render{ 25 | printer: printer, 26 | } 27 | } 28 | 29 | func (r *Render) SourceCode(ref common.Reference, highlight bool, showPointer bool) []byte { 30 | opts := r.fetch(ref, highlight) 31 | opts.showColumnPointer = showPointer 32 | 33 | return r.annotate(opts) 34 | } 35 | 36 | func (r *Render) fetch(ref common.Reference, highlight bool) annotateOpts { 37 | if !ref.Valid { 38 | return annotateOpts{} 39 | } 40 | 41 | file, linesCount := readFile(ref.File) 42 | if file == nil { 43 | return annotateOpts{} 44 | } 45 | 46 | ref = ref.ClampWithRealLinesCount(linesCount) 47 | content := readLines(file, ref) 48 | 49 | if highlight { 50 | content = highlightContent(ref.File, content) 51 | } 52 | 53 | return annotateOpts{code: content, ref: ref} 54 | } 55 | 56 | func (r *Render) annotate(opt annotateOpts) []byte { 57 | buf := bytes.NewBuffer(opt.code) 58 | sc := bufio.NewScanner(buf) 59 | currentLine := opt.ref.LineFrom 60 | 61 | var resultBuffer bytes.Buffer 62 | for sc.Scan() { 63 | prefixLine := r.printer.Gray(fmt.Sprintf("%4d |", currentLine)) 64 | prefixEmpty := r.printer.Gray(" ") 65 | 66 | // add line pointer 67 | if currentLine == opt.ref.Line { 68 | prefixLine = fmt.Sprintf("> %s", prefixLine) 69 | } else { 70 | prefixLine = fmt.Sprintf(" %s", prefixLine) 71 | } 72 | 73 | // draw line 74 | resultBuffer.WriteString(fmt.Sprintf("%s %s\n", 75 | prefixLine, 76 | r.replaceTabsToSpaces(sc.Bytes()), 77 | )) 78 | 79 | // add offset pointer 80 | if opt.showColumnPointer { 81 | if currentLine == opt.ref.Line { 82 | spaces := strings.Repeat(" ", int(math.Max(0, float64(opt.ref.Column-1)))) 83 | resultBuffer.WriteString(fmt.Sprintf("%s %s^\n", prefixEmpty, spaces)) 84 | } 85 | } 86 | 87 | currentLine++ 88 | } 89 | 90 | return resultBuffer.Bytes() 91 | } 92 | 93 | func (r *Render) replaceTabsToSpaces(src []byte) []byte { 94 | return []byte(strings.ReplaceAll(string(src), "\t", " ")) 95 | } 96 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/fe3dback/go-arch-lint 2 | 3 | go 1.25.0 4 | 5 | require ( 6 | github.com/alecthomas/chroma v0.10.0 7 | github.com/fe3dback/go-yaml v1.14.0 8 | github.com/google/go-cmdtest v0.2.0 9 | github.com/logrusorgru/aurora/v3 v3.0.0 10 | github.com/spf13/cobra v1.7.0 11 | github.com/stretchr/testify v1.8.4 12 | github.com/xeipuuv/gojsonschema v1.2.0 13 | golang.org/x/mod v0.28.0 14 | golang.org/x/sync v0.17.0 15 | golang.org/x/tools v0.37.0 16 | oss.terrastruct.com/d2 v0.5.1 17 | ) 18 | 19 | require ( 20 | cdr.dev/slog v1.4.2-0.20221206192828-e4803b10ae17 // indirect 21 | github.com/PuerkitoBio/goquery v1.8.0 // indirect 22 | github.com/alecthomas/chroma/v2 v2.5.0 // indirect 23 | github.com/andybalholm/cascadia v1.3.1 // indirect 24 | github.com/davecgh/go-spew v1.1.1 // indirect 25 | github.com/dlclark/regexp2 v1.8.1 // indirect 26 | github.com/dop251/goja v0.0.0-20230122112309-96b1610dd4f7 // indirect 27 | github.com/fatih/color v1.13.0 // indirect 28 | github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect 29 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect 30 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 31 | github.com/google/go-cmp v0.6.0 // indirect 32 | github.com/google/renameio v0.1.0 // indirect 33 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 34 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 35 | github.com/mattn/go-colorable v0.1.9 // indirect 36 | github.com/mattn/go-isatty v0.0.14 // indirect 37 | github.com/mazznoer/csscolorparser v0.1.3 // indirect 38 | github.com/pmezard/go-difflib v1.0.0 // indirect 39 | github.com/rivo/uniseg v0.4.3 // indirect 40 | github.com/spf13/pflag v1.0.5 // indirect 41 | github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect 42 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 43 | github.com/yuin/goldmark v1.5.3 // indirect 44 | go.opencensus.io v0.24.0 // indirect 45 | golang.org/x/crypto v0.42.0 // indirect 46 | golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9 // indirect 47 | golang.org/x/image v0.3.0 // indirect 48 | golang.org/x/net v0.44.0 // indirect 49 | golang.org/x/sys v0.36.0 // indirect 50 | golang.org/x/term v0.35.0 // indirect 51 | golang.org/x/text v0.29.0 // indirect 52 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect 53 | gonum.org/v1/plot v0.12.0 // indirect 54 | gopkg.in/yaml.v3 v3.0.1 // indirect 55 | oss.terrastruct.com/util-go v0.0.0-20230604222829-11c3c60fec14 // indirect 56 | ) 57 | -------------------------------------------------------------------------------- /internal/services/project/info/assembler.go: -------------------------------------------------------------------------------- 1 | package info 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "golang.org/x/mod/modfile" 9 | 10 | "github.com/fe3dback/go-arch-lint/internal/models" 11 | "github.com/fe3dback/go-arch-lint/internal/models/common" 12 | ) 13 | 14 | type Assembler struct{} 15 | 16 | func NewAssembler() *Assembler { 17 | return &Assembler{} 18 | } 19 | 20 | func (a *Assembler) ProjectInfo(rootDirectory string, archFilePath string) (common.Project, error) { 21 | projectPath, err := filepath.Abs(rootDirectory) 22 | if err != nil { 23 | return common.Project{}, fmt.Errorf("failed to resolve abs path '%s'", rootDirectory) 24 | } 25 | 26 | // check arch file 27 | goArchFilePath, err := resolveArchPath(projectPath, archFilePath) 28 | if err != nil { 29 | return common.Project{}, err 30 | } 31 | 32 | // check go.mod 33 | goModFilePath := filepath.Clean(fmt.Sprintf("%s/%s", projectPath, models.DefaultGoModFileName)) 34 | _, err = os.Stat(goModFilePath) 35 | if os.IsNotExist(err) { 36 | return common.Project{}, fmt.Errorf("not found project '%s' in '%s'", 37 | models.DefaultGoModFileName, 38 | goModFilePath, 39 | ) 40 | } 41 | 42 | // parse go.mod 43 | moduleName, err := checkCmdExtractModuleName(goModFilePath) 44 | if err != nil { 45 | return common.Project{}, fmt.Errorf("failed get module name: %w", err) 46 | } 47 | 48 | return common.Project{ 49 | Directory: projectPath, 50 | GoArchFilePath: goArchFilePath, 51 | GoModFilePath: goModFilePath, 52 | ModuleName: moduleName, 53 | }, nil 54 | } 55 | 56 | func checkCmdExtractModuleName(goModPath string) (string, error) { 57 | goModFile, err := checkCmdParseGoModFile(goModPath) 58 | if err != nil { 59 | return "", fmt.Errorf("can`t parse gomod: %w", err) 60 | } 61 | 62 | moduleName := goModFile.Module.Mod.Path 63 | if moduleName == "" { 64 | return "", fmt.Errorf("%s should contain module name in 'module'", models.DefaultGoModFileName) 65 | } 66 | 67 | return moduleName, nil 68 | } 69 | 70 | func checkCmdParseGoModFile(path string) (*modfile.File, error) { 71 | file, err := os.ReadFile(path) 72 | if err != nil { 73 | return nil, fmt.Errorf("failed to read '%s': %w", path, err) 74 | } 75 | 76 | mod, err := modfile.ParseLax(path, file, nil) 77 | if err != nil { 78 | return nil, fmt.Errorf("modfile parseLax failed '%s': %w", path, err) 79 | } 80 | 81 | return mod, nil 82 | } 83 | 84 | func resolveArchPath(projectPath, archFilePath string) (string, error) { 85 | if filepath.IsAbs(archFilePath) { 86 | return checkArchFile(archFilePath) 87 | } 88 | 89 | return checkArchFile(filepath.Join(projectPath, archFilePath)) 90 | } 91 | 92 | func checkArchFile(archFilePath string) (string, error) { 93 | _, err := os.Stat(archFilePath) 94 | if os.IsNotExist(err) { 95 | return "", fmt.Errorf("not found archfile in '%s'", archFilePath) 96 | } 97 | 98 | return archFilePath, nil 99 | } 100 | -------------------------------------------------------------------------------- /internal/models/common/reference.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | ) 7 | 8 | type ( 9 | Reference struct { 10 | Valid bool `json:"Valid"` 11 | File string `json:"File"` 12 | Line int `json:"Line"` 13 | LineFrom int `json:"-"` 14 | LineTo int `json:"-"` 15 | Column int `json:"Offset"` 16 | } 17 | ) 18 | 19 | func NewReferenceSingleLine(file string, line int, column int) Reference { 20 | return Reference{Valid: true}.guaranteeValidState(func(r *Reference) { 21 | r.Valid = true 22 | r.File = file 23 | r.Line = line 24 | r.LineFrom = line 25 | r.LineTo = line 26 | r.Column = column 27 | }) 28 | } 29 | 30 | func NewReferenceRange(file string, lineFrom, lineMain, lineTo int) Reference { 31 | return Reference{Valid: true}.guaranteeValidState(func(r *Reference) { 32 | r.Valid = true 33 | r.File = file 34 | r.Line = lineMain 35 | r.LineFrom = lineFrom 36 | r.LineTo = lineTo 37 | r.Column = 0 38 | }) 39 | } 40 | 41 | func NewEmptyReference() Reference { 42 | return Reference{Valid: false} 43 | } 44 | 45 | func (r Reference) String() string { 46 | if !r.Valid { 47 | return "" 48 | } 49 | 50 | return fmt.Sprintf("%s:%d", r.File, r.Line) 51 | } 52 | 53 | // ExtendRange will extend from-to range in both ways on growLinesCount lines 54 | // for example initialRange=[2..5], after ExtendRange(1) it will be [1..6] 55 | func (r Reference) ExtendRange(lower, upper int) Reference { 56 | return r.guaranteeValidState(func(r *Reference) { 57 | r.LineFrom -= lower 58 | r.LineTo += upper 59 | }) 60 | } 61 | 62 | // ClampWithRealLinesCount allows to clamp lines to real file lines count (upper clamp) 63 | func (r Reference) ClampWithRealLinesCount(linesCount int) Reference { 64 | return r.guaranteeValidState(func(r *Reference) { 65 | r.LineFrom = clampInt(r.LineFrom, 1, linesCount) 66 | r.Line = clampInt(r.Line, 1, linesCount) 67 | r.LineTo = clampInt(r.LineTo, 1, linesCount) 68 | }) 69 | } 70 | 71 | func (r Reference) guaranteeValidState(mutate func(r *Reference)) Reference { 72 | if !r.Valid { 73 | return r 74 | } 75 | 76 | mutate(&r) 77 | 78 | if r.File == "" { 79 | r.Valid = false 80 | r.LineFrom = 0 81 | r.Line = 0 82 | r.LineTo = 0 83 | r.Column = 0 84 | return r 85 | } 86 | 87 | // check lines 88 | if r.LineFrom > r.LineTo { 89 | r.LineFrom, r.LineTo = r.LineTo, r.LineFrom 90 | } 91 | 92 | r.LineFrom = clampInt(r.LineFrom, 1, r.LineTo) 93 | r.LineTo = clampInt(r.LineTo, r.LineFrom, math.MaxInt32) 94 | r.Line = clampInt(r.Line, r.LineFrom, r.LineTo) 95 | r.Column = clampInt(r.Column, 0, math.MaxInt32) 96 | 97 | return r 98 | } 99 | 100 | func clampInt(num, a, b int) int { 101 | minimum, maximum := sortInt(a, b) 102 | 103 | if num < minimum { 104 | num = minimum 105 | } 106 | if num > maximum { 107 | num = maximum 108 | } 109 | 110 | return num 111 | } 112 | 113 | func sortInt(a, b int) (minimum, maximum int) { 114 | if a > b { 115 | return b, a 116 | } 117 | 118 | return a, b 119 | } 120 | -------------------------------------------------------------------------------- /internal/app/internal/container/cnt_glue.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "github.com/fe3dback/go-arch-lint/internal/services/checker" 5 | "github.com/fe3dback/go-arch-lint/internal/services/common/path" 6 | "github.com/fe3dback/go-arch-lint/internal/services/common/yaml/reference" 7 | "github.com/fe3dback/go-arch-lint/internal/services/project/holder" 8 | "github.com/fe3dback/go-arch-lint/internal/services/project/info" 9 | "github.com/fe3dback/go-arch-lint/internal/services/project/resolver" 10 | "github.com/fe3dback/go-arch-lint/internal/services/project/scanner" 11 | "github.com/fe3dback/go-arch-lint/internal/services/render/code" 12 | "github.com/fe3dback/go-arch-lint/internal/services/schema" 13 | specassembler "github.com/fe3dback/go-arch-lint/internal/services/spec/assembler" 14 | "github.com/fe3dback/go-arch-lint/internal/services/spec/decoder" 15 | specvalidator "github.com/fe3dback/go-arch-lint/internal/services/spec/validator" 16 | ) 17 | 18 | func (c *Container) provideSpecAssembler() *specassembler.Assembler { 19 | return specassembler.NewAssembler( 20 | c.provideYamlSpecProvider(), 21 | c.provideSpecValidator(), 22 | c.providePathResolver(), 23 | ) 24 | } 25 | 26 | func (c *Container) provideSpecValidator() *specvalidator.Validator { 27 | return specvalidator.NewValidator( 28 | c.providePathResolver(), 29 | ) 30 | } 31 | 32 | func (c *Container) provideYamlSpecProvider() *decoder.Decoder { 33 | return decoder.NewDecoder( 34 | c.provideSourceCodeReferenceResolver(), 35 | c.provideJsonSchemaProvider(), 36 | ) 37 | } 38 | 39 | func (c *Container) providePathResolver() *path.Resolver { 40 | return path.NewResolver() 41 | } 42 | 43 | func (c *Container) provideSourceCodeReferenceResolver() *reference.Resolver { 44 | return reference.NewResolver() 45 | } 46 | 47 | func (c *Container) provideReferenceRender() *code.Render { 48 | return code.NewRender( 49 | c.provideColorPrinter(), 50 | ) 51 | } 52 | 53 | func (c *Container) provideSpecChecker() *checker.CompositeChecker { 54 | return checker.NewCompositeChecker( 55 | c.provideSpecImportsChecker(), 56 | c.provideSpecDeepScanChecker(), 57 | ) 58 | } 59 | 60 | func (c *Container) provideSpecImportsChecker() *checker.Imports { 61 | return checker.NewImport( 62 | c.provideProjectFilesResolver(), 63 | ) 64 | } 65 | 66 | func (c *Container) provideSpecDeepScanChecker() *checker.DeepScan { 67 | return checker.NewDeepScan( 68 | c.provideProjectFilesResolver(), 69 | c.provideReferenceRender(), 70 | ) 71 | } 72 | 73 | func (c *Container) provideProjectFilesResolver() *resolver.Resolver { 74 | return resolver.NewResolver( 75 | c.provideProjectFilesScanner(), 76 | c.provideProjectFilesHolder(), 77 | ) 78 | } 79 | 80 | func (c *Container) provideProjectFilesScanner() *scanner.Scanner { 81 | return scanner.NewScanner() 82 | } 83 | 84 | func (c *Container) provideProjectFilesHolder() *holder.Holder { 85 | return holder.NewHolder() 86 | } 87 | 88 | func (c *Container) provideProjectInfoAssembler() *info.Assembler { 89 | return info.NewAssembler() 90 | } 91 | 92 | func (c *Container) provideJsonSchemaProvider() *schema.Provider { 93 | return schema.NewProvider() 94 | } 95 | -------------------------------------------------------------------------------- /internal/services/render/ascii_functions.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | func (r *Renderer) asciiColorize(color string, value interface{}) (string, error) { 11 | colorizer := newColorizer(r.colorPrinter) 12 | out, err := colorizer.colorize( 13 | color, 14 | fmt.Sprintf("%v", value), 15 | ) 16 | if err != nil { 17 | return "", fmt.Errorf("failed colorize: %w", err) 18 | } 19 | 20 | return out, nil 21 | } 22 | 23 | func (r *Renderer) asciiTrimPrefix(prefix string, value interface{}) string { 24 | return strings.TrimPrefix(fmt.Sprintf("%s", value), prefix) 25 | } 26 | 27 | func (r *Renderer) asciiTrimSuffix(suffix string, value interface{}) string { 28 | return strings.TrimSuffix(fmt.Sprintf("%s", value), suffix) 29 | } 30 | 31 | func (r *Renderer) asciiDefaultValue(def string, value interface{}) string { 32 | sValue := fmt.Sprintf("%s", value) 33 | 34 | if sValue == "" { 35 | return def 36 | } 37 | 38 | return sValue 39 | } 40 | 41 | func (r *Renderer) asciiPadLeft(overallLen int, padStr string, value interface{}) string { 42 | s := fmt.Sprintf("%v", value) 43 | 44 | padCountInt := 1 + ((overallLen - len(padStr)) / len(padStr)) 45 | retStr := strings.Repeat(padStr, padCountInt) + s 46 | return retStr[(len(retStr) - overallLen):] 47 | } 48 | 49 | func (r *Renderer) asciiPadRight(overallLen int, padStr string, value interface{}) string { 50 | s := fmt.Sprintf("%v", value) 51 | 52 | padCountInt := 1 + ((overallLen - len(padStr)) / len(padStr)) 53 | retStr := s + strings.Repeat(padStr, padCountInt) 54 | return retStr[:overallLen] 55 | } 56 | 57 | func (r *Renderer) asciiLinePrefix(prefix string, value interface{}) string { 58 | lines := fmt.Sprintf("%s", value) 59 | result := make([]string, 0) 60 | 61 | for _, line := range strings.Split(lines, "\n") { 62 | result = append(result, prefix+line) 63 | } 64 | 65 | return strings.Join(result, "\n") 66 | } 67 | 68 | func (r *Renderer) asciiPathDirectory(value interface{}) string { 69 | return path.Dir(fmt.Sprintf("%v", value)) 70 | } 71 | 72 | func (r *Renderer) asciiPlus(a, b interface{}) (int, error) { 73 | iA, err := strconv.Atoi(fmt.Sprintf("%d", a)) 74 | if err != nil { 75 | return 0, fmt.Errorf("component A of 'plus' is not int: %s", a) 76 | } 77 | 78 | iB, err := strconv.Atoi(fmt.Sprintf("%d", b)) 79 | if err != nil { 80 | return 0, fmt.Errorf("component B of 'plus' is not int: %s", b) 81 | } 82 | 83 | return iA + iB, nil 84 | } 85 | 86 | func (r *Renderer) asciiMinus(a, b interface{}) (int, error) { 87 | iA, err := strconv.Atoi(fmt.Sprintf("%d", a)) 88 | if err != nil { 89 | return 0, fmt.Errorf("component A of 'minus' is not int: %s", a) 90 | } 91 | 92 | iB, err := strconv.Atoi(fmt.Sprintf("%d", b)) 93 | if err != nil { 94 | return 0, fmt.Errorf("component B of 'minus' is not int: %s", b) 95 | } 96 | 97 | return iA + iB, nil 98 | } 99 | 100 | func (r *Renderer) asciiConcat(sources ...interface{}) string { 101 | result := "" 102 | 103 | for _, source := range sources { 104 | result += fmt.Sprintf("%v", source) 105 | } 106 | 107 | return result 108 | } 109 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # More info: https://golangci-lint.run/docs/configuration/file/ 2 | version: "2" 3 | 4 | run: 5 | tests: true 6 | go: "1.25" 7 | build-tags: 8 | - integration 9 | 10 | output: 11 | formats: 12 | text: 13 | print-issued-lines: true 14 | print-linter-name: true 15 | 16 | linters: 17 | # v2: вместо disable-all используем default: none 18 | default: none 19 | enable: 20 | # Базовые качество/стиль 21 | - unused # Неиспользуемые константы/переменные/функции/типы 22 | - staticcheck # Большой набор проверок (honnef.co/go/tools) 23 | - govet # Встроенный "go vet": подозрительные конструкции 24 | - revive # Гибкий стилевой линтер (на замену golint) 25 | - whitespace # Лишние пустые строки в начале/конце блоков и т.п. 26 | - asciicheck # ASCII-только: стиль идентификаторов/комментариев 27 | - misspell # Опечатки в комментариях/строках 28 | - dupword # Повторяющиеся слова 29 | - decorder # Порядок объявлений (const/type/var/func) 30 | - goconst # Повторяющиеся литералы → константы 31 | - tagalign # Выравнивание тегов структур 32 | 33 | # Ошибки и обработка ошибок 34 | - errcheck # Непроверенные ошибки 35 | - errorlint # Правильная работа с обёртками ошибок (Go 1.13+) 36 | - errname # Имена ошибок: Err* / *Error 37 | 38 | # Контекст/конкурентность/тесты 39 | - copyloopvar # Копирование переменной цикла (замыкания/го‑рутины) 40 | 41 | # Производственный код/практики 42 | - ineffassign # Присваивания без использования 43 | - wastedassign # "Бесполезные" присваивания 44 | - unconvert # Лишние преобразования типов 45 | - predeclared # Затенение предобъявленных идентификаторов 46 | - makezero # make с ненулевой длиной 47 | - gochecknoinits # Запрещает init(): больше предсказуемости и тестопригодности 48 | - gosec # Статический анализ на уязвимости (безопасность) 49 | 50 | settings: 51 | revive: 52 | rules: 53 | - name: var-naming 54 | disabled: true 55 | unused: 56 | field-writes-are-uses: false 57 | exclusions: 58 | generated: lax 59 | presets: 60 | - comments 61 | - common-false-positives 62 | - legacy 63 | - std-error-handling 64 | paths: 65 | - third_party$ 66 | - builtin$ 67 | - examples$ 68 | issues: 69 | max-issues-per-linter: 0 70 | max-same-issues: 0 71 | 72 | # В v2 форматтеры выделены отдельно и могут участвовать в `run` как проверки. 73 | formatters: 74 | enable: 75 | - gci 76 | - gofumpt 77 | settings: 78 | gci: 79 | sections: 80 | - standard 81 | - default 82 | - localmodule 83 | # Исключения директорий для форматтеров дублируем, как рекомендует миграция 84 | exclusions: 85 | generated: lax 86 | paths: 87 | - third_party$ 88 | - builtin$ 89 | - examples$ 90 | - bin 91 | - var 92 | - tmp 93 | -------------------------------------------------------------------------------- /internal/services/spec/decoder/json_scheme.go: -------------------------------------------------------------------------------- 1 | package decoder 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/fe3dback/go-yaml" 10 | "github.com/xeipuuv/gojsonschema" 11 | ) 12 | 13 | type ( 14 | jsonSchemeNotice struct { 15 | notice string 16 | yamlPath *string 17 | } 18 | ) 19 | 20 | func jsonSchemeValidate(jsonSchema []byte, sourceCode []byte) ([]jsonSchemeNotice, error) { 21 | jsonDocument, err := jsonSchemeDocumentByCode(sourceCode) 22 | if err != nil { 23 | return nil, fmt.Errorf("failed provide json document loader: %w", err) 24 | } 25 | if jsonDocument == nil { 26 | return nil, fmt.Errorf("json document is invalid") 27 | } 28 | 29 | jsonScheme := gojsonschema.NewBytesLoader(jsonSchema) 30 | result, err := gojsonschema.Validate(jsonScheme, *jsonDocument) 31 | if err != nil { 32 | return nil, fmt.Errorf("json scheme validation error: %w", err) 33 | } 34 | 35 | notices := make([]jsonSchemeNotice, 0) 36 | for _, schemeErr := range result.Errors() { 37 | yamlPath := jsonSchemeExtractYamlPathFromError(schemeErr) 38 | titlePath := fmt.Sprintf("? <%s>", schemeErr.Context().String()) 39 | 40 | if yamlPath != nil { 41 | titlePath = *yamlPath 42 | } 43 | 44 | notices = append(notices, jsonSchemeNotice{ 45 | notice: fmt.Sprintf("(%s) %s", titlePath, schemeErr.Description()), 46 | yamlPath: yamlPath, 47 | }) 48 | } 49 | 50 | return notices, nil 51 | } 52 | 53 | func jsonSchemeDocumentByCode(sourceCode []byte) (*gojsonschema.JSONLoader, error) { 54 | jsonBody, err := yamlToJSON(sourceCode) 55 | if err != nil { 56 | return nil, fmt.Errorf("failed transform yaml to json: %w", err) 57 | } 58 | 59 | loader := gojsonschema.NewBytesLoader(jsonBody) 60 | return &loader, nil 61 | } 62 | 63 | func yamlToJSON(sourceCode []byte) ([]byte, error) { 64 | var body interface{} 65 | err := yaml.Unmarshal(sourceCode, &body) 66 | if err != nil { 67 | // invalid yaml document 68 | return nil, fmt.Errorf("invalid source yaml: %w", err) 69 | } 70 | 71 | jsonBody, err := json.Marshal(&body) 72 | if err != nil { 73 | // invalid json struct in mem 74 | return nil, fmt.Errorf("failed marshal to json: %w", err) 75 | } 76 | 77 | return jsonBody, nil 78 | } 79 | 80 | func jsonSchemeExtractYamlPathFromError(err gojsonschema.ResultError) *string { 81 | // todo: map's path not working, because json path $.a.b.c in yaml, can be: 82 | // - $.a.b.c (object) 83 | // - $.a[b].c (map) 84 | 85 | // root 86 | path := "(root)" 87 | 88 | // context 89 | if err.Field() == "(root)" { 90 | propertyName, ok := err.Details()["property"] 91 | if !ok { 92 | return nil 93 | } 94 | 95 | path = fmt.Sprintf("%s.%s", path, propertyName) 96 | } else { 97 | path = fmt.Sprintf("%s.%s", path, err.Field()) 98 | } 99 | 100 | path = jsonSchemeTransformJSONPathToYamlPath(path) 101 | 102 | // resolve path 103 | return &path 104 | } 105 | 106 | // transform jsonPath to yamlPath 107 | // 108 | // "(root).exclude.1" -> "$.exclude[1]" 109 | // "(root).some.field.22" -> "$.some.field[22]" 110 | // "(root).some.field.22a.b" -> "$.some.field.22a.b" 111 | func jsonSchemeTransformJSONPathToYamlPath(path string) string { 112 | // root -> $ 113 | path = strings.Replace(path, "(root)", "$", 1) 114 | 115 | // array index .1 -> [1] 116 | re := regexp.MustCompile(`\.([0-9]+)(\.|$)`) 117 | path = re.ReplaceAllString(path, "[${1}]${2}") 118 | 119 | return path 120 | } 121 | -------------------------------------------------------------------------------- /internal/services/checker/deepscan/ast_utils.go: -------------------------------------------------------------------------------- 1 | package deepscan 2 | 3 | import ( 4 | "fmt" 5 | "go/ast" 6 | "go/parser" 7 | "go/token" 8 | "os" 9 | "path/filepath" 10 | "regexp" 11 | "strings" 12 | "unicode" 13 | 14 | "golang.org/x/tools/go/packages" 15 | ) 16 | 17 | const parseMode = packages.NeedName | 18 | packages.NeedFiles | 19 | packages.NeedTypes | 20 | packages.NeedSyntax | 21 | packages.NeedTypesInfo 22 | 23 | func cachedPackage(ctx *searchCtx, path string) (*packages.Package, error) { 24 | if parsedPackage, exist := ctx.parsedPackages[path]; exist { 25 | return parsedPackage, nil 26 | } 27 | 28 | cfg := &packages.Config{ 29 | Mode: parseMode, 30 | Fset: ctx.fileSet, 31 | Dir: path, 32 | } 33 | parsedPackages, err := packages.Load(cfg, path) 34 | if err != nil { 35 | return nil, fmt.Errorf("failed parse go source: %w", err) 36 | } 37 | 38 | if len(parsedPackages) == 0 { 39 | return nil, fmt.Errorf("not found go sources") 40 | } 41 | 42 | for _, parsedPackage := range parsedPackages { 43 | // we always expect only one package by path 44 | ctx.parsedPackages[path] = parsedPackage 45 | break 46 | } 47 | 48 | return ctx.parsedPackages[path], nil 49 | } 50 | 51 | // isPublicName check that first char in string in uppercase 52 | // so its go public name (like `PublicMethod`) 53 | // return false for `privateMethod` 54 | func astIsPublicName(name string) bool { 55 | for _, r := range name { 56 | // check first rune is upper in name 57 | return unicode.IsUpper(r) 58 | } 59 | 60 | return false 61 | } 62 | 63 | type parseRecursiveCtx struct { 64 | excludedPaths []string 65 | excludedFileMatchers []*regexp.Regexp 66 | foundFiles map[string]struct{} 67 | } 68 | 69 | func parseRecursive( 70 | fset *token.FileSet, 71 | path string, 72 | excludedPaths []string, 73 | excludedFileMatchers []*regexp.Regexp, 74 | mode parser.Mode, 75 | ) (files map[string]*ast.File, first error) { 76 | files = make(map[string]*ast.File) 77 | 78 | parseCtx := parseRecursiveCtx{ 79 | excludedPaths: excludedPaths, 80 | excludedFileMatchers: excludedFileMatchers, 81 | foundFiles: map[string]struct{}{}, 82 | } 83 | 84 | err := filepath.Walk(path, func(path string, info os.FileInfo, err error) error { 85 | return resolveScopeFile(&parseCtx, path, info, err) 86 | }) 87 | if err != nil { 88 | return nil, fmt.Errorf("failed to walk project tree: %w", err) 89 | } 90 | 91 | for filePath := range parseCtx.foundFiles { 92 | fileAst, err := parser.ParseFile(fset, filePath, nil, mode) 93 | if err != nil { 94 | return nil, fmt.Errorf("failed parse '%s': %w", filePath, err) 95 | } 96 | 97 | files[filePath] = fileAst 98 | } 99 | 100 | return files, nil 101 | } 102 | 103 | func resolveScopeFile(ctx *parseRecursiveCtx, path string, info os.FileInfo, err error) error { 104 | if err != nil { 105 | return err 106 | } 107 | 108 | if _, alreadyExist := ctx.foundFiles[path]; alreadyExist { 109 | return nil 110 | } 111 | 112 | if info.IsDir() || !inScope(ctx, path) { 113 | return nil 114 | } 115 | 116 | ctx.foundFiles[path] = struct{}{} 117 | return nil 118 | } 119 | 120 | func inScope(ctx *parseRecursiveCtx, path string) bool { 121 | if filepath.Ext(path) != ".go" { 122 | return false 123 | } 124 | 125 | for _, excludePath := range ctx.excludedPaths { 126 | if strings.HasPrefix(path, excludePath) { 127 | return false 128 | } 129 | } 130 | 131 | for _, matcher := range ctx.excludedFileMatchers { 132 | if matcher.Match([]byte(path)) { 133 | return false 134 | } 135 | } 136 | 137 | return true 138 | } 139 | -------------------------------------------------------------------------------- /internal/services/project/scanner/scanner.go: -------------------------------------------------------------------------------- 1 | package scanner 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "go/ast" 7 | "go/parser" 8 | "go/token" 9 | "os" 10 | "path/filepath" 11 | "regexp" 12 | "strings" 13 | 14 | "golang.org/x/tools/go/packages" 15 | 16 | "github.com/fe3dback/go-arch-lint/internal/models" 17 | astUtil "github.com/fe3dback/go-arch-lint/internal/services/common/ast" 18 | ) 19 | 20 | type ( 21 | Scanner struct { 22 | stdPackages map[string]struct{} 23 | } 24 | 25 | resolveContext struct { 26 | projectDirectory string 27 | moduleName string 28 | excludePaths []models.ResolvedPath 29 | excludeFileMatchers []*regexp.Regexp 30 | 31 | tokenSet *token.FileSet 32 | results []models.ProjectFile 33 | } 34 | ) 35 | 36 | func NewScanner() *Scanner { 37 | scanner := &Scanner{ 38 | stdPackages: make(map[string]struct{}, 255), 39 | } 40 | 41 | stdPackages, err := packages.Load(nil, "std") 42 | if err != nil { 43 | panic(fmt.Errorf("failed load std packages")) 44 | } 45 | 46 | for _, stdPackage := range stdPackages { 47 | scanner.stdPackages[stdPackage.ID] = struct{}{} 48 | } 49 | 50 | return scanner 51 | } 52 | 53 | func (r *Scanner) Scan( 54 | _ context.Context, 55 | projectDirectory string, 56 | moduleName string, 57 | excludePaths []models.ResolvedPath, 58 | excludeFileMatchers []*regexp.Regexp, 59 | ) ([]models.ProjectFile, error) { 60 | rctx := resolveContext{ 61 | projectDirectory: projectDirectory, 62 | moduleName: moduleName, 63 | excludePaths: excludePaths, 64 | excludeFileMatchers: excludeFileMatchers, 65 | 66 | tokenSet: token.NewFileSet(), 67 | results: []models.ProjectFile{}, 68 | } 69 | 70 | err := filepath.Walk(rctx.projectDirectory, func(path string, info os.FileInfo, err error) error { 71 | return r.resolveFile(&rctx, path, info, err) 72 | }) 73 | if err != nil { 74 | return nil, fmt.Errorf("failed to walk project tree: %w", err) 75 | } 76 | 77 | return rctx.results, nil 78 | } 79 | 80 | func (r *Scanner) resolveFile(ctx *resolveContext, path string, info os.FileInfo, err error) error { 81 | if err != nil { 82 | return err 83 | } 84 | 85 | if info.IsDir() || !r.inScope(ctx, path) { 86 | return nil 87 | } 88 | 89 | return r.parse(ctx, path) 90 | } 91 | 92 | func (r *Scanner) inScope(ctx *resolveContext, path string) bool { 93 | if filepath.Ext(path) != ".go" { 94 | return false 95 | } 96 | 97 | for _, excludePath := range ctx.excludePaths { 98 | if strings.HasPrefix(path, excludePath.AbsPath) { 99 | return false 100 | } 101 | } 102 | 103 | for _, matcher := range ctx.excludeFileMatchers { 104 | if matcher.Match([]byte(path)) { 105 | return false 106 | } 107 | } 108 | 109 | return true 110 | } 111 | 112 | func (r *Scanner) parse(ctx *resolveContext, path string) error { 113 | fileAst, err := parser.ParseFile(ctx.tokenSet, path, nil, parser.ImportsOnly) 114 | if err != nil { 115 | return fmt.Errorf("failed to parse go source code at '%s': %w", path, err) 116 | } 117 | 118 | ctx.results = append(ctx.results, models.ProjectFile{ 119 | Path: path, 120 | Imports: r.extractImports(ctx, fileAst), 121 | }) 122 | 123 | return nil 124 | } 125 | 126 | func (r *Scanner) extractImports(ctx *resolveContext, fileAst *ast.File) []models.ResolvedImport { 127 | imports := make([]models.ResolvedImport, 0) 128 | 129 | for _, goImport := range fileAst.Imports { 130 | importPath := strings.Trim(goImport.Path.Value, "\"") 131 | imports = append(imports, models.ResolvedImport{ 132 | Name: importPath, 133 | ImportType: models.GetImportType(importPath, ctx.moduleName, r.stdPackages), 134 | Reference: astUtil.PositionFromToken(ctx.tokenSet.Position(goImport.Pos())), 135 | }) 136 | } 137 | 138 | return imports 139 | } 140 | -------------------------------------------------------------------------------- /internal/services/project/holder/holder.go: -------------------------------------------------------------------------------- 1 | package holder 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | 7 | "github.com/fe3dback/go-arch-lint/internal/models" 8 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 9 | ) 10 | 11 | type ( 12 | Holder struct{} 13 | 14 | matchedComponent struct { 15 | id string 16 | filesCount int 17 | } 18 | ) 19 | 20 | func NewHolder() *Holder { 21 | return &Holder{} 22 | } 23 | 24 | func (h *Holder) HoldProjectFiles(files []models.ProjectFile, components []arch.Component) []models.FileHold { 25 | matchedCount := make(map[string]int) 26 | // example: 27 | // /** = 100 28 | // /a/** = 5 29 | // /a/b/** = 1 30 | 31 | mapping := make(map[string][]string) 32 | // example: 33 | // /main.go = ["/"] 34 | // /a/src.go = ["/", "/a"] 35 | // /a/b/src.go = ["/", "/a", "/b"] 36 | 37 | backMapping := make(map[string]models.ProjectFile) 38 | for _, file := range files { 39 | backMapping[file.Path] = file 40 | 41 | if _, ok := mapping[file.Path]; !ok { 42 | mapping[file.Path] = make([]string, 0) 43 | } 44 | 45 | components := componentsMatchesFile(file.Path, components) 46 | for _, component := range components { 47 | if _, ok := matchedCount[component]; !ok { 48 | matchedCount[component] = 0 49 | } 50 | 51 | matchedCount[component]++ 52 | mapping[file.Path] = append(mapping[file.Path], component) 53 | } 54 | } 55 | 56 | results := make([]models.FileHold, 0) 57 | for filePath, componentIDs := range mapping { 58 | if len(componentIDs) == 0 { 59 | results = append(results, models.FileHold{ 60 | File: backMapping[filePath], 61 | ComponentID: nil, 62 | }) 63 | 64 | continue 65 | } 66 | 67 | defComponent := componentIDs[0] 68 | holder := matchedComponent{ 69 | id: defComponent, 70 | filesCount: matchedCount[defComponent], 71 | } 72 | 73 | if len(componentIDs) > 1 { 74 | for _, componentID := range componentIDs { 75 | variant := matchedComponent{ 76 | id: componentID, 77 | filesCount: matchedCount[componentID], 78 | } 79 | 80 | if compare(holder, variant) { 81 | holder = variant 82 | } 83 | } 84 | } 85 | 86 | results = append(results, models.FileHold{ 87 | File: backMapping[filePath], 88 | ComponentID: &holder.id, 89 | }) 90 | } 91 | 92 | return results 93 | } 94 | 95 | // should return true if B better than A 96 | func compare(a, b matchedComponent) bool { 97 | if a.id == b.id { 98 | return false 99 | } 100 | 101 | // smallest files match count 102 | if b.filesCount != a.filesCount { 103 | return b.filesCount < a.filesCount 104 | } 105 | 106 | // has more specified directory 107 | aLen := strings.Count(a.id, "/") 108 | bLen := strings.Count(b.id, "/") 109 | if bLen != aLen { 110 | return bLen > aLen 111 | } 112 | 113 | // longest name 114 | if len(b.id) != len(a.id) { 115 | return len(b.id) > len(a.id) 116 | } 117 | 118 | // stable sort for equal priority path's 119 | return b.id < a.id 120 | } 121 | 122 | func componentsMatchesFile(filePath string, components []arch.Component) []string { 123 | matched := make([]string, 0) 124 | packagePath := filepath.Dir(filePath) 125 | 126 | for _, component := range components { 127 | if componentMatchPackage(packagePath, component) { 128 | matched = append(matched, component.Name.Value) 129 | } 130 | } 131 | 132 | return matched 133 | } 134 | 135 | func componentMatchPackage(packagePath string, component arch.Component) bool { 136 | for _, componentDirectoryRef := range component.ResolvedPaths { 137 | resolvedPackagePath := componentDirectoryRef.Value.AbsPath 138 | if packageMathPath(packagePath, resolvedPackagePath) { 139 | return true 140 | } 141 | } 142 | 143 | return false 144 | } 145 | 146 | func packageMathPath(packagePath string, resolvedPackagePath string) bool { 147 | return packagePath == resolvedPackagePath 148 | } 149 | -------------------------------------------------------------------------------- /internal/services/spec/document.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "github.com/fe3dback/go-arch-lint/internal/models" 5 | "github.com/fe3dback/go-arch-lint/internal/models/common" 6 | ) 7 | 8 | // spec layout: 9 | // decoder - decode different config file formats (v1 ... latest) into one single Document interface 10 | // validator - will validate Document interface (check integrity between config fields) 11 | // assembler - will assemble arch.Spec from validated Document interface 12 | // 13 | // all other operations (business logic) code will use arch.Spec object for reading config values. 14 | 15 | type ( 16 | // ComponentName is abstraction useful for mapping real packages to one Component. 17 | ComponentName = string 18 | 19 | // VendorName is abstraction useful for mapping real vendor packages to one Vendor. 20 | VendorName = string 21 | 22 | Vendors = map[VendorName]common.Referable[Vendor] 23 | Components = map[ComponentName]common.Referable[Component] 24 | Dependencies = map[ComponentName]common.Referable[DependencyRule] 25 | 26 | Document interface { 27 | // Version of spec (scheme of document) 28 | Version() common.Referable[int] 29 | 30 | // WorkingDirectory relative to root, prepend this to all path's from spec 31 | WorkingDirectory() common.Referable[string] 32 | 33 | // Options is global spec options 34 | Options() Options 35 | 36 | // ExcludedDirectories from analyze, each contain relative directory name 37 | // List of directories 38 | // examples: 39 | // - internal/test 40 | // - vendor 41 | // - .idea 42 | ExcludedDirectories() []common.Referable[string] 43 | 44 | // ExcludedFilesRegExp from analyze, each project file will be matched with this regexp rules 45 | // List of regexp's 46 | // examples: 47 | // - "^.*_test\\.go$" 48 | ExcludedFilesRegExp() []common.Referable[string] 49 | 50 | // Vendors (map) 51 | Vendors() Vendors 52 | 53 | // CommonVendors is list of Vendors that can be imported to any project package 54 | CommonVendors() []common.Referable[string] 55 | 56 | // Components (map) 57 | Components() Components 58 | 59 | // CommonComponents is List of Components that can be imported to any project package 60 | CommonComponents() []common.Referable[string] 61 | 62 | // Dependencies map between Components and DependencyRule`s 63 | Dependencies() Dependencies 64 | } 65 | 66 | Options interface { 67 | // IsDependOnAnyVendor allows all project code depend on any third party vendor lib 68 | // analyze will not check imports with not local namespace's 69 | IsDependOnAnyVendor() common.Referable[bool] 70 | 71 | // DeepScan turn on usage of advanced AST linter 72 | // this is default behavior since v3+ configs 73 | DeepScan() common.Referable[bool] 74 | 75 | // IgnoreNotFoundComponents skips components that are not found by their glob 76 | // disabled by default 77 | IgnoreNotFoundComponents() common.Referable[bool] 78 | } 79 | 80 | Vendor interface { 81 | // ImportPaths is list of full import vendor qualified path 82 | // example: 83 | // - golang.org/x/mod/modfile 84 | // - example.com/*/libs/** 85 | ImportPaths() []models.Glob 86 | } 87 | 88 | Component interface { 89 | // RelativePaths can contain glob's 90 | // example: 91 | // - internal/service/*/models/** 92 | // - / 93 | // - tests/** 94 | RelativePaths() []models.Glob 95 | } 96 | 97 | DependencyRule interface { 98 | // MayDependOn is list of Component names, that can be imported to described component 99 | MayDependOn() []common.Referable[string] 100 | 101 | // CanUse is list of Vendor names, that can be imported to described component 102 | CanUse() []common.Referable[string] 103 | 104 | // AnyProjectDeps allow component to import any other local namespace packages 105 | AnyProjectDeps() common.Referable[bool] 106 | 107 | // AnyVendorDeps allow component to import any other vendor namespace packages 108 | AnyVendorDeps() common.Referable[bool] 109 | 110 | // DeepScan overrides deepScan global option 111 | DeepScan() common.Referable[bool] 112 | } 113 | ) 114 | -------------------------------------------------------------------------------- /internal/models/opr_check.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "github.com/fe3dback/go-arch-lint/internal/models/common" 4 | 5 | type ( 6 | CmdCheckIn struct { 7 | ProjectPath string 8 | ArchFile string 9 | MaxWarnings int 10 | } 11 | 12 | CmdCheckOut struct { 13 | DocumentNotices []CheckNotice `json:"ExecutionWarnings"` 14 | ArchHasWarnings bool `json:"ArchHasWarnings"` 15 | ArchWarningsDependency []CheckArchWarningDependency `json:"ArchWarningsDeps"` 16 | ArchWarningsMatch []CheckArchWarningMatch `json:"ArchWarningsNotMatched"` 17 | ArchWarningsDeepScan []CheckArchWarningDeepscan `json:"ArchWarningsDeepScan"` 18 | OmittedCount int `json:"OmittedCount"` 19 | ModuleName string `json:"ModuleName"` 20 | Qualities []CheckQuality `json:"Qualities"` 21 | } 22 | 23 | CheckQuality struct { 24 | ID string `json:"ID"` 25 | Used bool `json:"Used"` 26 | Name string `json:"-"` 27 | Hint string `json:"-"` 28 | } 29 | 30 | CheckNotice struct { 31 | Text string `json:"Text"` 32 | File string `json:"File"` 33 | Line int `json:"Line"` 34 | Column int `json:"Offset"` 35 | SourceCodePreview []byte `json:"-"` 36 | } 37 | 38 | CheckArchWarningDependency struct { 39 | ComponentName string `json:"ComponentName"` 40 | FileRelativePath string `json:"FileRelativePath"` 41 | FileAbsolutePath string `json:"FileAbsolutePath"` 42 | ResolvedImportName string `json:"ResolvedImportName"` 43 | Reference common.Reference `json:"Reference"` 44 | } 45 | 46 | CheckArchWarningMatch struct { 47 | FileRelativePath string `json:"FileRelativePath"` 48 | FileAbsolutePath string `json:"FileAbsolutePath"` 49 | Reference common.Reference `json:"-"` 50 | } 51 | 52 | CheckArchWarningDeepscan struct { 53 | Gate DeepscanWarningGate `json:"Gate"` 54 | Dependency DeepscanWarningDependency `json:"Dependency"` 55 | Target DeepscanWarningTarget `json:"Target"` 56 | } 57 | 58 | DeepscanWarningGate struct { 59 | ComponentName string `json:"ComponentName"` // operations 60 | MethodName string `json:"MethodName"` // NewOperation 61 | Definition common.Reference `json:"Definition"` // internal/glue/code/line_count.go:54 62 | RelativePath string `json:"-"` // internal/glue/code/line_count.go:54 63 | } 64 | 65 | DeepscanWarningDependency struct { 66 | ComponentName string `json:"ComponentName"` // repository 67 | Name string `json:"Name"` // micro.ViewRepository 68 | InjectionAST string `json:"InjectionAST"` // c.provideMicroViewRepository() 69 | Injection common.Reference `json:"Injection"` // internal/app/internal/container/cmd_mapping.go:15 70 | InjectionPath string `json:"-"` // internal/app/internal/container/cmd_mapping.go:15 71 | SourceCodePreview []byte `json:"-"` 72 | } 73 | 74 | DeepscanWarningTarget struct { 75 | Definition common.Reference `json:"Definition"` 76 | RelativePath string `json:"-"` // internal/app/internal/container/cmd_mapping.go:15 77 | } 78 | 79 | CheckResult struct { 80 | DependencyWarnings []CheckArchWarningDependency 81 | MatchWarnings []CheckArchWarningMatch 82 | DeepscanWarnings []CheckArchWarningDeepscan 83 | } 84 | ) 85 | 86 | func (cr *CheckResult) Append(another CheckResult) { 87 | cr.DependencyWarnings = append(cr.DependencyWarnings, another.DependencyWarnings...) 88 | cr.MatchWarnings = append(cr.MatchWarnings, another.MatchWarnings...) 89 | cr.DeepscanWarnings = append(cr.DeepscanWarnings, another.DeepscanWarnings...) 90 | } 91 | 92 | func (cr *CheckResult) HasNotices() bool { 93 | if len(cr.DependencyWarnings) > 0 { 94 | return true 95 | } 96 | if len(cr.MatchWarnings) > 0 { 97 | return true 98 | } 99 | if len(cr.DeepscanWarnings) > 0 { 100 | return true 101 | } 102 | 103 | return false 104 | } 105 | -------------------------------------------------------------------------------- /internal/app/internal/container/container_cmd.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/fe3dback/go-arch-lint/internal/models" 10 | ) 11 | 12 | type runner = func(cmd *cobra.Command) (any, error) 13 | 14 | func (c *Container) CommandRoot() *cobra.Command { 15 | flags := models.FlagsRoot{ 16 | UseColors: true, 17 | OutputType: models.OutputTypeDefault, 18 | OutputJsonOneLine: false, 19 | } 20 | flagAliasOutputTypeJson := false 21 | 22 | rootCmd := &cobra.Command{ 23 | Use: "go-arch-lint", 24 | Short: "Golang architecture linter", 25 | Long: "Check all project imports and compare to arch rules defined in yaml file.\nRead full documentation in: https://github.com/fe3dback/go-arch-lint", 26 | SilenceErrors: true, // redirect to stderr 27 | SilenceUsage: true, 28 | RunE: func(act *cobra.Command, _ []string) error { 29 | return act.Help() 30 | }, 31 | PersistentPreRunE: func(_ *cobra.Command, _ []string) error { 32 | // alias preprocessor 33 | if flagAliasOutputTypeJson { 34 | if flags.OutputType != models.OutputTypeDefault && flags.OutputType != models.OutputTypeJSON { 35 | return fmt.Errorf("flag --%s not compatible with --%s=%s", 36 | "json", 37 | "output-type", 38 | flags.OutputType, 39 | ) 40 | } 41 | 42 | flags.OutputType = models.OutputTypeJSON 43 | } 44 | 45 | // fallback to default's 46 | if flags.OutputType == models.OutputTypeDefault { 47 | flags.OutputType = models.OutputTypeASCII 48 | } 49 | 50 | // validate 51 | outputTypeIsValid := false 52 | for _, validValue := range models.OutputTypeValues { 53 | if flags.OutputType == validValue { 54 | outputTypeIsValid = true 55 | break 56 | } 57 | } 58 | 59 | if !outputTypeIsValid { 60 | return fmt.Errorf("unknown output-type: %s", flags.OutputType) 61 | } 62 | 63 | // save global flags for another child commands 64 | c.flags = flags 65 | return nil 66 | }, 67 | } 68 | 69 | // define global flags 70 | rootCmd.PersistentFlags().BoolVar(&flags.UseColors, "output-color", flags.UseColors, "use ANSI colors in terminal output") 71 | rootCmd.PersistentFlags().StringVar(&flags.OutputType, "output-type", flags.OutputType, fmt.Sprintf("type of command output, variants: [%s]", strings.Join(models.OutputTypeValues, ", "))) 72 | rootCmd.PersistentFlags().BoolVar(&flags.OutputJsonOneLine, "output-json-one-line", flags.OutputJsonOneLine, "format JSON as single line payload (without line breaks), only for json output type") 73 | rootCmd.PersistentFlags().BoolVar(&flagAliasOutputTypeJson, "json", flagAliasOutputTypeJson, fmt.Sprintf("(alias for --%s=%s)", 74 | "output-type", 75 | models.OutputTypeJSON, 76 | )) 77 | 78 | // apply sub commands 79 | for _, subCmd := range c.commands() { 80 | if subCmd.PersistentPreRun != nil { 81 | panic(fmt.Errorf("root sub command '%s' should not have 'PersistentPreRun', "+ 82 | "use 'PreRun' instead", rootCmd.Name(), 83 | )) 84 | } 85 | 86 | if subCmd.PersistentPreRunE != nil { 87 | panic(fmt.Errorf("root sub command '%s' should not have 'PersistentPreRunE', "+ 88 | "use 'PreRunE' instead", rootCmd.Name(), 89 | )) 90 | } 91 | 92 | rootCmd.AddCommand(subCmd) 93 | } 94 | 95 | return rootCmd 96 | } 97 | 98 | func (c *Container) commands() []*cobra.Command { 99 | type exec struct { 100 | cmd *cobra.Command 101 | runE runner 102 | } 103 | 104 | unwrap := func(cmd *cobra.Command, r runner) exec { 105 | return exec{cmd: cmd, runE: r} 106 | } 107 | 108 | executors := []exec{ 109 | unwrap(c.commandVersion()), 110 | unwrap(c.commandSelfInspect()), 111 | unwrap(c.commandSchema()), 112 | unwrap(c.commandCheck()), 113 | unwrap(c.commandMapping()), 114 | unwrap(c.commandGraph()), 115 | } 116 | 117 | list := make([]*cobra.Command, 0, len(executors)) 118 | for i := range executors { 119 | executor := executors[i] 120 | executor.cmd.RunE = func(activeCmd *cobra.Command, _ []string) error { 121 | return c.ProvideRenderer().RenderModel(executor.runE(activeCmd)) 122 | } 123 | list = append(list, executor.cmd) 124 | } 125 | 126 | return list 127 | } 128 | -------------------------------------------------------------------------------- /internal/services/spec/decoder/decoder.go: -------------------------------------------------------------------------------- 1 | package decoder 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/fe3dback/go-yaml" 10 | 11 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 12 | "github.com/fe3dback/go-arch-lint/internal/models/common" 13 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 14 | ) 15 | 16 | type Decoder struct { 17 | yamlReferenceResolver yamlSourceCodeReferenceResolver 18 | jsonSchemaProvider jsonSchemaProvider 19 | } 20 | 21 | func NewDecoder( 22 | yamlReferenceResolver yamlSourceCodeReferenceResolver, 23 | jsonSchemaProvider jsonSchemaProvider, 24 | ) *Decoder { 25 | return &Decoder{ 26 | yamlReferenceResolver: yamlReferenceResolver, 27 | jsonSchemaProvider: jsonSchemaProvider, 28 | } 29 | } 30 | 31 | func (sp *Decoder) Decode(archFile string) (spec.Document, []arch.Notice, error) { 32 | sourceCode, err := os.ReadFile(archFile) 33 | if err != nil { 34 | return nil, nil, fmt.Errorf("failed to provide source code of archfile: %w", err) 35 | } 36 | 37 | // read only doc Version 38 | documentVersion, err := sp.readVersion(sourceCode) 39 | if err != nil { 40 | // invalid yaml document 41 | return nil, nil, fmt.Errorf("failed to read 'version' from arch file: %w", err) 42 | } 43 | 44 | // validate yaml scheme by version 45 | schemeNotices := sp.jsonSchemeValidate(documentVersion, sourceCode, archFile) 46 | 47 | // try to read all document 48 | document, err := sp.decodeDocument(documentVersion, sourceCode, archFile) 49 | if err != nil { 50 | if len(schemeNotices) > 0 { 51 | // document invalid, but yaml 52 | return document, schemeNotices, nil 53 | } 54 | 55 | // invalid yaml document, or scheme validation failed 56 | return nil, nil, fmt.Errorf("failed to parse arch file (yaml): %w", err) 57 | } 58 | 59 | return document, schemeNotices, nil 60 | } 61 | 62 | func (sp *Decoder) decodeDocument(version int, sourceCode []byte, filePath string) (doc, error) { 63 | reader := bytes.NewBuffer(sourceCode) 64 | decoder := yaml.NewDecoder( 65 | reader, 66 | yaml.DisallowDuplicateKey(), 67 | yaml.DisallowUnknownField(), 68 | yaml.Strict(), 69 | ) 70 | 71 | decodeCtx := context.WithValue(context.Background(), yamlParentFileCtx{}, filePath) 72 | document := sp.createEmptyDocumentBeVersion(version) 73 | 74 | err := decoder.DecodeContext(decodeCtx, document) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | document.postSetup() 80 | return document, nil 81 | } 82 | 83 | func (sp *Decoder) createEmptyDocumentBeVersion(version int) doc { 84 | switch version { 85 | case 1: 86 | return &ArchV1{} 87 | case 2: 88 | return &ArchV2{} 89 | } 90 | 91 | // latest be default (it will be rejected next in spec validator, if version is not v3) 92 | return &ArchV3{} 93 | } 94 | 95 | func (sp *Decoder) readVersion(sourceCode []byte) (int, error) { 96 | type doc struct { 97 | Version int `json:"version"` 98 | } 99 | reader := bytes.NewBuffer(sourceCode) 100 | decoder := yaml.NewDecoder(reader) 101 | document := doc{} 102 | err := decoder.Decode(&document) 103 | if err != nil { 104 | return 0, err 105 | } 106 | 107 | return document.Version, nil 108 | } 109 | 110 | func (sp *Decoder) jsonSchemeValidate(schemeVersion int, sourceCode []byte, filePath string) []arch.Notice { 111 | jsonSchema, err := sp.jsonSchemaProvider.Provide(schemeVersion) 112 | if err != nil { 113 | return []arch.Notice{{ 114 | Notice: fmt.Errorf("failed to provide json scheme for validation: %w", err), 115 | Ref: common.NewEmptyReference(), 116 | }} 117 | } 118 | 119 | jsonNotices, err := jsonSchemeValidate(jsonSchema, sourceCode) 120 | if err != nil { 121 | return []arch.Notice{{ 122 | Notice: fmt.Errorf("failed to validate arch file with json scheme: %w", err), 123 | Ref: common.NewEmptyReference(), 124 | }} 125 | } 126 | 127 | schemeNotices := make([]arch.Notice, 0) 128 | for _, jsonNotice := range jsonNotices { 129 | schemeRef := common.NewEmptyReference() 130 | if jsonNotice.yamlPath != nil { 131 | schemeRef = sp.yamlReferenceResolver.Resolve(filePath, *jsonNotice.yamlPath) 132 | } 133 | 134 | schemeNotices = append(schemeNotices, arch.Notice{ 135 | Notice: fmt.Errorf("%v", jsonNotice.notice), 136 | Ref: schemeRef, 137 | }) 138 | } 139 | 140 | return schemeNotices 141 | } 142 | -------------------------------------------------------------------------------- /internal/operations/mapping/operation.go: -------------------------------------------------------------------------------- 1 | package mapping 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sort" 7 | 8 | "github.com/fe3dback/go-arch-lint/internal/models" 9 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 10 | ) 11 | 12 | type Operation struct { 13 | specAssembler specAssembler 14 | projectFilesResolver projectFilesResolver 15 | projectInfoAssembler projectInfoAssembler 16 | } 17 | 18 | func NewOperation( 19 | specAssembler specAssembler, 20 | projectFilesResolver projectFilesResolver, 21 | projectInfoAssembler projectInfoAssembler, 22 | ) *Operation { 23 | return &Operation{ 24 | specAssembler: specAssembler, 25 | projectFilesResolver: projectFilesResolver, 26 | projectInfoAssembler: projectInfoAssembler, 27 | } 28 | } 29 | 30 | func (o *Operation) Behave(ctx context.Context, in models.CmdMappingIn) (models.CmdMappingOut, error) { 31 | projectInfo, err := o.projectInfoAssembler.ProjectInfo(in.ProjectPath, in.ArchFile) 32 | if err != nil { 33 | return models.CmdMappingOut{}, fmt.Errorf("failed to assemble project info: %w", err) 34 | } 35 | 36 | spec, err := o.specAssembler.Assemble(projectInfo) 37 | if err != nil { 38 | return models.CmdMappingOut{}, fmt.Errorf("failed to assemble spec: %w", err) 39 | } 40 | 41 | projectFiles, err := o.projectFilesResolver.ProjectFiles(ctx, spec) 42 | if err != nil { 43 | return models.CmdMappingOut{}, fmt.Errorf("failed to resolve project files: %w", err) 44 | } 45 | 46 | return models.CmdMappingOut{ 47 | ProjectDirectory: spec.RootDirectory.Value, 48 | ModuleName: spec.ModuleName.Value, 49 | MappingGrouped: assembleMappingByComponent(spec, projectFiles), 50 | MappingList: assembleMappingByFile(projectFiles), 51 | Scheme: in.Scheme, 52 | }, nil 53 | } 54 | 55 | func assembleMappingByComponent( 56 | spec arch.Spec, 57 | projectFiles []models.FileHold, 58 | ) []models.CmdMappingOutGrouped { 59 | tmp := make(map[string]*models.CmdMappingOutGrouped) 60 | 61 | for _, projectFile := range projectFiles { 62 | componentName := componentName(projectFile.ComponentID) 63 | if _, exist := tmp[componentName]; !exist { 64 | tmp[componentName] = &models.CmdMappingOutGrouped{ 65 | ComponentName: componentName, 66 | FileNames: []string{}, 67 | } 68 | } 69 | 70 | fileName := projectFile.File.Path 71 | tmp[componentName].FileNames = append( 72 | tmp[componentName].FileNames, 73 | fileName, 74 | ) 75 | } 76 | 77 | mapping := make([]models.CmdMappingOutGrouped, 0) 78 | for _, component := range spec.Components { 79 | componentName := component.Name.Value 80 | if grouped, exist := tmp[componentName]; exist { 81 | sort.Strings(grouped.FileNames) 82 | mapping = append(mapping, *grouped) 83 | continue 84 | } 85 | 86 | mapping = append(mapping, models.CmdMappingOutGrouped{ 87 | ComponentName: componentName, 88 | FileNames: []string{}, 89 | }) 90 | } 91 | 92 | emptyComponentID := componentName(nil) 93 | if _, hasNotAttached := tmp[emptyComponentID]; hasNotAttached { 94 | notAttachedFiles := tmp[emptyComponentID].FileNames 95 | 96 | if len(notAttachedFiles) > 0 { 97 | sort.Strings(notAttachedFiles) 98 | mapping = append(mapping, models.CmdMappingOutGrouped{ 99 | ComponentName: emptyComponentID, 100 | FileNames: notAttachedFiles, 101 | }) 102 | } 103 | } 104 | 105 | sort.Slice(mapping, func(i, j int) bool { 106 | return mapping[i].ComponentName < mapping[j].ComponentName 107 | }) 108 | 109 | return mapping 110 | } 111 | 112 | func assembleMappingByFile(projectFiles []models.FileHold) []models.CmdMappingOutList { 113 | mapping := make([]models.CmdMappingOutList, 0) 114 | exist := make(map[string]struct{}) 115 | 116 | for _, projectFile := range projectFiles { 117 | fileName := projectFile.File.Path 118 | 119 | if _, exist := exist[fileName]; exist { 120 | continue 121 | } 122 | 123 | mapping = append(mapping, models.CmdMappingOutList{ 124 | FileName: fileName, 125 | ComponentName: componentName(projectFile.ComponentID), 126 | }) 127 | 128 | exist[fileName] = struct{}{} 129 | } 130 | 131 | sort.Slice(mapping, func(i, j int) bool { 132 | return mapping[i].FileName < mapping[j].FileName 133 | }) 134 | 135 | return mapping 136 | } 137 | 138 | func componentName(id *string) string { 139 | if id == nil { 140 | return "[not attached]" 141 | } 142 | 143 | return *id 144 | } 145 | -------------------------------------------------------------------------------- /internal/services/checker/deepscan/searcher.go: -------------------------------------------------------------------------------- 1 | package deepscan 2 | 3 | import ( 4 | "fmt" 5 | "go/ast" 6 | "go/parser" 7 | "go/token" 8 | "path" 9 | "path/filepath" 10 | "strings" 11 | "sync" 12 | 13 | "golang.org/x/tools/go/packages" 14 | 15 | "github.com/fe3dback/go-arch-lint/internal/models" 16 | astUtil "github.com/fe3dback/go-arch-lint/internal/services/common/ast" 17 | ) 18 | 19 | type ( 20 | // abs path to directory 21 | absPath = string 22 | 23 | // parsed packages 24 | packageCache = map[absPath]*packages.Package 25 | ) 26 | 27 | type ( 28 | Searcher struct { 29 | ctx *searchCtx 30 | 31 | mux sync.Mutex 32 | } 33 | 34 | searchCtx struct { 35 | // current search ctx 36 | criteria Criteria 37 | 38 | // hold all parsed packages in analyse scope 39 | // but only with imports declarations 40 | // used only for fast filter possible params 41 | parsedImports map[string]*ast.File 42 | 43 | // hold all already parsed ast packages 44 | // this holds all package meta information 45 | // like AST, types, etc... 46 | parsedPackages packageCache 47 | 48 | // parsed fileset 49 | fileSet *token.FileSet 50 | } 51 | ) 52 | 53 | func NewSearcher() *Searcher { 54 | return &Searcher{ 55 | ctx: &searchCtx{ 56 | parsedImports: map[string]*ast.File{}, 57 | parsedPackages: map[absPath]*packages.Package{}, 58 | fileSet: token.NewFileSet(), 59 | }, 60 | } 61 | } 62 | 63 | // Usages share same packages cache for every function call 64 | // so it`s good idea to check every package in project 65 | // with same Searcher instance 66 | // 67 | // This method will find all package functions with interfaces 68 | // and link it to all callers, with implementations 69 | // it will skip: 70 | // - methods without interface (not injectable) 71 | // - private methods (nobody outside can call it) 72 | // - only write chan (func (ch chan<-) (our code send something, so we not depend on implementations) 73 | // - with placeholder param names (func (_ myInterface)), nobody can use _, so code not depend on interface 74 | // 75 | // Can`t search from multiple goroutines, but safe for concurrent use (mutex inside) 76 | func (s *Searcher) Usages(c Criteria) ([]InjectionMethod, error) { 77 | s.mux.Lock() 78 | defer s.mux.Unlock() 79 | 80 | // current ctx 81 | s.ctx.criteria = c 82 | 83 | // search 84 | astPackage, err := cachedPackage(s.ctx, c.packagePath) 85 | if err != nil { 86 | return nil, fmt.Errorf("failed get package at '%s': %w", c.packagePath, err) 87 | } 88 | 89 | methods, err := s.extractMethodsFromPackage(astPackage) 90 | if err != nil { 91 | return nil, fmt.Errorf("failed extract methods from package at '%s': %w", c.packagePath, err) 92 | } 93 | 94 | err = s.applyImplementations(methods) 95 | if err != nil { 96 | return nil, fmt.Errorf("failed apply implementations info for found methods: %w", err) 97 | } 98 | 99 | return methods, nil 100 | } 101 | 102 | func (s *Searcher) sourceFromToken(pos token.Pos) Source { 103 | place := astUtil.PositionFromToken(s.ctx.fileSet.Position(pos)) 104 | absolutePath := filepath.Dir(place.File) 105 | importRef := s.pathToImport(absolutePath) 106 | pkg := path.Base(importRef) 107 | 108 | return Source{ 109 | Pkg: pkg, 110 | Import: models.ResolvedImport{ 111 | Name: importRef, 112 | ImportType: models.GetImportType(importRef, s.ctx.criteria.moduleName, nil), 113 | Reference: place, 114 | }, 115 | Path: absolutePath, 116 | Place: place, 117 | } 118 | } 119 | 120 | func (s *Searcher) pathToImport(packagePath string) string { 121 | packagePath = strings.TrimPrefix(packagePath, s.ctx.criteria.moduleRootPath) 122 | packagePath = strings.TrimPrefix(packagePath, string(filepath.Separator)) 123 | packagePath = strings.ReplaceAll(packagePath, string(filepath.Separator), "/") 124 | 125 | return fmt.Sprintf("%s/%s", s.ctx.criteria.moduleName, packagePath) 126 | } 127 | 128 | func (s *Searcher) preloadImports() error { 129 | if len(s.ctx.parsedImports) != 0 { 130 | return nil 131 | } 132 | 133 | found, err := parseRecursive( 134 | s.ctx.fileSet, 135 | s.ctx.criteria.analyseScope, 136 | s.ctx.criteria.excludePaths, 137 | s.ctx.criteria.excludeFileMatchers, 138 | parser.ImportsOnly, 139 | ) 140 | if err != nil { 141 | return fmt.Errorf("failed parse imports in scope '%s': %w", s.ctx.criteria.analyseScope, err) 142 | } 143 | 144 | s.ctx.parsedImports = found 145 | 146 | return nil 147 | } 148 | -------------------------------------------------------------------------------- /internal/services/spec/decoder/decoder_doc_v1.go: -------------------------------------------------------------------------------- 1 | package decoder 2 | 3 | import ( 4 | "github.com/fe3dback/go-arch-lint/internal/models" 5 | "github.com/fe3dback/go-arch-lint/internal/models/common" 6 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 7 | ) 8 | 9 | type ( 10 | // ArchV1 initial config schema 11 | ArchV1 struct { 12 | FVersion ref[int] `json:"version"` 13 | FAllow ArchV1Allow `json:"allow"` 14 | FExclude []ref[string] `json:"exclude"` 15 | FExcludeFilesRegExp []ref[string] `json:"excludeFiles"` 16 | FVendors map[spec.VendorName]ref[ArchV1Vendor] `json:"vendors"` 17 | FCommonVendors []ref[string] `json:"commonVendors"` 18 | FComponents map[spec.ComponentName]ref[ArchV1Component] `json:"components"` 19 | FCommonComponents []ref[string] `json:"commonComponents"` 20 | FDependencies map[spec.ComponentName]ref[ArchV1Rule] `json:"deps"` 21 | } 22 | 23 | ArchV1Allow struct { 24 | FDepOnAnyVendor ref[bool] `json:"depOnAnyVendor"` 25 | } 26 | 27 | ArchV1Vendor struct { 28 | FImportPath string `json:"in"` 29 | } 30 | 31 | ArchV1Component struct { 32 | FLocalPath string `json:"in"` 33 | } 34 | 35 | ArchV1Rule struct { 36 | FMayDependOn []ref[string] `json:"mayDependOn"` 37 | FCanUse []ref[string] `json:"canUse"` 38 | FAnyProjectDeps ref[bool] `json:"anyProjectDeps"` 39 | FAnyVendorDeps ref[bool] `json:"anyVendorDeps"` 40 | } 41 | ) 42 | 43 | func (a *ArchV1) postSetup() {} 44 | 45 | func (a *ArchV1) Version() common.Referable[int] { 46 | return castRef(a.FVersion) 47 | } 48 | 49 | func (a *ArchV1) WorkingDirectory() common.Referable[string] { 50 | return common.NewEmptyReferable("./") 51 | } 52 | 53 | func (a *ArchV1) Options() spec.Options { 54 | return a.FAllow 55 | } 56 | 57 | func (a *ArchV1) ExcludedDirectories() []common.Referable[string] { 58 | return castRefList(a.FExclude) 59 | } 60 | 61 | func (a *ArchV1) ExcludedFilesRegExp() []common.Referable[string] { 62 | return castRefList(a.FExcludeFilesRegExp) 63 | } 64 | 65 | func (a *ArchV1) Vendors() spec.Vendors { 66 | casted := make(spec.Vendors, len(a.FVendors)) 67 | for name, vendor := range a.FVendors { 68 | casted[name] = common.NewReferable(spec.Vendor(vendor.ref.Value), vendor.ref.Reference) 69 | } 70 | 71 | return casted 72 | } 73 | 74 | func (a *ArchV1) CommonVendors() []common.Referable[string] { 75 | return castRefList(a.FCommonVendors) 76 | } 77 | 78 | func (a *ArchV1) Components() spec.Components { 79 | casted := make(spec.Components, len(a.FComponents)) 80 | for name, cmp := range a.FComponents { 81 | casted[name] = common.NewReferable(spec.Component(cmp.ref.Value), cmp.ref.Reference) 82 | } 83 | 84 | return casted 85 | } 86 | 87 | func (a *ArchV1) CommonComponents() []common.Referable[string] { 88 | return castRefList(a.FCommonComponents) 89 | } 90 | 91 | func (a *ArchV1) Dependencies() spec.Dependencies { 92 | casted := make(spec.Dependencies, len(a.FDependencies)) 93 | for name, dep := range a.FDependencies { 94 | casted[name] = common.NewReferable(spec.DependencyRule(dep.ref.Value), dep.ref.Reference) 95 | } 96 | 97 | return casted 98 | } 99 | 100 | // -- 101 | 102 | func (a ArchV1Allow) IsDependOnAnyVendor() common.Referable[bool] { 103 | return castRef(a.FDepOnAnyVendor) 104 | } 105 | 106 | func (a ArchV1Allow) DeepScan() common.Referable[bool] { 107 | return common.NewEmptyReferable(false) 108 | } 109 | 110 | func (a ArchV1Allow) IgnoreNotFoundComponents() common.Referable[bool] { 111 | return common.NewEmptyReferable(false) 112 | } 113 | 114 | // -- 115 | 116 | func (a ArchV1Vendor) ImportPaths() []models.Glob { 117 | return []models.Glob{models.Glob(a.FImportPath)} 118 | } 119 | 120 | // -- 121 | 122 | func (a ArchV1Component) RelativePaths() []models.Glob { 123 | return []models.Glob{models.Glob(a.FLocalPath)} 124 | } 125 | 126 | // -- 127 | 128 | func (a ArchV1Rule) MayDependOn() []common.Referable[string] { 129 | return castRefList(a.FMayDependOn) 130 | } 131 | 132 | func (a ArchV1Rule) CanUse() []common.Referable[string] { 133 | return castRefList(a.FCanUse) 134 | } 135 | 136 | func (a ArchV1Rule) AnyProjectDeps() common.Referable[bool] { 137 | return castRef(a.FAnyProjectDeps) 138 | } 139 | 140 | func (a ArchV1Rule) AnyVendorDeps() common.Referable[bool] { 141 | return castRef(a.FAnyVendorDeps) 142 | } 143 | 144 | func (a ArchV1Rule) DeepScan() common.Referable[bool] { 145 | return common.NewEmptyReferable(false) 146 | } 147 | -------------------------------------------------------------------------------- /internal/services/checker/deepscan/criteria.go: -------------------------------------------------------------------------------- 1 | package deepscan 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "regexp" 8 | 9 | "golang.org/x/mod/modfile" 10 | ) 11 | 12 | type Criteria struct { 13 | // required 14 | // package to check 15 | // example: /home/neo/go/src/example.com/neo/project/internal/operation/hello 16 | packagePath string 17 | 18 | // optional (if not set, moduleRootPath will be used) 19 | // directory for recursive usage analyse 20 | // example: /home/neo/go/src/example.com/neo/project/internal 21 | analyseScope string 22 | 23 | // optional (will be auto located, where the closest go.mod placed) 24 | // full path to project, where go.mod is located 25 | // example: /home/neo/go/src/example.com/neo/project 26 | moduleRootPath string 27 | 28 | // optional (will be parsed from go.mod) 29 | // exactly as in go.mod 30 | // example: example.com/neo/project 31 | moduleName string 32 | 33 | // optional (default empty) 34 | // excluded absolute path's from analyseScope 35 | excludePaths []string 36 | 37 | // optional (default empty) 38 | // exclude regexp matchers, for each file in analyseScope 39 | // all rejected files will not be parsed 40 | excludeFileMatchers []*regexp.Regexp 41 | } 42 | 43 | type CriteriaArg = func(*Criteria) 44 | 45 | // NewCriteria build search Criteria for analyse 46 | func NewCriteria(args ...CriteriaArg) (Criteria, error) { 47 | // init 48 | criteria := Criteria{} 49 | for _, builder := range args { 50 | builder(&criteria) 51 | } 52 | 53 | // check required fields 54 | if criteria.packagePath == "" { 55 | return Criteria{}, fmt.Errorf("criteria packagePath should be set") 56 | } 57 | 58 | // set up optional fields 59 | err := fillDefaultCriteriaFields(&criteria) 60 | if err != nil { 61 | return Criteria{}, fmt.Errorf("failed fill optional criteria fields: %w", err) 62 | } 63 | 64 | // ok 65 | return criteria, nil 66 | } 67 | 68 | // WithPackagePath set full abs path to go package 69 | // who will be analysed 70 | func WithPackagePath(path string) CriteriaArg { 71 | return func(criteria *Criteria) { 72 | criteria.packagePath = path 73 | } 74 | } 75 | 76 | // WithAnalyseScope set full abs path to some project directory 77 | // can be project root, or some child directory, like 'internal' 78 | // only this directory and it`s child will be analysed for params 79 | func WithAnalyseScope(scope string) CriteriaArg { 80 | return func(criteria *Criteria) { 81 | criteria.analyseScope = scope 82 | } 83 | } 84 | 85 | // WithExcludedPath define list of abs path directories 86 | // for exclude from analyse scope 87 | func WithExcludedPath(paths []string) CriteriaArg { 88 | return func(criteria *Criteria) { 89 | criteria.excludePaths = paths 90 | } 91 | } 92 | 93 | // WithExcludedFileMatchers define list of regexp matchers 94 | // that will match each file name in analyse scope 95 | // if regexp match file name, it will be analysed for params 96 | func WithExcludedFileMatchers(matchers []*regexp.Regexp) CriteriaArg { 97 | return func(criteria *Criteria) { 98 | criteria.excludeFileMatchers = matchers 99 | } 100 | } 101 | 102 | func fillDefaultCriteriaFields(criteria *Criteria) error { 103 | if criteria.moduleName == "" || criteria.moduleRootPath == "" { 104 | moduleName, root, err := findRootPath(criteria.packagePath) 105 | if err != nil { 106 | return fmt.Errorf("failed find root path of '%s': %w", criteria.packagePath, err) 107 | } 108 | 109 | criteria.moduleName = moduleName 110 | criteria.moduleRootPath = root 111 | } 112 | 113 | if criteria.analyseScope == "" { 114 | criteria.analyseScope = criteria.moduleRootPath 115 | } 116 | 117 | return nil 118 | } 119 | 120 | func findRootPath(packagePath string) (moduleName string, rootPath string, err error) { 121 | if _, err := os.Stat(packagePath); os.IsNotExist(err) { 122 | return "", "", fmt.Errorf("packagePath directory not exist") 123 | } 124 | 125 | goModPath := filepath.Join(packagePath, "go.mod") 126 | _, err = os.Stat(goModPath) 127 | if err != nil { 128 | if os.IsNotExist(err) { 129 | // try find one level upper 130 | upperPath := filepath.Dir(packagePath) 131 | if upperPath == string(filepath.Separator) { 132 | return "", "", fmt.Errorf("go.mod not found on all parent levels") 133 | } 134 | 135 | return findRootPath(upperPath) 136 | } 137 | 138 | return "", "", fmt.Errorf("failed stat '%s': %w", goModPath, err) 139 | } 140 | 141 | file, err := os.ReadFile(goModPath) 142 | if err != nil { 143 | return "", "", fmt.Errorf("failed read '%s': %w", goModPath, err) 144 | } 145 | 146 | mod, err := modfile.ParseLax(goModPath, file, nil) 147 | if err != nil { 148 | return "", "", fmt.Errorf("modfile parse failed '%s': %w", goModPath, err) 149 | } 150 | 151 | return mod.Module.Mod.Path, packagePath, nil 152 | } 153 | -------------------------------------------------------------------------------- /internal/services/checker/checker_imports.go: -------------------------------------------------------------------------------- 1 | package checker 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/fe3dback/go-arch-lint/internal/models" 9 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 10 | "github.com/fe3dback/go-arch-lint/internal/models/common" 11 | ) 12 | 13 | type Imports struct { 14 | spec arch.Spec 15 | projectFilesResolver projectFilesResolver 16 | result results 17 | } 18 | 19 | func NewImport( 20 | projectFilesResolver projectFilesResolver, 21 | ) *Imports { 22 | return &Imports{ 23 | result: newResults(), 24 | projectFilesResolver: projectFilesResolver, 25 | } 26 | } 27 | 28 | func (c *Imports) Check(ctx context.Context, spec arch.Spec) (models.CheckResult, error) { 29 | c.spec = spec 30 | 31 | projectFiles, err := c.projectFilesResolver.ProjectFiles(ctx, spec) 32 | if err != nil { 33 | return models.CheckResult{}, fmt.Errorf("failed to resolve project files: %w", err) 34 | } 35 | 36 | components := c.assembleComponentsMap(spec) 37 | 38 | for _, projectFile := range projectFiles { 39 | if projectFile.ComponentID == nil { 40 | c.result.addNotMatchedWarning(models.CheckArchWarningMatch{ 41 | Reference: common.NewEmptyReference(), 42 | FileRelativePath: strings.TrimPrefix(projectFile.File.Path, spec.RootDirectory.Value), 43 | FileAbsolutePath: projectFile.File.Path, 44 | }) 45 | 46 | continue 47 | } 48 | 49 | componentID := *projectFile.ComponentID 50 | if component, ok := components[componentID]; ok { 51 | err := c.checkFile(component, projectFile.File) 52 | if err != nil { 53 | return models.CheckResult{}, fmt.Errorf("failed check file '%s': %w", projectFile.File.Path, err) 54 | } 55 | 56 | continue 57 | } 58 | 59 | return models.CheckResult{}, fmt.Errorf("not found component '%s' in map", componentID) 60 | } 61 | 62 | return c.result.assembleSortedResults(), nil 63 | } 64 | 65 | func (c *Imports) assembleComponentsMap(spec arch.Spec) map[string]arch.Component { 66 | results := make(map[string]arch.Component) 67 | 68 | for _, component := range spec.Components { 69 | results[component.Name.Value] = component 70 | } 71 | 72 | return results 73 | } 74 | 75 | func (c *Imports) checkFile(component arch.Component, file models.ProjectFile) error { 76 | for _, resolvedImport := range file.Imports { 77 | allowed, err := checkImport(component, resolvedImport, c.spec.Allow.DepOnAnyVendor.Value) 78 | if err != nil { 79 | return fmt.Errorf("failed check import '%s': %w", 80 | resolvedImport.Name, 81 | err, 82 | ) 83 | } 84 | 85 | if allowed { 86 | continue 87 | } 88 | 89 | c.result.addDependencyWarning(models.CheckArchWarningDependency{ 90 | Reference: resolvedImport.Reference, 91 | ComponentName: component.Name.Value, 92 | FileRelativePath: strings.TrimPrefix(file.Path, c.spec.RootDirectory.Value), 93 | FileAbsolutePath: file.Path, 94 | ResolvedImportName: resolvedImport.Name, 95 | }) 96 | } 97 | 98 | return nil 99 | } 100 | 101 | func checkImport( 102 | component arch.Component, 103 | resolvedImport models.ResolvedImport, 104 | allowDependOnAnyVendor bool, 105 | ) (bool, error) { 106 | switch resolvedImport.ImportType { 107 | case models.ImportTypeStdLib: 108 | return true, nil 109 | case models.ImportTypeVendor: 110 | if allowDependOnAnyVendor { 111 | return true, nil 112 | } 113 | 114 | return checkVendorImport(component, resolvedImport) 115 | case models.ImportTypeProject: 116 | return checkProjectImport(component, resolvedImport), nil 117 | default: 118 | panic(fmt.Sprintf("unknown import type: %+v", resolvedImport)) 119 | } 120 | } 121 | 122 | func checkVendorImport(component arch.Component, resolvedImport models.ResolvedImport) (bool, error) { 123 | if component.SpecialFlags.AllowAllVendorDeps.Value { 124 | return true, nil 125 | } 126 | 127 | for _, vendorGlob := range component.AllowedVendorGlobs { 128 | matched, err := vendorGlob.Value.Match(resolvedImport.Name) 129 | if err != nil { 130 | return false, models.NewReferableErr( 131 | fmt.Errorf("invalid vendor glob '%s': %w", 132 | string(vendorGlob.Value), 133 | err, 134 | ), 135 | vendorGlob.Reference, 136 | ) 137 | } 138 | 139 | if matched { 140 | return true, nil 141 | } 142 | } 143 | 144 | return false, nil 145 | } 146 | 147 | func checkProjectImport(component arch.Component, resolvedImport models.ResolvedImport) bool { 148 | if component.SpecialFlags.AllowAllProjectDeps.Value { 149 | return true 150 | } 151 | 152 | for _, allowedImportRef := range component.AllowedProjectImports { 153 | allowedImport := allowedImportRef.Value 154 | 155 | if allowedImport.ImportPath == resolvedImport.Name { 156 | return true 157 | } 158 | } 159 | 160 | return false 161 | } 162 | -------------------------------------------------------------------------------- /internal/services/schema/v1.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "id": "https://github.com/fe3dback/go-arch-lint/v1", 4 | "title": "Go Arch Lint V1", 5 | "type": "object", 6 | "description": "Arch file scheme version 1", 7 | "required": ["version", "components", "deps"], 8 | "additionalProperties": false, 9 | "properties": { 10 | "version": {"$ref": "#/definitions/version"}, 11 | "allow": {"$ref": "#/definitions/settings"}, 12 | "exclude": {"$ref": "#/definitions/exclude"}, 13 | "excludeFiles": {"$ref": "#/definitions/excludeFiles"}, 14 | "vendors": {"$ref": "#/definitions/vendors"}, 15 | "commonVendors": {"$ref": "#/definitions/commonVendors"}, 16 | "components": {"$ref": "#/definitions/components"}, 17 | "commonComponents": {"$ref": "#/definitions/commonComponents"}, 18 | "deps": {"$ref": "#/definitions/dependencies"} 19 | }, 20 | "definitions": { 21 | "version": { 22 | "title": "Scheme Version", 23 | "description": "Defines arch file syntax and file validation rules", 24 | "type": "integer", 25 | "minimum": 1, 26 | "maximum": 1 27 | }, 28 | "settings": { 29 | "title": "Global Scheme options", 30 | "type": "object", 31 | "additionalProperties": false, 32 | "properties": { 33 | "depOnAnyVendor": { 34 | "title": "Any project file can import any vendor lib", 35 | "type": "boolean" 36 | } 37 | } 38 | }, 39 | "exclude": { 40 | "title": "Excluded folders from analyse", 41 | "type": "array", 42 | "items": { 43 | "type": "string", 44 | "title": "relative path to project root" 45 | } 46 | }, 47 | "excludeFiles": { 48 | "title": "Excluded files from analyse matched by regexp", 49 | "description": "package will by excluded in all package files is matched by provided regexp's", 50 | "type": "array", 51 | "items": { 52 | "type": "string", 53 | "title": "regular expression for absolute file path matching", 54 | "x-intellij-language-injection": "regexp" 55 | } 56 | }, 57 | "vendors": { 58 | "title": "List of vendor libs", 59 | "type": "object", 60 | "additionalProperties": {"$ref": "#/definitions/vendor"} 61 | }, 62 | "vendor": { 63 | "type": "object", 64 | "required": ["in"], 65 | "properties": { 66 | "in": { 67 | "title": "full import path to vendor", 68 | "type": "string", 69 | "examples": ["golang.org/x/mod/modfile"] 70 | } 71 | }, 72 | "additionalProperties": false 73 | }, 74 | "commonVendors": { 75 | "title": "List of vendor names", 76 | "description": "All project packages can import this vendor libs", 77 | "type": "array", 78 | "items": { 79 | "type": "string", 80 | "title": "vendor name" 81 | } 82 | }, 83 | "components": { 84 | "title": "List of components", 85 | "type": "object", 86 | "additionalProperties": {"$ref": "#/definitions/component"} 87 | }, 88 | "component": { 89 | "type": "object", 90 | "required": ["in"], 91 | "properties": { 92 | "in": { 93 | "title": "relative path to project package", 94 | "description": "can contain glob for search", 95 | "type": "string", 96 | "examples": ["src/services", "src/services/*/repo", "src/*/services/**"] 97 | } 98 | }, 99 | "additionalProperties": false 100 | }, 101 | "commonComponents": { 102 | "title": "List of components names", 103 | "description": "All project packages can import this components, useful for utils packages like 'models'", 104 | "type": "array", 105 | "items": { 106 | "type": "string", 107 | "title": "component name" 108 | } 109 | }, 110 | "dependencies": { 111 | "title": "Dependency rules between spec and package imports", 112 | "type": "object", 113 | "additionalProperties": {"$ref": "#/definitions/dependencyRule"} 114 | }, 115 | "dependencyRule": { 116 | "type": "object", 117 | "properties": { 118 | "anyProjectDeps": { 119 | "title": "Allow import any project package?", 120 | "type": "boolean" 121 | }, 122 | "anyVendorDeps": { 123 | "title": "Allow import any vendor package?", 124 | "type": "boolean" 125 | }, 126 | "mayDependOn": { 127 | "title": "List of allowed components to import", 128 | "type": "array", 129 | "items": { 130 | "type": "string", 131 | "title": "component name" 132 | } 133 | }, 134 | "canUse": { 135 | "title": "List of allowed vendors to import", 136 | "type": "array", 137 | "items": { 138 | "type": "string", 139 | "title": "vendor name" 140 | } 141 | } 142 | }, 143 | "additionalProperties": false 144 | } 145 | } 146 | } -------------------------------------------------------------------------------- /internal/models/common/reference_test.go: -------------------------------------------------------------------------------- 1 | package common_test 2 | 3 | import ( 4 | "math" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/fe3dback/go-arch-lint/internal/models/common" 9 | ) 10 | 11 | func TestReference_ClampWithRealLinesCount(t *testing.T) { 12 | type args struct { 13 | line int 14 | regionHeight int 15 | maxLines int 16 | } 17 | 18 | tests := []struct { 19 | name string 20 | args args 21 | linesCount int 22 | wantLineFrom int 23 | wantLineMain int 24 | wantLineTo int 25 | }{ 26 | { 27 | name: "6 even", 28 | args: args{ 29 | line: 22, 30 | regionHeight: 6, 31 | maxLines: 37, 32 | }, 33 | wantLineFrom: 19, 34 | wantLineMain: 22, 35 | wantLineTo: 25, 36 | }, 37 | { 38 | name: "5 odd", 39 | args: args{ 40 | line: 22, 41 | regionHeight: 5, 42 | maxLines: 37, 43 | }, 44 | wantLineFrom: 19, 45 | wantLineMain: 22, 46 | wantLineTo: 24, 47 | }, 48 | { 49 | name: "1", 50 | args: args{ 51 | line: 1, 52 | regionHeight: 6, 53 | maxLines: 100, 54 | }, 55 | wantLineFrom: 1, 56 | wantLineMain: 1, 57 | wantLineTo: 4, 58 | }, 59 | { 60 | name: "2", 61 | args: args{ 62 | line: 2, 63 | regionHeight: 6, 64 | maxLines: 100, 65 | }, 66 | wantLineFrom: 1, 67 | wantLineMain: 2, 68 | wantLineTo: 5, 69 | }, 70 | { 71 | name: "3", 72 | args: args{ 73 | line: 3, 74 | regionHeight: 6, 75 | maxLines: 100, 76 | }, 77 | wantLineFrom: 1, 78 | wantLineMain: 3, 79 | wantLineTo: 6, 80 | }, 81 | { 82 | name: "4", 83 | args: args{ 84 | line: 4, 85 | regionHeight: 6, 86 | maxLines: 100, 87 | }, 88 | wantLineFrom: 1, 89 | wantLineMain: 4, 90 | wantLineTo: 7, 91 | }, 92 | { 93 | name: "5", 94 | args: args{ 95 | line: 5, 96 | regionHeight: 6, 97 | maxLines: 100, 98 | }, 99 | wantLineFrom: 2, 100 | wantLineMain: 5, 101 | wantLineTo: 8, 102 | }, 103 | { 104 | name: "-0", 105 | args: args{ 106 | line: 100, 107 | regionHeight: 6, 108 | maxLines: 100, 109 | }, 110 | wantLineFrom: 97, 111 | wantLineMain: 100, 112 | wantLineTo: 100, 113 | }, 114 | { 115 | name: "-1", 116 | args: args{ 117 | line: 99, 118 | regionHeight: 6, 119 | maxLines: 100, 120 | }, 121 | wantLineFrom: 96, 122 | wantLineMain: 99, 123 | wantLineTo: 100, 124 | }, 125 | { 126 | name: "-2", 127 | args: args{ 128 | line: 98, 129 | regionHeight: 6, 130 | maxLines: 100, 131 | }, 132 | wantLineFrom: 95, 133 | wantLineMain: 98, 134 | wantLineTo: 100, 135 | }, 136 | { 137 | name: "-4", 138 | args: args{ 139 | line: 96, 140 | regionHeight: 6, 141 | maxLines: 100, 142 | }, 143 | wantLineFrom: 93, 144 | wantLineMain: 96, 145 | wantLineTo: 99, 146 | }, 147 | { 148 | name: "zero", 149 | args: args{ 150 | line: 3, 151 | regionHeight: 0, 152 | maxLines: 10, 153 | }, 154 | wantLineFrom: 3, 155 | wantLineMain: 3, 156 | wantLineTo: 3, 157 | }, 158 | { 159 | name: "one", 160 | args: args{ 161 | line: 3, 162 | regionHeight: 1, 163 | maxLines: 10, 164 | }, 165 | wantLineFrom: 2, 166 | wantLineMain: 3, 167 | wantLineTo: 3, 168 | }, 169 | { 170 | name: "two", 171 | args: args{ 172 | line: 3, 173 | regionHeight: 2, 174 | maxLines: 10, 175 | }, 176 | wantLineFrom: 2, 177 | wantLineMain: 3, 178 | wantLineTo: 4, 179 | }, 180 | { 181 | name: "small height", 182 | args: args{ 183 | line: 2, 184 | regionHeight: 6, 185 | maxLines: 5, 186 | }, 187 | wantLineFrom: 1, 188 | wantLineMain: 2, 189 | wantLineTo: 5, 190 | }, 191 | { 192 | name: "main upper bound", 193 | args: args{ 194 | line: 15, 195 | regionHeight: 6, 196 | maxLines: 13, 197 | }, 198 | wantLineFrom: 12, 199 | wantLineMain: 13, 200 | wantLineTo: 13, 201 | }, 202 | { 203 | name: "main all bound", 204 | args: args{ 205 | line: 15, 206 | regionHeight: 6, 207 | maxLines: 8, 208 | }, 209 | wantLineFrom: 8, 210 | wantLineMain: 8, 211 | wantLineTo: 8, 212 | }, 213 | } 214 | for _, tt := range tests { 215 | t.Run(tt.name, func(t *testing.T) { 216 | ref := common.NewReferenceSingleLine("/tmp/dev", tt.args.line, 0) 217 | ref = ref.ExtendRange( 218 | int(math.Ceil(float64(tt.args.regionHeight)/2)), 219 | int(math.Floor(float64(tt.args.regionHeight)/2)), 220 | ) 221 | 222 | want := common.NewReferenceRange( 223 | "/tmp/dev", 224 | tt.wantLineFrom, 225 | tt.wantLineMain, 226 | tt.wantLineTo, 227 | ) 228 | 229 | if got := ref.ClampWithRealLinesCount(tt.args.maxLines); !reflect.DeepEqual(got, want) { 230 | t.Errorf("ClampWithRealLinesCount() = %v, want %v", got, want) 231 | } 232 | }) 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Logo image](./docs/images/logo.png) 2 | 3 | Linter used to enforce some good project structure and validate top level architecture (code layers) 4 | 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/fe3dback/go-arch-lint)](https://goreportcard.com/report/github.com/fe3dback/go-arch-lint) 6 | [![go-recipes](https://raw.githubusercontent.com/nikolaydubina/go-recipes/main/badge.svg?raw=true)](https://github.com/nikolaydubina/go-recipes) 7 | 8 | ## Quick start 9 | 10 | ### What exactly is project architecture? 11 | 12 | You can imagine some simple architecture, for example classic onion part from "clean architecture": 13 | 14 | ![Layouts example](./docs/images/layout_example.png) 15 | 16 | And describe/declare it as semantic yaml linter config: 17 | 18 | ```yaml 19 | version: 3 20 | workdir: internal 21 | components: 22 | handler: { in: handlers/* } # wildcard one level 23 | service: { in: services/** } # wildcard many levels 24 | repository: { in: domain/*/repository } # wildcard DDD repositories 25 | model: { in: models } # match exactly one package 26 | 27 | commonComponents: 28 | - models 29 | 30 | deps: 31 | handler: 32 | mayDependOn: 33 | - service 34 | service: 35 | mayDependOn: 36 | - repository 37 | ``` 38 | 39 | see [config syntax](docs/syntax/README.md) for details. 40 | 41 | And now linter will check all project code inside `internal` workdir 42 | and show warnings, when code violate this rules. 43 | 44 | For best experience you can add linter into CI workflow 45 | 46 | ### Example of broken code 47 | 48 | Imagine some `main.go`, when we provide `repository` into `handler` and get some bad 49 | flow: 50 | 51 | ```go 52 | func main() { 53 | // .. 54 | repository := booksRepository.NewRepository() 55 | handler := booksHandler.NewHandler( 56 | service, 57 | repository, // !!! 58 | ) 59 | // .. 60 | } 61 | ``` 62 | 63 | Linter will easily found this issue: 64 | 65 | ![Check stdout example](./docs/images/check-example.png) 66 | 67 | ### Install/Run 68 | 69 | #### Wia Docker 70 | 71 | ```bash 72 | docker run --rm -v ${PWD}:/app fe3dback/go-arch-lint:latest-stable-release check --project-path /app 73 | ``` 74 | 75 | [other docker tags and versions](https://hub.docker.com/r/fe3dback/go-arch-lint/tags) 76 | 77 | #### From sources 78 | It requires go 1.25+ 79 | 80 | ```bash 81 | go install github.com/fe3dback/go-arch-lint@latest 82 | ``` 83 | 84 | ```bash 85 | go-arch-lint check --project-path ~/code/my-project 86 | # or 87 | cd ~/code/my-project 88 | go-arch-lint check 89 | ``` 90 | 91 | #### Precompiled binaries 92 | 93 | [see on releases page](https://github.com/fe3dback/go-arch-lint/releases) 94 | 95 | ## Usage 96 | 97 | ### How to add linter to existing project? 98 | 99 | ![Adding linter steps](./docs/images/add-linter-steps.png) 100 | 101 | Adding a linter to a project takes several steps: 102 | 103 | 1. Current state of the project 104 | 2. Create a `.go-arch-lint.yml` file describing the ideal project architecture 105 | 3. Linter find some issues in the project. Don’t fix them right now, but “legalize” them by adding them to the config and marking `todo` with the label 106 | 4. In your free time, technical debt, etc. fix the code 107 | 5. After fixes, clean up config to target state 108 | 109 | ### Execute 110 | 111 | ``` 112 | Usage: 113 | go-arch-lint check [flags] 114 | 115 | Flags: 116 | --arch-file string arch file path (default ".go-arch-lint.yml") 117 | -h, --help help for check 118 | --max-warnings int max number of warnings to output (default 512) 119 | --project-path string absolute path to project directory (where '.go-arch-lint.yml' is located) (default "./") 120 | 121 | Global Flags: 122 | --json (alias for --output-type=json) 123 | --output-color use ANSI colors in terminal output (default true) 124 | --output-json-one-line format JSON as single line payload (without line breaks), only for json output type 125 | --output-type string type of command output, variants: [ascii, json] (default "default") 126 | ``` 127 | 128 | This linter will return: 129 | 130 | | Status Code | Description | 131 | |-------------|----------------------------------| 132 | | 0 | Project has correct architecture | 133 | | 1 | Found warnings | 134 | 135 | 136 | ### How is working? 137 | 138 | ![How is working](./docs/images/how-is-working.png) 139 | 140 | Linter will: 141 | - match/mark **go packages** with **components** 142 | - finds all dependencies between components 143 | - build a dependency graph 144 | - compares the actual (code) and desired (config) dependency graph 145 | - if it got a non-empty DIFF, then project has some issues 146 | 147 | ## Graph 148 | 149 | Example config of this repository: [.go-arch-lint.yml](.go-arch-lint.yml) 150 | 151 | ![graph](./docs/images/graph-example.png) 152 | 153 | You can generate dependencies graph with command `graph`: 154 | 155 | ```bash 156 | go-arch-lint graph 157 | ``` 158 | 159 | See full [graph documentation](docs/graph/README.md) for details. -------------------------------------------------------------------------------- /internal/services/checker/deepscan/searcher_parse_methods.go: -------------------------------------------------------------------------------- 1 | package deepscan 2 | 3 | import ( 4 | "fmt" 5 | "go/ast" 6 | "go/token" 7 | "go/types" 8 | 9 | "golang.org/x/tools/go/packages" 10 | ) 11 | 12 | func (s *Searcher) extractMethodsFromPackage(astPackage *packages.Package) ([]InjectionMethod, error) { 13 | result := make([]InjectionMethod, 0) 14 | 15 | for _, astFile := range astPackage.Syntax { 16 | methods, err := s.extractMethodsFromFile(astPackage, astFile) 17 | if err != nil { 18 | return nil, fmt.Errorf("failed extract public methods from '%s': %w", astFile.Name.String(), err) 19 | } 20 | 21 | result = append(result, methods...) 22 | } 23 | 24 | return result, nil 25 | } 26 | 27 | func (s *Searcher) extractMethodsFromFile(astPackage *packages.Package, astFile *ast.File) ([]InjectionMethod, error) { 28 | list := make([]InjectionMethod, 0) 29 | 30 | for _, iDecl := range astFile.Decls { 31 | decl, ok := iDecl.(*ast.FuncDecl) 32 | if !ok { 33 | // find only AST go methods, example: `func a()` 34 | continue 35 | } 36 | 37 | if !astIsPublicName(decl.Name.Name) { 38 | // exclude private methods 39 | continue 40 | } 41 | 42 | gates := s.extractMethodGates(astPackage, decl) 43 | if len(gates) == 0 { 44 | // this method not have interface params (gates) 45 | // so nothing can be injected into 46 | // and no reason for analyse it 47 | continue 48 | } 49 | 50 | list = append(list, InjectionMethod{ 51 | Name: decl.Name.Name, 52 | Definition: s.sourceFromToken(decl.Name.Pos()), 53 | Gates: gates, 54 | }) 55 | } 56 | 57 | return list, nil 58 | } 59 | 60 | func (s *Searcher) extractMethodGates(astPackage *packages.Package, method *ast.FuncDecl) []Gate { 61 | fields := method.Type.Params.List 62 | params := make([]Gate, 0, len(fields)) 63 | typeIndex := -1 64 | 65 | for _, field := range fields { 66 | paramType := astPackage.TypesInfo.TypeOf(field.Type) 67 | for _, fieldIdent := range field.Names { 68 | typeIndex++ 69 | 70 | if fieldIdent.Name == "_" { 71 | // argument not used, so we not use any 72 | // implementation logic, and not depend on it 73 | continue 74 | } 75 | 76 | interfaceName, pos, isInterface := s.extractInterfaceName(paramType) 77 | if !isInterface { 78 | continue 79 | } 80 | 81 | if !pos.IsValid() { 82 | // invalid pos, its anonymous `interface{}` 83 | // or some kind of this staff 84 | pos = field.Pos() 85 | } 86 | 87 | var isVariadic bool 88 | if _, ok := field.Type.(*ast.Ellipsis); ok { 89 | isVariadic = true 90 | } 91 | 92 | params = append(params, Gate{ 93 | MethodName: method.Name.Name, 94 | ParamName: fieldIdent.Name, 95 | Index: typeIndex, 96 | MethodDefinition: s.sourceFromToken(method.Pos()), 97 | ArgumentDefinition: s.sourceFromToken(field.Pos()), 98 | Interface: Interface{ 99 | Name: interfaceName, 100 | Definition: s.sourceFromToken(pos), 101 | GoType: paramType.String(), 102 | }, 103 | IsVariadic: isVariadic, 104 | }) 105 | } 106 | } 107 | 108 | return params 109 | } 110 | 111 | func (s *Searcher) extractInterfaceName(t types.Type) (name string, ref token.Pos, isInterface bool) { 112 | switch goType := t.(type) { 113 | // anon interfaces: `func(a interface{})` 114 | case *types.Interface: 115 | return t.String(), ref, true 116 | 117 | // named type or interface: `func(a myInterface)` or `func (a int)` 118 | case *types.Named: 119 | if _, _, isInterface := s.extractInterfaceName(goType.Underlying()); !isInterface { 120 | return "", ref, false 121 | } 122 | 123 | return goType.Obj().Name(), goType.Obj().Pos(), true 124 | 125 | // pointer to type: `func(a *int)`, possible can point to interface 126 | // but is useless in real code, so always skip this params 127 | case *types.Pointer: 128 | return "", ref, false 129 | 130 | case *types.Map: 131 | return s.extractInterfaceName(goType.Elem()) 132 | 133 | // `func(a []int)` or `func(a []myInterface)` 134 | // can be slice of interface, so need to check it 135 | case *types.Slice: 136 | return s.extractInterfaceName(goType.Elem()) 137 | 138 | // `func(a [5]int)` or `func(a [5]myInterface)` 139 | // can be array of interface, so need to check it 140 | case *types.Array: 141 | return s.extractInterfaceName(goType.Elem()) 142 | 143 | // `func(a chan int)` or `func(a chan myInterface)` 144 | // need to check operand, and where is interface 145 | // we have 3 possible chan options 146 | // <- chan :: its read only channel (implementation can be injected) 147 | // chan <- :: write only chanel (nothing can be injected) 148 | // chan :: r/w, possible can inject 149 | // if chan cannot inject anything, we skip it from analyse 150 | case *types.Chan: 151 | deepName, deepPos, isInterface := s.extractInterfaceName(goType.Elem()) 152 | if !isInterface { 153 | return "", ref, false 154 | } 155 | 156 | if goType.Dir() == types.SendOnly { 157 | // nothing be injected into write only interface 158 | return "", ref, false 159 | } 160 | 161 | return deepName, deepPos, true 162 | 163 | // not interface 164 | default: 165 | return "", ref, false 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /internal/services/spec/decoder/decoder_doc_v2.go: -------------------------------------------------------------------------------- 1 | package decoder 2 | 3 | import ( 4 | "github.com/fe3dback/go-arch-lint/internal/models" 5 | "github.com/fe3dback/go-arch-lint/internal/models/common" 6 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 7 | ) 8 | 9 | type ( 10 | // ArchV2 changes since ArchV1: 11 | // - added global "workdir" option 12 | // - vendor/component "in" now accept two types: (string, []string), instead of (string) 13 | ArchV2 struct { 14 | FVersion ref[int] `json:"version"` 15 | FWorkDir ref[string] `json:"workdir"` 16 | FAllow ArchV2Allow `json:"allow"` 17 | FExclude []ref[string] `json:"exclude"` 18 | FExcludeFilesRegExp []ref[string] `json:"excludeFiles"` 19 | FVendors map[spec.VendorName]ref[ArchV2Vendor] `json:"vendors"` 20 | FCommonVendors []ref[string] `json:"commonVendors"` 21 | FComponents map[spec.ComponentName]ref[ArchV2Component] `json:"components"` 22 | FCommonComponents []ref[string] `json:"commonComponents"` 23 | FDependencies map[spec.ComponentName]ref[ArchV2Rule] `json:"deps"` 24 | } 25 | 26 | ArchV2Allow struct { 27 | FDepOnAnyVendor ref[bool] `json:"depOnAnyVendor"` 28 | } 29 | 30 | ArchV2Vendor struct { 31 | FImportPaths stringList `json:"in"` 32 | } 33 | 34 | ArchV2Component struct { 35 | FLocalPaths stringList `json:"in"` 36 | } 37 | 38 | ArchV2Rule struct { 39 | FMayDependOn []ref[string] `json:"mayDependOn"` 40 | FCanUse []ref[string] `json:"canUse"` 41 | FAnyProjectDeps ref[bool] `json:"anyProjectDeps"` 42 | FAnyVendorDeps ref[bool] `json:"anyVendorDeps"` 43 | } 44 | ) 45 | 46 | func (a *ArchV2) postSetup() {} 47 | 48 | func (a *ArchV2) Version() common.Referable[int] { 49 | return castRef(a.FVersion) 50 | } 51 | 52 | func (a *ArchV2) WorkingDirectory() common.Referable[string] { 53 | // fallback from version 1 54 | actualWorkDirectory := "./" 55 | 56 | if a.FWorkDir.ref.Value != "" { 57 | actualWorkDirectory = a.FWorkDir.ref.Value 58 | } 59 | 60 | return common.NewReferable(actualWorkDirectory, a.FWorkDir.ref.Reference) 61 | } 62 | 63 | func (a *ArchV2) Options() spec.Options { 64 | return a.FAllow 65 | } 66 | 67 | func (a *ArchV2) ExcludedDirectories() []common.Referable[string] { 68 | return castRefList(a.FExclude) 69 | } 70 | 71 | func (a *ArchV2) ExcludedFilesRegExp() []common.Referable[string] { 72 | return castRefList(a.FExcludeFilesRegExp) 73 | } 74 | 75 | func (a *ArchV2) Vendors() spec.Vendors { 76 | casted := make(spec.Vendors, len(a.FVendors)) 77 | for name, vendor := range a.FVendors { 78 | casted[name] = common.NewReferable(spec.Vendor(vendor.ref.Value), vendor.ref.Reference) 79 | } 80 | 81 | return casted 82 | } 83 | 84 | func (a *ArchV2) CommonVendors() []common.Referable[string] { 85 | return castRefList(a.FCommonVendors) 86 | } 87 | 88 | func (a *ArchV2) Components() spec.Components { 89 | casted := make(spec.Components, len(a.FComponents)) 90 | for name, cmp := range a.FComponents { 91 | casted[name] = common.NewReferable(spec.Component(cmp.ref.Value), cmp.ref.Reference) 92 | } 93 | 94 | return casted 95 | } 96 | 97 | func (a *ArchV2) CommonComponents() []common.Referable[string] { 98 | return castRefList(a.FCommonComponents) 99 | } 100 | 101 | func (a *ArchV2) Dependencies() spec.Dependencies { 102 | casted := make(spec.Dependencies, len(a.FDependencies)) 103 | for name, dep := range a.FDependencies { 104 | casted[name] = common.NewReferable(spec.DependencyRule(dep.ref.Value), dep.ref.Reference) 105 | } 106 | 107 | return casted 108 | } 109 | 110 | // -- 111 | 112 | func (a ArchV2Allow) IsDependOnAnyVendor() common.Referable[bool] { 113 | return castRef(a.FDepOnAnyVendor) 114 | } 115 | 116 | func (a ArchV2Allow) DeepScan() common.Referable[bool] { 117 | return common.NewEmptyReferable(false) 118 | } 119 | 120 | func (a ArchV2Allow) IgnoreNotFoundComponents() common.Referable[bool] { 121 | return common.NewEmptyReferable(false) 122 | } 123 | 124 | // -- 125 | 126 | func (a ArchV2Vendor) ImportPaths() []models.Glob { 127 | casted := make([]models.Glob, 0, len(a.FImportPaths)) 128 | 129 | for _, path := range a.FImportPaths { 130 | casted = append(casted, models.Glob(path)) 131 | } 132 | 133 | return casted 134 | } 135 | 136 | // -- 137 | 138 | func (a ArchV2Component) RelativePaths() []models.Glob { 139 | casted := make([]models.Glob, 0, len(a.FLocalPaths)) 140 | 141 | for _, path := range a.FLocalPaths { 142 | casted = append(casted, models.Glob(path)) 143 | } 144 | 145 | return casted 146 | } 147 | 148 | // -- 149 | 150 | func (a ArchV2Rule) MayDependOn() []common.Referable[string] { 151 | return castRefList(a.FMayDependOn) 152 | } 153 | 154 | func (a ArchV2Rule) CanUse() []common.Referable[string] { 155 | return castRefList(a.FCanUse) 156 | } 157 | 158 | func (a ArchV2Rule) AnyProjectDeps() common.Referable[bool] { 159 | return castRef(a.FAnyProjectDeps) 160 | } 161 | 162 | func (a ArchV2Rule) AnyVendorDeps() common.Referable[bool] { 163 | return castRef(a.FAnyVendorDeps) 164 | } 165 | 166 | func (a ArchV2Rule) DeepScan() common.Referable[bool] { 167 | return common.NewEmptyReferable(false) 168 | } 169 | -------------------------------------------------------------------------------- /internal/services/render/renderer.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "strings" 9 | "text/template" 10 | 11 | "github.com/fe3dback/go-arch-lint/internal/models" 12 | ) 13 | 14 | const ( 15 | fnColorize = "colorize" 16 | fnTrimPrefix = "trimPrefix" 17 | fnTrimSuffix = "trimSuffix" 18 | fnTrimDef = "def" 19 | fnPadLeft = "padLeft" 20 | fnPadRight = "padRight" 21 | fnLinePrefix = "linePrefix" 22 | fnDir = "dir" 23 | fnPlus = "plus" 24 | fnMinus = "minus" 25 | fnConcat = "concat" 26 | ) 27 | 28 | type ( 29 | Renderer struct { 30 | colorPrinter colorPrinter 31 | referenceRender referenceRender 32 | outputType models.OutputType 33 | outputJSONOneLine bool 34 | asciiTemplates map[string]string 35 | } 36 | ) 37 | 38 | func NewRenderer( 39 | colorPrinter colorPrinter, 40 | referenceRender referenceRender, 41 | outputType models.OutputType, 42 | outputJSONOneLine bool, 43 | asciiTemplates map[string]string, 44 | ) *Renderer { 45 | return &Renderer{ 46 | colorPrinter: colorPrinter, 47 | referenceRender: referenceRender, 48 | outputType: outputType, 49 | outputJSONOneLine: outputJSONOneLine, 50 | asciiTemplates: asciiTemplates, 51 | } 52 | } 53 | 54 | func (r *Renderer) RenderModel(model interface{}, err error) error { 55 | if err != nil && !errors.Is(err, models.UserSpaceError{}) { 56 | var referableErr models.ReferableError 57 | if errors.As(err, &referableErr) { 58 | codePreview := r.referenceRender.SourceCode(referableErr.Reference().ExtendRange(1, 1), true, true) 59 | fmt.Printf("ERR: %s\n", err.Error()) 60 | fmt.Printf("------------\n") 61 | fmt.Printf("%s\n", codePreview) 62 | } 63 | 64 | return err 65 | } 66 | 67 | var renderErr error 68 | 69 | switch r.outputType { 70 | case models.OutputTypeJSON: 71 | renderErr = r.renderJSON(model) 72 | case models.OutputTypeASCII: 73 | renderErr = r.renderASCII(model) 74 | default: 75 | panic(fmt.Sprintf("failed to render: unknown output type: %s", r.outputType)) 76 | } 77 | 78 | if renderErr != nil { 79 | return fmt.Errorf("failed to render model: %w", renderErr) 80 | } 81 | 82 | return err 83 | } 84 | 85 | func (r *Renderer) renderASCII(model interface{}) error { 86 | templateName := fmt.Sprintf("%T", model) 87 | templateBuffer, exist := r.asciiTemplates[templateName] 88 | 89 | if !exist { 90 | return fmt.Errorf("ascii template for model '%s' not exist", templateName) 91 | } 92 | 93 | tpl, err := template. 94 | New(templateName). 95 | Funcs(map[string]interface{}{ 96 | fnColorize: r.asciiColorize, 97 | fnTrimPrefix: r.asciiTrimPrefix, 98 | fnTrimSuffix: r.asciiTrimSuffix, 99 | fnTrimDef: r.asciiDefaultValue, 100 | fnPadLeft: r.asciiPadLeft, 101 | fnPadRight: r.asciiPadRight, 102 | fnLinePrefix: r.asciiLinePrefix, 103 | fnDir: r.asciiPathDirectory, 104 | fnPlus: r.asciiPlus, 105 | fnMinus: r.asciiMinus, 106 | fnConcat: r.asciiConcat, 107 | }). 108 | Parse( 109 | preprocessRawASCIITemplate(templateBuffer), 110 | ) 111 | if err != nil { 112 | return fmt.Errorf("failed to render ascii view '%s': %w", templateName, err) 113 | } 114 | 115 | var buffer bytes.Buffer 116 | err = tpl.Execute(&buffer, model) 117 | if err != nil { 118 | return fmt.Errorf("failed to execute template '%s': %w", templateName, err) 119 | } 120 | 121 | fmt.Println(buffer.String()) 122 | return nil 123 | } 124 | 125 | func (r *Renderer) renderJSON(model interface{}) error { 126 | var jsonBuffer []byte 127 | var marshalErr error 128 | 129 | modelType, err := r.extractModelType(model) 130 | if err != nil { 131 | return fmt.Errorf("failed extract model type from '%T' (maybe not matched pattern: 'CmdXXXOut') : %w", model, err) 132 | } 133 | 134 | wrapperModel := struct { 135 | Type string `json:"Type"` 136 | Payload interface{} `json:"Payload"` 137 | }{ 138 | Type: modelType, 139 | Payload: model, 140 | } 141 | 142 | if r.outputJSONOneLine { 143 | jsonBuffer, marshalErr = json.Marshal(wrapperModel) 144 | } else { 145 | jsonBuffer, marshalErr = json.MarshalIndent(wrapperModel, "", " ") 146 | } 147 | 148 | if marshalErr != nil { 149 | return fmt.Errorf("failed to marshal payload '%v' to json: %w", model, marshalErr) 150 | } 151 | 152 | fmt.Println(string(jsonBuffer)) 153 | return nil 154 | } 155 | 156 | // Rename "anypackage.CmdXXXXOut" to "models.XXXX" 157 | // for back compatible with previous response version 158 | func (r *Renderer) extractModelType(model any) (string, error) { 159 | const expectedPrefix = "Cmd" 160 | const expectedSuffix = "Out" 161 | 162 | alias := fmt.Sprintf("%T", model) 163 | dotIndex := strings.Index(alias, ".") 164 | 165 | if dotIndex == -1 { 166 | return "", fmt.Errorf("DTO type '%s' without package name", alias) 167 | } 168 | 169 | dtoName := alias[dotIndex+1:] 170 | 171 | if !strings.HasPrefix(dtoName, expectedPrefix) { 172 | return "", fmt.Errorf("DTO name '%s' alias '%s' should has prefix '%s'", dtoName, alias, expectedPrefix) 173 | } 174 | 175 | if !strings.HasSuffix(dtoName, expectedSuffix) { 176 | return "", fmt.Errorf("DTO name '%s' alias '%s' should has suffix '%s'", dtoName, alias, expectedSuffix) 177 | } 178 | 179 | return fmt.Sprintf( 180 | "models.%s", 181 | strings.TrimPrefix(strings.TrimSuffix(dtoName, expectedSuffix), expectedPrefix), 182 | ), nil 183 | } 184 | -------------------------------------------------------------------------------- /internal/services/spec/assembler/assembler_ac_components.go: -------------------------------------------------------------------------------- 1 | package assembler 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | 7 | "github.com/fe3dback/go-arch-lint/internal/models" 8 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 9 | "github.com/fe3dback/go-arch-lint/internal/models/common" 10 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 11 | ) 12 | 13 | type ( 14 | componentsAssembler struct { 15 | resolver *resolver 16 | allowedProjectImportsAssembler *allowedProjectImportsAssembler 17 | allowedVendorImportsAssembler *allowedVendorImportsAssembler 18 | } 19 | ) 20 | 21 | func newComponentsAssembler( 22 | resolver *resolver, 23 | allowedProjectImportsAssembler *allowedProjectImportsAssembler, 24 | allowedVendorImportsAssembler *allowedVendorImportsAssembler, 25 | ) *componentsAssembler { 26 | return &componentsAssembler{ 27 | resolver: resolver, 28 | allowedProjectImportsAssembler: allowedProjectImportsAssembler, 29 | allowedVendorImportsAssembler: allowedVendorImportsAssembler, 30 | } 31 | } 32 | 33 | func (m *componentsAssembler) assemble(spec *arch.Spec, document spec.Document) error { 34 | for yamlName, yamlComponent := range document.Components() { 35 | component, err := m.assembleComponent(yamlName, yamlComponent, document) 36 | if err != nil { 37 | return fmt.Errorf("failed to assemble component '%s': %w", yamlName, err) 38 | } 39 | 40 | spec.Components = append(spec.Components, component) 41 | } 42 | 43 | return nil 44 | } 45 | 46 | func (m *componentsAssembler) assembleComponent( 47 | yamlName string, 48 | yamlComponent common.Referable[spec.Component], 49 | yamlDocument spec.Document, 50 | ) (arch.Component, error) { 51 | depMeta, hasDeps := yamlDocument.Dependencies()[yamlName] 52 | 53 | mayDependOn := make([]common.Referable[string], 0) 54 | canUse := make([]common.Referable[string], 0) 55 | deepScan := yamlDocument.Options().DeepScan() 56 | 57 | if hasDeps { 58 | mayDependOn = append(mayDependOn, depMeta.Value.MayDependOn()...) 59 | canUse = append(canUse, depMeta.Value.CanUse()...) 60 | deepScan = depMeta.Value.DeepScan() 61 | } 62 | 63 | cmp := arch.Component{ 64 | Name: common.NewReferable(yamlName, yamlComponent.Reference), 65 | MayDependOn: mayDependOn, 66 | CanUse: canUse, 67 | DeepScan: deepScan, 68 | } 69 | 70 | type enricher func() error 71 | enrichers := []enricher{ 72 | func() error { return m.enrichWithFlags(&cmp, yamlComponent, hasDeps, depMeta.Value) }, 73 | func() error { return m.enrichWithResolvedPaths(&cmp, yamlDocument, yamlName, yamlComponent) }, 74 | func() error { return m.enrichWithProjectImports(&cmp, yamlComponent, yamlDocument, mayDependOn) }, 75 | func() error { return m.enrichWithVendorGlobs(&cmp, yamlDocument, canUse) }, 76 | } 77 | 78 | for _, enrich := range enrichers { 79 | err := enrich() 80 | if err != nil { 81 | return arch.Component{}, fmt.Errorf("failed assemble component '%s', enrich '%T' err: %w", 82 | yamlName, 83 | enrich, 84 | err, 85 | ) 86 | } 87 | } 88 | 89 | return cmp, nil 90 | } 91 | 92 | func (m *componentsAssembler) enrichWithFlags( 93 | cmp *arch.Component, 94 | yamlComponent common.Referable[spec.Component], 95 | hasDeps bool, 96 | depMeta spec.DependencyRule, 97 | ) error { 98 | if hasDeps { 99 | cmp.SpecialFlags = arch.SpecialFlags{ 100 | AllowAllProjectDeps: depMeta.AnyProjectDeps(), 101 | AllowAllVendorDeps: depMeta.AnyVendorDeps(), 102 | } 103 | return nil 104 | } 105 | 106 | cmp.SpecialFlags = arch.SpecialFlags{ 107 | AllowAllProjectDeps: common.NewReferable(false, yamlComponent.Reference), 108 | AllowAllVendorDeps: common.NewReferable(false, yamlComponent.Reference), 109 | } 110 | 111 | return nil 112 | } 113 | 114 | func (m *componentsAssembler) enrichWithResolvedPaths( 115 | cmp *arch.Component, 116 | yamlDocument spec.Document, 117 | yamlName string, 118 | yamlComponent common.Referable[spec.Component], 119 | ) error { 120 | resolvedPaths := make([]common.Referable[models.ResolvedPath], 0) 121 | 122 | for _, componentIn := range yamlComponent.Value.RelativePaths() { 123 | tmpResolvedPath, err := m.resolver.resolveLocalGlobPath( 124 | path.Clean(fmt.Sprintf("%s/%s", 125 | yamlDocument.WorkingDirectory().Value, 126 | string(componentIn), 127 | )), 128 | ) 129 | if err != nil { 130 | return fmt.Errorf("failed to assemble component '%s' path '%s': %w", 131 | yamlName, 132 | componentIn, 133 | err, 134 | ) 135 | } 136 | 137 | wrappedPaths := wrap(yamlComponent.Reference, tmpResolvedPath) 138 | resolvedPaths = append(resolvedPaths, wrappedPaths...) 139 | } 140 | 141 | cmp.ResolvedPaths = resolvedPaths 142 | return nil 143 | } 144 | 145 | func (m *componentsAssembler) enrichWithProjectImports( 146 | cmp *arch.Component, 147 | yamlComponent common.Referable[spec.Component], 148 | yamlDocument spec.Document, 149 | mayDependOn []common.Referable[string], 150 | ) error { 151 | projectImports, err := m.allowedProjectImportsAssembler.assemble(yamlDocument, unwrap(mayDependOn)) 152 | if err != nil { 153 | return fmt.Errorf("failed to assemble component project imports: %w", err) 154 | } 155 | 156 | cmp.AllowedProjectImports = wrap(yamlComponent.Reference, projectImports) 157 | return nil 158 | } 159 | 160 | func (m *componentsAssembler) enrichWithVendorGlobs( 161 | cmp *arch.Component, 162 | yamlDocument spec.Document, 163 | canUse []common.Referable[string], 164 | ) error { 165 | vendorGlobs, err := m.allowedVendorImportsAssembler.assemble(yamlDocument, unwrap(canUse)) 166 | if err != nil { 167 | return fmt.Errorf("failed to assemble component vendor imports: %w", err) 168 | } 169 | 170 | cmp.AllowedVendorGlobs = vendorGlobs 171 | return nil 172 | } 173 | -------------------------------------------------------------------------------- /internal/operations/check/operation.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sort" 7 | 8 | "github.com/fe3dback/go-arch-lint/internal/models" 9 | "github.com/fe3dback/go-arch-lint/internal/models/arch" 10 | ) 11 | 12 | type ( 13 | Operation struct { 14 | projectInfoAssembler projectInfoAssembler 15 | specAssembler specAssembler 16 | specChecker specChecker 17 | referenceRender referenceRender 18 | highlightCodePreview bool 19 | } 20 | 21 | limiterResult struct { 22 | results models.CheckResult 23 | omittedCount int 24 | } 25 | ) 26 | 27 | func NewOperation( 28 | projectInfoAssembler projectInfoAssembler, 29 | specAssembler specAssembler, 30 | specChecker specChecker, 31 | referenceRender referenceRender, 32 | highlightCodePreview bool, 33 | ) *Operation { 34 | return &Operation{ 35 | projectInfoAssembler: projectInfoAssembler, 36 | specAssembler: specAssembler, 37 | specChecker: specChecker, 38 | referenceRender: referenceRender, 39 | highlightCodePreview: highlightCodePreview, 40 | } 41 | } 42 | 43 | func (o *Operation) Behave(ctx context.Context, in models.CmdCheckIn) (models.CmdCheckOut, error) { 44 | projectInfo, err := o.projectInfoAssembler.ProjectInfo(in.ProjectPath, in.ArchFile) 45 | if err != nil { 46 | return models.CmdCheckOut{}, fmt.Errorf("failed to assemble project info: %w", err) 47 | } 48 | 49 | spec, err := o.specAssembler.Assemble(projectInfo) 50 | if err != nil { 51 | return models.CmdCheckOut{}, fmt.Errorf("failed to assemble spec: %w", err) 52 | } 53 | 54 | result := models.CheckResult{} 55 | if len(spec.Integrity.DocumentNotices) == 0 { 56 | result, err = o.specChecker.Check(ctx, spec) 57 | if err != nil { 58 | return models.CmdCheckOut{}, fmt.Errorf("failed to check project deps: %w", err) 59 | } 60 | } 61 | 62 | limitedResult := o.limitResults(result, in.MaxWarnings) 63 | 64 | model := models.CmdCheckOut{ 65 | ModuleName: spec.ModuleName.Value, 66 | DocumentNotices: o.assembleNotice(spec.Integrity), 67 | ArchHasWarnings: o.resultsHasWarnings(limitedResult.results), 68 | ArchWarningsDependency: limitedResult.results.DependencyWarnings, 69 | ArchWarningsMatch: limitedResult.results.MatchWarnings, 70 | ArchWarningsDeepScan: limitedResult.results.DeepscanWarnings, 71 | OmittedCount: limitedResult.omittedCount, 72 | Qualities: []models.CheckQuality{ 73 | { 74 | ID: "component_imports", 75 | Name: "Base: component imports", 76 | Used: len(spec.Components) > 0, 77 | Hint: "always on", 78 | }, 79 | { 80 | ID: "vendor_imports", 81 | Name: "Advanced: vendor imports", 82 | Used: !spec.Allow.DepOnAnyVendor.Value, 83 | Hint: "switch 'allow.depOnAnyVendor = false' (or delete) to on", 84 | }, 85 | { 86 | ID: "deepscan", 87 | Name: "Advanced: method calls and dependency injections", 88 | Used: spec.Allow.DeepScan.Value, 89 | Hint: "switch 'allow.deepScan = true' (or delete) to on", 90 | }, 91 | }, 92 | } 93 | 94 | if model.ArchHasWarnings || len(model.DocumentNotices) > 0 { 95 | // normal output with exit code 1 96 | return model, models.NewUserSpaceError("check not successful") 97 | } 98 | 99 | return model, nil 100 | } 101 | 102 | func (o *Operation) limitResults(result models.CheckResult, maxWarnings int) limiterResult { 103 | passCount := 0 104 | limitedResults := models.CheckResult{ 105 | DependencyWarnings: []models.CheckArchWarningDependency{}, 106 | MatchWarnings: []models.CheckArchWarningMatch{}, 107 | DeepscanWarnings: []models.CheckArchWarningDeepscan{}, 108 | } 109 | 110 | // append deps 111 | for _, notice := range result.DependencyWarnings { 112 | if passCount >= maxWarnings { 113 | break 114 | } 115 | 116 | limitedResults.DependencyWarnings = append(limitedResults.DependencyWarnings, notice) 117 | passCount++ 118 | } 119 | 120 | // append not matched 121 | for _, notice := range result.MatchWarnings { 122 | if passCount >= maxWarnings { 123 | break 124 | } 125 | 126 | limitedResults.MatchWarnings = append(limitedResults.MatchWarnings, notice) 127 | passCount++ 128 | } 129 | 130 | // append deep scan 131 | for _, notice := range result.DeepscanWarnings { 132 | if passCount >= maxWarnings { 133 | break 134 | } 135 | 136 | limitedResults.DeepscanWarnings = append(limitedResults.DeepscanWarnings, notice) 137 | passCount++ 138 | } 139 | 140 | totalCount := 0 + 141 | len(result.DeepscanWarnings) + 142 | len(result.DependencyWarnings) + 143 | len(result.MatchWarnings) 144 | 145 | return limiterResult{ 146 | results: limitedResults, 147 | omittedCount: totalCount - passCount, 148 | } 149 | } 150 | 151 | func (o *Operation) resultsHasWarnings(result models.CheckResult) bool { 152 | if len(result.DependencyWarnings) > 0 { 153 | return true 154 | } 155 | 156 | if len(result.MatchWarnings) > 0 { 157 | return true 158 | } 159 | 160 | if len(result.DeepscanWarnings) > 0 { 161 | return true 162 | } 163 | 164 | return false 165 | } 166 | 167 | func (o *Operation) assembleNotice(integrity arch.Integrity) []models.CheckNotice { 168 | notices := make([]arch.Notice, 0) 169 | notices = append(notices, integrity.DocumentNotices...) 170 | 171 | results := make([]models.CheckNotice, 0) 172 | for _, notice := range notices { 173 | results = append(results, models.CheckNotice{ 174 | Text: fmt.Sprintf("%s", notice.Notice), 175 | File: notice.Ref.File, 176 | Line: notice.Ref.Line, 177 | Column: notice.Ref.Column, 178 | SourceCodePreview: o.referenceRender.SourceCode( 179 | notice.Ref.ExtendRange(1, 1), 180 | o.highlightCodePreview, 181 | true, 182 | ), 183 | }) 184 | } 185 | 186 | sort.Slice(results, func(i, j int) bool { 187 | sI := results[i] 188 | sJ := results[j] 189 | 190 | if sI.File == sJ.File { 191 | return sI.Line < sJ.Line 192 | } 193 | 194 | return sI.File < sJ.File 195 | }) 196 | 197 | return results 198 | } 199 | -------------------------------------------------------------------------------- /internal/services/spec/decoder/decoder_doc_v3.go: -------------------------------------------------------------------------------- 1 | package decoder 2 | 3 | import ( 4 | "github.com/fe3dback/go-arch-lint/internal/models" 5 | "github.com/fe3dback/go-arch-lint/internal/models/common" 6 | "github.com/fe3dback/go-arch-lint/internal/services/spec" 7 | ) 8 | 9 | type ( 10 | // ArchV3 changes since ArchV2: 11 | // - added deepScan option in allow and deps rules 12 | ArchV3 struct { 13 | FVersion ref[int] `json:"version"` 14 | FWorkDir ref[string] `json:"workdir"` 15 | FAllow ArchV3Allow `json:"allow"` 16 | FExclude []ref[string] `json:"exclude"` 17 | FExcludeFilesRegExp []ref[string] `json:"excludeFiles"` 18 | FVendors map[spec.VendorName]ref[ArchV3Vendor] `json:"vendors"` 19 | FCommonVendors []ref[string] `json:"commonVendors"` 20 | FComponents map[spec.ComponentName]ref[ArchV3Component] `json:"components"` 21 | FCommonComponents []ref[string] `json:"commonComponents"` 22 | FDependencies map[spec.ComponentName]ref[ArchV3Rule] `json:"deps"` 23 | } 24 | 25 | ArchV3Allow struct { 26 | FDepOnAnyVendor ref[bool] `json:"depOnAnyVendor"` 27 | FDeepScan ref[bool] `json:"deepScan"` 28 | FIgnoreNotFoundComponents ref[bool] `json:"ignoreNotFoundComponents"` 29 | } 30 | 31 | ArchV3Vendor struct { 32 | FImportPaths stringList `json:"in"` 33 | } 34 | 35 | ArchV3Component struct { 36 | FLocalPaths stringList `json:"in"` 37 | } 38 | 39 | ArchV3Rule struct { 40 | FMayDependOn []ref[string] `json:"mayDependOn"` 41 | FCanUse []ref[string] `json:"canUse"` 42 | FAnyProjectDeps ref[bool] `json:"anyProjectDeps"` 43 | FAnyVendorDeps ref[bool] `json:"anyVendorDeps"` 44 | FDeepScan ref[bool] `json:"deepScan"` 45 | } 46 | ) 47 | 48 | func (a *ArchV3) postSetup() { 49 | // deep scan nesting (global settings -> local settings) 50 | for depName := range a.FDependencies { 51 | localDeepScan := a.FDependencies[depName].ref.Value.FDeepScan 52 | 53 | if !localDeepScan.defined { 54 | dep := a.FDependencies[depName] 55 | dep.ref.Value.FDeepScan = ref[bool]{ 56 | defined: true, 57 | ref: a.FAllow.DeepScan(), 58 | } 59 | 60 | a.FDependencies[depName] = dep 61 | } 62 | } 63 | } 64 | 65 | func (a *ArchV3) Version() common.Referable[int] { 66 | return castRef(a.FVersion) 67 | } 68 | 69 | func (a *ArchV3) WorkingDirectory() common.Referable[string] { 70 | // fallback from version 1 71 | actualWorkDirectory := "./" 72 | 73 | if a.FWorkDir.ref.Value != "" { 74 | actualWorkDirectory = a.FWorkDir.ref.Value 75 | } 76 | 77 | return common.NewReferable(actualWorkDirectory, a.FWorkDir.ref.Reference) 78 | } 79 | 80 | func (a *ArchV3) Options() spec.Options { 81 | return a.FAllow 82 | } 83 | 84 | func (a *ArchV3) ExcludedDirectories() []common.Referable[string] { 85 | return castRefList(a.FExclude) 86 | } 87 | 88 | func (a *ArchV3) ExcludedFilesRegExp() []common.Referable[string] { 89 | return castRefList(a.FExcludeFilesRegExp) 90 | } 91 | 92 | func (a *ArchV3) Vendors() spec.Vendors { 93 | casted := make(spec.Vendors, len(a.FVendors)) 94 | for name, vendor := range a.FVendors { 95 | casted[name] = common.NewReferable(spec.Vendor(vendor.ref.Value), vendor.ref.Reference) 96 | } 97 | 98 | return casted 99 | } 100 | 101 | func (a *ArchV3) CommonVendors() []common.Referable[string] { 102 | return castRefList(a.FCommonVendors) 103 | } 104 | 105 | func (a *ArchV3) Components() spec.Components { 106 | casted := make(spec.Components, len(a.FComponents)) 107 | for name, cmp := range a.FComponents { 108 | casted[name] = common.NewReferable(spec.Component(cmp.ref.Value), cmp.ref.Reference) 109 | } 110 | 111 | return casted 112 | } 113 | 114 | func (a *ArchV3) CommonComponents() []common.Referable[string] { 115 | return castRefList(a.FCommonComponents) 116 | } 117 | 118 | func (a *ArchV3) Dependencies() spec.Dependencies { 119 | casted := make(spec.Dependencies, len(a.FDependencies)) 120 | for name, dep := range a.FDependencies { 121 | casted[name] = common.NewReferable(spec.DependencyRule(dep.ref.Value), dep.ref.Reference) 122 | } 123 | 124 | return casted 125 | } 126 | 127 | // -- 128 | 129 | func (a ArchV3Allow) IsDependOnAnyVendor() common.Referable[bool] { 130 | return castRef(a.FDepOnAnyVendor) 131 | } 132 | 133 | func (a ArchV3Allow) DeepScan() common.Referable[bool] { 134 | if a.FDeepScan.defined { 135 | return a.FDeepScan.ref 136 | } 137 | 138 | // be default it`s on from V3+ 139 | return common.NewEmptyReferable(true) 140 | } 141 | 142 | func (a ArchV3Allow) IgnoreNotFoundComponents() common.Referable[bool] { 143 | if a.FIgnoreNotFoundComponents.defined { 144 | return a.FIgnoreNotFoundComponents.ref 145 | } 146 | 147 | // disabled by default 148 | return common.NewEmptyReferable(false) 149 | } 150 | 151 | // -- 152 | 153 | func (a ArchV3Vendor) ImportPaths() []models.Glob { 154 | casted := make([]models.Glob, 0, len(a.FImportPaths)) 155 | 156 | for _, path := range a.FImportPaths { 157 | casted = append(casted, models.Glob(path)) 158 | } 159 | 160 | return casted 161 | } 162 | 163 | // -- 164 | 165 | func (a ArchV3Component) RelativePaths() []models.Glob { 166 | casted := make([]models.Glob, 0, len(a.FLocalPaths)) 167 | 168 | for _, path := range a.FLocalPaths { 169 | casted = append(casted, models.Glob(path)) 170 | } 171 | 172 | return casted 173 | } 174 | 175 | // -- 176 | 177 | func (a ArchV3Rule) MayDependOn() []common.Referable[string] { 178 | return castRefList(a.FMayDependOn) 179 | } 180 | 181 | func (a ArchV3Rule) CanUse() []common.Referable[string] { 182 | return castRefList(a.FCanUse) 183 | } 184 | 185 | func (a ArchV3Rule) AnyProjectDeps() common.Referable[bool] { 186 | return castRef(a.FAnyProjectDeps) 187 | } 188 | 189 | func (a ArchV3Rule) AnyVendorDeps() common.Referable[bool] { 190 | return castRef(a.FAnyVendorDeps) 191 | } 192 | 193 | func (a ArchV3Rule) DeepScan() common.Referable[bool] { 194 | return a.FDeepScan.ref 195 | } 196 | --------------------------------------------------------------------------------