@nitra/cursor 1.8.100 → 1.8.103

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/bin/n-cursor.js CHANGED
@@ -68,6 +68,11 @@ const BUNDLED_AGENTS_TEMPLATE_PATH = join(binDir, '..', AGENTS_TEMPLATE_FILE)
68
68
  /** Корінь установленого пакету (каталог з `mdc/`, `github-actions/`, …) */
69
69
  const BUNDLED_PACKAGE_ROOT = join(binDir, '..')
70
70
 
71
+ const YAML_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/
72
+ const SKILL_DESCRIPTION_RE = /description:\s*>-\s*\r?\n((?:[ \t]+[^\n]*(?:\r?\n|$))+)/m
73
+ const NEWLINE_RE = /\r?\n/
74
+ const LEADING_SPACES_RE = /^\s+/
75
+
71
76
  /**
72
77
  * Імена правил (без .mdc) з каталогу mdc поточної інсталяції пакету
73
78
  * @param {string} [bundledMdcDir] каталог `mdc/` у корені пакету (за замовчуванням — з поточного процесу)
@@ -192,7 +197,7 @@ async function readConfig(paths = {}) {
192
197
  }
193
198
 
194
199
  if (config.$schema !== CONFIG_SCHEMA_URL) {
195
- const { $schema: _omit, ...rest } = config
200
+ const rest = Object.fromEntries(Object.entries(config).filter(([k]) => k !== '$schema'))
196
201
  const normalized = { $schema: CONFIG_SCHEMA_URL, ...rest }
197
202
  await writeFile(configPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
198
203
  console.log(`📝 Оновлено поле $schema у ${CONFIG_FILE}\n`)
@@ -259,18 +264,18 @@ function managedSkillDirName(skillId) {
259
264
  * @returns {string | null} один рядок опису або null
260
265
  */
261
266
  function extractSkillDescription(text) {
262
- const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/)
267
+ const fm = text.match(YAML_FRONTMATTER_RE)
263
268
  if (!fm) {
264
269
  return null
265
270
  }
266
271
  const block = fm[1]
267
- const desc = block.match(/description:\s*>-\s*\r?\n((?:^\s+.+(?:\r?\n|$))+)/m)
272
+ const desc = block.match(SKILL_DESCRIPTION_RE)
268
273
  if (!desc) {
269
274
  return null
270
275
  }
271
276
  return desc[1]
272
- .split(/\r?\n/)
273
- .map(line => line.replace(/^\s+/, '').trimEnd())
277
+ .split(NEWLINE_RE)
278
+ .map(line => line.replace(LEADING_SPACES_RE, '').trimEnd())
274
279
  .join(' ')
275
280
  .trim()
276
281
  }
package/mdc/abie.mdc CHANGED
@@ -115,9 +115,9 @@ spec:
115
115
 
116
116
  ## k8s: overlay **ru** і **Service** (у т. ч. headless → NodePort)
117
117
 
118
- Для кожного **Service** в YAML під **`…/k8s/…`**, де шлях файлу **не** містить **`k8s/ua/`** чи **`k8s/ru/`** (маніфести base / спільного шару; у т. ч. **headless** з **`spec.clusterIP: None`** і **`-hl`**), якщо ще не **`spec.type: NodePort`** / **`LoadBalancer`** / **`ExternalName`**, у **`k8s/ru/kustomization.yaml`** того ж пакета (overlay **ru**) додай **inline** **JSON6902** у **`patches`**: **`target.kind: Service`**, **`target.name`** як у маніфеста, **`path: /spec/type`**, **`value: NodePort`**. Якщо в base було **`spec.clusterIP: None`**, у тому ж **patch** додай **`op: remove`** для **`/spec/clusterIP`** (не додавай **`remove`** на **`/spec/clusterIPs`**: у статичному YAML ключа часто немає **`kubectl kustomize`** падає з *Unable to remove nonexistent key*). Деталі — **`check-abie.mjs`**.
118
+ Для кожного **Service** в YAML під **`…/k8s/…`**, де шлях файлу **не** містить **`k8s/ua/`** чи **`k8s/ru/`** (маніфести base / спільного шару; у т. ч. **headless** з **`spec.clusterIP: None`** і **`-hl`**), якщо ще не **`spec.type: NodePort`** / **`LoadBalancer`** / **`ExternalName`**, у **`k8s/ru/kustomization.yaml`** того ж пакета (overlay **ru**) додай **inline** **JSON6902** у **`patches`**: **`target.kind: Service`**, **`target.name`** як у маніфеста, **`path: /spec/type`**, **`value: NodePort`**. Якщо в base було **`spec.clusterIP: None`**, у тому ж **patch** додай **`op: remove`** для **`/spec/clusterIP`**. Якщо в base **явно** задано **`spec.clusterIPs`** (зокрема **`['None']`**), додай **`op: remove`** і для **`/spec/clusterIPs`** — інакше **API** може відхилити **NodePort** (*`spec.clusterIPs[0]: Invalid value: "None"`*). **Не** додавай **`remove`** на **`/spec/clusterIPs`**, якщо ключа **немає** в base: **`kubectl kustomize`** тоді падає (*Unable to remove nonexistent key*). Якщо в base лише **`clusterIP: None`**, а помилка лишається після **`kubectl apply -k`** (злиття з уже існуючим **Service** у кластері), тимчасово **видали** **`Service`** у **`ru`** і застосуй знову, або додай у base поле **`clusterIPs`**, щоб **`remove`** у patch був валідний для **kustomize**. Деталі — **`check-abie.mjs`**.
119
119
 
120
- ```yaml title="…/ru/kustomization.yaml (фрагмент, headless → NodePort)"
120
+ ```yaml title="…/ru/kustomization.yaml (фрагмент, headless → NodePort, без clusterIPs у base)"
121
121
  patches:
122
122
  - target:
123
123
  kind: Service
@@ -130,6 +130,8 @@ patches:
130
130
  path: /spec/clusterIP
131
131
  ```
132
132
 
133
+ Якщо в base у цього **Service** уже є **`spec.clusterIPs`**, той самий **patch** розшир **remove** на **`/spec/clusterIPs`** (див. **`check-abie.mjs`**).
134
+
133
135
  ## k8s: overlay **ru** і HealthCheckPolicy
134
136
 
135
137
  Якщо в дереві **k8s** є **HealthCheckPolicy**, у **`ru/kustomization.yaml`** має бути patch **`$patch: delete`** для політики (узгоджено з **k8s.mdc**; перевірка в **`check-k8s.mjs`**, **`ruKustomizationHasHealthCheckDeletePatch`**). Підстав реальне ім’я замість **`СЕРВІС`**:
package/mdc/text.mdc CHANGED
@@ -104,7 +104,7 @@ version: '1.25'
104
104
  }
105
105
  ```
