@uknows/setup 0.1.0 → 0.2.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 (48) hide show
  1. package/README.md +99 -32
  2. package/lib/automate.mjs +1299 -0
  3. package/lib/check.mjs +149 -33
  4. package/lib/ci.mjs +21 -0
  5. package/lib/cli.mjs +114 -29
  6. package/lib/cloudid.mjs +456 -0
  7. package/lib/code.mjs +147 -0
  8. package/lib/context.mjs +126 -0
  9. package/lib/contract-docs.mjs +1780 -198
  10. package/lib/contract.mjs +93 -10
  11. package/lib/detect.mjs +96 -44
  12. package/lib/dockerfile.mjs +83 -0
  13. package/lib/endpoint.mjs +36 -5
  14. package/lib/engine.mjs +1272 -126
  15. package/lib/existing.mjs +448 -54
  16. package/lib/git.mjs +53 -0
  17. package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
  18. package/lib/hooks/pins.mjs +28 -0
  19. package/lib/hooks/uknows-otel.cjs +82 -0
  20. package/lib/hooks/uknows_otel.py +179 -0
  21. package/lib/hosts.mjs +521 -16
  22. package/lib/locks.mjs +353 -0
  23. package/lib/manifest.mjs +27 -7
  24. package/lib/members.mjs +269 -0
  25. package/lib/plan.mjs +130 -0
  26. package/lib/settings.mjs +216 -28
  27. package/lib/signin.mjs +259 -0
  28. package/lib/stacks/azure.mjs +345 -0
  29. package/lib/stacks/channel.mjs +125 -0
  30. package/lib/stacks/dotnet.mjs +336 -29
  31. package/lib/stacks/functions-hooks.mjs +228 -0
  32. package/lib/stacks/functions.mjs +148 -0
  33. package/lib/stacks/guided.mjs +153 -30
  34. package/lib/stacks/index.mjs +10 -2
  35. package/lib/stacks/java.mjs +14 -13
  36. package/lib/stacks/node.mjs +142 -19
  37. package/lib/stacks/otel.mjs +56 -4
  38. package/lib/stacks/python-bootstrap.mjs +27 -7
  39. package/lib/stacks/python-lock.mjs +105 -0
  40. package/lib/stacks/python-sets.mjs +143 -0
  41. package/lib/stacks/python.mjs +261 -63
  42. package/lib/toml.mjs +64 -0
  43. package/lib/types.mjs +52 -5
  44. package/lib/ui.mjs +139 -20
  45. package/lib/updates.mjs +398 -0
  46. package/lib/versions.mjs +115 -0
  47. package/lib/workspace.mjs +4 -1
  48. package/package.json +3 -8
package/lib/existing.mjs CHANGED
@@ -1,8 +1,25 @@
1
- // OpenTelemetry and other APM agents already in 1 service, from its manifests and Dockerfile.
2
- // Sentry counts only when its tracing is on, so for Sentry this also reads the source files.
3
- import { TROUBLESHOOTING } from './contract.mjs'
1
+ /*
2
+ * What 1 service already has for tracing, and the mode that follows from it. It reads the manifests of the
3
+ * service and of its first-party members (uv workspace members, path sources, file: packages, project
4
+ * references), the lock closure, the Dockerfile, deploy files, and the source of the service and its members.
5
+ * Sentry counts only when its tracing is on. Static reading only.
6
+ *
7
+ * classify() turns that into 1 mode:
8
+ * stop another APM agent, a classic App Insights SDK, or a package that brings its own OpenTelemetry
9
+ * azure-monitor Azure Monitor OpenTelemetry: keep it, add 1 exporter beside it
10
+ * otel OpenTelemetry SDK packages listed directly: settings only
11
+ * standard nothing yet
12
+ */
13
+ import { posix } from 'node:path'
14
+ import { AZURE_MONITOR, OTHER_EXPORTERS_NOTE, PHOENIX_KEY_STEP, PHOENIX_STEP, PINS, SETUP_TROUBLESHOOTING, TROUBLESHOOTING } from './contract.mjs'
15
+ import { installedPackages, noPackages, packageKey } from './locks.mjs'
16
+ import { findMembers } from './members.mjs'
17
+ import { pyName } from './toml.mjs'
4
18
 
5
19
  const at = (dir, name) => (dir === '.' ? name : `${dir}/${name}`)
20
+ const inside = (dir, path) => dir === '.' ? !path.startsWith('../') : path === dir || path.startsWith(`${dir}/`)
21
+ const AGAIN = 'Then run npx @uknows/setup again.'
22
+ const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
6
23
 
7
24
  /** Manifest name -> ecosystem. */
