@nitra/cursor 5.3.0 → 5.3.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.3.2] - 2026-06-11
4
+
5
+ ### Changed
6
+
7
+ - release: @nitra/cursor@5.3.1
8
+
9
+ ## [5.3.1] - 2026-06-11
10
+
11
+ ### Fixed
12
+
13
+ - lint: усунено eslint/oxlint-помилки (24) — ігнор кореневого `.worktrees/` в eslint.config, GENERIC_RE→масив дрібних patternів (sonarjs/regex-complexity), Array#sort→toSorted; cspell: додано `ollama` у словник
14
+
3
15
  ## [5.3.0] - 2026-06-11
4
16
 
5
17
  ### Added
package/bin/n-cursor.js CHANGED
@@ -1243,7 +1243,73 @@ function logRemovedManagedItems(title, basePath, names) {
1243
1243
  }
1244
1244
 
1245
1245
  /**
1246
- * Spawn-wrapper для `npx @nitra/cursor fix [<rule>...]`. Один шлях у коді: для кожного правила
1246
+ * Визначає список правил для fix: явно задані валідацією проти available) або
1247
+ * discovery з `.cursor/rules/*.mdc`. Друкує діагностику й кидає на невідомих правилах.
1248
+ * @param {string[]} requestedRules запитані правила (порожній → discovery)
1249
+ * @param {string[]} available доступні в пакеті rule-id
1250
+ * @param {boolean} json json-режим (впливає на early-return вивід при порожньому discovery)
1251
+ * @returns {Promise<string[]|null>} список id або null — нічого запускати (вивід уже зроблено)
1252
+ */
1253
+ async function resolveFixRuleIds(requestedRules, available, json) {
1254
+ if (requestedRules.length > 0) {
1255
+ const unknown = requestedRules.filter(id => !available.includes(id))
1256
+ if (unknown.length > 0) {
1257
+ console.error(`❌ Невідомі правила: ${unknown.join(', ')}`)
1258
+ console.log(` Доступні: ${available.join(', ')}`)
1259
+ throw new Error(`Unknown rules: ${unknown.join(', ')}`)
1260
+ }
1261
+ return requestedRules
1262
+ }
1263
+
1264
+ const mdcFiles = await listProjectRulesMdcFiles()
1265
+ if (mdcFiles.length === 0) {
1266
+ throw new Error(
1267
+ `Немає файлів *.mdc у ${RULES_DIR}/. Запустіть \`npx ${PACKAGE_NAME}\` або вкажіть правила: \`npx ${PACKAGE_NAME} fix bun ga\``
1268
+ )
1269
+ }
1270
+ const idsToRun = discoverCheckRulesFromCursorRules(available, mdcFiles)
1271
+ if (idsToRun.length === 0) {
1272
+ if (json) {
1273
+ process.stdout.write(`${JSON.stringify({ total: 0, failed: 0, rules: [] })}\n`)
1274
+ return null
1275
+ }
1276
+ console.log(
1277
+ `\n🔍 ${PACKAGE_NAME} fix — у ${RULES_DIR}/ немає правил з programmatic перевіркою ` +
1278
+ `(відповідного fix.mjs у пакеті). Нічого не запущено.\n`
1279
+ )
1280
+ return null
1281
+ }
1282
+ return idsToRun
1283
+ }
1284
+
1285
+ /**
1286
+ * Прогоняє `fix.mjs` кожного правила окремим процесом; збирає timings і (для json) per-rule output.
1287
+ * @param {string[]} idsToRun правила до запуску
1288
+ * @param {boolean} json json-режим — захоплює stdout/stderr у структуру замість inherit у термінал
1289
+ * @returns {{ totalFailed: number, timings: {id:string, ms:number, ok:boolean}[], ruleResults: {ruleId:string, ok:boolean, output:string}[] }} підсумок прогону
1290
+ */
1291
+ function runRuleFixProcesses(idsToRun, json) {
1292
+ let totalFailed = 0
1293
+ const timings = []
1294
+ const ruleResults = []
1295
+ for (const id of idsToRun) {
1296
+ const fixPath = join(BUNDLED_RULES_DIR, id, 'fix.mjs')
1297
+ const startedAt = Date.now()
1298
+ const result = json
1299
+ ? spawnSync('bun', [fixPath], { encoding: 'utf8' })
1300
+ : spawnSync('bun', [fixPath], { stdio: 'inherit' })
1301
+ const ok = result.status === 0
1302
+ timings.push({ id: `fix-${id}`, ms: Date.now() - startedAt, ok })
1303
+ if (json) {
1304
+ ruleResults.push({ ruleId: id, ok, output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim() })
1305
+ }
1306
+ if (!ok) totalFailed++
1307
+ }
1308
+ return { totalFailed, timings, ruleResults }
1309
+ }
1310
+
1311
+ /**
1312
+ * Spawn-wrapper для `npx \@nitra/cursor fix [<rule>...]`. Один шлях у коді: для кожного правила
1247
1313
  * робить `bun rules/<id>/fix.mjs` як окремий процес. Сам `fix.mjs` читає `.n-cursor.json`,
1248
1314
  * перевіряє whitelist (`runRuleCli`) і друкує per-rule summary.
1249
1315
  *
@@ -1272,56 +1338,12 @@ async function runFixCommand(requestedRules, opts = {}) {
1272
1338
  throw new Error('No rules found')
1273
1339
  }
1274
1340
 
1275
- let idsToRun
1276
- if (requestedRules.length > 0) {
1277
- const unknown = requestedRules.filter(id => !available.includes(id))
1278
- if (unknown.length > 0) {
1279
- console.error(`❌ Невідомі правила: ${unknown.join(', ')}`)
1280
- console.log(` Доступні: ${available.join(', ')}`)
1281
- throw new Error(`Unknown rules: ${unknown.join(', ')}`)
1282
- }
1283
- idsToRun = requestedRules
1284
- } else {
1285
- const mdcFiles = await listProjectRulesMdcFiles()
1286
- if (mdcFiles.length === 0) {
1287
- throw new Error(
1288
- `Немає файлів *.mdc у ${RULES_DIR}/. Запустіть \`npx ${PACKAGE_NAME}\` або вкажіть правила: \`npx ${PACKAGE_NAME} fix bun ga\``
1289
- )
1290
- }
1291
- idsToRun = discoverCheckRulesFromCursorRules(available, mdcFiles)
1292
- if (idsToRun.length === 0) {
1293
- if (json) {
1294
- process.stdout.write(`${JSON.stringify({ total: 0, failed: 0, rules: [] })}\n`)
1295
- return
1296
- }
1297
- console.log(
1298
- `\n🔍 ${PACKAGE_NAME} fix — у ${RULES_DIR}/ немає правил з programmatic перевіркою ` +
1299
- `(відповідного fix.mjs у пакеті). Нічого не запущено.\n`
1300
- )
1301
- return
1302
- }
1303
- }
1341
+ // json-режим: захоплюємо stdout/stderr правила у структуру (а не inherit у термінал),
1342
+ // щоб віддати агенту згруповано {ruleId, ok, output} і він читав лише впалі.
1343
+ const idsToRun = await resolveFixRuleIds(requestedRules, available, json)
1344
+ if (idsToRun === null) return
1304
1345
 
1305
- let totalFailed = 0
1306
- /** @type {{ id: string, ms: number, ok: boolean }[]} */
1307
- const timings = []
1308
- /** @type {{ ruleId: string, ok: boolean, output: string }[]} */
1309
- const ruleResults = []
1310
- for (const id of idsToRun) {
1311
- const fixPath = join(BUNDLED_RULES_DIR, id, 'fix.mjs')
1312
- const startedAt = Date.now()
1313
- // json-режим: захоплюємо stdout/stderr правила у структуру (а не inherit у термінал),
1314
- // щоб віддати агенту згруповано {ruleId, ok, output} і він читав лише впалі.
1315
- const result = json
1316
- ? spawnSync('bun', [fixPath], { encoding: 'utf8' })
1317
- : spawnSync('bun', [fixPath], { stdio: 'inherit' })
1318
- const ok = result.status === 0
1319
- timings.push({ id: `fix-${id}`, ms: Date.now() - startedAt, ok })
1320
- if (json) {
1321
- ruleResults.push({ ruleId: id, ok, output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim() })
1322
- }
1323
- if (!ok) totalFailed++
1324
- }
1346
+ const { totalFailed, timings, ruleResults } = runRuleFixProcesses(idsToRun, json)
1325
1347
 
