diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..6638760 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,71 @@ +version: 2.1 + +orbs: + codecov: codecov/codecov@1.2.2 + +executors: + clojure: + docker: + - image: circleci/clojure:lein + +jobs: + clj-kondo: + docker: + - image: cljkondo/clj-kondo:2021.02.13 + steps: + - checkout + - run: clj-kondo --lint . + + test-deps-plus: + executor: clojure + steps: + - checkout + - restore_cache: + keys: + - v1-jars-{{ checksum "project.clj" }} + - v1-jars + - run: lein test-ci + - run: + command: lein eastwood + when: always + - codecov/upload + - store_test_results: + path: target/test-results + - save_cache: + key: v1-jars-{{ checksum "project.clj" }} + paths: + - ~/.m2 + + publish: + docker: + - image: circleci/clojure:lein + steps: + - checkout + - restore_cache: + keys: + - v1-jars-{{ checksum "project.clj" }} + - v1-jars + - run: + name: Download dependencies + command: lein deps + - run: + name: Publish to clojars + command: lein deploy + +workflows: + build-test: + jobs: + - clj-kondo + - test-deps-plus: + filters: + tags: + only: /.*/ + - publish: + context: clojars-publish + requires: + - test-deps-plus + filters: + tags: + only: /.*/ + branches: + ignore: /.*/ diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..42c1f23 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,5 @@ +# Checklist + +- [ ] Updated the version, if [applicable](../blob/main/README.adoc#releasing) +- [ ] Updated the [CHANGELOG.adoc](../blob/main/CHANGELOG.adoc), if applicable +- [ ] Updated any documentation (READMEs and docstrings), if applicable diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a17fabe --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +target +classes +checkouts +pom.xml +pom.xml.asc +*.jar +*.class +.lein-* +.nrepl-port +.eastwood +.clj-kondo/.cache +.idea/ + diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc new file mode 100644 index 0000000..a9653f7 --- /dev/null +++ b/CHANGELOG.adoc @@ -0,0 +1,16 @@ +:toc: +:toclevels: 1 + += Changelog + +All notable changes to this project are documented in this file. + +The format is based on https://keepachangelog.com/en/1.0.0/[Keep a Changelog]. + += 2021 + +== 0.1.X - 2021-10-08 + +=== Added + +* Initial release. All commits are squashed for safety reasons. The original repository lives in GitHub's CircleCI-Archived org. diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..ee9bc81 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @circleci/backplane diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d921d3d --- /dev/null +++ b/LICENSE @@ -0,0 +1,214 @@ +THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE PUBLIC +LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION OF THE PROGRAM +CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + +1. DEFINITIONS + +"Contribution" means: + +a) in the case of the initial Contributor, the initial code and +documentation distributed under this Agreement, and + +b) in the case of each subsequent Contributor: + +i) changes to the Program, and + +ii) additions to the Program; + +where such changes and/or additions to the Program originate from and are +distributed by that particular Contributor. A Contribution 'originates' from +a Contributor if it was added to the Program by such Contributor itself or +anyone acting on such Contributor's behalf. Contributions do not include +additions to the Program which: (i) are separate modules of software +distributed in conjunction with the Program under their own license +agreement, and (ii) are not derivative works of the Program. + +"Contributor" means any person or entity that distributes the Program. + +"Licensed Patents" mean patent claims licensable by a Contributor which are +necessarily infringed by the use or sale of its Contribution alone or when +combined with the Program. + +"Program" means the Contributions distributed in accordance with this +Agreement. + +"Recipient" means anyone who receives the Program under this Agreement, +including all Contributors. + +2. GRANT OF RIGHTS + +a) Subject to the terms of this Agreement, each Contributor hereby grants +Recipient a non-exclusive, worldwide, royalty-free copyright license to +reproduce, prepare derivative works of, publicly display, publicly perform, +distribute and sublicense the Contribution of such Contributor, if any, and +such derivative works, in source code and object code form. + +b) Subject to the terms of this Agreement, each Contributor hereby grants +Recipient a non-exclusive, worldwide, royalty-free patent license under +Licensed Patents to make, use, sell, offer to sell, import and otherwise +transfer the Contribution of such Contributor, if any, in source code and +object code form. This patent license shall apply to the combination of the +Contribution and the Program if, at the time the Contribution is added by the +Contributor, such addition of the Contribution causes such combination to be +covered by the Licensed Patents. The patent license shall not apply to any +other combinations which include the Contribution. No hardware per se is +licensed hereunder. + +c) Recipient understands that although each Contributor grants the licenses +to its Contributions set forth herein, no assurances are provided by any +Contributor that the Program does not infringe the patent or other +intellectual property rights of any other entity. Each Contributor disclaims +any liability to Recipient for claims brought by any other entity based on +infringement of intellectual property rights or otherwise. As a condition to +exercising the rights and licenses granted hereunder, each Recipient hereby +assumes sole responsibility to secure any other intellectual property rights +needed, if any. For example, if a third party patent license is required to +allow Recipient to distribute the Program, it is Recipient's responsibility +to acquire that license before distributing the Program. + +d) Each Contributor represents that to its knowledge it has sufficient +copyright rights in its Contribution, if any, to grant the copyright license +set forth in this Agreement. + +3. REQUIREMENTS + +A Contributor may choose to distribute the Program in object code form under +its own license agreement, provided that: + +a) it complies with the terms and conditions of this Agreement; and + +b) its license agreement: + +i) effectively disclaims on behalf of all Contributors all warranties and +conditions, express and implied, including warranties or conditions of title +and non-infringement, and implied warranties or conditions of merchantability +and fitness for a particular purpose; + +ii) effectively excludes on behalf of all Contributors all liability for +damages, including direct, indirect, special, incidental and consequential +damages, such as lost profits; + +iii) states that any provisions which differ from this Agreement are offered +by that Contributor alone and not by any other party; and + +iv) states that source code for the Program is available from such +Contributor, and informs licensees how to obtain it in a reasonable manner on +or through a medium customarily used for software exchange. + +When the Program is made available in source code form: + +a) it must be made available under this Agreement; and + +b) a copy of this Agreement must be included with each copy of the Program. + +Contributors may not remove or alter any copyright notices contained within +the Program. + +Each Contributor must identify itself as the originator of its Contribution, +if any, in a manner that reasonably allows subsequent Recipients to identify +the originator of the Contribution. + +4. COMMERCIAL DISTRIBUTION + +Commercial distributors of software may accept certain responsibilities with +respect to end users, business partners and the like. While this license is +intended to facilitate the commercial use of the Program, the Contributor who +includes the Program in a commercial product offering should do so in a +manner which does not create potential liability for other Contributors. +Therefore, if a Contributor includes the Program in a commercial product +offering, such Contributor ("Commercial Contributor") hereby agrees to defend +and indemnify every other Contributor ("Indemnified Contributor") against any +losses, damages and costs (collectively "Losses") arising from claims, +lawsuits and other legal actions brought by a third party against the +Indemnified Contributor to the extent caused by the acts or omissions of such +Commercial Contributor in connection with its distribution of the Program in +a commercial product offering. The obligations in this section do not apply +to any claims or Losses relating to any actual or alleged intellectual +property infringement. In order to qualify, an Indemnified Contributor must: +a) promptly notify the Commercial Contributor in writing of such claim, and +b) allow the Commercial Contributor to control, and cooperate with the +Commercial Contributor in, the defense and any related settlement +negotiations. The Indemnified Contributor may participate in any such claim +at its own expense. + +For example, a Contributor might include the Program in a commercial product +offering, Product X. That Contributor is then a Commercial Contributor. If +that Commercial Contributor then makes performance claims, or offers +warranties related to Product X, those performance claims and warranties are +such Commercial Contributor's responsibility alone. Under this section, the +Commercial Contributor would have to defend claims against the other +Contributors related to those performance claims and warranties, and if a +court requires any other Contributor to pay any damages as a result, the +Commercial Contributor must pay those damages. + +5. NO WARRANTY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS PROVIDED ON +AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER +EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR +CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A +PARTICULAR PURPOSE. Each Recipient is solely responsible for determining the +appropriateness of using and distributing the Program and assumes all risks +associated with its exercise of rights under this Agreement , including but +not limited to the risks and costs of program errors, compliance with +applicable laws, damage to or loss of data, programs or equipment, and +unavailability or interruption of operations. + +6. DISCLAIMER OF LIABILITY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT NOR ANY +CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION +LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE +EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY +OF SUCH DAMAGES. + +7. GENERAL + +If any provision of this Agreement is invalid or unenforceable under +applicable law, it shall not affect the validity or enforceability of the +remainder of the terms of this Agreement, and without further action by the +parties hereto, such provision shall be reformed to the minimum extent +necessary to make such provision valid and enforceable. + +If Recipient institutes patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Program itself +(excluding combinations of the Program with other software or hardware) +infringes such Recipient's patent(s), then such Recipient's rights granted +under Section 2(b) shall terminate as of the date such litigation is filed. + +All Recipient's rights under this Agreement shall terminate if it fails to +comply with any of the material terms or conditions of this Agreement and +does not cure such failure in a reasonable period of time after becoming +aware of such noncompliance. If all Recipient's rights under this Agreement +terminate, Recipient agrees to cease use and distribution of the Program as +soon as reasonably practicable. However, Recipient's obligations under this +Agreement and any licenses granted by Recipient relating to the Program shall +continue and survive. + +Everyone is permitted to copy and distribute copies of this Agreement, but in +order to avoid inconsistency the Agreement is copyrighted and may only be +modified in the following manner. The Agreement Steward reserves the right to +publish new versions (including revisions) of this Agreement from time to +time. No one other than the Agreement Steward has the right to modify this +Agreement. The Eclipse Foundation is the initial Agreement Steward. The +Eclipse Foundation may assign the responsibility to serve as the Agreement +Steward to a suitable separate entity. Each new version of the Agreement will +be given a distinguishing version number. The Program (including +Contributions) may always be distributed subject to the version of the +Agreement under which it was received. In addition, after a new version of +the Agreement is published, Contributor may elect to distribute the Program +(including its Contributions) under the new version. Except as expressly +stated in Sections 2(a) and 2(b) above, Recipient receives no rights or +licenses to the intellectual property of any Contributor under this +Agreement, whether expressly, by implication, estoppel or otherwise. All +rights in the Program not expressly granted under this Agreement are +reserved. + +This Agreement is governed by the laws of the State of New York and the +intellectual property laws of the United States of America. No party to this +Agreement will bring a legal action under this Agreement more than one year +after the cause of action arose. Each party waives its rights to a jury trial +in any resulting litigation. diff --git a/README.adoc b/README.adoc new file mode 100644 index 0000000..5de319d --- /dev/null +++ b/README.adoc @@ -0,0 +1,264 @@ += deps-plus plugin + +deps-plus is a Leiningen plugin designed to help with analyzing project dependencies and reviewing dependency changes. + +== Installation + +Add `+[com.circleci/deps-plus "0.1.0"]+` to your `+:user+` profile plugins in `+~/.lein/profiles.clj+`. For example, + +[source,clj] +.... +{:user {:plugins [[com.circleci/deps-plus "0.1.0"]]}} +.... + +== Usage + +To run a deps-plus task simply run `+lein deps-plus <task name>+` from a Leiningen project. + +=== Tasks + +* *list* - Lists all dependencies of the current project. +* *list-save* - Save the project dependency list to "deps.list". +* *list-diff* - Compare project dependencies to those previously saved with `+list-save+`. +* *why* - Shows all paths to given dependency as a tree. This is similar to `+lein deps :why+`, but instead of only showing the single resolved path to dependency chosen by Maven, all copies and paths to the dependency (including those excluded by conflict resolution) are shown. + +=== Checks + +* link:#check-classpath-conflicts[*check-classpath-conflicts*] - Checks for classpath conflicts. +* link:#check-downgraded[*check-downgraded*] - Checks for dependencies which have been downgraded. +* link:#check-families[*check-families*] - Checks for inconsistent dependency versions within families. +* link:#check-management-conflicts[*check-management-conflicts*] - Checks for dependency management conflicts. +* link:#check-pedantic[*check-pedantic*] - Checks for version conflicts and version ranges. + +==== check-classpath-conflicts + +Checks for classpath conflicts. Classpath conflicts occur when resources which should be unique +(e.g. Java class files, Clojure namespaces, etc) are found in multiple dependencies. Classpath +conflicts can lead to unpredictable behavior at runtime as the version of the resource which is loaded +can depend on subtle factors like the ordering of JARs on the classpath, or the order in which JARs +are merged into an uberjar. Classpath conflicts are often caused by dependencies being duplicated +(e.g. two versions of a dependency with different Maven coordinates) or dependencies which bundle +other dependencies (e.g. shaded JARs). In these cases the conflict can be resolved by replacing the +offending dependency with a more appropriate one (e.g. a non-shaded version). + +_Example: Apache logging classes_ + +.... +Found 6 classpath conflicts between commons-logging:commons-logging:jar:1.2:compile and org.slf4j:jcl-over-slf4j:jar:1.7.30:compile + org/apache/commons/logging/Log.class + org/apache/commons/logging/LogConfigurationException.class + org/apache/commons/logging/LogFactory.class + org/apache/commons/logging/impl/NoOpLog.class + org/apache/commons/logging/impl/SimpleLog$1.class + org/apache/commons/logging/impl/SimpleLog.class +.... + +These packages both implement the same classes, but the latter implements the former with SLF4J. + +_Solution:_ Pick which package should be chosen (there's no practical way to tell which version of +the class has been chosen in the past). In this case, we'll pick `org.slf4j/jcl-over-slf4j`. So we +add a top level exclusion for `commons-logging/commons-logging`, preventing _any_ dependency from +pulling it in: + +[source,clj] +.... + :exclusions [... commons-logging/commons-logging ...] +.... + +_Example: com.google.javascript:closure-compiler_ + +The jar for the Closure Compiler includes a shaded version of https://github.com/google/gson[Gson] +without renaming the classes: + +.... +$ jar tf ~/.m2/repository/com/google/javascript/closure-compiler/v20160315/closure-compiler-v20160315.jar | grep 'com/google/gson' +com/google/gson/ +com/google/gson/Gson$5.class +com/google/gson/JsonNull.class +... +.... + +This always causes classpath conflicts when we also declare a direct dependency on Gson, even if the +version of Gson is the same in both places. You'll have to deal with this type of conflict on a case +by case basis. Here, there is an +https://mvnrepository.com/artifact/com.google.javascript/closure-compiler-unshaded[_unshaded_ version] +of the Closure Compiler available that does not include the Gson classes. + +==== check-downgraded + +A dependency is considered "downgraded" if the resolved version is older than the version specified by +some other included dependency. This can occur if `:managed-dependencies` is used to pin a dependency +version or if Maven conflict resolution happens to not select the latest version. + +==== check-families + +Checks for inconsistent dependency versions within families. Dependencies families are sets of +dependencies which share a common group ID and version. Inconsistent family versions can occur if the +`:managed-dependencies` for a family are incomplete or inconsistent. This situation can also occur +when different family members are brought in through different transitive dependency paths. To fix +this issue consider adding a complete set of `:managed-dependencies` entries for the family. + +_Example: io.netty family_ +.... +Suspicious combination, io.netty:netty-codec-socks:jar:4.1.34.Final:compile and io.netty:netty-buffer:jar:4.1.50.Final:compile +Suspicious combination, io.netty:netty-codec-socks:jar:4.1.34.Final:compile and io.netty:netty-codec:jar:4.1.50.Final:compile +Suspicious combination, io.netty:netty-codec-socks:jar:4.1.34.Final:compile and io.netty:netty-common:jar:4.1.50.Final:compile +Suspicious combination, io.netty:netty-codec-socks:jar:4.1.34.Final:compile and io.netty:netty-transport:jar:4.1.50.Final:compile +Suspicious combination, io.netty:netty-handler-proxy:jar:4.1.34.Final:compile and io.netty:netty-buffer:jar:4.1.50.Final:compile +Suspicious combination, io.netty:netty-handler-proxy:jar:4.1.34.Final:compile and io.netty:netty-codec-http:jar:4.1.50.Final:compile +Suspicious combination, io.netty:netty-handler-proxy:jar:4.1.34.Final:compile and io.netty:netty-codec:jar:4.1.50.Final:compile +Suspicious combination, io.netty:netty-handler-proxy:jar:4.1.34.Final:compile and io.netty:netty-common:jar:4.1.50.Final:compile +Suspicious combination, io.netty:netty-handler-proxy:jar:4.1.34.Final:compile and io.netty:netty-transport:jar:4.1.50.Final:compile +.... + +These netty dependencies are all in the same family, but they have different versions (`4.1.34.Final` +vs. `4.1.50.Final`). In this case, the `project.clj` declares a managed dependency on +`io.netty/netty-codec-http2` but not the other members of the family: + +[source,clj] +.... + :managed-dependencies [... [io.netty/netty-codec-http2 "4.1.50.Final"] ...] +.... +The related projects are coming in through transitive dependencies, and have different versions: +.... +$ lein deps-plus why io.netty:netty-codec-socks:jar:4.1.34.Final +... + ;; +- [circleci/my-project "0.1.0"] +... + ;; | \- [io.grpc/grpc-netty "1.20.0"] + ;; | \- [io.netty/netty-handler-proxy "4.1.34.Final"] + ;; | \- [io.netty/netty-codec-socks "4.1.34.Final"] (chosen) +... +.... + +_Solution:_ add a managed dependency for the family. Pick the desired version for the family (in +this case, we'll pick `4.1.50.Final`). At a minimum, for every package mentioned in the list of +suspicious combinations that has the _undesired_ version, add an entry in the `:managed-dependencies`: + +[source,clj] +.... + :managed-dependencies [[io.netty/netty-codec-socks "4.1.50.Final"] + [io.netty/netty-handler-proxy "4.1.50.Final"] + ...] +.... + +For completeness, you can add _every_ member of the family the project uses to managed dependencies. + +==== check-management-conflicts + +Checks for dependency management conflicts. A dependency management conflict occurs when a dependency +has versions specified in both `:dependencies` and `:managed-dependencies`. To resolve this issue you +can remove the version number from `:dependencies`. If you wish to override a managed dependency +version inherited from a parent project you should do so in your own `:managed-dependencies` section. + +_Example: org.clojure/core.async_ + +.... +org.clojure:core.async:jar:1.2.603 conflicts with managed dependency org.clojure:core.async:jar:1.3.610 +.... + +_Solution 1:_ if the exact version of `core.async` does not matter, remove the version number from +the `org.clojure/core.async` version in your dependencies to automatically get the version provided by +clj-parent: + +[source,clj] +.... + :dependencies [... [org.clojure/core.async] ...] +.... + +This solution also applies when the versions are identical: +.... +org.clojure:core.async:jar:1.3.610 conflicts with managed dependency org.clojure:core.async:jar:1.3.610 +.... + +_Solution 2:_ if it is necessary to pin the version `1.2.603`, move the dependency to the managed +dependencies: + +[source,clj] +.... + :managed-dependencies [... [org.clojure/core.async "1.2.603"] ...] +.... + +==== check-pedantic + +Checks for version conflicts and version ranges. This check is similar to Leiningen’s `:pedantic? +:abort` mode, but suggests `+:managed-dependencies+` instead of `:exclusions`. In general, expect to +see warnings when: + +1. A top-level dependency is overridden by another version +2. A transitive dependency is overridden by an _older_ version + +Unlike Leiningen, this task ignores plugin dependencies since these are unaffected by managed +dependencies. By default, each suggested managed dependency is shown alongside a dependency tree +for the conflict. Pass the `:quiet` flag to suppress the output of these trees. + +_Example: cheshire_ + +.... +Found 7 dependency conflicts. +Considering adding the following :managed-dependencies, + +... + ;; +- [cheshire/cheshire "5.9.0"] (chosen) +... + ;; \- [circleci/my-project "0.1.0"] + ;; +- [circleci/the-other-project "0.1.0"] + ;; | +- [circleci/rollcage "1.0.203"] + ;; | | \- [cheshire/cheshire "5.8.1"] (omitted) + ;; | +- [cheshire/cheshire "5.10.0"] (omitted) + ;; | \- [amperity/vault-clj "0.7.0"] + ;; | \- [cheshire/cheshire "5.8.1"] (omitted) + ;; \- [cheshire/cheshire "5.10.0"] (omitted) + [cheshire/cheshire "5.9.0"] +... +.... + +This shows all of the different versions of `cheshire/cheshire`, including which versions were chosen +(would actually be used when the program runs) vs. which were excluded. check-pedantic complains +because multiple dependencies ask for different versions of `cheshire/cheshire`, and the newest version +(transitively `"5.10.0"`), is omitted. + +_Solution 1:_ if the version of `cheshire/cheshire` from the `:dependencies` is not required for +correctness, remove it as an explicit dependency and retry. If the warning disappears, you can see +that the newest version wins with `why`: + +.... +$ lein deps-plus why cheshire + ;; +- [circleci/the-other-project "0.1.0"] + ;; | +- [circleci/rollcage "1.0.203"] + ;; | | \- [cheshire/cheshire "5.8.1"] (omitted) + ;; | +- [cheshire/cheshire "5.10.0"] (chosen) + ;; | \- [amperity/vault-clj "0.7.0"] + ;; | \- [cheshire/cheshire "5.8.1"] (omitted) +... +.... + +_Solution 2:_ add a managed dependency for the preferred version. Pick the version that should be +included (in this case, we'll pick `"5.9.0"`). This is the version `check-pedantic` suggests at the +bottom of the dependency knot. It's also the version that the project explicitly requires as a +`:dependency`. Move it to a managed dependency: + +[source,clj] +.... + :managed-dependencies [... [cheshire/cheshire "5.9.0"] ...] +.... + +Releasing +--------- + +New git tags are automatically published to Clojars. To find the most recent version of lein-deps-plus, look at the most recent tag in Clojars. + +The following should be updated on the `main` branch before tagging: + +- `project.clj` - version +- `CHANGELOG.adoc` - summary of changes + +Using semantic versioning is recommended, increment the: +1. MAJOR version when you make backward incompatible changes. +2. MINOR version when you add user-facing features in a backward compatible manner. +3. PATCH version when you make backward compatible bug fixes. + +License +------- + +Distributed under the http://www.eclipse.org/legal/epl-v10.html[Eclipse Public License]. diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..5dbecc5 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,12 @@ +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true + +comment: + layout: "files" + require_changes: true diff --git a/project.clj b/project.clj new file mode 100644 index 0000000..02382cc --- /dev/null +++ b/project.clj @@ -0,0 +1,27 @@ +(defproject com.circleci/deps-plus "0.1.0" + :plugins [[jonase/eastwood "0.3.6" :exclusions [org.clojure/clojure]]] + + :pedantic? :abort + + :managed-dependencies [[org.clojure/clojure "1.10.1"]] + + :profiles {:dev {:dependencies [[org.clojure/clojure] + [lambdaisland/kaocha "0.0-601"] + [lambdaisland/kaocha-cloverage "0.0-41"] + [lambdaisland/kaocha-junit-xml "0.0-70"]]} + :provided {:dependencies [[org.clojure/clojure] + [com.google.guava/guava "24.1.1-jre"] + [org.jsoup/jsoup "1.9.2"] + [leiningen/leiningen "2.9.1"]]}} + + :aliases {"test" ["run" "-m" "kaocha.runner"] + "test-ci" ["test" + "--plugin" "cloverage" + "--codecov" "--no-cov-html" "--no-cov-summary" + "--plugin" "kaocha.plugin/profiling" + "--plugin" "kaocha.plugin/junit-xml" + "--junit-xml-file" "target/test-results/results.xml"]} + :repositories [["releases" {:url "https://clojars.org/repo" + :username :env/clojars_username + :password :env/clojars_token + :sign-releases false}]]) diff --git a/src/circleci/deps_plus/core.clj b/src/circleci/deps_plus/core.clj new file mode 100644 index 0000000..a018bf5 --- /dev/null +++ b/src/circleci/deps_plus/core.clj @@ -0,0 +1,514 @@ +(ns circleci.deps-plus.core + (:require [leiningen.core.classpath :as lein-classpath] + [cemerick.pomegranate.aether :as aether] + [circleci.deps-plus.graph :as graph] + [clojure.set :as set] + [clojure.string :as string] + [leiningen.core.main :as lein]) + (:import (org.eclipse.aether DefaultRepositorySystemSession) + (org.eclipse.aether.graph DependencyNode) + (org.eclipse.aether.artifact Artifact) + (org.apache.maven.artifact.versioning ComparableVersion) + (org.eclipse.aether.collection CollectResult) + (java.io Writer File) + (java.util.jar JarFile) + (java.util Properties) + (org.eclipse.aether.util.graph.manager DependencyManagerUtils) + (org.eclipse.aether.resolution DependencyResult))) + +(def dependencies-key :dependencies) +(def managed-dependencies-key :managed-dependencies) + +(defrecord Coordinates [group-id artifact-id classifier version extension] + Object + (toString [this] + (->> [group-id artifact-id extension classifier (str version) (:scope (meta this))] + (remove empty?) + (string/join ":")))) + +(defn parse-coordinates [s] + (let [[group-id artifact-id ext x y z] (string/split s #":") + ext (or ext "jar")] + (cond + (some? z) + (let [classifier x + version (ComparableVersion. y) + scope z] + (with-meta + (->Coordinates group-id artifact-id classifier version ext) + {:scope scope})) + + (#{"compile" "provided" "runtime" "test"} y) + (let [version (ComparableVersion. x) + scope y] + (with-meta + (->Coordinates group-id artifact-id "" version ext) + {:scope scope})) + + (some? y) + (let [classifier x + version (ComparableVersion. y)] + (->Coordinates group-id artifact-id classifier version ext)) + + (some? x) + (let [version (ComparableVersion. x)] + (->Coordinates group-id artifact-id "" version ext)) + + :else + (->Coordinates group-id artifact-id "" nil ext)))) + +(defn- artifact-coordinates [^Artifact a] + (when a + (->Coordinates + (.getGroupId a) + (.getArtifactId a) + (.getClassifier a) + (some-> (.getVersion a) (ComparableVersion.)) + (.getExtension a)))) + +(defn- node-coordinates [^DependencyNode n] + (when-let [coord (artifact-coordinates (.getArtifact n))] + (with-meta coord + {:scope (.getScope (.getDependency n)) + :version-range (some-> n .getVersionConstraint .getRange) + :premanaged-version (DependencyManagerUtils/getPremanagedVersion n) + :premanaged-scope (DependencyManagerUtils/getPremanagedScope n) + :file (some-> n .getArtifact .getFile)}))) + +(defn coordinates->aether [{:keys [group-id artifact-id classifier version extension] :as dep}] + (let [scope (:scope (meta dep))] + (concat [(symbol group-id artifact-id) (str version)] + (when-not (string/blank? classifier) [:classifier classifier]) + (when-not (#{"jar" nil} extension) [:extension extension]) + (when-not (#{"compile" nil} scope) [:scope scope])))) + +(defn aether->coordinates [[group-artifact version & {:keys [scope classifier extension]}]] + (with-meta + (->Coordinates + (or (namespace group-artifact) (name group-artifact)) + (name group-artifact) + (or classifier "") + (some-> version ComparableVersion.) + (or extension "jar")) + {:scope scope})) + +(defn unversioned [coordinates] + (assoc coordinates :version nil)) + +(defn- basic-repository-session + "Returns a basic repository session that enables verbose dependency + management property tracking (pre-managed version, scope, etc)." + ^DefaultRepositorySystemSession [& args] + (doto ^DefaultRepositorySystemSession (apply aether/repository-session args) + (.setConfigProperty DependencyManagerUtils/CONFIG_PROP_VERBOSE true))) + +(defn- full-dependency-graph-repository-session + "Returns a repository session without a dependency graph transformer. + Without a dependency graph transformer the full dependency graph will + be returned by the dependency collector." + [& args] + (doto (apply basic-repository-session args) + (.setDependencyGraphTransformer nil))) + +(defn- dependency-graph [^DependencyNode root] + (loop [edges {} to-visit [root] visited #{root}] + (if (seq to-visit) + (let [[node & rest] to-visit + coordinates (node-coordinates node) + child-nodes (seq (.getChildren node)) + child-coordinates (into #{} (map node-coordinates child-nodes))] + (recur + (update edges coordinates set/union child-coordinates) + (into rest (filter #(not (visited %)) child-nodes)) + (into visited child-nodes))) + edges))) + +(defn- transfer-listener [e] + (let [{:keys [type] {:keys [name repository]} :resource} e] + (when (= :started type) + (locking *err* + (lein/warn "Retrieving" name "from" repository))))) + +(defn resolve-dependencies [project & flags] + (let [{:keys [repositories local-repo mirrors offline?]} project + flags (into #{} flags) + managed-coordinates (get project managed-dependencies-key) + coordinates (-> (get project dependencies-key) + (aether/merge-versions-from-managed-coords managed-coordinates)) + args (concat + [:local-repo local-repo + :repositories (into {} (map lein-classpath/add-repo-auth repositories)) + :coordinates coordinates + :mirrors (map lein-classpath/add-repo-auth mirrors) + :proxy (lein-classpath/get-proxy-settings) + :retrieve (boolean (:retrieve flags)) + :transfer-listener transfer-listener + :repository-session-fn (if (:full-graph flags) + full-dependency-graph-repository-session + basic-repository-session) + :offline? offline?] + + (when-not (:ignore-managed-dependencies flags) + [:managed-coordinates managed-coordinates])) + result (apply aether/resolve-dependencies* args) + root (if (:retrieve flags) + (.getRoot ^DependencyResult result) + (.getRoot ^CollectResult result))] + (dependency-graph root))) + +(defn- resolve-natural-dependencies + "Returns a graph obtained by merging the full \"natural\" dependency graphs + for each dependency specified by the given coordinates. Since this is a full + dependency graph, cyclic dependencies, version conflicts, and duplicate nodes + may be present. This graph can be useful for comparing the dependencies declared + by a given dependency to those resolved for use by the project." + [project coordinates] + (let [aether-coordinates (->> coordinates + (map coordinates->aether) + (into [])) + alt-project (merge project + {:dependencies aether-coordinates + :managed-dependencies nil})] + (resolve-dependencies alt-project :full-graph))) + +(defn- max* + "Returns the greatest value as ordered by `compare`." + [values] + (reduce + (fn [acc x] + (if (> (compare x acc) 0) x acc)) + values)) + +(defn check-downgraded* + "Returns a sequence of downgraded dependencies as described by `check-downgraded`. + Each downgraded dependency is returned as a pair, `[dependency max-version]`." + [project] + (let [resolved-deps (->> (resolve-dependencies project) + (keys) + (remove nil?) + (into #{})) + + ;; Get the full dependency graph obtained by depending directly on all of our + ;; resolved dependencies without managed dependencies in place. This allows us + ;; to compare the transitive dependencies that each of our dependencies would + ;; bring by default, to those we get with dependency management in place. + natural-dep-graph (resolve-natural-dependencies project resolved-deps) + natural-dep-versions (->> (keys natural-dep-graph) + (remove nil?) + (group-by unversioned) + (reduce-kv (fn [m k v] + (assoc m k (map :version v))) + {})) + + max-versions (reduce-kv + (fn [m k v] (assoc m k (max* v))) + {} + natural-dep-versions)] + + (for [dep (sort-by str resolved-deps) + :let [version (:version dep) + max-version (get max-versions (unversioned dep))] + :when (< (compare version max-version) 0)] + [dep max-version]))) + +(defn check-families* + "Returns a sequence of questionable `[child parent]` dependencies as + described by `check-families`." + [project] + (let [deps (->> (resolve-dependencies project) + (keys) + (remove nil?) + (into #{})) + + versions (->> deps + (map (fn [dep] [(unversioned dep) dep])) + (into {})) + + natural-deps (resolve-natural-dependencies project deps) + + same-family? (fn [a b] + (let [keys [:group-id :version]] + (= (select-keys a keys) (select-keys b keys))))] + + (for [dep (sort-by str deps) + ;; Find direct dependencies of dep which, in the natural dependency graph, + ;; are in part of the same family as dep (same group-id and version). For + ;; each of these family members, lookup the actual version resolved. + :let [family-members (->> (get natural-deps dep) + (filter #(same-family? dep %)) + (map unversioned) + (map #(get versions % (format "%s (excluded)" %))))] + member (sort-by str family-members) + :when (not= (:version member) (:version dep))] + [dep member]))) + +(defn write-dependency-list + [project ^Writer writer] + (doseq [dep (->> (resolve-dependencies project) + (keys) + (remove nil?) + (map str) + (sort))] + (.write writer (str dep "\n"))) + (.flush writer)) + +(defn- parse-dependency-list [s] + (when (seq s) + (->> (string/split-lines s) + (map parse-coordinates)))) + +(defn- diff-categorise + [^Coordinates old-dep ^Coordinates new-dep] + (let [old-scope (:scope (meta old-dep)) + old-version (:version old-dep) + new-scope (:scope (meta new-dep)) + new-version (:version new-dep)] + (cond + (not old-dep) + [:added (format "%s" new-dep)] + + (not new-dep) + [:removed (format "%s" old-dep)] + + (> (compare old-version new-version) 0) + [:downgraded (format "%s (%s -> %s)" new-dep old-version new-version)] + + (< (compare old-version new-version) 0) + [:upgraded (format "%s (%s -> %s)" new-dep old-version new-version)] + + (not= old-scope new-scope) + [:rescoped (format "%s (%s -> %s)" new-dep old-scope new-scope)]))) + +(defn diff-dependency-list + [project old-list ^Writer writer] + (let [index #(->> (group-by unversioned %) + (reduce-kv + (fn [m k v] + (assoc m k (first v))) + {})) + old (->> (parse-dependency-list old-list) + (index)) + new (->> (resolve-dependencies project) + (keys) + (remove nil?) + (index)) + all (into #{} (concat (keys old) (keys new))) + groups (->> (sort-by str all) + (keep #(diff-categorise (get old %) (get new %))) + (group-by first))] + (doseq [category [:added :removed :upgraded :downgraded :rescoped] + :when (contains? groups category) + :let [changes (category groups)]] + (.write writer (format ";; %s\n" (string/capitalize (name category)))) + (doseq [change changes] + (.write writer (format "%s\n" (second change)))) + (.write writer "\n"))) + (.flush writer)) + +(defn same-dependency? + "Returns true if the dependencies `a` and `b` are equal ignoring version." + [a b] + (= (unversioned a) (unversioned b))) + +(defn- apply-managed-dependency + "Applies a managed dependency to a dependency graph. + Matching dependencies are updated to point to the managed version of + the dependency. The dependency graph is then pruned of no longer + reachable dependencies." + [deps managed-dep] + (let [managed-dep (vary-meta managed-dep dissoc :version-range) + replace #(if (same-dependency? managed-dep %) managed-dep %) + deps (reduce-kv + (fn [acc dep directs] + (assoc acc + (if (same-dependency? managed-dep dep) + (vary-meta dep dissoc :version-range) + dep) + (into #{} (map replace directs)))) + {} + deps) + reachable (graph/graph-seq #(get deps %) nil)] + (select-keys deps reachable))) + +(defn- pedantic-conflict-check [resolved-dep all-versions] + (let [v (:version resolved-dep) + max-v (max* (map :version all-versions))] + (or (< (compare v max-v) 0) + (some #(:version-range (meta %)) all-versions)))) + +(defn- extract-targeted-subtree + "Extracts the minimal subtree from `full-graph` that captures all paths from + the nil root node to `target`." + [full-graph target] + (->> (graph/graph-path-seq #(get full-graph %) nil) + (filter #(same-dependency? (peek %) target)) + (mapcat #(partition 2 1 %)) + (group-by first) + (reduce-kv + (fn [acc node children] + (assoc acc node (into #{} (map second children)))) + {}))) + +(defn- find-first-conflict + "Find the first conflicting dependency in the given dependency graph. + The dependency returned is the first dependency found via a breadth + first search having conflicts." + [full-graph resolved conflict-check] + (let [;; For "all-versions" we look at the map values (i.e. the right-hand + ;; side of each graph edge) instead of the map keys, because the + ;; values preserve metadata, including :version-range information. + all-versions (->> (vals full-graph) + (mapcat identity) + (remove nil?) + (group-by unversioned)) + resolved-versions (reduce + (fn [acc dep] + (assoc acc (unversioned dep) dep)) + {} + resolved) + conflict (->> (graph/graph-seq #(get full-graph %) nil) + (remove nil?) + (map unversioned) + (filter #(conflict-check + (resolved-versions %) + (all-versions %))) + (map resolved-versions) + (first))] + (when conflict + [conflict (extract-targeted-subtree full-graph conflict)]))) + +(defn find-pedantic-conflicts + "Finds dependency version conflicts. + For each conflict, returns a vector containing the chosen version of the dependency, + and a minimal dependency tree containing all the paths to the conflicting dependency." + [project] + (let [resolved (->> (resolve-dependencies project) + (keys) + (remove nil?))] + (loop [conflicts [] full-graph (resolve-dependencies project :full-graph)] + (if-let [conflict (find-first-conflict full-graph resolved pedantic-conflict-check)] + (recur (conj conflicts conflict) + (apply-managed-dependency full-graph (first conflict))) + conflicts)))) + +(defn check-management-conflicts* + "Checks for conflicts between dependencies and managed dependencies. + A conflict occurs whenever a version is specified for a :dependency which + is also specified in :managed-dependencies. For each conflict, the + dependency and managed dependencies are returned as a tuple." + [project] + (let [managed-by-key (->> (:managed-dependencies project) + (map aether->coordinates) + (reduce + (fn [acc dep] + (assoc acc (unversioned dep) dep)) + {}))] + (for [dep (map aether->coordinates (:dependencies project)) + :let [managed (managed-by-key (unversioned dep))] + :when (and managed (:version dep))] + [dep managed]))) + +(defn resolve-targeted-dependency-subtree + "Returns the minimal dependency subtree that captures all paths to `target`. + The returned tree has a single nil root node. All leaf nodes of the returned + tree match `target`." + [project target] + (-> (resolve-dependencies project :full-graph) + (extract-targeted-subtree target))) + +(defn- jar-classpath-resources + "Returns a list of \"classpath resources\" found in the given JAR file. + \"Classpath resources\" are files in the JAR which should probably be unique + within the classpath (i.e. they should only appear in one JAR file). This + detection is heuristic based and may change over time." + [^File file] + (with-open [jar (JarFile. file)] + (->> (enumeration-seq (.entries jar)) + (remove #(.isDirectory %)) + (map #(.getName %)) + ;; Limit to well known extensions. There are otherwise a lot of + ;; false positives which are difficult to filter out. + (filter #(let [ext (peek (string/split % #"\."))] + (#{"class" "clj" "cljs" "cljc" "edn" "xml" "properties"} ext))) + ;; Maven metadata files under META-INF frequently conflict, but + ;; these conflicts are low risk. Some other things under META-INF + ;; (like Java service loader files) are merged by Leiningen. + (remove #(string/starts-with? % "META-INF/")) + ;; module-info.class -> Java 9+ module info (not an actual conflict) + ;; project.clj -> Leiningen project files (safe to ignore) + ;; data_readers.clj -> Merged by Leiningen when building an uberjar + (remove #{"module-info.class" + "project.clj" + "data_readers.clj"}) + (doall)))) + +(defn check-classpath-conflicts* + "Checks for classpath conflicts. + A classpath conflict occurs when multiple dependencies provide the same + resource. This detection applies heuristics to limit the number of false + positives (e.g. LICENSE files appear in multiple JARs). Conflicts are + returned as a map. Map keys are resource names. Map values are sets of + dependencies where the resource was found. Only conflicts are included so + these dependency sets always contain a least 2 entries." + [project] + (let [deps (resolve-dependencies project :retrieve) + dep-files (->> (keys deps) + (remove nil?) + (reduce + (fn [acc dep] + (if-let [file (:file (meta dep))] + (assoc acc dep file) + acc)) + {})) + jars (filter #(.endsWith (.getName (val %)) ".jar") dep-files)] + (->> (mapcat + (fn [[dep file]] + (->> (jar-classpath-resources file) + (map (fn [entry] [entry dep])))) + jars) + (group-by first) + (reduce-kv + (fn [acc entry deps] + (if (> (count deps) 1) + (assoc acc entry (into [] (map second deps))) + acc)) + {})))) + +(defn- shaded-jar-dependencies + "Return the coordinates for each dependency shaded within the given JAR." + [filename] + (with-open [jar (JarFile. filename)] + (doall (for [e (enumeration-seq (.entries jar)) + :when (.endsWith (.getName e) "/pom.properties")] + (with-open [is (.getInputStream jar e)] + (let [props (doto (Properties.) + (.load is)) + group-id (.get props "groupId") + artifact-id (.get props "artifactId") + version (.get props "version")] + (aether->coordinates + [(symbol group-id artifact-id) version]))))))) + +(defn who-shades* + "Return a map of dependencies which shade the given target dependency." + [project target] + (let [deps (resolve-dependencies project :retrieve) + dep-files (->> (keys deps) + (remove nil?) + (remove #(same-dependency? target %)) + (reduce + (fn [acc dep] + (let [file (:file (meta dep))] + (if (some-> file .getName (.endsWith ".jar")) + (assoc acc dep file) + acc))) + {}))] + (reduce-kv + (fn [acc dep jar-path] + (if-let [version (->> (shaded-jar-dependencies jar-path) + (filter #(same-dependency? target %)) + (first))] + (assoc acc dep version) + acc)) + {} + dep-files))) diff --git a/src/circleci/deps_plus/graph.clj b/src/circleci/deps_plus/graph.clj new file mode 100644 index 0000000..834d6fe --- /dev/null +++ b/src/circleci/deps_plus/graph.clj @@ -0,0 +1,29 @@ +(ns circleci.deps-plus.graph) + +(defn graph-path-seq + "Returns a sequence of non-cyclic paths found via a breadth-first walk. + Equivalent to `graph-seq`, but each node is returned as a vector of nodes + represent the path from `root` (the first element) to the visited node (the last element). + `neighbors` must be a fn of one arg that returns the nodes adjacent to the given node. + `root` is the starting node." + [neighbors root] + (loop [to-visit [[root]] visited []] + (if (seq to-visit) + (let [path (first to-visit) + path-set (into #{} path) + adj (->> (neighbors (peek path)) + (remove path-set)) + adj-paths (map #(conj path %) adj)] + (recur + (into (subvec to-visit 1) adj-paths) + (conj visited path))) + (seq visited)))) + +(defn graph-seq + "Returns a sequence of graph nodes found via a breadth-first walk. Inspired by `tree-seq`. + `neighbors` must be a fn of one arg that returns the nodes adjacent to the given node. + `root` is the starting node." + [neighbors root] + (->> (graph-path-seq neighbors root) + (map peek) + (distinct))) diff --git a/src/leiningen/deps_plus.clj b/src/leiningen/deps_plus.clj new file mode 100644 index 0000000..6d50298 --- /dev/null +++ b/src/leiningen/deps_plus.clj @@ -0,0 +1,275 @@ +(ns leiningen.deps-plus + (:refer-clojure :exclude [list]) + (:require [leiningen.core.main :as lein] + [circleci.deps-plus.core :as deps] + [clojure.java.io :as io] + [leiningen.core.project :as project] + [clojure.string :as str])) + +(defn- list + "Lists all dependencies of the current project." + [project] + (deps/write-dependency-list project *out*)) + +(defn- list-save + "Save a list of all dependencies of the current project." + [project] + (with-open [out (io/writer "deps.list")] + (deps/write-dependency-list project out))) + +(defn- list-diff + "Compare current dependencies to those previously saved with `list-save`." + [project] + (let [in (io/file "deps.list")] + (when-not (.exists in) + (lein/warn "No deps.list file found. list-save must be run before list-diff.") + (lein/exit 1)) + (deps/diff-dependency-list project (slurp in) *out*))) + +(defn- check-downgraded + "Checks for dependencies which have been downgraded. + A dependency is considered \"downgraded\" if the resolved version is older + than the version specified by some other included dependency. This can occur + if `:managed-dependencies` is used to pin a dependency version or if Maven + conflict resolution happens to not select the latest version." + [project] + (doseq [[dep max-version] (deps/check-downgraded* project)] + (lein/info (format "%s downgraded to %s" + (assoc dep :version max-version) + (:version dep))))) + +(defn- check-families + "Checks for inconsistent dependency versions within families. + Dependencies families are sets of dependencies which share a common group ID + and version. Inconsistent family versions can occur if the `:managed-dependencies` + for a family are incomplete or inconsistent. This situation can also occur + when different family members are brought in through different transitive + dependency paths. To fix this issue consider adding a complete set of + `:managed-dependencies` entries for the family." + [project] + (when-let [combos (seq (deps/check-families* project))] + (lein/info (format "Found %d suspicious combinations." (count combos))) + (doseq [[parent child] combos] + (lein/info "Suspicious combination," (str parent) "and" (str child))))) + +(defn- format-dependency-tree-node + "Formats the dependency in Leiningen style." + [dep] + (let [dep (deps/coordinates->aether dep)] + ;; A Leiningen dependency is formatted as, + ;; + ;; [group/artifact "version" :param-key "param-value" ...] + ;; + ;; We rely on the fact that unquoted and quoted values alternate + ;; to convert to a string representation. + (str "[" (->> (partition 2 dep) + (map #(apply format "%s \"%s\"" %)) + (str/join " ")) "]"))) + +(defn- print-dependency-tree + "Prints a human readable dependency tree. + `deps` should be a dependency tree encoded as a map from coordinates to + collections of direct dependency coordinates. If provided, `resolved` should + be a set of resolved dependency coordinates. Matching dependencies will then + be annotated in the dependency graph output." + [deps resolved] + (let [;; Build a sequence of tree paths in depth first order (i.e. the order + ;; we need to print them in). Each path element is a `[node is-last?]` + ;; tuple where "is-last?" indicates if the node is the last child of + ;; its parent node. + paths (tree-seq + (constantly true) + (fn [path] + (let [[node] (peek path) + children (get deps node) + last-idx (dec (count children))] + (->> (map (fn [child idx] [child (= idx last-idx)]) + children (range)) + (map #(conj path %))))) + [[nil true]]) + ;; Every path begins with the nil root node. + ;; Remove it to simplify output. + paths (->> (map #(subvec % 1) paths) + (remove empty?))] + (doseq [path paths] + (let [[node last?] (peek path) + prefix-parts (concat + (->> (pop path) + (map second) + (map (fn [last?] (if last? " " "| ")))) + [(if last? "\\- " "+- ")]) + prefix (apply str prefix-parts) + annotated? (some #(deps/same-dependency? % node) resolved) + version-range (:version-range (meta node)) + premanaged-version (or (:premanaged-version (meta node)) + (:version node)) + suffix (cond + ;; Dependency should not be annotated. + (not annotated?) + "" + + ;; Version was omitted by conflict resolution. + (not (resolved node)) + " (omitted)" + + ;; Version was resolved from version range. + version-range + (str " (chosen from range " version-range ")") + + ;; Version was chosen by :managed-dependencies. + (not= (str premanaged-version) (str (:version node))) + (str " (managed from " premanaged-version ")") + + ;; Version matches the version chosen. + :else " (chosen)")] + (lein/info (str " ;; " prefix (format-dependency-tree-node node) suffix)))))) + +(defn- check-pedantic + "Checks for dependency conflicts and shows suggested :managed-dependencies. + Version conflicts occur when dependency resolution selects a version of a + dependency that is older than one appearing elsewhere in the dependency + graph (e.g. clj-time 0.14.0 is chosen at the exclusion of 0.15.0). Conflicts + are also reported any time a version range is encountered in the dependency + graph. + + The output from this task is a set of suggested managed dependencies. For + each managed dependency a dependency tree is also output that shows how the + conflicting dependency is depended upon. To suppress the outputting of these + trees pass the \":quiet\" flag. + + This check is similar to Leiningen's `:pedantic? :abort` mode, but shows + suggested :managed-dependencies instead of :exclusions. Unlike Leiningen, + plugin dependencies are ignored by this task." + [project & args] + (when-let [conflicts (seq (sort-by str (deps/find-pedantic-conflicts project)))] + (lein/info (format "Found %d dependency conflicts." (count conflicts))) + (lein/info "Considering adding the following :managed-dependencies,") + (doseq [[dep conflict-tree] conflicts] + (when-not (some #{":quiet"} args) + (lein/info "") + (print-dependency-tree conflict-tree #{dep})) + (lein/info (format " [%s/%s \"%s\"]" + (:group-id dep) (:artifact-id dep) (:version dep)))))) + +(defn- check-management-conflicts + "Checks for dependency management conflicts. + A dependency management conflict occurs when a dependency has versions + specified in both :dependencies and :managed-dependencies." + [project] + (when-let [conflicts (seq (deps/check-management-conflicts* project))] + (lein/info (format "Found %d management conflicts." (count conflicts))) + (doseq [[dep managed] conflicts] + (lein/info (format "%s conflicts with managed dependency %s" + dep managed))))) + +(defn- check-classpath-conflicts + "Checks for classpath conflicts. + Classpath conflicts occur when resources which should be unique (e.g. Java + class files, Clojure namespaces, etc) are found in multiple dependencies. + Classpath conflicts can lead to unpredictable behavior at runtime as the + version of the resource which is loaded can depend on subtle factors like + the ordering of JARs on the classpath, or the order in which JARs are merged + into an uberjar. Classpath conflicts are often caused by dependencies being + duplicated (e.g. two versions of a dependency with different Maven + coordinates) or dependencies which bundle other dependencies (e.g. shaded + JARs). In these cases the conflict can be resolved by replacing the offending + dependency with a more appropriate one (e.g. a non-shaded version)." + [project] + (let [conflicts (deps/check-classpath-conflicts* project) + ;; Index conflicts by the sets of dependencies they appear in + conflicts-by-set (->> (group-by val conflicts) + (reduce-kv + (fn [acc k entries] + (assoc acc k (into [] (map key entries)))) + {})) + format-dep-set (fn [dep-set] + (let [deps (into [] (sort (map str dep-set)))] + (str (str/join ", " (pop deps)) " and " (peek deps)))) + ;; Pre-format the dependency set keys as strings so we can + ;; sort the output and provide a stable output order. + conflicts-by-set-str (reduce-kv + (fn [acc dep-set conflicts] + (assoc acc + (format-dep-set dep-set) + conflicts)) + {} + conflicts-by-set)] + (when-not (empty? conflicts) + (lein/info (format "Found %d classpath conflicts between %d dependencies." + (count conflicts) + (->> conflicts-by-set keys (reduce into) count))) + (doseq [[dep-set-str conflicts] (sort conflicts-by-set-str)] + (lein/info "") + (lein/info (format "Found %d classpath conflicts between %s" + (count conflicts) dep-set-str)) + (let [n 10] + (doseq [entry (take n (sort conflicts))] + (lein/info " " entry)) + (when (< n (count conflicts)) + (lein/info " " (- (count conflicts) n) "more..."))))))) + +(defn- parse-target-dependency [^String target] + (if (.contains target ":") + (deps/parse-coordinates target) + (deps/aether->coordinates [(symbol target) nil]))) + +(defn- why + "Show all paths to the given dependency as a tree. + This is similar to `lein deps :why`, but all copies and paths to the given + dependency are shown." + [project group-name] + (let [target (parse-target-dependency group-name) + target (->> (deps/resolve-dependencies project) + (keys) + (filter #(deps/same-dependency? % target)) + (first))] + (if target + (let [subtree (deps/resolve-targeted-dependency-subtree project target)] + (print-dependency-tree subtree #{target})) + (lein/warn "No dependency found matching" group-name)))) + +(defn- who-shades + "Show project dependencies which shade the given dependency." + [project group-name] + (let [target (parse-target-dependency group-name)] + (doseq [[dep target-version] (deps/who-shades* project target)] + (println (str dep) "shades" (str target-version))))) + +(defn- error [project message] + (lein/warn "[ERROR]" message "\n") + (lein/resolve-and-apply project ["help" "deps-plus"]) + (lein/exit 1 message)) + +(defn- with-managed-deps + "Run a Leiningen task with the projects managed dependencies used as its dependencies." + [project args] + (let [dependencies (->> (:managed-dependencies project) + (map (fn [[name _ & rest]] + (into [name nil] rest)))) + project (project/merge-profiles project [{:dependencies dependencies}])] + (lein/resolve-and-apply project args))) + +(defn deps-plus + "Extra tasks for analyzing dependencies" + {:subtasks [#'list #'list-save #'list-diff #'check-downgraded #'check-families #'check-pedantic + #'check-management-conflicts #'check-classpath-conflicts #'why #'who-shades]} + [project & [sub-task & args]] + (let [project (project/unmerge-profiles project [:base :user])] + (case sub-task + "list" (list project) + "list-save" (list-save project) + "list-diff" (list-diff project) + "check-downgraded" (check-downgraded project) + "check-families" (check-families project) + "check-pedantic" (apply check-pedantic project args) + "check-management-conflicts" (check-management-conflicts project) + "check-classpath-conflicts" (check-classpath-conflicts project) + "why" (apply why project args) + "who-shades" (apply who-shades project args) + "with-managed-deps" (with-managed-deps project args) + ;; Leiningen is supposed to translate "lein <task> help" into + ;; "lein help <task>" by default. For some reason it does not + ;; seem to be working. + "help" (lein/resolve-and-apply project (concat ["help" "deps-plus"] args)) + nil (error project "Task required.") + (error project "Unknown task.")))) diff --git a/test-projects/classpath-conflict.clj b/test-projects/classpath-conflict.clj new file mode 100644 index 0000000..2466977 --- /dev/null +++ b/test-projects/classpath-conflict.clj @@ -0,0 +1,3 @@ +(defproject deps-plus-tests/classpath-conflict "0-SNAPSHOT" + :dependencies [[com.google.guava/guava "17.0"] + [com.google.guava/guava-jdk5 "17.0"]]) diff --git a/test-projects/dependency-diff.clj b/test-projects/dependency-diff.clj new file mode 100644 index 0000000..67c95c4 --- /dev/null +++ b/test-projects/dependency-diff.clj @@ -0,0 +1,10 @@ +(defproject deps-plus-tests/dependency-diff "0-SNAPSHOT" + :offline? true + :dependencies [[fixture/added "1.0"] + [fixture/changed-classifier "1.0"] + [fixture/changed-extension "1.0"] + [fixture/changed-scope "1.0"] + [fixture/downgraded "0.9"] + ;; [fixture/removed "1.0"] + [fixture/unmodified "1.0"] + [fixture/upgraded "2.0"]]) diff --git a/test-projects/dependency-diff.list b/test-projects/dependency-diff.list new file mode 100644 index 0000000..d2fae44 --- /dev/null +++ b/test-projects/dependency-diff.list @@ -0,0 +1,7 @@ +fixture:changed-classifier:jar:sources:1.0:compile +fixture:changed-extension:tar.gz:1.0:compile +fixture:changed-scope:jar:1.0:test +fixture:downgraded:jar:1.0:compile +fixture:removed:jar:1.0:compile +fixture:unmodified:jar:1.0:compile +fixture:upgraded:jar:1.0:compile diff --git a/test-projects/dependency-list.clj b/test-projects/dependency-list.clj new file mode 100644 index 0000000..4b51799 --- /dev/null +++ b/test-projects/dependency-list.clj @@ -0,0 +1,3 @@ +(defproject deps-plus-tests/dependency-list "0-SNAPSHOT" + :dependencies [[io.grpc/grpc-context "1.20.0"] + [com.google.code.gson/gson "2.7"]]) diff --git a/test-projects/downgrade.clj b/test-projects/downgrade.clj new file mode 100644 index 0000000..8182aa7 --- /dev/null +++ b/test-projects/downgrade.clj @@ -0,0 +1,4 @@ +(defproject deps-plus-tests/family-mismatch "0-SNAPSHOT" + ;; grpc-core 1.20 depends on gson 2.7 + :managed-dependencies [[com.google.code.gson/gson "2.6"]] + :dependencies [[io.grpc/grpc-core "1.20.0"]]) diff --git a/test-projects/family-mismatch.clj b/test-projects/family-mismatch.clj new file mode 100644 index 0000000..08199b9 --- /dev/null +++ b/test-projects/family-mismatch.clj @@ -0,0 +1,3 @@ +(defproject deps-plus-tests/family-mismatch "0-SNAPSHOT" + :managed-dependencies [[io.grpc/grpc-context "1.19.0"]] + :dependencies [[io.grpc/grpc-core "1.20.0"]]) diff --git a/test-projects/management-conflict.clj b/test-projects/management-conflict.clj new file mode 100644 index 0000000..17e14c5 --- /dev/null +++ b/test-projects/management-conflict.clj @@ -0,0 +1,5 @@ +(defproject deps-plus-tests/management-conflict "0-SNAPSHOT" + :managed-dependencies [[io.grpc/grpc-api "1.21.0"] + [io.grpc/grpc-core "1.21.0"]] + :dependencies [[io.grpc/grpc-api "1.22.0"] + [io.grpc/grpc-core]]) diff --git a/test-projects/pedantic-range.clj b/test-projects/pedantic-range.clj new file mode 100644 index 0000000..42d6608 --- /dev/null +++ b/test-projects/pedantic-range.clj @@ -0,0 +1,3 @@ +(defproject deps-plus-tests/pedantic-range "0-SNAPSHOT" + ;; grpc-grpclb has a version ranged dependency on grpc-core + :dependencies [[io.grpc/grpc-grpclb "1.19.0"]]) diff --git a/test-projects/pedantic-version-conflict.clj b/test-projects/pedantic-version-conflict.clj new file mode 100644 index 0000000..7d0f4d1 --- /dev/null +++ b/test-projects/pedantic-version-conflict.clj @@ -0,0 +1,3 @@ +(defproject deps-plus-tests/pedantic-version-conflict "0-SNAPSHOT" + :dependencies [[io.grpc/grpc-protobuf "1.20.0"] + [io.grpc/grpc-grpclb "1.19.0"]]) diff --git a/test-projects/shaded-dependencies.clj b/test-projects/shaded-dependencies.clj new file mode 100644 index 0000000..63da9e2 --- /dev/null +++ b/test-projects/shaded-dependencies.clj @@ -0,0 +1,7 @@ +(defproject deps-plus-tests/classpath-conflict "0-SNAPSHOT" + :dependencies [[io.grpc/grpc-netty-shaded "1.20.0"] + ;; Included to ensure we also have unshaded copies of Netty + ;; dependencies to search. We do not want who-shades to return + ;; self matches (e.g. netty-common does not shade netty-common). + [io.grpc/grpc-netty "1.20.0"] + [io.honeycomb.libhoney/libhoney-java "1.3.1"]]) diff --git a/test/circleci/deps_plus/core_test.clj b/test/circleci/deps_plus/core_test.clj new file mode 100644 index 0000000..65f3af3 --- /dev/null +++ b/test/circleci/deps_plus/core_test.clj @@ -0,0 +1,274 @@ +(ns circleci.deps-plus.core-test + (:require [clojure.test :refer (deftest testing is use-fixtures)]) + (:require [circleci.deps-plus.core :as deps] + [leiningen.core.project :as lein-project] + [clojure.string :as str]) + (:import (org.apache.maven.artifact.versioning ComparableVersion) + (java.io StringWriter))) + +;; Needed when not running with :eval-in :leiningen (which would do this for us) +(use-fixtures :once + (fn [f] + (lein-project/ensure-dynamic-classloader) + (f))) + +(deftest check-families-tests + (testing "check-families*" + (let [project (lein-project/read "test-projects/family-mismatch.clj") + problems (into [] (deps/check-families* project)) + [parent child] (first problems)] + (is (= 1 (count problems))) + (is (= "grpc-core" (:artifact-id parent))) + (is (= "grpc-context" (:artifact-id child))) + (is (= "1.20.0" (str (:version parent)))) + (is (= "1.19.0" (str (:version child))))))) + +(deftest check-downgraded-tests + (testing "check-downgraded*" + (let [project (lein-project/read "test-projects/downgrade.clj") + problems (into [] (deps/check-downgraded* project)) + [dep max-version] (first problems)] + (is (= 1 (count problems))) + (is (= "com.google.code.gson:gson:jar:2.6:compile" (str dep))) + (is (= "2.7" (str max-version)))))) + +(deftest parse-coordinates + (testing "minimal coordinates" + (let [coord (deps/parse-coordinates "io.grpc:grpc-core")] + (is (= "io.grpc" (:group-id coord))) + (is (= "grpc-core" (:artifact-id coord))) + (is (= "jar" (:extension coord))) + (is (= "" (:classifier coord))) + (is (nil? (:version coord))) + (is (nil? (:scope (meta coord)))))) + + (testing "minimal coordinates with extension" + (let [coord (deps/parse-coordinates "io.grpc:grpc-core:tar.gz")] + (is (= "io.grpc" (:group-id coord))) + (is (= "grpc-core" (:artifact-id coord))) + (is (= "tar.gz" (:extension coord))) + (is (= "" (:classifier coord))) + (is (nil? (:version coord))) + (is (nil? (:scope (meta coord)))))) + + (testing "basic coordinates" + (let [coord (deps/parse-coordinates "io.grpc:grpc-core:jar:1.20.0")] + (is (= "io.grpc" (:group-id coord))) + (is (= "grpc-core" (:artifact-id coord))) + (is (= "jar" (:extension coord))) + (is (= "" (:classifier coord))) + (is (= (ComparableVersion. "1.20.0") (:version coord))) + (is (nil? (:scope (meta coord)))))) + + (testing "with scope" + (let [coord (deps/parse-coordinates "io.grpc:grpc-core:jar:1.20.0:provided")] + (is (= "io.grpc" (:group-id coord))) + (is (= "grpc-core" (:artifact-id coord))) + (is (= "jar" (:extension coord))) + (is (= "" (:classifier coord))) + (is (= (ComparableVersion. "1.20.0") (:version coord))) + (is (= "provided" (:scope (meta coord)))))) + + (testing "with classifier" + (let [coord (deps/parse-coordinates "io.grpc:grpc-core:jar:sources:1.20.0")] + (is (= "io.grpc" (:group-id coord))) + (is (= "grpc-core" (:artifact-id coord))) + (is (= "jar" (:extension coord))) + (is (= "sources" (:classifier coord))) + (is (= (ComparableVersion. "1.20.0") (:version coord))) + (is (nil? (:scope (meta coord)))))) + + (testing "with scope and classifier" + (let [coord (deps/parse-coordinates "io.grpc:grpc-core:jar:sources:1.20.0:provided")] + (is (= "io.grpc" (:group-id coord))) + (is (= "grpc-core" (:artifact-id coord))) + (is (= "jar" (:extension coord))) + (is (= "sources" (:classifier coord))) + (is (= (ComparableVersion. "1.20.0") (:version coord))) + (is (= "provided" (:scope (meta coord))))))) + +(deftest write-dependency-list + (let [project (lein-project/read "test-projects/dependency-list.clj" []) + buffer (StringWriter.) + _ (deps/write-dependency-list project buffer) + lines (str/split-lines (str buffer))] + (is (= "com.google.code.gson:gson:jar:2.7:compile" (first lines))) + (is (= "io.grpc:grpc-context:jar:1.20.0:compile" (second lines))))) + +(deftest diff-dependency-list + (let [project (lein-project/read "test-projects/dependency-diff.clj" []) + list (slurp "test-projects/dependency-diff.list") + buffer (StringWriter.) + _ (deps/diff-dependency-list project list buffer) + lines (str/split-lines (str buffer))] + (is (= [";; Added" + "fixture:added:jar:1.0:compile" + "fixture:changed-classifier:jar:1.0:compile" + "fixture:changed-extension:jar:1.0:compile" + "" + ";; Removed" + "fixture:changed-classifier:jar:sources:1.0:compile" + "fixture:changed-extension:tar.gz:1.0:compile" + "fixture:removed:jar:1.0:compile" + "" + ";; Upgraded" + "fixture:upgraded:jar:2.0:compile (1.0 -> 2.0)" + "" + ";; Downgraded" + "fixture:downgraded:jar:0.9:compile (1.0 -> 0.9)" + "" + ";; Rescoped" + "fixture:changed-scope:jar:1.0:compile (test -> compile)"] + lines))) + (testing "empty sections aren't included" + (let [project (lein-project/read "test-projects/dependency-diff.clj" []) + buffer (StringWriter.) + _ (deps/diff-dependency-list project "" buffer) + out (str buffer)] + (is (= [";; Added"] + (filter (fn [line] (str/starts-with? line ";; ")) + (str/split-lines out))))))) + +(deftest find-pedantic-conflicts-tests + (testing "version conflict" + (let [project (lein-project/read "test-projects/pedantic-version-conflict.clj" []) + conflicts (deps/find-pedantic-conflicts project)] + (is (= 1 (count conflicts))) + (is (= "io.grpc:grpc-core:jar:1.19.0:compile" (str (first (first conflicts))))))) + + (testing "version range" + (let [project (lein-project/read "test-projects/pedantic-range.clj" []) + conflicts (deps/find-pedantic-conflicts project)] + (is (= 1 (count conflicts))) + (is (= "io.grpc:grpc-core:jar:1.19.0:compile" (str (first (first conflicts)))))))) + +(deftest parse-dependency-list-test + (testing "empty list" + (let [deps (#'deps/parse-dependency-list "")] + (is (empty? deps)))) + + (testing "one dependency" + (let [deps (#'deps/parse-dependency-list "io.grpc:grpc-core:jar:1.19.0:compile") + {:keys [group-id artifact-id extension classifier version]} (first deps)] + (is (= 1 (count deps))) + (is (= "io.grpc" group-id)) + (is (= "grpc-core" artifact-id)) + (is (= "jar" extension)) + (is (= "" classifier)) + (is (= (ComparableVersion. "1.19.0") version)) + (is (= "compile" (:scope (meta (first deps))))))) + + (testing "multiple dependencies" + (let [deps (#'deps/parse-dependency-list (str "io.grpc:grpc-core:jar:1.19.0:compile\n" + "io.grpc:grpc-api:jar:1.21.0:compile\n"))] + (is (= 2 (count deps))) + (let [{:keys [group-id artifact-id extension classifier version]} (first deps)] + (is (= "io.grpc" group-id)) + (is (= "grpc-core" artifact-id)) + (is (= "jar" extension)) + (is (= "" classifier)) + (is (= (ComparableVersion. "1.19.0") version)) + (is (= "compile" (:scope (meta (first deps)))))) + (let [{:keys [group-id artifact-id extension classifier version]} (second deps)] + (is (= "io.grpc" group-id)) + (is (= "grpc-api" artifact-id)) + (is (= "jar" extension)) + (is (= "" classifier)) + (is (= (ComparableVersion. "1.21.0") version)) + (is (= "compile" (:scope (meta (first deps))))))))) + +(deftest check-management-conflicts-tests + (testing "management conflict" + (let [project (lein-project/read "test-projects/management-conflict.clj" []) + conflicts (deps/check-management-conflicts* project)] + (is (= 1 (count conflicts))) + (let [[dep managed] (first conflicts)] + (is (= "io.grpc:grpc-api:jar:1.22.0" (str dep))) + (is (= "io.grpc:grpc-api:jar:1.21.0" (str managed))))))) + +(deftest resolve-dependencies + (testing "standard resolution includes ranges" + (let [project (lein-project/read "test-projects/pedantic-range.clj" []) + resolved (deps/resolve-dependencies project) + grpc-cores (->> (vals resolved) + (mapcat identity) + (filter #(= "grpc-core" (:artifact-id %))))] + (is (< 0 (count grpc-cores))) + ;; Every copy should be identical and should therefore have a version range. + (is (every? (comp :version-range meta) grpc-cores)) + (is (= "[1.19.0,1.19.0]" (str (:version-range (meta (first grpc-cores)))))))) + + (testing "full-graph resolution includes ranges" + (let [project (lein-project/read "test-projects/pedantic-range.clj" []) + resolved (deps/resolve-dependencies project :full-graph) + grpc-cores (->> (vals resolved) + (mapcat identity) + (filter #(= "grpc-core" (:artifact-id %))))] + ;; We should have copies both with and without version ranges + (is (< 0 (count (filter (comp :version-range meta) grpc-cores)))) + (is (< 0 (count (remove (comp :version-range meta) grpc-cores)))))) + + (testing "standard resolution includes premanaged version" + (let [project (lein-project/read "test-projects/downgrade.clj" []) + resolved (deps/resolve-dependencies project) + gson (->> (keys resolved) + (filter #(= "gson" (:artifact-id %))) + (first))] + (is (some? gson)) + (is (= "2.6" (str (:version gson)))) + (is (= "2.7" (str (:premanaged-version (meta gson))))))) + + (testing "full-graph resolution includes premanaged version" + (let [project (lein-project/read "test-projects/downgrade.clj" []) + resolved (deps/resolve-dependencies project :full-graph) + gson (->> (keys resolved) + (filter #(= "gson" (:artifact-id %))) + (first))] + (is (some? gson)) + (is (= "2.6" (str (:version gson)))) + (is (= "2.7" (str (:premanaged-version (meta gson)))))))) + +(deftest check-classpath-conflicts-test + (let [project (lein-project/read "test-projects/classpath-conflict.clj" []) + conflicts (deps/check-classpath-conflicts* project) + conflicting-deps (get conflicts "com/google/common/base/Preconditions.class")] + (is (= 2 (count conflicting-deps))) + (is (= #{"guava" "guava-jdk5"} (into #{} (map :artifact-id conflicting-deps)))))) + +(deftest who-shades + (testing "dependency only appearing as shaded" + (let [project (lein-project/read "test-projects/shaded-dependencies.clj" []) + target (deps/parse-coordinates "commons-codec:commons-codec") + result (deps/who-shades* project target)] + (is (= 1 (count result))) + (let [[shader shadee] (first result)] + (is (= "io.honeycomb.libhoney:libhoney-java:jar:1.3.1:compile" (str shader))) + (is (= "commons-codec:commons-codec:jar:1.9" (str shadee)))))) + + ;; This case validates that who-shades does not return self + ;; matches (e.g. netty-common does not shade itself). + (testing "dependency appearing as both shaded and standalone" + (let [project (lein-project/read "test-projects/shaded-dependencies.clj" []) + target (deps/parse-coordinates "io.netty:netty-common") + result (deps/who-shades* project target)] + (is (= 1 (count result))) + (let [[shader shadee] (first result)] + (is (= "io.grpc:grpc-netty-shaded:jar:1.20.0:compile" (str shader))) + (is (= "io.netty:netty-common:jar:4.1.34.Final" (str shadee)))))) + + ;; This behavior could be changed, but at least at the moment the version + ;; number in the target is ignored. + (testing "dependency only appearing as shaded" + (let [project (lein-project/read "test-projects/shaded-dependencies.clj" []) + target (deps/parse-coordinates "commons-codec:commons-codec:jar:1.2.3") + result (deps/who-shades* project target)] + (is (= 1 (count result))) + (let [[shader shadee] (first result)] + (is (= "io.honeycomb.libhoney:libhoney-java:jar:1.3.1:compile" (str shader))) + (is (= "commons-codec:commons-codec:jar:1.9" (str shadee)))))) + + (testing "no matches" + (let [project (lein-project/read "test-projects/shaded-dependencies.clj" []) + target (deps/parse-coordinates "super-fake:so-fake:jar:what-big-versions") + result (deps/who-shades* project target)] + (is (empty? result))))) diff --git a/test/circleci/deps_plus/graph_test.clj b/test/circleci/deps_plus/graph_test.clj new file mode 100644 index 0000000..0aa78b5 --- /dev/null +++ b/test/circleci/deps_plus/graph_test.clj @@ -0,0 +1,58 @@ +(ns circleci.deps-plus.graph-test + (:require [clojure.test :refer [deftest is testing]]) + (:require [circleci.deps-plus.graph :as graph])) + +(defn- neighbors-fn [g] + (fn [n] (sort (get g n)))) + +(deftest graph-seq + (testing "break cycle" + (let [g {:a #{:b} + :b #{:a}}] + (is (= [:a :b] + (into [] (graph/graph-seq (neighbors-fn g) :a)))))) + + (testing "breadth first" + (let [g {:a #{:b :c} + :b #{:d :e} + :c #{:f :g}}] + (is (= [:a :b :c :d :e :f :g] + (into [] (graph/graph-seq (neighbors-fn g) :a)))))) + + (testing "remove duplicates" + (let [g {:a #{:b :c} + :b #{:c}}] + (is (= [:a :b :c] + (into [] (graph/graph-seq (neighbors-fn g) :a))))))) + +(deftest graph-path-seq + (testing "break cycle" + (let [g {:a #{:b} + :b #{:c} + :c #{:b}}] + (is (= [[:a] + [:a :b] + [:a :b :c]] + (into [] (graph/graph-path-seq (neighbors-fn g) :a)))))) + + (testing "breadth first" + (let [g {:a #{:b :c} + :b #{:d :e} + :c #{:f :g}}] + (is (= [[:a] + [:a :b] + [:a :c] + [:a :b :d] + [:a :b :e] + [:a :c :f] + [:a :c :g]] + (into [] (graph/graph-path-seq (neighbors-fn g) :a)))))) + + (testing "follow all paths" + (let [g {:a #{:b :c} + :b #{:c}}] + (is (= [[:a] + [:a :b] + [:a :c] + [:a :b :c]] + (into [] (graph/graph-path-seq (neighbors-fn g) :a)))))))