106
106
 
107
- Поле **`ignorePatterns`** додавай за потреби (наприклад згенеровані каталоги); якщо виключень немає ключ можна опустити.
107
+ Поле **`ignorePatterns`** обовʼязкове: у масиві мають бути **`**/hasura/metadata/**`** та **`**/schema.graphql`**; інші glob-и додавай за потреби (згенеровані каталоги тощо).
108
108
 
109
109
  Також потрібно прибрати, якщо є в проєкті, модуль **`@nitra/prettier-config`**, **prettier** та всі виклики prettier і налаштування для нього.
110
110
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.100",
3
+ "version": "1.8.103",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -34,7 +34,7 @@
34
34
  * Вибір **`op`** — **k8s.mdc**.
35
35
  *
36
36
  * **Service (overlay ru):** для кожного **Service**, оголошеного в YAML під **`…/k8s/…`**, де шлях **не** проходить через **`k8s/ua/`** чи **`k8s/ru/`** (маніфести base / спільного шару, у т. ч. **headless** з **`clusterIP: None`** і **`-hl`**), якщо ще не **NodePort** / **LoadBalancer** / **ExternalName**,
37
- * у файлі **`k8s/ru/kustomization.yaml`** того ж пакета (overlay середовища **ru**) — inline **JSON6902** на **`kind: Service`** з тим самим **`target.name`**: **`path: /spec/type`**, **`value: NodePort`**; якщо в base було **`spec.clusterIP: None`** — у тому ж patch додай **`op: remove`** для **`/spec/clusterIP`** (поле **`clusterIPs`** у статичному YAML часто відсутнє **`remove`** на **`/spec/clusterIPs`** ламає **`kubectl kustomize`**).
37
+ * у файлі **`k8s/ru/kustomization.yaml`** того ж пакета (overlay середовища **ru**) — inline **JSON6902** на **`kind: Service`** з тим самим **`target.name`**: **`path: /spec/type`**, **`value: NodePort`**; якщо в base було **`spec.clusterIP: None`** — **`op: remove`** для **`/spec/clusterIP`**; якщо в base **явно** задано **`spec.clusterIPs`** — також **`remove`** для **`/spec/clusterIPs`** (інакше **API** може залишити **`None`** для **NodePort**; без ключа **`clusterIPs`** у base **`remove`** на **`/spec/clusterIPs`** ламає **`kubectl kustomize`**).
38
38
  */
39
39
  import { existsSync } from 'node:fs'
40
40
  import { readFile } from 'node:fs/promises'
@@ -387,22 +387,43 @@ export function serviceDocumentRequiresRuClusterIPNoneRemoval(obj) {
387
387
  return sp.clusterIP === 'None'
388
388
  }
389
389
 
390
+ /**
391
+ * Чи в base-**Service** у **`spec`** явно задано поле **`clusterIPs`** (тоді **`remove`** на **`/spec/clusterIPs`** безпечний для **`kubectl kustomize`**).
392
+ * @param {unknown} obj корінь YAML (**Service**)
393
+ * @returns {boolean} **true**, якщо **`Object.hasOwn(spec, 'clusterIPs')`**
394
+ */
395
+ export function serviceDocumentBaseDeclaresClusterIPsField(obj) {
396
+ if (!isServiceDoc(obj)) {
397
+ return false
398
+ }
399
+ const rec = /** @type {Record<string, unknown>} */ (obj)
400
+ const spec = rec.spec
401
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
402
+ return false
403
+ }
404
+ const sp = /** @type {Record<string, unknown>} */ (spec)
405
+ return Object.hasOwn(sp, 'clusterIPs')
406
+ }
407
+
390
408
  /**
391
409
  * Чи **JSON6902**-текст містить **`op: remove`** для заданого **`path`** (порядок ключів **op** / **path** неважливий).
392
410
  * @param {string} patchText поле **patch** у kustomization
393
- * @param {string} posixPath наприклад **`/spec/clusterIP`**
411
+ * @param {string} posixPath **`/spec/clusterIP`** або **`/spec/clusterIPs`**
394
412
  * @returns {boolean} true, якщо знайдено пару **remove** + **path**
395
413
  */
