@socketsecurity/cli-with-sentry 1.12.1 → 1.13.0

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 (66) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +5 -5
  3. package/dist/cli.js +2701 -1888
  4. package/dist/cli.js.map +1 -1
  5. package/dist/constants.js +4 -4
  6. package/dist/constants.js.map +1 -1
  7. package/dist/manifest-scripts/dotnet-tool/Microsoft.Build.Locator.dll +0 -0
  8. package/dist/manifest-scripts/dotnet-tool/socket-facts-dotnet.deps.json +41 -0
  9. package/dist/manifest-scripts/dotnet-tool/socket-facts-dotnet.dll +0 -0
  10. package/dist/manifest-scripts/dotnet-tool/socket-facts-dotnet.dll.config +623 -0
  11. package/dist/manifest-scripts/dotnet-tool/socket-facts-dotnet.runtimeconfig.json +15 -0
  12. package/dist/manifest-scripts/maven-extension/coana-maven-extension.jar +0 -0
  13. package/dist/manifest-scripts/socket-facts.init.gradle +17 -4
  14. package/dist/manifest-scripts/socket-facts.plugin.scala +219 -42
  15. package/dist/manifest-scripts/socket-workspaces.init.gradle +16 -1
  16. package/dist/manifest-scripts/socket-workspaces.plugin.scala +15 -4
  17. package/dist/tsconfig.dts.tsbuildinfo +1 -1
  18. package/dist/types/commands/fix/generated-socket-facts.d.mts +3 -1
  19. package/dist/types/commands/fix/generated-socket-facts.d.mts.map +1 -1
  20. package/dist/types/commands/manifest/cmd-manifest-dotnet.d.mts +9 -0
  21. package/dist/types/commands/manifest/cmd-manifest-dotnet.d.mts.map +1 -0
  22. package/dist/types/commands/manifest/cmd-manifest.d.mts.map +1 -1
  23. package/dist/types/commands/manifest/convert-dotnet-to-facts.d.mts +18 -0
  24. package/dist/types/commands/manifest/convert-dotnet-to-facts.d.mts.map +1 -0
  25. package/dist/types/commands/manifest/detect-manifest-actions.d.mts +4 -0
  26. package/dist/types/commands/manifest/detect-manifest-actions.d.mts.map +1 -1
  27. package/dist/types/commands/manifest/discover-manifest-roots.d.mts +12 -3
  28. package/dist/types/commands/manifest/discover-manifest-roots.d.mts.map +1 -1
  29. package/dist/types/commands/manifest/enumerate-workspaces.d.mts +3 -1
  30. package/dist/types/commands/manifest/enumerate-workspaces.d.mts.map +1 -1
  31. package/dist/types/commands/manifest/generate-recursive-manifests.d.mts +19 -2
  32. package/dist/types/commands/manifest/generate-recursive-manifests.d.mts.map +1 -1
  33. package/dist/types/commands/manifest/generate_auto_manifest.d.mts.map +1 -1
  34. package/dist/types/commands/manifest/run-manifest-facts.d.mts +3 -1
  35. package/dist/types/commands/manifest/run-manifest-facts.d.mts.map +1 -1
  36. package/dist/types/commands/manifest/scripts/assemble.d.mts.map +1 -1
  37. package/dist/types/commands/manifest/scripts/build-tool.d.mts +16 -4
  38. package/dist/types/commands/manifest/scripts/build-tool.d.mts.map +1 -1
  39. package/dist/types/commands/manifest/scripts/exclude-paths-glob.d.mts +1 -0
  40. package/dist/types/commands/manifest/scripts/exclude-paths-glob.d.mts.map +1 -1
  41. package/dist/types/commands/manifest/scripts/facts.d.mts +46 -2
  42. package/dist/types/commands/manifest/scripts/facts.d.mts.map +1 -1
  43. package/dist/types/commands/manifest/scripts/records.d.mts +43 -5
  44. package/dist/types/commands/manifest/scripts/records.d.mts.map +1 -1
  45. package/dist/types/commands/manifest/scripts/resolution-report-nuget.d.mts +7 -0
  46. package/dist/types/commands/manifest/scripts/resolution-report-nuget.d.mts.map +1 -0
  47. package/dist/types/commands/manifest/scripts/resolution-report-render.d.mts.map +1 -1
  48. package/dist/types/commands/manifest/scripts/run.d.mts +14 -4
  49. package/dist/types/commands/manifest/scripts/run.d.mts.map +1 -1
  50. package/dist/types/commands/manifest/scripts/sidecar.d.mts +9 -1
  51. package/dist/types/commands/manifest/scripts/sidecar.d.mts.map +1 -1
  52. package/dist/types/commands/manifest/setup-manifest-config.d.mts +7 -4
  53. package/dist/types/commands/manifest/setup-manifest-config.d.mts.map +1 -1
  54. package/dist/types/commands/manifest/setup-recursive-manifest-config.d.mts.map +1 -1
  55. package/dist/types/commands/scan/handle-create-new-scan.d.mts.map +1 -1
  56. package/dist/types/commands/scan/perform-reachability-analysis.d.mts.map +1 -1
  57. package/dist/types/commands/scan/reachability-flags.d.mts +1 -1
  58. package/dist/types/commands/scan/reachability-flags.d.mts.map +1 -1
  59. package/dist/types/constants.d.mts +1 -1
  60. package/dist/types/utils/get-or-insert.d.mts +2 -0
  61. package/dist/types/utils/get-or-insert.d.mts.map +1 -0
  62. package/dist/types/utils/socket-json.d.mts +17 -0
  63. package/dist/types/utils/socket-json.d.mts.map +1 -1
  64. package/dist/utils.js +17 -1
  65. package/dist/utils.js.map +1 -1
  66. package/package.json +2 -1
