@socketsecurity/cli-with-sentry 1.1.131 → 1.1.132

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/cli.js +1227 -144
  3. package/dist/cli.js.map +1 -1
  4. package/dist/constants.js +4 -4
  5. package/dist/constants.js.map +1 -1
  6. package/dist/manifest-scripts/maven-extension/coana-maven-extension.jar +0 -0
  7. package/dist/manifest-scripts/socket-facts.init.gradle +479 -0
  8. package/dist/manifest-scripts/socket-facts.plugin.scala +444 -0
  9. package/dist/tsconfig.dts.tsbuildinfo +1 -1
  10. package/dist/types/commands/manifest/cmd-manifest-gradle.d.mts.map +1 -1
  11. package/dist/types/commands/manifest/cmd-manifest-kotlin.d.mts.map +1 -1
  12. package/dist/types/commands/manifest/cmd-manifest-maven.d.mts.map +1 -1
  13. package/dist/types/commands/manifest/convert-gradle-to-facts.d.mts +5 -6
  14. package/dist/types/commands/manifest/convert-gradle-to-facts.d.mts.map +1 -1
  15. package/dist/types/commands/manifest/convert-maven-to-facts.d.mts +5 -6
  16. package/dist/types/commands/manifest/convert-maven-to-facts.d.mts.map +1 -1
  17. package/dist/types/commands/manifest/convert-sbt-to-facts.d.mts +7 -8
  18. package/dist/types/commands/manifest/convert-sbt-to-facts.d.mts.map +1 -1
  19. package/dist/types/commands/manifest/generate_auto_manifest.d.mts +8 -1
  20. package/dist/types/commands/manifest/generate_auto_manifest.d.mts.map +1 -1
  21. package/dist/types/commands/manifest/run-manifest-facts.d.mts +20 -0
  22. package/dist/types/commands/manifest/run-manifest-facts.d.mts.map +1 -0
  23. package/dist/types/commands/manifest/scripts/assemble.d.mts +16 -0
  24. package/dist/types/commands/manifest/scripts/assemble.d.mts.map +1 -0
  25. package/dist/types/commands/manifest/scripts/build-tool.d.mts +3 -0
  26. package/dist/types/commands/manifest/scripts/build-tool.d.mts.map +1 -0
  27. package/dist/types/commands/manifest/scripts/facts.d.mts +46 -0
  28. package/dist/types/commands/manifest/scripts/facts.d.mts.map +1 -0
  29. package/dist/types/commands/manifest/scripts/records.d.mts +64 -0
  30. package/dist/types/commands/manifest/scripts/records.d.mts.map +1 -0
  31. package/dist/types/commands/manifest/scripts/resolution-report-gradle.d.mts +10 -0
  32. package/dist/types/commands/manifest/scripts/resolution-report-gradle.d.mts.map +1 -0
  33. package/dist/types/commands/manifest/scripts/resolution-report-ivy.d.mts +7 -0
  34. package/dist/types/commands/manifest/scripts/resolution-report-ivy.d.mts.map +1 -0
  35. package/dist/types/commands/manifest/scripts/resolution-report-maven.d.mts +8 -0
  36. package/dist/types/commands/manifest/scripts/resolution-report-maven.d.mts.map +1 -0
  37. package/dist/types/commands/manifest/scripts/resolution-report-render.d.mts +41 -0
  38. package/dist/types/commands/manifest/scripts/resolution-report-render.d.mts.map +1 -0
  39. package/dist/types/commands/manifest/scripts/resolution-report.d.mts +12 -0
  40. package/dist/types/commands/manifest/scripts/resolution-report.d.mts.map +1 -0
  41. package/dist/types/commands/manifest/scripts/run.d.mts +28 -0
  42. package/dist/types/commands/manifest/scripts/run.d.mts.map +1 -0
  43. package/dist/types/commands/manifest/scripts/sidecar.d.mts +27 -0
  44. package/dist/types/commands/manifest/scripts/sidecar.d.mts.map +1 -0
  45. package/dist/types/commands/scan/cmd-scan-create.d.mts.map +1 -1
  46. package/dist/types/commands/scan/handle-create-new-scan.d.mts.map +1 -1
  47. package/dist/types/commands/scan/perform-reachability-analysis.d.mts +4 -2
  48. package/dist/types/commands/scan/perform-reachability-analysis.d.mts.map +1 -1
  49. package/dist/utils.js +1 -78
  50. package/dist/utils.js.map +1 -1
  51. package/package.json +3 -2
  52. package/dist/types/commands/manifest/coana-manifest-facts.d.mts +0 -27
  53. package/dist/types/commands/manifest/coana-manifest-facts.d.mts.map +0 -1
  54. package/dist/types/utils/auto-manifest-config.d.mts +0 -55
  55. package/dist/types/utils/auto-manifest-config.d.mts.map +0 -1
