@nitra/cursor 1.8.220 → 1.8.221

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.
@@ -325,6 +325,18 @@ const K8S_BASE_SEGMENT_RE = /(^|\/)k8s\/base\//u
325
325
  const OXLINT_SCHEMA_MODELINE_RE = /^\s*#\s*yaml-language-server:\s*\$schema=\S+/u
326
326
  const HTTPS_SCHEMA_RE = /^https:/iu
327
327
  const HASURA_GRAPHQL_ENGINE_RE = /(^|\/)hasura\/graphql-engine(?::|$)/u
328
+ const BASE_CANON_MEMORY_RE = /^128Mi$/iu
329
+
330
+ /**
331
+ * Видаляє хвостові символи `\n` зі стрічки без regex (щоб не тригерити sonarjs/slow-regex).
332
+ * @param {string} s стрічка YAML/тексту
333
+ * @returns {string} стрічка без trailing newlines
334
+ */
335
+ function stripTrailingNewlines(s) {
336
+ let end = s.length
337
+ while (end > 0 && s.codePointAt(end - 1) === 10) end--
338
+ return end === s.length ? s : s.slice(0, end)
339
+ }
328
340
  const BATCH_V1BETA1_API_VERSION_LINE_RE = /^(\s*apiVersion:\s*)["']?batch\/v1beta1["']?(\s*)$/u
329
341
 
330
342
  /**
@@ -665,15 +677,25 @@ async function validateKustomizationPatchesStructuralSort(root, yamlFilesAbs, fa
665
677
  if (kust === null) continue
666
678
  const outer = kustomizationPatchesSortedViolation(kust)
667
679
  if (outer !== null) fail(`${rel}: ${outer}`)
668
- const patches = kust.patches
669
- if (!Array.isArray(patches)) continue
670
- for (const [i, p] of patches.entries()) {
671
- if (p === null || typeof p !== 'object' || Array.isArray(p)) continue
672
- const rec = /** @type {Record<string, unknown>} */ (p)
673
- if (typeof rec.patch !== 'string') continue
674
- const v = kustomizationInlinePatchOpsSortedViolation(rec.patch)
675
- if (v !== null) fail(`${rel}: patches[${i}] (${kustomizationPatchLabel(p, i)}): ${v}`)
676
- }
680
+ if (!Array.isArray(kust.patches)) continue
681
+ validateInlinePatchesSorted(rel, kust.patches, fail)
682
+ }
683
+ }
684
+
685
+ /**
686
+ * Перевіряє, що inline-`patch:` (рядок YAML/JSON) у кожному `patches[i]` має ops у канонічному порядку
687
+ * (`add`/`replace` за `path`). Чужі форми (без `patch`-стрічки, з `target` без inline-блока) пропускаються.
688
+ * @param {string} rel відносний шлях `kustomization.yaml` для повідомлень
689
+ * @param {unknown[]} patches масив `kust.patches` (рекордів)
690
+ * @param {(msg: string) => void} fail callback при порушенні
691
+ */
692
+ function validateInlinePatchesSorted(rel, patches, fail) {
693
+ for (const [i, p] of patches.entries()) {
694
+ if (p === null || typeof p !== 'object' || Array.isArray(p)) continue
695
+ const rec = /** @type {Record<string, unknown>} */ (p)
696
+ if (typeof rec.patch !== 'string') continue
697
+ const v = kustomizationInlinePatchOpsSortedViolation(rec.patch)
698
+ if (v !== null) fail(`${rel}: patches[${i}] (${kustomizationPatchLabel(p, i)}): ${v}`)
677
699
  }
678
700
  }
679
701
 
