-
Notifications
You must be signed in to change notification settings - Fork 7
feat(lint): native config lint command for Flex and Fixed config #108
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
pjcdawkins
wants to merge
17
commits into
main
Choose a base branch
from
feat/native-lint-command
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
1e62dbf
feat(lint): port pure config linter from ai-api
pjcdawkins eea3fdf
feat(lint): add native lint command for Flex config
pjcdawkins 237bfa5
feat(lint): support Fixed-style (legacy Platform.sh) config
pjcdawkins 792b80b
feat(lint): refresh embedded registry and schemas from upstream
pjcdawkins 13312b6
docs(lint): document path argument and output flags
pjcdawkins 0519b41
fix(lint): don't read stdin in non-interactive contexts; review fixes
pjcdawkins 61aaaa1
fix(lint): sort available route targets for deterministic output
pjcdawkins 9c59c35
feat(lint): resolve project root and detect style from vendor config
pjcdawkins 1c6d012
feat(lint): clearer command output
pjcdawkins def32c4
Drop GOEXPERIMENT=jsonv2
bojanz cc0ecc5
fix(lint): build Flex glob patterns with forward slashes
pjcdawkins 2721f42
fix(lint): always emit JSON arrays for errors and warnings
pjcdawkins ae3c6cc
feat(lint): check the shell syntax of worker commands
pjcdawkins 539ea49
chore(lint): drop unused registry helper and tidy tests and docs
pjcdawkins 96f8006
fix(lint): accept valkey-persistent and replica service types
pjcdawkins 7b93002
fix(lint): scope worker names to their application
pjcdawkins 709c3c1
chore(lint): refresh embedded registry and derive replica types
pjcdawkins File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| package commands | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "os" | ||
| "strings" | ||
| "unicode" | ||
|
|
||
| "github.com/fatih/color" | ||
| "github.com/spf13/cobra" | ||
|
|
||
| "github.com/upsun/cli/internal/config" | ||
| "github.com/upsun/cli/internal/lint" | ||
| ) | ||
|
|
||
| // errLintFailed signals that the configuration has errors, for a non-zero exit | ||
| // code. Its message is empty because output is printed by the command itself. | ||
| var errLintFailed = errors.New("") | ||
|
|
||
| func newLintCommand(cnf *config.Config) *cobra.Command { | ||
| cmd := &cobra.Command{ | ||
| Use: "app:config-validate [path]", | ||
| Short: "Validate project configuration", | ||
| Aliases: []string{"lint", "validate"}, | ||
| Args: cobra.MaximumNArgs(1), | ||
| SilenceErrors: true, | ||
| SilenceUsage: true, | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| return runLint(cmd, args, vendorFromConfig(cnf)) | ||
| }, | ||
| } | ||
| cmd.Flags().Bool("stdin", false, "Read merged Flex configuration from standard input") | ||
| cmd.Flags().String("format", "text", "Output format: text or json") | ||
| cmd.SetHelpFunc(func(_ *cobra.Command, _ []string) { | ||
| internalCmd := innerAppConfigValidateCommand(cnf) | ||
| fmt.Println(internalCmd.HelpPage(cnf)) | ||
| }) | ||
| return cmd | ||
| } | ||
|
|
||
| // vendorFromConfig builds the linter's vendor conventions from the CLI config. | ||
| func vendorFromConfig(cnf *config.Config) lint.Vendor { | ||
| return lint.Vendor{ | ||
| Flavor: cnf.Service.ProjectConfigFlavor, | ||
| ConfigDir: cnf.Service.ProjectConfigDir, | ||
| AppFile: cnf.Service.AppConfigFile, | ||
| } | ||
| } | ||
|
|
||
| func runLint(cmd *cobra.Command, args []string, vendor lint.Vendor) error { | ||
| result, format, err := lintInput(cmd, args, vendor) | ||
| if err != nil { | ||
| // Print operational errors ourselves, since the command silences errors. | ||
| // Go error strings are lowercase by convention; capitalize for display. | ||
| fmt.Fprintln(cmd.ErrOrStderr(), color.RedString(capitalizeFirst(err.Error()))) | ||
| return errLintFailed | ||
| } | ||
| return printLintResult(cmd, result, format) | ||
| } | ||
|
|
||
| func lintInput(cmd *cobra.Command, args []string, vendor lint.Vendor) (*lint.Result, string, error) { | ||
| explicitStdin, _ := cmd.Flags().GetBool("stdin") | ||
| format, _ := cmd.Flags().GetString("format") | ||
| if format != "text" && format != "json" { | ||
| return nil, "", fmt.Errorf("invalid --format %q: must be \"text\" or \"json\"", format) | ||
| } | ||
|
|
||
| ctx := cmd.Context() | ||
| if explicitStdin { | ||
| result, err := lintStdin(ctx, cmd) | ||
| return result, format, err | ||
| } | ||
|
|
||
| // With no path argument, lint piped stdin if it carries content; otherwise | ||
| // (e.g. a non-interactive shell or CI with no input) fall back to the directory. | ||
| if len(args) == 0 && stdinIsPiped() { | ||
| content, err := io.ReadAll(cmd.InOrStdin()) | ||
| if err != nil { | ||
| return nil, format, err | ||
| } | ||
| if strings.TrimSpace(string(content)) != "" { | ||
| result, err := lint.CheckContent(ctx, string(content)) | ||
| return result, format, err | ||
| } | ||
| } | ||
|
|
||
| path := "." | ||
| if len(args) == 1 { | ||
| path = args[0] | ||
| } | ||
| root := lint.FindProjectRoot(path) | ||
| if format == "text" { | ||
| fmt.Fprintln(cmd.ErrOrStderr(), "Validating configuration in directory: "+color.CyanString(root)) | ||
| } | ||
| result, _, err := lint.CheckDir(ctx, root, vendor) | ||
| return result, format, err | ||
| } | ||
|
|
||
| // capitalizeFirst upper-cases the first rune of s for user-facing display. | ||
| func capitalizeFirst(s string) string { | ||
| if s == "" { | ||
| return s | ||
| } | ||
| r := []rune(s) | ||
| r[0] = unicode.ToUpper(r[0]) | ||
| return string(r) | ||
| } | ||
|
|
||
| // lintStdin reads configuration from standard input and lints it. | ||
| func lintStdin(ctx context.Context, cmd *cobra.Command) (*lint.Result, error) { | ||
| content, err := io.ReadAll(cmd.InOrStdin()) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| return lint.CheckContent(ctx, string(content)) | ||
| } | ||
|
|
||
| // stdinIsPiped reports whether standard input is a pipe or file rather than a terminal. | ||
| func stdinIsPiped() bool { | ||
| stat, err := os.Stdin.Stat() | ||
| return err == nil && (stat.Mode()&os.ModeCharDevice) == 0 | ||
| } | ||
|
|
||
| // issuesOrEmpty replaces a nil slice with an empty one, so that the JSON output | ||
| // always contains arrays rather than null. | ||
| func issuesOrEmpty(issues []lint.Issue) []lint.Issue { | ||
| if issues == nil { | ||
| return []lint.Issue{} | ||
| } | ||
| return issues | ||
| } | ||
|
|
||
| func printLintResult(cmd *cobra.Command, result *lint.Result, format string) error { | ||
| if format == "json" { | ||
| out := struct { | ||
| Errors []lint.Issue `json:"errors"` | ||
| Warnings []lint.Issue `json:"warnings"` | ||
| }{Errors: issuesOrEmpty(result.Errors), Warnings: issuesOrEmpty(result.Warnings)} | ||
| enc := json.NewEncoder(cmd.OutOrStdout()) | ||
| enc.SetIndent("", " ") | ||
| if err := enc.Encode(out); err != nil { | ||
| return err | ||
| } | ||
| if result.HasErrors() { | ||
| return errLintFailed | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| w := cmd.ErrOrStderr() | ||
| printIssueSection(w, color.New(color.FgRed, color.Bold), "Linter errors:", result.ErrorLines()) | ||
| printIssueSection(w, color.New(color.FgYellow, color.Bold), "Linter warnings:", result.WarningLines()) | ||
| if result.HasErrors() { | ||
| return errLintFailed | ||
| } | ||
| if !result.HasWarnings() { | ||
| fmt.Fprintln(w, color.GreenString("✓")+" The configuration is valid.") | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // printIssueSection prints a colored heading followed by the issue lines in the | ||
| // default color. It is a no-op when there are no lines. | ||
| func printIssueSection(w io.Writer, heading *color.Color, title string, lines []string) { | ||
| if len(lines) == 0 { | ||
| return | ||
| } | ||
| fmt.Fprintln(w, heading.Sprint(title)) | ||
| fmt.Fprintln(w, strings.Join(lines, "\n")) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| package commands | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "encoding/json" | ||
| "io" | ||
| "testing" | ||
|
|
||
| "github.com/spf13/cobra" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
|
|
||
| "github.com/upsun/cli/internal/lint" | ||
| ) | ||
|
|
||
| // The JSON output documents "errors" and "warnings" as arrays, so a valid | ||
| // configuration must still yield arrays rather than null. | ||
| func TestPrintLintResult_JSONAlwaysArrays(t *testing.T) { | ||
| cmd := &cobra.Command{} | ||
| var out bytes.Buffer | ||
| cmd.SetOut(&out) | ||
| cmd.SetErr(io.Discard) | ||
|
|
||
| require.NoError(t, printLintResult(cmd, &lint.Result{}, "json")) | ||
|
|
||
| // Pointers distinguish a JSON null from an empty array. | ||
| var decoded struct { | ||
| Errors *[]lint.Issue `json:"errors"` | ||
| Warnings *[]lint.Issue `json:"warnings"` | ||
| } | ||
| require.NoError(t, json.Unmarshal(out.Bytes(), &decoded)) | ||
| require.NotNil(t, decoded.Errors, "errors should be an array, not null") | ||
| require.NotNil(t, decoded.Warnings, "warnings should be an array, not null") | ||
| assert.Empty(t, *decoded.Errors) | ||
| assert.Empty(t, *decoded.Warnings) | ||
| } | ||
|
|
||
| func TestPrintLintResult_JSONWithIssues(t *testing.T) { | ||
| cmd := &cobra.Command{} | ||
| var out bytes.Buffer | ||
| cmd.SetOut(&out) | ||
| cmd.SetErr(io.Discard) | ||
|
|
||
| result := &lint.Result{} | ||
| result.AddError("applications.app1.type", "invalid type") | ||
| result.AddWarning("applications.app1.web.commands.start", "a start command is needed") | ||
|
|
||
| require.ErrorIs(t, printLintResult(cmd, result, "json"), errLintFailed) | ||
|
|
||
| var decoded struct { | ||
| Errors []lint.Issue `json:"errors"` | ||
| Warnings []lint.Issue `json:"warnings"` | ||
| } | ||
| require.NoError(t, json.Unmarshal(out.Bytes(), &decoded)) | ||
| assert.Equal(t, []lint.Issue{{Path: "applications.app1.type", Message: "invalid type"}}, decoded.Errors) | ||
| assert.Equal(t, []lint.Issue{ | ||
| {Path: "applications.app1.web.commands.start", Message: "a start command is needed"}, | ||
| }, decoded.Warnings) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.