@uknows/setup 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -32
- package/lib/automate.mjs +1299 -0
- package/lib/check.mjs +149 -33
- package/lib/ci.mjs +21 -0
- package/lib/cli.mjs +114 -29
- package/lib/cloudid.mjs +456 -0
- package/lib/code.mjs +147 -0
- package/lib/context.mjs +126 -0
- package/lib/contract-docs.mjs +1780 -198
- package/lib/contract.mjs +93 -10
- package/lib/detect.mjs +96 -44
- package/lib/dockerfile.mjs +83 -0
- package/lib/endpoint.mjs +36 -5
- package/lib/engine.mjs +1272 -126
- package/lib/existing.mjs +448 -54
- package/lib/git.mjs +53 -0
- package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
- package/lib/hooks/pins.mjs +28 -0
- package/lib/hooks/uknows-otel.cjs +82 -0
- package/lib/hooks/uknows_otel.py +179 -0
- package/lib/hosts.mjs +521 -16
- package/lib/locks.mjs +353 -0
- package/lib/manifest.mjs +27 -7
- package/lib/members.mjs +269 -0
- package/lib/plan.mjs +130 -0
- package/lib/settings.mjs +216 -28
- package/lib/signin.mjs +259 -0
- package/lib/stacks/azure.mjs +345 -0
- package/lib/stacks/channel.mjs +125 -0
- package/lib/stacks/dotnet.mjs +336 -29
- package/lib/stacks/functions-hooks.mjs +228 -0
- package/lib/stacks/functions.mjs +148 -0
- package/lib/stacks/guided.mjs +153 -30
- package/lib/stacks/index.mjs +10 -2
- package/lib/stacks/java.mjs +14 -13
- package/lib/stacks/node.mjs +142 -19
- package/lib/stacks/otel.mjs +56 -4
- package/lib/stacks/python-bootstrap.mjs +27 -7
- package/lib/stacks/python-lock.mjs +105 -0
- package/lib/stacks/python-sets.mjs +143 -0
- package/lib/stacks/python.mjs +261 -63
- package/lib/toml.mjs +64 -0
- package/lib/types.mjs +52 -5
- package/lib/ui.mjs +139 -20
- package/lib/updates.mjs +398 -0
- package/lib/versions.mjs +115 -0
- package/lib/workspace.mjs +4 -1
- package/package.json +3 -8
package/lib/cloudid.mjs
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The cloud id of a container app, in the command that deploys it. Each deploy then sets OTEL_RESOURCE_ATTRIBUTES on the
|
|
3
|
+
* app: cloud.provider, cloud.platform, cloud.region (when known) and cloud.resource_id. With cloud.resource_id UKNOWS
|
|
4
|
+
* links the data of the app to that exact resource, and the gateway keeps only a keyed hash of it. The replica host
|
|
5
|
+
* name (host.name) still tells the revisions apart.
|
|
6
|
+
*
|
|
7
|
+
* Only where setup knows the exact id: the subscription, resource group and app that the deploy command names itself,
|
|
8
|
+
* written as the command writes them (a value, a shell variable or a workflow expression), so at deploy time the id is
|
|
9
|
+
* the one of the app that the command deploys. Never a guess, and never over a value the customer set:
|
|
10
|
+
* - the command sets OTEL_RESOURCE_ATTRIBUTES itself: the attributes it lacks go at the end of its value, and its own
|
|
11
|
+
* ones stay as they are;
|
|
12
|
+
* - --replace-env-vars, or environmentVariables of the deploy action: the pair goes on that list. The list holds every
|
|
13
|
+
* variable of the app, so nothing set elsewhere is lost;
|
|
14
|
+
* - --set-env-vars or no env option: only when az shows that no app the command can deploy has
|
|
15
|
+
* OTEL_RESOURCE_ATTRIBUTES (names only, never values), with 1 container or the one that the command names. The pair
|
|
16
|
+
* goes on the --set-env-vars list, never in a second --set-env-vars: az keeps only the last list (az 2.87.0, measured
|
|
17
|
+
* 22 Oct 2026).
|
|
18
|
+
* Nothing when a Dockerfile of the services sets OTEL_RESOURCE_ATTRIBUTES (the setting on the app would hide it), and
|
|
19
|
+
* nothing when the subscription is only in a secret: setup never copies a secret into the settings of an app.
|
|
20
|
+
* cloud.region only when az found the app and the deploy file names its subscription as a value.
|
|
21
|
+
*/
|
|
22
|
+
import { Doc } from './doc.mjs'
|
|
23
|
+
import { unique } from './ui.mjs'
|
|
24
|
+
|
|
25
|
+
export const RESOURCE_ATTRIBUTES = 'OTEL_RESOURCE_ATTRIBUTES'
|
|
26
|
+
/** cloud.platform of Container Apps in the OpenTelemetry semantic conventions (1.44.0). */
|
|
27
|
+
export const ACA_PLATFORM = 'azure.container_apps'
|
|
28
|
+
const ACA_TYPE = 'Microsoft.App/containerApps'
|
|
29
|
+
const UPDATE = /\baz\s+containerapp\s+update\b/
|
|
30
|
+
/** A shell word as written: workflow expressions, quoted parts, escapes and plain characters. */
|
|
31
|
+
const WORD = /(?:\$\{\{[^}]*\}\}|"(?:[^"\\]|\\.)*"|'[^']*'|\\.|[^\s"'\\])+/g
|
|
32
|
+
const OPERATOR = /^(?:&&|\|\||;|\||&|[0-9]?>.*|<.*|#.*)$/
|
|
33
|
+
const OPTION = /^--?[A-Za-z]/
|
|
34
|
+
const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
35
|
+
const WHY = 'Tell UKNOWS which app in Azure the data comes from.'
|
|
36
|
+
/** facts for a dry run in a terminal: the plan of a real run, as if az showed no OTEL_RESOURCE_ATTRIBUTES on the app. */
|
|
37
|
+
export const PREDICT = 'predict'
|
|
38
|
+
|
|
39
|
+
/** The attributes for an app: [key, value] pairs, the region only when known. */
|
|
40
|
+
export function cloudAttributes({ subscription, group, app, region = null }) {
|
|
41
|
+
return [['cloud.provider', 'azure'], ['cloud.platform', ACA_PLATFORM], ...(region ? [['cloud.region', region]] : []),
|
|
42
|
+
['cloud.resource_id', `/subscriptions/${subscription}/resourceGroups/${group}/providers/${ACA_TYPE}/${app}`]]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* value with the attributes of ours that it lacks at its end. Its own keys and values stay as they are. null when the
|
|
47
|
+
* value is not a plain list of key=value pairs (a variable, an empty pair), so setup cannot add to it safely.
|
|
48
|
+
* '' when nothing is missing.
|
|
49
|
+
*/
|
|
50
|
+
export function mergeAttributes(value, ours) {
|
|
51
|
+
const items = value.replace(/,+\s*$/, '').split(',')
|
|
52
|
+
if (!items.every((item) => /^\s*[\w.-]+\s*=\s*\S/.test(item))) return null
|
|
53
|
+
const keys = new Set(items.map((item) => item.slice(0, item.indexOf('=')).trim()))
|
|
54
|
+
return ours.filter(([key]) => !keys.has(key)).map(([key, v]) => `${key}=${v}`).join(',')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The text between matching quotes, or the word itself when it has no quotes. null when the quotes are mixed. */
|
|
58
|
+
function unquote(text) {
|
|
59
|
+
const m = /^(["'])(.*)\1$/.exec(text)
|
|
60
|
+
if (m) return { bare: m[2], quote: m[1] }
|
|
61
|
+
return /["']/.test(text) ? null : { bare: text, quote: '' }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A name as the command writes it, as text to put inside "...": a value, a shell variable ($NAME or ${NAME}, not in a
|
|
66
|
+
* workflow input) or a workflow expression that is not a secret. null for anything else.
|
|
67
|
+
*/
|
|
68
|
+
function nameText(word, { shell = true } = {}) {
|
|
69
|
+
const u = unquote(word)
|
|
70
|
+
if (!u) return null
|
|
71
|
+
if (/^[A-Za-z0-9][\w.()-]*$/.test(u.bare)) return { text: u.bare, literal: u.bare }
|
|
72
|
+
const variable = /^\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))$/.exec(u.bare)
|
|
73
|
+
if (variable && shell && u.quote !== "'") return { text: `\${${variable[1] ?? variable[2]}}`, ref: variable[1] ?? variable[2] }
|
|
74
|
+
const expression = /^\$\{\{\s*([A-Za-z_][\w.-]*)\s*\}\}$/.exec(u.bare)
|
|
75
|
+
if (expression && !/^secrets\./.test(expression[1])) return { text: `\${{ ${expression[1]} }}`, ref: /^env\.(\w+)$/.exec(expression[1])?.[1] ?? null }
|
|
76
|
+
if (expression) return { secret: true }
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The variables that a file sets for itself (KEY: value in YAML, export KEY=value or KEY=value in a script). */
|
|
81
|
+
function fileVariables(doc) {
|
|
82
|
+
const vars = new Map()
|
|
83
|
+
for (const line of doc.lines) {
|
|
84
|
+
const m = /^\s*([A-Z][A-Z0-9_]*)\s*:\s*(.+?)\s*$/.exec(line) ?? /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)=(\S+)\s*$/.exec(line)
|
|
85
|
+
if (m && !vars.has(m[1])) vars.set(m[1], m[2].replace(/\s+#.*$/, '').replace(/^["']|["']$/g, ''))
|
|
86
|
+
}
|
|
87
|
+
return vars
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The value that a name has in the file: itself, or the value of the variable it names (3 hops at most). null if unknown. */
|
|
91
|
+
function literalOf(name, vars) {
|
|
92
|
+
if (!name || name.secret) return null
|
|
93
|
+
if (name.literal) return name.literal
|
|
94
|
+
let ref = name.ref
|
|
95
|
+
for (let hop = 0; hop < 3 && ref; hop++) {
|
|
96
|
+
const v = vars.get(ref)
|
|
97
|
+
if (v === undefined) return null
|
|
98
|
+
if (/^[A-Za-z0-9][\w.()-]*$/.test(v)) return v
|
|
99
|
+
ref = /^\$\{\{\s*env\.(\w+)\s*\}\}$/.exec(v)?.[1] ?? /^\$\{?([A-Za-z_]\w*)\}?$/.exec(v)?.[1] ?? null
|
|
100
|
+
}
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The rows of the job that row i is in (a GitHub workflow), as text. null outside a job. */
|
|
105
|
+
function jobText(doc, i) {
|
|
106
|
+
const indent = (l) => /^\s*/.exec(l)[0].length
|
|
107
|
+
let top = i
|
|
108
|
+
while (top > 0 && !/^\s*steps\s*:/.test(doc.get(top))) top--
|
|
109
|
+
if (!/^\s*steps\s*:/.test(doc.get(top) ?? '')) return null
|
|
110
|
+
let job = top
|
|
111
|
+
while (job > 0 && !(doc.get(job).trim() && indent(doc.get(job)) < indent(doc.get(top)))) job--
|
|
112
|
+
let end = top + 1
|
|
113
|
+
while (end < doc.length && (!doc.get(end).trim() || indent(doc.get(end)) > indent(doc.get(job)))) end++
|
|
114
|
+
return doc.lines.slice(job, end).join('\n')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The subscription that azure/login signs in to in the job of row i: its subscription-id input. */
|
|
118
|
+
function loginSubscription(doc, i) {
|
|
119
|
+
const job = jobText(doc, i)
|
|
120
|
+
const raw = job ? /^\s*subscription-id\s*:\s*(.+?)\s*(?:#.*)?$/m.exec(job)?.[1] : null
|
|
121
|
+
return raw ? nameText(raw, { shell: false }) : null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Each az containerapp update command of a file: its first and last row and its words, with their rows and columns. */
|
|
125
|
+
export function updateCommands(doc) {
|
|
126
|
+
const out = []
|
|
127
|
+
for (let i = 0; i < doc.length; i++) {
|
|
128
|
+
if (/^\s*#/.test(doc.get(i)) || !UPDATE.test(doc.get(i))) continue
|
|
129
|
+
let end = i
|
|
130
|
+
while (end + 1 < doc.length && /\\\s*$/.test(doc.get(end))) end++
|
|
131
|
+
const words = []
|
|
132
|
+
for (let row = i; row <= end; row++) {
|
|
133
|
+
const text = doc.get(row).replace(/\\\s*$/, '')
|
|
134
|
+
for (const m of text.matchAll(WORD)) words.push({ text: m[0], row, start: m.index, end: m.index + m[0].length })
|
|
135
|
+
}
|
|
136
|
+
const at = words.findIndex((w, k) => w.text === 'az' && words[k + 1]?.text === 'containerapp' && words[k + 2]?.text === 'update')
|
|
137
|
+
if (at >= 0) {
|
|
138
|
+
const stop = words.findIndex((w, k) => k > at && OPERATOR.test(w.text))
|
|
139
|
+
out.push({ first: i, last: end, rows: doc.lines.slice(i, end + 1), words: words.slice(at, stop < 0 ? words.length : stop) })
|
|
140
|
+
}
|
|
141
|
+
i = end
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The options of a command: name -> {word, values}. --name=value counts too. */
|
|
147
|
+
function optionsOf(words) {
|
|
148
|
+
const options = new Map()
|
|
149
|
+
for (let k = 3; k < words.length; k++) {
|
|
150
|
+
const w = words[k]
|
|
151
|
+
const eq = /^(--?[A-Za-z][\w-]*)=(.*)$/.exec(w.text)
|
|
152
|
+
if (eq) {
|
|
153
|
+
const start = w.start + eq[1].length + 1
|
|
154
|
+
options.set(eq[1], { word: w, values: [{ ...w, text: eq[2], start }] })
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
if (!OPTION.test(w.text)) continue
|
|
158
|
+
const values = []
|
|
159
|
+
let j = k + 1
|
|
160
|
+
while (j < words.length && !OPTION.test(words[j].text)) values.push(words[j++])
|
|
161
|
+
options.set(w.text, { word: w, values })
|
|
162
|
+
k = j - 1
|
|
163
|
+
}
|
|
164
|
+
return options
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The deploy steps of container apps in the deploy files that the hosts name: each az containerapp update command, and
|
|
169
|
+
* each deploy action with environmentVariables on 1 line. Each spot: {file, row, kind ('az' or 'action'), names
|
|
170
|
+
* ({subscription, group, app, container} as nameText), list ('replace', 'set' or null), why (when setup cannot add the
|
|
171
|
+
* id there)}. Read only.
|
|
172
|
+
*/
|
|
173
|
+
export function cloudIdSpots(view, hosts) {
|
|
174
|
+
const files = unique((hosts ?? []).filter((h) => h.id === 'azure-container-apps').flatMap((h) => h.evidence ?? [])
|
|
175
|
+
.filter((e) => e.what === 'Deploy step runs az containerapp' || e.what === 'Azure Container Apps deploy action').map((e) => e.file))
|
|
176
|
+
.filter((f) => !f.startsWith('../') && !/\.ps1$/i.test(f))
|
|
177
|
+
const spots = []
|
|
178
|
+
for (const file of files) {
|
|
179
|
+
let text = null
|
|
180
|
+
try { text = view.read(file) } catch { text = null }
|
|
181
|
+
if (text === null) continue
|
|
182
|
+
const doc = new Doc(text)
|
|
183
|
+
const vars = fileVariables(doc)
|
|
184
|
+
for (const c of updateCommands(doc)) {
|
|
185
|
+
const options = optionsOf(c.words)
|
|
186
|
+
const get = (...names) => names.map((n) => options.get(n)).find(Boolean) ?? null
|
|
187
|
+
const one = (option) => (option && option.values.length === 1 ? nameText(option.values[0].text) : null)
|
|
188
|
+
const names = { subscription: one(get('--subscription')) ?? loginSubscription(doc, c.first), group: one(get('--resource-group', '-g')),
|
|
189
|
+
app: one(get('--name', '-n')), container: one(get('--container-name')) }
|
|
190
|
+
const replace = get('--replace-env-vars'), set = get('--set-env-vars')
|
|
191
|
+
const why = get('--ids') ? 'ids' : replace && set ? 'both-lists' : names.subscription?.secret ? 'secret-subscription'
|
|
192
|
+
: !names.subscription ? 'no-subscription' : !names.group || names.group.secret ? 'no-group' : !names.app || names.app.secret ? 'no-app' : null
|
|
193
|
+
spots.push({ file, row: c.first, kind: 'az', command: c, names, vars, list: replace ? 'replace' : set ? 'set' : null, why,
|
|
194
|
+
app: literalOf(names.app, vars), group: literalOf(names.group, vars), subscription: literalOf(names.subscription, vars) })
|
|
195
|
+
}
|
|
196
|
+
for (let i = 0; i < doc.length; i++) {
|
|
197
|
+
const m = /^(\s*environmentVariables\s*:\s*)(.*?)((?:\s+#.*)?)\s*$/.exec(doc.get(i))
|
|
198
|
+
if (!m || /^\s*#/.test(doc.get(i))) continue
|
|
199
|
+
const step = stepOf(doc, i)
|
|
200
|
+
if (!step.some((l) => /^\s*(?:-\s+)?uses\s*:\s*['"]?azure\/container-apps-deploy-action@/.test(l))) continue
|
|
201
|
+
const input = (key) => { const raw = step.map((l) => new RegExp(`^\\s*${key}\\s*:\\s*(.+?)\\s*(?:#.*)?$`).exec(l)?.[1]).find(Boolean); return raw ? nameText(raw, { shell: false }) : null }
|
|
202
|
+
const names = { subscription: loginSubscription(doc, i), group: input('resourceGroup'), app: input('containerAppName'), container: null }
|
|
203
|
+
const value = m[2]
|
|
204
|
+
const why = !value || /^[|>]/.test(value) ? 'value-form' : names.subscription?.secret ? 'secret-subscription' : !names.subscription ? 'no-subscription'
|
|
205
|
+
: !names.group || names.group.secret ? 'no-group' : !names.app || names.app.secret ? 'no-app' : null
|
|
206
|
+
spots.push({ file, row: i, kind: 'action', names, vars, list: 'replace', why,
|
|
207
|
+
app: literalOf(names.app, vars), group: literalOf(names.group, vars), subscription: literalOf(names.subscription, vars) })
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return spots
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The rows of the workflow step that row i is in. */
|
|
214
|
+
function stepOf(doc, i) {
|
|
215
|
+
const indent = (l) => /^\s*/.exec(l)[0].length
|
|
216
|
+
let start = i
|
|
217
|
+
while (start >= 0 && !/^\s*-\s/.test(doc.get(start))) start--
|
|
218
|
+
if (start < 0) return []
|
|
219
|
+
const pad = indent(doc.get(start))
|
|
220
|
+
const rows = [doc.get(start)]
|
|
221
|
+
for (let j = start + 1; j < doc.length && (!doc.get(j).trim() || indent(doc.get(j)) > pad); j++) rows.push(doc.get(j))
|
|
222
|
+
return rows
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** True when the spot needs az to show that the app has no OTEL_RESOURCE_ATTRIBUTES of its own. */
|
|
226
|
+
const needsAzure = (spot) => !spot.why && spot.kind === 'az' && spot.list !== 'replace' && !setsAttributes(spot)
|
|
227
|
+
|
|
228
|
+
/** The value word of OTEL_RESOURCE_ATTRIBUTES on the env list of an az spot, or null. */
|
|
229
|
+
function attributesWord(spot) {
|
|
230
|
+
if (spot.kind !== 'az' || !spot.list) return null
|
|
231
|
+
const options = optionsOf(spot.command.words)
|
|
232
|
+
const list = options.get(spot.list === 'replace' ? '--replace-env-vars' : '--set-env-vars')
|
|
233
|
+
return list?.values.find((w) => (unquote(w.text)?.bare ?? w.text.replace(/["']/g, '')).startsWith(`${RESOURCE_ATTRIBUTES}=`)) ?? null
|
|
234
|
+
}
|
|
235
|
+
const setsAttributes = (spot) => Boolean(attributesWord(spot))
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The apps that az must look up for the spots, as {app, group, subscription}: group and subscription null when the
|
|
239
|
+
* deploy file does not name them as values.
|
|
240
|
+
*/
|
|
241
|
+
export function cloudIdLookups(spots) {
|
|
242
|
+
const out = []
|
|
243
|
+
for (const spot of spots.filter((s) => !s.why && s.app && (needsAzure(s) || s.subscription))) {
|
|
244
|
+
if (!out.some((x) => x.app === spot.app && x.group === spot.group && x.subscription === spot.subscription)) {
|
|
245
|
+
out.push({ app: spot.app, group: spot.group, subscription: spot.subscription })
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return out
|
|
249
|
+
}
|
|
250
|
+
const factKey = (x) => `${x.app}|${x.group}|${x.subscription}`
|
|
251
|
+
|
|
252
|
+
/** fn for each item, at most n at a time, in order. */
|
|
253
|
+
async function inTurn(items, n, fn) {
|
|
254
|
+
const out = new Array(items.length)
|
|
255
|
+
let next = 0
|
|
256
|
+
const worker = async () => { while (next < items.length) { const i = next++; out[i] = await fn(items[i]) } }
|
|
257
|
+
await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker))
|
|
258
|
+
return out
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const parse = (text) => { try { return JSON.parse(text) } catch { return null } }
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* What az shows of the container apps that the deploy commands name, read only. For each lookup: the apps of that name
|
|
265
|
+
* (in that group and subscription when the file names them) in the subscriptions that az can see. Only when that is 1
|
|
266
|
+
* app, az shows its containers with the names of their settings (never values), in the subscription of that app, as
|
|
267
|
+
* the key store does. Map of lookup -> {found: [{sub, group, name, id, location}], app: the 1 app with containers, or
|
|
268
|
+
* null}. null when az is not installed or not signed in. Every call but az account list names the subscription.
|
|
269
|
+
*/
|
|
270
|
+
export async function cloudIdFacts(cli, { root, env }, lookups) {
|
|
271
|
+
if (!lookups.length) return null
|
|
272
|
+
const az = (args) => cli('az', [...args, '--only-show-errors'], { cwd: root, env, timeoutMs: 60000 })
|
|
273
|
+
const accounts = await az(['account', 'list', '--query', "[?state=='Enabled'].{id:id, name:name}", '-o', 'json'])
|
|
274
|
+
if (accounts.error === 'missing' || accounts.code !== 0) return null
|
|
275
|
+
const subs = (parse(accounts.stdout) ?? []).filter((s) => typeof s?.id === 'string' && GUID.test(s.id))
|
|
276
|
+
if (!subs.length) return null
|
|
277
|
+
const facts = new Map()
|
|
278
|
+
for (const lookup of lookups) {
|
|
279
|
+
const { app, group, subscription } = lookup
|
|
280
|
+
const where = subscription ? subs.filter((s) => s.id.toLowerCase() === subscription.toLowerCase()) : subs
|
|
281
|
+
const answers = await inTurn(where, 4, (sub) => az(['resource', 'list', '--subscription', sub.id, '--name', app, '--resource-type', ACA_TYPE,
|
|
282
|
+
'--query', '[].{id:id, name:name, resourceGroup:resourceGroup, location:location}', '-o', 'json']))
|
|
283
|
+
if (answers.some((a) => a.code !== 0)) { facts.set(factKey(lookup), null); continue }
|
|
284
|
+
const found = []
|
|
285
|
+
where.forEach((sub, i) => {
|
|
286
|
+
for (const row of parse(answers[i].stdout) ?? []) {
|
|
287
|
+
if (typeof row?.name !== 'string' || row.name.toLowerCase() !== app.toLowerCase() || typeof row.resourceGroup !== 'string') continue
|
|
288
|
+
if (group && row.resourceGroup.toLowerCase() !== group.toLowerCase()) continue
|
|
289
|
+
found.push({ sub: sub.id, group: row.resourceGroup, name: row.name, id: row.id, location: typeof row.location === 'string' ? row.location : null })
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
let one = null
|
|
293
|
+
if (found.length === 1) {
|
|
294
|
+
const f = found[0]
|
|
295
|
+
const shown = await az(['containerapp', 'show', '--subscription', f.sub, '-g', f.group, '-n', f.name,
|
|
296
|
+
'--query', 'properties.template.containers[].{name:name, env:env[].name}', '-o', 'json'])
|
|
297
|
+
const containers = shown.code === 0 ? parse(shown.stdout) : null
|
|
298
|
+
if (Array.isArray(containers)) one = { ...f, containers: containers.map((c) => ({ name: String(c?.name ?? ''), env: Array.isArray(c?.env) ? c.env.map(String) : [] })) }
|
|
299
|
+
}
|
|
300
|
+
facts.set(factKey(lookup), { found, app: one })
|
|
301
|
+
}
|
|
302
|
+
return facts
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Whether setup may set the variable with --set-env-vars on the apps that az found for a spot. Each app must lack it in
|
|
307
|
+
* the container that the command updates. {ok, apps} or {ok: false, why}.
|
|
308
|
+
*/
|
|
309
|
+
function azureAllows(spot, facts) {
|
|
310
|
+
if (facts === PREDICT) return spot.app ? { ok: true, predicted: true } : { ok: false, why: 'not-checked' }
|
|
311
|
+
const fact = facts?.get?.(factKey(spot))
|
|
312
|
+
if (!fact) return { ok: false, why: 'not-checked' }
|
|
313
|
+
if (!fact.found.length) return { ok: false, why: 'not-found' }
|
|
314
|
+
if (fact.found.length > 1) return { ok: false, why: 'not-unique' }
|
|
315
|
+
if (!fact.app) return { ok: false, why: 'not-checked' }
|
|
316
|
+
const container = literalOf(spot.names.container, spot.vars)
|
|
317
|
+
if (spot.names.container && !container) return { ok: false, why: 'container' }
|
|
318
|
+
const c = container ? fact.app.containers.find((x) => x.name === container) : fact.app.containers.length === 1 ? fact.app.containers[0] : null
|
|
319
|
+
if (!c) return { ok: false, why: 'container' }
|
|
320
|
+
if (c.env.includes(RESOURCE_ATTRIBUTES)) return { ok: false, why: 'has-own' }
|
|
321
|
+
return { ok: true }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The region of the app, when the deploy file names its subscription as a value and az found that 1 app. */
|
|
325
|
+
function regionOf(spot, facts) {
|
|
326
|
+
if (!spot.subscription || !spot.app || facts === PREDICT) return null
|
|
327
|
+
const found = facts?.get?.(factKey(spot))?.found ?? []
|
|
328
|
+
return found.length === 1 && /^[a-z0-9]+$/.test(found[0].location ?? '') ? found[0].location : null
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The az containerapp update command whose first row is row, or null. */
|
|
332
|
+
export function updateCommandAt(doc, row) {
|
|
333
|
+
return updateCommands(doc).find((c) => c.first === row) ?? null
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The change that puts pair (its text as the shell reads it) on the --set-env-vars list of a command: after the last
|
|
338
|
+
* value of the list, never in a second --set-env-vars (az keeps only the last list). A command with no list gets one:
|
|
339
|
+
* on a row of its own after the first row when the command goes over several rows, else after update.
|
|
340
|
+
*/
|
|
341
|
+
export function setEnvChange(command, pair) {
|
|
342
|
+
const list = optionsOf(command.words).get('--set-env-vars')
|
|
343
|
+
if (list) {
|
|
344
|
+
const after = list.values.at(-1) ?? list.word
|
|
345
|
+
return { row: after.row, start: after.end, end: after.end, text: ` ${pair}` }
|
|
346
|
+
}
|
|
347
|
+
if (command.last > command.first && command.rows[1] !== undefined) {
|
|
348
|
+
return { insertAfter: command.first, text: `${/^\s*/.exec(command.rows[1])[0]}--set-env-vars ${pair} \\` }
|
|
349
|
+
}
|
|
350
|
+
const update = command.words[2]
|
|
351
|
+
return { row: update.row, start: update.end, end: update.end, text: ` --set-env-vars ${pair}` }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Apply changes to a Doc, from the end, so the rows and columns of the ones before stay right. */
|
|
355
|
+
export function applyChanges(doc, changes) {
|
|
356
|
+
const rowOf = (c) => (c.insertAfter ?? c.row)
|
|
357
|
+
for (const c of [...changes].sort((a, b) => rowOf(b) - rowOf(a) || (b.start ?? 0) - (a.start ?? 0))) {
|
|
358
|
+
if (c.insertAfter !== undefined) { doc.insert(c.insertAfter + 1, c.text); continue }
|
|
359
|
+
const line = doc.get(c.row)
|
|
360
|
+
doc.set(c.row, `${line.slice(0, c.start)}${c.text}${line.slice(c.end)}`)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** A pair for the shell: "OTEL_RESOURCE_ATTRIBUTES=...". */
|
|
365
|
+
const shellPair = (attributes) => `"${RESOURCE_ATTRIBUTES}=${attributes.map(([k, v]) => `${k}=${v}`).join(',')}"`
|
|
366
|
+
|
|
367
|
+
/** The word with the missing attributes added inside it, keeping its quotes. null when setup cannot add them safely. */
|
|
368
|
+
function grownWord(text, missing) {
|
|
369
|
+
if (/^[^"']*"[^"]*"$/.test(text)) return `${text.slice(0, -1)},${missing}"`
|
|
370
|
+
if (!/["'\\]/.test(text)) return `"${text},${missing}"`
|
|
371
|
+
if (/^[^"']*'[^']*'$/.test(text)) return `${text}",${missing}"`
|
|
372
|
+
return null
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The edits that add the cloud id to the deploy steps of container apps, and 1 summary line each. dockerfiles: the
|
|
377
|
+
* Dockerfiles of the services; when one sets OTEL_RESOURCE_ATTRIBUTES, nothing changes. Returns {edits, summary, skipped}:
|
|
378
|
+
* skipped lists {file, row, why} for each deploy step that keeps its text.
|
|
379
|
+
*/
|
|
380
|
+
export function cloudIdPlan(view, spots, facts, { dockerfiles = [] } = {}) {
|
|
381
|
+
const out = { edits: [], summary: [], skipped: [] }
|
|
382
|
+
const own = dockerfiles.some((file) => { try { return new RegExp(`\\b${RESOURCE_ATTRIBUTES}\\b`).test(view.read(file) ?? '') } catch { return false } })
|
|
383
|
+
const byFile = new Map()
|
|
384
|
+
for (const spot of spots) {
|
|
385
|
+
const skip = (why) => out.skipped.push({ file: spot.file, row: spot.row + 1, why })
|
|
386
|
+
if (own) { skip('dockerfile-sets-it'); continue }
|
|
387
|
+
if (spot.why) { skip(spot.why); continue }
|
|
388
|
+
const attributes = cloudAttributes({ subscription: spot.names.subscription.text, group: spot.names.group.text, app: spot.names.app.text, region: regionOf(spot, facts) })
|
|
389
|
+
let change = null
|
|
390
|
+
if (spot.kind === 'action') {
|
|
391
|
+
change = actionChange(view, spot, attributes)
|
|
392
|
+
if (change?.none) continue
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
const word = attributesWord(spot)
|
|
396
|
+
if (word) {
|
|
397
|
+
const value = (unquote(word.text)?.bare ?? word.text.replace(/["']/g, '')).slice(RESOURCE_ATTRIBUTES.length + 1)
|
|
398
|
+
const missing = mergeAttributes(value, attributes)
|
|
399
|
+
if (missing === null) { skip('own-value'); continue }
|
|
400
|
+
if (missing === '') continue
|
|
401
|
+
const grown = grownWord(word.text, missing)
|
|
402
|
+
if (!grown) { skip('own-value'); continue }
|
|
403
|
+
change = { row: word.row, start: word.start, end: word.end, text: grown }
|
|
404
|
+
} else if (spot.list === 'replace') {
|
|
405
|
+
const list = optionsOf(spot.command.words).get('--replace-env-vars')
|
|
406
|
+
const after = list.values.at(-1) ?? list.word
|
|
407
|
+
change = { row: after.row, start: after.end, end: after.end, text: ` ${shellPair(attributes)}` }
|
|
408
|
+
} else {
|
|
409
|
+
const allowed = azureAllows(spot, facts)
|
|
410
|
+
if (!allowed.ok) { skip(allowed.why); continue }
|
|
411
|
+
change = { ...setEnvChange(spot.command, shellPair(attributes)), predicted: Boolean(allowed.predicted) }
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (!change) { skip('value-form'); continue }
|
|
415
|
+
if (!byFile.has(spot.file)) byFile.set(spot.file, [])
|
|
416
|
+
byFile.get(spot.file).push({ ...change, spot })
|
|
417
|
+
}
|
|
418
|
+
for (const [file, changes] of byFile) {
|
|
419
|
+
const before = view.read(file)
|
|
420
|
+
const doc = new Doc(before)
|
|
421
|
+
applyChanges(doc, changes)
|
|
422
|
+
const after = doc.toString()
|
|
423
|
+
if (after === before) continue
|
|
424
|
+
out.edits.push({ file, before, after, why: WHY })
|
|
425
|
+
for (const c of changes) {
|
|
426
|
+
const app = c.spot.app ? `the container app ${c.spot.app}` : 'the container app that it deploys'
|
|
427
|
+
const first = c.predicted ? ` A real run first checks in Azure that the app has no ${RESOURCE_ATTRIBUTES} of its own.` : ''
|
|
428
|
+
out.summary.push(`${file} line ${c.spot.row + 1}: each deploy sets ${RESOURCE_ATTRIBUTES} with the Azure resource id of ${app}, so UKNOWS knows which app in Azure the data comes from.${first}`)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return out
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The change for environmentVariables of the deploy action: the pair at the end of its list, or the missing attributes in it. */
|
|
435
|
+
function actionChange(view, spot, attributes) {
|
|
436
|
+
const doc = new Doc(view.read(spot.file))
|
|
437
|
+
const line = doc.get(spot.row)
|
|
438
|
+
const m = /^(\s*environmentVariables\s*:\s*)(.*?)((?:\s+#.*)?)\s*$/.exec(line)
|
|
439
|
+
if (!m) return null
|
|
440
|
+
const quoted = /^(["'])(.*)\1$/.exec(m[2])
|
|
441
|
+
if (!quoted && /["']/.test(m[2])) return null
|
|
442
|
+
const inner = quoted ? quoted[2] : m[2]
|
|
443
|
+
const begin = m[1].length + (quoted ? 1 : 0)
|
|
444
|
+
// The pairs of the list, with their columns in the line.
|
|
445
|
+
const pairs = [...inner.matchAll(/\S+/g)].map((p) => ({ text: p[0], start: begin + p.index, end: begin + p.index + p[0].length }))
|
|
446
|
+
const own = pairs.find((p) => p.text.startsWith(`${RESOURCE_ATTRIBUTES}=`))
|
|
447
|
+
const plain = attributes.map(([k, v]) => `${k}=${v}`).join(',')
|
|
448
|
+
if (own) {
|
|
449
|
+
const missing = mergeAttributes(own.text.slice(RESOURCE_ATTRIBUTES.length + 1), attributes)
|
|
450
|
+
if (missing === null) return null
|
|
451
|
+
if (missing === '') return { none: true }
|
|
452
|
+
return { row: spot.row, start: own.end, end: own.end, text: `,${missing}` }
|
|
453
|
+
}
|
|
454
|
+
const end = begin + inner.length
|
|
455
|
+
return { row: spot.row, start: end, end, text: `${inner ? ' ' : ''}${RESOURCE_ATTRIBUTES}=${plain}` }
|
|
456
|
+
}
|
package/lib/code.mjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Read and edit app source code as text, for the few lines the Azure Monitor mode adds. Strings and comments are
|
|
3
|
+
* masked first, so a search finds code only. Every edit keeps the other lines byte for byte (doc.mjs), and the
|
|
4
|
+
* caller refuses anything that is not plain: then the code goes into a printed step instead.
|
|
5
|
+
*/
|
|
6
|
+
import { Doc } from './doc.mjs'
|
|
7
|
+
|
|
8
|
+
/** Python comments and string literals (any prefix, single, double and triple quotes). */
|
|
9
|
+
const PY_TOKEN = /#[^\r\n]*|(?<![\w])[rRbBuUfFtT]{0,2}(?:'''[\s\S]*?'''|"""[\s\S]*?"""|'(?:\\[\s\S]|[^'\\\r\n])*'|"(?:\\[\s\S]|[^"\\\r\n])*")/g
|
|
10
|
+
/** C# comments, string and char literals: raw """...""", verbatim @"...", interpolated $"..." and plain. */
|
|
11
|
+
const CS_TOKEN = /\/\/[^\r\n]*|\/\*[\s\S]*?\*\/|\$*"""[\s\S]*?"""|(?:@\$?|\$@)"(?:""|[^"])*"|\$?"(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])+'/g
|
|
12
|
+
|
|
13
|
+
/** The text with every comment and string replaced by spaces. Line breaks stay, so offsets and lines still match. */
|
|
14
|
+
export const maskPython = (text) => mask(text, PY_TOKEN)
|
|
15
|
+
export const maskCsharp = (text) => mask(text, CS_TOKEN)
|
|
16
|
+
|
|
17
|
+
function mask(text, re) {
|
|
18
|
+
return text.replace(new RegExp(re.source, 'g'), (m) => m.replace(/[^\r\n]/g, ' '))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const lineAt = (text, index) => text.slice(0, index).split('\n').length - 1
|
|
22
|
+
|
|
23
|
+
/** The offset of the bracket that closes the one at open, in masked text. -1 when there is none. */
|
|
24
|
+
function closing(masked, open) {
|
|
25
|
+
let depth = 0
|
|
26
|
+
for (let i = open; i < masked.length; i++) {
|
|
27
|
+
const c = masked[i]
|
|
28
|
+
if (c === '(' || c === '[' || c === '{') depth++
|
|
29
|
+
else if (c === ')' || c === ']' || c === '}') { if (--depth === 0) return i }
|
|
30
|
+
}
|
|
31
|
+
return -1
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Bracket depth at an offset of masked text. */
|
|
35
|
+
function depthAt(masked, end) {
|
|
36
|
+
let depth = 0
|
|
37
|
+
for (let i = 0; i < end; i++) {
|
|
38
|
+
const c = masked[i]
|
|
39
|
+
if (c === '(' || c === '[' || c === '{') depth++
|
|
40
|
+
else if (c === ')' || c === ']' || c === '}') depth = Math.max(0, depth - 1)
|
|
41
|
+
}
|
|
42
|
+
return depth
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Calls of a Python function in code, not its def or a mention in a string or comment.
|
|
47
|
+
* @returns {{index: number, line: number}[]} index of the first character of the call (with its module prefix), 0-based line
|
|
48
|
+
*/
|
|
49
|
+
export function pythonCalls(text, name) {
|
|
50
|
+
const masked = maskPython(text)
|
|
51
|
+
const re = new RegExp(`(^|[^\\w.])((?:[A-Za-z_]\\w*\\s*\\.\\s*)*)${name}\\s*\\(`, 'g')
|
|
52
|
+
const out = []
|
|
53
|
+
for (const m of masked.matchAll(re)) {
|
|
54
|
+
const index = m.index + m[1].length
|
|
55
|
+
if (/\bdef\s+$/.test(masked.slice(Math.max(0, index - 20), index))) continue
|
|
56
|
+
out.push({ index, line: lineAt(text, index) })
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The statement of 1 call, when it is a whole statement on its own: only indentation (and an optional
|
|
63
|
+
* `name =`) before it, nothing but a comment after its closing parenthesis, no `;`, no `\` and no open
|
|
64
|
+
* bracket around it.
|
|
65
|
+
* @returns {{ok: true, start: number, end: number, indent: string} | {ok: false, reason: string}} 0-based lines.
|
|
66
|
+
*/
|
|
67
|
+
export function pythonStatement(text, call) {
|
|
68
|
+
const masked = maskPython(text)
|
|
69
|
+
const lineStart = masked.lastIndexOf('\n', call.index - 1) + 1
|
|
70
|
+
const before = masked.slice(lineStart, call.index)
|
|
71
|
+
if (!/^[ \t]*(?:[A-Za-z_]\w*(?:\s*:\s*[\w.[\], ]+)?\s*=\s*)?$/.test(before)) {
|
|
72
|
+
return { ok: false, reason: before.includes(';') ? 'a ; is on its line' : /:\s*$/.test(before.trim()) || /^\s*(if|elif|else|for|while|with|try|except|finally)\b/.test(before) ? 'it is on the same line as an if or another statement' : 'it is part of a longer statement' }
|
|
73
|
+
}
|
|
74
|
+
const prev = masked.slice(0, Math.max(0, lineStart - 1)).replace(/[ \t\r]+$/, '')
|
|
75
|
+
if (prev.endsWith('\\')) return { ok: false, reason: 'the line before it ends with \\' }
|
|
76
|
+
if (depthAt(masked, lineStart) > 0) return { ok: false, reason: 'it is inside brackets' }
|
|
77
|
+
const open = masked.indexOf('(', call.index)
|
|
78
|
+
const close = closing(masked, open)
|
|
79
|
+
if (close < 0) return { ok: false, reason: 'its closing parenthesis is missing' }
|
|
80
|
+
const lineEnd = masked.indexOf('\n', close)
|
|
81
|
+
const after = masked.slice(close + 1, lineEnd < 0 ? masked.length : lineEnd)
|
|
82
|
+
if (after.trim()) return { ok: false, reason: after.includes(';') ? 'a ; follows it' : after.includes('\\') ? 'its line ends with \\' : 'more code follows it on the same line' }
|
|
83
|
+
return { ok: true, start: lineAt(text, call.index), end: lineAt(text, close), indent: /^[ \t]*/.exec(text.slice(lineStart))[0] }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Insert a Python block after a statement, indented to it. At module level it gets 2 blank lines before it and
|
|
88
|
+
* before its last line (the call), as PEP 8 and the formatters want. In a function or block it gets 1.
|
|
89
|
+
* block: the docs text, whose last line is the call. quote: '"' or "'".
|
|
90
|
+
*/
|
|
91
|
+
export function insertPythonBlock(text, statement, block, quote = '"') {
|
|
92
|
+
const doc = new Doc(text)
|
|
93
|
+
const lines = block.split('\n')
|
|
94
|
+
const call = lines.pop()
|
|
95
|
+
while (lines.length && !lines.at(-1).trim()) lines.pop()
|
|
96
|
+
const gap = statement.indent ? 1 : 2
|
|
97
|
+
const fix = (line) => (quote === "'" ? line.replace(/"/g, "'") : line)
|
|
98
|
+
const indented = (line) => (line.trim() ? statement.indent + fix(line) : '')
|
|
99
|
+
doc.insert(statement.end + 1, ...Array(gap).fill(''), ...lines.map(indented), ...Array(gap).fill(''), indented(call))
|
|
100
|
+
return doc.toString()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The 1 statement `var builder = WebApplication.CreateBuilder(...);` of a C# file, with the variable name.
|
|
105
|
+
* @returns {{ok: true, end: number, indent: string, name: string} | {ok: false, reason: string}}
|
|
106
|
+
*/
|
|
107
|
+
export function csharpBuilder(text) {
|
|
108
|
+
const masked = maskCsharp(text)
|
|
109
|
+
const hits = [...masked.matchAll(/WebApplication\s*\.\s*Create(?:Slim)?Builder\s*\(/g)]
|
|
110
|
+
if (hits.length !== 1) return { ok: false, reason: hits.length ? `it calls WebApplication.CreateBuilder ${hits.length} times` : 'it does not call WebApplication.CreateBuilder' }
|
|
111
|
+
const lineStart = masked.lastIndexOf('\n', hits[0].index - 1) + 1
|
|
112
|
+
const head = /^([ \t]*)(?:var|WebApplicationBuilder)\s+([A-Za-z_]\w*)\s*=\s*$/.exec(masked.slice(lineStart, hits[0].index))
|
|
113
|
+
if (!head) return { ok: false, reason: 'the builder is not set in a plain statement' }
|
|
114
|
+
const close = closing(masked, masked.indexOf('(', hits[0].index))
|
|
115
|
+
const semi = close < 0 ? -1 : masked.indexOf(';', close)
|
|
116
|
+
if (semi < 0 || masked.slice(close + 1, semi).trim()) return { ok: false, reason: 'the statement does not end after the call' }
|
|
117
|
+
const lineEnd = masked.indexOf('\n', semi)
|
|
118
|
+
if (masked.slice(semi + 1, lineEnd < 0 ? masked.length : lineEnd).trim()) return { ok: false, reason: 'more code follows it on the same line' }
|
|
119
|
+
return { ok: true, end: lineAt(text, semi), indent: head[1], name: head[2] }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Add a using directive in sorted order (System namespaces first, as dotnet format does), or at the top when the
|
|
124
|
+
* file has none. Returns the text unchanged when the directive is there.
|
|
125
|
+
*/
|
|
126
|
+
export function addUsing(text, directive) {
|
|
127
|
+
const ns = /^using\s+([\w.]+)\s*;$/.exec(directive)?.[1]
|
|
128
|
+
const doc = new Doc(text)
|
|
129
|
+
const usings = []
|
|
130
|
+
for (let i = 0; i < doc.length; i++) {
|
|
131
|
+
const m = /^using\s+([\w.]+)\s*;\s*$/.exec(doc.get(i))
|
|
132
|
+
if (m) usings.push({ i, ns: m[1] })
|
|
133
|
+
else if (doc.get(i).trim() && !/^\s*(\/\/|#|global\s+using\b|using\s+(static\s|\w+\s*=))/.test(doc.get(i))) break
|
|
134
|
+
}
|
|
135
|
+
if (usings.some((u) => u.ns === ns) || new RegExp(`^\\s*global\\s+using\\s+${ns.replace(/\./g, '\\.')}\\s*;`, 'm').test(text)) return text
|
|
136
|
+
if (!usings.length) {
|
|
137
|
+
// At the first line of code, after a file header comment.
|
|
138
|
+
const first = doc.find((line) => line.trim() && !/^\s*\/\//.test(line))
|
|
139
|
+
doc.insert(Math.max(0, first), directive, '')
|
|
140
|
+
return doc.toString()
|
|
141
|
+
}
|
|
142
|
+
const key = (name) => [/^System(\.|$)/.test(name) ? 0 : 1, name]
|
|
143
|
+
const less = (a, b) => { const [x, y] = [key(a), key(b)]; return x[0] !== y[0] ? x[0] < y[0] : x[1].localeCompare(y[1], 'en', { sensitivity: 'base' }) < 0 }
|
|
144
|
+
const next = usings.find((u) => less(ns, u.ns))
|
|
145
|
+
doc.insert(next ? next.i : usings.at(-1).i + 1, directive)
|
|
146
|
+
return doc.toString()
|
|
147
|
+
}
|