socket 1.1.105 → 1.1.108

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.
@@ -0,0 +1,416 @@
1
+ package socket
2
+
3
+ import sbt._
4
+ import sbt.Keys._
5
+
6
+ import org.apache.ivy.Ivy
7
+ import org.apache.ivy.core.cache.DefaultRepositoryCacheManager
8
+ import org.apache.ivy.core.module.descriptor.{ Artifact, ModuleDescriptor }
9
+ import org.apache.ivy.core.module.id.ModuleRevisionId
10
+ import org.apache.ivy.core.report.ResolveReport
11
+ import org.apache.ivy.core.resolve.{ IvyNode, ResolveOptions }
12
+
13
+ import scala.collection.mutable
14
+
15
+ /**
16
+ * Socket facts plugin for sbt.
17
+ *
18
+ * Emits a single `.socket.facts.json` at the build root describing the
19
+ * resolved dependency graph of every project in the build, in the canonical
20
+ * SocketFacts schema (mirrors socket-facts.init.gradle on the gradle side):
21
+ *
22
+ * { "components": SF_Artifact[] }
23
+ *
24
+ * Each Maven component is
25
+ * { type: 'maven', namespace, name, version?, qualifiers? } &
26
+ * { id, direct?, dev?, dependencies? }
27
+ *
28
+ * The graph is read from Ivy resolution metadata only: `setDownload(false)`
29
+ * means no artifact jars are fetched, just the POM/ivy.xml needed to compute
30
+ * the transitive closure — we never consume bandwidth pulling jars.
31
+ *
32
+ * By default only the project's real dependency configurations are resolved
33
+ * (`compile`, `runtime`, `test`, `provided`, `optional`); the Scala
34
+ * compiler/scaladoc toolchain, sbt-native-packager configs (debian, docker,
35
+ * universal, ...), `-internal` duplicates and the sources/docs/pom artifact
36
+ * configs are skipped. They aren't the project's declared dependencies (the
37
+ * pom-path manifest omits them too) and resolving them dominates cost on large
38
+ * builds. Override the set with `-Dsocket.configs=comma,separated` glob
39
+ * patterns (case-sensitive; `*` = any sequence, `?` = single char). e.g.
40
+ * `compile,test` to keep only those scopes, `*Test*` to add custom test-
41
+ * like configs. One component is emitted per
42
+ * resolved module (org:name:version); a module's alternate artifacts
43
+ * (sources/javadoc classifier jars) are the same package, so they collapse
44
+ * into that single component rather than adding duplicates. `test`-scoped
45
+ * configs are tagged `dev`.
46
+ *
47
+ * Unresolved dependencies are fatal by default (non-zero exit) rather than
48
+ * silently dropped — the env is the user's own, set up to resolve their deps.
49
+ * `-Dsocket.ignoreUnresolved=true` downgrades that to a warning and emits the
50
+ * resolvable deps anyway.
51
+ *
52
+ * Intra-build project dependencies are omitted: sbt's `dependsOn` is a
53
+ * classpath dependency, not an Ivy one, so siblings rarely appear in a
54
+ * project's resolve, and a sibling referenced as an explicit library
55
+ * dependency is filtered out by coordinate. Each project's own external deps
56
+ * are aggregated, and resolution is per-project, so divergent per-module
57
+ * versions are all reported.
58
+ *
59
+ * Delivery: shipped as source and dropped into an isolated `-Dsbt.global.base`
60
+ * plugins dir, so it activates on any project without installation. It is
61
+ * compiled by the sbt meta-build, whose Scala is 2.10 for sbt 0.13 and 2.12
62
+ * for sbt 1.x — so this file must compile on both. Reaching into Ivy (stable
63
+ * across those sbt versions) keeps the code free of the version-specific sbt
64
+ * APIs that would otherwise need reflection, and lets us scope resolution to
65
+ * the configs we want (which sbt's own `update`/`updateFull` can't).
66
+ */
67
+ object SocketFactsPlugin extends AutoPlugin {
68
+ override def trigger = allRequirements
69
+
70
+ // The configurations resolved by default: the project's real dependency
71
+ // scopes. Override via `-Dsocket.configs`. Everything else (toolchain,
72
+ // packager, `-internal`, sources/docs/pom) is skipped — not declared deps,
73
+ // and costly to resolve.
74
+ private val DefaultConfs =
75
+ Set("compile", "optional", "provided", "runtime", "test")
76
+
77
+ // Must stay in sync with `DOT_SOCKET_DOT_FACTS_JSON` in src/constants.mts
78
+ // (TS side). Scala can't import the TS constant, so the two strings are
79
+ // intentionally duplicated; change them together.
80
+ private val SocketFactsFilename = ".socket.facts.json"
81
+
82
+ object autoImport {
83
+ val socketFacts =
84
+ taskKey[Unit]("Emit a Socket facts JSON for the whole build")
85
+ }
86
+ import autoImport._
87
+
88
+ override def projectSettings: Seq[Setting[_]] = Seq(
89
+ // Run once for the whole build; the task itself gathers every project via
90
+ // ScopeFilter, so we don't want sbt to also fan it out to aggregates.
91
+ // Note: `in` (not the newer `key / scope` slash syntax) is intentional —
92
+ // slash syntax doesn't exist in sbt 0.13, which we still support. The
93
+ // 1.5+ deprecation warning it triggers is harmless and only surfaces on a
94
+ // cold compile. Same goes for `baseDirectory in ThisBuild` below.
95
+ aggregate in socketFacts := false,
96
+ socketFacts := {
97
+ val log = streams.value.log
98
+ val modules = ivyModule.all(ScopeFilter(inAnyProject)).value
99
+ val buildRoot = (baseDirectory in ThisBuild).value
100
+
101
+ // First pass: every project's own coordinate (org:name:version), so
102
+ // intra-build deps are omitted even when referenced as explicit library
103
+ // deps. Keying on the full GAV (not just org:name) means an external dep
104
+ // that merely shares an org:name with a build project is still emitted.
105
+ val projectCoords = mutable.HashSet.empty[String]
106
+ modules.foreach { module =>
107
+ module.withModule(log) { (_, md, _) =>
108
+ projectCoords += gavKey(md.getModuleRevisionId)
109
+ }
110
+ }
111
+
112
+ val nodes = mutable.LinkedHashMap.empty[String, Node]
113
+ val unresolved = mutable.LinkedHashSet.empty[String]
114
+
115
+ // Second pass: resolve each project metadata-only and fold its graph in.
116
+ modules.foreach { module =>
117
+ module.withModule(log) { (ivy, md, _) =>
118
+ collectResolved(ivy, md, projectCoords, nodes, unresolved)
119
+ }
120
+ }
121
+
122
+ if (unresolved.nonEmpty) {
123
+ val ignore = boolProp("socket.ignoreUnresolved")
124
+ if (ignore) {
125
+ log.warn(
126
+ "Socket facts: skipping " + unresolved.size +
127
+ " unresolved dependency(ies) (ignore-unresolved):"
128
+ )
129
+ unresolved.toList.sorted.foreach(u => log.warn(" - " + u))
130
+ } else {
131
+ log.error("Socket facts: could not resolve these dependencies:")
132
+ unresolved.toList.sorted.foreach(u => log.error(" - " + u))
133
+ sys.error(
134
+ "Socket facts aborted: " + unresolved.size +
135
+ " unresolved dependency(ies). Pass --ignore-unresolved to skip " +
136
+ "them, or fix resolution (repositories, credentials, offline " +
137
+ "cache) and retry."
138
+ )
139
+ }
140
+ }
141
+
142
+ if (nodes.isEmpty) {
143
+ // println (not log.info) so the line reaches stdout without sbt's
144
+ // `[info]` prefix, matching what the CLI parses for.
145
+ println("[socket-facts] no resolvable dependencies in build, skipping")
146
+ } else {
147
+ val outDir = sys.props.get("socket.outputDirectory") match {
148
+ case Some(d) if d.nonEmpty => new File(d)
149
+ case _ => buildRoot
150
+ }
151
+ outDir.mkdirs()
152
+ val outName = sys.props.get("socket.outputFile") match {
153
+ case Some(f) if f.nonEmpty => f
154
+ case _ => SocketFactsFilename
155
+ }
156
+ val outFile = new File(outDir, outName)
157
+ IO.write(outFile, renderJson(nodes))
158
+ // println (not log.info) so the line reaches stdout without sbt's
159
+ // `[info]` prefix, matching what the CLI parses for.
160
+ println("Socket facts file written to: " + outFile.getAbsolutePath)
161
+ }
162
+ }
163
+ )
164
+
165
+ // Build a name-matcher closed over `-Dsocket.configs`. When set, patterns
166
+ // are matched as case-sensitive globs (`*` = any sequence, `?` = single
167
+ // char) so the same flag shape works as on `socket manifest gradle
168
+ // --facts`. With no wildcards a pattern is just an exact-name match,
169
+ // which preserves the prior comma-separated-names semantics. When unset
170
+ // we fall back to exact membership in DefaultConfs.
171
+ private def buildConfigMatcher(): String => Boolean =
172
+ sys.props.get("socket.configs") match {
173
+ case Some(s) if s.trim.nonEmpty =>
174
+ val patterns = s
175
+ .split(",")
176
+ .map(_.trim)
177
+ .filter(_.nonEmpty)
178
+ .map(globToRegex)
179
+ .toList
180
+ if (patterns.isEmpty) { (name: String) =>
181
+ DefaultConfs.contains(name)
182
+ } else { (name: String) =>
183
+ patterns.exists(_.matcher(name).matches())
184
+ }
185
+ case _ => (name: String) => DefaultConfs.contains(name)
186
+ }
187
+
188
+ private def globToRegex(glob: String): java.util.regex.Pattern = {
189
+ val sb = new StringBuilder
190
+ glob.foreach {
191
+ case '*' => sb.append(".*")
192
+ case '?' => sb.append('.')
193
+ case c if "\\.^$|+()[]{}".indexOf(c.toInt) >= 0 =>
194
+ sb.append('\\').append(c)
195
+ case c => sb.append(c)
196
+ }
197
+ java.util.regex.Pattern.compile(sb.toString)
198
+ }
199
+
200
+ private def boolProp(name: String): Boolean =
201
+ java.lang.Boolean.parseBoolean(sys.props.getOrElse(name, "false"))
202
+
203
+ // Resolve one project's module metadata-only and fold its graph into the
204
+ // shared, build-wide node map. Takes the stable Ivy types (not sbt's
205
+ // IvySbt#Module, which moved packages between 0.13 and 1.x).
206
+ private def collectResolved(
207
+ ivy: Ivy,
208
+ md: ModuleDescriptor,
209
+ projectCoords: scala.collection.Set[String],
210
+ nodes: mutable.LinkedHashMap[String, Node],
211
+ unresolved: mutable.LinkedHashSet[String]
212
+ ): Unit = {
213
+ val rootMrid = md.getModuleRevisionId
214
+ val matcher = buildConfigMatcher()
215
+ val confs = md.getConfigurationsNames.filter(matcher)
216
+ if (confs.nonEmpty) {
217
+ // Don't revalidate cached metadata over the network: with release
218
+ // coordinates the cached POM/ivy.xml never changes, so HEAD/GET-ing each
219
+ // cached module per resolve is pure overhead (~30% of warm-cache time).
220
+ // Missing metadata is still fetched (this is not cache-only), so cold
221
+ // caches still work — we just never re-check what we already have.
222
+ ivy.getSettings.getDefaultRepositoryCacheManager match {
223
+ case drcm: DefaultRepositoryCacheManager =>
224
+ drcm.setCheckmodified(false)
225
+ drcm.setUseOrigin(true)
226
+ drcm.setDefaultTTL(Long.MaxValue)
227
+ case _ =>
228
+ }
229
+ val options = new ResolveOptions()
230
+ options.setDownload(false)
231
+ options.setTransitive(true)
232
+ options.setConfs(confs)
233
+ // Skip Ivy's report rendering — it re-walks the graph and we don't use it.
234
+ options.setOutputReport(false)
235
+ val report: ResolveReport = ivy.resolve(md, options)
236
+
237
+ report.getUnresolvedDependencies.foreach { node =>
238
+ unresolved += node.getId.toString
239
+ }
240
+
241
+ // Pass 1: emit one node per resolved module, and remember which
242
+ // component id each Ivy module maps to (for wiring caller edges).
243
+ val mridToId = mutable.HashMap.empty[String, String]
244
+ val pass1 = report.getDependencies.iterator()
245
+ while (pass1.hasNext) {
246
+ val ivyNode = pass1.next().asInstanceOf[IvyNode]
247
+ if (isEmittable(ivyNode, projectCoords)) {
248
+ val mrid = ivyNode.getResolvedId
249
+ // prod = reached by any non-test config; a dep reached only via test
250
+ // configs is dev.
251
+ val prod =
252
+ ivyNode.getRootModuleConfigurations.exists(c => !isTestConf(c))
253
+ val coord = coordFor(ivyNode, mrid)
254
+ val node = nodes.getOrElseUpdate(coord.id, new Node(coord))
255
+ if (prod) {
256
+ node.prod = true
257
+ }
258
+ mridToId(mrid.toString) = coord.id
259
+ }
260
+ }
261
+
262
+ // Pass 2: wire caller edges. A caller that is the project root marks the
263
+ // node `direct`; any other caller becomes its parent.
264
+ val pass2 = report.getDependencies.iterator()
265
+ while (pass2.hasNext) {
266
+ val ivyNode = pass2.next().asInstanceOf[IvyNode]
267
+ if (isEmittable(ivyNode, projectCoords)) {
268
+ mridToId.get(ivyNode.getResolvedId.toString).foreach { childId =>
269
+ ivyNode.getAllCallers.foreach { caller =>
270
+ val callerMrid = caller.getModuleRevisionId
271
+ if (callerMrid == rootMrid) {
272
+ nodes(childId).direct = true
273
+ } else {
274
+ mridToId
275
+ .get(callerMrid.toString)
276
+ .foreach(parentId => nodes(parentId).children += childId)
277
+ }
278
+ }
279
+ }
280
+ }
281
+ }
282
+ }
283
+ }
284
+
285
+ // A configuration whose name mentions "test" (test, test-internal,
286
+ // IntegrationTest, ...) contributes dev dependencies. Name-based, mirroring
287
+ // the gradle script, so it also catches custom test-like configs.
288
+ private def isTestConf(name: String): Boolean =
289
+ name.toLowerCase.contains("test")
290
+
291
+ // A dependency node is emittable when it actually resolved to a real module
292
+ // that isn't a project in this build and isn't a conflict loser. Failed or
293
+ // unloaded nodes are skipped here (reading their metadata throws); they're
294
+ // reported separately via the resolve report's unresolved list.
295
+ private def isEmittable(
296
+ node: IvyNode,
297
+ projectCoords: scala.collection.Set[String]
298
+ ): Boolean = {
299
+ val mrid = node.getResolvedId
300
+ mrid != null &&
301
+ node.getModuleRevision != null &&
302
+ !node.hasProblem &&
303
+ !projectCoords.contains(gavKey(mrid)) &&
304
+ !node.isCompletelyEvicted
305
+ }
306
+
307
+ // Build-wide coordinate key (org:name:version) identifying a project in this
308
+ // build, so its own modules are omitted when they surface in another
309
+ // project's resolve. Full GAV (not just org:name) so a same-named external
310
+ // dependency at a different version is still emitted.
311
+ private def gavKey(mrid: ModuleRevisionId): String =
312
+ mrid.getOrganisation + ":" + mrid.getName + ":" + mrid.getRevision
313
+
314
+ private def coordFor(node: IvyNode, mrid: ModuleRevisionId): Coord =
315
+ Coord(mrid.getOrganisation, mrid.getName, mrid.getRevision, primaryExt(node))
316
+
317
+ // The packaging extension of the module's main (classifier-less) artifact.
318
+ // Reading artifact metadata never triggers a download. Defaults to jar,
319
+ // which is correct for the overwhelming majority of Maven dependencies.
320
+ private def primaryExt(node: IvyNode): String = {
321
+ val artifacts = node.getAllArtifacts
322
+ if (artifacts == null) {
323
+ "jar"
324
+ } else {
325
+ artifacts.find(a => classifierOf(a).isEmpty).map(extOf).getOrElse("jar")
326
+ }
327
+ }
328
+
329
+ private def extOf(a: Artifact): String = {
330
+ val e = a.getExt
331
+ if (e == null || e.isEmpty) "jar" else e
332
+ }
333
+
334
+ private def classifierOf(a: Artifact): String = {
335
+ val extra = a.getExtraAttributes
336
+ val raw =
337
+ if (extra.get("classifier") != null) extra.get("classifier")
338
+ else extra.get("m:classifier")
339
+ if (raw == null) "" else raw.toString
340
+ }
341
+
342
+ private def renderJson(nodes: mutable.LinkedHashMap[String, Node]): String = {
343
+ val sorted = nodes.values.toList.sortBy(_.coord.id)
344
+ val sb = new StringBuilder
345
+ sb.append("{\n \"components\": [\n")
346
+ sorted.zipWithIndex.foreach {
347
+ case (node, idx) =>
348
+ appendComponent(sb, node)
349
+ if (idx < sorted.size - 1) {
350
+ sb.append(",")
351
+ }
352
+ sb.append("\n")
353
+ }
354
+ sb.append(" ]\n}\n")
355
+ sb.toString
356
+ }
357
+
358
+ private def appendComponent(sb: StringBuilder, node: Node): Unit = {
359
+ val c = node.coord
360
+ val fields = mutable.ListBuffer.empty[String]
361
+ fields += "\"type\": \"maven\""
362
+ fields += "\"namespace\": " + jsonString(c.org)
363
+ fields += "\"name\": " + jsonString(c.name)
364
+ if (c.version.nonEmpty) {
365
+ fields += "\"version\": " + jsonString(c.version)
366
+ }
367
+ if (c.ext.nonEmpty) {
368
+ fields += "\"qualifiers\": { \"ext\": " + jsonString(c.ext) + " }"
369
+ }
370
+ fields += "\"id\": " + jsonString(c.id)
371
+ if (node.direct) {
372
+ fields += "\"direct\": true"
373
+ }
374
+ if (!node.prod) {
375
+ fields += "\"dev\": true"
376
+ }
377
+ if (node.children.nonEmpty) {
378
+ val depLines = node.children.toList.map(d => " " + jsonString(d))
379
+ fields += "\"dependencies\": [\n" + depLines.mkString(",\n") + "\n ]"
380
+ }
381
+ sb.append(" {\n ")
382
+ sb.append(fields.mkString(",\n "))
383
+ sb.append("\n }")
384
+ }
385
+
386
+ private def jsonString(s: String): String = {
387
+ val sb = new StringBuilder("\"")
388
+ s.foreach {
389
+ case '"' => sb.append("\\\"")
390
+ case '\\' => sb.append("\\\\")
391
+ case '\n' => sb.append("\\n")
392
+ case '\r' => sb.append("\\r")
393
+ case '\t' => sb.append("\\t")
394
+ case ch if ch < 0x20 => sb.append("\\u%04x".format(ch.toInt))
395
+ case ch => sb.append(ch)
396
+ }
397
+ sb.append("\"")
398
+ sb.toString
399
+ }
400
+
401
+ // A resolved Maven coordinate.
402
+ private final case class Coord(
403
+ org: String,
404
+ name: String,
405
+ version: String,
406
+ ext: String
407
+ ) {
408
+ val id: String = org + ":" + name + ":" + version
409
+ }
410
+
411
+ private final class Node(val coord: Coord) {
412
+ val children = mutable.TreeSet.empty[String]
413
+ var prod = false
414
+ var direct = false
415
+ }
416
+ }