From a043651ee2098dcd5ee320817e72cad0fa82b319 Mon Sep 17 00:00:00 2001 From: Alexander Trakhimenok Date: Mon, 31 Aug 2026 15:09:39 +0100 Subject: [PATCH 1/2] fix(cli): distinguish validation failure exits Reserve exit code 2 for invalid repository definitions and records so CI integrations can separate data findings from validator runtime failures. Co-Authored-By: OpenAI Codex --- cmd/ingitdb/commands/validate.go | 12 ++++--- cmd/ingitdb/commands/validate_test.go | 9 +++++ cmd/ingitdb/commands/validation_failure.go | 23 ++++++++++++ cmd/ingitdb/main.go | 10 +++++- cmd/ingitdb/main_test.go | 42 ++++++++++++++++++++-- docs/cli/commands/validate.md | 10 +++--- spec/features/cli/validate/README.md | 19 ++++++++-- 7 files changed, 111 insertions(+), 14 deletions(-) create mode 100644 cmd/ingitdb/commands/validation_failure.go diff --git a/cmd/ingitdb/commands/validate.go b/cmd/ingitdb/commands/validate.go index cb2fb19a..7c79f112 100644 --- a/cmd/ingitdb/commands/validate.go +++ b/cmd/ingitdb/commands/validate.go @@ -65,7 +65,8 @@ func Validate( } if result.HasErrors() { message := formatValidationFailure("incremental validation", result) - return fmt.Errorf("%s", message) + findingErr := fmt.Errorf("%s", message) + return NewValidationFailedError(findingErr) } return nil } @@ -80,13 +81,15 @@ func Validate( validateOpt := ingitdb.Validate() defRes, defErr := readDefinition(dirPath, validateOpt) if defErr != nil { - return fmt.Errorf("inGitDB database validation failed: %w", defErr) + validationErr := fmt.Errorf("inGitDB database validation failed: %w", defErr) + return NewValidationFailedError(validationErr) } def = defRes } else { defRes, defErr := readDefinition(dirPath) if defErr != nil { - return fmt.Errorf("inGitDB database validation failed: %w", defErr) + validationErr := fmt.Errorf("inGitDB database validation failed: %w", defErr) + return NewValidationFailedError(validationErr) } def = defRes } @@ -99,7 +102,8 @@ func Validate( } if result.HasErrors() { message := formatValidationFailure("data validation", result) - return fmt.Errorf("%s", message) + findingErr := fmt.Errorf("%s", message) + return NewValidationFailedError(findingErr) } // Log completion message for each collection for collectionKey := range def.Collections { diff --git a/cmd/ingitdb/commands/validate_test.go b/cmd/ingitdb/commands/validate_test.go index 1efd4982..735b388c 100644 --- a/cmd/ingitdb/commands/validate_test.go +++ b/cmd/ingitdb/commands/validate_test.go @@ -120,6 +120,9 @@ func TestValidate_DataValidationErrors(t *testing.T) { if err == nil { t.Fatal("expected error when data validation has errors") } + if !errors.Is(err, ErrValidationFailed) { + t.Fatalf("expected ErrValidationFailed, got: %v", err) + } } func TestValidate_DataValidationError(t *testing.T) { @@ -206,6 +209,9 @@ func TestValidate_IncrementalErrors(t *testing.T) { if err == nil { t.Fatal("expected error when incremental validation has errors") } + if !errors.Is(err, ErrValidationFailed) { + t.Fatalf("expected ErrValidationFailed, got: %v", err) + } } func TestValidate_IncrementalValidationError(t *testing.T) { @@ -264,6 +270,9 @@ func TestValidate_ReadDefinitionError(t *testing.T) { if err == nil { t.Fatal("expected error when readDefinition fails") } + if !errors.Is(err, ErrValidationFailed) { + t.Fatalf("expected ErrValidationFailed, got: %v", err) + } } func TestValidate_GetWdError(t *testing.T) { diff --git a/cmd/ingitdb/commands/validation_failure.go b/cmd/ingitdb/commands/validation_failure.go new file mode 100644 index 00000000..8141cd14 --- /dev/null +++ b/cmd/ingitdb/commands/validation_failure.go @@ -0,0 +1,23 @@ +package commands + +import ( + "errors" + "fmt" +) + +// ValidationFailedExitCode is returned when the validate command completed and +// found that the repository does not satisfy its InGitDB definition. +const ValidationFailedExitCode = 2 + +// ErrValidationFailed identifies repository validation findings separately +// from command configuration, I/O, startup, and other runtime failures. +var ErrValidationFailed = errors.New("repository validation failed") + +// NewValidationFailedError preserves a human-readable finding while making it +// possible for the process boundary to select ValidationFailedExitCode. +func NewValidationFailedError(err error) error { + if err == nil { + return ErrValidationFailed + } + return fmt.Errorf("%w: %v", ErrValidationFailed, err) +} diff --git a/cmd/ingitdb/main.go b/cmd/ingitdb/main.go index d359fd25..e7fc99cd 100644 --- a/cmd/ingitdb/main.go +++ b/cmd/ingitdb/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "os" @@ -28,7 +29,7 @@ var exit = os.Exit func main() { fatal := func(err error) { _, _ = fmt.Fprintf(os.Stderr, "error: %v\n", err) - exit(1) + exit(exitCodeForError(err)) } logf := func(args ...any) { _, _ = fmt.Fprintln(os.Stderr, args...) @@ -36,6 +37,13 @@ func main() { run(os.Args, os.UserHomeDir, os.Getwd, validator.ReadDefinition, fatal, logf) } +func exitCodeForError(err error) int { + if errors.Is(err, commands.ErrValidationFailed) { + return commands.ValidationFailedExitCode + } + return 1 +} + func defaultNewDB(rootDirPath string, def *ingitdb.Definition) (dal.DB, error) { return dalgo2fsingitdb.NewLocalDBWithDef(rootDirPath, def) } diff --git a/cmd/ingitdb/main_test.go b/cmd/ingitdb/main_test.go index 5cf70f2f..9f6b8f1e 100644 --- a/cmd/ingitdb/main_test.go +++ b/cmd/ingitdb/main_test.go @@ -3,10 +3,12 @@ package main import ( "context" "errors" + "fmt" "os" "testing" "github.com/dal-go/dalgo/dal" + "github.com/ingitdb/ingitdb-cli/cmd/ingitdb/commands" "github.com/ingitdb/ingitdb-go/ingitdb" ) @@ -103,6 +105,35 @@ func TestRun_ValidateError(t *testing.T) { } } +func TestExitCodeForError(t *testing.T) { + t.Parallel() + + invalidRecordErr := errors.New("invalid record") + validationErr := commands.NewValidationFailedError(invalidRecordErr) + invalidDefinitionErr := errors.New("invalid definition") + wrappedValidationErr := commands.NewValidationFailedError(invalidDefinitionErr) + wrappedValidationErr = fmt.Errorf("outer: %w", wrappedValidationErr) + tests := []struct { + name string + err error + want int + }{ + {name: "validation failure", err: validationErr, want: commands.ValidationFailedExitCode}, + {name: "wrapped validation failure", err: wrappedValidationErr, want: commands.ValidationFailedExitCode}, + {name: "runtime failure", err: errors.New("disk unavailable"), want: 1}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := exitCodeForError(test.err); got != test.want { + t.Fatalf("exitCodeForError() = %d, want %d", got, test.want) + } + }) + } +} + func TestRun_ValidateDefaultPath(t *testing.T) { t.Parallel() @@ -179,8 +210,10 @@ func TestMain_ReadDefinitionError(t *testing.T) { oldExit := exit exitCalled := false - exit = func(int) { + exitCode := 0 + exit = func(code int) { exitCalled = true + exitCode = code } t.Cleanup(func() { exit = oldExit @@ -199,6 +232,9 @@ func TestMain_ReadDefinitionError(t *testing.T) { if !exitCalled { t.Fatal("expected exit to be called") } + if exitCode != commands.ValidationFailedExitCode { + t.Fatalf("exit code = %d, want %d", exitCode, commands.ValidationFailedExitCode) + } } func TestRun_InvalidCommand(t *testing.T) { @@ -462,8 +498,8 @@ func TestMain_Fatal(t *testing.T) { if !exitCalled { t.Fatal("exit should be called") } - if exitCode != 1 { - t.Fatalf("exit code should be 1, got %d", exitCode) + if exitCode != commands.ValidationFailedExitCode { + t.Fatalf("exit code should be %d, got %d", commands.ValidationFailedExitCode, exitCode) } if len(output) == 0 { t.Fatal("error message should be written to stderr") diff --git a/docs/cli/commands/validate.md b/docs/cli/commands/validate.md index 6174274f..9bda38a9 100644 --- a/docs/cli/commands/validate.md +++ b/docs/cli/commands/validate.md @@ -19,9 +19,12 @@ the collection definitions and every record against its schema. Use `--only` to the definitions or just the records. With `--from-commit` / `--to-commit`, only files changed in that commit range are checked (see [Validator docs](components/validator/README.md)). -Exit code is `0` on success, non-zero on any validation error. Validation messages report -record counts per collection (e.g., "All 42 records are valid for collection: users" or -"38 out of 42 records are valid for collection: users"). +Exit code is `0` on success and `2` when validation completes with invalid +repository definitions or records. Command configuration, startup, and other +runtime failures exit `1`, so automation can distinguish invalid data +from a validator that could not complete. Validation messages report record +counts per collection (e.g., "All 42 records are valid for collection: users" +or "38 out of 42 records are valid for collection: users"). **Examples:** @@ -46,4 +49,3 @@ ingitdb validate --only=records --from-commit=abc1234 --to-commit=def5678 ``` --- - diff --git a/spec/features/cli/validate/README.md b/spec/features/cli/validate/README.md index f69db28a..d21a8813 100644 --- a/spec/features/cli/validate/README.md +++ b/spec/features/cli/validate/README.md @@ -49,6 +49,13 @@ For each collection, the command MUST print a summary line reporting how many re The command MUST exit with status `0` when validation passes and a non-zero status when any validation error is detected. +#### REQ: stable-validation-failure-exit-code + +The command MUST exit with status `2` when repository definition or record +validation completes with findings. Command configuration, validator startup, +and other runtime failures MUST exit with status `1`; no non-validation +failure MAY be reported as status `2`. + #### REQ: record-file-parsing Record validation MUST parse every candidate record file using the collection's declared `record_file.format` and `record_file.type` before counting it as valid. Unparsable files, including malformed Markdown frontmatter and invalid YAML, JSON, TOML, CSV, JSONL, or INGR content, MUST be reported as validation errors with the offending record path and a useful parse error. @@ -75,7 +82,7 @@ Source files implementing this feature (annotated with **Requirements:** cli/validate#req:subcommand-name, cli/validate#req:only-flag, cli/validate#req:per-collection-summary, cli/validate#req:exit-code -Running `ingitdb validate` with no flags from a directory containing a valid `.ingitdb.yaml` validates both definitions and records, prints a summary line per collection, and exits `0`. Introducing a single record that violates its schema causes the command to exit non-zero. +Running `ingitdb validate` with no flags from a directory containing a valid `.ingitdb.yaml` validates both definitions and records, prints a summary line per collection, and exits `0`. Introducing a single record that violates its schema causes the command to exit `2`. ### AC: scoped-validation @@ -87,7 +94,15 @@ Running `ingitdb validate` with no flags from a directory containing a valid `.i **Requirements:** cli/validate#req:only-flag, cli/validate#req:record-file-parsing, cli/validate#req:exit-code -Given a Markdown-backed collection, `ingitdb validate --only=records` MUST parse each `*.md` record file. If a record contains malformed YAML frontmatter, the command exits non-zero and the error output includes the record path plus the Markdown/YAML parse error. +Given a Markdown-backed collection, `ingitdb validate --only=records` MUST parse each `*.md` record file. If a record contains malformed YAML frontmatter, the command exits `2` and the error output includes the record path plus the Markdown/YAML parse error. + +### AC: runtime-failure-is-distinct + +**Requirements:** cli/validate#req:exit-code, cli/validate#req:stable-validation-failure-exit-code + +Given validation cannot complete because the validator returns an operational +error, when the command terminates, then it exits `1` rather than reporting the +repository as invalid with status `2`. ## Open Questions From d0a37c9d95cc4ca38108508f36ec15ed00955318 Mon Sep 17 00:00:00 2001 From: Alexander Trakhimenok Date: Mon, 31 Aug 2026 15:40:07 +0100 Subject: [PATCH 2/2] fix(cli): add safe validation diagnostics Give CI consumers repository-relative finding identities and constraint classes without record values. Recover command panics as generic runtime failures so exit code 2 remains reserved for validation findings. Co-Authored-By: OpenAI Codex --- cmd/ingitdb/commands/validate.go | 119 +++++++++++++++++++++++++- cmd/ingitdb/commands/validate_test.go | 73 ++++++++++++++++ cmd/ingitdb/main.go | 17 ++++ cmd/ingitdb/main_test.go | 28 ++++++ docs/cli/commands/validate.md | 10 ++- spec/features/cli/validate/README.md | 24 +++++- 6 files changed, 268 insertions(+), 3 deletions(-) diff --git a/cmd/ingitdb/commands/validate.go b/cmd/ingitdb/commands/validate.go index 7c79f112..d819d5e0 100644 --- a/cmd/ingitdb/commands/validate.go +++ b/cmd/ingitdb/commands/validate.go @@ -28,6 +28,7 @@ func Validate( RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() dirPath, _ := cmd.Flags().GetString("path") + safeDiagnostics, _ := cmd.Flags().GetBool("safe-diagnostics") if dirPath == "" { wd, err := getWd() if err != nil { @@ -40,7 +41,11 @@ func Validate( return err } dirPath = expanded - logf("inGitDB db path: ", dirPath) + if safeDiagnostics { + logf("inGitDB database root: repository-relative path") + } else { + logf("inGitDB db path: ", dirPath) + } // Validate --only flag onlyVal, _ := cmd.Flags().GetString("only") @@ -65,6 +70,9 @@ func Validate( } if result.HasErrors() { message := formatValidationFailure("incremental validation", result) + if safeDiagnostics { + message = formatSafeValidationFailure("incremental validation", dirPath, result) + } findingErr := fmt.Errorf("%s", message) return NewValidationFailedError(findingErr) } @@ -81,6 +89,10 @@ func Validate( validateOpt := ingitdb.Validate() defRes, defErr := readDefinition(dirPath, validateOpt) if defErr != nil { + if safeDiagnostics { + validationErr := fmt.Errorf("inGitDB database definition is invalid") + return NewValidationFailedError(validationErr) + } validationErr := fmt.Errorf("inGitDB database validation failed: %w", defErr) return NewValidationFailedError(validationErr) } @@ -88,6 +100,10 @@ func Validate( } else { defRes, defErr := readDefinition(dirPath) if defErr != nil { + if safeDiagnostics { + validationErr := fmt.Errorf("inGitDB database definition is invalid") + return NewValidationFailedError(validationErr) + } validationErr := fmt.Errorf("inGitDB database validation failed: %w", defErr) return NewValidationFailedError(validationErr) } @@ -98,10 +114,16 @@ func Validate( if shouldValidateRecords && dataVal != nil { result, valErr := dataVal.Validate(ctx, dirPath, def) if valErr != nil { + if safeDiagnostics { + return fmt.Errorf("data validator could not complete") + } return fmt.Errorf("data validation failed: %w", valErr) } if result.HasErrors() { message := formatValidationFailure("data validation", result) + if safeDiagnostics { + message = formatSafeValidationFailure("data validation", dirPath, result) + } findingErr := fmt.Errorf("%s", message) return NewValidationFailedError(findingErr) } @@ -122,9 +144,104 @@ func Validate( cmd.Flags().String("from-commit", "", "validate only records changed since this commit") cmd.Flags().String("to-commit", "", "validate only records up to this commit") cmd.Flags().String("only", "", `validate only "definition" or "records" (default: both)`) + cmd.Flags().Bool("safe-diagnostics", false, "report finding identities and constraint classes without record values") return cmd } +func formatSafeValidationFailure(prefix, rootPath string, result *ingitdb.ValidationResult) string { + validationErrors := result.Errors() + details := formatSafeValidationErrors(rootPath, validationErrors) + if details == "" { + return fmt.Sprintf("%s found %d error(s)", prefix, result.ErrorCount()) + } + return fmt.Sprintf("%s found %d error(s): %s", prefix, result.ErrorCount(), details) +} + +func formatSafeValidationErrors(rootPath string, validationErrors []ingitdb.ValidationError) string { + if len(validationErrors) == 0 { + return "" + } + parts := make([]string, 0, len(validationErrors)) + for _, validationErr := range validationErrors { + part := formatSafeValidationError(rootPath, validationErr) + parts = append(parts, part) + } + return strings.Join(parts, "; ") +} + +func formatSafeValidationError(rootPath string, validationErr ingitdb.ValidationError) string { + parts := make([]string, 0, 5) + if validationErr.CollectionID != "" { + collectionPart := fmt.Sprintf("collection %q", validationErr.CollectionID) + parts = append(parts, collectionPart) + } + if validationErr.FilePath != "" { + filePath := safeRepositoryPath(rootPath, validationErr.FilePath) + filePart := fmt.Sprintf("file %q", filePath) + parts = append(parts, filePart) + } + if validationErr.RecordKey != "" { + recordPart := fmt.Sprintf("record %q", validationErr.RecordKey) + parts = append(parts, recordPart) + } + if validationErr.FieldName != "" { + fieldPart := fmt.Sprintf("field %q", validationErr.FieldName) + parts = append(parts, fieldPart) + } + parts = append(parts, safeConstraintClass(validationErr.Message)) + return strings.Join(parts, ": ") +} + +func safeRepositoryPath(rootPath, filePath string) string { + cleanRoot, rootErr := filepath.Abs(rootPath) + if rootErr != nil { + return filepath.Base(filePath) + } + cleanFile := filePath + if !filepath.IsAbs(cleanFile) { + cleanFile = filepath.Join(cleanRoot, cleanFile) + } + cleanFile, fileErr := filepath.Abs(cleanFile) + if fileErr != nil { + return filepath.Base(filePath) + } + relPath, relErr := filepath.Rel(cleanRoot, cleanFile) + separator := string(filepath.Separator) + parentPrefix := ".." + separator + if relErr != nil || relPath == ".." || strings.HasPrefix(relPath, parentPrefix) { + return filepath.Base(filePath) + } + return filepath.ToSlash(relPath) +} + +func safeConstraintClass(message string) string { + normalized := strings.ToLower(message) + switch { + case strings.Contains(normalized, "foreign key"): + return "foreign-key constraint failed" + case strings.Contains(normalized, "missing required field"): + return "required-field constraint failed" + case strings.Contains(normalized, "wrong type"): + return "type constraint failed" + case strings.Contains(normalized, "not one of the permitted values"): + return "enum constraint failed" + case strings.Contains(normalized, "min_value"), strings.Contains(normalized, "max_value"): + return "range constraint failed" + case strings.Contains(normalized, "min_length"), strings.Contains(normalized, "max_length"), strings.Contains(normalized, "required length"): + return "length constraint failed" + case strings.Contains(normalized, "undeclared field"): + return "declared-field constraint failed" + case strings.Contains(normalized, "computed column"): + return "computed-field constraint failed" + case strings.Contains(normalized, "record(s)"): + return "record-count constraint failed" + case strings.Contains(normalized, "parse"): + return "record-format constraint failed" + default: + return "validation constraint failed" + } +} + func formatValidationFailure(prefix string, result *ingitdb.ValidationResult) string { details := formatValidationErrors(result.Errors()) if details == "" { diff --git a/cmd/ingitdb/commands/validate_test.go b/cmd/ingitdb/commands/validate_test.go index 735b388c..55d3c125 100644 --- a/cmd/ingitdb/commands/validate_test.go +++ b/cmd/ingitdb/commands/validate_test.go @@ -125,6 +125,79 @@ func TestValidate_DataValidationErrors(t *testing.T) { } } +func TestValidate_SafeDiagnosticsRedactRecordValue(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + secretValue := "github_pat_secret_value" + readDef := func(_ string, _ ...ingitdb.ReadOption) (*ingitdb.Definition, error) { + return &ingitdb.Definition{}, nil + } + dataVal := &mockDataValidator{ + result: func() *ingitdb.ValidationResult { + result := &ingitdb.ValidationResult{} + validationErr := ingitdb.ValidationError{ + CollectionID: "spaces", + FilePath: filepath.Join(dir, "sneat", "spaces", "space-1", "space.yaml"), + RecordKey: "space-1", + FieldName: "token", + Message: fmt.Sprintf("value %s for field %q is not one of the permitted values", secretValue, "token"), + } + result.Append(validationErr) + return result + }(), + } + homeDir := func() (string, error) { return "/tmp/home", nil } + getWd := func() (string, error) { return dir, nil } + var logs []string + logf := func(args ...any) { + line := fmt.Sprint(args...) + logs = append(logs, line) + } + cmd := Validate(homeDir, getWd, readDef, dataVal, nil, logf) + err := runCobraCommand(cmd, "--path="+dir, "--safe-diagnostics") + if err == nil { + t.Fatal("expected validation failure") + } + message := err.Error() + for _, want := range []string{"collection \"spaces\"", "file \"sneat/spaces/space-1/space.yaml\"", "record \"space-1\"", "field \"token\"", "enum constraint failed"} { + if !strings.Contains(message, want) { + t.Fatalf("safe diagnostic %q does not contain %q", message, want) + } + } + for _, forbidden := range []string{secretValue, dir} { + if strings.Contains(message, forbidden) { + t.Fatalf("safe diagnostic %q contains forbidden value %q", message, forbidden) + } + for _, line := range logs { + if strings.Contains(line, forbidden) { + t.Fatalf("safe diagnostic log %q contains forbidden value %q", line, forbidden) + } + } + } +} + +func TestValidate_SafeDiagnosticsRedactDefinitionError(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + secretValue := "definition-secret-value" + readDef := func(_ string, _ ...ingitdb.ReadOption) (*ingitdb.Definition, error) { + return nil, errors.New(secretValue) + } + homeDir := func() (string, error) { return "/tmp/home", nil } + getWd := func() (string, error) { return dir, nil } + logf := func(...any) {} + cmd := Validate(homeDir, getWd, readDef, nil, nil, logf) + err := runCobraCommand(cmd, "--path="+dir, "--safe-diagnostics") + if err == nil { + t.Fatal("expected validation failure") + } + if strings.Contains(err.Error(), secretValue) { + t.Fatalf("safe definition diagnostic contains secret: %v", err) + } +} + func TestValidate_DataValidationError(t *testing.T) { t.Parallel() diff --git a/cmd/ingitdb/main.go b/cmd/ingitdb/main.go index e7fc99cd..75c41e78 100644 --- a/cmd/ingitdb/main.go +++ b/cmd/ingitdb/main.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "os" bubbletea "charm.land/bubbletea/v2" @@ -27,6 +28,10 @@ import ( var exit = os.Exit func main() { + guardProcess(executeMain, os.Stderr, exit) +} + +func executeMain() { fatal := func(err error) { _, _ = fmt.Fprintf(os.Stderr, "error: %v\n", err) exit(exitCodeForError(err)) @@ -37,6 +42,18 @@ func main() { run(os.Args, os.UserHomeDir, os.Getwd, validator.ReadDefinition, fatal, logf) } +func guardProcess(runCommand func(), errorWriter io.Writer, exitProcess func(int)) { + defer func() { + recovered := recover() + if recovered == nil { + return + } + _, _ = fmt.Fprintln(errorWriter, "error: command crashed") + exitProcess(1) + }() + runCommand() +} + func exitCodeForError(err error) int { if errors.Is(err, commands.ErrValidationFailed) { return commands.ValidationFailedExitCode diff --git a/cmd/ingitdb/main_test.go b/cmd/ingitdb/main_test.go index 9f6b8f1e..a14a3949 100644 --- a/cmd/ingitdb/main_test.go +++ b/cmd/ingitdb/main_test.go @@ -1,10 +1,12 @@ package main import ( + "bytes" "context" "errors" "fmt" "os" + "strings" "testing" "github.com/dal-go/dalgo/dal" @@ -134,6 +136,32 @@ func TestExitCodeForError(t *testing.T) { } } +func TestGuardProcess_RecoversPanicAsRuntimeFailure(t *testing.T) { + t.Parallel() + + secretValue := "record-secret-that-must-not-reach-stderr" + runCommand := func() { + panic(secretValue) + } + var stderr bytes.Buffer + exitCode := 0 + exitProcess := func(code int) { + exitCode = code + } + + guardProcess(runCommand, &stderr, exitProcess) + + if exitCode != 1 { + t.Fatalf("exit code = %d, want 1", exitCode) + } + if strings.Contains(stderr.String(), secretValue) { + t.Fatalf("panic output leaked secret: %q", stderr.String()) + } + if !strings.Contains(stderr.String(), "command crashed") { + t.Fatalf("panic output = %q, want generic crash diagnostic", stderr.String()) + } +} + func TestRun_ValidateDefaultPath(t *testing.T) { t.Parallel() diff --git a/docs/cli/commands/validate.md b/docs/cli/commands/validate.md index 9bda38a9..73fda3a1 100644 --- a/docs/cli/commands/validate.md +++ b/docs/cli/commands/validate.md @@ -4,7 +4,7 @@ ``` -commit aingitdb validate [--path=PATH] [--only=definition|records] [--from-commit=SHA] [--to-commit=SHA] +ingitdb validate [--path=PATH] [--only=definition|records] [--from-commit=SHA] [--to-commit=SHA] [--safe-diagnostics] ``` | Flag | Description | @@ -13,6 +13,7 @@ commit aingitdb validate [--path=PATH] [--only=definition|records] [--from-commi | `--only=VALUE` | Validate only `definition` or `records`. Omit to validate both. | | `--from-commit=SHA` | Validate only records changed since this commit. | | `--to-commit=SHA` | Validate only records up to this commit. | +| `--safe-diagnostics` | Report repository-relative finding identities and constraint classes without record values. | Validates the database schema and records in the `.ingitdb.yaml` file. By default, checks both the collection definitions and every record against its schema. Use `--only` to validate just @@ -26,6 +27,10 @@ from a validator that could not complete. Validation messages report record counts per collection (e.g., "All 42 records are valid for collection: users" or "38 out of 42 records are valid for collection: users"). +The CLI process boundary recovers unexpected panics as generic runtime failures +with exit code `1`; panic payloads and stack traces are not printed because they +may contain repository values. + **Examples:** ```shell @@ -46,6 +51,9 @@ ingitdb validate --from-commit=abc1234 --to-commit=def5678 # 🔁 Validate records changed in a commit range (skip schema validation) ingitdb validate --only=records --from-commit=abc1234 --to-commit=def5678 + +# 🔒 CI-safe full validation without record values in diagnostics +ingitdb validate --safe-diagnostics ``` --- diff --git a/spec/features/cli/validate/README.md b/spec/features/cli/validate/README.md index d21a8813..65e50342 100644 --- a/spec/features/cli/validate/README.md +++ b/spec/features/cli/validate/README.md @@ -54,7 +54,16 @@ The command MUST exit with status `0` when validation passes and a non-zero stat The command MUST exit with status `2` when repository definition or record validation completes with findings. Command configuration, validator startup, and other runtime failures MUST exit with status `1`; no non-validation -failure MAY be reported as status `2`. +failure MAY be reported as status `2`. The CLI process boundary MUST recover an +unexpected panic as a generic status-`1` runtime failure without printing the +panic payload or stack trace. + +#### REQ: safe-diagnostics + +The `--safe-diagnostics` flag MUST report repository-relative file identity, +collection, record key, field, and a stable constraint class where available, +while omitting rejected record values, full record bodies, absolute repository +paths, and raw definition/parser errors that could contain repository data. #### REQ: record-file-parsing @@ -104,6 +113,19 @@ Given validation cannot complete because the validator returns an operational error, when the command terminates, then it exits `1` rather than reporting the repository as invalid with status `2`. +Given command execution panics with a secret-bearing payload, when the process +boundary recovers, then it exits `1` with a generic crash diagnostic that does +not contain the panic payload or a stack trace. + +### AC: safe-diagnostics-redact-record-values + +**Requirements:** cli/validate#req:safe-diagnostics, cli/validate#req:stable-validation-failure-exit-code + +Given a record violates an enum constraint with a secret-bearing rejected +value, when `ingitdb validate --safe-diagnostics` runs, then it exits `2` and +names the collection, repository-relative file, record key, field, and enum +constraint without printing the rejected value or absolute repository path. + ## Open Questions - Should `--only=definition` ignore record files entirely, or only skip schema enforcement on them?