@@ -2582,7 +2604,7 @@ function isBaseCanonCpuValue(cpu) {
2582
2604
  */
2583
2605
  function isBaseCanonMemoryValue(mem) {
2584
2606
  if (typeof mem !== 'string' || mem.trim() === '') return false
2585
- return /^128Mi$/iu.test(mem.trim())
2607
+ return BASE_CANON_MEMORY_RE.test(mem.trim())
2586
2608
  }
2587
2609
 
2588
2610
  /**
@@ -5212,13 +5234,11 @@ function checkProdOverridesInKustomization(kust, rel, fail, passFn, needs) {
5212
5234
  ok = false
5213
5235
  }
5214
5236
  }
5215
- if (needs.needsPdbMinAvailablePatch) {
5216
- if (!pdbPaths.has('/spec/minAvailable')) {
5217
- fail(
5218
- `${rel}: прод-оверлей має перевизначати spec.minAvailable для PodDisruptionBudget (мінімум 1 у проді) (k8s.mdc)`
5219
- )
5220
- ok = false
5221
- }
5237
+ if (needs.needsPdbMinAvailablePatch && !pdbPaths.has('/spec/minAvailable')) {
5238
+ fail(
5239
+ `${rel}: прод-оверлей має перевизначати spec.minAvailable для PodDisruptionBudget (мінімум 1 у проді) (k8s.mdc)`
5240
+ )
5241
+ ok = false
5222
5242
  }
5223
5243
  if (ok) {
5224
5244
  passFn(`${rel}: прод-оверрайди HPA/PDB за потреби присутні (k8s.mdc)`)
@@ -5573,18 +5593,7 @@ async function validateDeploymentsInDir(deployments, dir, root, fail, passFn) {
5573
5593
  const isK8sBaseLayer = isK8sYamlUnderBaseDirectory(`${relDir}/probe.yaml`)
5574
5594
  const deployRel = relDir === '' ? '.' : relDir
5575
5595
  if (isK8sBaseLayer && deployments.length > 0) {
5576
- const hpaAbs = join(dir, HPA_FILENAME)
5577
- if (existsSync(hpaAbs)) {
5578
- fail(
5579
- `${deployRel}/${HPA_FILENAME}: у шарі k8s/.../base не тримай локальний hpa.yaml — HPA живе у sibling components/ (k8s.mdc)`
5580
- )
5581
- }
5582
- const pdbAbs = join(dir, PDB_FILENAME)
5583
- if (existsSync(pdbAbs)) {
5584
- fail(
5585
- `${deployRel}/${PDB_FILENAME}: у шарі k8s/.../base не тримай локальний pdb.yaml — PDB живе у sibling components/ (k8s.mdc)`
5586
- )
5587
- }
5596
+ failIfBaseLayerHasLocalHpaOrPdb(dir, deployRel, fail)
5588
5597
  }
5589
5598
  const hpaDocs = isK8sBaseLayer ? [] : await readDocsByKindInDir(dir, 'HorizontalPodAutoscaler', HPA_FILENAME)
5590
5599
  const pdbDocs = isK8sBaseLayer ? [] : await readDocsByKindInDir(dir, 'PodDisruptionBudget', PDB_FILENAME)
@@ -5600,15 +5609,47 @@ async function validateDeploymentsInDir(deployments, dir, root, fail, passFn) {
5600
5609
  passFn
5601
5610
  )
5602
5611
  if (isK8sBaseLayer) {
5603
- const deployName = manifestMetadataName(deployment)
5604
- const appLabel = deploymentAppLabel(deployment)
5605
- if (deployName !== null && appLabel !== null) {
5606
- await validateComponentsForBaseDeployment(dir, deployName, appLabel, root, fail, passFn)
5607
- }
5612
+ await validateBaseLayerComponentsIfNamed(deployment, dir, root, fail, passFn)
5608
5613
  }
5609
5614
  }
5610
5615
  }
5611
5616
 
5617
+ /**
5618
+ * У шарі `…/k8s/…/base/` забороняє локальні `hpa.yaml` / `pdb.yaml` (вони мають жити у sibling `components/`).
5619
+ * @param {string} dir абсолютний каталог Deployment-маніфесту
5620
+ * @param {string} deployRel відносний шлях для повідомлень (`.` якщо корінь репо)
5621
+ * @param {(msg: string) => void} fail callback при порушенні
5622
+ */
5623
+ function failIfBaseLayerHasLocalHpaOrPdb(dir, deployRel, fail) {
5624
+ if (existsSync(join(dir, HPA_FILENAME))) {
5625
+ fail(
5626
+ `${deployRel}/${HPA_FILENAME}: у шарі k8s/.../base не тримай локальний hpa.yaml — HPA живе у sibling components/ (k8s.mdc)`
5627
+ )
5628
+ }
5629
+ if (existsSync(join(dir, PDB_FILENAME))) {
5630
+ fail(
5631
+ `${deployRel}/${PDB_FILENAME}: у шарі k8s/.../base не тримай локальний pdb.yaml — PDB живе у sibling components/ (k8s.mdc)`
5632
+ )
5633
+ }
5634
+ }
5635
+
5636
+ /**
5637
+ * Якщо у Deployment є `metadata.name` і `spec.selector.matchLabels.app` — викликає
5638
+ * `validateComponentsForBaseDeployment` для звірки sibling-`components/`. Без цих ключів
5639
+ * каталог `components/` неможливо звʼязати з конкретним Deployment, тож пропускаємо мовчки.
5640
+ * @param {Record<string, unknown>} deployment AST документа Deployment
5641
+ * @param {string} dir абсолютний каталог Deployment-маніфесту
5642
+ * @param {string} root абсолютний корінь репо
5643
+ * @param {(msg: string) => void} fail callback при порушенні
5644
+ * @param {(msg: string) => void} passFn callback при успіху
5645
+ */
5646
+ async function validateBaseLayerComponentsIfNamed(deployment, dir, root, fail, passFn) {
5647
+ const deployName = manifestMetadataName(deployment)
5648
+ const appLabel = deploymentAppLabel(deployment)
5649
+ if (deployName === null || appLabel === null) return
5650
+ await validateComponentsForBaseDeployment(dir, deployName, appLabel, root, fail, passFn)
5651
+ }
5652
+
5612
5653
  /**
5613
5654
  * Витягує документи Deployment з YAML-файлу (повертає порожній масив, якщо файл недоступний або немає Deployment).
5614
5655
  * @param {string} filePath абсолютний шлях до YAML-файлу
@@ -6302,6 +6343,20 @@ function applyConversionsToDoc(doc, conversions) {
6302
6343
  const patchesNode = doc.get('patches', true)
6303
6344
  if (!isSeq(patchesNode)) return false
6304
6345
 
6346
+ applyPatchConversionsToPatchesNode(patchesNode, groupConversionsByPatchIndex(conversions))
6347
+ if (patchesNode.items.length === 0) {
6348
+ doc.delete('patches')
6349
+ }
6350
+ appendConvertedImagesNode(doc, conversions)
6351
+ return true
6352
+ }
6353
+
6354
+ /**
6355
+ * Згруповує конвертації за індексом `patches[i]` і збирає `opIdx`-список ops, які треба видалити.
6356
+ * @param {Array<{ index: number, opIndex: number, totalOps: number }>} conversions конвертації
6357
+ * @returns {Map<number, { totalOps: number, opIdx: number[] }>} згруповане
6358
+ */
6359
+ function groupConversionsByPatchIndex(conversions) {
6305
6360
  /** @type {Map<number, { totalOps: number, opIdx: number[] }>} */
6306
6361
  const byPatch = new Map()
6307
6362
  for (const c of conversions) {
@@ -6309,39 +6364,61 @@ function applyConversionsToDoc(doc, conversions) {
6309
6364
  slot.opIdx.push(c.opIndex)
6310
6365
  byPatch.set(c.index, slot)
6311
6366
  }
6367
+ return byPatch
6368
+ }
6312
6369
 
6370
+ /**
6371
+ * Застосовує згруповані конвертації до `patches:` Sequence: видаляє повністю-конвертовані
6372
+ * patches або переписує inline `patch:` без конвертованих ops. Іде в порядку спадання
6373
+ * індексів, щоб зберегти стабільність вилучень з масиву.
6374
+ * @param {import('yaml').YAMLSeq & { get(i: number, keep: true): unknown, delete(i: number): void, items: unknown[] }} patchesNode YAML Seq (звужено через `isSeq` у caller-і)
6375
+ * @param {Map<number, { totalOps: number, opIdx: number[] }>} byPatch згруповані конвертації
6376
+ */
6377
+ function applyPatchConversionsToPatchesNode(patchesNode, byPatch) {
6313
6378
  const sortedIdx = [...byPatch.keys()].toSorted((a, b) => b - a)
6314
6379
  for (const i of sortedIdx) {
6315
6380
  const slot = byPatch.get(i)
6316
6381
  if (slot === undefined) continue
6317
- const { totalOps, opIdx } = slot
6318
- if (opIdx.length === totalOps) {
6382
+ if (slot.opIdx.length === slot.totalOps) {
6319
6383
  patchesNode.delete(i)
6320
6384
  continue
6321
6385
  }
6322
- const patchEntry = patchesNode.get(i, true)
6323
- if (patchEntry === undefined || patchEntry === null) continue
6324
- const patchScalar = patchEntry.get('patch', true)
6325
- if (patchScalar === undefined || patchScalar === null || typeof patchScalar.value !== 'string') continue
6326
- const rewritten = rewriteInlinePatchWithoutOps(patchScalar.value, opIdx)
6327
- if (rewritten === null) continue
6328
- patchScalar.value = rewritten
6386
+ rewriteInlinePatchAtIndex(patchesNode, i, slot.opIdx)
6329
6387
  }
6388
+ }
6330
6389
 
6331
- if (patchesNode.items.length === 0) {
6332
- doc.delete('patches')
6333
- }
6390
+ /**
6391
+ * Переписує inline `patch:` у `patches[i]`, видаляючи ops зі списку. Якщо вузол не знайдено
6392
+ * або переписування не вдалося — залишає Document без змін.
6393
+ * @param {import('yaml').YAMLSeq & { get(i: number, keep: true): unknown, delete(i: number): void, items: unknown[] }} patchesNode YAML Seq (звужено через `isSeq` у caller-і)
6394
+ * @param {number} i індекс у `patches:`
6395
+ * @param {number[]} opIdx індекси ops для видалення
6396
+ */
6397
+ function rewriteInlinePatchAtIndex(patchesNode, i, opIdx) {
6398
+ const patchEntry = patchesNode.get(i, true)
6399
+ if (patchEntry === undefined || patchEntry === null) return
6400
+ const patchScalar = patchEntry.get('patch', true)
6401
+ if (patchScalar === undefined || patchScalar === null || typeof patchScalar.value !== 'string') return
6402
+ const rewritten = rewriteInlinePatchWithoutOps(patchScalar.value, opIdx)
6403
+ if (rewritten === null) return
6404
+ patchScalar.value = rewritten
6405
+ }
6334
6406
 
6335
- let imagesNode = doc.get('images', true)
6336
- if (!isSeq(imagesNode)) {
6337
- imagesNode = doc.createNode([])
6407
+ /**
6408
+ * Дописує `images:` Seq у Document результатами конвертацій (створює, якщо немає).
6409
+ * @param {import('yaml').Document} doc YAML Document
6410
+ * @param {Array<{ name: string, newName: string, newTag: string | null }>} conversions конвертації
6411
+ */
6412
+ function appendConvertedImagesNode(doc, conversions) {
6413
+ const existing = doc.get('images', true)
6414
+ const imagesNode = isSeq(existing) ? existing : doc.createNode([])
6415
+ if (existing !== imagesNode) {
6338
6416
  doc.set('images', imagesNode)
6339
6417
  }
6340
6418
  for (const { name, newName, newTag } of conversions) {
6341
6419
  const entry = newTag === null ? { name, newName } : { name, newName, newTag }
6342
6420
  imagesNode.add(doc.createNode(entry))
6343
6421
  }
6344
- return true
6345
6422
  }
6346
6423
 
6347
6424
  /**
@@ -6370,7 +6447,7 @@ function rewriteInlinePatchWithoutOps(patchText, opIndices) {
6370
6447
  }
6371
6448
  if (seq.items.length === 0) return null
6372
6449
  seq.flow = false
6373
- return inner.toString().replace(/\n+$/u, '')
6450
+ return stripTrailingNewlines(inner.toString())
6374
6451
  }
6375
6452
 
6376
6453
  /**
@@ -33,9 +33,6 @@ const CHANGELOG_FIRST_VERSION_RE = /^## \[([^\]]+)\]/m
33
33
  /** Поле `version` у текстовому зрізі `package.json` (для `git show HEAD:npm/package.json`). */
34
34
  const PACKAGE_JSON_VERSION_RE = /"version":\s*"([^"]+)"/u
35
35
 
36
- /** Канонічний entrypoint типів для пакетів із вихідним `.js` під каталогом `npm/src` */
37
- const TYPES_INDEX = './types/index.d.ts'
38
-
39
36
  /** Файл проєкту TypeScript для emit без каталогу `src` (див. npm-module.mdc) */
40
37
  const EMIT_TYPES_CONFIG = 'npm/tsconfig.emit-types.json'
41
38
 
@@ -198,7 +195,7 @@ async function gitDiffNameOnlyNpm() {
198
195
  async function gitShowNpmPackageVersionAt(refPath) {
199
196
  try {
200
197
  const { stdout } = await execFileAsync('git', ['show', refPath], { encoding: 'utf8' })
201
- const m = stdout.match(/"version":\s*"([^"]+)"/)
198
+ const m = stdout.match(PACKAGE_JSON_VERSION_RE)
202
199
  return m ? m[1] : null
203
200
  } catch {
204
201
  return null
@@ -15,9 +15,9 @@ import { createCheckReporter } from './utils/check-reporter.mjs'
15
15
 
16
16
  /**
17
17
  * Перевіряє відповідність проєкту правилам php.mdc.
18
- * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
18
+ * @returns {number} 0 — все OK, 1 — є проблеми
19
19
  */
20
- export async function check() {
20
+ export function check() {
21
21
  const reporter = createCheckReporter()
22
22
  const { pass, fail } = reporter
23
23
 
@@ -27,10 +27,10 @@ export async function check() {
27
27
  fail('composer.json не знайдено в корені — додай (php.mdc)')
28
28
  }
29
29
 
30
- if (!existsSync('package.json')) {
31
- fail('package.json не знайдено в корені — додай (php.mdc)')
32
- } else {
30
+ if (existsSync('package.json')) {
33
31
  pass('package.json є (наявність lint-php перевіряє bun run lint-conftest → php.package_json)')
32
+ } else {
33
+ fail('package.json не знайдено в корені — додай (php.mdc)')
34
34
  }
35
35
 
36
36
  const wfPath = '.github/workflows/lint-php.yml'
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Stop-hook для Claude Code: запускається hook'ом із `.claude/settings.json` після того,
3
- * як агент сигналізує завершення ходу. Прозоро прокидає `npx @nitra/cursor check`
3
+ * як агент сигналізує завершення ходу. Прозоро прокидає `npx \@nitra/cursor check`
4
4
  * і повертає його exit code, щоб помилки правил блокували завершення.
5
5
  *
6
6
  * Захист від нескінченної рекурсії: якщо stdin містить `"stop_hook_active": true`
@@ -8,7 +8,7 @@
8
8
  * виходимо з кодом 0 без повторного запуску перевірок.
9
9
  *
10
10
  * Виклик з `bin/n-cursor.js`:
11
- * `npx --no @nitra/cursor stop-hook`
11
+ * `npx --no \@nitra/cursor stop-hook`
12
12
  */
13
13
  import { spawn } from 'node:child_process'
14
14
  import { once } from 'node:events'
@@ -42,7 +42,6 @@ const POLICY_DIR = join(PACKAGE_ROOT, 'policy')
42
42
  * в інших скриптах: `node_modules`, `.git`, `dist`, `coverage`, `build`,
43
43
  * `.turbo`, `.next`. Не використовуємо bun Glob, щоб не плодити залежності
44
44
  * за межами `node:fs`.
45
- *
46
45
  * @typedef {{
47
46
  * namespace: string,
48
47
  * policyDir: string,
@@ -72,6 +71,15 @@ function loadActiveCursorRules(cwd) {
72
71
 
73
72
  const SKIP_DIR_NAMES = new Set(['node_modules', '.git', 'dist', 'coverage', 'build', '.turbo', '.next'])
74
73
 
74
+ /** `…/k8s/<env>/configmap.yaml` (configmap безпосередньо у directory `k8s/<…>`). */
75
+ const K8S_CONFIGMAP_PATH_RE = /(^|\/)k8s\/[^/]+\/configmap\.yaml$/u
76
+ /** Будь-який шлях під сегментом `k8s/`. */
77
+ const K8S_DIR_PATH_RE = /(^|\/)k8s\//u
78
+ /** `…/k8s/<…>/hc.yaml` (HealthCheckPolicy будь-де під k8s). */
79
+ const K8S_HC_YAML_PATH_RE = /(^|\/)k8s\/.+\/hc\.yaml$/u
80
+ /** `…/k8s/…/base/…/hr.yaml` (HTTPRoute у base-шарі). */
81
+ const K8S_BASE_HR_YAML_PATH_RE = /(^|\/)k8s\/.*base\/.*hr\.yaml$/u
82
+
75
83
  /** @type {ConftestTarget[]} */
76
84
  const TARGETS = [
77
85
  // ── bun ─────────────────────────────────────────────────────────────────
@@ -207,7 +215,7 @@ const TARGETS = [
207
215
  namespace: 'js_run.configmap',
208
216
  policyDir: 'js_run',
209
217
  rule: 'js-run',
210
- walk: { match: rel => /(^|\/)k8s\/[^/]+\/configmap\.yaml$/.test(rel) }
218
+ walk: { match: rel => K8S_CONFIGMAP_PATH_RE.test(rel) }
211
219
  },
212
220
 
213
221
  // Усі YAML у дереві з сегментом `k8s` — пер-документні структурні правила.
@@ -215,7 +223,7 @@ const TARGETS = [
215
223
  namespace: 'k8s.manifest',
216
224
  policyDir: 'k8s',
217
225
  rule: 'k8s',
218
- walk: { match: rel => /(^|\/)k8s\//.test(rel) && (rel.endsWith('.yaml') || rel.endsWith('.yml')) }
226
+ walk: { match: rel => K8S_DIR_PATH_RE.test(rel) && (rel.endsWith('.yaml') || rel.endsWith('.yml')) }
219
227
  },
220
228
 
221
229
  // abie HealthCheckPolicy: `hc.yaml` у дереві k8s.
@@ -223,7 +231,7 @@ const TARGETS = [
223
231
  namespace: 'abie.health_check_policy',
224
232
  policyDir: 'abie',
225
233
  rule: 'abie',
226
- walk: { match: rel => /(^|\/)k8s\/.+\/hc\.yaml$/.test(rel) }
234
+ walk: { match: rel => K8S_HC_YAML_PATH_RE.test(rel) }
227
235
  },
228
236
 
229
237
  // abie HTTPRoute у `base/`.
@@ -231,7 +239,7 @@ const TARGETS = [
231
239
  namespace: 'abie.http_route_base',
232
240
  policyDir: 'abie',
233
241
  rule: 'abie',
234
- walk: { match: rel => /(^|\/)k8s\/.*base\/.*hr\.yaml$/.test(rel) }
242
+ walk: { match: rel => K8S_BASE_HR_YAML_PATH_RE.test(rel) }
235
243
  }
236
244
  ]
237
245
 
@@ -245,7 +253,7 @@ const TARGETS = [
245
253
  function collectFiles(root, match) {
246
254
  /** @type {string[]} */
247
255
  const out = []
248
- /** @param {string} dirAbs */
256
+ /** @param {string} dirAbs абсолютний шлях каталогу для рекурсивного обходу */
249
257
  function visit(dirAbs) {
250
258
  /** @type {import('node:fs').Dirent[]} */
251
259
  let entries
@@ -355,5 +363,5 @@ export function runLintConftestCli() {
355
363
  }
356
364
 
357
365
  if (import.meta.url === `file://${process.argv[1]}`) {
358
- process.exit(runLintConftestCli())
366
+ process.exitCode = runLintConftestCli()
359
367
  }
@@ -214,7 +214,7 @@ export function runLintGaCli() {
214
214
  * Поведінка fallback:
215
215
  * - якщо `conftest` не знайдено в PATH — друкуємо `ℹ` повідомлення з підказкою встановлення й
216
216
  * повертаємо 0 (тобто конфтест поки що **не** є обовʼязковою залежністю lint-ga; перевірки лежать
217
- * паралельно в `check-ga.mjs`, і `npx @nitra/cursor check ga` все одно їх запустить);
217
+ * паралельно в `check-ga.mjs`, і `npx \@nitra/cursor check ga` все одно їх запустить);
218
218
  * - якщо `conftest` є й полісі-каталог відсутній (нетипова інсталяція) — також `ℹ` skip;
219
219
  * - якщо є цільовий workflow і conftest — запускаємо `conftest test <workflow> -p <policy-dir>` і
220
220
  * повертаємо його exit-код, щоб порушення зупиняли lint-ga, як це робить actionlint/zizmor.
@@ -65,22 +65,25 @@ function printPatchInstallHints() {
65
65
  * @returns {string[]} відсортований масив шляхів відносно cwd
66
66
  */
67
67
  export function listShellScriptPaths(cwd) {
68
- const gitOk = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
69
- cwd,
70
- encoding: 'utf8',
71
- env: process.env
72
- })
73
- if (gitOk.status === 0 && gitOk.stdout.trim() === 'true') {
74
- const ls = spawnSync('git', ['ls-files', '-z', '--', ':(glob)**/*.sh'], {
68
+ const gitPath = resolveCmd('git')
69
+ if (gitPath) {
70
+ const gitOk = spawnSync(gitPath, ['rev-parse', '--is-inside-work-tree'], {
75
71
  cwd,
76
72
  encoding: 'utf8',
77
73
  env: process.env
78
74
  })
79
- if (ls.status !== 0) {
80
- return []
75
+ if (gitOk.status === 0 && gitOk.stdout.trim() === 'true') {
76
+ const ls = spawnSync(gitPath, ['ls-files', '-z', '--', ':(glob)**/*.sh'], {
77
+ cwd,
78
+ encoding: 'utf8',
79
+ env: process.env
80
+ })
81
+ if (ls.status !== 0) {
82
+ return []
83
+ }
84
+ const files = ls.stdout.split('\0').filter(Boolean)
85
+ return new Set(files).toSorted()
81
86
  }
82
- const files = ls.stdout.split('\0').filter(Boolean)
83
- return new Set(files).toSorted()
84
87
  }
85
88
 
86
89
  const fromGlob = globSync('**/*.sh', {
@@ -114,51 +117,87 @@ export function runShellcheckText(cwd = process.cwd()) {
114
117
  }
115
118
 
116
119
  for (const rel of files) {
117
- for (let round = 0; round < MAX_FIX_ROUNDS_PER_FILE; round++) {
118
- const diffResult = spawnSync(shellcheck, ['-f', 'diff', rel], {
119
- cwd: root,
120
- encoding: 'utf8',
121
- env: process.env,
122
- maxBuffer: 10 * 1024 * 1024
123
- })
124
-
125
- if (diffResult.error) {
126
- process.stderr.write(`${diffResult.error.message}\n`)
127
- return 1
128
- }
129
-
130
- const code = diffResult.status ?? 1
131
- const out = (diffResult.stdout ?? '').trim()
132
- const err = (diffResult.stderr ?? '').trim()
133
-
134
- if (code === 0) {
135
- break
136
- }
137
-
138
- if (err.includes(NON_AUTOFIXABLE_HINT) || !out) {
139
- break
140
- }
120
+ const fixCode = autofixOneFile(shellcheck, patchBin, root, rel)
121
+ if (fixCode !== 0) return fixCode
122
+ }
141
123
 
142
- const patchRun = spawnSync(patchBin, ['-p1'], {
143
- cwd: root,
144
- input: diffResult.stdout ?? '',
145
- encoding: 'utf8',
146
- env: process.env
147
- })
124
+ return runFinalShellcheck(shellcheck, files, root)
125
+ }
148
126
 
149
- if (patchRun.status !== 0) {
150
- if (patchRun.stderr?.length) {
151
- process.stderr.write(patchRun.stderr)
152
- }
153
- if (patchRun.stdout?.length) {
154
- process.stderr.write(patchRun.stdout)
155
- }
156
- process.stderr.write(`run-shellcheck-text: patch не застосував diff для ${rel}\n`)
157
- return 1
158
- }
127
+ /**
128
+ * Запускає до `MAX_FIX_ROUNDS_PER_FILE` ітерацій `shellcheck -f diff` + `patch` для одного файла.
129
+ * Виходить з 0 у випадках: shellcheck повернув 0, нема autofixable, або порожній diff.
130
+ * @param {string} shellcheck абсолютний шлях до shellcheck
131
+ * @param {string} patchBin абсолютний шлях до patch
132
+ * @param {string} root абсолютний робочий каталог (cwd для spawn)
133
+ * @param {string} rel відносний шлях файла від `root`
134
+ * @returns {number} 0 OK; 1 — помилка spawn або patch
135
+ */
136
+ function autofixOneFile(shellcheck, patchBin, root, rel) {
137
+ for (let round = 0; round < MAX_FIX_ROUNDS_PER_FILE; round++) {
138
+ const diffResult = spawnSync(shellcheck, ['-f', 'diff', rel], {
139
+ cwd: root,
140
+ encoding: 'utf8',
141
+ env: process.env,
142
+ maxBuffer: 10 * 1024 * 1024
143
+ })
144
+ if (diffResult.error) {
145
+ process.stderr.write(`${diffResult.error.message}\n`)
146
+ return 1
159
147
  }
148
+ if (shouldStopAutofixLoop(diffResult)) return 0
149
+ const patchCode = applyShellcheckDiff(patchBin, root, rel, diffResult.stdout ?? '')
150
+ if (patchCode !== 0) return patchCode
160
151
  }
152
+ return 0
153
+ }
154
+
155
+ /**
156
+ * Чи треба зупинити цикл авто-фіксів: shellcheck повернув 0, або у stderr є пометка
157
+ * `none were auto-fixable`, або stdout порожній (нема дифу для застосування).
158
+ * @param {{ status: number | null, stdout?: string | null, stderr?: string | null }} diffResult результат spawnSync
159
+ * @returns {boolean} true — більше нічого фіксити
160
+ */
161
+ function shouldStopAutofixLoop(diffResult) {
162
+ const code = diffResult.status ?? 1
163
+ if (code === 0) return true
164
+ const out = (diffResult.stdout ?? '').trim()
165
+ const err = (diffResult.stderr ?? '').trim()
166
+ return err.includes(NON_AUTOFIXABLE_HINT) || !out
167
+ }
161
168
 
169
+ /**
170
+ * Застосовує `shellcheck -f diff`-вивід через `patch -p1`. На помилку виливає stdout/stderr від patch
171
+ * у `process.stderr` (щоб користувач бачив, чому не застосувалося) і повертає 1.
172
+ * @param {string} patchBin абсолютний шлях до patch
173
+ * @param {string} root cwd для spawn
174
+ * @param {string} rel відносний шлях для повідомлення про помилку
175
+ * @param {string} diffStdout вміст unified-diff від shellcheck (input для patch)
176
+ * @returns {number} 0 — застосовано; 1 — помилка
177
+ */
178
+ function applyShellcheckDiff(patchBin, root, rel, diffStdout) {
179
+ const patchRun = spawnSync(patchBin, ['-p1'], {
180
+ cwd: root,
181
+ input: diffStdout,
182
+ encoding: 'utf8',
183
+ env: process.env
184
+ })
185
+ if (patchRun.status === 0) return 0
186
+ if (patchRun.stderr?.length) process.stderr.write(patchRun.stderr)
187
+ if (patchRun.stdout?.length) process.stderr.write(patchRun.stdout)
188
+ process.stderr.write(`run-shellcheck-text: patch не застосував diff для ${rel}\n`)
189
+ return 1
190
+ }
191
+
192
+ /**
193
+ * Фінальний прогон `shellcheck` по всіх файлах — без `-f diff`, щоб отримати звичайний звіт.
194
+ * Будь-який ненульовий код shellcheck-а пробрасує як 1 (з виводом stdout/stderr на користувацькі stream-и).
195
+ * @param {string} shellcheck абсолютний шлях до shellcheck
196
+ * @param {string[]} files відносні шляхи файлів для перевірки
197
+ * @param {string} root cwd для spawn
198
+ * @returns {number} 0 — чисто; 1 — помилка spawn або зауваження shellcheck
199
+ */
200
+ function runFinalShellcheck(shellcheck, files, root) {
162
201
  const finalRun = spawnSync(shellcheck, files, {
163
202
  cwd: root,
164
203
  encoding: 'utf8',
@@ -166,23 +205,14 @@ export function runShellcheckText(cwd = process.cwd()) {
166
205
  maxBuffer: 10 * 1024 * 1024,
167
206
  stdio: ['ignore', 'pipe', 'pipe']
168
207
  })
169
-
170
208
  if (finalRun.error) {
171
209
  process.stderr.write(`${finalRun.error.message}\n`)
172
210
  return 1
173
211
  }
174
-
175
- if (finalRun.status !== 0) {
176
- if (finalRun.stdout?.length) {
177
- process.stdout.write(finalRun.stdout)
178
- }
179
- if (finalRun.stderr?.length) {
180
- process.stderr.write(finalRun.stderr)
181
- }
182
- return 1
183
- }
184
-
185
- return 0
212
+ if (finalRun.status === 0) return 0
213
+ if (finalRun.stdout?.length) process.stdout.write(finalRun.stdout)
214
+ if (finalRun.stderr?.length) process.stderr.write(finalRun.stderr)
215
+ return 1
186
216
  }
187
217
 
188
218
  if (isRunAsCli()) {
@@ -21,7 +21,7 @@ import { existsSync } from 'node:fs'
21
21
  import { chmod, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
22
22
  import { join } from 'node:path'
23
23
 
24
- /** Маркер lint Stop-hook'а (`npx --no @nitra/cursor stop-hook`). */
24
+ /** Маркер lint Stop-hook'а (`npx --no \@nitra/cursor stop-hook`). */
25
25
  export const MANAGED_HOOK_COMMAND_MARKER = '@nitra/cursor stop-hook'
26
26
  /** Маркер ADR Stop-hook'а — підрядок шляху до bash-скрипта. */
27
27
  export const ADR_HOOK_COMMAND_MARKER = '.claude/hooks/capture-decisions.sh'
@@ -175,3 +175,31 @@ export function templateQuasisText(template) {
175
175
  export function isSqlListContextTemplate(template) {
176
176
  return SQL_LIST_CONTEXT_RE.test(templateQuasisText(template))
177
177
  }
178
+
179
+ /**
180
+ * Перевіряє, чи це виклик `require('<module>')` з рядковим аргументом.
181
+ * Спільне для сканерів імпортів (`bunyan-imports`, `redis-imports`, ...).
182
+ * @param {Record<string, unknown> | null | undefined} node вузол AST
183
+ * @returns {string | null} ім'я модуля з аргументу, інакше `null`
184
+ */
185
+ export function requireCallModule(node) {
186
+ if (!node || node.type !== 'CallExpression') return null
187
+ const callee = node.callee
188
+ if (!callee || callee.type !== 'Identifier' || callee.name !== 'require') return null
189
+ const arg = node.arguments?.[0]
190
+ if (!arg || arg.type !== 'Literal' || typeof arg.value !== 'string') return null
191
+ return arg.value
192
+ }
193
+
194
+ /**
195
+ * Перевіряє, чи це динамічний `import('<module>')` з рядковим аргументом.
196
+ * Спільне для сканерів імпортів.
197
+ * @param {Record<string, unknown> | null | undefined} node вузол AST
198
+ * @returns {string | null} ім'я модуля, інакше `null`
199
+ */
200
+ export function dynamicImportModule(node) {
201
+ if (!node || node.type !== 'ImportExpression') return null
202
+ const src = node.source
203
+ if (!src || src.type !== 'Literal' || typeof src.value !== 'string') return null
204
+ return src.value
205
+ }