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