@@ -42,6 +42,21 @@ gradle.ext.socketIsExcluded = { String rel ->
42
42
  if (c.isEmpty()) { return false }
43
43
  gradle.ext.socketExcludeMatchers.call().any { p -> p.matcher(c).matches() }
44
44
  }
45
+ // `-Psocket.excludeBase` (the scan root; the root project's dir by default) anchors `--exclude-paths`:
46
+ // a project relocated outside the build root still matches, one outside the scan root never does.
47
+ gradle.ext.socketExcludeBaseCache = null
48
+ gradle.ext.socketIsExcludedDir = { java.io.File dir ->
49
+ if (gradle.ext.socketExcludeMatchers.call().isEmpty()) { return false }
50
+ if (gradle.ext.socketExcludeBaseCache == null) {
51
+ def base = gradle.socketProp.call(gradle.rootProject, 'socket.excludeBase')?.toString()
52
+ gradle.ext.socketExcludeBaseCache = (base ? new java.io.File(base) : gradle.rootProject.projectDir).canonicalFile.toPath()
53
+ }
54
+ def path = dir.canonicalFile.toPath()
55
+ // On another Windows drive than the scan root, so outside it.
56
+ if (path.root != gradle.ext.socketExcludeBaseCache.root) { return false }
57
+ def rel = gradle.ext.socketExcludeBaseCache.relativize(path).toString().replace(File.separator, '/')
58
+ !(rel == '..' || rel.startsWith('../')) && gradle.ext.socketIsExcluded.call(rel.isEmpty() ? '.' : rel)
59
+ }
45
60
 
46
61
  // Synchronized collections so --parallel-enabled builds don't race; lives on gradle.ext so every
47
62
  // collector and the root aggregator share one instance.
