github.com/exercism/v2-configlet@v3.9.2+incompatible/cmd/lint.go (about)

     1  package cmd
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"fmt"
     7  	"net/http"
     8  	"os"
     9  	"strings"
    10  
    11  	"github.com/exercism/configlet/track"
    12  	"github.com/exercism/configlet/ui"
    13  	"github.com/spf13/cobra"
    14  )
    15  
    16  var (
    17  	// UUIDValidationURL is the endpoint to Exercism's UUID validation service.
    18  	UUIDValidationURL = "http://exercism.io/api/v1/uuids"
    19  	// noHTTP flag indicates if HTTP-based lint checks have been disabled at runtime.
    20  	noHTTP bool
    21  	// trackID flag allows the user to specify the ID of the track,
    22  	// for example if it is different to the local directory name
    23  	trackID string
    24  )
    25  
    26  // lintCmd defines the lint command.
    27  var lintCmd = &cobra.Command{
    28  	Use:   "lint " + pathExample,
    29  	Short: "Ensure that the track is configured correctly",
    30  	Long: `The lint command checks for any discrepancies in a track's configuration files.
    31  
    32  It ensures the following files are valid JSON:
    33  	config.json, maintainers.json
    34  
    35  It also checks that the exercises defined in the config.json file are complete.
    36  `,
    37  	Example: lintExampleText(),
    38  	Run:     runLint,
    39  	Args:    cobra.ExactArgs(1),
    40  }
    41  
    42  func lintExampleText() string {
    43  	cmds := []string{
    44  		"%[1]s lint %[2]s",
    45  		"%[1]s lint %[2]s --no-http",
    46  		"%[1]s lint %[2]s --track-id=<track id>",
    47  	}
    48  	s := "  " + strings.Join(cmds, "\n\n  ")
    49  	return fmt.Sprintf(s, binaryName, pathExample)
    50  }
    51  
    52  func runLint(cmd *cobra.Command, args []string) {
    53  	var hasErrors bool
    54  	for _, arg := range args {
    55  		if failed := lintTrack(arg); failed {
    56  			hasErrors = true
    57  		}
    58  	}
    59  	if hasErrors {
    60  		os.Exit(1)
    61  	}
    62  }
    63  
    64  func lintTrack(path string) bool {
    65  	if _, err := os.Stat(path); os.IsNotExist(err) {
    66  		ui.PrintError("path not found:", path)
    67  		return true
    68  	}
    69  
    70  	t, err := track.New(path)
    71  	if err != nil {
    72  		ui.PrintError(err.Error())
    73  		return true
    74  	}
    75  
    76  	if trackID != "" {
    77  		t.ID = trackID
    78  	}
    79  
    80  	configErrors := []struct {
    81  		check func(track.Track) []string
    82  		msg   string
    83  	}{
    84  		{
    85  			check: missingImplementations,
    86  			msg:   "An exercise with slug '%v' is referenced in config.json, but no implementation was found.",
    87  		},
    88  		{
    89  			check: missingMetadata,
    90  			msg:   "An implementation for '%v' was found, but config.json does not reference this exercise.",
    91  		},
    92  		{
    93  			check: missingReadme,
    94  			msg:   "The implementation for '%v' is missing a README.",
    95  		},
    96  		{
    97  			check: missingSolution,
    98  			msg:   "The implementation for '%v' is missing an example solution.",
    99  		},
   100  		{
   101  			check: missingTestSuite,
   102  			msg:   "The implementation for '%v' is missing a test suite.",
   103  		},
   104  		{
   105  			check: missingUUID,
   106  			msg:   "The exercise '%v' was found in config.json, but does not have a UUID.",
   107  		},
   108  		{
   109  			check: foregoneViolations,
   110  			msg:   "An implementation for '%v' was found, but config.json specifies that it should be foregone (not implemented).",
   111  		},
   112  		{
   113  			check: duplicateSlugs,
   114  			msg:   "The exercise '%v' was found in multiple (conflicting) categories in config.json.",
   115  		},
   116  		{
   117  			check: duplicateUUID,
   118  			msg:   "The following UUID occurs multiple times. Each exercise UUID must be unique.\n%v",
   119  		},
   120  		{
   121  			check: duplicateTrackUUID,
   122  			msg:   "The following UUID was found in multiple Exercism tracks. Each exercise UUID must be unique across tracks.\n%v",
   123  		},
   124  		{
   125  			check: lockedCoreViolation,
   126  			msg:   "The exercise '%v' is marked as core and unlocked by another exercise. A core exercise should not be unlocked by another.",
   127  		},
   128  		{
   129  			check: unlockedByValidExercise,
   130  			msg:   "The exercise '%v' is being unlocked by a non-core exercise. Non-core exercises can only be unlocked by core exercises.",
   131  		},
   132  	}
   133  
   134  	var hasErrors bool
   135  	for _, configError := range configErrors {
   136  		failedItems := configError.check(t)
   137  
   138  		if len(failedItems) > 0 {
   139  			hasErrors = true
   140  			for _, item := range failedItems {
   141  				ui.Print(fmt.Sprintf(configError.msg, item))
   142  
   143  			}
   144  		}
   145  	}
   146  	return hasErrors
   147  }
   148  
   149  func missingImplementations(t track.Track) []string {
   150  	metadata := map[string]bool{}
   151  	for _, exercise := range t.Config.Exercises {
   152  		metadata[exercise.Slug] = false
   153  	}
   154  	// Don't report missing implementations on foregone exercises.
   155  	for _, slug := range t.Config.ForegoneSlugs {
   156  		metadata[slug] = true
   157  	}
   158  	for _, exercise := range t.Exercises {
   159  		metadata[exercise.Slug] = true
   160  	}
   161  
   162  	slugs := []string{}
   163  	for slug, ok := range metadata {
   164  		if !ok {
   165  			slugs = append(slugs, slug)
   166  		}
   167  	}
   168  	return slugs
   169  }
   170  
   171  func missingMetadata(t track.Track) []string {
   172  	implementations := map[string]bool{}
   173  	for _, exercise := range t.Exercises {
   174  		implementations[exercise.Slug] = false
   175  	}
   176  
   177  	// Don't report missing metadata if the exercise is deprecated or foregone.
   178  	ignoredSlugs := append(t.Config.DeprecatedSlugs, t.Config.ForegoneSlugs...)
   179  	for _, slug := range ignoredSlugs {
   180  		implementations[slug] = true
   181  	}
   182  
   183  	for _, exercise := range t.Config.Exercises {
   184  		implementations[exercise.Slug] = true
   185  	}
   186  
   187  	slugs := []string{}
   188  	for slug, ok := range implementations {
   189  		if !ok {
   190  			slugs = append(slugs, slug)
   191  		}
   192  	}
   193  
   194  	return slugs
   195  }
   196  
   197  func missingSolution(t track.Track) []string {
   198  	solutions := map[string]bool{}
   199  	for _, exercise := range t.Exercises {
   200  		solutions[exercise.Slug] = exercise.IsValid()
   201  	}
   202  	// Don't complain about missing solutions in foregone exercises.
   203  	for _, slug := range t.Config.ForegoneSlugs {
   204  		solutions[slug] = true
   205  	}
   206  
   207  	slugs := []string{}
   208  	for slug, ok := range solutions {
   209  		if !ok {
   210  			slugs = append(slugs, slug)
   211  		}
   212  	}
   213  	return slugs
   214  }
   215  
   216  func missingReadme(t track.Track) []string {
   217  	readmes := map[string]bool{}
   218  	for _, exercise := range t.Exercises {
   219  		readmes[exercise.Slug] = exercise.HasReadme()
   220  	}
   221  	// Don't complain about missing readmes in foregone exercises.
   222  	for _, slug := range t.Config.ForegoneSlugs {
   223  		readmes[slug] = true
   224  	}
   225  
   226  	slugs := []string{}
   227  	for slug, ok := range readmes {
   228  		if !ok {
   229  			slugs = append(slugs, slug)
   230  		}
   231  	}
   232  	return slugs
   233  }
   234  
   235  func missingTestSuite(t track.Track) []string {
   236  	tests := map[string]bool{}
   237  	for _, exercise := range t.Exercises {
   238  		tests[exercise.Slug] = exercise.HasTestSuite()
   239  	}
   240  	// Don't complain about missing test suite in foregone exercises.
   241  	for _, slug := range t.Config.ForegoneSlugs {
   242  		tests[slug] = true
   243  	}
   244  
   245  	slugs := []string{}
   246  	for slug, ok := range tests {
   247  		if !ok {
   248  			slugs = append(slugs, slug)
   249  		}
   250  	}
   251  	return slugs
   252  }
   253  
   254  func missingUUID(t track.Track) []string {
   255  	slugs := []string{}
   256  	for _, exercise := range t.Config.Exercises {
   257  		if exercise.UUID == "" {
   258  			slugs = append(slugs, exercise.Slug)
   259  		}
   260  	}
   261  
   262  	return slugs
   263  }
   264  
   265  func foregoneViolations(t track.Track) []string {
   266  	violations := map[string]bool{}
   267  	for _, slug := range t.Config.ForegoneSlugs {
   268  		violations[slug] = true
   269  	}
   270  
   271  	slugs := []string{}
   272  	for _, exercise := range t.Exercises {
   273  		if violations[exercise.Slug] {
   274  			slugs = append(slugs, exercise.Slug)
   275  		}
   276  	}
   277  
   278  	return slugs
   279  }
   280  
   281  func duplicateSlugs(t track.Track) []string {
   282  	counts := map[string]int{}
   283  	for _, slug := range t.Config.ForegoneSlugs {
   284  		counts[slug]++
   285  	}
   286  	for _, slug := range t.Config.DeprecatedSlugs {
   287  		counts[slug]++
   288  	}
   289  	for _, exercise := range t.Config.Exercises {
   290  		counts[exercise.Slug]++
   291  	}
   292  
   293  	slugs := []string{}
   294  	for slug, count := range counts {
   295  		if count > 1 {
   296  			slugs = append(slugs, slug)
   297  		}
   298  	}
   299  	return slugs
   300  }
   301  
   302  func duplicateUUID(t track.Track) []string {
   303  	uuids := []string{}
   304  	seen := map[string]bool{}
   305  	for _, exercise := range t.Config.Exercises {
   306  		if exercise.UUID == "" {
   307  			continue
   308  		}
   309  
   310  		if seen[exercise.UUID] {
   311  			uuids = append(uuids, exercise.UUID)
   312  		}
   313  
   314  		seen[exercise.UUID] = true
   315  	}
   316  
   317  	return uuids
   318  }
   319  
   320  func duplicateTrackUUID(t track.Track) []string {
   321  	if noHTTP {
   322  		return []string{}
   323  	}
   324  
   325  	// Build up set of uuids to validate.
   326  	uuids := []string{}
   327  	for _, exercise := range t.Config.Exercises {
   328  		if exercise.UUID == "" {
   329  			continue
   330  		}
   331  		uuids = append(uuids, exercise.UUID)
   332  	}
   333  
   334  	payload := struct {
   335  		TrackID string   `json:"track_id"`
   336  		UUIDs   []string `json:"uuids"`
   337  	}{
   338  		TrackID: t.ID,
   339  		UUIDs:   uuids,
   340  	}
   341  
   342  	body, err := json.Marshal(payload)
   343  	if err != nil {
   344  		ui.PrintError(err.Error())
   345  		os.Exit(1)
   346  	}
   347  
   348  	resp, err := http.Post(UUIDValidationURL, "application/json", bytes.NewBuffer(body))
   349  	if err != nil {
   350  		ui.PrintError(err.Error())
   351  		os.Exit(1)
   352  	}
   353  	defer resp.Body.Close()
   354  
   355  	if resp.StatusCode == http.StatusConflict {
   356  		result := struct{ UUIDs []string }{}
   357  		if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
   358  			ui.PrintError(err.Error())
   359  			os.Exit(1)
   360  		}
   361  
   362  		return result.UUIDs
   363  	}
   364  
   365  	return []string{}
   366  }
   367  
   368  func lockedCoreViolation(t track.Track) []string {
   369  	slugs := []string{}
   370  	for _, exercise := range t.Config.Exercises {
   371  		if exercise.IsCore && exercise.UnlockedBy != nil {
   372  			slugs = append(slugs, exercise.Slug)
   373  		}
   374  	}
   375  
   376  	return slugs
   377  }
   378  
   379  func unlockedByValidExercise(t track.Track) []string {
   380  	slugs := []string{}
   381  	valid := map[string]bool{}
   382  
   383  	for _, exercise := range t.Config.Exercises {
   384  		if exercise.IsCore {
   385  			valid[exercise.Slug] = true
   386  		}
   387  	}
   388  
   389  	for _, exercise := range t.Config.Exercises {
   390  		if exercise.UnlockedBy == nil {
   391  			continue
   392  		}
   393  
   394  		if !valid[*exercise.UnlockedBy] {
   395  			slugs = append(slugs, exercise.Slug)
   396  		}
   397  	}
   398  
   399  	return slugs
   400  }
   401  
   402  func init() {
   403  	RootCmd.AddCommand(lintCmd)
   404  	lintCmd.Flags().BoolVar(&noHTTP, "no-http", false, "Disable remote HTTP-based linting.")
   405  	lintCmd.Flags().StringVar(&trackID, "track-id", "", "Specify the track ID (defaults to local directory name).")
   406  }