@@ -0,0 +1,444 @@
1
+ package socket
2
+
3
+ import sbt._
4
+ import sbt.Keys._
5
+
6
+ import scala.collection.mutable
7
+ import scala.reflect.ClassTag
8
+
9
+ /**
10
+ * Emits a flat line-protocol RECORDS file at the build root (NOT the final
11
+ * `.socket.facts.json`, and NOT to stdout — sbt prints resolution noise to stdout
12
+ * with no way to silence it). The TS assembler (utils/src/manifest-scripts/assemble.ts)
13
+ * reads the records and owns all SBOM construction — graph merge, content-addressed
14
+ * ids, contentHash. This plugin only RESOLVES and emits raw facts. See records.ts for
15
+ * the record grammar.
16
+ *
17
+ * Must compile on Scala 2.10/sbt 0.13 and Scala 2.12/sbt 1.x (compiled by the sbt
18
+ * meta-build), hence string-named TaskKeys and reflection for version-specific
19
+ * ExclusionRule / ResolveException / ConfigRef shapes.
20
+ */
21
+ object SocketFactsPlugin extends AutoPlugin {
22
+ override def trigger = allRequirements
23
+
24
+ object autoImport {
25
+ val socketFacts =
26
+ taskKey[Unit]("Emit Socket facts records for the whole build")
27
+ }
28
+ import autoImport._
29
+
30
+ override def projectSettings: Seq[Setting[_]] = Seq(
31
+ aggregate in socketFacts := false,
32
+ socketFacts := {
33
+ val st = state.value
34
+ val buildRoot = (baseDirectory in ThisBuild).value
35
+ val withFiles = boolProp("socket.withFiles")
36
+ val populateScope: Option[Set[String]] = readPopulateScope()
37
+
38
+ val extracted = Project.extract(st)
39
+ val allRefs = extracted.structure.allProjectRefs
40
+
41
+ // Prefer `updateFull` (coursier `update` returns empty callers); fall back to `update` (sbt 0.13).
42
+ val hasUpdateFull =
43
+ extracted.structure.settings.map(_.key.key).exists(_.label == "updateFull")
44
+ val updateTaskName = if (hasUpdateFull) "updateFull" else "update"
45
+
46
+ // One tree per resolution root (project, config); the TS assembler merges them path-sensitively.
47
+ val perSub = mutable.LinkedHashMap.empty[String, RootTree]
48
+ val failures = mutable.LinkedHashSet.empty[Failure]
49
+ val scannedConfigs = mutable.LinkedHashSet.empty[String]
50
+ val matcher = buildConfigMatcher()
51
+
52
+ // Real artifact ext per build module, so an ext-less inter-project dep gets its true coordinate.
53
+ val moduleExts = buildModuleExts(allRefs, extracted)
54
+
55
+ allRefs.foreach { ref =>
56
+ runUpdateResilient(updateTaskName, ref, extracted, st, failures).foreach { report =>
57
+ foldReport(report, ref, extracted, matcher, scannedConfigs, withFiles, populateScope, moduleExts).foreach {
58
+ case (rootKey, tree) => perSub(rootKey) = tree
59
+ }
60
+ }
61
+ }
62
+
63
+ val moduleDirs: Map[String, (Seq[String], Seq[String])] =
64
+ if (withFiles) buildModuleDirs(allRefs, extracted) else Map.empty
65
+
66
+ val rootPath = buildRoot.getCanonicalFile.toPath
67
+ def relOf(f: File): String = {
68
+ val r = rootPath.relativize(f.getCanonicalFile.toPath).toString.replace(java.io.File.separator, "/")
69
+ if (r.isEmpty) "." else r
70
+ }
71
+
72
+ val sb = new StringBuilder
73
+ def rec(fields: String*): Unit = {
74
+ sb.append(fields.map(esc).mkString("\t")); sb.append('\n')
75
+ }
76
+
77
+ rec("meta", "sbt", extracted.getOpt(sbtVersion).getOrElse(""), sys.props.getOrElse("java.version", ""))
78
+
79
+ // One `project` record per build module (sources/targets only with --with-files).
80
+ allRefs.foreach { ref =>
81
+ val mid = rootIdOf(extracted, ref)
82
+ val ver = if (mid.revision == null) "" else mid.revision
83
+ rec("project", ref.project, mid.organization, mid.name, ver, relOf(extracted.get(baseDirectory.in(ref))))
84
+ if (withFiles) {
85
+ moduleDirs.get(mid.organization + ":" + mid.name + ":" + ver).foreach {
86
+ case (sources, targets) =>
87
+ sources.foreach(s => rec("projectSrc", ref.project, s))
88
+ targets.foreach(t => rec("projectTgt", ref.project, t))
89
+ }
90
+ }
91
+ }
92
+
93
+ // One resolution root per (subproject, configuration); the TS assembler content-addresses
94
+ // divergent subtrees.
95
+ var rootIdx = 0
96
+ perSub.foreach {
97
+ case (_, tree) =>
98
+ val rootId = rootIdx.toString
99
+ rootIdx += 1
100
+ rec("root", rootId, tree.projectKey, tree.config, if (tree.prod) "1" else "0")
101
+ tree.nodes.foreach {
102
+ case (coordId, node) =>
103
+ val c = node.coord
104
+ rec("node", rootId, coordId, c.org, c.name, c.version, c.ext, c.classifier, if (node.direct) "1" else "0")
105
+ node.children.foreach(ch => rec("edge", rootId, coordId, ch))
106
+ node.targets.foreach(p => rec("file", rootId, coordId, p))
107
+ }
108
+ }
109
+
110
+ // Scanned configs + raw failures (Coana owns the abort/warn policy and report rendering).
111
+ scannedConfigs.toList.sorted.foreach(c => rec("scanned", c))
112
+ failures.foreach(f => rec("failure", f.coord, f.detail, f.config))
113
+
114
+ val recordsFile = sys.props.get("socket.recordsFile").filter(_.nonEmpty) match {
115
+ case Some(p) => new File(p)
116
+ case None => new File(buildRoot, ".socket.facts.records.tsv")
117
+ }
118
+ Option(recordsFile.getParentFile).foreach(_.mkdirs())
119
+ IO.write(recordsFile, sb.toString)
120
+ println("Socket facts records written to: " + recordsFile.getAbsolutePath)
121
+ }
122
+ )
123
+
124
+ // ---- resolution ---------------------------------------------------------
125
+
126
+ private def rootIdOf(extracted: Extracted, ref: ProjectRef): ModuleID = {
127
+ val sv = extracted.get(scalaVersion.in(ref))
128
+ val sbv = extracted.get(scalaBinaryVersion.in(ref))
129
+ CrossVersion.apply(sv, sbv)(extracted.get(projectID.in(ref)))
130
+ }
131
+
132
+ // GAV -> a build module's real artifact extension (jar for a normal project). coursier reports an
133
+ // inter-project dependency with no resolved artifact, so it would otherwise be ext-less and not
134
+ // match how depscan ingests it (ext=jar); stamping the real ext keeps a full, matching coordinate.
135
+ private def buildModuleExts(allRefs: Seq[ProjectRef], extracted: Extracted): Map[String, String] = {
136
+ allRefs.map { ref =>
137
+ val ext = extracted.getOpt(artifact.in(ref)).map(_.extension).filter(e => e != null && e.nonEmpty).getOrElse("jar")
138
+ gavKey(rootIdOf(extracted, ref)) -> ext
139
+ }.toMap
140
+ }
141
+
142
+ // Absolute source roots + compiled-output dirs per build module, keyed by GAV. --with-files only;
143
+ // absolute because reachability locates an internal module's code on THIS machine (no registry jar).
144
+ private def buildModuleDirs(
145
+ allRefs: Seq[ProjectRef],
146
+ extracted: Extracted
147
+ ): Map[String, (Seq[String], Seq[String])] = {
148
+ allRefs.map { ref =>
149
+ val mid = rootIdOf(extracted, ref)
150
+ val ver = if (mid.revision == null) "" else mid.revision
151
+ val sources = Seq(Compile, Test)
152
+ .flatMap(c => extracted.getOpt(sourceDirectories.in(ref).in(c)).getOrElse(Nil))
153
+ .map(_.getAbsolutePath).distinct.sorted
154
+ val targets = Seq(Compile, Test)
155
+ .flatMap(c => extracted.getOpt(classDirectory.in(ref).in(c)))
156
+ .map(_.getAbsolutePath).distinct.sorted
157
+ (mid.organization + ":" + mid.name + ":" + ver) -> ((sources, targets))
158
+ }.toMap
159
+ }
160
+
161
+ // On a hard resolve failure, record the failed modules and retry once with them excluded. Never throws.
162
+ private def runUpdateResilient(
163
+ taskName: String,
164
+ ref: ProjectRef,
165
+ extracted: Extracted,
166
+ state: State,
167
+ failures: mutable.LinkedHashSet[Failure]
168
+ ): Option[UpdateReport] = {
169
+ val key = TaskKey[UpdateReport](taskName)
170
+
171
+ def runOn(st: State): Either[Seq[ModuleID], UpdateReport] =
172
+ Project.runTask(key.in(ref).scopedKey, st) match {
173
+ case Some((_, sbt.Value(rep))) => Right(rep)
174
+ case Some((_, sbt.Inc(inc))) => Left(extractFailedModules(inc))
175
+ case _ => Left(Nil)
176
+ }
177
+
178
+ runOn(state) match {
179
+ case Right(rep) => Some(rep)
180
+ case Left(failed) =>
181
+ failed.foreach { m =>
182
+ failures += Failure(coordOf(m), "unresolved dependency", taskName)
183
+ }
184
+ if (failed.isEmpty) None
185
+ else
186
+ runOn(extracted.append(exclusionSettings(extracted, failed), state)) match {
187
+ case Right(rep) => Some(rep)
188
+ case Left(_) => None
189
+ }
190
+ }
191
+ }
192
+
193
+ // Reflection: ResolveException's package and the `failed` accessor differ between sbt 0.13 and 1.x.
194
+ private def extractFailedModules(inc: sbt.Incomplete): Seq[ModuleID] = {
195
+ val out = mutable.ListBuffer.empty[ModuleID]
196
+ def walk(i: sbt.Incomplete): Unit = {
197
+ i.directCause.foreach { ex =>
198
+ val cn = ex.getClass.getName
199
+ if (cn == "sbt.ResolveException" || cn == "sbt.librarymanagement.ResolveException") {
200
+ try {
201
+ val m = ex.getClass.getMethod("failed")
202
+ out ++= m.invoke(ex).asInstanceOf[Seq[ModuleID]]
203
+ } catch { case _: Throwable => }
204
+ }
205
+ }
206
+ i.causes.foreach(walk)
207
+ }
208
+ walk(inc)
209
+ out.toList.distinct
210
+ }
211
+
212
+ // ExclusionRule constructor shape differs between sbt 0.13 (`sbt.ExclusionRule`) and 1.x (`sbt.SbtExclusionRule`).
213
+ private def exclusionSettings(extracted: Extracted, failed: Seq[ModuleID]): Seq[Setting[_]] = {
214
+ val cls: Class[_] =
215
+ try Class.forName("sbt.SbtExclusionRule")
216
+ catch { case _: ClassNotFoundException => classOf[sbt.ExclusionRule] }
217
+
218
+ def rule(m: ModuleID): Any =
219
+ if (cls.getName.contains("SbtExclusionRule")) {
220
+ cls.getConstructors.find(_.getParameterCount == 5) match {
221
+ case Some(ctor) =>
222
+ ctor.newInstance(m.organization, m.name, "*", Seq(), sbt.CrossVersion.Disabled)
223
+ case None =>
224
+ throw new IllegalStateException("No suitable SbtExclusionRule constructor")
225
+ }
226
+ } else ExclusionRule(organization = m.organization, name = m.name)
227
+
228
+ def castSeq[T](xs: Seq[Any])(implicit ct: ClassTag[T]): Seq[T] = xs.map(_.asInstanceOf[T])
229
+
230
+ extracted.structure.allProjectRefs.map { ref =>
231
+ excludeDependencies.in(ref) := {
232
+ val original = excludeDependencies.in(ref).value
233
+ castSeq(original ++ failed.map(rule))(ClassTag(cls))
234
+ }
235
+ }
236
+ }
237
+
238
+ // Fold one project's UpdateReport into one tree per configuration (rootKey -> tree), keyed by coordinate.
239
+ private def foldReport(
240
+ report: UpdateReport,
241
+ ref: ProjectRef,
242
+ extracted: Extracted,
243
+ matcher: String => Boolean,
244
+ scannedConfigs: mutable.LinkedHashSet[String],
245
+ withFiles: Boolean,
246
+ populateScope: Option[Set[String]],
247
+ moduleExts: Map[String, String]
248
+ ): mutable.LinkedHashMap[String, RootTree] = {
249
+ val perRoot = mutable.LinkedHashMap.empty[String, RootTree]
250
+ val rootGav = gavKey(rootIdOf(extracted, ref))
251
+
252
+ def emittable(m: ModuleReport): Boolean = !m.evicted
253
+
254
+ def inScope(m: ModuleID): Boolean = populateScope match {
255
+ case None => true
256
+ case Some(gavs) => gavs.contains(gavKey(m))
257
+ }
258
+
259
+ report.configurations.foreach { cr =>
260
+ val cfg = confName(cr)
261
+ if (matcher(cfg)) {
262
+ scannedConfigs += cfg
263
+ val prod = isProdConf(cfg) && !isTestConf(cfg)
264
+ val nodes = mutable.LinkedHashMap.empty[String, Node]
265
+ // module GAV -> component ids (caller edges are module-level).
266
+ val midToIds = mutable.HashMap.empty[String, mutable.LinkedHashSet[String]]
267
+
268
+ cr.modules.foreach { m =>
269
+ if (emittable(m)) {
270
+ val ids = midToIds.getOrElseUpdate(gavKey(m.module), mutable.LinkedHashSet.empty[String])
271
+ variantsOf(m, moduleExts).foreach { case (coord, fileOpt) =>
272
+ val node = nodes.getOrElseUpdate(coord.id, new Node(coord))
273
+ ids += coord.id
274
+ if (withFiles && inScope(m.module)) fileOpt.foreach(f => node.targets += f.getAbsolutePath)
275
+ }
276
+ }
277
+ }
278
+
279
+ // Caller edges within this config: a root caller marks the child direct, any other becomes its parent.
280
+ cr.modules.foreach { m =>
281
+ if (emittable(m)) {
282
+ midToIds.get(gavKey(m.module)).foreach { childIds =>
283
+ m.callers.foreach { c =>
284
+ val callerKey = gavKey(c.caller)
285
+ if (callerKey == rootGav) childIds.foreach(cid => nodes(cid).direct = true)
286
+ else
287
+ midToIds.get(callerKey).foreach { parentIds =>
288
+ // Drop self-edges (test → main resolving to the same coordinate), matching gradle.
289
+ parentIds.foreach(pid => childIds.foreach(cid => if (pid != cid) nodes(pid).children += cid))
290
+ }
291
+ }
292
+ }
293
+ }
294
+ }
295
+
296
+ if (nodes.nonEmpty) perRoot(ref.project + "::" + cfg) = new RootTree(ref.project, cfg, prod, nodes)
297
+ }
298
+ }
299
+ perRoot
300
+ }
301
+
302
+ private def variantsOf(m: ModuleReport, moduleExts: Map[String, String]): Seq[(Coord, Option[File])] = {
303
+ val mid = m.module
304
+ // `mid.revision` is normally set for a resolved module, but normalize null (Coord.id calls
305
+ // `.nonEmpty`, which NPEs on null) to match the other call sites (gavKey, module records).
306
+ val ver = if (mid.revision == null) "" else mid.revision
307
+ val arts = m.artifacts
308
+ if (arts == null || arts.isEmpty)
309
+ // No resolved artifact (e.g. an inter-project dep): stamp the module's real ext if it's a build
310
+ // module (matches depscan's ext=jar), else leave ext-less.
311
+ Seq((Coord(mid.organization, mid.name, ver, moduleExts.getOrElse(gavKey(mid), ""), ""), None))
312
+ else
313
+ arts.toList.map {
314
+ case (art, file) =>
315
+ (Coord(mid.organization, mid.name, ver, extOf(art), classifierOf(art)), Option(file))
316
+ }
317
+ }
318
+
319
+ // ---- config selection ---------------------------------------------------
320
+
321
+ // With no includes the default is ALL configurations (captures build/tooling deps).
322
+ private def buildConfigMatcher(): String => Boolean = {
323
+ def parse(prop: String): List[java.util.regex.Pattern] =
324
+ sys.props.get(prop) match {
325
+ case Some(s) if s.trim.nonEmpty =>
326
+ s.split(",").map(_.trim).filter(_.nonEmpty).toList.map(globToRegex)
327
+ case _ => Nil
328
+ }
329
+ val includes = parse("socket.includeConfigs")
330
+ val excludes = parse("socket.excludeConfigs")
331
+ (name: String) => {
332
+ val included =
333
+ if (includes.isEmpty) true
334
+ else includes.exists(_.matcher(name).matches())
335
+ included && !excludes.exists(_.matcher(name).matches())
336
+ }
337
+ }
338
+
339
+ private def globToRegex(glob: String): java.util.regex.Pattern = {
340
+ val sb = new StringBuilder
341
+ glob.foreach {
342
+ case '*' => sb.append(".*")
343
+ case '?' => sb.append('.')
344
+ case c if "\\.^$|+()[]{}".indexOf(c.toInt) >= 0 =>
345
+ sb.append('\\').append(c)
346
+ case c => sb.append(c)
347
+ }
348
+ java.util.regex.Pattern.compile(sb.toString, java.util.regex.Pattern.CASE_INSENSITIVE)
349
+ }
350
+
351
+ // ConfigurationReport.configuration is a String on sbt 0.13, a ConfigRef on 1.x: read `.name` reflectively.
352
+ private def confName(cr: ConfigurationReport): String = {
353
+ val c: Any = cr.configuration
354
+ try c.getClass.getMethod("name").invoke(c).toString
355
+ catch { case _: Throwable => c.toString }
356
+ }
357
+
358
+ private def isTestConf(name: String): Boolean = name.toLowerCase.contains("test")
359
+
360
+ // Only feeds the informational `dev` flag, never gates analysis.
361
+ private def isProdConf(name: String): Boolean = {
362
+ val n = name.toLowerCase
363
+ n == "compile" || n == "runtime"
364
+ }
365
+
366
+ // ---- misc helpers --------------------------------------------------------
367
+
368
+ private def boolProp(name: String): Boolean =
369
+ java.lang.Boolean.parseBoolean(sys.props.getOrElse(name, "false"))
370
+
371
+ private def readPopulateScope(): Option[Set[String]] = {
372
+ sys.props.get("socket.populateFilesFor").map(_.trim).filter(_.nonEmpty) match {
373
+ case None => None
374
+ case Some(path) =>
375
+ val f = new java.io.File(path)
376
+ if (!f.exists) {
377
+ println("[socket-facts] WARN populateFilesFor file not found; recording files for all resolved artifacts")
378
+ None
379
+ } else {
380
+ val src = scala.io.Source.fromFile(f, "UTF-8")
381
+ try {
382
+ val set = src.getLines().map(_.trim).filter(_.nonEmpty).toSet
383
+ if (set.isEmpty) {
384
+ println("[socket-facts] WARN populateFilesFor file empty; recording files for all resolved artifacts")
385
+ None
386
+ } else {
387
+ println("[socket-facts] --with-files scoped to " + set.size + " SBOM artifact(s)")
388
+ Some(set)
389
+ }
390
+ } finally src.close()
391
+ }
392
+ }
393
+ }
394
+
395
+ private def gavKey(m: ModuleID): String = {
396
+ def s(v: String): String = if (v == null) "" else v
397
+ s(m.organization) + ":" + s(m.name) + ":" + s(m.revision)
398
+ }
399
+
400
+ private def coordOf(m: ModuleID): String = {
401
+ val rev = m.revision
402
+ m.organization + ":" + m.name + (if (rev != null && rev.nonEmpty) ":" + rev else "")
403
+ }
404
+
405
+ private def extOf(a: Artifact): String = {
406
+ val e = a.extension
407
+ if (e == null || e.isEmpty) "jar" else e
408
+ }
409
+
410
+ private def classifierOf(a: Artifact): String =
411
+ a.classifier.getOrElse("")
412
+
413
+ // Backslash-escape so a value can never break line/field framing (see records.ts unescape).
414
+ private def esc(v: String): String = {
415
+ if (v == null) ""
416
+ else v.replace("\\", "\\\\").replace("\t", "\\t").replace("\n", "\\n").replace("\r", "\\r")
417
+ }
418
+
419
+ private final case class Failure(coord: String, detail: String, config: String)
420
+
421
+ private final case class Coord(
422
+ org: String,
423
+ name: String,
424
+ version: String,
425
+ ext: String,
426
+ classifier: String
427
+ ) {
428
+ val id: String = Seq(org, name, ext, classifier, version).filter(_.nonEmpty).mkString(":")
429
+ }
430
+
431
+ private final class Node(val coord: Coord) {
432
+ val children = mutable.TreeSet.empty[String]
433
+ var direct = false
434
+ // External artifact's resolved jar(s); --with-files only.
435
+ val targets = mutable.TreeSet.empty[String]
436
+ }
437
+
438
+ private final class RootTree(
439
+ val projectKey: String,
440
+ val config: String,
441
+ val prod: Boolean,
442
+ val nodes: mutable.LinkedHashMap[String, Node]
443
+ )
444
+ }