@@ -94,7 +109,7 @@ gradle.projectsEvaluated { g ->
94
109
  // A wholly excluded subproject emits no project record and its configs are never resolved (see the
95
110
  // collector). The project maps above stay populated so a KEPT project depending on it
96
111
  // still recognizes it as a first-party module, and its paths are kept so it is analysed with its code.
97
- def excluded = g.ext.socketIsExcluded.call(rel(p.projectDir))
112
+ def excluded = g.ext.socketIsExcludedDir.call(p.projectDir)
98
113
  // Guarded: projects without the java `sourceSets` convention (pure aggregators) contribute empty
99
114
  // lists rather than failing. Kotlin and Android code lives outside it (see below).
100
115
  // ALL source sets are emitted (not just main/test) so custom ones (integrationTest,
@@ -200,9 +215,7 @@ allprojects { project ->
200
215
  def state = gradle.socketFactsState
201
216
  // Skip resolving a wholly excluded subproject: `--exclude-paths` covering its dir means we don't
202
217
  // resolve for it at all (matches the project-record skip in projectsEvaluated).
203
- def excludeRootPath = project.rootProject.projectDir.toPath()
204
- def excludeRelDir = excludeRootPath.relativize(project.projectDir.toPath()).toString().replace(File.separator, '/')
205
- if (gradle.ext.socketIsExcluded.call(excludeRelDir.isEmpty() ? '.' : excludeRelDir)) {
218
+ if (gradle.ext.socketIsExcludedDir.call(project.projectDir)) {
206
219
  return
207
220
  }
208
221
  // Per-RESOLUTION-ROOT accumulators, LOCAL to this collector: one (subproject, configuration)
@@ -41,12 +41,23 @@ object SocketFactsPlugin extends AutoPlugin {
41
41
  // never resolved and emits no project record. Source-file-level exclusion is left to the analysis.
42
42
  val excludeMatchers = parseExcludeMatchers()
43
43
  val rootCanonPath = buildRoot.getCanonicalFile.toPath
44
- def relOf(f: File): String = {
45
- val r = rootCanonPath.relativize(f.getCanonicalFile.toPath).toString.replace(java.io.File.separator, "/")
44
+ def relTo(base: java.nio.file.Path)(f: File): String = {
45
+ val r = base.relativize(f.getCanonicalFile.toPath).toString.replace(java.io.File.separator, "/")
46
46
  if (r.isEmpty) "." else r
47
47
  }
48
- def isExcludedRef(ref: ProjectRef): Boolean =
49
- isExcludedPath(relOf(extracted.get(baseDirectory.in(ref))), excludeMatchers)
48
+ def relOf(f: File): String = relTo(rootCanonPath)(f)
49
+ // `-Dsocket.excludeBase`: the directory `--exclude-paths` is relative to (the scan root; the
50
+ // build root by default); a project outside it never matches.
51
+ val excludeBase = sys.props.get("socket.excludeBase").filter(_.nonEmpty)
52
+ .map(b => new File(b).getCanonicalFile.toPath).getOrElse(rootCanonPath)
53
+ def isExcludedRef(ref: ProjectRef): Boolean = excludeMatchers.nonEmpty && {
54
+ val dir = extracted.get(baseDirectory.in(ref))
55
+ // Another Windows drive than the scan root is outside it.
56
+ dir.getCanonicalFile.toPath.getRoot == excludeBase.getRoot && {
57
+ val rel = relTo(excludeBase)(dir)
58
+ rel != ".." && !rel.startsWith("../") && isExcludedPath(rel, excludeMatchers)
59
+ }
60
+ }
50
61
 
51
62
  // Prefer `updateFull` (coursier `update` returns empty callers); fall back to `update` (sbt 0.13).
52
63
  val hasUpdateFull =
@@ -175,11 +186,11 @@ object SocketFactsPlugin extends AutoPlugin {
175
186
 
176
187
  // ---- resolution ---------------------------------------------------------
177
188
 
178
- private def rootIdOf(extracted: Extracted, ref: ProjectRef): ModuleID = {
179
- val sv = extracted.get(scalaVersion.in(ref))
180
- val sbv = extracted.get(scalaBinaryVersion.in(ref))
181
- CrossVersion.apply(sv, sbv)(extracted.get(projectID.in(ref)))
182
- }
189
+ private def crossOf(extracted: Extracted, ref: ProjectRef): ModuleID => ModuleID =
190
+ CrossVersion.apply(extracted.get(scalaVersion.in(ref)), extracted.get(scalaBinaryVersion.in(ref)))
191
+
192
+ private def rootIdOf(extracted: Extracted, ref: ProjectRef): ModuleID =
193
+ crossOf(extracted, ref)(extracted.get(projectID.in(ref)))
183
194
 
184
195
  // GAV -> a build module's real artifact extension (jar for a normal project). coursier reports an
185
196
  // inter-project dependency with no resolved artifact, so it would otherwise be ext-less and not
@@ -335,6 +346,47 @@ object SocketFactsPlugin extends AutoPlugin {
335
346
  dbf.newDocumentBuilder().parse(f)
336
347
  }
337
348
 
349
+ // Child elements by local name; "*" for all.
350
+ private def pomKids(e: org.w3c.dom.Element, name: String): Seq[org.w3c.dom.Element] = {
351
+ val ns = e.getChildNodes
352
+ (0 until ns.getLength).map(ns.item).collect {
353
+ case c: org.w3c.dom.Element if name == "*" || c.getNodeName.split(':').last == name => c
354
+ }
355
+ }
356
+
357
+ private def pomText(e: org.w3c.dom.Element, name: String): Option[String] =
358
+ pomKids(e, name).headOption.map(_.getTextContent.trim).filter(_.nonEmpty)
359
+
360
+ private def pomGroupId(e: org.w3c.dom.Element): String =
361
+ pomText(e, "groupId").orElse(pomKids(e, "parent").headOption.flatMap(pomText(_, "groupId"))).getOrElse("")
362
+
363
+ private val PropertyRef = "\\$\\{([^}]+)\\}".r
364
+
365
+ // A POM after inheritance: its dependencies (groupId:artifactId -> declared scope, optional), its
366
+ // managed scopes, the BOMs it imports, and its properties.
367
+ private final case class PomModel(
368
+ deps: Map[String, (String, Boolean)],
369
+ managed: Map[String, String],
370
+ imports: Seq[(String, String, String)],
371
+ props: Map[String, String])
372
+
373
+ private object PomModel {
374
+ val empty = PomModel(Map.empty, Map.empty, Nil, Map.empty)
375
+ }
376
+
377
+ // Until nothing changes, as a property's value may refer to another.
378
+ private def interpolate(s: String, props: Map[String, String]): String = {
379
+ var cur = s
380
+ var prev = ""
381
+ var rounds = 0
382
+ while (cur != prev && rounds < 16) {
383
+ prev = cur
384
+ cur = PropertyRef.replaceAllIn(cur, m => java.util.regex.Matcher.quoteReplacement(props.getOrElse(m.group(1), m.matched)))
385
+ rounds += 1
386
+ }
387
+ cur
388
+ }
389
+
338
390
  // Ivy's ResolveOptions.getDefaultResolveId: `<organisation>-<module>` of the resolved descriptor.
339
391
  private def ivyResolveId(descriptor: File): Option[String] =
340
392
  try {
@@ -453,10 +505,21 @@ object SocketFactsPlugin extends AutoPlugin {
453
505
  !m.evicted && !moduleExts.contains(gavKey(m.module)) && !unresolved.contains(gavKey(m.module))
454
506
 
455
507
  def confsOf(r: ProjectRef): Seq[Configuration] = extracted.getOpt(ivyConfigurations.in(r)).getOrElse(Nil)
508
+ lazy val refByProject: Map[String, ProjectRef] = extracted.structure.allProjectRefs.map(r => r.project -> r).toMap
509
+ // Whether build project `r` declares module `c` in one of `confs`. A declaration without a
510
+ // configuration goes to Compile; the report names the module cross-versioned.
511
+ def declares(r: ProjectRef, c: ModuleID, confs: Set[String]): Boolean = {
512
+ val cross = crossOf(extracted, r)
513
+ extracted.getOpt(libraryDependencies.in(r)).getOrElse(Nil).exists { d =>
514
+ val masters = d.configurations.getOrElse("compile").split(';').flatMap(_.split("->").head.split(',')).map(_.trim)
515
+ d.organization == c.organization && cross(d).name == c.name && masters.exists(m => m == "*" || confs(m))
516
+ }
517
+ }
456
518
  val buildDeps = extracted.get(buildDependencies)
457
519
 
458
- // Classpaths.interSort, recording the (project -> dependency project) edges it follows.
459
- def interProjectEdges(conf: Configuration): mutable.LinkedHashSet[(ProjectRef, ProjectRef)] = {
520
+ // Classpaths.interSort, recording the (project -> dependency project) edges it follows and the
521
+ // configurations of each project it reaches.
522
+ def interProjectEdges(conf: Configuration): (mutable.LinkedHashSet[(ProjectRef, ProjectRef)], Map[String, Set[String]]) = {
460
523
  val masterConfs = confsOf(ref).map(_.name)
461
524
  val visited = mutable.HashSet.empty[(ProjectRef, String)]
462
525
  val edges = mutable.LinkedHashSet.empty[(ProjectRef, ProjectRef)]
@@ -477,7 +540,7 @@ object SocketFactsPlugin extends AutoPlugin {
477
540
  }
478
541
  }
479
542
  visit(ref, conf)
480
- edges
543
+ (edges, visited.toSeq.groupBy(_._1.project).map { case (p, vs) => p -> vs.map(_._2).toSet })
481
544
  }
482
545
 
483
546
  def buildModuleCoord(r: ProjectRef): Coord = {
@@ -499,6 +562,7 @@ object SocketFactsPlugin extends AutoPlugin {
499
562
  val nodes = mutable.LinkedHashMap.empty[String, Node]
500
563
  // org:name -> component ids (caller edges are module-level).
501
564
  val midToIds = mutable.HashMap.empty[String, mutable.LinkedHashSet[String]]
565
+ var reachedConfs = Map.empty[String, Set[String]]
502
566
 
503
567
  confsOf(ref).find(_.name == cfg).foreach { conf =>
504
568
  // Keyed by project id, as projects need not have distinct coordinates.
@@ -511,7 +575,9 @@ object SocketFactsPlugin extends AutoPlugin {
511
575
  })
512
576
  r.project
513
577
  }
514
- interProjectEdges(conf).foreach { case (p, d) =>
578
+ val (edges, reached) = interProjectEdges(conf)
579
+ reachedConfs = reached
580
+ edges.foreach { case (p, d) =>
515
581
  val child = buildModuleNode(d)
516
582
  if (p == ref) nodes(child).direct = true
517
583
  else nodes(buildModuleNode(p)).children += child
@@ -538,19 +604,29 @@ object SocketFactsPlugin extends AutoPlugin {
538
604
  }
539
605
 
540
606
  // Caller edges within this config: a root caller marks the child direct, any other becomes its parent.
541
- cr.modules.foreach { m =>
542
- if (emittable(m)) {
543
- midToIds.get(moduleKey(m.module)).foreach { childIds =>
544
- // lm-coursier gives a declared caller its declared revision, not the resolved one, so
545
- // callers match by org:name (a configuration resolves one version per module).
546
- m.callers.foreach { c =>
547
- val callerKey = moduleKey(c.caller)
548
- if (callerKey == rootKey) childIds.foreach(cid => nodes(cid).direct = true)
549
- else
550
- midToIds.get(callerKey).foreach { parentIds =>
551
- // Drop self-edges (test → main resolving to the same coordinate), matching gradle.
552
- parentIds.foreach(pid => childIds.foreach(cid => if (pid != cid) nodes(pid).children += cid))
553
- }
607
+ // Coursier resolves a Maven relocation's target in place of the relocating module, but reports
608
+ // neither the relocating module nor who declared it, so that caller is missing from the report.
609
+ // Its declarers are read off the build's own declarations and the POMs instead.
610
+ val candidates = cr.modules.filter(emittable)
611
+ // Drops self-edges (test → main resolving to the same coordinate), matching gradle.
612
+ def link(parentIds: Iterable[String], childIds: Iterable[String]): Unit =
613
+ parentIds.foreach(pid => childIds.foreach(cid => if (pid != cid) nodes(pid).children += cid))
614
+ def declaredBy(p: String, c: ModuleID): Boolean =
615
+ refByProject.get(p).exists(declares(_, c, reachedConfs.getOrElse(p, Set.empty)))
616
+ candidates.foreach { m =>
617
+ midToIds.get(moduleKey(m.module)).foreach { childIds =>
618
+ // lm-coursier gives a declared caller its declared revision, not the resolved one, so
619
+ // callers match by org:name (a configuration resolves one version per module).
620
+ m.callers.foreach { c =>
621
+ val callerKey = moduleKey(c.caller)
622
+ if (callerKey == rootKey) childIds.foreach(cid => nodes(cid).direct = true)
623
+ else midToIds.get(callerKey) match {
624
+ case Some(parentIds) => link(parentIds, childIds)
625
+ case None if !resolver.ivy && resolver.poms.relocates(c.caller, m) =>
626
+ if (declaredBy(ref.project, c.caller)) childIds.foreach(cid => nodes(cid).direct = true)
627
+ link(nodes.collect { case (nid, n) if n.project.nonEmpty && declaredBy(n.project, c.caller) => nid }, childIds)
628
+ resolver.poms.declaringModules(c.caller, candidates).foreach(p => midToIds.get(moduleKey(p)).foreach(link(_, childIds)))
629
+ case None =>
554
630
  }
555
631
  }
556
632
  }
@@ -581,7 +657,7 @@ object SocketFactsPlugin extends AutoPlugin {
581
657
 
582
658
  // Artifacts the resolver expected and did not fetch. Under Ivy they come from its resolution report
583
659
  // instead (see ivyUnfetchedOf).
584
- private final class Resolver(val ivy: Boolean, poms: PomPackaging) {
660
+ private final class Resolver(val ivy: Boolean, val poms: PomPackaging) {
585
661
  def missingOf(m: ModuleReport): List[Artifact] =
586
662
  if (ivy) Nil else Option(m.missingArtifacts).toList.flatten.filterNot(poms.isUnexpectedDefault)
587
663
  }
@@ -590,15 +666,126 @@ object SocketFactsPlugin extends AutoPlugin {
590
666
  // the POM declares a jar packaging. Only the module's own POM tells whether its artifact is a jar
591
667
  // (packaging defaults to jar, so a parent's never applies); an unreadable POM counts as one.
592
668
  private final class PomPackaging(csrCacheDir: Option[File]) {
593
- private val memo = mutable.HashMap.empty[String, Boolean]
669
+ private val pomMemo = mutable.HashMap.empty[String, Option[org.w3c.dom.Element]]
670
+
671
+ private def pom(url: String): Option[org.w3c.dom.Element] =
672
+ pomMemo.getOrElseUpdate(url, coursierLocalFiles(url, csrCacheDir).find(_.isFile).flatMap { f =>
673
+ try Some(parseXml(f).getDocumentElement)
674
+ catch { case _: Exception => None }
675
+ })
676
+
677
+ // Maven repository layout.
678
+ private def moduleDir(base: String, org: String, name: String): String =
679
+ base + org.replace('.', '/') + "/" + name + "/"
680
+
681
+ private def pomUrl(base: String, org: String, name: String, rev: String): String =
682
+ moduleDir(base, org, name) + rev + "/" + name + "-" + rev + ".pom"
683
+
684
+ // The repository root a module's artifacts were fetched from.
685
+ private def repoBase(m: ModuleReport): Option[String] = {
686
+ val mid = m.module
687
+ val marker = moduleDir("/", mid.organization, mid.name) + orEmpty(mid.revision) + "/"
688
+ val urls = Option(m.artifacts).toList.flatten.flatMap(_._1.url) ++
689
+ Option(m.missingArtifacts).toList.flatten.flatMap(_.url)
690
+ urls.map(_.toString).collectFirst { case u if u.contains(marker) => u.substring(0, u.indexOf(marker) + 1) }
691
+ }
692
+
693
+ private val modelMemo = mutable.HashMap.empty[String, PomModel]
694
+
695
+ // Maven model inheritance and interpolation of a POM, as far as its dependencies' scopes need.
696
+ private def model(base: String, org: String, name: String, rev: String, depth: Int): PomModel = {
697
+ val url = pomUrl(base, org, name, rev)
698
+ modelMemo.getOrElseUpdate(url, pom(url) match {
699
+ case None => PomModel.empty
700
+ case Some(e) =>
701
+ val parent = pomKids(e, "parent").headOption
702
+ val pm = parent match {
703
+ case Some(p) if depth < 16 =>
704
+ model(base, pomText(p, "groupId").getOrElse(""), pomText(p, "artifactId").getOrElse(""),
705
+ pomText(p, "version").getOrElse(""), depth + 1)
706
+ case _ => PomModel.empty
707
+ }
708
+ val groupId = pomGroupId(e)
709
+ val artifactId = pomText(e, "artifactId").getOrElse("")
710
+ val version = pomText(e, "version").orElse(parent.flatMap(pomText(_, "version"))).getOrElse("")
711
+ val own = pomKids(e, "properties").flatMap(ps => pomKids(ps, "*")).map(p => p.getNodeName.split(':').last -> p.getTextContent.trim)
712
+ val props = pm.props ++ own ++ Seq(
713
+ "project.groupId" -> groupId, "pom.groupId" -> groupId, "groupId" -> groupId,
714
+ "project.artifactId" -> artifactId, "pom.artifactId" -> artifactId, "artifactId" -> artifactId,
715
+ "project.version" -> version, "pom.version" -> version, "version" -> version
716
+ ) ++ parent.toSeq.flatMap { p =>
717
+ Seq("project.parent.groupId" -> pomText(p, "groupId").getOrElse(""),
718
+ "project.parent.version" -> pomText(p, "version").getOrElse(""))
719
+ }
720
+ def field(d: _root_.org.w3c.dom.Element, name: String) = interpolate(pomText(d, name).getOrElse(""), props)
721
+ def ga(d: _root_.org.w3c.dom.Element) = field(d, "groupId") + ":" + field(d, "artifactId")
722
+ val deps = pomKids(e, "dependencies").flatMap(ds => pomKids(ds, "dependency"))
723
+ val managed = pomKids(e, "dependencyManagement").flatMap(pomKids(_, "dependencies")).flatMap(pomKids(_, "dependency"))
724
+ val (imports, scoped) = managed.partition(d => field(d, "scope") == "import" && field(d, "type") == "pom")
725
+ PomModel(
726
+ pm.deps ++ deps.map(d => ga(d) -> ((field(d, "scope"), field(d, "optional") == "true"))),
727
+ pm.managed ++ scoped.map(d => ga(d) -> field(d, "scope")),
728
+ imports.map(d => (field(d, "groupId"), field(d, "artifactId"), field(d, "version"))) ++ pm.imports,
729
+ props)
730
+ })
731
+ }
732
+
733
+ private val managedMemo = mutable.HashMap.empty[String, Map[String, String]]
734
+
735
+ // Managed scopes after BOM imports: the POM's own and inherited entries win, then the first import.
736
+ private def managedScopes(base: String, org: String, name: String, rev: String, depth: Int): Map[String, String] =
737
+ managedMemo.getOrElseUpdate(pomUrl(base, org, name, rev), {
738
+ val m = model(base, org, name, rev, 0)
739
+ m.imports.reverse.foldLeft(Map.empty[String, String]) { case (acc, (g, a, v)) =>
740
+ if (depth < 16) acc ++ managedScopes(base, g, a, v, depth + 1) else acc
741
+ } ++ m.managed
742
+ })
743
+
744
+ // org:name of the modules each relocating module's cached POMs relocate to.
745
+ private val relocationsMemo = mutable.HashMap.empty[(String, String), Set[String]]
746
+
747
+ // Whether `relocating` is a POM relocated to `target`'s module. The report gives the relocating
748
+ // module its declared revision, which may be a range, so every cached version is tried; the
749
+ // target may have resolved to another version than the relocation names.
750
+ def relocates(relocating: ModuleID, target: ModuleReport): Boolean =
751
+ repoBase(target).exists { base =>
752
+ relocationsMemo.getOrElseUpdate((base, gavKey(relocating)), {
753
+ val dir = moduleDir(base, relocating.organization, relocating.name)
754
+ val versions = orEmpty(relocating.revision) +: coursierLocalFiles(dir, csrCacheDir)
755
+ .flatMap(d => Option(d.listFiles).toSeq.flatten).filter(_.isDirectory).map(_.getName)
756
+ versions.distinct.flatMap { v =>
757
+ pom(pomUrl(base, relocating.organization, relocating.name, v)).toSeq.flatMap { e =>
758
+ pomKids(e, "distributionManagement").flatMap(pomKids(_, "relocation")).headOption.map { r =>
759
+ pomText(r, "groupId").getOrElse(pomGroupId(e)) + ":" +
760
+ pomText(r, "artifactId").orElse(pomText(e, "artifactId")).getOrElse("")
761
+ }
762
+ }
763
+ }.toSet
764
+ })(moduleKey(target.module))
765
+ }
766
+
767
+ // The modules among `candidates` whose POM passes `declared` on to their dependents: compile or
768
+ // runtime scope, declared or managed, and not optional.
769
+ def declaringModules(declared: ModuleID, candidates: Seq[ModuleReport]): Seq[ModuleID] = {
770
+ val key = moduleKey(declared)
771
+ candidates.filter { p =>
772
+ repoBase(p).exists { base =>
773
+ val m = model(base, p.module.organization, p.module.name, orEmpty(p.module.revision), 0)
774
+ m.deps.get(key).exists { case (scope, optional) =>
775
+ val effective =
776
+ if (scope.nonEmpty) scope
777
+ else managedScopes(base, p.module.organization, p.module.name, orEmpty(p.module.revision), 0).getOrElse(key, "")
778
+ Set("", "compile", "runtime")(effective) && !optional
779
+ }
780
+ }
781
+ }.map(_.module)
782
+ }
594
783
 
595
784
  def isUnexpectedDefault(a: Artifact): Boolean = {
596
785
  val ext = extOf(a)
597
786
  a.classifier.isEmpty && ext == "jar" && a.url.map(_.toString).exists { url =>
598
- url.endsWith("." + ext) && memo.getOrElseUpdate(url, {
599
- val pomUrl = url.dropRight(ext.length) + "pom"
600
- coursierLocalFiles(pomUrl, csrCacheDir).find(_.isFile).flatMap(pomPackaging).exists(p => !JarPackagings(p))
601
- })
787
+ url.endsWith("." + ext) &&
788
+ pom(url.dropRight(ext.length) + "pom").exists(e => !JarPackagings(pomText(e, "packaging").getOrElse("jar")))
602
789
  }
603
790
  }
604
791
  }
@@ -632,16 +819,6 @@ object SocketFactsPlugin extends AutoPlugin {
632
819
  if (ch > 128 || " %$&+,:;=?@<>#".indexOf(ch) >= 0) "%" + "%02X".format(ch.toInt) else ch.toString
633
820
  }
634
821
 
635
- // Maven's default packaging is jar.
636
- private def pomPackaging(pom: File): Option[String] =
637
- try {
638
- val kids = parseXml(pom).getDocumentElement.getChildNodes
639
- val declared = (0 until kids.getLength).map(kids.item).collectFirst {
640
- case e: org.w3c.dom.Element if e.getNodeName.split(':').last == "packaging" => e.getTextContent.trim
641
- }
642
- Some(declared.filter(_.nonEmpty).getOrElse("jar"))
643
- } catch { case _: Exception => None }
644
-
645
822
  // ---- config selection ---------------------------------------------------
646
823
 
647
824
  // With no includes the default is ALL configurations (captures build/tooling deps).
@@ -39,6 +39,21 @@ gradle.ext.socketIsExcluded = { String rel ->
39
39
  if (c.isEmpty()) { return false }
40
40
  gradle.ext.socketExcludeMatchers.call().any { p -> p.matcher(c).matches() }
41
41
  }
42
+ // `-Psocket.excludeBase` (the scan root; the root project's dir by default) anchors `--exclude-paths`:
43
+ // a project relocated outside the build root still matches, one outside the scan root never does.
44
+ gradle.ext.socketExcludeBaseCache = null
45
+ gradle.ext.socketIsExcludedDir = { java.io.File dir ->
46
+ if (gradle.ext.socketExcludeMatchers.call().isEmpty()) { return false }
47
+ if (gradle.ext.socketExcludeBaseCache == null) {
48
+ def base = gradle.socketProp.call(gradle.rootProject, 'socket.excludeBase')?.toString()
49
+ gradle.ext.socketExcludeBaseCache = (base ? new java.io.File(base) : gradle.rootProject.projectDir).canonicalFile.toPath()
50
+ }
51
+ def path = dir.canonicalFile.toPath()
52
+ // On another Windows drive than the scan root, so outside it.
53
+ if (path.root != gradle.ext.socketExcludeBaseCache.root) { return false }
54
+ def rel = gradle.ext.socketExcludeBaseCache.relativize(path).toString().replace(File.separator, '/')
55
+ !(rel == '..' || rel.startsWith('../')) && gradle.ext.socketIsExcluded.call(rel.isEmpty() ? '.' : rel)
56
+ }
42
57
 
43
58
  gradle.ext.socketWorkspacesInfo = Collections.synchronizedList([])
44
59
 
@@ -52,7 +67,7 @@ gradle.projectsEvaluated { g ->
52
67
  r.isEmpty() ? '.' : r
53
68
  }
54
69
  g.rootProject.allprojects.each { p ->
55
- if (g.ext.socketIsExcluded.call(rel(p.projectDir))) {
70
+ if (g.ext.socketIsExcludedDir.call(p.projectDir)) {
56
71
  return
57
72
  }
58
73
  def buildFiles = []
@@ -32,12 +32,23 @@ object SocketWorkspacesPlugin extends AutoPlugin {
32
32
  // emits no project record. Mirrors socket-facts.plugin.scala / the gradle / maven producers.
33
33
  val excludeMatchers = parseExcludeMatchers()
34
34
  val rootCanonPath = buildRoot.getCanonicalFile.toPath
35
- def relOf(f: File): String = {
36
- val r = rootCanonPath.relativize(f.getCanonicalFile.toPath).toString.replace(java.io.File.separator, "/")
35
+ def relTo(base: java.nio.file.Path)(f: File): String = {
36
+ val r = base.relativize(f.getCanonicalFile.toPath).toString.replace(java.io.File.separator, "/")
37
37
  if (r.isEmpty) "." else r
38
38
  }
39
- def isExcludedRef(ref: ProjectRef): Boolean =
40
- isExcludedPath(relOf(extracted.get(baseDirectory.in(ref))), excludeMatchers)
39
+ def relOf(f: File): String = relTo(rootCanonPath)(f)
40
+ // `-Dsocket.excludeBase`: the directory `--exclude-paths` is relative to (the scan root; the
41
+ // build root by default); a project outside it never matches.
42
+ val excludeBase = sys.props.get("socket.excludeBase").filter(_.nonEmpty)
43
+ .map(b => new File(b).getCanonicalFile.toPath).getOrElse(rootCanonPath)
44
+ def isExcludedRef(ref: ProjectRef): Boolean = excludeMatchers.nonEmpty && {
45
+ val dir = extracted.get(baseDirectory.in(ref))
46
+ // Another Windows drive than the scan root is outside it.
47
+ dir.getCanonicalFile.toPath.getRoot == excludeBase.getRoot && {
48
+ val rel = relTo(excludeBase)(dir)
49
+ rel != ".." && !rel.startsWith("../") && isExcludedPath(rel, excludeMatchers)
50
+ }
51
+ }
41
52
 
42
53
  def rootIdOf(ref: ProjectRef): ModuleID = {
43
54
  val sv = extracted.get(scalaVersion.in(ref))