From 382a6c8604a70052a743ade91cd5dcf82ef695db Mon Sep 17 00:00:00 2001 From: Jeroen Engels Date: Sun, 16 Feb 2020 19:03:49 +0100 Subject: [PATCH] Add NoInvalidLicense --- src/NoInvalidLicense.elm | 227 +++++++++++++++++++++++++++++++++ tests/NoInvalidLicenseTest.elm | 133 +++++++++++++++++++ 2 files changed, 360 insertions(+) create mode 100644 src/NoInvalidLicense.elm create mode 100644 tests/NoInvalidLicenseTest.elm diff --git a/src/NoInvalidLicense.elm b/src/NoInvalidLicense.elm new file mode 100644 index 00000000..6e187a60 --- /dev/null +++ b/src/NoInvalidLicense.elm @@ -0,0 +1,227 @@ +module NoInvalidLicense exposing (rule) + +{-| Forbid the use of dependencies that use unknown or forbidden licenses. + + +# Rule + +@docs rule + +-} + +import Dict exposing (Dict) +import Elm.License +import Elm.Package +import Elm.Project +import Elm.Syntax.ModuleName exposing (ModuleName) +import Elm.Syntax.Node exposing (Node) +import Elm.Syntax.Range exposing (Range) +import Review.Project +import Review.Rule as Rule exposing (Error, Rule) +import Set exposing (Set) + + +{-| Forbid the use of dependencies that use unknown or forbidden licenses. + + config = + [ NoInvalidLicense.rule + { allowed = [ "BSD-3-Clause", "MIT" ] + , forbidden = [ "GPL-3.0-only", "GPL-3.0-or-later" ] + } + ] + +-} +rule : Configuration -> Rule +rule configuration = + Rule.newProjectRuleSchema "NoInvalidLicense" + { moduleVisitorSchema = moduleVisitorSchema + , initProjectContext = initProjectContext + , fromProjectToModule = fromProjectToModule + , fromModuleToProject = fromModuleToProject + , foldProjectContexts = foldProjectContexts + } + |> Rule.withProjectElmJsonVisitor elmJsonVisitor + |> Rule.withProjectDependenciesVisitor dependenciesVisitor + |> Rule.withFinalProjectEvaluation (finalEvaluationForProject configuration) + |> Rule.fromProjectRuleSchema + + +type alias Configuration = + { allowed : List String + , forbidden : List String + } + + +moduleVisitorSchema : Rule.ModuleRuleSchema {} ModuleContext -> Rule.ModuleRuleSchema { hasAtLeastOneVisitor : () } ModuleContext +moduleVisitorSchema schema = + schema + |> Rule.withModuleDefinitionVisitor (\_ context -> ( [], context )) + + +dependenciesVisitor : Dict String Review.Project.Dependency -> ProjectContext -> ProjectContext +dependenciesVisitor dependencies projectContext = + let + licenses : Dict String String + licenses = + dependencies + |> Dict.toList + |> List.filterMap + (\( packageName, dependency ) -> + case dependency.elmJson of + Elm.Project.Package { license } -> + Just ( packageName, Elm.License.toString license ) + + Elm.Project.Application _ -> + Nothing + ) + |> Dict.fromList + in + { projectContext | licenses = licenses } + + + +-- PROJECT VISITORS + + +elmJsonVisitor : Maybe { elmJsonKey : Rule.ElmJsonKey, project : Elm.Project.Project } -> ProjectContext -> ProjectContext +elmJsonVisitor maybeProject projectContext = + case maybeProject of + Just { elmJsonKey, project } -> + let + directProjectDependencies : Set String + directProjectDependencies = + case project of + Elm.Project.Package { deps } -> + deps + |> List.map (Tuple.first >> Elm.Package.toString) + |> Set.fromList + + Elm.Project.Application { depsDirect } -> + depsDirect + |> List.map (Tuple.first >> Elm.Package.toString) + |> Set.fromList + in + { projectContext + | elmJsonKey = Just elmJsonKey + , directProjectDependencies = directProjectDependencies + } + + Nothing -> + projectContext + + + +-- CONTEXT + + +type alias ProjectContext = + { elmJsonKey : Maybe Rule.ElmJsonKey + , licenses : Dict String String + , directProjectDependencies : Set String + } + + +type alias ModuleContext = + ProjectContext + + +initProjectContext : ProjectContext +initProjectContext = + { elmJsonKey = Nothing + , licenses = Dict.empty + , directProjectDependencies = Set.empty + } + + +fromProjectToModule : Rule.FileKey -> Node ModuleName -> ProjectContext -> ModuleContext +fromProjectToModule _ _ projectContext = + projectContext + + +fromModuleToProject : Rule.FileKey -> Node ModuleName -> ModuleContext -> ProjectContext +fromModuleToProject _ _ moduleContext = + moduleContext + + +foldProjectContexts : ProjectContext -> ProjectContext -> ProjectContext +foldProjectContexts _ previousContext = + previousContext + + + +-- FINAL EVALUATION + + +finalEvaluationForProject : Configuration -> ProjectContext -> List Error +finalEvaluationForProject configuration projectContext = + case projectContext.elmJsonKey of + Just elmJsonKey -> + let + allowed : Set String + allowed = + Set.fromList configuration.allowed + + forbidden : Set String + forbidden = + Set.fromList configuration.forbidden + + unknownOrForbiddenLicenses : Dict String String + unknownOrForbiddenLicenses = + projectContext.licenses + |> Dict.filter (\_ license -> not <| Set.member license allowed) + in + unknownOrForbiddenLicenses + |> Dict.toList + |> List.map + (\( name, license ) -> + if Set.member license forbidden then + Rule.errorForElmJson elmJsonKey + (\elmJson -> + { message = "Forbidden license `" ++ license ++ "` for dependency `" ++ name ++ "`" + , details = [ "This license has been marked as forbidden and you should therefore not use this package." ] + , range = findPackageNameInElmJson name elmJson + } + ) + + else + Rule.errorForElmJson elmJsonKey + (\elmJson -> + { message = "Unknown license `" ++ license ++ "` for dependency `" ++ name ++ "`" + , details = + [ "Talk to your legal team and see if this license is allowed. If it is allowed, add it to the list of allowed licenses. Otherwise, add it to the list of forbidden licenses and remove this dependency." + , "More info about licenses at https://spdx.org/licenses." + ] + , range = findPackageNameInElmJson name elmJson + } + ) + ) + + Nothing -> + [] + + +findPackageNameInElmJson : String -> String -> Range +findPackageNameInElmJson packageName elmJson = + elmJson + |> String.lines + |> List.indexedMap Tuple.pair + |> List.filterMap + (\( row, line ) -> + case String.indexes ("\"" ++ packageName ++ "\"") line of + [] -> + Nothing + + column :: _ -> + Just + { start = + { row = row + 1 + , column = column + 2 + } + , end = + { row = row + 1 + , column = column + String.length packageName + 2 + } + } + ) + |> List.head + |> Maybe.withDefault { start = { row = 1, column = 1 }, end = { row = 10000, column = 1 } } diff --git a/tests/NoInvalidLicenseTest.elm b/tests/NoInvalidLicenseTest.elm new file mode 100644 index 00000000..4b38b103 --- /dev/null +++ b/tests/NoInvalidLicenseTest.elm @@ -0,0 +1,133 @@ +module NoInvalidLicenseTest exposing (all) + +import Elm.Project +import Elm.Version +import Json.Decode as Decode +import NoInvalidLicense exposing (rule) +import Review.Project as Project exposing (Project) +import Review.Test +import Test exposing (Test, describe, test) + + +createProject : String -> Project +createProject license = + Project.new + |> Project.withElmJson (createElmJson applicationElmJson) + |> Project.withDependency (dependency license) + + +createElmJson : String -> { path : String, raw : String, project : Elm.Project.Project } +createElmJson rawElmJson = + case Decode.decodeString Elm.Project.decoder rawElmJson of + Ok elmJson -> + { path = "elm.json" + , raw = rawElmJson + , project = elmJson + } + + Err _ -> + createElmJson rawElmJson + + +applicationElmJson : String +applicationElmJson = + """ +{ + "type": "application", + "source-directories": [ + "src" + ], + "elm-version": "0.19.1", + "dependencies": { + "direct": { + "elm/core": "1.0.0", + "author/dependency": "1.0.0" + }, + "indirect": {} + }, + "test-dependencies": { + "direct": {}, + "indirect": {} + } +}""" + + +dependency : String -> Project.Dependency +dependency license = + { name = "author/dependency" + , version = Elm.Version.one + , modules = + [ { name = "Foo" + , comment = "" + , unions = [] + , aliases = [] + , values = [] + , binops = [] + } + ] + , elmJson = .project <| createElmJson (""" +{ + "type": "package", + "name": "author/dependency", + "summary": "Summary", + "license": \"""" ++ license ++ """", + "version": "1.0.0", + "exposed-modules": [ + "Foo" + ], + "elm-version": "0.19.0 <= v < 0.20.0", + "dependencies": { + "elm/core": "1.0.0 <= v < 2.0.0" + }, + "test-dependencies": {} +}""") + } + + +sourceCode : String +sourceCode = + """ +module A exposing (a) +a = 1 +""" + + +all : Test +all = + describe "NoInvalidLicense" + [ test "should not report anything if there is no `elm.json` file" <| + \() -> + sourceCode + |> Review.Test.run (rule { allowed = [], forbidden = [] }) + |> Review.Test.expectNoErrors + , test "should not report anything if all dependencies have a license that is allowed" <| + \() -> + sourceCode + |> Review.Test.runWithProjectData (createProject "MIT") (rule { allowed = [ "MIT" ], forbidden = [] }) + |> Review.Test.expectNoErrors + , test "should report an error if a dependency has an unknown license" <| + \() -> + sourceCode + |> Review.Test.runWithProjectData (createProject "BSD-3-Clause") (rule { allowed = [ "MIT" ], forbidden = [] }) + |> Review.Test.expectErrorsForElmJson + [ Review.Test.error + { message = "Unknown license `BSD-3-Clause` for dependency `author/dependency`" + , details = + [ "Talk to your legal team and see if this license is allowed. If it is allowed, add it to the list of allowed licenses. Otherwise, add it to the list of forbidden licenses and remove this dependency." + , "More info about licenses at https://spdx.org/licenses." + ] + , under = "author/dependency" + } + ] + , test "should report an error if a dependency has a forbidden license" <| + \() -> + sourceCode + |> Review.Test.runWithProjectData (createProject "BSD-3-Clause") (rule { allowed = [ "MIT" ], forbidden = [ "BSD-3-Clause" ] }) + |> Review.Test.expectErrorsForElmJson + [ Review.Test.error + { message = "Forbidden license `BSD-3-Clause` for dependency `author/dependency`" + , details = [ "This license has been marked as forbidden and you should therefore not use this package." ] + , under = "author/dependency" + } + ] + ]