@nitra/cursor 1.8.144 → 1.8.147

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.
@@ -18,6 +18,7 @@ import {
18
18
  findSharedMssqlRequestInText,
19
19
  findUnsafeMssqlQueryTemplateCallInText,
20
20
  findUnsafeMssqlDynamicSqlListInText,
21
+ findUnsafeMssqlInListUnparsedInText,
21
22
  isMssqlScanSourceFile
22
23
  } from './utils/mssql-pool-scan.mjs'
23
24
  import { walkDir } from './utils/walkDir.mjs'
@@ -100,8 +101,8 @@ function parseLeadingSemver(range) {
100
101
  }
101
102
 
102
103
  /**
103
- * @param {{ major: number, minor: number, patch: number }} a
104
- * @param {{ major: number, minor: number, patch: number }} b
104
+ * @param {{ major: number, minor: number, patch: number }} a перша semver
105
+ * @param {{ major: number, minor: number, patch: number }} b друга semver
105
106
  * @returns {boolean} true, якщо a >= b
106
107
  */
107
108
  function semverGte(a, b) {
@@ -110,6 +111,153 @@ function semverGte(a, b) {
110
111
  return a.patch >= b.patch
111
112
  }
112
113
 
114
+ /**
115
+ * Аудит одного package.json на dependencies.mssql: версія має бути >=12.5.0.
116
+ * Повертає інкремент для лічильників `{ found, bad }`.
117
+ * @param {string} rel шлях у людино-читабельному вигляді
118
+ * @param {unknown} parsed розпарений package.json
119
+ * @param {(msg: string) => void} pass pass callback
120
+ * @param {(msg: string) => void} fail fail callback
121
+ * @returns {{ found: 0 | 1, bad: 0 | 1 }} прирости лічильників
122
+ */
123
+ function auditMssqlVersionInPackageJson(rel, parsed, pass, fail) {
124
+ const range = getMssqlDependencyRange(asObject(parsed).dependencies)
125
+ if (!range) return { found: 0, bad: 0 }
126
+
127
+ const parsedVer = parseLeadingSemver(range)
128
+ if (!parsedVer) {
129
+ fail(`js-mssql: ${rel}: dependencies.mssql має нечитабельну версію: ${JSON.stringify(range)} (js-mssql.mdc)`)
130
+ return { found: 1, bad: 1 }
131
+ }
132
+ if (semverGte(parsedVer, MIN_MSSQL_VERSION)) {
133
+ pass(`js-mssql: ${rel}: dependencies.mssql ${JSON.stringify(range)} (>=12.5.0)`)
134
+ return { found: 1, bad: 0 }
135
+ }
136
+ fail(`js-mssql: ${rel}: dependencies.mssql ${JSON.stringify(range)} — має бути >=12.5.0 (js-mssql.mdc)`)
137
+ return { found: 1, bad: 1 }
138
+ }
139
+
140
+ /**
141
+ * Прогін усіх package.json: рахує знайдені mssql і ті, що не задовольняють мінімум.
142
+ * @param {string} repoRoot корінь репозиторію
143
+ * @param {string[]} pkgJsonPaths абсолютні шляхи до package.json
144
+ * @param {(msg: string) => void} pass pass callback
145
+ * @param {(msg: string) => void} fail fail callback
146
+ * @returns {Promise<{ found: number, bad: number }>} підсумкові лічильники
147
+ */
148
+ async function aggregateMssqlVersionsAcrossPackages(repoRoot, pkgJsonPaths, pass, fail) {
149
+ let found = 0
150
+ let bad = 0
151
+ for (const absPath of pkgJsonPaths) {
152
+ const rel = relative(repoRoot, absPath).split('\\').join('/')
153
+ let parsed
154
+ try {
155
+ parsed = JSON.parse(await readFile(absPath, 'utf8'))
156
+ } catch {
157
+ fail(`js-mssql: ${rel} — невалідний JSON`)
158
+ continue
159
+ }
160
+ const inc = auditMssqlVersionInPackageJson(rel, parsed, pass, fail)
161
+ found += inc.found
162
+ bad += inc.bad
163
+ }
164
+ return { found, bad }
165
+ }
166
+
167
+ /**
168
+ * Підрахунок порушень у одному файлі джерела mssql.
169
+ * Кожен лічильник інкрементується відповідним сканером, повідомлення йдуть у `fail`.
170
+ * @param {string} rel relative-шлях файлу
171
+ * @param {string} content вихідний код
172
+ * @param {Record<string, number>} counters агрегатор лічильників
173
+ * @param {(msg: string) => void} fail fail callback
174
+ */
175
+ function scanMssqlOneSourceFile(rel, content, counters, fail) {
176
+ for (const v of findMssqlPerRequestConnectionInText(content, rel)) {
177
+ counters.violations++
178
+ fail(
179
+ `js-mssql: ${rel}:${v.line} — не створюй new sql.ConnectionPool(...) на кожен запит; використовуй singleton sql.ConnectionPool: ${v.snippet}`
180
+ )
181
+ }
182
+ for (const v of findSharedMssqlRequestInText(content, rel)) {
183
+ counters.sharedRequestViolations++
184
+ fail(
185
+ `js-mssql: ${rel}:${v.line} — заборонено шарити Request (наприклад export const request = pool.request()); створюй pool.request() щоразу заново (js-mssql.mdc): ${v.snippet}`
186
+ )
187
+ }
188
+ for (const v of findUnsafeMssqlQueryTemplateCallInText(content, rel)) {
189
+ counters.unsafeQueryCalls++
190
+ fail(
191
+ `js-mssql: ${rel}:${v.line} — заборонено query(\`...\`): це не tagged template; використовуй pool.request().query\`...\` (js-mssql.mdc): ${v.snippet}`
192
+ )
193
+ }
194
+ for (const v of findUnsafeMssqlDynamicSqlListInText(content, rel)) {
195
+ counters.unsafeDynamicSqlLists++
196
+ fail(
197
+ `js-mssql: ${rel}:${v.line} — заборонено підставляти у SQL динамічні списки через .join(',') (типово IN (...) / VALUES (...)); використовуй TVP (sql.Table) + JOIN/INSERT (js-mssql.mdc): ${v.snippet}`
198
+ )
199
+ }
200
+ for (const v of findUnsafeMssqlInListUnparsedInText(content, rel)) {
201
+ counters.unparsedInLists++
202
+ fail(
203
+ `js-mssql: ${rel}:${v.line} — у SQL IN (\${...}) значення мають бути попередньо приведені числовим парсером (parseInt/Number/BigInt/parseFloat) і відфільтровані від NaN, інакше можливий SQL injection (js-mssql.mdc): ${v.snippet}`
204
+ )
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Звіт про відсутність порушень у джерелах mssql: кожен лічильник із 0 → один pass-рядок.
210
+ * @param {Record<string, number>} counters лічильники після проходу всіх файлів
211
+ * @param {(msg: string) => void} pass pass callback
212
+ */
213
+ function reportZeroMssqlSourceViolations(counters, pass) {
214
+ if (counters.violations === 0) {
215
+ pass('js-mssql: немає створення new sql.ConnectionPool(...) всередині функцій (singleton pool)')
216
+ }
217
+ if (counters.sharedRequestViolations === 0) {
218
+ pass('js-mssql: немає shared Request (export const request = pool.request())')
219
+ }
220
+ if (counters.unsafeQueryCalls === 0) {
221
+ pass('js-mssql: немає небезпечних викликів query(`...`) (потрібно query`...`)')
222
+ }
223
+ if (counters.unsafeDynamicSqlLists === 0) {
224
+ pass("js-mssql: немає небезпечних динамічних SQL-списків через .join(',') у IN/VALUES")
225
+ }
226
+ if (counters.unparsedInLists === 0) {
227
+ pass(`js-mssql: немає підстановок IN (\${...}) без числового парсера значень`)
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Аудит усіх JS/TS-джерел репо щодо безпечного використання mssql.
233
+ * @param {string} repoRoot корінь репозиторію
234
+ * @param {(msg: string) => void} pass pass callback
235
+ * @param {(msg: string) => void} fail fail callback
236
+ * @returns {Promise<void>}
237
+ */
238
+ async function auditMssqlSources(repoRoot, pass, fail) {
239
+ const sourcePaths = await findAllSourcePathsForMssqlScan(repoRoot)
240
+ if (sourcePaths.length === 0) {
241
+ pass('js-mssql: немає JS/TS файлів для скану singleton ConnectionPool')
242
+ return
243
+ }
244
+
245
+ const counters = {
246
+ violations: 0,
247
+ sharedRequestViolations: 0,
248
+ unsafeQueryCalls: 0,
249
+ unsafeDynamicSqlLists: 0,
250
+ unparsedInLists: 0
251
+ }
252
+ for (const absPath of sourcePaths) {
253
+ const rel = relative(repoRoot, absPath).split('\\').join('/')
254
+ const content = await readFile(absPath, 'utf8')
255
+ scanMssqlOneSourceFile(rel, content, counters, fail)
256
+ }
257
+
258
+ reportZeroMssqlSourceViolations(counters, pass)
259
+ }
260
+
113
261
  /**
114
262
  * Перевіряє відповідність проєкту правилу js-mssql.mdc
115
263
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
@@ -119,8 +267,7 @@ export async function check() {
119
267
  const { pass, fail } = reporter
120
268
 
121
269
  const repoRoot = process.cwd()
122
- const rootPkg = join(repoRoot, 'package.json')
123
- if (!existsSync(rootPkg)) {
270
+ if (!existsSync(join(repoRoot, 'package.json'))) {
124
271
  pass('js-mssql: package.json у корені відсутній — перевірку пропущено')
125
272
  return reporter.getExitCode()
126
273
  }
@@ -131,94 +278,17 @@ export async function check() {
131
278
  return reporter.getExitCode()
132
279
  }
133
280
 
134
- let found = 0
135
- let bad = 0
136
- for (const absPath of pkgJsonPaths) {
137
- const rel = relative(repoRoot, absPath).split('\\').join('/')
138
- let parsed
139
- try {
140
- parsed = JSON.parse(await readFile(absPath, 'utf8'))
141
- } catch {
142
- fail(`js-mssql: ${rel} — невалідний JSON`)
143
- continue
144
- }
145
- const range = getMssqlDependencyRange(parsed.dependencies)
146
- if (range) {
147
- found++
148
- const parsedVer = parseLeadingSemver(range)
149
- if (!parsedVer) {
150
- bad++
151
- fail(`js-mssql: ${rel}: dependencies.mssql має нечитабельну версію: ${JSON.stringify(range)} (js-mssql.mdc)`)
152
- continue
153
- }
154
- if (semverGte(parsedVer, MIN_MSSQL_VERSION)) {
155
- pass(`js-mssql: ${rel}: dependencies.mssql ${JSON.stringify(range)} (>=12.5.0)`)
156
- } else {
157
- bad++
158
- fail(`js-mssql: ${rel}: dependencies.mssql ${JSON.stringify(range)} — має бути >=12.5.0 (js-mssql.mdc)`)
159
- }
160
- }
161
- }
281
+ const { found, bad } = await aggregateMssqlVersionsAcrossPackages(repoRoot, pkgJsonPaths, pass, fail)
162
282
 
163
283
  if (found === 0) {
164
284
  pass('js-mssql: пакет mssql не знайдено в dependencies жодного package.json')
165
- } else if (bad === 0) {
285
+ return reporter.getExitCode()
286
+ }
287
+ if (bad === 0) {
166
288
  pass(`js-mssql: всі знайдені dependencies.mssql відповідають мінімальній версії 12.5.0 (${found})`)
167
289
  }
168
290
 
169
- if (found > 0) {
170
- const sourcePaths = await findAllSourcePathsForMssqlScan(repoRoot)
171
- if (sourcePaths.length === 0) {
172
- pass('js-mssql: немає JS/TS файлів для скану singleton ConnectionPool')
173
- return reporter.getExitCode()
174
- }
175
-
176
- let violations = 0
177
- let sharedRequestViolations = 0
178
- let unsafeQueryCalls = 0
179
- let unsafeDynamicSqlLists = 0
180
- for (const absPath of sourcePaths) {
181
- const rel = relative(repoRoot, absPath).split('\\').join('/')
182
- const content = await readFile(absPath, 'utf8')
183
- for (const v of findMssqlPerRequestConnectionInText(content, rel)) {
184
- violations++
185
- fail(
186
- `js-mssql: ${rel}:${v.line} — не створюй new sql.ConnectionPool(...) на кожен запит; використовуй singleton sql.ConnectionPool: ${v.snippet}`
187
- )
188
- }
189
- for (const v of findSharedMssqlRequestInText(content, rel)) {
190
- sharedRequestViolations++
191
- fail(
192
- `js-mssql: ${rel}:${v.line} — заборонено шарити Request (наприклад export const request = pool.request()); створюй pool.request() щоразу заново (js-mssql.mdc): ${v.snippet}`
193
- )
194
- }
195
- for (const v of findUnsafeMssqlQueryTemplateCallInText(content, rel)) {
196
- unsafeQueryCalls++
197
- fail(
198
- `js-mssql: ${rel}:${v.line} — заборонено query(\`...\`): це не tagged template; використовуй pool.request().query\`...\` (js-mssql.mdc): ${v.snippet}`
199
- )
200
- }
201
- for (const v of findUnsafeMssqlDynamicSqlListInText(content, rel)) {
202
- unsafeDynamicSqlLists++
203
- fail(
204
- `js-mssql: ${rel}:${v.line} — заборонено підставляти у SQL динамічні списки через .join(',') (типово IN (...) / VALUES (...)); використовуй TVP (sql.Table) + JOIN/INSERT (js-mssql.mdc): ${v.snippet}`
205
- )
206
- }
207
- }
208
-
209
- if (violations === 0) {
210
- pass('js-mssql: немає створення new sql.ConnectionPool(...) всередині функцій (singleton pool)')
211
- }
212
- if (sharedRequestViolations === 0) {
213
- pass('js-mssql: немає shared Request (export const request = pool.request())')
214
- }
215
- if (unsafeQueryCalls === 0) {
216
- pass('js-mssql: немає небезпечних викликів query(`...`) (потрібно query`...`)')
217
- }
218
- if (unsafeDynamicSqlLists === 0) {
219
- pass("js-mssql: немає небезпечних динамічних SQL-списків через .join(',') у IN/VALUES")
220
- }
221
- }
291
+ await auditMssqlSources(repoRoot, pass, fail)
222
292
 
223
293
  return reporter.getExitCode()
224
294
  }
@@ -67,9 +67,10 @@
67
67
  * **`HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS`** зі значенням **`"true"`** (приймається булеве `true`
68
68
  * або рядок `"true"`, без регістрової залежності).
69
69
  *
70
- * **HPA / PDB / topologySpreadConstraints для кожного Deployment:** у каталозі з **`Deployment`** поруч
71
- * обов'язкові **`hpa.yaml`** (`autoscaling/v2`, `HorizontalPodAutoscaler`, `scaleTargetRef.name` = ім'я Deployment)
72
- * і **`pdb.yaml`** (`policy/v1`, `PodDisruptionBudget`, `selector.matchLabels.app` = мітка `app` Deployment).
70
+ * **HPA / PDB / topologySpreadConstraints:** для кожного **`Deployment`** у шарі **`…/k8s/…/base/`** (будь-який
71
+ * `.yaml` у цьому каталозі, наприклад **`deploy.yaml`**, **`deployment.yaml`**) у тому ж каталозі поруч обов'язкові
72
+ * **`hpa.yaml`**, **`pdb.yaml`** та канонічні **topologySpreadConstraints**. Workload-и без Deployment (**CronJob**
73
+ * тощо) та каталоги поза **`…/base/`** цим блоком не охоплюються.
73
74
  * Env-залежні межі за сегментом після `/k8s/`: **dev-like** (`base`, `dev`, `*-qa`) — `minReplicas === 1`,
74
75
  * `maxReplicas === 1`, `minAvailable === 0`; **прод** (решта) — `minReplicas >= 2`, `maxReplicas >= 2`,
75
76
  * `minAvailable >= 1`. Сам Deployment має мати у `spec.template.spec.topologySpreadConstraints` запис
@@ -88,11 +89,11 @@
88
89
  * `replacements[].path` має вказувати на наявний у репозиторії файл (`.yaml` / `.yml`) або каталог; інакше
89
90
  * помилка `check k8s` (k8s.mdc).
90
91
  *
91
- * **HPA / PDB тільки з Deployment у `base`:** у дереві Kustomize з `…/k8s/…/base/kustomization.yaml` не
92
+ * **HPA / PDB тільки з Deployment у шарі base:** у дереві Kustomize з `…/k8s/…/base/kustomization.yaml` не
92
93
  * дозволяти `HorizontalPodAutoscaler` / `PodDisruptionBudget` у `resources` / `bases` / `components` / `crds`
93
- * (рекурсивно), якщо в цьому ж дереві немає `Deployment`. У `kustomization.yaml` overlay, який підключає
94
- * каталог `…/k8s/…/base`, не додавай окремі YAML-файли з HPA / PDB, поки в наслідуваному `base` у дереві
95
- * не з’явиться `Deployment` (k8s.mdc).
94
+ * (рекурсивно), якщо в цьому ж дереві немає документа **`Deployment`** у жодному YAML під **`…/k8s/…/base/`**. У
95
+ * `kustomization.yaml` overlay, який підключає каталог `…/k8s/…/base`, не додавай окремі YAML-файли з HPA / PDB,
96
+ * поки в наслідуваному `base` у дереві не з’явиться такий Deployment (k8s.mdc).
96
97
  */
97
98
  import { existsSync } from 'node:fs'
98
99
  import { readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises'
@@ -118,6 +119,21 @@ const HASURA_GRAPHQL_ENGINE_ALLOWED_IMAGES = new Set([
118
119
  `docker.io/${HASURA_GRAPHQL_ENGINE_IMAGE}`
119
120
  ])
120
121
 
122
+ /**
123
+ * Чи відносний POSIX-шлях від кореня репо вказує на YAML під **`…/k8s/…/base/…`** (після сегмента **`k8s`** у шляху
124
+ * є каталог **`base`**). Тут очікуються маніфести шару **base**, включно з будь-яким файлом із **`kind: Deployment`**
125
+ * (наприклад **`deploy.yaml`**, **`deployment.yaml`**).
126
+ * @param {string} relPosix шлях через `/`
127
+ * @returns {boolean} true, якщо шлях лежить у каталозі `…/k8s/…/base/`
128
+ */
129
+ export function isK8sYamlUnderBaseDirectory(relPosix) {
130
+ const parts = relPosix.replaceAll('\\', '/').split('/').filter(Boolean)
131
+ const k = parts.indexOf('k8s')
132
+ if (k === -1) return false
133
+ const dirs = parts.slice(k + 1, -1)
134
+ return dirs.includes('base')
135
+ }
136
+
121
137
  /**
122
138
  * Ключі анотацій GKE (NEG / BackendConfig) у **Service** — заборонені (узгоджено з k8s.mdc).
123
139
  * @type {readonly string[]}
@@ -280,6 +296,7 @@ const K8S_BASE_SEGMENT_RE = /(^|\/)k8s\/base\//u
280
296
  const OXLINT_SCHEMA_MODELINE_RE = /^\s*#\s*yaml-language-server:\s*\$schema=\S+/u
281
297
  const HTTPS_SCHEMA_RE = /^https:/iu
282
298
  const HASURA_GRAPHQL_ENGINE_RE = /(^|\/)hasura\/graphql-engine(?::|$)/u
299
+ const BATCH_V1BETA1_API_VERSION_LINE_RE = /^(\s*apiVersion:\s*)["']?batch\/v1beta1["']?(\s*)$/u
283
300
 
284
301
  /**
285
302
  * Чи містить шлях сегмент директорії `k8s` (рівно ця назва компонента).
@@ -1013,6 +1030,110 @@ async function readK8sYamlDocumentRootsForInventory(abs) {
1013
1030
  return out
1014
1031
  }
1015
1032
 
1033
+ /**
1034
+ * Збирає абсолютні шляхи до YAML-файлів із дерева **`resources` / `bases` / `components` / `crds`** (рекурсивно
1035
+ * через вкладені **kustomization.yaml**). Дублює обхід **`collectResourceDescriptorsForKustomizationWalk`**, але
1036
+ * повертає лише шляхи файлів — для перевірки наявності **`Deployment`** у YAML під **`…/k8s/…/base/`**.
1037
+ * @param {string} kustAbs абсолютний шлях до **kustomization.yaml**
1038
+ * @param {string} rootNorm нормалізований абсолютний корінь репозиторію
1039
+ * @param {Set<string>} visitedKustomization нормалізовані абсолютні шляхи відвіданих **kustomization.yaml**
1040
+ * @returns {Promise<string[]>} список абсолютних шляхів до `.yaml` / `.yml`
1041
+ */
1042
+ async function collectYamlAbsPathsFromKustomizationTree(kustAbs, rootNorm, visitedKustomization) {
1043
+ const normKust = resolve(kustAbs)
1044
+ if (visitedKustomization.has(normKust)) {
1045
+ return []
1046
+ }
1047
+ visitedKustomization.add(normKust)
1048
+
1049
+ let raw
1050
+ try {
1051
+ raw = await readFile(normKust, 'utf8')
1052
+ } catch {
1053
+ return []
1054
+ }
1055
+ const lines = toLines(raw)
1056
+ const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
1057
+
1058
+ /** @type {import('yaml').Document[] | undefined} */
1059
+ let docs
1060
+ try {
1061
+ docs = parseAllDocuments(body)
1062
+ } catch {
1063
+ return []
1064
+ }
1065
+ const first = docs[0]?.toJSON()
1066
+ if (first === null || first === undefined || typeof first !== 'object' || Array.isArray(first)) {
1067
+ return []
1068
+ }
1069
+ const kustDir = dirname(normKust)
1070
+ const pathRefs = resourcePathRefsFromKustomizationObject(first)
1071
+
1072
+ /** @type {string[]} */
1073
+ const out = []
1074
+
1075
+ /**
1076
+ * @param {string} ref шлях з resources/bases/…
1077
+ * @returns {Promise<void>}
1078
+ */
1079
+ async function handleResourcePathRef(ref) {
1080
+ if (typeof ref !== 'string' || ref.includes('://')) {
1081
+ return
1082
+ }
1083
+ const resolved = resolve(kustDir, ref)
1084
+ if (!resolvedFilePathIsUnderRoot(rootNorm, resolved)) {
1085
+ return
1086
+ }
1087
+ /** @type {import('node:fs').Stats | undefined} */
1088
+ let st
1089
+ try {
1090
+ st = await stat(resolved)
1091
+ } catch {
1092
+ st = undefined
1093
+ }
1094
+ if (st === undefined) {
1095
+ return
1096
+ }
1097
+ if (st.isFile() && YAML_EXTENSION_RE.test(resolved)) {
1098
+ out.push(resolved)
1099
+ return
1100
+ }
1101
+ if (!st.isDirectory()) {
1102
+ return
1103
+ }
1104
+ const childK = existsSync(join(resolved, 'kustomization.yaml')) ? join(resolved, 'kustomization.yaml') : null
1105
+ if (childK !== null) {
1106
+ const sub = await collectYamlAbsPathsFromKustomizationTree(childK, rootNorm, visitedKustomization)
1107
+ out.push(...sub)
1108
+ }
1109
+ }
1110
+
1111
+ for (const ref of pathRefs) {
1112
+ await handleResourcePathRef(ref)
1113
+ }
1114
+
1115
+ return out
1116
+ }
1117
+
1118
+ /**
1119
+ * Чи в дереві kustomization є **`Deployment`** у будь-якому YAML під **`…/k8s/…/base/`** (умова для HPA/PDB у k8s.mdc).
1120
+ * @param {string} kustAbs kustomization.yaml
1121
+ * @param {string} rootNorm корінь репо
1122
+ * @returns {Promise<boolean>} true, якщо дерево містить Deployment у шарі base
1123
+ */
1124
+ async function kustomizationTreeHasDeploymentUnderK8sBase(kustAbs, rootNorm) {
1125
+ const visited = new Set()
1126
+ const paths = await collectYamlAbsPathsFromKustomizationTree(kustAbs, rootNorm, visited)
1127
+ const rootResolved = resolve(rootNorm)
1128
+ for (const abs of paths) {
1129
+ const rel = (relative(rootResolved, abs) || '').replaceAll('\\', '/')
1130
+ if (!isK8sYamlUnderBaseDirectory(rel)) continue
1131
+ const roots = await readK8sYamlDocumentRootsForInventory(abs)
1132
+ if (roots.some(o => o.kind === 'Deployment')) return true
1133
+ }
1134
+ return false
1135
+ }
1136
+
1016
1137
  /**
1017
1138
  * Збирає дескриптори ресурсів з **`resources` / `bases` / `components` / `crds`** для одного дерева kustomization.
1018
1139
  * Повторний вхід у той самий **`kustomization.yaml`** дає порожній внесок (як у **`collectKustomizeManagedRelPaths`**).
@@ -1243,7 +1364,7 @@ async function resolveExistingYamlFileUnderRoot(kustDir, pathStr, rootNorm) {
1243
1364
  return null
1244
1365
  }
1245
1366
  /** @type {import('node:fs').Stats | null} */
1246
- let st = null
1367
+ let st
1247
1368
  try {
1248
1369
  st = await stat(resolved)
1249
1370
  } catch {
@@ -1375,8 +1496,8 @@ async function validatePatchTargetsOneKustomizationFile(root, kustAbs, rootNorm,
1375
1496
  }
1376
1497
  const lines = toLines(raw)
1377
1498
  const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
1378
- /** @type {import('yaml').Document[] | null} */
1379
- let docs = null
1499
+ /** @type {import('yaml').Document[]} */
1500
+ let docs
1380
1501
  try {
1381
1502
  docs = parseAllDocuments(body)
1382
1503
  } catch {
@@ -1567,15 +1688,15 @@ async function removeBackendConfigOnlyK8sYamlFiles(root, fail, pass) {
1567
1688
  * Один рядок YAML: якщо це `apiVersion` зі значенням **`batch/v1beta1`**, повертає той самий рядок із **`batch/v1`**
1568
1689
  * (з тими самими відступами/пробілами після `apiVersion:`, крім випадків з лапками — нормалізується до `apiVersion: batch/v1`).
1569
1690
  * Рядки, що після trim починаються з `#`, не змінюються.
1570
- * @param {string} line
1571
- * @returns {string}
1691
+ * @param {string} line один рядок YAML
1692
+ * @returns {string} той самий рядок або з заміною apiVersion batch/v1beta1 на batch/v1
1572
1693
  */
1573
1694
  function rewriteLineBatchV1beta1ApiVersion(line) {
1574
1695
  const t = line.trimStart()
1575
1696
  if (t.startsWith('#')) {
1576
1697
  return line
1577
1698
  }
1578
- const m = line.match(/^(\s*apiVersion:\s*)(?:"|')?batch\/v1beta1(?:"|')?(\s*)$/)
1699
+ const m = line.match(BATCH_V1BETA1_API_VERSION_LINE_RE)
1579
1700
  if (m) {
1580
1701
  return `${m[1]}batch/v1${m[2]}`
1581
1702
  }
@@ -1586,11 +1707,11 @@ function rewriteLineBatchV1beta1ApiVersion(line) {
1586
1707
  * У повному тексті YAML замінює всі **цілі** рядки `apiVersion: batch/v1beta1` (за потреби в лапках) на `apiVersion: batch/v1`.
1587
1708
  * Зберігає **CRLF** / **LF** як у вихідному рядку.
1588
1709
  * @param {string} raw вміст файлу
1589
- * @returns {{ changed: boolean, content: string }}
1710
+ * @returns {{ changed: boolean, content: string }} прапорець зміни та оновлений текст
1590
1711
  */
1591
1712
  export function replaceBatchV1beta1ApiVersionInYamlText(raw) {
1592
1713
  const eol = raw.includes('\r\n') ? '\r\n' : '\n'
1593
- const lines = raw.split(/\r?\n/)
1714
+ const lines = raw.split(YAML_LINE_SPLIT_RE)
1594
1715
  let changed = false
1595
1716
  const out = lines.map(line => {
1596
1717
  const n = rewriteLineBatchV1beta1ApiVersion(line)
@@ -1608,8 +1729,8 @@ export function replaceBatchV1beta1ApiVersionInYamlText(raw) {
1608
1729
  /**
1609
1730
  * Проходить усі `*.yaml` / `*.yml` під сегментом `k8s` і на диску застосовує **`replaceBatchV1beta1ApiVersionInYamlText`**.
1610
1731
  * @param {string} root корінь репозиторію
1611
- * @param {(msg: string) => void} fail
1612
- * @param {(msg: string) => void} pass
1732
+ * @param {(msg: string) => void} fail колбек повідомлення про помилку
1733
+ * @param {(msg: string) => void} pass колбек успішного повідомлення
1613
1734
  * @returns {Promise<void>}
1614
1735
  */
1615
1736
  async function rewriteBatchV1beta1ApiVersionInK8sYamlFiles(root, fail, pass) {
@@ -1893,7 +2014,7 @@ function failIfJson6902RemoveAddConflictOnSamePath(rel, label, patchText, fail)
1893
2014
  */
1894
2015
  async function auditJson6902PatchExternalFile(rel, resolved, root, patchRef, fail) {
1895
2016
  /** @type {import('node:fs').Stats | null} */
1896
- let st = null
2017
+ let st
1897
2018
  try {
1898
2019
  st = await stat(resolved)
1899
2020
  } catch {
@@ -2010,8 +2131,8 @@ async function auditJson6902OneKustomizationYamlFile(root, rootNorm, kustAbs, fa
2010
2131
  }
2011
2132
  const lines = toLines(raw)
2012
2133
  const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
2013
- /** @type {import('yaml').Document[] | null} */
2014
- let docs = null
2134
+ /** @type {import('yaml').Document[]} */
2135
+ let docs
2015
2136
  try {
2016
2137
  docs = parseAllDocuments(body)
2017
2138
  } catch {
@@ -4480,8 +4601,9 @@ export async function kustomizeResourceTreeHpaPdbDeploymentFlags(kustAbs, rootNo
4480
4601
  /** @type {Set<string>} */
4481
4602
  const visitedKustomization = new Set()
4482
4603
  const desc = await collectResourceDescriptorsForKustomizationWalk(kustAbs, rootNorm, visitedKustomization)
4604
+ const hasDeployment = await kustomizationTreeHasDeploymentUnderK8sBase(kustAbs, rootNorm)
4483
4605
  return {
4484
- hasDeployment: desc.some(d => d.kind === 'Deployment'),
4606
+ hasDeployment,
4485
4607
  hasHpa: desc.some(d => d.kind === 'HorizontalPodAutoscaler'),
4486
4608
  hasPdb: desc.some(d => d.kind === 'PodDisruptionBudget')
4487
4609
  }
@@ -4854,9 +4976,9 @@ async function extractDeploymentsFromFile(filePath) {
4854
4976
  }
4855
4977
 
4856
4978
  /**
4857
- * Для кожного **Deployment** під `k8s/` перевіряє: у тому ж каталозі повинні бути
4858
- * `hpa.yaml` (валідний `autoscaling/v2`) і `pdb.yaml` (валідний `policy/v1`), а сам Deployment
4859
- * повинен мати канонічні **topologySpreadConstraints**. Env-залежні межі (`minReplicas`,
4979
+ * Для кожного **Deployment** у шарі **`…/k8s/…/base/`** (будь-який YAML у відповідному каталозі) перевіряє:
4980
+ * у тому ж каталозі повинні бути `hpa.yaml` (валідний `autoscaling/v2`) і `pdb.yaml` (валідний `policy/v1`),
4981
+ * а сам Deployment — канонічні **topologySpreadConstraints**. Env-залежні межі (`minReplicas`,
4860
4982
  * `minAvailable`) — за сегментом після `/k8s/`: `base` / `dev` / `*-qa` = dev-like, решта — прод.
4861
4983
  * @param {string} root корінь репозиторію
4862
4984
  * @param {string[]} yamlFilesAbs yaml під k8s
@@ -4864,18 +4986,25 @@ async function extractDeploymentsFromFile(filePath) {
4864
4986
  * @param {(msg: string) => void} passFn callback при успіху
4865
4987
  */
4866
4988
  async function validateDeploymentHpaPdbAndTopology(root, yamlFilesAbs, fail, passFn) {
4867
- /** @type {Set<string>} */
4868
- const seenDirs = new Set()
4989
+ const rootNorm = resolve(root)
4990
+ /** @type {Map<string, Record<string, unknown>[]>} */
4991
+ const deploymentsByDir = new Map()
4869
4992
  for (const abs of yamlFilesAbs) {
4993
+ const rel = (relative(rootNorm, abs) || abs).replaceAll('\\', '/')
4994
+ if (!isK8sYamlUnderBaseDirectory(rel)) continue
4995
+ const deployments = await extractDeploymentsFromFile(abs)
4996
+ if (deployments.length === 0) continue
4870
4997
  const dir = dirname(abs)
4871
- if (!seenDirs.has(dir)) {
4872
- const deployments = await extractDeploymentsFromFile(abs)
4873
- if (deployments.length > 0) {
4874
- seenDirs.add(dir)
4875
- await validateDeploymentsInDir(deployments, dir, root, fail, passFn)
4876
- }
4998
+ const merged = deploymentsByDir.get(dir)
4999
+ if (merged === undefined) {
5000
+ deploymentsByDir.set(dir, [...deployments])
5001
+ } else {
5002
+ merged.push(...deployments)
4877
5003
  }
4878
5004
  }
5005
+ for (const [dir, deployments] of deploymentsByDir) {
5006
+ await validateDeploymentsInDir(deployments, dir, rootNorm, fail, passFn)
5007
+ }
4879
5008
  }
4880
5009
 
4881
5010
  /**
@@ -285,7 +285,7 @@ async function checkTemplateFile(abs, root, passFn, failFn) {
285
285
  }
286
286
 
287
287
  const dir = dirname(abs)
288
- let iniNames = []
288
+ let iniNames
289
289
  try {
290
290
  const dirEntries = await readdir(dir)
291
291
  iniNames = dirEntries.filter(n => n.endsWith('.ini'))