@socketsecurity/cli-with-sentry 1.1.130 → 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 +12 -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,479 @@
1
+ // Invoke via:
2
+ // ./gradlew --init-script socket-facts.init.gradle socketFacts
3
+
4
+ import java.util.Collections
5
+
6
+ // Emits a flat line-protocol RECORDS file (NOT the final .socket.facts.json, and NOT to stdout). The
7
+ // TS assembler (utils/src/manifest-scripts/assemble.ts) reads the records and owns all SBOM
8
+ // construction — graph merge, content-addressed ids, contentHash. This script only RESOLVES and emits
9
+ // raw facts. See records.ts for the record grammar.
10
+
11
+ // `Project.findProperty` only exists since Gradle 2.13; fall back to hasProperty/property for older Gradle.
12
+ gradle.ext.socketProp = { proj, name -> proj.hasProperty(name) ? proj.property(name) : null }
13
+
14
+ // Synchronized collections so --parallel-enabled builds don't race; lives on gradle.ext so every
15
+ // collector and the root aggregator share one instance.
16
+ gradle.ext.socketFactsState = [
17
+ nodes : Collections.synchronizedMap([:]),
18
+ directIds : Collections.synchronizedSet([] as Set),
19
+ // `detail` is the build tool's failure message verbatim; Coana classifies the failure KIND from it.
20
+ failures : Collections.synchronizedList([]),
21
+ scannedConfigs : Collections.synchronizedSet([] as Set),
22
+ projectKeys : Collections.synchronizedSet([] as Set),
23
+ // Only populated with `-Psocket.withFiles=true`: reading `artifact.file` downloads the artifact.
24
+ paths : Collections.synchronizedMap([:]),
25
+ perSub : Collections.synchronizedMap([:]),
26
+ projectsInfo : Collections.synchronizedList([]),
27
+ // "group:name" -> the module's real artifact extension (e.g. jar), so an intra-project dep that
28
+ // resolves without selecting a published artifact still gets its true coordinate, not ext-less.
29
+ projectArtifactExt : Collections.synchronizedMap([:]),
30
+ ]
31
+
32
+ // Capture every project's (group:name) before collectors run so they can filter intra-project
33
+ // deps without an ordering dependency on other subprojects.
34
+ gradle.projectsEvaluated { g ->
35
+ def rootPath = g.rootProject.projectDir.toPath()
36
+ // '/'-normalized so values are stable across OSes and portable to another machine.
37
+ def rel = { java.io.File f ->
38
+ def r = rootPath.relativize(f.toPath()).toString().replace(File.separator, '/')
39
+ r.isEmpty() ? '.' : r
40
+ }
41
+ g.rootProject.allprojects.each { p ->
42
+ g.socketFactsState.projectKeys.add("${p.group ?: ''}:${p.name}")
43
+ // The module's real artifact extension (jar for java; '' for a project that builds no archive,
44
+ // e.g. a pure aggregator). Used to stamp intra-project deps that resolve without an artifact.
45
+ def artExt = ''
46
+ try {
47
+ def jt = p.tasks.findByName('jar')
48
+ if (jt != null) {
49
+ artExt = 'jar'
50
+ try { def e = jt.archiveExtension.getOrNull(); if (e) artExt = e } catch (Exception ig1) {
51
+ try { def e = jt.extension; if (e) artExt = e } catch (Exception ig2) {}
52
+ }
53
+ }
54
+ } catch (Exception ignore) {}
55
+ g.socketFactsState.projectArtifactExt["${p.group ?: ''}:${p.name}".toString()] = artExt
56
+ // Guarded: projects without the java `sourceSets` convention (pure aggregators, AGP-only
57
+ // Android modules) contribute empty lists rather than failing.
58
+ // `classesDirs` (Gradle 4+) falls back to the legacy single `classesDir` on older Gradle.
59
+ def sources = [] as Set
60
+ def targets = [] as Set
61
+ try {
62
+ if (p.hasProperty('sourceSets')) {
63
+ ['main', 'test'].each { sn ->
64
+ def ss = p.sourceSets.findByName(sn)
65
+ if (ss != null) {
66
+ ss.allSource.srcDirs.each { d -> sources << d.absolutePath }
67
+ try {
68
+ ss.output.classesDirs.each { d -> targets << d.absolutePath }
69
+ } catch (Exception e1) {
70
+ try { targets << ss.output.classesDir.absolutePath } catch (Exception ignore) {}
71
+ }
72
+ }
73
+ }
74
+ }
75
+ } catch (Exception ignore) {}
76
+ g.socketFactsState.projectsInfo.add([
77
+ path : p.path,
78
+ group : (p.group ?: '').toString(),
79
+ name : p.name,
80
+ version: (p.version ?: '').toString(),
81
+ dir : rel(p.projectDir),
82
+ sources: (sources as List).sort(),
83
+ targets: (targets as List).sort(),
84
+ ])
85
+ }
86
+ }
87
+
88
+ allprojects { project ->
89
+ def collectTask = project.tasks.create('socketFactsCollect') {
90
+ description = "Resolves ${project.path}'s configurations into the build-wide Socket facts accumulator"
91
+ // Dependency resolution depends on state Gradle's up-to-date tracking can't represent reliably.
92
+ outputs.upToDateWhen { false }
93
+
94
+ doLast {
95
+ def state = gradle.socketFactsState
96
+ // Per-RESOLUTION-ROOT accumulators, LOCAL to this collector: one (subproject, configuration)
97
+ // classpath resolved into its own coordinate-keyed tree. Reassigned per config below (the
98
+ // visit/upsertNode closures capture them); `failures`/`scannedConfigs` stay shared.
99
+ def nodes = [:]
100
+ def directIds = [] as Set
101
+ def failures = state.failures
102
+ def scannedConfigs = state.scannedConfigs
103
+ def projectKeys = state.projectKeys
104
+ def projectArtifactExt = state.projectArtifactExt
105
+ def paths = [:]
106
+ def withFiles = gradle.socketProp.call(project, 'socket.withFiles')?.toString()?.toLowerCase() == 'true'
107
+ // Optional `-Psocket.populateFilesFor=<file>` (newline-delimited GAVs): scope `--with-files`
108
+ // materialization to those GAVs. This run walks EVERY resolvable config (not told the SBOM's
109
+ // include/exclude), so without a scope it would force-download the build's entire dep universe.
110
+ // Matched at GAV level to tolerate untrustworthy-ext SBOMs (lockfile / version catalog hardcode
111
+ // ext=jar). Absent/empty/missing file -> materialize all. Parsed once under the state monitor.
112
+ def populateFilesFor = gradle.socketProp.call(project, 'socket.populateFilesFor')?.toString()
113
+ def populateGavs = null
114
+ if (withFiles && populateFilesFor != null && !populateFilesFor.trim().isEmpty()) {
115
+ synchronized (state) {
116
+ if (!state.containsKey('populateGavs')) {
117
+ def f = new File(populateFilesFor.trim())
118
+ if (f.exists()) {
119
+ def set = [] as Set
120
+ f.eachLine { line -> def t = line.trim(); if (!t.isEmpty()) { set << t } }
121
+ if (set.isEmpty()) {
122
+ // Empty file is more likely a wiring slip than a deliberate "fetch nothing"; materialize all.
123
+ state.populateGavs = null
124
+ println "[socket-facts] WARN populateFilesFor file empty; materializing files for all resolved artifacts"
125
+ } else {
126
+ state.populateGavs = set
127
+ println "[socket-facts] --with-files scoped to ${set.size()} SBOM artifact(s)"
128
+ }
129
+ } else {
130
+ // Missing file is more likely a wiring bug than an intentional empty scope; materialize all.
131
+ state.populateGavs = null
132
+ println "[socket-facts] WARN populateFilesFor file not found; materializing files for all resolved artifacts"
133
+ }
134
+ }
135
+ }
136
+ populateGavs = state.populateGavs
137
+ }
138
+
139
+ // Full Maven coordinate (absent segments dropped) — each ext/classifier variant is its own
140
+ // component; the id is the coordinate key reachability joins on (see mavenCoordinateKey).
141
+ def coordId = { coord ->
142
+ [coord.groupId, coord.artifactId, coord.ext, coord.classifier, coord.version].findAll { it }.join(':')
143
+ }
144
+
145
+ def isIntraProject = { String group, String name ->
146
+ projectKeys.contains("${group ?: ''}:${name}")
147
+ }
148
+
149
+ // A first-party module dep can resolve with no published artifact (classes-dir variant, or
150
+ // variant-ambiguous moduleArtifacts), leaving it ext-less. Stamp the module's real artifact
151
+ // extension so it keeps a full, stable coordinate instead of an ext-less one.
152
+ def effExt = { String group, String name, String ext ->
153
+ if ((ext == null || ext.isEmpty()) && isIntraProject(group, name)) {
154
+ projectArtifactExt["${group ?: ''}:${name}".toString()] ?: ''
155
+ } else {
156
+ ext ?: ''
157
+ }
158
+ }
159
+
160
+ // A node is created once; its prod flag accumulates (OR) across the configs that reach it.
161
+ def upsertNode = { Map coord, boolean isProd ->
162
+ def id = coordId(coord)
163
+ synchronized (nodes) {
164
+ def node = nodes[id]
165
+ if (node == null) {
166
+ node = [coord: coord, children: [] as Set, prod: false]
167
+ nodes[id] = node
168
+ }
169
+ if (isProd) {
170
+ node.prod = true
171
+ }
172
+ }
173
+ id
174
+ }
175
+
176
+ // Walk a resolved dependency and its transitive closure. We never touch `artifact.file` here —
177
+ // that forces Gradle to download the file (catastrophic on builds declaring distribution
178
+ // archives as deps); `artifact.extension`/`artifact.classifier` read already-resolved metadata.
179
+ // Intra-project deps (project(':lib')) ARE emitted and recursed, so an external the consumer
180
+ // pins/forces to a different version THROUGH the edge is captured (firstLevelModuleDependencies
181
+ // lists only declared deps, so a project dep's transitives are reachable only via the edge).
182
+ def visit
183
+ visit = { dep, boolean isProd, Map cache ->
184
+ if (cache.containsKey(dep)) {
185
+ return cache[dep]
186
+ }
187
+ // Pre-populate the cache to break cycles before we recurse.
188
+ def producedIds = [] as Set
189
+ cache[dep] = producedIds
190
+
191
+ // `moduleArtifacts` forces ARTIFACT-level variant selection, which can throw on a
192
+ // variant-ambiguous dep even when the dependency GRAPH resolves fine. Guard per-dep: on
193
+ // failure emit at GAV level and keep walking so one ambiguous artifact can't sink the config.
194
+ def artifacts
195
+ try {
196
+ artifacts = dep.moduleArtifacts
197
+ } catch (Exception e) {
198
+ artifacts = [] as Set
199
+ }
200
+ if (artifacts.isEmpty()) {
201
+ def ext = effExt(dep.moduleGroup, dep.moduleName, '')
202
+ // Skip a no-artifact first-party module (aggregator / build root): it builds no archive, so
203
+ // it's fully described by `projects` and is never a real artifact dependency.
204
+ if (!(ext.isEmpty() && isIntraProject(dep.moduleGroup, dep.moduleName))) {
205
+ producedIds << upsertNode([
206
+ groupId : dep.moduleGroup ?: '',
207
+ artifactId: dep.moduleName,
208
+ version : dep.moduleVersion ?: '',
209
+ classifier: '',
210
+ ext : ext,
211
+ ], isProd)
212
+ }
213
+ } else {
214
+ // Build the GAV scope key only when a scope is set: the no-scope path never reads it and
215
+ // this is the hottest line under --parallel.
216
+ def shouldMaterialize
217
+ if (!withFiles) {
218
+ shouldMaterialize = false
219
+ } else if (populateGavs == null) {
220
+ shouldMaterialize = true
221
+ } else {
222
+ def gav = "${dep.moduleGroup ?: ''}:${dep.moduleName}:${dep.moduleVersion ?: ''}".toString()
223
+ shouldMaterialize = populateGavs.contains(gav)
224
+ }
225
+ artifacts.each { a ->
226
+ // Directory variants (java-classes-directory etc.) carry no extension; for a first-party
227
+ // module fall back to its real artifact ext (never artifact.type, a Gradle variant attr).
228
+ def ext = effExt(dep.moduleGroup, dep.moduleName, a.extension)
229
+ // Skip a no-artifact first-party module (see the empty-artifacts branch above).
230
+ if (ext.isEmpty() && isIntraProject(dep.moduleGroup, dep.moduleName)) return
231
+ def aid = upsertNode([
232
+ groupId : dep.moduleGroup ?: '',
233
+ artifactId: dep.moduleName,
234
+ version : dep.moduleVersion ?: '',
235
+ classifier: a.classifier ?: '',
236
+ ext : ext,
237
+ ], isProd)
238
+ producedIds << aid
239
+ // `a.file` downloads the artifact if not already cached, so scoping avoids fetching
240
+ // artifacts the SBOM doesn't reference. Per-artifact try/catch: a single download can
241
+ // fail without the whole config being unresolvable.
242
+ if (shouldMaterialize) {
243
+ try {
244
+ def path = a.file.absolutePath
245
+ synchronized (paths) {
246
+ def set = paths[aid]
247
+ if (set == null) { set = [] as Set; paths[aid] = set }
248
+ set << path
249
+ }
250
+ } catch (Exception e) {
251
+ println "[socket-facts] could not materialize ${aid}: ${e.message?.readLines()?.first()}"
252
+ }
253
+ }
254
+ }
255
+ }
256
+
257
+ def childIds = [] as Set
258
+ dep.children.each { child ->
259
+ childIds.addAll(visit(child, isProd, cache))
260
+ }
261
+ // A skipped no-artifact first-party module produced no node, so bubble its resolved children
262
+ // up to the parent (return childIds) — otherwise the module's transitives orphan (attached to
263
+ // nothing, not bubbled). The module itself is described by `projects`.
264
+ if (producedIds.isEmpty()) {
265
+ cache[dep] = childIds
266
+ return childIds
267
+ }
268
+ synchronized (nodes) {
269
+ producedIds.each { pid ->
270
+ // Drop self-edges: a project that depends on its own output (test → main) can resolve
271
+ // to the same coordinate as its root, which would otherwise emit a node listing itself.
272
+ nodes[pid].children.addAll(childIds.findAll { it != pid })
273
+ }
274
+ }
275
+ producedIds
276
+ }
277
+
278
+ def isTestConfig = { String name -> name.toLowerCase().contains('test') }
279
+ // Matches real compile/runtime classpaths (modern `*Classpath`, legacy `compile`/`runtime`),
280
+ // NOT a loose substring that would wrongly promote war-plugin `providedCompile`/`providedRuntime`
281
+ // (provided deps are dev). Sets the informational `dev` flag only — never gates analysis.
282
+ def isClasspathConfig = { String name ->
283
+ def n = name.toLowerCase()
284
+ n.contains('classpath') || n == 'compile' || n == 'runtime'
285
+ }
286
+
287
+ // Config-name glob (`*`/`?` only) to a case-INSENSITIVE regex — Gradle config names are
288
+ // camelCase, so a case-sensitive `*Classpath` would miss the base `compileClasspath`.
289
+ def globToRegex = { String glob ->
290
+ def sb = new StringBuilder()
291
+ glob.each { String ch ->
292
+ switch (ch) {
293
+ case '*': sb << '.*'; break
294
+ case '?': sb << '.'; break
295
+ case '.': case '\\': case '^': case '$': case '|':
296
+ case '+': case '(': case ')':
297
+ case '[': case ']': case '{': case '}':
298
+ sb << '\\' << ch; break
299
+ default: sb << ch
300
+ }
301
+ }
302
+ java.util.regex.Pattern.compile(sb.toString(), java.util.regex.Pattern.CASE_INSENSITIVE)
303
+ }
304
+
305
+ // `-Psocket.includeConfigs`/`-Psocket.excludeConfigs`: comma-separated config-name globs. A
306
+ // config is walked when it matches some include (or there are none) AND matches no exclude.
307
+ def parsePatterns = { String s ->
308
+ def out = []
309
+ if (s != null && !s.trim().isEmpty()) {
310
+ s.split(',').each { raw ->
311
+ def p = raw.trim()
312
+ if (!p.isEmpty()) out << globToRegex(p)
313
+ }
314
+ }
315
+ out
316
+ }
317
+ def includePatterns = parsePatterns(gradle.socketProp.call(project, 'socket.includeConfigs')?.toString())
318
+ def excludePatterns = parsePatterns(gradle.socketProp.call(project, 'socket.excludeConfigs')?.toString())
319
+
320
+ // Don't filter by canBeConsumed: Gradle defaults both role flags to true, so a plain resolvable
321
+ // config is also consumable, and excluding consumable configs would drop legitimate custom ones.
322
+ // `isCanBeResolved` only exists since Gradle 3.3; older Gradle has no split (all resolvable).
323
+ def isResolvable = { c -> c.metaClass.respondsTo(c, 'isCanBeResolved') ? c.canBeResolved : true }
324
+ def targetConfigs = project.configurations.findAll {
325
+ if (!isResolvable(it)) return false
326
+ def name = it.name
327
+ if (excludePatterns.any { p -> p.matcher(name).matches() }) return false
328
+ if (!includePatterns.isEmpty() && !includePatterns.any { p -> p.matcher(name).matches() }) return false
329
+ return true
330
+ }
331
+
332
+ // Deepest cause's message, in FULL — Coana classifies/templates it, so we don't categorize here.
333
+ def rawDetail = { problem ->
334
+ def deepest = null
335
+ def t = problem
336
+ int guard = 0
337
+ while (t != null && guard++ < 12) { if (t.message) { deepest = t.message }; t = t.cause }
338
+ return (deepest ?: 'unknown resolution failure').toString().trim()
339
+ }
340
+
341
+ targetConfigs.each { cfg ->
342
+ // Recorded even if it resolves nothing or throws — Coana surfaces the full scanned set.
343
+ scannedConfigs.add(cfg.name)
344
+ // Informational only (OR-accumulated across configs); never gates analysis, so this name
345
+ // heuristic can only mis-bucket a dep in prod/dev views, never drop it.
346
+ def isProd = isClasspathConfig(cfg.name) && !isTestConfig(cfg.name)
347
+ // Fresh tree for THIS resolution root — captured by visit/upsertNode, so no union with siblings.
348
+ nodes = [:]
349
+ directIds = [] as Set
350
+ paths = [:]
351
+ // Per-config try/catch: AGP-style configs can fail variant ambiguity from an init-script
352
+ // context lacking the consumer attributes AGP sets internally.
353
+ try {
354
+ def lenient = cfg.resolvedConfiguration.lenientConfiguration
355
+ def cache = [:]
356
+ // No-arg getter only since Gradle 3.3; older Gradle has just the Spec-taking overload.
357
+ def firstLevel
358
+ try {
359
+ firstLevel = lenient.firstLevelModuleDependencies
360
+ } catch (MissingPropertyException e) {
361
+ firstLevel = lenient.getFirstLevelModuleDependencies({ true } as org.gradle.api.specs.Spec)
362
+ }
363
+ firstLevel.each { dep ->
364
+ directIds.addAll(visit(dep, isProd, cache))
365
+ }
366
+ // Unresolved deps drive the root aggregator's abort/warn decision but are NOT emitted as
367
+ // nodes — their selector-only coordinates would surface as half-formed phantom artifacts.
368
+ lenient.unresolvedModuleDependencies.each { dep ->
369
+ def sel = dep.selector
370
+ if (isIntraProject(sel.group, sel.name)) {
371
+ return
372
+ }
373
+ failures << [
374
+ coord : "${sel.group}:${sel.name}${sel.version ? ':' + sel.version : ''}".toString(),
375
+ detail: rawDetail(dep.problem),
376
+ config: cfg.name,
377
+ ]
378
+ }
379
+ } catch (Exception e) {
380
+ println "[socket-facts] skipping ${project.path}:${cfg.name}: ${e.message?.readLines()?.first()}"
381
+ }
382
+ // Stash this resolution root's tree; the key only has to be unique per root (aggregator
383
+ // groups by subtree hash). Skip empty configs — they'd contribute nothing.
384
+ if (!nodes.isEmpty()) {
385
+ synchronized (state.perSub) {
386
+ state.perSub["${project.path}::${cfg.name}".toString()] =
387
+ [nodes: nodes, direct: directIds, paths: paths, projectPath: project.path, config: cfg.name, prod: isProd]
388
+ }
389
+ }
390
+ }
391
+ }
392
+ }
393
+ }
394
+
395
+ rootProject { rp ->
396
+ // Hoist property reads to configuration time: the configuration cache forbids `Task.project` from
397
+ // task actions. The Socket CLI disables the cache for this run, but hoisting is cheap insurance.
398
+ def recordsFileOverride = gradle.socketProp.call(rp, 'socket.recordsFile')?.toString()
399
+ def defaultRecordsFile = new File(rp.projectDir, '.socket.facts.records.tsv').absolutePath
400
+ // `sources`/`targets` are --with-files-only; a plain run emits only the graph fields.
401
+ def withFilesProjects = gradle.socketProp.call(rp, 'socket.withFiles')?.toString()?.toLowerCase() == 'true'
402
+
403
+ rp.tasks.create('socketFacts') {
404
+ group = 'socket'
405
+ description = 'Emits Socket facts records for the entire build'
406
+ outputs.upToDateWhen { false }
407
+
408
+ doLast {
409
+ def state = gradle.socketFactsState
410
+
411
+ // Backslash-escape so a value can never break line/field framing (see records.ts unescape).
412
+ def esc = { v ->
413
+ (v == null ? '' : v.toString())
414
+ .replace('\\', '\\\\').replace('\t', '\\t').replace('\n', '\\n').replace('\r', '\\r')
415
+ }
416
+ def lines = []
417
+ def rec = { List fields -> lines << fields.collect { esc(it) }.join('\t') }
418
+
419
+ rec(['meta', 'gradle', gradle.gradleVersion, System.getProperty('java.version')])
420
+
421
+ // One `project` record per build module (sources/targets only with --with-files).
422
+ def projectsInfo
423
+ synchronized (state.projectsInfo) { projectsInfo = new ArrayList(state.projectsInfo) }
424
+ projectsInfo.each { pi ->
425
+ rec(['project', pi.path, pi.group ?: '', pi.name, pi.version ?: '', pi.dir])
426
+ if (withFilesProjects) {
427
+ pi.sources.each { s -> rec(['projectSrc', pi.path, s]) }
428
+ pi.targets.each { t -> rec(['projectTgt', pi.path, t]) }
429
+ }
430
+ }
431
+
432
+ // One resolution root per (subproject, configuration); the TS assembler merges them
433
+ // path-sensitively and content-addresses divergent subtrees.
434
+ def perSub
435
+ synchronized (state.perSub) { perSub = new LinkedHashMap(state.perSub) }
436
+ int rootIdx = 0
437
+ perSub.each { rootKey, tree ->
438
+ def rootId = (rootIdx++).toString()
439
+ rec(['root', rootId, tree.projectPath, tree.config, tree.prod ? '1' : '0'])
440
+ tree.nodes.each { coordId, node ->
441
+ def c = node.coord
442
+ rec(['node', rootId, coordId, c.groupId ?: '', c.artifactId ?: '', c.version ?: '', c.ext ?: '',
443
+ c.classifier ?: '', tree.direct.contains(coordId) ? '1' : '0'])
444
+ node.children.each { childId -> rec(['edge', rootId, coordId, childId]) }
445
+ def fs = tree.paths[coordId]
446
+ if (fs) { (fs as List).sort().each { p -> rec(['file', rootId, coordId, p]) } }
447
+ }
448
+ }
449
+
450
+ // Scanned configs + raw failures (Coana owns the abort/warn policy and report rendering).
451
+ def scanned
452
+ synchronized (state.scannedConfigs) { scanned = (state.scannedConfigs as List).sort() }
453
+ scanned.each { name -> rec(['scanned', name]) }
454
+ def failures
455
+ synchronized (state.failures) { failures = new ArrayList(state.failures) }
456
+ failures.each { f -> rec(['failure', f.coord, f.detail, f.config]) }
457
+
458
+ def outFile = new File(recordsFileOverride ?: defaultRecordsFile)
459
+ outFile.parentFile?.mkdirs()
460
+ // Explicit UTF-8: the TS reader reads UTF-8, but `File.text =` would encode
461
+ // with the JVM default charset (Cp1252 / US-ASCII on Windows or a non-UTF-8
462
+ // locale on JDK <=17), corrupting non-ASCII paths and failure messages.
463
+ outFile.withWriter('UTF-8') { it.write(lines.join('\n') + '\n') }
464
+ println "Socket facts records written to: ${outFile.absolutePath}"
465
+ }
466
+ }
467
+ }
468
+
469
+ gradle.projectsEvaluated { g ->
470
+ def aggregator = g.rootProject.tasks.findByName('socketFacts')
471
+ if (aggregator) {
472
+ g.rootProject.allprojects.each { p ->
473
+ def collector = p.tasks.findByName('socketFactsCollect')
474
+ if (collector) {
475
+ aggregator.dependsOn(collector)
476
+ }
477
+ }
478
+ }
479
+ }