1326
1348
  if (json) {
1327
1349
  process.stdout.write(`${JSON.stringify({ total: idsToRun.length, failed: totalFailed, rules: ruleResults })}\n`)
package/lib/models.mjs CHANGED
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * ## Бекенд за префіксом model-id
16
16
  *
17
- * model-id з префіксом `omlx/...` маршрутизується прямим HTTP до локального
17
+ * model-id з префіксом `omlx/...` іде прямим HTTP до локального
18
18
  * omlx-сервера (`npm/lib/omlx.mjs`), минаючи pi; решта (`openai/...`,
19
19
  * `ollama/...`, '') — через pi CLI. Тому локальні тири варто задавати у форматі
20
20
  * `omlx/<model>`, аби local-inference йшов напряму, а pi лишався шаром для хмари
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "5.3.0",
3
+ "version": "5.3.2",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -3942,7 +3942,7 @@ export function loadSnippetSpec(snippetName) {
3942
3942
  const url = NETWORK_POLICY_SNIPPET_URLS[snippetName]
3943
3943
  if (!url) throw new Error(`Unknown NetworkPolicy snippet: ${snippetName}`)
3944
3944
  const raw = readFileSync(fileURLToPath(url), 'utf8')
3945
- _snippetCache[snippetName] = /** @type {any} */ (parseDocument(raw).toJS()).spec
3945
+ _snippetCache[snippetName] = /** @type {{ spec: Record<string, unknown> }} */ (parseDocument(raw).toJS()).spec
3946
3946
  return _snippetCache[snippetName]
3947
3947
  }
3948
3948
 
@@ -3981,7 +3981,7 @@ const noopFail = msg => msg
3981
3981
  /**
3982
3982
  * `from`-peers для HTTPRoute-aware ingress-правила (GCP HC global + Envoy data-plane / proxy-only subnet).
3983
3983
  * Порядок зафіксовано детерміністичним (HC-global → 10.0.0.0/8). Див. розділ «HTTPRoute → NetworkPolicy ingress» у k8s.mdc.
3984
- * @type {ReadonlyArray<{ ipBlock: { cidr: string } }>}
3984
+ * @type {readonly { ipBlock: { cidr: string } }[]}
3985
3985
  */
3986
3986
  const NETWORK_POLICY_GCLB_INGRESS_FROM = Object.freeze([
3987
3987
  // eslint-disable-next-line sonarjs/no-hardcoded-ip
@@ -4061,45 +4061,7 @@ export async function collectHttpRouteIngressForWorkload(dir, appLabel, fail) {
4061
4061
  const servicesByName = new Map()
4062
4062
 
4063
4063
  for (const abs of yamlFiles) {
4064
- let raw
4065
- try {
4066
- raw = await readFile(abs, 'utf8')
4067
- } catch (error) {
4068
- const msg = error instanceof Error ? error.message : String(error)
4069
- fail(`${abs}: не вдалося прочитати для GCLB ingress (HTTPRoute → NetworkPolicy mapping; k8s.mdc): ${msg}`)
4070
- continue
4071
- }
4072
- const lines = toLines(raw)
4073
- const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
4074
- /** @type {import('yaml').Document[]} */
4075
- let docs
4076
- try {
4077
- docs = parseAllDocuments(body)
4078
- } catch (error) {
4079
- const msg = error instanceof Error ? error.message : String(error)
4080
- fail(`${abs}: не вдалося розібрати YAML для GCLB ingress (HTTPRoute → NetworkPolicy mapping; k8s.mdc): ${msg}`)
4081
- continue
4082
- }
4083
- for (const doc of docs) {
4084
- if (doc.errors.length > 0) {
4085
- fail(
4086
- `${abs}: YAML містить помилки для GCLB ingress (HTTPRoute → NetworkPolicy mapping; k8s.mdc): ${doc.errors[0].message}`
4087
- )
4088
- continue
4089
- }
4090
- const rec = asPlainRecord(doc.toJSON())
4091
- if (rec === null) continue
4092
- const av = rec.apiVersion
4093
- if (rec.kind === 'HTTPRoute' && typeof av === 'string' && av.startsWith(GATEWAY_API_GROUP_PREFIX)) {
4094
- collectHttpRouteBackendRefsInto(rec.spec, allBackendRefs)
4095
- } else if (rec.kind === 'Service') {
4096
- const name = manifestMetadataName(rec)
4097
- if (name !== null) {
4098
- const app = serviceSelectorAppLabel(rec.spec)
4099
- if (app !== null) servicesByName.set(name, app)
4100
- }
4101
- }
4102
- }
4064
+ await collectHttpRouteFileInto(abs, allBackendRefs, servicesByName, fail)
4103
4065
  }
4104
4066
 
4105
4067
  /** @type {Set<number>} */
@@ -4112,6 +4074,68 @@ export async function collectHttpRouteIngressForWorkload(dir, appLabel, fail) {
4112
4074
  return { ports: [...ports].toSorted((a, b) => a - b) }
4113
4075
  }
4114
4076
 
4077
+ /**
4078
+ * Читає й парсить один YAML-файл і акумулює HTTPRoute-backendRefs та Service `app`-мітки.
4079
+ * Read/parse/doc-помилки репортяться через `fail` і не зупиняють обхід (як у оригінальному циклі).
4080
+ * @param {string} abs абсолютний шлях до YAML-файлу
4081
+ * @param {Array<{ name: string, port: number }>} allBackendRefs акумулятор backendRefs
4082
+ * @param {Map<string, string>} servicesByName акумулятор Service name → app-label
4083
+ * @param {(msg: string) => void} fail callback при read/parse-помилці
4084
+ * @returns {Promise<void>} результат
4085
+ */
4086
+ async function collectHttpRouteFileInto(abs, allBackendRefs, servicesByName, fail) {
4087
+ let raw
4088
+ try {
4089
+ raw = await readFile(abs, 'utf8')
4090
+ } catch (error) {
4091
+ const msg = error instanceof Error ? error.message : String(error)
4092
+ fail(`${abs}: не вдалося прочитати для GCLB ingress (HTTPRoute → NetworkPolicy mapping; k8s.mdc): ${msg}`)
4093
+ return
4094
+ }
4095
+ const lines = toLines(raw)
4096
+ const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
4097
+ /** @type {import('yaml').Document[]} */
4098
+ let docs
4099
+ try {
4100
+ docs = parseAllDocuments(body)
4101
+ } catch (error) {
4102
+ const msg = error instanceof Error ? error.message : String(error)
4103
+ fail(`${abs}: не вдалося розібрати YAML для GCLB ingress (HTTPRoute → NetworkPolicy mapping; k8s.mdc): ${msg}`)
4104
+ return
4105
+ }
4106
+ for (const doc of docs) {
4107
+ if (doc.errors.length > 0) {
4108
+ fail(
4109
+ `${abs}: YAML містить помилки для GCLB ingress (HTTPRoute → NetworkPolicy mapping; k8s.mdc): ${doc.errors[0].message}`
4110
+ )
4111
+ continue
4112
+ }
4113
+ collectHttpRouteDocInto(doc.toJSON(), allBackendRefs, servicesByName)
4114
+ }
4115
+ }
4116
+
4117
+ /**
4118
+ * Класифікує один YAML-документ (HTTPRoute / Service) і акумулює backendRefs / Service `app`-мітку.
4119
+ * @param {unknown} json розпарсений документ
4120
+ * @param {Array<{ name: string, port: number }>} allBackendRefs акумулятор backendRefs
4121
+ * @param {Map<string, string>} servicesByName акумулятор Service name → app-label
4122
+ * @returns {void} результат
4123
+ */
4124
+ function collectHttpRouteDocInto(json, allBackendRefs, servicesByName) {
4125
+ const rec = asPlainRecord(json)
4126
+ if (rec === null) return
4127
+ const av = rec.apiVersion
4128
+ if (rec.kind === 'HTTPRoute' && typeof av === 'string' && av.startsWith(GATEWAY_API_GROUP_PREFIX)) {
4129
+ collectHttpRouteBackendRefsInto(rec.spec, allBackendRefs)
4130
+ } else if (rec.kind === 'Service') {
4131
+ const name = manifestMetadataName(rec)
4132
+ if (name !== null) {
4133
+ const app = serviceSelectorAppLabel(rec.spec)
4134
+ if (app !== null) servicesByName.set(name, app)
4135
+ }
4136
+ }
4137
+ }
4138
+
4115
4139
  /**
4116
4140
  * Витягує мітку `app` з `Service.spec.selector.app` (плоский селектор, без `matchLabels`).
4117
4141
  * Окремий helper від `appLabelFromSpecSelector` (той — для Deployment/StatefulSet, де `selector.matchLabels.app`).
@@ -4824,39 +4848,46 @@ export async function kustomizationTreeHasHasuraDeployment(kustAbs, rootNorm) {
4824
4848
  }
4825
4849
 
4826
4850
  /**
4827
- * Значення, яке inline-`patch` присвоює `data.HASURA_GRAPHQL_ENABLED_APIS`. Підтримка двох форматів:
4828
- * **JSON6902** (`op` add/replace на `/data/HASURA_GRAPHQL_ENABLED_APIS`) і **Strategic Merge**
4829
- * (`data.HASURA_GRAPHQL_ENABLED_APIS`). Зовнішні patch-файли (`patches[].path`) не охоплені Plan B trade-off.
4830
- * @param {string} patchText вміст поля `patch`
4831
- * @returns {string | null} присвоєне значення (рядок) або null, якщо patch не чіпає цей ключ
4851
+ * Парсить `patchText` як YAML і повертає JSON першого документа без помилок (або undefined).
4852
+ * @param {string} patchText непорожній trimmed-текст patch
4853
+ * @returns {unknown} JSON першого валідного документа або undefined
4832
4854
  */
4833
- export function enabledApisValueFromPatchText(patchText) {
4834
- const t = typeof patchText === 'string' ? patchText.trim() : ''
4835
- if (t === '') return null
4836
- let parsed
4855
+ function firstValidYamlJsonFromPatchText(patchText) {
4837
4856
  try {
4838
- for (const d of parseAllDocuments(t)) {
4839
- if (d.errors.length === 0) {
4840
- parsed = d.toJSON()
4841
- break
4842
- }
4857
+ for (const d of parseAllDocuments(patchText)) {
4858
+ if (d.errors.length === 0) return d.toJSON()
4843
4859
  }
4844
4860
  } catch {
4845
- return null
4861
+ return undefined
4846
4862
  }
4847
- if (Array.isArray(parsed)) {
4848
- for (const item of parsed) {
4849
- if (item === null || typeof item !== 'object' || Array.isArray(item)) continue
4850
- const rec = /** @type {Record<string, unknown>} */ (item)
4851
- const op = typeof rec.op === 'string' ? rec.op.trim().toLowerCase() : ''
4852
- const path = typeof rec.path === 'string' ? normalizeJsonPatchPath(rec.path) : ''
4853
- if ((op === 'add' || op === 'replace') && path === HASURA_ENABLED_APIS_DATA_POINTER) {
4854
- return typeof rec.value === 'string' ? rec.value : JSON.stringify(rec.value)
4855
- }
4863
+ return undefined
4864
+ }
4865
+
4866
+ /**
4867
+ * Значення `data.HASURA_GRAPHQL_ENABLED_APIS` з JSON6902-патчу (масив операцій).
4868
+ * Повертає значення першої `add`/`replace`-операції на `/data/HASURA_GRAPHQL_ENABLED_APIS`.
4869
+ * @param {unknown[]} ops масив JSON6902-операцій
4870
+ * @returns {string | null} присвоєне значення (рядок) або null
4871
+ */
4872
+ function enabledApisValueFromJson6902(ops) {
4873
+ for (const item of ops) {
4874
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) continue
4875
+ const rec = /** @type {Record<string, unknown>} */ (item)
4876
+ const op = typeof rec.op === 'string' ? rec.op.trim().toLowerCase() : ''
4877
+ const path = typeof rec.path === 'string' ? normalizeJsonPatchPath(rec.path) : ''
4878
+ if ((op === 'add' || op === 'replace') && path === HASURA_ENABLED_APIS_DATA_POINTER) {
4879
+ return typeof rec.value === 'string' ? rec.value : JSON.stringify(rec.value)
4856
4880
  }
4857
- return null
4858
4881
  }
4859
- if (parsed === null || typeof parsed !== 'object') return null
4882
+ return null
4883
+ }
4884
+
4885
+ /**
4886
+ * Значення `data.HASURA_GRAPHQL_ENABLED_APIS` зі Strategic-Merge-патчу (об'єкт із `data`).
4887
+ * @param {object} parsed розпарсений об'єкт patch
4888
+ * @returns {string | null} присвоєне значення (рядок) або null
4889
+ */
4890
+ function enabledApisValueFromStrategicMerge(parsed) {
4860
4891
  const data = /** @type {Record<string, unknown>} */ (parsed).data
4861
4892
  if (data === null || typeof data !== 'object' || Array.isArray(data)) return null
4862
4893
  const d = /** @type {Record<string, unknown>} */ (data)
@@ -4865,6 +4896,22 @@ export function enabledApisValueFromPatchText(patchText) {
4865
4896
  return typeof v === 'string' ? v : JSON.stringify(v)
4866
4897
  }
4867
4898
 
4899
+ /**
4900
+ * Значення, яке inline-`patch` присвоює `data.HASURA_GRAPHQL_ENABLED_APIS`. Підтримка двох форматів:
4901
+ * **JSON6902** (`op` add/replace на `/data/HASURA_GRAPHQL_ENABLED_APIS`) і **Strategic Merge**
4902
+ * (`data.HASURA_GRAPHQL_ENABLED_APIS`). Зовнішні patch-файли (`patches[].path`) не охоплені — Plan B trade-off.
4903
+ * @param {string} patchText вміст поля `patch`
4904
+ * @returns {string | null} присвоєне значення (рядок) або null, якщо patch не чіпає цей ключ
4905
+ */
4906
+ export function enabledApisValueFromPatchText(patchText) {
4907
+ const t = typeof patchText === 'string' ? patchText.trim() : ''
4908
+ if (t === '') return null
4909
+ const parsed = firstValidYamlJsonFromPatchText(t)
4910
+ if (Array.isArray(parsed)) return enabledApisValueFromJson6902(parsed)
4911
+ if (parsed === null || typeof parsed !== 'object') return null
4912
+ return enabledApisValueFromStrategicMerge(parsed)
4913
+ }
4914
+
4868
4915
  /**
4869
4916
  * Значення, яке `patches[]` kustomization присвоюють `data.HASURA_GRAPHQL_ENABLED_APIS` на цілі **ConfigMap**.
4870
4917
  * Повертає значення першого patch-а, що чіпає цей ключ, або null, якщо такого немає.
@@ -6307,6 +6354,34 @@ function networkPolicyPodSelectorAppLabel(spec) {
6307
6354
  return typeof app === 'string' ? app : ''
6308
6355
  }
6309
6356
 
6357
+ /**
6358
+ * Витягує `kind` workload з анотації `nitra.dev/workload-kind` у `metadata.annotations`
6359
+ * NetworkPolicy-документа; дефолт `'Deployment'`, якщо анотації немає або вона порожня.
6360
+ * @param {Record<string, unknown>} docRec корінь NetworkPolicy-документа
6361
+ * @returns {string} workload-kind
6362
+ */
6363
+ function legacyNetworkPolicyWorkloadKind(docRec) {
6364
+ const meta = getNestedObject(docRec, 'metadata')
6365
+ const annotations = meta === null ? null : getNestedObject(meta, 'annotations')
6366
+ const rawKind = annotations === null ? null : annotations['nitra.dev/workload-kind']
6367
+ return typeof rawKind === 'string' && rawKind !== '' ? rawKind : 'Deployment'
6368
+ }
6369
+
6370
+ /**
6371
+ * Будує `{ name, appLabel, kind }` для regenerate-канону з одного legacy NetworkPolicy-документа,
6372
+ * або `null`, якщо `name`/`appLabel` неповні (документ пропускається).
6373
+ * @param {unknown} doc розпарсений NetworkPolicy-документ
6374
+ * @returns {{ name: string, appLabel: string, kind: string } | null} spec або null
6375
+ */
6376
+ function legacyNetworkPolicySpecFromDoc(doc) {
6377
+ const name = manifestMetadataName(doc)
6378
+ const docRec = /** @type {Record<string, unknown>} */ (doc)
6379
+ const appLabel = networkPolicyPodSelectorAppLabel(docRec.spec)
6380
+ const kind = legacyNetworkPolicyWorkloadKind(docRec)
6381
+ if (typeof name === 'string' && name !== '' && appLabel !== '') return { name, appLabel, kind }
6382
+ return null
6383
+ }
6384
+
6310
6385
  /**
6311
6386
  * Migrate legacy `networkpolicy.yaml`: якщо хоч один документ має catch-all in-cluster egress —
6312
6387
  * перезаписати **всі** документи у файлі через `buildNetworkPolicyYaml(name, appLabel, kind, gclbPorts)`.
@@ -6327,21 +6402,8 @@ export async function regenerateLegacyNetworkPolicyDocsInFile(npAbs, fail) {
6327
6402
  */
6328
6403
  const specs = []
6329
6404
  for (const doc of docs) {
6330
- const name = manifestMetadataName(doc)
6331
- const docRec = /** @type {Record<string, unknown>} */ (doc)
6332
- const spec = docRec.spec
6333
- const appLabel = networkPolicyPodSelectorAppLabel(spec)
6334
- const meta = docRec.metadata
6335
- const annotations =
6336
- meta !== null && typeof meta === 'object' && !Array.isArray(meta)
6337
- ? /** @type {Record<string, unknown>} */ (meta).annotations
6338
- : null
6339
- const rawKind =
6340
- annotations !== null && typeof annotations === 'object' && !Array.isArray(annotations)
6341
- ? /** @type {Record<string, unknown>} */ (annotations)['nitra.dev/workload-kind']
6342
- : null
6343
- const kind = typeof rawKind === 'string' && rawKind !== '' ? rawKind : 'Deployment'
6344
- if (typeof name === 'string' && name !== '' && appLabel !== '') specs.push({ name, appLabel, kind })
6405
+ const spec = legacyNetworkPolicySpecFromDoc(doc)
6406
+ if (spec !== null) specs.push(spec)
6345
6407
  }
6346
6408
  if (specs.length === 0) return false
6347
6409
  const dir = dirname(npAbs)
@@ -41,6 +41,77 @@ function moduleJsDoc(source) {
41
41
  return m ? m[0] : null
42
42
  }
43
43
 
44
+ /**
45
+ * Чи `.mjs`-файл, що не є тестом (`*.test.mjs`).
46
+ * @param {import('node:fs').Dirent} fileEntry запис каталогу
47
+ * @returns {boolean} true для звичайних source-файлів
48
+ */
49
+ function isSourceMjs(fileEntry) {
50
+ return (
51
+ fileEntry.isFile() && fileEntry.name.endsWith('.mjs') && !fileEntry.name.endsWith('.test.mjs')
52
+ )
53
+ }
54
+
55
+ /**
56
+ * Перевіряє один source-файл: якщо поряд є `docs/<stem>.md` і module-level JSDoc
57
+ * містить >1 непорожній рядок — репортить порушення.
58
+ * @param {string} jsDir каталог `js/`
59
+ * @param {import('node:fs').Dirent} fileEntry запис файлу
60
+ * @param {string} cwd корінь репозиторію
61
+ * @param {ReturnType<typeof createCheckReporter>} reporter репортер
62
+ * @returns {Promise<void>}
63
+ */
64
+ async function checkSourceFile(jsDir, fileEntry, cwd, reporter) {
65
+ const stem = basename(fileEntry.name, '.mjs')
66
+ const docsPath = join(jsDir, 'docs', `${stem}.md`)
67
+ if (!existsSync(docsPath)) return
68
+
69
+ const filePath = join(jsDir, fileEntry.name)
70
+ const source = await readFile(filePath, 'utf8')
71
+ const block = moduleJsDoc(source)
72
+ if (!block) return
73
+
74
+ const count = contentLineCount(block)
75
+ if (count > 1) {
76
+ reporter.fail(
77
+ `${filePath.slice(cwd.length + 1)}: docs/${stem}.md вже описує поведінку — module-level JSDoc має бути pointer (≤1 рядок, зараз ${count})`
78
+ )
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Перевіряє всі source-файли в одному `js/`-каталозі правила/скіла.
84
+ * @param {string} jsDir каталог `js/`
85
+ * @param {string} cwd корінь репозиторію
86
+ * @param {ReturnType<typeof createCheckReporter>} reporter репортер
87
+ * @returns {Promise<void>}
88
+ */
89
+ async function checkJsDir(jsDir, cwd, reporter) {
90
+ for (const fileEntry of await readdir(jsDir, { withFileTypes: true })) {
91
+ if (!isSourceMjs(fileEntry)) continue
92
+ await checkSourceFile(jsDir, fileEntry, cwd, reporter)
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Перевіряє один base-сегмент (`npm/rules` чи `npm/skills`): обходить піддиректорії
98
+ * правил/скілів і їхні `js/`-каталоги.
99
+ * @param {string} absBase абсолютний шлях до base-сегмента
100
+ * @param {string} cwd корінь репозиторію
101
+ * @param {ReturnType<typeof createCheckReporter>} reporter репортер
102
+ * @returns {Promise<void>}
103
+ */
104
+ async function checkBaseSegment(absBase, cwd, reporter) {
105
+ for (const ruleEntry of await readdir(absBase, { withFileTypes: true })) {
106
+ if (!ruleEntry.isDirectory() || ruleEntry.name.startsWith('.')) continue
107
+
108
+ const jsDir = join(absBase, ruleEntry.name, 'js')
109
+ if (!existsSync(jsDir)) continue
110
+
111
+ await checkJsDir(jsDir, cwd, reporter)
112
+ }
113
+ }
114
+
44
115
  /**
45
116
  * Сканує `npm/rules/*\/js/*.mjs` і `npm/skills/*\/js/*.mjs`.
46
117
  * Якщо поряд існує `docs/<stem>.md` — module-level JSDoc має бути pointer (≤1 рядок),
@@ -54,33 +125,7 @@ export async function check(cwd = process.cwd()) {
54
125
  for (const baseSegment of ['npm/rules', 'npm/skills']) {
55
126
  const absBase = join(cwd, baseSegment)
56
127
  if (!existsSync(absBase)) continue
57
-
58
- for (const ruleEntry of await readdir(absBase, { withFileTypes: true })) {
59
- if (!ruleEntry.isDirectory() || ruleEntry.name.startsWith('.')) continue
60
-
61
- const jsDir = join(absBase, ruleEntry.name, 'js')
62
- if (!existsSync(jsDir)) continue
63
-
64
- for (const fileEntry of await readdir(jsDir, { withFileTypes: true })) {
65
- if (!fileEntry.isFile() || !fileEntry.name.endsWith('.mjs') || fileEntry.name.endsWith('.test.mjs')) continue
66
-
67
- const stem = basename(fileEntry.name, '.mjs')
68
- const docsPath = join(jsDir, 'docs', `${stem}.md`)
69
- if (!existsSync(docsPath)) continue
70
-
71
- const filePath = join(jsDir, fileEntry.name)
72
- const source = await readFile(filePath, 'utf8')
73
- const block = moduleJsDoc(source)
74
- if (!block) continue
75
-
76
- const count = contentLineCount(block)
77
- if (count > 1) {
78
- reporter.fail(
79
- `${filePath.slice(cwd.length + 1)}: docs/${stem}.md вже описує поведінку — module-level JSDoc має бути pointer (≤1 рядок, зараз ${count})`
80
- )
81
- }
82
- }
83
- }
128
+ await checkBaseSegment(absBase, cwd, reporter)
84
129
  }
85
130
 
86
131
  return reporter.getExitCode()