396
414
  export function jsonPatchRemovesPath(patchText, posixPath) {
397
415
  if (typeof patchText !== 'string' || patchText.trim() === '') {
398
416
  return false
399
417
  }
400
- if (posixPath !== '/spec/clusterIP') {
418
+ if (posixPath !== '/spec/clusterIP' && posixPath !== '/spec/clusterIPs') {
401
419
  return false
402
420
  }
403
- const pathRe = String.raw`path:\s*\/spec\/clusterIP\b`
421
+ const pathRe =
422
+ posixPath === '/spec/clusterIP'
423
+ ? String.raw`path:\s*\/spec\/clusterIP\b`
424
+ : String.raw`path:\s*\/spec\/clusterIPs\b`
404
425
  const opRe = String.raw`op:\s*remove\b`
405
- return new RegExp(`${opRe}[\\s\\S]{0,200}?${pathRe}`, 'mu').test(patchText) || new RegExp(`${pathRe}[\\s\\S]{0,200}?${opRe}`, 'mu').test(patchText)
426
+ return new RegExp(String.raw`${opRe}[\s\S]{0,200}?${pathRe}`, 'mu').test(patchText) || new RegExp(String.raw`${pathRe}[\s\S]{0,200}?${opRe}`, 'mu').test(patchText)
406
427
  }
407
428
 
408
429
  /**
@@ -506,7 +527,7 @@ function collectAbieRuServicePatchTextByTargetNameFromRaw(raw) {
506
527
  /**
507
528
  * Повідомлення про порушення patch **Service** у **ru/kustomization.yaml** (abie.mdc).
508
529
  * @param {string} raw повний текст **kustomization.yaml**
509
- * @param {Map<string, { requiresClusterIPNoneClear: boolean }>} targetsByName ім’я **Service** → чи треба прибрати **None**
530
+ * @param {Map<string, { requiresClusterIPNoneClear: boolean, requiresClusterIPsRemove?: boolean }>} targetsByName ім’я **Service** → прапорці patch
510
531
  * @returns {string[]} порожньо, якщо все OK
511
532
  */
512
533
  export function getAbieRuServiceNodePortPatchErrors(raw, targetsByName) {
@@ -518,7 +539,8 @@ export function getAbieRuServiceNodePortPatchErrors(raw, targetsByName) {
518
539
  const errors = []
519
540
  for (const name of [...targetsByName.keys()].toSorted((a, b) => a.localeCompare(b))) {
520
541
  const flags = targetsByName.get(name)
521
- const requiresClear = flags?.requiresClusterIPNoneClear === true
542
+ const requiresClusterIPRemove = flags?.requiresClusterIPNoneClear === true
543
+ const requiresClusterIPsRemove = flags?.requiresClusterIPsRemove === true
522
544
  const pt = byName.get(name)
523
545
  if (pt === undefined || String(pt).trim() === '') {
524
546
  errors.push(`${name}: немає inline patch для kind: Service`)
@@ -526,11 +548,16 @@ export function getAbieRuServiceNodePortPatchErrors(raw, targetsByName) {
526
548
  if (!jsonPatchTextSetsServiceTypeNodePort(pt)) {
527
549
  errors.push(`${name}: потрібен JSON6902 path /spec/type та value NodePort`)
528
550
  }
529
- if (requiresClear && !jsonPatchTextClearsHeadlessServiceClusterIPNone(pt)) {
551
+ if (requiresClusterIPRemove && !jsonPatchTextClearsHeadlessServiceClusterIPNone(pt)) {
530
552
  errors.push(
531
553
  `${name}: для spec.clusterIP: None додай у той самий patch op: remove для path /spec/clusterIP (abie.mdc)`
532
554
  )
533
555
  }
556
+ if (requiresClusterIPsRemove && !jsonPatchRemovesPath(pt, '/spec/clusterIPs')) {
557
+ errors.push(
558
+ `${name}: у base задано spec.clusterIPs — додай op: remove для path /spec/clusterIPs (інакше NodePort з None у clusterIPs; abie.mdc)`
559
+ )
560
+ }
534
561
  }
535
562
  }
536
563
  return errors
@@ -541,10 +568,10 @@ export function getAbieRuServiceNodePortPatchErrors(raw, targetsByName) {
541
568
  * @param {string} root корінь репозиторію
542
569
  * @param {string[]} yamlAbs абсолютні шляхи yaml під **k8s**
543
570
  * @param {(msg: string) => void} fail реєстрація помилки читання/парсингу
544
- * @returns {Promise<Map<string, Map<string, { requiresClusterIPNoneClear: boolean }>>>} **pkgAbs** → (**ім’я** → прапорці)
571
+ * @returns {Promise<Map<string, Map<string, { requiresClusterIPNoneClear: boolean, requiresClusterIPsRemove: boolean }>>>} **pkgAbs** → (**ім’я** → прапорці)
545
572
  */
546
573
  async function collectAbieRuNodePortServiceTargetsByPackage(root, yamlAbs, fail) {
547
- /** @type {Map<string, Map<string, { requiresClusterIPNoneClear: boolean }>>} */
574
+ /** @type {Map<string, Map<string, { requiresClusterIPNoneClear: boolean, requiresClusterIPsRemove: boolean }>>} */
548
575
  const map = new Map()
549
576
  for (const abs of yamlAbs) {
550
577
  const rel = relative(root, abs).replaceAll('\\', '/') || abs
@@ -590,9 +617,11 @@ async function collectAbieRuNodePortServiceTargetsByPackage(root, yamlAbs, fail)
590
617
  map.set(pkgAbs, inner)
591
618
  }
592
619
  const needClear = serviceDocumentRequiresRuClusterIPNoneRemoval(obj)
620
+ const needClusterIPsRemove = serviceDocumentBaseDeclaresClusterIPsField(obj)
593
621
  const prev = inner.get(n)
594
622
  inner.set(n, {
595
- requiresClusterIPNoneClear: (prev?.requiresClusterIPNoneClear === true) || needClear
623
+ requiresClusterIPNoneClear: (prev?.requiresClusterIPNoneClear === true) || needClear,
624
+ requiresClusterIPsRemove: (prev?.requiresClusterIPsRemove === true) || needClusterIPsRemove
596
625
  })
597
626
  }
598
627
  }
@@ -607,7 +636,7 @@ async function collectAbieRuNodePortServiceTargetsByPackage(root, yamlAbs, fail)
607
636
  }
608
637
 
609
638
  /**
610
- * У **`k8s/ru/kustomization.yaml`** для кожного **Service** з YAML **`k8s`**, шлях якого без сегментів **`k8s/ua/`** та **`k8s/ru/`** (у т. ч. **headless** / **`-hl`**) — **JSON6902** **`/spec/type` → NodePort**; якщо в base було **`clusterIP: None`** — також **`op: remove`** на **`/spec/clusterIP`** (abie.mdc).
639
+ * У **`k8s/ru/kustomization.yaml`** для кожного **Service** з YAML **`k8s`**, шлях якого без сегментів **`k8s/ua/`** та **`k8s/ru/`** (у т. ч. **headless** / **`-hl`**) — **JSON6902** **`/spec/type` → NodePort**; при **`clusterIP: None`** — **`op: remove`** на **`/spec/clusterIP`**; якщо в base є **`spec.clusterIPs`** — ще **`remove`** на **`/spec/clusterIPs`** (abie.mdc).
611
640
  * @param {string} root корінь
612
641
  * @param {string[]} yamlFilesAbs yaml під **k8s**
613
642
  * @param {(msg: string) => void} fail callback
@@ -19,6 +19,8 @@ import { readFile } from 'node:fs/promises'
19
19
 
20
20
  import { createCheckReporter } from './utils/check-reporter.mjs'
21
21
 
22
+ const OXFMT_END_RE = /&&[ \t]+oxfmt[ \t]+\.[ \t]*$/
23
+
22
24
  /**
23
25
  * Чи ім'я пакета дозволене в кореневих `devDependencies` за bun.mdc (лише **`@nitra/*`**).
24
26
  * @param {string} name ключ з поля `devDependencies`
@@ -142,20 +144,22 @@ export async function check() {
142
144
  if (aggregate.trim()) {
143
145
  const missing = lintPrefixed.filter(name => !aggregate.includes(`bun run ${name}`))
144
146
  if (missing.length > 0) {
147
+ const missingList = missing.map(s => `\`${s}\``).join(', ')
145
148
  fail(
146
- `Скрипт \`lint\` має викликати всі lint-* через bun run; відсутньо: ${missing.map(s => `\`${s}\``).join(', ')}`
149
+ `Скрипт \`lint\` має викликати всі lint-* через bun run; відсутньо: ${missingList}`
147
150
  )
148
151
  } else {
149
152
  pass('package.json: агрегований `lint` покриває всі `lint-*` скрипти')
150
- if (/\s*&&\s+oxfmt\s+\.\s*$/.test(aggregate.trim())) {
153
+ if (OXFMT_END_RE.test(aggregate.trim())) {
151
154
  pass('package.json: `lint` завершується `&& oxfmt .`')
152
155
  } else {
153
156
  fail('Скрипт `lint` має закінчуватися на `&& oxfmt .`')
154
157
  }
155
158
  }
156
159
  } else {
160
+ const scriptList = lintPrefixed.map(s => `\`${s}\``).join(', ')
157
161
  fail(
158
- `У package.json є скрипти ${lintPrefixed.map(s => `\`${s}\``).join(', ')}, але немає агрегованого \`lint\` — додай скрипт, який запускає їх через \`bun run\``
162
+ `У package.json є скрипти ${scriptList}, але немає агрегованого \`lint\` — додай скрипт, який запускає їх через \`bun run\``
159
163
  )
160
164
  }
161
165
  }
@@ -62,7 +62,8 @@ export async function check() {
62
62
  if (ok) {
63
63
  pass(`${rel} (${via})`)
64
64
  } else {
65
- fail(`${rel} (${via})${tail ? `:\n${tail}` : ''}`)
65
+ const detail = tail ? `:\n${tail}` : ''
66
+ fail(`${rel} (${via})${detail}`)
66
67
  }
67
68
  }
68
69
 
@@ -24,7 +24,7 @@ export const GRAPHQL_RC_FILENAME = '.graphqlrc.yml'
24
24
  export const REQUIRED_GRAPHQL_VSCODE_EXTENSION = 'graphql.vscode-graphql'
25
25
 
26
26
  /**
27
- * Перевіряє graphql.mdc: умовна вимога `.graphqlrc.yml` і `graphql.vscode-graphql` за наявності `gql`…``.
27
+ * Перевіряє graphql.mdc: умовна вимога .graphqlrc.yml і graphql.vscode-graphql за наявності gql tagged templates.
28
28
  * @returns {Promise<number>} 0 — OK, 1 — порушення
29
29
  */
30
30
  export async function check() {
@@ -59,19 +59,15 @@ export async function check() {
59
59
 
60
60
  pass(`Знайдено gql\`…\` у ${hits.length} файлі(ах): ${hits.slice(0, 5).join(', ')}${hits.length > 5 ? '…' : ''}`)
61
61
 
62
- if (!existsSync(GRAPHQL_RC_FILENAME)) {
62
+ if (existsSync(GRAPHQL_RC_FILENAME)) {
63
+ pass(`${GRAPHQL_RC_FILENAME} існує`)
64
+ } else {
63
65
  fail(
64
66
  `Відсутній ${GRAPHQL_RC_FILENAME} у корені — додай GraphQL Config (graphql.mdc), бо в проєкті є gql template literals`
65
67
  )
66
- } else {
67
- pass(`${GRAPHQL_RC_FILENAME} існує`)
68
68
  }
69
69
 
70
- if (!existsSync('.vscode/extensions.json')) {
71
- fail(
72
- '.vscode/extensions.json не існує — створи файл і додай у recommendations graphql.vscode-graphql (graphql.mdc)'
73
- )
74
- } else {
70
+ if (existsSync('.vscode/extensions.json')) {
75
71
  let ext
76
72
  try {
77
73
  ext = JSON.parse(await readFile('.vscode/extensions.json', 'utf8'))
@@ -83,14 +79,18 @@ export async function check() {
83
79
  const rec = ext.recommendations
84
80
  if (!Array.isArray(rec)) {
85
81
  fail('.vscode/extensions.json: поле recommendations має бути масивом')
86
- } else if (!rec.includes(REQUIRED_GRAPHQL_VSCODE_EXTENSION)) {
82
+ } else if (rec.includes(REQUIRED_GRAPHQL_VSCODE_EXTENSION)) {
83
+ pass(`.vscode/extensions.json: є ${REQUIRED_GRAPHQL_VSCODE_EXTENSION}`)
84
+ } else {
87
85
  fail(
88
86
  `.vscode/extensions.json: додай у recommendations "${REQUIRED_GRAPHQL_VSCODE_EXTENSION}" (graphql.mdc)`
89
87
  )
90
- } else {
91
- pass(`.vscode/extensions.json: є ${REQUIRED_GRAPHQL_VSCODE_EXTENSION}`)
92
88
  }
93
89
  }
90
+ } else {
91
+ fail(
92
+ '.vscode/extensions.json не існує — створи файл і додай у recommendations graphql.vscode-graphql (graphql.mdc)'
93
+ )
94
94
  }
95
95
 
96
96
  return reporter.getExitCode()
@@ -39,7 +39,6 @@ export function isCanonicalLintJs(script) {
39
39
 
40
40
  /**
41
41
  * Чи діапазон `@nitra/eslint-config` у `package.json` передбачає версію з транзитивним `@e18e/eslint-plugin` (>=3.5.0).
42
- *
43
42
  * @param {unknown} versionSpec значення `devDependencies['@nitra/eslint-config']`
44
43
  * @returns {boolean} true для `workspace:*` або першої semver у рядку >= 3.5.0
45
44
  */
@@ -60,7 +59,6 @@ export function nitraEslintConfigDeclaresE18eTransitive(versionSpec) {
60
59
 
61
60
  /**
62
61
  * Перевіряє потрібні поля `.oxlintrc.json` для розширення e18e (js-lint.mdc).
63
- *
64
62
  * @param {unknown} cfg корінь JSON-конфігурації oxlint
65
63
  * @returns {{ ok: boolean, failures: string[] }} `ok` і перелік повідомлень для `fail`
66
64
  */
@@ -511,8 +511,8 @@ async function findK8sYamlFiles(root) {
511
511
  if (!/\.ya?ml$/iu.test(p)) return
512
512
  out.push(p)
513
513
  })
514
- // eslint-disable-next-line unicorn/no-array-sort -- toSorted потребує lib ES2023 у перевірці типів IDE
515
- return [...out].sort((a, b) => a.localeCompare(b))
514
+
515
+ return out.toSorted((a, b) => a.localeCompare(b))
516
516
  }
517
517
 
518
518
  /**
@@ -16,6 +16,8 @@ import { readFile, stat } from 'node:fs/promises'
16
16
  import { join } from 'node:path'
17
17
 
18
18
  import { createCheckReporter } from './utils/check-reporter.mjs'
19
+
20
+ const TYPES_FILE_RE = /^\.\/types\/.+\.d\.(ts|mts)$/
19
21
  import {
20
22
  hasIdTokenWritePermission,
21
23
  hasNpmPublishStepWithPackage,
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Перевіряє текстовий стек і форматування за правилом text.mdc.
3
3
  *
4
- * oxfmt: `.oxfmtrc.json` з обовʼязковими ключами, VSCode (formatOnSave, defaultFormatter для js/ts/json/vue/css/html),
4
+ * oxfmt: `.oxfmtrc.json` з обовʼязковими ключами та масивом ignorePatterns (два канонічні glob-и з text.mdc для hasura metadata і schema.graphql),
5
+ * VSCode (formatOnSave, defaultFormatter для js/ts/json/vue/css/html),
5
6
  * відсутність Prettier у конфігах і залежностях.
6
7
  *
7
8
  * cspell, markdownlint через `bunx markdownlint-cli2` у `lint-text` (без оголошення пакета в package.json); у кореневих **`devDependencies`**
@@ -20,18 +21,25 @@ import { isAllowedRootDevDependency } from './check-bun.mjs'
20
21
  import { createCheckReporter } from './utils/check-reporter.mjs'
21
22
  import { anyRunStepIncludes, parseWorkflowYaml } from './utils/gha-workflow.mjs'
22
23
 
24
+ const WORKSPACE_STAR_RE = /^workspace:\*/
25
+ const VERSION_PREFIX_RE = /^[\^~>=<]+\s*/
26
+ const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)/
27
+
23
28
  /** Заголовок абзацу про апостроф у text.mdc / n-text.mdc. */
24
29
  const UK_APOSTROPHE_HEADING = '**Український апостроф:**'
25
30
 
31
+ /** Мінімальні glob-и в `ignorePatterns` у `.oxfmtrc.json` (text.mdc). */
32
+ const OXFMT_REQUIRED_IGNORE_PATTERNS = ['**/hasura/metadata/**', '**/schema.graphql']
33
+
26
34
  /**
27
- * Чи діапазон версії @nitra/cspell-dict у package.json означає лінію 2.0.0+ (з цієї версії словники входять у пакет).
35
+ * Чи діапазон версії `@nitra/cspell-dict` у package.json означає лінію 2.0.0+ (з цієї версії словники входять у пакет).
28
36
  * @param {string|undefined} range наприклад "^2.0.0"
29
- * @returns {boolean}
37
+ * @returns {boolean} true якщо мажорна версія >= 2
30
38
  */
31
39
  function cspellDictVersionAtLeast200(range) {
32
40
  if (typeof range !== 'string' || !range.trim()) return false
33
- const cleaned = range.trim().replace(/^workspace:\*/, '').replace(/^[\^~>=<]+\s*/, '')
34
- const m = cleaned.match(/^(\d+)\.(\d+)\.(\d+)/)
41
+ const cleaned = range.trim().replace(WORKSPACE_STAR_RE, '').replace(VERSION_PREFIX_RE, '')
42
+ const m = cleaned.match(SEMVER_RE)
35
43
  if (!m) return false
36
44
  const major = Number(m[1])
37
45
  return major >= 2
@@ -158,6 +166,22 @@ export async function check() {
158
166
  if (cfg.tabWidth !== 2) fail('.oxfmtrc.json: tabWidth має бути 2')
159
167
  if (cfg.useTabs !== false) fail('.oxfmtrc.json: useTabs має бути false')
160
168
  if (cfg.printWidth !== 120) fail('.oxfmtrc.json: printWidth має бути 120')
169
+
170
+ if (Array.isArray(cfg.ignorePatterns)) {
171
+ const set = new Set(cfg.ignorePatterns)
172
+ const missing = OXFMT_REQUIRED_IGNORE_PATTERNS.filter(p => !set.has(p))
173
+ if (missing.length === 0) {
174
+ pass('.oxfmtrc.json: ignorePatterns містить hasura/metadata та schema.graphql')
175
+ } else {
176
+ fail(
177
+ `.oxfmtrc.json ignorePatterns: додай відсутні елементи: ${missing.join(', ')} (канонічний приклад у text.mdc)`
178
+ )
179
+ }
180
+ } else {
181
+ fail(
182
+ `.oxfmtrc.json: додай масив ignorePatterns з ${OXFMT_REQUIRED_IGNORE_PATTERNS.join(', ')} (див. text.mdc)`
183
+ )
184
+ }
161
185
  } else {
162
186
  fail('.oxfmtrc.json не існує — створи його')
163
187
  }
@@ -245,10 +269,10 @@ export async function check() {
245
269
  const cspellRange = devDeps['@nitra/cspell-dict']
246
270
  if (!cspellRange) {
247
271
  fail('@nitra/cspell-dict у devDependencies обовʼязковий для cspell — bun add -d @nitra/cspell-dict@^2.0.0')
248
- } else if (!cspellDictVersionAtLeast200(cspellRange)) {
249
- fail('@nitra/cspell-dict має бути ^2.0.0 або новіший (словники зібрані в пакеті з 2.x)')
250
- } else {
272
+ } else if (cspellDictVersionAtLeast200(cspellRange)) {
251
273
  pass('@nitra/cspell-dict ^2.0.0+')
274
+ } else {
275
+ fail('@nitra/cspell-dict має бути ^2.0.0 або новіший (словники зібрані в пакеті з 2.x)')
252
276
  }
253
277
 
254
278
  const rootDeps = pkg.dependencies || {}
@@ -12,6 +12,8 @@ import { readFile } from 'node:fs/promises'
12
12
  import { join, relative } from 'node:path'
13
13
 
14
14
  import { createCheckReporter } from './utils/check-reporter.mjs'
15
+
16
+ const MAJOR_VERSION_RE = /(\d+)/
15
17
  import {
16
18
  findForbiddenVueImportsInSourceFile,
17
19
  isVueImportScanSourceFile,
@@ -73,7 +75,7 @@ async function checkVuePackage(rootDir, fail, passFn) {
73
75
  }
74
76
 
75
77
  if (devDeps.vite) {
76
- const match = devDeps.vite.match(/(\d+)/)
78
+ const match = devDeps.vite.match(MAJOR_VERSION_RE)
77
79
  if (match && Number(match[1]) >= 8) {
78
80
  passFn(`${prefix}vite >= 8: ${devDeps.vite}`)
79
81
  } else {
@@ -16,6 +16,10 @@ import { relative, resolve } from 'node:path'
16
16
 
17
17
  import { walkDir } from './utils/walkDir.mjs'
18
18
 
19
+ const K8S_YML_RE = /\.yml$/iu
20
+ const GITHUB_YAML_RE = /\.yaml$/iu
21
+ const EXTENSION_RE = /^(.+)(\.[^./\\]+)$/u
22
+
19
23
  /**
20
24
  * Відносний шлях від кореня з `/`; `null`, якщо поза root.
21
25
  * @param {string} rootAbs абсолютний корінь
@@ -34,7 +38,7 @@ export function posixRelFromRoot(rootAbs, fileAbs) {
34
38
  * @returns {boolean} true, якщо є сегмент k8s і суфікс .yml
35
39
  */
36
40
  export function pathMatchesK8sYml(relPosix) {
37
- if (!/\.yml$/iu.test(relPosix)) return false
41
+ if (!K8S_YML_RE.test(relPosix)) return false
38
42
  return relPosix.split('/').includes('k8s')
39
43
  }
40
44
 
@@ -44,7 +48,7 @@ export function pathMatchesK8sYml(relPosix) {
44
48
  * @returns {boolean} true, якщо є сегмент .github і суфікс .yaml
45
49
  */
46
50
  export function pathMatchesGithubYaml(relPosix) {
47
- if (!/\.yaml$/iu.test(relPosix)) return false
51
+ if (!GITHUB_YAML_RE.test(relPosix)) return false
48
52
  return relPosix.split('/').includes('.github')
49
53
  }
50
54
 
@@ -55,7 +59,7 @@ export function pathMatchesGithubYaml(relPosix) {
55
59
  * @returns {string} шлях з останнім розширенням, заміненим на newExt
56
60
  */
57
61
  export function replaceExtension(relPosix, newExt) {
58
- const m = relPosix.match(/^(.+)(\.[^./\\]+)$/u)
62
+ const m = relPosix.match(EXTENSION_RE)
59
63
  if (!m) return relPosix + newExt
60
64
  return m[1] + newExt
61
65
  }
@@ -64,7 +64,8 @@ async function main() {
64
64
  if (ok) {
65
65
  pass(`${rel} (${via})`)
66
66
  } else {
67
- fail(`${rel} (${via})${tail ? `:\n${tail}` : ''}`)
67
+ const detail = tail ? `:\n${tail}` : ''
68
+ fail(`${rel} (${via})${detail}`)
68
69
  }
69
70
  }
70
71
 
@@ -16,8 +16,12 @@ import { spawnSync } from 'node:child_process'
16
16
  import { basename, dirname } from 'node:path'
17
17
 
18
18
  import { isRunAsCli } from './cli-entry.mjs'
19
+ import { resolveCmd } from './utils/resolve-cmd.mjs'
19
20
  import { walkDir } from './utils/walkDir.mjs'
20
21
 
22
+ const PATH_SEPARATOR_RE = /[/\\]/u
23
+ const YAML_EXT_RE = /\.yaml$/iu
24
+
21
25
  /** Версія Kubernetes для kubeconform — синхронно з YANNH_PIN (без префікса v і суфікса -standalone-strict). */
22
26
  const KUBERNETES_VERSION = '1.33.9'
23
27
 
@@ -31,7 +35,7 @@ const DATREE_CRD_SCHEMA_LOCATION =
31
35
  * @returns {boolean} true, якщо серед компонентів шляху є каталог `k8s`
32
36
  */
33
37
  export function pathHasK8sSegment(filePath) {
34
- const parts = filePath.split(/[/\\]/u)
38
+ const parts = filePath.split(PATH_SEPARATOR_RE)
35
39
  return parts.includes('k8s')
36
40
  }
37
41
 
@@ -61,7 +65,7 @@ export async function findK8sRoots(root) {
61
65
  const roots = new Set()
62
66
  await walkDir(root, p => {
63
67
  if (!pathHasK8sSegment(p)) return
64
- if (!/\.yaml$/iu.test(p)) return
68
+ if (!YAML_EXT_RE.test(p)) return
65
69
  const k8sRoot = k8sRootFromFile(p)
66
70
  if (k8sRoot) roots.add(k8sRoot)
67
71
  })
@@ -85,7 +89,12 @@ function runKubeconform(dirs) {
85
89
  '-ignore-missing-schemas',
86
90
  ...dirs
87
91
  ]
88
- const r = spawnSync('kubeconform', args, { stdio: 'inherit', shell: false })
92
+ const kubeconformPath = resolveCmd('kubeconform')
93
+ if (!kubeconformPath) {
94
+ console.error('kubeconform не знайдено в PATH. Встанови з https://github.com/yannh/kubeconform#readme')
95
+ return 127
96
+ }
97
+ const r = spawnSync(kubeconformPath, args, { stdio: 'inherit', shell: false })
89
98
  if (r.error && 'code' in r.error && r.error.code === 'ENOENT') {
90
99
  console.error('kubeconform не знайдено в PATH. Встанови з https://github.com/yannh/kubeconform#readme')
91
100
  return 127
@@ -101,7 +110,12 @@ function runKubeconform(dirs) {
101
110
  */
102
111
  function runKubescape(dirs) {
103
112
  for (const d of dirs) {
104
- const r = spawnSync('kubescape', ['scan', d, '--severity-threshold', 'high'], {
113
+ const kubescapePath = resolveCmd('kubescape')
114
+ if (!kubescapePath) {
115
+ console.error('kubescape не знайдено в PATH. Встанови з https://github.com/kubescape/kubescape#readme')
116
+ return 127
117
+ }
118
+ const r = spawnSync(kubescapePath, ['scan', d, '--severity-threshold', 'high'], {
105
119
  stdio: 'inherit',
106
120
  shell: false
107
121
  })
@@ -19,6 +19,7 @@ import { dirname, join } from 'node:path'
19
19
  import { fileURLToPath } from 'node:url'
20
20
 
21
21
  import { isRunAsCli } from './cli-entry.mjs'
22
+ import { resolveCmd } from './utils/resolve-cmd.mjs'
22
23
 
23
24
  /** Типові glob-и для форматів, які обробляє v8r (див. опис CLI v8r). */
24
25
  export const DEFAULT_V8R_GLOBS = ['**/*.json', '**/*.json5', '**/*.yml', '**/*.yaml', '**/*.toml']
@@ -48,7 +49,8 @@ export function runV8rWithGlobs(globs = DEFAULT_V8R_GLOBS) {
48
49
  }
49
50
 
50
51
  for (const pattern of globs) {
51
- const result = spawnSync('bun', ['x', 'v8r', pattern, '-c', V8R_CATALOG_PATH], {
52
+ const bunPath = resolveCmd('bun') ?? process.execPath
53
+ const result = spawnSync(bunPath, ['x', 'v8r', pattern, '-c', V8R_CATALOG_PATH], {
52
54
  encoding: 'utf8',
53
55
  maxBuffer: 50 * 1024 * 1024,
54
56
  shell: false,
@@ -20,6 +20,14 @@ const NPM_LATEST_URL = 'https://registry.npmjs.org/@nitra/cursor/latest'
20
20
 
21
21
  const execFileAsync = promisify(execFile)
22
22
 
23
+ const WORKSPACE_RE = /^workspace:/i
24
+ const FILE_RE = /^file:/i
25
+ const LINK_RE = /^link:/i
26
+ const PORTAL_RE = /^portal:/i
27
+ const GIT_RE = /^git(\+|:\/\/)/i
28
+ const NPM_PROTO_RE = /^npm:/i
29
+ const HTTP_RE = /^https?:\/\//i
30
+
23
31
  /**
24
32
  * Чи не можна безпечно підставити semver з npm замість поточного специфікатора залежності.
25
33
  * @param {string} specifier значення з package.json
@@ -30,25 +38,25 @@ export function shouldSkipNpmVersionUpgrade(specifier) {
30
38
  if (!s) {
31
39
  return true
32
40
  }
33
- if (/^workspace:/i.test(s)) {
41
+ if (WORKSPACE_RE.test(s)) {
34
42
  return true
35
43
  }
36
- if (/^file:/i.test(s)) {
44
+ if (FILE_RE.test(s)) {
37
45
  return true
38
46
  }
39
- if (/^link:/i.test(s)) {
47
+ if (LINK_RE.test(s)) {
40
48
  return true
41
49
  }
42
- if (/^portal:/i.test(s)) {
50
+ if (PORTAL_RE.test(s)) {
43
51
  return true
44
52
  }
45
- if (/^git(\+|:\/\/)/i.test(s)) {
53
+ if (GIT_RE.test(s)) {
46
54
  return true
47
55
  }
48
- if (/^npm:/i.test(s)) {
56
+ if (NPM_PROTO_RE.test(s)) {
49
57
  return true
50
58
  }
51
- if (/^https?:\/\//i.test(s)) {
59
+ if (HTTP_RE.test(s)) {
52
60
  return true
53
61
  }
54
62
  if (s.startsWith('./') || s.startsWith('../')) {
@@ -8,6 +8,8 @@
8
8
  import { spawnSync } from 'node:child_process'
9
9
  import { relative, sep } from 'node:path'
10
10
 
11
+ import { resolveCmd } from './resolve-cmd.mjs'
12
+
11
13
  /** Тег образу для резервного запуску (узгоджуй з docker.mdc). */
12
14
  export const HADOLINT_IMAGE = 'hadolint/hadolint:v2.12.0'
13
15
 
@@ -29,12 +31,13 @@ export function posixRel(root, absPath) {
29
31
  */
30
32
  export function lintDockerfileWithHadolint(root, absPath) {
31
33
  const rel = posixRel(root, absPath)
32
- const local = spawnSync('hadolint', [rel], {
33
- cwd: root,
34
- encoding: 'utf8',
35
- maxBuffer: 10 * 1024 * 1024
36
- })
37
- if (!local.error) {
34
+ const hadolintPath = resolveCmd('hadolint')
35
+ if (hadolintPath) {
36
+ const local = spawnSync(hadolintPath, [rel], {
37
+ cwd: root,
38
+ encoding: 'utf8',
39
+ maxBuffer: 10 * 1024 * 1024
40
+ })
38
41
  const ok = local.status === 0
39
42
  return {
40
43
  ok,
@@ -43,16 +46,20 @@ export function lintDockerfileWithHadolint(root, absPath) {
43
46
  via: 'hadolint'
44
47
  }
45
48
  }
46
- if (local.error.code !== 'ENOENT') {
49
+
50
+ const dockerPath = resolveCmd('docker')
51
+ if (!dockerPath) {
47
52
  return {
48
53
  ok: false,
49
54
  stdout: '',
50
- stderr: local.error.message,
51
- via: 'hadolint'
55
+ stderr:
56
+ 'Не знайдено hadolint у PATH і не знайдено docker у PATH. ' +
57
+ 'Встанови hadolint (наприклад brew install hadolint) або Docker (див. docker.mdc).',
58
+ via: 'docker'
52
59
  }
53
60
  }
54
61
 
55
- const docker = spawnSync('docker', ['run', '--rm', '-v', `${root}:/workdir`, '-w', '/workdir', HADOLINT_IMAGE, rel], {
62
+ const docker = spawnSync(dockerPath, ['run', '--rm', '-v', `${root}:/workdir`, '-w', '/workdir', HADOLINT_IMAGE, rel], {
56
63
  cwd: root,
57
64
  encoding: 'utf8',
58
65
  maxBuffer: 10 * 1024 * 1024
@@ -12,10 +12,12 @@ import {
12
12
  shouldSkipFileForVueImportScan
13
13
  } from './vue-forbidden-imports.mjs'
14
14
 
15
+ const VUE_EXTENSION_RE = /\.vue$/u
16
+
15
17
  /**
16
18
  * Мова для Oxc за шляхом файлу (розширення).
17
19
  * @param {string} filePath віртуальний або реальний шлях
18
- * @returns {'js' | 'jsx' | 'ts' | 'tsx'}
20
+ * @returns {'js' | 'jsx' | 'ts' | 'tsx'} мова для Oxc парсера
19
21
  */
20
22
  function langFromPath(filePath) {
21
23
  const lower = filePath.toLowerCase()
@@ -34,11 +36,11 @@ function langFromPath(filePath) {
34
36
  /**
35
37
  * Віртуальний шлях для парсера: SFC розбираємо як TypeScript.
36
38
  * @param {string} relativePath відносний шлях до файлу
37
- * @returns {string}
39
+ * @returns {string} шлях із заміненим розширенням для SFC
38
40
  */
39
41
  function virtualPathForParse(relativePath) {
40
42
  if (relativePath.endsWith('.vue')) {
41
- return relativePath.replace(/\.vue$/u, '.ts')
43
+ return relativePath.replace(VUE_EXTENSION_RE, '.ts')
42
44
  }
43
45
  return relativePath
44
46
  }
@@ -46,7 +48,7 @@ function virtualPathForParse(relativePath) {
46
48
  /**
47
49
  * Чи містить AST хоча б один `gql` tagged template.
48
50
  * @param {unknown} node корінь або вузол AST
49
- * @returns {boolean}
51
+ * @returns {boolean} true якщо знайдено тег gql
50
52
  */
51
53
  function astContainsGqlTag(node) {
52
54
  if (node === null || node === undefined) {
@@ -56,7 +58,7 @@ function astContainsGqlTag(node) {
56
58
  return false
57
59
  }
58
60
  if (Array.isArray(node)) {
59
- return node.some(astContainsGqlTag)
61
+ return node.some(n => astContainsGqlTag(n))
60
62
  }
61
63
  if (node.type === 'TaggedTemplateExpression') {
62
64
  const tag = node.tag
@@ -65,10 +67,7 @@ function astContainsGqlTag(node) {
65
67
  }
66
68
  }
67
69
  for (const key of Object.keys(node)) {
68
- if (key === 'loc' || key === 'range') {
69
- continue
70
- }
71
- if (astContainsGqlTag(node[key])) {
70
+ if (key !== 'loc' && key !== 'range' && astContainsGqlTag(node[key])) {
72
71
  return true
73
72
  }
74
73
  }
@@ -99,7 +98,7 @@ export function sourceFileHasGqlTaggedTemplate(content, relativePath) {
99
98
  /**
100
99
  * Чи підлягає файл скануванню за розширенням (узгоджено з vue-import scan).
101
100
  * @param {string} relativePath відносний шлях
102
- * @returns {boolean}
101
+ * @returns {boolean} true якщо файл підлягає скануванню
103
102
  */
104
103
  export function isGqlScanSourceFile(relativePath) {
105
104
  return isVueImportScanSourceFile(relativePath)
@@ -108,7 +107,7 @@ export function isGqlScanSourceFile(relativePath) {
108
107
  /**
109
108
  * Чи пропустити файл (декларації, auto-imports) — ті самі критерії, що для vue-import scan.
110
109
  * @param {string} relativePosix шлях з posix-слешами
111
- * @returns {boolean}
110
+ * @returns {boolean} true якщо файл потрібно пропустити
112
111
  */
113
112
  export function shouldSkipFileForGqlScan(relativePosix) {
114
113
  return shouldSkipFileForVueImportScan(relativePosix)
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Утиліта для розв'язання абсолютного шляху до команди в PATH.
3
+ *
4
+ * Використовується для виклику зовнішніх інструментів через абсолютний шлях
5
+ * замість команди з PATH (sonarjs/no-os-command-from-path).
6
+ */
7
+ import { spawnSync } from 'node:child_process'
8
+ import { platform } from 'node:process'
9
+
10
+ /**
11
+ * Повертає абсолютний шлях до команди в PATH або null, якщо команда не знайдена.
12
+ * @param {string} cmd ім'я команди без шляху
13
+ * @returns {string | null} абсолютний шлях або null
14
+ */
15
+ export function resolveCmd(cmd) {
16
+ const whichCmd = platform === 'win32' ? 'where' : 'which'
17
+ const result = spawnSync(whichCmd, [cmd], { encoding: 'utf8' })
18
+ if (result.status !== 0 || result.error) {
19
+ return null
20
+ }
21
+ const line = result.stdout.trim().split('\n')[0].trim()
22
+ return line || null
23
+ }
@@ -10,6 +10,9 @@
10
10
  */
11
11
  import { parseSync } from 'oxc-parser'
12
12
 
13
+ const VUE_EXT_RE = /\.vue$/u
14
+ const SOURCE_FILE_RE = /\.(vue|[cm]?[jt]sx?)$/
15
+
13
16
  /**
14
17
  * Мова для Oxc за шляхом файлу (розширення).
15
18
  * @param {string} filePath віртуальний або реальний шлях до файлу
@@ -74,7 +77,7 @@ function isAllowedVueStaticImport(imp) {
74
77
  */
75
78
  function virtualPathForParse(relativePath) {
76
79
  if (relativePath.endsWith('.vue')) {
77
- return relativePath.replace(/\.vue$/u, '.ts')
80
+ return relativePath.replace(VUE_EXT_RE, '.ts')
78
81
  }
79
82
  return relativePath
80
83
  }
@@ -162,7 +165,7 @@ export function shouldSkipFileForVueImportScan(relativePosix) {
162
165
  * @returns {boolean} `true`, якщо розширення підходить для пошуку import
163
166
  */
164
167
  export function isVueImportScanSourceFile(relativePath) {
165
- return /\.(vue|[cm]?[jt]sx?)$/.test(relativePath)
168
+ return SOURCE_FILE_RE.test(relativePath)
166
169
  }
167
170
 
168
171
  /**
@@ -8,6 +8,8 @@ import { existsSync } from 'node:fs'
8
8
  import { glob, readFile } from 'node:fs/promises'
9
9
  import { dirname, join, relative } from 'node:path'
10
10
 
11
+ const TRAILING_SLASH_RE = /\/$/
12
+
11
13
  /**
12
14
  * Нормалізує поле `workspaces` з package.json до масиву шляхів / glob-патернів.
13
15
  * @param {unknown} workspaces значення `workspaces` з кореневого package.json
@@ -35,7 +37,11 @@ export async function getMonorepoPackageRootDirs(repoRoot = '.') {
35
37
  }
36
38
  const pkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
37
39
  for (const raw of normalizeWorkspacePatterns(pkg.workspaces)) {
38
- const w = raw.replaceAll('\\', '/').replace(/\/+$/, '') || '.'
40
+ let w = raw.replaceAll('\\', '/')
41
+ while (TRAILING_SLASH_RE.test(w)) {
42
+ w = w.slice(0, -1)
43
+ }
44
+ w = w || '.'
39
45
  if (w.includes('*')) {
40
46
  const globPat = `${w}/package.json`
41
47
  for await (const f of glob(globPat, { cwd: repoRoot })) {
@@ -25,7 +25,7 @@ bun run lint
25
25
 
26
26
  2. **Якщо exit code не 0** — проаналізуй вивід (останній упавший крок у ланцюжку **`lint`** часто видно з stderr / логів):
27
27
  - Де скрипт уже робить **auto-fix** (**`--fix`**, **`markdownlint-cli2 --fix`**, **`oxfmt`** тощо) — перезапусти **`bun run lint`** після змін файлів.
28
- - Де auto-fix **немає** (наприклад, **jscpd**, **cspell**, **zizmor**, перевірки без прапорця fix) — виправ код, конфіги або винятки **узгоджено з** `.cursor/rules/` (не розширюй ignore лише щоб приховати проблему без причини).
28
+ - Де auto-fix **немає** (наприклад, **jscpd**, **cspell**, **zizmor**, перевірки без прапорця fix) — **рефактори код проєкту**, щоб усунути порушення: перейменуй ідентифікатори, перепиши логіку, видали дублікати тощо. **Заборонено** додавати `eslint-disable`, `// @ts-ignore`, розширювати `ignores`, **`.cspellignore`**, **`.jscpdignore`** або інші винятки **лише** щоб приховати порушення без обґрунтованої причини — політика узгоджена з **`.cursor/rules/`** (зокрема **n-js-lint** для **jscpd**, **n-text** для **cspell** тощо).
29
29
  - Якщо спрацьовує **`sonarjs/cognitive-complexity`** — див. окремий блок нижче.
30
30
 
31
31
  3. **Цикл** — повторюй кроки 1–2, доки **`bun run lint`** не завершиться успішно. Після суттєвих правок за потреби ще раз **`bun run lint`**, щоб переконатися, що не зламав наступний крок у скрипті **`lint`**.