From c02d3b37a6653766d4047c203a1397065f385964 Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 25 Feb 2025 22:17:03 +0300 Subject: [PATCH] Refactor --- errors/enum.go | 59 ++++ errors/errors.go | 57 +++ gitlabcivalidator/errors.go | 34 -- gitlabcivalidator/models.go | 32 +- gitlabcivalidator/parser.go | 59 ++++ gitlabcivalidator/validator.go | 629 ++++++++++++++++++++++++++------- main.go | 7 +- 7 files changed, 710 insertions(+), 167 deletions(-) create mode 100644 errors/enum.go create mode 100644 errors/errors.go delete mode 100644 gitlabcivalidator/errors.go diff --git a/errors/enum.go b/errors/enum.go new file mode 100644 index 0000000..7ff4a64 --- /dev/null +++ b/errors/enum.go @@ -0,0 +1,59 @@ +package errors + +// ErrorCode — тип для кода ошибки. +type ErrorCode string + +// Перечисление кодов ошибок для валидации. +const ( + // Общие ошибки + ErrEmptyFile ErrorCode = "EMPTY_FILE" // файл пуст или отсутствует + ErrYamlSyntax ErrorCode = "YAML_SYNTAX_ERROR" // ошибка синтаксического анализа YAML + // Ошибки, связанные с job + ErrJobNameBlank ErrorCode = "JOB_NAME_BLANK" // имя задачи пустое + ErrMissingScript ErrorCode = "MISSING_SCRIPT" // отсутствует обязательное поле script (или run) + ErrBothScriptAndRun ErrorCode = "BOTH_SCRIPT_RUN" // одновременно присутствуют script и run + ErrJobStageNotExist ErrorCode = "JOB_STAGE_NOT_EXIST" // указан stage, которого нет в списке разрешённых + ErrUndefinedDependency ErrorCode = "UNDEFINED_DEPENDENCY" // зависимость не определена в списке job'ов + ErrInvalidStageOrder ErrorCode = "INVALID_STAGE_ORDER" // зависимость имеет stage, который идёт позже, чем у задачи + ErrDuplicateNeeds ErrorCode = "DUPLICATE_NEEDS" // дублирующиеся записи в needs + ErrUndefinedNeed ErrorCode = "UNDEFINED_NEED" // need ссылается на несуществующую задачу + ErrNeedNameTooLong ErrorCode = "NEED_NAME_TOO_LONG" // имя need превышает допустимую длину + ErrNoVisibleJob ErrorCode = "NO_VISIBLE_JOB" // нет ни одной видимой задачи + ErrMissingJobs ErrorCode = "MISSING_JOBS" + ErrArtifactsPathsBlank ErrorCode = "ARTIFACTS_PATHS_BLANK" // отсутствует или пустой блок paths в artifacts + + // Changes + ErrChangesNotArrayOfStrings ErrorCode = "CHANGES_NOT_ARRAY_OF_STRINGS" + ErrChangesInvalidType ErrorCode = "CHANGES_INVALID_TYPE" + ErrChangesTooManyEntries ErrorCode = "CHANGES_TOO_MANY_ENTRIES" + ErrChangesMissingPaths ErrorCode = "CHANGES_MISSING_PATHS" + + // Paths + ErrPathsNotArrayOfStrings ErrorCode = "PATHS_NOT_ARRAY_OF_STRINGS" + + // Job delayed parameters + ErrStartInMissing ErrorCode = "START_IN_MISSING" + ErrStartInInvalid ErrorCode = "START_IN_INVALID" + ErrStartInTooLong ErrorCode = "START_IN_TOO_LONG" + ErrStartInMustBeBlank ErrorCode = "START_IN_MUST_BE_BLANK" + + // Dependencies / Needs consistency + ErrDependencyNotInNeeds ErrorCode = "DEPENDENCY_NOT_IN_NEEDS" + + // Rules validation + ErrRulesOnlyExcept ErrorCode = "RULES_ONLY_EXCEPT" + ErrRulesOnly ErrorCode = "RULES_ONLY" + ErrRulesExcept ErrorCode = "RULES_EXCEPT" + ErrInvalidExpressionSyntax ErrorCode = "INVALID_EXPRESSION_SYNTAX" + ErrUnknownRulesKey ErrorCode = "UNKNOWN_RULES_KEY" + + ErrBeforeScriptInvalid ErrorCode = "BEFORE_SCRIPT_INVALID" + ErrServiceInvalid ErrorCode = "SERVICE_INVALID" + ErrStageInvalid ErrorCode = "STAGE_INVALID" + ErrVariablesInvalid ErrorCode = "VARIABLES_INVALID" + ErrVariablesInvalidKey ErrorCode = "VARIABLES_INVALID_KEY" + + ErrCyclicDependency ErrorCode = "CYCLIC_DEPENDENCY" // обнаружена циклическая зависимость + ErrBooleanValue ErrorCode = "BOOLEAN_VALUE" // значение должно быть булевым + ErrIncludeRulesInvalid ErrorCode = "INCLUDE_RULES_INVALID" // значение exists или changes не является строкой или массивом строк +) diff --git a/errors/errors.go b/errors/errors.go new file mode 100644 index 0000000..e64f435 --- /dev/null +++ b/errors/errors.go @@ -0,0 +1,57 @@ +package errors + +import ( + "fmt" + "strings" +) + +// ValidationErrorDetail хранит данные об одной ошибке. +type ValidationErrorDetail struct { + Code ErrorCode // Код ошибки + Message string // Текст сообщения + Line int // Номер строки, где возникла ошибка + Column int // Позиция в строке + EndLine int // Конечная строка + EndColumn int // Конечный столбец +} + +// ValidationError представляет совокупность ошибок валидации. +type ValidationError struct { + Errors []ValidationErrorDetail +} + +// Error возвращает строковое представление всех ошибок. +func (ve *ValidationError) Error() string { + var sb strings.Builder + sb.WriteString("Найдены ошибки валидации:\n") + for i, errDetail := range ve.Errors { + sb.WriteString(fmt.Sprintf("%d) [%s] (Line %d, Col %d): %s\n", i+1, errDetail.Code, errDetail.Line, errDetail.Column, errDetail.Message)) + } + return sb.String() +} + +// AddDetail добавляет новую ошибку в список. +func (ve *ValidationError) AddDetail(code ErrorCode, message string, line, column int) { + ve.Errors = append(ve.Errors, ValidationErrorDetail{ + Code: code, + Message: message, + Line: line, + Column: column, + }) +} + +func (ve *ValidationError) AddDetailWithSpan(code ErrorCode, message string, line, col, endLine, endCol int) { + ve.Errors = append(ve.Errors, ValidationErrorDetail{ + Code: code, + Message: message, + Line: line, + Column: col, + EndLine: endLine, + EndColumn: endCol, + }) +} + +// IsEmpty проверяет, имеются ли ошибки. +func (ve *ValidationError) IsEmpty() bool { + return len(ve.Errors) == 0 +} diff --git a/gitlabcivalidator/errors.go b/gitlabcivalidator/errors.go deleted file mode 100644 index 45e3876..0000000 --- a/gitlabcivalidator/errors.go +++ /dev/null @@ -1,34 +0,0 @@ -package gitlabcivalidator - -import ( - "fmt" - "strings" -) - -// ValidationError представляет собой совокупную ошибку, -// содержащую несколько сообщений об ошибках. -type ValidationError struct { - Errors []error -} - -func (ve *ValidationError) Error() string { - var sb strings.Builder - sb.WriteString("Found validation errors:\n") - for i, err := range ve.Errors { - sb.WriteString(fmt.Sprintf("%d) %s\n", i+1, err.Error())) - } - return sb.String() -} - -// Add добавляет новую ошибку во внутренний срез ValidationError. -func (ve *ValidationError) Add(err error) { - if err == nil { - return - } - ve.Errors = append(ve.Errors, err) -} - -// IsEmpty проверяет, есть ли в ValidationError какие-либо ошибки. -func (ve *ValidationError) IsEmpty() bool { - return len(ve.Errors) == 0 -} \ No newline at end of file diff --git a/gitlabcivalidator/models.go b/gitlabcivalidator/models.go index 6eddc72..528da9e 100644 --- a/gitlabcivalidator/models.go +++ b/gitlabcivalidator/models.go @@ -19,35 +19,49 @@ type GitLabCIConfig struct { Cache interface{} `yaml:"cache,omitempty"` Workflow interface{} `yaml:"workflow,omitempty"` Jobs map[string]*Job `yaml:"jobs,omitempty"` + + Changes interface{} `yaml:"changes,omitempty"` + Paths interface{} `yaml:"paths,omitempty"` // В реальном GitLab CI конфигурация может позволять // описывать job'ы не только в секции "jobs", но и на верхнем уровне. // Для упрощения в этом примере предполагаем, что все job'ы // расположены под ключом "jobs". } +type Artifacts struct { + Paths []string `yaml:"paths"` +} + // Job описывает структуру задачи (job). type Job struct { Name string `yaml:"-"` Stage string `yaml:"stage,omitempty"` Dependencies []string `yaml:"dependencies,omitempty"` - Needs []*Need `yaml:"needs,omitempty"` - Script interface{} `yaml:"script,omitempty"` // может быть строкой или списком строк - Run interface{} `yaml:"run,omitempty"` // для альтернативного описания шагов - Rules interface{} `yaml:"rules,omitempty"` // для динамических правил + Needs []string `yaml:"-"` // отключаем автоматическое декодирование + Script interface{} `yaml:"script,omitempty"` // может быть строкой или списком строк + Trigger interface{} `yaml:"trigger,omitempty"` // для описания trigger + Run interface{} `yaml:"run,omitempty"` // для альтернативного описания шагов + Rules interface{} `yaml:"rules,omitempty"` // для динамических правил + Artifacts *Artifacts `yaml:"artifacts,omitempty"` // блок artifacts + // ... могут быть и другие поля + When string `yaml:"when,omitempty"` + StartIn string `yaml:"start_in,omitempty"` + Only interface{} `yaml:"only,omitempty"` + Except interface{} `yaml:"except,omitempty"` + Line int `yaml:"-"` // Строка в файле Column int `yaml:"-"` // Позиция в строке DependencyLines map[string]struct { Line int Column int } `yaml:"-"` // Строки и позиции зависимостей -} -// Need описывает одну зависимость в разделе needs -type Need struct { - Name string `yaml:"name"` - // могут быть и другие поля, напр. "artifacts: true/false" и т.д. + NeedLines map[string]struct { + Line int + Column int + } `yaml:"-"` } // MaxJobNameLength здесь зададим некоторые константы, используемые для валидации diff --git a/gitlabcivalidator/parser.go b/gitlabcivalidator/parser.go index 657a262..c7625a3 100644 --- a/gitlabcivalidator/parser.go +++ b/gitlabcivalidator/parser.go @@ -29,6 +29,12 @@ func ParseGitLabCIConfig(data []byte) (*GitLabCIConfig, error) { // Основной объект — первый дочерний элемент mainNode := root.Content[0] + // Здесь выполняем проверку ключей + unknownKeys := findUnknownRootKeys(mainNode) + if len(unknownKeys) > 0 { + return nil, fmt.Errorf("contains unknown keys: %v", unknownKeys) + } + // Декодируем root-объект в структуру err = mainNode.Decode(&config) if err != nil { @@ -76,6 +82,26 @@ func ParseGitLabCIConfig(data []byte) (*GitLabCIConfig, error) { }{depNode.Line, depNode.Column} } } + + if depKey.Value == "needs" && depVal.Kind == yaml.SequenceNode { + // Инициализируем NeedLines, если ещё не инициализировано. + job.NeedLines = make(map[string]struct { + Line int + Column int + }) + for k := 0; k < len(depVal.Content); k++ { + needNode := depVal.Content[k] + job.Needs = append(job.Needs, needNode.Value) + job.NeedLines[needNode.Value] = struct { + Line int + Column int + }{ + Line: needNode.Line, + Column: needNode.Column, + } + } + } + } // Сохраняем job в конфигурации @@ -87,3 +113,36 @@ func ParseGitLabCIConfig(data []byte) (*GitLabCIConfig, error) { return &config, nil } + +func findUnknownRootKeys(mainNode *yaml.Node) []string { + // Разрешённые ключи корневого объекта + allowedKeys := map[string]struct{}{ + "default": {}, + "include": {}, + "before_script": {}, + "image": {}, + "services": {}, + "after_script": {}, + "variables": {}, + "stages": {}, + "cache": {}, + "workflow": {}, + "jobs": {}, + "changes": {}, + "paths": {}, + } + + var unknownKeys []string + if mainNode.Kind != yaml.MappingNode { + return unknownKeys + } + + // В YAML Mapping узле ключи находятся в нечетных индексах + for i := 0; i < len(mainNode.Content)-1; i += 2 { + keyNode := mainNode.Content[i] + if _, ok := allowedKeys[keyNode.Value]; !ok { + unknownKeys = append(unknownKeys, keyNode.Value) + } + } + return unknownKeys +} diff --git a/gitlabcivalidator/validator.go b/gitlabcivalidator/validator.go index b68f4b0..725da70 100644 --- a/gitlabcivalidator/validator.go +++ b/gitlabcivalidator/validator.go @@ -1,44 +1,63 @@ package gitlabcivalidator import ( - "errors" "fmt" + "gitflame.ru/CI_VLDR/errors" + "strings" + "time" ) +// computeEndPosition возвращает конечные координаты для фрагмента, предполагая, что текст находится на одной строке. +func computeEndPosition(startLine, startColumn int, text string) (endLine, endColumn int) { + // Если текст пустой, выделим хотя бы один символ + if len(text) == 0 { + return startLine, startColumn + 1 + } + return startLine, startColumn + len(text) +} + // ValidateGitLabCIFile — главная точка входа в библиотеку. // Принимает на вход содержимое GitLab CI-файла (в формате YAML) и опции валидации. // Возвращает ошибку, если есть проблемы в конфигурации (или nil, если всё хорошо). func ValidateGitLabCIFile(content string, opts ValidationOptions) error { - validationErr := &ValidationError{} + validationErr := &errors.ValidationError{} // 1) Проверяем, что содержимое не пустое if len(content) == 0 { - validationErr.Add(errors.New("файл .gitlab-ci.yml пуст или отсутствует")) + validationErr.AddDetailWithSpan(errors.ErrEmptyFile, "Please provide content of .gitflame-ci.yml", 0, 0, 0, 0) return validationErr } // 2) Парсим YAML cfg, err := ParseGitLabCIConfig([]byte(content)) if err != nil { - validationErr.Add(fmt.Errorf("ошибка синтаксического анализа YAML: %w", err)) + validationErr.AddDetailWithSpan(errors.ErrYamlSyntax, + fmt.Sprintf("ошибка синтаксического анализа YAML: %v", err), + 0, 0, 0, 0) return validationErr } // 3) (Опционально) Проверяем SHA проекта, // если включена опция и "проект с заданным SHA существует, но SHA не содержится ни в одной ветке/теге" - if opts.VerifyProjectSHA && opts.ProjectSHA != "" { - // Заглушка: предположим, что проект существует, - // а также предположим, что SHA нигде не встречается - // (в реальном мире тут нужно обращаться к GitLab API). - shaExists := checkSHAExistsInBranchesOrTags(opts.ProjectSHA) - if !shaExists { - validationErr.Add(fmt.Errorf("указанный SHA (%s) не содержится ни в одной ветке или теге", opts.ProjectSHA)) - } - } + //if opts.VerifyProjectSHA && opts.ProjectSHA != "" { + // // Заглушка: предположим, что проект существует, + // // а также предположим, что SHA нигде не встречается + // // (в реальном мире тут нужно обращаться к GitLab API). + // shaExists := checkSHAExistsInBranchesOrTags(opts.ProjectSHA) + // if !shaExists { + // validationErr.Add(fmt.Errorf("указанный SHA (%s) не содержится ни в одной ветке или теге", opts.ProjectSHA)) + // } + //} // 4) Валидируем структуру конфигурации validateRootObject(cfg, validationErr) validateJobs(cfg, validationErr) + validateChanges(cfg, validationErr) + validatePaths(cfg, validationErr) + validateService(cfg, validationErr) + validateStages(cfg, validationErr) + validateVariables(cfg, validationErr) + validateIncludeRules(cfg, validationErr) if !validationErr.IsEmpty() { return validationErr @@ -56,159 +75,296 @@ func checkSHAExistsInBranchesOrTags(sha string) bool { } // validateRootObject проверяет допустимые ключи на корневом уровне. -func validateRootObject(cfg *GitLabCIConfig, ve *ValidationError) { - // Допустимые ключи на корне - /*allowedKeys := map[string]struct{}{ - "default": {}, - "include": {}, - "before_script": {}, - "image": {}, - "services": {}, - "after_script": {}, - "variables": {}, - "stages": {}, - "cache": {}, - "workflow": {}, - "jobs": {}, - }*/ - - // Поскольку мы уже распарсили в структуру GitLabCIConfig, - // у нас нет возможности напрямую посмотреть "лишние" ключи, - // так как yaml.Unmarshal их "отбрасывает". - // - // В реальном случае можно было бы воспользоваться механизмом - // "yaml.UnmarshalStrict" или собственной реализацией обхода - // узлов YAML. Ниже — упрощённый пример проверки "строгим" способом. - // - // Для демонстрации можно парсить в виде map[string]interface{}, - // а затем проверить. Для краткости — опущено. - - // Проверим, что cfg.Stages - это действительно срез строк - // (если он есть) — базовая проверка типа. - // В реальном коде при Unmarshal строка превращается в nil, - // так что здесь слегка условно: - for i, s := range cfg.Stages { - if s == "" { - ve.Add(fmt.Errorf("stages[%d] содержит пустую строку", i)) +func validateRootObject(cfg *GitLabCIConfig, ve *errors.ValidationError) { + validateBeforeScript(cfg.BeforeScript, ve) +} + +// validateBeforeScript проверяет, что поле before_script представлено строкой +// или вложенным массивом строк (до 10 уровней вложенности). +func validateBeforeScript(before interface{}, ve *errors.ValidationError) { + if before == nil { + return + } + // Если это строка — всё ок. + if _, ok := before.(string); ok { + return + } + // Если это массив, проверяем вложенность. + if arr, ok := before.([]interface{}); ok { + if !validateNestedStringArray(arr, 1, 10) { + ve.AddDetailWithSpan(errors.ErrBeforeScriptInvalid, + "before_script config should be a string or a nested array of strings up to 10 levels deep", + 0, 0, 0, 0) + } + } else { + ve.AddDetailWithSpan(errors.ErrBeforeScriptInvalid, + "before_script config should be a string or a nested array of strings up to 10 levels deep", + 0, 0, 0, 0) + } +} + +// validateNestedStringArray рекурсивно проверяет, что все элементы массива являются строками +// или вложенными массивами строк и что глубина вложенности не превышает maxLevel. +func validateNestedStringArray(arr []interface{}, currentLevel, maxLevel int) bool { + if currentLevel > maxLevel { + return false + } + for _, item := range arr { + switch v := item.(type) { + case string: + // ок + case []interface{}: + if !validateNestedStringArray(v, currentLevel+1, maxLevel) { + return false + } + default: + return false + } + } + return true +} + +// validateService проверяет, что конфигурация сервиса имеет тип string или map[string]interface{}. +func validateService(cfg *GitLabCIConfig, ve *errors.ValidationError) { + if cfg.Services == nil { + return + } + switch cfg.Services.(type) { + case string: + // Корректно + case map[string]interface{}: + // Корректно + default: + ve.AddDetailWithSpan(errors.ErrServiceInvalid, + "config should be a hash or a string", + 0, 0, 0, 0) + } +} + +// validateStages проверяет, что каждая стадия задана как непустая строка. +func validateStages(cfg *GitLabCIConfig, ve *errors.ValidationError) { + for i, st := range cfg.Stages { + if strings.TrimSpace(st) == "" { + ve.AddDetailWithSpan(errors.ErrStageInvalid, + fmt.Sprintf("stage at index %d is empty; stage config should be a string", i), + 0, 0, 0, 0) + } + } +} + +// validateVariables проверяет корректность описания переменных. +// Если значение переменной — скаляр, оно должно быть строкой, числом или булевым значением. +// Если переменная задана как хэш, допустимы ключи: value, description, expand, options. +func validateVariables(cfg *GitLabCIConfig, ve *errors.ValidationError) { + if cfg.Variables == nil { + return + } + varsMap, ok := cfg.Variables.(map[string]interface{}) + if !ok { + ve.AddDetailWithSpan(errors.ErrVariablesInvalid, "variables config should be a map", 0, 0, 0, 0) + return + } + allowedKeys := map[string]struct{}{ + "value": {}, + "description": {}, + "expand": {}, + "options": {}, + } + for varName, val := range varsMap { + if isScalar(val) { + // всё ок + } else if nested, ok := val.(map[string]interface{}); ok { + invalidKeys := []string{} + for k := range nested { + if _, allowed := allowedKeys[k]; !allowed { + invalidKeys = append(invalidKeys, k) + } + } + if len(invalidKeys) > 0 { + ve.AddDetailWithSpan(errors.ErrVariablesInvalidKey, + fmt.Sprintf("variable '%s' uses invalid data keys: %s", varName, strings.Join(invalidKeys, ", ")), + 0, 0, 0, 0) + } + } else { + ve.AddDetailWithSpan(errors.ErrVariablesInvalid, + fmt.Sprintf("variable '%s' must be a scalar or a map", varName), + 0, 0, 0, 0) } } } +// isScalar возвращает true, если значение является скаляром (string, bool, числовым типом). +func isScalar(val interface{}) bool { + switch val.(type) { + case string, bool, int, int64, float64, float32: + return true + default: + return false + } +} + // validateJobs проводит серию проверок для job'ов. -func validateJobs(cfg *GitLabCIConfig, ve *ValidationError) { +func validateJobs(cfg *GitLabCIConfig, ve *errors.ValidationError) { + // Если jobs отсутствуют, возвращаем ошибку с позицией 0,0 (так как позиция не определена) if cfg.Jobs == nil || len(cfg.Jobs) == 0 { - ve.Add(errors.New("отсутствуют job'ы (jobs) в конфигурации")) + ve.AddDetailWithSpan(errors.ErrMissingJobs, "отсутствуют job'ы (jobs) в конфигурации", 0, 0, 0, 0) return } - // Список допустимых стадий, определённых в cfg.Stages + // Формируем список разрешённых стадий из cfg.Stages stagesAllowed := make(map[string]struct{}) for _, st := range cfg.Stages { stagesAllowed[st] = struct{}{} } - // Проверим наличие хотя бы одной "видимой" задачи - // (под "видимой" обычно понимается job без точки впереди имени, - // но деталь зависит от бизнес-логики; здесь покажем упрощённо). visibleCount := 0 - // 1. Сбор данных для проверки циклов зависимости - // (Graph: jobName -> список зависимых jobName). + // Для проверки циклов зависимости можно собрать dependency graph (пока не используем для цикла) dependencyGraph := make(map[string][]string) + // Обходим все job'ы for _, job := range cfg.Jobs { // a) Имя задачи не может быть пустым if job.Name == "" { - ve.Add(fmt.Errorf("у задачи отсутствует имя")) + ve.AddDetailWithSpan(errors.ErrJobNameBlank, "job name can't be blank", job.Line, job.Column, job.Line, job.Column) continue } - // b) Если имя начинается с точки, считаем job скрытым + // b) Если имя не начинается с точки — задача считается видимой if job.Name[0] != '.' { visibleCount++ } - // c) Обязательное поле script - scriptPresent := job.Script != nil - // Проверка на булевые значения и массивы строк можно реализовать - // дополнительно; здесь для примера выводим только общую идею. - if !scriptPresent && job.Run == nil { - ve.Add(fmt.Errorf("задача '%s' не содержит script или run", job.Name)) + //// c) Проверяем обязательное наличие script или run + //scriptPresent := job.Script != nil + //if !scriptPresent && job.Run == nil { + // ve.AddDetail(errors.ErrMissingScript, + // fmt.Sprintf("задача '%s' не содержит script или run", job.Name), + // job.Line, job.Column) + //} + + // d) Нельзя использовать одновременно script и run + if job.Script == nil && job.Run == nil && job.Trigger == nil { + ve.AddDetail(errors.ErrMissingScript, + fmt.Sprintf("задача '%s' не содержит script, run или trigger", job.Name), + job.Line, job.Column) } - // d) Проверка взаимоисключения ключей script и run - if job.Script != nil && job.Run != nil { - ve.Add(fmt.Errorf("задача '%s' содержит одновременно script и run", job.Name)) - } - - // e) stage должен быть в списке допустимых + // e) Если указан stage, он должен присутствовать в списке разрешённых if job.Stage != "" { if _, ok := stagesAllowed[job.Stage]; !ok { - ve.Add(fmt.Errorf("задача '%s' (строка %d, символ %d) имеет недопустимый stage '%s'", - job.Name, job.Line, job.Column, job.Stage)) + ve.AddDetail(errors.ErrJobStageNotExist, + fmt.Sprintf("задача '%s': выбранный stage '%s' не существует; доступные stage: %v", job.Name, job.Stage, cfg.Stages), + job.Line, job.Column) } } - // f) Dependencies (если есть) должны ссылаться на определённые job + // f) Проверяем, что все зависимости (dependencies) ссылаются на существующие job'ы dependencyGraph[job.Name] = append(dependencyGraph[job.Name], job.Dependencies...) for _, dep := range job.Dependencies { depPos, _ := job.DependencyLines[dep] if _, ok := cfg.Jobs[dep]; !ok { - ve.Add(fmt.Errorf("задача '%s' (строка %d, символ %d) имеет зависимость '%s' (строка %d, символ %d), которой нет среди job'ов", - job.Name, job.Line, job.Column, dep, depPos.Line, depPos.Column)) + ve.AddDetail(errors.ErrUndefinedDependency, + fmt.Sprintf("задача '%s' (строка %d, символ %d) имеет зависимость '%s' (строка %d, символ %d), которой нет среди job'ов", + job.Name, job.Line, job.Column, dep, depPos.Line, depPos.Column), + job.Line, job.Column) } else { - // Проверяем порядок stage (зависимость не должна быть позже) + // Проверяем порядок stage — зависимость не должна идти позже if !checkStageOrder(cfg, dep, job.Name) { - ve.Add(fmt.Errorf("задача '%s' имеет зависимость '%s' со стадией, идущей позже, чем '%s'", job.Name, dep, job.Name)) + ve.AddDetail(errors.ErrInvalidStageOrder, + fmt.Sprintf("задача '%s' имеет зависимость '%s' со стадией, идущей позже, чем '%s'", job.Name, dep, job.Name), + job.Line, job.Column) } } } - // g) Needs — проверки дублирования, наличия и пр. + // g) Проверяем блок needs if len(job.Needs) > 0 { needNames := make(map[string]struct{}) for _, nd := range job.Needs { - // Не должно быть дублирующихся name - if _, exist := needNames[nd.Name]; exist { - ve.Add(fmt.Errorf("задача '%s' содержит дублирующуюся зависимость '%s' в needs", job.Name, nd.Name)) + // Не должно быть дублирующихся записей в needs + if _, exist := needNames[nd]; exist { + // Здесь, например, выделяем весь фрагмент с нужной зависимостью. + // Предположим, что мы можем вычислить окончание как: + endCol := job.Column + len(nd) + ve.AddDetailWithSpan(errors.ErrDuplicateNeeds, + fmt.Sprintf("задача '%s' содержит дублирующуюся зависимость '%s' в needs", job.Name, nd), + job.Line, job.Column, job.Line, endCol) } else { - needNames[nd.Name] = struct{}{} + needNames[nd] = struct{}{} } // Имя в needs не должно быть пустым - if nd.Name == "" { - ve.Add(fmt.Errorf("задача '%s' имеет needs с пустым именем задачи", job.Name)) + if nd == "" { + ve.AddDetailWithSpan(errors.ErrJobNameBlank, + fmt.Sprintf("задача '%s' имеет needs с пустым именем задачи", job.Name), + job.Line, job.Column, job.Line, job.Column) } - // Проверка максимальной длины имени - if len(nd.Name) > MaxJobNameLength { - ve.Add(fmt.Errorf("задача '%s' содержит needs '%s', длина имени задачи превышает %d", - job.Name, nd.Name, MaxJobNameLength)) + // Проверяем максимальную длину имени + if len(nd) > MaxJobNameLength { + endCol := job.Column + len(nd) + ve.AddDetailWithSpan(errors.ErrNeedNameTooLong, + fmt.Sprintf("задача '%s' содержит needs '%s', длина имени задачи превышает %d", job.Name, nd, MaxJobNameLength), + job.Line, job.Column, job.Line, endCol) } - // Проверка, что такая задача есть в списке job'ов - if _, ok := cfg.Jobs[nd.Name]; !ok { - ve.Add(fmt.Errorf("задача '%s' ссылается на несуществующую задачу '%s' в needs", job.Name, nd.Name)) - } else { - // Проверяем порядок stage - if !checkStageOrder(cfg, nd.Name, job.Name) { - ve.Add(fmt.Errorf("задача '%s' имеет need '%s' со стадией, идущей позже, чем '%s'", job.Name, nd.Name, job.Name)) - } + // Каждая задача, указанная в needs, должна существовать + needPos, posExists := job.NeedLines[nd] + if !posExists { + needPos = struct{ Line, Column int }{Line: job.Line, Column: job.Column} } - - // Добавляем в граф для дальнейшей проверки циклов - dependencyGraph[job.Name] = append(dependencyGraph[job.Name], nd.Name) + if _, ok := cfg.Jobs[nd]; !ok { + endCol := needPos.Column + len(nd) + ve.AddDetailWithSpan(errors.ErrUndefinedNeed, + fmt.Sprintf("задача '%s' (Line %d, Col %d-%d) ссылается на несуществующую задачу '%s'", job.Name, needPos.Line, needPos.Column, endCol, nd), + job.Line, job.Column, + needPos.Line, endCol) + } else if !checkStageOrder(cfg, nd, job.Name) { + endCol := needPos.Column + len(nd) + ve.AddDetailWithSpan(errors.ErrInvalidStageOrder, + fmt.Sprintf("задача '%s' (Line %d, Col %d) имеет need '%s' (Line %d, Col %d-%d) со стадией, идущей позже, чем '%s'", + job.Name, job.Line, job.Column, nd, needPos.Line, needPos.Column, endCol, job.Name), + job.Line, job.Column, + needPos.Line, endCol) + } + // Добавляем запись needs в граф зависимостей + dependencyGraph[job.Name] = append(dependencyGraph[job.Name], nd) } } + + validateJobArtifacts(job, ve) + validateJobDelayedParameters(job, ve) + validateJobDependenciesNeedsConsistency(job, ve) + validateJobRules(job, ve) + } + // Проверка: должна быть хотя бы одна видимая задача if visibleCount == 0 { - ve.Add(errors.New("в разделе jobs не обнаружено ни одной «видимой» задачи")) + ve.AddDetail(errors.ErrNoVisibleJob, + "в разделе jobs не обнаружено ни одной «видимой» задачи", 0, 0) } - // 2. Проверяем граф зависимостей на отсутствие циклов - if hasCycles := checkCircularDependencies(dependencyGraph); hasCycles { - ve.Add(errors.New("обнаружены циклические зависимости в заданиях")) + // После обхода всех job проверяем граф зависимостей на циклы. + cycle := detectCycles(dependencyGraph) + if len(cycle) > 0 { + ve.AddDetail(errors.ErrCyclicDependency, + fmt.Sprintf("проверка циклических зависимостей: обнаружен цикл между job: %v", cycle), + 0, 0) + } +} + +func validateJobArtifacts(job *Job, ve *errors.ValidationError) { + if job.Artifacts != nil { + // Если paths не указаны или массив пустой, возвращаем ошибку + if len(job.Artifacts.Paths) == 0 { + ve.AddDetail( + errors.ErrArtifactsPathsBlank, + fmt.Sprintf("задача '%s': artifacts paths can't be blank", job.Name), + job.Line, job.Column, + ) + } } } @@ -245,35 +401,266 @@ func checkStageOrder(cfg *GitLabCIConfig, jobDep, jobName string) bool { return depIndex <= jobIndex } -// checkCircularDependencies анализирует граф зависимостей и проверяет наличие циклов. -// Здесь применяется простой DFS. -func checkCircularDependencies(graph map[string][]string) bool { - visited := make(map[string]bool) - recStack := make(map[string]bool) +func validateChanges(cfg *GitLabCIConfig, ve *errors.ValidationError) { + if cfg.Changes == nil { + return + } + switch v := cfg.Changes.(type) { + case []interface{}: + if len(v) > 50 { + ve.AddDetail(errors.ErrChangesTooManyEntries, "has too many entries (maximum 50)", 0, 0) + } + for _, item := range v { + if _, ok := item.(string); !ok { + ve.AddDetail(errors.ErrChangesNotArrayOfStrings, "changes config should be an array of strings", 0, 0) + break + } + } + case map[string]interface{}: + paths, ok := v["paths"] + if !ok { + ve.AddDetail(errors.ErrChangesMissingPaths, "changes config hash must contain key 'paths'", 0, 0) + } else { + arr, ok := paths.([]interface{}) + if !ok { + ve.AddDetail(errors.ErrChangesNotArrayOfStrings, "changes config should be an array of strings", 0, 0) + } else { + if len(arr) > 50 { + ve.AddDetail(errors.ErrChangesTooManyEntries, "has too many entries (maximum 50)", 0, 0) + } + for _, item := range arr { + if _, ok := item.(string); !ok { + ve.AddDetail(errors.ErrChangesNotArrayOfStrings, "changes config should be an array of strings", 0, 0) + break + } + } + } + } + case string: + ve.AddDetail(errors.ErrChangesInvalidType, "should be an array or a hash", 0, 0) + default: + ve.AddDetail(errors.ErrChangesInvalidType, "should be an array or a hash", 0, 0) + } +} - for node := range graph { - if dfsHasCycle(node, graph, visited, recStack) { - return true +func validatePaths(cfg *GitLabCIConfig, ve *errors.ValidationError) { + if cfg.Paths == nil { + return + } + arr, ok := cfg.Paths.([]interface{}) + if !ok { + ve.AddDetail(errors.ErrPathsNotArrayOfStrings, "paths config should be an array of strings", 0, 0) + return + } + for _, item := range arr { + if _, ok := item.(string); !ok { + ve.AddDetail(errors.ErrPathsNotArrayOfStrings, "paths config should be an array of strings", 0, 0) + break } } - return false } -func dfsHasCycle(node string, graph map[string][]string, visited, recStack map[string]bool) bool { - if !visited[node] { - // Отмечаем текущий узел как посещённый +// validateJobDelayedParameters проверяет параметры для отложенных (delayed) задач. +func validateJobDelayedParameters(job *Job, ve *errors.ValidationError) { + if job.When == "delayed" { + if job.StartIn == "" { + ve.AddDetail(errors.ErrStartInMissing, + fmt.Sprintf("задача '%s': start_in should be specified for delayed job", job.Name), + job.Line, job.Column) + } else { + d, err := time.ParseDuration(job.StartIn) + if err != nil { + ve.AddDetail(errors.ErrStartInInvalid, + fmt.Sprintf("задача '%s': job start in should be a duration", job.Name), + job.Line, job.Column) + } else if d > 7*24*time.Hour { + ve.AddDetail(errors.ErrStartInTooLong, + fmt.Sprintf("задача '%s': job start in should not exceed the limit", job.Name), + job.Line, job.Column) + } + } + } else { + // Если задача не отложенная, start_in должен быть пустым + if job.StartIn != "" { + ve.AddDetail(errors.ErrStartInMustBeBlank, + fmt.Sprintf("задача '%s': job start in must be blank when not delayed", job.Name), + job.Line, job.Column) + } + } +} + +// validateJobDependenciesNeedsConsistency убеждается, что все зависимости присутствуют в needs. +func validateJobDependenciesNeedsConsistency(job *Job, ve *errors.ValidationError) { + if len(job.Dependencies) > 0 && len(job.Needs) > 0 { + needNames := make(map[string]struct{}) + for _, nd := range job.Needs { + needNames[nd] = struct{}{} + } + for _, dep := range job.Dependencies { + if _, ok := needNames[dep]; !ok { + ve.AddDetail(errors.ErrDependencyNotInNeeds, + fmt.Sprintf("задача '%s': dependency '%s' should be part of needs", job.Name, dep), + job.Line, job.Column) + } + } + } +} + +func validateJobRules(job *Job, ve *errors.ValidationError) { + if job.Rules != nil { + // Если rules используется, не должно быть only или except + if job.Only != nil && job.Except != nil { + ve.AddDetail(errors.ErrRulesOnlyExcept, + fmt.Sprintf("задача '%s': may not be used with rules: only, except", job.Name), + job.Line, job.Column) + } else if job.Only != nil { + ve.AddDetail(errors.ErrRulesOnly, + fmt.Sprintf("задача '%s': may not be used with rules: only", job.Name), + job.Line, job.Column) + } else if job.Except != nil { + ve.AddDetail(errors.ErrRulesExcept, + fmt.Sprintf("задача '%s': may not be used with rules: except", job.Name), + job.Line, job.Column) + } + // Если в секции rules присутствует ключ if, проверим его тип и синтаксис + if rulesMap, ok := job.Rules.(map[string]interface{}); ok { + if ifVal, exists := rulesMap["if"]; exists { + if ifStr, ok := ifVal.(string); ok { + if !validateExpressionSyntax(ifStr) { + ve.AddDetail(errors.ErrInvalidExpressionSyntax, + fmt.Sprintf("задача '%s': invalid expression syntax", job.Name), + job.Line, job.Column) + } + } else { + ve.AddDetail(errors.ErrInvalidExpressionSyntax, + fmt.Sprintf("задача '%s': 'if' in rules should be a string", job.Name), + job.Line, job.Column) + } + } + // Проверим, что ключ allow_failure, если присутствует, имеет булевое значение + if af, exists := rulesMap["allow_failure"]; exists { + if _, ok := af.(bool); !ok { + ve.AddDetail(errors.ErrBooleanValue, + fmt.Sprintf("задача '%s': allow_failure in rules should be a boolean value", job.Name), + job.Line, job.Column) + } + } + // Проверка допустимых ключей в rules + allowed := map[string]struct{}{ + "if": {}, + "when": {}, + "start_in": {}, + "allow_failure": {}, + "changes": {}, + "exists": {}, + } + for key := range rulesMap { + if _, ok := allowed[key]; !ok { + ve.AddDetail(errors.ErrUnknownRulesKey, + fmt.Sprintf("задача '%s': unknown key in rules: %s", job.Name, key), + job.Line, job.Column) + } + } + } + } +} + +// validateExpressionSyntax выполняет базовую проверку синтаксиса выражения. +// В реальной реализации здесь может быть полноценный парсер. +func validateExpressionSyntax(expr string) bool { + // Для демонстрации считаем, что пустая строка или отсутствие каких-либо специальных символов — это ошибка. + return expr != "" +} + +// detectCycles принимает граф зависимостей в виде мапы: job -> список зависимых job, +// и возвращает список job, участвующих в цикле (если он обнаружен). +func detectCycles(dependencyGraph map[string][]string) []string { + visited := make(map[string]bool) + recStack := make(map[string]bool) + var cycle []string + + // Рекурсивная функция для обхода графа. + var dfs func(string) bool + dfs = func(node string) bool { + if recStack[node] { + // Если node уже в рекурсивном стеке, значит обнаружен цикл. + cycle = append(cycle, node) + return true + } + if visited[node] { + return false + } visited[node] = true recStack[node] = true - // Рекурсивно для всех соседей - for _, neighbor := range graph[node] { - if !visited[neighbor] && dfsHasCycle(neighbor, graph, visited, recStack) { - return true - } else if recStack[neighbor] { + for _, neighbor := range dependencyGraph[node] { + if dfs(neighbor) { + cycle = append(cycle, node) return true } } + + recStack[node] = false + return false + } + + // Запускаем DFS для каждой job. + for node := range dependencyGraph { + if !visited[node] { + if dfs(node) { + break // если цикл обнаружен, прерываем обход + } + } + } + return cycle +} + +// Функция для валидации include-правил +func validateIncludeRules(cfg *GitLabCIConfig, ve *errors.ValidationError) { + if cfg.Include == nil { + return + } + switch v := cfg.Include.(type) { + case map[string]interface{}: + validateIncludeRuleMap(v, ve) + case []interface{}: + for _, item := range v { + if m, ok := item.(map[string]interface{}); ok { + validateIncludeRuleMap(m, ve) + } else { + // Если элемент не является картой, можно считать его допустимым, + // либо добавить дополнительную проверку при необходимости. + } + } + default: + // Если include задан не как карта и не как массив, пропускаем проверку. + } +} + +// Функция, которая проверяет отдельную карту include-правила +func validateIncludeRuleMap(m map[string]interface{}, ve *errors.ValidationError) { + // Проверяем ключи "exists" и "changes" + keysToCheck := []string{"exists", "changes"} + for _, key := range keysToCheck { + if val, exists := m[key]; exists { + switch v := val.(type) { + case string: + // Корректное значение – строка. + case []interface{}: + // Проверяем, что все элементы массива – строки. + for _, item := range v { + if _, ok := item.(string); !ok { + ve.AddDetail(errors.ErrIncludeRulesInvalid, + fmt.Sprintf("include rule key '%s' should be an array of strings", key), + 0, 0) + break + } + } + default: + ve.AddDetail(errors.ErrIncludeRulesInvalid, + fmt.Sprintf("include rule key '%s' should be a string or an array of strings", key), + 0, 0) + } + } } - recStack[node] = false - return false } diff --git a/main.go b/main.go index 4421f2b..8061986 100644 --- a/main.go +++ b/main.go @@ -35,11 +35,12 @@ jobs: stage: build script: - echo "Building..." - test_job: - stage: test1 - dependencies: ["build_job2"] + needs: ["test_job2"] + test_job2: + stage: test script: - echo "Testing..." + needs: ["build_job", "test_job2", "test_job3"] ` fmt.Println("=== Пример 1: Корректный YAML ===")