@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.
- package/LICENSE +190 -0
- package/README.md +142 -2
- package/bin/uknows-setup.mjs +14 -0
- package/lib/check.mjs +211 -0
- package/lib/cli.mjs +166 -0
- package/lib/contract-docs.mjs +1154 -0
- package/lib/contract.mjs +66 -0
- package/lib/detect.mjs +575 -0
- package/lib/diff.mjs +147 -0
- package/lib/doc.mjs +98 -0
- package/lib/dockerfile.mjs +372 -0
- package/lib/endpoint.mjs +50 -0
- package/lib/engine.mjs +607 -0
- package/lib/existing.mjs +168 -0
- package/lib/git.mjs +47 -0
- package/lib/hosts.mjs +145 -0
- package/lib/manifest.mjs +112 -0
- package/lib/settings.mjs +280 -0
- package/lib/stacks/dotnet.mjs +165 -0
- package/lib/stacks/guided.mjs +104 -0
- package/lib/stacks/index.mjs +10 -0
- package/lib/stacks/java.mjs +56 -0
- package/lib/stacks/k8s.mjs +31 -0
- package/lib/stacks/node.mjs +235 -0
- package/lib/stacks/otel.mjs +11 -0
- package/lib/stacks/python-bootstrap.mjs +51 -0
- package/lib/stacks/python.mjs +434 -0
- package/lib/types.mjs +161 -0
- package/lib/ui.mjs +106 -0
- package/lib/workspace.mjs +198 -0
- package/package.json +42 -4
package/lib/contract.mjs
ADDED
|
@@ -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
|
+
}
|