[2.x] feat: dependencyLicenseInfo (#8506)

* Add JSON output support to dependencyLicenseInfo

- Add LicenseInfo rendering object with text and JSON output
- Add dependencyLicenseInfo input task key
- Implement dependencyLicenseInfo task with JSON format support
- Supports --out option for file output
- Auto-detects JSON format from .json file extension
- Follows same pattern as dependencyTree task

Resolves #7771
This commit is contained in:
Saber 2026-01-12 19:16:40 -08:00 committed by GitHub
parent 847703cd5e
commit aeabb90d2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 134 additions and 0 deletions

View File

@ -0,0 +1,50 @@
/*
* sbt
* Copyright 2023, Scala center
* Copyright 2011 - 2022, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt
package internal
package graph
package rendering
import sbt.internal.graph.*
object LicenseInfo {
def render(graph: ModuleGraph): String =
graph.nodes
.filter(_.isUsed)
.groupBy(_.license)
.toSeq
.sortBy(_._1)
.map { case (license, modules) =>
license.getOrElse("No license specified") + "\n" +
modules.map(m => s"\t ${m.id.idString}").mkString("\n")
}
.mkString("\n\n")
def renderJson(graph: ModuleGraph): String = {
// Create JSON array manually: [{license: "...", modules: [...]}, ...]
def escapeJson(str: String): String =
str.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r")
def formatModuleList(modules: Vector[String]): String =
modules.map(m => s""""${escapeJson(m)}"""").mkString("[", ",", "]")
val groups = graph.nodes
.filter(_.isUsed)
.groupBy(_.license)
.toSeq
.sortBy(_._1)
.map { case (license, modules) =>
val licenseStr = license.getOrElse("No license specified")
val moduleList = formatModuleList(modules.map(_.id.idString).toVector.sorted)
s"""{"license":"${escapeJson(licenseStr)}","modules":$moduleList}"""
}
groups.mkString("[", ",", "]")
}
}

View File

@ -34,6 +34,8 @@ abstract class DependencyTreeKeys:
private[sbt] val dependencyTreeModuleGraphStore =
taskKey[ModuleGraph]("The stored module-graph from the last run")
val whatDependsOn = inputKey[String]("Shows information about what depends on the given module")
val dependencyLicenseInfo =
inputKey[String]("Displays license information for dependencies in text or JSON format")
private[sbt] val dependencyTreeCrossProjectId = settingKey[ModuleID]("")
// 0 was added to avoid conflict with sbt-dependency-tree

View File

@ -254,8 +254,52 @@ OPTIONS
}
output
},
dependencyLicenseInfo := (Def.inputTaskDyn {
val s = streams.value
val args = ArgsParser.parsed.toList
val isHelp = args.contains(Arg.Help)
val isQuiet = args.contains(Arg.Quiet)
if isHelp then Def.task { s.log.info(licenseInfoUsageText); "" }
else
val formatOpt = (args
.collect { case Arg.Format(fmt) => fmt })
.reverse
.headOption
val outFileNameOpt = (args
.collect { case Arg.Out(out) => out })
.reverse
.headOption
val outFileOpt = outFileNameOpt.map(new File(_))
val format = (formatOpt, outFileNameOpt) match
case (None, Some(out)) if out.endsWith(".json") => Fmt.Json
case (Some(fmt), _) => fmt
case _ => Fmt.Tree
Def.task {
val graph = dependencyTreeModuleGraph0.value
val output = format match
case Fmt.Json => rendering.LicenseInfo.renderJson(graph)
case _ => rendering.LicenseInfo.render(graph)
handleOutput(output, outFileOpt, isQuiet, s.log)
}
}).evaluated,
)
def licenseInfoUsageText: String =
s"""dependencyLicenseInfo task displays license information for dependencies.
USAGE
dependencyLicenseInfo [subcommand] [options]
SUBCOMMAND
json Prints JSON (default is text)
help Prints this help
OPTIONS
--quiet Returns the output as task value
--out <file> Writes the output to the specified file;
The file extension will influence the default subcommand
"""
private def handleOutput(
content: String,
outputFileOpt: Option[File],

View File

@ -0,0 +1,30 @@
name := "license-info-test"
scalaVersion := "3.3.1"
libraryDependencies += "org.scala-lang" % "scala-library" % scalaVersion.value
TaskKey[Unit]("check") := {
import java.io.File
import sbt.io.IO
// Check text file
val textFile = new File("target/licenses.txt")
require(textFile.exists(), s"Text file ${textFile.getPath} does not exist")
val textContent = IO.read(textFile)
require(textContent.nonEmpty, "Text file is empty")
require(textContent.contains("scala-library") || textContent.contains("No license specified"),
"Text file should contain license information")
// Check JSON file
val jsonFile = new File("target/licenses.json")
require(jsonFile.exists(), s"JSON file ${jsonFile.getPath} does not exist")
val jsonContent = IO.read(jsonFile)
require(jsonContent.nonEmpty, "JSON file is empty")
require(jsonContent.trim.startsWith("[") && jsonContent.trim.endsWith("]"),
"JSON file should be a valid JSON array")
require(jsonContent.contains("\"license\"") && jsonContent.contains("\"modules\""),
"JSON file should contain license and modules fields")
()
}

View File

@ -0,0 +1,8 @@
# Test dependencyLicenseInfo text output
> dependencyLicenseInfo --out target/licenses.txt
$ exists target/licenses.txt
# Test dependencyLicenseInfo JSON output
> dependencyLicenseInfo json --out target/licenses.json
$ exists target/licenses.json
> check