@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.
- package/README.md +99 -32
- package/lib/automate.mjs +1299 -0
- package/lib/check.mjs +149 -33
- package/lib/ci.mjs +21 -0
- package/lib/cli.mjs +114 -29
- package/lib/cloudid.mjs +456 -0
- package/lib/code.mjs +147 -0
- package/lib/context.mjs +126 -0
- package/lib/contract-docs.mjs +1780 -198
- package/lib/contract.mjs +93 -10
- package/lib/detect.mjs +96 -44
- package/lib/dockerfile.mjs +83 -0
- package/lib/endpoint.mjs +36 -5
- package/lib/engine.mjs +1272 -126
- package/lib/existing.mjs +448 -54
- package/lib/git.mjs +53 -0
- package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
- package/lib/hooks/pins.mjs +28 -0
- package/lib/hooks/uknows-otel.cjs +82 -0
- package/lib/hooks/uknows_otel.py +179 -0
- package/lib/hosts.mjs +521 -16
- package/lib/locks.mjs +353 -0
- package/lib/manifest.mjs +27 -7
- package/lib/members.mjs +269 -0
- package/lib/plan.mjs +130 -0
- package/lib/settings.mjs +216 -28
- package/lib/signin.mjs +259 -0
- package/lib/stacks/azure.mjs +345 -0
- package/lib/stacks/channel.mjs +125 -0
- package/lib/stacks/dotnet.mjs +336 -29
- package/lib/stacks/functions-hooks.mjs +228 -0
- package/lib/stacks/functions.mjs +148 -0
- package/lib/stacks/guided.mjs +153 -30
- package/lib/stacks/index.mjs +10 -2
- package/lib/stacks/java.mjs +14 -13
- package/lib/stacks/node.mjs +142 -19
- package/lib/stacks/otel.mjs +56 -4
- package/lib/stacks/python-bootstrap.mjs +27 -7
- package/lib/stacks/python-lock.mjs +105 -0
- package/lib/stacks/python-sets.mjs +143 -0
- package/lib/stacks/python.mjs +261 -63
- package/lib/toml.mjs +64 -0
- package/lib/types.mjs +52 -5
- package/lib/ui.mjs +139 -20
- package/lib/updates.mjs +398 -0
- package/lib/versions.mjs +115 -0
- package/lib/workspace.mjs +4 -1
- package/package.json +3 -8
package/lib/existing.mjs
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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}
|
|
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,
|
|
57
|
-
const
|
|
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
|
|
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 =
|
|
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 &&
|
|
185
|
+
const dockerfile = (service.dockerfile && read(view, service.dockerfile)) || ''
|
|
77
186
|
dockerfile.split('\n').forEach((text, i) => {
|
|
78
|
-
const
|
|
79
|
-
for (const [vendor, re] of Object.entries(DOCKER_APM)) if (found(re)) add(vendor,
|
|
80
|
-
if (
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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,
|
|
362
|
+
...steps, AGAIN],
|
|
103
363
|
}
|
|
104
364
|
}
|
|
105
365
|
|
|
106
|
-
/**
|
|
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.
|
|
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*"([^"]+)"
|
|
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*["']([^"']+)["']
|
|
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) => {
|
|
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 = () => {
|
|
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')
|
|
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
|
-
|
|
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(
|
|
159
|
-
|
|
160
|
-
|
|
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
|
}
|