@uknows/setup 0.0.0-stage → 0.1.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.
@@ -0,0 +1,66 @@
1
+ /*
2
+ * The UKNOWS install contract. Data only.
3
+ *
4
+ * Everything the docs page shows comes from contract-docs.mjs, which
5
+ * tools/sync-contract.mjs generates from src/docs/install.ts. Import those
6
+ * values from here and never copy their text into other modules, so a
7
+ * re-sync updates the whole CLI.
8
+ *
9
+ * PINS and CLI below are values the CLI needs that the docs page does not
10
+ * show. Upstream facts checked on 2026-10-12.
11
+ */
12
+
13
+ export * from './contract-docs.mjs'
14
+ import { NODE_REGISTER, PING_URL } from './contract-docs.mjs'
15
+
16
+ export const PINS = {
17
+ node: {
18
+ /** Same 2 packages as the docs install line. The package manager picks the versions. */
19
+ packages: ['@opentelemetry/api', '@opentelemetry/auto-instrumentations-node'],
20
+ /** ESM apps also need this one as a direct dependency (pnpm cannot resolve hook.mjs otherwise). */
21
+ esmPackage: '@opentelemetry/instrumentation',
22
+ requireFlag: `--require ${NODE_REGISTER}`,
23
+ esmFlag: `--experimental-loader=@opentelemetry/instrumentation/hook.mjs --import ${NODE_REGISTER}`,
24
+ },
25
+ python: {
26
+ /** Same 2 packages as the docs install line, pinned like pip freeze would. */
27
+ distro: 'opentelemetry-distro==0.66b1',
28
+ exporter: 'opentelemetry-exporter-otlp-proto-http==1.45.1',
29
+ /** Version of each opentelemetry-instrumentation-* package that opentelemetry-bootstrap prints. */
30
+ contrib: '0.66b1',
31
+ /** opentelemetry-distro 0.66b1 needs Python 3.10 or later. */
32
+ minPython: [3, 10],
33
+ },
34
+ dotnet: {
35
+ package: 'OpenTelemetry.AutoInstrumentation',
36
+ version: '1.17.0',
37
+ /** 1.17.0 targets net8.0 and net462 only. */
38
+ minNet: 8,
39
+ /** The build fails when an app uses StackExchange.Redis [2.6.122, 4.0.0) without this package. */
40
+ redis: { library: 'StackExchange.Redis', package: 'OpenTelemetry.Instrumentation.StackExchangeRedis', version: '1.19.0-beta.1' },
41
+ },
42
+ go: {
43
+ /**
44
+ * go get takes the latest modules. go.opentelemetry.io/otel v1.47.0 and otelhttp declare go 1.26.0, so older Go
45
+ * builders fail (golang images set GOTOOLCHAIN=local). Checked upstream on 2026-10-12.
46
+ */
47
+ minGo: [1, 26],
48
+ },
49
+ php: {
50
+ /** The 6th setting for PHP. The docs PHP Dockerfile adds it with the 5. */
51
+ autoload: { name: 'OTEL_PHP_AUTOLOAD_ENABLED', value: 'true' },
52
+ },
53
+ }
54
+
55
+ export const CLI = {
56
+ /** Records what setup changed so remove can undo it exactly. Never holds secrets. */
57
+ manifest: '.uknows/setup.json',
58
+ /** Override for the ingest address, for a test environment. setup writes it and check sends to it (endpoint.mjs). */
59
+ endpointEnv: 'UKNOWS_INGEST_ENDPOINT',
60
+ /** Override for the site address (where check asks which workspace a key sends to), for a test environment. */
61
+ siteEnv: 'UKNOWS_SITE_URL',
62
+ /** The route of the site that says which workspace and environment a key sends to. */
63
+ pingPath: new URL(PING_URL).pathname,
64
+ exit: { ok: 0, error: 1, usage: 2, manual: 3 },
65
+ next: 'Next: commit, open a PR, deploy as usual.',
66
+ }
package/lib/detect.mjs ADDED
@@ -0,0 +1,575 @@
1
+ /*
2
+ * Find the services of a repo: their language, frameworks, Dockerfile, start
3
+ * command, name, runtime version and Kubernetes containers. Static reading
4
+ * only: nothing runs and nothing goes over the network. The rules come from
5
+ * the UKNOWS agent kit inspect step.
6
+ */
7
+ import { basename, posix } from 'node:path'
8
+ import { Doc, shellWords } from './doc.mjs'
9
+ import { dockerStart, parseDockerfile } from './dockerfile.mjs'
10
+ import { SETTINGS_FILE } from './contract.mjs'
11
+ import { detectHosts } from './hosts.mjs'
12
+ import { createWorkspace, repoView } from './workspace.mjs'
13
+
14
+ const MAX_BYTES = 1_000_000
15
+ /** Tie-break when 1 folder has 2 languages and nothing else decides: Node last, it is often front-end tooling. */
16
+ const LANGS = ['python', 'java', 'dotnet', 'go', 'php', 'ruby', 'node']
17
+ const LABEL = { node: 'Node.js', python: 'Python', dotnet: '.NET', java: 'Java', go: 'Go', php: 'PHP', ruby: 'Ruby' }
18
+ const NODE_FW = { express: 'express', fastify: 'fastify', '@nestjs/core': 'nestjs', koa: 'koa', '@hapi/hapi': 'hapi',
19
+ nitropack: 'nitro', nitro: 'nitro', nuxt: 'nuxt', next: 'next' }
20
+ const PY_FW = ['fastapi', 'django', 'flask', 'starlette', 'falcon', 'tornado']
21
+ const PHP_FW = { 'laravel/framework': 'laravel', 'symfony/framework-bundle': 'symfony', 'slim/slim': 'slim' }
22
+ const TEXT_FW = {
23
+ java: [['spring-boot', /spring-boot|org\.springframework\.boot/], ['quarkus', /io\.quarkus/], ['micronaut', /io\.micronaut/]],
24
+ go: [['echo', /github\.com\/labstack\/echo\b/], ['gin', /github\.com\/gin-gonic\/gin\b/], ['chi', /github\.com\/go-chi\/chi\b/]],
25
+ ruby: [['rails', /^\s*gem\s*\(?\s*['"]rails['"]/m], ['sinatra', /^\s*gem\s*\(?\s*['"]sinatra['"]/m]],
26
+ }
27
+ /** Base images per language, as in the UKNOWS agent kit. The tag gives the runtime version. */
28
+ const IMAGE = {
29
+ python: /(?:^|\/)python(?:[:@]|$)/, node: /(?:^|\/)node(?:[:@]|$)/, go: /(?:^|\/)golang(?:[:@]|$)/,
30
+ dotnet: /mcr\.microsoft\.com\/dotnet\/(?:aspnet|sdk|runtime|runtime-deps)(?:[:@]|$)/,
31
+ java: /(?:^|\/)(?:eclipse-temurin|openjdk|amazoncorretto|ibm-semeru-runtimes|sapmachine)(?:[:@]|$)/,
32
+ php: /(?:^|\/)php(?:[:@]|$)/, ruby: /(?:^|\/)ruby(?:[:@]|$)/,
33
+ }
34
+ const DOCKERFILE = /^(?:Dockerfile(?:\.[^/]+)?|[^/]+\.[Dd]ockerfile)$/
35
+ /** Files that build or deploy images: workflows, compose files, scripts. A Dockerfile named in them with -f is the one that ships. */
36
+ const DEPLOY_FILE = new RegExp(['^(?:\\.\\./)*\\.github/workflows/[^/]+\\.ya?ml$', '(?:^|/)(?:docker-)?compose[^/]*\\.ya?ml$', '(?:^|/)(?:Makefile|Jenkinsfile)$',
37
+ '(?:^|/)[^/]+\\.(?:sh|ps1)$', '(?:^|/)(?:azure-pipelines|cloudbuild|buildspec|skaffold|\\.gitlab-ci|bitbucket-pipelines)\\.ya?ml$'].join('|'))
38
+ const DOCKERFILE_REF = /(?:^|\s)(?:-f|--file)(?:\s+|=)["']?([^\s"';&|)]+)|^\s*-?\s*(?:dockerfile|file|dockerfilePath)\s*:\s*["']?([^\s"'#]+)/gim
39
+ /** A final image that only serves files, and dependencies of browser apps: such a Node folder is a frontend. */
40
+ const STATIC_IMAGE = /(?:^|\/)(?:nginx|nginx-unprivileged|httpd|caddy|lighttpd|openresty)(?:[:@]|$)/
41
+ const FRONTEND_DEP = /^(?:react|react-dom|vue|svelte|@angular\/core|vite|preact|solid-js|react-scripts|parcel|@vue\/cli-service)$/
42
+ const DEV_SERVER = /^(?:vite|react-scripts|ng|vue-cli-service|parcel|webpack|webpack-dev-server|serve|http-server)$/
43
+ const SERVER_FW = new Set(['express', 'fastify', 'nestjs', 'koa', 'hapi', 'nitro', 'nuxt', 'next'])
44
+ const RUNS_NODE = /^(?:node|nodejs|npm|npx|yarn|pnpm)$|\.[cm]?js$/
45
+ const DEV_VARIANT = /(?:^|[.-])(?:dev|development|test|testing|local|debug|ci)(?:[.-]|$)/i
46
+ const WORKLOAD = /^kind:\s*['"]?(?:Deployment|StatefulSet|DaemonSet|Pod|Job|CronJob)['"]?\s*(?:#.*)?$/m
47
+
48
+ /**
49
+ * Find the services, stacks, start commands and hosts of the repo. Static reading only.
50
+ * @param {string} root absolute or relative path of the repo root
51
+ * @returns {import('./types.mjs').Detection}
52
+ */
53
+ export function detect(root) {
54
+ const ws = createWorkspace(root)
55
+ const view = repoView(ws)
56
+ const problems = []
57
+ const read = reader(ws, problems)
58
+ const files = ws.files()
59
+ if (ws.truncated) {
60
+ problems.push({ code: 'too-many-files', message: 'The repo has more than 20000 files. This tool read the first 20000 only.' })
61
+ }
62
+
63
+ // 1. Folders with a manifest, per language.
64
+ const candidates = new Map()
65
+ for (const path of files) {
66
+ const lang = manifestLang(path, read)
67
+ if (!lang) continue
68
+ const dir = /(?:^|\/)requirements\/[^/]+\.txt$/.test(path) ? dirOf(dirOf(path)) : dirOf(path)
69
+ if (!candidates.has(dir)) candidates.set(dir, new Map())
70
+ const langs = candidates.get(dir)
71
+ if (!langs.has(lang)) langs.set(lang, [])
72
+ langs.get(lang).push(path)
73
+ }
74
+ for (const [dir, langs] of candidates) {
75
+ const pkg = langs.has('node') ? json(read(join(dir, 'package.json'))) : null
76
+ if (pkg && (pkg.workspaces || files.includes(join(dir, 'pnpm-workspace.yaml'))) && !text(pkg.scripts?.start)) langs.delete('node')
77
+ if (langs.has('java')) {
78
+ // A Maven parent POM or a Gradle multi-project root only groups the real services below it.
79
+ const gradleRoot = ['settings.gradle', 'settings.gradle.kts'].some((f) => /^\s*include\b/m.test(read(join(dir, f)) ?? ''))
80
+ const kept = langs.get('java').filter((p) => (p.endsWith('pom.xml') ? !/<packaging>\s*pom\s*<\/packaging>/.test(read(p) ?? '') : !gradleRoot))
81
+ if (kept.length) langs.set('java', kept); else langs.delete('java')
82
+ }
83
+ if (!langs.size) candidates.delete(dir)
84
+ }
85
+
86
+ // 2. Dockerfiles, then 1 per candidate folder.
87
+ const dockerfiles = files
88
+ .filter((p) => DOCKERFILE.test(posix.basename(p)) && !DEV_VARIANT.test(posix.basename(p).replace(/dockerfile/i, '')))
89
+ .map((path) => ({ path, info: parseDockerfile(read(path) ?? '') }))
90
+ .filter((d) => d.info.instructions.some((x) => x.keyword === 'FROM'))
91
+ const dirs = [...candidates.keys()]
92
+ // Folders that look like services before any Dockerfile is assigned: a framework or a start. Libraries do not count.
93
+ const likely = dirs.filter((dir) => [...candidates.get(dir)].some(([lang, paths]) => frameworks(lang, paths, read).length)
94
+ || /^web\s*:/m.test(read(join(dir, 'Procfile')) ?? '') || text(json(read(join(dir, 'package.json')))?.scripts?.start))
95
+ const pool = likely.length ? likely : dirs
96
+
97
+ // 3. Build each service.
98
+ const k8s = files.filter((p) => /\.ya?ml$/.test(p) && !p.startsWith('.github/') && !/(?:^|\/)pnpm-lock\.yaml$/.test(p))
99
+ .flatMap((path) => { const t = read(path, true); return t && !t.includes('{{') ? containersOf(path, t) : [] })
100
+ let refs = null
101
+ const build = (dir, langs) => {
102
+ const service = { dir, lang: null, frameworks: [], manifests: [], name: '', nameFrom: 'folder', dockerfile: null, start: null,
103
+ runtimeVersion: null, k8s: [], problems: [], evidence: [] }
104
+ const docker = assignDockerfile(dir, { dirs, pool, refs: () => (refs ??= dockerfileRefs(view)) }, dockerfiles, service)
105
+ service.dockerfile = docker?.path ?? null
106
+ const found = new Map([...langs].map(([lang, paths]) => [lang, { paths: paths.sort(byCodePoint), fw: frameworks(lang, paths, read) }]))
107
+ service.lang = chooseLang(found, docker, service)
108
+ if (service.lang) {
109
+ const { paths, fw } = found.get(service.lang)
110
+ service.manifests = paths
111
+ service.frameworks = [...new Set(fw.map((f) => f.id))].sort(byCodePoint)
112
+ for (const path of paths) service.evidence.push({ file: path, what: `${LABEL[service.lang]} manifest` })
113
+ for (const f of fw) service.evidence.push({ file: f.file, what: `${f.dep} in dependencies` })
114
+ }
115
+ const start = findStart(service, docker, read)
116
+ service.start = start.start
117
+ if (start.start) service.evidence.push({ file: start.start.file, line: start.start.line, what: 'start command' })
118
+ else if (service.lang && !start.declared) service.problems.push({ code: 'start-unknown', message: `Found no start command for ${where(dir)}.` })
119
+ service.declared = start.declared
120
+ service.runtimeVersion = runtimeVersion(service, docker, read)
121
+ if (frontend(service, docker, read)) {
122
+ service.problems = [...service.problems.filter((p) => p.code !== 'start-unknown'),
123
+ { code: 'frontend', message: 'Looks like a frontend. UKNOWS traces server apps.' }]
124
+ }
125
+ return service
126
+ }
127
+ let services = dirs.map((dir) => build(dir, candidates.get(dir)))
128
+ const qualified = services.filter((s) => s.frameworks.length || s.declared || s.dockerfile)
129
+ if (qualified.length) {
130
+ // Say which folders are left out and why, so a Go net/http service without a Dockerfile is never skipped silently.
131
+ for (const s of services.filter((x) => x.lang && !qualified.includes(x))) {
132
+ problems.push({ code: 'skipped', message: `${where(s.dir)} has ${LABEL[s.lang]} files but no web framework, Dockerfile or start command, so this tool skips it. If it is a service, run npx @uknows/setup --path ${s.dir}, or follow the manual steps.` })
133
+ }
134
+ services = qualified
135
+ }
136
+ else if (services.some((s) => s.dir === '.')) services = services.filter((s) => s.dir === '.')
137
+ // Frontends are left out when there is a server app. Alone, they stay, so the CLI can say why it does nothing.
138
+ const isFrontend = (s) => s.problems.some((p) => p.code === 'frontend')
139
+ if (services.some((s) => !isFrontend(s))) {
140
+ for (const s of services.filter(isFrontend)) {
141
+ problems.push({ code: 'frontend', message: `${where(s.dir)} looks like a frontend. UKNOWS traces server apps, so this tool skips it.` })
142
+ }
143
+ services = services.filter((s) => !isFrontend(s))
144
+ }
145
+ if (!services.length) services = [build('.', new Map())]
146
+ services.sort((a, b) => byCodePoint(a.dir, b.dir))
147
+
148
+ // 4. Names and Kubernetes containers.
149
+ for (const service of services) {
150
+ delete service.declared
151
+ const folder = service.dir === '.' ? basename(ws.root) : posix.basename(service.dir)
152
+ const proposed = manifestName(service, read) ?? { name: toServiceName(folder) ?? 'app', from: 'folder' }
153
+ const keys = new Set([proposed.name, folder, toServiceName(folder)])
154
+ let matches = k8s.filter((c) => keys.has(c.name) || keys.has(imageName(c.image)))
155
+ if (!matches.length && services.length === 1 && k8s.length === 1) matches = k8s
156
+ service.k8s = matches.map(({ file, doc, name, env, command, args }) => ({ file, doc, container: name,
157
+ env: Object.fromEntries([...env].filter(([key]) => key)), command, args }))
158
+ const existing = existingName(service, matches, read)
159
+ Object.assign(service, existing ? { name: existing.name, nameFrom: 'existing' } : { name: proposed.name, nameFrom: proposed.from })
160
+ if (existing) service.evidence.push({ file: existing.file, what: 'OTEL_SERVICE_NAME is already set' })
161
+ }
162
+ return { root: ws.root, services, hosts: detectHosts(view), problems }
163
+ }
164
+
165
+ /**
166
+ * Turn any text into a valid OTEL_SERVICE_NAME ([a-z0-9-], 1 to 63 chars), or null when nothing is left.
167
+ * PascalCase is split: OrdersApi becomes orders-api, HTTPServer becomes http-server.
168
+ */
169
+ export function toServiceName(text) {
170
+ if (typeof text !== 'string') return null
171
+ const name = text.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
172
+ .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 63).replace(/-+$/, '')
173
+ return name || null
174
+ }
175
+
176
+ // --- manifests and frameworks ------------------------------------------------
177
+
178
+ function manifestLang(path, read) {
179
+ const name = posix.basename(path)
180
+ if (name === 'package.json') return 'node'
181
+ if (name === 'pyproject.toml' || name === 'Pipfile' || /^requirements.*\.txt$/.test(name) || /(?:^|\/)requirements\/[^/]+\.txt$/.test(path)) return 'python'
182
+ if (name === 'go.mod') return 'go'
183
+ if (name.endsWith('.csproj')) return /Microsoft\.NET\.Sdk\.Web/.test(read(path) ?? '') ? 'dotnet' : null
184
+ if (name === 'pom.xml' || name === 'build.gradle' || name === 'build.gradle.kts') return 'java'
185
+ if (name === 'composer.json') return 'php'
186
+ if (name === 'Gemfile') return 'ruby'
187
+ return null
188
+ }
189
+
190
+ /** Frameworks from declared dependencies only, never from ordinary words in the text. */
191
+ function frameworks(lang, paths, read) {
192
+ const out = []
193
+ for (const file of paths) {
194
+ const body = read(file) ?? ''
195
+ const add = (id, dep) => out.push({ id, dep, file })
196
+ if (lang === 'node') {
197
+ const pkg = json(body)
198
+ for (const group of ['dependencies', 'devDependencies', 'peerDependencies']) {
199
+ for (const dep of Object.keys(object(pkg?.[group]))) if (NODE_FW[dep]) add(NODE_FW[dep], dep)
200
+ }
201
+ } else if (lang === 'python') {
202
+ for (const dep of new Set(pythonDeps(file, body, read))) if (PY_FW.includes(dep)) add(dep, dep)
203
+ } else if (lang === 'php') {
204
+ for (const dep of Object.keys(object(json(body)?.require))) if (PHP_FW[dep]) add(PHP_FW[dep], dep)
205
+ } else if (lang === 'dotnet') add('aspnetcore', 'Microsoft.NET.Sdk.Web')
206
+ else for (const [id, re] of TEXT_FW[lang] ?? []) if (re.test(body.replace(/<!--[\s\S]*?-->|^\s*(?:#|\/\/).*$/gm, ''))) add(id, id)
207
+ }
208
+ return out
209
+ }
210
+
211
+ /** Normalized names (PEP 503) of the requirements in a requirements file, pyproject.toml or Pipfile. */
212
+ function pythonDeps(path, body, read, depth = 0) {
213
+ const name = posix.basename(path)
214
+ const norm = (s) => /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(s)?.[1].toLowerCase().replace(/[-_.]+/g, '-')
215
+ if (name === 'pyproject.toml') {
216
+ const poetry = tomlKeys(tomlTable(body, 'tool.poetry.dependencies')).filter((k) => k !== 'python')
217
+ return [...tomlArray(tomlTable(body, 'project'), 'dependencies'), ...poetry].map(norm).filter(Boolean)
218
+ }
219
+ if (name === 'Pipfile') return tomlKeys(tomlTable(body, 'packages')).map(norm).filter(Boolean)
220
+ const out = []
221
+ for (const raw of body.split(/\r?\n/)) {
222
+ const line = raw.replace(/(?:^|\s)#.*$/, '').trim()
223
+ const include = /^(?:-r|--requirement)(?:\s+|=)(\S+)$/.exec(line)
224
+ if (include && depth === 0) {
225
+ const target = posix.normalize(join(dirOf(path), include[1]))
226
+ if (!target.startsWith('..')) out.push(...pythonDeps(target, read(target) ?? '', read, 1))
227
+ } else if (line && !line.startsWith('-')) out.push(norm(line))
228
+ }
229
+ return out.filter(Boolean)
230
+ }
231
+
232
+ // --- Dockerfile, language, start --------------------------------------------
233
+
234
+ /**
235
+ * The Dockerfile that builds the service in dir, by the 4 rules in order. Adds dockerfile-ambiguous when it cannot tell.
236
+ * dirs: every folder with a manifest. pool: the folders that look like services. refs: Dockerfile names that deploy files pass to -f.
237
+ */
238
+ function assignDockerfile(dir, { dirs, pool, refs }, dockerfiles, service) {
239
+ const pick = (d, why) => { service.evidence.push({ file: d.path, what: `Dockerfile for this service: ${why}` }); return d }
240
+ const ambiguous = (list, same = false) => {
241
+ const names = list.map((d) => d.path).join(', ')
242
+ service.problems.push({ code: 'dockerfile-ambiguous', message: `Found ${list.length} Dockerfiles for ${where(dir)}: ${names}. ${same
243
+ ? 'This tool does not know which one you deploy. Name it with -f in a deploy workflow or script, or keep 1 Dockerfile in the folder.'
244
+ : 'Use --path to pick a folder, or keep 1 Dockerfile.'}` })
245
+ return null
246
+ }
247
+ const own = dockerfiles.filter((d) => dirOf(d.path) === dir)
248
+ if (own.length === 1) return pick(own[0], 'it is in the service folder')
249
+ if (own.length > 1) {
250
+ // Dockerfile and Dockerfile.production: the deploy files tell which one ships.
251
+ const named = own.filter((d) => refs().has(posix.basename(d.path)))
252
+ return named.length === 1 ? pick(named[0], `${refs().get(posix.basename(named[0].path))} builds it with -f`) : ambiguous(own, true)
253
+ }
254
+ for (let up = dir; up !== '.';) {
255
+ up = dirOf(up)
256
+ const here = dockerfiles.filter((d) => dirOf(d.path) === up)
257
+ if (!here.length) continue
258
+ if (here.length === 1 && pool.includes(dir) && pool.filter((d) => inside(up, d)).length === 1) {
259
+ return pick(here[0], 'it is the only Dockerfile above the service folder')
260
+ }
261
+ break
262
+ }
263
+ if (dir !== '.') {
264
+ const free = dockerfiles.filter((d) => !dirs.includes(dirOf(d.path)))
265
+ const copying = free.filter((d) => copySources(d)
266
+ .some((src) => inside(dir, src) || inside(dir, posix.normalize(join(dirOf(d.path), src)))))
267
+ if (copying.length === 1) return pick(copying[0], 'it copies the service folder')
268
+ if (copying.length > 1) return ambiguous(copying)
269
+ }
270
+ // The only Dockerfile, unless it sits in the folder of another manifest: then it builds that one.
271
+ if (dockerfiles.length === 1 && !dirs.includes(dirOf(dockerfiles[0].path)) && (pool.length === 0 || (pool.length === 1 && pool[0] === dir))) {
272
+ return pick(dockerfiles[0], 'it is the only Dockerfile in the repo')
273
+ }
274
+ return null
275
+ }
276
+
277
+ /** Source paths of the COPY and ADD instructions that read the build context (not --from). */
278
+ function copySources(dockerfile) {
279
+ const out = []
280
+ for (const x of dockerfile.info.instructions) {
281
+ if ((x.keyword !== 'COPY' && x.keyword !== 'ADD') || /(?:^|\s)--from=/.test(x.args)) continue
282
+ const rest = x.args.replace(/^(?:--\S+\s+)*/, '')
283
+ let words = rest.split(/\s+/).filter(Boolean)
284
+ if (rest.startsWith('[')) { try { const list = JSON.parse(rest); if (Array.isArray(list)) words = list } catch { /* shell form */ } }
285
+ for (const src of words.slice(0, -1)) {
286
+ const clean = String(src).replace(/^\.\//, '').replace(/\/(?:\*|\.)?$/, '')
287
+ if (clean && clean !== '.' && !/^[a-z]+:\/\//i.test(clean)) out.push(clean)
288
+ }
289
+ }
290
+ return out
291
+ }
292
+
293
+ /** Dockerfile names (basenames) that deploy files pass to docker build -f, or name in compose and CI files -> the first such file. */
294
+ function dockerfileRefs(view) {
295
+ const refs = new Map()
296
+ for (const file of view.files().filter((f) => DEPLOY_FILE.test(f))) {
297
+ let text = null
298
+ try { text = (view.stat(file)?.size ?? 0) > MAX_BYTES ? null : view.read(file) } catch { /* unreadable */ }
299
+ for (const m of (text ?? '').matchAll(DOCKERFILE_REF)) {
300
+ const name = posix.basename(m[1] ?? m[2])
301
+ if (DOCKERFILE.test(name) && !refs.has(name)) refs.set(name, file)
302
+ }
303
+ }
304
+ return refs
305
+ }
306
+
307
+ /**
308
+ * A Node folder that builds a browser app: its final image only serves files (nginx and the like), or it has no
309
+ * Dockerfile, no server framework and no start other than a dev server, and it uses a browser framework.
310
+ */
311
+ function frontend(service, docker, read) {
312
+ if (service.lang !== 'node') return false
313
+ if (docker) {
314
+ const start = dockerStart(docker.info)
315
+ const words = start?.argv ?? start?.raw.split(/[\s"',[\]]+/) ?? []
316
+ return STATIC_IMAGE.test(stageImages(docker.info).at(-1) ?? '') && !words.some((w) => RUNS_NODE.test(posix.basename(w)))
317
+ }
318
+ if (service.frameworks.some((f) => SERVER_FW.has(f))) return false
319
+ const pkg = json(read(join(service.dir, 'package.json')))
320
+ if (!Object.keys({ ...object(pkg?.dependencies), ...object(pkg?.devDependencies) }).some((d) => FRONTEND_DEP.test(d))) return false
321
+ return !service.start || DEV_SERVER.test(posix.basename(service.start.argv?.[0] ?? ''))
322
+ }
323
+
324
+ /** Resolved FROM images of every stage, in order. The last one is the image that runs. */
325
+ function stageImages(info) {
326
+ const stages = new Map(), out = []
327
+ for (const x of info.instructions.filter((i) => i.keyword === 'FROM')) {
328
+ const words = x.args.split(/\s+/).filter((w) => !w.startsWith('--'))
329
+ let image = words[0] ?? ''
330
+ for (let hop = 0; hop < 5 && stages.has(image.toLowerCase()); hop++) image = stages.get(image.toLowerCase())
331
+ if (words[1]?.toLowerCase() === 'as' && words[2]) stages.set(words[2].toLowerCase(), image)
332
+ out.push(image)
333
+ }
334
+ return out
335
+ }
336
+
337
+ /** 1 language per folder: the Dockerfile image decides, else the only one with a framework, else LANGS order. */
338
+ function chooseLang(found, docker, service) {
339
+ const langs = LANGS.filter((lang) => found.has(lang))
340
+ if (langs.length < 2) return langs[0] ?? null
341
+ const images = docker ? stageImages(docker.info).reverse() : []
342
+ const byImage = images.map((image) => langs.find((lang) => IMAGE[lang].test(image))).find(Boolean)
343
+ const withFw = langs.filter((lang) => found.get(lang).fw.length)
344
+ const lang = byImage ?? (withFw.length === 1 ? withFw[0] : (withFw[0] ?? langs[0]))
345
+ const why = byImage ? `the Dockerfile image is ${LABEL[lang]}` : withFw.length === 1 ? 'it has the framework' : 'no file decides'
346
+ const others = langs.filter((l) => l !== lang).map((l) => LABEL[l]).join(', ')
347
+ service.evidence.push({ file: found.get(lang).paths[0], what: `${LABEL[lang]} chosen over ${others} because ${why}` })
348
+ return lang
349
+ }
350
+
351
+ /** Dockerfile final stage, else exactly 1 Procfile web: line, else package.json scripts.start (Node only). */
352
+ function findStart(service, docker, read) {
353
+ if (docker) {
354
+ const start = dockerStart(docker.info)
355
+ if (start) return { declared: true, start: { kind: 'dockerfile', file: docker.path, ...start } }
356
+ }
357
+ const procfile = join(service.dir, 'Procfile')
358
+ const doc = new Doc(read(procfile) ?? '')
359
+ const webs = doc.lines.map((line, i) => (/^web\s*:/.test(line) ? i : -1)).filter((i) => i >= 0)
360
+ if (webs.length > 1) {
361
+ service.problems.push({ code: 'start-ambiguous', message: `${procfile} has ${webs.length} web: lines. This tool did not pick one.` })
362
+ return { declared: true, start: null }
363
+ }
364
+ if (webs.length === 1) {
365
+ const raw = doc.get(webs[0]).replace(/^web\s*:\s*/, '').trim()
366
+ return { declared: true, start: { kind: 'procfile', file: procfile, line: webs[0] + 1, form: 'shell', raw, argv: shellWords(raw) } }
367
+ }
368
+ if (service.lang !== 'node') return { declared: false, start: null }
369
+ const file = join(service.dir, 'package.json')
370
+ const body = read(file) ?? ''
371
+ const scripts = object(json(body)?.scripts)
372
+ if (!text(scripts.start)) return { declared: false, start: null }
373
+ if (scripts.prestart || scripts.poststart) {
374
+ service.problems.push({ code: 'start-hooks', message: `${file} has a prestart or poststart script. Check that it still works with OpenTelemetry.` })
375
+ }
376
+ const lines = new Doc(body)
377
+ const line = lines.find(/"start"\s*:/, Math.max(0, lines.find(/"scripts"\s*:/)))
378
+ return { declared: true, start: { kind: 'package.json', file, line: line + 1, form: 'shell', raw: scripts.start, argv: shellWords(scripts.start) } }
379
+ }
380
+
381
+ /** Exact versions only: the final FROM tag, else .python-version, TargetFramework, the go directive or .nvmrc. */
382
+ function runtimeVersion(service, docker, read) {
383
+ const { lang, dir, manifests } = service
384
+ if (!lang) return null
385
+ const image = docker ? stageImages(docker.info).at(-1) : ''
386
+ if (image && IMAGE[lang].test(image)) {
387
+ const tag = /:v?(\d+(?:\.\d+){0,2})(?![\d.])/.exec(image.replace(/@.*$/, '').split('/').pop())
388
+ if (tag) return tag[1]
389
+ }
390
+ const exact = (file, re) => re.exec((read(join(dir, file)) ?? '').trim())?.[1] ?? null
391
+ if (lang === 'python') return exact('.python-version', /^(\d+\.\d+(?:\.\d+)?)$/)
392
+ if (lang === 'node') return exact('.nvmrc', /^v?(\d+(?:\.\d+){0,2})$/) ?? exact('.node-version', /^v?(\d+(?:\.\d+){0,2})$/)
393
+ if (lang === 'go') return /^go\s+(\d+\.\d+(?:\.\d+)?)\s*$/m.exec(read(manifests[0]) ?? '')?.[1] ?? null
394
+ if (lang === 'dotnet') {
395
+ const net = (body) => /<TargetFrameworks?>\s*net(\d+\.\d+)/.exec(body ?? '')?.[1] ?? null
396
+ let version = net(read(manifests[0]))
397
+ for (let up = dirOf(manifests[0]); !version; up = dirOf(up)) {
398
+ version = net(read(join(up, 'Directory.Build.props')))
399
+ if (up === '.') break
400
+ }
401
+ return version
402
+ }
403
+ return null
404
+ }
405
+
406
+ // --- names --------------------------------------------------------------------
407
+
408
+ function manifestName(service, read) {
409
+ for (const path of service.manifests) {
410
+ const body = read(path) ?? ''
411
+ const file = posix.basename(path)
412
+ let value = null
413
+ if (file === 'package.json') value = text(json(body)?.name)?.replace(/^@[^/]+\//, '')
414
+ else if (file === 'pyproject.toml') value = tomlString(tomlTable(body, 'project'), 'name') ?? tomlString(tomlTable(body, 'tool.poetry'), 'name')
415
+ else if (file.endsWith('.csproj')) {
416
+ const assembly = /<AssemblyName>\s*([^<]+?)\s*<\/AssemblyName>/.exec(body)?.[1]
417
+ value = assembly && !assembly.includes('$') ? assembly : file.slice(0, -'.csproj'.length)
418
+ } else if (file === 'pom.xml') {
419
+ const own = body.replace(/<!--[\s\S]*?-->/g, '')
420
+ .replace(/<(parent|dependencies|dependencyManagement|build|profiles|plugins|reporting|modules)\b[\s\S]*?<\/\1>/g, '')
421
+ value = /<artifactId>\s*([^<$]+?)\s*<\/artifactId>/.exec(own)?.[1]
422
+ } else if (file === 'go.mod') value = /^module\s+"?([^"\s]+)/m.exec(body)?.[1].replace(/\/v\d+$/, '').split('/').pop()
423
+ else if (file === 'composer.json') value = text(json(body)?.name)?.split('/').pop()
424
+ if (toServiceName(value)) return { name: toServiceName(value), from: file }
425
+ }
426
+ const gradle = /rootProject\.name\s*=\s*['"]([^'"]+)['"]/
427
+ const more = service.lang === 'java' ? [['settings.gradle', gradle], ['settings.gradle.kts', gradle]]
428
+ : service.lang === 'ruby' ? [['config/application.rb', /^\s*module\s+([A-Z]\w*)/m]] : []
429
+ for (const [file, re] of more) {
430
+ const value = re.exec(read(join(service.dir, file)) ?? '')?.[1]
431
+ if (toServiceName(value)) return { name: toServiceName(value), from: posix.basename(file) }
432
+ }
433
+ return null
434
+ }
435
+
436
+ /** An OTEL_SERVICE_NAME already in the Dockerfile final stage (or a stage it builds on), <dir>/otel.env or the Kubernetes container env. */
437
+ function existingName(service, containers, read) {
438
+ const found = []
439
+ if (service.dockerfile) {
440
+ const info = parseDockerfile(read(service.dockerfile) ?? '')
441
+ found.push([service.dockerfile, (info.env.get('OTEL_SERVICE_NAME') ?? info.inherited.get('OTEL_SERVICE_NAME'))?.value])
442
+ }
443
+ const envFile = join(service.dir, SETTINGS_FILE.path)
444
+ const line = /^\s*(?:export\s+)?OTEL_SERVICE_NAME\s*=\s*['"]?([^'"\r\n#]*?)['"]?\s*$/m.exec(read(envFile) ?? '')
445
+ found.push([envFile, line?.[1]])
446
+ for (const c of containers) found.push([c.file, c.env.get('OTEL_SERVICE_NAME')])
447
+ for (const [file, value] of found) {
448
+ if (typeof value === 'string' && !value.includes('$') && toServiceName(value)) return { file, name: toServiceName(value) }
449
+ }
450
+ return null
451
+ }
452
+
453
+ const imageName = (image) => (image ?? '').replace(/@.*$/, '').replace(/:[^/]*$/, '').split('/').pop()
454
+
455
+ // --- Kubernetes YAML ------------------------------------------------------------
456
+
457
+ /**
458
+ * Containers of the workloads in 1 plain YAML file. `doc` is the 0-based
459
+ * document index: the number of '---' lines above the container. Only
460
+ * block-style YAML is read.
461
+ */
462
+ function containersOf(file, body) {
463
+ const out = []
464
+ const lines = body.split(/\r?\n/)
465
+ let doc = 0, from = 0
466
+ const scan = (to) => {
467
+ const part = lines.slice(from, to)
468
+ if (!WORKLOAD.test(part.join('\n')) || !/^apiVersion:/m.test(part.join('\n'))) return
469
+ const rows = part.filter((l) => l.trim() && !/^\s*#/.test(l)).map((l) => ({ indent: l.length - l.trimStart().length, text: l.trim() }))
470
+ rows.forEach((row, i) => {
471
+ if (!/^containers:\s*(?:#.*)?$/.test(row.text)) return
472
+ for (const item of listItems(rows, i)) {
473
+ const name = scalar(item, 'name')
474
+ const envAt = item.findIndex((r) => r.indent === item.top && /^env:\s*(?:#.*)?$/.test(r.text))
475
+ const env = new Map(envAt < 0 ? [] : listItems(item, envAt).map((e) => [scalar(e, 'name'), scalar(e, 'value')]))
476
+ const has = (key) => item.some((r) => r.indent === item.top && new RegExp(`^${key}:`).test(r.text))
477
+ if (name) out.push({ file, doc, name, image: scalar(item, 'image'), env, command: has('command'), args: has('args') })
478
+ }
479
+ })
480
+ }
481
+ lines.forEach((line, i) => { if (/^---/.test(line)) { scan(i); doc++; from = i + 1 } })
482
+ scan(lines.length)
483
+ return out
484
+ }
485
+
486
+ /** The items of the block list under rows[at]. Each item is its rows, with `top` = the indent of its keys. */
487
+ function listItems(rows, at) {
488
+ const items = []
489
+ let dash = -1
490
+ for (let i = at + 1; i < rows.length; i++) {
491
+ const { indent, text: line } = rows[i]
492
+ const isDash = /^-(?:\s|$)/.test(line)
493
+ if (dash < 0) { if (!isDash || indent < rows[at].indent) break; dash = indent }
494
+ if (indent < dash || (indent === dash && !isDash)) break
495
+ if (indent === dash) {
496
+ const rest = line.slice(1), trimmed = rest.trimStart()
497
+ const item = trimmed ? [{ indent: indent + 1 + rest.length - trimmed.length, text: trimmed }] : []
498
+ items.push(Object.assign(item, { top: item[0]?.indent ?? -1 }))
499
+ } else {
500
+ const item = items[items.length - 1]
501
+ if (item.top < 0) item.top = indent
502
+ item.push(rows[i])
503
+ }
504
+ }
505
+ return items
506
+ }
507
+
508
+ function scalar(item, key) {
509
+ const row = item.find((r) => r.indent === item.top && r.text.startsWith(`${key}:`))
510
+ if (!row) return null
511
+ const value = row.text.slice(key.length + 1).replace(/\s+#.*$/, '').trim()
512
+ return /^(['"])(.*)\1$/.exec(value)?.[2] ?? (value || null)
513
+ }
514
+
515
+ // --- small TOML reader (tables, strings, string arrays, keys) -----------------
516
+
517
+ function tomlTable(body, header) {
518
+ const out = []
519
+ let on = false
520
+ for (const line of body.split(/\r?\n/)) {
521
+ const h = /^\s*\[([^\][]+)\]\s*(?:#.*)?$/.exec(line)
522
+ if (h) { on = h[1].trim() === header; continue }
523
+ if (/^\s*\[\[/.test(line)) { on = false; continue }
524
+ if (on) out.push(line)
525
+ }
526
+ return out.join('\n')
527
+ }
528
+ const tomlString = (table, key) => new RegExp(`^\\s*${key}\\s*=\\s*(["'])([^"'\\n]*)\\1`, 'm').exec(table)?.[2] ?? null
529
+ const tomlKeys = (table) => table.split('\n').map((l) => /^\s*"?([A-Za-z0-9_.-]+)"?\s*=/.exec(l)?.[1]).filter(Boolean)
530
+ function tomlArray(table, key) {
531
+ const m = new RegExp(`^\\s*${key}\\s*=\\s*\\[`, 'm').exec(table)
532
+ if (!m) return []
533
+ const out = []
534
+ let quote = '', value = ''
535
+ for (const c of table.slice(m.index + m[0].length)) {
536
+ if (quote) { if (c === quote) { out.push(value); quote = ''; value = '' } else value += c }
537
+ else if (c === '"' || c === "'") quote = c
538
+ else if (c === ']') break
539
+ }
540
+ return out
541
+ }
542
+
543
+ // --- helpers ------------------------------------------------------------------
544
+
545
+ /**
546
+ * Reads repo files once. Missing files read as null. Files over 1 MB read as
547
+ * null too, with a problem unless `quiet` (for files that are only scanned).
548
+ */
549
+ function reader(ws, problems) {
550
+ const cache = new Map()
551
+ return (path, quiet = false) => {
552
+ if (!cache.has(path)) {
553
+ let body = null, big = false
554
+ try { big = (ws.stat(path)?.size ?? 0) > MAX_BYTES; body = big ? null : ws.read(path) } catch { body = null }
555
+ cache.set(path, { body, big, told: false })
556
+ }
557
+ const entry = cache.get(path)
558
+ if (entry.big && !quiet && !entry.told) {
559
+ entry.told = true
560
+ problems.push({ code: 'file-too-large', message: `${path} is larger than 1 MB. This tool did not read it.` })
561
+ }
562
+ return entry.body
563
+ }
564
+ }
565
+
566
+ const dirOf = (path) => posix.dirname(path)
567
+ const join = (dir, file) => (dir === '.' ? file : `${dir}/${file}`)
568
+ const inside = (dir, path) => dir === '.' || path === dir || path.startsWith(`${dir}/`)
569
+ const where = (dir) => (dir === '.' ? 'the repo root' : dir)
570
+ const text = (value) => (typeof value === 'string' && value.trim() ? value : null)
571
+ const object = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : {})
572
+ const byCodePoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0)
573
+ function json(body) {
574
+ try { const value = JSON.parse(body ?? ''); return value && typeof value === 'object' && !Array.isArray(value) ? value : null } catch { return null }
575
+ }