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 }