8
25
  const ECOSYSTEM = [
@@ -10,24 +27,27 @@ const ECOSYSTEM = [
10
27
  [/^(pom\.xml|build\.gradle(\.kts)?)$/, 'java'], [/^go\.mod$/, 'go'], [/^composer\.json$/, 'php'], [/^Gemfile$/, 'ruby'],
11
28
  ]
12
29
 
13
- /** Per ecosystem: OpenTelemetry at SDK level (never the API package alone), other APM agents, and Sentry. */
30
+ /**
31
+ * Per ecosystem: OpenTelemetry at SDK level (never the API package alone), other APM agents, Sentry, and the
32
+ * Azure Monitor families: azure (a distro to keep), appInsights (3.x keeps, older stops), classic (stops),
33
+ * browser (a note only) and tracers (allowed, with advice about the key).
34
+ */
14
35
  const RULES = {
15
36
  node: { otel: /^(@opentelemetry\/(sdk-node|sdk-trace-.+|auto-instrumentations-node|exporter-trace-otlp-.+)|@vercel\/otel)$/,
16
- apm: { Datadog: /^dd-trace$/, 'New Relic': /^newrelic$/, 'Elastic APM': /^elastic-apm-node$/,
17
- 'Azure Monitor': /^(applicationinsights|@azure\/monitor-opentelemetry.*)$/, Dynatrace: /^@dynatrace\//, Instana: /^@instana\/collector$/ },
18
- sentry: /^@sentry\// },
37
+ apm: { Datadog: /^dd-trace$/, 'New Relic': /^newrelic$/, 'Elastic APM': /^elastic-apm-node$/, Dynatrace: /^@dynatrace\//, Instana: /^@instana\/collector$/ },
38
+ sentry: /^@sentry\//, azure: /^@azure\/monitor-opentelemetry$/, appInsights: /^applicationinsights$/, browser: /^@microsoft\/applicationinsights-/,
39
+ tracers: /^(@arizeai\/.+|langfuse(-.+)?|@langfuse\/.+|@traceloop\/.+|logfire|@pydantic\/logfire.*)$/ },
19
40
  python: { otel: /^opentelemetry-(sdk|distro|instrumentation.*)$/,
20
- apm: { Datadog: /^ddtrace$/, 'New Relic': /^newrelic$/, 'Elastic APM': /^elastic-apm$/,
21
- 'Azure Monitor': /^(azure-monitor-opentelemetry.*|opencensus-ext-azure)$/ },
22
- sentry: /^sentry-sdk$/ },
41
+ apm: { Datadog: /^ddtrace$/, 'New Relic': /^newrelic$/, 'Elastic APM': /^elastic-apm$/ },
42
+ sentry: /^sentry-sdk$/, azure: /^azure-monitor-opentelemetry$/, classic: /^opencensus-ext-azure$/,
43
+ tracers: /^(arize-phoenix(-otel)?|openinference-.+|langfuse|traceloop-sdk|logfire)$/ },
23
44
  dotnet: { otel: /^OpenTelemetry(\.Extensions\.Hosting|\.AutoInstrumentation|\.Exporter\..+)?$/i,
24
- apm: { Datadog: /^Datadog\.Trace/i, 'New Relic': /^NewRelic\./i, 'Elastic APM': /^Elastic\.Apm/i,
25
- 'Azure Monitor': /^(Microsoft\.ApplicationInsights|Azure\.Monitor\.OpenTelemetry)/i },
26
- sentry: /^Sentry(\.|$)/ },
45
+ apm: { Datadog: /^Datadog\.Trace/i, 'New Relic': /^NewRelic\./i, 'Elastic APM': /^Elastic\.Apm/i },
46
+ sentry: /^Sentry(\.|$)/, azure: /^Azure\.Monitor\.OpenTelemetry\.AspNetCore$/i, appInsights: /^Microsoft\.ApplicationInsights\.(AspNetCore|WorkerService)$/i,
47
+ classic: /^Microsoft\.ApplicationInsights(\.(Web|WindowsServer|DependencyCollector|PerfCounterCollector|EventCounterCollector))?$/i },
27
48
  java: { otel: /^io\.opentelemetry[\w.]*:opentelemetry-[\w-]*(sdk|starter|exporter|javaagent)/,
28
- apm: { Datadog: /^com\.datadoghq:dd-/, 'New Relic': /^com\.newrelic\./, 'Elastic APM': /^co\.elastic\.apm:/,
29
- 'Azure Monitor': /^(com\.microsoft\.azure:applicationinsights-|com\.azure:azure-monitor-opentelemetry)/ },
30
- sentry: /^io\.sentry:/ },
49
+ apm: { Datadog: /^com\.datadoghq:dd-/, 'New Relic': /^com\.newrelic\./, 'Elastic APM': /^co\.elastic\.apm:/ },
50
+ sentry: /^io\.sentry:/, classic: /^(com\.microsoft\.azure:applicationinsights-|com\.azure:azure-monitor-opentelemetry)/ },
31
51
  go: { otel: /^go\.opentelemetry\.io\/otel\/sdk(\/|$)/,
32
52
  apm: { Datadog: /dd-trace-go/, 'New Relic': /^github\.com\/newrelic\/go-agent/, 'Elastic APM': /^go\.elastic\.co\/apm/ },
33
53
  sentry: /^github\.com\/getsentry\/sentry-go/ },
@@ -39,51 +59,291 @@ const RULES = {
39
59
  sentry: /^sentry-(ruby|rails|raven)$/ },
40
60
  }
41
61
 
