@@ -12,7 +12,10 @@ import (
1212 "gopkg.in/yaml.v3"
1313
1414 "github.com/soda-data-inc/soda-cli/internal/api"
15+ "github.com/soda-data-inc/soda-cli/internal/config"
16+ "github.com/soda-data-inc/soda-cli/internal/lint"
1517 "github.com/soda-data-inc/soda-cli/internal/output"
18+ "github.com/soda-data-inc/soda-cli/internal/sodacore"
1619)
1720
1821var contractCmd = & cobra.Command {
@@ -276,20 +279,99 @@ var contractDiffCmd = &cobra.Command{
276279// ── contract lint ─────────────────────────────────────────────────────────────
277280
278281var contractLintCmd = & cobra.Command {
279- Use : "lint [file]" ,
282+ Use : "lint [file... ]" ,
280283 Aliases : []string {"validate" },
281284 Short : "Validate contract syntax (no network required)" ,
285+ Long : `Validate contract YAML files against the Soda data contract schema.
286+
287+ Checks structure, property names, and value types without connecting to Soda Cloud.
288+ Supports multiple files and glob patterns.
289+
290+ Exit codes: 0=valid, 2=validation errors found` ,
282291 RunE : func (cmd * cobra.Command , args []string ) error {
283- file := "contracts/*.yml"
284- if len (args ) > 0 {
285- file = args [ 0 ]
292+ files := resolveLintFiles ( args )
293+ if len (files ) == 0 {
294+ return output . Errorf ( 2 , "no contract files found — provide file paths or place contracts in a contracts/ directory" )
286295 }
287- fmt .Println (output .Dim .Render (" Linting " + file + "..." ))
288- output .PrintSuccess ("Contract syntax is valid." , GCtx )
289- return nil
296+
297+ results , err := lint .LintFiles (files )
298+ if err != nil {
299+ return output .Errorf (2 , "lint error: %v" , err )
300+ }
301+
302+ return displayLintResults (results )
290303 },
291304}
292305
306+ // resolveLintFiles expands arguments (which may contain globs) into file paths.
307+ // Falls back to contracts/*.yml then *.yml in the current directory.
308+ func resolveLintFiles (args []string ) []string {
309+ if len (args ) > 0 {
310+ var files []string
311+ for _ , arg := range args {
312+ matches , err := filepath .Glob (arg )
313+ if err != nil || len (matches ) == 0 {
314+ // Treat as literal file path
315+ files = append (files , arg )
316+ } else {
317+ files = append (files , matches ... )
318+ }
319+ }
320+ return files
321+ }
322+ // Default: contracts/*.yml, then *.yml
323+ if matches , _ := filepath .Glob ("contracts/*.yml" ); len (matches ) > 0 {
324+ return matches
325+ }
326+ if matches , _ := filepath .Glob ("*.yml" ); len (matches ) > 0 {
327+ return matches
328+ }
329+ return nil
330+ }
331+
332+ func displayLintResults (results []* lint.LintResult ) error {
333+ if output .EffectiveFmt (GCtx ) == "json" {
334+ s , err := lint .ResultsJSON (results )
335+ if err != nil {
336+ return output .Errorf (2 , "failed to encode results: %v" , err )
337+ }
338+ fmt .Println (s )
339+ for _ , r := range results {
340+ if ! r .Valid {
341+ return output .Errorf (2 , "" )
342+ }
343+ }
344+ return nil
345+ }
346+
347+ hasErrors := false
348+ totalFiles := len (results )
349+ validFiles := 0
350+
351+ for _ , r := range results {
352+ if r .Valid {
353+ validFiles ++
354+ if ! GCtx .Quiet {
355+ fmt .Println (" " + output .Green .Render ("✓" ) + " " + r .File )
356+ }
357+ } else {
358+ hasErrors = true
359+ fmt .Println (" " + output .Red .Render ("✗" ) + " " + r .File )
360+ for _ , e := range r .Errors {
361+ fmt .Printf (" %s: %s\n " , output .Dim .Render (e .Path ), e .Message )
362+ }
363+ }
364+ }
365+
366+ fmt .Println ()
367+ if hasErrors {
368+ invalidFiles := totalFiles - validFiles
369+ return output .Errorf (2 , "%d file(s) checked, %d valid, %d with errors" , totalFiles , validFiles , invalidFiles )
370+ }
371+ output .PrintSuccess (fmt .Sprintf ("All %d contract file(s) are valid." , totalFiles ), GCtx )
372+ return nil
373+ }
374+
293375// ── contract create ───────────────────────────────────────────────────────────
294376
295377var contractCreateCmd = & cobra.Command {
@@ -570,13 +652,23 @@ var contractVerifyCmd = &cobra.Command{
570652 Short : "Run contract checks against your data" ,
571653 Long : `Execute data quality checks defined in a contract file.
572654
573- Pushes the contract to Soda Cloud and triggers verification via a Runner.
655+ By default, pushes the contract to Soda Cloud and triggers verification via a Runner.
574656 Polls for results and displays a summary.
575657
658+ With --local, runs verification locally via soda-core (must be on PATH).
659+ In local mode, --datasource <config.yml> is required.
660+ Use --push to publish local results to Soda Cloud.
661+
576662 Exit codes: 0=all passing, 1=checks failed, 2=error, 3=auth error` ,
577663 Args : cobra .ExactArgs (1 ),
578664 RunE : func (cmd * cobra.Command , args []string ) error {
579665 file := args [0 ]
666+ local , _ := cmd .Flags ().GetBool ("local" )
667+
668+ if local {
669+ return runContractVerifyLocal (cmd , file )
670+ }
671+
580672 noWait , _ := cmd .Flags ().GetBool ("no-wait" )
581673
582674 client , err := newAPIClient ()
@@ -748,6 +840,107 @@ done:
748840 }
749841}
750842
843+ // ── contract verify --local ───────────────────────────────────────────────────
844+
845+ func runContractVerifyLocal (cmd * cobra.Command , contractFile string ) error {
846+ // Validate contract file exists
847+ if _ , err := os .Stat (contractFile ); err != nil {
848+ return output .Errorf (2 , "could not read file %s: %v" , contractFile , err )
849+ }
850+
851+ // --datasource is required in local mode
852+ datasourceFile , _ := cmd .Flags ().GetString ("datasource" )
853+ if datasourceFile == "" {
854+ return output .Errorf (2 , "--datasource <config-file> is required in local mode\n Example: sodacli contract verify orders.yml --local --datasource datasource.yml" )
855+ }
856+ if _ , err := os .Stat (datasourceFile ); err != nil {
857+ return output .Errorf (2 , "datasource config file not found: %s" , datasourceFile )
858+ }
859+
860+ // --no-wait doesn't apply in local mode
861+ if noWait , _ := cmd .Flags ().GetBool ("no-wait" ); noWait {
862+ fmt .Println (output .Yellow .Render (" Warning:" ) + " --no-wait is ignored in local mode (soda-core runs synchronously)" )
863+ }
864+
865+ // Find soda-core binary
866+ binPath , err := sodacore .FindBinary ()
867+ if err != nil {
868+ return err
869+ }
870+
871+ // Show version in verbose mode
872+ if GCtx .Verbose {
873+ version := sodacore .CheckVersion (binPath )
874+ fmt .Println (output .Dim .Render (" soda-core version: " + version ))
875+ }
876+
877+ // Handle --push: build temp soda-cloud config
878+ push , _ := cmd .Flags ().GetBool ("push" )
879+ var cloudConfigPath string
880+ var cleanup func ()
881+ if push {
882+ creds , err := config .LoadCredentials ()
883+ if err != nil {
884+ return output .Errorf (2 , "could not read credentials for --push: %v" , err )
885+ }
886+ profile , err := config .GetProfile (GCtx .Profile , creds )
887+ if err != nil {
888+ return output .Errorf (3 , "--push requires authentication: %v" , err )
889+ }
890+ cloudConfigPath , cleanup , err = sodacore .WriteTempCloudConfig (profile .Host , profile .APIKeyID , profile .APIKeySecret )
891+ if err != nil {
892+ return output .Errorf (2 , "could not create soda-cloud config: %v" , err )
893+ }
894+ defer cleanup ()
895+ }
896+
897+ // Build args
898+ setVars , _ := cmd .Flags ().GetStringArray ("set" )
899+ opts := sodacore.VerifyOpts {
900+ ContractFile : contractFile ,
901+ DatasourceFile : datasourceFile ,
902+ SetVars : setVars ,
903+ Verbose : GCtx .Verbose ,
904+ Publish : push ,
905+ SodaCloudFile : cloudConfigPath ,
906+ }
907+ cliArgs := sodacore .BuildVerifyArgs (opts )
908+
909+ // Print what we're about to do
910+ if ! GCtx .Quiet {
911+ fmt .Println (output .Dim .Render (" Running locally via soda-core..." ))
912+ if GCtx .Verbose {
913+ fmt .Println (output .Dim .Render (" Command: soda " + strings .Join (cliArgs , " " )))
914+ }
915+ }
916+
917+ // Execute
918+ stream := output .EffectiveFmt (GCtx ) != "json"
919+ result , err := sodacore .Run (binPath , cliArgs , stream )
920+ if err != nil {
921+ return output .Errorf (2 , "failed to execute soda-core: %v" , err )
922+ }
923+
924+ // JSON output mode: wrap soda-core output
925+ if ! stream {
926+ fmt .Printf (`{"local": true, "exit_code": %d, "output": %q}` + "\n " , result .ExitCode , result .Stdout )
927+ }
928+
929+ // Map exit code
930+ mapped := sodacore .MapExitCode (result .ExitCode , result .Stderr )
931+ if mapped == 0 {
932+ if ! GCtx .Quiet && stream {
933+ output .PrintSuccess ("Local verification passed." , GCtx )
934+ }
935+ return nil
936+ }
937+ if mapped == 1 {
938+ // soda-core already printed the failure summary; just set the exit code.
939+ return output .Errorf (1 , "" )
940+ }
941+ return output .Errorf (2 , "soda-core exited with error (exit code: %d)" , result .ExitCode )
942+ }
943+
751944// ── contract proposal ─────────────────────────────────────────────────────────
752945
753946var contractProposalCmd = & cobra.Command {
@@ -904,8 +1097,8 @@ func init() {
9041097 contractCopilotCmd .Flags ().String ("dataset" , "" , "Dataset FQN to generate from" )
9051098 contractCopilotCmd .Flags ().String ("output" , "" , "Output file path" )
9061099
907- contractVerifyCmd .Flags ().String ("datasource" , "" , "Datasource config file override " )
908- contractVerifyCmd .Flags ().Bool ("runner " , false , "Delegate execution to Soda Runner " )
1100+ contractVerifyCmd .Flags ().String ("datasource" , "" , "Datasource config file (required with --local) " )
1101+ contractVerifyCmd .Flags ().Bool ("local " , false , "Run verification locally via soda-core (requires soda-core on PATH) " )
9091102 contractVerifyCmd .Flags ().Bool ("push" , false , "Push results to Soda Cloud after verification" )
9101103 contractVerifyCmd .Flags ().Bool ("no-wait" , false , "Start verification and return immediately without waiting for results" )
9111104 contractVerifyCmd .Flags ().StringArray ("set" , nil , "Runtime variable overrides (key=value)" )
0 commit comments