42
- /** Agents that a Dockerfile loads (comment lines are ignored). */
62
+ /**
63
+ * Packages a wrapper may bring OpenTelemetry through without a stop: the Azure Monitor distros, OpenTelemetry
64
+ * itself and the allowed tracers (Phoenix, openinference, langfuse, traceloop, logfire).
65
+ */
66
+ const KNOWN = {
67
+ python: /^(opentelemetry-.+|azure-monitor-opentelemetry(-exporter)?|azure-core-tracing-opentelemetry|arize-phoenix(-otel)?|openinference-.+|langfuse|traceloop-sdk|logfire)$/,
68
+ dotnet: /^(opentelemetry(\..+)?|azure\.monitor\.opentelemetry(\..+)?|microsoft\.applicationinsights(\..+)?)$/,
69
+ node: /^(@opentelemetry\/.+|@azure\/monitor-opentelemetry(-exporter)?|applicationinsights|@vercel\/otel|@arizeai\/.+|langfuse(-.+)?|@langfuse\/.+|@traceloop\/.+|logfire)$/,
70
+ }
71
+ /** The Phoenix packages, whose register() falls back to OTEL_EXPORTER_OTLP_ENDPOINT. */
72
+ const PHOENIX = /^(arize-phoenix(-otel)?|@arizeai\/phoenix-otel)$/
73
+ /** Names that say a package is about telemetry. With OpenTelemetry under it, it is a wrapper. */
74
+ const WRAPPER_NAME = /otel|opentelemetry|telemetry|monitoring|tracing|observability/i
75
+ /** Requirements on OpenTelemetry packages, per language. */
76
+ const OTEL_DEP = { python: /^opentelemetry-/, dotnet: /^opentelemetry(\.|$)/i, node: /^@opentelemetry\// }
77
+ const LOCAL_SOURCES = new Set(['path', 'directory', 'file', 'git', 'url', 'link'])
78
+
79
+ /** Agents that a Dockerfile loads (comment lines are ignored). The App Insights Java agent counts as classic App Insights. */
43
80
  const DOCKER_APM = { Datadog: /\bdd-trace\b|\bddtrace(-run)?\b|dd-java-agent|datadog-dotnet-apm|datadog-setup\.php/,
44
81
  'New Relic': /\bnewrelic(-admin|\.jar|-php5?)?\b/i, 'Elastic APM': /elastic-apm-agent|elastic-apm-node|\belasticapm\b/i,
45
82
  'Azure Monitor': /applicationinsights-agent/i, Dynatrace: /\bdynatrace\b|\boneagent\b/i, Instana: /\binstana\b/i }
46
83
  const DOCKER_OTEL = /-javaagent:\S*opentelemetry/
47
84
  const TRACING = /tracesSampleRate|traces_sample_rate|TracesSampleRate|enableTracing|traces-sample-rate|tracesSampler|traces_sampler/
48
85
  const SOURCE = /\.(m?[jt]sx?|c[jt]s|py|cs|rb|go|php|java|kt|properties|ya?ml|json|exs?)$/
49
- const LOCKS = /(^|\/)(package-lock|npm-shrinkwrap|composer\.lock|packages\.lock)\.json$/
86
+ const LOCKS = /(^|\/)(package-lock|npm-shrinkwrap|composer\.lock|packages\.lock|project\.assets)\.json$|(^|\/)(uv|poetry|pdm)\.lock$/
87
+ const CODE = /\.(py|cs|m?[jt]sx?|c[jt]s)$/
88
+ const JS = /\.(m?[jt]sx?|c[jt]s)$/
89
+ const MAX_SCAN = 5000
90
+ const MAX_BYTES = 1_000_000
91
+
92
+ /**
93
+ * Lines in source code that matter: where Azure Monitor starts, a tracer provider, OTLP metrics or logs, sampling
94
+ * arguments and service names. [kind, regex, files]. Each match is 1 anchor.
95
+ */
96
+ const ANCHORS = [
97
+ ['azure-call', /\bconfigure_azure_monitor\s*\(/g, /\.py$/],
98
+ ['azure-call', /\bUseAzureMonitor\s*\(/g, /\.cs$/],
99
+ ['appinsights-call', /\bAddApplicationInsightsTelemetry(?:WorkerService)?\s*\(/g, /\.cs$/],
100
+ ['azure-call', /\buseAzureMonitor\s*\(/g, JS],
101
+ ['appinsights-call', /require\(\s*["']applicationinsights["']\s*\)|from\s+["']applicationinsights["']|import\s+["']applicationinsights["']/g, JS],
102
+ ['phoenix', /\bphoenix\.otel\b/g, /\.py$/],
103
+ // Phoenix gets its own address: the setting, or an address passed to register() (endpoint= in Python, url: in JS).
104
+ ['phoenix-endpoint', /\bPHOENIX_COLLECTOR_ENDPOINT\b|\bregister\s*\([^)]*\bendpoint\s*=/g, /\.py$/],
105
+ ['phoenix-endpoint', /\bPHOENIX_COLLECTOR_ENDPOINT\b|\bregister\s*\(\s*\{[^}]*\burl\s*:/g, JS],
106
+ // Phoenix gets its own key: the setting, or headers passed to register(). Else its exporter reads the OTLP headers, the UKNOWS key.
107
+ ['phoenix-key', /\bPHOENIX_API_KEY\b|\bPHOENIX_CLIENT_HEADERS\b|\bregister\s*\([^)]*\bheaders\s*=/g, /\.py$/],
108
+ ['phoenix-key', /\bPHOENIX_API_KEY\b|\bPHOENIX_CLIENT_HEADERS\b|\bregister\s*\(\s*\{[^}]*\bheaders\s*:/g, JS],
109
+ // Python web servers that Azure Monitor does not trace, when the package alone does not show that the app serves HTTP.
110
+ ['aiohttp-server', /\bfrom\s+aiohttp\s+import\s+[^\n]*\bweb\b|\baiohttp\.web\b/g, /\.py$/],
111
+ ['mcp-server', /\bFastMCP\s*\(|\bfrom\s+mcp\.server\b/g, /\.py$/],
112
+ ['provider', /\bset_tracer_provider\s*\(/g, /\.py$/],
113
+ ['otlp-signal', /\bOTLP(?:Metric|Log)Exporter\b|WriteTo\.OpenTelemetry\s*\(|\.With(?:Metrics|Logging)\s*\([^;]{0,400}?AddOtlpExporter|Logging\.AddOpenTelemetry\s*\([^;]{0,400}?AddOtlpExporter/g, CODE],
114
+ ['sampling', /\b(?:sampling_ratio|traces_per_second)\s*=\s*[^,)\n]+|\b(?:SamplingRatio|TracesPerSecond)\s*=\s*[^,;}\n]+|\b(?:samplingRatio|tracesPerSecond)\s*:\s*[^,}\n]+/g, CODE],
115
+ // The app sets the service name in code: it sets OTEL_SERVICE_NAME, or names service.name on a resource. A
116
+ // resource counts only in a file that also starts the global provider or Azure Monitor (resourceName below), so the
117
+ // resource of a second, private provider (Phoenix) does not.
118
+ ['service-name', /\benviron\s*\[\s*["']OTEL_SERVICE_NAME["']\s*\]\s*=(?!=)|\b(?:environ\.setdefault|putenv)\s*\(\s*["']OTEL_SERVICE_NAME["']|\bprocess\.env(?:\.OTEL_SERVICE_NAME|\[\s*["']OTEL_SERVICE_NAME["']\s*\])\s*=(?!=)|\bSetEnvironmentVariable\s*\(\s*"OTEL_SERVICE_NAME"/g, CODE],
119
+ ['service-resource', /["']service\.name["']|\bSERVICE_NAME\s*:|\.AddService\s*\(/g, CODE],
120
+ ['connection-string', /\bAPPLICATIONINSIGHTS_CONNECTION_STRING\b/g, CODE],
121
+ // .NET: AddOtlpExporter() reads only the general OTLP settings. UseOtlpExporter() reads the traces-only ones too.
122
+ ['add-otlp-exporter', /\.AddOtlpExporter\s*\(/g, /\.cs$/],
123
+ ['use-otlp-exporter', /\.UseOtlpExporter\s*\(/g, /\.cs$/],
124
+ ['traces-endpoint', /\bOTEL_EXPORTER_OTLP_TRACES_ENDPOINT\b/g, /\.cs$/],
125
+ ['traces-headers', /\bOTEL_EXPORTER_OTLP_TRACES_HEADERS\b/g, /\.cs$/],
126
+ ]
127
+ /** A file that starts the global tracer provider or Azure Monitor: a service.name on a resource in it names the app. */
128
+ const GLOBAL_PROVIDER = [
129
+ [/\.py$/, /\bset_tracer_provider\s*\(|\bconfigure_azure_monitor\s*\(/],
130
+ [/\.cs$/, /\bUseAzureMonitor\s*\(|\bAddOpenTelemetry\s*\(|\bSdk\.CreateTracerProviderBuilder\s*\(/],
131
+ [JS, /\buseAzureMonitor\s*\(|\bnew\s+NodeSDK\s*\(|\.register\s*\(\s*\)|\btrace\.setGlobalTracerProvider\s*\(/],
132
+ ]
133
+ /** Deploy files that can set environment variables of the app. */
134
+ const DEPLOY = /(?:^|\/)(?:Dockerfile(?:\.[^/]+)?|[^/]+\.[Dd]ockerfile|[^/]+\.(?:bicep|tf|ya?ml|env|sh)|\.env[^/]*|[^/]*task[-_.]?def[^/]*\.json)$/
135
+ const ENV_ANCHORS = [
136
+ ['service-name', /\bOTEL_SERVICE_NAME\b/g],
137
+ ['connection-string', /\bAPPLICATIONINSIGHTS_CONNECTION_STRING\b/g],
138
+ ['auto-attach', /\bAZURE_MONITOR_AUTO_ATTACH\b["']?\s*[=:,]?\s*["']?\s*(?:value\s*[:=]\s*["']?)?(true|1)\b/gi],
139
+ ['sampler-env', /\bOTEL_TRACES_SAMPLER\b["']?\s*[=:]\s*["']?([\w.]+)/g],
140
+ ['phoenix-endpoint', /\bPHOENIX_COLLECTOR_ENDPOINT\b/g],
141
+ ['phoenix-key', /\bPHOENIX_API_KEY\b|\bPHOENIX_CLIENT_HEADERS\b/g],
142
+ ]
50
143
 
51
144
  /**
52
145
  * @param {import('./types.mjs').Service} service
53
- * @param {import('./workspace.mjs').Workspace} ws
146
+ * @param {import('./workspace.mjs').RepoView|import('./workspace.mjs').Workspace} view The repo view (ctx.repo). A Workspace works too.
54
147
  * @returns {import('./types.mjs').Existing}
55
148
  */
56
- export function findExisting(service, ws) {
57
- const otel = [], apm = new Map(), sentry = []
149
+ export function findExisting(service, view) {
150
+ const lang = service.lang
151
+ const found = { otel: [], apm: new Map(), sentry: [], azure: [], classic: [], browser: [], tracers: [] }
58
152
  const add = (vendor, evidence) => {
59
- const list = apm.get(vendor) ?? apm.set(vendor, []).get(vendor)
153
+ const list = found.apm.get(vendor) ?? found.apm.set(vendor, []).get(vendor)
60
154
  if (!list.some((e) => e.file === evidence.file && e.what === evidence.what)) list.push(evidence)
61
155
  }
156
+ const { members, local } = lang ? findMembers(service, view) : { members: [], local: [] }
157
+ const installed = lang ? installedPackages(service, view, members) : noPackages()
158
+ const version = (eco, name, spec) => installed.packages.get(packageKey(eco, name))?.version ?? specVersion(spec)
62
159
  const manifests = new Set(service.manifests)
63
- for (const name of ws.list(service.dir)) if (ECOSYSTEM.some(([re]) => re.test(name))) manifests.add(at(service.dir, name))
160
+ for (const name of listDir(view, service.dir)) if (ECOSYSTEM.some(([re]) => re.test(name))) manifests.add(at(service.dir, name))
161
+ for (const m of members) for (const file of m.manifests) manifests.add(file)
64
162
  for (const file of [...manifests].sort()) {
65
- const text = ws.read(file)
163
+ const text = read(view, file)
66
164
  const eco = ECOSYSTEM.find(([re]) => re.test(file.split('/').pop()))?.[1]
67
165
  if (text === null || !eco) continue
68
166
  const rules = RULES[eco]
69
- for (const { name, line } of dependencies(file, text)) {
167
+ for (const { name, line, spec } of dependencies(file, text)) {
70
168
  const evidence = { file, line, what: name }
71
- if (rules.otel.test(name)) otel.push(evidence)
169
+ if (rules.otel.test(name)) found.otel.push(evidence)
72
170
  for (const [vendor, re] of Object.entries(rules.apm)) if (re.test(name)) add(vendor, evidence)
73
- if (rules.sentry.test(name)) sentry.push(evidence)
171
+ if (rules.sentry.test(name)) found.sentry.push(evidence)
172
+ if (rules.azure?.test(name)) found.azure.push({ ...evidence, version: version(eco, name, spec) })
173
+ if (rules.browser?.test(name)) found.browser.push(evidence)
174
+ if (rules.tracers?.test(name)) found.tracers.push(evidence)
175
+ if (rules.appInsights?.test(name) || rules.classic?.test(name)) {
176
+ const v = version(eco, name, spec)
177
+ const major = Number(/^\D*(\d+)/.exec(v ?? '')?.[1] ?? NaN)
178
+ // 3.x of the .NET and Node.js App Insights packages runs on OpenTelemetry: keep it. Older ones, or an unknown
179
+ // version, stop. The .NET helper packages at 3.x come with Microsoft.ApplicationInsights.AspNetCore 3.x.
180
+ if (rules.appInsights?.test(name) && major >= 3) found.azure.push({ ...evidence, version: v })
181
+ else if (!(eco === 'dotnet' && rules.classic?.test(name) && major >= 3)) found.classic.push({ ...evidence, version: v, what: `${name}${v ? ` ${v}` : ''}` })
182
+ }
74
183
  }
75
184
  }
76
- const dockerfile = (service.dockerfile && ws.read(service.dockerfile)) || ''
185
+ const dockerfile = (service.dockerfile && read(view, service.dockerfile)) || ''
77
186
  dockerfile.split('\n').forEach((text, i) => {
78
- const found = (re) => (/^\s*#/.test(text) || !re.test(text) ? null : { file: service.dockerfile, line: i + 1, what: re.exec(text)[0] })
79
- for (const [vendor, re] of Object.entries(DOCKER_APM)) if (found(re)) add(vendor, found(re))
80
- if (found(DOCKER_OTEL)) otel.push(found(DOCKER_OTEL))
187
+ const hit = (re) => (/^\s*#/.test(text) || !re.test(text) ? null : { file: service.dockerfile, line: i + 1, what: re.exec(text)[0] })
188
+ for (const [vendor, re] of Object.entries(DOCKER_APM)) if (hit(re)) (vendor === 'Azure Monitor' ? found.classic.push(hit(re)) : add(vendor, hit(re)))
189
+ if (hit(DOCKER_OTEL)) found.otel.push(hit(DOCKER_OTEL))
190
+ })
191
+ const dirs = [service.dir, ...members.map((m) => m.dir)]
192
+ let all = []
193
+ try { all = view.files() } catch { /* unreadable */ }
194
+ const sources = sourceFiles(view, all, dirs)
195
+ if (found.sentry.length) {
196
+ const hit = tracingIn(view, sources)
197
+ if (hit) for (const evidence of [...found.sentry, hit]) add('Sentry', evidence)
198
+ }
199
+ const own = new Set([service.dockerfile, ...(service.k8s ?? []).map((k) => k.file)].filter(Boolean))
200
+ // A deploy file names the service when it is its Dockerfile or Kubernetes file, or sits in the service folder.
201
+ const ofService = (a) => a.kind !== 'service-name' || service.dir === '.' || own.has(a.file) || inside(service.dir, a.file)
202
+ const anchors = [...scan(view, sources, ANCHORS).flatMap((a) => resourceName(view, a)), ...scan(view, deployFiles(view, all, service), ENV_ANCHORS).filter(ofService)]
203
+ const goLocal = lang === 'go' ? goModules(view, service, sources) : []
204
+ return { otel: found.otel, apm: [...found.apm].map(([vendor, evidence]) => ({ vendor, evidence })), azure: found.azure, classic: found.classic,
205
+ browser: found.browser, tracers: found.tracers, anchors, local, members, installed, goLocal }
206
+ }
207
+
208
+ /** A service.name on a resource counts as the service name only in a file that starts the global provider or Azure Monitor. */
209
+ function resourceName(view, anchor) {
210
+ if (anchor.kind !== 'service-resource') return [anchor]
211
+ const rule = GLOBAL_PROVIDER.find(([files]) => files.test(anchor.file))
212
+ return rule && rule[1].test(read(view, anchor.file) ?? '') ? [{ ...anchor, kind: 'service-name' }] : []
213
+ }
214
+
215
+ /**
216
+ * Go: the modules that go.mod replaces with a local folder, and whether that folder's go.mod requires OpenTelemetry.
217
+ * Such a module is a kit that brings its own OpenTelemetry (the wrapper stop), with the source file that imports it.
218
+ */
219
+ function goModules(view, service, sources) {
220
+ const file = at(service.dir, 'go.mod'), text = read(view, file)
221
+ if (!text) return []
222
+ const out = []
223
+ const lines = text.split('\n')
224
+ let block = false
225
+ lines.forEach((raw, i) => {
226
+ const l = raw.replace(/\/\/.*$/, '').trim()
227
+ if (/^replace\s*\($/.test(l)) { block = true; return }
228
+ if (block && l === ')') { block = false; return }
229
+ const m = /^(?:replace\s+)?(\S+)(?:\s+v\S+)?\s*=>\s*(\.{1,2}\/\S*)\s*$/.exec(block ? l : /^replace\s/.test(l) ? l : '')
230
+ if (!m) return
231
+ const dir = posix.normalize(at(service.dir, m[2])).replace(/\/+$/, '')
232
+ const mod = read(view, at(dir, 'go.mod')) ?? ''
233
+ const otel = /^\s*(?:require\s+)?(go\.opentelemetry\.io\/otel\S*)\s+(v\S+)/m.exec(mod)
234
+ const importer = sources.filter((f) => f.endsWith('.go') && !inside(dir, f)).map((f) => {
235
+ const code = read(view, f) ?? ''
236
+ const pos = code.indexOf(`"${m[1]}`)
237
+ return pos < 0 ? null : { file: f, line: code.slice(0, pos).split('\n').length }
238
+ }).find(Boolean) ?? null
239
+ out.push({ module: m[1], path: dir, file, line: i + 1, otel: otel ? { name: otel[1], version: otel[2] } : null, importer })
81
240
  })
82
- if (sentry.length) {
83
- const hit = tracingIn(service, ws)
84
- if (hit) for (const evidence of [...sentry, hit]) add('Sentry', evidence)
241
+ return out
242
+ }
243
+
244
+ /**
245
+ * The mode for 1 service, and why. Stops come first: another APM agent, a classic App Insights SDK (or codeless
246
+ * attach on App Service), a package that brings its own OpenTelemetry, or a local telemetry package with no lock.
247
+ * @param {Partial<import('./types.mjs').Existing>} existing
248
+ * @param {{lang?: string|null, hosts?: import('./types.mjs').Host[]}} [opts]
249
+ * @returns {import('./types.mjs').Classification}
250
+ */
251
+ export function classify(existing, { lang = null, hosts = [] } = {}) {
252
+ if (existing.apm?.length) return { mode: 'stop', reason: 'apm' }
253
+ const appService = hosts.some((h) => h.id === 'azure-app-service')
254
+ const attach = (existing.anchors ?? []).filter((a) => a.kind === 'auto-attach')
255
+ const classic = [...(existing.classic ?? []), ...(appService ? attach.map((a) => ({ ...a, what: 'AZURE_MONITOR_AUTO_ATTACH=true' })) : [])]
256
+ if (classic.length) return { mode: 'stop', reason: 'classic', classic }
257
+ // Also when OpenTelemetry is listed directly: a kit beside it can limit its version, or be the only code that starts it.
258
+ const wrapper = findWrapper(existing, lang)
259
+ if (wrapper) return { mode: 'stop', reason: 'wrapper', wrapper }
260
+ const local = suspectLocal(existing, lang)
261
+ if (local) return { mode: 'stop', reason: 'local', local }
262
+ if (existing.azure?.length) return { mode: 'azure-monitor', reason: null }
263
+ if (existing.otel?.length) return { mode: 'otel', reason: null }
264
+ return { mode: 'standard', reason: null }
265
+ }
266
+
267
+ /**
268
+ * The package that brings the OpenTelemetry SDK and is not standard OpenTelemetry: a local package, a telemetry name,
269
+ * or a version limit below what this tool needs. When the app lists the SDK itself, a package beside it still counts
270
+ * when it is local and needs OpenTelemetry, or has a telemetry name and needs the SDK itself (a library that only
271
+ * needs the API, such as Npgsql.OpenTelemetry, traces itself and is fine). Go: a module that go.mod replaces with a
272
+ * local folder whose go.mod requires OpenTelemetry.
273
+ */
274
+ function findWrapper(existing, lang) {
275
+ if (lang === 'go') {
276
+ const kit = (existing.goLocal ?? []).find((m) => m.otel)
277
+ return kit ? { package: { name: kit.module, version: null, file: kit.file, line: kit.line }, sdk: kit.otel, limit: null, need: null,
278
+ local: { path: kit.path, file: kit.file, line: kit.line, replace: `replace ${kit.module} => ./${posix.relative(posix.dirname(kit.file), kit.path) || '.'}` },
279
+ importer: kit.importer } : null
85
280
  }
86
- return { otel, apm: [...apm].map(([vendor, evidence]) => ({ vendor, evidence })) }
281
+ const sdk = PINS.sdk[lang]
282
+ const packages = existing.installed?.packages
283
+ if (!sdk || !packages) return null
284
+ const sdkKey = packageKey(lang, sdk.package)
285
+ const item = packages.get(sdkKey)
286
+ if (!item) return null
287
+ const installed = (p) => p.direct || p.via.length > 0
288
+ // Listed directly: in the lock, or in a manifest of the service (a lock can lag behind its manifest).
289
+ const direct = item.direct || (existing.otel ?? []).length > 0
290
+ const candidates = direct
291
+ ? [...packages].filter(([key, p]) => key !== sdkKey && installed(p) && (p.requires ?? []).some((r) => OTEL_DEP[lang].test(r.name))).map(([key]) => key)
292
+ : item.via
293
+ for (const via of candidates) {
294
+ const wrapper = packages.get(via)
295
+ const name = wrapper?.name ?? via
296
+ if (KNOWN[lang].test(lang === 'dotnet' ? name.toLowerCase() : lang === 'python' ? pyName(name) : name)) continue
297
+ const local = (existing.local ?? []).find((l) => packageKey(lang, l.name) === via) ?? null
298
+ const isLocal = LOCAL_SOURCES.has(wrapper?.source ?? '') || Boolean(local)
299
+ const limit = (wrapper?.requires ?? []).find((r) => OTEL_DEP[lang].test(r.name) && upperBound(lang, r.spec) !== null) ?? null
300
+ const below = Boolean(limit && sdk.need && compareVersions(upperBound(lang, limit.spec), sdk.need) <= 0)
301
+ const needsSdk = (wrapper?.requires ?? []).some((r) => packageKey(lang, r.name) === sdkKey)
302
+ const hit = direct ? isLocal || (WRAPPER_NAME.test(name) && needsSdk) : isLocal || WRAPPER_NAME.test(name) || below
303
+ if (hit) return { package: wrapper ?? { name, version: null, file: item.file }, sdk: item, limit, need: below && !direct ? sdk.need : null, local, direct }
304
+ }
305
+ return null
306
+ }
307
+
308
+ /** A local package file with a telemetry name that no lockfile explains. */
309
+ function suspectLocal(existing, lang) {
310
+ const packages = existing.installed?.packages ?? new Map()
311
+ return (existing.local ?? []).find((l) => l.kind !== 'source' && l.kind !== 'folder' && /otel|telemetry|monitoring/i.test(l.name)
312
+ && !packages.has(packageKey(lang ?? '', l.name))) ?? null
313
+ }
314
+
315
+ /**
316
+ * The block that says why the run stops for 1 service. reason apm uses explainApm.
317
+ * @param {import('./types.mjs').Service} service
318
+ * @param {Partial<import('./types.mjs').Existing>} existing
319
+ * @param {import('./types.mjs').Classification} cls
320
+ * @returns {import('./types.mjs').Block}
321
+ */
322
+ export function explainStop(service, existing, cls) {
323
+ const who = service.dir === '.' ? 'This app' : service.dir
324
+ const where = (e) => `${e.file}${e.line ? ` line ${e.line}` : ''}`
325
+ if (cls.reason === 'apm') return explainApm(service, existing.apm ?? [])
326
+ if (cls.reason === 'classic') {
327
+ const entry = SETUP_TROUBLESHOOTING.find((t) => t.id === 'app-insights-classic')
328
+ return { title: `${who} uses Application Insights in a way UKNOWS cannot join`,
329
+ lines: [...cls.classic.map((e) => `Found ${e.what} in ${where(e)}.`), entry.cause, ...entry.steps, AGAIN] }
330
+ }
331
+ const entry = SETUP_TROUBLESHOOTING.find((t) => t.id === 'otel-wrapper')
332
+ if (cls.reason === 'local') {
333
+ const l = cls.local
334
+ return { title: `Check ${l.name} before you add UKNOWS`, lines: [`${where(l)} uses the local package file ${l.path}.`,
335
+ 'Its name says that it can bring OpenTelemetry. There is no lockfile that shows what it brings.', entry.cause, ...entry.steps, AGAIN] }
336
+ }
337
+ const { package: pkg, sdk, limit, need, local, importer, direct } = cls.wrapper
338
+ const sdkName = PINS.sdk[service.lang]?.package ?? sdk.name
339
+ const lines = [`${pkg.name}${pkg.version ? ` ${pkg.version}` : ''} brings ${sdkName} ${sdk.version ?? ''}`.trimEnd() + ` (${pkg.file}${pkg.line ? ` line ${pkg.line}` : ''}).`]
340
+ if (local?.replace) lines.push(`It is a local module: ${local.replace} (${where(local)}).`)
341
+ else if (local) lines.push(`It is a local package: ${local.path}, named in ${where(local)}.`)
342
+ if (importer) lines.push(`${where(importer)} imports it.`)
343
+ if (limit) lines.push(`It needs ${limit.name} ${limit.spec} (${pkg.file}${limit.line ? ` line ${limit.line}` : ''}).`)
344
+ if (need) lines.push(`This tool's packages need ${sdkName} ${need} or later.`)
345
+ if (direct) lines.push(`The app also lists ${sdkName} itself. This tool still stops, because ${pkg.name} can limit the OpenTelemetry version and can be the code that starts OpenTelemetry.`)
346
+ return { title: `${who} gets OpenTelemetry from ${pkg.name}`, lines: [...lines, entry.cause, ...entry.steps, AGAIN] }
87
347
  }
88
348
 
89
349
  /**
@@ -99,46 +359,88 @@ export function explainApm(service, apm) {
99
359
  return {
100
360
  title: `${service.dir === '.' ? 'This app' : service.dir} already uses ${names}`,
101
361
  lines: [...apm.flatMap((a) => a.evidence.map((e) => `Found ${e.what} in ${e.file}${e.line ? ` line ${e.line}` : ''}.`)),
102
- ...steps, 'Then run npx @uknows/setup again.'],
362
+ ...steps, AGAIN],
103
363
  }
104
364
  }
105
365
 
106
- /** Package names a manifest lists, with 1-based lines. */
366
+ /**
367
+ * Notes and advice that never stop the run: the browser App Insights SDK, a connection string or codeless attach
368
+ * with no SDK, and tracers such as Phoenix that also read the key. Phoenix with no address of its own is a required
369
+ * step: it would send its traces to the UKNOWS address.
370
+ * @param {Partial<import('./types.mjs').Existing>} existing
371
+ * @param {{hosts?: import('./types.mjs').Host[], mode?: string}} [opts]
372
+ * @returns {{notes: string[], advice: import('./types.mjs').Block[]}}
373
+ */
374
+ export function existingNotes(existing, { hosts = [], mode = 'standard' } = {}) {
375
+ const notes = [], advice = []
376
+ const anchors = existing.anchors ?? []
377
+ for (const e of existing.browser ?? []) notes.push(fill(AZURE_MONITOR.plan.browser, { file: e.file, value: e.what }))
378
+ const sdk = existing.azure?.length || existing.classic?.length
379
+ const connection = anchors.find((a) => a.kind === 'connection-string')
380
+ if (connection && !sdk) notes.push(fill(AZURE_MONITOR.plan.connectionString, { file: connection.file }))
381
+ const attach = anchors.find((a) => a.kind === 'auto-attach')
382
+ if (attach && !hosts.some((h) => h.id === 'azure-app-service')) notes.push(fill(AZURE_MONITOR.plan.autoAttach, { file: attach.file }))
383
+ const tracer = existing.tracers?.[0] ?? anchors.find((a) => a.kind === 'phoenix')
384
+ const found = (e) => `Found ${e.what} in ${e.file}${e.line ? ` line ${e.line}` : ''}.`
385
+ // Phoenix with no address of its own sends to OTEL_EXPORTER_OTLP_ENDPOINT, which is UKNOWS after setup: a required step.
386
+ const phoenix = (existing.tracers ?? []).find((e) => PHOENIX.test(e.what)) ?? anchors.find((a) => a.kind === 'phoenix')
387
+ if (phoenix && mode !== 'stop' && !anchors.some((a) => a.kind === 'phoenix-endpoint')) {
388
+ advice.push({ title: PHOENIX_STEP.title, required: true, lines: [found(phoenix), ...PHOENIX_STEP.lines] })
389
+ }
390
+ // Phoenix with no key of its own sends the UKNOWS key to the Phoenix server: a required step too.
391
+ if (phoenix && mode !== 'stop' && !anchors.some((a) => a.kind === 'phoenix-key')) {
392
+ advice.push({ title: PHOENIX_KEY_STEP.title, required: true, lines: [found(phoenix), ...PHOENIX_KEY_STEP.lines] })
393
+ }
394
+ if (tracer && mode !== 'stop') {
395
+ advice.push({ title: 'Keep your UKNOWS key away from other exporters', required: false, lines: [found(tracer), OTHER_EXPORTERS_NOTE] })
396
+ }
397
+ return { notes, advice }
398
+ }
399
+
400
+ /** Package names a manifest lists, with 1-based lines and the version text (spec) when the manifest has one. */
107
401
  export function dependencies(file, text) {
108
402
  const base = file.split('/').pop()
109
403
  const lines = text.split('\n')
110
404
  const lineOf = (needle) => lines.findIndex((l) => l.includes(needle)) + 1 || undefined
111
405
  const out = []
112
- const push = (name, line) => { if (name && !out.some((d) => d.name === name)) out.push({ name, line }) }
406
+ const push = (name, line, spec = null) => { if (name && !out.some((d) => d.name === name)) out.push({ name, line, spec }) }
113
407
  if (base === 'package.json' || base === 'composer.json') {
114
408
  let json
115
409
  try { json = JSON.parse(text) } catch { return out }
116
410
  const groups = base === 'package.json' ? ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] : ['require', 'require-dev']
117
411
  for (const group of groups) {
118
- for (const name of Object.keys(json?.[group] ?? {})) push(name, lineOf(`${JSON.stringify(name)}`))
412
+ for (const [name, spec] of Object.entries(json?.[group] ?? {})) push(name, lineOf(`${JSON.stringify(name)}`), typeof spec === 'string' ? spec : null)
119
413
  }
120
414
  } else if (/\.csproj$/.test(base)) {
121
- for (const m of text.matchAll(/<PackageReference\s[^>]*?Include\s*=\s*"([^"]+)"/gi)) push(m[1], lineOf(m[0]))
415
+ for (const m of text.matchAll(/<PackageReference\s[^>]*?Include\s*=\s*"([^"]+)"[^>]*?(?:\/>|>([\s\S]*?)<\/PackageReference>)/gi)) {
416
+ const spec = /Version\s*=\s*"([^"]+)"/i.exec(m[0])?.[1] ?? /<Version>\s*([^<]+?)\s*</i.exec(m[2] ?? '')?.[1] ?? null
417
+ push(m[1], lineOf(m[0].split('\n')[0]), spec)
418
+ }
122
419
  } else if (base === 'pom.xml') {
123
420
  for (const m of text.matchAll(/<dependency>([\s\S]*?)<\/dependency>/g)) {
124
421
  const group = /<groupId>\s*([^<\s]+)/.exec(m[1])?.[1], artifact = /<artifactId>\s*([^<\s]+)/.exec(m[1])?.[1]
125
- if (group && artifact) push(`${group}:${artifact}`, lineOf(`<artifactId>${artifact}`))
422
+ if (group && artifact) push(`${group}:${artifact}`, lineOf(`<artifactId>${artifact}`), /<version>\s*([^<\s]+)/.exec(m[1])?.[1] ?? null)
126
423
  }
127
424
  } else if (/^build\.gradle/.test(base)) {
128
- for (const m of text.matchAll(/['"]([\w.-]+):([\w.-]+)(?::[^'"]*)?['"]/g)) push(`${m[1]}:${m[2]}`, lineOf(m[0]))
425
+ for (const m of text.matchAll(/['"]([\w.-]+):([\w.-]+)(?::([^'"]*))?['"]/g)) push(`${m[1]}:${m[2]}`, lineOf(m[0]), m[3] ?? null)
129
426
  } else if (base === 'go.mod') {
130
- lines.forEach((l, i) => { const m = /^\s*(?:require\s+)?([\w.-]+\.[\w-]+\/\S+)\s+v\d/.exec(l); if (m) push(m[1], i + 1) })
427
+ lines.forEach((l, i) => { const m = /^\s*(?:require\s+)?([\w.-]+\.[\w-]+\/\S+)\s+(v\d\S*)/.exec(l); if (m) push(m[1], i + 1, m[2]) })
131
428
  } else if (base === 'Gemfile') {
132
- lines.forEach((l, i) => { const m = /^\s*gem\s*\(?\s*["']([^"']+)["']/.exec(l); if (m) push(m[1], i + 1) })
429
+ lines.forEach((l, i) => { const m = /^\s*gem\s*\(?\s*["']([^"']+)["'](?:\s*,\s*["']([^"']+)["'])?/.exec(l); if (m) push(m[1], i + 1, m[2] ?? null) })
133
430
  } else if (/\.txt$/.test(base)) {
134
- lines.forEach((l, i) => { const m = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(l.replace(/#.*/, '')); if (m) push(pyName(m[1]), i + 1) })
431
+ lines.forEach((l, i) => {
432
+ const m = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*([^;#]*)/.exec(l.replace(/#.*/, ''))
433
+ if (m) push(pyName(m[1]), i + 1, m[2].trim() || null)
434
+ })
135
435
  } else {
136
436
  // pyproject.toml and Pipfile: dependency arrays and dependency tables.
137
437
  let table = '', array = false
138
438
  lines.forEach((raw, i) => {
139
439
  const l = raw.replace(/\s#.*$/, '')
140
440
  const closes = () => l.replace(/"[^"]*"|'[^']*'/g, '').includes(']')
141
- const quoted = () => { for (const m of l.matchAll(/["']([A-Za-z0-9][A-Za-z0-9._-]*)[^"']*["']/g)) push(pyName(m[1]), i + 1) }
441
+ const quoted = () => {
442
+ for (const m of l.matchAll(/["']([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]"']*\])?\s*([^"';]*)[^"']*["']/g)) push(pyName(m[1]), i + 1, m[2].trim() || null)
443
+ }
142
444
  if (array) { quoted(); array = !closes(); return }
143
445
  const header = /^\s*\[+([^\]]+)\]/.exec(l)
144
446
  if (header) { table = header[1]; return }
@@ -146,21 +448,113 @@ export function dependencies(file, text) {
146
448
  if (!kv) return
147
449
  if (kv[2].startsWith('[')) {
148
450
  if (/dependencies|requires/.test(kv[1]) || /dependencies|dependency-groups/.test(table)) { quoted(); array = !closes() }
149
- } else if (/dependencies|packages/.test(table) && kv[1] !== 'python') push(pyName(kv[1]), i + 1)
451
+ } else if (/dependencies|packages/.test(table) && kv[1] !== 'python') {
452
+ push(pyName(kv[1]), i + 1, /^["']([^"']*)["']/.exec(kv[2])?.[1] ?? /version\s*=\s*["']([^"']*)["']/.exec(kv[2])?.[1] ?? null)
453
+ }
150
454
  })
151
455
  }
152
456
  return out
153
457
  }
154
458
 
155
- const pyName = (name) => name.toLowerCase().replace(/[-_.]+/g, '-')
459
+ /** The first version number in a version text: '^3.3.0' -> '3.3.0', '[2.22.0, )' -> '2.22.0'. null when none. */
460
+ export function specVersion(spec) {
461
+ return /(\d+(?:\.\d+){0,3})/.exec(spec ?? '')?.[1] ?? null
462
+ }
463
+
464
+ /**
465
+ * The upper bound of a version limit, or null when there is none.
466
+ * Python: <1.45.0, <=1.44, ==1.44.0, ~=1.44.0. NuGet: [1.17.0, 1.19.0), [1.18.0]. npm: <2, ^1.25.0, ~1.25.0.
467
+ */
468
+ export function upperBound(lang, spec) {
469
+ if (!spec) return null
470
+ if (lang === 'dotnet') {
471
+ const range = /^\s*[[(]\s*([^,\])]*)\s*(?:,\s*([^\])]*))?\s*[\])]\s*$/.exec(spec)
472
+ if (!range) return null
473
+ return range[2] === undefined ? range[1] || null : range[2].trim() || null
474
+ }
475
+ if (lang === 'node') {
476
+ const caret = /^\s*\^(\d+)(?:\.(\d+))?/.exec(spec)
477
+ if (caret) return Number(caret[1]) > 0 ? `${Number(caret[1]) + 1}.0.0` : `0.${Number(caret[2] ?? 0) + 1}.0`
478
+ const tilde = /^\s*~(\d+)\.(\d+)/.exec(spec)
479
+ if (tilde) return `${tilde[1]}.${Number(tilde[2]) + 1}.0`
480
+ return /<=?\s*(\d[\w.]*)/.exec(spec)?.[1] ?? null
481
+ }
482
+ const lt = /<=?\s*(\d[\w.]*)/.exec(spec)
483
+ if (lt) return lt[1]
484
+ const exact = /===?\s*(\d[\w.]*)/.exec(spec)
485
+ if (exact) return exact[1]
486
+ const compatible = /~=\s*(\d+(?:\.\d+)*)/.exec(spec)
487
+ if (compatible) { const parts = compatible[1].split('.'); parts.pop(); parts[parts.length - 1] = String(Number(parts.at(-1)) + 1); return parts.join('.') }
488
+ return null
489
+ }
490
+
491
+ /** Compare 2 dotted versions by their numbers. Pre-release tags are ignored. */
492
+ export function compareVersions(a, b) {
493
+ const pa = String(a).split(/[.+-]/).map((x) => Number.parseInt(x, 10)), pb = String(b).split(/[.+-]/).map((x) => Number.parseInt(x, 10))
494
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
495
+ const x = Number.isNaN(pa[i] ?? 0) ? 0 : pa[i] ?? 0, y = Number.isNaN(pb[i] ?? 0) ? 0 : pb[i] ?? 0
496
+ if (x !== y) return x < y ? -1 : 1
497
+ }
498
+ return 0
499
+ }
500
+
501
+ function read(view, path) {
502
+ try { return (view.stat?.(path)?.size ?? 0) > MAX_BYTES * 16 ? null : view.read(path) } catch { return null }
503
+ }
504
+
505
+ function listDir(view, dir) {
506
+ try {
507
+ if (typeof view.list === 'function') return view.list(dir)
508
+ return view.files().filter((f) => posix.dirname(f) === dir).map((f) => posix.basename(f))
509
+ } catch { return [] }
510
+ }
511
+
512
+ /** Source files of the service and its members, capped. */
513
+ function sourceFiles(view, all, dirs) {
514
+ const out = []
515
+ for (const file of all) {
516
+ if (out.length >= MAX_SCAN) break
517
+ if (!SOURCE.test(file) || LOCKS.test(file) || !dirs.some((d) => inside(d, file))) continue
518
+ if ((view.stat?.(file)?.size ?? 0) > MAX_BYTES) continue
519
+ out.push(file)
520
+ }
521
+ return out
522
+ }
523
+
524
+ /** Deploy files in the repo that can set the env of this service: its Dockerfile, templates, workflows, compose and .env files. */
525
+ function deployFiles(view, all, service) {
526
+ const out = service.dockerfile ? [service.dockerfile] : []
527
+ for (const file of all) {
528
+ if (out.length >= MAX_SCAN) break
529
+ if (!DEPLOY.test(file) || LOCKS.test(file) || out.includes(file) || /(?:^|\/)pnpm-lock\.ya?ml$/.test(file)) continue
530
+ if ((view.stat?.(file)?.size ?? 0) > 262144) continue
531
+ out.push(file)
532
+ }
533
+ return out
534
+ }
535
+
536
+ /** Every match of the anchor rules in these files, as evidence with a kind. Comment-only lines still count. */
537
+ function scan(view, files, rules) {
538
+ const out = []
539
+ for (const file of files) {
540
+ const applies = rules.filter((r) => !r[2] || r[2].test(file))
541
+ if (!applies.length) continue
542
+ const text = read(view, file)
543
+ if (!text) continue
544
+ for (const [kind, re] of applies) {
545
+ for (const m of text.matchAll(new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`))) {
546
+ const line = text.slice(0, m.index).split('\n').length
547
+ out.push({ file, line, what: m[0].trim(), kind, ...(m[1] ? { value: m[1] } : {}) })
548
+ }
549
+ }
550
+ }
551
+ return out
552
+ }
156
553
 
157
554
  /** The first source line that turns Sentry tracing on, as evidence. */
158
- function tracingIn(service, ws) {
159
- let count = 0
160
- for (const file of ws.files(service.dir)) {
161
- if (!SOURCE.test(file) || LOCKS.test(file) || ++count > 5000) continue
162
- if ((ws.stat(file)?.size ?? 0) > 1_000_000) continue
163
- const text = ws.read(file) ?? ''
555
+ function tracingIn(view, files) {
556
+ for (const file of files) {
557
+ const text = read(view, file) ?? ''
164
558
  const m = TRACING.exec(text)
165
559
  if (m) return { file, line: text.slice(0, m.index).split('\n').length, what: `Sentry tracing (${m[0]})` }
166
560
  }